Commit 27155ca0 authored by Rubén Dávila Santos's avatar Rubén Dávila Santos

Merge branch 'time-tracking-integration' into 'master'

Integrate time tracking frontend and backend

Closes #985 

See merge request !870
parents 78b25760 0e41281e
/* eslint-disable */
(() => {
JumpToDiscussion = Vue.extend({
const JumpToDiscussion = Vue.extend({
mixins: [DiscussionMixins],
props: {
discussionId: String
......
/* eslint-disable */
//= require vue
//= require vue-resource
//= require_directory ./models
//= require_directory ./stores
//= require_directory ./services
......
//= require vue
//= require lib/utils/pretty_time
(() => {
const PrettyTime = gl.PrettyTime;
gl.IssuableTimeTracker = Vue.component('issuable-time-tracker', {
name: 'issuable-time-tracker',
props: ['time_estimate', 'time_spent', 'human_time_estimate', 'human_time_spent'],
data() {
return {
displayHelp: false,
};
},
computed: {
/* Select panels to show */
showComparison() {
return !!this.time_estimate && !!this.time_spent;
},
showEstimateOnly() {
return !!this.time_estimate && !this.time_spent;
},
showSpentOnly() {
return !!this.time_spent && !this.time_estimate;
},
showNoTimeTracking() {
return !this.time_estimate && !this.time_spent;
},
showHelp() {
return !!this.displayHelp;
},
/* Parsed time values */
parsedEstimate() {
return PrettyTime.parseSeconds(this.time_estimate);
},
parsedSpent() {
return PrettyTime.parseSeconds(this.time_spent);
},
parsedRemaining() {
const diffSeconds = this.time_estimate - this.time_spent;
return PrettyTime.parseSeconds(diffSeconds);
},
/* Human readable time values */
estimatedPretty() {
return this.human_time_estimate || PrettyTime.stringifyTime(this.parsedEstimate);
},
spentPretty() {
return this.human_time_spent || PrettyTime.stringifyTime(this.parsedSpent);
},
remainingPretty() {
return PrettyTime.stringifyTime(this.parsedRemaining);
},
remainingTooltipPretty() {
const prefix = this.diffMinutes < 0 ? 'Over by' : 'Time remaining:';
return `${prefix} ${this.remainingPretty}`;
},
/* Diff values for comparison meter */
diffMinutes() {
return this.time_estimate - this.time_spent;
},
diffPercent() {
return `${Math.floor(((this.time_spent / this.time_estimate) * 100))}%`;
},
diffStatusClass() {
return this.time_estimate >= this.time_spent ? 'within_estimate' : 'over_estimate';
},
},
methods: {
toggleHelpState(show) {
this.displayHelp = show;
},
abbreviateTime(timeStr) {
return PrettyTime.abbreviateTime(timeStr);
},
},
template: `
<div class='time_tracker time-tracking-component-wrap' v-cloak>
<div class='sidebar-collapsed-icon'>
<slot name='stopwatch'></slot>
<div class='time-tracking-collapsed-summary'>
<div class='compare' v-if='showComparison'>
<span>{{ abbreviateTime(spentPretty) }} / {{ abbreviateTime(estimatedPretty) }}</span>
</div>
<div class='estimate-only' v-if='showEstimateOnly'>
<span class='bold'>-- / {{ abbreviateTime(estimatedPretty) }}</span>
</div>
<div class='spend-only' v-if='showSpentOnly'>
<span class='bold'>{{ abbreviateTime(spentPretty) }} / --</span>
</div>
<div class='no-tracking' v-if='showNoTimeTracking'>
<span class='no-value'>None</span>
</div>
</div>
</div>
<div class='title hide-collapsed'>
Time tracking
<div class='help-button pull-right' v-if='!showHelp' @click='toggleHelpState(true)'>
<i class='fa fa-question-circle'></i>
</div>
</div>
<div class='time-tracking-content hide-collapsed'>
<div class='time-tracking-pane-compare' v-if='showComparison'>
<div class='compare-meter' data-toggle='tooltip' data-placement='top' :title='remainingTooltipPretty' :data-original-title='remainingTooltipPretty' :class='diffStatusClass' >
<div class='meter-container'>
<div :style='{ width: diffPercent }' class='meter-fill'></div>
</div>
<div class='compare-display-container'>
<div class='compare-display pull-left'>
<span class='compare-label'>Spent</span>
<span class='compare-value spent'>{{ spentPretty }}</span>
</div>
<div class='compare-display estimated pull-right'>
<span class='compare-label'>Est</span>
<span class='compare-value'>{{ estimatedPretty }}</span>
</div>
</div>
</div>
</div>
<div class='time-tracking-estimate-only' v-if='showEstimateOnly'>
<span class='bold'>Estimated:</span>
{{ estimatedPretty }}
</div>
<div class='time-tracking-spend-only' v-if='showSpentOnly'>
<span class='bold'>Spent:</span>
{{ spentPretty }}
</div>
<div class='time-tracking-no-tracking' v-if='showNoTimeTracking'>
<span class='no-value'>No estimate or time spent</span>
</div>
<div class='time-tracking-help-state' v-if='showHelp'>
<div class='close-help-button pull-right' @click='toggleHelpState(false)'>
<i class='fa fa-close'></i>
</div>
<div class='time-tracking-info'>
<h4>Track time with slash commands</h4>
<p>Slash commands can be used in the issues description and comment boxes.</p>
<p>
<code>/estimate</code>
will update the estimated time with the latest command.
</p>
<p>
<code>/spend</code>
will update the sum of the time spent.
</p>
<a class='btn btn-default learn-more-button' href='https://docs.gitlab.com/ee/workflow/time_tracking.html'>Learn more</a>
</div>
</div>
</div>
</div>
`,
});
})(window.gl || (window.gl = {}));
(() => {
/*
* TODO: Make these methods more configurable (e.g. parseSeconds timePeriodContstraints,
* stringifyTime condensed or non-condensed, abbreviateTimelengths)
* */
class PrettyTime {
/*
* Accepts seconds and returns a timeObject { weeks: #, days: #, hours: #, minutes: # }
* Seconds can be negative or positive, zero or non-zero.
*/
static parseSeconds(seconds) {
const DAYS_PER_WEEK = 5;
const HOURS_PER_DAY = 8;
const MINUTES_PER_HOUR = 60;
const MINUTES_PER_WEEK = DAYS_PER_WEEK * HOURS_PER_DAY * MINUTES_PER_HOUR;
const MINUTES_PER_DAY = HOURS_PER_DAY * MINUTES_PER_HOUR;
const timePeriodConstraints = {
weeks: MINUTES_PER_WEEK,
days: MINUTES_PER_DAY,
hours: MINUTES_PER_HOUR,
minutes: 1,
};
let unorderedMinutes = PrettyTime.secondsToMinutes(seconds);
return _.mapObject(timePeriodConstraints, (minutesPerPeriod) => {
const periodCount = Math.floor(unorderedMinutes / minutesPerPeriod);
unorderedMinutes -= (periodCount * minutesPerPeriod);
return periodCount;
});
}
/*
* Accepts a timeObject and returns a condensed string representation of it
* (e.g. '1w 2d 3h 1m' or '1h 30m'). Zero value units are not included.
*/
static stringifyTime(timeObject) {
const reducedTime = _.reduce(timeObject, (memo, unitValue, unitName) => {
const isNonZero = !!unitValue;
return isNonZero ? `${memo} ${unitValue}${unitName.charAt(0)}` : memo;
}, '').trim();
return reducedTime.length ? reducedTime : '0m';
}
/*
* Accepts a time string of any size (e.g. '1w 2d 3h 5m' or '1w 2d') and returns
* the first non-zero unit/value pair.
*/
static abbreviateTime(timeStr) {
return timeStr.split(' ')
.filter(unitStr => unitStr.charAt(0) !== '0')[0];
}
static secondsToMinutes(seconds) {
return Math.abs(seconds / 60);
}
}
gl.PrettyTime = PrettyTime;
})(window.gl || (window.gl = {}));
/*
* 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 }) {
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();
}
// 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() {
// 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() {
// 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.cancel : 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 = {}));
//= require vue
//= require vue-resource
(() => {
/*
* 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(payload) {
return this.resource(payload)
.then(data => this.publish(data));
}
post(payload) {
return this.resource(payload)
.then(data => this.publish(data));
}
put(payload) {
return this.resource(payload)
.then(data => this.publish(data));
}
delete(payload) {
return this.resource(payload)
.then(data => this.publish(data));
}
}
gl.SubbableResource = SubbableResource;
})(window.gl || (window.gl = {}));
//= require vue
//= require issuable_time_tracker
//= require smart_interval
//= require subbable_resource
(() => {
/* 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.
*/
class IssuableTimeTracking {
constructor(issuableJSON) {
const parsedIssuable = JSON.parse(issuableJSON);
return this.initComponent(parsedIssuable);
}
initComponent(parsedIssuable) {
this.parentInstance = new Vue({
el: '#issuable-time-tracker',
data: {
issuable: parsedIssuable,
},
methods: {
fetchIssuable() {
return gl.IssuableResource.get.call(gl.IssuableResource, {
type: 'GET',
url: gl.IssuableResource.endpoint,
});
},
initPolling() {
return new gl.SmartInterval({
callback: this.fetchIssuable,
startingInterval: 1000,
maxInterval: 10000,
incrementByFactorOf: 10,
lazyStart: false,
});
},
updateState(data) {
this.issuable = data;
},
subscribeToUpdates() {
gl.IssuableResource.subscribe(data => this.updateState(data));
},
listenForSlashCommands() {
$(document).on('ajax:success', '.gfm-form', (e, data) => {
const subscribedCommands = ['spend_time', 'time_estimate'];
const changedCommands = data.commands_changes;
if (changedCommands && _.intersection(subscribedCommands, changedCommands).length) {
this.fetchIssuable();
}
});
},
},
created() {
this.fetchIssuable();
},
mounted() {
this.initPolling();
this.subscribeToUpdates();
this.listenForSlashCommands();
},
});
}
}
gl.IssuableTimeTracking = IssuableTimeTracking;
})(window.gl || (window.gl = {}));
......@@ -12,6 +12,21 @@
$value = $block.find('.value');
abilityName = $dropdown.data('ability-name');
$loading = $block.find('.block-loading').fadeOut();
var renderMethod = function(data) {
if (data.weight != null) {
$value.html(data.weight);
} else {
$value.html('None');
}
return $sidebarCollapsedValue.html(data.weight);
};
if (gl.IssuableResource) {
gl.IssuableResource.subscribe(renderMethod);
}
updateWeight = function(selected) {
var data;
data = {};
......@@ -28,12 +43,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({
......
......@@ -5,7 +5,7 @@ $sidebar_collapsed_width: 62px;
$sidebar_width: 220px;
$gutter_collapsed_width: 62px;
$gutter_width: 290px;
$gutter_inner_width: 258px;
$gutter_inner_width: 250px;
$sidebar-transition-duration: .15s;
$sidebar-breakpoint: 1024px;
......@@ -92,6 +92,7 @@ $dark-background-color: #f5f5f5;
$table-text-gray: #8f8f8f;
$widget-expand-item: #e8f2f7;
$widget-inner-border: #eef0f2;
$sidebar-collapsed-icon-color: #999;
/*
* Text
......
......@@ -233,7 +233,7 @@
width: 100%;
text-align: center;
padding-bottom: 10px;
color: #999;
color: $sidebar-collapsed-icon-color;
span {
display: block;
......@@ -252,7 +252,7 @@
}
i {
color: #999;
color: $sidebar-collapsed-icon-color;
}
}
}
......@@ -407,3 +407,85 @@
}
}
}
.time_tracker {
padding-bottom: 0;
border-bottom: 0;
.sidebar-collapsed-icon {
> .stopwatch-svg {
display: inline-block;
}
svg {
width: 16px;
height: 16px;
fill: $sidebar-collapsed-icon-color;
}
}
.help-button,
.close-help-button {
cursor: pointer;
}
.compare-meter {
&.within_estimate {
.meter-fill {
background: $gl-primary;
}
}
&.over_estimate {
.meter-fill {
background: $red-light;
}
.time-remaining,
.compare-value.spent {
color: $red-light;
}
}
}
.meter-container {
background: $border-gray-light;
border-radius: 3px;
.meter-fill {
max-width: 100%;
height: 5px;
border-radius: 3px;
background: $gl-primary;
}
}
.compare-display-container {
display: flex;
justify-content: space-between;
margin-top: 5px;
.compare-display {
font-size: 13px;
color: $gl-gray-light;
.compare-value {
color: $gl-gray;
}
}
}
.time-tracking-help-state {
background: $white-light;
margin: 16px -20px 0;
padding: 16px 20px;
border-top: 1px solid $border-gray-light;
border-bottom: 1px solid $border-gray-light;
a:hover {
color: $btn-white-active;
}
}
}
......@@ -12,7 +12,7 @@ module IssuableActions
destroy_method = "destroy_#{issuable.class.name.underscore}".to_sym
TodoService.new.public_send(destroy_method, issuable, current_user)
name = issuable.class.name.titleize.downcase
name = issuable.human_class_name
flash[:notice] = "The #{name} was successfully deleted."
redirect_to polymorphic_path([@project.namespace.becomes(Namespace), @project, issuable.class])
end
......
......@@ -75,7 +75,7 @@ class Projects::IssuesController < Projects::ApplicationController
respond_to do |format|
format.html
format.json do
render json: @issue.to_json(include: [:milestone, :labels])
render json: IssueSerializer.new.represent(@issue)
end
end
end
......
......@@ -62,7 +62,7 @@ class Projects::MergeRequestsController < Projects::ApplicationController
format.html { define_discussion_vars }
format.json do
render json: @merge_request, methods: :rebase_in_progress?
render json: MergeRequestSerializer.new.represent(@merge_request, type: :full)
end
format.patch do
......
......@@ -146,24 +146,26 @@ class Projects::NotesController < Projects::ApplicationController
end
def note_json(note)
attrs = {
award: false,
id: note.id
}
if note.is_a?(AwardEmoji)
{
attrs.merge!(
valid: note.valid?,
award: true,
id: note.id,
name: note.name
}
)
elsif note.persisted?
Banzai::NoteRenderer.render([note], @project, current_user)
attrs = {
attrs.merge!(
valid: true,
id: note.id,
discussion_id: note.discussion_id,
html: note_html(note),
award: false,
note: note.note
}
)
if note.diff_note?
discussion = note.to_discussion
......@@ -188,15 +190,15 @@ class Projects::NotesController < Projects::ApplicationController
attrs[:original_discussion_id] = note.original_discussion_id
end
end
attrs
else
{
attrs.merge!(
valid: false,
award: false,
errors: note.errors
}
)
end
attrs[:commands_changes] = note.commands_changes unless attrs[:award]
attrs
end
def authorize_admin_note!
......
......@@ -30,6 +30,15 @@ module IssuablesHelper
end
end
def serialize_issuable(issuable)
case issuable
when Issue
IssueSerializer.new.represent(issuable).to_json
when MergeRequest
MergeRequestSerializer.new.represent(issuable).to_json
end
end
def template_dropdown_tag(issuable, &block)
title = selected_template(issuable) || "Choose a template"
options = {
......
......@@ -13,6 +13,7 @@ module Issuable
include StripAttribute
include Awardable
include Taskable
include TimeTrackable
included do
cache_markdown_field :title, pipeline: :single_line
......@@ -254,6 +255,17 @@ module Issuable
self.class.to_ability_name
end
# Convert this Issuable class name to a format usable by notifications.
#
# Examples:
#
# issuable.class # => MergeRequest
# issuable.human_class_name # => "merge request"
def human_class_name
@human_class_name ||= self.class.name.titleize.downcase
end
# Returns a Hash of attributes to be used for Twitter card metadata
def card_attributes
{
......
# == TimeTrackable concern
#
# Contains functionality related to objects that support time tracking.
#
# Used by Issue and MergeRequest.
#
module TimeTrackable
extend ActiveSupport::Concern
included do
attr_reader :time_spent
alias_method :time_spent?, :time_spent
default_value_for :time_estimate, value: 0, allows_nil: false
has_many :timelogs, as: :trackable, dependent: :destroy
end
def spend_time(seconds, user)
return if seconds == 0
@time_spent = seconds
@time_spent_user = user
if seconds == :reset
reset_spent_time
else
add_or_subtract_spent_time
end
end
def total_time_spent
timelogs.sum(:time_spent)
end
def human_total_time_spent
Gitlab::TimeTrackingFormatter.output(total_time_spent)
end
def human_time_estimate
Gitlab::TimeTrackingFormatter.output(time_estimate)
end
private
def reset_spent_time
timelogs.new(time_spent: total_time_spent * -1, user: @time_spent_user)
end
def add_or_subtract_spent_time
# Exit if time to subtract exceeds the total time spent.
return if time_spent < 0 && (time_spent.abs > total_time_spent)
timelogs.new(time_spent: time_spent, user: @time_spent_user)
end
end
......@@ -20,6 +20,9 @@ class Note < ActiveRecord::Base
# Banzai::ObjectRenderer
attr_accessor :user_visible_reference_count
# Attribute used to store the attributes that have ben changed by slash commands.
attr_accessor :commands_changes
default_value_for :system, false
attr_mentionable :note, pipeline: :note
......
class Timelog < ActiveRecord::Base
validates :time_spent, :user, presence: true
belongs_to :trackable, polymorphic: true
belongs_to :user
end
class IssuableEntity < Grape::Entity
expose :id
expose :iid
expose :assignee_id
expose :author_id
expose :description
expose :lock_version
expose :milestone_id
expose :position
expose :state
expose :title
expose :updated_by_id
expose :created_at
expose :updated_at
expose :deleted_at
expose :time_estimate
expose :total_time_spent
expose :human_time_estimate
expose :human_total_time_spent
end
class IssueEntity < IssuableEntity
expose :branch_name
expose :confidential
expose :due_date
expose :moved_to_id
expose :project_id
expose :weight
expose :milestone, using: API::Entities::Milestone
expose :labels, using: LabelEntity
end
class IssueSerializer < BaseSerializer
entity IssueEntity
end
class LabelEntity < Grape::Entity
expose :id
expose :title
expose :color
expose :description
expose :group_id
expose :project_id
expose :template
expose :created_at
expose :updated_at
end
class MergeRequestEntity < IssuableEntity
expose :approvals_before_merge
expose :in_progress_merge_commit_sha
expose :locked_at
expose :merge_commit_sha
expose :merge_error
expose :merge_params
expose :merge_status
expose :merge_user_id
expose :merge_when_build_succeeds
expose :rebase_commit_sha
expose :rebase_in_progress?, if: { type: :full }
expose :source_branch
expose :source_project_id
expose :target_branch
expose :target_project_id
end
class MergeRequestSerializer < BaseSerializer
entity MergeRequestEntity
end
......@@ -36,6 +36,14 @@ class IssuableBaseService < BaseService
end
end
def create_time_estimate_note(issuable)
SystemNoteService.change_time_estimate(issuable, issuable.project, current_user)
end
def create_time_spent_note(issuable)
SystemNoteService.change_time_spent(issuable, issuable.project, current_user)
end
def filter_params(issuable_ability_name = :issue)
filter_assignee
filter_milestone
......@@ -137,6 +145,7 @@ class IssuableBaseService < BaseService
def create(issuable)
merge_slash_commands_into_params!(issuable)
filter_params
change_time_spent(issuable)
params.delete(:state_event)
params[:author] ||= current_user
......@@ -177,6 +186,7 @@ class IssuableBaseService < BaseService
change_state(issuable)
change_subscription(issuable)
change_todo(issuable)
change_time_spent(issuable)
filter_params
old_labels = issuable.labels.to_a
old_mentioned_users = issuable.mentioned_users.to_a
......@@ -228,6 +238,12 @@ class IssuableBaseService < BaseService
end
end
def change_time_spent(issuable)
time_spent = params.delete(:spend_time)
issuable.spend_time(time_spent, current_user) if time_spent
end
def has_changes?(issuable, old_labels: [])
valid_attrs = [:title, :description, :assignee_id, :milestone_id, :target_branch]
......@@ -249,6 +265,14 @@ class IssuableBaseService < BaseService
create_task_status_note(issuable)
end
if issuable.previous_changes.include?('time_estimate')
create_time_estimate_note(issuable)
end
if issuable.time_spent?
create_time_spent_note(issuable)
end
create_labels_note(issuable, old_labels) if issuable.labels != old_labels
end
end
......@@ -35,7 +35,7 @@ module Notes
todo_service.new_note(note, current_user)
end
if command_params && command_params.any?
if command_params.present?
slash_commands_service.execute(command_params, note)
# We must add the error after we call #save because errors are reset
......@@ -43,6 +43,8 @@ module Notes
if only_commands
note.errors.add(:commands_only, 'Your commands have been executed!')
end
note.commands_changes = command_params.keys
end
note
......
......@@ -243,6 +243,53 @@ module SlashCommands
@updates[:wip_event] = issuable.work_in_progress? ? 'unwip' : 'wip'
end
desc 'Set time estimate'
params '<1w 3d 2h 14m>'
condition do
current_user.can?(:"admin_#{issuable.to_ability_name}", project)
end
command :estimate do |raw_duration|
time_estimate = Gitlab::TimeTrackingFormatter.parse(raw_duration)
if time_estimate
@updates[:time_estimate] = time_estimate
end
end
desc 'Add or substract spent time'
params '<1h 30m | -1h 30m>'
condition do
current_user.can?(:"admin_#{issuable.to_ability_name}", issuable)
end
command :spend do |raw_duration|
reduce_time = raw_duration.sub!(/\A-/, '')
time_spent = Gitlab::TimeTrackingFormatter.parse(raw_duration)
if time_spent
time_spent *= -1 if reduce_time
@updates[:spend_time] = time_spent
end
end
desc 'Remove time estimate'
condition do
issuable.persisted? &&
current_user.can?(:"admin_#{issuable.to_ability_name}", project)
end
command :remove_estimate do
@updates[:time_estimate] = 0
end
desc 'Remove spent time'
condition do
issuable.persisted? &&
current_user.can?(:"admin_#{issuable.to_ability_name}", project)
end
command :remove_time_spent do
@updates[:spend_time] = :reset
end
# This is a dummy command, so that it appears in the autocomplete commands
desc 'CC'
params '@user'
......
......@@ -111,6 +111,57 @@ module SystemNoteService
create_note(noteable: noteable, project: project, author: author, note: body)
end
# Called when the estimated time of a Noteable is changed
#
# noteable - Noteable object
# project - Project owning noteable
# author - User performing the change
# time_estimate - Estimated time
#
# Example Note text:
#
# "Changed estimate of this issue to 3d 5h"
#
# Returns the created Note object
def change_time_estimate(noteable, project, author)
parsed_time = Gitlab::TimeTrackingFormatter.output(noteable.time_estimate)
body = if noteable.time_estimate == 0
"Removed time estimate on this #{noteable.human_class_name}"
else
"Changed time estimate of this #{noteable.human_class_name} to #{parsed_time}"
end
create_note(noteable: noteable, project: project, author: author, note: body)
end
# Called when the spent time of a Noteable is changed
#
# noteable - Noteable object
# project - Project owning noteable
# author - User performing the change
# time_spent - Spent time
#
# Example Note text:
#
# "Added 2h 30m of time spent on this issue"
#
# Returns the created Note object
def change_time_spent(noteable, project, author)
time_spent = noteable.time_spent
if time_spent == :reset
body = "Removed time spent on this #{noteable.human_class_name}"
else
parsed_time = Gitlab::TimeTrackingFormatter.output(time_spent.abs)
action = time_spent > 0 ? 'Added' : 'Subtracted'
body = "#{action} #{parsed_time} of time spent on this #{noteable.human_class_name}"
end
create_note(noteable: noteable, project: project, author: author, note: body)
end
# Called when the status of a Noteable is changed
#
# noteable - Noteable object
......
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 12 14" enable-background="new 0 0 12 14"><path d="m11.5 2.4l-1.3-1.1-1 1.1 1.4 1.1.9-1.1"/><path d="m6.8 2v-.5h.5v-1.5h-2.6v1.5h.5v.5c-2.9.4-5.2 2.9-5.2 6 0 3.3 2.7 6 6 6s6-2.7 6-6c0-3-2.3-5.6-5.2-6m-.8 10.5c-2.5 0-4.5-2-4.5-4.5s2-4.5 4.5-4.5 4.5 2 4.5 4.5-2 4.5-4.5 4.5"/><path d="m6.2 8.9h-.5c-.1 0-.2-.1-.2-.2v-3.5c0-.1.1-.2.2-.2h.5c.1 0 .2.1.2.2v3.5c0 .1-.1.2-.2.2"/></svg>
\ No newline at end of file
......@@ -147,7 +147,7 @@
- else
.pull-right
- if can?(current_user, :"destroy_#{issuable.to_ability_name}", @project)
= link_to 'Delete', polymorphic_path([@project.namespace.becomes(Namespace), @project, issuable]), data: { confirm: "#{issuable.class.name.titleize} will be removed! Are you sure?" },
= link_to 'Delete', polymorphic_path([@project.namespace.becomes(Namespace), @project, issuable]), data: { confirm: "#{issuable.human_class_name} will be removed! Are you sure?" },
method: :delete, class: 'btn btn-danger btn-grouped'
= link_to 'Cancel', polymorphic_path([@project.namespace.becomes(Namespace), @project, issuable]), class: 'btn btn-grouped btn-cancel'
......
- 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,17 @@
.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 }})
- if issuable.has_attribute?(:time_estimate)
#issuable-time-tracker.block
%issuable-time-tracker{ ':time_estimate' => 'issuable.time_estimate', ':time_spent' => 'issuable.total_time_spent', ':human_time_estimate' => 'issuable.human_time_estimate', ':human_time_spent' => 'issuable.human_total_time_spent' }
// Fallback while content is loading
.title.hide-collapsed
Time tracking
= icon('spinner spin')
// TODO: Remove this when we have webpack/svg solution implemented
.stopwatch-svg{ slot:'stopwatch' }
%span.hide
= custom_icon('icon_stopwatch')
- if issuable.has_attribute?(:due_date)
.block.due_date
.sidebar-collapsed-icon
......@@ -195,6 +205,8 @@
= clipboard_button(clipboard_text: project_ref)
:javascript
gl.IssuableResource = new gl.SubbableResource('#{issuable_json_path(issuable)}');
new gl.IssuableTimeTracking("#{escape_javascript(serialize_issuable(issuable))}");
new MilestoneSelect('{"namespace":"#{@project.namespace.path}","path":"#{@project.path}"}');
new LabelsSelect();
new WeightSelect();
......
---
title: Add time tracking support for Issues and Merge Requests
merge_request: 870
author:
# See http://doc.gitlab.com/ce/development/migration_style_guide.html
# for more information on how to write migrations for GitLab.
class AddEstimateToIssuables < ActiveRecord::Migration
include Gitlab::Database::MigrationHelpers
# Set this constant to true if this migration requires downtime.
DOWNTIME = false
# When a migration requires downtime you **must** uncomment the following
# constant and define a short and easy to understand explanation as to why the
# migration requires downtime.
# DOWNTIME_REASON = ''
# When using the methods "add_concurrent_index" or "add_column_with_default"
# you must disable the use of transactions as these methods can not run in an
# existing transaction. When using "add_concurrent_index" make sure that this
# method is the _only_ method called in the migration, any other changes
# should go in a separate migration. This ensures that upon failure _only_ the
# index creation fails and can be retried or reverted easily.
#
# To disable transactions uncomment the following line and remove these
# comments:
# disable_ddl_transaction!
def up
add_column :issues, :time_estimate, :integer
add_column :merge_requests, :time_estimate, :integer
end
def down
remove_column :issues, :time_estimate
remove_column :merge_requests, :time_estimate
end
end
class CreateTimelogs < ActiveRecord::Migration
include Gitlab::Database::MigrationHelpers
DOWNTIME = false
def change
create_table :timelogs do |t|
t.integer :time_spent, null: false
t.references :trackable, polymorphic: true
t.references :user
t.timestamps null: false
end
add_index :timelogs, [:trackable_type, :trackable_id]
add_index :timelogs, :user_id
end
end
......@@ -563,6 +563,7 @@ ActiveRecord::Schema.define(version: 20161117114805) do
t.integer "lock_version"
t.text "title_html"
t.text "description_html"
t.integer "time_estimate"
end
add_index "issues", ["assignee_id"], name: "index_issues_on_assignee_id", using: :btree
......@@ -759,6 +760,7 @@ ActiveRecord::Schema.define(version: 20161117114805) do
t.integer "lock_version"
t.text "title_html"
t.text "description_html"
t.integer "time_estimate"
end
add_index "merge_requests", ["assignee_id"], name: "index_merge_requests_on_assignee_id", using: :btree
......@@ -1254,6 +1256,18 @@ ActiveRecord::Schema.define(version: 20161117114805) do
add_index "tags", ["name"], name: "index_tags_on_name", unique: true, using: :btree
create_table "timelogs", force: :cascade do |t|
t.integer "time_spent", null: false
t.integer "trackable_id"
t.string "trackable_type"
t.integer "user_id"
t.datetime "created_at", null: false
t.datetime "updated_at", null: false
end
add_index "timelogs", ["trackable_type", "trackable_id"], name: "index_timelogs_on_trackable_type_and_trackable_id", using: :btree
add_index "timelogs", ["user_id"], name: "index_timelogs_on_user_id", using: :btree
create_table "todos", force: :cascade do |t|
t.integer "user_id", null: false
t.integer "project_id", null: false
......
......@@ -29,3 +29,7 @@ do.
| <code>/due &lt;in 2 days &#124; this Friday &#124; December 31st&gt;</code> | Set due date |
| `/remove_due_date` | Remove due date |
| `/wip` | Toggle the Work In Progress status |
| <code>/estimate &lt;1w 3d 2h 14m&gt;</code> | Set time estimate |
| `/remove_estimate` | Remove estimated time |
| <code>/spend &lt;1h 30m &#124; -1h 5m&gt;</code> | Add or substract spent time |
| `/remove_time_spent` | Remove time spent |
......@@ -23,6 +23,7 @@
- [Slash commands](../user/project/slash_commands.md)
- [Sharing a project with a group](share_with_group.md)
- [Share projects with other groups](share_projects_with_other_groups.md)
- [Time tracking](time_tracking.md)
- [Two-factor Authentication (2FA)](two_factor_authentication.md)
- [Web Editor](../user/project/repository/web_editor.md)
- [Releases](releases.md)
......
# Time Tracking
> Introduced in GitLab 8.14.
Time Tracking lets teams stack their project estimates against their time spent.
Other interesting links:
- [Time Tracking landing page on about.gitlab.com][landing]
## Overview
Time Tracking lets you:
* record the time spent working on an issue or a merge request,
* add an estimate of the amount of time needed to complete an issue or a merge
request.
You don't have to indicate an estimate to enter the time spent, and vice versa.
Data about time tracking is shown on the issue/merge request sidebar, as shown
below.
![Time tracking in the sidebar](time-tracking/time-tracking-sidebar.png)
## How to enter data
Time Tracking uses two [slash commands] that GitLab introduced with this new
feature: `/spend` and `/estimate`.
Slash commands can be used in the body of an issue or a merge request, but also
in a comment in both an issue or a merge request.
Below is an example of how you can use those new slash commands inside a comment.
![Time tracking example in a comment](time-tracking/time-tracking-example.png)
Adding time entries (time spent or estimates) is limited to project members.
### Estimates
To enter an estimate, write `/estimate`, followed by the time. For example, if
you need to enter an estimate of 3 days, 5 hours and 10 minutes, you would write
`/estimate 3d 5h 10m`.
Every time you enter a new time estimate, any previous time estimates will be
overridden by this new value. There should only be one valid estimate in an
issue or a merge request.
To remove an estimation entirely, use `/remove_estimation`.
### Time spent
To enter a time spent, use `/spend 3d 5h 10m`.
Every new time spent entry will be added to the current total time spent for the
issue or the merge request.
You can remove time by entering a negative amount: `/spend -3d` will remove 3
days from the total time spent. You can't go below 0 minutes of time spent,
so GitLab will automatically reset the time spent if you remove a larger amount
of time compared to the time that was entered already.
To remove all the time spent at once, use `/remove_time_spent`.
## Configuration
The following time units are available:
* weeks (w)
* days (d)
* hours (h)
* minutes (m)
Default conversion rates are 1w = 5d and 1d = 8h.
[landing]: https://about.gitlab.com/features/time-tracking
[slash-commands]: ../user/project/slash_commands.md
......@@ -6,6 +6,7 @@ project_tree:
- :events
- issues:
- :events
- :timelogs
- notes:
- :author
- :events
......@@ -27,6 +28,7 @@ project_tree:
- :events
- :merge_request_diff
- :events
- :timelogs
- label_links:
- label:
:priorities
......
module Gitlab
module TimeTrackingFormatter
extend self
def parse(string)
with_custom_config do
ChronicDuration.parse(string, default_unit: 'hours') rescue nil
end
end
def output(seconds)
with_custom_config do
ChronicDuration.output(seconds, format: :short, limit_to_hours: false, weeks: true) rescue nil
end
end
def with_custom_config
# We may want to configure it through project settings in a future version.
ChronicDuration.hours_per_day = 8
ChronicDuration.days_per_week = 5
result = yield
ChronicDuration.hours_per_day = 24
ChronicDuration.days_per_week = 7
result
end
end
end
......@@ -272,6 +272,20 @@ describe Projects::IssuesController do
end
describe 'POST #create' do
def post_new_issue(attrs = {})
sign_in(user)
project = create(:empty_project, :public)
project.team << [user, :developer]
post :create, {
namespace_id: project.namespace.to_param,
project_id: project.to_param,
issue: { title: 'Title', description: 'Description' }.merge(attrs)
}
project.issues.first
end
context 'Akismet is enabled' do
before do
allow_any_instance_of(SpamService).to receive(:check_for_spam?).and_return(true)
......@@ -279,13 +293,7 @@ describe Projects::IssuesController do
end
def post_spam_issue
sign_in(user)
spam_project = create(:empty_project, :public)
post :create, {
namespace_id: spam_project.namespace.to_param,
project_id: spam_project.to_param,
issue: { title: 'Spam Title', description: 'Spam lives here' }
}
post_new_issue(title: 'Spam Title', description: 'Spam lives here')
end
it 'rejects an issue recognized as spam' do
......@@ -306,18 +314,26 @@ describe Projects::IssuesController do
request.env['action_dispatch.remote_ip'] = '127.0.0.1'
end
def post_new_issue
it 'creates a user agent detail' do
expect{ post_new_issue }.to change(UserAgentDetail, :count).by(1)
end
end
context 'when description has slash commands' do
before do
sign_in(user)
project = create(:empty_project, :public)
post :create, {
namespace_id: project.namespace.to_param,
project_id: project.to_param,
issue: { title: 'Title', description: 'Description' }
}
end
it 'creates a user agent detail' do
expect{ post_new_issue }.to change(UserAgentDetail, :count).by(1)
it 'can add spent time' do
issue = post_new_issue(description: '/spend 1h')
expect(issue.total_time_spent).to eq(3600)
end
it 'can set the time estimate' do
issue = post_new_issue(description: '/estimate 2h')
expect(issue.time_estimate).to eq(7200)
end
end
end
......
# Read about factories at https://github.com/thoughtbot/factory_girl
FactoryGirl.define do
factory :timelog do
time_spent 3600
user
association :trackable, factory: :issue
end
end
......@@ -100,6 +100,32 @@ feature 'Issues > User uses slash commands', feature: true, js: true do
end
end
describe 'Issuable time tracking' do
let(:issue) { create(:issue, project: project) }
before do
project.team << [user, :developer]
end
context 'Issue' do
before do
visit namespace_project_issue_path(project.namespace, project, issue)
end
it_behaves_like 'issuable time tracker'
end
context 'Merge Request' do
let(:merge_request) { create(:merge_request, source_project: project) }
before do
visit namespace_project_merge_request_path(project.namespace, project, merge_request)
end
it_behaves_like 'issuable time tracker'
end
end
describe 'toggling the WIP prefix from the title from note' do
let(:issue) { create(:issue, project: project) }
......
/* eslint-disable */
//= require jquery
//= require vue
//= require issuable_time_tracker
function initComponent(time_estimate = 100000, time_spent = 5000 ) {
fixture.set(`
<div>
<div id="mock-container"></div>
</div>
`);
this.initialData = {
time_estimate,
time_spent
};
this.timeTracker = new gl.IssuableTimeTracker({
el: '#mock-container',
propsData: this.initialData
});
}
((gl) => {
describe('Issuable Time Tracker', function() {
describe('Initialization', function() {
beforeEach(function() {
initComponent.apply(this);
});
it('should return something defined', function() {
expect(this.timeTracker).toBeDefined();
});
it ('should correctly set time_estimate', function() {
expect(this.timeTracker.time_estimate).toBe(this.initialData.time_estimate);
});
it ('should correctly set time_spent', function() {
expect(this.timeTracker.time_spent).toBe(this.initialData.time_spent);
});
});
describe('Content Display', function() {
describe('Panes', function() {
describe('Comparison pane', function() {
beforeEach(function() {
initComponent.apply(this);
});
it('should show the "Comparison" pane when time_estimate and time_spent are truthy', function() {
const $comparisonPane = this.timeTracker.$el.querySelector('.time-tracking-pane-compare');
expect(this.timeTracker.showComparison).toBe(true);
expect($comparisonPane).toBeVisible();
});
it('should display the human readable version of time estimated', function() {
const estimateText = this.timeTracker.$el.querySelector('.time-tracking-pane-compare .estimated .compare-value').innerText;
const correctText = '3d 3h 46m';
expect(estimateText).toBe(correctText);
});
it('should display the human readable version of time spent', function() {
const spentText = this.timeTracker.$el.querySelector('.time-tracking-pane-compare .compare-value.spent').innerText;
const correctText = '1h 23m';
expect(spentText).toBe(correctText);
});
describe('Remaining meter', function() {
it('should display the remaining meter with the correct width', function() {
const meterWidth = this.timeTracker.$el.querySelector('.time-tracking-pane-compare .meter-fill').style.width;
const correctWidth = '5%';
expect(meterWidth).toBe(correctWidth);
});
it('should display the remaining meter with the correct background color when within estimate', function() {
const styledMeter = $(this.timeTracker.$el).find('.time-tracking-pane-compare .within_estimate .meter-fill');
expect(styledMeter.length).toBe(1);
});
it('should display the remaining meter with the correct background color when over estimate', function() {
this.timeTracker.time_estimate = 1;
this.timeTracker.time_spent = 2;
Vue.nextTick(() => {
const styledMeter = $(this.timeTracker.$el).find('.time-tracking-pane-compare .over_estimate .meter-fill');
expect(styledMeter.length).toBe(1);
});
});
});
});
describe("Estimate only pane", function() {
beforeEach(function() {
initComponent.apply(this, [10000, 0]);
});
it('should only show the "Estimate only" pane when time_estimate is truthy and time_spent is falsey', function() {
const $estimateOnlyPane = this.timeTracker.$el.querySelector('.time-tracking-estimate-only');
expect(this.timeTracker.showEstimateOnly).toBe(true);
expect($estimateOnlyPane).toBeVisible();
});
it('should display the human readable version of time estimated', function() {
const estimateText = this.timeTracker.$el.querySelector('.time-tracking-estimate-only').innerText;
const correctText = 'Estimated: 2h 46m';
expect(estimateText).toBe(correctText);
});
});
describe('Spent only pane', function() {
beforeEach(function() {
initComponent.apply(this, [0, 5000]);
});
// Look for the value
it('should only show the "Spent only" pane when time_estimate is falsey and time_spent is truthy', function() {
const $spentOnlyPane = this.timeTracker.$el.querySelector('.time-tracking-spend-only');
expect(this.timeTracker.showSpentOnly).toBe(true);
expect($spentOnlyPane).toBeVisible();
});
it('should display the human readable version of time spent', function() {
const spentText = this.timeTracker.$el.querySelector('.time-tracking-spend-only').innerText;
const correctText = 'Spent: 1h 23m';
expect(spentText).toBe(correctText);
});
});
describe('No time tracking pane', function() {
beforeEach(function() {
initComponent.apply(this, [0, 0]);
});
it('should only show the "No time tracking" pane when both time_estimate and time_spent are falsey', function() {
const $noTrackingPane = this.timeTracker.$el.querySelector('.time-tracking-no-tracking');
expect(this.timeTracker.showNoTimeTracking).toBe(true);
expect($noTrackingPane).toBeVisible();
});
it('should display the status text', function() {
const noTrackingText = this.timeTracker.$el.querySelector('.time-tracking-no-tracking .no-value').innerText;
const correctText = 'No estimate or time spent';
expect(noTrackingText).toBe(correctText);
});
});
describe("Help pane", function() {
beforeEach(function() {
initComponent.apply(this, [0, 0]);
});
it('should not show the "Help" pane by default', function() {
const $helpPane = this.timeTracker.$el.querySelector('.time-tracking-help-state');
expect(this.timeTracker.showHelp).toBe(false);
expect($helpPane).toBeNull();
});
it('should link to the correct documentation', function(done) {
const correctUrl = 'https://docs.gitlab.com/ee/workflow/time_tracking.html';
$(this.timeTracker.$el).find('.help-button').click();
Vue.nextTick(() => {
const currentHref = $(this.timeTracker.$el).find('.learn-more-button').attr('href');
expect(currentHref).toBe(correctUrl);
done();
});
});
it('should show the "Help" pane when help button is clicked', function(done) {
$(this.timeTracker.$el).find('.help-button').click();
Vue.nextTick(() => {
const $helpPane = this.timeTracker.$el.querySelector('.time-tracking-help-state');
expect(this.timeTracker.showHelp).toBe(true);
expect($helpPane).toBeVisible();
done();
});
});
it('should not show the "Help" pane when help button is clicked and then closed', function(done) {
$(this.timeTracker.$el).find('.help-button').click();
Vue.nextTick(() => {
$(this.timeTracker.$el).find('.close-help-button').click();
Vue.nextTick(() => {
const $helpPane = this.timeTracker.$el.querySelector('.time-tracking-help-state');
expect(this.timeTracker.showHelp).toBe(false);
expect($helpPane).toBeNull();
done();
});
});
});
});
});
});
});
})(window.gl || (window.gl = {}));
//= require lib/utils/pretty_time
(() => {
const PrettyTime = gl.PrettyTime;
describe('PrettyTime methods', function () {
describe('parseSeconds', function () {
it('should correctly parse a negative value', function () {
const parser = PrettyTime.parseSeconds;
const zeroSeconds = parser(-1000);
expect(zeroSeconds.minutes).toBe(16);
expect(zeroSeconds.hours).toBe(0);
expect(zeroSeconds.days).toBe(0);
expect(zeroSeconds.weeks).toBe(0);
});
it('should correctly parse a zero value', function () {
const parser = PrettyTime.parseSeconds;
const zeroSeconds = parser(0);
expect(zeroSeconds.minutes).toBe(0);
expect(zeroSeconds.hours).toBe(0);
expect(zeroSeconds.days).toBe(0);
expect(zeroSeconds.weeks).toBe(0);
});
it('should correctly parse a small non-zero second values', function () {
const parser = PrettyTime.parseSeconds;
const subOneMinute = parser(10);
expect(subOneMinute.minutes).toBe(0);
expect(subOneMinute.hours).toBe(0);
expect(subOneMinute.days).toBe(0);
expect(subOneMinute.weeks).toBe(0);
const aboveOneMinute = parser(100);
expect(aboveOneMinute.minutes).toBe(1);
expect(aboveOneMinute.hours).toBe(0);
expect(aboveOneMinute.days).toBe(0);
expect(aboveOneMinute.weeks).toBe(0);
const manyMinutes = parser(1000);
expect(manyMinutes.minutes).toBe(16);
expect(manyMinutes.hours).toBe(0);
expect(manyMinutes.days).toBe(0);
expect(manyMinutes.weeks).toBe(0);
});
it('should correctly parse large second values', function () {
const parser = PrettyTime.parseSeconds;
const aboveOneHour = parser(4800);
expect(aboveOneHour.minutes).toBe(20);
expect(aboveOneHour.hours).toBe(1);
expect(aboveOneHour.days).toBe(0);
expect(aboveOneHour.weeks).toBe(0);
const aboveOneDay = parser(110000);
expect(aboveOneDay.minutes).toBe(33);
expect(aboveOneDay.hours).toBe(6);
expect(aboveOneDay.days).toBe(3);
expect(aboveOneDay.weeks).toBe(0);
const aboveOneWeek = parser(25000000);
expect(aboveOneWeek.minutes).toBe(26);
expect(aboveOneWeek.hours).toBe(0);
expect(aboveOneWeek.days).toBe(3);
expect(aboveOneWeek.weeks).toBe(173);
});
});
describe('stringifyTime', function () {
it('should stringify values with all non-zero units', function () {
const timeObject = {
weeks: 1,
days: 4,
hours: 7,
minutes: 20,
};
const timeString = PrettyTime.stringifyTime(timeObject);
expect(timeString).toBe('1w 4d 7h 20m');
});
it('should stringify values with some non-zero units', function () {
const timeObject = {
weeks: 0,
days: 4,
hours: 0,
minutes: 20,
};
const timeString = PrettyTime.stringifyTime(timeObject);
expect(timeString).toBe('4d 20m');
});
it('should stringify values with no non-zero units', function () {
const timeObject = {
weeks: 0,
days: 0,
hours: 0,
minutes: 0,
};
const timeString = PrettyTime.stringifyTime(timeObject);
expect(timeString).toBe('0m');
});
});
describe('abbreviateTime', function () {
it('should abbreviate stringified times for weeks', function () {
const fullTimeString = '1w 3d 4h 5m';
expect(PrettyTime.abbreviateTime(fullTimeString)).toBe('1w');
});
it('should abbreviate stringified times for non-weeks', function () {
const fullTimeString = '0w 3d 4h 5m';
expect(PrettyTime.abbreviateTime(fullTimeString)).toBe('3d');
});
});
});
})(window.gl || (window.gl = {}));
//= require jquery
//= require smart_interval
(() => {
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;
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);
}
describe('SmartInterval', function () {
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 resume an interval', function (done) {
const interval = this.smartInterval;
setTimeout(() => {
interval.cancel();
interval.resume();
const intervalId = interval.state.intervalId;
expect(intervalId).toBeTruthy();
done();
}, DEFAULT_SHORT_TIMEOUT);
});
});
describe('DOM Events', function () {
beforeEach(function () {
// This ensures DOM and DOM events are initialized for these specs.
fixture.set('<div></div>');
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();
expect(interval.state.intervalId).toBeUndefined();
// simulates triggering of visibilitychange event
interval.state.pageVisibility = 'visible';
interval.handleVisibilityChange();
expect(interval.state.intervalId).toBeTruthy();
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);
});
});
});
})(window.gl || (window.gl = {}));
/* 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 = {}));
......@@ -15,6 +15,7 @@ issues:
- events
- merge_requests_closing_issues
- metrics
- timelogs
events:
- author
- project
......@@ -80,6 +81,7 @@ merge_requests:
- approvals
- approvers
- approver_groups
- timelogs
merge_request_diff:
- merge_request
pipelines:
......@@ -207,3 +209,6 @@ award_emoji:
- user
priorities:
- label
timelogs:
- trackable
- user
......@@ -20,6 +20,7 @@ Issue:
- lock_version
- milestone_id
- weight
- time_estimate
Event:
- id
- target_type
......@@ -151,6 +152,7 @@ MergeRequest:
- milestone_id
- approvals_before_merge
- rebase_commit_sha
- time_estimate
MergeRequestDiff:
- id
- state
......@@ -345,4 +347,12 @@ LabelPriority:
- label_id
- priority
- created_at
- updated_at
\ No newline at end of file
- updated_at
Timelog:
- id
- time_spent
- trackable_id
- trackable_type
- user_id
- created_at
- updated_at
......@@ -384,4 +384,42 @@ describe Issue, "Issuable" do
expect(issue.assignee_or_author?(user)).to eq(false)
end
end
describe '#spend_time' do
let(:user) { create(:user) }
let(:issue) { create(:issue) }
def spend_time(seconds)
issue.spend_time(seconds, user)
issue.save!
end
context 'adding time' do
it 'should update the total time spent' do
spend_time(1800)
expect(issue.total_time_spent).to eq(1800)
end
end
context 'substracting time' do
before do
spend_time(1800)
end
it 'should update the total time spent' do
spend_time(-900)
expect(issue.total_time_spent).to eq(900)
end
context 'when time to substract exceeds the total time spent' do
it 'should not alter the total time spent' do
spend_time(-3600)
expect(issue.total_time_spent).to eq(1800)
end
end
end
end
end
require 'rails_helper'
RSpec.describe Timelog, type: :model do
subject { build(:timelog) }
it { is_expected.to be_valid }
it { is_expected.to validate_presence_of(:time_spent) }
it { is_expected.to validate_presence_of(:user) }
end
......@@ -84,6 +84,18 @@ describe Notes::SlashCommandsService, services: true do
expect(note.noteable).to be_open
end
end
describe '/spend' do
let(:note_text) { '/spend 1h' }
it 'updates the spent time on the noteable' do
content, command_params = service.extract_commands(note)
service.execute(command_params, note)
expect(content).to eq ''
expect(note.noteable.time_spent).to eq(3600)
end
end
end
describe 'note with command & text' do
......
......@@ -210,6 +210,46 @@ describe SlashCommands::InterpretService, services: true do
end
end
shared_examples 'estimate command' do
it 'populates time_estimate: 3600 if content contains /estimate 1h' do
_, updates = service.execute(content, issuable)
expect(updates).to eq(time_estimate: 3600)
end
end
shared_examples 'spend command' do
it 'populates spend_time: 3600 if content contains /spend 1h' do
_, updates = service.execute(content, issuable)
expect(updates).to eq(spend_time: 3600)
end
end
shared_examples 'spend command with negative time' do
it 'populates spend_time: -1800 if content contains /spend -30m' do
_, updates = service.execute(content, issuable)
expect(updates).to eq(spend_time: -1800)
end
end
shared_examples 'remove_estimate command' do
it 'populates time_estimate: 0 if content contains /remove_estimate' do
_, updates = service.execute(content, issuable)
expect(updates).to eq(time_estimate: 0)
end
end
shared_examples 'remove_time_spent command' do
it 'populates spend_time: :reset if content contains /remove_time_spent' do
_, updates = service.execute(content, issuable)
expect(updates).to eq(spend_time: :reset)
end
end
shared_examples 'empty command' do
it 'populates {} if content contains an unsupported command' do
_, updates = service.execute(content, issuable)
......@@ -321,7 +361,7 @@ describe SlashCommands::InterpretService, services: true do
it_behaves_like 'multiple label with same argument' do
let(:content) { %(/label ~"#{inprogress.title}" \n/label ~#{inprogress.title}) }
let(:issuable) { issue }
end
end
it_behaves_like 'unlabel command' do
let(:content) { %(/unlabel ~"#{inprogress.title}") }
......@@ -451,6 +491,51 @@ describe SlashCommands::InterpretService, services: true do
let(:issuable) { merge_request }
end
it_behaves_like 'estimate command' do
let(:content) { '/estimate 1h' }
let(:issuable) { issue }
end
it_behaves_like 'empty command' do
let(:content) { '/estimate' }
let(:issuable) { issue }
end
it_behaves_like 'empty command' do
let(:content) { '/estimate abc' }
let(:issuable) { issue }
end
it_behaves_like 'spend command' do
let(:content) { '/spend 1h' }
let(:issuable) { issue }
end
it_behaves_like 'spend command with negative time' do
let(:content) { '/spend -30m' }
let(:issuable) { issue }
end
it_behaves_like 'empty command' do
let(:content) { '/spend' }
let(:issuable) { issue }
end
it_behaves_like 'empty command' do
let(:content) { '/spend abc' }
let(:issuable) { issue }
end
it_behaves_like 'remove_estimate command' do
let(:content) { '/remove_estimate' }
let(:issuable) { issue }
end
it_behaves_like 'remove_time_spent command' do
let(:content) { '/remove_time_spent' }
let(:issuable) { issue }
end
context 'when current_user cannot :admin_issue' do
let(:visitor) { create(:user) }
let(:issue) { create(:issue, project: project, author: visitor) }
......
......@@ -594,4 +594,69 @@ describe SystemNoteService, services: true do
end
end
end
describe '.change_time_estimate' do
subject { described_class.change_time_estimate(noteable, project, author) }
it_behaves_like 'a system note'
context 'with a time estimate' do
it 'sets the note text' do
noteable.update_attribute(:time_estimate, 277200)
expect(subject.note).to eq "Changed time estimate of this issue to 1w 4d 5h"
end
end
context 'without a time estimate' do
it 'sets the note text' do
expect(subject.note).to eq "Removed time estimate on this issue"
end
end
end
describe '.change_time_spent' do
# We need a custom noteable in order to the shared examples to be green.
let(:noteable) do
mr = create(:merge_request, source_project: project)
mr.spend_time(1, author)
mr.save!
mr
end
subject do
described_class.change_time_spent(noteable, project, author)
end
it_behaves_like 'a system note'
context 'when time was added' do
it 'sets the note text' do
spend_time!(277200)
expect(subject.note).to eq "Added 1w 4d 5h of time spent on this merge request"
end
end
context 'when time was subtracted' do
it 'sets the note text' do
spend_time!(-277200)
expect(subject.note).to eq "Subtracted 1w 4d 5h of time spent on this merge request"
end
end
context 'when time was removed' do
it 'sets the note text' do
spend_time!(:reset)
expect(subject.note).to eq "Removed time spent on this merge request"
end
end
def spend_time!(seconds)
noteable.spend_time(seconds, author)
noteable.save!
end
end
end
shared_examples 'issuable time tracker' do
it 'renders the sidebar component empty state' do
page.within '.issuable-sidebar' do
expect(page).to have_content 'No estimate or time spent'
end
end
it 'updates the sidebar component when estimate is added' do
submit_time('/estimate 3w 1d 1h')
page.within '.time-tracking-estimate-only' do
expect(page).to have_content '3w 1d 1h'
end
end
it 'updates the sidebar component when spent is added' do
submit_time('/spend 3w 1d 1h')
page.within '.time-tracking-spend-only' do
expect(page).to have_content '3w 1d 1h'
end
end
it 'shows the comparison when estimate and spent are added' do
submit_time('/estimate 3w 1d 1h')
submit_time('/spend 3w 1d 1h')
page.within '.time-tracking-pane-compare' do
expect(page).to have_content '3w 1d 1h'
end
end
it 'updates the sidebar component when estimate is removed' do
submit_time('/estimate 3w 1d 1h')
submit_time('/remove_estimate')
page.within '#issuable-time-tracker' do
expect(page).to have_content 'No estimate or time spent'
end
end
it 'updates the sidebar component when spent is removed' do
submit_time('/spend 3w 1d 1h')
submit_time('/remove_time_spent')
page.within '#issuable-time-tracker' do
expect(page).to have_content 'No estimate or time spent'
end
end
it 'shows the help state when icon is clicked' do
page.within '#issuable-time-tracker' do
find('.help-button').click
expect(page).to have_content 'Track time with slash commands'
expect(page).to have_content 'Learn more'
end
end
it 'hides the help state when close icon is clicked' do
page.within '#issuable-time-tracker' do
find('.help-button').click
find('.close-help-button').click
expect(page).not_to have_content 'Track time with slash commands'
expect(page).not_to have_content 'Learn more'
end
end
end
def submit_time(slash_command)
fill_in 'note[note]', with: slash_command
click_button 'Comment'
wait_for_ajax
end
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