diff --git a/build/frontend-legacy/webpack.modules.cjs b/build/frontend-legacy/webpack.modules.cjs index f560508674162..5f574eef3359d 100644 --- a/build/frontend-legacy/webpack.modules.cjs +++ b/build/frontend-legacy/webpack.modules.cjs @@ -11,6 +11,7 @@ module.exports = { login: path.join(__dirname, 'core/src', 'login.js'), login_flow: path.join(__dirname, 'core/src', 'login-flow.ts'), main: path.join(__dirname, 'core/src', 'main.js'), + appmenu: path.join(__dirname, 'core/src/appmenu', 'main.ts'), maintenance: path.join(__dirname, 'core/src', 'maintenance.js'), 'public-page-menu': path.resolve(__dirname, 'core/src', 'public-page-menu.ts'), 'public-page-user-menu': path.resolve(__dirname, 'core/src', 'public-page-user-menu.ts'), diff --git a/core/src/components/AppItem.vue b/core/src/appmenu/AppItem.vue similarity index 100% rename from core/src/components/AppItem.vue rename to core/src/appmenu/AppItem.vue diff --git a/core/src/components/AppMenu.vue b/core/src/appmenu/AppMenu.vue similarity index 100% rename from core/src/components/AppMenu.vue rename to core/src/appmenu/AppMenu.vue diff --git a/core/src/appmenu/main.ts b/core/src/appmenu/main.ts new file mode 100644 index 0000000000000..659a5819c7530 --- /dev/null +++ b/core/src/appmenu/main.ts @@ -0,0 +1,39 @@ +/*! + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * Standalone entry for the waffle launcher (AppMenu). Mounts independently of + * core-main so the app grid lives in its own chunk. + */ +import Vue from 'vue' +import AppMenu from './AppMenu.vue' + +interface AppMenuInstance { + setNavigationCounter(id: string, counter: number): void +} + +declare global { + var OC: { + setNavigationCounter?: (id: string, counter: number) => void + } +} + +/** + * Mount the AppMenu into the header container, if present on this layout. + */ +function mount(): void { + const container = document.getElementById('header-start__appmenu') + if (!container) { + // No container on this layout (e.g. public pages). Nothing to mount. + return + } + const AppMenuApp = Vue.extend(AppMenu) + const instance = new AppMenuApp({}).$mount(container) as unknown as AppMenuInstance + + globalThis.OC = globalThis.OC ?? {} + globalThis.OC.setNavigationCounter = (id, counter) => { + instance.setNavigationCounter(id, counter) + } +} + +mount() diff --git a/core/src/components/MainMenu.js b/core/src/components/MainMenu.js deleted file mode 100644 index 4ec61d94aead7..0000000000000 --- a/core/src/components/MainMenu.js +++ /dev/null @@ -1,35 +0,0 @@ -/** - * SPDX-FileCopyrightText: 2019 Nextcloud GmbH and Nextcloud contributors - * SPDX-License-Identifier: AGPL-3.0-or-later - */ - -import { translatePlural as n, translate as t } from '@nextcloud/l10n' -import Vue from 'vue' -import AppMenu from './AppMenu.vue' - -/** - * Set up the main menu component ("AppMenu") - * This is the top left menu where users can navigate between different apps. - */ -export function setUp() { - Vue.mixin({ - methods: { - t, - n, - }, - }) - - const container = document.getElementById('header-start__appmenu') - if (!container) { - // no container, possibly we're on a public page - return - } - const AppMenuApp = Vue.extend(AppMenu) - const appMenu = new AppMenuApp({}).$mount(container) - - Object.assign(OC, { - setNavigationCounter(id, counter) { - appMenu.setNavigationCounter(id, counter) - }, - }) -} diff --git a/core/src/init.js b/core/src/init.js index 0bd8ca6d6a75d..1311793567daa 100644 --- a/core/src/init.js +++ b/core/src/init.js @@ -6,7 +6,6 @@ import { getLocale } from '@nextcloud/l10n' import moment from 'moment' import { setUp as setUpContactsMenu } from './components/ContactsMenu.js' -import { setUp as setUpMainMenu } from './components/MainMenu.js' import { setUp as setUpUserMenu } from './components/UserMenu.js' import { initSessionHeartBeat } from './session-heartbeat.ts' import { initFallbackClipboardAPI } from './utils/ClipboardFallback.ts' @@ -46,7 +45,6 @@ export function initCore() { initSessionHeartBeat() - setUpMainMenu() setUpUserMenu() setUpContactsMenu() } diff --git a/core/src/tests/components/AppItem.spec.ts b/core/src/tests/appmenu/AppItem.spec.ts similarity index 96% rename from core/src/tests/components/AppItem.spec.ts rename to core/src/tests/appmenu/AppItem.spec.ts index 4212a11c5f583..6a496a11f5fcd 100644 --- a/core/src/tests/components/AppItem.spec.ts +++ b/core/src/tests/appmenu/AppItem.spec.ts @@ -17,7 +17,7 @@ vi.mock('@nextcloud/l10n', () => ({ }, })) -import AppItem from '../../components/AppItem.vue' +import AppItem from '../../appmenu/AppItem.vue' function makeApp(overrides: Partial = {}): INavigationEntry { return { diff --git a/core/src/tests/components/AppMenu.spec.ts b/core/src/tests/appmenu/AppMenu.spec.ts similarity index 98% rename from core/src/tests/components/AppMenu.spec.ts rename to core/src/tests/appmenu/AppMenu.spec.ts index 33b9f07eb9c9c..5a49884a94ac8 100644 --- a/core/src/tests/components/AppMenu.spec.ts +++ b/core/src/tests/appmenu/AppMenu.spec.ts @@ -78,7 +78,7 @@ function eightApps(activeIndex: number = -1): INavigationEntry[] { // Import AFTER mocks are registered. Static `import` would hoist above // vi.mock() and break the wiring; dynamic import in beforeAll/await is the // idiomatic Vitest workaround when you need to control mock state per test. -import type AppMenuModule from '../../components/AppMenu.vue' +import type AppMenuModule from '../../appmenu/AppMenu.vue' let AppMenu: typeof AppMenuModule beforeEach(async () => { @@ -88,7 +88,7 @@ beforeEach(async () => { } initialState.loadState.mockImplementation((_app: string, key: string, fallback: unknown) => key === 'apps' ? fakeApps() : fallback) auth.getCurrentUser.mockReturnValue({ isAdmin: false }) - AppMenu = (await import('../../components/AppMenu.vue')).default + AppMenu = (await import('../../appmenu/AppMenu.vue')).default }) afterEach(() => { diff --git a/core/src/tests/appmenu/main.spec.ts b/core/src/tests/appmenu/main.spec.ts new file mode 100644 index 0000000000000..1e6483fac57d9 --- /dev/null +++ b/core/src/tests/appmenu/main.spec.ts @@ -0,0 +1,67 @@ +/*! + * SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors + * SPDX-License-Identifier: AGPL-3.0-or-later + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@nextcloud/initial-state', () => ({ + loadState: () => [], +})) +vi.mock('@nextcloud/auth', () => ({ + getCurrentUser: () => ({ isAdmin: false }), +})) +vi.mock('@nextcloud/event-bus', () => ({ + subscribe: () => undefined, + unsubscribe: () => undefined, +})) +vi.mock('@nextcloud/l10n', () => ({ + isRTL: () => false, + n: (_app: string, singular: string) => singular, + t: (_app: string, text: string) => text, +})) +vi.mock('@nextcloud/router', () => ({ + generateUrl: (url: string) => url, + imagePath: (_app: string, file: string) => `/img/${file}`, +})) + +declare global { + var OC: { setNavigationCounter?: (id: string, count: number) => void } +} + +// The id the bootstrap mounts into (must match main.ts). +function addContainer(): void { + const container = document.createElement('nav') + container.id = 'header-start__appmenu' + document.body.appendChild(container) +} + +describe('appmenu/main', () => { + beforeEach(() => { + document.body.innerHTML = '' + globalThis.OC = {} + vi.resetModules() + }) + + it('mounts AppMenu when the container is present', async () => { + addContainer() + + await import('../../appmenu/main.ts') + + // Vue 2 $mount replaces the container with AppMenu's root