Commit 4d55bca8 authored by Fatih Acet's avatar Fatih Acet

Merge branch 'timetracking-1.1' into 'master'

Implement Timetracking v1.1


- [x] Break issuable_time_tracking component into smaller components and bundle them
- [x]  Improve the help state UX to look like this: https://gitlab.com/gitlab-org/gitlab-ee/issues/985#note_16056031
- [x] Create helpers for props existence checking  https://gitlab.com/gitlab-org/gitlab-ee/merge_requests/870#note_18534273
- [x] Standardize on camelCase where possible, improve naming with computed values
- [x] Be clear about using human values from the server vs client 
- [x] Address the accessibility impact of only displaying the remaining time in a tooltip and the percentage of time spent in a colored meter.
- [x] Fix help 'Learn more' URL
- [x] Remove polling until the rest of the sidebar can be synced properly

cc: @jschatz1 

Closes https://gitlab.com/gitlab-org/gitlab-ee/issues/1263

See merge request !901
parents 4c7aaa0f f8d183ad
//= require ./time_tracking/time_tracking_bundle
/* 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>
`,
});
})();
/* 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>
`,
});
})();
/* 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>
`,
});
})();
/* 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>
`,
});
})();
/* 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>
`,
});
})();
/* 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>
`,
});
})();
/* 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>
`,
});
})();
/* eslint-disable */ /* global Vue */
//= require vue //= require ./components/time_tracker
//= require issuable_time_tracker
//= require smart_interval //= require smart_interval
//= require subbable_resource //= require subbable_resource
(() => { (() => {
/* This Vue instance represents what will become the parent instance for the /* This Vue instance represents what will become the parent instance for the
* sidebar. It will be responsible for managing `issuable` state and propagating * sidebar. It will be responsible for managing `issuable` state and propagating
* changes to sidebar components. * changes to sidebar components. We will want to create a separate service to
* interface with the server at that point.
*/ */
class IssuableTimeTracking { class IssuableTimeTracking {
...@@ -29,15 +29,6 @@ ...@@ -29,15 +29,6 @@
url: gl.IssuableResource.endpoint, url: gl.IssuableResource.endpoint,
}); });
}, },
initPolling() {
return new gl.SmartInterval({
callback: this.fetchIssuable,
startingInterval: 1000,
maxInterval: 10000,
incrementByFactorOf: 10,
lazyStart: false,
});
},
updateState(data) { updateState(data) {
this.issuable = data; this.issuable = data;
}, },
...@@ -59,7 +50,6 @@ ...@@ -59,7 +50,6 @@
this.fetchIssuable(); this.fetchIssuable();
}, },
mounted() { mounted() {
this.initPolling();
this.subscribeToUpdates(); this.subscribeToUpdates();
this.listenForSlashCommands(); this.listenForSlashCommands();
}, },
......
/* eslint-disable */
//= require vue
//= require lib/utils/pretty_time
(() => {
const PrettyTime = gl.PrettyTime;
gl.IssuableTimeTracker = Vue.component('issuable-time-tracker', {
name: 'issuable-time-tracker',
props: ['time_estimate', 'time_spent', 'human_time_estimate', 'human_time_spent'],
data() {
return {
displayHelp: false,
};
},
computed: {
/* Select panels to show */
showComparison() {
return !!this.time_estimate && !!this.time_spent;
},
showEstimateOnly() {
return !!this.time_estimate && !this.time_spent;
},
showSpentOnly() {
return !!this.time_spent && !this.time_estimate;
},
showNoTimeTracking() {
return !this.time_estimate && !this.time_spent;
},
showHelp() {
return !!this.displayHelp;
},
/* Parsed time values */
parsedEstimate() {
return PrettyTime.parseSeconds(this.time_estimate);
},
parsedSpent() {
return PrettyTime.parseSeconds(this.time_spent);
},
parsedRemaining() {
const diffSeconds = this.time_estimate - this.time_spent;
return PrettyTime.parseSeconds(diffSeconds);
},
/* Human readable time values */
estimatedPretty() {
return this.human_time_estimate || PrettyTime.stringifyTime(this.parsedEstimate);
},
spentPretty() {
return this.human_time_spent || PrettyTime.stringifyTime(this.parsedSpent);
},
remainingPretty() {
return PrettyTime.stringifyTime(this.parsedRemaining);
},
remainingTooltipPretty() {
const prefix = this.diffMinutes < 0 ? 'Over by' : 'Time remaining:';
return `${prefix} ${this.remainingPretty}`;
},
/* Diff values for comparison meter */
diffMinutes() {
return this.time_estimate - this.time_spent;
},
diffPercent() {
return `${Math.floor(((this.time_spent / this.time_estimate) * 100))}%`;
},
diffStatusClass() {
return this.time_estimate >= this.time_spent ? 'within_estimate' : 'over_estimate';
},
},
methods: {
toggleHelpState(show) {
this.displayHelp = show;
},
abbreviateTime(timeStr) {
return PrettyTime.abbreviateTime(timeStr);
},
},
template: `
<div class='time_tracker time-tracking-component-wrap' v-cloak>
<div class='sidebar-collapsed-icon'>
<slot name='stopwatch'></slot>
<div class='time-tracking-collapsed-summary'>
<div class='compare' v-if='showComparison'>
<span>{{ abbreviateTime(spentPretty) }} / {{ abbreviateTime(estimatedPretty) }}</span>
</div>
<div class='estimate-only' v-if='showEstimateOnly'>
<span class='bold'>-- / {{ abbreviateTime(estimatedPretty) }}</span>
</div>
<div class='spend-only' v-if='showSpentOnly'>
<span class='bold'>{{ abbreviateTime(spentPretty) }} / --</span>
</div>
<div class='no-tracking' v-if='showNoTimeTracking'>
<span class='no-value'>None</span>
</div>
</div>
</div>
<div class='title hide-collapsed'>
Time tracking
<div class='help-button pull-right' v-if='!showHelp' @click='toggleHelpState(true)'>
<i class='fa fa-question-circle'></i>
</div>
</div>
<div class='time-tracking-content hide-collapsed'>
<div class='time-tracking-pane-compare' v-if='showComparison'>
<div class='compare-meter' data-toggle='tooltip' data-placement='top' :title='remainingTooltipPretty' :data-original-title='remainingTooltipPretty' :class='diffStatusClass' >
<div class='meter-container'>
<div :style='{ width: diffPercent }' class='meter-fill'></div>
</div>
<div class='compare-display-container'>
<div class='compare-display pull-left'>
<span class='compare-label'>Spent</span>
<span class='compare-value spent'>{{ spentPretty }}</span>
</div>
<div class='compare-display estimated pull-right'>
<span class='compare-label'>Est</span>
<span class='compare-value'>{{ estimatedPretty }}</span>
</div>
</div>
</div>
</div>
<div class='time-tracking-estimate-only' v-if='showEstimateOnly'>
<span class='bold'>Estimated:</span>
{{ estimatedPretty }}
</div>
<div class='time-tracking-spend-only' v-if='showSpentOnly'>
<span class='bold'>Spent:</span>
{{ spentPretty }}
</div>
<div class='time-tracking-no-tracking' v-if='showNoTimeTracking'>
<span class='no-value'>No estimate or time spent</span>
</div>
<div class='time-tracking-help-state' v-if='showHelp'>
<div class='close-help-button pull-right' @click='toggleHelpState(false)'>
<i class='fa fa-close'></i>
</div>
<div class='time-tracking-info'>
<h4>Track time with slash commands</h4>
<p>Slash commands can be used in the issues description and comment boxes.</p>
<p>
<code>/estimate</code>
will update the estimated time with the latest command.
</p>
<p>
<code>/spend</code>
will update the sum of the time spent.
</p>
<a class='btn btn-default learn-more-button' href='https://docs.gitlab.com/ee/workflow/time_tracking.html'>Learn more</a>
</div>
</div>
</div>
</div>
`,
});
})(window.gl || (window.gl = {}));
...@@ -4,13 +4,13 @@ ...@@ -4,13 +4,13 @@
* stringifyTime condensed or non-condensed, abbreviateTimelengths) * 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: # } * Accepts seconds and returns a timeObject { weeks: #, days: #, hours: #, minutes: # }
* Seconds can be negative or positive, zero or non-zero. * Seconds can be negative or positive, zero or non-zero.
*/ */
static parseSeconds(seconds) { parseSeconds(seconds) {
const DAYS_PER_WEEK = 5; const DAYS_PER_WEEK = 5;
const HOURS_PER_DAY = 8; const HOURS_PER_DAY = 8;
const MINUTES_PER_HOUR = 60; const MINUTES_PER_HOUR = 60;
...@@ -24,7 +24,7 @@ ...@@ -24,7 +24,7 @@
minutes: 1, minutes: 1,
}; };
let unorderedMinutes = PrettyTime.secondsToMinutes(seconds); let unorderedMinutes = prettyTime.secondsToMinutes(seconds);
return _.mapObject(timePeriodConstraints, (minutesPerPeriod) => { return _.mapObject(timePeriodConstraints, (minutesPerPeriod) => {
const periodCount = Math.floor(unorderedMinutes / minutesPerPeriod); const periodCount = Math.floor(unorderedMinutes / minutesPerPeriod);
...@@ -33,35 +33,33 @@ ...@@ -33,35 +33,33 @@
return periodCount; return periodCount;
}); });
} },
/* /*
* Accepts a timeObject and returns a condensed string representation of it * 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. * (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 reducedTime = _.reduce(timeObject, (memo, unitValue, unitName) => {
const isNonZero = !!unitValue; const isNonZero = !!unitValue;
return isNonZero ? `${memo} ${unitValue}${unitName.charAt(0)}` : memo; return isNonZero ? `${memo} ${unitValue}${unitName.charAt(0)}` : memo;
}, '').trim(); }, '').trim();
return reducedTime.length ? reducedTime : '0m'; return reducedTime.length ? reducedTime : '0m';
} },
/* /*
* Accepts a time string of any size (e.g. '1w 2d 3h 5m' or '1w 2d') and returns * 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. * the first non-zero unit/value pair.
*/ */
static abbreviateTime(timeStr) { abbreviateTime(timeStr) {
return timeStr.split(' ') return timeStr.split(' ')
.filter(unitStr => unitStr.charAt(0) !== '0')[0]; .filter(unitStr => unitStr.charAt(0) !== '0')[0];
} },
static secondsToMinutes(seconds) { secondsToMinutes(seconds) {
return Math.abs(seconds / 60); return Math.abs(seconds / 60);
} },
} };
gl.PrettyTime = PrettyTime;
})(window.gl || (window.gl = {})); })(window.gl || (window.gl = {}));
//= require vue
//= require vue-resource
//= require vue
//= require vue-resource
(() => { (() => {
/* /*
* SubbableResource can be extended to provide a pubsub-style service for one-off REST * SubbableResource can be extended to provide a pubsub-style service for one-off REST
......
...@@ -498,4 +498,17 @@ ...@@ -498,4 +498,17 @@
color: $btn-white-active; 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;
}
} }
- page_title "#{@issue.title} (#{@issue.to_reference})", "Issues" - page_title "#{@issue.title} (#{@issue.to_reference})", "Issues"
- page_description @issue.description - page_description @issue.description
- page_card_attributes @issue.card_attributes - page_card_attributes @issue.card_attributes
- content_for :page_specific_javascripts do
= page_specific_javascript_tag('lib/vue_resource.js')
.clearfix.detail-page-header .clearfix.detail-page-header
.issuable-header .issuable-header
......
...@@ -2,6 +2,7 @@ ...@@ -2,6 +2,7 @@
- page_description @merge_request.description - page_description @merge_request.description
- page_card_attributes @merge_request.card_attributes - page_card_attributes @merge_request.card_attributes
- content_for :page_specific_javascripts do - content_for :page_specific_javascripts do
= page_specific_javascript_tag('lib/vue_resource.js')
= page_specific_javascript_tag('diff_notes/diff_notes_bundle.js') = page_specific_javascript_tag('diff_notes/diff_notes_bundle.js')
.merge-request{'data-url' => merge_request_path(@merge_request)} .merge-request{'data-url' => merge_request_path(@merge_request)}
......
- page_title "Merge Conflicts", "#{@merge_request.title} (#{@merge_request.to_reference}", "Merge Requests" - page_title "Merge Conflicts", "#{@merge_request.title} (#{@merge_request.to_reference}", "Merge Requests"
- content_for :page_specific_javascripts do - 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('merge_conflicts/merge_conflicts_bundle.js')
= page_specific_javascript_tag('lib/ace.js') = page_specific_javascript_tag('lib/ace.js')
= render "projects/merge_requests/show/mr_title" = render "projects/merge_requests/show/mr_title"
......
- todo = issuable_todo(issuable) - todo = issuable_todo(issuable)
- 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' } %aside.right-sidebar{ class: sidebar_gutter_collapsed_class, 'aria-live' => 'polite' }
.issuable-sidebar .issuable-sidebar
- can_edit_issuable = can?(current_user, :"admin_#{issuable.to_ability_name}", @project) - can_edit_issuable = can?(current_user, :"admin_#{issuable.to_ability_name}", @project)
...@@ -74,15 +76,11 @@ ...@@ -74,15 +76,11 @@
= 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 }}) = 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) - if issuable.has_attribute?(:time_estimate)
#issuable-time-tracker.block #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' } %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 // Fallback while content is loading
.title.hide-collapsed .title.hide-collapsed
Time tracking Time tracking
= icon('spinner spin') = icon('spinner spin')
// TODO: Remove this when we have webpack/svg solution implemented
.stopwatch-svg{ slot:'stopwatch' }
%span.hide
= custom_icon('icon_stopwatch')
- if issuable.has_attribute?(:due_date) - if issuable.has_attribute?(:due_date)
.block.due_date .block.due_date
.sidebar-collapsed-icon .sidebar-collapsed-icon
......
...@@ -88,12 +88,14 @@ module Gitlab ...@@ -88,12 +88,14 @@ module Gitlab
config.assets.precompile << "print.css" config.assets.precompile << "print.css"
config.assets.precompile << "notify.css" config.assets.precompile << "notify.css"
config.assets.precompile << "mailers/*.css" config.assets.precompile << "mailers/*.css"
config.assets.precompile << "lib/vue_resource.js"
config.assets.precompile << "graphs/graphs_bundle.js" config.assets.precompile << "graphs/graphs_bundle.js"
config.assets.precompile << "users/users_bundle.js" config.assets.precompile << "users/users_bundle.js"
config.assets.precompile << "network/network_bundle.js" config.assets.precompile << "network/network_bundle.js"
config.assets.precompile << "profile/profile_bundle.js" config.assets.precompile << "profile/profile_bundle.js"
config.assets.precompile << "protected_branches/protected_branches_bundle.js" config.assets.precompile << "protected_branches/protected_branches_bundle.js"
config.assets.precompile << "diff_notes/diff_notes_bundle.js" config.assets.precompile << "diff_notes/diff_notes_bundle.js"
config.assets.precompile << "issuable/issuable_bundle.js"
config.assets.precompile << "boards/boards_bundle.js" config.assets.precompile << "boards/boards_bundle.js"
config.assets.precompile << "cycle_analytics/cycle_analytics_bundle.js" config.assets.precompile << "cycle_analytics/cycle_analytics_bundle.js"
config.assets.precompile << "merge_conflicts/merge_conflicts_bundle.js" config.assets.precompile << "merge_conflicts/merge_conflicts_bundle.js"
......
/* eslint-disable */ /* eslint-disable */
//= require jquery //= require jquery
//= require vue //= require vue
//= require issuable_time_tracker //= require issuable/time_tracking/components/time_tracker
function initComponent(time_estimate = 100000, time_spent = 5000 ) { function initTimeTrackingComponent(opts) {
fixture.set(` fixture.set(`
<div> <div>
<div id="mock-container"></div> <div id="mock-container"></div>
...@@ -11,13 +11,17 @@ function initComponent(time_estimate = 100000, time_spent = 5000 ) { ...@@ -11,13 +11,17 @@ function initComponent(time_estimate = 100000, time_spent = 5000 ) {
`); `);
this.initialData = { this.initialData = {
time_estimate, time_estimate: opts.timeEstimate,
time_spent time_spent: opts.timeSpent,
human_time_estimate: opts.timeEstimateHumanReadable,
human_time_spent: opts.timeSpentHumanReadable,
docsUrl: '/help/workflow/time_tracking.md',
}; };
this.timeTracker = new gl.IssuableTimeTracker({ const TimeTrackingComponent = Vue.component('issuable-time-tracker');
this.timeTracker = new TimeTrackingComponent({
el: '#mock-container', el: '#mock-container',
propsData: this.initialData propsData: this.initialData,
}); });
} }
...@@ -25,18 +29,24 @@ function initComponent(time_estimate = 100000, time_spent = 5000 ) { ...@@ -25,18 +29,24 @@ function initComponent(time_estimate = 100000, time_spent = 5000 ) {
describe('Issuable Time Tracker', function() { describe('Issuable Time Tracker', function() {
describe('Initialization', function() { describe('Initialization', function() {
beforeEach(function() { beforeEach(function() {
initComponent.apply(this); initTimeTrackingComponent.call(this, { timeEstimate: 100000, timeSpent: 5000, timeEstimateHumanReadable: '2h 46m', timeSpentHumanReadable: '1h 23m' });
}); });
it('should return something defined', function() { it('should return something defined', function() {
expect(this.timeTracker).toBeDefined(); expect(this.timeTracker).toBeDefined();
}); });
it ('should correctly set time_estimate', function() { it ('should correctly set timeEstimate', function(done) {
expect(this.timeTracker.time_estimate).toBe(this.initialData.time_estimate); 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();
}); });
it ('should correctly set time_spent', function() {
expect(this.timeTracker.time_spent).toBe(this.initialData.time_spent);
}); });
}); });
...@@ -44,49 +54,43 @@ function initComponent(time_estimate = 100000, time_spent = 5000 ) { ...@@ -44,49 +54,43 @@ function initComponent(time_estimate = 100000, time_spent = 5000 ) {
describe('Panes', function() { describe('Panes', function() {
describe('Comparison pane', function() { describe('Comparison pane', function() {
beforeEach(function() { beforeEach(function() {
initComponent.apply(this); initTimeTrackingComponent.call(this, { timeEstimate: 100000, timeSpent: 5000, timeEstimateHumanReadable: '', timeSpentHumanReadable: '' });
});
it('should show the "Comparison" pane when time_estimate and time_spent are truthy', function() {
const $comparisonPane = this.timeTracker.$el.querySelector('.time-tracking-pane-compare');
expect(this.timeTracker.showComparison).toBe(true);
expect($comparisonPane).toBeVisible();
}); });
it('should display the human readable version of time estimated', function() { it('should show the "Comparison" pane when timeEstimate and time_spent are truthy', function(done) {
const estimateText = this.timeTracker.$el.querySelector('.time-tracking-pane-compare .estimated .compare-value').innerText; Vue.nextTick(() => {
const correctText = '3d 3h 46m'; const $comparisonPane = this.timeTracker.$el.querySelector('.time-tracking-comparison-pane');
expect(this.timeTracker.showComparisonState).toBe(true);
expect(estimateText).toBe(correctText); done();
}); });
it('should display the human readable version of time spent', function() {
const spentText = this.timeTracker.$el.querySelector('.time-tracking-pane-compare .compare-value.spent').innerText;
const correctText = '1h 23m';
expect(spentText).toBe(correctText);
}); });
describe('Remaining meter', function() { describe('Remaining meter', function() {
it('should display the remaining meter with the correct width', function() { it('should display the remaining meter with the correct width', function(done) {
const meterWidth = this.timeTracker.$el.querySelector('.time-tracking-pane-compare .meter-fill').style.width; Vue.nextTick(() => {
const meterWidth = this.timeTracker.$el.querySelector('.time-tracking-comparison-pane .meter-fill').style.width;
const correctWidth = '5%'; const correctWidth = '5%';
expect(meterWidth).toBe(correctWidth); expect(meterWidth).toBe(correctWidth);
done();
})
}); });
it('should display the remaining meter with the correct background color when within estimate', function() { it('should display the remaining meter with the correct background color when within estimate', function(done) {
const styledMeter = $(this.timeTracker.$el).find('.time-tracking-pane-compare .within_estimate .meter-fill'); Vue.nextTick(() => {
const styledMeter = $(this.timeTracker.$el).find('.time-tracking-comparison-pane .within_estimate .meter-fill');
expect(styledMeter.length).toBe(1); expect(styledMeter.length).toBe(1);
done()
});
}); });
it('should display the remaining meter with the correct background color when over estimate', function() { it('should display the remaining meter with the correct background color when over estimate', function(done) {
this.timeTracker.time_estimate = 1; this.timeTracker.time_estimate = 100000;
this.timeTracker.time_spent = 2; this.timeTracker.time_spent = 20000000;
Vue.nextTick(() => { Vue.nextTick(() => {
const styledMeter = $(this.timeTracker.$el).find('.time-tracking-pane-compare .over_estimate .meter-fill'); const styledMeter = $(this.timeTracker.$el).find('.time-tracking-comparison-pane .over_estimate .meter-fill');
expect(styledMeter.length).toBe(1); expect(styledMeter.length).toBe(1);
done();
}); });
}); });
}); });
...@@ -94,116 +98,100 @@ function initComponent(time_estimate = 100000, time_spent = 5000 ) { ...@@ -94,116 +98,100 @@ function initComponent(time_estimate = 100000, time_spent = 5000 ) {
describe("Estimate only pane", function() { describe("Estimate only pane", function() {
beforeEach(function() { beforeEach(function() {
initComponent.apply(this, [10000, 0]); initTimeTrackingComponent.call(this, { timeEstimate: 100000, timeSpent: 0, timeEstimateHumanReadable: '2h 46m', timeSpentHumanReadable: '' });
}); });
it('should only show the "Estimate only" pane when time_estimate is truthy and time_spent is falsey', function() { it('should display the human readable version of time estimated', function(done) {
const $estimateOnlyPane = this.timeTracker.$el.querySelector('.time-tracking-estimate-only'); Vue.nextTick(() => {
const estimateText = this.timeTracker.$el.querySelector('.time-tracking-estimate-only-pane').innerText;
expect(this.timeTracker.showEstimateOnly).toBe(true);
expect($estimateOnlyPane).toBeVisible();
});
it('should display the human readable version of time estimated', function() {
const estimateText = this.timeTracker.$el.querySelector('.time-tracking-estimate-only').innerText;
const correctText = 'Estimated: 2h 46m'; const correctText = 'Estimated: 2h 46m';
expect(estimateText).toBe(correctText); expect(estimateText).toBe(correctText);
done();
});
}); });
}); });
describe('Spent only pane', function() { describe('Spent only pane', function() {
beforeEach(function() { beforeEach(function() {
initComponent.apply(this, [0, 5000]); initTimeTrackingComponent.call(this, { timeEstimate: 0, timeSpent: 5000, timeEstimateHumanReadable: '2h 46m', timeSpentHumanReadable: '1h 23m' });
}); });
// Look for the value
it('should only show the "Spent only" pane when time_estimate is falsey and time_spent is truthy', function() {
const $spentOnlyPane = this.timeTracker.$el.querySelector('.time-tracking-spend-only');
expect(this.timeTracker.showSpentOnly).toBe(true); it('should display the human readable version of time spent', function(done) {
expect($spentOnlyPane).toBeVisible(); Vue.nextTick(() => {
}); const spentText = this.timeTracker.$el.querySelector('.time-tracking-spend-only-pane').innerText;
it('should display the human readable version of time spent', function() {
const spentText = this.timeTracker.$el.querySelector('.time-tracking-spend-only').innerText;
const correctText = 'Spent: 1h 23m'; const correctText = 'Spent: 1h 23m';
expect(spentText).toBe(correctText); expect(spentText).toBe(correctText);
done();
});
}); });
}); });
describe('No time tracking pane', function() { describe('No time tracking pane', function() {
beforeEach(function() { beforeEach(function() {
initComponent.apply(this, [0, 0]); initTimeTrackingComponent.call(this, { timeEstimate: 0, timeSpent: 0, timeEstimateHumanReadable: 0, timeSpentHumanReadable: 0 });
});
it('should only show the "No time tracking" pane when both time_estimate and time_spent are falsey', function() {
const $noTrackingPane = this.timeTracker.$el.querySelector('.time-tracking-no-tracking');
expect(this.timeTracker.showNoTimeTracking).toBe(true);
expect($noTrackingPane).toBeVisible();
}); });
it('should display the status text', function() { it('should only show the "No time tracking" pane when both timeEstimate and time_spent are falsey', function(done) {
const noTrackingText = this.timeTracker.$el.querySelector('.time-tracking-no-tracking .no-value').innerText; Vue.nextTick(() => {
const $noTrackingPane = this.timeTracker.$el.querySelector('.time-tracking-no-tracking-pane');
const noTrackingText =$noTrackingPane.innerText;
const correctText = 'No estimate or time spent'; const correctText = 'No estimate or time spent';
expect(this.timeTracker.showNoTimeTrackingState).toBe(true);
expect($noTrackingPane).toBeVisible();
expect(noTrackingText).toBe(correctText); expect(noTrackingText).toBe(correctText);
done();
});
}); });
}); });
describe("Help pane", function() { describe("Help pane", function() {
beforeEach(function() { beforeEach(function() {
initComponent.apply(this, [0, 0]); initTimeTrackingComponent.call(this, { timeEstimate: 0, timeSpent: 0 });
}); });
it('should not show the "Help" pane by default', function() { it('should not show the "Help" pane by default', function(done) {
Vue.nextTick(() => {
const $helpPane = this.timeTracker.$el.querySelector('.time-tracking-help-state'); const $helpPane = this.timeTracker.$el.querySelector('.time-tracking-help-state');
expect(this.timeTracker.showHelp).toBe(false); expect(this.timeTracker.showHelpState).toBe(false);
expect($helpPane).toBeNull(); expect($helpPane).toBeNull();
});
it('should link to the correct documentation', function(done) {
const correctUrl = 'https://docs.gitlab.com/ee/workflow/time_tracking.html';
$(this.timeTracker.$el).find('.help-button').click();
Vue.nextTick(() => {
const currentHref = $(this.timeTracker.$el).find('.learn-more-button').attr('href');
expect(currentHref).toBe(correctUrl);
done(); done();
}); });
}); });
it('should show the "Help" pane when help button is clicked', function(done) { it('should show the "Help" pane when help button is clicked', function(done) {
Vue.nextTick(() => {
$(this.timeTracker.$el).find('.help-button').click(); $(this.timeTracker.$el).find('.help-button').click();
Vue.nextTick(() => { setTimeout(() => {
const $helpPane = this.timeTracker.$el.querySelector('.time-tracking-help-state'); const $helpPane = this.timeTracker.$el.querySelector('.time-tracking-help-state');
expect(this.timeTracker.showHelp).toBe(true); expect(this.timeTracker.showHelpState).toBe(true);
expect($helpPane).toBeVisible(); expect($helpPane).toBeVisible();
done(); done();
}, 10);
}); });
}); });
it('should not show the "Help" pane when help button is clicked and then closed', function(done) { 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(); $(this.timeTracker.$el).find('.help-button').click();
Vue.nextTick(() => { setTimeout(() => {
$(this.timeTracker.$el).find('.close-help-button').click(); $(this.timeTracker.$el).find('.close-help-button').click();
Vue.nextTick(() => { setTimeout(() => {
const $helpPane = this.timeTracker.$el.querySelector('.time-tracking-help-state'); const $helpPane = this.timeTracker.$el.querySelector('.time-tracking-help-state');
expect(this.timeTracker.showHelp).toBe(false); expect(this.timeTracker.showHelpState).toBe(false);
expect($helpPane).toBeNull(); expect($helpPane).toBeNull();
done(); done();
}); }, 1000);
}, 1000);
}); });
}); });
}); });
......
//= require lib/utils/pretty_time //= 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 () { describe('parseSeconds', function () {
it('should correctly parse a negative value', function () { it('should correctly parse a negative value', function () {
const parser = PrettyTime.parseSeconds; const parser = prettyTime.parseSeconds;
const zeroSeconds = parser(-1000); const zeroSeconds = parser(-1000);
...@@ -17,7 +17,7 @@ ...@@ -17,7 +17,7 @@
}); });
it('should correctly parse a zero value', function () { it('should correctly parse a zero value', function () {
const parser = PrettyTime.parseSeconds; const parser = prettyTime.parseSeconds;
const zeroSeconds = parser(0); const zeroSeconds = parser(0);
...@@ -28,7 +28,7 @@ ...@@ -28,7 +28,7 @@
}); });
it('should correctly parse a small non-zero second values', function () { it('should correctly parse a small non-zero second values', function () {
const parser = PrettyTime.parseSeconds; const parser = prettyTime.parseSeconds;
const subOneMinute = parser(10); const subOneMinute = parser(10);
...@@ -53,7 +53,7 @@ ...@@ -53,7 +53,7 @@
}); });
it('should correctly parse large second values', function () { it('should correctly parse large second values', function () {
const parser = PrettyTime.parseSeconds; const parser = prettyTime.parseSeconds;
const aboveOneHour = parser(4800); const aboveOneHour = parser(4800);
...@@ -87,7 +87,7 @@ ...@@ -87,7 +87,7 @@
minutes: 20, minutes: 20,
}; };
const timeString = PrettyTime.stringifyTime(timeObject); const timeString = prettyTime.stringifyTime(timeObject);
expect(timeString).toBe('1w 4d 7h 20m'); expect(timeString).toBe('1w 4d 7h 20m');
}); });
...@@ -100,7 +100,7 @@ ...@@ -100,7 +100,7 @@
minutes: 20, minutes: 20,
}; };
const timeString = PrettyTime.stringifyTime(timeObject); const timeString = prettyTime.stringifyTime(timeObject);
expect(timeString).toBe('4d 20m'); expect(timeString).toBe('4d 20m');
}); });
...@@ -113,7 +113,7 @@ ...@@ -113,7 +113,7 @@
minutes: 0, minutes: 0,
}; };
const timeString = PrettyTime.stringifyTime(timeObject); const timeString = prettyTime.stringifyTime(timeObject);
expect(timeString).toBe('0m'); expect(timeString).toBe('0m');
}); });
...@@ -122,12 +122,12 @@ ...@@ -122,12 +122,12 @@
describe('abbreviateTime', function () { describe('abbreviateTime', function () {
it('should abbreviate stringified times for weeks', function () { it('should abbreviate stringified times for weeks', function () {
const fullTimeString = '1w 3d 4h 5m'; 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 () { it('should abbreviate stringified times for non-weeks', function () {
const fullTimeString = '0w 3d 4h 5m'; const fullTimeString = '0w 3d 4h 5m';
expect(PrettyTime.abbreviateTime(fullTimeString)).toBe('3d'); expect(prettyTime.abbreviateTime(fullTimeString)).toBe('3d');
}); });
}); });
}); });
......
shared_examples 'issuable time tracker' do shared_examples 'issuable time tracker' do
it 'renders the sidebar component empty state' do it 'renders the sidebar component empty state' do
page.within '.issuable-sidebar' do page.within '.time-tracking-no-tracking-pane' do
expect(page).to have_content 'No estimate or time spent' expect(page).to have_content 'No estimate or time spent'
end end
end end
...@@ -8,7 +8,7 @@ shared_examples 'issuable time tracker' do ...@@ -8,7 +8,7 @@ shared_examples 'issuable time tracker' do
it 'updates the sidebar component when estimate is added' do it 'updates the sidebar component when estimate is added' do
submit_time('/estimate 3w 1d 1h') submit_time('/estimate 3w 1d 1h')
page.within '.time-tracking-estimate-only' do page.within '.time-tracking-estimate-only-pane' do
expect(page).to have_content '3w 1d 1h' expect(page).to have_content '3w 1d 1h'
end end
end end
...@@ -16,7 +16,7 @@ shared_examples 'issuable time tracker' do ...@@ -16,7 +16,7 @@ shared_examples 'issuable time tracker' do
it 'updates the sidebar component when spent is added' do it 'updates the sidebar component when spent is added' do
submit_time('/spend 3w 1d 1h') submit_time('/spend 3w 1d 1h')
page.within '.time-tracking-spend-only' do page.within '.time-tracking-spend-only-pane' do
expect(page).to have_content '3w 1d 1h' expect(page).to have_content '3w 1d 1h'
end end
end end
...@@ -25,7 +25,7 @@ shared_examples 'issuable time tracker' do ...@@ -25,7 +25,7 @@ shared_examples 'issuable time tracker' do
submit_time('/estimate 3w 1d 1h') submit_time('/estimate 3w 1d 1h')
submit_time('/spend 3w 1d 1h') submit_time('/spend 3w 1d 1h')
page.within '.time-tracking-pane-compare' do page.within '.time-tracking-comparison-pane' do
expect(page).to have_content '3w 1d 1h' expect(page).to have_content '3w 1d 1h'
end end
end end
...@@ -65,6 +65,14 @@ shared_examples 'issuable time tracker' do ...@@ -65,6 +65,14 @@ shared_examples 'issuable time tracker' do
expect(page).not_to have_content 'Learn more' expect(page).not_to have_content 'Learn more'
end end
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 end
def submit_time(slash_command) def submit_time(slash_command)
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment