Merge pull request #452 from ivandiazwm/master

Custom fields feature
This commit is contained in:
Ivan Diaz 2019-02-03 18:37:35 -02:00 committed by GitHub
commit a4c44fb9ab
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
45 changed files with 1025 additions and 48 deletions

View File

@ -88,6 +88,7 @@ class TicketEvent extends React.Component {
<span className="ticket-event__comment-badge-container">
<span className="ticket-event__comment-badge">{i18n((this.props.author.staff) ? 'STAFF' : 'CUSTOMER')}</span>
</span>
{this.props.author.customfields.map(this.renderCustomFieldValue.bind(this))}
{(this.props.private*1) ? this.renderPrivateBadge() : null}
</div>
<div className="ticket-event__comment-date">{DateTransformer.transformToString(this.props.date)}</div>
@ -198,7 +199,17 @@ class TicketEvent extends React.Component {
<div className="ticket-event__file">
{node}
</div>
)
);
}
renderCustomFieldValue(customField) {
return (
<span className="ticket-event__comment-badge-container">
<span className="ticket-event__comment-badge">
{customField.customfield}: <span className="ticket-event__comment-badge-value">{customField.value}</span>
</span>
</span>
);
}
getClass() {

View File

@ -96,7 +96,7 @@
border-top: none;
padding: 20px 10px;
text-align: left;
img {
max-width:100%;
}
@ -111,6 +111,10 @@
font-size: 12px;
}
&__comment-badge-value {
font-weight: normal;
}
&_staff {
.ticket-event__icon {
background-color: $primary-blue;

View File

@ -39,6 +39,7 @@ import AdminPanelCustomResponses from 'app/admin/panel/tickets/admin-panel-custo
import AdminPanelListUsers from 'app/admin/panel/users/admin-panel-list-users';
import AdminPanelViewUser from 'app/admin/panel/users/admin-panel-view-user';
import AdminPanelBanUsers from 'app/admin/panel/users/admin-panel-ban-users';
import AdminPanelCustomFields from 'app/admin/panel/users/admin-panel-custom-fields';
import AdminPanelListArticles from 'app/admin/panel/articles/admin-panel-list-articles';
import AdminPanelViewArticle from 'app/admin/panel/articles/admin-panel-view-article';
@ -120,6 +121,7 @@ export default (
<Route path="list-users" component={AdminPanelListUsers} />
<Route path="view-user/:userId" component={AdminPanelViewUser} />
<Route path="ban-users" component={AdminPanelBanUsers} />
<Route path="custom-fields" component={AdminPanelCustomFields} />
</Route>
<Route path="articles">

View File

@ -153,6 +153,11 @@ class AdminPanelMenu extends React.Component {
name: i18n('BAN_USERS'),
path: '/admin/panel/users/ban-users',
level: 1
},
{
name: i18n('CUSTOM_FIELDS'),
path: '/admin/panel/users/custom-fields',
level: 1
}
])
},

View File

@ -1,6 +1,7 @@
import React from 'react';
import _ from 'lodash';
import {connect} from 'react-redux';
import randomString from 'random-string';
import i18n from 'lib-app/i18n';
import API from 'lib-app/api-call';
@ -55,6 +56,7 @@ class AdminPanelEmailSettings extends React.Component {
['imap-host']: '',
['imap-user']: '',
['imap-pass']: 'HIDDEN',
['imap-token']: '',
},
};
@ -135,7 +137,7 @@ class AdminPanelEmailSettings extends React.Component {
<SubmitButton className="admin-panel-email-settings__submit" type="secondary"
size="small">{i18n('SAVE')}</SubmitButton>
<SubmitButton type="tertiary" size="small" onClick={this.testSMTP.bind(this)}>
Test
{i18n('TEST')}
</SubmitButton>
</div>
</Form>
@ -148,11 +150,12 @@ class AdminPanelEmailSettings extends React.Component {
<FormField name="imap-host" label={i18n('IMAP_SERVER')} fieldProps={{size: 'large'}}/>
<FormField name="imap-user" label={i18n('IMAP_USER')} fieldProps={{size: 'large'}}/>
<FormField name="imap-pass" label={i18n('IMAP_PASSWORD')} fieldProps={{size: 'large'}}/>
<FormField name="imap-token" label={i18n('IMAP_TOKEN')} infoMessage={i18n('IMAP_TOKEN_DESCRIPTION')} fieldProps={{size: 'large', icon: 'refresh', onIconClick: this.generateImapToken.bind(this)}}/>
<div className="admin-panel-email-settings__server-form-buttons">
<SubmitButton className="admin-panel-email-settings__submit" type="secondary"
size="small">{i18n('SAVE')}</SubmitButton>
<SubmitButton type="tertiary" size="small" onClick={this.testIMAP.bind(this)}>
Test
{i18n('TEST')}
</SubmitButton>
</div>
</Form>
@ -334,6 +337,15 @@ class AdminPanelEmailSettings extends React.Component {
AreYouSure.openModal(i18n('WILL_RECOVER_EMAIL_TEMPLATE'), this.recoverEmailTemplate.bind(this));
}
generateImapToken() {
this.setState({
imapForm: {
...this.state.imapForm,
['imap-token']: randomString({length: 20}),
}
});
}
submitEmailAddress(form) {
this.editSettings(form, 'EMAIL_SUCCESS');
}
@ -481,6 +493,7 @@ class AdminPanelEmailSettings extends React.Component {
['imap-host']: result.data['imap-host'],
['imap-user']: result.data['imap-user'],
['imap-pass']: 'HIDDEN',
['imap-token']: result.data['imap-token'],
},
}));
}

View File

