Chorehancement: update to Angular v22, 'zoneless' / 'reactive' (#13114)

This commit is contained in:
shamoon
2026-07-10 00:42:16 -07:00
committed by GitHub
parent f244442c65
commit 106b41a15c
213 changed files with 5363 additions and 5842 deletions
@@ -1,13 +1,13 @@
<div class="card document-card-large mb-3 shadow-sm bg-light placeholder-glow fade" [class.show]="show" [class.card-selected]="selected" [class.document-card]="selectable" (mouseleave)="mouseLeaveCard()">
<div class="card document-card-large mb-3 shadow-sm bg-light placeholder-glow fade" [class.show]="show()" [class.card-selected]="selected()" [class.document-card]="selectable" (mouseleave)="mouseLeaveCard()">
<div class="row g-0">
<div class="col-md-2 doc-img-container rounded-start" (click)="this.toggleSelected.emit($event)" (dblclick)="dblClickDocument.emit()">
@if (document) {
@if (document()) {
<img [src]="getThumbUrl()" class="card-img doc-img border-end rounded-start" [class.inverted]="getIsThumbInverted()">
<div class="border-end border-bottom bg-light document-card-check">
<div class="form-check">
<input type="checkbox" class="form-check-input" id="smallCardCheck{{document.id}}" [checked]="selected" (click)="this.toggleSelected.emit($event)">
<label class="form-check-label" for="smallCardCheck{{document.id}}"></label>
<input type="checkbox" class="form-check-input" id="smallCardCheck{{document().id}}" [checked]="selected()" (click)="this.toggleSelected.emit($event)">
<label class="form-check-label" for="smallCardCheck{{document().id}}"></label>
</div>
</div>
} @else {
@@ -19,20 +19,20 @@
<div class="card-body">
<div class="d-flex justify-content-between align-items-center">
<h5 class="card-title w-100">
@if (document) {
@if (displayFields.includes(DisplayField.CORRESPONDENT) && document.correspondent) {
@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 | correspondentName | async}}</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 | correspondentName | async}}
{{document().correspondent | correspondentName | async}}
}
@if (displayFields.includes(DisplayField.TITLE)) {:}
@if (displayFields().includes(DisplayField.TITLE)) {:}
}
@if (displayFields.includes(DisplayField.TITLE)) {
{{document.title | documentTitle}}
@if (displayFields().includes(DisplayField.TITLE)) {
{{document().title | documentTitle}}
}
@if (displayFields.includes(DisplayField.TAGS)) {
@for (tagID of document.tags; track tagID) {
@if (displayFields().includes(DisplayField.TAGS)) {
@for (tagID of document().tags; track tagID) {
<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>
}
}
@@ -42,9 +42,9 @@
</h5>
</div>
<p class="card-text">
@if (document) {
@if (document()) {
@if (hasSearchHighlights) {
<span [innerHtml]="document.__search_hit__.highlights"></span>
<span [innerHtml]="document().__search_hit__.highlights"></span>
}
@for (highlight of searchNoteHighlights; track highlight) {
<span class="d-block">
@@ -64,14 +64,14 @@
<div class="d-flex flex-column flex-md-row align-items-md-center">
<div class="btn-group">
@if (document) {
@if (document()) {
<a class="btn btn-sm btn-outline-secondary" (click)="clickMoreLike.emit()">
<i-bs name="diagram-3" class="me-1"></i-bs><span class="d-none d-md-inline" i18n>More like this</span>
</a>
<a routerLink="/documents/{{document.id}}" class="btn btn-sm btn-outline-secondary" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.Document }">
<a routerLink="/documents/{{document().id}}" class="btn btn-sm btn-outline-secondary" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.Document }">
<i-bs name="file-earmark-richtext" class="me-1"></i-bs><span class="d-none d-md-inline" i18n>Open</span>
</a>
<pngx-preview-popup [document]="document" #popupPreview>
<pngx-preview-popup [document]="document()" #popupPreview>
<i-bs name="eye" class="me-1"></i-bs><span class="d-none d-md-inline" i18n>View</span>
</pngx-preview-popup>
<a class="btn btn-sm btn-outline-secondary" [href]="getDownloadUrl()">
@@ -86,76 +86,76 @@
</div>
<div class="list-group list-group-horizontal border-0 card-info ms-md-auto mt-2 mt-md-0">
@if (document) {
@if (displayFields.includes(DisplayField.NOTES) && notesEnabled && document.notes.length) {
<button routerLink="/documents/{{document.id}}/notes" class="list-group-item btn btn-sm bg-light text-dark p-1 border-0 me-2 d-flex align-items-center" title="View notes" i18n-title>
<i-bs width=".9em" height=".9em" class="me-2 text-muted" name="chat-left-text"></i-bs><small>{{document.notes.length}} Notes</small>
@if (document()) {
@if (displayFields().includes(DisplayField.NOTES) && notesEnabled && document().notes.length) {
<button routerLink="/documents/{{document().id}}/notes" class="list-group-item btn btn-sm bg-light text-dark p-1 border-0 me-2 d-flex align-items-center" title="View notes" i18n-title>
<i-bs width=".9em" height=".9em" class="me-2 text-muted" name="chat-left-text"></i-bs><small>{{document().notes.length}} Notes</small>
</button>
}
@if (displayFields.includes(DisplayField.DOCUMENT_TYPE) && document.document_type) {
@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 | documentTypeName | async}}</small>
(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 | documentTypeName | async}}</small>
</button>
}
@if (displayFields.includes(DisplayField.STORAGE_PATH) && document.storage_path) {
@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 | storagePathName | async}}</small>
(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 | storagePathName | async}}</small>
</button>
}
@if (displayFields.includes(DisplayField.ASN) && document.archive_serial_number | isNumber) {
@if (displayFields().includes(DisplayField.ASN) && document().archive_serial_number | isNumber) {
<div class="list-group-item me-2 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="upc-scan"></i-bs><small>#{{document.archive_serial_number}}</small>
<i-bs width=".9em" height=".9em" class="me-2 text-muted" name="upc-scan"></i-bs><small>#{{document().archive_serial_number}}</small>
</div>
}
@if (displayFields.includes(DisplayField.CREATED) || displayFields.includes(DisplayField.ADDED)) {
@if (displayFields().includes(DisplayField.CREATED) || displayFields().includes(DisplayField.ADDED)) {
<ng-template #dateTooltip>
<div class="d-flex flex-column text-light">
<span i18n>Created: {{ document.created | customDate }}</span>
<span i18n>Added: {{ document.added | customDate }}</span>
<span i18n>Modified: {{ document.modified | customDate }}</span>
<span i18n>Created: {{ document().created | customDate }}</span>
<span i18n>Added: {{ document().added | customDate }}</span>
<span i18n>Modified: {{ document().modified | customDate }}</span>
</div>
</ng-template>
@if (displayFields.includes(DisplayField.CREATED)) {
@if (displayFields().includes(DisplayField.CREATED)) {
<div class="list-group-item bg-light text-dark p-1 border-0 d-flex align-items-center" [ngbTooltip]="dateTooltip">
<i-bs width=".9em" height=".9em" class="me-2 text-muted" name="calendar-event"></i-bs><small>{{document.created | customDate:'mediumDate'}}</small>
<i-bs width=".9em" height=".9em" class="me-2 text-muted" name="calendar-event"></i-bs><small>{{document().created | customDate:'mediumDate'}}</small>
</div>
}
@if (displayFields.includes(DisplayField.ADDED)) {
@if (displayFields().includes(DisplayField.ADDED)) {
<div class="list-group-item bg-light text-dark p-1 border-0 d-flex align-items-center" [ngbTooltip]="dateTooltip">
<i-bs width=".9em" height=".9em" class="me-2 text-muted" name="calendar-event"></i-bs><small>{{document.added | customDate:'mediumDate'}}</small>
<i-bs width=".9em" height=".9em" class="me-2 text-muted" name="calendar-event"></i-bs><small>{{document().added | customDate:'mediumDate'}}</small>
</div>
}
}
@if (displayFields.includes(DisplayField.PAGE_COUNT) && document.page_count) {
@if (displayFields().includes(DisplayField.PAGE_COUNT) && document().page_count) {
<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="files"></i-bs>
<small i18n>{document.page_count, plural, =1 {1 page} other {{{document.page_count}} pages}}</small>
<small i18n>{document().page_count, plural, =1 {1 page} other {{{document().page_count}} pages}}</small>
</div>
}
@if (displayFields.includes(DisplayField.OWNER) && document.owner && document.owner !== settingsService.currentUser.id) {
@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 | async}}</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) {
@if (displayFields().includes(DisplayField.SHARED) && document().is_shared_by_requester) {
<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="people-fill"></i-bs><small i18n>Shared</small>
</div>
}
@if (document.__search_hit__?.score) {
@if (document().__search_hit__?.score) {
<div class="list-group-item bg-light text-dark border-0 d-flex p-0 ps-4 search-score">
<small class="me-2 text-muted" i18n>Score:</small>
<ngb-progressbar [type]="searchScoreClass" [value]="document.__search_hit__.score" class="search-score-bar mx-2 mt-1" [max]="1"></ngb-progressbar>
<ngb-progressbar [type]="searchScoreClass" [value]="document().__search_hit__.score" class="search-score-bar mx-2 mt-1" [max]="1"></ngb-progressbar>
</div>
}
@for (field of document.custom_fields; track field.field) {
@if (displayFields.includes(DisplayField.CUSTOM_FIELD + field.field)) {
@for (field of document().custom_fields; track field.field) {
@if (displayFields().includes(DisplayField.CUSTOM_FIELD + field.field)) {
<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="ui-radios"></i-bs>
<small>
<pngx-custom-field-display [document]="document" [fieldId]="field.field" showNameIfEmpty="true"></pngx-custom-field-display>
<pngx-custom-field-display [document]="document()" [fieldId]="field.field" showNameIfEmpty="true"></pngx-custom-field-display>
</small>
</div>
}
@@ -2,6 +2,26 @@
overflow-wrap: anywhere;
}
.fade.show {
animation: pngx-entry-fade 160ms ease-out;
}
@keyframes pngx-entry-fade {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
@media (prefers-reduced-motion: reduce) {
.fade.show {
animation: none;
}
}
.doc-img-container {
position: relative;
}
@@ -67,16 +67,19 @@ describe('DocumentCardLargeComponent', () => {
fixture = TestBed.createComponent(DocumentCardLargeComponent)
component = fixture.componentInstance
component.document = doc
fixture.componentRef.setInput('document', {
...doc,
tags: [...doc.tags],
notes: [...doc.notes],
})
fixture.detectChanges()
jest.useFakeTimers()
})
it('should show the card', () => {
expect(component.show).toBeFalsy()
expect(component.show()).toBeTruthy()
component.ngAfterViewInit()
jest.advanceTimersByTime(100)
expect(component.show).toBeTruthy()
expect(component.show()).toBeTruthy()
})
it('should display a document', () => {
@@ -91,25 +94,34 @@ describe('DocumentCardLargeComponent', () => {
it('should display search hits with colored score', () => {
// high
component.document.__search_hit__ = {
score: 0.9,
rank: 1,
highlights: 'cheesecake',
}
fixture.componentRef.setInput('document', {
...component.document(),
__search_hit__: {
score: 0.9,
rank: 1,
highlights: 'cheesecake',
},
})
fixture.detectChanges()
let search_hit = fixture.debugElement.query(By.css('.search-score'))
expect(search_hit).not.toBeUndefined()
expect(component.searchScoreClass).toEqual('success')
// medium
component.document.__search_hit__.score = 0.6
fixture.componentRef.setInput('document', {
...component.document(),
__search_hit__: { ...component.document().__search_hit__, score: 0.6 },
})
fixture.detectChanges()
search_hit = fixture.debugElement.query(By.css('.search-score'))
expect(search_hit).not.toBeUndefined()
expect(component.searchScoreClass).toEqual('warning')
// low
component.document.__search_hit__.score = 0.1
fixture.componentRef.setInput('document', {
...component.document(),
__search_hit__: { ...component.document().__search_hit__, score: 0.1 },
})
fixture.detectChanges()
search_hit = fixture.debugElement.query(By.css('.search-score'))
expect(search_hit).not.toBeUndefined()
@@ -117,23 +129,29 @@ describe('DocumentCardLargeComponent', () => {
})
it('should display note highlights', () => {
component.document.__search_hit__ = {
score: 0.9,
rank: 1,
note_highlights: '<span>bananas</span>',
}
fixture.componentRef.setInput('document', {
...component.document(),
__search_hit__: {
score: 0.9,
rank: 1,
note_highlights: '<span>bananas</span>',
},
})
fixture.detectChanges()
expect(fixture.nativeElement.textContent).toContain('bananas')
expect(component.searchNoteHighlights).toContain('<span>bananas</span>')
})
it('should fall back to document content when a search hit has no highlights', () => {
component.document.__search_hit__ = {
score: 0.9,
rank: 1,
highlights: '',
note_highlights: null,
}
fixture.componentRef.setInput('document', {
...component.document(),
__search_hit__: {
score: 0.9,
rank: 1,
highlights: '',
note_highlights: null,
},
})
fixture.detectChanges()
expect(fixture.nativeElement.textContent).toContain('Cupcake ipsum')
@@ -3,10 +3,10 @@ import {
AfterViewInit,
Component,
EventEmitter,
Input,
Output,
ViewChild,
inject,
input,
} from '@angular/core'
import { RouterModule } from '@angular/router'
import {
@@ -14,7 +14,6 @@ import {
NgbTooltipModule,
} from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { delay, of } from 'rxjs'
import {
DEFAULT_DISPLAY_FIELDS,
DisplayField,
@@ -65,15 +64,14 @@ export class DocumentCardLargeComponent
{
private documentService = inject(DocumentService)
settingsService = inject(SettingsService)
readonly selected = input(false)
readonly displayFields = input<string[]>(
DEFAULT_DISPLAY_FIELDS.map((f) => f.id)
)
readonly document = input<Document>(undefined)
DisplayField = DisplayField
@Input()
selected = false
@Input()
displayFields: string[] = DEFAULT_DISPLAY_FIELDS.map((f) => f.id)
@Output()
toggleSelected = new EventEmitter()
@@ -81,9 +79,6 @@ export class DocumentCardLargeComponent
return this.toggleSelected.observers.length > 0
}
@Input()
document: Document
@Output()
dblClickDocument = new EventEmitter()
@@ -108,18 +103,15 @@ export class DocumentCardLargeComponent
popoverHidden = true
ngAfterViewInit(): void {
of(true)
.pipe(delay(50))
.subscribe(() => {
this.show = true
})
this.show.set(true)
}
get searchScoreClass() {
if (this.document.__search_hit__) {
if (this.document.__search_hit__.score > 0.7) {
const document = this.document()
if (document.__search_hit__) {
if (document.__search_hit__.score > 0.7) {
return 'success'
} else if (this.document.__search_hit__.score > 0.3) {
} else if (document.__search_hit__.score > 0.3) {
return 'warning'
} else {
return 'danger'
@@ -129,12 +121,10 @@ export class DocumentCardLargeComponent
get searchNoteHighlights() {
let highlights = []
if (
this.document['__search_hit__'] &&
this.document['__search_hit__'].note_highlights
) {
const document = this.document()
if (document?.['__search_hit__']?.note_highlights) {
// only show notes with a match
highlights = (this.document['__search_hit__'].note_highlights as string)
highlights = (document['__search_hit__'].note_highlights as string)
.split(',')
.filter((highlight) => highlight.includes('<span'))
}
@@ -146,11 +136,11 @@ export class DocumentCardLargeComponent
}
getThumbUrl() {
return this.documentService.getThumbUrl(this.document.id)
return this.documentService.getThumbUrl(this.document().id)
}
getDownloadUrl() {
return this.documentService.getDownloadUrl(this.document.id)
return this.documentService.getDownloadUrl(this.document().id)
}
mouseLeaveCard() {
@@ -158,19 +148,21 @@ export class DocumentCardLargeComponent
}
get contentTrimmed() {
const document = this.document()
return (
this.document.content.substring(0, 500) +
(this.document.content.length > 500 ? '...' : '')
document.content.substring(0, 500) +
(document.content.length > 500 ? '...' : '')
)
}
get hasSearchHighlights() {
return Boolean(this.document?.__search_hit__?.highlights?.trim()?.length)
return Boolean(this.document()?.__search_hit__?.highlights?.trim()?.length)
}
get shouldShowContentFallback() {
const document = this.document()
return (
this.document?.__search_hit__?.score == null ||
document?.__search_hit__?.score == null ||
(!this.hasSearchHighlights && this.searchNoteHighlights.length === 0)
)
}