Adding new Admin UI. Updating Fredy to V3.0.0 as it has been a large rewrite. Thanks for all contributions and help on the way!
This commit is contained in:
Christian Kellner
2021-01-21 16:09:23 +01:00
committed by GitHub
parent 8185bfe818
commit b2847f6834
124 changed files with 9768 additions and 1495 deletions

89
ui/src/App.js Normal file
View File

@@ -0,0 +1,89 @@
import React, { useEffect } from 'react';
import InsufficientPermission from './components/permission/InsufficientPermission';
import PermissionAwareRoute from './components/permission/PermissionAwareRoute';
import ToastsContainer from './components/toasts/ToastContainer';
import JobMutation from './views/jobs/mutation/JobMutation';
import UserMutator from './views/user/mutation/UserMutator';
import ToastContext from './components/toasts/ToastContext';
import JobInsight from './views/jobs/insights/JobInsight';
import { useDispatch, useSelector } from 'react-redux';
import useToast from './components/toasts/useToast';
import { Switch, Redirect } from 'react-router-dom';
import Logout from './components/logout/Logout';
import Logo from './components/logo/Logo';
import Menu from './components/menu/Menu';
import Login from './views/login/Login';
import Users from './views/user/Users';
import Jobs from './views/jobs/Jobs';
import { Route } from 'react-router';
import './App.less';
export default function FredyApp() {
const dispatch = useDispatch();
const [showToast, onToastFinished, toasts] = useToast();
const [loading, setLoading] = React.useState(true);
const currentUser = useSelector((state) => state.user.currentUser);
useEffect(async () => {
await dispatch.provider.getProvider();
await dispatch.jobs.getJobs();
await dispatch.notificationAdapter.getAdapter();
await dispatch.user.getCurrentUser();
setLoading(false);
}, [currentUser?.userId]);
const needsLogin = () => {
return currentUser == null || Object.keys(currentUser).length === 0;
};
const isAdmin = () => currentUser != null && currentUser.isAdmin;
const login = () => (
<Switch>
<Route name="Login" path={'/login'} component={Login} />
<Redirect from="*" to={'/login'} />
</Switch>
);
return loading ? null : needsLogin() ? (
login()
) : (
<ToastContext.Provider value={{ showToast }}>
<div className="app">
<div className="app__container">
<Logout />
<Logo width={190} white />
<Menu isAdmin={isAdmin()} />
<ToastsContainer toasts={toasts} onToastFinished={onToastFinished} />
<Switch>
<Route name="Insufficient Permission" path={'/403'} component={InsufficientPermission} />
<Route name="Create new Job" path={'/jobs/new'} component={JobMutation} />
<Route name="Edit a Job" path={'/jobs/edit/:jobId'} component={JobMutation} />
<Route name="Insights into a Job" path={'/jobs/insights/:jobId'} component={JobInsight} />
<Route name="Job overview" path={'/jobs'} component={Jobs} />
<PermissionAwareRoute
name="Create new User"
path="/users/new"
component={<UserMutator />}
currentUser={currentUser}
/>
<PermissionAwareRoute
name="Edit a user"
path="/users/edit/:userId"
component={<UserMutator />}
currentUser={currentUser}
/>
<PermissionAwareRoute name="Users" path="/users" component={<Users />} currentUser={currentUser} />
<Redirect from="/" to={'/jobs'} />
</Switch>
</div>
</div>
</ToastContext.Provider>
);
}
FredyApp.displayName = 'FredyApp';

13
ui/src/App.less Normal file
View File

@@ -0,0 +1,13 @@
.app {
display:flex;
flex-direction: column;
width:100%;
&__container {
width: 100%;
padding: 1rem 1rem;
background-color: #3f3e3ef5;
color: #f1f1f1;
}
}

22
ui/src/Index.js Normal file
View File

@@ -0,0 +1,22 @@
import React from 'react';
import { reduxStore } from './services/rematch/store';
import { HashRouter } from 'react-router-dom';
import { createHashHistory } from 'history';
import { Provider } from 'react-redux';
import ReactDOM from 'react-dom';
const history = createHashHistory();
import App from './App';
import './Index.less';
ReactDOM.render(
<Provider store={reduxStore}>
<HashRouter history={history}>
<App />
</HashRouter>
</Provider>,
document.getElementById('fredy')
);

6
ui/src/Index.less Normal file
View File

