diff options
author | Bryce Johnson <bryce@gitlab.com> | 2016-12-20 15:44:24 +0100 |
---|---|---|
committer | Ruben Davila <rdavila84@gmail.com> | 2017-01-15 11:10:04 -0500 |
commit | 64dd41a0e21360c380cab394f8a5c9b4945b7fd1 (patch) | |
tree | dc0cde8263d60786fbe05338790ccd186070f8fd | |
parent | f1bd9f05e2ca0c165f06c47c32d44f2951265e6e (diff) | |
download | gitlab-ce-64dd41a0e21360c380cab394f8a5c9b4945b7fd1.tar.gz |
Backport timetracking frontend to CE.
22 files changed, 792 insertions, 16 deletions
diff --git a/app/assets/javascripts/diff_notes/diff_notes_bundle.js.es6 b/app/assets/javascripts/diff_notes/diff_notes_bundle.js.es6 index 840b5aa922e..1b3a57d0962 100644 --- a/app/assets/javascripts/diff_notes/diff_notes_bundle.js.es6 +++ b/app/assets/javascripts/diff_notes/diff_notes_bundle.js.es6 @@ -2,8 +2,6 @@ /* global Vue */ /* global ResolveCount */ -//= require vue -//= require vue-resource //= require_directory ./models //= require_directory ./stores //= require_directory ./services diff --git a/app/assets/javascripts/issuable/issuable_bundle.js.es6 b/app/assets/javascripts/issuable/issuable_bundle.js.es6 new file mode 100644 index 00000000000..7d0465aa8b4 --- /dev/null +++ b/app/assets/javascripts/issuable/issuable_bundle.js.es6 @@ -0,0 +1 @@ +//= require ./time_tracking/time_tracking_bundle diff --git a/app/assets/javascripts/issuable/time_tracking/components/collapsed_state.js.es6 b/app/assets/javascripts/issuable/time_tracking/components/collapsed_state.js.es6 new file mode 100644 index 00000000000..72433df2818 --- /dev/null +++ b/app/assets/javascripts/issuable/time_tracking/components/collapsed_state.js.es6 @@ -0,0 +1,42 @@ +/* global Vue */ +//= require lib/utils/pretty_time + +(() => { + Vue.component('time-tracking-collapsed-state', { + name: 'time-tracking-collapsed-state', + props: [ + 'showComparisonState', + 'showSpentOnlyState', + 'showEstimateOnlyState', + 'showNoTimeTrackingState', + 'timeSpentHumanReadable', + 'timeEstimateHumanReadable', + 'stopwatchSvg', + ], + methods: { + abbreviateTime(timeStr) { + return gl.utils.prettyTime.abbreviateTime(timeStr); + }, + }, + template: ` + <div class='sidebar-collapsed-icon'> + <div v-html='stopwatchSvg'></div> + <div class='time-tracking-collapsed-summary'> + <div class='compare' v-if='showComparisonState'> + <span>{{ abbreviateTime(timeSpentHumanReadable) }} / {{ abbreviateTime(timeEstimateHumanReadable) }}</span> + </div> + <div class='estimate-only' v-if='showEstimateOnlyState'> + <span class='bold'>-- / {{ abbreviateTime(timeEstimateHumanReadable) }}</span> + </div> + <div class='spend-only' v-if='showSpentOnlyState'> + <span class='bold'>{{ abbreviateTime(timeSpentHumanReadable) }} / --</span> + </div> + <div class='no-tracking' v-if='showNoTimeTrackingState'> + <span class='no-value'>None</span> + </div> + </div> + </div> + `, + }); +})(); + diff --git a/app/assets/javascripts/issuable/time_tracking/components/comparison_pane.js.es6 b/app/assets/javascripts/issuable/time_tracking/components/comparison_pane.js.es6 new file mode 100644 index 00000000000..6abbd5dd167 --- /dev/null +++ b/app/assets/javascripts/issuable/time_tracking/components/comparison_pane.js.es6 @@ -0,0 +1,69 @@ +/* global Vue */ +//= require lib/utils/pretty_time + +(() => { + const prettyTime = gl.utils.prettyTime; + + Vue.component('time-tracking-comparison-pane', { + name: 'time-tracking-comparison-pane', + props: [ + 'timeSpent', + 'timeEstimate', + 'timeSpentHumanReadable', + 'timeEstimateHumanReadable', + ], + computed: { + parsedRemaining() { + const diffSeconds = this.timeEstimate - this.timeSpent; + return prettyTime.parseSeconds(diffSeconds); + }, + timeRemainingHumanReadable() { + return prettyTime.stringifyTime(this.parsedRemaining); + }, + timeRemainingTooltip() { + const prefix = this.timeRemainingMinutes < 0 ? 'Over by' : 'Time remaining:'; + return `${prefix} ${this.timeRemainingHumanReadable}`; + }, + /* Diff values for comparison meter */ + timeRemainingMinutes() { + return this.timeEstimate - this.timeSpent; + }, + timeRemainingPercent() { + return `${Math.floor((this.timeSpent / this.timeEstimate) * 100)}%`; + }, + timeRemainingStatusClass() { + return this.timeEstimate >= this.timeSpent ? 'within_estimate' : 'over_estimate'; + }, + /* Parsed time values */ + parsedEstimate() { + return prettyTime.parseSeconds(this.timeEstimate); + }, + parsedSpent() { + return prettyTime.parseSeconds(this.timeSpent); + }, + }, + template: ` + <div class='time-tracking-comparison-pane'> + <div class='compare-meter' data-toggle='tooltip' data-placement='top' role='timeRemainingDisplay' + :aria-valuenow='timeRemainingTooltip' + :title='timeRemainingTooltip' + :data-original-title='timeRemainingTooltip' + :class='timeRemainingStatusClass'> + <div class='meter-container' role='timeSpentPercent' :aria-valuenow='timeRemainingPercent'> + <div :style='{ width: timeRemainingPercent }' class='meter-fill'></div> + </div> + <div class='compare-display-container'> + <div class='compare-display pull-left'> + <span class='compare-label'>Spent</span> + <span class='compare-value spent'>{{ timeSpentHumanReadable }}</span> + </div> + <div class='compare-display estimated pull-right'> + <span class='compare-label'>Est</span> + <span class='compare-value'>{{ timeEstimateHumanReadable }}</span> + </div> + </div> + </div> + </div> + `, + }); +})(); diff --git a/app/assets/javascripts/issuable/time_tracking/components/estimate_only_pane.js.es6 b/app/assets/javascripts/issuable/time_tracking/components/estimate_only_pane.js.es6 new file mode 100644 index 00000000000..309e9f2f9ef --- /dev/null +++ b/app/assets/javascripts/issuable/time_tracking/components/estimate_only_pane.js.es6 @@ -0,0 +1,13 @@ +/* global Vue */ +(() => { + Vue.component('time-tracking-estimate-only-pane', { + name: 'time-tracking-estimate-only-pane', + props: ['timeEstimateHumanReadable'], + template: ` + <div class='time-tracking-estimate-only-pane'> + <span class='bold'>Estimated:</span> + {{ timeEstimateHumanReadable }} + </div> + `, + }); +})(); diff --git a/app/assets/javascripts/issuable/time_tracking/components/help_state.js.es6 b/app/assets/javascripts/issuable/time_tracking/components/help_state.js.es6 new file mode 100644 index 00000000000..d7ced6d7151 --- /dev/null +++ b/app/assets/javascripts/issuable/time_tracking/components/help_state.js.es6 @@ -0,0 +1,24 @@ +/* global Vue */ +(() => { + Vue.component('time-tracking-help-state', { + name: 'time-tracking-help-state', + props: ['docsUrl'], + template: ` + <div class='time-tracking-help-state'> + <div class='time-tracking-info'> + <h4>Track time with slash commands</h4> + <p>Slash commands can be used in the issues description and comment boxes.</p> + <p> + <code>/estimate</code> + will update the estimated time with the latest command. + </p> + <p> + <code>/spend</code> + will update the sum of the time spent. + </p> + <a class='btn btn-default learn-more-button' :href='docsUrl'>Learn more</a> + </div> + </div> + `, + }); +})(); diff --git a/app/assets/javascripts/issuable/time_tracking/components/no_tracking_pane.js.es6 b/app/assets/javascripts/issuable/time_tracking/components/no_tracking_pane.js.es6 new file mode 100644 index 00000000000..1d2ca643b5b --- /dev/null +++ b/app/assets/javascripts/issuable/time_tracking/components/no_tracking_pane.js.es6 @@ -0,0 +1,11 @@ +/* global Vue */ +(() => { + Vue.component('time-tracking-no-tracking-pane', { + name: 'time-tracking-no-tracking-pane', + template: ` + <div class='time-tracking-no-tracking-pane'> + <span class='no-value'>No estimate or time spent</span> + </div> + `, + }); +})(); diff --git a/app/assets/javascripts/issuable/time_tracking/components/spent_only_pane.js.es6 b/app/assets/javascripts/issuable/time_tracking/components/spent_only_pane.js.es6 new file mode 100644 index 00000000000..ed283fec3c3 --- /dev/null +++ b/app/assets/javascripts/issuable/time_tracking/components/spent_only_pane.js.es6 @@ -0,0 +1,13 @@ +/* global Vue */ +(() => { + Vue.component('time-tracking-spent-only-pane', { + name: 'time-tracking-spent-only-pane', + props: ['timeSpentHumanReadable'], + template: ` + <div class='time-tracking-spend-only-pane'> + <span class='bold'>Spent:</span> + {{ timeSpentHumanReadable }} + </div> + `, + }); +})(); diff --git a/app/assets/javascripts/issuable/time_tracking/components/time_tracker.js.es6 b/app/assets/javascripts/issuable/time_tracking/components/time_tracker.js.es6 new file mode 100644 index 00000000000..26563a7713b --- /dev/null +++ b/app/assets/javascripts/issuable/time_tracking/components/time_tracker.js.es6 @@ -0,0 +1,118 @@ +/* global Vue */ +//= require ./help_state +//= require ./collapsed_state +//= require ./spent_only_pane +//= require ./no_tracking_pane +//= require ./estimate_only_pane +//= require ./comparison_pane + +(() => { + Vue.component('issuable-time-tracker', { + name: 'issuable-time-tracker', + props: [ + 'time_estimate', + 'time_spent', + 'human_time_estimate', + 'human_time_spent', + 'stopwatchSvg', + 'docsUrl', + ], + data() { + return { + showHelp: false, + }; + }, + computed: { + timeSpent() { + return this.time_spent; + }, + timeEstimate() { + return this.time_estimate; + }, + timeEstimateHumanReadable() { + return this.human_time_estimate; + }, + timeSpentHumanReadable() { + return this.human_time_spent; + }, + hasTimeSpent() { + return !!this.timeSpent; + }, + hasTimeEstimate() { + return !!this.timeEstimate; + }, + showComparisonState() { + return this.hasTimeEstimate && this.hasTimeSpent; + }, + showEstimateOnlyState() { + return this.hasTimeEstimate && !this.hasTimeSpent; + }, + showSpentOnlyState() { + return this.hasTimeSpent && !this.hasTimeEstimate; + }, + showNoTimeTrackingState() { + return !this.hasTimeEstimate && !this.hasTimeSpent; + }, + showHelpState() { + return !!this.showHelp; + }, + }, + methods: { + toggleHelpState(show) { + this.showHelp = show; + }, + }, + template: ` + <div class='time_tracker time-tracking-component-wrap' v-cloak> + <time-tracking-collapsed-state + :show-comparison-state='showComparisonState' + :show-help-state='showHelpState' + :show-spent-only-state='showSpentOnlyState' + :show-estimate-only-state='showEstimateOnlyState' + :time-spent-human-readable='timeSpentHumanReadable' + :time-estimate-human-readable='timeEstimateHumanReadable' + :stopwatch-svg='stopwatchSvg'> + </time-tracking-collapsed-state> + <div class='title hide-collapsed'> + Time tracking + <div class='help-button pull-right' + v-if='!showHelpState' + @click='toggleHelpState(true)'> + <i class='fa fa-question-circle'></i> + </div> + <div class='close-help-button pull-right' + v-if='showHelpState' + @click='toggleHelpState(false)'> + <i class='fa fa-close'></i> + </div> + </div> + <div class='time-tracking-content hide-collapsed'> + <time-tracking-estimate-only-pane + v-if='showEstimateOnlyState' + :time-estimate-human-readable='timeEstimateHumanReadable'> + </time-tracking-estimate-only-pane> + <time-tracking-spent-only-pane + v-if='showSpentOnlyState' + :time-spent-human-readable='timeSpentHumanReadable'> + </time-tracking-spent-only-pane> + <time-tracking-no-tracking-pane + v-if='showNoTimeTrackingState'> + </time-tracking-no-tracking-pane> + <time-tracking-comparison-pane + v-if='showComparisonState' + :time-estimate='timeEstimate' + :time-spent='timeSpent' + :time-spent-human-readable='timeSpentHumanReadable' + :time-estimate-human-readable='timeEstimateHumanReadable'> + </time-tracking-comparison-pane> + <transition name='help-state-toggle'> + <time-tracking-help-state + v-if='showHelpState' + :docs-url='docsUrl'> + </time-tracking-help-state> + </transition> + </div> + </div> + `, + }); +})(); diff --git a/app/assets/javascripts/issuable/time_tracking/time_tracking_bundle.js.es6 b/app/assets/javascripts/issuable/time_tracking/time_tracking_bundle.js.es6 new file mode 100644 index 00000000000..0b8da2b1f4f --- /dev/null +++ b/app/assets/javascripts/issuable/time_tracking/time_tracking_bundle.js.es6 @@ -0,0 +1,61 @@ +/* global Vue */ +//= require ./components/time_tracker +//= require smart_interval +//= require subbable_resource + +(() => { + /* This Vue instance represents what will become the parent instance for the + * sidebar. It will be responsible for managing `issuable` state and propagating + * changes to sidebar components. We will want to create a separate service to + * interface with the server at that point. + */ + + class IssuableTimeTracking { + constructor(issuableJSON) { + const parsedIssuable = JSON.parse(issuableJSON); + return this.initComponent(parsedIssuable); + } + + initComponent(parsedIssuable) { + this.parentInstance = new Vue({ + el: '#issuable-time-tracker', + data: { + issuable: parsedIssuable, + }, + methods: { + fetchIssuable() { + return gl.IssuableResource.get.call(gl.IssuableResource, { + type: 'GET', + url: gl.IssuableResource.endpoint, + }); + }, + updateState(data) { + this.issuable = data; + }, + subscribeToUpdates() { + gl.IssuableResource.subscribe(data => this.updateState(data)); + }, + listenForSlashCommands() { + $(document).on('ajax:success', '.gfm-form', (e, data) => { + const subscribedCommands = ['spend_time', 'time_estimate']; + const changedCommands = data.commands_changes; + + if (changedCommands && _.intersection(subscribedCommands, changedCommands).length) { + this.fetchIssuable(); + } + }); + }, + }, + created() { + this.fetchIssuable(); + }, + mounted() { + this.subscribeToUpdates(); + this.listenForSlashCommands(); + }, + }); + } + } + + gl.IssuableTimeTracking = IssuableTimeTracking; +})(window.gl || (window.gl = {})); diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss index cf9424ea5dd..4baf6ee781a 100644 --- a/app/assets/stylesheets/framework/variables.scss +++ b/app/assets/stylesheets/framework/variables.scss @@ -5,7 +5,7 @@ $sidebar_collapsed_width: 62px; $sidebar_width: 220px; $gutter_collapsed_width: 62px; $gutter_width: 290px; -$gutter_inner_width: 258px; +$gutter_inner_width: 250px; $sidebar-transition-duration: .15s; $sidebar-breakpoint: 1024px; @@ -85,6 +85,7 @@ $warning-message-border: #f0e2bb; */ $border-color: #e5e5e5; $focus-border-color: #3aabf0; +$sidebar-collapsed-icon-color: #999; $well-expand-item: #e8f2f7; $well-inner-border: #eef0f2; $well-light-border: #f1f1f1; diff --git a/app/assets/stylesheets/pages/issuable.scss b/app/assets/stylesheets/pages/issuable.scss index 3272a862b85..c9014ac2906 100644 --- a/app/assets/stylesheets/pages/issuable.scss +++ b/app/assets/stylesheets/pages/issuable.scss @@ -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; + } +} diff --git a/app/views/projects/issues/show.html.haml b/app/views/projects/issues/show.html.haml index 43141971231..9fa00811af0 100644 --- a/app/views/projects/issues/show.html.haml +++ b/app/views/projects/issues/show.html.haml @@ -2,6 +2,8 @@ - page_title "#{@issue.title} (#{@issue.to_reference})", "Issues" - page_description @issue.description - page_card_attributes @issue.card_attributes +- content_for :page_specific_javascripts do + = page_specific_javascript_tag('lib/vue_resource.js') .clearfix.detail-page-header .issuable-header diff --git a/app/views/projects/merge_requests/_show.html.haml b/app/views/projects/merge_requests/_show.html.haml index 110dd11d1ce..0516801cdf3 100644 --- a/app/views/projects/merge_requests/_show.html.haml +++ b/app/views/projects/merge_requests/_show.html.haml @@ -3,6 +3,7 @@ - page_description @merge_request.description - page_card_attributes @merge_request.card_attributes - content_for :page_specific_javascripts do + = page_specific_javascript_tag('lib/vue_resource.js') = page_specific_javascript_tag('diff_notes/diff_notes_bundle.js') .merge-request{ 'data-url' => merge_request_path(@merge_request) } diff --git a/app/views/projects/merge_requests/conflicts.html.haml b/app/views/projects/merge_requests/conflicts.html.haml index b8b87dcdcaf..ebef2157d34 100644 --- a/app/views/projects/merge_requests/conflicts.html.haml +++ b/app/views/projects/merge_requests/conflicts.html.haml @@ -1,5 +1,6 @@ - page_title "Merge Conflicts", "#{@merge_request.title} (#{@merge_request.to_reference}", "Merge Requests" - content_for :page_specific_javascripts do + = page_specific_javascript_tag('lib/vue_resource.js') = page_specific_javascript_tag('merge_conflicts/merge_conflicts_bundle.js') = page_specific_javascript_tag('lib/ace.js') = render "projects/merge_requests/show/mr_title" diff --git a/app/views/shared/icons/_icon_stopwatch.svg b/app/views/shared/icons/_icon_stopwatch.svg new file mode 100644 index 00000000000..f20de04538e --- /dev/null +++ b/app/views/shared/icons/_icon_stopwatch.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 12 14" enable-background="new 0 0 12 14"><path d="m11.5 2.4l-1.3-1.1-1 1.1 1.4 1.1.9-1.1"/><path d="m6.8 2v-.5h.5v-1.5h-2.6v1.5h.5v.5c-2.9.4-5.2 2.9-5.2 6 0 3.3 2.7 6 6 6s6-2.7 6-6c0-3-2.3-5.6-5.2-6m-.8 10.5c-2.5 0-4.5-2-4.5-4.5s2-4.5 4.5-4.5 4.5 2 4.5 4.5-2 4.5-4.5 4.5"/><path d="m6.2 8.9h-.5c-.1 0-.2-.1-.2-.2v-3.5c0-.1.1-.2.2-.2h.5c.1 0 .2.1.2.2v3.5c0 .1-.1.2-.2.2"/></svg>
\ No newline at end of file diff --git a/app/views/shared/issuable/_sidebar.html.haml b/app/views/shared/issuable/_sidebar.html.haml index a02b815e3cd..c46710af758 100644 --- a/app/views/shared/issuable/_sidebar.html.haml +++ b/app/views/shared/issuable/_sidebar.html.haml @@ -1,5 +1,7 @@ - todo = issuable_todo(issuable) -%aside.right-sidebar{ class: sidebar_gutter_collapsed_class } +- content_for :page_specific_javascripts do + = page_specific_javascript_tag('issuable/issuable_bundle.js') +%aside.right-sidebar{ class: sidebar_gutter_collapsed_class, 'aria-live' => 'polite' } .issuable-sidebar - can_edit_issuable = can?(current_user, :"admin_#{issuable.to_ability_name}", @project) .block.issuable-sidebar-header @@ -72,7 +74,13 @@ .selectbox.hide-collapsed = f.hidden_field 'milestone_id', value: issuable.milestone_id, id: nil = dropdown_tag('Milestone', options: { title: 'Assign milestone', toggle_class: 'js-milestone-select js-extra-options', filter: true, dropdown_class: 'dropdown-menu-selectable', placeholder: 'Search milestones', data: { show_no: true, field_name: "#{issuable.to_ability_name}[milestone_id]", project_id: @project.id, issuable_id: issuable.id, milestones: namespace_project_milestones_path(@project.namespace, @project, :json), ability_name: issuable.to_ability_name, issue_update: issuable_json_path(issuable), use_id: true }}) - + - if issuable.has_attribute?(:time_estimate) + #issuable-time-tracker.block + %issuable-time-tracker{ ':time_estimate' => 'issuable.time_estimate', ':time_spent' => 'issuable.total_time_spent', ':human_time_estimate' => 'issuable.human_time_estimate', ':human_time_spent' => 'issuable.human_total_time_spent', 'stopwatch-svg' => custom_icon('icon_stopwatch'), 'docs-url' => help_page_path('workflow/time_tracking.md')} + // Fallback while content is loading + .title.hide-collapsed + Time tracking + = icon('spinner spin') - if issuable.has_attribute?(:due_date) .block.due_date .sidebar-collapsed-icon @@ -162,6 +170,8 @@ = clipboard_button(clipboard_text: project_ref, title: "Copy reference to clipboard", placement: "left") :javascript + gl.IssuableResource = new gl.SubbableResource('#{issuable_json_path(issuable)}'); + new gl.IssuableTimeTracking("#{escape_javascript(serialize_issuable(issuable))}"); new MilestoneSelect('{"namespace":"#{@project.namespace.path}","path":"#{@project.path}"}'); new LabelsSelect(); new IssuableContext('#{escape_javascript(current_user.to_json(only: [:username, :id, :name]))}'); diff --git a/config/application.rb b/config/application.rb index 8ce549cebf6..f00e58a36ca 100644 --- a/config/application.rb +++ b/config/application.rb @@ -88,6 +88,7 @@ module Gitlab config.assets.precompile << "print.css" config.assets.precompile << "notify.css" config.assets.precompile << "mailers/*.css" + config.assets.precompile << "lib/vue_resource.js" config.assets.precompile << "katex.css" config.assets.precompile << "katex.js" config.assets.precompile << "xterm/xterm.css" @@ -98,6 +99,7 @@ module Gitlab config.assets.precompile << "protected_branches/protected_branches_bundle.js" config.assets.precompile << "diff_notes/diff_notes_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 << "cycle_analytics/cycle_analytics_bundle.js" config.assets.precompile << "merge_conflicts/merge_conflicts_bundle.js" diff --git a/spec/features/issues/user_uses_slash_commands_spec.rb b/spec/features/issues/user_uses_slash_commands_spec.rb index 31f75512f4a..f2d4aadf540 100644 --- a/spec/features/issues/user_uses_slash_commands_spec.rb +++ b/spec/features/issues/user_uses_slash_commands_spec.rb @@ -100,6 +100,32 @@ feature 'Issues > User uses slash commands', feature: true, js: true do 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 let(:issue) { create(:issue, project: project) } diff --git a/spec/javascripts/issuable_time_tracker_spec.js.es6 b/spec/javascripts/issuable_time_tracker_spec.js.es6 new file mode 100644 index 00000000000..a1e979e8d09 --- /dev/null +++ b/spec/javascripts/issuable_time_tracker_spec.js.es6 @@ -0,0 +1,201 @@ +/* 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 = {})); diff --git a/spec/javascripts/pretty_time_spec.js.es6 b/spec/javascripts/pretty_time_spec.js.es6 index 2e12d45f7a7..7a04fba5f7f 100644 --- a/spec/javascripts/pretty_time_spec.js.es6 +++ b/spec/javascripts/pretty_time_spec.js.es6 @@ -1,12 +1,12 @@ //= 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 () { it('should correctly parse a negative value', function () { - const parser = PrettyTime.parseSeconds; + const parser = prettyTime.parseSeconds; const zeroSeconds = parser(-1000); @@ -17,7 +17,7 @@ }); it('should correctly parse a zero value', function () { - const parser = PrettyTime.parseSeconds; + const parser = prettyTime.parseSeconds; const zeroSeconds = parser(0); @@ -28,7 +28,7 @@ }); it('should correctly parse a small non-zero second values', function () { - const parser = PrettyTime.parseSeconds; + const parser = prettyTime.parseSeconds; const subOneMinute = parser(10); @@ -53,7 +53,7 @@ }); it('should correctly parse large second values', function () { - const parser = PrettyTime.parseSeconds; + const parser = prettyTime.parseSeconds; const aboveOneHour = parser(4800); @@ -87,7 +87,7 @@ minutes: 20, }; - const timeString = PrettyTime.stringifyTime(timeObject); + const timeString = prettyTime.stringifyTime(timeObject); expect(timeString).toBe('1w 4d 7h 20m'); }); @@ -100,7 +100,7 @@ minutes: 20, }; - const timeString = PrettyTime.stringifyTime(timeObject); + const timeString = prettyTime.stringifyTime(timeObject); expect(timeString).toBe('4d 20m'); }); @@ -113,7 +113,7 @@ minutes: 0, }; - const timeString = PrettyTime.stringifyTime(timeObject); + const timeString = prettyTime.stringifyTime(timeObject); expect(timeString).toBe('0m'); }); @@ -122,12 +122,12 @@ describe('abbreviateTime', function () { it('should abbreviate stringified times for weeks', function () { 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 () { const fullTimeString = '0w 3d 4h 5m'; - expect(PrettyTime.abbreviateTime(fullTimeString)).toBe('3d'); + expect(prettyTime.abbreviateTime(fullTimeString)).toBe('3d'); }); }); }); diff --git a/spec/support/time_tracking_shared_examples.rb b/spec/support/time_tracking_shared_examples.rb new file mode 100644 index 00000000000..02657684b57 --- /dev/null +++ b/spec/support/time_tracking_shared_examples.rb @@ -0,0 +1,82 @@ +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 |