Compare commits

...

80 Commits

Author SHA1 Message Date
Daniel
67b845c32e simplified setup 2021-01-04 17:03:58 +01:00
Dmitriynj
fe52eec28e add right datetime format 2021-01-04 14:37:01 +01:00
Dmitriynj
4816028fc1 changing package.json scripts. adjusting mock datetime format 2021-01-04 14:37:01 +01:00
Dmitriynj
1c9a24444a change jest timeout. add @capire/chinook as dependency 2021-01-04 14:37:01 +01:00
Dmitriynj
193e762554 returning package.json dependencies 2021-01-04 14:37:01 +01:00
Dmitriynj
9abeb67d82 returning subfolders names 2021-01-04 14:37:01 +01:00
Dmitriynj
9d28ca9844 changing chinook sql kind property to sql 2021-01-04 14:37:01 +01:00
Dmitriynj
a45d79e8e8 change tests 2021-01-04 14:37:01 +01:00
Dmitriynj
4fd0b74b8c change folders structure 2021-01-04 14:37:01 +01:00
Daniel
69e510a407 Moved to chinook + added .env 2021-01-04 14:37:01 +01:00
Daniel
5cec82fa00 re-including app folder 2021-01-04 14:37:01 +01:00
Daniel
ef0f5bea65 Cleanup folder layout 2021-01-04 14:37:01 +01:00
Dmitriynj
317d45074a change tests 2021-01-04 14:37:01 +01:00
Dmitriynj
cb71e2ed9b update tests 2021-01-04 14:37:01 +01:00
Dmitriynj
145becb1c4 change retry count 2021-01-04 14:37:01 +01:00
Dmitriynj
aeafb1d010 change invoice action impl. changing .http sample file 2021-01-04 14:37:01 +01:00
Dmitriynj
72616ae4ce changing retry number. add readme.md point 2021-01-04 14:37:01 +01:00
Dmitriynj
fc41981eb9 resolve quotation mark error when searching 2021-01-04 14:37:01 +01:00
Dmitriynj
b3ea0cc4f1 add retry interceptor impl 2021-01-04 14:37:01 +01:00
Dmitriynj
09dd526f22 remove unnecessary assosiation. add /index.html route to main page 2021-01-04 14:37:01 +01:00
Dmitriynj
1dd1863266 changing webpack-dev-server-config 2021-01-04 14:37:01 +01:00
Dmitriynj
4ebc20f8ce changing readme.md style 2021-01-04 14:37:01 +01:00
Dmitriynj
bebc18a3e6 changing endings 2021-01-04 14:37:01 +01:00
Dmitriynj
fcd1bf9c20 change words to lowwer case in readme.md 2021-01-04 14:37:01 +01:00
Dmitriynj
d3d4b32c79 change readme.md. add folders description 2021-01-04 14:37:01 +01:00
Dmitriynj
de04a896d1 changing cds services 2021-01-04 14:37:01 +01:00
Dmitriynj
723bd93ef3 change readme.md 2021-01-04 14:37:01 +01:00
Dzmitry_Tamashevich@epam.com
64cc4ec26a move xs-securiry.json 2021-01-04 14:37:01 +01:00
Dzmitry_Tamashevich@epam.com
ee63541845 change gitignore. change server.js 2021-01-04 14:37:01 +01:00
Dzmitry_Tamashevich@epam.com
00474edffe add app to gitignore 2021-01-04 14:37:01 +01:00
Dzmitry_Tamashevich@epam.com
dbe4b8a7bd moving front app to subfolder. add webpack config with watch and dev-server mode. moving deploy things in subfolder 2021-01-04 14:37:01 +01:00
Tamashevich, Dzmitry
0e86e1e1fd change token live time 2021-01-04 14:37:01 +01:00
Dzmitry_Tamashevich@epam.com
90fc300ada add .mta files to gitignore 2021-01-04 14:37:01 +01:00
Dzmitry_Tamashevich@epam.com
a04cc0c25f changing tests. removing importData func 2021-01-04 14:37:01 +01:00
Tamashevich, Dzmitry
029ba61098 changing invoice request 2021-01-04 14:37:01 +01:00
Dzmitry_Tamashevich@epam.com
d9b607919a changing logger 2021-01-04 14:37:01 +01:00
Dzmitry_Tamashevich@epam.com
f439119e73 change readme.md. clean up console.logs. add check for exsisting invoices 2021-01-04 14:37:01 +01:00
Dzmitry_Tamashevich@epam.com
fe0562f38b edit person page. remove invoicedItems on logout 2021-01-04 14:37:01 +01:00
Dzmitry_Tamashevich@epam.com
58af1879f7 clean up things 2021-01-04 14:37:01 +01:00
Dzmitry_Tamashevich@epam.com
6454019713 now create tracks method works properly 2021-01-04 14:37:01 +01:00
Dzmitry_Tamashevich@epam.com
3d176237c1 refactoring error page 2021-01-04 14:37:01 +01:00
Dzmitry_Tamashevich@epam.com
938abb6387 add response interceptors for refreshTokens method 2021-01-04 14:37:01 +01:00
Dzmitry_Tamashevich@epam.com
76cbf7f9ca add flow when invalid credentials 2021-01-04 14:37:01 +01:00
Dzmitry_Tamashevich@epam.com
4b4fe2dc3f refactoring user settings in the frontend 2021-01-04 14:37:01 +01:00
Dzmitry_Tamashevich@epam.com
d78e759bf7 add hana kind 2021-01-04 14:37:01 +01:00
Dzmitry_Tamashevich@epam.com
185e6b939f change invoiceDate impl 2021-01-04 14:37:01 +01:00
Dzmitry_Tamashevich@epam.com
7045914e57 add env vars. change db kind 2021-01-04 14:37:01 +01:00
Tamashevich, Dzmitry
05550a14b1 add frontend code. add deploy config 2021-01-04 14:37:01 +01:00
Tamashevich, Dzmitry
25bdc0a6b2 refactor csv data 2021-01-04 14:37:01 +01:00
Tamashevich, Dzmitry
29fb47f2e9 add dev only things 2021-01-04 14:37:01 +01:00
Dzmitry_Tamashevich@epam.com
fdd2a7a2c5 providing initial data. replacing bycript with bycriptjs 2021-01-04 14:37:01 +01:00
Tamashevich, Dzmitry
3d1502ddfe return bool flag 2021-01-04 14:37:01 +01:00
Dzmitry_Tamashevich@epam.com
c932b486e1 add compositions for cascade delete 2021-01-04 14:37:01 +01:00
Dzmitry_Tamashevich@epam.com
e08b1c6246 refactoring code 2021-01-04 14:37:01 +01:00
Dzmitry_Tamashevich@epam.com
ecdc32bad1 replacing restriction conditions 2021-01-04 14:37:01 +01:00
Dzmitry_Tamashevich@epam.com
34acef85b6 refactoring requests 2021-01-04 14:37:01 +01:00
Dzmitry_Tamashevich@epam.com
70b0c85346 add custom authentication checks 2021-01-04 14:37:01 +01:00
Dzmitry_Tamashevich@epam.com
3cf02cb567 refactor import usage. refactor invoices implementation 2021-01-04 14:37:01 +01:00
Dzmitry_Tamashevich@epam.com
bcfce87276 add customer restriction when browsing invoices 2021-01-04 14:37:01 +01:00
Dzmitry_Tamashevich@epam.com
a319199e10 add cancel invoice action 2021-01-04 14:37:01 +01:00
Dzmitry_Tamashevich@epam.com
52f00c62b7 add status to invoices 2021-01-04 14:37:01 +01:00
Dzmitry_Tamashevich@epam.com
9a63f406ec rename person type 2021-01-04 14:37:01 +01:00
Dzmitry_Tamashevich@epam.com
49b8f4ef95 reaname services 2021-01-04 14:37:01 +01:00
Dzmitry_Tamashevich@epam.com
30d5c789bc add getUser method 2021-01-04 14:37:01 +01:00
Dzmitry_Tamashevich@epam.com
0690762207 add invoice action 2021-01-04 14:37:01 +01:00
Dzmitry_Tamashevich@epam.com
49f6b8c060 add mocked auth 2021-01-04 14:37:01 +01:00
Dzmitry_Tamashevich@epam.com
937d9caf2b add app bundles to medi-store 2021-01-04 14:37:01 +01:00
Dzmitry_Tamashevich@epam.com
ae05e8a609 remove compiled bundles from app 2021-01-04 14:37:01 +01:00
Dzmitry_Tamashevich@epam.com
a917dac1b2 add getMyTracks option 2021-01-04 14:37:01 +01:00
Tamashevich, Dzmitry
99d4da34d7 adjust README.md with frontend repo link provided 2021-01-04 14:37:01 +01:00
Dzmitry_Tamashevich@epam.com
bdbd9d425b add tracks main page. add get my tracks action 2021-01-04 14:37:01 +01:00
Dzmitry_Tamashevich@epam.com
0f6444589f return timeout for mocha runner 2021-01-04 14:37:01 +01:00
Dzmitry_Tamashevich@epam.com
d796bf1ec9 remove redundant args 2021-01-04 14:37:01 +01:00
Dzmitry_Tamashevich@epam.com
41da47aa4f adjast regex for omiting id from column name 2021-01-04 14:37:01 +01:00
Dzmitry_Tamashevich@epam.com
4783a5729d add tests. add to many associations 2021-01-04 14:37:01 +01:00
Dzmitry_Tamashevich@epam.com
e58ad84a2f add reorder for column names 2021-01-04 14:37:01 +01:00
Dzmitry_Tamashevich@epam.com
fc07f7ebba remove redundantt folder 2021-01-04 14:37:01 +01:00
Dzmitry_Tamashevich@epam.com
7a0e8fdba6 remove redundantt folder 2021-01-04 14:37:01 +01:00
Dzmitry_Tamashevich@epam.com
e7be207911 add cli tool 2021-01-04 14:37:01 +01:00
Dzmitry_Tamashevich@epam.com
e33a455154 creating cds model of chinook db 2021-01-04 14:37:01 +01:00
95 changed files with 19997 additions and 1 deletions

