diff options
Diffstat (limited to 'app')
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]))}'); |