Commit 9a61b300 authored by Jackie Fraser's avatar Jackie Fraser Committed by Savas Vedova

Add import members from project modal [RUN ALL RSPEC]

parent 3813d2a6
......@@ -3,6 +3,7 @@ import axios from '../lib/utils/axios_utils';
import { buildApiUrl } from './api_utils';
const PROJECTS_PATH = '/api/:version/projects.json';
const PROJECT_IMPORT_MEMBERS_PATH = '/api/:version/projects/:id/import_project_members/:project_id';
export function getProjects(query, options, callback = () => {}) {
const url = buildApiUrl(PROJECTS_PATH);
......@@ -25,3 +26,10 @@ export function getProjects(query, options, callback = () => {}) {
return { data, headers };
});
}
export function importProjectMembers(sourceId, targetId) {
const url = buildApiUrl(PROJECT_IMPORT_MEMBERS_PATH)
.replace(':id', sourceId)
.replace(':project_id', targetId);
return axios.post(url);
}
<script>
import { GlButton, GlFormGroup, GlModal, GlModalDirective, GlSprintf } from '@gitlab/ui';
import { uniqueId } from 'lodash';
import { importProjectMembers } from '~/api/projects_api';
import { s__, __, sprintf } from '~/locale';
import ProjectSelect from './project_select.vue';
export default {
components: {
GlButton,
GlFormGroup,
GlModal,
GlSprintf,
ProjectSelect,
},
directives: {
GlModal: GlModalDirective,
},
props: {
projectId: {
type: String,
required: true,
},
projectName: {
type: String,
required: true,
},
},
data() {
return {
projectToBeImported: {},
invalidFeedbackMessage: '',
isLoading: false,
};
},
computed: {
modalIntro() {
return sprintf(this.$options.i18n.modalIntro, {
name: this.projectName,
});
},
importDisabled() {
return Object.keys(this.projectToBeImported).length === 0;
},
validationState() {
return this.invalidFeedbackMessage === '' ? null : false;
},
},
methods: {
submitImport() {
this.isLoading = true;
return importProjectMembers(this.projectId, this.projectToBeImported.id)
.then(this.showToastMessage)
.catch(this.showErrorAlert)
.finally(() => {
this.isLoading = false;
this.projectToBeImported = {};
});
},
closeModal() {
this.invalidFeedbackMessage = '';
this.$refs.modal.hide();
},
showToastMessage() {
this.$toast.show(this.$options.i18n.successMessage, this.$options.toastOptions);
this.closeModal();
},
showErrorAlert() {
this.invalidFeedbackMessage = this.$options.i18n.defaultError;
},
},
toastOptions() {
return {
onComplete: () => {
this.projectToBeImported = {};
},
};
},
i18n: {
buttonText: s__('ImportAProjectModal|Import from a project'),
projectLabel: __('Project'),
modalTitle: s__('ImportAProjectModal|Import members from another project'),
modalIntro: s__(
"ImportAProjectModal|You're importing members to the %{strongStart}%{name}%{strongEnd} project.",
),
modalHelpText: s__(
'ImportAProjectModal|Only project members (not group members) are imported, and they get the same permissions as the project you import from.',
),
modalPrimaryButton: s__('ImportAProjectModal|Import project members'),
modalCancelButton: __('Cancel'),
defaultError: s__('ImportAProjectModal|Unable to import project members'),
successMessage: s__('ImportAProjectModal|Successfully imported'),
},
projectSelectLabelId: 'project-select',
modalId: uniqueId('import-a-project-modal-'),
formClasses: 'gl-mt-3 gl-sm-w-auto gl-w-full',
buttonClasses: 'gl-w-full',
};
</script>
<template>
<form :class="$options.formClasses">
<gl-button v-gl-modal="$options.modalId" :class="$options.buttonClasses" variant="default">{{
$options.i18n.buttonText
}}</gl-button>
<gl-modal
ref="modal"
:modal-id="$options.modalId"
size="sm"
:title="$options.i18n.modalTitle"
ok-variant="danger"
footer-class="gl-bg-gray-10 gl-p-5"
>
<div>
<p ref="modalIntro">
<gl-sprintf :message="modalIntro">
<template #strong="{ content }">
<strong>{{ content }}</strong>
</template>
</gl-sprintf>
</p>
<gl-form-group
:invalid-feedback="invalidFeedbackMessage"
:state="validationState"
data-testid="form-group"
>
<label :id="$options.projectSelectLabelId" class="col-form-label">{{
$options.i18n.projectLabel
}}</label>
<project-select v-model="projectToBeImported" />
</gl-form-group>
<p>{{ $options.i18n.modalHelpText }}</p>
</div>
<template #modal-footer>
<div
class="gl-display-flex gl-flex-direction-row gl-justify-content-end gl-flex-wrap gl-m-0"
>
<gl-button data-testid="cancel-button" @click="closeModal">
{{ $options.i18n.modalCancelButton }}
</gl-button>
<div class="gl-mr-3"></div>
<gl-button
:disabled="importDisabled"
:loading="isLoading"
variant="success"
data-testid="import-button"
@click="submitImport"
>{{ $options.i18n.modalPrimaryButton }}</gl-button
>
</div>
</template>
</gl-modal>
</form>
</template>
<script>
import {
GlAvatarLabeled,
GlDropdown,
GlDropdownItem,
GlDropdownText,
GlSearchBoxByType,
} from '@gitlab/ui';
import { debounce } from 'lodash';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
import { s__ } from '~/locale';
import { getProjects } from '~/rest_api';
import { SEARCH_DELAY, GROUP_FILTERS } from '../constants';
export default {
name: 'ProjectSelect',
components: {
GlAvatarLabeled,
GlDropdown,
GlDropdownItem,
GlDropdownText,
GlSearchBoxByType,
},
model: {
prop: 'selectedProject',
},
props: {
groupsFilter: {
type: String,
required: false,
default: GROUP_FILTERS.ALL,
validator: (value) => Object.values(GROUP_FILTERS).includes(value),
},
parentGroupId: {
type: Number,
required: false,
default: 0,
},
},
data() {
return {
isFetching: false,
projects: [],
selectedProject: {},
searchTerm: '',
errorMessage: '',
};
},
computed: {
selectedProjectName() {
return this.selectedProject.name || this.$options.i18n.dropdownText;
},
isFetchResultEmpty() {
return this.projects.length === 0 && !this.isFetching;
},
},
watch: {
searchTerm() {
this.retrieveProjects();
},
},
mounted() {
this.retrieveProjects();
},
methods: {
retrieveProjects: debounce(function debouncedRetrieveProjects() {
this.isFetching = true;
this.errorMessage = '';
return this.fetchProjects()
.then((response) => {
this.projects = response.data.map((project) => ({
...convertObjectPropsToCamelCase(project),
name: project.name_with_namespace,
}));
})
.catch(() => {
this.errorMessage = this.$options.i18n.errorFetchingProjects;
})
.finally(() => {
this.isFetching = false;
});
}, SEARCH_DELAY),
fetchProjects() {
return getProjects(this.searchTerm, this.$options.defaultFetchOptions);
},
selectProject(project) {
this.selectedProject = project;
this.$emit('input', this.selectedProject);
},
},
i18n: {
dropdownText: s__('ProjectSelect|Select a project'),
searchPlaceholder: s__('ProjectSelect|Search projects'),
emptySearchResult: s__('ProjectSelect|No matching results'),
errorFetchingProjects: s__(
'ProjectSelect|There was an error fetching the projects. Please try again.',
),
},
defaultFetchOptions: {
exclude_internal: true,
active: true,
},
};
</script>
<template>
<div>
<gl-dropdown
data-testid="project-select-dropdown"
:text="selectedProjectName"
toggle-class="gl-mb-2"
block
menu-class="gl-w-full!"
>
<gl-search-box-by-type
v-model="searchTerm"
:is-loading="isFetching"
:placeholder="$options.i18n.searchPlaceholder"
data-qa-selector="project_select_dropdown_search_field"
/>
<gl-dropdown-item
v-for="project in projects"
:key="project.id"
:name="project.name"
@click="selectProject(project)"
>
<gl-avatar-labeled
:label="project.name"
:src="project.avatarUrl"
:entity-id="project.id"
:entity-name="project.name"
:size="32"
/>
</gl-dropdown-item>
<gl-dropdown-text v-if="errorMessage" data-testid="error-message">
<span class="gl-text-gray-500">{{ errorMessage }}</span>
</gl-dropdown-text>
<gl-dropdown-text v-else-if="isFetchResultEmpty" data-testid="empty-result-message">
<span class="gl-text-gray-500">{{ $options.i18n.emptySearchResult }}</span>
</gl-dropdown-text>
</gl-dropdown>
</div>
</template>
import Vue from 'vue';
import ImportAProjectModal from '~/invite_members/components/import_a_project_modal.vue';
export default function initImportAProjectModal() {
const el = document.querySelector('.js-import-a-project-modal');
if (!el) {
return false;
}
const { projectId, projectName } = el.dataset;
return new Vue({
el,
render: (createElement) =>
createElement(ImportAProjectModal, {
props: {
projectId,
projectName,
},
}),
});
}
import groupsSelect from '~/groups_select';
import initImportAProjectModal from '~/invite_members/init_import_a_project_modal';
import initInviteGroupTrigger from '~/invite_members/init_invite_group_trigger';
import initInviteMembersForm from '~/invite_members/init_invite_members_form';
import initInviteMembersModal from '~/invite_members/init_invite_members_modal';
......@@ -14,6 +15,7 @@ import UsersSelect from '~/users_select';
groupsSelect();
memberExpirationDate();
memberExpirationDate('.js-access-expiration-date-groups');
initImportAProjectModal();
initInviteMembersModal();
initInviteMembersTrigger();
initInviteGroupTrigger();
......
......@@ -18,10 +18,7 @@
.col-md-12.col-lg-6
.gl-display-flex.gl-flex-wrap.gl-justify-content-end
- if can_admin_project_member?(@project)
= link_to _("Import a project"),
import_project_project_members_path(@project),
class: "btn btn-default btn-md gl-button gl-mt-3 gl-sm-w-auto gl-w-full",
title: _("Import members from another project")
.js-import-a-project-modal{ data: { project_id: @project.id, project_name: @project.name } }
- if @project.allowed_to_share_with_group?
.js-invite-group-trigger{ data: { classes: 'gl-mt-3 gl-sm-w-auto gl-w-full gl-sm-ml-3', display_text: _('Invite a group') } }
- if can_admin_project_member?(@project)
......
......@@ -11,9 +11,9 @@ RSpec.describe 'Project > Members > Invite group and members' do
using RSpec::Parameterized::TableSyntax
where(:invite_members_group_modal_enabled, :expected_invite_member_selector, :expected_invite_group_selector, :expected_import_button_text) do
true | '.js-invite-members-trigger' | '.js-invite-group-trigger' | 'Import a project'
false | '#invite-member-tab' | '#invite-group-tab' | 'Import'
where(:invite_members_group_modal_enabled, :expected_invite_member_selector, :expected_invite_group_selector, :expected_import_button_selector) do
true | '.js-invite-members-trigger' | '.js-invite-group-trigger' | '.js-import-a-project-modal'
false | '#invite-member-tab' | '#invite-group-tab' | '.invite-users-form .btn-default'
end
with_them do
......@@ -31,7 +31,7 @@ RSpec.describe 'Project > Members > Invite group and members' do
expect(page).to have_selector(expected_invite_member_selector)
expect(page).to have_selector(expected_invite_group_selector)
expect(page).to have_link(expected_import_button_text)
expect(page).to have_selector(expected_import_button_selector)
end
end
......
......@@ -20,7 +20,7 @@ RSpec.describe "User manages members" do
it { expect(page).to have_selector(".js-invite-members-trigger") }
it { expect(page).to have_selector(".js-invite-group-trigger") }
it { expect(page).to have_link("Import a project") }
it { expect(page).to have_selector(".js-import-a-project-modal") }
end
shared_examples "when group membership is locked" do
......
......@@ -27,7 +27,7 @@ RSpec.describe 'projects/project_members/index', :aggregate_failures do
expect(rendered).to have_content('Project members')
expect(rendered).to have_content('You can invite another group to')
expect(rendered).not_to have_link('Import a project')
expect(rendered).not_to have_link('Import from a project')
expect(rendered).to have_selector('.js-invite-group-trigger')
expect(rendered).not_to have_selector('.js-invite-members-trigger')
expect(rendered).not_to have_content('Members can be added by project')
......
......@@ -17007,9 +17007,6 @@ msgstr ""
msgid "Import Projects from Gitea"
msgstr ""
msgid "Import a project"
msgstr ""
msgid "Import an exported GitLab project"
msgstr ""
......@@ -17094,6 +17091,27 @@ msgstr ""
msgid "Import timed out. Import took longer than %{import_jobs_expiration} seconds"
msgstr ""
msgid "ImportAProjectModal|Import from a project"
msgstr ""
msgid "ImportAProjectModal|Import members from another project"
msgstr ""
msgid "ImportAProjectModal|Import project members"
msgstr ""
msgid "ImportAProjectModal|Only project members (not group members) are imported, and they get the same permissions as the project you import from."
msgstr ""
msgid "ImportAProjectModal|Successfully imported"
msgstr ""
msgid "ImportAProjectModal|Unable to import project members"
msgstr ""
msgid "ImportAProjectModal|You're importing members to the %{strongStart}%{name}%{strongEnd} project."
msgstr ""
msgid "ImportButtons|Connect repositories from"
msgstr ""
......@@ -26191,9 +26209,21 @@ msgstr ""
msgid "ProjectSelect| or group"
msgstr ""
msgid "ProjectSelect|No matching results"
msgstr ""
msgid "ProjectSelect|Search for project"
msgstr ""
msgid "ProjectSelect|Search projects"
msgstr ""
msgid "ProjectSelect|Select a project"
msgstr ""
msgid "ProjectSelect|There was an error fetching the projects. Please try again."
msgstr ""
msgid "ProjectService|Drone server URL"
msgstr ""
......
......@@ -43,10 +43,15 @@ RSpec.describe 'Projects > Settings > User manages project members' do
visit(project_project_members_path(project))
click_link('Import a project')
click_on 'Import from a project'
click_on 'Select a project'
wait_for_requests
select2(project2.id, from: '#source_project_id')
click_button('Import project members')
click_button project2.name
click_button 'Import project members'
wait_for_requests
page.refresh
expect(find_member_row(user_mike)).to have_content('Reporter')
end
......
import MockAdapter from 'axios-mock-adapter';
import * as projectsApi from '~/api/projects_api';
import axios from '~/lib/utils/axios_utils';
describe('~/api/projects_api.js', () => {
let mock;
let originalGon;
const projectId = 1;
beforeEach(() => {
mock = new MockAdapter(axios);
originalGon = window.gon;
window.gon = { api_version: 'v7' };
});
afterEach(() => {
mock.restore();
window.gon = originalGon;
});
describe('getProjects', () => {
beforeEach(() => {
jest.spyOn(axios, 'get');
});
it('retrieves projects from the correct URL and returns them in the response data', () => {
const expectedUrl = '/api/v7/projects.json';
const expectedParams = { params: { per_page: 20, search: '', simple: true } };
const expectedProjects = [{ name: 'project 1' }];
const query = '';
const options = {};
mock.onGet(expectedUrl).reply(200, { data: expectedProjects });
return projectsApi.getProjects(query, options).then(({ data }) => {
expect(axios.get).toHaveBeenCalledWith(expectedUrl, expectedParams);
expect(data.data).toEqual(expectedProjects);
});
});
});
describe('importProjectMembers', () => {
beforeEach(() => {
jest.spyOn(axios, 'post');
});
it('posts to the correct URL and returns the response message', () => {
const targetId = 2;
const expectedUrl = '/api/v7/projects/1/import_project_members/2';
const expectedMessage = 'Successfully imported';
mock.onPost(expectedUrl).replyOnce(200, expectedMessage);
return projectsApi.importProjectMembers(projectId, targetId).then(({ data }) => {
expect(axios.post).toHaveBeenCalledWith(expectedUrl);
expect(data).toEqual(expectedMessage);
});
});
});
});
import { GlFormGroup, GlSprintf, GlModal } from '@gitlab/ui';
import MockAdapter from 'axios-mock-adapter';
import { stubComponent } from 'helpers/stub_component';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import waitForPromises from 'helpers/wait_for_promises';
import * as ProjectsApi from '~/api/projects_api';
import ImportAProjectModal from '~/invite_members/components/import_a_project_modal.vue';
import ProjectSelect from '~/invite_members/components/project_select.vue';
import axios from '~/lib/utils/axios_utils';
let wrapper;
let mock;
const projectId = '1';
const projectName = 'test name';
const projectToBeImported = { id: '2' };
const $toast = {
show: jest.fn(),
};
const createComponent = () => {
wrapper = shallowMountExtended(ImportAProjectModal, {
propsData: {
projectId,
projectName,
},
stubs: {
GlModal: stubComponent(GlModal, {
template:
'<div><slot name="modal-title"></slot><slot></slot><slot name="modal-footer"></slot></div>',
}),
GlSprintf,
GlFormGroup: stubComponent(GlFormGroup, {
props: ['state', 'invalidFeedback'],
}),
},
mocks: {
$toast,
},
});
};
beforeEach(() => {
gon.api_version = 'v4';
mock = new MockAdapter(axios);
});
afterEach(() => {
wrapper.destroy();
mock.restore();
});
describe('ImportAProjectModal', () => {
const findIntroText = () => wrapper.find({ ref: 'modalIntro' }).text();
const findCancelButton = () => wrapper.findByTestId('cancel-button');
const findImportButton = () => wrapper.findByTestId('import-button');
const clickImportButton = () => findImportButton().vm.$emit('click');
const clickCancelButton = () => findCancelButton().vm.$emit('click');
const findFormGroup = () => wrapper.findByTestId('form-group');
const formGroupInvalidFeedback = () => findFormGroup().props('invalidFeedback');
const formGroupErrorState = () => findFormGroup().props('state');
const findProjectSelect = () => wrapper.findComponent(ProjectSelect);
describe('rendering the modal', () => {
beforeEach(() => {
createComponent();
});
it('renders the modal with the correct title', () => {
expect(wrapper.findComponent(GlModal).props('title')).toBe(
'Import members from another project',
);
});
it('renders the Cancel button text correctly', () => {
expect(findCancelButton().text()).toBe('Cancel');
});
it('renders the Import button text correctly', () => {
expect(findImportButton().text()).toBe('Import project members');
});
it('renders the modal intro text correctly', () => {
expect(findIntroText()).toBe("You're importing members to the test name project.");
});
it('renders the Import button modal without isLoading', () => {
expect(findImportButton().props('loading')).toBe(false);
});
it('sets isLoading to true when the Invite button is clicked', async () => {
clickImportButton();
await wrapper.vm.$nextTick();
expect(findImportButton().props('loading')).toBe(true);
});
});
describe('submitting the import form', () => {
describe('when the import is successful', () => {
beforeEach(() => {
createComponent();
findProjectSelect().vm.$emit('input', projectToBeImported);
jest.spyOn(ProjectsApi, 'importProjectMembers').mockResolvedValue();
clickImportButton();
});
it('calls Api importProjectMembers', () => {
expect(ProjectsApi.importProjectMembers).toHaveBeenCalledWith(
projectId,
projectToBeImported.id,
);
});
it('displays the successful toastMessage', () => {
expect($toast.show).toHaveBeenCalledWith(
'Successfully imported',
wrapper.vm.$options.toastOptions,
);
});
it('sets isLoading to false after success', () => {
expect(findImportButton().props('loading')).toBe(false);
});
});
describe('when the import fails', () => {
beforeEach(async () => {
createComponent();
findProjectSelect().vm.$emit('input', projectToBeImported);
jest
.spyOn(ProjectsApi, 'importProjectMembers')
.mockRejectedValue({ response: { data: { success: false } } });
clickImportButton();
await waitForPromises();
});
it('displays the generic error message', () => {
expect(formGroupInvalidFeedback()).toBe('Unable to import project members');
expect(formGroupErrorState()).toBe(false);
});
it('sets isLoading to false after error', () => {
expect(findImportButton().props('loading')).toBe(false);
});
it('clears the error when the modal is closed with an error', async () => {
expect(formGroupInvalidFeedback()).toBe('Unable to import project members');
expect(formGroupErrorState()).toBe(false);
clickCancelButton();
await wrapper.vm.$nextTick();
expect(formGroupInvalidFeedback()).toBe('');
expect(formGroupErrorState()).not.toBe(false);
});
});
});
});
import { GlSearchBoxByType, GlAvatarLabeled, GlDropdownItem } from '@gitlab/ui';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import waitForPromises from 'helpers/wait_for_promises';
import * as projectsApi from '~/api/projects_api';
import ProjectSelect from '~/invite_members/components/project_select.vue';
import { allProjects, project1 } from '../mock_data/api_response_data';
describe('ProjectSelect', () => {
let wrapper;
const createComponent = () => {
wrapper = shallowMountExtended(ProjectSelect, {});
};
beforeEach(() => {
jest.spyOn(projectsApi, 'getProjects').mockResolvedValue(allProjects);
createComponent();
});
afterEach(() => {
wrapper.destroy();
});
const findSearchBoxByType = () => wrapper.findComponent(GlSearchBoxByType);
const findDropdownItem = (index) => wrapper.findAllComponents(GlDropdownItem).at(index);
const findAvatarLabeled = (index) => findDropdownItem(index).findComponent(GlAvatarLabeled);
const findEmptyResultMessage = () => wrapper.findByTestId('empty-result-message');
const findErrorMessage = () => wrapper.findByTestId('error-message');
it('renders GlSearchBoxByType with default attributes', () => {
expect(findSearchBoxByType().exists()).toBe(true);
expect(findSearchBoxByType().vm.$attrs).toMatchObject({
placeholder: 'Search projects',
});
});
describe('when user types in the search input', () => {
let resolveApiRequest;
let rejectApiRequest;
beforeEach(() => {
jest.spyOn(projectsApi, 'getProjects').mockImplementation(
() =>
new Promise((resolve, reject) => {
resolveApiRequest = resolve;
rejectApiRequest = reject;
}),
);
findSearchBoxByType().vm.$emit('input', project1.name);
});
it('calls the API', () => {
resolveApiRequest({ data: allProjects });
expect(projectsApi.getProjects).toHaveBeenCalledWith(project1.name, {
active: true,
exclude_internal: true,
});
});
it('displays loading icon while waiting for API call to resolve and then sets loading false', async () => {
expect(findSearchBoxByType().props('isLoading')).toBe(true);
resolveApiRequest({ data: allProjects });
await waitForPromises();
expect(findSearchBoxByType().props('isLoading')).toBe(false);
expect(findEmptyResultMessage().exists()).toBe(false);
expect(findErrorMessage().exists()).toBe(false);
});
it('displays a dropdown item and avatar for each project fetched', async () => {
resolveApiRequest({ data: allProjects });
await waitForPromises();
allProjects.forEach((project, index) => {
expect(findDropdownItem(index).attributes('name')).toBe(project.name_with_namespace);
expect(findAvatarLabeled(index).attributes()).toMatchObject({
src: project.avatar_url,
'entity-id': String(project.id),
'entity-name': project.name_with_namespace,
});
expect(findAvatarLabeled(index).props('label')).toBe(project.name_with_namespace);
});
});
it('displays the empty message when the API results are empty', async () => {
resolveApiRequest({ data: [] });
await waitForPromises();
expect(findEmptyResultMessage().text()).toBe('No matching results');
});
it('displays the error message when the fetch fails', async () => {
rejectApiRequest();
await waitForPromises();
expect(findErrorMessage().text()).toBe(
'There was an error fetching the projects. Please try again.',
);
});
});
});
export const project1 = {
id: 1,
name: 'Project One',
name_with_namespace: 'Project One',
avatar_url: 'test1',
};
export const project2 = {
id: 2,
name: 'Project One',
name_with_namespace: 'Project Two',
avatar_url: 'test2',
};
export const allProjects = [project1, project2];
......@@ -25,7 +25,7 @@ RSpec.describe 'projects/project_members/index', :aggregate_failures do
expect(rendered).to have_content('Project members')
expect(rendered).to have_content('You can invite a new member')
expect(rendered).to have_link('Import a project')
expect(rendered).to have_selector('.js-import-a-project-modal')
expect(rendered).to have_selector('.js-invite-group-trigger')
expect(rendered).to have_selector('.js-invite-members-trigger')
expect(rendered).not_to have_content('Members can be added by project')
......@@ -85,7 +85,7 @@ RSpec.describe 'projects/project_members/index', :aggregate_failures do
expect(rendered).to have_content('Project members')
expect(rendered).not_to have_content('You can invite a new member')
expect(rendered).not_to have_link('Import a project')
expect(rendered).not_to have_selector('.js-import-a-project-modal')
expect(rendered).not_to have_selector('.js-invite-group-trigger')
expect(rendered).not_to have_selector('.js-invite-members-trigger')
expect(rendered).to have_content('Members can be added by project')
......
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