3
chinook/.env Normal file
View File

@@ -0,0 +1,3 @@
# REVISIT: This is not a good practice -> don't do it that way, we just did it to save some time :)
ACCESS_TOKEN_SECRET=secret
REFRESH_TOKEN_SECRET=refresh-secret

35
chinook/.gitignore vendored Normal file
View File

@@ -0,0 +1,35 @@
# CAP media-store
_out
*.db
connection.properties
default-*.json
gen/
node_modules/
target/
package-lock.json
app/build
# html5Deployer
app/deployers/html5Deployer/resources/
# Web IDE, App Studio
.che/
.gen/
# MTA
*_mta_build_tmp
*.mtar
*.mta
mta_archives/
# Other
.DS_Store
*.orig
*.log
*.iml
*.flattened-pom.xml
# IDEs
# .vscode
# .idea

20
chinook/.vscode/extensions.json vendored Normal file
View File

@@ -0,0 +1,20 @@
{
// See https://go.microsoft.com/fwlink/?LinkId=827846 to learn about workspace recommendations.
// Extension identifier format: ${publisher}.${name}. Example: vscode.csharp
// List of extensions which should be recommended for users of this workspace.
"recommendations": [
// >>>>>>>> Add CDS Editor here as soon it is available of vscode marketplace!,
"dbaeumer.vscode-eslint",
"esbenp.prettier-vscode",
"mechatroner.rainbow-csv",
"humao.rest-client",
"alexcvzz.vscode-sqlite",
"hbenl.vscode-mocha-test-adapter",
"sdras.night-owl"
],
// List of extensions recommended by VS Code that should not be recommended for users of this workspace.
"unwantedRecommendations": [
]
}

