Merge branch 'master' into info-card-implementation

# Conflicts:
#	client/src/data/languages/en.js
This commit is contained in:
ivan 2016-08-31 15:14:25 -03:00
commit 13cb79b559
37 changed files with 590 additions and 35 deletions

View File

@ -11,8 +11,8 @@ const ConfigActions = requireUnit('actions/config-actions', {
describe('Config Actions,', function () {
describe('init action', function () {
it('should return INIT_CONFIGS_FULFILLED with configs if it is already retrieved', function () {
sessionStoreMock.areConfigsStored.returns(true);
it('should return INIT_CONFIGS_FULFILLED with configs if it user is logged in', function () {
sessionStoreMock.isLoggedIn.returns(true);
sessionStoreMock.getConfigs.returns({
config1: 'CONFIG_1',
config2: 'CONFIG_2'
@ -31,7 +31,7 @@ describe('Config Actions,', function () {
it('should return INIT_CONFIGS with API_RESULT if it is not retrieved', function () {
APICallMock.call.reset();
sessionStoreMock.areConfigsStored.returns(false);
sessionStoreMock.isLoggedIn.returns(false);
sessionStoreMock.getConfigs.returns({
config1: 'CONFIG_1',
config2: 'CONFIG_2'
@ -42,7 +42,7 @@ describe('Config Actions,', function () {
payload: 'API_RESULT'
});
expect(APICallMock.call).to.have.been.calledWith({
path: '/system/get-configs',
path: '/system/get-settings',
data: {}
});
});

View File

@ -3,7 +3,7 @@ import sessionStore from 'lib-app/session-store';
export default {
init() {
if (sessionStore.areConfigsStored()) {
if (sessionStore.isLoggedIn()) {
return {
type: 'INIT_CONFIGS_FULFILLED',
payload: {
@ -14,7 +14,7 @@ export default {
return {
type: 'INIT_CONFIGS',
payload: API.call({
path: '/system/get-configs',
path: '/system/get-settings',
data: {}
})
};

View File

@ -2,6 +2,8 @@ import API from 'lib-app/api-call';
import sessionStore from 'lib-app/session-store';
import store from 'app/store';
import ConfigActions from 'actions/config-actions';
export default {
login(loginData) {
return {

View File

@ -0,0 +1,35 @@
import React from 'react';
import ReactDOM from 'react-dom';
import ReCAPTCHA from 'react-google-recaptcha';
import {connect} from 'react-redux';
class Captcha extends React.Component {
constructor(props) {
super(props);
this.state = {
value: ''
};
}
render() {
return (
<ReCAPTCHA sitekey={this.props.sitekey} ref="reCaptcha" onChange={(value) => {this.setState({value})}} tabIndex="0" />
);
}
getValue() {
return this.state.value;
}
focus() {
ReactDOM.findDOMNode(this).focus();
}
}
export default connect((store) => {
return {
sitekey: store.config.reCaptchaKey
};
}, null, null, { withRef: true })(Captcha);

View File

@ -4,6 +4,7 @@ import { browserHistory } from 'react-router';
import i18n from 'lib-app/i18n';
import API from 'lib-app/api-call';
import SessionStore from 'lib-app/session-store';
import store from 'app/store';
import SessionActions from 'actions/session-actions';
@ -41,11 +42,7 @@ class CreateTicketForm extends React.Component {
<div className="row">
<FormField className="col-md-7" label="Title" name="title" validation="TITLE" required field="input" fieldProps={{size: 'large'}}/>
<FormField className="col-md-5" label="Department" name="departmentId" field="select" fieldProps={{
items: [
{content: 'Sales Support'},
{content: 'Technical Issues'},
{content: 'System and Administration'}
],
items: SessionStore.getDepartments().map((department) => {return {content: department}}),
size: 'medium'
}} />
</div>
@ -70,7 +67,7 @@ class CreateTicketForm extends React.Component {
renderCaptcha() {
return (
<div className="create-ticket-form__captcha">
<ReCAPTCHA sitekey="6LfM5CYTAAAAAGLz6ctpf-hchX2_l0Ge-Bn-n8wS" onChange={function () {}}/>
<Captcha />
</div>
);
}

View File

@ -1,14 +1,30 @@
import React from 'react';
import _ from 'lodash';
import {connect} from 'react-redux';
import TicketViewer from 'app/main/dashboard/dashboard-ticket/ticket-viewer';
class DashboardTicketPage extends React.Component {
static propTypes = {
tickets: React.PropTypes.array
};
render() {
return (
<div>
DASHBOARD TICKET PAGE
<div className="dashboard-ticket-page">
<TicketViewer ticket={this.getTicketData()} />
</div>
);
}
getTicketData() {
return _.find(this.props.tickets, {ticketNumber: this.props.params.ticketNumber});
}
}
export default DashboardTicketPage;
export default connect((store) => {
return {
tickets: store.session.userTickets
};
})(DashboardTicketPage);

View File

@ -0,0 +1,3 @@
.dashboard-ticket-page {
padding: 0 10px;
}

View File

@ -0,0 +1,105 @@
import React from 'react';
import classNames from 'classnames';
import i18n from 'lib-app/i18n';
import Icon from 'core-components/icon';
class TicketAction extends React.Component {
static propTypes = {
type: React.PropTypes.oneOf(['comment', 'assign']),
config: React.PropTypes.object
};
static defaultProps = {
type: 'comment'
};
render() {
return (
<div className={this.getClass()}>
<span className="ticket-action__connector" />
<div className="col-md-1">
<div className="ticket-action__icon">
<Icon name="comment-o" size="2x" />
</div>
</div>
<div className="col-md-11">
{this.renderActionDescription()}
</div>
</div>
);
}
renderActionDescription() {
const renders = {
'comment': this.renderComment.bind(this),
'assign': this.renderAssignment.bind(this)
};
return renders[this.props.type]();
}
renderComment() {
const {config} = this.props;
return (
<div className="ticket-action__comment">
<span className="ticket-action__comment-pointer" />
<div className="ticket-action__comment-author">
<span className="ticket-action__comment-author-name">{config.author.name}</span>
<span className="ticket-action__comment-author-type">({i18n((config.author.staff) ? 'STAFF' : 'CUSTOMER')})</span>
</div>
<div className="ticket-action__comment-date">{config.date}</div>
<div className="ticket-action__comment-content">{config.content}</div>
{this.renderFileRow(config.file)}
</div>
);
}
renderAssignment() {
// TODO: Add actions architecture instead of just comments
return (
<div className="ticket-action__assignment">
</div>
)
}
renderFileRow(file) {
let node = null;
if (file) {
node = <span> {this.getFileLink(file)} <Icon name="paperclip" /> </span>;
} else {
node = i18n('NO_ATTACHMENT');
}
return (
<div className="ticket-viewer__file">
{node}
</div>
)
}
getClass() {
const {config} = this.props;
let classes = {
'row': true,
'ticket-action': true,
'ticket-action_staff': config.author && config.author.staff
};
return classNames(classes);
}
getFileLink(filePath = '') {
const fileName = filePath.replace(/^.*[\\\/]/, '');
return (
<a href={filePath} target="_blank">{fileName}</a>
)
}
}
export default TicketAction;

View File

@ -0,0 +1,83 @@
@import "../../../../scss/vars";
.ticket-action {
margin-top: 20px;
text-align: left;
position: relative;
&__connector {
position: absolute;
background-color: $light-grey;
width: 3px;
height: 100%;
top: 38px;
left: 33px;
z-index: 0;
}
&__icon {
vertical-align: top;
background-color: $secondary-blue;
color: white;
border-radius: 5px;
width: 42px;
height: 42px;
padding-left: 8px;
padding-top: 4px;
}
&__comment {
position: relative;
&-pointer {
right: 100%;
border: solid transparent;
position: absolute;
border-right-color: $light-grey;
border-width: 13px;
margin-top: 8px;
}
&-author {
text-align: left;
float: left;
position: relative;
padding: 12px;
color: $primary-black;
&-type {
font-size: 10px;
padding-left: 10px;
color: $secondary-blue;
font-variant: small-caps;
}
}
&-date {
text-align: right;
border: 2px solid $light-grey;
border-bottom: none;
padding: 12px;
background-color: $light-grey;
}
&-content {
background-color: white;
border: 2px solid $very-light-grey;
border-top: none;
padding: 20px 10px;
text-align: left;
}
}
&_staff {
.ticket-action__icon {
background-color: $primary-blue;
}
.ticket-action__comment-author-type {
color: $primary-blue;
}
}
}

View File

@ -0,0 +1,65 @@
import React from 'react';
import i18n from 'lib-app/i18n';
import TicketAction from 'app/main/dashboard/dashboard-ticket/ticket-action';
import Form from 'core-components/form';
import FormField from 'core-components/form-field';
import SubmitButton from 'core-components/submit-button';
class TicketViewer extends React.Component {
static propTypes = {
ticket: React.PropTypes.object
};
static defaultProps = {
ticket: {
author: {},
department: {},
comments: []
}
};
render() {
return (
<div className="ticket-viewer">
<div className="ticket-viewer__header row">
<span className="ticket-viewer__number">#{this.props.ticket.ticketNumber}</span>
<span className="ticket-viewer__title">{this.props.ticket.title}</span>
</div>
<div className="ticket-viewer__info-row-header row">
<div className="ticket-viewer__department col-md-4">{i18n('DEPARTMENT')}</div>
<div className="ticket-viewer__author col-md-4">{i18n('AUTHOR')}</div>
<div className="ticket-viewer__date col-md-4">{i18n('DATE')}</div>
</div>
<div className="ticket-viewer__info-row-values row">
<div className="ticket-viewer__department col-md-4">{this.props.ticket.department.name}</div>
<div className="ticket-viewer__author col-md-4">{this.props.ticket.author.name}</div>
<div className="ticket-viewer__date col-md-4">{this.props.ticket.date}</div>
</div>
<div className="ticket-viewer__content">
<TicketAction type="comment" config={this.props.ticket} />
</div>
<div className="ticket-viewer__comments">
{this.props.ticket.comments.map(this.renderComment.bind(this))}
</div>
<div className="ticket-viewer__response">
<div className="ticket-viewer__response-title row">{i18n('RESPOND')}</div>
<div className="ticket-viewer__response-field row">
<Form>
<FormField name="content" validation="TEXT_AREA" required field="textarea" />
<SubmitButton>{i18n('RESPOND_TICKET')}</SubmitButton>
</Form>
</div>
</div>
</div>
);
}
renderComment(comment, index) {
return (
<TicketAction type="comment" config={comment} key={index} />
);
}
}
export default TicketViewer;

View File

@ -0,0 +1,79 @@
@import "../../../../scss/vars";
.ticket-viewer {
&__header {
background-color: $primary-blue;
border-top-right-radius: 4px;
border-top-left-radius: 4px;
color: white;
font-size: 16px;
padding: 6px 0;
}
&__number {
color: white;
margin-right: 10px;
font-size: 14px;
}
&__title {
display: inline-block;
}
&__info-row-header {
background-color: $light-grey;
font-weight: bold;
}
&__info-row-values {
background-color: $light-grey;
color: $secondary-blue;
}
&__date {
}
&__author {
}
&__department {
}
&__content {
margin-top: 10px;
}
&__file {
background-color: $very-light-grey;
text-align: right;
padding: 5px 10px;
font-size: 12px;
}
&__comments {
position: relative;
}
&__response {
margin-top: 20px;
position: relative;
&-title {
background-color: $primary-blue;
text-align: left;
padding: 5px;
color: white;
border-top-right-radius: 4px;
border-top-left-radius: 4px;
}
&-field {
background-color: $very-light-grey;
padding: 20px;
text-align: left;
}
}
}

View File

@ -1,9 +1,11 @@
import React from 'react';
import ReCAPTCHA from 'react-google-recaptcha';
import ReactDOM from 'react-dom';
import _ from 'lodash';
import i18n from 'lib-app/i18n';
import API from 'lib-app/api-call';
import Captcha from 'app/main/captcha';
import SubmitButton from 'core-components/submit-button';
import Message from 'core-components/message';
import Form from 'core-components/form';
@ -34,7 +36,7 @@ class MainSignUpPageWidget extends React.Component {
<FormField {...this.getInputProps(true)} label="Repeat Password" name="repeated-password" validation="REPEAT_PASSWORD" required/>
</div>
<div className="signup-widget__captcha">
<ReCAPTCHA sitekey="6LfM5CYTAAAAAGLz6ctpf-hchX2_l0Ge-Bn-n8wS" onChange={function () {}}/>
<Captcha ref="captcha"/>
</div>
<SubmitButton type="primary">SIGN UP</SubmitButton>
</Form>
@ -75,14 +77,20 @@ class MainSignUpPageWidget extends React.Component {
}
onLoginFormSubmit(formState) {
this.setState({
loading: true
});
const captcha = this.refs.captcha.getWrappedInstance();
API.call({
path: '/user/signup',
data: formState
}).then(this.onSignupSuccess.bind(this)).catch(this.onSignupFail.bind(this));
if (!captcha.getValue()) {
captcha.focus();
} else {
this.setState({
loading: true
});
API.call({
path: '/user/signup',
data: _.extend({captcha: captcha.getValue()}, formState)
}).then(this.onSignupSuccess.bind(this)).catch(this.onSignupFail.bind(this));
}
}
onSignupSuccess() {

View File

@ -3,6 +3,7 @@
.text-editor {
&__editor {
background-color: white;
border: 1px solid $grey;
border-radius: 3px;
padding: 8px;

View File

@ -1,13 +1,18 @@
module.exports = [
{
path: '/system/get-configs',
path: '/system/get-settings',
time: 1000,
response: function () {
return {
status: 'success',
data: {
'language': 'us',
'reCaptchaKey': '6LfM5CYTAAAAAGLz6ctpf-hchX2_l0Ge-Bn-n8wS'
'reCaptchaKey': '6LfM5CYTAAAAAGLz6ctpf-hchX2_l0Ge-Bn-n8wS',
'departments': [
'Sales Support',
'Technical Issues',
'System and Administration'
]
}
};
}

View File

@ -24,6 +24,14 @@ export default {
'ACCOUNT_DESCRIPTION': 'All your tickets are stored in your accounts\'s profile. Keep track off all your tickets you send to our staff team.',
'SUPPORT_CENTER': 'Support Center',
'SUPPORT_CENTER_DESCRIPTION': 'Welcome to our support center. You can contact us through a tickets system. Your tickets will be answered by our staff.',
'DEPARTMENT': 'Department',
'AUTHOR': 'Author',
'DATE': 'Date',
'RESPOND': 'Respond',
'RESPOND_TICKET': 'Respond Ticket',
'NO_ATTACHMENT': 'No file attachment',
'STAFF': 'Staff',
'CUSTOMER': 'Customer',
//ERRORS
'EMAIL_NOT_EXIST': 'Email does not exist',

View File

@ -6,7 +6,7 @@
<meta name="description" content="">
<meta name="viewport" content="width=device-width">
<title>App Name</title>
<title>OS4</title>
<link rel="stylesheet" href="/css/main.css">
</head>

View File

@ -42,6 +42,10 @@ class SessionStore {
return JSON.parse(this.getItem('userData'));
}
getDepartments() {
return JSON.parse(this.getItem('departments'));
}
storeRememberData({token, userId, expiration}) {
this.setItem('rememberData-token', token);
this.setItem('rememberData-userId', userId);
@ -51,19 +55,17 @@ class SessionStore {
storeConfigs(configs) {
this.setItem('language', configs.language);
this.setItem('reCaptchaKey', configs.reCaptchaKey);
this.setItem('departments', JSON.stringify(configs.departments));
}
getConfigs() {
return {
language: this.getItem('language'),
reCaptchaKey: this.getItem('reCaptchaKey')
reCaptchaKey: this.getItem('reCaptchaKey'),
departments: this.getDepartments()
};
}
areConfigsStored() {
return !!this.getItem('reCaptchaKey');
}
isRememberDataExpired() {
let rememberData = this.getRememberData();

View File

@ -6,6 +6,8 @@ $primary-blue: #414A59;
$secondary-blue: #20B8c5;
$primary-green: #82CA9C;
$very-light-grey: #F7F7F7;
$light-grey: #EEEEEE;
$grey: #E7E7E7;
$medium-grey: #D9D9D9;

View File

@ -3,7 +3,8 @@
"slim/slim": "~2.0",
"gabordemooij/redbean": "~4.2",
"respect/validation": "^1.1",
"phpmailer/phpmailer": "^5.2"
"phpmailer/phpmailer": "^5.2",
"google/recaptcha": "~1.1"
},
"require-dev": {
"phpunit/phpunit": "5.0.*"

View File

@ -1,9 +1,11 @@
<?php
require_once 'system/init-settings.php';
require_once 'system/get-settings.php';
$systemControllerGroup = new ControllerGroup();
$systemControllerGroup->setGroupPath('/system');
$systemControllerGroup->addController(new InitSettingsController);
$systemControllerGroup->addController(new GetSettingsController);
$systemControllerGroup->finalize();

View File

@ -0,0 +1,20 @@
<?php
class GetSettingsController extends Controller {
const PATH = '/get-settings';
public function validations() {
return [
'permission' => 'any',
'requestData' => []
];
}
public function handler() {
Response::respondSuccess([
'language' => Setting::getSetting('language')->getValue(),
'reCaptchaKey' => Setting::getSetting('recaptcha-public')->getValue(),
'departments' => Department::getDepartmentNames()
]);
}
}

View File

@ -25,6 +25,8 @@ class InitSettingsController extends Controller {
private function storeGlobalSettings() {
$this->storeSettings([
'language' => 'en',
'recaptcha-public' => '',
'recaptcha-private' => '',
'no-reply-email' => 'noreply@opensupports.com',
'smtp-host' => 'localhost',
'smtp-port' => 7070,

View File

@ -1,6 +1,7 @@
<?php
use Respect\Validation\Validator as DataValidator;
DataValidator::with('CustomValidations', true);
class SignUpController extends Controller {
const PATH = '/signup';
@ -24,6 +25,10 @@ class SignUpController extends Controller {
'password' => [
'validation' => DataValidator::length(5, 200),
'error' => ERRORS::INVALID_PASSWORD
],
'captcha' => [
'validation' => DataValidator::captcha(),
'error' => ERRORS::INVALID_CAPTCHA
]
]
];

View File

@ -13,4 +13,5 @@ class ERRORS {
const INVALID_TICKET = 'Invalid ticket';
const INIT_SETTINGS_DONE = 'Settings already initialized';
const INVALID_OLD_PASSWORD = 'Invalid old password';
const INVALID_CAPTCHA = 'Invalid captcha';
}

View File

@ -40,6 +40,7 @@ spl_autoload_register(function ($class) {
//Load custom validations
include_once 'libs/validations/dataStoreId.php';
include_once 'libs/validations/userEmail.php';
include_once 'libs/validations/captcha.php';
// LOAD CONTROLLERS
foreach (glob('controllers/*.php') as $controller) {

View File

@ -0,0 +1,19 @@
<?php
namespace CustomValidations;
use Respect\Validation\Rules\AbstractRule;
class Captcha extends AbstractRule {
public function validate($reCaptchaResponse) {
$reCaptchaPrivateKey = \Setting::getSetting('recaptcha-private')->getValue();
if (!$reCaptchaPrivateKey) return true;
$reCaptcha = new \ReCaptcha\ReCaptcha($reCaptchaPrivateKey);
$reCaptchaValidation = $reCaptcha->verify($reCaptchaResponse, $_SERVER['REMOTE_ADDR']);
return $reCaptchaValidation->isSuccess();
}
}

View File

@ -1,4 +1,5 @@
<?php
use RedBeanPHP\Facade as RedBean;
class Department extends DataStore {
const TABLE = 'department';
@ -9,4 +10,15 @@ class Department extends DataStore {
'sharedTicketList'
];
}
public static function getDepartmentNames() {
$departmentsQuantity = RedBean::count(Department::TABLE);
$departmentsNameList = [];
for ($departmentIndex = 1; $departmentIndex <= $departmentsQuantity; ++$departmentIndex) {
$departmentsNameList[] = Department::getDataStore($departmentIndex)->name;
}
return $departmentsNameList;
}
}

View File

@ -1,2 +1,3 @@
phpunit --colors tests/models
phpunit --colors tests/controllers
phpunit --colors tests/controllers
phpunit --colors tests/libs

View File

@ -1,6 +1,7 @@
<?php
include_once 'tests/__lib__/Mock.php';
class BeanMock implements ArrayAccess {
class BeanMock extends \Mock implements ArrayAccess {
private $properties;
public function __construct($array = []) {

View File

@ -0,0 +1,24 @@
<?php
namespace ReCaptcha {
include_once 'tests/__lib__/Mock.php';
class ReCaptcha extends \Mock {
public static $functionList = array();
public static $staticVerify;
public $verify;
public static function initVerify($value = true) {
self::$staticVerify = \Mock::stub()->returns(new \Mock([
'isSuccess' => \Mock::stub()->returns($value)
]));
}
public function __construct($privateKey) {
parent::__construct();
$this->privateKey = $privateKey;
$this->verify = self::$staticVerify;
}
}
}

View File

@ -0,0 +1,5 @@
<?php
namespace Respect\Validation\Rules {
class AbstractRule {}
}

View File

@ -20,6 +20,7 @@ class Setting extends \Mock {
$mockUserInstance->name = 'MOCK_SETTING_NAME';
$mockUserInstance->value = 'MOCK_SETTING_VALUE';
$mockUserInstance->getValue = \Mock::stub()->returns('MOCK_SETTING_VALUE');
return $mockUserInstance;
}

View File

@ -0,0 +1,35 @@
<?php
include_once 'tests/__lib__/Mock.php';
include_once 'tests/__mocks__/RespectMock.php';
include_once 'tests/__mocks__/SettingMock.php';
include_once 'tests/__mocks__/ReCaptchaMock.php';
include_once 'libs/validations/captcha.php';
class CaptchaValidationTest extends PHPUnit_Framework_TestCase {
protected function setUp() {
Setting::initStubs();
\ReCaptcha\ReCaptcha::initVerify();
$_SERVER['REMOTE_ADDR'] = 'MOCK_REMOTE';
}
public function testShouldReturnCorrectValue() {
$captchaValidation = new \CustomValidations\Captcha();
$response = $captchaValidation->validate('MOCK_RESPONSE');
$this->assertTrue($response);
\ReCaptcha\ReCaptcha::initVerify(false);
$response = $captchaValidation->validate('MOCK_RESPONSE');
$this->assertFalse($response);
}
public function testShouldPassCorrectValuesToCaptcha() {
$captchaValidation = new \CustomValidations\Captcha();
$captchaValidation->validate('MOCK_RESPONSE');
$this->assertTrue(Setting::get('getSetting')->hasBeenCalledWithArgs('recaptcha-private'));
$this->assertTrue(\ReCaptcha\ReCaptcha::$staticVerify->hasBeenCalledWithArgs('MOCK_RESPONSE', 'MOCK_REMOTE'));
}
}

View File

@ -10,6 +10,7 @@ require './scripts.rb'
# TESTS
require './system/init-settings.rb'
require './system/get-settings.rb'
require './user/signup.rb'
require './user/login.rb'
require './user/send-recover-password.rb'

View File

@ -1,2 +1,4 @@
./clean_db.sh
./clean_db.sh
./clean_db.sh
bacon init.rb

View File

@ -0,0 +1,11 @@
describe '/system/get-settings' do
it 'should return correct values' do
result = request('/system/get-settings')
(result['status']).should.equal('success')
(result['data']['language']).should.equal('en')
(result['data']['departments'][0]).should.equal('Tech Support')
(result['data']['departments'][1]).should.equal('Suggestions')
(result['data']['departments'][2]).should.equal('Sales and Subscriptions')
end
end