Compare commits

...
23 changed files with 1016 additions and 158 deletions
+19
View File
@@ -14,6 +14,7 @@ import { DocumentListComponent } from './components/document-list/document-list.
import { DocumentAttributesComponent } from './components/manage/document-attributes/document-attributes.component'
import { MailComponent } from './components/manage/mail/mail.component'
import { SavedViewsComponent } from './components/manage/saved-views/saved-views.component'
import { ShareLinksComponent } from './components/manage/share-links/share-links.component'
import { WorkflowsComponent } from './components/manage/workflows/workflows.component'
import { NotFoundComponent } from './components/not-found/not-found.component'
import { DirtyDocGuard } from './guards/dirty-doc.guard'
@@ -310,6 +311,24 @@ export const routes: Routes = [
componentName: 'SavedViewsComponent',
},
},
{
path: 'share-links',
component: ShareLinksComponent,
canActivate: [PermissionsGuard],
data: {
requiredPermissionAny: [
{
action: PermissionAction.View,
type: PermissionType.ShareLink,
},
{
action: PermissionAction.View,
type: PermissionType.ShareLinkBundle,
},
],
componentName: 'ShareLinksComponent',
},
},
],
},
@@ -244,6 +244,15 @@
<i-bs class="me-2" name="window-stack"></i-bs><span class="nav-link-label"><ng-container i18n>Saved Views</ng-container></span>
</a>
</li>
@if (canManageShareLinks) {
<li class="nav-item app-link">
<a class="nav-link" routerLink="share-links" routerLinkActive="active" (click)="closeMenu()"
ngbPopover="Share links" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end"
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
<i-bs class="me-2" name="link"></i-bs><span class="nav-link-label"><ng-container i18n>Share links</ng-container></span>
</a>
</li>
}
<li class="nav-item app-link"
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Workflow }"
tourAnchor="tour.workflows">
@@ -221,6 +221,19 @@ export class AppFrameComponent
return this.appTitleSetting() || environment.appTitle
}
get canManageShareLinks(): boolean {
return (
this.permissionsService.currentUserCan(
PermissionAction.View,
PermissionType.ShareLink
) ||
this.permissionsService.currentUserCan(
PermissionAction.View,
PermissionType.ShareLinkBundle
)
)
}
get customAppTitle(): string {
return this.appTitleSetting()
}
@@ -7,6 +7,7 @@ import {
import { EventEmitter, signal } from '@angular/core'
import { ComponentFixture, TestBed } from '@angular/core/testing'
import { By } from '@angular/platform-browser'
import { Router } from '@angular/router'
import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
import { of, throwError } from 'rxjs'
@@ -46,7 +47,6 @@ import { StoragePathEditDialogComponent } from '../../common/edit-dialog/storage
import { TagEditDialogComponent } from '../../common/edit-dialog/tag-edit-dialog/tag-edit-dialog.component'
import { FilterableDropdownComponent } from '../../common/filterable-dropdown/filterable-dropdown.component'
import { ShareLinkBundleDialogComponent } from '../../common/share-link-bundle-dialog/share-link-bundle-dialog.component'
import { ShareLinkBundleManageDialogComponent } from '../../common/share-link-bundle-manage-dialog/share-link-bundle-manage-dialog.component'
import { BulkEditorComponent } from './bulk-editor.component'
const selectionData: SelectionData = {
@@ -82,6 +82,7 @@ describe('BulkEditorComponent', () => {
let customFieldsService: CustomFieldsService
let httpTestingController: HttpTestingController
let shareLinkBundleService: ShareLinkBundleService
let router: Router
beforeEach(async () => {
TestBed.configureTestingModule({
@@ -167,11 +168,14 @@ describe('BulkEditorComponent', () => {
provide: ShareLinkBundleService,
useValue: {
createBundle: jest.fn(),
listAllBundles: jest.fn(),
rebuildBundle: jest.fn(),
delete: jest.fn(),
},
},
{
provide: Router,
useValue: { navigate: jest.fn().mockResolvedValue(true) },
},
provideHttpClient(withInterceptorsFromDi()),
provideHttpClientTesting(),
],
@@ -189,6 +193,7 @@ describe('BulkEditorComponent', () => {
customFieldsService = TestBed.inject(CustomFieldsService)
httpTestingController = TestBed.inject(HttpTestingController)
shareLinkBundleService = TestBed.inject(ShareLinkBundleService)
router = TestBed.inject(Router)
fixture = TestBed.createComponent(BulkEditorComponent)
component = fixture.componentInstance
@@ -1824,9 +1829,9 @@ describe('BulkEditorComponent', () => {
},
}
const openSpy = jest.spyOn(modalService, 'open')
openSpy.mockReturnValueOnce(modalRef as NgbModalRef)
openSpy.mockReturnValueOnce({} as NgbModalRef)
const openSpy = jest
.spyOn(modalService, 'open')
.mockReturnValueOnce(modalRef as NgbModalRef)
;(shareLinkBundleService.createBundle as jest.Mock).mockReturnValueOnce(
of({ id: 42 })
)
@@ -1860,11 +1865,9 @@ describe('BulkEditorComponent', () => {
dialogInstance.onOpenManage()
expect(modalRef.close).toHaveBeenCalled()
expect(openSpy).toHaveBeenNthCalledWith(
2,
ShareLinkBundleManageDialogComponent,
expect.objectContaining({ backdrop: 'static', size: 'lg' })
)
expect(router.navigate).toHaveBeenCalledWith(['/share-links'], {
queryParams: { type: 'bundles' },
})
openSpy.mockRestore()
})
@@ -1917,13 +1920,10 @@ describe('BulkEditorComponent', () => {
openSpy.mockRestore()
})
it('should open share link bundle management dialog', () => {
const openSpy = jest.spyOn(modalService, 'open')
it('should navigate to share link bundle management', () => {
component.manageShareLinkBundles()
expect(openSpy).toHaveBeenCalledWith(
ShareLinkBundleManageDialogComponent,
expect.objectContaining({ backdrop: 'static', size: 'lg' })
)
openSpy.mockRestore()
expect(router.navigate).toHaveBeenCalledWith(['/share-links'], {
queryParams: { type: 'bundles' },
})
})
})
@@ -12,6 +12,7 @@ import {
FormsModule,
ReactiveFormsModule,
} from '@angular/forms'
import { Router } from '@angular/router'
import {
NgbDropdownModule,
NgbModal,
@@ -69,7 +70,6 @@ import {
import { ToggleableItemState } from '../../common/filterable-dropdown/toggleable-dropdown-button/toggleable-dropdown-button.component'
import { PermissionsDialogComponent } from '../../common/permissions-dialog/permissions-dialog.component'
import { ShareLinkBundleDialogComponent } from '../../common/share-link-bundle-dialog/share-link-bundle-dialog.component'
import { ShareLinkBundleManageDialogComponent } from '../../common/share-link-bundle-manage-dialog/share-link-bundle-manage-dialog.component'
import { ComponentWithPermissions } from '../../with-permissions/with-permissions.component'
import { CustomFieldsBulkEditDialogComponent } from './custom-fields-bulk-edit-dialog/custom-fields-bulk-edit-dialog.component'
@@ -104,6 +104,7 @@ export class BulkEditorComponent
public readonly permissionService = inject(PermissionsService)
private savedViewService = inject(SavedViewService)
private readonly shareLinkBundleService = inject(ShareLinkBundleService)
private readonly router = inject(Router)
tagSelectionModel = new FilterableDropdownSelectionModel(true)
correspondentSelectionModel = new FilterableDropdownSelectionModel()
@@ -1135,9 +1136,8 @@ export class BulkEditorComponent
}
manageShareLinkBundles() {
this.modalService.open(ShareLinkBundleManageDialogComponent, {
backdrop: 'static',
size: 'lg',
void this.router.navigate(['/share-links'], {
queryParams: { type: 'bundles' },
})
}
@@ -1,38 +1,22 @@
<div class="modal-header">
<h4 class="modal-title">{{ title }}</h4>
<button type="button" class="btn-close" aria-label="Close" (click)="close()"></button>
</div>
<div class="modal-body">
@if (loading()) {
<div class="d-flex align-items-center gap-2">
<div class="spinner-border spinner-border-sm" role="status"></div>
<span i18n>Loading share link bundles…</span>
</div>
}
<div class="border border-top-0 rounded-bottom p-3">
@if (!loading() && error()) {
<div class="alert alert-danger mb-0" role="alert">
{{ error() }}
</div>
}
@if (!loading() && !error()) {
<div class="d-flex justify-content-between align-items-center mb-2">
<p class="mb-0 text-muted small">
<ng-container i18n>Status updates every few seconds while bundles are being prepared.</ng-container>
</p>
</div>
@if (bundles().length === 0) {
<p class="mb-0 text-muted fst-italic" i18n>No share link bundles currently exist.</p>
}
@if (bundles().length > 0) {
<div class="table-responsive">
<table class="table table-sm align-middle mb-0">
<table class="table table-sm align-middle mb-0 bg-body">
<thead>
<tr>
<th scope="col" i18n>Created</th>
<th scope="col" i18n>Status</th>
<th scope="col" class="fw-normal" pngxSortable="created" [currentSortField]="sortField()" [currentSortReverse]="sortReverse()" (sort)="onSort($event)" i18n>Created</th>
<th scope="col" class="fw-normal" pngxSortable="status" [currentSortField]="sortField()" [currentSortReverse]="sortReverse()" (sort)="onSort($event)" i18n>Status</th>
<th scope="col" i18n>Size</th>
<th scope="col" i18n>Expires</th>
<th scope="col" class="fw-normal" pngxSortable="expiration" [currentSortField]="sortField()" [currentSortReverse]="sortReverse()" (sort)="onSort($event)" i18n>Expires</th>
<th scope="col" i18n>Documents</th>
<th scope="col" i18n>File version</th>
<th scope="col" class="text-end" i18n>Actions</th>
@@ -96,6 +80,9 @@
<td>
@if (bundle.expiration) {
{{ bundle.expiration | date: 'short' }}
@if (isExpired(bundle.expiration)) {
<span class="badge text-bg-danger ms-2" i18n>Expired</span>
}
}
@if (!bundle.expiration) {
<span i18n>Never</span>
@@ -104,42 +91,49 @@
<td>{{ bundle.document_count }}</td>
<td>{{ fileVersionLabel(bundle.file_version) }}</td>
<td class="text-end">
<div class="btn-group btn-group-sm">
<button
type="button"
class="btn btn-outline-primary"
[disabled]="bundle.status !== statuses.Ready"
(click)="copy(bundle)"
title="Copy share link"
i18n-title
>
@if (copiedSlug() === bundle.slug) {
<i-bs name="clipboard-check"></i-bs>
}
@if (copiedSlug() !== bundle.slug) {
<i-bs name="clipboard"></i-bs>
}
<span class="visually-hidden" i18n>Copy share link</span>
</button>
@if (bundle.status === statuses.Failed) {
<div class="d-inline-block position-relative">
<span
class="badge bg-primary small fade position-absolute top-50 end-100 translate-middle-y me-2 pe-none z-3 text-nowrap"
[class.show]="copiedSlug() === bundle.slug"
i18n
>Copied!</span>
<div class="btn-group btn-group-sm">
<button
type="button"
class="btn btn-outline-warning"
[disabled]="loading()"
(click)="retry(bundle)"
class="btn btn-outline-primary"
[disabled]="bundle.status !== statuses.Ready"
(click)="copy(bundle)"
title="Copy share link"
i18n-title
>
<i-bs name="arrow-clockwise"></i-bs>
<span class="visually-hidden" i18n>Retry</span>
@if (copiedSlug() === bundle.slug) {
<i-bs name="clipboard-check"></i-bs>
}
@if (copiedSlug() !== bundle.slug) {
<i-bs name="clipboard"></i-bs>
}
<span class="visually-hidden" i18n>Copy share link</span>
</button>
}
<pngx-confirm-button
buttonClasses="btn btn-sm btn-outline-danger"
[disabled]="loading()"
(confirm)="delete(bundle)"
iconName="trash"
>
<span class="visually-hidden" i18n>Delete share link bundle</span>
</pngx-confirm-button>
@if (bundle.status === statuses.Failed) {
<button
type="button"
class="btn btn-outline-warning"
[disabled]="loading()"
(click)="retry(bundle)"
>
<i-bs name="arrow-clockwise"></i-bs>
<span class="visually-hidden" i18n>Retry</span>
</button>
}
<pngx-confirm-button
buttonClasses="btn btn-sm btn-outline-danger"
[disabled]="loading()"
(confirm)="delete(bundle)"
iconName="trash"
>
<span class="visually-hidden" i18n>Delete share link bundle</span>
</pngx-confirm-button>
</div>
</div>
</td>
</tr>
@@ -147,10 +141,32 @@
</tbody>
</table>
</div>
<div class="d-flex flex-wrap justify-content-end align-items-center gap-3 mt-3 ms-auto">
<div class="d-flex flex-wrap justify-content-end align-items-center gap-3">
<div class="d-flex align-items-center">
<label class="small text-muted me-2" for="shareLinkBundlePageSize" i18n>Show:</label>
<select id="shareLinkBundlePageSize" class="form-select form-select-sm w-auto" [(ngModel)]="pageSize">
<option [ngValue]="25">25</option>
<option [ngValue]="50">50</option>
<option [ngValue]="100">100</option>
</select>
<span class="small text-muted ms-2 d-none d-md-inline" i18n>per page</span>
</div>
@if (total() > pageSize) {
<ngb-pagination
class="mb-0"
[pageSize]="pageSize"
[collectionSize]="total()"
[page]="page()"
[maxSize]="5"
(pageChange)="setPage($event)"
size="sm"
aria-label="Share link bundles pagination"
i18n-aria-label
></ngb-pagination>
}
</div>
</div>
}
}
</div>
<div class="modal-footer">
<button type="button" class="btn btn-outline-secondary btn-sm" (click)="close()" i18n>Close</button>
</div>
@@ -1,6 +1,5 @@
import { Clipboard } from '@angular/cdk/clipboard'
import { ComponentFixture, TestBed } from '@angular/core/testing'
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
import { of, throwError } from 'rxjs'
import { FileVersion } from 'src/app/data/share-link'
@@ -8,13 +7,15 @@ import {
ShareLinkBundleStatus,
ShareLinkBundleSummary,
} from 'src/app/data/share-link-bundle'
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
import { ShareLinkBundleService } from 'src/app/services/rest/share-link-bundle.service'
import { SettingsService } from 'src/app/services/settings.service'
import { ToastService } from 'src/app/services/toast.service'
import { environment } from 'src/environments/environment'
import { ShareLinkBundleManageDialogComponent } from './share-link-bundle-manage-dialog.component'
import { ShareLinkBundleListComponent } from './share-link-bundle-list.component'
class MockShareLinkBundleService {
listAllBundles = jest.fn()
list = jest.fn()
delete = jest.fn()
rebuildBundle = jest.fn()
}
@@ -24,13 +25,12 @@ class MockToastService {
showError = jest.fn()
}
describe('ShareLinkBundleManageDialogComponent', () => {
let component: ShareLinkBundleManageDialogComponent
let fixture: ComponentFixture<ShareLinkBundleManageDialogComponent>
describe('ShareLinkBundleListComponent', () => {
let component: ShareLinkBundleListComponent
let fixture: ComponentFixture<ShareLinkBundleListComponent>
let service: MockShareLinkBundleService
let toastService: MockToastService
let clipboard: Clipboard
let activeModal: NgbActiveModal
let originalApiBaseUrl: string
beforeEach(() => {
@@ -38,26 +38,24 @@ describe('ShareLinkBundleManageDialogComponent', () => {
toastService = new MockToastService()
originalApiBaseUrl = environment.apiBaseUrl
service.listAllBundles.mockReturnValue(of([]))
service.list.mockReturnValue(of({ count: 0, results: [] }))
service.delete.mockReturnValue(of(true))
service.rebuildBundle.mockReturnValue(of(sampleBundle()))
TestBed.configureTestingModule({
imports: [
ShareLinkBundleManageDialogComponent,
ShareLinkBundleListComponent,
NgxBootstrapIconsModule.pick(allIcons),
],
providers: [
NgbActiveModal,
{ provide: ShareLinkBundleService, useValue: service },
{ provide: ToastService, useValue: toastService },
],
})
fixture = TestBed.createComponent(ShareLinkBundleManageDialogComponent)
fixture = TestBed.createComponent(ShareLinkBundleListComponent)
component = fixture.componentInstance
clipboard = TestBed.inject(Clipboard)
activeModal = TestBed.inject(NgbActiveModal)
})
afterEach(() => {
@@ -84,28 +82,28 @@ describe('ShareLinkBundleManageDialogComponent', () => {
it('loads bundles on init and polls periodically', () => {
jest.useFakeTimers()
const bundles = [sampleBundle({ status: ShareLinkBundleStatus.Ready })]
service.listAllBundles.mockReset()
service.listAllBundles
.mockReturnValueOnce(of(bundles))
.mockReturnValue(of(bundles))
service.list.mockReset()
service.list
.mockReturnValueOnce(of({ count: bundles.length, results: bundles }))
.mockReturnValue(of({ count: bundles.length, results: bundles }))
fixture.detectChanges()
expect(service.listAllBundles).toHaveBeenCalledTimes(1)
expect(service.list).toHaveBeenCalledWith(1, 25, 'created', true)
expect(component.bundles()).toEqual(bundles)
expect(component.loading()).toBe(false)
expect(component.error()).toBeNull()
jest.advanceTimersByTime(5000)
expect(service.listAllBundles).toHaveBeenCalledTimes(2)
expect(service.list).toHaveBeenCalledTimes(2)
})
it('handles errors when loading bundles', () => {
jest.useFakeTimers()
service.listAllBundles.mockReset()
service.listAllBundles
service.list.mockReset()
service.list
.mockReturnValueOnce(throwError(() => new Error('load fail')))
.mockReturnValue(of([]))
.mockReturnValue(of({ count: 0, results: [] }))
fixture.detectChanges()
@@ -114,7 +112,57 @@ describe('ShareLinkBundleManageDialogComponent', () => {
expect(component.loading()).toBe(false)
jest.advanceTimersByTime(5000)
expect(service.listAllBundles).toHaveBeenCalledTimes(2)
expect(service.list).toHaveBeenCalledTimes(2)
})
it('loads another page', () => {
fixture.detectChanges()
component.setPage(2)
expect(service.list).toHaveBeenLastCalledWith(2, 25, 'created', true)
})
it('sorts bundles and returns to the first page', () => {
fixture.detectChanges()
component.page.set(2)
component.onSort({ column: 'status', reverse: false })
expect(component.page()).toBe(1)
expect(service.list).toHaveBeenLastCalledWith(1, 25, 'status', false)
})
it('marks expired share link bundles', () => {
service.list.mockReturnValue(
of({
count: 1,
results: [sampleBundle({ expiration: '2000-01-01T00:00:00.000Z' })],
})
)
fixture.detectChanges()
expect(fixture.nativeElement.textContent).toContain('Expired')
})
it('stores a changed page size and reloads from the first page', () => {
fixture.detectChanges()
const settingsService = TestBed.inject(SettingsService)
jest
.spyOn(settingsService, 'get')
.mockReturnValueOnce({ share_link_bundles: 25 })
const setSpy = jest.spyOn(settingsService, 'set')
jest.spyOn(settingsService, 'storeSettings').mockReturnValue(of({}))
component.page.set(2)
component.pageSize = 100
expect(setSpy).toHaveBeenCalledWith(SETTINGS_KEYS.OBJECT_LIST_SIZES, {
share_link_bundles: 100,
})
expect(component.page()).toBe(1)
expect(service.list).toHaveBeenLastCalledWith(1, 100, 'created', true)
})
it('copies bundle links when ready', () => {
@@ -126,16 +174,24 @@ describe('ShareLinkBundleManageDialogComponent', () => {
slug: 'ready-slug',
status: ShareLinkBundleStatus.Ready,
})
component.bundles.set([readyBundle])
fixture.detectChanges()
component.copy(readyBundle)
expect(clipboard.copy).toHaveBeenCalledWith(
component.getShareUrl(readyBundle)
)
expect(component.copiedSlug()).toBe('ready-slug')
expect(toastService.showInfo).toHaveBeenCalled()
expect(toastService.showInfo).not.toHaveBeenCalled()
fixture.detectChanges()
expect(
fixture.nativeElement.querySelector('.badge.show').textContent
).toContain('Copied!')
jest.advanceTimersByTime(3000)
expect(component.copiedSlug()).toBeNull()
fixture.detectChanges()
expect(fixture.nativeElement.querySelector('.badge.show')).toBeNull()
})
it('ignores copy requests for non-ready bundles', () => {
@@ -146,7 +202,7 @@ describe('ShareLinkBundleManageDialogComponent', () => {
})
it('deletes bundles and refreshes list', () => {
service.listAllBundles.mockReturnValue(of([]))
service.list.mockReturnValue(of({ count: 0, results: [] }))
service.delete.mockReturnValue(of(true))
fixture.detectChanges()
@@ -157,12 +213,12 @@ describe('ShareLinkBundleManageDialogComponent', () => {
expect(toastService.showInfo).toHaveBeenCalledWith(
expect.stringContaining('deleted.')
)
expect(service.listAllBundles).toHaveBeenCalledTimes(2)
expect(service.list).toHaveBeenCalledTimes(2)
expect(component.loading()).toBe(false)
})
it('handles delete errors gracefully', () => {
service.listAllBundles.mockReturnValue(of([]))
service.list.mockReturnValue(of({ count: 0, results: [] }))
service.delete.mockReturnValue(throwError(() => new Error('delete fail')))
fixture.detectChanges()
@@ -174,7 +230,7 @@ describe('ShareLinkBundleManageDialogComponent', () => {
})
it('retries bundle build and replaces existing entry', () => {
service.listAllBundles.mockReturnValue(of([]))
service.list.mockReturnValue(of({ count: 0, results: [] }))
const updated = sampleBundle({ status: ShareLinkBundleStatus.Ready })
service.rebuildBundle.mockReturnValue(of(updated))
@@ -189,7 +245,7 @@ describe('ShareLinkBundleManageDialogComponent', () => {
})
it('adds new bundle when retry returns unknown entry', () => {
service.listAllBundles.mockReturnValue(of([]))
service.list.mockReturnValue(of({ count: 0, results: [] }))
service.rebuildBundle.mockReturnValue(
of(sampleBundle({ id: 99, slug: 'new-slug' }))
)
@@ -203,7 +259,7 @@ describe('ShareLinkBundleManageDialogComponent', () => {
})
it('handles retry errors', () => {
service.listAllBundles.mockReturnValue(of([]))
service.list.mockReturnValue(of({ count: 0, results: [] }))
service.rebuildBundle.mockReturnValue(throwError(() => new Error('fail')))
fixture.detectChanges()
@@ -213,8 +269,8 @@ describe('ShareLinkBundleManageDialogComponent', () => {
expect(toastService.showError).toHaveBeenCalled()
})
it('maps helpers and closes dialog', () => {
service.listAllBundles.mockReturnValue(of([]))
it('maps status and file version helpers', () => {
service.list.mockReturnValue(of({ count: 0, results: [] }))
fixture.detectChanges()
expect(component.statusLabel(ShareLinkBundleStatus.Processing)).toContain(
@@ -227,9 +283,5 @@ describe('ShareLinkBundleManageDialogComponent', () => {
environment.apiBaseUrl = 'https://example.com/api/'
const url = component.getShareUrl(sampleBundle({ slug: 'sluggy' }))
expect(url).toBe('https://example.com/share/sluggy')
const closeSpy = jest.spyOn(activeModal, 'close')
component.close()
expect(closeSpy).toHaveBeenCalled()
})
})
@@ -1,7 +1,11 @@
import { Clipboard } from '@angular/cdk/clipboard'
import { CommonModule } from '@angular/common'
import { Component, OnDestroy, OnInit, inject, signal } from '@angular/core'
import { NgbActiveModal, NgbPopoverModule } from '@ng-bootstrap/ng-bootstrap'
import { FormsModule } from '@angular/forms'
import {
NgbPaginationModule,
NgbPopoverModule,
} from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { Subject, catchError, of, switchMap, takeUntil, timer } from 'rxjs'
import { FileVersion } from 'src/app/data/share-link'
@@ -11,42 +15,77 @@ import {
ShareLinkBundleStatus,
ShareLinkBundleSummary,
} from 'src/app/data/share-link-bundle'
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
import {
SortEvent,
SortableDirective,
} from 'src/app/directives/sortable.directive'
import { FileSizePipe } from 'src/app/pipes/file-size.pipe'
import { ShareLinkBundleService } from 'src/app/services/rest/share-link-bundle.service'
import { SettingsService } from 'src/app/services/settings.service'
import { ToastService } from 'src/app/services/toast.service'
import { environment } from 'src/environments/environment'
import { LoadingComponentWithPermissions } from '../../loading-component/loading.component'
import { ConfirmButtonComponent } from '../confirm-button/confirm-button.component'
import { ConfirmButtonComponent } from 'src/app/components/common/confirm-button/confirm-button.component'
import { LoadingComponentWithPermissions } from 'src/app/components/loading-component/loading.component'
@Component({
selector: 'pngx-share-link-bundle-manage-dialog',
templateUrl: './share-link-bundle-manage-dialog.component.html',
styleUrls: ['./share-link-bundle-manage-dialog.component.scss'],
selector: 'pngx-share-link-bundle-list',
templateUrl: './share-link-bundle-list.component.html',
styleUrls: ['./share-link-bundle-list.component.scss'],
imports: [
ConfirmButtonComponent,
CommonModule,
FormsModule,
NgbPaginationModule,
NgbPopoverModule,
NgxBootstrapIconsModule,
SortableDirective,
FileSizePipe,
],
})
export class ShareLinkBundleManageDialogComponent
export class ShareLinkBundleListComponent
extends LoadingComponentWithPermissions
implements OnInit, OnDestroy
{
private readonly activeModal = inject(NgbActiveModal)
private readonly shareLinkBundleService = inject(ShareLinkBundleService)
private readonly settingsService = inject(SettingsService)
private readonly toastService = inject(ToastService)
private readonly clipboard = inject(Clipboard)
title = $localize`Share link bundles`
readonly bundles = signal<ShareLinkBundleSummary[]>([])
readonly error = signal<string | null>(null)
readonly copiedSlug = signal<string | null>(null)
readonly total = signal(0)
readonly page = signal(1)
readonly sortField = signal('created')
readonly sortReverse = signal(true)
readonly statuses = ShareLinkBundleStatus
readonly fileVersions = FileVersion
get pageSize(): number {
return (
this.settingsService.get(SETTINGS_KEYS.OBJECT_LIST_SIZES)
?.share_link_bundles || 25
)
}
set pageSize(pageSize: number) {
this.settingsService.set(SETTINGS_KEYS.OBJECT_LIST_SIZES, {
...this.settingsService.get(SETTINGS_KEYS.OBJECT_LIST_SIZES),
share_link_bundles: pageSize,
})
this.settingsService.storeSettings().subscribe({
next: () => {
this.page.set(1)
this.triggerRefresh(false)
},
error: (error) => {
this.toastService.showError($localize`Error saving settings`, error)
},
})
}
private readonly refresh$ = new Subject<boolean>()
ngOnInit(): void {
@@ -57,25 +96,33 @@ export class ShareLinkBundleManageDialogComponent
this.loading.set(true)
}
this.error.set(null)
return this.shareLinkBundleService.listAllBundles().pipe(
catchError((error) => {
if (!silent) {
this.loading.set(false)
}
this.error.set($localize`Failed to load share link bundles.`)
this.toastService.showError(
$localize`Error retrieving share link bundles.`,
error
)
return of(null)
})
)
return this.shareLinkBundleService
.list(
this.page(),
this.pageSize,
this.sortField(),
this.sortReverse()
)
.pipe(
catchError((error) => {
if (!silent) {
this.loading.set(false)
}
this.error.set($localize`Failed to load share link bundles.`)
this.toastService.showError(
$localize`Error retrieving share link bundles.`,
error
)
return of(null)
})
)
}),
takeUntil(this.unsubscribeNotifier)
)
.subscribe((results) => {
if (results) {
this.bundles.set(results)
this.bundles.set(results.results)
this.total.set(results.count)
this.copiedSlug.set(null)
}
this.loading.set(false)
@@ -98,6 +145,18 @@ export class ShareLinkBundleManageDialogComponent
}`
}
setPage(page: number): void {
this.page.set(page)
this.triggerRefresh(false)
}
onSort(event: SortEvent): void {
this.sortField.set(event.column || 'created')
this.sortReverse.set(event.column ? event.reverse : true)
this.page.set(1)
this.triggerRefresh(false)
}
copy(bundle: ShareLinkBundleSummary): void {
if (bundle.status !== ShareLinkBundleStatus.Ready) {
return
@@ -108,7 +167,6 @@ export class ShareLinkBundleManageDialogComponent
setTimeout(() => {
this.copiedSlug.set(null)
}, 3000)
this.toastService.showInfo($localize`Share link copied to clipboard.`)
}
}
@@ -117,6 +175,9 @@ export class ShareLinkBundleManageDialogComponent
this.loading.set(true)
this.shareLinkBundleService.delete(bundle).subscribe({
next: () => {
if (this.bundles().length === 1 && this.page() > 1) {
this.page.update((page) => page - 1)
}
this.toastService.showInfo($localize`Share link bundle deleted.`)
this.triggerRefresh(false)
},
@@ -153,8 +214,8 @@ export class ShareLinkBundleManageDialogComponent
return SHARE_LINK_BUNDLE_FILE_VERSION_LABELS[version] ?? version
}
close(): void {
this.activeModal.close()
isExpired(expiration?: string): boolean {
return !!expiration && Date.parse(expiration) <= Date.now()
}
private replaceBundle(updated: ShareLinkBundleSummary): void {
@@ -0,0 +1,110 @@
<div class="border border-top-0 rounded-bottom p-3">
@if (!loading() && error()) {
<div class="alert alert-danger mb-0" role="alert">{{ error() }}</div>
}
@if (!loading() && !error() && links().length === 0) {
<p class="mb-0 text-muted fst-italic" i18n>
No document share links currently exist.
</p>
}
@if (!loading() && !error() && links().length > 0) {
<div class="table-responsive">
<table class="table table-sm align-middle mb-0 bg-body">
<thead>
<tr>
<th scope="col" class="fw-normal" pngxSortable="document__title" [currentSortField]="sortField()" [currentSortReverse]="sortReverse()" (sort)="onSort($event)" i18n>Document</th>
<th scope="col" class="fw-normal" pngxSortable="created" [currentSortField]="sortField()" [currentSortReverse]="sortReverse()" (sort)="onSort($event)" i18n>Created</th>
<th scope="col" class="fw-normal" pngxSortable="expiration" [currentSortField]="sortField()" [currentSortReverse]="sortReverse()" (sort)="onSort($event)" i18n>Expires</th>
<th scope="col" i18n>File version</th>
<th scope="col" class="text-end" i18n>Actions</th>
</tr>
</thead>
<tbody>
@for (link of links(); track link.id) {
<tr>
<td>
<a routerLink="/documents/{{ link.document }}">{{ link.document_title | documentTitle }}</a>
<span class="badge bg-primary text-primary-text-contrast ms-3 small fs-normal cursor-pointer" (click)="copyDocumentID(link.document)">
@if (copiedDocumentID() === link.document) {
<i-bs width="1em" height="1em" name="clipboard-check" class="me-1"></i-bs><ng-container i18n>Copied!</ng-container>
} @else {
ID: {{link.document}}
}
</span>
</td>
<td>{{ link.created | date: 'short' }}</td>
<td>
@if (link.expiration) {
{{ link.expiration | date: 'short' }}
@if (isExpired(link.expiration)) {
<span class="badge text-bg-danger ms-2" i18n>Expired</span>
}
} @else {
<span i18n>Never</span>
}
</td>
<td>{{ fileVersionLabel(link.file_version) }}</td>
<td class="text-end">
<div class="d-inline-block position-relative">
<span
class="badge bg-primary small fade position-absolute top-50 end-100 translate-middle-y me-2 pe-none z-3 text-nowrap"
[class.show]="copiedID() === link.id"
i18n
>Copied!</span>
<div class="btn-group btn-group-sm">
<button
type="button"
class="btn btn-outline-primary"
(click)="copy(link)"
title="Copy share link"
i18n-title
>
@if (copiedID() === link.id) {
<i-bs name="clipboard-check"></i-bs>
} @else {
<i-bs name="clipboard"></i-bs>
}
<span class="visually-hidden" i18n>Copy share link</span>
</button>
<pngx-confirm-button
*pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.ShareLink }"
buttonClasses="btn btn-sm btn-outline-danger"
(confirm)="delete(link)"
iconName="trash"
>
<span class="visually-hidden" i18n>Delete share link</span>
</pngx-confirm-button>
</div>
</div>
</td>
</tr>
}
</tbody>
</table>
</div>
<div class="d-flex flex-wrap justify-content-end align-items-center gap-3 mt-3 ms-auto">
<div class="d-flex align-items-center">
<label class="small text-muted me-2" for="shareLinkPageSize" i18n>Show:</label>
<select id="shareLinkPageSize" class="form-select form-select-sm w-auto" [(ngModel)]="pageSize">
<option [ngValue]="25">25</option>
<option [ngValue]="50">50</option>
<option [ngValue]="100">100</option>
</select>
<span class="small text-muted ms-2 d-none d-md-inline" i18n>per page</span>
</div>
@if (total() > pageSize) {
<ngb-pagination
class="mb-0"
[pageSize]="pageSize"
[collectionSize]="total()"
[page]="page()"
[maxSize]="5"
(pageChange)="setPage($event)"
size="sm"
aria-label="Share links pagination"
i18n-aria-label
></ngb-pagination>
}
</div>
}
</div>
@@ -0,0 +1,155 @@
import { Clipboard } from '@angular/cdk/clipboard'
import { ComponentFixture, TestBed } from '@angular/core/testing'
import { RouterTestingModule } from '@angular/router/testing'
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
import { of, throwError } from 'rxjs'
import { FileVersion, ShareLink } from 'src/app/data/share-link'
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
import { ShareLinkService } from 'src/app/services/rest/share-link.service'
import { SettingsService } from 'src/app/services/settings.service'
import { ToastService } from 'src/app/services/toast.service'
import { ShareLinkListComponent } from './share-link-list.component'
describe('ShareLinkListComponent', () => {
let component: ShareLinkListComponent
let fixture: ComponentFixture<ShareLinkListComponent>
let service: jest.Mocked<Pick<ShareLinkService, 'list' | 'delete'>>
let clipboard: Clipboard
let toastService: jest.Mocked<Pick<ToastService, 'showInfo' | 'showError'>>
const link = {
id: 1,
document: 42,
document_title: 'Test document',
slug: 'share-slug',
created: new Date().toISOString(),
expiration: null,
file_version: FileVersion.Archive,
} as ShareLink
beforeEach(() => {
service = {
list: jest.fn().mockReturnValue(of({ count: 1, results: [link] })),
delete: jest.fn().mockReturnValue(of(true)),
}
toastService = {
showInfo: jest.fn(),
showError: jest.fn(),
}
TestBed.configureTestingModule({
imports: [
ShareLinkListComponent,
NgxBootstrapIconsModule.pick(allIcons),
RouterTestingModule,
],
providers: [
{ provide: ShareLinkService, useValue: service },
{ provide: ToastService, useValue: toastService },
],
})
fixture = TestBed.createComponent(ShareLinkListComponent)
component = fixture.componentInstance
clipboard = TestBed.inject(Clipboard)
})
afterEach(() => {
jest.clearAllTimers()
jest.useRealTimers()
})
it('loads and renders document share links', () => {
fixture.detectChanges()
expect(service.list).toHaveBeenCalledWith(1, 25, 'created', true)
expect(component.links()).toEqual([link])
expect(fixture.nativeElement.textContent).toContain('Test document')
expect(fixture.nativeElement.textContent).toContain('ID: 42')
})
it('loads another page', () => {
fixture.detectChanges()
component.setPage(2)
expect(service.list).toHaveBeenLastCalledWith(2, 25, 'created', true)
})
it('sorts links and returns to the first page', () => {
fixture.detectChanges()
component.page.set(2)
component.onSort({ column: 'expiration', reverse: false })
expect(component.page()).toBe(1)
expect(service.list).toHaveBeenLastCalledWith(1, 25, 'expiration', false)
})
it('marks expired share links', () => {
service.list.mockReturnValue(
of({
count: 1,
results: [
{
...link,
expiration: '2000-01-01T00:00:00.000Z',
},
],
})
)
fixture.detectChanges()
expect(fixture.nativeElement.textContent).toContain('Expired')
})
it('stores a changed page size and reloads from the first page', () => {
const settingsService = TestBed.inject(SettingsService)
jest.spyOn(settingsService, 'get').mockReturnValueOnce({ share_links: 25 })
const setSpy = jest.spyOn(settingsService, 'set')
jest.spyOn(settingsService, 'storeSettings').mockReturnValue(of({}))
const reloadSpy = jest.spyOn(component, 'reload')
component.page.set(2)
component.pageSize = 50
expect(setSpy).toHaveBeenCalledWith(SETTINGS_KEYS.OBJECT_LIST_SIZES, {
share_links: 50,
})
expect(component.page()).toBe(1)
expect(reloadSpy).toHaveBeenCalled()
})
it('shows local copy feedback without a toast', () => {
jest.useFakeTimers()
jest.spyOn(clipboard, 'copy').mockReturnValue(true)
fixture.detectChanges()
component.copy(link)
fixture.detectChanges()
expect(component.copiedID()).toBe(link.id)
expect(fixture.nativeElement.querySelector('.badge.show')).not.toBeNull()
expect(toastService.showInfo).not.toHaveBeenCalled()
jest.advanceTimersByTime(3000)
expect(component.copiedID()).toBeNull()
})
it('deletes a link and reloads the list', () => {
fixture.detectChanges()
component.delete(link)
expect(service.delete).toHaveBeenCalledWith(link)
expect(service.list).toHaveBeenCalledTimes(2)
expect(toastService.showInfo).toHaveBeenCalled()
})
it('shows an error when loading fails', () => {
service.list.mockReturnValue(throwError(() => new Error('load failed')))
fixture.detectChanges()
expect(component.error()).toContain('Failed to load share links.')
expect(toastService.showError).toHaveBeenCalled()
})
})
@@ -0,0 +1,172 @@
import { Clipboard } from '@angular/cdk/clipboard'
import { CommonModule } from '@angular/common'
import { Component, OnInit, inject, signal } from '@angular/core'
import { FormsModule } from '@angular/forms'
import { RouterModule } from '@angular/router'
import { NgbPaginationModule } from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { takeUntil } from 'rxjs'
import { ConfirmButtonComponent } from 'src/app/components/common/confirm-button/confirm-button.component'
import { LoadingComponentWithPermissions } from 'src/app/components/loading-component/loading.component'
import { FileVersion, ShareLink } from 'src/app/data/share-link'
import { SHARE_LINK_BUNDLE_FILE_VERSION_LABELS } from 'src/app/data/share-link-bundle'
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
import {
SortEvent,
SortableDirective,
} from 'src/app/directives/sortable.directive'
import { DocumentTitlePipe } from 'src/app/pipes/document-title.pipe'
import {
PermissionAction,
PermissionType,
} from 'src/app/services/permissions.service'
import { ShareLinkService } from 'src/app/services/rest/share-link.service'
import { SettingsService } from 'src/app/services/settings.service'
import { ToastService } from 'src/app/services/toast.service'
import { environment } from 'src/environments/environment'
@Component({
selector: 'pngx-share-link-list',
templateUrl: './share-link-list.component.html',
imports: [
CommonModule,
ConfirmButtonComponent,
DocumentTitlePipe,
FormsModule,
IfPermissionsDirective,
NgbPaginationModule,
NgxBootstrapIconsModule,
RouterModule,
SortableDirective,
],
})
export class ShareLinkListComponent
extends LoadingComponentWithPermissions
implements OnInit
{
private readonly clipboard = inject(Clipboard)
private readonly shareLinkService = inject(ShareLinkService)
private readonly settingsService = inject(SettingsService)
private readonly toastService = inject(ToastService)
readonly links = signal<ShareLink[]>([])
readonly total = signal(0)
readonly page = signal(1)
readonly sortField = signal('created')
readonly sortReverse = signal(true)
readonly copiedID = signal<number | null>(null)
readonly copiedDocumentID = signal<number | null>(null)
readonly error = signal<string | null>(null)
readonly PermissionAction = PermissionAction
readonly PermissionType = PermissionType
get pageSize(): number {
return (
this.settingsService.get(SETTINGS_KEYS.OBJECT_LIST_SIZES)?.share_links ||
25
)
}
set pageSize(pageSize: number) {
this.settingsService.set(SETTINGS_KEYS.OBJECT_LIST_SIZES, {
...this.settingsService.get(SETTINGS_KEYS.OBJECT_LIST_SIZES),
share_links: pageSize,
})
this.settingsService.storeSettings().subscribe({
next: () => {
this.page.set(1)
this.reload()
},
error: (error) => {
this.toastService.showError($localize`Error saving settings`, error)
},
})
}
ngOnInit(): void {
this.reload()
}
reload(): void {
this.loading.set(true)
this.error.set(null)
this.shareLinkService
.list(this.page(), this.pageSize, this.sortField(), this.sortReverse())
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe({
next: (results) => {
this.links.set(results.results)
this.total.set(results.count)
this.loading.set(false)
},
error: (error) => {
this.loading.set(false)
this.error.set($localize`Failed to load share links.`)
this.toastService.showError(
$localize`Error retrieving share links.`,
error
)
},
})
}
setPage(page: number): void {
this.page.set(page)
this.reload()
}
onSort(event: SortEvent): void {
this.sortField.set(event.column || 'created')
this.sortReverse.set(event.column ? event.reverse : true)
this.page.set(1)
this.reload()
}
getShareUrl(link: ShareLink): string {
const apiURL = new URL(environment.apiBaseUrl)
return `${apiURL.origin}${apiURL.pathname.replace(/\/api\/$/, '/share/')}${
link.slug
}`
}
fileVersionLabel(version: FileVersion): string {
return SHARE_LINK_BUNDLE_FILE_VERSION_LABELS[version] ?? version
}
isExpired(expiration?: string): boolean {
return !!expiration && Date.parse(expiration) <= Date.now()
}
copy(link: ShareLink): void {
if (this.clipboard.copy(this.getShareUrl(link))) {
this.copiedID.set(link.id)
setTimeout(() => this.copiedID.set(null), 3000)
}
}
delete(link: ShareLink): void {
this.shareLinkService.delete(link).subscribe({
next: () => {
if (this.links().length === 1 && this.page() > 1) {
this.page.update((page) => page - 1)
}
this.toastService.showInfo($localize`Share link deleted.`)
this.reload()
},
error: (error) => {
this.toastService.showError(
$localize`Error deleting share link.`,
error
)
},
})
}
copyDocumentID(documentID: number): void {
if (this.clipboard.copy(documentID.toString())) {
this.copiedDocumentID.set(documentID)
setTimeout(() => this.copiedDocumentID.set(null), 3000)
}
}
}
@@ -0,0 +1,34 @@
<pngx-page-header
title="Share links"
i18n-title
info="Manage public links to individual documents and document bundles."
i18n-info
[loading]="loading()"
></pngx-page-header>
<ul
ngbNav
#nav="ngbNav"
class="nav-tabs"
[activeId]="activeNavID()"
(activeIdChange)="selectTab($event)"
>
@if (canViewDocumentLinks) {
<li [ngbNavItem]="ShareLinksNavIDs.DocumentLinks">
<button ngbNavLink i18n>Document links</button>
<ng-template ngbNavContent>
<pngx-share-link-list></pngx-share-link-list>
</ng-template>
</li>
}
@if (canViewBundles) {
<li [ngbNavItem]="ShareLinksNavIDs.Bundles">
<button ngbNavLink i18n>Bundles</button>
<ng-template ngbNavContent>
<pngx-share-link-bundle-list></pngx-share-link-bundle-list>
</ng-template>
</li>
}
</ul>
<div class="bg-body" [ngbNavOutlet]="nav"></div>
@@ -0,0 +1,102 @@
import { ComponentFixture, TestBed } from '@angular/core/testing'
import { ActivatedRoute, convertToParamMap, Router } from '@angular/router'
import { NgbNavModule } from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
import { of } from 'rxjs'
import {
PermissionAction,
PermissionsService,
PermissionType,
} from 'src/app/services/permissions.service'
import { ShareLinkBundleService } from 'src/app/services/rest/share-link-bundle.service'
import { ShareLinkService } from 'src/app/services/rest/share-link.service'
import { ToastService } from 'src/app/services/toast.service'
import { PageHeaderComponent } from '../../common/page-header/page-header.component'
import { ShareLinksComponent, ShareLinksNavIDs } from './share-links.component'
describe('ShareLinksComponent', () => {
let fixture: ComponentFixture<ShareLinksComponent>
let permissionsService: PermissionsService
let router: Router
const configure = async (type: string = null) => {
await TestBed.configureTestingModule({
imports: [
ShareLinksComponent,
NgbNavModule,
NgxBootstrapIconsModule.pick(allIcons),
PageHeaderComponent,
],
providers: [
PermissionsService,
{
provide: ActivatedRoute,
useValue: {
snapshot: { queryParamMap: convertToParamMap({ type }) },
},
},
{
provide: Router,
useValue: { navigate: jest.fn().mockResolvedValue(true) },
},
{
provide: ShareLinkBundleService,
useValue: {
list: jest.fn().mockReturnValue(of({ count: 0, results: [] })),
rebuildBundle: jest.fn(),
delete: jest.fn(),
},
},
{
provide: ShareLinkService,
useValue: {
list: jest.fn().mockReturnValue(of({ count: 0, results: [] })),
delete: jest.fn(),
},
},
{
provide: ToastService,
useValue: { showInfo: jest.fn(), showError: jest.fn() },
},
],
}).compileComponents()
permissionsService = TestBed.inject(PermissionsService)
router = TestBed.inject(Router)
}
afterEach(() => TestBed.resetTestingModule())
it('uses the requested bundles tab when permitted', async () => {
await configure(ShareLinksNavIDs.Bundles)
jest
.spyOn(permissionsService, 'currentUserCan')
.mockImplementation(
(action, type) =>
action === PermissionAction.View &&
type === PermissionType.ShareLinkBundle
)
fixture = TestBed.createComponent(ShareLinksComponent)
fixture.detectChanges()
expect(fixture.componentInstance.activeNavID()).toBe(
ShareLinksNavIDs.Bundles
)
expect(fixture.nativeElement.textContent).not.toContain('Document links')
})
it('updates the URL when a tab is selected', async () => {
await configure()
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
fixture = TestBed.createComponent(ShareLinksComponent)
fixture.componentInstance.selectTab(ShareLinksNavIDs.Bundles)
expect(router.navigate).toHaveBeenCalledWith([], {
relativeTo: TestBed.inject(ActivatedRoute),
queryParams: { type: ShareLinksNavIDs.Bundles },
queryParamsHandling: 'merge',
})
})
})
@@ -0,0 +1,78 @@
import { Component, computed, inject, signal, viewChild } from '@angular/core'
import { ActivatedRoute, Router } from '@angular/router'
import { NgbNavModule } from '@ng-bootstrap/ng-bootstrap'
import {
PermissionAction,
PermissionsService,
PermissionType,
} from 'src/app/services/permissions.service'
import { PageHeaderComponent } from '../../common/page-header/page-header.component'
import { ShareLinkBundleListComponent } from './share-link-bundle-list/share-link-bundle-list.component'
import { ShareLinkListComponent } from './share-link-list/share-link-list.component'
export enum ShareLinksNavIDs {
DocumentLinks = 'documents',
Bundles = 'bundles',
}
@Component({
selector: 'pngx-share-links',
templateUrl: './share-links.component.html',
imports: [
NgbNavModule,
PageHeaderComponent,
ShareLinkBundleListComponent,
ShareLinkListComponent,
],
})
export class ShareLinksComponent {
private readonly route = inject(ActivatedRoute)
private readonly router = inject(Router)
private readonly permissionsService = inject(PermissionsService)
readonly ShareLinksNavIDs = ShareLinksNavIDs
readonly activeNavID = signal(this.getInitialNavID())
private readonly documentLinks = viewChild(ShareLinkListComponent)
private readonly bundles = viewChild(ShareLinkBundleListComponent)
readonly loading = computed(() => {
const activeList =
this.activeNavID() === ShareLinksNavIDs.DocumentLinks
? this.documentLinks()
: this.bundles()
return activeList?.loading() ?? true
})
get canViewDocumentLinks(): boolean {
return this.permissionsService.currentUserCan(
PermissionAction.View,
PermissionType.ShareLink
)
}
get canViewBundles(): boolean {
return this.permissionsService.currentUserCan(
PermissionAction.View,
PermissionType.ShareLinkBundle
)
}
selectTab(tab: ShareLinksNavIDs): void {
this.activeNavID.set(tab)
void this.router.navigate([], {
relativeTo: this.route,
queryParams: { type: tab },
queryParamsHandling: 'merge',
})
}
private getInitialNavID(): ShareLinksNavIDs {
const requestedTab = this.route.snapshot.queryParamMap.get('type')
if (requestedTab === ShareLinksNavIDs.Bundles && this.canViewBundles) {
return ShareLinksNavIDs.Bundles
}
if (this.canViewDocumentLinks) {
return ShareLinksNavIDs.DocumentLinks
}
return ShareLinksNavIDs.Bundles
}
}
+2
View File
@@ -26,5 +26,7 @@ export interface ShareLink extends ObjectWithPermissions {
document: number // Document
document_title?: string
file_version: string
}
+2
View File
@@ -228,6 +228,8 @@ export const SETTINGS: UiSetting[] = [
document_types: 25,
tags: 25,
storage_paths: 25,
share_links: 25,
share_link_bundles: 25,
},
},
{
@@ -48,13 +48,4 @@ describe('ShareLinkBundleService', () => {
expect(req.request.body).toEqual({})
req.flush({})
})
it('lists bundles with expected parameters', () => {
subscription = service.listAllBundles().subscribe()
const req = httpTestingController.expectOne(
`${environment.apiBaseUrl}${endpoint}/?page=1&page_size=1000&ordering=-created`
)
expect(req.request.method).toBe('GET')
req.flush({ results: [] })
})
})
@@ -1,6 +1,5 @@
import { Injectable } from '@angular/core'
import { Observable } from 'rxjs'
import { map } from 'rxjs/operators'
import {
ShareLinkBundleCreatePayload,
ShareLinkBundleSummary,
@@ -32,10 +31,4 @@ export class ShareLinkBundleService extends AbstractNameFilterService<ShareLinkB
{}
)
}
listAllBundles(): Observable<ShareLinkBundleSummary[]> {
return this.list(1, 1000, 'created', true).pipe(
map((response) => response.results)
)
}
}
+4
View File
@@ -626,6 +626,10 @@ ul.pagination {
table.table {
--bs-table-color: var(--bs-body-color);
--bs-table-bg: var(--bs-light-rgb);
&.bg-body {
--bs-table-bg: var(--bs-body-bg);
}
}
.close {
+6
View File
@@ -2812,6 +2812,11 @@ class AcknowledgeTasksViewSerializer(serializers.Serializer[dict[str, Any]]):
class ShareLinkSerializer(OwnedObjectSerializer):
document_title = serializers.CharField(
source="document.title",
read_only=True,
)
class Meta:
model = ShareLink
fields = (
@@ -2820,6 +2825,7 @@ class ShareLinkSerializer(OwnedObjectSerializer):
"expiration",
"slug",
"document",
"document_title",
"file_version",
)
+32
View File
@@ -3735,6 +3735,7 @@ class TestDocumentApi(DirectoriesMixin, ConsumeTaskMixin, APITestCase):
},
)
self.assertEqual(resp.status_code, status.HTTP_201_CREATED)
self.assertEqual(resp.data["document_title"], doc.title)
resp = self.client.post(
"/api/share_links/",
@@ -3745,6 +3746,17 @@ class TestDocumentApi(DirectoriesMixin, ConsumeTaskMixin, APITestCase):
},
)
self.assertEqual(resp.status_code, status.HTTP_201_CREATED)
self.assertEqual(resp.data["document_title"], doc.title)
response = self.client.get("/api/share_links/", format="json")
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(response.data["count"], 2)
self.assertTrue(
all(
link["document_title"] == doc.title for link in response.data["results"]
),
)
response = self.client.get(
f"/api/documents/{doc.pk}/share_links/",
@@ -3756,6 +3768,9 @@ class TestDocumentApi(DirectoriesMixin, ConsumeTaskMixin, APITestCase):
resp_data = response.json()
self.assertEqual(len(resp_data), 2)
self.assertTrue(
all(link["document_title"] == doc.title for link in resp_data),
)
self.assertGreater(len(resp_data[1]["slug"]), 0)
self.assertIsNone(resp_data[1]["expiration"])
@@ -3781,6 +3796,23 @@ class TestDocumentApi(DirectoriesMixin, ConsumeTaskMixin, APITestCase):
self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND)
def test_order_share_links_by_document_title(self) -> None:
document_zulu = Document.objects.create(title="Zulu")
document_alpha = Document.objects.create(title="Alpha")
ShareLink.objects.create(document=document_zulu, slug="zulu-link")
ShareLink.objects.create(document=document_alpha, slug="alpha-link")
response = self.client.get(
"/api/share_links/?ordering=document__title",
format="json",
)
self.assertEqual(response.status_code, status.HTTP_200_OK)
self.assertEqual(
[link["document_title"] for link in response.data["results"]],
["Alpha", "Zulu"],
)
def test_share_links_permissions_aware(self) -> None:
"""
GIVEN:
+10 -3
View File
@@ -1888,7 +1888,14 @@ class DocumentViewSet(
now = timezone.now()
links = (
ShareLink.objects.filter(document=doc)
.only("pk", "created", "expiration", "slug")
.select_related("document")
.only(
"pk",
"created",
"expiration",
"slug",
"document__title",
)
.exclude(expiration__lt=now)
.order_by("-created")
)
@@ -4553,7 +4560,7 @@ class ShareLinkViewSet(
):
model = ShareLink
queryset = ShareLink.objects.all()
queryset = ShareLink.objects.select_related("document")
serializer_class = ShareLinkSerializer
pagination_class = StandardPagination
@@ -4564,7 +4571,7 @@ class ShareLinkViewSet(
PermittedObjectsFilter,
)
filterset_class = ShareLinkFilterSet
ordering_fields = ("created", "expiration", "document")
ordering_fields = ("created", "expiration", "document__title")
@extend_schema_view(