17
chinook/.vscode/launch.json vendored Normal file
View File

@@ -0,0 +1,17 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"command": "cds run --with-mocks --in-memory?",
"name": "cds run",
"request": "launch",
"type": "node-terminal",
"skipFiles": ["<node_internals>/**"]
}
]
}

8
chinook/.vscode/settings.json vendored Normal file
View File

@@ -0,0 +1,8 @@
{
"files.exclude": {
"**/.gitignore": true,
"**/.git": true,
"**/.vscode": true
},
"files.watcherExclude": {}
}

25
chinook/.vscode/tasks.json vendored Normal file
View File

@@ -0,0 +1,25 @@
{
// See https://go.microsoft.com/fwlink/?LinkId=733558
// for the documentation about the tasks.json format
"version": "2.0.0",
"tasks": [
{
"type": "shell",
"label": "cds watch",
"command": "cds",
"args": ["watch"],
"group": {
"kind": "build",
"isDefault": true
},
"problemMatcher": []
},
{
"type": "shell",
"label": "cds run",
"command": "cds",
"args": ["run", "--with-mocks", "--in-memory?"],
"problemMatcher": []
}
]
}

96
chinook/README.md Normal file
View File

@@ -0,0 +1,96 @@
# Getting Started
Welcome to your new project.
It contains these folders and files, following our recommended project layout:
| File or Folder | Purpose |
| ---------------- | ------------------------------------ |
| `app/` | will contain compiled front bundles |
| `app/front/` | contains frontend app on react |
| `app/deployers/` | contains deployment stuff |
| `db/` | your domain models and data go here |
| `srv/` | your service models and code go here |
| `test/` | your services tests |
| `package.json` | project metadata and configuration |
| `mta.yaml` | deployment config |
| `readme.md` | this getting started guide |
| `server.js` | initial server set up |
## Development
- Start cds service on 4004 port in watch mode:
```json
cds watch
```
- Open `app/front` folder and run next commands. This will install dependencies and run frontend src files watcher. When you will change src files your bundles in app directory will re-compiled. Now you can enjoy development:
```json
npm install
npm run watch
```
> For better frontend development experience use below command instead of watcher. This will start frontend dev server on 3000 port. Now your bundles will be hot reloaded, this means you do not need reload the page to see changes:
>
> ```json
> npm run start
> ```
## Test
- Change package.json db section
```json
"db": {
"kind": "sql"
}
```
- Run tests
```json
npm run test
```
## Deployment
- Make sure you already have hana trial instance in your cockpit dashboard (SAP Cloud Platform).
Or if you are using hana instance - change it in mta.yaml config file from hanatrial to hana
- Change package.json db section
```json
"db": {
"kind": "hana"
}
```
- Authenticate to the Cloud Foundry:
```json
cf login
```
- Open `app/front` folder and run the following commands. This will create frontend production bundles in app subfolder:
```json
npm install
npm run build:prod
```
- Clean up app/deployers/html5Deployer/resources folder from the previous frontend build
- From root directory run:
```json
mbt build -t ./
cf deploy media-store_1.0.0.mtar
```
- Now your services should be deployed with hanatrial instance and filled with initial data
## Learn More
- [Learn more about CAP](https://cap.cloud.sap/docs/get-started/)
- [Deploying to Cloud Foundry](https://cap.cloud.sap/docs/advanced/deploy-to-cloud)

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

@@ -0,0 +1,7 @@
{
"xsappname": "media-store-xsuaa",
"tenant-mode": "dedicated",
"scopes": [],
"attributes": [],
"role-templates": []
}

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,43 @@
{
"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",
"consistent-return": "off",
"prefer-destructuring": "off"
}
}

23
chinook/app/front/.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
chinook/app/front/.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,67 @@
{
"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:dev": "./node_modules/.bin/webpack --config ./webpack/webpack.dev.js",
"build:prod": "./node_modules/.bin/webpack --config ./webpack/webpack.prod.js",
"lint": "./node_modules/.bin/eslint"
},
"dependencies": {
"@ant-design/icons": "4.3.0",
"@pmmmwh/react-refresh-webpack-plugin": "^0.4.3",
"@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-refresh": "^0.9.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.

Binary file not shown.

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,168 @@
import axios from 'axios';
import { getUserFromLS, getLocaleFromLS } from '../util/localStorageService';
import { emitter } from '../util/EventEmitter';
const TIMEOUT = 2000;
const RETRY_COUNT = 3;
/**
* This is axios instance
*/
const axiosInstance = axios.create({
baseURL: process.env.SERVICE_URL,
timeout: TIMEOUT,
retryDelay: TIMEOUT,
retry: RETRY_COUNT,
});
/**
* 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;
}
}
/**
* Init axios defaults
*/
const user = getUserFromLS();
const locale = getLocaleFromLS();
changeUserDefaults(user);
changeLocaleDefaults(locale);
/**
* Retry request if response time is too long
* See link below
* {@link https://github.com/axios/axios/issues/164#issuecomment-327837467 GitHub}
* @param {*} err response error object
*/
function axiosRetryInterceptor(err) {
const config = err.config;
// If config does not exist or the retry option is not set, reject
if (config && config.retry) {
// Set the variable for keeping track of the retry count
config.retryCount = config.retryCount || 0;
// Check if we've maxed out the total number of retries
if (config.retryCount >= config.retry) {
// Reject with the error
return Promise.reject(err);
}
// Increase the retry count
config.retryCount += 1;
// Create new promise to handle exponential backoff
const backoff = new Promise((resolve) => {
setTimeout(() => {
resolve();
}, config.retryDelay || 1);
});
// Return the promise in which recalls axios to retry the request
return backoff.then(() => {
return axios(config);
});
}
}
/**
* Things below needed for refresh tokens mechanism implementation
*/
let isRefreshing = false;
let subscribers = [];
const refreshTokens = (refreshToken) => {
return axiosInstance.post(
'users/refreshTokens',
{ refreshToken },
{
headers: { 'content-type': 'application/json' },
}
);
};
/**
* Refresh tokens interceptor
* See link below
* {@link https://gist.github.com/mkjiau/650013a99c341c9f23ca00ccb213db1c#gistcomment-3536511 GitHub}
* @param {*} error error response object
*/
function axiosRefreshTokensInterceptor(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);
},
});
});
}
}
axiosInstance.interceptors.response.use(null, (error) => {
return (
axiosRefreshTokensInterceptor(error) || axiosRetryInterceptor(error) || Promise.reject(error)
);
});
export { axiosInstance, changeLocaleDefaults, changeUserDefaults };

