diff --git a/src-ui/src/app/components/document-list/document-list.component.html b/src-ui/src/app/components/document-list/document-list.component.html index 18c4a2fcc..eb0959d44 100644 --- a/src-ui/src/app/components/document-list/document-list.component.html +++ b/src-ui/src/app/components/document-list/document-list.component.html @@ -104,11 +104,9 @@ } } -
- @if (list.activeSavedViewId) { - - } -
+ @if (list.activeSavedViewId && activeSavedViewCanChange) { + + } All saved views diff --git a/src-ui/src/app/components/document-list/document-list.component.spec.ts b/src-ui/src/app/components/document-list/document-list.component.spec.ts index 87a6ee0a1..a83ddc02e 100644 --- a/src-ui/src/app/components/document-list/document-list.component.spec.ts +++ b/src-ui/src/app/components/document-list/document-list.component.spec.ts @@ -452,7 +452,7 @@ describe('DocumentListComponent', () => { }) it('should handle error on view saving', () => { - component.list.activateSavedView({ + const view: SavedView = { id: 10, name: 'Saved View 10', sort_field: 'added', @@ -463,7 +463,16 @@ describe('DocumentListComponent', () => { value: '20', }, ], - }) + } + jest.spyOn(savedViewService, 'getCached').mockReturnValue(of(view)) + const queryParams = { view: view.id.toString() } + jest + .spyOn(activatedRoute, 'queryParamMap', 'get') + .mockReturnValue(of(convertToParamMap(queryParams))) + activatedRoute.snapshot.queryParams = queryParams + router.routerState.snapshot.url = '/view/10/' + fixture.detectChanges() + const toastErrorSpy = jest.spyOn(toastService, 'showError') jest .spyOn(savedViewService, 'patch') @@ -475,6 +484,40 @@ describe('DocumentListComponent', () => { ) }) + it('should not save a view without object change permissions', () => { + const view: SavedView = { + id: 10, + name: 'Saved View 10', + sort_field: 'added', + sort_reverse: true, + filter_rules: [ + { + rule_type: FILTER_HAS_TAGS_ANY, + value: '20', + }, + ], + owner: 999, + user_can_change: false, + } + jest.spyOn(savedViewService, 'getCached').mockReturnValue(of(view)) + jest + .spyOn(permissionService, 'currentUserHasObjectPermissions') + .mockReturnValue(false) + const queryParams = { view: view.id.toString() } + jest + .spyOn(activatedRoute, 'queryParamMap', 'get') + .mockReturnValue(of(convertToParamMap(queryParams))) + activatedRoute.snapshot.queryParams = queryParams + router.routerState.snapshot.url = '/view/10/' + fixture.detectChanges() + + const patchSpy = jest.spyOn(savedViewService, 'patch') + + component.saveViewConfig() + + expect(patchSpy).not.toHaveBeenCalled() + }) + it('should support edited view saving as', () => { const view: SavedView = { id: 10, diff --git a/src-ui/src/app/components/document-list/document-list.component.ts b/src-ui/src/app/components/document-list/document-list.component.ts index d2d21ee17..b0e6aee88 100644 --- a/src-ui/src/app/components/document-list/document-list.component.ts +++ b/src-ui/src/app/components/document-list/document-list.component.ts @@ -47,7 +47,10 @@ import { UsernamePipe } from 'src/app/pipes/username.pipe' import { DocumentListViewService } from 'src/app/services/document-list-view.service' import { HotKeyService } from 'src/app/services/hot-key.service' import { OpenDocumentsService } from 'src/app/services/open-documents.service' -import { PermissionsService } from 'src/app/services/permissions.service' +import { + PermissionAction, + PermissionsService, +} from 'src/app/services/permissions.service' import { SavedViewService } from 'src/app/services/rest/saved-view.service' import { SettingsService } from 'src/app/services/settings.service' import { ToastService } from 'src/app/services/toast.service' @@ -148,12 +151,18 @@ export class DocumentListComponent unmodifiedFilterRules: FilterRule[] = [] private unmodifiedSavedView: SavedView + private activeSavedView: SavedView | null = null private unsubscribeNotifier: Subject = new Subject() get savedViewIsModified(): boolean { - if (!this.list.activeSavedViewId || !this.unmodifiedSavedView) return false - else { + if ( + !this.list.activeSavedViewId || + !this.unmodifiedSavedView || + !this.activeSavedViewCanChange + ) { + return false + } else { return ( this.unmodifiedSavedView.sort_field !== this.list.sortField || this.unmodifiedSavedView.sort_reverse !== this.list.sortReverse || @@ -180,6 +189,16 @@ export class DocumentListComponent } } + get activeSavedViewCanChange(): boolean { + if (!this.activeSavedView) { + return false + } + return this.permissionService.currentUserHasObjectPermissions( + PermissionAction.Change, + this.activeSavedView + ) + } + get isFiltered() { return !!this.filterEditor?.rulesModified } @@ -256,11 +275,13 @@ export class DocumentListComponent .pipe(takeUntil(this.unsubscribeNotifier)) .subscribe(({ view }) => { if (!view) { + this.activeSavedView = null this.router.navigate(['404'], { replaceUrl: true, }) return } + this.activeSavedView = view this.unmodifiedSavedView = view this.list.activateSavedViewWithQueryParams( view, @@ -284,6 +305,7 @@ export class DocumentListComponent // loading a saved view on /documents this.loadViewConfig(parseInt(queryParams.get('view'))) } else { + this.activeSavedView = null this.list.activateSavedView(null) this.list.loadFromQueryParams(queryParams) this.unmodifiedFilterRules = [] @@ -366,7 +388,7 @@ export class DocumentListComponent } saveViewConfig() { - if (this.list.activeSavedViewId != null) { + if (this.list.activeSavedViewId != null && this.activeSavedViewCanChange) { let savedView: SavedView = { id: this.list.activeSavedViewId, filter_rules: this.list.filterRules, @@ -380,6 +402,7 @@ export class DocumentListComponent .pipe(first()) .subscribe({ next: (view) => { + this.activeSavedView = view this.unmodifiedSavedView = view this.toastService.showInfo( $localize`View "${this.list.activeSavedViewTitle}" saved successfully.` @@ -401,6 +424,11 @@ export class DocumentListComponent .getCached(viewID) .pipe(first()) .subscribe((view) => { + if (!view) { + this.activeSavedView = null + return + } + this.activeSavedView = view this.unmodifiedSavedView = view this.list.activateSavedView(view) this.list.reload(() => { diff --git a/src-ui/src/app/components/manage/saved-views/saved-views.component.html b/src-ui/src/app/components/manage/saved-views/saved-views.component.html index 2f5ef3338..9f7b5be7f 100644 --- a/src-ui/src/app/components/manage/saved-views/saved-views.component.html +++ b/src-ui/src/app/components/manage/saved-views/saved-views.component.html @@ -25,15 +25,17 @@
- - - + @if (canDeleteSavedView(view)) { + + + + }
diff --git a/src-ui/src/app/components/manage/saved-views/saved-views.component.spec.ts b/src-ui/src/app/components/manage/saved-views/saved-views.component.spec.ts index 10bc5db8e..4f44bf3d0 100644 --- a/src-ui/src/app/components/manage/saved-views/saved-views.component.spec.ts +++ b/src-ui/src/app/components/manage/saved-views/saved-views.component.spec.ts @@ -3,7 +3,6 @@ import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http' import { provideHttpClientTesting } from '@angular/common/http/testing' import { ComponentFixture, TestBed } from '@angular/core/testing' import { FormsModule, ReactiveFormsModule } 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' @@ -57,6 +56,8 @@ describe('SavedViewsComponent', () => { provide: PermissionsService, useValue: { currentUserCan: () => true, + currentUserHasObjectPermissions: () => true, + currentUserOwnsObject: () => true, }, }, { @@ -96,12 +97,11 @@ describe('SavedViewsComponent', () => { 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')) + const control = component.savedViewsForm + .get('savedViews') + .get(savedViews[0].id.toString()) + .get('show_on_dashboard') + control.setValue(!savedViews[0].show_on_dashboard) // saved views error first savedViewPatchSpy.mockReturnValueOnce( @@ -116,6 +116,7 @@ describe('SavedViewsComponent', () => { // succeed saved views savedViewPatchSpy.mockReturnValueOnce(of(savedViews as SavedView[])) + control.setValue(savedViews[0].show_on_dashboard) component.save() expect(toastErrorSpy).not.toHaveBeenCalled() expect(savedViewPatchSpy).toHaveBeenCalled() @@ -127,25 +128,21 @@ describe('SavedViewsComponent', () => { 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 + component.savedViewsForm + .get('savedViews') + .get(view.id.toString()) + .get('show_on_dashboard') + .setValue(!view.show_on_dashboard) fixture.detectChanges() component.save() expect(patchSpy).toHaveBeenCalledWith([ - { + expect.objectContaining({ id: view.id, name: view.name, show_in_sidebar: view.show_in_sidebar, show_on_dashboard: !view.show_on_dashboard, - }, + }), ]) }) @@ -162,14 +159,17 @@ describe('SavedViewsComponent', () => { 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.savedViewsForm + .get('savedViews') + .get(view.id.toString()) + .get('show_on_dashboard') + .setValue(!view.show_on_dashboard) component.reset() expect( - component.savedViewsForm.get('savedViews').get(view.id.toString()).value[ - 'show_on_dashboard' - ] + component.savedViewsForm + .get('savedViews') + .get(view.id.toString()) + .get('show_on_dashboard').value ).toEqual(view.show_on_dashboard) }) }) diff --git a/src-ui/src/app/components/manage/saved-views/saved-views.component.ts b/src-ui/src/app/components/manage/saved-views/saved-views.component.ts index 015f9b486..765a01ead 100644 --- a/src-ui/src/app/components/manage/saved-views/saved-views.component.ts +++ b/src-ui/src/app/components/manage/saved-views/saved-views.component.ts @@ -11,6 +11,10 @@ import { BehaviorSubject, Observable, takeUntil } from 'rxjs' import { DisplayMode } from 'src/app/data/document' import { SavedView } from 'src/app/data/saved-view' import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive' +import { + PermissionAction, + PermissionsService, +} from 'src/app/services/permissions.service' import { SavedViewService } from 'src/app/services/rest/saved-view.service' import { SettingsService } from 'src/app/services/settings.service' import { ToastService } from 'src/app/services/toast.service' @@ -41,6 +45,7 @@ export class SavedViewsComponent implements OnInit, OnDestroy { private savedViewService = inject(SavedViewService) + private permissionsService = inject(PermissionsService) private settings = inject(SettingsService) private toastService = inject(ToastService) @@ -95,16 +100,20 @@ export class SavedViewsComponent display_mode: view.display_mode, display_fields: view.display_fields, } + const canEdit = this.canEditSavedView(view) 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([]), + id: new FormControl({ value: null, disabled: !canEdit }), + name: new FormControl({ value: null, disabled: !canEdit }), + show_on_dashboard: new FormControl({ + value: null, + disabled: !canEdit, + }), + show_in_sidebar: new FormControl({ value: null, disabled: !canEdit }), + page_size: new FormControl({ value: null, disabled: !canEdit }), + display_mode: new FormControl({ value: null, disabled: !canEdit }), + display_fields: new FormControl({ value: [], disabled: !canEdit }), }) ) } @@ -126,6 +135,9 @@ export class SavedViewsComponent } public deleteSavedView(savedView: SavedView) { + if (!this.canDeleteSavedView(savedView)) { + return + } this.savedViewService.delete(savedView).subscribe(() => { this.savedViewsGroup.removeControl(savedView.id.toString()) this.savedViews.splice(this.savedViews.indexOf(savedView), 1) @@ -148,9 +160,9 @@ export class SavedViewsComponent // only patch views that have actually changed const changed: SavedView[] = [] Object.values(this.savedViewsGroup.controls) - .filter((g: FormGroup) => !g.pristine) + .filter((g: FormGroup) => g.enabled && !g.pristine) .forEach((group: FormGroup) => { - changed.push(group.value) + changed.push(group.getRawValue()) }) if (changed.length) { this.savedViewService.patchMany(changed).subscribe({ @@ -167,4 +179,15 @@ export class SavedViewsComponent }) } } + + public canEditSavedView(view: SavedView): boolean { + return this.permissionsService.currentUserHasObjectPermissions( + PermissionAction.Change, + view + ) + } + + public canDeleteSavedView(view: SavedView): boolean { + return this.permissionsService.currentUserOwnsObject(view) + } }