diff --git a/media-store/.gitignore b/media-store/.gitignore
index 74ce3610..5dedc486 100644
--- a/media-store/.gitignore
+++ b/media-store/.gitignore
@@ -6,6 +6,10 @@ default-*.json
gen/
node_modules/
target/
+package-lock.json
+
+# html5Deployer
+html5Deployer/resources/app/
# Web IDE, App Studio
.che/
diff --git a/media-store/app/.gitignore b/media-store/app/.gitignore
new file mode 100644
index 00000000..4d29575d
--- /dev/null
+++ b/media-store/app/.gitignore
@@ -0,0 +1,23 @@
+# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
+
+# dependencies
+/node_modules
+/.pnp
+.pnp.js
+
+# testing
+/coverage
+
+# production
+/build
+
+# misc
+.DS_Store
+.env.local
+.env.development.local
+.env.test.local
+.env.production.local
+
+npm-debug.log*
+yarn-debug.log*
+yarn-error.log*
diff --git a/media-store/app/package.json b/media-store/app/package.json
new file mode 100644
index 00000000..39e7b41c
--- /dev/null
+++ b/media-store/app/package.json
@@ -0,0 +1,41 @@
+{
+ "name": "mediastore",
+ "version": "0.1.0",
+ "private": true,
+ "dependencies": {
+ "@testing-library/jest-dom": "^4.2.4",
+ "@testing-library/react": "^9.3.2",
+ "@testing-library/user-event": "^7.1.2",
+ "@umijs/hooks": "^1.9.3",
+ "antd": "^4.8.2",
+ "axios": "^0.20.0",
+ "lodash": "^4.17.20",
+ "moment": "^2.29.1",
+ "react": "^16.14.0",
+ "react-dom": "^16.14.0",
+ "react-inline-editing": "^1.0.10",
+ "react-router-dom": "^5.2.0",
+ "react-scripts": "^4.0.0"
+ },
+ "scripts": {
+ "start": "react-scripts start",
+ "build": "react-scripts build",
+ "test": "react-scripts test",
+ "eject": "react-scripts eject"
+ },
+ "eslintConfig": {
+ "extends": "react-app"
+ },
+ "browserslist": {
+ "production": [
+ ">0.2%",
+ "not dead",
+ "not op_mini all"
+ ],
+ "development": [
+ "last 1 chrome version",
+ "last 1 firefox version",
+ "last 1 safari version"
+ ]
+ }
+}
diff --git a/media-store/app/public/favicon.ico b/media-store/app/public/favicon.ico
new file mode 100644
index 00000000..bcd5dfd6
Binary files /dev/null and b/media-store/app/public/favicon.ico differ
diff --git a/media-store/app/public/index.html b/media-store/app/public/index.html
new file mode 100644
index 00000000..aa069f27
--- /dev/null
+++ b/media-store/app/public/index.html
@@ -0,0 +1,43 @@
+
+
+
+
+
+
+
+
+
+
+
+
+ React App
+
+
+
+
+
+
+
diff --git a/media-store/app/public/logo192.png b/media-store/app/public/logo192.png
new file mode 100644
index 00000000..fc44b0a3
Binary files /dev/null and b/media-store/app/public/logo192.png differ
diff --git a/media-store/app/public/logo512.png b/media-store/app/public/logo512.png
new file mode 100644
index 00000000..a4e47a65
Binary files /dev/null and b/media-store/app/public/logo512.png differ
diff --git a/media-store/app/public/manifest.json b/media-store/app/public/manifest.json
new file mode 100644
index 00000000..45979ace
--- /dev/null
+++ b/media-store/app/public/manifest.json
@@ -0,0 +1,31 @@
+{
+ "short_name": "React App",
+ "name": "Create React App Sample",
+ "icons": [
+ {
+ "src": "favicon.ico",
+ "sizes": "64x64 32x32 24x24 16x16",
+ "type": "image/x-icon"
+ },
+ {
+ "src": "logo192.png",
+ "type": "image/png",
+ "sizes": "192x192"
+ },
+ {
+ "src": "logo512.png",
+ "type": "image/png",
+ "sizes": "512x512"
+ }
+ ],
+ "start_url": ".",
+ "display": "standalone",
+ "theme_color": "#000000",
+ "background_color": "#ffffff",
+ "sap.app": {
+ "id": "mediastore",
+ "applicationVersion": {
+ "version": "1.0.0"
+ }
+ }
+}
diff --git a/media-store/app/public/robots.txt b/media-store/app/public/robots.txt
new file mode 100644
index 00000000..e9e57dc4
--- /dev/null
+++ b/media-store/app/public/robots.txt
@@ -0,0 +1,3 @@
+# https://www.robotstxt.org/robotstxt.html
+User-agent: *
+Disallow:
diff --git a/media-store/app/public/xs-app.json b/media-store/app/public/xs-app.json
new file mode 100644
index 00000000..930c40ee
--- /dev/null
+++ b/media-store/app/public/xs-app.json
@@ -0,0 +1,10 @@
+{
+ "welcomeFile": "/index.html",
+ "routes": [
+ {
+ "source": "^(.*)",
+ "target": "$1",
+ "service": "html5-apps-repo-rt"
+ }
+ ]
+}
\ No newline at end of file
diff --git a/media-store/app/src/App.css b/media-store/app/src/App.css
new file mode 100644
index 00000000..7eaa3433
--- /dev/null
+++ b/media-store/app/src/App.css
@@ -0,0 +1,42 @@
+.App {
+ text-align: center;
+}
+
+.App-logo {
+ height: 40vmin;
+ pointer-events: none;
+}
+
+@media (prefers-reduced-motion: no-preference) {
+ .App-logo {
+ animation: App-logo-spin infinite 20s linear;
+ }
+}
+
+.App-header {
+ background-color: #282c34;
+ min-height: 100vh;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ font-size: calc(10px + 2vmin);
+ color: white;
+}
+
+.App-link {
+ color: #61dafb;
+}
+
+@keyframes App-logo-spin {
+ from {
+ transform: rotate(0deg);
+ }
+ to {
+ 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
new file mode 100644
index 00000000..286bfc9a
--- /dev/null
+++ b/media-store/app/src/App.js
@@ -0,0 +1,18 @@
+import React from "react";
+import "antd/dist/antd.css";
+import "./App.css";
+import { Layout } from "antd";
+import { MyRouter } from "./Router";
+import { GlobalContextProvider } from "./GlobalContext";
+
+const App = () => {
+ return (
+
+
+
+
+
+ );
+};
+
+export default App;
diff --git a/media-store/app/src/App.test.js b/media-store/app/src/App.test.js
new file mode 100644
index 00000000..4db7ebc2
--- /dev/null
+++ b/media-store/app/src/App.test.js
@@ -0,0 +1,9 @@
+import React from 'react';
+import { render } from '@testing-library/react';
+import App from './App';
+
+test('renders learn react link', () => {
+ const { getByText } = render();
+ const linkElement = getByText(/learn react/i);
+ expect(linkElement).toBeInTheDocument();
+});
diff --git a/media-store/app/src/CurrentPageHeader.js b/media-store/app/src/CurrentPageHeader.js
new file mode 100644
index 00000000..7687f45b
--- /dev/null
+++ b/media-store/app/src/CurrentPageHeader.js
@@ -0,0 +1,30 @@
+import React from "react";
+import { Breadcrumb, Spin } from "antd";
+import { useLocation } from "react-router-dom";
+import { useGlobals } from "./GlobalContext";
+
+const names = {
+ "/": "Browse / Tracks",
+ "/person": "Profile",
+ "/login": "Login form",
+ "/invoice": "Requested items",
+ "/manage": "Manage store",
+};
+
+const CurrentPageHeader = () => {
+ const location = useLocation();
+ const { loading } = useGlobals();
+
+ return (
+
+
+ {names[location.pathname]}
+ {loading && }
+
+
+ );
+};
+
+export { CurrentPageHeader };
diff --git a/media-store/app/src/Editable.js b/media-store/app/src/Editable.js
new file mode 100644
index 00000000..edd00b06
--- /dev/null
+++ b/media-store/app/src/Editable.js
@@ -0,0 +1,52 @@
+import React, { useEffect, useRef } from "react";
+
+const Editable = ({ value, onChange, type }) => {
+ const inputRef = useRef();
+
+ useEffect(() => {
+ const { current } = inputRef;
+
+ current.value = value;
+
+ const handleFocus = () => {
+ console.log("input is focussed");
+ // current.disabled = false;
+ current.style.backgroundColor = "#f0f2f5";
+ };
+ const handleBlur = () => {
+ console.log("input is blurred");
+ // current.disabled = true;
+ current.style.backgroundColor = "white";
+ };
+
+ const handleInput = (e) => onChange(e.target.value);
+
+ current.addEventListener("focus", handleFocus);
+ current.addEventListener("blur", handleBlur);
+ current.addEventListener("input", handleInput);
+
+ return () => {
+ current.removeEventListener("focus", handleFocus);
+ current.removeEventListener("blur", handleBlur);
+ current.removeEventListener("input", handleInput);
+ };
+ });
+
+ return (
+
+ );
+};
+
+export { Editable };
diff --git a/media-store/app/src/GlobalContext.js b/media-store/app/src/GlobalContext.js
new file mode 100644
index 00000000..d3d2acd9
--- /dev/null
+++ b/media-store/app/src/GlobalContext.js
@@ -0,0 +1,126 @@
+import React, { useMemo, createContext, useContext, useState } from "react";
+import axios from "axios";
+
+const globalContext = {
+ error: {},
+ loading: true,
+ user: {
+ ID: undefined,
+ roles: [],
+ email: undefined,
+ level: undefined,
+ token: undefined,
+ },
+ locale: undefined,
+ invoicedItems: [],
+ notifications: [],
+};
+const GlobalContext = createContext(globalContext);
+const useGlobals = () => useContext(GlobalContext);
+const AVAILABLE_LOCALES = ["en", "fr", "de"];
+
+const useUserData = () => {
+ const getUserDataFromLS = () => {
+ let userFromLS;
+ try {
+ userFromLS = JSON.parse(localStorage.getItem("user"));
+ } catch (e) {}
+ if (userFromLS) {
+ axios.defaults.headers.common[
+ "Authorization"
+ ] = `Basic ${userFromLS.token}`;
+ axios.defaults.userID = userFromLS.ID;
+ axios.defaults.userEntity =
+ !!userFromLS && userFromLS.roles.includes("customer")
+ ? `Customers/${userFromLS.ID}`
+ : `Employees/${userFromLS.ID}`;
+ }
+ axios.defaults.tracksEntity =
+ !!userFromLS && userFromLS.roles.includes("customer")
+ ? "MarkedTracks"
+ : "Tracks";
+ return userFromLS;
+ };
+
+ const setUserDataToLS = (value) => {
+ if (!!value) {
+ localStorage.setItem("user", JSON.stringify(value));
+ axios.defaults.headers.common["Authorization"] = `Basic ${value.token}`;
+ axios.defaults.tracksEntity = value.roles.includes("customer")
+ ? "MarkedTracks"
+ : "Tracks";
+ axios.defaults.userEntity =
+ !!value && value.roles.includes("customer")
+ ? `Customers/${value.ID}`
+ : `Employees/${value.ID}`;
+ } else {
+ localStorage.removeItem("user");
+ delete axios.defaults.headers.common["Authorization"];
+ delete axios.defaults.userEntity;
+ axios.defaults.tracksEntity =
+ !!value && value.roles.includes("customer") ? "MarkedTracks" : "Tracks";
+ }
+ };
+
+ 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/Header.css b/media-store/app/src/Header.css
new file mode 100644
index 00000000..3d78184d
--- /dev/null
+++ b/media-store/app/src/Header.css
@@ -0,0 +1,3 @@
+.ant-menu-item .anticon {
+ margin: 0;
+}
diff --git a/media-store/app/src/Header.js b/media-store/app/src/Header.js
new file mode 100644
index 00000000..23719258
--- /dev/null
+++ b/media-store/app/src/Header.js
@@ -0,0 +1,129 @@
+import React from "react";
+import { Menu, Badge } from "antd";
+import { isEmpty } from "lodash";
+import {
+ CreditCardOutlined,
+ LogoutOutlined,
+ LoginOutlined,
+} from "@ant-design/icons";
+import { useHistory, useLocation } from "react-router-dom";
+import { useGlobals } from "./GlobalContext";
+import "./Header.css";
+
+const { SubMenu } = Menu;
+
+const keys = ["/", "/person", "/login", "/manage", "/invoice"];
+const AVAILABLE_LOCALES = ["en", "fr", "de"];
+const RELOAD_LOCATION_NUMBER = 0;
+
+const Header = () => {
+ const history = useHistory();
+ const location = useLocation();
+ const { user, invoicedItems, setUser, locale, setLocale } = useGlobals();
+ const currentKey = [keys.find((key) => key === location.pathname)];
+ const haveInvoicedItems = !isEmpty(invoicedItems);
+ const invoicedItemsLength = invoicedItems.length;
+
+ const onChangeLocale = (value) => {
+ setLocale(value);
+ history.go(RELOAD_LOCATION_NUMBER);
+ };
+ const localeElements = AVAILABLE_LOCALES.filter(
+ (localeName) => localeName !== locale
+ ).map((curLocale, index) => (
+ onChangeLocale(curLocale)}
+ >
+ {curLocale}
+
+ ));
+
+ return (
+
+
+
+
+
+ );
+};
+
+export { Header };
diff --git a/media-store/app/src/Router.js b/media-store/app/src/Router.js
new file mode 100644
index 00000000..21dd6e96
--- /dev/null
+++ b/media-store/app/src/Router.js
@@ -0,0 +1,77 @@
+import React from "react";
+import {
+ BrowserRouter as Router,
+ Switch,
+ Route,
+ Redirect,
+} from "react-router-dom";
+import { isEmpty } from "lodash";
+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";
+
+const needCustomer = ({ user }) => !!user && user.roles.includes("customer");
+
+const RestrictedLogin = withRestrictions(Login, ({ user }) => !user);
+const RestrictedInvoicePage = withRestrictions(
+ InvoicePage,
+ ({ user, invoicedItems }) => needCustomer({ user }) && !isEmpty(invoicedItems)
+);
+const RestrictedMyInvoicesSection = withRestrictedSection(
+ MyInvoices,
+ needCustomer
+);
+const RestrictedPersonPage = withRestrictions(PersonPage, ({ user }) => !!user);
+const RestrictedManageStore = withRestrictions(
+ ManageStore,
+ ({ user }) => !!user && user.roles.includes("employee")
+);
+
+const MyRouter = () => {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ }
+ />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+export { MyRouter };
diff --git a/media-store/app/src/api-service.js b/media-store/app/src/api-service.js
new file mode 100644
index 00000000..f84ef4e2
--- /dev/null
+++ b/media-store/app/src/api-service.js
@@ -0,0 +1,170 @@
+import { isEmpty } from "lodash";
+import axios from "axios";
+
+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 constructGenresQuery = (genreIds) => {
+ return !isEmpty(genreIds)
+ ? " and " + genreIds.map((value) => `genre_ID eq ${value}`).join(" or ")
+ : "";
+};
+
+const fetchTacks = ({
+ $top = 20,
+ $skip = 0,
+ genreIds = [],
+ substr = "",
+} = {}) => {
+ const serializeTracksUrl = () => {
+ return `$expand=genre,album($expand=artist)&$top=${$top}&$skip=${$skip}&$filter=${
+ `contains(name,'${substr}')` + constructGenresQuery(genreIds)
+ }`;
+ };
+
+ return axios.get(`${BROWSE_TRACKS_SERVICE}/${axios.defaults.tracksEntity}`, {
+ params: {},
+ paramsSerializer: () => serializeTracksUrl(),
+ });
+};
+
+const countTracks = ({ genreIds = [], substr = "" } = {}) => {
+ return axios.get(
+ `${BROWSE_TRACKS_SERVICE}/${axios.defaults.tracksEntity}/$count?$filter=${
+ `contains(name,'${substr}')` + constructGenresQuery(genreIds)
+ }`
+ );
+};
+
+const fetchGenres = () => {
+ return axios.get(`${BROWSE_TRACKS_SERVICE}/Genres`);
+};
+
+const invoice = (tracks) => {
+ return axios.post(
+ `${INVOICES_SERVICE}/invoice`,
+ {
+ tracks: tracks.map(({ unitPrice, ID }) => ({
+ unitPrice: `${unitPrice}`,
+ ID,
+ })),
+ },
+ {
+ headers: { "content-type": "application/json;IEEE754Compatible=true" },
+ }
+ );
+};
+
+const fetchPerson = () => {
+ return axios.get(`${USER_SERVICE}/${axios.defaults.userEntity}`);
+};
+
+const confirmPerson = (person) => {
+ return axios.put(
+ `${USER_SERVICE}/${axios.defaults.userEntity}`,
+ {
+ ...person,
+ },
+ {
+ headers: { "content-type": "application/json" },
+ }
+ );
+};
+
+const fetchInvoices = () => {
+ return axios.get(
+ `${INVOICES_SERVICE}/Invoices?$expand=invoiceItems($expand=track($expand=album($expand=artist)))`
+ );
+};
+
+const cancelInvoice = (ID) => {
+ return axios.post(
+ `${INVOICES_SERVICE}/cancelInvoice`,
+ {
+ ID,
+ },
+ {
+ headers: { "content-type": "application/json" },
+ }
+ );
+};
+
+const fetchAlbumsByName = (substr = "", top) => {
+ return axios.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" },
+ });
+};
+
+const addArtist = (data) => {
+ return axios.post(`${MANAGE_STORE}/Artists`, data, {
+ headers: { "content-type": "application/json" },
+ });
+};
+
+const addAlbum = (data) => {
+ return axios.post(`${MANAGE_STORE}/Albums`, data, {
+ headers: { "content-type": "application/json" },
+ });
+};
+
+const fetchArtistsByName = (substr = "", top) => {
+ return axios.get(
+ `${MANAGE_STORE}/Artists?$filter=${`contains(name,'${substr}')&$top=${top}`}`
+ );
+};
+
+const login = (data) => {
+ return axios.post(`${USER_SERVICE}/login`, data, {
+ headers: { "content-type": "application/json" },
+ });
+};
+
+const updateTrack = (track) => {
+ return axios.put(
+ `${MANAGE_STORE}/Tracks/${track.ID}`,
+ {
+ ...track,
+ },
+ {
+ headers: { "content-type": "application/json" },
+ }
+ );
+};
+
+const getTrack = (ID) => {
+ return axios.get(
+ `${BROWSE_TRACKS_SERVICE}/${axios.defaults.tracksEntity}/${ID}?$expand=genre,album($expand=artist)`
+ );
+};
+
+const deleteTrack = (ID) => {
+ return axios.delete(`${MANAGE_STORE}/Tracks(${ID})`);
+};
+
+export {
+ fetchTacks,
+ countTracks,
+ fetchGenres,
+ invoice,
+ fetchPerson,
+ confirmPerson,
+ fetchInvoices,
+ cancelInvoice,
+ fetchAlbumsByName,
+ addTrack,
+ addArtist,
+ addAlbum,
+ fetchArtistsByName,
+ login,
+ updateTrack,
+ getTrack,
+ deleteTrack,
+};
diff --git a/media-store/app/src/index.css b/media-store/app/src/index.css
new file mode 100644
index 00000000..b48bc267
--- /dev/null
+++ b/media-store/app/src/index.css
@@ -0,0 +1,18 @@
+@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
new file mode 100644
index 00000000..48da9f65
--- /dev/null
+++ b/media-store/app/src/index.js
@@ -0,0 +1,12 @@
+import React from "react";
+import ReactDOM from "react-dom";
+import "./index.css";
+import App from "./App";
+import * as serviceWorker from "./serviceWorker";
+
+ReactDOM.render(, document.getElementById("root"));
+
+// If you want your app to work offline and load faster, you can change
+// unregister() to register() below. Note this comes with some pitfalls.
+// Learn more about service workers: https://bit.ly/CRA-PWA
+serviceWorker.unregister();
diff --git a/media-store/app/src/logo.svg b/media-store/app/src/logo.svg
new file mode 100644
index 00000000..6b60c104
--- /dev/null
+++ b/media-store/app/src/logo.svg
@@ -0,0 +1,7 @@
+
diff --git a/media-store/app/src/pages/ErrorPage.js b/media-store/app/src/pages/ErrorPage.js
new file mode 100644
index 00000000..8eb24cf1
--- /dev/null
+++ b/media-store/app/src/pages/ErrorPage.js
@@ -0,0 +1,42 @@
+import React from "react";
+import { useHistory } from "react-router-dom";
+import { isEmpty } from "lodash";
+import { Result, Button } from "antd";
+import { useGlobals } from "../GlobalContext";
+
+const ErrorPage = () => {
+ const { error, setError } = useGlobals();
+ const history = useHistory();
+
+ const onGoHome = () => {
+ setError({});
+ history.push("/");
+ };
+
+ const errorResultProps = isEmpty(error)
+ ? {
+ status: 404,
+ title: "Not found",
+ subTitle: "Sorry, the page you visited does not exist.",
+ }
+ : {
+ status: [404, 403, 500].includes(error.status)
+ ? error.status
+ : undefined,
+ title: `${error.status} ${error.statusText}`,
+ subTitle: error.message,
+ };
+
+ return (
+
+ Back Home
+
+ }
+ />
+ );
+};
+
+export { ErrorPage };
diff --git a/media-store/app/src/pages/invoice/InvoicePage.css b/media-store/app/src/pages/invoice/InvoicePage.css
new file mode 100644
index 00000000..654e4407
--- /dev/null
+++ b/media-store/app/src/pages/invoice/InvoicePage.css
@@ -0,0 +1,4 @@
+.ant-table-cell,
+.ant-table-footer {
+ background: white !important;
+}
diff --git a/media-store/app/src/pages/invoice/InvoicePage.js b/media-store/app/src/pages/invoice/InvoicePage.js
new file mode 100644
index 00000000..b9cb0f1d
--- /dev/null
+++ b/media-store/app/src/pages/invoice/InvoicePage.js
@@ -0,0 +1,99 @@
+import React from "react";
+import { Table, Button, message } from "antd";
+import { useGlobals } from "../../GlobalContext";
+import { useHistory } from "react-router-dom";
+import { invoice } from "../../api-service";
+import { useErrors } from "../../useErrors";
+import "./InvoicePage.css";
+
+const columns = [
+ {
+ title: "Name",
+ dataIndex: "name",
+ },
+ {
+ title: "Artist",
+ dataIndex: "artist",
+ },
+ {
+ title: "Album",
+ dataIndex: "albumTitle",
+ },
+ {
+ title: "Price",
+ dataIndex: "unitPrice",
+ },
+];
+const MESSAGE_TIMEOUT = 2;
+
+const InvoicePage = () => {
+ const history = useHistory();
+ const { handleError } = useErrors();
+ const { invoicedItems, setInvoicedItems, setLoading } = useGlobals();
+
+ const data = invoicedItems.map(({ ID: key, ...otherProps }) => ({
+ key,
+ ...otherProps,
+ }));
+
+ const onBuy = () => {
+ setLoading(true);
+ invoice(
+ invoicedItems.map(({ ID, unitPrice }) => ({
+ ID,
+ unitPrice,
+ }))
+ )
+ .then(() => {
+ setLoading(false);
+ setInvoicedItems([]);
+ message.success("Invoice successfully completed", MESSAGE_TIMEOUT);
+ history.push("/person");
+ })
+ .catch(handleError);
+ };
+ const onCancel = () => {
+ setInvoicedItems([]);
+ history.push("/");
+ };
+
+ return (
+
+
(
+
+
+
+
+ )}
+ />
+
+ );
+};
+
+export { InvoicePage };
diff --git a/media-store/app/src/pages/login/Login.js b/media-store/app/src/pages/login/Login.js
new file mode 100644
index 00000000..13dbf307
--- /dev/null
+++ b/media-store/app/src/pages/login/Login.js
@@ -0,0 +1,103 @@
+import React from "react";
+import { Form, Input, Button, Checkbox } from "antd";
+import { login } from "../../api-service";
+import { useHistory } from "react-router-dom";
+import { useGlobals } from "../../GlobalContext";
+import { useErrors } from "../../useErrors";
+
+const USER_SERVICE = "http://localhost:4004/users";
+
+const layout = {
+ labelCol: {
+ span: 8,
+ },
+ wrapperCol: {
+ span: 8,
+ },
+};
+const tailLayout = {
+ wrapperCol: {
+ offset: 8,
+ span: 8,
+ },
+};
+
+const Login = () => {
+ const history = useHistory();
+ const { setLoading, setUser } = useGlobals();
+ const { handleError } = useErrors();
+
+ const onFinish = (values) => {
+ console.log("Validation Success:", values);
+ setLoading(true);
+ login({ email: values.email, password: values.password })
+ .then((response) => {
+ console.log(response.data);
+ const { ID, email, level, token, roles } = response.data;
+ setUser({
+ ID,
+ roles,
+ email,
+ level,
+ token,
+ });
+ setLoading(false);
+ history.push("/");
+ })
+ .catch(handleError);
+ };
+
+ const onFinishFailed = (errorInfo) => {
+ console.log("Validation Failed:", errorInfo);
+ };
+
+ return (
+
+
+
+
+
+
+
+
+
+ Remember me
+
+
+
+
+
+
+ );
+};
+
+export { Login };
diff --git a/media-store/app/src/pages/manage-store/AddAlbumForm.js b/media-store/app/src/pages/manage-store/AddAlbumForm.js
new file mode 100644
index 00000000..0e0494ca
--- /dev/null
+++ b/media-store/app/src/pages/manage-store/AddAlbumForm.js
@@ -0,0 +1,62 @@
+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";
+
+const REQUIRED = [
+ {
+ required: true,
+ message: "This filed is required!",
+ },
+];
+const ARTISTS_LIMIT = 10;
+
+const getArtists = function (value) {
+ return fetchArtistsByName(value, ARTISTS_LIMIT)
+ .then((response) => response.data.value)
+ .catch(this.handleError);
+};
+
+const AddAlbumForm = () => {
+ const { handleError } = useErrors();
+ // const {
+ // data: artists,
+ // loading: isArtistsLoading,
+ // onChange: onChangeArtistInput,
+ // cancel: onArtistCancel,
+ // } = useSearch(getArtists.bind({ handleError }));
+
+ // useEffect(() => {
+ // onChangeArtistInput();
+ // }, []);
+
+ return (
+ <>
+ Add album
+
+
+
+
+ {/* */}
+
+ >
+ );
+};
+
+export { AddAlbumForm };
diff --git a/media-store/app/src/pages/manage-store/AddArtistForm.js b/media-store/app/src/pages/manage-store/AddArtistForm.js
new file mode 100644
index 00000000..ed31f9d6
--- /dev/null
+++ b/media-store/app/src/pages/manage-store/AddArtistForm.js
@@ -0,0 +1,22 @@
+import React from "react";
+import { Form, Input } from "antd";
+
+const REQUIRED = [
+ {
+ required: true,
+ message: "This filed is required!",
+ },
+];
+
+const AddArtistForm = () => {
+ return (
+ <>
+ Add artist
+
+
+
+ >
+ );
+};
+
+export { AddArtistForm };
diff --git a/media-store/app/src/pages/manage-store/ManageStore.css b/media-store/app/src/pages/manage-store/ManageStore.css
new file mode 100644
index 00000000..319c262f
--- /dev/null
+++ b/media-store/app/src/pages/manage-store/ManageStore.css
@@ -0,0 +1,5 @@
+.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;
+}
diff --git a/media-store/app/src/pages/manage-store/ManageStore.js b/media-store/app/src/pages/manage-store/ManageStore.js
new file mode 100644
index 00000000..a2992883
--- /dev/null
+++ b/media-store/app/src/pages/manage-store/ManageStore.js
@@ -0,0 +1,122 @@
+import React, { useState, useMemo, useEffect } from "react";
+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 "./ManageStore.css";
+
+const FORM_TYPES = {
+ track: "track",
+ artist: "artist",
+ album: "album",
+ playlist: "",
+};
+const DEFAULT_MEDIA_TYPE_ID = 1;
+const MESSAGE_TIMEOUT = 2;
+
+const chooseForm = (type) => {
+ return (
+ (type === "track" && ) ||
+ (type === "artist" && ) ||
+ (type === "album" && )
+ );
+};
+
+const ManageStore = () => {
+ const [form] = Form.useForm();
+ const { handleError } = useErrors();
+ const { setLoading } = useGlobals();
+ const [formType, setFormType] = useState("track");
+
+ useEffect(() => {
+ form.resetFields();
+ }, [formType]);
+
+ const formElement = useMemo(() => {
+ return chooseForm(formType);
+ }, [formType]);
+
+ const onChangeForm = (event) => {
+ setFormType(event.target.value);
+ };
+
+ const sendCreateRequest = ({ type, ...data }) => {
+ setLoading(true);
+
+ let promise;
+ switch (type) {
+ case FORM_TYPES.track:
+ promise = addTrack({
+ name: data.name,
+ composer: data.composer,
+ album: { ID: data.albumID },
+ mediaType: { ID: DEFAULT_MEDIA_TYPE_ID },
+ genre: { ID: data.genreID },
+ });
+ break;
+ case FORM_TYPES.artist:
+ promise = addArtist(data);
+ break;
+ case FORM_TYPES.album:
+ promise = addAlbum({ title: data.name, artist: { ID: data.artistID } });
+ break;
+ default:
+ }
+
+ promise
+ .then(() => {
+ setLoading(false);
+ message.success("Entity successfully created", MESSAGE_TIMEOUT);
+ form.resetFields();
+ })
+ .catch(handleError);
+ };
+
+ return (
+
+
+
+ Track
+
+ Album
+
+ Artist
+
+
+
+ {formElement}
+
+
+
+
+ );
+};
+
+export { ManageStore };
diff --git a/media-store/app/src/pages/manage-store/TrackForm.js b/media-store/app/src/pages/manage-store/TrackForm.js
new file mode 100644
index 00000000..135a2911
--- /dev/null
+++ b/media-store/app/src/pages/manage-store/TrackForm.js
@@ -0,0 +1,83 @@
+import React, { useEffect, useState } from "react";
+import { Form, Input, Select } 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";
+
+const ALBUMS_LIMIT = 10;
+const REQUIRED = [
+ {
+ required: true,
+ message: "This filed is required!",
+ },
+];
+
+const getAlbums = function (value) {
+ return fetchAlbumsByName(value, ALBUMS_LIMIT)
+ .then((response) => response.data.value)
+ .catch(this.handleError);
+};
+
+const TrackForm = ({ initialAlbumTitle }) => {
+ const { handleError } = useErrors();
+ const {
+ data: albums,
+ loading: isAlbumsLoading,
+ onChange: onChangeAlbumInput,
+ cancel: onAlbumCancel,
+ } = useSearch(getAlbums.bind({ handleError }));
+ const { setLoading } = useGlobals();
+ const [genres, setGenres] = useState([]);
+
+ useEffect(() => {
+ setLoading(true);
+ Promise.all([fetchGenres(), onChangeAlbumInput(initialAlbumTitle)])
+ .then((responses) => {
+ setGenres(head(responses).data.value);
+ setLoading(false);
+ })
+ .catch(handleError);
+ }, []);
+
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+};
+
+export { TrackForm };
diff --git a/media-store/app/src/pages/person/MyInvoices.js b/media-store/app/src/pages/person/MyInvoices.js
new file mode 100644
index 00000000..6a1719f5
--- /dev/null
+++ b/media-store/app/src/pages/person/MyInvoices.js
@@ -0,0 +1,188 @@
+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";
+
+const { Panel } = Collapse;
+const MESSAGE_TIMEOUT = 2;
+const INVOICE_STATUS = {
+ 2: {
+ tagTitle: "Shipped",
+ color: "green",
+ },
+ 1: {
+ tagTitle: "Submitted",
+ color: "processing",
+ canCancel: true,
+ },
+ ["-1"]: {
+ tagTitle: "Cancelled",
+ color: "default",
+ },
+};
+const CANCELLED_STATUS = -1;
+const DATE_TIME_FORMAT_PATTERN = "LLLL";
+const INVOICE_ITEMS_COLUMNS = [
+ {
+ title: "Track name",
+ dataIndex: "name",
+ },
+ {
+ title: "Artist",
+ dataIndex: "artistName",
+ },
+ {
+ title: "Album",
+ dataIndex: "albumTitle",
+ },
+ {
+ title: "Price",
+ dataIndex: "unitPrice",
+ },
+];
+const LEVERAGE_DURATION = 1; // in hours
+const STATUSES = { submitted: 1, shipped: 2, canceled: -1 };
+
+const isLeverageTimeExpired = (invoiceDate) => {
+ const duration = moment.duration(
+ moment(moment().utc().format()).diff(invoiceDate)
+ );
+ return duration.asHours() > LEVERAGE_DURATION;
+};
+
+const chooseStatus = (invoiceDate, statusFromDb) => {
+ if (
+ isLeverageTimeExpired(invoiceDate) &&
+ statusFromDb !== STATUSES.canceled
+ ) {
+ return INVOICE_STATUS[STATUSES.shipped];
+ }
+ return INVOICE_STATUS[statusFromDb];
+};
+
+const ExtraHeader = ({ ID, invoiceDate, status: initialStatus }) => {
+ const { loading, setLoading } = useGlobals();
+ const { handleError } = useErrors();
+ const [loadingHeaderId, setLoadingHeaderId] = useState();
+ const [status, setStatus] = useState(initialStatus);
+ const statusConfig = chooseStatus(invoiceDate, status);
+
+ const onCancelInvoice = (event, ID) => {
+ event.stopPropagation();
+ setLoading(true);
+ setLoadingHeaderId(ID);
+ cancelInvoice(ID)
+ .then(() => {
+ message.success("Invoice successfully cancelled", MESSAGE_TIMEOUT);
+ setLoading(false);
+ setLoadingHeaderId(undefined);
+ setStatus(CANCELLED_STATUS);
+ })
+ .catch(handleError);
+ };
+
+ return (
+
+ {statusConfig.tagTitle}
+ {statusConfig.canCancel && (
+
+ )}
+
+ );
+};
+
+const MyInvoices = () => {
+ const { handleError } = useErrors();
+ const { setLoading } = useGlobals();
+ const [invoices, setInvoices] = useState([]);
+
+ useEffect(() => {
+ setLoading(true);
+ fetchInvoices()
+ .then((response) => {
+ const {
+ data: { value },
+ } = response;
+ setInvoices(value);
+ setLoading(false);
+ })
+ .catch(handleError);
+ }, []);
+
+ const genExtra = useCallback(
+ (ID, status, invoiceDate) => (
+
+ ),
+ []
+ );
+ const invoiceElements = useMemo(() => {
+ return invoices.map(({ ID, status, invoiceDate, total, invoiceItems }) => {
+ const invoiceItemsData = invoiceItems.map(
+ ({
+ ID,
+ track: {
+ name,
+ unitPrice,
+ album: {
+ title: albumTitle,
+ artist: { name: artistName },
+ },
+ },
+ }) => ({
+ key: ID,
+ ID,
+ name,
+ unitPrice,
+ albumTitle,
+ artistName,
+ })
+ );
+
+ return (
+
+
+
(
+ {`Total price: ${total}`}
+ )}
+ />
+
+
+ );
+ });
+ }, [invoices]);
+
+ return (
+
+ {invoiceElements && (
+ <>
+
My invoices
+
+ {invoiceElements}
+
+ >
+ )}
+
+ );
+};
+
+export { MyInvoices };
diff --git a/media-store/app/src/pages/person/PersonPage.js b/media-store/app/src/pages/person/PersonPage.js
new file mode 100644
index 00000000..6d6ff04c
--- /dev/null
+++ b/media-store/app/src/pages/person/PersonPage.js
@@ -0,0 +1,126 @@
+import React, { useState, useEffect, 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";
+
+const MESSAGE_TIMEOUT = 2;
+const PERSON_PROP = {
+ address: "Address ",
+ city: "City ",
+ country: "Country ",
+ fax: "Fax: ",
+ firstName: "First name: ",
+ lastName: "Last name: ",
+ phone: "Phone: ",
+ postalCode: "Postal code: ",
+ state: "State",
+ email: "email",
+ company: "Company: ",
+};
+
+const PersonPage = ({ myInvoicesSection }) => {
+ const { setLoading } = useGlobals();
+ const { handleError } = useErrors();
+ const [initialPerson, setInitialPerson] = useState({});
+ const [person, setPerson] = useState({
+ lastName: "",
+ firstName: "",
+ city: "",
+ state: "",
+ address: "",
+ country: "",
+ phone: "",
+ postalCode: "",
+ fax: "",
+ email: "",
+ company: "",
+ });
+
+ useEffect(() => {
+ setLoading(true);
+
+ fetchPerson()
+ .then((response) => {
+ let { data: personData } = response;
+ personData = omit(personData, "@odata.context", "ID");
+ console.log("personData", personData);
+ setInitialPerson(personData);
+ setPerson(personData);
+ setLoading(false);
+ })
+ .catch(handleError);
+ }, []);
+
+ const onConfirmChanges = () => {
+ setLoading(true);
+ confirmPerson(person)
+ .then(() => {
+ setLoading(false);
+ setInitialPerson(person);
+ message.success("Person successfully updated", MESSAGE_TIMEOUT);
+ })
+ .catch(handleError);
+ };
+ const isPersonChanged = useMemo(() => {
+ const keysOne = Object.keys(initialPerson);
+ const keysTwo = Object.keys(person);
+ if (keysOne.length !== keysTwo.length) {
+ return true;
+ }
+
+ for (let key of keysOne) {
+ if (initialPerson[key] !== person[key]) {
+ return true;
+ }
+ }
+
+ return false;
+ }, [person, initialPerson]);
+
+ const personProperties = Object.keys(person).reduce((acc, currentKey) => {
+ if (currentKey === "email") {
+ return acc;
+ }
+ return acc.concat([
+
+ {PERSON_PROP[currentKey]}
+
+ setPerson({ ...person, [`${currentKey}`]: value })
+ }
+ />
+
,
+ ]);
+ }, []);
+
+ return (
+ <>
+
+ {personProperties}
+
+ Email: {person.email}
+
+ {isPersonChanged && (
+
+ )}
+
+ {myInvoicesSection}
+ >
+ );
+};
+
+export { PersonPage };
diff --git a/media-store/app/src/pages/tracks/DeleteAction.js b/media-store/app/src/pages/tracks/DeleteAction.js
new file mode 100644
index 00000000..85ab408f
--- /dev/null
+++ b/media-store/app/src/pages/tracks/DeleteAction.js
@@ -0,0 +1,44 @@
+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;
+
+const DeleteAction = ({ ID, onDeleteTrack }) => {
+ const [modalVisible, setModalVisible] = useState(false);
+ const { handleError } = useErrors();
+
+ const onOk = () => {
+ setModalVisible(false);
+ deleteTrack(ID)
+ .then(() => {
+ onDeleteTrack();
+ setModalVisible(false);
+ message.success("Track successfully deleted!", MESSAGE_TIMEOUT);
+ })
+ .catch(handleError);
+ };
+
+ const onCancel = () => setModalVisible(false);
+ const onOpenModal = () => {
+ setModalVisible(true);
+ };
+
+ return (
+ <>
+ Delete
+
+ Are You really want to delete this track?
+
+ >
+ );
+};
+
+export { DeleteAction };
diff --git a/media-store/app/src/pages/tracks/EditAction.js b/media-store/app/src/pages/tracks/EditAction.js
new file mode 100644
index 00000000..99b89b80
--- /dev/null
+++ b/media-store/app/src/pages/tracks/EditAction.js
@@ -0,0 +1,111 @@
+import React from "react";
+import { Button, Modal, Form, message } from "antd";
+import { EditOutlined, LoadingOutlined } from "@ant-design/icons";
+import { useErrors } from "../../useErrors";
+import { TrackForm } from "../manage-store/TrackForm";
+import { updateTrack, getTrack } from "../../api-service";
+
+const MESSAGE_TIMEOUT = 2;
+
+const EditAction = ({ ID, name, composer, genre, album, afterTrackUpdate }) => {
+ const [visible, setVisible] = React.useState(false);
+ const [confirmLoading, setConfirmLoading] = React.useState(false);
+ const [updateLoading, setUpdateLoading] = React.useState(false);
+ const [form] = Form.useForm();
+ const { handleError } = useErrors();
+
+ const onShowModal = () => {
+ setVisible(true);
+ };
+
+ const onFinish = (value) => {
+ setConfirmLoading(true);
+ updateTrack({
+ ID,
+ name: value.name,
+ composer: value.composer,
+ album: { ID: value.albumID },
+ genre: { ID: value.genreID },
+ })
+ .then(() => {
+ message.success("Track successfully updated!", MESSAGE_TIMEOUT);
+ setConfirmLoading(false);
+ setVisible(false);
+ afterCloseModal();
+ })
+ .catch(handleError);
+ };
+
+ const handleOk = () => {
+ form.submit();
+ };
+
+ const handleCancel = () => {
+ console.log("Clicked cancel button");
+ setVisible(false);
+ };
+
+ const afterCloseModal = () => {
+ setUpdateLoading(true);
+ getTrack(ID)
+ .then((response) => {
+ afterTrackUpdate(response.data);
+ setUpdateLoading(false);
+ })
+ .catch(handleError);
+ };
+
+ return (
+ <>
+ {updateLoading ? (
+
+ ) : (
+
+ )}
+
+ Cancel
+ ,
+ ,
+ ]}
+ >
+
+
+ >
+ );
+};
+
+export { EditAction };
diff --git a/media-store/app/src/pages/tracks/Track.css b/media-store/app/src/pages/tracks/Track.css
new file mode 100644
index 00000000..e2107b16
--- /dev/null
+++ b/media-store/app/src/pages/tracks/Track.css
@@ -0,0 +1,7 @@
+span > span.anticon.anticon-delete:hover {
+ color: #ff4d4f;
+}
+
+.card-element {
+ transition: opacity 0.5s ease-in-out;
+}
diff --git a/media-store/app/src/pages/tracks/Track.js b/media-store/app/src/pages/tracks/Track.js
new file mode 100644
index 00000000..c71a9fb5
--- /dev/null
+++ b/media-store/app/src/pages/tracks/Track.js
@@ -0,0 +1,119 @@
+import React, { useState, useEffect, useRef } from "react";
+import { Card, Button } from "antd";
+import { PlusOutlined, MinusOutlined } from "@ant-design/icons";
+import { useGlobals } from "../../GlobalContext";
+import { withRestrictedSection } from "../../withRestrictions";
+import { EditAction } from "./EditAction";
+import { DeleteAction } from "./DeleteAction";
+import "./Track.css";
+
+const RestrictedButton = withRestrictedSection(
+ Button,
+ ({ user }) => !!user && user.roles.includes("customer")
+);
+
+const RestrictedEditAction = withRestrictedSection(
+ EditAction,
+ ({ user }) => !!user && user.roles.includes("employee")
+);
+const RestrictedDeleteAction = withRestrictedSection(
+ DeleteAction,
+ ({ user }) => !!user && user.roles.includes("employee")
+);
+
+const Track = ({
+ initialTrack,
+ isButtonVisible,
+ isInvoiced: isInvoicedProp,
+ onDeleteTrack,
+}) => {
+ const trackElement = useRef();
+ const { setInvoicedItems, invoicedItems } = useGlobals();
+ const [isInvoiced, setIsInvoiced] = useState(isInvoicedProp);
+ const [track, setTrack] = useState(initialTrack);
+
+ const onChangedStatus = () => {
+ const newInvoiced = !isInvoiced;
+ if (newInvoiced) {
+ setInvoicedItems([
+ ...invoicedItems,
+ {
+ ID: track.ID,
+ name: track.name,
+ artist: track.album.artist.name,
+ albumTitle: track.album.title,
+ unitPrice: track.unitPrice,
+ },
+ ]);
+ } else {
+ setInvoicedItems(
+ invoicedItems.filter(({ ID: curID }) => curID !== track.ID)
+ );
+ }
+ setIsInvoiced(newInvoiced);
+ };
+
+ return (
+
+
{
+ trackElement.current.style.opacity = 0;
+ setTimeout(() => onDeleteTrack(track.ID), 500);
+ }}
+ />,
+ setTrack(value)}
+ />,
+ ]}
+ style={{ borderRadius: 6 }}
+ title={track.name}
+ bordered={false}
+ >
+
+ Artist:{" "}
+ {track.album.artist.name}
+
+
+ Album: {track.album.title}
+
+
+ Genre: {track.genre.name}
+
+
+ {track.composer && (
+
+ Compositor:{" "}
+ {track.composer}
+
+ )}
+
+
+
+ Price: {track.unitPrice}
+
+ {isButtonVisible && (
+
+ {isInvoiced ? : }
+
+ )}
+
+
+
+ );
+};
+
+export { Track };
diff --git a/media-store/app/src/pages/tracks/TracksPage.css b/media-store/app/src/pages/tracks/TracksPage.css
new file mode 100644
index 00000000..1d322107
--- /dev/null
+++ b/media-store/app/src/pages/tracks/TracksPage.css
@@ -0,0 +1,15 @@
+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;
+}
diff --git a/media-store/app/src/pages/tracks/TracksPage.js b/media-store/app/src/pages/tracks/TracksPage.js
new file mode 100644
index 00000000..5f177b65
--- /dev/null
+++ b/media-store/app/src/pages/tracks/TracksPage.js
@@ -0,0 +1,223 @@
+import React, { useEffect, 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";
+
+const { Search } = Input;
+const { Option } = Select;
+
+const DEBOUNCE_TIMER = 500;
+const DEBOUNCE_OPTIONS = {
+ leading: true,
+ trailing: false,
+};
+
+const renderGenres = (genres) =>
+ genres.map(({ ID, name }) => (
+
+ ));
+
+const TracksContainer = () => {
+ const { setLoading, invoicedItems } = useGlobals();
+ const { handleError } = useErrors();
+ const [state, setState] = useState({
+ tracks: [],
+ genres: [],
+ pagination: {
+ currentPage: 1,
+ totalItems: 0,
+ pageSize: 20,
+ },
+ searchOptions: {
+ substr: "",
+ genreIds: [],
+ },
+ });
+
+ useEffect(() => {
+ setLoading(true);
+
+ const countTracksReq = countTracks();
+ const getTracksRequest = fetchTacks();
+ const getGenresReq = fetchGenres();
+
+ Promise.all([countTracksReq, getTracksRequest, getGenresReq])
+ .then((responses) => {
+ const [
+ { data: totalItems },
+ {
+ data: { value: tracks },
+ },
+ {
+ data: { value: genres },
+ },
+ ] = responses;
+ setState({
+ ...state,
+ tracks,
+ genres,
+ pagination: { ...state.pagination, totalItems },
+ });
+ setLoading(false);
+ })
+ .catch(handleError);
+ }, []);
+
+ const onSearch = debounce(
+ () => {
+ setLoading(true);
+ const options = {
+ $top: state.pagination.pageSize,
+ substr: state.searchOptions.substr,
+ genreIds: state.searchOptions.genreIds,
+ };
+
+ Promise.all([
+ fetchTacks(options),
+ countTracks({
+ substr: options.substr,
+ genreIds: options.genreIds,
+ }),
+ ])
+ .then((responses) => {
+ const [
+ {
+ data: { value: tracks },
+ },
+ { data: totalItems },
+ ] = responses;
+ setState({
+ ...state,
+ tracks,
+ pagination: { ...state.pagination, totalItems },
+ });
+ setLoading(false);
+ })
+ .catch(handleError);
+ },
+ DEBOUNCE_TIMER,
+ DEBOUNCE_OPTIONS
+ );
+ const onSelectChange = (genres) => {
+ setState({
+ ...state,
+ searchOptions: {
+ ...state.searchOptions,
+ genreIds: genres.map((value) => parseInt(value, 10)),
+ },
+ });
+ };
+ const onSearchChange = (event) => {
+ setState({
+ ...state,
+ searchOptions: { ...state.searchOptions, substr: event.target.value },
+ });
+ };
+ const onChangePage = (pageNumber) => {
+ document
+ .querySelector("section.ant-layout")
+ .scrollTo({ top: 0, left: 0, behavior: "smooth" });
+ setLoading(true);
+
+ const options = {
+ $top: state.pagination.pageSize,
+ substr: state.searchOptions.substr,
+ genreIds: state.searchOptions.genreIds,
+ $skip: (pageNumber - 1) * state.pagination.pageSize,
+ };
+ fetchTacks(options)
+ .then((response) => {
+ setState({
+ ...state,
+ tracks: response.data.value,
+ pagination: { ...state.pagination, currentPage: pageNumber },
+ });
+ setLoading(false);
+ })
+ .catch(handleError);
+ };
+ const deleteTrack = (ID) => {
+ setState({
+ ...state,
+ tracks: state.tracks.filter(({ ID: curID }) => curID !== ID),
+ });
+ };
+ const renderTracks = (tracks, invoicedItems) =>
+ tracks.map(
+ ({ ID, name, composer, genre, unitPrice, alreadyOrdered, album }) => (
+
+