View File

@@ -0,0 +1,164 @@
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,
},
{
headers: { 'content-type': 'application/json' },
}
);
};
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,141 @@
import React from 'react';
import { Menu, Badge, Spin, message } 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, MESSAGE_TIMEOUT } 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, 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) => (
<Menu.Item key={curLocale} onClick={() => onChangeLocale(curLocale)}>
{curLocale}
</Menu.Item>
)
);
const onUserLogout = () => {
emitter.emit('UPDATE_USER', undefined);
message.warn(
'Now you are not authenticated. Log in to use full functionality',
MESSAGE_TIMEOUT
);
history.push('/');
};
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>
)}
</Menu>
<Menu
style={{ width: '50%', display: 'flex', justifyContent: 'flex-end' }}
theme="light"
mode="horizontal"
selectedKeys={currentKey}
>
<Menu.Item>
{loading && <Spin indicator={<LoadingOutlined style={{ fontSize: 24 }} spin />} />}
</Menu.Item>
{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
key="/login"
onClick={() => history.push('/login')}
icon={<LoginOutlined style={{ fontSize: 16 }} />}
/>
)}
</Menu>
</div>
);
};
export default Header;

View File

@@ -0,0 +1,101 @@
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 }) => ({
ID,
}))
)
.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:ssZ';
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,58 @@
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 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={['/index.html', '/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,221 @@
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 isEven = (value) => {
return value % 2 === 0;
};
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.replace(/'*/g, (value) =>
isEven(value.length) ? value : `${value}'`
),
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-col${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,96 @@
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!',
},
];
function getAlbums(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,
};
TrackForm.defaultProps = {
initialAlbumTitle: undefined,
};
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,117 @@
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 afterCloseModal = () => {
setUpdateLoading(true);
getTrack(ID)
.then((response) => {
afterTrackUpdate(response.data);
setUpdateLoading(false);
})
.catch(handleError);
};
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);
};
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,
composer,
genreID: genre.ID,
albumID: album.ID,
unitPrice,
}}
>
<TrackForm initialAlbumTitle={album.title} />
</Form>
</Modal>
</>
);
};
EditAction.propTypes = {
ID: PropTypes.number.isRequired,
name: PropTypes.string.isRequired,
composer: PropTypes.string,
genre: PropTypes.object.isRequired,
unitPrice: PropTypes.number.isRequired,
album: PropTypes.object.isRequired,
afterTrackUpdate: PropTypes.func.isRequired,
};
EditAction.defaultProps = {
composer: undefined,
};
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,48 @@
import React, { useState, useRef } from 'react';
import { Card } from 'antd';
import PropTypes from 'prop-types';
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>
);
};
ManagedTrack.propTypes = {
initialTrack: PropTypes.object.isRequired,
onDeleteTrack: PropTypes.func.isRequired,
};
export { ManagedTrack };

