mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-09-10 19:58:01 +00:00
Compare commits
17
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2d25400a23 | ||
|
|
279e075f58 | ||
|
|
2654dc46cf | ||
|
|
1f6a4ce402 | ||
|
|
8152c50288 | ||
|
|
ef8b87571f | ||
|
|
ce1dffbbd5 | ||
|
|
26b288f61a | ||
|
|
abd08e8000 | ||
|
|
649923d639 | ||
|
|
c869deed0e | ||
|
|
007316edf7 | ||
|
|
ade66f4228 | ||
|
|
4feeaea460 | ||
|
|
e13fb69913 | ||
|
|
80be118e05 | ||
|
|
5f4ac876e4 |
@@ -14,6 +14,7 @@ import { DocumentListComponent } from './components/document-list/document-list.
|
|||||||
import { DocumentAttributesComponent } from './components/manage/document-attributes/document-attributes.component'
|
import { DocumentAttributesComponent } from './components/manage/document-attributes/document-attributes.component'
|
||||||
import { MailComponent } from './components/manage/mail/mail.component'
|
import { MailComponent } from './components/manage/mail/mail.component'
|
||||||
import { SavedViewsComponent } from './components/manage/saved-views/saved-views.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 { WorkflowsComponent } from './components/manage/workflows/workflows.component'
|
||||||
import { NotFoundComponent } from './components/not-found/not-found.component'
|
import { NotFoundComponent } from './components/not-found/not-found.component'
|
||||||
import { DirtyDocGuard } from './guards/dirty-doc.guard'
|
import { DirtyDocGuard } from './guards/dirty-doc.guard'
|
||||||
@@ -310,6 +311,24 @@ export const routes: Routes = [
|
|||||||
componentName: 'SavedViewsComponent',
|
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>
|
<i-bs class="me-2" name="window-stack"></i-bs><span class="nav-link-label"><ng-container i18n>Saved Views</ng-container></span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</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"
|
<li class="nav-item app-link"
|
||||||
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Workflow }"
|
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Workflow }"
|
||||||
tourAnchor="tour.workflows">
|
tourAnchor="tour.workflows">
|
||||||
|
|||||||
@@ -221,6 +221,19 @@ export class AppFrameComponent
|
|||||||
return this.appTitleSetting() || environment.appTitle
|
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 {
|
get customAppTitle(): string {
|
||||||
return this.appTitleSetting()
|
return this.appTitleSetting()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
import { EventEmitter, signal } from '@angular/core'
|
import { EventEmitter, signal } from '@angular/core'
|
||||||
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||||
import { By } from '@angular/platform-browser'
|
import { By } from '@angular/platform-browser'
|
||||||
|
import { Router } from '@angular/router'
|
||||||
import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap'
|
import { NgbModal, NgbModalRef } 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'
|
||||||
@@ -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 { TagEditDialogComponent } from '../../common/edit-dialog/tag-edit-dialog/tag-edit-dialog.component'
|
||||||
import { FilterableDropdownComponent } from '../../common/filterable-dropdown/filterable-dropdown.component'
|
import { FilterableDropdownComponent } from '../../common/filterable-dropdown/filterable-dropdown.component'
|
||||||
import { ShareLinkBundleDialogComponent } from '../../common/share-link-bundle-dialog/share-link-bundle-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 { BulkEditorComponent } from './bulk-editor.component'
|
import { BulkEditorComponent } from './bulk-editor.component'
|
||||||
|
|
||||||
const selectionData: SelectionData = {
|
const selectionData: SelectionData = {
|
||||||
@@ -82,6 +82,7 @@ describe('BulkEditorComponent', () => {
|
|||||||
let customFieldsService: CustomFieldsService
|
let customFieldsService: CustomFieldsService
|
||||||
let httpTestingController: HttpTestingController
|
let httpTestingController: HttpTestingController
|
||||||
let shareLinkBundleService: ShareLinkBundleService
|
let shareLinkBundleService: ShareLinkBundleService
|
||||||
|
let router: Router
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
TestBed.configureTestingModule({
|
TestBed.configureTestingModule({
|
||||||
@@ -167,11 +168,14 @@ describe('BulkEditorComponent', () => {
|
|||||||
provide: ShareLinkBundleService,
|
provide: ShareLinkBundleService,
|
||||||
useValue: {
|
useValue: {
|
||||||
createBundle: jest.fn(),
|
createBundle: jest.fn(),
|
||||||
listAllBundles: jest.fn(),
|
|
||||||
rebuildBundle: jest.fn(),
|
rebuildBundle: jest.fn(),
|
||||||
delete: jest.fn(),
|
delete: jest.fn(),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
provide: Router,
|
||||||
|
useValue: { navigate: jest.fn().mockResolvedValue(true) },
|
||||||
|
},
|
||||||
provideHttpClient(withInterceptorsFromDi()),
|
provideHttpClient(withInterceptorsFromDi()),
|
||||||
provideHttpClientTesting(),
|
provideHttpClientTesting(),
|
||||||
],
|
],
|
||||||
@@ -189,6 +193,7 @@ describe('BulkEditorComponent', () => {
|
|||||||
customFieldsService = TestBed.inject(CustomFieldsService)
|
customFieldsService = TestBed.inject(CustomFieldsService)
|
||||||
httpTestingController = TestBed.inject(HttpTestingController)
|
httpTestingController = TestBed.inject(HttpTestingController)
|
||||||
shareLinkBundleService = TestBed.inject(ShareLinkBundleService)
|
shareLinkBundleService = TestBed.inject(ShareLinkBundleService)
|
||||||
|
router = TestBed.inject(Router)
|
||||||
|
|
||||||
fixture = TestBed.createComponent(BulkEditorComponent)
|
fixture = TestBed.createComponent(BulkEditorComponent)
|
||||||
component = fixture.componentInstance
|
component = fixture.componentInstance
|
||||||
@@ -1824,9 +1829,9 @@ describe('BulkEditorComponent', () => {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
const openSpy = jest.spyOn(modalService, 'open')
|
const openSpy = jest
|
||||||
openSpy.mockReturnValueOnce(modalRef as NgbModalRef)
|
.spyOn(modalService, 'open')
|
||||||
openSpy.mockReturnValueOnce({} as NgbModalRef)
|
.mockReturnValueOnce(modalRef as NgbModalRef)
|
||||||
;(shareLinkBundleService.createBundle as jest.Mock).mockReturnValueOnce(
|
;(shareLinkBundleService.createBundle as jest.Mock).mockReturnValueOnce(
|
||||||
of({ id: 42 })
|
of({ id: 42 })
|
||||||
)
|
)
|
||||||
@@ -1860,11 +1865,9 @@ describe('BulkEditorComponent', () => {
|
|||||||
|
|
||||||
dialogInstance.onOpenManage()
|
dialogInstance.onOpenManage()
|
||||||
expect(modalRef.close).toHaveBeenCalled()
|
expect(modalRef.close).toHaveBeenCalled()
|
||||||
expect(openSpy).toHaveBeenNthCalledWith(
|
expect(router.navigate).toHaveBeenCalledWith(['/share-links'], {
|
||||||
2,
|
queryParams: { type: 'bundles' },
|
||||||
ShareLinkBundleManageDialogComponent,
|
})
|
||||||
expect.objectContaining({ backdrop: 'static', size: 'lg' })
|
|
||||||
)
|
|
||||||
openSpy.mockRestore()
|
openSpy.mockRestore()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -1917,13 +1920,10 @@ describe('BulkEditorComponent', () => {
|
|||||||
openSpy.mockRestore()
|
openSpy.mockRestore()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should open share link bundle management dialog', () => {
|
it('should navigate to share link bundle management', () => {
|
||||||
const openSpy = jest.spyOn(modalService, 'open')
|
|
||||||
component.manageShareLinkBundles()
|
component.manageShareLinkBundles()
|
||||||
expect(openSpy).toHaveBeenCalledWith(
|
expect(router.navigate).toHaveBeenCalledWith(['/share-links'], {
|
||||||
ShareLinkBundleManageDialogComponent,
|
queryParams: { type: 'bundles' },
|
||||||
expect.objectContaining({ backdrop: 'static', size: 'lg' })
|
})
|
||||||
)
|
|
||||||
openSpy.mockRestore()
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
FormsModule,
|
FormsModule,
|
||||||
ReactiveFormsModule,
|
ReactiveFormsModule,
|
||||||
} from '@angular/forms'
|
} from '@angular/forms'
|
||||||
|
import { Router } from '@angular/router'
|
||||||
import {
|
import {
|
||||||
NgbDropdownModule,
|
NgbDropdownModule,
|
||||||
NgbModal,
|
NgbModal,
|
||||||
@@ -69,7 +70,6 @@ import {
|
|||||||
import { ToggleableItemState } from '../../common/filterable-dropdown/toggleable-dropdown-button/toggleable-dropdown-button.component'
|
import { ToggleableItemState } from '../../common/filterable-dropdown/toggleable-dropdown-button/toggleable-dropdown-button.component'
|
||||||
import { PermissionsDialogComponent } from '../../common/permissions-dialog/permissions-dialog.component'
|
import { PermissionsDialogComponent } from '../../common/permissions-dialog/permissions-dialog.component'
|
||||||
import { ShareLinkBundleDialogComponent } from '../../common/share-link-bundle-dialog/share-link-bundle-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 { ComponentWithPermissions } from '../../with-permissions/with-permissions.component'
|
||||||
import { CustomFieldsBulkEditDialogComponent } from './custom-fields-bulk-edit-dialog/custom-fields-bulk-edit-dialog.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)
|
public readonly permissionService = inject(PermissionsService)
|
||||||
private savedViewService = inject(SavedViewService)
|
private savedViewService = inject(SavedViewService)
|
||||||
private readonly shareLinkBundleService = inject(ShareLinkBundleService)
|
private readonly shareLinkBundleService = inject(ShareLinkBundleService)
|
||||||
|
private readonly router = inject(Router)
|
||||||
|
|
||||||
tagSelectionModel = new FilterableDropdownSelectionModel(true)
|
tagSelectionModel = new FilterableDropdownSelectionModel(true)
|
||||||
correspondentSelectionModel = new FilterableDropdownSelectionModel()
|
correspondentSelectionModel = new FilterableDropdownSelectionModel()
|
||||||
@@ -1135,9 +1136,8 @@ export class BulkEditorComponent
|
|||||||
}
|
}
|
||||||
|
|
||||||
manageShareLinkBundles() {
|
manageShareLinkBundles() {
|
||||||
this.modalService.open(ShareLinkBundleManageDialogComponent, {
|
void this.router.navigate(['/share-links'], {
|
||||||
backdrop: 'static',
|
queryParams: { type: 'bundles' },
|
||||||
size: 'lg',
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+73
-57
@@ -1,38 +1,22 @@
|
|||||||
<div class="modal-header">
|
<div class="border border-top-0 rounded-bottom p-3">
|
||||||
<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>
|
|
||||||
}
|
|
||||||
@if (!loading() && error()) {
|
@if (!loading() && error()) {
|
||||||
<div class="alert alert-danger mb-0" role="alert">
|
<div class="alert alert-danger mb-0" role="alert">
|
||||||
{{ error() }}
|
{{ error() }}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@if (!loading() && !error()) {
|
@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) {
|
@if (bundles().length === 0) {
|
||||||
<p class="mb-0 text-muted fst-italic" i18n>No share link bundles currently exist.</p>
|
<p class="mb-0 text-muted fst-italic" i18n>No share link bundles currently exist.</p>
|
||||||
}
|
}
|
||||||
@if (bundles().length > 0) {
|
@if (bundles().length > 0) {
|
||||||
<div class="table-responsive">
|
<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>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th scope="col" i18n>Created</th>
|
<th scope="col" class="fw-normal" pngxSortable="created" [currentSortField]="sortField()" [currentSortReverse]="sortReverse()" (sort)="onSort($event)" i18n>Created</th>
|
||||||
<th scope="col" i18n>Status</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>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>Documents</th>
|
||||||
<th scope="col" i18n>File version</th>
|
<th scope="col" i18n>File version</th>
|
||||||
<th scope="col" class="text-end" i18n>Actions</th>
|
<th scope="col" class="text-end" i18n>Actions</th>
|
||||||
@@ -96,6 +80,9 @@
|
|||||||
<td>
|
<td>
|
||||||
@if (bundle.expiration) {
|
@if (bundle.expiration) {
|
||||||
{{ bundle.expiration | date: 'short' }}
|
{{ bundle.expiration | date: 'short' }}
|
||||||
|
@if (isExpired(bundle.expiration)) {
|
||||||
|
<span class="badge text-bg-danger ms-2" i18n>Expired</span>
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@if (!bundle.expiration) {
|
@if (!bundle.expiration) {
|
||||||
<span i18n>Never</span>
|
<span i18n>Never</span>
|
||||||
@@ -104,42 +91,49 @@
|
|||||||
<td>{{ bundle.document_count }}</td>
|
<td>{{ bundle.document_count }}</td>
|
||||||
<td>{{ fileVersionLabel(bundle.file_version) }}</td>
|
<td>{{ fileVersionLabel(bundle.file_version) }}</td>
|
||||||
<td class="text-end">
|
<td class="text-end">
|
||||||
<div class="btn-group btn-group-sm">
|
<div class="d-inline-block position-relative">
|
||||||
<button
|
<span
|
||||||
type="button"
|
class="badge bg-primary small fade position-absolute top-50 end-100 translate-middle-y me-2 pe-none z-3 text-nowrap"
|
||||||
class="btn btn-outline-primary"
|
[class.show]="copiedSlug() === bundle.slug"
|
||||||
[disabled]="bundle.status !== statuses.Ready"
|
i18n
|
||||||
(click)="copy(bundle)"
|
>Copied!</span>
|
||||||
title="Copy share link"
|
<div class="btn-group btn-group-sm">
|
||||||
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) {
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="btn btn-outline-warning"
|
class="btn btn-outline-primary"
|
||||||
[disabled]="loading()"
|
[disabled]="bundle.status !== statuses.Ready"
|
||||||
(click)="retry(bundle)"
|
(click)="copy(bundle)"
|
||||||
|
title="Copy share link"
|
||||||
|
i18n-title
|
||||||
>
|
>
|
||||||
<i-bs name="arrow-clockwise"></i-bs>
|
@if (copiedSlug() === bundle.slug) {
|
||||||
<span class="visually-hidden" i18n>Retry</span>
|
<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>
|
</button>
|
||||||
}
|
@if (bundle.status === statuses.Failed) {
|
||||||
<pngx-confirm-button
|
<button
|
||||||
buttonClasses="btn btn-sm btn-outline-danger"
|
type="button"
|
||||||
[disabled]="loading()"
|
class="btn btn-outline-warning"
|
||||||
(confirm)="delete(bundle)"
|
[disabled]="loading()"
|
||||||
iconName="trash"
|
(click)="retry(bundle)"
|
||||||
>
|
>
|
||||||
<span class="visually-hidden" i18n>Delete share link bundle</span>
|
<i-bs name="arrow-clockwise"></i-bs>
|
||||||
</pngx-confirm-button>
|
<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>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -147,10 +141,32 @@
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<div class="modal-footer">
|
|
||||||
<button type="button" class="btn btn-outline-secondary btn-sm" (click)="close()" i18n>Close</button>
|
|
||||||
</div>
|
|
||||||
+87
-35
@@ -1,6 +1,5 @@
|
|||||||
import { Clipboard } from '@angular/cdk/clipboard'
|
import { Clipboard } from '@angular/cdk/clipboard'
|
||||||
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||||
import { NgbActiveModal } 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'
|
||||||
import { FileVersion } from 'src/app/data/share-link'
|
import { FileVersion } from 'src/app/data/share-link'
|
||||||
@@ -8,13 +7,15 @@ import {
|
|||||||
ShareLinkBundleStatus,
|
ShareLinkBundleStatus,
|
||||||
ShareLinkBundleSummary,
|
ShareLinkBundleSummary,
|
||||||
} from 'src/app/data/share-link-bundle'
|
} 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 { 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 { ToastService } from 'src/app/services/toast.service'
|
||||||
import { environment } from 'src/environments/environment'
|
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 {
|
class MockShareLinkBundleService {
|
||||||
listAllBundles = jest.fn()
|
list = jest.fn()
|
||||||
delete = jest.fn()
|
delete = jest.fn()
|
||||||
rebuildBundle = jest.fn()
|
rebuildBundle = jest.fn()
|
||||||
}
|
}
|
||||||
@@ -24,13 +25,12 @@ class MockToastService {
|
|||||||
showError = jest.fn()
|
showError = jest.fn()
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('ShareLinkBundleManageDialogComponent', () => {
|
describe('ShareLinkBundleListComponent', () => {
|
||||||
let component: ShareLinkBundleManageDialogComponent
|
let component: ShareLinkBundleListComponent
|
||||||
let fixture: ComponentFixture<ShareLinkBundleManageDialogComponent>
|
let fixture: ComponentFixture<ShareLinkBundleListComponent>
|
||||||
let service: MockShareLinkBundleService
|
let service: MockShareLinkBundleService
|
||||||
let toastService: MockToastService
|
let toastService: MockToastService
|
||||||
let clipboard: Clipboard
|
let clipboard: Clipboard
|
||||||
let activeModal: NgbActiveModal
|
|
||||||
let originalApiBaseUrl: string
|
let originalApiBaseUrl: string
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -38,26 +38,24 @@ describe('ShareLinkBundleManageDialogComponent', () => {
|
|||||||
toastService = new MockToastService()
|
toastService = new MockToastService()
|
||||||
originalApiBaseUrl = environment.apiBaseUrl
|
originalApiBaseUrl = environment.apiBaseUrl
|
||||||
|
|
||||||
service.listAllBundles.mockReturnValue(of([]))
|
service.list.mockReturnValue(of({ count: 0, results: [] }))
|
||||||
service.delete.mockReturnValue(of(true))
|
service.delete.mockReturnValue(of(true))
|
||||||
service.rebuildBundle.mockReturnValue(of(sampleBundle()))
|
service.rebuildBundle.mockReturnValue(of(sampleBundle()))
|
||||||
|
|
||||||
TestBed.configureTestingModule({
|
TestBed.configureTestingModule({
|
||||||
imports: [
|
imports: [
|
||||||
ShareLinkBundleManageDialogComponent,
|
ShareLinkBundleListComponent,
|
||||||
NgxBootstrapIconsModule.pick(allIcons),
|
NgxBootstrapIconsModule.pick(allIcons),
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
NgbActiveModal,
|
|
||||||
{ provide: ShareLinkBundleService, useValue: service },
|
{ provide: ShareLinkBundleService, useValue: service },
|
||||||
{ provide: ToastService, useValue: toastService },
|
{ provide: ToastService, useValue: toastService },
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|
||||||
fixture = TestBed.createComponent(ShareLinkBundleManageDialogComponent)
|
fixture = TestBed.createComponent(ShareLinkBundleListComponent)
|
||||||
component = fixture.componentInstance
|
component = fixture.componentInstance
|
||||||
clipboard = TestBed.inject(Clipboard)
|
clipboard = TestBed.inject(Clipboard)
|
||||||
activeModal = TestBed.inject(NgbActiveModal)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -84,28 +82,28 @@ describe('ShareLinkBundleManageDialogComponent', () => {
|
|||||||
it('loads bundles on init and polls periodically', () => {
|
it('loads bundles on init and polls periodically', () => {
|
||||||
jest.useFakeTimers()
|
jest.useFakeTimers()
|
||||||
const bundles = [sampleBundle({ status: ShareLinkBundleStatus.Ready })]
|
const bundles = [sampleBundle({ status: ShareLinkBundleStatus.Ready })]
|
||||||
service.listAllBundles.mockReset()
|
service.list.mockReset()
|
||||||
service.listAllBundles
|
service.list
|
||||||
.mockReturnValueOnce(of(bundles))
|
.mockReturnValueOnce(of({ count: bundles.length, results: bundles }))
|
||||||
.mockReturnValue(of(bundles))
|
.mockReturnValue(of({ count: bundles.length, results: bundles }))
|
||||||
|
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
|
|
||||||
expect(service.listAllBundles).toHaveBeenCalledTimes(1)
|
expect(service.list).toHaveBeenCalledWith(1, 25, 'created', true)
|
||||||
expect(component.bundles()).toEqual(bundles)
|
expect(component.bundles()).toEqual(bundles)
|
||||||
expect(component.loading()).toBe(false)
|
expect(component.loading()).toBe(false)
|
||||||
expect(component.error()).toBeNull()
|
expect(component.error()).toBeNull()
|
||||||
|
|
||||||
jest.advanceTimersByTime(5000)
|
jest.advanceTimersByTime(5000)
|
||||||
expect(service.listAllBundles).toHaveBeenCalledTimes(2)
|
expect(service.list).toHaveBeenCalledTimes(2)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('handles errors when loading bundles', () => {
|
it('handles errors when loading bundles', () => {
|
||||||
jest.useFakeTimers()
|
jest.useFakeTimers()
|
||||||
service.listAllBundles.mockReset()
|
service.list.mockReset()
|
||||||
service.listAllBundles
|
service.list
|
||||||
.mockReturnValueOnce(throwError(() => new Error('load fail')))
|
.mockReturnValueOnce(throwError(() => new Error('load fail')))
|
||||||
.mockReturnValue(of([]))
|
.mockReturnValue(of({ count: 0, results: [] }))
|
||||||
|
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
|
|
||||||
@@ -114,7 +112,57 @@ describe('ShareLinkBundleManageDialogComponent', () => {
|
|||||||
expect(component.loading()).toBe(false)
|
expect(component.loading()).toBe(false)
|
||||||
|
|
||||||
jest.advanceTimersByTime(5000)
|
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', () => {
|
it('copies bundle links when ready', () => {
|
||||||
@@ -126,16 +174,24 @@ describe('ShareLinkBundleManageDialogComponent', () => {
|
|||||||
slug: 'ready-slug',
|
slug: 'ready-slug',
|
||||||
status: ShareLinkBundleStatus.Ready,
|
status: ShareLinkBundleStatus.Ready,
|
||||||
})
|
})
|
||||||
|
component.bundles.set([readyBundle])
|
||||||
|
fixture.detectChanges()
|
||||||
component.copy(readyBundle)
|
component.copy(readyBundle)
|
||||||
|
|
||||||
expect(clipboard.copy).toHaveBeenCalledWith(
|
expect(clipboard.copy).toHaveBeenCalledWith(
|
||||||
component.getShareUrl(readyBundle)
|
component.getShareUrl(readyBundle)
|
||||||
)
|
)
|
||||||
expect(component.copiedSlug()).toBe('ready-slug')
|
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)
|
jest.advanceTimersByTime(3000)
|
||||||
expect(component.copiedSlug()).toBeNull()
|
expect(component.copiedSlug()).toBeNull()
|
||||||
|
fixture.detectChanges()
|
||||||
|
expect(fixture.nativeElement.querySelector('.badge.show')).toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('ignores copy requests for non-ready bundles', () => {
|
it('ignores copy requests for non-ready bundles', () => {
|
||||||
@@ -146,7 +202,7 @@ describe('ShareLinkBundleManageDialogComponent', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('deletes bundles and refreshes list', () => {
|
it('deletes bundles and refreshes list', () => {
|
||||||
service.listAllBundles.mockReturnValue(of([]))
|
service.list.mockReturnValue(of({ count: 0, results: [] }))
|
||||||
service.delete.mockReturnValue(of(true))
|
service.delete.mockReturnValue(of(true))
|
||||||
|
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
@@ -157,12 +213,12 @@ describe('ShareLinkBundleManageDialogComponent', () => {
|
|||||||
expect(toastService.showInfo).toHaveBeenCalledWith(
|
expect(toastService.showInfo).toHaveBeenCalledWith(
|
||||||
expect.stringContaining('deleted.')
|
expect.stringContaining('deleted.')
|
||||||
)
|
)
|
||||||
expect(service.listAllBundles).toHaveBeenCalledTimes(2)
|
expect(service.list).toHaveBeenCalledTimes(2)
|
||||||
expect(component.loading()).toBe(false)
|
expect(component.loading()).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('handles delete errors gracefully', () => {
|
it('handles delete errors gracefully', () => {
|
||||||
service.listAllBundles.mockReturnValue(of([]))
|
service.list.mockReturnValue(of({ count: 0, results: [] }))
|
||||||
service.delete.mockReturnValue(throwError(() => new Error('delete fail')))
|
service.delete.mockReturnValue(throwError(() => new Error('delete fail')))
|
||||||
|
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
@@ -174,7 +230,7 @@ describe('ShareLinkBundleManageDialogComponent', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('retries bundle build and replaces existing entry', () => {
|
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 })
|
const updated = sampleBundle({ status: ShareLinkBundleStatus.Ready })
|
||||||
service.rebuildBundle.mockReturnValue(of(updated))
|
service.rebuildBundle.mockReturnValue(of(updated))
|
||||||
|
|
||||||
@@ -189,7 +245,7 @@ describe('ShareLinkBundleManageDialogComponent', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('adds new bundle when retry returns unknown entry', () => {
|
it('adds new bundle when retry returns unknown entry', () => {
|
||||||
service.listAllBundles.mockReturnValue(of([]))
|
service.list.mockReturnValue(of({ count: 0, results: [] }))
|
||||||
service.rebuildBundle.mockReturnValue(
|
service.rebuildBundle.mockReturnValue(
|
||||||
of(sampleBundle({ id: 99, slug: 'new-slug' }))
|
of(sampleBundle({ id: 99, slug: 'new-slug' }))
|
||||||
)
|
)
|
||||||
@@ -203,7 +259,7 @@ describe('ShareLinkBundleManageDialogComponent', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('handles retry errors', () => {
|
it('handles retry errors', () => {
|
||||||
service.listAllBundles.mockReturnValue(of([]))
|
service.list.mockReturnValue(of({ count: 0, results: [] }))
|
||||||
service.rebuildBundle.mockReturnValue(throwError(() => new Error('fail')))
|
service.rebuildBundle.mockReturnValue(throwError(() => new Error('fail')))
|
||||||
|
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
@@ -213,8 +269,8 @@ describe('ShareLinkBundleManageDialogComponent', () => {
|
|||||||
expect(toastService.showError).toHaveBeenCalled()
|
expect(toastService.showError).toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('maps helpers and closes dialog', () => {
|
it('maps status and file version helpers', () => {
|
||||||
service.listAllBundles.mockReturnValue(of([]))
|
service.list.mockReturnValue(of({ count: 0, results: [] }))
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
|
|
||||||
expect(component.statusLabel(ShareLinkBundleStatus.Processing)).toContain(
|
expect(component.statusLabel(ShareLinkBundleStatus.Processing)).toContain(
|
||||||
@@ -227,9 +283,5 @@ describe('ShareLinkBundleManageDialogComponent', () => {
|
|||||||
environment.apiBaseUrl = 'https://example.com/api/'
|
environment.apiBaseUrl = 'https://example.com/api/'
|
||||||
const url = component.getShareUrl(sampleBundle({ slug: 'sluggy' }))
|
const url = component.getShareUrl(sampleBundle({ slug: 'sluggy' }))
|
||||||
expect(url).toBe('https://example.com/share/sluggy')
|
expect(url).toBe('https://example.com/share/sluggy')
|
||||||
|
|
||||||
const closeSpy = jest.spyOn(activeModal, 'close')
|
|
||||||
component.close()
|
|
||||||
expect(closeSpy).toHaveBeenCalled()
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
+87
-26
@@ -1,7 +1,11 @@
|
|||||||
import { Clipboard } from '@angular/cdk/clipboard'
|
import { Clipboard } from '@angular/cdk/clipboard'
|
||||||
import { CommonModule } from '@angular/common'
|
import { CommonModule } from '@angular/common'
|
||||||
import { Component, OnDestroy, OnInit, inject, signal } from '@angular/core'
|
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 { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||||
import { Subject, catchError, of, switchMap, takeUntil, timer } from 'rxjs'
|
import { Subject, catchError, of, switchMap, takeUntil, timer } from 'rxjs'
|
||||||
import { FileVersion } from 'src/app/data/share-link'
|
import { FileVersion } from 'src/app/data/share-link'
|
||||||
@@ -11,42 +15,77 @@ import {
|
|||||||
ShareLinkBundleStatus,
|
ShareLinkBundleStatus,
|
||||||
ShareLinkBundleSummary,
|
ShareLinkBundleSummary,
|
||||||
} from 'src/app/data/share-link-bundle'
|
} 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 { FileSizePipe } from 'src/app/pipes/file-size.pipe'
|
||||||
import { ShareLinkBundleService } from 'src/app/services/rest/share-link-bundle.service'
|
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 { ToastService } from 'src/app/services/toast.service'
|
||||||
import { environment } from 'src/environments/environment'
|
import { environment } from 'src/environments/environment'
|
||||||
import { LoadingComponentWithPermissions } from '../../loading-component/loading.component'
|
import { ConfirmButtonComponent } from 'src/app/components/common/confirm-button/confirm-button.component'
|
||||||
import { ConfirmButtonComponent } from '../confirm-button/confirm-button.component'
|
import { LoadingComponentWithPermissions } from 'src/app/components/loading-component/loading.component'
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'pngx-share-link-bundle-manage-dialog',
|
selector: 'pngx-share-link-bundle-list',
|
||||||
templateUrl: './share-link-bundle-manage-dialog.component.html',
|
templateUrl: './share-link-bundle-list.component.html',
|
||||||
styleUrls: ['./share-link-bundle-manage-dialog.component.scss'],
|
styleUrls: ['./share-link-bundle-list.component.scss'],
|
||||||
imports: [
|
imports: [
|
||||||
ConfirmButtonComponent,
|
ConfirmButtonComponent,
|
||||||
CommonModule,
|
CommonModule,
|
||||||
|
FormsModule,
|
||||||
|
NgbPaginationModule,
|
||||||
NgbPopoverModule,
|
NgbPopoverModule,
|
||||||
NgxBootstrapIconsModule,
|
NgxBootstrapIconsModule,
|
||||||
|
SortableDirective,
|
||||||
FileSizePipe,
|
FileSizePipe,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class ShareLinkBundleManageDialogComponent
|
export class ShareLinkBundleListComponent
|
||||||
extends LoadingComponentWithPermissions
|
extends LoadingComponentWithPermissions
|
||||||
implements OnInit, OnDestroy
|
implements OnInit, OnDestroy
|
||||||
{
|
{
|
||||||
private readonly activeModal = inject(NgbActiveModal)
|
|
||||||
private readonly shareLinkBundleService = inject(ShareLinkBundleService)
|
private readonly shareLinkBundleService = inject(ShareLinkBundleService)
|
||||||
|
private readonly settingsService = inject(SettingsService)
|
||||||
private readonly toastService = inject(ToastService)
|
private readonly toastService = inject(ToastService)
|
||||||
private readonly clipboard = inject(Clipboard)
|
private readonly clipboard = inject(Clipboard)
|
||||||
|
|
||||||
title = $localize`Share link bundles`
|
|
||||||
readonly bundles = signal<ShareLinkBundleSummary[]>([])
|
readonly bundles = signal<ShareLinkBundleSummary[]>([])
|
||||||
readonly error = signal<string | null>(null)
|
readonly error = signal<string | null>(null)
|
||||||
readonly copiedSlug = 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 statuses = ShareLinkBundleStatus
|
||||||
readonly fileVersions = FileVersion
|
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>()
|
private readonly refresh$ = new Subject<boolean>()
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
@@ -57,25 +96,33 @@ export class ShareLinkBundleManageDialogComponent
|
|||||||
this.loading.set(true)
|
this.loading.set(true)
|
||||||
}
|
}
|
||||||
this.error.set(null)
|
this.error.set(null)
|
||||||
return this.shareLinkBundleService.listAllBundles().pipe(
|
return this.shareLinkBundleService
|
||||||
catchError((error) => {
|
.list(
|
||||||
if (!silent) {
|
this.page(),
|
||||||
this.loading.set(false)
|
this.pageSize,
|
||||||
}
|
this.sortField(),
|
||||||
this.error.set($localize`Failed to load share link bundles.`)
|
this.sortReverse()
|
||||||
this.toastService.showError(
|
)
|
||||||
$localize`Error retrieving share link bundles.`,
|
.pipe(
|
||||||
error
|
catchError((error) => {
|
||||||
)
|
if (!silent) {
|
||||||
return of(null)
|
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)
|
takeUntil(this.unsubscribeNotifier)
|
||||||
)
|
)
|
||||||
.subscribe((results) => {
|
.subscribe((results) => {
|
||||||
if (results) {
|
if (results) {
|
||||||
this.bundles.set(results)
|
this.bundles.set(results.results)
|
||||||
|
this.total.set(results.count)
|
||||||
this.copiedSlug.set(null)
|
this.copiedSlug.set(null)
|
||||||
}
|
}
|
||||||
this.loading.set(false)
|
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 {
|
copy(bundle: ShareLinkBundleSummary): void {
|
||||||
if (bundle.status !== ShareLinkBundleStatus.Ready) {
|
if (bundle.status !== ShareLinkBundleStatus.Ready) {
|
||||||
return
|
return
|
||||||
@@ -108,7 +167,6 @@ export class ShareLinkBundleManageDialogComponent
|
|||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
this.copiedSlug.set(null)
|
this.copiedSlug.set(null)
|
||||||
}, 3000)
|
}, 3000)
|
||||||
this.toastService.showInfo($localize`Share link copied to clipboard.`)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -117,6 +175,9 @@ export class ShareLinkBundleManageDialogComponent
|
|||||||
this.loading.set(true)
|
this.loading.set(true)
|
||||||
this.shareLinkBundleService.delete(bundle).subscribe({
|
this.shareLinkBundleService.delete(bundle).subscribe({
|
||||||
next: () => {
|
next: () => {
|
||||||
|
if (this.bundles().length === 1 && this.page() > 1) {
|
||||||
|
this.page.update((page) => page - 1)
|
||||||
|
}
|
||||||
this.toastService.showInfo($localize`Share link bundle deleted.`)
|
this.toastService.showInfo($localize`Share link bundle deleted.`)
|
||||||
this.triggerRefresh(false)
|
this.triggerRefresh(false)
|
||||||
},
|
},
|
||||||
@@ -153,8 +214,8 @@ export class ShareLinkBundleManageDialogComponent
|
|||||||
return SHARE_LINK_BUNDLE_FILE_VERSION_LABELS[version] ?? version
|
return SHARE_LINK_BUNDLE_FILE_VERSION_LABELS[version] ?? version
|
||||||
}
|
}
|
||||||
|
|
||||||
close(): void {
|
isExpired(expiration?: string): boolean {
|
||||||
this.activeModal.close()
|
return !!expiration && Date.parse(expiration) <= Date.now()
|
||||||
}
|
}
|
||||||
|
|
||||||
private replaceBundle(updated: ShareLinkBundleSummary): void {
|
private replaceBundle(updated: ShareLinkBundleSummary): void {
|
||||||
+110
@@ -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>
|
||||||
+155
@@ -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()
|
||||||
|
})
|
||||||
|
})
|
||||||
+172
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -26,5 +26,7 @@ export interface ShareLink extends ObjectWithPermissions {
|
|||||||
|
|
||||||
document: number // Document
|
document: number // Document
|
||||||
|
|
||||||
|
document_title?: string
|
||||||
|
|
||||||
file_version: string
|
file_version: string
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -228,6 +228,8 @@ export const SETTINGS: UiSetting[] = [
|
|||||||
document_types: 25,
|
document_types: 25,
|
||||||
tags: 25,
|
tags: 25,
|
||||||
storage_paths: 25,
|
storage_paths: 25,
|
||||||
|
share_links: 25,
|
||||||
|
share_link_bundles: 25,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -48,13 +48,4 @@ describe('ShareLinkBundleService', () => {
|
|||||||
expect(req.request.body).toEqual({})
|
expect(req.request.body).toEqual({})
|
||||||
req.flush({})
|
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 { Injectable } from '@angular/core'
|
||||||
import { Observable } from 'rxjs'
|
import { Observable } from 'rxjs'
|
||||||
import { map } from 'rxjs/operators'
|
|
||||||
import {
|
import {
|
||||||
ShareLinkBundleCreatePayload,
|
ShareLinkBundleCreatePayload,
|
||||||
ShareLinkBundleSummary,
|
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)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -626,6 +626,10 @@ ul.pagination {
|
|||||||
table.table {
|
table.table {
|
||||||
--bs-table-color: var(--bs-body-color);
|
--bs-table-color: var(--bs-body-color);
|
||||||
--bs-table-bg: var(--bs-light-rgb);
|
--bs-table-bg: var(--bs-light-rgb);
|
||||||
|
|
||||||
|
&.bg-body {
|
||||||
|
--bs-table-bg: var(--bs-body-bg);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.close {
|
.close {
|
||||||
|
|||||||
@@ -2812,6 +2812,11 @@ class AcknowledgeTasksViewSerializer(serializers.Serializer[dict[str, Any]]):
|
|||||||
|
|
||||||
|
|
||||||
class ShareLinkSerializer(OwnedObjectSerializer):
|
class ShareLinkSerializer(OwnedObjectSerializer):
|
||||||
|
document_title = serializers.CharField(
|
||||||
|
source="document.title",
|
||||||
|
read_only=True,
|
||||||
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = ShareLink
|
model = ShareLink
|
||||||
fields = (
|
fields = (
|
||||||
@@ -2820,6 +2825,7 @@ class ShareLinkSerializer(OwnedObjectSerializer):
|
|||||||
"expiration",
|
"expiration",
|
||||||
"slug",
|
"slug",
|
||||||
"document",
|
"document",
|
||||||
|
"document_title",
|
||||||
"file_version",
|
"file_version",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -3735,6 +3735,7 @@ class TestDocumentApi(DirectoriesMixin, ConsumeTaskMixin, APITestCase):
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
self.assertEqual(resp.status_code, status.HTTP_201_CREATED)
|
self.assertEqual(resp.status_code, status.HTTP_201_CREATED)
|
||||||
|
self.assertEqual(resp.data["document_title"], doc.title)
|
||||||
|
|
||||||
resp = self.client.post(
|
resp = self.client.post(
|
||||||
"/api/share_links/",
|
"/api/share_links/",
|
||||||
@@ -3745,6 +3746,17 @@ class TestDocumentApi(DirectoriesMixin, ConsumeTaskMixin, APITestCase):
|
|||||||
},
|
},
|
||||||
)
|
)
|
||||||
self.assertEqual(resp.status_code, status.HTTP_201_CREATED)
|
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(
|
response = self.client.get(
|
||||||
f"/api/documents/{doc.pk}/share_links/",
|
f"/api/documents/{doc.pk}/share_links/",
|
||||||
@@ -3756,6 +3768,9 @@ class TestDocumentApi(DirectoriesMixin, ConsumeTaskMixin, APITestCase):
|
|||||||
resp_data = response.json()
|
resp_data = response.json()
|
||||||
|
|
||||||
self.assertEqual(len(resp_data), 2)
|
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.assertGreater(len(resp_data[1]["slug"]), 0)
|
||||||
self.assertIsNone(resp_data[1]["expiration"])
|
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)
|
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:
|
def test_share_links_permissions_aware(self) -> None:
|
||||||
"""
|
"""
|
||||||
GIVEN:
|
GIVEN:
|
||||||
|
|||||||
+10
-3
@@ -1888,7 +1888,14 @@ class DocumentViewSet(
|
|||||||
now = timezone.now()
|
now = timezone.now()
|
||||||
links = (
|
links = (
|
||||||
ShareLink.objects.filter(document=doc)
|
ShareLink.objects.filter(document=doc)
|
||||||
.only("pk", "created", "expiration", "slug")
|
.select_related("document")
|
||||||
|
.only(
|
||||||
|
"pk",
|
||||||
|
"created",
|
||||||
|
"expiration",
|
||||||
|
"slug",
|
||||||
|
"document__title",
|
||||||
|
)
|
||||||
.exclude(expiration__lt=now)
|
.exclude(expiration__lt=now)
|
||||||
.order_by("-created")
|
.order_by("-created")
|
||||||
)
|
)
|
||||||
@@ -4553,7 +4560,7 @@ class ShareLinkViewSet(
|
|||||||
):
|
):
|
||||||
model = ShareLink
|
model = ShareLink
|
||||||
|
|
||||||
queryset = ShareLink.objects.all()
|
queryset = ShareLink.objects.select_related("document")
|
||||||
|
|
||||||
serializer_class = ShareLinkSerializer
|
serializer_class = ShareLinkSerializer
|
||||||
pagination_class = StandardPagination
|
pagination_class = StandardPagination
|
||||||
@@ -4564,7 +4571,7 @@ class ShareLinkViewSet(
|
|||||||
PermittedObjectsFilter,
|
PermittedObjectsFilter,
|
||||||
)
|
)
|
||||||
filterset_class = ShareLinkFilterSet
|
filterset_class = ShareLinkFilterSet
|
||||||
ordering_fields = ("created", "expiration", "document")
|
ordering_fields = ("created", "expiration", "document__title")
|
||||||
|
|
||||||
|
|
||||||
@extend_schema_view(
|
@extend_schema_view(
|
||||||
|
|||||||
Reference in New Issue
Block a user