Commit 95e04916 authored by Clement Ho's avatar Clement Ho Committed by Jacob Schatz

Add multiple assignee support to sidebar

parent 8cce7368
...@@ -255,7 +255,8 @@ GitLabDropdown = (function() { ...@@ -255,7 +255,8 @@ GitLabDropdown = (function() {
} }
}; };
// Remote data // Remote data
})(this) })(this),
instance: this,
}); });
} }
} }
...@@ -269,6 +270,7 @@ GitLabDropdown = (function() { ...@@ -269,6 +270,7 @@ GitLabDropdown = (function() {
remote: this.options.filterRemote, remote: this.options.filterRemote,
query: this.options.data, query: this.options.data,
keys: searchFields, keys: searchFields,
instance: this,
elements: (function(_this) { elements: (function(_this) {
return function() { return function() {
selector = '.dropdown-content li:not(' + NON_SELECTABLE_CLASSES + ')'; selector = '.dropdown-content li:not(' + NON_SELECTABLE_CLASSES + ')';
...@@ -343,21 +345,26 @@ GitLabDropdown = (function() { ...@@ -343,21 +345,26 @@ GitLabDropdown = (function() {
} }
this.dropdown.on("click", selector, function(e) { this.dropdown.on("click", selector, function(e) {
var $el, selected, selectedObj, isMarking; var $el, selected, selectedObj, isMarking;
$el = $(this); $el = $(e.currentTarget);
selected = self.rowClicked($el); selected = self.rowClicked($el);
selectedObj = selected ? selected[0] : null; selectedObj = selected ? selected[0] : null;
isMarking = selected ? selected[1] : null; isMarking = selected ? selected[1] : null;
if (self.options.clicked) { if (this.options.clicked) {
self.options.clicked(selectedObj, $el, e, isMarking); this.options.clicked.call(this, {
selectedObj,
$el,
e,
isMarking,
});
} }
// Update label right after all modifications in dropdown has been done // Update label right after all modifications in dropdown has been done
if (self.options.toggleLabel) { if (this.options.toggleLabel) {
self.updateLabel(selectedObj, $el, self); this.updateLabel(selectedObj, $el, this);
} }
$el.trigger('blur'); $el.trigger('blur');
}); }.bind(this));
} }
} }
...@@ -724,6 +731,7 @@ GitLabDropdown = (function() { ...@@ -724,6 +731,7 @@ GitLabDropdown = (function() {
if (this.options.inputId != null) { if (this.options.inputId != null) {
$input.attr('id', this.options.inputId); $input.attr('id', this.options.inputId);
} }
return this.dropdown.before($input); return this.dropdown.before($input);
}; };
...@@ -844,7 +852,14 @@ GitLabDropdown = (function() { ...@@ -844,7 +852,14 @@ GitLabDropdown = (function() {
if (instance == null) { if (instance == null) {
instance = null; instance = null;
} }
return $(this.el).find(".dropdown-toggle-text").text(this.options.toggleLabel(selected, el, instance));
let toggleText = this.options.toggleLabel(selected, el, instance);
if (this.options.updateLabel) {
// Option to override the dropdown label text
toggleText = this.options.updateLabel;
}
return $(this.el).find(".dropdown-toggle-text").text(toggleText);
}; };
GitLabDropdown.prototype.clearField = function(field, isInput) { GitLabDropdown.prototype.clearField = function(field, isInput) {
......
require('./time_tracking/time_tracking_bundle');
import Vue from 'vue';
import stopwatchSvg from 'icons/_icon_stopwatch.svg';
require('../../../lib/utils/pretty_time');
(() => {
Vue.component('time-tracking-collapsed-state', {
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,
},
timeEstimateHumanReadable: {
type: String,
required: false,
},
},
methods: {
abbreviateTime(timeStr) {
return gl.utils.prettyTime.abbreviateTime(timeStr);
},
},
template: `
<div class='sidebar-collapsed-icon'>
${stopwatchSvg}
<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>
`,
});
})();
import Vue from 'vue';
require('../../../lib/utils/pretty_time');
(() => {
const prettyTime = gl.utils.prettyTime;
Vue.component('time-tracking-comparison-pane', {
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>
<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>
`,
});
})();
import Vue from 'vue';
(() => {
Vue.component('time-tracking-estimate-only-pane', {
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>
`,
});
})();
import Vue from 'vue';
(() => {
Vue.component('time-tracking-help-state', {
name: 'time-tracking-help-state',
props: {
docsUrl: {
type: String,
required: true,
},
},
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>
`,
});
})();
import Vue from '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>
`,
});
})();
import Vue from 'vue';
(() => {
Vue.component('time-tracking-spent-only-pane', {
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 Vue from '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: {
type: Number,
required: true,
default: 0,
},
time_spent: {
type: Number,
required: true,
default: 0,
},
human_time_estimate: {
type: String,
required: false,
},
human_time_spent: {
type: String,
required: false,
},
docsUrl: {
type: String,
required: true,
},
},
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-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'>
</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' aria-hidden='true'></i>
</div>
<div class='close-help-button pull-right'
v-if='showHelpState'
@click='toggleHelpState(false)'>
<i class='fa fa-close' aria-hidden='true'></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>
`,
});
})();
import Vue from 'vue';
import VueResource from 'vue-resource';
require('./components/time_tracker');
require('../../smart_interval');
require('../../subbable_resource');
Vue.use(VueResource);
(() => {
/* 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
? Object.keys(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 = {}));
...@@ -19,8 +19,8 @@ ...@@ -19,8 +19,8 @@
return label; return label;
}; };
})(this), })(this),
clicked: function(item, $el, e) { clicked: function(options) {
return e.preventDefault(); return options.e.preventDefault();
}, },
id: function(obj, el) { id: function(obj, el) {
return $(el).data("id"); return $(el).data("id");
......
...@@ -330,7 +330,10 @@ ...@@ -330,7 +330,10 @@
}, },
multiSelect: $dropdown.hasClass('js-multiselect'), multiSelect: $dropdown.hasClass('js-multiselect'),
vue: $dropdown.hasClass('js-issue-board-sidebar'), vue: $dropdown.hasClass('js-issue-board-sidebar'),
clicked: function(label, $el, e, isMarking) { clicked: function(options) {
const { $el, e, isMarking } = options;
const label = options.selectedObj;
var isIssueIndex, isMRIndex, page, boardsModel; var isIssueIndex, isMRIndex, page, boardsModel;
page = $('body').data('page'); page = $('body').data('page');
......
...@@ -171,7 +171,6 @@ import './single_file_diff'; ...@@ -171,7 +171,6 @@ import './single_file_diff';
import './smart_interval'; import './smart_interval';
import './snippets_list'; import './snippets_list';
import './star'; import './star';
import './subbable_resource';
import './subscription'; import './subscription';
import './subscription_select'; import './subscription_select';
import './syntax_highlight'; import './syntax_highlight';
......
...@@ -144,7 +144,10 @@ import Vue from 'vue'; ...@@ -144,7 +144,10 @@ import Vue from 'vue';
return true; return true;
}, },
clicked: function(selected, $el, e) { clicked: function(options) {
const { $el, e } = options;
let selected = options.selectedObj;
var data, isIssueIndex, isMRIndex, page, boardsStore; var data, isIssueIndex, isMRIndex, page, boardsStore;
if (!selected) return; if (!selected) return;
page = $('body').data('page'); page = $('body').data('page');
......
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,
},
},
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 -
<button
type="button"
class="btn-link"
@click="assignSelf"
>
assign yourself
</button>
</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"
@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.humanTimeSpent = data.human_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 = {}));
...@@ -19,8 +19,8 @@ ...@@ -19,8 +19,8 @@
return label; return label;
}; };
})(this), })(this),
clicked: function(item, $el, e) { clicked: function(options) {
return e.preventDefault(); return options.e.preventDefault();
}, },
id: function(obj, el) { id: function(obj, el) {
return $(el).data("id"); return $(el).data("id");
......
...@@ -3,6 +3,7 @@ ...@@ -3,6 +3,7 @@
/* global ListUser */ /* global ListUser */
import Vue from 'vue'; import Vue from 'vue';
import eventHub from './sidebar/event_hub';
(function() { (function() {
var bind = function(fn, me) { return function() { return fn.apply(me, arguments); }; }, var bind = function(fn, me) { return function() { return fn.apply(me, arguments); }; },
...@@ -53,6 +54,33 @@ import Vue from 'vue'; ...@@ -53,6 +54,33 @@ import Vue from 'vue';
$collapsedSidebar = $block.find('.sidebar-collapsed-user'); $collapsedSidebar = $block.find('.sidebar-collapsed-user');
$loading = $block.find('.block-loading').fadeOut(); $loading = $block.find('.block-loading').fadeOut();
if ($block[0]) {
$block[0].addEventListener('assignYourself', () => {
// Remove unassigned selected from the DOM
const unassignedSelected = $dropdown.closest('.selectbox')
.find("input[name='" + ($dropdown.data('field-name')) + "'][value=0]");
if (unassignedSelected) {
unassignedSelected.remove();
}
// Save current selected user to the DOM
const input = document.createElement('input');
input.type = 'hidden';
input.name = $dropdown.data('field-name');
input.value = _this.currentUser.id;
$dropdown.before(input);
});
}
var getSelected = function() {
return $selectbox
.find(`input[name="${$dropdown.data('field-name')}"]`)
.map((index, input) => parseInt(input.value, 10))
.get();
};
var updateIssueBoardsIssue = function () { var updateIssueBoardsIssue = function () {
$loading.removeClass('hidden').fadeIn(); $loading.removeClass('hidden').fadeIn();
gl.issueBoards.BoardsStore.detail.issue.update($dropdown.attr('data-issue-update')) gl.issueBoards.BoardsStore.detail.issue.update($dropdown.attr('data-issue-update'))
...@@ -93,6 +121,7 @@ import Vue from 'vue'; ...@@ -93,6 +121,7 @@ import Vue from 'vue';
data[abilityName].assignee_id = selected != null ? selected : null; data[abilityName].assignee_id = selected != null ? selected : null;
$loading.removeClass('hidden').fadeIn(); $loading.removeClass('hidden').fadeIn();
$dropdown.trigger('loading.gl.dropdown'); $dropdown.trigger('loading.gl.dropdown');
return $.ajax({ return $.ajax({
type: 'PUT', type: 'PUT',
dataType: 'json', dataType: 'json',
...@@ -102,7 +131,6 @@ import Vue from 'vue'; ...@@ -102,7 +131,6 @@ import Vue from 'vue';
var user; var user;
$dropdown.trigger('loaded.gl.dropdown'); $dropdown.trigger('loaded.gl.dropdown');
$loading.fadeOut(); $loading.fadeOut();
$selectbox.hide();
if (data.assignee) { if (data.assignee) {
user = { user = {
name: data.assignee.name, name: data.assignee.name,
...@@ -129,7 +157,20 @@ import Vue from 'vue'; ...@@ -129,7 +157,20 @@ import Vue from 'vue';
var isAuthorFilter; var isAuthorFilter;
isAuthorFilter = $('.js-author-search'); isAuthorFilter = $('.js-author-search');
return _this.users(term, options, function(users) { return _this.users(term, options, function(users) {
var anyUser, index, j, len, name, obj, showDivider; // GitLabDropdownFilter returns this.instance
// GitLabDropdownRemote returns this.options.instance
const glDropdown = this.instance || this.options.instance;
glDropdown.options.processData(term, users, callback);
}.bind(this));
},
processData: function(term, users, callback) {
let anyUser;
let index;
let j;
let len;
let name;
let obj;
let showDivider;
if (term.length === 0) { if (term.length === 0) {
showDivider = 0; showDivider = 0;
if (firstUser) { if (firstUser) {
...@@ -164,16 +205,42 @@ import Vue from 'vue'; ...@@ -164,16 +205,42 @@ import Vue from 'vue';
}; };
users.unshift(anyUser); users.unshift(anyUser);
} }
}
if (showDivider) { if (showDivider) {
users.splice(showDivider, 0, "divider"); users.splice(showDivider, 0, 'divider');
}
if ($dropdown.hasClass('js-multiselect')) {
const selected = getSelected().filter(i => i !== 0);
if (selected.length > 0) {
if ($dropdown.data('dropdown-header')) {
showDivider += 1;
users.splice(showDivider, 0, {
header: $dropdown.data('dropdown-header'),
});
}
const selectedUsers = users
.filter(u => selected.indexOf(u.id) !== -1)
.sort((a, b) => a.name > b.name);
users = users.filter(u => selected.indexOf(u.id) === -1);
selectedUsers.forEach((selectedUser) => {
showDivider += 1;
users.splice(showDivider, 0, selectedUser);
});
users.splice(showDivider + 1, 0, 'divider');
}
}
} }
callback(users); callback(users);
if (showMenuAbove) { if (showMenuAbove) {
$dropdown.data('glDropdown').positionMenuAbove(); $dropdown.data('glDropdown').positionMenuAbove();
} }
});
}, },
filterable: true, filterable: true,
filterRemote: true, filterRemote: true,
...@@ -182,7 +249,21 @@ import Vue from 'vue'; ...@@ -182,7 +249,21 @@ import Vue from 'vue';
}, },
selectable: true, selectable: true,
fieldName: $dropdown.data('field-name'), fieldName: $dropdown.data('field-name'),
toggleLabel: function(selected, el) { toggleLabel: function(selected, el, glDropdown) {
const inputValue = glDropdown.filterInput.val();
if (this.multiSelect && inputValue === '') {
// Remove non-users from the fullData array
const users = glDropdown.fullData.filter(r => typeof r === 'object'
&& !Object.prototype.hasOwnProperty.call(r, 'beforeDivider')
&& !Object.prototype.hasOwnProperty.call(r, 'header')
);
const callback = glDropdown.parseData.bind(glDropdown);
// Update the data model
this.processData(inputValue, users, callback);
}
if (selected && 'id' in selected && $(el).hasClass('is-active')) { if (selected && 'id' in selected && $(el).hasClass('is-active')) {
if (selected.text) { if (selected.text) {
return selected.text; return selected.text;
...@@ -194,14 +275,77 @@ import Vue from 'vue'; ...@@ -194,14 +275,77 @@ import Vue from 'vue';
} }
}, },
defaultLabel: defaultLabel, defaultLabel: defaultLabel,
inputId: 'issue_assignee_id',
hidden: function(e) { hidden: function(e) {
if ($dropdown.hasClass('js-multiselect')) {
eventHub.$emit('sidebar.saveAssignees');
}
$selectbox.hide(); $selectbox.hide();
// Recalculate where .value is because vue might have changed it
$block = $selectbox.closest('.block');
$value = $block.find('.value');
// display:block overrides the hide-collapse rule // display:block overrides the hide-collapse rule
return $value.css('display', ''); return $value.css('display', '');
}, },
multiSelect: $dropdown.hasClass('js-multiselect'),
vue: $dropdown.hasClass('js-issue-board-sidebar'), vue: $dropdown.hasClass('js-issue-board-sidebar'),
clicked: function(user, $el, e) { clicked: function(options) {
const { $el, e, isMarking } = options;
const user = options.selectedObj;
if ($dropdown.hasClass('js-multiselect')) {
const isActive = $el.hasClass('is-active');
const previouslySelected = $dropdown.closest('.selectbox')
.find("input[name='" + ($dropdown.data('field-name')) + "'][value!=0]");
// Enables support for limiting the number of users selected
// Automatically removes the first on the list if more users are selected
const maxSelect = $dropdown.data('max-select');
if (maxSelect) {
const selected = getSelected();
if (selected.length > maxSelect) {
const firstSelectedId = selected[0];
const firstSelected = $dropdown.closest('.selectbox')
.find(`input[name='${$dropdown.data('field-name')}'][value=${firstSelectedId}]`);
firstSelected.remove();
eventHub.$emit('sidebar.removeAssignee', {
id: firstSelectedId,
});
}
}
if (user.beforeDivider && user.name.toLowerCase() === 'unassigned') {
// Unassigned selected
previouslySelected.each((index, element) => {
const id = parseInt(element.value, 10);
element.remove();
});
eventHub.$emit('sidebar.removeAllAssignees');
} else if (isActive) {
// user selected
eventHub.$emit('sidebar.addAssignee', user);
// Remove unassigned selection (if it was previously selected)
const unassignedSelected = $dropdown.closest('.selectbox')
.find("input[name='" + ($dropdown.data('field-name')) + "'][value=0]");
if (unassignedSelected) {
unassignedSelected.remove();
}
} else {
if (previouslySelected.length === 0) {
// Select unassigned because there is no more selected users
this.addInput($dropdown.data('field-name'), 0, {});
}
// User unselected
eventHub.$emit('sidebar.removeAssignee', user);
}
}
var isIssueIndex, isMRIndex, page, selected; var isIssueIndex, isMRIndex, page, selected;
page = $('body').data('page'); page = $('body').data('page');
isIssueIndex = page === 'projects:issues:index'; isIssueIndex = page === 'projects:issues:index';
...@@ -236,7 +380,7 @@ import Vue from 'vue'; ...@@ -236,7 +380,7 @@ import Vue from 'vue';
} }
updateIssueBoardsIssue(); updateIssueBoardsIssue();
} else { } else if (!$dropdown.hasClass('js-multiselect')) {
selected = $dropdown.closest('.selectbox').find("input[name='" + ($dropdown.data('field-name')) + "']").val(); selected = $dropdown.closest('.selectbox').find("input[name='" + ($dropdown.data('field-name')) + "']").val();
return assignTo(selected); return assignTo(selected);
} }
...@@ -247,29 +391,43 @@ import Vue from 'vue'; ...@@ -247,29 +391,43 @@ import Vue from 'vue';
opened: function(e) { opened: function(e) {
const $el = $(e.currentTarget); const $el = $(e.currentTarget);
$el.find('.is-active').removeClass('is-active'); $el.find('.is-active').removeClass('is-active');
const initialSelected = getSelected().forEach((selectedId) => {
$el.find(`li[data-user-id="${selectedId}"] .dropdown-menu-user-link`).addClass('is-active'); $el.find(`li[data-user-id="${selectedId}"] .dropdown-menu-user-link`).addClass('is-active');
});
}, },
updateLabel: $dropdown.data('dropdown-title'),
renderRow: function(user) { renderRow: function(user) {
var avatar, img, listClosingTags, listWithName, listWithUserName, selected, username; var avatar, img, listClosingTags, listWithName, listWithUserName, selected, username;
username = user.username ? "@" + user.username : ""; username = user.username ? "@" + user.username : "";
avatar = user.avatar_url ? user.avatar_url : false; avatar = user.avatar_url ? user.avatar_url : false;
selected = user.id === parseInt(selectedId, 10) ? "is-active" : "";
const fieldName = this.fieldName;
const field = $dropdown.closest('.selectbox').find("input[name='" + fieldName + "'][value='" + user.id + "']");
if (field.length) {
selected = true;
}
img = ""; img = "";
if (user.beforeDivider != null) { if (user.beforeDivider != null) {
"<li> <a href='#' class='" + selected + "'> " + user.name + " </a> </li>"; `<li><a href='#' class='${selected === true ? 'is-active' : ''}'>${user.name}</a></li>`;
} else { } else {
if (avatar) { if (avatar) {
img = "<img src='" + avatar + "' class='avatar avatar-inline' width='30' />"; img = "<img src='" + avatar + "' class='avatar avatar-inline' width='32' />";
} }
} }
// split into three parts so we can remove the username section if nessesary
listWithName = "<li data-user-id=" + user.id + "> <a href='#' class='dropdown-menu-user-link " + selected + "'> " + img + " <strong class='dropdown-menu-user-full-name'> " + user.name + " </strong>"; return `
listWithUserName = "<span class='dropdown-menu-user-username'> " + username + " </span>"; <li data-user-id=${user.id}>
listClosingTags = "</a> </li>"; <a href='#' class='dropdown-menu-user-link ${selected === true ? 'is-active' : ''}'>
if (username === '') { ${img}
listWithUserName = ''; <strong class='dropdown-menu-user-full-name'>
} ${user.name}
return listWithName + listWithUserName + listClosingTags; </strong>
${username ? `<span class='dropdown-menu-user-username'>${username}</span>` : ''}
</a>
</li>
`;
} }
}); });
}; };
......
...@@ -54,7 +54,10 @@ ...@@ -54,7 +54,10 @@
return ''; return '';
} }
}, },
clicked: function(selected, $el, e) { clicked: function(options) {
const e = options.e;
let selected = options.selectedObj;
if ($(dropdown).is(".js-filter-submit")) { if ($(dropdown).is(".js-filter-submit")) {
return $(dropdown).parents('form').submit(); return $(dropdown).parents('form').submit();
} else if ($dropdown.is('.js-issuable-form-weight')) { } else if ($dropdown.is('.js-issuable-form-weight')) {
......
...@@ -97,7 +97,7 @@ ...@@ -97,7 +97,7 @@
.avatar-counter { .avatar-counter {
background-color: $gray-darkest; background-color: $gray-darkest;
color: $white-light; color: $white-light;
border: 1px solid $avatar-counter-border-color; border: 1px solid $border-color;
border-radius: 1em; border-radius: 1em;
font-family: $regular_font; font-family: $regular_font;
font-size: 9px; font-size: 9px;
......
...@@ -222,11 +222,9 @@ ...@@ -222,11 +222,9 @@
} }
.dropdown-header { .dropdown-header {
color: $gl-text-color; color: $gl-text-color-secondary;
font-size: 13px; font-size: 13px;
font-weight: 600;
line-height: 22px; line-height: 22px;
text-transform: capitalize;
padding: 0 16px; padding: 0 16px;
} }
...@@ -291,8 +289,8 @@ ...@@ -291,8 +289,8 @@
.dropdown-menu-user { .dropdown-menu-user {
.avatar { .avatar {
float: left; float: left;
width: 30px; width: 2 * $gl-padding;
height: 30px; height: 2 * $gl-padding;
margin: 0 10px 0 0; margin: 0 10px 0 0;
} }
} }
...@@ -331,6 +329,7 @@ ...@@ -331,6 +329,7 @@
.dropdown-menu-selectable { .dropdown-menu-selectable {
a { a {
padding-left: 26px; padding-left: 26px;
position: relative;
&.is-indeterminate, &.is-indeterminate,
&.is-active { &.is-active {
...@@ -355,6 +354,9 @@ ...@@ -355,6 +354,9 @@
&.is-active::before { &.is-active::before {
content: "\f00c"; content: "\f00c";
position: absolute;
top: 50%;
transform: translateY(-50%);
} }
} }
} }
......
...@@ -439,7 +439,6 @@ $help-shortcut-header-color: #333; ...@@ -439,7 +439,6 @@ $help-shortcut-header-color: #333;
$issues-today-bg: #f3fff2; $issues-today-bg: #f3fff2;
$issues-today-border: #e1e8d5; $issues-today-border: #e1e8d5;
$compare-display-color: #888; $compare-display-color: #888;
$avatar-counter-border-color: #e6e6e6;
/* /*
* jQuery UI * jQuery UI
......
...@@ -557,14 +557,7 @@ ...@@ -557,14 +557,7 @@
.diff-comments-more-count, .diff-comments-more-count,
.diff-notes-collapse { .diff-notes-collapse {
background-color: $gray-darkest; @extend .avatar-counter;
color: $white-light;
border: 1px solid $white-light;
border-radius: 1em;
font-family: $regular_font;
font-size: 9px;
line-height: 17px;
text-align: center;
} }
.diff-notes-collapse { .diff-notes-collapse {
......
...@@ -90,10 +90,15 @@ ...@@ -90,10 +90,15 @@
} }
.right-sidebar { .right-sidebar {
a { a,
.btn-link {
color: inherit; color: inherit;
} }
.btn-link {
outline: none;
}
.issuable-header-text { .issuable-header-text {
margin-top: 7px; margin-top: 7px;
} }
...@@ -209,6 +214,10 @@ ...@@ -209,6 +214,10 @@
} }
} }
.assign-yourself .btn-link {
padding-left: 0;
}
.bold { .bold {
font-weight: 600; font-weight: 600;
} }
...@@ -237,6 +246,10 @@ ...@@ -237,6 +246,10 @@
margin-left: 0; margin-left: 0;
} }
.assignee .user-list .avatar {
margin: 0;
}
.username { .username {
display: block; display: block;
margin-top: 4px; margin-top: 4px;
...@@ -294,14 +307,23 @@ ...@@ -294,14 +307,23 @@
margin-top: 0; margin-top: 0;
} }
.sidebar-avatar-counter {
padding-top: 2px;
}
.author { .author {
display: none; display: none;
} }
.avatar:hover { .avatar:hover,
.avatar-counter:hover {
border-color: $issuable-sidebar-color; border-color: $issuable-sidebar-color;
} }
.avatar-counter:hover {
color: $issuable-sidebar-color;
}
.btn-clipboard { .btn-clipboard {
border: none; border: none;
color: $issuable-sidebar-color; color: $issuable-sidebar-color;
...@@ -311,6 +333,17 @@ ...@@ -311,6 +333,17 @@
color: $gl-text-color; color: $gl-text-color;
} }
} }
&.multiple-users {
display: flex;
justify-content: center;
}
}
.sidebar-avatar-counter {
width: 24px;
height: 24px;
border-radius: 12px;
} }
.sidebar-collapsed-user { .sidebar-collapsed-user {
...@@ -321,6 +354,37 @@ ...@@ -321,6 +354,37 @@
.issuable-header-btn { .issuable-header-btn {
display: none; display: none;
} }
.multiple-users {
height: 24px;
margin-bottom: 17px;
margin-top: 4px;
padding-bottom: 4px;
.btn-link {
padding: 0;
border: 0;
.avatar {
margin: 0;
}
}
.btn-link:first-child {
position: absolute;
left: 10px;
z-index: 1;
}
.btn-link:last-child {
position: absolute;
right: 10px;
&:hover {
text-decoration: none;
}
}
}
} }
a { a {
...@@ -365,17 +429,21 @@ ...@@ -365,17 +429,21 @@
} }
.participants-list { .participants-list {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
margin: -5px; margin: -5px;
} }
.user-list {
display: flex;
flex-wrap: wrap;
}
.participants-author { .participants-author {
display: inline-block; flex-basis: 14%;
padding: 5px; padding: 5px;
&:nth-of-type(7n) {
padding-right: 0;
}
.author_link { .author_link {
display: block; display: block;
} }
...@@ -385,13 +453,39 @@ ...@@ -385,13 +453,39 @@
} }
} }
.participants-more { .user-item {
display: inline-block;
padding: 5px;
flex-basis: 20%;
.user-link {
display: inline-block;
}
}
.participants-more,
.user-list-more {
margin-top: 5px; margin-top: 5px;
margin-left: 5px; margin-left: 5px;
a { a,
.btn-link {
color: $gl-text-color-secondary; color: $gl-text-color-secondary;
} }
.btn-link {
outline: none;
padding: 0;
}
.btn-link:hover {
@extend a:hover;
text-decoration: none;
}
.btn-link:focus {
text-decoration: none;
}
} }
.issuable-form-padding-top { .issuable-form-padding-top {
......
...@@ -157,7 +157,7 @@ class Projects::IssuesController < Projects::ApplicationController ...@@ -157,7 +157,7 @@ class Projects::IssuesController < Projects::ApplicationController
if @issue.valid? if @issue.valid?
render json: @issue.to_json(methods: [:task_status, :task_status_short], render json: @issue.to_json(methods: [:task_status, :task_status_short],
include: { milestone: {}, include: { milestone: {},
assignees: { only: [:name, :username], methods: [:avatar_url] }, assignees: { only: [:id, :name, :username], methods: [:avatar_url] },
labels: { methods: :text_color } }) labels: { methods: :text_color } })
else else
render json: { errors: @issue.errors.full_messages }, status: :unprocessable_entity render json: { errors: @issue.errors.full_messages }, status: :unprocessable_entity
......
class IssueEntity < IssuableEntity class IssueEntity < IssuableEntity
expose :branch_name expose :branch_name
expose :confidential expose :confidential
expose :assignee_ids expose :assignees, using: API::Entities::UserBasic
expose :due_date expose :due_date
expose :moved_to_id expose :moved_to_id
expose :project_id expose :project_id
......
...@@ -13,7 +13,7 @@ ...@@ -13,7 +13,7 @@
.participants-author.js-participants-author .participants-author.js-participants-author
= link_to_member(@project, participant, name: false, size: 24) = link_to_member(@project, participant, name: false, size: 24)
- if participants_extra > 0 - if participants_extra > 0
.participants-more .hide-collapsed.participants-more
%a.js-participants-more{ href: "#", data: { original_text: "+ #{participants_size - 7} more", less_text: "- show less" } } %a.js-participants-more{ href: "#", data: { original_text: "+ #{participants_size - 7} more", less_text: "- show less" } }
+ #{participants_extra} more + #{participants_extra} more
:javascript :javascript
......
- todo = issuable_todo(issuable) - todo = issuable_todo(issuable)
- content_for :page_specific_javascripts do - content_for :page_specific_javascripts do
= page_specific_javascript_bundle_tag('common_vue') = page_specific_javascript_bundle_tag('common_vue')
= page_specific_javascript_bundle_tag('issuable') = page_specific_javascript_bundle_tag('sidebar')
%aside.right-sidebar.js-right-sidebar{ data: { "offset-top" => "101", "spy" => "affix" }, class: sidebar_gutter_collapsed_class, 'aria-live' => 'polite' } %aside.right-sidebar.js-right-sidebar{ data: { "offset-top" => "101", "spy" => "affix" }, class: sidebar_gutter_collapsed_class, 'aria-live' => 'polite' }
.issuable-sidebar .issuable-sidebar{ data: { endpoint: "#{issuable_json_path(issuable)}" } }
- 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
- if current_user - if current_user
...@@ -22,9 +22,55 @@ ...@@ -22,9 +22,55 @@
= icon('spin spinner', class: 'hidden js-issuable-todo-loading', 'aria-hidden': 'true') = icon('spin spinner', class: 'hidden js-issuable-todo-loading', 'aria-hidden': 'true')
= form_for [@project.namespace.becomes(Namespace), @project, issuable], remote: true, format: :json, html: { class: 'issuable-context-form inline-update js-issuable-update' } do |f| = form_for [@project.namespace.becomes(Namespace), @project, issuable], remote: true, format: :json, html: { class: 'issuable-context-form inline-update js-issuable-update' } do |f|
.block.assignee
- if issuable.instance_of?(Issue)
#js-vue-sidebar-assignees{ data: { field: "#{issuable.to_ability_name}[assignee_ids]" } }
- else
.sidebar-collapsed-icon.sidebar-collapsed-user{ data: { toggle: "tooltip", placement: "left", container: "body" }, title: (issuable.assignee.name if issuable.assignee) }
- if issuable.assignee
= link_to_member(@project, issuable.assignee, size: 24)
- else
= icon('user', 'aria-hidden': 'true')
.title.hide-collapsed
Assignee
= icon('spinner spin', class: 'hidden block-loading', 'aria-hidden': 'true')
- if can_edit_issuable
= link_to 'Edit', '#', class: 'edit-link pull-right'
.value.hide-collapsed
- if issuable.assignee
= link_to_member(@project, issuable.assignee, size: 32, extra_class: 'bold') do
- if issuable.instance_of?(MergeRequest) && !issuable.can_be_merged_by?(issuable.assignee)
%span.pull-right.cannot-be-merged{ data: { toggle: 'tooltip', placement: 'left' }, title: 'Not allowed to merge' }
= icon('exclamation-triangle', 'aria-hidden': 'true')
%span.username
= issuable.assignee.to_reference
- else
%span.assign-yourself.no-value
No assignee
- if can_edit_issuable
\-
%a.js-assign-yourself{ href: '#' }
assign yourself
.selectbox.hide-collapsed
- issuable.assignees.each do |assignee|
= hidden_field_tag "#{issuable.to_ability_name}[assignee_ids][]", assignee.id, id: nil
= render "shared/issuable/form/#{issuable.model_name.singular}_assignee", can_edit_issuable: can_edit_issuable, issuable: issuable, f: f - options = { toggle_class: 'js-user-search js-author-search', title: 'Assign to', filter: true, dropdown_class: 'dropdown-menu-user dropdown-menu-selectable dropdown-menu-author', placeholder: 'Search users', data: { first_user: (current_user.username if current_user), current_user: true, project_id: (@project.id if @project), author_id: issuable.author_id, field_name: "#{issuable.to_ability_name}[assignee_id]", issue_update: issuable_json_path(issuable), ability_name: issuable.to_ability_name, null_user: true } }
- if issuable.instance_of?(Issue)
- if issuable.assignees.length == 0
= hidden_field_tag "#{issuable.to_ability_name}[assignee_ids][]", 0, id: nil
- title = 'Select assignee(s)'
- options[:toggle_class] += ' js-multiselect js-save-user-data'
- options[:data][:field_name] = "#{issuable.to_ability_name}[assignee_ids][]"
- options[:data][:multi_select] = true
- options[:data]['dropdown-title'] = title
- options[:data]['dropdown-header'] = 'Assignee(s)'
- else
- title = 'Select assignee'
= dropdown_tag(title, options: options)
.block.milestone .block.milestone
.sidebar-collapsed-icon .sidebar-collapsed-icon
= icon('clock-o', 'aria-hidden': 'true') = icon('clock-o', 'aria-hidden': 'true')
...@@ -50,7 +96,6 @@ ...@@ -50,7 +96,6 @@
= 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) - if issuable.has_attribute?(:time_estimate)
#issuable-time-tracker.block #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', 'docs-url' => help_page_path('workflow/time_tracking.md') }
// Fallback while content is loading // Fallback while content is loading
.title.hide-collapsed .title.hide-collapsed
Time tracking Time tracking
...@@ -171,8 +216,13 @@ ...@@ -171,8 +216,13 @@
= 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)}'); gl.sidebarOptions = {
new gl.IssuableTimeTracking("#{escape_javascript(serialize_issuable(issuable))}"); endpoint: "#{issuable_json_path(issuable)}",
editable: #{can_edit_issuable ? true : false},
currentUser: #{current_user.to_json(only: [:username, :id, :name], methods: :avatar_url)},
rootPath: "#{root_path}"
};
new MilestoneSelect('{"full_path":"#{@project.full_path}"}'); new MilestoneSelect('{"full_path":"#{@project.full_path}"}');
new LabelsSelect(); new LabelsSelect();
new WeightSelect(); new WeightSelect();
......
...@@ -33,7 +33,7 @@ var config = { ...@@ -33,7 +33,7 @@ var config = {
graphs: './graphs/graphs_bundle.js', graphs: './graphs/graphs_bundle.js',
groups_list: './groups_list.js', groups_list: './groups_list.js',
issues: './issues/issues_bundle.js', issues: './issues/issues_bundle.js',
issuable: './issuable/issuable_bundle.js', sidebar: './sidebar/sidebar_bundle.js',
merge_conflicts: './merge_conflicts/merge_conflicts_bundle.js', merge_conflicts: './merge_conflicts/merge_conflicts_bundle.js',
merge_request_widget: './merge_request_widget/ci_bundle.js', merge_request_widget: './merge_request_widget/ci_bundle.js',
mr_widget_ee: './merge_request_widget/widget_bundle.js', mr_widget_ee: './merge_request_widget/widget_bundle.js',
...@@ -105,7 +105,7 @@ var config = { ...@@ -105,7 +105,7 @@ var config = {
'diff_notes', 'diff_notes',
'environments', 'environments',
'environments_folder', 'environments_folder',
'issuable', 'sidebar',
'merge_conflicts', 'merge_conflicts',
'mr_widget_ee', 'mr_widget_ee',
'vue_pipelines', 'vue_pipelines',
......
...@@ -2,7 +2,7 @@ ...@@ -2,7 +2,7 @@
import Vue from 'vue'; import Vue from 'vue';
require('~/issuable/time_tracking/components/time_tracker'); import '~/sidebar/components/time_tracking/time_tracker';
function initTimeTrackingComponent(opts) { function initTimeTrackingComponent(opts) {
setFixtures(` setFixtures(`
......
/* eslint-disable max-len, arrow-parens, comma-dangle */
require('~/subbable_resource');
/*
* Test that each rest verb calls the publish and subscribe function and passes the correct value back
*
*
* */
((global) => {
describe('Subbable Resource', function () {
describe('PubSub', function () {
beforeEach(function () {
this.MockResource = new global.SubbableResource('https://example.com');
});
it('should successfully add a single subscriber', function () {
const callback = () => {};
this.MockResource.subscribe(callback);
expect(this.MockResource.subscribers.length).toBe(1);
expect(this.MockResource.subscribers[0]).toBe(callback);
});
it('should successfully add multiple subscribers', function () {
const callbackOne = () => {};
const callbackTwo = () => {};
const callbackThree = () => {};
this.MockResource.subscribe(callbackOne);
this.MockResource.subscribe(callbackTwo);
this.MockResource.subscribe(callbackThree);
expect(this.MockResource.subscribers.length).toBe(3);
});
it('should successfully publish an update to a single subscriber', function () {
const state = { myprop: 1 };
const callbacks = {
one: (data) => expect(data.myprop).toBe(2),
two: (data) => expect(data.myprop).toBe(2),
three: (data) => expect(data.myprop).toBe(2)
};
const spyOne = spyOn(callbacks, 'one');
const spyTwo = spyOn(callbacks, 'two');
const spyThree = spyOn(callbacks, 'three');
this.MockResource.subscribe(callbacks.one);
this.MockResource.subscribe(callbacks.two);
this.MockResource.subscribe(callbacks.three);
state.myprop += 1;
this.MockResource.publish(state);
expect(spyOne).toHaveBeenCalled();
expect(spyTwo).toHaveBeenCalled();
expect(spyThree).toHaveBeenCalled();
});
});
});
})(window.gl || (window.gl = {}));
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment