Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Manage members in app #804

Merged
merged 18 commits into from
Jul 27, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
1338d17
fix(frontend): Minor improvements to wording and design of member picker
mejo- Jul 12, 2023
a576076
fix(memberPicker): Improve user experience with debounced searching
mejo- Jul 12, 2023
fc7c0be
feat(memberPicker): Refactor and return results with empty search
mejo- Jul 12, 2023
2b902e6
refactor(memberPicker): Make displaying selected members optional
mejo- Jul 19, 2023
332fb50
feat(memberModal): member management modal
mejo- Jul 21, 2023
6417f9d
feat(memberPicker): circle API calls to add, remove or change members
mejo- Jul 21, 2023
a667f31
feat(memberPicker): Use Member component in MemberSearchResults
mejo- Jul 24, 2023
c7eb69e
feat(memberPicker): Add loading indicator to Member component
mejo- Jul 24, 2023
8b8b610
test(cypress): Add Cypress tests for CollectiveMemberModal
mejo- Jul 24, 2023
8ac98a5
feat(MemberPicker): Swap icons for admin and moderator
mejo- Jul 24, 2023
28483c9
feat(MembersModal): Remove ties to Contacts app
mejo- Jul 25, 2023
da47ba2
fix(MemberPicker): Make search results accessible via keyboard
mejo- Jul 25, 2023
578c693
fix(css): Fix double scroll container on narrow windows
mejo- Jul 25, 2023
d0f95a0
fix(circles): use source of `basedOn` for members with circle as user…
mejo- Jul 26, 2023
3ea2981
fix(MemberPicker): Filter out current circle from search results
mejo- Jul 26, 2023
33ce1ce
fix(MemberPicker): Show success/error notifications on member actions
mejo- Jul 26, 2023
e1dda60
fix(MemberPicker): respect inherrited current user via groups/circles
mejo- Jul 26, 2023
0d4ec55
test(cypress): Workaround broken remove member test in CI
mejo- Jul 27, 2023
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .github/workflows/cypress.yml
Original file line number Diff line number Diff line change
Expand Up @@ -172,6 +172,7 @@ jobs:
run: |
mkdir data
./occ maintenance:install --verbose --database=sqlite --database-name=nextcloud --database-host=127.0.0.1 --database-port=$DB_PORT --database-user=root --database-pass=rootpassword --admin-user admin --admin-pass admin
./occ config:system:set --value="http://localhost:8080" -- overwrite.cli.url
./occ app:enable --force contacts
./occ app:enable --force files_pdfviewer
./occ app:enable --force ${{ env.APP_NAME }}
Expand Down
11 changes: 0 additions & 11 deletions cypress/e2e/apps.spec.js
Original file line number Diff line number Diff line change
Expand Up @@ -21,16 +21,6 @@
*/