@ -0,0 +1,128 @@
import React from 'react';
import _ from 'lodash';
import i18n from 'lib-app/i18n';
import API from 'lib-app/api-call';
import Header from 'core-components/header';
import Button from 'core-components/button';
import SubmitButton from 'core-components/submit-button';
import Form from 'core-components/form';
import FormField from 'core-components/form-field';
import Message from 'core-components/message';
class AdminPanelCustomFieldForm extends React.Component {
static propTypes = {
onClose: React.PropTypes.func,
onChange: React.PropTypes.func,
};
state = {
loading: false,
error: null,
addForm: {},
addFormOptions: [],
};
render() {
return (
<div className="admin-panel-custom-field-form">
<Header title={i18n('NEW_CUSTOM_FIELD')} description={i18n('NEW_CUSTOM_FIELD_DESCRIPTION')} />
<div className="admin-panel-custom-field-form__form-container">
<Form
className="admin-panel-custom-field-form__form"
loading={this.state.loading}
values={this.state.addForm}
onChange={this.onAddFormChange.bind(this)}
onSubmit={this.onSubmit.bind(this)}>
<FormField name="name" validation="NAME" label={i18n('NAME')} field="input" fieldProps={{size: 'large'}} required/>
<FormField name="description" label={i18n('FIELD_DESCRIPTION')} field="input" fieldProps={{size: 'large'}}/>
<FormField name="type" label={i18n('TYPE')} field="select" fieldProps={{size: 'large', items: [{content: i18n('TEXT_INPUT')}, {content: i18n('SELECT_INPUT')}]}} required/>
{this.state.addForm.type ? this.renderOptionFormFields() : null}
{this.state.error ? this.renderErrorMessage() : null}
<div className="admin-panel-custom-field-form__buttons">
<SubmitButton>{i18n('SUBMIT')}</SubmitButton>
<Button onClick={this.props.onClose} type="link">{i18n('CLOSE')}</Button>
</div>
</Form>
</div>
</div>
);
}
renderErrorMessage() {
return (
<Message type="error">
{this.state.error}
</Message>
);
}
renderOptionFormFields() {
return (
<div className="admin-panel-custom-field-form__options">
<div className="admin-panel-custom-field-form__options-title">{i18n('OPTIONS')}</div>
{this.state.addFormOptions.map(this.renderFormOption.bind(this))}
<Button className="admin-panel-custom-field-form__option-add-button" iconName="plus" size="medium" type="secondary" onClick={this.onAddOptionClick.bind(this)} />
</div>
);
}
renderFormOption(option, index) {
return (
<div key={index} className="admin-panel-custom-field-form__option">
<FormField className="admin-panel-custom-field-form__option-field" name={`option_${index}`} label={i18n('OPTION', {index: index+1})} type="input"/>
<Button className="admin-panel-custom-field-form__option-delete-button" size="medium" iconName="times" onClick={this.onDeleteOptionClick.bind(this, index)}/>
</div>
);
}
onAddOptionClick(event) {
event.preventDefault();
let addFormOptions = _.clone(this.state.addFormOptions);
addFormOptions.push("");
this.setState({ addFormOptions });
}
onDeleteOptionClick(index, event) {
event.preventDefault();
let addForm = _.clone(this.state.addForm);
let addFormOptions = this.state.addFormOptions.filter((option, idx) => idx != index);
Object.keys(addForm).forEach(key => _.includes(key, 'option_') ? delete addForm[key] : null);
addFormOptions.forEach((option, _index) => addForm[`option_${_index}`] = option);
this.setState({addForm, addFormOptions});
}
onAddFormChange(addForm) {
const addFormOptions = this.state.addFormOptions.map((option, index) => addForm[`option_${index}`]);
this.setState({addForm, addFormOptions});
}
onSubmit(form) {
this.setState({loading: true, message: null});
API.call({
path: '/system/add-custom-field',
data: {
name: form.name,
description: form.description,
type: form.type ? 'select' : 'text',
options: form.type ? JSON.stringify(this.state.addFormOptions) : []
}
})
.then(() => {
this.setState({loading: false, message: null});
if(this.props.onChange) this.props.onChange();
})
.catch(result => this.setState({loading: false, error: result.message}));
}
}
export default AdminPanelCustomFieldForm;

View File

@ -0,0 +1,30 @@
.admin-panel-custom-field-form {
min-width: 400px;
&__options {
&-title {
margin-bottom: 10px;
}
}
&__option {
display: flex;
align-items: center;
&-add-button {
margin: 20px 0;
}
&-delete-button {
margin-top: 7px;
margin-left: 12px;
}
}
&__buttons {
display: flex;
justify-content: space-between;
}
}

View File

@ -0,0 +1,108 @@
import React from 'react';
import _ from 'lodash';
import i18n from 'lib-app/i18n';
import API from 'lib-app/api-call';
import AdminPanelCustomFieldForm from 'app/admin/panel/users/admin-panel-custom-field-form';
import ModalContainer from 'app-components/modal-container';
import AreYouSure from 'app-components/are-you-sure';
import Header from 'core-components/header';
import Button from 'core-components/button';
import Icon from 'core-components/icon';
import InfoTooltip from 'core-components/info-tooltip';
import Table from 'core-components/table';
class AdminPanelCustomFields extends React.Component {
state = {
customFields: [],
};
componentDidMount() {
this.retrieveCustomFields();
}
render() {
return (
<div className="admin-panel-custom-fields">
<Header title={i18n('CUSTOM_FIELDS')} description={i18n('CUSTOM_FIELDS_DESCRIPTION')} />
{this.renderCustomFieldList()}
<div className="admin-panel-custom-fields__add-button">
<Button type="secondary" onClick={this.onNewCustomFieldClick.bind(this)}>
<Icon name="plus"/> {i18n('NEW_CUSTOM_FIELD')}
</Button>
</div>
</div>
);
}
renderCustomFieldList() {
return (
<Table
className="admin-panel-custom-fields__list"
headers={[
{key: 'name', value: 'Name'},
{key: 'type', value: 'Type'},
{key: 'options', value: 'Options'},
{key: 'actions', value: ''},
]}
rows={this.state.customFields.map(this.getCustomField.bind(this))}
/>
);
}
getCustomField(customField, index) {
const {id, description, name, type, options} = customField;
let descriptionInfoTooltip = null;
if(description) {
descriptionInfoTooltip = <InfoTooltip text={description} />;
}
return {
name: <div>{name} {descriptionInfoTooltip}</div>,
type,
options: JSON.stringify(options.map(option => option.name)),
actions: <Button size="medium" onClick={this.onDeleteCustomField.bind(this, id)}>Remove</Button>,
}
}
onNewCustomFieldClick() {
ModalContainer.openModal(
<AdminPanelCustomFieldForm
onClose={ModalContainer.closeModal}
onChange={() => {
this.retrieveCustomFields();
ModalContainer.closeModal();
}}/>
);
}
onDeleteCustomField(id) {
AreYouSure.openModal(i18n('DELETE_CUSTOM_FIELD_SURE'), this.deleteCustomField.bind(this, id));
}
deleteCustomField(id) {
API.call({
path: '/system/delete-custom-field',
data: {id}
})
.catch(() => this.setState({}))
.then(() => this.retrieveCustomFields());
}
retrieveCustomFields() {
API.call({
path: '/system/get-custom-fields',
data: {}
})
.catch(() => this.setState({}))
.then(result => this.setState({
customFields: result.data
}));
}
}
export default AdminPanelCustomFields;

