Commit 1d99c775 authored by Phil Hughes's avatar Phil Hughes

Merge branch 'master' into notebooklab-in-repo

parents a8b7a4b7 7d7dfee4
...@@ -7,100 +7,98 @@ import boardBlankState from './board_blank_state'; ...@@ -7,100 +7,98 @@ import boardBlankState from './board_blank_state';
require('./board_delete'); require('./board_delete');
require('./board_list'); require('./board_list');
(() => { const Store = gl.issueBoards.BoardsStore;
const Store = gl.issueBoards.BoardsStore;
window.gl = window.gl || {}; window.gl = window.gl || {};
window.gl.issueBoards = window.gl.issueBoards || {}; window.gl.issueBoards = window.gl.issueBoards || {};
gl.issueBoards.Board = Vue.extend({ gl.issueBoards.Board = Vue.extend({
template: '#js-board-template', template: '#js-board-template',
components: { components: {
boardList, boardList,
'board-delete': gl.issueBoards.BoardDelete, 'board-delete': gl.issueBoards.BoardDelete,
boardBlankState, boardBlankState,
}, },
props: { props: {
list: Object, list: Object,
disabled: Boolean, disabled: Boolean,
issueLinkBase: String, issueLinkBase: String,
rootPath: String, rootPath: String,
}, },
data () { data () {
return { return {
detailIssue: Store.detail, detailIssue: Store.detail,
filter: Store.filter, filter: Store.filter,
}; };
}, },
watch: { watch: {
filter: { filter: {
handler() { handler() {
this.list.page = 1; this.list.page = 1;
this.list.getIssues(true); this.list.getIssues(true);
},
deep: true,
}, },
detailIssue: { deep: true,
handler () { },
if (!Object.keys(this.detailIssue.issue).length) return; detailIssue: {
handler () {
if (!Object.keys(this.detailIssue.issue).length) return;
const issue = this.list.findIssue(this.detailIssue.issue.id); const issue = this.list.findIssue(this.detailIssue.issue.id);
if (issue) { if (issue) {
const offsetLeft = this.$el.offsetLeft; const offsetLeft = this.$el.offsetLeft;
const boardsList = document.querySelectorAll('.boards-list')[0]; const boardsList = document.querySelectorAll('.boards-list')[0];
const left = boardsList.scrollLeft - offsetLeft; const left = boardsList.scrollLeft - offsetLeft;
let right = (offsetLeft + this.$el.offsetWidth); let right = (offsetLeft + this.$el.offsetWidth);
if (window.innerWidth > 768 && boardsList.classList.contains('is-compact')) { if (window.innerWidth > 768 && boardsList.classList.contains('is-compact')) {
// -290 here because width of boardsList is animating so therefore // -290 here because width of boardsList is animating so therefore
// getting the width here is incorrect // getting the width here is incorrect
// 290 is the width of the sidebar // 290 is the width of the sidebar
right -= (boardsList.offsetWidth - 290); right -= (boardsList.offsetWidth - 290);
} else { } else {
right -= boardsList.offsetWidth; right -= boardsList.offsetWidth;
} }
if (right - boardsList.scrollLeft > 0) { if (right - boardsList.scrollLeft > 0) {
$(boardsList).animate({ $(boardsList).animate({
scrollLeft: right scrollLeft: right
}, this.sortableOptions.animation); }, this.sortableOptions.animation);
} else if (left > 0) { } else if (left > 0) {
$(boardsList).animate({ $(boardsList).animate({
scrollLeft: offsetLeft scrollLeft: offsetLeft
}, this.sortableOptions.animation); }, this.sortableOptions.animation);
}
} }
}, }
deep: true },
} deep: true
}, }
methods: { },
showNewIssueForm() { methods: {
this.$refs['board-list'].showIssueForm = !this.$refs['board-list'].showIssueForm; showNewIssueForm() {
} this.$refs['board-list'].showIssueForm = !this.$refs['board-list'].showIssueForm;
}, }
mounted () { },
this.sortableOptions = gl.issueBoards.getBoardSortableDefaultOptions({ mounted () {
disabled: this.disabled, this.sortableOptions = gl.issueBoards.getBoardSortableDefaultOptions({
group: 'boards', disabled: this.disabled,
draggable: '.is-draggable', group: 'boards',
handle: '.js-board-handle', draggable: '.is-draggable',
onEnd: (e) => { handle: '.js-board-handle',
gl.issueBoards.onEnd(); onEnd: (e) => {
gl.issueBoards.onEnd();
if (e.newIndex !== undefined && e.oldIndex !== e.newIndex) { if (e.newIndex !== undefined && e.oldIndex !== e.newIndex) {
const order = this.sortable.toArray(); const order = this.sortable.toArray();
const list = Store.findList('id', parseInt(e.item.dataset.id, 10)); const list = Store.findList('id', parseInt(e.item.dataset.id, 10));
this.$nextTick(() => { this.$nextTick(() => {
Store.moveList(list, order); Store.moveList(list, order);
}); });
}
} }
}); }
});
this.sortable = Sortable.create(this.$el.parentNode, this.sortableOptions); this.sortable = Sortable.create(this.$el.parentNode, this.sortableOptions);
}, },
}); });
})();
...@@ -2,22 +2,20 @@ ...@@ -2,22 +2,20 @@
import Vue from 'vue'; import Vue from 'vue';
(() => { window.gl = window.gl || {};
window.gl = window.gl || {}; window.gl.issueBoards = window.gl.issueBoards || {};
window.gl.issueBoards = window.gl.issueBoards || {};
gl.issueBoards.BoardDelete = Vue.extend({ gl.issueBoards.BoardDelete = Vue.extend({
props: { props: {
list: Object list: Object
}, },
methods: { methods: {
deleteBoard () { deleteBoard () {
$(this.$el).tooltip('hide'); $(this.$el).tooltip('hide');
if (confirm('Are you sure you want to delete this list?')) { if (confirm('Are you sure you want to delete this list?')) {
this.list.destroy(); this.list.destroy();
}
} }
} }
}); }
})(); });
...@@ -8,66 +8,64 @@ import Vue from 'vue'; ...@@ -8,66 +8,64 @@ import Vue from 'vue';
require('./sidebar/remove_issue'); require('./sidebar/remove_issue');
(() => { const Store = gl.issueBoards.BoardsStore;
const Store = gl.issueBoards.BoardsStore;
window.gl = window.gl || {}; window.gl = window.gl || {};
window.gl.issueBoards = window.gl.issueBoards || {}; window.gl.issueBoards = window.gl.issueBoards || {};
gl.issueBoards.BoardSidebar = Vue.extend({ gl.issueBoards.BoardSidebar = Vue.extend({
props: { props: {
currentUser: Object currentUser: Object
}, },
data() { data() {
return { return {
detail: Store.detail, detail: Store.detail,
issue: {}, issue: {},
list: {}, list: {},
}; };
}, },
computed: { computed: {
showSidebar () { showSidebar () {
return Object.keys(this.issue).length; return Object.keys(this.issue).length;
} }
}, },
watch: { watch: {
detail: { detail: {
handler () { handler () {
if (this.issue.id !== this.detail.issue.id) { if (this.issue.id !== this.detail.issue.id) {
$('.js-issue-board-sidebar', this.$el).each((i, el) => { $('.js-issue-board-sidebar', this.$el).each((i, el) => {
$(el).data('glDropdown').clearMenu(); $(el).data('glDropdown').clearMenu();
});
}
this.issue = this.detail.issue;
this.list = this.detail.list;
},
deep: true
},
issue () {
if (this.showSidebar) {
this.$nextTick(() => {
$('.right-sidebar').getNiceScroll(0).doScrollTop(0, 0);
$('.right-sidebar').getNiceScroll().resize();
}); });
} }
}
this.issue = this.detail.issue;
this.list = this.detail.list;
},
deep: true
}, },
methods: { issue () {
closeSidebar () { if (this.showSidebar) {
this.detail.issue = {}; this.$nextTick(() => {
$('.right-sidebar').getNiceScroll(0).doScrollTop(0, 0);
$('.right-sidebar').getNiceScroll().resize();
});
} }
}, }
mounted () { },
new IssuableContext(this.currentUser); methods: {
new MilestoneSelect(); closeSidebar () {
new gl.DueDateSelectors(); this.detail.issue = {};
new LabelsSelect(); }
new Sidebar(); },
gl.Subscription.bindAll('.subscription'); mounted () {
}, new IssuableContext(this.currentUser);
components: { new MilestoneSelect();
removeBtn: gl.issueBoards.RemoveIssueBtn, new gl.DueDateSelectors();
}, new LabelsSelect();
}); new Sidebar();
})(); gl.Subscription.bindAll('.subscription');
},
components: {
removeBtn: gl.issueBoards.RemoveIssueBtn,
},
});
import Vue from 'vue'; import Vue from 'vue';
import eventHub from '../eventhub'; import eventHub from '../eventhub';
(() => { const Store = gl.issueBoards.BoardsStore;
const Store = gl.issueBoards.BoardsStore;
window.gl = window.gl || {}; window.gl = window.gl || {};
window.gl.issueBoards = window.gl.issueBoards || {}; window.gl.issueBoards = window.gl.issueBoards || {};
gl.issueBoards.IssueCardInner = Vue.extend({ gl.issueBoards.IssueCardInner = Vue.extend({
props: { props: {
issue: { issue: {
type: Object, type: Object,
required: true, required: true,
},
issueLinkBase: {
type: String,
required: true,
},
list: {
type: Object,
required: false,
default: () => ({}),
},
rootPath: {
type: String,
required: true,
},
updateFilters: {
type: Boolean,
required: false,
default: false,
},
}, },
computed: { issueLinkBase: {
cardUrl() { type: String,
return `${this.issueLinkBase}/${this.issue.id}`; required: true,
},
assigneeUrl() {
return `${this.rootPath}${this.issue.assignee.username}`;
},
assigneeUrlTitle() {
return `Assigned to ${this.issue.assignee.name}`;
},
avatarUrlTitle() {
return `Avatar for ${this.issue.assignee.name}`;
},
issueId() {
return `#${this.issue.id}`;
},
showLabelFooter() {
return this.issue.labels.find(l => this.showLabel(l)) !== undefined;
},
}, },
methods: { list: {
showLabel(label) { type: Object,
if (!this.list) return true; required: false,
default: () => ({}),
},
rootPath: {
type: String,
required: true,
},
updateFilters: {
type: Boolean,
required: false,
default: false,
},
},
computed: {
cardUrl() {
return `${this.issueLinkBase}/${this.issue.id}`;
},
assigneeUrl() {
return `${this.rootPath}${this.issue.assignee.username}`;
},
assigneeUrlTitle() {
return `Assigned to ${this.issue.assignee.name}`;
},
avatarUrlTitle() {
return `Avatar for ${this.issue.assignee.name}`;
},
issueId() {
return `#${this.issue.id}`;
},
showLabelFooter() {
return this.issue.labels.find(l => this.showLabel(l)) !== undefined;
},
},
methods: {
showLabel(label) {
if (!this.list) return true;
return !this.list.label || label.id !== this.list.label.id; return !this.list.label || label.id !== this.list.label.id;
}, },
filterByLabel(label, e) { filterByLabel(label, e) {
if (!this.updateFilters) return; if (!this.updateFilters) return;
const filterPath = gl.issueBoards.BoardsStore.filter.path.split('&'); const filterPath = gl.issueBoards.BoardsStore.filter.path.split('&');
const labelTitle = encodeURIComponent(label.title); const labelTitle = encodeURIComponent(label.title);
const param = `label_name[]=${labelTitle}`; const param = `label_name[]=${labelTitle}`;
const labelIndex = filterPath.indexOf(param); const labelIndex = filterPath.indexOf(param);
$(e.currentTarget).tooltip('hide'); $(e.currentTarget).tooltip('hide');
if (labelIndex === -1) { if (labelIndex === -1) {
filterPath.push(param); filterPath.push(param);
} else { } else {
filterPath.splice(labelIndex, 1); filterPath.splice(labelIndex, 1);
} }
gl.issueBoards.BoardsStore.filter.path = filterPath.join('&'); gl.issueBoards.BoardsStore.filter.path = filterPath.join('&');
Store.updateFiltersUrl(); Store.updateFiltersUrl();
eventHub.$emit('updateTokens'); eventHub.$emit('updateTokens');
}, },
labelStyle(label) { labelStyle(label) {
return { return {
backgroundColor: label.color, backgroundColor: label.color,
color: label.textColor, color: label.textColor,
}; };
},
}, },
template: ` },
<div> template: `
<div class="card-header"> <div>
<h4 class="card-title"> <div class="card-header">
<i <h4 class="card-title">
class="fa fa-eye-slash confidential-icon" <i
v-if="issue.confidential" class="fa fa-eye-slash confidential-icon"
aria-hidden="true" v-if="issue.confidential"
/> aria-hidden="true"
<a />
class="js-no-trigger"
:href="cardUrl"
:title="issue.title">{{ issue.title }}</a>
<span
class="card-number"
v-if="issue.id"
>
{{ issueId }}
</span>
</h4>
<a <a
class="card-assignee has-tooltip js-no-trigger" class="js-no-trigger"
:href="assigneeUrl" :href="cardUrl"
:title="assigneeUrlTitle" :title="issue.title">{{ issue.title }}</a>
v-if="issue.assignee" <span
data-container="body" class="card-number"
v-if="issue.id"
> >
<img {{ issueId }}
class="avatar avatar-inline s20 js-no-trigger" </span>
:src="issue.assignee.avatar" </h4>
width="20" <a
height="20" class="card-assignee has-tooltip js-no-trigger"
:alt="avatarUrlTitle" :href="assigneeUrl"
/> :title="assigneeUrlTitle"
</a> v-if="issue.assignee"
</div> data-container="body"
<div class="card-footer" v-if="showLabelFooter"> >
<button <img
class="label color-label has-tooltip js-no-trigger" class="avatar avatar-inline s20 js-no-trigger"
v-for="label in issue.labels" :src="issue.assignee.avatar"
type="button" width="20"
v-if="showLabel(label)" height="20"
@click="filterByLabel(label, $event)" :alt="avatarUrlTitle"
:style="labelStyle(label)" />
:title="label.description" </a>
data-container="body"> </div>
{{ label.title }} <div class="card-footer" v-if="showLabelFooter">
</button> <button
</div> class="label color-label has-tooltip js-no-trigger"
v-for="label in issue.labels"
type="button"
v-if="showLabel(label)"
@click="filterByLabel(label, $event)"
:style="labelStyle(label)"
:title="label.description"
data-container="body">
{{ label.title }}
</button>
</div> </div>
`, </div>
}); `,
})(); });
import Vue from 'vue'; import Vue from 'vue';
(() => { const ModalStore = gl.issueBoards.ModalStore;
const ModalStore = gl.issueBoards.ModalStore;
gl.issueBoards.ModalEmptyState = Vue.extend({ gl.issueBoards.ModalEmptyState = Vue.extend({
mixins: [gl.issueBoards.ModalMixins], mixins: [gl.issueBoards.ModalMixins],
data() { data() {
return ModalStore.store; return ModalStore.store;
},
props: {
image: {
type: String,
required: true,
}, },
props: { newIssuePath: {
image: { type: String,
type: String, required: true,
required: true,
},
newIssuePath: {
type: String,
required: true,
},
}, },
computed: { },
contents() { computed: {
const obj = { contents() {
title: 'You haven\'t added any issues to your project yet', const obj = {
content: ` title: 'You haven\'t added any issues to your project yet',
An issue can be a bug, a todo or a feature request that needs to be content: `
discussed in a project. Besides, issues are searchable and filterable. An issue can be a bug, a todo or a feature request that needs to be
`, discussed in a project. Besides, issues are searchable and filterable.
}; `,
};
if (this.activeTab === 'selected') { if (this.activeTab === 'selected') {
obj.title = 'You haven\'t selected any issues yet'; obj.title = 'You haven\'t selected any issues yet';
obj.content = ` obj.content = `
Go back to <strong>Open issues</strong> and select some issues Go back to <strong>Open issues</strong> and select some issues
to add to your board. to add to your board.
`; `;
} }
return obj; return obj;
},
}, },
template: ` },
<section class="empty-state"> template: `
<div class="row"> <section class="empty-state">
<div class="col-xs-12 col-sm-6 col-sm-push-6"> <div class="row">
<aside class="svg-content" v-html="image"></aside> <div class="col-xs-12 col-sm-6 col-sm-push-6">
</div> <aside class="svg-content" v-html="image"></aside>
<div class="col-xs-12 col-sm-6 col-sm-pull-6"> </div>
<div class="text-content"> <div class="col-xs-12 col-sm-6 col-sm-pull-6">
<h4>{{ contents.title }}</h4> <div class="text-content">
<p v-html="contents.content"></p> <h4>{{ contents.title }}</h4>
<a <p v-html="contents.content"></p>
:href="newIssuePath" <a
class="btn btn-success btn-inverted" :href="newIssuePath"
v-if="activeTab === 'all'"> class="btn btn-success btn-inverted"
New issue v-if="activeTab === 'all'">
</a> New issue
<button </a>
type="button" <button
class="btn btn-default" type="button"
@click="changeTab('all')" class="btn btn-default"
v-if="activeTab === 'selected'"> @click="changeTab('all')"
Open issues v-if="activeTab === 'selected'">
</button> Open issues
</div> </button>
</div> </div>
</div> </div>
</section> </div>
`, </section>
}); `,
})(); });
...@@ -5,80 +5,78 @@ import Vue from 'vue'; ...@@ -5,80 +5,78 @@ import Vue from 'vue';
require('./lists_dropdown'); require('./lists_dropdown');
(() => { const ModalStore = gl.issueBoards.ModalStore;
const ModalStore = gl.issueBoards.ModalStore;
gl.issueBoards.ModalFooter = Vue.extend({ gl.issueBoards.ModalFooter = Vue.extend({
mixins: [gl.issueBoards.ModalMixins], mixins: [gl.issueBoards.ModalMixins],
data() { data() {
return { return {
modal: ModalStore.store, modal: ModalStore.store,
state: gl.issueBoards.BoardsStore.state, state: gl.issueBoards.BoardsStore.state,
}; };
},
computed: {
submitDisabled() {
return !ModalStore.selectedCount();
}, },
computed: { submitText() {
submitDisabled() { const count = ModalStore.selectedCount();
return !ModalStore.selectedCount();
},
submitText() {
const count = ModalStore.selectedCount();
return `Add ${count > 0 ? count : ''} ${gl.text.pluralize('issue', count)}`; return `Add ${count > 0 ? count : ''} ${gl.text.pluralize('issue', count)}`;
},
}, },
methods: { },
addIssues() { methods: {
const list = this.modal.selectedList || this.state.lists[0]; addIssues() {
const selectedIssues = ModalStore.getSelectedIssues(); const list = this.modal.selectedList || this.state.lists[0];
const issueIds = selectedIssues.map(issue => issue.globalId); const selectedIssues = ModalStore.getSelectedIssues();
const issueIds = selectedIssues.map(issue => issue.globalId);
// Post the data to the backend // Post the data to the backend
gl.boardService.bulkUpdate(issueIds, { gl.boardService.bulkUpdate(issueIds, {
add_label_ids: [list.label.id], add_label_ids: [list.label.id],
}).catch(() => { }).catch(() => {
new Flash('Failed to update issues, please try again.', 'alert'); new Flash('Failed to update issues, please try again.', 'alert');
selectedIssues.forEach((issue) => {
list.removeIssue(issue);
list.issuesSize -= 1;
});
});
// Add the issues on the frontend
selectedIssues.forEach((issue) => { selectedIssues.forEach((issue) => {
list.addIssue(issue); list.removeIssue(issue);
list.issuesSize += 1; list.issuesSize -= 1;
}); });
});
this.toggleModal(false); // Add the issues on the frontend
}, selectedIssues.forEach((issue) => {
}, list.addIssue(issue);
components: { list.issuesSize += 1;
'lists-dropdown': gl.issueBoards.ModalFooterListsDropdown, });
this.toggleModal(false);
}, },
template: ` },
<footer components: {
class="form-actions add-issues-footer"> 'lists-dropdown': gl.issueBoards.ModalFooterListsDropdown,
<div class="pull-left"> },
<button template: `
class="btn btn-success" <footer
type="button" class="form-actions add-issues-footer">
:disabled="submitDisabled" <div class="pull-left">
@click="addIssues">
{{ submitText }}
</button>
<span class="inline add-issues-footer-to-list">
to list
</span>
<lists-dropdown></lists-dropdown>
</div>
<button <button
class="btn btn-default pull-right" class="btn btn-success"
type="button" type="button"
@click="toggleModal(false)"> :disabled="submitDisabled"
Cancel @click="addIssues">
{{ submitText }}
</button> </button>
</footer> <span class="inline add-issues-footer-to-list">
`, to list
}); </span>
})(); <lists-dropdown></lists-dropdown>
</div>
<button
class="btn btn-default pull-right"
type="button"
@click="toggleModal(false)">
Cancel
</button>
</footer>
`,
});
...@@ -3,80 +3,78 @@ import modalFilters from './filters'; ...@@ -3,80 +3,78 @@ import modalFilters from './filters';
require('./tabs'); require('./tabs');
(() => { const ModalStore = gl.issueBoards.ModalStore;
const ModalStore = gl.issueBoards.ModalStore;
gl.issueBoards.ModalHeader = Vue.extend({ gl.issueBoards.ModalHeader = Vue.extend({
mixins: [gl.issueBoards.ModalMixins], mixins: [gl.issueBoards.ModalMixins],
props: { props: {
projectId: { projectId: {
type: Number, type: Number,
required: true, required: true,
},
milestonePath: {
type: String,
required: true,
},
labelPath: {
type: String,
required: true,
},
}, },
data() { milestonePath: {
return ModalStore.store; type: String,
required: true,
}, },
computed: { labelPath: {
selectAllText() { type: String,
if (ModalStore.selectedCount() !== this.issues.length || this.issues.length === 0) { required: true,
return 'Select all';
}
return 'Deselect all';
},
showSearch() {
return this.activeTab === 'all' && !this.loading && this.issuesCount > 0;
},
}, },
methods: { },
toggleAll() { data() {
this.$refs.selectAllBtn.blur(); return ModalStore.store;
},
computed: {
selectAllText() {
if (ModalStore.selectedCount() !== this.issues.length || this.issues.length === 0) {
return 'Select all';
}
ModalStore.toggleAll(); return 'Deselect all';
}, },
showSearch() {
return this.activeTab === 'all' && !this.loading && this.issuesCount > 0;
}, },
components: { },
'modal-tabs': gl.issueBoards.ModalTabs, methods: {
modalFilters, toggleAll() {
this.$refs.selectAllBtn.blur();
ModalStore.toggleAll();
}, },
template: ` },
<div> components: {
<header class="add-issues-header form-actions"> 'modal-tabs': gl.issueBoards.ModalTabs,
<h2> modalFilters,
Add issues },
<button template: `
type="button" <div>
class="close" <header class="add-issues-header form-actions">
data-dismiss="modal" <h2>
aria-label="Close" Add issues
@click="toggleModal(false)">
<span aria-hidden="true">×</span>
</button>
</h2>
</header>
<modal-tabs v-if="!loading && issuesCount > 0"></modal-tabs>
<div
class="add-issues-search append-bottom-10"
v-if="showSearch">
<modal-filters :store="filter" />
<button <button
type="button" type="button"
class="btn btn-success btn-inverted prepend-left-10" class="close"
ref="selectAllBtn" data-dismiss="modal"
@click="toggleAll"> aria-label="Close"
{{ selectAllText }} @click="toggleModal(false)">
<span aria-hidden="true">×</span>
</button> </button>
</div> </h2>
</header>
<modal-tabs v-if="!loading && issuesCount > 0"></modal-tabs>
<div
class="add-issues-search append-bottom-10"
v-if="showSearch">
<modal-filters :store="filter" />
<button
type="button"
class="btn btn-success btn-inverted prepend-left-10"
ref="selectAllBtn"
@click="toggleAll">
{{ selectAllText }}
</button>
</div> </div>
`, </div>
}); `,
})(); });
...@@ -8,160 +8,158 @@ require('./list'); ...@@ -8,160 +8,158 @@ require('./list');
require('./footer'); require('./footer');
require('./empty_state'); require('./empty_state');
(() => { const ModalStore = gl.issueBoards.ModalStore;
const ModalStore = gl.issueBoards.ModalStore;
gl.issueBoards.IssuesModal = Vue.extend({ gl.issueBoards.IssuesModal = Vue.extend({
props: { props: {
blankStateImage: { blankStateImage: {
type: String, type: String,
required: true, required: true,
},
newIssuePath: {
type: String,
required: true,
},
issueLinkBase: {
type: String,
required: true,
},
rootPath: {
type: String,
required: true,
},
projectId: {
type: Number,
required: true,
},
milestonePath: {
type: String,
required: true,
},
labelPath: {
type: String,
required: true,
},
}, },
data() { newIssuePath: {
return ModalStore.store; type: String,
required: true,
}, },
watch: { issueLinkBase: {
page() { type: String,
this.loadIssues(); required: true,
}, },
showAddIssuesModal() { rootPath: {
if (this.showAddIssuesModal && !this.issues.length) { type: String,
this.loading = true; required: true,
},
projectId: {
type: Number,
required: true,
},
milestonePath: {
type: String,
required: true,
},
labelPath: {
type: String,
required: true,
},
},
data() {
return ModalStore.store;
},
watch: {
page() {
this.loadIssues();
},
showAddIssuesModal() {
if (this.showAddIssuesModal && !this.issues.length) {
this.loading = true;
this.loadIssues()
.then(() => {
this.loading = false;
});
} else if (!this.showAddIssuesModal) {
this.issues = [];
this.selectedIssues = [];
this.issuesCount = false;
}
},
filter: {
handler() {
if (this.$el.tagName) {
this.page = 1;
this.filterLoading = true;
this.loadIssues() this.loadIssues(true)
.then(() => { .then(() => {
this.loading = false; this.filterLoading = false;
}); });
} else if (!this.showAddIssuesModal) {
this.issues = [];
this.selectedIssues = [];
this.issuesCount = false;
} }
}, },
filter: { deep: true,
handler() {
if (this.$el.tagName) {
this.page = 1;
this.filterLoading = true;
this.loadIssues(true)
.then(() => {
this.filterLoading = false;
});
}
},
deep: true,
},
}, },
methods: { },
loadIssues(clearIssues = false) { methods: {
if (!this.showAddIssuesModal) return false; loadIssues(clearIssues = false) {
if (!this.showAddIssuesModal) return false;
return gl.boardService.getBacklog(queryData(this.filter.path, {
page: this.page,
per: this.perPage,
})).then((res) => {
const data = res.json();
if (clearIssues) {
this.issues = [];
}
data.issues.forEach((issueObj) => { return gl.boardService.getBacklog(queryData(this.filter.path, {
const issue = new ListIssue(issueObj); page: this.page,
const foundSelectedIssue = ModalStore.findSelectedIssue(issue); per: this.perPage,
issue.selected = !!foundSelectedIssue; })).then((res) => {
const data = res.json();
this.issues.push(issue); if (clearIssues) {
}); this.issues = [];
}
this.loadingNewPage = false; data.issues.forEach((issueObj) => {
const issue = new ListIssue(issueObj);
const foundSelectedIssue = ModalStore.findSelectedIssue(issue);
issue.selected = !!foundSelectedIssue;
if (!this.issuesCount) { this.issues.push(issue);
this.issuesCount = data.size;
}
}); });
},
},
computed: {
showList() {
if (this.activeTab === 'selected') {
return this.selectedIssues.length > 0;
}
return this.issuesCount > 0; this.loadingNewPage = false;
},
showEmptyState() {
if (!this.loading && this.issuesCount === 0) {
return true;
}
return this.activeTab === 'selected' && this.selectedIssues.length === 0; if (!this.issuesCount) {
}, this.issuesCount = data.size;
}
});
}, },
created() { },
this.page = 1; computed: {
showList() {
if (this.activeTab === 'selected') {
return this.selectedIssues.length > 0;
}
return this.issuesCount > 0;
}, },
components: { showEmptyState() {
'modal-header': gl.issueBoards.ModalHeader, if (!this.loading && this.issuesCount === 0) {
'modal-list': gl.issueBoards.ModalList, return true;
'modal-footer': gl.issueBoards.ModalFooter, }
'empty-state': gl.issueBoards.ModalEmptyState,
return this.activeTab === 'selected' && this.selectedIssues.length === 0;
}, },
template: ` },
<div created() {
class="add-issues-modal" this.page = 1;
v-if="showAddIssuesModal"> },
<div class="add-issues-container"> components: {
<modal-header 'modal-header': gl.issueBoards.ModalHeader,
:project-id="projectId" 'modal-list': gl.issueBoards.ModalList,
:milestone-path="milestonePath" 'modal-footer': gl.issueBoards.ModalFooter,
:label-path="labelPath"> 'empty-state': gl.issueBoards.ModalEmptyState,
</modal-header> },
<modal-list template: `
:image="blankStateImage" <div
:issue-link-base="issueLinkBase" class="add-issues-modal"
:root-path="rootPath" v-if="showAddIssuesModal">
v-if="!loading && showList && !filterLoading"></modal-list> <div class="add-issues-container">
<empty-state <modal-header
v-if="showEmptyState" :project-id="projectId"
:image="blankStateImage" :milestone-path="milestonePath"
:new-issue-path="newIssuePath"></empty-state> :label-path="labelPath">
<section </modal-header>
class="add-issues-list text-center" <modal-list
v-if="loading || filterLoading"> :image="blankStateImage"
<div class="add-issues-list-loading"> :issue-link-base="issueLinkBase"
<i class="fa fa-spinner fa-spin"></i> :root-path="rootPath"
</div> v-if="!loading && showList && !filterLoading"></modal-list>
</section> <empty-state
<modal-footer></modal-footer> v-if="showEmptyState"
</div> :image="blankStateImage"
:new-issue-path="newIssuePath"></empty-state>
<section
class="add-issues-list text-center"
v-if="loading || filterLoading">
<div class="add-issues-list-loading">
<i class="fa fa-spinner fa-spin"></i>
</div>
</section>
<modal-footer></modal-footer>
</div> </div>
`, </div>
}); `,
})(); });
...@@ -3,159 +3,157 @@ ...@@ -3,159 +3,157 @@
import Vue from 'vue'; import Vue from 'vue';
(() => { const ModalStore = gl.issueBoards.ModalStore;
const ModalStore = gl.issueBoards.ModalStore;
gl.issueBoards.ModalList = Vue.extend({ gl.issueBoards.ModalList = Vue.extend({
props: { props: {
issueLinkBase: { issueLinkBase: {
type: String, type: String,
required: true, required: true,
},
rootPath: {
type: String,
required: true,
},
image: {
type: String,
required: true,
},
}, },
data() { rootPath: {
return ModalStore.store; type: String,
required: true,
}, },
watch: { image: {
activeTab() { type: String,
if (this.activeTab === 'all') { required: true,
ModalStore.purgeUnselectedIssues();
}
},
}, },
computed: { },
loopIssues() { data() {
if (this.activeTab === 'all') { return ModalStore.store;
return this.issues; },
} watch: {
activeTab() {
return this.selectedIssues; if (this.activeTab === 'all') {
}, ModalStore.purgeUnselectedIssues();
groupedIssues() { }
const groups = []; },
this.loopIssues.forEach((issue, i) => { },
const index = i % this.columns; computed: {
loopIssues() {
if (!groups[index]) { if (this.activeTab === 'all') {
groups.push([]); return this.issues;
} }
groups[index].push(issue);
});
return groups; return this.selectedIssues;
},
}, },
methods: { groupedIssues() {
scrollHandler() { const groups = [];
const currentPage = Math.floor(this.issues.length / this.perPage); this.loopIssues.forEach((issue, i) => {
const index = i % this.columns;
if ((this.scrollTop() > this.scrollHeight() - 100) && !this.loadingNewPage if (!groups[index]) {
&& currentPage === this.page) { groups.push([]);
this.loadingNewPage = true;
this.page += 1;
} }
},
toggleIssue(e, issue) {
if (e.target.tagName !== 'A') {
ModalStore.toggleIssue(issue);
}
},
listHeight() {
return this.$refs.list.getBoundingClientRect().height;
},
scrollHeight() {
return this.$refs.list.scrollHeight;
},
scrollTop() {
return this.$refs.list.scrollTop + this.listHeight();
},
showIssue(issue) {
if (this.activeTab === 'all') return true;
const index = ModalStore.selectedIssueIndex(issue);
return index !== -1; groups[index].push(issue);
}, });
setColumnCount() {
const breakpoint = bp.getBreakpointSize();
if (breakpoint === 'lg' || breakpoint === 'md') { return groups;
this.columns = 3;
} else if (breakpoint === 'sm') {
this.columns = 2;
} else {
this.columns = 1;
}
},
}, },
mounted() { },
this.scrollHandlerWrapper = this.scrollHandler.bind(this); methods: {
this.setColumnCountWrapper = this.setColumnCount.bind(this); scrollHandler() {
this.setColumnCount(); const currentPage = Math.floor(this.issues.length / this.perPage);
this.$refs.list.addEventListener('scroll', this.scrollHandlerWrapper); if ((this.scrollTop() > this.scrollHeight() - 100) && !this.loadingNewPage
window.addEventListener('resize', this.setColumnCountWrapper); && currentPage === this.page) {
this.loadingNewPage = true;
this.page += 1;
}
},
toggleIssue(e, issue) {
if (e.target.tagName !== 'A') {
ModalStore.toggleIssue(issue);
}
},
listHeight() {
return this.$refs.list.getBoundingClientRect().height;
}, },
beforeDestroy() { scrollHeight() {
this.$refs.list.removeEventListener('scroll', this.scrollHandlerWrapper); return this.$refs.list.scrollHeight;
window.removeEventListener('resize', this.setColumnCountWrapper);
}, },
components: { scrollTop() {
'issue-card-inner': gl.issueBoards.IssueCardInner, return this.$refs.list.scrollTop + this.listHeight();
}, },
template: ` showIssue(issue) {
<section if (this.activeTab === 'all') return true;
class="add-issues-list add-issues-list-columns"
ref="list"> const index = ModalStore.selectedIssueIndex(issue);
return index !== -1;
},
setColumnCount() {
const breakpoint = bp.getBreakpointSize();
if (breakpoint === 'lg' || breakpoint === 'md') {
this.columns = 3;
} else if (breakpoint === 'sm') {
this.columns = 2;
} else {
this.columns = 1;
}
},
},
mounted() {
this.scrollHandlerWrapper = this.scrollHandler.bind(this);
this.setColumnCountWrapper = this.setColumnCount.bind(this);
this.setColumnCount();
this.$refs.list.addEventListener('scroll', this.scrollHandlerWrapper);
window.addEventListener('resize', this.setColumnCountWrapper);
},
beforeDestroy() {
this.$refs.list.removeEventListener('scroll', this.scrollHandlerWrapper);
window.removeEventListener('resize', this.setColumnCountWrapper);
},
components: {
'issue-card-inner': gl.issueBoards.IssueCardInner,
},
template: `
<section
class="add-issues-list add-issues-list-columns"
ref="list">
<div
class="empty-state add-issues-empty-state-filter text-center"
v-if="issuesCount > 0 && issues.length === 0">
<div <div
class="empty-state add-issues-empty-state-filter text-center" class="svg-content"
v-if="issuesCount > 0 && issues.length === 0"> v-html="image">
<div </div>
class="svg-content" <div class="text-content">
v-html="image"> <h4>
</div> There are no issues to show.
<div class="text-content"> </h4>
<h4>
There are no issues to show.
</h4>
</div>
</div> </div>
</div>
<div
v-for="group in groupedIssues"
class="add-issues-list-column">
<div <div
v-for="group in groupedIssues" v-for="issue in group"
class="add-issues-list-column"> v-if="showIssue(issue)"
class="card-parent">
<div <div
v-for="issue in group" class="card"
v-if="showIssue(issue)" :class="{ 'is-active': issue.selected }"
class="card-parent"> @click="toggleIssue($event, issue)">
<div <issue-card-inner
class="card" :issue="issue"
:class="{ 'is-active': issue.selected }" :issue-link-base="issueLinkBase"
@click="toggleIssue($event, issue)"> :root-path="rootPath">
<issue-card-inner </issue-card-inner>
:issue="issue" <span
:issue-link-base="issueLinkBase" :aria-label="'Issue #' + issue.id + ' selected'"
:root-path="rootPath"> aria-checked="true"
</issue-card-inner> v-if="issue.selected"
<span class="issue-card-selected text-center">
:aria-label="'Issue #' + issue.id + ' selected'" <i class="fa fa-check"></i>
aria-checked="true" </span>
v-if="issue.selected"
class="issue-card-selected text-center">
<i class="fa fa-check"></i>
</span>
</div>
</div> </div>
</div> </div>
</section> </div>
`, </section>
}); `,
})(); });
import Vue from 'vue'; import Vue from 'vue';
(() => { const ModalStore = gl.issueBoards.ModalStore;
const ModalStore = gl.issueBoards.ModalStore;
gl.issueBoards.ModalFooterListsDropdown = Vue.extend({ gl.issueBoards.ModalFooterListsDropdown = Vue.extend({
data() { data() {
return { return {
modal: ModalStore.store, modal: ModalStore.store,
state: gl.issueBoards.BoardsStore.state, state: gl.issueBoards.BoardsStore.state,
}; };
},
computed: {
selected() {
return this.modal.selectedList || this.state.lists[0];
}, },
computed: { },
selected() { destroyed() {
return this.modal.selectedList || this.state.lists[0]; this.modal.selectedList = null;
}, },
}, template: `
destroyed() { <div class="dropdown inline">
this.modal.selectedList = null; <button
}, class="dropdown-menu-toggle"
template: ` type="button"
<div class="dropdown inline"> data-toggle="dropdown"
<button aria-expanded="false">
class="dropdown-menu-toggle" <span
type="button" class="dropdown-label-box"
data-toggle="dropdown" :style="{ backgroundColor: selected.label.color }">
aria-expanded="false"> </span>
<span {{ selected.title }}
class="dropdown-label-box" <i class="fa fa-chevron-down"></i>
:style="{ backgroundColor: selected.label.color }"> </button>
</span> <div class="dropdown-menu dropdown-menu-selectable dropdown-menu-drop-up">
{{ selected.title }} <ul>
<i class="fa fa-chevron-down"></i> <li
</button> v-for="list in state.lists"
<div class="dropdown-menu dropdown-menu-selectable dropdown-menu-drop-up"> v-if="list.type == 'label'">
<ul> <a
<li href="#"
v-for="list in state.lists" role="button"
v-if="list.type == 'label'"> :class="{ 'is-active': list.id == selected.id }"
<a @click.prevent="modal.selectedList = list">
href="#" <span
role="button" class="dropdown-label-box"
:class="{ 'is-active': list.id == selected.id }" :style="{ backgroundColor: list.label.color }">
@click.prevent="modal.selectedList = list"> </span>
<span {{ list.title }}
class="dropdown-label-box" </a>
:style="{ backgroundColor: list.label.color }"> </li>
</span> </ul>
{{ list.title }}
</a>
</li>
</ul>
</div>
</div> </div>
`, </div>
}); `,
})(); });
import Vue from 'vue'; import Vue from 'vue';
(() => { const ModalStore = gl.issueBoards.ModalStore;
const ModalStore = gl.issueBoards.ModalStore;
gl.issueBoards.ModalTabs = Vue.extend({ gl.issueBoards.ModalTabs = Vue.extend({
mixins: [gl.issueBoards.ModalMixins], mixins: [gl.issueBoards.ModalMixins],
data() { data() {
return ModalStore.store; return ModalStore.store;
},
computed: {
selectedCount() {
return ModalStore.selectedCount();
}, },
computed: { },
selectedCount() { destroyed() {
return ModalStore.selectedCount(); this.activeTab = 'all';
}, },
}, template: `
destroyed() { <div class="top-area prepend-top-10 append-bottom-10">
this.activeTab = 'all'; <ul class="nav-links issues-state-filters">
}, <li :class="{ 'active': activeTab == 'all' }">
template: ` <a
<div class="top-area prepend-top-10 append-bottom-10"> href="#"
<ul class="nav-links issues-state-filters"> role="button"
<li :class="{ 'active': activeTab == 'all' }"> @click.prevent="changeTab('all')">
<a Open issues
href="#" <span class="badge">
role="button" {{ issuesCount }}
@click.prevent="changeTab('all')"> </span>
Open issues </a>
<span class="badge"> </li>
{{ issuesCount }} <li :class="{ 'active': activeTab == 'selected' }">
</span> <a
</a> href="#"
</li> role="button"
<li :class="{ 'active': activeTab == 'selected' }"> @click.prevent="changeTab('selected')">
<a Selected issues
href="#" <span class="badge">
role="button" {{ selectedCount }}
@click.prevent="changeTab('selected')"> </span>
Selected issues </a>
<span class="badge"> </li>
{{ selectedCount }} </ul>
</span> </div>
</a> `,
</li> });
</ul>
</div>
`,
});
})();
/* eslint-disable comma-dangle, func-names, no-new, space-before-function-paren, one-var */ /* eslint-disable comma-dangle, func-names, no-new, space-before-function-paren, one-var */
(() => { window.gl = window.gl || {};
window.gl = window.gl || {}; window.gl.issueBoards = window.gl.issueBoards || {};
window.gl.issueBoards = window.gl.issueBoards || {};
const Store = gl.issueBoards.BoardsStore; const Store = gl.issueBoards.BoardsStore;
$(document).off('created.label').on('created.label', (e, label) => { $(document).off('created.label').on('created.label', (e, label) => {
Store.new({ Store.new({
title: label.title,
position: Store.state.lists.length - 2,
list_type: 'label',
label: {
id: label.id,
title: label.title, title: label.title,
position: Store.state.lists.length - 2, color: label.color
list_type: 'label', }
label: {
id: label.id,
title: label.title,
color: label.color
}
});
}); });
});
gl.issueBoards.newListDropdownInit = () => { gl.issueBoards.newListDropdownInit = () => {
$('.js-new-board-list').each(function () { $('.js-new-board-list').each(function () {
const $this = $(this); const $this = $(this);
new gl.CreateLabelDropdown($this.closest('.dropdown').find('.dropdown-new-label'), $this.data('namespace-path'), $this.data('project-path')); new gl.CreateLabelDropdown($this.closest('.dropdown').find('.dropdown-new-label'), $this.data('namespace-path'), $this.data('project-path'));
$this.glDropdown({ $this.glDropdown({
data(term, callback) { data(term, callback) {
$.get($this.attr('data-labels')) $.get($this.attr('data-labels'))
.then((resp) => { .then((resp) => {
callback(resp); callback(resp);
});
},
renderRow (label) {
const active = Store.findList('title', label.title);
const $li = $('<li />');
const $a = $('<a />', {
class: (active ? `is-active js-board-list-${active.id}` : ''),
text: label.title,
href: '#'
});
const $labelColor = $('<span />', {
class: 'dropdown-label-box',
style: `background-color: ${label.color}`
}); });
},
renderRow (label) {
const active = Store.findList('title', label.title);
const $li = $('<li />');
const $a = $('<a />', {
class: (active ? `is-active js-board-list-${active.id}` : ''),
text: label.title,
href: '#'
});
const $labelColor = $('<span />', {
class: 'dropdown-label-box',
style: `background-color: ${label.color}`
});
return $li.append($a.prepend($labelColor)); return $li.append($a.prepend($labelColor));
}, },
search: { search: {
fields: ['title'] fields: ['title']
}, },
filterable: true, filterable: true,
selectable: true, selectable: true,
multiSelect: true, multiSelect: true,
clicked (label, $el, e) { clicked (label, $el, e) {
e.preventDefault(); e.preventDefault();
if (!Store.findList('title', label.title)) { if (!Store.findList('title', label.title)) {
Store.new({ Store.new({
title: label.title,
position: Store.state.lists.length - 2,
list_type: 'label',
label: {
id: label.id,
title: label.title, title: label.title,
position: Store.state.lists.length - 2, color: label.color
list_type: 'label', }
label: { });
id: label.id,
title: label.title,
color: label.color
}
});
Store.state.lists = _.sortBy(Store.state.lists, 'position'); Store.state.lists = _.sortBy(Store.state.lists, 'position');
}
} }
}); }
}); });
}; });
})(); };
...@@ -3,59 +3,57 @@ ...@@ -3,59 +3,57 @@
import Vue from 'vue'; import Vue from 'vue';
(() => { const Store = gl.issueBoards.BoardsStore;
const Store = gl.issueBoards.BoardsStore;
window.gl = window.gl || {};
window.gl = window.gl || {}; window.gl.issueBoards = window.gl.issueBoards || {};
window.gl.issueBoards = window.gl.issueBoards || {};
gl.issueBoards.RemoveIssueBtn = Vue.extend({
gl.issueBoards.RemoveIssueBtn = Vue.extend({ props: {
props: { issue: {
issue: { type: Object,
type: Object, required: true,
required: true,
},
list: {
type: Object,
required: true,
},
}, },
methods: { list: {
removeIssue() { type: Object,
const issue = this.issue; required: true,
const lists = issue.getLists(); },
const labelIds = lists.map(list => list.label.id); },
methods: {
// Post the remove data removeIssue() {
gl.boardService.bulkUpdate([issue.globalId], { const issue = this.issue;
remove_label_ids: labelIds, const lists = issue.getLists();
}).catch(() => { const labelIds = lists.map(list => list.label.id);
new Flash('Failed to remove issue from board, please try again.', 'alert');
// Post the remove data
lists.forEach((list) => { gl.boardService.bulkUpdate([issue.globalId], {
list.addIssue(issue); remove_label_ids: labelIds,
}); }).catch(() => {
}); new Flash('Failed to remove issue from board, please try again.', 'alert');
// Remove from the frontend store
lists.forEach((list) => { lists.forEach((list) => {
list.removeIssue(issue); list.addIssue(issue);
}); });
});
// Remove from the frontend store
lists.forEach((list) => {
list.removeIssue(issue);
});
Store.detail.issue = {}; Store.detail.issue = {};
},
}, },
template: ` },
<div template: `
class="block list" <div
v-if="list.type !== 'closed'"> class="block list"
<button v-if="list.type !== 'closed'">
class="btn btn-default btn-block" <button
type="button" class="btn btn-default btn-block"
@click="removeIssue"> type="button"
Remove from board @click="removeIssue">
</button> Remove from board
</div> </button>
`, </div>
}); `,
})(); });
(() => { const ModalStore = gl.issueBoards.ModalStore;
const ModalStore = gl.issueBoards.ModalStore;
gl.issueBoards.ModalMixins = { gl.issueBoards.ModalMixins = {
methods: { methods: {
toggleModal(toggle) { toggleModal(toggle) {
ModalStore.store.showAddIssuesModal = toggle; ModalStore.store.showAddIssuesModal = toggle;
},
changeTab(tab) {
ModalStore.store.activeTab = tab;
},
}, },
}; changeTab(tab) {
})(); ModalStore.store.activeTab = tab;
},
},
};
/* eslint-disable no-unused-vars, no-mixed-operators, comma-dangle */ /* eslint-disable no-unused-vars, no-mixed-operators, comma-dangle */
/* global DocumentTouch */ /* global DocumentTouch */
((w) => { window.gl = window.gl || {};
window.gl = window.gl || {}; window.gl.issueBoards = window.gl.issueBoards || {};
window.gl.issueBoards = window.gl.issueBoards || {};
gl.issueBoards.onStart = () => { gl.issueBoards.onStart = () => {
$('.has-tooltip').tooltip('hide') $('.has-tooltip').tooltip('hide')
.tooltip('disable'); .tooltip('disable');
document.body.classList.add('is-dragging'); document.body.classList.add('is-dragging');
}; };
gl.issueBoards.onEnd = () => {
$('.has-tooltip').tooltip('enable');
document.body.classList.remove('is-dragging');
};
gl.issueBoards.touchEnabled = ('ontouchstart' in window) || window.DocumentTouch && document instanceof DocumentTouch; gl.issueBoards.onEnd = () => {
$('.has-tooltip').tooltip('enable');
document.body.classList.remove('is-dragging');
};
gl.issueBoards.getBoardSortableDefaultOptions = (obj) => { gl.issueBoards.touchEnabled = ('ontouchstart' in window) || window.DocumentTouch && document instanceof DocumentTouch;
const defaultSortOptions = {
animation: 200,
forceFallback: true,
fallbackClass: 'is-dragging',
fallbackOnBody: true,
ghostClass: 'is-ghost',
filter: '.board-delete, .btn',
delay: gl.issueBoards.touchEnabled ? 100 : 0,
scrollSensitivity: gl.issueBoards.touchEnabled ? 60 : 100,
scrollSpeed: 20,
onStart: gl.issueBoards.onStart,
onEnd: gl.issueBoards.onEnd
};
Object.keys(obj).forEach((key) => { defaultSortOptions[key] = obj[key]; }); gl.issueBoards.getBoardSortableDefaultOptions = (obj) => {
return defaultSortOptions; const defaultSortOptions = {
animation: 200,
forceFallback: true,
fallbackClass: 'is-dragging',
fallbackOnBody: true,
ghostClass: 'is-ghost',
filter: '.board-delete, .btn',
delay: gl.issueBoards.touchEnabled ? 100 : 0,
scrollSensitivity: gl.issueBoards.touchEnabled ? 60 : 100,
scrollSpeed: 20,
onStart: gl.issueBoards.onStart,
onEnd: gl.issueBoards.onEnd
}; };
})(window);
Object.keys(obj).forEach((key) => { defaultSortOptions[key] = obj[key]; });
return defaultSortOptions;
};
...@@ -3,125 +3,123 @@ ...@@ -3,125 +3,123 @@
import Cookies from 'js-cookie'; import Cookies from 'js-cookie';
(() => { window.gl = window.gl || {};
window.gl = window.gl || {}; window.gl.issueBoards = window.gl.issueBoards || {};
window.gl.issueBoards = window.gl.issueBoards || {};
gl.issueBoards.BoardsStore = { gl.issueBoards.BoardsStore = {
disabled: false, disabled: false,
filter: { filter: {
path: '', path: '',
}, },
state: {}, state: {},
detail: { detail: {
issue: {} issue: {}
}, },
moving: { moving: {
issue: {}, issue: {},
list: {} list: {}
}, },
create () { create () {
this.state.lists = []; this.state.lists = [];
this.filter.path = gl.utils.getUrlParamsArray().join('&'); this.filter.path = gl.utils.getUrlParamsArray().join('&');
}, },
addList (listObj) { addList (listObj) {
const list = new List(listObj); const list = new List(listObj);
this.state.lists.push(list); this.state.lists.push(list);
return list; return list;
}, },
new (listObj) { new (listObj) {
const list = this.addList(listObj); const list = this.addList(listObj);
list list
.save() .save()
.then(() => { .then(() => {
this.state.lists = _.sortBy(this.state.lists, 'position'); this.state.lists = _.sortBy(this.state.lists, 'position');
});
this.removeBlankState();
},
updateNewListDropdown (listId) {
$(`.js-board-list-${listId}`).removeClass('is-active');
},
shouldAddBlankState () {
// Decide whether to add the blank state
return !(this.state.lists.filter(list => list.type !== 'closed')[0]);
},
addBlankState () {
if (!this.shouldAddBlankState() || this.welcomeIsHidden() || this.disabled) return;
this.addList({
id: 'blank',
list_type: 'blank',
title: 'Welcome to your Issue Board!',
position: 0
}); });
this.removeBlankState();
},
updateNewListDropdown (listId) {
$(`.js-board-list-${listId}`).removeClass('is-active');
},
shouldAddBlankState () {
// Decide whether to add the blank state
return !(this.state.lists.filter(list => list.type !== 'closed')[0]);
},
addBlankState () {
if (!this.shouldAddBlankState() || this.welcomeIsHidden() || this.disabled) return;
this.state.lists = _.sortBy(this.state.lists, 'position'); this.addList({
}, id: 'blank',
removeBlankState () { list_type: 'blank',
this.removeList('blank'); title: 'Welcome to your Issue Board!',
position: 0
Cookies.set('issue_board_welcome_hidden', 'true', { });
expires: 365 * 10,
path: ''
});
},
welcomeIsHidden () {
return Cookies.get('issue_board_welcome_hidden') === 'true';
},
removeList (id, type = 'blank') {
const list = this.findList('id', id, type);
if (!list) return; this.state.lists = _.sortBy(this.state.lists, 'position');
},
removeBlankState () {
this.removeList('blank');
this.state.lists = this.state.lists.filter(list => list.id !== id); Cookies.set('issue_board_welcome_hidden', 'true', {
}, expires: 365 * 10,
moveList (listFrom, orderLists) { path: ''
orderLists.forEach((id, i) => { });
const list = this.findList('id', parseInt(id, 10)); },
welcomeIsHidden () {
return Cookies.get('issue_board_welcome_hidden') === 'true';
},
removeList (id, type = 'blank') {
const list = this.findList('id', id, type);
list.position = i; if (!list) return;
});
listFrom.update();
},
moveIssueToList (listFrom, listTo, issue, newIndex) {
const issueTo = listTo.findIssue(issue.id);
const issueLists = issue.getLists();
const listLabels = issueLists.map(listIssue => listIssue.label);
if (!issueTo) { this.state.lists = this.state.lists.filter(list => list.id !== id);
// Add to new lists issues if it doesn't already exist },
listTo.addIssue(issue, listFrom, newIndex); moveList (listFrom, orderLists) {
} else { orderLists.forEach((id, i) => {
listTo.updateIssueLabel(issue, listFrom); const list = this.findList('id', parseInt(id, 10));
issueTo.removeLabel(listFrom.label);
}
if (listTo.type === 'closed') { list.position = i;
issueLists.forEach((list) => { });
list.removeIssue(issue); listFrom.update();
}); },
issue.removeLabels(listLabels); moveIssueToList (listFrom, listTo, issue, newIndex) {
} else { const issueTo = listTo.findIssue(issue.id);
listFrom.removeIssue(issue); const issueLists = issue.getLists();
} const listLabels = issueLists.map(listIssue => listIssue.label);
},
moveIssueInList (list, issue, oldIndex, newIndex, idArray) {
const beforeId = parseInt(idArray[newIndex - 1], 10) || null;
const afterId = parseInt(idArray[newIndex + 1], 10) || null;
list.moveIssue(issue, oldIndex, newIndex, beforeId, afterId); if (!issueTo) {
}, // Add to new lists issues if it doesn't already exist
findList (key, val, type = 'label') { listTo.addIssue(issue, listFrom, newIndex);
return this.state.lists.filter((list) => { } else {
const byType = type ? list['type'] === type : true; listTo.updateIssueLabel(issue, listFrom);
issueTo.removeLabel(listFrom.label);
}
return list[key] === val && byType; if (listTo.type === 'closed') {
})[0]; issueLists.forEach((list) => {
}, list.removeIssue(issue);
updateFiltersUrl () { });
history.pushState(null, null, `?${this.filter.path}`); issue.removeLabels(listLabels);
} else {
listFrom.removeIssue(issue);
} }
}; },
})(); moveIssueInList (list, issue, oldIndex, newIndex, idArray) {
const beforeId = parseInt(idArray[newIndex - 1], 10) || null;
const afterId = parseInt(idArray[newIndex + 1], 10) || null;
list.moveIssue(issue, oldIndex, newIndex, beforeId, afterId);
},
findList (key, val, type = 'label') {
return this.state.lists.filter((list) => {
const byType = type ? list['type'] === type : true;
return list[key] === val && byType;
})[0];
},
updateFiltersUrl () {
history.pushState(null, null, `?${this.filter.path}`);
}
};
(() => { window.gl = window.gl || {};
window.gl = window.gl || {}; window.gl.issueBoards = window.gl.issueBoards || {};
window.gl.issueBoards = window.gl.issueBoards || {};
class ModalStore {
class ModalStore { constructor() {
constructor() { this.store = {
this.store = { columns: 3,
columns: 3, issues: [],
issues: [], issuesCount: false,
issuesCount: false, selectedIssues: [],
selectedIssues: [], showAddIssuesModal: false,
showAddIssuesModal: false, activeTab: 'all',
activeTab: 'all', selectedList: null,
selectedList: null, searchTerm: '',
searchTerm: '', loading: false,
loading: false, loadingNewPage: false,
loadingNewPage: false, filterLoading: false,
filterLoading: false, page: 1,
page: 1, perPage: 50,
perPage: 50, filter: {
filter: { path: '',
path: '', },
}, };
}; }
}
selectedCount() { selectedCount() {
return this.getSelectedIssues().length; return this.getSelectedIssues().length;
} }
toggleIssue(issueObj) { toggleIssue(issueObj) {
const issue = issueObj; const issue = issueObj;
const selected = issue.selected; const selected = issue.selected;
issue.selected = !selected; issue.selected = !selected;
if (!selected) { if (!selected) {
this.addSelectedIssue(issue); this.addSelectedIssue(issue);
} else { } else {
this.removeSelectedIssue(issue); this.removeSelectedIssue(issue);
}
} }
}
toggleAll() { toggleAll() {
const select = this.selectedCount() !== this.store.issues.length; const select = this.selectedCount() !== this.store.issues.length;
this.store.issues.forEach((issue) => { this.store.issues.forEach((issue) => {
const issueUpdate = issue; const issueUpdate = issue;
if (issueUpdate.selected !== select) { if (issueUpdate.selected !== select) {
issueUpdate.selected = select; issueUpdate.selected = select;
if (select) { if (select) {
this.addSelectedIssue(issue); this.addSelectedIssue(issue);
} else { } else {
this.removeSelectedIssue(issue); this.removeSelectedIssue(issue);
}
} }
}); }
} });
}
getSelectedIssues() { getSelectedIssues() {
return this.store.selectedIssues.filter(issue => issue.selected); return this.store.selectedIssues.filter(issue => issue.selected);
} }
addSelectedIssue(issue) { addSelectedIssue(issue) {
const index = this.selectedIssueIndex(issue); const index = this.selectedIssueIndex(issue);
if (index === -1) { if (index === -1) {
this.store.selectedIssues.push(issue); this.store.selectedIssues.push(issue);
}
} }
}
removeSelectedIssue(issue, forcePurge = false) { removeSelectedIssue(issue, forcePurge = false) {
if (this.store.activeTab === 'all' || forcePurge) { if (this.store.activeTab === 'all' || forcePurge) {
this.store.selectedIssues = this.store.selectedIssues this.store.selectedIssues = this.store.selectedIssues
.filter(fIssue => fIssue.id !== issue.id); .filter(fIssue => fIssue.id !== issue.id);
}
} }
}
purgeUnselectedIssues() { purgeUnselectedIssues() {
this.store.selectedIssues.forEach((issue) => { this.store.selectedIssues.forEach((issue) => {
if (!issue.selected) { if (!issue.selected) {
this.removeSelectedIssue(issue, true); this.removeSelectedIssue(issue, true);
} }
}); });
} }
selectedIssueIndex(issue) { selectedIssueIndex(issue) {
return this.store.selectedIssues.indexOf(issue); return this.store.selectedIssues.indexOf(issue);
} }
findSelectedIssue(issue) { findSelectedIssue(issue) {
return this.store.selectedIssues return this.store.selectedIssues
.filter(filteredIssue => filteredIssue.id === issue.id)[0]; .filter(filteredIssue => filteredIssue.id === issue.id)[0];
}
} }
}
gl.issueBoards.ModalStore = new ModalStore(); gl.issueBoards.ModalStore = new ModalStore();
})();
...@@ -2,46 +2,45 @@ ...@@ -2,46 +2,45 @@
import Vue from 'vue'; import Vue from 'vue';
((global) => { const global = window.gl || (window.gl = {});
global.cycleAnalytics = global.cycleAnalytics || {}; global.cycleAnalytics = global.cycleAnalytics || {};
global.cycleAnalytics.StageCodeComponent = Vue.extend({ global.cycleAnalytics.StageCodeComponent = Vue.extend({
props: { props: {
items: Array, items: Array,
stage: Object, stage: Object,
}, },
template: ` template: `
<div> <div>
<div class="events-description"> <div class="events-description">
{{ stage.description }} {{ stage.description }}
<limit-warning :count="items.length" /> <limit-warning :count="items.length" />
</div>
<ul class="stage-event-list">
<li v-for="mergeRequest in items" class="stage-event-item">
<div class="item-details">
<img class="avatar" :src="mergeRequest.author.avatarUrl">
<h5 class="item-title merge-merquest-title">
<a :href="mergeRequest.url">
{{ mergeRequest.title }}
</a>
</h5>
<a :href="mergeRequest.url" class="issue-link">!{{ mergeRequest.iid }}</a>
&middot;
<span>
Opened
<a :href="mergeRequest.url" class="issue-date">{{ mergeRequest.createdAt }}</a>
</span>
<span>
by
<a :href="mergeRequest.author.webUrl" class="issue-author-link">{{ mergeRequest.author.name }}</a>
</span>
</div>
<div class="item-time">
<total-time :time="mergeRequest.totalTime"></total-time>
</div>
</li>
</ul>
</div> </div>
`, <ul class="stage-event-list">
}); <li v-for="mergeRequest in items" class="stage-event-item">
})(window.gl || (window.gl = {})); <div class="item-details">
<img class="avatar" :src="mergeRequest.author.avatarUrl">
<h5 class="item-title merge-merquest-title">
<a :href="mergeRequest.url">
{{ mergeRequest.title }}
</a>
</h5>
<a :href="mergeRequest.url" class="issue-link">!{{ mergeRequest.iid }}</a>
&middot;
<span>
Opened
<a :href="mergeRequest.url" class="issue-date">{{ mergeRequest.createdAt }}</a>
</span>
<span>
by
<a :href="mergeRequest.author.webUrl" class="issue-author-link">{{ mergeRequest.author.name }}</a>
</span>
</div>
<div class="item-time">
<total-time :time="mergeRequest.totalTime"></total-time>
</div>
</li>
</ul>
</div>
`,
});
...@@ -2,48 +2,47 @@ ...@@ -2,48 +2,47 @@
import Vue from 'vue'; import Vue from 'vue';
((global) => { const global = window.gl || (window.gl = {});
global.cycleAnalytics = global.cycleAnalytics || {}; global.cycleAnalytics = global.cycleAnalytics || {};
global.cycleAnalytics.StageIssueComponent = Vue.extend({ global.cycleAnalytics.StageIssueComponent = Vue.extend({
props: { props: {
items: Array, items: Array,
stage: Object, stage: Object,
}, },
template: ` template: `
<div> <div>
<div class="events-description"> <div class="events-description">
{{ stage.description }} {{ stage.description }}
<limit-warning :count="items.length" /> <limit-warning :count="items.length" />
</div>
<ul class="stage-event-list">
<li v-for="issue in items" class="stage-event-item">
<div class="item-details">
<img class="avatar" :src="issue.author.avatarUrl">
<h5 class="item-title issue-title">
<a class="issue-title" :href="issue.url">
{{ issue.title }}
</a>
</h5>
<a :href="issue.url" class="issue-link">#{{ issue.iid }}</a>
&middot;
<span>
Opened
<a :href="issue.url" class="issue-date">{{ issue.createdAt }}</a>
</span>
<span>
by
<a :href="issue.author.webUrl" class="issue-author-link">
{{ issue.author.name }}
</a>
</span>
</div>
<div class="item-time">
<total-time :time="issue.totalTime"></total-time>
</div>
</li>
</ul>
</div> </div>
`, <ul class="stage-event-list">
}); <li v-for="issue in items" class="stage-event-item">
})(window.gl || (window.gl = {})); <div class="item-details">
<img class="avatar" :src="issue.author.avatarUrl">
<h5 class="item-title issue-title">
<a class="issue-title" :href="issue.url">
{{ issue.title }}
</a>
</h5>
<a :href="issue.url" class="issue-link">#{{ issue.iid }}</a>
&middot;
<span>
Opened
<a :href="issue.url" class="issue-date">{{ issue.createdAt }}</a>
</span>
<span>
by
<a :href="issue.author.webUrl" class="issue-author-link">
{{ issue.author.name }}
</a>
</span>
</div>
<div class="item-time">
<total-time :time="issue.totalTime"></total-time>
</div>
</li>
</ul>
</div>
`,
});
...@@ -2,50 +2,49 @@ ...@@ -2,50 +2,49 @@
import Vue from 'vue'; import Vue from 'vue';
import iconCommit from '../svg/icon_commit.svg'; import iconCommit from '../svg/icon_commit.svg';
((global) => { const global = window.gl || (window.gl = {});
global.cycleAnalytics = global.cycleAnalytics || {}; global.cycleAnalytics = global.cycleAnalytics || {};
global.cycleAnalytics.StagePlanComponent = Vue.extend({ global.cycleAnalytics.StagePlanComponent = Vue.extend({
props: { props: {
items: Array, items: Array,
stage: Object, stage: Object,
}, },
data() { data() {
return { iconCommit }; return { iconCommit };
}, },
template: ` template: `
<div> <div>
<div class="events-description"> <div class="events-description">
{{ stage.description }} {{ stage.description }}
<limit-warning :count="items.length" /> <limit-warning :count="items.length" />
</div>
<ul class="stage-event-list">
<li v-for="commit in items" class="stage-event-item">
<div class="item-details item-conmmit-component">
<img class="avatar" :src="commit.author.avatarUrl">
<h5 class="item-title commit-title">
<a :href="commit.commitUrl">
{{ commit.title }}
</a>
</h5>
<span>
First
<span class="commit-icon">${iconCommit}</span>
<a :href="commit.commitUrl" class="commit-hash-link monospace">{{ commit.shortSha }}</a>
pushed by
<a :href="commit.author.webUrl" class="commit-author-link">
{{ commit.author.name }}
</a>
</span>
</div>
<div class="item-time">
<total-time :time="commit.totalTime"></total-time>
</div>
</li>
</ul>
</div> </div>
`, <ul class="stage-event-list">
}); <li v-for="commit in items" class="stage-event-item">
})(window.gl || (window.gl = {})); <div class="item-details item-conmmit-component">
<img class="avatar" :src="commit.author.avatarUrl">
<h5 class="item-title commit-title">
<a :href="commit.commitUrl">
{{ commit.title }}
</a>
</h5>
<span>
First
<span class="commit-icon">${iconCommit}</span>
<a :href="commit.commitUrl" class="commit-hash-link monospace">{{ commit.shortSha }}</a>
pushed by
<a :href="commit.author.webUrl" class="commit-author-link">
{{ commit.author.name }}
</a>
</span>
</div>
<div class="item-time">
<total-time :time="commit.totalTime"></total-time>
</div>
</li>
</ul>
</div>
`,
});
...@@ -2,48 +2,47 @@ ...@@ -2,48 +2,47 @@
import Vue from 'vue'; import Vue from 'vue';
((global) => { const global = window.gl || (window.gl = {});
global.cycleAnalytics = global.cycleAnalytics || {}; global.cycleAnalytics = global.cycleAnalytics || {};
global.cycleAnalytics.StageProductionComponent = Vue.extend({ global.cycleAnalytics.StageProductionComponent = Vue.extend({
props: { props: {
items: Array, items: Array,
stage: Object, stage: Object,
}, },
template: ` template: `
<div> <div>
<div class="events-description"> <div class="events-description">
{{ stage.description }} {{ stage.description }}
<limit-warning :count="items.length" /> <limit-warning :count="items.length" />
</div>
<ul class="stage-event-list">
<li v-for="issue in items" class="stage-event-item">
<div class="item-details">
<img class="avatar" :src="issue.author.avatarUrl">
<h5 class="item-title issue-title">
<a class="issue-title" :href="issue.url">
{{ issue.title }}
</a>
</h5>
<a :href="issue.url" class="issue-link">#{{ issue.iid }}</a>
&middot;
<span>
Opened
<a :href="issue.url" class="issue-date">{{ issue.createdAt }}</a>
</span>
<span>
by
<a :href="issue.author.webUrl" class="issue-author-link">
{{ issue.author.name }}
</a>
</span>
</div>
<div class="item-time">
<total-time :time="issue.totalTime"></total-time>
</div>
</li>
</ul>
</div> </div>
`, <ul class="stage-event-list">
}); <li v-for="issue in items" class="stage-event-item">
})(window.gl || (window.gl = {})); <div class="item-details">
<img class="avatar" :src="issue.author.avatarUrl">
<h5 class="item-title issue-title">
<a class="issue-title" :href="issue.url">
{{ issue.title }}
</a>
</h5>
<a :href="issue.url" class="issue-link">#{{ issue.iid }}</a>
&middot;
<span>
Opened
<a :href="issue.url" class="issue-date">{{ issue.createdAt }}</a>
</span>
<span>
by
<a :href="issue.author.webUrl" class="issue-author-link">
{{ issue.author.name }}
</a>
</span>
</div>
<div class="item-time">
<total-time :time="issue.totalTime"></total-time>
</div>
</li>
</ul>
</div>
`,
});
...@@ -2,58 +2,57 @@ ...@@ -2,58 +2,57 @@
import Vue from 'vue'; import Vue from 'vue';
((global) => { const global = window.gl || (window.gl = {});
global.cycleAnalytics = global.cycleAnalytics || {}; global.cycleAnalytics = global.cycleAnalytics || {};
global.cycleAnalytics.StageReviewComponent = Vue.extend({ global.cycleAnalytics.StageReviewComponent = Vue.extend({
props: { props: {
items: Array, items: Array,
stage: Object, stage: Object,
}, },
template: ` template: `
<div> <div>
<div class="events-description"> <div class="events-description">
{{ stage.description }} {{ stage.description }}
<limit-warning :count="items.length" /> <limit-warning :count="items.length" />
</div> </div>
<ul class="stage-event-list"> <ul class="stage-event-list">
<li v-for="mergeRequest in items" class="stage-event-item"> <li v-for="mergeRequest in items" class="stage-event-item">
<div class="item-details"> <div class="item-details">
<img class="avatar" :src="mergeRequest.author.avatarUrl"> <img class="avatar" :src="mergeRequest.author.avatarUrl">
<h5 class="item-title merge-merquest-title"> <h5 class="item-title merge-merquest-title">
<a :href="mergeRequest.url"> <a :href="mergeRequest.url">
{{ mergeRequest.title }} {{ mergeRequest.title }}
</a> </a>
</h5> </h5>
<a :href="mergeRequest.url" class="issue-link">!{{ mergeRequest.iid }}</a> <a :href="mergeRequest.url" class="issue-link">!{{ mergeRequest.iid }}</a>
&middot; &middot;
<span> <span>
Opened Opened
<a :href="mergeRequest.url" class="issue-date">{{ mergeRequest.createdAt }}</a> <a :href="mergeRequest.url" class="issue-date">{{ mergeRequest.createdAt }}</a>
</span>
<span>
by
<a :href="mergeRequest.author.webUrl" class="issue-author-link">{{ mergeRequest.author.name }}</a>
</span>
<template v-if="mergeRequest.state === 'closed'">
<span class="merge-request-state">
<i class="fa fa-ban"></i>
{{ mergeRequest.state.toUpperCase() }}
</span> </span>
<span> </template>
by <template v-else>
<a :href="mergeRequest.author.webUrl" class="issue-author-link">{{ mergeRequest.author.name }}</a> <span class="merge-request-branch" v-if="mergeRequest.branch">
<i class= "fa fa-code-fork"></i>
<a :href="mergeRequest.branch.url">{{ mergeRequest.branch.name }}</a>
</span> </span>
<template v-if="mergeRequest.state === 'closed'"> </template>
<span class="merge-request-state"> </div>
<i class="fa fa-ban"></i> <div class="item-time">
{{ mergeRequest.state.toUpperCase() }} <total-time :time="mergeRequest.totalTime"></total-time>
</span> </div>
</template> </li>
<template v-else> </ul>
<span class="merge-request-branch" v-if="mergeRequest.branch"> </div>
<i class= "fa fa-code-fork"></i> `,
<a :href="mergeRequest.branch.url">{{ mergeRequest.branch.name }}</a> });
</span>
</template>
</div>
<div class="item-time">
<total-time :time="mergeRequest.totalTime"></total-time>
</div>
</li>
</ul>
</div>
`,
});
})(window.gl || (window.gl = {}));
...@@ -2,48 +2,47 @@ ...@@ -2,48 +2,47 @@
import Vue from 'vue'; import Vue from 'vue';
import iconBranch from '../svg/icon_branch.svg'; import iconBranch from '../svg/icon_branch.svg';
((global) => { const global = window.gl || (window.gl = {});
global.cycleAnalytics = global.cycleAnalytics || {}; global.cycleAnalytics = global.cycleAnalytics || {};
global.cycleAnalytics.StageStagingComponent = Vue.extend({ global.cycleAnalytics.StageStagingComponent = Vue.extend({
props: { props: {
items: Array, items: Array,
stage: Object, stage: Object,
}, },
data() { data() {
return { iconBranch }; return { iconBranch };
}, },
template: ` template: `
<div> <div>
<div class="events-description"> <div class="events-description">
{{ stage.description }} {{ stage.description }}
<limit-warning :count="items.length" /> <limit-warning :count="items.length" />
</div>
<ul class="stage-event-list">
<li v-for="build in items" class="stage-event-item item-build-component">
<div class="item-details">
<img class="avatar" :src="build.author.avatarUrl">
<h5 class="item-title">
<a :href="build.url" class="pipeline-id">#{{ build.id }}</a>
<i class="fa fa-code-fork"></i>
<a :href="build.branch.url" class="branch-name monospace">{{ build.branch.name }}</a>
<span class="icon-branch">${iconBranch}</span>
<a :href="build.commitUrl" class="short-sha monospace">{{ build.shortSha }}</a>
</h5>
<span>
<a :href="build.url" class="build-date">{{ build.date }}</a>
by
<a :href="build.author.webUrl" class="issue-author-link">
{{ build.author.name }}
</a>
</span>
</div>
<div class="item-time">
<total-time :time="build.totalTime"></total-time>
</div>
</li>
</ul>
</div> </div>
`, <ul class="stage-event-list">
}); <li v-for="build in items" class="stage-event-item item-build-component">
})(window.gl || (window.gl = {})); <div class="item-details">
<img class="avatar" :src="build.author.avatarUrl">
<h5 class="item-title">
<a :href="build.url" class="pipeline-id">#{{ build.id }}</a>
<i class="fa fa-code-fork"></i>
<a :href="build.branch.url" class="branch-name monospace">{{ build.branch.name }}</a>
<span class="icon-branch">${iconBranch}</span>
<a :href="build.commitUrl" class="short-sha monospace">{{ build.shortSha }}</a>
</h5>
<span>
<a :href="build.url" class="build-date">{{ build.date }}</a>
by
<a :href="build.author.webUrl" class="issue-author-link">
{{ build.author.name }}
</a>
</span>
</div>
<div class="item-time">
<total-time :time="build.totalTime"></total-time>
</div>
</li>
</ul>
</div>
`,
});
...@@ -3,48 +3,47 @@ import Vue from 'vue'; ...@@ -3,48 +3,47 @@ import Vue from 'vue';
import iconBuildStatus from '../svg/icon_build_status.svg'; import iconBuildStatus from '../svg/icon_build_status.svg';
import iconBranch from '../svg/icon_branch.svg'; import iconBranch from '../svg/icon_branch.svg';
((global) => { const global = window.gl || (window.gl = {});
global.cycleAnalytics = global.cycleAnalytics || {}; global.cycleAnalytics = global.cycleAnalytics || {};
global.cycleAnalytics.StageTestComponent = Vue.extend({ global.cycleAnalytics.StageTestComponent = Vue.extend({
props: { props: {
items: Array, items: Array,
stage: Object, stage: Object,
}, },
data() { data() {
return { iconBuildStatus, iconBranch }; return { iconBuildStatus, iconBranch };
}, },
template: ` template: `
<div> <div>
<div class="events-description"> <div class="events-description">
{{ stage.description }} {{ stage.description }}
<limit-warning :count="items.length" /> <limit-warning :count="items.length" />
</div>
<ul class="stage-event-list">
<li v-for="build in items" class="stage-event-item item-build-component">
<div class="item-details">
<h5 class="item-title">
<span class="icon-build-status">${iconBuildStatus}</span>
<a :href="build.url" class="item-build-name">{{ build.name }}</a>
&middot;
<a :href="build.url" class="pipeline-id">#{{ build.id }}</a>
<i class="fa fa-code-fork"></i>
<a :href="build.branch.url" class="branch-name monospace">{{ build.branch.name }}</a>
<span class="icon-branch">${iconBranch}</span>
<a :href="build.commitUrl" class="short-sha monospace">{{ build.shortSha }}</a>
</h5>
<span>
<a :href="build.url" class="issue-date">
{{ build.date }}
</a>
</span>
</div>
<div class="item-time">
<total-time :time="build.totalTime"></total-time>
</div>
</li>
</ul>
</div> </div>
`, <ul class="stage-event-list">
}); <li v-for="build in items" class="stage-event-item item-build-component">
})(window.gl || (window.gl = {})); <div class="item-details">
<h5 class="item-title">
<span class="icon-build-status">${iconBuildStatus}</span>
<a :href="build.url" class="item-build-name">{{ build.name }}</a>
&middot;
<a :href="build.url" class="pipeline-id">#{{ build.id }}</a>
<i class="fa fa-code-fork"></i>
<a :href="build.branch.url" class="branch-name monospace">{{ build.branch.name }}</a>
<span class="icon-branch">${iconBranch}</span>
<a :href="build.commitUrl" class="short-sha monospace">{{ build.shortSha }}</a>
</h5>
<span>
<a :href="build.url" class="issue-date">
{{ build.date }}
</a>
</span>
</div>
<div class="item-time">
<total-time :time="build.totalTime"></total-time>
</div>
</li>
</ul>
</div>
`,
});
...@@ -2,25 +2,24 @@ ...@@ -2,25 +2,24 @@
import Vue from 'vue'; import Vue from 'vue';
((global) => { const global = window.gl || (window.gl = {});
global.cycleAnalytics = global.cycleAnalytics || {}; global.cycleAnalytics = global.cycleAnalytics || {};
global.cycleAnalytics.TotalTimeComponent = Vue.extend({ global.cycleAnalytics.TotalTimeComponent = Vue.extend({
props: { props: {
time: Object, time: Object,
}, },
template: ` template: `
<span class="total-time"> <span class="total-time">
<template v-if="Object.keys(time).length"> <template v-if="Object.keys(time).length">
<template v-if="time.days">{{ time.days }} <span>{{ time.days === 1 ? 'day' : 'days' }}</span></template> <template v-if="time.days">{{ time.days }} <span>{{ time.days === 1 ? 'day' : 'days' }}</span></template>
<template v-if="time.hours">{{ time.hours }} <span>hr</span></template> <template v-if="time.hours">{{ time.hours }} <span>hr</span></template>
<template v-if="time.mins && !time.days">{{ time.mins }} <span>mins</span></template> <template v-if="time.mins && !time.days">{{ time.mins }} <span>mins</span></template>
<template v-if="time.seconds && Object.keys(time).length === 1 || time.seconds === 0">{{ time.seconds }} <span>s</span></template> <template v-if="time.seconds && Object.keys(time).length === 1 || time.seconds === 0">{{ time.seconds }} <span>s</span></template>
</template> </template>
<template v-else> <template v-else>
-- --
</template> </template>
</span> </span>
`, `,
}); });
})(window.gl || (window.gl = {}));
/* eslint-disable no-param-reassign */ /* eslint-disable no-param-reassign */
((global) => {
global.cycleAnalytics = global.cycleAnalytics || {};
class CycleAnalyticsService { const global = window.gl || (window.gl = {});
constructor(options) { global.cycleAnalytics = global.cycleAnalytics || {};
this.requestPath = options.requestPath;
}
fetchCycleAnalyticsData(options) { class CycleAnalyticsService {
options = options || { startDate: 30 }; constructor(options) {
this.requestPath = options.requestPath;
return $.ajax({ }
url: this.requestPath,
method: 'GET',
dataType: 'json',
contentType: 'application/json',
data: {
cycle_analytics: {
start_date: options.startDate,
},
},
});
}
fetchStageData(options) { fetchCycleAnalyticsData(options) {
const { options = options || { startDate: 30 };
stage,
startDate,
} = options;
return $.get(`${this.requestPath}/events/${stage.title.toLowerCase()}.json`, { return $.ajax({
url: this.requestPath,
method: 'GET',
dataType: 'json',
contentType: 'application/json',
data: {
cycle_analytics: { cycle_analytics: {
start_date: startDate, start_date: options.startDate,
}, },
}); },
} });
}
fetchStageData(options) {
const {
stage,
startDate,
} = options;
return $.get(`${this.requestPath}/events/${stage.title.toLowerCase()}.json`, {
cycle_analytics: {
start_date: startDate,
},
});
} }
}
global.cycleAnalytics.CycleAnalyticsService = CycleAnalyticsService; global.cycleAnalytics.CycleAnalyticsService = CycleAnalyticsService;
})(window.gl || (window.gl = {}));
...@@ -3,102 +3,101 @@ ...@@ -3,102 +3,101 @@
require('../lib/utils/text_utility'); require('../lib/utils/text_utility');
const DEFAULT_EVENT_OBJECTS = require('./default_event_objects'); const DEFAULT_EVENT_OBJECTS = require('./default_event_objects');
((global) => { const global = window.gl || (window.gl = {});
global.cycleAnalytics = global.cycleAnalytics || {}; global.cycleAnalytics = global.cycleAnalytics || {};
const EMPTY_STAGE_TEXTS = { const EMPTY_STAGE_TEXTS = {
issue: 'The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage.', issue: 'The issue stage shows the time it takes from creating an issue to assigning the issue to a milestone, or add the issue to a list on your Issue Board. Begin creating issues to see data for this stage.',
plan: 'The planning stage shows the time from the previous step to pushing your first commit. This time will be added automatically once you push your first commit.', plan: 'The planning stage shows the time from the previous step to pushing your first commit. This time will be added automatically once you push your first commit.',
code: 'The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request.', code: 'The coding stage shows the time from the first commit to creating the merge request. The data will automatically be added here once you create your first merge request.',
test: 'The testing stage shows the time GitLab CI takes to run every pipeline for the related merge request. The data will automatically be added after your first pipeline finishes running.', test: 'The testing stage shows the time GitLab CI takes to run every pipeline for the related merge request. The data will automatically be added after your first pipeline finishes running.',
review: 'The review stage shows the time from creating the merge request to merging it. The data will automatically be added after you merge your first merge request.', review: 'The review stage shows the time from creating the merge request to merging it. The data will automatically be added after you merge your first merge request.',
staging: 'The staging stage shows the time between merging the MR and deploying code to the production environment. The data will be automatically added once you deploy to production for the first time.', staging: 'The staging stage shows the time between merging the MR and deploying code to the production environment. The data will be automatically added once you deploy to production for the first time.',
production: 'The production stage shows the total time it takes between creating an issue and deploying the code to production. The data will be automatically added once you have completed the full idea to production cycle.', production: 'The production stage shows the total time it takes between creating an issue and deploying the code to production. The data will be automatically added once you have completed the full idea to production cycle.',
}; };
global.cycleAnalytics.CycleAnalyticsStore = { global.cycleAnalytics.CycleAnalyticsStore = {
state: { state: {
summary: '', summary: '',
stats: '', stats: '',
analytics: '', analytics: '',
events: [], events: [],
stages: [], stages: [],
}, },
setCycleAnalyticsData(data) { setCycleAnalyticsData(data) {
this.state = Object.assign(this.state, this.decorateData(data)); this.state = Object.assign(this.state, this.decorateData(data));
}, },
decorateData(data) { decorateData(data) {
const newData = {}; const newData = {};
newData.stages = data.stats || []; newData.stages = data.stats || [];
newData.summary = data.summary || []; newData.summary = data.summary || [];
newData.summary.forEach((item) => { newData.summary.forEach((item) => {
item.value = item.value || '-'; item.value = item.value || '-';
}); });
newData.stages.forEach((item) => { newData.stages.forEach((item) => {
const stageSlug = gl.text.dasherize(item.title.toLowerCase()); const stageSlug = gl.text.dasherize(item.title.toLowerCase());
item.active = false; item.active = false;
item.isUserAllowed = data.permissions[stageSlug]; item.isUserAllowed = data.permissions[stageSlug];
item.emptyStageText = EMPTY_STAGE_TEXTS[stageSlug]; item.emptyStageText = EMPTY_STAGE_TEXTS[stageSlug];
item.component = `stage-${stageSlug}-component`; item.component = `stage-${stageSlug}-component`;
item.slug = stageSlug; item.slug = stageSlug;
}); });
newData.analytics = data; newData.analytics = data;
return newData; return newData;
}, },
setLoadingState(state) { setLoadingState(state) {
this.state.isLoading = state; this.state.isLoading = state;
}, },
setErrorState(state) { setErrorState(state) {
this.state.hasError = state; this.state.hasError = state;
}, },
deactivateAllStages() { deactivateAllStages() {
this.state.stages.forEach((stage) => { this.state.stages.forEach((stage) => {
stage.active = false; stage.active = false;
}); });
}, },
setActiveStage(stage) { setActiveStage(stage) {
this.deactivateAllStages(); this.deactivateAllStages();
stage.active = true; stage.active = true;
}, },
setStageEvents(events, stage) { setStageEvents(events, stage) {
this.state.events = this.decorateEvents(events, stage); this.state.events = this.decorateEvents(events, stage);
}, },
decorateEvents(events, stage) { decorateEvents(events, stage) {
const newEvents = []; const newEvents = [];
events.forEach((item) => { events.forEach((item) => {
if (!item) return; if (!item) return;
const eventItem = Object.assign({}, DEFAULT_EVENT_OBJECTS[stage.slug], item); const eventItem = Object.assign({}, DEFAULT_EVENT_OBJECTS[stage.slug], item);
eventItem.totalTime = eventItem.total_time; eventItem.totalTime = eventItem.total_time;
if (eventItem.author) { if (eventItem.author) {
eventItem.author.webUrl = eventItem.author.web_url; eventItem.author.webUrl = eventItem.author.web_url;
eventItem.author.avatarUrl = eventItem.author.avatar_url; eventItem.author.avatarUrl = eventItem.author.avatar_url;
} }
if (eventItem.created_at) eventItem.createdAt = eventItem.created_at; if (eventItem.created_at) eventItem.createdAt = eventItem.created_at;
if (eventItem.short_sha) eventItem.shortSha = eventItem.short_sha; if (eventItem.short_sha) eventItem.shortSha = eventItem.short_sha;
if (eventItem.commit_url) eventItem.commitUrl = eventItem.commit_url; if (eventItem.commit_url) eventItem.commitUrl = eventItem.commit_url;
delete eventItem.author.web_url; delete eventItem.author.web_url;
delete eventItem.author.avatar_url; delete eventItem.author.avatar_url;
delete eventItem.total_time; delete eventItem.total_time;
delete eventItem.created_at; delete eventItem.created_at;
delete eventItem.short_sha; delete eventItem.short_sha;
delete eventItem.commit_url; delete eventItem.commit_url;
newEvents.push(eventItem); newEvents.push(eventItem);
}); });
return newEvents; return newEvents;
}, },
currentActiveStage() { currentActiveStage() {
return this.state.stages.find(stage => stage.active); return this.state.stages.find(stage => stage.active);
}, },
}; };
})(window.gl || (window.gl = {}));
/* eslint-disable func-names, space-before-function-paren, wrap-iife, no-var, no-param-reassign, no-cond-assign, one-var, one-var-declaration-per-line, no-void, guard-for-in, no-restricted-syntax, prefer-template, quotes, max-len */ /* eslint-disable func-names, space-before-function-paren, wrap-iife, no-var, no-param-reassign, no-cond-assign, one-var, one-var-declaration-per-line, no-void, guard-for-in, no-restricted-syntax, prefer-template, quotes, max-len */
(function() { var base;
(function(w) { var w = window;
var base; if (w.gl == null) {
if (w.gl == null) { w.gl = {};
w.gl = {}; }
if ((base = w.gl).utils == null) {
base.utils = {};
}
// Returns an array containing the value(s) of the
// of the key passed as an argument
w.gl.utils.getParameterValues = function(sParam) {
var i, sPageURL, sParameterName, sURLVariables, values;
sPageURL = decodeURIComponent(window.location.search.substring(1));
sURLVariables = sPageURL.split('&');
sParameterName = void 0;
values = [];
i = 0;
while (i < sURLVariables.length) {
sParameterName = sURLVariables[i].split('=');
if (sParameterName[0] === sParam) {
values.push(sParameterName[1].replace(/\+/g, ' '));
} }
if ((base = w.gl).utils == null) { i += 1;
base.utils = {}; }
return values;
};
// @param {Object} params - url keys and value to merge
// @param {String} url
w.gl.utils.mergeUrlParams = function(params, url) {
var lastChar, newUrl, paramName, paramValue, pattern;
newUrl = decodeURIComponent(url);
for (paramName in params) {
paramValue = params[paramName];
pattern = new RegExp("\\b(" + paramName + "=).*?(&|$)");
if (paramValue == null) {
newUrl = newUrl.replace(pattern, '');
} else if (url.search(pattern) !== -1) {
newUrl = newUrl.replace(pattern, "$1" + paramValue + "$2");
} else {
newUrl = "" + newUrl + (newUrl.indexOf('?') > 0 ? '&' : '?') + paramName + "=" + paramValue;
} }
// Returns an array containing the value(s) of the }
// of the key passed as an argument // Remove a trailing ampersand
w.gl.utils.getParameterValues = function(sParam) { lastChar = newUrl[newUrl.length - 1];
var i, sPageURL, sParameterName, sURLVariables, values; if (lastChar === '&') {
sPageURL = decodeURIComponent(window.location.search.substring(1)); newUrl = newUrl.slice(0, -1);
sURLVariables = sPageURL.split('&'); }
sParameterName = void 0; return newUrl;
values = []; };
i = 0; // removes parameter query string from url. returns the modified url
while (i < sURLVariables.length) { w.gl.utils.removeParamQueryString = function(url, param) {
sParameterName = sURLVariables[i].split('='); var urlVariables, variables;
if (sParameterName[0] === sParam) { url = decodeURIComponent(url);
values.push(sParameterName[1].replace(/\+/g, ' ')); urlVariables = url.split('&');
} return ((function() {
i += 1; var j, len, results;
results = [];
for (j = 0, len = urlVariables.length; j < len; j += 1) {
variables = urlVariables[j];
if (variables.indexOf(param) === -1) {
results.push(variables);
} }
return values; }
}; return results;
// @param {Object} params - url keys and value to merge })()).join('&');
// @param {String} url };
w.gl.utils.mergeUrlParams = function(params, url) { w.gl.utils.removeParams = (params) => {
var lastChar, newUrl, paramName, paramValue, pattern; const url = new URL(window.location.href);
newUrl = decodeURIComponent(url); params.forEach((param) => {
for (paramName in params) { url.search = w.gl.utils.removeParamQueryString(url.search, param);
paramValue = params[paramName]; });
pattern = new RegExp("\\b(" + paramName + "=).*?(&|$)"); return url.href;
if (paramValue == null) { };
newUrl = newUrl.replace(pattern, ''); w.gl.utils.getLocationHash = function(url) {
} else if (url.search(pattern) !== -1) { var hashIndex;
newUrl = newUrl.replace(pattern, "$1" + paramValue + "$2"); if (typeof url === 'undefined') {
} else { // Note: We can't use window.location.hash here because it's
newUrl = "" + newUrl + (newUrl.indexOf('?') > 0 ? '&' : '?') + paramName + "=" + paramValue; // not consistent across browsers - Firefox will pre-decode it
} url = window.location.href;
} }
// Remove a trailing ampersand hashIndex = url.indexOf('#');
lastChar = newUrl[newUrl.length - 1]; return hashIndex === -1 ? null : url.substring(hashIndex + 1);
if (lastChar === '&') { };
newUrl = newUrl.slice(0, -1);
}
return newUrl;
};
// removes parameter query string from url. returns the modified url
w.gl.utils.removeParamQueryString = function(url, param) {
var urlVariables, variables;
url = decodeURIComponent(url);
urlVariables = url.split('&');
return ((function() {
var j, len, results;
results = [];
for (j = 0, len = urlVariables.length; j < len; j += 1) {
variables = urlVariables[j];
if (variables.indexOf(param) === -1) {
results.push(variables);
}
}
return results;
})()).join('&');
};
w.gl.utils.removeParams = (params) => {
const url = new URL(window.location.href);
params.forEach((param) => {
url.search = w.gl.utils.removeParamQueryString(url.search, param);
});
return url.href;
};
w.gl.utils.getLocationHash = function(url) {
var hashIndex;
if (typeof url === 'undefined') {
// Note: We can't use window.location.hash here because it's
// not consistent across browsers - Firefox will pre-decode it
url = window.location.href;
}
hashIndex = url.indexOf('#');
return hashIndex === -1 ? null : url.substring(hashIndex + 1);
};
w.gl.utils.refreshCurrentPage = () => gl.utils.visitUrl(document.location.href); w.gl.utils.refreshCurrentPage = () => gl.utils.visitUrl(document.location.href);
w.gl.utils.visitUrl = (url) => { w.gl.utils.visitUrl = (url) => {
document.location.href = url; document.location.href = url;
}; };
})(window);
}).call(window);
/* eslint-disable func-names, space-before-function-paren, no-var, prefer-rest-params, wrap-iife, max-len */ /* eslint-disable func-names, space-before-function-paren, no-var, prefer-rest-params, wrap-iife, max-len */
(function() { import '~/lib/utils/url_utility';
var bind = function(fn, me) { return function() { return fn.apply(me, arguments); }; };
(function() {
this.MergedButtons = (function() { this.MergedButtons = (function() {
function MergedButtons() { function MergedButtons() {
this.removeSourceBranch = bind(this.removeSourceBranch, this); this.removeSourceBranch = this.removeSourceBranch.bind(this);
this.removeBranchSuccess = this.removeBranchSuccess.bind(this);
this.removeBranchError = this.removeBranchError.bind(this);
this.$removeBranchWidget = $('.remove_source_branch_widget'); this.$removeBranchWidget = $('.remove_source_branch_widget');
this.$removeBranchProgress = $('.remove_source_branch_in_progress'); this.$removeBranchProgress = $('.remove_source_branch_in_progress');
this.$removeBranchFailed = $('.remove_source_branch_widget.failed'); this.$removeBranchFailed = $('.remove_source_branch_widget.failed');
...@@ -22,7 +24,7 @@ ...@@ -22,7 +24,7 @@
MergedButtons.prototype.initEventListeners = function() { MergedButtons.prototype.initEventListeners = function() {
$(document).on('click', '.remove_source_branch', this.removeSourceBranch); $(document).on('click', '.remove_source_branch', this.removeSourceBranch);
$(document).on('ajax:success', '.remove_source_branch', this.removeBranchSuccess); $(document).on('ajax:success', '.remove_source_branch', this.removeBranchSuccess);
return $(document).on('ajax:error', '.remove_source_branch', this.removeBranchError); $(document).on('ajax:error', '.remove_source_branch', this.removeBranchError);
}; };
MergedButtons.prototype.removeSourceBranch = function() { MergedButtons.prototype.removeSourceBranch = function() {
...@@ -31,7 +33,7 @@ ...@@ -31,7 +33,7 @@
}; };
MergedButtons.prototype.removeBranchSuccess = function() { MergedButtons.prototype.removeBranchSuccess = function() {
return location.reload(); gl.utils.refreshCurrentPage();
}; };
MergedButtons.prototype.removeBranchError = function() { MergedButtons.prototype.removeBranchError = function() {
......
...@@ -158,6 +158,7 @@ ...@@ -158,6 +158,7 @@
li.task-list-item { li.task-list-item {
list-style-type: none; list-style-type: none;
position: relative; position: relative;
min-height: 22px;
padding-left: 28px; padding-left: 28px;
margin-left: 0 !important; margin-left: 0 !important;
......
...@@ -627,7 +627,6 @@ ul.notes { ...@@ -627,7 +627,6 @@ ul.notes {
} }
&:not(.is-disabled):hover, &:not(.is-disabled):hover,
&:not(.is-disabled):focus,
&.is-active { &.is-active {
color: $gl-text-green; color: $gl-text-green;
...@@ -641,6 +640,11 @@ ul.notes { ...@@ -641,6 +640,11 @@ ul.notes {
height: 15px; height: 15px;
width: 15px; width: 15px;
} }
.loading {
margin: 0;
height: auto;
}
} }
.discussion-next-btn { .discussion-next-btn {
......
...@@ -20,7 +20,8 @@ class ContainerRepository < ActiveRecord::Base ...@@ -20,7 +20,8 @@ class ContainerRepository < ActiveRecord::Base
end end
def path def path
@path ||= [project.full_path, name].select(&:present?).join('/') @path ||= [project.full_path, name]
.select(&:present?).join('/').downcase
end end
def location def location
......
...@@ -23,7 +23,7 @@ ...@@ -23,7 +23,7 @@
Registry Registry
- if project_nav_tab? :issues - if project_nav_tab? :issues
= nav_link(controller: [:issues, :labels, :milestones, :boards]) do = nav_link(controller: @project.default_issues_tracker? ? [:issues, :labels, :milestones, :boards] : :issues) do
= link_to namespace_project_issues_path(@project.namespace, @project), title: 'Issues', class: 'shortcuts-issues' do = link_to namespace_project_issues_path(@project.namespace, @project), title: 'Issues', class: 'shortcuts-issues' do
%span %span
Issues Issues
...@@ -31,7 +31,7 @@ ...@@ -31,7 +31,7 @@
%span.badge.count.issue_counter= number_with_delimiter(IssuesFinder.new(current_user, project_id: @project.id).execute.opened.count) %span.badge.count.issue_counter= number_with_delimiter(IssuesFinder.new(current_user, project_id: @project.id).execute.opened.count)
- if project_nav_tab? :merge_requests - if project_nav_tab? :merge_requests
= nav_link(controller: :merge_requests) do = nav_link(controller: @project.default_issues_tracker? ? :merge_requests : [:merge_requests, :labels, :milestones]) do
= link_to namespace_project_merge_requests_path(@project.namespace, @project), title: 'Merge Requests', class: 'shortcuts-merge_requests' do = link_to namespace_project_merge_requests_path(@project.namespace, @project), title: 'Merge Requests', class: 'shortcuts-merge_requests' do
%span %span
Merge Requests Merge Requests
......
- @no_container = true - @no_container = true
- page_title "Edit", @label.name, "Labels" - page_title "Edit", @label.name, "Labels"
= render "projects/issues/head" = render "shared/mr_head"
%div{ class: container_class } %div{ class: container_class }
%h3.page-title %h3.page-title
......
- @no_container = true - @no_container = true
- page_title "Labels" - page_title "Labels"
- hide_class = '' - hide_class = ''
= render "projects/issues/head" = render "shared/mr_head"
- if @labels.exists? || @prioritized_labels.exists? - if @labels.exists? || @prioritized_labels.exists?
%div{ class: container_class } %div{ class: container_class }
......
- @no_container = true - @no_container = true
- page_title "New Label" - page_title "New Label"
= render "projects/issues/head" = render "shared/mr_head"
%div{ class: container_class } %div{ class: container_class }
%h3.page-title %h3.page-title
......
= content_for :sub_nav do
.scrolling-tabs-container.sub-nav-scroll
= render 'shared/nav_scroll'
.nav-links.sub-nav.scrolling-tabs
%ul{ class: (container_class) }
= nav_link(controller: :merge_requests) do
= link_to namespace_project_merge_requests_path(@project.namespace, @project), title: 'Merge Requests' do
%span
List
- if project_nav_tab? :labels
= nav_link(controller: :labels) do
= link_to namespace_project_labels_path(@project.namespace, @project), title: 'Labels' do
%span
Labels
- if project_nav_tab? :milestones
= nav_link(controller: :milestones) do
= link_to namespace_project_milestones_path(@project.namespace, @project), title: 'Milestones' do
%span
Milestones
...@@ -2,6 +2,9 @@ ...@@ -2,6 +2,9 @@
- @bulk_edit = can?(current_user, :admin_merge_request, @project) - @bulk_edit = can?(current_user, :admin_merge_request, @project)
- page_title "Merge Requests" - page_title "Merge Requests"
- unless @project.default_issues_tracker?
= content_for :sub_nav do
= render "projects/merge_requests/head"
= render 'projects/last_push' = render 'projects/last_push'
- content_for :page_specific_javascripts do - content_for :page_specific_javascripts do
......
- @no_container = true - @no_container = true
- page_title "Edit", @milestone.title, "Milestones" - page_title "Edit", @milestone.title, "Milestones"
= render "projects/issues/head" = render "shared/mr_head"
%div{ class: container_class } %div{ class: container_class }
......
- @no_container = true - @no_container = true
- page_title 'Milestones' - page_title 'Milestones'
= render 'projects/issues/head' = render "shared/mr_head"
%div{ class: container_class } %div{ class: container_class }
.top-area .top-area
......
- @no_container = true - @no_container = true
- page_title "New Milestone" - page_title "New Milestone"
= render "projects/issues/head" = render "shared/mr_head"
%div{ class: container_class } %div{ class: container_class }
%h3.page-title %h3.page-title
......
- @no_container = true - @no_container = true
- page_title @milestone.title, "Milestones" - page_title @milestone.title, "Milestones"
- page_description @milestone.description - page_description @milestone.description
= render "projects/issues/head" = render "shared/mr_head"
%div{ class: container_class } %div{ class: container_class }
.detail-page-header.milestone-page-header .detail-page-header.milestone-page-header
......
...@@ -52,11 +52,10 @@ ...@@ -52,11 +52,10 @@
":aria-label" => "buttonText", ":aria-label" => "buttonText",
"@click" => "resolve", "@click" => "resolve",
":title" => "buttonText", ":title" => "buttonText",
"v-show" => "!loading",
":ref" => "'button'" } ":ref" => "'button'" }
= icon("spin spinner", "v-show" => "loading")
= render "shared/icons/icon_status_success.svg" = icon("spin spinner", "v-show" => "loading", class: 'loading')
%div{ 'v-show' => '!loading' }= render "shared/icons/icon_status_success.svg"
- if current_user - if current_user
- if note.emoji_awardable? - if note.emoji_awardable?
......
- if @project.default_issues_tracker?
= render "projects/issues/head"
- else
= render "projects/merge_requests/head"
---
title: Show sub-nav under Merge Requests when issue tracker is non-default.
merge_request: 10658
author:
---
title: Fixed alignment of empty task list items
merge_request:
author:
---
title: Fix invalid encoding when showing some traces
merge_request: 10681
author:
...@@ -13,8 +13,8 @@ module Banzai ...@@ -13,8 +13,8 @@ module Banzai
issuables = extractor.extract([doc]) issuables = extractor.extract([doc])
issuables.each do |node, issuable| issuables.each do |node, issuable|
if VISIBLE_STATES.include?(issuable.state) if VISIBLE_STATES.include?(issuable.state) && node.children.present?
node.children.last.content += " [#{issuable.state}]" node.add_child(Nokogiri::XML::Text.new(" [#{issuable.state}]", doc))
end end
end end
......
...@@ -15,7 +15,7 @@ module ContainerRegistry ...@@ -15,7 +15,7 @@ module ContainerRegistry
LEVELS_SUPPORTED = 3 LEVELS_SUPPORTED = 3
def initialize(path) def initialize(path)
@path = path @path = path.to_s.downcase
end end
def valid? def valid?
...@@ -25,7 +25,7 @@ module ContainerRegistry ...@@ -25,7 +25,7 @@ module ContainerRegistry
end end
def components def components
@components ||= @path.to_s.split('/') @components ||= @path.split('/')
end end
def nodes def nodes
......
...@@ -25,11 +25,10 @@ module Gitlab ...@@ -25,11 +25,10 @@ module Gitlab
end end
def limit(last_bytes = LIMIT_SIZE) def limit(last_bytes = LIMIT_SIZE)
stream_size = size if last_bytes < size
if stream_size < last_bytes stream.seek(-last_bytes, IO::SEEK_END)
last_bytes = stream_size stream.readline
end end
stream.seek(-last_bytes, IO::SEEK_END)
end end
def append(data, offset) def append(data, offset)
......
...@@ -198,6 +198,8 @@ feature 'Diff notes resolve', feature: true, js: true do ...@@ -198,6 +198,8 @@ feature 'Diff notes resolve', feature: true, js: true do
it 'does not mark discussion as resolved when resolving single note' do it 'does not mark discussion as resolved when resolving single note' do
page.first '.diff-content .note' do page.first '.diff-content .note' do
first('.line-resolve-btn').click first('.line-resolve-btn').click
expect(page).to have_selector('.note-action-button .loading')
expect(first('.line-resolve-btn')['data-original-title']).to eq("Resolved by #{user.name}") expect(first('.line-resolve-btn')['data-original-title']).to eq("Resolved by #{user.name}")
end end
......
.
..
😺
ヾ(´༎ຶД༎ຶ`)ノ
許功蓋
...@@ -7,6 +7,7 @@ describe Projects::MergeRequestsController, '(JavaScript fixtures)', type: :cont ...@@ -7,6 +7,7 @@ describe Projects::MergeRequestsController, '(JavaScript fixtures)', type: :cont
let(:namespace) { create(:namespace, name: 'frontend-fixtures' )} let(:namespace) { create(:namespace, name: 'frontend-fixtures' )}
let(:project) { create(:project, namespace: namespace, path: 'merge-requests-project') } let(:project) { create(:project, namespace: namespace, path: 'merge-requests-project') }
let(:merge_request) { create(:merge_request, :with_diffs, source_project: project, target_project: project, description: '- [ ] Task List Item') } let(:merge_request) { create(:merge_request, :with_diffs, source_project: project, target_project: project, description: '- [ ] Task List Item') }
let(:merged_merge_request) { create(:merge_request, :merged, source_project: project, target_project: project) }
let(:pipeline) do let(:pipeline) do
create( create(
:ci_pipeline, :ci_pipeline,
...@@ -32,6 +33,12 @@ describe Projects::MergeRequestsController, '(JavaScript fixtures)', type: :cont ...@@ -32,6 +33,12 @@ describe Projects::MergeRequestsController, '(JavaScript fixtures)', type: :cont
render_merge_request(example.description, merge_request) render_merge_request(example.description, merge_request)
end end
it 'merge_requests/merged_merge_request.html.raw' do |example|
allow_any_instance_of(MergeRequest).to receive(:source_branch_exists?).and_return(true)
allow_any_instance_of(MergeRequest).to receive(:can_remove_source_branch?).and_return(true)
render_merge_request(example.description, merged_merge_request)
end
private private
def render_merge_request(fixture_file_name, merge_request) def render_merge_request(fixture_file_name, merge_request)
......
require('~/lib/utils/text_utility'); require('~/lib/utils/text_utility');
(() => { describe('text_utility', () => {
describe('text_utility', () => { describe('gl.text.getTextWidth', () => {
describe('gl.text.getTextWidth', () => { it('returns zero width when no text is passed', () => {
it('returns zero width when no text is passed', () => { expect(gl.text.getTextWidth('')).toBe(0);
expect(gl.text.getTextWidth('')).toBe(0); });
});
it('returns zero width when no text is passed and font is passed', () => { it('returns zero width when no text is passed and font is passed', () => {
expect(gl.text.getTextWidth('', '100px sans-serif')).toBe(0); expect(gl.text.getTextWidth('', '100px sans-serif')).toBe(0);
}); });
it('returns width when text is passed', () => { it('returns width when text is passed', () => {
expect(gl.text.getTextWidth('foo') > 0).toBe(true); expect(gl.text.getTextWidth('foo') > 0).toBe(true);
}); });
it('returns bigger width when font is larger', () => { it('returns bigger width when font is larger', () => {
const largeFont = gl.text.getTextWidth('foo', '100px sans-serif'); const largeFont = gl.text.getTextWidth('foo', '100px sans-serif');
const regular = gl.text.getTextWidth('foo', '10px sans-serif'); const regular = gl.text.getTextWidth('foo', '10px sans-serif');
expect(largeFont > regular).toBe(true); expect(largeFont > regular).toBe(true);
});
}); });
});
describe('gl.text.pluralize', () => { describe('gl.text.pluralize', () => {
it('returns pluralized', () => { it('returns pluralized', () => {
expect(gl.text.pluralize('test', 2)).toBe('tests'); expect(gl.text.pluralize('test', 2)).toBe('tests');
}); });
it('returns pluralized when count is 0', () => { it('returns pluralized when count is 0', () => {
expect(gl.text.pluralize('test', 0)).toBe('tests'); expect(gl.text.pluralize('test', 0)).toBe('tests');
}); });
it('does not return pluralized', () => { it('does not return pluralized', () => {
expect(gl.text.pluralize('test', 1)).toBe('test'); expect(gl.text.pluralize('test', 1)).toBe('test');
});
}); });
});
describe('gl.text.highCountTrim', () => { describe('gl.text.highCountTrim', () => {
it('returns 99+ for count >= 100', () => { it('returns 99+ for count >= 100', () => {
expect(gl.text.highCountTrim(105)).toBe('99+'); expect(gl.text.highCountTrim(105)).toBe('99+');
expect(gl.text.highCountTrim(100)).toBe('99+'); expect(gl.text.highCountTrim(100)).toBe('99+');
}); });
it('returns exact number for count < 100', () => { it('returns exact number for count < 100', () => {
expect(gl.text.highCountTrim(45)).toBe(45); expect(gl.text.highCountTrim(45)).toBe(45);
});
}); });
});
describe('gl.text.insertText', () => { describe('gl.text.insertText', () => {
let textArea; let textArea;
beforeAll(() => { beforeAll(() => {
textArea = document.createElement('textarea'); textArea = document.createElement('textarea');
document.querySelector('body').appendChild(textArea); document.querySelector('body').appendChild(textArea);
}); });
afterAll(() => { afterAll(() => {
textArea.parentNode.removeChild(textArea); textArea.parentNode.removeChild(textArea);
}); });
describe('without selection', () => { describe('without selection', () => {
it('inserts the tag on an empty line', () => { it('inserts the tag on an empty line', () => {
const initialValue = ''; const initialValue = '';
textArea.value = initialValue; textArea.value = initialValue;
textArea.selectionStart = 0; textArea.selectionStart = 0;
textArea.selectionEnd = 0; textArea.selectionEnd = 0;
gl.text.insertText(textArea, textArea.value, '*', null, '', false); gl.text.insertText(textArea, textArea.value, '*', null, '', false);
expect(textArea.value).toEqual(`${initialValue}* `); expect(textArea.value).toEqual(`${initialValue}* `);
}); });
it('inserts the tag on a new line if the current one is not empty', () => { it('inserts the tag on a new line if the current one is not empty', () => {
const initialValue = 'some text'; const initialValue = 'some text';
textArea.value = initialValue; textArea.value = initialValue;
textArea.setSelectionRange(initialValue.length, initialValue.length); textArea.setSelectionRange(initialValue.length, initialValue.length);
gl.text.insertText(textArea, textArea.value, '*', null, '', false); gl.text.insertText(textArea, textArea.value, '*', null, '', false);
expect(textArea.value).toEqual(`${initialValue}\n* `); expect(textArea.value).toEqual(`${initialValue}\n* `);
}); });
it('inserts the tag on the same line if the current line only contains spaces', () => { it('inserts the tag on the same line if the current line only contains spaces', () => {
const initialValue = ' '; const initialValue = ' ';
textArea.value = initialValue; textArea.value = initialValue;
textArea.setSelectionRange(initialValue.length, initialValue.length); textArea.setSelectionRange(initialValue.length, initialValue.length);
gl.text.insertText(textArea, textArea.value, '*', null, '', false); gl.text.insertText(textArea, textArea.value, '*', null, '', false);
expect(textArea.value).toEqual(`${initialValue}* `); expect(textArea.value).toEqual(`${initialValue}* `);
}); });
it('inserts the tag on the same line if the current line only contains tabs', () => { it('inserts the tag on the same line if the current line only contains tabs', () => {
const initialValue = '\t\t\t'; const initialValue = '\t\t\t';
textArea.value = initialValue; textArea.value = initialValue;
textArea.setSelectionRange(initialValue.length, initialValue.length); textArea.setSelectionRange(initialValue.length, initialValue.length);
gl.text.insertText(textArea, textArea.value, '*', null, '', false); gl.text.insertText(textArea, textArea.value, '*', null, '', false);
expect(textArea.value).toEqual(`${initialValue}* `); expect(textArea.value).toEqual(`${initialValue}* `);
});
}); });
}); });
}); });
})(); });
/* global MergedButtons */
import '~/merged_buttons';
describe('MergedButtons', () => {
const fixturesPath = 'merge_requests/merged_merge_request.html.raw';
preloadFixtures(fixturesPath);
beforeEach(() => {
loadFixtures(fixturesPath);
this.mergedButtons = new MergedButtons();
this.$removeBranchWidget = $('.remove_source_branch_widget:not(.failed)');
this.$removeBranchProgress = $('.remove_source_branch_in_progress');
this.$removeBranchFailed = $('.remove_source_branch_widget.failed');
this.$removeBranchButton = $('.remove_source_branch');
});
describe('removeSourceBranch', () => {
it('shows loader', () => {
$('.remove_source_branch').trigger('click');
expect(this.$removeBranchProgress).toBeVisible();
expect(this.$removeBranchWidget).not.toBeVisible();
});
});
describe('removeBranchSuccess', () => {
it('refreshes page when branch removed', () => {
spyOn(gl.utils, 'refreshCurrentPage').and.stub();
const response = { status: 200 };
this.$removeBranchButton.trigger('ajax:success', response, 'xhr');
expect(gl.utils.refreshCurrentPage).toHaveBeenCalled();
});
});
describe('removeBranchError', () => {
it('shows error message', () => {
const response = { status: 500 };
this.$removeBranchButton.trigger('ajax:error', response, 'xhr');
expect(this.$removeBranchFailed).toBeVisible();
expect(this.$removeBranchProgress).not.toBeVisible();
expect(this.$removeBranchWidget).not.toBeVisible();
});
});
});
...@@ -6,8 +6,8 @@ describe Banzai::Filter::IssuableStateFilter, lib: true do ...@@ -6,8 +6,8 @@ describe Banzai::Filter::IssuableStateFilter, lib: true do
let(:user) { create(:user) } let(:user) { create(:user) }
def create_link(data) def create_link(text, data)
link_to('text', '', class: 'gfm has-tooltip', data: data) link_to(text, '', class: 'gfm has-tooltip', data: data)
end end
it 'ignores non-GFM links' do it 'ignores non-GFM links' do
...@@ -19,16 +19,37 @@ describe Banzai::Filter::IssuableStateFilter, lib: true do ...@@ -19,16 +19,37 @@ describe Banzai::Filter::IssuableStateFilter, lib: true do
it 'ignores non-issuable links' do it 'ignores non-issuable links' do
project = create(:empty_project, :public) project = create(:empty_project, :public)
link = create_link(project: project, reference_type: 'issue') link = create_link('text', project: project, reference_type: 'issue')
doc = filter(link, current_user: user) doc = filter(link, current_user: user)
expect(doc.css('a').last.text).to eq('text') expect(doc.css('a').last.text).to eq('text')
end end
it 'ignores issuable links with empty content' do
issue = create(:issue, :closed)
link = create_link('', issue: issue.id, reference_type: 'issue')
doc = filter(link, current_user: user)
expect(doc.css('a').last.text).to eq('')
end
it 'adds text with standard formatting' do
issue = create(:issue, :closed)
link = create_link(
'something <strong>else</strong>'.html_safe,
issue: issue.id,
reference_type: 'issue'
)
doc = filter(link, current_user: user)
expect(doc.css('a').last.inner_html).
to eq('something <strong>else</strong> [closed]')
end
context 'for issue references' do context 'for issue references' do
it 'ignores open issue references' do it 'ignores open issue references' do
issue = create(:issue) issue = create(:issue)
link = create_link(issue: issue.id, reference_type: 'issue') link = create_link('text', issue: issue.id, reference_type: 'issue')
doc = filter(link, current_user: user) doc = filter(link, current_user: user)
expect(doc.css('a').last.text).to eq('text') expect(doc.css('a').last.text).to eq('text')
...@@ -36,7 +57,7 @@ describe Banzai::Filter::IssuableStateFilter, lib: true do ...@@ -36,7 +57,7 @@ describe Banzai::Filter::IssuableStateFilter, lib: true do
it 'ignores reopened issue references' do it 'ignores reopened issue references' do
reopened_issue = create(:issue, :reopened) reopened_issue = create(:issue, :reopened)
link = create_link(issue: reopened_issue.id, reference_type: 'issue') link = create_link('text', issue: reopened_issue.id, reference_type: 'issue')
doc = filter(link, current_user: user) doc = filter(link, current_user: user)
expect(doc.css('a').last.text).to eq('text') expect(doc.css('a').last.text).to eq('text')
...@@ -44,7 +65,7 @@ describe Banzai::Filter::IssuableStateFilter, lib: true do ...@@ -44,7 +65,7 @@ describe Banzai::Filter::IssuableStateFilter, lib: true do
it 'appends [closed] to closed issue references' do it 'appends [closed] to closed issue references' do
closed_issue = create(:issue, :closed) closed_issue = create(:issue, :closed)
link = create_link(issue: closed_issue.id, reference_type: 'issue') link = create_link('text', issue: closed_issue.id, reference_type: 'issue')
doc = filter(link, current_user: user) doc = filter(link, current_user: user)
expect(doc.css('a').last.text).to eq('text [closed]') expect(doc.css('a').last.text).to eq('text [closed]')
...@@ -54,7 +75,7 @@ describe Banzai::Filter::IssuableStateFilter, lib: true do ...@@ -54,7 +75,7 @@ describe Banzai::Filter::IssuableStateFilter, lib: true do
context 'for merge request references' do context 'for merge request references' do
it 'ignores open merge request references' do it 'ignores open merge request references' do
mr = create(:merge_request) mr = create(:merge_request)
link = create_link(merge_request: mr.id, reference_type: 'merge_request') link = create_link('text', merge_request: mr.id, reference_type: 'merge_request')
doc = filter(link, current_user: user) doc = filter(link, current_user: user)
expect(doc.css('a').last.text).to eq('text') expect(doc.css('a').last.text).to eq('text')
...@@ -62,7 +83,7 @@ describe Banzai::Filter::IssuableStateFilter, lib: true do ...@@ -62,7 +83,7 @@ describe Banzai::Filter::IssuableStateFilter, lib: true do
it 'ignores reopened merge request references' do it 'ignores reopened merge request references' do
mr = create(:merge_request, :reopened) mr = create(:merge_request, :reopened)
link = create_link(merge_request: mr.id, reference_type: 'merge_request') link = create_link('text', merge_request: mr.id, reference_type: 'merge_request')
doc = filter(link, current_user: user) doc = filter(link, current_user: user)
expect(doc.css('a').last.text).to eq('text') expect(doc.css('a').last.text).to eq('text')
...@@ -70,7 +91,7 @@ describe Banzai::Filter::IssuableStateFilter, lib: true do ...@@ -70,7 +91,7 @@ describe Banzai::Filter::IssuableStateFilter, lib: true do
it 'ignores locked merge request references' do it 'ignores locked merge request references' do
mr = create(:merge_request, :locked) mr = create(:merge_request, :locked)
link = create_link(merge_request: mr.id, reference_type: 'merge_request') link = create_link('text', merge_request: mr.id, reference_type: 'merge_request')
doc = filter(link, current_user: user) doc = filter(link, current_user: user)
expect(doc.css('a').last.text).to eq('text') expect(doc.css('a').last.text).to eq('text')
...@@ -78,7 +99,7 @@ describe Banzai::Filter::IssuableStateFilter, lib: true do ...@@ -78,7 +99,7 @@ describe Banzai::Filter::IssuableStateFilter, lib: true do
it 'appends [closed] to closed merge request references' do it 'appends [closed] to closed merge request references' do
mr = create(:merge_request, :closed) mr = create(:merge_request, :closed)
link = create_link(merge_request: mr.id, reference_type: 'merge_request') link = create_link('text', merge_request: mr.id, reference_type: 'merge_request')
doc = filter(link, current_user: user) doc = filter(link, current_user: user)
expect(doc.css('a').last.text).to eq('text [closed]') expect(doc.css('a').last.text).to eq('text [closed]')
...@@ -86,7 +107,7 @@ describe Banzai::Filter::IssuableStateFilter, lib: true do ...@@ -86,7 +107,7 @@ describe Banzai::Filter::IssuableStateFilter, lib: true do
it 'appends [merged] to merged merge request references' do it 'appends [merged] to merged merge request references' do
mr = create(:merge_request, :merged) mr = create(:merge_request, :merged)
link = create_link(merge_request: mr.id, reference_type: 'merge_request') link = create_link('text', merge_request: mr.id, reference_type: 'merge_request')
doc = filter(link, current_user: user) doc = filter(link, current_user: user)
expect(doc.css('a').last.text).to eq('text [merged]') expect(doc.css('a').last.text).to eq('text [merged]')
......
...@@ -33,10 +33,20 @@ describe ContainerRegistry::Path do ...@@ -33,10 +33,20 @@ describe ContainerRegistry::Path do
end end
describe '#to_s' do describe '#to_s' do
let(:path) { 'some/image' } context 'when path does not have uppercase characters' do
let(:path) { 'some/image' }
it 'return a string with a repository path' do it 'return a string with a repository path' do
expect(subject.to_s).to eq path expect(subject.to_s).to eq 'some/image'
end
end
context 'when path has uppercase characters' do
let(:path) { 'SoMe/ImAgE' }
it 'return a string with a repository path' do
expect(subject.to_s).to eq 'some/image'
end
end end
end end
...@@ -70,6 +80,12 @@ describe ContainerRegistry::Path do ...@@ -70,6 +80,12 @@ describe ContainerRegistry::Path do
it { is_expected.to be_valid } it { is_expected.to be_valid }
end end
context 'when path contains uppercase letters' do
let(:path) { 'Some/Registry' }
it { is_expected.to be_valid }
end
end end
describe '#has_repository?' do describe '#has_repository?' do
......
...@@ -17,12 +17,12 @@ describe Gitlab::Ci::Trace::Stream do ...@@ -17,12 +17,12 @@ describe Gitlab::Ci::Trace::Stream do
describe '#limit' do describe '#limit' do
let(:stream) do let(:stream) do
described_class.new do described_class.new do
StringIO.new("12345678") StringIO.new((1..8).to_a.join("\n"))
end end
end end
it 'if size is larger we start from beggining' do it 'if size is larger we start from beginning' do
stream.limit(10) stream.limit(20)
expect(stream.tell).to eq(0) expect(stream.tell).to eq(0)
end end
...@@ -30,7 +30,27 @@ describe Gitlab::Ci::Trace::Stream do ...@@ -30,7 +30,27 @@ describe Gitlab::Ci::Trace::Stream do
it 'if size is smaller we start from the end' do it 'if size is smaller we start from the end' do
stream.limit(2) stream.limit(2)
expect(stream.tell).to eq(6) expect(stream.raw).to eq("8")
end
context 'when the trace contains ANSI sequence and Unicode' do
let(:stream) do
described_class.new do
File.open(expand_fixture_path('trace/ansi-sequence-and-unicode'))
end
end
it 'forwards to the next linefeed, case 1' do
stream.limit(7)
expect(stream.raw).to eq('')
end
it 'forwards to the next linefeed, case 2' do
stream.limit(29)
expect(stream.raw).to eq("\e[01;32m許功蓋\e[0m\n")
end
end end
end end
......
...@@ -34,8 +34,18 @@ describe ContainerRepository do ...@@ -34,8 +34,18 @@ describe ContainerRepository do
end end
describe '#path' do describe '#path' do
it 'returns a full path to the repository' do context 'when project path does not contain uppercase letters' do
expect(repository.path).to eq('group/test/my_image') it 'returns a full path to the repository' do
expect(repository.path).to eq('group/test/my_image')
end
end
context 'when path contains uppercase letters' do
let(:project) { create(:project, path: 'MY_PROJECT', group: group) }
it 'returns a full path without capital letters' do
expect(repository.path).to eq('group/my_project/my_image')
end
end end
end end
......
module FixtureHelpers module FixtureHelpers
def fixture_file(filename) def fixture_file(filename)
return '' if filename.blank? return '' if filename.blank?
file_path = File.expand_path(Rails.root.join('spec/fixtures/', filename)) File.read(expand_fixture_path(filename))
File.read(file_path) end
def expand_fixture_path(filename)
File.expand_path(Rails.root.join('spec/fixtures/', filename))
end end
end end
......
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