Skip to content
Snippets Groups Projects
Commit 387c4b2c authored by Valery Sizov's avatar Valery Sizov
Browse files

Backport of multiple_assignees_feature [ci skip]

parent 68c12e15
No related branches found
No related tags found
No related merge requests found
Showing
with 988 additions and 54 deletions
Loading
Loading
@@ -112,7 +112,8 @@ import Cookies from 'js-cookie';
toggleLabel: function(obj, $el) {
return $el.text().trim();
},
clicked: function(selected, $el, e) {
clicked: function(options) {
const { e } = options;
e.preventDefault();
if ($('input[name="ref"]').length) {
var $form = $dropdown.closest('form');
Loading
Loading
Loading
Loading
@@ -19,7 +19,10 @@
return 'Select';
}
},
clicked(item, $el, e) {
clicked(opts) {
const { $el, e } = opts;
const item = opts.selectedObj;
e.preventDefault();
onSelect();
}
Loading
Loading
Loading
Loading
@@ -35,7 +35,8 @@ class ProtectedBranchDropdown {
return _.escape(protectedBranch.id);
},
onFilter: this.toggleCreateNewButton.bind(this),
clicked: (item, $el, e) => {
clicked: (options) => {
const { $el, e } = options;
e.preventDefault();
this.onSelect();
}
Loading
Loading
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>
`,
};
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>
`,
};
/* 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>
`,
};
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>
`,
};
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>
`,
};
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>
`,
};
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>
`,
};
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>
`,
};
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>
`,
};
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>
`,
};
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>
`,
};
import Vue from 'vue';
export default new Vue();
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,
});
}
}
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');
});
/* 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'));
}
}
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 = [];
}
}
(() => {
/*
* SubbableResource can be extended to provide a pubsub-style service for one-off REST
* calls. Subscribe by passing a callback or render method you will use to handle responses.
*
* */
class SubbableResource {
constructor(resourcePath) {
this.endpoint = resourcePath;
// TODO: Switch to axios.create
this.resource = $.ajax;
this.subscribers = [];
}
subscribe(callback) {
this.subscribers.push(callback);
}
publish(newResponse) {
const responseCopy = _.extend({}, newResponse);
this.subscribers.forEach((fn) => {
fn(responseCopy);
});
return newResponse;
}
get(payload) {
return this.resource(payload)
.then(data => this.publish(data));
}
post(payload) {
return this.resource(payload)
.then(data => this.publish(data));
}
put(payload) {
return this.resource(payload)
.then(data => this.publish(data));
}
delete(payload) {
return this.resource(payload)
.then(data => this.publish(data));
}
}
gl.SubbableResource = SubbableResource;
})(window.gl || (window.gl = {}));
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment