Merge branch 'dev' into feature-bulk-edit-cf-values

This commit is contained in:
shamoon 2024-12-05 20:34:35 -08:00
commit 490e48c131
43 changed files with 617 additions and 537 deletions

File diff suppressed because it is too large Load Diff

View File

@ -47,7 +47,7 @@
</tr>
}
@for (document of documentsInTrash; track document.id) {
<tr (click)="toggleSelected(document); $event.stopPropagation();" (mouseleave)="popupPreview.close()">
<tr (click)="toggleSelected(document); $event.stopPropagation();" (mouseleave)="popupPreview.close()" class="data-row" [class.reveal]="reveal">
<td>
<div class="form-check m-0 ms-2 me-n2">
<input type="checkbox" class="form-check-input" id="{{document.id}}" [checked]="selectedDocuments.has(document.id)" (click)="toggleSelected(document); $event.stopPropagation();">

View File

@ -0,0 +1,8 @@
.data-row {
opacity: 0;
transition: opacity .2s;
}
.reveal {
opacity: 1;
}

View File

@ -69,6 +69,7 @@ describe('TrashComponent', () => {
})
it('should call correct service method on reload', () => {
jest.useFakeTimers()
const trashSpy = jest.spyOn(trashService, 'getTrash')
trashSpy.mockReturnValue(
of({
@ -78,6 +79,7 @@ describe('TrashComponent', () => {
})
)
component.reload()
jest.advanceTimersByTime(100)
expect(trashSpy).toHaveBeenCalled()
expect(component.documentsInTrash).toEqual(documentsInTrash)
})

View File

@ -4,7 +4,7 @@ import { Document } from 'src/app/data/document'
import { ToastService } from 'src/app/services/toast.service'
import { TrashService } from 'src/app/services/trash.service'
import { ConfirmDialogComponent } from '../../common/confirm-dialog/confirm-dialog.component'
import { Subject, takeUntil } from 'rxjs'
import { delay, Subject, takeUntil, tap } from 'rxjs'
import { SettingsService } from 'src/app/services/settings.service'
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
import { Router } from '@angular/router'
@ -21,6 +21,7 @@ export class TrashComponent implements OnDestroy {
public page: number = 1
public totalDocuments: number
public isLoading: boolean = false
public reveal: boolean = false
unsubscribeNotifier: Subject<void> = new Subject()
constructor(
@ -40,12 +41,20 @@ export class TrashComponent implements OnDestroy {
reload() {
this.isLoading = true
this.trashService.getTrash(this.page).subscribe((r) => {
this.documentsInTrash = r.results
this.totalDocuments = r.count
this.isLoading = false
this.selectedDocuments.clear()
})
this.trashService
.getTrash(this.page)
.pipe(
tap((r) => {
this.documentsInTrash = r.results
this.totalDocuments = r.count
this.selectedDocuments.clear()
}),
delay(100)
)
.subscribe(() => {
this.reveal = true
this.isLoading = false
})
}
delete(document: Document) {

View File

@ -38,8 +38,10 @@ describe('SwitchComponent', () => {
expect(component.value).toBeFalsy()
})
it('should show note if unset', () => {
it('should correctly report unset', () => {
component.value = null
expect(component.isUnset).toBeTruthy()
component.value = undefined
expect(component.isUnset).toBeTruthy()
})
})

View File

@ -18,7 +18,7 @@
<div class="ms-n2 me-1">
<i-bs name="grip-vertical"></i-bs>
</div>
<h6 class="card-title mb-0" i18n>Loading...</h6>
<h6 class="card-title mb-0"></h6>
</div>
</div>
</div>

View File

@ -9,8 +9,9 @@
<a class="btn-link text-decoration-none" header-buttons [routerLink]="[]" (click)="showAll()" i18n>Show all</a>
}
<div content class="wrapper" [class.reveal]="reveal">
@if (displayMode === DisplayMode.TABLE) {
<table content class="table table-hover mb-0 mt-n2 align-middle">
<table class="table table-hover mb-0 mt-n2 align-middle">
<thead>
<tr>
@for (field of displayFields; track field; let i = $index) {
@ -28,71 +29,59 @@
</tr>
</thead>
<tbody>
@if (loading) {
@for (i of [].constructor(pageSize); track i) {
<tr>
@for (field of displayFields; track field; let j = $index) {
<td class="py-2 py-md" [ngClass]="{ 'd-none d-md-table-cell': j > 1 }">
<div class="placeholder-glow text-start py-2">
<span class="placeholder bg-secondary w-50"></span>
</div>
</td>
}
</tr>
}
}
@for (doc of documents; track doc.id) {
@for (doc of documents; track doc.id; let i = $index) {
<tr>
@for (field of displayFields; track field; let i = $index) {
<td class="py-2 py-md-3 position-relative" [ngClass]="{ 'd-none d-md-table-cell': i > 1 }">
@switch (field) {
@case (DisplayField.ADDED) {
<a routerLink="/documents/{{doc.id}}" class="btn-link text-dark text-decoration-none py-2 py-md-3" title="Open document" i18n-title>{{doc.added | customDate}}</a>
}
@case (DisplayField.CREATED) {
<a routerLink="/documents/{{doc.id}}" class="btn-link text-dark text-decoration-none py-2 py-md-3" title="Open document" i18n-title>{{doc.created_date | customDate}}</a>
}
@case (DisplayField.TITLE) {
<a routerLink="/documents/{{doc.id}}" title="Open document" i18n-title class="btn-link text-dark text-decoration-none py-2 py-md-3">{{doc.title | documentTitle}}</a>
}
@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>
}
}
@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>
}
}
@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>
}
}
@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>
}
}
}
@if (field.startsWith(DisplayField.CUSTOM_FIELD)) {
<pngx-custom-field-display [document]="doc" [fieldDisplayKey]="field"></pngx-custom-field-display>
}
@if (i === displayFields.length - 1) {
<div class="btn-group position-absolute top-50 end-0 translate-middle-y">
<a [href]="getPreviewUrl(doc)" title="View Preview" i18n-title target="_blank" class="btn px-4 btn-dark border-dark-subtle"
[ngbPopover]="previewContent" [popoverTitle]="doc.title | documentTitle"
autoClose="true" popoverClass="shadow popover-preview" container="body" (mouseenter)="mouseEnterPreviewButton(doc)" (mouseleave)="mouseLeavePreviewButton()" #popover="ngbPopover">
<i-bs width="0.8em" height="0.8em" name="eye"></i-bs>
</a>
<ng-template #previewContent>
<pngx-preview-popup [document]="doc" (mouseenter)="mouseEnterPreview()" (mouseleave)="mouseLeavePreview()"></pngx-preview-popup>
</ng-template>
<a [href]="getDownloadUrl(doc)" class="btn px-4 btn-dark border-dark-subtle" title="Download" i18n-title (click)="$event.stopPropagation()">
<i-bs width="0.8em" height="0.8em" name="download"></i-bs>
</a>
@for (field of displayFields; track field; let j = $index) {
<td class="py-2 py-md-3 position-relative" [ngClass]="{ 'd-none d-md-table-cell': j > 1 }">
@if (loading && reveal) {
<div class="placeholder-glow text-start">
<span class="placeholder bg-secondary w-50" [ngStyle]="{ opacity: 1 - (i * 1/documents.length) }"></span>
</div>
} @else {
@switch (field) {
@case (DisplayField.ADDED) {
<a routerLink="/documents/{{doc.id}}" class="btn-link text-dark text-decoration-none py-2 py-md-3" title="Open document" i18n-title>{{doc.added | customDate}}</a>
}
@case (DisplayField.CREATED) {
<a routerLink="/documents/{{doc.id}}" class="btn-link text-dark text-decoration-none py-2 py-md-3" title="Open document" i18n-title>{{doc.created_date | customDate}}</a>
}
@case (DisplayField.TITLE) {
<a routerLink="/documents/{{doc.id}}" title="Open document" i18n-title class="btn-link text-dark text-decoration-none py-2 py-md-3">{{doc.title | documentTitle}}</a>
}
@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>
}
}
@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>
}
}
@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>
}
}
@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>
}
}
}
@if (field.startsWith(DisplayField.CUSTOM_FIELD)) {
<pngx-custom-field-display [document]="doc" [fieldDisplayKey]="field"></pngx-custom-field-display>
}
@if (j === displayFields.length - 1) {
<div class="btn-group position-absolute top-50 end-0 translate-middle-y" (mouseleave)="popupPreview.close()">
<pngx-preview-popup [document]="doc" linkClasses="btn px-4 btn-dark border-dark-subtle" #popupPreview>
<i-bs width="0.8em" height="0.8em" name="eye"></i-bs>
</pngx-preview-popup>
<a [href]="getDownloadUrl(doc)" class="btn px-4 btn-dark border-dark-subtle" title="Download" i18n-title (click)="$event.stopPropagation()">
<i-bs width="0.8em" height="0.8em" name="download"></i-bs>
</a>
</div>
}
}
</td>
}
@ -101,17 +90,13 @@
</tbody>
</table>
} @else if (displayMode === DisplayMode.SMALL_CARDS) {
<div content class="row row-cols-paperless-cards my-n2">
@if (loading) {
@for (i of [].constructor(pageSize); track i) {
<pngx-document-card-small class="p-0"></pngx-document-card-small>
}
}
@for (d of documents; track d.id) {
<div class="row row-cols-paperless-cards my-n2">
@for (d of documents; track d.id; let i = $index) {
<pngx-document-card-small
class="p-0"
[ngStyle]="{ opacity: !loading && reveal ? 1 : 1 - (i * 1/documents.length) }"
(dblClickDocument)="openDocumentDetail(d)"
[document]="d"
[document]="!loading && reveal ? d : null"
[displayFields]="displayFields"
(clickTag)="clickTag($event)"
(clickCorrespondent)="clickCorrespondent($event)"
@ -121,16 +106,12 @@
}
</div>
} @else if (displayMode === DisplayMode.LARGE_CARDS) {
<div content class="row my-n2">
@if (loading) {
@for (i of [].constructor(pageSize); track i) {
<pngx-document-card-large></pngx-document-card-large>
}
}
@for (d of documents; track d.id) {
<div class="row my-n2">
@for (d of documents; track d.id; let i = $index) {
<pngx-document-card-large
(dblClickDocument)="openDocumentDetail(d)"
[document]="d"
[document]="!loading && reveal ? d : null"
[ngStyle]="{ opacity: !loading && reveal ? 1 : 1 - (i * 1/documents.length) }"
[displayFields]="displayFields"
(clickTag)="clickTag($event)"
(clickCorrespondent)="clickCorrespondent($event)"
@ -141,8 +122,8 @@
}
</div>
} @else {
<p content i18n class="text-center text-muted mb-0 fst-italic">No documents</p>
<p i18n class="text-center text-muted mb-0 fst-italic">No documents</p>
}
</div>
</pngx-widget-frame>

View File

@ -1,3 +1,17 @@
.wrapper {
transition: all .3s ease-out;
overflow: hidden;
max-height: 0;
opacity: .1;
width: 100%;
}
.reveal {
max-height: 1000px;
opacity: 1;
overflow: visible;
}
table {
overflow-wrap: anywhere;
table-layout: fixed;

View File

@ -187,7 +187,7 @@ describe('SavedViewWidgetComponent', () => {
fixture.detectChanges()
})
it('should show a list of documents', () => {
it('should show a list of documents', fakeAsync(() => {
jest.spyOn(documentService, 'listFiltered').mockReturnValue(
of({
all: [2, 3],
@ -196,43 +196,17 @@ describe('SavedViewWidgetComponent', () => {
})
)
component.ngOnInit()
tick(500)
fixture.detectChanges()
expect(fixture.debugElement.nativeElement.textContent).toContain('doc2')
expect(fixture.debugElement.nativeElement.textContent).toContain('doc3')
// preview + download buttons
expect(
fixture.debugElement.queryAll(By.css('td a.btn'))[0].attributes['href']
).toEqual(component.getPreviewUrl(documentResults[0]))
).toEqual(documentService.getPreviewUrl(documentResults[0].id))
expect(
fixture.debugElement.queryAll(By.css('td a.btn'))[1].attributes['href']
).toEqual(component.getDownloadUrl(documentResults[0]))
})
it('should show preview on mouseover after delay to preload content', fakeAsync(() => {
jest.spyOn(documentService, 'listFiltered').mockReturnValue(
of({
all: [2, 3],
count: 2,
results: documentResults,
})
)
component.ngOnInit()
fixture.detectChanges()
component.mouseEnterPreviewButton(documentResults[0])
expect(component.popover.isOpen()).toBeTruthy()
expect(component.popoverHidden).toBeTruthy()
tick(600)
expect(component.popoverHidden).toBeFalsy()
component.maybeClosePopover()
component.mouseEnterPreviewButton(documentResults[1])
tick(100)
component.mouseLeavePreviewButton()
component.mouseEnterPreview()
expect(component.popover.isOpen()).toBeTruthy()
component.mouseLeavePreview()
tick(600)
expect(component.popover.isOpen()).toBeFalsy()
}))
it('should call api endpoint and load results', () => {

View File

@ -7,7 +7,7 @@ import {
ViewChildren,
} from '@angular/core'
import { Router } from '@angular/router'
import { Subject, takeUntil } from 'rxjs'
import { delay, Subject, takeUntil, tap } from 'rxjs'
import {
DEFAULT_DASHBOARD_DISPLAY_FIELDS,
DEFAULT_DASHBOARD_VIEW_PAGE_SIZE,
@ -52,7 +52,8 @@ export class SavedViewWidgetComponent
public DisplayField = DisplayField
public CustomFieldDataType = CustomFieldDataType
loading: boolean = true
public loading: boolean = true
public reveal: boolean = false
private customFields: CustomField[] = []
@ -137,16 +138,22 @@ export class SavedViewWidgetComponent
this.documentService
.listFiltered(
1,
this.pageSize,
this.savedView?.page_size ?? DEFAULT_DASHBOARD_VIEW_PAGE_SIZE,
this.savedView.sort_field,
this.savedView.sort_reverse,
this.savedView.filter_rules,
{ truncate_content: true }
)
.pipe(takeUntil(this.unsubscribeNotifier))
.pipe(
takeUntil(this.unsubscribeNotifier),
tap((result) => {
this.reveal = true
this.documents = result.results
}),
delay(500)
)
.subscribe((result) => {
this.loading = false
this.documents = result.results
})
}
@ -206,55 +213,10 @@ export class SavedViewWidgetComponent
this.router.navigate(['documents', document.id])
}
getPreviewUrl(document: Document): string {
return this.documentService.getPreviewUrl(document.id)
}
getDownloadUrl(document: Document): string {
return this.documentService.getDownloadUrl(document.id)
}
mouseEnterPreviewButton(doc: Document) {
const newPopover = this.popovers.get(this.documents.indexOf(doc))
if (this.popover !== newPopover && this.popover?.isOpen())
this.popover.close()
this.popover = newPopover
this.mouseOnPreview = true
if (!this.popover.isOpen()) {
// we're going to open but hide to pre-load content during hover delay
this.popover.open()
this.popoverHidden = true
setTimeout(() => {
if (this.mouseOnPreview) {
// show popover
this.popoverHidden = false
} else {
this.popover.close()
}
}, 600)
}
}
mouseEnterPreview() {
this.mouseOnPreview = true
}
mouseLeavePreview() {
this.mouseOnPreview = false
this.maybeClosePopover()
}
mouseLeavePreviewButton() {
this.mouseOnPreview = false
this.maybeClosePopover()
}
maybeClosePopover() {
setTimeout(() => {
if (!this.mouseOnPreview) this.popover?.close()
}, 300)
}
public getColumnTitle(field: DisplayField): string {
if (field.startsWith(DisplayField.CUSTOM_FIELD)) {
const id = field.split('_')[2]

View File

@ -1,6 +1,6 @@
<pngx-widget-frame title="Statistics" [loading]="loading" i18n-title>
<ng-container content>
<div class="list-group border-light">
<div class="list-group border-light placeholder-glow">
@if (loading) {
<div class="list-group-item d-flex">
<div class="placeholder w-50"></div>
@ -18,7 +18,7 @@
<div class="placeholder w-25"></div>
<span class="placeholder badge rounded-pill ms-auto" style="width: 25px;">&nbsp;</span>
</div>
<div class="list-group-item filetypes placeholder-glow">
<div class="list-group-item filetypes">
<div class="placeholder w-100 d-block mb-2"></div>
<div class="placeholder w-100 d-block mb-2"></div>
<div class="placeholder w-100 d-block"></div>

View File

@ -1,4 +1,4 @@
<div class="card shadow-sm bg-light" cdkDrag [cdkDragDisabled]="!draggable" cdkDragPreviewContainer="parent">
<div class="card shadow-sm bg-light fade" [class.reveal]="reveal" cdkDrag [cdkDragDisabled]="!draggable" cdkDragPreviewContainer="parent">
<div class="card-header">
<div class="d-flex justify-content-between align-items-center">
<div class="d-flex">

View File

@ -1,3 +1,12 @@
i-bs {
cursor: move;
}
.card {
opacity: 0;
transition: opacity .2s;
}
.reveal {
opacity: 1;
}

View File

@ -35,6 +35,7 @@ describe('WidgetFrameComponent', () => {
fixture = TestBed.createComponent(WidgetFrameComponent)
component = fixture.componentInstance
jest.useFakeTimers()
fixture.detectChanges()
})
@ -51,4 +52,10 @@ describe('WidgetFrameComponent', () => {
fixture.detectChanges()
expect(fixture.debugElement.query(By.css('.spinner-border'))).not.toBeNull()
})
it('should reveal', () => {
expect(component.reveal).toBeFalsy()
jest.advanceTimersByTime(100)
expect(component.reveal).toBeTruthy()
})
})

View File

@ -1,11 +1,11 @@
import { Component, Input } from '@angular/core'
import { AfterViewInit, Component, Input } from '@angular/core'
@Component({
selector: 'pngx-widget-frame',
templateUrl: './widget-frame.component.html',
styleUrls: ['./widget-frame.component.scss'],
})
export class WidgetFrameComponent {
export class WidgetFrameComponent implements AfterViewInit {
constructor() {}
@Input()
@ -16,4 +16,12 @@ export class WidgetFrameComponent {
@Input()
draggable: any
public reveal: boolean = false
ngAfterViewInit(): void {
setTimeout(() => {
this.reveal = true
}, 100)
}
}

View File

@ -1,4 +1,4 @@
<div class="card mb-3 shadow-sm bg-light placeholder-glow" [class.card-selected]="selected" [class.document-card]="selectable" (mouseleave)="mouseLeaveCard()">
<div class="card document-card-large mb-3 shadow-sm bg-light placeholder-glow" [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) {

View File

@ -120,4 +120,12 @@ describe('DocumentCardLargeComponent', () => {
expect(fixture.nativeElement.textContent).toContain('bananas')
expect(component.searchNoteHighlights).toContain('<span>bananas</span>')
})
it('should try to close the preview on mouse leave', () => {
component.popupPreview = {
close: jest.fn(),
} as any
component.mouseLeaveCard()
expect(component.popupPreview.close).toHaveBeenCalled()
})
})

View File

@ -108,12 +108,8 @@ export class DocumentCardLargeComponent extends ComponentWithPermissions {
return this.documentService.getDownloadUrl(this.document.id)
}
get previewUrl() {
return this.documentService.getPreviewUrl(this.document.id)
}
mouseLeaveCard() {
this.popupPreview.close()
this.popupPreview?.close()
}
get contentTrimmed() {

View File

@ -1,5 +1,5 @@
<div class="col p-2 h-100 placeholder-glow">
<div class="card h-100 shadow-sm document-card" [class.card-selected]="selected" (mouseleave)="mouseLeaveCard()">
<div class="col p-2 h-100">
<div class="card h-100 shadow-sm document-card" [class.placeholder-glow]="!document" [class.card-selected]="selected" (mouseleave)="mouseLeaveCard()">
<div class="border-bottom doc-img-container rounded-top" (click)="this.toggleSelected.emit($event)" (dblclick)="dblClickDocument.emit(this)">
@if (document) {
<img class="card-img doc-img" [class.inverted]="getIsThumbInverted()" [src]="getThumbUrl()">
@ -14,7 +14,7 @@
<div class="placeholder bg-secondary w-100 card-img doc-img"></div>
}
@if (displayFields?.includes(DisplayField.TAGS) && document) {
@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>
@ -38,12 +38,17 @@
<div class="card-body bg-light p-2">
<p class="card-text">
@if (document && 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>
@if (displayFields.includes(DisplayField.TITLE)) {:}
}
@if (document && displayFields.includes(DisplayField.TITLE)) {
{{document.title | documentTitle}}
@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>
@if (displayFields.includes(DisplayField.TITLE)) {:}
}
@if (displayFields.includes(DisplayField.TITLE)) {
{{document.title | documentTitle}}
}
} @else {
<div class="placeholder bg-secondary w-100"></div>
<div class="placeholder bg-secondary w-50"></div>
}
@if (!document) {
<div class="placeholder bg-secondary w-100"></div>

View File

@ -111,4 +111,12 @@ describe('DocumentCardSmallComponent', () => {
fixture.debugElement.queryAll(By.directive(TagComponent))
).toHaveLength(6)
})
it('should try to close the preview on mouse leave', () => {
component.popupPreview = {
close: jest.fn(),
} as any
component.mouseLeaveCard()
expect(component.popupPreview.close).toHaveBeenCalled()
})
})

View File

@ -94,7 +94,7 @@ export class DocumentCardSmallComponent extends ComponentWithPermissions {
}
mouseLeaveCard() {
this.popupPreview.close()
this.popupPreview?.close()
}
get notesEnabled(): boolean {

View File

@ -12,7 +12,6 @@ import { CorrespondentService } from 'src/app/services/rest/correspondent.servic
import { ToastService } from 'src/app/services/toast.service'
import { CorrespondentEditDialogComponent } from '../../common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component'
import { ManagementListComponent } from '../management-list/management-list.component'
import { takeUntil } from 'rxjs'
@Component({
selector: 'pngx-correspondent-list',
@ -65,24 +64,7 @@ export class CorrespondentListComponent extends ManagementListComponent<Correspo
}
public reloadData(): void {
this.isLoading = true
this.clearSelection()
this.service
.listFiltered(
this.page,
null,
this.sortField,
this.sortReverse,
this._nameFilter,
true,
{ last_correspondence: true }
)
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe((c) => {
this.data = c.results
this.collectionSize = c.count
this.isLoading = false
})
super.reloadData({ last_correspondence: true })
}
getDeleteMessage(object: Correspondent) {

View File

@ -13,16 +13,23 @@
<ul class="list-group">
<li class="list-group-item">
<div class="row">
<div class="row reveal">
<div class="col" i18n>Name</div>
<div class="col" i18n>Data Type</div>
<div class="col" i18n>Actions</div>
</div>
</li>
@if (loading) {
<li class="list-group-item">
<div class="spinner-border spinner-border-sm me-2" role="status"></div>
<ng-container i18n>Loading...</ng-container>
</li>
}
@for (field of fields; track field) {
<li class="list-group-item">
<div class="row">
<div class="row" [class.reveal]="reveal">
<div class="col d-flex align-items-center"><button class="btn btn-link p-0 text-start" type="button" (click)="editField(field)" [disabled]="!permissionsService.currentUserCan(PermissionAction.Change, PermissionType.CustomField)">{{field.name}}</button></div>
<div class="col d-flex align-items-center">{{getDataType(field)}}</div>
<div class="col">
@ -59,7 +66,7 @@
</div>
</li>
}
@if (fields.length === 0) {
@if (!loading && fields.length === 0) {
<li class="list-group-item" i18n>No fields defined.</li>
}
</ul>

View File

@ -2,3 +2,12 @@
.d-block.d-sm-none .dropdown-toggle::after {
display: none;
}
.list-group-item .row {
opacity: 0;
transition: opacity .2s;
}
.list-group-item .reveal {
opacity: 1;
}

View File

@ -95,6 +95,8 @@ describe('CustomFieldsComponent', () => {
fixture = TestBed.createComponent(CustomFieldsComponent)
component = fixture.componentInstance
fixture.detectChanges()
jest.useFakeTimers()
jest.advanceTimersByTime(100)
})
it('should support create, show notification on error / success', () => {
@ -119,6 +121,7 @@ describe('CustomFieldsComponent', () => {
editDialog.succeeded.emit(fields[0])
expect(toastInfoSpy).toHaveBeenCalled()
expect(reloadSpy).toHaveBeenCalled()
jest.advanceTimersByTime(100)
})
it('should support edit, show notification on error / success', () => {

View File

@ -1,6 +1,6 @@
import { Component, OnInit } from '@angular/core'
import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
import { Subject, takeUntil } from 'rxjs'
import { delay, Subject, takeUntil, tap } from 'rxjs'
import { DATA_TYPE_LABELS, CustomField } from 'src/app/data/custom-field'
import { PermissionsService } from 'src/app/services/permissions.service'
import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
@ -28,6 +28,9 @@ export class CustomFieldsComponent
{
public fields: CustomField[] = []
public loading: boolean = true
public reveal: boolean = false
private unsubscribeNotifier: Subject<any> = new Subject()
constructor(
private customFieldsService: CustomFieldsService,
@ -47,9 +50,16 @@ export class CustomFieldsComponent
reload() {
this.customFieldsService
.listAll()
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe((r) => {
this.fields = r.results
.pipe(
takeUntil(this.unsubscribeNotifier),
tap((r) => {
this.fields = r.results
}),
delay(100)
)
.subscribe(() => {
this.reveal = true
this.loading = false
})
}

View File

@ -26,7 +26,7 @@
</h4>
<ul class="list-group">
<li class="list-group-item">
<div class="row">
<div class="row reveal">
<div class="col" i18n>Name</div>
<div class="col" i18n>Server</div>
<div class="col d-none d-sm-block" i18n>Username</div>
@ -34,9 +34,16 @@
</div>
</li>
@if (loadingAccounts) {
<li class="list-group-item">
<div class="spinner-border spinner-border-sm me-2" role="status"></div>
<ng-container i18n>Loading...</ng-container>
</li>
}
@for (account of mailAccounts; track account) {
<li class="list-group-item">
<div class="row">
<div class="row" [class.reveal]="revealAccounts">
<div class="col d-flex align-items-center">
<button class="btn btn-link p-0 text-start" type="button" (click)="editMailAccount(account)" [disabled]="!permissionsService.currentUserCan(PermissionAction.Change, PermissionType.MailAccount)">
{{account.name}}@switch (account.account_type) {
@ -76,7 +83,7 @@
</div>
</li>
}
@if (mailAccounts.length === 0) {
@if (!loadingAccounts && mailAccounts.length === 0) {
<li class="list-group-item" i18n>No mail accounts defined.</li>
}
</ul>
@ -92,7 +99,7 @@
</h4>
<ul class="list-group">
<li class="list-group-item">
<div class="row">
<div class="row reveal">
<div class="col" i18n>Name</div>
<div class="col d-none d-sm-block" i18n>Sort Order</div>
<div class="col" i18n>Account</div>
@ -101,9 +108,16 @@
</div>
</li>
@if (loadingRules) {
<li class="list-group-item">
<div class="spinner-border spinner-border-sm me-2" role="status"></div>
<ng-container i18n>Loading...</ng-container>
</li>
}
@for (rule of mailRules; track rule) {
<li class="list-group-item">
<div class="row">
<div class="row" [class.reveal]="revealRules">
<div class="col d-flex align-items-center"><button class="btn btn-link p-0 text-start" type="button" (click)="editMailRule(rule)" [disabled]="!permissionsService.currentUserCan(PermissionAction.Change, PermissionType.MailRule)">{{rule.name}}</button></div>
<div class="col d-flex align-items-center d-none d-sm-flex">{{rule.order}}</div>
<div class="col d-flex align-items-center">{{(mailAccountService.getCached(rule.account) | async)?.name}}</div>
@ -151,7 +165,7 @@
</div>
</li>
}
@if (mailRules.length === 0) {
@if (!loadingRules && mailRules.length === 0) {
<li class="list-group-item" i18n>No mail rules defined.</li>
}
</ul>

View File

@ -2,3 +2,12 @@
.d-block.d-sm-none .dropdown-toggle::after {
display: none;
}
.list-group-item .row {
opacity: 0;
transition: opacity .2s;
}
.list-group-item .reveal {
opacity: 1;
}

View File

@ -129,6 +129,8 @@ describe('MailComponent', () => {
fixture = TestBed.createComponent(MailComponent)
component = fixture.componentInstance
fixture.detectChanges()
jest.useFakeTimers()
jest.advanceTimersByTime(100)
})
function completeSetup(excludeService = null) {
@ -386,6 +388,7 @@ describe('MailComponent', () => {
component.oAuthAccountId = 3
const editSpy = jest.spyOn(component, 'editMailAccount')
component.ngOnInit()
jest.advanceTimersByTime(200)
expect(editSpy).toHaveBeenCalled()
})
})

View File

@ -1,6 +1,6 @@
import { Component, OnInit, OnDestroy } from '@angular/core'
import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
import { Subject, first, takeUntil } from 'rxjs'
import { Subject, delay, first, takeUntil, tap } from 'rxjs'
import { ObjectWithPermissions } from 'src/app/data/object-with-permissions'
import { MailAccount, MailAccountType } from 'src/app/data/mail-account'
import { MailRule } from 'src/app/data/mail-rule'
@ -47,6 +47,11 @@ export class MailComponent
return this.settingsService.get(SETTINGS_KEYS.OUTLOOK_OAUTH_URL)
}
public loadingRules: boolean = true
public revealRules: boolean = false
public loadingAccounts: boolean = true
public revealAccounts: boolean = false
constructor(
public mailAccountService: MailAccountService,
public mailRuleService: MailRuleService,
@ -62,9 +67,10 @@ export class MailComponent
ngOnInit(): void {
this.mailAccountService
.listAll(null, null, { full_perms: true })
.pipe(first(), takeUntil(this.unsubscribeNotifier))
.subscribe({
next: (r) => {
.pipe(
first(),
takeUntil(this.unsubscribeNotifier),
tap((r) => {
this.mailAccounts = r.results
if (this.oAuthAccountId) {
this.editMailAccount(
@ -73,6 +79,13 @@ export class MailComponent
)
)
}
}),
delay(100)
)
.subscribe({
next: () => {
this.loadingAccounts = false
this.revealAccounts = true
},
error: (e) => {
this.toastService.showError(
@ -84,10 +97,18 @@ export class MailComponent
this.mailRuleService
.listAll(null, null, { full_perms: true })
.pipe(first(), takeUntil(this.unsubscribeNotifier))
.pipe(
first(),
takeUntil(this.unsubscribeNotifier),
tap((r) => {
this.mailRules = r.results
}),
delay(100)
)
.subscribe({
next: (r) => {
this.mailRules = r.results
this.loadingRules = false
this.revealRules = true
},
error: (e) => {
this.toastService.showError($localize`Error retrieving mail rules`, e)

View File

@ -53,7 +53,7 @@
</tr>
}
@for (object of data; track object) {
<tr (click)="toggleSelected(object); $event.stopPropagation();">
<tr (click)="toggleSelected(object); $event.stopPropagation();" class="data-row" [class.reveal]="reveal">
<td>
<div class="form-check m-0 ms-2 me-n2">
<input type="checkbox" class="form-check-input" id="{{typeName}}{{object.id}}" [checked]="selectedObjects.has(object.id)" (click)="toggleSelected(object); $event.stopPropagation();">

View File

@ -10,3 +10,12 @@ tbody tr:last-child td {
.form-check {
min-height: 0;
}
.data-row {
opacity: 0;
transition: opacity .2s;
}
.reveal {
opacity: 1;
}

View File

@ -150,6 +150,7 @@ describe('ManagementListComponent', () => {
fixture.detectChanges()
expect(component.nameFilter).toBeNull()
expect(component.data).toEqual(tags)
tick(100) // load
}))
it('should support create, show notification on error / success', () => {

View File

@ -7,7 +7,13 @@ import {
} from '@angular/core'
import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
import { Subject } from 'rxjs'
import { debounceTime, distinctUntilChanged, takeUntil } from 'rxjs/operators'
import {
debounceTime,
delay,
distinctUntilChanged,
takeUntil,
tap,
} from 'rxjs/operators'
import {
MatchingModel,
MATCHING_ALGORITHMS,
@ -89,6 +95,8 @@ export abstract class ManagementListComponent<T extends ObjectWithId>
public selectedObjects: Set<number> = new Set()
public togggleAll: boolean = false
public reveal: boolean = false
ngOnInit(): void {
this.reloadData()
@ -132,7 +140,7 @@ export abstract class ManagementListComponent<T extends ObjectWithId>
this.reloadData()
}
reloadData() {
reloadData(extraParams: { [key: string]: any } = null) {
this.isLoading = true
this.clearSelection()
this.service
@ -142,12 +150,19 @@ export abstract class ManagementListComponent<T extends ObjectWithId>
this.sortField,
this.sortReverse,
this._nameFilter,
true
true,
extraParams
)
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe((c) => {
this.data = c.results
this.collectionSize = c.count
.pipe(
takeUntil(this.unsubscribeNotifier),
tap((c) => {
this.data = c.results
this.collectionSize = c.count
}),
delay(100)
)
.subscribe(() => {
this.reveal = true
this.isLoading = false
})
}

View File

@ -13,7 +13,7 @@
<ul class="list-group">
<li class="list-group-item">
<div class="row">
<div class="row reveal">
<div class="col" i18n>Name</div>
<div class="col d-none d-sm-flex" i18n>Sort order</div>
<div class="col" i18n>Status</div>
@ -22,9 +22,16 @@
</div>
</li>
@if (loading) {
<li class="list-group-item">
<div class="spinner-border spinner-border-sm me-2" role="status"></div>
<ng-container i18n>Loading...</ng-container>
</li>
}
@for (workflow of workflows; track workflow.id) {
<li class="list-group-item">
<div class="row">
<div class="row" [class.reveal]="reveal">
<div class="col d-flex align-items-center"><button class="btn btn-link p-0 text-start" type="button" (click)="editWorkflow(workflow)" [disabled]="!permissionsService.currentUserCan(PermissionAction.Change, PermissionType.Workflow)">{{workflow.name}}</button></div>
<div class="col d-flex align-items-center d-none d-sm-flex"><code>{{workflow.order}}</code></div>
<div class="col d-flex align-items-center">
@ -69,7 +76,7 @@
</div>
</li>
}
@if (workflows.length === 0) {
<li class="list-group-item" i18n>No workflows defined.</li>
@if (!loading && workflows.length === 0) {
<li class="list-group-item" [class.reveal]="reveal" i18n>No workflows defined.</li>
}
</ul>

View File

@ -2,3 +2,12 @@
.d-block.d-sm-none .dropdown-toggle::after {
display: none;
}
.list-group-item .row {
opacity: 0;
transition: opacity .2s;
}
.list-group-item .reveal {
opacity: 1;
}

View File

@ -119,10 +119,11 @@ describe('WorkflowsComponent', () => {
)
modalService = TestBed.inject(NgbModal)
toastService = TestBed.inject(ToastService)
jest.useFakeTimers()
fixture = TestBed.createComponent(WorkflowsComponent)
component = fixture.componentInstance
fixture.detectChanges()
jest.advanceTimersByTime(100)
})
it('should support create, show notification on error / success', () => {

View File

@ -1,7 +1,7 @@
import { Component, OnInit } from '@angular/core'
import { WorkflowService } from 'src/app/services/rest/workflow.service'
import { ComponentWithPermissions } from '../../with-permissions/with-permissions.component'
import { Subject, takeUntil } from 'rxjs'
import { delay, Subject, takeUntil, tap } from 'rxjs'
import { Workflow } from 'src/app/data/workflow'
import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
import { ToastService } from 'src/app/services/toast.service'
@ -26,6 +26,9 @@ export class WorkflowsComponent
private unsubscribeNotifier: Subject<any> = new Subject()
public loading: boolean = false
public reveal: boolean = false
constructor(
private workflowService: WorkflowService,
public permissionsService: PermissionsService,
@ -40,11 +43,17 @@ export class WorkflowsComponent
}
reload() {
this.loading = true
this.workflowService
.listAll()
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe((r) => {
this.workflows = r.results
.pipe(
takeUntil(this.unsubscribeNotifier),
tap((r) => (this.workflows = r.results)),
delay(100)
)
.subscribe(() => {
this.reveal = true
this.loading = false
})
}

View File

@ -1,8 +1,10 @@
import {
BRIGHTNESS,
componentToHex,
computeLuminance,
estimateBrightnessForColor,
hexToHsl,
hslToRgb,
randomColor,
rgbToHsl,
} from './color'
@ -44,7 +46,21 @@ describe('Color Utils', () => {
expect(hsl).toEqual([0, 0, 0.5019607843137255])
})
it('should convert hsl to rgb', () => {
let rgb = hslToRgb(0, 0, 0.5)
expect(rgb).toEqual([127.5, 127.5, 127.5])
expect(hslToRgb(0, 0, 0)).toEqual([0, 0, 0])
expect(hslToRgb(0, 0, 1)).toEqual([255, 255, 255])
})
it('should return a random color', () => {
expect(randomColor()).not.toBeNull()
})
it('should convert component to hex', () => {
expect(componentToHex(0)).toEqual('00')
expect(componentToHex(255)).toEqual('ff')
expect(componentToHex(128)).toEqual('80')
expect(componentToHex(15)).toEqual('0f')
})
})

View File

@ -5,7 +5,7 @@ export const BRIGHTNESS = {
DARK: 'dark',
}
function componentToHex(c) {
export function componentToHex(c) {
var hex = Math.floor(c).toString(16)
return hex.length == 1 ? '0' + hex : hex
}
@ -23,21 +23,22 @@ function componentToHex(c) {
* @param Number l The lightness
* @return Array The RGB representation
*/
function hslToRgb(h, s, l) {
function hue2rgb(p, q, t) {
if (t < 0) t += 1
if (t > 1) t -= 1
if (t < 1 / 6) return p + (q - p) * 6 * t
if (t < 1 / 2) return q
if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6
return p
}
export function hslToRgb(h, s, l) {
var r, g, b
if (s == 0) {
r = g = b = l // achromatic
} else {
function hue2rgb(p, q, t) {
if (t < 0) t += 1
if (t > 1) t -= 1
if (t < 1 / 6) return p + (q - p) * 6 * t
if (t < 1 / 2) return q
if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6
return p
}
var q = l < 0.5 ? l * (1 + s) : l + s - l * s
var p = 2 * l - q
r = hue2rgb(p, q, h + 1 / 3)

View File

@ -203,19 +203,23 @@ $form-check-radio-checked-bg-image-dark: url("data:image/svg+xml,<svg xmlns='htt
@supports (hanging-punctuation: first) and (font: -apple-system-body) and (-webkit-appearance: none) {
// Safari does not like the filters on the image, see https://github.com/paperless-ngx/paperless-ngx/pull/8121
.doc-img-container {
background-color: #ffffff !important;
}
.document-card:not(.placeholder-glow),
.document-card-large:not(.placeholder-glow) {
.doc-img-container {
background-color: #ffffff !important;
width: 101%;
}
.doc-img {
filter: none !important;
box-shadow: inset 0px 0px 0px 10px rgba(0,0,0,1);
}
.doc-img {
filter: none !important;
box-shadow: inset 0px 0px 0px 10px rgba(0,0,0,1);
}
.doc-img.inverted {
filter: none !important;
mix-blend-mode: difference;
opacity: 0.95;
.doc-img.inverted {
filter: none !important;
mix-blend-mode: difference;
opacity: 0.95;
}
}
}