add response interceptors for refreshTokens method

This commit is contained in:
Dzmitry_Tamashevich@epam.com
2020-11-23 22:27:36 +03:00
committed by Daniel Hutzel
parent 76cbf7f9ca
commit 938abb6387
53 changed files with 4702 additions and 4513 deletions

13
media-store/app/.vscode/launch.json vendored Normal file
View File

@@ -0,0 +1,13 @@
{
"version": "0.2.0",
"configurations": [
{
"name": "Chrome",
"type": "chrome",
"request": "launch",
"url": "http://localhost:3000",
"webRoot": "${workspaceRoot}/src"
}
]
}

View File

@@ -9,6 +9,7 @@
"@umijs/hooks": "^1.9.3",
"antd": "^4.8.2",
"axios": "^0.20.0",
"events": "^3.2.0",
"lodash": "^4.17.20",
"moment": "^2.29.1",
"react": "^16.14.0",
@@ -18,9 +19,9 @@
"react-scripts": "^4.0.0"
},
"scripts": {
"start": "react-scripts start",
"start": "react-scripts start --no-cache",
"build": "react-scripts build",
"test": "react-scripts test",
"test": "react-scripts test --no-cache",
"eject": "react-scripts eject"
},
"eslintConfig": {

View File

@@ -1,3 +1,22 @@
@import "~antd/dist/antd.css";
html {
overflow: hidden;
}
#root {
height: 100%;
}
section.ant-layout {
height: 100vh;
overflow: auto;
}
/* Layout
*/
.site-layout .site-layout-background {
background: #fff;
}
.App {
text-align: center;
}
@@ -36,7 +55,3 @@
transform: rotate(360deg);
}
}
.ant-menu.ant-menu-sub.ant-menu-vertical {
border-radius: 6px !important;
}

View File

@@ -2,15 +2,15 @@ import React from "react";
import "antd/dist/antd.css";
import "./App.css";
import { Layout } from "antd";
import { MyRouter } from "./Router";
import { GlobalContextProvider } from "./GlobalContext";
import { MyRouter } from "./components/Router";
import { AppStateContextProvider } from "./contexts/AppStateContext";
const App = () => {
return (
<Layout style={{ height: "100%" }}>
<GlobalContextProvider>
<AppStateContextProvider>
<MyRouter />
</GlobalContextProvider>
</AppStateContextProvider>
</Layout>
);
};

View File

@@ -1,137 +0,0 @@
import React, { useMemo, createContext, useContext, useState } from "react";
import axios from "axios";
import { isEmpty, isArray } from "lodash";
const globalContext = {
error: {},
loading: true,
user: {
ID: undefined,
roles: [],
email: undefined,
token: undefined,
},
locale: undefined,
invoicedItems: [],
notifications: [],
};
const GlobalContext = createContext(globalContext);
const useGlobals = () => useContext(GlobalContext);
const AVAILABLE_LOCALES = ["en", "fr", "de"];
const isValidUser = (user) => {
return (
!isEmpty(user) &&
user.ID &&
user.roles &&
user.email &&
user.token &&
isArray(user.roles)
);
};
const resetAxiosParams = () => {
delete axios.defaults.headers.common["Authorization"];
delete axios.defaults.userEntity;
axios.defaults.tracksEntity = "Tracks";
};
const setAxiosParams = (user) => {
axios.defaults.headers.common["Authorization"] = `Basic ${user.token}`;
axios.defaults.userID = user.ID;
if (user.roles.includes("customer")) {
axios.defaults.userEntity = `Customers/${user.ID}`;
axios.defaults.tracksEntity = "MarkedTracks";
} else {
axios.defaults.userEntity = `Employees/${user.ID}`;
axios.defaults.tracksEntity = "Tracks";
}
};
const useUserData = () => {
const getUserDataFromLS = () => {
let userFromLS;
try {
userFromLS = JSON.parse(localStorage.getItem("user"));
} catch (e) {}
if (isValidUser(userFromLS)) {
setAxiosParams(userFromLS);
return userFromLS;
} else {
localStorage.removeItem("user");
resetAxiosParams();
}
};
const setUserDataToLS = (value) => {
if (isValidUser(value)) {
localStorage.setItem("user", JSON.stringify(value));
setAxiosParams(value);
} else {
localStorage.removeItem("user");
resetAxiosParams();
}
};
const setLocaleToLS = (value) => {
localStorage.setItem("locale", value);
axios.defaults.headers.common["Accept-language"] = value;
};
const getLocaleFromLS = () => {
const localeFromLS = localStorage.getItem("locale");
const selectedLocale =
localeFromLS &&
localeFromLS !== "undefined" &&
AVAILABLE_LOCALES.includes(localeFromLS)
? localeFromLS
: "en";
axios.defaults.headers.common["Accept-language"] = selectedLocale;
return selectedLocale;
};
return { getUserDataFromLS, setUserDataToLS, setLocaleToLS, getLocaleFromLS };
};
const GlobalContextProvider = ({ children }) => {
const [loading, setLoading] = useState(false);
const [error, setError] = useState({});
const [invoicedItems, setInvoicedItems] = useState([]);
const [user, setUser] = useState(null);
const [locale, setLocale] = useState(undefined);
const {
getUserDataFromLS,
setUserDataToLS,
getLocaleFromLS,
setLocaleToLS,
} = useUserData();
const value = useMemo(
() => ({
error: error,
loading: loading,
invoicedItems: invoicedItems,
user: user ? user : getUserDataFromLS(),
locale: locale ? locale : getLocaleFromLS(),
setLoading,
setError,
setInvoicedItems,
setUser: (userParam) => {
setUserDataToLS(userParam);
setUser(userParam);
},
setLocale: (localeParam) => {
setLocaleToLS(localeParam);
setLocale(localeParam);
},
}),
[locale, user, loading, error, invoicedItems]
);
return (
<GlobalContext.Provider value={value}>{children}</GlobalContext.Provider>
);
};
export { GlobalContextProvider, useGlobals };

View File

