Feature: support sorting sidebar saved views (#4381)

This commit is contained in:
shamoon
2023-10-19 19:41:01 -07:00
committed by GitHub
parent 999ae678c2
commit 9880f9ebc7
18 changed files with 397 additions and 147 deletions

View File

@@ -180,11 +180,23 @@ describe('SettingsComponent', () => {
activatedRoute.snapshot.fragment = '#notifications'
const scrollSpy = jest.spyOn(viewportScroller, 'scrollToAnchor')
component.ngOnInit()
expect(component.activeNavID).toEqual(3) // Users & Groups
expect(component.activeNavID).toEqual(3) // Notifications
component.ngAfterViewInit()
expect(scrollSpy).toHaveBeenCalledWith('#notifications')
})
it('should enable organizing of sidebar saved views even on direct navigation', () => {
completeSetup()
jest
.spyOn(activatedRoute, 'paramMap', 'get')
.mockReturnValue(of(convertToParamMap({ section: 'savedviews' })))
activatedRoute.snapshot.fragment = '#savedviews'
component.ngOnInit()
expect(component.activeNavID).toEqual(4) // Saved Views
component.ngAfterViewInit()
expect(settingsService.organizingSidebarSavedViews).toBeTruthy()
})
it('should support save saved views, show error', () => {
completeSetup()

View File

@@ -194,6 +194,9 @@ export class SettingsComponent
if (navIDKey) {
this.activeNavID = SettingsNavIDs[navIDKey]
}
if (this.activeNavID === SettingsNavIDs.SavedViews) {
this.settings.organizingSidebarSavedViews = true
}
}
})
}
@@ -275,11 +278,15 @@ export class SettingsComponent
this.router
.navigate(['settings', foundNavIDkey.toLowerCase()])
.then((navigated) => {
this.settings.organizingSidebarSavedViews = false
if (!navigated && this.isDirty) {
this.activeNavID = navChangeEvent.activeId
} else if (navigated && this.isDirty) {
this.initialize()
}
if (this.activeNavID === SettingsNavIDs.SavedViews) {
this.settings.organizingSidebarSavedViews = true
}
})
}
@@ -352,6 +359,7 @@ export class SettingsComponent
ngOnDestroy() {
if (this.isDirty) this.settings.updateAppearanceSettings() // in case user changed appearance but didnt save
this.storeSub && this.storeSub.unsubscribe()
this.settings.organizingSidebarSavedViews = false
}
deleteSavedView(savedView: PaperlessSavedView) {

View File

@@ -87,17 +87,28 @@
</li>
</ul>
<ng-container *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.SavedView }">
<h6 class="sidebar-heading px-3 mt-3 mb-1 text-muted" *ngIf='savedViewService.loading || savedViewService.sidebarViews.length > 0'>
<h6 class="sidebar-heading px-3 mt-3 mb-1 text-muted" *ngIf='savedViewService.loading || sidebarViews?.length > 0'>
<span i18n>Saved views</span>
<div *ngIf="savedViewService.loading" class="spinner-border spinner-border-sm fw-normal ms-2" role="status"></div>
</h6>
<ul class="nav flex-column mb-2">
<li class="nav-item w-100" *ngFor="let view of savedViewService.sidebarViews">
<ul class="nav flex-column mb-2" cdkDropList (cdkDropListDropped)="onDrop($event)">
<li class="nav-item w-100" *ngFor="let view of sidebarViews"
cdkDrag
[cdkDragDisabled]="!settingsService.organizingSidebarSavedViews"
cdkDragPreviewContainer="parent"
cdkDragPreviewClass="navItemDrag"
(cdkDragStarted)="onDragStart($event)"
(cdkDragEnded)="onDragEnd($event)">
<a class="nav-link" [class.text-truncate]="!slimSidebarEnabled" routerLink="view/{{view.id}}" routerLinkActive="active" (click)="closeMenu()" [ngbPopover]="view.name" [disablePopover]="!slimSidebarEnabled" placement="end" container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
<svg class="sidebaricon" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#funnel"/>
</svg><span>&nbsp;{{view.name}}</span>
</a>
<div *ngIf="settingsService.organizingSidebarSavedViews" class="position-absolute end-0 top-0 px-3 py-2" [class.me-n3]="slimSidebarEnabled" cdkDragHandle>
<svg class="sidebaricon text-muted" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#grip-vertical"/>
</svg>
</div>
</li>
</ul>
</ng-container>

