Commit 64dd41a0 authored by Bryce Johnson's avatar Bryce Johnson Committed by Ruben Davila

Backport timetracking frontend to CE.

parent f1bd9f05
...@@ -2,8 +2,6 @@ ...@@ -2,8 +2,6 @@
/* global Vue */ /* global Vue */
/* global ResolveCount */ /* global ResolveCount */
//= require vue
//= require vue-resource
//= require_directory ./models //= require_directory ./models
//= require_directory ./stores //= require_directory ./stores
//= require_directory ./services //= require_directory ./services
......
//= 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>
`,
});
})();
/* 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 = {}));
...@@ -5,7 +5,7 @@ $sidebar_collapsed_width: 62px; ...@@ -5,7 +5,7 @@ $sidebar_collapsed_width: 62px;
$sidebar_width: 220px; $sidebar_width: 220px;
$gutter_collapsed_width: 62px; $gutter_collapsed_width: 62px;
$gutter_width: 290px; $gutter_width: 290px;
$gutter_inner_width: 258px; $gutter_inner_width: 250px;
$sidebar-transition-duration: .15s; $sidebar-transition-duration: .15s;
$sidebar-breakpoint: 1024px; $sidebar-breakpoint: 1024px;
...@@ -85,6 +85,7 @@ $warning-message-border: #f0e2bb; ...@@ -85,6 +85,7 @@ $warning-message-border: #f0e2bb;
*/ */
$border-color: #e5e5e5; $border-color: #e5e5e5;
$focus-border-color: #3aabf0; $focus-border-color: #3aabf0;
$sidebar-collapsed-icon-color: #999;
$well-expand-item: #e8f2f7; $well-expand-item: #e8f2f7;
$well-inner-border: #eef0f2; $well-inner-border: #eef0f2;
$well-light-border: #f1f1f1; $well-light-border: #f1f1f1;
......
...@@ -469,3 +469,102 @@ ...@@ -469,3 +469,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-gray;
}
}
.help-button,
.close-help-button {
cursor: pointer;
}
.compare-meter {
&.within_estimate {
.meter-fill {
background: $gl-primary;
}
}
&.over_estimate {
.meter-fill {
background: $red-light;
}
.time-remaining,
.compare-value.spent {
color: $red-light;
}
}
}
.meter-container {
background: $border-gray-light;
border-radius: 3px;
.meter-fill {
max-width: 100%;
height: 5px;
border-radius: 3px;
background: $gl-primary;
}
}
.compare-display-container {
display: flex;
justify-content: space-between;
margin-top: 5px;
.compare-display {
font-size: 13px;
color: $gl-gray-light;
.compare-value {
color: $gl-gray;
}
}
}
.time-tracking-help-state {
background: $white-light;
margin: 16px -20px 0;
padding: 16px 20px;
border-top: 1px solid $border-gray-light;
border-bottom: 1px solid $border-gray-light;
a:hover {
color: $btn-white-active;
}
}
.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;
}
}
...@@ -2,6 +2,8 @@ ...@@ -2,6 +2,8 @@
- 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
......
...@@ -3,6 +3,7 @@ ...@@ -3,6 +3,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"
......
<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
- todo = issuable_todo(issuable) - 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 .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)
.block.issuable-sidebar-header .block.issuable-sidebar-header
...@@ -72,7 +74,13 @@ ...@@ -72,7 +74,13 @@
.selectbox.hide-collapsed .selectbox.hide-collapsed
= f.hidden_field 'milestone_id', value: issuable.milestone_id, id: nil = 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 }}) = 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) - if issuable.has_attribute?(:due_date)
.block.due_date .block.due_date
.sidebar-collapsed-icon .sidebar-collapsed-icon
...@@ -162,6 +170,8 @@ ...@@ -162,6 +170,8 @@
= clipboard_button(clipboard_text: project_ref, title: "Copy reference to clipboard", placement: "left") = clipboard_button(clipboard_text: project_ref, title: "Copy reference to clipboard", placement: "left")
:javascript :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 MilestoneSelect('{"namespace":"#{@project.namespace.path}","path":"#{@project.path}"}');
new LabelsSelect(); new LabelsSelect();
new IssuableContext('#{escape_javascript(current_user.to_json(only: [:username, :id, :name]))}'); new IssuableContext('#{escape_javascript(current_user.to_json(only: [:username, :id, :name]))}');
......
...@@ -88,6 +88,7 @@ module Gitlab ...@@ -88,6 +88,7 @@ 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 << "katex.css" config.assets.precompile << "katex.css"
config.assets.precompile << "katex.js" config.assets.precompile << "katex.js"
config.assets.precompile << "xterm/xterm.css" config.assets.precompile << "xterm/xterm.css"
...@@ -98,6 +99,7 @@ module Gitlab ...@@ -98,6 +99,7 @@ module Gitlab
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 << "merge_request_widget/ci_bundle.js" config.assets.precompile << "merge_request_widget/ci_bundle.js"
config.assets.precompile << "issuable/issuable_bundle.js"
config.assets.precompile << "boards/boards_bundle.js" config.assets.precompile << "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"
......
...@@ -100,6 +100,32 @@ feature 'Issues > User uses slash commands', feature: true, js: true do ...@@ -100,6 +100,32 @@ feature 'Issues > User uses slash commands', feature: true, js: true do
end end
end end
describe 'Issuable time tracking' do
let(:issue) { create(:issue, project: project) }
before do
project.team << [user, :developer]
end
context 'Issue' do
before do
visit namespace_project_issue_path(project.namespace, project, issue)
end
it_behaves_like 'issuable time tracker'
end
context 'Merge Request' do
let(:merge_request) { create(:merge_request, source_project: project) }
before do
visit namespace_project_merge_request_path(project.namespace, project, merge_request)
end
it_behaves_like 'issuable time tracker'
end
end
describe 'toggling the WIP prefix from the title from note' do describe 'toggling the WIP prefix from the title from note' do
let(:issue) { create(:issue, project: project) } let(:issue) { create(:issue, project: project) }
......
/* eslint-disable */
//= require jquery
//= require vue
//= require issuable/time_tracking/components/time_tracker
function initTimeTrackingComponent(opts) {
fixture.set(`
<div>
<div id="mock-container"></div>
</div>
`);
this.initialData = {
time_estimate: opts.timeEstimate,
time_spent: opts.timeSpent,
human_time_estimate: opts.timeEstimateHumanReadable,
human_time_spent: opts.timeSpentHumanReadable,
docsUrl: '/help/workflow/time_tracking.md',
};
const TimeTrackingComponent = Vue.component('issuable-time-tracker');
this.timeTracker = new TimeTrackingComponent({
el: '#mock-container',
propsData: this.initialData,
});
}
((gl) => {
describe('Issuable Time Tracker', function() {
describe('Initialization', function() {
beforeEach(function() {
initTimeTrackingComponent.call(this, { timeEstimate: 100000, timeSpent: 5000, timeEstimateHumanReadable: '2h 46m', timeSpentHumanReadable: '1h 23m' });
});
it('should return something defined', function() {
expect(this.timeTracker).toBeDefined();
});
it ('should correctly set timeEstimate', function(done) {
Vue.nextTick(() => {
expect(this.timeTracker.timeEstimate).toBe(this.initialData.time_estimate);
done();
});
});
it ('should correctly set time_spent', function(done) {
Vue.nextTick(() => {
expect(this.timeTracker.timeSpent).toBe(this.initialData.time_spent);
done();
});
});
});
describe('Content Display', function() {
describe('Panes', function() {
describe('Comparison pane', function() {
beforeEach(function() {
initTimeTrackingComponent.call(this, { timeEstimate: 100000, timeSpent: 5000, timeEstimateHumanReadable: '', timeSpentHumanReadable: '' });
});
it('should show the "Comparison" pane when timeEstimate and time_spent are truthy', function(done) {
Vue.nextTick(() => {
const $comparisonPane = this.timeTracker.$el.querySelector('.time-tracking-comparison-pane');
expect(this.timeTracker.showComparisonState).toBe(true);
done();
});
});
describe('Remaining meter', function() {
it('should display the remaining meter with the correct width', function(done) {
Vue.nextTick(() => {
const meterWidth = this.timeTracker.$el.querySelector('.time-tracking-comparison-pane .meter-fill').style.width;
const correctWidth = '5%';
expect(meterWidth).toBe(correctWidth);
done();
})
});
it('should display the remaining meter with the correct background color when within estimate', function(done) {
Vue.nextTick(() => {
const styledMeter = $(this.timeTracker.$el).find('.time-tracking-comparison-pane .within_estimate .meter-fill');
expect(styledMeter.length).toBe(1);
done()
});
});
it('should display the remaining meter with the correct background color when over estimate', function(done) {
this.timeTracker.time_estimate = 100000;
this.timeTracker.time_spent = 20000000;
Vue.nextTick(() => {
const styledMeter = $(this.timeTracker.$el).find('.time-tracking-comparison-pane .over_estimate .meter-fill');
expect(styledMeter.length).toBe(1);
done();
});
});
});
});
describe("Estimate only pane", function() {
beforeEach(function() {
initTimeTrackingComponent.call(this, { timeEstimate: 100000, timeSpent: 0, timeEstimateHumanReadable: '2h 46m', timeSpentHumanReadable: '' });
});
it('should display the human readable version of time estimated', function(done) {
Vue.nextTick(() => {
const estimateText = this.timeTracker.$el.querySelector('.time-tracking-estimate-only-pane').innerText;
const correctText = 'Estimated: 2h 46m';
expect(estimateText).toBe(correctText);
done();
});
});
});
describe('Spent only pane', function() {
beforeEach(function() {
initTimeTrackingComponent.call(this, { timeEstimate: 0, timeSpent: 5000, timeEstimateHumanReadable: '2h 46m', timeSpentHumanReadable: '1h 23m' });
});
it('should display the human readable version of time spent', function(done) {
Vue.nextTick(() => {
const spentText = this.timeTracker.$el.querySelector('.time-tracking-spend-only-pane').innerText;
const correctText = 'Spent: 1h 23m';
expect(spentText).toBe(correctText);
done();
});
});
});
describe('No time tracking pane', function() {
beforeEach(function() {
initTimeTrackingComponent.call(this, { timeEstimate: 0, timeSpent: 0, timeEstimateHumanReadable: 0, timeSpentHumanReadable: 0 });
});
it('should only show the "No time tracking" pane when both timeEstimate and time_spent are falsey', function(done) {
Vue.nextTick(() => {
const $noTrackingPane = this.timeTracker.$el.querySelector('.time-tracking-no-tracking-pane');
const noTrackingText =$noTrackingPane.innerText;
const correctText = 'No estimate or time spent';
expect(this.timeTracker.showNoTimeTrackingState).toBe(true);
expect($noTrackingPane).toBeVisible();
expect(noTrackingText).toBe(correctText);
done();
});
});
});
describe("Help pane", function() {
beforeEach(function() {
initTimeTrackingComponent.call(this, { timeEstimate: 0, timeSpent: 0 });
});
it('should not show the "Help" pane by default', function(done) {
Vue.nextTick(() => {
const $helpPane = this.timeTracker.$el.querySelector('.time-tracking-help-state');
expect(this.timeTracker.showHelpState).toBe(false);
expect($helpPane).toBeNull();
done();
});
});
it('should show the "Help" pane when help button is clicked', function(done) {
Vue.nextTick(() => {
$(this.timeTracker.$el).find('.help-button').click();
setTimeout(() => {
const $helpPane = this.timeTracker.$el.querySelector('.time-tracking-help-state');
expect(this.timeTracker.showHelpState).toBe(true);
expect($helpPane).toBeVisible();
done();
}, 10);
});
});
it('should not show the "Help" pane when help button is clicked and then closed', function(done) {
Vue.nextTick(() => {
$(this.timeTracker.$el).find('.help-button').click();
setTimeout(() => {
$(this.timeTracker.$el).find('.close-help-button').click();
setTimeout(() => {
const $helpPane = this.timeTracker.$el.querySelector('.time-tracking-help-state');
expect(this.timeTracker.showHelpState).toBe(false);
expect($helpPane).toBeNull();
done();
}, 1000);
}, 1000);
});
});
});
});
});
});
})(window.gl || (window.gl = {}));
//= 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
it 'renders the sidebar component empty state' do
page.within '.time-tracking-no-tracking-pane' do
expect(page).to have_content 'No estimate or time spent'
end
end
it 'updates the sidebar component when estimate is added' do
submit_time('/estimate 3w 1d 1h')
page.within '.time-tracking-estimate-only-pane' do
expect(page).to have_content '3w 1d 1h'
end
end
it 'updates the sidebar component when spent is added' do
submit_time('/spend 3w 1d 1h')
page.within '.time-tracking-spend-only-pane' do
expect(page).to have_content '3w 1d 1h'
end
end
it 'shows the comparison when estimate and spent are added' do
submit_time('/estimate 3w 1d 1h')
submit_time('/spend 3w 1d 1h')
page.within '.time-tracking-comparison-pane' do
expect(page).to have_content '3w 1d 1h'
end
end
it 'updates the sidebar component when estimate is removed' do
submit_time('/estimate 3w 1d 1h')
submit_time('/remove_estimate')
page.within '#issuable-time-tracker' do
expect(page).to have_content 'No estimate or time spent'
end
end
it 'updates the sidebar component when spent is removed' do
submit_time('/spend 3w 1d 1h')
submit_time('/remove_time_spent')
page.within '#issuable-time-tracker' do
expect(page).to have_content 'No estimate or time spent'
end
end
it 'shows the help state when icon is clicked' do
page.within '#issuable-time-tracker' do
find('.help-button').click
expect(page).to have_content 'Track time with slash commands'
expect(page).to have_content 'Learn more'
end
end
it 'hides the help state when close icon is clicked' do
page.within '#issuable-time-tracker' do
find('.help-button').click
find('.close-help-button').click
expect(page).not_to have_content 'Track time with slash commands'
expect(page).not_to have_content 'Learn more'
end
end
it 'displays the correct help url' do
page.within '#issuable-time-tracker' do
find('.help-button').click
expect(find_link('Learn more')[:href]).to have_content('/help/workflow/time_tracking.md')
end
end
end
def submit_time(slash_command)
fill_in 'note[note]', with: slash_command
click_button 'Comment'
wait_for_ajax
end
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