@@ -0,0 +1,21 @@
import axios from "axios";
import { getUserFromLS, getLocaleFromLS } from "../util/localStorageService";
import {
changeUserDefaults,
changeLocaleDefaults,
} from "./changeAxiosDefaults";
import { API } from "../util/constants";
import { responseErrorInterceptor } from "./responseErrorInterceptor";
const axiosInstance = axios.create({
baseURL: API,
timeout: 1000,
});
const user = getUserFromLS();
const locale = getLocaleFromLS();
changeUserDefaults(user);
changeLocaleDefaults(locale);
axiosInstance.interceptors.response.use(null, responseErrorInterceptor);
export { axiosInstance, changeLocaleDefaults, changeUserDefaults };

View File

@@ -1,15 +1,10 @@
import { isEmpty } from "lodash";
import axios from "axios";
import { axiosInstance } from "./axiosInstance";
// in dev mode using provided api
// in prod mode using proxy
const API =
process.env.NODE_ENV === "development" ? "http://localhost:4004/" : "api/";
const BROWSE_TRACKS_SERVICE = `${API}browse-tracks`;
const INVOICES_SERVICE = `${API}browse-invoices`;
const USER_SERVICE = `${API}users`;
const MANAGE_STORE = `${API}manage-store`;
const BROWSE_TRACKS_SERVICE = "browse-tracks";
const INVOICES_SERVICE = "browse-invoices";
const USER_SERVICE = "users";
const MANAGE_STORE = "manage-store";
const constructGenresQuery = (genreIds) => {
return !isEmpty(genreIds)
@@ -29,26 +24,31 @@ const fetchTacks = ({
}`;
};
return axios.get(`${BROWSE_TRACKS_SERVICE}/${axios.defaults.tracksEntity}`, {
params: {},
paramsSerializer: () => serializeTracksUrl(),
});
return axiosInstance.get(
`${BROWSE_TRACKS_SERVICE}/${axiosInstance.defaults.tracksEntity}`,
{
params: {},
paramsSerializer: () => serializeTracksUrl(),
}
);
};
const countTracks = ({ genreIds = [], substr = "" } = {}) => {
return axios.get(
`${BROWSE_TRACKS_SERVICE}/${axios.defaults.tracksEntity}/$count?$filter=${
const tracksEntity = axiosInstance.defaults.tracksEntity;
return axiosInstance.get(
`${BROWSE_TRACKS_SERVICE}/${tracksEntity}/$count?$filter=${
`contains(name,'${substr}')` + constructGenresQuery(genreIds)
}`
);
};
const fetchGenres = () => {
return axios.get(`${BROWSE_TRACKS_SERVICE}/Genres`);
return axiosInstance.get(`${BROWSE_TRACKS_SERVICE}/Genres`);
};
const invoice = (tracks) => {
return axios.post(
return axiosInstance.post(
`${INVOICES_SERVICE}/invoice`,
{
tracks: tracks.map(({ unitPrice, ID }) => ({
@@ -63,12 +63,14 @@ const invoice = (tracks) => {
};
const fetchPerson = () => {
return axios.get(`${USER_SERVICE}/${axios.defaults.userEntity}`);
return axiosInstance.get(
`${USER_SERVICE}/${axiosInstance.defaults.userEntity}`
);
};
const confirmPerson = (person) => {
return axios.put(
`${USER_SERVICE}/${axios.defaults.userEntity}`,
return axiosInstance.put(
`${USER_SERVICE}/${axiosInstance.defaults.userEntity}`,
{
...person,
},
@@ -79,13 +81,13 @@ const confirmPerson = (person) => {
};
const fetchInvoices = () => {
return axios.get(
return axiosInstance.get(
`${INVOICES_SERVICE}/Invoices?$expand=invoiceItems($expand=track($expand=album($expand=artist)))`
);
};
const cancelInvoice = (ID) => {
return axios.post(
return axiosInstance.post(
`${INVOICES_SERVICE}/cancelInvoice`,
{
ID,
@@ -97,61 +99,71 @@ const cancelInvoice = (ID) => {
};
const fetchAlbumsByName = (substr = "", top) => {
return axios.get(
return axiosInstance.get(
`${BROWSE_TRACKS_SERVICE}/Albums?$filter=${`contains(title,'${substr}')&$top=${top}`}`
);
};
const addTrack = (data) => {
return axios.post(`${MANAGE_STORE}/Tracks`, data, {
headers: { "content-type": "application/json" },
return axiosInstance.post(`${MANAGE_STORE}/Tracks`, data, {
headers: { "content-type": "application/json;IEEE754Compatible=true" },
});
};
const addArtist = (data) => {
return axios.post(`${MANAGE_STORE}/Artists`, data, {
return axiosInstance.post(`${MANAGE_STORE}/Artists`, data, {
headers: { "content-type": "application/json" },
});
};
const addAlbum = (data) => {
return axios.post(`${MANAGE_STORE}/Albums`, data, {
return axiosInstance.post(`${MANAGE_STORE}/Albums`, data, {
headers: { "content-type": "application/json" },
});
};
const fetchArtistsByName = (substr = "", top) => {
return axios.get(
return axiosInstance.get(
`${MANAGE_STORE}/Artists?$filter=${`contains(name,'${substr}')&$top=${top}`}`
);
};
const login = (data) => {
return axios.post(`${USER_SERVICE}/login`, data, {
return axiosInstance.post(`${USER_SERVICE}/login`, data, {
headers: { "content-type": "application/json" },
});
};
const updateTrack = (track) => {
return axios.put(
return axiosInstance.put(
`${MANAGE_STORE}/Tracks/${track.ID}`,
{
...track,
},
{
headers: { "content-type": "application/json" },
headers: { "content-type": "application/json;IEEE754Compatible=true" },
}
);
};
const getTrack = (ID) => {
return axios.get(
`${BROWSE_TRACKS_SERVICE}/${axios.defaults.tracksEntity}/${ID}?$expand=genre,album($expand=artist)`
return axiosInstance.get(
`${BROWSE_TRACKS_SERVICE}/${axiosInstance.defaults.tracksEntity}/${ID}?$expand=genre,album($expand=artist)`
);
};
const deleteTrack = (ID) => {
return axios.delete(`${MANAGE_STORE}/Tracks(${ID})`);
return axiosInstance.delete(`${MANAGE_STORE}/Tracks(${ID})`);
};
const refreshTokens = (refreshToken) => {
return axiosInstance.post(
`${USER_SERVICE}/refreshTokens`,
{ refreshToken },
{
headers: { "content-type": "application/json" },
}
);
};
export {
@@ -172,4 +184,5 @@ export {
updateTrack,
getTrack,
deleteTrack,
refreshTokens,
};

View File

@@ -0,0 +1,27 @@
import { axiosInstance } from "./axiosInstance";
function changeUserDefaults(currentUser) {
if (currentUser) {
axiosInstance.defaults.headers.common[
"Authorization"
] = `Basic ${currentUser.accessToken}`;
axiosInstance.defaults.userID = currentUser.ID;
if (currentUser.roles.includes("customer")) {
axiosInstance.defaults.userEntity = `Customers/${currentUser.ID}`;
axiosInstance.defaults.tracksEntity = "MarkedTracks";
} else {
axiosInstance.defaults.userEntity = `Employees/${currentUser.ID}`;
axiosInstance.defaults.tracksEntity = "Tracks";
}
} else {
axiosInstance.defaults.tracksEntity = "Tracks";
}
}
function changeLocaleDefaults(locale) {
if (locale) {
axiosInstance.defaults.headers.common["Accept-language"] = locale;
}
}
export { changeLocaleDefaults, changeUserDefaults };

View File

@@ -0,0 +1,64 @@
import { emitter } from "../util/EventEmitter";
import { axiosInstance } from "./axiosInstance";
import { refreshTokens } from "./calls";
import { getUserFromLS } from "../util/localStorageService";
let isRefreshing = false;
let subscribers = [];
function responseErrorInterceptor(error) {
const originalRequest = error.config;
if (error.response && error.response.status === 401) {
if (originalRequest.url === "users/login") {
return Promise.reject(error);
}
// if users/refreshTokens request failed
if (isRefreshing && originalRequest.url === "users/refreshTokens") {
subscribers.forEach((request) => request.reject(error));
subscribers = [];
isRefreshing = false;
return Promise.reject(error);
}
// if got a 401 error we sending users/refreshTokens request
if (!isRefreshing) {
isRefreshing = true;
refreshTokens(getUserFromLS().refreshToken)
.then((response) => {
emitter.emit("UPDATE_USER", response.data);
subscribers.forEach((request) =>
request.resolve(response.data.accessToken)
);
})
.catch(() => {
subscribers.forEach((request) => request.reject(error));
})
.finally(() => {
subscribers = [];
isRefreshing = false;
});
return;
}
// holding requests which should be sended after users/refreshTokens complete
// otherwise if users/refreshTokens failed an error will be thrown
return new Promise((resolve, reject) => {
subscribers.push({
resolve: (newAccessToken) => {
originalRequest.headers.Authorization = "Basic " + newAccessToken;
resolve(axiosInstance(originalRequest));
},
reject: (err) => {
reject(err);
},
});
});
}
return Promise.reject(error);
}
export { responseErrorInterceptor };

View File

@@ -1,7 +1,7 @@
import React from "react";
import { Breadcrumb, Spin } from "antd";
import { useLocation } from "react-router-dom";
import { useGlobals } from "./GlobalContext";
import { useAppState } from "../hooks/useAppState";
const names = {
"/": "Browse / Tracks",
@@ -13,7 +13,7 @@ const names = {
const CurrentPageHeader = () => {
const location = useLocation();
const { loading } = useGlobals();
const { loading } = useAppState();
return (
<Breadcrumb

View File

@@ -39,7 +39,6 @@ const Editable = ({ value, onChange, type }) => {
fontWeight: 600,
outline: "none",
border: "none",
borderRadius: 6,
backgroundColor: "white",
padding: "0 2px",
}}

View File

@@ -7,7 +7,10 @@ import {
LoginOutlined,
} from "@ant-design/icons";
import { useHistory, useLocation } from "react-router-dom";
import { useGlobals } from "./GlobalContext";
import { useAppState } from "../hooks/useAppState";
import { setLocaleToLS } from "../util/localStorageService";
import { changeLocaleDefaults } from "../api/axiosInstance";
import { emitter } from "../util/EventEmitter";
import "./Header.css";
const { SubMenu } = Menu;
@@ -19,12 +22,14 @@ const RELOAD_LOCATION_NUMBER = 0;
const Header = () => {
const history = useHistory();
const location = useLocation();
const { user, invoicedItems, setUser, locale, setLocale } = useGlobals();
const { user, invoicedItems, locale, setLocale } = useAppState();
const currentKey = [keys.find((key) => key === location.pathname)];
const haveInvoicedItems = !isEmpty(invoicedItems);
const invoicedItemsLength = invoicedItems.length;
const onChangeLocale = (value) => {
setLocaleToLS(value);
changeLocaleDefaults(value);
setLocale(value);
history.go(RELOAD_LOCATION_NUMBER);
};
@@ -39,6 +44,12 @@ const Header = () => {
</Menu.Item>
));
const onUserLogout = () => {
emitter.emit("UPDATE_USER", undefined);
history.push("/");
};
return (
<div
style={{
@@ -107,10 +118,7 @@ const Header = () => {
{!!user ? (
<Menu.Item
onClick={() => {
setUser(undefined);
history.push("/");
}}
onClick={onUserLogout}
danger
icon={<LogoutOutlined style={{ fontSize: 16 }} />}
></Menu.Item>

View File

@@ -6,16 +6,19 @@ import {
Redirect,
} from "react-router-dom";
import { isEmpty } from "lodash";
import { TracksContainer } from "./pages/tracks/TracksPage";
import { TracksContainer } from "../pages/tracks/TracksPage";
import { CurrentPageHeader } from "./CurrentPageHeader";
import { Header } from "./Header";
import { PersonPage } from "./pages/person/PersonPage";
import { ErrorPage } from "./pages/ErrorPage";
import { Login } from "./pages/login/Login";
import { withRestrictions, withRestrictedSection } from "./withRestrictions";
import { InvoicePage } from "./pages/invoice/InvoicePage";
import { ManageStore } from "./pages/manage-store/ManageStore";
import { MyInvoices } from "./pages/person/MyInvoices";
import { Header } from "../components/Header";
import { PersonPage } from "../pages/person/PersonPage";
import { ErrorPage } from "../pages/ErrorPage";
import { Login } from "../pages/login/Login";
import {
withRestrictions,
withRestrictedSection,
} from "../hocs/withRestrictions";
import { InvoicePage } from "../pages/invoice/InvoicePage";
import { ManageStore } from "../pages/manage-store/ManageStore";
import { MyInvoices } from "../pages/person/MyInvoices";
const needCustomer = ({ user }) => !!user && user.roles.includes("customer");

View File

@@ -0,0 +1,72 @@
import React, { useMemo, createContext, useState, useEffect } from "react";
import {
getUserFromLS,
getLocaleFromLS,
setUserToLS,
} from "../util/localStorageService";
import { changeUserDefaults } from "../api/axiosInstance";
import { emitter } from "../util/EventEmitter";
let counter = 0;
const globalContext = {
error: {},
loading: true,
user: {
ID: undefined,
roles: [],
email: undefined,
accessToken: undefined,
refreshToken: undefined,
},
locale: undefined,
invoicedItems: [],
notifications: [],
};
const AppStateContext = createContext(globalContext);
const AppStateContextProvider = ({ children }) => {
const [invoicedItems, setInvoicedItems] = useState([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState({});
const [user, setUser] = useState(getUserFromLS());
const [locale, setLocale] = useState(getLocaleFromLS());
useEffect(() => {
const updateUser = (newUser) => {
console.log("USER_UPDATE WAS TRIGGERED", counter++);
changeUserDefaults(newUser);
setUserToLS(newUser);
setUser(newUser);
};
emitter.on("UPDATE_USER", updateUser);
console.log("listener was registered");
return () => {
emitter.removeListener("UPDATE_USER", updateUser);
};
}, []);
const value = useMemo(
() => ({
error: error,
loading: loading,
invoicedItems: invoicedItems,
user: user,
locale: locale,
setLoading,
setError,
setInvoicedItems,
setUser: setUser,
setLocale: setLocale,
}),
[locale, user, loading, error, invoicedItems]
);
return (
<AppStateContext.Provider value={value}>
{children}
</AppStateContext.Provider>
);
};
export { AppStateContextProvider, AppStateContext };

View File

@@ -1,10 +1,10 @@
import React from "react";
import { Redirect } from "react-router-dom";
import { useGlobals } from "./GlobalContext";
import { useAppState } from "../hooks/useAppState";
const withRestrictions = (Component, isUserMeetRestrictions) => {
return (props) => {
const { user, invoicedItems } = useGlobals();
const { user, invoicedItems } = useAppState();
return isUserMeetRestrictions({ user, invoicedItems }) ? (
<Component {...props} />
) : (
@@ -15,7 +15,7 @@ const withRestrictions = (Component, isUserMeetRestrictions) => {
const withRestrictedSection = (Component, isUserMeetRestrictions) => {
return (props) => {
const { user, invoicedItems } = useGlobals();
const { user, invoicedItems } = useAppState();
return (
isUserMeetRestrictions({ user, invoicedItems }) && (
<Component {...props} />

View File

@@ -0,0 +1,22 @@
import { useEffect } from "react";
function useAbortableEffect(effect, dependencies) {
const status = {}; // mutable status object
useEffect(() => {
status.aborted = false;
// pass the mutable object to the effect callback
// store the returned value for cleanup
const cleanUpFn = effect(status);
return () => {
// mutate the object to signal the consumer
// this effect is cleaning up
status.aborted = true;
if (typeof cleanUpFn === "function") {
// run the cleanup function
cleanUpFn();
}
};
}, [...dependencies]);
}
export { useAbortableEffect };

View File

@@ -0,0 +1,6 @@
import { useContext } from "react";
import { AppStateContext } from "../contexts/AppStateContext";
const useAppState = () => useContext(AppStateContext);
export { useAppState };

View File

@@ -0,0 +1,42 @@
import { useHistory } from "react-router-dom";
import { useAppState } from "./useAppState";
import { emitter } from "../util/EventEmitter";
import { message } from "antd";
import { MESSAGE_TIMEOUT } from "../util/constants";
const useErrors = () => {
const history = useHistory();
const { setError } = useAppState();
const handleError = (error) => {
console.error("Error", error);
if (error.response) {
if (error.response.status === 401) {
emitter.emit("UPDATE_USER", undefined);
message.error("You are unauthorized, try login again", MESSAGE_TIMEOUT);
}
const { status, statusText, data } = error.response;
setError({
status,
statusText,
message: data.error ? data.error.message : data,
});
} else {
setError({
status: "",
statusText: "Error",
message: "Something went wrong",
});
}
history.push("/error");
};
return {
handleError,
};
};
export { useErrors };

View File

@@ -1,18 +0,0 @@
@import "~antd/dist/antd.css";
html {
overflow: hidden;
}
#root {
height: 100%;
}
section.ant-layout {
height: 100vh;
overflow: auto;
}
/* Layout
*/
.site-layout .site-layout-background {
background: #fff;
}

View File

@@ -1,6 +1,5 @@
import React from "react";
import ReactDOM from "react-dom";
import "./index.css";
import App from "./App";
import * as serviceWorker from "./serviceWorker";

View File

@@ -1,15 +1,19 @@
import React from "react";
import React, { useEffect } from "react";
import { useHistory } from "react-router-dom";
import { isEmpty } from "lodash";
import { Result, Button } from "antd";
import { useGlobals } from "../GlobalContext";
import { useAppState } from "../hooks/useAppState";
const ErrorPage = () => {
const { error, setError } = useGlobals();
const { error, setError } = useAppState();
const history = useHistory();
const onGoHome = () => {
useEffect(() => {
setError({});
history.replace("/");
}, []);
const onGoHome = () => {
history.push("/");
};

View File

@@ -1,9 +1,11 @@
import React from "react";
import { Table, Button, message } from "antd";
import { useGlobals } from "../../GlobalContext";
import { useAppState } from "../../hooks/useAppState";
import { useHistory } from "react-router-dom";
import { invoice } from "../../api-service";
import { useErrors } from "../../useErrors";
import { invoice } from "../../api/calls";
import { useErrors } from "../../hooks/useErrors";
import { MESSAGE_TIMEOUT } from "../../util/constants";
import "./InvoicePage.css";
const columns = [
@@ -24,12 +26,11 @@ const columns = [
dataIndex: "unitPrice",
},
];
const MESSAGE_TIMEOUT = 2;
const InvoicePage = () => {
const history = useHistory();
const { handleError } = useErrors();
const { invoicedItems, setInvoicedItems, setLoading } = useGlobals();
const { invoicedItems, setInvoicedItems, setLoading } = useAppState();
const data = invoicedItems.map(({ ID: key, ...otherProps }) => ({
key,
@@ -45,12 +46,12 @@ const InvoicePage = () => {
}))
)
.then(() => {
setLoading(false);
setInvoicedItems([]);
message.success("Invoice successfully completed", MESSAGE_TIMEOUT);
history.push("/person");
})
.catch(handleError);
.catch(handleError)
.finally(() => setLoading(false));
};
const onCancel = () => {
setInvoicedItems([]);
@@ -58,7 +59,7 @@ const InvoicePage = () => {
};
return (
<div style={{ borderRadius: 6, backgroundColor: "white", padding: 10 }}>
<div style={{ backgroundColor: "white", padding: 10 }}>
<Table
bordered={false}
pagination={false}
@@ -73,17 +74,12 @@ const InvoicePage = () => {
padding: 5,
}}
>
<Button
type="primary"
size="large"
style={{ borderRadius: 6 }}
onClick={onBuy}
>
<Button type="primary" size="large" onClick={onBuy}>
Buy
</Button>
<Button
size="large"
style={{ borderRadius: 6, marginLeft: 5 }}
style={{ marginLeft: 5 }}
onClick={onCancel}
danger
>

View File

@@ -1,9 +1,11 @@
import React from "react";
import { Form, Input, Button, Checkbox, message } from "antd";
import { login } from "../../api-service";
import { login } from "../../api/calls";
import { useHistory } from "react-router-dom";
import { useGlobals } from "../../GlobalContext";
import { useErrors } from "../../useErrors";
import { useAppState } from "../../hooks/useAppState";
import { useErrors } from "../../hooks/useErrors";
import { MESSAGE_TIMEOUT } from "../../util/constants";
import { emitter } from "../../util/EventEmitter";
const layout = {
labelCol: {
@@ -19,12 +21,11 @@ const tailLayout = {
span: 8,
},
};
const MESSAGE_TIMEOUT = 2;
const Login = () => {
const [form] = Form.useForm();
const history = useHistory();
const { setLoading, setUser } = useGlobals();
const { setLoading } = useAppState();
const { handleError } = useErrors();
const onFinish = (values) => {
@@ -32,25 +33,19 @@ const Login = () => {
setLoading(true);
login({ email: values.email, password: values.password })
.then((response) => {
const { ID, email, level, token, roles } = response.data;
setUser({
ID,
roles,
email,
level,
token,
});
emitter.emit("UPDATE_USER", response.data);
history.push("/");
})
.catch((error) => {
if (error.response.status === 401) {
console.log(error);
if (error.response && error.response.status === 401) {
form.resetFields();
message.error("Invalid credentials!", MESSAGE_TIMEOUT);
} else {
handleError(error);
}
})
.then(() => setLoading(false));
.finally(() => setLoading(false));
};
const onFinishFailed = (errorInfo) => {
@@ -78,7 +73,7 @@ const Login = () => {
},
]}
>
<Input style={{ borderRadius: 6 }} />
<Input />
</Form.Item>
<Form.Item
@@ -91,7 +86,7 @@ const Login = () => {
},
]}
>
<Input.Password style={{ borderRadius: 6 }} />
<Input.Password style={{}} />
</Form.Item>
<Form.Item {...tailLayout} name="remember" valuePropName="checked">

View File

@@ -1,8 +1,8 @@
import React, { useEffect } from "react";
import { Form, Input, Select } from "antd";
import { useSearch } from "@umijs/hooks";
import { useErrors } from "../../useErrors";
import { fetchArtistsByName } from "../../api-service";
import { useErrors } from "../../hooks/useErrors";
import { fetchArtistsByName } from "../../api/calls";
const REQUIRED = [
{

View File

@@ -1,5 +0,0 @@
.ant-select.ant-select-single.ant-select-show-arrow.ant-select-show-search
> div,
.ant-form-item-control-input-content > input {
border-radius: 6px !important;
}

View File

@@ -3,9 +3,10 @@ import { Form, Radio, Button, message } from "antd";
import { TrackForm } from "./TrackForm";
import { AddArtistForm } from "./AddArtistForm";
import { AddAlbumForm } from "./AddAlbumForm";
import { useErrors } from "../../useErrors";
import { useGlobals } from "../../GlobalContext";
import { addTrack, addArtist, addAlbum } from "../../api-service";
import { useErrors } from "../../hooks/useErrors";
import { useAppState } from "../../hooks/useAppState";
import { addTrack, addArtist, addAlbum } from "../../api/calls";
import { MESSAGE_TIMEOUT } from "../../util/constants";
import "./ManageStore.css";
const FORM_TYPES = {
@@ -14,8 +15,6 @@ const FORM_TYPES = {
album: "album",
playlist: "",
};
const DEFAULT_MEDIA_TYPE_ID = 1;
const MESSAGE_TIMEOUT = 2;
const chooseForm = (type) => {
return (
@@ -28,7 +27,7 @@ const chooseForm = (type) => {
const ManageStore = () => {
const [form] = Form.useForm();
const { handleError } = useErrors();
const { setLoading } = useGlobals();
const { setLoading } = useAppState();
const [formType, setFormType] = useState("track");
useEffect(() => {
@@ -53,8 +52,8 @@ const ManageStore = () => {
name: data.name,
composer: data.composer,
album: { ID: data.albumID },
mediaType: { ID: DEFAULT_MEDIA_TYPE_ID },
genre: { ID: data.genreID },
unitPrice: data.unitPrice.toString(),
});
break;
case FORM_TYPES.artist:
@@ -68,11 +67,11 @@ const ManageStore = () => {
promise
.then(() => {
setLoading(false);
message.success("Entity successfully created", MESSAGE_TIMEOUT);
form.resetFields();
})
.catch(handleError);
.catch(handleError)
.finally(() => setLoading(false));
};
return (
@@ -95,13 +94,9 @@ const ManageStore = () => {
>
<Form.Item label="Entity" name="type">
<Radio.Group onChange={onChangeForm}>
<Radio.Button value="track" style={{ borderRadius: "6px 0 0 6px" }}>
Track
</Radio.Button>
<Radio.Button value="track">Track</Radio.Button>
<Radio.Button value="album">Album</Radio.Button>
<Radio.Button value="artist" style={{ borderRadius: "0 6px 6px 0" }}>
Artist
</Radio.Button>
<Radio.Button value="artist">Artist</Radio.Button>
</Radio.Group>
</Form.Item>
{formElement}
@@ -111,7 +106,6 @@ const ManageStore = () => {
span: 14,
offset: 4,
}}
style={{ borderRadius: 6 }}
>
<Button onClick={() => form.submit()}>Create</Button>
</Form.Item>

View File

@@ -1,10 +1,10 @@
import React, { useEffect, useState } from "react";
import { Form, Input, Select } from "antd";
import { Form, Input, Select, InputNumber } from "antd";
import { head } from "lodash";
import { useSearch } from "@umijs/hooks";
import { useGlobals } from "../../GlobalContext";
import { fetchAlbumsByName, fetchGenres } from "../../api-service";
import { useErrors } from "../../useErrors";
import { useAppState } from "../../hooks/useAppState";
import { fetchAlbumsByName, fetchGenres } from "../../api/calls";
import { useErrors } from "../../hooks/useErrors";
const ALBUMS_LIMIT = 10;
const REQUIRED = [
@@ -13,6 +13,10 @@ const REQUIRED = [
message: "This filed is required!",
},
];
const PRICE_INPUT_RULE = {
pattern: /^(?:\d*\.\d\d)$/,
message: "Price should have precision 2 and dot separator!",
};
const getAlbums = function (value) {
return fetchAlbumsByName(value, ALBUMS_LIMIT)
@@ -28,17 +32,15 @@ const TrackForm = ({ initialAlbumTitle }) => {
onChange: onChangeAlbumInput,
cancel: onAlbumCancel,
} = useSearch(getAlbums.bind({ handleError }));
const { setLoading } = useGlobals();
const { setLoading } = useAppState();
const [genres, setGenres] = useState([]);
useEffect(() => {
setLoading(true);
Promise.all([fetchGenres(), onChangeAlbumInput(initialAlbumTitle)])
.then((responses) => {
setGenres(head(responses).data.value);
setLoading(false);
})
.catch(handleError);
.then((responses) => setGenres(head(responses).data.value))
.catch(handleError)
.finally(() => setLoading(false));
}, []);
return (
@@ -76,6 +78,18 @@ const TrackForm = ({ initialAlbumTitle }) => {
))}
</Select>
</Form.Item>
<Form.Item
label="Unit price"
name="unitPrice"
precision={2}
rules={REQUIRED}
>
<InputNumber
precision={2}
decimalSeparator="."
parser={(value) => value.replace(/\$\s?|(,*)/g, "")}
/>
</Form.Item>
</div>
);
};

View File

@@ -1,12 +1,12 @@
import React, { useState, useEffect, useMemo, useCallback } from "react";
import { Button, message, Divider, Tag, Collapse, Table, Spin } from "antd";
import moment from "moment";
import { useErrors } from "../../useErrors";
import { useGlobals } from "../../GlobalContext";
import { cancelInvoice, fetchInvoices } from "../../api-service";
import { useErrors } from "../../hooks/useErrors";
import { useAppState } from "../../hooks/useAppState";
import { cancelInvoice, fetchInvoices } from "../../api/calls";
import { MESSAGE_TIMEOUT } from "../../util/constants";
const { Panel } = Collapse;
const MESSAGE_TIMEOUT = 2;
const INVOICE_STATUS = {
2: {
tagTitle: "Shipped",
@@ -64,7 +64,7 @@ const chooseStatus = (utcNowTimestamp, invoiceDate, statusFromDb) => {
};
const ExtraHeader = ({ ID, invoiceDate, status: initialStatus }) => {
const { loading, setLoading } = useGlobals();
const { loading, setLoading } = useAppState();
const { handleError } = useErrors();
const [loadingHeaderId, setLoadingHeaderId] = useState();
const [status, setStatus] = useState(initialStatus);
@@ -83,11 +83,11 @@ const ExtraHeader = ({ ID, invoiceDate, status: initialStatus }) => {
cancelInvoice(ID)
.then(() => {
message.success("Invoice successfully cancelled", MESSAGE_TIMEOUT);
setLoading(false);
setLoadingHeaderId(undefined);
setStatus(CANCELLED_STATUS);
})
.catch(handleError);
.catch(handleError)
.finally(() => setLoading(false));
};
return (
@@ -108,20 +108,15 @@ const ExtraHeader = ({ ID, invoiceDate, status: initialStatus }) => {
const MyInvoices = () => {
const { handleError } = useErrors();
const { setLoading } = useGlobals();
const { setLoading } = useAppState();
const [invoices, setInvoices] = useState([]);
useEffect(() => {
setLoading(true);
fetchInvoices()
.then((response) => {
const {
data: { value },
} = response;
setInvoices(value);
setLoading(false);
})
.catch(handleError);
.then(({ data: { value } }) => setInvoices(value))
.catch(handleError)
.finally(() => setLoading(false));
}, []);
const genExtra = useCallback(
@@ -183,9 +178,7 @@ const MyInvoices = () => {
{invoiceElements && (
<>
<Divider orientation="left">My invoices</Divider>
<Collapse style={{ borderRadius: 6 }} expandIconPosition="left">
{invoiceElements}
</Collapse>
<Collapse expandIconPosition="left">{invoiceElements}</Collapse>
</>
)}
</div>

View File

@@ -1,12 +1,13 @@
import React, { useState, useEffect, useMemo } from "react";
import React, { useState, useMemo } from "react";
import { Card, Button, message } from "antd";
import { omit } from "lodash";
import { fetchPerson, confirmPerson } from "../../api-service";
import { useErrors } from "../../useErrors";
import { useGlobals } from "../../GlobalContext";
import { Editable } from "../../Editable";
import { fetchPerson, confirmPerson } from "../../api/calls";
import { useErrors } from "../../hooks/useErrors";
import { useAppState } from "../../hooks/useAppState";
import { Editable } from "../../components/Editable";
import { MESSAGE_TIMEOUT } from "../../util/constants";
import { useAbortableEffect } from "../../hooks/useAbortableEffect";
const MESSAGE_TIMEOUT = 2;
const PERSON_PROP = {
address: "Address ",
city: "City ",
@@ -22,7 +23,7 @@ const PERSON_PROP = {
};
const PersonPage = ({ myInvoicesSection }) => {
const { setLoading } = useGlobals();
const { setLoading } = useAppState();
const { handleError } = useErrors();
const [initialPerson, setInitialPerson] = useState({});
const [person, setPerson] = useState({
@@ -39,30 +40,31 @@ const PersonPage = ({ myInvoicesSection }) => {
company: "",
});
useEffect(() => {
useAbortableEffect((status) => {
setLoading(true);
fetchPerson()
.then((response) => {
let { data: personData } = response;
.then(({ data: personData }) => {
personData = omit(personData, "@odata.context", "ID");
console.log("personData", personData);
setInitialPerson(personData);
setPerson(personData);
setLoading(false);
if (!status.aborted) {
setInitialPerson(personData);
setPerson(personData);
}
})
.catch(handleError);
.catch(handleError)
.finally(() => setLoading(false));
}, []);
const onConfirmChanges = () => {
setLoading(true);
confirmPerson(person)
.then(() => {
setLoading(false);
setInitialPerson(person);
message.success("Person successfully updated", MESSAGE_TIMEOUT);
})
.catch(handleError);
.catch(handleError)
.finally(() => setLoading(false));
};
const isPersonChanged = useMemo(() => {
const keysOne = Object.keys(initialPerson);
@@ -100,10 +102,7 @@ const PersonPage = ({ myInvoicesSection }) => {
return (
<>
<Card
style={{ borderRadius: 6 }}
title={`${person.lastName} ${person.firstName}`}
>
<Card title={`${person.lastName} ${person.firstName}`}>
{personProperties}
<div>
Email: <span style={{ fontWeight: 600 }}>{person.email}</span>
@@ -111,7 +110,7 @@ const PersonPage = ({ myInvoicesSection }) => {
{isPersonChanged && (
<Button
type="primary"
style={{ margin: 10, borderRadius: 6 }}
style={{ margin: 10 }}
onClick={onConfirmChanges}
>
Confirm changes

View File

@@ -1,10 +1,9 @@
import React, { useState } from "react";
import { Modal, message } from "antd";
import { DeleteOutlined } from "@ant-design/icons";
import { deleteTrack } from "../../api-service";
import { useErrors } from "../../useErrors";
const MESSAGE_TIMEOUT = 2;
import { deleteTrack } from "../../api/calls";
import { useErrors } from "../../hooks/useErrors";
import { MESSAGE_TIMEOUT } from "../../util/constants";
const DeleteAction = ({ ID, onDeleteTrack }) => {
const [modalVisible, setModalVisible] = useState(false);

View File

@@ -1,13 +1,20 @@
import React from "react";
import { Button, Modal, Form, message } from "antd";
import { EditOutlined, LoadingOutlined } from "@ant-design/icons";
import { useErrors } from "../../useErrors";
import { useErrors } from "../../hooks/useErrors";
import { TrackForm } from "../manage-store/TrackForm";
import { updateTrack, getTrack } from "../../api-service";
import { updateTrack, getTrack } from "../../api/calls";
import { MESSAGE_TIMEOUT } from "../../util/constants";
const MESSAGE_TIMEOUT = 2;
const EditAction = ({ ID, name, composer, genre, album, afterTrackUpdate }) => {
const EditAction = ({
ID,
name,
composer,
genre,
unitPrice,
album,
afterTrackUpdate,
}) => {
const [visible, setVisible] = React.useState(false);
const [confirmLoading, setConfirmLoading] = React.useState(false);
const [updateLoading, setUpdateLoading] = React.useState(false);
@@ -26,6 +33,7 @@ const EditAction = ({ ID, name, composer, genre, album, afterTrackUpdate }) => {
composer: value.composer,
album: { ID: value.albumID },
genre: { ID: value.genreID },
unitPrice: value.unitPrice.toString(),
})
.then(() => {
message.success("Track successfully updated!", MESSAGE_TIMEOUT);
@@ -99,6 +107,7 @@ const EditAction = ({ ID, name, composer, genre, album, afterTrackUpdate }) => {
composer: composer,
genreID: genre.ID,
albumID: album.ID,
unitPrice: unitPrice,
}}
>
<TrackForm initialAlbumTitle={album.title} />

View File

@@ -1,16 +1,15 @@
import React, { useState, useRef } from "react";
import { Card, Button } from "antd";
import { PlusOutlined, MinusOutlined } from "@ant-design/icons";
import { useGlobals } from "../../GlobalContext";
import { withRestrictedSection } from "../../withRestrictions";
import { useAppState } from "../../hooks/useAppState";
import { withRestrictedSection } from "../../hocs/withRestrictions";
import { EditAction } from "./EditAction";
import { DeleteAction } from "./DeleteAction";
import "./Track.css";
const RestrictedButton = withRestrictedSection(
Button,
({ user }) => !!user && user.roles.includes("customer")
);
const RestrictedButton = withRestrictedSection(Button, ({ user }) => {
return !!user && user.roles.includes("customer");
});
const RestrictedEditAction = withRestrictedSection(
EditAction,
@@ -28,7 +27,7 @@ const Track = ({
onDeleteTrack,
}) => {
const trackElement = useRef();
const { setInvoicedItems, invoicedItems } = useGlobals();
const { setInvoicedItems, invoicedItems } = useAppState();
const [isInvoiced, setIsInvoiced] = useState(isInvoicedProp);
const [track, setTrack] = useState(initialTrack);
@@ -70,10 +69,10 @@ const Track = ({
composer={track.composer}
album={track.album}
genre={track.genre}
unitPrice={track.unitPrice}
afterTrackUpdate={(value) => setTrack(value)}
/>,
]}
style={{ borderRadius: 6 }}
title={track.name}
bordered={false}
>

View File

@@ -1,15 +1,4 @@
div.ant-select.ant-select-multiple.ant-select-show-search > div,
div.ant-select-dropdown.ant-select-dropdown-placement-bottomLeft {
border-radius: 6px;
}
.ant-select > div.ant-select-selector {
padding: 5px;
min-width: 300px;
}
.ant-pagination-prev > .ant-pagination-item-link,
.ant-pagination-next > .ant-pagination-item-link,
.ant-pagination-item {
border-radius: 6px;
}

View File

@@ -1,11 +1,14 @@
import React, { useEffect, useState } from "react";
import React, { useState } from "react";
import { debounce } from "lodash";
import { Input, Col, Row, Select, Pagination } from "antd";
import { Track } from "./Track";
import "./TracksPage.css";
import { useGlobals } from "../../GlobalContext";
import { useErrors } from "../../useErrors";
import { fetchTacks, countTracks, fetchGenres } from "../../api-service";
import { useAppState } from "../../hooks/useAppState";
import { useErrors } from "../../hooks/useErrors";
import { fetchTacks, countTracks, fetchGenres } from "../../api/calls";
import { useAbortableEffect } from "../../hooks/useAbortableEffect";
let counter = 0;
const { Search } = Input;
const { Option } = Select;
@@ -24,7 +27,7 @@ const renderGenres = (genres) =>
));
const TracksContainer = () => {
const { setLoading, invoicedItems } = useGlobals();
const { setLoading, invoicedItems } = useAppState();
const { handleError } = useErrors();
const [state, setState] = useState({
tracks: [],
@@ -40,16 +43,17 @@ const TracksContainer = () => {
},
});
useEffect(() => {
useAbortableEffect((status) => {
setLoading(true);
const countTracksReq = countTracks();
const getTracksRequest = fetchTacks();
const getGenresReq = fetchGenres();
console.log("calling requests", counter++);
Promise.all([countTracksReq, getTracksRequest, getGenresReq])
.then((responses) => {
const [
.then(
([
{ data: totalItems },
{
data: { value: tracks },
@@ -57,16 +61,19 @@ const TracksContainer = () => {
{
data: { value: genres },
},
] = responses;
setState({
...state,
tracks,
genres,
pagination: { ...state.pagination, totalItems },
});
setLoading(false);
})
.catch(handleError);
]) => {
if (!status.aborted) {
setState({
...state,
tracks,
genres,
pagination: { ...state.pagination, totalItems },
});
}
}
)
.catch(handleError)
.finally(() => setLoading(false));
}, []);
const onSearch = debounce(
@@ -85,21 +92,15 @@ const TracksContainer = () => {
genreIds: options.genreIds,
}),
])
.then((responses) => {
const [
{
data: { value: tracks },
},
{ data: totalItems },
] = responses;
.then(([{ data: { value: tracks } }, { data: totalItems }]) =>
setState({
...state,
tracks,
pagination: { ...state.pagination, totalItems },
});
setLoading(false);
})
.catch(handleError);
})
)
.catch(handleError)
.finally(() => setLoading(false));
},
DEBOUNCE_TIMER,
DEBOUNCE_OPTIONS
@@ -132,15 +133,15 @@ const TracksContainer = () => {
$skip: (pageNumber - 1) * state.pagination.pageSize,
};
fetchTacks(options)
.then((response) => {
.then((response) =>
setState({
...state,
tracks: response.data.value,
pagination: { ...state.pagination, currentPage: pageNumber },
});
setLoading(false);
})
.catch(handleError);
})
)
.catch(handleError)
.finally(() => setLoading(false));
};
const deleteTrack = (ID) => {
setState({

View File

@@ -1,13 +0,0 @@
const reducersFactory = (initialState, handlers) => {
return (state = initialState, action) => {
const handler = handlers[action.type];
if (handler) {
return handler(state, action);
}
return state;
};
};
export { reducersFactory };

View File

@@ -1,47 +0,0 @@
import { useHistory } from "react-router-dom";
import { useGlobals } from "./GlobalContext";
import { message } from "antd";
const MESSAGE_TIMEOUT = 2;
const useErrors = () => {
const history = useHistory();
const { setError, setUser, setLoading } = useGlobals();
const handleError = (error) => {
console.error("Error", error);
console.log("error", error);
if (error.response) {
if (error.response.status === 401 || error.response.status === 403) {
setUser(undefined);
setLoading(false);
// message.error("You are unauthorized, try login again", MESSAGE_TIMEOUT);
// history.push("/login");
// return;
}
setError({
status: error.response.status,
statusText: error.response.statusText,
message: error.response.data.error
? error.response.data.error.message
: error.response.data,
});
} else {
setError({
status: "",
statusText: "Network error",
message: "Please, check your connection",
});
}
history.push("/error");
};
return {
handleError,
};
};
export { useErrors };

View File

@@ -0,0 +1,5 @@
import EventEmitter from "events";
const emitter = new EventEmitter();
export { emitter };

View File

@@ -0,0 +1,8 @@
export const AVAILABLE_LOCALES = ["en", "fr", "de"];
export const MESSAGE_TIMEOUT = 2;
// in dev mode using provided api
// in prod mode using proxy
export const API =
process.env.NODE_ENV === "development" ? "http://localhost:4004/" : "api/";

View File

@@ -0,0 +1,35 @@
import { isValidUser } from "./validateUser";
import { AVAILABLE_LOCALES } from "./constants";
const setUserToLS = (user) => {
if (user) {
localStorage.setItem("user", JSON.stringify(user));
} else {
localStorage.removeItem("user");
}
};
const getUserFromLS = () => {
let userFromLS;
try {
userFromLS = JSON.parse(localStorage.getItem("user"));
if (isValidUser(userFromLS)) {
return userFromLS;
}
} catch (e) {}
};
const getLocaleFromLS = () => {
const localeFromLS = localStorage.getItem("locale");
return localeFromLS &&
localeFromLS !== "undefined" &&
AVAILABLE_LOCALES.includes(localeFromLS)
? localeFromLS
: "en";
};
const setLocaleToLS = (locale) => {
localStorage.setItem("locale", locale);
};
export { setLocaleToLS, getLocaleFromLS, getUserFromLS, setUserToLS };

View File

@@ -0,0 +1,20 @@
import { isArray, isEmpty, isString, isNumber } from "lodash";
const CUSTOMER_ROLE = "customer";
const EMPLOYEE_ROLE = "employee";
const isValidUser = (user) => {
return (
!isEmpty(user) &&
isNumber(user.ID) &&
isArray(user.roles) &&
!!user.roles.some(
(role) => role === CUSTOMER_ROLE || role === EMPLOYEE_ROLE
) &&
isString(user.email) &&
isString(user.accessToken) &&
isString(user.refreshToken)
);
};
export { isValidUser };