@@ -0,0 +1,6 @@
body, html {
margin: 0;
height: 100%;
width: 100%;
background-color: #3f3e3ef5;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 621 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 284 KiB

BIN
ui/src/assets/logo.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

View File

@@ -0,0 +1,12 @@
import React from 'react';
import { Header } from 'semantic-ui-react';
import './Headline.less';
export default function Headline({ text, size = 'medium', className = '' } = {}) {
return (
<Header className={`headline ${className}`} size={size}>
{text}
</Header>
);
}

View File

@@ -0,0 +1,3 @@
.headline{
color: #f1f1f1 !important;
}

View File

@@ -0,0 +1,9 @@
import React from 'react';
import logo from '../../assets/logo.png';
import logoWhite from '../../assets/logo_white.png';
import './Logo.less';
export default function Logo({ width = 350, white = false } = {}) {
return <img src={white ? logoWhite : logo} width={width} className="logo" />;
}

View File

@@ -0,0 +1,5 @@
.logo {
position: absolute;
top: .1rem;
right: 2rem;
}

View File

@@ -0,0 +1,21 @@
import React from 'react';
import { Button } from 'semantic-ui-react';
import { xhrPost } from '../../services/xhr';
const Logout = function Logout() {
return (
<Button
content="Logout"
labelPosition="left"
icon="user"
size="mini"
onClick={async () => {
await xhrPost('/api/login/logout');
location.reload();
}}
negative
/>
);
};
export default Logout;

View File

@@ -0,0 +1,39 @@
import React from 'react';
import { useHistory } from 'react-router-dom';
import { Menu } from 'semantic-ui-react';
import './Menu.less';
import { useLocation } from 'react-router';
const TopMenu = function TopMenu({ isAdmin }) {
const history = useHistory();
const location = useLocation();
const isActiveRoute = (name) => location.pathname.indexOf(name) !== -1;
return (
<Menu pointing secondary className="topMenu">
<Menu.Item
name="jobs"
active={isActiveRoute('jobs')}
className={isActiveRoute('jobs') ? 'topMenu__active' : 'topMenu__item'}
onClick={() => history.push('/jobs')}
>
Job Configuration
</Menu.Item>
{isAdmin && (
<Menu.Item
name="user"
active={isActiveRoute('users')}
className={isActiveRoute('users') ? 'topMenu__active' : 'topMenu__item'}
onClick={() => history.push('/users')}
>
User configuration
</Menu.Item>
)}
</Menu>
);
};
export default TopMenu;

View File

@@ -0,0 +1,15 @@
.topMenu {
border-bottom: 1px solid #b7b7b7f2 !important;
&__active {
border-bottom: 1px solid #06dcfff2 !important;
font-weight: 550 !important;
color: #78e5ff !important;
margin: 0 0 -1px !important;
}
&__item {
color: #fffffff2 !important;
border-color: transparent !important;
}
}

View File

@@ -0,0 +1,15 @@
import React from 'react';
import { Header } from 'semantic-ui-react';
import insufficientPermission from '../../assets/insufficient_permission.png';
export default function InsufficientPermission() {
return (
<div style={{ display: 'flex', justifyContent: 'center', alignItems: 'center', flexDirection: 'column' }}>
<img src={insufficientPermission} height={250} />
<br />
<Header as="h4" inverted>
Insufficient permission :(
</Header>
</div>
);
}

View File

@@ -0,0 +1,21 @@
import React from 'react';
import { Redirect } from 'react-router-dom';
import { Route } from 'react-router';
export default function PermissionAwareRoute({ currentUser, name, path, component }) {
/**
* Checks if given component should be rendered if current user has given permission enabled. If that's not the case,
* user is redirected to '/403'.
*
* @param permission
* @param component
* @param path
* @returns {*}
*/
const checkIfAdmin = (component, path) => {
return currentUser != null && currentUser.isAdmin ? component : <Redirect from={path} to="/403" />;
};
return <Route name={name} path={path} render={() => checkIfAdmin(component, path)} />;
}

View File

@@ -0,0 +1,29 @@
import React from 'react';
import './Placeholder.less';
function getPlaceholder(rowCount, className) {
const rows = [];
for (let i = 0; i < rowCount; i++) {
rows.push(<div className="place__line" key={i} />);
}
const clazz = `place ${className == null ? '' : className}`;
return (
<div className={clazz}>
<div className="place__circle" />
<div className="place__place_lines_wrapper">{rows}</div>
</div>
);
}
export default function Placeholder({ rows = 3, ready = false, children, customPlaceholder, className }) {
if (!ready) {
if (customPlaceholder != null) {
return customPlaceholder;
}
return getPlaceholder(rows, className);
}
return children;
}

View File

@@ -0,0 +1,36 @@
.place {
height: 100%;
width: 100%;
display:flex;
&__place_lines_wrapper{
width:100%;
}
&__line {
height: 10px;
margin: 10px;
animation: pulse 1s infinite ease-in-out;
}
&__circle {
height: 4rem;
width: 5rem;
margin: 10px;
border-radius: 360px;
animation: pulse 1s infinite ease-in-out;
}
}
@keyframes pulse {
0% {
background-color: rgba(165, 165, 165, 0.1)
}
50% {
background-color: rgba(165, 165, 165, 0.3)
}
100% {
background-color: rgba(165, 165, 165, 0.1)
}
}

View File

@@ -0,0 +1,66 @@
import React, { Fragment } from 'react';
import { Table, Button } from 'semantic-ui-react';
import Switch from 'react-switch';
const emptyTable = () => {
return (
<Table.Row>
<Table.Cell collapsing colSpan={6} style={{ textAlign: 'center' }}>
No Data
</Table.Cell>
</Table.Row>
);
};
const content = (jobs, onJobRemoval, onJobStatusChanged, onJobEdit, onJobInsight) => {
return (
<Fragment>
{Object.keys(jobs).map((jobKey) => {
const job = jobs[jobKey];
return (
<Table.Row key={jobKey}>
<Table.Cell collapsing>
<Switch onChange={(checked) => onJobStatusChanged(job.id, checked)} checked={job.enabled} />
</Table.Cell>
<Table.Cell>{job.name}</Table.Cell>
<Table.Cell>{job.numberOfFoundListings || 0}</Table.Cell>
<Table.Cell>{job.provider.length || 0}</Table.Cell>
<Table.Cell>{job.notificationAdapter.length || 0}</Table.Cell>
<Table.Cell>
<div style={{ float: 'right' }}>
<Button circular color="teal" icon="chart line" onClick={() => onJobInsight(job.id)} />
<Button circular color="red" icon="trash" onClick={() => onJobRemoval(job.id)} />
<Button circular color="blue" icon="edit" onClick={() => onJobEdit(job.id)} />
</div>
</Table.Cell>
</Table.Row>
);
})}
</Fragment>
);
};
export default function JobTable({ jobs = {}, onJobRemoval, onJobStatusChanged, onJobEdit, onJobInsight } = {}) {
return (
<Table singleLine inverted>
<Table.Header>
<Table.Row>
<Table.HeaderCell />
<Table.HeaderCell>Job Name</Table.HeaderCell>
<Table.HeaderCell>Number of findings</Table.HeaderCell>
<Table.HeaderCell>Active provider</Table.HeaderCell>
<Table.HeaderCell>Active notification adapter</Table.HeaderCell>
<Table.HeaderCell></Table.HeaderCell>
</Table.Row>
</Table.Header>
<Table.Body>
{Object.keys(jobs).length === 0
? emptyTable()
: content(jobs, onJobRemoval, onJobStatusChanged, onJobEdit, onJobInsight)}
</Table.Body>
</Table>
);
}

View File

@@ -0,0 +1,49 @@
import React, { Fragment } from 'react';
import { Table, Button } from 'semantic-ui-react';
const emptyTable = () => {
return (
<Table.Row>
<Table.Cell collapsing colSpan="3" style={{ textAlign: 'center' }}>
No Data
</Table.Cell>
</Table.Row>
);
};
const content = (adapterData, onRemove) => {
return (
<Fragment>
{adapterData.map((data) => {
return (
<Table.Row key={data.id}>
<Table.Cell>{data.name}</Table.Cell>
<Table.Cell>
<div style={{ float: 'right' }}>
<Button circular color="red" icon="trash" onClick={() => onRemove(data.id)} />
</div>
</Table.Cell>
</Table.Row>
);
})}
</Fragment>
);
};
export default function NotificationAdapterTable({ notificationAdapter = [], onRemove } = {}) {
return (
<Table singleLine inverted>
<Table.Header>
<Table.Row>
<Table.HeaderCell>Notification Adapter Name</Table.HeaderCell>
<Table.HeaderCell></Table.HeaderCell>
</Table.Row>
</Table.Header>
<Table.Body>
{notificationAdapter.length === 0 ? emptyTable() : content(notificationAdapter, onRemove)}
</Table.Body>
</Table>
);
}

View File

@@ -0,0 +1,53 @@
import React, { Fragment } from 'react';
import { Table, Button } from 'semantic-ui-react';
const emptyTable = () => {
return (
<Table.Row>
<Table.Cell collapsing colSpan="3" style={{ textAlign: 'center' }}>
No Data
</Table.Cell>
</Table.Row>
);
};
const truncate = (str, n) => {
return str.length > n ? str.substr(0, n - 1) + '…' : str;
};
const content = (providerData, onRemove) => {
return (
<Fragment>
{providerData.map((data) => {
return (
<Table.Row key={data.id}>
<Table.Cell>{data.name}</Table.Cell>
<Table.Cell>{truncate(data.url, 60)}</Table.Cell>
<Table.Cell>
<div style={{ float: 'right' }}>
<Button circular color="red" icon="trash" onClick={() => onRemove(data.id)} />
</div>
</Table.Cell>
</Table.Row>
);
})}
</Fragment>
);
};
export default function ProviderTable({ providerData = [], onRemove } = {}) {
return (
<Table singleLine inverted>
<Table.Header>
<Table.Row>
<Table.HeaderCell>Provider Name</Table.HeaderCell>
<Table.HeaderCell>Url</Table.HeaderCell>
<Table.HeaderCell></Table.HeaderCell>
</Table.Row>
</Table.Header>
<Table.Body>{providerData.length === 0 ? emptyTable() : content(providerData, onRemove)}</Table.Body>
</Table>
);
}

View File

@@ -0,0 +1,49 @@
import React from 'react';
import { Table, Button } from 'semantic-ui-react';
import { format } from '../../services/time/timeService';
const emptyTable = () => {
return (
<Table.Row>
<Table.Cell collapsing colSpan={4} style={{ textAlign: 'center' }}>
No Data
</Table.Cell>
</Table.Row>
);
};
const content = (user, onUserRemoval, onUserEdit) => {
return user.map((user) => {
return (
<Table.Row key={user.id}>
<Table.Cell>{user.username}</Table.Cell>
<Table.Cell>{user.lastLogin == null ? '---' : format(user.lastLogin)}</Table.Cell>
<Table.Cell>{user.numberOfJobs}</Table.Cell>
<Table.Cell>
<div style={{ float: 'right' }}>
<Button circular color="red" icon="trash" onClick={() => onUserRemoval(user.id)} />
<Button circular color="blue" icon="edit" onClick={() => onUserEdit(user.id)} />
</div>
</Table.Cell>
</Table.Row>
);
});
};
export default function UserTable({ user = [], onUserRemoval, onUserEdit } = {}) {
return (
<Table inverted>
<Table.Header>
<Table.Row>
<Table.HeaderCell>Username</Table.HeaderCell>
<Table.HeaderCell>Last login</Table.HeaderCell>
<Table.HeaderCell>Number of jobs</Table.HeaderCell>
<Table.HeaderCell></Table.HeaderCell>
</Table.Row>
</Table.Header>
<Table.Body>{user.length === 0 ? emptyTable() : content(user, onUserRemoval, onUserEdit)}</Table.Body>
</Table>
);
}

View File

@@ -0,0 +1,27 @@
import React from 'react';
import './Toasts.css';
export default function Toast({ id, delay = 5500, message, onHide, backgroundColor, color, title }) {
const [className, setClassname] = React.useState('toast-container show-toast');
React.useEffect(() => {
let hideTimeout = null;
const timeout = setTimeout(() => {
setClassname('toast-container hide-toast');
hideTimeout = setTimeout(() => {
onHide && onHide(id);
}, 500);
}, delay);
return () => {
clearTimeout(timeout);
clearTimeout(hideTimeout);
};
}, [id, delay, onHide]);
return (
<div className={className} style={{ backgroundColor, color }}>
<h5>{title}</h5>
{message}
</div>
);
}

View File

@@ -0,0 +1,12 @@
import Toast from './Toast';
import React from 'react';
export default function ToastsContainer({ toasts, onToastFinished }) {
return (
<div className="toasts-container">
{toasts.map((toast, index) => (
<Toast key={index} {...toast} onHide={onToastFinished} />
))}
</div>
);
}

View File

@@ -0,0 +1,7 @@
import { createContext } from 'react';
const CheckoutDrawerContext = createContext({
showToast: () => {},
});
export default CheckoutDrawerContext;

View File

@@ -0,0 +1,63 @@
.toasts-container {
position: fixed;
z-index: 65535;
right: 0;
max-width: 250px;
display: flex;
flex-direction: column-reverse;
}
.toasts-container > .toast-container {
margin-bottom: 10px;
}
.toasts-container:last-child {
margin-bottom: 0;
}
.toast-container {
visibility: hidden;
position: relative;
z-index: 65535;
right: -1000px;
background-color: skyblue;
border-radius: 10px;
padding: 10px;
min-width: 10rem;
min-height: 3rem;
}
.toast-container.show-toast {
visibility: visible;
right: 24px;
animation: slidein 0.5s;
}
.toast-container.hide-toast {
visibility: visible;
animation: slideout 0.5s;
}
@keyframes slidein {
from {
right: -1000px;
opacity: 0;
}
to {
right: 24px;
opacity: 1;
}
}
@keyframes slideout {
from {
right: 24px;
opacity: 1;
}
to {
right: -1000px;
opacity: 0;
}
}

View File

@@ -0,0 +1,23 @@
import React from 'react';
export default function useToast() {
const [toasts, setToasts] = React.useState([]);
const showToast = ({ message, delay, color, backgroundColor, title }) => {
const toast = {
id: toasts.length,
message,
delay,
backgroundColor,
color,
title,
};
setToasts([...toasts, toast].reverse());
};
const onToastFinished = (id) => {
setToasts(toasts.filter((toast) => toast.id !== id));
};
return [showToast, onToastFinished, toasts];
}

17
ui/src/index.html Normal file
View File

@@ -0,0 +1,17 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8"
name="viewport"
content="user-scalable=no, width=device-width, initial-scale=1, maximum-scale=1">
<link rel="stylesheet" href="//cdnjs.cloudflare.com/ajax/libs/semantic-ui/2.4.1/semantic.min.css">
<meta name="google" content="notranslate">
<title>Fredy</title>
</head>
<body>
<div id="fredy" style="position: absolute;top: 0;left: 0;right: 0;bottom: 0;"></div>
</body>
<script src="fredy.bundle.js"></script>
</html>

View File

@@ -0,0 +1,43 @@
import { xhrGet } from '../../xhr';
export const jobs = {
state: {
jobs: [],
insights: {},
},
reducers: {
setJobs: (state, payload) => {
return {
...state,
jobs: Object.freeze(payload),
};
},
setJobInsights: (state, payload, jobId) => {
return {
...state,
insights: {
...state.insights,
[jobId]: Object.freeze(payload),
},
};
},
},
effects: {
async getJobs() {
try {
const response = await xhrGet('/api/jobs');
this.setJobs(response.json);
} catch (Exception) {
console.error(`Error while trying to get resource for api/jobs. Error:`, Exception);
}
},
async getInsightDataForJob(jobId) {
try {
const response = await xhrGet(`/api/jobs/insights/${jobId}`);
this.setJobInsights(response.json, jobId);
} catch (Exception) {
console.error(`Error while trying to get resource for api/jobs/insights. Error:`, Exception);
}
},
},
};

View File

@@ -0,0 +1,19 @@
import { xhrGet } from '../../xhr';
export const notificationAdapter = {
state: [],
reducers: {
setAdapter: (state, payload) => {
return [...Object.freeze(payload)];
},
},
effects: {
async getAdapter() {
try {
const response = await xhrGet('/api/jobs/notificationAdapter');
this.setAdapter(response.json);
} catch (Exception) {
console.error(`Error while trying to get resource for api/jobs/notificationAdapter. Error:`, Exception);
}
},
},
};

View File

@@ -0,0 +1,19 @@
import { xhrGet } from '../../xhr';
export const provider = {
state: [],
reducers: {
setProvider: (state, payload) => {
return [...Object.freeze(payload)];
},
},
effects: {
async getProvider() {
try {
const response = await xhrGet('/api/jobs/provider');
this.setProvider(response.json);
} catch (Exception) {
console.error(`Error while trying to get resource for api/jobs/provider. Error:`, Exception);
}
},
},
};

View File

@@ -0,0 +1,41 @@
import { xhrGet } from '../../xhr';
export const user = {
state: {
users: [],
currentUser: null,
},
reducers: {
//only admins
setUsers: (state, payload) => {
return {
...state,
users: payload,
};
},
setCurrentUser: (state, payload) => {
return {
...state,
currentUser: Object.freeze(payload),
};
},
},
effects: {
async getUsers() {
try {
const response = await xhrGet('/api/admin/users');
this.setUsers(response.json);
} catch (Exception) {
console.error('Error while trying to get resource for api/admin/users. Error:', Exception);
}
},
async getCurrentUser() {
try {
const response = await xhrGet('/api/login/user');
this.setCurrentUser(response.json);
} catch (Exception) {
console.error('Error while trying to get resource for api/login/user. Error:', Exception);
}
},
},
};

View File

@@ -0,0 +1,30 @@
import { notificationAdapter } from './models/notificationAdapter';
import createLoadingPlugin from '@rematch/loading';
import { provider } from './models/provider';
import { createLogger } from 'redux-logger';
import { jobs } from './models/jobs';
import { user } from './models/user';
import { init } from '@rematch/core';
const middleware = [];
if (process.env.NODE_ENV === 'development') {
// eslint-disable-line no-redeclare
middleware.push(createLogger({ duration: false, collapsed: (getState, action, logEntry) => !logEntry.error }));
}
const store = init({
name: 'fredy',
models: {
notificationAdapter,
provider,
jobs,
user,
},
plugins: [createLoadingPlugin({})],
redux: {
middlewares: middleware,
},
});
export const reduxStore = store;

View File

@@ -0,0 +1,12 @@
export function format(ts) {
return new Intl.DateTimeFormat('default', {
year: 'numeric',
month: 'numeric',
day: 'numeric',
hour: 'numeric',
minute: 'numeric',
second: 'numeric',
}).format(ts);
}
export const roundToNext5Minute = (ts) => Math.ceil(ts / (1000 * 60 * 5)) * (1000 * 60 * 5);

View File

@@ -0,0 +1,13 @@
export function transform({ id, name, fields }) {
const fieldValues = {};
Object.keys(fields).map((key) => {
fieldValues[key] = fields[key].value;
});
return {
id,
name,
fields: fieldValues,
};
}

View File

@@ -0,0 +1,8 @@
export function transform({ name, id, enabled, url }) {
return {
name,
id,
enabled,
url,
};
}

140
ui/src/services/xhr.js Normal file
View File

@@ -0,0 +1,140 @@
/**
* post something to our backend.
*
* @param url
* @param data based on the content type, you need to make sure to parse in the proper data
* @param contentType default is json
* @returns {Promise}
*/
export function xhrPost(url, data, contentType = 'application/json; charset=utf-8', isJson = true) {
return executePostOrPutCall(url, contentType, data, isJson, true);
}
/**
* put request to backend.
*
* @param url
* @param data based on the content type, you need to make sure to parse in the proper data
* @param contentType default is json
* @returns {Promise}
*/
export function xhrPut(url, data, contentType = 'application/json; charset=utf-8', isJson = true) {
return executePostOrPutCall(url, contentType, data, isJson, false);
}
function executePostOrPutCall(url, contentType, data, isJson, isPost) {
return new Promise((resolve, reject) => {
fetch(url, {
method: isPost ? 'POST' : 'PUT',
cache: 'no-cache',
credentials: 'include',
mode: 'cors',
headers: {
'Content-Type': contentType,
},
body: data == null ? JSON.stringify({}) : JSON.stringify(data),
})
.then((response) => (isJson ? parseJSON(response) : response))
.then((response) => resolve(response))
.catch((error) => {
reject(error);
});
});
}
/**
* get request to backend
* returns a Promise with
* {
* status: statusCode,
* json: values
* }
*
* if an error occurs, the promise rejects with
* {
* json: errors: ['error', 'error']
* }
* @param url
* @param contentType
* @param isJson
* @returns {Promise}
*/
export function xhrGet(url, contentType = 'application/json; charset=utf-8', isJson = true) {
return new Promise((resolve, reject) => {
fetch(url, {
credentials: 'include',
mode: 'cors',
headers: {
'Content-Type': contentType,
},
})
.then((response) => (isJson ? parseJSON(response) : response))
.then((response) => resolve(response))
.catch((error) => {
reject(error);
});
});
}
/**
* delete request to backend
* returns a Promise with
* {
* status: statusCode,
* json: values
* }
*
* if an error occurs, the promise rejects with
* {
* json: errors: ['error', 'error']
* }
* @param url
* @param data
* @returns {Promise}
*/
export function xhrDelete(url, data, contentType = 'application/json; charset=utf-8') {
return new Promise((resolve, reject) => {
fetch(url, {
method: 'DELETE',
credentials: 'include',
mode: 'cors',
body: data == null ? JSON.stringify({}) : JSON.stringify(data),
headers: {
'Content-Type': contentType,
},
})
.then((response) => parseJSON(response))
.then((response) => resolve(response))
.catch((error) => {
if (error.json != null && error.json.message != null) {
reject(error.json.message);
} else {
reject({ errors: [`Unspecified Network error`] });
}
});
});
}
function parseJSON(response) {
return new Promise((resolve, reject) =>
response
.text()
.then((text) => {
//some responses doesn't contain a body. .json() would throw errors here...
const json = text != null && text.length > 0 ? JSON.parse(text) : {};
if (response.ok) {
resolve({
status: response.status,
json,
});
} else {
reject({
status: response.status,
json,
});
}
})
.catch((error) => reject('Error while trying to parse json.', error))
);
}

78
ui/src/views/jobs/Jobs.js Normal file
View File

@@ -0,0 +1,78 @@
import React from 'react';
import ToastContext from '../../components/toasts/ToastContext';
import JobTable from '../../components/table/JobTable';
import { useSelector, useDispatch } from 'react-redux';
import { xhrDelete, xhrPut } from '../../services/xhr';
import { Button, Icon } from 'semantic-ui-react';
import { useHistory } from 'react-router-dom';
import './Jobs.less';
export default function Jobs() {
const jobs = useSelector((state) => state.jobs.jobs);
const history = useHistory();
const dispatch = useDispatch();
const ctx = React.useContext(ToastContext);
const onJobRemoval = async (jobId) => {
try {
await xhrDelete('/api/jobs', { jobId });
ctx.showToast({
title: 'Success',
message: 'Job successfully remove',
delay: 5000,
backgroundColor: '#87eb8f',
color: '#000',
});
await dispatch.jobs.getJobs();
} catch (error) {
ctx.showToast({
title: 'Error',
message: error,
delay: 35000,
backgroundColor: '#db2828',
color: '#fff',
});
}
};
const onJobStatusChanged = async (jobId, status) => {
try {
await xhrPut(`/api/jobs/${jobId}/status`, { status });
ctx.showToast({
title: 'Success',
message: 'Job status successfully changed',
delay: 5000,
backgroundColor: '#87eb8f',
color: '#000',
});
await dispatch.jobs.getJobs();
} catch (error) {
ctx.showToast({
title: 'Error',
message: error,
delay: 35000,
backgroundColor: '#db2828',
color: '#fff',
});
}
};
return (
<div>
<Button primary className="jobs__newButton" onClick={() => history.push('/jobs/new')}>
<Icon name="plus" />
New Job
</Button>
<JobTable
jobs={jobs || []}
onJobRemoval={onJobRemoval}
onJobStatusChanged={onJobStatusChanged}
onJobInsight={(jobId) => history.push(`/jobs/insights/${jobId}`)}
onJobEdit={(jobId) => history.push(`/jobs/edit/${jobId}`)}
/>
</div>
);
}

View File

@@ -0,0 +1,7 @@
.jobs {
&__newButton{
margin-top:1rem !important;
float: right;
margin-bottom: 1rem !important;
}
}

View File

@@ -0,0 +1,66 @@
import React from 'react';
import { roundToNext5Minute } from '../../../services/time/timeService';
import Headline from '../../../components/headline/Headline';
import { useDispatch, useSelector } from 'react-redux';
import { useParams } from 'react-router';
import Linechart from './Linechart';
const JobInsight = function JobInsight() {
const dispatch = useDispatch();
const insights = useSelector((state) => state.jobs.insights);
const jobs = useSelector((state) => state.jobs.jobs);
const params = useParams();
React.useEffect(() => {
dispatch.jobs.getInsightDataForJob(params.jobId);
dispatch.jobs.getJobs();
}, []);
const getData = () => {
const data = insights[params.jobId] || {};
const result = [];
Object.keys(data).forEach((key) => {
const series = {
name: key[0].toUpperCase() + key.substring(1),
data: [],
};
const tmpTimeObj = {};
Object.values(data[key] || {}).forEach((listingTs) => {
const time = roundToNext5Minute(listingTs);
tmpTimeObj[time] = tmpTimeObj[time] == null ? 1 : tmpTimeObj[time] + 1;
});
Object.keys(tmpTimeObj)
.sort()
.forEach((timeKey) => {
series.data.push([parseInt(timeKey), tmpTimeObj[timeKey]]);
});
result.push(series);
});
return result;
};
const getJobName = () => {
const job = jobs.find((job) => job.id === params.jobId);
if (job == null) {
return 'unknown';
} else {
return job.name;
}
};
return (
<div>
<Headline text={`Insights into Job: ${getJobName()}`} />
<Linechart isLoading={false} series={getData()} />
</div>
);
};
export default JobInsight;

View File

@@ -0,0 +1,340 @@
import React from 'react';
import Placeholder from '../../../components/placeholder/Placeholder';
import HighchartsReact from 'highcharts-react-official';
import Highcharts from 'highcharts/highcharts.src.js';
import './Linechart.less';
Highcharts.theme = {
colors: [
'#2b908f',
'#90ee7e',
'#f45b5b',
'#7798BF',
'#aaeeee',
'#ff0066',
'#eeaaee',
'#55BF3B',
'#DF5353',
'#7798BF',
'#aaeeee',
],
chart: {
backgroundColor: {
linearGradient: {
x1: 0,
y1: 0,
x2: 1,
y2: 1,
},
stops: [
[0, '#2a2a2b'],
[1, '#3e3e40'],
],
},
style: {
fontFamily: "'Unica One', sans-serif",
},
plotBorderColor: '#606063',
},
title: {
style: {
color: '#E0E0E3',
textTransform: 'uppercase',
fontSize: '20px',
},
},
subtitle: {
style: {
color: '#E0E0E3',
textTransform: 'uppercase',
},
},
xAxis: {
gridLineColor: '#707073',
labels: {
style: {
color: '#E0E0E3',
},
},
lineColor: '#707073',
minorGridLineColor: '#505053',
tickColor: '#707073',
title: {
style: {
color: '#A0A0A3',
},
},
},
yAxis: {
gridLineColor: '#707073',
labels: {
style: {
color: '#E0E0E3',
},
},
lineColor: '#707073',
minorGridLineColor: '#505053',
tickColor: '#707073',
tickWidth: 1,
title: {
style: {
color: '#A0A0A3',
},
},
},
tooltip: {
backgroundColor: 'rgba(0, 0, 0, 0.85)',
style: {
color: '#F0F0F0',
},
},
plotOptions: {
series: {
dataLabels: {
color: '#F0F0F3',
style: {
fontSize: '13px',
},
},
marker: {
lineColor: '#333',
},
},
boxplot: {
fillColor: '#505053',
},
candlestick: {
lineColor: 'white',
},
errorbar: {
color: 'white',
},
},
legend: {
backgroundColor: 'rgba(0, 0, 0, 0.5)',
itemStyle: {
color: '#E0E0E3',
},
itemHoverStyle: {
color: '#FFF',
},
itemHiddenStyle: {
color: '#606063',
},
title: {
style: {
color: '#C0C0C0',
},
},
},
credits: {
style: {
color: '#666',
},
},
labels: {
style: {
color: '#707073',
},
},
drilldown: {
activeAxisLabelStyle: {
color: '#F0F0F3',
},
activeDataLabelStyle: {
color: '#F0F0F3',
},
},
navigation: {
buttonOptions: {
symbolStroke: '#DDDDDD',
theme: {
fill: '#505053',
},
},
},
// scroll charts
rangeSelector: {
buttonTheme: {
fill: '#505053',
stroke: '#000000',
style: {
color: '#CCC',
},
states: {
hover: {
fill: '#707073',
stroke: '#000000',
style: {
color: 'white',
},
},
select: {
fill: '#000003',
stroke: '#000000',
style: {
color: 'white',
},
},
},
},
inputBoxBorderColor: '#505053',
inputStyle: {
backgroundColor: '#333',
color: 'silver',
},
labelStyle: {
color: 'silver',
},
},
navigator: {
handles: {
backgroundColor: '#666',
borderColor: '#AAA',
},
outlineColor: '#CCC',
maskFill: 'rgba(255,255,255,0.1)',
series: {
color: '#7798BF',
lineColor: '#A6C7ED',
},
xAxis: {
gridLineColor: '#505053',
},
},
scrollbar: {
barBackgroundColor: '#808083',
barBorderColor: '#808083',
buttonArrowColor: '#CCC',
buttonBackgroundColor: '#606063',
buttonBorderColor: '#606063',
rifleColor: '#FFF',
trackBackgroundColor: '#404043',
trackBorderColor: '#404043',
},
};
// Apply the theme
Highcharts.setOptions(Highcharts.theme);
const defaultOptions = {
title: {
text: null,
},
legend: {
enabled: true,
},
xAxis: {
//most of the time (if not everytime), the x axis is time
type: 'datetime',
crosshair: {
snap: false,
},
},
yAxis: {
title: {
text: null,
},
//do not show float numbers
allowDecimals: false,
},
chart: {
type: 'line',
zoomType: 'x',
plotBackgroundColor: null,
plotBorderWidth: null,
},
exporting: {
enabled: false,
},
tooltip: {
shared: true,
formatter: null,
},
plotOptions: {
line: {
animation: false,
marker: {
enabled: false,
},
},
series: {
lineWidth: 1.5,
connectNulls: true,
marker: {
enabled: false,
},
},
},
series: [],
};
/**
* Usage of this chart:
* title: optional (show a title, if null, no title is shown)
* zoom: optional (if true, zooming in x axis is possible)
* legend: optional (show / hide the legend)
* series: mandatory (an array of data to be shown)
*
* <Linechart
* title="something"
* legend={true}
* timeframe={week/month/all} --> If this is not set, we assume the timeframe is 'all'
* //everything that is "subscribed" to this topic will receive this update
* highlightTopic="someTopic"
* height={"500px"}
* zoom={true}
* series={[
* {
* name: 'something',
* data: [x,y],
* dashStyle: (OPTIONAL) | solid / 'shortdot'
* }
* ]}
* />
*/
const Linechart = function Linechart({ title, series, height, isLoading = false }) {
const options = () => {
return {
...defaultOptions,
title: {
text: title,
},
time: {
useUTC: false,
},
legend: {
enabled: true,
},
series: series.map((series) => {
return {
...series,
};
}),
chart: {
type: 'line',
zoomType: 'x',
height: height || '400px',
},
};
};
return (
<Placeholder ready={!isLoading} rows={6}>
{series == null || series.length === 0 ? (
<div className="linechart__no__data">No Data for selected timeframe :-/</div>
) : (
<HighchartsReact highcharts={Highcharts} options={options()} />
)}
</Placeholder>
);
};
export default Linechart;

View File

@@ -0,0 +1,15 @@
.linechart {
&__no__data {
width: 100%;
text-align: center;
display: flex;
align-items: center;
justify-content: center;
color: #06dcfff2;
flex-direction: column;
&__height {
height: 30.7rem;
}
}
}

View File

@@ -0,0 +1,240 @@
import React, { Fragment, useState } from 'react';
import NotificationAdapterMutator from './components/notificationAdapter/NotificationAdapterMutator';
import NotificationAdapterTable from '../../../components/table/NotificationAdapterTable';
import { Header, Icon, Form, Popup, Button, Label } from 'semantic-ui-react';
import ProviderTable from '../../../components/table/ProviderTable';
import ProviderMutator from './components/provider/ProviderMutator';
import ToastContext from '../../../components/toasts/ToastContext';
import Headline from '../../../components/headline/Headline';
import { useDispatch, useSelector } from 'react-redux';
import { xhrPost } from '../../../services/xhr';
import { useHistory } from 'react-router-dom';
import { useParams } from 'react-router';
import './JobMutation.less';
import Switch from 'react-switch';
export default function JobMutator() {
const jobs = useSelector((state) => state.jobs.jobs);
const params = useParams();
const jobToBeEdit = params.jobId == null ? null : jobs.find((job) => job.id === params.jobId);
const defaultBlacklist = jobToBeEdit?.blacklist || [];
const defaultName = jobToBeEdit?.name || null;
const defaultProviderData = jobToBeEdit?.provider || [];
const defaultNotificationAdapter = jobToBeEdit?.notificationAdapter || [];
const defaultEnabled = jobToBeEdit?.enabled ?? true;
const [providerCreationVisible, setProviderCreationVisibility] = useState(false);
const [notificationCreationVisible, setNotificationCreationVisibility] = useState(false);
const [providerData, setProviderData] = useState(defaultProviderData);
const [name, setName] = useState(defaultName);
const [blacklist, setBlacklist] = useState(defaultBlacklist);
const [notificationAdapterData, setNotificationAdapterData] = useState(defaultNotificationAdapter);
const [enabled, setEnabled] = useState(defaultEnabled);
const history = useHistory();
const dispatch = useDispatch();
const ctx = React.useContext(ToastContext);
const header = (name, icon) => (
<Header as="h5" inverted>
<Icon name={icon} inverted />
{name}
</Header>
);
const help = (helpText) => (
<div>
<Popup
content={helpText}
trigger={
<Header as="h6" inverted>
<Icon name="help circle" inverted />
What is this?
</Header>
}
/>
</div>
);
const isSavingEnabled = () => {
return notificationAdapterData.length > 0 && providerData.length > 0 && name != null && name.length > 0;
};
const mutateJob = async () => {
try {
await xhrPost('/api/jobs', {
provider: providerData,
notificationAdapter: notificationAdapterData,
name,
blacklist,
enabled,
jobId: jobToBeEdit?.id || null,
});
await dispatch.jobs.getJobs();
ctx.showToast({
title: 'Success',
message: 'Job successfully saved...',
delay: 5000,
backgroundColor: '#87eb8f',
color: '#000',
});
history.push('/jobs');
} catch (Exception) {
console.error(Exception);
ctx.showToast({
title: 'Error',
message: Exception,
delay: 35000,
backgroundColor: '#db2828',
color: '#fff',
});
}
};
return (
<Fragment>
<ProviderMutator
visible={providerCreationVisible}
onVisibilityChanged={(visible) => setProviderCreationVisibility(visible)}
selected={providerData}
onData={(data) => {
setProviderData([...providerData, data]);
}}
/>
<NotificationAdapterMutator
visible={notificationCreationVisible}
onVisibilityChanged={(visible) => setNotificationCreationVisibility(visible)}
selected={providerData}
onData={(data) => {
setNotificationAdapterData([...notificationAdapterData, data]);
}}
/>
<Headline text={jobToBeEdit ? 'Edit a Job' : 'Create a new Job'} />
<Form className="jobMutation__form">
<div className="jobMutation__block">
<Form.Input
type="text"
maxLength={40}
placeholder="Name"
autoFocus
inverted
width={6}
defaultValue={name}
onChange={(e) => setName(e.target.value)}
/>
</div>
<div className="jobMutation__block jobMutation__separator">
{header('Provider', 'briefcase')}
<div className="jobMutation__helpContainer">
{help(
'A provider is essentially the service (Immowelt etc.) that Fredy is using to search for new listings. When adding a new provider, Fredy will open a new tab pointing ' +
'to the website of this provider. You have to adjust your search parameter and click on "Search". If the results are being shown, copy the browser url. This is the url, Fredy will use ' +
'to search for new listings.'
)}
<Form.Button primary className="jobMutation__newButton" onClick={() => setProviderCreationVisibility(true)}>
<Icon name="plus" />
Add new Provider
</Form.Button>
</div>
<ProviderTable
providerData={providerData}
onRemove={(providerId) => {
setProviderData(providerData.filter((provider) => provider.id !== providerId));
}}
/>
</div>
<div className="jobMutation__block jobMutation__separator">
{header('Notification Adapter', 'bell')}
<div className="jobMutation__helpContainer">
{help(
'Fredy supports multiple ways to notify you about new findings. These are called notification adapter. You can chose between email, Telegram etc.'
)}
<Form.Button
primary
className="jobMutation__newButton"
onClick={() => setNotificationCreationVisibility(true)}
>
<Icon name="plus" />
Add new Notification Adapter
</Form.Button>
</div>
<NotificationAdapterTable
notificationAdapter={notificationAdapterData}
onRemove={(adapterId) => {
setNotificationAdapterData(notificationAdapterData.filter((adapter) => adapter.id !== adapterId));
}}
/>
</div>
<div className="jobMutation__block jobMutation__separator">
{header('Blacklist', 'bell')}
<div className="jobMutation__helpContainer">
{help(
'If a listing contains one of these words, it will be filtered out. Words must be comma separated. To remove a word from the black list, just click the red label(s).'
)}
</div>
<Form.Input
type="text"
className="jobMutation__spaceTop"
maxLength={40}
placeholder="Comma separated list of blacklisted words"
autoFocus
inverted
width={6}
onChange={(e) => {
if (e.target.value.indexOf(',') !== -1) {
setBlacklist([...blacklist, e.target.value.replace(',', '')]);
e.target.value = '';
}
}}
/>
{blacklist.map((blacklistWord) => (
<Label
as="a"
key={`blacklist_${blacklistWord}`}
onClick={(e, obj) => {
setBlacklist(blacklist.filter((word) => word !== obj.content));
}}
content={blacklistWord}
icon="thumbs down"
color="red"
/>
))}
</div>
<div className="jobMutation__block jobMutation__separator">
{header('Job activation', 'play circle outline')}
<div className="jobMutation__helpContainer">
{help(
'Whether or not the job is activated. If it is not activated, it will be ignored when Fredy checks for new listings.'
)}
</div>
<Switch className="jobMutation__spaceTop" onChange={(checked) => setEnabled(checked)} checked={enabled} />
</div>
<Button color="red" onClick={() => history.push('/jobs')}>
Cancel
</Button>
<Button color="green" disabled={!isSavingEnabled()} onClick={mutateJob}>
Save
</Button>
</Form>
</Fragment>
);
}

View File

@@ -0,0 +1,29 @@
.jobMutation {
&__form {
margin-top:2rem;
}
&__block {
margin-bottom: 2rem;
}
&__newButton{
float: right;
}
&__helpContainer {
display: flex;
justify-content: space-between;
align-items: flex-end;
}
&__spaceTop{
margin-top:1rem !important;
}
&__separator{
background-color: #3a3a3a;
border-radius: 10px;
padding: .8rem;
}
}

View File

@@ -0,0 +1,232 @@
import React, { useState } from 'react';
import { transform } from '../../../../../services/transformer/notificationAdapterTransformer';
import { Modal, Form, Button, Dropdown, Input, Message } from 'semantic-ui-react';
import { xhrPost } from '../../../../../services/xhr';
import Help from './NotificationHelpDisplay';
import { useSelector } from 'react-redux';
import Switch from 'react-switch';
import './NotificationAdapterMutator.less';
const sortAdapter = (a, b) => {
if (a.name < b.name) {
return -1;
}
if (a.name > b.name) {
return 1;
}
return 0;
};
const validate = (selectedAdapter) => {
const results = [];
for (let uiElement of Object.values(selectedAdapter.fields || [])) {
if (uiElement.value == null) {
results.push('All fields are mandatory and must be set.');
continue;
}
if (uiElement.type === 'number' && (typeof uiElement.value !== 'number' || uiElement.value < 0)) {
results.push('A number field cannot contain anything else and must be > 0.');
continue;
}
if (uiElement.type === 'boolean' && typeof uiElement.value !== 'boolean') {
results.push('A boolean field cannot be of a different type.');
continue;
}
if (typeof uiElement.value === 'string' && uiElement.value.length === 0) {
results.push('All fields are mandatory and must be set.');
}
}
return [...new Set(results)];
};
export default function NotificationAdapterMutator({
onVisibilityChanged,
visible = false,
selected = [],
onData,
} = {}) {
const adapter = useSelector((state) => state.notificationAdapter);
const [selectedAdapter, setSelectedAdapter] = useState(null);
const [validationMessage, setValidationMessage] = useState(null);
const [successMessage, setSuccessMessage] = useState(null);
const onSubmit = (doStore) => {
if (doStore) {
const validationResults = validate(selectedAdapter);
if (validationResults.length > 0) {
setValidationMessage(validationResults.join('<br/>'));
return;
}
onData(
transform({
id: selectedAdapter.id,
name: selectedAdapter.name,
fields: selectedAdapter.fields || {},
})
);
setSelectedAdapter(null);
onVisibilityChanged(false);
} else {
setSelectedAdapter(null);
onVisibilityChanged(false);
}
};
const onTry = () => {
setValidationMessage(null);
setSuccessMessage(null);
const validationResults = validate(selectedAdapter);
if (validationResults.length > 0) {
setValidationMessage(validationResults.join('<br/>'));
return;
}
xhrPost('/api/jobs/notificationAdapter/try', {
id: selectedAdapter.id,
fields: {
...selectedAdapter.fields,
},
})
.then(() => {
setSuccessMessage('It seems like it worked! Please check your service.');
})
.catch((error) =>
setValidationMessage(`This did not work :-( I've received the following error: ${error.json.message}`)
);
};
const setValue = (selectedAdapter, uiElement, key, value) => {
uiElement.value = value;
setSelectedAdapter({
...selectedAdapter,
config: {
...selectedAdapter.fields,
[key]: uiElement,
},
});
};
const getFieldsFor = (selectedAdapter_) => {
const selectedAdapter = Object.assign({}, selectedAdapter_);
return Object.keys(selectedAdapter.fields || []).map((key) => {
const uiElement = selectedAdapter.fields[key];
return (
<Form.Field key={uiElement.description}>
<label>{uiElement.label}:</label>
{uiElement.type === 'boolean' ? (
<Switch
checked={uiElement.value || false}
onChange={(checked) => {
setValue(selectedAdapter, uiElement, key, checked);
}}
/>
) : (
<Input
type={uiElement.type}
value={uiElement.value || ''}
placeholder={uiElement.label}
onChange={(e) => {
setValue(selectedAdapter, uiElement, key, e.target.value);
}}
/>
)}
</Form.Field>
);
});
};
return (
<Modal
onClose={() => onVisibilityChanged(false)}
onOpen={() => onVisibilityChanged(true)}
open={visible}
style={{ width: '95%' }}
>
<Modal.Header>Adding a new Notification Adapter</Modal.Header>
<Modal.Content image>
<Modal.Description>
{validationMessage != null && (
<Message negative>
<Message.Header>Houston we have a problem...</Message.Header>
<p dangerouslySetInnerHTML={{ __html: validationMessage }} />
</Message>
)}
{successMessage != null && (
<Message positive>
<Message.Header>Yay!</Message.Header>
<p dangerouslySetInnerHTML={{ __html: successMessage }} />
</Message>
)}
<p>
When Fredy found new listings, we like to report them to you. To do so, notification adapter can be
configured. <br />
There are multiple ways how Fredy can send new listings to you. Chose your weapon...
</p>
<Dropdown
placeholder="Select a notification adapteer"
className="providerMutator__fields"
selection
value={selectedAdapter == null ? '' : selectedAdapter.id}
options={adapter
.map((a) => {
return {
key: a.id,
value: a.id,
text: a.name,
};
})
//filter out those, that have already been selected
.filter((option) => selected.find((selectedOption) => selectedOption.id === option.key) == null)
.sort(sortAdapter)}
onChange={(e, { value }) => {
setSuccessMessage(null);
setValidationMessage(null);
const selectedAdapter = adapter.find((a) => a.id === value);
setSelectedAdapter(Object.assign({}, selectedAdapter));
}}
/>
<br />
<br />
{selectedAdapter != null && (
<Form>
<i>{selectedAdapter.description}</i>
<br />
{selectedAdapter.readme != null && (
<React.Fragment>
<Help readme={selectedAdapter.readme} />
</React.Fragment>
)}
<br />
{getFieldsFor(selectedAdapter)}
</Form>
)}
</Modal.Description>
</Modal.Content>
<Modal.Actions>
<Button
content="Try"
labelPosition="left"
floated="left"
icon="hand spock"
onClick={() => onTry()}
color="teal"
/>
<Button color="black" onClick={() => onSubmit(false)}>
Cancel
</Button>
<Button content="Save" labelPosition="right" icon="checkmark" onClick={() => onSubmit(true)} positive />
</Modal.Actions>
</Modal>
);
}

View File

@@ -0,0 +1,16 @@
.providerMutator {
&__fields{
width:25rem !important;
}
&__helpBox {
background-color: #ececec;
border-radius: 5px;
padding: 1rem !important;
}
&__helpLink{
color: #4183c4;
cursor: pointer;
}
}

View File

@@ -0,0 +1,19 @@
import React from 'react';
import { Accordion, Icon } from 'semantic-ui-react';
export default function Help({ readme }) {
const [active, setActive] = React.useState(false);
return (
<Accordion>
<Accordion.Title active={active} index={0} onClick={() => setActive(!active)}>
<React.Fragment>
<Icon name="dropdown" /> <span className="providerMutator__helpLink"> More information</span>
</React.Fragment>
</Accordion.Title>
<Accordion.Content active={active} className="providerMutator__helpBox">
<p dangerouslySetInnerHTML={{ __html: readme }} />
</Accordion.Content>
</Accordion>
);
}

View File

@@ -0,0 +1,136 @@
import React, { useState } from 'react';
import { transform } from '../../../../../services/transformer/providerTransformer';
import { Modal, Icon, Button, Dropdown, Input, Message } from 'semantic-ui-react';
import { useSelector } from 'react-redux';
import './ProviderMutator.less';
const sortProvider = (a, b) => {
if (a.key < b.key) {
return -1;
}
if (a.key > b.key) {
return 1;
}
return 0;
};
export default function ProviderMutator({ onVisibilityChanged, visible = false, selected = [], onData } = {}) {
const provider = useSelector((state) => state.provider);
const [selectedProvider, setSelectedProvider] = useState(null);
const [providerUrl, setProviderUrl] = useState(null);
const [validationMessage, setValidationMessage] = useState(null);
const validate = () => {
if (selectedProvider == null || selectedProvider.length === 0 || providerUrl == null || providerUrl.length === 0) {
return 'Please select a provider and copy the browser url into the textfield after configuring your search parameter.';
}
try {
const url = new URL(providerUrl);
if (selectedProvider.baseUrl.indexOf(url.origin) === -1) {
return 'The url you have copied is not valid.';
}
} catch (Exception) {
return 'The url you have copied is not valid.';
}
return null;
};
const onSubmit = (doStore) => {
if (doStore) {
const validationResult = validate();
if (validationResult == null) {
onData(
transform({
url: providerUrl,
id: selectedProvider.id,
name: selectedProvider.name,
})
);
setProviderUrl(null);
setSelectedProvider(null);
onVisibilityChanged(false);
} else {
setValidationMessage(validationResult);
}
} else {
setProviderUrl(null);
setSelectedProvider(null);
onVisibilityChanged(false);
}
};
return (
<Modal onClose={() => onVisibilityChanged(false)} onOpen={() => onVisibilityChanged(true)} open={visible}>
<Modal.Header>Adding a new Provider</Modal.Header>
<Modal.Content image>
<Modal.Description>
{validationMessage != null && (
<Message negative>
<Message.Header>Houston we have a problem...</Message.Header>
<p>{validationMessage}</p>
</Message>
)}
<p>
Provider are the <Icon name="heart" color="red" /> of Fredy. We're supporting multiple Provider such as
Immowelt, Kalaydo etc. Select a provider from the list below.
<br />
Fredy will then open the provider's url in a new tab.
</p>
<p>
You will need to configure your search parameter like you would do when you do a regular search on the
provider's website.
<br />
When the search results are shown on the website, copy the url and paste it into the textfield below.
<br />
<span style={{ color: '#ff0000' }}>
Do not forget to sort the results by date before copying the url to Fredy, so that Fredy always captures
the latest search results.
</span>
</p>
<Dropdown
placeholder="Select a provider"
className="providerMutator__fields"
selection
value={selectedProvider == null ? '' : selectedProvider.id}
options={provider
.map((pro) => {
return {
key: pro.id,
value: pro.id,
text: pro.name,
};
})
//filter out those, that have already been selected
.filter((option) => selected.find((selectedOption) => selectedOption.id === option.key) == null)
.sort(sortProvider)}
onChange={(e, { value }) => {
const selectedProvider = provider.find((pro) => pro.id === value);
setSelectedProvider(selectedProvider);
window.open(selectedProvider.baseUrl);
}}
/>
<br />
<br />
<Input
type="text"
placeholder="Provider Url"
width={10}
className="providerMutator__fields"
onBlur={(e) => {
setProviderUrl(e.target.value);
}}
/>
</Modal.Description>
</Modal.Content>
<Modal.Actions>
<Button color="black" onClick={() => onSubmit(false)}>
Cancel
</Button>
<Button content="Save" labelPosition="right" icon="checkmark" onClick={() => onSubmit(true)} positive />
</Modal.Actions>
</Modal>
);
}

View File

@@ -0,0 +1,5 @@
.providerMutator {
&__fields{
width:25rem !important;
}
}

View File

@@ -0,0 +1,78 @@
import React from 'react';
import { Input } from 'semantic-ui-react';
import cityBackground from '../../assets/city_background.jpg';
import Logo from '../../components/logo/Logo';
import { xhrPost } from '../../services/xhr';
import { Message } from 'semantic-ui-react';
import { useHistory } from 'react-router';
import { useDispatch } from 'react-redux';
import './login.less';
export default function Login() {
const dispatch = useDispatch();
const [username, setUserName] = React.useState('');
const [password, setPassword] = React.useState('');
const [error, setError] = React.useState(null);
const history = useHistory();
const tryLogin = async () => {
if (username.length === 0 || password.length === 0) {
setError('Username and password are mandatory.');
return;
}
try {
await xhrPost('/api/login', {
username,
password,
});
setError(null);
} catch (Exception) {
setError('Login not successful...');
return;
}
await dispatch.user.getCurrentUser();
history.push('/jobs');
};
return (
<div className="login">
<Logo />
<div className="login__bgImage" style={{ background: `url(${cityBackground})` }} />
<form>
<div className="login__loginWrapper">
{error && <Message negative icon="error" content={error} />}
<Input
icon="user"
iconPosition="left"
placeholder="Username"
defaultValue={username}
style={{ marginTop: error ? '1rem' : '4rem' }}
autoFocus
onChange={(e) => setUserName(e.target.value)}
/>
<Input
type="password"
icon="lock"
iconPosition="left"
defaultValue={password}
placeholder="Password"
style={{ marginTop: '2rem' }}
onChange={(e) => setPassword(e.target.value)}
/>
<button className="ui primary button" style={{ marginTop: '3rem' }} onClick={tryLogin}>
Login
</button>
</div>
</form>
</div>
);
}
Login.displayName = 'Login';

View File

@@ -0,0 +1,31 @@
.login {
display: flex;
justify-content: center;
align-items: center;
width:100%;
height: 100%;
&__bgImage {
background-size: cover;
filter: blur(8px);
-webkit-filter: blur(8px);
background-size: cover;
position: absolute;
top: 0;
left: 0;
z-index: -1;
right: 0;
bottom: 0;
}
&__loginWrapper {
border: 1px solid #555050;
border-radius: 30px;
height: 25rem;
width: 30rem;
background-color: #151313ab;
display: flex;
flex-direction: column;
padding: 2rem;
}
}

View File

@@ -0,0 +1,23 @@
import React from 'react';
import { Modal, Header, Icon, Button } from 'semantic-ui-react';
const UserRemovalModal = function UserRemovalModal({ onOk, onCancel }) {
return (
<Modal open={true}>
<Header icon="warning sign" content="Warning" />
<Modal.Content>
<p>Removing this user will also remove all associated jobs.</p>
</Modal.Content>
<Modal.Actions>
<Button color="red" onClick={() => onCancel()}>
<Icon name="remove" /> Cancel
</Button>
<Button color="green" onClick={() => onOk()}>
<Icon name="checkmark" /> Remove
</Button>
</Modal.Actions>
</Modal>
);
};
export default UserRemovalModal;

View File

@@ -0,0 +1,79 @@
import React from 'react';
import ToastContext from '../../components/toasts/ToastContext';
import UserTable from '../../components/table/UserTable';
import { useDispatch, useSelector } from 'react-redux';
import { Button, Icon } from 'semantic-ui-react';
import UserRemovalModal from './UserRemovalModal';
import { xhrDelete } from '../../services/xhr';
import { useHistory } from 'react-router';
import './Users.less';
const Users = function Users() {
const dispatch = useDispatch();
const [loading, setLoading] = React.useState(true);
const users = useSelector((state) => state.user.users);
const ctx = React.useContext(ToastContext);
const [userIdToBeRemoved, setUserIdToBeRemoved] = React.useState(null);
const history = useHistory();
React.useEffect(async () => {
await dispatch.user.getUsers();
setLoading(false);
}, []);
const onUserRemoval = async () => {
try {
await xhrDelete('/api/admin/users', { userId: userIdToBeRemoved });
ctx.showToast({
title: 'Success',
message: 'User successfully remove',
delay: 4000,
backgroundColor: '#87eb8f',
color: '#000',
});
setUserIdToBeRemoved(null);
await dispatch.jobs.getJobs();
await dispatch.user.getUsers();
} catch (error) {
ctx.showToast({
title: 'Error',
message: error,
delay: 8000,
backgroundColor: '#db2828',
color: '#fff',
});
setUserIdToBeRemoved(null);
}
};
return (
<div>
{!loading && (
<React.Fragment>
{userIdToBeRemoved && <UserRemovalModal onCancel={() => setUserIdToBeRemoved(null)} onOk={onUserRemoval} />}
<Button primary className="users__newButton" onClick={() => history.push('/users/new')}>
<Icon name="plus" />
Create new User
</Button>
<UserTable
user={users}
onUserEdit={(userId) => {
history.push(`/users/edit/${userId}`);
}}
onUserRemoval={(userId) => {
setUserIdToBeRemoved(userId);
//throw warning message that all jobs will be removed associated to this user
//check if at least 1 admin is available
}}
/>
</React.Fragment>
)}
</div>
);
};
export default Users;

View File

@@ -0,0 +1,7 @@
.users {
&__newButton{
margin-top:1rem !important;
float: right;
margin-bottom: 1rem !important;
}
}

View File

@@ -0,0 +1,116 @@
import React from 'react';
import ToastContext from '../../../components/toasts/ToastContext';
import { xhrGet, xhrPost } from '../../../services/xhr';
import { useHistory, useParams } from 'react-router';
import { Button, Form } from 'semantic-ui-react';
import { useDispatch } from 'react-redux';
import Switch from 'react-switch';
import './UserMutator.less';
const UserMutator = function UserMutator() {
const params = useParams();
const [username, setUsername] = React.useState('');
const [password, setPassword] = React.useState('');
const [password2, setPassword2] = React.useState('');
const [isAdmin, setIsAdmin] = React.useState(false);
const history = useHistory();
const ctx = React.useContext(ToastContext);
const dispatch = useDispatch();
React.useEffect(async () => {
if (params.userId != null) {
try {
const userJson = await xhrGet(`/api/admin/users/${params.userId}`);
const user = userJson.json;
const defaultName = user?.username || '';
const defaultIsAdmin = user?.isAdmin || false;
setUsername(defaultName);
setIsAdmin(defaultIsAdmin);
} catch (Exception) {
console.error(Exception);
}
}
}, [params.userId]);
const saveUser = async () => {
try {
await xhrPost('/api/admin/users', {
userId: params.userId || null,
username,
password,
password2,
isAdmin,
});
await dispatch.user.getUsers();
ctx.showToast({
title: 'Success',
message: 'User successfully saved...',
delay: 5000,
backgroundColor: '#87eb8f',
color: '#000',
});
history.push('/users');
} catch (Exception) {
console.error(Exception);
ctx.showToast({
title: 'Error',
message: Exception.json.message,
delay: 6000,
backgroundColor: '#db2828',
color: '#fff',
});
}
};
return (
<Form inverted className="userMutator">
<Form.Input
type="text"
label="Username"
maxLength={30}
placeholder="Username"
autoFocus
inverted
width={6}
defaultValue={username}
onChange={(e) => setUsername(e.target.value)}
/>
<Form.Input
type="password"
label="Password"
placeholder="Password"
inverted
width={6}
defaultValue={password}
onChange={(e) => setPassword(e.target.value)}
/>
<Form.Input
type="password"
label="Retype password"
placeholder="Retype password"
inverted
width={6}
defaultValue={password2}
onChange={(e) => setPassword2(e.target.value)}
/>
<Form.Field>
<label>Is user an admin?</label>
<Switch checked={isAdmin} onChange={(checked) => setIsAdmin(checked)} />
</Form.Field>
<Button color="red" onClick={() => history.push('/users')}>
Cancel
</Button>
<Button color="green" onClick={saveUser}>
Save
</Button>
</Form>
);
};
export default UserMutator;

View File

@@ -0,0 +1,3 @@
.userMutator {
margin-top: 2rem ;
}