Merge branch 'master' into master

This commit is contained in:
kirbypls 2021-09-20 14:08:30 -03:00 committed by GitHub
commit 9f17dea87c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
524 changed files with 49108 additions and 9946 deletions

184
.circleci/config.yml Normal file
View File

@ -0,0 +1,184 @@
version: 2.1
orbs:
php: circleci/php@1.0.2
node: circleci/node@1.1.6
aws-cli: circleci/aws-cli@1.0.0
jobs:
install_composer_packages:
executor: php/default
steps:
- checkout
- php/install-composer
- run:
name: Install php extensions
command: |
sudo add-apt-repository ppa:ondrej/php
sudo apt install php7.4-imap
- php/install-packages:
app-dir: server/
- persist_to_workspace:
root: .
paths:
- .
install_node_packages:
docker:
- image: circleci/node:11.15.0-stretch
steps:
- attach_workspace:
at: .
- restore_cache:
keys:
- node-cache-{{ checksum "client/package.json" }}
- run:
name: Install dependencies
command: |
sudo npm install -g npm@6.7.0
sudo npm install -g mocha@6.2.0
cd client && npm install
- save_cache:
paths:
- client/node_modules
key: node-cache-{{ checksum "client/package.json" }}
- persist_to_workspace:
root: .
paths:
- .
deploy_staging_files:
docker:
- image: circleci/node:11.15.0-stretch
environment:
- GIT_COMMIT_DESC: git log --format=oneline -n 1 $CIRCLE_SHA1
steps:
- attach_workspace:
at: .
- deploy:
name: Deploy staging files
command: |
if [ ! "$CIRCLE_BRANCH" = "master" ]; then exit 0; fi
if [[ "$GIT_COMMIT_DESC" = Release* ]]; then exit 0; fi
sudo apt update
sudo apt install -y lftp
make deploy-staging-files
make deploy-staging-population
add_release_commit:
docker:
- image: circleci/node:11.15.0-stretch
parameters:
version:
type: string
default: ""
steps:
- attach_workspace:
at: .
- add_ssh_keys:
fingerprints:
- "45:1e:cf:38:3f:9f:97:87:5b:b8:fd:e1:6c:71:11:41"
- run:
name: Commit new version
command: |
export VERSION=<< parameters.version >>
cd version_upgrades/release_script
npm i
npm run modify-files
cd ../..
ssh-keyscan -H github.com >> ~/.ssh/known_hosts
git config --global user.email "ivan@opensupports.com"
git config --global user.name "CircleCI-BOT"
git add .
git commit -m "Release $VERSION"
git checkout -b release-${VERSION}
git push origin release-${VERSION}
- persist_to_workspace:
root: .
paths:
- .
add_release_tag:
docker:
- image: circleci/node:11.15.0-stretch
parameters:
version:
type: string
default: ""
steps:
- attach_workspace:
at: .
- add_ssh_keys:
fingerprints:
- "45:1e:cf:38:3f:9f:97:87:5b:b8:fd:e1:6c:71:11:41"
- run:
name: Add Release tag
command: |
export VERSION=<< parameters.version >>
sudo apt-get update
sudo apt-get install lftp
make build-release-bundles
make upload-bundles
# make push-prerelease-tag
make populate-staging-release
- persist_to_workspace:
root: .
paths:
- .
parameters:
version:
type: string
default: ""
run_build:
type: boolean
default: true
workflows:
build:
when:
and:
- equal: [ master, << pipeline.git.branch >> ]
- << pipeline.parameters.run_build >>
jobs:
- install_composer_packages
- install_node_packages:
requires:
- install_composer_packages
- deploy_staging_files:
requires:
- install_node_packages
release:
when: << pipeline.parameters.version >>
jobs:
- install_composer_packages
- install_node_packages:
requires:
- install_composer_packages
- add_release_commit:
version: << pipeline.parameters.version >>
requires:
- install_node_packages
- add_release_tag:
version: << pipeline.parameters.version >>
requires:
- add_release_commit

5
.gitignore vendored
View File

@ -1,8 +1,6 @@
.idea
.jshintrc
client/package-lock.json
tests/Gemfile.lock
server/composer.lock
.DS_Store
server/vendor
server/files/
!server/files/.gitkeep
@ -11,3 +9,4 @@ server/.dbdata
server/.fakemail
server/apidoc
dist/
.env

View File

@ -10,15 +10,16 @@ services:
- mysql
before_install:
- rvm use 2.2 --install --binary --fuzzy
- rvm use 2.3 --install --binary --fuzzy
- ruby --version
- mysql -e 'CREATE DATABASE development;'
- nvm install 6.14.4
- npm install -g npm@6.1.0
- npm install -g mocha
- npm install -g mocha@6.2.0
- cd client
- npm install
- cd ../tests
- gem install bundler
- bundle install
- gem install bacon
- cd ../server

67
Makefile Normal file
View File

@ -0,0 +1,67 @@
#!make
-include .env
deploy-staging-files:
./build.sh
mv dist/opensupports_dev.zip .
make upload-bundles
deploy-staging-population:
curl --request POST \
--url https://circleci.com/api/v2/project/github/opensupports/staging-population/pipeline \
--header 'Circle-Token: ${CIRCLE_API_USER_TOKEN}' \
--header 'content-type: application/json' \
--data '{"branch":"master","parameters":{"server_to_deploy": "dev1"}}'
curl --request POST \
--url https://circleci.com/api/v2/project/github/opensupports/staging-population/pipeline \
--header 'Circle-Token: ${CIRCLE_API_USER_TOKEN}' \
--header 'content-type: application/json' \
--data '{"branch":"master","parameters":{"server_to_deploy": "dev2"}}'
curl --request POST \
--url https://circleci.com/api/v2/project/github/opensupports/staging-population/pipeline \
--header 'Circle-Token: ${CIRCLE_API_USER_TOKEN}' \
--header 'content-type: application/json' \
--data '{"branch":"master","parameters":{"server_to_deploy": "dev3"}}'
build-release-bundles:
$(eval UPGRADE_ZIP="opensupports_v$(VERSION)_update.zip")
./build.sh
mv dist/opensupports_dev.zip .
cp opensupports_dev.zip ${UPGRADE_ZIP} && \
mv opensupports_dev.zip opensupports_v${VERSION}.zip && \
zip -d ${UPGRADE_ZIP} "api/config.php" && \
(( \
zip -r ${UPGRADE_ZIP} "version_upgrades/${VERSION}" && \
zip -r ${UPGRADE_ZIP} "version_upgrades/mysql_connect.php" \
) || true)
upload-bundles:
for file in *.zip ; do \
lftp -c "open -u $(FTP_USER),$(FTP_PASSWORD) $(FTP_HOST); set ssl:verify-certificate no; put -O /files/ $${file}"; \
done
push-prerelease-tag:
echo -e "Release v${VERSION}\n====\n" > log.txt && \
git log $(git describe --tags --abbrev=0 @^)..@ --pretty=format:'%s' >> log.txt
# ./version_upgrades/release_script/node_modules/.bin/github-release upload \
# --owner opensupports \
# --repo opensupports \
# --draft true\
# --tag "v$(VERSION)" \
# --release-name "Release v$(VERSION)" \
# --body "$(<log.txt)" \
# opensupports_v${VERSION}.zip opensupports_v${VERSION}_update.zip
populate-staging-release:
curl --request POST \
--url https://circleci.com/api/v2/project/github/opensupports/staging-population/pipeline \
--header 'Circle-Token: ${CIRCLE_API_USER_TOKEN}' \
--header 'content-type: application/json' \
--data '{"branch":"master","parameters":{"server_to_deploy": "westeros", "version_to_deploy": "${VERSION}"}}'
curl --request POST \
--url https://circleci.com/api/v2/project/github/opensupports/staging-population/pipeline \
--header 'Circle-Token: ${CIRCLE_API_USER_TOKEN}' \
--header 'content-type: application/json' \
--data '{"branch":"master","parameters":{"server_to_deploy": "senate", "version_to_deploy": "${VERSION}_update"}}'
deploy-staging-release: build-release-bundles upload-bundles populate-staging-release

View File

