Fix(dev): history causing infinite requests (#12059)

This commit is contained in:
shamoon
2026-02-10 17:01:47 -08:00
committed by GitHub
parent e8e027abc0
commit 775e32bf3b
6 changed files with 147 additions and 95 deletions

View File

@@ -0,0 +1,61 @@
@if (loading) {
<div class="d-flex">
<output class="spinner-border spinner-border-sm fw-normal" role="status"></output>
</div>
} @else {
<ul class="list-group">
@if (entries.length === 0) {
<li class="list-group-item">
<div class="d-flex justify-content-center">
<span class="fst-italic" i18n>No entries found.</span>
</div>
</li>
} @else {
@for (entry of entries; track entry.id) {
<li class="list-group-item">
<div class="d-flex justify-content-between align-items-center">
<ng-template #timestamp>
<div class="text-light">
{{ entry.timestamp | customDate:'longDate' }} {{ entry.timestamp | date:'shortTime' }}
</div>
</ng-template>
<span class="text-muted" [ngbTooltip]="timestamp">{{ entry.timestamp | customDate:'relative' }}</span>
@if (entry.actor) {
<span class="ms-3 fst-italic">{{ entry.actor.username }}</span>
} @else {
<span class="ms-3 fst-italic">System</span>
}
<span class="badge bg-secondary ms-auto" [class.bg-primary]="entry.action === AuditLogAction.Create">{{ entry.action | titlecase }}</span>
</div>
<ul class="mt-2">
@for (change of entry.changes | keyvalue; track change.key) {
@if (change.value["type"] === 'm2m') {
<li>
<span class="fst-italic">{{ change.value["operation"] | titlecase }}</span>&nbsp;
<span>{{ change.key | titlecase }}</span>:&nbsp;
<code class="text-primary">{{ change.value["objects"].join(', ') }}</code>
</li>
}
@else if (change.value["type"] === 'custom_field') {
<li>
<span>{{ change.value["field"] }}</span>:&nbsp;
<code class="text-primary">{{ change.value["value"] }}</code>
</li>
}
@else {
<li>
<span>{{ change.key | titlecase }}</span>:&nbsp;
@if (change.key === 'content') {
<code class="text-primary">{{ change.value[1]?.substring(0,100) }}...</code>
} @else {
<code class="text-primary">{{ getPrettyName(change.key, change.value[1]) | async }}</code>
}
</li>
}
}
</ul>
</li>
}
}
</ul>
}

View File

@@ -0,0 +1,183 @@
import { ComponentFixture, TestBed } from '@angular/core/testing'
import { DatePipe } from '@angular/common'
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
import { provideHttpClientTesting } from '@angular/common/http/testing'
import { NgbCollapseModule } from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
import { of } from 'rxjs'
import { AuditLogAction } from 'src/app/data/auditlog-entry'
import { DataType } from 'src/app/data/datatype'
import { CorrespondentService } from 'src/app/services/rest/correspondent.service'
import { DocumentTypeService } from 'src/app/services/rest/document-type.service'
import { DocumentService } from 'src/app/services/rest/document.service'
import { StoragePathService } from 'src/app/services/rest/storage-path.service'
import { UserService } from 'src/app/services/rest/user.service'
import { DocumentHistoryComponent } from './document-history.component'
describe('DocumentHistoryComponent', () => {
let component: DocumentHistoryComponent
let fixture: ComponentFixture<DocumentHistoryComponent>
let documentService: DocumentService
let correspondentService: CorrespondentService
let documentTypeService: DocumentTypeService
let storagePathService: StoragePathService
let userService: UserService
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [
DocumentHistoryComponent,
NgbCollapseModule,
NgxBootstrapIconsModule.pick(allIcons),
],
providers: [
DatePipe,
provideHttpClient(withInterceptorsFromDi()),
provideHttpClientTesting(),
],
}).compileComponents()
fixture = TestBed.createComponent(DocumentHistoryComponent)
documentService = TestBed.inject(DocumentService)
correspondentService = TestBed.inject(CorrespondentService)
documentTypeService = TestBed.inject(DocumentTypeService)
storagePathService = TestBed.inject(StoragePathService)
userService = TestBed.inject(UserService)
component = fixture.componentInstance
})
it('should get audit log entries on init', () => {
const getHistorySpy = jest.spyOn(documentService, 'getHistory')
getHistorySpy.mockReturnValue(
of([
{
id: 1,
actor: {
id: 1,
username: 'user1',
},
action: AuditLogAction.Create,
timestamp: '2021-01-01T00:00:00Z',
remote_addr: '1.2.3.4',
changes: {
title: ['old title', 'new title'],
},
},
])
)
component.documentId = 1
fixture.detectChanges()
expect(getHistorySpy).toHaveBeenCalledWith(1)
})
it('getPrettyName should return the correspondent name', () => {
const correspondentId = '1'
const correspondentName = 'John Doe'
const getCachedSpy = jest
.spyOn(correspondentService, 'getCached')
.mockReturnValue(of({ name: correspondentName }))
component
.getPrettyName(DataType.Correspondent, correspondentId)
.subscribe((result) => {
expect(result).toBe(correspondentName)
})
expect(getCachedSpy).toHaveBeenCalledWith(parseInt(correspondentId))
})
it('getPrettyName should memoize results to avoid resubscribe loops', () => {
const correspondentId = '1'
const getCachedSpy = jest
.spyOn(correspondentService, 'getCached')
.mockReturnValue(of({ name: 'John Doe' }))
const a = component.getPrettyName(DataType.Correspondent, correspondentId)
const b = component.getPrettyName(DataType.Correspondent, correspondentId)
expect(a).toBe(b)
expect(getCachedSpy).toHaveBeenCalledTimes(1)
})
it('getPrettyName should fall back to the correspondent id when missing', () => {
const correspondentId = '1'
jest.spyOn(correspondentService, 'getCached').mockReturnValue(of(null))
component
.getPrettyName(DataType.Correspondent, correspondentId)
.subscribe((result) => {
expect(result).toBe(correspondentId)
})
})
it('getPrettyName should return the document type name', () => {
const documentTypeId = '1'
const documentTypeName = 'Invoice'
const getCachedSpy = jest
.spyOn(documentTypeService, 'getCached')
.mockReturnValue(of({ name: documentTypeName }))
component
.getPrettyName(DataType.DocumentType, documentTypeId)
.subscribe((result) => {
expect(result).toBe(documentTypeName)
})
expect(getCachedSpy).toHaveBeenCalledWith(parseInt(documentTypeId))
})
it('getPrettyName should fall back to the document type id when missing', () => {
const documentTypeId = '1'
jest.spyOn(documentTypeService, 'getCached').mockReturnValue(of(null))
component
.getPrettyName(DataType.DocumentType, documentTypeId)
.subscribe((result) => {
expect(result).toBe(documentTypeId)
})
})
it('getPrettyName should return the storage path path', () => {
const storagePathId = '1'
const storagePath = '/path/to/storage'
const getCachedSpy = jest
.spyOn(storagePathService, 'getCached')
.mockReturnValue(of({ path: storagePath }))
component
.getPrettyName(DataType.StoragePath, storagePathId)
.subscribe((result) => {
expect(result).toBe(storagePath)
})
expect(getCachedSpy).toHaveBeenCalledWith(parseInt(storagePathId))
})
it('getPrettyName should fall back to the storage path id when missing', () => {
const storagePathId = '1'
jest.spyOn(storagePathService, 'getCached').mockReturnValue(of(null))
component
.getPrettyName(DataType.StoragePath, storagePathId)
.subscribe((result) => {
expect(result).toBe(storagePathId)
})
})
it('getPrettyName should return the owner username', () => {
const ownerId = '1'
const ownerUsername = 'user1'
const getCachedSpy = jest
.spyOn(userService, 'getCached')
.mockReturnValue(of({ username: ownerUsername }))
component.getPrettyName('owner', ownerId).subscribe((result) => {
expect(result).toBe(ownerUsername)
})
expect(getCachedSpy).toHaveBeenCalledWith(parseInt(ownerId))
})
it('getPrettyName should fall back to the owner id when missing', () => {
const ownerId = '1'
jest.spyOn(userService, 'getCached').mockReturnValue(of(null))
component.getPrettyName('owner', ownerId).subscribe((result) => {
expect(result).toBe(ownerId)
})
})
it('getPrettyName should return the value as is for other types', () => {
const id = '123'
component.getPrettyName('other', id).subscribe((result) => {
expect(result).toBe(id)
})
})
})

