mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-09-10 11:48:00 +00:00
OK, basic share link management page
This commit is contained in:
+98
@@ -0,0 +1,98 @@
|
||||
<div class="border border-top-0 rounded-bottom p-3">
|
||||
@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 links…</span>
|
||||
</div>
|
||||
}
|
||||
@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">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col" i18n>Document</th>
|
||||
<th scope="col" i18n>Created</th>
|
||||
<th scope="col" 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 }}">
|
||||
<ng-container i18n>Document #{{ link.document }}</ng-container>
|
||||
</a>
|
||||
</td>
|
||||
<td>{{ link.created | date: 'short' }}</td>
|
||||
<td>
|
||||
@if (link.expiration) {
|
||||
{{ link.expiration | date: 'short' }}
|
||||
} @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>
|
||||
@if (total() > pageSize) {
|
||||
<div class="d-flex justify-content-end mt-3">
|
||||
<ngb-pagination
|
||||
[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>
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
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 { ShareLinkService } from 'src/app/services/rest/share-link.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,
|
||||
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('Document #42')
|
||||
})
|
||||
|
||||
it('loads another page', () => {
|
||||
fixture.detectChanges()
|
||||
component.setPage(2)
|
||||
|
||||
expect(service.list).toHaveBeenLastCalledWith(2, 25, 'created', true)
|
||||
})
|
||||
|
||||
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()
|
||||
})
|
||||
})
|
||||
+117
@@ -0,0 +1,117 @@
|
||||
import { Clipboard } from '@angular/cdk/clipboard'
|
||||
import { CommonModule } from '@angular/common'
|
||||
import { Component, OnInit, inject, signal } from '@angular/core'
|
||||
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 { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
||||
import {
|
||||
PermissionAction,
|
||||
PermissionType,
|
||||
} from 'src/app/services/permissions.service'
|
||||
import { ShareLinkService } from 'src/app/services/rest/share-link.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,
|
||||
IfPermissionsDirective,
|
||||
NgbPaginationModule,
|
||||
NgxBootstrapIconsModule,
|
||||
RouterModule,
|
||||
],
|
||||
})
|
||||
export class ShareLinkListComponent
|
||||
extends LoadingComponentWithPermissions
|
||||
implements OnInit
|
||||
{
|
||||
private readonly clipboard = inject(Clipboard)
|
||||
private readonly shareLinkService = inject(ShareLinkService)
|
||||
private readonly toastService = inject(ToastService)
|
||||
|
||||
readonly links = signal<ShareLink[]>([])
|
||||
readonly total = signal(0)
|
||||
readonly page = signal(1)
|
||||
readonly copiedID = signal<number | null>(null)
|
||||
readonly error = signal<string | null>(null)
|
||||
readonly pageSize = 25
|
||||
readonly PermissionAction = PermissionAction
|
||||
readonly PermissionType = PermissionType
|
||||
|
||||
ngOnInit(): void {
|
||||
this.reload()
|
||||
}
|
||||
|
||||
reload(): void {
|
||||
this.loading.set(true)
|
||||
this.error.set(null)
|
||||
this.shareLinkService
|
||||
.list(this.page(), this.pageSize, 'created', true)
|
||||
.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()
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
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
|
||||
)
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -16,9 +16,7 @@
|
||||
<li [ngbNavItem]="ShareLinksNavIDs.DocumentLinks">
|
||||
<button ngbNavLink i18n>Document links</button>
|
||||
<ng-template ngbNavContent>
|
||||
<div class="border border-top-0 rounded-bottom p-3 text-muted" i18n>
|
||||
Manage links to individual documents here.
|
||||
</div>
|
||||
<pngx-share-link-list></pngx-share-link-list>
|
||||
</ng-template>
|
||||
</li>
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
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'
|
||||
@@ -46,6 +47,13 @@ describe('ShareLinksComponent', () => {
|
||||
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() },
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
} 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',
|
||||
@@ -17,7 +18,12 @@ export enum ShareLinksNavIDs {
|
||||
@Component({
|
||||
selector: 'pngx-share-links',
|
||||
templateUrl: './share-links.component.html',
|
||||
imports: [NgbNavModule, PageHeaderComponent, ShareLinkBundleListComponent],
|
||||
imports: [
|
||||
NgbNavModule,
|
||||
PageHeaderComponent,
|
||||
ShareLinkBundleListComponent,
|
||||
ShareLinkListComponent,
|
||||
],
|
||||
})
|
||||
export class ShareLinksComponent {
|
||||
private readonly route = inject(ActivatedRoute)
|
||||
|
||||
Reference in New Issue
Block a user