mirror of
https://github.com/nextcloud/server.git
synced 2026-04-15 22:11:17 -04:00
Merge pull request #57676 from nextcloud/feat/allow-filter-contacts-by-team
Some checks failed
CodeQL Advanced / Analyze (actions) (push) Waiting to run
CodeQL Advanced / Analyze (javascript-typescript) (push) Waiting to run
Integration sqlite / changes (push) Waiting to run
Integration sqlite / integration-sqlite (master, 8.4, main, --tags ~@large files_features) (push) Blocked by required conditions
Integration sqlite / integration-sqlite (master, 8.4, main, capabilities_features) (push) Blocked by required conditions
Integration sqlite / integration-sqlite (master, 8.4, main, collaboration_features) (push) Blocked by required conditions
Integration sqlite / integration-sqlite (master, 8.4, main, comments_features) (push) Blocked by required conditions
Integration sqlite / integration-sqlite (master, 8.4, main, dav_features) (push) Blocked by required conditions
Integration sqlite / integration-sqlite (master, 8.4, main, features) (push) Blocked by required conditions
Integration sqlite / integration-sqlite (master, 8.4, main, federation_features) (push) Blocked by required conditions
Integration sqlite / integration-sqlite (master, 8.4, main, file_conversions) (push) Blocked by required conditions
Integration sqlite / integration-sqlite (master, 8.4, main, files_reminders) (push) Blocked by required conditions
Integration sqlite / integration-sqlite (master, 8.4, main, filesdrop_features) (push) Blocked by required conditions
Integration sqlite / integration-sqlite (master, 8.4, main, ldap_features) (push) Blocked by required conditions
Integration sqlite / integration-sqlite (master, 8.4, main, openldap_features) (push) Blocked by required conditions
Integration sqlite / integration-sqlite (master, 8.4, main, openldap_numerical_features) (push) Blocked by required conditions
Integration sqlite / integration-sqlite (master, 8.4, main, remoteapi_features) (push) Blocked by required conditions
Integration sqlite / integration-sqlite (master, 8.4, main, routing_features) (push) Blocked by required conditions
Integration sqlite / integration-sqlite (master, 8.4, main, setup_features) (push) Blocked by required conditions
Integration sqlite / integration-sqlite (master, 8.4, main, sharees_features) (push) Blocked by required conditions
Integration sqlite / integration-sqlite (master, 8.4, main, sharing_features) (push) Blocked by required conditions
Integration sqlite / integration-sqlite (master, 8.4, main, theming_features) (push) Blocked by required conditions
Integration sqlite / integration-sqlite (master, 8.4, main, videoverification_features) (push) Blocked by required conditions
Integration sqlite / integration-sqlite-summary (push) Blocked by required conditions
Psalm static code analysis / static-code-analysis (push) Has been cancelled
Psalm static code analysis / static-code-analysis-security (push) Has been cancelled
Psalm static code analysis / static-code-analysis-ocp (push) Has been cancelled
Psalm static code analysis / static-code-analysis-ncu (push) Has been cancelled
Some checks failed
CodeQL Advanced / Analyze (actions) (push) Waiting to run
CodeQL Advanced / Analyze (javascript-typescript) (push) Waiting to run
Integration sqlite / changes (push) Waiting to run
Integration sqlite / integration-sqlite (master, 8.4, main, --tags ~@large files_features) (push) Blocked by required conditions
Integration sqlite / integration-sqlite (master, 8.4, main, capabilities_features) (push) Blocked by required conditions
Integration sqlite / integration-sqlite (master, 8.4, main, collaboration_features) (push) Blocked by required conditions
Integration sqlite / integration-sqlite (master, 8.4, main, comments_features) (push) Blocked by required conditions
Integration sqlite / integration-sqlite (master, 8.4, main, dav_features) (push) Blocked by required conditions
Integration sqlite / integration-sqlite (master, 8.4, main, features) (push) Blocked by required conditions
Integration sqlite / integration-sqlite (master, 8.4, main, federation_features) (push) Blocked by required conditions
Integration sqlite / integration-sqlite (master, 8.4, main, file_conversions) (push) Blocked by required conditions
Integration sqlite / integration-sqlite (master, 8.4, main, files_reminders) (push) Blocked by required conditions
Integration sqlite / integration-sqlite (master, 8.4, main, filesdrop_features) (push) Blocked by required conditions
Integration sqlite / integration-sqlite (master, 8.4, main, ldap_features) (push) Blocked by required conditions
Integration sqlite / integration-sqlite (master, 8.4, main, openldap_features) (push) Blocked by required conditions
Integration sqlite / integration-sqlite (master, 8.4, main, openldap_numerical_features) (push) Blocked by required conditions
Integration sqlite / integration-sqlite (master, 8.4, main, remoteapi_features) (push) Blocked by required conditions
Integration sqlite / integration-sqlite (master, 8.4, main, routing_features) (push) Blocked by required conditions
Integration sqlite / integration-sqlite (master, 8.4, main, setup_features) (push) Blocked by required conditions
Integration sqlite / integration-sqlite (master, 8.4, main, sharees_features) (push) Blocked by required conditions
Integration sqlite / integration-sqlite (master, 8.4, main, sharing_features) (push) Blocked by required conditions
Integration sqlite / integration-sqlite (master, 8.4, main, theming_features) (push) Blocked by required conditions
Integration sqlite / integration-sqlite (master, 8.4, main, videoverification_features) (push) Blocked by required conditions
Integration sqlite / integration-sqlite-summary (push) Blocked by required conditions
Psalm static code analysis / static-code-analysis (push) Has been cancelled
Psalm static code analysis / static-code-analysis-security (push) Has been cancelled
Psalm static code analysis / static-code-analysis-ocp (push) Has been cancelled
Psalm static code analysis / static-code-analysis-ncu (push) Has been cancelled
feat: allow to filter contacts by team
This commit is contained in:
commit
58b404a31c
10 changed files with 297 additions and 194 deletions
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
4
dist/core-main.js
vendored
4
dist/core-main.js
vendored
File diff suppressed because one or more lines are too long
2
dist/core-main.js.map
vendored
2
dist/core-main.js.map
vendored
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -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[]
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in a new issue