Merge branch 'backport-time-tracking-ce' into 'master'
Backport timetracking to CE See merge request !8195
This commit is contained in:
commit
f208897ccb
|
@ -2,8 +2,6 @@
|
|||
/* global Vue */
|
||||
/* global ResolveCount */
|
||||
|
||||
//= require vue
|
||||
//= require vue-resource
|
||||
//= require_directory ./models
|
||||
//= require_directory ./stores
|
||||
//= require_directory ./services
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
//= require ./time_tracking/time_tracking_bundle
|
|
@ -0,0 +1,42 @@
|
|||
/* global Vue */
|
||||
//= require lib/utils/pretty_time
|
||||
|
||||
(() => {
|
||||
Vue.component('time-tracking-collapsed-state', {
|
||||
name: 'time-tracking-collapsed-state',
|
||||
props: [
|
||||
'showComparisonState',
|
||||
'showSpentOnlyState',
|
||||
'showEstimateOnlyState',
|
||||
'showNoTimeTrackingState',
|
||||
'timeSpentHumanReadable',
|
||||
'timeEstimateHumanReadable',
|
||||
'stopwatchSvg',
|
||||
],
|
||||
methods: {
|
||||
abbreviateTime(timeStr) {
|
||||
return gl.utils.prettyTime.abbreviateTime(timeStr);
|
||||
},
|
||||
},
|
||||
template: `
|
||||
<div class='sidebar-collapsed-icon'>
|
||||
<div v-html='stopwatchSvg'></div>
|
||||
<div class='time-tracking-collapsed-summary'>
|
||||
<div class='compare' v-if='showComparisonState'>
|
||||
<span>{{ abbreviateTime(timeSpentHumanReadable) }} / {{ abbreviateTime(timeEstimateHumanReadable) }}</span>
|
||||
</div>
|
||||
<div class='estimate-only' v-if='showEstimateOnlyState'>
|
||||
<span class='bold'>-- / {{ abbreviateTime(timeEstimateHumanReadable) }}</span>
|
||||
</div>
|
||||
<div class='spend-only' v-if='showSpentOnlyState'>
|
||||
<span class='bold'>{{ abbreviateTime(timeSpentHumanReadable) }} / --</span>
|
||||
</div>
|
||||
<div class='no-tracking' v-if='showNoTimeTrackingState'>
|
||||
<span class='no-value'>None</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
});
|
||||
})();
|
||||
|
|
@ -0,0 +1,69 @@
|
|||
/* global Vue */
|
||||
//= require lib/utils/pretty_time
|
||||
|
||||
(() => {
|
||||
const prettyTime = gl.utils.prettyTime;
|
||||
|
||||
Vue.component('time-tracking-comparison-pane', {
|
||||
name: 'time-tracking-comparison-pane',
|
||||
props: [
|
||||
'timeSpent',
|
||||
'timeEstimate',
|
||||
'timeSpentHumanReadable',
|
||||
'timeEstimateHumanReadable',
|
||||
],
|
||||
computed: {
|
||||
parsedRemaining() {
|
||||
const diffSeconds = this.timeEstimate - this.timeSpent;
|
||||
return prettyTime.parseSeconds(diffSeconds);
|
||||
},
|
||||
timeRemainingHumanReadable() {
|
||||
return prettyTime.stringifyTime(this.parsedRemaining);
|
||||
},
|
||||
timeRemainingTooltip() {
|
||||
const prefix = this.timeRemainingMinutes < 0 ? 'Over by' : 'Time remaining:';
|
||||
return `${prefix} ${this.timeRemainingHumanReadable}`;
|
||||
},
|
||||
/* Diff values for comparison meter */
|
||||
timeRemainingMinutes() {
|
||||
return this.timeEstimate - this.timeSpent;
|
||||
},
|
||||
timeRemainingPercent() {
|
||||
return `${Math.floor((this.timeSpent / this.timeEstimate) * 100)}%`;
|
||||
},
|
||||
timeRemainingStatusClass() {
|
||||
return this.timeEstimate >= this.timeSpent ? 'within_estimate' : 'over_estimate';
|
||||
},
|
||||
/* Parsed time values */
|
||||
parsedEstimate() {
|
||||
return prettyTime.parseSeconds(this.timeEstimate);
|
||||
},
|
||||
parsedSpent() {
|
||||
return prettyTime.parseSeconds(this.timeSpent);
|
||||
},
|
||||
},
|
||||
template: `
|
||||
<div class='time-tracking-comparison-pane'>
|
||||
<div class='compare-meter' data-toggle='tooltip' data-placement='top' role='timeRemainingDisplay'
|
||||
:aria-valuenow='timeRemainingTooltip'
|
||||
:title='timeRemainingTooltip'
|
||||
:data-original-title='timeRemainingTooltip'
|
||||
:class='timeRemainingStatusClass'>
|
||||
<div class='meter-container' role='timeSpentPercent' :aria-valuenow='timeRemainingPercent'>
|
||||
<div :style='{ width: timeRemainingPercent }' 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'>{{ timeSpentHumanReadable }}</span>
|
||||
</div>
|
||||
<div class='compare-display estimated pull-right'>
|
||||
<span class='compare-label'>Est</span>
|
||||
<span class='compare-value'>{{ timeEstimateHumanReadable }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
});
|
||||
})();
|
|
@ -0,0 +1,13 @@
|
|||
/* global Vue */
|
||||
(() => {
|
||||
Vue.component('time-tracking-estimate-only-pane', {
|
||||
name: 'time-tracking-estimate-only-pane',
|
||||
props: ['timeEstimateHumanReadable'],
|
||||
template: `
|
||||
<div class='time-tracking-estimate-only-pane'>
|
||||
<span class='bold'>Estimated:</span>
|
||||
{{ timeEstimateHumanReadable }}
|
||||
</div>
|
||||
`,
|
||||
});
|
||||
})();
|
|
@ -0,0 +1,24 @@
|
|||
/* global Vue */
|
||||
(() => {
|
||||
Vue.component('time-tracking-help-state', {
|
||||
name: 'time-tracking-help-state',
|
||||
props: ['docsUrl'],
|
||||
template: `
|
||||
<div class='time-tracking-help-state'>
|
||||
<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='docsUrl'>Learn more</a>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
});
|
||||
})();
|
|
@ -0,0 +1,11 @@
|
|||
/* global Vue */
|
||||
(() => {
|
||||
Vue.component('time-tracking-no-tracking-pane', {
|
||||
name: 'time-tracking-no-tracking-pane',
|
||||
template: `
|
||||
<div class='time-tracking-no-tracking-pane'>
|
||||
<span class='no-value'>No estimate or time spent</span>
|
||||
</div>
|
||||
`,
|
||||
});
|
||||
})();
|
|
@ -0,0 +1,13 @@
|
|||
/* global Vue */
|
||||
(() => {
|
||||
Vue.component('time-tracking-spent-only-pane', {
|
||||
name: 'time-tracking-spent-only-pane',
|
||||
props: ['timeSpentHumanReadable'],
|
||||
template: `
|
||||
<div class='time-tracking-spend-only-pane'>
|
||||
<span class='bold'>Spent:</span>
|
||||
{{ timeSpentHumanReadable }}
|
||||
</div>
|
||||
`,
|
||||
});
|
||||
})();
|
|
@ -0,0 +1,118 @@
|
|||
/* global Vue */
|
||||
//= require ./help_state
|
||||
//= require ./collapsed_state
|
||||
//= require ./spent_only_pane
|
||||
//= require ./no_tracking_pane
|
||||
//= require ./estimate_only_pane
|
||||
//= require ./comparison_pane
|
||||
|
||||
(() => {
|
||||
Vue.component('issuable-time-tracker', {
|
||||
name: 'issuable-time-tracker',
|
||||
props: [
|
||||
'time_estimate',
|
||||
'time_spent',
|
||||
'human_time_estimate',
|
||||
'human_time_spent',
|
||||
'stopwatchSvg',
|
||||
'docsUrl',
|
||||
],
|
||||
data() {
|
||||
return {
|
||||
showHelp: false,
|
||||
};
|
||||
},
|
||||
computed: {
|
||||
timeSpent() {
|
||||
return this.time_spent;
|
||||
},
|
||||
timeEstimate() {
|
||||
return this.time_estimate;
|
||||
},
|
||||
timeEstimateHumanReadable() {
|
||||
return this.human_time_estimate;
|
||||
},
|
||||
timeSpentHumanReadable() {
|
||||
return this.human_time_spent;
|
||||
},
|
||||
hasTimeSpent() {
|
||||
return !!this.timeSpent;
|
||||
},
|
||||
hasTimeEstimate() {
|
||||
return !!this.timeEstimate;
|
||||
},
|
||||
showComparisonState() {
|
||||
return this.hasTimeEstimate && this.hasTimeSpent;
|
||||
},
|
||||
showEstimateOnlyState() {
|
||||
return this.hasTimeEstimate && !this.hasTimeSpent;
|
||||
},
|
||||
showSpentOnlyState() {
|
||||
return this.hasTimeSpent && !this.hasTimeEstimate;
|
||||
},
|
||||
showNoTimeTrackingState() {
|
||||
return !this.hasTimeEstimate && !this.hasTimeSpent;
|
||||
},
|
||||
showHelpState() {
|
||||
return !!this.showHelp;
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
toggleHelpState(show) {
|
||||
this.showHelp = show;
|
||||
},
|
||||
},
|
||||
template: `
|
||||
<div class='time_tracker time-tracking-component-wrap' v-cloak>
|
||||
<time-tracking-collapsed-state
|
||||
:show-comparison-state='showComparisonState'
|
||||
:show-help-state='showHelpState'
|
||||
:show-spent-only-state='showSpentOnlyState'
|
||||
:show-estimate-only-state='showEstimateOnlyState'
|
||||
:time-spent-human-readable='timeSpentHumanReadable'
|
||||
:time-estimate-human-readable='timeEstimateHumanReadable'
|
||||
:stopwatch-svg='stopwatchSvg'>
|
||||
</time-tracking-collapsed-state>
|
||||
<div class='title hide-collapsed'>
|
||||
Time tracking
|
||||
<div class='help-button pull-right'
|
||||
v-if='!showHelpState'
|
||||
@click='toggleHelpState(true)'>
|
||||
<i class='fa fa-question-circle'></i>
|
||||
</div>
|
||||
<div class='close-help-button pull-right'
|
||||
v-if='showHelpState'
|
||||
@click='toggleHelpState(false)'>
|
||||
<i class='fa fa-close'></i>
|
||||
</div>
|
||||
</div>
|
||||
<div class='time-tracking-content hide-collapsed'>
|
||||
<time-tracking-estimate-only-pane
|
||||
v-if='showEstimateOnlyState'
|
||||
:time-estimate-human-readable='timeEstimateHumanReadable'>
|
||||
</time-tracking-estimate-only-pane>
|
||||
<time-tracking-spent-only-pane
|
||||
v-if='showSpentOnlyState'
|
||||
:time-spent-human-readable='timeSpentHumanReadable'>
|
||||
</time-tracking-spent-only-pane>
|
||||
<time-tracking-no-tracking-pane
|
||||
v-if='showNoTimeTrackingState'>
|
||||
</time-tracking-no-tracking-pane>
|
||||
<time-tracking-comparison-pane
|
||||
v-if='showComparisonState'
|
||||
:time-estimate='timeEstimate'
|
||||
:time-spent='timeSpent'
|
||||
:time-spent-human-readable='timeSpentHumanReadable'
|
||||
:time-estimate-human-readable='timeEstimateHumanReadable'>
|
||||
</time-tracking-comparison-pane>
|
||||
<transition name='help-state-toggle'>
|
||||
<time-tracking-help-state
|
||||
v-if='showHelpState'
|
||||
:docs-url='docsUrl'>
|
||||
</time-tracking-help-state>
|
||||
</transition>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
});
|
||||
})();
|
|
@ -0,0 +1,61 @@
|
|||
/* global Vue */
|
||||
//= require ./components/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. We will want to create a separate service to
|
||||
* interface with the server at that point.
|
||||
*/
|
||||
|
||||
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,
|
||||
});
|
||||
},
|
||||
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.subscribeToUpdates();
|
||||
this.listenForSlashCommands();
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
gl.IssuableTimeTracking = IssuableTimeTracking;
|
||||
})(window.gl || (window.gl = {}));
|
|
@ -4,13 +4,13 @@
|
|||
* stringifyTime condensed or non-condensed, abbreviateTimelengths)
|
||||
* */
|
||||
|
||||
class PrettyTime {
|
||||
|
||||
const utils = window.gl.utils = gl.utils || {};
|
||||
const prettyTime = utils.prettyTime = {
|
||||
/*
|
||||
* Accepts seconds and returns a timeObject { weeks: #, days: #, hours: #, minutes: # }
|
||||
* Seconds can be negative or positive, zero or non-zero.
|
||||
*/
|
||||
static parseSeconds(seconds) {
|
||||
parseSeconds(seconds) {
|
||||
const DAYS_PER_WEEK = 5;
|
||||
const HOURS_PER_DAY = 8;
|
||||
const MINUTES_PER_HOUR = 60;
|
||||
|
@ -24,7 +24,7 @@
|
|||
minutes: 1,
|
||||
};
|
||||
|
||||
let unorderedMinutes = PrettyTime.secondsToMinutes(seconds);
|
||||
let unorderedMinutes = prettyTime.secondsToMinutes(seconds);
|
||||
|
||||
return _.mapObject(timePeriodConstraints, (minutesPerPeriod) => {
|
||||
const periodCount = Math.floor(unorderedMinutes / minutesPerPeriod);
|
||||
|
@ -33,35 +33,33 @@
|
|||
|
||||
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) {
|
||||
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) {
|
||||
abbreviateTime(timeStr) {
|
||||
return timeStr.split(' ')
|
||||
.filter(unitStr => unitStr.charAt(0) !== '0')[0];
|
||||
}
|
||||
},
|
||||
|
||||
static secondsToMinutes(seconds) {
|
||||
secondsToMinutes(seconds) {
|
||||
return Math.abs(seconds / 60);
|
||||
}
|
||||
}
|
||||
|
||||
gl.PrettyTime = PrettyTime;
|
||||
},
|
||||
};
|
||||
})(window.gl || (window.gl = {}));
|
||||
|
|
|
@ -0,0 +1,2 @@
|
|||
//= require vue
|
||||
//= require vue-resource
|
|
@ -1,6 +1,3 @@
|
|||
//= require vue
|
||||
//= require vue-resource
|
||||
|
||||
(() => {
|
||||
/*
|
||||
* SubbableResource can be extended to provide a pubsub-style service for one-off REST
|
||||
|
|
|
@ -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;
|
||||
|
||||
|
@ -56,6 +56,7 @@ $black-transparent: rgba(0, 0, 0, 0.3);
|
|||
$border-white-light: darken($white-light, $darken-border-factor);
|
||||
$border-white-normal: darken($white-normal, $darken-border-factor);
|
||||
|
||||
$border-gray-light: darken($gray-light, $darken-border-factor);
|
||||
$border-gray-normal: darken($gray-normal, $darken-border-factor);
|
||||
$border-gray-dark: darken($white-normal, $darken-border-factor);
|
||||
|
||||
|
@ -85,6 +86,7 @@ $warning-message-border: #f0e2bb;
|
|||
*/
|
||||
$border-color: #e5e5e5;
|
||||
$focus-border-color: #3aabf0;
|
||||
$sidebar-collapsed-icon-color: #999;
|
||||
$well-expand-item: #e8f2f7;
|
||||
$well-inner-border: #eef0f2;
|
||||
$well-light-border: #f1f1f1;
|
||||
|
@ -280,6 +282,7 @@ $dropdown-hover-color: #3b86ff;
|
|||
*/
|
||||
$btn-active-gray: #ececec;
|
||||
$btn-active-gray-light: e4e7ed;
|
||||
$btn-white-active: #848484;
|
||||
|
||||
/*
|
||||
* Badges
|
||||
|
@ -433,6 +436,7 @@ $help-shortcut-header-color: #333;
|
|||
*/
|
||||
$issues-today-bg: #f3fff2;
|
||||
$issues-today-border: #e1e8d5;
|
||||
$compare-display-color: #888;
|
||||
|
||||
/*
|
||||
* jQuery UI
|
||||
|
|
|
@ -473,3 +473,102 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
&:hover svg {
|
||||
fill: $gl-text-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: $compare-display-color;
|
||||
|
||||
.compare-value {
|
||||
color: $gl-text-color;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.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;
|
||||
}
|
||||
}
|
||||
|
||||
.help-state-toggle-enter-active {
|
||||
transition: all .8s ease;
|
||||
}
|
||||
|
||||
.help-state-toggle-leave-active {
|
||||
transition: all .5s ease;
|
||||
}
|
||||
|
||||
.help-state-toggle-enter,
|
||||
.help-state-toggle-leave-active {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -0,0 +1,58 @@
|
|||
# == 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
|
|
@ -0,0 +1,6 @@
|
|||
class Timelog < ActiveRecord::Base
|
||||
validates :time_spent, :user, presence: true
|
||||
|
||||
belongs_to :trackable, polymorphic: true
|
||||
belongs_to :user
|
||||
end
|
|
@ -13,4 +13,8 @@ class IssuableEntity < Grape::Entity
|
|||
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
|
||||
|
|
|
@ -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 = :"admin_#{issuable.to_ability_name}"
|
||||
|
||||
|
@ -156,6 +164,7 @@ class IssuableBaseService < BaseService
|
|||
def create(issuable)
|
||||
merge_slash_commands_into_params!(issuable)
|
||||
filter_params(issuable)
|
||||
change_time_spent(issuable)
|
||||
|
||||
params.delete(:state_event)
|
||||
params[:author] ||= current_user
|
||||
|
@ -198,13 +207,14 @@ class IssuableBaseService < BaseService
|
|||
change_subscription(issuable)
|
||||
change_todo(issuable)
|
||||
filter_params(issuable)
|
||||
time_spent = change_time_spent(issuable)
|
||||
old_labels = issuable.labels.to_a
|
||||
old_mentioned_users = issuable.mentioned_users.to_a
|
||||
|
||||
label_ids = process_label_ids(params, existing_label_ids: issuable.label_ids)
|
||||
params[:label_ids] = label_ids if labels_changing?(issuable.label_ids, label_ids)
|
||||
|
||||
if params.present? && update_issuable(issuable, params)
|
||||
if (params.present? || time_spent) && update_issuable(issuable, params)
|
||||
# We do not touch as it will affect a update on updated_at field
|
||||
ActiveRecord::Base.no_touching do
|
||||
handle_common_system_notes(issuable, old_labels: old_labels)
|
||||
|
@ -251,6 +261,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]
|
||||
|
||||
|
@ -272,6 +288,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
|
||||
|
|
|
@ -255,6 +255,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'
|
||||
|
|
|
@ -109,6 +109,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
|
||||
|
|
|
@ -2,6 +2,8 @@
|
|||
- page_title "#{@issue.title} (#{@issue.to_reference})", "Issues"
|
||||
- page_description @issue.description
|
||||
- page_card_attributes @issue.card_attributes
|
||||
- content_for :page_specific_javascripts do
|
||||
= page_specific_javascript_tag('lib/vue_resource.js')
|
||||
|
||||
.clearfix.detail-page-header
|
||||
.issuable-header
|
||||
|
|
|
@ -3,6 +3,7 @@
|
|||
- page_description @merge_request.description
|
||||
- page_card_attributes @merge_request.card_attributes
|
||||
- content_for :page_specific_javascripts do
|
||||
= page_specific_javascript_tag('lib/vue_resource.js')
|
||||
= page_specific_javascript_tag('diff_notes/diff_notes_bundle.js')
|
||||
|
||||
.merge-request{ 'data-url' => merge_request_path(@merge_request) }
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
- page_title "Merge Conflicts", "#{@merge_request.title} (#{@merge_request.to_reference}", "Merge Requests"
|
||||
- content_for :page_specific_javascripts do
|
||||
= page_specific_javascript_tag('lib/vue_resource.js')
|
||||
= page_specific_javascript_tag('merge_conflicts/merge_conflicts_bundle.js')
|
||||
= page_specific_javascript_tag('lib/ace.js')
|
||||
= render "projects/merge_requests/show/mr_title"
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
<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>
|
After Width: | Height: | Size: 430 B |
|
@ -1,5 +1,7 @@
|
|||
- todo = issuable_todo(issuable)
|
||||
%aside.right-sidebar{ class: sidebar_gutter_collapsed_class }
|
||||
- content_for :page_specific_javascripts do
|
||||
= page_specific_javascript_tag('issuable/issuable_bundle.js')
|
||||
%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 +74,13 @@
|
|||
.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', 'stopwatch-svg' => custom_icon('icon_stopwatch'), 'docs-url' => help_page_path('workflow/time_tracking.md') }
|
||||
// Fallback while content is loading
|
||||
.title.hide-collapsed
|
||||
Time tracking
|
||||
= icon('spinner spin')
|
||||
- if issuable.has_attribute?(:due_date)
|
||||
.block.due_date
|
||||
.sidebar-collapsed-icon
|
||||
|
@ -162,6 +170,8 @@
|
|||
= clipboard_button(clipboard_text: project_ref, title: "Copy reference to clipboard", placement: "left")
|
||||
|
||||
: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 IssuableContext('#{escape_javascript(current_user.to_json(only: [:username, :id, :name]))}');
|
||||
|
|
|
@ -88,6 +88,7 @@ module Gitlab
|
|||
config.assets.precompile << "print.css"
|
||||
config.assets.precompile << "notify.css"
|
||||
config.assets.precompile << "mailers/*.css"
|
||||
config.assets.precompile << "lib/vue_resource.js"
|
||||
config.assets.precompile << "katex.css"
|
||||
config.assets.precompile << "katex.js"
|
||||
config.assets.precompile << "xterm/xterm.css"
|
||||
|
@ -98,6 +99,7 @@ module Gitlab
|
|||
config.assets.precompile << "protected_branches/protected_branches_bundle.js"
|
||||
config.assets.precompile << "diff_notes/diff_notes_bundle.js"
|
||||
config.assets.precompile << "merge_request_widget/ci_bundle.js"
|
||||
config.assets.precompile << "issuable/issuable_bundle.js"
|
||||
config.assets.precompile << "boards/boards_bundle.js"
|
||||
config.assets.precompile << "cycle_analytics/cycle_analytics_bundle.js"
|
||||
config.assets.precompile << "merge_conflicts/merge_conflicts_bundle.js"
|
||||
|
|
|
@ -0,0 +1,30 @@
|
|||
# See http://doc.gitlab.com/ce/development/migration_style_guide.html
|
||||
# for more information on how to write migrations for GitLab.
|
||||
|
||||
class AddTimeEstimateToIssuables < 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 change
|
||||
add_column :issues, :time_estimate, :integer
|
||||
add_column :merge_requests, :time_estimate, :integer
|
||||
end
|
||||
end
|
|
@ -0,0 +1,38 @@
|
|||
# See http://doc.gitlab.com/ce/development/migration_style_guide.html
|
||||
# for more information on how to write migrations for GitLab.
|
||||
|
||||
class CreateTimelogs < 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 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
|
14
db/schema.rb
14
db/schema.rb
|
@ -506,6 +506,7 @@ ActiveRecord::Schema.define(version: 20170106172224) 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
|
||||
|
@ -685,6 +686,7 @@ ActiveRecord::Schema.define(version: 20170106172224) 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
|
||||
|
@ -1128,6 +1130,18 @@ ActiveRecord::Schema.define(version: 20170106172224) 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
|
||||
|
|
|
@ -30,3 +30,7 @@ do.
|
|||
| <code>/due <in 2 days | this Friday | December 31st></code> | Set due date |
|
||||
| `/remove_due_date` | Remove due date |
|
||||
| `/wip` | Toggle the Work In Progress status |
|
||||
| <code>/estimate <1w 3d 2h 14m></code> | Set time estimate |
|
||||
| `/remove_estimate` | Remove estimated time |
|
||||
| <code>/spend <1h 30m | -1h 5m></code> | Add or substract spent time |
|
||||
| `/remove_time_spent` | Remove time spent |
|
||||
|
|
|
@ -19,6 +19,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)
|
||||
- [Web Editor](../user/project/repository/web_editor.md)
|
||||
- [Releases](releases.md)
|
||||
- [Milestones](milestones.md)
|
||||
|
|
Binary file not shown.
After Width: | Height: | Size: 47 KiB |
Binary file not shown.
After Width: | Height: | Size: 19 KiB |
|
@ -0,0 +1,73 @@
|
|||
# Time Tracking
|
||||
|
||||
> Introduced in GitLab 8.14.
|
||||
|
||||
Time Tracking allows you to track estimates and time spent on issues and merge
|
||||
requests within GitLab.
|
||||
|
||||
## 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
|
||||
|
|
|
@ -0,0 +1,30 @@
|
|||
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
|
|
@ -326,6 +326,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 'resolving discussions in MergeRequest' do
|
||||
let(:discussion) { Discussion.for_diff_notes([create(:diff_note_on_merge_request)]).first }
|
||||
let(:merge_request) { discussion.noteable }
|
||||
|
@ -369,13 +383,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
|
||||
|
@ -396,20 +404,28 @@ describe Projects::IssuesController do
|
|||
request.env['action_dispatch.remote_ip'] = '127.0.0.1'
|
||||
end
|
||||
|
||||
def post_new_issue
|
||||
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)
|
||||
end
|
||||
end
|
||||
|
||||
context 'when description has slash commands' do
|
||||
before do
|
||||
sign_in(user)
|
||||
end
|
||||
|
||||
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
|
||||
|
||||
describe 'POST #mark_as_spam' do
|
||||
|
|
|
@ -0,0 +1,9 @@
|
|||
# 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,58 @@ 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 '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) }
|
||||
|
||||
|
|
|
@ -0,0 +1,201 @@
|
|||
/* eslint-disable */
|
||||
//= require jquery
|
||||
//= require vue
|
||||
//= require issuable/time_tracking/components/time_tracker
|
||||
|
||||
function initTimeTrackingComponent(opts) {
|
||||
fixture.set(`
|
||||
<div>
|
||||
<div id="mock-container"></div>
|
||||
</div>
|
||||
`);
|
||||
|
||||
this.initialData = {
|
||||
time_estimate: opts.timeEstimate,
|
||||
time_spent: opts.timeSpent,
|
||||
human_time_estimate: opts.timeEstimateHumanReadable,
|
||||
human_time_spent: opts.timeSpentHumanReadable,
|
||||
docsUrl: '/help/workflow/time_tracking.md',
|
||||
};
|
||||
|
||||
const TimeTrackingComponent = Vue.component('issuable-time-tracker');
|
||||
this.timeTracker = new TimeTrackingComponent({
|
||||
el: '#mock-container',
|
||||
propsData: this.initialData,
|
||||
});
|
||||
}
|
||||
|
||||
((gl) => {
|
||||
describe('Issuable Time Tracker', function() {
|
||||
describe('Initialization', function() {
|
||||
beforeEach(function() {
|
||||
initTimeTrackingComponent.call(this, { timeEstimate: 100000, timeSpent: 5000, timeEstimateHumanReadable: '2h 46m', timeSpentHumanReadable: '1h 23m' });
|
||||
});
|
||||
|
||||
it('should return something defined', function() {
|
||||
expect(this.timeTracker).toBeDefined();
|
||||
});
|
||||
|
||||
it ('should correctly set timeEstimate', function(done) {
|
||||
Vue.nextTick(() => {
|
||||
expect(this.timeTracker.timeEstimate).toBe(this.initialData.time_estimate);
|
||||
done();
|
||||
});
|
||||
});
|
||||
it ('should correctly set time_spent', function(done) {
|
||||
Vue.nextTick(() => {
|
||||
expect(this.timeTracker.timeSpent).toBe(this.initialData.time_spent);
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Content Display', function() {
|
||||
describe('Panes', function() {
|
||||
describe('Comparison pane', function() {
|
||||
beforeEach(function() {
|
||||
initTimeTrackingComponent.call(this, { timeEstimate: 100000, timeSpent: 5000, timeEstimateHumanReadable: '', timeSpentHumanReadable: '' });
|
||||
});
|
||||
|
||||
it('should show the "Comparison" pane when timeEstimate and time_spent are truthy', function(done) {
|
||||
Vue.nextTick(() => {
|
||||
const $comparisonPane = this.timeTracker.$el.querySelector('.time-tracking-comparison-pane');
|
||||
expect(this.timeTracker.showComparisonState).toBe(true);
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Remaining meter', function() {
|
||||
it('should display the remaining meter with the correct width', function(done) {
|
||||
Vue.nextTick(() => {
|
||||
const meterWidth = this.timeTracker.$el.querySelector('.time-tracking-comparison-pane .meter-fill').style.width;
|
||||
const correctWidth = '5%';
|
||||
|
||||
expect(meterWidth).toBe(correctWidth);
|
||||
done();
|
||||
})
|
||||
});
|
||||
|
||||
it('should display the remaining meter with the correct background color when within estimate', function(done) {
|
||||
Vue.nextTick(() => {
|
||||
const styledMeter = $(this.timeTracker.$el).find('.time-tracking-comparison-pane .within_estimate .meter-fill');
|
||||
expect(styledMeter.length).toBe(1);
|
||||
done()
|
||||
});
|
||||
});
|
||||
|
||||
it('should display the remaining meter with the correct background color when over estimate', function(done) {
|
||||
this.timeTracker.time_estimate = 100000;
|
||||
this.timeTracker.time_spent = 20000000;
|
||||
Vue.nextTick(() => {
|
||||
const styledMeter = $(this.timeTracker.$el).find('.time-tracking-comparison-pane .over_estimate .meter-fill');
|
||||
expect(styledMeter.length).toBe(1);
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Estimate only pane", function() {
|
||||
beforeEach(function() {
|
||||
initTimeTrackingComponent.call(this, { timeEstimate: 100000, timeSpent: 0, timeEstimateHumanReadable: '2h 46m', timeSpentHumanReadable: '' });
|
||||
});
|
||||
|
||||
it('should display the human readable version of time estimated', function(done) {
|
||||
Vue.nextTick(() => {
|
||||
const estimateText = this.timeTracker.$el.querySelector('.time-tracking-estimate-only-pane').innerText;
|
||||
const correctText = 'Estimated: 2h 46m';
|
||||
|
||||
expect(estimateText).toBe(correctText);
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Spent only pane', function() {
|
||||
beforeEach(function() {
|
||||
initTimeTrackingComponent.call(this, { timeEstimate: 0, timeSpent: 5000, timeEstimateHumanReadable: '2h 46m', timeSpentHumanReadable: '1h 23m' });
|
||||
});
|
||||
|
||||
it('should display the human readable version of time spent', function(done) {
|
||||
Vue.nextTick(() => {
|
||||
const spentText = this.timeTracker.$el.querySelector('.time-tracking-spend-only-pane').innerText;
|
||||
const correctText = 'Spent: 1h 23m';
|
||||
|
||||
expect(spentText).toBe(correctText);
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('No time tracking pane', function() {
|
||||
beforeEach(function() {
|
||||
initTimeTrackingComponent.call(this, { timeEstimate: 0, timeSpent: 0, timeEstimateHumanReadable: 0, timeSpentHumanReadable: 0 });
|
||||
});
|
||||
|
||||
it('should only show the "No time tracking" pane when both timeEstimate and time_spent are falsey', function(done) {
|
||||
Vue.nextTick(() => {
|
||||
const $noTrackingPane = this.timeTracker.$el.querySelector('.time-tracking-no-tracking-pane');
|
||||
const noTrackingText =$noTrackingPane.innerText;
|
||||
const correctText = 'No estimate or time spent';
|
||||
|
||||
expect(this.timeTracker.showNoTimeTrackingState).toBe(true);
|
||||
expect($noTrackingPane).toBeVisible();
|
||||
expect(noTrackingText).toBe(correctText);
|
||||
done();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("Help pane", function() {
|
||||
beforeEach(function() {
|
||||
initTimeTrackingComponent.call(this, { timeEstimate: 0, timeSpent: 0 });
|
||||
});
|
||||
|
||||
it('should not show the "Help" pane by default', function(done) {
|
||||
Vue.nextTick(() => {
|
||||
const $helpPane = this.timeTracker.$el.querySelector('.time-tracking-help-state');
|
||||
|
||||
expect(this.timeTracker.showHelpState).toBe(false);
|
||||
expect($helpPane).toBeNull();
|
||||
done();
|
||||
});
|
||||
});
|
||||
|
||||
it('should show the "Help" pane when help button is clicked', function(done) {
|
||||
Vue.nextTick(() => {
|
||||
$(this.timeTracker.$el).find('.help-button').click();
|
||||
|
||||
setTimeout(() => {
|
||||
const $helpPane = this.timeTracker.$el.querySelector('.time-tracking-help-state');
|
||||
expect(this.timeTracker.showHelpState).toBe(true);
|
||||
expect($helpPane).toBeVisible();
|
||||
done();
|
||||
}, 10);
|
||||
});
|
||||
});
|
||||
|
||||
it('should not show the "Help" pane when help button is clicked and then closed', function(done) {
|
||||
Vue.nextTick(() => {
|
||||
$(this.timeTracker.$el).find('.help-button').click();
|
||||
|
||||
setTimeout(() => {
|
||||
|
||||
$(this.timeTracker.$el).find('.close-help-button').click();
|
||||
|
||||
setTimeout(() => {
|
||||
const $helpPane = this.timeTracker.$el.querySelector('.time-tracking-help-state');
|
||||
|
||||
expect(this.timeTracker.showHelpState).toBe(false);
|
||||
expect($helpPane).toBeNull();
|
||||
|
||||
done();
|
||||
}, 1000);
|
||||
}, 1000);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
})(window.gl || (window.gl = {}));
|
|
@ -1,12 +1,12 @@
|
|||
//= require lib/utils/pretty_time
|
||||
|
||||
(() => {
|
||||
const PrettyTime = gl.PrettyTime;
|
||||
const prettyTime = gl.utils.prettyTime;
|
||||
|
||||
describe('PrettyTime methods', function () {
|
||||
describe('prettyTime methods', function () {
|
||||
describe('parseSeconds', function () {
|
||||
it('should correctly parse a negative value', function () {
|
||||
const parser = PrettyTime.parseSeconds;
|
||||
const parser = prettyTime.parseSeconds;
|
||||
|
||||
const zeroSeconds = parser(-1000);
|
||||
|
||||
|
@ -17,7 +17,7 @@
|
|||
});
|
||||
|
||||
it('should correctly parse a zero value', function () {
|
||||
const parser = PrettyTime.parseSeconds;
|
||||
const parser = prettyTime.parseSeconds;
|
||||
|
||||
const zeroSeconds = parser(0);
|
||||
|
||||
|
@ -28,7 +28,7 @@
|
|||
});
|
||||
|
||||
it('should correctly parse a small non-zero second values', function () {
|
||||
const parser = PrettyTime.parseSeconds;
|
||||
const parser = prettyTime.parseSeconds;
|
||||
|
||||
const subOneMinute = parser(10);
|
||||
|
||||
|
@ -53,7 +53,7 @@
|
|||
});
|
||||
|
||||
it('should correctly parse large second values', function () {
|
||||
const parser = PrettyTime.parseSeconds;
|
||||
const parser = prettyTime.parseSeconds;
|
||||
|
||||
const aboveOneHour = parser(4800);
|
||||
|
||||
|
@ -87,7 +87,7 @@
|
|||
minutes: 20,
|
||||
};
|
||||
|
||||
const timeString = PrettyTime.stringifyTime(timeObject);
|
||||
const timeString = prettyTime.stringifyTime(timeObject);
|
||||
|
||||
expect(timeString).toBe('1w 4d 7h 20m');
|
||||
});
|
||||
|
@ -100,7 +100,7 @@
|
|||
minutes: 20,
|
||||
};
|
||||
|
||||
const timeString = PrettyTime.stringifyTime(timeObject);
|
||||
const timeString = prettyTime.stringifyTime(timeObject);
|
||||
|
||||
expect(timeString).toBe('4d 20m');
|
||||
});
|
||||
|
@ -113,7 +113,7 @@
|
|||
minutes: 0,
|
||||
};
|
||||
|
||||
const timeString = PrettyTime.stringifyTime(timeObject);
|
||||
const timeString = prettyTime.stringifyTime(timeObject);
|
||||
|
||||
expect(timeString).toBe('0m');
|
||||
});
|
||||
|
@ -122,12 +122,12 @@
|
|||
describe('abbreviateTime', function () {
|
||||
it('should abbreviate stringified times for weeks', function () {
|
||||
const fullTimeString = '1w 3d 4h 5m';
|
||||
expect(PrettyTime.abbreviateTime(fullTimeString)).toBe('1w');
|
||||
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');
|
||||
expect(prettyTime.abbreviateTime(fullTimeString)).toBe('3d');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -15,6 +15,7 @@ issues:
|
|||
- events
|
||||
- merge_requests_closing_issues
|
||||
- metrics
|
||||
- timelogs
|
||||
events:
|
||||
- author
|
||||
- project
|
||||
|
@ -77,6 +78,7 @@ merge_requests:
|
|||
- events
|
||||
- merge_requests_closing_issues
|
||||
- metrics
|
||||
- timelogs
|
||||
merge_request_diff:
|
||||
- merge_request
|
||||
pipelines:
|
||||
|
@ -198,3 +200,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
|
||||
|
@ -150,6 +151,7 @@ MergeRequest:
|
|||
- milestone_id
|
||||
- approvals_before_merge
|
||||
- rebase_commit_sha
|
||||
- time_estimate
|
||||
MergeRequestDiff:
|
||||
- id
|
||||
- state
|
||||
|
@ -344,3 +346,11 @@ LabelPriority:
|
|||
- priority
|
||||
- created_at
|
||||
- updated_at
|
||||
Timelog:
|
||||
- id
|
||||
- time_spent
|
||||
- trackable_id
|
||||
- trackable_type
|
||||
- user_id
|
||||
- created_at
|
||||
- updated_at
|
||||
|
|
|
@ -408,4 +408,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
|
||||
|
|
|
@ -0,0 +1,10 @@
|
|||
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
|
|
@ -86,6 +86,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
|
||||
|
|
|
@ -211,6 +211,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)
|
||||
|
@ -518,6 +558,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) }
|
||||
|
|
|
@ -740,4 +740,69 @@ describe SystemNoteService, services: true do
|
|||
expect(note.note).to include(issue.to_reference)
|
||||
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
|
||||
|
|
|
@ -0,0 +1,82 @@
|
|||
shared_examples 'issuable time tracker' do
|
||||
it 'renders the sidebar component empty state' do
|
||||
page.within '.time-tracking-no-tracking-pane' 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-pane' 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-pane' 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-comparison-pane' 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
|
||||
|
||||
it 'displays the correct help url' do
|
||||
page.within '#issuable-time-tracker' do
|
||||
find('.help-button').click
|
||||
|
||||
expect(find_link('Learn more')[:href]).to have_content('/help/workflow/time_tracking.md')
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
def submit_time(slash_command)
|
||||
fill_in 'note[note]', with: slash_command
|
||||
click_button 'Comment'
|
||||
wait_for_ajax
|
||||
end
|
Loading…
Reference in New Issue