Merge branch 'dev' into 2312-add-pdf-layout-choice

This commit is contained in:
Trenton H
2025-02-07 09:21:52 -08:00
committed by GitHub
126 changed files with 5504 additions and 4078 deletions

View File

@@ -83,9 +83,9 @@ test('date filtering', async ({ page }) => {
await page.routeFromHAR(REQUESTS_HAR3, { notFound: 'fallback' })
await page.goto('/documents')
await page.getByRole('button', { name: 'Dates' }).click()
await page.getByRole('menuitem', { name: 'Last 3 months' }).first().click()
await page.getByRole('menuitem', { name: 'Within 3 months' }).first().click()
await expect(page.locator('pngx-document-list')).toHaveText(/one document/i)
await page.getByRole('menuitem', { name: 'Last 3 months' }).first().click()
await page.getByRole('menuitem', { name: 'Within 3 months' }).first().click()
await page.getByLabel('Datesselected').getByRole('button').first().click()
await page.getByRole('combobox', { name: 'Select month' }).selectOption('12')
await page.getByRole('combobox', { name: 'Select year' }).selectOption('2022')

View File

@@ -3687,7 +3687,7 @@
"time": 1.501,
"request": {
"method": "GET",
"url": "http://localhost:8000/api/documents/?page=1&page_size=50&ordering=-created&truncate_content=true&created__date__gt=2022-12-11",
"url": "http://localhost:8000/api/documents/?page=1&page_size=50&ordering=-created&truncate_content=true&created__date__gte=2022-12-11",
"httpVersion": "HTTP/1.1",
"cookies": [],
"headers": [
@@ -3721,7 +3721,7 @@
"value": "true"
},
{
"name": "created__date__gt",
"name": "created__date__gte",
"value": "2022-12-11"
}
],

File diff suppressed because it is too large Load Diff

3733
src-ui/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -11,17 +11,17 @@
},
"private": true,
"dependencies": {
"@angular/cdk": "^19.0.2",
"@angular/common": "~19.0.3",
"@angular/compiler": "~19.0.3",
"@angular/core": "~19.0.3",
"@angular/forms": "~19.0.3",
"@angular/localize": "~19.0.3",
"@angular/platform-browser": "~19.0.3",
"@angular/platform-browser-dynamic": "~19.0.3",
"@angular/router": "~19.0.3",
"@angular/cdk": "^19.1.2",
"@angular/common": "~19.1.4",
"@angular/compiler": "~19.1.4",
"@angular/core": "~19.1.4",
"@angular/forms": "~19.1.4",
"@angular/localize": "~19.1.4",
"@angular/platform-browser": "~19.1.4",
"@angular/platform-browser-dynamic": "~19.1.4",
"@angular/router": "~19.1.4",
"@ng-bootstrap/ng-bootstrap": "^18.0.0",
"@ng-select/ng-select": "^14.1.0",
"@ng-select/ng-select": "^14.2.0",
"@ngneat/dirty-check-forms": "^3.0.3",
"@popperjs/core": "^2.11.8",
"bootstrap": "^5.3.3",
@@ -30,36 +30,37 @@
"ng2-pdf-viewer": "^10.4.0",
"ngx-bootstrap-icons": "^1.9.3",
"ngx-color": "^9.0.0",
"ngx-cookie-service": "^19.0.0",
"ngx-cookie-service": "^19.1.0",
"ngx-device-detector": "^9.0.0",
"ngx-file-drop": "^16.0.0",
"ngx-ui-tour-ng-bootstrap": "^16.0.0",
"rxjs": "^7.8.1",
"tslib": "^2.8.1",
"utif": "^3.1.0",
"uuid": "^11.0.2",
"uuid": "^11.0.5",
"zone.js": "^0.15.0"
},
"devDependencies": {
"@angular-builders/custom-webpack": "^19.0.0-beta.0",
"@angular-builders/jest": "^19.0.0-beta.1",
"@angular-builders/custom-webpack": "^19.0.0",
"@angular-builders/jest": "^19.0.0",
"@angular-devkit/build-angular": "^19.0.4",
"@angular-devkit/core": "^19.0.4",
"@angular-devkit/schematics": "^19.0.4",
"@angular-eslint/builder": "19.0.0",
"@angular-eslint/eslint-plugin": "19.0.0",
"@angular-eslint/eslint-plugin-template": "19.0.0",
"@angular-eslint/schematics": "19.0.0",
"@angular-eslint/template-parser": "19.0.0",
"@angular/cli": "~19.0.4",
"@angular/compiler-cli": "~19.0.3",
"@codecov/webpack-plugin": "^1.2.1",
"@playwright/test": "^1.48.2",
"@angular-devkit/core": "^19.1.5",
"@angular-devkit/schematics": "^19.1.5",
"@angular-eslint/builder": "19.0.2",
"@angular-eslint/eslint-plugin": "19.0.2",
"@angular-eslint/eslint-plugin-template": "19.0.2",
"@angular-eslint/schematics": "19.0.2",
"@angular-eslint/template-parser": "19.0.2",
"@angular/cli": "~19.1.5",
"@angular/compiler-cli": "~19.1.4",
"@codecov/webpack-plugin": "^1.8.0",
"@playwright/test": "^1.50.1",
"@types/jest": "^29.5.14",
"@types/node": "^22.8.6",
"@typescript-eslint/eslint-plugin": "^8.12.2",
"@typescript-eslint/parser": "^8.12.2",
"@types/node": "^22.13.0",
"@typescript-eslint/eslint-plugin": "^8.22.0",
"@typescript-eslint/parser": "^8.22.0",
"@typescript-eslint/utils": "^8.0.0",
"eslint": "^9.14.0",
"eslint": "^9.19.0",
"jest": "29.7.0",
"jest-environment-jsdom": "^29.7.0",
"jest-preset-angular": "^14.4.2",

View File

@@ -100,6 +100,15 @@ Object.defineProperty(navigator, 'clipboard', {
},
})
Object.defineProperty(navigator, 'canShare', { value: () => true })
if (!navigator.share) {
Object.defineProperty(navigator, 'share', { value: jest.fn() })
}
if (!URL.createObjectURL) {
Object.defineProperty(window.URL, 'createObjectURL', { value: jest.fn() })
}
if (!URL.revokeObjectURL) {
Object.defineProperty(window.URL, 'revokeObjectURL', { value: jest.fn() })
}
Object.defineProperty(window, 'ResizeObserver', { value: mock() })
Object.defineProperty(window, 'location', {
configurable: true,

View File

@@ -18,20 +18,20 @@ import { ToastsComponent } from './components/common/toasts/toasts.component'
import { FileDropComponent } from './components/file-drop/file-drop.component'
import { DirtySavedViewGuard } from './guards/dirty-saved-view.guard'
import { PermissionsGuard } from './guards/permissions.guard'
import {
ConsumerStatusService,
FileStatus,
} from './services/consumer-status.service'
import { HotKeyService } from './services/hot-key.service'
import { PermissionsService } from './services/permissions.service'
import { SettingsService } from './services/settings.service'
import { Toast, ToastService } from './services/toast.service'
import {
FileStatus,
WebsocketStatusService,
} from './services/websocket-status.service'
describe('AppComponent', () => {
let component: AppComponent
let fixture: ComponentFixture<AppComponent>
let tourService: TourService
let consumerStatusService: ConsumerStatusService
let websocketStatusService: WebsocketStatusService
let permissionsService: PermissionsService
let toastService: ToastService
let router: Router
@@ -59,7 +59,7 @@ describe('AppComponent', () => {
}).compileComponents()
tourService = TestBed.inject(TourService)
consumerStatusService = TestBed.inject(ConsumerStatusService)
websocketStatusService = TestBed.inject(WebsocketStatusService)
permissionsService = TestBed.inject(PermissionsService)
settingsService = TestBed.inject(SettingsService)
toastService = TestBed.inject(ToastService)
@@ -90,7 +90,7 @@ describe('AppComponent', () => {
const toastSpy = jest.spyOn(toastService, 'show')
const fileStatusSubject = new Subject<FileStatus>()
jest
.spyOn(consumerStatusService, 'onDocumentConsumptionFinished')
.spyOn(websocketStatusService, 'onDocumentConsumptionFinished')
.mockReturnValue(fileStatusSubject)
component.ngOnInit()
const status = new FileStatus()
@@ -109,7 +109,7 @@ describe('AppComponent', () => {
const toastSpy = jest.spyOn(toastService, 'show')
const fileStatusSubject = new Subject<FileStatus>()
jest
.spyOn(consumerStatusService, 'onDocumentConsumptionFinished')
.spyOn(websocketStatusService, 'onDocumentConsumptionFinished')
.mockReturnValue(fileStatusSubject)
component.ngOnInit()
fileStatusSubject.next(new FileStatus())
@@ -122,7 +122,7 @@ describe('AppComponent', () => {
const toastSpy = jest.spyOn(toastService, 'show')
const fileStatusSubject = new Subject<FileStatus>()
jest
.spyOn(consumerStatusService, 'onDocumentDetected')
.spyOn(websocketStatusService, 'onDocumentDetected')
.mockReturnValue(fileStatusSubject)
component.ngOnInit()
fileStatusSubject.next(new FileStatus())
@@ -136,7 +136,7 @@ describe('AppComponent', () => {
const toastSpy = jest.spyOn(toastService, 'show')
const fileStatusSubject = new Subject<FileStatus>()
jest
.spyOn(consumerStatusService, 'onDocumentDetected')
.spyOn(websocketStatusService, 'onDocumentDetected')
.mockReturnValue(fileStatusSubject)
component.ngOnInit()
fileStatusSubject.next(new FileStatus())
@@ -148,7 +148,7 @@ describe('AppComponent', () => {
const toastSpy = jest.spyOn(toastService, 'showError')
const fileStatusSubject = new Subject<FileStatus>()
jest
.spyOn(consumerStatusService, 'onDocumentConsumptionFailed')
.spyOn(websocketStatusService, 'onDocumentConsumptionFailed')
.mockReturnValue(fileStatusSubject)
component.ngOnInit()
fileStatusSubject.next(new FileStatus())

View File

@@ -5,7 +5,7 @@ import { first, Subscription } from 'rxjs'
import { ToastsComponent } from './components/common/toasts/toasts.component'
import { FileDropComponent } from './components/file-drop/file-drop.component'
import { SETTINGS_KEYS } from './data/ui-settings'
import { ConsumerStatusService } from './services/consumer-status.service'
import { ComponentRouterService } from './services/component-router.service'
import { HotKeyService } from './services/hot-key.service'
import {
PermissionAction,
@@ -15,6 +15,7 @@ import {
import { SettingsService } from './services/settings.service'
import { TasksService } from './services/tasks.service'
import { ToastService } from './services/toast.service'
import { WebsocketStatusService } from './services/websocket-status.service'
@Component({
selector: 'pngx-root',
@@ -34,14 +35,15 @@ export class AppComponent implements OnInit, OnDestroy {
constructor(
private settings: SettingsService,
private consumerStatusService: ConsumerStatusService,
private websocketStatusService: WebsocketStatusService,
private toastService: ToastService,
private router: Router,
private tasksService: TasksService,
public tourService: TourService,
private renderer: Renderer2,
private permissionsService: PermissionsService,
private hotKeyService: HotKeyService
private hotKeyService: HotKeyService,
private componentRouterService: ComponentRouterService
) {
let anyWindow = window as any
anyWindow.pdfWorkerSrc = 'assets/js/pdf.worker.min.mjs'
@@ -49,7 +51,7 @@ export class AppComponent implements OnInit, OnDestroy {
}
ngOnDestroy(): void {
this.consumerStatusService.disconnect()
this.websocketStatusService.disconnect()
if (this.successSubscription) {
this.successSubscription.unsubscribe()
}
@@ -74,9 +76,9 @@ export class AppComponent implements OnInit, OnDestroy {
}
ngOnInit(): void {
this.consumerStatusService.connect()
this.websocketStatusService.connect()
this.successSubscription = this.consumerStatusService
this.successSubscription = this.websocketStatusService
.onDocumentConsumptionFinished()
.subscribe((status) => {
this.tasksService.reload()
@@ -106,7 +108,7 @@ export class AppComponent implements OnInit, OnDestroy {
}
})
this.failedSubscription = this.consumerStatusService
this.failedSubscription = this.websocketStatusService
.onDocumentConsumptionFailed()
.subscribe((status) => {
this.tasksService.reload()
@@ -119,7 +121,7 @@ export class AppComponent implements OnInit, OnDestroy {
}
})
this.newDocumentSubscription = this.consumerStatusService
this.newDocumentSubscription = this.websocketStatusService
.onDocumentDetected()
.subscribe((status) => {
this.tasksService.reload()

View File

@@ -41,7 +41,7 @@
<ng-template ngbNavContent>
<div class="row">
<div class="col-xl-6 pe-xl-5">
<h4 i18n>Appearance</h4>
<h5 i18n>Appearance</h5>
<div class="row mb-3">
<div class="col-md-3 col-form-label pt-0">
<span i18n>Display language</span>
@@ -154,28 +154,7 @@
</div>
</div>
<h4 class="mt-4" i18n>Document editing</h4>
<div class="row mb-3">
<div class="col">
<pngx-input-check i18n-title title="Use PDF viewer provided by the browser" i18n-hint hint="This is usually faster for displaying large PDF documents, but it might not work on some browsers." formControlName="useNativePdfViewer"></pngx-input-check>
</div>
</div>
<div class="row mb-3">
<div class="col">
<pngx-input-check i18n-title title="Automatically remove inbox tag(s) on save" formControlName="documentEditingRemoveInboxTags"></pngx-input-check>
</div>
</div>
<div class="row mb-3">
<div class="col">
<pngx-input-check i18n-title title="Show document thumbnail during loading" formControlName="documentEditingOverlayThumbnail"></pngx-input-check>
</div>
</div>
</div>
<div class="col-xl-6 ps-xl-5">
<h4 class="mt-4 mt-md-0" id="update-checking" i18n>Update checking</h4>
<h5 class="mt-3" id="update-checking" i18n>Update checking</h5>
<div class="row mb-3">
<div class="col d-flex flex-row align-items-start">
<pngx-input-check i18n-title title="Enable update checking" formControlName="updateCheckingEnabled"></pngx-input-check>
@@ -193,7 +172,56 @@
</div>
</div>
<h4 class="mt-4" i18n>Bulk editing</h4>
<h5 class="mt-3" i18n>Saved Views</h5>
<div class="row mb-3">
<div class="col">
<pngx-input-check i18n-title title="Show warning when closing saved views with unsaved changes" formControlName="savedViewsWarnOnUnsavedChange"></pngx-input-check>
</div>
</div>
</div>
<div class="col-xl-6 ps-xl-5">
<h5 class="mt-3 mt-md-0" i18n>Document editing</h5>
<div class="row mb-3">
<div class="col">
<pngx-input-check i18n-title title="Use PDF viewer provided by the browser" i18n-hint hint="This is usually faster for displaying large PDF documents, but it might not work on some browsers." formControlName="useNativePdfViewer"></pngx-input-check>
</div>
</div>
<div class="row mb-3">
<div class="col-2">
<span i18n>Default zoom:</span>
</div>
<div class="col">
<select class="form-select" formControlName="pdfViewerDefaultZoom">
<option [ngValue]="ZoomSetting.PageWidth" i18n>Fit width</option>
<option [ngValue]="ZoomSetting.PageFit" i18n>Fit page</option>
</select>
<p class="small text-muted mt-1" i18n>Only applies to the Paperless-ngx PDF viewer.</p>
</div>
</div>
<div class="row mb-3">
<div class="col">
<pngx-input-check i18n-title title="Automatically remove inbox tag(s) on save" formControlName="documentEditingRemoveInboxTags"></pngx-input-check>
</div>
</div>
<div class="row mb-3">
<div class="col">
<pngx-input-check i18n-title title="Show document thumbnail during loading" formControlName="documentEditingOverlayThumbnail"></pngx-input-check>
</div>
</div>
<h5 class="mt-3" i18n>Notes</h5>
<div class="row mb-3">
<div class="col">
<pngx-input-check i18n-title title="Enable notes" formControlName="notesEnabled"></pngx-input-check>
</div>
</div>
<h5 class="mt-3" i18n>Bulk editing</h5>
<div class="row mb-3">
<div class="col">
<pngx-input-check i18n-title title="Show confirmation dialogs" formControlName="bulkEditConfirmationDialogs"></pngx-input-check>
@@ -201,7 +229,7 @@
</div>
</div>
<h4 class="mt-4" i18n>Global search</h4>
<h5 class="mt-3" i18n>Global search</h5>
<div class="row mb-3">
<div class="col">
<pngx-input-check i18n-title title="Do not include advanced search results" formControlName="searchDbOnly"></pngx-input-check>
@@ -224,19 +252,6 @@
</div>
</div>
<h4 class="mt-4" i18n>Saved Views</h4>
<div class="row mb-3">
<div class="col">
<pngx-input-check i18n-title title="Show warning when closing saved views with unsaved changes" formControlName="savedViewsWarnOnUnsavedChange"></pngx-input-check>
</div>
</div>
<h4 class="mt-4" i18n>Notes</h4>
<div class="row mb-3">
<div class="col">
<pngx-input-check i18n-title title="Enable notes" formControlName="notesEnabled"></pngx-input-check>
</div>
</div>
</div>
</div>
@@ -247,7 +262,7 @@
<a ngbNavLink i18n>Permissions</a>
<ng-template ngbNavContent>
<h4 i18n>Default Permissions</h4>
<h5 i18n>Default Permissions</h5>
<div class="row mb-3">
<div class="col">
@@ -329,7 +344,7 @@
<a ngbNavLink i18n>Notifications</a>
<ng-template ngbNavContent>
<h4 i18n>Document processing</h4>
<h5 i18n>Document processing</h5>
<div class="row mb-3">
<div class="col">

View File

@@ -212,7 +212,7 @@ describe('SettingsComponent', () => {
expect(toastErrorSpy).toHaveBeenCalled()
expect(storeSpy).toHaveBeenCalled()
expect(appearanceSettingsSpy).not.toHaveBeenCalled()
expect(setSpy).toHaveBeenCalledTimes(28)
expect(setSpy).toHaveBeenCalledTimes(29)
// succeed
storeSpy.mockReturnValueOnce(of(true))

View File

@@ -63,6 +63,7 @@ import { PermissionsUserComponent } from '../../common/input/permissions/permiss
import { SelectComponent } from '../../common/input/select/select.component'
import { PageHeaderComponent } from '../../common/page-header/page-header.component'
import { SystemStatusDialogComponent } from '../../common/system-status-dialog/system-status-dialog.component'
import { ZoomSetting } from '../../document-detail/document-detail.component'
import { ComponentWithPermissions } from '../../with-permissions/with-permissions.component'
enum SettingsNavIDs {
@@ -125,6 +126,7 @@ export class SettingsComponent
defaultPermsEditUsers: new FormControl(null),
defaultPermsEditGroups: new FormControl(null),
useNativePdfViewer: new FormControl(null),
pdfViewerDefaultZoom: new FormControl(null),
documentEditingRemoveInboxTags: new FormControl(null),
documentEditingOverlayThumbnail: new FormControl(null),
searchDbOnly: new FormControl(null),
@@ -154,6 +156,8 @@ export class SettingsComponent
public readonly GlobalSearchType = GlobalSearchType
public readonly ZoomSetting = ZoomSetting
get systemStatusHasErrors(): boolean {
return (
this.systemStatus.database.status === SystemStatusItemStatus.ERROR ||
@@ -276,6 +280,9 @@ export class SettingsComponent
useNativePdfViewer: this.settings.get(
SETTINGS_KEYS.USE_NATIVE_PDF_VIEWER
),
pdfViewerDefaultZoom: this.settings.get(
SETTINGS_KEYS.PDF_VIEWER_ZOOM_SETTING
),
displayLanguage: this.settings.getLanguage(),
dateLocale: this.settings.get(SETTINGS_KEYS.DATE_LOCALE),
dateFormat: this.settings.get(SETTINGS_KEYS.DATE_FORMAT),
@@ -435,6 +442,10 @@ export class SettingsComponent
SETTINGS_KEYS.USE_NATIVE_PDF_VIEWER,
this.settingsForm.value.useNativePdfViewer
)
this.settings.set(
SETTINGS_KEYS.PDF_VIEWER_ZOOM_SETTING,
this.settingsForm.value.pdfViewerDefaultZoom
)
this.settings.set(
SETTINGS_KEYS.DATE_LOCALE,
this.settingsForm.value.dateLocale

View File

@@ -86,12 +86,17 @@ export class TrashComponent
modal.componentInstance.buttonsEnabled = false
this.trashService.emptyTrash([document.id]).subscribe({
next: () => {
this.toastService.showInfo($localize`Document deleted`)
this.toastService.showInfo(
$localize`Document "${document.title}" deleted`
)
modal.close()
this.reload()
},
error: (err) => {
this.toastService.showError($localize`Error deleting document`, err)
this.toastService.showError(
$localize`Error deleting document "${document.title}"`,
err
)
modal.close()
},
})
@@ -136,7 +141,7 @@ export class TrashComponent
this.trashService.restoreDocuments([document.id]).subscribe({
next: () => {
this.toastService.show({
content: $localize`Document restored`,
content: $localize`Document "${document.title}" restored`,
delay: 5000,
actionName: $localize`Open document`,
action: () => {
@@ -146,7 +151,10 @@ export class TrashComponent
this.reload()
},
error: (err) => {
this.toastService.showError($localize`Error restoring document`, err)
this.toastService.showError(
$localize`Error restoring document "${document.title}"`,
err
)
},
})
}

View File

@@ -134,7 +134,7 @@ describe('UsersAndGroupsComponent', () => {
deleteSpy.mockReturnValueOnce(of(true))
deleteDialog.confirm()
expect(listAllSpy).toHaveBeenCalled()
expect(toastInfoSpy).toHaveBeenCalledWith('Deleted user')
expect(toastInfoSpy).toHaveBeenCalledWith('Deleted user "user1"')
})
it('should logout current user if password changed, after delay', fakeAsync(() => {
@@ -178,7 +178,7 @@ describe('UsersAndGroupsComponent', () => {
completeSetup()
let modal: NgbModalRef
modalService.activeInstances.subscribe((refs) => (modal = refs[0]))
component.deleteGroup(users[0])
component.deleteGroup(groups[0])
const deleteDialog = modal.componentInstance as ConfirmDialogComponent
const deleteSpy = jest.spyOn(groupService, 'delete')
const toastErrorSpy = jest.spyOn(toastService, 'showError')
@@ -192,7 +192,7 @@ describe('UsersAndGroupsComponent', () => {
deleteSpy.mockReturnValueOnce(of(true))
deleteDialog.confirm()
expect(listAllSpy).toHaveBeenCalled()
expect(toastInfoSpy).toHaveBeenCalledWith('Deleted group')
expect(toastInfoSpy).toHaveBeenCalledWith('Deleted group "group1"')
})
it('should get group name', () => {

View File

@@ -129,13 +129,16 @@ export class UsersAndGroupsComponent
this.usersService.delete(user).subscribe({
next: () => {
modal.close()
this.toastService.showInfo($localize`Deleted user`)
this.toastService.showInfo($localize`Deleted user "${user.username}"`)
this.usersService.listAll().subscribe((r) => {
this.users = r.results
})
},
error: (e) => {
this.toastService.showError($localize`Error deleting user.`, e)
this.toastService.showError(
$localize`Error deleting user "${user.username}".`,
e
)
},
})
})
@@ -179,13 +182,16 @@ export class UsersAndGroupsComponent
this.groupsService.delete(group).subscribe({
next: () => {
modal.close()
this.toastService.showInfo($localize`Deleted group`)
this.toastService.showInfo($localize`Deleted group "${group.name}"`)
this.groupsService.listAll().subscribe((r) => {
this.groups = r.results
})
},
error: (e) => {
this.toastService.showError($localize`Error deleting group.`, e)
this.toastService.showError(
$localize`Error deleting group "${group.name}".`,
e
)
},
})
})

View File

@@ -30,12 +30,13 @@
</div>
</div>
<ul ngbNav class="order-sm-3">
<pngx-toasts-dropdown></pngx-toasts-dropdown>
<li ngbDropdown class="nav-item dropdown">
<button class="btn border-0" id="userDropdown" ngbDropdownToggle>
<span class="small me-2 d-none d-sm-inline">
<button class="btn ps-1 border-0" id="userDropdown" ngbDropdownToggle>
<i-bs width="1.3em" height="1.3em" name="person-circle"></i-bs>
<span class="small ms-2 d-none d-sm-inline">
{{this.settingsService.displayName}}
</span>
<i-bs width="1.3em" height="1.3em" name="person-circle"></i-bs>
</button>
<div ngbDropdownMenu class="dropdown-menu-end shadow me-2" aria-labelledby="userDropdown">
<div class="d-sm-none">

View File

@@ -250,8 +250,8 @@ main {
}
}
.dropdown.show .dropdown-toggle,
.dropdown-toggle:hover {
:host ::ng-deep .dropdown.show .dropdown-toggle,
:host ::ng-deep .dropdown-toggle:hover {
opacity: 0.7;
}

View File

@@ -48,6 +48,7 @@ import { ProfileEditDialogComponent } from '../common/profile-edit-dialog/profil
import { DocumentDetailComponent } from '../document-detail/document-detail.component'
import { ComponentWithPermissions } from '../with-permissions/with-permissions.component'
import { GlobalSearchComponent } from './global-search/global-search.component'
import { ToastsDropdownComponent } from './toasts-dropdown/toasts-dropdown.component'
@Component({
selector: 'pngx-app-frame',
@@ -57,6 +58,7 @@ import { GlobalSearchComponent } from './global-search/global-search.component'
GlobalSearchComponent,
DocumentTitlePipe,
IfPermissionsDirective,
ToastsDropdownComponent,
RouterModule,
NgClass,
NgbDropdownModule,

View File

@@ -0,0 +1,28 @@
<li ngbDropdown class="nav-item" (openChange)="onOpenChange($event)">
@if (toasts.length) {
<span class="badge rounded-pill z-3 pe-none bg-secondary me-2 position-absolute top-0 left-0">{{ toasts.length }}</span>
}
<button class="btn border-0" id="notificationsDropdown" ngbDropdownToggle>
<i-bs width="1.3em" height="1.3em" name="bell"></i-bs>
</button>
<div ngbDropdownMenu class="dropdown-menu-end shadow p-3" aria-labelledby="notificationsDropdown">
<div class="btn-toolbar align-items-center" role="toolbar">
<h6 i18n>Notifications</h6>
<div class="btn-group ms-auto">
<button class="btn btn-sm btn-outline-secondary mb-2 ms-auto"
(click)="toastService.clearToasts()"
[disabled]="toasts.length === 0"
i18n>Clear All</button>
</div>
</div>
@if (toasts.length === 0) {
<p class="text-center mb-0 small text-muted"><em i18n>No notifications</em></p>
}
<div class="scroll-list">
@for (toast of toasts; track toast.id) {
<pngx-toast [autohide]="false" [toast]="toast" (hidden)="onHidden(toast)" (close)="toastService.closeToast(toast)"></pngx-toast>
}
</div>
</div>
</li>

View File

@@ -0,0 +1,22 @@
.dropdown-menu {
width: var(--pngx-toast-max-width);
}
.dropdown-menu .scroll-list {
max-height: 500px;
overflow-y: auto;
}
.dropdown-toggle::after {
display: none;
}
.dropdown-item {
white-space: initial;
}
@media screen and (max-width: 400px) {
:host ::ng-deep .dropdown-menu-end {
right: -3rem;
}
}

View File

@@ -0,0 +1,112 @@
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
import { provideHttpClientTesting } from '@angular/common/http/testing'
import {
ComponentFixture,
TestBed,
discardPeriodicTasks,
fakeAsync,
flush,
} from '@angular/core/testing'
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
import { Subject } from 'rxjs'
import { Toast, ToastService } from 'src/app/services/toast.service'
import { ToastsDropdownComponent } from './toasts-dropdown.component'
const toasts = [
{
id: 'abc-123',
content: 'foo bar',
delay: 5000,
},
{
id: 'def-123',
content: 'Error 1 content',
delay: 5000,
error: 'Error 1 string',
},
{
id: 'ghi-123',
content: 'Error 2 content',
delay: 5000,
error: {
url: 'https://example.com',
status: 500,
statusText: 'Internal Server Error',
message: 'Internal server error 500 message',
error: { detail: 'Error 2 message details' },
},
},
]
describe('ToastsDropdownComponent', () => {
let component: ToastsDropdownComponent
let fixture: ComponentFixture<ToastsDropdownComponent>
let toastService: ToastService
let toastsSubject: Subject<Toast[]> = new Subject()
beforeEach(async () => {
TestBed.configureTestingModule({
imports: [
ToastsDropdownComponent,
NgxBootstrapIconsModule.pick(allIcons),
],
providers: [
provideHttpClient(withInterceptorsFromDi()),
provideHttpClientTesting(),
],
}).compileComponents()
fixture = TestBed.createComponent(ToastsDropdownComponent)
toastService = TestBed.inject(ToastService)
jest.spyOn(toastService, 'getToasts').mockReturnValue(toastsSubject)
component = fixture.componentInstance
fixture.detectChanges()
})
it('should call getToasts and return toasts', fakeAsync(() => {
const spy = jest.spyOn(toastService, 'getToasts')
component.ngOnInit()
toastsSubject.next(toasts)
fixture.detectChanges()
expect(spy).toHaveBeenCalled()
expect(component.toasts).toContainEqual({
id: 'abc-123',
content: 'foo bar',
delay: 5000,
})
component.ngOnDestroy()
flush()
discardPeriodicTasks()
}))
it('should show a toast', fakeAsync(() => {
component.ngOnInit()
toastsSubject.next(toasts)
fixture.detectChanges()
expect(fixture.nativeElement.textContent).toContain('foo bar')
component.ngOnDestroy()
flush()
discardPeriodicTasks()
}))
it('should toggle suppressPopupToasts', fakeAsync((finish) => {
component.ngOnInit()
fixture.detectChanges()
toastsSubject.next(toasts)
const spy = jest.spyOn(toastService, 'suppressPopupToasts', 'set')
component.onOpenChange(true)
expect(spy).toHaveBeenCalledWith(true)
component.ngOnDestroy()
flush()
discardPeriodicTasks()
}))
})

View File

@@ -0,0 +1,42 @@
import { Component, OnDestroy, OnInit } from '@angular/core'
import {
NgbDropdownModule,
NgbProgressbarModule,
} from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { Subscription } from 'rxjs'
import { Toast, ToastService } from 'src/app/services/toast.service'
import { ToastComponent } from '../../common/toast/toast.component'
@Component({
selector: 'pngx-toasts-dropdown',
templateUrl: './toasts-dropdown.component.html',
styleUrls: ['./toasts-dropdown.component.scss'],
imports: [
ToastComponent,
NgbDropdownModule,
NgbProgressbarModule,
NgxBootstrapIconsModule,
],
})
export class ToastsDropdownComponent implements OnInit, OnDestroy {
constructor(public toastService: ToastService) {}
private subscription: Subscription
public toasts: Toast[] = []
ngOnDestroy(): void {
this.subscription?.unsubscribe()
}
ngOnInit(): void {
this.subscription = this.toastService.getToasts().subscribe((toasts) => {
this.toasts = [...toasts]
})
}
onOpenChange(open: boolean): void {
this.toastService.suppressPopupToasts = open
}
}

View File

@@ -29,10 +29,17 @@
<input class="form-control" placeholder="yyyy-mm-dd"
[(ngModel)]="atom.value"
ngbDatepicker
#d="ngbDatepicker" />
#d="ngbDatepicker"
[footerTemplate]="datePickerFooterTemplate" />
<button class="btn btn-sm btn-outline-secondary rounded-end" (click)="d.toggle()" type="button">
<i-bs name="calendar-event"></i-bs>
</button>
<ng-template #datePickerFooterTemplate>
<div class="btn-group-xs border-top p-2 d-flex">
<button type="button" class="btn btn-primary" (click)="atom.value = today; d.close()" i18n>Today</button>
<button type="button" class="btn btn-secondary ms-auto" (click)="d.close()" i18n>Close</button>
</div>
</ng-template>
} @else if (getCustomFieldByID(atom.field)?.data_type === CustomFieldDataType.Float || getCustomFieldByID(atom.field)?.data_type === CustomFieldDataType.Integer) {
<input class="w-25 form-control rounded-end" type="number" [(ngModel)]="atom.value" [disabled]="disabled">
} @else if (getCustomFieldByID(atom.field)?.data_type === CustomFieldDataType.Boolean) {

View File

@@ -41,3 +41,9 @@
min-width: 140px;
}
}
.btn-group-xs {
> .btn {
border-radius: 0.15rem;
}
}

View File

@@ -241,6 +241,8 @@ export class CustomFieldsQueryDropdownComponent extends LoadingComponentWithPerm
customFields: CustomField[] = []
public readonly today: string = new Date().toISOString().split('T')[0]
constructor(protected customFieldsService: CustomFieldsService) {
super()
this.selectionModel = new CustomFieldQueriesModel()

View File

@@ -1,5 +1,5 @@
<div class="btn-group w-100" ngbDropdown role="group" [popperOptions]="popperOptions">
<button class="btn btn-sm" id="dropdown{{title}}" ngbDropdownToggle [ngClass]="createdDateBefore || createdDateAfter ? 'btn-primary' : 'btn-outline-primary'" [disabled]="disabled">
<button class="btn btn-sm" id="dropdown{{title}}" ngbDropdownToggle [ngClass]="createdDateTo || createdDateFrom ? 'btn-primary' : 'btn-outline-primary'" [disabled]="disabled">
<i-bs width="1em" height="1em" name="calendar-event-fill"></i-bs>
<div class="d-none d-sm-inline">&nbsp;{{title}}</div>
<pngx-clearable-badge [selected]="isActive" (cleared)="reset()"></pngx-clearable-badge><span class="visually-hidden">selected</span>
@@ -31,40 +31,52 @@
<div class="list-group-item d-flex p-2" role="menuitem">
<div class="selected-icon">
@if (createdDateAfter) {
<a class="text-light focus-variants" href="javascript:void(0)" (click)="clearCreatedAfter()">
@if (createdDateFrom) {
<a class="text-light focus-variants" href="javascript:void(0)" (click)="clearCreatedFrom()">
<i-bs width="1em" height="1em" name="check" class="variant-unfocused"></i-bs>
<i-bs width="1em" height="1em" name="x" class="variant-focused text-primary"></i-bs>
</a>
}
</div>
<div class="input-group input-group-sm small ps-1 pe-2">
<span class="input-group-text w-25 small text-muted" i18n>After</span>
<span class="input-group-text w-25 small text-muted" i18n>From</span>
<input class="form-control small" [placeholder]="datePlaceHolder" (dateSelect)="onChangeDebounce()" (change)="onChangeDebounce()" (keypress)="onKeyPress($event)"
maxlength="10" [(ngModel)]="createdDateAfter" ngbDatepicker #createdDateAfterPicker="ngbDatepicker">
<button class="btn btn-outline-secondary" (click)="createdDateAfterPicker.toggle()" type="button">
maxlength="10" [(ngModel)]="createdDateFrom" ngbDatepicker #createdDateFromPicker="ngbDatepicker" [footerTemplate]="createdFromFooterTemplate">
<button class="btn btn-outline-secondary" (click)="createdDateFromPicker.toggle()" type="button">
<i-bs width="1em" height="1em" name="calendar"></i-bs>
</button>
<ng-template #createdFromFooterTemplate>
<div class="btn-group-xs border-top p-2 d-flex">
<button class="btn btn-primary" (click)="createdDateFrom = today; onChangeDebounce()" i18n>Today</button>
<button class="btn btn-secondary ms-auto" (click)="createdDateFromPicker.close()" i18n>Close</button>
</div>
</ng-template>
</div>
</div>
<div class="list-group-item d-flex p-2" role="menuitem">
<div class="selected-icon">
@if (createdDateBefore) {
<a class="text-light focus-variants" href="javascript:void(0)" (click)="clearCreatedBefore()">
@if (createdDateTo) {
<a class="text-light focus-variants" href="javascript:void(0)" (click)="clearCreatedTo()">
<i-bs width="1em" height="1em" name="check" class="variant-unfocused"></i-bs>
<i-bs width="1em" height="1em" name="x" class="variant-focused text-primary"></i-bs>
</a>
}
</div>
<div class="input-group input-group-sm small ps-1 pe-2">
<span class="input-group-text w-25 small text-muted" i18n>Before</span>
<span class="input-group-text w-25 small text-muted" i18n>To</span>
<input class="form-control small" [placeholder]="datePlaceHolder" (dateSelect)="onChangeDebounce()" (change)="onChangeDebounce()" (keypress)="onKeyPress($event)"
maxlength="10" [(ngModel)]="createdDateBefore" ngbDatepicker #createdDateBeforePicker="ngbDatepicker">
<button class="btn btn-outline-secondary" (click)="createdDateBeforePicker.toggle()" type="button">
maxlength="10" [(ngModel)]="createdDateTo" ngbDatepicker #createdDateToPicker="ngbDatepicker" [footerTemplate]="createdToFooterTemplate">
<button class="btn btn-outline-secondary" (click)="createdDateToPicker.toggle()" type="button">
<i-bs width="1em" height="1em" name="calendar"></i-bs>
</button>
<ng-template #createdToFooterTemplate>
<div class="btn-group-xs border-top p-2 d-flex">
<button class="btn btn-primary" (click)="createdDateTo = today; onChangeDebounce()" i18n>Today</button>
<button class="btn btn-secondary ms-auto" (click)="createdDateToPicker.close()" i18n>Close</button>
</div>
</ng-template>
</div>
</div>
@@ -95,40 +107,52 @@
<div class="list-group-item d-flex p-2" role="menuitem">
<div class="selected-icon">
@if (addedDateAfter) {
<a class="text-light focus-variants" href="javascript:void(0)" (click)="clearAddedAfter()">
@if (addedDateFrom) {
<a class="text-light focus-variants" href="javascript:void(0)" (click)="clearAddedFrom()">
<i-bs width="1em" height="1em" name="check" class="variant-unfocused"></i-bs>
<i-bs width="1em" height="1em" name="x" class="variant-focused text-primary"></i-bs>
</a>
}
</div>
<div class="input-group input-group-sm small ps-1 pe-2">
<span class="input-group-text w-25 small text-muted" i18n>After</span>
<span class="input-group-text w-25 small text-muted" i18n>From</span>
<input class="form-control small" [placeholder]="datePlaceHolder" (dateSelect)="onChangeDebounce()" (change)="onChangeDebounce()" (keypress)="onKeyPress($event)"
maxlength="10" [(ngModel)]="addedDateAfter" ngbDatepicker #addedDateAfterPicker="ngbDatepicker">
<button class="btn btn-outline-secondary" (click)="addedDateAfterPicker.toggle()" type="button">
maxlength="10" [(ngModel)]="addedDateFrom" ngbDatepicker #addedDateFromPicker="ngbDatepicker" [footerTemplate]="addedFromFooterTemplate">
<button class="btn btn-outline-secondary" (click)="addedDateFromPicker.toggle()" type="button">
<i-bs width="1em" height="1em" name="calendar"></i-bs>
</button>
<ng-template #addedFromFooterTemplate>
<div class="btn-group-xs border-top p-2 d-flex">
<button class="btn btn-primary" (click)="addedDateFrom = today; onChangeDebounce()" i18n>Today</button>
<button class="btn btn-secondary ms-auto" (click)="addedDateFromPicker.close()" i18n>Close</button>
</div>
</ng-template>
</div>
</div>
<div class="list-group-item d-flex p-2" role="menuitem">
<div class="selected-icon">
@if (addedDateBefore) {
<a class="text-light focus-variants" href="javascript:void(0)" (click)="clearAddedBefore()">
@if (addedDateTo) {
<a class="text-light focus-variants" href="javascript:void(0)" (click)="clearAddedTo()">
<i-bs width="1em" height="1em" name="check" class="variant-unfocused"></i-bs>
<i-bs width="1em" height="1em" name="x" class="variant-focused text-primary"></i-bs>
</a>
}
</div>
<div class="input-group input-group-sm small ps-1 pe-2">
<span class="input-group-text w-25 small text-muted" i18n>Before</span>
<span class="input-group-text w-25 small text-muted" i18n>To</span>
<input class="form-control small" [placeholder]="datePlaceHolder" (dateSelect)="onChangeDebounce()" (change)="onChangeDebounce()" (keypress)="onKeyPress($event)"
maxlength="10" [(ngModel)]="addedDateBefore" ngbDatepicker #addedDateBeforePicker="ngbDatepicker">
<button class="btn btn-outline-secondary" (click)="addedDateBeforePicker.toggle()" type="button">
maxlength="10" [(ngModel)]="addedDateTo" ngbDatepicker #addedDateToPicker="ngbDatepicker" [footerTemplate]="addedToFooterTemplate">
<button class="btn btn-outline-secondary" (click)="addedDateToPicker.toggle()" type="button">
<i-bs width="1em" height="1em" name="calendar"></i-bs>
</button>
<ng-template #addedToFooterTemplate>
<div class="btn-group-xs border-top p-2 d-flex">
<button class="btn btn-primary" (click)="addedDateTo = today; onChangeDebounce()" i18n>Today</button>
<button class="btn btn-secondary ms-auto" (click)="addedDateToPicker.close()" i18n>Close</button>
</div>
</ng-template>
</div>
</div>

View File

@@ -41,3 +41,9 @@
}
}
}
.btn-group-xs {
> .btn {
border-radius: 0.15rem;
}
}

View File

@@ -61,7 +61,7 @@ describe('DatesDropdownComponent', () => {
it('should support date input, emit change', fakeAsync(() => {
let result: string
component.createdDateAfterChange.subscribe((date) => (result = date))
component.createdDateFromChange.subscribe((date) => (result = date))
const input: HTMLInputElement = fixture.nativeElement.querySelector('input')
input.value = '5/30/2023'
input.dispatchEvent(new Event('change'))
@@ -83,68 +83,68 @@ describe('DatesDropdownComponent', () => {
let result: DateSelection
component.datesSet.subscribe((date) => (result = date))
component.setCreatedRelativeDate(null)
component.setCreatedRelativeDate(RelativeDate.LAST_7_DAYS)
component.setCreatedRelativeDate(RelativeDate.WITHIN_1_WEEK)
component.setAddedRelativeDate(null)
component.setAddedRelativeDate(RelativeDate.LAST_7_DAYS)
component.setAddedRelativeDate(RelativeDate.WITHIN_1_WEEK)
tick(500)
expect(result).toEqual({
createdAfter: null,
createdBefore: null,
createdRelativeDateID: RelativeDate.LAST_7_DAYS,
addedAfter: null,
addedBefore: null,
addedRelativeDateID: RelativeDate.LAST_7_DAYS,
createdFrom: null,
createdTo: null,
createdRelativeDateID: RelativeDate.WITHIN_1_WEEK,
addedFrom: null,
addedTo: null,
addedRelativeDateID: RelativeDate.WITHIN_1_WEEK,
})
}))
it('should support report if active', () => {
component.createdRelativeDate = RelativeDate.LAST_7_DAYS
component.createdRelativeDate = RelativeDate.WITHIN_1_WEEK
expect(component.isActive).toBeTruthy()
component.createdRelativeDate = null
component.createdDateAfter = '2023-05-30'
component.createdDateFrom = '2023-05-30'
expect(component.isActive).toBeTruthy()
component.createdDateAfter = null
component.createdDateBefore = '2023-05-30'
component.createdDateFrom = null
component.createdDateTo = '2023-05-30'
expect(component.isActive).toBeTruthy()
component.createdDateBefore = null
component.createdDateTo = null
component.addedRelativeDate = RelativeDate.LAST_7_DAYS
component.addedRelativeDate = RelativeDate.WITHIN_1_WEEK
expect(component.isActive).toBeTruthy()
component.addedRelativeDate = null
component.addedDateAfter = '2023-05-30'
component.addedDateFrom = '2023-05-30'
expect(component.isActive).toBeTruthy()
component.addedDateAfter = null
component.addedDateBefore = '2023-05-30'
component.addedDateFrom = null
component.addedDateTo = '2023-05-30'
expect(component.isActive).toBeTruthy()
component.addedDateBefore = null
component.addedDateTo = null
expect(component.isActive).toBeFalsy()
})
it('should support reset', () => {
component.createdDateAfter = '2023-05-30'
component.createdDateFrom = '2023-05-30'
component.reset()
expect(component.createdDateAfter).toBeNull()
expect(component.createdDateFrom).toBeNull()
})
it('should support clearAfter', () => {
component.createdDateAfter = '2023-05-30'
component.clearCreatedAfter()
expect(component.createdDateAfter).toBeNull()
it('should support clearFrom', () => {
component.createdDateFrom = '2023-05-30'
component.clearCreatedFrom()
expect(component.createdDateFrom).toBeNull()
component.addedDateAfter = '2023-05-30'
component.clearAddedAfter()
expect(component.addedDateAfter).toBeNull()
component.addedDateFrom = '2023-05-30'
component.clearAddedFrom()
expect(component.addedDateFrom).toBeNull()
})
it('should support clearBefore', () => {
component.createdDateBefore = '2023-05-30'
component.clearCreatedBefore()
expect(component.createdDateBefore).toBeNull()
it('should support clearTo', () => {
component.createdDateTo = '2023-05-30'
component.clearCreatedTo()
expect(component.createdDateTo).toBeNull()
component.addedDateBefore = '2023-05-30'
component.clearAddedBefore()
expect(component.addedDateBefore).toBeNull()
component.addedDateTo = '2023-05-30'
component.clearAddedTo()
expect(component.addedDateTo).toBeNull()
})
it('should limit keyboard events', () => {

View File

@@ -23,19 +23,19 @@ import { popperOptionsReenablePreventOverflow } from 'src/app/utils/popper-optio
import { ClearableBadgeComponent } from '../clearable-badge/clearable-badge.component'
export interface DateSelection {
createdBefore?: string
createdAfter?: string
createdTo?: string
createdFrom?: string
createdRelativeDateID?: number
addedBefore?: string
addedAfter?: string
addedTo?: string
addedFrom?: string
addedRelativeDateID?: number
}
export enum RelativeDate {
LAST_7_DAYS = 0,
LAST_MONTH = 1,
LAST_3_MONTHS = 2,
LAST_YEAR = 3,
WITHIN_1_WEEK = 0,
WITHIN_1_MONTH = 1,
WITHIN_3_MONTHS = 2,
WITHIN_1_YEAR = 3,
}
@Component({
@@ -63,23 +63,23 @@ export class DatesDropdownComponent implements OnInit, OnDestroy {
relativeDates = [
{
id: RelativeDate.LAST_7_DAYS,
name: $localize`Last 7 days`,
id: RelativeDate.WITHIN_1_WEEK,
name: $localize`Within 1 week`,
date: new Date().setDate(new Date().getDate() - 7),
},
{
id: RelativeDate.LAST_MONTH,
name: $localize`Last month`,
id: RelativeDate.WITHIN_1_MONTH,
name: $localize`Within 1 month`,
date: new Date().setMonth(new Date().getMonth() - 1),
},
{
id: RelativeDate.LAST_3_MONTHS,
name: $localize`Last 3 months`,
id: RelativeDate.WITHIN_3_MONTHS,
name: $localize`Within 3 months`,
date: new Date().setMonth(new Date().getMonth() - 3),
},
{
id: RelativeDate.LAST_YEAR,
name: $localize`Last year`,
id: RelativeDate.WITHIN_1_YEAR,
name: $localize`Within 1 year`,
date: new Date().setFullYear(new Date().getFullYear() - 1),
},
]
@@ -88,16 +88,16 @@ export class DatesDropdownComponent implements OnInit, OnDestroy {
// created
@Input()
createdDateBefore: string
createdDateTo: string
@Output()
createdDateBeforeChange = new EventEmitter<string>()
createdDateToChange = new EventEmitter<string>()
@Input()
createdDateAfter: string
createdDateFrom: string
@Output()
createdDateAfterChange = new EventEmitter<string>()
createdDateFromChange = new EventEmitter<string>()
@Input()
createdRelativeDate: RelativeDate
@@ -107,16 +107,16 @@ export class DatesDropdownComponent implements OnInit, OnDestroy {
// added
@Input()
addedDateBefore: string
addedDateTo: string
@Output()
addedDateBeforeChange = new EventEmitter<string>()
addedDateToChange = new EventEmitter<string>()
@Input()
addedDateAfter: string
addedDateFrom: string
@Output()
addedDateAfterChange = new EventEmitter<string>()
addedDateFromChange = new EventEmitter<string>()
@Input()
addedRelativeDate: RelativeDate
@@ -133,14 +133,16 @@ export class DatesDropdownComponent implements OnInit, OnDestroy {
@Input()
disabled: boolean = false
public readonly today: string = new Date().toISOString().split('T')[0]
get isActive(): boolean {
return (
this.createdRelativeDate !== null ||
this.createdDateAfter?.length > 0 ||
this.createdDateBefore?.length > 0 ||
this.createdDateFrom?.length > 0 ||
this.createdDateTo?.length > 0 ||
this.addedRelativeDate !== null ||
this.addedDateAfter?.length > 0 ||
this.addedDateBefore?.length > 0
this.addedDateFrom?.length > 0 ||
this.addedDateTo?.length > 0
)
}
@@ -161,42 +163,42 @@ export class DatesDropdownComponent implements OnInit, OnDestroy {
}
reset() {
this.createdDateBefore = null
this.createdDateAfter = null
this.createdDateTo = null
this.createdDateFrom = null
this.createdRelativeDate = null
this.addedDateBefore = null
this.addedDateAfter = null
this.addedDateTo = null
this.addedDateFrom = null
this.addedRelativeDate = null
this.onChange()
}
setCreatedRelativeDate(rd: RelativeDate) {
this.createdDateBefore = null
this.createdDateAfter = null
this.createdDateTo = null
this.createdDateFrom = null
this.createdRelativeDate = this.createdRelativeDate == rd ? null : rd
this.onChange()
}
setAddedRelativeDate(rd: RelativeDate) {
this.addedDateBefore = null
this.addedDateAfter = null
this.addedDateTo = null
this.addedDateFrom = null
this.addedRelativeDate = this.addedRelativeDate == rd ? null : rd
this.onChange()
}
onChange() {
this.createdDateBeforeChange.emit(this.createdDateBefore)
this.createdDateAfterChange.emit(this.createdDateAfter)
this.createdDateToChange.emit(this.createdDateTo)
this.createdDateFromChange.emit(this.createdDateFrom)
this.createdRelativeDateChange.emit(this.createdRelativeDate)
this.addedDateBeforeChange.emit(this.addedDateBefore)
this.addedDateAfterChange.emit(this.addedDateAfter)
this.addedDateToChange.emit(this.addedDateTo)
this.addedDateFromChange.emit(this.addedDateFrom)
this.addedRelativeDateChange.emit(this.addedRelativeDate)
this.datesSet.emit({
createdAfter: this.createdDateAfter,
createdBefore: this.createdDateBefore,
createdFrom: this.createdDateFrom,
createdTo: this.createdDateTo,
createdRelativeDateID: this.createdRelativeDate,
addedAfter: this.addedDateAfter,
addedBefore: this.addedDateBefore,
addedFrom: this.addedDateFrom,
addedTo: this.addedDateTo,
addedRelativeDateID: this.addedRelativeDate,
})
}
@@ -205,30 +207,30 @@ export class DatesDropdownComponent implements OnInit, OnDestroy {
this.createdRelativeDate = null
this.addedRelativeDate = null
this.datesSetDebounce$.next({
createdAfter: this.createdDateAfter,
createdBefore: this.createdDateBefore,
addedAfter: this.addedDateAfter,
addedBefore: this.addedDateBefore,
createdAfter: this.createdDateFrom,
createdBefore: this.createdDateTo,
addedAfter: this.addedDateFrom,
addedBefore: this.addedDateTo,
})
}
clearCreatedBefore() {
this.createdDateBefore = null
clearCreatedTo() {
this.createdDateTo = null
this.onChange()
}
clearCreatedAfter() {
this.createdDateAfter = null
clearCreatedFrom() {
this.createdDateFrom = null
this.onChange()
}
clearAddedBefore() {
this.addedDateBefore = null
clearAddedTo() {
this.addedDateTo = null
this.onChange()
}
clearAddedAfter() {
this.addedDateAfter = null
clearAddedFrom() {
this.addedDateFrom = null
this.onChange()
}

View File

@@ -12,10 +12,16 @@
<div class="input-group" [class.is-invalid]="error">
<input #inputField class="form-control" [class.is-invalid]="error" [placeholder]="placeholder" [id]="inputId" maxlength="10"
(dateSelect)="onChange(value)" (change)="onChange(value)" (keypress)="onKeyPress($event)" (paste)="onPaste($event)"
name="dp" [(ngModel)]="value" ngbDatepicker #datePicker="ngbDatepicker" #datePickerContent="ngModel" [disabled]="disabled">
name="dp" [(ngModel)]="value" ngbDatepicker #datePicker="ngbDatepicker" #datePickerContent="ngModel" [disabled]="disabled" [footerTemplate]="datePickerFooterTemplate">
<button class="btn btn-outline-secondary calendar" (click)="datePicker.toggle()" type="button" [disabled]="disabled">
<i-bs width="1.2em" height="1.2em" name="calendar"></i-bs>
</button>
<ng-template #datePickerFooterTemplate>
<div class="btn-group-xs border-top p-2 d-flex">
<button type="button" class="btn btn-primary" (click)="value = today; onChange(value); datePicker.close()" i18n>Today</button>
<button type="button" class="btn btn-secondary ms-auto" (click)="datePicker.close()" i18n>Close</button>
</div>
</ng-template>
@if (showFilter) {
<button class="btn btn-outline-secondary" type="button" (click)="onFilterDocuments()" [disabled]="this.value === null" title="{{ filterButtonTitle }}">
<i-bs width="1.2em" height="1.2em" name="filter"></i-bs>

View File

@@ -0,0 +1,5 @@
.btn-group-xs {
> .btn {
border-radius: 0.15rem;
}
}

View File

@@ -62,6 +62,8 @@ export class DateComponent
@Output()
filterDocuments = new EventEmitter<NgbDateStruct[]>()
public readonly today: string = new Date().toISOString().split('T')[0]
getSuggestions() {
return this.suggestions == null
? []

View File

@@ -0,0 +1,56 @@
<ngb-toast
[autohide]="autohide"
[delay]="toast.delay"
[class]="toast.classname"
[class.mb-2]="true"
(shown)="onShown(toast)"
(hidden)="hidden.emit(toast)">
@if (autohide) {
<ngb-progressbar class="position-absolute h-100 w-100 top-90 start-0 bottom-0 end-0 pe-none" type="dark" [max]="toast.delay" [value]="toast.delayRemaining"></ngb-progressbar>
<span class="visually-hidden">{{ toast.delayRemaining / 1000 | number: '1.0-0' }} seconds</span>
}
<div class="d-flex align-items-top">
@if (!toast.error) {
<i-bs width="0.9em" height="0.9em" name="info-circle"></i-bs>
}
@if (toast.error) {
<i-bs width="0.9em" height="0.9em" name="exclamation-triangle"></i-bs>
}
<div>
<p class="ms-2 mb-0">{{toast.content}}</p>
@if (toast.error) {
<details class="ms-2">
<div class="mt-2 ms-n4 me-n2 small">
@if (isDetailedError(toast.error)) {
<dl class="row mb-0">
<dt class="col-sm-3 fw-normal text-end">URL</dt>
<dd class="col-sm-9">{{ toast.error.url }}</dd>
<dt class="col-sm-3 fw-normal text-end" i18n>Status</dt>
<dd class="col-sm-9">{{ toast.error.status }} <em>{{ toast.error.statusText }}</em></dd>
<dt class="col-sm-3 fw-normal text-end" i18n>Error</dt>
<dd class="col-sm-9">{{ getErrorText(toast.error) }}</dd>
</dl>
}
<div class="row">
<div class="col offset-sm-3">
<button class="btn btn-sm btn-outline-dark" (click)="copyError(toast.error)">
@if (!copied) {
<i-bs name="clipboard"></i-bs>&nbsp;
}
@if (copied) {
<i-bs name="clipboard-check"></i-bs>&nbsp;
}
<ng-container i18n>Copy Raw Error</ng-container>
</button>
</div>
</div>
</div>
</details>
}
@if (toast.action) {
<p class="mb-0 mt-2"><button class="btn btn-sm btn-outline-secondary" (click)="close.emit(toast); toast.action()">{{toast.actionName}}</button></p>
}
</div>
<button type="button" class="btn-close ms-auto flex-shrink-0" data-bs-dismiss="toast" aria-label="Close" (click)="close.emit(toast);"></button>
</div>
</ngb-toast>

View File

@@ -0,0 +1,20 @@
::ng-deep .toast-body {
position: relative;
}
::ng-deep .toast.error {
border-color: hsla(350, 79%, 40%, 0.4); // bg-danger
}
::ng-deep .toast.error .toast-body {
background-color: hsla(350, 79%, 40%, 0.8); // bg-danger
border-top-left-radius: inherit;
border-top-right-radius: inherit;
border-bottom-left-radius: inherit;
border-bottom-right-radius: inherit;
}
.progress {
background-color: var(--pngx-primary);
opacity: .07;
}

View File

@@ -0,0 +1,104 @@
import {
ComponentFixture,
discardPeriodicTasks,
fakeAsync,
flush,
TestBed,
tick,
} from '@angular/core/testing'
import { Clipboard } from '@angular/cdk/clipboard'
import { allIcons, NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { ToastComponent } from './toast.component'
const toast1 = {
content: 'Error 1 content',
delay: 5000,
error: 'Error 1 string',
}
const toast2 = {
content: 'Error 2 content',
delay: 5000,
error: {
url: 'https://example.com',
status: 500,
statusText: 'Internal Server Error',
message: 'Internal server error 500 message',
error: { detail: 'Error 2 message details' },
},
}
describe('ToastComponent', () => {
let component: ToastComponent
let fixture: ComponentFixture<ToastComponent>
let clipboard: Clipboard
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [ToastComponent, NgxBootstrapIconsModule.pick(allIcons)],
}).compileComponents()
fixture = TestBed.createComponent(ToastComponent)
clipboard = TestBed.inject(Clipboard)
component = fixture.componentInstance
})
it('should create', () => {
expect(component).toBeTruthy()
})
it('should countdown toast', fakeAsync(() => {
component.toast = toast2
fixture.detectChanges()
component.onShown(toast2)
tick(5000)
expect(component.toast.delayRemaining).toEqual(0)
flush()
discardPeriodicTasks()
}))
it('should show an error if given with toast', fakeAsync(() => {
component.toast = toast1
fixture.detectChanges()
expect(fixture.nativeElement.querySelector('details')).not.toBeNull()
expect(fixture.nativeElement.textContent).toContain('Error 1 content')
flush()
discardPeriodicTasks()
}))
it('should show error details, support copy', fakeAsync(() => {
component.toast = toast2
fixture.detectChanges()
expect(fixture.nativeElement.querySelector('details')).not.toBeNull()
expect(fixture.nativeElement.textContent).toContain(
'Error 2 message details'
)
const copySpy = jest.spyOn(clipboard, 'copy')
component.copyError(toast2.error)
expect(copySpy).toHaveBeenCalled()
flush()
discardPeriodicTasks()
}))
it('should parse error text, add ellipsis', () => {
expect(component.getErrorText(toast2.error)).toEqual(
'Error 2 message details'
)
expect(component.getErrorText({ error: 'Error string no detail' })).toEqual(
'Error string no detail'
)
expect(component.getErrorText('Error string')).toEqual('')
expect(
component.getErrorText({ error: { message: 'foo error bar' } })
).toContain('{"message":"foo error bar"}')
expect(
component.getErrorText({ error: new Array(205).join('a') })
).toContain('...')
})
})

View File

@@ -0,0 +1,76 @@
import { Clipboard } from '@angular/cdk/clipboard'
import { DecimalPipe } from '@angular/common'
import { Component, EventEmitter, Input, Output } from '@angular/core'
import {
NgbProgressbarModule,
NgbToastModule,
} from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { interval, take } from 'rxjs'
import { Toast } from 'src/app/services/toast.service'
@Component({
selector: 'pngx-toast',
imports: [
DecimalPipe,
NgbToastModule,
NgbProgressbarModule,
NgxBootstrapIconsModule,
],
templateUrl: './toast.component.html',
styleUrl: './toast.component.scss',
})
export class ToastComponent {
@Input() toast: Toast
@Input() autohide: boolean = true
@Output() hidden: EventEmitter<Toast> = new EventEmitter<Toast>()
@Output() close: EventEmitter<Toast> = new EventEmitter<Toast>()
public copied: boolean = false
constructor(private clipboard: Clipboard) {}
onShown(toast: Toast) {
if (!this.autohide) return
const refreshInterval = 150
const delay = toast.delay - 500 // for fade animation
interval(refreshInterval)
.pipe(take(Math.round(delay / refreshInterval)))
.subscribe((count) => {
toast.delayRemaining = Math.max(
0,
delay - refreshInterval * (count + 1)
)
})
}
public isDetailedError(error: any): boolean {
return (
typeof error === 'object' &&
'status' in error &&
'statusText' in error &&
'url' in error &&
'message' in error &&
'error' in error
)
}
public copyError(error: any) {
this.clipboard.copy(JSON.stringify(error))
this.copied = true
setTimeout(() => {
this.copied = false
}, 3000)
}
getErrorText(error: any) {
let text: string = error.error?.detail ?? error.error ?? ''
if (typeof text === 'object') text = JSON.stringify(text)
return `${text.slice(0, 200)}${text.length > 200 ? '...' : ''}`
}
}

View File

@@ -1,55 +1,3 @@
@for (toast of toasts; track toast) {
<ngb-toast
[autohide]="true" [delay]="toast.delay"
[class]="toast.classname"
[class.mb-2]="true"
(shown)="onShow(toast)"
(hidden)="toastService.closeToast(toast)">
<ngb-progressbar class="position-absolute h-100 w-100 top-90 start-0 bottom-0 end-0 pe-none" type="dark" [max]="toast.delay" [value]="toast.delayRemaining"></ngb-progressbar>
<span class="visually-hidden">{{ toast.delayRemaining / 1000 | number: '1.0-0' }} seconds</span>
<div class="d-flex align-items-top">
@if (!toast.error) {
<i-bs width="0.9em" height="0.9em" name="info-circle"></i-bs>
}
@if (toast.error) {
<i-bs width="0.9em" height="0.9em" name="exclamation-triangle"></i-bs>
}
<div>
<p class="ms-2 mb-0">{{toast.content}}</p>
@if (toast.error) {
<details class="ms-2">
<div class="mt-2 ms-n4 me-n2 small">
@if (isDetailedError(toast.error)) {
<dl class="row mb-0">
<dt class="col-sm-3 fw-normal text-end">URL</dt>
<dd class="col-sm-9">{{ toast.error.url }}</dd>
<dt class="col-sm-3 fw-normal text-end" i18n>Status</dt>
<dd class="col-sm-9">{{ toast.error.status }} <em>{{ toast.error.statusText }}</em></dd>
<dt class="col-sm-3 fw-normal text-end" i18n>Error</dt>
<dd class="col-sm-9">{{ getErrorText(toast.error) }}</dd>
</dl>
}
<div class="row">
<div class="col offset-sm-3">
<button class="btn btn-sm btn-outline-dark" (click)="copyError(toast.error)">
@if (!copied) {
<i-bs name="clipboard"></i-bs>&nbsp;
}
@if (copied) {
<i-bs name="clipboard-check"></i-bs>&nbsp;
}
<ng-container i18n>Copy Raw Error</ng-container>
</button>
</div>
</div>
</div>
</details>
}
@if (toast.action) {
<p class="mb-0 mt-2"><button class="btn btn-sm btn-outline-secondary" (click)="toastService.closeToast(toast); toast.action()">{{toast.actionName}}</button></p>
}
</div>
<button type="button" class="btn-close ms-auto flex-shrink-0" data-bs-dismiss="toast" aria-label="Close" (click)="toastService.closeToast(toast);"></button>
</div>
</ngb-toast>
@for (toast of toasts; track toast.id) {
<pngx-toast [toast]="toast" [autohide]="true" (close)="closeToast()"></pngx-toast>
}

View File

@@ -1,7 +1,7 @@
:host {
position: fixed;
top: 0;
right: 0;
right: calc(50% - (var(--pngx-toast-max-width) / 2));
margin: 0.3em;
z-index: 1200;
}
@@ -9,24 +9,3 @@
.toast:not(.show) {
display: block; // this corrects an ng-bootstrap bug that prevented animations
}
::ng-deep .toast-body {
position: relative;
}
::ng-deep .toast.error {
border-color: hsla(350, 79%, 40%, 0.4); // bg-danger
}
::ng-deep .toast.error .toast-body {
background-color: hsla(350, 79%, 40%, 0.8); // bg-danger
border-top-left-radius: inherit;
border-top-right-radius: inherit;
border-bottom-left-radius: inherit;
border-bottom-right-radius: inherit;
}
.progress {
background-color: var(--pngx-primary);
opacity: .07;
}

View File

@@ -1,58 +1,33 @@
import { Clipboard } from '@angular/cdk/clipboard'
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
import { provideHttpClientTesting } from '@angular/common/http/testing'
import {
ComponentFixture,
TestBed,
discardPeriodicTasks,
fakeAsync,
flush,
tick,
} from '@angular/core/testing'
import { ComponentFixture, TestBed } from '@angular/core/testing'
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
import { of } from 'rxjs'
import { ToastService } from 'src/app/services/toast.service'
import { Subject } from 'rxjs'
import { Toast, ToastService } from 'src/app/services/toast.service'
import { ToastsComponent } from './toasts.component'
const toasts = [
{
content: 'foo bar',
delay: 5000,
const toast = {
content: 'Error 2 content',
delay: 5000,
error: {
url: 'https://example.com',
status: 500,
statusText: 'Internal Server Error',
message: 'Internal server error 500 message',
error: { detail: 'Error 2 message details' },
},
{
content: 'Error 1 content',
delay: 5000,
error: 'Error 1 string',
},
{
content: 'Error 2 content',
delay: 5000,
error: {
url: 'https://example.com',
status: 500,
statusText: 'Internal Server Error',
message: 'Internal server error 500 message',
error: { detail: 'Error 2 message details' },
},
},
]
}
describe('ToastsComponent', () => {
let component: ToastsComponent
let fixture: ComponentFixture<ToastsComponent>
let toastService: ToastService
let clipboard: Clipboard
let toastSubject: Subject<Toast> = new Subject()
beforeEach(async () => {
TestBed.configureTestingModule({
imports: [ToastsComponent, NgxBootstrapIconsModule.pick(allIcons)],
providers: [
{
provide: ToastService,
useValue: {
getToasts: () => of(toasts),
},
},
provideHttpClient(withInterceptorsFromDi()),
provideHttpClientTesting(),
],
@@ -60,95 +35,37 @@ describe('ToastsComponent', () => {
fixture = TestBed.createComponent(ToastsComponent)
toastService = TestBed.inject(ToastService)
clipboard = TestBed.inject(Clipboard)
jest.replaceProperty(toastService, 'showToast', toastSubject)
component = fixture.componentInstance
fixture.detectChanges()
})
it('should call getToasts and return toasts', fakeAsync(() => {
const spy = jest.spyOn(toastService, 'getToasts')
it('should create', () => {
expect(component).toBeTruthy()
})
component.ngOnInit()
fixture.detectChanges()
it('should close toast', () => {
component.toasts = [toast]
const closeToastSpy = jest.spyOn(toastService, 'closeToast')
component.closeToast()
expect(component.toasts).toEqual([])
expect(closeToastSpy).toHaveBeenCalledWith(toast)
})
expect(spy).toHaveBeenCalled()
expect(component.toasts).toContainEqual({
content: 'foo bar',
delay: 5000,
})
component.ngOnDestroy()
flush()
discardPeriodicTasks()
}))
it('should show a toast', fakeAsync(() => {
component.ngOnInit()
fixture.detectChanges()
expect(fixture.nativeElement.textContent).toContain('foo bar')
component.ngOnDestroy()
flush()
discardPeriodicTasks()
}))
it('should countdown toast', fakeAsync(() => {
component.ngOnInit()
fixture.detectChanges()
component.onShow(toasts[0])
tick(5000)
expect(component.toasts[0].delayRemaining).toEqual(0)
component.ngOnDestroy()
flush()
discardPeriodicTasks()
}))
it('should show an error if given with toast', fakeAsync(() => {
component.ngOnInit()
fixture.detectChanges()
expect(fixture.nativeElement.querySelector('details')).not.toBeNull()
expect(fixture.nativeElement.textContent).toContain('Error 1 content')
component.ngOnDestroy()
flush()
discardPeriodicTasks()
}))
it('should show error details, support copy', fakeAsync(() => {
component.ngOnInit()
fixture.detectChanges()
expect(fixture.nativeElement.querySelector('details')).not.toBeNull()
expect(fixture.nativeElement.textContent).toContain(
'Error 2 message details'
it('should unsubscribe', () => {
const unsubscribeSpy = jest.spyOn(
(component as any).subscription,
'unsubscribe'
)
const copySpy = jest.spyOn(clipboard, 'copy')
component.copyError(toasts[2].error)
expect(copySpy).toHaveBeenCalled()
component.ngOnDestroy()
flush()
discardPeriodicTasks()
}))
expect(unsubscribeSpy).toHaveBeenCalled()
})
it('should parse error text, add ellipsis', () => {
expect(component.getErrorText(toasts[2].error)).toEqual(
'Error 2 message details'
)
expect(component.getErrorText({ error: 'Error string no detail' })).toEqual(
'Error string no detail'
)
expect(component.getErrorText('Error string')).toEqual('')
expect(
component.getErrorText({ error: { message: 'foo error bar' } })
).toContain('{"message":"foo error bar"}')
expect(
component.getErrorText({ error: new Array(205).join('a') })
).toContain('...')
it('should subscribe to toastService', () => {
component.ngOnInit()
toastSubject.next(toast)
expect(component.toasts).toEqual([toast])
})
})

View File

@@ -1,92 +1,43 @@
import { Clipboard } from '@angular/cdk/clipboard'
import { DecimalPipe } from '@angular/common'
import { Component, OnDestroy, OnInit } from '@angular/core'
import {
NgbAccordionModule,
NgbProgressbarModule,
NgbToastModule,
} from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { Subscription, interval, take } from 'rxjs'
import { Subscription } from 'rxjs'
import { Toast, ToastService } from 'src/app/services/toast.service'
import { ToastComponent } from '../toast/toast.component'
@Component({
selector: 'pngx-toasts',
templateUrl: './toasts.component.html',
styleUrls: ['./toasts.component.scss'],
imports: [
DecimalPipe,
NgbToastModule,
ToastComponent,
NgbAccordionModule,
NgbProgressbarModule,
NgxBootstrapIconsModule,
],
})
export class ToastsComponent implements OnInit, OnDestroy {
constructor(
public toastService: ToastService,
private clipboard: Clipboard
) {}
constructor(public toastService: ToastService) {}
private subscription: Subscription
public toasts: Toast[] = []
public copied: boolean = false
public seconds: number = 0
public toasts: Toast[] = [] // array to force change detection
ngOnDestroy(): void {
this.subscription?.unsubscribe()
}
ngOnInit(): void {
this.subscription = this.toastService.getToasts().subscribe((toasts) => {
this.toasts = toasts
this.toasts.forEach((t) => {
if (typeof t.error === 'string') {
try {
t.error = JSON.parse(t.error)
} catch (e) {}
}
})
this.subscription = this.toastService.showToast.subscribe((toast) => {
this.toasts = toast ? [toast] : []
})
}
onShow(toast: Toast) {
const refreshInterval = 150
const delay = toast.delay - 500 // for fade animation
interval(refreshInterval)
.pipe(take(delay / refreshInterval))
.subscribe((count) => {
toast.delayRemaining = Math.max(
0,
delay - refreshInterval * (count + 1)
)
})
}
public isDetailedError(error: any): boolean {
return (
typeof error === 'object' &&
'status' in error &&
'statusText' in error &&
'url' in error &&
'message' in error &&
'error' in error
)
}
public copyError(error: any) {
this.clipboard.copy(JSON.stringify(error))
this.copied = true
setTimeout(() => {
this.copied = false
}, 3000)
}
getErrorText(error: any) {
let text: string = error.error?.detail ?? error.error ?? ''
if (typeof text === 'object') text = JSON.stringify(text)
return `${text.slice(0, 200)}${text.length > 200 ? '...' : ''}`
closeToast() {
this.toastService.closeToast(this.toasts[0])
this.toasts = []
}
}

View File

@@ -33,14 +33,14 @@ import { PermissionsGuard } from 'src/app/guards/permissions.guard'
import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
import { DocumentTitlePipe } from 'src/app/pipes/document-title.pipe'
import { SafeUrlPipe } from 'src/app/pipes/safeurl.pipe'
import {
ConsumerStatusService,
FileStatus,
} from 'src/app/services/consumer-status.service'
import { DocumentListViewService } from 'src/app/services/document-list-view.service'
import { PermissionsService } from 'src/app/services/permissions.service'
import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
import { DocumentService } from 'src/app/services/rest/document.service'
import {
FileStatus,
WebsocketStatusService,
} from 'src/app/services/websocket-status.service'
import { WidgetFrameComponent } from '../widget-frame/widget-frame.component'
import { SavedViewWidgetComponent } from './saved-view-widget.component'
@@ -112,7 +112,7 @@ describe('SavedViewWidgetComponent', () => {
let component: SavedViewWidgetComponent
let fixture: ComponentFixture<SavedViewWidgetComponent>
let documentService: DocumentService
let consumerStatusService: ConsumerStatusService
let websocketStatusService: WebsocketStatusService
let documentListViewService: DocumentListViewService
let router: Router
@@ -176,7 +176,7 @@ describe('SavedViewWidgetComponent', () => {
}).compileComponents()
documentService = TestBed.inject(DocumentService)
consumerStatusService = TestBed.inject(ConsumerStatusService)
websocketStatusService = TestBed.inject(WebsocketStatusService)
documentListViewService = TestBed.inject(DocumentListViewService)
router = TestBed.inject(Router)
fixture = TestBed.createComponent(SavedViewWidgetComponent)
@@ -235,7 +235,7 @@ describe('SavedViewWidgetComponent', () => {
it('should reload on document consumption finished', () => {
const fileStatusSubject = new Subject<FileStatus>()
jest
.spyOn(consumerStatusService, 'onDocumentConsumptionFinished')
.spyOn(websocketStatusService, 'onDocumentConsumptionFinished')
.mockReturnValue(fileStatusSubject)
const reloadSpy = jest.spyOn(component, 'reload')
component.ngOnInit()

View File

@@ -42,7 +42,6 @@ import { DocumentTitlePipe } from 'src/app/pipes/document-title.pipe'
import { DocumentTypeNamePipe } from 'src/app/pipes/document-type-name.pipe'
import { StoragePathNamePipe } from 'src/app/pipes/storage-path-name.pipe'
import { UsernamePipe } from 'src/app/pipes/username.pipe'
import { ConsumerStatusService } from 'src/app/services/consumer-status.service'
import { DocumentListViewService } from 'src/app/services/document-list-view.service'
import { OpenDocumentsService } from 'src/app/services/open-documents.service'
import {
@@ -53,6 +52,7 @@ import {
import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
import { DocumentService } from 'src/app/services/rest/document.service'
import { SettingsService } from 'src/app/services/settings.service'
import { WebsocketStatusService } from 'src/app/services/websocket-status.service'
import { WidgetFrameComponent } from '../widget-frame/widget-frame.component'
@Component({
@@ -94,7 +94,7 @@ export class SavedViewWidgetComponent
private documentService: DocumentService,
private router: Router,
private list: DocumentListViewService,
private consumerStatusService: ConsumerStatusService,
private websocketStatusService: WebsocketStatusService,
public openDocumentsService: OpenDocumentsService,
public documentListViewService: DocumentListViewService,
public permissionsService: PermissionsService,
@@ -124,7 +124,7 @@ export class SavedViewWidgetComponent
ngOnInit(): void {
this.reload()
this.displayMode = this.savedView.display_mode ?? DisplayMode.TABLE
this.consumerStatusService
this.websocketStatusService
.onDocumentConsumptionFinished()
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe(() => {

View File

@@ -12,9 +12,9 @@ import { routes } from 'src/app/app-routing.module'
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
import { PermissionsGuard } from 'src/app/guards/permissions.guard'
import {
ConsumerStatusService,
FileStatus,
} from 'src/app/services/consumer-status.service'
WebsocketStatusService,
} from 'src/app/services/websocket-status.service'
import { environment } from 'src/environments/environment'
import { WidgetFrameComponent } from '../widget-frame/widget-frame.component'
import { StatisticsWidgetComponent } from './statistics-widget.component'
@@ -23,7 +23,7 @@ describe('StatisticsWidgetComponent', () => {
let component: StatisticsWidgetComponent
let fixture: ComponentFixture<StatisticsWidgetComponent>
let httpTestingController: HttpTestingController
let consumerStatusService: ConsumerStatusService
let websocketStatusService: WebsocketStatusService
const fileStatusSubject = new Subject<FileStatus>()
beforeEach(async () => {
@@ -44,9 +44,9 @@ describe('StatisticsWidgetComponent', () => {
}).compileComponents()
fixture = TestBed.createComponent(StatisticsWidgetComponent)
consumerStatusService = TestBed.inject(ConsumerStatusService)
websocketStatusService = TestBed.inject(WebsocketStatusService)
jest
.spyOn(consumerStatusService, 'onDocumentConsumptionFinished')
.spyOn(websocketStatusService, 'onDocumentConsumptionFinished')
.mockReturnValue(fileStatusSubject)
component = fixture.componentInstance

View File

@@ -8,8 +8,8 @@ import { first, Subject, Subscription, takeUntil } from 'rxjs'
import { ComponentWithPermissions } from 'src/app/components/with-permissions/with-permissions.component'
import { FILTER_HAS_TAGS_ANY } from 'src/app/data/filter-rule-type'
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
import { ConsumerStatusService } from 'src/app/services/consumer-status.service'
import { DocumentListViewService } from 'src/app/services/document-list-view.service'
import { WebsocketStatusService } from 'src/app/services/websocket-status.service'
import { environment } from 'src/environments/environment'
import { WidgetFrameComponent } from '../widget-frame/widget-frame.component'
@@ -51,7 +51,7 @@ export class StatisticsWidgetComponent
constructor(
private http: HttpClient,
private consumerStatusService: ConsumerStatusService,
private websocketConnectionService: WebsocketStatusService,
private documentListViewService: DocumentListViewService
) {
super()
@@ -109,7 +109,7 @@ export class StatisticsWidgetComponent
ngOnInit(): void {
this.reload()
this.subscription = this.consumerStatusService
this.subscription = this.websocketConnectionService
.onDocumentConsumptionFinished()
.subscribe(() => {
this.reload()

View File

@@ -12,13 +12,13 @@ import { NgbAlert, NgbCollapse } from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
import { routes } from 'src/app/app-routing.module'
import { PermissionsGuard } from 'src/app/guards/permissions.guard'
import {
ConsumerStatusService,
FileStatus,
FileStatusPhase,
} from 'src/app/services/consumer-status.service'
import { PermissionsService } from 'src/app/services/permissions.service'
import { UploadDocumentsService } from 'src/app/services/upload-documents.service'
import {
FileStatus,
FileStatusPhase,
WebsocketStatusService,
} from 'src/app/services/websocket-status.service'
import { UploadFileWidgetComponent } from './upload-file-widget.component'
const FAILED_STATUSES = [new FileStatus()]
@@ -42,7 +42,7 @@ const DEFAULT_STATUSES = [
describe('UploadFileWidgetComponent', () => {
let component: UploadFileWidgetComponent
let fixture: ComponentFixture<UploadFileWidgetComponent>
let consumerStatusService: ConsumerStatusService
let websocketStatusService: WebsocketStatusService
let uploadDocumentsService: UploadDocumentsService
beforeEach(async () => {
@@ -65,7 +65,7 @@ describe('UploadFileWidgetComponent', () => {
],
}).compileComponents()
consumerStatusService = TestBed.inject(ConsumerStatusService)
websocketStatusService = TestBed.inject(WebsocketStatusService)
uploadDocumentsService = TestBed.inject(UploadDocumentsService)
fixture = TestBed.createComponent(UploadFileWidgetComponent)
component = fixture.componentInstance
@@ -91,14 +91,14 @@ describe('UploadFileWidgetComponent', () => {
})
it('should generate stats summary', () => {
mockConsumerStatuses(consumerStatusService)
mockConsumerStatuses(websocketStatusService)
expect(component.getStatusSummary()).toEqual(
'Processing: 6, Failed: 1, Added: 4'
)
})
it('should report an upload progress summary', () => {
mockConsumerStatuses(consumerStatusService)
mockConsumerStatuses(websocketStatusService)
expect(component.getTotalUploadProgress()).toEqual(0.75)
})
@@ -117,7 +117,7 @@ describe('UploadFileWidgetComponent', () => {
})
it('should enforce a maximum number of alerts', () => {
mockConsumerStatuses(consumerStatusService)
mockConsumerStatuses(websocketStatusService)
fixture.detectChanges()
// 5 total, 1 hidden
expect(fixture.debugElement.queryAll(By.directive(NgbAlert))).toHaveLength(
@@ -131,19 +131,19 @@ describe('UploadFileWidgetComponent', () => {
})
it('should allow dismissing an alert', () => {
const dismissSpy = jest.spyOn(consumerStatusService, 'dismiss')
const dismissSpy = jest.spyOn(websocketStatusService, 'dismiss')
component.dismiss(new FileStatus())
expect(dismissSpy).toHaveBeenCalled()
})
it('should allow dismissing completed alerts', fakeAsync(() => {
mockConsumerStatuses(consumerStatusService)
mockConsumerStatuses(websocketStatusService)
component.alertsExpanded = true
fixture.detectChanges()
jest
.spyOn(component, 'getStatusCompleted')
.mockImplementation(() => SUCCESS_STATUSES)
const dismissSpy = jest.spyOn(consumerStatusService, 'dismiss')
const dismissSpy = jest.spyOn(websocketStatusService, 'dismiss')
component.dismissCompleted()
tick(1000)
fixture.detectChanges()

View File

@@ -12,13 +12,13 @@ import { TourNgBootstrapModule } from 'ngx-ui-tour-ng-bootstrap'
import { ComponentWithPermissions } from 'src/app/components/with-permissions/with-permissions.component'
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
import {
ConsumerStatusService,
FileStatus,
FileStatusPhase,
} from 'src/app/services/consumer-status.service'
import { SettingsService } from 'src/app/services/settings.service'
import { UploadDocumentsService } from 'src/app/services/upload-documents.service'
import {
FileStatus,
FileStatusPhase,
WebsocketStatusService,
} from 'src/app/services/websocket-status.service'
import { WidgetFrameComponent } from '../widget-frame/widget-frame.component'
const MAX_ALERTS = 5
@@ -46,7 +46,7 @@ export class UploadFileWidgetComponent extends ComponentWithPermissions {
@ViewChildren(NgbAlert) alerts: QueryList<NgbAlert>
constructor(
private consumerStatusService: ConsumerStatusService,
private websocketStatusService: WebsocketStatusService,
private uploadDocumentsService: UploadDocumentsService,
public settingsService: SettingsService
) {
@@ -54,13 +54,13 @@ export class UploadFileWidgetComponent extends ComponentWithPermissions {
}
getStatus() {
return this.consumerStatusService.getConsumerStatus().slice(0, MAX_ALERTS)
return this.websocketStatusService.getConsumerStatus().slice(0, MAX_ALERTS)
}
getStatusSummary() {
let strings = []
let countUploadingAndProcessing =
this.consumerStatusService.getConsumerStatusNotCompleted().length
this.websocketStatusService.getConsumerStatusNotCompleted().length
let countFailed = this.getStatusFailed().length
let countSuccess = this.getStatusSuccess().length
if (countUploadingAndProcessing > 0) {
@@ -78,27 +78,30 @@ export class UploadFileWidgetComponent extends ComponentWithPermissions {
}
getStatusHidden() {
if (this.consumerStatusService.getConsumerStatus().length < MAX_ALERTS)
if (this.websocketStatusService.getConsumerStatus().length < MAX_ALERTS)
return []
else return this.consumerStatusService.getConsumerStatus().slice(MAX_ALERTS)
else
return this.websocketStatusService.getConsumerStatus().slice(MAX_ALERTS)
}
getStatusUploading() {
return this.consumerStatusService.getConsumerStatus(
return this.websocketStatusService.getConsumerStatus(
FileStatusPhase.UPLOADING
)
}
getStatusFailed() {
return this.consumerStatusService.getConsumerStatus(FileStatusPhase.FAILED)
return this.websocketStatusService.getConsumerStatus(FileStatusPhase.FAILED)
}
getStatusSuccess() {
return this.consumerStatusService.getConsumerStatus(FileStatusPhase.SUCCESS)
return this.websocketStatusService.getConsumerStatus(
FileStatusPhase.SUCCESS
)
}
getStatusCompleted() {
return this.consumerStatusService.getConsumerStatusCompleted()
return this.websocketStatusService.getConsumerStatusCompleted()
}
getTotalUploadProgress() {
@@ -134,12 +137,12 @@ export class UploadFileWidgetComponent extends ComponentWithPermissions {
}
dismiss(status: FileStatus) {
this.consumerStatusService.dismiss(status)
this.websocketStatusService.dismiss(status)
}
dismissCompleted() {
this.getStatusCompleted().forEach((status) =>
this.consumerStatusService.dismiss(status)
this.websocketStatusService.dismiss(status)
)
}

View File

@@ -9,9 +9,9 @@
}
<div class="input-group input-group-sm me-md-5 d-none d-md-flex">
<button class="btn btn-outline-secondary" (click)="decreaseZoom()" i18n>-</button>
<select class="form-select" (change)="onZoomSelect($event)">
<select class="form-select" (change)="setZoom($event.target.value)">
@for (setting of zoomSettings; track setting) {
<option [value]="setting" [selected]="previewZoomSetting === setting">
<option [value]="setting" [attr.selected]="isZoomSelected(setting) ? 'selected' : null">
{{ getZoomSettingTitle(setting) }}
</option>
}
@@ -25,15 +25,20 @@
</button>
<div class="btn-group">
<a [href]="downloadUrl" class="btn btn-sm btn-outline-primary">
<i-bs width="1.2em" height="1.2em" name="download"></i-bs><span class="d-none d-lg-inline ps-1" i18n>Download</span>
</a>
<button (click)="download()" class="btn btn-sm btn-outline-primary" [disabled]="downloading">
@if (downloading) {
<div class="spinner-border spinner-border-sm" role="status"></div>
} @else {
<i-bs width="1.2em" height="1.2em" name="download"></i-bs>
}
<span class="d-none d-lg-inline ps-1" i18n>Download</span>
</button>
@if (metadata?.has_archive_version) {
<div class="btn-group" ngbDropdown role="group">
<button class="btn btn-sm btn-outline-primary dropdown-toggle" ngbDropdownToggle></button>
<button class="btn btn-sm btn-outline-primary dropdown-toggle" [disabled]="downloading" ngbDropdownToggle></button>
<div class="dropdown-menu shadow" ngbDropdownMenu>
<a ngbDropdownItem [href]="downloadOriginalUrl" i18n>Download original</a>
<button ngbDropdownItem (click)="download(true)" [disabled]="downloading" i18n>Download original</button>
</div>
</div>
}
@@ -351,9 +356,9 @@
</ng-template>
<ng-template #previewContent>
<div class="thumb-preview position-absolute pe-none" [class.fade]="previewLoaded">
<div class="thumb-preview position-absolute pe-none text-center" [class.fade]="previewLoaded">
@if (showThumbnailOverlay) {
<img [src]="thumbUrl | safeUrl" class="" width="100%" height="auto" alt="Document loading..." i18n-alt />
<img [src]="thumbUrl | safeUrl" class="mx-auto" [attr.width]="previewZoomScale === 'page-fit' ? 'auto' : '100%'" [attr.height]="previewZoomScale === 'page-fit' ? '100%' : 'auto'" alt="Document loading..." i18n-alt />
}
<div class="position-absolute top-0 start-0 m-2 p-2 d-flex align-items-center justify-content-center">
<div>

View File

@@ -85,5 +85,8 @@ textarea.rtl {
> img {
filter: blur(1px);
max-width: 100%;
object-fit: contain;
object-position: top;
}
}

View File

@@ -24,6 +24,7 @@ import {
NgbModalRef,
} from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
import { DeviceDetectorService } from 'ngx-device-detector'
import { of, throwError } from 'rxjs'
import { routes } from 'src/app/app-routing.module'
import { Correspondent } from 'src/app/data/correspondent'
@@ -45,6 +46,7 @@ import { Tag } from 'src/app/data/tag'
import { PermissionsGuard } from 'src/app/guards/permissions.guard'
import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
import { DocumentTitlePipe } from 'src/app/pipes/document-title.pipe'
import { ComponentRouterService } from 'src/app/services/component-router.service'
import { DocumentListViewService } from 'src/app/services/document-list-view.service'
import { OpenDocumentsService } from 'src/app/services/open-documents.service'
import { PermissionsService } from 'src/app/services/permissions.service'
@@ -60,7 +62,10 @@ import { ToastService } from 'src/app/services/toast.service'
import { environment } from 'src/environments/environment'
import { ConfirmDialogComponent } from '../common/confirm-dialog/confirm-dialog.component'
import { CustomFieldsDropdownComponent } from '../common/custom-fields-dropdown/custom-fields-dropdown.component'
import { DocumentDetailComponent } from './document-detail.component'
import {
DocumentDetailComponent,
ZoomSetting,
} from './document-detail.component'
const doc: Document = {
id: 3,
@@ -126,7 +131,9 @@ describe('DocumentDetailComponent', () => {
let documentListViewService: DocumentListViewService
let settingsService: SettingsService
let customFieldsService: CustomFieldsService
let deviceDetectorService: DeviceDetectorService
let httpTestingController: HttpTestingController
let componentRouterService: ComponentRouterService
let currentUserCan = true
let currentUserHasObjectPermissions = true
@@ -262,8 +269,10 @@ describe('DocumentDetailComponent', () => {
settingsService = TestBed.inject(SettingsService)
settingsService.currentUser = { id: 1 }
customFieldsService = TestBed.inject(CustomFieldsService)
deviceDetectorService = TestBed.inject(DeviceDetectorService)
fixture = TestBed.createComponent(DocumentDetailComponent)
httpTestingController = TestBed.inject(HttpTestingController)
componentRouterService = TestBed.inject(ComponentRouterService)
component = fixture.componentInstance
})
@@ -448,7 +457,9 @@ describe('DocumentDetailComponent', () => {
component.save(true)
expect(updateSpy).toHaveBeenCalled()
expect(closeSpy).toHaveBeenCalled()
expect(toastSpy).toHaveBeenCalledWith('Document saved successfully.')
expect(toastSpy).toHaveBeenCalledWith(
'Document "Doc 3" saved successfully.'
)
})
it('should support save without close and show success toast', () => {
@@ -461,7 +472,9 @@ describe('DocumentDetailComponent', () => {
component.save()
expect(updateSpy).toHaveBeenCalled()
expect(closeSpy).not.toHaveBeenCalled()
expect(toastSpy).toHaveBeenCalledWith('Document saved successfully.')
expect(toastSpy).toHaveBeenCalledWith(
'Document "Doc 3" saved successfully.'
)
})
it('should show toast error on save if error occurs', () => {
@@ -476,7 +489,10 @@ describe('DocumentDetailComponent', () => {
component.save()
expect(updateSpy).toHaveBeenCalled()
expect(closeSpy).not.toHaveBeenCalled()
expect(toastSpy).toHaveBeenCalledWith('Error saving document', error)
expect(toastSpy).toHaveBeenCalledWith(
'Error saving document "Doc 3"',
error
)
})
it('should show error toast on save but close if user can no longer edit', () => {
@@ -492,7 +508,9 @@ describe('DocumentDetailComponent', () => {
component.save(true)
expect(updateSpy).toHaveBeenCalled()
expect(closeSpy).toHaveBeenCalled()
expect(toastSpy).toHaveBeenCalledWith('Document saved successfully.')
expect(toastSpy).toHaveBeenCalledWith(
'Document "Doc 3" saved successfully.'
)
})
it('should allow save and next', () => {
@@ -568,6 +586,16 @@ describe('DocumentDetailComponent', () => {
expect(navigateSpy).toHaveBeenCalledWith(['documents'])
})
it('should allow close and navigate to the last view if available', () => {
initNormally()
jest
.spyOn(componentRouterService, 'getComponentURLBefore')
.mockReturnValue('dashboard')
const navigateSpy = jest.spyOn(router, 'navigate')
component.close()
expect(navigateSpy).toHaveBeenCalledWith(['dashboard'])
})
it('should allow close and navigate to documents by default', () => {
initNormally()
jest
@@ -728,7 +756,7 @@ describe('DocumentDetailComponent', () => {
it('should support zoom controls', () => {
initNormally()
component.onZoomSelect({ target: { value: '1' } } as any) // from select
component.setZoom(ZoomSetting.One) // from select
expect(component.previewZoomSetting).toEqual('1')
component.increaseZoom()
expect(component.previewZoomSetting).toEqual('1.5')
@@ -736,18 +764,18 @@ describe('DocumentDetailComponent', () => {
expect(component.previewZoomSetting).toEqual('2')
component.decreaseZoom()
expect(component.previewZoomSetting).toEqual('1.5')
component.onZoomSelect({ target: { value: '1' } } as any) // from select
component.setZoom(ZoomSetting.One) // from select
component.decreaseZoom()
expect(component.previewZoomSetting).toEqual('.75')
component.onZoomSelect({ target: { value: 'page-fit' } } as any) // from select
component.setZoom(ZoomSetting.PageFit) // from select
expect(component.previewZoomScale).toEqual('page-fit')
expect(component.previewZoomSetting).toEqual('1')
component.increaseZoom()
expect(component.previewZoomSetting).toEqual('1.5')
expect(component.previewZoomScale).toEqual('page-width')
component.onZoomSelect({ target: { value: 'page-fit' } } as any) // from select
component.setZoom(ZoomSetting.PageFit) // from select
expect(component.previewZoomScale).toEqual('page-fit')
expect(component.previewZoomSetting).toEqual('1')
component.decreaseZoom()
@@ -755,6 +783,19 @@ describe('DocumentDetailComponent', () => {
expect(component.previewZoomScale).toEqual('page-width')
})
it('should select correct zoom setting in dropdown', () => {
initNormally()
component.setZoom(ZoomSetting.PageFit)
expect(component.isZoomSelected(ZoomSetting.PageFit)).toBeTruthy()
expect(component.isZoomSelected(ZoomSetting.One)).toBeFalsy()
component.setZoom(ZoomSetting.PageWidth)
expect(component.isZoomSelected(ZoomSetting.One)).toBeTruthy()
expect(component.isZoomSelected(ZoomSetting.PageFit)).toBeFalsy()
component.setZoom(ZoomSetting.Quarter)
expect(component.isZoomSelected(ZoomSetting.Quarter)).toBeTruthy()
expect(component.isZoomSelected(ZoomSetting.PageFit)).toBeFalsy()
})
it('should support updating notes dynamically', () => {
const notes = [
{
@@ -1255,4 +1296,38 @@ describe('DocumentDetailComponent', () => {
.error(new ErrorEvent('failed'))
expect(component.tiffError).not.toBeUndefined()
})
it('should support download using share sheet on mobile, direct download otherwise', () => {
const shareSpy = jest.spyOn(navigator, 'share')
const createSpy = jest.spyOn(document, 'createElement')
const urlRevokeSpy = jest.spyOn(URL, 'revokeObjectURL')
initNormally()
// Mobile
jest.spyOn(deviceDetectorService, 'isDesktop').mockReturnValue(false)
component.download()
httpTestingController
.expectOne(`${environment.apiBaseUrl}documents/${doc.id}/download/`)
.error(new ProgressEvent('failed'))
expect(shareSpy).not.toHaveBeenCalled()
component.download(true)
httpTestingController
.expectOne(
`${environment.apiBaseUrl}documents/${doc.id}/download/?original=true`
)
.flush(new ArrayBuffer(100))
expect(shareSpy).toHaveBeenCalled()
// Desktop
shareSpy.mockClear()
jest.spyOn(deviceDetectorService, 'isDesktop').mockReturnValue(true)
component.download()
httpTestingController
.expectOne(`${environment.apiBaseUrl}documents/${doc.id}/download/`)
.flush(new ArrayBuffer(100))
expect(shareSpy).not.toHaveBeenCalled()
expect(createSpy).toHaveBeenCalledWith('a')
expect(urlRevokeSpy).toHaveBeenCalled()
})
})

View File

@@ -20,6 +20,7 @@ import {
import { dirtyCheck, DirtyComponent } from '@ngneat/dirty-check-forms'
import { PDFDocumentProxy, PdfViewerModule } from 'ng2-pdf-viewer'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { DeviceDetectorService } from 'ngx-device-detector'
import { BehaviorSubject, Observable, Subject } from 'rxjs'
import {
debounceTime,
@@ -59,6 +60,7 @@ import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
import { DocumentTitlePipe } from 'src/app/pipes/document-title.pipe'
import { FileSizePipe } from 'src/app/pipes/file-size.pipe'
import { SafeUrlPipe } from 'src/app/pipes/safeurl.pipe'
import { ComponentRouterService } from 'src/app/services/component-router.service'
import { DocumentListViewService } from 'src/app/services/document-list-view.service'
import { HotKeyService } from 'src/app/services/hot-key.service'
import { OpenDocumentsService } from 'src/app/services/open-documents.service'
@@ -122,7 +124,7 @@ enum ContentRenderType {
TIFF = 'tiff',
}
enum ZoomSetting {
export enum ZoomSetting {
PageFit = 'page-fit',
PageWidth = 'page-width',
Quarter = '.25',
@@ -194,8 +196,6 @@ export class DocumentDetailComponent
previewUrl: string
thumbUrl: string
previewText: string
downloadUrl: string
downloadOriginalUrl: string
previewLoaded: boolean = false
tiffURL: string
tiffError: string
@@ -233,6 +233,9 @@ export class DocumentDetailComponent
ogDate: Date
customFields: CustomField[]
public downloading: boolean = false
public readonly CustomFieldDataType = CustomFieldDataType
public readonly ContentRenderType = ContentRenderType
@@ -272,7 +275,9 @@ export class DocumentDetailComponent
private userService: UserService,
private customFieldsService: CustomFieldsService,
private http: HttpClient,
private hotKeyService: HotKeyService
private hotKeyService: HotKeyService,
private componentRouterService: ComponentRouterService,
private deviceDetectorService: DeviceDetectorService
) {
super()
}
@@ -323,6 +328,7 @@ export class DocumentDetailComponent
}
ngOnInit(): void {
this.setZoom(this.settings.get(SETTINGS_KEYS.PDF_VIEWER_ZOOM_SETTING))
this.documentForm.valueChanges
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe(() => {
@@ -415,13 +421,6 @@ export class DocumentDetailComponent
.pipe(
switchMap((doc) => {
this.documentId = doc.id
this.downloadUrl = this.documentsService.getDownloadUrl(
this.documentId
)
this.downloadOriginalUrl = this.documentsService.getDownloadUrl(
this.documentId,
true
)
this.suggestions = null
const openDocument = this.openDocumentService.getOpenDocument(
this.documentId
@@ -810,7 +809,9 @@ export class DocumentDetailComponent
this.store.next(newValues)
this.openDocumentService.setDirty(this.document, false)
this.openDocumentService.save()
this.toastService.showInfo($localize`Document saved successfully.`)
this.toastService.showInfo(
$localize`Document "${newValues.title}" saved successfully.`
)
this.networkActive = false
this.error = null
if (close) {
@@ -824,11 +825,16 @@ export class DocumentDetailComponent
error: (error) => {
this.networkActive = false
if (!this.userCanEdit) {
this.toastService.showInfo($localize`Document saved successfully.`)
this.toastService.showInfo(
$localize`Document "${this.document.title}" saved successfully.`
)
close && this.close()
} else {
this.error = error.error
this.toastService.showError($localize`Error saving document`, error)
this.toastService.showError(
$localize`Error saving document "${this.document.title}"`,
error
)
}
},
})
@@ -888,6 +894,10 @@ export class DocumentDetailComponent
'view',
this.documentListViewService.activeSavedViewId,
])
} else if (this.componentRouterService.getComponentURLBefore()) {
this.router.navigate([
this.componentRouterService.getComponentURLBefore(),
])
} else {
this.router.navigate(['documents'])
}
@@ -953,7 +963,7 @@ export class DocumentDetailComponent
.subscribe({
next: () => {
this.toastService.showInfo(
$localize`Reprocess operation will begin in the background. Close and re-open or reload this document after the operation has completed to see new content.`
$localize`Reprocess operation for "${this.document.title}" will begin in the background. Close and re-open or reload this document after the operation has completed to see new content.`
)
if (modal) {
modal.close()
@@ -972,6 +982,52 @@ export class DocumentDetailComponent
})
}
download(original: boolean = false) {
this.downloading = true
const downloadUrl = this.documentsService.getDownloadUrl(
this.documentId,
original
)
this.http.get(downloadUrl, { responseType: 'blob' }).subscribe({
next: (blob) => {
this.downloading = false
const blobParts = [blob]
const file = new File(
blobParts,
original
? this.document.original_file_name
: this.document.archived_file_name,
{
type: original ? this.document.mime_type : 'application/pdf',
}
)
if (
!this.deviceDetectorService.isDesktop() &&
navigator.canShare &&
navigator.canShare({ files: [file] })
) {
navigator.share({
files: [file],
})
} else {
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = this.document.title
a.click()
URL.revokeObjectURL(url)
}
},
error: (error) => {
this.downloading = false
this.toastService.showError(
$localize`Error downloading document`,
error
)
},
})
}
hasNext() {
return this.documentListViewService.hasNext(this.documentId)
}
@@ -1017,14 +1073,13 @@ export class DocumentDetailComponent
}
}
onZoomSelect(event: Event) {
const setting = (event.target as HTMLSelectElement)?.value as ZoomSetting
if (ZoomSetting.PageFit === setting) {
this.previewZoomSetting = ZoomSetting.One
setZoom(setting: ZoomSetting) {
if (ZoomSetting.PageFit === setting || ZoomSetting.PageWidth === setting) {
this.previewZoomScale = setting
this.previewZoomSetting = ZoomSetting.One
} else {
this.previewZoomScale = ZoomSetting.PageWidth
this.previewZoomSetting = setting
this.previewZoomScale = ZoomSetting.PageWidth
}
}
@@ -1034,6 +1089,14 @@ export class DocumentDetailComponent
)
}
isZoomSelected(setting: ZoomSetting): boolean {
if (this.previewZoomScale === ZoomSetting.PageFit) {
return setting === ZoomSetting.PageFit
}
return this.previewZoomSetting === setting
}
getZoomSettingTitle(setting: ZoomSetting): string {
switch (setting) {
case ZoomSetting.PageFit:
@@ -1267,7 +1330,7 @@ export class DocumentDetailComponent
.subscribe({
next: () => {
this.toastService.showInfo(
$localize`Split operation will begin in the background.`
$localize`Split operation for "${this.document.title}" will begin in the background.`
)
modal.close()
},
@@ -1306,7 +1369,7 @@ export class DocumentDetailComponent
.subscribe({
next: () => {
this.toastService.show({
content: $localize`Rotation will begin in the background. Close and re-open the document after the operation has completed to see the changes.`,
content: $localize`Rotation of "${this.document.title}" will begin in the background. Close and re-open the document after the operation has completed to see the changes.`,
delay: 8000,
action: this.close.bind(this),
actionName: $localize`Close`,
@@ -1346,7 +1409,7 @@ export class DocumentDetailComponent
.subscribe({
next: () => {
this.toastService.showInfo(
$localize`Delete pages operation will begin in the background. Close and re-open or reload this document after the operation has completed to see the changes.`
$localize`Delete pages operation for "${this.document.title}" will begin in the background. Close and re-open or reload this document after the operation has completed to see the changes.`
)
modal.close()
},

View File

@@ -1039,6 +1039,7 @@ describe('BulkEditorComponent', () => {
httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
) // listAllFilteredIds
expect(documentListViewService.selected.size).toEqual(0)
})
it('should support bulk download with archive, originals or both and file formatting', () => {

View File

@@ -268,6 +268,9 @@ export class BulkEditorComponent
.pipe(first())
.subscribe({
next: () => {
if (args['delete_originals']) {
this.list.selected.clear()
}
this.list.reload()
this.list.reduceSelectionToFilter()
this.list.selected.forEach((id) => {

View File

@@ -32,7 +32,7 @@
{{document.title | documentTitle}}
}
@if (displayFields.includes(DisplayField.TAGS)) {
@for (tagID of document.tags; track t) {
@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>
}
}

View File

@@ -38,16 +38,16 @@ import { DocumentTitlePipe } from 'src/app/pipes/document-title.pipe'
import { FilterPipe } from 'src/app/pipes/filter.pipe'
import { SafeHtmlPipe } from 'src/app/pipes/safehtml.pipe'
import { UsernamePipe } from 'src/app/pipes/username.pipe'
import {
ConsumerStatusService,
FileStatus,
} from 'src/app/services/consumer-status.service'
import { DocumentListViewService } from 'src/app/services/document-list-view.service'
import { PermissionsService } from 'src/app/services/permissions.service'
import { DocumentService } from 'src/app/services/rest/document.service'
import { SavedViewService } from 'src/app/services/rest/saved-view.service'
import { SettingsService } from 'src/app/services/settings.service'
import { ToastService } from 'src/app/services/toast.service'
import {
FileStatus,
WebsocketStatusService,
} from 'src/app/services/websocket-status.service'
import { DocumentCardLargeComponent } from './document-card-large/document-card-large.component'
import { DocumentCardSmallComponent } from './document-card-small/document-card-small.component'
import { DocumentListComponent } from './document-list.component'
@@ -81,7 +81,7 @@ describe('DocumentListComponent', () => {
let fixture: ComponentFixture<DocumentListComponent>
let documentListService: DocumentListViewService
let documentService: DocumentService
let consumerStatusService: ConsumerStatusService
let websocketStatusService: WebsocketStatusService
let savedViewService: SavedViewService
let router: Router
let activatedRoute: ActivatedRoute
@@ -112,7 +112,7 @@ describe('DocumentListComponent', () => {
documentListService = TestBed.inject(DocumentListViewService)
documentService = TestBed.inject(DocumentService)
consumerStatusService = TestBed.inject(ConsumerStatusService)
websocketStatusService = TestBed.inject(WebsocketStatusService)
savedViewService = TestBed.inject(SavedViewService)
router = TestBed.inject(Router)
activatedRoute = TestBed.inject(ActivatedRoute)
@@ -128,13 +128,24 @@ describe('DocumentListComponent', () => {
const reloadSpy = jest.spyOn(documentListService, 'reload')
const fileStatusSubject = new Subject<FileStatus>()
jest
.spyOn(consumerStatusService, 'onDocumentConsumptionFinished')
.spyOn(websocketStatusService, 'onDocumentConsumptionFinished')
.mockReturnValue(fileStatusSubject)
fixture.detectChanges()
fileStatusSubject.next(new FileStatus())
expect(reloadSpy).toHaveBeenCalled()
})
it('should reload on document deleted', () => {
const reloadSpy = jest.spyOn(documentListService, 'reload')
const documentDeletedSubject = new Subject<boolean>()
jest
.spyOn(websocketStatusService, 'onDocumentDeleted')
.mockReturnValue(documentDeletedSubject)
fixture.detectChanges()
documentDeletedSubject.next(true)
expect(reloadSpy).toHaveBeenCalled()
})
it('should show score sort fields on fulltext queries', () => {
documentListService.filterRules = [
{

View File

@@ -43,7 +43,6 @@ import { DocumentTitlePipe } from 'src/app/pipes/document-title.pipe'
import { DocumentTypeNamePipe } from 'src/app/pipes/document-type-name.pipe'
import { StoragePathNamePipe } from 'src/app/pipes/storage-path-name.pipe'
import { UsernamePipe } from 'src/app/pipes/username.pipe'
import { ConsumerStatusService } from 'src/app/services/consumer-status.service'
import { DocumentListViewService } from 'src/app/services/document-list-view.service'
import { HotKeyService } from 'src/app/services/hot-key.service'
import { OpenDocumentsService } from 'src/app/services/open-documents.service'
@@ -51,6 +50,7 @@ import { PermissionsService } from 'src/app/services/permissions.service'
import { SavedViewService } from 'src/app/services/rest/saved-view.service'
import { SettingsService } from 'src/app/services/settings.service'
import { ToastService } from 'src/app/services/toast.service'
import { WebsocketStatusService } from 'src/app/services/websocket-status.service'
import {
filterRulesDiffer,
isFullTextFilterRule,
@@ -113,7 +113,7 @@ export class DocumentListComponent
private router: Router,
private toastService: ToastService,
private modalService: NgbModal,
private consumerStatusService: ConsumerStatusService,
private websocketStatusService: WebsocketStatusService,
public openDocumentsService: OpenDocumentsService,
public settingsService: SettingsService,
private hotKeyService: HotKeyService,
@@ -234,13 +234,17 @@ export class DocumentListComponent
}
ngOnInit(): void {
this.consumerStatusService
this.websocketStatusService
.onDocumentConsumptionFinished()
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe(() => {
this.list.reload()
})
this.websocketStatusService.onDocumentDeleted().subscribe(() => {
this.list.reload()
})
this.route.paramMap
.pipe(
filter((params) => params.has('id')), // only on saved view e.g. /view/id

View File

@@ -94,11 +94,11 @@
<pngx-dates-dropdown class="flex-fill fade" [class.show]="show"
title="Dates" i18n-title
(datesSet)="updateRules()"
[(createdDateBefore)]="dateCreatedBefore"
[(createdDateAfter)]="dateCreatedAfter"
[(createdDateTo)]="dateCreatedTo"
[(createdDateFrom)]="dateCreatedFrom"
[(createdRelativeDate)]="dateCreatedRelativeDate"
[(addedDateBefore)]="dateAddedBefore"
[(addedDateAfter)]="dateAddedAfter"
[(addedDateTo)]="dateAddedTo"
[(addedDateFrom)]="dateAddedFrom"
[(addedRelativeDate)]="dateAddedRelativeDate">
</pngx-dates-dropdown>
<pngx-permissions-filter-dropdown class="flex-fill fade" [class.show]="show"

View File

@@ -32,6 +32,8 @@ import { DocumentType } from 'src/app/data/document-type'
import {
FILTER_ADDED_AFTER,
FILTER_ADDED_BEFORE,
FILTER_ADDED_FROM,
FILTER_ADDED_TO,
FILTER_ASN,
FILTER_ASN_GT,
FILTER_ASN_ISNULL,
@@ -39,6 +41,8 @@ import {
FILTER_CORRESPONDENT,
FILTER_CREATED_AFTER,
FILTER_CREATED_BEFORE,
FILTER_CREATED_FROM,
FILTER_CREATED_TO,
FILTER_CUSTOM_FIELDS_QUERY,
FILTER_CUSTOM_FIELDS_TEXT,
FILTER_DOCUMENT_TYPE,
@@ -465,48 +469,92 @@ describe('FilterEditorComponent', () => {
])
}))
it('should ingest filter rules for date created after', fakeAsync(() => {
expect(component.dateCreatedAfter).toBeNull()
it('should ingest filter rules for date created after and adjust date by 1 day', fakeAsync(() => {
expect(component.dateCreatedFrom).toBeNull()
component.filterRules = [
{
rule_type: FILTER_CREATED_AFTER,
value: '2023-05-14',
},
]
expect(component.dateCreatedAfter).toEqual('2023-05-14')
expect(component.dateCreatedFrom).toEqual('2023-05-15')
}))
it('should ingest filter rules for date created before', fakeAsync(() => {
expect(component.dateCreatedBefore).toBeNull()
it('should ingest filter rules for date created from', fakeAsync(() => {
expect(component.dateCreatedFrom).toBeNull()
component.filterRules = [
{
rule_type: FILTER_CREATED_FROM,
value: '2023-05-14',
},
]
expect(component.dateCreatedFrom).toEqual('2023-05-14')
}))
it('should ingest filter rules for date created before and adjust date by 1 day', fakeAsync(() => {
expect(component.dateCreatedTo).toBeNull()
component.filterRules = [
{
rule_type: FILTER_CREATED_BEFORE,
value: '2023-05-14',
},
]
expect(component.dateCreatedBefore).toEqual('2023-05-14')
expect(component.dateCreatedTo).toEqual('2023-05-13')
}))
it('should ingest filter rules for date added after', fakeAsync(() => {
expect(component.dateAddedAfter).toBeNull()
it('should ingest filter rules for date created to', fakeAsync(() => {
expect(component.dateCreatedTo).toBeNull()
component.filterRules = [
{
rule_type: FILTER_CREATED_TO,
value: '2023-05-14',
},
]
expect(component.dateCreatedTo).toEqual('2023-05-14')
}))
it('should ingest filter rules for date added after and adjust date by 1 day', fakeAsync(() => {
expect(component.dateAddedFrom).toBeNull()
component.filterRules = [
{
rule_type: FILTER_ADDED_AFTER,
value: '2023-05-14',
},
]
expect(component.dateAddedAfter).toEqual('2023-05-14')
expect(component.dateAddedFrom).toEqual('2023-05-15')
}))
it('should ingest filter rules for date added before', fakeAsync(() => {
expect(component.dateAddedBefore).toBeNull()
it('should ingest filter rules for date added from', fakeAsync(() => {
expect(component.dateAddedFrom).toBeNull()
component.filterRules = [
{
rule_type: FILTER_ADDED_FROM,
value: '2023-05-14',
},
]
expect(component.dateAddedFrom).toEqual('2023-05-14')
}))
it('should ingest filter rules for date added before and adjust date by 1 day', fakeAsync(() => {
expect(component.dateAddedTo).toBeNull()
component.filterRules = [
{
rule_type: FILTER_ADDED_BEFORE,
value: '2023-05-14',
},
]
expect(component.dateAddedBefore).toEqual('2023-05-14')
expect(component.dateAddedTo).toEqual('2023-05-13')
}))
it('should ingest filter rules for date added to', fakeAsync(() => {
expect(component.dateAddedTo).toBeNull()
component.filterRules = [
{
rule_type: FILTER_ADDED_TO,
value: '2023-05-14',
},
]
expect(component.dateAddedTo).toEqual('2023-05-14')
}))
it('should ingest filter rules for has all tags', fakeAsync(() => {
@@ -1464,7 +1512,7 @@ describe('FilterEditorComponent', () => {
])
}))
it('should convert user input to correct filter rules on date created after', fakeAsync(() => {
it('should convert user input to correct filter rules on date created from', fakeAsync(() => {
const dateCreatedDropdown = fixture.debugElement.queryAll(
By.directive(DatesDropdownComponent)
)[0]
@@ -1473,18 +1521,18 @@ describe('FilterEditorComponent', () => {
dateCreatedAfter.nativeElement.value = '05/14/2023'
// dateCreatedAfter.triggerEventHandler('change')
// TODO: why isn't ngModel triggering this on change?
component.dateCreatedAfter = '2023-05-14'
component.dateCreatedFrom = '2023-05-14'
fixture.detectChanges()
tick(400)
expect(component.filterRules).toEqual([
{
rule_type: FILTER_CREATED_AFTER,
rule_type: FILTER_CREATED_FROM,
value: '2023-05-14',
},
])
}))
it('should convert user input to correct filter rules on date created before', fakeAsync(() => {
it('should convert user input to correct filter rules on date created to', fakeAsync(() => {
const dateCreatedDropdown = fixture.debugElement.queryAll(
By.directive(DatesDropdownComponent)
)[0]
@@ -1493,12 +1541,12 @@ describe('FilterEditorComponent', () => {
dateCreatedBefore.nativeElement.value = '05/14/2023'
// dateCreatedBefore.triggerEventHandler('change')
// TODO: why isn't ngModel triggering this on change?
component.dateCreatedBefore = '2023-05-14'
component.dateCreatedTo = '2023-05-14'
fixture.detectChanges()
tick(400)
expect(component.filterRules).toEqual([
{
rule_type: FILTER_CREATED_BEFORE,
rule_type: FILTER_CREATED_TO,
value: '2023-05-14',
},
])
@@ -1578,12 +1626,12 @@ describe('FilterEditorComponent', () => {
dateAddedAfter.nativeElement.value = '05/14/2023'
// dateAddedAfter.triggerEventHandler('change')
// TODO: why isn't ngModel triggering this on change?
component.dateAddedAfter = '2023-05-14'
component.dateAddedFrom = '2023-05-14'
fixture.detectChanges()
tick(400)
expect(component.filterRules).toEqual([
{
rule_type: FILTER_ADDED_AFTER,
rule_type: FILTER_ADDED_FROM,
value: '2023-05-14',
},
])
@@ -1598,12 +1646,12 @@ describe('FilterEditorComponent', () => {
dateAddedBefore.nativeElement.value = '05/14/2023'
// dateAddedBefore.triggerEventHandler('change')
// TODO: why isn't ngModel triggering this on change?
component.dateAddedBefore = '2023-05-14'
component.dateAddedTo = '2023-05-14'
fixture.detectChanges()
tick(400)
expect(component.filterRules).toEqual([
{
rule_type: FILTER_ADDED_BEFORE,
rule_type: FILTER_ADDED_TO,
value: '2023-05-14',
},
])

View File

@@ -38,6 +38,8 @@ import { FilterRule } from 'src/app/data/filter-rule'
import {
FILTER_ADDED_AFTER,
FILTER_ADDED_BEFORE,
FILTER_ADDED_FROM,
FILTER_ADDED_TO,
FILTER_ASN,
FILTER_ASN_GT,
FILTER_ASN_ISNULL,
@@ -45,6 +47,8 @@ import {
FILTER_CORRESPONDENT,
FILTER_CREATED_AFTER,
FILTER_CREATED_BEFORE,
FILTER_CREATED_FROM,
FILTER_CREATED_TO,
FILTER_CUSTOM_FIELDS_QUERY,
FILTER_CUSTOM_FIELDS_TEXT,
FILTER_DOCUMENT_TYPE,
@@ -133,19 +137,19 @@ const RELATIVE_DATE_QUERY_REGEXP_CREATED = /created:\[([^\]]+)\]/g
const RELATIVE_DATE_QUERY_REGEXP_ADDED = /added:\[([^\]]+)\]/g
const RELATIVE_DATE_QUERYSTRINGS = [
{
relativeDate: RelativeDate.LAST_7_DAYS,
relativeDate: RelativeDate.WITHIN_1_WEEK,
dateQuery: '-1 week to now',
},
{
relativeDate: RelativeDate.LAST_MONTH,
relativeDate: RelativeDate.WITHIN_1_MONTH,
dateQuery: '-1 month to now',
},
{
relativeDate: RelativeDate.LAST_3_MONTHS,
relativeDate: RelativeDate.WITHIN_3_MONTHS,
dateQuery: '-3 month to now',
},
{
relativeDate: RelativeDate.LAST_YEAR,
relativeDate: RelativeDate.WITHIN_1_YEAR,
dateQuery: '-1 year to now',
},
]
@@ -349,10 +353,10 @@ export class FilterEditorComponent
storagePathSelectionModel = new FilterableDropdownSelectionModel()
customFieldQueriesModel = new CustomFieldQueriesModel()
dateCreatedBefore: string
dateCreatedAfter: string
dateAddedBefore: string
dateAddedAfter: string
dateCreatedTo: string
dateCreatedFrom: string
dateAddedTo: string
dateAddedFrom: string
dateCreatedRelativeDate: RelativeDate
dateAddedRelativeDate: RelativeDate
@@ -385,10 +389,10 @@ export class FilterEditorComponent
this.customFieldQueriesModel.clear(false)
this._textFilter = null
this._moreLikeId = null
this.dateAddedBefore = null
this.dateAddedAfter = null
this.dateCreatedBefore = null
this.dateCreatedAfter = null
this.dateAddedTo = null
this.dateAddedFrom = null
this.dateCreatedTo = null
this.dateCreatedFrom = null
this.dateCreatedRelativeDate = null
this.dateAddedRelativeDate = null
this.textFilterModifier = TEXT_FILTER_MODIFIER_EQUALS
@@ -458,16 +462,40 @@ export class FilterEditorComponent
})
break
case FILTER_CREATED_AFTER:
this.dateCreatedAfter = rule.value
// Old rules require adjusting date by a day
const createdAfter = new Date(rule.value)
createdAfter.setDate(createdAfter.getDate() + 1)
this.dateCreatedFrom = createdAfter.toISOString().split('T')[0]
break
case FILTER_CREATED_BEFORE:
this.dateCreatedBefore = rule.value
// Old rules require adjusting date by a day
const createdBefore = new Date(rule.value)
createdBefore.setDate(createdBefore.getDate() - 1)
this.dateCreatedTo = createdBefore.toISOString().split('T')[0]
break
case FILTER_ADDED_AFTER:
this.dateAddedAfter = rule.value
// Old rules require adjusting date by a day
const addedAfter = new Date(rule.value)
addedAfter.setDate(addedAfter.getDate() + 1)
this.dateAddedFrom = addedAfter.toISOString().split('T')[0]
break
case FILTER_ADDED_BEFORE:
this.dateAddedBefore = rule.value
// Old rules require adjusting date by a day
const addedBefore = new Date(rule.value)
addedBefore.setDate(addedBefore.getDate() - 1)
this.dateAddedTo = addedBefore.toISOString().split('T')[0]
break
case FILTER_CREATED_FROM:
this.dateCreatedFrom = rule.value
break
case FILTER_CREATED_TO:
this.dateCreatedTo = rule.value
break
case FILTER_ADDED_FROM:
this.dateAddedFrom = rule.value
break
case FILTER_ADDED_TO:
this.dateAddedTo = rule.value
break
case FILTER_HAS_TAGS_ALL:
this.tagSelectionModel.logicalOperator = LogicalOperator.And
@@ -814,28 +842,28 @@ export class FilterEditorComponent
value: JSON.stringify(queries[0]),
})
}
if (this.dateCreatedBefore) {
if (this.dateCreatedTo) {
filterRules.push({
rule_type: FILTER_CREATED_BEFORE,
value: this.dateCreatedBefore,
rule_type: FILTER_CREATED_TO,
value: this.dateCreatedTo,
})
}
if (this.dateCreatedAfter) {
if (this.dateCreatedFrom) {
filterRules.push({
rule_type: FILTER_CREATED_AFTER,
value: this.dateCreatedAfter,
rule_type: FILTER_CREATED_FROM,
value: this.dateCreatedFrom,
})
}
if (this.dateAddedBefore) {
if (this.dateAddedTo) {
filterRules.push({
rule_type: FILTER_ADDED_BEFORE,
value: this.dateAddedBefore,
rule_type: FILTER_ADDED_TO,
value: this.dateAddedTo,
})
}
if (this.dateAddedAfter) {
if (this.dateAddedFrom) {
filterRules.push({
rule_type: FILTER_ADDED_AFTER,
value: this.dateAddedAfter,
rule_type: FILTER_ADDED_FROM,
value: this.dateAddedFrom,
})
}
if (

View File

@@ -108,13 +108,16 @@ export class CustomFieldsComponent
this.customFieldsService.delete(field).subscribe({
next: () => {
modal.close()
this.toastService.showInfo($localize`Deleted field`)
this.toastService.showInfo($localize`Deleted field "${field.name}"`)
this.customFieldsService.clearCache()
this.settingsService.initializeDisplayFields()
this.reload()
},
error: (e) => {
this.toastService.showError($localize`Error deleting field.`, e)
this.toastService.showError(
$localize`Error deleting field "${field.name}".`,
e
)
},
})
})

View File

@@ -214,7 +214,7 @@ describe('MailComponent', () => {
deleteSpy.mockReturnValueOnce(of(true))
deleteDialog.confirm()
expect(listAllSpy).toHaveBeenCalled()
expect(toastInfoSpy).toHaveBeenCalledWith('Deleted mail account')
expect(toastInfoSpy).toHaveBeenCalledWith('Deleted mail account "account1"')
})
it('should support process mail account, show error if needed', () => {
@@ -231,7 +231,9 @@ describe('MailComponent', () => {
expect(toastErrorSpy).toHaveBeenCalled()
processSpy.mockReturnValueOnce(of(true))
component.processAccount(mailAccounts[0] as MailAccount)
expect(toastInfoSpy).toHaveBeenCalledWith('Processing mail account')
expect(toastInfoSpy).toHaveBeenCalledWith(
'Processing mail account "account1"'
)
})
it('should support edit / create mail rule, show error if needed', () => {
@@ -274,14 +276,14 @@ describe('MailComponent', () => {
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
const listAllSpy = jest.spyOn(mailRuleService, 'listAll')
deleteSpy.mockReturnValueOnce(
throwError(() => new Error('error deleting mail rule'))
throwError(() => new Error('error deleting mail rule "rule1"'))
)
deleteDialog.confirm()
expect(toastErrorSpy).toBeCalled()
deleteSpy.mockReturnValueOnce(of(true))
deleteDialog.confirm()
expect(listAllSpy).toHaveBeenCalled()
expect(toastInfoSpy).toHaveBeenCalledWith('Deleted mail rule')
expect(toastInfoSpy).toHaveBeenCalledWith('Deleted mail rule "rule1"')
})
it('should support edit permissions on mail rule objects', () => {

View File

@@ -200,7 +200,9 @@ export class MailComponent
this.mailAccountService.delete(account).subscribe({
next: () => {
modal.close()
this.toastService.showInfo($localize`Deleted mail account`)
this.toastService.showInfo(
$localize`Deleted mail account "${account.name}"`
)
this.mailAccountService.clearCache()
this.mailAccountService
.listAll(null, null, { full_perms: true })
@@ -210,7 +212,7 @@ export class MailComponent
},
error: (e) => {
this.toastService.showError(
$localize`Error deleting mail account.`,
$localize`Error deleting mail account "${account.name}".`,
e
)
},
@@ -221,10 +223,15 @@ export class MailComponent
processAccount(account: MailAccount) {
this.mailAccountService.processAccount(account).subscribe({
next: () => {
this.toastService.showInfo($localize`Processing mail account`)
this.toastService.showInfo(
$localize`Processing mail account "${account.name}"`
)
},
error: (e) => {
this.toastService.showError($localize`Error processing mail account`, e)
this.toastService.showError(
$localize`Error processing mail account "${account.name}"`,
e
)
},
})
}
@@ -272,7 +279,10 @@ export class MailComponent
)
},
error: (e) => {
this.toastService.showError($localize`Error toggling rule.`, e)
this.toastService.showError(
$localize`Error toggling rule "${rule.name}".`,
e
)
},
})
}
@@ -291,7 +301,9 @@ export class MailComponent
this.mailRuleService.delete(rule).subscribe({
next: () => {
modal.close()
this.toastService.showInfo($localize`Deleted mail rule`)
this.toastService.showInfo(
$localize`Deleted mail rule "${rule.name}"`
)
this.mailRuleService.clearCache()
this.mailRuleService
.listAll(null, null, { full_perms: true })
@@ -300,7 +312,10 @@ export class MailComponent
})
},
error: (e) => {
this.toastService.showError($localize`Error deleting mail rule.`, e)
this.toastService.showError(
$localize`Error deleting mail rule "${rule.name}".`,
e
)
},
})
})

View File

@@ -73,35 +73,37 @@
</td>
}
<td scope="row">
<div class="btn-group d-block d-sm-none">
<div ngbDropdown container="body" class="d-inline-block">
<button type="button" class="btn btn-link" id="actionsMenuMobile" (click)="$event.stopPropagation()" ngbDropdownToggle>
<i-bs name="three-dots-vertical"></i-bs>
</button>
<div ngbDropdownMenu aria-labelledby="actionsMenuMobile">
<button (click)="openEditDialog(object)" *pngxIfPermissions="{ action: PermissionAction.Change, type: permissionType }" ngbDropdownItem i18n>Edit</button>
<button class="text-danger" (click)="openDeleteDialog(object)" *pngxIfPermissions="{ action: PermissionAction.Delete, type: permissionType }" ngbDropdownItem i18n>Delete</button>
@if (object.document_count > 0) {
<button (click)="filterDocuments(object)" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }" ngbDropdownItem i18n>Filter Documents ({{ object.document_count }})</button>
}
<div class="btn-toolbar gap-2">
<div class="btn-group d-block d-sm-none">
<div ngbDropdown container="body" class="d-inline-block">
<button type="button" class="btn btn-link" id="actionsMenuMobile" (click)="$event.stopPropagation()" ngbDropdownToggle>
<i-bs name="three-dots-vertical"></i-bs>
</button>
<div ngbDropdownMenu aria-labelledby="actionsMenuMobile">
<button (click)="openEditDialog(object)" *pngxIfPermissions="{ action: PermissionAction.Change, type: permissionType }" ngbDropdownItem i18n>Edit</button>
<button class="text-danger" (click)="openDeleteDialog(object)" *pngxIfPermissions="{ action: PermissionAction.Delete, type: permissionType }" ngbDropdownItem i18n>Delete</button>
@if (object.document_count > 0) {
<button (click)="filterDocuments(object)" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }" ngbDropdownItem i18n>Filter Documents ({{ object.document_count }})</button>
}
</div>
</div>
</div>
</div>
<div class="btn-group d-none d-sm-inline-block">
<button class="btn btn-sm btn-outline-secondary" (click)="openEditDialog(object); $event.stopPropagation();" *pngxIfPermissions="{ action: PermissionAction.Change, type: permissionType }" [disabled]="!userCanEdit(object)">
<i-bs width="1em" height="1em" name="pencil"></i-bs>&nbsp;<ng-container i18n>Edit</ng-container>
</button>
<button class="btn btn-sm btn-outline-danger" (click)="openDeleteDialog(object); $event.stopPropagation();" *pngxIfPermissions="{ action: PermissionAction.Delete, type: permissionType }" [disabled]="!userCanDelete(object)">
<i-bs width="1em" height="1em" name="trash"></i-bs>&nbsp;<ng-container i18n>Delete</ng-container>
</button>
</div>
@if (object.document_count > 0) {
<div class="btn-group d-none d-sm-inline-block ms-2">
<button class="btn btn-sm btn-outline-secondary" (click)="filterDocuments(object); $event.stopPropagation();" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }">
<i-bs width="1em" height="1em" name="filter"></i-bs>&nbsp;<ng-container i18n>Documents</ng-container><span class="badge bg-light text-secondary ms-2">{{ object.document_count }}</span>
<div class="btn-group d-none d-sm-inline-block">
<button class="btn btn-sm btn-outline-secondary" (click)="openEditDialog(object); $event.stopPropagation();" *pngxIfPermissions="{ action: PermissionAction.Change, type: permissionType }" [disabled]="!userCanEdit(object)">
<i-bs width="1em" height="1em" name="pencil"></i-bs>&nbsp;<ng-container i18n>Edit</ng-container>
</button>
<button class="btn btn-sm btn-outline-danger" (click)="openDeleteDialog(object); $event.stopPropagation();" *pngxIfPermissions="{ action: PermissionAction.Delete, type: permissionType }" [disabled]="!userCanDelete(object)">
<i-bs width="1em" height="1em" name="trash"></i-bs>&nbsp;<ng-container i18n>Delete</ng-container>
</button>
</div>
}
@if (object.document_count > 0) {
<div class="btn-group d-none d-sm-inline-block">
<button class="btn btn-sm btn-outline-secondary" (click)="filterDocuments(object); $event.stopPropagation();" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }">
<i-bs width="1em" height="1em" name="filter"></i-bs>&nbsp;<ng-container i18n>Documents</ng-container><span class="badge bg-light text-secondary ms-2">{{ object.document_count }}</span>
</button>
</div>
}
</div>
</td>
</tr>
}

View File

@@ -21,7 +21,6 @@ import {
MATCHING_ALGORITHMS,
MatchingModel,
} from 'src/app/data/matching-model'
import { ObjectWithId } from 'src/app/data/object-with-id'
import { ObjectWithPermissions } from 'src/app/data/object-with-permissions'
import {
SortableDirective,
@@ -56,7 +55,7 @@ export interface ManagementListColumn {
}
@Directive()
export abstract class ManagementListComponent<T extends ObjectWithId>
export abstract class ManagementListComponent<T extends MatchingModel>
extends LoadingComponentWithPermissions
implements OnInit, OnDestroy
{
@@ -195,7 +194,7 @@ export abstract class ManagementListComponent<T extends ObjectWithId>
activeModal.componentInstance.succeeded.subscribe(() => {
this.reloadData()
this.toastService.showInfo(
$localize`Successfully updated ${this.typeName}.`
$localize`Successfully updated ${this.typeName} "${object.name}".`
)
})
activeModal.componentInstance.failed.subscribe((e) => {
@@ -208,7 +207,7 @@ export abstract class ManagementListComponent<T extends ObjectWithId>
abstract getDeleteMessage(object: T)
filterDocuments(object: ObjectWithId) {
filterDocuments(object: MatchingModel) {
this.documentListViewService.quickFilter([
{ rule_type: this.filterRuleType, value: object.id.toString() },
])

View File

@@ -142,12 +142,17 @@ export class WorkflowsComponent
this.workflowService.delete(workflow).subscribe({
next: () => {
modal.close()
this.toastService.showInfo($localize`Deleted workflow`)
this.toastService.showInfo(
$localize`Deleted workflow "${workflow.name}".`
)
this.workflowService.clearCache()
this.reload()
},
error: (e) => {
this.toastService.showError($localize`Error deleting workflow.`, e)
this.toastService.showError(
$localize`Error deleting workflow "${workflow.name}".`,
e
)
},
})
})
@@ -158,14 +163,17 @@ export class WorkflowsComponent
next: () => {
this.toastService.showInfo(
workflow.enabled
? $localize`Enabled workflow`
: $localize`Disabled workflow`
? $localize`Enabled workflow "${workflow.name}"`
: $localize`Disabled workflow "${workflow.name}"`
)
this.workflowService.clearCache()
this.reload()
},
error: (e) => {
this.toastService.showError($localize`Error toggling workflow.`, e)
this.toastService.showError(
$localize`Error toggling workflow "${workflow.name}".`,
e
)
},
})
}

View File

@@ -36,6 +36,11 @@ export const FILTER_CREATED_DAY = 12
export const FILTER_ADDED_BEFORE = 13
export const FILTER_ADDED_AFTER = 14
export const FILTER_CREATED_TO = 43
export const FILTER_CREATED_FROM = 44
export const FILTER_ADDED_TO = 45
export const FILTER_ADDED_FROM = 46
export const FILTER_MODIFIED_BEFORE = 15
export const FILTER_MODIFIED_AFTER = 16
@@ -179,6 +184,18 @@ export const FILTER_RULE_TYPES: FilterRuleType[] = [
datatype: 'date',
multi: false,
},
{
id: FILTER_CREATED_TO,
filtervar: 'created__date__lte',
datatype: 'date',
multi: false,
},
{
id: FILTER_CREATED_FROM,
filtervar: 'created__date__gte',
datatype: 'date',
multi: false,
},
{
id: FILTER_CREATED_YEAR,
filtervar: 'created__year',
@@ -210,6 +227,18 @@ export const FILTER_RULE_TYPES: FilterRuleType[] = [
datatype: 'date',
multi: false,
},
{
id: FILTER_ADDED_TO,
filtervar: 'added__date__lte',
datatype: 'date',
multi: false,
},
{
id: FILTER_ADDED_FROM,
filtervar: 'added__date__gte',
datatype: 'date',
multi: false,
},
{
id: FILTER_MODIFIED_BEFORE,
filtervar: 'modified__date__lt',

View File

@@ -33,6 +33,8 @@ export const SETTINGS_KEYS = {
DARK_MODE_THUMB_INVERTED: 'general-settings:dark-mode:thumb-inverted',
THEME_COLOR: 'general-settings:theme:color',
USE_NATIVE_PDF_VIEWER: 'general-settings:document-details:native-pdf-viewer',
PDF_VIEWER_ZOOM_SETTING:
'general-settings:document-details:pdf-viewer-zoom-setting',
DATE_LOCALE: 'general-settings:date-display:date-locale',
DATE_FORMAT: 'general-settings:date-display:date-format',
NOTIFICATIONS_CONSUMER_NEW_DOCUMENT:
@@ -269,4 +271,9 @@ export const SETTINGS: UiSetting[] = [
type: 'boolean',
default: false,
},
{
key: SETTINGS_KEYS.PDF_VIEWER_ZOOM_SETTING,
type: 'string',
default: 'page-width', // ZoomSetting from 'document-detail.component'
},
]

View File

@@ -0,0 +1,3 @@
export interface WebsocketDocumentsDeletedMessage {
documents: number[]
}

View File

@@ -1,4 +1,4 @@
export interface WebsocketConsumerStatusMessage {
export interface WebsocketProgressMessage {
filename?: string
task_id?: string
current_progress?: number

View File

@@ -0,0 +1,102 @@
import { TestBed } from '@angular/core/testing'
import { ActivationStart, Router } from '@angular/router'
import { Subject } from 'rxjs'
import { ComponentRouterService } from './component-router.service'
describe('ComponentRouterService', () => {
let service: ComponentRouterService
let router: Router
let eventsSubject: Subject<any>
beforeEach(() => {
eventsSubject = new Subject<any>()
TestBed.configureTestingModule({
providers: [
ComponentRouterService,
{
provide: Router,
useValue: {
events: eventsSubject.asObservable(),
},
},
],
})
service = TestBed.inject(ComponentRouterService)
router = TestBed.inject(Router)
})
it('should add to history and componentHistory on ActivationStart event', () => {
eventsSubject.next(
new ActivationStart({
url: 'test-url',
component: { name: 'TestComponent' },
} as any)
)
expect((service as any).history).toEqual(['test-url'])
expect((service as any).componentHistory).toEqual(['TestComponent'])
})
it('should not add duplicate component names to componentHistory', () => {
eventsSubject.next(
new ActivationStart({
url: 'test-url-1',
component: { name: 'TestComponent' },
} as any)
)
eventsSubject.next(
new ActivationStart({
url: 'test-url-2',
component: { name: 'TestComponent' },
} as any)
)
expect((service as any).componentHistory.length).toBe(1)
expect((service as any).componentHistory).toEqual(['TestComponent'])
})
it('should return the URL of the component before the current one', () => {
eventsSubject.next(
new ActivationStart({
url: 'test-url-1',
component: { name: 'TestComponent1' },
} as any)
)
eventsSubject.next(
new ActivationStart({
url: 'test-url-2',
component: { name: 'TestComponent2' },
} as any)
)
expect(service.getComponentURLBefore()).toBe('test-url-1')
})
it('should update the URL of the current component if the same component is loaded via a different URL', () => {
eventsSubject.next(
new ActivationStart({
url: 'test-url-1',
component: { name: 'TestComponent' },
} as any)
)
eventsSubject.next(
new ActivationStart({
url: 'test-url-2',
component: { name: 'TestComponent' },
} as any)
)
expect((service as any).history).toEqual(['test-url-2'])
})
it('should return null if there is no previous component', () => {
eventsSubject.next(
new ActivationStart({
url: 'test-url',
component: { name: 'TestComponent' },
} as any)
)
expect(service.getComponentURLBefore()).toBeNull()
})
})

View File

@@ -0,0 +1,38 @@
import { Injectable } from '@angular/core'
import { ActivationStart, Event, Router } from '@angular/router'
import { filter } from 'rxjs'
const EXCLUDE_COMPONENTS = ['AppFrameComponent']
@Injectable({
providedIn: 'root',
})
export class ComponentRouterService {
private history: string[] = []
private componentHistory: any[] = []
constructor(private router: Router) {
this.router.events
.pipe(filter((event: Event) => event instanceof ActivationStart))
.subscribe((event: ActivationStart) => {
if (
this.componentHistory[this.componentHistory.length - 1] !==
event.snapshot.component.name &&
!EXCLUDE_COMPONENTS.includes(event.snapshot.component.name)
) {
this.history.push(event.snapshot.url.toString())
this.componentHistory.push(event.snapshot.component.name)
} else {
// Update the URL of the current component in case the same component was loaded via a different URL
this.history[this.history.length - 1] = event.snapshot.url.toString()
}
})
}
public getComponentURLBefore(): any {
if (this.componentHistory.length > 1) {
return this.history[this.history.length - 2]
}
return null
}
}

View File

@@ -1,326 +0,0 @@
import {
HttpEventType,
HttpResponse,
provideHttpClient,
withInterceptorsFromDi,
} from '@angular/common/http'
import {
HttpTestingController,
provideHttpClientTesting,
} from '@angular/common/http/testing'
import { TestBed } from '@angular/core/testing'
import WS from 'jest-websocket-mock'
import { environment } from 'src/environments/environment'
import {
ConsumerStatusService,
FILE_STATUS_MESSAGES,
FileStatusPhase,
} from './consumer-status.service'
import { DocumentService } from './rest/document.service'
import { SettingsService } from './settings.service'
describe('ConsumerStatusService', () => {
let httpTestingController: HttpTestingController
let consumerStatusService: ConsumerStatusService
let documentService: DocumentService
let settingsService: SettingsService
const server = new WS(
`${environment.webSocketProtocol}//${environment.webSocketHost}${environment.webSocketBaseUrl}status/`,
{ jsonProtocol: true }
)
beforeEach(() => {
TestBed.configureTestingModule({
imports: [],
providers: [
ConsumerStatusService,
DocumentService,
SettingsService,
provideHttpClient(withInterceptorsFromDi()),
provideHttpClientTesting(),
],
})
httpTestingController = TestBed.inject(HttpTestingController)
settingsService = TestBed.inject(SettingsService)
settingsService.currentUser = {
id: 1,
username: 'testuser',
is_superuser: false,
}
consumerStatusService = TestBed.inject(ConsumerStatusService)
documentService = TestBed.inject(DocumentService)
})
afterEach(() => {
httpTestingController.verify()
})
it('should update status on websocket processing progress', () => {
const task_id = '1234'
const status = consumerStatusService.newFileUpload('file.pdf')
expect(status.getProgress()).toEqual(0)
consumerStatusService.connect()
consumerStatusService
.onDocumentConsumptionFinished()
.subscribe((filestatus) => {
expect(filestatus.phase).toEqual(FileStatusPhase.SUCCESS)
})
consumerStatusService.onDocumentDetected().subscribe((filestatus) => {
expect(filestatus.phase).toEqual(FileStatusPhase.STARTED)
})
server.send({
task_id,
filename: 'file.pdf',
current_progress: 50,
max_progress: 100,
document_id: 12,
status: 'WORKING',
})
expect(status.getProgress()).toBeCloseTo(0.6) // (0.8 * 50/100) + .2
expect(consumerStatusService.getConsumerStatusNotCompleted()).toEqual([
status,
])
server.send({
task_id,
filename: 'file.pdf',
current_progress: 100,
max_progress: 100,
document_id: 12,
status: 'SUCCESS',
message: FILE_STATUS_MESSAGES.finished,
})
expect(status.getProgress()).toEqual(1)
expect(consumerStatusService.getConsumerStatusNotCompleted()).toHaveLength(
0
)
expect(consumerStatusService.getConsumerStatusCompleted()).toHaveLength(1)
consumerStatusService.disconnect()
})
it('should update status on websocket failed progress', () => {
const task_id = '1234'
const status = consumerStatusService.newFileUpload('file.pdf')
status.taskId = task_id
consumerStatusService.connect()
consumerStatusService
.onDocumentConsumptionFailed()
.subscribe((filestatus) => {
expect(filestatus.phase).toEqual(FileStatusPhase.FAILED)
})
server.send({
task_id,
filename: 'file.pdf',
current_progress: 50,
max_progress: 100,
document_id: 12,
})
expect(consumerStatusService.getConsumerStatusNotCompleted()).toEqual([
status,
])
server.send({
task_id,
filename: 'file.pdf',
current_progress: 50,
max_progress: 100,
document_id: 12,
status: 'FAILED',
message: FILE_STATUS_MESSAGES.document_already_exists,
})
expect(consumerStatusService.getConsumerStatusNotCompleted()).toHaveLength(
0
)
expect(consumerStatusService.getConsumerStatusCompleted()).toHaveLength(1)
})
it('should update status on upload progress', () => {
const task_id = '1234'
const status = consumerStatusService.newFileUpload('file.pdf')
documentService.uploadDocument({}).subscribe((event) => {
if (event.type === HttpEventType.Response) {
status.taskId = event.body['task_id']
status.message = $localize`Upload complete, waiting...`
} else if (event.type === HttpEventType.UploadProgress) {
status.updateProgress(
FileStatusPhase.UPLOADING,
event.loaded,
event.total
)
}
})
const req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/post_document/`
)
req.event(
new HttpResponse({
body: {
task_id,
},
})
)
req.event({
type: HttpEventType.UploadProgress,
loaded: 100,
total: 300,
})
expect(
consumerStatusService.getConsumerStatus(FileStatusPhase.UPLOADING)
).toEqual([status])
expect(consumerStatusService.getConsumerStatus()).toEqual([status])
expect(consumerStatusService.getConsumerStatusNotCompleted()).toEqual([
status,
])
req.event({
type: HttpEventType.UploadProgress,
loaded: 300,
total: 300,
})
expect(status.getProgress()).toEqual(0.2) // 0.2 * 300/300
})
it('should support dismiss completed', () => {
consumerStatusService.connect()
server.send({
task_id: '1234',
filename: 'file.pdf',
current_progress: 100,
max_progress: 100,
document_id: 12,
status: 'SUCCESS',
message: 'finished',
})
expect(consumerStatusService.getConsumerStatusCompleted()).toHaveLength(1)
consumerStatusService.dismissCompleted()
expect(consumerStatusService.getConsumerStatusCompleted()).toHaveLength(0)
consumerStatusService.disconnect()
})
it('should support dismiss', () => {
const task_id = '1234'
const status = consumerStatusService.newFileUpload('file.pdf')
status.taskId = task_id
status.updateProgress(FileStatusPhase.UPLOADING, 50, 100)
const status2 = consumerStatusService.newFileUpload('file2.pdf')
status2.updateProgress(FileStatusPhase.UPLOADING, 50, 100)
expect(
consumerStatusService.getConsumerStatus(FileStatusPhase.UPLOADING)
).toEqual([status, status2])
expect(consumerStatusService.getConsumerStatus()).toEqual([status, status2])
expect(consumerStatusService.getConsumerStatusNotCompleted()).toEqual([
status,
status2,
])
consumerStatusService.dismiss(status)
expect(consumerStatusService.getConsumerStatus()).toEqual([status2])
consumerStatusService.dismiss(status2)
expect(consumerStatusService.getConsumerStatus()).toHaveLength(0)
})
it('should support fail', () => {
const task_id = '1234'
const status = consumerStatusService.newFileUpload('file.pdf')
status.taskId = task_id
status.updateProgress(FileStatusPhase.UPLOADING, 50, 100)
expect(consumerStatusService.getConsumerStatusNotCompleted()).toHaveLength(
1
)
expect(consumerStatusService.getConsumerStatusCompleted()).toHaveLength(0)
consumerStatusService.fail(status, 'fail')
expect(consumerStatusService.getConsumerStatusNotCompleted()).toHaveLength(
0
)
expect(consumerStatusService.getConsumerStatusCompleted()).toHaveLength(1)
})
it('should notify of document created on status message without upload', () => {
let detected = false
consumerStatusService.onDocumentDetected().subscribe((filestatus) => {
expect(filestatus.phase).toEqual(FileStatusPhase.STARTED)
detected = true
})
consumerStatusService.connect()
server.send({
task_id: '1234',
filename: 'file.pdf',
current_progress: 0,
max_progress: 100,
message: 'new_file',
status: 'STARTED',
})
consumerStatusService.disconnect()
expect(detected).toBeTruthy()
})
it('should notify of document in progress without upload', () => {
consumerStatusService.connect()
server.send({
task_id: '1234',
filename: 'file.pdf',
current_progress: 50,
max_progress: 100,
docuement_id: 12,
status: 'WORKING',
})
consumerStatusService.disconnect()
expect(consumerStatusService.getConsumerStatusNotCompleted()).toHaveLength(
1
)
})
it('should not notify current user if document has different expected owner', () => {
consumerStatusService.connect()
server.send({
task_id: '1234',
filename: 'file1.pdf',
current_progress: 50,
max_progress: 100,
docuement_id: 12,
owner_id: 1,
status: 'WORKING',
})
server.send({
task_id: '5678',
filename: 'file2.pdf',
current_progress: 50,
max_progress: 100,
docuement_id: 13,
owner_id: 2,
status: 'WORKING',
})
consumerStatusService.disconnect()
expect(consumerStatusService.getConsumerStatusNotCompleted()).toHaveLength(
1
)
})
})

View File

@@ -25,6 +25,33 @@ describe('ToastService', () => {
})
})
it('adds a unique id to toast on show', () => {
const toast = {
title: 'Title',
content: 'content',
delay: 5000,
}
toastService.show(toast)
toastService.getToasts().subscribe((toasts) => {
expect(toasts[0].id).toBeDefined()
})
})
it('parses error string to object on show', () => {
const toast = {
title: 'Title',
content: 'content',
delay: 5000,
error: 'Error string',
}
toastService.show(toast)
toastService.getToasts().subscribe((toasts) => {
expect(toasts[0].error).toEqual('Error string')
})
})
it('creates toasts with defaults on showInfo and showError', () => {
toastService.showInfo('Info toast')
toastService.showError('Error toast')
@@ -54,4 +81,29 @@ describe('ToastService', () => {
expect(toasts).toHaveLength(0)
})
})
it('clears all toasts on clearToasts', () => {
toastService.showInfo('Info toast')
toastService.showError('Error toast')
toastService.clearToasts()
toastService.getToasts().subscribe((toasts) => {
expect(toasts).toHaveLength(0)
})
})
it('suppresses popup toasts if suppressPopupToasts is true', (finish) => {
toastService.showToast.subscribe((toast) => {
expect(toast).not.toBeNull()
})
toastService.showInfo('Info toast')
toastService.showToast.subscribe((toast) => {
expect(toast).toBeNull()
finish()
})
toastService.suppressPopupToasts = true
toastService.showInfo('Info toast')
})
})

View File

@@ -1,7 +1,10 @@
import { Injectable } from '@angular/core'
import { Subject } from 'rxjs'
import { v4 as uuidv4 } from 'uuid'
export interface Toast {
id?: string
content: string
delay: number
@@ -22,13 +25,32 @@ export interface Toast {
})
export class ToastService {
constructor() {}
_suppressPopupToasts: boolean
set suppressPopupToasts(value: boolean) {
this._suppressPopupToasts = value
this.showToast.next(null)
}
private toasts: Toast[] = []
private toastsSubject: Subject<Toast[]> = new Subject()
public showToast: Subject<Toast> = new Subject()
show(toast: Toast) {
this.toasts.push(toast)
if (!toast.id) {
toast.id = uuidv4()
}
if (typeof toast.error === 'string') {
try {
toast.error = JSON.parse(toast.error)
} catch (e) {}
}
this.toasts.unshift(toast)
if (!this._suppressPopupToasts) {
this.showToast.next(toast)
}
this.toastsSubject.next(this.toasts)
}
@@ -46,7 +68,7 @@ export class ToastService {
}
closeToast(toast: Toast) {
let index = this.toasts.findIndex((t) => t == toast)
let index = this.toasts.findIndex((t) => t.id == toast.id)
if (index > -1) {
this.toasts.splice(index, 1)
this.toastsSubject.next(this.toasts)
@@ -56,4 +78,10 @@ export class ToastService {
getToasts() {
return this.toastsSubject
}
clearToasts() {
this.toasts = []
this.toastsSubject.next(this.toasts)
this.showToast.next(null)
}
}

View File

@@ -9,11 +9,11 @@ import {
} from '@angular/common/http/testing'
import { TestBed } from '@angular/core/testing'
import { environment } from 'src/environments/environment'
import {
ConsumerStatusService,
FileStatusPhase,
} from './consumer-status.service'
import { UploadDocumentsService } from './upload-documents.service'
import {
FileStatusPhase,
WebsocketStatusService,
} from './websocket-status.service'
const files = [
{
@@ -45,14 +45,14 @@ const fileList = {
describe('UploadDocumentsService', () => {
let httpTestingController: HttpTestingController
let uploadDocumentsService: UploadDocumentsService
let consumerStatusService: ConsumerStatusService
let websocketStatusService: WebsocketStatusService
beforeEach(() => {
TestBed.configureTestingModule({
imports: [],
providers: [
UploadDocumentsService,
ConsumerStatusService,
WebsocketStatusService,
provideHttpClient(withInterceptorsFromDi()),
provideHttpClientTesting(),
],
@@ -60,7 +60,7 @@ describe('UploadDocumentsService', () => {
httpTestingController = TestBed.inject(HttpTestingController)
uploadDocumentsService = TestBed.inject(UploadDocumentsService)
consumerStatusService = TestBed.inject(ConsumerStatusService)
websocketStatusService = TestBed.inject(WebsocketStatusService)
})
afterEach(() => {
@@ -80,11 +80,11 @@ describe('UploadDocumentsService', () => {
it('updates progress during upload and failure', () => {
uploadDocumentsService.uploadFiles(fileList)
expect(consumerStatusService.getConsumerStatusNotCompleted()).toHaveLength(
expect(websocketStatusService.getConsumerStatusNotCompleted()).toHaveLength(
2
)
expect(
consumerStatusService.getConsumerStatus(FileStatusPhase.UPLOADING)
websocketStatusService.getConsumerStatus(FileStatusPhase.UPLOADING)
).toHaveLength(0)
const req = httpTestingController.match(
@@ -98,7 +98,7 @@ describe('UploadDocumentsService', () => {
})
expect(
consumerStatusService.getConsumerStatus(FileStatusPhase.UPLOADING)
websocketStatusService.getConsumerStatus(FileStatusPhase.UPLOADING)
).toHaveLength(1)
})
@@ -110,7 +110,7 @@ describe('UploadDocumentsService', () => {
)
expect(
consumerStatusService.getConsumerStatus(FileStatusPhase.FAILED)
websocketStatusService.getConsumerStatus(FileStatusPhase.FAILED)
).toHaveLength(0)
req[0].flush(
@@ -122,7 +122,7 @@ describe('UploadDocumentsService', () => {
)
expect(
consumerStatusService.getConsumerStatus(FileStatusPhase.FAILED)
websocketStatusService.getConsumerStatus(FileStatusPhase.FAILED)
).toHaveLength(1)
uploadDocumentsService.uploadFiles(fileList)
@@ -140,7 +140,7 @@ describe('UploadDocumentsService', () => {
)
expect(
consumerStatusService.getConsumerStatus(FileStatusPhase.FAILED)
websocketStatusService.getConsumerStatus(FileStatusPhase.FAILED)
).toHaveLength(2)
})

View File

@@ -2,11 +2,11 @@ import { HttpEventType } from '@angular/common/http'
import { Injectable } from '@angular/core'
import { FileSystemFileEntry, NgxFileDropEntry } from 'ngx-file-drop'
import { Subscription } from 'rxjs'
import {
ConsumerStatusService,
FileStatusPhase,
} from './consumer-status.service'
import { DocumentService } from './rest/document.service'
import {
FileStatusPhase,
WebsocketStatusService,
} from './websocket-status.service'
@Injectable({
providedIn: 'root',
@@ -16,7 +16,7 @@ export class UploadDocumentsService {
constructor(
private documentService: DocumentService,
private consumerStatusService: ConsumerStatusService
private websocketStatusService: WebsocketStatusService
) {}
onNgxFileDrop(files: NgxFileDropEntry[]) {
@@ -37,7 +37,7 @@ export class UploadDocumentsService {
private uploadFile(file: File) {
let formData = new FormData()
formData.append('document', file, file.name)
let status = this.consumerStatusService.newFileUpload(file.name)
let status = this.websocketStatusService.newFileUpload(file.name)
status.message = $localize`Connecting...`
@@ -61,11 +61,11 @@ export class UploadDocumentsService {
error: (error) => {
switch (error.status) {
case 400: {
this.consumerStatusService.fail(status, error.error.document)
this.websocketStatusService.fail(status, error.error.document)
break
}
default: {
this.consumerStatusService.fail(
this.websocketStatusService.fail(
status,
$localize`HTTP error: ${error.status} ${error.statusText}`
)

View File

@@ -0,0 +1,375 @@
import {
HttpEventType,
HttpResponse,
provideHttpClient,
withInterceptorsFromDi,
} from '@angular/common/http'
import {
HttpTestingController,
provideHttpClientTesting,
} from '@angular/common/http/testing'
import { TestBed } from '@angular/core/testing'
import WS from 'jest-websocket-mock'
import { environment } from 'src/environments/environment'
import { DocumentService } from './rest/document.service'
import { SettingsService } from './settings.service'
import {
FILE_STATUS_MESSAGES,
FileStatusPhase,
WebsocketStatusService,
WebsocketStatusType,
} from './websocket-status.service'
describe('ConsumerStatusService', () => {
let httpTestingController: HttpTestingController
let websocketStatusService: WebsocketStatusService
let documentService: DocumentService
let settingsService: SettingsService
const server = new WS(
`${environment.webSocketProtocol}//${environment.webSocketHost}${environment.webSocketBaseUrl}status/`,
{ jsonProtocol: true }
)
beforeEach(() => {
TestBed.configureTestingModule({
imports: [],
providers: [
WebsocketStatusService,
DocumentService,
SettingsService,
provideHttpClient(withInterceptorsFromDi()),
provideHttpClientTesting(),
],
})
httpTestingController = TestBed.inject(HttpTestingController)
settingsService = TestBed.inject(SettingsService)
settingsService.currentUser = {
id: 1,
username: 'testuser',
is_superuser: false,
}
websocketStatusService = TestBed.inject(WebsocketStatusService)
documentService = TestBed.inject(DocumentService)
})
afterEach(() => {
httpTestingController.verify()
})
it('should update status on websocket processing progress', () => {
const task_id = '1234'
const status = websocketStatusService.newFileUpload('file.pdf')
expect(status.getProgress()).toEqual(0)
websocketStatusService.connect()
websocketStatusService
.onDocumentConsumptionFinished()
.subscribe((filestatus) => {
expect(filestatus.phase).toEqual(FileStatusPhase.SUCCESS)
})
websocketStatusService.onDocumentDetected().subscribe((filestatus) => {
expect(filestatus.phase).toEqual(FileStatusPhase.STARTED)
})
server.send({
type: WebsocketStatusType.STATUS_UPDATE,
data: {
task_id,
filename: 'file.pdf',
current_progress: 50,
max_progress: 100,
document_id: 12,
status: 'WORKING',
},
})
expect(status.getProgress()).toBeCloseTo(0.6) // (0.8 * 50/100) + .2
expect(websocketStatusService.getConsumerStatusNotCompleted()).toEqual([
status,
])
server.send({
type: WebsocketStatusType.STATUS_UPDATE,
data: {
task_id,
filename: 'file.pdf',
current_progress: 100,
max_progress: 100,
document_id: 12,
status: 'SUCCESS',
message: FILE_STATUS_MESSAGES.finished,
},
})
expect(status.getProgress()).toEqual(1)
expect(websocketStatusService.getConsumerStatusNotCompleted()).toHaveLength(
0
)
expect(websocketStatusService.getConsumerStatusCompleted()).toHaveLength(1)
websocketStatusService.disconnect()
})
it('should update status on websocket failed progress', () => {
const task_id = '1234'
const status = websocketStatusService.newFileUpload('file.pdf')
status.taskId = task_id
websocketStatusService.connect()
websocketStatusService
.onDocumentConsumptionFailed()
.subscribe((filestatus) => {
expect(filestatus.phase).toEqual(FileStatusPhase.FAILED)
})
server.send({
type: WebsocketStatusType.STATUS_UPDATE,
data: {
task_id,
filename: 'file.pdf',
current_progress: 50,
max_progress: 100,
document_id: 12,
},
})
expect(websocketStatusService.getConsumerStatusNotCompleted()).toEqual([
status,
])
server.send({
type: WebsocketStatusType.STATUS_UPDATE,
data: {
task_id,
filename: 'file.pdf',
current_progress: 50,
max_progress: 100,
document_id: 12,
status: 'FAILED',
message: FILE_STATUS_MESSAGES.document_already_exists,
},
})
expect(websocketStatusService.getConsumerStatusNotCompleted()).toHaveLength(
0
)
expect(websocketStatusService.getConsumerStatusCompleted()).toHaveLength(1)
})
it('should update status on upload progress', () => {
const task_id = '1234'
const status = websocketStatusService.newFileUpload('file.pdf')
documentService.uploadDocument({}).subscribe((event) => {
if (event.type === HttpEventType.Response) {
status.taskId = event.body['task_id']
status.message = $localize`Upload complete, waiting...`
} else if (event.type === HttpEventType.UploadProgress) {
status.updateProgress(
FileStatusPhase.UPLOADING,
event.loaded,
event.total
)
}
})
const req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/post_document/`
)
req.event(
new HttpResponse({
body: {
task_id,
},
})
)
req.event({
type: HttpEventType.UploadProgress,
loaded: 100,
total: 300,
})
expect(
websocketStatusService.getConsumerStatus(FileStatusPhase.UPLOADING)
).toEqual([status])
expect(websocketStatusService.getConsumerStatus()).toEqual([status])
expect(websocketStatusService.getConsumerStatusNotCompleted()).toEqual([
status,
])
req.event({
type: HttpEventType.UploadProgress,
loaded: 300,
total: 300,
})
expect(status.getProgress()).toEqual(0.2) // 0.2 * 300/300
})
it('should support dismiss completed', () => {
websocketStatusService.connect()
server.send({
type: WebsocketStatusType.STATUS_UPDATE,
data: {
task_id: '1234',
filename: 'file.pdf',
current_progress: 100,
max_progress: 100,
document_id: 12,
status: 'SUCCESS',
message: 'finished',
},
})
expect(websocketStatusService.getConsumerStatusCompleted()).toHaveLength(1)
websocketStatusService.dismissCompleted()
expect(websocketStatusService.getConsumerStatusCompleted()).toHaveLength(0)
websocketStatusService.disconnect()
})
it('should support dismiss', () => {
const task_id = '1234'
const status = websocketStatusService.newFileUpload('file.pdf')
status.taskId = task_id
status.updateProgress(FileStatusPhase.UPLOADING, 50, 100)
const status2 = websocketStatusService.newFileUpload('file2.pdf')
status2.updateProgress(FileStatusPhase.UPLOADING, 50, 100)
expect(
websocketStatusService.getConsumerStatus(FileStatusPhase.UPLOADING)
).toEqual([status, status2])
expect(websocketStatusService.getConsumerStatus()).toEqual([
status,
status2,
])
expect(websocketStatusService.getConsumerStatusNotCompleted()).toEqual([
status,
status2,
])
websocketStatusService.dismiss(status)
expect(websocketStatusService.getConsumerStatus()).toEqual([status2])
websocketStatusService.dismiss(status2)
expect(websocketStatusService.getConsumerStatus()).toHaveLength(0)
})
it('should support fail', () => {
const task_id = '1234'
const status = websocketStatusService.newFileUpload('file.pdf')
status.taskId = task_id
status.updateProgress(FileStatusPhase.UPLOADING, 50, 100)
expect(websocketStatusService.getConsumerStatusNotCompleted()).toHaveLength(
1
)
expect(websocketStatusService.getConsumerStatusCompleted()).toHaveLength(0)
websocketStatusService.fail(status, 'fail')
expect(websocketStatusService.getConsumerStatusNotCompleted()).toHaveLength(
0
)
expect(websocketStatusService.getConsumerStatusCompleted()).toHaveLength(1)
})
it('should notify of document created on status message without upload', () => {
let detected = false
websocketStatusService.onDocumentDetected().subscribe((filestatus) => {
expect(filestatus.phase).toEqual(FileStatusPhase.STARTED)
detected = true
})
websocketStatusService.connect()
server.send({
type: WebsocketStatusType.STATUS_UPDATE,
data: {
task_id: '1234',
filename: 'file.pdf',
current_progress: 0,
max_progress: 100,
message: 'new_file',
status: 'STARTED',
},
})
websocketStatusService.disconnect()
expect(detected).toBeTruthy()
})
it('should notify of document in progress without upload', () => {
websocketStatusService.connect()
server.send({
type: WebsocketStatusType.STATUS_UPDATE,
data: {
task_id: '1234',
filename: 'file.pdf',
current_progress: 50,
max_progress: 100,
docuement_id: 12,
status: 'WORKING',
},
})
websocketStatusService.disconnect()
expect(websocketStatusService.getConsumerStatusNotCompleted()).toHaveLength(
1
)
})
it('should not notify current user if document has different expected owner', () => {
websocketStatusService.connect()
server.send({
type: WebsocketStatusType.STATUS_UPDATE,
data: {
task_id: '1234',
filename: 'file1.pdf',
current_progress: 50,
max_progress: 100,
docuement_id: 12,
owner_id: 1,
status: 'WORKING',
},
})
server.send({
type: WebsocketStatusType.STATUS_UPDATE,
data: {
task_id: '5678',
filename: 'file2.pdf',
current_progress: 50,
max_progress: 100,
docuement_id: 13,
owner_id: 2,
status: 'WORKING',
},
})
websocketStatusService.disconnect()
expect(websocketStatusService.getConsumerStatusNotCompleted()).toHaveLength(
1
)
})
it('should trigger deleted subject on document deleted', () => {
let deleted = false
websocketStatusService.onDocumentDeleted().subscribe(() => {
deleted = true
})
websocketStatusService.connect()
server.send({
type: WebsocketStatusType.DOCUMENTS_DELETED,
data: {
documents: [1, 2, 3],
},
})
websocketStatusService.disconnect()
expect(deleted).toBeTruthy()
})
})

View File

@@ -1,9 +1,15 @@
import { Injectable } from '@angular/core'
import { Subject } from 'rxjs'
import { environment } from 'src/environments/environment'
import { WebsocketConsumerStatusMessage } from '../data/websocket-consumer-status-message'
import { WebsocketDocumentsDeletedMessage } from '../data/websocket-documents-deleted-message'
import { WebsocketProgressMessage } from '../data/websocket-progress-message'
import { SettingsService } from './settings.service'
export enum WebsocketStatusType {
STATUS_UPDATE = 'status_update',
DOCUMENTS_DELETED = 'documents_deleted',
}
// see ProgressStatusOptions in src/documents/plugins/helpers.py
export enum FileStatusPhase {
STARTED = 0,
@@ -85,7 +91,7 @@ export class FileStatus {
@Injectable({
providedIn: 'root',
})
export class ConsumerStatusService {
export class WebsocketStatusService {
constructor(private settingsService: SettingsService) {}
private statusWebSocket: WebSocket
@@ -95,6 +101,7 @@ export class ConsumerStatusService {
private documentDetectedSubject = new Subject<FileStatus>()
private documentConsumptionFinishedSubject = new Subject<FileStatus>()
private documentConsumptionFailedSubject = new Subject<FileStatus>()
private documentDeletedSubject = new Subject<boolean>()
private get(taskId: string, filename?: string) {
let status =
@@ -145,63 +152,75 @@ export class ConsumerStatusService {
this.statusWebSocket = new WebSocket(
`${environment.webSocketProtocol}//${environment.webSocketHost}${environment.webSocketBaseUrl}status/`
)
this.statusWebSocket.onmessage = (ev) => {
let statusMessage: WebsocketConsumerStatusMessage = JSON.parse(ev['data'])
this.statusWebSocket.onmessage = (ev: MessageEvent) => {
const {
type,
data: messageData,
}: {
type: WebsocketStatusType
data: WebsocketProgressMessage | WebsocketDocumentsDeletedMessage
} = JSON.parse(ev.data)
// fallback if backend didn't restrict message
if (
statusMessage.owner_id &&
statusMessage.owner_id !== this.settingsService.currentUser?.id &&
!this.settingsService.currentUser?.is_superuser
) {
return
}
let statusMessageGet = this.get(
statusMessage.task_id,
statusMessage.filename
)
let status = statusMessageGet.status
let created = statusMessageGet.created
status.updateProgress(
FileStatusPhase.WORKING,
statusMessage.current_progress,
statusMessage.max_progress
)
if (
statusMessage.message &&
statusMessage.message in FILE_STATUS_MESSAGES
) {
status.message = FILE_STATUS_MESSAGES[statusMessage.message]
} else if (statusMessage.message) {
status.message = statusMessage.message
}
status.documentId = statusMessage.document_id
if (statusMessage.status in FileStatusPhase) {
status.phase = FileStatusPhase[statusMessage.status]
}
switch (status.phase) {
case FileStatusPhase.STARTED:
if (created) this.documentDetectedSubject.next(status)
switch (type) {
case WebsocketStatusType.DOCUMENTS_DELETED:
this.documentDeletedSubject.next(true)
break
case FileStatusPhase.SUCCESS:
this.documentConsumptionFinishedSubject.next(status)
break
case FileStatusPhase.FAILED:
this.documentConsumptionFailedSubject.next(status)
break
default:
case WebsocketStatusType.STATUS_UPDATE:
this.handleProgressUpdate(messageData as WebsocketProgressMessage)
break
}
}
}
handleProgressUpdate(messageData: WebsocketProgressMessage) {
// fallback if backend didn't restrict message
if (
messageData.owner_id &&
messageData.owner_id !== this.settingsService.currentUser?.id &&
!this.settingsService.currentUser?.is_superuser
) {
return
}
let statusMessageGet = this.get(messageData.task_id, messageData.filename)
let status = statusMessageGet.status
let created = statusMessageGet.created
status.updateProgress(
FileStatusPhase.WORKING,
messageData.current_progress,
messageData.max_progress
)
if (messageData.message && messageData.message in FILE_STATUS_MESSAGES) {
status.message = FILE_STATUS_MESSAGES[messageData.message]
} else if (messageData.message) {
status.message = messageData.message
}
status.documentId = messageData.document_id
if (messageData.status in FileStatusPhase) {
status.phase = FileStatusPhase[messageData.status]
}
switch (status.phase) {
case FileStatusPhase.STARTED:
if (created) this.documentDetectedSubject.next(status)
break
case FileStatusPhase.SUCCESS:
this.documentConsumptionFinishedSubject.next(status)
break
case FileStatusPhase.FAILED:
this.documentConsumptionFailedSubject.next(status)
break
default:
break
}
}
fail(status: FileStatus, message: string) {
status.message = message
status.phase = FileStatusPhase.FAILED
@@ -250,4 +269,8 @@ export class ConsumerStatusService {
onDocumentDetected() {
return this.documentDetectedSubject
}
onDocumentDeleted() {
return this.documentDeletedSubject
}
}

View File

@@ -5,7 +5,7 @@ export const environment = {
apiBaseUrl: document.baseURI + 'api/',
apiVersion: '7',
appTitle: 'Paperless-ngx',
version: '2.14.6',
version: '2.14.7',
webSocketHost: window.location.host,
webSocketProtocol: window.location.protocol == 'https:' ? 'wss:' : 'ws:',
webSocketBaseUrl: base_url.pathname + 'ws/',

View File

@@ -34,6 +34,7 @@ import {
arrowRightShort,
arrowUpRight,
asterisk,
bell,
bodyText,
boxArrowUp,
boxArrowUpRight,
@@ -235,6 +236,7 @@ const icons = {
arrowRightShort,
arrowUpRight,
asterisk,
bell,
braces,
bodyText,
boxArrowUp,

View File

@@ -570,6 +570,10 @@ table.table {
color: var(--bs-body-color);
}
.toast {
--bs-toast-max-width: var(--pngx-toast-max-width);
}
.alert-primary {
--bs-alert-color: var(--bs-primary);
--bs-alert-bg: var(--pngx-primary-faded);

View File

@@ -24,6 +24,10 @@
--pngx-bg-alt2: var(--bs-gray-200);
--pngx-bg-disabled: #f7f7f7;
--pngx-focus-alpha: 0.3;
--pngx-toast-max-width: 360px;
@media screen and (min-width: 1024px) {
--pngx-toast-max-width: 450px;
}
}
// Dark text colors allow for maintain contrast with theme color changes