diff --git a/app/assets/javascripts/boards/components/board_form.vue b/app/assets/javascripts/boards/components/board_form.vue new file mode 100644 index 0000000000000000000000000000000000000000..6754abf4019c576b3f3c5c0faebf3ba6d87f587f --- /dev/null +++ b/app/assets/javascripts/boards/components/board_form.vue @@ -0,0 +1,216 @@ + + + diff --git a/app/assets/javascripts/boards/components/boards_selector.vue b/app/assets/javascripts/boards/components/boards_selector.vue new file mode 100644 index 0000000000000000000000000000000000000000..b05de4538f2b23f64369f34a94112e6b46674f07 --- /dev/null +++ b/app/assets/javascripts/boards/components/boards_selector.vue @@ -0,0 +1,334 @@ + + + diff --git a/app/assets/javascripts/boards/index.js b/app/assets/javascripts/boards/index.js index d6a5372b22d44c0e2afe43f7b59274a22809db5b..f5a617a85adaccfc6fd1e72971f152938f4166a3 100644 --- a/app/assets/javascripts/boards/index.js +++ b/app/assets/javascripts/boards/index.js @@ -1,7 +1,6 @@ import $ from 'jquery'; import Vue from 'vue'; -import mountMultipleBoardsSwitcher from 'ee_else_ce/boards/mount_multiple_boards_switcher'; import Flash from '~/flash'; import { __ } from '~/locale'; import './models/label'; @@ -31,6 +30,7 @@ import { } from '~/lib/utils/common_utils'; import boardConfigToggle from 'ee_else_ce/boards/config_toggle'; import toggleFocusMode from 'ee_else_ce/boards/toggle_focus'; +import mountMultipleBoardsSwitcher from './mount_multiple_boards_switcher'; let issueBoardsApp; diff --git a/app/assets/javascripts/boards/mount_multiple_boards_switcher.js b/app/assets/javascripts/boards/mount_multiple_boards_switcher.js index bdb14a7f2f27a8e6f8559e23dca01a85c7c81056..8d22f00978492fa32a9d94121886c22ef83bb1f1 100644 --- a/app/assets/javascripts/boards/mount_multiple_boards_switcher.js +++ b/app/assets/javascripts/boards/mount_multiple_boards_switcher.js @@ -1,2 +1,35 @@ -// this will be moved from EE to CE as part of https://gitlab.com/gitlab-org/gitlab-ce/issues/53811 -export default () => {}; +import Vue from 'vue'; +import { parseBoolean } from '~/lib/utils/common_utils'; +import BoardsSelector from '~/boards/components/boards_selector.vue'; + +export default () => { + const boardsSwitcherElement = document.getElementById('js-multiple-boards-switcher'); + return new Vue({ + el: boardsSwitcherElement, + components: { + BoardsSelector, + }, + data() { + const { dataset } = boardsSwitcherElement; + + const boardsSelectorProps = { + ...dataset, + currentBoard: JSON.parse(dataset.currentBoard), + hasMissingBoards: parseBoolean(dataset.hasMissingBoards), + canAdminBoard: parseBoolean(dataset.canAdminBoard), + multipleIssueBoardsAvailable: parseBoolean(dataset.multipleIssueBoardsAvailable), + projectId: Number(dataset.projectId), + groupId: Number(dataset.groupId), + scopedIssueBoardFeatureEnabled: parseBoolean(dataset.scopedIssueBoardFeatureEnabled), + weights: JSON.parse(dataset.weights), + }; + + return { boardsSelectorProps }; + }, + render(createElement) { + return createElement(BoardsSelector, { + props: this.boardsSelectorProps, + }); + }, + }); +}; diff --git a/app/views/shared/boards/_switcher.html.haml b/app/views/shared/boards/_switcher.html.haml new file mode 100644 index 0000000000000000000000000000000000000000..791186307629142088ffac80c2398a483b2b2296 --- /dev/null +++ b/app/views/shared/boards/_switcher.html.haml @@ -0,0 +1,16 @@ +- parent = board.parent +- milestone_filter_opts = { format: :json } +- milestone_filter_opts = milestone_filter_opts.merge(only_group_milestones: true) if board.group_board? +- weights = Gitlab.ee? ? ([Issue::WEIGHT_ANY] + Issue.weight_options) : [] + +#js-multiple-boards-switcher.inline.boards-switcher{ data: { current_board: current_board_json.to_json, + milestone_path: milestones_filter_path(milestone_filter_opts), + board_base_url: board_base_url, + has_missing_boards: (!multiple_boards_available? && current_board_parent.boards.size > 1).to_s, + can_admin_board: can?(current_user, :admin_board, parent).to_s, + multiple_issue_boards_available: parent.multiple_issue_boards_available?.to_s, + labels_path: labels_filter_path_with_defaults(only_group_labels: true, include_descendant_groups: true), + project_id: @project&.id, + group_id: @group&.id, + scoped_issue_board_feature_enabled: Gitlab.ee? && parent.feature_available?(:scoped_issue_board) ? 'true' : 'false', + weights: weights.to_json } } diff --git a/app/views/shared/issuable/_search_bar.html.haml b/app/views/shared/issuable/_search_bar.html.haml index a97ac5e2a2d9ded5e7681cdd69a3e8daa4c138d1..e253413929abec84f6b8a66266308a764d75e2d3 100644 --- a/app/views/shared/issuable/_search_bar.html.haml +++ b/app/views/shared/issuable/_search_bar.html.haml @@ -6,7 +6,7 @@ .issues-filters{ class: ("w-100" if type == :boards_modal) } .issues-details-filters.filtered-search-block.d-flex.flex-column.flex-md-row{ class: block_css_class, "v-pre" => type == :boards_modal } - if type == :boards - = render_if_exists "shared/boards/switcher", board: board + = render "shared/boards/switcher", board: board = form_tag page_filter_path, method: :get, class: 'filter-form js-filter-form w-100' do - if params[:search].present? = hidden_field_tag :search, params[:search] diff --git a/changelogs/unreleased/winh-multiple-issueboards-core.yml b/changelogs/unreleased/winh-multiple-issueboards-core.yml new file mode 100644 index 0000000000000000000000000000000000000000..c45e420c133a7d7fc04659ebd8652c82c956a628 --- /dev/null +++ b/changelogs/unreleased/winh-multiple-issueboards-core.yml @@ -0,0 +1,5 @@ +--- +title: Move multiple issue boards to core +merge_request: 30503 +author: +type: changed diff --git a/locale/gitlab.pot b/locale/gitlab.pot index 5e8d1ac206a5fa2ea0b0bf291cdb9c3622050795..7b742660a4c6b10c888a7be69102ace479a8676d 100644 --- a/locale/gitlab.pot +++ b/locale/gitlab.pot @@ -5717,6 +5717,21 @@ msgstr "" msgid "IssueBoards|Boards" msgstr "" +msgid "IssueBoards|Create new board" +msgstr "" + +msgid "IssueBoards|Delete board" +msgstr "" + +msgid "IssueBoards|No matching boards found" +msgstr "" + +msgid "IssueBoards|Some of your boards are hidden, activate a license to see them again." +msgstr "" + +msgid "IssueBoards|Switch board" +msgstr "" + msgid "IssueTracker|Bugzilla issue tracker" msgstr "" @@ -8698,6 +8713,9 @@ msgstr "" msgid "Receive notifications about your own activity" msgstr "" +msgid "Recent" +msgstr "" + msgid "Recent Project Activity" msgstr "" diff --git a/spec/javascripts/boards/components/board_form_spec.js b/spec/javascripts/boards/components/board_form_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..e9014156a987eb00fdbae16d0cd608a1bc2f9cf4 --- /dev/null +++ b/spec/javascripts/boards/components/board_form_spec.js @@ -0,0 +1,56 @@ +import $ from 'jquery'; +import Vue from 'vue'; +import boardsStore from '~/boards/stores/boards_store'; +import boardForm from '~/boards/components/board_form.vue'; +import mountComponent from 'spec/helpers/vue_mount_component_helper'; + +describe('board_form.vue', () => { + const props = { + canAdminBoard: false, + labelsPath: `${gl.TEST_HOST}/labels/path`, + milestonePath: `${gl.TEST_HOST}/milestone/path`, + }; + let vm; + + beforeEach(() => { + spyOn($, 'ajax'); + boardsStore.state.currentPage = 'edit'; + const Component = Vue.extend(boardForm); + vm = mountComponent(Component, props); + }); + + afterEach(() => { + vm.$destroy(); + }); + + describe('methods', () => { + describe('cancel', () => { + it('resets currentPage', done => { + vm.cancel(); + + Vue.nextTick() + .then(() => { + expect(boardsStore.state.currentPage).toBe(''); + }) + .then(done) + .catch(done.fail); + }); + }); + }); + + describe('buttons', () => { + it('cancel button triggers cancel()', done => { + spyOn(vm, 'cancel'); + + Vue.nextTick() + .then(() => { + const cancelButton = vm.$el.querySelector('button[data-dismiss="modal"]'); + cancelButton.click(); + + expect(vm.cancel).toHaveBeenCalled(); + }) + .then(done) + .catch(done.fail); + }); + }); +}); diff --git a/spec/javascripts/boards/components/boards_selector_spec.js b/spec/javascripts/boards/components/boards_selector_spec.js new file mode 100644 index 0000000000000000000000000000000000000000..504bc51778c19cb6f68531916f69b5728116bbcc --- /dev/null +++ b/spec/javascripts/boards/components/boards_selector_spec.js @@ -0,0 +1,205 @@ +import Vue from 'vue'; +import BoardService from '~/boards/services/board_service'; +import BoardsSelector from '~/boards/components/boards_selector.vue'; +import mountComponent from 'spec/helpers/vue_mount_component_helper'; +import { TEST_HOST } from 'spec/test_constants'; +import boardsStore from '~/boards/stores/boards_store'; + +const throttleDuration = 1; + +function boardGenerator(n) { + return new Array(n).fill().map((board, id) => { + const name = `board${id}`; + + return { + id, + name, + }; + }); +} + +describe('BoardsSelector', () => { + let vm; + let allBoardsResponse; + let recentBoardsResponse; + let fillSearchBox; + const boards = boardGenerator(20); + const recentBoards = boardGenerator(5); + + beforeEach(done => { + setFixtures('
'); + window.gl = window.gl || {}; + + boardsStore.setEndpoints({ + boardsEndpoint: '', + recentBoardsEndpoint: '', + listsEndpoint: '', + bulkUpdatePath: '', + boardId: '', + }); + window.gl.boardService = new BoardService(); + + allBoardsResponse = Promise.resolve({ + data: boards, + }); + recentBoardsResponse = Promise.resolve({ + data: recentBoards, + }); + + spyOn(BoardService.prototype, 'allBoards').and.returnValue(allBoardsResponse); + spyOn(BoardService.prototype, 'recentBoards').and.returnValue(recentBoardsResponse); + + const Component = Vue.extend(BoardsSelector); + vm = mountComponent( + Component, + { + throttleDuration, + currentBoard: { + id: 1, + name: 'Development', + milestone_id: null, + weight: null, + assignee_id: null, + labels: [], + }, + milestonePath: `${TEST_HOST}/milestone/path`, + boardBaseUrl: `${TEST_HOST}/board/base/url`, + hasMissingBoards: false, + canAdminBoard: true, + multipleIssueBoardsAvailable: true, + labelsPath: `${TEST_HOST}/labels/path`, + projectId: 42, + groupId: 19, + scopedIssueBoardFeatureEnabled: true, + weights: [], + }, + document.querySelector('.js-boards-selector'), + ); + + vm.$el.querySelector('.js-dropdown-toggle').click(); + + Promise.all([allBoardsResponse, recentBoardsResponse]) + .then(() => vm.$nextTick()) + .then(done) + .catch(done.fail); + + fillSearchBox = filterTerm => { + const { searchBox } = vm.$refs; + const searchBoxInput = searchBox.$el.querySelector('input'); + searchBoxInput.value = filterTerm; + searchBoxInput.dispatchEvent(new Event('input')); + }; + }); + + afterEach(() => { + vm.$destroy(); + window.gl.boardService = undefined; + }); + + describe('filtering', () => { + it('shows all boards without filtering', done => { + vm.$nextTick() + .then(() => { + const dropdownItem = vm.$el.querySelectorAll('.js-dropdown-item'); + + expect(dropdownItem.length).toBe(boards.length + recentBoards.length); + }) + .then(done) + .catch(done.fail); + }); + + it('shows only matching boards when filtering', done => { + const filterTerm = 'board1'; + const expectedCount = boards.filter(board => board.name.includes(filterTerm)).length; + + fillSearchBox(filterTerm); + + vm.$nextTick() + .then(() => { + const dropdownItems = vm.$el.querySelectorAll('.js-dropdown-item'); + + expect(dropdownItems.length).toBe(expectedCount); + }) + .then(done) + .catch(done.fail); + }); + + it('shows message if there are no matching boards', done => { + fillSearchBox('does not exist'); + + vm.$nextTick() + .then(() => { + const dropdownItems = vm.$el.querySelectorAll('.js-dropdown-item'); + + expect(dropdownItems.length).toBe(0); + expect(vm.$el).toContainText('No matching boards found'); + }) + .then(done) + .catch(done.fail); + }); + }); + + describe('recent boards section', () => { + it('shows only when boards are greater than 10', done => { + vm.$nextTick() + .then(() => { + const headerEls = vm.$el.querySelectorAll('.dropdown-bold-header'); + + const expectedCount = 2; // Recent + All + + expect(expectedCount).toBe(headerEls.length); + }) + .then(done) + .catch(done.fail); + }); + + it('does not show when boards are less than 10', done => { + spyOn(vm, 'initScrollFade'); + spyOn(vm, 'setScrollFade'); + + vm.$nextTick() + .then(() => { + vm.boards = vm.boards.slice(0, 5); + }) + .then(vm.$nextTick) + .then(() => { + const headerEls = vm.$el.querySelectorAll('.dropdown-bold-header'); + const expectedCount = 0; + + expect(expectedCount).toBe(headerEls.length); + }) + .then(done) + .catch(done.fail); + }); + + it('does not show when recentBoards api returns empty array', done => { + vm.$nextTick() + .then(() => { + vm.recentBoards = []; + }) + .then(vm.$nextTick) + .then(() => { + const headerEls = vm.$el.querySelectorAll('.dropdown-bold-header'); + const expectedCount = 0; + + expect(expectedCount).toBe(headerEls.length); + }) + .then(done) + .catch(done.fail); + }); + + it('does not show when search is active', done => { + fillSearchBox('Random string'); + + vm.$nextTick() + .then(() => { + const headerEls = vm.$el.querySelectorAll('.dropdown-bold-header'); + const expectedCount = 0; + + expect(expectedCount).toBe(headerEls.length); + }) + .then(done) + .catch(done.fail); + }); + }); +});