Skip to content
Projects
Groups
Snippets
Help
Loading...
Help
Support
Keyboard shortcuts
?
Submit feedback
Contribute to GitLab
Sign in / Register
Toggle navigation
G
gitlab-ce
Project overview
Project overview
Details
Activity
Releases
Repository
Repository
Files
Commits
Branches
Tags
Contributors
Graph
Compare
Issues
0
Issues
0
List
Boards
Labels
Milestones
Merge Requests
0
Merge Requests
0
Analytics
Analytics
Repository
Value Stream
Wiki
Wiki
Snippets
Snippets
Members
Members
Collapse sidebar
Close sidebar
Activity
Graph
Create a new issue
Commits
Issue Boards
Open sidebar
Léo-Paul Géneau
gitlab-ce
Commits
64dd41a0
Commit
64dd41a0
authored
Dec 20, 2016
by
Bryce Johnson
Committed by
Ruben Davila
Jan 15, 2017
Browse files
Options
Browse Files
Download
Email Patches
Plain Diff
Backport timetracking frontend to CE.
parent
f1bd9f05
Changes
22
Hide whitespace changes
Inline
Side-by-side
Showing
22 changed files
with
792 additions
and
16 deletions
+792
-16
app/assets/javascripts/diff_notes/diff_notes_bundle.js.es6
app/assets/javascripts/diff_notes/diff_notes_bundle.js.es6
+0
-2
app/assets/javascripts/issuable/issuable_bundle.js.es6
app/assets/javascripts/issuable/issuable_bundle.js.es6
+1
-0
app/assets/javascripts/issuable/time_tracking/components/collapsed_state.js.es6
.../issuable/time_tracking/components/collapsed_state.js.es6
+42
-0
app/assets/javascripts/issuable/time_tracking/components/comparison_pane.js.es6
.../issuable/time_tracking/components/comparison_pane.js.es6
+69
-0
app/assets/javascripts/issuable/time_tracking/components/estimate_only_pane.js.es6
...suable/time_tracking/components/estimate_only_pane.js.es6
+13
-0
app/assets/javascripts/issuable/time_tracking/components/help_state.js.es6
...ripts/issuable/time_tracking/components/help_state.js.es6
+24
-0
app/assets/javascripts/issuable/time_tracking/components/no_tracking_pane.js.es6
...issuable/time_tracking/components/no_tracking_pane.js.es6
+11
-0
app/assets/javascripts/issuable/time_tracking/components/spent_only_pane.js.es6
.../issuable/time_tracking/components/spent_only_pane.js.es6
+13
-0
app/assets/javascripts/issuable/time_tracking/components/time_tracker.js.es6
...pts/issuable/time_tracking/components/time_tracker.js.es6
+118
-0
app/assets/javascripts/issuable/time_tracking/time_tracking_bundle.js.es6
...cripts/issuable/time_tracking/time_tracking_bundle.js.es6
+61
-0
app/assets/stylesheets/framework/variables.scss
app/assets/stylesheets/framework/variables.scss
+2
-1
app/assets/stylesheets/pages/issuable.scss
app/assets/stylesheets/pages/issuable.scss
+99
-0
app/views/projects/issues/show.html.haml
app/views/projects/issues/show.html.haml
+2
-0
app/views/projects/merge_requests/_show.html.haml
app/views/projects/merge_requests/_show.html.haml
+1
-0
app/views/projects/merge_requests/conflicts.html.haml
app/views/projects/merge_requests/conflicts.html.haml
+1
-0
app/views/shared/icons/_icon_stopwatch.svg
app/views/shared/icons/_icon_stopwatch.svg
+1
-0
app/views/shared/issuable/_sidebar.html.haml
app/views/shared/issuable/_sidebar.html.haml
+12
-2
config/application.rb
config/application.rb
+2
-0
spec/features/issues/user_uses_slash_commands_spec.rb
spec/features/issues/user_uses_slash_commands_spec.rb
+26
-0
spec/javascripts/issuable_time_tracker_spec.js.es6
spec/javascripts/issuable_time_tracker_spec.js.es6
+201
-0
spec/javascripts/pretty_time_spec.js.es6
spec/javascripts/pretty_time_spec.js.es6
+11
-11
spec/support/time_tracking_shared_examples.rb
spec/support/time_tracking_shared_examples.rb
+82
-0
No files found.
app/assets/javascripts/diff_notes/diff_notes_bundle.js.es6
View file @
64dd41a0
...
@@ -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
...
...
app/assets/javascripts/issuable/issuable_bundle.js.es6
0 → 100644
View file @
64dd41a0
//= require ./time_tracking/time_tracking_bundle
app/assets/javascripts/issuable/time_tracking/components/collapsed_state.js.es6
0 → 100644
View file @
64dd41a0
/* 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>
`,
});
})();
app/assets/javascripts/issuable/time_tracking/components/comparison_pane.js.es6
0 → 100644
View file @
64dd41a0
/* 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>
`,
});
})();
app/assets/javascripts/issuable/time_tracking/components/estimate_only_pane.js.es6
0 → 100644
View file @
64dd41a0
/* 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>
`,
});
})();
app/assets/javascripts/issuable/time_tracking/components/help_state.js.es6
0 → 100644
View file @
64dd41a0
/* 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>
`,
});
})();
app/assets/javascripts/issuable/time_tracking/components/no_tracking_pane.js.es6
0 → 100644
View file @
64dd41a0
/* 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>
`,
});
})();
app/assets/javascripts/issuable/time_tracking/components/spent_only_pane.js.es6
0 → 100644
View file @
64dd41a0
/* 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>
`,
});
})();
app/assets/javascripts/issuable/time_tracking/components/time_tracker.js.es6
0 → 100644
View file @
64dd41a0
/* 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>
`,
});
})();
app/assets/javascripts/issuable/time_tracking/time_tracking_bundle.js.es6
0 → 100644
View file @
64dd41a0
/* 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 = {}));
app/assets/stylesheets/framework/variables.scss
View file @
64dd41a0
...
@@ -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
:
25
8
px
;
$gutter_inner_width
:
25
0
px
;
$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
;
...
...
app/assets/stylesheets/pages/issuable.scss
View file @
64dd41a0
...
@@ -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
;
}
}
app/views/projects/issues/show.html.haml
View file @
64dd41a0
...
@@ -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
...
...
app/views/projects/merge_requests/_show.html.haml
View file @
64dd41a0
...
@@ -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
)
}
...
...
app/views/projects/merge_requests/conflicts.html.haml
View file @
64dd41a0
-
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"
...
...
app/views/shared/icons/_icon_stopwatch.svg
0 → 100644
View file @
64dd41a0
<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
app/views/shared/issuable/_sidebar.html.haml
View file @
64dd41a0
-
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
]))
}
'
);
...
...
config/application.rb
View file @
64dd41a0
...
@@ -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"
...
...
spec/features/issues/user_uses_slash_commands_spec.rb
View file @
64dd41a0
...
@@ -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
)
}
...
...
spec/javascripts/issuable_time_tracker_spec.js.es6
0 → 100644
View file @
64dd41a0
/* 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 = {}));
spec/javascripts/pretty_time_spec.js.es6
View file @
64dd41a0
//= require lib/utils/pretty_time
//= require lib/utils/pretty_time
(() => {
(() => {
const
PrettyTime = gl.P
rettyTime;
const
prettyTime = gl.utils.p
rettyTime;
describe('
P
rettyTime methods', function () {
describe('
p
rettyTime 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 =
P
rettyTime.parseSeconds;
const parser =
p
rettyTime.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 =
P
rettyTime.parseSeconds;
const parser =
p
rettyTime.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 =
P
rettyTime.parseSeconds;
const parser =
p
rettyTime.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 =
P
rettyTime.parseSeconds;
const parser =
p
rettyTime.parseSeconds;
const aboveOneHour = parser(4800);
const aboveOneHour = parser(4800);
...
@@ -87,7 +87,7 @@
...
@@ -87,7 +87,7 @@
minutes: 20,
minutes: 20,
};
};
const timeString =
P
rettyTime.stringifyTime(timeObject);
const timeString =
p
rettyTime.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 =
P
rettyTime.stringifyTime(timeObject);
const timeString =
p
rettyTime.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 =
P
rettyTime.stringifyTime(timeObject);
const timeString =
p
rettyTime.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(
P
rettyTime.abbreviateTime(fullTimeString)).toBe('1w');
expect(
p
rettyTime.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(
P
rettyTime.abbreviateTime(fullTimeString)).toBe('3d');
expect(
p
rettyTime.abbreviateTime(fullTimeString)).toBe('3d');
});
});
});
});
});
});
...
...
spec/support/time_tracking_shared_examples.rb
0 → 100644
View file @
64dd41a0
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
Write
Preview
Markdown
is supported
0%
Try again
or
attach a new file
Attach a file
Cancel
You are about to add
0
people
to the discussion. Proceed with caution.
Finish editing this message first!
Cancel
Please
register
or
sign in
to comment