From c7a1a3afb45e8c4a3766560ff979a83a31086e85 Mon Sep 17 00:00:00 2001 From: louis Date: Sun, 23 Oct 2022 19:56:36 +0200 Subject: [PATCH 01/31] =?UTF-8?q?=F0=9F=92=84=20Use=20MUI=20in=20LoginView?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .eslintrc.json | 2 +- package.json | 8 + src/App.tsx | 2 - src/declaration.d.ts | 1 + src/index.tsx | 4 + src/views/LoginView.test.tsx | 83 ++++++++ src/views/LoginView.tsx | 131 ++++++------ yarn.lock | 387 ++++++++++++++++++++++++++++++++++- 8 files changed, 543 insertions(+), 75 deletions(-) create mode 100644 src/views/LoginView.test.tsx diff --git a/.eslintrc.json b/.eslintrc.json index ee0a01c4..b7e65979 100644 --- a/.eslintrc.json +++ b/.eslintrc.json @@ -27,7 +27,7 @@ "@typescript-eslint/no-explicit-any": "warn", "@typescript-eslint/no-unused-vars": "error", "no-console": "error", - "react/jsx-no-bind": "warn", + "react/jsx-no-bind": "off", "react/jsx-sort-props": [ "warn", { diff --git a/package.json b/package.json index 6e7979b6..790b47d3 100644 --- a/package.json +++ b/package.json @@ -7,10 +7,15 @@ "postinstall": "semantic-ui-css-patch" }, "dependencies": { + "@emotion/react": "^11.10.4", + "@emotion/styled": "^11.10.4", + "@fontsource/roboto": "^4.5.8", "@fortawesome/fontawesome-svg-core": "^6.2.0", "@fortawesome/free-brands-svg-icons": "^6.2.0", "@fortawesome/free-solid-svg-icons": "^6.2.0", "@fortawesome/react-fontawesome": "^0.2.0", + "@mui/icons-material": "^5.10.9", + "@mui/material": "^5.10.10", "@semantic-ui-react/css-patch": "^1.1.2", "@tanstack/react-query": "^4.10.3", "@tanstack/react-query-devtools": "^4.11.0", @@ -39,6 +44,8 @@ "@babel/preset-typescript": "^7.18.6", "@testing-library/jest-dom": "^5.16.5", "@types/jest": "^29.2.1", + "@testing-library/react": "^13.4.0", + "@testing-library/user-event": "^14.4.3", "@types/jwt-decode": "^3.1.0", "@types/leaflet": "^1.9.0", "@types/leaflet-draw": "^1.0.5", @@ -75,6 +82,7 @@ "jest-environment-jsdom": "^29.3.1", "jest-fetch-mock": "^3.0.3", "jest-transform-stub": "^2.0.0", + "jwt-encode": "^1.0.1", "postcss": "^8.4.16", "postcss-loader": "^7.0.1", "postcss-remove-google-fonts": "^1.2.0", diff --git a/src/App.tsx b/src/App.tsx index 4cc84b73..ce28f495 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -10,8 +10,6 @@ import TickerView from './views/TickerView' import UsersView from './views/UsersView' import ProtectedRoute from './components/ProtectedRoute' import NotFoundView from './views/NotFoundView' -import 'semantic-ui-css/semantic.min.css' -import './index.css' import '../leaflet.config.js' import { FeatureProvider } from './components/useFeature' diff --git a/src/declaration.d.ts b/src/declaration.d.ts index 6e9fb0ca..e3a26839 100644 --- a/src/declaration.d.ts +++ b/src/declaration.d.ts @@ -1,2 +1,3 @@ declare module '*.png' declare module 'jwt-decode' +declare module 'jwt-encode' diff --git a/src/index.tsx b/src/index.tsx index 93a00949..aad746d0 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,5 +1,9 @@ import React, { createRoot } from 'react-dom/client' import App from './App' +import '@fontsource/roboto/300.css' +import '@fontsource/roboto/400.css' +import '@fontsource/roboto/500.css' +import '@fontsource/roboto/700.css' const container = document.getElementById('root') // eslint-disable-next-line @typescript-eslint/no-non-null-assertion diff --git a/src/views/LoginView.test.tsx b/src/views/LoginView.test.tsx new file mode 100644 index 00000000..ac10265f --- /dev/null +++ b/src/views/LoginView.test.tsx @@ -0,0 +1,83 @@ +import React from 'react' +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { MemoryRouter } from 'react-router' +import { AuthProvider } from '../components/useAuth' +import LoginView from './LoginView' +import * as api from '../api/Auth' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import sign from 'jwt-encode' + +function setup() { + const client = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }) + return render( + + + + + + + + ) +} + +describe('LoginView', function () { + test('login successful', async function () { + const jwt = sign( + { id: 1, email: 'louis@systemli.org', roles: ['admin', 'user'] }, + 'secret' + ) + jest + .spyOn(api, 'login') + .mockResolvedValue({ code: 200, token: jwt, expire: new Date() }) + setup() + + const email = screen + .getByTestId('email') + .querySelector('input') as HTMLInputElement + const password = screen + .getByTestId('password') + .querySelector('input') as HTMLInputElement + const submit = screen.getByTestId('submit') as HTMLElement + + expect(email).toBeInTheDocument() + expect(password).toBeInTheDocument() + expect(submit).toBeInTheDocument() + + await userEvent.type(email, 'louis@systemli.org') + await userEvent.type(password, 'password') + await userEvent.click(submit) + + expect(api.login).toHaveBeenCalledWith('louis@systemli.org', 'password') + }) + + test('login failed', async function () { + jest.spyOn(api, 'login').mockRejectedValue(new Error('Login failed')) + setup() + + const email = screen + .getByTestId('email') + .querySelector('input') as HTMLInputElement + const password = screen + .getByTestId('password') + .querySelector('input') as HTMLInputElement + const submit = screen.getByTestId('submit') as HTMLElement + + expect(email).toBeInTheDocument() + expect(password).toBeInTheDocument() + expect(submit).toBeInTheDocument() + + await userEvent.type(email, 'louis@systemli.org') + await userEvent.type(password, 'password') + await userEvent.click(submit) + + expect(api.login).toHaveBeenCalledWith('louis@systemli.org', 'password') + expect(await screen.findByText('Login failed')).toBeInTheDocument() + }) +}) diff --git a/src/views/LoginView.tsx b/src/views/LoginView.tsx index 22103616..23e3e58f 100644 --- a/src/views/LoginView.tsx +++ b/src/views/LoginView.tsx @@ -1,17 +1,18 @@ -import React, { FC, FormEvent, useCallback, useEffect } from 'react' -import { SubmitHandler, useForm } from 'react-hook-form' -import { useNavigate } from 'react-router' import { + Alert, + Box, Button, Container, - Form, Grid, - Header, - Icon, - InputOnChangeData, - Message, -} from 'semantic-ui-react' + Paper, + TextField, + Typography, +} from '@mui/material' +import React, { FC, useEffect } from 'react' +import { SubmitHandler, useForm } from 'react-hook-form' +import { useNavigate } from 'react-router' import useAuth from '../components/useAuth' +import logo from '../assets/logo.png' interface FormValues { email: string @@ -19,70 +20,76 @@ interface FormValues { } const LoginView: FC = () => { - const { register, handleSubmit, setValue } = useForm() + const { getValues, handleSubmit, register, reset } = useForm() const { login, error, user } = useAuth() const navigate = useNavigate() - const onChange = useCallback( - (e: FormEvent, { name, value }: InputOnChangeData) => { - setValue(name, value) - }, - [setValue] - ) - - const onSubmit: SubmitHandler = data => { - login(data.email, data.password) + const onSubmit: SubmitHandler = ({ email, password }) => { + login(email, password) } useEffect(() => { if (user) navigate('/') - - register('email') - register('password') }) + useEffect(() => { + if (error) { + reset({ email: getValues('email'), password: '' }) + } + }, [error, getValues, reset]) + return ( - - - - - -
- - Login -
-
- -
- {error ? ( - - ) : null} - - - - -
-
+ + + + + Systemli Logo + + Ticker Login + + + + + +
+ {error ? ( + + {error.message} + + ) : null} + + + + +
-
+
) } diff --git a/yarn.lock b/yarn.lock index 35303c31..6a59c20b 100644 --- a/yarn.lock +++ b/yarn.lock @@ -15,7 +15,7 @@ "@jridgewell/gen-mapping" "^0.1.0" "@jridgewell/trace-mapping" "^0.3.9" -"@babel/code-frame@^7.0.0", "@babel/code-frame@^7.12.13", "@babel/code-frame@^7.16.0", "@babel/code-frame@^7.18.6", "@babel/code-frame@^7.8.3": +"@babel/code-frame@^7.0.0", "@babel/code-frame@^7.10.4", "@babel/code-frame@^7.12.13", "@babel/code-frame@^7.16.0", "@babel/code-frame@^7.18.6", "@babel/code-frame@^7.8.3": version "7.18.6" resolved "https://registry.yarnpkg.com/@babel/code-frame/-/code-frame-7.18.6.tgz#3b25d38c89600baa2dcc219edfa88a74eb2c427a" integrity sha512-TDCmlK5eOvH+eH7cdAFlNXeVJqWIQ7gW9tY1GJIpUtFb6CmjVyq2VM3u71bOyR8CRihcCgMUYoDNyLXao3+70Q== @@ -194,7 +194,7 @@ dependencies: "@babel/types" "^7.18.9" -"@babel/helper-module-imports@^7.18.6": +"@babel/helper-module-imports@^7.16.7", "@babel/helper-module-imports@^7.18.6": version "7.18.6" resolved "https://registry.yarnpkg.com/@babel/helper-module-imports/-/helper-module-imports-7.18.6.tgz#1e3ebdbbd08aad1437b428c50204db13c5a3ca6e" integrity sha512-0NFvs3VkuSYbFi1x2Vd6tKrywq+z/cLeYC/RJNFrIX/30Bf5aiGYbtvGXolEktzJH8o5E5KJ3tT+nkxuuZFVlA== @@ -586,7 +586,7 @@ dependencies: "@babel/helper-plugin-utils" "^7.8.0" -"@babel/plugin-syntax-jsx@^7.18.6", "@babel/plugin-syntax-jsx@^7.7.2": +"@babel/plugin-syntax-jsx@^7.17.12", "@babel/plugin-syntax-jsx@^7.18.6", "@babel/plugin-syntax-jsx@^7.7.2": version "7.18.6" resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.18.6.tgz#a8feef63b010150abd97f1649ec296e849943ca0" integrity sha512-6mmljtAedFGTWu2p/8WIORGwy+61PLgOMPOdazc7YoJ9ZCWUyFy3A6CpPkRKLKD1ToAesxX8KGEViAiLo9N+7Q== @@ -1096,6 +1096,13 @@ dependencies: regenerator-runtime "^0.13.4" +"@babel/runtime@^7.18.3", "@babel/runtime@^7.19.0", "@babel/runtime@^7.5.5", "@babel/runtime@^7.8.7": + version "7.19.4" + resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.19.4.tgz#a42f814502ee467d55b38dd1c256f53a7b885c78" + integrity sha512-EXpLCrk55f+cYqmHsSR+yD/0gAIMxxA9QK9lnQWzhMCvt+YmoBN7Zx94s++Kv0+unHk39vxNO8t+CMA2WSS3wA== + dependencies: + regenerator-runtime "^0.13.4" + "@babel/runtime@^7.9.2": version "7.19.0" resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.19.0.tgz#22b11c037b094d27a8a2504ea4dcff00f50e2259" @@ -1154,6 +1161,114 @@ resolved "https://registry.yarnpkg.com/@discoveryjs/json-ext/-/json-ext-0.5.7.tgz#1d572bfbbe14b7704e0ba0f39b74815b84870d70" integrity sha512-dBVuXR082gk3jsFp7Rd/JI4kytwGHecnCoTtXFb7DB6CNHp4rg5k1bhg0nWdLGLnOV71lmDzGQaLMy8iPLY0pw== +"@emotion/babel-plugin@^11.10.0": + version "11.10.2" + resolved "https://registry.yarnpkg.com/@emotion/babel-plugin/-/babel-plugin-11.10.2.tgz#879db80ba622b3f6076917a1e6f648b1c7d008c7" + integrity sha512-xNQ57njWTFVfPAc3cjfuaPdsgLp5QOSuRsj9MA6ndEhH/AzuZM86qIQzt6rq+aGBwj3n5/TkLmU5lhAfdRmogA== + dependencies: + "@babel/helper-module-imports" "^7.16.7" + "@babel/plugin-syntax-jsx" "^7.17.12" + "@babel/runtime" "^7.18.3" + "@emotion/hash" "^0.9.0" + "@emotion/memoize" "^0.8.0" + "@emotion/serialize" "^1.1.0" + babel-plugin-macros "^3.1.0" + convert-source-map "^1.5.0" + escape-string-regexp "^4.0.0" + find-root "^1.1.0" + source-map "^0.5.7" + stylis "4.0.13" + +"@emotion/cache@^11.10.0", "@emotion/cache@^11.10.3": + version "11.10.3" + resolved "https://registry.yarnpkg.com/@emotion/cache/-/cache-11.10.3.tgz#c4f67904fad10c945fea5165c3a5a0583c164b87" + integrity sha512-Psmp/7ovAa8appWh3g51goxu/z3iVms7JXOreq136D8Bbn6dYraPnmL6mdM8GThEx9vwSn92Fz+mGSjBzN8UPQ== + dependencies: + "@emotion/memoize" "^0.8.0" + "@emotion/sheet" "^1.2.0" + "@emotion/utils" "^1.2.0" + "@emotion/weak-memoize" "^0.3.0" + stylis "4.0.13" + +"@emotion/hash@^0.9.0": + version "0.9.0" + resolved "https://registry.yarnpkg.com/@emotion/hash/-/hash-0.9.0.tgz#c5153d50401ee3c027a57a177bc269b16d889cb7" + integrity sha512-14FtKiHhy2QoPIzdTcvh//8OyBlknNs2nXRwIhG904opCby3l+9Xaf/wuPvICBF0rc1ZCNBd3nKe9cd2mecVkQ== + +"@emotion/is-prop-valid@^1.2.0": + version "1.2.0" + resolved "https://registry.yarnpkg.com/@emotion/is-prop-valid/-/is-prop-valid-1.2.0.tgz#7f2d35c97891669f7e276eb71c83376a5dc44c83" + integrity sha512-3aDpDprjM0AwaxGE09bOPkNxHpBd+kA6jty3RnaEXdweX1DF1U3VQpPYb0g1IStAuK7SVQ1cy+bNBBKp4W3Fjg== + dependencies: + "@emotion/memoize" "^0.8.0" + +"@emotion/memoize@^0.8.0": + version "0.8.0" + resolved "https://registry.yarnpkg.com/@emotion/memoize/-/memoize-0.8.0.tgz#f580f9beb67176fa57aae70b08ed510e1b18980f" + integrity sha512-G/YwXTkv7Den9mXDO7AhLWkE3q+I92B+VqAE+dYG4NGPaHZGvt3G8Q0p9vmE+sq7rTGphUbAvmQ9YpbfMQGGlA== + +"@emotion/react@^11.10.4": + version "11.10.4" + resolved "https://registry.yarnpkg.com/@emotion/react/-/react-11.10.4.tgz#9dc6bccbda5d70ff68fdb204746c0e8b13a79199" + integrity sha512-j0AkMpr6BL8gldJZ6XQsQ8DnS9TxEQu1R+OGmDZiWjBAJtCcbt0tS3I/YffoqHXxH6MjgI7KdMbYKw3MEiU9eA== + dependencies: + "@babel/runtime" "^7.18.3" + "@emotion/babel-plugin" "^11.10.0" + "@emotion/cache" "^11.10.0" + "@emotion/serialize" "^1.1.0" + "@emotion/use-insertion-effect-with-fallbacks" "^1.0.0" + "@emotion/utils" "^1.2.0" + "@emotion/weak-memoize" "^0.3.0" + hoist-non-react-statics "^3.3.1" + +"@emotion/serialize@^1.1.0": + version "1.1.0" + resolved "https://registry.yarnpkg.com/@emotion/serialize/-/serialize-1.1.0.tgz#b1f97b1011b09346a40e9796c37a3397b4ea8ea8" + integrity sha512-F1ZZZW51T/fx+wKbVlwsfchr5q97iW8brAnXmsskz4d0hVB4O3M/SiA3SaeH06x02lSNzkkQv+n3AX3kCXKSFA== + dependencies: + "@emotion/hash" "^0.9.0" + "@emotion/memoize" "^0.8.0" + "@emotion/unitless" "^0.8.0" + "@emotion/utils" "^1.2.0" + csstype "^3.0.2" + +"@emotion/sheet@^1.2.0": + version "1.2.0" + resolved "https://registry.yarnpkg.com/@emotion/sheet/-/sheet-1.2.0.tgz#771b1987855839e214fc1741bde43089397f7be5" + integrity sha512-OiTkRgpxescko+M51tZsMq7Puu/KP55wMT8BgpcXVG2hqXc0Vo0mfymJ/Uj24Hp0i083ji/o0aLddh08UEjq8w== + +"@emotion/styled@^11.10.4": + version "11.10.4" + resolved "https://registry.yarnpkg.com/@emotion/styled/-/styled-11.10.4.tgz#e93f84a4d54003c2acbde178c3f97b421fce1cd4" + integrity sha512-pRl4R8Ez3UXvOPfc2bzIoV8u9P97UedgHS4FPX594ntwEuAMA114wlaHvOK24HB48uqfXiGlYIZYCxVJ1R1ttQ== + dependencies: + "@babel/runtime" "^7.18.3" + "@emotion/babel-plugin" "^11.10.0" + "@emotion/is-prop-valid" "^1.2.0" + "@emotion/serialize" "^1.1.0" + "@emotion/use-insertion-effect-with-fallbacks" "^1.0.0" + "@emotion/utils" "^1.2.0" + +"@emotion/unitless@^0.8.0": + version "0.8.0" + resolved "https://registry.yarnpkg.com/@emotion/unitless/-/unitless-0.8.0.tgz#a4a36e9cbdc6903737cd20d38033241e1b8833db" + integrity sha512-VINS5vEYAscRl2ZUDiT3uMPlrFQupiKgHz5AA4bCH1miKBg4qtwkim1qPmJj/4WG6TreYMY111rEFsjupcOKHw== + +"@emotion/use-insertion-effect-with-fallbacks@^1.0.0": + version "1.0.0" + resolved "https://registry.yarnpkg.com/@emotion/use-insertion-effect-with-fallbacks/-/use-insertion-effect-with-fallbacks-1.0.0.tgz#ffadaec35dbb7885bd54de3fa267ab2f860294df" + integrity sha512-1eEgUGmkaljiBnRMTdksDV1W4kUnmwgp7X9G8B++9GYwl1lUdqSndSriIrTJ0N7LQaoauY9JJ2yhiOYK5+NI4A== + +"@emotion/utils@^1.2.0": + version "1.2.0" + resolved "https://registry.yarnpkg.com/@emotion/utils/-/utils-1.2.0.tgz#9716eaccbc6b5ded2ea5a90d65562609aab0f561" + integrity sha512-sn3WH53Kzpw8oQ5mgMmIzzyAaH2ZqFEbozVVBSYp538E06OSE6ytOp7pRAjNQR+Q/orwqdQYJSe2m3hCOeznkw== + +"@emotion/weak-memoize@^0.3.0": + version "0.3.0" + resolved "https://registry.yarnpkg.com/@emotion/weak-memoize/-/weak-memoize-0.3.0.tgz#ea89004119dc42db2e1dba0f97d553f7372f6fcb" + integrity sha512-AHPmaAx+RYfZz0eYu6Gviiagpmiyw98ySSlQvCUhVGDRtDFe4DBS0x1bSjdF3gqUDYOczB+yYvBTtEylYSdRhg== + "@eslint/eslintrc@^1.4.1": version "1.4.1" resolved "https://registry.yarnpkg.com/@eslint/eslintrc/-/eslintrc-1.4.1.tgz#af58772019a2d271b7e2d4c23ff4ddcba3ccfb3e" @@ -1184,6 +1299,11 @@ "@babel/runtime" "^7.10.4" react-is "^16.6.3" +"@fontsource/roboto@^4.5.8": + version "4.5.8" + resolved "https://registry.yarnpkg.com/@fontsource/roboto/-/roboto-4.5.8.tgz#56347764786079838faf43f0eeda22dd7328437f" + integrity sha512-CnD7zLItIzt86q4Sj3kZUiLcBk1dSk81qcqgMGaZe7SQ1P8hFNxhMl5AZthK1zrDM5m74VVhaOpuMGIL4gagaA== + "@fortawesome/fontawesome-common-types@6.2.0": version "6.2.0" resolved "https://registry.yarnpkg.com/@fortawesome/fontawesome-common-types/-/fontawesome-common-types-6.2.0.tgz#76467a94aa888aeb22aafa43eb6ff889df3a5a7f" @@ -1526,6 +1646,99 @@ resolved "https://registry.yarnpkg.com/@leichtgewicht/ip-codec/-/ip-codec-2.0.4.tgz#b2ac626d6cb9c8718ab459166d4bb405b8ffa78b" integrity sha512-Hcv+nVC0kZnQ3tD9GVu5xSMR4VVYOteQIr/hwFPVEvPdlXqgGEuRjiheChHgdM+JyqdgNcmzZOX/tnl0JOiI7A== +"@mui/base@5.0.0-alpha.102": + version "5.0.0-alpha.102" + resolved "https://registry.yarnpkg.com/@mui/base/-/base-5.0.0-alpha.102.tgz#53b07d0b73d3afe1d2a3feb3b43c8188bb821796" + integrity sha512-5e/qAIP+DlkrZxIt/cwnDw/A3ii22WkoEoWKHyu4+oeGs3/1Flh7qLaN4h5EAIBB9TvTEZEUzvmsTInmIj6ghg== + dependencies: + "@babel/runtime" "^7.19.0" + "@emotion/is-prop-valid" "^1.2.0" + "@mui/types" "^7.2.0" + "@mui/utils" "^5.10.9" + "@popperjs/core" "^2.11.6" + clsx "^1.2.1" + prop-types "^15.8.1" + react-is "^18.2.0" + +"@mui/core-downloads-tracker@^5.10.10": + version "5.10.10" + resolved "https://registry.yarnpkg.com/@mui/core-downloads-tracker/-/core-downloads-tracker-5.10.10.tgz#a3e5d2f6e5146e9a85d48824c386a31be1746ba3" + integrity sha512-aDuE2PNEh+hAndxEWlZgq7uiFPZKJtnkPDX7v6kSCrMXA32ZaQ6rZi5olmC7DUHt/BaOSxb7N/im/ss0XBkDhA== + +"@mui/icons-material@^5.10.9": + version "5.10.9" + resolved "https://registry.yarnpkg.com/@mui/icons-material/-/icons-material-5.10.9.tgz#f9522c49797caf30146acc576e37ecb4f95bbc38" + integrity sha512-sqClXdEM39WKQJOQ0ZCPTptaZgqwibhj2EFV9N0v7BU1PO8y4OcX/a2wIQHn4fNuDjIZktJIBrmU23h7aqlGgg== + dependencies: + "@babel/runtime" "^7.19.0" + +"@mui/material@^5.10.10": + version "5.10.10" + resolved "https://registry.yarnpkg.com/@mui/material/-/material-5.10.10.tgz#df780d933c0aa9d4a5272f32c9cc24bf1ea72cff" + integrity sha512-ioLvqY7VvcePz9dnEIRhpiVvtJmAFmvG6rtLXXzVdMmAVbSaelr5Io07mPz/mCyqE+Uv8/4EuJV276DWO7etzA== + dependencies: + "@babel/runtime" "^7.19.0" + "@mui/base" "5.0.0-alpha.102" + "@mui/core-downloads-tracker" "^5.10.10" + "@mui/system" "^5.10.10" + "@mui/types" "^7.2.0" + "@mui/utils" "^5.10.9" + "@types/react-transition-group" "^4.4.5" + clsx "^1.2.1" + csstype "^3.1.1" + prop-types "^15.8.1" + react-is "^18.2.0" + react-transition-group "^4.4.5" + +"@mui/private-theming@^5.10.9": + version "5.10.9" + resolved "https://registry.yarnpkg.com/@mui/private-theming/-/private-theming-5.10.9.tgz#c427bfa736455703975cdb108dbde6a174ba7971" + integrity sha512-BN7/CnsVPVyBaQpDTij4uV2xGYHHHhOgpdxeYLlIu+TqnsVM7wUeF+37kXvHovxM6xmL5qoaVUD98gDC0IZnHg== + dependencies: + "@babel/runtime" "^7.19.0" + "@mui/utils" "^5.10.9" + prop-types "^15.8.1" + +"@mui/styled-engine@^5.10.8": + version "5.10.8" + resolved "https://registry.yarnpkg.com/@mui/styled-engine/-/styled-engine-5.10.8.tgz#2db411e4278f06f70ccb6b5cd56ace67109513f6" + integrity sha512-w+y8WI18EJV6zM/q41ug19cE70JTeO6sWFsQ7tgePQFpy6ToCVPh0YLrtqxUZXSoMStW5FMw0t9fHTFAqPbngw== + dependencies: + "@babel/runtime" "^7.19.0" + "@emotion/cache" "^11.10.3" + csstype "^3.1.1" + prop-types "^15.8.1" + +"@mui/system@^5.10.10": + version "5.10.10" + resolved "https://registry.yarnpkg.com/@mui/system/-/system-5.10.10.tgz#fbc34f29a3b62268c3d2b2be92819a35fc52de90" + integrity sha512-TXwtKN0adKpBrZmO+eilQWoPf2veh050HLYrN78Kps9OhlvO70v/2Kya0+mORFhu9yhpAwjHXO8JII/R4a5ZLA== + dependencies: + "@babel/runtime" "^7.19.0" + "@mui/private-theming" "^5.10.9" + "@mui/styled-engine" "^5.10.8" + "@mui/types" "^7.2.0" + "@mui/utils" "^5.10.9" + clsx "^1.2.1" + csstype "^3.1.1" + prop-types "^15.8.1" + +"@mui/types@^7.2.0": + version "7.2.0" + resolved "https://registry.yarnpkg.com/@mui/types/-/types-7.2.0.tgz#91380c2d42420f51f404120f7a9270eadd6f5c23" + integrity sha512-lGXtFKe5lp3UxTBGqKI1l7G8sE2xBik8qCfrLHD5olwP/YU0/ReWoWT7Lp1//ri32dK39oPMrJN8TgbkCSbsNA== + +"@mui/utils@^5.10.9": + version "5.10.9" + resolved "https://registry.yarnpkg.com/@mui/utils/-/utils-5.10.9.tgz#9dc455f9230f43eeb81d96a9a4bdb3855bb9ea39" + integrity sha512-2tdHWrq3+WCy+G6TIIaFx3cg7PorXZ71P375ExuX61od1NOAJP1mK90VxQ8N4aqnj2vmO3AQDkV4oV2Ktvt4bA== + dependencies: + "@babel/runtime" "^7.19.0" + "@types/prop-types" "^15.7.5" + "@types/react-is" "^16.7.1 || ^17.0.0" + prop-types "^15.8.1" + react-is "^18.2.0" + "@nodelib/fs.scandir@2.1.5": version "2.1.5" resolved "https://registry.yarnpkg.com/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz#7619c2eb21b25483f6d167548b4cfd5a7488c3d5" @@ -1547,6 +1760,11 @@ "@nodelib/fs.scandir" "2.1.5" fastq "^1.6.0" +"@popperjs/core@^2.11.6": + version "2.11.6" + resolved "https://registry.yarnpkg.com/@popperjs/core/-/core-2.11.6.tgz#cee20bd55e68a1720bdab363ecf0c821ded4cd45" + integrity sha512-50/17A98tWUfQ176raKiOGXuYpLyyVMkxxG6oylzL3BPOlA6ADGdK7EYunSa4I064xerltq9TGXs8HmOk5E+vw== + "@popperjs/core@^2.6.0": version "2.11.5" resolved "https://registry.yarnpkg.com/@popperjs/core/-/core-2.11.5.tgz#db5a11bf66bdab39569719555b0f76e138d7bd64" @@ -1631,6 +1849,20 @@ "@tanstack/query-core" "4.10.3" use-sync-external-store "^1.2.0" +"@testing-library/dom@^8.5.0": + version "8.19.0" + resolved "https://registry.yarnpkg.com/@testing-library/dom/-/dom-8.19.0.tgz#bd3f83c217ebac16694329e413d9ad5fdcfd785f" + integrity sha512-6YWYPPpxG3e/xOo6HIWwB/58HukkwIVTOaZ0VwdMVjhRUX/01E4FtQbck9GazOOj7MXHc5RBzMrU86iBJHbI+A== + dependencies: + "@babel/code-frame" "^7.10.4" + "@babel/runtime" "^7.12.5" + "@types/aria-query" "^4.2.0" + aria-query "^5.0.0" + chalk "^4.1.0" + dom-accessibility-api "^0.5.9" + lz-string "^1.4.4" + pretty-format "^27.0.2" + "@testing-library/jest-dom@^5.16.5": version "5.16.5" resolved "https://registry.yarnpkg.com/@testing-library/jest-dom/-/jest-dom-5.16.5.tgz#3912846af19a29b2dbf32a6ae9c31ef52580074e" @@ -1646,6 +1878,20 @@ lodash "^4.17.15" redent "^3.0.0" +"@testing-library/react@^13.4.0": + version "13.4.0" + resolved "https://registry.yarnpkg.com/@testing-library/react/-/react-13.4.0.tgz#6a31e3bf5951615593ad984e96b9e5e2d9380966" + integrity sha512-sXOGON+WNTh3MLE9rve97ftaZukN3oNf2KjDy7YTx6hcTO2uuLHuCGynMDhFwGw/jYf4OJ2Qk0i4i79qMNNkyw== + dependencies: + "@babel/runtime" "^7.12.5" + "@testing-library/dom" "^8.5.0" + "@types/react-dom" "^18.0.0" + +"@testing-library/user-event@^14.4.3": + version "14.4.3" + resolved "https://registry.yarnpkg.com/@testing-library/user-event/-/user-event-14.4.3.tgz#af975e367743fa91989cd666666aec31a8f50591" + integrity sha512-kCUc5MEwaEMakkO5x7aoD+DLi02ehmEM2QCGWvNqAS1dV/fAvORWEjnjsEIvml59M7Y5kCkWN6fCCyPOe8OL6Q== + "@tootallnate/once@2": version "2.0.0" resolved "https://registry.yarnpkg.com/@tootallnate/once/-/once-2.0.0.tgz#f544a148d3ab35801c1f633a7441fd87c2e484bf" @@ -1676,6 +1922,11 @@ resolved "https://registry.yarnpkg.com/@tsconfig/node16/-/node16-1.0.2.tgz#423c77877d0569db20e1fc80885ac4118314010e" integrity sha512-eZxlbI8GZscaGS7kkc/trHTT5xgrjH3/1n2JDwusC9iahPKWMRvRjJSAN5mCXviuTGQ/lHnhvv8Q1YTpnfz9gA== +"@types/aria-query@^4.2.0": + version "4.2.2" + resolved "https://registry.yarnpkg.com/@types/aria-query/-/aria-query-4.2.2.tgz#ed4e0ad92306a704f9fb132a0cfcf77486dbe2bc" + integrity sha512-HnYpAE1Y6kRyKM/XkEuiRQhTHvkzMBurTHnpFLYLBGPIylZNPs9jJcuOOYWxPLJCSEtmZT0Y8rHDokKN7rRTig== + "@types/babel__core@^7.1.14": version "7.1.19" resolved "https://registry.yarnpkg.com/@types/babel__core/-/babel__core-7.1.19.tgz#7b497495b7d1b4812bdb9d02804d0576f43ee460" @@ -1939,7 +2190,7 @@ resolved "https://registry.yarnpkg.com/@types/prettier/-/prettier-2.7.1.tgz#dfd20e2dc35f027cdd6c1908e80a5ddc7499670e" integrity sha512-ri0UmynRRvZiiUJdiz38MmIblKK+oH30MztdBVR95dv/Ubw6neWSb8u1XpRb72L4qsZOhz+L+z9JD40SJmfWow== -"@types/prop-types@*", "@types/prop-types@^15.0.0": +"@types/prop-types@*", "@types/prop-types@^15.0.0", "@types/prop-types@^15.7.5": version "15.7.5" resolved "https://registry.yarnpkg.com/@types/prop-types/-/prop-types-15.7.5.tgz#5f19d2b85a98e9558036f6a3cacc8819420f05cf" integrity sha512-JCB8C6SnDoQf0cNycqd/35A7MjcnK+ZTqE7judS6o7utxUCg6imJg3QK2qzHKszlTjcj2cn+NwMB2i96ubpj7w== @@ -1954,13 +2205,20 @@ resolved "https://registry.yarnpkg.com/@types/range-parser/-/range-parser-1.2.4.tgz#cd667bcfdd025213aafb7ca5915a932590acdcdc" integrity sha512-EEhsLsD6UsDM1yFhAvy0Cjr6VwmpMWqFBCb9w07wVugF7w9nfajxLuVmngTIpgS6svCnm6Vaw+MZhoDCKnOfsw== -"@types/react-dom@^18.0.6": +"@types/react-dom@^18.0.0", "@types/react-dom@^18.0.6": version "18.0.6" resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-18.0.6.tgz#36652900024842b74607a17786b6662dd1e103a1" integrity sha512-/5OFZgfIPSwy+YuIBP/FgJnQnsxhZhjjrnxudMddeblOouIodEQ75X14Rr4wGSG/bknL+Omy9iWlLo1u/9GzAA== dependencies: "@types/react" "*" +"@types/react-is@^16.7.1 || ^17.0.0": + version "17.0.3" + resolved "https://registry.yarnpkg.com/@types/react-is/-/react-is-17.0.3.tgz#2d855ba575f2fc8d17ef9861f084acc4b90a137a" + integrity sha512-aBTIWg1emtu95bLTLx0cpkxwGW3ueZv71nE2YFBpL8k/z5czEW8yYpOo8Dp+UUAFAtKwNaOsh/ioSeQnWlZcfw== + dependencies: + "@types/react" "*" + "@types/react-router-dom@^5.3.3": version "5.3.3" resolved "https://registry.yarnpkg.com/@types/react-router-dom/-/react-router-dom-5.3.3.tgz#e9d6b4a66fcdbd651a5f106c2656a30088cc1e83" @@ -1978,6 +2236,13 @@ "@types/history" "^4.7.11" "@types/react" "*" +"@types/react-transition-group@^4.4.5": + version "4.4.5" + resolved "https://registry.yarnpkg.com/@types/react-transition-group/-/react-transition-group-4.4.5.tgz#aae20dcf773c5aa275d5b9f7cdbca638abc5e416" + integrity sha512-juKD/eiSM3/xZYzjuzH6ZwpP+/lejltmiS3QEzV/vmb/Q8+HfDmxu+Baga8UEMGBqV88Nbg4l2hY/K2DkyaLLA== + dependencies: + "@types/react" "*" + "@types/react-twitter-auth@^0.0.4": version "0.0.4" resolved "https://registry.yarnpkg.com/@types/react-twitter-auth/-/react-twitter-auth-0.0.4.tgz#bb1836bbbbbdfb8623c78cf3e6ce44b9930e7e45" @@ -3030,6 +3295,11 @@ clsx@^1.1.1: resolved "https://registry.yarnpkg.com/clsx/-/clsx-1.1.1.tgz#98b3134f9abbdf23b2663491ace13c5c03a73188" integrity sha512-6/bPho624p3S2pMyvP5kKBPXnI3ufHLObBFCfgx+LkeR5lg2XYy2hqZqUf45ypD8COn2bhgGJSUE+l5dhNBieA== +clsx@^1.2.1: + version "1.2.1" + resolved "https://registry.yarnpkg.com/clsx/-/clsx-1.2.1.tgz#0ddc4a20a549b59c93a4116bb26f5294ca17dc12" + integrity sha512-EcR6r5a8bj6pu3ycsa/E/cKVGuTgZJZdsyUYHOksG/UHIiKfjxzRxYJpyVBwYaQeOvghal9fcc4PidlgzugAQg== + co@^4.6.0: version "4.6.0" resolved "https://registry.yarnpkg.com/co/-/co-4.6.0.tgz#6ea6bdf3d853ae54ccb8e47bfa0bf3f9031fb184" @@ -3153,13 +3423,25 @@ content-type@~1.0.4: resolved "https://registry.yarnpkg.com/content-type/-/content-type-1.0.4.tgz#e138cc75e040c727b1966fe5e5f8c9aee256fe3b" integrity sha512-hIP3EEPs8tB9AT1L+NUqtwOAps4mk2Zob89MWXMHjHWg9milF/j4osnnQLXBCBFBk/tvIG/tUc9mOUJiPBhPXA== -convert-source-map@^1.4.0, convert-source-map@^1.6.0, convert-source-map@^1.7.0: +convert-source-map@^1.4.0, convert-source-map@^1.6.0: version "1.8.0" resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-1.8.0.tgz#f3373c32d21b4d780dd8004514684fb791ca4369" integrity sha512-+OQdjP49zViI/6i7nIJpA8rAl4sV/JdPfU9nZs3VqOwGIgizICvuN2ru6fMd+4llL0tar18UYJXfZ/TWtmhUjA== dependencies: safe-buffer "~5.1.1" +convert-source-map@^1.5.0: + version "1.9.0" + resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-1.9.0.tgz#7faae62353fb4213366d0ca98358d22e8368b05f" + integrity sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A== + +convert-source-map@^1.7.0: + version "1.7.0" + resolved "https://registry.yarnpkg.com/convert-source-map/-/convert-source-map-1.7.0.tgz#17a2cb882d7f77d3490585e2ce6c524424a3a442" + integrity sha512-4FJkXzKXEDB1snCFZlLP4gpC3JILicCpGbzG9f9G7tGqGCzETQ2hWPrcinA9oU4wtf2biUaEH5065UnMeR33oA== + dependencies: + safe-buffer "~5.1.1" + cookie-signature@1.0.6: version "1.0.6" resolved "https://registry.yarnpkg.com/cookie-signature/-/cookie-signature-1.0.6.tgz#e303a882b342cc3ee8ca513a79999734dab3ae2c" @@ -3368,6 +3650,11 @@ csstype@^3.0.2: resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.1.0.tgz#4ddcac3718d787cf9df0d1b7d15033925c8f29f2" integrity sha512-uX1KG+x9h5hIJsaKR9xHUeUraxf8IODOwq9JLNPq6BwB04a/xgpq3rcx47l5BZu5zBPlgD342tdke3Hom/nJRA== +csstype@^3.1.1: + version "3.1.1" + resolved "https://registry.yarnpkg.com/csstype/-/csstype-3.1.1.tgz#841b532c45c758ee546a11d5bd7b7b473c8c30b9" + integrity sha512-DJR/VvkAvSZW9bTouZue2sSxDwdTN92uHjqeKVm+0dAqdfNykRzQ95tay8aXMBAAPpUiq4Qcug2L7neoRh2Egw== + damerau-levenshtein@^1.0.8: version "1.0.8" resolved "https://registry.yarnpkg.com/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz#b43d286ccbd36bc5b2f7ed41caf2d0aba1f8a6e7" @@ -3548,7 +3835,7 @@ doctrine@^3.0.0: dependencies: esutils "^2.0.2" -dom-accessibility-api@^0.5.6: +dom-accessibility-api@^0.5.6, dom-accessibility-api@^0.5.9: version "0.5.14" resolved "https://registry.yarnpkg.com/dom-accessibility-api/-/dom-accessibility-api-0.5.14.tgz#56082f71b1dc7aac69d83c4285eef39c15d93f56" integrity sha512-NMt+m9zFMPZe0JcY9gN224Qvk6qLIdqex29clBvc/y75ZBX9YA9wNK3frsYvu2DI1xcCIwxwnX+TlsJ2DSOADg== @@ -3560,6 +3847,14 @@ dom-converter@^0.2.0: dependencies: utila "~0.4" +dom-helpers@^5.0.1: + version "5.2.1" + resolved "https://registry.yarnpkg.com/dom-helpers/-/dom-helpers-5.2.1.tgz#d9400536b2bf8225ad98fe052e029451ac40e902" + integrity sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA== + dependencies: + "@babel/runtime" "^7.8.7" + csstype "^3.0.2" + dom-serializer@^1.0.1: version "1.4.1" resolved "https://registry.yarnpkg.com/dom-serializer/-/dom-serializer-1.4.1.tgz#de5d41b1aea290215dc45a6dae8adcf1d32e2d30" @@ -4272,6 +4567,11 @@ find-cache-dir@^3.3.2: make-dir "^3.0.2" pkg-dir "^4.1.0" +find-root@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/find-root/-/find-root-1.1.0.tgz#abcfc8ba76f708c42a97b3d685b7e9450bfb9ce4" + integrity sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng== + find-up@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/find-up/-/find-up-2.1.0.tgz#45d1b7e506c717ddd482775a2b77920a3c0c57a7" @@ -4601,6 +4901,13 @@ he@^1.2.0: resolved "https://registry.yarnpkg.com/he/-/he-1.2.0.tgz#84ae65fa7eafb165fddb61566ae14baf05664f0f" integrity sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw== +hoist-non-react-statics@^3.3.1: + version "3.3.2" + resolved "https://registry.yarnpkg.com/hoist-non-react-statics/-/hoist-non-react-statics-3.3.2.tgz#ece0acaf71d62c2969c2ec59feff42a4b1a85b45" + integrity sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw== + dependencies: + react-is "^16.7.0" + hpack.js@^2.1.6: version "2.1.6" resolved "https://registry.yarnpkg.com/hpack.js/-/hpack.js-2.1.6.tgz#87774c0949e513f42e84575b3c45681fade2a0b2" @@ -5652,6 +5959,13 @@ jwt-decode@*, jwt-decode@^3.1.2: resolved "https://registry.yarnpkg.com/jwt-decode/-/jwt-decode-3.1.2.tgz#3fb319f3675a2df0c2895c8f5e9fa4b67b04ed59" integrity sha512-UfpWE/VZn0iP50d8cz9NrZLM9lSWhcJ+0Gt/nm4by88UL+J1SiKN8/5dkjMmbEzwL2CAe+67GsegCbIKtbp75A== +jwt-encode@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/jwt-encode/-/jwt-encode-1.0.1.tgz#6602a0475f757c7d5d559a7e923f3d3d5ecd938c" + integrity sha512-QrGiNhynbAYyFdbC1GbjborzenSFs5Ga+2nE0uBokGXsH11xrgd1AX55HR7P+wGQyyZOn6LUO5iKlh74dlhBkA== + dependencies: + ts.cryptojs256 "^1.0.1" + keyboard-key@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/keyboard-key/-/keyboard-key-1.1.0.tgz#6f2e8e37fa11475bb1f1d65d5174f1b35653f5b7" @@ -5837,6 +6151,11 @@ lru-cache@^6.0.0: dependencies: yallist "^4.0.0" +lz-string@^1.4.4: + version "1.4.4" + resolved "https://registry.yarnpkg.com/lz-string/-/lz-string-1.4.4.tgz#c0d8eaf36059f705796e1e344811cf4c498d3a26" + integrity sha512-0ckx7ZHRPqb0oUm8zNr+90mtf9DQB60H1wMCjBtfi62Kl3a7JbHob6gA2bC+xRvZoOL+1hzUK8jeuEIQE8svEQ== + make-dir@^3.0.0, make-dir@^3.0.2: version "3.1.0" resolved "https://registry.yarnpkg.com/make-dir/-/make-dir-3.1.0.tgz#415e967046b3a7f1d185277d84aa58203726a13f" @@ -6930,7 +7249,25 @@ pretty-error@^4.0.0: lodash "^4.17.20" renderkid "^3.0.0" -pretty-format@^29.0.0, pretty-format@^29.2.1: +pretty-format@^27.0.2: + version "27.5.1" + resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-27.5.1.tgz#2181879fdea51a7a5851fb39d920faa63f01d88e" + integrity sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ== + dependencies: + ansi-regex "^5.0.1" + ansi-styles "^5.0.0" + react-is "^17.0.1" + +pretty-format@^29.0.0: + version "29.1.2" + resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-29.1.2.tgz#b1f6b75be7d699be1a051f5da36e8ae9e76a8e6a" + integrity sha512-CGJ6VVGXVRP2o2Dorl4mAwwvDWT25luIsYhkyVQW32E4nL+TgW939J7LlKT/npq5Cpq6j3s+sy+13yk7xYpBmg== + dependencies: + "@jest/schemas" "^29.0.0" + ansi-styles "^5.0.0" + react-is "^18.0.0" + +pretty-format@^29.2.1: version "29.2.1" resolved "https://registry.yarnpkg.com/pretty-format/-/pretty-format-29.2.1.tgz#86e7748fe8bbc96a6a4e04fa99172630907a9611" integrity sha512-Y41Sa4aLCtKAXvwuIpTvcFBkyeYp2gdFWzXGA+ZNES3VwURIB165XO/z7CjETwzCCS53MjW/rLMyyqEnTtaOfA== @@ -7110,16 +7447,21 @@ react-image-lightbox@^5.1.4: prop-types "^15.7.2" react-modal "^3.11.1" -react-is@^16.13.1, react-is@^16.6.3: +react-is@^16.13.1, react-is@^16.6.3, react-is@^16.7.0: version "16.13.1" resolved "https://registry.yarnpkg.com/react-is/-/react-is-16.13.1.tgz#789729a4dc36de2999dc156dd6c1d9c18cea56a4" integrity sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ== -"react-is@^16.8.6 || ^17.0.0 || ^18.0.0": +"react-is@^16.8.6 || ^17.0.0 || ^18.0.0", react-is@^18.2.0: version "18.2.0" resolved "https://registry.yarnpkg.com/react-is/-/react-is-18.2.0.tgz#199431eeaaa2e09f86427efbb4f1473edb47609b" integrity sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w== +react-is@^17.0.1: + version "17.0.2" + resolved "https://registry.yarnpkg.com/react-is/-/react-is-17.0.2.tgz#e691d4a8e9c789365655539ab372762b0efb54f0" + integrity sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w== + react-is@^18.0.0: version "18.1.0" resolved "https://registry.yarnpkg.com/react-is/-/react-is-18.1.0.tgz#61aaed3096d30eacf2a2127118b5b41387d32a67" @@ -7204,6 +7546,16 @@ react-router@6.4.1, react-router@^6.4.1: dependencies: "@remix-run/router" "1.0.1" +react-transition-group@^4.4.5: + version "4.4.5" + resolved "https://registry.yarnpkg.com/react-transition-group/-/react-transition-group-4.4.5.tgz#e53d4e3f3344da8521489fbef8f2581d42becdd1" + integrity sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g== + dependencies: + "@babel/runtime" "^7.5.5" + dom-helpers "^5.0.1" + loose-envify "^1.4.0" + prop-types "^15.6.2" + react-twitter-auth@0.0.13: version "0.0.13" resolved "https://registry.yarnpkg.com/react-twitter-auth/-/react-twitter-auth-0.0.13.tgz#73af3b43066d93682adfcbf7ecce007c7a0da1dc" @@ -7764,6 +8116,11 @@ source-map@0.6.1, source-map@^0.6.0, source-map@^0.6.1, source-map@~0.6.0, sourc resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.6.1.tgz#74722af32e9614e9c287a8d0bbde48b5e2f1a263" integrity sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g== +source-map@^0.5.7: + version "0.5.7" + resolved "https://registry.yarnpkg.com/source-map/-/source-map-0.5.7.tgz#8a039d2d1021d22d1ea14c80d8ea468ba2ef3fcc" + integrity sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ== + space-separated-tokens@^2.0.0: version "2.0.1" resolved "https://registry.yarnpkg.com/space-separated-tokens/-/space-separated-tokens-2.0.1.tgz#43193cec4fb858a2ce934b7f98b7f2c18107098b" @@ -7941,6 +8298,11 @@ stylehacks@^5.1.0: browserslist "^4.16.6" postcss-selector-parser "^6.0.4" +stylis@4.0.13: + version "4.0.13" + resolved "https://registry.yarnpkg.com/stylis/-/stylis-4.0.13.tgz#f5db332e376d13cc84ecfe5dace9a2a51d954c91" + integrity sha512-xGPXiFVl4YED9Jh7Euv2V220mriG9u4B2TA6Ybjc1catrstKD2PpIdU3U0RKpkVBC2EhmL/F0sPCr9vrFTNRag== + superjson@^1.10.0: version "1.10.0" resolved "https://registry.yarnpkg.com/superjson/-/superjson-1.10.0.tgz#279362e6c1789b0b6bdfa280e82ee43d0e0fa514" @@ -8134,6 +8496,11 @@ ts-node@^10.9.1: v8-compile-cache-lib "^3.0.1" yn "3.1.1" +ts.cryptojs256@^1.0.1: + version "1.0.1" + resolved "https://registry.yarnpkg.com/ts.cryptojs256/-/ts.cryptojs256-1.0.1.tgz#56a3d3a3bc022ad358b3e62cd8dae3224d9384f1" + integrity sha512-9XtEgRVOZBCdpPcCEhfvv9I2AVXdvfI81I/KpFM0wEfbq5JVHlXH7bfIjGQmYrIHGiBEHKsIaStQ87k926J7dA== + tsconfig-paths@^3.14.1: version "3.14.1" resolved "https://registry.yarnpkg.com/tsconfig-paths/-/tsconfig-paths-3.14.1.tgz#ba0734599e8ea36c862798e920bcf163277b137a" From 564abeb0d76b7117032dd157f9f7b28c0b2a73da Mon Sep 17 00:00:00 2001 From: louis Date: Tue, 25 Oct 2022 00:15:05 +0200 Subject: [PATCH 02/31] =?UTF-8?q?=F0=9F=92=84=20Add=20ThemeProvider=20with?= =?UTF-8?q?=20several=20overrides?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/App.tsx | 71 ++++++------ src/index.css | 14 --- src/theme/GlobalStyles.tsx | 54 +++++++++ src/theme/ThemeProvider.tsx | 218 ++++++++++++++++++++++++++++++++++++ src/theme/customShadows.ts | 53 +++++++++ src/theme/declaration.d.ts | 15 +++ src/theme/palette.ts | 110 ++++++++++++++++++ src/theme/shadows.ts | 38 +++++++ src/theme/typography.ts | 107 ++++++++++++++++++ src/views/LoginView.tsx | 6 +- 10 files changed, 639 insertions(+), 47 deletions(-) delete mode 100644 src/index.css create mode 100644 src/theme/GlobalStyles.tsx create mode 100644 src/theme/ThemeProvider.tsx create mode 100644 src/theme/customShadows.ts create mode 100644 src/theme/declaration.d.ts create mode 100644 src/theme/palette.ts create mode 100644 src/theme/shadows.ts create mode 100644 src/theme/typography.ts diff --git a/src/App.tsx b/src/App.tsx index ce28f495..b9dd7079 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -10,8 +10,9 @@ import TickerView from './views/TickerView' import UsersView from './views/UsersView' import ProtectedRoute from './components/ProtectedRoute' import NotFoundView from './views/NotFoundView' -import '../leaflet.config.js' import { FeatureProvider } from './components/useFeature' +import ThemeProvider from './theme/ThemeProvider' +import '../leaflet.config.js' const App: FC = () => { const queryClient = new QueryClient({ @@ -19,37 +20,43 @@ const App: FC = () => { }) return ( - - - - - - } role="user" />} - path="/" - /> - } role="user" />} - path="/ticker/:tickerId" - /> - } role="admin" />} - path="/users" - /> - } role="admin" /> - } - path="/settings" - /> - } path="/login" /> - } path="*" /> - - - - - - + + + + + + + } role="user" />} + path="/" + /> + } role="user" /> + } + path="/ticker/:tickerId" + /> + } role="admin" /> + } + path="/users" + /> + } role="admin" /> + } + path="/settings" + /> + } path="/login" /> + } path="*" /> + + + + + + + ) } diff --git a/src/index.css b/src/index.css deleted file mode 100644 index 957354ec..00000000 --- a/src/index.css +++ /dev/null @@ -1,14 +0,0 @@ -.ui.modal > .close { - top: 0.5rem; - right: 0.5rem; - color: #000; -} - -.app { - margin-top: 50px; -} - -.leaflet-container { - height: 400px; - z-index: 1; -} diff --git a/src/theme/GlobalStyles.tsx b/src/theme/GlobalStyles.tsx new file mode 100644 index 00000000..b327d61a --- /dev/null +++ b/src/theme/GlobalStyles.tsx @@ -0,0 +1,54 @@ +import React, { FC } from 'react' +import { GlobalStyles as MUIGlobalStyles } from '@mui/material' + +const GlobalStyles: FC = () => { + return ( + + ) +} + +export default GlobalStyles diff --git a/src/theme/ThemeProvider.tsx b/src/theme/ThemeProvider.tsx new file mode 100644 index 00000000..5e885753 --- /dev/null +++ b/src/theme/ThemeProvider.tsx @@ -0,0 +1,218 @@ +import React, { FC, ReactNode } from 'react' +import { + ThemeProvider as MUIThemeProvider, + createTheme, + StyledEngineProvider, + CssBaseline, + alpha, +} from '@mui/material' +import GlobalStyles from './GlobalStyles' +import palette from './palette' +import shadows from './shadows' +import typography from './typography' +import customShadows from './customShadows' + +const theme = createTheme({ + palette: palette, + shadows: shadows, + typography: typography, + customShadows: customShadows, +}) +theme.components = { + MuiAutocomplete: { + styleOverrides: { + paper: { + boxShadow: customShadows.z20, + }, + }, + }, + MuiBackdrop: { + styleOverrides: { + root: { + backgroundColor: alpha(palette.grey[800], 0.8), + }, + invisible: { + background: 'transparent', + }, + }, + }, + MuiButton: { + styleOverrides: { + root: { + '&:hover': { + boxShadow: 'none', + }, + }, + sizeLarge: { + height: 48, + }, + containedInherit: { + color: palette.grey[800], + boxShadow: customShadows.z8, + '&:hover': { + backgroundColor: palette.grey[400], + }, + }, + containedPrimary: { + boxShadow: customShadows.primary, + }, + containedSecondary: { + boxShadow: customShadows.secondary, + }, + outlinedInherit: { + border: `1px solid ${alpha(palette.grey[500], 0.32)}`, + '&:hover': { + backgroundColor: palette.action.hover, + }, + }, + textInherit: { + '&:hover': { + backgroundColor: palette.action.hover, + }, + }, + }, + }, + MuiCard: { + styleOverrides: { + root: { + boxShadow: theme.customShadows.card, + borderRadius: Number(theme.shape.borderRadius) * 2, + position: 'relative', + zIndex: 0, // Fix Safari overflow: hidden with border radius + }, + }, + }, + MuiCardHeader: { + defaultProps: { + titleTypographyProps: { variant: 'h6' }, + subheaderTypographyProps: { variant: 'body2' }, + }, + styleOverrides: { + root: { + padding: theme.spacing(3, 3, 0), + }, + }, + }, + MuiCardContent: { + styleOverrides: { + root: { + padding: theme.spacing(3), + }, + }, + }, + MuiInputBase: { + styleOverrides: { + root: { + '&.Mui-disabled': { + '& svg': { color: palette.text.disabled }, + }, + }, + input: { + '&::placeholder': { + opacity: 1, + color: palette.text.disabled, + }, + }, + }, + }, + MuiInput: { + styleOverrides: { + underline: { + '&:before': { + borderBottomColor: alpha(palette.grey[500], 0.56), + }, + }, + }, + }, + MuiFilledInput: { + styleOverrides: { + root: { + backgroundColor: alpha(palette.grey[500], 0.12), + '&:hover': { + backgroundColor: alpha(palette.grey[500], 0.16), + }, + '&.Mui-focused': { + backgroundColor: palette.action.focus, + }, + '&.Mui-disabled': { + backgroundColor: palette.action.disabledBackground, + }, + }, + underline: { + '&:before': { + borderBottomColor: alpha(palette.grey[500], 0.56), + }, + }, + }, + }, + MuiOutlinedInput: { + styleOverrides: { + root: { + '& .MuiOutlinedInput-notchedOutline': { + borderColor: alpha(palette.grey[500], 0.32), + }, + '&.Mui-disabled': { + '& .MuiOutlinedInput-notchedOutline': { + borderColor: palette.action.disabledBackground, + }, + }, + }, + }, + }, + MuiPaper: { + defaultProps: { + elevation: 0, + }, + styleOverrides: { + root: { + backgroundImage: 'none', + }, + }, + }, + MuiTableCell: { + styleOverrides: { + head: { + color: palette.text.secondary, + backgroundColor: palette.background.neutral, + }, + }, + }, + MuiTooltip: { + styleOverrides: { + tooltip: { + backgroundColor: palette.grey[800], + }, + arrow: { + color: palette.grey[800], + }, + }, + }, + MuiTypography: { + styleOverrides: { + paragraph: { + marginBottom: theme.spacing(2), + }, + gutterBottom: { + marginBottom: theme.spacing(1), + }, + }, + }, +} + +interface Props { + children: ReactNode +} + +const ThemeProvider: FC = ({ children }) => { + return ( + + + + + {children} + + + ) +} + +export default ThemeProvider diff --git a/src/theme/customShadows.ts b/src/theme/customShadows.ts new file mode 100644 index 00000000..6106f3a8 --- /dev/null +++ b/src/theme/customShadows.ts @@ -0,0 +1,53 @@ +import { alpha } from '@mui/material/styles' +import palette from './palette' + +const color = palette.grey[500] +const transparent = alpha(color, 0.16) + +export interface CustomShadows { + z1: string + z4: string + z8: string + z12: string + z16: string + z20: string + z24: string + primary: string + info: string + secondary: string + success: string + warning: string + error: string + card: string + dialog: string + dropdown: string +} + +const customShadows: CustomShadows = { + z1: `0 1px 2px 0 ${transparent}`, + z4: `0 4px 8px 0 ${transparent}`, + z8: `0 8px 16px 0 ${transparent}`, + z12: `0 12px 24px -4px ${transparent}`, + z16: `0 16px 32px -4px ${transparent}`, + z20: `0 20px 40px -4px ${transparent}`, + z24: `0 24px 48px 0 ${transparent}`, + // + primary: `0 8px 16px 0 ${alpha(palette.primary.main, 0.24)}`, + info: `0 8px 16px 0 ${alpha(palette.info.main, 0.24)}`, + secondary: `0 8px 16px 0 ${alpha(palette.secondary.main, 0.24)}`, + success: `0 8px 16px 0 ${alpha(palette.success.main, 0.24)}`, + warning: `0 8px 16px 0 ${alpha(palette.warning.main, 0.24)}`, + error: `0 8px 16px 0 ${alpha(palette.error.main, 0.24)}`, + // + card: `0 0 2px 0 ${alpha(color, 0.2)}, 0 12px 24px -4px ${alpha( + color, + 0.12 + )}`, + dialog: `-40px 40px 80px -8px ${alpha(color, 0.24)}`, + dropdown: `0 0 2px 0 ${alpha(color, 0.24)}, -20px 20px 40px -4px ${alpha( + color, + 0.24 + )}`, +} + +export default customShadows diff --git a/src/theme/declaration.d.ts b/src/theme/declaration.d.ts new file mode 100644 index 00000000..0153bfd9 --- /dev/null +++ b/src/theme/declaration.d.ts @@ -0,0 +1,15 @@ +import { CustomShadows } from './customShadows' + +declare module '@mui/material/styles' { + interface Theme { + customShadows: CustomShadows + } + + interface ThemeOptions { + customShadows: CustomShadows + } + + interface TypeBackground { + neutral: string + } +} diff --git a/src/theme/palette.ts b/src/theme/palette.ts new file mode 100644 index 00000000..a7e79256 --- /dev/null +++ b/src/theme/palette.ts @@ -0,0 +1,110 @@ +import { Color } from '@mui/material' +import { alpha, PaletteColor } from '@mui/material/styles' + +const grey: Color = { + 50: '', + 100: '#F9FAFB', + 200: '#F4F6F8', + 300: '#DFE3E8', + 400: '#C4CDD5', + 500: '#919EAB', + 600: '#637381', + 700: '#454F5B', + 800: '#212B36', + 900: '#161C24', + A100: '', + A200: '', + A400: '', + A700: '', +} + +const primary: PaletteColor = { + //lighter: '#D1E9FC', + light: '#76B0F1', + main: '#2065D1', + dark: '#103996', + //darker: '#061B64', + contrastText: '#fff', +} + +const secondary: PaletteColor = { + //lighter: '#D6E4FF', + light: '#84A9FF', + main: '#3366FF', + dark: '#1939B7', + //darker: '#091A7A', + contrastText: '#fff', +} + +const info: PaletteColor = { + //lighter: '#D0F2FF', + light: '#74CAFF', + main: '#1890FF', + dark: '#0C53B7', + //darker: '#04297A', + contrastText: '#fff', +} + +const success: PaletteColor = { + //lighter: '#E9FCD4', + light: '#AAF27F', + main: '#54D62C', + dark: '#229A16', + //darker: '#08660D', + contrastText: grey[800], +} + +const warning: PaletteColor = { + //lighter: '#FFF7CD', + light: '#FFE16A', + main: '#FFC107', + dark: '#B78103', + //darker: '#7A4F01', + contrastText: grey[800], +} + +const error: PaletteColor = { + //lighter: '#FFE7D9', + light: '#FFA48D', + main: '#FF4842', + dark: '#B72136', + //darker: '#7A0C2E', + contrastText: '#fff', +} + +const palette = { + common: { black: '#000', white: '#fff' }, + primary: primary, + secondary: secondary, + info: info, + success: success, + warning: warning, + error: error, + grey: grey, + divider: alpha(grey[500], 0.24), + text: { + primary: grey[800], + secondary: grey[600], + disabled: grey[500], + }, + background: { + paper: '#fff', + default: grey[100], + neutral: grey[200], + }, + action: { + activatedOpacity: 0, + active: grey[600], + disabled: alpha(grey[500], 0.8), + disabledBackground: alpha(grey[500], 0.24), + disabledOpacity: 0.48, + focus: alpha(grey[500], 0.24), + focusOpacity: 0, + hover: alpha(grey[500], 0.08), + hoverOpacity: 0.08, + selected: alpha(grey[500], 0.16), + selectedOpacity: 0, + }, +} + +export default palette diff --git a/src/theme/shadows.ts b/src/theme/shadows.ts new file mode 100644 index 00000000..b4bf7633 --- /dev/null +++ b/src/theme/shadows.ts @@ -0,0 +1,38 @@ +import { alpha, Shadows } from '@mui/material/styles' +import palette from './palette' + +const color = palette.grey[500] + +const transparent1 = alpha(color, 0.2) +const transparent2 = alpha(color, 0.14) +const transparent3 = alpha(color, 0.12) + +const shadows: Shadows = [ + 'none', + `0px 2px 1px -1px ${transparent1},0px 1px 1px 0px ${transparent2},0px 1px 3px 0px ${transparent3}`, + `0px 3px 1px -2px ${transparent1},0px 2px 2px 0px ${transparent2},0px 1px 5px 0px ${transparent3}`, + `0px 3px 3px -2px ${transparent1},0px 3px 4px 0px ${transparent2},0px 1px 8px 0px ${transparent3}`, + `0px 2px 4px -1px ${transparent1},0px 4px 5px 0px ${transparent2},0px 1px 10px 0px ${transparent3}`, + `0px 3px 5px -1px ${transparent1},0px 5px 8px 0px ${transparent2},0px 1px 14px 0px ${transparent3}`, + `0px 3px 5px -1px ${transparent1},0px 6px 10px 0px ${transparent2},0px 1px 18px 0px ${transparent3}`, + `0px 4px 5px -2px ${transparent1},0px 7px 10px 1px ${transparent2},0px 2px 16px 1px ${transparent3}`, + `0px 5px 5px -3px ${transparent1},0px 8px 10px 1px ${transparent2},0px 3px 14px 2px ${transparent3}`, + `0px 5px 6px -3px ${transparent1},0px 9px 12px 1px ${transparent2},0px 3px 16px 2px ${transparent3}`, + `0px 6px 6px -3px ${transparent1},0px 10px 14px 1px ${transparent2},0px 4px 18px 3px ${transparent3}`, + `0px 6px 7px -4px ${transparent1},0px 11px 15px 1px ${transparent2},0px 4px 20px 3px ${transparent3}`, + `0px 7px 8px -4px ${transparent1},0px 12px 17px 2px ${transparent2},0px 5px 22px 4px ${transparent3}`, + `0px 7px 8px -4px ${transparent1},0px 13px 19px 2px ${transparent2},0px 5px 24px 4px ${transparent3}`, + `0px 7px 9px -4px ${transparent1},0px 14px 21px 2px ${transparent2},0px 5px 26px 4px ${transparent3}`, + `0px 8px 9px -5px ${transparent1},0px 15px 22px 2px ${transparent2},0px 6px 28px 5px ${transparent3}`, + `0px 8px 10px -5px ${transparent1},0px 16px 24px 2px ${transparent2},0px 6px 30px 5px ${transparent3}`, + `0px 8px 11px -5px ${transparent1},0px 17px 26px 2px ${transparent2},0px 6px 32px 5px ${transparent3}`, + `0px 9px 11px -5px ${transparent1},0px 18px 28px 2px ${transparent2},0px 7px 34px 6px ${transparent3}`, + `0px 9px 12px -6px ${transparent1},0px 19px 29px 2px ${transparent2},0px 7px 36px 6px ${transparent3}`, + `0px 10px 13px -6px ${transparent1},0px 20px 31px 3px ${transparent2},0px 8px 38px 7px ${transparent3}`, + `0px 10px 13px -6px ${transparent1},0px 21px 33px 3px ${transparent2},0px 8px 40px 7px ${transparent3}`, + `0px 10px 14px -6px ${transparent1},0px 22px 35px 3px ${transparent2},0px 8px 42px 7px ${transparent3}`, + `0px 11px 14px -7px ${transparent1},0px 23px 36px 3px ${transparent2},0px 9px 44px 8px ${transparent3}`, + `0px 11px 15px -7px ${transparent1},0px 24px 38px 3px ${transparent2},0px 9px 46px 8px ${transparent3}`, +] + +export default shadows diff --git a/src/theme/typography.ts b/src/theme/typography.ts new file mode 100644 index 00000000..175ca43a --- /dev/null +++ b/src/theme/typography.ts @@ -0,0 +1,107 @@ +import { Typography } from '@mui/material/styles/createTypography' + +export function remToPx(value: number) { + return Math.round(value * 16) +} + +export function pxToRem(value: number) { + return `${value / 16}rem` +} + +export function responsiveFontSizes({ sm, md, lg }) { + return { + '@media (min-width:600px)': { + fontSize: pxToRem(sm), + }, + '@media (min-width:900px)': { + fontSize: pxToRem(md), + }, + '@media (min-width:1200px)': { + fontSize: pxToRem(lg), + }, + } +} + +// ---------------------------------------------------------------------- + +const FONT_PRIMARY = 'Roboto, sans-serif' // Google Font +// const FONT_SECONDARY = 'CircularStd, sans-serif'; // Local Font + +const typography: Typography = { + fontFamily: FONT_PRIMARY, + fontWeightRegular: 400, + fontWeightMedium: 600, + fontWeightBold: 700, + h1: { + fontWeight: 800, + lineHeight: 80 / 64, + fontSize: pxToRem(40), + ...responsiveFontSizes({ sm: 52, md: 58, lg: 64 }), + }, + h2: { + fontWeight: 800, + lineHeight: 64 / 48, + fontSize: pxToRem(32), + ...responsiveFontSizes({ sm: 40, md: 44, lg: 48 }), + }, + h3: { + fontWeight: 700, + lineHeight: 1.5, + fontSize: pxToRem(24), + ...responsiveFontSizes({ sm: 26, md: 30, lg: 32 }), + }, + h4: { + fontWeight: 700, + lineHeight: 1.5, + fontSize: pxToRem(20), + ...responsiveFontSizes({ sm: 20, md: 24, lg: 24 }), + }, + h5: { + fontWeight: 700, + lineHeight: 1.5, + fontSize: pxToRem(18), + ...responsiveFontSizes({ sm: 19, md: 20, lg: 20 }), + }, + h6: { + fontWeight: 700, + lineHeight: 28 / 18, + fontSize: pxToRem(17), + ...responsiveFontSizes({ sm: 18, md: 18, lg: 18 }), + }, + subtitle1: { + fontWeight: 600, + lineHeight: 1.5, + fontSize: pxToRem(16), + }, + subtitle2: { + fontWeight: 600, + lineHeight: 22 / 14, + fontSize: pxToRem(14), + }, + body1: { + lineHeight: 1.5, + fontSize: pxToRem(16), + }, + body2: { + lineHeight: 22 / 14, + fontSize: pxToRem(14), + }, + caption: { + lineHeight: 1.5, + fontSize: pxToRem(12), + }, + overline: { + fontWeight: 700, + lineHeight: 1.5, + fontSize: pxToRem(12), + textTransform: 'uppercase', + }, + button: { + fontWeight: 700, + lineHeight: 24 / 14, + fontSize: pxToRem(14), + textTransform: 'capitalize', + }, +} + +export default typography diff --git a/src/views/LoginView.tsx b/src/views/LoginView.tsx index 23e3e58f..29de2bcc 100644 --- a/src/views/LoginView.tsx +++ b/src/views/LoginView.tsx @@ -43,7 +43,11 @@ const LoginView: FC = () => { - Systemli Logo + Systemli Logo Ticker Login From 6c896913db4fd2934d8919d2aba481972ba903aa Mon Sep 17 00:00:00 2001 From: louis Date: Tue, 25 Oct 2022 16:33:38 +0200 Subject: [PATCH 03/31] =?UTF-8?q?=F0=9F=92=84=20Rework=20the=20Layout=20an?= =?UTF-8?q?d=20Navigation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/navigation/Clock.tsx | 25 -------- src/components/navigation/Nav.tsx | 25 ++++++++ src/components/navigation/NavItem.tsx | 33 ++++++++++ src/components/navigation/Navigation.tsx | 74 ---------------------- src/components/navigation/UserDropdown.tsx | 51 +++++++++++++++ src/views/Layout.tsx | 53 ++++++++++++++-- 6 files changed, 156 insertions(+), 105 deletions(-) delete mode 100644 src/components/navigation/Clock.tsx create mode 100644 src/components/navigation/Nav.tsx create mode 100644 src/components/navigation/NavItem.tsx delete mode 100644 src/components/navigation/Navigation.tsx create mode 100644 src/components/navigation/UserDropdown.tsx diff --git a/src/components/navigation/Clock.tsx b/src/components/navigation/Clock.tsx deleted file mode 100644 index b572eceb..00000000 --- a/src/components/navigation/Clock.tsx +++ /dev/null @@ -1,25 +0,0 @@ -import React, { FC, useEffect, useState } from 'react' -import Moment from 'react-moment' - -interface Props { - format?: string -} - -const Clock: FC = props => { - const [date, setDate] = useState(new Date()) - - useEffect(() => { - const interval = setInterval(() => { - setDate(new Date()) - }, 1000) - return () => clearInterval(interval) - }, []) - - return ( - - {date} - - ) -} - -export default Clock diff --git a/src/components/navigation/Nav.tsx b/src/components/navigation/Nav.tsx new file mode 100644 index 00000000..c7ea8e45 --- /dev/null +++ b/src/components/navigation/Nav.tsx @@ -0,0 +1,25 @@ +import React, { FC } from 'react' +import { colors, Container } from '@mui/material' + +interface Props { + children: React.ReactNode +} + +const Nav: FC = ({ children }) => { + return ( + + {children} + + ) +} + +export default Nav diff --git a/src/components/navigation/NavItem.tsx b/src/components/navigation/NavItem.tsx new file mode 100644 index 00000000..85ffde9c --- /dev/null +++ b/src/components/navigation/NavItem.tsx @@ -0,0 +1,33 @@ +import { Box, Button, colors } from '@mui/material' +import React, { FC } from 'react' +import { Link } from 'react-router-dom' + +interface Props { + active: boolean + icon: React.ReactNode + title: string + to: string +} + +const NavItem: FC = ({ active, icon, title, to }) => { + return ( + + + + ) +} + +export default NavItem diff --git a/src/components/navigation/Navigation.tsx b/src/components/navigation/Navigation.tsx deleted file mode 100644 index 9f86ec87..00000000 --- a/src/components/navigation/Navigation.tsx +++ /dev/null @@ -1,74 +0,0 @@ -import React, { FC, useCallback } from 'react' -import { Container, Dropdown, Image, Menu } from 'semantic-ui-react' -import Clock from './Clock' -import logo from '../../assets/logo.png' -import { useNavigate, useLocation } from 'react-router-dom' -import useAuth from '../useAuth' - -const Navigation: FC = () => { - const { user, logout } = useAuth() - const navigate = useNavigate() - const location = useLocation() - - const userItem = ( - - - { - logout() - }, [logout])} - > - Logout - - - - ) - - const usersItem = ( - navigate('/users'), [navigate])} - > - Users - - ) - - const settingsItem = ( - navigate('/settings'), [navigate])} - > - Settings - - ) - - return ( - - - - - - navigate('/'), [navigate])} - > - Home - - {user?.roles.includes('admin') ? usersItem : null} - {user?.roles.includes('admin') ? settingsItem : null} - - - - - {user ? userItem : null} - - - - ) -} - -export default Navigation diff --git a/src/components/navigation/UserDropdown.tsx b/src/components/navigation/UserDropdown.tsx new file mode 100644 index 00000000..ff9c0338 --- /dev/null +++ b/src/components/navigation/UserDropdown.tsx @@ -0,0 +1,51 @@ +import React, { FC, useCallback, useState } from 'react' +import { AccountCircle } from '@mui/icons-material' +import { IconButton, Menu, MenuItem } from '@mui/material' +import useAuth from '../useAuth' + +const UserDropdown: FC = () => { + const [anchorEl, setAnchorEl] = useState(null) + const { user, logout } = useAuth() + + const handleMenu = (event: React.MouseEvent) => { + setAnchorEl(event.currentTarget) + } + + const handleClose = () => { + setAnchorEl(null) + } + + const handleLogout = useCallback(() => { + logout() + }, [logout]) + + return ( + <> + + + + + + {user?.email} + + Logout + + + ) +} + +export default UserDropdown diff --git a/src/views/Layout.tsx b/src/views/Layout.tsx index 814a3b26..4b42f5c8 100644 --- a/src/views/Layout.tsx +++ b/src/views/Layout.tsx @@ -1,17 +1,58 @@ import React, { FC } from 'react' -import { Container } from 'semantic-ui-react' -import Navigation from '../components/navigation/Navigation' +import { Box, Container } from '@mui/material' +import Nav from '../components/navigation/Nav' +import NavItem from '../components/navigation/NavItem' +import UserDropdown from '../components/navigation/UserDropdown' +import useAuth from '../components/useAuth' +import { useLocation } from 'react-router-dom' +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' +import { + faGaugeHigh, + faGears, + faUsers, +} from '@fortawesome/free-solid-svg-icons' interface Props { children: React.ReactNode } const Layout: FC = ({ children }) => { + const { user } = useAuth() + const location = useLocation() + return ( - - - {children} - + <> + + {children} + ) } From 5de496a37824f4536445da1311abe4fe78966df1 Mon Sep 17 00:00:00 2001 From: louis Date: Wed, 26 Oct 2022 22:51:38 +0200 Subject: [PATCH 04/31] =?UTF-8?q?=F0=9F=92=84=20Improve=20NavItem=20for=20?= =?UTF-8?q?smaller=20Screens?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/navigation/NavItem.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/components/navigation/NavItem.tsx b/src/components/navigation/NavItem.tsx index 85ffde9c..f38b6a3d 100644 --- a/src/components/navigation/NavItem.tsx +++ b/src/components/navigation/NavItem.tsx @@ -16,15 +16,15 @@ const NavItem: FC = ({ active, icon, title, to }) => { size="large" sx={{ mx: 1, - px: 4, + px: { xs: 0, md: 4 }, backgroundColor: active ? colors.blue[50] : colors.grey[100], borderRadius: 4, color: colors.common['black'], fontSize: '1rem', }} > - {icon} - {title} + {icon} + {title} ) From cdd91d5a705e696919030b470d853f9978a07edc Mon Sep 17 00:00:00 2001 From: louis Date: Wed, 26 Oct 2022 22:51:56 +0200 Subject: [PATCH 05/31] =?UTF-8?q?=F0=9F=92=84=20Add=20Loader=20Component?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/Loader.tsx | 15 +++++++++++++++ 1 file changed, 15 insertions(+) create mode 100644 src/components/Loader.tsx diff --git a/src/components/Loader.tsx b/src/components/Loader.tsx new file mode 100644 index 00000000..c4b09310 --- /dev/null +++ b/src/components/Loader.tsx @@ -0,0 +1,15 @@ +import { CircularProgress, Stack, Typography } from '@mui/material' +import React, { FC } from 'react' + +const Loader: FC = () => { + return ( + + + + Loading + + + ) +} + +export default Loader From 1a144c028ce1021780989412130953e6383a749e Mon Sep 17 00:00:00 2001 From: louis Date: Wed, 26 Oct 2022 23:29:23 +0200 Subject: [PATCH 06/31] =?UTF-8?q?=F0=9F=92=84=20Use=20MUI=20for=20UsersVie?= =?UTF-8?q?w?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/ticker/TickersDropdown.tsx | 103 +++++++----- src/components/user/UserForm.tsx | 187 ++++++++++++---------- src/components/user/UserList.tsx | 71 ++++---- src/components/user/UserListItem.tsx | 125 +++++++++++---- src/components/user/UserListItems.tsx | 6 +- src/components/user/UserModalDelete.tsx | 70 +++++--- src/components/user/UserModalForm.tsx | 86 +++++----- src/views/UsersView.test.tsx | 56 +++++++ src/views/UsersView.tsx | 51 ++++-- 9 files changed, 485 insertions(+), 270 deletions(-) create mode 100644 src/views/UsersView.test.tsx diff --git a/src/components/ticker/TickersDropdown.tsx b/src/components/ticker/TickersDropdown.tsx index 1966594f..95a7bc4c 100644 --- a/src/components/ticker/TickersDropdown.tsx +++ b/src/components/ticker/TickersDropdown.tsx @@ -1,59 +1,84 @@ -import React, { - FC, - SyntheticEvent, - useCallback, - useEffect, - useState, -} from 'react' -import { Dropdown, DropdownItemProps, DropdownProps } from 'semantic-ui-react' -import { useTickerApi } from '../../api/Ticker' +import React, { FC, useEffect, useState } from 'react' +import { + Box, + Chip, + FormControl, + InputLabel, + MenuItem, + OutlinedInput, + Select, + SelectChangeEvent, + SxProps, +} from '@mui/material' +import { Ticker, useTickerApi } from '../../api/Ticker' import useAuth from '../useAuth' interface Props { name: string - defaultValue?: Array - onChange: (event: SyntheticEvent, input: DropdownProps) => void + defaultValue: Array + onChange: (tickers: number[]) => void + sx?: SxProps } -const TickersDropdown: FC = props => { - const [options, setOptions] = useState([]) +const TickersDropdown: FC = ({ name, defaultValue, onChange, sx }) => { + const [options, setOptions] = useState>([]) + const [tickers, setTickers] = useState>(defaultValue) const { token } = useAuth() const { getTickers } = useTickerApi(token) - const onChange = useCallback( - (event: SyntheticEvent, input: DropdownProps) => { - props.onChange(event, input) - }, - [props] - ) + const handleChange = (event: SelectChangeEvent) => { + if (typeof event.target.value !== 'string') { + setTickers(event.target.value) + onChange(event.target.value) + } + } useEffect(() => { getTickers() .then(response => response.data.tickers) .then(tickers => { - const availableOptions: DropdownItemProps[] = [] - tickers.map(ticker => { - availableOptions.push({ - key: ticker.id, - text: ticker.title, - value: ticker.id, - }) - }) - setOptions(availableOptions) + setOptions(tickers) }) - }, [getTickers]) + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []) + + const renderValue = (selected: number[]) => { + const selectedTickers = options.filter(ticker => { + return selected.includes(ticker.id) + }) + + return ( + + {selectedTickers.map(ticker => ( + + ))} + + ) + } return ( - + + Tickers + + ) } diff --git a/src/components/user/UserForm.tsx b/src/components/user/UserForm.tsx index c630f15b..c3d2cc29 100644 --- a/src/components/user/UserForm.tsx +++ b/src/components/user/UserForm.tsx @@ -1,25 +1,21 @@ -import React, { - ChangeEvent, - FC, - FormEvent, - SyntheticEvent, - useCallback, - useEffect, -} from 'react' -import { - CheckboxProps, - DropdownProps, - Form, - Header, - InputOnChangeData, -} from 'semantic-ui-react' +import React, { FC, useEffect } from 'react' import { SubmitHandler, useForm } from 'react-hook-form' import { User, useUserApi } from '../../api/User' import { useQueryClient } from '@tanstack/react-query' -import TickersDropdown from '../ticker/TickersDropdown' import useAuth from '../useAuth' +import { + FormControlLabel, + Checkbox, + FormGroup, + TextField, + Typography, + Grid, + Divider, +} from '@mui/material' +import TickersDropdown from '../ticker/TickersDropdown' interface Props { + id: string user?: User callback: () => void } @@ -32,35 +28,25 @@ interface FormValues { tickers: Array } -const UserForm: FC = props => { - const user = props.user +const UserForm: FC = ({ id, user, callback }) => { const { token } = useAuth() const { postUser, putUser } = useUserApi(token) - const { handleSubmit, register, setValue } = useForm({ + const { + formState: { errors }, + handleSubmit, + register, + setValue, + watch, + } = useForm({ defaultValues: { email: user?.email, is_super_admin: user?.is_super_admin, + tickers: user?.tickers, }, }) const queryClient = useQueryClient() - - const onChange = useCallback( - ( - e: ChangeEvent | FormEvent | SyntheticEvent, - { - name, - value, - checked, - }: InputOnChangeData | CheckboxProps | DropdownProps - ) => { - if (checked !== undefined) { - setValue(name, checked) - } else { - setValue(name, value) - } - }, - [setValue] - ) + const isSuperAdminChecked = watch('is_super_admin') + const password = watch('password', '') const onSubmit: SubmitHandler = data => { const formData = { @@ -73,67 +59,98 @@ const UserForm: FC = props => { if (user) { putUser(formData, user).finally(() => { queryClient.invalidateQueries(['users']) - props.callback() + callback() }) } else { postUser(formData).finally(() => { queryClient.invalidateQueries(['users']) - props.callback() + callback() }) } } useEffect(() => { - register('email') - register('is_super_admin') - register('password') - register('password_validate') register('tickers') }) return ( -
- - - - - - - - - {!user?.is_super_admin ? ( - <> -
Permissions
- - - ) : null} - +
+ + + + + + + + } + label="Super Admin" + /> + + + + + + + value === password || 'The passwords do not match', + })} + helperText={errors.password_validate?.message} + label="Repeat Password" + name="password_validate" + required={!user} + type="password" + /> + + + {!isSuperAdminChecked ? ( + + + + Permissions + + { + setValue('tickers', tickers) + }} + sx={{ width: '100%' }} + /> + + ) : null} + +
) } diff --git a/src/components/user/UserList.tsx b/src/components/user/UserList.tsx index 756c2529..7bca5acf 100644 --- a/src/components/user/UserList.tsx +++ b/src/components/user/UserList.tsx @@ -1,11 +1,17 @@ import React, { FC } from 'react' -import { Button, Dimmer, Loader, Table } from 'semantic-ui-react' import { useQuery } from '@tanstack/react-query' import UserListItems from './UserListItems' -import UserModalForm from './UserModalForm' import useAuth from '../useAuth' import { useUserApi } from '../../api/User' import ErrorView from '../../views/ErrorView' +import { + Table, + TableCell, + TableContainer, + TableHead, + TableRow, +} from '@mui/material' +import Loader from '../Loader' const UserList: FC = () => { const { token } = useAuth() @@ -13,11 +19,7 @@ const UserList: FC = () => { const { isLoading, error, data } = useQuery(['users'], getUsers) if (isLoading) { - return ( - - Loading - - ) + return } if (error || data === undefined || data.status === 'error') { @@ -27,41 +29,32 @@ const UserList: FC = () => { const users = data.data.users return ( - + - - - ID - Admin - Email - Creation Time - - - + + + + ID + + + Admin + + + E-Mail + + + Creation Time + + + + - - - - - - - - - } - /> - - -
-
+ ) } diff --git a/src/components/user/UserListItem.tsx b/src/components/user/UserListItem.tsx index e2a621ac..d7cf4799 100644 --- a/src/components/user/UserListItem.tsx +++ b/src/components/user/UserListItem.tsx @@ -1,6 +1,22 @@ -import React, { FC } from 'react' +import React, { FC, useState } from 'react' +import { + faCheck, + faPencil, + faTrash, + faXmark, +} from '@fortawesome/free-solid-svg-icons' +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' +import { MoreVert } from '@mui/icons-material' +import { + colors, + IconButton, + MenuItem, + Popover, + TableCell, + TableRow, + Typography, +} from '@mui/material' import Moment from 'react-moment' -import { Button, Icon, Label, Table } from 'semantic-ui-react' import { User } from '../../api/User' import UserModalDelete from './UserModalDelete' import UserModalForm from './UserModalForm' @@ -9,35 +25,88 @@ interface Props { user: User } -const UserListItem: FC = props => { - const user = props.user +const UserListItem: FC = ({ user }) => { + const [formModalOpen, setFormModalOpen] = useState(false) + const [deleteModalOpen, setDeleteModalOpen] = useState(false) + const [anchorEl, setAnchorEl] = useState(null) + + const handleMenu = (event: React.MouseEvent) => { + setAnchorEl(event.currentTarget) + } + + const handleClose = () => { + setAnchorEl(null) + } return ( - - {user.id} - - - - {user.email} - + + + {user.id} + + + {user.is_super_admin ? ( + + ) : ( + + )} + + {user.email} + - - - - } - user={user} - /> - } - user={user} - /> - - - + + + + + + + { + handleClose() + setFormModalOpen(true) + }} + > + + Edit + + { + handleClose() + setDeleteModalOpen(true) + }} + sx={{ color: colors.red[400] }} + > + + Delete + + + setFormModalOpen(false)} + open={formModalOpen} + user={user} + /> + setDeleteModalOpen(false)} + open={deleteModalOpen} + user={user} + /> + + ) } diff --git a/src/components/user/UserListItems.tsx b/src/components/user/UserListItems.tsx index e49cff05..0912132a 100644 --- a/src/components/user/UserListItems.tsx +++ b/src/components/user/UserListItems.tsx @@ -1,5 +1,5 @@ import React, { FC } from 'react' -import { Table } from 'semantic-ui-react' +import { TableBody } from '@mui/material' import { User } from '../../api/User' import UserListItem from './UserListItem' @@ -9,11 +9,11 @@ interface Props { const UserListItems: FC = ({ users }: Props) => { return ( - + {users.map(user => ( ))} - + ) } diff --git a/src/components/user/UserModalDelete.tsx b/src/components/user/UserModalDelete.tsx index fa43cc2e..bd939b70 100644 --- a/src/components/user/UserModalDelete.tsx +++ b/src/components/user/UserModalDelete.tsx @@ -1,46 +1,66 @@ -import React, { FC, useCallback, useState } from 'react' +import React, { FC, useCallback } from 'react' import { useQueryClient } from '@tanstack/react-query' -import { Confirm } from 'semantic-ui-react' import { User, useUserApi } from '../../api/User' import useAuth from '../useAuth' +import { + Button, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + IconButton, + Stack, +} from '@mui/material' +import { Close } from '@mui/icons-material' interface Props { + onClose: () => void + open: boolean user: User - trigger: React.ReactNode } -const UserModalDelete: FC = props => { +const UserModalDelete: FC = ({ onClose, open, user }) => { const { token } = useAuth() const { deleteUser } = useUserApi(token) - const [open, setOpen] = useState(false) const queryClient = useQueryClient() - const user = props.user - const handleCancel = useCallback(() => { - setOpen(false) - }, []) + const handleClose = () => { + onClose() + } - const handleConfirm = useCallback(() => { + const handleDelete = useCallback(() => { deleteUser(user).finally(() => { queryClient.invalidateQueries(['users']) - setOpen(false) + onClose() }) - }, [deleteUser, user, queryClient]) - - const handleOpen = useCallback(() => { - setOpen(true) - }, []) + }, [deleteUser, user, queryClient, onClose]) return ( - + + + + Delete User + + + + + + + Are you sure to delete the user? This action cannot be undone. + + + + + + ) } diff --git a/src/components/user/UserModalForm.tsx b/src/components/user/UserModalForm.tsx index 5f88a4dd..edc2e72e 100644 --- a/src/components/user/UserModalForm.tsx +++ b/src/components/user/UserModalForm.tsx @@ -1,51 +1,59 @@ -import React, { FC, useCallback, useState } from 'react' -import { Button, Header, Modal } from 'semantic-ui-react' +import React, { FC } from 'react' +import { Close } from '@mui/icons-material' +import { + Button, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + IconButton, + Stack, +} from '@mui/material' import { User } from '../../api/User' import UserForm from './UserForm' interface Props { + onClose: () => void + open: boolean user?: User - trigger: React.ReactNode } -const UserModalForm: FC = props => { - const [open, setOpen] = useState(false) - - const handleClose = useCallback(() => { - setOpen(false) - }, []) - - const handleOpen = useCallback(() => { - setOpen(true) - }, []) +const UserModalForm: FC = ({ open, onClose, user }) => { + const handleClose = () => { + onClose() + } return ( - -
{props.user ? 'Edit User' : 'Create User'}
- - - - - - + + + ) } diff --git a/src/views/UsersView.test.tsx b/src/views/UsersView.test.tsx new file mode 100644 index 00000000..d74d8868 --- /dev/null +++ b/src/views/UsersView.test.tsx @@ -0,0 +1,56 @@ +import React from 'react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { render, screen } from '@testing-library/react' +import { MemoryRouter } from 'react-router' +import { AuthProvider } from '../components/useAuth' +import UsersView from './UsersView' +import sign from 'jwt-encode' +import fetchMock from 'jest-fetch-mock' + +describe('UsersView', function () { + const jwt = sign( + { id: 1, email: 'louis@systemli.org', roles: ['admin', 'user'] }, + 'secret' + ) + + function setup() { + const client = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }) + return render( + + + + + + + + ) + } + + test('renders list', async function () { + jest.spyOn(window.localStorage.__proto__, 'getItem').mockReturnValue(jwt) + fetchMock.mockResponseOnce( + JSON.stringify({ + data: { + users: [ + { + id: 1, + creation_date: new Date(), + email: 'admin@systemli.org', + is_super_admin: true, + }, + ], + }, + }) + ) + setup() + + expect(screen.getByText(/loading/i)).toBeInTheDocument() + expect(await screen.findByText('admin@systemli.org')).toBeInTheDocument() + }) +}) diff --git a/src/views/UsersView.tsx b/src/views/UsersView.tsx index 6baab93c..5315663d 100644 --- a/src/views/UsersView.tsx +++ b/src/views/UsersView.tsx @@ -1,22 +1,49 @@ -import React, { FC } from 'react' -import { Grid, Header } from 'semantic-ui-react' +import { faPlus } from '@fortawesome/free-solid-svg-icons' +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' +import { Button, Card, Grid, Stack, Typography } from '@mui/material' +import React, { FC, useState } from 'react' import UserList from '../components/user/UserList' +import UserModalForm from '../components/user/UserModalForm' import Layout from './Layout' const UsersView: FC = () => { + const [formModalOpen, setFormModalOpen] = useState(false) + return ( - - - -
Users
-
-
- - + + + + + Users + + + { + setFormModalOpen(false) + }} + open={formModalOpen} + /> + + + + - - + +
) From 640cba6e72823a3bd4d9ec71347a7a12fdc5280c Mon Sep 17 00:00:00 2001 From: louis Date: Thu, 27 Oct 2022 18:14:23 +0200 Subject: [PATCH 07/31] =?UTF-8?q?=F0=9F=90=9B=20Fix=20types=20in=20typogra?= =?UTF-8?q?phy.ts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/theme/typography.ts | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/src/theme/typography.ts b/src/theme/typography.ts index 175ca43a..8aa25ea5 100644 --- a/src/theme/typography.ts +++ b/src/theme/typography.ts @@ -1,4 +1,4 @@ -import { Typography } from '@mui/material/styles/createTypography' +import { TypographyOptions } from '@mui/material/styles/createTypography' export function remToPx(value: number) { return Math.round(value * 16) @@ -8,7 +8,15 @@ export function pxToRem(value: number) { return `${value / 16}rem` } -export function responsiveFontSizes({ sm, md, lg }) { +export function responsiveFontSizes({ + sm, + md, + lg, +}: { + sm: number + md: number + lg: number +}) { return { '@media (min-width:600px)': { fontSize: pxToRem(sm), @@ -27,7 +35,7 @@ export function responsiveFontSizes({ sm, md, lg }) { const FONT_PRIMARY = 'Roboto, sans-serif' // Google Font // const FONT_SECONDARY = 'CircularStd, sans-serif'; // Local Font -const typography: Typography = { +const typography: TypographyOptions = { fontFamily: FONT_PRIMARY, fontWeightRegular: 400, fontWeightMedium: 600, From 49ac683e195e676f17ad8144257652f87b3d3eac Mon Sep 17 00:00:00 2001 From: louis Date: Thu, 27 Oct 2022 19:14:22 +0200 Subject: [PATCH 08/31] =?UTF-8?q?=E2=9C=85=20Add=20Tests=20for=20UsersView?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/user/UserListItem.tsx | 4 +- src/views/UsersView.test.tsx | 105 ++++++++++++++++++++++++++- 2 files changed, 107 insertions(+), 2 deletions(-) diff --git a/src/components/user/UserListItem.tsx b/src/components/user/UserListItem.tsx index d7cf4799..3c002589 100644 --- a/src/components/user/UserListItem.tsx +++ b/src/components/user/UserListItem.tsx @@ -55,7 +55,7 @@ const UserListItem: FC = ({ user }) => { - + = ({ user }) => { transformOrigin={{ vertical: 'top', horizontal: 'right' }} > { handleClose() setFormModalOpen(true) @@ -85,6 +86,7 @@ const UserListItem: FC = ({ user }) => { Edit { handleClose() setDeleteModalOpen(true) diff --git a/src/views/UsersView.test.tsx b/src/views/UsersView.test.tsx index d74d8868..4c62ab2c 100644 --- a/src/views/UsersView.test.tsx +++ b/src/views/UsersView.test.tsx @@ -6,6 +6,7 @@ import { AuthProvider } from '../components/useAuth' import UsersView from './UsersView' import sign from 'jwt-encode' import fetchMock from 'jest-fetch-mock' +import userEvent from '@testing-library/user-event' describe('UsersView', function () { const jwt = sign( @@ -13,6 +14,11 @@ describe('UsersView', function () { 'secret' ) + beforeEach(() => { + jest.spyOn(window.localStorage.__proto__, 'getItem').mockReturnValue(jwt) + fetchMock.resetMocks() + }) + function setup() { const client = new QueryClient({ defaultOptions: { @@ -33,7 +39,6 @@ describe('UsersView', function () { } test('renders list', async function () { - jest.spyOn(window.localStorage.__proto__, 'getItem').mockReturnValue(jwt) fetchMock.mockResponseOnce( JSON.stringify({ data: { @@ -53,4 +58,102 @@ describe('UsersView', function () { expect(screen.getByText(/loading/i)).toBeInTheDocument() expect(await screen.findByText('admin@systemli.org')).toBeInTheDocument() }) + + test('open new users dialog', async function () { + setup() + fetchMock.mockIf( + /\/v1\/admin\/users/i, + JSON.stringify({ + data: { + users: [], + }, + }) + ) + fetchMock.mockIf( + /\/v1\/admin\/tickers/i, + JSON.stringify({ + data: { + tickers: [], + }, + }) + ) + + const button = await screen.findByRole('button', { name: /new user/i }) + expect(button).toBeInTheDocument() + + await userEvent.click(button) + + const dialogTitle = await screen.findByText(/create user/i) + expect(dialogTitle).toBeInTheDocument() + + const close = screen.getByRole('button', { name: /close/i }) + expect(close).toBeInTheDocument() + + await userEvent.click(close) + + expect(dialogTitle).not.toBeVisible() + }) + + test('open dialog for existing user', async function () { + fetchMock.mockIf( + /\/v1\/admin\/users/i, + JSON.stringify({ + data: { + users: [ + { + id: 1, + creation_date: new Date(), + email: 'admin@systemli.org', + is_super_admin: true, + }, + ], + }, + }) + ) + setup() + + expect(await screen.findByText('admin@systemli.org')).toBeInTheDocument() + + const menuButton = screen.getByTestId('usermenu') + + await userEvent.click(menuButton) + + const editButton = screen.getByTestId('usermenu-edit') + + expect(editButton).toBeVisible() + + await userEvent.click(editButton) + + expect(editButton).not.toBeVisible() + + const editTitle = screen.getByText(/update user/i) + const editClose = screen.getByRole('button', { name: /close/i }) + + expect(editTitle).toBeInTheDocument() + expect(editClose).toBeInTheDocument() + + await userEvent.click(editClose) + + expect(editTitle).not.toBeVisible() + + await userEvent.click(menuButton) + + const deleteButton = screen.getByTestId('usermenu-delete') + + expect(deleteButton).toBeVisible() + + await userEvent.click(deleteButton) + + expect(deleteButton).not.toBeVisible() + + const deleteTitle = screen.getByText(/delete user/i) + const deleteClose = screen.getByRole('button', { name: /close/i }) + + expect(deleteTitle).toBeInTheDocument() + expect(deleteClose).toBeInTheDocument() + + await userEvent.click(deleteClose) + + expect(deleteClose).not.toBeVisible() + }) }) From 724f195bbfb1a199135ab8f6b9da4968774ab88d Mon Sep 17 00:00:00 2001 From: louis Date: Thu, 27 Oct 2022 21:14:12 +0200 Subject: [PATCH 09/31] =?UTF-8?q?=F0=9F=92=84=20Use=20MUI=20for=20Settings?= =?UTF-8?q?View?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../settings/InactiveSettingsCard.tsx | 162 ++++++++------ .../settings/InactiveSettingsForm.tsx | 197 +++++++++--------- .../settings/InactiveSettingsModalForm.tsx | 90 ++++---- .../settings/RefreshIntervalCard.tsx | 83 +++++--- .../settings/RefreshIntervalForm.tsx | 50 +++-- .../settings/RefreshIntervalModalForm.tsx | 90 ++++---- src/views/Layout.tsx | 4 +- src/views/SettingsView.tsx | 37 ++-- src/views/UsersView.tsx | 4 +- 9 files changed, 401 insertions(+), 316 deletions(-) diff --git a/src/components/settings/InactiveSettingsCard.tsx b/src/components/settings/InactiveSettingsCard.tsx index 19efab5d..79400848 100644 --- a/src/components/settings/InactiveSettingsCard.tsx +++ b/src/components/settings/InactiveSettingsCard.tsx @@ -1,21 +1,25 @@ -import React, { FC } from 'react' -import ReactMarkdown from 'react-markdown' +import React, { FC, useState } from 'react' import { useQuery } from '@tanstack/react-query' +import { useSettingsApi } from '../../api/Settings' +import ErrorView from '../../views/ErrorView' +import useAuth from '../useAuth' +import Loader from '../Loader' import { + Box, Button, Card, - Dimmer, - Header, - Icon, - List, - Loader, -} from 'semantic-ui-react' -import { useSettingsApi } from '../../api/Settings' -import ErrorView from '../../views/ErrorView' + CardContent, + Divider, + Grid, + Typography, +} from '@mui/material' +import { Stack } from '@mui/system' +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' +import { faPencil } from '@fortawesome/free-solid-svg-icons' import InactiveSettingsModalForm from './InactiveSettingsModalForm' -import useAuth from '../useAuth' const InactiveSettingsCard: FC = () => { + const [formOpen, setFormOpen] = useState(false) const { token } = useAuth() const { getInactiveSettings } = useSettingsApi(token) const { isLoading, error, data } = useQuery( @@ -23,12 +27,16 @@ const InactiveSettingsCard: FC = () => { getInactiveSettings ) + const handleFormOpen = () => { + setFormOpen(true) + } + + const handleFormClose = () => { + setFormOpen(false) + } + if (isLoading) { - return ( - - Loading - - ) + return } if (error || data === undefined || data.status === 'error') { @@ -39,58 +47,84 @@ const InactiveSettingsCard: FC = () => { return ( - - - - Inactive Settings - - + + + + Inactive Settings + + + + These settings have affect for inactive or non-configured tickers. - - - - - - - Headline - {setting.value.headline} - - - Subheadline - {setting.value.sub_headline} - - - Description - {setting.value.description} - - -
Information
- - - - {setting.value.author} - - - - {setting.value.email} - - - - {setting.value.homepage} - - - - {setting.value.twitter} - - -
-
- + + + + + + + Headline + + {setting.value.headline} + + + + Subheadline + + {setting.value.sub_headline} + + + + Description + + {setting.value.description} + + + + + + Author + + {setting.value.author} + + + + + + Homepage + + {setting.value.homepage} + + + + + + E-Mail + + {setting.value.email} + + + + + + Twitter + + {setting.value.twitter} + + + } /> - +
) } diff --git a/src/components/settings/InactiveSettingsForm.tsx b/src/components/settings/InactiveSettingsForm.tsx index 98c0f1e9..21300109 100644 --- a/src/components/settings/InactiveSettingsForm.tsx +++ b/src/components/settings/InactiveSettingsForm.tsx @@ -1,22 +1,12 @@ -import React, { - ChangeEvent, - FC, - FormEvent, - useCallback, - useEffect, -} from 'react' +import React, { FC } from 'react' import { SubmitHandler, useForm } from 'react-hook-form' import { useQueryClient } from '@tanstack/react-query' -import { - Form, - Header, - InputOnChangeData, - TextAreaProps, -} from 'semantic-ui-react' import { InactiveSetting, Setting, useSettingsApi } from '../../api/Settings' import useAuth from '../useAuth' +import { FormGroup, Grid, TextField } from '@mui/material' interface Props { + name: string setting: Setting callback: () => void } @@ -31,9 +21,8 @@ interface FormValues { twitter: string } -const InactiveSettingsForm: FC = props => { - const setting = props.setting - const { handleSubmit, register, setValue } = useForm({ +const InactiveSettingsForm: FC = ({ name, setting, callback }) => { + const { handleSubmit, register } = useForm({ defaultValues: { headline: setting.value.headline, sub_headline: setting.value.sub_headline, @@ -48,95 +37,105 @@ const InactiveSettingsForm: FC = props => { const { putInactiveSettings } = useSettingsApi(token) const queryClient = useQueryClient() - const onChange = useCallback( - ( - e: FormEvent | ChangeEvent, - { name, value }: InputOnChangeData | TextAreaProps - ) => { - setValue(name, value) - }, - [setValue] - ) - const onSubmit: SubmitHandler = data => { putInactiveSettings(data) .then(() => queryClient.invalidateQueries(['inactive_settings'])) - .finally(() => props.callback()) + .finally(() => callback()) } - useEffect(() => { - register('headline') - register('sub_headline') - register('description') - register('author') - register('email') - register('homepage') - register('twitter') - }) - return ( -
- - - - - -
Information
- - - - - - - - - +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
) } diff --git a/src/components/settings/InactiveSettingsModalForm.tsx b/src/components/settings/InactiveSettingsModalForm.tsx index 9c5fdf0e..74df2082 100644 --- a/src/components/settings/InactiveSettingsModalForm.tsx +++ b/src/components/settings/InactiveSettingsModalForm.tsx @@ -1,51 +1,63 @@ -import React, { FC, useCallback, useState } from 'react' -import { Button, Header, Modal } from 'semantic-ui-react' +import { Close } from '@mui/icons-material' +import { + Button, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + IconButton, + Stack, +} from '@mui/material' +import React, { FC } from 'react' import { InactiveSetting, Setting } from '../../api/Settings' import InactiveSettingsForm from './InactiveSettingsForm' interface Props { + open: boolean + onClose: () => void setting: Setting - trigger: React.ReactNode } -const InactiveSettingsModalForm: FC = props => { - const [open, setOpen] = useState(false) - - const handleClose = useCallback(() => { - setOpen(false) - }, []) - - const handleOpen = useCallback(() => { - setOpen(true) - }, []) +const InactiveSettingsModalForm: FC = ({ open, onClose, setting }) => { + const handleClose = () => { + onClose() + } return ( - -
Edit Inactive Settings
- - - - - - + + + ) } diff --git a/src/components/settings/RefreshIntervalCard.tsx b/src/components/settings/RefreshIntervalCard.tsx index 4272ff82..e4739866 100644 --- a/src/components/settings/RefreshIntervalCard.tsx +++ b/src/components/settings/RefreshIntervalCard.tsx @@ -1,12 +1,24 @@ -import React, { FC } from 'react' +import React, { FC, useState } from 'react' import { useQuery } from '@tanstack/react-query' -import { Button, Card, Dimmer, Icon, List, Loader } from 'semantic-ui-react' import { useSettingsApi } from '../../api/Settings' import ErrorView from '../../views/ErrorView' -import RefreshIntervalModalForm from './RefreshIntervalModalForm' import useAuth from '../useAuth' +import { + Box, + Button, + Card, + CardContent, + Divider, + Stack, + Typography, +} from '@mui/material' +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' +import { faPencil } from '@fortawesome/free-solid-svg-icons' +import Loader from '../Loader' +import RefreshIntervalModalForm from './RefreshIntervalModalForm' const RefreshIntervalCard: FC = () => { + const [formOpen, setFormOpen] = useState(false) const { token } = useAuth() const { getRefreshInterval } = useSettingsApi(token) const { isLoading, error, data } = useQuery( @@ -14,12 +26,16 @@ const RefreshIntervalCard: FC = () => { getRefreshInterval ) + const handleFormOpen = () => { + setFormOpen(true) + } + + const handleFormClose = () => { + setFormOpen(false) + } + if (isLoading) { - return ( - - Loading - - ) + return } if (error || data === undefined || data.status === 'error') { @@ -34,31 +50,38 @@ const RefreshIntervalCard: FC = () => { return ( - - - - Refresh Interval - - - These setting configures the reload interval for the frontend - - - - - - - - {setting.value} ms - - - - - + + + + Refresh Interval + + + + + These settings have affect for inactive or non-configured tickers. + + + + + + + Refresh Interval + + {setting.value} ms + } /> - + ) } diff --git a/src/components/settings/RefreshIntervalForm.tsx b/src/components/settings/RefreshIntervalForm.tsx index 8dab6e35..9206da0a 100644 --- a/src/components/settings/RefreshIntervalForm.tsx +++ b/src/components/settings/RefreshIntervalForm.tsx @@ -1,11 +1,13 @@ -import React, { FC, FormEvent, useCallback, useEffect } from 'react' +import React, { FC } from 'react' import { SubmitHandler, useForm } from 'react-hook-form' import { useQueryClient } from '@tanstack/react-query' -import { Form, InputOnChangeData } from 'semantic-ui-react' import { Setting, useSettingsApi } from '../../api/Settings' import useAuth from '../useAuth' +import { FormGroup, TextField } from '@mui/material' +import { Grid } from 'semantic-ui-react' interface Props { + name: string setting: Setting callback: () => void } @@ -14,9 +16,8 @@ interface FormValues { refresh_interval: number } -const RefreshIntervalForm: FC = props => { - const setting = props.setting - const { handleSubmit, register, setValue } = useForm({ +const RefreshIntervalForm: FC = ({ name, setting, callback }) => { + const { handleSubmit, register } = useForm({ defaultValues: { refresh_interval: parseInt(setting.value, 10), }, @@ -25,33 +26,30 @@ const RefreshIntervalForm: FC = props => { const { putRefreshInterval } = useSettingsApi(token) const queryClient = useQueryClient() - const onChange = useCallback( - (e: FormEvent, { name, value }: InputOnChangeData) => { - setValue(name, value) - }, - [setValue] - ) - const onSubmit: SubmitHandler = data => { putRefreshInterval(data.refresh_interval) .then(() => queryClient.invalidateQueries(['refresh_interval_setting'])) - .finally(() => props.callback()) + .finally(() => callback()) } - useEffect(() => { - register('refresh_interval', { valueAsNumber: true }) - }) - return ( -
- - +
+ + + + + + + +
) } diff --git a/src/components/settings/RefreshIntervalModalForm.tsx b/src/components/settings/RefreshIntervalModalForm.tsx index cad247fc..65a0f189 100644 --- a/src/components/settings/RefreshIntervalModalForm.tsx +++ b/src/components/settings/RefreshIntervalModalForm.tsx @@ -1,51 +1,63 @@ -import React, { FC, useCallback, useState } from 'react' -import { Button, Header, Modal } from 'semantic-ui-react' +import { Close } from '@mui/icons-material' +import { + Button, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + IconButton, +} from '@mui/material' +import { Stack } from '@mui/system' +import React, { FC } from 'react' import { Setting } from '../../api/Settings' import RefreshIntervalForm from './RefreshIntervalForm' interface Props { + open: boolean + onClose: () => void setting: Setting - trigger: React.ReactNode } -const RefreshIntervalModalForm: FC = props => { - const [open, setOpen] = useState(false) - - const handleClose = useCallback(() => { - setOpen(false) - }, []) - - const handleOpen = useCallback(() => { - setOpen(true) - }, []) +const RefreshIntervalModalForm: FC = ({ open, onClose, setting }) => { + const handleClose = () => { + onClose() + } return ( - -
Edit Refresh Interval
- - - - - - + + + ) } diff --git a/src/views/Layout.tsx b/src/views/Layout.tsx index 4b42f5c8..a5ef3658 100644 --- a/src/views/Layout.tsx +++ b/src/views/Layout.tsx @@ -51,7 +51,9 @@ const Layout: FC = ({ children }) => { - {children} + + {children} + ) } diff --git a/src/views/SettingsView.tsx b/src/views/SettingsView.tsx index a950315e..2af5dc49 100644 --- a/src/views/SettingsView.tsx +++ b/src/views/SettingsView.tsx @@ -1,26 +1,31 @@ import React, { FC } from 'react' -import { Grid, Header } from 'semantic-ui-react' +import Layout from './Layout' +import { Grid, Stack, Typography } from '@mui/material' import RefreshIntervalCard from '../components/settings/RefreshIntervalCard' import InactiveSettingsCard from '../components/settings/InactiveSettingsCard' -import Layout from './Layout' const SettingsView: FC = () => { return ( - - - -
Settings
-
-
- - - - - - - - + + + + + Settings + + + + + + + + +
) diff --git a/src/views/UsersView.tsx b/src/views/UsersView.tsx index 5315663d..b2bd2b8a 100644 --- a/src/views/UsersView.tsx +++ b/src/views/UsersView.tsx @@ -11,13 +11,13 @@ const UsersView: FC = () => { return ( - + Users From 65d34b1b1d8e282444429122247141fde0b3c0d0 Mon Sep 17 00:00:00 2001 From: louis Date: Fri, 28 Oct 2022 11:58:11 +0200 Subject: [PATCH 10/31] =?UTF-8?q?=E2=9C=85=20Add=20Tests=20for=20SettingsV?= =?UTF-8?q?iew?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../settings/InactiveSettingsCard.tsx | 2 +- .../settings/RefreshIntervalCard.tsx | 2 +- src/views/SettingsView.test.tsx | 121 ++++++++++++++++++ 3 files changed, 123 insertions(+), 2 deletions(-) create mode 100644 src/views/SettingsView.test.tsx diff --git a/src/components/settings/InactiveSettingsCard.tsx b/src/components/settings/InactiveSettingsCard.tsx index 79400848..1e55353c 100644 --- a/src/components/settings/InactiveSettingsCard.tsx +++ b/src/components/settings/InactiveSettingsCard.tsx @@ -56,7 +56,7 @@ const InactiveSettingsCard: FC = () => { Inactive Settings - diff --git a/src/components/settings/RefreshIntervalCard.tsx b/src/components/settings/RefreshIntervalCard.tsx index e4739866..2ea9f247 100644 --- a/src/components/settings/RefreshIntervalCard.tsx +++ b/src/components/settings/RefreshIntervalCard.tsx @@ -59,7 +59,7 @@ const RefreshIntervalCard: FC = () => { Refresh Interval - diff --git a/src/views/SettingsView.test.tsx b/src/views/SettingsView.test.tsx new file mode 100644 index 00000000..48000179 --- /dev/null +++ b/src/views/SettingsView.test.tsx @@ -0,0 +1,121 @@ +import React from 'react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { render, screen } from '@testing-library/react' +import { MemoryRouter } from 'react-router' +import { AuthProvider } from '../components/useAuth' +import sign from 'jwt-encode' +import fetchMock from 'jest-fetch-mock' +import SettingsView from './SettingsView' +import userEvent from '@testing-library/user-event' + +describe('SettingsView', function () { + const jwt = sign( + { id: 1, email: 'louis@systemli.org', roles: ['admin', 'user'] }, + 'secret' + ) + const inactiveSettingsResponse = JSON.stringify({ + data: { + setting: { + id: 1, + name: 'inactive_settings', + value: { + headline: 'headline', + sub_headline: 'sub_headline', + description: 'description', + author: 'author', + email: 'email', + homepage: 'homepage', + twitter: 'twitter', + }, + }, + }, + }) + const refreshIntervalResponse = JSON.stringify({ + data: { + setting: { + id: 2, + name: 'refresh_interval', + value: 10000, + }, + }, + }) + + beforeEach(() => { + jest.spyOn(window.localStorage.__proto__, 'getItem').mockReturnValue(jwt) + fetchMock.resetMocks() + }) + + function setup() { + const client = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }) + return render( + + + + + + + + ) + } + + test('renders settings and open dialogs', async function () { + fetchMock.mockIf(/^http:\/\/localhost:8080\/.*$/, (request: Request) => { + if (request.url.endsWith('/admin/settings/inactive_settings')) { + return Promise.resolve(inactiveSettingsResponse) + } + if (request.url.endsWith('/admin/settings/refresh_interval')) { + return Promise.resolve(refreshIntervalResponse) + } + + return Promise.resolve( + JSON.stringify({ + data: [], + status: 'error', + error: 'error message', + }) + ) + }) + + setup() + + const loaders = screen.getAllByText(/loading/i) + expect(loaders).toHaveLength(2) + loaders.forEach(loader => { + expect(loader).toBeInTheDocument() + }) + + expect(await screen.findByText('headline')).toBeInTheDocument() + + const inactiveSettingEditButton = screen.getByTestId('inactivesetting-edit') + expect(inactiveSettingEditButton).toBeInTheDocument() + + await userEvent.click(inactiveSettingEditButton) + + const inactiveSettingsDialogTitle = screen.getByText( + 'Edit Inactive Settings' + ) + expect(inactiveSettingsDialogTitle).toBeInTheDocument() + + await userEvent.click(screen.getByRole('button', { name: /close/i })) + + expect(inactiveSettingsDialogTitle).not.toBeVisible() + + const refreshIntervalEditButton = screen.getByTestId('refreshinterval-edit') + expect(refreshIntervalEditButton).toBeInTheDocument() + + await userEvent.click(refreshIntervalEditButton) + + const refreshIntervalDialogTitle = screen.getByText('Edit Refresh Interval') + expect(refreshIntervalDialogTitle).toBeInTheDocument() + + await userEvent.click(screen.getByRole('button', { name: /close/i })) + + expect(refreshIntervalDialogTitle).not.toBeVisible() + }) +}) From 170abf877836417c0317ff7b6087b12390810b41 Mon Sep 17 00:00:00 2001 From: louis Date: Fri, 28 Oct 2022 12:28:31 +0200 Subject: [PATCH 11/31] =?UTF-8?q?=F0=9F=92=84=20Use=20MUI=20for=20ErrorVie?= =?UTF-8?q?w?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../settings/InactiveSettingsCard.tsx | 6 ++- .../settings/RefreshIntervalCard.tsx | 2 +- src/components/user/UserList.tsx | 6 ++- src/views/ErrorView.tsx | 52 ++++++++++++++----- src/views/HomeView.tsx | 4 +- src/views/SettingsView.test.tsx | 14 +++++ src/views/TickerView.tsx | 6 ++- src/views/UsersView.test.tsx | 10 ++++ 8 files changed, 83 insertions(+), 17 deletions(-) diff --git a/src/components/settings/InactiveSettingsCard.tsx b/src/components/settings/InactiveSettingsCard.tsx index 1e55353c..1baadc30 100644 --- a/src/components/settings/InactiveSettingsCard.tsx +++ b/src/components/settings/InactiveSettingsCard.tsx @@ -40,7 +40,11 @@ const InactiveSettingsCard: FC = () => { } if (error || data === undefined || data.status === 'error') { - return Unable to fetch inactive settings from server. + return ( + + Unable to fetch inactive settings from server. + + ) } const setting = data.data.setting diff --git a/src/components/settings/RefreshIntervalCard.tsx b/src/components/settings/RefreshIntervalCard.tsx index 2ea9f247..e4a54a5a 100644 --- a/src/components/settings/RefreshIntervalCard.tsx +++ b/src/components/settings/RefreshIntervalCard.tsx @@ -40,7 +40,7 @@ const RefreshIntervalCard: FC = () => { if (error || data === undefined || data.status === 'error') { return ( - + Unable to fetch refresh interval setting from server. ) diff --git a/src/components/user/UserList.tsx b/src/components/user/UserList.tsx index 7bca5acf..e697edda 100644 --- a/src/components/user/UserList.tsx +++ b/src/components/user/UserList.tsx @@ -23,7 +23,11 @@ const UserList: FC = () => { } if (error || data === undefined || data.status === 'error') { - return Unable to fetch users from server. + return ( + + Unable to fetch users from server. + + ) } const users = data.data.users diff --git a/src/views/ErrorView.tsx b/src/views/ErrorView.tsx index ac15341b..7fe34d20 100644 --- a/src/views/ErrorView.tsx +++ b/src/views/ErrorView.tsx @@ -1,22 +1,50 @@ +import { + Box, + Button, + Card, + CardContent, + colors, + Divider, + Stack, + Typography, +} from '@mui/material' +import { QueryKey, useQueryClient } from '@tanstack/react-query' import React, { FC } from 'react' -import { Icon, Message } from 'semantic-ui-react' interface Props { children: React.ReactNode + queryKey: QueryKey } -const ErrorView: FC = ({ children }) => { +const ErrorView: FC = ({ children, queryKey }) => { + const queryClient = useQueryClient() + + const handleClick = () => { + queryClient.invalidateQueries(queryKey) + } + return ( - - - - Oh no! An error occured -

{children}

-

- Please try again later or contact your administrator. -

-
-
+ + + + Oh no! An error occured + + + + + + + + {children} + + Please try again later or contact your administrator. + + + + + ) } diff --git a/src/views/HomeView.tsx b/src/views/HomeView.tsx index ef21da7e..e0c0f8be 100644 --- a/src/views/HomeView.tsx +++ b/src/views/HomeView.tsx @@ -32,7 +32,9 @@ const HomeView: FC = () => { if (error || data === undefined || data.status === 'error') { return ( - Unable to fetch tickers from server. + + Unable to fetch tickers from server. + ) } diff --git a/src/views/SettingsView.test.tsx b/src/views/SettingsView.test.tsx index 48000179..f49d2e67 100644 --- a/src/views/SettingsView.test.tsx +++ b/src/views/SettingsView.test.tsx @@ -118,4 +118,18 @@ describe('SettingsView', function () { expect(refreshIntervalDialogTitle).not.toBeVisible() }) + + test('settings could not fetched', async function () { + fetchMock.mockReject(new Error('network error')) + setup() + + const loaders = screen.getAllByText(/loading/i) + expect(loaders).toHaveLength(2) + loaders.forEach(loader => { + expect(loader).toBeInTheDocument() + }) + expect( + await screen.findByText('Oh no! An error occured') + ).toBeInTheDocument() + }) }) diff --git a/src/views/TickerView.tsx b/src/views/TickerView.tsx index cc713f2a..d8dfbcf7 100644 --- a/src/views/TickerView.tsx +++ b/src/views/TickerView.tsx @@ -27,7 +27,11 @@ const TickerView: FC = () => { } if (error || data === undefined || data.status === 'error') { - return Unable to fetch the ticker from server. + return ( + + Unable to fetch the ticker from server. + + ) } const ticker = data.data.ticker diff --git a/src/views/UsersView.test.tsx b/src/views/UsersView.test.tsx index 4c62ab2c..0ed7545c 100644 --- a/src/views/UsersView.test.tsx +++ b/src/views/UsersView.test.tsx @@ -156,4 +156,14 @@ describe('UsersView', function () { expect(deleteClose).not.toBeVisible() }) + + test('user list could not fetched', async function () { + fetchMock.mockReject(new Error('network error')) + setup() + + expect(screen.getByText(/loading/i)).toBeInTheDocument() + expect( + await screen.findByText('Oh no! An error occured') + ).toBeInTheDocument() + }) }) From 875f451c8b098259c0c6f8461394d09bcfcdb1f5 Mon Sep 17 00:00:00 2001 From: louis Date: Sat, 29 Oct 2022 00:38:05 +0200 Subject: [PATCH 12/31] =?UTF-8?q?=F0=9F=92=84=20Use=20MUI=20for=20HomeView?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/ticker/LocationSearch.tsx | 163 ++------ src/components/ticker/TickerCard.tsx | 20 +- src/components/ticker/TickerForm.tsx | 395 +++++++++++--------- src/components/ticker/TickerList.tsx | 122 +++--- src/components/ticker/TickerListItem.tsx | 145 +++++-- src/components/ticker/TickerListItems.tsx | 6 +- src/components/ticker/TickerModalDelete.tsx | 66 ++-- src/components/ticker/TickerModalForm.tsx | 87 +++-- src/views/HomeView.tsx | 126 +++---- 9 files changed, 584 insertions(+), 546 deletions(-) diff --git a/src/components/ticker/LocationSearch.tsx b/src/components/ticker/LocationSearch.tsx index dbdf17b7..01de1682 100644 --- a/src/components/ticker/LocationSearch.tsx +++ b/src/components/ticker/LocationSearch.tsx @@ -1,12 +1,5 @@ -import React, { - FC, - MouseEvent, - useCallback, - useEffect, - useReducer, - useRef, -} from 'react' -import { Search, SearchProps } from 'semantic-ui-react' +import React, { FC, useRef, useState } from 'react' +import { Autocomplete, TextField } from '@mui/material' interface SearchResult { place_id: number @@ -21,136 +14,60 @@ export interface Result { lon: number } -interface State { - loading: boolean - results: Result[] - value: string -} - -const initialState: State = { - loading: false, - results: [], - value: '', -} - -enum SearchActionType { - CLEAN_QUERY = 'CLEAN_QUERY', - START_SEARCH = 'START_SEARCH', - FINISH_SEARCH = 'FINISH_SEARCH', - UPDATE_SELECTION = 'UPDATE_SELECTION', -} - -interface SearchAction { - type: SearchActionType - query?: string - results?: Result[] - selection?: string -} - -async function api(value: string): Promise { +async function api( + value: string, + signal: AbortSignal +): Promise { const url = 'https://nominatim.openstreetmap.org/search?format=json&limit=5&q=' + value - const timeout = (time: number) => { - const controller = new AbortController() - setTimeout(() => controller.abort(), time * 1000) - return controller - } - - return fetch(url, { signal: timeout(30).signal }).then(res => res.json()) -} - -function searchReducer(state: any, action: SearchAction) { - switch (action.type) { - case SearchActionType.CLEAN_QUERY: - return initialState - case SearchActionType.START_SEARCH: - return { ...state, loading: true, value: action.query, results: [] } - case SearchActionType.FINISH_SEARCH: - return { ...state, loading: false, results: action.results } - case SearchActionType.UPDATE_SELECTION: - return { ...state, value: action.selection } - - default: - throw new Error() - } + return fetch(url, { signal }).then(res => res.json()) } interface Props { callback: (result: Result) => void } -const LocationSearch: FC = props => { - const [state, dispatch] = useReducer(searchReducer, initialState) - const { loading, results, value } = state - const timeoutRef: { current: NodeJS.Timeout | null } = useRef(null) - - const handleResultSelect = useCallback( - (event: MouseEvent, data: any) => { - dispatch({ - type: SearchActionType.UPDATE_SELECTION, - selection: data.result.title, - }) - - props.callback(data.result) - }, - [props] - ) - - const handleSearchChange = useCallback( - (event: MouseEvent, data: SearchProps) => { - const value = data.value - if (value === undefined) { - return - } +const LocationSearch: FC = ({ callback }) => { + const [options, setOptions] = useState([]) + const previousController = useRef() - clearTimeout(timeoutRef.current as NodeJS.Timeout) - dispatch({ type: SearchActionType.START_SEARCH, query: value }) + const handleInputChange = (event: React.SyntheticEvent, value: string) => { + if (previousController.current) { + previousController.current.abort() + } - timeoutRef.current = setTimeout(() => { - if (value.length === 0) { - dispatch({ type: SearchActionType.CLEAN_QUERY }) - return - } + const controller = new AbortController() + const signal = controller.signal + previousController.current = controller - const results: Result[] = [] - api(value) - .then(data => { - data.forEach(entry => { - results.push({ - title: entry.display_name, - lat: entry.lat, - lon: entry.lon, - }) - }) - }) - .finally(() => { - dispatch({ - type: SearchActionType.FINISH_SEARCH, - results: results, - }) - }) - }, 300) - }, - [] - ) + api(value, signal) + .then(options => setOptions(options)) + .catch(() => { + // We ignore the error + }) + } - useEffect(() => { - return () => { - clearTimeout(timeoutRef.current as NodeJS.Timeout) + const handleChange = ( + event: React.SyntheticEvent, + value: SearchResult | null + ) => { + if (value) { + callback({ title: value?.display_name, lat: value?.lat, lon: value?.lon }) } - }, []) + } return ( - - - + option.display_name} + onChange={handleChange} + onInputChange={handleInputChange} + options={options} + renderInput={params => ( + + )} + /> ) } diff --git a/src/components/ticker/TickerCard.tsx b/src/components/ticker/TickerCard.tsx index 8833d332..74d74bbf 100644 --- a/src/components/ticker/TickerCard.tsx +++ b/src/components/ticker/TickerCard.tsx @@ -1,10 +1,7 @@ import React, { FC } from 'react' import { Button, Card, Icon, Label } from 'semantic-ui-react' import ReactMarkdown from 'react-markdown' -import TickerModalForm from './TickerModalForm' import { Ticker } from '../../api/Ticker' -import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' -import { faGear } from '@fortawesome/free-solid-svg-icons' interface Props { ticker: Ticker @@ -40,22 +37,7 @@ const TickerCard: FC = props => { - - - - - } - /> - } - /> - + ) diff --git a/src/components/ticker/TickerForm.tsx b/src/components/ticker/TickerForm.tsx index a9e8a689..d58fef90 100644 --- a/src/components/ticker/TickerForm.tsx +++ b/src/components/ticker/TickerForm.tsx @@ -1,29 +1,36 @@ -import React, { - ChangeEvent, - FC, - FormEvent, - useCallback, - useEffect, -} from 'react' -import { - Button, - CheckboxProps, - Form, - Header, - Icon, - Input, - InputOnChangeData, - Message, - TextAreaProps, -} from 'semantic-ui-react' +import React, { FC, useCallback, useEffect } from 'react' import { Ticker, useTickerApi } from '../../api/Ticker' import { SubmitHandler, useForm } from 'react-hook-form' import { useQueryClient } from '@tanstack/react-query' import useAuth from '../useAuth' import LocationSearch, { Result } from './LocationSearch' import { MapContainer, Marker, TileLayer } from 'react-leaflet' +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' +import { + Alert, + Button, + Checkbox, + FormControlLabel, + FormGroup, + Grid, + InputAdornment, + Stack, + TextField, + Typography, +} from '@mui/material' +import { + faComputerMouse, + faEnvelope, + faUser, +} from '@fortawesome/free-solid-svg-icons' +import { + faFacebook, + faTelegram, + faTwitter, +} from '@fortawesome/free-brands-svg-icons' interface Props { + id: string ticker?: Ticker callback: () => void } @@ -47,8 +54,7 @@ interface FormValues { } } -const TickerForm: FC = props => { - const ticker = props.ticker +const TickerForm: FC = ({ callback, id, ticker }) => { const { handleSubmit, register, setValue, watch } = useForm({ defaultValues: { title: ticker?.title, @@ -69,7 +75,7 @@ const TickerForm: FC = props => { }, }, }) - const { token, user } = useAuth() + const { token } = useAuth() const { postTicker, putTicker } = useTickerApi(token) const queryClient = useQueryClient() @@ -91,35 +97,17 @@ const TickerForm: FC = props => { [setValue] ) - const onChange = useCallback( - ( - e: ChangeEvent | FormEvent, - { - name, - value, - checked, - }: InputOnChangeData | CheckboxProps | TextAreaProps - ) => { - if (checked !== undefined) { - setValue(name, checked) - } else { - setValue(name, value) - } - }, - [setValue] - ) - const onSubmit: SubmitHandler = data => { if (ticker) { putTicker(data, ticker.id).finally(() => { queryClient.invalidateQueries(['tickers']) queryClient.invalidateQueries(['ticker', ticker.id]) - props.callback() + callback() }) } else { postTicker(data).finally(() => { queryClient.invalidateQueries(['tickers']) - props.callback() + callback() }) } } @@ -132,143 +120,194 @@ const TickerForm: FC = props => { const position = watch('location') return ( -
- - - - - - -
Information
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
Location
- - You can add a default location to the ticker. This will help you to have - a pre-selected location when you add a map to a message.
- Current Location: {position.lat.toFixed(2)},{position.lon.toFixed(2)} -
- - - - - - +
+
+ {position.lat !== 0 && position.lon !== 0 ? ( + + + + + + + ) : null} +
+ ) } diff --git a/src/components/ticker/TickerList.tsx b/src/components/ticker/TickerList.tsx index a4eaaf9a..57333108 100644 --- a/src/components/ticker/TickerList.tsx +++ b/src/components/ticker/TickerList.tsx @@ -1,54 +1,92 @@ import React, { FC } from 'react' -import { Ticker } from '../../api/Ticker' -import { Button, Table } from 'semantic-ui-react' -import TickerModalForm from './TickerModalForm' +import { useTickerApi } from '../../api/Ticker' import TickerListItems from './TickerListItems' import useAuth from '../useAuth' +import { useQuery } from '@tanstack/react-query' +import Loader from '../Loader' +import ErrorView from '../../views/ErrorView' +import { Navigate } from 'react-router' +import { + Card, + CardContent, + Table, + TableCell, + TableContainer, + TableHead, + TableRow, + Typography, +} from '@mui/material' -interface Props { - tickers: Ticker[] -} +const TickerList: FC = () => { + const { token, user } = useAuth() + const { getTickers } = useTickerApi(token) + const { isLoading, error, data } = useQuery(['tickers'], getTickers) + + if (isLoading) { + return + } + + if (error || data === undefined || data.status === 'error') { + return ( + + Unable to fetch tickers from server. + + ) + } + + const tickers = data.data.tickers + + if (tickers.length === 0 && user?.roles.includes('admin')) { + return ( + + + + Welcome! + + + There are no tickers yet. To start with a ticker, create one. + + + + ) + } + + if (tickers.length === 0 && !user?.roles.includes('admin')) { + return ( + + + + Oh no! Something unexpected happened + + + Currently there are no tickers for you. Contact your administrator + if that should be different. + + + + ) + } -const TickerList: FC = props => { - const { user } = useAuth() + if (tickers.length === 1 && !user?.roles.includes('admin')) { + return + } return ( - + - - - - Title - Domain - - - - - {user?.roles.includes('admin') ? ( - - - - - - - - } - /> - - - - ) : null} + + + + Active + + Title + Domain + + + +
-
+ ) } diff --git a/src/components/ticker/TickerListItem.tsx b/src/components/ticker/TickerListItem.tsx index 27b56882..1ea51a62 100644 --- a/src/components/ticker/TickerListItem.tsx +++ b/src/components/ticker/TickerListItem.tsx @@ -1,10 +1,27 @@ -import React, { FC, useCallback } from 'react' +import React, { FC, useState } from 'react' import { useNavigate } from 'react-router' -import { Button, Icon, Table } from 'semantic-ui-react' import { Ticker } from '../../api/Ticker' +import useAuth from '../useAuth' +import { + colors, + IconButton, + MenuItem, + Popover, + TableCell, + TableRow, + Typography, +} from '@mui/material' +import { MoreVert } from '@mui/icons-material' +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' +import { + faCheck, + faHandPointer, + faPencil, + faTrash, + faXmark, +} from '@fortawesome/free-solid-svg-icons' import TickerModalDelete from './TickerModalDelete' import TickerModalForm from './TickerModalForm' -import useAuth from '../useAuth' interface Props { ticker: Ticker @@ -13,43 +30,99 @@ interface Props { const TickerListItem: FC = ({ ticker }: Props) => { const { user } = useAuth() const navigate = useNavigate() + const [formModalOpen, setFormModalOpen] = useState(false) + const [deleteModalOpen, setDeleteModalOpen] = useState(false) + const [anchorEl, setAnchorEl] = useState(null) + + const handleMenu = (event: React.MouseEvent) => { + setAnchorEl(event.currentTarget) + } + + const handleClose = () => { + setAnchorEl(null) + } + + const handleUse = () => { + navigate(`/ticker/${ticker.id}`) + } return ( - - - - - {ticker.title} - {ticker.domain} - - - + + + ) } diff --git a/src/components/ticker/TickerModalForm.tsx b/src/components/ticker/TickerModalForm.tsx index 7d438bb0..0892fa18 100644 --- a/src/components/ticker/TickerModalForm.tsx +++ b/src/components/ticker/TickerModalForm.tsx @@ -1,52 +1,59 @@ -import React, { FC, useCallback, useState } from 'react' -import { Button, Header, Modal } from 'semantic-ui-react' +import { Close } from '@mui/icons-material' +import { + Button, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + IconButton, + Stack, +} from '@mui/material' +import React, { FC } from 'react' import { Ticker } from '../../api/Ticker' import TickerForm from './TickerForm' interface Props { + onClose: () => void + open: boolean ticker?: Ticker - trigger: React.ReactNode } -const TickerModalForm: FC = props => { - const [open, setOpen] = useState(false) - - const handleClose = useCallback(() => { - setOpen(false) - }, []) - - const handleOpen = useCallback(() => { - setOpen(true) - }, []) +const TickerModalForm: FC = ({ onClose, open, ticker }) => { + const handleClose = () => { + onClose() + } return ( - - {' '} -
- {props.ticker ? `Edit ${props.ticker.title}` : 'Create Ticker'} -
- - - - - - + + + ) } diff --git a/src/views/HomeView.tsx b/src/views/HomeView.tsx index e0c0f8be..568957fe 100644 --- a/src/views/HomeView.tsx +++ b/src/views/HomeView.tsx @@ -1,93 +1,55 @@ -import React, { FC } from 'react' -import { - Button, - Dimmer, - Grid, - Header, - Loader, - Message, -} from 'semantic-ui-react' +import React, { FC, useState } from 'react' import TickerList from '../components/ticker/TickerList' import useAuth from '../components/useAuth' -import { useTickerApi } from '../api/Ticker' -import { useQuery } from '@tanstack/react-query' -import TickerModalForm from '../components/ticker/TickerModalForm' import Layout from './Layout' -import ErrorView from './ErrorView' -import { Navigate } from 'react-router' +import { Button, Card, Grid, Stack, Typography } from '@mui/material' +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' +import { faPlus } from '@fortawesome/free-solid-svg-icons' +import TickerModalForm from '../components/ticker/TickerModalForm' const HomeView: FC = () => { - const { token, user } = useAuth() - const { getTickers } = useTickerApi(token) - const { isLoading, error, data } = useQuery(['tickers'], getTickers) - - if (isLoading) { - return ( - - Loading - - ) - } - - if (error || data === undefined || data.status === 'error') { - return ( - - - Unable to fetch tickers from server. - - - ) - } - - const tickers = data.data.tickers - - if (tickers.length === 0 && user?.roles.includes('admin')) { - return ( - - - - - - Welcome! -

You need to create a your first ticker.

-

- - } - /> -

-
-
-
-
-
- ) - } - - if (tickers.length === 1 && !user?.roles.includes('admin')) { - return - } + const { user } = useAuth() + const [formModalOpen, setFormModalOpen] = useState(false) return ( - - - -
Available Configurations
-
-
- - - - - + + + + + Tickers + + {user?.roles.includes('admin') ? ( + <> + + { + setFormModalOpen(false) + }} + open={formModalOpen} + /> + + ) : null} + + + + + + +
) From ac8618c2b1c8df92390e2ef8ad9747e6e92f92cc Mon Sep 17 00:00:00 2001 From: louis Date: Sat, 29 Oct 2022 19:20:16 +0200 Subject: [PATCH 13/31] =?UTF-8?q?=E2=9C=85=20Add=20Tests=20for=20HomeView?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- jest.config.ts | 4 +- src/__mocks__/react-leaflet.tsx | 13 ++ src/components/ticker/TickerList.tsx | 58 ++++---- src/views/HomeView.test.tsx | 201 +++++++++++++++++++++++++++ 4 files changed, 245 insertions(+), 31 deletions(-) create mode 100644 src/__mocks__/react-leaflet.tsx create mode 100644 src/views/HomeView.test.tsx diff --git a/jest.config.ts b/jest.config.ts index 4fed3752..a14cd0eb 100644 --- a/jest.config.ts +++ b/jest.config.ts @@ -5,7 +5,7 @@ const config: Config.InitialOptions = { testEnvironment: 'jsdom', preset: 'ts-jest', moduleNameMapper: { - 'react-markdown': '/src/__mocks_/react-markdown.js', + 'react-leaflet': '/src/__mocks__/react-leaflet.tsx', '^.+.(css|styl|less|sass|scss|png|jpg|ttf|woff|woff2)$': 'jest-transform-stub', }, @@ -15,7 +15,7 @@ const config: Config.InitialOptions = { '.+\\.(css|styl|less|sass|scss|png|jpg|ttf|woff|woff2)$': 'jest-transform-stub', }, - transformIgnorePatterns: ['/node_modules/(?!react-markdown/)'], + transformIgnorePatterns: ['/node_modules/(?!react-leaflet)'], setupFilesAfterEnv: ['./jest-setup.ts'], collectCoverageFrom: ['src/**/*.{ts,tsx}'], } diff --git a/src/__mocks__/react-leaflet.tsx b/src/__mocks__/react-leaflet.tsx new file mode 100644 index 00000000..c002b669 --- /dev/null +++ b/src/__mocks__/react-leaflet.tsx @@ -0,0 +1,13 @@ +import React from 'react' + +export function MapContainer({ children }: { children: React.ReactNode }) { + return <>{children} +} + +export function Marker({ children }: { children: React.ReactNode }) { + return <>{children} +} + +export function TileLayer({ children }: { children: React.ReactNode }) { + return <>{children} +} diff --git a/src/components/ticker/TickerList.tsx b/src/components/ticker/TickerList.tsx index 57333108..0800ed62 100644 --- a/src/components/ticker/TickerList.tsx +++ b/src/components/ticker/TickerList.tsx @@ -36,35 +36,35 @@ const TickerList: FC = () => { const tickers = data.data.tickers - if (tickers.length === 0 && user?.roles.includes('admin')) { - return ( - - - - Welcome! - - - There are no tickers yet. To start with a ticker, create one. - - - - ) - } - - if (tickers.length === 0 && !user?.roles.includes('admin')) { - return ( - - - - Oh no! Something unexpected happened - - - Currently there are no tickers for you. Contact your administrator - if that should be different. - - - - ) + if (tickers.length === 0) { + if (user?.roles.includes('admin')) { + return ( + + + + Welcome! + + + There are no tickers yet. To start with a ticker, create one. + + + + ) + } else { + return ( + + + + Oh no! Something unexpected happened + + + Currently there are no tickers for you. Contact your administrator + if that should be different. + + + + ) + } } if (tickers.length === 1 && !user?.roles.includes('admin')) { diff --git a/src/views/HomeView.test.tsx b/src/views/HomeView.test.tsx new file mode 100644 index 00000000..807c8922 --- /dev/null +++ b/src/views/HomeView.test.tsx @@ -0,0 +1,201 @@ +import React from 'react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { render, screen } from '@testing-library/react' +import sign from 'jwt-encode' +import { MemoryRouter } from 'react-router' +import { AuthProvider } from '../components/useAuth' +import HomeView from './HomeView' +import userEvent from '@testing-library/user-event' + +describe('HomeView', function () { + beforeEach(() => { + fetchMock.resetMocks() + }) + + function jwt(role: string): string { + return sign( + { + id: 1, + email: 'louis@systemli.org', + roles: role === 'admin' ? ['admin', 'user'] : ['user'], + exp: new Date().getTime() / 1000 + 600, + }, + 'secret' + ) + } + + const emptyTickerResponse = JSON.stringify({ + data: { tickers: [] }, + status: 'success', + }) + + const singleTickerResponse = JSON.stringify({ + data: { + tickers: [ + { + id: 1, + creation_date: new Date(), + domain: 'localhost', + title: 'title', + description: 'description', + active: true, + }, + ], + }, + status: 'success', + }) + + function setup() { + const client = new QueryClient({ + defaultOptions: { + queries: { + retry: false, + }, + }, + }) + return render( + + + + + + + + ) + } + + test('render empty list for admins', async function () { + jest + .spyOn(window.localStorage.__proto__, 'getItem') + .mockReturnValue(jwt('admin')) + fetchMock.mockIf(/^http:\/\/localhost:8080\/.*$/, (request: Request) => { + if (request.url.endsWith('/admin/tickers')) { + return Promise.resolve(emptyTickerResponse) + } + + return Promise.resolve( + JSON.stringify({ + data: {}, + status: 'error', + error: 'error message', + }) + ) + }) + setup() + + expect(screen.getByText(/loading/i)).toBeInTheDocument() + expect( + await screen.findByText( + 'There are no tickers yet. To start with a ticker, create one.' + ) + ).toBeInTheDocument() + }) + + test('render empty list for user', async function () { + jest + .spyOn(window.localStorage.__proto__, 'getItem') + .mockReturnValue(jwt('user')) + fetchMock.mockIf(/^http:\/\/localhost:8080\/.*$/, (request: Request) => { + if (request.url.endsWith('/admin/tickers')) { + return Promise.resolve(emptyTickerResponse) + } + + return Promise.resolve( + JSON.stringify({ + data: {}, + status: 'error', + error: 'error message', + }) + ) + }) + setup() + + expect(screen.getByText(/loading/i)).toBeInTheDocument() + expect( + await screen.findByText( + 'Currently there are no tickers for you. Contact your administrator if that should be different.' + ) + ).toBeInTheDocument() + }) + + test('render ticker view for user with 1 ticker', async function () { + jest + .spyOn(window.localStorage.__proto__, 'getItem') + .mockReturnValue(jwt('user')) + fetchMock.mockIf(/^http:\/\/localhost:8080\/.*$/, (request: Request) => { + if (request.url.endsWith('/admin/tickers')) { + return Promise.resolve(singleTickerResponse) + } + + return Promise.resolve( + JSON.stringify({ + data: {}, + status: 'error', + error: 'error message', + }) + ) + }) + setup() + + expect(screen.getByText(/loading/i)).toBeInTheDocument() + }) + + test('renders list with entries', async function () { + jest + .spyOn(window.localStorage.__proto__, 'getItem') + .mockReturnValue(jwt('admin')) + fetchMock.mockIf(/^http:\/\/localhost:8080\/.*$/, (request: Request) => { + if (request.url.endsWith('/admin/tickers')) { + return Promise.resolve(singleTickerResponse) + } + + return Promise.resolve( + JSON.stringify({ + data: {}, + status: 'error', + error: 'error message', + }) + ) + }) + setup() + + expect(screen.getByText(/loading/i)).toBeInTheDocument() + expect(await screen.findByText('localhost')).toBeInTheDocument() + }) + + test('open create ticker form', async function () { + jest + .spyOn(window.localStorage.__proto__, 'getItem') + .mockReturnValue(jwt('admin')) + fetchMock.mockIf(/^http:\/\/localhost:8080\/.*$/, (request: Request) => { + if (request.url.endsWith('/admin/tickers')) { + return Promise.resolve(emptyTickerResponse) + } + + return Promise.resolve( + JSON.stringify({ + data: {}, + status: 'error', + error: 'error message', + }) + ) + }) + setup() + + expect(screen.getByText(/loading/i)).toBeInTheDocument() + + const button = screen.getByRole('button', { name: 'New Ticker' }) + expect(button).toBeInTheDocument() + + await userEvent.click(button) + + const dialogTitle = screen.getByText(/create ticker/i) + expect(dialogTitle).toBeInTheDocument() + + const closeButton = screen.getByRole('button', { name: /close/i }) + expect(closeButton).toBeInTheDocument() + + await userEvent.click(closeButton) + expect(dialogTitle).not.toBeVisible() + }) +}) From 89af64859c2b04acf92d6c5ce9996aea11ec296a Mon Sep 17 00:00:00 2001 From: louis Date: Tue, 17 Jan 2023 17:52:29 -0800 Subject: [PATCH 14/31] =?UTF-8?q?=F0=9F=92=84=20Use=20MUI=20for=20Message?= =?UTF-8?q?=20Form=20&=20Ticker=20Card?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/common/NamedListItem.tsx | 20 +++ src/components/message/AttachmentPreview.tsx | 35 ++++ src/components/message/AttachmentsPreview.tsx | 33 ++++ .../message/MessageAttachmentsButton.tsx | 54 ------ .../message/MessageAttachmentsPreview.tsx | 49 ------ src/components/message/MessageForm.tsx | 156 +++++++++--------- src/components/message/MessageFormCounter.tsx | 37 +++-- src/components/message/MessageMapModal.tsx | 109 ++++++------ src/components/message/UploadButton.tsx | 54 ++++++ src/components/ticker/MastodonCard.tsx | 115 ++++++------- .../ticker/SocialConnectionChip.tsx | 25 +++ src/components/ticker/Ticker.tsx | 142 ++++++++-------- src/components/ticker/TickerCard.tsx | 99 +++++++---- 13 files changed, 515 insertions(+), 413 deletions(-) create mode 100644 src/components/common/NamedListItem.tsx create mode 100644 src/components/message/AttachmentPreview.tsx create mode 100644 src/components/message/AttachmentsPreview.tsx delete mode 100644 src/components/message/MessageAttachmentsButton.tsx delete mode 100644 src/components/message/MessageAttachmentsPreview.tsx create mode 100644 src/components/message/UploadButton.tsx create mode 100644 src/components/ticker/SocialConnectionChip.tsx diff --git a/src/components/common/NamedListItem.tsx b/src/components/common/NamedListItem.tsx new file mode 100644 index 00000000..8938403f --- /dev/null +++ b/src/components/common/NamedListItem.tsx @@ -0,0 +1,20 @@ +import React, { FC } from 'react' +import { Box, Typography } from '@mui/material' + +interface Props { + title: string + children: React.ReactNode +} + +const NamedListItem: FC = ({ title, children }) => { + return ( + + + {title} + + {children} + + ) +} + +export default NamedListItem diff --git a/src/components/message/AttachmentPreview.tsx b/src/components/message/AttachmentPreview.tsx new file mode 100644 index 00000000..64bd6c94 --- /dev/null +++ b/src/components/message/AttachmentPreview.tsx @@ -0,0 +1,35 @@ +import React, { FC } from 'react' +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' +import { IconButton, ImageListItem } from '@mui/material' +import { Upload } from '../../api/Upload' +import { faXmarkSquare } from '@fortawesome/free-solid-svg-icons' + +interface Props { + onDelete: (upload: Upload) => void + upload: Upload +} + +const AttachmentPreview: FC = ({ onDelete, upload }) => { + const handleDelete = () => { + onDelete(upload) + } + + return ( + + + + + + + ) +} + +export default AttachmentPreview diff --git a/src/components/message/AttachmentsPreview.tsx b/src/components/message/AttachmentsPreview.tsx new file mode 100644 index 00000000..21bf698b --- /dev/null +++ b/src/components/message/AttachmentsPreview.tsx @@ -0,0 +1,33 @@ +import { ImageList } from '@mui/material' +import React, { FC } from 'react' +import { Upload } from '../../api/Upload' +import AttachmentPreview from './AttachmentPreview' + +interface Props { + attachments: Upload[] + onDelete: (upload: Upload) => void +} + +const AttachmentsPreview: FC = ({ attachments, onDelete }) => { + const images = attachments.map((upload, key) => { + return ( + onDelete(upload)} + upload={upload} + /> + ) + }) + + if (images.length === 0) { + return null + } + + return ( + + {images} + + ) +} + +export default AttachmentsPreview diff --git a/src/components/message/MessageAttachmentsButton.tsx b/src/components/message/MessageAttachmentsButton.tsx deleted file mode 100644 index 3618eb56..00000000 --- a/src/components/message/MessageAttachmentsButton.tsx +++ /dev/null @@ -1,54 +0,0 @@ -import React, { createRef, FC, useCallback } from 'react' -import { Button } from 'semantic-ui-react' -import { Ticker } from '../../api/Ticker' -import { useUploadApi, Upload } from '../../api/Upload' -import useAuth from '../useAuth' - -interface Props { - ticker: Ticker - onUpload: (uploads: Upload[]) => void -} - -const MessageAttachmentsButton: FC = props => { - const ref = createRef() - const { token } = useAuth() - const { postUpload } = useUploadApi(token) - - const refClick = useCallback(() => { - ref.current?.click() - }, [ref]) - - const onUpload = useCallback( - (e: React.FormEvent) => { - e.preventDefault() - // @ts-ignore - const files = e.target.files as Array - const formData = new FormData() - for (let i = 0; i < files.length; i++) { - // @ts-ignore - formData.append('files', files[i]) - } - formData.append('ticker', props.ticker.id.toString()) - - postUpload(formData).then(response => { - props.onUpload(response.data.uploads) - }) - }, - [postUpload, props] - ) - - return ( - - + + setMapDialogOpen(true)}> + + + setMapDialogOpen(false)} + open={mapDialogOpen} + ticker={ticker} + /> + + + + + - - ) : ( - - - + + + + + Mastodon + + + + You are currently not connected to Mastodon. New messages will not be published to your account and old messages can not be deleted anymore. - - - - - - } - size="tiny" - /> - } - /> - - - + + + + ) } diff --git a/src/components/ticker/SocialConnectionChip.tsx b/src/components/ticker/SocialConnectionChip.tsx new file mode 100644 index 00000000..57263e23 --- /dev/null +++ b/src/components/ticker/SocialConnectionChip.tsx @@ -0,0 +1,25 @@ +import React, { FC } from 'react' +import { faCheck, faXmark } from '@fortawesome/free-solid-svg-icons' +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' +import { Chip } from '@mui/material' + +interface Props { + active: boolean + label: string +} + +const SocialConnectionChip: FC = ({ active, label }) => { + return ( + <> + } + label={label} + size="small" + sx={{ mr: 1 }} + variant="outlined" + /> + + ) +} + +export default SocialConnectionChip diff --git a/src/components/ticker/Ticker.tsx b/src/components/ticker/Ticker.tsx index 2e790d73..4a86ef1a 100644 --- a/src/components/ticker/Ticker.tsx +++ b/src/components/ticker/Ticker.tsx @@ -1,92 +1,82 @@ -import React, { FC } from 'react' -import { Button, Grid, Header } from 'semantic-ui-react' +import React, { FC, useState } from 'react' import { Ticker as Model } from '../../api/Ticker' import MessageForm from '../message/MessageForm' import TickerCard from './TickerCard' import MessageList from '../message/MessageList' -import useAuth from '../useAuth' -import TickerUsersCard from './TickerUserCard' -import TickerResetModal from './TickerResetModal' -import TwitterCard from './TwitterCard' -import TelegramCard from './TelegramCard' -import useFeature from '../useFeature' -import MastodonCard from './MastodonCard' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' +import { faGear } from '@fortawesome/free-solid-svg-icons' import { - faMastodon, - faTelegram, - faTwitter, -} from '@fortawesome/free-brands-svg-icons' -import { faGear, faRadiation, faUsers } from '@fortawesome/free-solid-svg-icons' + Button, + Card, + CardContent, + Grid, + Stack, + Typography, +} from '@mui/material' +import TickerModalForm from './TickerModalForm' interface Props { ticker: Model } -const Ticker: FC = props => { - const { user } = useAuth() - const ticker = props.ticker - const { telegram_enabled, twitter_enabled } = useFeature() +const Ticker: FC = ({ ticker }) => { + const [formModalOpen, setFormModalOpen] = useState(false) return ( - - - -
Messages
- - -
- -
- Settings -
- -
- Mastodon -
- - {twitter_enabled && ( - <> -
- Twitter -
- - - )} - - {telegram_enabled && ( - <> -
- Telegram -
- - - )} - {user?.roles.includes('admin') && ( - -
- Users -
- -
- Danger Zone -
- - } - /> -
- )} -
-
+ + + + + Ticker + + + { + setFormModalOpen(false) + }} + open={formModalOpen} + ticker={ticker} + /> + + + + + + + + + + + + + + + + + + + + + ) } diff --git a/src/components/ticker/TickerCard.tsx b/src/components/ticker/TickerCard.tsx index 74d74bbf..c948e93d 100644 --- a/src/components/ticker/TickerCard.tsx +++ b/src/components/ticker/TickerCard.tsx @@ -1,44 +1,77 @@ import React, { FC } from 'react' -import { Button, Card, Icon, Label } from 'semantic-ui-react' -import ReactMarkdown from 'react-markdown' +import { Box, Card, CardContent, Typography } from '@mui/material' import { Ticker } from '../../api/Ticker' +import NamedListItem from '../common/NamedListItem' +import SocialConnectionChip from './SocialConnectionChip' +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' +import { + faCheck, + faHeading, + faLink, + faXmark, +} from '@fortawesome/free-solid-svg-icons' interface Props { ticker: Ticker } -const TickerCard: FC = props => { +const TickerCard: FC = ({ ticker }) => { return ( - - - - - {props.ticker.title} - - - - {props.ticker.domain} - - - - {props.ticker.description} - - - - - + + + + + + {ticker.title} + + + + + + {ticker.active ? 'Active' : 'Inactive'} + + + + + + + {ticker.domain} + + + + + + + + + + + ) } From 7fe86bcbc80ad64fa197b68850e841b9d76fbc9c Mon Sep 17 00:00:00 2001 From: louis Date: Tue, 17 Jan 2023 20:48:20 -0800 Subject: [PATCH 15/31] =?UTF-8?q?=F0=9F=92=84=20Use=20MUI=20for=20MessageL?= =?UTF-8?q?ist?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/message/Message.tsx | 151 +++--------------- src/components/message/MessageAttachments.tsx | 63 ++++++++ src/components/message/MessageFooter.tsx | 46 ++++++ src/components/message/MessageList.tsx | 22 +-- src/components/message/MessageModalDelete.tsx | 80 ++++++---- src/components/ticker/Ticker.tsx | 6 +- 6 files changed, 192 insertions(+), 176 deletions(-) create mode 100644 src/components/message/MessageAttachments.tsx create mode 100644 src/components/message/MessageFooter.tsx diff --git a/src/components/message/Message.tsx b/src/components/message/Message.tsx index e841dc19..7e9a5493 100644 --- a/src/components/message/Message.tsx +++ b/src/components/message/Message.tsx @@ -1,19 +1,13 @@ -import React, { FC, useCallback, useState } from 'react' -import { Card, Icon, Image } from 'semantic-ui-react' -import Moment from 'react-moment' +import React, { FC, useState } from 'react' import { Message as MessageType } from '../../api/Message' -import Lightbox from 'react-image-lightbox' -import 'react-image-lightbox/style.css' import { replaceMagic } from '../../lib/replaceLinksHelper' import MessageModalDelete from './MessageModalDelete' import MessageMap from './MessageMap' import { Ticker } from '../../api/Ticker' -import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' -import { - faMastodon, - faTelegram, - faTwitter, -} from '@fortawesome/free-brands-svg-icons' +import { Card, CardContent, IconButton, useTheme } from '@mui/material' +import MessageAttachements from './MessageAttachments' +import MessageFooter from './MessageFooter' +import { Close } from '@mui/icons-material' interface Props { message: MessageType @@ -21,133 +15,34 @@ interface Props { } const Message: FC = ({ message, ticker }) => { - const [imageLightboxOpen, setImageLightboxOpen] = useState(false) - const [imageIndex, setImageIndex] = useState(0) - - const openImageLightbox = useCallback(() => setImageLightboxOpen(true), []) - const closeImageLightbox = useCallback(() => setImageLightboxOpen(false), []) - - const twitterIcon = useCallback(() => { - if (message.twitter_url) { - return ( - - - - - - ) - } - - return null - }, [message.twitter_url]) - - const telegramIcon = useCallback(() => { - if (message.telegram_url) { - return ( - - - - - - ) - } - - return null - }, [message.telegram_url]) - - const mastodonIcon = useCallback(() => { - if (message.mastodon_url) { - return ( - - - - - - ) - } - - return null - }, [message.mastodon_url]) - - const renderAttachments = () => { - const attachments = message.attachments - - if (attachments === null || attachments.length === 0) { - return null - } - - const images = attachments.map((image, key) => ( - { - openImageLightbox() - setImageIndex(key) - }} - rounded - src={image.url} - style={{ width: 200, height: 200, objectFit: 'cover' }} - /> - )) - const urls = attachments.map(image => image.url) - - return ( - - {imageLightboxOpen && ( - - setImageIndex((imageIndex + 1) % urls.length) - } - onMovePrevRequest={() => - setImageIndex((imageIndex + urls.length - 1) % urls.length) - } - prevSrc={urls[(imageIndex + urls.length - 1) % urls.length]} - /> - )} - {images} - - ) - } + const theme = useTheme() + const [deleteModalOpen, setDeleteModalOpen] = useState(false) return ( - - + + + { + setDeleteModalOpen(true) + }} + sx={{ position: 'absolute', right: theme.spacing(3) }} + > + + - } + onClose={() => setDeleteModalOpen(false)} + open={deleteModalOpen} />

- - {renderAttachments()} - - - {twitterIcon()} - {telegramIcon()} - {mastodonIcon()} - {message.creation_date} - + + + + ) } diff --git a/src/components/message/MessageAttachments.tsx b/src/components/message/MessageAttachments.tsx new file mode 100644 index 00000000..d788440b --- /dev/null +++ b/src/components/message/MessageAttachments.tsx @@ -0,0 +1,63 @@ +import { ImageList, ImageListItem } from '@mui/material' +import React, { FC, useCallback, useState } from 'react' +import Lightbox from 'react-image-lightbox' +import { Message } from '../../api/Message' +import 'react-image-lightbox/style.css' + +interface Props { + message: Message +} + +const MessageAttachements: FC = ({ message }) => { + const [imageLightboxOpen, setImageLightboxOpen] = useState(false) + const [imageIndex, setImageIndex] = useState(0) + const attachments = message.attachments + + const openImageLightbox = useCallback(() => setImageLightboxOpen(true), []) + const closeImageLightbox = useCallback(() => setImageLightboxOpen(false), []) + + if (attachments === null || attachments.length === 0) { + return null + } + + const images = attachments.map((image, key) => ( + { + openImageLightbox() + setImageIndex(key) + }} + sx={{ position: 'relative' }} + > + + + )) + const urls = attachments.map(image => image.url) + + return ( + <> + {imageLightboxOpen && ( + + setImageIndex((imageIndex + 1) % urls.length) + } + onMovePrevRequest={() => + setImageIndex((imageIndex + urls.length - 1) % urls.length) + } + prevSrc={urls[(imageIndex + urls.length - 1) % urls.length]} + /> + )} + {images} + + ) +} + +export default MessageAttachements diff --git a/src/components/message/MessageFooter.tsx b/src/components/message/MessageFooter.tsx new file mode 100644 index 00000000..73b8f0fa --- /dev/null +++ b/src/components/message/MessageFooter.tsx @@ -0,0 +1,46 @@ +import { IconProp } from '@fortawesome/fontawesome-svg-core' +import { + faMastodon, + faTelegram, + faTwitter, +} from '@fortawesome/free-brands-svg-icons' +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' +import { Box, Stack, Typography } from '@mui/material' +import React, { FC } from 'react' +import Moment from 'react-moment' +import { Message } from '../../api/Message' + +interface Props { + message: Message +} +const MessageFooter: FC = ({ message }) => { + return ( + + + + {message.creation_date} + + + + + + + + + ) +} + +interface IconProps { + url?: string + icon: IconProp +} + +const Icon: FC = ({ url, icon }) => { + return url ? ( + + + + ) : null +} + +export default MessageFooter diff --git a/src/components/message/MessageList.tsx b/src/components/message/MessageList.tsx index bb257861..159c9ea8 100644 --- a/src/components/message/MessageList.tsx +++ b/src/components/message/MessageList.tsx @@ -1,10 +1,11 @@ import React, { FC } from 'react' -import { Dimmer, Feed, Loader } from 'semantic-ui-react' +import { useQuery } from '@tanstack/react-query' import { Ticker } from '../../api/Ticker' import { useMessageApi } from '../../api/Message' import Message from './Message' -import { useQuery } from '@tanstack/react-query' import useAuth from '../useAuth' +import ErrorView from '../../views/ErrorView' +import Loader from '../Loader' interface Props { ticker: Ticker @@ -18,24 +19,23 @@ const MessageList: FC = ({ ticker }) => { ) if (isLoading) { - return ( - - Loading - - ) + return } if (error || data === undefined) { - //TODO: Generic Error View - return Error occured + return ( + + Unable to fetch messages from server. + + ) } return ( - + <> {data.data.messages.map(message => ( ))} - + ) } diff --git a/src/components/message/MessageModalDelete.tsx b/src/components/message/MessageModalDelete.tsx index 8e9e2539..48861231 100644 --- a/src/components/message/MessageModalDelete.tsx +++ b/src/components/message/MessageModalDelete.tsx @@ -1,49 +1,65 @@ -import React, { FC, useCallback, useState } from 'react' +import React, { FC, useCallback } from 'react' import { useQueryClient } from '@tanstack/react-query' -import { Confirm } from 'semantic-ui-react' import { Message, useMessageApi } from '../../api/Message' import useAuth from '../useAuth' +import { + Button, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + IconButton, + Stack, +} from '@mui/material' +import { Close } from '@mui/icons-material' interface Props { + onClose: () => void + open: boolean message: Message - trigger: React.ReactNode } - -const MessageModalDelete: FC = props => { +const MessageModalDelete: FC = ({ message, onClose, open }) => { const { token } = useAuth() const { deleteMessage } = useMessageApi(token) - const [open, setOpen] = useState(false) const queryClient = useQueryClient() - const message = props.message - - const handleCancel = useCallback(() => { - setOpen(false) - }, []) - const handleConfirm = useCallback(() => { - deleteMessage(message) - .then(() => { - queryClient.invalidateQueries(['messages', message.ticker]) - }) - .finally(() => { - setOpen(false) - }) - }, [deleteMessage, message, queryClient]) + const handleClose = () => { + onClose() + } - const handleOpen = useCallback(() => { - setOpen(true) - }, []) + const handleDelete = useCallback(() => { + deleteMessage(message).then(() => { + queryClient.invalidateQueries(['messages', message.ticker]) + onClose() + }) + }, [deleteMessage, message, onClose, queryClient]) return ( - +

+ + + Delete Message + + + + + + + Are you sure to delete the message? This action cannot be undone. + + + + + + ) } diff --git a/src/components/ticker/Ticker.tsx b/src/components/ticker/Ticker.tsx index 4a86ef1a..506830d2 100644 --- a/src/components/ticker/Ticker.tsx +++ b/src/components/ticker/Ticker.tsx @@ -70,11 +70,7 @@ const Ticker: FC = ({ ticker }) => {
- - - - - +
From 1ce724349c9341b7719da54e6fbae696691ed3e2 Mon Sep 17 00:00:00 2001 From: louis Date: Wed, 18 Jan 2023 09:46:10 -0800 Subject: [PATCH 16/31] =?UTF-8?q?=F0=9F=92=84=20Use=20MUI=20for=20Ticker?= =?UTF-8?q?=20DangerZone?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/ticker/Ticker.tsx | 5 ++ .../ticker/TickerDangerZoneCard.tsx | 42 +++++++++ src/components/ticker/TickerResetModal.tsx | 86 ++++++++++--------- 3 files changed, 93 insertions(+), 40 deletions(-) create mode 100644 src/components/ticker/TickerDangerZoneCard.tsx diff --git a/src/components/ticker/Ticker.tsx b/src/components/ticker/Ticker.tsx index 506830d2..a63bb83c 100644 --- a/src/components/ticker/Ticker.tsx +++ b/src/components/ticker/Ticker.tsx @@ -6,6 +6,7 @@ import MessageList from '../message/MessageList' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { faGear } from '@fortawesome/free-solid-svg-icons' import { + Box, Button, Card, CardContent, @@ -14,6 +15,7 @@ import { Typography, } from '@mui/material' import TickerModalForm from './TickerModalForm' +import TickerDangerZoneCard from './TickerDangerZoneCard' interface Props { ticker: Model @@ -60,6 +62,9 @@ const Ticker: FC = ({ ticker }) => { xs={12} > + + + diff --git a/src/components/ticker/TickerDangerZoneCard.tsx b/src/components/ticker/TickerDangerZoneCard.tsx new file mode 100644 index 00000000..ea3a4efb --- /dev/null +++ b/src/components/ticker/TickerDangerZoneCard.tsx @@ -0,0 +1,42 @@ +import React, { FC, useState } from 'react' +import { Box, Button, Card, CardContent, Typography } from '@mui/material' +import { Ticker } from '../../api/Ticker' +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' +import { faBiohazard, faTrash } from '@fortawesome/free-solid-svg-icons' +import TickerResetModal from './TickerResetModal' +import useAuth from '../useAuth' + +interface Props { + ticker: Ticker +} +const TickerDangerZoneCard: FC = ({ ticker }) => { + const { user } = useAuth() + const [resetOpen, setResetOpen] = useState(false) + + return user?.roles.includes('admin') ? ( + + + + Danger Zone + + + + setResetOpen(false)} + open={resetOpen} + ticker={ticker} + /> + + + + ) : null +} + +export default TickerDangerZoneCard diff --git a/src/components/ticker/TickerResetModal.tsx b/src/components/ticker/TickerResetModal.tsx index bbead9a3..59026ecc 100644 --- a/src/components/ticker/TickerResetModal.tsx +++ b/src/components/ticker/TickerResetModal.tsx @@ -1,50 +1,60 @@ -import React, { FC, useCallback, useState } from 'react' -import { Button, Modal } from 'semantic-ui-react' +import React, { FC, useCallback } from 'react' import { Ticker, useTickerApi } from '../../api/Ticker' import useAuth from '../useAuth' import { useQueryClient } from '@tanstack/react-query' +import { + Button, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + IconButton, + Stack, +} from '@mui/material' +import { Close } from '@mui/icons-material' interface Props { + onClose: () => void + open: boolean ticker: Ticker - trigger: React.ReactNode } -const TickerResetModal: FC = props => { - const [open, setOpen] = useState(false) +const TickerResetModal: FC = ({ onClose, open, ticker }) => { const { token } = useAuth() const { putTickerReset } = useTickerApi(token) const queryClient = useQueryClient() - const handleCancel = useCallback(() => { - setOpen(false) - }, []) - - const handleOpen = useCallback(() => { - setOpen(true) - }, []) + const handleClose = () => { + onClose() + } const handleReset = useCallback(() => { - putTickerReset(props.ticker) + putTickerReset(ticker) .then(() => { - queryClient.invalidateQueries(['messages', props.ticker.id]) - queryClient.invalidateQueries(['tickerUsers', props.ticker.id]) - queryClient.invalidateQueries(['ticker', props.ticker.id]) + queryClient.invalidateQueries(['messages', ticker.id]) + queryClient.invalidateQueries(['tickerUsers', ticker.id]) + queryClient.invalidateQueries(['ticker', ticker.id]) }) .finally(() => { - setOpen(false) + onClose() }) - }, [props.ticker, putTickerReset, queryClient]) + }, [onClose, putTickerReset, queryClient, ticker]) return ( - - Reset Ticker - + + + + Reset Ticker + + + + + +

Are you sure you want to reset the ticker?

@@ -52,20 +62,16 @@ const TickerResetModal: FC = props => { This will remove all messages, descriptions, the connection to twitter and disable the ticker.

-
- - + - - - - ) : ( + return ( = ({ ticker }) => { justifyContent="space-between" > - Mastodon + Mastodon - - - You are currently not connected to Mastodon. New messages will not be - published to your account and old messages can not be deleted anymore. - + {mastodon.connected ? ( + + + You are connected to Mastodon. + + Profile: {profileLink} + + ) : ( + + You are currently not connected to Mastodon. New messages will not + be published to your account and old messages can not be deleted + anymore. + + )} - + {mastodon.connected ? ( + + {mastodon.active ? ( + + ) : ( + + )} + + + ) : null} + setOpen(false)} + open={open} + ticker={ticker} + /> ) } diff --git a/src/components/ticker/MastodonForm.tsx b/src/components/ticker/MastodonForm.tsx index f582cddb..afcc3210 100644 --- a/src/components/ticker/MastodonForm.tsx +++ b/src/components/ticker/MastodonForm.tsx @@ -1,12 +1,14 @@ +import { + Checkbox, + FormControlLabel, + FormGroup, + Grid, + TextField, + Typography, +} from '@mui/material' import { useQueryClient } from '@tanstack/react-query' -import React, { ChangeEvent, FC, FormEvent, useCallback } from 'react' +import React, { FC } from 'react' import { SubmitHandler, useForm } from 'react-hook-form' -import { - CheckboxProps, - Form, - InputOnChangeData, - Message, -} from 'semantic-ui-react' import { Ticker, TickerMastodonFormData, useTickerApi } from '../../api/Ticker' import useAuth from '../useAuth' @@ -19,7 +21,7 @@ const MastodonForm: FC = ({ callback, ticker }) => { const mastodon = ticker.mastodon const { token } = useAuth() const { putTickerMastodon } = useTickerApi(token) - const { handleSubmit, setValue } = useForm({ + const { handleSubmit, register } = useForm({ defaultValues: { active: mastodon.active, server: mastodon.server, @@ -27,20 +29,6 @@ const MastodonForm: FC = ({ callback, ticker }) => { }) const queryClient = useQueryClient() - const onChange = useCallback( - ( - e: ChangeEvent | FormEvent, - { name, value, checked }: InputOnChangeData | CheckboxProps - ) => { - if (checked !== undefined) { - setValue(name, checked) - } else { - setValue(name, value) - } - }, - [setValue] - ) - const onSubmit: SubmitHandler = data => { putTickerMastodon(data, ticker).finally(() => { queryClient.invalidateQueries(['ticker', ticker.id]) @@ -49,56 +37,73 @@ const MastodonForm: FC = ({ callback, ticker }) => { } return ( -
- - Information - - You need to create a Application for Ticker in Mastodon. Go to your - profile settings in Mastodon. You find a menu point {`"`}Developer - {`"`} where you need to create an Application. After saving you see - the required secrets and tokens. - - - Required Scopes: read write write:media write:statuses - - - - - - - - +
+ + + + You need to create a Application for Ticker in Mastodon. Go to your + profile settings in Mastodon. You find a menu point {`"`}Developer + {`"`} where you need to create an Application. After saving you see + the required secrets and tokens. Required Scopes:{' '} + read write write:media write:statuses + + + + + + } + label="Active" + /> + + + + + + + + + + + + + + + + + + + + + + + +
) } diff --git a/src/components/ticker/MastodonModalForm.tsx b/src/components/ticker/MastodonModalForm.tsx index 9b3ed3fc..1a7c27c8 100644 --- a/src/components/ticker/MastodonModalForm.tsx +++ b/src/components/ticker/MastodonModalForm.tsx @@ -1,54 +1,59 @@ -import React, { FC, useCallback, useState } from 'react' -import { Button, Modal } from 'semantic-ui-react' +import React, { FC } from 'react' +import { Close } from '@mui/icons-material' +import { + Button, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + IconButton, + Stack, +} from '@mui/material' import { Ticker } from '../../api/Ticker' import MastodonForm from './MastodonForm' interface Props { + onClose: () => void + open: boolean ticker: Ticker - trigger: React.ReactNode } -const MastodonModalForm: FC = ({ ticker, trigger }) => { - const [open, setOpen] = useState(false) - - const handleClose = useCallback(() => { - setOpen(false) - }, []) - - const handleOpen = useCallback(() => { - setOpen(true) - }, []) +const MastodonModalForm: FC = ({ onClose, open, ticker }) => { + const handleClose = () => { + onClose() + } return ( - - Configure Mastodon - + + + + Configure Mastodon + + + + + + - - - - + + + ) } diff --git a/src/components/ticker/TelegramCard.tsx b/src/components/ticker/TelegramCard.tsx index f82f21c5..b023e5f4 100644 --- a/src/components/ticker/TelegramCard.tsx +++ b/src/components/ticker/TelegramCard.tsx @@ -1,8 +1,17 @@ import { faTelegram } from '@fortawesome/free-brands-svg-icons' +import { faBan, faPause, faPlay } from '@fortawesome/free-solid-svg-icons' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' +import { + Box, + Button, + Card, + CardActions, + CardContent, + Stack, + Typography, +} from '@mui/material' import { useQueryClient } from '@tanstack/react-query' -import React, { FC, useCallback } from 'react' -import { Button, Card, Container, Icon } from 'semantic-ui-react' +import React, { FC, useCallback, useState } from 'react' import { Ticker, useTickerApi } from '../../api/Ticker' import useAuth from '../useAuth' import TelegramModalForm from './TelegramModalForm' @@ -14,6 +23,7 @@ interface Props { const TelegramCard: FC = ({ ticker }) => { const { token } = useAuth() const { deleteTickerTelegram, putTickerTelegram } = useTickerApi(token) + const [open, setOpen] = useState(false) const queryClient = useQueryClient() const telegram = ticker.telegram @@ -30,70 +40,72 @@ const TelegramCard: FC = ({ ticker }) => { }) }, [deleteTickerTelegram, queryClient, ticker]) - return telegram.connected ? ( - - - - - - {telegram.channel_name} - - Bot: {telegram.bot_username} - - - - {telegram.active ? ( - + + {telegram.connected ? ( + + + You are connected to Telegram. + + + Your Channel: {telegram.channel_name} + + + Bot: {telegram.bot_username} + + + ) : ( + <> + You are currently not connected to Telegram. New messages will not + be published to your channel and old messages can not be deleted + anymore. + + )} + + {telegram.connected ? ( + + {telegram.active ? ( + ) : ( + + )} + + + ) : null} + setOpen(false)} + open={open} + ticker={ticker} + /> + ) } diff --git a/src/components/ticker/TelegramForm.tsx b/src/components/ticker/TelegramForm.tsx index fcea7fed..d4999de1 100644 --- a/src/components/ticker/TelegramForm.tsx +++ b/src/components/ticker/TelegramForm.tsx @@ -1,20 +1,16 @@ -import React, { - ChangeEvent, - FC, - FormEvent, - useCallback, - useEffect, -} from 'react' +import React, { FC } from 'react' import { SubmitHandler, useForm } from 'react-hook-form' import { useQueryClient } from '@tanstack/react-query' -import { - CheckboxProps, - Form, - InputOnChangeData, - Message, -} from 'semantic-ui-react' import { Ticker, useTickerApi } from '../../api/Ticker' import useAuth from '../useAuth' +import { + Checkbox, + FormControlLabel, + FormGroup, + Grid, + TextField, + Typography, +} from '@mui/material' interface Props { callback: () => void @@ -30,12 +26,7 @@ const TelegramForm: FC = ({ callback, ticker }) => { const telegram = ticker.telegram const { token } = useAuth() const { putTickerTelegram } = useTickerApi(token) - const { - formState: { errors }, - handleSubmit, - register, - setValue, - } = useForm({ + const { handleSubmit, register } = useForm({ defaultValues: { active: telegram.active, channel_name: telegram.channel_name, @@ -43,26 +34,6 @@ const TelegramForm: FC = ({ callback, ticker }) => { }) const queryClient = useQueryClient() - useEffect(() => { - register('channel_name', { - pattern: { value: /@\w+/i, message: 'The Channel must start with an @' }, - }) - }, [register]) - - const onChange = useCallback( - ( - e: ChangeEvent | FormEvent, - { name, value, checked }: InputOnChangeData | CheckboxProps - ) => { - if (checked !== undefined) { - setValue(name, checked) - } else { - setValue(name, value) - } - }, - [setValue] - ) - const onSubmit: SubmitHandler = data => { putTickerTelegram(data, ticker).finally(() => { queryClient.invalidateQueries(['ticker', ticker.id]) @@ -71,34 +42,44 @@ const TelegramForm: FC = ({ callback, ticker }) => { } return ( -
- - Information - - Only public Telegram Channels are supported. The name of the Channel - is prefixed with an @ (e.g. @channel). - - - - - +
+ + + + Only public Telegram Channels are supported. The name of the Channel + is prefixed with an @ (e.g. @channel). + + + + + + } + label="Active" + /> + + + + + + + + +
) } diff --git a/src/components/ticker/TelegramModalForm.tsx b/src/components/ticker/TelegramModalForm.tsx index 1f8d30cc..a84043b3 100644 --- a/src/components/ticker/TelegramModalForm.tsx +++ b/src/components/ticker/TelegramModalForm.tsx @@ -1,54 +1,59 @@ -import React, { FC, useCallback, useState } from 'react' -import { Button, Modal } from 'semantic-ui-react' +import React, { FC } from 'react' +import { Close } from '@mui/icons-material' +import { + Button, + Dialog, + DialogActions, + DialogContent, + DialogTitle, + IconButton, + Stack, +} from '@mui/material' import { Ticker } from '../../api/Ticker' import TelegramForm from './TelegramForm' interface Props { + onClose: () => void + open: boolean ticker: Ticker - trigger: React.ReactNode } -const TelegramModalForm: FC = ({ ticker, trigger }) => { - const [open, setOpen] = useState(false) - - const handleClose = useCallback(() => { - setOpen(false) - }, []) - - const handleOpen = useCallback(() => { - setOpen(true) - }, []) +const TelegramModalForm: FC = ({ onClose, open, ticker }) => { + const handleClose = () => { + onClose() + } return ( - - Configure Telegram - + + + + Configure Telegram + + + + + + - - - - + + + ) } diff --git a/src/components/ticker/TickerForm.tsx b/src/components/ticker/TickerForm.tsx index d58fef90..455a57ba 100644 --- a/src/components/ticker/TickerForm.tsx +++ b/src/components/ticker/TickerForm.tsx @@ -8,6 +8,7 @@ import { MapContainer, Marker, TileLayer } from 'react-leaflet' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { Alert, + Box, Button, Checkbox, FormControlLabel, @@ -17,6 +18,7 @@ import { Stack, TextField, Typography, + useTheme, } from '@mui/material' import { faComputerMouse, @@ -78,6 +80,7 @@ const TickerForm: FC = ({ callback, id, ticker }) => { const { token } = useAuth() const { postTicker, putTicker } = useTickerApi(token) const queryClient = useQueryClient() + const theme = useTheme() const onLocationChange = useCallback( (result: Result) => { @@ -307,6 +310,22 @@ const TickerForm: FC = ({ callback, id, ticker }) => {
) : null}
+ + + + + + ) } diff --git a/src/components/ticker/TickerModalForm.tsx b/src/components/ticker/TickerModalForm.tsx index 0892fa18..3a795cf4 100644 --- a/src/components/ticker/TickerModalForm.tsx +++ b/src/components/ticker/TickerModalForm.tsx @@ -1,16 +1,18 @@ import { Close } from '@mui/icons-material' import { - Button, Dialog, - DialogActions, DialogContent, DialogTitle, IconButton, Stack, + Tab, + Tabs, } from '@mui/material' -import React, { FC } from 'react' +import React, { FC, useState } from 'react' import { Ticker } from '../../api/Ticker' +import TabPanel from '../common/TabPanel' import TickerForm from './TickerForm' +import TickerSocialConnections from './TickerSocialConnections' interface Props { onClose: () => void @@ -19,10 +21,16 @@ interface Props { } const TickerModalForm: FC = ({ onClose, open, ticker }) => { + const [tabValue, setTabValue] = useState(0) + const handleClose = () => { onClose() } + const handleTabChange = (e: React.SyntheticEvent, value: number) => { + setTabValue(value) + } + return ( @@ -31,28 +39,26 @@ const TickerModalForm: FC = ({ onClose, open, ticker }) => { direction="row" justifyContent="space-between" > - {ticker ? 'Update Ticker' : 'Create Ticker'} + {ticker ? 'Configure Ticker' : 'Create Ticker'} - + + + + + + + + {ticker ? ( + + + + ) : null} - - - - ) } diff --git a/src/components/ticker/TickerSocialConnections.tsx b/src/components/ticker/TickerSocialConnections.tsx new file mode 100644 index 00000000..a32d38af --- /dev/null +++ b/src/components/ticker/TickerSocialConnections.tsx @@ -0,0 +1,28 @@ +import { Grid } from '@mui/material' +import React, { FC } from 'react' +import { Ticker } from '../../api/Ticker' +import MastodonCard from './MastodonCard' +import TelegramCard from './TelegramCard' +import TwitterCard from './TwitterCard' + +interface Props { + ticker: Ticker +} + +const TickerSocialConnections: FC = ({ ticker }) => { + return ( + + + + + + + + + + + + ) +} + +export default TickerSocialConnections diff --git a/src/components/ticker/TwitterCard.tsx b/src/components/ticker/TwitterCard.tsx index 0797e1f6..5cbfe590 100644 --- a/src/components/ticker/TwitterCard.tsx +++ b/src/components/ticker/TwitterCard.tsx @@ -1,12 +1,22 @@ import React, { FC, useCallback } from 'react' import { useQueryClient } from '@tanstack/react-query' import TwitterLogin from 'react-twitter-auth' -import { Button, Card, Container, Icon, Image } from 'semantic-ui-react' import { ApiUrl } from '../../api/Api' import { Ticker, useTickerApi } from '../../api/Ticker' import useAuth from '../useAuth' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' import { faTwitter } from '@fortawesome/free-brands-svg-icons' +import { + Box, + Button, + Card, + CardActions, + CardContent, + Icon, + Stack, + Typography, +} from '@mui/material' +import { faBan, faPause, faPlay } from '@fortawesome/free-solid-svg-icons' interface Props { ticker: Ticker @@ -18,9 +28,9 @@ interface TwitterAuthResponseData { } const TwitterCard: FC = ({ ticker }) => { - const queryClient = useQueryClient() const { token } = useAuth() const { deleteTickerTwitter, putTickerTwitter } = useTickerApi(token) + const queryClient = useQueryClient() const twitter = ticker.twitter || {} const requestTokenUrl = `${ApiUrl}/admin/auth/twitter/request_token?callback=${encodeURI( @@ -62,83 +72,82 @@ const TwitterCard: FC = ({ ticker }) => { alert(error) }, []) - return twitter.connected ? ( - - - - {twitter.image_url != '' && ( - - )} - - - {twitter.name} - - - + + + + Twitter + + {twitter.connected === false ? ( + //TODO: Reimplement and remove the dependency + - @{twitter.screen_name} - - - {twitter.description} - - - - {twitter.active ? ( - + ) : ( + + )} + + + ) : null} + ) } From 61730136e863ff8d0bbe7e37a1996e1f544d2ba6 Mon Sep 17 00:00:00 2001 From: louis Date: Fri, 20 Jan 2023 21:53:16 -0800 Subject: [PATCH 19/31] =?UTF-8?q?=F0=9F=92=84=20Remove=20some=20references?= =?UTF-8?q?=20for=20semantic-ui?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/settings/RefreshIntervalForm.tsx | 3 +-- src/views/NotFoundView.tsx | 4 ++-- src/views/TickerView.tsx | 4 ++-- 3 files changed, 5 insertions(+), 6 deletions(-) diff --git a/src/components/settings/RefreshIntervalForm.tsx b/src/components/settings/RefreshIntervalForm.tsx index 9206da0a..53b422eb 100644 --- a/src/components/settings/RefreshIntervalForm.tsx +++ b/src/components/settings/RefreshIntervalForm.tsx @@ -3,8 +3,7 @@ import { SubmitHandler, useForm } from 'react-hook-form' import { useQueryClient } from '@tanstack/react-query' import { Setting, useSettingsApi } from '../../api/Settings' import useAuth from '../useAuth' -import { FormGroup, TextField } from '@mui/material' -import { Grid } from 'semantic-ui-react' +import { FormGroup, Grid, TextField } from '@mui/material' interface Props { name: string diff --git a/src/views/NotFoundView.tsx b/src/views/NotFoundView.tsx index bd827c02..45390921 100644 --- a/src/views/NotFoundView.tsx +++ b/src/views/NotFoundView.tsx @@ -1,11 +1,11 @@ +import { Alert } from '@mui/material' import React, { FC } from 'react' -import { Message } from 'semantic-ui-react' import Layout from './Layout' const NotFoundView: FC = () => { return ( - Not Found + Not found ) } diff --git a/src/views/TickerView.tsx b/src/views/TickerView.tsx index d8dfbcf7..ade0c0e6 100644 --- a/src/views/TickerView.tsx +++ b/src/views/TickerView.tsx @@ -1,5 +1,4 @@ import React, { FC } from 'react' -import { Loader } from 'semantic-ui-react' import { useTickerApi } from '../api/Ticker' import { useQuery } from '@tanstack/react-query' import { useParams } from 'react-router-dom' @@ -7,6 +6,7 @@ import useAuth from '../components/useAuth' import Ticker from '../components/ticker/Ticker' import Layout from './Layout' import ErrorView from './ErrorView' +import Loader from '../components/Loader' interface TickerViewParams { tickerId: string @@ -23,7 +23,7 @@ const TickerView: FC = () => { ) if (isLoading) { - return + return } if (error || data === undefined || data.status === 'error') { From 5afda6b710dd5a7cb84a87a757ab5ea709963b1d Mon Sep 17 00:00:00 2001 From: louis Date: Fri, 20 Jan 2023 21:53:50 -0800 Subject: [PATCH 20/31] =?UTF-8?q?=F0=9F=92=84=20Use=20MUI=20for=20TickerUs?= =?UTF-8?q?erCard?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/components/ticker/Ticker.tsx | 4 + src/components/ticker/TickerAddUserForm.tsx | 89 ++++++++++++++++++ src/components/ticker/TickerAddUserModal.tsx | 60 ++++++++++++ .../ticker/TickerDangerZoneCard.tsx | 4 +- src/components/ticker/TickerUserAddForm.tsx | 93 ------------------- src/components/ticker/TickerUserCard.tsx | 60 ------------ src/components/ticker/TickerUserList.tsx | 20 ++-- src/components/ticker/TickerUserListItem.tsx | 46 ++++----- src/components/ticker/TickerUserModalAdd.tsx | 58 ------------ .../ticker/TickerUserModalDelete.tsx | 69 +++++++++----- src/components/ticker/TickerUsersCard.tsx | 71 ++++++++++++++ 11 files changed, 304 insertions(+), 270 deletions(-) create mode 100644 src/components/ticker/TickerAddUserForm.tsx create mode 100644 src/components/ticker/TickerAddUserModal.tsx delete mode 100644 src/components/ticker/TickerUserAddForm.tsx delete mode 100644 src/components/ticker/TickerUserCard.tsx delete mode 100644 src/components/ticker/TickerUserModalAdd.tsx create mode 100644 src/components/ticker/TickerUsersCard.tsx diff --git a/src/components/ticker/Ticker.tsx b/src/components/ticker/Ticker.tsx index a63bb83c..5b2a8970 100644 --- a/src/components/ticker/Ticker.tsx +++ b/src/components/ticker/Ticker.tsx @@ -16,6 +16,7 @@ import { } from '@mui/material' import TickerModalForm from './TickerModalForm' import TickerDangerZoneCard from './TickerDangerZoneCard' +import TickerUsersCard from './TickerUsersCard' interface Props { ticker: Model @@ -62,6 +63,9 @@ const Ticker: FC = ({ ticker }) => { xs={12} > + + + diff --git a/src/components/ticker/TickerAddUserForm.tsx b/src/components/ticker/TickerAddUserForm.tsx new file mode 100644 index 00000000..da4d6d33 --- /dev/null +++ b/src/components/ticker/TickerAddUserForm.tsx @@ -0,0 +1,89 @@ +import React, { FC } from 'react' +import { Controller, SubmitHandler, useForm } from 'react-hook-form' +import { useQuery, useQueryClient } from '@tanstack/react-query' +import { Ticker, useTickerApi } from '../../api/Ticker' +import { User, useUserApi } from '../../api/User' +import useAuth from '../useAuth' +import { FormControl, InputLabel, MenuItem, Select } from '@mui/material' + +interface Props { + ticker: Ticker + onSubmit: () => void + users: User[] +} + +interface Option { + key: number + text: string + value: number +} + +interface FormValues { + users: Array +} + +const TickerAddUserForm: FC = ({ onSubmit, ticker, users }) => { + const { token } = useAuth() + const { getUsers } = useUserApi(token) + const { putTickerUsers } = useTickerApi(token) + const { isLoading, data, error } = useQuery( + ['tickerUsersAvailable'], + getUsers + ) + const { control, handleSubmit } = useForm() + const queryClient = useQueryClient() + + const updateTickerUsers: SubmitHandler = data => { + putTickerUsers(ticker, data.users).then(() => { + queryClient.invalidateQueries(['tickerUsers', ticker.id]) + onSubmit() + }) + } + + if (isLoading) { + return <>Loading + } + + if (error || data === undefined) { + return <>error + } + + const options: Array