Skip to content

Commit 1b4243f

Browse files
committed
fix(NavigationManager): resolve entries only when needed
The `init` method previously contained two different logics: 1. It set up the internal state of default apps and app order 2. It resolved the app navigation entries The 1. is needed before `add` can be called, so it was always called by the `add` method, but this also resolved all appinfo.xml entries on the first `add` call even if never used. The 2. is only needed when the navigations are actually fetched. This splits the logic into two functions: - `init` for the bare initialization - `resolveAppNavigationEntries` for resolving the entries when requesting to output them. This should give a small performance improvement for API calls and fixes a problem when navigations are added before all apps are registered. Signed-off-by: Ferdinand Thiessen <opensource@fthiessen.de>
1 parent d108e81 commit 1b4243f

2 files changed

Lines changed: 82 additions & 25 deletions

File tree

cypress/e2e/core/header_app-menu.cy.ts

Lines changed: 40 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,20 +12,18 @@ const getAppMenu = () => getNextcloudHeader().find('.app-menu')
1212
// the next-best stable selectors.
1313
const getWaffleTrigger = () => getAppMenu().find('.app-menu__waffle')
1414

15-
describe('Header: App menu (waffle launcher)', { testIsolation: true }, () => {
16-
beforeEach(() => {
17-
clearState()
18-
})
15+
before(clearState)
1916

20-
describe('Open and click', () => {
17+
describe('Header: App menu (waffle launcher)', { testIsolation: true }, () => {
18+
describe('Normal user', () => {
2119
beforeEach(() => {
2220
cy.createRandomUser().then(($user) => {
2321
cy.login($user)
2422
cy.visit('/')
2523
})
2624
})
2725

28-
it('opens the popover and navigates when a tile is clicked', () => {
26+
it('Open and click opens the popover and navigates when a tile is clicked', () => {
2927
getWaffleTrigger().click()
3028
cy.get('.app-menu__popover').should('be.visible')
3129
getWaffleTrigger().should('have.attr', 'aria-expanded', 'true')
@@ -39,9 +37,16 @@ describe('Header: App menu (waffle launcher)', { testIsolation: true }, () => {
3937
cy.location('pathname').should('include', '/apps/')
4038
})
4139
})
40+
41+
it('has all correct app navigation items', () => {
42+
waffleMenuShouldContainApps([
43+
{ name: 'Files', href: '/apps/files' },
44+
{ name: 'Dashboard', href: '/apps/dashboard' },
45+
])
46+
})
4247
})
4348

44-
describe('Admin gating: "More apps" tile', () => {
49+
describe('Admin', () => {
4550
const admin = new User('admin', 'admin')
4651

4752
beforeEach(() => {
@@ -54,5 +59,33 @@ describe('Header: App menu (waffle launcher)', { testIsolation: true }, () => {
5459
cy.get('.app-menu__popover').should('be.visible')
5560
cy.findByRole('menuitem', { name: 'More apps' }).should('be.visible')
5661
})
62+
63+
it('has all correct app navigation items', () => {
64+
waffleMenuShouldContainApps([
65+
{ name: 'Files', href: '/apps/files' },
66+
{ name: 'Dashboard', href: '/apps/dashboard' },
67+
{ name: 'Appstore', href: '/settings/apps' },
68+
])
69+
})
5770
})
5871
})
72+
73+
/**
74+
* Check that the waffle menu contains the given apps, by name and href.
75+
*
76+
* @param apps - The apps that should be present in the waffle menu, with their expected name and href.
77+
*/
78+
function waffleMenuShouldContainApps(apps: { name: string, href: string }[]) {
79+
getWaffleTrigger().click()
80+
getWaffleTrigger().should('have.attr', 'aria-expanded', 'true')
81+
cy.findByRole('menu', { name: 'Apps' }).should('be.visible')
82+
83+
cy.findAllByRole('menuitem')
84+
.then((items) => {
85+
apps.forEach((app) => {
86+
const item = items.toArray().find((i) => i.textContent?.includes(app.name))
87+
expect(item, `App menu should contain ${app.name}`).to.exist
88+
expect(item?.getAttribute('href')).to.match(new RegExp(`${app.href}(\\?.+|/?$)`))
89+
})
90+
})
91+
}

lib/private/NavigationManager.php

Lines changed: 42 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@ class NavigationManager implements INavigationManager {
3737
protected bool $init = false;
3838
/** User defined app order (cached for the `add` function) */
3939
private ?array $customAppOrder = null;
40+
/** List of loaded app info */
41+
private array $loadedAppInfo = [];
4042

4143
public function __construct(
4244
protected IAppManager $appManager,
@@ -56,7 +58,7 @@ public function add(array|callable $entry): void {
5658
$this->closureEntries[] = $entry;
5759
return;
5860
}
59-
$this->init(false);
61+
$this->init();
6062

6163
$id = $entry['id'];
6264

@@ -99,7 +101,7 @@ private function updateDefaultEntries(): void {
99101

100102
#[Override]
101103
public function getAll(string $type = 'link'): array {
102-
$this->init();
104+
$this->resolveAppNavigationEntries();
103105

104106
$result = $this->entries;
105107
if ($type !== 'all') {
@@ -180,7 +182,16 @@ public function getActiveEntry(): ?string {
180182
return $this->activeEntry;
181183
}
182184

183-
private function init(bool $resolveClosures = true): void {
185+
/**
186+
* Initialize the internal state.
187+
* This loads the default app mapping and user mapping for app ordering.
188+
*/
189+
private function init(): void {
190+
if ($this->init) {
191+
return;
192+
}
193+
$this->init = true;
194+
184195
if ($this->customAppOrder === null) {
185196
if ($this->userSession->isLoggedIn()) {
186197
$user = $this->userSession->getUser();
@@ -189,21 +200,23 @@ private function init(bool $resolveClosures = true): void {
189200
$this->customAppOrder = [];
190201
}
191202
}
203+
}
192204

193-
if ($resolveClosures) {
194-
while ($c = array_pop($this->closureEntries)) {
195-
$this->add($c());
196-
}
205+
/**
206+
* Resolve the app navigation entries from closures and info.xml files.
207+
*/
208+
private function resolveAppNavigationEntries(): void {
209+
// Resolve app navigation closures
210+
while ($c = array_pop($this->closureEntries)) {
211+
$this->add($c());
197212
}
198213

199-
if ($this->init) {
200-
return;
214+
// Resolve dynamically added navigation entries via event listeners
215+
if ($this->loadedAppInfo === []) {
216+
$this->eventDispatcher->dispatchTyped(new LoadAdditionalEntriesEvent());
201217
}
202-
$this->init = true;
203-
204-
$l = $this->l10nFac->get('lib');
205-
$this->eventDispatcher->dispatchTyped(new LoadAdditionalEntriesEvent());
206218

219+
// Resolve classic info.xml based navigation entries
207220
if ($this->userSession->isLoggedIn()) {
208221
$user = $this->userSession->getUser();
209222
$apps = $this->appManager->getEnabledAppsForUser($user);
@@ -212,6 +225,11 @@ private function init(bool $resolveClosures = true): void {
212225
}
213226

214227
foreach ($apps as $app) {
228+
// skip already loaded apps
229+
if (in_array($app, $this->loadedAppInfo)) {
230+
continue;
231+
}
232+
215233
// load plugins and collections from info.xml
216234
$info = $this->appManager->getAppInfo($app);
217235
if (!isset($info['navigations']['navigation'])) {
@@ -230,7 +248,6 @@ private function init(bool $resolveClosures = true): void {
230248
if ($role === 'admin' && !$this->isAdmin()) {
231249
continue;
232250
}
233-
$l = $this->l10nFac->get($app);
234251
$id = $nav['id'] ?? $app . ($key === 0 ? '' : $key);
235252
$order = $nav['order'] ?? 100;
236253
$type = $nav['type'];
@@ -249,7 +266,14 @@ private function init(bool $resolveClosures = true): void {
249266
if ($icon === null) {
250267
$icon = $this->urlGenerator->imagePath('core', 'places/default-app-icon.svg');
251268
}
269+
if ($type === 'link' && $route === '') {
270+
// This means either the route is invalid in the info.xml or the app was not year loaded by the router
271+
$this->logger->debug('Missing or invalid navigation route for app ' . $app, ['entry' => $nav]);
272+
continue;
273+
}
252274

275+
$l = $this->l10nFac->get($app);
276+
$this->loadedAppInfo[] = $app;
253277
$this->add(array_merge([
254278
// Navigation id
255279
'id' => $id,
@@ -287,13 +311,13 @@ public function setUnreadCounter(string $id, int $unreadCounter): void {
287311

288312
#[Override]
289313
public function get(string $id): ?array {
290-
$this->init();
314+
$this->resolveAppNavigationEntries();
291315
return $this->entries[$id];
292316
}
293317

294318
#[Override]
295319
public function getDefaultEntryIdForUser(?IUser $user = null, bool $withFallbacks = true): string {
296-
$this->init();
320+
$this->resolveAppNavigationEntries();
297321
// Disable fallbacks here, as we need to override them with the user defaults if none are configured.
298322
$defaultEntryIds = $this->getDefaultEntryIds(false);
299323

@@ -335,7 +359,7 @@ public function getDefaultEntryIdForUser(?IUser $user = null, bool $withFallback
335359

336360
#[Override]
337361
public function getDefaultEntryIds(bool $withFallbacks = true): array {
338-
$this->init();
362+
$this->resolveAppNavigationEntries();
339363
$storedIds = explode(',', $this->config->getSystemValueString('defaultapp', $withFallbacks ? 'dashboard,files' : ''));
340364
$ids = [];
341365
$entryIds = array_keys($this->entries);
@@ -349,7 +373,7 @@ public function getDefaultEntryIds(bool $withFallbacks = true): array {
349373

350374
#[Override]
351375
public function setDefaultEntryIds(array $ids): void {
352-
$this->init();
376+
$this->resolveAppNavigationEntries();
353377
$entryIds = array_keys($this->entries);
354378

355379
foreach ($ids as $id) {

0 commit comments

Comments
 (0)