describe('The apps', function() {
describe('Circles', function() {

it('shows circles in the contacts app', function() {
cy.login('jane', { route: '/apps/contacts' })
cy.get('.app-navigation')
.should('contain', 'Circles')
})

})

describe('Collectives', function() {

it('allows creating a new collective', function() {
Expand Down Expand Up @@ -65,5 +55,4 @@ describe('The apps', function() {
})

})

})
109 changes: 109 additions & 0 deletions cypress/e2e/collective-members.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
/**
* @copyright Copyright (c) 2022 Jonas <jonas@freesources.org>
*
* @author Jonas <jonas@freesources.org>
*
* @license AGPL-3.0-or-later
*
* 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/>.
*
*/

/**
* Tests for Collectives settings.
*/

describe('Collective members', function() {
before(function() {
cy.login('bob')
cy.deleteCollective('Members Collective')
cy.deleteAndSeedCollective('Members Collective')
})

beforeEach(function() {
cy.login('bob')

cy.get('.collectives_list_item')
.contains('li', 'Members Collective')
.find('.action-item__menutoggle')
.click({ force: true })
cy.get('button.action-button')
.contains('Manage members')
.click()
})

describe('Manage members', function() {
it('Allows to add members', function() {
const addMembers = ['alice', 'Bobs Group']
for (const member of addMembers) {
cy.get('.member-picker input[type="text"]').clear()
cy.get('.member-picker input[type="text"]').type(member)
cy.get('.member-search-results .member-row').contains(member).click()
cy.get('.current-members .member-row').should('contain', member)
cy.get('.member-search-results .member-row').should('not.exist')
}
})

it('Allows to change membership management', function() {
const member = 'Bobs Group'

// Promote to admin
cy.get('.current-members').contains('.member-row', member)
.find('.member-row__actions')
.click()
cy.intercept('PUT', '**/circles/circles/*/members/*/level').as('updateCircleMemberLevel')
cy.intercept('GET', '**/circles/circles/*/members').as('getCircleMembers')
cy.get('button.action-button')
.contains('Promote to admin')
.click()
cy.wait('@updateCircleMemberLevel')
cy.wait('@getCircleMembers')
cy.get('.current-members').contains('.member-row', member)
.should('contain', '(admin)')

// Demote to moderator
cy.get('.current-members').contains('.member-row', member)
.find('.member-row__actions')
.click()
cy.intercept('PUT', '**/circles/circles/*/members/*/level').as('updateCircleMemberLevel')
cy.intercept('GET', '**/circles/circles/*/members').as('getCircleMembers')
cy.get('button.action-button')
.contains('Demote to moderator')
.click()
cy.wait('@updateCircleMemberLevel')
cy.wait('@getCircleMembers')
cy.get('.current-members').contains('.member-row', member)
.should('contain', '(moderator)')
})

it('Allows to remove member', function() {
const member = 'alice'

cy.get('.current-members').contains('.member-row', member)
.find('.member-row__actions')
.click()
cy.intercept('DELETE', '**/circles/circles/*/members/*').as('removeCircleMember')
cy.intercept('GET', '**/circles/circles/*/members').as('getCircleMembers')
cy.get('button.action-button')
.contains('Remove')
.click()
cy.wait('@removeCircleMember')
cy.wait('@getCircleMembers')

// Fixme: removing members from a circle is async and doesn't work with
// the single process PHP webserver used in CI.
// cy.get('.current-members .member-row').should('not.contain', member)
})
})
})
2 changes: 1 addition & 1 deletion cypress/support/commands.js
Original file line number Diff line number Diff line change
Expand Up @@ -143,7 +143,7 @@ Cypress.Commands.add('createCollective', (name, members = []) => {
for (const member of members) {
cy.get('.member-picker input[type="text"]').clear()
cy.get('.member-picker input[type="text"]').type(`${member}`)
cy.get('.search-results .user-bubble__content').contains(member).click()
cy.get('.member-search-results .member-row').contains(member).click()
cy.get('.selected-members .user-bubble__content').should('contain', member)
}
}
Expand Down
7 changes: 1 addition & 6 deletions src/Collectives.vue
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
</template>

<script>
import { showInfo, showError } from '@nextcloud/dialogs'
import { showInfo } from '@nextcloud/dialogs'
import { mapActions, mapGetters, mapState } from 'vuex'
import { GET_COLLECTIVES_FOLDER, GET_COLLECTIVES, GET_TRASH_COLLECTIVES } from './store/actions.js'
import displayError from './util/displayError.js'
Expand Down Expand Up @@ -66,11 +66,6 @@ export default {
this.getCollectivesFolder()
this.getTrashCollectives()
}

if (!this.isPublic && !('contacts' in this.OC.appswebroots)) {
console.error('The contacts app is required to manage members')
showError(t('collectives', 'The contacts app is required to manage members'))
}
},

methods: {
Expand Down
31 changes: 14 additions & 17 deletions src/components/Collective/CollectiveActions.vue
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
<template>
<div>
<NcActionLink v-if="showManageMembers"
:href="circleLink">
<NcActionButton v-if="isCollectiveAdmin(collective)"
:close-after-click="true"
@click="openCollectiveMembers()">
<template #icon>
<CirclesIcon :size="20" />
<AccountMultipleIcon :size="20" />
</template>
{{ t('collectives', 'Manage members') }}
</NcActionLink>
<NcActionSeparator v-if="showManageMembers" />
</NcActionButton>
<NcActionSeparator v-if="isCollectiveAdmin(collective)" />
<NcActionButton v-if="collectiveCanShare(collective)"
v-show="!isShared"
:close-after-click="false"
Expand Down Expand Up @@ -63,8 +64,7 @@
</template>
{{ t('collectives', 'Settings') }}
</NcActionButton>
<NcActionButton v-if="!isCollectiveAdmin(collective)"
:close-after-click="true"
<NcActionButton :close-after-click="true"
@click="leaveCollectiveWithUndo(collective)">
{{ t('collectives', 'Leave collective') }}
<template #icon>
Expand All @@ -79,8 +79,8 @@ import { mapActions, mapGetters, mapMutations } from 'vuex'
import { NcActionButton, NcActionCheckbox, NcActionLink, NcActionSeparator, NcLoadingIcon } from '@nextcloud/vue'
import { showError, showUndo } from '@nextcloud/dialogs'
import { generateUrl } from '@nextcloud/router'
import AccountMultipleIcon from 'vue-material-design-icons/AccountMultiple.vue'
import CheckIcon from 'vue-material-design-icons/Check.vue'
import CirclesIcon from '../Icon/CirclesIcon.vue'
import CogIcon from 'vue-material-design-icons/Cog.vue'
import ContentPasteIcon from 'vue-material-design-icons/ContentPaste.vue'
import DownloadIcon from 'vue-material-design-icons/Download.vue'
Expand All @@ -101,7 +101,7 @@ export default {
name: 'CollectiveActions',

components: {
CirclesIcon,
AccountMultipleIcon,
CheckIcon,
CogIcon,
ContentPasteIcon,
Expand Down Expand Up @@ -144,14 +144,6 @@ export default {
'loading',
]),

isContactsInstalled() {
return 'contacts' in this.OC.appswebroots
},

showManageMembers() {
return this.isCollectiveAdmin(this.collective) && this.isContactsInstalled
},

circleLink() {
return generateUrl('/apps/contacts/direct/circle/' + this.collective.circleId)
},
Expand Down Expand Up @@ -198,6 +190,7 @@ export default {
}),

...mapMutations([
'setMembersCollectiveId',
'setSettingsCollectiveId',
]),

Expand All @@ -216,6 +209,10 @@ export default {
this.copyToClipboard(window.location.origin + this.collectiveShareUrl(collective))
},

openCollectiveMembers() {
this.setMembersCollectiveId(this.collective.id)
},

openCollectiveSettings() {
this.setSettingsCollectiveId(this.collective.id)
},
Expand Down
123 changes: 123 additions & 0 deletions src/components/Member/CurrentMembers.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
<template>
<div class="current-members">
<NcAppNavigationCaption v-if="isSearching"
:title="t('collectives', 'Members')" />
<Member v-for="item in searchedMembers"
:key="item.singleId"
:circle-id="circleId"
:member-id="item.id"
:user-id="item.userId"
:display-name="item.displayName"
:user-type="circleMemberType(item)"
:level="item.level"
:is-current-user="isCurrentUser(item)"
:is-searched="false" />
<Hint v-if="isSearching && searchedMembers.length === 0"
:hint="t('collectives', 'No search results')" />
</div>
</template>

<script>
import { mapGetters } from 'vuex'
import { getCurrentUser } from '@nextcloud/auth'
import { NcAppNavigationCaption } from '@nextcloud/vue'
import Hint from './Hint.vue'
import Member from './Member.vue'

export default {
name: 'CurrentMembers',

components: {
Hint,
Member,
NcAppNavigationCaption,
},

props: {
circleId: {
type: String,
required: true,
},
currentMembers: {
type: Array,
required: true,
},
searchQuery: {
type: String,
default: '',
},
},

computed: {
...mapGetters([
'circleMemberType',
]),

isSearching() {
return this.searchQuery !== ''
},

sortedMembers() {
return this.currentMembers
.slice()
.sort(this.sortMembers)
},

searchedMembers() {
if (!this.isSearching) {
return this.sortedMembers
}

return this.sortedMembers
.filter(m => m.displayName.toLowerCase().includes(this.searchQuery.toLowerCase()))
},

currentUser() {
return getCurrentUser().uid
},

isCurrentUser() {
return function(item) {
return item.userId === this.currentUser
|| item.singleId === item.circle.initiator.singleId
}
},
},

methods: {
/**
*
* @param {object} m1 First member
* @param {string} m1.userId First member user ID
* @param {string} m1.displayName First member display name
* @param {number} m1.level First member level
* @param {number} m1.userType First member user type
* @param {object} m2 Second member
* @param {string} m2.userId Second member user ID
* @param {string} m2.displayName Second member display name
* @param {number} m2.level Second member level
* @param {number} m2.userType Second member user type
*/
sortMembers(m1, m2) {
if (m1.userId === this.currentUser) {
return -1
} else if (m2.userId === this.currentUser) {
return 1
}

// Sort by level (admin > moderator > member)
if (m1.level !== m2.level) {
return m1.level < m2.level
}

// Sort by user type (user > group > circle)
if (this.circleMemberType(m1) !== this.circleMemberType(m2)) {
return this.circleMemberType(m1) > this.circleMemberType(m2)
}

// Sort by display name
return m1.displayName.localeCompare(m2.displayName)
},
},
}
</script>
Loading
Loading