mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2025-08-12 00:19:48 +00:00
Change: make saved views manage its own component (#8423)
This commit is contained in:
@@ -0,0 +1,75 @@
|
||||
<pngx-page-header
|
||||
title="Saved Views"
|
||||
i18n-title
|
||||
info="Customize the views of your documents."
|
||||
i18n-info
|
||||
>
|
||||
</pngx-page-header>
|
||||
<form [formGroup]="savedViewsForm" (ngSubmit)="save()">
|
||||
<ul class="list-group mb-3" formGroupName="savedViews">
|
||||
@for (view of savedViews; track view) {
|
||||
<li class="list-group-item py-3">
|
||||
<div [formGroupName]="view.id">
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<pngx-input-text title="Name" formControlName="name"></pngx-input-text>
|
||||
</div>
|
||||
<div class="col">
|
||||
<div class="form-check form-switch mt-3">
|
||||
<input type="checkbox" class="form-check-input" id="show_on_dashboard_{{view.id}}" formControlName="show_on_dashboard">
|
||||
<label class="form-check-label" for="show_on_dashboard_{{view.id}}" i18n>Show on dashboard</label>
|
||||
</div>
|
||||
<div class="form-check form-switch">
|
||||
<input type="checkbox" class="form-check-input" id="show_in_sidebar_{{view.id}}" formControlName="show_in_sidebar">
|
||||
<label class="form-check-label" for="show_in_sidebar_{{view.id}}" i18n>Show in sidebar</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<label class="form-label" for="name_{{view.id}}" i18n>Actions</label>
|
||||
<pngx-confirm-button
|
||||
label="Delete"
|
||||
i18n-label
|
||||
(confirm)="deleteSavedView(view)"
|
||||
*pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.SavedView }"
|
||||
buttonClasses="btn-sm btn-outline-danger form-control"
|
||||
iconName="trash">
|
||||
</pngx-confirm-button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<pngx-input-number i18n-title title="Documents page size" [showAdd]="false" formControlName="page_size"></pngx-input-number>
|
||||
</div>
|
||||
<div class="col">
|
||||
<label class="form-label" for="display_mode_{{view.id}}" i18n>Display as</label>
|
||||
<select class="form-select" formControlName="display_mode">
|
||||
<option [ngValue]="DisplayMode.TABLE" i18n>Table</option>
|
||||
<option [ngValue]="DisplayMode.SMALL_CARDS" i18n>Small Cards</option>
|
||||
<option [ngValue]="DisplayMode.LARGE_CARDS" i18n>Large Cards</option>
|
||||
</select>
|
||||
</div>
|
||||
@if (displayFields) {
|
||||
<pngx-input-drag-drop-select i18n-title title="Show" i18n-emptyText emptyText="Default" [items]="displayFields" formControlName="display_fields"></pngx-input-drag-drop-select>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
}
|
||||
|
||||
@if (savedViews && savedViews.length === 0) {
|
||||
<li class="list-group-item">
|
||||
<div i18n>No saved views defined.</div>
|
||||
</li>
|
||||
}
|
||||
|
||||
@if (!savedViews) {
|
||||
<li class="list-group-item">
|
||||
<div class="spinner-border spinner-border-sm fw-normal ms-2 me-auto" role="status"></div>
|
||||
<div class="visually-hidden" i18n>Loading...</div>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
|
||||
<button type="submit" class="btn btn-primary mb-2" [disabled]="(isDirty$ | async) === false" i18n>Save</button>
|
||||
<button type="button" (click)="reset()" class="btn btn-secondary ms-2 mb-2" [disabled]="(isDirty$ | async) === false" i18n>Cancel</button>
|
||||
</form>
|
@@ -0,0 +1,3 @@
|
||||
:host {
|
||||
display: block;
|
||||
}
|
@@ -0,0 +1,165 @@
|
||||
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
|
||||
import { provideHttpClientTesting } from '@angular/common/http/testing'
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||
import { ReactiveFormsModule, FormsModule } from '@angular/forms'
|
||||
import { By } from '@angular/platform-browser'
|
||||
import { NgbModule } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
||||
import { of, throwError } from 'rxjs'
|
||||
import { SavedView } from 'src/app/data/saved-view'
|
||||
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
||||
import { PermissionsGuard } from 'src/app/guards/permissions.guard'
|
||||
import { PermissionsService } from 'src/app/services/permissions.service'
|
||||
import { SavedViewService } from 'src/app/services/rest/saved-view.service'
|
||||
import { ToastService } from 'src/app/services/toast.service'
|
||||
import { ConfirmButtonComponent } from '../../common/confirm-button/confirm-button.component'
|
||||
import { CheckComponent } from '../../common/input/check/check.component'
|
||||
import { DragDropSelectComponent } from '../../common/input/drag-drop-select/drag-drop-select.component'
|
||||
import { NumberComponent } from '../../common/input/number/number.component'
|
||||
import { SelectComponent } from '../../common/input/select/select.component'
|
||||
import { TextComponent } from '../../common/input/text/text.component'
|
||||
import { PageHeaderComponent } from '../../common/page-header/page-header.component'
|
||||
import { SavedViewsComponent } from './saved-views.component'
|
||||
import { DragDropModule } from '@angular/cdk/drag-drop'
|
||||
|
||||
const savedViews = [
|
||||
{ id: 1, name: 'view1', show_in_sidebar: true, show_on_dashboard: true },
|
||||
{ id: 2, name: 'view2', show_in_sidebar: false, show_on_dashboard: false },
|
||||
]
|
||||
|
||||
describe('SavedViewsComponent', () => {
|
||||
let component: SavedViewsComponent
|
||||
let fixture: ComponentFixture<SavedViewsComponent>
|
||||
let savedViewService: SavedViewService
|
||||
let toastService: ToastService
|
||||
|
||||
beforeEach(async () => {
|
||||
TestBed.configureTestingModule({
|
||||
declarations: [
|
||||
SavedViewsComponent,
|
||||
PageHeaderComponent,
|
||||
IfPermissionsDirective,
|
||||
CheckComponent,
|
||||
SelectComponent,
|
||||
TextComponent,
|
||||
NumberComponent,
|
||||
ConfirmButtonComponent,
|
||||
DragDropSelectComponent,
|
||||
],
|
||||
imports: [
|
||||
NgbModule,
|
||||
NgxBootstrapIconsModule.pick(allIcons),
|
||||
ReactiveFormsModule,
|
||||
FormsModule,
|
||||
DragDropModule,
|
||||
],
|
||||
providers: [
|
||||
{
|
||||
provide: PermissionsService,
|
||||
useValue: {
|
||||
currentUserCan: () => true,
|
||||
},
|
||||
},
|
||||
PermissionsGuard,
|
||||
provideHttpClient(withInterceptorsFromDi()),
|
||||
provideHttpClientTesting(),
|
||||
],
|
||||
}).compileComponents()
|
||||
|
||||
savedViewService = TestBed.inject(SavedViewService)
|
||||
toastService = TestBed.inject(ToastService)
|
||||
fixture = TestBed.createComponent(SavedViewsComponent)
|
||||
component = fixture.componentInstance
|
||||
|
||||
jest.spyOn(savedViewService, 'listAll').mockReturnValue(
|
||||
of({
|
||||
all: savedViews.map((v) => v.id),
|
||||
count: savedViews.length,
|
||||
results: (savedViews as SavedView[]).concat([]),
|
||||
})
|
||||
)
|
||||
|
||||
fixture.detectChanges()
|
||||
})
|
||||
|
||||
it('should support save saved views, show error', () => {
|
||||
const toastErrorSpy = jest.spyOn(toastService, 'showError')
|
||||
const toastSpy = jest.spyOn(toastService, 'show')
|
||||
const savedViewPatchSpy = jest.spyOn(savedViewService, 'patchMany')
|
||||
|
||||
const toggle = fixture.debugElement.query(
|
||||
By.css('.form-check.form-switch input')
|
||||
)
|
||||
toggle.nativeElement.checked = true
|
||||
toggle.nativeElement.dispatchEvent(new Event('change'))
|
||||
|
||||
// saved views error first
|
||||
savedViewPatchSpy.mockReturnValueOnce(
|
||||
throwError(() => new Error('unable to save saved views'))
|
||||
)
|
||||
component.save()
|
||||
expect(toastErrorSpy).toHaveBeenCalled()
|
||||
expect(savedViewPatchSpy).toHaveBeenCalled()
|
||||
toastSpy.mockClear()
|
||||
toastErrorSpy.mockClear()
|
||||
savedViewPatchSpy.mockClear()
|
||||
|
||||
// succeed saved views
|
||||
savedViewPatchSpy.mockReturnValueOnce(of(savedViews as SavedView[]))
|
||||
component.save()
|
||||
expect(toastErrorSpy).not.toHaveBeenCalled()
|
||||
expect(savedViewPatchSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should update only patch saved views that have changed', () => {
|
||||
const patchSpy = jest.spyOn(savedViewService, 'patchMany')
|
||||
component.save()
|
||||
expect(patchSpy).not.toHaveBeenCalled()
|
||||
|
||||
const view = savedViews[0]
|
||||
const toggle = fixture.debugElement.query(
|
||||
By.css('.form-check.form-switch input')
|
||||
)
|
||||
toggle.nativeElement.checked = true
|
||||
toggle.nativeElement.dispatchEvent(new Event('change'))
|
||||
// register change
|
||||
component.savedViewsForm.get('savedViews').get(view.id.toString()).value[
|
||||
'show_on_dashboard'
|
||||
] = !view.show_on_dashboard
|
||||
fixture.detectChanges()
|
||||
|
||||
component.save()
|
||||
expect(patchSpy).toHaveBeenCalledWith([
|
||||
{
|
||||
id: view.id,
|
||||
name: view.name,
|
||||
show_in_sidebar: view.show_in_sidebar,
|
||||
show_on_dashboard: !view.show_on_dashboard,
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('should support delete saved view', () => {
|
||||
const toastSpy = jest.spyOn(toastService, 'showInfo')
|
||||
const deleteSpy = jest.spyOn(savedViewService, 'delete')
|
||||
deleteSpy.mockReturnValue(of(true))
|
||||
component.deleteSavedView(savedViews[0] as SavedView)
|
||||
expect(deleteSpy).toHaveBeenCalled()
|
||||
expect(toastSpy).toHaveBeenCalledWith(
|
||||
`Saved view "${savedViews[0].name}" deleted.`
|
||||
)
|
||||
})
|
||||
|
||||
it('should support reset', () => {
|
||||
const view = savedViews[0]
|
||||
component.savedViewsForm.get('savedViews').get(view.id.toString()).value[
|
||||
'show_on_dashboard'
|
||||
] = !view.show_on_dashboard
|
||||
component.reset()
|
||||
expect(
|
||||
component.savedViewsForm.get('savedViews').get(view.id.toString()).value[
|
||||
'show_on_dashboard'
|
||||
]
|
||||
).toEqual(view.show_on_dashboard)
|
||||
})
|
||||
})
|
@@ -0,0 +1,150 @@
|
||||
import { Component, OnDestroy, OnInit } from '@angular/core'
|
||||
import { FormControl, FormGroup } from '@angular/forms'
|
||||
import { SavedView } from 'src/app/data/saved-view'
|
||||
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 { DisplayMode } from 'src/app/data/document'
|
||||
import { BehaviorSubject, Observable, Subject, Subscription } from 'rxjs'
|
||||
import { dirtyCheck } from '@ngneat/dirty-check-forms'
|
||||
import { ToastService } from 'src/app/services/toast.service'
|
||||
|
||||
@Component({
|
||||
selector: 'pngx-saved-views',
|
||||
templateUrl: './saved-views.component.html',
|
||||
styleUrl: './saved-views.component.scss',
|
||||
})
|
||||
export class SavedViewsComponent
|
||||
extends ComponentWithPermissions
|
||||
implements OnInit, OnDestroy
|
||||
{
|
||||
DisplayMode = DisplayMode
|
||||
|
||||
public savedViews: SavedView[]
|
||||
private savedViewsGroup = new FormGroup({})
|
||||
public savedViewsForm: FormGroup = new FormGroup({
|
||||
savedViews: this.savedViewsGroup,
|
||||
})
|
||||
|
||||
private store: BehaviorSubject<any>
|
||||
private storeSub: Subscription
|
||||
public isDirty$: Observable<boolean>
|
||||
private isDirty: boolean = false
|
||||
private unsubscribeNotifier: Subject<any> = new Subject()
|
||||
private savePending: boolean = false
|
||||
|
||||
get displayFields() {
|
||||
return this.settings.allDisplayFields
|
||||
}
|
||||
|
||||
constructor(
|
||||
private savedViewService: SavedViewService,
|
||||
private settings: SettingsService,
|
||||
private toastService: ToastService
|
||||
) {
|
||||
super()
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.settings.organizingSidebarSavedViews = true
|
||||
|
||||
this.savedViewService.listAll().subscribe((r) => {
|
||||
this.savedViews = r.results
|
||||
this.initialize()
|
||||
})
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.settings.organizingSidebarSavedViews = false
|
||||
this.unsubscribeNotifier.next(this)
|
||||
this.unsubscribeNotifier.complete()
|
||||
this.storeSub.unsubscribe()
|
||||
}
|
||||
|
||||
private initialize() {
|
||||
this.emptyGroup(this.savedViewsGroup)
|
||||
|
||||
let storeData = {
|
||||
savedViews: {},
|
||||
}
|
||||
|
||||
for (let view of this.savedViews) {
|
||||
storeData.savedViews[view.id.toString()] = {
|
||||
id: view.id,
|
||||
name: view.name,
|
||||
show_on_dashboard: view.show_on_dashboard,
|
||||
show_in_sidebar: view.show_in_sidebar,
|
||||
page_size: view.page_size,
|
||||
display_mode: view.display_mode,
|
||||
display_fields: view.display_fields,
|
||||
}
|
||||
this.savedViewsGroup.addControl(
|
||||
view.id.toString(),
|
||||
new FormGroup({
|
||||
id: new FormControl(null),
|
||||
name: new FormControl(null),
|
||||
show_on_dashboard: new FormControl(null),
|
||||
show_in_sidebar: new FormControl(null),
|
||||
page_size: new FormControl(null),
|
||||
display_mode: new FormControl(null),
|
||||
display_fields: new FormControl([]),
|
||||
})
|
||||
)
|
||||
}
|
||||
|
||||
this.store = new BehaviorSubject(storeData)
|
||||
this.storeSub = this.store.asObservable().subscribe((state) => {
|
||||
this.savedViewsForm.patchValue(state, { emitEvent: false })
|
||||
})
|
||||
|
||||
// Initialize dirtyCheck
|
||||
this.isDirty$ = dirtyCheck(this.savedViewsForm, this.store.asObservable())
|
||||
}
|
||||
|
||||
public reset() {
|
||||
this.savedViewsForm.patchValue(this.store.getValue())
|
||||
}
|
||||
|
||||
public deleteSavedView(savedView: SavedView) {
|
||||
this.savedViewService.delete(savedView).subscribe(() => {
|
||||
this.savedViewsGroup.removeControl(savedView.id.toString())
|
||||
this.savedViews.splice(this.savedViews.indexOf(savedView), 1)
|
||||
this.toastService.showInfo(
|
||||
$localize`Saved view "${savedView.name}" deleted.`
|
||||
)
|
||||
this.savedViewService.clearCache()
|
||||
this.savedViewService.listAll().subscribe((r) => {
|
||||
this.savedViews = r.results
|
||||
this.initialize()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
private emptyGroup(group: FormGroup) {
|
||||
Object.keys(group.controls).forEach((key) => group.removeControl(key))
|
||||
}
|
||||
|
||||
public save() {
|
||||
// only patch views that have actually changed
|
||||
const changed: SavedView[] = []
|
||||
Object.values(this.savedViewsGroup.controls)
|
||||
.filter((g: FormGroup) => !g.pristine)
|
||||
.forEach((group: FormGroup) => {
|
||||
changed.push(group.value)
|
||||
})
|
||||
if (changed.length) {
|
||||
this.savedViewService.patchMany(changed).subscribe({
|
||||
next: () => {
|
||||
this.toastService.showInfo($localize`Views saved successfully.`)
|
||||
this.store.next(this.savedViewsForm.value)
|
||||
},
|
||||
error: (error) => {
|
||||
this.toastService.showError(
|
||||
$localize`Error while saving views.`,
|
||||
error
|
||||
)
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user