]> git.ipfire.org Git - thirdparty/paperless-ngx.git/commitdiff
Fix/refactor: remove doc observables, fix username async (#8908)
authorshamoon <4887959+shamoon@users.noreply.github.com>
Sat, 25 Jan 2025 20:38:36 +0000 (12:38 -0800)
committerGitHub <noreply@github.com>
Sat, 25 Jan 2025 20:38:36 +0000 (12:38 -0800)
30 files changed:
src-ui/messages.xlf
src-ui/src/app/components/common/filterable-dropdown/filterable-dropdown.component.spec.ts
src-ui/src/app/components/common/filterable-dropdown/toggleable-dropdown-button/toggleable-dropdown-button.component.spec.ts
src-ui/src/app/components/common/tag/tag.component.html
src-ui/src/app/components/common/tag/tag.component.spec.ts
src-ui/src/app/components/common/tag/tag.component.ts
src-ui/src/app/components/dashboard/widgets/saved-view-widget/saved-view-widget.component.html
src-ui/src/app/components/dashboard/widgets/saved-view-widget/saved-view-widget.component.ts
src-ui/src/app/components/document-list/document-card-large/document-card-large.component.html
src-ui/src/app/components/document-list/document-card-large/document-card-large.component.ts
src-ui/src/app/components/document-list/document-card-small/document-card-small.component.html
src-ui/src/app/components/document-list/document-card-small/document-card-small.component.spec.ts
src-ui/src/app/components/document-list/document-card-small/document-card-small.component.ts
src-ui/src/app/components/document-list/document-list.component.html
src-ui/src/app/components/document-list/document-list.component.spec.ts
src-ui/src/app/components/document-list/document-list.component.ts
src-ui/src/app/data/document.ts
src-ui/src/app/pipes/correspondent-name.pipe.spec.ts [new file with mode: 0644]
src-ui/src/app/pipes/correspondent-name.pipe.ts [new file with mode: 0644]
src-ui/src/app/pipes/document-type-name.pipe.spec.ts [new file with mode: 0644]
src-ui/src/app/pipes/document-type-name.pipe.ts [new file with mode: 0644]
src-ui/src/app/pipes/object-name.pipe.spec.ts [new file with mode: 0644]
src-ui/src/app/pipes/object-name.pipe.ts [new file with mode: 0644]
src-ui/src/app/pipes/storage-path-name.pipe.spec.ts [new file with mode: 0644]
src-ui/src/app/pipes/storage-path-name.pipe.ts [new file with mode: 0644]
src-ui/src/app/pipes/username.pipe.spec.ts
src-ui/src/app/pipes/username.pipe.ts
src-ui/src/app/services/rest/document.service.spec.ts
src-ui/src/app/services/rest/document.service.ts
src-ui/src/main.ts

index d45a2a8b1aa9efbe7e3fb1f92c91c43ddca3a5bc..981394b8713e0b06eeb191c8a9a04a2aa0b6fd5c 100644 (file)
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/document-list.component.ts</context>
-          <context context-type="linenumber">187</context>
+          <context context-type="linenumber">193</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/manage/custom-fields/custom-fields.component.html</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/data/document.ts</context>
-          <context context-type="linenumber">63</context>
+          <context context-type="linenumber">58</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/data/document.ts</context>
-          <context context-type="linenumber">100</context>
+          <context context-type="linenumber">95</context>
         </context-group>
       </trans-unit>
       <trans-unit id="293524471897878391" datatype="html">
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/data/document.ts</context>
-          <context context-type="linenumber">39</context>
+          <context context-type="linenumber">34</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/data/document.ts</context>
-          <context context-type="linenumber">97</context>
+          <context context-type="linenumber">92</context>
         </context-group>
       </trans-unit>
       <trans-unit id="5968132631442328843" datatype="html">
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/data/document.ts</context>
-          <context context-type="linenumber">47</context>
+          <context context-type="linenumber">42</context>
         </context-group>
       </trans-unit>
       <trans-unit id="4369111787961525769" datatype="html">
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/data/document.ts</context>
-          <context context-type="linenumber">43</context>
+          <context context-type="linenumber">38</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/data/document.ts</context>
-          <context context-type="linenumber">98</context>
+          <context context-type="linenumber">93</context>
         </context-group>
       </trans-unit>
       <trans-unit id="4873149362496451858" datatype="html">
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/data/document.ts</context>
-          <context context-type="linenumber">99</context>
+          <context context-type="linenumber">94</context>
         </context-group>
       </trans-unit>
       <trans-unit id="8686921715946540725" datatype="html">
           <context context-type="linenumber">13</context>
         </context-group>
         <context-group purpose="location">
-          <context context-type="sourcefile">src/app/components/document-list/document-card-small/document-card-small.component.ts</context>
-          <context context-type="linenumber">121</context>
+          <context context-type="sourcefile">src/app/pipes/object-name.pipe.ts</context>
+          <context context-type="linenumber">43</context>
         </context-group>
       </trans-unit>
       <trans-unit id="2504502765849142619" datatype="html">
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/data/document.ts</context>
-          <context context-type="linenumber">35</context>
+          <context context-type="linenumber">30</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/data/document.ts</context>
-          <context context-type="linenumber">95</context>
+          <context context-type="linenumber">90</context>
         </context-group>
       </trans-unit>
       <trans-unit id="1379170675585571971" datatype="html">
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/data/document.ts</context>
-          <context context-type="linenumber">51</context>
+          <context context-type="linenumber">46</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/data/document.ts</context>
-          <context context-type="linenumber">94</context>
+          <context context-type="linenumber">89</context>
         </context-group>
       </trans-unit>
       <trans-unit id="5066119607229701477" datatype="html">
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/data/document.ts</context>
-          <context context-type="linenumber">55</context>
+          <context context-type="linenumber">50</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/data/document.ts</context>
-          <context context-type="linenumber">96</context>
+          <context context-type="linenumber">91</context>
         </context-group>
       </trans-unit>
       <trans-unit id="2091353339965748767" datatype="html">
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/data/document.ts</context>
-          <context context-type="linenumber">59</context>
+          <context context-type="linenumber">54</context>
         </context-group>
       </trans-unit>
       <trans-unit id="5607669932062416162" datatype="html">
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/data/document.ts</context>
-          <context context-type="linenumber">71</context>
+          <context context-type="linenumber">66</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/pipes/username.pipe.ts</context>
-          <context context-type="linenumber">33</context>
+          <context context-type="linenumber">37</context>
         </context-group>
       </trans-unit>
       <trans-unit id="2332107018974972998" datatype="html">
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/document-list.component.ts</context>
-          <context context-type="linenumber">304</context>
+          <context context-type="linenumber">310</context>
         </context-group>
       </trans-unit>
       <trans-unit id="1494518490116523821" datatype="html">
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/document-list.component.ts</context>
-          <context context-type="linenumber">297</context>
+          <context context-type="linenumber">303</context>
         </context-group>
       </trans-unit>
       <trans-unit id="8461842260159597706" datatype="html">
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/data/document.ts</context>
-          <context context-type="linenumber">75</context>
+          <context context-type="linenumber">70</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/data/document.ts</context>
-          <context context-type="linenumber">93</context>
+          <context context-type="linenumber">88</context>
         </context-group>
       </trans-unit>
       <trans-unit id="6954625430271090777" datatype="html">
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/data/document.ts</context>
-          <context context-type="linenumber">67</context>
+          <context context-type="linenumber">62</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/data/document.ts</context>
-          <context context-type="linenumber">101</context>
+          <context context-type="linenumber">96</context>
         </context-group>
       </trans-unit>
       <trans-unit id="3557446856808034218" datatype="html">
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/data/document.ts</context>
-          <context context-type="linenumber">79</context>
+          <context context-type="linenumber">74</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/data/document.ts</context>
-          <context context-type="linenumber">102</context>
+          <context context-type="linenumber">97</context>
         </context-group>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/data/paperless-config.ts</context>
         <source>Reset filters / selection</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/document-list.component.ts</context>
-          <context context-type="linenumber">285</context>
+          <context context-type="linenumber">291</context>
         </context-group>
       </trans-unit>
       <trans-unit id="4135055128446167640" datatype="html">
         <source>Open first [selected] document</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/document-list.component.ts</context>
-          <context context-type="linenumber">313</context>
+          <context context-type="linenumber">319</context>
         </context-group>
       </trans-unit>
       <trans-unit id="3629960544875360046" datatype="html">
         <source>Previous page</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/document-list.component.ts</context>
-          <context context-type="linenumber">329</context>
+          <context context-type="linenumber">335</context>
         </context-group>
       </trans-unit>
       <trans-unit id="3337301694210287595" datatype="html">
         <source>Next page</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/document-list.component.ts</context>
-          <context context-type="linenumber">341</context>
+          <context context-type="linenumber">347</context>
         </context-group>
       </trans-unit>
       <trans-unit id="2155249406916744630" datatype="html">
         <source>View &quot;<x id="PH" equiv-text="this.list.activeSavedViewTitle"/>&quot; saved successfully.</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/document-list.component.ts</context>
-          <context context-type="linenumber">373</context>
+          <context context-type="linenumber">379</context>
         </context-group>
       </trans-unit>
       <trans-unit id="6837554170707123455" datatype="html">
         <source>View &quot;<x id="PH" equiv-text="savedView.name"/>&quot; created successfully.</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/components/document-list/document-list.component.ts</context>
-          <context context-type="linenumber">416</context>
+          <context context-type="linenumber">422</context>
         </context-group>
       </trans-unit>
       <trans-unit id="739880801667335279" datatype="html">
         <source>Search score</source>
         <context-group purpose="location">
           <context context-type="sourcefile">src/app/data/document.ts</context>
-          <context context-type="linenumber">108</context>
+          <context context-type="linenumber">103</context>
         </context-group>
         <note priority="1" from="description">Score is a value returned by the full text search engine and specifies how well a result matches the given query</note>
       </trans-unit>
index 0dcc8c9cfb60f05602d20131b55532df37c58be3..f402cbdcec4a321f8be908b91a4d1ba6a688c90f 100644 (file)
@@ -1,3 +1,5 @@
+import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
+import { provideHttpClientTesting } from '@angular/common/http/testing'
 import {
   ComponentFixture,
   TestBed,
@@ -51,7 +53,11 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
 
   beforeEach(async () => {
     TestBed.configureTestingModule({
-      providers: [FilterPipe],
+      providers: [
+        FilterPipe,
+        provideHttpClient(withInterceptorsFromDi()),
+        provideHttpClientTesting(),
+      ],
       imports: [NgxBootstrapIconsModule.pick(allIcons)],
     }).compileComponents()
 
index 9e1f3d14795cfa82f48b5945c2cc8174b985030f..17d3a35cf3fdbc8fb1b09b86d1a433a0416a639e 100644 (file)
@@ -1,3 +1,5 @@
+import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
+import { provideHttpClientTesting } from '@angular/common/http/testing'
 import { ComponentFixture, TestBed } from '@angular/core/testing'
 import { Tag } from 'src/app/data/tag'
 import { TagComponent } from '../../tag/tag.component'
@@ -12,7 +14,10 @@ describe('ToggleableDropdownButtonComponent', () => {
 
   beforeEach(async () => {
     TestBed.configureTestingModule({
-      providers: [],
+      providers: [
+        provideHttpClient(withInterceptorsFromDi()),
+        provideHttpClientTesting(),
+      ],
       imports: [ToggleableDropdownButtonComponent, TagComponent],
     }).compileComponents()
 
index f845d743bf7c8284b9023f24e2e955a84d7d78d5..df1767db84142902930492455f5dbd06a24d79dc 100644 (file)
@@ -1,4 +1,4 @@
-@if (tag !== undefined) {
+@if (tag) {
   @if (!clickable) {
     <span class="badge" [style.background]="tag.color" [style.color]="tag.text_color">{{tag.name}}</span>
   }
index 6da8052baf2a9b61f9d37f097c0ce68c3769b07a..e84e7fc5ce668d3b963cd05f7b790b838547b8ae 100644 (file)
@@ -1,6 +1,11 @@
+import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
+import { provideHttpClientTesting } from '@angular/common/http/testing'
 import { ComponentFixture, TestBed } from '@angular/core/testing'
 import { By } from '@angular/platform-browser'
+import { of } from 'rxjs'
 import { Tag } from 'src/app/data/tag'
+import { PermissionsService } from 'src/app/services/permissions.service'
+import { TagService } from 'src/app/services/rest/tag.service'
 import { TagComponent } from './tag.component'
 
 const tag: Tag = {
@@ -12,13 +17,20 @@ const tag: Tag = {
 describe('TagComponent', () => {
   let component: TagComponent
   let fixture: ComponentFixture<TagComponent>
+  let permissionsService: PermissionsService
+  let tagService: TagService
 
   beforeEach(async () => {
     TestBed.configureTestingModule({
-      providers: [],
+      providers: [
+        provideHttpClient(withInterceptorsFromDi()),
+        provideHttpClientTesting(),
+      ],
       imports: [TagComponent],
     }).compileComponents()
 
+    permissionsService = TestBed.inject(PermissionsService)
+    tagService = TestBed.inject(TagService)
     fixture = TestBed.createComponent(TagComponent)
     component = fixture.componentInstance
     fixture.detectChanges()
@@ -47,4 +59,13 @@ describe('TagComponent', () => {
     fixture.detectChanges()
     expect(fixture.debugElement.query(By.css('a.badge'))).not.toBeNull()
   })
+
+  it('should support retrieving tag by ID', () => {
+    jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
+    const getCachedSpy = jest.spyOn(tagService, 'getCached')
+    getCachedSpy.mockReturnValue(of(tag))
+    component.tagID = 1
+    expect(getCachedSpy).toHaveBeenCalledWith(1)
+    expect(component.tag).toEqual(tag)
+  })
 })
index 0a052f9b582dbb49ff6800699de1cd9019138d3e..97d9710ef848e3e06cfc2682932facddecd495cc 100644 (file)
@@ -1,5 +1,11 @@
 import { Component, Input } from '@angular/core'
 import { Tag } from 'src/app/data/tag'
+import {
+  PermissionAction,
+  PermissionsService,
+  PermissionType,
+} from 'src/app/services/permissions.service'
+import { TagService } from 'src/app/services/rest/tag.service'
 
 @Component({
   selector: 'pngx-tag',
@@ -7,10 +13,39 @@ import { Tag } from 'src/app/data/tag'
   styleUrls: ['./tag.component.scss'],
 })
 export class TagComponent {
-  constructor() {}
+  private _tag: Tag
+  private _tagID: number
+
+  constructor(
+    private permissionsService: PermissionsService,
+    private tagService: TagService
+  ) {}
+
+  @Input()
+  public set tag(tag: Tag) {
+    this._tag = tag
+  }
+
+  public get tag(): Tag {
+    return this._tag
+  }
 
   @Input()
-  tag: Tag
+  set tagID(tagID: number) {
+    if (tagID !== this._tagID) {
+      this._tagID = tagID
+      if (
+        this.permissionsService.currentUserCan(
+          PermissionAction.View,
+          PermissionType.Tag
+        )
+      ) {
+        this.tagService.getCached(this._tagID).subscribe((tag) => {
+          this.tag = tag
+        })
+      }
+    }
+  }
 
   @Input()
   linkTitle: string = ''
index 52aa48c2e5117b7206156e3ae894ff7367b37982..f398e60931c3f2089de6aba3bd21a88d99c83510 100644 (file)
                     }
                     @case (DisplayField.CORRESPONDENT) {
                       @if (doc.correspondent) {
-                        <a class="btn-link text-dark text-decoration-none" type="button" (click)="clickCorrespondent(doc.correspondent, $event)" title="Filter by correspondent" i18n-title>{{(doc.correspondent$ | async)?.name}}</a>
+                        <a class="btn-link text-dark text-decoration-none" type="button" (click)="clickCorrespondent(doc.correspondent, $event)" title="Filter by correspondent" i18n-title>{{doc.correspondent | correspondentName | async}}</a>
                       }
                     }
                     @case (DisplayField.TAGS) {
-                      @for (t of doc.tags$ | async; track t) {
-                        <pngx-tag [tag]="t" class="ms-1" (click)="clickTag(t.id, $event)" [clickable]="true" linkTitle="Filter by tag" i18n-title></pngx-tag>
+                      @for (tagID of doc.tags; track tagID) {
+                        <pngx-tag [tagID]="tagID" class="ms-1" (click)="clickTag(tagID, $event)" [clickable]="true" linkTitle="Filter by tag" i18n-title></pngx-tag>
                       }
                     }
                     @case (DisplayField.DOCUMENT_TYPE) {
                       @if (doc.document_type) {
-                        <a class="btn-link text-dark text-decoration-none" type="button" (click)="clickDocType(doc.document_type, $event)" title="Filter by document type" i18n-title>{{(doc.document_type$ | async)?.name}}</a>
+                        <a class="btn-link text-dark text-decoration-none" type="button" (click)="clickDocType(doc.document_type, $event)" title="Filter by document type" i18n-title>{{doc.document_type | documentTypeName | async}}</a>
                       }
                     }
                     @case (DisplayField.STORAGE_PATH) {
                       @if (doc.storage_path) {
-                        <a class="btn-link text-dark text-decoration-none" type="button" (click)="clickStoragePath(doc.storage_path, $event)" title="Filter by storage path" i18n-title>{{(doc.storage_path$ | async)?.name}}</a>
+                        <a class="btn-link text-dark text-decoration-none" type="button" (click)="clickStoragePath(doc.storage_path, $event)" title="Filter by storage path" i18n-title>{{doc.storage_path | storagePathName | async}}</a>
                       }
                     }
                     @case (DisplayField.OWNER) {
                       @if (doc.owner) {
-                        <a class="btn-link text-dark text-decoration-none" type="button" (click)="clickOwner(doc.owner, $event)" title="Filter by owner" i18n-title>{{doc.owner | username}}</a>
+                        <a class="btn-link text-dark text-decoration-none" type="button" (click)="clickOwner(doc.owner, $event)" title="Filter by owner" i18n-title>{{doc.owner | username | async}}</a>
                       }
                     }
                     @case (DisplayField.ASN) {
index cf2dc4a45bf5b60c5443f0d8dbc4e0742b5406c3..7f6c5755b85865adbc9d48bf00f27f76c8977b67 100644 (file)
@@ -36,8 +36,11 @@ import {
 } from 'src/app/data/filter-rule-type'
 import { SavedView } from 'src/app/data/saved-view'
 import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
+import { CorrespondentNamePipe } from 'src/app/pipes/correspondent-name.pipe'
 import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
 import { DocumentTitlePipe } from 'src/app/pipes/document-title.pipe'
+import { DocumentTypeNamePipe } from 'src/app/pipes/document-type-name.pipe'
+import { StoragePathNamePipe } from 'src/app/pipes/storage-path-name.pipe'
 import { UsernamePipe } from 'src/app/pipes/username.pipe'
 import { ConsumerStatusService } from 'src/app/services/consumer-status.service'
 import { DocumentListViewService } from 'src/app/services/document-list-view.service'
@@ -65,6 +68,9 @@ import { WidgetFrameComponent } from '../widget-frame/widget-frame.component'
     WidgetFrameComponent,
     IfPermissionsDirective,
     UsernamePipe,
+    CorrespondentNamePipe,
+    DocumentTypeNamePipe,
+    StoragePathNamePipe,
     AsyncPipe,
     DocumentTitlePipe,
     CustomDatePipe,
index 46399aa3271f5078f2f3d9f7b3c59d30da7a6d0b..6ebbd6055c023b57a7f31c1b3501c9c63e60beb6 100644 (file)
@@ -22,9 +22,9 @@
             @if (document) {
               @if (displayFields.includes(DisplayField.CORRESPONDENT) && document.correspondent) {
                 @if (clickCorrespondent.observers.length ) {
-                  <a title="Filter by correspondent" i18n-title (click)="clickCorrespondent.emit(document.correspondent);$event.stopPropagation()" class="fw-bold btn-link">{{(document.correspondent$ | async)?.name}}</a>
+                  <a title="Filter by correspondent" i18n-title (click)="clickCorrespondent.emit(document.correspondent);$event.stopPropagation()" class="fw-bold btn-link">{{document.correspondent | correspondentName | async}}</a>
                 } @else {
-                  {{(document.correspondent$ | async)?.name}}
+                  {{document.correspondent | correspondentName | async}}
                 }
                 @if (displayFields.includes(DisplayField.TITLE)) {:}
               }
@@ -32,8 +32,8 @@
                 {{document.title | documentTitle}}
               }
               @if (displayFields.includes(DisplayField.TAGS)) {
-                @for (t of document.tags$ | async; track t) {
-                  <pngx-tag [tag]="t" linkTitle="Filter by tag" i18n-linkTitle class="ms-1" (click)="clickTag.emit(t.id);$event.stopPropagation()" [clickable]="clickTag.observers.length"></pngx-tag>
+                @for (tagID of document.tags; track t) {
+                  <pngx-tag [tagID]="tagID" linkTitle="Filter by tag" i18n-linkTitle class="ms-1" (click)="clickTag.emit(tagID);$event.stopPropagation()" [clickable]="clickTag.observers.length"></pngx-tag>
                 }
               }
             } @else {
               @if (displayFields.includes(DisplayField.DOCUMENT_TYPE) && document.document_type) {
                 <button type="button" class="list-group-item btn btn-sm bg-light text-dark p-1 border-0 me-2 d-flex align-items-center" title="Filter by document type" i18n-title
                   (click)="clickDocumentType.emit(document.document_type);$event.stopPropagation()">
-                  <i-bs width=".9em" height=".9em" class="me-2 text-muted" name="file-earmark"></i-bs><small>{{(document.document_type$ | async)?.name}}</small>
+                  <i-bs width=".9em" height=".9em" class="me-2 text-muted" name="file-earmark"></i-bs><small>{{document.document_type | documentTypeName | async}}</small>
                 </button>
               }
               @if (displayFields.includes(DisplayField.STORAGE_PATH) && document.storage_path) {
                 <button type="button" class="list-group-item btn btn-sm bg-light text-dark p-1 border-0 me-2 d-flex align-items-center" title="Filter by storage path" i18n-title
                   (click)="clickStoragePath.emit(document.storage_path);$event.stopPropagation()">
-                  <i-bs width=".9em" height=".9em" class="me-2 text-muted" name="archive"></i-bs><small>{{(document.storage_path$ | async)?.name}}</small>
+                  <i-bs width=".9em" height=".9em" class="me-2 text-muted" name="archive"></i-bs><small>{{document.storage_path | storagePathName | async}}</small>
                 </button>
               }
               @if (displayFields.includes(DisplayField.ASN) && document.archive_serial_number | isNumber) {
               }
               @if (displayFields.includes(DisplayField.OWNER) && document.owner && document.owner !== settingsService.currentUser.id) {
                 <div class="list-group-item bg-light text-dark p-1 border-0 d-flex align-items-center">
-                  <i-bs width=".9em" height=".9em" class="me-2 text-muted" name="person-fill-lock"></i-bs><small>{{document.owner | username}}</small>
+                  <i-bs width=".9em" height=".9em" class="me-2 text-muted" name="person-fill-lock"></i-bs><small>{{document.owner | username | async}}</small>
                 </div>
               }
               @if (displayFields.includes(DisplayField.SHARED) && document.is_shared_by_requester) {
index aaedf27aeee10fbfb26fd36bc326b9759fc17c4b..a179b450af7bc1a47230d42fe7308857a9ab453d 100644 (file)
@@ -21,9 +21,12 @@ import {
 } from 'src/app/data/document'
 import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
 import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
+import { CorrespondentNamePipe } from 'src/app/pipes/correspondent-name.pipe'
 import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
 import { DocumentTitlePipe } from 'src/app/pipes/document-title.pipe'
+import { DocumentTypeNamePipe } from 'src/app/pipes/document-type-name.pipe'
 import { IsNumberPipe } from 'src/app/pipes/is-number.pipe'
+import { StoragePathNamePipe } from 'src/app/pipes/storage-path-name.pipe'
 import { UsernamePipe } from 'src/app/pipes/username.pipe'
 import { DocumentService } from 'src/app/services/rest/document.service'
 import { SettingsService } from 'src/app/services/settings.service'
@@ -44,6 +47,9 @@ import { LoadingComponentWithPermissions } from '../../loading-component/loading
     CustomFieldDisplayComponent,
     AsyncPipe,
     UsernamePipe,
+    CorrespondentNamePipe,
+    DocumentTypeNamePipe,
+    StoragePathNamePipe,
     IfPermissionsDirective,
     CustomDatePipe,
     RouterModule,
index cde273dfec28286612ca1b6f918f6f6f6a9ff7d3..a166acd946d661b91f2b582dd2590b7bfb1b2252 100644 (file)
@@ -16,8 +16,8 @@
 
       @if (document && displayFields?.includes(DisplayField.TAGS)) {
         <div class="tags d-flex flex-column text-end position-absolute me-1 fs-6">
-          @for (t of getTagsLimited$() | async; track t) {
-            <pngx-tag [tag]="t" (click)="clickTag.emit(t.id);$event.stopPropagation()" [clickable]="true" linkTitle="Toggle tag filter" i18n-linkTitle></pngx-tag>
+          @for (tagID of tagIDs; track tagID) {
+            <pngx-tag [tagID]="tagID" (click)="clickTag.emit(tagID);$event.stopPropagation()" [clickable]="true" linkTitle="Toggle tag filter" i18n-linkTitle></pngx-tag>
           }
           @if (moreTags) {
             <div>
@@ -40,7 +40,7 @@
       <p class="card-text">
         @if (document) {
           @if (displayFields.includes(DisplayField.CORRESPONDENT) && document.correspondent) {
-            <a title="Toggle correspondent filter" i18n-title (click)="clickCorrespondent.emit(document.correspondent);$event.stopPropagation()" class="fw-bold btn-link">{{(document.correspondent$ | async)?.name ?? privateName}}</a>
+            <a title="Toggle correspondent filter" i18n-title (click)="clickCorrespondent.emit(document.correspondent);$event.stopPropagation()" class="fw-bold btn-link">{{document.correspondent | correspondentName | async}}</a>
             @if (displayFields.includes(DisplayField.TITLE)) {:}
           }
           @if (displayFields.includes(DisplayField.TITLE)) {
             <button type="button" class="list-group-item list-group-item-action bg-transparent ps-0 p-1 border-0" title="Toggle document type filter" i18n-title
               (click)="clickDocumentType.emit(document.document_type);$event.stopPropagation()">
               <i-bs width="1em" height="1em" class="me-2 text-muted" name="file-earmark"></i-bs>
-              <small>{{(document.document_type$ | async)?.name ?? privateName}}</small>
+              <small>{{document.document_type | documentTypeName | async}}</small>
             </button>
           }
           @if (displayFields.includes(DisplayField.STORAGE_PATH) && document.storage_path) {
             <button type="button" class="list-group-item list-group-item-action bg-transparent ps-0 p-1 border-0" title="Toggle storage path filter" i18n-title
               (click)="clickStoragePath.emit(document.storage_path);$event.stopPropagation()">
               <i-bs width="1em" height="1em" class="me-2 text-muted" name="folder"></i-bs>
-              <small>{{(document.storage_path$ | async)?.name ?? privateName}}</small>
+              <small>{{document.storage_path | storagePathName | async}}</small>
             </button>
           }
           @if (displayFields.includes(DisplayField.CREATED)) {
           @if (displayFields.includes(DisplayField.OWNER) && document.owner && document.owner !== settingsService.currentUser.id) {
             <div class="ps-0 p-1">
               <i-bs width="1em" height="1em" class="me-2 text-muted" name="person-fill-lock"></i-bs>
-              <small>{{document.owner | username}}</small>
+              <small>{{document.owner | username | async}}</small>
             </div>
           }
           @if (displayFields.includes(DisplayField.SHARED) && document.is_shared_by_requester) {
index 7e6d9da2012a35cffc7933ae7c8d04a67ac7cbbd..63cfc5a50c5cb4daab542aa8a5dc0236ce5b9a0c 100644 (file)
@@ -5,8 +5,6 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'
 import { By } from '@angular/platform-browser'
 import { RouterTestingModule } from '@angular/router/testing'
 import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
-import { of } from 'rxjs'
-import { Tag } from 'src/app/data/tag'
 import { TagComponent } from '../../common/tag/tag.component'
 import { DocumentCardSmallComponent } from './document-card-small.component'
 
@@ -24,16 +22,6 @@ const doc = {
       note: 'This is some note content bananas',
     },
   ],
-  tags$: of([
-    { id: 1, name: 'Tag1' },
-    { id: 2, name: 'Tag2' },
-    { id: 3, name: 'Tag3' },
-    { id: 4, name: 'Tag4' },
-    { id: 5, name: 'Tag5' },
-    { id: 6, name: 'Tag6' },
-    { id: 7, name: 'Tag7' },
-    { id: 8, name: 'Tag8' },
-  ]),
   content:
     'Cupcake ipsum dolor sit amet ice cream. Donut shortbread cheesecake caramels tiramisu pastry caramels chocolate bar. Tart tootsie roll muffin icing cotton candy topping sweet roll. Pie lollipop dragée sesame snaps donut tart pudding. Oat cake apple pie danish danish candy canes. Shortbread candy canes sesame snaps muffin tiramisu marshmallow chocolate bar halvah. Cake lemon drops candy apple pie carrot cake bonbon halvah pastry gummi bears. Sweet roll candy ice cream sesame snaps marzipan cookie ice cream. Cake cheesecake apple pie muffin candy toffee lollipop. Carrot cake oat cake cookie biscuit cupcake cake marshmallow. Sweet roll jujubes carrot cake cheesecake cake candy canes sweet roll gingerbread jelly beans. Apple pie sugar plum oat cake halvah cake. Pie oat cake chocolate cake cookie gingerbread marzipan. Lemon drops cheesecake lollipop danish marzipan candy.',
 }
@@ -80,7 +68,6 @@ describe('DocumentCardSmallComponent', () => {
       fixture.debugElement.queryAll(By.directive(TagComponent))
     ).toHaveLength(5)
     component.document.tags = [1, 2]
-    component.document.tags$ = of([{ id: 1 } as Tag, { id: 2 } as Tag])
     fixture.detectChanges()
     expect(
       fixture.debugElement.queryAll(By.directive(TagComponent))
index 9d5bcf704058856f877b6796762f6eb6e13568e0..6e4e3943ee83309d383fb00f1f3110f9c682b5d4 100644 (file)
@@ -14,7 +14,7 @@ import {
 } from '@ng-bootstrap/ng-bootstrap'
 import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
 import { of } from 'rxjs'
-import { delay, map } from 'rxjs/operators'
+import { delay } from 'rxjs/operators'
 import {
   DEFAULT_DISPLAY_FIELDS,
   DisplayField,
@@ -22,9 +22,12 @@ import {
 } from 'src/app/data/document'
 import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
 import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
+import { CorrespondentNamePipe } from 'src/app/pipes/correspondent-name.pipe'
 import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
 import { DocumentTitlePipe } from 'src/app/pipes/document-title.pipe'
+import { DocumentTypeNamePipe } from 'src/app/pipes/document-type-name.pipe'
 import { IsNumberPipe } from 'src/app/pipes/is-number.pipe'
+import { StoragePathNamePipe } from 'src/app/pipes/storage-path-name.pipe'
 import { UsernamePipe } from 'src/app/pipes/username.pipe'
 import { DocumentService } from 'src/app/services/rest/document.service'
 import { SettingsService } from 'src/app/services/settings.service'
@@ -45,6 +48,9 @@ import { LoadingComponentWithPermissions } from '../../loading-component/loading
     CustomFieldDisplayComponent,
     AsyncPipe,
     UsernamePipe,
+    CorrespondentNamePipe,
+    DocumentTypeNamePipe,
+    StoragePathNamePipe,
     IfPermissionsDirective,
     CustomDatePipe,
     RouterModule,
@@ -117,22 +123,14 @@ export class DocumentCardSmallComponent
     return this.documentService.getDownloadUrl(this.document.id)
   }
 
-  get privateName() {
-    return $localize`Private`
-  }
-
-  getTagsLimited$() {
+  get tagIDs() {
     const limit = this.document.notes.length > 0 ? 6 : 7
-    return this.document.tags$?.pipe(
-      map((tags) => {
-        if (tags.length > limit) {
-          this.moreTags = tags.length - (limit - 1)
-          return tags.slice(0, limit - 1)
-        } else {
-          return tags
-        }
-      })
-    )
+    if (this.document.tags.length > limit) {
+      this.moreTags = this.document.tags.length - (limit - 1)
+      return this.document.tags.slice(0, limit - 1)
+    } else {
+      return this.document.tags
+    }
   }
 
   mouseLeaveCard() {
index 73f7a1ce88b53e93890d62aaa8d69e333297b067..1bcee8a81984bd8bb094cc4be4d724bd7f6d70d8 100644 (file)
                 @if (activeDisplayFields.includes(DisplayField.CORRESPONDENT) && permissionService.currentUserCan(PermissionAction.View, PermissionType.Correspondent)) {
                   <td class="">
                     @if (d.correspondent) {
-                      <a (click)="clickCorrespondent(d.correspondent);$event.stopPropagation()" title="Filter by correspondent" i18n-title>{{(d.correspondent$ | async)?.name}}</a>
+                      <a (click)="clickCorrespondent(d.correspondent);$event.stopPropagation()" title="Filter by correspondent" i18n-title>{{d.correspondent | correspondentName | async}}</a>
                     }
                   </td>
                 }
                       </div>
                     }
                     @if (activeDisplayFields.includes(DisplayField.TAGS)) {
-                      @for (t of d.tags$ | async; track t) {
-                        <pngx-tag [tag]="t" class="ms-1" clickable="true" linkTitle="Filter by tag" i18n-linkTitle (click)="clickTag(t.id);$event.stopPropagation()"></pngx-tag>
+                      @for (tagID of d.tags; track t) {
+                        <pngx-tag [tagID]="tagID" class="ms-1" clickable="true" linkTitle="Filter by tag" i18n-linkTitle (click)="clickTag(tagID);$event.stopPropagation()"></pngx-tag>
                       }
                     }
                   </td>
                 }
                 @if (activeDisplayFields.includes(DisplayField.OWNER) && permissionService.currentUserCan(PermissionAction.View, PermissionType.User)) {
                   <td>
-                    {{d.owner | username}}
+                    {{d.owner | username | async}}
                   </td>
                 }
                 @if (activeDisplayFields.includes(DisplayField.NOTES) && notesEnabled) {
                 @if (activeDisplayFields.includes(DisplayField.DOCUMENT_TYPE) && permissionService.currentUserCan(PermissionAction.View, PermissionType.DocumentType)) {
                   <td class="">
                     @if (d.document_type) {
-                      <a (click)="clickDocumentType(d.document_type);$event.stopPropagation()" title="Filter by document type" i18n-title>{{(d.document_type$ | async)?.name}}</a>
+                      <a (click)="clickDocumentType(d.document_type);$event.stopPropagation()" title="Filter by document type" i18n-title>{{d.document_type | documentTypeName | async}}</a>
                     }
                   </td>
                 }
                 @if (activeDisplayFields.includes(DisplayField.STORAGE_PATH) && permissionService.currentUserCan(PermissionAction.View, PermissionType.StoragePath)) {
                   <td class="">
                     @if (d.storage_path) {
-                      <a (click)="clickStoragePath(d.storage_path);$event.stopPropagation()" title="Filter by storage path" i18n-title>{{(d.storage_path$ | async)?.name}}</a>
+                      <a (click)="clickStoragePath(d.storage_path);$event.stopPropagation()" title="Filter by storage path" i18n-title>{{d.storage_path | storagePathName | async}}</a>
                     }
                   </td>
                 }
index dfdab018f5aebc28ad31f02215deb1b86b427bdc..805a65846764a7f338dbaf9bb5179a3289ae26b8 100644 (file)
@@ -57,21 +57,21 @@ const docs: Document[] = [
     id: 1,
     title: 'Doc1',
     notes: [],
-    tags$: new Subject(),
+    tags: [],
     content: 'document content 1',
   },
   {
     id: 2,
     title: 'Doc2',
     notes: [],
-    tags$: new Subject(),
+    tags: [],
     content: 'document content 2',
   },
   {
     id: 3,
     title: 'Doc3',
     notes: [],
-    tags$: new Subject(),
+    tags: [],
     content: 'document content 3',
   },
 ]
@@ -650,7 +650,6 @@ describe('DocumentListComponent', () => {
       id: i + 1,
       title: `Doc${i + 1}`,
       notes: [],
-      tags$: new Subject(),
       content: `document content ${i + 1}`,
     }))
     jest
index a19ac341df84ac9472dcc4b7f27cc27d04ebf4cf..b845a524a0f5fbf3e32efada37d21bc1e47e0f51 100644 (file)
@@ -37,8 +37,11 @@ import {
   SortableDirective,
   SortEvent,
 } from 'src/app/directives/sortable.directive'
+import { CorrespondentNamePipe } from 'src/app/pipes/correspondent-name.pipe'
 import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
 import { DocumentTitlePipe } from 'src/app/pipes/document-title.pipe'
+import { DocumentTypeNamePipe } from 'src/app/pipes/document-type-name.pipe'
+import { StoragePathNamePipe } from 'src/app/pipes/storage-path-name.pipe'
 import { UsernamePipe } from 'src/app/pipes/username.pipe'
 import { ConsumerStatusService } from 'src/app/services/consumer-status.service'
 import { DocumentListViewService } from 'src/app/services/document-list-view.service'
@@ -81,6 +84,9 @@ import { SaveViewConfigDialogComponent } from './save-view-config-dialog/save-vi
     IfPermissionsDirective,
     SortableDirective,
     UsernamePipe,
+    CorrespondentNamePipe,
+    DocumentTypeNamePipe,
+    StoragePathNamePipe,
     NgxBootstrapIconsModule,
     AsyncPipe,
     FormsModule,
index 168fcff922e76035d285e54b34c0c8c480e5055a..e5f00148edc5e7eca3549a13f7966d82ff82ed55 100644 (file)
@@ -1,11 +1,6 @@
-import { Observable } from 'rxjs'
-import { Correspondent } from './correspondent'
 import { CustomFieldInstance } from './custom-field-instance'
 import { DocumentNote } from './document-note'
-import { DocumentType } from './document-type'
 import { ObjectWithPermissions } from './object-with-permissions'
-import { StoragePath } from './storage-path'
-import { Tag } from './tag'
 
 export enum DisplayMode {
   TABLE = 'table',
@@ -118,24 +113,16 @@ export interface SearchHit {
 }
 
 export interface Document extends ObjectWithPermissions {
-  correspondent$?: Observable<Correspondent>
-
   correspondent?: number
 
-  document_type$?: Observable<DocumentType>
-
   document_type?: number
 
-  storage_path$?: Observable<StoragePath>
-
   storage_path?: number
 
   title?: string
 
   content?: string
 
-  tags$?: Observable<Tag[]>
-
   tags?: number[]
 
   checksum?: string
diff --git a/src-ui/src/app/pipes/correspondent-name.pipe.spec.ts b/src-ui/src/app/pipes/correspondent-name.pipe.spec.ts
new file mode 100644 (file)
index 0000000..701011c
--- /dev/null
@@ -0,0 +1,28 @@
+import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
+import { provideHttpClientTesting } from '@angular/common/http/testing'
+import { TestBed } from '@angular/core/testing'
+import { PermissionsService } from '../services/permissions.service'
+import { CorrespondentService } from '../services/rest/correspondent.service'
+import { CorrespondentNamePipe } from './correspondent-name.pipe'
+
+describe('CorrespondentNamePipe', () => {
+  let pipe: CorrespondentNamePipe
+
+  beforeEach(() => {
+    TestBed.configureTestingModule({
+      providers: [
+        provideHttpClient(withInterceptorsFromDi()),
+        provideHttpClientTesting(),
+      ],
+    })
+  })
+
+  // The pipe is a simple wrapper around ObjectNamePipe, see ObjectNamePipe for the actual tests.
+  it('should be created', () => {
+    pipe = new CorrespondentNamePipe(
+      TestBed.inject(PermissionsService),
+      TestBed.inject(CorrespondentService)
+    )
+    expect(pipe).toBeTruthy()
+  })
+})
diff --git a/src-ui/src/app/pipes/correspondent-name.pipe.ts b/src-ui/src/app/pipes/correspondent-name.pipe.ts
new file mode 100644 (file)
index 0000000..c068c5c
--- /dev/null
@@ -0,0 +1,22 @@
+import { Pipe, PipeTransform } from '@angular/core'
+import {
+  PermissionsService,
+  PermissionType,
+} from '../services/permissions.service'
+import { CorrespondentService } from '../services/rest/correspondent.service'
+import { ObjectNamePipe } from './object-name.pipe'
+
+@Pipe({
+  name: 'correspondentName',
+})
+export class CorrespondentNamePipe
+  extends ObjectNamePipe
+  implements PipeTransform
+{
+  constructor(
+    permissionsService: PermissionsService,
+    objectService: CorrespondentService
+  ) {
+    super(permissionsService, PermissionType.Correspondent, objectService)
+  }
+}
diff --git a/src-ui/src/app/pipes/document-type-name.pipe.spec.ts b/src-ui/src/app/pipes/document-type-name.pipe.spec.ts
new file mode 100644 (file)
index 0000000..20219dc
--- /dev/null
@@ -0,0 +1,28 @@
+import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
+import { provideHttpClientTesting } from '@angular/common/http/testing'
+import { TestBed } from '@angular/core/testing'
+import { PermissionsService } from '../services/permissions.service'
+import { DocumentTypeService } from '../services/rest/document-type.service'
+import { DocumentTypeNamePipe } from './document-type-name.pipe'
+
+describe('DocumentTypeNamePipe', () => {
+  let pipe: DocumentTypeNamePipe
+
+  beforeEach(() => {
+    TestBed.configureTestingModule({
+      providers: [
+        provideHttpClient(withInterceptorsFromDi()),
+        provideHttpClientTesting(),
+      ],
+    })
+  })
+
+  // The pipe is a simple wrapper around ObjectNamePipe, see ObjectNamePipe for the actual tests.
+  it('should be created', () => {
+    pipe = new DocumentTypeNamePipe(
+      TestBed.inject(PermissionsService),
+      TestBed.inject(DocumentTypeService)
+    )
+    expect(pipe).toBeTruthy()
+  })
+})
diff --git a/src-ui/src/app/pipes/document-type-name.pipe.ts b/src-ui/src/app/pipes/document-type-name.pipe.ts
new file mode 100644 (file)
index 0000000..8bb6520
--- /dev/null
@@ -0,0 +1,22 @@
+import { Pipe, PipeTransform } from '@angular/core'
+import {
+  PermissionsService,
+  PermissionType,
+} from '../services/permissions.service'
+import { DocumentTypeService } from '../services/rest/document-type.service'
+import { ObjectNamePipe } from './object-name.pipe'
+
+@Pipe({
+  name: 'documentTypeName',
+})
+export class DocumentTypeNamePipe
+  extends ObjectNamePipe
+  implements PipeTransform
+{
+  constructor(
+    permissionsService: PermissionsService,
+    objectService: DocumentTypeService
+  ) {
+    super(permissionsService, PermissionType.DocumentType, objectService)
+  }
+}
diff --git a/src-ui/src/app/pipes/object-name.pipe.spec.ts b/src-ui/src/app/pipes/object-name.pipe.spec.ts
new file mode 100644 (file)
index 0000000..ebe44af
--- /dev/null
@@ -0,0 +1,88 @@
+import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
+import { provideHttpClientTesting } from '@angular/common/http/testing'
+import { TestBed } from '@angular/core/testing'
+import { of, throwError } from 'rxjs'
+import { MatchingModel } from '../data/matching-model'
+import { PermissionsService } from '../services/permissions.service'
+import { AbstractNameFilterService } from '../services/rest/abstract-name-filter-service'
+import { CorrespondentService } from '../services/rest/correspondent.service'
+import { CorrespondentNamePipe } from './correspondent-name.pipe'
+import { ObjectNamePipe } from './object-name.pipe'
+
+describe('ObjectNamePipe', () => {
+  /*
+    ObjectNamePipe is an abstract class to prevent instantiation,
+    so we test the concrete implementation CorrespondentNamePipe instead.
+  */
+  let pipe: CorrespondentNamePipe
+  let permissionsService: PermissionsService
+  let objectService: AbstractNameFilterService<MatchingModel>
+
+  beforeEach(() => {
+    TestBed.configureTestingModule({
+      providers: [
+        ObjectNamePipe,
+        provideHttpClient(withInterceptorsFromDi()),
+        provideHttpClientTesting(),
+      ],
+    })
+
+    permissionsService = TestBed.inject(PermissionsService)
+    objectService = TestBed.inject(CorrespondentService)
+    pipe = new CorrespondentNamePipe(permissionsService, objectService)
+  })
+
+  it('should return object name if user has permission', (done) => {
+    const mockObjects = {
+      results: [
+        { id: 1, name: 'Object 1' },
+        { id: 2, name: 'Object 2' },
+      ],
+      count: 2,
+      all: [1, 2],
+    }
+    jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
+    jest.spyOn(objectService, 'listAll').mockReturnValue(of(mockObjects))
+
+    pipe.transform(1).subscribe((result) => {
+      expect(result).toBe('Object 1')
+      done()
+    })
+  })
+
+  it('should return empty string if object not found', (done) => {
+    const mockObjects = {
+      results: [{ id: 2, name: 'Object 2' }],
+      count: 1,
+      all: [2],
+    }
+    jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
+    jest.spyOn(objectService, 'listAll').mockReturnValue(of(mockObjects))
+
+    pipe.transform(1).subscribe((result) => {
+      expect(result).toBe('')
+      done()
+    })
+  })
+
+  it('should return "Private" if user does not have permission', (done) => {
+    jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(false)
+
+    pipe.transform(1).subscribe((result) => {
+      expect(result).toBe('Private')
+      done()
+    })
+  })
+
+  it('should handle error and return empty string', (done) => {
+    jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
+    jest
+      .spyOn(objectService, 'listAll')
+      .mockReturnValueOnce(throwError(() => new Error('Error getting objects')))
+
+    pipe.transform(1).subscribe((result) => {
+      expect(result).toBe('')
+      done()
+    })
+  })
+})
diff --git a/src-ui/src/app/pipes/object-name.pipe.ts b/src-ui/src/app/pipes/object-name.pipe.ts
new file mode 100644 (file)
index 0000000..89c8613
--- /dev/null
@@ -0,0 +1,46 @@
+import { Pipe, PipeTransform } from '@angular/core'
+import { catchError, map, Observable, of } from 'rxjs'
+import { MatchingModel } from '../data/matching-model'
+import {
+  PermissionAction,
+  PermissionsService,
+  PermissionType,
+} from '../services/permissions.service'
+import { AbstractNameFilterService } from '../services/rest/abstract-name-filter-service'
+
+@Pipe({
+  name: 'objectName',
+})
+export abstract class ObjectNamePipe implements PipeTransform {
+  /*
+    ObjectNamePipe is an abstract class to prevent instantiation,
+    object-specific pipes extend this class and provide the
+    correct permission type, and object service.
+  */
+  protected objects: MatchingModel[]
+
+  constructor(
+    protected permissionsService: PermissionsService,
+    protected permissionType: PermissionType,
+    protected objectService: AbstractNameFilterService<MatchingModel>
+  ) {}
+
+  transform(obejctId: number): Observable<string> {
+    if (
+      this.permissionsService.currentUserCan(
+        PermissionAction.View,
+        this.permissionType
+      )
+    ) {
+      return this.objectService.listAll().pipe(
+        map((objects) => {
+          this.objects = objects.results
+          return this.objects.find((o) => o.id === obejctId)?.name || ''
+        }),
+        catchError(() => of(''))
+      )
+    } else {
+      return of($localize`Private`)
+    }
+  }
+}
diff --git a/src-ui/src/app/pipes/storage-path-name.pipe.spec.ts b/src-ui/src/app/pipes/storage-path-name.pipe.spec.ts
new file mode 100644 (file)
index 0000000..d49f990
--- /dev/null
@@ -0,0 +1,28 @@
+import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
+import { provideHttpClientTesting } from '@angular/common/http/testing'
+import { TestBed } from '@angular/core/testing'
+import { PermissionsService } from '../services/permissions.service'
+import { StoragePathService } from '../services/rest/storage-path.service'
+import { StoragePathNamePipe } from './storage-path-name.pipe'
+
+describe('StoragePathNamePipe', () => {
+  let pipe: StoragePathNamePipe
+
+  beforeEach(() => {
+    TestBed.configureTestingModule({
+      providers: [
+        provideHttpClient(withInterceptorsFromDi()),
+        provideHttpClientTesting(),
+      ],
+    })
+  })
+
+  // The pipe is a simple wrapper around ObjectNamePipe, see ObjectNamePipe for the actual tests.
+  it('should be created', () => {
+    pipe = new StoragePathNamePipe(
+      TestBed.inject(PermissionsService),
+      TestBed.inject(StoragePathService)
+    )
+    expect(pipe).toBeTruthy()
+  })
+})
diff --git a/src-ui/src/app/pipes/storage-path-name.pipe.ts b/src-ui/src/app/pipes/storage-path-name.pipe.ts
new file mode 100644 (file)
index 0000000..5a16685
--- /dev/null
@@ -0,0 +1,22 @@
+import { Pipe, PipeTransform } from '@angular/core'
+import {
+  PermissionsService,
+  PermissionType,
+} from '../services/permissions.service'
+import { StoragePathService } from '../services/rest/storage-path.service'
+import { ObjectNamePipe } from './object-name.pipe'
+
+@Pipe({
+  name: 'storagePathName',
+})
+export class StoragePathNamePipe
+  extends ObjectNamePipe
+  implements PipeTransform
+{
+  constructor(
+    permissionsService: PermissionsService,
+    objectService: StoragePathService
+  ) {
+    super(permissionsService, PermissionType.StoragePath, objectService)
+  }
+}
index 08381632625a68b365611d26dd17d8a41cfdfcbb..33b21c950dea8fb338fed2ca2a00a7f0e7fee74a 100644 (file)
@@ -37,7 +37,11 @@ describe('UsernamePipe', () => {
     httpTestingController.verify()
   })
 
-  it('should transform user id to username', () => {
+  it('should transform user id to username', (done) => {
+    pipe.transform(2).subscribe((username) => {
+      expect(username).toEqual('username2')
+    })
+
     const req = httpTestingController.expectOne(
       `${environment.apiBaseUrl}users/?page=1&page_size=100000`
     )
@@ -55,24 +59,39 @@ describe('UsernamePipe', () => {
         },
       ],
     })
+    pipe.transform(3).subscribe((username) => {
+      expect(username).toEqual('User Name3')
+    })
 
-    let username = pipe.transform(2)
-    expect(username).toEqual('username2')
-
-    username = pipe.transform(3)
-    expect(username).toEqual('User Name3')
+    pipe.transform(4).subscribe((username) => {
+      expect(username).toEqual('')
+      done()
+    })
+  })
 
-    username = pipe.transform(4)
-    expect(username).toEqual('')
+  it('should show generic label when insufficient permissions', (done) => {
+    jest
+      .spyOn(permissionsService, 'currentUserCan')
+      .mockImplementation((action, type) => {
+        return false
+      })
+    pipe.transform(4).subscribe((username) => {
+      expect(username).toEqual('Shared')
+      done()
+    })
+    httpTestingController.expectNone(
+      `${environment.apiBaseUrl}users/?page=1&page_size=100000`
+    )
   })
 
-  it('should show generic label when no users retrieved', () => {
+  it('should show empty string when no users retrieved due to error', (done) => {
+    pipe.transform(4).subscribe((username) => {
+      expect(username).toEqual('')
+      done()
+    })
     const req = httpTestingController.expectOne(
       `${environment.apiBaseUrl}users/?page=1&page_size=100000`
     )
-    req.flush(null)
-
-    let username = pipe.transform(4)
-    expect(username).toEqual('Shared')
+    req.error(new ProgressEvent('error'))
   })
 })
index 54e8385d802d57cecad86509a94cde7afd10cfb7..f8a3be98728e59c877cfa89ba7978f96cc9778ef 100644 (file)
@@ -1,9 +1,10 @@
 import { Pipe, PipeTransform } from '@angular/core'
+import { catchError, map, Observable, of } from 'rxjs'
 import { User } from '../data/user'
 import {
   PermissionAction,
-  PermissionType,
   PermissionsService,
+  PermissionType,
 } from '../services/permissions.service'
 import { UserService } from '../services/rest/user.service'
 
@@ -14,25 +15,29 @@ export class UsernamePipe implements PipeTransform {
   users: User[]
 
   constructor(
-    permissionsService: PermissionsService,
-    userService: UserService
-  ) {
+    private permissionsService: PermissionsService,
+    private userService: UserService
+  ) {}
+
+  transform(userID: number): Observable<string> {
     if (
-      permissionsService.currentUserCan(
+      this.permissionsService.currentUserCan(
         PermissionAction.View,
         PermissionType.User
       )
     ) {
-      userService.listAll().subscribe((r) => (this.users = r.results))
+      return this.userService.listAll().pipe(
+        map((users) => {
+          this.users = users.results
+          return this.getName(this.users.find((u) => u.id === userID))
+        }),
+        catchError(() => of(''))
+      )
+    } else {
+      return of($localize`Shared`)
     }
   }
 
-  transform(userID: number): string {
-    return this.users
-      ? (this.getName(this.users.find((u) => u.id === userID)) ?? '')
-      : $localize`Shared`
-  }
-
   getName(user: User): string {
     if (!user) return ''
     const name = [user.first_name, user.last_name].join(' ')
index dc358d6c7112d1c374555d05afc0f378fd2f3b4e..4d7d7cef7fb45409eac30e11cc68c6c3bdcca6ee 100644 (file)
@@ -251,26 +251,6 @@ describe(`DocumentService`, () => {
     )
   })
 
-  it('should add observables to document', () => {
-    subscription = service
-      .listFiltered(1, 25, 'title', false, [])
-      .subscribe((result) => {
-        expect(result.results).toHaveLength(3)
-        const doc = result.results[0]
-        expect(doc.correspondent$).not.toBeNull()
-        expect(doc.document_type$).not.toBeNull()
-        expect(doc.tags$).not.toBeNull()
-        expect(doc.storage_path$).not.toBeNull()
-      })
-    httpTestingController
-      .expectOne(
-        `${environment.apiBaseUrl}${endpoint}/?page=1&page_size=25&ordering=title`
-      )
-      .flush({
-        results: documents,
-      })
-  })
-
   it('should set search query', () => {
     const searchQuery = 'hello'
     service.searchQuery = searchQuery
index e05ab8373f99fff5d776f77293cbad5a9ad0149f..6bc29276bfea2065411a438cb541821c240130fa 100644 (file)
@@ -1,7 +1,7 @@
 import { HttpClient } from '@angular/common/http'
 import { Injectable } from '@angular/core'
 import { Observable } from 'rxjs'
-import { map, tap } from 'rxjs/operators'
+import { map } from 'rxjs/operators'
 import { AuditLogEntry } from 'src/app/data/auditlog-entry'
 import { CustomField } from 'src/app/data/custom-field'
 import {
@@ -22,11 +22,7 @@ import {
 } from '../permissions.service'
 import { SettingsService } from '../settings.service'
 import { AbstractPaperlessService } from './abstract-paperless-service'
-import { CorrespondentService } from './correspondent.service'
 import { CustomFieldsService } from './custom-fields.service'
-import { DocumentTypeService } from './document-type.service'
-import { StoragePathService } from './storage-path.service'
-import { TagService } from './tag.service'
 
 export interface SelectionDataItem {
   id: number
@@ -61,10 +57,6 @@ export class DocumentService extends AbstractPaperlessService<Document> {
 
   constructor(
     http: HttpClient,
-    private correspondentService: CorrespondentService,
-    private documentTypeService: DocumentTypeService,
-    private tagService: TagService,
-    private storagePathService: StoragePathService,
     private permissionsService: PermissionsService,
     private settingsService: SettingsService,
     private customFieldService: CustomFieldsService
@@ -137,54 +129,6 @@ export class DocumentService extends AbstractPaperlessService<Document> {
     ]
   }
 
-  addObservablesToDocument(doc: Document) {
-    if (
-      doc.correspondent &&
-      this.permissionsService.currentUserCan(
-        PermissionAction.View,
-        PermissionType.Correspondent
-      )
-    ) {
-      doc.correspondent$ = this.correspondentService.getCached(
-        doc.correspondent
-      )
-    }
-    if (
-      doc.document_type &&
-      this.permissionsService.currentUserCan(
-        PermissionAction.View,
-        PermissionType.DocumentType
-      )
-    ) {
-      doc.document_type$ = this.documentTypeService.getCached(doc.document_type)
-    }
-    if (
-      doc.tags &&
-      this.permissionsService.currentUserCan(
-        PermissionAction.View,
-        PermissionType.Tag
-      )
-    ) {
-      doc.tags$ = this.tagService
-        .getCachedMany(doc.tags)
-        .pipe(
-          tap((tags) =>
-            tags.sort((tagA, tagB) => tagA.name.localeCompare(tagB.name))
-          )
-        )
-    }
-    if (
-      doc.storage_path &&
-      this.permissionsService.currentUserCan(
-        PermissionAction.View,
-        PermissionType.StoragePath
-      )
-    ) {
-      doc.storage_path$ = this.storagePathService.getCached(doc.storage_path)
-    }
-    return doc
-  }
-
   listFiltered(
     page?: number,
     pageSize?: number,
@@ -199,11 +143,6 @@ export class DocumentService extends AbstractPaperlessService<Document> {
       sortField,
       sortReverse,
       Object.assign(extraParams, queryParamsFromFilterRules(filterRules))
-    ).pipe(
-      map((results) => {
-        results.results.forEach((doc) => this.addObservablesToDocument(doc))
-        return results
-      })
     )
   }
 
index 998ebf260902e2a43372edf17e449d7bb2bf1fc2..83aa12dc2134e26d54382dee2f0ed0d673e006e7 100644 (file)
@@ -180,6 +180,9 @@ import localeSv from '@angular/common/locales/sv'
 import localeTr from '@angular/common/locales/tr'
 import localeUk from '@angular/common/locales/uk'
 import localeZh from '@angular/common/locales/zh'
+import { CorrespondentNamePipe } from './app/pipes/correspondent-name.pipe'
+import { DocumentTypeNamePipe } from './app/pipes/document-type-name.pipe'
+import { StoragePathNamePipe } from './app/pipes/storage-path-name.pipe'
 
 registerLocaleData(localeAf)
 registerLocaleData(localeAr)
@@ -375,6 +378,9 @@ bootstrapApplication(AppComponent, {
     DirtyDocGuard,
     DirtySavedViewGuard,
     UsernamePipe,
+    CorrespondentNamePipe,
+    DocumentTypeNamePipe,
+    StoragePathNamePipe,
     provideHttpClient(withInterceptorsFromDi()),
   ],
 }).catch((err) => console.error(err))