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

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