diff --git a/media-store/app/.vscode/launch.json b/media-store/app/.vscode/launch.json new file mode 100644 index 00000000..d4e0b5d5 --- /dev/null +++ b/media-store/app/.vscode/launch.json @@ -0,0 +1,13 @@ + +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Chrome", + "type": "chrome", + "request": "launch", + "url": "http://localhost:3000", + "webRoot": "${workspaceRoot}/src" + } + ] +} \ No newline at end of file diff --git a/media-store/app/package.json b/media-store/app/package.json index 39e7b41c..6da78597 100644 --- a/media-store/app/package.json +++ b/media-store/app/package.json @@ -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": { diff --git a/media-store/app/src/App.css b/media-store/app/src/App.css index 7eaa3433..1ec8f2fe 100644 --- a/media-store/app/src/App.css +++ b/media-store/app/src/App.css @@ -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; -} diff --git a/media-store/app/src/App.js b/media-store/app/src/App.js index 286bfc9a..c1794718 100644 --- a/media-store/app/src/App.js +++ b/media-store/app/src/App.js @@ -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 ( - + - + ); }; diff --git a/media-store/app/src/GlobalContext.js b/media-store/app/src/GlobalContext.js deleted file mode 100644 index c19e1609..00000000 --- a/media-store/app/src/GlobalContext.js +++ /dev/null @@ -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 ( - {children} - ); -}; - -export { GlobalContextProvider, useGlobals }; diff --git a/media-store/app/src/api/axiosInstance.js b/media-store/app/src/api/axiosInstance.js new file mode 100644 index 00000000..abde261a --- /dev/null +++ b/media-store/app/src/api/axiosInstance.js @@ -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 }; diff --git a/media-store/app/src/api-service.js b/media-store/app/src/api/calls.js similarity index 59% rename from media-store/app/src/api-service.js rename to media-store/app/src/api/calls.js index 532afe9a..fb35f412 100644 --- a/media-store/app/src/api-service.js +++ b/media-store/app/src/api/calls.js @@ -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, }; diff --git a/media-store/app/src/api/changeAxiosDefaults.js b/media-store/app/src/api/changeAxiosDefaults.js new file mode 100644 index 00000000..be816790 --- /dev/null +++ b/media-store/app/src/api/changeAxiosDefaults.js @@ -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 }; diff --git a/media-store/app/src/api/responseErrorInterceptor.js b/media-store/app/src/api/responseErrorInterceptor.js new file mode 100644 index 00000000..ba68c2ab --- /dev/null +++ b/media-store/app/src/api/responseErrorInterceptor.js @@ -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 }; diff --git a/media-store/app/src/CurrentPageHeader.js b/media-store/app/src/components/CurrentPageHeader.js similarity index 88% rename from media-store/app/src/CurrentPageHeader.js rename to media-store/app/src/components/CurrentPageHeader.js index 7687f45b..121987f8 100644 --- a/media-store/app/src/CurrentPageHeader.js +++ b/media-store/app/src/components/CurrentPageHeader.js @@ -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 ( { fontWeight: 600, outline: "none", border: "none", - borderRadius: 6, backgroundColor: "white", padding: "0 2px", }} diff --git a/media-store/app/src/Header.css b/media-store/app/src/components/Header.css similarity index 100% rename from media-store/app/src/Header.css rename to media-store/app/src/components/Header.css diff --git a/media-store/app/src/Header.js b/media-store/app/src/components/Header.js similarity index 86% rename from media-store/app/src/Header.js rename to media-store/app/src/components/Header.js index 23719258..abf7ccc0 100644 --- a/media-store/app/src/Header.js +++ b/media-store/app/src/components/Header.js @@ -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 = () => { )); + const onUserLogout = () => { + emitter.emit("UPDATE_USER", undefined); + + history.push("/"); + }; + return (
{ {!!user ? ( { - setUser(undefined); - history.push("/"); - }} + onClick={onUserLogout} danger icon={} > diff --git a/media-store/app/src/Router.js b/media-store/app/src/components/Router.js similarity index 78% rename from media-store/app/src/Router.js rename to media-store/app/src/components/Router.js index 21dd6e96..1525bff8 100644 --- a/media-store/app/src/Router.js +++ b/media-store/app/src/components/Router.js @@ -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"); diff --git a/media-store/app/src/contexts/AppStateContext.js b/media-store/app/src/contexts/AppStateContext.js new file mode 100644 index 00000000..f2ea5eb2 --- /dev/null +++ b/media-store/app/src/contexts/AppStateContext.js @@ -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 ( + + {children} + + ); +}; + +export { AppStateContextProvider, AppStateContext }; diff --git a/media-store/app/src/withRestrictions.js b/media-store/app/src/hocs/withRestrictions.js similarity index 79% rename from media-store/app/src/withRestrictions.js rename to media-store/app/src/hocs/withRestrictions.js index 905d7eb0..1f9d0c2d 100644 --- a/media-store/app/src/withRestrictions.js +++ b/media-store/app/src/hocs/withRestrictions.js @@ -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 }) ? ( ) : ( @@ -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 }) && ( diff --git a/media-store/app/src/hooks/useAbortableEffect.js b/media-store/app/src/hooks/useAbortableEffect.js new file mode 100644 index 00000000..6fa18e87 --- /dev/null +++ b/media-store/app/src/hooks/useAbortableEffect.js @@ -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 }; diff --git a/media-store/app/src/hooks/useAppState.js b/media-store/app/src/hooks/useAppState.js new file mode 100644 index 00000000..6ea35f5f --- /dev/null +++ b/media-store/app/src/hooks/useAppState.js @@ -0,0 +1,6 @@ +import { useContext } from "react"; +import { AppStateContext } from "../contexts/AppStateContext"; + +const useAppState = () => useContext(AppStateContext); + +export { useAppState }; diff --git a/media-store/app/src/hooks/useErrors.js b/media-store/app/src/hooks/useErrors.js new file mode 100644 index 00000000..0c9d7a8c --- /dev/null +++ b/media-store/app/src/hooks/useErrors.js @@ -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 }; diff --git a/media-store/app/src/index.css b/media-store/app/src/index.css deleted file mode 100644 index b48bc267..00000000 --- a/media-store/app/src/index.css +++ /dev/null @@ -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; -} diff --git a/media-store/app/src/index.js b/media-store/app/src/index.js index 48da9f65..c1684e83 100644 --- a/media-store/app/src/index.js +++ b/media-store/app/src/index.js @@ -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"; diff --git a/media-store/app/src/pages/ErrorPage.js b/media-store/app/src/pages/ErrorPage.js index 8eb24cf1..0524f47f 100644 --- a/media-store/app/src/pages/ErrorPage.js +++ b/media-store/app/src/pages/ErrorPage.js @@ -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("/"); }; diff --git a/media-store/app/src/pages/invoice/InvoicePage.js b/media-store/app/src/pages/invoice/InvoicePage.js index b9cb0f1d..5a083281 100644 --- a/media-store/app/src/pages/invoice/InvoicePage.js +++ b/media-store/app/src/pages/invoice/InvoicePage.js @@ -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 ( -
+
{ padding: 5, }} > - diff --git a/media-store/app/src/pages/manage-store/TrackForm.js b/media-store/app/src/pages/manage-store/TrackForm.js index 135a2911..bb16bbae 100644 --- a/media-store/app/src/pages/manage-store/TrackForm.js +++ b/media-store/app/src/pages/manage-store/TrackForm.js @@ -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 }) => { ))} + + value.replace(/\$\s?|(,*)/g, "")} + /> + ); }; diff --git a/media-store/app/src/pages/person/MyInvoices.js b/media-store/app/src/pages/person/MyInvoices.js index a696d2cc..79aa6db8 100644 --- a/media-store/app/src/pages/person/MyInvoices.js +++ b/media-store/app/src/pages/person/MyInvoices.js @@ -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 && ( <> My invoices - - {invoiceElements} - + {invoiceElements} )} diff --git a/media-store/app/src/pages/person/PersonPage.js b/media-store/app/src/pages/person/PersonPage.js index 6d6ff04c..ad1a590a 100644 --- a/media-store/app/src/pages/person/PersonPage.js +++ b/media-store/app/src/pages/person/PersonPage.js @@ -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 ( <> - + {personProperties}
Email: {person.email} @@ -111,7 +110,7 @@ const PersonPage = ({ myInvoicesSection }) => { {isPersonChanged && (