mirror of
https://github.com/orangecoding/fredy.git
synced 2026-06-16 12:31:07 +00:00
UI (#15)
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:
committed by
GitHub
parent
8185bfe818
commit
b2847f6834
78
ui/src/views/jobs/Jobs.js
Normal file
78
ui/src/views/jobs/Jobs.js
Normal 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>
|
||||
);
|
||||
}
|
||||
7
ui/src/views/jobs/Jobs.less
Normal file
7
ui/src/views/jobs/Jobs.less
Normal file
@@ -0,0 +1,7 @@
|
||||
.jobs {
|
||||
&__newButton{
|
||||
margin-top:1rem !important;
|
||||
float: right;
|
||||
margin-bottom: 1rem !important;
|
||||
}
|
||||
}
|
||||
66
ui/src/views/jobs/insights/JobInsight.js
Normal file
66
ui/src/views/jobs/insights/JobInsight.js
Normal 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;
|
||||
340
ui/src/views/jobs/insights/Linechart.js
Normal file
340
ui/src/views/jobs/insights/Linechart.js
Normal 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;
|
||||
15
ui/src/views/jobs/insights/Linechart.less
Normal file
15
ui/src/views/jobs/insights/Linechart.less
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
240
ui/src/views/jobs/mutation/JobMutation.js
Normal file
240
ui/src/views/jobs/mutation/JobMutation.js
Normal 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>
|
||||
);
|
||||
}
|
||||
29
ui/src/views/jobs/mutation/JobMutation.less
Normal file
29
ui/src/views/jobs/mutation/JobMutation.less
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
.providerMutator {
|
||||
&__fields{
|
||||
width:25rem !important;
|
||||
}
|
||||
|
||||
&__helpBox {
|
||||
background-color: #ececec;
|
||||
border-radius: 5px;
|
||||
padding: 1rem !important;
|
||||
}
|
||||
|
||||
&__helpLink{
|
||||
color: #4183c4;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
.providerMutator {
|
||||
&__fields{
|
||||
width:25rem !important;
|
||||
}
|
||||
}
|
||||
78
ui/src/views/login/Login.js
Normal file
78
ui/src/views/login/Login.js
Normal 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';
|
||||
31
ui/src/views/login/login.less
Normal file
31
ui/src/views/login/login.less
Normal 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;
|
||||
}
|
||||
}
|
||||
23
ui/src/views/user/UserRemovalModal.js
Normal file
23
ui/src/views/user/UserRemovalModal.js
Normal 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;
|
||||
79
ui/src/views/user/Users.js
Normal file
79
ui/src/views/user/Users.js
Normal 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;
|
||||
7
ui/src/views/user/Users.less
Normal file
7
ui/src/views/user/Users.less
Normal file
@@ -0,0 +1,7 @@
|
||||
.users {
|
||||
&__newButton{
|
||||
margin-top:1rem !important;
|
||||
float: right;
|
||||
margin-bottom: 1rem !important;
|
||||
}
|
||||
}
|
||||
116
ui/src/views/user/mutation/UserMutator.js
Normal file
116
ui/src/views/user/mutation/UserMutator.js
Normal 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;
|
||||
3
ui/src/views/user/mutation/UserMutator.less
Normal file
3
ui/src/views/user/mutation/UserMutator.less
Normal file
@@ -0,0 +1,3 @@
|
||||
.userMutator {
|
||||
margin-top: 2rem ;
|
||||
}
|
||||
Reference in New Issue
Block a user