Просмотр исходного кода

Merge pull request #256 from smiclea/cypress

Create e2e tests for users and projects management
Dorin Paslaru 8 лет назад
Родитель
Сommit
4419e0e7be

+ 1 - 1
private/cypress/integration/4 - migrations and replicas/VmWare -> Azure Replica/3 - Delete replica.js

@@ -14,7 +14,7 @@ along with this program.  If not, see <http://www.gnu.org/licenses/>.
 
 // @flow
 
-describe('Scheduler Operations', () => {
+describe('Delete replica', () => {
   before(() => {
     cy.login()
   })

+ 64 - 0
private/cypress/integration/6 - users and projects/1 - Create a project.js

@@ -0,0 +1,64 @@
+/*
+Copyright (C) 2018  Cloudbase Solutions SRL
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+GNU Affero General Public License for more details.
+You should have received a copy of the GNU Affero General Public License
+along with this program.  If not, see <http://www.gnu.org/licenses/>.
+*/
+
+// @flow
+
+import { navigationMenu } from '../../../../src/config'
+
+const isEnabled: () => boolean = () => {
+  let usersEnabled = navigationMenu.find(i => i.value === 'users' && i.disabled === false)
+  let projectsEnabled = navigationMenu.find(i => i.value === 'projects' && i.disabled === false)
+  return Boolean(usersEnabled && projectsEnabled)
+}
+
+describe('Create a project', () => {
+  before(() => {
+    if (isEnabled()) {
+      cy.login()
+    }
+  })
+
+  beforeEach(() => {
+    Cypress.Cookies.preserveOnce('token', 'projectId')
+  })
+
+  if (!isEnabled()) {
+    it('Users and projects management is disabled!', () => { })
+    return
+  }
+
+  it('Shows projects page', () => {
+    cy.get('a[data-test-id="navigation-item-projects"]').click()
+    cy.title().should('eq', 'Projects')
+  })
+
+  it('Shows new project modal', () => {
+    cy.get('div[data-test-id="newItemDropdown-button"]').click()
+    cy.get('a[data-test-id="newItemDropdown-listItem-Project"]').click()
+    cy.get('div[data-test-id="modal-title"]').should('contain', 'New Project')
+  })
+
+  it('Creates project', () => {
+    cy.get('input[data-test-id="endpointField-textInput-project_name"]').type('cypress-project')
+    cy.get('input[data-test-id="endpointField-textInput-description"]').type('Project created by Cypress')
+    cy.server()
+    cy.route({ url: '**/projects/', method: 'POST' }).as('addProject')
+    cy.route({ url: '**/roles/**', method: 'PUT' }).as('addRole')
+    cy.get('button').contains('New Project').click()
+    cy.wait('@addProject')
+    cy.wait('@addRole')
+    cy.get('div[data-test-id="plItem-content"]').should('contain', 'cypress-project')
+    cy.get('div[data-test-id="plItem-content"]').should('contain', 'Project created by Cypress')
+  })
+})

+ 89 - 0
private/cypress/integration/6 - users and projects/2 - Add a new user as a member.js

@@ -0,0 +1,89 @@
+/*
+Copyright (C) 2018  Cloudbase Solutions SRL
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+GNU Affero General Public License for more details.
+You should have received a copy of the GNU Affero General Public License
+along with this program.  If not, see <http://www.gnu.org/licenses/>.
+*/
+
+// @flow
+
+import { navigationMenu } from '../../../../src/config'
+
+const isEnabled: () => boolean = () => {
+  let usersEnabled = navigationMenu.find(i => i.value === 'users' && i.disabled === false)
+  let projectsEnabled = navigationMenu.find(i => i.value === 'projects' && i.disabled === false)
+  return Boolean(usersEnabled && projectsEnabled)
+}
+
+describe('Adds a new user as a member to the project', () => {
+  before(() => {
+    if (isEnabled()) {
+      cy.login()
+    }
+  })
+
+  beforeEach(() => {
+    Cypress.Cookies.preserveOnce('token', 'projectId')
+  })
+
+  if (!isEnabled()) {
+    it('Users and projects management is disabled!', () => { })
+    return
+  }
+
+  it('Shows projects details page', () => {
+    cy.get('a[data-test-id="navigation-item-projects"]').click()
+    cy.get('div[data-test-id="plItem-content"]').contains('cypress-project').click()
+    cy.title().should('eq', 'Project Details')
+  })
+
+  it('Opens add member modal', () => {
+    cy.get('button').contains('Add Member').click()
+    cy.get('div[data-test-id="modal-title"]').should('contain', 'Add Project Member')
+  })
+
+  it('Creates new user', () => {
+    cy.get('div[data-test-id="toggleButtonBar-new"]').click()
+    cy.get('input[data-test-id="endpointField-textInput-username"]').type('cypress-member-user')
+    cy.get('input[data-test-id="endpointField-textInput-description"]').type('User created by Cypress in Add Project Member modal')
+    cy.get('div[data-test-id="endpointField-dropdown-Primary Project"]').click()
+    cy.get('div[data-test-id="dropdownListItem"]').contains('cypress-project').click()
+    cy.get('div[data-test-id="endpointField-multidropdown-role(s)"]').click()
+    cy.get('div[data-test-id="dropdownListItem"]').contains('_member_').click()
+    cy.get('input[data-test-id="endpointField-textInput-password"]').type('cypress-member-user')
+    cy.get('input[data-test-id="endpointField-textInput-confirm_password"]').type('cypress-member-user')
+    cy.server()
+    cy.route({ url: '**/users', method: 'POST' }).as('addUser')
+    cy.route({ url: '**/roles/**', method: 'PUT' }).as('addRole')
+    cy.route({ url: '**/role_assignments**', method: 'GET' }).as('getRoles')
+    cy.get('button[data-test-id="projectModal-addButton"]').contains('Add Member').click()
+    cy.wait('@addUser')
+    cy.wait('@addRole')
+    cy.wait('@getRoles')
+    cy.get('a[data-test-id="pdContent-users-cypress-member-user"]').its('length').should('eq', 1)
+    cy.get('div[data-test-id="pdContent-roles-cypress-member-user"]').should('contain', '_member_')
+  })
+
+  it('Adds admin as its role', () => {
+    cy.get('div[data-test-id="pdContent-roles-cypress-member-user"]').click()
+    cy.get('div[data-test-id="dropdownLink-listItem"]').contains('admin').click()
+    cy.get('div[data-test-id="pdContent-roles-cypress-member-user"]').should('contain', '_member_, admin')
+  })
+
+  it('Removes user from project', () => {
+    cy.get('div[data-test-id="pdContent-actions-cypress-member-user"]').click()
+    cy.get('div[data-test-id="dropdownLink-listItem"]').contains('Remove').click()
+    cy.server()
+    cy.route({ url: '**/roles/**', method: 'DELETE' }).as('deleteRole')
+    cy.get('button[data-test-id="aModal-yesButton"]').click()
+    cy.wait('@deleteRole')
+    cy.get('a[data-test-id="pdContent-users-cypress-member-user"]').should('not.exist')
+  })
+})

+ 77 - 0
private/cypress/integration/6 - users and projects/3 - Add existing user as a member.js

@@ -0,0 +1,77 @@
+/*
+Copyright (C) 2018  Cloudbase Solutions SRL
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+GNU Affero General Public License for more details.
+You should have received a copy of the GNU Affero General Public License
+along with this program.  If not, see <http://www.gnu.org/licenses/>.
+*/
+
+// @flow
+
+import { navigationMenu } from '../../../../src/config'
+
+const isEnabled: () => boolean = () => {
+  let usersEnabled = navigationMenu.find(i => i.value === 'users' && i.disabled === false)
+  let projectsEnabled = navigationMenu.find(i => i.value === 'projects' && i.disabled === false)
+  return Boolean(usersEnabled && projectsEnabled)
+}
+
+describe('Adds existing user as a member to the project', () => {
+  before(() => {
+    if (isEnabled()) {
+      cy.login()
+    }
+  })
+
+  beforeEach(() => {
+    Cypress.Cookies.preserveOnce('token', 'projectId')
+  })
+
+  if (!isEnabled()) {
+    it('Users and projects management is disabled!', () => { })
+    return
+  }
+
+  it('Shows projects details page', () => {
+    cy.get('a[data-test-id="navigation-item-projects"]').click()
+    cy.get('div[data-test-id="plItem-content"]').contains('cypress-project').click()
+  })
+
+  it('Opens add member modal', () => {
+    cy.get('button').contains('Add Member').click()
+    cy.get('div[data-test-id="modal-title"]').should('contain', 'Add Project Member')
+  })
+
+  it('Adds existing user', () => {
+    cy.get('input[data-test-id="textInput-input"]').type('cy')
+    cy.get('div[data-test-id="ad-listItem"]').contains('cypress-member-user').click()
+    cy.get('div[data-test-id="endpointField-multidropdown-role(s)"]').click()
+    cy.get('div[data-test-id="dropdownListItem"]').contains('_member_').click()
+    cy.get('div[data-test-id="dropdownListItem"]').contains('admin').click()
+    cy.get('div[data-test-id="modal-title"]').click()
+    cy.server()
+    cy.route({ url: '**/roles/**', method: 'PUT' }).as('addRoles')
+    cy.route({ url: '**/role_assignments**', method: 'GET' }).as('getRoles')
+    cy.get('button[data-test-id="projectModal-addButton"]').click()
+    cy.wait('@addRoles')
+    cy.wait('@getRoles')
+    cy.get('div[data-test-id="pdContent-roles-cypress-member-user"]').should('contain', '_member_, admin')
+  })
+
+  it('Deletes the user', () => {
+    cy.server()
+    cy.route({ url: '**/role_assignments**', method: 'GET' }).as('getRoles')
+    cy.get('a[data-test-id="pdContent-users-cypress-member-user"]').click()
+    cy.wait('@getRoles')
+    cy.get('button').contains('Delete user').click()
+    cy.route({ url: '**/users/**', method: 'DELETE' }).as('deleteUser')
+    cy.get('button[data-test-id="aModal-yesButton"]').click()
+    cy.wait('@deleteUser')
+  })
+})

+ 67 - 0
private/cypress/integration/6 - users and projects/4 - Create a user.js

@@ -0,0 +1,67 @@
+/*
+Copyright (C) 2018  Cloudbase Solutions SRL
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+GNU Affero General Public License for more details.
+You should have received a copy of the GNU Affero General Public License
+along with this program.  If not, see <http://www.gnu.org/licenses/>.
+*/
+
+// @flow
+
+import { navigationMenu } from '../../../../src/config'
+
+const isEnabled: () => boolean = () => {
+  let usersEnabled = navigationMenu.find(i => i.value === 'users' && i.disabled === false)
+  let projectsEnabled = navigationMenu.find(i => i.value === 'projects' && i.disabled === false)
+  return Boolean(usersEnabled && projectsEnabled)
+}
+
+describe('Create a user', () => {
+  before(() => {
+    if (isEnabled()) {
+      cy.login()
+    }
+  })
+
+  beforeEach(() => {
+    Cypress.Cookies.preserveOnce('token', 'projectId')
+  })
+
+  if (!isEnabled()) {
+    it('Users and projects management is disabled!', () => { })
+    return
+  }
+
+  it('Shows users page', () => {
+    cy.get('a[data-test-id="navigation-item-users"]').click()
+    cy.title().should('eq', 'Users')
+  })
+
+  it('Shows new user modal', () => {
+    cy.get('div[data-test-id="newItemDropdown-button"]').click()
+    cy.get('a[data-test-id="newItemDropdown-listItem-User"]').click()
+    cy.get('div[data-test-id="modal-title"]').should('contain', 'New User')
+  })
+
+  it('Creates user', () => {
+    cy.get('input[data-test-id="endpointField-textInput-username"]').type('cypress-user')
+    cy.get('input[data-test-id="endpointField-textInput-description"]').type('User created by Cypress')
+    cy.get('div[data-test-id="endpointField-dropdown-Primary Project"]').click()
+    cy.get('div[data-test-id="dropdownListItem"]').contains('cypress-project').click()
+    cy.get('input[data-test-id="endpointField-textInput-new_password"]').type('cypress-user')
+    cy.get('input[data-test-id="endpointField-textInput-confirm_password"]').type('cypress-user')
+    cy.server()
+    cy.route({ url: '**/users', method: 'POST' }).as('addUser')
+    cy.route({ url: '**/roles/**', method: 'PUT' }).as('addRole')
+    cy.get('button').contains('New User').click()
+    cy.wait('@addUser')
+    cy.wait('@addRole')
+    cy.get('div[data-test-id="ulItem-name"]').contains('cypress-user').should('exist')
+  })
+})

+ 78 - 0
private/cypress/integration/6 - users and projects/5 - Edit and delete user.js

@@ -0,0 +1,78 @@
+/*
+Copyright (C) 2018  Cloudbase Solutions SRL
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+GNU Affero General Public License for more details.
+You should have received a copy of the GNU Affero General Public License
+along with this program.  If not, see <http://www.gnu.org/licenses/>.
+*/
+
+// @flow
+
+import { navigationMenu } from '../../../../src/config'
+
+const isEnabled: () => boolean = () => {
+  let usersEnabled = navigationMenu.find(i => i.value === 'users' && i.disabled === false)
+  let projectsEnabled = navigationMenu.find(i => i.value === 'projects' && i.disabled === false)
+  return Boolean(usersEnabled && projectsEnabled)
+}
+
+describe('Edit created user', () => {
+  before(() => {
+    if (isEnabled()) {
+      cy.login()
+    }
+  })
+
+  beforeEach(() => {
+    Cypress.Cookies.preserveOnce('token', 'projectId')
+  })
+
+  if (!isEnabled()) {
+    it('Users and projects management is disabled!', () => { })
+    return
+  }
+
+  it('Shows user details page', () => {
+    cy.get('a[data-test-id="navigation-item-users"]').click()
+    cy.server()
+    cy.route({ url: '**/role_assignments**', method: 'GET' }).as('getRoles')
+    cy.get('div[data-test-id="ulItem-name"]').contains('cypress-user').click()
+    cy.wait('@getRoles')
+    cy.title().should('eq', 'User Details')
+    cy.get('div[data-test-id="dcHeader-title"]').should('contain', 'cypress-user')
+  })
+
+  it('Opens user edit modal', () => {
+    cy.get('button').contains('Edit user').click()
+    cy.get('div[data-test-id="modal-title"]').should('contain', 'Update User')
+  })
+
+  it('Edits user', () => {
+    cy.get('input[data-test-id="endpointField-textInput-username"]').clear()
+    cy.get('input[data-test-id="endpointField-textInput-username"]').type('user-cypress')
+    cy.server()
+    cy.route({ url: '**/users/**', method: 'PATCH' }).as('updateUser')
+    cy.route({ url: '**/role_assignments**', method: 'GET' }).as('getRoles')
+    cy.get('button').contains('Update User').click()
+    cy.wait('@updateUser')
+    cy.wait('@getRoles')
+    cy.get('div[data-test-id="dcHeader-title"]').should('contain', 'user-cypress')
+  })
+
+  it('Deletes the user', () => {
+    cy.server()
+    cy.get('button').contains('Delete user').click()
+    cy.route({ url: '**/users/**', method: 'DELETE' }).as('deleteUser')
+    cy.route({ url: '**/users', method: 'GET' }).as('getUsers')
+    cy.get('button[data-test-id="aModal-yesButton"]').click()
+    cy.wait('@deleteUser')
+    cy.wait('@getUsers')
+    cy.get('div[data-test-id="ulItem-name"]').contains('user-cypress').should('not.exist')
+  })
+})

+ 76 - 0
private/cypress/integration/6 - users and projects/6 - Edit and delete project.js

@@ -0,0 +1,76 @@
+/*
+Copyright (C) 2018  Cloudbase Solutions SRL
+This program is free software: you can redistribute it and/or modify
+it under the terms of the GNU Affero General Public License as
+published by the Free Software Foundation, either version 3 of the
+License, or (at your option) any later version.
+This program is distributed in the hope that it will be useful,
+but WITHOUT ANY WARRANTY; without even the implied warranty of
+MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+GNU Affero General Public License for more details.
+You should have received a copy of the GNU Affero General Public License
+along with this program.  If not, see <http://www.gnu.org/licenses/>.
+*/
+
+// @flow
+
+import { navigationMenu } from '../../../../src/config'
+
+const isEnabled: () => boolean = () => {
+  let usersEnabled = navigationMenu.find(i => i.value === 'users' && i.disabled === false)
+  let projectsEnabled = navigationMenu.find(i => i.value === 'projects' && i.disabled === false)
+  return Boolean(usersEnabled && projectsEnabled)
+}
+
+describe('Edit created project', () => {
+  before(() => {
+    if (isEnabled()) {
+      cy.login()
+    }
+  })
+
+  beforeEach(() => {
+    Cypress.Cookies.preserveOnce('token', 'projectId')
+  })
+
+  if (!isEnabled()) {
+    it('Users and projects management is disabled!', () => { })
+    return
+  }
+
+  it('Shows project details page', () => {
+    cy.get('a[data-test-id="navigation-item-projects"]').click()
+    cy.server()
+    cy.route({ url: '**/role_assignments**', method: 'GET' }).as('getRoles')
+    cy.get('div[data-test-id="plItem-content"]').contains('cypress-project').click()
+    cy.wait('@getRoles')
+    cy.title().should('eq', 'Project Details')
+    cy.get('div[data-test-id="dcHeader-title"]').should('contain', 'cypress-project')
+  })
+
+  it('Opens project edit modal', () => {
+    cy.get('button').contains('Edit Project').click()
+    cy.get('div[data-test-id="modal-title"]').should('contain', 'Update Project')
+  })
+
+  it('Edits project', () => {
+    cy.get('input[data-test-id="endpointField-textInput-project_name"]').clear()
+    cy.get('input[data-test-id="endpointField-textInput-project_name').type('project-cypress')
+    cy.server()
+    cy.route({ url: '**/projects/**', method: 'PATCH' }).as('updateProject')
+    cy.get('button').contains('Update Project').click()
+    cy.wait('@updateProject')
+    cy.get('div[data-test-id="dcHeader-title"]').should('contain', 'project-cypress')
+  })
+
+  it('Deletes the project', () => {
+    cy.server()
+    cy.get('button').contains('Delete Project').click()
+    cy.route({ url: '**/projects/**', method: 'DELETE' }).as('deleteProject')
+    cy.route({ url: '**/role_assignments**', method: 'GET' }).as('getRoles')
+    cy.get('button[data-test-id="aModal-yesButton"]').click()
+    cy.wait('@deleteProject')
+    cy.wait('@getRoles')
+    cy.get('div[data-test-id="plItem-content"]').contains('project-cypress').should('not.exist')
+  })
+})

+ 4 - 1
private/cypress/support/commands.js

@@ -51,7 +51,10 @@ Cypress.Commands.add('login', () => {
       url: projectsUrl,
       headers: { 'X-Auth-Token': unscopedToken },
     }).then(projectsReponse => {
-      let projectId = projectsReponse.body.projects[0].id
+      let projects = projectsReponse.body.projects
+      let cypressProject = projects.find(p => p.name === 'cypress')
+      let projectId = cypressProject ? cypressProject.id : projects[0].id
+
       expect(projectId).to.exist
 
       let scopedBody = {

+ 1 - 0
src/components/molecules/AutocompleteDropdown/AutocompleteDropdown.jsx

@@ -350,6 +350,7 @@ class AutocompleteDropdown extends React.Component<Props, State> {
           let duplicatedLabel = duplicatedLabels.find(l => l === label)
           let listItem = (
             <ListItem
+              data-test-id="ad-listItem"
               key={value}
               innerRef={ref => { if (i === 0) { this.firstItemRef = ref } }}
               onMouseDown={() => { this.itemMouseDown = true }}

+ 5 - 1
src/components/molecules/DropdownLink/DropdownLink.jsx

@@ -344,7 +344,11 @@ class DropdownLink extends React.Component<Props, State> {
     }
 
     return (
-      <ListItems innerRef={ref => { this.listItemsRef = ref }} searchable={this.props.searchable}>
+      <ListItems
+        data-test-id="dropdownLink-listItem"
+        innerRef={ref => { this.listItemsRef = ref }}
+        searchable={this.props.searchable}
+      >
         {this.getFilteredItems().map((item) => {
           return this.renderItem(item)
         })}

+ 1 - 0
src/components/molecules/EndpointField/EndpointField.jsx

@@ -153,6 +153,7 @@ class Field extends React.Component<Props> {
   renderArrayDropdown() {
     return (
       <Dropdown
+        data-test-id={`endpointField-multidropdown-${this.props.name}`}
         multipleSelection
         large={this.props.large}
         disabled={this.props.disabled}

+ 2 - 1
src/components/molecules/ProjectListItem/ProjectListItem.jsx

@@ -99,6 +99,7 @@ type Props = {
   isCurrentProject: (projectId: string) => boolean,
   onSwitchProjectClick: (projectId: string) => void,
 }
+const testName = 'plItem'
 @observer
 class ProjectListItem extends React.Component<Props> {
   render() {
@@ -106,7 +107,7 @@ class ProjectListItem extends React.Component<Props> {
 
     return (
       <Wrapper>
-        <Content onClick={this.props.onClick}>
+        <Content onClick={this.props.onClick} data-test-id={`${testName}-content`}>
           <Image />
           <Title>
             <TitleLabel>{this.props.item.name}</TitleLabel>

+ 2 - 1
src/components/molecules/UserListItem/UserListItem.jsx

@@ -96,6 +96,7 @@ type Props = {
   onClick: () => void,
   getProjectName: (projectId: ?string) => string,
 }
+const testName = 'ulItem'
 @observer
 class EndpointListItem extends React.Component<Props> {
   render() {
@@ -104,7 +105,7 @@ class EndpointListItem extends React.Component<Props> {
         <Content onClick={this.props.onClick}>
           <Image />
           <Title>
-            <TitleLabel>{this.props.item.name}</TitleLabel>
+            <TitleLabel data-test-id={`${testName}-name`}>{this.props.item.name}</TitleLabel>
             <Subtitle>{this.props.item.description}</Subtitle>
           </Title>
           <Body>

+ 8 - 2
src/components/organisms/ProjectDetailsContent/ProjectDetailsContent.jsx

@@ -61,7 +61,7 @@ const LoadingWrapper = styled.div`
   width: 100%;
   margin: 32px 0 64px 0;
 `
-const TableStyled = styled(Table) `
+const TableStyled = styled(Table)`
   margin-top: 42px;
   margin-bottom: 32px;
 `
@@ -250,8 +250,13 @@ class ProjectDetailsContent extends React.Component<Props, State> {
       let userActions = actions(user)
       let userRoles = getUserRoles(user)
       const columns = [
-        <UserName disabled={!user.enabled} href={`#/user/${user.id}`}>{user.name}</UserName>,
+        <UserName
+          data-test-id={`pdContent-users-${user.name}`}
+          disabled={!user.enabled}
+          href={`#/user/${user.id}`}
+        >{user.name}</UserName>,
         <DropdownLink
+          data-test-id={`pdContent-roles-${user.name}`}
           width="214px"
           getLabel={() => userRoles.length > 0 ? userRoles.map(r => r.label).join(', ') : 'No roles'}
           selectedItems={userRoles.map(r => r.value)}
@@ -267,6 +272,7 @@ class ProjectDetailsContent extends React.Component<Props, State> {
         />,
         <UserColumn disabled={!user.enabled}>{user.enabled ? 'Enabled' : 'Disabled'}</UserColumn>,
         <DropdownLink
+          data-test-id={`pdContent-actions-${user.name}`}
           noCheckmark
           width="82px"
           items={userActions}

+ 1 - 0
src/components/organisms/ProjectMemberModal/ProjectMemberModal.jsx

@@ -372,6 +372,7 @@ class ProjectMemberModal extends React.Component<Props, State> {
               large
               disabled={this.props.loading}
               onClick={() => { this.handleAddClick() }}
+              data-test-id="projectModal-addButton"
             >Add Member</Button>
           </Buttons>
         </Wrapper>

+ 2 - 2
src/components/pages/ProjectDetailsPage/ProjectDetailsPage.jsx

@@ -99,8 +99,8 @@ class ProjectDetailsPage extends React.Component<Props, State> {
     let roles = projectStore.roleAssignments
       .filter(a => a.scope.project.id === this.props.match.params.id)
       .filter(a => a.user.id === user.id)
-    let oldRoleId = roles.length > 0 ? roles[0].role.id : ''
-    projectStore.removeUser(this.props.match.params.id, user.id, oldRoleId)
+      .map(ra => ra.role.id)
+    projectStore.removeUser(this.props.match.params.id, user.id, roles)
   }
 
   handleEditProjectClick() {

+ 7 - 5
src/sources/ProjectSource.js

@@ -65,11 +65,13 @@ class ProjectsSource {
     })
   }
 
-  static removeUser(projectId: string, userId: string, roleId: string): Promise<void> {
-    return Api.send({
-      url: `${coriolisUrl}identity/projects/${projectId}/users/${userId}/roles/${roleId}`,
-      method: 'DELETE',
-    }).then(() => { })
+  static removeUser(projectId: string, userId: string, roleIds: string[]): Promise<void> {
+    return Promise.all(roleIds.map(id => {
+      return Api.send({
+        url: `${coriolisUrl}identity/projects/${projectId}/users/${userId}/roles/${id}`,
+        method: 'DELETE',
+      })
+    })).then(() => { })
   }
 
   static assignUser(projectId: string, userId: string, roleId: string): Promise<void> {

+ 3 - 3
src/stores/ProjectStore.js

@@ -78,8 +78,8 @@ class ProjectStore {
     this.users = []
   }
 
-  @action removeUser(projectId: string, userId: string, roleId: string): Promise<void> {
-    return ProjectSource.removeUser(projectId, userId, roleId).then(() => {
+  @action removeUser(projectId: string, userId: string, roleIds: string[]): Promise<void> {
+    return ProjectSource.removeUser(projectId, userId, roleIds).then(() => {
       this.users = this.users.filter(u => u.id !== userId)
     })
   }
@@ -89,7 +89,7 @@ class ProjectStore {
   }
 
   @action removeUserRole(projectId: string, userId: string, roleId: string): Promise<void> {
-    return ProjectSource.removeUser(projectId, userId, roleId)
+    return ProjectSource.removeUser(projectId, userId, [roleId])
   }
 
   @action update(projectId: string, project: Project): Promise<void> {