feat: allow to filter contacts by team

Signed-off-by: Ferdinand Thiessen <opensource@fthiessen.de>
This commit is contained in:
Ferdinand Thiessen 2026-01-21 00:47:44 +01:00
parent d9d1d04e2e
commit 503acb0ed6
No known key found for this signature in database
GPG key ID: 7E849AE05218500F
6 changed files with 291 additions and 188 deletions

View file

@ -31,9 +31,9 @@ const emit = defineEmits<{
}>()
const PresetNames = {
// TRANSLATORS Large organization > Big organization > Small organization
// TRANSLATORS Large organization > Big organization > Small organization
LARGE: t('settings', 'Large organization'),
// TRANSLATORS Large organization > Big organization > Small organization
// TRANSLATORS Large organization > Big organization > Small organization
MEDIUM: t('settings', 'Big organization'),
SMALL: t('settings', 'Small organization'),
SHARED: t('settings', 'Hosting company'),

View file

@ -13,14 +13,17 @@ use OCP\AppFramework\Http;
use OCP\AppFramework\Http\Attribute\FrontpageRoute;
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
use OCP\AppFramework\Http\JSONResponse;
use OCP\Contacts\ContactsMenu\IEntry;
use OCP\IRequest;
use OCP\IUserSession;
use OCP\Teams\ITeamManager;
class ContactsMenuController extends Controller {
public function __construct(
IRequest $request,
private IUserSession $userSession,
private Manager $manager,
private ITeamManager $teamManager,
) {
parent::__construct('core', $request);
}
@ -31,8 +34,18 @@ class ContactsMenuController extends Controller {
*/
#[NoAdminRequired]
#[FrontpageRoute(verb: 'POST', url: '/contactsmenu/contacts')]
public function index(?string $filter = null): array {
return $this->manager->getEntries($this->userSession->getUser(), $filter);
public function index(?string $filter = null, ?string $teamId = null): array {
$entries = $this->manager->getEntries($this->userSession->getUser(), $filter);
if ($teamId !== null) {
/** @var \OC\Teams\TeamManager */
$teamManager = $this->teamManager;
$memberIds = $teamManager->getMembersOfTeam($teamId, $this->userSession->getUser()->getUID());
$entries['contacts'] = array_filter(
$entries['contacts'],
fn (IEntry $entry) => in_array($entry->getProperty('UID'), $memberIds, true)
);
}
return $entries;
}
/**
@ -49,4 +62,13 @@ class ContactsMenuController extends Controller {
}
return new JSONResponse([], Http::STATUS_NOT_FOUND);
}
/**
* @return \JsonSerializable[]
*/
#[NoAdminRequired]
#[FrontpageRoute(verb: 'GET', url: '/contactsmenu/teams')]
public function getTeams(): array {
return $this->teamManager->getTeamsForUser($this->userSession->getUser()->getUID());
}
}

View file

@ -3,12 +3,13 @@
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { mount, shallowMount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import { cleanup, findAllByRole, render } from '@testing-library/vue'
import { afterEach, describe, expect, it, vi } from 'vitest'
import ContactsMenu from '../../views/ContactsMenu.vue'
const axios = vi.hoisted(() => ({
post: vi.fn(),
get: vi.fn(),
}))
vi.mock('@nextcloud/axios', () => ({ default: axios }))
@ -16,67 +17,47 @@ vi.mock('@nextcloud/auth', () => ({
getCurrentUser: () => ({ uid: 'user', isAdmin: false, displayName: 'User' }),
}))
afterEach(cleanup)
describe('ContactsMenu', function() {
it('is closed by default', () => {
const view = shallowMount(ContactsMenu)
expect(view.vm.contacts).toEqual([])
expect(view.vm.loadingText).toBe(undefined)
})
it('shows a loading text', async () => {
const view = shallowMount(ContactsMenu)
axios.post.mockResolvedValue({
const { promise, resolve } = Promise.withResolvers<void>()
axios.post.mockImplementationOnce(async () => (await promise, {
data: {
contacts: [],
contactsAppEnabled: false,
},
}))
axios.get.mockResolvedValue({
data: [],
})
const opening = view.vm.handleOpen()
const view = render(ContactsMenu)
await view.findByRole('button')
.then((button) => button.click())
expect(view.vm.contacts).toEqual([])
expect(view.vm.loadingText).toBe('Loading your contacts …')
await opening
await expect(view.findByText(/Loading your contacts\s…/)).resolves.toBeTruthy()
resolve()
await expect(view.findByText('No contacts found')).resolves.toBeTruthy()
})
it('shows error view when contacts can not be loaded', async () => {
const view = mount(ContactsMenu)
axios.post.mockResolvedValue({})
axios.get.mockResolvedValue({
data: [],
})
vi.spyOn(console, 'error').mockImplementation(() => {})
try {
await view.vm.handleOpen()
throw new Error('should not be reached')
} catch {
expect(console.error).toHaveBeenCalled()
console.error.mockRestore()
expect(view.vm.error).toBe(true)
expect(view.vm.contacts).toEqual([])
expect(view.text()).toContain('Could not load your contacts')
}
})
it('shows text when there are no contacts', async () => {
const view = mount(ContactsMenu)
axios.post.mockResolvedValueOnce({
data: {
contacts: [],
contactsAppEnabled: false,
},
})
await view.vm.handleOpen()
expect(view.vm.error).toBe(false)
expect(view.vm.contacts).toEqual([])
expect(view.vm.loadingText).toBe(undefined)
expect(view.text()).toContain('No contacts found')
const view = render(ContactsMenu)
await view.findByRole('button')
.then((button) => button.click())
await expect(view.findByText(/Could not load your contacts/)).resolves.toBeTruthy()
})
it('shows contacts', async () => {
const view = mount(ContactsMenu)
axios.get.mockResolvedValue({
data: [],
})
axios.post.mockResolvedValue({
data: {
contacts: [
@ -131,12 +112,16 @@ describe('ContactsMenu', function() {
},
})
await view.vm.handleOpen()
const view = render(ContactsMenu)
await view.findByRole('button')
.then((button) => button.click())
expect(view.vm.error).toBe(false)
expect(view.vm.contacts.length).toBe(2)
expect(view.text()).toContain('Acosta Lancaster')
expect(view.text()).toContain('Adeline Snider')
expect(view.text()).toContain('Show all contacts')
await expect(view.findByRole('list', { name: 'Contacts list' })).resolves.toBeTruthy()
const list = view.getByRole('list', { name: 'Contacts list' })
await expect(findAllByRole(list, 'listitem')).resolves.toHaveLength(2)
const items = await findAllByRole(list, 'listitem')
expect(items[0]!.textContent).toContain('Acosta Lancaster')
expect(items[1]!.textContent).toContain('Adeline Snider')
})
})

View file

@ -3,28 +3,191 @@
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<script setup lang="ts">
import { mdiAccountGroupOutline, mdiContacts, mdiMagnify } from '@mdi/js'
import { getCurrentUser } from '@nextcloud/auth'
import axios from '@nextcloud/axios'
import { getBuilder } from '@nextcloud/browser-storage'
import { t } from '@nextcloud/l10n'
import { generateUrl } from '@nextcloud/router'
import debounce from 'debounce'
import { computed, nextTick, onMounted, ref, watch } from 'vue'
import NcActionButton from '@nextcloud/vue/components/NcActionButton'
import NcActions from '@nextcloud/vue/components/NcActions'
import NcButton from '@nextcloud/vue/components/NcButton'
import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent'
import NcHeaderMenu from '@nextcloud/vue/components/NcHeaderMenu'
import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
import NcTextField from '@nextcloud/vue/components/NcTextField'
import ContactMenuEntry from '../components/ContactsMenu/ContactMenuEntry.vue'
import logger from '../logger.js'
const storage = getBuilder('core:contacts')
.persist(true)
.clearOnLogout(true)
.build()
const user = getCurrentUser()!
const contactsAppURL = generateUrl('/apps/contacts')
const contactsAppMgmtURL = generateUrl('/settings/apps/social/contacts')
const contactsMenuInput = ref<HTMLInputElement>()
const actions = ref(window.OC?.ContactsMenu?.actions || [])
const contactsAppEnabled = ref(false)
const contacts = ref([])
const loadingText = ref<string>()
const hasError = ref(false)
const searchTerm = ref('')
const teams = ref<ITeam[]>([])
const selectedTeam = ref<string>('$_all_$')
const selectedTeamName = computed(() => teams.value.find((t) => t.teamId === selectedTeam.value)?.displayName)
onMounted(async () => {
const team = storage.getItem('core:contacts:team')
if (team) {
selectedTeam.value = JSON.parse(team)
}
if (userTeams.length === 0) {
try {
const { data } = await axios.get<ITeam[]>(generateUrl('/contactsmenu/teams'))
userTeams.push(...data)
} catch (error) {
logger.error('could not load user teams', { error })
}
}
teams.value = [...userTeams]
})
watch(selectedTeam, () => {
storage.setItem('core:contacts:team', JSON.stringify(selectedTeam.value))
getContacts(searchTerm.value)
})
/**
* Load contacts when opening the menu
*/
async function onOpened() {
await getContacts('')
}
/**
* Load contacts from the server
*
* @param searchTerm - The search term to filter contacts by
*/
async function getContacts(searchTerm: string) {
if (searchTerm === '') {
loadingText.value = t('core', 'Loading your contacts …')
} else {
loadingText.value = t('core', 'Looking for {term} …', {
term: searchTerm,
})
}
// Let the user try a different query if the previous one failed
hasError.value = false
try {
const { data } = await axios.post(generateUrl('/contactsmenu/contacts'), {
filter: searchTerm,
teamId: selectedTeam.value !== '$_all_$' ? selectedTeam.value : undefined,
})
contacts.value = data.contacts
contactsAppEnabled.value = data.contactsAppEnabled
loadingText.value = undefined
} catch (error) {
logger.error('could not load contacts', {
error,
searchTerm,
})
hasError.value = true
}
}
const onInputDebounced = debounce(function() {
getContacts(searchTerm.value)
}, 500)
/**
* Reset the search state
*/
function onReset() {
searchTerm.value = ''
contacts.value = []
focusInput()
}
/**
* Focus the search input on next tick
*/
function focusInput() {
nextTick(() => {
contactsMenuInput.value?.focus()
contactsMenuInput.value?.select()
})
}
</script>
<script lang="ts">
interface ITeam {
teamId: string
displayName: string
link: string
}
const userTeams: ITeam[] = []
</script>
<template>
<NcHeaderMenu
id="contactsmenu"
class="contactsmenu"
:aria-label="t('core', 'Search contacts')"
@open="handleOpen">
exclude-click-outside-selectors=".v-popper__popper"
@open="onOpened">
<template #trigger>
<NcIconSvgWrapper class="contactsmenu__trigger-icon" :path="mdiContacts" />
</template>
<div class="contactsmenu__menu">
<div class="contactsmenu__menu__search-container">
<div class="contactsmenu__menu__input-wrapper">
<NcActions force-menu :aria-label="t('core', 'Filter by team')" variant="tertiary">
<template #icon>
<NcIconSvgWrapper :path="mdiAccountGroupOutline" />
</template>
<template #default>
<NcActionButton
:modelValue.sync="selectedTeam"
value="$_all_$"
type="radio">
{{ t('core', 'All teams') }}
</NcActionButton>
<NcActionButton
v-for="team of teams"
:key="team.teamId"
:modelValue.sync="selectedTeam"
:value="team.teamId"
type="radio">
{{ team.displayName }}
</NcActionButton>
</template>
</NcActions>
<NcTextField
id="contactsmenu__menu__search"
ref="contactsMenuInput"
v-model="searchTerm"
class="contactsmenu__menu__search"
trailing-button-icon="close"
:label="t('core', 'Search contacts')"
:label="selectedTeamName
? t('core', 'Search contacts in team {team}', { team: selectedTeamName })
: t('core', 'Search contacts …')
"
:trailing-button-label="t('core', 'Reset search')"
:show-trailing-button="searchTerm !== ''"
:placeholder="t('core', 'Search contacts …')"
class="contactsmenu__menu__search"
type="search"
@input="onInputDebounced"
@trailing-button-click="onReset" />
</div>
@ -41,7 +204,7 @@
</template>
</NcButton>
</div>
<NcEmptyContent v-if="error" :name="t('core', 'Could not load your contacts')">
<NcEmptyContent v-if="hasError" :name="t('core', 'Could not load your contacts')">
<template #icon>
<NcIconSvgWrapper :path="mdiMagnify" />
</template>
@ -58,7 +221,7 @@
</NcEmptyContent>
<div v-else class="contactsmenu__menu__content">
<div id="contactsmenu-contacts">
<ul>
<ul :aria-label="t('core', 'Contacts list')">
<ContactMenuEntry v-for="contact in contacts" :key="contact.id" :contact="contact" />
</ul>
</div>
@ -67,7 +230,7 @@
{{ t('core', 'Show all contacts') }}
</NcButton>
</div>
<div v-else-if="canInstallApp" class="contactsmenu__menu__content__footer">
<div v-else-if="user.isAdmin" class="contactsmenu__menu__content__footer">
<NcButton variant="tertiary" :href="contactsAppMgmtURL">
{{ t('core', 'Install the Contacts app') }}
</NcButton>
@ -77,120 +240,6 @@
</NcHeaderMenu>
</template>
<script>
import { mdiContacts, mdiMagnify } from '@mdi/js'
import { getCurrentUser } from '@nextcloud/auth'
import axios from '@nextcloud/axios'
import { t } from '@nextcloud/l10n'
import { generateUrl } from '@nextcloud/router'
import debounce from 'debounce'
import NcButton from '@nextcloud/vue/components/NcButton'
import NcEmptyContent from '@nextcloud/vue/components/NcEmptyContent'
import NcHeaderMenu from '@nextcloud/vue/components/NcHeaderMenu'
import NcIconSvgWrapper from '@nextcloud/vue/components/NcIconSvgWrapper'
import NcLoadingIcon from '@nextcloud/vue/components/NcLoadingIcon'
import NcTextField from '@nextcloud/vue/components/NcTextField'
import ContactMenuEntry from '../components/ContactsMenu/ContactMenuEntry.vue'
import logger from '../logger.js'
import Nextcloud from '../mixins/Nextcloud.js'
export default {
name: 'ContactsMenu',
components: {
ContactMenuEntry,
NcButton,
NcEmptyContent,
NcHeaderMenu,
NcIconSvgWrapper,
NcLoadingIcon,
NcTextField,
},
mixins: [Nextcloud],
setup() {
return {
mdiContacts,
mdiMagnify,
}
},
data() {
const user = getCurrentUser()
return {
actions: window.OC?.ContactsMenu?.actions || [],
contactsAppEnabled: false,
contactsAppURL: generateUrl('/apps/contacts'),
contactsAppMgmtURL: generateUrl('/settings/apps/social/contacts'),
canInstallApp: user.isAdmin,
contacts: [],
loadingText: undefined,
error: false,
searchTerm: '',
}
},
methods: {
async handleOpen() {
await this.getContacts('')
},
async getContacts(searchTerm) {
if (searchTerm === '') {
this.loadingText = t('core', 'Loading your contacts …')
} else {
this.loadingText = t('core', 'Looking for {term} …', {
term: searchTerm,
})
}
// Let the user try a different query if the previous one failed
this.error = false
try {
const { data: { contacts, contactsAppEnabled } } = await axios.post(generateUrl('/contactsmenu/contacts'), {
filter: searchTerm,
})
this.contacts = contacts
this.contactsAppEnabled = contactsAppEnabled
this.loadingText = undefined
} catch (error) {
logger.error('could not load contacts', {
error,
searchTerm,
})
this.error = true
}
},
onInputDebounced: debounce(function() {
this.getContacts(this.searchTerm)
}, 500),
/**
* Reset the search state
*/
onReset() {
this.searchTerm = ''
this.contacts = []
this.focusInput()
},
/**
* Focus the search input on next tick
*/
focusInput() {
this.$nextTick(() => {
this.$refs.contactsMenuInput.focus()
this.$refs.contactsMenuInput.select()
})
},
},
}
</script>
<style lang="scss" scoped>
.contactsmenu {
overflow-y: hidden;
@ -206,12 +255,6 @@ export default {
height: calc(50px * 6 + 2px + 26px);
max-height: inherit;
label[for="contactsmenu__menu__search"] {
font-weight: bold;
font-size: 19px;
margin-inline-start: 13px;
}
&__search-container {
padding: 10px;
display: flex;
@ -223,6 +266,8 @@ export default {
z-index: 2;
top: 0;
flex-grow: 1;
display: flex;
gap: var(--default-grid-baseline);
}
&__search {

View file

@ -132,6 +132,18 @@ class TeamManager implements ITeamManager {
}
}
/**
* @return string[]
*/
public function getMembersOfTeam(string $teamId, string $userId): array {
$team = $this->getTeam($teamId, $userId);
if ($team === null) {
return [];
}
$members = $team->getInheritedMembers();
return array_map(fn ($member) => $member->getUserId(), $members);
}
/**
* @return Circle[]
*/

View file

@ -9,6 +9,7 @@ namespace Tests\Controller;
use OC\Contacts\ContactsMenu\Manager;
use OC\Core\Controller\ContactsMenuController;
use OC\Teams\TeamManager;
use OCP\Contacts\ContactsMenu\IEntry;
use OCP\IRequest;
use OCP\IUser;
@ -17,11 +18,9 @@ use PHPUnit\Framework\MockObject\MockObject;
use Test\TestCase;
class ContactsMenuControllerTest extends TestCase {
/** @var IUserSession|MockObject */
private $userSession;
/** @var Manager|MockObject */
private $contactsManager;
private IUserSession&MockObject $userSession;
private Manager&MockObject $contactsManager;
private TeamManager&MockObject $teamManager;
private ContactsMenuController $controller;
@ -31,8 +30,14 @@ class ContactsMenuControllerTest extends TestCase {
$request = $this->createMock(IRequest::class);
$this->userSession = $this->createMock(IUserSession::class);
$this->contactsManager = $this->createMock(Manager::class);
$this->teamManager = $this->createMock(TeamManager::class);
$this->controller = new ContactsMenuController($request, $this->userSession, $this->contactsManager);
$this->controller = new ContactsMenuController(
$request,
$this->userSession,
$this->contactsManager,
$this->teamManager,
);
}
public function testIndex(): void {
@ -54,6 +59,40 @@ class ContactsMenuControllerTest extends TestCase {
$this->assertEquals($entries, $response);
}
public function testIndex_withTeam(): void {
$user = $this->createMock(IUser::class);
$user->method('getUID')
->willReturn('current-user');
$entries = [
$this->createMock(IEntry::class),
$this->createMock(IEntry::class),
];
$entries[0]->method('getProperty')
->with('UID')
->willReturn('member1');
$entries[0]->method('getProperty')
->with('UID')
->willReturn('member2');
$this->userSession->expects($this->atLeastOnce())
->method('getUser')
->willReturn($user);
$this->contactsManager->expects($this->once())
->method('getEntries')
->with($this->equalTo($user), $this->equalTo(null))
->willReturn(['contacts' => $entries]);
$this->teamManager->expects($this->once())
->method('getMembersOfTeam')
->with('team-id', 'current-user')
->willReturn(['member1', 'member3']);
$response = $this->controller->index(teamId: 'team-id');
$this->assertEquals([$entries[0]], $response['contacts']);
}
public function testFindOne(): void {
$user = $this->createMock(IUser::class);
$entry = $this->createMock(IEntry::class);