View File

@ -0,0 +1,11 @@
.admin-panel-custom-fields {
&__list {
text-align: left;
}
&__add-button {
text-align: left;
margin-top: 14px;
}
}

View File

@ -20,6 +20,7 @@ class AdminPanelViewUser extends React.Component {
email: '',
verified: true,
tickets: [],
customfields: [],
invalid: false,
loading: true,
disabled: false
@ -64,6 +65,7 @@ class AdminPanelViewUser extends React.Component {
{(!this.state.verified) ? this.renderNotVerified() : null}
</div>
</div>
{this.state.customfields.map(this.renderCustomField.bind(this))}
<div className="admin-panel-view-user__action-buttons">
<Button
className="admin-panel-view-user__action-button"
@ -98,6 +100,17 @@ class AdminPanelViewUser extends React.Component {
);
}
renderCustomField(customfield) {
return (
<div className="admin-panel-view-user__info-item">
{customfield.customfield}
<div className="admin-panel-view-user__info-box">
{customfield.value}
</div>
</div>
);
}
getTicketListProps() {
return {
type: 'secondary',
@ -115,6 +128,7 @@ class AdminPanelViewUser extends React.Component {
verified: result.data.verified,
tickets: result.data.tickets,
disabled: result.data.disabled,
customfields: result.data.customfields,
loading: false
});
}

View File

@ -1,8 +1,11 @@
import React from 'react';
import {connect} from 'react-redux';
import _ from 'lodash';
import API from 'lib-app/api-call';
import i18n from 'lib-app/i18n';
import SessionActions from 'actions/session-actions';
import AreYouSure from 'app-components/are-you-sure';
import Header from 'core-components/header';
@ -13,17 +16,41 @@ import Message from 'core-components/message';
class DashboardEditProfilePage extends React.Component {
state= {
static propTypes = {
userCustomFields: React.PropTypes.object,
};
static defaultProps = {
userCustomFields: {},
};
state = {
loadingEmail: false,
loadingPass: false,
messageEmail:'',
messagePass:''
messageEmail: '',
messagePass: '',
customFields: [],
customFieldsFrom: {},
loadingCustomFields: false,
};
componentDidMount() {
this.retrieveCustomFields();
}
render() {
return (
<div className="edit-profile-page">
<Header title={i18n('EDIT_PROFILE')} description={i18n('EDIT_PROFILE_VIEW_DESCRIPTION')} />
<div className="edit-profile-page__title">{i18n('ADDITIONAL_FIELDS')}</div>
<Form loading={this.state.loadingCustomFields} values={this.state.customFieldsFrom} onChange={form => this.setState({customFieldsFrom: form})} onSubmit={this.onCustomFieldsSubmit.bind(this)}>
<div className="edit-profile-page__custom-fields">
{this.state.customFields.map(this.renderCustomField.bind(this))}
</div>
<div className="row">
<SubmitButton>{i18n('SAVE')}</SubmitButton>
</div>
</Form>
<div className="edit-profile-page__title">{i18n('EDIT_EMAIL')}</div>
<Form loading={this.state.loadingEmail} onSubmit={this.onSubmitEditEmail.bind(this)}>
<FormField name="newEmail" label={i18n('NEW_EMAIL')} field="input" validation="EMAIL" fieldProps={{size:'large'}} required/>
@ -41,6 +68,25 @@ class DashboardEditProfilePage extends React.Component {
</div>
);
}
renderCustomField(customField, key) {
if(customField.type === 'text') {
return (
<div className="edit-profile-page__custom-field" key={key}>
<FormField name={customField.name} label={customField.name} infoMessage={customField.description} field="input" fieldProps={{size:'small'}}/>
</div>
);
} else {
const items = customField.options.map(option => ({content: option.name, value: option.name}));
return (
<div className="edit-profile-page__custom-field" key={key}>
<FormField name={customField.name} label={customField.name} infoMessage={customField.description} field="select" fieldProps={{size:'small', items}}/>
</div>
);
}
}
renderMessageEmail() {
switch (this.state.messageEmail) {
case 'success':
@ -52,6 +98,7 @@ class DashboardEditProfilePage extends React.Component {
}
}
renderMessagePass() {
switch (this.state.messagePass) {
case 'success':
@ -62,10 +109,37 @@ class DashboardEditProfilePage extends React.Component {
return null;
}
}
onCustomFieldsSubmit(form) {
const {customFields} = this.state;
const parsedFrom = {}
customFields.forEach(customField => {
if(customField.type === 'select') {
parsedFrom[`customfield_${customField.name}`] = customField.options[form[customField.name]].name;
} else {
parsedFrom[`customfield_${customField.name}`] = form[customField.name];
}
});
this.setState({
loadingCustomFields: true,
});
API.call({
path: '/user/edit-custom-fields',
data: parsedFrom
}).then(() => {
this.setState({loadingCustomFields: false});
this.props.dispatch(SessionActions.getUserData());
});
}
onSubmitEditEmail(formState) {
AreYouSure.openModal(i18n('EMAIL_WILL_CHANGE'), this.callEditEmailAPI.bind(this, formState));
}
onSubmitEditPassword(formState) {
AreYouSure.openModal(i18n('PASSWORD_WILL_CHANGE'), this.callEditPassAPI.bind(this, formState));
}
@ -115,6 +189,39 @@ class DashboardEditProfilePage extends React.Component {
}.bind(this));
}
retrieveCustomFields() {
API.call({
path: '/system/get-custom-fields',
data: {}
})
.then(result => {
const customFieldsFrom = {};
const {userCustomFields} = this.props;
result.data.forEach(customField => {
if(customField.type === 'select') {
const index = _.indexOf(customField.options.map(option => option.name), userCustomFields[customField.name]);
customFieldsFrom[customField.name] = (index === -1 ? 0 : index);
} else {
customFieldsFrom[customField.name] = userCustomFields[customField.name] || '';
}
});
this.setState({
customFields: result.data,
customFieldsFrom,
});
});
}
}
export default DashboardEditProfilePage;
export default connect((store) => {
const userCustomFields = {};
store.session.userCustomFields.forEach(customField => {
userCustomFields[customField.customfield] = customField.value;
});
return {
userCustomFields: userCustomFields || {},
};
})(DashboardEditProfilePage);

View File

@ -13,4 +13,13 @@
margin-top: 20px;
margin-bottom: 20px;
}
}
&__custom-fields {
text-align: left;
}
&__custom-field {
display: inline-block;
margin-right: 20px;
}
}