View File

@@ -169,6 +169,7 @@ main {
.nav-item {
position: relative;
list-style-type: none;
&:hover .close {
display: block;
@@ -310,3 +311,11 @@ main {
opacity: 100%;
}
}
.nav-item .position-absolute {
cursor: move;
}
::ng-deep .navItemDrag .position-absolute svg {
display: none;
}

View File

@@ -19,7 +19,7 @@ import { SETTINGS_KEYS } from 'src/app/data/paperless-uisettings'
import { RemoteVersionService } from 'src/app/services/rest/remote-version.service'
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { of } from 'rxjs'
import { of, throwError } from 'rxjs'
import { ToastService } from 'src/app/services/toast.service'
import { environment } from 'src/environments/environment'
import { OpenDocumentsService } from 'src/app/services/open-documents.service'
@@ -30,7 +30,47 @@ import { DocumentListViewService } from 'src/app/services/document-list-view.ser
import { FILTER_FULLTEXT_QUERY } from 'src/app/data/filter-rule-type'
import { routes } from 'src/app/app-routing.module'
import { PermissionsGuard } from 'src/app/guards/permissions.guard'
import { CdkDragDrop } from '@angular/cdk/drag-drop'
import { PaperlessSavedView } from 'src/app/data/paperless-saved-view'
const saved_views = [
{
name: 'Saved View 0',
id: 0,
show_on_dashboard: true,
show_in_sidebar: true,
sort_field: 'name',
sort_reverse: true,
filter_rules: [],
},
{
name: 'Saved View 1',
id: 1,
show_on_dashboard: false,
show_in_sidebar: false,
sort_field: 'name',
sort_reverse: true,
filter_rules: [],
},
{
name: 'Saved View 2',
id: 2,
show_on_dashboard: true,
show_in_sidebar: true,
sort_field: 'name',
sort_reverse: true,
filter_rules: [],
},
{
name: 'Saved View 3',
id: 3,
show_on_dashboard: true,
show_in_sidebar: true,
sort_field: 'name',
sort_reverse: true,
filter_rules: [],
},
]
const document = { id: 2, title: 'Hello world' }
describe('AppFrameComponent', () => {
@@ -60,7 +100,19 @@ describe('AppFrameComponent', () => {
],
providers: [
SettingsService,
SavedViewService,
{
provide: SavedViewService,
useValue: {
initialize: () => {},
listAll: () =>
of({
all: [saved_views.map((v) => v.id)],
count: saved_views.length,
results: saved_views,
}),
sidebarViews: saved_views.filter((v) => v.show_in_sidebar),
},
},
PermissionsService,
RemoteVersionService,
IfPermissionsDirective,
@@ -269,4 +321,45 @@ describe('AppFrameComponent', () => {
},
])
})
it('should disable global dropzone on start drag + drop, re-enable after', () => {
expect(settingsService.globalDropzoneEnabled).toBeTruthy()
component.onDragStart(null)
expect(settingsService.globalDropzoneEnabled).toBeFalsy()
component.onDragEnd(null)
expect(settingsService.globalDropzoneEnabled).toBeTruthy()
})
it('should update saved view sorting on drag + drop, show info', () => {
const settingsSpy = jest.spyOn(settingsService, 'updateSidebarViewsSort')
const toastSpy = jest.spyOn(toastService, 'showInfo')
jest.spyOn(settingsService, 'storeSettings').mockReturnValue(of(true))
component.onDrop({ previousIndex: 0, currentIndex: 1 } as CdkDragDrop<
PaperlessSavedView[]
>)
expect(settingsSpy).toHaveBeenCalledWith([
saved_views[2],
saved_views[0],
saved_views[3],
])
expect(toastSpy).toHaveBeenCalled()
})
it('should update saved view sorting on drag + drop, show error', () => {
jest.spyOn(settingsService, 'get').mockImplementation((key) => {
if (key === SETTINGS_KEYS.SIDEBAR_VIEWS_SORT_ORDER) return []
})
fixture.destroy()
fixture = TestBed.createComponent(AppFrameComponent)
component = fixture.componentInstance
fixture.detectChanges()
const toastSpy = jest.spyOn(toastService, 'showError')
jest
.spyOn(settingsService, 'storeSettings')
.mockReturnValue(throwError(() => new Error('unable to save')))
component.onDrop({ previousIndex: 0, currentIndex: 2 } as CdkDragDrop<
PaperlessSavedView[]
>)
expect(toastSpy).toHaveBeenCalled()
})
})

View File

@@ -32,6 +32,13 @@ import {
PermissionsService,
PermissionType,
} from 'src/app/services/permissions.service'
import { PaperlessSavedView } from 'src/app/data/paperless-saved-view'
import {
CdkDragStart,
CdkDragEnd,
CdkDragDrop,
moveItemInArray,
} from '@angular/cdk/drag-drop'
@Component({
selector: 'pngx-app-frame',
@@ -42,6 +49,17 @@ export class AppFrameComponent
extends ComponentWithPermissions
implements OnInit, ComponentCanDeactivate
{
versionString = `${environment.appTitle} ${environment.version}`
appRemoteVersion: AppRemoteVersion
isMenuCollapsed: boolean = true
slimSidebarAnimating: boolean = false
searchField = new FormControl('')
sidebarViews: PaperlessSavedView[]
constructor(
public router: Router,
private activatedRoute: ActivatedRoute,
@@ -63,7 +81,7 @@ export class AppFrameComponent
PermissionType.SavedView
)
) {
savedViewService.initialize()
this.savedViewService.initialize()
}
}
@@ -72,15 +90,12 @@ export class AppFrameComponent
this.checkForUpdates()
}
this.tasksService.reload()
this.savedViewService.listAll().subscribe(() => {
this.sidebarViews = this.savedViewService.sidebarViews
})
}
versionString = `${environment.appTitle} ${environment.version}`
appRemoteVersion: AppRemoteVersion
isMenuCollapsed: boolean = true
slimSidebarAnimating: boolean = false
toggleSlimSidebar(): void {
this.slimSidebarAnimating = true
this.slimSidebarEnabled = !this.slimSidebarEnabled
@@ -121,8 +136,6 @@ export class AppFrameComponent
return !this.openDocumentsService.hasDirty()
}
searchField = new FormControl('')
get searchFieldEmpty(): boolean {
return this.searchField.value.trim().length == 0
}
@@ -218,6 +231,27 @@ export class AppFrameComponent
})
}
onDragStart(event: CdkDragStart) {
this.settingsService.globalDropzoneEnabled = false
}
onDragEnd(event: CdkDragEnd) {
this.settingsService.globalDropzoneEnabled = true
}
onDrop(event: CdkDragDrop<PaperlessSavedView[]>) {
moveItemInArray(this.sidebarViews, event.previousIndex, event.currentIndex)
this.settingsService.updateSidebarViewsSort(this.sidebarViews).subscribe({
next: () => {
this.toastService.showInfo($localize`Sidebar views updated`)
},
error: (e) => {
this.toastService.showError($localize`Error updating sidebar views`, e)
},
})
}
private checkForUpdates() {
this.remoteVersionService
.checkForUpdates()

View File

@@ -1,19 +1,3 @@
.col-sidebar .row {
top: 3.5rem;
}
:host ::ng-deep {
.cdk-drag-placeholder {
opacity: .5;
}
/* Animate items as they're being sorted. */
.cdk-drop-list-dragging .cdk-drag {
transition: transform 250ms cubic-bezier(0, 0, 0.2, 1);
}
/* Animate an item that has been dropped. */
.cdk-drag-animating {
transition: transform 300ms cubic-bezier(0, 0, 0.2, 1);
}
}

View File

@@ -29,22 +29,7 @@ export class DashboardComponent extends ComponentWithPermissions {
super()
this.savedViewService.listAll().subscribe(() => {
const sorted: number[] = this.settingsService.get(
SETTINGS_KEYS.DASHBOARD_VIEWS_SORT_ORDER
)
this.dashboardViews =
sorted?.length > 0
? sorted
.map((id) =>
this.savedViewService.dashboardViews.find((v) => v.id === id)
)
.concat(
this.savedViewService.dashboardViews.filter(
(v) => !sorted.includes(v.id)
)
)
.filter((v) => v)
: [...this.savedViewService.dashboardViews]
this.dashboardViews = this.savedViewService.dashboardViews
})
}

View File

@@ -17,7 +17,7 @@
<div class="d-flex justify-content-between align-items-center my-2">
<div class="progress flex-grow-1">
<div *ngFor="let filetype of statistics?.document_file_type_counts; let i = index; let last = last"
class="progress-bar bg-primary text-primary-contrast"
class="progress-bar bg-primary"
role="progressbar"
[ngbPopover]="getFileTypeName(filetype)"
i18n-ngbPopover

View File

@@ -1,4 +1,4 @@
<ngb-alert class="pe-3" type="primary" [dismissible]="true" (closed)="dismiss.emit(true)">
<ngb-alert class="pe-3 text-primary-contrast" type="primary" [dismissible]="true" (closed)="dismiss.emit(true)">
<h4 class="alert-heading"><ng-container i18n>Paperless-ngx is running!</ng-container> 🎉</h4>
<p i18n>You're ready to start uploading documents! Explore the various features of this web app on your own, or start a quick tour using the button below.</p>
<p i18n>More detail on how to use and configure Paperless-ngx is always available in the <a href="https://docs.paperless-ngx.com" target="_blank">documentation</a>.</p>

View File

@@ -43,6 +43,8 @@ export const SETTINGS_KEYS = {
'general-settings:saved-views:warn-on-unsaved-change',
DASHBOARD_VIEWS_SORT_ORDER:
'general-settings:saved-views:dashboard-views-sort-order',
SIDEBAR_VIEWS_SORT_ORDER:
'general-settings:saved-views:sidebar-views-sort-order',
TOUR_COMPLETE: 'general-settings:tour-complete',
DEFAULT_PERMS_OWNER: 'general-settings:permissions:default-owner',
DEFAULT_PERMS_VIEW_USERS: 'general-settings:permissions:default-view-users',
@@ -187,4 +189,9 @@ export const SETTINGS: PaperlessUiSetting[] = [
type: 'array',
default: [],
},
{
key: SETTINGS_KEYS.SIDEBAR_VIEWS_SORT_ORDER,
type: 'array',
default: [],
},
]

View File

@@ -4,6 +4,8 @@ import { TestBed } from '@angular/core/testing'
import { environment } from 'src/environments/environment'
import { commonAbstractPaperlessServiceTests } from './abstract-paperless-service.spec'
import { SavedViewService } from './saved-view.service'
import { SettingsService } from '../settings.service'
import { SETTINGS_KEYS } from 'src/app/data/paperless-uisettings'
let httpTestingController: HttpTestingController
let service: SavedViewService
@@ -22,8 +24,8 @@ const saved_views = [
{
name: 'Saved View 2',
id: 2,
show_on_dashboard: false,
show_in_sidebar: false,
show_on_dashboard: true,
show_in_sidebar: true,
sort_field: 'name',
sort_reverse: true,
filter_rules: [],
@@ -32,6 +34,15 @@ const saved_views = [
name: 'Saved View 3',
id: 3,
show_on_dashboard: true,
show_in_sidebar: true,
sort_field: 'name',
sort_reverse: true,
filter_rules: [],
},
{
name: 'Saved View 4',
id: 4,
show_on_dashboard: false,
show_in_sidebar: false,
sort_field: 'name',
sort_reverse: true,
@@ -43,6 +54,8 @@ const saved_views = [
commonAbstractPaperlessServiceTests(endpoint, SavedViewService)
describe(`Additional service tests for SavedViewService`, () => {
let settingsService
it('should retrieve saved views and sort them', () => {
service.initialize()
const req = httpTestingController.expectOne(
@@ -51,9 +64,9 @@ describe(`Additional service tests for SavedViewService`, () => {
req.flush({
results: saved_views,
})
expect(service.allViews).toHaveLength(3)
expect(service.dashboardViews).toHaveLength(2)
expect(service.sidebarViews).toHaveLength(1)
expect(service.allViews).toHaveLength(4)
expect(service.dashboardViews).toHaveLength(3)
expect(service.sidebarViews).toHaveLength(3)
})
it('should support patchMany', () => {
@@ -67,11 +80,36 @@ describe(`Additional service tests for SavedViewService`, () => {
})
})
it('should sort dashboard views', () => {
service['savedViews'] = saved_views
jest.spyOn(settingsService, 'get').mockImplementation((key) => {
if (key === SETTINGS_KEYS.DASHBOARD_VIEWS_SORT_ORDER) return [3, 1, 2]
})
expect(service.dashboardViews).toEqual([
saved_views[2],
saved_views[0],
saved_views[1],
])
})
it('should sort sidebar views', () => {
service['savedViews'] = saved_views
jest.spyOn(settingsService, 'get').mockImplementation((key) => {
if (key === SETTINGS_KEYS.SIDEBAR_VIEWS_SORT_ORDER) return [3, 1, 2]
})
expect(service.sidebarViews).toEqual([
saved_views[2],
saved_views[0],
saved_views[1],
])
})
beforeEach(() => {
// Dont need to setup again
httpTestingController = TestBed.inject(HttpTestingController)
service = TestBed.inject(SavedViewService)
settingsService = TestBed.inject(SettingsService)
})
afterEach(() => {

View File

@@ -5,6 +5,8 @@ import { tap } from 'rxjs/operators'
import { PaperlessSavedView } from 'src/app/data/paperless-saved-view'
import { PermissionsService } from '../permissions.service'
import { AbstractPaperlessService } from './abstract-paperless-service'
import { SettingsService } from '../settings.service'
import { SETTINGS_KEYS } from 'src/app/data/paperless-uisettings'
@Injectable({
providedIn: 'root',
@@ -12,7 +14,11 @@ import { AbstractPaperlessService } from './abstract-paperless-service'
export class SavedViewService extends AbstractPaperlessService<PaperlessSavedView> {
loading: boolean
constructor(http: HttpClient, permissionService: PermissionsService) {
constructor(
http: HttpClient,
permissionService: PermissionsService,
private settingsService: SettingsService
) {
super(http, 'saved_views')
}
@@ -25,6 +31,7 @@ export class SavedViewService extends AbstractPaperlessService<PaperlessSavedVie
this.listAll().subscribe((r) => {
this.savedViews = r.results
this.loading = false
this.settingsService.dashboardIsEmpty = this.dashboardViews.length === 0
})
}
@@ -34,12 +41,34 @@ export class SavedViewService extends AbstractPaperlessService<PaperlessSavedVie
return this.savedViews
}
get sidebarViews() {
return this.savedViews.filter((v) => v.show_in_sidebar)
get sidebarViews(): PaperlessSavedView[] {
const sidebarViews = this.savedViews.filter((v) => v.show_in_sidebar)
const sorted: number[] = this.settingsService.get(
SETTINGS_KEYS.SIDEBAR_VIEWS_SORT_ORDER
)
return sorted?.length > 0
? sorted
.map((id) => sidebarViews.find((v) => v.id === id))
.concat(sidebarViews.filter((v) => !sorted.includes(v.id)))
.filter((v) => v)
: [...sidebarViews]
}
get dashboardViews() {
return this.savedViews.filter((v) => v.show_on_dashboard)
get dashboardViews(): PaperlessSavedView[] {
const dashboardViews = this.savedViews.filter((v) => v.show_on_dashboard)
const sorted: number[] = this.settingsService.get(
SETTINGS_KEYS.DASHBOARD_VIEWS_SORT_ORDER
)
return sorted?.length > 0
? sorted
.map((id) => dashboardViews.find((v) => v.id === id))
.concat(dashboardViews.filter((v) => !sorted.includes(v.id)))
.filter((v) => v)
: [...dashboardViews]
}
create(o: PaperlessSavedView) {

View File

@@ -292,6 +292,14 @@ describe('SettingsService', () => {
SETTINGS_KEYS.DASHBOARD_VIEWS_SORT_ORDER,
[1, 4]
)
settingsService.updateSidebarViewsSort([
{ id: 1 } as PaperlessSavedView,
{ id: 4 } as PaperlessSavedView,
])
expect(setSpy).toHaveBeenCalledWith(
SETTINGS_KEYS.SIDEBAR_VIEWS_SORT_ORDER,
[1, 4]
)
httpTestingController
.expectOne(`${environment.apiBaseUrl}ui_settings/`)
.flush(ui_settings)

View File

@@ -24,7 +24,6 @@ import {
} from '../data/paperless-uisettings'
import { PaperlessUser } from '../data/paperless-user'
import { PermissionsService } from './permissions.service'
import { SavedViewService } from './rest/saved-view.service'
import { ToastService } from './toast.service'
import { PaperlessSavedView } from '../data/paperless-saved-view'
@@ -55,8 +54,11 @@ export class SettingsService {
return this._renderer
}
public dashboardIsEmpty: boolean = false
public globalDropzoneEnabled: boolean = true
public globalDropzoneActive: boolean = false
public organizingSidebarSavedViews: boolean = false
constructor(
rendererFactory: RendererFactory2,
@@ -66,7 +68,6 @@ export class SettingsService {
@Inject(LOCALE_ID) private localeId: string,
protected http: HttpClient,
private toastService: ToastService,
private savedViewService: SavedViewService,
private permissionsService: PermissionsService
) {
this._renderer = rendererFactory.createRenderer(null, null)
@@ -515,11 +516,7 @@ export class SettingsService {
}
offerTour(): boolean {
return (
!this.savedViewService.loading &&
this.savedViewService.dashboardViews.length == 0 &&
!this.get(SETTINGS_KEYS.TOUR_COMPLETE)
)
return this.dashboardIsEmpty && !this.get(SETTINGS_KEYS.TOUR_COMPLETE)
}
completeTour() {
@@ -544,4 +541,11 @@ export class SettingsService {
])
return this.storeSettings()
}
updateSidebarViewsSort(sidebarViews: PaperlessSavedView[]): Observable<any> {
this.set(SETTINGS_KEYS.SIDEBAR_VIEWS_SORT_ORDER, [
...new Set(sidebarViews.map((v) => v.id)),
])
return this.storeSettings()
}
}

View File

@@ -642,3 +642,17 @@ code {
.me-1px {
margin-right: 1px !important;
}
.cdk-drag-placeholder {
opacity: .5;
}
/* Animate items as they're being sorted. */
.cdk-drop-list-dragging .cdk-drag {
transition: transform 250ms cubic-bezier(0, 0, 0.2, 1);
}
/* Animate an item that has been dropped. */
.cdk-drag-animating {
transition: transform 300ms cubic-bezier(0, 0, 0.2, 1);
}

View File

@@ -226,7 +226,7 @@ $form-check-radio-checked-bg-image-dark: url("data:image/svg+xml,<svg xmlns='htt
}
.alert-primary {
--bs-alert-color: var(--pngx-primary-text-contrast);
--bs-alert-color: var(--bs-primary);
--bs-alert-bg: var(--pngx-primary-darken-27);
--bs-alert-border-color: var(--pngx-bg-darker);
}