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> </tr>
} }
@for (document of documentsInTrash; track document.id) { @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> <td>
<div class="form-check m-0 ms-2 me-n2"> <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();"> <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', () => { it('should call correct service method on reload', () => {
jest.useFakeTimers()
const trashSpy = jest.spyOn(trashService, 'getTrash') const trashSpy = jest.spyOn(trashService, 'getTrash')
trashSpy.mockReturnValue( trashSpy.mockReturnValue(
of({ of({
@ -78,6 +79,7 @@ describe('TrashComponent', () => {
}) })
) )
component.reload() component.reload()
jest.advanceTimersByTime(100)
expect(trashSpy).toHaveBeenCalled() expect(trashSpy).toHaveBeenCalled()
expect(component.documentsInTrash).toEqual(documentsInTrash) 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 { ToastService } from 'src/app/services/toast.service'
import { TrashService } from 'src/app/services/trash.service' import { TrashService } from 'src/app/services/trash.service'
import { ConfirmDialogComponent } from '../../common/confirm-dialog/confirm-dialog.component' 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 { SettingsService } from 'src/app/services/settings.service'
import { SETTINGS_KEYS } from 'src/app/data/ui-settings' import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
import { Router } from '@angular/router' import { Router } from '@angular/router'
@ -21,6 +21,7 @@ export class TrashComponent implements OnDestroy {
public page: number = 1 public page: number = 1
public totalDocuments: number public totalDocuments: number
public isLoading: boolean = false public isLoading: boolean = false
public reveal: boolean = false
unsubscribeNotifier: Subject<void> = new Subject() unsubscribeNotifier: Subject<void> = new Subject()
constructor( constructor(
@ -40,12 +41,20 @@ export class TrashComponent implements OnDestroy {
reload() { reload() {
this.isLoading = true this.isLoading = true
this.trashService.getTrash(this.page).subscribe((r) => { this.trashService
this.documentsInTrash = r.results .getTrash(this.page)
this.totalDocuments = r.count .pipe(
this.isLoading = false tap((r) => {
this.selectedDocuments.clear() this.documentsInTrash = r.results
}) this.totalDocuments = r.count
this.selectedDocuments.clear()
}),
delay(100)
)
.subscribe(() => {
this.reveal = true
this.isLoading = false
})
} }
delete(document: Document) { delete(document: Document) {

View File

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

View File

@ -18,7 +18,7 @@
<div class="ms-n2 me-1"> <div class="ms-n2 me-1">
<i-bs name="grip-vertical"></i-bs> <i-bs name="grip-vertical"></i-bs>
</div> </div>
<h6 class="card-title mb-0" i18n>Loading...</h6> <h6 class="card-title mb-0"></h6>
</div> </div>
</div> </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> <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) { @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> <thead>
<tr> <tr>
@for (field of displayFields; track field; let i = $index) { @for (field of displayFields; track field; let i = $index) {
@ -28,71 +29,59 @@
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@if (loading) { @for (doc of documents; track doc.id; let i = $index) {
@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) {
<tr> <tr>
@for (field of displayFields; track field; let i = $index) { @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': i > 1 }"> <td class="py-2 py-md-3 position-relative" [ngClass]="{ 'd-none d-md-table-cell': j > 1 }">
@switch (field) { @if (loading && reveal) {
@case (DisplayField.ADDED) { <div class="placeholder-glow text-start">
<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> <span class="placeholder bg-secondary w-50" [ngStyle]="{ opacity: 1 - (i * 1/documents.length) }"></span>
}
@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>
</div> </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> </td>
} }
@ -101,17 +90,13 @@
</tbody> </tbody>
</table> </table>
} @else if (displayMode === DisplayMode.SMALL_CARDS) { } @else if (displayMode === DisplayMode.SMALL_CARDS) {
<div content class="row row-cols-paperless-cards my-n2"> <div class="row row-cols-paperless-cards my-n2">
@if (loading) { @for (d of documents; track d.id; let i = $index) {
@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) {
<pngx-document-card-small <pngx-document-card-small
class="p-0" class="p-0"
[ngStyle]="{ opacity: !loading && reveal ? 1 : 1 - (i * 1/documents.length) }"
(dblClickDocument)="openDocumentDetail(d)" (dblClickDocument)="openDocumentDetail(d)"
[document]="d" [document]="!loading && reveal ? d : null"
[displayFields]="displayFields" [displayFields]="displayFields"
(clickTag)="clickTag($event)" (clickTag)="clickTag($event)"
(clickCorrespondent)="clickCorrespondent($event)" (clickCorrespondent)="clickCorrespondent($event)"
@ -121,16 +106,12 @@
} }
</div> </div>
} @else if (displayMode === DisplayMode.LARGE_CARDS) { } @else if (displayMode === DisplayMode.LARGE_CARDS) {
<div content class="row my-n2"> <div class="row my-n2">
@if (loading) { @for (d of documents; track d.id; let i = $index) {
@for (i of [].constructor(pageSize); track i) {
<pngx-document-card-large></pngx-document-card-large>
}
}
@for (d of documents; track d.id) {
<pngx-document-card-large <pngx-document-card-large
(dblClickDocument)="openDocumentDetail(d)" (dblClickDocument)="openDocumentDetail(d)"
[document]="d" [document]="!loading && reveal ? d : null"
[ngStyle]="{ opacity: !loading && reveal ? 1 : 1 - (i * 1/documents.length) }"
[displayFields]="displayFields" [displayFields]="displayFields"
(clickTag)="clickTag($event)" (clickTag)="clickTag($event)"
(clickCorrespondent)="clickCorrespondent($event)" (clickCorrespondent)="clickCorrespondent($event)"
@ -141,8 +122,8 @@
} }
</div> </div>
} @else { } @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> </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 { table {
overflow-wrap: anywhere; overflow-wrap: anywhere;
table-layout: fixed; table-layout: fixed;

View File

@ -187,7 +187,7 @@ describe('SavedViewWidgetComponent', () => {
fixture.detectChanges() fixture.detectChanges()
}) })
it('should show a list of documents', () => { it('should show a list of documents', fakeAsync(() => {
jest.spyOn(documentService, 'listFiltered').mockReturnValue( jest.spyOn(documentService, 'listFiltered').mockReturnValue(
of({ of({
all: [2, 3], all: [2, 3],
@ -196,43 +196,17 @@ describe('SavedViewWidgetComponent', () => {
}) })
) )
component.ngOnInit() component.ngOnInit()
tick(500)
fixture.detectChanges() fixture.detectChanges()
expect(fixture.debugElement.nativeElement.textContent).toContain('doc2') expect(fixture.debugElement.nativeElement.textContent).toContain('doc2')
expect(fixture.debugElement.nativeElement.textContent).toContain('doc3') expect(fixture.debugElement.nativeElement.textContent).toContain('doc3')
// preview + download buttons // preview + download buttons
expect( expect(
fixture.debugElement.queryAll(By.css('td a.btn'))[0].attributes['href'] fixture.debugElement.queryAll(By.css('td a.btn'))[0].attributes['href']
).toEqual(component.getPreviewUrl(documentResults[0])) ).toEqual(documentService.getPreviewUrl(documentResults[0].id))
expect( expect(
fixture.debugElement.queryAll(By.css('td a.btn'))[1].attributes['href'] fixture.debugElement.queryAll(By.css('td a.btn'))[1].attributes['href']
).toEqual(component.getDownloadUrl(documentResults[0])) ).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', () => { it('should call api endpoint and load results', () => {

View File

@ -7,7 +7,7 @@ import {
ViewChildren, ViewChildren,
} from '@angular/core' } from '@angular/core'
import { Router } from '@angular/router' import { Router } from '@angular/router'
import { Subject, takeUntil } from 'rxjs' import { delay, Subject, takeUntil, tap } from 'rxjs'
import { import {
DEFAULT_DASHBOARD_DISPLAY_FIELDS, DEFAULT_DASHBOARD_DISPLAY_FIELDS,
DEFAULT_DASHBOARD_VIEW_PAGE_SIZE, DEFAULT_DASHBOARD_VIEW_PAGE_SIZE,
@ -52,7 +52,8 @@ export class SavedViewWidgetComponent
public DisplayField = DisplayField public DisplayField = DisplayField
public CustomFieldDataType = CustomFieldDataType public CustomFieldDataType = CustomFieldDataType
loading: boolean = true public loading: boolean = true
public reveal: boolean = false
private customFields: CustomField[] = [] private customFields: CustomField[] = []
@ -137,16 +138,22 @@ export class SavedViewWidgetComponent
this.documentService this.documentService
.listFiltered( .listFiltered(
1, 1,
this.pageSize, this.savedView?.page_size ?? DEFAULT_DASHBOARD_VIEW_PAGE_SIZE,
this.savedView.sort_field, this.savedView.sort_field,
this.savedView.sort_reverse, this.savedView.sort_reverse,
this.savedView.filter_rules, this.savedView.filter_rules,
{ truncate_content: true } { truncate_content: true }
) )
.pipe(takeUntil(this.unsubscribeNotifier)) .pipe(
takeUntil(this.unsubscribeNotifier),
tap((result) => {
this.reveal = true
this.documents = result.results
}),
delay(500)
)
.subscribe((result) => { .subscribe((result) => {
this.loading = false this.loading = false
this.documents = result.results
}) })
} }
@ -206,55 +213,10 @@ export class SavedViewWidgetComponent
this.router.navigate(['documents', document.id]) this.router.navigate(['documents', document.id])
} }
getPreviewUrl(document: Document): string {
return this.documentService.getPreviewUrl(document.id)
}
getDownloadUrl(document: Document): string { getDownloadUrl(document: Document): string {
return this.documentService.getDownloadUrl(document.id) 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 { public getColumnTitle(field: DisplayField): string {
if (field.startsWith(DisplayField.CUSTOM_FIELD)) { if (field.startsWith(DisplayField.CUSTOM_FIELD)) {
const id = field.split('_')[2] const id = field.split('_')[2]

View File

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

View File

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

View File

@ -35,6 +35,7 @@ describe('WidgetFrameComponent', () => {
fixture = TestBed.createComponent(WidgetFrameComponent) fixture = TestBed.createComponent(WidgetFrameComponent)
component = fixture.componentInstance component = fixture.componentInstance
jest.useFakeTimers()
fixture.detectChanges() fixture.detectChanges()
}) })
@ -51,4 +52,10 @@ describe('WidgetFrameComponent', () => {
fixture.detectChanges() fixture.detectChanges()
expect(fixture.debugElement.query(By.css('.spinner-border'))).not.toBeNull() 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({ @Component({
selector: 'pngx-widget-frame', selector: 'pngx-widget-frame',
templateUrl: './widget-frame.component.html', templateUrl: './widget-frame.component.html',
styleUrls: ['./widget-frame.component.scss'], styleUrls: ['./widget-frame.component.scss'],
}) })
export class WidgetFrameComponent { export class WidgetFrameComponent implements AfterViewInit {
constructor() {} constructor() {}
@Input() @Input()
@ -16,4 +16,12 @@ export class WidgetFrameComponent {
@Input() @Input()
draggable: any 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="row g-0">
<div class="col-md-2 doc-img-container rounded-start" (click)="this.toggleSelected.emit($event)" (dblclick)="dblClickDocument.emit()"> <div class="col-md-2 doc-img-container rounded-start" (click)="this.toggleSelected.emit($event)" (dblclick)="dblClickDocument.emit()">
@if (document) { @if (document) {

View File

@ -120,4 +120,12 @@ describe('DocumentCardLargeComponent', () => {
expect(fixture.nativeElement.textContent).toContain('bananas') expect(fixture.nativeElement.textContent).toContain('bananas')
expect(component.searchNoteHighlights).toContain('<span>bananas</span>') 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) return this.documentService.getDownloadUrl(this.document.id)
} }
get previewUrl() {
return this.documentService.getPreviewUrl(this.document.id)
}
mouseLeaveCard() { mouseLeaveCard() {
this.popupPreview.close() this.popupPreview?.close()
} }
get contentTrimmed() { get contentTrimmed() {

View File

@ -1,5 +1,5 @@
<div class="col p-2 h-100 placeholder-glow"> <div class="col p-2 h-100">
<div class="card h-100 shadow-sm document-card" [class.card-selected]="selected" (mouseleave)="mouseLeaveCard()"> <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)"> <div class="border-bottom doc-img-container rounded-top" (click)="this.toggleSelected.emit($event)" (dblclick)="dblClickDocument.emit(this)">
@if (document) { @if (document) {
<img class="card-img doc-img" [class.inverted]="getIsThumbInverted()" [src]="getThumbUrl()"> <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> <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"> <div class="tags d-flex flex-column text-end position-absolute me-1 fs-6">
@for (t of getTagsLimited$() | async; track t) { @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> <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"> <div class="card-body bg-light p-2">
<p class="card-text"> <p class="card-text">
@if (document && displayFields.includes(DisplayField.CORRESPONDENT) && document.correspondent) { @if (document) {
<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.CORRESPONDENT) && document.correspondent) {
@if (displayFields.includes(DisplayField.TITLE)) {:} <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 (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) { @if (!document) {
<div class="placeholder bg-secondary w-100"></div> <div class="placeholder bg-secondary w-100"></div>

View File

@ -111,4 +111,12 @@ describe('DocumentCardSmallComponent', () => {
fixture.debugElement.queryAll(By.directive(TagComponent)) fixture.debugElement.queryAll(By.directive(TagComponent))
).toHaveLength(6) ).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() { mouseLeaveCard() {
this.popupPreview.close() this.popupPreview?.close()
} }
get notesEnabled(): boolean { 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 { ToastService } from 'src/app/services/toast.service'
import { CorrespondentEditDialogComponent } from '../../common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component' import { CorrespondentEditDialogComponent } from '../../common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component'
import { ManagementListComponent } from '../management-list/management-list.component' import { ManagementListComponent } from '../management-list/management-list.component'
import { takeUntil } from 'rxjs'
@Component({ @Component({
selector: 'pngx-correspondent-list', selector: 'pngx-correspondent-list',
@ -65,24 +64,7 @@ export class CorrespondentListComponent extends ManagementListComponent<Correspo
} }
public reloadData(): void { public reloadData(): void {
this.isLoading = true super.reloadData({ last_correspondence: 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
})
} }
getDeleteMessage(object: Correspondent) { getDeleteMessage(object: Correspondent) {

View File

@ -13,16 +13,23 @@
<ul class="list-group"> <ul class="list-group">
<li class="list-group-item"> <li class="list-group-item">
<div class="row"> <div class="row reveal">
<div class="col" i18n>Name</div> <div class="col" i18n>Name</div>
<div class="col" i18n>Data Type</div> <div class="col" i18n>Data Type</div>
<div class="col" i18n>Actions</div> <div class="col" i18n>Actions</div>
</div> </div>
</li> </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) { @for (field of fields; track field) {
<li class="list-group-item"> <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"><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 d-flex align-items-center">{{getDataType(field)}}</div>
<div class="col"> <div class="col">
@ -59,7 +66,7 @@
</div> </div>
</li> </li>
} }
@if (fields.length === 0) { @if (!loading && fields.length === 0) {
<li class="list-group-item" i18n>No fields defined.</li> <li class="list-group-item" i18n>No fields defined.</li>
} }
</ul> </ul>

View File

@ -2,3 +2,12 @@
.d-block.d-sm-none .dropdown-toggle::after { .d-block.d-sm-none .dropdown-toggle::after {
display: none; 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) fixture = TestBed.createComponent(CustomFieldsComponent)
component = fixture.componentInstance component = fixture.componentInstance
fixture.detectChanges() fixture.detectChanges()
jest.useFakeTimers()
jest.advanceTimersByTime(100)
}) })
it('should support create, show notification on error / success', () => { it('should support create, show notification on error / success', () => {
@ -119,6 +121,7 @@ describe('CustomFieldsComponent', () => {
editDialog.succeeded.emit(fields[0]) editDialog.succeeded.emit(fields[0])
expect(toastInfoSpy).toHaveBeenCalled() expect(toastInfoSpy).toHaveBeenCalled()
expect(reloadSpy).toHaveBeenCalled() expect(reloadSpy).toHaveBeenCalled()
jest.advanceTimersByTime(100)
}) })
it('should support edit, show notification on error / success', () => { it('should support edit, show notification on error / success', () => {

View File

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

View File

@ -26,7 +26,7 @@
</h4> </h4>
<ul class="list-group"> <ul class="list-group">
<li class="list-group-item"> <li class="list-group-item">
<div class="row"> <div class="row reveal">
<div class="col" i18n>Name</div> <div class="col" i18n>Name</div>
<div class="col" i18n>Server</div> <div class="col" i18n>Server</div>
<div class="col d-none d-sm-block" i18n>Username</div> <div class="col d-none d-sm-block" i18n>Username</div>
@ -34,9 +34,16 @@
</div> </div>
</li> </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) { @for (account of mailAccounts; track account) {
<li class="list-group-item"> <li class="list-group-item">
<div class="row"> <div class="row" [class.reveal]="revealAccounts">
<div class="col d-flex align-items-center"> <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)"> <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) { {{account.name}}@switch (account.account_type) {
@ -76,7 +83,7 @@
</div> </div>
</li> </li>
} }
@if (mailAccounts.length === 0) { @if (!loadingAccounts && mailAccounts.length === 0) {
<li class="list-group-item" i18n>No mail accounts defined.</li> <li class="list-group-item" i18n>No mail accounts defined.</li>
} }
</ul> </ul>
@ -92,7 +99,7 @@
</h4> </h4>
<ul class="list-group"> <ul class="list-group">
<li class="list-group-item"> <li class="list-group-item">
<div class="row"> <div class="row reveal">
<div class="col" i18n>Name</div> <div class="col" i18n>Name</div>
<div class="col d-none d-sm-block" i18n>Sort Order</div> <div class="col d-none d-sm-block" i18n>Sort Order</div>
<div class="col" i18n>Account</div> <div class="col" i18n>Account</div>
@ -101,9 +108,16 @@
</div> </div>
</li> </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) { @for (rule of mailRules; track rule) {
<li class="list-group-item"> <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"><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 d-none d-sm-flex">{{rule.order}}</div>
<div class="col d-flex align-items-center">{{(mailAccountService.getCached(rule.account) | async)?.name}}</div> <div class="col d-flex align-items-center">{{(mailAccountService.getCached(rule.account) | async)?.name}}</div>
@ -151,7 +165,7 @@
</div> </div>
</li> </li>
} }
@if (mailRules.length === 0) { @if (!loadingRules && mailRules.length === 0) {
<li class="list-group-item" i18n>No mail rules defined.</li> <li class="list-group-item" i18n>No mail rules defined.</li>
} }
</ul> </ul>

View File

@ -2,3 +2,12 @@
.d-block.d-sm-none .dropdown-toggle::after { .d-block.d-sm-none .dropdown-toggle::after {
display: none; 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) fixture = TestBed.createComponent(MailComponent)
component = fixture.componentInstance component = fixture.componentInstance
fixture.detectChanges() fixture.detectChanges()
jest.useFakeTimers()
jest.advanceTimersByTime(100)
}) })
function completeSetup(excludeService = null) { function completeSetup(excludeService = null) {
@ -386,6 +388,7 @@ describe('MailComponent', () => {
component.oAuthAccountId = 3 component.oAuthAccountId = 3
const editSpy = jest.spyOn(component, 'editMailAccount') const editSpy = jest.spyOn(component, 'editMailAccount')
component.ngOnInit() component.ngOnInit()
jest.advanceTimersByTime(200)
expect(editSpy).toHaveBeenCalled() expect(editSpy).toHaveBeenCalled()
}) })
}) })

View File

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

View File

@ -53,7 +53,7 @@
</tr> </tr>
} }
@for (object of data; track object) { @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> <td>
<div class="form-check m-0 ms-2 me-n2"> <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();"> <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 { .form-check {
min-height: 0; min-height: 0;
} }
.data-row {
opacity: 0;
transition: opacity .2s;
}
.reveal {
opacity: 1;
}

View File

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

View File

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

View File

@ -13,7 +13,7 @@
<ul class="list-group"> <ul class="list-group">
<li class="list-group-item"> <li class="list-group-item">
<div class="row"> <div class="row reveal">
<div class="col" i18n>Name</div> <div class="col" i18n>Name</div>
<div class="col d-none d-sm-flex" i18n>Sort order</div> <div class="col d-none d-sm-flex" i18n>Sort order</div>
<div class="col" i18n>Status</div> <div class="col" i18n>Status</div>
@ -22,9 +22,16 @@
</div> </div>
</li> </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) { @for (workflow of workflows; track workflow.id) {
<li class="list-group-item"> <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"><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 d-none d-sm-flex"><code>{{workflow.order}}</code></div>
<div class="col d-flex align-items-center"> <div class="col d-flex align-items-center">
@ -69,7 +76,7 @@
</div> </div>
</li> </li>
} }
@if (workflows.length === 0) { @if (!loading && workflows.length === 0) {
<li class="list-group-item" i18n>No workflows defined.</li> <li class="list-group-item" [class.reveal]="reveal" i18n>No workflows defined.</li>
} }
</ul> </ul>

View File

@ -2,3 +2,12 @@
.d-block.d-sm-none .dropdown-toggle::after { .d-block.d-sm-none .dropdown-toggle::after {
display: none; 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) modalService = TestBed.inject(NgbModal)
toastService = TestBed.inject(ToastService) toastService = TestBed.inject(ToastService)
jest.useFakeTimers()
fixture = TestBed.createComponent(WorkflowsComponent) fixture = TestBed.createComponent(WorkflowsComponent)
component = fixture.componentInstance component = fixture.componentInstance
fixture.detectChanges() fixture.detectChanges()
jest.advanceTimersByTime(100)
}) })
it('should support create, show notification on error / success', () => { it('should support create, show notification on error / success', () => {

View File

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

View File

@ -1,8 +1,10 @@
import { import {
BRIGHTNESS, BRIGHTNESS,
componentToHex,
computeLuminance, computeLuminance,
estimateBrightnessForColor, estimateBrightnessForColor,
hexToHsl, hexToHsl,
hslToRgb,
randomColor, randomColor,
rgbToHsl, rgbToHsl,
} from './color' } from './color'
@ -44,7 +46,21 @@ describe('Color Utils', () => {
expect(hsl).toEqual([0, 0, 0.5019607843137255]) 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', () => { it('should return a random color', () => {
expect(randomColor()).not.toBeNull() 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', DARK: 'dark',
} }
function componentToHex(c) { export function componentToHex(c) {
var hex = Math.floor(c).toString(16) var hex = Math.floor(c).toString(16)
return hex.length == 1 ? '0' + hex : hex return hex.length == 1 ? '0' + hex : hex
} }
@ -23,21 +23,22 @@ function componentToHex(c) {
* @param Number l The lightness * @param Number l The lightness
* @return Array The RGB representation * @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 var r, g, b
if (s == 0) { if (s == 0) {
r = g = b = l // achromatic r = g = b = l // achromatic
} else { } 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 q = l < 0.5 ? l * (1 + s) : l + s - l * s
var p = 2 * l - q var p = 2 * l - q
r = hue2rgb(p, q, h + 1 / 3) 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) { @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 // Safari does not like the filters on the image, see https://github.com/paperless-ngx/paperless-ngx/pull/8121
.doc-img-container { .document-card:not(.placeholder-glow),
background-color: #ffffff !important; .document-card-large:not(.placeholder-glow) {
} .doc-img-container {
background-color: #ffffff !important;
width: 101%;
}
.doc-img { .doc-img {
filter: none !important; filter: none !important;
box-shadow: inset 0px 0px 0px 10px rgba(0,0,0,1); box-shadow: inset 0px 0px 0px 10px rgba(0,0,0,1);
} }
.doc-img.inverted { .doc-img.inverted {
filter: none !important; filter: none !important;
mix-blend-mode: difference; mix-blend-mode: difference;
opacity: 0.95; opacity: 0.95;
}
} }
} }