Commit 091d1f97 authored by Phil Hughes's avatar Phil Hughes

Merge branch '1379-update-sidebar-weight-to-vue-and-add-to-boards' into 'master'

Refactor sidebar weight block to use Vue and be async + add to Issue Boards

Closes #1379

See merge request gitlab-org/gitlab-ee!3566
parents ded4e2aa e5d48566
......@@ -89,12 +89,14 @@ $(() => {
eventHub.$on('newDetailIssue', this.updateDetailIssue);
eventHub.$on('clearDetailIssue', this.clearDetailIssue);
sidebarEventHub.$on('toggleSubscription', this.toggleSubscription);
sidebarEventHub.$on('updateWeight', this.updateWeight);
},
beforeDestroy() {
eventHub.$off('updateTokens', this.updateTokens);
eventHub.$off('newDetailIssue', this.updateDetailIssue);
eventHub.$off('clearDetailIssue', this.clearDetailIssue);
sidebarEventHub.$off('toggleSubscription', this.toggleSubscription);
sidebarEventHub.$off('updateWeight', this.updateWeight);
},
mounted () {
this.filterManager = new FilteredSearchBoards(Store.filter, true, Store.cantEdit);
......@@ -131,16 +133,20 @@ $(() => {
const sidebarInfoEndpoint = newIssue.sidebarInfoEndpoint;
if (sidebarInfoEndpoint && newIssue.subscribed === undefined) {
newIssue.setFetchingState('subscriptions', true);
newIssue.setFetchingState('weight', true);
BoardService.getIssueInfo(sidebarInfoEndpoint)
.then(res => res.json())
.then((data) => {
newIssue.setFetchingState('subscriptions', false);
newIssue.setFetchingState('weight', false);
newIssue.updateData({
subscribed: data.subscribed,
weight: data.weight,
});
})
.catch(() => {
newIssue.setFetchingState('subscriptions', false);
newIssue.setFetchingState('weight', false);
Flash(__('An error occurred while fetching sidebar data'));
});
}
......@@ -166,6 +172,24 @@ $(() => {
Flash(__('An error occurred when toggling the notification subscription'));
});
}
},
updateWeight(newWeight, id) {
const issue = Store.detail.issue;
if (issue.id === id && issue.sidebarInfoEndpoint) {
issue.setLoadingState('weight', true);
BoardService.updateWeight(issue.sidebarInfoEndpoint, newWeight)
.then(res => res.json())
.then((data) => {
issue.setLoadingState('weight', false);
issue.updateData({
weight: data.weight,
});
})
.catch(() => {
issue.setLoadingState('weight', false);
Flash(__('An error occurred when updating the issue weight'));
});
}
}
},
});
......
......@@ -3,6 +3,7 @@
/* global Sidebar */
import Vue from 'vue';
import weight from 'ee/sidebar/components/weight/weight.vue';
import Flash from '../../flash';
import eventHub from '../../sidebar/event_hub';
import assigneeTitle from '../../sidebar/components/assignees/assignee_title';
......@@ -124,5 +125,6 @@ gl.issueBoards.BoardSidebar = Vue.extend({
assignees,
removeBtn: gl.issueBoards.RemoveIssueBtn,
subscriptions,
weight,
},
});
......@@ -20,6 +20,10 @@ class ListIssue {
this.position = obj.relative_position || Infinity;
this.isFetching = {
subscriptions: true,
weight: true,
};
this.isLoading = {
weight: false,
};
this.sidebarInfoEndpoint = obj.issue_sidebar_endpoint;
this.toggleSubscriptionEndpoint = obj.toggle_subscription_endpoint;
......@@ -94,6 +98,10 @@ class ListIssue {
this.isFetching[key] = value;
}
setLoadingState(key, value) {
this.isLoading[key] = value;
}
update (url) {
const data = {
issue: {
......
......@@ -122,6 +122,14 @@ export default class BoardService {
return Vue.http.get(endpoint);
}
static updateWeight(endpoint, weight = null) {
return Vue.http.put(endpoint, {
'issue[weight]': weight,
}, {
emulateJSON: true,
});
}
static toggleIssueSubscription(endpoint) {
return Vue.http.post(endpoint);
}
......
......@@ -514,10 +514,11 @@ GitLabDropdown = (function() {
const dropdownToggle = this.dropdown.find('.dropdown-menu-toggle');
const hasFilterBulkUpdate = dropdownToggle.hasClass('js-filter-bulk-update');
const shouldRefreshOnOpen = dropdownToggle.hasClass('js-gl-dropdown-refresh-on-open');
const hasMultiSelect = dropdownToggle.hasClass('js-multiselect');
// Makes indeterminate items effective
if (this.fullData && hasFilterBulkUpdate) {
if (this.fullData && (shouldRefreshOnOpen || hasFilterBulkUpdate)) {
this.parseData(this.fullData);
}
......
......@@ -15,7 +15,7 @@ import Cookies from 'js-cookie';
Sidebar.prototype.removeListeners = function () {
this.sidebar.off('click', '.sidebar-collapsed-icon');
$('.dropdown').off('hidden.gl.dropdown');
this.sidebar.off('hidden.gl.dropdown');
$('.dropdown').off('loading.gl.dropdown');
$('.dropdown').off('loaded.gl.dropdown');
$(document).off('click', '.js-sidebar-toggle');
......@@ -25,7 +25,7 @@ import Cookies from 'js-cookie';
const $document = $(document);
this.sidebar.on('click', '.sidebar-collapsed-icon', this, this.sidebarCollapseClicked);
$('.dropdown').on('hidden.gl.dropdown', this, this.onSidebarDropdownHidden);
this.sidebar.on('hidden.gl.dropdown', this, this.onSidebarDropdownHidden);
$('.dropdown').on('loading.gl.dropdown', this.sidebarDropdownLoading);
$('.dropdown').on('loaded.gl.dropdown', this.sidebarDropdownLoaded);
......@@ -180,7 +180,7 @@ import Cookies from 'js-cookie';
var $block, sidebar;
sidebar = e.data;
e.preventDefault();
$block = $(this).closest('.block');
$block = $(e.target).closest('.block');
return sidebar.sidebarDropdownHidden($block);
};
......
import Vue from 'vue';
import SidebarTimeTracking from './components/time_tracking/sidebar_time_tracking';
import SidebarAssignees from './components/assignees/sidebar_assignees';
import ConfidentialIssueSidebar from './components/confidential/confidential_issue_sidebar.vue';
import SidebarMoveIssue from './lib/sidebar_move_issue';
import LockIssueSidebar from './components/lock/lock_issue_sidebar.vue';
import sidebarParticipants from './components/participants/sidebar_participants.vue';
import sidebarSubscriptions from './components/subscriptions/sidebar_subscriptions.vue';
import Translate from '../vue_shared/translate';
Vue.use(Translate);
function mountConfidentialComponent(mediator) {
const el = document.getElementById('js-confidential-entry-point');
if (!el) return;
const dataNode = document.getElementById('js-confidential-issue-data');
const initialData = JSON.parse(dataNode.innerHTML);
const ConfidentialComp = Vue.extend(ConfidentialIssueSidebar);
new ConfidentialComp({
propsData: {
isConfidential: initialData.is_confidential,
isEditable: initialData.is_editable,
service: mediator.service,
},
}).$mount(el);
}
function mountLockComponent(mediator) {
const el = document.getElementById('js-lock-entry-point');
if (!el) return;
const dataNode = document.getElementById('js-lock-issue-data');
const initialData = JSON.parse(dataNode.innerHTML);
const LockComp = Vue.extend(LockIssueSidebar);
new LockComp({
propsData: {
isLocked: initialData.is_locked,
isEditable: initialData.is_editable,
mediator,
issuableType: gl.utils.isInIssuePage() ? 'issue' : 'merge_request',
},
}).$mount(el);
}
function mountParticipantsComponent() {
const el = document.querySelector('.js-sidebar-participants-entry-point');
if (!el) return;
// eslint-disable-next-line no-new
new Vue({
el,
components: {
sidebarParticipants,
},
render: createElement => createElement('sidebar-participants', {}),
});
}
function mountSubscriptionsComponent() {
const el = document.querySelector('.js-sidebar-subscriptions-entry-point');
if (!el) return;
// eslint-disable-next-line no-new
new Vue({
el,
components: {
sidebarSubscriptions,
},
render: createElement => createElement('sidebar-subscriptions', {}),
});
}
function mount(mediator) {
const sidebarAssigneesEl = document.getElementById('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);
}
mountConfidentialComponent(mediator);
mountLockComponent(mediator);
mountParticipantsComponent();
mountSubscriptionsComponent();
new SidebarMoveIssue(
mediator,
$('.js-move-issue'),
$('.js-move-issue-confirmation-button'),
).init();
new Vue(SidebarTimeTracking).$mount('#issuable-time-tracker');
}
export default mount;
import Vue from 'vue';
import SidebarTimeTracking from './components/time_tracking/sidebar_time_tracking';
import SidebarAssignees from './components/assignees/sidebar_assignees';
import ConfidentialIssueSidebar from './components/confidential/confidential_issue_sidebar.vue';
import SidebarMoveIssue from './lib/sidebar_move_issue';
import LockIssueSidebar from './components/lock/lock_issue_sidebar.vue';
import sidebarParticipants from './components/participants/sidebar_participants.vue';
import sidebarSubscriptions from './components/subscriptions/sidebar_subscriptions.vue';
import Translate from '../vue_shared/translate';
import Mediator from './sidebar_mediator';
Vue.use(Translate);
function mountConfidentialComponent(mediator) {
const el = document.getElementById('js-confidential-entry-point');
if (!el) return;
const dataNode = document.getElementById('js-confidential-issue-data');
const initialData = JSON.parse(dataNode.innerHTML);
const ConfidentialComp = Vue.extend(ConfidentialIssueSidebar);
new ConfidentialComp({
propsData: {
isConfidential: initialData.is_confidential,
isEditable: initialData.is_editable,
service: mediator.service,
},
}).$mount(el);
}
function mountLockComponent(mediator) {
const el = document.getElementById('js-lock-entry-point');
if (!el) return;
const dataNode = document.getElementById('js-lock-issue-data');
const initialData = JSON.parse(dataNode.innerHTML);
const LockComp = Vue.extend(LockIssueSidebar);
new LockComp({
propsData: {
isLocked: initialData.is_locked,
isEditable: initialData.is_editable,
mediator,
issuableType: gl.utils.isInIssuePage() ? 'issue' : 'merge_request',
},
}).$mount(el);
}
function mountParticipantsComponent() {
const el = document.querySelector('.js-sidebar-participants-entry-point');
if (!el) return;
// eslint-disable-next-line no-new
new Vue({
el,
components: {
sidebarParticipants,
},
render: createElement => createElement('sidebar-participants', {}),
});
}
function mountSubscriptionsComponent() {
const el = document.querySelector('.js-sidebar-subscriptions-entry-point');
if (!el) return;
// eslint-disable-next-line no-new
new Vue({
el,
components: {
sidebarSubscriptions,
},
render: createElement => createElement('sidebar-subscriptions', {}),
});
}
import mountSidebarEE from 'ee/sidebar/mount_sidebar';
import Mediator from 'ee/sidebar/sidebar_mediator';
import mountSidebar from './mount_sidebar';
function domContentLoaded() {
const sidebarOptions = JSON.parse(document.querySelector('.js-sidebar-options').innerHTML);
const mediator = new Mediator(sidebarOptions);
mediator.fetch();
const sidebarAssigneesEl = document.getElementById('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);
}
mountConfidentialComponent(mediator);
mountLockComponent(mediator);
mountParticipantsComponent();
mountSubscriptionsComponent();
new SidebarMoveIssue(
mediator,
$('.js-move-issue'),
$('.js-move-issue-confirmation-button'),
).init();
new Vue(SidebarTimeTracking).$mount('#issuable-time-tracker');
mountSidebar(mediator);
mountSidebarEE(mediator);
}
document.addEventListener('DOMContentLoaded', domContentLoaded);
......
import Store from 'ee/sidebar/stores/sidebar_store';
import Flash from '../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({
endpoint: options.endpoint,
toggleSubscriptionEndpoint: options.toggleSubscriptionEndpoint,
moveIssueEndpoint: options.moveIssueEndpoint,
projectsAutocompleteEndpoint: options.projectsAutocompleteEndpoint,
});
SidebarMediator.singleton = this;
this.initSingleton(options);
}
return SidebarMediator.singleton;
}
initSingleton(options) {
this.store = new Store(options);
this.service = new Service({
endpoint: options.endpoint,
toggleSubscriptionEndpoint: options.toggleSubscriptionEndpoint,
moveIssueEndpoint: options.moveIssueEndpoint,
projectsAutocompleteEndpoint: options.projectsAutocompleteEndpoint,
});
SidebarMediator.singleton = this;
}
assignYourself() {
this.store.addAssignee(this.store.currentUser);
}
......@@ -35,17 +39,21 @@ export default class SidebarMediator {
}
fetch() {
this.service.get()
return this.service.get()
.then(response => response.json())
.then((data) => {
this.store.setAssigneeData(data);
this.store.setTimeTrackingData(data);
this.store.setParticipantsData(data);
this.store.setSubscriptionsData(data);
this.processFetchedData(data);
})
.catch(() => new Flash('Error occurred when fetching sidebar data'));
}
processFetchedData(data) {
this.store.setAssigneeData(data);
this.store.setTimeTrackingData(data);
this.store.setParticipantsData(data);
this.store.setSubscriptionsData(data);
}
toggleSubscription() {
this.store.setFetchingState('subscriptions', true);
return this.service.toggleSubscription()
......
......@@ -15,6 +15,7 @@ export default class SidebarStore {
participants: true,
subscriptions: true,
};
this.isLoading = {};
this.autocompleteProjects = [];
this.moveToProjectId = 0;
this.isLockDialogOpen = false;
......@@ -55,6 +56,10 @@ export default class SidebarStore {
this.isFetching[key] = value;
}
setLoadingState(key, value) {
this.isLoading[key] = value;
}
addAssignee(assignee) {
if (!this.findAssignee(assignee)) {
this.assignees.push(assignee);
......
......@@ -367,7 +367,9 @@ module IssuablesHelper
editable: can_edit_issuable,
currentUser: current_user.as_json(only: [:username, :id, :name], methods: :avatar_url),
rootPath: root_path,
fullPath: @project.full_path
fullPath: @project.full_path,
weightOptions: Issue.weight_options,
weightNoneValue: Issue::WEIGHT_NONE
}
end
......
class IssuableSidebarEntity < Grape::Entity
include RequestAwareEntity
prepend ::EE::IssuableSidebarEntity
expose :participants, using: ::API::Entities::UserBasic do |issuable|
issuable.participants(request.current_user)
......
......@@ -23,6 +23,7 @@
= render "shared/boards/components/sidebar/milestone"
= render "shared/boards/components/sidebar/due_date"
= render "shared/boards/components/sidebar/labels"
= render "shared/boards/components/sidebar/weight"
= render "shared/boards/components/sidebar/notifications"
%remove-btn{ ":issue" => "issue",
":issue-update" => "'#{build_issue_link_base}/' + issue.iid + '.json'",
......
......@@ -116,31 +116,8 @@
= render partial: "shared/issuable/label_page_create"
- if issuable.supports_weight?
.block.weight
.sidebar-collapsed-icon
= icon('balance-scale')
%span
- if issuable.weight
= issuable.weight
- else
No
.title.hide-collapsed
Weight
= icon('spinner spin', class: 'block-loading')
- if can?(current_user, :"admin_#{issuable.to_ability_name}", @project)
= link_to 'Edit', '#', class: 'edit-link pull-right'
.value.hide-collapsed
- if issuable.weight
%strong= issuable.weight
- else
%span.no-value None
.selectbox.hide-collapsed
= weight_dropdown_tag(issuable, title: 'Change weight', data: { field_name: 'weight', issue_update: "#{issuable_json_path(issuable)}", ability_name: "#{issuable.to_ability_name}" }) do
%ul
- Issue.weight_options.each do |weight|
%li
%a{ href: "#", data: { id: weight, none: weight == Issue::WEIGHT_NONE }, class: ("is-active" if params[:weight] == weight.to_s) }
= weight
.js-sidebar-weight-entry-point
= render 'shared/promotions/promote_issue_weights'
- if issuable.has_attribute?(:confidential)
......
---
title: View, add, and edit weight on Issue from the Issue Board contextual sidebar
merge_request: 3566
author:
type: added
<script>
import Flash from '~/flash';
import eventHub from '~/sidebar/event_hub';
import weightComponent from './weight.vue';
export default {
props: {
mediator: {
required: true,
type: Object,
validator(mediatorObject) {
return mediatorObject.updateWeight && mediatorObject.store;
},
},
},
components: {
weight: weightComponent,
},
methods: {
onUpdateWeight(newWeight) {
this.mediator.updateWeight(newWeight)
.catch(() => {
Flash('Error occurred while updating the issue weight');
});
},
},
created() {
eventHub.$on('updateWeight', this.onUpdateWeight);
},
beforeDestroy() {
eventHub.$off('updateWeight', this.onUpdateWeight);
},
};
</script>
<template>
<weight
:fetching="mediator.store.isFetching.weight"
:loading="mediator.store.isLoading.weight"
:weight="mediator.store.weight"
:weight-options="mediator.store.weightOptions"
:weight-none-value="mediator.store.weightNoneValue"
:editable="mediator.store.editable" />
</template>
<script>
import $ from 'jquery';
import { s__ } from '~/locale';
import eventHub from '~/sidebar/event_hub';
import icon from '~/vue_shared/components/icon.vue';
import loadingIcon from '~/vue_shared/components/loading_icon.vue';
export default {
props: {
fetching: {
type: Boolean,
required: false,
default: false,
},
loading: {
type: Boolean,
required: false,
default: false,
},
weight: {
type: Number,
required: false,
},
weightOptions: {
type: Array,
required: true,
},
weightNoneValue: {
type: String,
required: true,
},
editable: {
type: Boolean,
required: false,
default: false,
},
id: {
type: Number,
required: false,
},
},
data() {
return {
shouldShowDropdown: false,
collapseAfterDropdownCloses: false,
};
},
components: {
icon,
loadingIcon,
},
computed: {
isNoValue() {
return this.checkIfNoValue(this.weight);
},
collapsedWeightLabel() {
let label = this.weight;
if (this.checkIfNoValue(this.weight)) {
label = s__('Sidebar|No');
}
return label;
},
noValueLabel() {
return s__('Sidebar|None');
},
changeWeightLabel() {
return s__('Sidebar|Change weight');
},
dropdownToggleLabel() {
let label = this.weight;
if (this.checkIfNoValue(this.weight)) {
label = s__('Sidebar|Weight');
}
return label;
},
shouldShowWeight() {
return !this.fetching && !this.shouldShowDropdown;
},
},
methods: {
checkIfNoValue(weight) {
return weight === undefined ||
weight === null ||
weight === 0 ||
weight === this.weightNoneValue;
},
showDropdown() {
this.shouldShowDropdown = true;
// Trigger the bootstrap dropdown
setTimeout(() => {
$(this.$refs.dropdownToggle).dropdown('toggle');
});
},
onCollapsedClick() {
this.collapseAfterDropdownCloses = true;
this.showDropdown();
},
},
mounted() {
$(this.$refs.weightDropdown).glDropdown({
showMenuAbove: false,
selectable: true,
filterable: false,
multiSelect: false,
data: (searchTerm, callback) => {
callback(this.weightOptions);
},
renderRow: (weight) => {
const isActive = weight === this.weight ||
(this.checkIfNoValue(weight) && this.checkIfNoValue(this.weight));
return `
<li>
<a href="#" class="${isActive ? 'is-active' : ''}">
${weight}
</a>
</li>
`;
},
hidden: () => {
this.shouldShowDropdown = false;
this.collapseAfterDropdownCloses = false;
},
clicked: (options) => {
const selectedValue = this.checkIfNoValue(options.selectedObj) ? null : options.selectedObj;
const resultantValue = options.isMarking ? selectedValue : null;
eventHub.$emit('updateWeight', resultantValue, this.id);
},
});
},
};
</script>
<template>
<div
class="block weight"
:class="{ 'collapse-after-update': collapseAfterDropdownCloses }"
>
<div
class="sidebar-collapsed-icon js-weight-collapsed-block"
@click="onCollapsedClick"
>
<icon
name="scale"
:size="16"
/>
<loading-icon
v-if="fetching"
class="js-weight-collapsed-loading-icon"
/>
<span
v-else
class="js-weight-collapsed-weight-label"
>
{{ collapsedWeightLabel }}
</span>
</div>
<div class="title hide-collapsed">
{{ s__('Sidebar|Weight') }}
<loading-icon
v-if="fetching || loading"
:inline="true"
class="js-weight-loading-icon"
/>
<a
v-if="editable"
class="pull-right js-weight-edit-link"
href="#"
@click="showDropdown"
>
{{ s__('Sidebar|Edit') }}
</a>
</div>
<div
v-if="shouldShowWeight"
class="value hide-collapsed js-weight-weight-label"
>
<strong v-if="!isNoValue">
{{ weight }}
</strong>
<span
v-else
class="no-value">
{{ noValueLabel }}
</span>
</div>
<div
class="selectbox hide-collapsed"
:class="{ show: shouldShowDropdown }"
>
<div
ref="weightDropdown"
class="dropdown"
>
<button
ref="dropdownToggle"
class="dropdown-menu-toggle js-gl-dropdown-refresh-on-open"
type="button"
data-toggle="dropdown"
>
<span
class="dropdown-toggle-text js-weight-dropdown-toggle-text"
:class="{ 'is-default': isNoValue }"
>
{{ dropdownToggleLabel }}
</span>
<i
aria-hidden="true"
data-hidden="true"
class="fa fa-chevron-down"
></i>
</button>
<div
v-once
class="dropdown-menu dropdown-select dropdown-menu-selectable dropdown-menu-weight"
>
<div class="dropdown-title">
<span>
{{ changeWeightLabel }}
</span>
<button
class="dropdown-title-button dropdown-menu-close"
aria-label="Close"
type="button"
>
<i
aria-hidden="true"
data-hidden="true"
class="fa fa-times dropdown-menu-close-icon"
></i>
</button>
</div>
<div class="dropdown-content js-weight-dropdown-content"></div>
</div>
</div>
</div>
</div>
</template>
import Vue from 'vue';
import sidebarWeight from './components/weight/sidebar_weight.vue';
function mountWeightComponent(mediator) {
const el = document.querySelector('.js-sidebar-weight-entry-point');
if (!el) return;
// eslint-disable-next-line no-new
new Vue({
el,
components: {
sidebarWeight,
},
render: createElement => createElement('sidebar-weight', {
props: {
mediator,
},
}),
});
}
function mount(mediator) {
mountWeightComponent(mediator);
}
export default mount;
import CESidebarMediator from '~/sidebar/sidebar_mediator';
import Store from 'ee/sidebar/stores/sidebar_store';
export default class SidebarMediator extends CESidebarMediator {
initSingleton(options) {
super.initSingleton(options);
this.store = new Store(options);
}
processFetchedData(data) {
super.processFetchedData(data);
this.store.setWeightData(data);
}
updateWeight(newWeight) {
this.store.setLoadingState('weight', true);
return this.service.update('issue[weight]', newWeight)
.then(res => res.json())
.then((data) => {
this.store.setWeight(data.weight);
this.store.setLoadingState('weight', false);
})
.catch((err) => {
this.store.setLoadingState('weight', false);
throw err;
});
}
}
import CESidebarStore from '~/sidebar/stores/sidebar_store';
export default class SidebarStore extends CESidebarStore {
constructor(store) {
super(store);
this.isFetching.weight = true;
this.isLoading.weight = false;
this.weight = null;
this.weightOptions = store.weightOptions;
this.weightNoneValue = store.weightNoneValue;
}
setWeightData(data) {
this.isFetching.weight = false;
this.weight = data.weight || null;
}
setWeight(newWeight) {
this.weight = newWeight;
}
}
module EE
module IssuableSidebarEntity
extend ActiveSupport::Concern
prepended do
expose :weight, if: ->(issuable, options) { issuable.supports_weight? }
end
end
end
%weight{ ":fetching" => "issue.isFetching && issue.isFetching.weight",
":loading" => "issue.isLoading && issue.isLoading.weight",
":weight" => "issue.weight",
":weight-options" => Issue.weight_options,
"weight-none-value" => Issue::WEIGHT_NONE,
":editable" => can_admin_issue?,
":id" => "issue.id" }
......@@ -9,11 +9,12 @@ describe 'Issue Boards', :js do
let!(:milestone) { create(:milestone, project: project) }
let!(:development) { create(:label, project: project, name: 'Development') }
let!(:stretch) { create(:label, project: project, name: 'Stretch') }
let!(:issue1) { create(:labeled_issue, project: project, assignees: [user], milestone: milestone, labels: [development], relative_position: 2) }
let!(:issue1) { create(:labeled_issue, project: project, assignees: [user], milestone: milestone, labels: [development], weight: 3, relative_position: 2) }
let!(:issue2) { create(:labeled_issue, project: project, labels: [development, stretch], relative_position: 1) }
let(:board) { create(:board, project: project) }
let!(:list) { create(:list, board: board, label: development, position: 0) }
let(:card) { find('.board:nth-child(2)').first('.card') }
let(:card1) { find('.board:nth-child(2)').find('.card:nth-child(2)') }
let(:card2) { find('.board:nth-child(2)').find('.card:nth-child(1)') }
around do |example|
Timecop.freeze { example.run }
......@@ -33,7 +34,7 @@ describe 'Issue Boards', :js do
context 'assignee' do
it 'updates the issues assignee' do
click_card(card)
click_card(card2)
page.within('.assignee') do
click_link 'Edit'
......@@ -49,11 +50,11 @@ describe 'Issue Boards', :js do
expect(page).to have_content(user.name)
end
expect(card).to have_selector('.avatar')
expect(card2).to have_selector('.avatar')
end
it 'adds multiple assignees' do
click_card(card)
click_card(card2)
page.within('.assignee') do
click_link 'Edit'
......@@ -69,7 +70,7 @@ describe 'Issue Boards', :js do
expect(page).to have_content(user2.name)
end
expect(card.all('.avatar').length).to eq(2)
expect(card2.all('.avatar').length).to eq(2)
end
it 'removes the assignee' do
......@@ -96,7 +97,7 @@ describe 'Issue Boards', :js do
end
it 'assignees to current user' do
click_card(card)
click_card(card2)
page.within(find('.assignee')) do
expect(page).to have_content('No assignee')
......@@ -108,11 +109,11 @@ describe 'Issue Boards', :js do
expect(page).to have_content(user.name)
end
expect(card).to have_selector('.avatar')
expect(card2).to have_selector('.avatar')
end
it 'updates assignee dropdown' do
click_card(card)
click_card(card2)
page.within('.assignee') do
click_link 'Edit'
......@@ -139,4 +140,67 @@ describe 'Issue Boards', :js do
end
end
end
context 'weight' do
it 'displays weight async' do
click_card(card1)
wait_for_requests
expect(find('.js-weight-weight-label').text).to have_content(issue1.weight)
end
it 'updates weight in sidebar to 1' do
click_card(card1)
wait_for_requests
page.within '.weight' do
click_link 'Edit'
click_link '1'
page.within '.value' do
expect(page).to have_content '1'
end
end
# Ensure the request was sent and things are persisted
visit project_board_path(project, board)
wait_for_requests
click_card(card1)
wait_for_requests
page.within '.weight' do
page.within '.value' do
expect(page).to have_content '1'
end
end
end
it 'updates weight in sidebar to no weight' do
click_card(card1)
wait_for_requests
page.within '.weight' do
click_link 'Edit'
click_link 'No Weight'
page.within '.value' do
expect(page).to have_content 'None'
end
end
# Ensure the request was sent and things are persisted
visit project_board_path(project, board)
wait_for_requests
click_card(card1)
wait_for_requests
page.within '.weight' do
page.within '.value' do
expect(page).to have_content 'None'
end
end
end
end
end
......@@ -15,7 +15,8 @@
"assignees": {
"type": "array",
"items": { "$ref": "../public_api/v4/user/basic.json" }
}
},
"weight": { "type": ["integer", "null"] }
},
"additionalProperties": false
}
......@@ -146,6 +146,12 @@ describe('Issue model', () => {
expect(issue.isFetching.subscriptions).toBe(false);
});
it('sets loading state', () => {
issue.setLoadingState('foo', true);
expect(issue.isLoading.foo).toBe(true);
});
describe('update', () => {
it('passes assignee ids when there are assignees', (done) => {
spyOn(Vue.http, 'patch').and.callFake((url, data) => {
......
import CEMockData from './mock_data';
const RESPONSE_MAP = { ...CEMockData.responseMap };
RESPONSE_MAP.GET['/gitlab-org/gitlab-shell/issues/5.json?serializer=sidebar'] = {
assignees: [
{
name: 'User 0',
username: 'user0',
id: 22,
state: 'active',
avatar_url: 'http: //www.gravatar.com/avatar/52e4ce24a915fb7e51e1ad3b57f4b00a?s=80\u0026d=identicon',
web_url: 'http: //localhost:3001/user0',
},
{
name: 'Marguerite Bartell',
username: 'tajuana',
id: 18,
state: 'active',
avatar_url: 'http: //www.gravatar.com/avatar/4852a41fb41616bf8f140d3701673f53?s=80\u0026d=identicon',
web_url: 'http: //localhost:3001/tajuana',
},
{
name: 'Laureen Ritchie',
username: 'michaele.will',
id: 16,
state: 'active',
avatar_url: 'http: //www.gravatar.com/avatar/e301827eb03be955c9c172cb9a8e4e8a?s=80\u0026d=identicon',
web_url: 'http: //localhost:3001/michaele.will',
},
],
human_time_estimate: null,
human_total_time_spent: null,
participants: [
{
name: 'User 0',
username: 'user0',
id: 22,
state: 'active',
avatar_url: 'http: //www.gravatar.com/avatar/52e4ce24a915fb7e51e1ad3b57f4b00a?s=80\u0026d=identicon',
web_url: 'http: //localhost:3001/user0',
},
{
name: 'Marguerite Bartell',
username: 'tajuana',
id: 18,
state: 'active',
avatar_url: 'http: //www.gravatar.com/avatar/4852a41fb41616bf8f140d3701673f53?s=80\u0026d=identicon',
web_url: 'http: //localhost:3001/tajuana',
},
{
name: 'Laureen Ritchie',
username: 'michaele.will',
id: 16,
state: 'active',
avatar_url: 'http: //www.gravatar.com/avatar/e301827eb03be955c9c172cb9a8e4e8a?s=80\u0026d=identicon',
web_url: 'http: //localhost:3001/michaele.will',
},
],
subscribed: true,
time_estimate: 0,
total_time_spent: 0,
weight: 3,
};
export default {
...CEMockData,
mediator: {
...CEMockData.mediator,
weightOptions: ['No Weight', 0, 1, 3],
weightNoneValue: 'No Weight',
},
responseMap: RESPONSE_MAP,
};
import Vue from 'vue';
import SidebarMediator from 'ee/sidebar/sidebar_mediator';
import SidebarStore from 'ee/sidebar/stores/sidebar_store';
import SidebarService from '~/sidebar/services/sidebar_service';
import Mock from './ee_mock_data';
describe('EE Sidebar mediator', () => {
beforeEach(() => {
Vue.http.interceptors.push(Mock.sidebarMockInterceptor);
this.mediator = new SidebarMediator(Mock.mediator);
});
afterEach(() => {
SidebarService.singleton = null;
SidebarStore.singleton = null;
SidebarMediator.singleton = null;
Vue.http.interceptors = _.without(Vue.http.interceptors, Mock.sidebarMockInterceptor);
});
it('processes fetched data', () => {
const mockData = Mock.responseMap.GET['/gitlab-org/gitlab-shell/issues/5.json?serializer=sidebar'];
this.mediator.processFetchedData(mockData);
expect(this.mediator.store.weight).toEqual(mockData.weight);
});
});
import SidebarStore from 'ee/sidebar/stores/sidebar_store';
describe('EE Sidebar store', () => {
beforeEach(() => {
this.store = new SidebarStore({
weightOptions: ['No Weight', 0, 1, 3],
weightNoneValue: 'No Weight',
});
});
afterEach(() => {
SidebarStore.singleton = null;
});
it('sets weight data', () => {
expect(this.store.weight).toEqual(null);
const weight = 3;
this.store.setWeightData({
weight,
});
expect(this.store.isFetching.weight).toEqual(false);
expect(this.store.weight).toEqual(weight);
});
it('set weight', () => {
const weight = 3;
this.store.setWeight(weight);
expect(this.store.weight).toEqual(weight);
});
});
/* eslint-disable quote-props*/
const sidebarMockData = {
const RESPONSE_MAP = {
'GET': {
'/gitlab-org/gitlab-shell/issues/5.json': {
id: 45,
......@@ -66,6 +66,65 @@ const sidebarMockData = {
},
labels: [],
},
'/gitlab-org/gitlab-shell/issues/5.json?serializer=sidebar': {
assignees: [
{
name: 'User 0',
username: 'user0',
id: 22,
state: 'active',
avatar_url: 'http: //www.gravatar.com/avatar/52e4ce24a915fb7e51e1ad3b57f4b00a?s=80\u0026d=identicon',
web_url: 'http: //localhost:3001/user0',
},
{
name: 'Marguerite Bartell',
username: 'tajuana',
id: 18,
state: 'active',
avatar_url: 'http: //www.gravatar.com/avatar/4852a41fb41616bf8f140d3701673f53?s=80\u0026d=identicon',
web_url: 'http: //localhost:3001/tajuana',
},
{
name: 'Laureen Ritchie',
username: 'michaele.will',
id: 16,
state: 'active',
avatar_url: 'http: //www.gravatar.com/avatar/e301827eb03be955c9c172cb9a8e4e8a?s=80\u0026d=identicon',
web_url: 'http: //localhost:3001/michaele.will',
},
],
human_time_estimate: null,
human_total_time_spent: null,
participants: [
{
name: 'User 0',
username: 'user0',
id: 22,
state: 'active',
avatar_url: 'http: //www.gravatar.com/avatar/52e4ce24a915fb7e51e1ad3b57f4b00a?s=80\u0026d=identicon',
web_url: 'http: //localhost:3001/user0',
},
{
name: 'Marguerite Bartell',
username: 'tajuana',
id: 18,
state: 'active',
avatar_url: 'http: //www.gravatar.com/avatar/4852a41fb41616bf8f140d3701673f53?s=80\u0026d=identicon',
web_url: 'http: //localhost:3001/tajuana',
},
{
name: 'Laureen Ritchie',
username: 'michaele.will',
id: 16,
state: 'active',
avatar_url: 'http: //www.gravatar.com/avatar/e301827eb03be955c9c172cb9a8e4e8a?s=80\u0026d=identicon',
web_url: 'http: //localhost:3001/michaele.will',
},
],
subscribed: true,
time_estimate: 0,
total_time_spent: 0,
},
'/autocomplete/projects?project_id=15': [
{
'id': 0,
......@@ -113,9 +172,10 @@ const sidebarMockData = {
},
};
export default {
const mockData = {
responseMap: RESPONSE_MAP,
mediator: {
endpoint: '/gitlab-org/gitlab-shell/issues/5.json',
endpoint: '/gitlab-org/gitlab-shell/issues/5.json?serializer=sidebar',
toggleSubscriptionEndpoint: '/gitlab-org/gitlab-shell/issues/5/toggle_subscription',
moveIssueEndpoint: '/gitlab-org/gitlab-shell/issues/5/move',
projectsAutocompleteEndpoint: '/autocomplete/projects?project_id=15',
......@@ -141,12 +201,14 @@ export default {
name: 'Administrator',
username: 'root',
},
};
sidebarMockInterceptor(request, next) {
const body = sidebarMockData[request.method.toUpperCase()][request.url];
mockData.sidebarMockInterceptor = function (request, next) {
const body = this.responseMap[request.method.toUpperCase()][request.url];
next(request.respondWith(JSON.stringify(body), {
status: 200,
}));
},
};
next(request.respondWith(JSON.stringify(body), {
status: 200,
}));
}.bind(mockData);
export default mockData;
......@@ -33,10 +33,29 @@ describe('Sidebar mediator', () => {
.catch(done.fail);
});
it('fetches the data', () => {
spyOn(this.mediator.service, 'get').and.callThrough();
this.mediator.fetch();
expect(this.mediator.service.get).toHaveBeenCalled();
it('fetches the data', (done) => {
const mockData = Mock.responseMap.GET['/gitlab-org/gitlab-shell/issues/5.json?serializer=sidebar'];
spyOn(this.mediator, 'processFetchedData').and.callThrough();
this.mediator.fetch()
.then(() => {
expect(this.mediator.processFetchedData).toHaveBeenCalledWith(mockData);
})
.then(done)
.catch(done.fail);
});
it('processes fetched data', () => {
const mockData = Mock.responseMap.GET['/gitlab-org/gitlab-shell/issues/5.json?serializer=sidebar'];
this.mediator.processFetchedData(mockData);
expect(this.mediator.store.assignees).toEqual(mockData.assignees);
expect(this.mediator.store.humanTimeEstimate).toEqual(mockData.human_time_estimate);
expect(this.mediator.store.humanTotalTimeSpent).toEqual(mockData.human_total_time_spent);
expect(this.mediator.store.participants).toEqual(mockData.participants);
expect(this.mediator.store.subscribed).toEqual(mockData.subscribed);
expect(this.mediator.store.timeEstimate).toEqual(mockData.time_estimate);
expect(this.mediator.store.totalTimeSpent).toEqual(mockData.total_time_spent);
});
it('sets moveToProjectId', () => {
......
......@@ -120,6 +120,12 @@ describe('Sidebar store', () => {
expect(this.store.isFetching.participants).toEqual(false);
});
it('sets loading state', () => {
this.store.setLoadingState('assignees', true);
expect(this.store.isLoading.assignees).toEqual(true);
});
it('set time tracking data', () => {
this.store.setTimeTrackingData(Mock.time);
expect(this.store.timeEstimate).toEqual(Mock.time.time_estimate);
......
import Vue from 'vue';
import sidebarWeight from 'ee/sidebar/components/weight/sidebar_weight.vue';
import SidebarMediator from 'ee/sidebar/sidebar_mediator';
import SidebarService from '~/sidebar/services/sidebar_service';
import SidebarStore from 'ee/sidebar/stores/sidebar_store';
import eventHub from '~/sidebar/event_hub';
import mountComponent from '../helpers/vue_mount_component_helper';
import Mock from './ee_mock_data';
describe('Sidebar Weight', function () {
let vm;
let sidebarMediator;
let SidebarWeight;
beforeEach(() => {
SidebarWeight = Vue.extend(sidebarWeight);
// Setup the stores, services, etc
sidebarMediator = new SidebarMediator(Mock.mediator);
});
afterEach(() => {
vm.$destroy();
SidebarService.singleton = null;
SidebarStore.singleton = null;
SidebarMediator.singleton = null;
});
it('calls the mediator updateWeight on event', () => {
spyOn(SidebarMediator.prototype, 'updateWeight').and.returnValue(Promise.resolve());
vm = mountComponent(SidebarWeight, {
mediator: sidebarMediator,
});
eventHub.$emit('updateWeight');
expect(SidebarMediator.prototype.updateWeight).toHaveBeenCalled();
});
});
import Vue from 'vue';
import weight from 'ee/sidebar/components/weight/weight.vue';
import eventHub from '~/sidebar/event_hub';
import mountComponent from '../helpers/vue_mount_component_helper';
import getSetTimeoutPromise from '../helpers/set_timeout_promise_helper';
const DEFAULT_PROPS = {
weightOptions: ['No Weight', 1, 2, 3],
weightNoneValue: 'No Weight',
};
describe('Weight', function () {
let vm;
let Weight;
beforeEach(() => {
Weight = Vue.extend(weight);
});
afterEach(() => {
vm.$destroy();
});
it('shows loading spinner when fetching', () => {
vm = mountComponent(Weight, {
...DEFAULT_PROPS,
fetching: true,
});
expect(vm.$el.querySelector('.js-weight-collapsed-loading-icon')).not.toBeNull();
expect(vm.$el.querySelector('.js-weight-loading-icon')).not.toBeNull();
});
it('shows loading spinner when loading', () => {
vm = mountComponent(Weight, {
...DEFAULT_PROPS,
fetching: false,
loading: true,
});
// We show the value in the collapsed view instead of the loading icon
expect(vm.$el.querySelector('.js-weight-collapsed-loading-icon')).toBeNull();
expect(vm.$el.querySelector('.js-weight-loading-icon')).not.toBeNull();
});
it('shows weight value', () => {
const WEIGHT = 3;
vm = mountComponent(Weight, {
...DEFAULT_PROPS,
fetching: false,
weight: WEIGHT,
});
expect(vm.$el.querySelector('.js-weight-collapsed-weight-label').textContent.trim()).toEqual(`${WEIGHT}`);
expect(vm.$el.querySelector('.js-weight-weight-label').textContent.trim()).toEqual(`${WEIGHT}`);
expect(vm.$el.querySelector('.js-weight-dropdown-toggle-text').textContent.trim()).toEqual(`${WEIGHT}`);
});
it('shows weight no-value', () => {
const WEIGHT = null;
vm = mountComponent(Weight, {
...DEFAULT_PROPS,
fetching: false,
weight: WEIGHT,
});
expect(vm.$el.querySelector('.js-weight-collapsed-weight-label').textContent.trim()).toEqual('No');
expect(vm.$el.querySelector('.js-weight-weight-label').textContent.trim()).toEqual('None');
// Put a placeholder in the dropdown toggle
expect(vm.$el.querySelector('.js-weight-dropdown-toggle-text').textContent.trim()).toEqual('Weight');
});
it('adds `collapse-after-update` class when clicking the collapsed block', (done) => {
vm = mountComponent(Weight, {
...DEFAULT_PROPS,
});
vm.$el.querySelector('.js-weight-collapsed-block').click();
vm.$nextTick()
.then(() => {
expect(vm.$el.classList.contains('collapse-after-update')).toEqual(true);
})
.then(done)
.catch(done.fail);
});
it('shows dropdown on "Edit" link click', (done) => {
vm = mountComponent(Weight, {
...DEFAULT_PROPS,
editable: true,
});
expect(vm.shouldShowDropdown).toEqual(false);
vm.$el.querySelector('.js-weight-edit-link').click();
vm.$nextTick()
.then(() => {
expect(vm.shouldShowDropdown).toEqual(true);
})
.then(done)
.catch(done.fail);
});
it('emits event on dropdown item click', (done) => {
const ID = 123;
spyOn(eventHub, '$emit');
vm = mountComponent(Weight, {
...DEFAULT_PROPS,
editable: true,
id: ID,
});
vm.$el.querySelector('.js-weight-edit-link').click();
vm.$nextTick()
.then(() => getSetTimeoutPromise())
.then(() => {
vm.$el.querySelector('.js-weight-dropdown-content li:nth-child(2) a').click();
})
.then(() => {
expect(eventHub.$emit).toHaveBeenCalledWith('updateWeight', DEFAULT_PROPS.weightOptions[1], ID);
})
.then(done)
.catch(done.fail);
});
});
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