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
92 changes: 81 additions & 11 deletions src/teams/api.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,24 +32,30 @@ vi.mock('./team-page/services/collaborationAutocompletion.js', () => ({
getSuggestions: vi.fn(),
}))

const { createTeam, fetchTeams, setTeamFavorite, reorderFavoriteTeams } = await import('./api.ts')
const { createTeam, fetchTeams, joinTeam, setTeamFavorite, reorderFavoriteTeams } = await import('./api.ts')

describe('fetchTeams', () => {
it('drops circles the current user is not a member of', async () => {
it('splits active members from discoverable teams', async () => {
const circles = [
{ id: 'team1', name: 'team1', displayName: 'Team One', population: 3, initiator: { level: 9 } },
{ id: 'visible1', name: 'visible1', displayName: 'Visible circle', population: 10, initiator: null },
{ id: 'owner-team', name: 'owner-team', displayName: 'Zebra owner', population: 3, initiator: { level: 9, status: 'Member' } },
{ id: 'open-team', name: 'open-team', displayName: 'Bravo open', population: 10, initiator: null, config: 24 },
{ id: 'closed-team', name: 'closed-team', displayName: 'Charlie closed', population: 7, initiator: null, config: 8 },
{ id: 'pending-team', name: 'pending-team', displayName: 'Alpha pending', population: 5, initiator: { level: 0, status: 'Requesting' }, config: 88 },
{ id: 'invited-team', name: 'invited-team', displayName: 'Echo invited', population: 2, initiator: { level: 0, status: 'Invited' } },
]
vi.mocked(axios.get)
.mockResolvedValueOnce({ data: { ocs: { data: circles } } })
.mockResolvedValueOnce({ data: { ocs: { data: [] } } })
.mockResolvedValueOnce({ data: { ocs: { data: { circleIds: [] } } } })

const teams = await fetchTeams()
const overview = await fetchTeams()

expect(teams).toHaveLength(1)
expect(teams[0].id).toBe('team1')
expect(teams[0].myRole).toBe('owner')
expect(overview.teams.map((team) => team.id)).toEqual(['owner-team', 'invited-team'])
expect(overview.teams[0].myRole).toBe('owner')
expect(overview.discoverableTeams.map((team) => team.id)).toEqual(['pending-team', 'open-team', 'closed-team'])
expect(overview.discoverableTeams[0]).toMatchObject({ pending: true, canJoin: false, memberCount: 5 })
expect(overview.discoverableTeams[1]).toMatchObject({ pending: false, canJoin: true, memberCount: 10 })
expect(overview.discoverableTeams[2]).toMatchObject({ pending: false, canJoin: false, memberCount: 7 })
})

it('returns an empty list when the circles response carries no data', async () => {
Expand All @@ -58,7 +64,71 @@ describe('fetchTeams', () => {
.mockResolvedValueOnce({ data: { ocs: { data: [] } } })
.mockResolvedValueOnce({ data: { ocs: { data: { circleIds: [] } } } })

await expect(fetchTeams()).resolves.toEqual([])
await expect(fetchTeams()).resolves.toEqual({ teams: [], discoverableTeams: [] })
})

it('uses fallback metadata for discoverable and member teams', async () => {
const circles = [
{ id: 'discoverable-defaults', name: 'Fallback discoverable', displayName: '', initiator: null },
{ id: 'member-defaults', name: 'Fallback member', displayName: '', initiator: { level: 1, status: 'Member' } },
{ id: 'member-preview-count', name: 'Preview member', displayName: 'Preview member', initiator: { level: 1, status: 'Member' } },
]
vi.mocked(axios.get)
.mockResolvedValueOnce({ data: { ocs: { data: circles } } })
.mockResolvedValueOnce({
data: {
ocs: {
data: [{
singleId: 'member-preview-count',
members: [{ singleId: 'alice', userId: 'alice', displayName: 'Alice', type: 1 }],
resources: [],
}],
},
},
})
.mockResolvedValueOnce({ data: { ocs: { data: { circleIds: [] } } } })

const overview = await fetchTeams()

expect(overview.discoverableTeams).toMatchObject([{
id: 'discoverable-defaults',
displayName: 'Fallback discoverable',
description: '',
memberCount: 0,
canJoin: false,
pending: false,
}])
expect(overview.teams).toMatchObject([
{ id: 'member-defaults', displayName: 'Fallback member', description: '', memberCount: 0 },
{
id: 'member-preview-count',
displayName: 'Preview member',
memberCount: 1,
members: [{ id: 'alice', userId: 'alice', displayName: 'Alice', isUser: true, role: 'member' }],
},
])
})
})

describe('joinTeam', () => {
it('returns requested when the join requires approval', async () => {
vi.mocked(axios.put).mockResolvedValueOnce({ data: { ocs: { data: { status: 'Requesting' } } } })

await expect(joinTeam('team1')).resolves.toBe('requested')
expect(axios.put).toHaveBeenCalledWith(expect.stringMatching(/\/join$/), {})
})

it('returns joined for an immediate membership', async () => {
vi.mocked(axios.put).mockResolvedValueOnce({ data: { ocs: { data: { status: 'Member' } } } })

await expect(joinTeam('team1')).resolves.toBe('joined')
expect(axios.put).toHaveBeenCalledWith(expect.stringMatching(/\/join$/), {})
})

it('propagates a rejected join request', async () => {
vi.mocked(axios.put).mockRejectedValueOnce(new Error('join failed'))

await expect(joinTeam('team1')).rejects.toThrow('join failed')
})
})

Expand Down Expand Up @@ -110,7 +180,7 @@ describe('favorite teams API', () => {
vi.mocked(axios.get).mockResolvedValueOnce({ data: { ocs: { data: [{ id: 'A', name: 'A', displayName: 'A', initiator: { level: 1 } }] } } })
.mockResolvedValueOnce({ data: { ocs: { data: [] } } })
.mockRejectedValueOnce(new Error('favorites unavailable'))
const teams = await fetchTeams()
expect(teams.map((team) => [team.id, team.isFavorite])).toEqual([['A', false]])
const overview = await fetchTeams()
expect(overview.teams.map((team) => [team.id, team.isFavorite])).toEqual([['A', false]])
})
})
100 changes: 80 additions & 20 deletions src/teams/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

import type { Member, MemberCandidate, Resource, SharedResource, Team, TeamRole } from './types.ts'
import type { DiscoverableTeam, Member, MemberCandidate, Resource, SharedResource, Team, TeamRole } from './types.ts'

import axios from '@nextcloud/axios'
import { FileType } from '@nextcloud/files'
Expand All @@ -13,6 +13,11 @@ import { logger } from '../logger.ts'
import { SHARES_TYPES_MEMBER_MAP } from './team-page/models/constants.ts'
import { getRecommendations, getSuggestions } from './team-page/services/collaborationAutocompletion.js'

/** `Circle::CFG_OPEN`: anyone can join or request to join. */
const CIRCLE_CONFIG_OPEN = 16
/** `Member::STATUS_REQUEST`: a join request awaiting moderator approval. */
const MEMBER_STATUS_REQUEST = 'Requesting'

/** `SHARES_TYPES_MEMBER_MAP` is built dynamically, so type its shape explicitly. */
const shareTypeToMemberType = SHARES_TYPES_MEMBER_MAP as Record<number, number>

Expand Down Expand Up @@ -48,7 +53,40 @@ interface RawCircle {
displayName: string
description?: string
population?: number
initiator?: { level?: number } | null
config?: number
initiator?: { level?: number, status?: string } | null
}

/** Teams the user is part of, and visible teams they could join. */
export interface TeamsOverview {
teams: Team[]
discoverableTeams: DiscoverableTeam[]
}

/**
* Whether the user's only link to the circle is a pending join request.
*
* @param circle - The raw circle
*/
function isPendingRequest(circle: RawCircle): boolean {
return circle.initiator?.status === MEMBER_STATUS_REQUEST
}

/**
* Map a circle the user is not an active member of.
*
* @param circle - The raw circle
*/
function mapDiscoverableTeam(circle: RawCircle): DiscoverableTeam {
const pending = isPendingRequest(circle)
return {
id: circle.id,
displayName: circle.displayName || circle.name,
description: circle.description ?? '',
memberCount: circle.population ?? 0,
canJoin: !pending && ((circle.config ?? 0) & CIRCLE_CONFIG_OPEN) !== 0,
pending,
}
}

/** Raw team as returned by the dashboard widget endpoint. */
Expand Down Expand Up @@ -136,10 +174,10 @@ function mapFullMember(raw: RawMember): Member {
}

/**
* Fetch all of the current user's teams, merging team metadata (name,
* description, member count, our role) with the members preview and resources.
* Fetch the current user's teams and discoverable teams, merging active team
* metadata (name, description, member count, our role) with previews.
*/
export async function fetchTeams(): Promise<Team[]> {
export async function fetchTeams(): Promise<TeamsOverview> {
const [circlesRes, dashRes, favoritesRes] = await Promise.allSettled([
axios.get<OcsResponse<RawCircle[]>>(generateOcsUrl('apps/circles/circles') + '?limit=-1'),
axios.get<OcsResponse<RawDashboardTeam[]>>(generateOcsUrl('apps/circles/teams/dashboard/widget') + '?limit=200&offset=0'),
Expand All @@ -150,7 +188,12 @@ export async function fetchTeams(): Promise<Team[]> {
if (circlesRes.status === 'rejected') {
throw circlesRes.reason
}
const circles = (circlesRes.value.data.ocs.data ?? []).filter((circle) => circle.initiator)
const allCircles = circlesRes.value.data.ocs.data ?? []
const memberCircles = allCircles.filter((circle) => circle.initiator && !isPendingRequest(circle))
const discoverableTeams = allCircles
.filter((circle) => !circle.initiator || isPendingRequest(circle))
.map(mapDiscoverableTeam)
.sort((a, b) => a.displayName.localeCompare(b.displayName))

// The dashboard only enriches each team with member/resource previews, so
// treat a failure there as "no previews" rather than failing the whole page.
Expand All @@ -169,20 +212,23 @@ export async function fetchTeams(): Promise<Team[]> {
}
const favoritePositions = new Map(favoriteCircleIds.map((circleId, position) => [circleId, position]))

return circles.map((circle) => {
const extra = dashboardById.get(circle.id)
return {
id: circle.id,
displayName: circle.displayName || circle.name,
description: circle.description ?? '',
isFavorite: favoritePositions.has(circle.id),
favoritePosition: favoritePositions.get(circle.id) ?? null,
memberCount: circle.population ?? extra?.members.length ?? 0,
myRole: levelToRole(circle.initiator?.level),
members: (extra?.members ?? []).map(mapPreviewMember),
resources: (extra?.resources ?? []).map(mapResource),
}
})
return {
teams: memberCircles.map((circle) => {
const extra = dashboardById.get(circle.id)
return {
id: circle.id,
displayName: circle.displayName || circle.name,
description: circle.description ?? '',
isFavorite: favoritePositions.has(circle.id),
favoritePosition: favoritePositions.get(circle.id) ?? null,
memberCount: circle.population ?? extra?.members.length ?? 0,
myRole: levelToRole(circle.initiator?.level),
members: (extra?.members ?? []).map(mapPreviewMember),
resources: (extra?.resources ?? []).map(mapResource),
}
}),
discoverableTeams,
}
}

/**
Expand Down Expand Up @@ -257,6 +303,20 @@ export async function leaveTeam(teamId: string): Promise<void> {
)
}

/**
* Join a team, or request to join it when it requires approval.
*
* @param teamId - The team single id
* @return 'requested' when the join awaits approval, otherwise 'joined'
*/
export async function joinTeam(teamId: string): Promise<'joined' | 'requested'> {
const res = await axios.put<OcsResponse<{ status?: string }>>(
generateOcsUrl('apps/circles/circles/{circleId}/join', { circleId: teamId }),
{},
)
return res.data.ocs.data?.status === MEMBER_STATUS_REQUEST ? 'requested' : 'joined'
}

/**
* Delete a team.
*
Expand Down
101 changes: 101 additions & 0 deletions src/teams/components/DiscoverableTeamCard.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
/*!
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

import type { DiscoverableTeam } from '../types.ts'

import { shallowMount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
import DiscoverableTeamCard from './DiscoverableTeamCard.vue'

vi.mock('@nextcloud/l10n', async (importOriginal) => ({
...(await importOriginal<object>()),
t: (_app: string, text: string) => text,
n: (_app: string, _singular: string, _plural: string, count: number) => `${count} members`,
}))

const NcAvatarStub = {
name: 'NcAvatar',
props: ['displayName', 'isNoUser', 'size', 'hideStatus', 'disableMenu', 'disableTooltip'],
template: '<div />',
}

const NcButtonWithIconStub = {
name: 'NcButton',
props: { disabled: Boolean },
template: '<button :disabled="disabled"><slot name="icon" /><slot /></button>',
}

const team: DiscoverableTeam = {
id: 'team-1',
displayName: 'Product team',
description: 'A team for product work',
memberCount: 4,
canJoin: true,
pending: false,
}

describe('DiscoverableTeamCard', () => {
it('emits join when the join button is clicked', async () => {
const wrapper = shallowMount(DiscoverableTeamCard, { props: { team } })
const button = wrapper.findComponent({ name: 'NcButton' })

expect(button.exists()).toBe(true)
await button.trigger('click')
expect(wrapper.emitted('join')).toEqual([[]])
})

it('shows pending state without a join button', () => {
const wrapper = shallowMount(DiscoverableTeamCard, {
props: { team: { ...team, canJoin: false, pending: true } },
})

expect(wrapper.text()).toContain('Request pending')
expect(wrapper.findComponent({ name: 'NcButton' }).exists()).toBe(false)
})

it('shows neither a pending state nor a join button when closed', () => {
const wrapper = shallowMount(DiscoverableTeamCard, {
props: { team: { ...team, canJoin: false } },
})

expect(wrapper.text()).not.toContain('Request pending')
expect(wrapper.findComponent({ name: 'NcButton' }).exists()).toBe(false)
})

it('disables joining and shows a loading icon while the request is in flight', () => {
const wrapper = shallowMount(DiscoverableTeamCard, {
props: { team, joining: true },
global: { stubs: { NcButton: NcButtonWithIconStub } },
})
const button = wrapper.findComponent({ name: 'NcButton' })

expect(button.props('disabled')).toBe(true)
expect(wrapper.findComponent({ name: 'NcLoadingIcon' }).exists()).toBe(true)
})

it('disables joining without a loading icon when another request is in flight', () => {
const wrapper = shallowMount(DiscoverableTeamCard, {
props: { team, disabled: true },
global: { stubs: { NcButton: NcButtonWithIconStub } },
})
const button = wrapper.findComponent({ name: 'NcButton' })

expect(button.props('disabled')).toBe(true)
expect(wrapper.findComponent({ name: 'NcLoadingIcon' }).exists()).toBe(false)
})

it('has no link and gives NcAvatar no member-only props', () => {
const wrapper = shallowMount(DiscoverableTeamCard, {
props: { team },
global: { stubs: { NcAvatar: NcAvatarStub } },
})
const avatar = wrapper.findComponent({ name: 'NcAvatar' })

expect(wrapper.find('a').exists()).toBe(false)
expect(wrapper.findComponent({ name: 'RouterLink' }).exists()).toBe(false)
expect(avatar.vm.$attrs).not.toHaveProperty('url')
expect(avatar.vm.$attrs).not.toHaveProperty('user')
})
})
Loading
Loading