Basic frontend respect saved view perms

This commit is contained in:
shamoon
2026-02-20 08:45:11 -08:00
parent e0e517358d
commit e9d87ef049
6 changed files with 147 additions and 53 deletions

View File

@@ -104,11 +104,9 @@
} }
} }
<div *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.SavedView }"> @if (list.activeSavedViewId && activeSavedViewCanChange) {
@if (list.activeSavedViewId) { <button ngbDropdownItem (click)="saveViewConfig()" [disabled]="!savedViewIsModified" i18n>Save "{{list.activeSavedViewTitle}}"</button>
<button ngbDropdownItem (click)="saveViewConfig()" [disabled]="!savedViewIsModified" i18n>Save "{{list.activeSavedViewTitle}}"</button> }
}
</div>
<button ngbDropdownItem (click)="saveViewConfigAs()" *pngxIfPermissions="{ action: PermissionAction.Add, type: PermissionType.SavedView }" i18n>Save as...</button> <button ngbDropdownItem (click)="saveViewConfigAs()" *pngxIfPermissions="{ action: PermissionAction.Add, type: PermissionType.SavedView }" i18n>Save as...</button>
<a ngbDropdownItem routerLink="/savedviews" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.SavedView }" i18n>All saved views</a> <a ngbDropdownItem routerLink="/savedviews" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.SavedView }" i18n>All saved views</a>
</div> </div>

View File

