Wiring up the toggles

This commit is contained in:
shamoon
2026-09-08 12:09:59 -07:00
parent d66c681aae
commit 5ecf06463d
8 changed files with 126 additions and 15 deletions
@@ -226,6 +226,16 @@ describe('SettingsComponent', () => {
)
})
it('enables sidebar item controls on general settings until destroyed', () => {
completeSetup()
expect(settingsService.organizingSidebarItems()).toBe(true)
component.ngOnDestroy()
expect(settingsService.organizingSidebarItems()).toBe(false)
})
it('should support tabbed settings & change URL, prevent navigation if dirty confirmation rejected', async () => {
completeSetup()
const navigateSpy = jest.spyOn(router, 'navigate')
@@ -297,14 +297,17 @@ export class SettingsComponent
this.activatedRoute.paramMap.subscribe((paramMap) => {
const section = paramMap.get('section')
let navID = SettingsNavIDs.General
if (section) {
const navIDKey: string = Object.keys(SettingsNavIDs).find(
(navID) => navID.toLowerCase() == section
)
if (navIDKey) {
this.activeNavID.set(SettingsNavIDs[navIDKey])
navID = SettingsNavIDs[navIDKey]
}
}
this.activeNavID.set(navID)
this.settings.organizingSidebarItems.set(navID === SettingsNavIDs.General)
})
}
@@ -463,6 +466,7 @@ export class SettingsComponent
}
ngOnDestroy() {
this.settings.organizingSidebarItems.set(false)
if (this.isDirty) this.settings.updateAppearanceSettings() // in case user changed appearance but didn't save
this.storeSub && this.storeSub.unsubscribe()
}
@@ -74,6 +74,7 @@
<div class="row">
<nav id="sidebarMenu" class="d-md-block bg-light sidebar collapse"
[ngClass]="slimSidebarEnabled ? 'slim' : 'expanded'" [class.animating]="slimSidebarAnimating()"
[class.sidebar-customizing]="settingsService.organizingSidebarItems()"
[ngbCollapse]="isMenuCollapsed()">
@if (canSaveSettings) {
<button class="btn btn-sm btn-dark sidebar-slim-toggler" (click)="toggleSlimSidebar()" [aria-label]="slimSidebarEnabled ? 'Expand sidebar' : 'Collapse sidebar'" i18n-aria-label>
@@ -86,12 +87,15 @@
}
<div class="sidebar-sticky pt-3 pb-1 d-flex flex-column justify-space-around">
<ul class="nav flex-column">
<li class="nav-item app-link" [class.d-none]="settingsService.sidebarItemIsHidden(HideableSidebarItemID.Dashboard)">
<a class="nav-link" routerLink="dashboard" routerLinkActive="active" (click)="closeMenu()"
<li class="nav-item app-link sidebar-customizable-item" [class.d-none]="settingsService.sidebarItemIsHidden(HideableSidebarItemID.Dashboard) && !settingsService.organizingSidebarItems()">
<a class="nav-link" [class.opacity-50]="settingsService.sidebarItemIsHidden(HideableSidebarItemID.Dashboard)" routerLink="dashboard" routerLinkActive="active" (click)="closeMenu()"
ngbPopover="Dashboard" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end"
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
<i-bs class="me-2" name="house"></i-bs><span class="nav-link-label"><ng-container i18n>Dashboard</ng-container></span>
</a>
@if (settingsService.organizingSidebarItems()) {
<pngx-input-switch class="sidebar-item-toggle" [compact]="true" title="Dashboard" i18n-title [ngModel]="!settingsService.sidebarItemIsHidden(HideableSidebarItemID.Dashboard)" (ngModelChange)="toggleSidebarItem(HideableSidebarItemID.Dashboard, $event)"></pngx-input-switch>
}
</li>
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }">
<a class="nav-link" routerLink="documents" routerLinkActive="active"
@@ -237,29 +241,38 @@
</div>
</li>
}
<li class="nav-item app-link" [class.d-none]="settingsService.sidebarItemIsHidden(HideableSidebarItemID.SavedViews)" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.SavedView }">
<a class="nav-link" routerLink="savedviews" routerLinkActive="active" (click)="closeMenu()"
<li class="nav-item app-link sidebar-customizable-item" [class.d-none]="settingsService.sidebarItemIsHidden(HideableSidebarItemID.SavedViews) && !settingsService.organizingSidebarItems()" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.SavedView }">
<a class="nav-link" [class.opacity-50]="settingsService.sidebarItemIsHidden(HideableSidebarItemID.SavedViews)" routerLink="savedviews" routerLinkActive="active" (click)="closeMenu()"
ngbPopover="Saved Views" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end"
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
<i-bs class="me-2" name="window-stack"></i-bs><span class="nav-link-label"><ng-container i18n>Saved Views</ng-container></span>
</a>
@if (settingsService.organizingSidebarItems()) {
<pngx-input-switch class="sidebar-item-toggle" [compact]="true" title="Saved Views" i18n-title [ngModel]="!settingsService.sidebarItemIsHidden(HideableSidebarItemID.SavedViews)" (ngModelChange)="toggleSidebarItem(HideableSidebarItemID.SavedViews, $event)"></pngx-input-switch>
}
</li>
<li class="nav-item app-link" [class.d-none]="settingsService.sidebarItemIsHidden(HideableSidebarItemID.Workflows)"
<li class="nav-item app-link sidebar-customizable-item" [class.d-none]="settingsService.sidebarItemIsHidden(HideableSidebarItemID.Workflows) && !settingsService.organizingSidebarItems()"
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Workflow }"
tourAnchor="tour.workflows">
<a class="nav-link" routerLink="workflows" routerLinkActive="active" (click)="closeMenu()"
<a class="nav-link" [class.opacity-50]="settingsService.sidebarItemIsHidden(HideableSidebarItemID.Workflows)" routerLink="workflows" routerLinkActive="active" (click)="closeMenu()"
ngbPopover="Workflows" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end"
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
<i-bs class="me-2" name="boxes"></i-bs><span class="nav-link-label"><ng-container i18n>Workflows</ng-container></span>
</a>
@if (settingsService.organizingSidebarItems()) {
<pngx-input-switch class="sidebar-item-toggle" [compact]="true" title="Workflows" i18n-title [ngModel]="!settingsService.sidebarItemIsHidden(HideableSidebarItemID.Workflows)" (ngModelChange)="toggleSidebarItem(HideableSidebarItemID.Workflows, $event)"></pngx-input-switch>
}
</li>
<li class="nav-item app-link" [class.d-none]="settingsService.sidebarItemIsHidden(HideableSidebarItemID.Mail)" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.MailAccount }"
<li class="nav-item app-link sidebar-customizable-item" [class.d-none]="settingsService.sidebarItemIsHidden(HideableSidebarItemID.Mail) && !settingsService.organizingSidebarItems()" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.MailAccount }"
tourAnchor="tour.mail">
<a class="nav-link" routerLink="mail" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Mail"
<a class="nav-link" [class.opacity-50]="settingsService.sidebarItemIsHidden(HideableSidebarItemID.Mail)" routerLink="mail" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Mail"
i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end" container="body"
triggers="mouseenter:mouseleave" popoverClass="popover-slim">
<i-bs class="me-2" name="envelope"></i-bs><span class="nav-link-label"><ng-container i18n>Mail</ng-container></span>
</a>
@if (settingsService.organizingSidebarItems()) {
<pngx-input-switch class="sidebar-item-toggle" [compact]="true" title="Mail" i18n-title [ngModel]="!settingsService.sidebarItemIsHidden(HideableSidebarItemID.Mail)" (ngModelChange)="toggleSidebarItem(HideableSidebarItemID.Mail, $event)"></pngx-input-switch>
}
</li>
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.Document }">
<a class="nav-link" routerLink="trash" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Trash"
@@ -322,13 +335,16 @@
</a>
</li>
}
<li class="nav-item mt-2" [class.d-none]="settingsService.sidebarItemIsHidden(HideableSidebarItemID.Documentation)" tourAnchor="tour.outro">
<a class="text-muted small d-flex align-items-center flex-wrap text-decoration-none nav-anchor"
<li class="nav-item mt-2 sidebar-customizable-item" [class.d-none]="settingsService.sidebarItemIsHidden(HideableSidebarItemID.Documentation) && !settingsService.organizingSidebarItems()" tourAnchor="tour.outro">
<a class="text-muted small d-flex align-items-center flex-wrap text-decoration-none nav-anchor" [class.opacity-50]="settingsService.sidebarItemIsHidden(HideableSidebarItemID.Documentation)"
target="_blank" rel="noopener noreferrer" href="https://docs.paperless-ngx.com" ngbPopover="Documentation"
i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end" container="body"
triggers="mouseenter:mouseleave" popoverClass="popover-slim">
<i-bs class="d-flex me-2" name="question-circle"></i-bs><span><ng-container i18n>Documentation</ng-container></span>
</a>
@if (settingsService.organizingSidebarItems()) {
<pngx-input-switch class="sidebar-item-toggle" [compact]="true" title="Documentation" i18n-title [ngModel]="!settingsService.sidebarItemIsHidden(HideableSidebarItemID.Documentation)" (ngModelChange)="toggleSidebarItem(HideableSidebarItemID.Documentation, $event)"></pngx-input-switch>
}
</li>
<li class="nav-item" [class.visually-hidden]="slimSidebarEnabled">
<div class="text-muted small d-flex align-items-center flex-wrap nav-label">
@@ -33,6 +33,22 @@
display: none !important;
}
&.sidebar-customizing .sidebar-customizable-item {
position: relative;
.nav-link,
.nav-anchor {
padding-right: 3.5rem;
}
.sidebar-item-toggle {
position: absolute;
top: 50%;
right: 0.25rem;
transform: translateY(-50%);
}
}
@media (min-width: 768px) {
--pngx-sidebar-width: var(--pngx-sidebar-expanded-width);
}
@@ -121,6 +137,10 @@ main {
.sidebar.slim {
max-width: 55px;
.sidebar-item-toggle {
display: none;
}
li.nav-item span.badge {
display: inline-block;
margin-right: -4px;
@@ -308,6 +308,25 @@ describe('AppFrameComponent', () => {
).not.toContain('d-none')
})
it('should show hidden items and visibility switches while customizing', () => {
settingsService.set(SETTINGS_KEYS.SIDEBAR_HIDDEN_ITEMS, [
HideableSidebarItemID.Dashboard,
])
settingsService.organizingSidebarItems.set(true)
fixture.detectChanges()
expect(
fixture.nativeElement.querySelectorAll('pngx-input-switch').length
).toBe(5)
expect(
fixture.nativeElement.querySelector('[routerLink="dashboard"]')
.parentElement.classList
).not.toContain('d-none')
expect(
fixture.nativeElement.querySelector('[routerLink="dashboard"]').classList
).toContain('opacity-50')
})
it('should show error on toggle slim sidebar if store settings fails', () => {
jest.spyOn(console, 'warn').mockImplementation(() => {})
const toastSpy = jest.spyOn(toastService, 'showError')
@@ -7,6 +7,7 @@ import {
} from '@angular/cdk/drag-drop'
import { NgClass } from '@angular/common'
import { Component, HostListener, inject, OnInit, signal } from '@angular/core'
import { FormsModule } from '@angular/forms'
import { ActivatedRoute, Router, RouterModule } from '@angular/router'
import {
NgbCollapseModule,
@@ -52,6 +53,7 @@ import { ChatComponent } from '../chat/chat/chat.component'
import { BrandMarkComponent } from '../common/logo/brand-mark/brand-mark.component'
import { LogoComponent } from '../common/logo/logo.component'
import { ProfileEditDialogComponent } from '../common/profile-edit-dialog/profile-edit-dialog.component'
import { SwitchComponent } from '../common/input/switch/switch.component'
import { DocumentDetailComponent } from '../document-detail/document-detail.component'
import { ComponentWithPermissions } from '../with-permissions/with-permissions.component'
import { GlobalSearchComponent } from './global-search/global-search.component'
@@ -80,6 +82,8 @@ const SCROLL_THRESHOLD = 16
NgxBootstrapIconsModule,
DragDropModule,
TourNgBootstrap,
FormsModule,
SwitchComponent,
],
})
export class AppFrameComponent
@@ -200,6 +204,25 @@ export class AppFrameComponent
}, 200) // slightly longer than css animation for slim sidebar
}
toggleSidebarItem(item: HideableSidebarItemID, visible: boolean): void {
const previousHiddenItems = this.settingsService.hiddenSidebarItems()
this.settingsService
.updateSidebarItemVisibility(item, visible)
.pipe(first())
.subscribe({
error: (error) => {
this.settingsService.set(
SETTINGS_KEYS.SIDEBAR_HIDDEN_ITEMS,
previousHiddenItems
)
this.toastService.showError(
$localize`An error occurred while saving settings.`,
error
)
},
})
}
toggleAttributesSections(event?: Event): void {
event?.preventDefault()
event?.stopPropagation()
@@ -234,7 +234,7 @@ describe('SettingsService', () => {
expect(notesEnabled()).toBeFalsy()
})
it('reports whether a sidebar item is hidden', () => {
it('updates sidebar item visibility', () => {
httpTestingController
.expectOne(`${environment.apiBaseUrl}ui_settings/`)
.flush(ui_settings)
@@ -243,15 +243,19 @@ describe('SettingsService', () => {
settingsService.sidebarItemIsHidden(HideableSidebarItemID.Workflows)
).toBe(false)
settingsService.set(SETTINGS_KEYS.SIDEBAR_HIDDEN_ITEMS, [
settingsService.updateSidebarItemVisibility(
HideableSidebarItemID.Workflows,
])
false
)
expect(
settingsService.sidebarItemIsHidden(HideableSidebarItemID.Workflows)
).toBe(true)
settingsService.set(SETTINGS_KEYS.SIDEBAR_HIDDEN_ITEMS, [])
settingsService.updateSidebarItemVisibility(
HideableSidebarItemID.Workflows,
true
)
expect(
settingsService.sidebarItemIsHidden(HideableSidebarItemID.Workflows)
@@ -314,6 +314,7 @@ export class SettingsService {
readonly globalDropzoneEnabled = signal(true)
readonly globalDropzoneActive = signal(false)
readonly organizingSidebarSavedViews = signal(false)
readonly organizingSidebarItems = signal(false)
readonly hiddenSidebarItems = this.getSignal<HideableSidebarItemID[]>(
SETTINGS_KEYS.SIDEBAR_HIDDEN_ITEMS
)
@@ -757,6 +758,20 @@ export class SettingsService {
return this.hiddenSidebarItems().includes(item)
}
updateSidebarItemVisibility(
item: HideableSidebarItemID,
visible: boolean
): Observable<any> {
const hiddenItems = new Set(this.hiddenSidebarItems())
if (visible) {
hiddenItems.delete(item)
} else {
hiddenItems.add(item)
}
this.set(SETTINGS_KEYS.SIDEBAR_HIDDEN_ITEMS, [...hiddenItems])
return this.storeSettings()
}
updateSavedViewsVisibility(
dashboardVisibleViewIds: number[],
sidebarVisibleViewIds: number[]