summaryrefslogtreecommitdiff
path: root/app
diff options
context:
space:
mode:
Diffstat (limited to 'app')
-rw-r--r--app/assets/javascripts/diff_notes/diff_notes_bundle.js.es62
-rw-r--r--app/assets/javascripts/issuable/issuable_bundle.js.es61
-rw-r--r--app/assets/javascripts/issuable/time_tracking/components/collapsed_state.js.es642
-rw-r--r--app/assets/javascripts/issuable/time_tracking/components/comparison_pane.js.es669
-rw-r--r--app/assets/javascripts/issuable/time_tracking/components/estimate_only_pane.js.es613
-rw-r--r--app/assets/javascripts/issuable/time_tracking/components/help_state.js.es624
-rw-r--r--app/assets/javascripts/issuable/time_tracking/components/no_tracking_pane.js.es611
-rw-r--r--app/assets/javascripts/issuable/time_tracking/components/spent_only_pane.js.es613
-rw-r--r--app/assets/javascripts/issuable/time_tracking/components/time_tracker.js.es6118
-rw-r--r--app/assets/javascripts/issuable/time_tracking/time_tracking_bundle.js.es661
-rw-r--r--app/assets/javascripts/lib/utils/pretty_time.js.es626
-rw-r--r--app/assets/javascripts/lib/vue_resource.js.es62
-rw-r--r--app/assets/javascripts/subbable_resource.js.es63
-rw-r--r--app/assets/stylesheets/framework/variables.scss6
-rw-r--r--app/assets/stylesheets/pages/issuable.scss99
-rw-r--r--app/helpers/issuables_helper.rb9
-rw-r--r--app/models/concerns/issuable.rb1
-rw-r--r--app/models/concerns/time_trackable.rb58
-rw-r--r--app/models/timelog.rb6
-rw-r--r--app/serializers/issuable_entity.rb4
-rw-r--r--app/services/issuable_base_service.rb26
-rw-r--r--app/services/slash_commands/interpret_service.rb47
-rw-r--r--app/services/system_note_service.rb51
-rw-r--r--app/views/projects/issues/show.html.haml2
-rw-r--r--app/views/projects/merge_requests/_show.html.haml1
-rw-r--r--app/views/projects/merge_requests/conflicts.html.haml1
-rw-r--r--app/views/shared/icons/_icon_stopwatch.svg1
-rw-r--r--app/views/shared/issuable/_sidebar.html.haml14
28 files changed, 688 insertions, 23 deletions
diff --git a/app/assets/javascripts/diff_notes/diff_notes_bundle.js.es6 b/app/assets/javascripts/diff_notes/diff_notes_bundle.js.es6
index 840b5aa922e..1b3a57d0962 100644
--- a/app/assets/javascripts/diff_notes/diff_notes_bundle.js.es6
+++ b/app/assets/javascripts/diff_notes/diff_notes_bundle.js.es6
@@ -2,8 +2,6 @@
/* global Vue */
/* global ResolveCount */
-//= require vue
-//= require vue-resource
//= require_directory ./models
//= require_directory ./stores
//= require_directory ./services
diff --git a/app/assets/javascripts/issuable/issuable_bundle.js.es6 b/app/assets/javascripts/issuable/issuable_bundle.js.es6
new file mode 100644
index 00000000000..7d0465aa8b4
--- /dev/null
+++ b/app/assets/javascripts/issuable/issuable_bundle.js.es6
@@ -0,0 +1 @@
+//= require ./time_tracking/time_tracking_bundle
diff --git a/app/assets/javascripts/issuable/time_tracking/components/collapsed_state.js.es6 b/app/assets/javascripts/issuable/time_tracking/components/collapsed_state.js.es6
new file mode 100644
index 00000000000..72433df2818
--- /dev/null
+++ b/app/assets/javascripts/issuable/time_tracking/components/collapsed_state.js.es6
@@ -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>
+ `,
+ });
+})();
+
diff --git a/app/assets/javascripts/issuable/time_tracking/components/comparison_pane.js.es6 b/app/assets/javascripts/issuable/time_tracking/components/comparison_pane.js.es6
new file mode 100644
index 00000000000..6abbd5dd167
--- /dev/null
+++ b/app/assets/javascripts/issuable/time_tracking/components/comparison_pane.js.es6
@@ -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>
+ `,
+ });
+})();
diff --git a/app/assets/javascripts/issuable/time_tracking/components/estimate_only_pane.js.es6 b/app/assets/javascripts/issuable/time_tracking/components/estimate_only_pane.js.es6
new file mode 100644
index 00000000000..309e9f2f9ef
--- /dev/null
+++ b/app/assets/javascripts/issuable/time_tracking/components/estimate_only_pane.js.es6
@@ -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>
+ `,
+ });
+})();
diff --git a/app/assets/javascripts/issuable/time_tracking/components/help_state.js.es6 b/app/assets/javascripts/issuable/time_tracking/components/help_state.js.es6
new file mode 100644
index 00000000000..d7ced6d7151
--- /dev/null
+++ b/app/assets/javascripts/issuable/time_tracking/components/help_state.js.es6
@@ -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>
+ `,
+ });
+})();
diff --git a/app/assets/javascripts/issuable/time_tracking/components/no_tracking_pane.js.es6 b/app/assets/javascripts/issuable/time_tracking/components/no_tracking_pane.js.es6
new file mode 100644
index 00000000000..1d2ca643b5b
--- /dev/null
+++ b/app/assets/javascripts/issuable/time_tracking/components/no_tracking_pane.js.es6
@@ -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>
+ `,
+ });
+})();
diff --git a/app/assets/javascripts/issuable/time_tracking/components/spent_only_pane.js.es6 b/app/assets/javascripts/issuable/time_tracking/components/spent_only_pane.js.es6
new file mode 100644
index 00000000000..ed283fec3c3
--- /dev/null
+++ b/app/assets/javascripts/issuable/time_tracking/components/spent_only_pane.js.es6
@@ -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>
+ `,
+ });
+})();
diff --git a/app/assets/javascripts/issuable/time_tracking/components/time_tracker.js.es6 b/app/assets/javascripts/issuable/time_tracking/components/time_tracker.js.es6
new file mode 100644
index 00000000000..26563a7713b
--- /dev/null
+++ b/app/assets/javascripts/issuable/time_tracking/components/time_tracker.js.es6
@@ -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>
+ `,
+ });
+})();
diff --git a/app/assets/javascripts/issuable/time_tracking/time_tracking_bundle.js.es6 b/app/assets/javascripts/issuable/time_tracking/time_tracking_bundle.js.es6
new file mode 100644
index 00000000000..0b8da2b1f4f
--- /dev/null
+++ b/app/assets/javascripts/issuable/time_tracking/time_tracking_bundle.js.es6
@@ -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 = {}));
diff --git a/app/assets/javascripts/lib/utils/pretty_time.js.es6 b/app/assets/javascripts/lib/utils/pretty_time.js.es6
index ccaf447eb0b..ae397212e55 100644
--- a/app/assets/javascripts/lib/utils/pretty_time.js.es6
+++ b/app/assets/javascripts/lib/utils/pretty_time.js.es6
@@ -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 = {}));
diff --git a/app/assets/javascripts/lib/vue_resource.js.es6 b/app/assets/javascripts/lib/vue_resource.js.es6
new file mode 100644
index 00000000000..eff1dcabfa2
--- /dev/null
+++ b/app/assets/javascripts/lib/vue_resource.js.es6
@@ -0,0 +1,2 @@
+//= require vue
+//= require vue-resource
diff --git a/app/assets/javascripts/subbable_resource.js.es6 b/app/assets/javascripts/subbable_resource.js.es6
index 932120157a3..d8191605128 100644
--- a/app/assets/javascripts/subbable_resource.js.es6
+++ b/app/assets/javascripts/subbable_resource.js.es6
@@ -1,6 +1,3 @@
-//= require vue
-//= require vue-resource
-
(() => {
/*
* SubbableResource can be extended to provide a pubsub-style service for one-off REST
diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss
index 349cd9c189e..07cb669a46e 100644
--- a/app/assets/stylesheets/framework/variables.scss
+++ b/app/assets/stylesheets/framework/variables.scss
@@ -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
diff --git a/app/assets/stylesheets/pages/issuable.scss b/app/assets/stylesheets/pages/issuable.scss
index 0ae5dc5c537..324c6cec96a 100644
--- a/app/assets/stylesheets/pages/issuable.scss
+++ b/app/assets/stylesheets/pages/issuable.scss
@@ -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;
+ }
+}
diff --git a/app/helpers/issuables_helper.rb b/app/helpers/issuables_helper.rb
index 1c213983a5b..e5bb8b93e76 100644
--- a/app/helpers/issuables_helper.rb
+++ b/app/helpers/issuables_helper.rb
@@ -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 = {
diff --git a/app/models/concerns/issuable.rb b/app/models/concerns/issuable.rb
index 5e63825bf99..3517969eabc 100644
--- a/app/models/concerns/issuable.rb
+++ b/app/models/concerns/issuable.rb
@@ -13,6 +13,7 @@ module Issuable
include StripAttribute
include Awardable
include Taskable
+ include TimeTrackable
included do
cache_markdown_field :title, pipeline: :single_line
diff --git a/app/models/concerns/time_trackable.rb b/app/models/concerns/time_trackable.rb
new file mode 100644
index 00000000000..6fa2af4e4e6
--- /dev/null
+++ b/app/models/concerns/time_trackable.rb
@@ -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
diff --git a/app/models/timelog.rb b/app/models/timelog.rb
new file mode 100644
index 00000000000..f768c4e3da5
--- /dev/null
+++ b/app/models/timelog.rb
@@ -0,0 +1,6 @@
+class Timelog < ActiveRecord::Base
+ validates :time_spent, :user, presence: true
+
+ belongs_to :trackable, polymorphic: true
+ belongs_to :user
+end
diff --git a/app/serializers/issuable_entity.rb b/app/serializers/issuable_entity.rb
index 17c9160cb19..29aecb50849 100644
--- a/app/serializers/issuable_entity.rb
+++ b/app/serializers/issuable_entity.rb
@@ -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
diff --git a/app/services/issuable_base_service.rb b/app/services/issuable_base_service.rb
index 4ce5fd993d9..7491c256b99 100644
--- a/app/services/issuable_base_service.rb
+++ b/app/services/issuable_base_service.rb
@@ -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
diff --git a/app/services/slash_commands/interpret_service.rb b/app/services/slash_commands/interpret_service.rb
index d18844ac0e3..a6e35d340e9 100644
--- a/app/services/slash_commands/interpret_service.rb
+++ b/app/services/slash_commands/interpret_service.rb
@@ -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'
diff --git a/app/services/system_note_service.rb b/app/services/system_note_service.rb
index 7613ecd5021..5ca2551ee61 100644
--- a/app/services/system_note_service.rb
+++ b/app/services/system_note_service.rb
@@ -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
diff --git a/app/views/projects/issues/show.html.haml b/app/views/projects/issues/show.html.haml
index 43141971231..9fa00811af0 100644
--- a/app/views/projects/issues/show.html.haml
+++ b/app/views/projects/issues/show.html.haml
@@ -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
diff --git a/app/views/projects/merge_requests/_show.html.haml b/app/views/projects/merge_requests/_show.html.haml
index 0e14b9d4bf4..2a7cd3a19d0 100644
--- a/app/views/projects/merge_requests/_show.html.haml
+++ b/app/views/projects/merge_requests/_show.html.haml
@@ -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) }
diff --git a/app/views/projects/merge_requests/conflicts.html.haml b/app/views/projects/merge_requests/conflicts.html.haml
index b8b87dcdcaf..ebef2157d34 100644
--- a/app/views/projects/merge_requests/conflicts.html.haml
+++ b/app/views/projects/merge_requests/conflicts.html.haml
@@ -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"
diff --git a/app/views/shared/icons/_icon_stopwatch.svg b/app/views/shared/icons/_icon_stopwatch.svg
new file mode 100644
index 00000000000..f20de04538e
--- /dev/null
+++ b/app/views/shared/icons/_icon_stopwatch.svg
@@ -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> \ No newline at end of file
diff --git a/app/views/shared/issuable/_sidebar.html.haml b/app/views/shared/issuable/_sidebar.html.haml
index a02b815e3cd..ec9bcaf63dd 100644
--- a/app/views/shared/issuable/_sidebar.html.haml
+++ b/app/views/shared/issuable/_sidebar.html.haml
@@ -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]))}');