diff options
author | Mike Greiling <mike@pixelcog.com> | 2017-02-03 22:17:03 +0300 |
---|---|---|
committer | Mike Greiling <mike@pixelcog.com> | 2017-02-03 22:17:03 +0300 |
commit | 04dc2b76d7e382a6b6369576753cfc20928c99b8 (patch) | |
tree | b9062e9886f1913a634e505755e284d7073f2eaa /app/assets/javascripts/boards | |
parent | 152b292d0b566547875a44470c76e9a43cb28a36 (diff) | |
parent | 538a6875d3b53f909e9b5b6ef396a457cbcae760 (diff) |
Merge branch 'master' into go-go-gadget-webpack
* master: (181 commits)
Fixed adding to list bug
Remove unnecessary queries for .atom and .json in Dashboard::ProjectsController#index
Fixed modal lists dropdown not updating when list is deleted
Fixed remove btn error after creating new issue in list
Removed duplicated test
Removed Masonry, instead uses groups of data
Uses mixins for repeated functions
Fixed up specs
Props use objects with required & type values
Removes labels instead of closing issue when clicking remove button
Fixed JS lint errors
Fixed issue card spec
Added webkit CSS properties
Fixed bug with empty state showing after search Fixed users href path being incorrect
Fixed bug where 2 un-selected issues would stay on selected tab
Fixed DB schema Changed how components are added in objects
Added remove button
Add optional id property to the issue schema
Fixed issue link href
Disabled add issues button if no lists exist
...
Diffstat (limited to 'app/assets/javascripts/boards')
21 files changed, 968 insertions, 43 deletions
diff --git a/app/assets/javascripts/boards/boards_bundle.js.es6 b/app/assets/javascripts/boards/boards_bundle.js.es6 index 8c7f59652ca..1053d167131 100644 --- a/app/assets/javascripts/boards/boards_bundle.js.es6 +++ b/app/assets/javascripts/boards/boards_bundle.js.es6 @@ -15,11 +15,13 @@ requireAll(require.context('./filters', true, /^\.\/.*\.(js|es6)$/)); require('./components/board'); require('./components/board_sidebar'); require('./components/new_list_dropdown'); +require('./components/modal/index'); require('./vue_resource_interceptor'); $(() => { const $boardApp = document.getElementById('board-app'); const Store = gl.issueBoards.BoardsStore; + const ModalStore = gl.issueBoards.ModalStore; window.gl = window.gl || {}; @@ -33,7 +35,8 @@ $(() => { el: $boardApp, components: { 'board': gl.issueBoards.Board, - 'board-sidebar': gl.issueBoards.BoardSidebar + 'board-sidebar': gl.issueBoards.BoardSidebar, + 'board-add-issues-modal': gl.issueBoards.IssuesModal, }, data: { state: Store.state, @@ -42,6 +45,8 @@ $(() => { boardId: $boardApp.dataset.boardId, disabled: $boardApp.dataset.disabled === 'true', issueLinkBase: $boardApp.dataset.issueLinkBase, + rootPath: $boardApp.dataset.rootPath, + bulkUpdatePath: $boardApp.dataset.bulkUpdatePath, detailIssue: Store.detail }, computed: { @@ -50,7 +55,7 @@ $(() => { }, }, created () { - gl.boardService = new BoardService(this.endpoint, this.boardId); + gl.boardService = new BoardService(this.endpoint, this.bulkUpdatePath, this.boardId); }, mounted () { Store.disabled = this.disabled; @@ -61,8 +66,6 @@ $(() => { if (list.type === 'done') { list.position = Infinity; - } else if (list.type === 'backlog') { - list.position = -1; } }); @@ -83,4 +86,27 @@ $(() => { gl.issueBoards.newListDropdownInit(); } }); + + gl.IssueBoardsModalAddBtn = new Vue({ + mixins: [gl.issueBoards.ModalMixins], + el: '#js-add-issues-btn', + data: { + modal: ModalStore.store, + store: Store.state, + }, + computed: { + disabled() { + return Store.shouldAddBlankState(); + }, + }, + template: ` + <button + class="btn btn-create pull-right prepend-left-10 has-tooltip" + type="button" + :disabled="disabled" + @click="toggleModal(true)"> + Add issues + </button> + `, + }); }); diff --git a/app/assets/javascripts/boards/components/board.js.es6 b/app/assets/javascripts/boards/components/board.js.es6 index d0dbbb17d3e..18324de18b3 100644 --- a/app/assets/javascripts/boards/components/board.js.es6 +++ b/app/assets/javascripts/boards/components/board.js.es6 @@ -22,7 +22,8 @@ require('./board_list'); props: { list: Object, disabled: Boolean, - issueLinkBase: String + issueLinkBase: String, + rootPath: String, }, data () { return { diff --git a/app/assets/javascripts/boards/components/board_card.js.es6 b/app/assets/javascripts/boards/components/board_card.js.es6 index 5fc50280811..0ea66bd027c 100644 --- a/app/assets/javascripts/boards/components/board_card.js.es6 +++ b/app/assets/javascripts/boards/components/board_card.js.es6 @@ -1,6 +1,8 @@ /* eslint-disable comma-dangle, space-before-function-paren, dot-notation */ /* global Vue */ +require('./issue_card_inner'); + (() => { const Store = gl.issueBoards.BoardsStore; @@ -9,12 +11,16 @@ gl.issueBoards.BoardCard = Vue.extend({ template: '#js-board-list-card', + components: { + 'issue-card-inner': gl.issueBoards.IssueCardInner, + }, props: { list: Object, issue: Object, issueLinkBase: String, disabled: Boolean, - index: Number + index: Number, + rootPath: String, }, data () { return { @@ -28,31 +34,6 @@ } }, methods: { - filterByLabel (label, e) { - let labelToggleText = label.title; - const labelIndex = Store.state.filters['label_name'].indexOf(label.title); - $(e.target).tooltip('hide'); - - if (labelIndex === -1) { - Store.state.filters['label_name'].push(label.title); - $('.labels-filter').prepend(`<input type="hidden" name="label_name[]" value="${label.title}" />`); - } else { - Store.state.filters['label_name'].splice(labelIndex, 1); - labelToggleText = Store.state.filters['label_name'][0]; - $(`.labels-filter input[name="label_name[]"][value="${label.title}"]`).remove(); - } - - const selectedLabels = Store.state.filters['label_name']; - if (selectedLabels.length === 0) { - labelToggleText = 'Label'; - } else if (selectedLabels.length > 1) { - labelToggleText = `${selectedLabels[0]} + ${selectedLabels.length - 1} more`; - } - - $('.labels-filter .dropdown-toggle-text').text(labelToggleText); - - Store.updateFiltersUrl(); - }, mouseDown () { this.showDetail = true; }, @@ -71,6 +52,7 @@ Store.detail.issue = {}; } else { Store.detail.issue = this.issue; + Store.detail.list = this.list; } } } diff --git a/app/assets/javascripts/boards/components/board_list.js.es6 b/app/assets/javascripts/boards/components/board_list.js.es6 index 57059cef444..60b0a30af3f 100644 --- a/app/assets/javascripts/boards/components/board_list.js.es6 +++ b/app/assets/javascripts/boards/components/board_list.js.es6 @@ -23,6 +23,7 @@ require('./board_new_issue'); issues: Array, loading: Boolean, issueLinkBase: String, + rootPath: String, }, data () { return { diff --git a/app/assets/javascripts/boards/components/board_new_issue.js.es6 b/app/assets/javascripts/boards/components/board_new_issue.js.es6 index 2386d3a613c..b5c14a198ba 100644 --- a/app/assets/javascripts/boards/components/board_new_issue.js.es6 +++ b/app/assets/javascripts/boards/components/board_new_issue.js.es6 @@ -37,6 +37,7 @@ $(this.$refs.submitButton).enable(); Store.detail.issue = issue; + Store.detail.list = this.list; }) .catch(() => { // Need this because our jQuery very kindly disables buttons on ALL form submissions diff --git a/app/assets/javascripts/boards/components/board_sidebar.js.es6 b/app/assets/javascripts/boards/components/board_sidebar.js.es6 index 75dfcb66bb0..dfc6eed785c 100644 --- a/app/assets/javascripts/boards/components/board_sidebar.js.es6 +++ b/app/assets/javascripts/boards/components/board_sidebar.js.es6 @@ -5,6 +5,8 @@ /* global LabelsSelect */ /* global Sidebar */ +require('./sidebar/remove_issue'); + (() => { const Store = gl.issueBoards.BoardsStore; @@ -18,7 +20,8 @@ data() { return { detail: Store.detail, - issue: {} + issue: {}, + list: {}, }; }, computed: { @@ -36,6 +39,7 @@ } this.issue = this.detail.issue; + this.list = this.detail.list; }, deep: true }, @@ -60,6 +64,9 @@ new LabelsSelect(); new Sidebar(); gl.Subscription.bindAll('.subscription'); - } + }, + components: { + removeBtn: gl.issueBoards.RemoveIssueBtn, + }, }); })(); diff --git a/app/assets/javascripts/boards/components/issue_card_inner.js.es6 b/app/assets/javascripts/boards/components/issue_card_inner.js.es6 new file mode 100644 index 00000000000..22a8b971ff8 --- /dev/null +++ b/app/assets/javascripts/boards/components/issue_card_inner.js.es6 @@ -0,0 +1,111 @@ +/* global Vue */ +(() => { + const Store = gl.issueBoards.BoardsStore; + + window.gl = window.gl || {}; + window.gl.issueBoards = window.gl.issueBoards || {}; + + gl.issueBoards.IssueCardInner = Vue.extend({ + props: { + issue: { + type: Object, + required: true, + }, + issueLinkBase: { + type: String, + required: true, + }, + list: { + type: Object, + required: false, + }, + rootPath: { + type: String, + required: true, + }, + }, + methods: { + showLabel(label) { + if (!this.list) return true; + + return !this.list.label || label.id !== this.list.label.id; + }, + filterByLabel(label, e) { + let labelToggleText = label.title; + const labelIndex = Store.state.filters.label_name.indexOf(label.title); + $(e.currentTarget).tooltip('hide'); + + if (labelIndex === -1) { + Store.state.filters.label_name.push(label.title); + $('.labels-filter').prepend(`<input type="hidden" name="label_name[]" value="${label.title}" />`); + } else { + Store.state.filters.label_name.splice(labelIndex, 1); + labelToggleText = Store.state.filters.label_name[0]; + $(`.labels-filter input[name="label_name[]"][value="${label.title}"]`).remove(); + } + + const selectedLabels = Store.state.filters.label_name; + if (selectedLabels.length === 0) { + labelToggleText = 'Label'; + } else if (selectedLabels.length > 1) { + labelToggleText = `${selectedLabels[0]} + ${selectedLabels.length - 1} more`; + } + + $('.labels-filter .dropdown-toggle-text').text(labelToggleText); + + Store.updateFiltersUrl(); + }, + labelStyle(label) { + return { + backgroundColor: label.color, + color: label.textColor, + }; + }, + }, + template: ` + <div> + <h4 class="card-title"> + <i + class="fa fa-eye-slash confidential-icon" + v-if="issue.confidential"></i> + <a + :href="issueLinkBase + '/' + issue.id" + :title="issue.title"> + {{ issue.title }} + </a> + </h4> + <div class="card-footer"> + <span + class="card-number" + v-if="issue.id"> + #{{ issue.id }} + </span> + <a + class="card-assignee has-tooltip" + :href="rootPath + issue.assignee.username" + :title="'Assigned to ' + issue.assignee.name" + v-if="issue.assignee" + data-container="body"> + <img + class="avatar avatar-inline s20" + :src="issue.assignee.avatar" + width="20" + height="20" + :alt="'Avatar for ' + issue.assignee.name" /> + </a> + <button + class="label color-label has-tooltip" + 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> + `, + }); +})(); diff --git a/app/assets/javascripts/boards/components/modal/empty_state.js.es6 b/app/assets/javascripts/boards/components/modal/empty_state.js.es6 new file mode 100644 index 00000000000..9538f5b69e9 --- /dev/null +++ b/app/assets/javascripts/boards/components/modal/empty_state.js.es6 @@ -0,0 +1,70 @@ +/* global Vue */ +(() => { + const ModalStore = gl.issueBoards.ModalStore; + + gl.issueBoards.ModalEmptyState = Vue.extend({ + mixins: [gl.issueBoards.ModalMixins], + data() { + return ModalStore.store; + }, + props: { + image: { + type: String, + required: true, + }, + newIssuePath: { + type: String, + required: true, + }, + }, + computed: { + contents() { + const obj = { + title: 'You haven\'t added any issues to your project yet', + content: ` + 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') { + obj.title = 'You haven\'t selected any issues yet'; + obj.content = ` + Go back to <strong>All issues</strong> and select some issues + to add to your board. + `; + } + + return obj; + }, + }, + template: ` + <section class="empty-state"> + <div class="row"> + <div class="col-xs-12 col-sm-6 col-sm-push-6"> + <aside class="svg-content" v-html="image"></aside> + </div> + <div class="col-xs-12 col-sm-6 col-sm-pull-6"> + <div class="text-content"> + <h4>{{ contents.title }}</h4> + <p v-html="contents.content"></p> + <a + :href="newIssuePath" + class="btn btn-success btn-inverted" + v-if="activeTab === 'all'"> + New issue + </a> + <button + type="button" + class="btn btn-default" + @click="changeTab('all')" + v-if="activeTab === 'selected'"> + All issues + </button> + </div> + </div> + </div> + </section> + `, + }); +})(); diff --git a/app/assets/javascripts/boards/components/modal/footer.js.es6 b/app/assets/javascripts/boards/components/modal/footer.js.es6 new file mode 100644 index 00000000000..1cbc422c961 --- /dev/null +++ b/app/assets/javascripts/boards/components/modal/footer.js.es6 @@ -0,0 +1,83 @@ +/* eslint-disable no-new */ +/* global Vue */ +/* global Flash */ + +require('./lists_dropdown'); + +(() => { + const ModalStore = gl.issueBoards.ModalStore; + + gl.issueBoards.ModalFooter = Vue.extend({ + mixins: [gl.issueBoards.ModalMixins], + data() { + return { + modal: ModalStore.store, + state: gl.issueBoards.BoardsStore.state, + }; + }, + computed: { + submitDisabled() { + return !ModalStore.selectedCount(); + }, + submitText() { + const count = ModalStore.selectedCount(); + + return `Add ${count > 0 ? count : ''} ${gl.text.pluralize('issue', count)}`; + }, + }, + methods: { + addIssues() { + const list = this.modal.selectedList || this.state.lists[0]; + const selectedIssues = ModalStore.getSelectedIssues(); + const issueIds = selectedIssues.map(issue => issue.globalId); + + // Post the data to the backend + gl.boardService.bulkUpdate(issueIds, { + add_label_ids: [list.label.id], + }).catch(() => { + 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) => { + list.addIssue(issue); + list.issuesSize += 1; + }); + + this.toggleModal(false); + }, + }, + components: { + 'lists-dropdown': gl.issueBoards.ModalFooterListsDropdown, + }, + template: ` + <footer + class="form-actions add-issues-footer"> + <div class="pull-left"> + <button + class="btn btn-success" + type="button" + :disabled="submitDisabled" + @click="addIssues"> + {{ submitText }} + </button> + <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> + `, + }); +})(); diff --git a/app/assets/javascripts/boards/components/modal/header.js.es6 b/app/assets/javascripts/boards/components/modal/header.js.es6 new file mode 100644 index 00000000000..ab903722ba4 --- /dev/null +++ b/app/assets/javascripts/boards/components/modal/header.js.es6 @@ -0,0 +1,70 @@ +/* global Vue */ + +require('./tabs'); + +(() => { + const ModalStore = gl.issueBoards.ModalStore; + + gl.issueBoards.ModalHeader = Vue.extend({ + mixins: [gl.issueBoards.ModalMixins], + data() { + return ModalStore.store; + }, + computed: { + selectAllText() { + if (ModalStore.selectedCount() !== this.issues.length || this.issues.length === 0) { + return 'Select all'; + } + + return 'Deselect all'; + }, + showSearch() { + return this.activeTab === 'all' && !this.loading && this.issuesCount > 0; + }, + }, + methods: { + toggleAll() { + this.$refs.selectAllBtn.blur(); + + ModalStore.toggleAll(); + }, + }, + components: { + 'modal-tabs': gl.issueBoards.ModalTabs, + }, + template: ` + <div> + <header class="add-issues-header form-actions"> + <h2> + Add issues + <button + type="button" + class="close" + data-dismiss="modal" + aria-label="Close" + @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"> + <input + placeholder="Search issues..." + class="form-control" + type="search" + v-model="searchTerm" /> + <button + type="button" + class="btn btn-success btn-inverted prepend-left-10" + ref="selectAllBtn" + @click="toggleAll"> + {{ selectAllText }} + </button> + </div> + </div> + `, + }); +})(); diff --git a/app/assets/javascripts/boards/components/modal/index.js.es6 b/app/assets/javascripts/boards/components/modal/index.js.es6 new file mode 100644 index 00000000000..d367b7e4246 --- /dev/null +++ b/app/assets/javascripts/boards/components/modal/index.js.es6 @@ -0,0 +1,136 @@ +/* global Vue */ +/* global ListIssue */ + +require('./header'); +require('./list'); +require('./footer'); +require('./empty_state'); + +(() => { + const ModalStore = gl.issueBoards.ModalStore; + + gl.issueBoards.IssuesModal = Vue.extend({ + props: { + blankStateImage: { + type: String, + required: true, + }, + newIssuePath: { + type: String, + required: true, + }, + issueLinkBase: { + type: String, + required: true, + }, + rootPath: { + type: String, + required: true, + }, + }, + data() { + return ModalStore.store; + }, + watch: { + page() { + this.loadIssues(); + }, + searchTerm() { + this.searchOperation(); + }, + 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; + } + }, + }, + methods: { + searchOperation: _.debounce(function searchOperationDebounce() { + this.loadIssues(true); + }, 500), + loadIssues(clearIssues = false) { + return gl.boardService.getBacklog({ + search: this.searchTerm, + page: this.page, + per: this.perPage, + }).then((res) => { + const data = res.json(); + + if (clearIssues) { + this.issues = []; + } + + data.issues.forEach((issueObj) => { + const issue = new ListIssue(issueObj); + const foundSelectedIssue = ModalStore.findSelectedIssue(issue); + issue.selected = !!foundSelectedIssue; + + this.issues.push(issue); + }); + + this.loadingNewPage = false; + + if (!this.issuesCount) { + this.issuesCount = data.size; + } + }); + }, + }, + computed: { + showList() { + if (this.activeTab === 'selected') { + return this.selectedIssues.length > 0; + } + + return this.issuesCount > 0; + }, + showEmptyState() { + if (!this.loading && this.issuesCount === 0) { + return true; + } + + return this.activeTab === 'selected' && this.selectedIssues.length === 0; + }, + }, + components: { + 'modal-header': gl.issueBoards.ModalHeader, + 'modal-list': gl.issueBoards.ModalList, + 'modal-footer': gl.issueBoards.ModalFooter, + 'empty-state': gl.issueBoards.ModalEmptyState, + }, + template: ` + <div + class="add-issues-modal" + v-if="showAddIssuesModal"> + <div class="add-issues-container"> + <modal-header></modal-header> + <modal-list + :issue-link-base="issueLinkBase" + :root-path="rootPath" + v-if="!loading && showList"></modal-list> + <empty-state + v-if="showEmptyState" + :image="blankStateImage" + :new-issue-path="newIssuePath"></empty-state> + <section + class="add-issues-list text-center" + v-if="loading"> + <div class="add-issues-list-loading"> + <i class="fa fa-spinner fa-spin"></i> + </div> + </section> + <modal-footer></modal-footer> + </div> + </div> + `, + }); +})(); diff --git a/app/assets/javascripts/boards/components/modal/list.js.es6 b/app/assets/javascripts/boards/components/modal/list.js.es6 new file mode 100644 index 00000000000..d0901219216 --- /dev/null +++ b/app/assets/javascripts/boards/components/modal/list.js.es6 @@ -0,0 +1,142 @@ +/* global Vue */ +/* global ListIssue */ +/* global bp */ +(() => { + const ModalStore = gl.issueBoards.ModalStore; + + gl.issueBoards.ModalList = Vue.extend({ + props: { + issueLinkBase: { + type: String, + required: true, + }, + rootPath: { + type: String, + required: true, + }, + }, + data() { + return ModalStore.store; + }, + watch: { + activeTab() { + if (this.activeTab === 'all') { + ModalStore.purgeUnselectedIssues(); + } + }, + }, + computed: { + loopIssues() { + if (this.activeTab === 'all') { + return this.issues; + } + + return this.selectedIssues; + }, + groupedIssues() { + const groups = []; + this.loopIssues.forEach((issue, i) => { + const index = i % this.columns; + + if (!groups[index]) { + groups.push([]); + } + + groups[index].push(issue); + }); + + return groups; + }, + }, + methods: { + scrollHandler() { + const currentPage = Math.floor(this.issues.length / this.perPage); + + if ((this.scrollTop() > this.scrollHeight() - 100) && !this.loadingNewPage + && 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; + }, + 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; + }, + 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 + v-for="group in groupedIssues" + class="add-issues-list-column"> + <div + v-for="issue in group" + v-if="showIssue(issue)" + class="card-parent"> + <div + class="card" + :class="{ 'is-active': issue.selected }" + @click="toggleIssue($event, issue)"> + <issue-card-inner + :issue="issue" + :issue-link-base="issueLinkBase" + :root-path="rootPath"> + </issue-card-inner> + <span + :aria-label="'Issue #' + issue.id + ' selected'" + aria-checked="true" + v-if="issue.selected" + class="issue-card-selected text-center"> + <i class="fa fa-check"></i> + </span> + </div> + </div> + </div> + </section> + `, + }); +})(); diff --git a/app/assets/javascripts/boards/components/modal/lists_dropdown.js.es6 b/app/assets/javascripts/boards/components/modal/lists_dropdown.js.es6 new file mode 100644 index 00000000000..3c05120a2da --- /dev/null +++ b/app/assets/javascripts/boards/components/modal/lists_dropdown.js.es6 @@ -0,0 +1,56 @@ +/* global Vue */ +(() => { + const ModalStore = gl.issueBoards.ModalStore; + + gl.issueBoards.ModalFooterListsDropdown = Vue.extend({ + data() { + return { + modal: ModalStore.store, + state: gl.issueBoards.BoardsStore.state, + }; + }, + computed: { + selected() { + return this.modal.selectedList || this.state.lists[0]; + }, + }, + destroyed() { + this.modal.selectedList = null; + }, + template: ` + <div class="dropdown inline"> + <button + class="dropdown-menu-toggle" + type="button" + data-toggle="dropdown" + aria-expanded="false"> + <span + class="dropdown-label-box" + :style="{ backgroundColor: selected.label.color }"> + </span> + {{ selected.title }} + <i class="fa fa-chevron-down"></i> + </button> + <div class="dropdown-menu dropdown-menu-selectable dropdown-menu-drop-up"> + <ul> + <li + v-for="list in state.lists" + v-if="list.type == 'label'"> + <a + href="#" + role="button" + :class="{ 'is-active': list.id == selected.id }" + @click.prevent="modal.selectedList = list"> + <span + class="dropdown-label-box" + :style="{ backgroundColor: list.label.color }"> + </span> + {{ list.title }} + </a> + </li> + </ul> + </div> + </div> + `, + }); +})(); diff --git a/app/assets/javascripts/boards/components/modal/tabs.js.es6 b/app/assets/javascripts/boards/components/modal/tabs.js.es6 new file mode 100644 index 00000000000..e8cb43f3503 --- /dev/null +++ b/app/assets/javascripts/boards/components/modal/tabs.js.es6 @@ -0,0 +1,47 @@ +/* global Vue */ +(() => { + const ModalStore = gl.issueBoards.ModalStore; + + gl.issueBoards.ModalTabs = Vue.extend({ + mixins: [gl.issueBoards.ModalMixins], + data() { + return ModalStore.store; + }, + computed: { + selectedCount() { + return ModalStore.selectedCount(); + }, + }, + destroyed() { + this.activeTab = 'all'; + }, + template: ` + <div class="top-area prepend-top-10 append-bottom-10"> + <ul class="nav-links issues-state-filters"> + <li :class="{ 'active': activeTab == 'all' }"> + <a + href="#" + role="button" + @click.prevent="changeTab('all')"> + All issues + <span class="badge"> + {{ issuesCount }} + </span> + </a> + </li> + <li :class="{ 'active': activeTab == 'selected' }"> + <a + href="#" + role="button" + @click.prevent="changeTab('selected')"> + Selected issues + <span class="badge"> + {{ selectedCount }} + </span> + </a> + </li> + </ul> + </div> + `, + }); +})(); diff --git a/app/assets/javascripts/boards/components/sidebar/remove_issue.js.es6 b/app/assets/javascripts/boards/components/sidebar/remove_issue.js.es6 new file mode 100644 index 00000000000..e74935e1cb0 --- /dev/null +++ b/app/assets/javascripts/boards/components/sidebar/remove_issue.js.es6 @@ -0,0 +1,59 @@ +/* eslint-disable no-new */ +/* global Vue */ +/* global Flash */ +(() => { + const Store = gl.issueBoards.BoardsStore; + + window.gl = window.gl || {}; + window.gl.issueBoards = window.gl.issueBoards || {}; + + gl.issueBoards.RemoveIssueBtn = Vue.extend({ + props: { + issue: { + type: Object, + required: true, + }, + list: { + type: Object, + required: true, + }, + }, + methods: { + removeIssue() { + const issue = this.issue; + const lists = issue.getLists(); + const labelIds = lists.map(list => list.label.id); + + // Post the remove data + gl.boardService.bulkUpdate([issue.globalId], { + remove_label_ids: labelIds, + }).catch(() => { + new Flash('Failed to remove issue from board, please try again.', 'alert'); + + lists.forEach((list) => { + list.addIssue(issue); + }); + }); + + // Remove from the frontend store + lists.forEach((list) => { + list.removeIssue(issue); + }); + + Store.detail.issue = {}; + }, + }, + template: ` + <div + class="block list" + v-if="list.type !== 'done'"> + <button + class="btn btn-default btn-block" + type="button" + @click="removeIssue"> + Remove from board + </button> + </div> + `, + }); +})(); diff --git a/app/assets/javascripts/boards/mixins/modal_mixins.js.es6 b/app/assets/javascripts/boards/mixins/modal_mixins.js.es6 new file mode 100644 index 00000000000..d378b7d4baf --- /dev/null +++ b/app/assets/javascripts/boards/mixins/modal_mixins.js.es6 @@ -0,0 +1,14 @@ +(() => { + const ModalStore = gl.issueBoards.ModalStore; + + gl.issueBoards.ModalMixins = { + methods: { + toggleModal(toggle) { + ModalStore.store.showAddIssuesModal = toggle; + }, + changeTab(tab) { + ModalStore.store.activeTab = tab; + }, + }, + }; +})(); diff --git a/app/assets/javascripts/boards/models/issue.js.es6 b/app/assets/javascripts/boards/models/issue.js.es6 index 31531c3ee34..2d0a295ae4d 100644 --- a/app/assets/javascripts/boards/models/issue.js.es6 +++ b/app/assets/javascripts/boards/models/issue.js.es6 @@ -6,12 +6,15 @@ class ListIssue { constructor (obj) { + this.globalId = obj.id; this.id = obj.iid; this.title = obj.title; this.confidential = obj.confidential; this.dueDate = obj.due_date; this.subscribed = obj.subscribed; this.labels = []; + this.selected = false; + this.assignee = false; if (obj.assignee) { this.assignee = new ListUser(obj.assignee); diff --git a/app/assets/javascripts/boards/models/list.js.es6 b/app/assets/javascripts/boards/models/list.js.es6 index 3dd5f273057..5152be56b66 100644 --- a/app/assets/javascripts/boards/models/list.js.es6 +++ b/app/assets/javascripts/boards/models/list.js.es6 @@ -9,7 +9,7 @@ class List { this.position = obj.position; this.title = obj.title; this.type = obj.list_type; - this.preset = ['backlog', 'done', 'blank'].indexOf(this.type) > -1; + this.preset = ['done', 'blank'].indexOf(this.type) > -1; this.filters = gl.issueBoards.BoardsStore.state.filters; this.page = 1; this.loading = true; diff --git a/app/assets/javascripts/boards/services/board_service.js.es6 b/app/assets/javascripts/boards/services/board_service.js.es6 index ea55158306b..065e90518df 100644 --- a/app/assets/javascripts/boards/services/board_service.js.es6 +++ b/app/assets/javascripts/boards/services/board_service.js.es6 @@ -2,7 +2,13 @@ /* global Vue */ class BoardService { - constructor (root, boardId) { + constructor (root, bulkUpdatePath, boardId) { + this.boards = Vue.resource(`${root}{/id}.json`, {}, { + issues: { + method: 'GET', + url: `${root}/${boardId}/issues.json` + } + }); this.lists = Vue.resource(`${root}/${boardId}/lists{/id}`, {}, { generate: { method: 'POST', @@ -10,7 +16,12 @@ class BoardService { } }); this.issue = Vue.resource(`${root}/${boardId}/issues{/id}`, {}); - this.issues = Vue.resource(`${root}/${boardId}/lists{/id}/issues`, {}); + this.issues = Vue.resource(`${root}/${boardId}/lists{/id}/issues`, {}, { + bulkUpdate: { + method: 'POST', + url: bulkUpdatePath, + }, + }); Vue.http.interceptors.push((request, next) => { request.headers['X-CSRF-Token'] = $.rails.csrfToken(); @@ -65,6 +76,20 @@ class BoardService { issue }); } + + getBacklog(data) { + return this.boards.issues(data); + } + + bulkUpdate(issueIds, extraData = {}) { + const data = { + update: Object.assign(extraData, { + issuable_ids: issueIds.join(','), + }), + }; + + return this.issues.bulkUpdate(data); + } } window.BoardService = BoardService; diff --git a/app/assets/javascripts/boards/stores/boards_store.js.es6 b/app/assets/javascripts/boards/stores/boards_store.js.es6 index cdf1b09c0a4..50842ecbaaa 100644 --- a/app/assets/javascripts/boards/stores/boards_store.js.es6 +++ b/app/assets/javascripts/boards/stores/boards_store.js.es6 @@ -34,15 +34,10 @@ }, new (listObj) { const list = this.addList(listObj); - const backlogList = this.findList('type', 'backlog', 'backlog'); list .save() .then(() => { - // Remove any new issues from the backlog - // as they will be visible in the new list - list.issues.forEach(backlogList.removeIssue.bind(backlogList)); - this.state.lists = _.sortBy(this.state.lists, 'position'); }); this.removeBlankState(); @@ -52,7 +47,7 @@ }, shouldAddBlankState () { // Decide whether to add the blank state - return !(this.state.lists.filter(list => list.type !== 'backlog' && list.type !== 'done')[0]); + return !(this.state.lists.filter(list => list.type !== 'done')[0]); }, addBlankState () { if (!this.shouldAddBlankState() || this.welcomeIsHidden() || this.disabled) return; @@ -102,7 +97,7 @@ listTo.addIssue(issue, listFrom, newIndex); } - if (listTo.type === 'done' && listFrom.type !== 'backlog') { + if (listTo.type === 'done') { issueLists.forEach((list) => { list.removeIssue(issue); }); diff --git a/app/assets/javascripts/boards/stores/modal_store.js.es6 b/app/assets/javascripts/boards/stores/modal_store.js.es6 new file mode 100644 index 00000000000..73518b42b84 --- /dev/null +++ b/app/assets/javascripts/boards/stores/modal_store.js.es6 @@ -0,0 +1,96 @@ +(() => { + window.gl = window.gl || {}; + window.gl.issueBoards = window.gl.issueBoards || {}; + + class ModalStore { + constructor() { + this.store = { + columns: 3, + issues: [], + issuesCount: false, + selectedIssues: [], + showAddIssuesModal: false, + activeTab: 'all', + selectedList: null, + searchTerm: '', + loading: false, + loadingNewPage: false, + page: 1, + perPage: 50, + }; + } + + selectedCount() { + return this.getSelectedIssues().length; + } + + toggleIssue(issueObj) { + const issue = issueObj; + const selected = issue.selected; + + issue.selected = !selected; + + if (!selected) { + this.addSelectedIssue(issue); + } else { + this.removeSelectedIssue(issue); + } + } + + toggleAll() { + const select = this.selectedCount() !== this.store.issues.length; + + this.store.issues.forEach((issue) => { + const issueUpdate = issue; + + if (issueUpdate.selected !== select) { + issueUpdate.selected = select; + + if (select) { + this.addSelectedIssue(issue); + } else { + this.removeSelectedIssue(issue); + } + } + }); + } + + getSelectedIssues() { + return this.store.selectedIssues.filter(issue => issue.selected); + } + + addSelectedIssue(issue) { + const index = this.selectedIssueIndex(issue); + + if (index === -1) { + this.store.selectedIssues.push(issue); + } + } + + removeSelectedIssue(issue, forcePurge = false) { + if (this.store.activeTab === 'all' || forcePurge) { + this.store.selectedIssues = this.store.selectedIssues + .filter(fIssue => fIssue.id !== issue.id); + } + } + + purgeUnselectedIssues() { + this.store.selectedIssues.forEach((issue) => { + if (!issue.selected) { + this.removeSelectedIssue(issue, true); + } + }); + } + + selectedIssueIndex(issue) { + return this.store.selectedIssues.indexOf(issue); + } + + findSelectedIssue(issue) { + return this.store.selectedIssues + .filter(filteredIssue => filteredIssue.id === issue.id)[0]; + } + } + + gl.issueBoards.ModalStore = new ModalStore(); +})(); |