@ -1,6 +1,6 @@
![OpenSupports](http://www.opensupports.com/logo.png)
[![Build Status](https://travis-ci.org/opensupports/opensupports.svg?branch=master)](https://travis-ci.org/opensupports/opensupports) v4.3.2
[![Build Status](https://travis-ci.org/opensupports/opensupports.svg?branch=master)](https://travis-ci.org/opensupports/opensupports) v4.9.0
OpenSupports is an open source ticket system built primarily with PHP and ReactJS.
Please, visit our website for more information: [http://www.opensupports.com/](http://www.opensupports.com/)
@ -20,28 +20,25 @@ Here is a guide of how to set up the development environment in OpenSupports.
- `curl -sL https://deb.nodesource.com/setup_4.x | sudo -E bash -`
- `sudo apt-get install -y nodejs`
4. Install npm: `sudo apt-get install npm`
5. Install gulp: `sudo npm install -g gulp`
6. Go to client: `cd opensupports/client`
7. Install dependencies: `npm install`
8. Rebuild node-sass: `npm rebuild node-sass`
9. Run: `gulp dev`
5. Go to client: `cd opensupports/client`
6. Install dependencies: `npm install`
7. Rebuild node-sass: `npm rebuild node-sass`
8. Run: `npm start` (PHP server api it must be running at :8080)
10. Go to the main app: `http://localhost:3000/app` or to the component demo `http://localhost:3000/demo`
11. Your browser will automatically be opened and directed to the browser-sync proxy address.
12. Use `gulp dev --api` to disable fixtures and use the real PHP server api (it must be running at :8080).
12. Use `npm start-fixtures` to enable fixtures and not require php server to be running.
Now that `gulp dev` is running, the server is up as well and serving files from the `/build` directory. Any changes in the `/src` directory will be automatically processed by Gulp and the changes will be injected to any open browsers pointed at the proxy address.
OpenSupport uses by default the port 3000, but this port could already be used. If this is the case, you can modify this in the file: `client/gulp/config.js`.
OpenSupport uses by default the port 3000, but this port could already be used. If this is the case, you can modify this in the file: `client/webpack.config.js`.
##### Production Task
Just as there is a `gulp dev` task for development, there is also a `gulp prod` task for putting the project into a production-ready state. This will run each of the tasks, while also adding the image minification task discussed above.
Just as there is a task for development, there is also a `npm build` task for putting the project into a production-ready state. This will run each of the tasks, while also adding the image minification task discussed above and the result store in `dist/` folder.
**Reminder:** Notice there is `index.html` and `index.php`. The first one searches the backend server where `config.js` says it, the second one uses `/api` to find the server. If you want to run OpenSupports in a single server, then use `index.php`.
#### Frontend Unit Testing
1. Do the steps described before.
2. Install mocha: `sudo npm install -g mocha`
2. Install mocha: `npm install -g mocha@6.2.0`
3. Run `npm test` to run the tests.
### Getting up and running BACK-END (server folder)
@ -68,7 +65,7 @@ Once you've installed dependencies for frontend and backend, you can run `./buil
##### BACKEND API RUBY TESTING
1. Go to tests folder: `cd opensupports/tests`
2. Run `make install` to install ruby and its the required dependencies
2. Run `make build` to install ruby container and its required dependencies
- `make run` for running tests (database will be cleared)
- `make clear` for clearing database

View File

@ -1,39 +1,37 @@
echo "1/3 Building frontend..."
cd client
gulp prod --api
npm run build
rm build/index.html
echo "2/3 Creating api folder..."
cd ../server
echo -n > config.php
mkdir files2
mv files/.htaccess files2
rm -rf files/
mv files2 files
cd ..
mkdir api
cp server/index.php api
cp server/.htaccess api
cp server/composer.json api
cp server/composer.lock api
cp -R server/controllers api
cp -R server/data api
cp -R server/libs api
cp -R server/models api
cp -R server/vendor api
cp -R server/files api
echo -n > api/config.php
mv server/index.php api
mv server/.htaccess api
mv server/composer.json api
mv server/composer.lock api
mv server/controllers api
mv server/data api
mv server/libs api
mv server/models api
mv server/vendor api
mv server/files api
cp server/config.php api
chmod -R 755 .
cp client/src/index.php client/build
echo "3/3 Generating zip..."
cd client/build
zip opensupports_dev.zip index.php
zip -u opensupports_dev.zip .htaccess
zip -u opensupports_dev.zip css/main.css
zip -u opensupports_dev.zip js/main.js
zip -ur opensupports_dev.zip fonts
zip -u opensupports_dev.zip bundle.js
zip -ur opensupports_dev.zip images
mv opensupports_dev.zip ../..
cd ../..
zip -ur opensupports_dev.zip api
rm -rf dist
mkdir dist
mv opensupports_dev.zip dist
rm -rf api

View File

@ -1,3 +1,4 @@
{
"optional": ["es7.classProperties"]
}
"presets": ["@babel/preset-env", "@babel/preset-react"],
"plugins": ["@babel/plugin-proposal-class-properties", "add-module-exports"]
}

View File

@ -1,31 +0,0 @@
'use strict';
module.exports = {
'serverport': 3006,
'scripts': {
'src': './src/*.js',
'dest': './build/js/'
},
'images': {
'src': './src/assets/images/**/*.{jpeg,jpg,png}',
'dest': './build/images/'
},
'styles': {
'src': './src/**/*.scss',
'dest': './build/css/'
},
'fonts': {
'src': './src/scss/font_awesome/fonts/*',
'dest': './build/fonts/'
},
'sourceDir': './src/',
'buildDir': './build/'
};

View File

@ -1,11 +0,0 @@
'use strict';
var fs = require('fs');
var onlyScripts = require('./util/script-filter');
var tasks = fs.readdirSync('./gulp/tasks/').filter(onlyScripts);
process.env.NODE_PATH = './src';
tasks.forEach(function(task) {
require('./tasks/' + task);
});

View File

@ -1,14 +0,0 @@
'use strict';
var config = require('../config');
var browserSync = require('browser-sync');
var gulp = require('gulp');
gulp.task('browserSync', function() {
browserSync({
proxy: 'localhost:' + config.serverport,
startPath: '/'
});
});

View File

@ -1,78 +0,0 @@
'use strict';
var gulp = require('gulp');
var gulpif = require('gulp-if');
var gutil = require('gulp-util');
var source = require('vinyl-source-stream');
var streamify = require('gulp-streamify');
var sourcemaps = require('gulp-sourcemaps');
var rename = require('gulp-rename');
var watchify = require('watchify');
var browserify = require('browserify');
var babelify = require('babelify');
var uglify = require('gulp-uglify');
var browserSync = require('browser-sync');
var debowerify = require('debowerify');
var handleErrors = require('../util/handle-errors');
var config = require('../config');
var util = require('gulp-util');
// Based on: http://blog.avisi.nl/2014/04/25/how-to-keep-a-fast-build-with-browserify-and-reactjs/
function buildScript(file, watch) {
var bundler = browserify({
entries: [config.sourceDir + file],
debug: !global.isProd,
insertGlobalVars: {
isProd: function () {
return (global.isProd) ? "'enabled'" : "'disabled'";
},
noFixtures: function() {
return (util.env['api']) ? "'enabled'" : "'disabled'";
}
},
cache: {},
packageCache: {},
fullPaths: false
});
if ( watch ) {
bundler = watchify(bundler);
bundler.on('update', rebundle);
}
bundler.transform(babelify, {'optional': ['es7.classProperties']});
bundler.transform(debowerify);
function rebundle() {
var stream = bundler.bundle();
gutil.log('Rebundle...');
return stream.on('error', handleErrors)
.pipe(source(file))
.pipe(gulpif(global.isProd, streamify(uglify())))
.pipe(streamify(rename({
basename: 'main'
})))
.pipe(gulpif(!global.isProd, sourcemaps.write('./')))
.pipe(gulp.dest(config.scripts.dest))
.pipe(gulpif(browserSync.active, browserSync.reload({ stream: true, once: true })));
}
return rebundle();
}
gulp.task('browserify', function() {
// Only run watchify if NOT production
return buildScript('index.js', !global.isProd);
});
gulp.task('config', function() {
return gulp.src(config.sourceDir + 'config.js')
.pipe(gulp.dest(config.scripts.dest))
});

View File

@ -1,11 +0,0 @@
'use strict';
var config = require('../config');
var gulp = require('gulp');
var del = require('del');
gulp.task('clean', function(cb) {
del([config.buildDir], cb);
});

View File

@ -1,10 +0,0 @@
'use strict';
var gulp = require('gulp');
var config = require('../config');
gulp.task('copyFonts', function() {
return gulp.src(config.fonts.src)
.pipe(gulp.dest(config.fonts.dest))
});

View File

@ -1,11 +0,0 @@
'use strict';
var gulp = require('gulp');
gulp.task('copyIcons', function() {
// Copy icons from root directory to build/
return gulp.src(['./*.png', './favicon.ico'])
.pipe(gulp.dest('build/'));
});

View File

@ -1,12 +0,0 @@
'use strict';
var gulp = require('gulp');
var config = require('../config');
gulp.task('copyIndex', function() {
gulp.src(config.sourceDir + 'index.html').pipe(gulp.dest(config.buildDir));
gulp.src(config.sourceDir + 'index.php').pipe(gulp.dest(config.buildDir));
gulp.src(config.sourceDir + '.htaccess').pipe(gulp.dest(config.buildDir));
});

View File

@ -1,10 +0,0 @@
'use strict';
var gulp = require('gulp');
//var config = require('../config');
gulp.task('deploy', ['prod'], function() {
// Deploy to hosting environment
});

View File

@ -1,15 +0,0 @@
'use strict';
var gulp = require('gulp');
var runSequence = require('run-sequence');
gulp.task('dev', ['clean'], function(callback) {
callback = callback || function() {};
global.isProd = false;
// Run all tasks once
return runSequence(['sass', 'imagemin', 'browserify', 'copyFonts', 'copyIndex', 'copyIcons', 'config'], 'watch', callback);
});

View File

@ -1,17 +0,0 @@
'use strict';
var gulp = require('gulp');
var gulpif = require('gulp-if');
var imagemin = require('gulp-imagemin');
var browserSync = require('browser-sync');
var config = require('../config');
gulp.task('imagemin', function() {
// Run imagemin task on all images
return gulp.src(config.images.src)
.pipe(gulpif(global.isProd, imagemin()))
.pipe(gulp.dest(config.images.dest))
.pipe(gulpif(browserSync.active, browserSync.reload({ stream: true, once: true })));
});

View File

@ -1,15 +0,0 @@
'use strict';
var gulp = require('gulp');
var runSequence = require('run-sequence');
gulp.task('prod', ['clean'], function(callback) {
process.env.NODE_ENV = 'production';
callback = callback || function() {};
global.isProd = true;
runSequence(['sass', 'imagemin', 'browserify', 'copyFonts', 'copyIndex', 'copyIcons'], callback);
});

View File

@ -1,29 +0,0 @@
'use strict';
var gulp = require('gulp');
var sass = require('gulp-sass');
var gulpif = require('gulp-if');
var browserSync = require('browser-sync');
var autoprefixer = require('gulp-autoprefixer');
var bulkSass = require('gulp-sass-bulk-import');
var plumber = require('gulp-plumber');
var handleErrors = require('../util/handle-errors');
var config = require('../config');
gulp.task('sass', function () {
return gulp.src(config.styles.src)
.pipe(bulkSass())
.pipe(plumber())
.pipe(sass({
sourceComments: global.isProd ? 'none' : 'map',
sourceMap: 'sass',
outputStyle: global.isProd ? 'compressed' : 'nested'
}))
.on('error', handleErrors)
.pipe(autoprefixer("last 2 versions", "> 1%", "ie 8"))
.on('error', handleErrors)
.pipe(gulp.dest(config.styles.dest))
.pipe(gulpif(browserSync.active, browserSync.reload({ stream: true })));
});

View File

@ -1,44 +0,0 @@
'use strict';
var config = require('../config');
var http = require('http');
var express = require('express');
var gulp = require('gulp');
var gutil = require('gulp-util');
var morgan = require('morgan');
var proxy = require('express-http-proxy');
gulp.task('server', function() {
var server = express();
// log all requests to the console
server.use(morgan('dev'));
server.use(express.static(config.buildDir));
// Proxy php server api
server.use('/api', proxy('http://localhost:8080', {
forwardPath: function(req, res) {
return require('url').parse(req.url).path;
}
}));
// Serve index.html for all routes to leave routing up to react-router
server.all('/*', function(req, res) {
res.sendFile('index.html', { root: 'build' });
});
// Start webserver if not already running
var s = http.createServer(server);
s.on('error', function(err){
if(err.code === 'EADDRINUSE'){
gutil.log('Development server is already started at port ' + config.serverport);
}
else {
throw err;
}
});
s.listen(config.serverport);
});

View File

@ -1,10 +0,0 @@
'use strict';
var gulp = require('gulp');
//var config = require('../config');
gulp.task('test', function() {
// Run all tests
});

View File

@ -1,13 +0,0 @@
'use strict';
var gulp = require('gulp');
var config = require('../config');
gulp.task('watch', ['browserSync', 'server'], function() {
// Scripts are automatically watched by Watchify inside Browserify task
gulp.watch(config.styles.src, ['sass']);
gulp.watch(config.images.src, ['imagemin']);
gulp.watch(config.sourceDir + 'index.html', ['copyIndex']);
});

View File

@ -1,27 +0,0 @@
'use strict';
var notify = require('gulp-notify');
module.exports = function(error) {
if( !global.isProd ) {
var args = Array.prototype.slice.call(arguments);
// Send error to notification center with gulp-notify
notify.onError({
title: 'Compile Error',
message: '<%= error.message %>'
}).apply(this, args);
// Keep gulp from hanging on this task
this.emit('end');
} else {
// Log the error and stop the process
// to prevent broken code from building
console.log(error);
process.exit(1);
}
};

View File

@ -1,9 +0,0 @@
'use strict';
var path = require('path');
// Filters out non .coffee and .js files. Prevents
// accidental inclusion of possible hidden files
module.exports = function(name) {
return /(\.(js|coffee)$)/i.test(path.extname(name));
};

View File

@ -1,5 +0,0 @@
'use strict';
global.isProd = false;
require('./gulp');

21684
client/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
{
"name": "OpenSupports",
"version": "4.2.0",
"version": "4.9.0",
"author": "Ivan Diaz <contact@opensupports.com>",
"description": "Open source ticket system made with PHP and ReactJS",
"repository": {
@ -13,65 +13,78 @@
"npm": "^2.1.x"
},
"scripts": {
"test": "export NODE_PATH=src && mocha src/lib-test/preprocessor.js --compilers js:babel-core/register --recursive src/**/**/__tests__/*-test.js"
"start": "webpack-dev-server",
"start-fixtures": "webpack-dev-server --env.FIXTURES=1",
"build": "./node_modules/.bin/rimraf build && NODE_ENV=production ./node_modules/.bin/webpack -p --devtool none",
"test": "export NODE_PATH=src && mocha src/lib-test/preprocessor.js --require @babel/register --recursive src/**/**/__tests__/*-test.js"
},
"devDependencies": {
"@babel/cli": "^7.5.5",
"@babel/core": "^7.5.5",
"@babel/node": "^7.5.5",
"@babel/plugin-proposal-class-properties": "^7.4.4",
"@babel/plugin-transform-modules-commonjs": "^7.5.0",
"@babel/preset-env": "^7.5.5",
"@babel/preset-react": "^7.0.0",
"@babel/register": "^7.5.5",
"axios-mock-adapter": "^1.15.0",
"babel-core": "^5.8.22",
"babel-plugin-transform-class-properties": "^6.11.5",
"babel-register": "^6.7.2",
"babelify": "^6.1.x",
"browser-sync": "^2.7.13",
"browserify": "^10.2.6",
"babel-loader": "^8.0.6",
"babel-plugin-add-module-exports": "^1.0.2",
"browser-sync": "^2.27.5",
"chai": "^3.5.0",
"copy-webpack-plugin": "^5.0.3",
"css-loader": "^3.0.0",
"debowerify": "^1.3.1",
"del": "^1.2.0",
"eslint": "^5.16.0",
"eslint-loader": "^2.1.2",
"express": "^4.13.1",
"express-http-proxy": "^0.6.0",
"gulp": "^3.9.0",
"gulp-autoprefixer": "^2.3.1",
"gulp-connect-php": "0.0.5",
"gulp-if": "^1.2.5",
"gulp-imagemin": "^2.3.0",
"gulp-notify": "^2.2.0",
"gulp-plumber": "^1.2.0",
"gulp-rename": "^1.2.2",
"gulp-sass": "^4.0.1",
"gulp-sass-bulk-import": "^1.0.1",
"gulp-sourcemaps": "^2.6.4",
"gulp-streamify": "0.0.5",
"gulp-uglify": "^2.1.2",
"gulp-util": "^3.0.6",
"file-loader": "^4.0.0",
"html-webpack-plugin": "^3.2.0",
"humps": "^0.6.0",
"jsdom": "^8.4.1",
"mocha": "^6.2.0",
"morgan": "^1.6.1",
"node-sass": "^4.12.0",
"nodemon": "^1.19.1",
"path": "^0.12.7",
"proxyquire": "^1.7.4",
"react-addons-test-utils": "^15.0.1",
"rimraf": "^2.6.3",
"run-sequence": "^1.1.1",
"sass-loader": "^7.1.0",
"sinon": "^1.17.3",
"sinon-chai": "^2.8.0",
"style-loader": "^0.23.1",
"stylelint-webpack-plugin": "^0.10.5",
"vinyl-source-stream": "^1.1.0",
"watchify": "^3.2.x"
"watchify": "^3.2.x",
"webpack": "^4.34.0",
"webpack-bundle-analyzer": "^3.3.2",
"webpack-cli": "^3.3.4",
"webpack-dev-server": "^3.7.1",
"webpack-import-glob": "^2.0.0"
},
"dependencies": {
"app-module-path": "^1.0.3",
"axios": "^0.18.0",
"chart.js": "^2.4.0",
"axios": "^0.21.1",
"chart.js": "^2.9.3",
"classnames": "^2.2.5",
"history": "^3.0.0",
"html-to-text": "^4.0.0",
"keycode": "^2.1.4",
"localStorage": "^1.0.3",
"lodash": "^3.10.0",
"lodash": "^4.17.21",
"messageformat": "^0.2.2",
"moment": "^2.27.0",
"qs": "^6.5.2",
"query-string": "^6.12.1",
"quill-image-resize-module-react": "^3.0.0",
"random-string": "^0.2.0",
"react": "^15.4.2",
"react-chartjs-2": "^2.0.0",
"react-chartjs-2": "^2.10.0",
"react-document-title": "^1.0.2",
"react-dom": "^15.4.2",
"react-draft-wysiwyg": "^1.12.13",
"react-google-recaptcha": "^0.5.2",
"react-motion": "^0.4.7",
"react-quill": "^1.3.1",

View File

@ -1,164 +1,160 @@
const sessionStoreMock = require('lib-app/__mocks__/session-store-mock');
const APICallMock = require('lib-app/__mocks__/api-call-mock');
const storeMock = {
dispatch: stub().returns({
then: stub()
})
};
// const sessionStoreMock = require('lib-app/__mocks__/session-store-mock');
// const APICallMock = require('lib-app/__mocks__/api-call-mock');
// const storeMock = require('app/__mocks__/store-mock');
const SessionActions = requireUnit('actions/session-actions', {
'lib-app/api-call': APICallMock,
'lib-app/session-store': sessionStoreMock,
'app/store': storeMock
});
// const SessionActions = requireUnit('actions/session-actions', {
// 'lib-app/api-call': APICallMock,
// 'lib-app/session-store': sessionStoreMock,
// 'app/store': storeMock
// });
describe('Session Actions,', function () {
// describe('Session Actions,', function () {
describe('login action', function () {
it('should return LOGIN with with a result promise', function () {
APICallMock.call.returns({
then: function (resolve) {
resolve({
data: {
userId: 14,
token: 'SOME_TOKEN'
}
});
}
});
// describe('login action', function () {
// it('should return LOGIN with with a result promise', function () {
// APICallMock.call.returns({
// then: function (resolve) {
// resolve({
// data: {
// userId: 14,
// token: 'SOME_TOKEN'
// }
// });
// }
// });
let loginData = {
email: 'SOME_EMAIL',
password: 'SOME_PASSWORD',
remember: false
};
// let loginData = {
// email: 'SOME_EMAIL',
// password: 'SOME_PASSWORD',
// remember: false
// };
expect(SessionActions.login(loginData).type).to.equal('LOGIN');
expect(storeMock.dispatch).to.have.been.calledWithMatch({type: 'USER_DATA'});
expect(APICallMock.call).to.have.been.calledWith({
path: '/user/get',
data: {
csrf_userid: 14,
csrf_token: 'SOME_TOKEN'
}
});
});
});
// expect(SessionActions.login(loginData).type).to.equal('LOGIN');
// expect(storeMock.dispatch).to.have.been.calledWithMatch({type: 'USER_DATA'});
// expect(APICallMock.call).to.have.been.calledWith({
// path: '/user/get',
// data: {
// csrf_userid: 14,
// csrf_token: 'SOME_TOKEN'
// }
// });
// });
// });
describe('autoLogin action', function () {
it('should return LOGIN_AUTO with remember data from sessionStore', function () {
APICallMock.call.returns({
then: function (resolve) {
resolve({
data: {
userId: 14
}
});
}
});
sessionStoreMock.getRememberData.returns({
token: 'SOME_TOKEN',
userId: 'SOME_ID',
expiration: 'SOME_EXPIRATION'
});
// describe('autoLogin action', function () {
// it('should return LOGIN_AUTO with remember data from sessionStore', function () {
// APICallMock.call.returns({
// then: function (resolve) {
// resolve({
// data: {
// userId: 14
// }
// });
// }
// });
// sessionStoreMock.getRememberData.returns({
// token: 'SOME_TOKEN',
// userId: 'SOME_ID',
// expiration: 'SOME_EXPIRATION'
// });
expect(SessionActions.autoLogin().type).to.equal('LOGIN_AUTO');
expect(storeMock.dispatch).to.have.been.calledWithMatch({type: 'USER_DATA'});
expect(APICallMock.call).to.have.been.calledWith({
path: '/user/login',
data: {
rememberToken: 'SOME_TOKEN',
userId: 'SOME_ID',
remember: 1,
isAutomatic: 1
}
});
});
});
// expect(SessionActions.autoLogin().type).to.equal('LOGIN_AUTO');
// expect(storeMock.dispatch).to.have.been.calledWithMatch({type: 'USER_DATA'});
// expect(APICallMock.call).to.have.been.calledWith({
// path: '/user/login',
// data: {
// rememberToken: 'SOME_TOKEN',
// userId: 'SOME_ID',
// remember: 1,
// isAutomatic: 1
// }
// });
// });
// });
describe('logout action', function () {
it('should return LOGOUT and call /user/logout', function () {
APICallMock.call.returns('API_RESULT');
APICallMock.call.reset();
// describe('logout action', function () {
// it('should return LOGOUT and call /user/logout', function () {
// APICallMock.call.returns('API_RESULT');
// APICallMock.call.reset();
expect(SessionActions.logout()).to.deep.equal({
type: 'LOGOUT',
payload: 'API_RESULT'
});
expect(APICallMock.call).to.have.been.calledWith({
path: '/user/logout',
data: {}
});
});
});
// expect(SessionActions.logout()).to.deep.equal({
// type: 'LOGOUT',
// payload: 'API_RESULT'
// });
// expect(APICallMock.call).to.have.been.calledWith({
// path: '/user/logout',
// data: {}
// });
// });
// });
describe('initSession action', function () {
beforeEach(function () {
APICallMock.call.returns({
then: function (resolve) {
resolve({
data: {
sessionActive: false
}
});
}
});
APICallMock.call.reset();
storeMock.dispatch.reset();
sessionStoreMock.isLoggedIn.returns(true)
});
// describe('initSession action', function () {
// beforeEach(function () {
// APICallMock.call.returns({
// then: function (resolve) {
// resolve({
// data: {
// sessionActive: false
// }
// });
// }
// });
// APICallMock.call.reset();
// storeMock.dispatch.reset();
// sessionStoreMock.isLoggedIn.returns(true)
// });
after(function () {
APICallMock.call.returns(new Promise(function (resolve) {
resolve({
data: {
sessionActive: true
}
});
}));
});
// after(function () {
// APICallMock.call.returns(new Promise(function (resolve) {
// resolve({
// data: {
// sessionActive: true
// }
// });
// }));
// });
it('should return CHECK_SESSION and dispatch SESSION_CHECKED if session is active', function () {
APICallMock.call.returns({
then: function (resolve) {
resolve({
data: {
sessionActive: true
}
});
}
});
// it('should return CHECK_SESSION and dispatch SESSION_CHECKED if session is active', function () {
// APICallMock.call.returns({
// then: function (resolve) {
// resolve({
// data: {
// sessionActive: true
// }
// });
// }
// });
expect(SessionActions.initSession().type).to.equal('CHECK_SESSION');
expect(storeMock.dispatch).to.have.been.calledWith({type: 'SESSION_CHECKED'});
expect(APICallMock.call).to.have.been.calledWith({
path: '/user/check-session',
data: {}
});
});
// expect(SessionActions.initSession().type).to.equal('CHECK_SESSION');
// expect(storeMock.dispatch).to.have.been.calledWith({type: 'SESSION_CHECKED'});
// expect(APICallMock.call).to.have.been.calledWith({
// path: '/user/check-session',
// data: {}
// });
// });
it('should return CHECK_SESSION and dispatch LOGOUT_FULFILLED if session is not active and no remember data', function () {
sessionStoreMock.isRememberDataExpired.returns(true);
// it('should return CHECK_SESSION and dispatch LOGOUT_FULFILLED if session is not active and no remember data', function () {
// sessionStoreMock.isRememberDataExpired.returns(true);
expect(SessionActions.initSession().type).to.equal('CHECK_SESSION');
expect(storeMock.dispatch).to.have.been.calledWith({type: 'LOGOUT_FULFILLED'});
expect(APICallMock.call).to.have.been.calledWith({
path: '/user/check-session',
data: {}
});
});
// expect(SessionActions.initSession().type).to.equal('CHECK_SESSION');
// expect(storeMock.dispatch).to.have.been.calledWith({type: 'LOGOUT_FULFILLED'});
// expect(APICallMock.call).to.have.been.calledWith({
// path: '/user/check-session',
// data: {}
// });
// });
it('should return CHECK_SESSION and dispatch LOGIN_AUTO if session is not active but remember data exists', function () {
sessionStoreMock.isRememberDataExpired.returns(false);
// it('should return CHECK_SESSION and dispatch LOGIN_AUTO if session is not active but remember data exists', function () {
// sessionStoreMock.isRememberDataExpired.returns(false);
expect(SessionActions.initSession().type).to.equal('CHECK_SESSION');
expect(storeMock.dispatch).to.not.have.been.calledWith({type: 'LOGOUT_FULFILLED'});
expect(APICallMock.call).to.have.been.calledWith({
path: '/user/check-session',
data: {}
});
// expect(SessionActions.initSession().type).to.equal('CHECK_SESSION');
// expect(storeMock.dispatch).to.not.have.been.calledWith({type: 'LOGOUT_FULFILLED'});
// expect(APICallMock.call).to.have.been.calledWith({
// path: '/user/check-session',
// data: {}
// });
expect(storeMock.dispatch).to.have.been.calledWith(SessionActions.autoLogin());
});
});
});
// expect(storeMock.dispatch).to.have.been.calledWith(SessionActions.autoLogin());
// });
// });
// });

View File

@ -12,22 +12,22 @@ export default {
};
},
retrieveMyTickets(page, closed = 0) {
retrieveMyTickets(page, closed = 0, departmentId = 0) {
return {
type: 'MY_TICKETS',
payload: API.call({
path: '/staff/get-tickets',
data: {page, closed}
data: {page, closed, departmentId}
})
};
},
retrieveNewTickets(page = 1) {
retrieveNewTickets(page = 1, departmentId = 0) {
return {
type: 'NEW_TICKETS',
payload: API.call({
path: '/staff/get-new-tickets',
data: {page}
data: {page, departmentId}
})
};
},

View File

@ -0,0 +1,15 @@
export default {
showLoginForm() {
return {
type: 'SHOW_LOGIN_FORM',
payload: true
};
},
hideLoginForm() {
return {
type: 'HIDE_LOGIN_FORM',
payload: false
};
}
};

View File

@ -0,0 +1,80 @@
import API from 'lib-app/api-call';
import searchTicketsUtils from 'lib-app/search-tickets-utils';
import history from 'lib-app/history';
export default {
setLoadingInTrue() {
return {
type: 'SEARCH_FILTERS_SET_LOADING_IN_TRUE',
payload: {}
}
},
retrieveSearchTickets(ticketQueryListState, filters = {}) {
return {
type: 'SEARCH_TICKETS',
payload: API.call({
path: '/ticket/search',
data: {
...filters,
page: ticketQueryListState.page
}
})
}
},
changeForm(form) {
return {
type: 'SEARCH_FILTERS_CHANGE_FORM',
payload: form
}
},
changeFilters(listConfig) {
const filtersForAPI = searchTicketsUtils.prepareFiltersForAPI(listConfig.filters);
return {
type: 'SEARCH_FILTERS_CHANGE_FILTERS',
payload: {...listConfig, filtersForAPI}
}
},
setDefaultFormValues() {
return {
type: 'SEARCH_FILTERS_SET_DEFAULT_FORM_VALUES',
payload: {}
}
},
changeShowFilters(showFilters) {
return {
type: 'SEARCH_FILTERS_CHANGE_SHOW_FILTERS',
payload: showFilters
}
},
changePage(filtersWithPage) {
const filtersForAPI = searchTicketsUtils.prepareFiltersForAPI(filtersWithPage);
const currentPath = window.location.pathname;
const urlQuery = searchTicketsUtils.getFiltersForURL({
filters: filtersForAPI,
shouldRemoveCustomParam: false,
shouldRemoveUseInitialValuesParam: true
});
urlQuery && history.push(`${currentPath}${urlQuery}`);
return {
type: 'SEARCH_FILTERS_CHANGE_PAGE',
payload: {...filtersWithPage, ...filtersForAPI}
}
},
changeOrderBy(filtersWithOrderBy) {
const filtersForAPI = searchTicketsUtils.prepareFiltersForAPI(filtersWithOrderBy);
const currentPath = window.location.pathname;
const urlQuery = searchTicketsUtils.getFiltersForURL({
filters: filtersForAPI,
shouldRemoveCustomParam: false,
shouldRemoveUseInitialValuesParam: true
});
urlQuery && history.push(`${currentPath}${urlQuery}`);
return {
type: 'SEARCH_FILTERS_CHANGE_ORDER_BY',
payload: {...filtersWithOrderBy, ...filtersForAPI}
}
},
};

View File

@ -2,6 +2,7 @@ import _ from 'lodash';
import API from 'lib-app/api-call';
import AdminDataActions from 'actions/admin-data-actions';
import ConfigActions from 'actions/config-actions';
import sessionStore from 'lib-app/session-store';
import store from 'app/store';
@ -16,11 +17,14 @@ export default {
path: '/user/login',
data: _.extend(loginData, {remember: loginData.remember * 1})
}).then((result) => {
store.dispatch(this.getUserData(result.data.userId, result.data.token, result.data.staff)).then(() => {
if(result.data.staff) {
store.dispatch(AdminDataActions.retrieveCustomResponses());
}
});
store.dispatch(this.getUserData(result.data.userId, result.data.token, result.data.staff))
.then(() => store.dispatch(ConfigActions.updateData()))
.then(() => {
if(result.data.staff) {
store.dispatch(AdminDataActions.retrieveCustomResponses());
store.dispatch(AdminDataActions.retrieveStaffMembers());
}
});
resolve(result);
}).catch((result) => {
@ -50,12 +54,12 @@ export default {
data: {
userId: rememberData.userId,
rememberToken: rememberData.token,
staff: rememberData.isStaff,
remember: 1,
isAutomatic: 1
}
}).then((result) => {
store.dispatch(this.getUserData(result.data.userId, result.data.token));
store.dispatch(this.getUserData(result.data.userId, result.data.token, result.data.staff));
return result;
})
};

View File

@ -12,7 +12,14 @@ const TicketList = requireUnit('app-components/ticket-list', {
'core-components/button': Button,
'core-components/tooltip': Tooltip,
'app-components/department-dropdown': DepartmentDropdown,
'lib-app/i18n': i18n
'lib-app/i18n': i18n,
'react-redux': {
connect: function() {
return function(param) {
return param;
}
}
},
});
describe('TicketList component', function () {
@ -28,11 +35,11 @@ describe('TicketList component', function () {
id: 1,
name: 'Sales Support'
},
priority: 'low',
author: {
id: 3,
name: 'Francisco Villegas'
}
},
tags: []
};
let list = _.range(5).map(() => ticket);
@ -50,7 +57,7 @@ describe('TicketList component', function () {
function renderTicketList(props = {}) {
ticketList = TestUtils.renderIntoDocument(
<TicketList tickets={tickets} {...props}/>
<TicketList tickets={tickets} {...props}></TicketList>
);
table = TestUtils.scryRenderedComponentsWithType(ticketList, Table);
@ -61,28 +68,28 @@ describe('TicketList component', function () {
renderTicketList();
expect(table[0].props.loading).to.equal(false);
expect(table[0].props.pageSize).to.equal(10);
expect(table[0].props.headers).to.deep.equal([
expect(table[0].props.headers[0]).to.deep.equal(
{
key: 'number',
value: i18n('NUMBER'),
className: 'ticket-list__number col-md-1'
},
});
expect(table[0].props.headers[1]).to.deep.equal(
{
key: 'title',
value: i18n('TITLE'),
className: 'ticket-list__title col-md-6'
},
});
expect(table[0].props.headers[2]).to.deep.equal(
{
key: 'department',
value: i18n('DEPARTMENT'),
className: 'ticket-list__department col-md-3'
},
{
key: 'date',
value: i18n('DATE'),
className: 'ticket-list__date col-md-2'
}
]);
});
expect(table[0].props.headers[3].key).to.equal('date');
expect(table[0].props.headers[3].value.props.children[0]).to.equal(i18n('DATE'));
expect(table[0].props.headers[3].value.props.children[1]).to.equal(null);
expect(table[0].props.headers[3].className).to.equal('ticket-list__date col-md-2');
});
it('should pass loading to Table', function () {
@ -90,31 +97,6 @@ describe('TicketList component', function () {
expect(table[0].props.loading).to.equal(true);
});
it('should pass correct compare function to Table', function () {
let minCompare = table[0].props.comp;
let row1 = {
closed: false,
unread: false,
date: '20160405'
};
let row2 = {
closed: false,
unread: false,
date: '20160406'
};
expect(minCompare(row1, row2)).to.equal(1);
row1.unread = true;
expect(minCompare(row1, row2)).to.equal(-1);
row2.unread = true;
expect(minCompare(row1, row2)).to.equal(1);
row2.date = '20160401';
expect(minCompare(row1, row2)).to.equal(-1);
});
describe('when using secondary type', function () {
beforeEach(function () {
renderTicketList({
@ -127,38 +109,34 @@ describe('TicketList component', function () {
});
it('should pass correct props to Table', function () {
expect(table[0].props.headers).to.deep.equal([
expect(table[0].props.headers[0]).to.deep.equal(
{
key: 'number',
value: i18n('NUMBER'),
className: 'ticket-list__number col-md-1'
},
});
expect(table[0].props.headers[1]).to.deep.equal(
{
key: 'title',
value: i18n('TITLE'),
className: 'ticket-list__title col-md-4'
},
{
key: 'priority',
value: i18n('PRIORITY'),
className: 'ticket-list__priority col-md-1'
},
});
expect(table[0].props.headers[2]).to.deep.equal(
{
key: 'department',
value: i18n('DEPARTMENT'),
className: 'ticket-list__department col-md-2'
},
});
expect(table[0].props.headers[3]).to.deep.equal(
{
key: 'author',
value: i18n('AUTHOR'),
className: 'ticket-list__author col-md-2'
},
{
key: 'date',
value: i18n('DATE'),
className: 'ticket-list__date col-md-2'
}
]);
});
expect(table[0].props.headers[4].key).to.equal('date');
expect(table[0].props.headers[4].value.props.children[0]).to.equal(i18n('DATE'));
expect(table[0].props.headers[4].value.props.children[1]).to.equal(null);
expect(table[0].props.headers[4].className).to.equal('ticket-list__date col-md-2');
});
it('should pass correct props to dropdown', function () {

View File

@ -18,10 +18,12 @@ class ActivityRow extends React.Component {
'CREATE_TICKET',
'RE_OPEN',
'DEPARTMENT_CHANGED',
'PRIORITY_CHANGED',
'EDIT_TITLE',
'EDIT_COMMENT',
'EDIT_SETTINGS',
'SIGNUP',
'INVITE',
'ADD_TOPIC',
'ADD_ARTICLE',
'DELETE_TOPIC',
@ -56,16 +58,16 @@ class ActivityRow extends React.Component {
'CREATE_TICKET',
'RE_OPEN',
'DEPARTMENT_CHANGED',
'PRIORITY_CHANGED'
'COMMENT_EDITED',
'EDIT_TITLE',
'EDIT_COMMENT',
];
return (
<div className="activity-row">
<Icon {...this.getIconProps()} className="activity-row__icon"/>
<span>
<Link className="activity-row__name-link" to={this.getNameLinkDestination()}>
{this.props.author.name}
</Link>
{this.renderAuthorName()}
</span>
<span className="activity-row__message"> {i18n('ACTIVITY_' + this.props.type)} </span>
{_.includes(ticketRelatedTypes, this.props.type) ? this.renderTicketNumber() : this.props.to}
@ -74,6 +76,18 @@ class ActivityRow extends React.Component {
);
}
renderAuthorName() {
let name = this.props.author.name;
if (this.props.author.id) {
name = <Link className="activity-row__name-link" to={this.getNameLinkDestination()}>
{this.props.author.name}
</Link>;
}
return name;
}
renderTicketNumber() {
let ticketNumber = (this.props.mode === 'staff') ? this.props.ticketNumber : this.props.to;
@ -99,10 +113,12 @@ class ActivityRow extends React.Component {
'CREATE_TICKET': 'ticket',
'RE_OPEN': 'unlock-alt',
'DEPARTMENT_CHANGED': 'exchange',
'PRIORITY_CHANGED': 'exclamation',
'EDIT_TITLE': 'edit',
'EDIT_COMMENT': 'edit',
'EDIT_SETTINGS': 'wrench',
'SIGNUP': 'user-plus',
'INVITE': 'user-plus',
'ADD_TOPIC': 'book',
'ADD_ARTICLE': 'book',
'DELETE_TOPIC': 'book',

View File

@ -5,8 +5,7 @@ import ModalContainer from 'app-components/modal-container';
import Button from 'core-components/button';
import Input from 'core-components/input';
import Icon from 'core-components/icon';
import Loading from 'core-components/loading'
class AreYouSure extends React.Component {
static propTypes = {
@ -24,13 +23,14 @@ class AreYouSure extends React.Component {
};
state = {
loading: false,
password: ''
};
static openModal(description, onYes, type = 'default') {
ModalContainer.openModal(
<AreYouSure description={description} onYes={onYes} type={type}/>,
true
<AreYouSure description={description} onYes={onYes} type={type} />,
{noPadding: true, closeButton: {showCloseButton: true, whiteColor: true}}
);
}
@ -39,28 +39,34 @@ class AreYouSure extends React.Component {
}
render() {
const { loading } = this.state;
const { description, type } = this.props;
return (
<div className="are-you-sure" role="dialog" aria-labelledby="are-you-sure__header" aria-describedby="are-you-sure__description">
<div className="are-you-sure__header" id="are-you-sure__header">
{i18n('ARE_YOU_SURE')}
</div>
<span className="are-you-sure__close-icon" onClick={this.onNo.bind(this)}>
<Icon name="times" size="2x"/>
</span>
<div className="are-you-sure__description" id="are-you-sure__description">
{this.props.description || (this.props.type === 'secure' && i18n('PLEASE_CONFIRM_PASSWORD'))}
{description || (type === 'secure' && i18n('PLEASE_CONFIRM_PASSWORD'))}
</div>
{(this.props.type === 'secure') ? this.renderPassword() : null}
{(type === 'secure') ? this.renderPassword() : null}
<span className="separator" />
<div className="are-you-sure__buttons">
<div className="are-you-sure__no-button">
<Button type="link" size="auto" onClick={this.onNo.bind(this)} tabIndex="2">
<Button disabled={loading} type="link" size="auto" onClick={this.onNo.bind(this)} tabIndex="2">
{i18n('CANCEL')}
</Button>
</div>
<div className="are-you-sure__yes-button">
<Button type="secondary" size="small" onClick={this.onYes.bind(this)} ref="yesButton" tabIndex="2">
{i18n('YES')}
<Button
type="secondary"
size="small"
onClick={this.onYes.bind(this)}
ref="yesButton"
tabIndex="2"
disabled={loading}>
{loading ? <Loading /> : i18n('YES')}
</Button>
</div>
</div>
@ -69,8 +75,20 @@ class AreYouSure extends React.Component {
}
renderPassword() {
const { password, loading } = this.state;
return (
<Input className="are-you-sure__password" password placeholder="password" name="password" ref="password" size="medium" value={this.state.password} onChange={this.onPasswordChange.bind(this)} onKeyDown={this.onInputKeyDown.bind(this)}/>
<Input
className="are-you-sure__password"
password
placeholder="password"
name="password"
ref="password"
size="medium"
value={password}
onChange={this.onPasswordChange.bind(this)}
onKeyDown={this.onInputKeyDown.bind(this)}
disabled={loading} />
);
}
@ -87,28 +105,59 @@ class AreYouSure extends React.Component {
}
onYes() {
if (this.props.type === 'secure' && !this.state.password) {
const { password } = this.state;
const { type, onYes } = this.props;
if(type === 'secure' && !password) {
this.refs.password.focus()
}
if (this.props.type === 'default' || this.state.password) {
this.closeModal();
if (this.props.onYes) {
this.props.onYes(this.state.password);
if(type === 'default' || password) {
if(onYes) {
const result = onYes(password);
if(this.isPromise(result)) {
this.setState({
loading: true
});
result
.then(() => {
this.setState({
loading: false
});
this.closeModal();
})
.catch(() => {
this.setState({
loading: false,
});
this.closeModal();
})
} else {
this.closeModal();
}
} else {
this.closeModal();
}
}
}
isPromise(object) {
if(Promise && Promise.resolve) {
return Promise.resolve(object) == object;
} else {
throw "Promise not supported in your environment"
}
}
onNo() {
this.closeModal();
}
closeModal() {
if (this.context.closeModal) {
this.context.closeModal();
}
const { closeModal } = this.context;
closeModal && closeModal();
}
}
export default AreYouSure;
export default AreYouSure;

View File

@ -24,12 +24,16 @@
&__buttons {
margin-top: 10px;
padding-bottom: 10px;
text-align: right;
width: 100%;
display: inline-flex;
flex-direction: row;
justify-content: flex-end;
align-items: center;
}
&__yes-button,
&__no-button {
display: inline-block;
display: block;
margin-right: 10px;
}

View File

@ -32,11 +32,11 @@ class ArticleAddModal extends React.Component {
<Form onSubmit={this.onAddNewArticleFormSubmit.bind(this)} loading={this.state.loading}>
<FormField name="title" label={i18n('TITLE')} field="input" fieldProps={{size: 'large'}} validation="TITLE" required/>
<FormField name="content" label={i18n('CONTENT')} field="textarea" validation="TEXT_AREA" required fieldProps={{allowImages: this.props.allowAttachments}}/>
<SubmitButton type="secondary">{i18n('ADD_ARTICLE')}</SubmitButton>
<Button className="article-add-modal__cancel-button" type="link" onClick={(event) => {
event.preventDefault();
ModalContainer.closeModal();
}}>{i18n('CANCEL')}</Button>
<SubmitButton className="article-add-modal__submit-button" type="secondary">{i18n('ADD_ARTICLE')}</SubmitButton>
</Form>
</div>
);

View File

@ -2,7 +2,11 @@
width: 800px;
&__cancel-button {
float: right;
float: left;
margin-top: 15px;
}
&__submit-button {
float: right;
}
}

View File

@ -36,11 +36,13 @@ class ArticlesList extends React.Component {
}
render() {
if(this.props.errored) {
const { errored, loading } = this.props;
if(errored) {
return <Message type="error">{i18n('ERROR_RETRIEVING_ARTICLES')}</Message>;
}
return (this.props.loading) ? <Loading /> : this.renderContent();
return loading ? <Loading /> : this.renderContent();
}
renderContent() {
@ -53,17 +55,19 @@ class ArticlesList extends React.Component {
}
renderTopics() {
const { topics, editable, articlePath } = this.props;
return (
<div className="articles-list__topics">
{this.props.topics.map((topic, index) => {
{topics.map((topic, index) => {
return (
<div key={index}>
<TopicViewer
{...topic}
id={topic.id * 1}
editable={this.props.editable}
editable={editable}
onChange={this.retrieveArticles.bind(this)}
articlePath={this.props.articlePath} />
articlePath={articlePath} />
<span className="separator" />
</div>
);
@ -76,7 +80,7 @@ class ArticlesList extends React.Component {
return (
<div className="articles-list__add-topic-button">
<Button onClick={() => ModalContainer.openModal(<TopicEditModal addForm onChange={this.retrieveArticles.bind(this)} />)} type="secondary" className="articles-list__add">
<Icon name="plus-circle" size="2x" className="articles-list__add-icon"/> {i18n('ADD_TOPIC')}
<Icon name="plus" className="articles-list__add-icon" /> {i18n('ADD_TOPIC')}
</Button>
</div>
);
@ -88,9 +92,11 @@ class ArticlesList extends React.Component {
}
export default connect((store) => {
const { topics, errored, loading } = store.articles;
return {
topics: store.articles.topics,
errored: store.articles.errored,
loading: store.articles.loading
topics: topics.map((topic) => {return {...topic, private: topic.private === "1"}}),
errored,
loading
};
})(ArticlesList);

View File

@ -1,14 +1,14 @@
@import "../scss/vars";
.articles-list {
&__add {
position: relative;
}
&__add-icon {
position: absolute;
left: 10px;
margin-top: -4px;
&__add-topic-button {
display: flex;
flex-direction: row;
justify-content: flex-end;
align-items: center;
}
}
}

View File

@ -1,6 +1,4 @@
import React from 'react';
import {connect} from 'react-redux';
import classNames from 'classnames';
import DropDown from 'core-components/drop-down';
import Icon from 'core-components/icon';

View File

@ -1,7 +1,6 @@
import React from 'react';
import { connect } from 'react-redux';
import keyCode from 'keycode';
import classNames from 'classnames';
import store from 'app/store';
import ModalActions from 'actions/modal-actions';
@ -9,11 +8,14 @@ import Modal from 'core-components/modal';
class ModalContainer extends React.Component {
static openModal(content, noPadding) {
static openModal(
content,
options={noPadding: false, outsideClick: false, closeButton: {showCloseButton: false, whiteColor: false}}
) {
store.dispatch(
ModalActions.openModal({
content,
noPadding
options
})
);
}
@ -49,8 +51,16 @@ class ModalContainer extends React.Component {
}
renderModal() {
const { content, options } = this.props.modal;
const { noPadding, outsideClick, closeButton } = options;
return (
<Modal content={this.props.modal.content} noPadding={this.props.modal.noPadding}/>
<Modal
content={content}
noPadding={noPadding}
outsideClick={outsideClick}
onOutsideClick={this.closeModal.bind(this)}
closeButton={closeButton} />
);
}
@ -69,4 +79,4 @@ export default connect((store) => {
return {
modal: store.modal
};
})(ModalContainer);
})(ModalContainer);

View File

@ -25,9 +25,14 @@ class PasswordRecovery extends React.Component {
};
render() {
const { renderLogo, formProps, onBackToLoginClick } = this.props;
const {
renderLogo,
formProps,
onBackToLoginClick,
style
} = this.props;
return (
<Widget {...this.props} className={this.getClass()} title={!renderLogo && i18n('RECOVER_PASSWORD')}>
<Widget style={style} className={this.getClass()} title={!renderLogo ? i18n('RECOVER_PASSWORD') : ''}>
{this.renderLogo()}
<Form {...formProps}>
<div className="password-recovery__inputs">

View File

@ -24,3 +24,15 @@
margin-bottom: 30px;
}
}
@media screen and (max-width: 320px) {
.widget {
margin-left: -12px;
}
}
@media screen and (max-width: 992px) {
.password-recovery__content {
width: 100%;
}
}

View File

@ -61,4 +61,28 @@
&__pagination {
}
}
@media screen and (max-width: 415px) {
.people-list {
&__item {
height: unset;
padding: unset;
display: flex;
flex-direction: column;
justify-content: center;
align-items: flex-end;
&-profile-pic-wrapper {
top: 15px;
left: 15px;
}
&-block {
width: unset;
display: unset;
padding: 15px 10px 0 0;
}
}
}
}
}

View File

@ -0,0 +1,48 @@
import React from 'react';
import ModalContainer from 'app-components/modal-container';
import Button from 'core-components/button';
import Message from 'core-components/message';
class PopupMessage extends React.Component {
static propTypes = Message.propTypes;
static contextTypes = {
closeModal: React.PropTypes.func
};
static open(props) {
ModalContainer.openModal(
<PopupMessage {...props} />,
{noPadding: true, outsideClick: true}
);
}
componentDidMount() {
this.refs.closeButton && this.refs.closeButton.focus();
}
render() {
return (
<div className="popup-message">
<Message {...this.props} className="popup-message__message" />
<Button
className="popup-message__close-button"
iconName="times"
type="clean"
ref="closeButton"
onClick={this.closeModal.bind(this)} />
</div>
);
}
closeModal() {
const { closeModal } = this.context;
closeModal && closeModal();
}
}
export default PopupMessage;

View File

@ -0,0 +1,17 @@
@import "../scss/vars";
.popup-message {
min-width: 500px;
position: relative;
&__close-button {
position: absolute;
top: 0;
right: 0;
color: $dark-grey;
&:focus {
outline: none;
color: $grey;
}
}
}

View File

@ -0,0 +1,42 @@
import React from 'react';
import Tooltip from 'core-components/tooltip';
class StatCard extends React.Component {
static propTypes = {
label: React.PropTypes.string,
description: React.PropTypes.string,
value: React.PropTypes.number,
isPercentage: React.PropTypes.bool
};
state = {
showTooltip: false
};
render() {
const {
label,
description,
value,
isPercentage
} = this.props;
const displayValue = isNaN(value) ? "-" : (isPercentage ? value.toFixed(2) : value);
return (
<Tooltip content={description} show={this.state.showTooltip} >
<div className="stat-card" onMouseEnter={() => this.setState({showTooltip: true})} onMouseLeave={() => this.setState({showTooltip: false})}>
<div className="stat-card__wrapper">
{label}
<div className="stat-card__container">
{displayValue}{isPercentage && !isNaN(value) ? "%" : ""}
</div>
</div>
</div>
</Tooltip>
);
}
}
export default StatCard;

View File

@ -0,0 +1,22 @@
@import "../scss/vars";
.stat-card {
margin: 0 4px;
&__wrapper {
padding: 10px;
min-width: 160px;
margin: 8px auto;
transition: 0.3s;
box-shadow: 0 4px 8px 0 rgba(0,0,0,0.2);
text-align: center;
&:hover {
box-shadow: 0 8px 16px 0 rgba(0,0,0,0.2);
}
}
&__container {
font-size: $font-size--lg;
padding: 16px;
}
}

View File

@ -1,100 +0,0 @@
import React from 'react';
import {Line} from 'react-chartjs-2';
import i18n from 'lib-app/i18n';
class StatsChart extends React.Component {
static propTypes = {
strokes: React.PropTypes.arrayOf(React.PropTypes.shape({
name: React.PropTypes.string,
values: React.PropTypes.arrayOf(React.PropTypes.shape({
date: React.PropTypes.string,
value: React.PropTypes.number
}))
})),
period: React.PropTypes.number
};
render() {
return (
<div>
<Line data={this.getChartData()} options={this.getChartOptions()} width={800} height={400} redraw/>
</div>
);
}
getChartData() {
let labels = this.getLabels();
let color = {
'CLOSE': 'rgba(150, 20, 20, 0.6)',
'CREATE_TICKET': 'rgba(20, 150, 20, 0.6)',
'SIGNUP': 'rgba(20, 20, 150, 0.6)',
'COMMENT': 'rgba(20, 200, 200, 0.6)',
'ASSIGN': 'rgba(20, 150, 20, 0.6)'
};
let strokes = this.props.strokes.slice();
let datasets = strokes.map((stroke, index) => {
return {
label: i18n('CHART_' + stroke.name),
data: stroke.values.map((val) => val.value),
fill: false,
borderWidth: this.getBorderWidth(),
borderColor: color[stroke.name],
pointBorderColor: color[stroke.name],
pointRadius: 0,
pointHoverRadius: 3,
lineTension: 0.2,
pointHoverBackgroundColor: color[stroke.name],
hitRadius: this.hitRadius(index)
}
});
return {
labels: labels,
datasets: datasets
};
}
getBorderWidth() {
return (this.props.period <= 90) ? 3 : 2;
}
getLabels() {
let months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec'];
let labels = [];
if (!this.props.strokes.length) {
labels = Array.from('x'.repeat(this.props.period));
}
else {
labels = this.props.strokes[0].values.map((item) => {
let idx = item.date.slice(4, 6) - 1;
return item.date.slice(6, 8) + ' ' + months[idx];
});
}
return labels;
}
hitRadius(index) {
if (this.props.period <= 7) return 20;
if (this.props.period <= 30) return 15;
if (this.props.period <= 90) return 10;
return 3;
}
getChartOptions() {
return {
legend: {
display: false
}
};
}
}
export default StatsChart;

View File

@ -1,186 +0,0 @@
import React from 'react';
import _ from 'lodash';
import classNames from 'classnames';
import i18n from 'lib-app/i18n';
import API from 'lib-app/api-call';
import DropDown from 'core-components/drop-down';
import ToggleList from 'core-components/toggle-list';
import StatsChart from 'app-components/stats-chart';
const generalStrokes = ['CREATE_TICKET', 'CLOSE', 'SIGNUP', 'COMMENT'];
const staffStrokes = ['ASSIGN', 'CLOSE'];
const ID = {
'CREATE_TICKET': 0,
'ASSIGN': 0,
'CLOSE': 1,
'SIGNUP': 2,
'COMMENT': 3
};
const statsPeriod = {
'WEEK': 7,
'MONTH': 30
};
class Stats extends React.Component {
static propTypes = {
type: React.PropTypes.string,
staffId: React.PropTypes.number
};
state = {
stats: this.getDefaultStats(),
strokes: this.getStrokes().map((name) => {
return {
name: name,
values: []
}
}),
showed: [0],
period: 0
};
componentDidMount() {
this.retrieve('WEEK');
}
render() {
return (
<div className={this.getClass()}>
<DropDown {...this.getDropDownProps()}/>
<ToggleList {...this.getToggleListProps()} />
<StatsChart {...this.getStatsChartProps()} />
</div>
);
}
getClass() {
let classes = {
'stats': true,
'stats_staff': this.props.type === 'staff'
};
return classNames(classes);
}
getToggleListProps() {
return {
values: this.state.showed,
className: 'stats__toggle-list',
onChange: this.onToggleListChange.bind(this),
type: this.props.type === 'general' ? 'default' : 'small',
items: this.getStrokes().map((name) => {
return {
className: 'stats__toggle-list_' + name,
content: (
<div className="stats__toggle-list-item">
<div className="stats__toggle-list-item-value">{this.state.stats[name]}</div>
<div className="stats__toggle-list-item-name">{i18n('CHART_' + name)}</div>
</div>
)
}
})
};
}
onToggleListChange(event) {
this.setState({
showed: event.target.value
});
}
getDropDownProps() {
return {
items: Object.keys(statsPeriod).map(key => 'LAST_' + statsPeriod[key] + '_DAYS').map((name) => {
return {
content: i18n(name),
icon: ''
};
}),
onChange: this.onDropDownChange.bind(this),
className: 'stats__dropdown'
}
}
onDropDownChange(event) {
this.retrieve(Object.keys(statsPeriod)[event.index]);
}
getStatsChartProps() {
let showed = this.getShowedArray();
return {
period: this.state.period,
strokes: _.filter(this.state.strokes, (s, i) => showed[i])
};
}
retrieve(periodName) {
API.call({
path: '/system/get-stats',
data: this.getApiCallData(periodName)
}).then(this.onRetrieveSuccess.bind(this));
}
onRetrieveSuccess(result) {
let newStats = this.getDefaultStats();
let newStrokes = this.getStrokes().map((name) => {
return {
name: name,
values: []
};
});
let realPeriod = result.data.length / this.getStrokes().length;
result.data.reverse().map((item) => {
newStats[item.type] += item.value * 1;
newStrokes[ ID[item.type] ].values.push({
date: item.date,
value: item.value * 1
});
});
this.setState({stats: newStats, strokes: newStrokes, period: realPeriod});
}
getShowedArray() {
let showed = this.getStrokes().map(() => false);
for (let i = 0; i < showed.length; i++) {
showed[this.state.showed[i]] = true;
}
return showed;
}
getStrokes() {
return this.props.type === 'general' ? generalStrokes : staffStrokes;
}
getDefaultStats() {
return this.props.type === 'general' ?
{
'CREATE_TICKET': 0,
'CLOSE': 0,
'SIGNUP': 0,
'COMMENT': 0
} :
{
'ASSIGN': 0,
'CLOSE': 0
};
}
getApiCallData(periodName) {
return this.props.type === 'general' ? {period: periodName} : {period: periodName, staffId: this.props.staffId};
}
}
export default Stats;

View File

@ -1,76 +0,0 @@
@import '../scss/vars';
.stats {
&__dropdown {
margin-left: auto;
margin-bottom: 20px;
}
&__toggle-list {
margin-bottom: 20px;
user-select: none;
&-item {
&-value {
font-size: $font-size--lg;
line-height: 80px;
}
&-name {
font-size: $font-size--md;
line-height: 20px;
display: inline-flex;
}
}
&_CREATE_TICKET.toggle-list__selected {
box-shadow: inset 0 -5px 0px 0px rgba(20, 150, 20, 0.6);
}
&_CLOSE.toggle-list__selected {
box-shadow: inset 0 -5px 0px 0px rgba(150, 20, 20, 0.6);
}
&_SIGNUP.toggle-list__selected {
box-shadow: inset 0 -5px 0px 0px rgba(20, 20, 150, 0.6);
}
&_COMMENT.toggle-list__selected {
box-shadow: inset 0 -5px 0px 0px rgba(20, 200, 200, 0.6);
}
&_ASSIGN.toggle-list__selected {
box-shadow: inset 0 -5px 0px 0px rgba(20, 150, 20, 0.6);
}
}
&_staff {
.stats__dropdown {
margin-left: auto;
margin-bottom: 20px;
float: left;
}
.stats__toggle-list {
margin-bottom: 20px;
float: right;
&-item {
&-value {
font-size: $font-size--md;
line-height: 40px;
}
&-name {
font-size: $font-size--sm;
line-height: 20px;
}
}
}
}
}

View File

@ -1,5 +1,6 @@
import React from 'react';
import classNames from 'classnames';
import {connect} from 'react-redux';
import i18n from 'lib-app/i18n';
import API from 'lib-app/api-call';
@ -7,7 +8,13 @@ import API from 'lib-app/api-call';
import DateTransformer from 'lib-core/date-transformer';
import Icon from 'core-components/icon';
import Tooltip from 'core-components/tooltip';
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';
const VIEW_USER_PATH = "/admin/panel/users/view-user/";
const VIEW_STAFF_PATH = "/admin/panel/staff/view-staff/";
class TicketEvent extends React.Component {
static propTypes = {
type: React.PropTypes.oneOf([
@ -17,12 +24,18 @@ class TicketEvent extends React.Component {
'CLOSE',
'RE_OPEN',
'DEPARTMENT_CHANGED',
'PRIORITY_CHANGED'
]),
author: React.PropTypes.object,
content: React.PropTypes.string,
date: React.PropTypes.string,
private: React.PropTypes.string,
edited: React.PropTypes.bool,
edit: React.PropTypes.bool,
onToggleEdit: React.PropTypes.func
};
state = {
content: this.props.content
};
render() {
@ -73,30 +86,87 @@ class TicketEvent extends React.Component {
'CLOSE': this.renderClosed.bind(this),
'RE_OPEN': this.renderReOpened.bind(this),
'DEPARTMENT_CHANGED': this.renderDepartmentChange.bind(this),
'PRIORITY_CHANGED': this.renderPriorityChange.bind(this)
};
return renders[this.props.type]();
}
renderComment() {
const {
author,
date,
edit,
file
} = this.props;
const customFields = (author && author.customfields) || [];
return (
<div className="ticket-event__comment">
<span className="ticket-event__comment-pointer" />
<div className="ticket-event__comment-author">
<span className="ticket-event__comment-author-name">{this.props.author.name}</span>
{this.renderCommentAuthor()}
<span className="ticket-event__comment-badge-container">
<span className="ticket-event__comment-badge">{i18n((this.props.author.staff) ? 'STAFF' : 'CUSTOMER')}</span>
<span className="ticket-event__comment-badge">{i18n((author.staff) ? 'STAFF' : 'CUSTOMER')}</span>
</span>
{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>
<div className="ticket-event__comment-content" dangerouslySetInnerHTML={{__html: this.props.content}}></div>
{this.renderFileRow(this.props.file)}
<div className="ticket-event__comment-date">{DateTransformer.transformToString(date)}</div>
{!edit ? this.renderContent() : this.renderEditField()}
{this.renderFooter(file)}
</div>
);
}
renderCommentAuthor() {
const {
author,
level
} = this.props;
const commentAutorClass = "ticket-event__comment-author-name";
let commentAuthor;
if(level === "3") {
commentAuthor = (
<a className={commentAutorClass} href={((author.staff) ? VIEW_STAFF_PATH : VIEW_USER_PATH)+author.id}>
{author.name}
</a>
);
} else if(level && !author.staff) {
commentAuthor = <a className={commentAutorClass} href={VIEW_USER_PATH+author.id}>{author.name}</a>;
} else {
commentAuthor = <span className={commentAutorClass}>{author.name}</span>;
}
return commentAuthor;
}
renderContent() {
return (
<div className="ticket-event__comment-content ql-editor">
<div dangerouslySetInnerHTML={{__html: this.props.content}}></div>
{((this.props.author.id == this.props.userId && this.props.author.staff == this.props.userStaff) || this.props.userStaff) ? this.renderEditIcon() : null}
</div>
)
}
renderEditIcon() {
return (
<div className="ticket-event__comment-content__edit" >
<Icon name="pencil" onClick={this.props.onToggleEdit} />
</div>
)
}
renderEditField() {
return (
<Form loading={this.props.loading} values={{content:this.state.content}} onChange={(form) => {this.setState({content:form.content})}} onSubmit={this.props.onEdit}>
<FormField name="content" required field="textarea" validation="TEXT_AREA" fieldProps={{allowImages: this.props.allowAttachments}}/>
<div className="ticket-event__submit-edited-comment" >
<SubmitButton type="secondary" >{i18n('SUBMIT')}</SubmitButton>
<Button size="medium" onClick={this.props.onToggleEdit}>{i18n('CLOSE')}</Button>
</div>
</Form>
);
}
renderAssignment() {
let assignedTo = this.props.content;
let authorName = this.props.author.name;
@ -164,17 +234,6 @@ class TicketEvent extends React.Component {
);
}
renderPriorityChange() {
return (
<div className="ticket-event__circled">
<span className="ticket-event__circled-author">{this.props.author.name}</span>
<span className="ticket-event__circled-text"> {i18n('ACTIVITY_PRIORITY_CHANGED_THIS')}</span>
<span className="ticket-event__circled-indication"> {this.props.content}</span>
<span className="ticket-event__circled-date"> {i18n('DATE_PREFIX')} {DateTransformer.transformToString(this.props.date)}</span>
</div>
);
}
renderPrivateBadge() {
return (
<span className="ticket-event__comment-badge-container">
@ -185,8 +244,9 @@ class TicketEvent extends React.Component {
);
}
renderFileRow(file) {
renderFooter(file) {
let node = null;
let edited = null;
if (file) {
node = <span> {this.getFileLink(file)} <Icon name="paperclip" /> </span>;
@ -194,11 +254,30 @@ class TicketEvent extends React.Component {
node = i18n('NO_ATTACHMENT');
}
if (this.props.edited && this.props.type === 'COMMENT') {
edited = i18n('COMMENT_EDITED');
}
return (
<div className="ticket-event__file">
{node}
<div className="ticket-event__items">
<div className="ticket-event__edited">
{edited}
</div>
<div className="ticket-event__file">
{node}
</div>
</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() {
@ -209,7 +288,6 @@ class TicketEvent extends React.Component {
'CLOSE': true,
'RE_OPEN': true,
'DEPARTMENT_CHANGED': true,
'PRIORITY_CHANGED': true
};
const classes = {
'row': true,
@ -220,7 +298,6 @@ class TicketEvent extends React.Component {
'ticket-event_close': this.props.type === 'CLOSE',
'ticket-event_reopen': this.props.type === 'RE_OPEN',
'ticket-event_department': this.props.type === 'DEPARTMENT_CHANGED',
'ticket-event_priority': this.props.type === 'PRIORITY_CHANGED',
'ticket-event_private': this.props.private*1,
};
@ -235,7 +312,6 @@ class TicketEvent extends React.Component {
'CLOSE': 'lock',
'RE_OPEN': 'unlock-alt',
'DEPARTMENT_CHANGED': 'exchange',
'PRIORITY_CHANGED': 'exclamation'
};
const iconSize = {
'COMMENT': '2x',
@ -244,7 +320,6 @@ class TicketEvent extends React.Component {
'CLOSE': 'lg',
'RE_OPEN': 'lg',
'DEPARTMENT_CHANGED': 'lg',
'PRIORITY_CHANGED': 'lg'
};
return {
@ -262,4 +337,7 @@ class TicketEvent extends React.Component {
}
}
export default TicketEvent;
export default connect((store) => {
return { level: store.session.userLevel };
})(TicketEvent);

View File

@ -84,7 +84,7 @@
border: 2px solid $light-grey;
border-bottom: none;
padding: 12px;
font-size: 10.6px;
font-size: 12.5px;
font-family: helvetica;
background-color: $light-grey;
@ -94,21 +94,56 @@
background-color: white;
border: 2px solid $very-light-grey;
border-top: none;
padding: 20px 10px;
padding: 28px 10px;
text-align: left;
position:relative;
overflow-y: initial;
&:hover {
.ticket-event__comment-content__edit {
color: grey;
cursor:pointer;
}
}
img {
max-width:100%;
}
&__edit {
position:absolute;
top: 3px;
right: 9px;
align-self: right;
color:white;
}
}
}
&__submit-edited-comment {
display:flex;
align-items: center;
justify-content: space-between;
padding: 8px;
}
&__items {
background-color: $very-light-grey;
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px;
font-size: 12px;
}
&__file {
background-color: $very-light-grey;
cursor: pointer;
text-align: right;
padding: 5px 10px;
font-size: 12px;
}
&__edited{
font-style: italic;
}
&__comment-badge-value {
font-weight: normal;
}
&_staff {
@ -178,12 +213,6 @@
}
}
&_priority {
.ticket-event__icon {
padding-left: 11px;
padding-top: 5px;
}
}
&_private {
.ticket-event__comment-pointer {
border-right-color: $light-yellow;

View File

@ -35,14 +35,6 @@ class TicketInfo extends React.Component {
{(this.props.ticket.closed) ? 'closed' : 'open'}
</span>
</div>
<div className="ticket-info__properties__priority">
<span className="ticket-info__properties__label">
{i18n('PRIORITY')}:
</span>
<span className={this.getPriorityClass()}>
{this.props.ticket.priority}
</span>
</div>
<div className="ticket-info__properties__owner">
<span className="ticket-info__properties__label">
{i18n('OWNED')}:
@ -80,15 +72,6 @@ class TicketInfo extends React.Component {
}
}
getPriorityClass() {
let priorityClasses = {
'low': 'ticket-info__properties__badge-green',
'medium': 'ticket-info__properties__badge-blue',
'high': 'ticket-info__properties__badge-red'
};
return priorityClasses[this.props.ticket.priority];
}
}
export default TicketInfo;

View File

@ -36,7 +36,6 @@
&__status,
&__owner,
&__priority,
&__comments {
display: inline-block;
width: 50%;

View File

@ -1,5 +1,7 @@
import React from 'react';
import _ from 'lodash';
import {connect} from 'react-redux';
import queryString from 'query-string';
import i18n from 'lib-app/i18n';
import DateTransformer from 'lib-core/date-transformer';
@ -9,8 +11,10 @@ import DepartmentDropdown from 'app-components/department-dropdown';
import Table from 'core-components/table';
import Button from 'core-components/button';
import Tooltip from 'core-components/tooltip';
import Icon from 'core-components/icon';
import Checkbox from 'core-components/checkbox';
import Tag from 'core-components/tag';
import Icon from 'core-components/icon';
import Message from 'core-components/message';
class TicketList extends React.Component {
static propTypes = {
@ -25,7 +29,8 @@ class TicketList extends React.Component {
'secondary'
]),
closedTicketsShown: React.PropTypes.bool,
onClosedTicketsShownChange: React.PropTypes.func
onClosedTicketsShownChange: React.PropTypes.func,
onDepartmentChange: React.PropTypes.func
};
static defaultProps = {
@ -43,11 +48,18 @@ class TicketList extends React.Component {
};
render() {
const { type, showDepartmentDropdown, onClosedTicketsShownChange } = this.props;
return (
<div className="ticket-list">
<div className="ticket-list__filters">
{(this.props.type === 'secondary' && this.props.showDepartmentDropdown) ? this.renderDepartmentsDropDown() : null}
{this.props.onClosedTicketsShownChange ? this.renderFilterCheckbox() : null}
{(type === 'primary') ? this.renderMessage() : null}
{
((type === 'secondary') && showDepartmentDropdown) ?
this.renderDepartmentsDropDown() :
null
}
{onClosedTicketsShownChange ? this.renderFilterCheckbox() : null}
</div>
<Table {...this.getTableProps()} />
</div>
@ -56,7 +68,15 @@ class TicketList extends React.Component {
renderFilterCheckbox() {
return <Checkbox className="ticket-list__checkbox" label={i18n("SHOW_CLOSED_TICKETS")} value={this.props.closedTicketsShown} onChange={this.props.onClosedTicketsShownChange} wrapInLabel/>
return (
<Checkbox
className="ticket-list__checkbox"
label={i18n("SHOW_CLOSED_TICKETS")}
value={this.props.closedTicketsShown}
onChange={this.props.onClosedTicketsShownChange}
wrapInLabel
/>
);
}
renderDepartmentsDropDown() {
@ -67,28 +87,46 @@ class TicketList extends React.Component {
);
}
renderMessage() {
switch (queryString.parse(window.location.search)["message"]) {
case 'success':
return <Message className="create-ticket-form__message" type="success">{i18n('TICKET_SENT')}</Message>
case 'fail':
return <Message className="create-ticket-form__message" type="error">{i18n('TICKET_SENT_ERROR')}</Message>;
default:
return null;
}
}
getDepartmentDropdownProps() {
const { departments, onDepartmentChange } = this.props;
return {
departments: this.getDepartments(),
onChange: (event) => {
const departmentId = event.index && departments[event.index - 1].id;
this.setState({
selectedDepartment: event.index && this.props.departments[event.index - 1].id
selectedDepartment: departmentId
});
onDepartmentChange && onDepartmentChange(departmentId || null);
},
size: 'medium'
};
}
getTableProps() {
const { loading, page, pages, onPageChange } = this.props;
return {
loading: this.props.loading,
loading,
headers: this.getTableHeaders(),
rows: this.getTableRows(),
pageSize: 10,
comp: this.compareFunction,
page: this.props.page,
pages: this.props.pages,
onPageChange: this.props.onPageChange
page,
pages,
onPageChange
};
}
@ -103,7 +141,9 @@ class TicketList extends React.Component {
}
getTableHeaders() {
if (this.props.type == 'primary' ) {
const { type } = this.props;
if(type == 'primary' ) {
return [
{
key: 'number',
@ -122,11 +162,14 @@ class TicketList extends React.Component {
},
{
key: 'date',
value: i18n('DATE'),
value: <div>
{i18n('DATE')}
{this.renderSortArrow('date')}
</div>,
className: 'ticket-list__date col-md-2'
}
];
} else if (this.props.type == 'secondary') {
} else if(type == 'secondary') {
return [
{
key: 'number',
@ -138,11 +181,6 @@ class TicketList extends React.Component {
value: i18n('TITLE'),
className: 'ticket-list__title col-md-4'
},
{
key: 'priority',
value: i18n('PRIORITY'),
className: 'ticket-list__priority col-md-1'
},
{
key: 'department',
value: i18n('DEPARTMENT'),
@ -155,107 +193,110 @@ class TicketList extends React.Component {
},
{
key: 'date',
value: i18n('DATE'),
value: <div>
{i18n('DATE')}
{this.renderSortArrow('date')}
</div>,
className: 'ticket-list__date col-md-2'
}
];
}
}
renderSortArrow(header) {
const { orderBy, showOrderArrows, onChangeOrderBy } = this.props;
return (
showOrderArrows ?
<Icon
name={`arrow-${this.getIconName(header, orderBy)}`}
className="ticket-list__order-icon"
color={this.getIconColor(header, orderBy)}
onClick={() => onChangeOrderBy(header)} /> :
null
);
}
getIconName(header, orderBy) {
return (orderBy && orderBy.value === header && orderBy.asc) ? "up" : "down";
}
getIconColor(header, orderBy) {
return (orderBy && orderBy.value === header) ? "gray" : "white";
}
getTableRows() {
return this.getTickets().map(this.gerTicketTableObject.bind(this));
}
getTickets() {
return (this.state.selectedDepartment) ? _.filter(this.props.tickets, (ticket) => {
return ticket.department.id == this.state.selectedDepartment
}) : this.props.tickets;
const { tickets } = this.props;
const { selectedDepartment } = this.state;
return (
(selectedDepartment) ?
_.filter(tickets, (ticket) => { return ticket.department.id == selectedDepartment}) :
tickets
);
}
gerTicketTableObject(ticket) {
let titleText = (this.isTicketUnread(ticket)) ? ticket.title + ' (1)' : ticket.title;
const { date, title, ticketNumber, closed, tags, department, author } = ticket;
const dateTodayWithOutHoursAndMinutes = DateTransformer.getDateToday();
const ticketDateWithOutHoursAndMinutes = Math.floor(DateTransformer.UTCDateToLocalNumericDate(JSON.stringify(date*1)) / 10000);
const stringTicketLocalDateFormat = DateTransformer.transformToString(date, false, true);
const ticketDate = (
((dateTodayWithOutHoursAndMinutes - ticketDateWithOutHoursAndMinutes) > 1) ?
stringTicketLocalDateFormat :
`${(dateTodayWithOutHoursAndMinutes - ticketDateWithOutHoursAndMinutes) ? "Yesterday" : "Today"} at ${stringTicketLocalDateFormat.slice(-5)}`
);
let titleText = (this.isTicketUnread(ticket)) ? title + ' (1)' : title;
return {
number: (
<Tooltip content={<TicketInfo ticket={ticket}/>} openOnHover>
{'#' + ticket.ticketNumber}
<Tooltip content={<TicketInfo ticket={ticket} />} openOnHover>
{'#' + ticketNumber}
</Tooltip>
),
title: (
<Button className="ticket-list__title-link" type="clean" route={{to: this.props.ticketPath + ticket.ticketNumber}}>
{titleText}
</Button>
<div>
{closed ? <Icon size="sm" name="lock" /> : null}
<Button className="ticket-list__title-link" type="clean" route={{to: this.props.ticketPath + ticketNumber}}>
{titleText}
</Button>
{(tags || []).map((tagName,index) => {
let tag = _.find(this.props.tags, {name:tagName});
return <Tag size='small' name={tag && tag.name} color={tag && tag.color} key={index} />
})}
</div>
),
priority: this.getTicketPriority(ticket.priority),
department: ticket.department.name,
author: ticket.author.name,
date: DateTransformer.transformToString(ticket.date, false),
department: department.name,
author: author.name,
date: ticketDate,
unread: this.isTicketUnread(ticket),
highlighted: this.isTicketUnread(ticket)
};
}
getTicketPriority(priority) {
if(priority == 'high'){
return (
<span className="ticket-list__priority-high">{i18n('HIGH')}</span>
);
}
if(priority == 'medium'){
return (
<span className="ticket-list__priority-medium">{i18n('MEDIUM')}</span>
);
}
if(priority == 'low'){
return (
<span className="ticket-list__priority-low">{i18n('LOW')}</span>
);
}
}
compareFunction(row1, row2) {
if (row1.closed == row2.closed) {
if (row1.unread == row2.unread) {
let s1 = row1.date;
let s2 = row2.date;
let y1 = s1.substring(0, 4);
let y2 = s2.substring(0, 4);
if (y1 == y2) {
let m1 = s1.substring(4, 6);
let m2 = s2.substring(4, 6);
if (m1 == m2) {
let d1 = s1.substring(6, 8);
let d2 = s2.substring(6, 8);
if (d1 == d2) {
return 0;
}
return d1 > d2 ? -1 : 1;
}
return m1 > m2 ? -1 : 1;
}
return y1 > y2 ? -1 : 1;
}
return row1.unread ? -1 : 1;
}
return row1.closed ? -1 : 1;
}
isTicketUnread(ticket) {
if(this.props.type === 'primary') {
return ticket.unread;
} else if(this.props.type === 'secondary') {
if(ticket.author.id == this.props.userId && ticket.author.staff) {
return ticket.unread;
} else {
return ticket.unreadStaff;
}
const { type, userId } = this.props;
const { unread, author, unreadStaff } = ticket;
if(type === 'primary') {
return unread;
} else if(type === 'secondary') {
if(author.id == userId && author.staff) {
return unread;
} else {
return unreadStaff;
}
}
}
}
export default TicketList;
export default connect((store) => {
return {
tags: store.config['tags']
};
})(TicketList);

View File

@ -1,6 +1,10 @@
@import "../scss/vars";
.ticket-list {
&__order-icon {
padding-left: 5px;
font-size: 14px;
}
&__filters {
margin-bottom: 25px;
@ -43,24 +47,4 @@
text-decoration: underline;
}
&__priority-low,
&__priority-medium,
&__priority-high {
display: inline-block;
border-radius: 10px;
width: 70px;
color: white;
}
&__priority-low {
background-color: $primary-green;
}
&__priority-medium {
background-color: $secondary-blue;
}
&__priority-high {
background-color: $primary-red;
}
}

View File

@ -0,0 +1,387 @@
import React from 'react';
import _ from 'lodash';
import {connect} from 'react-redux';
import SearchFiltersActions from 'actions/search-filters-actions';
import i18n from 'lib-app/i18n';
import API from 'lib-app/api-call';
import history from 'lib-app/history';
import searchTicketsUtils from 'lib-app/search-tickets-utils';
import Form from 'core-components/form';
import SubmitButton from 'core-components/submit-button';
import FormField from 'core-components/form-field';
import Icon from 'core-components/icon';
import Button from 'core-components/button';
import Loading from 'core-components/loading';
class TicketQueryFilters extends React.Component {
static propTypes = {
filters: React.PropTypes.shape({
query: React.PropTypes.string,
departments: React.PropTypes.string,
owners: React.PropTypes.string,
tags: React.PropTypes.string,
dateRange: React.PropTypes.string,
})
}
render() {
const {
formState,
filters,
showFilters,
ticketQueryListState
} = this.props;
return (
<div className={"ticket-query-filters" + (showFilters ? "__open" : "") }>
<Form
loading={ticketQueryListState.loading}
values={this.getFormValue(formState)}
onChange={this.onChangeForm.bind(this)}
onSubmit={this.onSubmitListConfig.bind(this)}>
<div className="ticket-query-filters__search-box">
<FormField name="query" field="search-box" fieldProps={{onSearch: this.onSubmitListConfig.bind(this)}} />
</div>
<div className="ticket-query-filters__first-row">
<FormField
label={i18n('DATE')}
name="dateRange"
field="date-range"
fieldProps={{defaultValue: formState.dateRange}} />
<FormField
label={i18n('STATUS')}
name="closed"
field="select"
fieldProps={{
items: this.getStatusItems(),
className: 'ticket-query-filters__status-drop-down'
}} />
</div>
<div className="ticket-query-filters__second-row">
<FormField
label={i18n('DEPARTMENTS')}
name="departments"
field="autocomplete"
fieldProps={{items: this.getDepartmentsItems()}} />
<FormField
label={i18n('OWNER')}
name="owners"
field="autocomplete"
fieldProps={{items: this.getStaffList()}} />
</div>
<div className="ticket-query-filters__third-row">
<FormField
label={i18n('TAGS')}
name="tags"
field="tag-selector"
fieldProps={{
items: this.getTags(filters.tags),
onRemoveClick: this.removeTag.bind(this),
onTagSelected: this.addTag.bind(this)
}} />
<FormField
label={i18n('AUTHORS')}
name="authors"
field="autocomplete"
fieldProps={{
getItemListFromQuery: this.searchAuthors.bind(this),
comparerFunction: this.autorsComparer.bind(this)
}} />
</div>
<div className="ticket-query-filters__container">
<Button
className="ticket-query-filters__container__button ticket-query-filters__container__clear-button"
size= "medium"
disabled={ticketQueryListState.loading}
onClick={this.clearFormValues.bind(this)}>
{ticketQueryListState.loading ? <Loading /> : i18n('CLEAR')}
</Button>
<SubmitButton
className="ticket-query-filters__container__button ticket-query-filters__container__search-button"
type="secondary"
size= "medium">
{i18n('SEARCH')}
</SubmitButton>
</div>
</Form>
<span className="separator" />
</div>
);
}
searchAuthors(query, blacklist = []) {
blacklist = blacklist.map(item => {return {isStaff: item.isStaff, id: item.id}});
return API.call({
path: '/ticket/search-authors',
data: {
query: query,
blackList: JSON.stringify(blacklist)
}
}).then(r => {
return r.data.authors.map(author => {
return {
name: author.name,
color: "gray",
id: author.id*1,
profilePic: author.profilePic,
isStaff: author.isStaff * 1,
content: author.profilePic !== undefined ? this.renderStaffOption(author) : author.name,
contentOnSelected: author.profilePic !== undefined ? this.renderStaffSelected(author) : author.name
}});
});
}
renderDepartmentOption(department) {
return (
<div className="ticket-query-filters__department-option" key={`department-option-${department.id}`}>
{department.private*1 ?
<Icon className="ticket-query-filters__department-option__icon" name='user-secret'/> :
null}
<span className="ticket-query-filters__department-option__name">{department.name}</span>
</div>
);
}
renderDeparmentSelected(department) {
return (
<div className="ticket-query-filters__department-selected" key={`department-selected-${department.id}`}>
{department.private*1 ?
<Icon className="ticket-query-filters__department-selected__icon" name='user-secret'/> :
null}
<span className="ticket-query-filters__department-selected__name">{department.name}</span>
</div>
);
}
renderStaffOption(staff) {
return (
<div className="ticket-query-filters__staff-option" key={`staff-option-${staff.id}`}>
<img className="ticket-query-filters__staff-option__profile-pic" src={this.getStaffProfilePic(staff)}/>
<span className="ticket-query-filters__staff-option__name">{staff.name}</span>
</div>
);
}
renderStaffSelected(staff) {
return (
<div className="ticket-query-filters__staff-selected" key={`staff-selected-${staff.id}`}>
<img className="ticket-query-filters__staff-selected__profile-pic" src={this.getStaffProfilePic(staff)}/>
<span className="ticket-query-filters__staff-selected__name">{staff.name}</span>
</div>
);
}
addTag(tag) {
const { formState } = this.props;
let selectedTagsId = formState.tags.concat(this.tagsNametoTagsId(this.getSelectedTagsName([tag])));
this.onChangeFormState({...formState, tags: selectedTagsId});
}
autorsComparer(autorList, autorSelectedList) {
return autorList.filter(item => !_.some(autorSelectedList, {id: item.id, isStaff: item.isStaff}));
}
clearFormValues(event) {
event.preventDefault();
this.props.dispatch(SearchFiltersActions.setDefaultFormValues());
}
getDepartmentsItems() {
const { departments, } = this.props;
let departmentsList = departments.map(department => {
return {
id: JSON.parse(department.id),
name: department.name.toLowerCase(),
color: 'gray',
contentOnSelected: this.renderDeparmentSelected(department),
content: this.renderDepartmentOption(department),
}
});
return departmentsList;
}
getSelectedDepartments(selectedDepartmentsId) {
let selectedDepartments = [];
if(selectedDepartmentsId !== undefined) {
let departments = this.getDepartmentsItems();
selectedDepartments = departments.filter(item => _.includes(selectedDepartmentsId, item.id));
}
return selectedDepartments;
}
getSelectedStaffs(selectedStaffsId) {
let selectedStaffs = [];
if(selectedStaffsId !== undefined) {
let staffs = this.getStaffList();
selectedStaffs = staffs.filter(staff => _.includes(selectedStaffsId, staff.id));
}
return selectedStaffs;
}
getSelectedTagsName(selectedTagsId) {
let selectedTagsName = [];
if(selectedTagsId !== undefined) {
let tagList = this.getTags();
let selectedTags = tagList.filter(item => _.includes(selectedTagsId, item.id));
selectedTagsName = selectedTags.map(tag => tag.name);
}
return selectedTagsName;
}
getStaffList() {
const { staffList, } = this.props;
let newStaffList = staffList.map(staff => {
return {
id: JSON.parse(staff.id),
name: staff.name.toLowerCase(),
color: 'gray',
contentOnSelected: this.renderStaffSelected(staff),
content: this.renderStaffOption(staff),
}
});
return newStaffList;
}
getStaffProfilePic(staff) {
return staff.profilePic ? API.getFileLink(staff.profilePic) : (API.getURL() + '/images/profile.png');
}
getStatusItems() {
let items = [
{id: 0, name: 'Any', content: i18n('ANY')},
{id: 1, name: 'Opened', content: i18n('OPENED')},
{id: 2, name: 'Closed', content: i18n('CLOSED')},
];
return items;
}
getTags() {
const { tags, } = this.props;
let newTagList = tags.map(tag => {
return {
id: JSON.parse(tag.id),
name: tag.name,
color : tag.color
}
});
return newTagList;
}
onChangeFormState(formValues) {
this.props.dispatch(SearchFiltersActions.changeForm(formValues));
}
onSubmitListConfig() {
const {
formState,
filters,
formEdited,
} = this.props;
const listConfigWithCompleteAuthorsList = searchTicketsUtils.formValueToListConfig(
{...formState, orderBy: filters.orderBy, page: 1},
true
);
if(formEdited && formState.dateRange.valid) {
const filtersForAPI = searchTicketsUtils.prepareFiltersForAPI(listConfigWithCompleteAuthorsList.filters);
const currentPath = window.location.pathname;
const urlQuery = searchTicketsUtils.getFiltersForURL({
filters: filtersForAPI,
shouldRemoveCustomParam: true,
shouldRemoveUseInitialValuesParam: true
});
urlQuery && history.push(`${currentPath}${urlQuery}`);
}
}
removeTag(tag) {
const { formState } = this.props;
let tagListName = formState.tags;
let newTagList = tagListName.filter(item => item !== tag);
let selectedTags = this.tagsNametoTagsId(this.getSelectedTagsName(newTagList));
this.onChangeFormState({...formState, tags: selectedTags});
}
tagsNametoTagsId(selectedTagsName) {
let tagList = this.getTags();
let selectedTags = tagList.filter(item => _.includes(selectedTagsName, item.name));
let selectedTagsId = selectedTags.map(tag => tag.id);
return selectedTagsId;
}
onChangeForm(data) {
const newStartDate = data.dateRange.startDate ? data.dateRange.startDate : searchTicketsUtils.getDefaultLocalStartDate();
const newEndDate = data.dateRange.endDate ? data.dateRange.endDate : searchTicketsUtils.getDefaultlocalEndDate();
const departmentsId = data.departments.map(department => department.id);
const staffsId = data.owners.map(staff => staff.id);
const tagsName = this.tagsNametoTagsId(data.tags);
const authors = data.authors.map(({name, id, isStaff, profilePic, color}) => ({name, id: id*1, isStaff, profilePic, color}));
this.onChangeFormState({
...data,
tags: tagsName,
owners: staffsId,
departments: departmentsId,
authors: authors,
dateRange: {
...data.dateRange,
startDate: newStartDate,
endDate: newEndDate
}
});
}
getFormValue(form) {
return {
...form,
departments: this.getSelectedDepartments(form.departments),
owners: this.getSelectedStaffs(form.owners),
tags: this.getSelectedTagsName(form.tags),
authors: this.getAuthors(form.authors),
}
}
getAuthors(authors = []) {
return authors.map(author => ({
name: author.name,
color: "gray",
id: author.id*1,
isStaff: author.isStaff*1,
profilePic: author.profilePic,
content: author.profilePic !== undefined ? this.renderStaffOption(author) : author.name,
contentOnSelected: author.profilePic !== undefined ? this.renderStaffSelected(author) : author.name
}));
}
}
export default connect((store) => {
return {
tags: store.config.tags,
departments: store.config.departments,
staffList: store.adminData.staffMembers,
formState: store.searchFilters.form,
filters: store.searchFilters.listConfig.filters,
showFilters: store.searchFilters.showFilters,
formEdited: store.searchFilters.formEdited,
ticketQueryListState: store.searchFilters.ticketQueryListState,
};
})(TicketQueryFilters);

View File

@ -0,0 +1,118 @@
@import '../scss/vars';
.ticket-query-filters {
opacity: 0;
max-height: 0;
overflow-y: hidden;
transition: all 0.2s ease;
&__open {
max-height: 1000px;
opacity: 1;
transition: all 0.2s ease;
}
&__container {
display: flex;
flex-direction: row;
align-items: center;
justify-content: flex-end;
&__button {
margin: 0 10px;
}
}
&__department-option {
&__name {
margin-left: 10px;
}
}
&__department-selected {
display: inline-block;
border-radius: 3px;
margin-left: 5px;
padding: 1px;
font-size: 13px;
cursor: default;
&__icon {
padding-right: 5px;
}
}
&__first-row {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
padding: 0 10%;
}
&__second-row,
&__third-row {
display: flex;
flex-direction: row;
justify-content: space-around;
align-items: flex-start;
}
&__status-drop-down > .drop-down__current-item {
background-color: $very-light-grey;
&:focus {
background-color: $medium-grey;
}
}
&__search-box {
width: 100%;
padding: 0 50px;
}
&__staff-option {
&__profile-pic {
width: 25px;
height: 25px;
border-radius: 50%;
}
&__name {
margin-left: 10px;
}
}
&__staff-selected {
display: inline-block;
border-radius: 3px;
margin-left: 5px;
padding: 1px;
font-size: 13px;
cursor: default;
&__profile-pic {
width: 25px;
height: 25px;
border-radius: 50%;
margin-right: 5px;
}
}
&__title {
padding-bottom: 10px;
}
}
@media screen and (max-width: 992px) {
.ticket-query-filters {
&__first-row,
&__second-row,
&__third-row {
display: flex;
flex-direction: column;
align-items: flex-start;
padding: unset;
}
}
}

View File

@ -0,0 +1,83 @@
import React from 'react';
import _ from 'lodash';
import i18n from 'lib-app/i18n';
import {connect} from 'react-redux';
import TicketList from 'app-components/ticket-list';
import Message from 'core-components/message';
import searchFiltersActions from '../actions/search-filters-actions';
import queryString from 'query-string';
class TicketQueryList extends React.Component {
state = {
tickets: [],
page: 1,
pages: 0,
error: null,
loading: true
};
render() {
return (
<div>
{
(this.state.error) ?
<Message type="error">{i18n('ERROR_RETRIEVING_TICKETS')}</Message> :
<TicketList {...this.getTicketListProps()}/>
}
</div>
);
}
onPageChange(event) {
const {
dispatch,
filters
} = this.props;
dispatch(searchFiltersActions.changePage({
...filters,
page: event.target.value
}));
}
getTicketListProps () {
const {
filters,
onChangeOrderBy,
userId,
ticketQueryListState
} = this.props;
const page = {
...ticketQueryListState,
...queryString.parse(window.location.search)
}.page*1;
return {
userId: userId,
ticketPath: '/admin/panel/tickets/view-ticket/',
tickets: ticketQueryListState.tickets,
page: page,
pages: ticketQueryListState.pages,
loading: ticketQueryListState.loading,
type: 'secondary',
showDepartmentDropdown: false,
closedTicketsShown: false,
onPageChange:this.onPageChange.bind(this),
orderBy: filters.orderBy ? JSON.parse(filters.orderBy) : filters.orderBy,
showOrderArrows: true,
onChangeOrderBy: onChangeOrderBy,
};
}
}
export default connect((store) => {
return {
userId: store.session.userId*1,
filters: store.searchFilters.listConfig.filters,
ticketQueryListState: store.searchFilters.ticketQueryListState
};
})(TicketQueryList);

View File

@ -8,10 +8,11 @@ import i18n from 'lib-app/i18n';
import API from 'lib-app/api-call';
import SessionStore from 'lib-app/session-store';
import MentionsParser from 'lib-app/mentions-parser';
import history from 'lib-app/history';
import searchTicketsUtils from 'lib-app/search-tickets-utils';
import TicketEvent from 'app-components/ticket-event';
import AreYouSure from 'app-components/are-you-sure';
import DateTransformer from 'lib-core/date-transformer';
import Form from 'core-components/form';
import FormField from 'core-components/form-field';
import SubmitButton from 'core-components/submit-button';
@ -22,6 +23,9 @@ import Icon from 'core-components/icon';
import TextEditor from 'core-components/text-editor';
import InfoTooltip from 'core-components/info-tooltip';
import DepartmentDropdown from 'app-components/department-dropdown';
import TagSelector from 'core-components/tag-selector';
import Tag from 'core-components/tag';
import Loading from 'core-components/loading';
class TicketViewer extends React.Component {
static propTypes = {
@ -36,15 +40,18 @@ class TicketViewer extends React.Component {
userId: React.PropTypes.number,
userStaff: React.PropTypes.bool,
userDepartments: React.PropTypes.array,
userLevel: React.PropTypes.number
userLevel: React.PropTypes.number,
tags: React.PropTypes.array
};
static defaultProps = {
tags: [],
editable: false,
ticket: {
author: {},
department: {},
comments: []
comments: [],
edited: null
}
};
@ -52,173 +59,362 @@ class TicketViewer extends React.Component {
loading: false,
commentValue: TextEditor.createEmpty(),
commentEdited: false,
commentPrivate: false
commentPrivate: false,
edit: false,
editId: 0,
tagSelectorLoading: false,
editTitle: false,
newTitle: this.props.ticket.title,
editTitleError: false,
editTitleLoading: false,
editStatus: false,
editTags: false,
editOwner: false,
editDepartment: false,
};
componentDidMount() {
if(!this.props.staffMembersLoaded && this.props.userStaff) {
this.props.dispatch(AdminDataActions.retrieveStaffMembers());
const { staffMembersLoaded, userStaff, dispatch } = this.props;
if(!staffMembersLoaded && userStaff) {
dispatch(AdminDataActions.retrieveStaffMembers());
}
}
render() {
const ticket = this.props.ticket;
const { ticket, userStaff, userId, editable, allowAttachments, assignmentAllowed } = this.props;
return (
<div className="ticket-viewer">
<div className="ticket-viewer__header row">
<span className="ticket-viewer__number">#{ticket.ticketNumber}</span>
<span className="ticket-viewer__title">{ticket.title}</span>
<span className="ticket-viewer__flag">
<Icon name={(ticket.language === 'en') ? 'us' : ticket.language}/>
</span>
</div>
{this.props.editable ? this.renderEditableHeaders() : this.renderHeaders()}
{this.state.editTitle ? this.renderEditableTitle() : this.renderTitleHeader()}
{editable ? this.renderEditableHeaders() : this.renderHeaders()}
<div className="ticket-viewer__content">
<TicketEvent type="COMMENT" author={ticket.author} content={this.props.userStaff ? MentionsParser.parse(ticket.content) : ticket.content} date={ticket.date} file={ticket.file}/>
<TicketEvent
loading={this.state.loading}
type="COMMENT"
author={ticket.author}
content={userStaff ? MentionsParser.parse(ticket.content) : ticket.content}
userStaff={userStaff}
userId={userId}
date={ticket.date}
onEdit={this.onEdit.bind(this,0)}
edited={ticket.edited}
file={ticket.file}
edit={this.state.edit && this.state.editId == 0}
onToggleEdit={this.onToggleEdit.bind(this, 0)}
allowAttachments={allowAttachments} />
</div>
<div className="ticket-viewer__comments">
{ticket.events && ticket.events.map(this.renderTicketEvent.bind(this))}
</div>
{(!this.props.ticket.closed && (this.props.editable || !this.props.assignmentAllowed)) ? this.renderResponseField() : (this.showDeleteButton())? <Button size="medium" onClick={this.onDeleteTicketClick.bind(this)}>{i18n('DELETE_TICKET')}</Button> : null}
{(!ticket.closed && (editable || !assignmentAllowed)) ? this.renderResponseField() : (this.showDeleteButton()) ? this.renderDeleteTicketButton() : null}
</div>
);
}
renderEditableHeaders() {
const ticket = this.props.ticket;
const departments = this.getDepartmentsForTransfer();
renderTitleHeader() {
const {ticket, userStaff, userId} = this.props;
const {ticketNumber, title, author, editedTitle, language} = ticket;
const priorities = {
'low': 0,
'medium': 1,
'high': 2
return(
<div className="ticket-viewer__header">
<span className="ticket-viewer__number">#{ticketNumber}</span>
<span className="ticket-viewer__title">{title}</span>
<span className="ticket-viewer__flag">
<Icon name={(language === 'en') ? 'us' : language} />
</span>
{((author.id == userId && author.staff == userStaff) || userStaff) ? this.renderEditTitleOption() : null}
{editedTitle ? this.renderEditedTitleText() : null }
</div>
)
}
renderEditableTitle(){
return(
<div className="ticket-viewer__header">
<div className="ticket-viewer__edit-title-box">
<FormField
className="ticket-viewer___input-edit-title"
error={this.state.editTitleError}
value={this.state.newTitle}
field='input'
onChange={(e) => this.setState({newTitle: e.target.value})} />
</div>
<div className="ticket-viewer__edit-title__buttons">
<Button disabled={this.state.editTitleLoading} type='primary' size="medium" onClick={() => this.setState({editTitle: false, newTitle: this.props.ticket.title})}>
{this.state.editTitleLoading ? <Loading /> : <Icon name="times" />}
</Button>
<Button disabled={this.state.editTitleLoading} type='secondary' size="medium" onClick={this.changeTitle.bind(this)}>
{this.state.editTitleLoading ? <Loading /> : <Icon name="check" />}
</Button>
</div>
</div>
)
}
renderEditableHeaders() {
const { userStaff, ticket } = this.props;
const filtersOnlyWithAuthor = {
authors: [
{
id: ticket.author.id*1,
isStaff: ticket.author.staff*1
}
]
};
const priorityList = [
{content: i18n('LOW')},
{content: i18n('MEDIUM')},
{content: i18n('HIGH')}
];
return (
<div className="ticket-viewer__headers">
<div className="ticket-viewer__info-row-header row">
<div className="col-md-4">{i18n('DEPARTMENT')}</div>
<div className="col-md-4">{i18n('AUTHOR')}</div>
<div className="col-md-4">{i18n('DATE')}</div>
</div>
<div className="ticket-viewer__info-row-values row">
<div className="col-md-4">
<DepartmentDropdown className="ticket-viewer__editable-dropdown"
departments={departments}
selectedIndex={_.findIndex(departments, {id: this.props.ticket.department.id})}
onChange={this.onDepartmentDropdownChanged.bind(this)} />
<div className="ticket-viewer__info">
<div className="ticket-viewer__info-container-editable">
<div className="ticket-viewer__info-container-editable__column">
<div className="ticket-viewer__info-header">{i18n('DEPARTMENT')}</div>
<div className="ticket-viewer__info-value">
{
this.state.editDepartment ?
this.renderEditDepartment() :
ticket.department.name
}
</div>
</div>
{userStaff ? this.renderEditOption("Department") : null}
</div>
<div className="col-md-4">{ticket.author.name}</div>
<div className="col-md-4">{DateTransformer.transformToString(ticket.date)}</div>
</div>
<div className="ticket-viewer__info-row-header row">
<div className="col-md-4">{i18n('PRIORITY')}</div>
<div className="col-md-4">{i18n('OWNER')}</div>
<div className="col-md-4">{i18n('STATUS')}</div>
</div>
<div className="ticket-viewer__info-row-values row">
<div className="col-md-4">
<DropDown className="ticket-viewer__editable-dropdown" items={priorityList} selectedIndex={priorities[ticket.priority]} onChange={this.onPriorityDropdownChanged.bind(this)} />
<div className="ticket-viewer__info-container-editable">
<div className="ticket-viewer__info-container-editable__column">
<div className="ticket-viewer__info-header">{i18n('TAGS')}</div>
<div className="ticket-viewer__info-value">
{
this.state.editTags ?
this.renderEditTags() :
this.renderTags()
}
</div>
</div>
{userStaff ? this.renderEditOption("Tags") : null}
</div>
<div className="col-md-4">
{this.renderAssignStaffList()}
</div>
<div className="col-md-4">
{ticket.closed ?
<Button type='secondary' size="extra-small" onClick={this.onReopenClick.bind(this)}>
{i18n('RE_OPEN')}
</Button> : i18n('OPENED')}
<div className="ticket-viewer__info-container-editable">
<div className="ticket-viewer__info-container-editable__column">
<div className="ticket-viewer__info-header">{i18n('OWNER')}</div>
<div className="ticket-viewer__info-value">
{this.renderOwnerNode()}
</div>
</div>
{userStaff ? this.renderEditOption("Owner") : null}
</div>
</div>
<div className="ticket-viewer__info">
<div className="ticket-viewer__info-container-editable">
<div className="ticket-viewer__info-container-editable__column">
<div className="ticket-viewer__info-header">{i18n('AUTHOR')}</div>
<div className="ticket-viewer__info-value">
<a className="ticket-viewer__info-author-name" href={this.searchTickets(filtersOnlyWithAuthor)}>
{ticket.author.name}
</a>
</div>
</div>
</div>
<div className="ticket-viewer__info-container-editable">
<div className="ticket-viewer__info-container-editable__column">
<div className="ticket-viewer__info-header">{i18n('STATUS')}</div>
<div className="ticket-viewer__info-value">
{this.state.editStatus ? this.renderEditStatus() : (ticket.closed ? i18n('CLOSED') : i18n('OPENED'))}
</div>
</div>
{userStaff ? this.renderEditOption("Status") : null}
</div>
</div>
</div>
);
}
renderEditTags() {
const { tags, ticket } = this.props;
return (
<div className="ticket-viewer__edit-tags">
<TagSelector
items={tags}
values={ticket.tags}
onRemoveClick={this.removeTag.bind(this)}
onTagSelected={this.addTag.bind(this)}
loading={this.state.tagSelectorLoading} />
{this.renderCancelButton("Tags")}
</div>
);
}
renderEditStatus() {
return (
<div className="ticket-viewer__edit-status__buttons">
{this.renderCancelButton("Status")}
{this.props.ticket.closed ?
<Button type='secondary' size="medium" onClick={this.onReopenClick.bind(this)}>
{i18n('RE_OPEN')}
</Button> :
this.renderCloseTicketButton()}
</div>
);
}
renderHeaders() {
const ticket = this.props.ticket;
const priorities = {
'low': 'LOW',
'medium': 'MEDIUM',
'high': 'HIGH'
};
return (
<div className="ticket-viewer__headers">
<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">{ticket.department.name}</div>
<div className="ticket-viewer__author col-md-4">{ticket.author.name}</div>
<div className="ticket-viewer__date col-md-4">{DateTransformer.transformToString(ticket.date, false)}</div>
</div>
<div className="ticket-viewer__info-row-header row">
<div className="ticket-viewer__department col-md-4">{i18n('PRIORITY')}</div>
<div className="ticket-viewer__author col-md-4">{i18n('OWNER')}</div>
<div className="ticket-viewer__date col-md-4">{i18n('STATUS')}</div>
</div>
<div className="ticket-viewer__info-row-values row">
<div className="col-md-4">
{i18n(priorities[this.props.ticket.priority || 'low'])}
<div className="ticket-viewer__info">
<div className="ticket-viewer__info-container">
<div className="ticket-viewer__info-header">{i18n('DEPARTMENT')}</div>
<div className="ticket-viewer__info-value">{ticket.department.name}</div>
</div>
<div className="col-md-4">
{this.renderOwnerNode()}
<div className="ticket-viewer__info-container">
<div className="ticket-viewer__info-header">{i18n('AUTHOR')}</div>
<div className="ticket-viewer__info-value">{ticket.author.name}</div>
</div>
<div className="col-md-4">
{i18n((this.props.ticket.closed) ? 'CLOSED' : 'OPENED')}
<div className="ticket-viewer__info-container">
<div className="ticket-viewer__info-header">{i18n('TAGS')}</div>
<div className="ticket-viewer__info-value">
{this.renderTags()}
</div>
</div>
</div>
<div className="ticket-viewer__info">
<div className="ticket-viewer__info-container">
<div className="ticket-viewer__info-header">{i18n('OWNER')}</div>
<div className="ticket-viewer__info-value">
{this.renderOwnerNode()}
</div>
</div>
<div className="ticket-viewer__info-container">
<div className="ticket-viewer__info-header">{i18n('STATUS')}</div>
<div className="ticket-viewer__info-value">
{i18n((ticket.closed) ? 'CLOSED' : 'OPENED')}
</div>
</div>
</div>
</div>
);
}
renderOwnerNode() {
let ownerNode = null;
renderTags() {
const { ticket, tags } = this.props;
const TAGS = (
ticket.tags.length ?
ticket.tags.map((tagName, index) => {
const tag = _.find(tags, {name: tagName});
return <Tag name={tag && tag.name} color={tag && tag.color} key={index} />
}) :
i18n('NONE')
);
if (this.props.assignmentAllowed) {
ownerNode = this.renderAssignStaffList();
} else {
ownerNode = (this.props.ticket.owner) ? this.props.ticket.owner.name : i18n('NONE')
}
return ownerNode;
return TAGS;
}
renderAssignStaffList() {
const items = this.getStaffAssignmentItems();
const ownerId = this.props.ticket.owner && this.props.ticket.owner.id;
renderOwnerNode() {
const { assignmentAllowed, ticket } = this.props;
const filtersOnlyWithOwner = ticket.owner && {owners: [ticket.owner.id*1]};
let ownerNode = null;
if(assignmentAllowed && ticket.owner) {
ownerNode = (
<a className="ticket-viewer__info-owner-name" href={this.searchTickets(filtersOnlyWithOwner)}>
{ticket.owner.name}
</a>
);
} else {
ownerNode = (
<span className="ticket-viewer__info-owner-name">
{(ticket.owner) ? ticket.owner.name : i18n('NONE')}
</span>
);
}
return (assignmentAllowed && this.state.editOwner) ? this.renderEditOwner() : ownerNode;
}
renderEditOwner() {
const items = this.getStaffAssignmentItems();
const { ticket } = this.props;
const ownerId = ticket.owner && ticket.owner.id*1;
let selectedIndex = _.findIndex(items, {id: ownerId});
selectedIndex = (selectedIndex !== -1) ? selectedIndex : 0;
return (
<DropDown
className="ticket-viewer__editable-dropdown" items={items}
selectedIndex={selectedIndex}
onChange={this.onAssignmentChange.bind(this)}
/>
<div className="ticket-viewer__edit-owner">
<DropDown
className="ticket-viewer__editable-dropdown" items={items}
selectedIndex={selectedIndex}
onChange={this.onAssignmentChange.bind(this)} />
{this.renderCancelButton("Owner")}
</div>
);
}
renderEditDepartment() {
const { ticket } = this.props;
const departments = this.getDepartmentsForTransfer();
return (
<div className="ticket-viewer__edit-owner">
<DepartmentDropdown
className="ticket-viewer__editable-dropdown"
departments={departments}
selectedIndex={_.findIndex(departments, {id: ticket.department.id})}
onChange={this.onDepartmentDropdownChanged.bind(this)} />
{this.renderCancelButton("Department")}
</div>
);
}
renderEditTitleOption() {
return(
<span className="ticket-viewer__edit-title-icon">
<Icon name="pencil" onClick={() => this.setState({editTitle: true})} />
</span>
)
}
renderEditOption(option) {
return(
<span className="ticket-viewer__edit-icon">
<Icon name="pencil" onClick={() => this.setState({["edit"+option]: true})} />
</span>
);
}
renderEditedTitleText(){
return(
<div className="ticket-viewer__edited-title-text"> {i18n('TITLE_EDITED')} </div>
)
}
renderCancelButton(option) {
return <Button type='link' size="medium" onClick={() => this.setState({["edit"+option]: false})}>{i18n('CLOSE')}</Button>
}
renderTicketEvent(options, index) {
if (this.props.userStaff && typeof options.content === 'string') {
const { userStaff, ticket, userId, allowAttachments } = this.props;
if(userStaff && typeof options.content === 'string') {
options.content = MentionsParser.parse(options.content);
}
return (
<TicketEvent {...options} author={(!_.isEmpty(options.author)) ? options.author : this.props.ticket.author} key={index} />
<TicketEvent
{...options}
author={(!_.isEmpty(options.author)) ? options.author : ticket.author}
userStaff={userStaff}
userId={userId}
onEdit={this.onEdit.bind(this, options.id)}
edit={this.state.edit && this.state.editId == options.id}
onToggleEdit={this.onToggleEdit.bind(this, options.id)}
key={index}
allowAttachments={allowAttachments} />
);
}
renderResponseField() {
const { allowAttachments } = this.props;
return (
<div className="ticket-viewer__response">
<Form {...this.getCommentFormProps()}>
@ -230,13 +426,15 @@ class TicketViewer extends React.Component {
</div>
</div>
<div className="ticket-viewer__response-field row">
<FormField name="content" validation="TEXT_AREA" required field="textarea" fieldProps={{allowImages: this.props.allowAttachments}}/>
{(this.props.allowAttachments) ? <FormField name="file" field="file"/> : null}
<FormField name="content" validation="TEXT_AREA" required field="textarea" fieldProps={{allowImages: allowAttachments}} />
<div className="ticket-viewer__response-buttons">
{allowAttachments ? <FormField name="file" field="file" /> : null}
<SubmitButton type="secondary">{i18n('RESPOND_TICKET')}</SubmitButton>
<div>
<Button size="medium" onClick={this.onCloseTicketClick.bind(this)}>{i18n('CLOSE_TICKET')}</Button>
{(this.showDeleteButton())? <Button className="ticket-viewer__delete-button" size="medium" onClick={this.onDeleteTicketClick.bind(this)}>{i18n('DELETE_TICKET')}</Button> : null}
</div>
<div className="ticket-viewer__buttons-column">
<div className="ticket-viewer__buttons-row">
{(this.showDeleteButton()) ? this.renderDeleteTicketButton() : null}
{this.renderCloseTicketButton()}
</div>
</div>
</div>
@ -246,10 +444,22 @@ class TicketViewer extends React.Component {
);
}
renderDeleteTicketButton() {
return (
<Button className="ticket-viewer__delete-button" size="medium" onClick={this.onDeleteTicketClick.bind(this)}>{i18n('DELETE_TICKET')}</Button>
);
}
renderCloseTicketButton() {
return (
<Button size="medium" onClick={this.onCloseTicketClick.bind(this)}>{i18n('CLOSE_TICKET')}</Button>
);
}
renderCustomResponses() {
let customResponsesNode = null;
if (this.props.customResponses && this.props.editable) {
if(this.props.customResponses && this.props.editable) {
let customResponses = this.props.customResponses.map((customResponse) => {
return {
content: customResponse.name
@ -262,7 +472,7 @@ class TicketViewer extends React.Component {
customResponsesNode = (
<div className="ticket-viewer__response-custom">
<DropDown items={customResponses} size="medium" onChange={this.onCustomResponsesChanged.bind(this)}/>
<DropDown items={customResponses} size="medium" onChange={this.onCustomResponsesChanged.bind(this)} />
</div>
);
}
@ -271,10 +481,10 @@ class TicketViewer extends React.Component {
}
renderPrivate() {
if (this.props.userStaff) {
if(this.props.userStaff) {
return (
<div className="ticket-viewer__response-private">
<FormField label={i18n('PRIVATE')} name="private" field="checkbox"/>
<FormField label={i18n('PRIVATE')} name="private" field="checkbox" />
<InfoTooltip className="ticket-viewer__response-private-info" text={i18n('PRIVATE_RESPONSE_DESCRIPTION')} />
</div>
);
@ -289,6 +499,13 @@ class TicketViewer extends React.Component {
);
}
searchTickets(filters) {
const SEARCH_TICKETS_PATH = '/admin/panel/tickets/search-tickets';
const urlQuery = filters && searchTicketsUtils.getFiltersForURL({filters});
return urlQuery && `${SEARCH_TICKETS_PATH}${urlQuery}`;
}
getCommentFormProps() {
return {
onSubmit: this.onSubmit.bind(this),
@ -312,11 +529,7 @@ class TicketViewer extends React.Component {
}
onDepartmentDropdownChanged(event) {
AreYouSure.openModal(null, this.changeDepartment.bind(this, event.index));
}
onPriorityDropdownChanged(event) {
AreYouSure.openModal(null, this.changePriority.bind(this, event.index));
AreYouSure.openModal(null, this.changeDepartment.bind(this, this.getDepartmentsForTransfer()[event.index].id));
}
onAssignmentChange(event) {
@ -330,27 +543,39 @@ class TicketViewer extends React.Component {
let APICallPromise = new Promise(resolve => resolve());
if(owner) {
APICallPromise.then(() => API.call({
APICallPromise = APICallPromise.then(() => API.call({
path: '/staff/un-assign-ticket',
data: { ticketNumber }
}));
}
if(id !== 0) {
APICallPromise.then(() => API.call({
APICallPromise = APICallPromise.then(() => API.call({
path: '/staff/assign-ticket',
data: { ticketNumber, staffId: id }
}));
}
APICallPromise.then(this.onTicketModification.bind(this));
this.setState({
editOwner: false
});
return APICallPromise.then(this.onTicketModification.bind(this));
}
onReopenClick() {
this.setState({
editStatus: false
});
AreYouSure.openModal(null, this.reopenTicket.bind(this));
}
onCloseTicketClick(event) {
this.setState({
editStatus: false
});
event.preventDefault();
AreYouSure.openModal(null, this.closeTicket.bind(this));
}
@ -360,8 +585,33 @@ class TicketViewer extends React.Component {
AreYouSure.openModal(null, this.deleteTicket.bind(this));
}
reopenTicket() {
changeTitle(){
this.setState({
editTitleLoading: true
});
API.call({
path: '/ticket/edit-title',
data: {
ticketNumber: this.props.ticket.ticketNumber,
title: this.state.newTitle
}
}).then(() => {
this.setState({
editTitle: false,
editTitleError: false,
editTitleLoading: false
});
this.onTicketModification();
}).catch((result) => {
this.setState({
editTitleError: i18n(result.message),
editTitleLoading: false
})
});
}
reopenTicket() {
return API.call({
path: '/ticket/re-open',
data: {
ticketNumber: this.props.ticket.ticketNumber
@ -370,7 +620,7 @@ class TicketViewer extends React.Component {
}
closeTicket() {
API.call({
return API.call({
path: '/ticket/close',
data: {
ticketNumber: this.props.ticket.ticketNumber
@ -379,38 +629,75 @@ class TicketViewer extends React.Component {
}
deleteTicket() {
API.call({
return API.call({
path: '/ticket/delete',
data: {
ticketNumber: this.props.ticket.ticketNumber
}
}).then(this.onTicketModification.bind(this));
}).then((result) => {
this.onTicketModification(result);
history.push('/admin/panel/tickets/my-tickets/');
});
}
changeDepartment(index) {
API.call({
changeDepartment(departmentId) {
const { userId, userDepartments, ticket } = this.props;
this.setState({
editDepartment: false
});
return API.call({
path: '/ticket/change-department',
data: {
ticketNumber: this.props.ticket.ticketNumber,
departmentId: this.getDepartmentsForTransfer()[index].id
ticketNumber: ticket.ticketNumber,
departmentId
}
}).then(this.onTicketModification.bind(this));
}).then((_.some(userDepartments, {id: departmentId}) || (userId === (ticket.author.id*1))) ? this.onTicketModification.bind(this) : history.goBack());
}
changePriority(index) {
const priorities = [
'low',
'medium',
'high'
];
addTag(tag) {
this.setState({
tagSelectorLoading: true,
})
API.call({
path: '/ticket/change-priority',
path: '/ticket/add-tag',
data: {
ticketNumber: this.props.ticket.ticketNumber,
priority: priorities[index]
tagId: tag
}
}).then(this.onTicketModification.bind(this));
})
.then(() => {
this.setState({
tagSelectorLoading: false,
});
this.onTicketModification();
})
.catch(() => this.setState({
tagSelectorLoading: false,
}))
}
removeTag(tag) {
this.setState({
tagSelectorLoading: true,
});
API.call({
path: '/ticket/remove-tag',
data: {
ticketNumber: this.props.ticket.ticketNumber,
tagId: tag
}
}).then(() => {
this.setState({
tagSelectorLoading: false,
});
this.onTicketModification();
}).catch(() => this.setState({
tagSelectorLoading: false,
}))
}
onCustomResponsesChanged({index}) {
@ -428,6 +715,52 @@ class TicketViewer extends React.Component {
}
}
onToggleEdit(ticketEventId){
this.setState({
edit: !this.state.edit,
editId: ticketEventId
})
}
onEdit(ticketeventid,{content}) {
this.setState({
loading: true
});
const data = {};
if(ticketeventid){
data.ticketEventId = ticketeventid
}else{
data.ticketNumber = this.props.ticket.ticketNumber
}
API.call({
path: '/ticket/edit-comment',
data: _.extend(
data,
TextEditor.getContentFormData(content)
)
}).then(this.onEditCommentSuccess.bind(this), this.onFailCommentFail.bind(this));
}
onEditCommentSuccess() {
this.setState({
loading: false,
commentError: false,
commentEdited: false,
edit:false
});
this.onTicketModification();
}
onFailCommentFail() {
this.setState({
loading: false,
commentError: true
});
}
onSubmit(formState) {
this.setState({
loading: true
@ -437,7 +770,7 @@ class TicketViewer extends React.Component {
path: '/ticket/comment',
dataAsForm: true,
data: _.extend({
ticketNumber: this.props.ticket.ticketNumber
ticketNumber: this.props.ticket.ticketNumber,
}, formState, {private: formState.private ? 1 : 0}, TextEditor.getContentFormData(formState.content))
}).then(this.onCommentSuccess.bind(this), this.onCommentFail.bind(this));
}
@ -461,60 +794,78 @@ class TicketViewer extends React.Component {
}
onTicketModification() {
if (this.props.onChange) {
this.props.onChange();
}
const { onChange } = this.props;
onChange && onChange();
}
getStaffAssignmentItems() {
const {staffMembers, userDepartments, userId, ticket} = this.props;
const ticketDepartmentId = ticket.department.id;
const { userDepartments, userId, ticket } = this.props;
let staffAssignmentItems = [
{content: 'None', id: 0}
{content: i18n('NONE'), contentOnSelected: i18n('NONE'), id: 0}
];
if(_.any(userDepartments, {id: ticketDepartmentId})) {
staffAssignmentItems.push({content: i18n('ASSIGN_TO_ME'), id: userId});
if(_.some(userDepartments, {id: ticket.department.id})) {
staffAssignmentItems.push({
content: i18n('ASSIGN_TO_ME'),
contentOnSelected: this.getCurrentStaff().name,
id: userId
});
}
staffAssignmentItems = staffAssignmentItems.concat(
_.map(
_.filter(staffMembers, ({id, departments}) => {
return (id != userId) && _.any(departments, {id: ticketDepartmentId});
}),
({id, name}) => ({content: name, id})
this.getStaffList(),
({id, name}) => ({content: name, contentOnSelected: name, id: id*1})
)
);
return staffAssignmentItems;
}
getStaffList() {
const { userId, staffMembers, ticket } = this.props;
return _.filter(staffMembers, ({id, departments}) => {
return (id != userId) && _.some(departments, {id: ticket.department.id});
})
}
getCurrentStaff() {
const { userId, staffMembers, ticket } = this.props;
return _.find(staffMembers, ({id}) => {return id == userId});
}
getDepartmentsForTransfer() {
return this.props.ticket.author.staff ? SessionStore.getDepartments() : this.getPublicDepartments();
}
showDeleteButton() {
if(!this.props.ticket.owner) {
if(this.props.userLevel == 3) return true;
if(this.props.userId == this.props.ticket.author.id) {
if((this.props.userStaff && this.props.ticket.author.staff) || (!this.props.userStaff && !this.props.ticket.author.staff)){
const { ticket, userLevel, userId, userStaff } = this.props;
if(!ticket.owner) {
if(userLevel === 3) return true;
if(userId == ticket.author.id*1) {
if((userStaff && ticket.author.staff) || (!userStaff && !ticket.author.staff)){
return true;
}
}
}
return false;
}
}
export default connect((store) => {
return {
userId: store.session.userId,
userId: store.session.userId*1,
userStaff: store.session.staff,
userDepartments: store.session.userDepartments,
staffMembers: store.adminData.staffMembers,
staffMembersLoaded: store.adminData.staffMembersLoaded,
allowAttachments: store.config['allow-attachments'],
userSystemEnabled: store.config['user-system-enabled'],
userLevel: store.session.userLevel
userLevel: store.session.userLevel*1,
tags: store.config['tags'].map((tag) => {return {...tag, id: tag.id*1}})
};
})(TicketViewer);

View File

@ -1,7 +1,6 @@
@import "../scss/vars";
.ticket-viewer {
&__header {
background-color: $primary-blue;
border-top-right-radius: 4px;
@ -9,31 +8,148 @@
color: white;
font-size: 16px;
padding: 6px 0;
display: flex;
align-items:center;
justify-content:center;
&:hover {
.ticket-viewer__edit-title-icon {
color: $grey;
}
}
}
&__buttons-column {
padding-top: 10px;
display: flex;
flex-direction: column;
justify-content: flex-start;
align-items: flex-start;
}
&__buttons-row {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: flex-start;
width: 250px;
}
&__edited-title-text {
font-style: italic;
font-size: 14px;
margin: 0 10px;
}
&__edit-icon {
right: 12px;
margin: 0 10px;
color: $light-grey;
&:hover {
cursor:pointer;
}
}
&__edit-title-icon {
color: $primary-blue;
right: 12px;
margin: 0 10px;
&:hover {
cursor:pointer;
}
}
&__edit-status__buttons {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
margin-top: 10px;
}
&___input-edit-title {
color: black;
align-items:center;
justify-content: center;
margin-bottom: 6px;
margin-right: 6px;
.input__text {
height: 25px;
}
}
&__edit-title__buttons {
display: flex;
flex-direction: row;
justify-content: space-around;
align-items: center;
width: 160px;
}
&__number {
color: white;
margin-right: 10px;
margin-right: 30px;
font-size: 14px;
}
&__title {
display: inline-block;
max-width: 375px;
}
&__flag {
margin-left: 10px;
margin-left: 30px;
}
&__info-row-header {
&__info {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: flex-start;
background-color: $light-grey;
font-weight: bold;
}
padding: 0 15px 30px 15px;
&__info-row-values {
background-color: $light-grey;
color: $secondary-blue;
padding-bottom: 10px;
&-container {
display: flex;
flex-direction: column;
justify-content: space-around;
align-items: center;
min-width: 150px;
max-width: 250px;
}
&-container-editable {
display: flex;
flex-direction: row;
justify-content: center;
align-items: flex-start;
min-width: 300px;
&:hover {
.ticket-viewer__edit-icon {
color: $primary-blue;
}
}
&__column {
display: flex;
flex-direction: column;
justify-content: space-around;
align-items: center;
min-width: 170px;
}
}
&-header {
font-weight: bold;
}
&-value {
color: $secondary-blue;
padding-bottom: 10px;
width: 100%;
}
}
&__editable-dropdown {
@ -92,12 +208,48 @@
&-buttons {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
}
}
&__delete-button {
margin-left: 10px;
}
@media screen and (max-width: 1151px) {
.ticket-viewer__info {
&-container {
width: 200px;
min-width: unset;
max-width: unset;
}
&-container-editable {
min-width: 250px;
max-width: 300px;
}
}
}
@media screen and (max-width: 992px) {
.ticket-viewer__info {
flex-direction: column;
justify-content: space-between;
align-items: center;
}
}
@media screen and (max-width: 571px) {
.ticket-viewer {
&__number, &__edit-title-icon {
margin: 0 5px;
}
&__flag {
padding: 0 10px;
}
}
}
}

View File

@ -37,15 +37,16 @@ class TopicEditModal extends React.Component {
<FormField name="title" label={i18n('TITLE')} fieldProps={{size: 'large'}} validation="TITLE" required />
<FormField name="icon" className="topic-edit-modal__icon" label={i18n('ICON')} decorator={IconSelector} />
<FormField name="color" className="topic-edit-modal__color" label={i18n('COLOR')} decorator={ColorSelector} />
<FormField className="topic-edit-modal__private" label={i18n('PRIVATE')} name="private" field="checkbox"/>
<FormField className="topic-edit-modal__private" label={i18n('PRIVATE')} name="private" field="checkbox" />
<InfoTooltip className="topic-edit-modal__private" text={i18n('PRIVATE_TOPIC_DESCRIPTION')} />
<SubmitButton className="topic-edit-modal__save-button" type="secondary" size="small">
{i18n('SAVE')}
</SubmitButton>
<Button className="topic-edit-modal__discard-button" onClick={this.onDiscardClick.bind(this)} size="small">
{i18n('CANCEL')}
</Button>
<div className="topic-edit-modal__buttons-container">
<Button className="topic-edit-modal__discard-button" onClick={this.onDiscardClick.bind(this)} size="small">
{i18n('CANCEL')}
</Button>
<SubmitButton className="topic-edit-modal__save-button" type="secondary" size="small">
{i18n('SAVE')}
</SubmitButton>
</div>
</Form>
</div>
);

View File

@ -13,9 +13,19 @@
}
&__discard-button {
float: right;
&__buttons-container {
width: 100%;
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
padding: 20px 0 0 0;
}
&__discard-button {
float: left;
}
&__private {
display: inline-block;
padding-right: 10px;

View File

@ -166,7 +166,7 @@ class TopicViewer extends React.Component {
}
onDeleteClick() {
API.call({
return API.call({
path: '/article/delete-topic',
data: {
topicId: this.props.id

View File

@ -50,7 +50,7 @@ class App extends React.Component {
'application': true,
'application_modal-opened': (this.props.modal.opened),
'application_full-width': (this.props.config.layout === 'full-width' && !_.includes(this.props.location.pathname, '/admin')),
'application_user-system': (this.props.config['user-system-enabled'])
'application_mandatory-login': (this.props.config['mandatory-login'])
};
return classNames(classes);
@ -99,7 +99,7 @@ class App extends React.Component {
history.push('/');
}
if(props.config['user-system-enabled'] && _.includes(props.location.pathname, '/check-ticket')) {
if(props.config['mandatory-login'] && _.includes(props.location.pathname, '/check-ticket')) {
history.push('/');
}
@ -111,7 +111,7 @@ class App extends React.Component {
history.push('/admin');
}
if(isProd && _.includes(props.location.pathname, '/components-demo')) {
if(process.env.NODE_ENV === 'production' && _.includes(props.location.pathname, '/components-demo')) {
history.push('/');
}
}

View File

@ -32,13 +32,14 @@ import AdminPanelMyAccount from 'app/admin/panel/dashboard/admin-panel-my-accoun
import AdminPanelMyTickets from 'app/admin/panel/tickets/admin-panel-my-tickets';
import AdminPanelNewTickets from 'app/admin/panel/tickets/admin-panel-new-tickets';
import AdminPanelAllTickets from 'app/admin/panel/tickets/admin-panel-all-tickets';
import AdminPanelSearchTickets from 'app/admin/panel/tickets/admin-panel-search-tickets';
import AdminPanelViewTicket from 'app/admin/panel/tickets/admin-panel-view-ticket';
import AdminPanelCustomResponses from 'app/admin/panel/tickets/admin-panel-custom-responses';
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';
@ -49,7 +50,8 @@ import AdminPanelViewStaff from 'app/admin/panel/staff/admin-panel-view-staff';
import AdminPanelSystemPreferences from 'app/admin/panel/settings/admin-panel-system-preferences';
import AdminPanelAdvancedSettings from 'app/admin/panel/settings/admin-panel-advanced-settings';
import AdminPanelEmailTemplates from 'app/admin/panel/settings/admin-panel-email-templates';
import AdminPanelEmailSettings from 'app/admin/panel/settings/admin-panel-email-settings';
import AdminPanelCustomTags from 'app/admin/panel/settings/admin-panel-custom-tags';
// INSTALLATION
import InstallLayout from 'app/install/install-layout';
@ -59,7 +61,7 @@ import InstallStep3Database from 'app/install/install-step-3-database';
import InstallStep4UserSystem from 'app/install/install-step-4-user-system';
import InstallStep5Settings from 'app/install/install-step-5-settings';
import InstallStep6Admin from 'app/install/install-step-6-admin';
import InstallStep7Completed from 'app/install/install-step-7-completed';
import InstallCompleted from 'app/install/install-completed';
export default (
<Router history={history}>
@ -96,12 +98,12 @@ export default (
<Route path="step-4" component={InstallStep4UserSystem} />
<Route path="step-5" component={InstallStep5Settings} />
<Route path="step-6" component={InstallStep6Admin} />
<Route path="step-7" component={InstallStep7Completed} />
<Route path="completed" component={InstallCompleted} />
</Route>
<Route path="admin">
<IndexRoute component={AdminLoginPage} />
<Route path="panel" component={AdminPanelLayout}>
<IndexRedirect to="stats" />
<IndexRedirect to="activity" />
<Route path="stats" component={AdminPanelStats} />
<Route path="activity" component={AdminPanelActivity} />
<Route path="my-account" component={AdminPanelMyAccount} />
@ -110,7 +112,7 @@ export default (
<IndexRedirect to="my-tickets" />
<Route path="my-tickets" component={AdminPanelMyTickets} />
<Route path="new-tickets" component={AdminPanelNewTickets} />
<Route path="all-tickets" component={AdminPanelAllTickets} />
<Route path="search-tickets" component={AdminPanelSearchTickets} />
<Route path="custom-responses" component={AdminPanelCustomResponses} />
<Route path="view-ticket/:ticketNumber" component={AdminPanelViewTicket} />
</Route>
@ -120,6 +122,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">
@ -139,7 +142,8 @@ export default (
<IndexRedirect to="system-preferences" />
<Route path="system-preferences" component={AdminPanelSystemPreferences} />
<Route path="advanced-settings" component={AdminPanelAdvancedSettings} />
<Route path="email-templates" component={AdminPanelEmailTemplates} />
<Route path="email-settings" component={AdminPanelEmailSettings} />
<Route path="custom-tags" component={AdminPanelCustomTags} />
</Route>
</Route>
</Route>

View File

@ -1,5 +1,5 @@
export default {
dispatch: stub(),
dispatch: stub().returns(new Promise(r => r())),
getState: stub().returns({
config: {},
session: {},

View File

@ -49,11 +49,32 @@ class AdminLoginPage extends React.Component {
<div>
<Widget className="admin-login-page__content">
<div className="admin-login-page__image"><img width="100%" src={API.getURL() + '/images/logo.png'} alt="OpenSupports Admin Panel"/></div>
<div className="admin-login-page__login-form">
<Form onSubmit={this.onLoginFormSubmit.bind(this)} loading={this.props.session.pending}>
<FormField name="email" label={i18n('EMAIL')} field="input" validation="EMAIL" fieldProps={{size:'large'}} required />
<FormField name="password" label={i18n('PASSWORD')} field="input" fieldProps={{password:true, size:'large'}} />
<SubmitButton>{i18n('LOG_IN')}</SubmitButton>
<div className="admin-login-page__login-form-container">
<Form {...this.getLoginFormProps()}>
<div className="admin-login-page__login-form-container__login-form__fields">
<FormField
name="email"
label={i18n('EMAIL')}
className="admin-login-page__login-form-container__login-form__fields__email"
field="input"
validation="EMAIL"
fieldProps={{size:'large'}}
required />
<FormField
name="password"
label={i18n('PASSWORD')}
className="admin-login-page__login-form-container__login-form__fields__password"
field="input"
fieldProps={{password:true, size:'large'}} />
<FormField
name="remember"
label={i18n('REMEMBER_ME')}
className="admin-login-page__login-form-container__login-form__fields__remember"
field="checkbox" />
</div>
<div className="admin-login-page__login-form-container__login-form__submit-button">
<SubmitButton>{i18n('LOG_IN')}</SubmitButton>
</div>
</Form>
</div>
{this.renderRecoverStatus()}
@ -68,7 +89,7 @@ class AdminLoginPage extends React.Component {
renderPasswordRecovery() {
return (
<div>
<div className="admin-login-page__recovery-form-container">
<PasswordRecovery recoverSent={this.state.recoverSent} formProps={this.getRecoverFormProps()} onBackToLoginClick={this.onBackToLoginClick.bind(this)} renderLogo={true}/>
</div>
);
@ -105,7 +126,7 @@ class AdminLoginPage extends React.Component {
getLoginFormProps() {
return {
loading: this.props.session.pending,
className: 'admin-login-page__form',
className: 'admin-login-page__login-form-container__login-form',
ref: 'loginForm',
onSubmit: this.onLoginFormSubmit.bind(this),
errors: this.getLoginFormErrors(),
@ -114,12 +135,17 @@ class AdminLoginPage extends React.Component {
}
getRecoverFormProps() {
const {
loadingRecover,
recoverFormErrors
} = this.state;
return {
loading: this.state.loadingRecover,
className: 'admin-login-page__form',
loading: loadingRecover,
className: 'admin-login-page__recovery-form-container__recovery-form',
ref: 'recoverForm',
onSubmit: this.onForgotPasswordSubmit.bind(this),
errors: this.state.recoverFormErrors,
errors: recoverFormErrors,
onValidateErrors: this.onRecoverFormErrorsValidation.bind(this)
};
}

View File

@ -19,9 +19,13 @@
margin-bottom: 30px;
}
&__login-form {
&__login-form-container {
margin: 0 auto;
display: inline-block;
&__login-form__fields {
padding: 10px 0;
}
}
&__error {

View File

@ -29,7 +29,7 @@ class AdminPanel extends React.Component {
</div>
<div className="row">
<div className="col-md-12 admin-panel-layout__content">
<Widget>
<Widget className='admin-panel-layout__content__widget'>
{this.props.children}
</Widget>
</div>

View File

@ -4,4 +4,10 @@
&__header {
margin-bottom: 20px;
}
@media screen and (max-width: 415px) {
.admin-panel-layout__content__widget {
padding: 20px 5px;
}
}
}

View File

@ -2,10 +2,13 @@ import React from 'react';
import _ from 'lodash';
import {connect} from 'react-redux';
import {dispatch} from 'app/store';
import i18n from 'lib-app/i18n';
import Menu from 'core-components/menu';
import queryString from 'query-string';
const INITIAL_PAGE = 1;
class AdminPanelMenu extends React.Component {
static contextTypes = {
@ -70,15 +73,32 @@ class AdminPanelMenu extends React.Component {
onGroupItemClick(index) {
const group = this.getRoutes()[this.getGroupIndex()];
const item = group.items[index];
this.context.router.push(group.items[index].path);
this.context.router.push(item.path);
item.onItemClick && item.onItemClick();
}
getGroupItemIndex() {
const { location } = this.props;
const search = window.location.search;
const filtersInURL = queryString.parse(search);
const group = this.getRoutes()[this.getGroupIndex()];
const pathname = this.props.location.pathname;
const pathname = location.pathname + location.search;
const SEARCH_TICKETS_PATH = '/admin/panel/tickets/search-tickets';
return _.findIndex(group.items, {path: pathname});
return (
_.findIndex(
group.items,
(item) => {
if(location.pathname === SEARCH_TICKETS_PATH) {
const customTicketsListNumber = queryString.parse(item.path.slice(SEARCH_TICKETS_PATH.length)).custom;
return item.path.includes(SEARCH_TICKETS_PATH) && customTicketsListNumber === filtersInURL.custom;
}
return item.path === pathname;
}
)
);
}
getGroupIndex() {
@ -90,8 +110,24 @@ class AdminPanelMenu extends React.Component {
return (groupIndex === -1) ? 0 : groupIndex;
}
getCustomlists() {
if(window.customTicketList){
return window.customTicketList.map((item, index) => {
return {
name: item.title,
path: `/admin/panel/tickets/search-tickets?custom=${index}&page=${INITIAL_PAGE}&useInitialValues=true`,
level: 1,
}
})
} else {
return [];
}
}
getRoutes() {
return this.getItemsByFilteredByLevel([
const customLists = this.getCustomlists();
return this.getItemsByFilteredByLevel(_.without([
{
groupName: i18n('DASHBOARD'),
path: '/admin/panel',
@ -99,13 +135,13 @@ class AdminPanelMenu extends React.Component {
level: 1,
items: this.getItemsByFilteredByLevel([
{
name: i18n('STATISTICS'),
path: '/admin/panel/stats',
name: i18n('LAST_ACTIVITY'),
path: '/admin/panel/activity',
level: 1
},
{
name: i18n('LAST_ACTIVITY'),
path: '/admin/panel/activity',
name: i18n('STATISTICS'),
path: '/admin/panel/stats',
level: 1
}
])
@ -127,15 +163,16 @@ class AdminPanelMenu extends React.Component {
level: 1
},
{
name: i18n('ALL_TICKETS'),
path: '/admin/panel/tickets/all-tickets',
level: 1
name: i18n('SEARCH_TICKETS'),
path: `/admin/panel/tickets/search-tickets?page=${INITIAL_PAGE}&useInitialValues=true`,
level: 1,
},
{
name: i18n('CUSTOM_RESPONSES'),
path: '/admin/panel/tickets/custom-responses',
level: 2
}
},
...customLists
])
},
{
@ -153,6 +190,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
}
])
},
@ -170,7 +212,6 @@ class AdminPanelMenu extends React.Component {
])
},
{
groupName: i18n('STAFF'),
path: '/admin/panel/staff',
icon: 'users',
@ -206,13 +247,18 @@ class AdminPanelMenu extends React.Component {
level: 3
},
{
name: i18n('EMAIL_TEMPLATES'),
path: '/admin/panel/settings/email-templates',
name: i18n('EMAIL_SETTINGS'),
path: '/admin/panel/settings/email-settings',
level: 3
},
{
name: i18n('CUSTOM_TAGS'),
path: '/admin/panel/settings/custom-tags',
level: 3
}
])
}
]);
], null));
}
getItemsByFilteredByLevel(items) {
@ -222,6 +268,8 @@ class AdminPanelMenu extends React.Component {
export default connect((store) => {
return {
level: store.session.userLevel
level: store.session.userLevel,
config: store.config,
searchFilters: store.searchFilters,
};
})(AdminPanelMenu);

View File

@ -3,4 +3,11 @@
&__list {
padding: 0 50px;
}
}
@media screen and (max-width: 415px) {
.admin-panel-list-articles__list {
padding: 0;
}
}
}

View File

@ -66,17 +66,17 @@ class AdminPanelViewArticle extends React.Component {
return (
<div className="admin-panel-view-article__content">
<div className="admin-panel-view-article__edit-buttons">
<Button className="admin-panel-view-article__edit-button" size="medium" onClick={this.onEditClick.bind(this, article)} type="tertiary">
{i18n('EDIT')}
</Button>
<Button size="medium" onClick={this.onDeleteClick.bind(this, article)}>
{i18n('DELETE')}
</Button>
<Button className="admin-panel-view-article__edit-button" size="medium" onClick={this.onEditClick.bind(this, article)} type="tertiary">
{i18n('EDIT')}
</Button>
</div>
<div className="admin-panel-view-article__article">
<Header title={article.title}/>
<div className="admin-panel-view-article__article-content">
<div className="admin-panel-view-article__article-content ql-editor">
<div dangerouslySetInnerHTML={{__html: MentionsParser.parse(article.content)}}/>
</div>
<div className="admin-panel-view-article__last-edited">
@ -91,13 +91,13 @@ class AdminPanelViewArticle extends React.Component {
return (
<Form values={this.state.form} onChange={(form) => this.setState({form})} onSubmit={this.onFormSubmit.bind(this)}>
<div className="admin-panel-view-article__buttons">
<SubmitButton className="admin-panel-view-article__button" type="secondary" size="medium">{i18n('SAVE')}</SubmitButton>
<Button className="admin-panel-view-article__button" size="medium" onClick={this.onFormCancel.bind(this)}>
{i18n('CANCEL')}
</Button>
<SubmitButton className="admin-panel-view-article__button" type="secondary" size="medium">{i18n('SAVE')}</SubmitButton>
</div>
<FormField name="title" label={i18n('TITLE')} />
<FormField name="content" label={i18n('CONTENT')} field="textarea" fieldProps={{allowImages: this.props.allowAttachments}}/>
<FormField name="content" label={i18n('CONTENT')} field="textarea" validation="TEXT_AREA" required fieldProps={{allowImages: this.props.allowAttachments}}/>
</Form>
);
}
@ -153,7 +153,7 @@ class AdminPanelViewArticle extends React.Component {
}
onArticleDeleted(article) {
API.call({
return API.call({
path: '/article/delete',
data: {
articleId: article.id

View File

@ -1,7 +1,15 @@
.admin-panel-view-article {
&__content {
word-break: break-word;
}
&__edit-buttons {
text-align: left;
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
width: 200px;
margin-bottom: 20px;
}
@ -19,8 +27,8 @@
text-align: left;
margin-bottom: 20px;
}
&__button {
margin-right: 20px;
}
}
}

View File

@ -43,7 +43,7 @@ class AdminPanelActivity extends React.Component {
</div>
);
}
getMenuProps() {
return {
className: 'admin-panel-activity__menu',
@ -148,4 +148,4 @@ class AdminPanelActivity extends React.Component {
}
}
export default AdminPanelActivity;
export default AdminPanelActivity;

View File

@ -3,7 +3,6 @@ import {connect} from 'react-redux';
import _ from 'lodash';
import i18n from 'lib-app/i18n';
import API from 'lib-app/api-call';
import SessionActions from 'actions/session-actions';
import StaffEditor from 'app/admin/panel/staff/staff-editor';

View File

@ -1,20 +1,288 @@
import React from 'react';
import { connect } from 'react-redux';
import { Bar, HorizontalBar } from 'react-chartjs-2';
import date from 'lib-app/date';
import API from 'lib-app/api-call';
import i18n from 'lib-app/i18n';
import Stats from 'app-components/stats';
import StatCard from 'app-components/stat-card';
import Header from 'core-components/header';
import Tooltip from 'core-components/tooltip';
import Form from 'core-components/form';
import FormField from 'core-components/form-field';
import Icon from 'core-components/icon';
import Loading from 'core-components/loading';
import SubmitButton from 'core-components/submit-button';
import Button from 'core-components/button';
class AdminPanelStats extends React.Component {
state = {
loading: true,
rawForm: {
dateRange: this.getInitialDateRange(),
departments: [],
owners: [],
tags: []
},
ticketData: {}
};
getInitialDateRange() {
let firstDayOfMonth = new Date();
firstDayOfMonth.setDate(1);
firstDayOfMonth.setHours(0);
firstDayOfMonth.setMinutes(0);
let todayAtNight = new Date();
todayAtNight.setHours(23);
todayAtNight.setMinutes(59);
return {
startDate: date.getFullDate(firstDayOfMonth),
endDate: date.getFullDate(todayAtNight)
}
}
componentDidMount() {
this.retrieveStats();
}
render() {
const {
loading,
rawForm
} = this.state;
return (
<div className="admin-panel-stats">
<Header title={i18n('STATISTICS')} description={i18n('STATISTICS_DESCRIPTION')}/>
<Stats type="general"/>
<Header title={i18n('STATISTICS')} description={i18n('STATISTICS_DESCRIPTION')} />
<Form className="admin-panel-stats__form" loading={loading} values={rawForm} onChange={this.onFormChange.bind(this)} onSubmit={this.onFormSubmit.bind(this)}>
<div className="admin-panel-stats__form__container">
<div className="admin-panel-stats__form__container__row">
<div className="admin-panel-stats__form__container__col">
<FormField name="dateRange" label={i18n('DATE')} field="date-range" fieldProps={{defaultValue: rawForm.dateRange}} />
<FormField name="tags" label={i18n('TAGS')} field="tag-selector" fieldProps={{items: this.getTagItems()}} />
</div>
<div className="admin-panel-stats__form__container__col">
<FormField name="departments" label={i18n('DEPARTMENTS')} field="autocomplete" fieldProps={{items: this.getDepartmentsItems()}} />
<FormField name="owners" label={i18n('OWNER')} field="autocomplete" fieldProps={{items: this.getStaffItems()}} />
</div>
</div>
</div>
<div className="admin-panel-stats__container">
<Button
className="admin-panel-stats__container__button admin-panel-stats__container__clear-button"
size= "medium"
disabled={loading}
onClick={this.clearFormValues.bind(this)}>
{loading ? <Loading /> : i18n('CLEAR')}
</Button>
<SubmitButton
className="admin-panel-stats__container__button admin-panel-stats__container__apply-button"
type="secondary"
size= "medium">
{i18n('APPLY')}
</SubmitButton>
</div>
</Form>
<div className="row">
<div className="col-md-12">
<span className="separator" />
</div>
</div>
{loading ? <div className="admin-panel-stats__loading"><Loading backgrounded size="large" /></div> : this.renderStatistics()}
</div>
)
}
renderStatistics() {
const primaryBlueWithTransparency = (alpha) => `rgba(32, 184, 197, ${alpha})`;
const ticketsByHoursChartData = {
labels: Array.from(Array(24).keys()),
datasets: [
{
label: 'Created Tickets by Hour',
backgroundColor: primaryBlueWithTransparency(0.2),
borderColor: primaryBlueWithTransparency(1),
borderWidth: 1,
hoverBackgroundColor: primaryBlueWithTransparency(0.4),
hoverBorderColor: primaryBlueWithTransparency(1),
data: this.state.ticketData.created_by_hour
}
]
};
const primaryGreenWithTransparency = (alpha) => `rgba(130, 202, 156, ${alpha})`;
const ticketsByWeekdayChartData = {
labels: [
i18n('MONDAY'),
i18n('TUESDAY'),
i18n('WEDNESDAY'),
i18n('THURSDAY'),
i18n('FRIDAY'),
i18n('SATURDAY'),
i18n('SUNDAY')
],
datasets: [
{
label: 'Created Tickets by Weekday',
backgroundColor: primaryGreenWithTransparency(0.2),
borderColor: primaryGreenWithTransparency(1),
borderWidth: 1,
hoverBackgroundColor: primaryGreenWithTransparency(0.4),
hoverBorderColor: primaryGreenWithTransparency(1),
data: this.state.ticketData.created_by_weekday
}
]
}
return (
<div>
{this.renderStatCards()}
<Bar
options={this.getStatsOptions('y')}
data={ticketsByHoursChartData}
legend={{onClick: null}} /> {/* Weird, but if you only set the legend here, it changes that of the HorizontalBar next too*/}
<HorizontalBar
options={this.getStatsOptions('x')}
data={ticketsByWeekdayChartData}
legend={{onClick: null}} />
</div>
);
}
renderStatCards() {
const {created, open, closed, instant, reopened} = this.state.ticketData;
return (
<div className="admin-panel-stats__card-list">
<StatCard label={i18n('CREATED')} description={i18n('CREATED_DESCRIPTION')} value={created} isPercentage={false} />
<StatCard label={i18n('OPEN')} description={i18n('OPEN_DESCRIPTION')} value={open} isPercentage={false} />
<StatCard label={i18n('CLOSED')} description={i18n('CLOSED_DESCRIPTION')} value={closed} isPercentage={false} />
<StatCard label={i18n('INSTANT')} description={i18n('INSTANT_DESCRIPTION')} value={100*instant / closed} isPercentage={true} />
<StatCard label={i18n('REOPENED')} description={i18n('REOPENED_DESCRIPTION')} value={100*reopened / created} isPercentage={true} />
</div>
)
}
getStatsOptions(axis) {
return {
scales: {
[`${axis}Axes`]: [{
ticks: {
beginAtZero: true
}
}]
}
}
}
clearFormValues(event) {
event.preventDefault();
this.setState({
rawForm: {
dateRange: this.getInitialDateRange(),
departments: [],
owners: [],
tags: []
}
});
}
getTagItems() {
return this.props.tags.map((tag) => {
return {
id: JSON.parse(tag.id),
name: tag.name,
color : tag.color
}
});
}
getSelectedTagIds() {
return this.props.tags.filter(tag => _.includes(this.state.rawForm.tags, tag.name)).map(tag => tag.id);
}
getStaffItems() {
const getStaffProfilePic = (staff) => {
return staff.profilePic ? API.getFileLink(staff.profilePic) : (API.getURL() + '/images/profile.png');
}
const renderStaffItem = (staff, style) => {
return (
<div className={`admin-panel-stats__staff-${style}`} key={`staff-${style}-${staff.id}`}>
<img className={`admin-panel-stats__staff-${style}__profile-pic`} src={getStaffProfilePic(staff)} />
<span className={`admin-panel-stats__staff-${style}__name`}>{staff.name}</span>
</div>
)
};
const { staffList } = this.props;
let newStaffList = staffList.map(staff => {
return {
id: JSON.parse(staff.id),
name: staff.name.toLowerCase(),
color: 'gray',
contentOnSelected: renderStaffItem(staff, 'selected'),
content: renderStaffItem(staff, 'option'),
}
});
return newStaffList;
}
getDepartmentsItems() {
const renderDepartmentItem = (department, style) => {
return (
<div className={`admin-panel-stats__department-${style}`} key={`department-${style}-${department.id}`}>
{department.private*1 ? <Icon className={`admin-panel-stats__department-${style}__icon`} name='user-secret' /> : null}
<span className={`admin-panel-stats__department-${style}__name`}>{department.name}</span>
</div>
);
};
return this.props.departments.map(department => {
return {
id: JSON.parse(department.id),
name: department.name.toLowerCase(),
color: 'gray',
contentOnSelected: renderDepartmentItem(department, 'selected'),
content: renderDepartmentItem(department, 'option'),
}
});
}
retrieveStats() {
const { rawForm } = this.state;
const { startDate, endDate } = rawForm.dateRange;
API.call({
path: '/system/get-stats',
data: {
dateRange: "[" + startDate.toString() + "," + endDate.toString() + "]",
departments: "[" + rawForm.departments.map(department => department.id) + "]",
owners: "[" + rawForm.owners.map(owner => owner.id) + "]",
tags: "[" + this.getSelectedTagIds() + "]"
}
}).then(({data}) => {
this.setState({ticketData: data, loading: false});
}).catch((error) => {
if (showLogs) console.error('ERROR: ', error);
})
}
onFormChange(newFormState) {
this.setState({rawForm: newFormState});
}
onFormSubmit() {
this.retrieveStats();
}
}
export default AdminPanelStats;
export default connect((store) => {
return {
tags: store.config.tags,
departments: store.config.departments,
staffList: store.adminData.staffMembers
};
})(AdminPanelStats);

View File

@ -0,0 +1,110 @@
@import "../../../../scss/vars";
.admin-panel-stats {
text-align: left;
&__form {
&__container {
display: flex;
flex-direction: row;
align-items: center;
justify-content: center;
&__row {
display: flex;
flex-direction: row;
align-items: center;
justify-content: space-between;
width: 80%;
}
&__col {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
}
}
}
&__container {
display: flex;
flex-direction: row;
align-items: center;
justify-content: flex-end;
&__button {
margin: 0 10px;
}
}
&__loading {
min-height: 361px;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
background-color: $grey;
}
&__card-list {
display: flex;
flex-direction: row;
flex-wrap: wrap;
justify-content: space-around;
}
&__department-option { // Duplicated from ticket-query-filters, please REMOVE
&__name {
margin-left: 10px;
}
}
&__department-selected { // Duplicated from ticket-query-filters, please REMOVE
display: inline-block;
border-radius: 3px;
margin-left: 5px;
padding: 1px;
font-size: 13px;
cursor: default;
&__icon {
padding-right: 5px;
}
}
&__staff-option { // Duplicated from ticket-query-filters, please REMOVE
&__profile-pic {
width: 25px;
height: 25px;
border-radius: 50%;
}
&__name {
margin-left: 10px;
}
}
&__staff-selected { // Duplicated from ticket-query-filters, please REMOVE
display: inline-block;
border-radius: 3px;
margin-left: 5px;
padding: 1px;
font-size: 13px;
cursor: default;
&__profile-pic {
width: 25px;
height: 25px;
border-radius: 50%;
margin-right: 5px;
}
}
}
@media screen and (max-width: 992px) {
.admin-panel-stats__form__container__row {
display: flex;
flex-direction: column;
}
}

View File

@ -4,7 +4,6 @@ import {connect} from 'react-redux';
import ConfigActions from 'actions/config-actions';
import API from 'lib-app/api-call';
import i18n from 'lib-app/i18n';
import ToggleButton from 'app-components/toggle-button';
import AreYouSure from 'app-components/are-you-sure';
import ModalContainer from 'app-components/modal-container';
@ -17,6 +16,7 @@ import Listing from 'core-components/listing';
import Form from 'core-components/form';
import FormField from 'core-components/form-field';
import SubmitButton from 'core-components/submit-button';
import Checkbox from 'core-components/checkbox';
class AdminPanelAdvancedSettings extends React.Component {
@ -25,10 +25,9 @@ class AdminPanelAdvancedSettings extends React.Component {
messageTitle: null,
messageType: '',
messageContent: '',
keyName: '',
keyCode: '',
selectedAPIKey: -1,
APIKeys: []
APIKeys: [],
error: ''
};
componentDidMount() {
@ -36,24 +35,33 @@ class AdminPanelAdvancedSettings extends React.Component {
}
render() {
const { config } = this.props;
const { messageType, error, selectedAPIKey } = this.state;
return (
<div className="admin-panel-advanced-settings">
<Header title={i18n('ADVANCED_SETTINGS')} description={i18n('ADVANCED_SETTINGS_DESCRIPTION')}/>
{(this.state.messageType) ? this.renderMessage() : null}
<Header title={i18n('ADVANCED_SETTINGS')} description={i18n('ADVANCED_SETTINGS_DESCRIPTION')} />
{messageType ? this.renderMessage() : null}
<div className="row">
<div className="col-md-12">
<div className="col-md-6">
<div className="admin-panel-advanced-settings__user-system-enabled">
<span className="admin-panel-advanced-settings__text">
{i18n('ENABLE_USER_SYSTEM')} <InfoTooltip text={i18n('ENABLE_USER_SYSTEM_DESCRIPTION')} />
</span>
<ToggleButton className="admin-panel-advanced-settings__toggle-button" value={this.props.config['user-system-enabled']} onChange={this.onToggleButtonUserSystemChange.bind(this)}/>
</div>
<div className="col-md-6 admin-panel-advanced-settings__mandatory-login">
<Checkbox
label={i18n('ENABLE_MANDATORY_LOGIN')}
disabled={!config['registration']}
className="admin-panel-advanced-settings__mandatory-login__checkbox"
value={config['mandatory-login']}
onChange={this.onCheckboxMandatoryLoginChange.bind(this)}
wrapInLabel />
</div>
<div className="col-md-6">
<div className="admin-panel-advanced-settings__registration">
<span className="admin-panel-advanced-settings__text">{i18n('ENABLE_USER_REGISTRATION')}</span>
<ToggleButton className="admin-panel-advanced-settings__toggle-button" value={this.props.config['registration']} onChange={this.onToggleButtonRegistrationChange.bind(this)}/>
<Checkbox
label={i18n('ENABLE_USER_REGISTRATION')}
disabled={!config['mandatory-login']}
className="admin-panel-advanced-settings__registration__checkbox"
value={config['registration']}
onChange={this.onCheckboxRegistrationChange.bind(this)}
wrapInLabel />
</div>
</div>
</div>
@ -65,7 +73,7 @@ class AdminPanelAdvancedSettings extends React.Component {
<div className="admin-panel-advanced-settings__text">
{i18n('INCLUDE_USERS_VIA_CSV')} <InfoTooltip text={i18n('CSV_DESCRIPTION')} />
</div>
<FileUploader className="admin-panel-advanced-settings__button" text="Upload" onChange={this.onImportCSV.bind(this)}/>
<FileUploader className="admin-panel-advanced-settings__button" text="Upload" onChange={this.onImportCSV.bind(this)} />
</div>
<div className="col-md-4">
<div className="admin-panel-advanced-settings__text">{i18n('BACKUP_DATABASE')}</div>
@ -84,8 +92,8 @@ class AdminPanelAdvancedSettings extends React.Component {
<div className="col-md-4">
<Listing {...this.getListingProps()} />
</div>
<div className="col-md-8">
{(this.state.selectedAPIKey === -1) ? this.renderNoKey() : this.renderKey()}
<div className="col-md-8 admin-panel-advanced-settings__api-keys__container">
{error ? <Message type="error">{i18n(error)}</Message> : ((selectedAPIKey === -1) ? this.renderNoKey() : this.renderKey())}
</div>
</div>
</div>
@ -94,8 +102,12 @@ class AdminPanelAdvancedSettings extends React.Component {
}
renderMessage() {
const { messageType, messageTitle, messageContent } = this.state;
return (
<Message type={this.state.messageType} title={this.state.messageTitle}>{this.state.messageContent}</Message>
<Message className="admin-panel-advanced-settings__message" type={messageType} title={messageTitle}>
{messageContent}
</Message>
);
}
@ -108,14 +120,31 @@ class AdminPanelAdvancedSettings extends React.Component {
}
renderKey() {
let currentAPIKey = this.state.APIKeys[this.state.selectedAPIKey];
const { APIKeys, selectedAPIKey } = this.state;
const {
name,
token,
canCreateTickets,
shouldReturnTicketNumber,
canCheckTickets,
canCreateUser
} = APIKeys[selectedAPIKey];
return (
<div className="admin-panel-advanced-settings__api-keys-info">
<div className="admin-panel-advanced-settings__api-keys__container-info">
<div className="admin-panel-advanced-settings__api-keys-subtitle">{i18n('NAME_OF_KEY')}</div>
<div className="admin-panel-advanced-settings__api-keys-data">{currentAPIKey.name}</div>
<div className="admin-panel-advanced-settings__api-keys-data">{name}</div>
<div className="admin-panel-advanced-settings__api-keys-subtitle">{i18n('KEY')}</div>
<div className="admin-panel-advanced-settings__api-keys-data">{currentAPIKey.token}</div>
<div className="admin-panel-advanced-settings__api-keys-data">{token}</div>
<div className="admin-panel-advanced-settings__api-keys-subtitle">{i18n('PERMISSIONS')}</div>
<div className="admin-panel-advanced-settings__api-keys__permissions">
<FormField className="admin-panel-advanced-settings__api-keys__permissions__item" value={canCreateTickets*1} label={i18n('TICKET_CREATION_PERMISSION')} field='checkbox' />
<FormField value={shouldReturnTicketNumber*1} label={i18n('TICKET_NUMBER_RETURN_PERMISSION')} field='checkbox' />
</div>
<div className="admin-panel-advanced-settings__api-keys__permissions">
<FormField className="admin-panel-advanced-settings__api-keys__permissions__item" value={canCheckTickets*1} label={i18n('TICKET_CHECK_PERMISSION')} field='checkbox' />
<FormField value={canCreateUser*1} label={i18n('USER_CREATION_PERMISSION')} field='checkbox' />
</div>
<Button className="admin-panel-advanced-settings__api-keys-button" size="medium" onClick={this.onDeleteKeyClick.bind(this)}>
{i18n('DELETE')}
</Button>
@ -134,7 +163,7 @@ class AdminPanelAdvancedSettings extends React.Component {
};
}),
selectedIndex: this.state.selectedAPIKey,
onChange: index => this.setState({selectedAPIKey: index}),
onChange: index => this.setState({selectedAPIKey: index, error:''}),
onAddClick: this.openAPIKeyModal.bind(this)
};
}
@ -142,18 +171,51 @@ class AdminPanelAdvancedSettings extends React.Component {
openAPIKeyModal() {
ModalContainer.openModal(
<Form className="admin-panel-advanced-settings__api-keys-modal" onSubmit={this.addAPIKey.bind(this)}>
<Header title={i18n('ADD_API_KEY')} description={i18n('ADD_API_KEY_DESCRIPTION')}/>
<FormField name="name" label={i18n('NAME_OF_KEY')} validation="DEFAULT" required fieldProps={{size: 'large'}}/>
<SubmitButton type="secondary">{i18n('SUBMIT')}</SubmitButton>
</Form>
<Header title={i18n('ADD_API_KEY')} description={i18n('ADD_API_KEY_DESCRIPTION')} />
<FormField name="name" label={i18n('NAME_OF_KEY')} validation="DEFAULT" required fieldProps={{size: 'large'}} />
<div className="admin-panel-advanced-settings__api-keys__permissions">
<FormField className = "admin-panel-advanced-settings__api-keys__permissions__item" name="createTicketPermission" label={i18n('TICKET_CREATION_PERMISSION')} field='checkbox' />
<FormField name="ticketNumberPermission" label={i18n('TICKET_NUMBER_RETURN_PERMISSION')} field='checkbox' />
</div>
<div className="admin-panel-advanced-settings__api-keys__permissions" >
<FormField className = "admin-panel-advanced-settings__api-keys__permissions__item" name="checkTicketPermission" label={i18n('TICKET_CHECK_PERMISSION')} field='checkbox' />
<FormField name="userPermission" label={i18n('USER_CREATION_PERMISSION')} field='checkbox' />
</div>
<div className="admin-panel-advanced-settings__api-keys__buttons-container">
<Button
className="admin-panel-advanced-settings__api-keys__cancel-button"
onClick={(e) => {e.preventDefault(); ModalContainer.closeModal();}}
type='link'
size="medium">
{i18n('CANCEL')}
</Button>
<SubmitButton className="admin-panel-advanced-settings__api-keys-modal__submit-button" type="secondary">{i18n('SUBMIT')}</SubmitButton>
</div>
</Form>,
{
closeButton: {
showCloseButton: true
}
}
);
}
addAPIKey({name}) {
addAPIKey({name,userPermission,createTicketPermission,checkTicketPermission,ticketNumberPermission}) {
ModalContainer.closeModal();
this.setState({
error: ''
});
API.call({
path: '/system/add-api-key',
data: {name}
data: {
name,
canCreateUsers: userPermission*1,
canCreateTickets: createTicketPermission*1,
canCheckTickets: checkTicketPermission*1,
shouldReturnTicketNumber: ticketNumberPermission*1
}
}).then(this.getAllKeys.bind(this));
}
@ -161,15 +223,20 @@ class AdminPanelAdvancedSettings extends React.Component {
API.call({
path: '/system/get-api-keys',
data: {}
}).then(this.onRetrieveSuccess.bind(this));
}).then(this.onRetrieveSuccess.bind(this))
}
onDeleteKeyClick() {
const {
APIKeys,
selectedAPIKey
} = this.state;
AreYouSure.openModal(null, () => {
API.call({
return API.call({
path: '/system/delete-api-key',
data: {
name: this.state.APIKeys[this.state.selectedAPIKey].name
name: APIKeys[selectedAPIKey].name
}
}).then(this.getAllKeys.bind(this));
});
@ -178,21 +245,24 @@ class AdminPanelAdvancedSettings extends React.Component {
onRetrieveSuccess(result) {
this.setState({
APIKeys: result.data,
selectedAPIKey: -1
selectedAPIKey: -1,
error: null
});
}
onToggleButtonUserSystemChange() {
AreYouSure.openModal(null, this.onAreYouSureUserSystemOk.bind(this), 'secure');
onCheckboxMandatoryLoginChange() {
AreYouSure.openModal(null, this.onAreYouSureMandatoryLoginOk.bind(this), 'secure');
}
onToggleButtonRegistrationChange() {
onCheckboxRegistrationChange() {
AreYouSure.openModal(null, this.onAreYouSureRegistrationOk.bind(this), 'secure');
}
onAreYouSureUserSystemOk(password) {
API.call({
path: this.props.config['user-system-enabled'] ? '/system/disable-user-system' : '/system/enable-user-system',
onAreYouSureMandatoryLoginOk(password) {
const { config, dispatch } = this.props;
return API.call({
path: config['mandatory-login'] ? '/system/disable-mandatory-login' : '/system/enable-mandatory-login',
data: {
password: password
}
@ -200,15 +270,17 @@ class AdminPanelAdvancedSettings extends React.Component {
this.setState({
messageType: 'success',
messageTitle: null,
messageContent: this.props.config['user-system-enabled'] ? i18n('USER_SYSTEM_DISABLED') : i18n('USER_SYSTEM_ENABLED')
messageContent: config['mandatory-login'] ? i18n('MANDATORY_LOGIN_DISABLED') : i18n('MANDATORY_LOGIN_ENABLED')
});
this.props.dispatch(ConfigActions.updateData());
dispatch(ConfigActions.updateData());
}).catch(() => this.setState({messageType: 'error', messageTitle: null, messageContent: i18n('ERROR_UPDATING_SETTINGS')}));
}
onAreYouSureRegistrationOk(password) {
API.call({
path: this.props.config['registration'] ? '/system/disable-registration' : '/system/enable-registration',
const { config, dispatch } = this.props;
return API.call({
path: config['registration'] ? '/system/disable-registration' : '/system/enable-registration',
data: {
password: password
}
@ -216,9 +288,9 @@ class AdminPanelAdvancedSettings extends React.Component {
this.setState({
messageType: 'success',
messageTitle: null,
messageContent: this.props.config['registration'] ? i18n('REGISTRATION_DISABLED') : i18n('REGISTRATION_ENABLED')
messageContent: config['registration'] ? i18n('REGISTRATION_DISABLED') : i18n('REGISTRATION_ENABLED')
});
this.props.dispatch(ConfigActions.updateData());
dispatch(ConfigActions.updateData());
}).catch(() => this.setState({messageType: 'error', messageTitle: null, messageContent: i18n('ERROR_UPDATING_SETTINGS')}));
}
@ -227,7 +299,7 @@ class AdminPanelAdvancedSettings extends React.Component {
}
onAreYouSureCSVOk(file, password) {
API.call({
return API.call({
path: '/system/csv-import',
dataAsForm: true,
data: {
@ -270,7 +342,7 @@ class AdminPanelAdvancedSettings extends React.Component {
}
onAreYouSureDeleteAllUsersOk(password) {
API.call({
return API.call({
path: '/system/delete-all-users',
data: {
password: password

View File

@ -1,7 +1,7 @@
@import "../../../../scss/vars";
.admin-panel-advanced-settings {
&__user-system-enabled {
&__mandatory-login {
}
@ -9,13 +9,6 @@
}
&__toggle-button {
display: inline-block;
margin-left: 20px;
margin-top: 20px;
margin-bottom: 20px;
}
&__text {
margin-top: 30px;
margin-bottom: 20px;
@ -28,13 +21,20 @@
&__api-keys {
&__buttons-container {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: flex-end;
}
&-title {
font-size: $font-size--bg;
margin-bottom: 20px;
text-align: left;
}
&-info {
&__container-info {
text-align: left;
}
@ -52,8 +52,21 @@
padding: 5px 0;
}
&__permissions {
display: flex;
justify-content: flex-start;
margin-bottom: 20px;
&__item {
margin-right: 25px;
}
}
&-modal {
min-width: 500px;
min-width: 400px;
&__submit-button {
margin-top: 20px;
}
}
&-none {
@ -61,4 +74,36 @@
font-size: $font-size--md;
}
}
&__message {
margin-bottom: 20px;
}
@media screen and (max-width: 415px) {
.admin-panel-advanced-settings {
&__api-keys {
&-button {
margin-bottom: 30px;
width: 150px;
}
&__container {
padding: 30px 0;
&-info {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
}
}
&-subtitle {
margin: 10px 0 0 0;
}
}
}
}
}

View File

@ -0,0 +1,156 @@
import React from 'react';
import i18n from 'lib-app/i18n';
import API from 'lib-app/api-call';
import Button from 'core-components/button';
import Header from 'core-components/header';
import Form from 'core-components/form';
import FormField from 'core-components/form-field';
import SubmitButton from 'core-components/submit-button';
import ColorSelector from 'core-components/color-selector';
class AdminPanelCustomTagsModal extends React.Component {
static contextTypes = {
closeModal: React.PropTypes.func,
createTag: React.PropTypes.bool
};
static propTypes = {
defaultValues: React.PropTypes.object,
onTagCreated: React.PropTypes.func
};
state = {
form: this.props.defaultValues || {name: '', color: '#ff6900'},
loading: false
};
render() {
return (
this.renderTagContentPopUp(this.props.createTag)
);
}
renderTagContentPopUp(create) {
const {
form,
errors,
loading,
} = this.state;
let title, description, nameRequired, submitFunction;
if(create) {
title = i18n('ADD_CUSTOM_TAG');
description = i18n('DESCRIPTION_ADD_CUSTOM_TAG');
submitFunction = this.onSubmitNewTag.bind(this);
nameRequired = true;
} else {
title = i18n('EDIT_CUSTOM_TAG');
description = i18n('DESCRIPTION_EDIT_CUSTOM_TAG');
nameRequired = false;
submitFunction = this.onSubmitEditTag.bind(this);
}
return (
<div className='admin-panel-custom-tags-modal'>
<Header title={title} description={description} />
<Form
values={form}
onChange={this.onFormChange.bind(this)}
onSubmit={submitFunction}
errors={errors}
onValidateErrors={errors => this.setState({errors})}
loading={loading}>
<FormField name="name" label={i18n('NAME')} fieldProps={{size: 'large'}} required={nameRequired} />
<FormField name="color" label={i18n('COLOR')} decorator={ColorSelector} />
<div className='admin-panel-custom-tags-modal__actions'>
<Button onClick={this.onDiscardClick.bind(this)} size="small">
{i18n('CANCEL')}
</Button>
<SubmitButton type="secondary" size="small">
{i18n('SAVE')}
</SubmitButton>
</div>
</Form>
</div>
);
}
onFormChange(form) {
this.setState({
form
});
}
onSubmitEditTag(form) {
this.setState({
loading: true
});
API.call({
path: '/ticket/edit-tag',
data: {
tagId: this.props.id,
name: form.name,
color: form.color,
}
}).then(() => {
this.context.closeModal();
if(this.props.onTagChange) {
this.props.onTagChange();
}
}).catch((result) => {
this.setState({
loading: false,
errors: {
'name': result.message
}
});
});
}
onSubmitNewTag(form) {
this.setState({
loading: true
});
API.call({
path: '/ticket/create-tag',
data: {
name: form.name,
color: form.color,
}
}).then(() => {
this.context.closeModal();
if(this.props.onTagCreated) {
this.props.onTagCreated();
}
}).catch((result) => {
this.setState({
loading: false,
errors: {
'name': result.message
}
});
});
}
onDiscardClick(event) {
event.preventDefault();
this.context.closeModal();
this.setState({
loading: false,
errors: {}
});
}
}
export default AdminPanelCustomTagsModal;

View File

@ -0,0 +1,7 @@
.admin-panel-custom-tags-modal {
&__actions{
display: flex;
justify-content: space-between;
}
}

View File

@ -0,0 +1,109 @@
import React from 'react';
import {connect} from 'react-redux';
import AdminPanelCustomTagsModal from 'app/admin/panel/settings/admin-panel-custom-tags-modal';
import i18n from 'lib-app/i18n';
import API from 'lib-app/api-call';
import ConfigActions from 'actions/config-actions';
import AreYouSure from 'app-components/are-you-sure';
import ModalContainer from 'app-components/modal-container';
import Icon from 'core-components/icon';
import Button from 'core-components/button';
import Header from 'core-components/header';
import Tag from 'core-components/tag';
class AdminPanelCustomTags extends React.Component {
static propTypes = {
tags: React.PropTypes.arrayOf(
React.PropTypes.shape({
name: React.PropTypes.string,
color: React.PropTypes.string,
id: React.PropTypes.number
})
),
};
componentDidMount() {
this.retrieveCustomTags();
}
render() {
return (
<div className="admin-panel-custom-tags">
<Header title={i18n('CUSTOM_TAGS')} description={i18n('CUSTOM_TAGS_DESCRIPTION')} />
{this.renderContent()}
</div>
);
}
renderContent() {
return (
<div className="admin-panel-custom-tags__content">
<div>
<Button onClick={this.openTagModal.bind(this)} type="secondary">
<Icon className="admin-panel-custom-tags__add-button-icon" name="plus" /> {i18n('ADD_CUSTOM_TAG')}
</Button>
</div>
<div className="admin-panel-custom-tags__tag-list">
{this.props.tags.map(this.renderTag.bind(this))}
</div>
</div>
);
}
renderTag(tag, index) {
return (
<div key={index} className="admin-panel-custom-tags__tag-container" >
<Tag
color={tag.color}
name={tag.name}
onEditClick={this.openEditTagModal.bind(this, tag.id, tag.name, tag.color)}
onRemoveClick={this.onDeleteClick.bind(this, tag.id)}
size='large'
showEditButton
showDeleteButton />
</div>
)
}
openTagModal() {
ModalContainer.openModal(
<AdminPanelCustomTagsModal onTagCreated={this.retrieveCustomTags.bind(this)} createTag />
);
}
openEditTagModal(tagId,tagName,tagColor, event) {
ModalContainer.openModal(
<AdminPanelCustomTagsModal defaultValues={{name: tagName , color: tagColor}} id={tagId} onTagChange={this.retrieveCustomTags.bind(this)} />
);
}
onDeleteClick(tagId, event) {
event.preventDefault();
AreYouSure.openModal(i18n('WILL_DELETE_CUSTOM_TAG'), this.deleteCustomTag.bind(this, tagId));
}
deleteCustomTag(tagId) {
return API.call({
path: '/ticket/delete-tag',
data: {
tagId,
}
}).then(() => {
this.retrieveCustomTags()
});
}
retrieveCustomTags() {
this.props.dispatch(ConfigActions.updateData());
}
}
export default connect((store) => {
return {
tags: store.config['tags'].map((tag) => {return {...tag, id: tag.id*1}})
};
})(AdminPanelCustomTags);

View File

@ -0,0 +1,18 @@
.admin-panel-custom-tags {
&__content {
text-align: left;
}
&__add-button-icon{
margin-left: 5px;
}
&__tag-list{
margin-top: 15px;
}
&__tag-container{
margin-top:5px ;
}
}

View File

@ -0,0 +1,539 @@
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';
import AreYouSure from 'app-components/are-you-sure';
import LanguageSelector from 'app-components/language-selector';
import PopupMessage from 'app-components/popup-message';
import Button from 'core-components/button';
import Header from 'core-components/header';
import Listing from 'core-components/listing';
import Loading from 'core-components/loading';
import Form from 'core-components/form';
import FormField from 'core-components/form-field';
import SubmitButton from 'core-components/submit-button';
import Message from 'core-components/message';
class AdminPanelEmailSettings extends React.Component {
static propTypes = {
language: React.PropTypes.string,
};
state = {
headerImage: '',
loadingHeaderImage: false,
loadingList: true,
loadingTemplate: false,
templates: [],
loadingForm: false,
selectedIndex: -1,
edited: false,
errors: {},
language: this.props.language,
imapLoading: false,
smtpLoading: false,
form: {
subject: '',
text1: '',
text2: '',
text3: '',
},
emailForm: {
['server-email']: '',
},
smtpForm: {
['smtp-host']: '',
['smtp-user']: '',
['smtp-pass']: 'HIDDEN',
},
imapForm: {
['imap-host']: '',
['imap-user']: '',
['imap-pass']: 'HIDDEN',
['imap-token']: '',
},
};
componentDidMount() {
this.retrieveMailTemplateList();
this.retrieveHeaderImage();
}
render() {
return (
<div className="admin-panel-email-settings">
{(!this.state.loadingList) ? this.renderContent() : this.renderLoading()}
</div>
);
}
renderContent() {
return (
<div>
{this.renderEmailSettings()}
<Header title={i18n('EMAIL_TEMPLATES')} description={i18n('EMAIL_TEMPLATES_DESCRIPTION')} />
<div className="row">
<div className="col-md-3">
<Listing {...this.getListingProps()} />
</div>
{(this.state.selectedIndex !== -1) ? this.renderForm() : null}
</div>
</div>
);
}
renderLoading() {
return (
<div className="admin-panel-email-settings__loading">
<Loading backgrounded size="large" />
</div>
);
}
renderEmailSettings() {
return (
<div>
<Header title={i18n('EMAIL_SETTINGS')} description={i18n('EMAIL_SETTINGS_DESCRIPTION')} />
<Form className="admin-panel-email-settings__email-form"
onSubmit={this.submitEmailAddress.bind(this)}
onChange={emailForm => this.setState({emailForm})}
values={this.state.emailForm}>
<div className="admin-panel-email-settings__email-container">
<FormField className="admin-panel-email-settings__email-server-address"
name="server-email"
label={i18n('EMAIL_SERVER_ADDRESS')}
fieldProps={{size: 'large'}}
infoMessage={i18n('EMAIL_SERVER_ADDRESS_DESCRIPTION')} />
<SubmitButton className="admin-panel-email-settings__submit" type="secondary"
size="small">{i18n('SAVE')}</SubmitButton>
</div>
</Form>
<Form className="admin-panel-email-settings__image-form"
values={{headerImage: this.state.headerImage}}
onChange={form => this.setState({headerImage: form.headerImage})}
onSubmit={this.onHeaderImageSubmit.bind(this)}>
<div className="admin-panel-email-settings__image-container">
<FormField className="admin-panel-email-settings__image-header-url"
label={i18n('IMAGE_HEADER_URL')} name="headerImage" required
infoMessage={i18n('IMAGE_HEADER_URL_DESCRIPTION')}
fieldProps={{size: 'large'}} />
<SubmitButton className="admin-panel-email-settings__image-header-submit" type="secondary"
size="small">{i18n('SAVE')}</SubmitButton>
</div>
</Form>
<div className="admin-panel-email-settings__servers">
<div className="admin-panel-email-settings__box">
<Header title={i18n('SMTP_SERVER')} description={i18n('SMTP_SERVER_DESCRIPTION')} />
<Form onSubmit={this.submitSMTP.bind(this)} onChange={smtpForm => this.setState({smtpForm})}
values={this.state.smtpForm} loading={this.state.smtpLoading}>
<FormField name="smtp-host" label={i18n('SMTP_SERVER')} fieldProps={{size: 'large'}} />
<FormField name="smtp-user" label={i18n('SMTP_USER')} fieldProps={{size: 'large'}} />
<FormField name="smtp-pass" label={i18n('SMTP_PASSWORD')} fieldProps={{size: 'large', autoComplete: 'off'}} />
<div className="admin-panel-email-settings__server-form-buttons">
<SubmitButton type="tertiary" size="small" onClick={this.testSMTP.bind(this)}>
{i18n('TEST')}
</SubmitButton>
<SubmitButton className="admin-panel-email-settings__submit" type="secondary" size="small">
{i18n('SAVE')}
</SubmitButton>
</div>
</Form>
</div>
<div className="admin-panel-email-settings__box">
<Header title={i18n('IMAP_SERVER')} description={i18n('IMAP_SERVER_DESCRIPTION')} />
<Form onSubmit={this.submitIMAP.bind(this)} onChange={imapForm => this.setState({imapForm})}
values={this.state.imapForm} loading={this.state.imapLoading}>
<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', autoComplete: 'off'}} />
<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 type="tertiary" size="small" onClick={this.testIMAP.bind(this)}>
{i18n('TEST')}
</SubmitButton>
<SubmitButton className="admin-panel-email-settings__submit" type="secondary" size="small">
{i18n('SAVE')}
</SubmitButton>
</div>
</Form>
<Message className="admin-panel-email-settings__imap-message" type="info">
{i18n('IMAP_POLLING_DESCRIPTION', {url: `${apiRoot}/system/email-polling`})}
</Message>
</div>
</div>
</div>
);
}
renderForm() {
const {
form,
language,
selectedIndex,
edited
} = this.state;
return (
<div className="col-md-9">
<FormField label={i18n('LANGUAGE')} decorator={LanguageSelector} value={language}
onChange={event => this.onItemChange(selectedIndex, event.target.value)}
fieldProps={{
type: 'allowed',
size: 'medium'
}} />
<Form {...this.getFormProps()}>
<div className="row">
<div className="col-md-7">
<FormField label={i18n('SUBJECT')} name="subject" validation="TITLE" required
fieldProps={{size: 'large'}} />
</div>
</div>
<FormField key="text1" label={i18n('TEXT') + '1'} name="text1" validation="TEXT_AREA" required
decorator={'textarea'}
fieldProps={{className: 'admin-panel-email-settings__text-area'}} />
{(form.text2) ?
<FormField key="text2" label={i18n('TEXT') + '2'} name="text2" validation="TEXT_AREA" required
decorator={'textarea'}
fieldProps={{className: 'admin-panel-email-settings__text-area'}} /> : null}
{(form.text3) ?
<FormField key="text3" label={i18n('TEXT') + '3'} name="text3" validation="TEXT_AREA" required
decorator={'textarea'}
fieldProps={{className: 'admin-panel-email-settings__text-area'}} /> : null}
<div className="admin-panel-email-settings__actions">
<div className="admin-panel-email-settings__optional-buttons">
<div className="admin-panel-email-settings__recover-button">
<Button onClick={this.onRecoverClick.bind(this)} size="medium">
{i18n('RECOVER_DEFAULT')}
</Button>
</div>
{edited ? this.renderDiscardButton() : null}
</div>
<div className="admin-panel-email-settings__save-button">
<SubmitButton
key="submit-email-template"
type="secondary"
size="small"
onClick={(e) => {e.preventDefault(); this.onFormSubmit(form);}}>
{i18n('SAVE')}
</SubmitButton>
</div>
</div>
</Form>
</div>
);
}
renderDiscardButton() {
return (
<div className="admin-panel-email-settings__discard-button">
<Button onClick={this.onDiscardChangesClick.bind(this)} size="medium">
{i18n('DISCARD_CHANGES')}
</Button>
</div>
);
}
getListingProps() {
return {
title: i18n('EMAIL_TEMPLATES'),
items: this.getTemplateItems(),
selectedIndex: this.state.selectedIndex,
onChange: this.onItemChange.bind(this)
};
}
getFormProps() {
return {
values: this.state.form,
errors: this.state.errors,
loading: this.state.loadingForm,
onChange: (form) => {
this.setState({form, edited: true})
},
onValidateErrors: (errors) => {
this.setState({errors})
},
}
}
getTemplateItems() {
return this.state.templates.map((template) => {
return {
content: template
};
});
}
onItemChange(index, language) {
if (this.state.edited) {
AreYouSure.openModal(i18n('WILL_LOSE_CHANGES'), this.retrieveEmailTemplate.bind(this, index, language || this.state.language));
} else {
this.retrieveEmailTemplate(index, language || this.state.language);
}
}
onHeaderImageSubmit(form) {
this.setState({
loadingHeaderImage: true,
});
API.call({
path: '/system/edit-settings',
data: {
'mail-template-header-image': form['headerImage']
}
}).then(() => this.setState({
loadingHeaderImage: false,
}))
}
onFormSubmit(form) {
const {selectedIndex, language, templates} = this.state;
this.setState({loadingForm: true});
API.call({
path: '/system/edit-mail-template',
data: {
template: templates[selectedIndex],
language,
subject: form.subject,
text1: form.text1,
text2: form.text2,
text3: form.text3,
}
}).then(() => {
this.setState({loadingForm: false, edited: false});
}).catch(response => {
this.setState({
loadingForm: false,
});
switch (response.message) {
case 'INVALID_SUBJECT':
this.setState({
errors: {subject: i18n('INVALID_SYNTAX')}
});
break;
case 'INVALID_TEXT_1':
this.setState({
errors: {text1: i18n('INVALID_SYNTAX')}
});
break;
case 'INVALID_TEXT_2':
this.setState({
errors: {text2: i18n('INVALID_SYNTAX')}
});
break;
case 'INVALID_TEXT_3':
this.setState({
errors: {text3: i18n('INVALID_SYNTAX')}
});
break;
}
});
}
onDiscardChangesClick(event) {
event.preventDefault();
this.onItemChange(this.state.selectedIndex, this.state.language);
}
onRecoverClick(event) {
event.preventDefault();
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');
}
submitSMTP(form) {
this.setState({
smtpLoading: true
});
this.editSettings(form, 'SMTP_SUCCESS')
.then(() => this.setState({
smtpLoading: false
}));
}
submitIMAP(form) {
this.setState({
imapLoading: true
});
this.editSettings(form, 'IMAP_SUCCESS')
.then(() => this.setState({
imapLoading: false
}));
}
editSettings(form, successMessage) {
return API.call({
path: '/system/edit-settings',
data: this.parsePasswordField(form)
}).then(() => PopupMessage.open({
title: i18n('SETTINGS_UPDATED'),
children: i18n(successMessage),
type: 'success'
})).catch(response => PopupMessage.open({
title: i18n('ERROR_UPDATING_SETTINGS'),
children: response.message,
type: 'error'
}));
}
testSMTP(event) {
event.preventDefault();
this.setState({
smtpLoading: true
});
API.call({
path: '/system/test-smtp',
data: this.parsePasswordField(this.state.smtpForm)
}).then(() => PopupMessage.open({
title: `${i18n('SUCCESSFUL_CONNECTION')}: SMTP`,
children: i18n('SERVER_CREDENTIALS_WORKING'),
type: 'success',
})).catch(response => PopupMessage.open({
title: `${i18n('UNSUCCESSFUL_CONNECTION')}: SMTP`,
children: `${i18n('SERVER_ERROR')}: ${response.message}`,
type: 'error',
})).then(() => this.setState({
smtpLoading: false
}));
}
testIMAP(event) {
event.preventDefault();
this.setState({
imapLoading: true
});
API.call({
path: '/system/test-imap',
data: this.parsePasswordField(this.state.imapForm)
}).then(() => PopupMessage.open({
title: `${i18n('SUCCESSFUL_CONNECTION')}: IMAP`,
children: i18n('SERVER_CREDENTIALS_WORKING'),
type: 'success',
})).catch(response => PopupMessage.open({
title: `${i18n('UNSUCCESSFUL_CONNECTION')}: IMAP`,
children: `${i18n('SERVER_ERROR')}: ${response.message}`,
type: 'error',
})).then(() => this.setState({
imapLoading: false
}));
}
recoverEmailTemplate() {
const {selectedIndex, language, templates} = this.state;
return API.call({
path: '/system/recover-mail-template',
data: {
template: templates[selectedIndex],
language
}
}).then(() => {
this.retrieveEmailTemplate(this.state.selectedIndex, language);
});
}
retrieveEmailTemplate(index, language) {
this.setState({
loadingForm: true,
});
return API.call({
path: '/system/get-mail-template',
data: {template: this.state.templates[index], language}
}).then((result) => this.setState({
language,
selectedIndex: index,
edited: false,
loadingForm: false,
form: result.data,
errors: {},
}));
}
retrieveMailTemplateList() {
API.call({
path: '/system/get-mail-template-list',
data: {}
}).then((result) => this.setState({
loadingList: false,
templates: result.data
}));
}
retrieveHeaderImage() {
API.call({
path: '/system/get-settings',
data: {allSettings: 1}
}).then(result => this.setState({
headerImage: result.data['mail-template-header-image'] || '',
emailForm: {
['server-email']: result.data['server-email'] || '',
},
smtpForm: {
['smtp-host']: result.data['smtp-host'] || '',
['smtp-user']: result.data['smtp-user'] || '',
['smtp-pass']: 'HIDDEN',
},
imapForm: {
['imap-host']: result.data['imap-host'] || '',
['imap-user']: result.data['imap-user'] || '',
['imap-pass']: 'HIDDEN',
['imap-token']: result.data['imap-token'] || '',
},
}));
}
parsePasswordField(form) {
let parsedForm = _.extend({}, form);
delete parsedForm['smtp-pass'];
delete parsedForm['imap-pass'];
return _.extend(parsedForm, {
[ form['smtp-pass'] && form['smtp-pass'] !== 'HIDDEN' ? 'smtp-pass' : null]: form['smtp-pass'],
[ form['imap-pass'] && form['imap-pass'] !== 'HIDDEN' ? 'imap-pass' : null]: form['imap-pass'],
})
}
}
export default connect((store) => {
return {
language: store.config.language,
};
})(AdminPanelEmailSettings);

View File

@ -0,0 +1,93 @@
@import "../../../../scss/vars";
.admin-panel-email-settings {
&__loading {
min-height: 361px;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
background-color: $grey;
}
&__text-area {
width: 100%;
height: 45px;
}
&__save-button {
display: inline-block;
float: right;
}
&__optional-buttons {
display: inline-block;
float: left;
}
&__discard-button {
display: inline-block;
}
&__recover-button {
display: inline-block;
margin: 0 10px;
}
&__image-container,
&__email-container {
background-color: $very-light-grey;
display: flex;
justify-content: space-between;
align-items: center;
padding: 5px 20px;
margin-top: 20px;
}
&__box {
text-align: center;
background-color: $very-light-grey;
padding: 30px;
margin-bottom: 30px;
flex-basis: 48%;
}
&__servers {
display: flex;
justify-content: space-evenly;
margin-top: 30px;
}
&__server-form-buttons {
display: flex;
justify-content: space-between;
align-items: center;
}
&__imap-message {
margin-top: 10px;
padding-left: 40px;
text-align: left;
}
}
@media screen and (max-width: 992px) {
.admin-panel-email-settings {
&__image-container > label > .input,
&__email-container > label > .input {
display: unset;
}
&__image-container,
&__email-container,
&__servers {
display: flex;
flex-direction: column;
}
&__email-server-address,
&__image-header-url {
width: 100%;
}
}
}

View File

@ -1,295 +0,0 @@
import React from 'react';
import _ from 'lodash';
import {connect} from 'react-redux';
import i18n from 'lib-app/i18n';
import API from 'lib-app/api-call';
import AreYouSure from 'app-components/are-you-sure';
import LanguageSelector from 'app-components/language-selector';
import Button from 'core-components/button';
import Header from 'core-components/header';
import Listing from 'core-components/listing';
import Loading from 'core-components/loading';
import Form from 'core-components/form';
import FormField from 'core-components/form-field';
import SubmitButton from 'core-components/submit-button';
class AdminPanelEmailTemplates extends React.Component {
static propTypes = {
language: React.PropTypes.string,
};
state = {
headerImage: '',
loadingHeaderImage: false,
loadingList: true,
loadingTemplate: false,
templates: [],
loadingForm: false,
selectedIndex: -1,
edited: false,
errors: {},
language: this.props.language,
form: {
subject: '',
text1: '',
text2: '',
text3: '',
}
};
componentDidMount() {
this.retrieveMailTemplateList();
this.retrieveHeaderImage();
}
render() {
return (
<div className="admin-panel-email-templates">
<Header title={i18n('EMAIL_TEMPLATES')} description={i18n('EMAIL_TEMPLATES_DESCRIPTION')} />
{(!this.state.loadingList) ? this.renderContent() : this.renderLoading()}
</div>
);
}
renderContent() {
return (
<div>
<div className="row">
<div className="col-md-3">
<Listing {...this.getListingProps()}/>
</div>
{(this.state.selectedIndex != -1) ? this.renderForm() : null}
</div>
<Form values={{headerImage: this.state.headerImage}} onChange={form => this.setState({headerImage: form.headerImage})} onSubmit={this.onHeaderImageSubmit.bind(this)}>
<div className="admin-panel-email-templates__image-container">
<FormField className="admin-panel-email-templates__image-header-url" label={i18n('IMAGE_HEADER_URL')} name="headerImage" required fieldProps={{size: 'large'}} />
<SubmitButton className="admin-panel-email-templates__image-header-submit" type="secondary" size="small">{i18n('SAVE')}</SubmitButton>
</div>
</Form>
</div>
);
}
renderLoading() {
return (
<div className="admin-panel-email-templates__loading">
<Loading backgrounded size="large"/>
</div>
);
}
renderForm() {
return (
<div className="col-md-9">
<FormField label={i18n('LANGUAGE')} decorator={LanguageSelector} value={this.state.language} onChange={event => this.onItemChange(this.state.selectedIndex, event.target.value)} fieldProps={{
type: 'allowed',
size: 'medium'
}}/>
<Form {...this.getFormProps()}>
<div className="row">
<div className="col-md-7">
<FormField label={i18n('SUBJECT')} name="subject" validation="TITLE" required fieldProps={{size: 'large'}}/>
</div>
</div>
<FormField label={i18n('TEXT') + '1'} name="text1" validation="TEXT_AREA" required decorator={'textarea'} fieldProps={{className: 'admin-panel-email-templates__text-area'}} />
{(this.state.form.text2) ? <FormField label={i18n('TEXT') + '2'} name="text2" validation="TEXT_AREA" required decorator={'textarea'} fieldProps={{className: 'admin-panel-email-templates__text-area'}} /> : null}
{(this.state.form.text3) ? <FormField label={i18n('TEXT') + '3'} name="text3" validation="TEXT_AREA" required decorator={'textarea'} fieldProps={{className: 'admin-panel-email-templates__text-area'}} /> : null}
<div className="admin-panel-email-templates__actions">
<div className="admin-panel-email-templates__save-button">
<SubmitButton type="secondary" size="small">{i18n('SAVE')}</SubmitButton>
</div>
<div className="admin-panel-email-templates__optional-buttons">
{(this.state.edited) ? this.renderDiscardButton() : null}
<div className="admin-panel-email-templates__recover-button">
<Button onClick={this.onRecoverClick.bind(this)} size="medium">
{i18n('RECOVER_DEFAULT')}
</Button>
</div>
</div>
</div>
</Form>
</div>
);
}
renderDiscardButton() {
return (
<div className="admin-panel-email-templates__discard-button">
<Button onClick={this.onDiscardChangesClick.bind(this)} size="medium">
{i18n('DISCARD_CHANGES')}
</Button>
</div>
);
}
getListingProps() {
return {
title: i18n('EMAIL_TEMPLATES'),
items: this.getTemplateItems(),
selectedIndex: this.state.selectedIndex,
onChange: this.onItemChange.bind(this)
};
}
getFormProps() {
return {
values: this.state.form,
errors: this.state.errors,
loading: this.state.loadingForm,
onChange: (form) => {this.setState({form, edited: true})},
onValidateErrors: (errors) => {this.setState({errors})},
onSubmit: this.onFormSubmit.bind(this)
}
}
getTemplateItems() {
return this.state.templates.map((template) => {
return {
content: template
};
});
}
onItemChange(index, language) {
if(this.state.edited) {
AreYouSure.openModal(i18n('WILL_LOSE_CHANGES'), this.retrieveEmailTemplate.bind(this, index, language || this.state.language));
} else {
this.retrieveEmailTemplate(index, language || this.state.language);
}
}
onHeaderImageSubmit(form) {
this.setState({
loadingHeaderImage: true,
});
API.call({
path: '/system/edit-settings',
data: {
'mail-template-header-image': form['headerImage']
}
}).then(() => this.setState({
loadingHeaderImage: false,
}))
}
onFormSubmit(form) {
const {selectedIndex, language, templates} = this.state;
this.setState({loadingForm: true});
API.call({
path: '/system/edit-mail-template',
data: {
template: templates[selectedIndex],
language,
subject: form.subject,
text1: form.text1,
text2: form.text2,
text3: form.text3,
}
}).then(() => {
this.setState({loadingForm: false, edited: false});
}).catch(response => {
this.setState({
loadingForm: false,
});
switch(response.message) {
case 'INVALID_SUBJECT':
this.setState({
errors: {subject: i18n('INVALID_SYNTAX')}
});
break;
case 'INVALID_TEXT_1':
this.setState({
errors: {text1: i18n('INVALID_SYNTAX')}
});
break;
case 'INVALID_TEXT_2':
this.setState({
errors: {text2: i18n('INVALID_SYNTAX')}
});
break;
case 'INVALID_TEXT_3':
this.setState({
errors: {text3: i18n('INVALID_SYNTAX')}
});
break;
}
});
}
onDiscardChangesClick(event) {
event.preventDefault();
this.onItemChange(this.state.selectedIndex, this.state.language);
}
onRecoverClick(event) {
event.preventDefault();
AreYouSure.openModal(i18n('WILL_RECOVER_EMAIL_TEMPLATE'), this.recoverEmailTemplate.bind(this));
}
recoverEmailTemplate() {
const {selectedIndex, language, templates} = this.state;
API.call({
path: '/system/recover-mail-template',
data: {
template: templates[selectedIndex],
language
}
}).then(() => {
this.retrieveEmailTemplate(this.state.selectedIndex, language);
});
}
retrieveEmailTemplate(index, language) {
this.setState({
loadingForm: true,
});
API.call({
path: '/system/get-mail-template',
data: {template: this.state.templates[index], language}
}).then((result) => this.setState({
language,
selectedIndex: index,
edited: false,
loadingForm: false,
form: result.data,
errors: {},
}));
}
retrieveMailTemplateList() {
API.call({
path: '/system/get-mail-template-list',
data: {}
}).then((result) => this.setState({
loadingList: false,
templates: result.data
}));
}
retrieveHeaderImage() {
API.call({
path: '/system/get-settings',
data: {allSettings: 1}
}).then(result => this.setState({
headerImage: result.data['mail-template-header-image']
}));
}
}
export default connect((store) => {
return {
language: store.config.language,
};
})(AdminPanelEmailTemplates);

View File

@ -1,37 +0,0 @@
@import "../../../../scss/vars";
.admin-panel-email-templates {
&__text-area {
width: 100%;
height: 45px;
}
&__save-button {
display: inline-block;
float: left;
}
&__optional-buttons {
display: inline-block;
float: right;
}
&__discard-button {
display: inline-block;
}
&__recover-button {
display: inline-block;
margin-left: 10px;
}
&__image-container {
background-color: $very-light-grey;
display: flex;
justify-content: space-between;
align-items: center;
padding: 5px 20px;
margin-top: 20px;
}
}

View File

@ -26,7 +26,6 @@ class AdminPanelSystemPreferences extends React.Component {
message: null,
values: {
maintenance: false,
'smtp-pass': 'HIDDEN',
}
};
@ -53,35 +52,12 @@ class AdminPanelSystemPreferences extends React.Component {
</div>
</div>
<div className="row">
<div className="col-md-6">
<FormField label={i18n('SUPPORT_CENTER_URL')} fieldProps={{size: 'large'}} name="url" validation="URL" required/>
<FormField label={i18n('SUPPORT_CENTER_LAYOUT')} fieldProps={{size: 'large', items: [{content: i18n('BOXED')}, {content: i18n('FULL_WIDTH')}]}} field="select" name="layout" />
<div className="col-md-6 admin-panel-system-preferences__form-fields">
<FormField className="admin-panel-system-preferences__form-fields__input" label={i18n('SUPPORT_CENTER_URL')} fieldProps={{size: 'large'}} name="url" validation="URL" required/>
<FormField className="admin-panel-system-preferences__form-fields__select" label={i18n('SUPPORT_CENTER_LAYOUT')} fieldProps={{size: 'large', items: [{content: i18n('BOXED')}, {content: i18n('FULL_WIDTH')}]}} field="select" name="layout" />
</div>
<div className="col-md-6">
<FormField label={i18n('SUPPORT_CENTER_TITLE')} fieldProps={{size: 'large'}} name="title" validation="TITLE" required/>
<FormField label={i18n('DEFAULT_TIMEZONE')} fieldProps={{size: 'large'}} name="time-zone"/>
</div>
</div>
<div className="row">
<div className="col-md-12">
<span className="separator" />
<div className="row">
<div className="col-md-6">
<FormField label={i18n('NOREPLY_EMAIL')} fieldProps={{size: 'large'}} name="no-reply-email"/>
<FormField label={i18n('SMTP_USER')} fieldProps={{size: 'large'}} name="smtp-user"/>
</div>
<div className="col-md-6">
<div className="row">
<div className="col-md-9">
<FormField label={i18n('SMTP_SERVER')} fieldProps={{size: 'large'}} name="smtp-host"/>
<FormField label={i18n('SMTP_PASSWORD')} fieldProps={{size: 'large'}} name="smtp-pass"/>
</div>
<div className="col-md-3">
<FormField label={i18n('PORT')} fieldProps={{size: 'auto'}} name="smtp-port"/>
</div>
</div>
</div>
</div>
</div>
</div>
<div className="row">
@ -122,12 +98,12 @@ class AdminPanelSystemPreferences extends React.Component {
<span className="separator" />
</div>
</div>
<div className="row">
<div className="row admin-panel-system-preferences__container">
<div className="col-md-4 col-md-offset-2">
<SubmitButton type="secondary">{i18n('UPDATE_SETTINGS')}</SubmitButton>
<Button className="admin-panel-system-preferences__container__button" onClick={this.onDiscardChangesSubmit.bind(this)}>{i18n('DISCARD_CHANGES')}</Button>
</div>
<div className="col-md-4">
<Button onClick={this.onDiscardChangesSubmit.bind(this)}>{i18n('DISCARD_CHANGES')}</Button>
<SubmitButton className="admin-panel-system-preferences__container__button" type="secondary">{i18n('UPDATE_SETTINGS')}</SubmitButton>
</div>
</div>
</Form>
@ -172,12 +148,6 @@ class AdminPanelSystemPreferences extends React.Component {
'url': form['url'],
'title': form['title'],
'layout': form['layout'] ? 'full-width' : 'boxed',
'time-zone': form['time-zone'],
'no-reply-email': form['no-reply-email'],
'smtp-host': form['smtp-host'],
'smtp-port': form['smtp-port'],
'smtp-user': form['smtp-user'],
[ form['smtp-pass'] !== 'HIDDEN' ? 'smtp-pass' : null]: form['smtp-pass'],
'maintenance-mode': form['maintenance-mode'] * 1,
'allow-attachments': form['allow-attachments'] * 1,
'max-size': form['max-size'],
@ -218,12 +188,6 @@ class AdminPanelSystemPreferences extends React.Component {
'url': result.data['url'],
'title': result.data['title'],
'layout': (result.data['layout'] == 'full-width') ? 1 : 0,
'time-zone': result.data['time-zone'],
'no-reply-email': result.data['no-reply-email'],
'smtp-host': result.data['smtp-host'],
'smtp-port': result.data['smtp-port'],
'smtp-user': result.data['smtp-user'],
'smtp-pass': 'HIDDEN',
'maintenance-mode': !!(result.data['maintenance-mode'] * 1),
'allow-attachments': !!(result.data['allow-attachments'] * 1),
'max-size': result.data['max-size'],

View File

@ -51,4 +51,30 @@
&__message {
margin-top: 20px;
}
}
@media screen and (max-width: 376px) {
.admin-panel-system-preferences {
&__form-fields {
padding: 0;
&__input, &__select {
padding: 0 15px;
}
}
}
}
@media screen and (max-width: 415px) {
.admin-panel-system-preferences {
&__container__button {
margin: 10px;
}
&__languages {
&-allowed, &-supported {
display: unset;
}
}
}
}
}

View File

@ -67,7 +67,7 @@ class AddStaffModal extends React.Component {
return SessionStore.getDepartments().map(department => {
if(department.private*1){
return <spam>{department.name} <Icon name='user-secret'/> </spam>
}else {
} else {
return department.name;
}
});

View File

@ -1,13 +1,14 @@
import React from 'react';
import _ from 'lodash';
import {connect} from 'react-redux';
import {connect} from 'react-redux';
import i18n from 'lib-app/i18n';
import API from 'lib-app/api-call';
import ConfigActions from 'actions/config-actions';
import SessionStore from 'lib-app/session-store';
import AreYouSure from 'app-components/are-you-sure';
import DepartmentDropDown from 'app-components/department-dropdown';
import DepartmentDropdown from 'app-components/department-dropdown';
import InfoTooltip from 'core-components/info-tooltip';
import Button from 'core-components/button';
@ -16,9 +17,19 @@ import Listing from 'core-components/listing';
import Form from 'core-components/form';
import FormField from 'core-components/form-field';
import SubmitButton from 'core-components/submit-button';
import DropDown from 'core-components/drop-down';
import Icon from 'core-components/icon';
import Message from 'core-components/message';
import Loading from 'core-components/loading'
function getPublicDepartmentList(){
return _.filter(SessionStore.getDepartments(),item => item.private != 1)
}
export const getPublicDepartmentIndexFromDepartmentId = (departmentId, departments) => {
const departmentIndex = _.findIndex(departments, department => department.id == departmentId);
return (departmentIndex !== -1) ? departmentIndex : 0;
}
class AdminPanelDepartments extends React.Component {
static defaultProps = {
@ -29,52 +40,107 @@ class AdminPanelDepartments extends React.Component {
formLoading: false,
selectedIndex: -1,
selectedDropDownIndex: 0,
edited: false,
editedAddDepartmentForm: false,
editedDefaultDepartmentForm: false,
errorMessage: null,
errors: {},
defaultDepartmentError: null,
form: {
title: '',
name: '',
language: 'en',
private: 0,
}
private: 0
},
defaultDepartment: this.props.defaultDepartmentId,
defaultDepartmentLocked: this.props.defaultDepartmentLocked * 1
};
render() {
const { errorMessage, formLoading, selectedIndex } = this.state;
return (
<div className="admin-panel-departments">
<Header title={i18n('DEPARTMENTS')} description={i18n('DEPARTMENTS_DESCRIPTION')} />
<div className="row">
<div className="col-md-4">
<Listing {...this.getListingProps()}/>
<Listing {...this.getListingProps()} />
</div>
<div className="col-md-8">
{(this.state.errorMessage) ? <Message type="error">{i18n(this.state.errorMessage)}</Message> : null}
{errorMessage ? <Message type="error">{i18n(errorMessage)}</Message> : null}
<Form {...this.getFormProps()}>
<div>
<FormField className="admin-panel-departments__name" label={i18n('NAME')} name="name" validation="NAME" required fieldProps={{size: 'large'}}/>
<div className="admin-panel-departments__container">
<FormField className="admin-panel-departments__container__name" label={i18n('NAME')} name="name" validation="NAME" required fieldProps={{size: 'large'}} />
<div className="admin-panel-departments__private-option">
<FormField label={i18n('PRIVATE')} name="private" field="checkbox"/>
<InfoTooltip className="admin-panel-departments__info-tooltip" text={i18n('PRIVATE_DEPARTMENT_DESCRIPTION')} />
<FormField label={i18n('PRIVATE')} name="private" field="checkbox" />
<InfoTooltip className="admin-panel-departments__container__info-tooltip" text={i18n('PRIVATE_DEPARTMENT_DESCRIPTION')} />
</div>
</div>
<div className="admin-panel-departments__buttons-container">
{(selectedIndex !== -1 && this.props.departments.length) ? this.renderOptionalButtons() : null}
<div className="admin-panel-departments__update-name-button__container">
<SubmitButton
size="medium"
className="admin-panel-departments__update-name-button"
type="secondary">
{
formLoading ?
<Loading /> :
i18n((selectedIndex !== -1) ? 'UPDATE_DEPARTMENT' : 'ADD_DEPARTMENT')
}
</SubmitButton>
</div>
</div>
<SubmitButton size="medium" className="admin-panel-departments__update-name-button" type="secondary">
{i18n((this.state.selectedIndex !== -1) ? 'UPDATE_DEPARTMENT' : 'ADD_DEPARTMENT')}
</SubmitButton>
</Form>
{(this.state.selectedIndex !== -1 && this.props.departments.length) ? this.renderOptionalButtons() : null}
</div>
</div>
{this.renderDefaultDepartmentForm()}
</div>
);
}
renderDefaultDepartmentForm() {
const { defaultDepartmentError, formLoading } = this.state;
return (
<div className="admin-panel-departments__default-departments-container">
<span className="separator" />
{(defaultDepartmentError !== null) ?
((!defaultDepartmentError) ?
<Message type="success">{i18n('SETTINGS_UPDATED')}</Message> :
<Message type="error">{i18n(defaultDepartmentError)}</Message>) :
null}
<Form {...this.getDefaultDepartmentFormProps()} className="admin-panel-departments__default-departments-container__form">
<div className="admin-panel-departments__default-departments-container__form__fields" >
<FormField
className="admin-panel-departments__default-departments-container__form__fields__select"
label={i18n('DEFAULT_DEPARTMENT')}
name="defaultDepartment"
field="select"
decorator={DepartmentDropdown}
fieldProps={{departments: getPublicDepartmentList(), size: 'medium'}} />
<FormField className="admin-panel-departments__default-departments-container__form__fields__checkbox" label={i18n('LOCK_DEPARTMENT_DESCRIPTION')} name="locked" field="checkbox" />
</div>
<SubmitButton
className="admin-panel-departments__default-departments-container__form__button"
size="medium"
type="secondary" >
{formLoading ? <Loading /> : i18n('UPDATE_DEFAULT_DEPARTMENT')}
</SubmitButton>
</Form>
</div>
)
}
renderOptionalButtons() {
return (
<div className="admin-panel-departments__optional-buttons">
<div className="admin-panel-departments__discard-button">
<Button onClick={this.onDiscardChangesClick.bind(this)} size="medium">{i18n('DISCARD_CHANGES')}</Button>
</div>
<div className="admin-panel-departments__optional-buttons-container">
{this.props.departments.length > 1 ? this.renderDeleteButton() : null}
{
(this.state.editedAddDepartmentForm) ?
<div className="admin-panel-departments__discard-button">
<Button onClick={this.onDiscardChangesClick.bind(this)} size="medium">{i18n('DISCARD_CHANGES')}</Button>
</div> :
null
}
</div>
);
}
@ -93,25 +159,32 @@ class AdminPanelDepartments extends React.Component {
{i18n('WILL_DELETE_DEPARTMENT')}
<div className="admin-panel-departments__transfer-tickets">
<span className="admin-panel-departments__transfer-tickets-title">{i18n('TRANSFER_TICKETS_TO')}</span>
<DepartmentDropDown className="admin-panel-departments__transfer-tickets-drop-down" departments={this.getDropDownDepartments()} onChange={(event) => this.setState({selectedDropDownIndex: event.index})} size="medium"/>
<DepartmentDropdown
className="admin-panel-departments__transfer-tickets-drop-down"
departments={this.getDropDownDepartments()}
onChange={(event) => this.setState({selectedDropDownIndex: event.index})}
size="medium" />
</div>
</div>
);
}
getListingProps() {
const { departments, defaultDepartmentId } = this.props;
return {
className: 'admin-panel-departments__list',
title: i18n('DEPARTMENTS'),
items: this.props.departments.map(department => {
items: departments.map(department => {
return {
content: (
<span>
{department.name}
{department.private*1 ? <Icon className="admin-panel-departments__private-icon" name='user-secret'/> : null }
{department.private*1 ? <Icon className="admin-panel-departments__private-icon" name='user-secret' /> : null}
{department.id == defaultDepartmentId ? <spam className="admin-panel-departments__default-icon"> {i18n('DEFAULT')} </spam> : null}
{(!department.owners) ? (
<span className="admin-panel-departments__warning">
<InfoTooltip type="warning" text={i18n('NO_STAFF_ASSIGNED')}/>
<InfoTooltip type="warning" text={i18n('NO_STAFF_ASSIGNED')} />
</span>
) : null}
</span>
@ -126,39 +199,81 @@ class AdminPanelDepartments extends React.Component {
}
getFormProps() {
const { form, errors, formLoading } = this.state;
return {
values: this.state.form,
errors: this.state.errors,
loading: this.state.formLoading,
onChange: (form) => {this.setState({form, edited: true})},
values: {...form, private: !!form.private},
errors: errors,
loading: formLoading,
onChange: (form) => {this.setState({form, editedAddDepartmentForm: true})},
onValidateErrors: (errors) => {this.setState({errors})},
onSubmit: this.onFormSubmit.bind(this)
onSubmit: this.onFormSubmit.bind(this),
loading: formLoading,
className: 'admin-panel-departments__form'
};
}
getDefaultDepartmentFormProps() {
const { formLoading, defaultDepartment, defaultDepartmentLocked } = this.state;
return {
values: {
defaultDepartment: getPublicDepartmentIndexFromDepartmentId(defaultDepartment, getPublicDepartmentList()),
locked: defaultDepartmentLocked ? true : false,
},
onChange: (formValue) => {
this.setState({
editedDefaultDepartmentForm: true,
defaultDepartmentError: null,
defaultDepartment: getPublicDepartmentList()[formValue.defaultDepartment].id,
defaultDepartmentLocked: formValue.locked
});
},
onSubmit: this.onDefaultDepartmentFormSubmit.bind(this),
loading: formLoading
};
}
onItemChange(index) {
if(this.state.edited) {
if(this.state.editedAddDepartmentForm) {
AreYouSure.openModal(i18n('WILL_LOSE_CHANGES'), this.updateForm.bind(this, index));
} else {
this.updateForm(index);
}
}
onDefaultDepartmentFormSubmit(formValue) {
let publicDepartments = getPublicDepartmentList();
this.setState({formLoading: true, editedDefaultDepartmentForm: false});
API.call({
path: '/system/edit-settings',
data: {
'default-department-id': this.getCurrentDepartment(publicDepartments, formValue.defaultDepartment).id,
'default-is-locked': formValue.locked ? 1 : 0
}
}).then(() => {
this.retrieveDepartments(true);
this.setState({formLoading: false, errorMessage: false, defaultDepartmentError: false});
}).catch(result => this.setState({formLoading: false, defaultDepartmentError: result.message}));
}
onFormSubmit(form) {
this.setState({formLoading: true, edited: false});
this.setState({formLoading: true, editedAddDepartmentForm: false});
if(this.state.selectedIndex !== -1) {
API.call({
path: '/system/edit-department',
data: {
departmentId: this.getCurrentDepartment().id,
departmentId: this.getCurrentDepartment(this.props.departments).id,
name: form.name,
private: form.private ? 1 : 0
}
}).then(() => {
this.setState({formLoading: false});
this.setState({formLoading: false, errorMessage: false, defaultDepartmentError: null});
this.retrieveDepartments();
}).catch(result => this.setState({formLoading: false, errorMessage: result.message}));
}).catch(result => this.setState({formLoading: false, errorMessage: result.message, defaultDepartmentError: null}));
} else {
API.call({
path: '/system/add-department',
@ -167,9 +282,13 @@ class AdminPanelDepartments extends React.Component {
private: form.private ? 1 : 0
}
}).then(() => {
this.setState({formLoading: false,errorMessage: false, defaultDepartmentError: null});
this.retrieveDepartments();
this.onItemChange(-1);
}).catch(this.onItemChange.bind(this, -1));
}).catch(() => {
this.onItemChange.bind(this, -1);
this.setState({formLoading: false, defaultDepartmentError: null});
});
}
}
@ -186,49 +305,56 @@ class AdminPanelDepartments extends React.Component {
}
deleteDepartment() {
API.call({
return API.call({
path: '/system/delete-department',
data: {
departmentId: this.getCurrentDepartment().id,
departmentId: this.getCurrentDepartment(this.props.departments).id,
transferDepartmentId: this.getDropDownItemId()
}
}).then(() => {
this.retrieveDepartments();
this.onItemChange(-1);
this.setState({defaultDepartmentError: null});
})
.catch(result => this.setState({errorMessage: result.message}));
.catch(result => this.setState({errorMessage: result.message, defaultDepartmentError: null}));
}
updateForm(index) {
let form = _.clone(this.state.form);
let department = this.getCurrentDepartment(index);
let department = this.getCurrentDepartment(this.props.departments, index);
form.name = (department && department.name) || '';
form.private = (department && department.private) || 0;
this.setState({
selectedIndex: index,
edited: false,
editedAddDepartmentForm: false,
formLoading: false,
form,
errorMessage: null,
errors: {}
errors: {},
defaultDepartmentError: null
});
}
retrieveDepartments() {
retrieveDepartments(fromUpdateDefaultDepartmentForm = false) {
this.props.dispatch(ConfigActions.updateData());
this.setState({
edited: false
});
this.setState(
fromUpdateDefaultDepartmentForm ?
{editedDefaultDepartmentForm: false} :
{editedAddDepartmentForm: false}
);
}
getCurrentDepartment(index) {
return this.props.departments[(index == undefined) ? this.state.selectedIndex : index];
getCurrentDepartment(list, index) {
return list[(index == undefined) ? this.state.selectedIndex : index];
}
getDropDownItemId() {
return this.props.departments.filter((department, index) => index !== this.state.selectedIndex)[this.state.selectedDropDownIndex].id;
const { selectedIndex, selectedDropDownIndex } = this.state;
return this.props.departments.filter((department, index) => index !== selectedIndex)[selectedDropDownIndex].id;
}
getDropDownDepartments() {
@ -238,6 +364,8 @@ class AdminPanelDepartments extends React.Component {
export default connect((store) => {
return {
defaultDepartmentId: store.config['default-department-id']*1,
defaultDepartmentLocked: store.config['default-is-locked']*1,
departments: store.config.departments
};
})(AdminPanelDepartments);

View File

@ -7,26 +7,67 @@
}
&__update-name-button {
float: left;
min-width: 156px;
&__container {
padding: 0 0 0 10px;
}
}
&__name {
display:inline-block;margin-right: 101px;
}
&__private-option {
display:inline-block;
margin-left: 10px;
}
&__optional-buttons {
float: right;
&__buttons-container {
display: flex;
flex-direction: row;
justify-content: flex-end;
align-items: center;
width: 100%;
}
&__discard-button,
&__delete-button {
display: inline-block;
margin-left: 10px;
padding: 0 10px;
}
&__container{
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
width: 460px;
&__info-tooltip {
margin-left: 2px;
}
}
&__form {
display: flex;
flex-direction: column;
justify-content: space-around;
align-items: flex-start;
}
&__default-departments-container {
&__form {
text-align: left;
&__fields {
width: 100%;
display: flex;
flex-direction: column;
justify-content: space-around;
align-items: flex-start;
padding-bottom: 10px;
}
&__button {
min-width: 156px;
}
}
}
&__optional-buttons-container {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
}
&__warning {
@ -49,7 +90,43 @@
&__private-icon {
margin-left: 5px;
}
&__info-tooltip {
margin-left: 2px;
&__default-icon {
color: lightgrey;
font-style: italic;
text-transform: lowercase;
margin-left: 3px;
}
@media screen and (max-width: 415px) {
.admin-panel-departments {
&__container{
display: unset;
width: 100%;
&__name {
margin: 30px;
}
&__private-option {
margin: 10px;
}
}
&__update-name-button {
float: unset;
margin: 15px;
}
&__default-departments-container__form__fields {
&__checkbox {
display: flex;
flex-direction: row;
justify-content: space-between;
align-items: center;
}
}
}
}
}

View File

@ -11,10 +11,9 @@ import SessionStore from 'lib-app/session-store';
import PeopleList from 'app-components/people-list';
import ModalContainer from 'app-components/modal-container';
import AddStaffModal from 'app/admin/panel/staff/add-staff-modal';
import InviteStaffModal from 'app/admin/panel/staff/invite-staff-modal';
import Header from 'core-components/header';
import DropDown from 'core-components/drop-down';
import Button from 'core-components/button';
import Icon from 'core-components/icon';
import Loading from 'core-components/loading';
@ -47,8 +46,8 @@ class AdminPanelStaffMembers extends React.Component {
<Header title={i18n('STAFF_MEMBERS')} description={i18n('STAFF_MEMBERS_DESCRIPTION')} />
<div className="admin-panel-staff-members__wrapper">
<DepartmentDropdown {...this.getDepartmentDropdownProps()} className="admin-panel-staff-members__dropdown" />
<Button onClick={this.onAddNewStaff.bind(this)} size="medium" type="secondary" className="admin-panel-staff-members__button">
<Icon name="user-plus" className=""/> {i18n('ADD_NEW_STAFF')}
<Button onClick={this.onInviteStaff.bind(this)} size="medium" type="secondary" className="admin-panel-staff-members__button">
<Icon name="user-plus" className="" /> {i18n('INVITE_STAFF')}
</Button>
</div>
{(this.props.loading) ? <Loading backgrounded /> : <PeopleList list={this.getStaffList()} page={this.state.page} onPageSelect={(index) => this.setState({page: index+1})} />}
@ -56,8 +55,8 @@ class AdminPanelStaffMembers extends React.Component {
);
}
onAddNewStaff() {
ModalContainer.openModal(<AddStaffModal onSuccess={this.retrieveStaffMembers.bind(this)} />);
onInviteStaff() {
ModalContainer.openModal(<InviteStaffModal onSuccess={this.retrieveStaffMembers.bind(this)} />);
}
getDepartmentDropdownProps() {

View File

@ -26,4 +26,21 @@
&__private {
margin-left: 5px;
}
@media screen and (max-width: 415px) {
.admin-panel-staff-members {
&__drowpdown {
float: unset;
}
&__button {
float: unset;
margin: 15px;
}
&__wrapper {
height: unset;
}
}
}
}

View File

@ -32,8 +32,21 @@ class AdminPanelViewStaff extends React.Component {
}
getProps() {
return _.extend({}, this.state.userData, {
staffId: this.props.params.staffId * 1,
const { userData } = this.state;
const {
userId,
params
} = this.props;
const userDataWithNumericLevel = {
...userData,
level: userData.level*1,
sendEmailOnNewTicket: userData.sendEmailOnNewTicket === "1",
myAccount: this.props.userEmail == userData.email
};
return _.extend({}, userDataWithNumericLevel, {
userId: userId*1,
staffId: params.staffId*1,
onDelete: this.onDelete.bind(this),
onChange: this.retrieveStaff.bind(this)
});
@ -49,14 +62,18 @@ class AdminPanelViewStaff extends React.Component {
}
onStaffRetrieved(result) {
const {
userId,
params,
dispatch
} = this.props;
this.setState({
loading: false,
userData: result.data
});
if(this.props.userId == this.props.params.staffId) {
this.props.dispatch(SessionActions.getUserData(null, null, true))
}
if(userId == params.staffId) dispatch(SessionActions.getUserData(null, null, true));
}
onDelete() {

Some files were not shown because too many files have changed in this diff Show More