View File

@@ -0,0 +1,63 @@
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.isRequired,
isAlreadyOrdered: PropTypes.bool,
};
Track.defaultProps = {
isAlreadyOrdered: undefined,
};
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>

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({ dangerouslyAllowCleanPatternsOutsideProject: true }),
new HtmlWebpackPlugin({
template: path.join(__dirname, '../public/index.html'),
filename: path.join(__dirname, '../../build/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, '../../build'),
globOptions: {
dot: true,
ignore: ['**/index.html'],
},
},
],
}),
new webpack.ProgressPlugin(),
],
};

View File

@@ -0,0 +1,19 @@
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,68 @@
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 ReactRefreshWebpackPlugin = require('@pmmmwh/react-refresh-webpack-plugin');
module.exports = {
entry: {
app: './src/index.jsx',
},
devtool: 'inline-source-map',
devServer: {
contentBase: './dist',
hot: true,
port: 3000,
compress: true, // compress files to gzip to increase download speed
disableHostCheck: false, // by default true, it is not recomended,
// because it makes app vulnerable to DNS rebinding attacks
open: true, // open the browser after server had been started
historyApiFallback: true,
},
module: {
rules: [
{
test: /\.(js|jsx)$/,
exclude: /(node_modules)/,
use: {
loader: 'babel-loader',
options: {
presets: ['@babel/preset-env', '@babel/preset-react'],
plugins: ['react-refresh/babel'].filter(Boolean),
},
},
},
{
test: /\.(png|jpg)$/,
use: [{ loader: 'url-loader' }],
},
{
test: /\.css$/,
use: [{ loader: 'style-loader' }, { loader: 'css-loader' }],
},
],
},
plugins: [
new CleanWebpackPlugin({ cleanStaleWebpackAssets: false }),
new HtmlWebpackPlugin({
template: path.join(__dirname, '../public/index.html'),
}),
new InterpolateHtmlPlugin(HtmlWebpackPlugin, {
PUBLIC_URL: '',
}),
new webpack.DefinePlugin({
'process.env.SERVICE_URL': JSON.stringify('http://localhost:4004/'),
}),
new webpack.ProgressPlugin(),
new webpack.HotModuleReplacementPlugin(), // for hot module replacement option of devServer
new ReactRefreshWebpackPlugin(),
].filter(Boolean),
output: {
filename: '[name].bundle.js',
path: path.resolve(__dirname, 'dist'),
},
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, '../../build/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'],
},
],
},
});

View File

