moving front app to subfolder. add webpack config with watch and dev-server mode. moving deploy things in subfolder

This commit is contained in:
Dzmitry_Tamashevich@epam.com
2020-11-29 21:02:37 +03:00
committed by Daniel Hutzel
parent 0e86e1e1fd
commit dbe4b8a7bd
70 changed files with 2755 additions and 3 deletions

View File

@@ -0,0 +1,5 @@
{
"presets": ["@babel/preset-react", "@babel/preset-env"],
"plugins": ["@babel/plugin-transform-runtime", "babel-plugin-syntax-dynamic-import"]
}

View 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
View 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*

View File

@@ -0,0 +1,4 @@
{
"printWidth": 100,
"singleQuote": true
}

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

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

View File

@@ -0,0 +1 @@
"# Media store UI"

View 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"
}
}

View 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>

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

View 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"
}
}
}

View File

@@ -0,0 +1,3 @@
# https://www.robotstxt.org/robotstxt.html
User-agent: *
Disallow:

View File

@@ -0,0 +1,10 @@
{
"welcomeFile": "/index.html",
"routes": [
{
"source": "^(.*)",
"target": "$1",
"service": "html5-apps-repo-rt"
}
]
}

View 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);
}
}

View 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;

View 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 };

View 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,
};

View 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;

View File

@@ -0,0 +1,3 @@
.ant-menu-item .anticon {
margin: 0;
}

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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 };

View File

@@ -0,0 +1,4 @@
.ant-select > div.ant-select-selector {
padding: 5px;
min-width: 300px;
}

View 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;

View File

@@ -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 };

View File

@@ -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 };

View File

@@ -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 };

View 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 };

View 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 };

View File

@@ -0,0 +1,7 @@
span > span.anticon.anticon-delete:hover {
color: #ff4d4f;
}
.card-element {
transition: opacity 0.5s ease-in-out;
}

View 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 };

View 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 };

View 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 };

View 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 };

View 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 };

View File

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

View File

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

View File

@@ -0,0 +1,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 };

View 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();

View 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

View File

View 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';

View File

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

View 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');

View 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 };

View 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 };

View 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(),
],
};

View 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' }],
},
],
};

View 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'] },
};

View 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'] },
};

View 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' }],
},
],
},
});

View 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'],
},
],
},
});

BIN
media-store/app/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

View 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="/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="/manifest.json" />
<!--
Notice the use of 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", "/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`.
-->
<script src="/static/app.75bbe376e4ccdece0eed.js"></script><script src="/static/react.75bbe376e4ccdece0eed.js"></script><script src="/static/lodash.75bbe376e4ccdece0eed.js"></script><script src="/static/moment.75bbe376e4ccdece0eed.js"></script><script src="/static/events.75bbe376e4ccdece0eed.js"></script><script src="/static/axios.75bbe376e4ccdece0eed.js"></script><script src="/static/antd.75bbe376e4ccdece0eed.js"></script></body>
</html>

BIN
media-store/app/logo192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

BIN
media-store/app/logo512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

View 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"
}
}
}

View File

@@ -0,0 +1,3 @@
# https://www.robotstxt.org/robotstxt.html
User-agent: *
Disallow:

View File

@@ -0,0 +1,10 @@
{
"welcomeFile": "/index.html",
"routes": [
{
"source": "^(.*)",
"target": "$1",
"service": "html5-apps-repo-rt"
}
]
}

View File

@@ -0,0 +1,11 @@
{
"name": "media-store-approuter",
"description": "Approuter",
"version": "1.0.0",
"dependencies": {
"@sap/approuter": "^6.8.2"
},
"scripts": {
"start": "node node_modules/@sap/approuter/approuter.js"
}
}

View File

@@ -0,0 +1,17 @@
{
"welcomeFile": "/index.html",
"authenticationMethod": "none",
"routes": [
{
"source": "/api/(.*)",
"target": "$1",
"destination": "srv-binding",
"authenticationType": "none"
},
{
"source": "^(.*)",
"target": "mediastore/$1",
"service": "html5-apps-repo-rt"
}
]
}

View File

@@ -0,0 +1,12 @@
{
"name": "media-store-html5deployer",
"engines": {
"node": ">=6.0.0"
},
"dependencies": {
"@sap/html5-app-deployer": "^2.0.0"
},
"scripts": {
"start": "node node_modules/@sap/html5-app-deployer/index.js"
}
}

View File

@@ -49,7 +49,6 @@ module.exports = async function () {
({ track_ID: curID }) => newInvoicedTracks.includes(curID)
);
if (isNewInvoiceHasInvoicedTracks) {
await transaction.rollback();
req.reject(400, "Invoice contains already owned values");
}

View File

@@ -1,5 +1,7 @@
const cds = require("@sap/cds");
const SHIPPED_STATUS = 1;
module.exports = async function () {
const db = await cds.connect.to("db"); // connect to database service
@@ -13,6 +15,7 @@ module.exports = async function () {
"invoice_ID in",
SELECT("ID").from(Invoices).where({
customer_ID: req.user.attr.ID,
status: SHIPPED_STATUS,
})
)
);

View File

@@ -4,8 +4,7 @@ module.exports = async function () {
const db = await cds.connect.to("db"); // connect to database service
this.on("CREATE", "*", async (req) => {
const selectLastQuery = SELECT.one(req.entity)
.orderBy({ ID: "desc" });
const selectLastQuery = SELECT.one(req.entity).orderBy({ ID: "desc" });
const transaction = await db.tx(req);

View File

@@ -0,0 +1,28 @@
const getDurationInMilliseconds = (start) => {
const NS_PER_SEC = 1e9; // convert to nanoseconds
const NS_TO_MS = 1e6; // convert to milliseconds
const diff = process.hrtime(start);
return (diff[0] * NS_PER_SEC + diff[1]) / NS_TO_MS;
};
const getFormattedDateTime = () => {
let currentDateTime = new Date();
let formattedDateTime =
currentDateTime.getFullYear() +
"-" +
(currentDateTime.getMonth() + 1) +
"-" +
currentDateTime.getDate() +
" " +
currentDateTime.getHours() +
":" +
currentDateTime.getMinutes() +
":" +
currentDateTime.getSeconds();
return formattedDateTime;
};
module.exports = {
getFormattedDateTime,
getDurationInMilliseconds,
};