moving front app to subfolder. add webpack config with watch and dev-server mode. moving deploy things in subfolder
This commit is contained in:
committed by
Daniel Hutzel
parent
0e86e1e1fd
commit
dbe4b8a7bd
5
media-store/app-src/.babelrc
Normal file
5
media-store/app-src/.babelrc
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"presets": ["@babel/preset-react", "@babel/preset-env"],
|
||||
"plugins": ["@babel/plugin-transform-runtime", "babel-plugin-syntax-dynamic-import"]
|
||||
}
|
||||
|
||||
41
media-store/app-src/.eslintrc.json
Normal file
41
media-store/app-src/.eslintrc.json
Normal file
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"env": {
|
||||
"browser": true,
|
||||
"es2020": true
|
||||
},
|
||||
"extends": ["plugin:react/recommended", "airbnb", "prettier"],
|
||||
"parserOptions": {
|
||||
"ecmaFeatures": {
|
||||
"jsx": true
|
||||
},
|
||||
"ecmaVersion": 11,
|
||||
"sourceType": "module"
|
||||
},
|
||||
"plugins": ["react", "prettier"],
|
||||
"rules": {
|
||||
"prettier/prettier": ["error", { "parser": "flow", "endOfLine": "auto" }],
|
||||
"linebreak-style": [0, "error", "windows"],
|
||||
"import/prefer-default-export": "off",
|
||||
"no-shadow": "off",
|
||||
"react/forbid-prop-types": "off",
|
||||
"no-alert": "off",
|
||||
"jsx-a11y/label-has-associated-control": [
|
||||
"error",
|
||||
{
|
||||
"required": {
|
||||
"some": ["nesting", "id"]
|
||||
}
|
||||
}
|
||||
],
|
||||
"jsx-a11y/label-has-for": [
|
||||
"error",
|
||||
{
|
||||
"required": {
|
||||
"some": ["nesting", "id"]
|
||||
}
|
||||
}
|
||||
],
|
||||
"react/jsx-props-no-spreading": "off", // props spreading,
|
||||
"no-console": "off"
|
||||
}
|
||||
}
|
||||
23
media-store/app-src/.gitignore
vendored
Normal file
23
media-store/app-src/.gitignore
vendored
Normal file
@@ -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*
|
||||
4
media-store/app-src/.prettierrc
Normal file
4
media-store/app-src/.prettierrc
Normal file
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"printWidth": 100,
|
||||
"singleQuote": true
|
||||
}
|
||||
13
media-store/app-src/.vscode/launch.json
vendored
Normal file
13
media-store/app-src/.vscode/launch.json
vendored
Normal file
@@ -0,0 +1,13 @@
|
||||
|
||||
{
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "Chrome",
|
||||
"type": "chrome",
|
||||
"request": "launch",
|
||||
"url": "http://localhost:3000",
|
||||
"webRoot": "${workspaceRoot}/src"
|
||||
}
|
||||
]
|
||||
}
|
||||
1
media-store/app-src/README.md
Normal file
1
media-store/app-src/README.md
Normal file
@@ -0,0 +1 @@
|
||||
"# Media store UI"
|
||||
63
media-store/app-src/package.json
Normal file
63
media-store/app-src/package.json
Normal file
@@ -0,0 +1,63 @@
|
||||
{
|
||||
"name": "mediastore",
|
||||
"version": "0.1.0",
|
||||
"private": false,
|
||||
"scripts": {
|
||||
"start": "./node_modules/.bin/webpack-dev-server --config ./webpack/webpack-dev-server.js",
|
||||
"watch": "./node_modules/.bin/webpack -w --config ./webpack/webpack.dev.js",
|
||||
"build": "./node_modules/.bin/webpack --config ./webpack/webpack.prod.js",
|
||||
"lint": "./node_modules/.bin/eslint"
|
||||
},
|
||||
"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",
|
||||
"clean-webpack-plugin": "^3.0.0",
|
||||
"copy-webpack-plugin": "^6.3.2",
|
||||
"css-minimizer-webpack-plugin": "^1.1.5",
|
||||
"events": "^3.2.0",
|
||||
"html-webpack-plugin": "^4.5.0",
|
||||
"lodash": "^4.17.20",
|
||||
"mini-css-extract-plugin": "^1.3.1",
|
||||
"moment": "^2.29.1",
|
||||
"prop-types": "^15.7.2",
|
||||
"react": "^16.14.0",
|
||||
"react-dev-utils": "^11.0.1",
|
||||
"react-dom": "^16.14.0",
|
||||
"react-router-dom": "^5.2.0",
|
||||
"terser-webpack-plugin": "^5.0.3",
|
||||
"webpack": "5.8.0",
|
||||
"webpack-dev-server": "^3.11.0",
|
||||
"webpack-merge": "^5.4.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.12.9",
|
||||
"@babel/plugin-transform-runtime": "^7.12.1",
|
||||
"@babel/polyfill": "^7.12.1",
|
||||
"@babel/preset-env": "^7.12.7",
|
||||
"@babel/preset-react": "^7.12.7",
|
||||
"@babel/runtime": "^7.12.5",
|
||||
"babel-loader": "^8.2.2",
|
||||
"babel-plugin-syntax-dynamic-import": "^6.18.0",
|
||||
"cowsay": "^1.4.0",
|
||||
"css-loader": "^5.0.1",
|
||||
"eslint": "^7.14.0",
|
||||
"eslint-config-airbnb": "^18.2.1",
|
||||
"eslint-config-prettier": "^6.15.0",
|
||||
"eslint-plugin-import": "^2.22.1",
|
||||
"eslint-plugin-jsx-a11y": "^6.4.1",
|
||||
"eslint-plugin-prettier": "^3.1.4",
|
||||
"eslint-plugin-react": "^7.21.5",
|
||||
"eslint-plugin-react-hooks": "^4.2.0",
|
||||
"prettier": "^2.2.1",
|
||||
"style-loader": "^2.0.0",
|
||||
"url-loader": "^4.1.1",
|
||||
"webpack-cli": "^3.3.12"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"extends": "react-app"
|
||||
}
|
||||
}
|
||||
44
media-store/app-src/public/index.html
Normal file
44
media-store/app-src/public/index.html
Normal file
@@ -0,0 +1,44 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="theme-color" content="#000000" />
|
||||
<meta
|
||||
name="description"
|
||||
content="Web site created using create-react-app" />
|
||||
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
|
||||
<!--
|
||||
manifest.json provides metadata used when your web app is installed on a
|
||||
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
|
||||
-->
|
||||
<link rel="manifest" href="%PUBLIC_URL%/manifest.json" />
|
||||
<!--
|
||||
Notice the use of %PUBLIC_URL% in the tags above.
|
||||
It will be replaced with the URL of the `public` folder during the build.
|
||||
Only files inside the `public` folder can be referenced from the HTML.
|
||||
|
||||
Unlike "/favicon.ico" or "favicon.ico", "%PUBLIC_URL%/favicon.ico" will
|
||||
work correctly both with client-side routing and a non-root public URL.
|
||||
Learn how to configure a non-root public URL by running `npm run build`.
|
||||
-->
|
||||
<title>React App</title>
|
||||
</head>
|
||||
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
<div id="root"></div>
|
||||
<!--
|
||||
This HTML file is a template.
|
||||
If you open it directly in the browser, you will see an empty page.
|
||||
|
||||
You can add webfonts, meta tags, or analytics to this file.
|
||||
The build step will place the bundled scripts into the <body> tag.
|
||||
|
||||
To begin the development, run `npm start` or `yarn start`.
|
||||
To create a production bundle, use `npm run build` or `yarn build`.
|
||||
-->
|
||||
</body>
|
||||
|
||||
</html>
|
||||
BIN
media-store/app-src/public/logo192.png
Normal file
BIN
media-store/app-src/public/logo192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.2 KiB |
BIN
media-store/app-src/public/logo512.png
Normal file
BIN
media-store/app-src/public/logo512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.4 KiB |
31
media-store/app-src/public/manifest.json
Normal file
31
media-store/app-src/public/manifest.json
Normal file
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
3
media-store/app-src/public/robots.txt
Normal file
3
media-store/app-src/public/robots.txt
Normal file
@@ -0,0 +1,3 @@
|
||||
# https://www.robotstxt.org/robotstxt.html
|
||||
User-agent: *
|
||||
Disallow:
|
||||
10
media-store/app-src/public/xs-app.json
Normal file
10
media-store/app-src/public/xs-app.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"welcomeFile": "/index.html",
|
||||
"routes": [
|
||||
{
|
||||
"source": "^(.*)",
|
||||
"target": "$1",
|
||||
"service": "html5-apps-repo-rt"
|
||||
}
|
||||
]
|
||||
}
|
||||
57
media-store/app-src/src/App.css
Normal file
57
media-store/app-src/src/App.css
Normal file
@@ -0,0 +1,57 @@
|
||||
@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;
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
}
|
||||
18
media-store/app-src/src/App.jsx
Normal file
18
media-store/app-src/src/App.jsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import React from 'react';
|
||||
import 'antd/dist/antd.css';
|
||||
import './App.css';
|
||||
import { Layout } from 'antd';
|
||||
import { MyRouter } from './components/Router';
|
||||
import { AppStateContextProvider } from './contexts/AppStateContext';
|
||||
|
||||
const App = () => {
|
||||
return (
|
||||
<Layout style={{ height: '100%' }}>
|
||||
<AppStateContextProvider>
|
||||
<MyRouter />
|
||||
</AppStateContextProvider>
|
||||
</Layout>
|
||||
);
|
||||
};
|
||||
|
||||
export default App;
|
||||
116
media-store/app-src/src/api/axiosInstance.js
Normal file
116
media-store/app-src/src/api/axiosInstance.js
Normal file
@@ -0,0 +1,116 @@
|
||||
import axios from 'axios';
|
||||
import { getUserFromLS, getLocaleFromLS } from '../util/localStorageService';
|
||||
import { emitter } from '../util/EventEmitter';
|
||||
|
||||
/**
|
||||
* This is axios instance
|
||||
*/
|
||||
const axiosInstance = axios.create({
|
||||
baseURL: process.env.SERVICE_URL,
|
||||
timeout: 2000,
|
||||
});
|
||||
|
||||
/**
|
||||
* Changing user axios default params,
|
||||
* which are used in api call functions (calls.js)
|
||||
* @param {*} currentUser current user from react state and local storage
|
||||
*/
|
||||
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';
|
||||
}
|
||||
}
|
||||
/**
|
||||
* This func changing axios instance default params
|
||||
* @param {*} locale current locale from react state and local storage
|
||||
*/
|
||||
function changeLocaleDefaults(locale) {
|
||||
if (locale) {
|
||||
axiosInstance.defaults.headers.common['Accept-language'] = locale;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Initializing initial data
|
||||
*/
|
||||
const user = getUserFromLS();
|
||||
const locale = getLocaleFromLS();
|
||||
changeUserDefaults(user);
|
||||
changeLocaleDefaults(locale);
|
||||
|
||||
/**
|
||||
* Error interceptor for refresh tokens mechanism
|
||||
*/
|
||||
let isRefreshing = false;
|
||||
let subscribers = [];
|
||||
const refreshTokens = (refreshToken) => {
|
||||
return axiosInstance.post(
|
||||
'users/refreshTokens',
|
||||
{ refreshToken },
|
||||
{
|
||||
headers: { 'content-type': 'application/json' },
|
||||
}
|
||||
);
|
||||
};
|
||||
axiosInstance.interceptors.response.use(null, (error) => {
|
||||
const originalRequest = error.config;
|
||||
const user = getUserFromLS();
|
||||
|
||||
if (error.response && error.response.status === 401 && !!user) {
|
||||
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(user.refreshToken)
|
||||
.then((response) => {
|
||||
emitter.emit('UPDATE_USER', response.data);
|
||||
subscribers.forEach((request) => request.resolve(response.data.accessToken));
|
||||
subscribers = [];
|
||||
isRefreshing = false;
|
||||
})
|
||||
.catch(() => {
|
||||
emitter.emit('UPDATE_USER', undefined);
|
||||
});
|
||||
}
|
||||
|
||||
// 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 { axiosInstance, changeLocaleDefaults, changeUserDefaults };
|
||||
167
media-store/app-src/src/api/calls.js
Normal file
167
media-store/app-src/src/api/calls.js
Normal file
@@ -0,0 +1,167 @@
|
||||
import { isEmpty } from 'lodash';
|
||||
import { axiosInstance } from './axiosInstance';
|
||||
|
||||
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)
|
||||
? ` 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 axiosInstance.get(`${BROWSE_TRACKS_SERVICE}/${axiosInstance.defaults.tracksEntity}`, {
|
||||
params: {},
|
||||
paramsSerializer: () => serializeTracksUrl(),
|
||||
});
|
||||
};
|
||||
|
||||
const countTracks = ({ genreIds = [], substr = '' } = {}) => {
|
||||
const { tracksEntity } = axiosInstance.defaults;
|
||||
|
||||
return axiosInstance.get(
|
||||
`${BROWSE_TRACKS_SERVICE}/${tracksEntity}/$count?$filter=${`contains(name,'${substr}')${constructGenresQuery(
|
||||
genreIds
|
||||
)}`}`
|
||||
);
|
||||
};
|
||||
|
||||
const fetchGenres = () => {
|
||||
return axiosInstance.get(`${BROWSE_TRACKS_SERVICE}/Genres`);
|
||||
};
|
||||
|
||||
const invoice = (tracks) => {
|
||||
return axiosInstance.post(
|
||||
`${INVOICES_SERVICE}/invoice`,
|
||||
{
|
||||
tracks: tracks.map(({ unitPrice, ID }) => ({
|
||||
unitPrice: `${unitPrice}`,
|
||||
ID,
|
||||
})),
|
||||
},
|
||||
{
|
||||
headers: { 'content-type': 'application/json;IEEE754Compatible=true' },
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const fetchPerson = () => {
|
||||
return axiosInstance.get(`${USER_SERVICE}/${axiosInstance.defaults.userEntity}`);
|
||||
};
|
||||
|
||||
const confirmPerson = (person) => {
|
||||
return axiosInstance.put(
|
||||
`${USER_SERVICE}/${axiosInstance.defaults.userEntity}`,
|
||||
{
|
||||
...person,
|
||||
},
|
||||
{
|
||||
headers: { 'content-type': 'application/json' },
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const fetchInvoices = () => {
|
||||
return axiosInstance.get(
|
||||
`${INVOICES_SERVICE}/Invoices?$expand=invoiceItems($expand=track($expand=album($expand=artist)))`
|
||||
);
|
||||
};
|
||||
|
||||
const cancelInvoice = (ID) => {
|
||||
return axiosInstance.post(
|
||||
`${INVOICES_SERVICE}/cancelInvoice`,
|
||||
{
|
||||
ID,
|
||||
},
|
||||
{
|
||||
headers: { 'content-type': 'application/json' },
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const fetchAlbumsByName = (substr = '', top) => {
|
||||
return axiosInstance.get(
|
||||
`${BROWSE_TRACKS_SERVICE}/Albums?$filter=${`contains(title,'${substr}')&$top=${top}`}`
|
||||
);
|
||||
};
|
||||
|
||||
const addTrack = (data) => {
|
||||
return axiosInstance.post(`${MANAGE_STORE}/Tracks`, data, {
|
||||
headers: { 'content-type': 'application/json;IEEE754Compatible=true' },
|
||||
});
|
||||
};
|
||||
|
||||
const addArtist = (data) => {
|
||||
return axiosInstance.post(`${MANAGE_STORE}/Artists`, data, {
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
};
|
||||
|
||||
const addAlbum = (data) => {
|
||||
return axiosInstance.post(`${MANAGE_STORE}/Albums`, data, {
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
};
|
||||
|
||||
const fetchArtistsByName = (substr = '', top) => {
|
||||
return axiosInstance.get(
|
||||
`${MANAGE_STORE}/Artists?$filter=${`contains(name,'${substr}')&$top=${top}`}`
|
||||
);
|
||||
};
|
||||
|
||||
const login = (data) => {
|
||||
return axiosInstance.post(`${USER_SERVICE}/login`, data, {
|
||||
headers: { 'content-type': 'application/json' },
|
||||
});
|
||||
};
|
||||
|
||||
const updateTrack = (track) => {
|
||||
return axiosInstance.put(
|
||||
`${MANAGE_STORE}/Tracks/${track.ID}`,
|
||||
{
|
||||
...track,
|
||||
},
|
||||
{
|
||||
headers: { 'content-type': 'application/json;IEEE754Compatible=true' },
|
||||
}
|
||||
);
|
||||
};
|
||||
|
||||
const getTrack = (ID) => {
|
||||
return axiosInstance.get(
|
||||
`${BROWSE_TRACKS_SERVICE}/${axiosInstance.defaults.tracksEntity}/${ID}?$expand=genre,album($expand=artist)`
|
||||
);
|
||||
};
|
||||
|
||||
const deleteTrack = (ID) => {
|
||||
return axiosInstance.delete(`${MANAGE_STORE}/Tracks(${ID})`);
|
||||
};
|
||||
|
||||
export {
|
||||
fetchTacks,
|
||||
countTracks,
|
||||
fetchGenres,
|
||||
invoice,
|
||||
fetchPerson,
|
||||
confirmPerson,
|
||||
fetchInvoices,
|
||||
cancelInvoice,
|
||||
fetchAlbumsByName,
|
||||
addTrack,
|
||||
addArtist,
|
||||
addAlbum,
|
||||
fetchArtistsByName,
|
||||
login,
|
||||
updateTrack,
|
||||
getTrack,
|
||||
deleteTrack,
|
||||
};
|
||||
49
media-store/app-src/src/components/ErrorPage.jsx
Normal file
49
media-store/app-src/src/components/ErrorPage.jsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import React from 'react';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import { isEmpty } from 'lodash';
|
||||
import { Result, Button } from 'antd';
|
||||
import { useAppState } from '../hooks/useAppState';
|
||||
|
||||
const ErrorPage = () => {
|
||||
const { error, setError } = useAppState();
|
||||
const history = useHistory();
|
||||
|
||||
const onGoHome = () => {
|
||||
setError({});
|
||||
history.push('/');
|
||||
};
|
||||
|
||||
const goLoginPage = () => {
|
||||
setError({});
|
||||
history.push('/login');
|
||||
};
|
||||
|
||||
const goHomeButton = (
|
||||
<Button onClick={onGoHome} key={1} type="primary">
|
||||
Back Home
|
||||
</Button>
|
||||
);
|
||||
const goLoginButton = (
|
||||
<Button onClick={goLoginPage} key={2} type="primary">
|
||||
Login
|
||||
</Button>
|
||||
);
|
||||
|
||||
const errorResultProps = isEmpty(error)
|
||||
? {
|
||||
status: 404,
|
||||
title: 'Not found',
|
||||
subTitle: 'Sorry, the page you visited does not exist.',
|
||||
extra: goHomeButton,
|
||||
}
|
||||
: {
|
||||
status: [404, 403, 500].includes(error.status) ? error.status : 'error',
|
||||
title: error.statusText,
|
||||
subTitle: error.message,
|
||||
extra: error.status === 401 ? [goHomeButton, goLoginButton] : goHomeButton,
|
||||
};
|
||||
|
||||
return <Result {...errorResultProps} />;
|
||||
};
|
||||
|
||||
export default ErrorPage;
|
||||
3
media-store/app-src/src/components/Header.css
Normal file
3
media-store/app-src/src/components/Header.css
Normal file
@@ -0,0 +1,3 @@
|
||||
.ant-menu-item .anticon {
|
||||
margin: 0;
|
||||
}
|
||||
139
media-store/app-src/src/components/Header.jsx
Normal file
139
media-store/app-src/src/components/Header.jsx
Normal file
@@ -0,0 +1,139 @@
|
||||
import React from 'react';
|
||||
import { Menu, Badge, Spin } from 'antd';
|
||||
import { isEmpty } from 'lodash';
|
||||
import {
|
||||
CreditCardOutlined,
|
||||
LogoutOutlined,
|
||||
LoginOutlined,
|
||||
LoadingOutlined,
|
||||
} from '@ant-design/icons';
|
||||
import { useHistory, useLocation } from 'react-router-dom';
|
||||
import { useAppState } from '../hooks/useAppState';
|
||||
import { setLocaleToLS } from '../util/localStorageService';
|
||||
import { changeLocaleDefaults } from '../api/axiosInstance';
|
||||
import { emitter } from '../util/EventEmitter';
|
||||
import './Header.css';
|
||||
import { requireEmployee, requireCustomer } from '../util/constants';
|
||||
|
||||
const { SubMenu } = Menu;
|
||||
|
||||
const keys = ['/', '/person', '/login', '/manage', '/invoice', '/invoices'];
|
||||
const AVAILABLE_LOCALES = ['en', 'fr', 'de'];
|
||||
const RELOAD_LOCATION_NUMBER = 0;
|
||||
|
||||
const Header = () => {
|
||||
const history = useHistory();
|
||||
const location = useLocation();
|
||||
const { user, invoicedItems, setInvoicedItems, locale, setLocale, loading } = 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);
|
||||
};
|
||||
const localeElements = AVAILABLE_LOCALES.filter((localeName) => localeName !== locale).map(
|
||||
(curLocale, index) => (
|
||||
<Menu.Item key={`${index}${curLocale}`} onClick={() => onChangeLocale(curLocale)}>
|
||||
{curLocale}
|
||||
</Menu.Item>
|
||||
)
|
||||
);
|
||||
|
||||
const onUserLogout = () => {
|
||||
emitter.emit('UPDATE_USER', undefined);
|
||||
history.go(0);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'baseline',
|
||||
alignItems: 'center',
|
||||
paddingLeft: '15vh',
|
||||
paddingRight: '15vh',
|
||||
background: 'white',
|
||||
}}
|
||||
>
|
||||
<Menu theme="light" mode="horizontal" style={{ width: '50%' }} selectedKeys={currentKey}>
|
||||
<Menu.Item key="/" onClick={() => history.push('/')}>
|
||||
Browse
|
||||
</Menu.Item>
|
||||
|
||||
{!!user && (
|
||||
<Menu.Item key="/person" onClick={() => history.push('/person')}>
|
||||
Profile
|
||||
</Menu.Item>
|
||||
)}
|
||||
{requireCustomer(user) && (
|
||||
<Menu.Item key="/invoices" onClick={() => history.push('/invoices')}>
|
||||
Invoices
|
||||
</Menu.Item>
|
||||
)}
|
||||
{requireEmployee(user) && (
|
||||
<Menu.Item key="/manage" onClick={() => history.push('/manage')}>
|
||||
Manages
|
||||
</Menu.Item>
|
||||
)}
|
||||
<span>
|
||||
{loading && <Spin indicator={<LoadingOutlined style={{ fontSize: 24 }} spin />} />}
|
||||
</span>
|
||||
</Menu>
|
||||
|
||||
<Menu
|
||||
style={{ width: '50%', display: 'flex', justifyContent: 'flex-end' }}
|
||||
theme="light"
|
||||
mode="horizontal"
|
||||
selectedKeys={currentKey}
|
||||
>
|
||||
{haveInvoicedItems && (
|
||||
<Menu.Item
|
||||
style={{
|
||||
width: 40,
|
||||
display: 'flex',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
onClick={() => history.push('/invoice')}
|
||||
key="/invoice"
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
height: '100%',
|
||||
}}
|
||||
>
|
||||
<Badge
|
||||
size="default"
|
||||
style={{ backgroundColor: '#2db7f5' }}
|
||||
count={invoicedItemsLength}
|
||||
>
|
||||
<CreditCardOutlined style={{ fontSize: 16 }} />
|
||||
</Badge>
|
||||
</div>
|
||||
</Menu.Item>
|
||||
)}
|
||||
|
||||
<SubMenu title={locale}>{localeElements}</SubMenu>
|
||||
|
||||
{!!user ? (
|
||||
<Menu.Item
|
||||
onClick={onUserLogout}
|
||||
danger
|
||||
icon={<LogoutOutlined style={{ fontSize: 16 }} />}
|
||||
></Menu.Item>
|
||||
) : (
|
||||
<Menu.Item
|
||||
key="/login"
|
||||
onClick={() => history.push('/login')}
|
||||
icon={<LoginOutlined style={{ fontSize: 16 }} />}
|
||||
></Menu.Item>
|
||||
)}
|
||||
</Menu>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default Header;
|
||||
102
media-store/app-src/src/components/InvoicePage.jsx
Normal file
102
media-store/app-src/src/components/InvoicePage.jsx
Normal file
@@ -0,0 +1,102 @@
|
||||
import React from 'react';
|
||||
import { Table, Button, message } from 'antd';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import { useAppState } from '../hooks/useAppState';
|
||||
import { invoice } from '../api/calls';
|
||||
import { useErrors } from '../hooks/useErrors';
|
||||
import { MESSAGE_TIMEOUT } from '../util/constants';
|
||||
|
||||
const columns = [
|
||||
{
|
||||
title: 'Name',
|
||||
dataIndex: 'name',
|
||||
},
|
||||
{
|
||||
title: 'Artist',
|
||||
dataIndex: 'artist',
|
||||
},
|
||||
{
|
||||
title: 'Album',
|
||||
dataIndex: 'albumTitle',
|
||||
},
|
||||
{
|
||||
title: 'Price',
|
||||
dataIndex: 'unitPrice',
|
||||
},
|
||||
];
|
||||
|
||||
const InvoicePage = () => {
|
||||
const history = useHistory();
|
||||
const { handleError } = useErrors();
|
||||
const { user, invoicedItems, setInvoicedItems, setLoading } = useAppState();
|
||||
|
||||
const data = invoicedItems.map(({ ID, ...otherProps }) => ({
|
||||
key: `invoiceItem${ID}`,
|
||||
...otherProps,
|
||||
}));
|
||||
|
||||
const onBuy = () => {
|
||||
setLoading(true);
|
||||
invoice(
|
||||
invoicedItems.map(({ ID, unitPrice }) => ({
|
||||
ID,
|
||||
unitPrice,
|
||||
}))
|
||||
)
|
||||
.then(() => {
|
||||
setInvoicedItems([]);
|
||||
message.success('Invoice successfully completed', MESSAGE_TIMEOUT);
|
||||
history.push('/invoices');
|
||||
})
|
||||
.catch(handleError)
|
||||
.finally(() => setLoading(false));
|
||||
};
|
||||
const onCancel = () => {
|
||||
setInvoicedItems([]);
|
||||
history.push('/');
|
||||
};
|
||||
const goLogin = () => {
|
||||
history.push('/login');
|
||||
};
|
||||
|
||||
return (
|
||||
<div style={{ backgroundColor: 'white', padding: 10 }}>
|
||||
<Table
|
||||
bordered={false}
|
||||
pagination={false}
|
||||
columns={columns}
|
||||
dataSource={data}
|
||||
size="middle"
|
||||
footer={() => (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
padding: 5,
|
||||
}}
|
||||
>
|
||||
{user ? (
|
||||
<>
|
||||
<Button type="primary" size="large" onClick={onBuy}>
|
||||
Buy
|
||||
</Button>
|
||||
<Button size="large" style={{ marginLeft: 5 }} onClick={onCancel} danger>
|
||||
Cancel
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
<section>
|
||||
<Button type="primary" size="large" onClick={goLogin}>
|
||||
Login
|
||||
</Button>
|
||||
<span> to buy selected</span>
|
||||
</section>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default InvoicePage;
|
||||
107
media-store/app-src/src/components/Login.jsx
Normal file
107
media-store/app-src/src/components/Login.jsx
Normal file
@@ -0,0 +1,107 @@
|
||||
import React from 'react';
|
||||
import { Form, Input, Button, Checkbox, message } from 'antd';
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import { login } from '../api/calls';
|
||||
import { useAppState } from '../hooks/useAppState';
|
||||
import { useErrors } from '../hooks/useErrors';
|
||||
import { MESSAGE_TIMEOUT } from '../util/constants';
|
||||
import { emitter } from '../util/EventEmitter';
|
||||
|
||||
const layout = {
|
||||
labelCol: {
|
||||
span: 8,
|
||||
},
|
||||
wrapperCol: {
|
||||
span: 8,
|
||||
},
|
||||
};
|
||||
const tailLayout = {
|
||||
wrapperCol: {
|
||||
offset: 8,
|
||||
span: 8,
|
||||
},
|
||||
};
|
||||
|
||||
const Login = () => {
|
||||
const [form] = Form.useForm();
|
||||
const history = useHistory();
|
||||
const { setLoading, setInvoicedItems } = useAppState();
|
||||
const { handleError } = useErrors();
|
||||
|
||||
const onFinish = (values) => {
|
||||
setLoading(true);
|
||||
login({ email: values.email, password: values.password })
|
||||
.then(({ data: user }) => {
|
||||
emitter.emit('UPDATE_USER', user);
|
||||
if (user.roles.includes('employee')) {
|
||||
setInvoicedItems([]);
|
||||
}
|
||||
history.push('/');
|
||||
})
|
||||
.catch((error) => {
|
||||
console.log(error);
|
||||
if (error.response && error.response.status === 401) {
|
||||
form.resetFields();
|
||||
message.error('Invalid credentials!', MESSAGE_TIMEOUT);
|
||||
} else {
|
||||
handleError(error);
|
||||
}
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
};
|
||||
|
||||
const onFinishFailed = (errorInfo) => {
|
||||
console.log('Validation Failed:', errorInfo);
|
||||
};
|
||||
|
||||
return (
|
||||
<Form
|
||||
form={form}
|
||||
{...layout}
|
||||
name="basic"
|
||||
initialValues={{
|
||||
remember: true,
|
||||
}}
|
||||
onFinish={onFinish}
|
||||
onFinishFailed={onFinishFailed}
|
||||
>
|
||||
<Form.Item
|
||||
label="Email"
|
||||
name="email"
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
message: 'Please input your email!',
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
label="Password"
|
||||
name="password"
|
||||
rules={[
|
||||
{
|
||||
required: true,
|
||||
message: 'Please input your password!',
|
||||
},
|
||||
]}
|
||||
>
|
||||
<Input.Password style={{}} />
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item {...tailLayout} name="remember" valuePropName="checked">
|
||||
<Checkbox>Remember me</Checkbox>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item {...tailLayout}>
|
||||
<Button type="primary" htmlType="submit">
|
||||
Submit
|
||||
</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
|
||||
export default Login;
|
||||
115
media-store/app-src/src/components/ManageStore.jsx
Normal file
115
media-store/app-src/src/components/ManageStore.jsx
Normal file
@@ -0,0 +1,115 @@
|
||||
import React, { useState, useMemo, useEffect } from 'react';
|
||||
import { Form, Radio, Button, message } from 'antd';
|
||||
import { TrackForm } from './manage-store/TrackForm';
|
||||
import { AddArtistForm } from './manage-store/AddArtistForm';
|
||||
import { AddAlbumForm } from './manage-store/AddAlbumForm';
|
||||
import { useErrors } from '../hooks/useErrors';
|
||||
import { useAppState } from '../hooks/useAppState';
|
||||
import { addTrack, addArtist, addAlbum } from '../api/calls';
|
||||
import { MESSAGE_TIMEOUT } from '../util/constants';
|
||||
|
||||
const FORM_TYPES = {
|
||||
track: 'track',
|
||||
artist: 'artist',
|
||||
album: 'album',
|
||||
playlist: '',
|
||||
};
|
||||
|
||||
const chooseForm = (type) => {
|
||||
return (
|
||||
(type === 'track' && <TrackForm />) ||
|
||||
(type === 'artist' && <AddArtistForm />) ||
|
||||
(type === 'album' && <AddAlbumForm />)
|
||||
);
|
||||
};
|
||||
|
||||
const ManageStore = () => {
|
||||
const [form] = Form.useForm();
|
||||
const { handleError } = useErrors();
|
||||
const { setLoading } = useAppState();
|
||||
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 },
|
||||
genre: { ID: data.genreID },
|
||||
unitPrice: data.unitPrice.toString(),
|
||||
});
|
||||
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(() => {
|
||||
message.success('Entity successfully created', MESSAGE_TIMEOUT);
|
||||
form.resetFields();
|
||||
})
|
||||
.catch(handleError)
|
||||
.finally(() => setLoading(false));
|
||||
};
|
||||
|
||||
return (
|
||||
<Form
|
||||
style={{ width: 700 }}
|
||||
form={form}
|
||||
labelCol={{
|
||||
span: 4,
|
||||
}}
|
||||
wrapperCol={{
|
||||
span: 14,
|
||||
}}
|
||||
layout="horizontal"
|
||||
initialValues={{
|
||||
type: formType,
|
||||
}}
|
||||
type={formType}
|
||||
onFinish={sendCreateRequest}
|
||||
onFinishFailed={() => console.log('Not valid params provided')}
|
||||
>
|
||||
<Form.Item label="Entity" name="type">
|
||||
<Radio.Group onChange={onChangeForm}>
|
||||
<Radio.Button value="track">Track</Radio.Button>
|
||||
<Radio.Button value="album">Album</Radio.Button>
|
||||
<Radio.Button value="artist">Artist</Radio.Button>
|
||||
</Radio.Group>
|
||||
</Form.Item>
|
||||
{formElement}
|
||||
<Form.Item
|
||||
type="primary"
|
||||
wrapperCol={{
|
||||
span: 14,
|
||||
offset: 4,
|
||||
}}
|
||||
>
|
||||
<Button onClick={() => form.submit()}>Create</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
|
||||
export default ManageStore;
|
||||
170
media-store/app-src/src/components/MyInvoicesPage.jsx
Normal file
170
media-store/app-src/src/components/MyInvoicesPage.jsx
Normal file
@@ -0,0 +1,170 @@
|
||||
import React, { useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Button, message, Tag, Collapse, Table, Spin } from 'antd';
|
||||
import moment from 'moment';
|
||||
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 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 UTC_DATE_TIME_FORMAT = 'YYYY-MM-DDThh:mm:ss';
|
||||
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 = (utcNowTimestamp, invoiceDate) => {
|
||||
const duration = moment.duration(moment(utcNowTimestamp).diff(moment(invoiceDate).valueOf()));
|
||||
return duration.asHours() > LEVERAGE_DURATION;
|
||||
};
|
||||
|
||||
const chooseStatus = (utcNowTimestamp, invoiceDate, statusFromDb) => {
|
||||
if (isLeverageTimeExpired(utcNowTimestamp, invoiceDate) && statusFromDb !== STATUSES.canceled) {
|
||||
return INVOICE_STATUS[STATUSES.shipped];
|
||||
}
|
||||
return INVOICE_STATUS[statusFromDb];
|
||||
};
|
||||
|
||||
const ExtraHeader = ({ ID, invoiceDate, status: initialStatus }) => {
|
||||
const { loading, setLoading } = useAppState();
|
||||
const { handleError } = useErrors();
|
||||
const [loadingHeaderId, setLoadingHeaderId] = useState();
|
||||
const [status, setStatus] = useState(initialStatus);
|
||||
|
||||
const statusConfig = useMemo(() => {
|
||||
const utcNowTimestamp = moment(moment().utc().format(UTC_DATE_TIME_FORMAT)).valueOf();
|
||||
return chooseStatus(utcNowTimestamp, invoiceDate, status);
|
||||
}, [status]);
|
||||
|
||||
const onCancelInvoice = (event, ID) => {
|
||||
event.stopPropagation();
|
||||
setLoading(true);
|
||||
setLoadingHeaderId(ID);
|
||||
cancelInvoice(ID)
|
||||
.then(() => {
|
||||
message.success('Invoice successfully cancelled', MESSAGE_TIMEOUT);
|
||||
setLoadingHeaderId(undefined);
|
||||
setStatus(CANCELLED_STATUS);
|
||||
})
|
||||
.catch(handleError)
|
||||
.finally(() => setLoading(false));
|
||||
};
|
||||
|
||||
return (
|
||||
<Spin spinning={loading && loadingHeaderId === ID}>
|
||||
<Tag color={statusConfig.color}>{statusConfig.tagTitle}</Tag>
|
||||
{statusConfig.canCancel && (
|
||||
<Button onClick={(event) => onCancelInvoice(event, ID)} size="small" danger>
|
||||
Cancel
|
||||
</Button>
|
||||
)}
|
||||
</Spin>
|
||||
);
|
||||
};
|
||||
ExtraHeader.propTypes = {
|
||||
ID: PropTypes.number.isRequired,
|
||||
status: PropTypes.number.isRequired,
|
||||
invoiceDate: PropTypes.string.isRequired,
|
||||
};
|
||||
|
||||
const MyInvoicesPage = () => {
|
||||
const { handleError } = useErrors();
|
||||
const { setLoading } = useAppState();
|
||||
const [invoices, setInvoices] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
fetchInvoices()
|
||||
.then(({ data: { value } }) => setInvoices(value))
|
||||
.catch(handleError)
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const genExtra = useCallback(
|
||||
(ID, status, invoiceDate) => <ExtraHeader ID={ID} status={status} invoiceDate={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 (
|
||||
<Panel
|
||||
header={moment(invoiceDate).format(DATE_TIME_FORMAT_PATTERN)}
|
||||
key={ID}
|
||||
extra={genExtra(ID, status, invoiceDate)}
|
||||
>
|
||||
<div>
|
||||
<Table
|
||||
bordered={false}
|
||||
pagination={false}
|
||||
columns={INVOICE_ITEMS_COLUMNS}
|
||||
dataSource={invoiceItemsData}
|
||||
size="middle"
|
||||
footer={() => <span style={{ fontWeight: 600 }}>{`Total price: ${total}`}</span>}
|
||||
/>
|
||||
</div>
|
||||
</Panel>
|
||||
);
|
||||
});
|
||||
}, [invoices]);
|
||||
|
||||
return (
|
||||
<div>{invoiceElements && <Collapse expandIconPosition="left">{invoiceElements}</Collapse>}</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default MyInvoicesPage;
|
||||
108
media-store/app-src/src/components/PersonPage.jsx
Normal file
108
media-store/app-src/src/components/PersonPage.jsx
Normal file
@@ -0,0 +1,108 @@
|
||||
import React, { useState } from 'react';
|
||||
import { Form, Button, message, Input } from 'antd';
|
||||
import { omit, map } from 'lodash';
|
||||
import { fetchPerson, confirmPerson } from '../api/calls';
|
||||
import { useErrors } from '../hooks/useErrors';
|
||||
import { useAppState } from '../hooks/useAppState';
|
||||
import { MESSAGE_TIMEOUT } from '../util/constants';
|
||||
import { useAbortableEffect } from '../hooks/useAbortableEffect';
|
||||
|
||||
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 = () => {
|
||||
const { setLoading } = useAppState();
|
||||
const { handleError } = useErrors();
|
||||
const [form] = Form.useForm();
|
||||
const [person, setPerson] = useState({
|
||||
lastName: '',
|
||||
firstName: '',
|
||||
city: '',
|
||||
state: '',
|
||||
address: '',
|
||||
country: '',
|
||||
phone: '',
|
||||
postalCode: '',
|
||||
fax: '',
|
||||
email: '',
|
||||
company: '',
|
||||
});
|
||||
|
||||
useAbortableEffect((status) => {
|
||||
setLoading(true);
|
||||
|
||||
fetchPerson()
|
||||
.then(({ data }) => {
|
||||
const personData = omit(data, '@odata.context', 'ID');
|
||||
if (!status.aborted) {
|
||||
setPerson(personData);
|
||||
}
|
||||
})
|
||||
.catch(handleError)
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const onConfirmChanges = (newPerson) => {
|
||||
setLoading(true);
|
||||
confirmPerson(newPerson)
|
||||
.then(() => {
|
||||
message.success('Person successfully updated', MESSAGE_TIMEOUT);
|
||||
})
|
||||
.catch(handleError)
|
||||
.finally(() => setLoading(false));
|
||||
};
|
||||
|
||||
const personProperties = map(Object.keys(person), (currentKey) => (
|
||||
<div key={currentKey}>
|
||||
<Form.Item label={PERSON_PROP[currentKey]} name={currentKey}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</div>
|
||||
));
|
||||
|
||||
return (
|
||||
<>
|
||||
{person.lastName !== '' && (
|
||||
<Form
|
||||
form={form}
|
||||
labelCol={{
|
||||
span: 4,
|
||||
}}
|
||||
wrapperCol={{
|
||||
span: 14,
|
||||
}}
|
||||
layout="horizontal"
|
||||
onFinish={onConfirmChanges}
|
||||
onFinishFailed={() => console.log('Not valid params provided')}
|
||||
initialValues={{
|
||||
...person,
|
||||
}}
|
||||
>
|
||||
{personProperties}
|
||||
<Form.Item
|
||||
type="primary"
|
||||
wrapperCol={{
|
||||
span: 14,
|
||||
offset: 4,
|
||||
}}
|
||||
>
|
||||
<Button onClick={() => form.submit()}>Confirm changes</Button>
|
||||
</Form.Item>
|
||||
</Form>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default PersonPage;
|
||||
67
media-store/app-src/src/components/Router.jsx
Normal file
67
media-store/app-src/src/components/Router.jsx
Normal file
@@ -0,0 +1,67 @@
|
||||
import React from 'react';
|
||||
import { BrowserRouter as Router, Switch, Route } from 'react-router-dom';
|
||||
import { isEmpty } from 'lodash';
|
||||
import TracksContainer from './TracksPage';
|
||||
import Header from './Header';
|
||||
import PersonPage from './PersonPage';
|
||||
import ErrorPage from './ErrorPage';
|
||||
import InvoicePage from './InvoicePage';
|
||||
import ManageStore from './ManageStore';
|
||||
import MyInvoicesPage from './MyInvoicesPage';
|
||||
import Login from './Login';
|
||||
import { withRestrictions } from '../hocs/withRestrictions';
|
||||
import { requireEmployee } from '../util/constants';
|
||||
|
||||
// const TracksContainer = React.lazy(() => import('./TracksPage'));
|
||||
// const Header = React.lazy(() => import('./Header'));
|
||||
// const PersonPage = React.lazy(() => import('./PersonPage'));
|
||||
// const ErrorPage = React.lazy(() => import('./ErrorPage'));
|
||||
// const InvoicePage = React.lazy(() => import('./InvoicePage'));
|
||||
// const ManageStore = React.lazy(() => import('./ManageStore'));
|
||||
// const MyInvoicesPage = React.lazy(() => import('./MyInvoicesPage'));
|
||||
// const Login = React.lazy(() => import('./Login'));
|
||||
|
||||
const RestrictedLogin = withRestrictions(Login, ({ user }) => !user);
|
||||
const RestrictedInvoicePage = withRestrictions(
|
||||
InvoicePage,
|
||||
({ user, invoicedItems }) => !requireEmployee(user) && !isEmpty(invoicedItems)
|
||||
);
|
||||
const RestrictedPersonPage = withRestrictions(PersonPage, ({ user }) => !!user);
|
||||
const RestrictedManageStore = withRestrictions(ManageStore, ({ user }) => requireEmployee(user));
|
||||
|
||||
const MyRouter = () => {
|
||||
return (
|
||||
<Router>
|
||||
<Header />
|
||||
<div style={{ padding: '2em 20vh' }}>
|
||||
<React.Suspense fallback={<div>Loading...</div>}>
|
||||
<Switch>
|
||||
<Route exact path={['/', '/tracks']}>
|
||||
<TracksContainer />
|
||||
</Route>
|
||||
<Route exact path="/person">
|
||||
<RestrictedPersonPage />
|
||||
</Route>
|
||||
<Route exact path="/login">
|
||||
<RestrictedLogin />
|
||||
</Route>
|
||||
<Route exact path="/invoice">
|
||||
<RestrictedInvoicePage />
|
||||
</Route>
|
||||
<Route exact path="/invoices">
|
||||
<MyInvoicesPage />
|
||||
</Route>
|
||||
<Route exact path="/manage">
|
||||
<RestrictedManageStore />
|
||||
</Route>
|
||||
<Route path="/">
|
||||
<ErrorPage />
|
||||
</Route>
|
||||
</Switch>
|
||||
</React.Suspense>
|
||||
</div>
|
||||
</Router>
|
||||
);
|
||||
};
|
||||
|
||||
export { MyRouter };
|
||||
4
media-store/app-src/src/components/TracksPage.css
Normal file
4
media-store/app-src/src/components/TracksPage.css
Normal file
@@ -0,0 +1,4 @@
|
||||
.ant-select > div.ant-select-selector {
|
||||
padding: 5px;
|
||||
min-width: 300px;
|
||||
}
|
||||
215
media-store/app-src/src/components/TracksPage.jsx
Normal file
215
media-store/app-src/src/components/TracksPage.jsx
Normal file
@@ -0,0 +1,215 @@
|
||||
import React, { useState } from 'react';
|
||||
import { debounce } from 'lodash';
|
||||
import { Input, Col, Row, Select, Pagination } from 'antd';
|
||||
import { Track } from './tracks/Track';
|
||||
import { ManagedTrack } from './tracks/ManagedTrack';
|
||||
import { useAppState } from '../hooks/useAppState';
|
||||
import { useErrors } from '../hooks/useErrors';
|
||||
import { fetchTacks, countTracks, fetchGenres } from '../api/calls';
|
||||
import { useAbortableEffect } from '../hooks/useAbortableEffect';
|
||||
import { requireEmployee } from '../util/constants';
|
||||
import './TracksPage.css';
|
||||
|
||||
const { Search } = Input;
|
||||
const { Option } = Select;
|
||||
|
||||
const DEBOUNCE_TIMER = 500;
|
||||
const DEBOUNCE_OPTIONS = {
|
||||
leading: true,
|
||||
trailing: false,
|
||||
};
|
||||
|
||||
const renderGenres = (genres) =>
|
||||
genres.map(({ ID, name }) => (
|
||||
<Option key={ID} value={ID.toString()}>
|
||||
{name}
|
||||
</Option>
|
||||
));
|
||||
|
||||
const TracksContainer = () => {
|
||||
const { setLoading, user } = useAppState();
|
||||
const { handleError } = useErrors();
|
||||
const [state, setState] = useState({
|
||||
tracks: [],
|
||||
genres: [],
|
||||
pagination: {
|
||||
currentPage: 1,
|
||||
totalItems: 0,
|
||||
pageSize: 20,
|
||||
},
|
||||
searchOptions: {
|
||||
substr: '',
|
||||
genreIds: [],
|
||||
},
|
||||
});
|
||||
|
||||
useAbortableEffect((status) => {
|
||||
setLoading(true);
|
||||
|
||||
const countTracksReq = countTracks();
|
||||
const getTracksRequest = fetchTacks();
|
||||
const getGenresReq = fetchGenres();
|
||||
|
||||
Promise.all([countTracksReq, getTracksRequest, getGenresReq])
|
||||
.then(
|
||||
([
|
||||
{ data: totalItems },
|
||||
{
|
||||
data: { value: tracks },
|
||||
},
|
||||
{
|
||||
data: { value: genres },
|
||||
},
|
||||
]) => {
|
||||
if (!status.aborted) {
|
||||
setState({
|
||||
...state,
|
||||
tracks,
|
||||
genres,
|
||||
pagination: { ...state.pagination, totalItems },
|
||||
});
|
||||
}
|
||||
}
|
||||
)
|
||||
.catch(handleError)
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
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(([{ data: { value: tracks } }, { data: totalItems }]) =>
|
||||
setState({
|
||||
...state,
|
||||
tracks,
|
||||
pagination: { ...state.pagination, totalItems },
|
||||
})
|
||||
)
|
||||
.catch(handleError)
|
||||
.finally(() => setLoading(false));
|
||||
},
|
||||
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 },
|
||||
})
|
||||
)
|
||||
.catch(handleError)
|
||||
.finally(() => setLoading(false));
|
||||
};
|
||||
const deleteTrack = (ID) => {
|
||||
setState({
|
||||
...state,
|
||||
tracks: state.tracks.filter(({ ID: curID }) => curID !== ID),
|
||||
});
|
||||
};
|
||||
const renderTracks = (tracks) => {
|
||||
const isEmployee = requireEmployee(user);
|
||||
const TrackComponent = isEmployee ? ManagedTrack : Track;
|
||||
return tracks.map((track) => {
|
||||
const isAlreadyOrdered = !isEmployee && track.alreadyOrdered;
|
||||
const onDeleteTrack = isEmployee && ((ID) => deleteTrack(ID));
|
||||
return (
|
||||
<Col key={track.ID} className="gutter-row" span={8}>
|
||||
<TrackComponent
|
||||
initialTrack={track}
|
||||
onDeleteTrack={onDeleteTrack}
|
||||
isAlreadyOrdered={isAlreadyOrdered}
|
||||
/>
|
||||
</Col>
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
const trackElements = renderTracks(state.tracks);
|
||||
const genreElements = renderGenres(state.genres);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'start',
|
||||
maxWidth: 600,
|
||||
paddingBottom: 10,
|
||||
}}
|
||||
>
|
||||
<Select
|
||||
mode="multiple"
|
||||
allowClear
|
||||
style={{ marginRight: 10, borderRadius: 6 }}
|
||||
placeholder="Genres"
|
||||
onChange={(value) => onSelectChange(value)}
|
||||
>
|
||||
{genreElements}
|
||||
</Select>
|
||||
<Search
|
||||
style={{
|
||||
borderRadius: 6,
|
||||
}}
|
||||
placeholder="Search tracks"
|
||||
size="large"
|
||||
onSearch={onSearch}
|
||||
onChange={onSearchChange}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<Row gutter={[{ xs: 8, sm: 16, md: 24, lg: 32 }, 24]}>{trackElements}</Row>
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'center' }}>
|
||||
<Pagination
|
||||
showSizeChanger={false}
|
||||
defaultCurrent={1}
|
||||
total={state.pagination.totalItems}
|
||||
pageSize={state.pagination.pageSize}
|
||||
onChange={onChangePage}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default TracksContainer;
|
||||
@@ -0,0 +1,62 @@
|
||||
import React, { useEffect } from 'react';
|
||||
import { Form, Input, Select } from 'antd';
|
||||
import { useSearch } from '@umijs/hooks';
|
||||
import { useErrors } from '../../hooks/useErrors';
|
||||
import { fetchArtistsByName } from '../../api/calls';
|
||||
|
||||
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 (
|
||||
<>
|
||||
<h3>Add album</h3>
|
||||
<Form.Item label="Name" name="name" rules={REQUIRED}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item label="Artist" name="artistID" rules={REQUIRED}>
|
||||
<Select
|
||||
showSearch
|
||||
placeholder="Select artist"
|
||||
filterOption={false}
|
||||
onSearch={onChangeArtistInput}
|
||||
loading={isArtistsLoading}
|
||||
onBlur={onArtistCancel}
|
||||
style={{ width: 300 }}
|
||||
>
|
||||
{artists &&
|
||||
artists.map((artist) => (
|
||||
<Select.Option key={artist.name} value={artist.ID}>
|
||||
{artist.name}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export { AddAlbumForm };
|
||||
@@ -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 (
|
||||
<>
|
||||
<h3>Add artist</h3>
|
||||
<Form.Item label="Name" name="name" rules={REQUIRED}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export { AddArtistForm };
|
||||
@@ -0,0 +1,93 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Form, Input, Select, InputNumber } from 'antd';
|
||||
import { head } from 'lodash';
|
||||
import { useSearch } from '@umijs/hooks';
|
||||
import { useAppState } from '../../hooks/useAppState';
|
||||
import { fetchAlbumsByName, fetchGenres } from '../../api/calls';
|
||||
import { useErrors } from '../../hooks/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 } = useAppState();
|
||||
const [genres, setGenres] = useState([]);
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
Promise.all([fetchGenres(), onChangeAlbumInput(initialAlbumTitle)])
|
||||
.then((responses) => setGenres(head(responses).data.value))
|
||||
.catch(handleError)
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Form.Item label="Name" name="name" rules={REQUIRED}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item label="Composer" name="composer" rules={REQUIRED}>
|
||||
<Input />
|
||||
</Form.Item>
|
||||
<Form.Item label="Album" name="albumID" rules={REQUIRED}>
|
||||
<Select
|
||||
showSearch
|
||||
placeholder="Select album"
|
||||
filterOption={false}
|
||||
onSearch={onChangeAlbumInput}
|
||||
loading={isAlbumsLoading}
|
||||
onBlur={onAlbumCancel}
|
||||
>
|
||||
{albums &&
|
||||
albums.map((album) => (
|
||||
<Select.Option key={album.title} value={album.ID}>
|
||||
{album.title}
|
||||
</Select.Option>
|
||||
))}
|
||||
</Select>
|
||||
</Form.Item>
|
||||
<Form.Item label="Genre" name="genreID" rules={REQUIRED}>
|
||||
<Select showSearch placeholder="Select genre" filterOption={false}>
|
||||
{genres &&
|
||||
genres.map((genre) => (
|
||||
<Select.Option key={genre.name} value={genre.ID}>
|
||||
{genre.name}
|
||||
</Select.Option>
|
||||
))}
|
||||
</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>
|
||||
);
|
||||
};
|
||||
|
||||
TrackForm.propTypes = {
|
||||
initialAlbumTitle: PropTypes.string,
|
||||
};
|
||||
|
||||
export { TrackForm };
|
||||
44
media-store/app-src/src/components/tracks/DeleteAction.jsx
Normal file
44
media-store/app-src/src/components/tracks/DeleteAction.jsx
Normal file
@@ -0,0 +1,44 @@
|
||||
import React, { useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Modal, message } from 'antd';
|
||||
import { DeleteOutlined } from '@ant-design/icons';
|
||||
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);
|
||||
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 (
|
||||
<>
|
||||
<DeleteOutlined onClick={onOpenModal}>Delete</DeleteOutlined>
|
||||
<Modal title="Confirm" visible={modalVisible} onOk={onOk} onCancel={onCancel}>
|
||||
<p>Are You really want to delete this track?</p>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
DeleteAction.propTypes = {
|
||||
ID: PropTypes.number.isRequired,
|
||||
onDeleteTrack: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export { DeleteAction };
|
||||
113
media-store/app-src/src/components/tracks/EditAction.jsx
Normal file
113
media-store/app-src/src/components/tracks/EditAction.jsx
Normal file
@@ -0,0 +1,113 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Button, Modal, Form, message } from 'antd';
|
||||
import { EditOutlined, LoadingOutlined } from '@ant-design/icons';
|
||||
import { useErrors } from '../../hooks/useErrors';
|
||||
import { TrackForm } from '../manage-store/TrackForm';
|
||||
import { updateTrack, getTrack } from '../../api/calls';
|
||||
import { MESSAGE_TIMEOUT } from '../../util/constants';
|
||||
|
||||
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);
|
||||
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 },
|
||||
unitPrice: value.unitPrice.toString(),
|
||||
})
|
||||
.then(() => {
|
||||
message.success('Track successfully updated!', MESSAGE_TIMEOUT);
|
||||
setConfirmLoading(false);
|
||||
setVisible(false);
|
||||
afterCloseModal();
|
||||
})
|
||||
.catch(handleError);
|
||||
};
|
||||
|
||||
const handleOk = () => {
|
||||
form.submit();
|
||||
};
|
||||
|
||||
const handleCancel = () => {
|
||||
setVisible(false);
|
||||
};
|
||||
|
||||
const afterCloseModal = () => {
|
||||
setUpdateLoading(true);
|
||||
getTrack(ID)
|
||||
.then((response) => {
|
||||
afterTrackUpdate(response.data);
|
||||
setUpdateLoading(false);
|
||||
})
|
||||
.catch(handleError);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{updateLoading ? <LoadingOutlined /> : <EditOutlined onClick={onShowModal} />}
|
||||
<Modal
|
||||
title="Edit track"
|
||||
visible={visible}
|
||||
confirmLoading={confirmLoading}
|
||||
onOk={handleOk}
|
||||
onCancel={handleCancel}
|
||||
width={600}
|
||||
footer={[
|
||||
<Button key="back" onClick={handleCancel}>
|
||||
Cancel
|
||||
</Button>,
|
||||
<Button key="submit" type="primary" loading={confirmLoading} onClick={handleOk}>
|
||||
Submit
|
||||
</Button>,
|
||||
]}
|
||||
>
|
||||
<Form
|
||||
form={form}
|
||||
labelCol={{
|
||||
span: 4,
|
||||
}}
|
||||
wrapperCol={{
|
||||
span: 14,
|
||||
}}
|
||||
layout="horizontal"
|
||||
onFinish={onFinish}
|
||||
onFinishFailed={() => console.log('Not valid params provided')}
|
||||
initialValues={{
|
||||
name: name,
|
||||
composer: composer,
|
||||
genreID: genre.ID,
|
||||
albumID: album.ID,
|
||||
unitPrice: unitPrice,
|
||||
}}
|
||||
>
|
||||
<TrackForm initialAlbumTitle={album.title} />
|
||||
</Form>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
EditAction.propTypes = {
|
||||
ID: PropTypes.number.isRequired,
|
||||
name: PropTypes.string.isRequired,
|
||||
composer: PropTypes.string.isRequired,
|
||||
genre: PropTypes.object.isRequired,
|
||||
unitPrice: PropTypes.number.isRequired,
|
||||
album: PropTypes.object.isRequired,
|
||||
afterTrackUpdate: PropTypes.func.isRequired,
|
||||
};
|
||||
|
||||
export { EditAction };
|
||||
@@ -0,0 +1,7 @@
|
||||
span > span.anticon.anticon-delete:hover {
|
||||
color: #ff4d4f;
|
||||
}
|
||||
|
||||
.card-element {
|
||||
transition: opacity 0.5s ease-in-out;
|
||||
}
|
||||
42
media-store/app-src/src/components/tracks/ManagedTrack.jsx
Normal file
42
media-store/app-src/src/components/tracks/ManagedTrack.jsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import React, { useState, useRef } from "react";
|
||||
import { Card } from "antd";
|
||||
import { EditAction } from "./EditAction";
|
||||
import { DeleteAction } from "./DeleteAction";
|
||||
import { TrackCardBody } from "./TrackCardBody";
|
||||
import "./ManagedTrack.css";
|
||||
|
||||
const ManagedTrack = ({ initialTrack, onDeleteTrack }) => {
|
||||
const trackElement = useRef();
|
||||
const [track, setTrack] = useState(initialTrack);
|
||||
|
||||
return (
|
||||
<div className="card-element" ref={trackElement}>
|
||||
<Card
|
||||
actions={[
|
||||
<DeleteAction
|
||||
ID={track.ID}
|
||||
onDeleteTrack={() => {
|
||||
trackElement.current.style.opacity = 0;
|
||||
setTimeout(() => onDeleteTrack(track.ID), 500);
|
||||
}}
|
||||
/>,
|
||||
<EditAction
|
||||
ID={track.ID}
|
||||
name={track.name}
|
||||
composer={track.composer}
|
||||
album={track.album}
|
||||
genre={track.genre}
|
||||
unitPrice={track.unitPrice}
|
||||
afterTrackUpdate={(value) => setTrack(value)}
|
||||
/>,
|
||||
]}
|
||||
title={track.name}
|
||||
bordered={false}
|
||||
>
|
||||
<TrackCardBody track={track} />
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export { ManagedTrack };
|
||||
60
media-store/app-src/src/components/tracks/Track.jsx
Normal file
60
media-store/app-src/src/components/tracks/Track.jsx
Normal file
@@ -0,0 +1,60 @@
|
||||
import React, { useState, useRef } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { Card, Button } from 'antd';
|
||||
import { PlusOutlined, MinusOutlined } from '@ant-design/icons';
|
||||
import { useAppState } from '../../hooks/useAppState';
|
||||
import { TrackCardBody } from './TrackCardBody';
|
||||
|
||||
const Track = ({ initialTrack, isAlreadyOrdered }) => {
|
||||
const trackElement = useRef();
|
||||
const { setInvoicedItems, invoicedItems } = useAppState();
|
||||
const [isJustInvoiced, setIsJustInvoiced] = useState(
|
||||
invoicedItems.find((curTrack) => curTrack.ID === initialTrack.ID)
|
||||
);
|
||||
|
||||
const onChangedStatus = () => {
|
||||
const newIsJustInvoiced = !isJustInvoiced;
|
||||
if (newIsJustInvoiced) {
|
||||
setInvoicedItems([
|
||||
...invoicedItems,
|
||||
{
|
||||
ID: initialTrack.ID,
|
||||
name: initialTrack.name,
|
||||
artist: initialTrack.album.artist.name,
|
||||
albumTitle: initialTrack.album.title,
|
||||
unitPrice: initialTrack.unitPrice,
|
||||
},
|
||||
]);
|
||||
} else {
|
||||
setInvoicedItems(invoicedItems.filter(({ ID: curID }) => curID !== initialTrack.ID));
|
||||
}
|
||||
setIsJustInvoiced(newIsJustInvoiced);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="card-element" ref={trackElement}>
|
||||
<Card
|
||||
actions={[
|
||||
<>
|
||||
{!isAlreadyOrdered && (
|
||||
<Button onClick={onChangedStatus} danger={isJustInvoiced}>
|
||||
{isJustInvoiced ? <MinusOutlined /> : <PlusOutlined />}
|
||||
</Button>
|
||||
)}
|
||||
</>,
|
||||
]}
|
||||
title={initialTrack.name}
|
||||
bordered={false}
|
||||
>
|
||||
<TrackCardBody track={initialTrack} />
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Track.propTypes = {
|
||||
initialTrack: PropTypes.object,
|
||||
isAlreadyOrdered: PropTypes.bool,
|
||||
};
|
||||
|
||||
export { Track };
|
||||
41
media-store/app-src/src/components/tracks/TrackCardBody.jsx
Normal file
41
media-store/app-src/src/components/tracks/TrackCardBody.jsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import React from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
|
||||
const TrackCardBody = ({ track }) => {
|
||||
return (
|
||||
<>
|
||||
<div>
|
||||
Artist:
|
||||
<span style={{ fontWeight: 600 }}>{track.album.artist.name}</span>
|
||||
</div>
|
||||
<div>
|
||||
Album:
|
||||
<span style={{ fontWeight: 600 }}>{track.album.title}</span>
|
||||
</div>
|
||||
<div>
|
||||
Genre:
|
||||
<span style={{ fontWeight: 600 }}>{track.genre.name}</span>
|
||||
</div>
|
||||
<div>
|
||||
{track.composer && (
|
||||
<span>
|
||||
Compositor:
|
||||
<span style={{ fontWeight: 600 }}>{track.composer}</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||
<span>
|
||||
Price:
|
||||
<span style={{ fontWeight: 600 }}>{track.unitPrice}</span>
|
||||
</span>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
TrackCardBody.propTypes = {
|
||||
track: PropTypes.object.isRequired,
|
||||
};
|
||||
|
||||
export { TrackCardBody };
|
||||
66
media-store/app-src/src/contexts/AppStateContext.jsx
Normal file
66
media-store/app-src/src/contexts/AppStateContext.jsx
Normal file
@@ -0,0 +1,66 @@
|
||||
import React, { useMemo, createContext, useState, useEffect } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { getUserFromLS, getLocaleFromLS, setUserToLS } from '../util/localStorageService';
|
||||
import { changeUserDefaults } from '../api/axiosInstance';
|
||||
import { emitter } from '../util/EventEmitter';
|
||||
|
||||
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');
|
||||
changeUserDefaults(newUser);
|
||||
setUserToLS(newUser);
|
||||
setUser(newUser);
|
||||
};
|
||||
emitter.on('UPDATE_USER', updateUser);
|
||||
return () => {
|
||||
emitter.removeListener('UPDATE_USER', updateUser);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const value = useMemo(
|
||||
() => ({
|
||||
error,
|
||||
loading,
|
||||
invoicedItems,
|
||||
user,
|
||||
locale,
|
||||
setLoading,
|
||||
setError,
|
||||
setInvoicedItems,
|
||||
setUser,
|
||||
setLocale,
|
||||
}),
|
||||
[locale, user, loading, error, invoicedItems]
|
||||
);
|
||||
|
||||
return <AppStateContext.Provider value={value}>{children}</AppStateContext.Provider>;
|
||||
};
|
||||
|
||||
AppStateContextProvider.propTypes = {
|
||||
children: PropTypes.element.isRequired,
|
||||
};
|
||||
|
||||
export { AppStateContextProvider, AppStateContext };
|
||||
16
media-store/app-src/src/hocs/withRestrictions.jsx
Normal file
16
media-store/app-src/src/hocs/withRestrictions.jsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import React from 'react';
|
||||
import { Redirect } from 'react-router-dom';
|
||||
import { useAppState } from '../hooks/useAppState';
|
||||
|
||||
const withRestrictions = (Component, isUserMeetRestrictions) => {
|
||||
return (props) => {
|
||||
const { user, invoicedItems } = useAppState();
|
||||
return isUserMeetRestrictions({ user, invoicedItems }) ? (
|
||||
<Component {...props} />
|
||||
) : (
|
||||
<Redirect exact to="/error" />
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
export { withRestrictions };
|
||||
22
media-store/app-src/src/hooks/useAbortableEffect.js
Normal file
22
media-store/app-src/src/hooks/useAbortableEffect.js
Normal 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 };
|
||||
6
media-store/app-src/src/hooks/useAppState.js
Normal file
6
media-store/app-src/src/hooks/useAppState.js
Normal file
@@ -0,0 +1,6 @@
|
||||
import { useContext } from 'react';
|
||||
import { AppStateContext } from '../contexts/AppStateContext';
|
||||
|
||||
const useAppState = () => useContext(AppStateContext);
|
||||
|
||||
export { useAppState };
|
||||
34
media-store/app-src/src/hooks/useErrors.js
Normal file
34
media-store/app-src/src/hooks/useErrors.js
Normal file
@@ -0,0 +1,34 @@
|
||||
import { useHistory } from 'react-router-dom';
|
||||
import { useAppState } from './useAppState';
|
||||
|
||||
const useErrors = () => {
|
||||
const history = useHistory();
|
||||
const { setError } = useAppState();
|
||||
|
||||
const handleError = (error) => {
|
||||
console.error('Error', error);
|
||||
|
||||
if (error.response) {
|
||||
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. Seems like request is too long',
|
||||
});
|
||||
}
|
||||
|
||||
history.push('/error');
|
||||
};
|
||||
|
||||
return {
|
||||
handleError,
|
||||
};
|
||||
};
|
||||
|
||||
export { useErrors };
|
||||
11
media-store/app-src/src/index.jsx
Normal file
11
media-store/app-src/src/index.jsx
Normal file
@@ -0,0 +1,11 @@
|
||||
import React from 'react';
|
||||
import ReactDOM from 'react-dom';
|
||||
import App from './App';
|
||||
// import * as serviceWorker from './serviceWorker';
|
||||
|
||||
ReactDOM.render(<App />, 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();
|
||||
7
media-store/app-src/src/logo.svg
Normal file
7
media-store/app-src/src/logo.svg
Normal file
@@ -0,0 +1,7 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 841.9 595.3">
|
||||
<g fill="#61DAFB">
|
||||
<path d="M666.3 296.5c0-32.5-40.7-63.3-103.1-82.4 14.4-63.6 8-114.2-20.2-130.4-6.5-3.8-14.1-5.6-22.4-5.6v22.3c4.6 0 8.3.9 11.4 2.6 13.6 7.8 19.5 37.5 14.9 75.7-1.1 9.4-2.9 19.3-5.1 29.4-19.6-4.8-41-8.5-63.5-10.9-13.5-18.5-27.5-35.3-41.6-50 32.6-30.3 63.2-46.9 84-46.9V78c-27.5 0-63.5 19.6-99.9 53.6-36.4-33.8-72.4-53.2-99.9-53.2v22.3c20.7 0 51.4 16.5 84 46.6-14 14.7-28 31.4-41.3 49.9-22.6 2.4-44 6.1-63.6 11-2.3-10-4-19.7-5.2-29-4.7-38.2 1.1-67.9 14.6-75.8 3-1.8 6.9-2.6 11.5-2.6V78.5c-8.4 0-16 1.8-22.6 5.6-28.1 16.2-34.4 66.7-19.9 130.1-62.2 19.2-102.7 49.9-102.7 82.3 0 32.5 40.7 63.3 103.1 82.4-14.4 63.6-8 114.2 20.2 130.4 6.5 3.8 14.1 5.6 22.5 5.6 27.5 0 63.5-19.6 99.9-53.6 36.4 33.8 72.4 53.2 99.9 53.2 8.4 0 16-1.8 22.6-5.6 28.1-16.2 34.4-66.7 19.9-130.1 62-19.1 102.5-49.9 102.5-82.3zm-130.2-66.7c-3.7 12.9-8.3 26.2-13.5 39.5-4.1-8-8.4-16-13.1-24-4.6-8-9.5-15.8-14.4-23.4 14.2 2.1 27.9 4.7 41 7.9zm-45.8 106.5c-7.8 13.5-15.8 26.3-24.1 38.2-14.9 1.3-30 2-45.2 2-15.1 0-30.2-.7-45-1.9-8.3-11.9-16.4-24.6-24.2-38-7.6-13.1-14.5-26.4-20.8-39.8 6.2-13.4 13.2-26.8 20.7-39.9 7.8-13.5 15.8-26.3 24.1-38.2 14.9-1.3 30-2 45.2-2 15.1 0 30.2.7 45 1.9 8.3 11.9 16.4 24.6 24.2 38 7.6 13.1 14.5 26.4 20.8 39.8-6.3 13.4-13.2 26.8-20.7 39.9zm32.3-13c5.4 13.4 10 26.8 13.8 39.8-13.1 3.2-26.9 5.9-41.2 8 4.9-7.7 9.8-15.6 14.4-23.7 4.6-8 8.9-16.1 13-24.1zM421.2 430c-9.3-9.6-18.6-20.3-27.8-32 9 .4 18.2.7 27.5.7 9.4 0 18.7-.2 27.8-.7-9 11.7-18.3 22.4-27.5 32zm-74.4-58.9c-14.2-2.1-27.9-4.7-41-7.9 3.7-12.9 8.3-26.2 13.5-39.5 4.1 8 8.4 16 13.1 24 4.7 8 9.5 15.8 14.4 23.4zM420.7 163c9.3 9.6 18.6 20.3 27.8 32-9-.4-18.2-.7-27.5-.7-9.4 0-18.7.2-27.8.7 9-11.7 18.3-22.4 27.5-32zm-74 58.9c-4.9 7.7-9.8 15.6-14.4 23.7-4.6 8-8.9 16-13 24-5.4-13.4-10-26.8-13.8-39.8 13.1-3.1 26.9-5.8 41.2-7.9zm-90.5 125.2c-35.4-15.1-58.3-34.9-58.3-50.6 0-15.7 22.9-35.6 58.3-50.6 8.6-3.7 18-7 27.7-10.1 5.7 19.6 13.2 40 22.5 60.9-9.2 20.8-16.6 41.1-22.2 60.6-9.9-3.1-19.3-6.5-28-10.2zM310 490c-13.6-7.8-19.5-37.5-14.9-75.7 1.1-9.4 2.9-19.3 5.1-29.4 19.6 4.8 41 8.5 63.5 10.9 13.5 18.5 27.5 35.3 41.6 50-32.6 30.3-63.2 46.9-84 46.9-4.5-.1-8.3-1-11.3-2.7zm237.2-76.2c4.7 38.2-1.1 67.9-14.6 75.8-3 1.8-6.9 2.6-11.5 2.6-20.7 0-51.4-16.5-84-46.6 14-14.7 28-31.4 41.3-49.9 22.6-2.4 44-6.1 63.6-11 2.3 10.1 4.1 19.8 5.2 29.1zm38.5-66.7c-8.6 3.7-18 7-27.7 10.1-5.7-19.6-13.2-40-22.5-60.9 9.2-20.8 16.6-41.1 22.2-60.6 9.9 3.1 19.3 6.5 28.1 10.2 35.4 15.1 58.3 34.9 58.3 50.6-.1 15.7-23 35.6-58.4 50.6zM320.8 78.4z"/>
|
||||
<circle cx="420.9" cy="296.5" r="45.7"/>
|
||||
<path d="M520.5 78.1z"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 2.6 KiB |
0
media-store/app-src/src/serviceWorker.js
Normal file
0
media-store/app-src/src/serviceWorker.js
Normal file
5
media-store/app-src/src/setupTests.js
Normal file
5
media-store/app-src/src/setupTests.js
Normal file
@@ -0,0 +1,5 @@
|
||||
// jest-dom adds custom jest matchers for asserting on DOM nodes.
|
||||
// allows you to do things like:
|
||||
// expect(element).toHaveTextContent(/react/i)
|
||||
// learn more: https://github.com/testing-library/jest-dom
|
||||
import '@testing-library/jest-dom/extend-expect';
|
||||
5
media-store/app-src/src/util/EventEmitter.js
Normal file
5
media-store/app-src/src/util/EventEmitter.js
Normal file
@@ -0,0 +1,5 @@
|
||||
import EventEmitter from 'events';
|
||||
|
||||
const emitter = new EventEmitter();
|
||||
|
||||
export { emitter };
|
||||
7
media-store/app-src/src/util/constants.js
Normal file
7
media-store/app-src/src/util/constants.js
Normal file
@@ -0,0 +1,7 @@
|
||||
export const AVAILABLE_LOCALES = ['en', 'fr', 'de'];
|
||||
|
||||
export const MESSAGE_TIMEOUT = 2;
|
||||
|
||||
export const requireEmployee = (user) => !!user && user.roles.includes('employee');
|
||||
|
||||
export const requireCustomer = (user) => !!user && user.roles.includes('customer');
|
||||
36
media-store/app-src/src/util/localStorageService.js
Normal file
36
media-store/app-src/src/util/localStorageService.js
Normal file
@@ -0,0 +1,36 @@
|
||||
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) {
|
||||
console.error('User from local storage are not valid');
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
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 };
|
||||
18
media-store/app-src/src/util/validateUser.js
Normal file
18
media-store/app-src/src/util/validateUser.js
Normal file
@@ -0,0 +1,18 @@
|
||||
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 };
|
||||
33
media-store/app-src/webpack/common-plugins.js
Normal file
33
media-store/app-src/webpack/common-plugins.js
Normal file
@@ -0,0 +1,33 @@
|
||||
const path = require('path');
|
||||
const webpack = require('webpack');
|
||||
const { CleanWebpackPlugin } = require('clean-webpack-plugin');
|
||||
const HtmlWebpackPlugin = require('html-webpack-plugin');
|
||||
const CopyPlugin = require('copy-webpack-plugin');
|
||||
const InterpolateHtmlPlugin = require('react-dev-utils/InterpolateHtmlPlugin');
|
||||
|
||||
module.exports = {
|
||||
plugins: [
|
||||
new CleanWebpackPlugin(),
|
||||
new HtmlWebpackPlugin({
|
||||
template: path.join(__dirname, '../public/index.html'),
|
||||
filename: path.join(__dirname, '../../app/index.html'),
|
||||
publicPath: '/static/', // for js bundles path
|
||||
}),
|
||||
new InterpolateHtmlPlugin(HtmlWebpackPlugin, {
|
||||
PUBLIC_URL: '',
|
||||
}),
|
||||
new CopyPlugin({
|
||||
patterns: [
|
||||
{
|
||||
from: path.join(__dirname, '../public'),
|
||||
to: path.join(__dirname, '../../app'),
|
||||
globOptions: {
|
||||
dot: true,
|
||||
ignore: ['**/index.html'],
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
new webpack.ProgressPlugin(),
|
||||
],
|
||||
};
|
||||
18
media-store/app-src/webpack/common-rules.js
Normal file
18
media-store/app-src/webpack/common-rules.js
Normal file
@@ -0,0 +1,18 @@
|
||||
module.exports = {
|
||||
rules: [
|
||||
{
|
||||
test: /\.(js|jsx)$/,
|
||||
exclude: /(node_modules)/,
|
||||
use: {
|
||||
loader: 'babel-loader',
|
||||
options: {
|
||||
presets: ['@babel/preset-env', '@babel/preset-react'],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
test: /\.(png|jpg)$/,
|
||||
use: [{ loader: 'url-loader' }],
|
||||
},
|
||||
],
|
||||
};
|
||||
62
media-store/app-src/webpack/webpack-dev-server.js
Normal file
62
media-store/app-src/webpack/webpack-dev-server.js
Normal file
@@ -0,0 +1,62 @@
|
||||
const path = require('path');
|
||||
const webpack = require('webpack');
|
||||
const HtmlWebpackPlugin = require('html-webpack-plugin');
|
||||
const { CleanWebpackPlugin } = require('clean-webpack-plugin');
|
||||
const InterpolateHtmlPlugin = require('react-dev-utils/InterpolateHtmlPlugin');
|
||||
const { rules } = require('./common-rules');
|
||||
|
||||
module.exports = {
|
||||
mode: 'development',
|
||||
devtool: 'inline-source-map',
|
||||
entry: {
|
||||
index: './src/index.jsx',
|
||||
react: ['react', 'react-dom'],
|
||||
lodash: ['lodash'],
|
||||
moment: ['moment'],
|
||||
events: ['events'],
|
||||
axios: ['axios'],
|
||||
antd: ['antd'],
|
||||
},
|
||||
devServer: {
|
||||
contentBase: './dist',
|
||||
compress: true, // compress files to gzip to increase download speed
|
||||
port: 3000,
|
||||
disableHostCheck: false, // by default true, it is not recomended,
|
||||
// because it makes app vulnerable to DNS rebinding attacks
|
||||
headers: {
|
||||
'X-Custom-header': 'custom', // this requires apps with authentication
|
||||
// useful config obj
|
||||
},
|
||||
open: true, // open the browser after server had been started
|
||||
hot: true, // hot module replacement
|
||||
historyApiFallback: true, // needs for react-router-dom
|
||||
},
|
||||
plugins: [
|
||||
new CleanWebpackPlugin({ cleanStaleWebpackAssets: false }),
|
||||
new HtmlWebpackPlugin({
|
||||
template: path.join(__dirname, '../public/index.html'),
|
||||
}),
|
||||
new InterpolateHtmlPlugin(HtmlWebpackPlugin, {
|
||||
PUBLIC_URL: '',
|
||||
}),
|
||||
new webpack.ProgressPlugin(),
|
||||
new webpack.DefinePlugin({
|
||||
'process.env.SERVICE_URL': JSON.stringify('http://localhost:4004/'),
|
||||
}),
|
||||
new webpack.HotModuleReplacementPlugin(), // for hot module replacement option of devServer
|
||||
],
|
||||
output: {
|
||||
filename: '[name].[fullhash].js',
|
||||
path: path.resolve(__dirname, 'dist'),
|
||||
},
|
||||
module: {
|
||||
rules: [
|
||||
...rules,
|
||||
{
|
||||
test: /\.css$/,
|
||||
use: [{ loader: 'style-loader' }, { loader: 'css-loader' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
resolve: { extensions: ['*', '.js', '.jsx'] },
|
||||
};
|
||||
25
media-store/app-src/webpack/webpack.common.js
Normal file
25
media-store/app-src/webpack/webpack.common.js
Normal file
@@ -0,0 +1,25 @@
|
||||
const path = require('path');
|
||||
|
||||
module.exports = {
|
||||
entry: {
|
||||
app: './src/index.jsx', // Bundle with our code
|
||||
react: ['react', 'react-dom'],
|
||||
lodash: ['lodash'],
|
||||
moment: ['moment'],
|
||||
events: ['events'],
|
||||
axios: ['axios'],
|
||||
antd: ['antd'],
|
||||
},
|
||||
output: {
|
||||
// [name] - name of the entry (bundle),
|
||||
// [checksum] or [hash] - to cache different bundles
|
||||
// from update when developing (doing changes in the files)
|
||||
filename: '[name].[fullhash].js',
|
||||
// in this folder path bundles will be placed
|
||||
path: path.resolve(__dirname, '../../app/static'),
|
||||
// where you uploaded your bundled files. (Relative to server root)
|
||||
// needs for react-router-dom
|
||||
publicPath: '/static/',
|
||||
},
|
||||
resolve: { extensions: ['*', '.js', '.jsx'] },
|
||||
};
|
||||
25
media-store/app-src/webpack/webpack.dev.js
Normal file
25
media-store/app-src/webpack/webpack.dev.js
Normal file
@@ -0,0 +1,25 @@
|
||||
const webpack = require('webpack');
|
||||
const { merge } = require('webpack-merge');
|
||||
const common = require('./webpack.common.js');
|
||||
const { rules } = require('./common-rules');
|
||||
const { plugins } = require('./common-plugins');
|
||||
|
||||
module.exports = merge(common, {
|
||||
mode: 'development',
|
||||
devtool: 'inline-source-map',
|
||||
plugins: [
|
||||
...plugins,
|
||||
new webpack.DefinePlugin({
|
||||
'process.env.SERVICE_URL': JSON.stringify('http://localhost:4004/'),
|
||||
}),
|
||||
],
|
||||
module: {
|
||||
rules: [
|
||||
...rules,
|
||||
{
|
||||
test: /\.css$/,
|
||||
use: [{ loader: 'style-loader' }, { loader: 'css-loader' }],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
40
media-store/app-src/webpack/webpack.prod.js
Normal file
40
media-store/app-src/webpack/webpack.prod.js
Normal file
@@ -0,0 +1,40 @@
|
||||
const webpack = require('webpack');
|
||||
const { merge } = require('webpack-merge');
|
||||
const MiniCssExtractPlugin = require('mini-css-extract-plugin');
|
||||
const CssMinimizerPlugin = require('css-minimizer-webpack-plugin');
|
||||
const TerserPlugin = require('terser-webpack-plugin');
|
||||
const common = require('./webpack.common.js');
|
||||
const { rules } = require('./common-rules');
|
||||
const { plugins } = require('./common-plugins');
|
||||
|
||||
module.exports = merge(common, {
|
||||
mode: 'production',
|
||||
devtool: 'source-map',
|
||||
plugins: [
|
||||
...plugins,
|
||||
new webpack.DefinePlugin({
|
||||
'process.env.SERVICE_URL': JSON.stringify('api/'),
|
||||
}),
|
||||
new MiniCssExtractPlugin({
|
||||
filename: '[name].css',
|
||||
chunkFilename: '[id].css',
|
||||
}),
|
||||
],
|
||||
optimization: {
|
||||
splitChunks: {
|
||||
// To split up js code to different bundles.
|
||||
chunks: 'all', // Now bundle with our code will be cleaned up
|
||||
}, // from vendors imports (2mb ~> 100kb)
|
||||
minimize: true,
|
||||
minimizer: [new TerserPlugin(), new CssMinimizerPlugin()], // to minimize file size
|
||||
},
|
||||
module: {
|
||||
rules: [
|
||||
...rules,
|
||||
{
|
||||
test: /\.css$/,
|
||||
use: [MiniCssExtractPlugin.loader, 'css-loader'],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user