@@ -452,7 +452,7 @@ describe('DocumentListComponent', () => {
}) })
it('should handle error on view saving', () => { it('should handle error on view saving', () => {
component.list.activateSavedView({ const view: SavedView = {
id: 10, id: 10,
name: 'Saved View 10', name: 'Saved View 10',
sort_field: 'added', sort_field: 'added',
@@ -463,7 +463,16 @@ describe('DocumentListComponent', () => {
value: '20', 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') const toastErrorSpy = jest.spyOn(toastService, 'showError')
jest jest
.spyOn(savedViewService, 'patch') .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', () => { it('should support edited view saving as', () => {
const view: SavedView = { const view: SavedView = {
id: 10, id: 10,

View File

@@ -47,7 +47,10 @@ import { UsernamePipe } from 'src/app/pipes/username.pipe'
import { DocumentListViewService } from 'src/app/services/document-list-view.service' import { DocumentListViewService } from 'src/app/services/document-list-view.service'
import { HotKeyService } from 'src/app/services/hot-key.service' import { HotKeyService } from 'src/app/services/hot-key.service'
import { OpenDocumentsService } from 'src/app/services/open-documents.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 { SavedViewService } from 'src/app/services/rest/saved-view.service'
import { SettingsService } from 'src/app/services/settings.service' import { SettingsService } from 'src/app/services/settings.service'
import { ToastService } from 'src/app/services/toast.service' import { ToastService } from 'src/app/services/toast.service'
@@ -148,12 +151,18 @@ export class DocumentListComponent
unmodifiedFilterRules: FilterRule[] = [] unmodifiedFilterRules: FilterRule[] = []
private unmodifiedSavedView: SavedView private unmodifiedSavedView: SavedView
private activeSavedView: SavedView | null = null
private unsubscribeNotifier: Subject<any> = new Subject() private unsubscribeNotifier: Subject<any> = new Subject()
get savedViewIsModified(): boolean { get savedViewIsModified(): boolean {
if (!this.list.activeSavedViewId || !this.unmodifiedSavedView) return false if (
else { !this.list.activeSavedViewId ||
!this.unmodifiedSavedView ||
!this.activeSavedViewCanChange
) {
return false
} else {
return ( return (
this.unmodifiedSavedView.sort_field !== this.list.sortField || this.unmodifiedSavedView.sort_field !== this.list.sortField ||
this.unmodifiedSavedView.sort_reverse !== this.list.sortReverse || 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() { get isFiltered() {
return !!this.filterEditor?.rulesModified return !!this.filterEditor?.rulesModified
} }
@@ -256,11 +275,13 @@ export class DocumentListComponent
.pipe(takeUntil(this.unsubscribeNotifier)) .pipe(takeUntil(this.unsubscribeNotifier))
.subscribe(({ view }) => { .subscribe(({ view }) => {
if (!view) { if (!view) {
this.activeSavedView = null
this.router.navigate(['404'], { this.router.navigate(['404'], {
replaceUrl: true, replaceUrl: true,
}) })
return return
} }
this.activeSavedView = view
this.unmodifiedSavedView = view this.unmodifiedSavedView = view
this.list.activateSavedViewWithQueryParams( this.list.activateSavedViewWithQueryParams(
view, view,
@@ -284,6 +305,7 @@ export class DocumentListComponent
// loading a saved view on /documents // loading a saved view on /documents
this.loadViewConfig(parseInt(queryParams.get('view'))) this.loadViewConfig(parseInt(queryParams.get('view')))
} else { } else {
this.activeSavedView = null
this.list.activateSavedView(null) this.list.activateSavedView(null)
this.list.loadFromQueryParams(queryParams) this.list.loadFromQueryParams(queryParams)
this.unmodifiedFilterRules = [] this.unmodifiedFilterRules = []
@@ -366,7 +388,7 @@ export class DocumentListComponent
} }
saveViewConfig() { saveViewConfig() {
if (this.list.activeSavedViewId != null) { if (this.list.activeSavedViewId != null && this.activeSavedViewCanChange) {
let savedView: SavedView = { let savedView: SavedView = {
id: this.list.activeSavedViewId, id: this.list.activeSavedViewId,
filter_rules: this.list.filterRules, filter_rules: this.list.filterRules,
@@ -380,6 +402,7 @@ export class DocumentListComponent
.pipe(first()) .pipe(first())
.subscribe({ .subscribe({
next: (view) => { next: (view) => {
this.activeSavedView = view
this.unmodifiedSavedView = view this.unmodifiedSavedView = view
this.toastService.showInfo( this.toastService.showInfo(
$localize`View "${this.list.activeSavedViewTitle}" saved successfully.` $localize`View "${this.list.activeSavedViewTitle}" saved successfully.`
@@ -401,6 +424,11 @@ export class DocumentListComponent
.getCached(viewID) .getCached(viewID)
.pipe(first()) .pipe(first())
.subscribe((view) => { .subscribe((view) => {
if (!view) {
this.activeSavedView = null
return
}
this.activeSavedView = view
this.unmodifiedSavedView = view this.unmodifiedSavedView = view
this.list.activateSavedView(view) this.list.activateSavedView(view)
this.list.reload(() => { this.list.reload(() => {

View File

@@ -25,15 +25,17 @@
</div> </div>
</div> </div>
<div class="col-auto"> <div class="col-auto">
<label class="form-label" for="name_{{view.id}}" i18n>Actions</label> @if (canDeleteSavedView(view)) {
<pngx-confirm-button <label class="form-label" for="name_{{view.id}}" i18n>Actions</label>
label="Delete" <pngx-confirm-button
i18n-label label="Delete"
(confirm)="deleteSavedView(view)" i18n-label
*pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.SavedView }" (confirm)="deleteSavedView(view)"
buttonClasses="btn-sm btn-outline-danger form-control" *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.SavedView }"
iconName="trash"> buttonClasses="btn-sm btn-outline-danger form-control"
</pngx-confirm-button> iconName="trash">
</pngx-confirm-button>
}
</div> </div>
</div> </div>
<div class="row"> <div class="row">

View File

@@ -3,7 +3,6 @@ import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
import { provideHttpClientTesting } from '@angular/common/http/testing' import { provideHttpClientTesting } from '@angular/common/http/testing'
import { ComponentFixture, TestBed } from '@angular/core/testing' import { ComponentFixture, TestBed } from '@angular/core/testing'
import { FormsModule, ReactiveFormsModule } from '@angular/forms' import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { By } from '@angular/platform-browser'
import { NgbModule } from '@ng-bootstrap/ng-bootstrap' import { NgbModule } from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons' import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
import { of, throwError } from 'rxjs' import { of, throwError } from 'rxjs'
@@ -57,6 +56,8 @@ describe('SavedViewsComponent', () => {
provide: PermissionsService, provide: PermissionsService,
useValue: { useValue: {
currentUserCan: () => true, currentUserCan: () => true,
currentUserHasObjectPermissions: () => true,
currentUserOwnsObject: () => true,
}, },
}, },
{ {
@@ -96,12 +97,11 @@ describe('SavedViewsComponent', () => {
const toastErrorSpy = jest.spyOn(toastService, 'showError') const toastErrorSpy = jest.spyOn(toastService, 'showError')
const toastSpy = jest.spyOn(toastService, 'show') const toastSpy = jest.spyOn(toastService, 'show')
const savedViewPatchSpy = jest.spyOn(savedViewService, 'patchMany') const savedViewPatchSpy = jest.spyOn(savedViewService, 'patchMany')
const control = component.savedViewsForm
const toggle = fixture.debugElement.query( .get('savedViews')
By.css('.form-check.form-switch input') .get(savedViews[0].id.toString())
) .get('show_on_dashboard')
toggle.nativeElement.checked = true control.setValue(!savedViews[0].show_on_dashboard)
toggle.nativeElement.dispatchEvent(new Event('change'))
// saved views error first // saved views error first
savedViewPatchSpy.mockReturnValueOnce( savedViewPatchSpy.mockReturnValueOnce(
@@ -116,6 +116,7 @@ describe('SavedViewsComponent', () => {
// succeed saved views // succeed saved views
savedViewPatchSpy.mockReturnValueOnce(of(savedViews as SavedView[])) savedViewPatchSpy.mockReturnValueOnce(of(savedViews as SavedView[]))
control.setValue(savedViews[0].show_on_dashboard)
component.save() component.save()
expect(toastErrorSpy).not.toHaveBeenCalled() expect(toastErrorSpy).not.toHaveBeenCalled()
expect(savedViewPatchSpy).toHaveBeenCalled() expect(savedViewPatchSpy).toHaveBeenCalled()
@@ -127,25 +128,21 @@ describe('SavedViewsComponent', () => {
expect(patchSpy).not.toHaveBeenCalled() expect(patchSpy).not.toHaveBeenCalled()
const view = savedViews[0] const view = savedViews[0]
const toggle = fixture.debugElement.query( component.savedViewsForm
By.css('.form-check.form-switch input') .get('savedViews')
) .get(view.id.toString())
toggle.nativeElement.checked = true .get('show_on_dashboard')
toggle.nativeElement.dispatchEvent(new Event('change')) .setValue(!view.show_on_dashboard)
// register change
component.savedViewsForm.get('savedViews').get(view.id.toString()).value[
'show_on_dashboard'
] = !view.show_on_dashboard
fixture.detectChanges() fixture.detectChanges()
component.save() component.save()
expect(patchSpy).toHaveBeenCalledWith([ expect(patchSpy).toHaveBeenCalledWith([
{ expect.objectContaining({
id: view.id, id: view.id,
name: view.name, name: view.name,
show_in_sidebar: view.show_in_sidebar, show_in_sidebar: view.show_in_sidebar,
show_on_dashboard: !view.show_on_dashboard, show_on_dashboard: !view.show_on_dashboard,
}, }),
]) ])
}) })
@@ -162,14 +159,17 @@ describe('SavedViewsComponent', () => {
it('should support reset', () => { it('should support reset', () => {
const view = savedViews[0] const view = savedViews[0]
component.savedViewsForm.get('savedViews').get(view.id.toString()).value[ component.savedViewsForm
'show_on_dashboard' .get('savedViews')
] = !view.show_on_dashboard .get(view.id.toString())
.get('show_on_dashboard')
.setValue(!view.show_on_dashboard)
component.reset() component.reset()
expect( expect(
component.savedViewsForm.get('savedViews').get(view.id.toString()).value[ component.savedViewsForm
'show_on_dashboard' .get('savedViews')
] .get(view.id.toString())
.get('show_on_dashboard').value
).toEqual(view.show_on_dashboard) ).toEqual(view.show_on_dashboard)
}) })
}) })

View File

@@ -11,6 +11,10 @@ import { BehaviorSubject, Observable, takeUntil } from 'rxjs'
import { DisplayMode } from 'src/app/data/document' import { DisplayMode } from 'src/app/data/document'
import { SavedView } from 'src/app/data/saved-view' import { SavedView } from 'src/app/data/saved-view'
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive' 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 { SavedViewService } from 'src/app/services/rest/saved-view.service'
import { SettingsService } from 'src/app/services/settings.service' import { SettingsService } from 'src/app/services/settings.service'
import { ToastService } from 'src/app/services/toast.service' import { ToastService } from 'src/app/services/toast.service'
@@ -41,6 +45,7 @@ export class SavedViewsComponent
implements OnInit, OnDestroy implements OnInit, OnDestroy
{ {
private savedViewService = inject(SavedViewService) private savedViewService = inject(SavedViewService)
private permissionsService = inject(PermissionsService)
private settings = inject(SettingsService) private settings = inject(SettingsService)
private toastService = inject(ToastService) private toastService = inject(ToastService)
@@ -95,16 +100,20 @@ export class SavedViewsComponent
display_mode: view.display_mode, display_mode: view.display_mode,
display_fields: view.display_fields, display_fields: view.display_fields,
} }
const canEdit = this.canEditSavedView(view)
this.savedViewsGroup.addControl( this.savedViewsGroup.addControl(
view.id.toString(), view.id.toString(),
new FormGroup({ new FormGroup({
id: new FormControl(null), id: new FormControl({ value: null, disabled: !canEdit }),
name: new FormControl(null), name: new FormControl({ value: null, disabled: !canEdit }),
show_on_dashboard: new FormControl(null), show_on_dashboard: new FormControl({
show_in_sidebar: new FormControl(null), value: null,
page_size: new FormControl(null), disabled: !canEdit,
display_mode: new FormControl(null), }),
display_fields: new FormControl([]), 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) { public deleteSavedView(savedView: SavedView) {
if (!this.canDeleteSavedView(savedView)) {
return
}
this.savedViewService.delete(savedView).subscribe(() => { this.savedViewService.delete(savedView).subscribe(() => {
this.savedViewsGroup.removeControl(savedView.id.toString()) this.savedViewsGroup.removeControl(savedView.id.toString())
this.savedViews.splice(this.savedViews.indexOf(savedView), 1) this.savedViews.splice(this.savedViews.indexOf(savedView), 1)
@@ -148,9 +160,9 @@ export class SavedViewsComponent
// only patch views that have actually changed // only patch views that have actually changed
const changed: SavedView[] = [] const changed: SavedView[] = []
Object.values(this.savedViewsGroup.controls) Object.values(this.savedViewsGroup.controls)
.filter((g: FormGroup) => !g.pristine) .filter((g: FormGroup) => g.enabled && !g.pristine)
.forEach((group: FormGroup) => { .forEach((group: FormGroup) => {
changed.push(group.value) changed.push(group.getRawValue())
}) })
if (changed.length) { if (changed.length) {
this.savedViewService.patchMany(changed).subscribe({ 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)
}
} }