From c869deed0eec131c450951c1d5e47fb0cdd58d79 Mon Sep 17 00:00:00 2001 From: shamoon <4887959+shamoon@users.noreply.github.com> Date: Sat, 5 Sep 2026 17:24:41 -0700 Subject: [PATCH] OK, basic share link management page --- .../share-link-list.component.html | 98 +++++++++++++++ .../share-link-list.component.spec.ts | 106 ++++++++++++++++ .../share-link-list.component.ts | 117 ++++++++++++++++++ .../share-links/share-links.component.html | 4 +- .../share-links/share-links.component.spec.ts | 8 ++ .../share-links/share-links.component.ts | 8 +- 6 files changed, 337 insertions(+), 4 deletions(-) create mode 100644 src-ui/src/app/components/manage/share-links/share-link-list/share-link-list.component.html create mode 100644 src-ui/src/app/components/manage/share-links/share-link-list/share-link-list.component.spec.ts create mode 100644 src-ui/src/app/components/manage/share-links/share-link-list/share-link-list.component.ts diff --git a/src-ui/src/app/components/manage/share-links/share-link-list/share-link-list.component.html b/src-ui/src/app/components/manage/share-links/share-link-list/share-link-list.component.html new file mode 100644 index 000000000..701e52310 --- /dev/null +++ b/src-ui/src/app/components/manage/share-links/share-link-list/share-link-list.component.html @@ -0,0 +1,98 @@ +
+ @if (loading()) { +
+
+ Loading share links… +
+ } + @if (!loading() && error()) { + + } + @if (!loading() && !error() && links().length === 0) { +

+ No document share links currently exist. +

+ } + @if (!loading() && !error() && links().length > 0) { +
+ + + + + + + + + + + + @for (link of links(); track link.id) { + + + + + + + + } + +
DocumentCreatedExpiresFile versionActions
+ + Document #{{ link.document }} + + {{ link.created | date: 'short' }} + @if (link.expiration) { + {{ link.expiration | date: 'short' }} + } @else { + Never + } + {{ fileVersionLabel(link.file_version) }} +
+ Copied! +
+ + + Delete share link + +
+
+
+
+ @if (total() > pageSize) { +
+ +
+ } + } +
diff --git a/src-ui/src/app/components/manage/share-links/share-link-list/share-link-list.component.spec.ts b/src-ui/src/app/components/manage/share-links/share-link-list/share-link-list.component.spec.ts new file mode 100644 index 000000000..ed4e51974 --- /dev/null +++ b/src-ui/src/app/components/manage/share-links/share-link-list/share-link-list.component.spec.ts @@ -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 + let service: jest.Mocked> + let clipboard: Clipboard + let toastService: jest.Mocked> + + 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() + }) +}) diff --git a/src-ui/src/app/components/manage/share-links/share-link-list/share-link-list.component.ts b/src-ui/src/app/components/manage/share-links/share-link-list/share-link-list.component.ts new file mode 100644 index 000000000..ab1bc44a6 --- /dev/null +++ b/src-ui/src/app/components/manage/share-links/share-link-list/share-link-list.component.ts @@ -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([]) + readonly total = signal(0) + readonly page = signal(1) + readonly copiedID = signal(null) + readonly error = signal(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 + ) + }, + }) + } +} diff --git a/src-ui/src/app/components/manage/share-links/share-links.component.html b/src-ui/src/app/components/manage/share-links/share-links.component.html index 6d96349fb..0dfe2c909 100644 --- a/src-ui/src/app/components/manage/share-links/share-links.component.html +++ b/src-ui/src/app/components/manage/share-links/share-links.component.html @@ -16,9 +16,7 @@
  • -
    - Manage links to individual documents here. -
    +
  • } diff --git a/src-ui/src/app/components/manage/share-links/share-links.component.spec.ts b/src-ui/src/app/components/manage/share-links/share-links.component.spec.ts index 084170f52..dfee17ae4 100644 --- a/src-ui/src/app/components/manage/share-links/share-links.component.spec.ts +++ b/src-ui/src/app/components/manage/share-links/share-links.component.spec.ts @@ -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() }, diff --git a/src-ui/src/app/components/manage/share-links/share-links.component.ts b/src-ui/src/app/components/manage/share-links/share-links.component.ts index c1979af30..fed59bbf5 100644 --- a/src-ui/src/app/components/manage/share-links/share-links.component.ts +++ b/src-ui/src/app/components/manage/share-links/share-links.component.ts @@ -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)