Skip to content

Commit ead4695

Browse files
committed
feat: implement team upgrade banner and remove legacy folder share
Signed-off-by: Stefan Dietrich <stefan.dietrich@dataport.de>
1 parent a70f5bc commit ead4695

6 files changed

Lines changed: 591 additions & 155 deletions

File tree

lib/Controller/PageController.php

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,14 +10,18 @@
1010
namespace OCA\Circles\Controller;
1111

1212
use OCA\Circles\AppInfo\Application;
13+
use OCA\Circles\ConfigLexicon;
1314
use OCA\Circles\Service\ConfigService;
1415
use OCP\AppFramework\Controller;
1516
use OCP\AppFramework\Http\Attribute\FrontpageRoute;
1617
use OCP\AppFramework\Http\Attribute\NoAdminRequired;
1718
use OCP\AppFramework\Http\Attribute\NoCSRFRequired;
1819
use OCP\AppFramework\Http\NotFoundResponse;
1920
use OCP\AppFramework\Http\TemplateResponse;
21+
use OCP\AppFramework\Services\IAppConfig;
22+
use OCP\AppFramework\Services\IInitialState;
2023
use OCP\IRequest;
24+
use OCP\Teams\ITeamManager;
2125
use OCP\Util;
2226

2327
/**
@@ -27,6 +31,9 @@ class PageController extends Controller {
2731
public function __construct(
2832
IRequest $request,
2933
private ConfigService $configService,
34+
private IAppConfig $appConfig,
35+
private IInitialState $initialState,
36+
private ITeamManager $teamManager,
3037
) {
3138
parent::__construct(Application::APP_ID, $request);
3239
}
@@ -41,6 +48,14 @@ public function index(): TemplateResponse|NotFoundResponse {
4148
return new NotFoundResponse();
4249
}
4350

51+
$this->initialState->provideInitialState(
52+
'teamFolderAutoCreate',
53+
$this->appConfig->getAppValueBool(ConfigLexicon::TEAM_FOLDER_AUTO_CREATE, true),
54+
);
55+
/** @psalm-suppress UndefinedInterfaceMethod -- ITeamManager::getTeamFolderProvider() is @since 35.0.0 */
56+
$providerAvailable = $this->teamManager->getTeamFolderProvider() !== null;
57+
$this->initialState->provideInitialState('teamFolderProviderAvailable', $providerAvailable);
58+
4459
Util::addScript(Application::APP_ID, 'teams-main');
4560
Util::addStyle(Application::APP_ID, 'teams-main');
4661

