Welcome to mirror list, hosted at ThFree Co, Russian Federation.

gitlab.com/gitlab-org/gitlab-foss.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorValery Sizov <valery@gitlab.com>2017-05-04 15:11:15 +0300
committerValery Sizov <valery@gitlab.com>2017-05-04 17:11:53 +0300
commit387c4b2c21a44360386a9b8ce6849e7f1b8a3de9 (patch)
tree446b8338efe8ad22ca03b00b2dc72b22c4174e02 /app/assets/javascripts/sidebar
parent68c12e15cc236548918f91393ebef3c06c124814 (diff)
Backport of multiple_assignees_feature [ci skip]
Diffstat (limited to 'app/assets/javascripts/sidebar')
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/assignee_title.js41
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/assignees.js224
-rw-r--r--app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.js84
-rw-r--r--app/assets/javascripts/sidebar/components/time_tracking/collapsed_state.js97
-rw-r--r--app/assets/javascripts/sidebar/components/time_tracking/comparison_pane.js98
-rw-r--r--app/assets/javascripts/sidebar/components/time_tracking/estimate_only_pane.js17
-rw-r--r--app/assets/javascripts/sidebar/components/time_tracking/help_state.js44
-rw-r--r--app/assets/javascripts/sidebar/components/time_tracking/no_tracking_pane.js10
-rw-r--r--app/assets/javascripts/sidebar/components/time_tracking/sidebar_time_tracking.js45
-rw-r--r--app/assets/javascripts/sidebar/components/time_tracking/spent_only_pane.js15
-rw-r--r--app/assets/javascripts/sidebar/components/time_tracking/time_tracker.js163
-rw-r--r--app/assets/javascripts/sidebar/event_hub.js3
-rw-r--r--app/assets/javascripts/sidebar/services/sidebar_service.js28
-rw-r--r--app/assets/javascripts/sidebar/sidebar_bundle.js21
-rw-r--r--app/assets/javascripts/sidebar/sidebar_mediator.js38
-rw-r--r--app/assets/javascripts/sidebar/stores/sidebar_store.js52
16 files changed, 980 insertions, 0 deletions
diff --git a/app/assets/javascripts/sidebar/components/assignees/assignee_title.js b/app/assets/javascripts/sidebar/components/assignees/assignee_title.js
new file mode 100644
index 00000000000..a9ad3708514
--- /dev/null
+++ b/app/assets/javascripts/sidebar/components/assignees/assignee_title.js
@@ -0,0 +1,41 @@
+export default {
+ name: 'AssigneeTitle',
+ props: {
+ loading: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ numberOfAssignees: {
+ type: Number,
+ required: true,
+ },
+ editable: {
+ type: Boolean,
+ required: true,
+ },
+ },
+ computed: {
+ assigneeTitle() {
+ const assignees = this.numberOfAssignees;
+ return assignees > 1 ? `${assignees} Assignees` : 'Assignee';
+ },
+ },
+ template: `
+ <div class="title hide-collapsed">
+ {{assigneeTitle}}
+ <i
+ v-if="loading"
+ aria-hidden="true"
+ class="fa fa-spinner fa-spin block-loading"
+ />
+ <a
+ v-if="editable"
+ class="edit-link pull-right"
+ href="#"
+ >
+ Edit
+ </a>
+ </div>
+ `,
+};
diff --git a/app/assets/javascripts/sidebar/components/assignees/assignees.js b/app/assets/javascripts/sidebar/components/assignees/assignees.js
new file mode 100644
index 00000000000..88d7650f40a
--- /dev/null
+++ b/app/assets/javascripts/sidebar/components/assignees/assignees.js
@@ -0,0 +1,224 @@
+export default {
+ name: 'Assignees',
+ data() {
+ return {
+ defaultRenderCount: 5,
+ defaultMaxCounter: 99,
+ showLess: true,
+ };
+ },
+ props: {
+ rootPath: {
+ type: String,
+ required: true,
+ },
+ users: {
+ type: Array,
+ required: true,
+ },
+ editable: {
+ type: Boolean,
+ required: true,
+ },
+ },
+ computed: {
+ firstUser() {
+ return this.users[0];
+ },
+ hasMoreThanTwoAssignees() {
+ return this.users.length > 2;
+ },
+ hasMoreThanOneAssignee() {
+ return this.users.length > 1;
+ },
+ hasAssignees() {
+ return this.users.length > 0;
+ },
+ hasNoUsers() {
+ return !this.users.length;
+ },
+ hasOneUser() {
+ return this.users.length === 1;
+ },
+ renderShowMoreSection() {
+ return this.users.length > this.defaultRenderCount;
+ },
+ numberOfHiddenAssignees() {
+ return this.users.length - this.defaultRenderCount;
+ },
+ isHiddenAssignees() {
+ return this.numberOfHiddenAssignees > 0;
+ },
+ hiddenAssigneesLabel() {
+ return `+ ${this.numberOfHiddenAssignees} more`;
+ },
+ collapsedTooltipTitle() {
+ const maxRender = Math.min(this.defaultRenderCount, this.users.length);
+ const renderUsers = this.users.slice(0, maxRender);
+ const names = renderUsers.map(u => u.name);
+
+ if (this.users.length > maxRender) {
+ names.push(`+ ${this.users.length - maxRender} more`);
+ }
+
+ return names.join(', ');
+ },
+ sidebarAvatarCounter() {
+ let counter = `+${this.users.length - 1}`;
+
+ if (this.users.length > this.defaultMaxCounter) {
+ counter = `${this.defaultMaxCounter}+`;
+ }
+
+ return counter;
+ },
+ },
+ methods: {
+ assignSelf() {
+ this.$emit('assign-self');
+ },
+ toggleShowLess() {
+ this.showLess = !this.showLess;
+ },
+ renderAssignee(index) {
+ return !this.showLess || (index < this.defaultRenderCount && this.showLess);
+ },
+ avatarUrl(user) {
+ return user.avatarUrl || user.avatar_url;
+ },
+ assigneeUrl(user) {
+ return `${this.rootPath}${user.username}`;
+ },
+ assigneeAlt(user) {
+ return `${user.name}'s avatar`;
+ },
+ assigneeUsername(user) {
+ return `@${user.username}`;
+ },
+ shouldRenderCollapsedAssignee(index) {
+ const firstTwo = this.users.length <= 2 && index <= 2;
+
+ return index === 0 || firstTwo;
+ },
+ },
+ template: `
+ <div>
+ <div
+ class="sidebar-collapsed-icon sidebar-collapsed-user"
+ :class="{ 'multiple-users': hasMoreThanOneAssignee, 'has-tooltip': hasAssignees }"
+ data-container="body"
+ data-placement="left"
+ :title="collapsedTooltipTitle"
+ >
+ <i
+ v-if="hasNoUsers"
+ aria-label="No Assignee"
+ class="fa fa-user"
+ />
+ <button
+ type="button"
+ class="btn-link"
+ v-for="(user, index) in users"
+ v-if="shouldRenderCollapsedAssignee(index)"
+ >
+ <img
+ width="24"
+ class="avatar avatar-inline s24"
+ :alt="assigneeAlt(user)"
+ :src="avatarUrl(user)"
+ />
+ <span class="author">
+ {{ user.name }}
+ </span>
+ </button>
+ <button
+ v-if="hasMoreThanTwoAssignees"
+ class="btn-link"
+ type="button"
+ >
+ <span
+ class="avatar-counter sidebar-avatar-counter"
+ >
+ {{ sidebarAvatarCounter }}
+ </span>
+ </button>
+ </div>
+ <div class="value hide-collapsed">
+ <template v-if="hasNoUsers">
+ <span class="assign-yourself no-value">
+ No assignee
+ <template v-if="editable">
+ -
+ <button
+ type="button"
+ class="btn-link"
+ @click="assignSelf"
+ >
+ assign yourself
+ </button>
+ </template>
+ </span>
+ </template>
+ <template v-else-if="hasOneUser">
+ <a
+ class="author_link bold"
+ :href="assigneeUrl(firstUser)"
+ >
+ <img
+ width="32"
+ class="avatar avatar-inline s32"
+ :alt="assigneeAlt(firstUser)"
+ :src="avatarUrl(firstUser)"
+ />
+ <span class="author">
+ {{ firstUser.name }}
+ </span>
+ <span class="username">
+ {{ assigneeUsername(firstUser) }}
+ </span>
+ </a>
+ </template>
+ <template v-else>
+ <div class="user-list">
+ <div
+ class="user-item"
+ v-for="(user, index) in users"
+ v-if="renderAssignee(index)"
+ >
+ <a
+ class="user-link has-tooltip"
+ data-placement="bottom"
+ :href="assigneeUrl(user)"
+ :data-title="user.name"
+ >
+ <img
+ width="32"
+ class="avatar avatar-inline s32"
+ :alt="assigneeAlt(user)"
+ :src="avatarUrl(user)"
+ />
+ </a>
+ </div>
+ </div>
+ <div
+ v-if="renderShowMoreSection"
+ class="user-list-more"
+ >
+ <button
+ type="button"
+ class="btn-link"
+ @click="toggleShowLess"
+ >
+ <template v-if="showLess">
+ {{ hiddenAssigneesLabel }}
+ </template>
+ <template v-else>
+ - show less
+ </template>
+ </button>
+ </div>
+ </template>
+ </div>
+ </div>
+ `,
+};
diff --git a/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.js b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.js
new file mode 100644
index 00000000000..1488a66c695
--- /dev/null
+++ b/app/assets/javascripts/sidebar/components/assignees/sidebar_assignees.js
@@ -0,0 +1,84 @@
+/* global Flash */
+
+import AssigneeTitle from './assignee_title';
+import Assignees from './assignees';
+
+import Store from '../../stores/sidebar_store';
+import Mediator from '../../sidebar_mediator';
+
+import eventHub from '../../event_hub';
+
+export default {
+ name: 'SidebarAssignees',
+ data() {
+ return {
+ mediator: new Mediator(),
+ store: new Store(),
+ loading: false,
+ field: '',
+ };
+ },
+ components: {
+ 'assignee-title': AssigneeTitle,
+ assignees: Assignees,
+ },
+ methods: {
+ assignSelf() {
+ // Notify gl dropdown that we are now assigning to current user
+ this.$el.parentElement.dispatchEvent(new Event('assignYourself'));
+
+ this.mediator.assignYourself();
+ this.saveAssignees();
+ },
+ saveAssignees() {
+ this.loading = true;
+
+ function setLoadingFalse() {
+ this.loading = false;
+ }
+
+ this.mediator.saveAssignees(this.field)
+ .then(setLoadingFalse.bind(this))
+ .catch(() => {
+ setLoadingFalse();
+ return new Flash('Error occurred when saving assignees');
+ });
+ },
+ },
+ created() {
+ this.removeAssignee = this.store.removeAssignee.bind(this.store);
+ this.addAssignee = this.store.addAssignee.bind(this.store);
+ this.removeAllAssignees = this.store.removeAllAssignees.bind(this.store);
+
+ // Get events from glDropdown
+ eventHub.$on('sidebar.removeAssignee', this.removeAssignee);
+ eventHub.$on('sidebar.addAssignee', this.addAssignee);
+ eventHub.$on('sidebar.removeAllAssignees', this.removeAllAssignees);
+ eventHub.$on('sidebar.saveAssignees', this.saveAssignees);
+ },
+ beforeDestroy() {
+ eventHub.$off('sidebar.removeAssignee', this.removeAssignee);
+ eventHub.$off('sidebar.addAssignee', this.addAssignee);
+ eventHub.$off('sidebar.removeAllAssignees', this.removeAllAssignees);
+ eventHub.$off('sidebar.saveAssignees', this.saveAssignees);
+ },
+ beforeMount() {
+ this.field = this.$el.dataset.field;
+ },
+ template: `
+ <div>
+ <assignee-title
+ :number-of-assignees="store.assignees.length"
+ :loading="loading"
+ :editable="store.editable"
+ />
+ <assignees
+ class="value"
+ :root-path="store.rootPath"
+ :users="store.assignees"
+ :editable="store.editable"
+ @assign-self="assignSelf"
+ />
+ </div>
+ `,
+};
diff --git a/app/assets/javascripts/sidebar/components/time_tracking/collapsed_state.js b/app/assets/javascripts/sidebar/components/time_tracking/collapsed_state.js
new file mode 100644
index 00000000000..0da265053bd
--- /dev/null
+++ b/app/assets/javascripts/sidebar/components/time_tracking/collapsed_state.js
@@ -0,0 +1,97 @@
+import stopwatchSvg from 'icons/_icon_stopwatch.svg';
+
+import '../../../lib/utils/pretty_time';
+
+export default {
+ name: 'time-tracking-collapsed-state',
+ props: {
+ showComparisonState: {
+ type: Boolean,
+ required: true,
+ },
+ showSpentOnlyState: {
+ type: Boolean,
+ required: true,
+ },
+ showEstimateOnlyState: {
+ type: Boolean,
+ required: true,
+ },
+ showNoTimeTrackingState: {
+ type: Boolean,
+ required: true,
+ },
+ timeSpentHumanReadable: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ timeEstimateHumanReadable: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ },
+ computed: {
+ timeSpent() {
+ return this.abbreviateTime(this.timeSpentHumanReadable);
+ },
+ timeEstimate() {
+ return this.abbreviateTime(this.timeEstimateHumanReadable);
+ },
+ divClass() {
+ if (this.showComparisonState) {
+ return 'compare';
+ } else if (this.showEstimateOnlyState) {
+ return 'estimate-only';
+ } else if (this.showSpentOnlyState) {
+ return 'spend-only';
+ } else if (this.showNoTimeTrackingState) {
+ return 'no-tracking';
+ }
+
+ return '';
+ },
+ spanClass() {
+ if (this.showComparisonState) {
+ return '';
+ } else if (this.showEstimateOnlyState || this.showSpentOnlyState) {
+ return 'bold';
+ } else if (this.showNoTimeTrackingState) {
+ return 'no-value';
+ }
+
+ return '';
+ },
+ text() {
+ if (this.showComparisonState) {
+ return `${this.timeSpent} / ${this.timeEstimate}`;
+ } else if (this.showEstimateOnlyState) {
+ return `-- / ${this.timeEstimate}`;
+ } else if (this.showSpentOnlyState) {
+ return `${this.timeSpent} / --`;
+ } else if (this.showNoTimeTrackingState) {
+ return 'None';
+ }
+
+ return '';
+ },
+ },
+ methods: {
+ abbreviateTime(timeStr) {
+ return gl.utils.prettyTime.abbreviateTime(timeStr);
+ },
+ },
+ template: `
+ <div class="sidebar-collapsed-icon">
+ ${stopwatchSvg}
+ <div class="time-tracking-collapsed-summary">
+ <div :class="divClass">
+ <span :class="spanClass">
+ {{ text }}
+ </span>
+ </div>
+ </div>
+ </div>
+ `,
+};
diff --git a/app/assets/javascripts/sidebar/components/time_tracking/comparison_pane.js b/app/assets/javascripts/sidebar/components/time_tracking/comparison_pane.js
new file mode 100644
index 00000000000..40f5c89c5bb
--- /dev/null
+++ b/app/assets/javascripts/sidebar/components/time_tracking/comparison_pane.js
@@ -0,0 +1,98 @@
+import '../../../lib/utils/pretty_time';
+
+const prettyTime = gl.utils.prettyTime;
+
+export default {
+ name: 'time-tracking-comparison-pane',
+ props: {
+ timeSpent: {
+ type: Number,
+ required: true,
+ },
+ timeEstimate: {
+ type: Number,
+ required: true,
+ },
+ timeSpentHumanReadable: {
+ type: String,
+ required: true,
+ },
+ timeEstimateHumanReadable: {
+ type: String,
+ required: true,
+ },
+ },
+ 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 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/sidebar/components/time_tracking/estimate_only_pane.js b/app/assets/javascripts/sidebar/components/time_tracking/estimate_only_pane.js
new file mode 100644
index 00000000000..ad1b9179db0
--- /dev/null
+++ b/app/assets/javascripts/sidebar/components/time_tracking/estimate_only_pane.js
@@ -0,0 +1,17 @@
+export default {
+ name: 'time-tracking-estimate-only-pane',
+ props: {
+ timeEstimateHumanReadable: {
+ type: String,
+ required: true,
+ },
+ },
+ template: `
+ <div class="time-tracking-estimate-only-pane">
+ <span class="bold">
+ Estimated:
+ </span>
+ {{ timeEstimateHumanReadable }}
+ </div>
+ `,
+};
diff --git a/app/assets/javascripts/sidebar/components/time_tracking/help_state.js b/app/assets/javascripts/sidebar/components/time_tracking/help_state.js
new file mode 100644
index 00000000000..b2a77462fe0
--- /dev/null
+++ b/app/assets/javascripts/sidebar/components/time_tracking/help_state.js
@@ -0,0 +1,44 @@
+export default {
+ name: 'time-tracking-help-state',
+ props: {
+ rootPath: {
+ type: String,
+ required: true,
+ },
+ },
+ computed: {
+ href() {
+ return `${this.rootPath}help/workflow/time_tracking.md`;
+ },
+ },
+ 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="href"
+ >
+ Learn more
+ </a>
+ </div>
+ </div>
+ `,
+};
diff --git a/app/assets/javascripts/sidebar/components/time_tracking/no_tracking_pane.js b/app/assets/javascripts/sidebar/components/time_tracking/no_tracking_pane.js
new file mode 100644
index 00000000000..d1dd1dcdd27
--- /dev/null
+++ b/app/assets/javascripts/sidebar/components/time_tracking/no_tracking_pane.js
@@ -0,0 +1,10 @@
+export default {
+ 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/sidebar/components/time_tracking/sidebar_time_tracking.js b/app/assets/javascripts/sidebar/components/time_tracking/sidebar_time_tracking.js
new file mode 100644
index 00000000000..e2dba1fb0c2
--- /dev/null
+++ b/app/assets/javascripts/sidebar/components/time_tracking/sidebar_time_tracking.js
@@ -0,0 +1,45 @@
+import '~/smart_interval';
+
+import timeTracker from './time_tracker';
+
+import Store from '../../stores/sidebar_store';
+import Mediator from '../../sidebar_mediator';
+
+export default {
+ data() {
+ return {
+ mediator: new Mediator(),
+ store: new Store(),
+ };
+ },
+ components: {
+ 'issuable-time-tracker': timeTracker,
+ },
+ methods: {
+ listenForSlashCommands() {
+ $(document).on('ajax:success', '.gfm-form', (e, data) => {
+ const subscribedCommands = ['spend_time', 'time_estimate'];
+ const changedCommands = data.commands_changes
+ ? Object.keys(data.commands_changes)
+ : [];
+ if (changedCommands && _.intersection(subscribedCommands, changedCommands).length) {
+ this.mediator.fetch();
+ }
+ });
+ },
+ },
+ mounted() {
+ this.listenForSlashCommands();
+ },
+ template: `
+ <div class="block">
+ <issuable-time-tracker
+ :time_estimate="store.timeEstimate"
+ :time_spent="store.totalTimeSpent"
+ :human_time_estimate="store.humanTimeEstimate"
+ :human_time_spent="store.humanTotalTimeSpent"
+ :rootPath="store.rootPath"
+ />
+ </div>
+ `,
+};
diff --git a/app/assets/javascripts/sidebar/components/time_tracking/spent_only_pane.js b/app/assets/javascripts/sidebar/components/time_tracking/spent_only_pane.js
new file mode 100644
index 00000000000..bf987562647
--- /dev/null
+++ b/app/assets/javascripts/sidebar/components/time_tracking/spent_only_pane.js
@@ -0,0 +1,15 @@
+export default {
+ name: 'time-tracking-spent-only-pane',
+ props: {
+ timeSpentHumanReadable: {
+ type: String,
+ required: true,
+ },
+ },
+ template: `
+ <div class="time-tracking-spend-only-pane">
+ <span class="bold">Spent:</span>
+ {{ timeSpentHumanReadable }}
+ </div>
+ `,
+};
diff --git a/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.js b/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.js
new file mode 100644
index 00000000000..ed0d71a4f79
--- /dev/null
+++ b/app/assets/javascripts/sidebar/components/time_tracking/time_tracker.js
@@ -0,0 +1,163 @@
+import timeTrackingHelpState from './help_state';
+import timeTrackingCollapsedState from './collapsed_state';
+import timeTrackingSpentOnlyPane from './spent_only_pane';
+import timeTrackingNoTrackingPane from './no_tracking_pane';
+import timeTrackingEstimateOnlyPane from './estimate_only_pane';
+import timeTrackingComparisonPane from './comparison_pane';
+
+import eventHub from '../../event_hub';
+
+export default {
+ name: 'issuable-time-tracker',
+ props: {
+ time_estimate: {
+ type: Number,
+ required: true,
+ },
+ time_spent: {
+ type: Number,
+ required: true,
+ },
+ human_time_estimate: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ human_time_spent: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ rootPath: {
+ type: String,
+ required: true,
+ },
+ },
+ data() {
+ return {
+ showHelp: false,
+ };
+ },
+ components: {
+ 'time-tracking-collapsed-state': timeTrackingCollapsedState,
+ 'time-tracking-estimate-only-pane': timeTrackingEstimateOnlyPane,
+ 'time-tracking-spent-only-pane': timeTrackingSpentOnlyPane,
+ 'time-tracking-no-tracking-pane': timeTrackingNoTrackingPane,
+ 'time-tracking-comparison-pane': timeTrackingComparisonPane,
+ 'time-tracking-help-state': timeTrackingHelpState,
+ },
+ 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;
+ },
+ update(data) {
+ this.time_estimate = data.time_estimate;
+ this.time_spent = data.time_spent;
+ this.human_time_estimate = data.human_time_estimate;
+ this.human_time_spent = data.human_time_spent;
+ },
+ },
+ created() {
+ eventHub.$on('timeTracker:updateData', this.update);
+ },
+ template: `
+ <div
+ class="time_tracker time-tracking-component-wrap"
+ v-cloak
+ >
+ <time-tracking-collapsed-state
+ :show-comparison-state="showComparisonState"
+ :show-no-time-tracking-state="showNoTimeTrackingState"
+ :show-help-state="showHelpState"
+ :show-spent-only-state="showSpentOnlyState"
+ :show-estimate-only-state="showEstimateOnlyState"
+ :time-spent-human-readable="timeSpentHumanReadable"
+ :time-estimate-human-readable="timeEstimateHumanReadable"
+ />
+ <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"
+ aria-hidden="true"
+ />
+ </div>
+ <div
+ class="close-help-button pull-right"
+ v-if="showHelpState"
+ @click="toggleHelpState(false)"
+ >
+ <i
+ class="fa fa-close"
+ aria-hidden="true"
+ />
+ </div>
+ </div>
+ <div class="time-tracking-content hide-collapsed">
+ <time-tracking-estimate-only-pane
+ v-if="showEstimateOnlyState"
+ :time-estimate-human-readable="timeEstimateHumanReadable"
+ />
+ <time-tracking-spent-only-pane
+ v-if="showSpentOnlyState"
+ :time-spent-human-readable="timeSpentHumanReadable"
+ />
+ <time-tracking-no-tracking-pane
+ v-if="showNoTimeTrackingState"
+ />
+ <time-tracking-comparison-pane
+ v-if="showComparisonState"
+ :time-estimate="timeEstimate"
+ :time-spent="timeSpent"
+ :time-spent-human-readable="timeSpentHumanReadable"
+ :time-estimate-human-readable="timeEstimateHumanReadable"
+ />
+ <transition name="help-state-toggle">
+ <time-tracking-help-state
+ v-if="showHelpState"
+ :rootPath="rootPath"
+ />
+ </transition>
+ </div>
+ </div>
+ `,
+};
diff --git a/app/assets/javascripts/sidebar/event_hub.js b/app/assets/javascripts/sidebar/event_hub.js
new file mode 100644
index 00000000000..0948c2e5352
--- /dev/null
+++ b/app/assets/javascripts/sidebar/event_hub.js
@@ -0,0 +1,3 @@
+import Vue from 'vue';
+
+export default new Vue();
diff --git a/app/assets/javascripts/sidebar/services/sidebar_service.js b/app/assets/javascripts/sidebar/services/sidebar_service.js
new file mode 100644
index 00000000000..5a82d01dc41
--- /dev/null
+++ b/app/assets/javascripts/sidebar/services/sidebar_service.js
@@ -0,0 +1,28 @@
+import Vue from 'vue';
+import VueResource from 'vue-resource';
+
+Vue.use(VueResource);
+
+export default class SidebarService {
+ constructor(endpoint) {
+ if (!SidebarService.singleton) {
+ this.endpoint = endpoint;
+
+ SidebarService.singleton = this;
+ }
+
+ return SidebarService.singleton;
+ }
+
+ get() {
+ return Vue.http.get(this.endpoint);
+ }
+
+ update(key, data) {
+ return Vue.http.put(this.endpoint, {
+ [key]: data,
+ }, {
+ emulateJSON: true,
+ });
+ }
+}
diff --git a/app/assets/javascripts/sidebar/sidebar_bundle.js b/app/assets/javascripts/sidebar/sidebar_bundle.js
new file mode 100644
index 00000000000..2ce53c2ed30
--- /dev/null
+++ b/app/assets/javascripts/sidebar/sidebar_bundle.js
@@ -0,0 +1,21 @@
+import Vue from 'vue';
+import sidebarTimeTracking from './components/time_tracking/sidebar_time_tracking';
+import sidebarAssignees from './components/assignees/sidebar_assignees';
+
+import Mediator from './sidebar_mediator';
+
+document.addEventListener('DOMContentLoaded', () => {
+ const mediator = new Mediator(gl.sidebarOptions);
+ mediator.fetch();
+
+ const sidebarAssigneesEl = document.querySelector('#js-vue-sidebar-assignees');
+
+ // Only create the sidebarAssignees vue app if it is found in the DOM
+ // We currently do not use sidebarAssignees for the MR page
+ if (sidebarAssigneesEl) {
+ new Vue(sidebarAssignees).$mount(sidebarAssigneesEl);
+ }
+
+ new Vue(sidebarTimeTracking).$mount('#issuable-time-tracker');
+});
+
diff --git a/app/assets/javascripts/sidebar/sidebar_mediator.js b/app/assets/javascripts/sidebar/sidebar_mediator.js
new file mode 100644
index 00000000000..c13f3391f0d
--- /dev/null
+++ b/app/assets/javascripts/sidebar/sidebar_mediator.js
@@ -0,0 +1,38 @@
+/* global Flash */
+
+import Service from './services/sidebar_service';
+import Store from './stores/sidebar_store';
+
+export default class SidebarMediator {
+ constructor(options) {
+ if (!SidebarMediator.singleton) {
+ this.store = new Store(options);
+ this.service = new Service(options.endpoint);
+ SidebarMediator.singleton = this;
+ }
+
+ return SidebarMediator.singleton;
+ }
+
+ assignYourself() {
+ this.store.addAssignee(this.store.currentUser);
+ }
+
+ saveAssignees(field) {
+ const selected = this.store.assignees.map(u => u.id);
+
+ // If there are no ids, that means we have to unassign (which is id = 0)
+ // And it only accepts an array, hence [0]
+ return this.service.update(field, selected.length === 0 ? [0] : selected);
+ }
+
+ fetch() {
+ this.service.get()
+ .then((response) => {
+ const data = response.json();
+ this.store.processAssigneeData(data);
+ this.store.processTimeTrackingData(data);
+ })
+ .catch(() => new Flash('Error occured when fetching sidebar data'));
+ }
+}
diff --git a/app/assets/javascripts/sidebar/stores/sidebar_store.js b/app/assets/javascripts/sidebar/stores/sidebar_store.js
new file mode 100644
index 00000000000..94408c4d715
--- /dev/null
+++ b/app/assets/javascripts/sidebar/stores/sidebar_store.js
@@ -0,0 +1,52 @@
+export default class SidebarStore {
+ constructor(store) {
+ if (!SidebarStore.singleton) {
+ const { currentUser, rootPath, editable } = store;
+ this.currentUser = currentUser;
+ this.rootPath = rootPath;
+ this.editable = editable;
+ this.timeEstimate = 0;
+ this.totalTimeSpent = 0;
+ this.humanTimeEstimate = '';
+ this.humanTimeSpent = '';
+ this.assignees = [];
+
+ SidebarStore.singleton = this;
+ }
+
+ return SidebarStore.singleton;
+ }
+
+ processAssigneeData(data) {
+ if (data.assignees) {
+ this.assignees = data.assignees;
+ }
+ }
+
+ processTimeTrackingData(data) {
+ this.timeEstimate = data.time_estimate;
+ this.totalTimeSpent = data.total_time_spent;
+ this.humanTimeEstimate = data.human_time_estimate;
+ this.humanTotalTimeSpent = data.human_total_time_spent;
+ }
+
+ addAssignee(assignee) {
+ if (!this.findAssignee(assignee)) {
+ this.assignees.push(assignee);
+ }
+ }
+
+ findAssignee(findAssignee) {
+ return this.assignees.filter(assignee => assignee.id === findAssignee.id)[0];
+ }
+
+ removeAssignee(removeAssignee) {
+ if (removeAssignee) {
+ this.assignees = this.assignees.filter(assignee => assignee.id !== removeAssignee.id);
+ }
+ }
+
+ removeAllAssignees() {
+ this.assignees = [];
+ }
+}