Support drag + drop reorder of dashboard saved views

This commit is contained in:
shamoon 2023-09-24 01:55:24 -07:00
parent d8314500a0
commit 96eea42a8e
19 changed files with 301 additions and 34 deletions

View File

@ -27,6 +27,7 @@
"ngx-clipboard": "^16.0.0",
"ngx-color": "^9.0.0",
"ngx-cookie-service": "^16.0.1",
"ngx-drag-drop": "^16.1.0",
"ngx-file-drop": "^16.0.0",
"ngx-ui-tour-ng-bootstrap": "^13.0.4",
"rxjs": "^7.8.1",
@ -14061,6 +14062,18 @@
"@angular/core": "^16.0.0"
}
},
"node_modules/ngx-drag-drop": {
"version": "16.1.0",
"resolved": "https://registry.npmjs.org/ngx-drag-drop/-/ngx-drag-drop-16.1.0.tgz",
"integrity": "sha512-y2l9pJGD7OupsIRkCElN/JqTgzjg2V9ZxymKGQR7ZjjcdjaP1wKkiFWIgVEvLNtb8wgm10U+9tkGwLClGaHkQA==",
"dependencies": {
"tslib": "^2.3.0"
},
"peerDependencies": {
"@angular/common": "^16.0.0",
"@angular/core": "^16.0.0"
}
},
"node_modules/ngx-file-drop": {
"version": "16.0.0",
"resolved": "https://registry.npmjs.org/ngx-file-drop/-/ngx-file-drop-16.0.0.tgz",

View File

@ -29,6 +29,7 @@
"ngx-clipboard": "^16.0.0",
"ngx-color": "^9.0.0",
"ngx-cookie-service": "^16.0.1",
"ngx-drag-drop": "^16.1.0",
"ngx-file-drop": "^16.0.0",
"ngx-ui-tour-ng-bootstrap": "^13.0.4",
"rxjs": "^7.8.1",

View File

@ -252,9 +252,12 @@ export class AppComponent implements OnInit, OnDestroy {
}
public get dragDropEnabled(): boolean {
return this.permissionsService.currentUserCan(
PermissionAction.Add,
PermissionType.Document
return (
this.settings.globalDropzoneEnabled &&
this.permissionsService.currentUserCan(
PermissionAction.Add,
PermissionType.Document
)
)
}

View File

@ -99,6 +99,7 @@ import { ConsumptionTemplatesComponent } from './components/manage/consumption-t
import { ConsumptionTemplateEditDialogComponent } from './components/common/edit-dialog/consumption-template-edit-dialog/consumption-template-edit-dialog.component'
import { MailComponent } from './components/manage/mail/mail.component'
import { UsersAndGroupsComponent } from './components/admin/users-groups/users-groups.component'
import { DndModule } from 'ngx-drag-drop'
import localeAf from '@angular/common/locales/af'
import localeAr from '@angular/common/locales/ar'
@ -254,6 +255,7 @@ function initializeApp(settings: SettingsService) {
NgSelectModule,
ColorSliderModule,
TourNgBootstrapModule,
DndModule,
],
providers: [
{

View File

@ -4,7 +4,11 @@
<div class="row">
<div class="col-auto col-lg-8 col-xl-9 mb-4">
<div class="row row-cols-1 g-4" tourAnchor="tour.dashboard">
<div class="row row-cols-1 g-4" tourAnchor="tour.dashboard"
dndDropzone
dndEffectAllowed="move"
(dndDrop)="onDrop($event)"
>
<div *ngIf="savedViewService.loading" class="col">
<div class="spinner-border spinner-border-sm me-2" role="status"></div>
<ng-container i18n>Loading...</ng-container>
@ -15,12 +19,17 @@
</div>
<ng-container *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.SavedView }">
<ng-container *ngFor="let v of savedViewService.dashboardViews; first as isFirst">
<div class="col">
<pngx-saved-view-widget [savedView]="v"></pngx-saved-view-widget>
</div>
</ng-container>
<div *ngFor="let v of dashboardViews" class="col">
<pngx-saved-view-widget
[savedView]="v"
(dndStart)="onDragStart($event)"
(dndMoved)="onDragged(v)"
(dndEnd)="onDragEnd($event)"
>
</pngx-saved-view-widget>
</div>
</ng-container>
<div class="p-1" dndPlaceholderRef></div>
</div>
</div>
<div class="col-auto col-lg-4 col-xl-3">

View File

@ -17,12 +17,56 @@ import { NgxFileDropModule } from 'ngx-file-drop'
import { RouterTestingModule } from '@angular/router/testing'
import { TourNgBootstrapModule, TourService } from 'ngx-ui-tour-ng-bootstrap'
import { LogoComponent } from '../common/logo/logo.component'
import { of, throwError } from 'rxjs'
import { DndDropEvent, DndModule } from 'ngx-drag-drop'
import { ToastService } from 'src/app/services/toast.service'
import { SETTINGS_KEYS } from 'src/app/data/paperless-uisettings'
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: false,
sort_field: 'name',
sort_reverse: true,
filter_rules: [],
},
{
name: 'Saved View 3',
id: 3,
show_on_dashboard: true,
show_in_sidebar: false,
sort_field: 'name',
sort_reverse: true,
filter_rules: [],
},
]
describe('DashboardComponent', () => {
let component: DashboardComponent
let fixture: ComponentFixture<DashboardComponent>
let settingsService: SettingsService
let tourService: TourService
let toastService: ToastService
beforeEach(async () => {
TestBed.configureTestingModule({
@ -47,24 +91,13 @@ describe('DashboardComponent', () => {
{
provide: SavedViewService,
useValue: {
dashboardViews: [
{
id: 1,
name: 'saved view 1',
show_on_dashboard: true,
sort_field: 'added',
sort_reverse: true,
filter_rules: [],
},
{
id: 2,
name: 'saved view 2',
show_on_dashboard: true,
sort_field: 'created',
sort_reverse: true,
filter_rules: [],
},
],
listAll: () =>
of({
all: [saved_views.map((v) => v.id)],
count: saved_views.length,
results: saved_views,
}),
dashboardViews: saved_views.filter((v) => v.show_on_dashboard),
},
},
],
@ -74,6 +107,7 @@ describe('DashboardComponent', () => {
NgxFileDropModule,
RouterTestingModule,
TourNgBootstrapModule,
DndModule,
],
}).compileComponents()
@ -82,7 +116,11 @@ describe('DashboardComponent', () => {
first_name: 'Foo',
last_name: 'Bar',
}
jest.spyOn(settingsService, 'get').mockImplementation((key) => {
if (key === SETTINGS_KEYS.DASHBOARD_VIEWS_SORT_ORDER) return [0, 2, 3]
})
tourService = TestBed.inject(TourService)
toastService = TestBed.inject(ToastService)
fixture = TestBed.createComponent(DashboardComponent)
component = fixture.componentInstance
@ -100,7 +138,7 @@ describe('DashboardComponent', () => {
it('should show dashboard widgets', () => {
expect(
fixture.debugElement.queryAll(By.directive(SavedViewWidgetComponent))
).toHaveLength(2)
).toHaveLength(saved_views.filter((v) => v.show_on_dashboard).length)
})
it('should end tour service if still running and welcome widget dismissed', () => {
@ -116,4 +154,44 @@ describe('DashboardComponent', () => {
component.completeTour()
expect(settingsCompleteTourSpy).toHaveBeenCalled()
})
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, 'updateDashboardViewsSort')
const toastSpy = jest.spyOn(toastService, 'showInfo')
jest.spyOn(settingsService, 'storeSettings').mockReturnValue(of(true))
component.onDrop({ index: 2, data: saved_views[0] } as DndDropEvent)
component.onDragged(saved_views[0])
expect(settingsSpy).toHaveBeenCalledWith([
saved_views[2],
saved_views[0],
saved_views[3],
])
expect(toastSpy).toHaveBeenCalled()
component.onDrop({ data: saved_views[3] } as DndDropEvent)
})
it('should update saved view sorting on drag + drop, show info2', () => {
jest.spyOn(settingsService, 'get').mockImplementation((key) => {
if (key === SETTINGS_KEYS.DASHBOARD_VIEWS_SORT_ORDER) return []
})
fixture.destroy()
fixture = TestBed.createComponent(DashboardComponent)
component = fixture.componentInstance
fixture.detectChanges()
const toastSpy = jest.spyOn(toastService, 'showError')
jest
.spyOn(settingsService, 'storeSettings')
.mockReturnValue(throwError(() => new Error('unable to save')))
component.onDrop({ index: 2, data: saved_views[0] } as DndDropEvent)
component.onDragged(saved_views[0])
expect(toastSpy).toHaveBeenCalled()
})
})

View File

@ -3,6 +3,10 @@ import { SavedViewService } from 'src/app/services/rest/saved-view.service'
import { SettingsService } from 'src/app/services/settings.service'
import { ComponentWithPermissions } from '../with-permissions/with-permissions.component'
import { TourService } from 'ngx-ui-tour-ng-bootstrap'
import { PaperlessSavedView } from 'src/app/data/paperless-saved-view'
import { DndDropEvent } from 'ngx-drag-drop'
import { ToastService } from 'src/app/services/toast.service'
import { SETTINGS_KEYS } from 'src/app/data/paperless-uisettings'
@Component({
selector: 'pngx-dashboard',
@ -10,12 +14,38 @@ import { TourService } from 'ngx-ui-tour-ng-bootstrap'
styleUrls: ['./dashboard.component.scss'],
})
export class DashboardComponent extends ComponentWithPermissions {
public dashboardViews: PaperlessSavedView[] = []
constructor(
public settingsService: SettingsService,
public savedViewService: SavedViewService,
private tourService: TourService
private tourService: TourService,
private toastService: ToastService
) {
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]
console.log(
this.dashboardViews,
sorted,
this.savedViewService.dashboardViews
)
})
}
get subtitle() {
@ -33,4 +63,35 @@ export class DashboardComponent extends ComponentWithPermissions {
this.settingsService.completeTour()
}
}
onDragStart(event: DragEvent) {
this.settingsService.globalDropzoneEnabled = false
}
onDragged(v: PaperlessSavedView) {
const index = this.dashboardViews.indexOf(v)
this.dashboardViews.splice(index, 1)
this.settingsService
.updateDashboardViewsSort(this.dashboardViews)
.subscribe({
next: () => {
this.toastService.showInfo($localize`Dashboard updated`)
},
error: (e) => {
this.toastService.showError($localize`Error updating dashboard`, e)
},
})
}
onDragEnd(event: DragEvent) {
this.settingsService.globalDropzoneEnabled = true
}
onDrop(event: DndDropEvent) {
if (typeof event.index === 'undefined') {
event.index = this.dashboardViews.length
}
this.dashboardViews.splice(event.index, 0, event.data)
}
}

View File

@ -1,4 +1,13 @@
<pngx-widget-frame [title]="savedView.name" [loading]="loading" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }">
<pngx-widget-frame
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }"
[title]="savedView.name"
[loading]="loading"
[draggable]="savedView"
(dndStart)="dndStart.emit($event)"
(dndMoved)="dndMoved.emit($event)"
(dndCanceled)="dndCanceled.emit($event)"
(dndEnd)="dndEnd.emit($event)"
>
<a *ngIf="documents.length" class="btn-link" header-buttons [routerLink]="[]" (click)="showAll()" i18n>Show all</a>

View File

@ -28,6 +28,7 @@ import { WidgetFrameComponent } from '../widget-frame/widget-frame.component'
import { SavedViewWidgetComponent } from './saved-view-widget.component'
import { By } from '@angular/platform-browser'
import { SafeUrlPipe } from 'src/app/pipes/safeurl.pipe'
import { DndModule } from 'ngx-drag-drop'
const savedView: PaperlessSavedView = {
id: 1,
@ -90,6 +91,7 @@ describe('SavedViewWidgetComponent', () => {
HttpClientTestingModule,
NgbModule,
RouterTestingModule.withRoutes(routes),
DndModule,
],
}).compileComponents()

View File

@ -1,8 +1,10 @@
import {
Component,
EventEmitter,
Input,
OnDestroy,
OnInit,
Output,
QueryList,
ViewChildren,
} from '@angular/core'
@ -51,6 +53,18 @@ export class SavedViewWidgetComponent
@Input()
savedView: PaperlessSavedView
@Output()
dndStart: EventEmitter<DragEvent> = new EventEmitter()
@Output()
dndMoved: EventEmitter<DragEvent> = new EventEmitter()
@Output()
dndCanceled: EventEmitter<DragEvent> = new EventEmitter()
@Output()
dndEnd: EventEmitter<DragEvent> = new EventEmitter()
documents: PaperlessDocument[] = []
unsubscribeNotifier: Subject<any> = new Subject()

View File

@ -12,6 +12,7 @@ import { RouterTestingModule } from '@angular/router/testing'
import { routes } from 'src/app/app-routing.module'
import { PermissionsGuard } from 'src/app/guards/permissions.guard'
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
import { DndModule } from 'ngx-drag-drop'
describe('StatisticsWidgetComponent', () => {
let component: StatisticsWidgetComponent
@ -30,6 +31,7 @@ describe('StatisticsWidgetComponent', () => {
HttpClientTestingModule,
NgbModule,
RouterTestingModule.withRoutes(routes),
DndModule,
],
}).compileComponents()

View File

@ -26,6 +26,7 @@ import { PermissionsService } from 'src/app/services/permissions.service'
import { UploadDocumentsService } from 'src/app/services/upload-documents.service'
import { WidgetFrameComponent } from '../widget-frame/widget-frame.component'
import { UploadFileWidgetComponent } from './upload-file-widget.component'
import { DndModule } from 'ngx-drag-drop'
describe('UploadFileWidgetComponent', () => {
let component: UploadFileWidgetComponent
@ -55,6 +56,7 @@ describe('UploadFileWidgetComponent', () => {
RouterTestingModule.withRoutes(routes),
NgxFileDropModule,
NgbAlertModule,
DndModule,
],
}).compileComponents()

View File

@ -1,7 +1,21 @@
<div class="card shadow-sm bg-light">
<div class="card shadow-sm bg-light"
[dndDraggable]="draggable"
dndEffectAllowed="move"
[dndDisableIf]="!draggable"
(dndStart)="dndStart.emit($event)"
(dndMoved)="dndMoved.emit($event)"
(dndCanceled)="dndCanceled.emit($event)"
(dndEnd)="dndEnd.emit($event)">
<div class="card-header">
<div class="d-flex justify-content-between align-items-center">
<h6 class="card-title mb-0">{{title}}</h6>
<div class="d-flex">
<div *ngIf="draggable" class="ms-n2 me-1" dndHandle>
<svg class="sidebaricon text-muted" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#grip-vertical"/>
</svg>
</div>
<h6 class="card-title mb-0">{{title}}</h6>
</div>
<ng-container *ngIf="loading">
<div class="spinner-border spinner-border-sm fw-normal ms-2 me-auto" role="status"></div>
<div class="visually-hidden" i18n>Loading...</div>

View File

@ -4,6 +4,7 @@ import { By } from '@angular/platform-browser'
import { NgbAlertModule, NgbAlert } from '@ng-bootstrap/ng-bootstrap'
import { PermissionsGuard } from 'src/app/guards/permissions.guard'
import { WidgetFrameComponent } from './widget-frame.component'
import { DndModule } from 'ngx-drag-drop'
@Component({
template: `
@ -29,7 +30,7 @@ describe('WidgetFrameComponent', () => {
TestBed.configureTestingModule({
declarations: [WidgetFrameComponent, WidgetFrameComponent],
providers: [PermissionsGuard],
imports: [NgbAlertModule],
imports: [NgbAlertModule, DndModule],
}).compileComponents()
fixture = TestBed.createComponent(WidgetFrameComponent)

View File

@ -1,4 +1,4 @@
import { Component, Input } from '@angular/core'
import { Component, EventEmitter, Input, Output } from '@angular/core'
@Component({
selector: 'pngx-widget-frame',
@ -13,4 +13,19 @@ export class WidgetFrameComponent {
@Input()
loading: boolean = false
@Input()
draggable: any
@Output()
dndStart: EventEmitter<DragEvent> = new EventEmitter()
@Output()
dndMoved: EventEmitter<DragEvent> = new EventEmitter()
@Output()
dndCanceled: EventEmitter<DragEvent> = new EventEmitter()
@Output()
dndEnd: EventEmitter<DragEvent> = new EventEmitter()
}

View File

@ -41,6 +41,8 @@ export const SETTINGS_KEYS = {
'general-settings:update-checking:backend-setting',
SAVED_VIEWS_WARN_ON_UNSAVED_CHANGE:
'general-settings:saved-views:warn-on-unsaved-change',
DASHBOARD_VIEWS_SORT_ORDER:
'general-settings:saved-views:dashboard-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',
@ -180,4 +182,9 @@ export const SETTINGS: PaperlessUiSetting[] = [
type: 'array',
default: [],
},
{
key: SETTINGS_KEYS.DASHBOARD_VIEWS_SORT_ORDER,
type: 'array',
default: [],
},
]

View File

@ -15,6 +15,7 @@ import {
SETTINGS_KEYS,
} from '../data/paperless-uisettings'
import { SettingsService } from './settings.service'
import { PaperlessSavedView } from '../data/paperless-saved-view'
describe('SettingsService', () => {
let httpTestingController: HttpTestingController
@ -277,4 +278,22 @@ describe('SettingsService', () => {
)[0]
expect(req.request.method).toEqual('POST')
})
it('should update saved view sorting', () => {
httpTestingController
.expectOne(`${environment.apiBaseUrl}ui_settings/`)
.flush(ui_settings)
const setSpy = jest.spyOn(settingsService, 'set')
settingsService.updateDashboardViewsSort([
{ id: 1 } as PaperlessSavedView,
{ id: 4 } as PaperlessSavedView,
])
expect(setSpy).toHaveBeenCalledWith(
SETTINGS_KEYS.DASHBOARD_VIEWS_SORT_ORDER,
[1, 4]
)
httpTestingController
.expectOne(`${environment.apiBaseUrl}ui_settings/`)
.flush(ui_settings)
})
})

View File

@ -26,6 +26,7 @@ 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'
export interface LanguageOption {
code: string
@ -54,6 +55,8 @@ export class SettingsService {
return this._renderer
}
public globalDropzoneEnabled: boolean = true
constructor(
rendererFactory: RendererFactory2,
@Inject(DOCUMENT) private document,
@ -531,4 +534,13 @@ export class SettingsService {
})
}
}
updateDashboardViewsSort(
dashboardViews: PaperlessSavedView[]
): Observable<any> {
this.set(SETTINGS_KEYS.DASHBOARD_VIEWS_SORT_ORDER, [
...new Set(dashboardViews.map((v) => v.id)),
])
return this.storeSettings()
}
}