Commit 47f60167 authored by Clement Ho's avatar Clement Ho

[skip ci] Introduce mediator

parent 7b935cc8
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 = {}));
......@@ -171,7 +171,6 @@ import './single_file_diff';
import './smart_interval';
import './snippets_list';
import './star';
import './subbable_resource';
import './subscription';
import './subscription_select';
import './syntax_highlight';
......
import eventHub from '../event_hub';
export default {
name: 'Assignees',
data() {
......@@ -52,7 +50,7 @@ export default {
},
methods: {
assignSelf() {
eventHub.$emit('addCurrentUser');
this.$emit('assignSelf');
},
toggleShowLess() {
this.showLess = !this.showLess;
......@@ -91,7 +89,7 @@ export default {
width="24"
class="avatar avatar-inline s24"
:alt="assigneeAlt(user)"
:src="user.avatarUrl"
:src="user.avatar_url"
>
<span class="author">{{user.name}}</span>
</button>
......@@ -129,7 +127,7 @@ export default {
width="32"
class="avatar avatar-inline s32"
:alt="assigneeAlt(users[0])"
:src="users[0].avatarUrl"
:src="users[0].avatar_url"
>
<span class="author">{{users[0].name}}</span>
<span class="username">@{{users[0].username}}</span>
......@@ -152,7 +150,7 @@ export default {
width="32"
class="avatar avatar-inline s32"
:alt="assigneeAlt(user)"
:src="user.avatarUrl"
:src="user.avatar_url"
/>
</a>
</div>
......
/* global Flash */
import AssigneeTitle from './assignee_title';
import Assignees from './assignees';
import eventHub from './event_hub';
import store from '../../stores/sidebar_store';
import mediator from '../../sidebar_mediator';
import AssigneeTitle from './components/assignee_title';
import Assignees from './components/assignees';
import SidebarAssigneesService from './services/sidebar_assignees_service';
import SidebarAssigneesStore from './stores/sidebar_assignees_store';
import eventHub from '../../event_hub';
export default {
el: '#js-vue-sidebar-assignees',
name: 'SidebarAssignees',
data() {
const selector = this.$options.el;
const element = document.querySelector(selector);
// Get data from data attributes passed from haml
const rootPath = element.dataset.rootPath;
const path = element.dataset.path;
const field = element.dataset.field;
const editable = element.hasAttribute('data-editable');
const currentUserId = parseInt(element.dataset.userId, 10);
const service = new SidebarAssigneesService(path, field);
const store = new SidebarAssigneesStore({
currentUserId,
rootPath,
editable,
assignees: gl.sidebarAssigneesData,
});
return {
loading: false,
store,
service,
loading: false,
field: '',
};
},
components: {
'assignee-title': AssigneeTitle,
'assignees': Assignees,
},
computed: {
numberOfAssignees() {
return this.store.users.length;
return this.store.selectedUserIds.length;
},
},
created() {
eventHub.$on('addCurrentUser', this.addCurrentUser);
eventHub.$on('addUser', this.store.addUserId.bind(this.store));
eventHub.$on('removeUser', this.store.removeUserId.bind(this.store));
eventHub.$on('removeAllUsers', this.store.removeAllUserIds.bind(this.store));
eventHub.$on('saveUsers', this.saveUsers);
},
methods: {
addCurrentUser() {
this.store.addCurrentUserId();
assignSelf() {
// Notify gl dropdown that we are now assigning to current user
this.$el.parentElement.dispatchEvent(new Event('assignYourself'));
mediator.assignYourself();
this.saveUsers();
},
saveUsers() {
this.loading = true;
this.service.update(this.store.getUserIds())
.then((response) => {
this.loading = false;
this.store.setUsers(response.data.assignees);
})
.catch(() => {
this.loading = false;
return new Flash('An error occured while saving assignees');
});
},
mediator.saveSelectedUsers(this.field).then(() => this.loading = false);
}
},
components: {
'assignee-title': AssigneeTitle,
'assignees': Assignees,
created() {
// Get events from glDropdown
eventHub.$on('sidebar:removeUser', this.store.removeUserId.bind(this.store));
eventHub.$on('sidebar:addUser', this.store.addUserId.bind(this.store));
eventHub.$on('sidebar:removeAllUsers', this.store.removeAllUserIds.bind(this.store));
eventHub.$on('sidebar:saveUsers', this.saveUsers);
},
beforeMount() {
const element = this.$el;
this.field = element.dataset.field;
},
template: `
<div>
......@@ -83,9 +57,10 @@ export default {
/>
<assignees
class="value"
v-if="!store.loading"
v-if="!loading"
:rootPath="store.rootPath"
:users="store.renderedUsers"
@assignSelf="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,
},
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 '../../../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>
<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 eventHub from '../../event_hub';
import store from '../../stores/sidebar_store';
import mediator from '../../sidebar_mediator';
export default {
data() {
return {
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) {
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,
default: 0,
},
time_spent: {
type: Number,
required: true,
default: 0,
},
human_time_estimate: {
type: String,
required: false,
},
human_time_spent: {
type: String,
required: false,
},
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>
`,
};
......@@ -4,15 +4,18 @@ import '../../vue_shared/vue_resource_interceptor';
Vue.use(VueResource);
export default class SidebarAssigneesService {
constructor(path, field) {
this.field = field;
this.path = path;
export default class SidebarService {
constructor(endpoint) {
this.endpoint = endpoint;
}
update(userIds) {
return Vue.http.put(this.path, {
[this.field]: userIds,
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', () => {
mediator.init(gl.sidebarOptions);
mediator.fetch();
new Vue(sidebarAssignees).$mount('#js-vue-sidebar-assignees');
new Vue(sidebarTimeTracking).$mount('#issuable-time-tracker');
});
import Service from './services/sidebar_service';
import store from './stores/sidebar_store';
export default {
init(options) {
store.init(options);
this.service = new Service(options.endpoint);
},
assignYourself(field) {
store.addUserId(store.currentUserId);
},
saveSelectedUsers(field) {
return new Promise((resolve, reject) => {
const selected = store.selectedUserIds;
// If there are no ids, that means we have to unassign (which is id = 0)
// And it only accepts an array, hence [0]
this.service.update(field, selected.length === 0 ? [0] : selected)
.then((response) => {
store.processUserData(response.data);
resolve();
})
.catch(() => {
reject();
return new Flash('Error occurred when saving users');
});
});
},
fetch() {
return new Promise((resolve, reject) => {
this.service.get()
.then((response) => {
this.fetching = false;
store.processUserData(response.data);
store.processTimeTrackingData(response.data);
return resolve();
})
.catch(() => {
reject();
return new Flash('Error occured when fetching sidebar data');
});
});
},
}
export default class SidebarAssigneesStore {
constructor(store) {
const { currentUserId, assignees, rootPath, editable } = store;
export default {
timeEstimate: 0,
totalTimeSpent: 0,
humanTimeEstimate: '',
humanTimeSpent: '',
selectedUserIds: [],
renderedUsers: [],
init(store) {
const { currentUserId, rootPath, editable } = store;
this.currentUserId = currentUserId;
this.rootPath = rootPath;
this.selectedUserIds = [];
this.renderedUsers = [];
this.loading = false;
this.editable = editable;
},
this.setUsers(assignees);
}
processUserData(data) {
this.renderedUsers = data.assignees;
addCurrentUserId() {
this.addUserId(this.currentUserId);
}
this.removeAllUserIds();
this.renderedUsers.map(u => this.addUserId(u.id));
},
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;
},
addUserId(id) {
// Prevent duplicate user id's from being added
if (this.selectedUserIds.indexOf(id) === -1) {
this.selectedUserIds.push(id);
}
}
},
removeUserId(id) {
this.selectedUserIds = this.selectedUserIds.filter(uid => uid !== id);
}
},
removeAllUserIds() {
this.selectedUserIds = [];
}
getUserIds() {
// If there are no ids, that means we have to unassign (which is id = 0)
return this.selectedUserIds.length > 0 ? this.selectedUserIds : [0];
}
setUsers(users) {
this.renderedUsers = users.map((u) => ({
id: u.id,
name: u.name,
username: u.username,
avatarUrl: u.avatar_url,
}));
this.selectedUserIds = users.map(u => u.id);
}
}
};
import Vue from 'vue';
import sidebarAssigneesOptions from './sidebar_assignees_options';
document.addEventListener('DOMContentLoaded', () => new Vue(sidebarAssigneesOptions));
(() => {
/*
* 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 = {}));
......@@ -3,7 +3,7 @@
/* global ListUser */
import Vue from 'vue';
import eventHub from './sidebar_assignees/event_hub';
import eventHub from './sidebar/event_hub';
(function() {
var bind = function(fn, me) { return function() { return fn.apply(me, arguments); }; },
......@@ -54,23 +54,25 @@ import eventHub from './sidebar_assignees/event_hub';
$collapsedSidebar = $block.find('.sidebar-collapsed-user');
$loading = $block.find('.block-loading').fadeOut();
$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 ($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();
}
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;
// 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);
});
$dropdown.before(input);
});
}
var getSelected = function() {
return $selectbox
......@@ -269,7 +271,7 @@ import eventHub from './sidebar_assignees/event_hub';
defaultLabel: defaultLabel,
hidden: function(e) {
if ($dropdown.hasClass('js-multiselect')) {
eventHub.$emit('saveUsers');
eventHub.$emit('sidebar:saveUsers');
}
$selectbox.hide();
......@@ -294,10 +296,10 @@ import eventHub from './sidebar_assignees/event_hub';
const id = parseInt(element.value, 10);
element.remove();
});
eventHub.$emit('removeAllUsers');
eventHub.$emit('sidebar:removeAllUsers');
} else if (isActive) {
// user selected
eventHub.$emit('addUser', user.id);
eventHub.$emit('sidebar:addUser', user.id);
// Remove unassigned selection (if it was previously selected)
const unassignedSelected = $dropdown.closest('.selectbox')
......@@ -313,7 +315,7 @@ import eventHub from './sidebar_assignees/event_hub';
}
// User unselected
eventHub.$emit('removeUser', user.id);
eventHub.$emit('sidebar:removeUser', user.id);
}
}
......
class IssueEntity < IssuableEntity
expose :branch_name
expose :confidential
expose :assignee_ids
expose :assignees
expose :due_date
expose :moved_to_id
expose :project_id
......
- todo = issuable_todo(issuable)
- content_for :page_specific_javascripts do
= 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' }
.issuable-sidebar
.issuable-sidebar{ data: { endpoint: "#{issuable_json_path(issuable)}" } }
- can_edit_issuable = can?(current_user, :"admin_#{issuable.to_ability_name}", @project)
.block.issuable-sidebar-header
- if current_user
......@@ -24,19 +24,7 @@
= 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: { path: issuable_json_path(issuable), field: "#{issuable.to_ability_name}[assignee_ids]",'editable' => can_edit_issuable ? true : false, user: { id: current_user.id }, root: { path: root_path } } }
- content_for :page_specific_javascripts do
= page_specific_javascript_bundle_tag('sidebar_assignees')
:javascript
gl.sidebarAssigneesData = [];
- issuable.assignees.each do |assignee|
:javascript
gl.sidebarAssigneesData.push({
id: #{assignee.id},
name: "#{assignee.name}",
username: "#{assignee.username}",
avatar_url: "#{assignee.avatar_url}"
})
#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
......@@ -108,11 +96,10 @@
= dropdown_tag('Milestone', options: { title: 'Assign milestone', toggle_class: 'js-milestone-select js-extra-options', filter: true, dropdown_class: 'dropdown-menu-selectable', placeholder: 'Search milestones', data: { show_no: true, field_name: "#{issuable.to_ability_name}[milestone_id]", project_id: @project.id, issuable_id: issuable.id, milestones: namespace_project_milestones_path(@project.namespace, @project, :json), ability_name: issuable.to_ability_name, issue_update: issuable_json_path(issuable), use_id: true }})
- if issuable.has_attribute?(:time_estimate)
#issuable-time-tracker.block
%issuable-time-tracker{ ':time_estimate' => 'issuable.time_estimate', ':time_spent' => 'issuable.total_time_spent', ':human_time_estimate' => 'issuable.human_time_estimate', ':human_time_spent' => 'issuable.human_total_time_spent', 'docs-url' => help_page_path('workflow/time_tracking.md') }
// Fallback while content is loading
.title.hide-collapsed
Time tracking
= icon('spinner spin', 'aria-hidden': 'true')
// Fallback while content is loading
.title.hide-collapsed
Time tracking
= icon('spinner spin', 'aria-hidden': 'true')
- if issuable.has_attribute?(:due_date)
.block.due_date
.sidebar-collapsed-icon
......@@ -229,8 +216,13 @@
= clipboard_button(clipboard_text: project_ref, title: "Copy reference to clipboard", placement: "left")
:javascript
gl.IssuableResource = new gl.SubbableResource('#{issuable_json_path(issuable)}');
new gl.IssuableTimeTracking("#{escape_javascript(serialize_issuable(issuable))}");
gl.sidebarOptions = {
endpoint: "#{issuable_json_path(issuable)}",
editable: #{can_edit_issuable ? true : false},
currentUserId: #{current_user.id},
rootPath: "#{root_path}"
};
new MilestoneSelect('{"full_path":"#{@project.full_path}"}');
new LabelsSelect();
new WeightSelect();
......
......@@ -33,7 +33,7 @@ var config = {
graphs: './graphs/graphs_bundle.js',
groups_list: './groups_list.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_request_widget: './merge_request_widget/ci_bundle.js',
mr_widget_ee: './merge_request_widget/widget_bundle.js',
......@@ -46,7 +46,6 @@ var config = {
u2f: ['vendor/u2f'],
users: './users/users_bundle.js',
vue_pipelines: './vue_pipelines_index/index.js',
sidebar_assignees: './sidebar_assignees/index.js',
},
output: {
......@@ -106,7 +105,7 @@ var config = {
'diff_notes',
'environments',
'environments_folder',
'issuable',
'sidebar',
'merge_conflicts',
'mr_widget_ee',
'vue_pipelines',
......
class VueSpecHelper {
static createComponent(Vue, componentName, propsData) {
const Component = Vue.extend.call(Vue, componentName);
return new Component({
el: document.createElement('div'),
propsData,
});
}
}
module.exports = VueSpecHelper;
import Vue from 'vue';
import VueSpecHelper from './vue_spec_helper';
import ClassSpecHelper from './class_spec_helper';
describe('VueSpecHelper', () => {
describe('createComponent', () => {
const sample = {
name: 'Sample',
props: {
content: {
type: String,
required: false,
},
},
template: `
<div>{{content}}</div>
`,
};
it('should be a static method', () => {
expect(ClassSpecHelper.itShouldBeAStaticMethod(VueSpecHelper, 'createComponent').status()).toBe('passed');
});
it('should call Vue.extend', () => {
spyOn(Vue, 'extend').and.callThrough();
VueSpecHelper.createComponent(Vue, sample, {});
expect(Vue.extend).toHaveBeenCalled();
});
it('should return view model', () => {
const vm = VueSpecHelper.createComponent(Vue, sample, {
content: 'content',
});
expect(vm.$el.textContent).toEqual('content');
});
});
});
......@@ -2,7 +2,7 @@
import Vue from 'vue';
require('~/issuable/time_tracking/components/time_tracker');
import '~/issuable/components/time_tracking/time_tracker';
function initTimeTrackingComponent(opts) {
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 = {}));
import Vue from 'vue';
import assigneeTitleComponent from '~/vue_sidebar_assignees/components/assignee_title';
import VueSpecHelper from '../helpers/vue_spec_helper';
describe('AssigneeTitle', () => {
const createComponent = props =>
VueSpecHelper.createComponent(Vue, assigneeTitleComponent, props);
describe('computed', () => {
describe('assigneeTitle', () => {
it('returns "Assignee" when there is only one assignee', () => {
const vm = createComponent({
numberOfAssignees: 1,
editable: true,
});
expect(vm.assigneeTitle).toEqual('Assignee');
});
it('returns "Assignee" when there is only no assignee', () => {
const vm = createComponent({
numberOfAssignees: 0,
editable: true,
});
expect(vm.assigneeTitle).toEqual('Assignee');
});
it('returns "2 Assignees" when there is two assignee', () => {
const vm = createComponent({
numberOfAssignees: 2,
editable: false,
});
expect(vm.assigneeTitle).toEqual('2 Assignees');
});
});
});
describe('template', () => {
it('should render assigneeTitle', () => {
const vm = createComponent({
numberOfAssignees: 100,
editable: false,
});
const el = vm.$el;
expect(el.tagName).toEqual('DIV');
expect(el.textContent.trim()).toEqual(vm.assigneeTitle);
});
it('should display spinner when loading', () => {
const el = createComponent({
numberOfAssignees: 0,
loading: true,
editable: false,
}).$el;
const i = el.querySelector('i');
expect(i).toBeDefined();
});
it('should not display spinner when not loading', () => {
const el = createComponent({
numberOfAssignees: 0,
editable: true,
}).$el;
const i = el.querySelector('i');
expect(i).toBeNull();
});
it('should display edit link when editable', () => {
const el = createComponent({
numberOfAssignees: 0,
editable: true,
}).$el;
const editLink = el.querySelector('.edit-link');
expect(editLink).toBeDefined();
});
it('should display edit link when not editable', () => {
const el = createComponent({
numberOfAssignees: 0,
editable: false,
}).$el;
const editLink = el.querySelector('.edit-link');
expect(editLink).toBeNull();
});
});
});
import Vue from 'vue';
import assigneesComponent from '~/vue_sidebar_assignees/components/collapsed/assignees';
import avatarComponent from '~/vue_sidebar_assignees/components/collapsed/avatar';
import VueSpecHelper from '../../helpers/vue_spec_helper';
import { mockUser, mockUser2, mockUser3 } from '../mock_data';
describe('CollapsedAssignees', () => {
const mockUsers = [mockUser, mockUser2];
const createAssigneesComponent = props =>
VueSpecHelper.createComponent(Vue, assigneesComponent, props);
const createAvatarComponent = props =>
VueSpecHelper.createComponent(Vue, avatarComponent, props);
describe('computed', () => {
describe('title', () => {
it('returns one name when there is one assignee', () => {
const users = Object.assign([], mockUsers);
users.pop();
const vm = createAssigneesComponent({
users,
});
expect(vm.title).toEqual('Clark Kent');
});
it('returns two names when there are two assignees', () => {
const vm = createAssigneesComponent({
users: mockUsers,
});
expect(vm.title).toEqual('Clark Kent, Bruce Wayne');
});
it('returns more text when there are more than defaultRenderCount assignees', () => {
const vm = createAssigneesComponent({
users: mockUsers,
defaultRenderCount: 1,
});
expect(vm.title).toEqual('Clark Kent, + 1 more');
});
});
describe('counter', () => {
it('should return one less than users.length', () => {
const vm = createAssigneesComponent({
users: mockUsers,
});
expect(vm.counter).toEqual('+1');
});
it('should return defaultMaxCounter+ when users.length is greater than defaultMaxCounter', () => {
const vm = createAssigneesComponent({
users: mockUsers,
defaultMaxCounter: 1,
});
expect(vm.counter).toEqual('1+');
});
});
describe('hasNoAssignees', () => {
it('returns true when there are no assignees', () => {
const vm = createAssigneesComponent({
users: [],
});
expect(vm.hasNoAssignees).toEqual(true);
});
it('returns false when there are assignees', () => {
const vm = createAssigneesComponent({
users: mockUsers,
});
expect(vm.hasNoAssignees).toEqual(false);
});
});
describe('hasTwoAssignees', () => {
it('returns true when there are two assignees', () => {
const vm = createAssigneesComponent({
users: mockUsers,
});
expect(vm.hasTwoAssignees).toEqual(true);
});
it('returns false when there is no assignes', () => {
const vm = createAssigneesComponent({
users: [],
});
expect(vm.hasTwoAssignees).toEqual(false);
});
});
describe('moreThanOneAssignees', () => {
it('returns true when there are more than one assignee', () => {
const vm = createAssigneesComponent({
users: mockUsers,
});
expect(vm.moreThanOneAssignees).toEqual(true);
});
it('returns false when there is one assignee', () => {
const users = Object.assign([], mockUsers);
users.pop();
const vm = createAssigneesComponent({
users,
});
expect(vm.moreThanOneAssignees).toEqual(false);
});
});
describe('moreThanTwoAssignees', () => {
it('returns true when there are more than two assignees', () => {
const users = Object.assign([], mockUsers);
users.push(mockUser3);
const vm = createAssigneesComponent({
users,
});
expect(vm.moreThanTwoAssignees).toEqual(true);
});
it('returns false when there are two assignees', () => {
const vm = createAssigneesComponent({
users: mockUsers,
});
expect(vm.moreThanTwoAssignees).toEqual(false);
});
});
});
describe('components', () => {
it('should have components added', () => {
expect(assigneesComponent.components['collapsed-avatar']).toBeDefined();
});
});
describe('template', () => {
function avatarProp(user) {
return {
name: user.name,
avatarUrl: user.avatarUrl,
};
}
it('should render fa-user if there are no assignees', () => {
const el = createAssigneesComponent({
users: [],
}).$el;
const sidebarCollapsedIcons = el.querySelectorAll('.sidebar-collapsed-icon');
expect(sidebarCollapsedIcons.length).toEqual(1);
const userIcon = sidebarCollapsedIcons[0].querySelector('.fa-user');
expect(userIcon).toBeDefined();
});
it('should not render fa-user if there are assignees', () => {
const el = createAssigneesComponent({
users: mockUsers,
}).$el;
const sidebarCollapsedIcons = el.querySelectorAll('.sidebar-collapsed-icon');
expect(sidebarCollapsedIcons.length).toEqual(1);
const userIcon = sidebarCollapsedIcons[0].querySelector('.fa-user');
expect(userIcon).toBeNull();
});
it('should render one assignee if there is one assignee', () => {
const users = Object.assign([], mockUsers);
users.pop();
const vm = createAssigneesComponent({
users,
});
const el = vm.$el;
const sidebarCollapsedIcons = el.querySelectorAll('.sidebar-collapsed-icon');
expect(sidebarCollapsedIcons.length).toEqual(1);
const div = sidebarCollapsedIcons[0];
expect(div.getAttribute('data-original-title')).toEqual(vm.title);
expect(div.classList.contains('multiple-users')).toEqual(false);
expect(div.querySelector('.avatar-counter')).toBeNull();
const avatarEl = createAvatarComponent(avatarProp(users[0])).$el;
const divWithoutComments = div.innerHTML.replace(/<!---->/g, '').trim();
expect(divWithoutComments).toEqual(avatarEl.outerHTML);
});
it('should render two assignees if there are two assignees', () => {
const vm = createAssigneesComponent({
users: mockUsers,
});
const el = vm.$el;
const sidebarCollapsedIcons = el.querySelectorAll('.sidebar-collapsed-icon');
expect(sidebarCollapsedIcons.length).toEqual(1);
const div = sidebarCollapsedIcons[0];
expect(div.getAttribute('data-original-title')).toEqual(vm.title);
expect(div.classList.contains('multiple-users')).toEqual(true);
expect(div.querySelector('.avatar-counter')).toBeNull();
const avatarEl = [
createAvatarComponent(avatarProp(mockUsers[0])).$el,
createAvatarComponent(avatarProp(mockUsers[1])).$el,
];
const divWithoutComments = div.innerHTML.replace(/<!---->/g, '').trim();
expect(divWithoutComments).toEqual(`${avatarEl[0].outerHTML} ${avatarEl[1].outerHTML}`);
});
it('should render counter if there are more than two assignees', () => {
const users = Object.assign([], mockUsers);
users.push(mockUser3);
const vm = createAssigneesComponent({
users,
});
const el = vm.$el;
const sidebarCollapsedIcons = el.querySelectorAll('.sidebar-collapsed-icon');
expect(sidebarCollapsedIcons.length).toEqual(1);
const div = sidebarCollapsedIcons[0];
expect(div.getAttribute('data-original-title')).toEqual(vm.title);
expect(div.classList.contains('multiple-users')).toEqual(true);
const avatarCounter = div.querySelector('.avatar-counter');
expect(avatarCounter).toBeDefined();
expect(avatarCounter.textContent).toEqual(vm.counter);
const avatarEl = createAvatarComponent(avatarProp(users[0])).$el;
expect(div.innerHTML.indexOf(avatarEl.outerHTML) !== -1).toEqual(true);
});
});
});
import Vue from 'vue';
import avatarComponent from '~/vue_sidebar_assignees/components/collapsed/avatar';
import VueSpecHelper from '../../helpers/vue_spec_helper';
import { mockUser } from '../mock_data';
describe('CollapsedAvatar', () => {
const createComponent = props =>
VueSpecHelper.createComponent(Vue, avatarComponent, props);
describe('computed', () => {
describe('alt', () => {
it('returns avatar alt text', () => {
const vm = createComponent(mockUser);
expect(vm.alt).toEqual(`${mockUser.name}'s avatar`);
});
});
});
describe('template', () => {
it('should render alt text', () => {
const vm = createComponent(mockUser);
const el = vm.$el;
const avatar = el.querySelector('.avatar');
expect(avatar.getAttribute('alt')).toEqual(vm.alt);
});
it('should render avatar src url', () => {
const el = createComponent(mockUser).$el;
const avatar = el.querySelector('.avatar');
expect(avatar.getAttribute('src')).toEqual(mockUser.avatarUrl);
});
it('should render name', () => {
const el = createComponent(mockUser).$el;
const span = el.querySelector('.author');
expect(span.textContent).toEqual(mockUser.name);
});
});
});
import Vue from 'vue';
import multipleAssigneesComponent from '~/vue_sidebar_assignees/components/expanded/multiple_assignees';
import VueSpecHelper from '../../helpers/vue_spec_helper';
import { mockUser, mockUser2, mockUser3 } from '../mock_data';
describe('MultipleAssignees', () => {
const mockStore = {
users: [mockUser, mockUser2],
defaultRenderCount: 1,
rootPath: 'rootPath',
};
const createComponent = props =>
VueSpecHelper.createComponent(Vue, multipleAssigneesComponent, props);
describe('computed', () => {
describe('renderShowMoreSection', () => {
it('should return true when users.length is greater than defaultRenderCount', () => {
const vm = createComponent({
store: mockStore,
});
expect(vm.renderShowMoreSection).toEqual(true);
});
it('should return false when users.length is not greater than defaultRenderCount', () => {
const newStore = Object.assign({}, mockStore);
newStore.defaultRenderCount = 5;
const vm = createComponent({
store: newStore,
});
expect(vm.renderShowMoreSection).toEqual(false);
});
});
describe('numberOfHiddenAssignees', () => {
it('should return number of assignees that are not rendered', () => {
const vm = createComponent({
store: mockStore,
});
expect(vm.numberOfHiddenAssignees).toEqual(1);
});
});
describe('isHiddenAssignees', () => {
it('should return true when numberOfHiddenAssignees is greater than zero', () => {
const vm = createComponent({
store: mockStore,
});
expect(vm.isHiddenAssignees).toEqual(true);
});
it('should return false when numberOfHiddenAssignees is zero', () => {
const newStore = Object.assign({}, mockStore);
newStore.defaultRenderCount = 2;
const vm = createComponent({
store: newStore,
});
expect(vm.isHiddenAssignees).toEqual(false);
});
});
});
describe('methods', () => {
describe('toggleShowLess', () => {
it('should toggle showLess', () => {
const vm = createComponent({
store: mockStore,
});
expect(vm.showLess).toEqual(true);
vm.toggleShowLess();
expect(vm.showLess).toEqual(false);
});
});
describe('renderAssignee', () => {
it('should return true if showLess is false', () => {
const vm = createComponent({
store: mockStore,
});
vm.showLess = false;
expect(vm.renderAssignee(0)).toEqual(true);
});
it('should return true if showLess is true and index is less than defaultRenderCount', () => {
const vm = createComponent({
store: mockStore,
});
vm.showLess = true;
expect(vm.renderAssignee(0)).toEqual(true);
});
it('should return false if showLess is true and index is greater than defaultRenderCount', () => {
const vm = createComponent({
store: mockStore,
});
vm.showLess = true;
expect(vm.renderAssignee(10)).toEqual(false);
});
});
describe('assigneeUrl', () => {
it('should return url', () => {
const vm = createComponent({
store: mockStore,
});
const username = 'username';
expect(vm.assigneeUrl(username)).toEqual(`${mockStore.rootPath}${username}`);
});
});
describe('assigneeAlt', () => {
it('should return alt', () => {
const vm = createComponent({
store: mockStore,
});
const name = 'name';
expect(vm.assigneeAlt(name)).toEqual(`${name}'s avatar`);
});
});
});
describe('template', () => {
let vm;
let el;
describe('userItem', () => {
let userItems;
beforeEach(() => {
const newStore = Object.assign({}, mockStore);
newStore.defaultRenderCount = 2;
// Create a new copy to prevent mutating `mockStore.users`
const users = Object.assign([], mockStore.users);
users.push(mockUser3);
newStore.users = users;
vm = createComponent({
store: newStore,
});
el = vm.$el;
userItems = el.querySelectorAll('.user-item');
});
it('should render multiple user-item', () => {
expect(userItems.length).toEqual(2);
});
it('should render href', () => {
[].forEach.call(userItems, (item, index) => {
const user = vm.store.users[index];
const a = item.querySelector('a');
expect(a.getAttribute('href')).toEqual(vm.assigneeUrl(user.username));
});
});
it('should render anchor title', () => {
[].forEach.call(userItems, (item, index) => {
const user = vm.store.users[index];
const a = item.querySelector('a');
expect(a.getAttribute('data-title')).toEqual(user.name);
});
});
it('should render image alt', () => {
[].forEach.call(userItems, (item, index) => {
const user = vm.store.users[index];
const img = item.querySelector('img');
expect(img.getAttribute('alt')).toEqual(vm.assigneeAlt(user.name));
});
});
it('should render image', () => {
[].forEach.call(userItems, (item, index) => {
const user = vm.store.users[index];
const img = item.querySelector('img');
expect(img.getAttribute('src')).toEqual(user.avatarUrl);
});
});
});
describe('userListMore', () => {
beforeEach(() => {
vm = createComponent({
store: mockStore,
});
el = vm.$el;
});
it('should render user-list-more', () => {
const userListMore = el.querySelector('.user-list-more');
expect(userListMore).toBeDefined();
});
it('should toggle user-list-more', (done) => {
const button = el.querySelector('button');
const buttonContent = button.textContent;
button.click();
Vue.nextTick(() => {
expect(button.textContent.trim()).not.toEqual(buttonContent);
done();
});
});
it('should render show less', () => {
const button = el.querySelector('button');
expect(button.textContent.trim()).toEqual('+ 1 more');
});
describe('show more', () => {
let button;
beforeEach(() => {
button = el.querySelector('button');
});
it('should render show more', (done) => {
button.click();
Vue.nextTick(() => {
expect(button.textContent.trim()).toEqual('- show less');
done();
});
});
it('should render number of hidden assignees', (done) => {
const count = el.querySelectorAll('.user-item').length;
button.click();
Vue.nextTick(() => {
expect(el.querySelectorAll('.user-item').length > count).toEqual(true);
done();
});
});
});
});
});
});
import Vue from 'vue';
import noAssigneeComponent from '~/vue_sidebar_assignees/components/expanded/no_assignee';
import VueSpecHelper from '../../helpers/vue_spec_helper';
describe('NoAssignee', () => {
const mockStore = {
addCurrentUser: () => {},
};
const createComponent = props =>
VueSpecHelper.createComponent(Vue, noAssigneeComponent, props);
describe('methods', () => {
describe('assignSelf', () => {
it('should call addCurrentUser in store', () => {
spyOn(mockStore, 'addCurrentUser').and.callThrough();
const vm = createComponent({
store: mockStore,
});
vm.assignSelf();
expect(mockStore.addCurrentUser).toHaveBeenCalled();
});
});
});
describe('template', () => {
it('should call addCurrentUser when button is clicked', () => {
spyOn(mockStore, 'addCurrentUser').and.callThrough();
const vm = createComponent({
store: mockStore,
});
const el = vm.$el;
const button = el.querySelector('button');
button.click();
expect(mockStore.addCurrentUser).toHaveBeenCalled();
});
});
});
import Vue from 'vue';
import singleAssigneeComponent from '~/vue_sidebar_assignees/components/expanded/single_assignee';
import VueSpecHelper from '../../helpers/vue_spec_helper';
import { mockUser, mockUser2 } from '../mock_data';
describe('SingleAssignee', () => {
const mockStore = {
users: [mockUser],
rootPath: 'rootPath',
};
const createComponent = props =>
VueSpecHelper.createComponent(Vue, singleAssigneeComponent, props);
describe('computed', () => {
describe('user', () => {
it('should return first user', () => {
const newMockStore = Object.assign({}, mockStore);
newMockStore.users.push(mockUser2);
const vm = createComponent({
store: newMockStore,
});
expect(vm.user).toEqual(newMockStore.users[0]);
});
});
describe('userUrl', () => {
it('should return url', () => {
const vm = createComponent({
store: mockStore,
});
expect(vm.userUrl).toEqual(`${mockStore.rootPath}${mockStore.users[0].username}`);
});
});
describe('username', () => {
it('should return username', () => {
const vm = createComponent({
store: mockStore,
});
expect(vm.username).toEqual(`@${mockStore.users[0].username}`);
});
});
describe('avatarAlt', () => {
it('should return alt text', () => {
const vm = createComponent({
store: mockStore,
});
expect(vm.avatarAlt).toEqual(`${mockStore.users[0].name}'s avatar`);
});
});
});
describe('template', () => {
let vm;
let el;
beforeEach(() => {
vm = createComponent({
store: mockStore,
});
el = vm.$el;
});
it('should load the userUrl in href ', () => {
const link = el.querySelector('a');
expect(link.href).toEqual(`${window.location.origin}/${vm.userUrl}`);
});
it('should load the avatarAlt', () => {
const img = el.querySelector('img');
expect(img.alt).toEqual(vm.avatarAlt);
});
it('should load the avatar image', () => {
const img = el.querySelector('img');
expect(img.src).toEqual(vm.user.avatarUrl);
});
it('should load the user\'s name', () => {
const name = el.querySelector('.author');
expect(name.textContent).toEqual(vm.user.name);
});
it('should load the user\'s username', () => {
const username = el.querySelector('.username');
expect(username.textContent).toEqual(vm.username);
});
});
});
const mockUser = {
id: 1,
name: 'Clark Kent',
username: 'superman',
avatarUrl: 'https://superman.com/avatar.png',
};
const mockUser2 = {
id: 2,
name: 'Bruce Wayne',
username: 'batman',
avatarUrl: 'https://batman.com/avatar.png',
};
const mockUser3 = {
id: 3,
name: 'Tony Stark',
username: 'ironman',
avatarUrl: 'https://ironman.com/avatar.png',
};
module.exports = {
mockUser,
mockUser2,
mockUser3,
};
import SidebarAssigneesService from '~/vue_sidebar_assignees/services/sidebar_assignees_service';
describe('SidebarAssigneesService', () => {
let service;
beforeEach(() => {
service = new SidebarAssigneesService('', 'field');
});
describe('constructor', () => {
it('should save field', () => {
expect(service.field).toEqual('field');
});
it('should save sidebarAssigneeResource', () => {
expect(service.sidebarAssigneeResource).toBeDefined();
});
});
describe('update', () => {
it('should call vue resource update', (done) => {
const userIds = [1, 2, 3];
spyOn(service.sidebarAssigneeResource, 'update').and.callFake((o) => {
expect(o.field).toEqual(userIds);
done();
});
service.update(userIds);
});
});
});
import '~/flash';
import SidebarAssigneesStore from '~/vue_sidebar_assignees/stores/sidebar_assignees_store';
import { mockUser } from '../mock_data';
describe('SidebarAssigneesStore', () => {
let params;
beforeEach(() => {
params = {
currentUserId: 1,
service: {
update: () => {},
},
rootPath: 'rootPath',
editable: true,
};
});
const getStore = p => new SidebarAssigneesStore(p);
it('should store information', () => {
const store = getStore(params);
Object.keys(params).forEach((k) => {
expect(store[k]).toEqual(params[k]);
});
});
describe('addUser', () => {
let store;
beforeEach(() => {
store = getStore(params);
});
it('should add user to users array', () => {
expect(store.users.length).toEqual(0);
store.addUser(mockUser);
expect(store.users.length).toEqual(1);
expect(store.users[0]).toEqual(mockUser);
expect(store.saved).toEqual(false);
});
it('should set saved flag to true if second param is true', () => {
store.addUser(mockUser, true);
expect(store.saved).toEqual(true);
});
});
describe('addCurrentUser', () => {
let store;
beforeEach(() => {
store = getStore(params);
spyOn(store, 'saveUsers').and.callFake(() => {});
});
it('should add current user to users array', () => {
spyOn(store, 'addUser').and.callThrough();
store.addCurrentUser();
expect(store.addUser).toHaveBeenCalledWith({
id: 1,
});
});
it('should call saveUsers', () => {
store.addCurrentUser();
expect(store.saveUsers).toHaveBeenCalled();
});
});
describe('removeUser', () => {
let store;
beforeEach(() => {
store = getStore(params);
store.addUser(mockUser, true);
});
it('should remove user from users array', () => {
expect(store.users.length).toEqual(1);
store.removeUser(mockUser.id);
expect(store.users.length).toEqual(0);
});
it('should set saved flag to false', () => {
expect(store.saved).toEqual(true);
store.removeUser(mockUser.id);
expect(store.saved).toEqual(false);
});
});
describe('saveUsers', () => {
it('should save unassigned user when there are no users2', () => {
const spyParams = Object.assign({}, params);
const store = getStore(spyParams);
spyOn(spyParams.service, 'update').and.callFake(() =>
new Promise(resolve =>
resolve({
data: {
assignees: [],
},
}),
),
);
store.saveUsers();
expect(spyParams.service.update).toHaveBeenCalledWith([0]);
expect(store.users.length).toEqual(0);
});
it('should catch error', () => {
const spyParams = Object.assign({}, params);
const store = getStore(spyParams);
spyOn(window, 'Flash').and.callThrough();
spyOn(spyParams.service, 'update').and.callFake(() =>
new Promise((resolve, reject) => reject()),
);
store.saveUsers();
setTimeout(() => {
expect(window.Flash).toHaveBeenCalled();
});
});
it('should save unassigned user when there are no users', () => {
const spyParams = Object.assign({}, params);
const store = getStore(spyParams);
spyOn(spyParams.service, 'update').and.callFake(() =>
new Promise(resolve =>
resolve({
data: {
assignees: [],
},
}),
),
);
store.saveUsers();
expect(spyParams.service.update).toHaveBeenCalledWith([0]);
expect(store.users.length).toEqual(0);
});
});
});
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