Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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 package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@
"dependencies": {
"@mdi/js": "^7.4.47",
"@nextcloud/axios": "^2.5.1",
"@nextcloud/capabilities": "^1.2.1",
"@nextcloud/cdav-library": "^2.6.2",
"@nextcloud/dialogs": "^7.4.0",
"@nextcloud/files": "^4.0.0",
Expand Down
2 changes: 2 additions & 0 deletions src/teams/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ interface RawSuggestion {
shareWith: string
shareType: number
user?: string | null
subname: string
}

/**
Expand Down Expand Up @@ -585,6 +586,7 @@ export async function searchMemberCandidates(term: string): Promise<MemberCandid
shareWith: suggestion.shareWith,
shareType: suggestion.shareType,
displayName: suggestion.label,
subname: suggestion.subname,
isUser: suggestion.user !== null && suggestion.user !== undefined,
}))
}
Expand Down
3 changes: 3 additions & 0 deletions src/teams/components/TeamCreationWizard.vue
Original file line number Diff line number Diff line change
Expand Up @@ -394,6 +394,9 @@ function onFormSubmit(): void {
disableMenu
hideStatus />
</template>
<template v-if="candidate.subname" #subname>
{{ candidate.subname }}
</template>
</NcListItem>
</ul>
</template>
Expand Down
114 changes: 31 additions & 83 deletions src/teams/team-page/components/EntityPicker/EntitySearchResult.vue
Original file line number Diff line number Diff line change
Expand Up @@ -8,32 +8,39 @@
{{ t('circles', 'Add {type}', { type: source.label.toLowerCase() }) }}
</h4>

<UserBubble
<NcListItem
v-else
class="entity-picker__bubble"
:class="{ 'entity-picker__bubble--selected': isSelected }"
:display-name="source.label"
:user="source.shareType === ShareType.User ? source.user : undefined"
:margin="6"
:size="44"
url="#"
:name="source.label"
:active="isSelected"
compact
@click.stop.prevent="onClick(source)">
<template #title>
<span class="entity-picker__bubble-checkmark icon-checkmark" />
<template #icon>
<NcAvatar
:user="source.shareType === ShareType.User ? source.user : undefined"
:display-name="source.label"
:is-no-user="source.shareType !== ShareType.User"
:size="32"
disable-menu
hide-status />
</template>
</UserBubble>
<template v-if="source.subname" #subname>
{{ source.subname }}
</template>
</NcListItem>
</template>

<script>
// @ts-nocheck
import { ShareType } from '@nextcloud/sharing'
import { NcUserBubble as UserBubble } from '@nextcloud/vue'
import NcAvatar from '@nextcloud/vue/components/NcAvatar'
import NcListItem from '@nextcloud/vue/components/NcListItem'

export default {
name: 'EntitySearchResult',

components: {
UserBubble,
NcAvatar,
NcListItem,
},

props: {
Expand Down Expand Up @@ -78,76 +85,17 @@ export default {
// 44px is what we choose and have very good visual-to-usability ratio
$clickable-area: 44px;

// background icon size
// also used for the scss icon font
$icon-size: 16px;

// icon padding for a $clickable-area width and a $icon-size icon
// ( 44px - 16px ) / 2
$icon-margin: math.div($clickable-area - $icon-size, 2);

.entity-picker {
&__option {
&-caption {
list-style-type: none;
user-select: none;
white-space: nowrap;
text-overflow: ellipsis;
pointer-events: none;
box-shadow: none !important;
line-height: $clickable-area;

&:not(:first-child) {
margin-top: math.div($clickable-area, 2);
}
}
}

&__bubble {
display: flex;
margin-bottom: 4px;

&-checkmark {
display: block;
opacity: 0;
}

// Show checkmark on selected
&--selected .entity-picker__bubble-checkmark {
opacity: 1;
}

// Show primary bg on hovering entities
&--selected,
&:hover,
&:focus {
:deep(.user-bubble__content) {
// better visual with light default tint
background-color: var(--color-primary-element-light);
}
}
.entity-picker__option-caption {
list-style-type: none;
user-select: none;
white-space: nowrap;
text-overflow: ellipsis;
pointer-events: none;
box-shadow: none !important;
line-height: $clickable-area;

&:not(:first-child) {
margin-top: math.div($clickable-area, 2);
}
}

:deep(.user-bubble__content){
// Take full width
width: 100%;
// Override default styling
background: none;
.user-bubble__secondary {
// Force show checkmark
display: inline-flex;
margin-inline: auto 4px;
}
&, * {
// the whole row is clickable,let's force the proper cursor
cursor: pointer;
user-select: none;
-webkit-user-drag: none;
-khtml-user-drag: none;
-moz-user-drag: none;
-o-user-drag: none;
}
}

</style>
53 changes: 31 additions & 22 deletions src/teams/team-page/services/collaborationAutocompletion.js
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@
*/

import axios from '@nextcloud/axios'
import { getCapabilities } from '@nextcloud/capabilities'
import { t } from '@nextcloud/l10n'
import { generateOcsUrl } from '@nextcloud/router'
import { ShareType } from '@nextcloud/sharing'
import { CircleConfigs, SHARES_TYPES_MEMBER_MAP } from '../models/constants.ts'
Expand Down Expand Up @@ -67,27 +69,7 @@ export async function getSuggestions(search, circle = null) {
// sort by type so we can get user&groups first...
.sort((a, b) => a.shareType - b.shareType)

const allSuggestions = exactSuggestions.concat(suggestions)

// Count occurances of display names in order to provide a distinguishable description if needed
const nameCounts = allSuggestions.reduce((nameCounts, result) => {
if (!result.displayName) {
return nameCounts
}
if (!nameCounts[result.displayName]) {
nameCounts[result.displayName] = 0
}
nameCounts[result.displayName]++
return nameCounts
}, {})

const finalResults = allSuggestions.map((item) => {
// Make sure that items with duplicate displayName get the shareWith applied as a description
if (nameCounts[item.displayName] > 1 && !item.desc) {
return { ...item, desc: item.shareWithDisplayNameUnique }
}
return item
})
const finalResults = exactSuggestions.concat(suggestions)

logger.info('suggestions', { finalResults })

Expand Down Expand Up @@ -119,16 +101,43 @@ export async function getRecommendations() {
return finalResults
}

/**
* Secondary line telling apart entries with the same name,
* same rules as the files_sharing sharing input.
*
* @param {object} result raw sharee result
* @return {string}
*/
function getSubname(result) {
switch (result.value.shareType) {
case ShareType.User:
// Admins can hide the email/unique name for privacy
return getCapabilities().files_sharing?.sharee?.always_show_unique === true
? result.shareWithDisplayNameUnique ?? ''
: ''
case ShareType.Email:
return result.value.shareWith
case ShareType.Remote:
case ShareType.RemoteGroup:
return result.value.server
? t('circles', 'on {server}', { server: result.value.server })
: ''
default:
return result.shareWithDescription ?? ''
}
}

function formatResults(result) {
const type = `picker-${result.value.shareType}`
return {
label: result.label,
id: `${type}-${result.value.shareWith}`,
// If this is a user, set as user for avatar display by UserBubble
user: [window.OC.Share.SHARE_TYPE_USER, window.OC.Share.SHARE_TYPE_REMOTE].indexOf(result.value.shareType) > -1
user: [ShareType.User, ShareType.Remote].indexOf(result.value.shareType) > -1
? result.value.shareWith
: null,
type,
subname: getSubname(result),
...result.value,
}
}
78 changes: 78 additions & 0 deletions src/teams/team-page/services/collaborationAutocompletion.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
/*!
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

import axios from '@nextcloud/axios'
import { getCapabilities } from '@nextcloud/capabilities'
import { ShareType } from '@nextcloud/sharing'
import { beforeEach, describe, expect, it, vi } from 'vitest'

vi.mock('@nextcloud/axios', () => ({
default: { get: vi.fn() },
}))

vi.mock('@nextcloud/router', () => ({
generateOcsUrl: (path: string) => `/ocs/${path}`,
}))

vi.mock('@nextcloud/capabilities', () => ({
getCapabilities: vi.fn(),
}))

vi.mock('../../../logger.ts', () => ({
logger: { info: vi.fn() },
}))

vi.mock('../models/constants.ts', () => ({
SHARES_TYPES_MEMBER_MAP: {},
CircleConfigs: { FEDERATED: 32768 },
}))

const { getSuggestions } = await import('./collaborationAutocompletion.js')

/**
* @param alwaysShowUnique - value of the files_sharing always_show_unique capability
*/
function mockCapabilities(alwaysShowUnique: boolean) {
vi.mocked(getCapabilities).mockReturnValue({
files_sharing: { sharee: { always_show_unique: alwaysShowUnique } },
})
}

describe('getSuggestions', () => {
beforeEach(() => {
vi.mocked(axios.get).mockResolvedValue({
data: {
ocs: {
data: {
exact: { users: [] },
users: [
{ label: 'Alex', value: { shareType: ShareType.User, shareWith: 'alex1' }, shareWithDisplayNameUnique: 'alex@one.example' },
{ label: 'Alex', value: { shareType: ShareType.User, shareWith: 'alex2' }, shareWithDisplayNameUnique: 'alex@two.example' },
],
emails: [
{ label: 'Ext', value: { shareType: ShareType.Email, shareWith: 'ext@example.com' } },
],
},
},
},
})
})

it('shows the email to tell apart users with the same name', async () => {
mockCapabilities(true)

const results = await getSuggestions('alex')

expect(results.map((r: { subname: string }) => r.subname)).toEqual(['alex@one.example', 'alex@two.example', 'ext@example.com'])
})

it('hides the user email when the admin disabled it', async () => {
mockCapabilities(false)

const results = await getSuggestions('alex')

expect(results.map((r: { subname: string }) => r.subname)).toEqual(['', '', 'ext@example.com'])
})
})
2 changes: 2 additions & 0 deletions src/teams/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,8 @@ export interface MemberCandidate {
/** The sharee share type, see `@nextcloud/sharing`'s `ShareType`. */
shareType: number
displayName: string
/** Secondary line telling apart candidates with the same name (e.g. their email). */
subname: string
/** Whether this candidate is a real Nextcloud user (controls avatar rendering). */
isUser: boolean
}
Loading