From 95068552ea3b1471f900d2d4070bd5e04e12475d Mon Sep 17 00:00:00 2001 From: Bryce Johnson Date: Fri, 28 Oct 2016 14:54:25 +0200 Subject: [PATCH] Prepare timetracking frontend for backend. --- app/assets/javascripts/application.js | 1 + .../directives/tooltip_title.js.es6 | 23 ++ .../javascripts/issuable_time_tracker.js.es6 | 256 ++++++++++++++++++ app/assets/javascripts/smart_interval.js.es6 | 139 ++++++++++ .../javascripts/subbable_resource.js.es6 | 53 ++++ app/assets/javascripts/weight_select.js | 23 +- app/assets/stylesheets/pages/issuable.scss | 55 ++++ app/views/shared/icons/_icon_stopwatch.svg | 1 + app/views/shared/issuable/_sidebar.html.haml | 8 +- .../issuable_time_tracker_spec.js.es6 | 71 +++++ spec/javascripts/smart_interval_spec.js.es6 | 185 +++++++++++++ .../javascripts/subbable_resource_spec.js.es6 | 65 +++++ 12 files changed, 871 insertions(+), 9 deletions(-) create mode 100644 app/assets/javascripts/directives/tooltip_title.js.es6 create mode 100644 app/assets/javascripts/issuable_time_tracker.js.es6 create mode 100644 app/assets/javascripts/smart_interval.js.es6 create mode 100644 app/assets/javascripts/subbable_resource.js.es6 create mode 100644 app/views/shared/icons/_icon_stopwatch.svg create mode 100644 spec/javascripts/issuable_time_tracker_spec.js.es6 create mode 100644 spec/javascripts/smart_interval_spec.js.es6 create mode 100644 spec/javascripts/subbable_resource_spec.js.es6 diff --git a/app/assets/javascripts/application.js b/app/assets/javascripts/application.js index a26829aa5006ce..03097fe213ed41 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 00000000000000..716d1c33c64dc7 --- /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 00000000000000..74af0f77a068bd --- /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: ` +
+ +
+ Time tracking +
+
+
+
+
+
+
+
+
+
+
+ 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 00000000000000..b87641ff720ff2 --- /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 00000000000000..7428b65da3e841 --- /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 2f16cb8a4f0aeb..8d5a6f4ae71b67 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 230b927a17daae..6e106edc44e41a 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 00000000000000..f20de04538e823 --- /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 9a82fc88660e78..f563ffc902b69b 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 00000000000000..30ef7a1c294378 --- /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 00000000000000..3a286edb79bb12 --- /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 00000000000000..df395296791a5c --- /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 = {})); -- GitLab