Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2019-09-23 21:06:29 +00:00
parent c792263edf
commit b925465787
29 changed files with 371 additions and 206 deletions

View File

@ -74,6 +74,11 @@ const Api = {
});
},
groupLabels(namespace) {
const url = Api.buildUrl(Api.groupLabelsPath).replace(':namespace_path', namespace);
return axios.get(url).then(({ data }) => data);
},
// Return namespaces list. Filtered by query
namespaces(query, callback) {
const url = Api.buildUrl(Api.namespacesPath);

View File

@ -48,6 +48,8 @@ export default () => {
import('ee_component/analytics/cycle_analytics/components/custom_stage_form.vue'),
AddStageButton: () =>
import('ee_component/analytics/cycle_analytics/components/add_stage_button.vue'),
CustomStageFormContainer: () =>
import('ee_component/analytics/cycle_analytics/components/custom_stage_form_container.vue'),
},
mixins: [filterMixins, addStageMixin],
data() {

View File

@ -536,13 +536,6 @@ export const stringifyTime = (timeObject, fullNameFormat = false) => {
return reducedTime.length ? reducedTime : '0m';
};
/**
* Accepts a time string of any size (e.g. '1w 2d 3h 5m' or '1w 2d') and returns
* the first non-zero unit/value pair.
*/
export const abbreviateTime = timeStr =>
timeStr.split(' ').filter(unitStr => unitStr.charAt(0) !== '0')[0];
/**
* Calculates the milliseconds between now and a given date string.
* The result cannot become negative.

View File

@ -1,6 +1,5 @@
<script>
import { __, sprintf } from '~/locale';
import { abbreviateTime } from '~/lib/utils/datetime_utility';
import icon from '~/vue_shared/components/icon.vue';
import tooltip from '~/vue_shared/directives/tooltip';
@ -41,12 +40,6 @@ export default {
},
},
computed: {
timeSpent() {
return this.abbreviateTime(this.timeSpentHumanReadable);
},
timeEstimate() {
return this.abbreviateTime(this.timeEstimateHumanReadable);
},
divClass() {
if (this.showComparisonState) {
return 'compare';
@ -73,11 +66,11 @@ export default {
},
text() {
if (this.showComparisonState) {
return `${this.timeSpent} / ${this.timeEstimate}`;
return `${this.timeSpentHumanReadable} / ${this.timeEstimateHumanReadable}`;
} else if (this.showEstimateOnlyState) {
return `-- / ${this.timeEstimate}`;
return `-- / ${this.timeEstimateHumanReadable}`;
} else if (this.showSpentOnlyState) {
return `${this.timeSpent} / --`;
return `${this.timeSpentHumanReadable} / --`;
} else if (this.showNoTimeTrackingState) {
return __('None');
}
@ -100,11 +93,6 @@ export default {
return this.showNoTimeTrackingState ? __('Time tracking') : this.timeTrackedTooltipText;
},
},
methods: {
abbreviateTime(timeStr) {
return abbreviateTime(timeStr);
},
},
};
</script>

View File

@ -110,7 +110,10 @@ export default {
<div class="ci-widget-container d-flex">
<div class="ci-widget-content">
<div class="media-body">
<div class="font-weight-bold js-pipeline-info-container">
<div
class="font-weight-bold js-pipeline-info-container"
data-qa-selector="merge_request_pipeline_info_content"
>
{{ pipeline.details.name }}
<gl-link :href="pipeline.path" class="pipeline-id font-weight-normal pipeline-number"
>#{{ pipeline.id }}</gl-link

View File

@ -0,0 +1,21 @@
import Vue from 'vue';
// see recaptcha_tags in app/views/shared/_recaptcha_form.html.haml
export const callbackName = 'recaptchaDialogCallback';
export const eventHub = new Vue();
const throwDuplicateCallbackError = () => {
throw new Error(`${callbackName} is already defined!`);
};
if (window[callbackName]) {
throwDuplicateCallbackError();
}
const callback = () => eventHub.$emit('submit');
Object.defineProperty(window, callbackName, {
get: () => callback,
set: throwDuplicateCallbackError,
});

View File

@ -1,5 +1,6 @@
<script>
import DeprecatedModal from './deprecated_modal.vue';
import { eventHub } from './recaptcha_eventhub';
export default {
name: 'RecaptchaModal',
@ -30,14 +31,11 @@ export default {
},
mounted() {
if (window.recaptchaDialogCallback) {
throw new Error('recaptchaDialogCallback is already defined!');
}
window.recaptchaDialogCallback = this.submit.bind(this);
eventHub.$on('submit', this.submit);
},
beforeDestroy() {
window.recaptchaDialogCallback = null;
eventHub.$off('submit', this.submit);
},
methods: {

View File

@ -88,7 +88,7 @@ module BoardsHelper
end
def boards_link_text
if multiple_boards_available?
if current_board_parent.multiple_issue_boards_available?
s_("IssueBoards|Boards")
else
s_("IssueBoards|Board")

View File

@ -4,10 +4,10 @@ module Boards
module Lists
class UpdateService < Boards::BaseService
def execute(list)
return not_authorized if preferences? && !can_read?(list)
return not_authorized if position? && !can_admin?(list)
update_preferences_result = update_preferences(list) if can_read?(list)
update_position_result = update_position(list) if can_admin?(list)
if update_preferences(list) || update_position(list)
if update_preferences_result || update_position_result
success(list: list)
else
error(list.errors.messages, 422)
@ -32,10 +32,6 @@ module Boards
{ collapsed: Gitlab::Utils.to_boolean(params[:collapsed]) }
end
def not_authorized
error("Not authorized", 403)
end
def preferences?
params.has_key?(:collapsed)
end

View File

@ -9,7 +9,7 @@
= _('Configure the %{link} integration.').html_safe % { link: link_to('Snowplow', 'https://snowplowanalytics.com/', target: '_blank') }
.settings-content
= form_for @application_setting, url: integrations_admin_application_settings_path, html: { class: 'fieldset-form' } do |f|
= form_for @application_setting, url: integrations_admin_application_settings_path(anchor: 'js-snowplow-settings'), html: { class: 'fieldset-form' } do |f|
= form_errors(@application_setting)
%fieldset

View File

@ -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 && (multiple_boards_available? || current_board_parent.boards.size > 1)
- if type == :boards
= 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?

View File

@ -0,0 +1,5 @@
---
title: Fix ordering of issue board lists not being persisted
merge_request: 17356
author:
type: fixed

View File

@ -0,0 +1,5 @@
---
title: Users can view the blame or history of a file with newlines in its filename.
merge_request: 17543
author: Jesse Hall @jessehall3
type: fixed

View File

@ -34,7 +34,7 @@ scope format: false do
# ref regex used in constraints. Regex verification now done in controller.
get 'logs_tree/*path', action: :logs_tree, as: :logs_file, format: false, constraints: {
id: /.*/,
path: /.*/
path: /[^\0]*/
}
end
end

View File

@ -4547,6 +4547,42 @@ msgstr ""
msgid "CustomCycleAnalytics|Add a stage"
msgstr ""
msgid "CustomCycleAnalytics|Add stage"
msgstr ""
msgid "CustomCycleAnalytics|Enter a name for the stage"
msgstr ""
msgid "CustomCycleAnalytics|Name"
msgstr ""
msgid "CustomCycleAnalytics|New stage"
msgstr ""
msgid "CustomCycleAnalytics|Please select a start event first"
msgstr ""
msgid "CustomCycleAnalytics|Select start event"
msgstr ""
msgid "CustomCycleAnalytics|Select stop event"
msgstr ""
msgid "CustomCycleAnalytics|Start event"
msgstr ""
msgid "CustomCycleAnalytics|Start event changed, please select a valid stop event"
msgstr ""
msgid "CustomCycleAnalytics|Start event label"
msgstr ""
msgid "CustomCycleAnalytics|Stop event"
msgstr ""
msgid "CustomCycleAnalytics|Stop event label"
msgstr ""
msgid "Customize colors"
msgstr ""
@ -10208,9 +10244,6 @@ msgstr ""
msgid "New snippet"
msgstr ""
msgid "New stage"
msgstr ""
msgid "New subgroup"
msgstr ""
@ -15698,6 +15731,9 @@ msgstr ""
msgid "There was an error fetching configuration for charts"
msgstr ""
msgid "There was an error fetching the form data"
msgstr ""
msgid "There was an error gathering the chart data"
msgstr ""

View File

@ -6,6 +6,16 @@ module QA
class Show < Page::Base
include Page::Component::Note
view 'app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.vue' do
element :dropdown_toggle
element :download_email_patches
element :download_plain_diff
end
view 'app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue' do
element :merge_request_pipeline_info_content
end
view 'app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue' do
element :merge_button
element :fast_forward_message, 'Fast-forward merge without a merge commit' # rubocop:disable QA/ElementWithPattern
@ -27,12 +37,6 @@ module QA
element :squash_checkbox
end
view 'app/assets/javascripts/vue_merge_request_widget/components/mr_widget_header.vue' do
element :dropdown_toggle
element :download_email_patches
element :download_plain_diff
end
view 'app/views/projects/merge_requests/show.html.haml' do
element :notes_tab
element :diffs_tab
@ -111,6 +115,11 @@ module QA
end
end
def has_pipeline_status?(text)
# Pipelines can be slow, so we wait a bit longer than the usual 10 seconds
has_element?(:merge_request_pipeline_info_content, text: text, wait: 30)
end
def has_title?(title)
has_element?(:title, text: title)
end

View File

@ -11,6 +11,7 @@ module QA
view 'app/views/projects/edit.html.haml' do
element :advanced_settings
element :merge_request_settings
end
view 'app/views/projects/settings/_general.html.haml' do
@ -41,6 +42,12 @@ module QA
end
end
def expand_merge_requests_settings(&block)
expand_section(:merge_request_settings) do
MergeRequest.perform(&block)
end
end
def expand_visibility_project_features_permissions(&block)
expand_section(:visibility_features_permissions_content) do
VisibilityFeaturesPermissions.perform(&block)

View File

@ -8,7 +8,6 @@ module QA
include Common
view 'app/views/projects/edit.html.haml' do
element :merge_request_settings
element :save_merge_request_changes
end
@ -16,14 +15,18 @@ module QA
element :radio_button_merge_ff
end
def click_save_changes
click_element :save_merge_request_changes
end
def enable_ff_only
expand_section(:merge_request_settings) do
click_element :radio_button_merge_ff
click_element :save_merge_request_changes
end
click_element :radio_button_merge_ff
click_save_changes
end
end
end
end
end
end
QA::Page::Project::Settings::MergeRequest.prepend_if_ee("QA::EE::Page::Project::Settings::MergeRequest")

View File

@ -13,8 +13,12 @@ module QA
end
project.visit!
Page::Project::Menu.perform(&:click_settings)
Page::Project::Settings::MergeRequest.perform(&:enable_ff_only)
Page::Project::Menu.perform(&:go_to_general_settings)
Page::Project::Settings::Main.perform do |main|
main.expand_merge_requests_settings do |settings|
settings.enable_ff_only
end
end
merge_request = Resource::MergeRequest.fabricate! do |merge_request|
merge_request.project = project

View File

@ -162,10 +162,10 @@ describe Boards::ListsController do
end
context 'with unauthorized user' do
it 'returns a forbidden 403 response' do
it 'returns a 422 unprocessable entity response' do
move user: guest, board: board, list: planning, position: 6
expect(response).to have_gitlab_http_status(403)
expect(response).to have_gitlab_http_status(422)
end
end

View File

@ -0,0 +1,122 @@
import { shallowMount, createLocalVue } from '@vue/test-utils';
import Vuex from 'vuex';
import { GlLoadingIcon } from '@gitlab/ui';
import ErrorMessage from '~/ide/components/error_message.vue';
const localVue = createLocalVue();
localVue.use(Vuex);
describe('IDE error message component', () => {
let wrapper;
const setErrorMessageMock = jest.fn();
const createComponent = messageProps => {
const fakeStore = new Vuex.Store({
actions: { setErrorMessage: setErrorMessageMock },
});
wrapper = shallowMount(ErrorMessage, {
propsData: {
message: {
text: 'some text',
actionText: 'test action',
actionPayload: 'testActionPayload',
...messageProps,
},
},
store: fakeStore,
localVue,
sync: false,
});
};
beforeEach(() => {
setErrorMessageMock.mockReset();
});
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
it('renders error message', () => {
const text = 'error message';
createComponent({ text });
expect(wrapper.text()).toContain(text);
});
it('clears error message on click', () => {
createComponent();
wrapper.trigger('click');
expect(setErrorMessageMock).toHaveBeenCalledWith(expect.any(Object), null, undefined);
});
describe('with action', () => {
let actionMock;
const message = {
actionText: 'test action',
actionPayload: 'testActionPayload',
};
beforeEach(() => {
actionMock = jest.fn().mockResolvedValue();
createComponent({
...message,
action: actionMock,
});
});
it('renders action button', () => {
const button = wrapper.find('button');
expect(button.exists()).toBe(true);
expect(button.text()).toContain(message.actionText);
});
it('does not clear error message on click', () => {
wrapper.trigger('click');
expect(setErrorMessageMock).not.toHaveBeenCalled();
});
it('dispatches action', () => {
wrapper.find('button').trigger('click');
expect(actionMock).toHaveBeenCalledWith(message.actionPayload);
});
it('does not dispatch action when already loading', () => {
wrapper.find('button').trigger('click');
actionMock.mockReset();
wrapper.find('button').trigger('click');
expect(actionMock).not.toHaveBeenCalled();
});
it('shows loading icon when loading', () => {
let resolve;
actionMock.mockImplementation(
() =>
new Promise(ok => {
resolve = ok;
}),
);
wrapper.find('button').trigger('click');
return wrapper.vm.$nextTick(() => {
expect(wrapper.find(GlLoadingIcon).isVisible()).toBe(true);
resolve();
});
});
it('hides loading icon when operation finishes', () => {
wrapper.find('button').trigger('click');
return actionMock()
.then(() => wrapper.vm.$nextTick())
.then(() => {
expect(wrapper.find(GlLoadingIcon).isVisible()).toBe(false);
});
});
});
});

View File

@ -388,20 +388,6 @@ describe('prettyTime methods', () => {
expect(datetimeUtility.stringifyTime(timeObject, true)).toEqual('1 week 1 hour');
});
});
describe('abbreviateTime', () => {
it('should abbreviate stringified times for weeks', () => {
const fullTimeString = '1w 3d 4h 5m';
expect(datetimeUtility.abbreviateTime(fullTimeString)).toBe('1w');
});
it('should abbreviate stringified times for non-weeks', () => {
const fullTimeString = '0w 3d 4h 5m';
expect(datetimeUtility.abbreviateTime(fullTimeString)).toBe('3d');
});
});
});
describe('calculateRemainingMilliseconds', () => {

View File

@ -0,0 +1,21 @@
import { eventHub, callbackName } from '~/vue_shared/components/recaptcha_eventhub';
describe('reCAPTCHA event hub', () => {
// the following test case currently crashes
// see https://gitlab.com/gitlab-org/gitlab/issues/29192#note_217840035
// eslint-disable-next-line jest/no-disabled-tests
it.skip('throws an error for overriding the callback', () => {
expect(() => {
window[callbackName] = 'something';
}).toThrow();
});
it('triggering callback emits a submit event', () => {
const eventHandler = jest.fn();
eventHub.$once('submit', eventHandler);
window[callbackName]();
expect(eventHandler).toHaveBeenCalled();
});
});

View File

@ -0,0 +1,36 @@
import { shallowMount } from '@vue/test-utils';
import { eventHub } from '~/vue_shared/components/recaptcha_eventhub';
import RecaptchaModal from '~/vue_shared/components/recaptcha_modal';
describe('RecaptchaModal', () => {
const recaptchaFormId = 'recaptcha-form';
const recaptchaHtml = `<form id="${recaptchaFormId}"></form>`;
let wrapper;
const findRecaptchaForm = () => wrapper.find(`#${recaptchaFormId}`).element;
beforeEach(() => {
wrapper = shallowMount(RecaptchaModal, {
sync: false,
propsData: {
html: recaptchaHtml,
},
});
});
afterEach(() => {
wrapper.destroy();
});
it('submits the form if event hub emits submit event', () => {
const form = findRecaptchaForm();
jest.spyOn(form, 'submit').mockImplementation();
eventHub.$emit('submit');
expect(form.submit).toHaveBeenCalled();
});
});

View File

@ -1,106 +0,0 @@
import Vue from 'vue';
import store from '~/ide/stores';
import ErrorMessage from '~/ide/components/error_message.vue';
import { createComponentWithStore } from '../../helpers/vue_mount_component_helper';
import { resetStore } from '../helpers';
describe('IDE error message component', () => {
const Component = Vue.extend(ErrorMessage);
let vm;
beforeEach(() => {
vm = createComponentWithStore(Component, store, {
message: {
text: 'error message',
action: null,
actionText: null,
},
}).$mount();
});
afterEach(() => {
vm.$destroy();
resetStore(vm.$store);
});
it('renders error message', () => {
expect(vm.$el.textContent).toContain('error message');
});
it('clears error message on click', () => {
spyOn(vm, 'setErrorMessage');
vm.$el.click();
expect(vm.setErrorMessage).toHaveBeenCalledWith(null);
});
describe('with action', () => {
let actionSpy;
beforeEach(done => {
actionSpy = jasmine.createSpy('action').and.returnValue(Promise.resolve());
vm.message.action = actionSpy;
vm.message.actionText = 'test action';
vm.message.actionPayload = 'testActionPayload';
vm.$nextTick(done);
});
it('renders action button', () => {
expect(vm.$el.querySelector('.flash-action')).not.toBe(null);
expect(vm.$el.textContent).toContain('test action');
});
it('does not clear error message on click', () => {
spyOn(vm, 'setErrorMessage');
vm.$el.click();
expect(vm.setErrorMessage).not.toHaveBeenCalled();
});
it('dispatches action', done => {
vm.$el.querySelector('.flash-action').click();
vm.$nextTick(() => {
expect(actionSpy).toHaveBeenCalledWith('testActionPayload');
done();
});
});
it('does not dispatch action when already loading', () => {
vm.isLoading = true;
vm.$el.querySelector('.flash-action').click();
expect(actionSpy).not.toHaveBeenCalledWith();
});
it('resets isLoading after click', done => {
vm.$el.querySelector('.flash-action').click();
expect(vm.isLoading).toBe(true);
setTimeout(() => {
expect(vm.isLoading).toBe(false);
done();
});
});
it('shows loading icon when isLoading is true', done => {
expect(vm.$el.querySelector('.loading-container').style.display).not.toBe('');
vm.isLoading = true;
vm.$nextTick(() => {
expect(vm.$el.querySelector('.loading-container').style.display).toBe('');
done();
});
});
});
});

View File

@ -83,8 +83,8 @@ describe('Issuable Time Tracker', () => {
initTimeTrackingComponent({
timeEstimate: 100000, // 1d 3h
timeSpent: 5000, // 1h 23m
timeEstimateHumanReadable: '',
timeSpentHumanReadable: '',
timeEstimateHumanReadable: '1d 3h',
timeSpentHumanReadable: '1h 23m',
});
});
@ -98,6 +98,16 @@ describe('Issuable Time Tracker', () => {
});
});
it('should show full times when the sidebar is collapsed', done => {
Vue.nextTick(() => {
const timeTrackingText = vm.$el.querySelector('.time-tracking-collapsed-summary span')
.innerText;
expect(timeTrackingText).toBe('1h 23m / 1d 3h');
done();
});
});
describe('Remaining meter', () => {
it('should display the remaining meter with the correct width', done => {
Vue.nextTick(() => {

View File

@ -276,6 +276,11 @@ describe 'project routing' do
expect(get('/gitlab/gitlabhq/refs/feature%2B45/logs_tree/foo/bar/baz')).to route_to('projects/refs#logs_tree', namespace_id: 'gitlab', project_id: 'gitlabhq', id: 'feature+45', path: 'foo/bar/baz')
expect(get('/gitlab/gitlabhq/refs/feature@45/logs_tree/foo/bar/baz')).to route_to('projects/refs#logs_tree', namespace_id: 'gitlab', project_id: 'gitlabhq', id: 'feature@45', path: 'foo/bar/baz')
expect(get('/gitlab/gitlabhq/refs/stable/logs_tree/files.scss')).to route_to('projects/refs#logs_tree', namespace_id: 'gitlab', project_id: 'gitlabhq', id: 'stable', path: 'files.scss')
assert_routing({ path: "/gitlab/gitlabhq/refs/stable/logs_tree/new%0A%0Aline.txt",
method: :get },
{ controller: 'projects/refs', action: 'logs_tree',
namespace_id: 'gitlab', project_id: 'gitlabhq',
id: "stable", path: "new\n\nline.txt" })
end
end

View File

@ -10,9 +10,8 @@ describe Boards::Lists::UpdateService do
context 'when user can admin list' do
it 'calls Lists::MoveService to update list position' do
board.parent.add_developer(user)
service = described_class.new(board.parent, user, position: 1)
expect(Boards::Lists::MoveService).to receive(:new).with(board.parent, user, { position: 1 }).and_call_original
expect(Boards::Lists::MoveService).to receive(:new).with(board.parent, user, params).and_call_original
expect_any_instance_of(Boards::Lists::MoveService).to receive(:execute).with(list)
service.execute(list)
@ -21,8 +20,6 @@ describe Boards::Lists::UpdateService do
context 'when user cannot admin list' do
it 'does not call Lists::MoveService to update list position' do
service = described_class.new(board.parent, user, position: 1)
expect(Boards::Lists::MoveService).not_to receive(:new)
service.execute(list)
@ -34,7 +31,6 @@ describe Boards::Lists::UpdateService do
context 'when user can read list' do
it 'updates list preference for user' do
board.parent.add_guest(user)
service = described_class.new(board.parent, user, collapsed: true)
service.execute(list)
@ -44,8 +40,6 @@ describe Boards::Lists::UpdateService do
context 'when user cannot read list' do
it 'does not update list preference for user' do
service = described_class.new(board.parent, user, collapsed: true)
service.execute(list)
expect(list.preferences_for(user).collapsed).to be_nil
@ -54,35 +48,61 @@ describe Boards::Lists::UpdateService do
end
describe '#execute' do
let(:service) { described_class.new(board.parent, user, params) }
context 'when position parameter is present' do
let(:params) { { position: 1 } }
context 'for projects' do
it_behaves_like 'moving list' do
let(:project) { create(:project, :private) }
let(:board) { create(:board, project: project) }
end
let(:project) { create(:project, :private) }
let(:board) { create(:board, project: project) }
it_behaves_like 'moving list'
end
context 'for groups' do
it_behaves_like 'moving list' do
let(:group) { create(:group, :private) }
let(:board) { create(:board, group: group) }
end
let(:group) { create(:group, :private) }
let(:board) { create(:board, group: group) }
it_behaves_like 'moving list'
end
end
context 'when collapsed parameter is present' do
let(:params) { { collapsed: true } }
context 'for projects' do
it_behaves_like 'updating list preferences' do
let(:project) { create(:project, :private) }
let(:board) { create(:board, project: project) }
end
let(:project) { create(:project, :private) }
let(:board) { create(:board, project: project) }
it_behaves_like 'updating list preferences'
end
context 'for groups' do
it_behaves_like 'updating list preferences' do
let(:group) { create(:group, :private) }
let(:board) { create(:board, group: group) }
end
let(:project) { create(:project, :private) }
let(:board) { create(:board, project: project) }
it_behaves_like 'updating list preferences'
end
end
context 'when position and collapsed are both present' do
let(:params) { { collapsed: true, position: 1 } }
context 'for projects' do
let(:project) { create(:project, :private) }
let(:board) { create(:board, project: project) }
it_behaves_like 'moving list'
it_behaves_like 'updating list preferences'
end
context 'for groups' do
let(:group) { create(:group, :private) }
let(:board) { create(:board, group: group) }
it_behaves_like 'moving list'
it_behaves_like 'updating list preferences'
end
end
end

View File

@ -11,10 +11,6 @@ shared_examples_for 'multiple issue boards' do
wait_for_requests
end
it 'shows board switcher' do
expect(page).to have_css('.boards-switcher')
end
it 'shows current board name' do
page.within('.boards-switcher') do
expect(page).to have_content(board.name)