View File

@@ -0,0 +1,114 @@
import { AsyncPipe, KeyValuePipe, TitleCasePipe } from '@angular/common'
import { Component, Input, OnInit, inject } from '@angular/core'
import { NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { Observable, first, map, of, shareReplay } from 'rxjs'
import { AuditLogAction, AuditLogEntry } from 'src/app/data/auditlog-entry'
import { DataType } from 'src/app/data/datatype'
import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
import { CorrespondentService } from 'src/app/services/rest/correspondent.service'
import { DocumentTypeService } from 'src/app/services/rest/document-type.service'
import { DocumentService } from 'src/app/services/rest/document.service'
import { StoragePathService } from 'src/app/services/rest/storage-path.service'
import { UserService } from 'src/app/services/rest/user.service'
@Component({
selector: 'pngx-document-history',
templateUrl: './document-history.component.html',
styleUrl: './document-history.component.scss',
imports: [
CustomDatePipe,
NgbTooltipModule,
AsyncPipe,
KeyValuePipe,
TitleCasePipe,
NgxBootstrapIconsModule,
],
})
export class DocumentHistoryComponent implements OnInit {
private documentService = inject(DocumentService)
private correspondentService = inject(CorrespondentService)
private storagePathService = inject(StoragePathService)
private documentTypeService = inject(DocumentTypeService)
private userService = inject(UserService)
public AuditLogAction = AuditLogAction
private _documentId: number
@Input()
set documentId(id: number) {
if (this._documentId !== id) {
this._documentId = id
this.prettyNameCache.clear()
this.loadHistory()
}
}
public loading: boolean = true
public entries: AuditLogEntry[] = []
private readonly prettyNameCache = new Map<string, Observable<string>>()
ngOnInit(): void {
this.loadHistory()
}
private loadHistory(): void {
if (this._documentId) {
this.loading = true
this.documentService.getHistory(this._documentId).subscribe((entries) => {
this.entries = entries
this.loading = false
})
}
}
getPrettyName(type: DataType | string, id: string): Observable<string> {
const cacheKey = `${type}:${id}`
const cached = this.prettyNameCache.get(cacheKey)
if (cached) {
return cached
}
const idInt = parseInt(id, 10)
const fallback$ = of(id)
let result$: Observable<string>
if (!Number.isFinite(idInt)) {
result$ = fallback$
} else {
switch (type) {
case DataType.Correspondent:
result$ = this.correspondentService.getCached(idInt).pipe(
first(),
map((correspondent) => correspondent?.name ?? id)
)
break
case DataType.DocumentType:
result$ = this.documentTypeService.getCached(idInt).pipe(
first(),
map((documentType) => documentType?.name ?? id)
)
break
case DataType.StoragePath:
result$ = this.storagePathService.getCached(idInt).pipe(
first(),
map((storagePath) => storagePath?.path ?? id)
)
break
case 'owner':
result$ = this.userService.getCached(idInt).pipe(
first(),
map((user) => user?.username ?? id)
)
break
default:
result$ = fallback$
}
}
const shared$ = result$.pipe(shareReplay({ bufferSize: 1, refCount: true }))
this.prettyNameCache.set(cacheKey, shared$)
return shared$
}
}