add frontend code. add deploy config

This commit is contained in:
Tamashevich, Dzmitry
2020-11-14 22:21:42 +03:00
committed by Daniel Hutzel
parent 25bdc0a6b2
commit 05550a14b1
52 changed files with 2629 additions and 2183 deletions

View File

@@ -6,6 +6,10 @@ default-*.json
gen/
node_modules/
target/
package-lock.json
# html5Deployer
html5Deployer/resources/app/
# Web IDE, App Studio
.che/

23
media-store/app/.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,41 @@
{
"name": "mediastore",
"version": "0.1.0",
"private": true,
"dependencies": {
"@testing-library/jest-dom": "^4.2.4",
"@testing-library/react": "^9.3.2",
"@testing-library/user-event": "^7.1.2",
"@umijs/hooks": "^1.9.3",
"antd": "^4.8.2",
"axios": "^0.20.0",
"lodash": "^4.17.20",
"moment": "^2.29.1",
"react": "^16.14.0",
"react-dom": "^16.14.0",
"react-inline-editing": "^1.0.10",
"react-router-dom": "^5.2.0",
"react-scripts": "^4.0.0"
},
"scripts": {
"start": "react-scripts start",
"build": "react-scripts build",
"test": "react-scripts test",
"eject": "react-scripts eject"
},
"eslintConfig": {
"extends": "react-app"
},
"browserslist": {
"production": [
">0.2%",
"not dead",
"not op_mini all"
],
"development": [
"last 1 chrome version",
"last 1 firefox version",
"last 1 safari version"
]
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.1 KiB

View File

@@ -0,0 +1,43 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<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,42 @@
.App {
text-align: center;
}
.App-logo {
height: 40vmin;
pointer-events: none;
}
@media (prefers-reduced-motion: no-preference) {
.App-logo {
animation: App-logo-spin infinite 20s linear;
}
}
.App-header {
background-color: #282c34;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
font-size: calc(10px + 2vmin);
color: white;
}
.App-link {
color: #61dafb;
}
@keyframes App-logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}
.ant-menu.ant-menu-sub.ant-menu-vertical {
border-radius: 6px !important;
}

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 "./Router";
import { GlobalContextProvider } from "./GlobalContext";
const App = () => {
return (
<Layout style={{ height: "100%" }}>
<GlobalContextProvider>
<MyRouter />
</GlobalContextProvider>
</Layout>
);
};
export default App;

View File

@@ -0,0 +1,9 @@
import React from 'react';
import { render } from '@testing-library/react';
import App from './App';
test('renders learn react link', () => {
const { getByText } = render(<App />);
const linkElement = getByText(/learn react/i);
expect(linkElement).toBeInTheDocument();
});

View File

@@ -0,0 +1,30 @@
import React from "react";
import { Breadcrumb, Spin } from "antd";
import { useLocation } from "react-router-dom";
import { useGlobals } from "./GlobalContext";
const names = {
"/": "Browse / Tracks",
"/person": "Profile",
"/login": "Login form",
"/invoice": "Requested items",
"/manage": "Manage store",
};
const CurrentPageHeader = () => {
const location = useLocation();
const { loading } = useGlobals();
return (
<Breadcrumb
style={{ height: 50, paddingBottom: 20, fontWeight: 600, fontSize: 20 }}
>
<Breadcrumb.Item>
{names[location.pathname]}
<span style={{ padding: 10 }}>{loading && <Spin />}</span>
</Breadcrumb.Item>
</Breadcrumb>
);
};
export { CurrentPageHeader };

View File

@@ -0,0 +1,52 @@
import React, { useEffect, useRef } from "react";
const Editable = ({ value, onChange, type }) => {
const inputRef = useRef();
useEffect(() => {
const { current } = inputRef;
current.value = value;
const handleFocus = () => {
console.log("input is focussed");
// current.disabled = false;
current.style.backgroundColor = "#f0f2f5";
};
const handleBlur = () => {
console.log("input is blurred");
// current.disabled = true;
current.style.backgroundColor = "white";
};
const handleInput = (e) => onChange(e.target.value);
current.addEventListener("focus", handleFocus);
current.addEventListener("blur", handleBlur);
current.addEventListener("input", handleInput);
return () => {
current.removeEventListener("focus", handleFocus);
current.removeEventListener("blur", handleBlur);
current.removeEventListener("input", handleInput);
};
});
return (
<input
ref={inputRef}
style={{
fontWeight: 600,
outline: "none",
border: "none",
borderRadius: 6,
backgroundColor: "white",
padding: "0 2px",
}}
type={type}
name="task"
/>
);
};
export { Editable };

View File

@@ -0,0 +1,126 @@
import React, { useMemo, createContext, useContext, useState } from "react";
import axios from "axios";
const globalContext = {
error: {},
loading: true,
user: {
ID: undefined,
roles: [],
email: undefined,
level: undefined,
token: undefined,
},
locale: undefined,
invoicedItems: [],
notifications: [],
};
const GlobalContext = createContext(globalContext);
const useGlobals = () => useContext(GlobalContext);
const AVAILABLE_LOCALES = ["en", "fr", "de"];
const useUserData = () => {
const getUserDataFromLS = () => {
let userFromLS;
try {
userFromLS = JSON.parse(localStorage.getItem("user"));
} catch (e) {}
if (userFromLS) {
axios.defaults.headers.common[
"Authorization"
] = `Basic ${userFromLS.token}`;
axios.defaults.userID = userFromLS.ID;
axios.defaults.userEntity =
!!userFromLS && userFromLS.roles.includes("customer")
? `Customers/${userFromLS.ID}`
: `Employees/${userFromLS.ID}`;
}
axios.defaults.tracksEntity =
!!userFromLS && userFromLS.roles.includes("customer")
? "MarkedTracks"
: "Tracks";
return userFromLS;
};
const setUserDataToLS = (value) => {
if (!!value) {
localStorage.setItem("user", JSON.stringify(value));
axios.defaults.headers.common["Authorization"] = `Basic ${value.token}`;
axios.defaults.tracksEntity = value.roles.includes("customer")
? "MarkedTracks"
: "Tracks";
axios.defaults.userEntity =
!!value && value.roles.includes("customer")
? `Customers/${value.ID}`
: `Employees/${value.ID}`;
} else {
localStorage.removeItem("user");
delete axios.defaults.headers.common["Authorization"];
delete axios.defaults.userEntity;
axios.defaults.tracksEntity =
!!value && value.roles.includes("customer") ? "MarkedTracks" : "Tracks";
}
};
const setLocaleToLS = (value) => {
localStorage.setItem("locale", value);
axios.defaults.headers.common["Accept-language"] = value;
};
const getLocaleFromLS = () => {
const localeFromLS = localStorage.getItem("locale");
const selectedLocale =
localeFromLS &&
localeFromLS !== "undefined" &&
AVAILABLE_LOCALES.includes(localeFromLS)
? localeFromLS
: "en";
axios.defaults.headers.common["Accept-language"] = selectedLocale;
return selectedLocale;
};
return { getUserDataFromLS, setUserDataToLS, setLocaleToLS, getLocaleFromLS };
};
const GlobalContextProvider = ({ children }) => {
const [loading, setLoading] = useState(false);
const [error, setError] = useState({});
const [invoicedItems, setInvoicedItems] = useState([]);
const [user, setUser] = useState(null);
const [locale, setLocale] = useState(undefined);
const {
getUserDataFromLS,
setUserDataToLS,
getLocaleFromLS,
setLocaleToLS,
} = useUserData();
const value = useMemo(
() => ({
error: error,
loading: loading,
invoicedItems: invoicedItems,
user: user ? user : getUserDataFromLS(),
locale: locale ? locale : getLocaleFromLS(),
setLoading,
setError,
setInvoicedItems,
setUser: (userParam) => {
setUserDataToLS(userParam);
setUser(userParam);
},
setLocale: (localeParam) => {
setLocaleToLS(localeParam);
setLocale(localeParam);
},
}),
[locale, user, loading, error, invoicedItems]
);
return (
<GlobalContext.Provider value={value}>{children}</GlobalContext.Provider>
);
};
export { GlobalContextProvider, useGlobals };

View File

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

View File

@@ -0,0 +1,129 @@
import React from "react";
import { Menu, Badge } from "antd";
import { isEmpty } from "lodash";
import {
CreditCardOutlined,
LogoutOutlined,
LoginOutlined,
} from "@ant-design/icons";
import { useHistory, useLocation } from "react-router-dom";
import { useGlobals } from "./GlobalContext";
import "./Header.css";
const { SubMenu } = Menu;
const keys = ["/", "/person", "/login", "/manage", "/invoice"];
const AVAILABLE_LOCALES = ["en", "fr", "de"];
const RELOAD_LOCATION_NUMBER = 0;
const Header = () => {
const history = useHistory();
const location = useLocation();
const { user, invoicedItems, setUser, locale, setLocale } = useGlobals();
const currentKey = [keys.find((key) => key === location.pathname)];
const haveInvoicedItems = !isEmpty(invoicedItems);
const invoicedItemsLength = invoicedItems.length;
const onChangeLocale = (value) => {
setLocale(value);
history.go(RELOAD_LOCATION_NUMBER);
};
const localeElements = AVAILABLE_LOCALES.filter(
(localeName) => localeName !== locale
).map((curLocale, index) => (
<Menu.Item
key={`${index}${curLocale}`}
onClick={() => onChangeLocale(curLocale)}
>
{curLocale}
</Menu.Item>
));
return (
<div
style={{
display: "flex",
justifyContent: "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>
)}
{!!user && user.roles.includes("employee") && (
<Menu.Item key="/manage" onClick={() => history.push("/manage")}>
Manage
</Menu.Item>
)}
</Menu>
<Menu
style={{ width: "50%", display: "flex", justifyContent: "flex-end" }}
theme="light"
mode="horizontal"
selectedKeys={currentKey}
>
{haveInvoicedItems && !!user && user.roles.includes("customer") && (
<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={() => {
setUser(undefined);
history.push("/");
}}
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 { Header };

View File

@@ -0,0 +1,77 @@
import React from "react";
import {
BrowserRouter as Router,
Switch,
Route,
Redirect,
} from "react-router-dom";
import { isEmpty } from "lodash";
import { TracksContainer } from "./pages/tracks/TracksPage";
import { CurrentPageHeader } from "./CurrentPageHeader";
import { Header } from "./Header";
import { PersonPage } from "./pages/person/PersonPage";
import { ErrorPage } from "./pages/ErrorPage";
import { Login } from "./pages/login/Login";
import { withRestrictions, withRestrictedSection } from "./withRestrictions";
import { InvoicePage } from "./pages/invoice/InvoicePage";
import { ManageStore } from "./pages/manage-store/ManageStore";
import { MyInvoices } from "./pages/person/MyInvoices";
const needCustomer = ({ user }) => !!user && user.roles.includes("customer");
const RestrictedLogin = withRestrictions(Login, ({ user }) => !user);
const RestrictedInvoicePage = withRestrictions(
InvoicePage,
({ user, invoicedItems }) => needCustomer({ user }) && !isEmpty(invoicedItems)
);
const RestrictedMyInvoicesSection = withRestrictedSection(
MyInvoices,
needCustomer
);
const RestrictedPersonPage = withRestrictions(PersonPage, ({ user }) => !!user);
const RestrictedManageStore = withRestrictions(
ManageStore,
({ user }) => !!user && user.roles.includes("employee")
);
const MyRouter = () => {
return (
<Router>
<Switch>
<Route path="/error">
<ErrorPage />
</Route>
<Route>
<Header />
<div style={{ padding: "2em 20vh" }}>
<CurrentPageHeader />
<Switch>
<Route exact path={["/"]}>
<TracksContainer />
</Route>
<Route exact path="/person">
<RestrictedPersonPage
myInvoicesSection={<RestrictedMyInvoicesSection />}
/>
</Route>
<Route exact path="/login">
<RestrictedLogin />
</Route>
<Route exact path="/invoice">
<RestrictedInvoicePage />
</Route>
<Route exact path="/manage">
<RestrictedManageStore />
</Route>
<Route>
<Redirect to="/error" />
</Route>
</Switch>
</div>
</Route>
</Switch>
</Router>
);
};
export { MyRouter };

View File

@@ -0,0 +1,170 @@
import { isEmpty } from "lodash";
import axios from "axios";
const BROWSE_TRACKS_SERVICE = `api/browse-tracks`;
const INVOICES_SERVICE = `api/browse-invoices`;
const USER_SERVICE = `api/users`;
const MANAGE_STORE = `api/manage-store`;
const constructGenresQuery = (genreIds) => {
return !isEmpty(genreIds)
? " and " + genreIds.map((value) => `genre_ID eq ${value}`).join(" or ")
: "";
};
const fetchTacks = ({
$top = 20,
$skip = 0,
genreIds = [],
substr = "",
} = {}) => {
const serializeTracksUrl = () => {
return `$expand=genre,album($expand=artist)&$top=${$top}&$skip=${$skip}&$filter=${
`contains(name,'${substr}')` + constructGenresQuery(genreIds)
}`;
};
return axios.get(`${BROWSE_TRACKS_SERVICE}/${axios.defaults.tracksEntity}`, {
params: {},
paramsSerializer: () => serializeTracksUrl(),
});
};
const countTracks = ({ genreIds = [], substr = "" } = {}) => {
return axios.get(
`${BROWSE_TRACKS_SERVICE}/${axios.defaults.tracksEntity}/$count?$filter=${
`contains(name,'${substr}')` + constructGenresQuery(genreIds)
}`
);
};
const fetchGenres = () => {
return axios.get(`${BROWSE_TRACKS_SERVICE}/Genres`);
};
const invoice = (tracks) => {
return axios.post(
`${INVOICES_SERVICE}/invoice`,
{
tracks: tracks.map(({ unitPrice, ID }) => ({
unitPrice: `${unitPrice}`,
ID,
})),
},
{
headers: { "content-type": "application/json;IEEE754Compatible=true" },
}
);
};
const fetchPerson = () => {
return axios.get(`${USER_SERVICE}/${axios.defaults.userEntity}`);
};
const confirmPerson = (person) => {
return axios.put(
`${USER_SERVICE}/${axios.defaults.userEntity}`,
{
...person,
},
{
headers: { "content-type": "application/json" },
}
);
};
const fetchInvoices = () => {
return axios.get(
`${INVOICES_SERVICE}/Invoices?$expand=invoiceItems($expand=track($expand=album($expand=artist)))`
);
};
const cancelInvoice = (ID) => {
return axios.post(
`${INVOICES_SERVICE}/cancelInvoice`,
{
ID,
},
{
headers: { "content-type": "application/json" },
}
);
};
const fetchAlbumsByName = (substr = "", top) => {
return axios.get(
`${BROWSE_TRACKS_SERVICE}/Albums?$filter=${`contains(title,'${substr}')&$top=${top}`}`
);
};
const addTrack = (data) => {
return axios.post(`${MANAGE_STORE}/Tracks`, data, {
headers: { "content-type": "application/json" },
});
};
const addArtist = (data) => {
return axios.post(`${MANAGE_STORE}/Artists`, data, {
headers: { "content-type": "application/json" },
});
};
const addAlbum = (data) => {
return axios.post(`${MANAGE_STORE}/Albums`, data, {
headers: { "content-type": "application/json" },
});
};
const fetchArtistsByName = (substr = "", top) => {
return axios.get(
`${MANAGE_STORE}/Artists?$filter=${`contains(name,'${substr}')&$top=${top}`}`
);
};
const login = (data) => {
return axios.post(`${USER_SERVICE}/login`, data, {
headers: { "content-type": "application/json" },
});
};
const updateTrack = (track) => {
return axios.put(
`${MANAGE_STORE}/Tracks/${track.ID}`,
{
...track,
},
{
headers: { "content-type": "application/json" },
}
);
};
const getTrack = (ID) => {
return axios.get(
`${BROWSE_TRACKS_SERVICE}/${axios.defaults.tracksEntity}/${ID}?$expand=genre,album($expand=artist)`
);
};
const deleteTrack = (ID) => {
return axios.delete(`${MANAGE_STORE}/Tracks(${ID})`);
};
export {
fetchTacks,
countTracks,
fetchGenres,
invoice,
fetchPerson,
confirmPerson,
fetchInvoices,
cancelInvoice,
fetchAlbumsByName,
addTrack,
addArtist,
addAlbum,
fetchArtistsByName,
login,
updateTrack,
getTrack,
deleteTrack,
};

View File

@@ -0,0 +1,18 @@
@import "~antd/dist/antd.css";
html {
overflow: hidden;
}
#root {
height: 100%;
}
section.ant-layout {
height: 100vh;
overflow: auto;
}
/* Layout
*/
.site-layout .site-layout-background {
background: #fff;
}

View File

@@ -0,0 +1,12 @@
import React from "react";
import ReactDOM from "react-dom";
import "./index.css";
import App from "./App";
import * as serviceWorker from "./serviceWorker";
ReactDOM.render(<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

@@ -0,0 +1,42 @@
import React from "react";
import { useHistory } from "react-router-dom";
import { isEmpty } from "lodash";
import { Result, Button } from "antd";
import { useGlobals } from "../GlobalContext";
const ErrorPage = () => {
const { error, setError } = useGlobals();
const history = useHistory();
const onGoHome = () => {
setError({});
history.push("/");
};
const errorResultProps = isEmpty(error)
? {
status: 404,
title: "Not found",
subTitle: "Sorry, the page you visited does not exist.",
}
: {
status: [404, 403, 500].includes(error.status)
? error.status
: undefined,
title: `${error.status} ${error.statusText}`,
subTitle: error.message,
};
return (
<Result
{...errorResultProps}
extra={
<Button onClick={onGoHome} type="primary">
Back Home
</Button>
}
/>
);
};
export { ErrorPage };

View File

@@ -0,0 +1,4 @@
.ant-table-cell,
.ant-table-footer {
background: white !important;
}

View File

@@ -0,0 +1,99 @@
import React from "react";
import { Table, Button, message } from "antd";
import { useGlobals } from "../../GlobalContext";
import { useHistory } from "react-router-dom";
import { invoice } from "../../api-service";
import { useErrors } from "../../useErrors";
import "./InvoicePage.css";
const columns = [
{
title: "Name",
dataIndex: "name",
},
{
title: "Artist",
dataIndex: "artist",
},
{
title: "Album",
dataIndex: "albumTitle",
},
{
title: "Price",
dataIndex: "unitPrice",
},
];
const MESSAGE_TIMEOUT = 2;
const InvoicePage = () => {
const history = useHistory();
const { handleError } = useErrors();
const { invoicedItems, setInvoicedItems, setLoading } = useGlobals();
const data = invoicedItems.map(({ ID: key, ...otherProps }) => ({
key,
...otherProps,
}));
const onBuy = () => {
setLoading(true);
invoice(
invoicedItems.map(({ ID, unitPrice }) => ({
ID,
unitPrice,
}))
)
.then(() => {
setLoading(false);
setInvoicedItems([]);
message.success("Invoice successfully completed", MESSAGE_TIMEOUT);
history.push("/person");
})
.catch(handleError);
};
const onCancel = () => {
setInvoicedItems([]);
history.push("/");
};
return (
<div style={{ borderRadius: 6, 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,
}}
>
<Button
type="primary"
size="large"
style={{ borderRadius: 6 }}
onClick={onBuy}
>
Buy
</Button>
<Button
size="large"
style={{ borderRadius: 6, marginLeft: 5 }}
onClick={onCancel}
danger
>
Cancel
</Button>
</div>
)}
/>
</div>
);
};
export { InvoicePage };

View File

@@ -0,0 +1,103 @@
import React from "react";
import { Form, Input, Button, Checkbox } from "antd";
import { login } from "../../api-service";
import { useHistory } from "react-router-dom";
import { useGlobals } from "../../GlobalContext";
import { useErrors } from "../../useErrors";
const USER_SERVICE = "http://localhost:4004/users";
const layout = {
labelCol: {
span: 8,
},
wrapperCol: {
span: 8,
},
};
const tailLayout = {
wrapperCol: {
offset: 8,
span: 8,
},
};
const Login = () => {
const history = useHistory();
const { setLoading, setUser } = useGlobals();
const { handleError } = useErrors();
const onFinish = (values) => {
console.log("Validation Success:", values);
setLoading(true);
login({ email: values.email, password: values.password })
.then((response) => {
console.log(response.data);
const { ID, email, level, token, roles } = response.data;
setUser({
ID,
roles,
email,
level,
token,
});
setLoading(false);
history.push("/");
})
.catch(handleError);
};
const onFinishFailed = (errorInfo) => {
console.log("Validation Failed:", errorInfo);
};
return (
<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 style={{ borderRadius: 6 }} />
</Form.Item>
<Form.Item
label="Password"
name="password"
rules={[
{
required: true,
message: "Please input your password!",
},
]}
>
<Input.Password style={{ borderRadius: 6 }} />
</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 { Login };

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 "../../useErrors";
import { fetchArtistsByName } from "../../api-service";
const REQUIRED = [
{
required: true,
message: "This filed is required!",
},
];
const ARTISTS_LIMIT = 10;
const getArtists = function (value) {
return fetchArtistsByName(value, ARTISTS_LIMIT)
.then((response) => response.data.value)
.catch(this.handleError);
};
const AddAlbumForm = () => {
const { handleError } = useErrors();
// const {
// data: artists,
// loading: isArtistsLoading,
// onChange: onChangeArtistInput,
// cancel: onArtistCancel,
// } = useSearch(getArtists.bind({ handleError }));
// useEffect(() => {
// onChangeArtistInput();
// }, []);
return (
<>
<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,5 @@
.ant-select.ant-select-single.ant-select-show-arrow.ant-select-show-search
> div,
.ant-form-item-control-input-content > input {
border-radius: 6px !important;
}

View File

@@ -0,0 +1,122 @@
import React, { useState, useMemo, useEffect } from "react";
import { Form, Radio, Button, message } from "antd";
import { TrackForm } from "./TrackForm";
import { AddArtistForm } from "./AddArtistForm";
import { AddAlbumForm } from "./AddAlbumForm";
import { useErrors } from "../../useErrors";
import { useGlobals } from "../../GlobalContext";
import { addTrack, addArtist, addAlbum } from "../../api-service";
import "./ManageStore.css";
const FORM_TYPES = {
track: "track",
artist: "artist",
album: "album",
playlist: "",
};
const DEFAULT_MEDIA_TYPE_ID = 1;
const MESSAGE_TIMEOUT = 2;
const chooseForm = (type) => {
return (
(type === "track" && <TrackForm />) ||
(type === "artist" && <AddArtistForm />) ||
(type === "album" && <AddAlbumForm />)
);
};
const ManageStore = () => {
const [form] = Form.useForm();
const { handleError } = useErrors();
const { setLoading } = useGlobals();
const [formType, setFormType] = useState("track");
useEffect(() => {
form.resetFields();
}, [formType]);
const formElement = useMemo(() => {
return chooseForm(formType);
}, [formType]);
const onChangeForm = (event) => {
setFormType(event.target.value);
};
const sendCreateRequest = ({ type, ...data }) => {
setLoading(true);
let promise;
switch (type) {
case FORM_TYPES.track:
promise = addTrack({
name: data.name,
composer: data.composer,
album: { ID: data.albumID },
mediaType: { ID: DEFAULT_MEDIA_TYPE_ID },
genre: { ID: data.genreID },
});
break;
case FORM_TYPES.artist:
promise = addArtist(data);
break;
case FORM_TYPES.album:
promise = addAlbum({ title: data.name, artist: { ID: data.artistID } });
break;
default:
}
promise
.then(() => {
setLoading(false);
message.success("Entity successfully created", MESSAGE_TIMEOUT);
form.resetFields();
})
.catch(handleError);
};
return (
<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" style={{ borderRadius: "6px 0 0 6px" }}>
Track
</Radio.Button>
<Radio.Button value="album">Album</Radio.Button>
<Radio.Button value="artist" style={{ borderRadius: "0 6px 6px 0" }}>
Artist
</Radio.Button>
</Radio.Group>
</Form.Item>
{formElement}
<Form.Item
type="primary"
wrapperCol={{
span: 14,
offset: 4,
}}
style={{ borderRadius: 6 }}
>
<Button onClick={() => form.submit()}>Create</Button>
</Form.Item>
</Form>
);
};
export { ManageStore };

View File

@@ -0,0 +1,83 @@
import React, { useEffect, useState } from "react";
import { Form, Input, Select } from "antd";
import { head } from "lodash";
import { useSearch } from "@umijs/hooks";
import { useGlobals } from "../../GlobalContext";
import { fetchAlbumsByName, fetchGenres } from "../../api-service";
import { useErrors } from "../../useErrors";
const ALBUMS_LIMIT = 10;
const REQUIRED = [
{
required: true,
message: "This filed is required!",
},
];
const getAlbums = function (value) {
return fetchAlbumsByName(value, ALBUMS_LIMIT)
.then((response) => response.data.value)
.catch(this.handleError);
};
const TrackForm = ({ initialAlbumTitle }) => {
const { handleError } = useErrors();
const {
data: albums,
loading: isAlbumsLoading,
onChange: onChangeAlbumInput,
cancel: onAlbumCancel,
} = useSearch(getAlbums.bind({ handleError }));
const { setLoading } = useGlobals();
const [genres, setGenres] = useState([]);
useEffect(() => {
setLoading(true);
Promise.all([fetchGenres(), onChangeAlbumInput(initialAlbumTitle)])
.then((responses) => {
setGenres(head(responses).data.value);
setLoading(false);
})
.catch(handleError);
}, []);
return (
<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>
</div>
);
};
export { TrackForm };

View File

@@ -0,0 +1,188 @@
import React, { useState, useEffect, useMemo, useCallback } from "react";
import { Button, message, Divider, Tag, Collapse, Table, Spin } from "antd";
import moment from "moment";
import { useErrors } from "../../useErrors";
import { useGlobals } from "../../GlobalContext";
import { cancelInvoice, fetchInvoices } from "../../api-service";
const { Panel } = Collapse;
const MESSAGE_TIMEOUT = 2;
const INVOICE_STATUS = {
2: {
tagTitle: "Shipped",
color: "green",
},
1: {
tagTitle: "Submitted",
color: "processing",
canCancel: true,
},
["-1"]: {
tagTitle: "Cancelled",
color: "default",
},
};
const CANCELLED_STATUS = -1;
const DATE_TIME_FORMAT_PATTERN = "LLLL";
const INVOICE_ITEMS_COLUMNS = [
{
title: "Track name",
dataIndex: "name",
},
{
title: "Artist",
dataIndex: "artistName",
},
{
title: "Album",
dataIndex: "albumTitle",
},
{
title: "Price",
dataIndex: "unitPrice",
},
];
const LEVERAGE_DURATION = 1; // in hours
const STATUSES = { submitted: 1, shipped: 2, canceled: -1 };
const isLeverageTimeExpired = (invoiceDate) => {
const duration = moment.duration(
moment(moment().utc().format()).diff(invoiceDate)
);
return duration.asHours() > LEVERAGE_DURATION;
};
const chooseStatus = (invoiceDate, statusFromDb) => {
if (
isLeverageTimeExpired(invoiceDate) &&
statusFromDb !== STATUSES.canceled
) {
return INVOICE_STATUS[STATUSES.shipped];
}
return INVOICE_STATUS[statusFromDb];
};
const ExtraHeader = ({ ID, invoiceDate, status: initialStatus }) => {
const { loading, setLoading } = useGlobals();
const { handleError } = useErrors();
const [loadingHeaderId, setLoadingHeaderId] = useState();
const [status, setStatus] = useState(initialStatus);
const statusConfig = chooseStatus(invoiceDate, status);
const onCancelInvoice = (event, ID) => {
event.stopPropagation();
setLoading(true);
setLoadingHeaderId(ID);
cancelInvoice(ID)
.then(() => {
message.success("Invoice successfully cancelled", MESSAGE_TIMEOUT);
setLoading(false);
setLoadingHeaderId(undefined);
setStatus(CANCELLED_STATUS);
})
.catch(handleError);
};
return (
<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>
);
};
const MyInvoices = () => {
const { handleError } = useErrors();
const { setLoading } = useGlobals();
const [invoices, setInvoices] = useState([]);
useEffect(() => {
setLoading(true);
fetchInvoices()
.then((response) => {
const {
data: { value },
} = response;
setInvoices(value);
setLoading(false);
})
.catch(handleError);
}, []);
const genExtra = useCallback(
(ID, status, invoiceDate) => (
<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 && (
<>
<Divider orientation="left">My invoices</Divider>
<Collapse style={{ borderRadius: 6 }} expandIconPosition="left">
{invoiceElements}
</Collapse>
</>
)}
</div>
);
};
export { MyInvoices };

View File

@@ -0,0 +1,126 @@
import React, { useState, useEffect, useMemo } from "react";
import { Card, Button, message } from "antd";
import { omit } from "lodash";
import { fetchPerson, confirmPerson } from "../../api-service";
import { useErrors } from "../../useErrors";
import { useGlobals } from "../../GlobalContext";
import { Editable } from "../../Editable";
const MESSAGE_TIMEOUT = 2;
const PERSON_PROP = {
address: "Address ",
city: "City ",
country: "Country ",
fax: "Fax: ",
firstName: "First name: ",
lastName: "Last name: ",
phone: "Phone: ",
postalCode: "Postal code: ",
state: "State",
email: "email",
company: "Company: ",
};
const PersonPage = ({ myInvoicesSection }) => {
const { setLoading } = useGlobals();
const { handleError } = useErrors();
const [initialPerson, setInitialPerson] = useState({});
const [person, setPerson] = useState({
lastName: "",
firstName: "",
city: "",
state: "",
address: "",
country: "",
phone: "",
postalCode: "",
fax: "",
email: "",
company: "",
});
useEffect(() => {
setLoading(true);
fetchPerson()
.then((response) => {
let { data: personData } = response;
personData = omit(personData, "@odata.context", "ID");
console.log("personData", personData);
setInitialPerson(personData);
setPerson(personData);
setLoading(false);
})
.catch(handleError);
}, []);
const onConfirmChanges = () => {
setLoading(true);
confirmPerson(person)
.then(() => {
setLoading(false);
setInitialPerson(person);
message.success("Person successfully updated", MESSAGE_TIMEOUT);
})
.catch(handleError);
};
const isPersonChanged = useMemo(() => {
const keysOne = Object.keys(initialPerson);
const keysTwo = Object.keys(person);
if (keysOne.length !== keysTwo.length) {
return true;
}
for (let key of keysOne) {
if (initialPerson[key] !== person[key]) {
return true;
}
}
return false;
}, [person, initialPerson]);
const personProperties = Object.keys(person).reduce((acc, currentKey) => {
if (currentKey === "email") {
return acc;
}
return acc.concat([
<div key={currentKey}>
{PERSON_PROP[currentKey]}
<Editable
type="text"
value={person[currentKey]}
onChange={(value) =>
setPerson({ ...person, [`${currentKey}`]: value })
}
/>
</div>,
]);
}, []);
return (
<>
<Card
style={{ borderRadius: 6 }}
title={`${person.lastName} ${person.firstName}`}
>
{personProperties}
<div>
Email: <span style={{ fontWeight: 600 }}>{person.email}</span>
</div>
{isPersonChanged && (
<Button
type="primary"
style={{ margin: 10, borderRadius: 6 }}
onClick={onConfirmChanges}
>
Confirm changes
</Button>
)}
</Card>
{myInvoicesSection}
</>
);
};
export { PersonPage };

View File

@@ -0,0 +1,44 @@
import React, { useState } from "react";
import { Modal, message } from "antd";
import { DeleteOutlined } from "@ant-design/icons";
import { deleteTrack } from "../../api-service";
import { useErrors } from "../../useErrors";
const MESSAGE_TIMEOUT = 2;
const DeleteAction = ({ ID, onDeleteTrack }) => {
const [modalVisible, setModalVisible] = useState(false);
const { handleError } = useErrors();
const onOk = () => {
setModalVisible(false);
deleteTrack(ID)
.then(() => {
onDeleteTrack();
setModalVisible(false);
message.success("Track successfully deleted!", MESSAGE_TIMEOUT);
})
.catch(handleError);
};
const onCancel = () => setModalVisible(false);
const onOpenModal = () => {
setModalVisible(true);
};
return (
<>
<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>
</>
);
};
export { DeleteAction };

View File

@@ -0,0 +1,111 @@
import React from "react";
import { Button, Modal, Form, message } from "antd";
import { EditOutlined, LoadingOutlined } from "@ant-design/icons";
import { useErrors } from "../../useErrors";
import { TrackForm } from "../manage-store/TrackForm";
import { updateTrack, getTrack } from "../../api-service";
const MESSAGE_TIMEOUT = 2;
const EditAction = ({ ID, name, composer, genre, album, afterTrackUpdate }) => {
const [visible, setVisible] = React.useState(false);
const [confirmLoading, setConfirmLoading] = React.useState(false);
const [updateLoading, setUpdateLoading] = React.useState(false);
const [form] = Form.useForm();
const { handleError } = useErrors();
const onShowModal = () => {
setVisible(true);
};
const onFinish = (value) => {
setConfirmLoading(true);
updateTrack({
ID,
name: value.name,
composer: value.composer,
album: { ID: value.albumID },
genre: { ID: value.genreID },
})
.then(() => {
message.success("Track successfully updated!", MESSAGE_TIMEOUT);
setConfirmLoading(false);
setVisible(false);
afterCloseModal();
})
.catch(handleError);
};
const handleOk = () => {
form.submit();
};
const handleCancel = () => {
console.log("Clicked cancel button");
setVisible(false);
};
const afterCloseModal = () => {
setUpdateLoading(true);
getTrack(ID)
.then((response) => {
afterTrackUpdate(response.data);
setUpdateLoading(false);
})
.catch(handleError);
};
return (
<>
{updateLoading ? (
<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,
}}
>
<TrackForm initialAlbumTitle={album.title} />
</Form>
</Modal>
</>
);
};
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,119 @@
import React, { useState, useEffect, useRef } from "react";
import { Card, Button } from "antd";
import { PlusOutlined, MinusOutlined } from "@ant-design/icons";
import { useGlobals } from "../../GlobalContext";
import { withRestrictedSection } from "../../withRestrictions";
import { EditAction } from "./EditAction";
import { DeleteAction } from "./DeleteAction";
import "./Track.css";
const RestrictedButton = withRestrictedSection(
Button,
({ user }) => !!user && user.roles.includes("customer")
);
const RestrictedEditAction = withRestrictedSection(
EditAction,
({ user }) => !!user && user.roles.includes("employee")
);
const RestrictedDeleteAction = withRestrictedSection(
DeleteAction,
({ user }) => !!user && user.roles.includes("employee")
);
const Track = ({
initialTrack,
isButtonVisible,
isInvoiced: isInvoicedProp,
onDeleteTrack,
}) => {
const trackElement = useRef();
const { setInvoicedItems, invoicedItems } = useGlobals();
const [isInvoiced, setIsInvoiced] = useState(isInvoicedProp);
const [track, setTrack] = useState(initialTrack);
const onChangedStatus = () => {
const newInvoiced = !isInvoiced;
if (newInvoiced) {
setInvoicedItems([
...invoicedItems,
{
ID: track.ID,
name: track.name,
artist: track.album.artist.name,
albumTitle: track.album.title,
unitPrice: track.unitPrice,
},
]);
} else {
setInvoicedItems(
invoicedItems.filter(({ ID: curID }) => curID !== track.ID)
);
}
setIsInvoiced(newInvoiced);
};
return (
<div className="card-element" ref={trackElement}>
<Card
actions={[
<RestrictedDeleteAction
ID={track.ID}
onDeleteTrack={() => {
trackElement.current.style.opacity = 0;
setTimeout(() => onDeleteTrack(track.ID), 500);
}}
/>,
<RestrictedEditAction
ID={track.ID}
name={track.name}
composer={track.composer}
album={track.album}
genre={track.genre}
afterTrackUpdate={(value) => setTrack(value)}
/>,
]}
style={{ borderRadius: 6 }}
title={track.name}
bordered={false}
>
<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>
{isButtonVisible && (
<RestrictedButton
type="primary"
size="small"
shape="circle"
onClick={onChangedStatus}
danger={isInvoiced}
>
{isInvoiced ? <MinusOutlined /> : <PlusOutlined />}
</RestrictedButton>
)}
</div>
</Card>
</div>
);
};
export { Track };

View File

@@ -0,0 +1,15 @@
div.ant-select.ant-select-multiple.ant-select-show-search > div,
div.ant-select-dropdown.ant-select-dropdown-placement-bottomLeft {
border-radius: 6px;
}
.ant-select > div.ant-select-selector {
padding: 5px;
min-width: 300px;
}
.ant-pagination-prev > .ant-pagination-item-link,
.ant-pagination-next > .ant-pagination-item-link,
.ant-pagination-item {
border-radius: 6px;
}

View File

@@ -0,0 +1,223 @@
import React, { useEffect, useState } from "react";
import { debounce } from "lodash";
import { Input, Col, Row, Select, Pagination } from "antd";
import { Track } from "./Track";
import "./TracksPage.css";
import { useGlobals } from "../../GlobalContext";
import { useErrors } from "../../useErrors";
import { fetchTacks, countTracks, fetchGenres } from "../../api-service";
const { Search } = Input;
const { Option } = Select;
const DEBOUNCE_TIMER = 500;
const DEBOUNCE_OPTIONS = {
leading: true,
trailing: false,
};
const renderGenres = (genres) =>
genres.map(({ ID, name }) => (
<Option key={ID} value={ID.toString()}>
{name}
</Option>
));
const TracksContainer = () => {
const { setLoading, invoicedItems } = useGlobals();
const { handleError } = useErrors();
const [state, setState] = useState({
tracks: [],
genres: [],
pagination: {
currentPage: 1,
totalItems: 0,
pageSize: 20,
},
searchOptions: {
substr: "",
genreIds: [],
},
});
useEffect(() => {
setLoading(true);
const countTracksReq = countTracks();
const getTracksRequest = fetchTacks();
const getGenresReq = fetchGenres();
Promise.all([countTracksReq, getTracksRequest, getGenresReq])
.then((responses) => {
const [
{ data: totalItems },
{
data: { value: tracks },
},
{
data: { value: genres },
},
] = responses;
setState({
...state,
tracks,
genres,
pagination: { ...state.pagination, totalItems },
});
setLoading(false);
})
.catch(handleError);
}, []);
const onSearch = debounce(
() => {
setLoading(true);
const options = {
$top: state.pagination.pageSize,
substr: state.searchOptions.substr,
genreIds: state.searchOptions.genreIds,
};
Promise.all([
fetchTacks(options),
countTracks({
substr: options.substr,
genreIds: options.genreIds,
}),
])
.then((responses) => {
const [
{
data: { value: tracks },
},
{ data: totalItems },
] = responses;
setState({
...state,
tracks,
pagination: { ...state.pagination, totalItems },
});
setLoading(false);
})
.catch(handleError);
},
DEBOUNCE_TIMER,
DEBOUNCE_OPTIONS
);
const onSelectChange = (genres) => {
setState({
...state,
searchOptions: {
...state.searchOptions,
genreIds: genres.map((value) => parseInt(value, 10)),
},
});
};
const onSearchChange = (event) => {
setState({
...state,
searchOptions: { ...state.searchOptions, substr: event.target.value },
});
};
const onChangePage = (pageNumber) => {
document
.querySelector("section.ant-layout")
.scrollTo({ top: 0, left: 0, behavior: "smooth" });
setLoading(true);
const options = {
$top: state.pagination.pageSize,
substr: state.searchOptions.substr,
genreIds: state.searchOptions.genreIds,
$skip: (pageNumber - 1) * state.pagination.pageSize,
};
fetchTacks(options)
.then((response) => {
setState({
...state,
tracks: response.data.value,
pagination: { ...state.pagination, currentPage: pageNumber },
});
setLoading(false);
})
.catch(handleError);
};
const deleteTrack = (ID) => {
setState({
...state,
tracks: state.tracks.filter(({ ID: curID }) => curID !== ID),
});
};
const renderTracks = (tracks, invoicedItems) =>
tracks.map(
({ ID, name, composer, genre, unitPrice, alreadyOrdered, album }) => (
<Col key={ID} className="gutter-row" span={8}>
<Track
initialTrack={{
ID,
name,
genre,
album,
artist: album.artist.name,
composer,
unitPrice,
}}
isButtonVisible={!alreadyOrdered}
isInvoiced={invoicedItems.find(({ ID: curID }) => curID === ID)}
onDeleteTrack={(ID) => deleteTrack(ID)}
/>
</Col>
)
);
const trackElements = renderTracks(state.tracks, invoicedItems);
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 { TracksContainer };

View File

@@ -0,0 +1,13 @@
const reducersFactory = (initialState, handlers) => {
return (state = initialState, action) => {
const handler = handlers[action.type];
if (handler) {
return handler(state, action);
}
return state;
};
};
export { reducersFactory };

View File

@@ -0,0 +1,141 @@
// This optional code is used to register a service worker.
// register() is not called by default.
// This lets the app load faster on subsequent visits in production, and gives
// it offline capabilities. However, it also means that developers (and users)
// will only see deployed updates on subsequent visits to a page, after all the
// existing tabs open on the page have been closed, since previously cached
// resources are updated in the background.
// To learn more about the benefits of this model and instructions on how to
// opt-in, read https://bit.ly/CRA-PWA
const isLocalhost = Boolean(
window.location.hostname === 'localhost' ||
// [::1] is the IPv6 localhost address.
window.location.hostname === '[::1]' ||
// 127.0.0.0/8 are considered localhost for IPv4.
window.location.hostname.match(
/^127(?:\.(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}$/
)
);
export function register(config) {
if (process.env.NODE_ENV === 'production' && 'serviceWorker' in navigator) {
// The URL constructor is available in all browsers that support SW.
const publicUrl = new URL(process.env.PUBLIC_URL, window.location.href);
if (publicUrl.origin !== window.location.origin) {
// Our service worker won't work if PUBLIC_URL is on a different origin
// from what our page is served on. This might happen if a CDN is used to
// serve assets; see https://github.com/facebook/create-react-app/issues/2374
return;
}
window.addEventListener('load', () => {
const swUrl = `${process.env.PUBLIC_URL}/service-worker.js`;
if (isLocalhost) {
// This is running on localhost. Let's check if a service worker still exists or not.
checkValidServiceWorker(swUrl, config);
// Add some additional logging to localhost, pointing developers to the
// service worker/PWA documentation.
navigator.serviceWorker.ready.then(() => {
console.log(
'This web app is being served cache-first by a service ' +
'worker. To learn more, visit https://bit.ly/CRA-PWA'
);
});
} else {
// Is not localhost. Just register service worker
registerValidSW(swUrl, config);
}
});
}
}
function registerValidSW(swUrl, config) {
navigator.serviceWorker
.register(swUrl)
.then(registration => {
registration.onupdatefound = () => {
const installingWorker = registration.installing;
if (installingWorker == null) {
return;
}
installingWorker.onstatechange = () => {
if (installingWorker.state === 'installed') {
if (navigator.serviceWorker.controller) {
// At this point, the updated precached content has been fetched,
// but the previous service worker will still serve the older
// content until all client tabs are closed.
console.log(
'New content is available and will be used when all ' +
'tabs for this page are closed. See https://bit.ly/CRA-PWA.'
);
// Execute callback
if (config && config.onUpdate) {
config.onUpdate(registration);
}
} else {
// At this point, everything has been precached.
// It's the perfect time to display a
// "Content is cached for offline use." message.
console.log('Content is cached for offline use.');
// Execute callback
if (config && config.onSuccess) {
config.onSuccess(registration);
}
}
}
};
};
})
.catch(error => {
console.error('Error during service worker registration:', error);
});
}
function checkValidServiceWorker(swUrl, config) {
// Check if the service worker can be found. If it can't reload the page.
fetch(swUrl, {
headers: { 'Service-Worker': 'script' },
})
.then(response => {
// Ensure service worker exists, and that we really are getting a JS file.
const contentType = response.headers.get('content-type');
if (
response.status === 404 ||
(contentType != null && contentType.indexOf('javascript') === -1)
) {
// No service worker found. Probably a different app. Reload the page.
navigator.serviceWorker.ready.then(registration => {
registration.unregister().then(() => {
window.location.reload();
});
});
} else {
// Service worker found. Proceed as normal.
registerValidSW(swUrl, config);
}
})
.catch(() => {
console.log(
'No internet connection found. App is running in offline mode.'
);
});
}
export function unregister() {
if ('serviceWorker' in navigator) {
navigator.serviceWorker.ready
.then(registration => {
registration.unregister();
})
.catch(error => {
console.error(error.message);
});
}
}

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,47 @@
import { useHistory } from "react-router-dom";
import { useGlobals } from "./GlobalContext";
import { message } from "antd";
const MESSAGE_TIMEOUT = 2;
const useErrors = () => {
const history = useHistory();
const { setError, setUser, setLoading } = useGlobals();
const handleError = (error) => {
console.error("Error", error);
console.log("error", error);
if (error.response) {
if (error.response.status === 401 || error.response.status === 403) {
setUser(undefined);
setLoading(false);
// message.error("You are unauthorized, try login again", MESSAGE_TIMEOUT);
// history.push("/login");
// return;
}
setError({
status: error.response.status,
statusText: error.response.statusText,
message: error.response.data.error
? error.response.data.error.message
: error.response.data,
});
} else {
setError({
status: "",
statusText: "Network error",
message: "Please, check your connection",
});
}
history.push("/error");
};
return {
handleError,
};
};
export { useErrors };

View File

@@ -0,0 +1,27 @@
import React from "react";
import { Redirect } from "react-router-dom";
import { useGlobals } from "./GlobalContext";
const withRestrictions = (Component, isUserMeetRestrictions) => {
return (props) => {
const { user, invoicedItems } = useGlobals();
return isUserMeetRestrictions({ user, invoicedItems }) ? (
<Component {...props} />
) : (
<Redirect exact to="/error" />
);
};
};
const withRestrictedSection = (Component, isUserMeetRestrictions) => {
return (props) => {
const { user, invoicedItems } = useGlobals();
return (
isUserMeetRestrictions({ user, invoicedItems }) && (
<Component {...props} />
)
);
};
};
export { withRestrictions, withRestrictedSection };

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

125
media-store/mta.yaml Normal file
View File

@@ -0,0 +1,125 @@
## Generated mta.yaml based on template version 0.2.0
## appName = media-store-documentation-test
## language=nodejs; multiTenant=false
## approuter=
_schema-version: "3.1"
ID: media-store
version: 1.0.0
description: "A simple CAP project."
parameters:
enable-parallel-deployments: true
deploy_mode: html5-repo
build-parameters:
before-all:
- builder: custom
commands:
- npm install
- npx @sap/cds-dk build
modules:
# --------------------- SERVER MODULE ------------------------
- name: media-store-srv
# ------------------------------------------------------------
type: nodejs
path: gen/srv
properties:
EXIT: 1 # required by deploy.js task to terminate
requires:
# Resources extracted from CAP configuration
- name: media-store-hdi
provides:
- name: srv-binding # required by consumers of CAP services (e.g. approuter)
properties:
srv-url: ${default-url}
# -------------------- SIDECAR MODULE ------------------------
- name: media-store-db
# ------------------------------------------------------------
type: hdb
path: gen/db
parameters:
app-name: media-store-hdi
requires:
# 'hana' and 'xsuaa' resources extracted from CAP configuration
- name: media-store-hdi
# --------------------- HTML5DEPLOYER MODULE -----------------
- name: media-store-hmtl5-deployer
# ------------------------------------------------------------
type: com.sap.html5.application-content
path: html5Deployer
requires:
- name: media-store-html5-host
build-parameters:
requires:
- name: media-store-html5-app
artifacts:
- "./*"
target-path: resources/app
# --------------------- FRONTEND APP MODULE ---------------------
- name: media-store-html5-app
# ------------------------------------------------------------
type: html5
path: app
build-parameters:
supported-platforms: []
build-result: build
# --------------------- APPROUTER MODULE ---------------------
- name: media-store-approuter
# ------------------------------------------------------------
type: approuter.nodejs
path: approuter
requires:
- name: media-store-html5-runtime
- name: media-store-xsuaa
- name: srv-binding
group: destinations
properties:
name: srv-binding
url: ~{srv-url}
forwardAuthToken: true
resources:
# services extracted from CAP configuration
# 'service-plan' can be configured via 'cds.requires.<name>.vcap.plan'
# ------------------------------------------------------------
- name: media-store-hdi
# ------------------------------------------------------------
type: com.sap.xs.hdi-container
parameters:
service: hanatrial # or 'hanatrial' on trial landscapes
service-plan: hdi-shared
properties:
hdi-service-name: ${service-name}
# --------------------- HTML5 Runtime ----------------------
- name: media-store-html5-runtime
# ------------------------------------------------------------
parameters:
service-name: media-store-html5-runtime
service-plan: app-runtime
service: html5-apps-repo
type: org.cloudfoundry.managed-service
# --------------------- HTML5 Host -------------------------
- name: media-store-html5-host
# ------------------------------------------------------------
parameters:
service-name: media-store-html5-host
service-plan: app-host
service: html5-apps-repo
config:
sizeLimit: 2
type: org.cloudfoundry.managed-service
# --------------------- XSUAA Service ---------------------
- name: media-store-xsuaa
# ------------------------------------------------------------
parameters:
path: ./xs-security.json
service-plan: application
service: xsuaa
type: org.cloudfoundry.managed-service

File diff suppressed because it is too large Load Diff

View File

@@ -29,11 +29,7 @@
"ACCESS_TOKEN_SECRET": "secret",
"requires": {
"db": {
"kind": "sqlite",
"model": "*",
"credentials": {
"database": "mychinook.db"
}
"kind": "hana"
},
"auth": {
"impl": "srv/auth.js"

View File

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