Commit a0ea5707 authored by Farid Neshat's avatar Farid Neshat

Show & Request timeoffs using slack interactive buttons

parent e5fa43bb
This diff is collapsed.
......@@ -20,8 +20,7 @@
"dependencies": {
"bluebird": "^3.5.0",
"coffee-script": "^1.12.7",
"hubot-conversation": "^1.1.1",
"hubot-slack-interactive-messages": "0.0.1",
"lite-lru": "0.0.1",
"moment": "^2.18.1",
"moment-business-time": "^0.7.0",
"node-bamboohr": "^1.0.2",
......@@ -33,12 +32,14 @@
"hubot": "2.x"
},
"devDependencies": {
"@slack/client": "^3.15.0",
"chai": "^4.1.0",
"hubot": "2.x",
"hubot-test-helper": "^1.7.0",
"mocha": "^3.4.2",
"sinon": "^2.4.1",
"sinon-chai": "^2.7.0",
"slack-mock": "^1.1.0",
"supertest": "^3.0.0"
},
"main": "index.coffee",
......
'use strict';
/**
* Given a regex of possible key matches, it will convert a sentence to a key/value object:
* parseArgs(/\b(from|to)\b/
* @param regex A regex to match the key names.
* @param args The string to parse
* @returns {{}} A key value object, where keys represent the regex match and values is the string between the match till the next match
*/
module.exports = (regex, args) => {
// Throw away punctuations. This doesn't support non-english languages.
// / and - are for dates.
......
This diff is collapsed.
......@@ -21,17 +21,23 @@ const moment = require('moment');
require('moment-business-time');
const { Date } = require('sugar');
const Conversation = require('hubot-conversation');
const bamboohr = require('../bamboohr');
const types = require('../types');
const utils = require('../utils');
const parseArgs = require('../parse-args');
const DIALOG_TIMEOUT = 600000;
const WORKING_HOURS = 8;
const LiteLRU = require('lite-lru');
const MAX_CACHE_ENTRIES = 500;
// Probably this should as many employees the company has.
// This cache if full potentially can take about 50MB of memory.
module.exports = function (robot) {
const switchBoard = new Conversation(robot, ['user']);
const NAMESPACE = 'bamboohr-timeoff.request';
module.exports = function (robot) {
const lru = new LiteLRU(MAX_CACHE_ENTRIES, robot.brain.get(NAMESPACE) || []);
lru.onAdd = () => {
robot.brain.set(NAMESPACE, lru);
};
robot.respond(/request\s+((\w+?)[\s-]+)?(time[- ]?off|leave|vacation)\s+(.+?)\s*$/i,
res => {
let [,, type, type2, args] = res.match;
......@@ -56,53 +62,136 @@ module.exports = function (robot) {
}
type = types.getType(type, reason) || types.timeOffTypes[0];
const parseResult = parseRequestArgs(args);
let { start, amount, end } = normalizeArgs(parseResult, type.unit);
return bamboohr.employeesAsync().then(employees => {
const profile = res.message.user.profile;
const email = profile && profile.email;
if (!email) {
res.reply("You don't have an email associated with your user.");
return;
}
const employee = employees.find(employee => employee.fields.workEmail === email);
if (!employee) {
res.reply("I can't find your email in the BambooHR system.");
return;
}
const options = {
start: utils.serializeDate(start),
end: utils.serializeDate(end),
amount: amount,
typeId: type.id,
employee: employee.id,
reason,
};
let { start, amount, end } = normalizeArgs(parseRequestArgs(args), type.unit);
const timeOffStr = utils.formatTimeOff({ type: type.name, start, amount, end, unit: type.unit});
res.reply(`So you want ${timeOffStr}?`);
const dialog = switchBoard.startDialog(res, DIALOG_TIMEOUT,
'You didn\'t answer. I don\'t have all day to wait for you. Next time answer in 60 seconds!');
const id = lru.add(options);
const message = renderMessage(id, options);
dialog.addChoice(/yes/, (msg) => {
const request = {
status: 'requested',
start,
end,
timeOffTypeId: type.id,
amount,
};
return robot.adapter.client.web.chat.postEphemeral(res.message.room, '',
res.message.user.id, message);
});
});
if (reason) {
request.notes = [{
from: 'employee',
note: reason
}];
function renderMessage(id, {start, end, amount, typeId}) {
const type = types.timeOffTypes.find(type => type.id === typeId);
const timeOffStr = utils.formatTimeOff({ type: type.name, start, amount, end, unit: type.unit});
return {
"attachments": [
{
"text": `So you want ${timeOffStr}?`,
"color": "3AA3E3",
"mrkdwn_in": ["text"],
callback_id: 'bamboohr-timeoff.request#' + id,
"actions": [
{
"name": "type",
"text": "Pick a type...",
"type": "select",
"options": types.timeOffTypes.map(types.toOption),
selected_options: [types.toOption(type)]
},
{
"name": "request",
"text": "Yes, make the request!",
"type": "button",
"style": "primary"
},
{
"name": "cancel",
"text": "No, not really",
"type": "button"
}
]
}
]
}
}
bamboohr.employeesAsync().then(employees => {
const profile = res.message.user.profile;
const email = profile && profile.email;
if (!email) {
res.reply("You don't have an email associated with your user.");
return;
}
const employee = employees.find(employee => employee.fields.workEmail === email);
if (!employee) {
res.reply("I can't find your email in the BambooHR system.");
return;
}
return employee.requestTimeOffAsync(request);
}).done(id => msg.reply('Your timeoff request is created. ;)'), utils.errorHandler(msg));
});
robot.setActionHandler(new RegExp(NAMESPACE + '#'), payload => {
const id = parseInt(payload.callback_id.split('#')[1]);
const data = lru.get(id);
if (!data) {
return {
attachments: [{
actions : [],
text : `Sorry... I have forgotten about this request, please make a new one! :peace_symbol: `
}]
};
}
dialog.addChoice(/no/, (msg) => {
dialog.resetChoices();
});
});
const originalMessage = renderMessage(id, data);
const { attachments: [ attachment ] } = originalMessage;
const { actions: [ action ] } = payload;
const typeId = action.name === 'type' ? action.selected_options[0].value : data.typeId;
const type = types.timeOffTypes.find(type => type.id === typeId);
if (action.name === 'type') {
const start = utils.parseDate(data.start);
const end = utils.parseDate(data.end);
data.typeId = type.id;
lru.set(id, data);
const timeOffStr = utils.formatTimeOff({ type: type.name, start, amount: data.amount, end, unit: type.unit});
attachment.text = `So you want ${timeOffStr}?`;
attachment.actions[0].selectedOptions = types.toOption(type);
return originalMessage;
} else if (action.name === 'cancel') {
attachment.actions = [];
attachment.text = `Ok, I pretend you never asked!`;
lru.delete(id);
return originalMessage;
} else {
const request = {
status: 'requested',
start: data.start,
end: data.end,
timeOffTypeId: typeId,
amount: data.amount,
};
if (data.reason) {
request.notes = [{
from: 'employee',
note: data.reason
}];
}
return bamboohr.employee(data.employee)
.requestTimeOffAsync(request)
.then(id => 'Your timeoff request is created. ;)', utils.errorHandler())
.then(text => {
lru.delete(id);
attachment.actions = [];
attachment.text = text;
return originalMessage;
});
}
});
robot.respond(/timeoff help/i, (res) => {
res.reply(`
......@@ -142,7 +231,6 @@ const normalizeArgs = module.exports.normalizeArgs = ({start, amount, end}, unit
end = moment(start).addWorkingTime(amount, 'hours');
}
start = moment(start).nextWorkingTime();
end = moment(end).lastWorkingTime();
......@@ -162,8 +250,9 @@ const normalizeArgs = module.exports.normalizeArgs = ({start, amount, end}, unit
};
};
const parseRequestArgs = module.exports.parseArgs = args => {
const parseRequestArgs = module.exports.parseArgs = args => {
args = parseArgs(/\b(from|on|for|untill|till|to)\b/ig, args);
let currentDate;
......
......@@ -10,8 +10,15 @@ exports.ready = bamboohr.timeOffTypesAsync().done(result => {
type.unit = type.units;
type.shortName = type.name.toLowerCase().replace('leave', '').trim();
});
const vacation = timeOffTypes.findIndex(type => type.shortName.includes('vacaction'));
if (vacation !== -1) {
// Bring vacation to the top as the default.
timeOffTypes.unshift(timeOffTypes.splice(vacation, 1))
}
});
exports.getType = (name, reason) => {
return timeOffTypes.find(type => type.shortName === name);
};
exports.toOption = type => ({ value: type.id.toString(), text: type.name });
......@@ -14,11 +14,18 @@ exports.formatTimeOff = ({type, start, end, amount, unit, amountUnit, id}) => {
return result;
};
exports.parseDate = date => moment(date, 'YYYY-MM-DD');
exports.serializeDate = date =>
((typeof date === 'string') ? exports.parseDate(date) : moment(date)).format('YYYY-MM-DD');
exports.formatDate = (date, withYear, format = HUMAN_DATE_FORMAT) =>
moment(date).format(format + (withYear ? ' YYYY' : ''));
exports.errorHandler = msg => err => {
msg.reply('I\'m sorry, something went wrong: ' + err.message || err);
const message = 'I\'m sorry, something went wrong: ' + err.message || err;
if (msg) {
msg.reply(message);
}
console.error(err);
console.error(err.stack);
return message;
};
This diff is collapsed.
......@@ -4,8 +4,11 @@
'use strict';
const sinon = require('sinon');
const WebClient = require('@slack/client').WebClient;
const web = new WebClient('');
module.exports = robot => {
robot.setActionHandler = sinon.spy();
robot.setOptionsHandler = sinon.spy();
robot.adapter.client = { web };
};
......@@ -2,6 +2,8 @@
require('coffee-script/register');
const BambooEmployee = require('node-bamboohr/lib/employee');
const utils = require('../src/utils');
const Helper = require('hubot-test-helper');
const sinon = require('sinon');
const chai = require('chai');
......@@ -9,11 +11,12 @@ chai.use(require('sinon-chai'));
const { expect } = chai;
// Must be required before the script to stub everything properly
require('./stubs');
const stubs = require('./stubs');
const sctiptPath = '../src/scripts/request.js';
const script = require(sctiptPath);
const helper = new Helper(sctiptPath);
const helper = new Helper(['./deps-mocks.js', sctiptPath]);
const slackMock = require('slack-mock')();
describe('Request', function() {
before(function () {
......@@ -30,25 +33,222 @@ describe('Request', function() {
afterEach(function() {
this.room.destroy();
slackMock.reset();
});
const userParams = {
profile: { email: stubs.email }
};
describe('requesting timeoff', function () {
it('from for', function() {
return this.room.user.say('bob', 'hubot request timeoff from 25 July 2017 for 2 days').then(() => {
expect(this.room.messages[1][1]).to.be.equal('@bob So you want vacation from *Tue Jul 25' +
' 2017* to *Wed Jul 26* for 16 working hours?');
return this.room.user.say('bob', 'hubot request timeoff from 25 July 2017 for 2 days because life', userParams)
.then(() => {
const result = JSON.parse(slackMock.web.calls[0].params.attachments)[0];
expect(result).to.be.deep.include({
"text": "So you want vacation from *Tue Jul 25 2017* to *Wed Jul 26* for 16 working hours?",
"color": "3AA3E3",
"mrkdwn_in": ["text"],
"actions": [
{
"name": "type",
"text": "Pick a type...",
"type": "select",
"options": [
{
"text": "Vacation",
"value": "1"
},
{
"text": "Sick Leave",
"value": "2"
}
],
selected_options: [{ text: 'Vacation', value: '1' }]
},
{
"name": "request",
"text": "Yes, make the request!",
"type": "button",
"style": "primary"
},
{
"name": "cancel",
"text": "No, not really",
"type": "button"
}
]
});
const actionHandlerArgs = this.room.robot.setActionHandler.args[0];
const { callback_id } = result;
expect(callback_id).to.match(actionHandlerArgs[0]);
return actionHandlerArgs[1]({
"actions": [
{
"name": "type",
"selected_options": [
{
"value": "2"
}
]
}
],
"callback_id": callback_id,
"original_message": result,
});
}).then(message => {
expect(message.attachments[0].text).to.be.equal(
`So you want sick leave from *Tue Jul 25 2017* to *Wed Jul 26* for 16 working hours?`);
const actionHandlerArgs = this.room.robot.setActionHandler.args[0];
const { callback_id } = message.attachments[0];
expect(callback_id).to.match(actionHandlerArgs[0]);
expect(message.attachments[0].actions.length).to.be.equal(3);
return actionHandlerArgs[1]({
"actions": [
{
"name": "request"
}
],
"callback_id": callback_id,
"original_message": message,
});
}).then(message => {
expect(message.attachments[0].text).to.be.equal(
`Your timeoff request is created. ;)`);
expect(message.attachments[0].actions).to.be.eql([]);
const fn = BambooEmployee.prototype.requestTimeOff;
expect(fn.thisValues[0].id).to.be.equal(stubs.employeeId);
expect(fn.args[0][0]).to.deep.include({
status: 'requested',
timeOffTypeId: "2",
amount: 16,
notes: [{
from: 'employee',
note: 'life'
}]
});
const actionHandlerArgs = this.room.robot.setActionHandler.args[0];
const { callback_id } = message.attachments[0];
return actionHandlerArgs[1]({
"actions": [
{
"name": "request"
}
],
"callback_id": callback_id,
"original_message": message,
});
}).then(message => {
expect(message.attachments[0].text).to.be.equal(
`Sorry... I have forgotten about this request, please make a new one! :peace_symbol: `);
});
});
it('from for because', function() {
return this.room.user.say('bob', 'hubot request timeoff from 25 July 2017 for 2 days because fuck').then(() => {
expect(this.room.messages[1][1]).to.be.equal('@bob So you want vacation from *Tue Jul 25' +
' 2017* to *Wed Jul 26* for 16 working hours?');
});
return this.room.user.say('bob', 'hubot request timeoff from 25 July 2017 for 2 days because fuck', userParams)
.then(() => {
const result = JSON.parse(slackMock.web.calls[0].params.attachments)[0];
expect(result).to.be.deep.include({
"text": 'So you want vacation from *Tue Jul 25 2017* to *Wed Jul 26* for 16 working hours?',
"color": "3AA3E3",
"mrkdwn_in": ["text"],
"actions": [
{
"name": "type",
"text": "Pick a type...",
"type": "select",
"options": [
{
"text": "Vacation",
"value": "1"
},
{
"text": "Sick Leave",
"value": "2"
}
],
selected_options: [{ text: 'Vacation', value: '1' }]
},
{
"name": "request",
"text": "Yes, make the request!",
"type": "button",
"style": "primary"
},
{
"name": "cancel",
"text": "No, not really",
"type": "button"
}
]
});
const actionHandlerArgs = this.room.robot.setActionHandler.args[0];
const { callback_id } = result;
return actionHandlerArgs[1]({
"actions": [
{
"name": "cancel"
}
],
"callback_id": callback_id,
"original_message": result,
});
}).then(message => {
expect(message.attachments[0].text).to.be.equal('Ok, I pretend you never asked!');
const actionHandlerArgs = this.room.robot.setActionHandler.args[0];
const { callback_id } = message.attachments[0];
return actionHandlerArgs[1]({
"actions": [
{
"name": "request"
}
],
"callback_id": callback_id,
"original_message": message,
});
}).then(message => {
expect(message.attachments[0].text).to.be.equal(
`Sorry... I have forgotten about this request, please make a new one! :peace_symbol: `);
});
});
it('from for 8 hours because', function() {
return this.room.user.say('bob', 'hubot request timeoff from 25 July 2017 for 8 hours because fuck').then(() => {
expect(this.room.messages[1][1]).to.be.equal('@bob So you want vacation from *Tue Jul 25' +
' 2017* to *Tue Jul 25* for 8 working hours?');
});
return this.room.user.say('bob', 'hubot request timeoff from 25 July 2017 for 8 hours because fuck', userParams)
.then(() => {
const result = JSON.parse(slackMock.web.calls[0].params.attachments)[0];
expect(result).to.be.deep.include({
"text": 'So you want vacation from *Tue Jul 25 2017* to *Tue Jul 25* for 8 working hours?',
"color": "3AA3E3",
"mrkdwn_in": ["text"],
"actions": [
{
"name": "type",
"text": "Pick a type...",
"type": "select",
"options": [
{
"text": "Vacation",
"value": "1"
},
{
"text": "Sick Leave",
"value": "2"
}
],
selected_options: [{ text: 'Vacation', value: '1' }]
},
{
"name": "request",
"text": "Yes, make the request!",
"type": "button",
"style": "primary"
},
{
"name": "cancel",
"text": "No, not really",
"type": "button"
}
]
});
});
});
});
......
......@@ -12,11 +12,11 @@ exports.name = 'Farid';
const stubs = [
sinon.stub(Bamboohr.prototype, 'timeOffTypes').yields(null, {
timeOffTypes: [{
id: 1,
id: '1',
name: 'Vacation',
units: 'hours',
}, {
id: 2,
id: '2',
name: 'Sick Leave',
units: 'hours',
}],
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment