refactor(settings): use NcAppNavigation for the settings navigation

Migrate away from jQuery and Snap.js for the navigation.
This is required to finally drop both dependencies.

Signed-off-by: Ferdinand Thiessen <opensource@fthiessen.de>
This commit is contained in:
Ferdinand Thiessen 2026-01-25 14:33:14 +01:00
parent 87022e1ae1
commit 2e4ede0320
No known key found for this signature in database
GPG key ID: 7E849AE05218500F
8 changed files with 152 additions and 132 deletions

View file

@ -19,27 +19,6 @@ input {
clear: both;
}
/* icons for sidebar */
.nav-icon-personal-settings {
@include functions.icon-color('personal', 'settings', variables.$color-black);
}
.nav-icon-security {
@include functions.icon-color('toggle-filelist', 'settings', variables.$color-black);
}
.nav-icon-clientsbox {
@include functions.icon-color('change', 'settings', variables.$color-black);
}
.nav-icon-federated-cloud {
@include functions.icon-color('share', 'settings', variables.$color-black);
}
.nav-icon-second-factor-backup-codes, .nav-icon-ssl-root-certificate {
@include functions.icon-color('password', 'settings', variables.$color-black);
}
#personal-settings-avatar-container {
display: inline-grid;
grid-template-columns: 1fr;
@ -325,20 +304,6 @@ table.nostyle {
}
}
li.active {
.delete,
.rename {
display: block;
}
}
.app-navigation-entry-utils {
.delete,
.rename {
display: none;
}
}
#usersearchform {
position: absolute;
top: 2px;
@ -424,26 +389,6 @@ span.usersLastLoginTooltip {
white-space: nowrap;
}
/* SETTINGS NAVIGATION */
#app-navigation {
/* Navigation icons */
img {
margin-bottom: -3px;
margin-inline-end: 6px;
width: 16px;
}
li span.no-icon {
padding-inline-start: 32px;
}
ul li.active > span.utils {
.delete, .rename {
display: block;
}
}
}
/* SETTINGS SECTIONS */
// to match with NcSettingsSection component
.section {

View file

@ -49,22 +49,10 @@ trait CommonSettingsTrait {
/** @var IInitialState */
private $initialState;
/**
* @return array{forms: array{personal: array, admin: array}}
*/
private function getNavigationParameters(string $currentType, string $currentSection): array {
return [
'forms' => [
'personal' => $this->formatPersonalSections($currentType, $currentSection),
'admin' => $this->formatAdminSections($currentType, $currentSection),
],
];
}
/**
* @param IIconSection[][] $sections
* @psalm-param 'admin'|'personal' $type
* @return list<array{anchor: string, section-name: string, active: bool, icon: string}>
* @return list<array{id: string, name: string, active: bool, icon: string}>
*/
protected function formatSections(array $sections, string $currentSection, string $type, string $currentType): array {
$templateParameters = [];
@ -89,8 +77,8 @@ trait CommonSettingsTrait {
&& $type === $currentType;
$templateParameters[] = [
'anchor' => $section->getID(),
'section-name' => $section->getName(),
'id' => $section->getID(),
'name' => $section->getName(),
'active' => $active,
'icon' => $icon,
];
@ -99,11 +87,17 @@ trait CommonSettingsTrait {
return $templateParameters;
}
/**
* @return list<array{id: string, name: string, active: bool, icon: string}>
*/
protected function formatPersonalSections(string $currentType, string $currentSection): array {
$sections = $this->settingsManager->getPersonalSections();
return $this->formatSections($sections, $currentSection, 'personal', $currentType);
}
/**
* @return list<array{id: string, name: string, active: bool, icon: string}>
*/
protected function formatAdminSections(string $currentType, string $currentSection): array {
$sections = $this->settingsManager->getAdminSections();
return $this->formatSections($sections, $currentSection, 'admin', $currentType);
@ -175,9 +169,13 @@ trait CommonSettingsTrait {
$this->initialState->provideInitialState('declarative-settings-forms', $declarativeSettings);
}
$this->initialState->provideInitialState('sections', [
'personal' => $this->formatPersonalSections($type, $section),
'admin' => $this->formatAdminSections($type, $section),
]);
$settings = array_merge(...$settings);
$templateParams = $this->formatSettings($settings, $declarativeSettings);
$templateParams = array_merge($templateParams, $this->getNavigationParameters($type, $section));
$activeSection = $this->settingsManager->getSection($type, $section);
if ($activeSection) {
@ -186,6 +184,7 @@ trait CommonSettingsTrait {
$templateParams['activeSectionType'] = $type;
}
Util::addScript(Application::APP_ID, 'main', prepend: true);
return new TemplateResponse('settings', 'settings/frame', $templateParams);
}
}

View file

@ -0,0 +1,54 @@
<!--
- SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<script setup lang="ts">
import { generateUrl } from '@nextcloud/router'
import { computed } from 'vue'
import NcAppNavigationItem from '@nextcloud/vue/components/NcAppNavigationItem'
export interface ISettingsSection {
id: string
name: string
icon?: string
active: boolean
}
const props = defineProps<{
section: ISettingsSection
type: 'admin' | 'personal'
}>()
const href = computed(() => generateUrl('/settings/{type}/{section}', {
type: props.type === 'personal' ? 'user' : 'admin',
section: props.section.id,
}))
</script>
<template>
<NcAppNavigationItem
:name="section.name"
:active="section.active"
:href="href">
<template v-if="section.icon" #icon>
<img
:class="$style.settingsNavigationItem__icon"
:src="section.icon"
alt="">
</template>
</NcAppNavigationItem>
</template>
<style module>
.settingsNavigationItem__icon {
width: var(--default-font-size);
height: var(--default-font-size);
object-fit: contain;
filter: var(--background-invert-if-dark);
}
:global(.active) .settingsNavigationItem__icon {
filter: var(--primary-invert-if-dark);
}
</style>

View file

@ -0,0 +1,13 @@
/*!
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { getCSPNonce } from '@nextcloud/auth'
import Vue from 'vue'
import SettingsNavigation from './views/SettingsNavigation.vue'
__webpack_nonce__ = getCSPNonce()
const app = new Vue(SettingsNavigation)
app.$mount('#app-navigation')

View file

@ -0,0 +1,51 @@
<!--
- SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
- SPDX-License-Identifier: AGPL-3.0-or-later
-->
<script setup lang="ts">
import type { ISettingsSection } from '../components/SettingsNavigationItem.vue'
import { loadState } from '@nextcloud/initial-state'
import { t } from '@nextcloud/l10n'
import NcAppNavigation from '@nextcloud/vue/components/NcAppNavigation'
import NcAppNavigationCaption from '@nextcloud/vue/components/NcAppNavigationCaption'
import NcAppNavigationList from '@nextcloud/vue/components/NcAppNavigationList'
import SettingsNavigationItem from '../components/SettingsNavigationItem.vue'
const {
personal: personalSections,
admin: adminSections,
} = loadState<{ admin: ISettingsSection[], personal: ISettingsSection[] }>('settings', 'sections')
const hasAdminSections = adminSections.length > 0
</script>
<template>
<NcAppNavigation>
<NcAppNavigationCaption
heading-id="settings-personal_section_heading"
is-heading
:name="t('settings', 'Personal')" />
<NcAppNavigationList aria-labelledby="settings-personal_section_heading">
<SettingsNavigationItem
v-for="section in personalSections"
:key="'personal-section--' + section.id"
:section="section"
type="personal" />
</NcAppNavigationList>
<template v-if="hasAdminSections">
<NcAppNavigationCaption
heading-id="settings-admin_section_heading"
is-heading
:name="t('settings', 'Administration')" />
<NcAppNavigationList aria-labelledby="settings-admin_section_heading">
<SettingsNavigationItem
v-for="section in adminSections"
:key="'admin-section--' + section.id"
:section="section"
type="admin" />
</NcAppNavigationList>
</template>
</NcAppNavigation>
</template>

View file

@ -7,61 +7,7 @@
style('settings', 'settings');
?>
<div id="app-navigation">
<?php if (!empty($_['forms']['admin'])): ?>
<div id="app-navigation-caption-personal" class="app-navigation-caption"><?php p($l->t('Personal')); ?></div>
<?php endif; ?>
<nav class="app-navigation-personal" aria-labelledby="app-navigation-caption-personal">
<ul>
<?php foreach ($_['forms']['personal'] as $form) {
if (isset($form['anchor'])) {
$anchor = \OCP\Server::get(\OCP\IURLGenerator::class)->linkToRoute('settings.PersonalSettings.index', ['section' => $form['anchor']]);
$class = 'nav-icon-' . $form['anchor'];
$sectionName = $form['section-name']; ?>
<li <?php print_unescaped($form['active'] ? ' class="active"' : ''); ?> data-section-id="<?php print_unescaped($form['anchor']); ?>" data-section-type="personal">
<a href="<?php p($anchor); ?>"<?php print_unescaped($form['active'] ? ' aria-current="page"' : ''); ?>>
<?php if (!empty($form['icon'])) { ?>
<img alt="" src="<?php print_unescaped($form['icon']); ?>">
<span><?php p($form['section-name']); ?></span>
<?php } else { ?>
<span class="no-icon"><?php p($form['section-name']); ?></span>
<?php } ?>
</a>
</li>
<?php
}
}
?>
</ul>
</nav>
<?php if (!empty($_['forms']['admin'])): ?>
<div id="app-navigation-caption-administration" class="app-navigation-caption"><?php p($l->t('Administration')); ?></div>
<?php endif; ?>
<nav class="app-navigation-administration" aria-labelledby="app-navigation-caption-administration">
<ul>
<?php foreach ($_['forms']['admin'] as $form) {
if (isset($form['anchor'])) {
$anchor = \OCP\Server::get(\OCP\IURLGenerator::class)->linkToRoute('settings.AdminSettings.index', ['section' => $form['anchor']]);
$class = 'nav-icon-' . $form['anchor'];
$sectionName = $form['section-name']; ?>
<li <?php print_unescaped($form['active'] ? ' class="active"' : ''); ?> data-section-id="<?php print_unescaped($form['anchor']); ?>" data-section-type="admin">
<a href="<?php p($anchor); ?>"<?php print_unescaped($form['active'] ? ' aria-current="page"' : ''); ?>>
<?php if (!empty($form['icon'])) { ?>
<img alt="" src="<?php print_unescaped($form['icon']); ?>">
<span><?php p($form['section-name']); ?></span>
<?php } else { ?>
<span class="no-icon"><?php p($form['section-name']); ?></span>
<?php } ?>
</a>
</li>
<?php
}
}
?>
</ul>
</nav>
</div>
<div id="app-navigation"></div>
<main id="app-content" <?php if (!empty($_['activeSectionId'])) { ?> data-active-section-id="<?php print_unescaped($_['activeSectionId']) ?>" <?php } if (!empty($_['activeSectionType'])) { ?> data-active-section-type="<?php print_unescaped($_['activeSectionType']) ?>" <?php } ?>>
<?php print_unescaped($_['content']); ?>
</main>

View file

@ -122,12 +122,23 @@ class AdminSettingsControllerTest extends TestCase {
->with($user, 'admin', 'test')
->willReturn([]);
$idx = $this->adminSettingsController->index('test');
$initialState = [];
$this->initialState->expects(self::atLeastOnce())
->method('provideInitialState')
->willReturnCallback(function () use (&$initialState) {
$initialState[] = func_get_args();
});
$expected = new TemplateResponse('settings', 'settings/frame', [
'forms' => ['personal' => [], 'admin' => []],
'content' => ''
]);
$this->assertEquals($expected, $idx);
$expected = new TemplateResponse(
'settings',
'settings/frame',
[
'content' => ''
],
);
$this->assertEquals($expected, $this->adminSettingsController->index('test'));
$this->assertEquals([
['sections', ['admin' => [], 'personal' => []]],
], $initialState);
}
}

View file

@ -54,6 +54,7 @@ module.exports = {
'public-nickname-handler': path.join(__dirname, 'apps/files_sharing/src', 'public-nickname-handler.ts'),
},
settings: {
main: path.join(__dirname, 'apps/settings/src', 'main-settings.ts'),
'vue-settings-admin-overview': path.join(__dirname, 'apps/settings/src', 'main-admin-overview.ts'),
'vue-settings-admin-basic-settings': path.join(__dirname, 'apps/settings/src', 'main-admin-basic-settings.js'),
'vue-settings-admin-ai': path.join(__dirname, 'apps/settings/src', 'main-admin-ai.js'),