src/components/AdminTeamFolders.vue

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -154,9 +154,7 @@ async function onSaveQuota() {
154154
{{ t('circles', 'Automatically create a team space') }}
155155
</NcCheckboxRadioSwitch>
156156

157-
<div
158-
v-show="teamFolderAutoCreate"
159-
class="team-folders__sub-section">
157+
<div class="team-folders__sub-section">
160158
<div class="team-folders__input-row">
161159
<NcSelect
162160
v-model="selectedQuota"
@@ -184,7 +182,6 @@ async function onSaveQuota() {
184182
<style scoped>
185183
.team-folders__sub-section {
186184
margin-top: 12px;
187-
margin-left: 44px;
188185
display: flex;
189186
flex-direction: column;
190187
gap: 8px;
Lines changed: 213 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,213 @@
1+
/*!
2+
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
3+
* SPDX-License-Identifier: AGPL-3.0-or-later
4+
*/
5+
6+
import type { TeamFolder } from '../../api.ts'
7+
8+
import { shallowMount } from '@vue/test-utils'
9+
import { beforeEach, describe, expect, it, vi } from 'vitest'
10+
import Circle from '../models/circle.ts'
11+
import CircleDetails from './CircleDetails.vue'
12+
13+
/**
14+
* The component reads these at construction time (in data()), so the mock
15+
* values must be in place before mount. `vi.hoisted` keeps them available to
16+
* the factory mock below.
17+
*/
18+
const loadState = vi.hoisted(() => vi.fn((app: string, key: string, fallback: unknown) => fallback))
19+
const getTeamFolder = vi.hoisted(() => vi.fn<(teamId: string) => Promise<TeamFolder | null>>(async () => null))
20+
const upgradeTeamFolder = vi.hoisted(() => vi.fn<(teamId: string) => Promise<TeamFolder>>(async () => ({ id: 1, mountPoint: 'Team' })))
21+
22+
vi.mock('@nextcloud/initial-state', () => ({ loadState }))
23+
vi.mock('@nextcloud/auth', () => ({
24+
getCurrentUser: () => ({ uid: 'owner-1', isAdmin: false }),
25+
}))
26+
vi.mock('@nextcloud/router', async (importOriginal) => {
27+
const actual = await importOriginal<typeof import('@nextcloud/router')>()
28+
return {
29+
...actual,
30+
generateUrl: (tpl: string) => tpl,
31+
generateOcsUrl: (tpl: string) => tpl,
32+
generateRemoteUrl: (tpl: string) => tpl,
33+
}
34+
})
35+
vi.mock('@nextcloud/axios', () => ({
36+
default: {
37+
get: vi.fn(async () => ({ data: { ocs: { data: { resources: [] } } } })),
38+
post: vi.fn(async () => ({ data: { ocs: { data: {} } } })),
39+
request: vi.fn(async () => ({ data: {} })),
40+
},
41+
}))
42+
vi.mock('@nextcloud/dialogs', () => ({
43+
FilePickerClosed: {},
44+
FilePickerType: { Choose: 1 },
45+
getFilePickerBuilder: () => ({
46+
setMultiSelect: () => ({ setMimeTypeFilter: () => ({ setType: () => ({ allowDirectories: () => ({ build: () => ({}) }) }) }) }),
47+
}),
48+
showError: vi.fn(),
49+
showSuccess: vi.fn(),
50+
}))
51+
vi.mock('@nextcloud/event-bus', () => ({ emit: vi.fn() }))
52+
vi.mock('../../api.ts', () => ({ getTeamFolder, upgradeTeamFolder }))
53+
54+
/**
55+
* Build a Circle instance with the fields the component under test needs.
56+
*
57+
* @param overrides - partial raw data merged over the defaults
58+
*/
59+
function makeCircle(overrides: Record<string, unknown> = {}): Circle {
60+
return new Circle({
61+
id: 'team-1',
62+
displayName: 'Marketing',
63+
description: '',
64+
creation: 0,
65+
population: 1,
66+
populationInherited: 1,
67+
config: 0,
68+
settings: {},
69+
owner: { singleId: 'owner-1', userId: 'owner-1', displayName: 'Owner', level: 9, type: 1, isUser: true, instance: '', source: '' },
70+
initiator: { singleId: 'owner-1', userId: 'owner-1', displayName: 'Owner', level: 9, type: 1, isUser: true, instance: '', source: '' },
71+
...overrides,
72+
})
73+
}
74+
75+
const MEMBER_INITIATOR = { singleId: 'm-1', userId: 'm-1', displayName: 'Member', level: 1, type: 1, isUser: true, instance: '', source: '' }
76+
77+
/**
78+
* Mount the component with owner-level permissions and explicit feature flags.
79+
*
80+
* @param overrides - circle raw-data overrides (e.g. initiator level)
81+
* @param flags - feature flags for loadState + optional pre-linked team folder
82+
*/
83+
function mountDetails(
84+
overrides: Record<string, unknown> = {},
85+
flags: { autoCreate?: boolean; providerAvailable?: boolean; teamFolder?: TeamFolder | null } = {},
86+
) {
87+
loadState.mockImplementation((app: string, key: string, fallback: unknown) => {
88+
if (app !== 'circles') return fallback
89+
if (key === 'teamFolderAutoCreate') return flags.autoCreate ?? true
90+
if (key === 'teamFolderProviderAvailable') return flags.providerAvailable ?? true
91+
return fallback
92+
})
93+
getTeamFolder.mockImplementation(async () => flags.teamFolder ?? null)
94+
const circle = makeCircle(overrides)
95+
return shallowMount(CircleDetails, {
96+
props: { circle },
97+
global: {
98+
mocks: {
99+
t: (pkg: string, text: string, vars?: Record<string, unknown>) => {
100+
if (vars && typeof text === 'string') {
101+
return text.replace(/\{(\w+)\}/g, (_, k: string) => String(vars[k] ?? `{${k}}`))
102+
}
103+
return text
104+
},
105+
$store: {
106+
getters: { getCircle: () => null },
107+
dispatch: vi.fn(),
108+
commit: vi.fn(),
109+
},
110+
$router: { push: vi.fn(), resolve: () => ({ href: '#' }) },
111+
},
112+
stubs: {
113+
// Render NcNoteCard and NcButton slot/text content so banner
114+
// text is visible in the rendered HTML.
115+
NcNoteCard: {
116+
template: '<div class="nc-notecard-stub"><slot /></div>',
117+
},
118+
NcButton: {
119+
template: '<button class="nc-button-stub"><slot /></button>',
120+
},
121+
},
122+
},
123+
})
124+
}
125+
126+
describe('CircleDetails folder button', () => {
127+
beforeEach(() => {
128+
vi.clearAllMocks()
129+
})
130+
131+
it('renders no folder button in the Create section (banner handles it)', () => {
132+
const wrapper = mountDetails({}, { autoCreate: true, providerAvailable: true })
133+
expect(wrapper.vm.folderButtonType).toBeNull()
134+
// The Create section has Talk/Collective/Calendar buttons but no 'teamfolder'
135+
const buttons = wrapper.findAllComponents({ name: 'TeamResourceButton' })
136+
const ids = buttons.map((b) => b.props('resourceType')?.id)
137+
expect(ids).not.toContain('teamfolder')
138+
expect(ids).not.toContain('folder')
139+
})
140+
141+
it('renders no folder button when a team folder already exists', async () => {
142+
const wrapper = mountDetails({}, {
143+
autoCreate: true,
144+
providerAvailable: true,
145+
teamFolder: { id: 42, mountPoint: 'Marketing' },
146+
})
147+
await vi.waitFor(() => expect(wrapper.vm.teamFolder).not.toBeNull())
148+
expect(wrapper.vm.folderButtonType).toBeNull()
149+
})
150+
})
151+
152+
describe('CircleDetails team folder upgrade banner', () => {
153+
beforeEach(() => {
154+
vi.clearAllMocks()
155+
})
156+
157+
it('renders the banner with create text for owners when provider is available', async () => {
158+
const wrapper = mountDetails({}, { autoCreate: true, providerAvailable: true })
159+
await vi.waitFor(() => expect(wrapper.vm.loadingTeamFolder).toBe(false))
160+
161+
expect(wrapper.vm.showTeamFolderBanner).toBe(true)
162+
const html = wrapper.html()
163+
expect(html).toContain('Create one to share files with the whole team.')
164+
expect(html).toContain('Create team folder')
165+
})
166+
167+
it('renders the banner with "ask owner" text for non-owners', async () => {
168+
const wrapper = mountDetails({ initiator: MEMBER_INITIATOR }, { autoCreate: true, providerAvailable: true })
169+
await vi.waitFor(() => expect(wrapper.vm.loadingTeamFolder).toBe(false))
170+
171+
const html = wrapper.html()
172+
expect(html).toContain('Ask a team owner to create one.')
173+
expect(html).not.toContain('Create team folder')
174+
})
175+
176+
it('renders the banner with "ask admin" text when provider is missing', async () => {
177+
const wrapper = mountDetails({}, { autoCreate: true, providerAvailable: false })
178+
await vi.waitFor(() => expect(wrapper.vm.loadingTeamFolder).toBe(false))
179+
180+
const html = wrapper.html()
181+
expect(html).toContain('Ask your administrator to enable the Team Folders app.')
182+
expect(html).not.toContain('Create team folder')
183+
})
184+
185+
it('hides the banner when a team folder exists', async () => {
186+
const wrapper = mountDetails({}, {
187+
autoCreate: true,
188+
providerAvailable: true,
189+
teamFolder: { id: 42, mountPoint: 'Marketing' },
190+
})
191+
await vi.waitFor(() => expect(wrapper.vm.teamFolder).not.toBeNull())
192+
193+
expect(wrapper.vm.showTeamFolderBanner).toBe(false)
194+
const html = wrapper.html()
195+
expect(html).not.toContain('This team does not have a team folder yet.')
196+
})
197+
198+
it('hides the banner while loading', () => {
199+
const wrapper = mountDetails({}, { autoCreate: true, providerAvailable: true })
200+
// loadTeamFolder is in-flight; loadingTeamFolder is true
201+
expect(wrapper.vm.loadingTeamFolder).toBe(true)
202+
expect(wrapper.vm.showTeamFolderBanner).toBe(false)
203+
})
204+
205+
it('shows the banner even when auto-create is off (owner can still create)', async () => {
206+
const wrapper = mountDetails({}, { autoCreate: false, providerAvailable: true })
207+
await vi.waitFor(() => expect(wrapper.vm.loadingTeamFolder).toBe(false))
208+
209+
const html = wrapper.html()
210+
expect(html).toContain('Create one to share files with the whole team.')
211+
expect(html).toContain('Create team folder')
212+
})
213+
})

0 commit comments

Comments
 (0)