@@ -0,0 +1,349 @@
ID,title,artist_ID
1,For Those About To Rock We Salute You,1
2,Balls to the Wall,2
3,Restless and Wild,2
4,Let There Be Rock,1
5,Big Ones,3
6,Jagged Little Pill,4
7,Facelift,5
8,Warner 25 Anos,6
9,Plays Metallica By Four Cellos,7
10,Audioslave,8
11,Out Of Exile,8
12,BackBeat Soundtrack,9
13,The Best Of Billy Cobham,10
14,Alcohol Fueled Brewtality Live! [Disc 1],11
15,Alcohol Fueled Brewtality Live! [Disc 2],11
16,Black Sabbath,12
17,Black Sabbath Vol. 4 (Remaster),12
18,Body Count,13
19,Chemical Wedding,14
20,The Best Of Buddy Guy - The Millenium Collection,15
21,Prenda Minha,16
22,Sozinho Remix Ao Vivo,16
23,Minha Historia,17
24,Afrociberdelia,18
25,Da Lama Ao Caos,18
26,Acústico MTV [Live],19
27,Cidade Negra - Hits,19
28,Na Pista,20
29,Axé Bahia 2001,21
30,BBC Sessions [Disc 1] [Live],22
31,Bongo Fury,23
32,Carnaval 2001,21
33,Chill: Brazil (Disc 1),24
34,Chill: Brazil (Disc 2),6
35,Garage Inc. (Disc 1),50
36,Greatest Hits II,51
37,Greatest Kiss,52
38,Heart of the Night,53
39,International Superhits,54
40,Into The Light,55
41,Meus Momentos,56
42,Minha História,57
43,MK III The Final Concerts [Disc 1],58
44,Physical Graffiti [Disc 1],22
45,Sambas De Enredo 2001,21
46,Supernatural,59
47,The Best of Ed Motta,37
48,The Essential Miles Davis [Disc 1],68
49,The Essential Miles Davis [Disc 2],68
50,The Final Concerts (Disc 2),58
51,Up An' Atom,69
52,Vinícius De Moraes - Sem Limite,70
53,Vozes do MPB,21
54,"Chronicle, Vol. 1",76
55,"Chronicle, Vol. 2",76
56,Cássia Eller - Coleção Sem Limite [Disc 2],77
57,Cássia Eller - Sem Limite [Disc 1],77
58,Come Taste The Band,58
59,Deep Purple In Rock,58
60,Fireball,58
61,Knocking at Your Back Door: The Best Of Deep Purple in the 80's,58
62,Machine Head,58
63,Purpendicular,58
64,Slaves And Masters,58
65,Stormbringer,58
66,The Battle Rages On,58
67,Vault: Def Leppard's Greatest Hits,78
68,Outbreak,79
69,Djavan Ao Vivo - Vol. 02,80
70,Djavan Ao Vivo - Vol. 1,80
71,Elis Regina-Minha História,41
72,The Cream Of Clapton,81
73,Unplugged,81
74,Album Of The Year,82
75,Angel Dust,82
76,King For A Day Fool For A Lifetime,82
77,The Real Thing,82
78,Deixa Entrar,83
79,In Your Honor [Disc 1],84
80,In Your Honor [Disc 2],84
81,One By One,84
82,The Colour And The Shape,84
83,My Way: The Best Of Frank Sinatra [Disc 1],85
84,Roda De Funk,86
85,As Canções de Eu Tu Eles,27
86,Quanta Gente Veio Ver (Live),27
87,Quanta Gente Veio ver--Bônus De Carnaval,27
88,Faceless,87
89,American Idiot,54
90,Appetite for Destruction,88
91,Use Your Illusion I,88
92,Use Your Illusion II,88
93,Blue Moods,89
94,A Matter of Life and Death,90
95,A Real Dead One,90
96,A Real Live One,90
97,Brave New World,90
98,Dance Of Death,90
99,Fear Of The Dark,90
100,Iron Maiden,90
101,Killers,90
102,Live After Death,90
103,Live At Donington 1992 (Disc 1),90
104,Live At Donington 1992 (Disc 2),90
105,No Prayer For The Dying,90
106,Piece Of Mind,90
107,Powerslave,90
108,Rock In Rio [CD1],90
109,Rock In Rio [CD2],90
110,Seventh Son of a Seventh Son,90
111,Somewhere in Time,90
112,The Number of The Beast,90
113,The X Factor,90
114,Virtual XI,90
115,Sex Machine,91
116,Emergency On Planet Earth,92
117,Synkronized,92
118,The Return Of The Space Cowboy,92
119,Get Born,93
120,Are You Experienced?,94
121,Surfing with the Alien (Remastered),95
122,Jorge Ben Jor 25 Anos,46
123,Jota Quest-1995,96
124,Cafezinho,97
125,Living After Midnight,98
126,Unplugged [Live],52
127,BBC Sessions [Disc 2] [Live],22
128,Coda,22
129,Houses Of The Holy,22
130,In Through The Out Door,22
131,IV,22
132,Led Zeppelin I,22
133,Led Zeppelin II,22
134,Led Zeppelin III,22
135,Physical Graffiti [Disc 2],22
136,Presence,22
137,The Song Remains The Same (Disc 1),22
138,The Song Remains The Same (Disc 2),22
139,A TempestadeTempestade Ou O Livro Dos Dias,99
140,Mais Do Mesmo,99
141,Greatest Hits,100
142,Lulu Santos - RCA 100 Anos De Música - Álbum 01,101
143,Lulu Santos - RCA 100 Anos De Música - Álbum 02,101
144,Misplaced Childhood,102
145,Barulhinho Bom,103
146,Seek And Shall Find: More Of The Best (1963-1981),104
147,The Best Of Men At Work,105
148,Black Album,50
149,Garage Inc. (Disc 2),50
150,Kill 'Em All,50
151,Load,50
152,Master Of Puppets,50
153,ReLoad,50
154,Ride The Lightning,50
155,St. Anger,50
156,...And Justice For All,50
157,Miles Ahead,68
158,Milton Nascimento Ao Vivo,42
159,Minas,42
160,Ace Of Spades,106
161,Demorou...,108
162,Motley Crue Greatest Hits,109
163,From The Muddy Banks Of The Wishkah [Live],110
164,Nevermind,110
165,Compositores,111
166,Olodum,112
167,Acústico MTV,113
168,Arquivo II,113
169,Arquivo Os Paralamas Do Sucesso,113
170,Bark at the Moon (Remastered),114
171,Blizzard of Ozz,114
172,Diary of a Madman (Remastered),114
173,No More Tears (Remastered),114
174,Tribute,114
175,Walking Into Clarksdale,115
176,Original Soundtracks 1,116
177,The Beast Live,117
178,Live On Two Legs [Live],118
179,Pearl Jam,118
180,Riot Act,118
181,Ten,118
182,Vs.,118
183,Dark Side Of The Moon,120
184,Os Cães Ladram Mas A Caravana Não Pára,121
185,Greatest Hits I,51
186,News Of The World,51
187,Out Of Time,122
188,Green,124
189,New Adventures In Hi-Fi,124
190,The Best Of R.E.M.: The IRS Years,124
191,Cesta Básica,125
192,Raul Seixas,126
193,Blood Sugar Sex Magik,127
194,By The Way,127
195,Californication,127
196,Retrospective I (1974-1980),128
197,Santana - As Years Go By,59
198,Santana Live,59
199,Maquinarama,130
200,O Samba Poconé,130
201,Judas 0: B-Sides and Rarities,131
202,Rotten Apples: Greatest Hits,131
203,A-Sides,132
204,Morning Dance,53
205,In Step,133
206,Core,134
207,Mezmerize,135
208,[1997] Black Light Syndrome,136
209,Live [Disc 1],137
210,Live [Disc 2],137
211,The Singles,138
212,Beyond Good And Evil,139
213,"Pure Cult: The Best Of The Cult (For Rockers, Ravers, Lovers & Sinners) [UK]",139
214,The Doors,140
215,The Police Greatest Hits,141
216,"Hot Rocks, 1964-1971 (Disc 1)",142
217,No Security,142
218,Voodoo Lounge,142
219,Tangents,143
220,Transmission,143
221,My Generation - The Very Best Of The Who,144
222,Serie Sem Limite (Disc 1),145
223,Serie Sem Limite (Disc 2),145
224,Acústico,146
225,Volume Dois,146
226,Battlestar Galactica: The Story So Far,147
227,"Battlestar Galactica, Season 3",147
228,"Heroes, Season 1",148
229,"Lost, Season 3",149
230,"Lost, Season 1",149
231,"Lost, Season 2",149
232,Achtung Baby,150
233,All That You Can't Leave Behind,150
234,B-Sides 1980-1990,150
235,How To Dismantle An Atomic Bomb,150
236,Pop,150
237,Rattle And Hum,150
238,The Best Of 1980-1990,150
239,War,150
240,Zooropa,150
241,UB40 The Best Of - Volume Two [UK],151
242,Diver Down,152
243,"The Best Of Van Halen, Vol. I",152
244,Van Halen,152
245,Van Halen III,152
246,Contraband,153
247,Vinicius De Moraes,72
248,Ao Vivo [IMPORT],155
249,"The Office, Season 1",156
250,"The Office, Season 2",156
251,"The Office, Season 3",156
252,Un-Led-Ed,157
253,"Battlestar Galactica (Classic), Season 1",158
254,Aquaman,159
255,Instant Karma: The Amnesty International Campaign to Save Darfur,150
256,Speak of the Devil,114
257,20th Century Masters - The Millennium Collection: The Best of Scorpions,179
258,House of Pain,180
259,Radio Brasil (O Som da Jovem Vanguarda) - Seleccao de Henrique Amaro,36
260,Cake: B-Sides and Rarities,196
261,"LOST, Season 4",149
262,Quiet Songs,197
263,Muso Ko,198
264,Realize,199
265,Every Kind of Light,200
266,Duos II,201
267,Worlds,202
268,The Best of Beethoven,203
269,Temple of the Dog,204
270,Carry On,205
271,Revelations,8
272,Adorate Deum: Gregorian Chant from the Proper of the Mass,206
273,Allegri: Miserere,207
274,Pachelbel: Canon & Gigue,208
275,Vivaldi: The Four Seasons,209
276,Bach: Violin Concertos,210
277,Bach: Goldberg Variations,211
278,Bach: The Cello Suites,212
279,Handel: The Messiah (Highlights),213
280,The World of Classical Favourites,214
281,Sir Neville Marriner: A Celebration,215
282,Mozart: Wind Concertos,216
283,Haydn: Symphonies 99 - 104,217
284,Beethoven: Symhonies Nos. 5 & 6,218
285,A Soprano Inspired,219
286,Great Opera Choruses,220
287,Wagner: Favourite Overtures,221
288,"Fauré: Requiem, Ravel: Pavane & Others",222
289,Tchaikovsky: The Nutcracker,223
290,The Last Night of the Proms,224
291,Puccini: Madama Butterfly - Highlights,225
292,"Holst: The Planets, Op. 32 & Vaughan Williams: Fantasies",226
293,Pavarotti's Opera Made Easy,227
294,Great Performances - Barber's Adagio and Other Romantic Favorites for Strings,228
295,Carmina Burana,229
296,"A Copland Celebration, Vol. I",230
297,Bach: Toccata & Fugue in D Minor,231
298,Prokofiev: Symphony No.1,232
299,Scheherazade,233
300,Bach: The Brandenburg Concertos,234
301,Chopin: Piano Concertos Nos. 1 & 2,235
302,Mascagni: Cavalleria Rusticana,236
303,Sibelius: Finlandia,237
304,Beethoven Piano Sonatas: Moonlight & Pastorale,238
305,Great Recordings of the Century - Mahler: Das Lied von der Erde,240
306,Elgar: Cello Concerto & Vaughan Williams: Fantasias,241
307,"Adams, John: The Chairman Dances",242
308,"Tchaikovsky: 1812 Festival Overture, Op.49, Capriccio Italien & Beethoven: Wellington's Victory",243
309,Palestrina: Missa Papae Marcelli & Allegri: Miserere,244
310,Prokofiev: Romeo & Juliet,245
311,Strauss: Waltzes,226
312,Berlioz: Symphonie Fantastique,245
313,Bizet: Carmen Highlights,246
314,English Renaissance,247
315,Handel: Music for the Royal Fireworks (Original Version 1749),208
316,Grieg: Peer Gynt Suites & Sibelius: Pelléas et Mélisande,248
317,Mozart Gala: Famous Arias,249
318,SCRIABIN: Vers la flamme,250
319,Armada: Music from the Courts of England and Spain,251
320,Mozart: Symphonies Nos. 40 & 41,248
321,Back to Black,252
322,Frank,252
323,Carried to Dust (Bonus Track Version),253
324,Beethoven: Symphony No. 6 'Pastoral' Etc.,254
325,Bartok: Violin & Viola Concertos,255
326,Mendelssohn: A Midsummer Night's Dream,256
327,Bach: Orchestral Suites Nos. 1 - 4,257
328,"Charpentier: Divertissements, Airs & Concerts",258
329,South American Getaway,259
330,Górecki: Symphony No. 3,260
331,Purcell: The Fairy Queen,261
332,The Ultimate Relexation Album,262
333,Purcell: Music for the Queen Mary,263
334,Weill: The Seven Deadly Sins,264
335,"J.S. Bach: Chaconne, Suite in E Minor, Partita in E Major & Prelude, Fugue and Allegro",265
336,Prokofiev: Symphony No.5 & Stravinksy: Le Sacre Du Printemps,248
337,"Szymanowski: Piano Works, Vol. 1",266
338,Nielsen: The Six Symphonies,267
339,Great Recordings of the Century: Paganini's 24 Caprices,268
340,Liszt - 12 Études D'Execution Transcendante,269
341,"Great Recordings of the Century - Shubert: Schwanengesang, 4 Lieder",270
342,"Locatelli: Concertos for Violin, Strings and Continuo, Vol. 3",271
343,Respighi:Pines of Rome,226
344,Schubert: The Late String Quartets & String Quintet (3 CD's),272
345,Monteverdi: L'Orfeo,273
346,Mozart: Chamber Music,274
347,Koyaanisqatsi (Soundtrack from the Motion Picture),275
348,asdaasdasdsd,3
Internal Server Error - Bruno's Gitea
500 Internal Server Error

Gitea Version: 1.24.4