View File

@ -16,18 +16,28 @@ import Header from 'core-components/header';
class MainSignUpWidget extends React.Component {
static propTypes = {
onSuccess: React.PropTypes.func,
className: React.PropTypes.string
};
constructor(props) {
super(props);
this.state = {
loading: false,
email: null
email: null,
customFields: []
};
}
static propTypes = {
onSuccess: React.PropTypes.func,
className: React.PropTypes.string
};
componentDidMount() {
API.call({
path: '/system/get-custom-fields',
data: {}
})
.then(result => this.setState({customFields: result.data}));
}
render() {
return (
@ -39,6 +49,7 @@ class MainSignUpWidget extends React.Component {
<FormField {...this.getInputProps()} label={i18n('EMAIL')} name="email" validation="EMAIL" required/>
<FormField {...this.getInputProps(true)} label={i18n('PASSWORD')} name="password" validation="PASSWORD" required/>
<FormField {...this.getInputProps(true)} label={i18n('REPEAT_PASSWORD')} name="repeated-password" validation="REPEAT_PASSWORD" required/>
{this.state.customFields.map(this.renderCustomField.bind(this))}
</div>
<div className="signup-widget__captcha">
<Captcha ref="captcha"/>
@ -51,6 +62,31 @@ class MainSignUpWidget extends React.Component {
);
}
renderCustomField(customField, key) {
if(customField.type === 'text') {
return (
<FormField {...this.getInputProps()}
name={`customfield_${customField.name}`}
key={key}
label={customField.name}
infoMessage={customField.description}
field="input"/>
);
} else {
const items = customField.options.map(option => ({content: option.name, value: option.name}));
return (
<FormField
name={`customfield_${customField.name}`}
key={key}
label={customField.name}
infoMessage={customField.description}
field="select"
fieldProps={{size:'medium', items}}/>
);
}
}
renderMessage() {
switch (this.state.message) {
case 'success':
@ -98,9 +134,17 @@ class MainSignUpWidget extends React.Component {
loading: true
});
const form = _.clone(formState);
this.state.customFields.forEach(customField => {
if(customField.type === 'select') {
form[`customfield_${customField.name}`] = customField.options[form[`customfield_${customField.name}`]].name;
}
})
API.call({
path: '/user/signup',
data: _.extend({captcha: captcha.getValue()}, formState)
data: _.extend({captcha: captcha.getValue()}, form)
}).then(this.onSignupSuccess.bind(this)).catch(this.onSignupFail.bind(this));
}
}

View File

@ -18,7 +18,8 @@ class Input extends React.Component {
password: React.PropTypes.bool,
required: React.PropTypes.bool,
icon: React.PropTypes.string,
error: React.PropTypes.string
error: React.PropTypes.string,
onIconClick: React.PropTypes.func
};
static defaultProps = {
@ -38,7 +39,7 @@ class Input extends React.Component {
let icon = null;
if (this.props.icon) {
icon = <span className="input__icon"><Icon name={this.props.icon} /></span>
icon = <span className="input__icon" onClick={this.onIconClick.bind(this)}><Icon name={this.props.icon} /></span>
}
return icon;
@ -66,6 +67,7 @@ class Input extends React.Component {
'input': true,
'input_with-icon': (this.props.icon),
'input_errored': (this.props.errored),
'input_icon-clickable': (this.props.onIconClick),
['input_' + this.props.size]: true,
[this.props.className]: (this.props.className)
@ -74,6 +76,14 @@ class Input extends React.Component {
return classNames(classes);
}
onIconClick(event) {
if(this.props.onIconClick) {
event.preventDefault();
this.focus();
this.props.onIconClick(event);
}
}
focus() {
if (this.refs.nativeInput) {
this.refs.nativeInput.focus();

View File

@ -58,4 +58,11 @@
border: 1px solid $primary-red;
}
}
&_icon-clickable {
.input__icon {
cursor: pointer;
}
}
}

View File

@ -61,7 +61,7 @@ export default {
'HIGH': 'Hoch',
'MEDIUM': 'Mittel',
'LOW': 'Niedrig',
'TITLE': 'Titel',
'TITLE': 'Betreff',
'CONTENT': 'Inhalt',
'SAVE': 'Speichern',
'DISCARD_CHANGES': 'Änderungen verwerfen',

View File

@ -144,6 +144,8 @@ export default {
'IMAP_USER': 'IMAP User',
'IMAP_SERVER': 'IMAP Server',
'IMAP_PASSWORD': 'IMAP Password',
'IMAP_TOKEN': 'IMAP Token',
'IMAP_TOKEN_DESCRIPTION': 'Use this token to authenticate the polling request.',
'PORT': 'Port',
'RECAPTCHA_PUBLIC_KEY': 'Recaptcha Public Key',
'RECAPTCHA_PRIVATE_KEY': 'Recaptcha Private Key',
@ -196,6 +198,15 @@ export default {
'IMAGE_HEADER_URL': 'Image header URL',
'IMAGE_HEADER_DESCRIPTION': 'Image that will be used as header of the email',
'EMAIL_SETTINGS': 'Email Settings',
'ADDITIONAL_FIELDS': 'Additonal Fields',
'NEW_CUSTOM_FIELD': 'New Custom field',
'TYPE': 'Type',
'SELECT_INPUT': 'Select input',
'TEXT_INPUT': 'Text input',
'OPTION': 'Option {index}',
'OPTIONS': 'Options',
'FIELD_DESCRIPTION': 'Field description (Optional)',
'CUSTOM_FIELDS': 'Custom fields',
'CHART_CREATE_TICKET': 'Tickets created',
'CHART_CLOSE': 'Tickets closed',
@ -316,6 +327,8 @@ export default {
'PRIVATE_DEPARTMENT_DESCRIPTION': 'This department will only be seen by staff members',
'EMAIL_SETTINGS_DESCRIPTION': 'Here you can edit the settings for receiving and sending email to your customers.',
'IMAP_POLLING_DESCRIPTION': 'Inbox checking will not be done automatically by OpenSupports. You have to make POST requests periodically to this url to process the emails: {url}',
'NEW_CUSTOM_FIELD_DESCRIPTION': 'Here you can create a custom field for an user, it can be a blank text box or a fixed set of options.',
'CUSTOM_FIELDS_DESCRIPTION': 'Custom fields are defined additional fields the users are able to fill to provide more information about them.',
//ERRORS
'EMAIL_OR_PASSWORD': 'Email or password invalid',
@ -376,12 +389,14 @@ export default {
'SUCCESSFUL_CONNECTION': 'Successful connection',
'UNSUCCESSFUL_CONNECTION': 'Unsuccessful connection',
'SERVER_CREDENTIALS_WORKING': 'Server credentials are working correctly',
'DELETE_CUSTOM_FIELD_SURE': 'Some users may be using this field. Are you sure you want to delete it?',
'LAST_7_DAYS': 'Last 7 days',
'LAST_30_DAYS': 'Last 30 days',
'LAST_90_DAYS': 'Last 90 days',
'LAST_365_DAYS': 'Last 365 days',
'TEST': 'Test',
'ACTIVITY_COMMENT_THIS': 'commented this ticket',
'ACTIVITY_ASSIGN_THIS': 'assigned this ticket to',
'ACTIVITY_UN_ASSIGN_THIS': 'unassigned this ticket to',

View File

@ -113,7 +113,8 @@ class SessionReducer extends Reducer {
userLevel: userData.level,
userDepartments: userData.departments,
userTickets: userData.tickets,
userSendEmailOnNewTicket: userData.sendEmailOnNewTicket * 1
userSendEmailOnNewTicket: userData.sendEmailOnNewTicket * 1,
userCustomFields: userData.customfields
});
}
@ -132,7 +133,8 @@ class SessionReducer extends Reducer {
userDepartments: userData.departments,
userTickets: userData.tickets,
userId: userId,
userSendEmailOnNewTicket: userData.sendEmailOnNewTicket * 1
userSendEmailOnNewTicket: userData.sendEmailOnNewTicket * 1,
userCustomFields: userData.customfields
});
}

View File

@ -8,7 +8,9 @@
"ifsnop/mysqldump-php": "2.*",
"ezyang/htmlpurifier": "^4.8",
"codeguy/upload": "^1.3",
"php-imap/php-imap": "^3.0"
"php-imap/php-imap": "^3.0",
"willdurand/email-reply-parser": "^2.8",
"ext-fileinfo": "^1.0"
},
"require-dev": {
"phpunit/phpunit": "^5.7"

View File

@ -69,7 +69,7 @@ class GetStaffController extends Controller {
'level' => $user->level,
'staff' => true,
'departments' => $parsedDepartmentList,
'tickets' => $user->sharedTicketList->toArray(),
'tickets' => $user->sharedTicketList->toArray(true),
'sendEmailOnNewTicket' => $user->sendEmailOnNewTicket
]);
}

View File

@ -31,5 +31,9 @@ $systemControllerGroup->addController(new DisableUserSystemController);
$systemControllerGroup->addController(new EnableUserSystemController);
$systemControllerGroup->addController(new TestSMTPController);
$systemControllerGroup->addController(new TestIMAPController);
$systemControllerGroup->addController(new EmailPollingController);
$systemControllerGroup->addController(new AddCustomFieldController);
$systemControllerGroup->addController(new DeleteCustomFieldController);
$systemControllerGroup->addController(new GetCustomFieldsController);
$systemControllerGroup->finalize();

View File

@ -0,0 +1,98 @@
<?php
use Respect\Validation\Validator as DataValidator;
/**
* @api {post} /system/add-custom-field Add a custom field
* @apiVersion 4.4.0
*
* @apiName Add Custom field
*
* @apiGroup System
*
* @apiDescription This path creates a Custom field.
*
* @apiPermission staff2
*
* @apiParam {Number} name Name of the custom field.
* @apiParam {String} type One of 'text' and 'select'.
* @apiParam {String} options JSON array of strings with the option names.
*
* @apiUse NO_PERMISSION
* @apiUse INVALID_NAME
* @apiUse INVALID_CUSTOM_FIELD_TYPE
* @apiUse INVALID_CUSTOM_FIELD_OPTIONS
* @apiUse CUSTOM_FIELD_ALREADY_EXISTS
*
* @apiSuccess {Object} data Empty object
*
*/
class AddCustomFieldController extends Controller {
const PATH = '/add-custom-field';
const METHOD = 'POST';
public function validations() {
return [
'permission' => 'staff_2',
'requestData' => [
'name' => [
'validation' => DataValidator::length(2, 100),
'error' => ERRORS::INVALID_NAME
],
'type' => [
'validation' => DataValidator::oneOf(
DataValidator::equals('text'),
DataValidator::equals('select')
),
'error' => ERRORS::INVALID_CUSTOM_FIELD_TYPE
],
'options' => [
'validation' => DataValidator::oneOf(
DataValidator::json(),
DataValidator::nullType()
),
'error' => ERRORS::INVALID_CUSTOM_FIELD_OPTIONS
]
]
];
}
public function handler() {
$name = Controller::request('name');
$type = Controller::request('type');
$description = Controller::request('description');
$options = Controller::request('options');
if(!Customfield::getDataStore($name, 'name')->isNull())
throw new Exception(ERRORS::CUSTOM_FIELD_ALREADY_EXISTS);
$customField = new Customfield();
$customField->setProperties([
'name' => $name,
'type' => $type,
'description' => $description,
'ownCustomfieldoptionList' => $this->getOptionList($options)
]);
$customField->store();
Response::respondSuccess();
}
public function getOptionList($optionNames) {
$options = new DataStoreList();
if(!$optionNames) return $options;
$optionNames = json_decode($optionNames);
foreach($optionNames as $optionName) {
$option = new Customfieldoption();
$option->setProperties([
'name' => $optionName,
]);
$options->add($option);
}
return $options;
}
}

View File

@ -0,0 +1,56 @@
<?php
use Respect\Validation\Validator as DataValidator;
/**
* @api {post} /system/delete-custom-field Delete custom field
* @apiVersion 4.4.0
*
* @apiName Delete a custom field
*
* @apiGroup System
*
* @apiDescription This path deletes a custom field and all its uses.
*
* @apiPermission staff2
*
* @apiParam {Number} id Id of the custom field to delete.
*
* @apiUse NO_PERMISSION
* @apiUse INVALID_CUSTOM_FIELD
*
* @apiSuccess {Object} data Empty object
*
*/
class DeleteCustomFieldController extends Controller {
const PATH = '/delete-custom-field';
const METHOD = 'POST';
public function validations() {
return [
'permission' => 'staff_2',
'requestData' => [
'id' => [
'validation' => DataValidator::dataStoreId('customfield'),
'error' => ERRORS::INVALID_CUSTOM_FIELD,
],
]
];
}
public function handler() {
$customField = Customfield::getDataStore(Controller::request('id'));
foreach(Users::getAll() as $user) {
$customFieldValueList = $user->xownCustomfieldvalueList || [];
foreach($customFieldValueList as $customFieldValue) {
if($customFieldValue->customfield->id == $customField->id) {
$user->xownCustomfieldvalueList->remove($customFieldValue);
}
}
}
$customField->delete();
}
}

View File

@ -42,6 +42,7 @@ class EditSettingsController extends Controller {
'imap-host',
'imap-user',
'imap-pass',
'imap-token',
'smtp-host',
'smtp-user',
'smtp-pass',

View File

@ -1,6 +1,7 @@
<?php
use Respect\Validation\Validator as DataValidator;
class EmailPolling extends Controller {
class EmailPollingController extends Controller {
const PATH = '/email-polling';
const METHOD = 'POST';
@ -9,7 +10,12 @@ class EmailPolling extends Controller {
public function validations() {
return [
'permission' => 'any',
'requestData' => []
'requestData' => [
'token' => [
'validation' => DataValidator::length(1, 200),
'error' => ERRORS::INVALID_TOKEN
]
]
];
}
@ -19,6 +25,10 @@ class EmailPolling extends Controller {
$defaultLanguage = Setting::getSetting('language')->getValue();
$defaultDepartmentId = Department::getAll()->first()->id;
if(Controller::request('token') !== Setting::getSetting('imap-token')->getValue())
throw new RequestException(ERRORS::INVALID_TOKEN);
if(Controller::isUserSystemEnabled())
throw new RequestException(ERRORS::USER_SYSTEM);
@ -26,12 +36,12 @@ class EmailPolling extends Controller {
Setting::getSetting('imap-host')->getValue(),
Setting::getSetting('imap-user')->getValue(),
Setting::getSetting('imap-pass')->getValue(),
__DIR__
'files/'
);
$errors = [];
$emails = $this->getLastEmails();
/*
$session = Session::getInstance();
$oldSession = [
'userId' => $session->getUserId(),
@ -61,6 +71,17 @@ class EmailPolling extends Controller {
return null;
});
if($email->getAttachement()) {
$attachment = $email->getAttachement();
$_FILES['file'] = [
'name' => $attachment->name,
'type' => mime_content_type($attachment->filePath),
'tmp_name' => $attachment->filePath,
'error' => UPLOAD_ERR_OK,
'size' => filesize($attachment->filePath),
];
}
try {
if($email->isReply()) {
if($email->getTicket()->authorToArray()['email'] === $email->getSender()) {
@ -79,6 +100,8 @@ class EmailPolling extends Controller {
'error' => $e->__toString(),
];
}
unset($_FILES['file']);
}
$session->clearSessionData();
@ -90,7 +113,7 @@ class EmailPolling extends Controller {
Response::respondError(ERRORS::EMAIL_POLLING, null, $errors);
} else {
Response::respondSuccess();
}
}*/
}
public function getLastEmails() {
@ -101,12 +124,14 @@ class EmailPolling extends Controller {
foreach($mailsIds as $mailId) {
$mail = $this->mailbox->getMail($mailId);
$mailHeader = $this->mailbox->getMailHeader($mailId);
$mailAttachment = count($mail->getAttachments()) ? $mail->getAttachments()[0] : null;
$emails[] = new Email([
'fromAddress' => $mailHeader->fromAddress,
'fromName' => $mailHeader->fromName,
'subject' => $mailHeader->subject,
'content' => $mail->textPlain,
'file' => null,
'file' => $mailAttachment,
]);
}

View File

@ -0,0 +1,38 @@
<?php
use Respect\Validation\Validator as DataValidator;
/**
* @api {post} /system/get-custom-fields Get custom fields
* @apiVersion 4.4.0
*
* @apiName Get all Custom field items
*
* @apiGroup System
*
* @apiDescription This path retrieves the all CustomField items.
*
* @apiPermission any
*
* @apiUse NO_PERMISSION
*
* @apiSuccess {[Customfield](#api-Data_Structures-ObjectCustomfield)[]} data Array of Customfield
*
*/
class GetCustomFieldsController extends Controller {
const PATH = '/get-custom-fields';
const METHOD = 'POST';
public function validations() {
return [
'permission' => 'any',
'requestData' => []
];
}
public function handler() {
$customFieldList = Customfield::getAll();
Response::respondSuccess($customFieldList->toArray());
}
}

View File

@ -50,6 +50,7 @@ class GetSettingsController extends Controller {
'smtp-user' => Setting::getSetting('smtp-user')->getValue(),
'imap-host' => Setting::getSetting('imap-host')->getValue(),
'imap-user' => Setting::getSetting('imap-user')->getValue(),
'imap-token' => Setting::getSetting('imap-token')->getValue(),
'registration' => Setting::getSetting('registration')->getValue(),
'departments' => Department::getAllDepartmentNames(),
'supportedLanguages' => Language::getSupportedLanguages(),

View File

@ -88,7 +88,8 @@ class InitSettingsController extends Controller {
'ticket-gap' => Hashing::generateRandomPrime(100000, 999999),
'ticket-first-number' => Hashing::generateRandomNumber(100000, 999999),
'session-prefix' => 'opensupports-'.Hashing::generateRandomToken().'_',
'mail-template-header-image' => 'https://s3.amazonaws.com/opensupports/logo.png'
'mail-template-header-image' => 'https://s3.amazonaws.com/opensupports/logo.png',
'imap-token' => '',
]);
}

View File

@ -79,7 +79,7 @@ class CommentController extends Controller {
public function handler() {
$this->requestData();
$ticketAuthor = $this->ticket->authorToArray();
$isAuthor = $this->ticket->isAuthor(Controller::getLoggedUser());
$isAuthor = $this->ticket->isAuthor(Controller::getLoggedUser()) || Session::getInstance()->isTicketSession();
$isOwner = $this->ticket->isOwner(Controller::getLoggedUser());
if((Controller::isUserSystemEnabled() || Controller::isStaffLogged()) && !$isOwner && !$isAuthor) {
@ -89,13 +89,13 @@ class CommentController extends Controller {
$this->storeComment();
if($isAuthor && $this->ticket->owner) {
$this->sendMail([
'email' => $this->ticket->owner->email,
'name' => $this->ticket->owner->name,
'staff' => true
]);
} else {
$this->sendMail($ticketAuthor);
$this->sendMail([
'email' => $this->ticket->owner->email,
'name' => $this->ticket->owner->name,
'staff' => true
]);
} else if($isOwner) {
$this->sendMail($ticketAuthor);
}
Log::createLog('COMMENT', $this->ticket->ticketNumber);

View File

@ -20,4 +20,6 @@ $userControllers->addController(new ListBanUserController);
$userControllers->addController(new VerifyController);
$userControllers->addController(new EnableUserController);
$userControllers->addController(new DisableUserController);
$userControllers->addController(new EditCustomFieldsController);
$userControllers->finalize();

View File

@ -0,0 +1,55 @@
<?php
use Respect\Validation\Validator as DataValidator;
/**
* @api {post} /user/edit-custom-fields Edit custom field values
* @apiVersion 4.4.0
*
* @apiName Edit custom field values
*
* @apiGroup User
*
* @apiDescription This path is for editing the custom fields of a user.
*
* @apiPermission user
*
* @apiParam {String} userId Id of the user if it is not the one logged. Optional.
* @apiParam {String} customfield_ Custom field values for this user.
*
* @apiUse NO_PERMISSION
* @apiUse INVALID_CUSTOM_FIELD_OPTION
*
* @apiSuccess {Object} data Empty object
*
*/
class EditCustomFieldsController extends Controller {
const PATH = '/edit-custom-fields';
const METHOD = 'POST';
public function validations() {
return [
'permission' => 'user',
'requestData' => []
];
}
public function handler() {
$userId = Controller::request('userId') * 1;
$user = Controller::getLoggedUser();
if($userId && Controller::isStaffLogged(2)) {
$user = User::getDataStore($userId);
if($user->isNull())
throw new RequestException(ERRORS::INVALID_USER);
}
$user->setProperties([
'xownCustomfieldvalueList' => $this->getCustomFieldValues()
]);
$user->store();
Response::respondSuccess();
}
}

View File

@ -66,9 +66,10 @@ class GetUserByIdController extends Controller {
'name' => $user->name,
'email' => $user->email,
'signupDate' => $user->signupDate,
'tickets' => $tickets->toArray(),
'tickets' => $tickets->toArray(true),
'verified' => !$user->verificationToken,
'disabled' => !!$user->disabled
'disabled' => !!$user->disabled,
'customfields' => $user->xownCustomfieldvalueList->toArray(),
]);
}
}

View File

@ -55,7 +55,8 @@ class GetUserController extends Controller {
'name' => $user->name,
'email' => $user->email,
'verified' => !$user->verificationToken,
'tickets' => $parsedTicketList
'tickets' => $parsedTicketList,
'customfields' => $user->xownCustomfieldvalueList->toArray(),
]);
}
}

View File

@ -19,6 +19,7 @@ DataValidator::with('CustomValidations', true);
* @apiParam {String} email The email of the new user.
* @apiParam {String} password The password of the new user.
* @apiParam {String} apiKey APIKey to sign up an user if the user system is disabled.
* @apiParam {String} customfield_ Custom field values for this user.
*
* @apiUse INVALID_NAME
* @apiUse INVALID_EMAIL
@ -28,6 +29,7 @@ DataValidator::with('CustomValidations', true);
* @apiUse USER_EXISTS
* @apiUse ALREADY_BANNED
* @apiUse NO_PERMISSION
* @apiUse INVALID_CUSTOM_FIELD_OPTION
*
* @apiSuccess {Object} data Information about created user
* @apiSuccess {Number} data.userId Id of the new user
@ -131,7 +133,8 @@ class SignUpController extends Controller {
'tickets' => 0,
'email' => $this->userEmail,
'password' => Hashing::hashPassword($this->userPassword),
'verificationToken' => (MailSender::getInstance()->isConnected()) ? $this->verificationToken : null
'verificationToken' => (MailSender::getInstance()->isConnected()) ? $this->verificationToken : null,
'xownCustomfieldvalueList' => $this->getCustomFieldValues()
]);
return $userInstance->store();

View File

@ -209,7 +209,31 @@
*/
/**
* @apiDefine EMAIL_POLLING
* @apiError {String} EMAIL_POLLING Email polling
* @apiError {String} EMAIL_POLLING Email polling was unsuccesful
*/
/**
* @apiDefine IMAP_CONNECTION
* @apiError {String} IMAP_CONNECTION Imap connection was unsuccesfull
*/
/**
* @apiDefine CUSTOM_FIELD_ALREADY_EXISTS
* @apiError {String} CUSTOM_FIELD_ALREADY_EXISTS Custom field already exists
*/
/**
* @apiDefine INVALID_CUSTOM_FIELD
* @apiError {String} INVALID_CUSTOM_FIELD Custom field id is invalid
*/
/**
* @apiDefine INVALID_CUSTOM_FIELD_TYPE
* @apiError {String} INVALID_CUSTOM_FIELD_TYPE The type is invalid
*/
/**
* @apiDefine INVALID_CUSTOM_FIELD_OPTIONS
* @apiError {String} INVALID_CUSTOM_FIELD_OPTIONS Options are not a json array
*/
/**
* @apiDefine INVALID_CUSTOM_FIELD_OPTION
* @apiError {String} INVALID_CUSTOM_FIELD_OPTION Option is not in the list of possibles
*/
class ERRORS {
@ -268,4 +292,9 @@ class ERRORS {
const INVALID_TEXT_3 = 'INVALID_TEXT_3';
const DEPARTMENT_PRIVATE_TICKETS = 'DEPARTMENT_PRIVATE_TICKETS';
const EMAIL_POLLING = 'EMAIL_POLLING';
const CUSTOM_FIELD_ALREADY_EXISTS = 'CUSTOM_FIELD_ALREADY_EXISTS';
const INVALID_CUSTOM_FIELD = 'INVALID_CUSTOM_FIELD';
const INVALID_CUSTOM_FIELD_TYPE = 'INVALID_CUSTOM_FIELD_TYPE';
const INVALID_CUSTOM_FIELD_OPTIONS = 'INVALID_CUSTOM_FIELD_OPTIONS';
const INVALID_CUSTOM_FIELD_OPTION = 'INVALID_CUSTOM_FIELD_OPTION';
}

View File

@ -149,4 +149,42 @@ abstract class Controller {
public static function isUserSystemEnabled() {
return Setting::getSetting('user-system-enabled')->getValue();
}
public static function getCustomFieldValues() {
$customFields = Customfield::getAll();
$customFieldValues = new DataStoreList();
foreach($customFields as $customField) {
$value = Controller::request('customfield_' . $customField->name);
if($value !== null) {
$customFieldValue = new Customfieldvalue();
$customFieldValue->setProperties([
'customfield' => $customField,
]);
if($customField->type == 'select') {
$ok = false;
foreach($customField->ownCustomfieldoptionList as $option) {
if($option->name == $value) {
$customFieldValue->setProperties([
'customfieldoption' => $option,
'value' => $option->name,
]);
$ok = true;
}
}
if(!$ok)
throw new RequestException(ERRORS::INVALID_CUSTOM_FIELD_OPTION);
} else {
$customFieldValue->setProperties([
'value' => $value,
]);
}
$customFieldValues->add($customFieldValue);
}
}
return $customFieldValues;
}
}

View File

@ -1,4 +1,5 @@
<?php
use EmailReplyParser\Parser\EmailParser;
class Email {
private $sender;
@ -49,7 +50,9 @@ class Email {
}
private function parseContent($data) {
return $data['content'];
$emailParser = new EmailParser();
return $emailParser->parse($data['content'])->getVisibleText();
}
private function parseAttachment($data) {

View File

@ -0,0 +1,34 @@
<?php
/**
* @api {OBJECT} Customfield Customfield
* @apiVersion 4.4.0
* @apiGroup Data Structures
* @apiParam {Number} id Id of the custom filed.
* @apiParam {String} name Name of the custom filed.
* @apiParam {String} description Description of the custom field,
* @apiParam {String} Type Type of custom field (select or text)
* @apiParam {Customfieldoption[]} options List of possible values if it is select
*/
class Customfield extends DataStore {
const TABLE = 'customfield';
public static function getProps() {
return [
'name',
'description',
'type',
'ownCustomfieldoptionList'
];
}
public function toArray() {
return [
'id' => $this->id,
'name' => $this->name,
'description' => $this->description,
'type' => $this->type,
'options' => $this->ownCustomfieldoptionList->toArray()
];
}
}

View File

@ -0,0 +1,25 @@
<?php
/**
* @api {OBJECT} Customfieldoption Customfieldoption
* @apiVersion 4.4.0
* @apiGroup Data Structures
* @apiParam {Number} id Id of the option.
* @apiParam {String} name Name of the option.
*/
class Customfieldoption extends DataStore {
const TABLE = 'customfieldoption';
public static function getProps() {
return [
'name'
];
}
public function toArray() {
return [
'id' => $this->id,
'name' => $this->name
];
}
}

View File

@ -0,0 +1,30 @@
<?php
/**
* @api {OBJECT} Customfieldvalue Customfieldvalue
* @apiVersion 4.4.0
* @apiGroup Data Structures
* @apiParam {Number} id Id of the value.
* @apiParam {Customfield} customfield Customfield of the value.
* @apiParam {String} value Content of the value..
*/
class Customfieldvalue extends DataStore {
const TABLE = 'customfieldvalue';
public static function getProps() {
return [
'customfield',
'value',
'customfieldoption'
];
}
public function toArray() {
return [
'id' => $this->id,
'customfield' => $this->customfield->name,
'value' => $this->value,
'customfieldoption' => $this->customfieldoption ? $this->customfieldoption->toArray() : null,
];
}
}

View File

@ -173,6 +173,7 @@ abstract class DataStore {
$listType = str_replace('List', '', $listType);
$listType = str_replace('shared', '', $listType);
$listType = str_replace('xown', '', $listType);
$listType = str_replace('own', '', $listType);
return $listType;

View File

@ -41,6 +41,10 @@ class Session {
$this->store('token', Hashing::generateRandomToken());
}
public function isTicketSession() {
return $this->getStoredData('ticketNumber') && $this->getStoredData('token');
}
public function getTicketNumber() {
return $this->getStoredData('ticketNumber');
}

View File

@ -141,7 +141,8 @@ class Ticket extends DataStore {
'name' => $author->name,
'staff' => $author instanceof Staff,
'profilePic' => ($author instanceof Staff) ? $author->profilePic : null,
'email' => $author->email
'email' => $author->email,
'customfields' => $author->xownCustomfieldvalueList ? $author->xownCustomfieldvalueList->toArray() : [],
];
} else {
return [

View File

@ -85,7 +85,8 @@ class Ticketevent extends DataStore {
'author' => [
'name' => $user ? $user->name : null,
'staff' => $user instanceOf Staff,
'id' => $user ? $user->id : null
'id' => $user ? $user->id : null,
'customfields' => $user->xownCustomfieldvalueList ? $user->xownCustomfieldvalueList->toArray() : [],
]
];
}

View File

@ -9,6 +9,7 @@ use RedBeanPHP\Facade as RedBean;
* @apiParam {Number} id The id of the user.
* @apiParam {String} name The name of the user.
* @apiParam {Boolean} verified Indicates if the user has verified the email.
* @apiParam {[CustomField](#api-Data_Structures-ObjectCustomfield)[]} customfields Indicates the values for custom fields.
*/
class User extends DataStore {
@ -29,7 +30,8 @@ class User extends DataStore {
'tickets',
'sharedTicketList',
'verificationToken',
'disabled'
'disabled',
'xownCustomfieldvalueList'
];
}
@ -47,7 +49,8 @@ class User extends DataStore {
'id' => $this->id,
'name' => $this->name,
'verified' => !$this->verificationToken,
'disabled' => $this->disabled
'disabled' => $this->disabled,
'customfields' => $this->xownCustomfieldvalueList->toArray(),
];
}
}