diff --git a/app/assets/javascripts/application.js b/app/assets/javascripts/application.js
index a26829aa5006cee8fda914b78a6ff6c9ffb29f6c..03097fe213ed41d05d181db33d8bab367fa2dd92 100644
--- a/app/assets/javascripts/application.js
+++ b/app/assets/javascripts/application.js
@@ -49,6 +49,7 @@
/*= require_directory ./blob */
/*= require_directory ./templates */
/*= require_directory ./commit */
+/*= require_directory ./directives */
/*= require_directory ./extensions */
/*= require_directory ./lib/utils */
/*= require_directory ./u2f */
diff --git a/app/assets/javascripts/directives/tooltip_title.js.es6 b/app/assets/javascripts/directives/tooltip_title.js.es6
new file mode 100644
index 0000000000000000000000000000000000000000..716d1c33c64dc704a16ee15c7dd92b0bbe36633f
--- /dev/null
+++ b/app/assets/javascripts/directives/tooltip_title.js.es6
@@ -0,0 +1,23 @@
+//= require vue
+
+((global) => {
+
+ /**
+ * This directive ensures the text used to populate a Bootstrap tooltip is
+ * updated dynamically. The tooltip's `title` is not stored or accessed
+ * elsewhere, making it reasonably safe to write to as needed.
+ */
+
+ Vue.directive('tooltip-title', {
+ update(el, binding) {
+ const titleInitAttr = 'title';
+ const titleStoreAttr = 'data-original-title';
+
+ const updatedValue = binding.value || el.getAttribute(titleInitAttr);
+
+ el.setAttribute(titleInitAttr, updatedValue);
+ el.setAttribute(titleStoreAttr, updatedValue);
+ },
+ });
+
+})(window.gl || (window.gl = {}));
diff --git a/app/assets/javascripts/issuable_time_tracker.js.es6 b/app/assets/javascripts/issuable_time_tracker.js.es6
new file mode 100644
index 0000000000000000000000000000000000000000..74af0f77a068bdb9c93d59bdc3755c923157dd16
--- /dev/null
+++ b/app/assets/javascripts/issuable_time_tracker.js.es6
@@ -0,0 +1,256 @@
+//= vue
+//= smart_interval
+//= subbable_resource
+
+((global) => {
+ $(() => {
+ const mockData = gl.generateTimeTrackingMockData('estimate-and-spend');
+
+ /* This Vue instance represents what will become the parent instance for the
+ * sidebar. It will be responsible for managing `issuable` state and propagating
+ * changes to sidebar components.
+ */
+ new Vue({
+ el: '#issuable-time-tracker',
+ data: {
+ time_estimated: mockData.time_estimated,
+ time_spent: mockData.time_spent,
+ },
+ computed: {
+ fetchIssuable() {
+ return gl.IssuableResource.get.bind(gl.IssuableResource, { type: 'GET', url: gl.IssuableResource.endpoint });
+ }
+ },
+ methods: {
+ initPolling() {
+ new gl. TODO:SmartInterval({
+ callback: this.fetchIssuable,
+ startingInterval: 1000,
+ maxInterval: 10000,
+ incrementByFactorOf: 2,
+ lazyStart: false,
+ });
+ },
+ updateState(data) {
+ data = global.generateTimeTrackingMockData('estimate-and-spend');
+ this.time_estimated = data.time_estimated;
+ this.time_spent = data.time_spent;
+ },
+ },
+ created() {
+ $(document).on('ajax:success', '.gfm-form', (e) => {
+ // TODO: check if slash command was included.
+ this.fetchIssuable();
+ });
+ },
+ mounted() {
+ gl.IssuableResource.subscribe(data => this.updateState(data));
+ this.initPolling();
+ }
+ });
+ });
+
+ Vue.component('issuable-time-tracker', {
+ props: ['time_estimated', 'time_spent'],
+ template: `
+
+
+
+
+
+
+
+
+
+ Spent
+ {{ spentPretty }}
+
+
+ Est
+ {{ estimatedPretty }}
+
+
+
+
+
+ Estimated:
+ {{ estimatedPretty }}
+
+
+ Spent:
+ {{ spentPretty }}
+
+
+ No estimate or time spent
+
+
+
+
+
+
Track time with slash commands
+
Slash commands can be used in the issues description and comment boxes.
+
+ /estimate
+ will update the estimated time with the latest command.
+
+
+ /spend
+ will update the sum of the time spent.
+
+
+
+
+
+ `,
+ data: function() {
+ return {
+ displayHelp: false,
+ loading: false,
+ }
+ },
+ computed: {
+ showComparison() {
+ return !!this.time_estimated && !!this.time_spent;
+ },
+ showEstimateOnly() {
+ return !!this.time_estimated && !this.time_spent;
+ },
+ showSpentOnly() {
+ return !!this.time_spent && !this.time_estimated;
+ },
+ showNoTimeTracking() {
+ return !this.time_estimated && !this.time_spent;
+ },
+ showHelp() {
+ return !!this.displayHelp;
+ },
+ estimatedPretty() {
+ return this.stringifyTime(this.time_estimated);
+ },
+ spentPretty() {
+ return this.stringifyTime(this.time_spent);
+ },
+ remainingPretty() {
+ return this.stringifyTime(this.parsedDiff);
+ },
+ remainingTooltipPretty() {
+ const prefix = this.diffMinutes < 0 ? 'Over by' : 'Time remaining:';
+ return `${prefix} ${this.remainingPretty}`;
+ },
+ parsedDiff () {
+ const MAX_DAYS = 5, MAX_HOURS = 8, MAX_MINUTES = 60;
+ const timePeriodConstraints = [
+ [ 'weeks', MAX_HOURS * MAX_DAYS ],
+ [ 'days', MAX_MINUTES * MAX_HOURS ],
+ [ 'hours', MAX_MINUTES ],
+ [ 'minutes', 1 ]
+ ];
+
+ const parsedDiff = {};
+
+ let unorderedMinutes = Math.abs(this.diffMinutes);
+
+ timePeriodConstraints.forEach((period, idx, collection) => {
+ const periodName = period[0];
+ const minutesPerPeriod = period[1];
+ const periodCount = Math.floor(unorderedMinutes / minutesPerPeriod);
+
+ unorderedMinutes -= (periodCount * minutesPerPeriod);
+ parsedDiff[periodName] = periodCount;
+ });
+
+ return parsedDiff;
+ },
+ diffMinutes () {
+ const time_estimated = this.time_estimated;
+ const time_spent = this.time_spent;
+ return time_estimated.totalMinutes - time_spent.totalMinutes;
+ },
+ diffPercent() {
+ const estimate = this.estimate;
+ return Math.floor((this.time_spent.totalMinutes / this.time_estimated.totalMinutes * 100)) + '%';
+ },
+ diffStatus() {
+ return this.time_estimated.totalMinutes >= this.time_spent.totalMinutes ? 'within_estimate' : 'over_estimate';
+ }
+ },
+ methods: {
+ abbreviateTime(value) {
+ return value.split(' ')[0];
+ },
+ toggleHelpState(show) {
+ this.displayHelp = show;
+ },
+ stringifyTime(obj) {
+ return _.reduce(obj, (memo, val, key) => {
+ return (key !== 'totalMinutes' && val !== 0) ? (memo + `${val}${key.charAt(0)} `) : memo;
+ }, '').trim();
+ },
+ },
+ });
+
+
+/***** Mock Data ******/
+
+ global.generateTimeTrackingMockData = generateMockStates;
+
+ function generateMockStates(state) {
+ const configurations = {
+ 'estimate-only': {
+ time_estimated: generateTimeObj(),
+ time_spent: null
+ },
+ 'spent-only': {
+ time_estimated: null,
+ time_spent: generateTimeObj()
+ },
+ 'estimate-and-spend': {
+ time_estimated: generateTimeObj(),
+ time_spent: generateTimeObj()
+ },
+ 'nothing': {
+ time_estimated: null,
+ time_spent: null
+ }
+ };
+ return configurations[state];
+ }
+
+ function generateTimeObj(
+ weeks = getRandomInt(0, 12),
+ days = getRandomInt(0, 7),
+ hours = getRandomInt(0, 8),
+ minutes = getRandomInt(0, 60),
+ totalMinutes = getRandomInt(0, 25 * 7 * 8 * 60)) {
+ return {
+ weeks, days, hours, minutes, totalMinutes
+ };
+ }
+
+ function getRandomInt(min, max) {
+ const justReturnZero = Math.random > .5;
+
+ return justReturnZero ? 0 : Math.floor(Math.random() * (max - min + 1)) + min;
+ }
+}) (window.gl || (window.gl = {}));
diff --git a/app/assets/javascripts/smart_interval.js.es6 b/app/assets/javascripts/smart_interval.js.es6
new file mode 100644
index 0000000000000000000000000000000000000000..b87641ff720ff22fe703f523608d403c988f2371
--- /dev/null
+++ b/app/assets/javascripts/smart_interval.js.es6
@@ -0,0 +1,139 @@
+/*
+* Instances of SmartInterval extend the functionality of `setInterval`, make it configurable
+* and controllable by a public API.
+*
+* */
+
+(() => {
+ class SmartInterval {
+ /**
+ * @param { function } callback Function to be called on each iteration (required)
+ * @param { milliseconds } startingInterval `currentInterval` is set to this initially
+ * @param { milliseconds } maxInterval `currentInterval` will be incremented to this
+ * @param { integer } incrementByFactorOf `currentInterval` is incremented by this factor
+ * @param { boolean } lazyStart Configure if timer is initialized on instantiation or lazily
+ */
+ constructor({ callback, startingInterval, maxInterval, incrementByFactorOf, lazyStart = false }) {
+ this.cfg = {
+ callback,
+ startingInterval,
+ maxInterval,
+ incrementByFactorOf,
+ lazyStart,
+ };
+
+ this.state = {
+ intervalId: null,
+ currentInterval: startingInterval,
+ pageVisibility: 'visible',
+ };
+
+ this.initInterval();
+ }
+ /* public */
+
+ start() {
+ const cfg = this.cfg;
+ const state = this.state;
+
+ state.intervalId = window.setInterval(() => {
+ cfg.callback();
+
+ if (this.getCurrentInterval() === cfg.maxInterval) {
+ return;
+ }
+
+ this.incrementInterval();
+ this.resume();
+ }, this.getCurrentInterval());
+ }
+
+ // cancel the existing timer, setting the currentInterval back to startingInterval
+ cancel() {
+ this.setCurrentInterval(this.cfg.startingInterval);
+ this.stopTimer();
+ }
+
+ // cancel the existing timer, without resetting the currentInterval
+ pause() {
+ this.stopTimer();
+ }
+
+ // start a timer, using the existing interval
+ resume() {
+ this.stopTimer(); // stop exsiting timer, in case timer was not previously stopped
+ this.start();
+ }
+
+ destroy() {
+ this.cancel();
+ $(document).off('visibilitychange').off('page:before-unload');
+ }
+
+ /* private */
+
+ initInterval() {
+ const cfg = this.cfg;
+
+ if (!cfg.lazyStart) {
+ this.start();
+ }
+
+ this.initVisibilityChangeHandling();
+ this.initPageUnloadHandling();
+ }
+
+ initVisibilityChangeHandling() {
+ const cfg = this.cfg;
+
+ // cancel interval when tab no longer shown (prevents cached pages from polling)
+ $(document)
+ .off('visibilitychange').on('visibilitychange', (e) => {
+ this.state.pageVisibility = e.target.visibilityState;
+ this.handleVisibilityChange();
+ });
+ }
+
+ initPageUnloadHandling() {
+ const cfg = this.cfg;
+
+ // prevent interval continuing after page change, when kept in cache by Turbolinks
+ $(document).on('page:before-unload', () => this.cancel());
+ }
+
+ handleVisibilityChange() {
+ const state = this.state;
+
+ const intervalAction = state.pageVisibility === 'hidden' ? this.pause : this.resume;
+
+ intervalAction.apply(this);
+ }
+
+ getCurrentInterval() {
+ return this.state.currentInterval;
+ }
+
+ setCurrentInterval(newInterval) {
+ this.state.currentInterval = newInterval;
+ }
+
+ incrementInterval() {
+ const cfg = this.cfg;
+ const currentInterval = this.getCurrentInterval();
+ let nextInterval = currentInterval * cfg.incrementByFactorOf;
+
+ if (nextInterval > cfg.maxInterval) {
+ nextInterval = cfg.maxInterval;
+ }
+
+ this.setCurrentInterval(nextInterval);
+ }
+
+ stopTimer() {
+ const state = this.state;
+
+ state.intervalId = window.clearInterval(state.intervalId);
+ }
+ }
+ gl.SmartInterval = SmartInterval;
+})(window.gl || (window.gl = {}));
diff --git a/app/assets/javascripts/subbable_resource.js.es6 b/app/assets/javascripts/subbable_resource.js.es6
new file mode 100644
index 0000000000000000000000000000000000000000..7428b65da3e841badac93d0dae633aad04b9a0c3
--- /dev/null
+++ b/app/assets/javascripts/subbable_resource.js.es6
@@ -0,0 +1,53 @@
+//= require vue
+//= require vue-resource
+
+((global) => {
+/*
+* SubbableResource can be extended to provide a pubsub-style service for one-off REST
+* calls. Subscribe by passing a callback or render method you will use to handle responses.
+ *
+* */
+
+ class SubbableResource {
+ constructor(resourcePath) {
+ this.endpoint = resourcePath;
+ // TODO: Switch to axios.create
+ this.resource = $.ajax;
+ this.subscribers = [];
+ }
+
+ subscribe(callback) {
+ this.subscribers.push(callback);
+ }
+
+ publish(newResponse) {
+ const responseCopy = _.extend({}, newResponse);
+ this.subscribers.forEach((fn) => {
+ fn(responseCopy);
+ });
+ return newResponse;
+ }
+
+ get(data) {
+ return this.resource(data)
+ .then(data => this.publish(data));
+ }
+
+ post(data) {
+ return this.resource(data)
+ .then(data => this.publish(data));
+ }
+
+ put(data) {
+ return this.resource(data)
+ .then(data => this.publish(data));
+ }
+
+ delete(data) {
+ return this.resource(data)
+ .then(data => this.publish(data));
+ }
+ }
+
+ gl.SubbableResource = SubbableResource;
+})(window.gl || (window.gl = {}));
diff --git a/app/assets/javascripts/weight_select.js b/app/assets/javascripts/weight_select.js
index 2f16cb8a4f0aebdb0fd2c90efe8d3779eac4274f..8d5a6f4ae71b6752e2b3e6732775076c6567c8eb 100644
--- a/app/assets/javascripts/weight_select.js
+++ b/app/assets/javascripts/weight_select.js
@@ -12,6 +12,20 @@
$value = $block.find('.value');
abilityName = $dropdown.data('ability-name');
$loading = $block.find('.block-loading').fadeOut();
+
+ var ajaxResource = gl.IssuableResource ? gl.IssuableResource.put.bind(gl.IssuableResource) : $.ajax;
+
+ var renderMethod = function(data) {
+ if (data.weight != null) {
+ $value.html(data.weight);
+ } else {
+ $value.html('None');
+ }
+ return $sidebarCollapsedValue.html(data.weight);
+ };
+
+ gl.IssuableResource && gl.IssuableResource.subscribe(renderMethod);
+
updateWeight = function(selected) {
var data;
data = {};
@@ -19,7 +33,7 @@
data[abilityName].weight = selected != null ? selected : null;
$loading.fadeIn();
$dropdown.trigger('loading.gl.dropdown');
- return $.ajax({
+ return ajaxResource({
type: 'PUT',
dataType: 'json',
url: updateUrl,
@@ -28,12 +42,7 @@
$dropdown.trigger('loaded.gl.dropdown');
$loading.fadeOut();
$selectbox.hide();
- if (data.weight != null) {
- $value.html(data.weight);
- } else {
- $value.html('None');
- }
- return $sidebarCollapsedValue.html(data.weight);
+ renderMethod(data);
});
};
return $dropdown.glDropdown({
diff --git a/app/assets/stylesheets/pages/issuable.scss b/app/assets/stylesheets/pages/issuable.scss
index 230b927a17daaefd7d5d9786b8dbd40c108a5949..6e106edc44e41a200381b23d77a165cc615941bb 100644
--- a/app/assets/stylesheets/pages/issuable.scss
+++ b/app/assets/stylesheets/pages/issuable.scss
@@ -421,3 +421,58 @@
}
}
}
+
+#issuable-time-tracker {
+ .time-tracking-help-state {
+ padding: 10px 0;
+ margin-top: 10px;
+ border-top: 1px solid #dcdcdc;
+ }
+
+ .meter-container {
+ background: $gray-lighter;
+ border-radius: 2px;
+ }
+
+ .meter-fill {
+ max-width: 100%;
+ height: 4px;
+ background: $gl-text-green;
+ }
+
+ .help-button, .close-help-button {
+ cursor: pointer;
+ }
+ .over_estimate {
+ .meter-fill {
+ background: $red-light ;
+ }
+ .time-remaining, .compare-value.spent {
+ color: $red-light ;
+ }
+ }
+ .sidebar-collapsed-icon {
+ svg {
+ width: 16px;
+ height: 16px;
+ fill: #999;
+ }
+ }
+
+ .within_estimate {
+ .meter-fill {
+ background: $gl-text-green;
+ }
+ }
+ .compare-display-container {
+ margin-top: 5px;
+ }
+ .compare-display {
+ font-size: 13px;
+ color: $gl-gray-light;
+
+ .compare-value {
+ color: $gl-gray;
+ }
+ }
+}
diff --git a/app/views/shared/icons/_icon_stopwatch.svg b/app/views/shared/icons/_icon_stopwatch.svg
new file mode 100644
index 0000000000000000000000000000000000000000..f20de04538e8238851798d321b1a8b5e9a20b774
--- /dev/null
+++ b/app/views/shared/icons/_icon_stopwatch.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/app/views/shared/issuable/_sidebar.html.haml b/app/views/shared/issuable/_sidebar.html.haml
index 9a82fc88660e78c3c931676cc071353e42adb594..f563ffc902b69b6552eb04d66db0874a41620ad8 100644
--- a/app/views/shared/issuable/_sidebar.html.haml
+++ b/app/views/shared/issuable/_sidebar.html.haml
@@ -1,5 +1,5 @@
- todo = issuable_todo(issuable)
-%aside.right-sidebar{ class: sidebar_gutter_collapsed_class }
+%aside.right-sidebar{ class: sidebar_gutter_collapsed_class, 'aria-live' => 'polite' }
.issuable-sidebar
- can_edit_issuable = can?(current_user, :"admin_#{issuable.to_ability_name}", @project)
.block.issuable-sidebar-header
@@ -72,7 +72,10 @@
.selectbox.hide-collapsed
= f.hidden_field 'milestone_id', value: issuable.milestone_id, id: nil
= dropdown_tag('Milestone', options: { title: 'Assign milestone', toggle_class: 'js-milestone-select js-extra-options', filter: true, dropdown_class: 'dropdown-menu-selectable', placeholder: 'Search milestones', data: { show_no: true, field_name: "#{issuable.to_ability_name}[milestone_id]", project_id: @project.id, issuable_id: issuable.id, milestones: namespace_project_milestones_path(@project.namespace, @project, :json), ability_name: issuable.to_ability_name, issue_update: issuable_json_path(issuable), use_id: true }})
-
+ // TODO: Need to add check for time_estimated - if issuable.has_attribute?(:time_estimated) once hooked up to backend
+ - if issuable.has_attribute?(:due_date)
+ #issuable-time-tracker.block
+ %issuable-time-tracker{ ':time_estimated' => 'time_estimated', ':time_spent' => 'time_spent' }
- if issuable.has_attribute?(:due_date)
.block.due_date
.sidebar-collapsed-icon
@@ -195,6 +198,7 @@
= clipboard_button(clipboard_text: project_ref)
:javascript
+ gl.IssuableResource = new gl.SubbableResource('#{issuable_json_path(issuable)}');
new MilestoneSelect('{"namespace":"#{@project.namespace.path}","path":"#{@project.path}"}');
new LabelsSelect();
new WeightSelect();
diff --git a/spec/javascripts/issuable_time_tracker_spec.js.es6 b/spec/javascripts/issuable_time_tracker_spec.js.es6
new file mode 100644
index 0000000000000000000000000000000000000000..30ef7a1c294378fb74add033a3d89544e6d21c62
--- /dev/null
+++ b/spec/javascripts/issuable_time_tracker_spec.js.es6
@@ -0,0 +1,71 @@
+/* eslint-disable */
+//= require jquery
+//= require vue
+//= require vue-resource
+//= require issuable_time_tracker
+
+((gl) => {
+
+ function generateTimeObject (weeks, days, hours, minutes, totalMinutes) {
+ return { weeks, days, hours, minutes, totalMinutes };
+ }
+
+ describe('Issuable Time Tracker', function() {
+ beforeEach(function() {
+ const time_estimated = generateTimeObject(2, 2, 2, 0, 5880);
+ const time_spent = generateTimeObject(1, 1, 1, 0, 2940);
+ const timeTrackingComponent = Vue.extend(gl.TimeTrackingDisplay);
+ this.timeTracker = new timeTrackingComponent({ data: { time_estimated, time_spent }}).$mount();
+ });
+
+ // show the correct pane
+ // stringify a time value
+ // the percent is being calculated and displayed correctly on the compare meter
+ // differ works, if needed
+ //
+ it('should parse a time diff based on total minutes', function() {
+ const parsedDiff = this.timeTracker.parsedDiff;
+ expect(parsedDiff.weeks).toBe(1);
+ expect(parsedDiff.days).toBe(1);
+ expect(parsedDiff.hours).toBe(1);
+ expect(parsedDiff.minutes).toBe(0);
+ });
+
+ it('should stringify a time value', function() {
+ const timeTracker = this.timeTracker;
+ const noZeroes = generateTimeObject(1, 1, 1, 2, 2940);
+ const someZeroes = generateTimeObject(1, 0, 1, 0, 2940);
+
+ expect(timeTracker.stringifyTime(noZeroes)).toBe('1w 1d 1h 2m');
+ expect(timeTracker.stringifyTime(someZeroes)).toBe('1w 1h');
+ });
+
+ it('should abbreviate a stringified value', function() {
+ const stringifyTime = this.timeTracker.stringifyTime;
+
+ const oneWeek = stringifyTime(generateTimeObject(1, 1, 1, 1, 2940));
+ const oneDay = stringifyTime(generateTimeObject(0, 1, 1, 1, 2940));
+ const oneHour = stringifyTime(generateTimeObject(0, 0, 1, 1, 2940));
+ const oneMinute = stringifyTime(generateTimeObject(0, 0, 0, 1, 2940));
+
+ const abbreviateTimeFilter = Vue.filter('abbreviate-time');
+
+ expect(abbreviateTimeFilter(oneWeek)).toBe('1w');
+ expect(abbreviateTimeFilter(oneDay)).toBe('1d');
+ expect(abbreviateTimeFilter(oneHour)).toBe('1h');
+ expect(abbreviateTimeFilter(oneMinute)).toBe('1m');
+ });
+
+ it('should toggle the help state', function() {
+ const timeTracker = this.timeTracker;
+
+ expect(timeTracker.displayHelp).toBe(false);
+
+ timeTracker.toggleHelpState(true);
+ expect(timeTracker.displayHelp).toBe(true);
+
+ timeTracker.toggleHelpState(false);
+ expect(timeTracker.displayHelp).toBe(false);
+ });
+ });
+})(window.gl || (window.gl = {}));
diff --git a/spec/javascripts/smart_interval_spec.js.es6 b/spec/javascripts/smart_interval_spec.js.es6
new file mode 100644
index 0000000000000000000000000000000000000000..3a286edb79bb12bdf7fac8f834c6b70123a6bdc1
--- /dev/null
+++ b/spec/javascripts/smart_interval_spec.js.es6
@@ -0,0 +1,185 @@
+/* eslint-disable */
+//= require jquery
+//= require smart_interval
+
+
+((global) => {
+ describe('SmartInterval', function () {
+
+ const DEFAULT_MAX_INTERVAL = 100;
+ const DEFAULT_STARTING_INTERVAL = 5;
+ const DEFAULT_SHORT_TIMEOUT = 75;
+ const DEFAULT_LONG_TIMEOUT = 1000;
+ const DEFAULT_INCREMENT_FACTOR = 2;
+
+ describe('Increment Interval', function () {
+ beforeEach(function () {
+ this.smartInterval = createDefaultSmartInterval();
+ });
+
+ it('should increment the interval delay', function (done) {
+ const interval = this.smartInterval;
+ setTimeout(() => {
+ const intervalConfig = this.smartInterval.cfg;
+ const iterationCount = 4;
+ const maxIntervalAfterIterations = intervalConfig.startingInterval *
+ Math.pow(intervalConfig.incrementByFactorOf, (iterationCount - 1)); // 40
+ const currentInterval = interval.getCurrentInterval();
+
+ // Provide some flexibility for performance of testing environment
+ expect(currentInterval).toBeGreaterThan(intervalConfig.startingInterval);
+ expect(currentInterval <= maxIntervalAfterIterations).toBeTruthy();
+
+ done();
+ }, DEFAULT_SHORT_TIMEOUT); // 4 iterations, increment by 2x = (5 + 10 + 20 + 40)
+ });
+
+ it('should not increment past maxInterval', function (done) {
+ const interval = this.smartInterval;
+
+ setTimeout(() => {
+ const currentInterval = interval.getCurrentInterval();
+ expect(currentInterval).toBe(interval.cfg.maxInterval);
+
+ done();
+ }, DEFAULT_LONG_TIMEOUT);
+ });
+ });
+
+ describe('Public methods', function () {
+ beforeEach(function () {
+ this.smartInterval = createDefaultSmartInterval();
+ });
+
+ it('should cancel an interval', function (done) {
+ const interval = this.smartInterval;
+
+ setTimeout(() => {
+ interval.cancel();
+
+ const intervalId = interval.state.intervalId;
+ const currentInterval = interval.getCurrentInterval();
+ const intervalLowerLimit = interval.cfg.startingInterval;
+
+ expect(intervalId).toBeUndefined();
+ expect(currentInterval).toBe(intervalLowerLimit);
+
+ done();
+ }, DEFAULT_SHORT_TIMEOUT);
+ });
+
+ it('should pause an interval', function (done) {
+ const interval = this.smartInterval;
+
+ setTimeout(() => {
+ interval.pause();
+
+ const intervalId = interval.state.intervalId;
+ const currentInterval = interval.getCurrentInterval();
+ const intervalLowerLimit = interval.cfg.startingInterval;
+
+ expect(intervalId).toBeUndefined();
+ expect(currentInterval).toBeGreaterThan(intervalLowerLimit);
+
+ done();
+ }, DEFAULT_SHORT_TIMEOUT);
+ });
+
+ it('should resume an interval', function (done) {
+ const interval = this.smartInterval;
+
+ setTimeout(() => {
+ interval.pause();
+
+ const lastInterval = interval.getCurrentInterval();
+ interval.resume();
+
+ const nextInterval = interval.getCurrentInterval();
+ const intervalId = interval.state.intervalId;
+
+ expect(intervalId).toBeTruthy();
+ expect(nextInterval).toBe(lastInterval);
+
+ done();
+ }, DEFAULT_SHORT_TIMEOUT);
+ });
+ });
+
+ describe('DOM Events', function () {
+ beforeEach(function () {
+ // This ensures DOM and DOM events are initialized for these specs.
+ fixture.set('');
+
+ this.smartInterval = createDefaultSmartInterval();
+ });
+
+ it('should pause when page is not visible', function (done) {
+ const interval = this.smartInterval;
+
+ setTimeout(() => {
+ expect(interval.state.intervalId).toBeTruthy();
+
+ // simulates triggering of visibilitychange event
+ interval.state.pageVisibility = 'hidden';
+ interval.handleVisibilityChange();
+
+ expect(interval.state.intervalId).toBeUndefined();
+ done();
+ }, DEFAULT_SHORT_TIMEOUT);
+ });
+
+ it('should resume when page is becomes visible at the previous interval', function (done) {
+ const interval = this.smartInterval;
+
+ setTimeout(() => {
+ expect(interval.state.intervalId).toBeTruthy();
+
+ // simulates triggering of visibilitychange event
+ interval.state.pageVisibility = 'hidden';
+ interval.handleVisibilityChange();
+
+ const pausedIntervalLength = interval.getCurrentInterval();
+
+ expect(interval.state.intervalId).toBeUndefined();
+
+ // simulates triggering of visibilitychange event
+ interval.state.pageVisibility = 'visible';
+ interval.handleVisibilityChange();
+
+ expect(interval.state.intervalId).toBeTruthy();
+ expect(interval.getCurrentInterval()).toBe(pausedIntervalLength);
+
+ done();
+ }, DEFAULT_SHORT_TIMEOUT);
+ });
+
+ it('should cancel on page unload', function (done) {
+ const interval = this.smartInterval;
+
+ setTimeout(() => {
+ $(document).trigger('page:before-unload');
+ expect(interval.state.intervalId).toBeUndefined();
+ expect(interval.getCurrentInterval()).toBe(interval.cfg.startingInterval);
+ done();
+ }, DEFAULT_SHORT_TIMEOUT);
+ });
+ });
+ });
+
+ function createDefaultSmartInterval(config) {
+ const defaultParams = {
+ callback: () => {},
+ startingInterval: DEFAULT_STARTING_INTERVAL,
+ maxInterval: DEFAULT_MAX_INTERVAL,
+ incrementByFactorOf: DEFAULT_INCREMENT_FACTOR,
+ delayStartBy: 0,
+ lazyStart: false,
+ };
+
+ if (config) {
+ _.extend(defaultParams, config);
+ }
+
+ return new gl.SmartInterval(defaultParams);
+ }
+})(window.gl || (window.gl = {}));
diff --git a/spec/javascripts/subbable_resource_spec.js.es6 b/spec/javascripts/subbable_resource_spec.js.es6
new file mode 100644
index 0000000000000000000000000000000000000000..df395296791a5c908ce638f4149e5a3861bfbd4c
--- /dev/null
+++ b/spec/javascripts/subbable_resource_spec.js.es6
@@ -0,0 +1,65 @@
+/* eslint-disable */
+//= vue
+//= vue-resource
+//= require jquery
+//= require subbable_resource
+
+/*
+* Test that each rest verb calls the publish and subscribe function and passes the correct value back
+*
+*
+* */
+((global) => {
+ describe('Subbable Resource', function () {
+ describe('PubSub', function () {
+ beforeEach(function () {
+ this.MockResource = new global.SubbableResource('https://example.com');
+ });
+ it('should successfully add a single subscriber', function () {
+ const callback = () => {};
+ this.MockResource.subscribe(callback);
+
+ expect(this.MockResource.subscribers.length).toBe(1);
+ expect(this.MockResource.subscribers[0]).toBe(callback);
+ });
+
+ it('should successfully add multiple subscribers', function () {
+ const callbackOne = () => {};
+ const callbackTwo = () => {};
+ const callbackThree = () => {};
+
+ this.MockResource.subscribe(callbackOne);
+ this.MockResource.subscribe(callbackTwo);
+ this.MockResource.subscribe(callbackThree);
+
+ expect(this.MockResource.subscribers.length).toBe(3);
+ });
+
+ it('should successfully publish an update to a single subscriber', function () {
+ const state = { myprop: 1 };
+
+ const callbacks = {
+ one: (data) => expect(data.myprop).toBe(2),
+ two: (data) => expect(data.myprop).toBe(2),
+ three: (data) => expect(data.myprop).toBe(2)
+ };
+
+ const spyOne = spyOn(callbacks, 'one');
+ const spyTwo = spyOn(callbacks, 'two');
+ const spyThree = spyOn(callbacks, 'three');
+
+ this.MockResource.subscribe(callbacks.one);
+ this.MockResource.subscribe(callbacks.two);
+ this.MockResource.subscribe(callbacks.three);
+
+ state.myprop++;
+
+ this.MockResource.publish(state);
+
+ expect(spyOne).toHaveBeenCalled();
+ expect(spyTwo).toHaveBeenCalled();
+ expect(spyThree).toHaveBeenCalled();
+ });
+ });
+ });
+})(window.gl || (window.gl = {}));