Skip to content
Open
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 build/frontend-legacy/webpack.modules.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -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.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'),
Expand Down
39 changes: 39 additions & 0 deletions core/src/appmenu.ts
Original file line number Diff line number Diff line change
@@ -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 './components/AppMenu/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()
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@
role="menu"
:aria-label="t('core', 'Apps')">
<div ref="grid" class="app-menu__grid" @keydown="onGridKeydown">
<AppItem
<AppMenuItem
v-for="(item, i) in gridItems"
:key="item.id"
ref="items"
Expand Down Expand Up @@ -69,7 +69,7 @@
</template>

<script lang="ts">
import type { INavigationEntry } from '../types/navigation.d.ts'
import type { INavigationEntry } from '../../types/navigation.d.ts'

import { getCurrentUser } from '@nextcloud/auth'
import { subscribe, unsubscribe } from '@nextcloud/event-bus'
Expand All @@ -80,8 +80,8 @@ import { defineComponent, ref } from 'vue'
import NcButton from '@nextcloud/vue/components/NcButton'
import NcPopover from '@nextcloud/vue/components/NcPopover'
import IconDotsGrid from 'vue-material-design-icons/DotsGrid.vue'
import AppItem from './AppItem.vue'
import logger from '../logger.js'
import AppMenuItem from './AppMenuItem.vue'
import logger from '../../logger.js'

// Settings IDs that represent actions, not navigable pages.
const SETTINGS_ACTION_IDS = new Set(['logout'])
Expand All @@ -90,7 +90,7 @@ export default defineComponent({
name: 'AppMenu',

components: {
AppItem,
AppMenuItem,
IconDotsGrid,
NcButton,
NcPopover,
Expand Down Expand Up @@ -509,7 +509,7 @@ export default defineComponent({

// Extra top padding on first-row tiles so the hover bg reads
// concentric with the popover's rounded top corner. !important
// because AppItem's scoped rule has the same specificity.
// because AppMenuItem's scoped rule has the same specificity.
> :nth-child(-n+4) {
padding-block-start: calc(var(--default-grid-baseline) * 2) !important;
}
Expand Down Expand Up @@ -538,7 +538,7 @@ export default defineComponent({
margin-block-start: -1px;
}

// Without this reset the override above cascades into AppItem and inflates
// Without this reset the override above cascades into AppMenuItem and inflates
// its hover radius. Restores the system default from apps/theming/css/default.css.
.app-menu__popover-base .app-menu__popover {
--border-radius-element: 8px;
Expand Down
Comment thread
pringelmann marked this conversation as resolved.
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@
</template>

<script setup lang="ts">
import type { INavigationEntry } from '../types/navigation.d.ts'
import type { INavigationEntry } from '../../types/navigation.d.ts'

import { n } from '@nextcloud/l10n'
import { computed } from 'vue'
Expand Down
35 changes: 0 additions & 35 deletions core/src/components/MainMenu.js

This file was deleted.

2 changes: 0 additions & 2 deletions core/src/init.js
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -46,7 +45,6 @@ export function initCore() {

initSessionHeartBeat()

setUpMainMenu()
setUpUserMenu()
setUpContactsMenu()
}
67 changes: 67 additions & 0 deletions core/src/tests/appmenu.spec.ts
Original file line number Diff line number Diff line change
@@ -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('core: appmenu', () => {
beforeEach(() => {
document.body.innerHTML = ''
globalThis.OC = {}
vi.resetModules()
})

it('mounts AppMenu when the container is present', async () => {
addContainer()

await import('../appmenu.ts')

// Vue 2 $mount replaces the container with AppMenu's root <nav class="app-menu">.
expect(document.querySelector('.app-menu')).not.toBeNull()
})

it('no-ops when the container is missing', async () => {
await import('../appmenu.ts')

expect(document.body.children.length).toBe(0)
})

it('exposes OC.setNavigationCounter as a callable function', async () => {
addContainer()

await import('../appmenu.ts')

expect(typeof globalThis.OC.setNavigationCounter).toBe('function')
})
})
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 { INavigationEntry } from '../../types/navigation.d.ts'
import type { INavigationEntry } from '../../../types/navigation.d.ts'

import { mount } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
Expand Down Expand Up @@ -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 '../../../components/AppMenu/AppMenu.vue'
let AppMenu: typeof AppMenuModule

beforeEach(async () => {
Expand All @@ -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('../../../components/AppMenu/AppMenu.vue')).default
})

afterEach(() => {
Expand All @@ -109,7 +109,7 @@ async function openPopover(wrapper: ReturnType<typeof mount>) {
}

describe('core: AppMenu', () => {
it('renders one AppItem per app in the list, plus the "App store" tile for non-admins', async () => {
it('renders one AppMenuItem per app in the list, plus the "App store" tile for non-admins', async () => {
const wrapper = mount(AppMenu, { attachTo: document.body })
await openPopover(wrapper)

Expand Down
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 { INavigationEntry } from '../../types/navigation.d.ts'
import type { INavigationEntry } from '../../../types/navigation.d.ts'

import { mount } from '@vue/test-utils'
import { describe, expect, it, vi } from 'vitest'
Expand All @@ -17,7 +17,7 @@ vi.mock('@nextcloud/l10n', () => ({
},
}))

import AppItem from '../../components/AppItem.vue'
import AppMenuItem from '../../../components/AppMenu/AppMenuItem.vue'

function makeApp(overrides: Partial<INavigationEntry> = {}): INavigationEntry {
return {
Expand All @@ -33,14 +33,14 @@ function makeApp(overrides: Partial<INavigationEntry> = {}): INavigationEntry {
}
}

describe('core: AppItem', () => {
describe('core: AppMenuItem', () => {
it('renders the label', () => {
const wrapper = mount(AppItem, { propsData: { app: makeApp({ name: 'Files' }) } })
const wrapper = mount(AppMenuItem, { propsData: { app: makeApp({ name: 'Files' }) } })
expect(wrapper.text()).toContain('Files')
})

it('active app has aria-current="page"', () => {
const wrapper = mount(AppItem, { propsData: { app: makeApp({ active: true }) } })
const wrapper = mount(AppMenuItem, { propsData: { app: makeApp({ active: true }) } })
expect(wrapper.attributes('aria-current')).toBe('page')
})
})
2 changes: 2 additions & 0 deletions lib/private/TemplateLayout.php
Original file line number Diff line number Diff line change
Expand Up @@ -92,6 +92,8 @@ public function getPageTemplate(string $renderAs, string $appId): ITemplate {
Util::addScript('core', 'unified-search', 'core');
}

Util::addScript('core', 'appmenu', 'core');

// Set logo link target
$logoUrl = $this->config->getSystemValueString('logo_url', '');
$page->assign('logoUrl', $logoUrl);
Expand Down
Loading