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

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