Merge branch 'dev' into patch-1

This commit is contained in:
Trenton H 2025-02-07 09:41:50 -08:00 committed by GitHub
commit 4cb2b97b71
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
101 changed files with 2682 additions and 1613 deletions

View File

@ -51,7 +51,7 @@ repos:
- 'prettier-plugin-organize-imports@4.1.0' - 'prettier-plugin-organize-imports@4.1.0'
# Python hooks # Python hooks
- repo: https://github.com/astral-sh/ruff-pre-commit - repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.9.3 rev: v0.9.4
hooks: hooks:
- id: ruff - id: ruff
- id: ruff-format - id: ruff-format

View File

@ -32,6 +32,7 @@ extend-select = [
"RUF", # https://docs.astral.sh/ruff/rules/#ruff-specific-rules-ruf "RUF", # https://docs.astral.sh/ruff/rules/#ruff-specific-rules-ruf
"FLY", # https://docs.astral.sh/ruff/rules/#flynt-fly "FLY", # https://docs.astral.sh/ruff/rules/#flynt-fly
"PTH", # https://docs.astral.sh/ruff/rules/#flake8-use-pathlib-pth "PTH", # https://docs.astral.sh/ruff/rules/#flake8-use-pathlib-pth
"FBT", # https://docs.astral.sh/ruff/rules/#flake8-boolean-trap-fbt
] ]
ignore = ["DJ001", "SIM105", "RUF012"] ignore = ["DJ001", "SIM105", "RUF012"]

79
Pipfile.lock generated
View File

@ -1,7 +1,7 @@
{ {
"_meta": { "_meta": {
"hash": { "hash": {
"sha256": "3806c1dbfde8e9383e748c106c217170d6dcdbb8b95d573030b2294dab32d462" "sha256": "6a7869231917d0cf6f5852520b5cb9b0df3802ed162b1a8107d0b1e1c37f0535"
}, },
"pipfile-spec": 6, "pipfile-spec": 6,
"requires": {}, "requires": {},
@ -589,12 +589,12 @@
}, },
"django-soft-delete": { "django-soft-delete": {
"hashes": [ "hashes": [
"sha256:cc40398ccd869c75a6d6ba7f526e16c4afe2b0c0811c213a318d96bb4c58a787", "sha256:603a29e82bbb7a5bada69f2754fad225ccd8cd7f485320ec06d0fc4e9dfddcf0",
"sha256:fdaf2788d404930557f1300ce40bbd764f6938775a35a3175c66fe7778666093" "sha256:d2f9db449a4f008e9786f82fa4bafbe4075f7a0b3284844735007e988b2a4df6"
], ],
"index": "pypi", "index": "pypi",
"markers": "python_version >= '3.6'", "markers": "python_version >= '3.6'",
"version": "==1.0.16" "version": "==1.0.18"
}, },
"djangorestframework": { "djangorestframework": {
"hashes": [ "hashes": [
@ -2264,7 +2264,7 @@
"sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d", "sha256:04e5ca0351e0f3f85c6853954072df659d0d13fac324d0072316b67d7794700d",
"sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8" "sha256:1a7ead55c7e559dd4dee8856e3a88b41225abfe1ce8df57b7c13915fe121ffb8"
], ],
"markers": "python_version >= '3.8'", "markers": "python_version < '3.11'",
"version": "==4.12.2" "version": "==4.12.2"
}, },
"tzdata": { "tzdata": {
@ -2837,19 +2837,19 @@
}, },
"babel": { "babel": {
"hashes": [ "hashes": [
"sha256:368b5b98b37c06b7daf6696391c3240c938b37767d4584413e8438c5c435fa8b", "sha256:0c54cffb19f690cdcc52a3b50bcbf71e07a808d1c80d549f2459b9d2cf0afb9d",
"sha256:d1f3554ca26605fe173f3de0c65f750f5a42f924499bf134de6423582298e316" "sha256:4d0b53093fdfb4b21c92b5213dba5a1b23885afa8383709427046b21c366e5f2"
], ],
"markers": "python_version >= '3.8'", "markers": "python_version >= '3.8'",
"version": "==2.16.0" "version": "==2.17.0"
}, },
"certifi": { "certifi": {
"hashes": [ "hashes": [
"sha256:1275f7a45be9464efc1173084eaa30f866fe2e47d389406136d332ed4967ec56", "sha256:3d5da6925056f6f18f119200434a4780a94263f10d1c21d032a6f6b2baa20651",
"sha256:b650d30f370c2b724812bee08008be0c4163b163ddaec3f2546c1caf65f191db" "sha256:ca78db4565a652026a4db2bcdf68f2fb589ea80d0be70e03929ed730746b84fe"
], ],
"markers": "python_version >= '3.6'", "markers": "python_version >= '3.6'",
"version": "==2024.12.14" "version": "==2025.1.31"
}, },
"cffi": { "cffi": {
"hashes": [ "hashes": [
@ -3302,7 +3302,6 @@
"sha256:8fefff8dc3034e27bb80d67c671eb8a9bc424c0ef4c0826edbff304cceff43bb", "sha256:8fefff8dc3034e27bb80d67c671eb8a9bc424c0ef4c0826edbff304cceff43bb",
"sha256:aba0f4dc9ed8013c424088f68a5c226f7d6097ed89b246d7749c2ec4175c6adb" "sha256:aba0f4dc9ed8013c424088f68a5c226f7d6097ed89b246d7749c2ec4175c6adb"
], ],
"index": "pypi",
"markers": "python_version >= '3.7'", "markers": "python_version >= '3.7'",
"version": "==3.1.5" "version": "==3.1.5"
}, },
@ -3415,12 +3414,12 @@
}, },
"mkdocs-material": { "mkdocs-material": {
"hashes": [ "hashes": [
"sha256:ae5fe16f3d7c9ccd05bb6916a7da7420cf99a9ce5e33debd9d40403a090d5825", "sha256:71d90dbd63b393ad11a4d90151dfe3dcbfcd802c0f29ce80bebd9bbac6abc753",
"sha256:f24100f234741f4d423a9d672a909d859668a4f404796be3cf035f10d6050385" "sha256:a3de1c5d4c745f10afa78b1a02f917b9dce0808fb206adc0f5bb48b58c1ca21f"
], ],
"index": "pypi", "index": "pypi",
"markers": "python_version >= '3.8'", "markers": "python_version >= '3.8'",
"version": "==9.5.50" "version": "==9.6.2"
}, },
"mkdocs-material-extensions": { "mkdocs-material-extensions": {
"hashes": [ "hashes": [
@ -3658,11 +3657,11 @@
}, },
"pymdown-extensions": { "pymdown-extensions": {
"hashes": [ "hashes": [
"sha256:637951cbfbe9874ba28134fb3ce4b8bcadd6aca89ac4998ec29dcbafd554ae08", "sha256:05e0bee73d64b9c71a4ae17c72abc2f700e8bc8403755a00580b49a4e9f189e9",
"sha256:b65801996a0cd4f42a3110810c306c45b7313c09b0610a6f773730f2a9e3c96b" "sha256:41e576ce3f5d650be59e900e4ceff231e0aed2a88cf30acaee41e02f063a061b"
], ],
"markers": "python_version >= '3.8'", "markers": "python_version >= '3.8'",
"version": "==10.14.1" "version": "==10.14.3"
}, },
"pyopenssl": { "pyopenssl": {
"hashes": [ "hashes": [
@ -3757,8 +3756,7 @@
"sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3",
"sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427" "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427"
], ],
"index": "pypi", "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2'",
"version": "==2.9.0.post0" "version": "==2.9.0.post0"
}, },
"pywavelets": { "pywavelets": {
@ -3982,28 +3980,28 @@
}, },
"ruff": { "ruff": {
"hashes": [ "hashes": [
"sha256:006e5de2621304c8810bcd2ee101587712fa93b4f955ed0985907a36c427e0c2", "sha256:05bebf4cdbe3ef75430d26c375773978950bbf4ee3c95ccb5448940dc092408e",
"sha256:040ceb7f20791dfa0e78b4230ee9dce23da3b64dd5848e40e3bf3ab76468dcf4", "sha256:1d4c8772670aecf037d1bf7a07c39106574d143b26cfe5ed1787d2f31e800214",
"sha256:2dc153c25e715be41bb228bc651c1e9b1a88d5c6e5ed0194fa0dfea02b026439", "sha256:37c892540108314a6f01f105040b5106aeb829fa5fb0561d2dcaf71485021137",
"sha256:33866c3cc2a575cbd546f2cd02bdd466fed65118e4365ee538a3deffd6fcb730", "sha256:433dedf6ddfdec7f1ac7575ec1eb9844fa60c4c8c2f8887a070672b8d353d34c",
"sha256:5a5a46e09355695fbdbb30ed9889d6cf1c61b77b700a9fafc21b41f097bfbba4", "sha256:54499fb08408e32b57360f6f9de7157a5fec24ad79cb3f42ef2c3f3f728dfe2b",
"sha256:646909a1e25e0dc28fbc529eab8eb7bb583079628e8cbe738192853dbbe43af5", "sha256:56acd6c694da3695a7461cc55775f3a409c3815ac467279dfa126061d84b314b",
"sha256:7f39b879064c7d9670197d91124a75d118d00b0990586549949aae80cdc16624", "sha256:585792f1e81509e38ac5123492f8875fbc36f3ede8185af0a26df348e5154f41",
"sha256:800d773f6d4d33b0a3c60e2c6ae8f4c202ea2de056365acfa519aa48acf28e0b", "sha256:64e73d25b954f71ff100bb70f39f1ee09e880728efb4250c632ceed4e4cdf706",
"sha256:8293f89985a090ebc3ed1064df31f3b4b56320cdfcec8b60d3295bddb955c22a", "sha256:6907ee3529244bb0ed066683e075f09285b38dd5b4039370df6ff06041ca19e7",
"sha256:90230a6b8055ad47d3325e9ee8f8a9ae7e273078a66401ac66df68943ced029b", "sha256:6ce6743ed64d9afab4fafeaea70d3631b4d4b28b592db21a5c2d1f0ef52934bf",
"sha256:96a87ec31dc1044d8c2da2ebbed1c456d9b561e7d087734336518181b26b3aa5", "sha256:87c90c32357c74f11deb7fbb065126d91771b207bf9bfaaee01277ca59b574ec",
"sha256:9bb7554aca6f842645022fe2d301c264e6925baa708b392867b7a62645304df4", "sha256:a6c634fc6f5a0ceae1ab3e13c58183978185d131a29c425e4eaa9f40afe1e6d6",
"sha256:a187171e7c09efa4b4cc30ee5d0d55a8d6c5311b3e1b74ac5cb96cc89bafc43c", "sha256:bfc5f1d7afeda8d5d37660eeca6d389b142d7f2b5a1ab659d9214ebd0e025231",
"sha256:ba6eea4459dbd6b1be4e6bfc766079fb9b8dd2e5a35aff6baee4d9b1514ea519", "sha256:d612dbd0f3a919a8cc1d12037168bfa536862066808960e0cc901404b77968f0",
"sha256:c4bb09d2bbb394e3730d0918c00276e79b2de70ec2a5231cd4ebb51a57df9ba1", "sha256:db1192ddda2200671f9ef61d9597fcef89d934f5d1705e571a93a67fb13a4402",
"sha256:c59ab92f8e92d6725b7ded9d4a31be3ef42688a115c6d3da9457a5bda140e2b4", "sha256:de9edf2ce4b9ddf43fd93e20ef635a900e25f622f87ed6e3047a664d0e8f810e",
"sha256:cabc332b7075a914ecea912cd1f3d4370489c8018f2c945a30bcc934e3bc06a6", "sha256:e0c93e7d47ed951b9394cf352d6695b31498e68fd5782d6cbc282425655f687a",
"sha256:eabe5eb2c19a42f4808c03b82bd313fc84d4e395133fb3fc1b1516170a31213c" "sha256:faa935fc00ae854d8b638c16a5f1ce881bc3f67446957dd6f2af440a5fc8526b"
], ],
"index": "pypi", "index": "pypi",
"markers": "python_version >= '3.7'", "markers": "python_version >= '3.7'",
"version": "==0.9.3" "version": "==0.9.4"
}, },
"scipy": { "scipy": {
"hashes": [ "hashes": [
@ -4072,7 +4070,7 @@
"sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274",
"sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81" "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81"
], ],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2'", "markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3'",
"version": "==1.17.0" "version": "==1.17.0"
}, },
"sniffio": { "sniffio": {
@ -4205,7 +4203,6 @@
"sha256:e7631a77ffb1f7d2eefa4445ebbee491c720a5661ddf6df3498ebecae5ed375c", "sha256:e7631a77ffb1f7d2eefa4445ebbee491c720a5661ddf6df3498ebecae5ed375c",
"sha256:ef810fbf7b781a5a593894e4f439773830bdecb885e6880d957d5b9382a960d2" "sha256:ef810fbf7b781a5a593894e4f439773830bdecb885e6880d957d5b9382a960d2"
], ],
"index": "pypi",
"markers": "python_version >= '3.9'", "markers": "python_version >= '3.9'",
"version": "==6.0.0" "version": "==6.0.0"
}, },

View File

@ -83,9 +83,9 @@ test('date filtering', async ({ page }) => {
await page.routeFromHAR(REQUESTS_HAR3, { notFound: 'fallback' }) await page.routeFromHAR(REQUESTS_HAR3, { notFound: 'fallback' })
await page.goto('/documents') await page.goto('/documents')
await page.getByRole('button', { name: 'Dates' }).click() 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 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.getByLabel('Datesselected').getByRole('button').first().click()
await page.getByRole('combobox', { name: 'Select month' }).selectOption('12') await page.getByRole('combobox', { name: 'Select month' }).selectOption('12')
await page.getByRole('combobox', { name: 'Select year' }).selectOption('2022') await page.getByRole('combobox', { name: 'Select year' }).selectOption('2022')

View File

@ -3687,7 +3687,7 @@
"time": 1.501, "time": 1.501,
"request": { "request": {
"method": "GET", "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", "httpVersion": "HTTP/1.1",
"cookies": [], "cookies": [],
"headers": [ "headers": [
@ -3721,7 +3721,7 @@
"value": "true" "value": "true"
}, },
{ {
"name": "created__date__gt", "name": "created__date__gte",
"value": "2022-12-11" "value": "2022-12-11"
} }
], ],

File diff suppressed because it is too large Load Diff

View File

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

View File

@ -6,7 +6,6 @@ import { ToastsComponent } from './components/common/toasts/toasts.component'
import { FileDropComponent } from './components/file-drop/file-drop.component' import { FileDropComponent } from './components/file-drop/file-drop.component'
import { SETTINGS_KEYS } from './data/ui-settings' import { SETTINGS_KEYS } from './data/ui-settings'
import { ComponentRouterService } from './services/component-router.service' import { ComponentRouterService } from './services/component-router.service'
import { ConsumerStatusService } from './services/consumer-status.service'
import { HotKeyService } from './services/hot-key.service' import { HotKeyService } from './services/hot-key.service'
import { import {
PermissionAction, PermissionAction,
@ -16,6 +15,7 @@ import {
import { SettingsService } from './services/settings.service' import { SettingsService } from './services/settings.service'
import { TasksService } from './services/tasks.service' import { TasksService } from './services/tasks.service'
import { ToastService } from './services/toast.service' import { ToastService } from './services/toast.service'
import { WebsocketStatusService } from './services/websocket-status.service'
@Component({ @Component({
selector: 'pngx-root', selector: 'pngx-root',
@ -35,7 +35,7 @@ export class AppComponent implements OnInit, OnDestroy {
constructor( constructor(
private settings: SettingsService, private settings: SettingsService,
private consumerStatusService: ConsumerStatusService, private websocketStatusService: WebsocketStatusService,
private toastService: ToastService, private toastService: ToastService,
private router: Router, private router: Router,
private tasksService: TasksService, private tasksService: TasksService,
@ -51,7 +51,7 @@ export class AppComponent implements OnInit, OnDestroy {
} }
ngOnDestroy(): void { ngOnDestroy(): void {
this.consumerStatusService.disconnect() this.websocketStatusService.disconnect()
if (this.successSubscription) { if (this.successSubscription) {
this.successSubscription.unsubscribe() this.successSubscription.unsubscribe()
} }
@ -76,9 +76,9 @@ export class AppComponent implements OnInit, OnDestroy {
} }
ngOnInit(): void { ngOnInit(): void {
this.consumerStatusService.connect() this.websocketStatusService.connect()
this.successSubscription = this.consumerStatusService this.successSubscription = this.websocketStatusService
.onDocumentConsumptionFinished() .onDocumentConsumptionFinished()
.subscribe((status) => { .subscribe((status) => {
this.tasksService.reload() this.tasksService.reload()
@ -108,7 +108,7 @@ export class AppComponent implements OnInit, OnDestroy {
} }
}) })
this.failedSubscription = this.consumerStatusService this.failedSubscription = this.websocketStatusService
.onDocumentConsumptionFailed() .onDocumentConsumptionFailed()
.subscribe((status) => { .subscribe((status) => {
this.tasksService.reload() this.tasksService.reload()
@ -121,7 +121,7 @@ export class AppComponent implements OnInit, OnDestroy {
} }
}) })
this.newDocumentSubscription = this.consumerStatusService this.newDocumentSubscription = this.websocketStatusService
.onDocumentDetected() .onDocumentDetected()
.subscribe((status) => { .subscribe((status) => {
this.tasksService.reload() this.tasksService.reload()

View File

@ -41,7 +41,7 @@
<ng-template ngbNavContent> <ng-template ngbNavContent>
<div class="row"> <div class="row">
<div class="col-xl-6 pe-xl-5"> <div class="col-xl-6 pe-xl-5">
<h4 i18n>Appearance</h4> <h5 i18n>Appearance</h5>
<div class="row mb-3"> <div class="row mb-3">
<div class="col-md-3 col-form-label pt-0"> <div class="col-md-3 col-form-label pt-0">
<span i18n>Display language</span> <span i18n>Display language</span>
@ -154,28 +154,7 @@
</div> </div>
</div> </div>
<h4 class="mt-4" i18n>Document editing</h4> <h5 class="mt-3" id="update-checking" i18n>Update checking</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">
<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>
<div class="row mb-3"> <div class="row mb-3">
<div class="col d-flex flex-row align-items-start"> <div class="col d-flex flex-row align-items-start">
<pngx-input-check i18n-title title="Enable update checking" formControlName="updateCheckingEnabled"></pngx-input-check> <pngx-input-check i18n-title title="Enable update checking" formControlName="updateCheckingEnabled"></pngx-input-check>
@ -193,7 +172,56 @@
</div> </div>
</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="row mb-3">
<div class="col"> <div class="col">
<pngx-input-check i18n-title title="Show confirmation dialogs" formControlName="bulkEditConfirmationDialogs"></pngx-input-check> <pngx-input-check i18n-title title="Show confirmation dialogs" formControlName="bulkEditConfirmationDialogs"></pngx-input-check>
@ -201,7 +229,7 @@
</div> </div>
</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="row mb-3">
<div class="col"> <div class="col">
<pngx-input-check i18n-title title="Do not include advanced search results" formControlName="searchDbOnly"></pngx-input-check> <pngx-input-check i18n-title title="Do not include advanced search results" formControlName="searchDbOnly"></pngx-input-check>
@ -224,19 +252,6 @@
</div> </div>
</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>
</div> </div>
@ -247,7 +262,7 @@
<a ngbNavLink i18n>Permissions</a> <a ngbNavLink i18n>Permissions</a>
<ng-template ngbNavContent> <ng-template ngbNavContent>
<h4 i18n>Default Permissions</h4> <h5 i18n>Default Permissions</h5>
<div class="row mb-3"> <div class="row mb-3">
<div class="col"> <div class="col">
@ -329,7 +344,7 @@
<a ngbNavLink i18n>Notifications</a> <a ngbNavLink i18n>Notifications</a>
<ng-template ngbNavContent> <ng-template ngbNavContent>
<h4 i18n>Document processing</h4> <h5 i18n>Document processing</h5>
<div class="row mb-3"> <div class="row mb-3">
<div class="col"> <div class="col">

View File

@ -212,7 +212,7 @@ describe('SettingsComponent', () => {
expect(toastErrorSpy).toHaveBeenCalled() expect(toastErrorSpy).toHaveBeenCalled()
expect(storeSpy).toHaveBeenCalled() expect(storeSpy).toHaveBeenCalled()
expect(appearanceSettingsSpy).not.toHaveBeenCalled() expect(appearanceSettingsSpy).not.toHaveBeenCalled()
expect(setSpy).toHaveBeenCalledTimes(28) expect(setSpy).toHaveBeenCalledTimes(29)
// succeed // succeed
storeSpy.mockReturnValueOnce(of(true)) 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 { SelectComponent } from '../../common/input/select/select.component'
import { PageHeaderComponent } from '../../common/page-header/page-header.component' import { PageHeaderComponent } from '../../common/page-header/page-header.component'
import { SystemStatusDialogComponent } from '../../common/system-status-dialog/system-status-dialog.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' import { ComponentWithPermissions } from '../../with-permissions/with-permissions.component'
enum SettingsNavIDs { enum SettingsNavIDs {
@ -125,6 +126,7 @@ export class SettingsComponent
defaultPermsEditUsers: new FormControl(null), defaultPermsEditUsers: new FormControl(null),
defaultPermsEditGroups: new FormControl(null), defaultPermsEditGroups: new FormControl(null),
useNativePdfViewer: new FormControl(null), useNativePdfViewer: new FormControl(null),
pdfViewerDefaultZoom: new FormControl(null),
documentEditingRemoveInboxTags: new FormControl(null), documentEditingRemoveInboxTags: new FormControl(null),
documentEditingOverlayThumbnail: new FormControl(null), documentEditingOverlayThumbnail: new FormControl(null),
searchDbOnly: new FormControl(null), searchDbOnly: new FormControl(null),
@ -154,6 +156,8 @@ export class SettingsComponent
public readonly GlobalSearchType = GlobalSearchType public readonly GlobalSearchType = GlobalSearchType
public readonly ZoomSetting = ZoomSetting
get systemStatusHasErrors(): boolean { get systemStatusHasErrors(): boolean {
return ( return (
this.systemStatus.database.status === SystemStatusItemStatus.ERROR || this.systemStatus.database.status === SystemStatusItemStatus.ERROR ||
@ -276,6 +280,9 @@ export class SettingsComponent
useNativePdfViewer: this.settings.get( useNativePdfViewer: this.settings.get(
SETTINGS_KEYS.USE_NATIVE_PDF_VIEWER SETTINGS_KEYS.USE_NATIVE_PDF_VIEWER
), ),
pdfViewerDefaultZoom: this.settings.get(
SETTINGS_KEYS.PDF_VIEWER_ZOOM_SETTING
),
displayLanguage: this.settings.getLanguage(), displayLanguage: this.settings.getLanguage(),
dateLocale: this.settings.get(SETTINGS_KEYS.DATE_LOCALE), dateLocale: this.settings.get(SETTINGS_KEYS.DATE_LOCALE),
dateFormat: this.settings.get(SETTINGS_KEYS.DATE_FORMAT), dateFormat: this.settings.get(SETTINGS_KEYS.DATE_FORMAT),
@ -435,6 +442,10 @@ export class SettingsComponent
SETTINGS_KEYS.USE_NATIVE_PDF_VIEWER, SETTINGS_KEYS.USE_NATIVE_PDF_VIEWER,
this.settingsForm.value.useNativePdfViewer this.settingsForm.value.useNativePdfViewer
) )
this.settings.set(
SETTINGS_KEYS.PDF_VIEWER_ZOOM_SETTING,
this.settingsForm.value.pdfViewerDefaultZoom
)
this.settings.set( this.settings.set(
SETTINGS_KEYS.DATE_LOCALE, SETTINGS_KEYS.DATE_LOCALE,
this.settingsForm.value.dateLocale this.settingsForm.value.dateLocale

View File

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

View File

@ -250,8 +250,8 @@ main {
} }
} }
.dropdown.show .dropdown-toggle, :host ::ng-deep .dropdown.show .dropdown-toggle,
.dropdown-toggle:hover { :host ::ng-deep .dropdown-toggle:hover {
opacity: 0.7; 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 { DocumentDetailComponent } from '../document-detail/document-detail.component'
import { ComponentWithPermissions } from '../with-permissions/with-permissions.component' import { ComponentWithPermissions } from '../with-permissions/with-permissions.component'
import { GlobalSearchComponent } from './global-search/global-search.component' import { GlobalSearchComponent } from './global-search/global-search.component'
import { ToastsDropdownComponent } from './toasts-dropdown/toasts-dropdown.component'
@Component({ @Component({
selector: 'pngx-app-frame', selector: 'pngx-app-frame',
@ -57,6 +58,7 @@ import { GlobalSearchComponent } from './global-search/global-search.component'
GlobalSearchComponent, GlobalSearchComponent,
DocumentTitlePipe, DocumentTitlePipe,
IfPermissionsDirective, IfPermissionsDirective,
ToastsDropdownComponent,
RouterModule, RouterModule,
NgClass, NgClass,
NgbDropdownModule, 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" <input class="form-control" placeholder="yyyy-mm-dd"
[(ngModel)]="atom.value" [(ngModel)]="atom.value"
ngbDatepicker ngbDatepicker
#d="ngbDatepicker" /> #d="ngbDatepicker"
[footerTemplate]="datePickerFooterTemplate" />
<button class="btn btn-sm btn-outline-secondary rounded-end" (click)="d.toggle()" type="button"> <button class="btn btn-sm btn-outline-secondary rounded-end" (click)="d.toggle()" type="button">
<i-bs name="calendar-event"></i-bs> <i-bs name="calendar-event"></i-bs>
</button> </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) { } @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"> <input class="w-25 form-control rounded-end" type="number" [(ngModel)]="atom.value" [disabled]="disabled">
} @else if (getCustomFieldByID(atom.field)?.data_type === CustomFieldDataType.Boolean) { } @else if (getCustomFieldByID(atom.field)?.data_type === CustomFieldDataType.Boolean) {

View File

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

View File

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

View File

@ -1,5 +1,5 @@
<div class="btn-group w-100" ngbDropdown role="group" [popperOptions]="popperOptions"> <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> <i-bs width="1em" height="1em" name="calendar-event-fill"></i-bs>
<div class="d-none d-sm-inline">&nbsp;{{title}}</div> <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> <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="list-group-item d-flex p-2" role="menuitem">
<div class="selected-icon"> <div class="selected-icon">
@if (createdDateAfter) { @if (createdDateFrom) {
<a class="text-light focus-variants" href="javascript:void(0)" (click)="clearCreatedAfter()"> <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="check" class="variant-unfocused"></i-bs>
<i-bs width="1em" height="1em" name="x" class="variant-focused text-primary"></i-bs> <i-bs width="1em" height="1em" name="x" class="variant-focused text-primary"></i-bs>
</a> </a>
} }
</div> </div>
<div class="input-group input-group-sm small ps-1 pe-2"> <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)" <input class="form-control small" [placeholder]="datePlaceHolder" (dateSelect)="onChangeDebounce()" (change)="onChangeDebounce()" (keypress)="onKeyPress($event)"
maxlength="10" [(ngModel)]="createdDateAfter" ngbDatepicker #createdDateAfterPicker="ngbDatepicker"> maxlength="10" [(ngModel)]="createdDateFrom" ngbDatepicker #createdDateFromPicker="ngbDatepicker" [footerTemplate]="createdFromFooterTemplate">
<button class="btn btn-outline-secondary" (click)="createdDateAfterPicker.toggle()" type="button"> <button class="btn btn-outline-secondary" (click)="createdDateFromPicker.toggle()" type="button">
<i-bs width="1em" height="1em" name="calendar"></i-bs> <i-bs width="1em" height="1em" name="calendar"></i-bs>
</button> </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> </div>
<div class="list-group-item d-flex p-2" role="menuitem"> <div class="list-group-item d-flex p-2" role="menuitem">
<div class="selected-icon"> <div class="selected-icon">
@if (createdDateBefore) { @if (createdDateTo) {
<a class="text-light focus-variants" href="javascript:void(0)" (click)="clearCreatedBefore()"> <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="check" class="variant-unfocused"></i-bs>
<i-bs width="1em" height="1em" name="x" class="variant-focused text-primary"></i-bs> <i-bs width="1em" height="1em" name="x" class="variant-focused text-primary"></i-bs>
</a> </a>
} }
</div> </div>
<div class="input-group input-group-sm small ps-1 pe-2"> <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)" <input class="form-control small" [placeholder]="datePlaceHolder" (dateSelect)="onChangeDebounce()" (change)="onChangeDebounce()" (keypress)="onKeyPress($event)"
maxlength="10" [(ngModel)]="createdDateBefore" ngbDatepicker #createdDateBeforePicker="ngbDatepicker"> maxlength="10" [(ngModel)]="createdDateTo" ngbDatepicker #createdDateToPicker="ngbDatepicker" [footerTemplate]="createdToFooterTemplate">
<button class="btn btn-outline-secondary" (click)="createdDateBeforePicker.toggle()" type="button"> <button class="btn btn-outline-secondary" (click)="createdDateToPicker.toggle()" type="button">
<i-bs width="1em" height="1em" name="calendar"></i-bs> <i-bs width="1em" height="1em" name="calendar"></i-bs>
</button> </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>
</div> </div>
@ -95,40 +107,52 @@
<div class="list-group-item d-flex p-2" role="menuitem"> <div class="list-group-item d-flex p-2" role="menuitem">
<div class="selected-icon"> <div class="selected-icon">
@if (addedDateAfter) { @if (addedDateFrom) {
<a class="text-light focus-variants" href="javascript:void(0)" (click)="clearAddedAfter()"> <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="check" class="variant-unfocused"></i-bs>
<i-bs width="1em" height="1em" name="x" class="variant-focused text-primary"></i-bs> <i-bs width="1em" height="1em" name="x" class="variant-focused text-primary"></i-bs>
</a> </a>
} }
</div> </div>
<div class="input-group input-group-sm small ps-1 pe-2"> <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)" <input class="form-control small" [placeholder]="datePlaceHolder" (dateSelect)="onChangeDebounce()" (change)="onChangeDebounce()" (keypress)="onKeyPress($event)"
maxlength="10" [(ngModel)]="addedDateAfter" ngbDatepicker #addedDateAfterPicker="ngbDatepicker"> maxlength="10" [(ngModel)]="addedDateFrom" ngbDatepicker #addedDateFromPicker="ngbDatepicker" [footerTemplate]="addedFromFooterTemplate">
<button class="btn btn-outline-secondary" (click)="addedDateAfterPicker.toggle()" type="button"> <button class="btn btn-outline-secondary" (click)="addedDateFromPicker.toggle()" type="button">
<i-bs width="1em" height="1em" name="calendar"></i-bs> <i-bs width="1em" height="1em" name="calendar"></i-bs>
</button> </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> </div>
<div class="list-group-item d-flex p-2" role="menuitem"> <div class="list-group-item d-flex p-2" role="menuitem">
<div class="selected-icon"> <div class="selected-icon">
@if (addedDateBefore) { @if (addedDateTo) {
<a class="text-light focus-variants" href="javascript:void(0)" (click)="clearAddedBefore()"> <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="check" class="variant-unfocused"></i-bs>
<i-bs width="1em" height="1em" name="x" class="variant-focused text-primary"></i-bs> <i-bs width="1em" height="1em" name="x" class="variant-focused text-primary"></i-bs>
</a> </a>
} }
</div> </div>
<div class="input-group input-group-sm small ps-1 pe-2"> <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)" <input class="form-control small" [placeholder]="datePlaceHolder" (dateSelect)="onChangeDebounce()" (change)="onChangeDebounce()" (keypress)="onKeyPress($event)"
maxlength="10" [(ngModel)]="addedDateBefore" ngbDatepicker #addedDateBeforePicker="ngbDatepicker"> maxlength="10" [(ngModel)]="addedDateTo" ngbDatepicker #addedDateToPicker="ngbDatepicker" [footerTemplate]="addedToFooterTemplate">
<button class="btn btn-outline-secondary" (click)="addedDateBeforePicker.toggle()" type="button"> <button class="btn btn-outline-secondary" (click)="addedDateToPicker.toggle()" type="button">
<i-bs width="1em" height="1em" name="calendar"></i-bs> <i-bs width="1em" height="1em" name="calendar"></i-bs>
</button> </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>
</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(() => { it('should support date input, emit change', fakeAsync(() => {
let result: string let result: string
component.createdDateAfterChange.subscribe((date) => (result = date)) component.createdDateFromChange.subscribe((date) => (result = date))
const input: HTMLInputElement = fixture.nativeElement.querySelector('input') const input: HTMLInputElement = fixture.nativeElement.querySelector('input')
input.value = '5/30/2023' input.value = '5/30/2023'
input.dispatchEvent(new Event('change')) input.dispatchEvent(new Event('change'))
@ -83,68 +83,68 @@ describe('DatesDropdownComponent', () => {
let result: DateSelection let result: DateSelection
component.datesSet.subscribe((date) => (result = date)) component.datesSet.subscribe((date) => (result = date))
component.setCreatedRelativeDate(null) component.setCreatedRelativeDate(null)
component.setCreatedRelativeDate(RelativeDate.LAST_7_DAYS) component.setCreatedRelativeDate(RelativeDate.WITHIN_1_WEEK)
component.setAddedRelativeDate(null) component.setAddedRelativeDate(null)
component.setAddedRelativeDate(RelativeDate.LAST_7_DAYS) component.setAddedRelativeDate(RelativeDate.WITHIN_1_WEEK)
tick(500) tick(500)
expect(result).toEqual({ expect(result).toEqual({
createdAfter: null, createdFrom: null,
createdBefore: null, createdTo: null,
createdRelativeDateID: RelativeDate.LAST_7_DAYS, createdRelativeDateID: RelativeDate.WITHIN_1_WEEK,
addedAfter: null, addedFrom: null,
addedBefore: null, addedTo: null,
addedRelativeDateID: RelativeDate.LAST_7_DAYS, addedRelativeDateID: RelativeDate.WITHIN_1_WEEK,
}) })
})) }))
it('should support report if active', () => { it('should support report if active', () => {
component.createdRelativeDate = RelativeDate.LAST_7_DAYS component.createdRelativeDate = RelativeDate.WITHIN_1_WEEK
expect(component.isActive).toBeTruthy() expect(component.isActive).toBeTruthy()
component.createdRelativeDate = null component.createdRelativeDate = null
component.createdDateAfter = '2023-05-30' component.createdDateFrom = '2023-05-30'
expect(component.isActive).toBeTruthy() expect(component.isActive).toBeTruthy()
component.createdDateAfter = null component.createdDateFrom = null
component.createdDateBefore = '2023-05-30' component.createdDateTo = '2023-05-30'
expect(component.isActive).toBeTruthy() 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() expect(component.isActive).toBeTruthy()
component.addedRelativeDate = null component.addedRelativeDate = null
component.addedDateAfter = '2023-05-30' component.addedDateFrom = '2023-05-30'
expect(component.isActive).toBeTruthy() expect(component.isActive).toBeTruthy()
component.addedDateAfter = null component.addedDateFrom = null
component.addedDateBefore = '2023-05-30' component.addedDateTo = '2023-05-30'
expect(component.isActive).toBeTruthy() expect(component.isActive).toBeTruthy()
component.addedDateBefore = null component.addedDateTo = null
expect(component.isActive).toBeFalsy() expect(component.isActive).toBeFalsy()
}) })
it('should support reset', () => { it('should support reset', () => {
component.createdDateAfter = '2023-05-30' component.createdDateFrom = '2023-05-30'
component.reset() component.reset()
expect(component.createdDateAfter).toBeNull() expect(component.createdDateFrom).toBeNull()
}) })
it('should support clearAfter', () => { it('should support clearFrom', () => {
component.createdDateAfter = '2023-05-30' component.createdDateFrom = '2023-05-30'
component.clearCreatedAfter() component.clearCreatedFrom()
expect(component.createdDateAfter).toBeNull() expect(component.createdDateFrom).toBeNull()
component.addedDateAfter = '2023-05-30' component.addedDateFrom = '2023-05-30'
component.clearAddedAfter() component.clearAddedFrom()
expect(component.addedDateAfter).toBeNull() expect(component.addedDateFrom).toBeNull()
}) })
it('should support clearBefore', () => { it('should support clearTo', () => {
component.createdDateBefore = '2023-05-30' component.createdDateTo = '2023-05-30'
component.clearCreatedBefore() component.clearCreatedTo()
expect(component.createdDateBefore).toBeNull() expect(component.createdDateTo).toBeNull()
component.addedDateBefore = '2023-05-30' component.addedDateTo = '2023-05-30'
component.clearAddedBefore() component.clearAddedTo()
expect(component.addedDateBefore).toBeNull() expect(component.addedDateTo).toBeNull()
}) })
it('should limit keyboard events', () => { 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' import { ClearableBadgeComponent } from '../clearable-badge/clearable-badge.component'
export interface DateSelection { export interface DateSelection {
createdBefore?: string createdTo?: string
createdAfter?: string createdFrom?: string
createdRelativeDateID?: number createdRelativeDateID?: number
addedBefore?: string addedTo?: string
addedAfter?: string addedFrom?: string
addedRelativeDateID?: number addedRelativeDateID?: number
} }
export enum RelativeDate { export enum RelativeDate {
LAST_7_DAYS = 0, WITHIN_1_WEEK = 0,
LAST_MONTH = 1, WITHIN_1_MONTH = 1,
LAST_3_MONTHS = 2, WITHIN_3_MONTHS = 2,
LAST_YEAR = 3, WITHIN_1_YEAR = 3,
} }
@Component({ @Component({
@ -63,23 +63,23 @@ export class DatesDropdownComponent implements OnInit, OnDestroy {
relativeDates = [ relativeDates = [
{ {
id: RelativeDate.LAST_7_DAYS, id: RelativeDate.WITHIN_1_WEEK,
name: $localize`Last 7 days`, name: $localize`Within 1 week`,
date: new Date().setDate(new Date().getDate() - 7), date: new Date().setDate(new Date().getDate() - 7),
}, },
{ {
id: RelativeDate.LAST_MONTH, id: RelativeDate.WITHIN_1_MONTH,
name: $localize`Last month`, name: $localize`Within 1 month`,
date: new Date().setMonth(new Date().getMonth() - 1), date: new Date().setMonth(new Date().getMonth() - 1),
}, },
{ {
id: RelativeDate.LAST_3_MONTHS, id: RelativeDate.WITHIN_3_MONTHS,
name: $localize`Last 3 months`, name: $localize`Within 3 months`,
date: new Date().setMonth(new Date().getMonth() - 3), date: new Date().setMonth(new Date().getMonth() - 3),
}, },
{ {
id: RelativeDate.LAST_YEAR, id: RelativeDate.WITHIN_1_YEAR,
name: $localize`Last year`, name: $localize`Within 1 year`,
date: new Date().setFullYear(new Date().getFullYear() - 1), date: new Date().setFullYear(new Date().getFullYear() - 1),
}, },
] ]
@ -88,16 +88,16 @@ export class DatesDropdownComponent implements OnInit, OnDestroy {
// created // created
@Input() @Input()
createdDateBefore: string createdDateTo: string
@Output() @Output()
createdDateBeforeChange = new EventEmitter<string>() createdDateToChange = new EventEmitter<string>()
@Input() @Input()
createdDateAfter: string createdDateFrom: string
@Output() @Output()
createdDateAfterChange = new EventEmitter<string>() createdDateFromChange = new EventEmitter<string>()
@Input() @Input()
createdRelativeDate: RelativeDate createdRelativeDate: RelativeDate
@ -107,16 +107,16 @@ export class DatesDropdownComponent implements OnInit, OnDestroy {
// added // added
@Input() @Input()
addedDateBefore: string addedDateTo: string
@Output() @Output()
addedDateBeforeChange = new EventEmitter<string>() addedDateToChange = new EventEmitter<string>()
@Input() @Input()
addedDateAfter: string addedDateFrom: string
@Output() @Output()
addedDateAfterChange = new EventEmitter<string>() addedDateFromChange = new EventEmitter<string>()
@Input() @Input()
addedRelativeDate: RelativeDate addedRelativeDate: RelativeDate
@ -133,14 +133,16 @@ export class DatesDropdownComponent implements OnInit, OnDestroy {
@Input() @Input()
disabled: boolean = false disabled: boolean = false
public readonly today: string = new Date().toISOString().split('T')[0]
get isActive(): boolean { get isActive(): boolean {
return ( return (
this.createdRelativeDate !== null || this.createdRelativeDate !== null ||
this.createdDateAfter?.length > 0 || this.createdDateFrom?.length > 0 ||
this.createdDateBefore?.length > 0 || this.createdDateTo?.length > 0 ||
this.addedRelativeDate !== null || this.addedRelativeDate !== null ||
this.addedDateAfter?.length > 0 || this.addedDateFrom?.length > 0 ||
this.addedDateBefore?.length > 0 this.addedDateTo?.length > 0
) )
} }
@ -161,42 +163,42 @@ export class DatesDropdownComponent implements OnInit, OnDestroy {
} }
reset() { reset() {
this.createdDateBefore = null this.createdDateTo = null
this.createdDateAfter = null this.createdDateFrom = null
this.createdRelativeDate = null this.createdRelativeDate = null
this.addedDateBefore = null this.addedDateTo = null
this.addedDateAfter = null this.addedDateFrom = null
this.addedRelativeDate = null this.addedRelativeDate = null
this.onChange() this.onChange()
} }
setCreatedRelativeDate(rd: RelativeDate) { setCreatedRelativeDate(rd: RelativeDate) {
this.createdDateBefore = null this.createdDateTo = null
this.createdDateAfter = null this.createdDateFrom = null
this.createdRelativeDate = this.createdRelativeDate == rd ? null : rd this.createdRelativeDate = this.createdRelativeDate == rd ? null : rd
this.onChange() this.onChange()
} }
setAddedRelativeDate(rd: RelativeDate) { setAddedRelativeDate(rd: RelativeDate) {
this.addedDateBefore = null this.addedDateTo = null
this.addedDateAfter = null this.addedDateFrom = null
this.addedRelativeDate = this.addedRelativeDate == rd ? null : rd this.addedRelativeDate = this.addedRelativeDate == rd ? null : rd
this.onChange() this.onChange()
} }
onChange() { onChange() {
this.createdDateBeforeChange.emit(this.createdDateBefore) this.createdDateToChange.emit(this.createdDateTo)
this.createdDateAfterChange.emit(this.createdDateAfter) this.createdDateFromChange.emit(this.createdDateFrom)
this.createdRelativeDateChange.emit(this.createdRelativeDate) this.createdRelativeDateChange.emit(this.createdRelativeDate)
this.addedDateBeforeChange.emit(this.addedDateBefore) this.addedDateToChange.emit(this.addedDateTo)
this.addedDateAfterChange.emit(this.addedDateAfter) this.addedDateFromChange.emit(this.addedDateFrom)
this.addedRelativeDateChange.emit(this.addedRelativeDate) this.addedRelativeDateChange.emit(this.addedRelativeDate)
this.datesSet.emit({ this.datesSet.emit({
createdAfter: this.createdDateAfter, createdFrom: this.createdDateFrom,
createdBefore: this.createdDateBefore, createdTo: this.createdDateTo,
createdRelativeDateID: this.createdRelativeDate, createdRelativeDateID: this.createdRelativeDate,
addedAfter: this.addedDateAfter, addedFrom: this.addedDateFrom,
addedBefore: this.addedDateBefore, addedTo: this.addedDateTo,
addedRelativeDateID: this.addedRelativeDate, addedRelativeDateID: this.addedRelativeDate,
}) })
} }
@ -205,30 +207,30 @@ export class DatesDropdownComponent implements OnInit, OnDestroy {
this.createdRelativeDate = null this.createdRelativeDate = null
this.addedRelativeDate = null this.addedRelativeDate = null
this.datesSetDebounce$.next({ this.datesSetDebounce$.next({
createdAfter: this.createdDateAfter, createdAfter: this.createdDateFrom,
createdBefore: this.createdDateBefore, createdBefore: this.createdDateTo,
addedAfter: this.addedDateAfter, addedAfter: this.addedDateFrom,
addedBefore: this.addedDateBefore, addedBefore: this.addedDateTo,
}) })
} }
clearCreatedBefore() { clearCreatedTo() {
this.createdDateBefore = null this.createdDateTo = null
this.onChange() this.onChange()
} }
clearCreatedAfter() { clearCreatedFrom() {
this.createdDateAfter = null this.createdDateFrom = null
this.onChange() this.onChange()
} }
clearAddedBefore() { clearAddedTo() {
this.addedDateBefore = null this.addedDateTo = null
this.onChange() this.onChange()
} }
clearAddedAfter() { clearAddedFrom() {
this.addedDateAfter = null this.addedDateFrom = null
this.onChange() this.onChange()
} }

View File

@ -12,10 +12,16 @@
<div class="input-group" [class.is-invalid]="error"> <div class="input-group" [class.is-invalid]="error">
<input #inputField class="form-control" [class.is-invalid]="error" [placeholder]="placeholder" [id]="inputId" maxlength="10" <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)" (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"> <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> <i-bs width="1.2em" height="1.2em" name="calendar"></i-bs>
</button> </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) { @if (showFilter) {
<button class="btn btn-outline-secondary" type="button" (click)="onFilterDocuments()" [disabled]="this.value === null" title="{{ filterButtonTitle }}"> <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> <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() @Output()
filterDocuments = new EventEmitter<NgbDateStruct[]>() filterDocuments = new EventEmitter<NgbDateStruct[]>()
public readonly today: string = new Date().toISOString().split('T')[0]
getSuggestions() { getSuggestions() {
return this.suggestions == null 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) { @for (toast of toasts; track toast.id) {
<ngb-toast <pngx-toast [toast]="toast" [autohide]="true" (close)="closeToast()"></pngx-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>
} }

View File

@ -1,7 +1,7 @@
:host { :host {
position: fixed; position: fixed;
top: 0; top: 0;
right: 0; right: calc(50% - (var(--pngx-toast-max-width) / 2));
margin: 0.3em; margin: 0.3em;
z-index: 1200; z-index: 1200;
} }
@ -9,24 +9,3 @@
.toast:not(.show) { .toast:not(.show) {
display: block; // this corrects an ng-bootstrap bug that prevented animations 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 { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
import { provideHttpClientTesting } from '@angular/common/http/testing' import { provideHttpClientTesting } from '@angular/common/http/testing'
import { import { ComponentFixture, TestBed } from '@angular/core/testing'
ComponentFixture,
TestBed,
discardPeriodicTasks,
fakeAsync,
flush,
tick,
} from '@angular/core/testing'
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons' import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
import { of } from 'rxjs' import { Subject } from 'rxjs'
import { ToastService } from 'src/app/services/toast.service' import { Toast, ToastService } from 'src/app/services/toast.service'
import { ToastsComponent } from './toasts.component' import { ToastsComponent } from './toasts.component'
const toasts = [ const toast = {
{ content: 'Error 2 content',
content: 'foo bar', delay: 5000,
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', () => { describe('ToastsComponent', () => {
let component: ToastsComponent let component: ToastsComponent
let fixture: ComponentFixture<ToastsComponent> let fixture: ComponentFixture<ToastsComponent>
let toastService: ToastService let toastService: ToastService
let clipboard: Clipboard let toastSubject: Subject<Toast> = new Subject()
beforeEach(async () => { beforeEach(async () => {
TestBed.configureTestingModule({ TestBed.configureTestingModule({
imports: [ToastsComponent, NgxBootstrapIconsModule.pick(allIcons)], imports: [ToastsComponent, NgxBootstrapIconsModule.pick(allIcons)],
providers: [ providers: [
{
provide: ToastService,
useValue: {
getToasts: () => of(toasts),
},
},
provideHttpClient(withInterceptorsFromDi()), provideHttpClient(withInterceptorsFromDi()),
provideHttpClientTesting(), provideHttpClientTesting(),
], ],
@ -60,95 +35,37 @@ describe('ToastsComponent', () => {
fixture = TestBed.createComponent(ToastsComponent) fixture = TestBed.createComponent(ToastsComponent)
toastService = TestBed.inject(ToastService) toastService = TestBed.inject(ToastService)
clipboard = TestBed.inject(Clipboard) jest.replaceProperty(toastService, 'showToast', toastSubject)
component = fixture.componentInstance component = fixture.componentInstance
fixture.detectChanges() fixture.detectChanges()
}) })
it('should call getToasts and return toasts', fakeAsync(() => { it('should create', () => {
const spy = jest.spyOn(toastService, 'getToasts') expect(component).toBeTruthy()
})
component.ngOnInit() it('should close toast', () => {
fixture.detectChanges() component.toasts = [toast]
const closeToastSpy = jest.spyOn(toastService, 'closeToast')
component.closeToast()
expect(component.toasts).toEqual([])
expect(closeToastSpy).toHaveBeenCalledWith(toast)
})
expect(spy).toHaveBeenCalled() it('should unsubscribe', () => {
expect(component.toasts).toContainEqual({ const unsubscribeSpy = jest.spyOn(
content: 'foo bar', (component as any).subscription,
delay: 5000, 'unsubscribe'
})
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'
) )
const copySpy = jest.spyOn(clipboard, 'copy')
component.copyError(toasts[2].error)
expect(copySpy).toHaveBeenCalled()
component.ngOnDestroy() component.ngOnDestroy()
flush() expect(unsubscribeSpy).toHaveBeenCalled()
discardPeriodicTasks() })
}))
it('should parse error text, add ellipsis', () => { it('should subscribe to toastService', () => {
expect(component.getErrorText(toasts[2].error)).toEqual( component.ngOnInit()
'Error 2 message details' toastSubject.next(toast)
) expect(component.toasts).toEqual([toast])
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

@ -1,92 +1,43 @@
import { Clipboard } from '@angular/cdk/clipboard'
import { DecimalPipe } from '@angular/common'
import { Component, OnDestroy, OnInit } from '@angular/core' import { Component, OnDestroy, OnInit } from '@angular/core'
import { import {
NgbAccordionModule,
NgbProgressbarModule, NgbProgressbarModule,
NgbToastModule,
} from '@ng-bootstrap/ng-bootstrap' } from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons' 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 { Toast, ToastService } from 'src/app/services/toast.service'
import { ToastComponent } from '../toast/toast.component'
@Component({ @Component({
selector: 'pngx-toasts', selector: 'pngx-toasts',
templateUrl: './toasts.component.html', templateUrl: './toasts.component.html',
styleUrls: ['./toasts.component.scss'], styleUrls: ['./toasts.component.scss'],
imports: [ imports: [
DecimalPipe, ToastComponent,
NgbToastModule, NgbAccordionModule,
NgbProgressbarModule, NgbProgressbarModule,
NgxBootstrapIconsModule, NgxBootstrapIconsModule,
], ],
}) })
export class ToastsComponent implements OnInit, OnDestroy { export class ToastsComponent implements OnInit, OnDestroy {
constructor( constructor(public toastService: ToastService) {}
public toastService: ToastService,
private clipboard: Clipboard
) {}
private subscription: Subscription private subscription: Subscription
public toasts: Toast[] = [] public toasts: Toast[] = [] // array to force change detection
public copied: boolean = false
public seconds: number = 0
ngOnDestroy(): void { ngOnDestroy(): void {
this.subscription?.unsubscribe() this.subscription?.unsubscribe()
} }
ngOnInit(): void { ngOnInit(): void {
this.subscription = this.toastService.getToasts().subscribe((toasts) => { this.subscription = this.toastService.showToast.subscribe((toast) => {
this.toasts = toasts this.toasts = toast ? [toast] : []
this.toasts.forEach((t) => {
if (typeof t.error === 'string') {
try {
t.error = JSON.parse(t.error)
} catch (e) {}
}
})
}) })
} }
onShow(toast: Toast) { closeToast() {
const refreshInterval = 150 this.toastService.closeToast(this.toasts[0])
const delay = toast.delay - 500 // for fade animation this.toasts = []
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 ? '...' : ''}`
} }
} }

View File

@ -33,14 +33,14 @@ import { PermissionsGuard } from 'src/app/guards/permissions.guard'
import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe' import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
import { DocumentTitlePipe } from 'src/app/pipes/document-title.pipe' import { DocumentTitlePipe } from 'src/app/pipes/document-title.pipe'
import { SafeUrlPipe } from 'src/app/pipes/safeurl.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 { DocumentListViewService } from 'src/app/services/document-list-view.service'
import { PermissionsService } from 'src/app/services/permissions.service' import { PermissionsService } from 'src/app/services/permissions.service'
import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service' import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
import { DocumentService } from 'src/app/services/rest/document.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 { WidgetFrameComponent } from '../widget-frame/widget-frame.component'
import { SavedViewWidgetComponent } from './saved-view-widget.component' import { SavedViewWidgetComponent } from './saved-view-widget.component'
@ -112,7 +112,7 @@ describe('SavedViewWidgetComponent', () => {
let component: SavedViewWidgetComponent let component: SavedViewWidgetComponent
let fixture: ComponentFixture<SavedViewWidgetComponent> let fixture: ComponentFixture<SavedViewWidgetComponent>
let documentService: DocumentService let documentService: DocumentService
let consumerStatusService: ConsumerStatusService let websocketStatusService: WebsocketStatusService
let documentListViewService: DocumentListViewService let documentListViewService: DocumentListViewService
let router: Router let router: Router
@ -176,7 +176,7 @@ describe('SavedViewWidgetComponent', () => {
}).compileComponents() }).compileComponents()
documentService = TestBed.inject(DocumentService) documentService = TestBed.inject(DocumentService)
consumerStatusService = TestBed.inject(ConsumerStatusService) websocketStatusService = TestBed.inject(WebsocketStatusService)
documentListViewService = TestBed.inject(DocumentListViewService) documentListViewService = TestBed.inject(DocumentListViewService)
router = TestBed.inject(Router) router = TestBed.inject(Router)
fixture = TestBed.createComponent(SavedViewWidgetComponent) fixture = TestBed.createComponent(SavedViewWidgetComponent)
@ -235,7 +235,7 @@ describe('SavedViewWidgetComponent', () => {
it('should reload on document consumption finished', () => { it('should reload on document consumption finished', () => {
const fileStatusSubject = new Subject<FileStatus>() const fileStatusSubject = new Subject<FileStatus>()
jest jest
.spyOn(consumerStatusService, 'onDocumentConsumptionFinished') .spyOn(websocketStatusService, 'onDocumentConsumptionFinished')
.mockReturnValue(fileStatusSubject) .mockReturnValue(fileStatusSubject)
const reloadSpy = jest.spyOn(component, 'reload') const reloadSpy = jest.spyOn(component, 'reload')
component.ngOnInit() 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 { DocumentTypeNamePipe } from 'src/app/pipes/document-type-name.pipe'
import { StoragePathNamePipe } from 'src/app/pipes/storage-path-name.pipe' import { StoragePathNamePipe } from 'src/app/pipes/storage-path-name.pipe'
import { UsernamePipe } from 'src/app/pipes/username.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 { DocumentListViewService } from 'src/app/services/document-list-view.service'
import { OpenDocumentsService } from 'src/app/services/open-documents.service' import { OpenDocumentsService } from 'src/app/services/open-documents.service'
import { import {
@ -53,6 +52,7 @@ import {
import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service' import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
import { DocumentService } from 'src/app/services/rest/document.service' import { DocumentService } from 'src/app/services/rest/document.service'
import { SettingsService } from 'src/app/services/settings.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' import { WidgetFrameComponent } from '../widget-frame/widget-frame.component'
@Component({ @Component({
@ -94,7 +94,7 @@ export class SavedViewWidgetComponent
private documentService: DocumentService, private documentService: DocumentService,
private router: Router, private router: Router,
private list: DocumentListViewService, private list: DocumentListViewService,
private consumerStatusService: ConsumerStatusService, private websocketStatusService: WebsocketStatusService,
public openDocumentsService: OpenDocumentsService, public openDocumentsService: OpenDocumentsService,
public documentListViewService: DocumentListViewService, public documentListViewService: DocumentListViewService,
public permissionsService: PermissionsService, public permissionsService: PermissionsService,
@ -124,7 +124,7 @@ export class SavedViewWidgetComponent
ngOnInit(): void { ngOnInit(): void {
this.reload() this.reload()
this.displayMode = this.savedView.display_mode ?? DisplayMode.TABLE this.displayMode = this.savedView.display_mode ?? DisplayMode.TABLE
this.consumerStatusService this.websocketStatusService
.onDocumentConsumptionFinished() .onDocumentConsumptionFinished()
.pipe(takeUntil(this.unsubscribeNotifier)) .pipe(takeUntil(this.unsubscribeNotifier))
.subscribe(() => { .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 { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
import { PermissionsGuard } from 'src/app/guards/permissions.guard' import { PermissionsGuard } from 'src/app/guards/permissions.guard'
import { import {
ConsumerStatusService,
FileStatus, FileStatus,
} from 'src/app/services/consumer-status.service' WebsocketStatusService,
} from 'src/app/services/websocket-status.service'
import { environment } from 'src/environments/environment' import { environment } from 'src/environments/environment'
import { WidgetFrameComponent } from '../widget-frame/widget-frame.component' import { WidgetFrameComponent } from '../widget-frame/widget-frame.component'
import { StatisticsWidgetComponent } from './statistics-widget.component' import { StatisticsWidgetComponent } from './statistics-widget.component'
@ -23,7 +23,7 @@ describe('StatisticsWidgetComponent', () => {
let component: StatisticsWidgetComponent let component: StatisticsWidgetComponent
let fixture: ComponentFixture<StatisticsWidgetComponent> let fixture: ComponentFixture<StatisticsWidgetComponent>
let httpTestingController: HttpTestingController let httpTestingController: HttpTestingController
let consumerStatusService: ConsumerStatusService let websocketStatusService: WebsocketStatusService
const fileStatusSubject = new Subject<FileStatus>() const fileStatusSubject = new Subject<FileStatus>()
beforeEach(async () => { beforeEach(async () => {
@ -44,9 +44,9 @@ describe('StatisticsWidgetComponent', () => {
}).compileComponents() }).compileComponents()
fixture = TestBed.createComponent(StatisticsWidgetComponent) fixture = TestBed.createComponent(StatisticsWidgetComponent)
consumerStatusService = TestBed.inject(ConsumerStatusService) websocketStatusService = TestBed.inject(WebsocketStatusService)
jest jest
.spyOn(consumerStatusService, 'onDocumentConsumptionFinished') .spyOn(websocketStatusService, 'onDocumentConsumptionFinished')
.mockReturnValue(fileStatusSubject) .mockReturnValue(fileStatusSubject)
component = fixture.componentInstance 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 { ComponentWithPermissions } from 'src/app/components/with-permissions/with-permissions.component'
import { FILTER_HAS_TAGS_ANY } from 'src/app/data/filter-rule-type' import { FILTER_HAS_TAGS_ANY } from 'src/app/data/filter-rule-type'
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive' 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 { 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 { environment } from 'src/environments/environment'
import { WidgetFrameComponent } from '../widget-frame/widget-frame.component' import { WidgetFrameComponent } from '../widget-frame/widget-frame.component'
@ -51,7 +51,7 @@ export class StatisticsWidgetComponent
constructor( constructor(
private http: HttpClient, private http: HttpClient,
private consumerStatusService: ConsumerStatusService, private websocketConnectionService: WebsocketStatusService,
private documentListViewService: DocumentListViewService private documentListViewService: DocumentListViewService
) { ) {
super() super()
@ -109,7 +109,7 @@ export class StatisticsWidgetComponent
ngOnInit(): void { ngOnInit(): void {
this.reload() this.reload()
this.subscription = this.consumerStatusService this.subscription = this.websocketConnectionService
.onDocumentConsumptionFinished() .onDocumentConsumptionFinished()
.subscribe(() => { .subscribe(() => {
this.reload() this.reload()

View File

@ -12,13 +12,13 @@ import { NgbAlert, NgbCollapse } from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons' import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
import { routes } from 'src/app/app-routing.module' import { routes } from 'src/app/app-routing.module'
import { PermissionsGuard } from 'src/app/guards/permissions.guard' 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 { PermissionsService } from 'src/app/services/permissions.service'
import { UploadDocumentsService } from 'src/app/services/upload-documents.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' import { UploadFileWidgetComponent } from './upload-file-widget.component'
const FAILED_STATUSES = [new FileStatus()] const FAILED_STATUSES = [new FileStatus()]
@ -42,7 +42,7 @@ const DEFAULT_STATUSES = [
describe('UploadFileWidgetComponent', () => { describe('UploadFileWidgetComponent', () => {
let component: UploadFileWidgetComponent let component: UploadFileWidgetComponent
let fixture: ComponentFixture<UploadFileWidgetComponent> let fixture: ComponentFixture<UploadFileWidgetComponent>
let consumerStatusService: ConsumerStatusService let websocketStatusService: WebsocketStatusService
let uploadDocumentsService: UploadDocumentsService let uploadDocumentsService: UploadDocumentsService
beforeEach(async () => { beforeEach(async () => {
@ -65,7 +65,7 @@ describe('UploadFileWidgetComponent', () => {
], ],
}).compileComponents() }).compileComponents()
consumerStatusService = TestBed.inject(ConsumerStatusService) websocketStatusService = TestBed.inject(WebsocketStatusService)
uploadDocumentsService = TestBed.inject(UploadDocumentsService) uploadDocumentsService = TestBed.inject(UploadDocumentsService)
fixture = TestBed.createComponent(UploadFileWidgetComponent) fixture = TestBed.createComponent(UploadFileWidgetComponent)
component = fixture.componentInstance component = fixture.componentInstance
@ -91,14 +91,14 @@ describe('UploadFileWidgetComponent', () => {
}) })
it('should generate stats summary', () => { it('should generate stats summary', () => {
mockConsumerStatuses(consumerStatusService) mockConsumerStatuses(websocketStatusService)
expect(component.getStatusSummary()).toEqual( expect(component.getStatusSummary()).toEqual(
'Processing: 6, Failed: 1, Added: 4' 'Processing: 6, Failed: 1, Added: 4'
) )
}) })
it('should report an upload progress summary', () => { it('should report an upload progress summary', () => {
mockConsumerStatuses(consumerStatusService) mockConsumerStatuses(websocketStatusService)
expect(component.getTotalUploadProgress()).toEqual(0.75) expect(component.getTotalUploadProgress()).toEqual(0.75)
}) })
@ -117,7 +117,7 @@ describe('UploadFileWidgetComponent', () => {
}) })
it('should enforce a maximum number of alerts', () => { it('should enforce a maximum number of alerts', () => {
mockConsumerStatuses(consumerStatusService) mockConsumerStatuses(websocketStatusService)
fixture.detectChanges() fixture.detectChanges()
// 5 total, 1 hidden // 5 total, 1 hidden
expect(fixture.debugElement.queryAll(By.directive(NgbAlert))).toHaveLength( expect(fixture.debugElement.queryAll(By.directive(NgbAlert))).toHaveLength(
@ -131,19 +131,19 @@ describe('UploadFileWidgetComponent', () => {
}) })
it('should allow dismissing an alert', () => { it('should allow dismissing an alert', () => {
const dismissSpy = jest.spyOn(consumerStatusService, 'dismiss') const dismissSpy = jest.spyOn(websocketStatusService, 'dismiss')
component.dismiss(new FileStatus()) component.dismiss(new FileStatus())
expect(dismissSpy).toHaveBeenCalled() expect(dismissSpy).toHaveBeenCalled()
}) })
it('should allow dismissing completed alerts', fakeAsync(() => { it('should allow dismissing completed alerts', fakeAsync(() => {
mockConsumerStatuses(consumerStatusService) mockConsumerStatuses(websocketStatusService)
component.alertsExpanded = true component.alertsExpanded = true
fixture.detectChanges() fixture.detectChanges()
jest jest
.spyOn(component, 'getStatusCompleted') .spyOn(component, 'getStatusCompleted')
.mockImplementation(() => SUCCESS_STATUSES) .mockImplementation(() => SUCCESS_STATUSES)
const dismissSpy = jest.spyOn(consumerStatusService, 'dismiss') const dismissSpy = jest.spyOn(websocketStatusService, 'dismiss')
component.dismissCompleted() component.dismissCompleted()
tick(1000) tick(1000)
fixture.detectChanges() 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 { ComponentWithPermissions } from 'src/app/components/with-permissions/with-permissions.component'
import { SETTINGS_KEYS } from 'src/app/data/ui-settings' import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive' 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 { SettingsService } from 'src/app/services/settings.service'
import { UploadDocumentsService } from 'src/app/services/upload-documents.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' import { WidgetFrameComponent } from '../widget-frame/widget-frame.component'
const MAX_ALERTS = 5 const MAX_ALERTS = 5
@ -46,7 +46,7 @@ export class UploadFileWidgetComponent extends ComponentWithPermissions {
@ViewChildren(NgbAlert) alerts: QueryList<NgbAlert> @ViewChildren(NgbAlert) alerts: QueryList<NgbAlert>
constructor( constructor(
private consumerStatusService: ConsumerStatusService, private websocketStatusService: WebsocketStatusService,
private uploadDocumentsService: UploadDocumentsService, private uploadDocumentsService: UploadDocumentsService,
public settingsService: SettingsService public settingsService: SettingsService
) { ) {
@ -54,13 +54,13 @@ export class UploadFileWidgetComponent extends ComponentWithPermissions {
} }
getStatus() { getStatus() {
return this.consumerStatusService.getConsumerStatus().slice(0, MAX_ALERTS) return this.websocketStatusService.getConsumerStatus().slice(0, MAX_ALERTS)
} }
getStatusSummary() { getStatusSummary() {
let strings = [] let strings = []
let countUploadingAndProcessing = let countUploadingAndProcessing =
this.consumerStatusService.getConsumerStatusNotCompleted().length this.websocketStatusService.getConsumerStatusNotCompleted().length
let countFailed = this.getStatusFailed().length let countFailed = this.getStatusFailed().length
let countSuccess = this.getStatusSuccess().length let countSuccess = this.getStatusSuccess().length
if (countUploadingAndProcessing > 0) { if (countUploadingAndProcessing > 0) {
@ -78,27 +78,30 @@ export class UploadFileWidgetComponent extends ComponentWithPermissions {
} }
getStatusHidden() { getStatusHidden() {
if (this.consumerStatusService.getConsumerStatus().length < MAX_ALERTS) if (this.websocketStatusService.getConsumerStatus().length < MAX_ALERTS)
return [] return []
else return this.consumerStatusService.getConsumerStatus().slice(MAX_ALERTS) else
return this.websocketStatusService.getConsumerStatus().slice(MAX_ALERTS)
} }
getStatusUploading() { getStatusUploading() {
return this.consumerStatusService.getConsumerStatus( return this.websocketStatusService.getConsumerStatus(
FileStatusPhase.UPLOADING FileStatusPhase.UPLOADING
) )
} }
getStatusFailed() { getStatusFailed() {
return this.consumerStatusService.getConsumerStatus(FileStatusPhase.FAILED) return this.websocketStatusService.getConsumerStatus(FileStatusPhase.FAILED)
} }
getStatusSuccess() { getStatusSuccess() {
return this.consumerStatusService.getConsumerStatus(FileStatusPhase.SUCCESS) return this.websocketStatusService.getConsumerStatus(
FileStatusPhase.SUCCESS
)
} }
getStatusCompleted() { getStatusCompleted() {
return this.consumerStatusService.getConsumerStatusCompleted() return this.websocketStatusService.getConsumerStatusCompleted()
} }
getTotalUploadProgress() { getTotalUploadProgress() {
@ -134,12 +137,12 @@ export class UploadFileWidgetComponent extends ComponentWithPermissions {
} }
dismiss(status: FileStatus) { dismiss(status: FileStatus) {
this.consumerStatusService.dismiss(status) this.websocketStatusService.dismiss(status)
} }
dismissCompleted() { dismissCompleted() {
this.getStatusCompleted().forEach((status) => 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"> <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> <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) { @for (setting of zoomSettings; track setting) {
<option [value]="setting" [selected]="previewZoomSetting === setting"> <option [value]="setting" [attr.selected]="isZoomSelected(setting) ? 'selected' : null">
{{ getZoomSettingTitle(setting) }} {{ getZoomSettingTitle(setting) }}
</option> </option>
} }
@ -356,9 +356,9 @@
</ng-template> </ng-template>
<ng-template #previewContent> <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) { @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 class="position-absolute top-0 start-0 m-2 p-2 d-flex align-items-center justify-content-center">
<div> <div>

View File

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

View File

@ -62,7 +62,10 @@ import { ToastService } from 'src/app/services/toast.service'
import { environment } from 'src/environments/environment' import { environment } from 'src/environments/environment'
import { ConfirmDialogComponent } from '../common/confirm-dialog/confirm-dialog.component' import { ConfirmDialogComponent } from '../common/confirm-dialog/confirm-dialog.component'
import { CustomFieldsDropdownComponent } from '../common/custom-fields-dropdown/custom-fields-dropdown.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 = { const doc: Document = {
id: 3, id: 3,
@ -753,7 +756,7 @@ describe('DocumentDetailComponent', () => {
it('should support zoom controls', () => { it('should support zoom controls', () => {
initNormally() initNormally()
component.onZoomSelect({ target: { value: '1' } } as any) // from select component.setZoom(ZoomSetting.One) // from select
expect(component.previewZoomSetting).toEqual('1') expect(component.previewZoomSetting).toEqual('1')
component.increaseZoom() component.increaseZoom()
expect(component.previewZoomSetting).toEqual('1.5') expect(component.previewZoomSetting).toEqual('1.5')
@ -761,18 +764,18 @@ describe('DocumentDetailComponent', () => {
expect(component.previewZoomSetting).toEqual('2') expect(component.previewZoomSetting).toEqual('2')
component.decreaseZoom() component.decreaseZoom()
expect(component.previewZoomSetting).toEqual('1.5') expect(component.previewZoomSetting).toEqual('1.5')
component.onZoomSelect({ target: { value: '1' } } as any) // from select component.setZoom(ZoomSetting.One) // from select
component.decreaseZoom() component.decreaseZoom()
expect(component.previewZoomSetting).toEqual('.75') 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.previewZoomScale).toEqual('page-fit')
expect(component.previewZoomSetting).toEqual('1') expect(component.previewZoomSetting).toEqual('1')
component.increaseZoom() component.increaseZoom()
expect(component.previewZoomSetting).toEqual('1.5') expect(component.previewZoomSetting).toEqual('1.5')
expect(component.previewZoomScale).toEqual('page-width') 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.previewZoomScale).toEqual('page-fit')
expect(component.previewZoomSetting).toEqual('1') expect(component.previewZoomSetting).toEqual('1')
component.decreaseZoom() component.decreaseZoom()
@ -780,6 +783,19 @@ describe('DocumentDetailComponent', () => {
expect(component.previewZoomScale).toEqual('page-width') 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', () => { it('should support updating notes dynamically', () => {
const notes = [ const notes = [
{ {

View File

@ -124,7 +124,7 @@ enum ContentRenderType {
TIFF = 'tiff', TIFF = 'tiff',
} }
enum ZoomSetting { export enum ZoomSetting {
PageFit = 'page-fit', PageFit = 'page-fit',
PageWidth = 'page-width', PageWidth = 'page-width',
Quarter = '.25', Quarter = '.25',
@ -328,6 +328,7 @@ export class DocumentDetailComponent
} }
ngOnInit(): void { ngOnInit(): void {
this.setZoom(this.settings.get(SETTINGS_KEYS.PDF_VIEWER_ZOOM_SETTING))
this.documentForm.valueChanges this.documentForm.valueChanges
.pipe(takeUntil(this.unsubscribeNotifier)) .pipe(takeUntil(this.unsubscribeNotifier))
.subscribe(() => { .subscribe(() => {
@ -1072,14 +1073,13 @@ export class DocumentDetailComponent
} }
} }
onZoomSelect(event: Event) { setZoom(setting: ZoomSetting) {
const setting = (event.target as HTMLSelectElement)?.value as ZoomSetting if (ZoomSetting.PageFit === setting || ZoomSetting.PageWidth === setting) {
if (ZoomSetting.PageFit === setting) {
this.previewZoomSetting = ZoomSetting.One
this.previewZoomScale = setting this.previewZoomScale = setting
this.previewZoomSetting = ZoomSetting.One
} else { } else {
this.previewZoomScale = ZoomSetting.PageWidth
this.previewZoomSetting = setting this.previewZoomSetting = setting
this.previewZoomScale = ZoomSetting.PageWidth
} }
} }
@ -1089,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 { getZoomSettingTitle(setting: ZoomSetting): string {
switch (setting) { switch (setting) {
case ZoomSetting.PageFit: case ZoomSetting.PageFit:

View File

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

View File

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

View File

@ -38,16 +38,16 @@ import { DocumentTitlePipe } from 'src/app/pipes/document-title.pipe'
import { FilterPipe } from 'src/app/pipes/filter.pipe' import { FilterPipe } from 'src/app/pipes/filter.pipe'
import { SafeHtmlPipe } from 'src/app/pipes/safehtml.pipe' import { SafeHtmlPipe } from 'src/app/pipes/safehtml.pipe'
import { UsernamePipe } from 'src/app/pipes/username.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 { DocumentListViewService } from 'src/app/services/document-list-view.service'
import { PermissionsService } from 'src/app/services/permissions.service' import { PermissionsService } from 'src/app/services/permissions.service'
import { DocumentService } from 'src/app/services/rest/document.service' import { DocumentService } from 'src/app/services/rest/document.service'
import { SavedViewService } from 'src/app/services/rest/saved-view.service' import { SavedViewService } from 'src/app/services/rest/saved-view.service'
import { SettingsService } from 'src/app/services/settings.service' import { SettingsService } from 'src/app/services/settings.service'
import { ToastService } from 'src/app/services/toast.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 { DocumentCardLargeComponent } from './document-card-large/document-card-large.component'
import { DocumentCardSmallComponent } from './document-card-small/document-card-small.component' import { DocumentCardSmallComponent } from './document-card-small/document-card-small.component'
import { DocumentListComponent } from './document-list.component' import { DocumentListComponent } from './document-list.component'
@ -81,7 +81,7 @@ describe('DocumentListComponent', () => {
let fixture: ComponentFixture<DocumentListComponent> let fixture: ComponentFixture<DocumentListComponent>
let documentListService: DocumentListViewService let documentListService: DocumentListViewService
let documentService: DocumentService let documentService: DocumentService
let consumerStatusService: ConsumerStatusService let websocketStatusService: WebsocketStatusService
let savedViewService: SavedViewService let savedViewService: SavedViewService
let router: Router let router: Router
let activatedRoute: ActivatedRoute let activatedRoute: ActivatedRoute
@ -112,7 +112,7 @@ describe('DocumentListComponent', () => {
documentListService = TestBed.inject(DocumentListViewService) documentListService = TestBed.inject(DocumentListViewService)
documentService = TestBed.inject(DocumentService) documentService = TestBed.inject(DocumentService)
consumerStatusService = TestBed.inject(ConsumerStatusService) websocketStatusService = TestBed.inject(WebsocketStatusService)
savedViewService = TestBed.inject(SavedViewService) savedViewService = TestBed.inject(SavedViewService)
router = TestBed.inject(Router) router = TestBed.inject(Router)
activatedRoute = TestBed.inject(ActivatedRoute) activatedRoute = TestBed.inject(ActivatedRoute)
@ -128,13 +128,24 @@ describe('DocumentListComponent', () => {
const reloadSpy = jest.spyOn(documentListService, 'reload') const reloadSpy = jest.spyOn(documentListService, 'reload')
const fileStatusSubject = new Subject<FileStatus>() const fileStatusSubject = new Subject<FileStatus>()
jest jest
.spyOn(consumerStatusService, 'onDocumentConsumptionFinished') .spyOn(websocketStatusService, 'onDocumentConsumptionFinished')
.mockReturnValue(fileStatusSubject) .mockReturnValue(fileStatusSubject)
fixture.detectChanges() fixture.detectChanges()
fileStatusSubject.next(new FileStatus()) fileStatusSubject.next(new FileStatus())
expect(reloadSpy).toHaveBeenCalled() 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', () => { it('should show score sort fields on fulltext queries', () => {
documentListService.filterRules = [ 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 { DocumentTypeNamePipe } from 'src/app/pipes/document-type-name.pipe'
import { StoragePathNamePipe } from 'src/app/pipes/storage-path-name.pipe' import { StoragePathNamePipe } from 'src/app/pipes/storage-path-name.pipe'
import { UsernamePipe } from 'src/app/pipes/username.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 { DocumentListViewService } from 'src/app/services/document-list-view.service'
import { HotKeyService } from 'src/app/services/hot-key.service' import { HotKeyService } from 'src/app/services/hot-key.service'
import { OpenDocumentsService } from 'src/app/services/open-documents.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 { SavedViewService } from 'src/app/services/rest/saved-view.service'
import { SettingsService } from 'src/app/services/settings.service' import { SettingsService } from 'src/app/services/settings.service'
import { ToastService } from 'src/app/services/toast.service' import { ToastService } from 'src/app/services/toast.service'
import { WebsocketStatusService } from 'src/app/services/websocket-status.service'
import { import {
filterRulesDiffer, filterRulesDiffer,
isFullTextFilterRule, isFullTextFilterRule,
@ -113,7 +113,7 @@ export class DocumentListComponent
private router: Router, private router: Router,
private toastService: ToastService, private toastService: ToastService,
private modalService: NgbModal, private modalService: NgbModal,
private consumerStatusService: ConsumerStatusService, private websocketStatusService: WebsocketStatusService,
public openDocumentsService: OpenDocumentsService, public openDocumentsService: OpenDocumentsService,
public settingsService: SettingsService, public settingsService: SettingsService,
private hotKeyService: HotKeyService, private hotKeyService: HotKeyService,
@ -234,13 +234,17 @@ export class DocumentListComponent
} }
ngOnInit(): void { ngOnInit(): void {
this.consumerStatusService this.websocketStatusService
.onDocumentConsumptionFinished() .onDocumentConsumptionFinished()
.pipe(takeUntil(this.unsubscribeNotifier)) .pipe(takeUntil(this.unsubscribeNotifier))
.subscribe(() => { .subscribe(() => {
this.list.reload() this.list.reload()
}) })
this.websocketStatusService.onDocumentDeleted().subscribe(() => {
this.list.reload()
})
this.route.paramMap this.route.paramMap
.pipe( .pipe(
filter((params) => params.has('id')), // only on saved view e.g. /view/id 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" <pngx-dates-dropdown class="flex-fill fade" [class.show]="show"
title="Dates" i18n-title title="Dates" i18n-title
(datesSet)="updateRules()" (datesSet)="updateRules()"
[(createdDateBefore)]="dateCreatedBefore" [(createdDateTo)]="dateCreatedTo"
[(createdDateAfter)]="dateCreatedAfter" [(createdDateFrom)]="dateCreatedFrom"
[(createdRelativeDate)]="dateCreatedRelativeDate" [(createdRelativeDate)]="dateCreatedRelativeDate"
[(addedDateBefore)]="dateAddedBefore" [(addedDateTo)]="dateAddedTo"
[(addedDateAfter)]="dateAddedAfter" [(addedDateFrom)]="dateAddedFrom"
[(addedRelativeDate)]="dateAddedRelativeDate"> [(addedRelativeDate)]="dateAddedRelativeDate">
</pngx-dates-dropdown> </pngx-dates-dropdown>
<pngx-permissions-filter-dropdown class="flex-fill fade" [class.show]="show" <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 { import {
FILTER_ADDED_AFTER, FILTER_ADDED_AFTER,
FILTER_ADDED_BEFORE, FILTER_ADDED_BEFORE,
FILTER_ADDED_FROM,
FILTER_ADDED_TO,
FILTER_ASN, FILTER_ASN,
FILTER_ASN_GT, FILTER_ASN_GT,
FILTER_ASN_ISNULL, FILTER_ASN_ISNULL,
@ -39,6 +41,8 @@ import {
FILTER_CORRESPONDENT, FILTER_CORRESPONDENT,
FILTER_CREATED_AFTER, FILTER_CREATED_AFTER,
FILTER_CREATED_BEFORE, FILTER_CREATED_BEFORE,
FILTER_CREATED_FROM,
FILTER_CREATED_TO,
FILTER_CUSTOM_FIELDS_QUERY, FILTER_CUSTOM_FIELDS_QUERY,
FILTER_CUSTOM_FIELDS_TEXT, FILTER_CUSTOM_FIELDS_TEXT,
FILTER_DOCUMENT_TYPE, FILTER_DOCUMENT_TYPE,
@ -465,48 +469,92 @@ describe('FilterEditorComponent', () => {
]) ])
})) }))
it('should ingest filter rules for date created after', fakeAsync(() => { it('should ingest filter rules for date created after and adjust date by 1 day', fakeAsync(() => {
expect(component.dateCreatedAfter).toBeNull() expect(component.dateCreatedFrom).toBeNull()
component.filterRules = [ component.filterRules = [
{ {
rule_type: FILTER_CREATED_AFTER, rule_type: FILTER_CREATED_AFTER,
value: '2023-05-14', 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(() => { it('should ingest filter rules for date created from', fakeAsync(() => {
expect(component.dateCreatedBefore).toBeNull() 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 = [ component.filterRules = [
{ {
rule_type: FILTER_CREATED_BEFORE, rule_type: FILTER_CREATED_BEFORE,
value: '2023-05-14', 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(() => { it('should ingest filter rules for date created to', fakeAsync(() => {
expect(component.dateAddedAfter).toBeNull() 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 = [ component.filterRules = [
{ {
rule_type: FILTER_ADDED_AFTER, rule_type: FILTER_ADDED_AFTER,
value: '2023-05-14', 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(() => { it('should ingest filter rules for date added from', fakeAsync(() => {
expect(component.dateAddedBefore).toBeNull() 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 = [ component.filterRules = [
{ {
rule_type: FILTER_ADDED_BEFORE, rule_type: FILTER_ADDED_BEFORE,
value: '2023-05-14', 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(() => { 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( const dateCreatedDropdown = fixture.debugElement.queryAll(
By.directive(DatesDropdownComponent) By.directive(DatesDropdownComponent)
)[0] )[0]
@ -1473,18 +1521,18 @@ describe('FilterEditorComponent', () => {
dateCreatedAfter.nativeElement.value = '05/14/2023' dateCreatedAfter.nativeElement.value = '05/14/2023'
// dateCreatedAfter.triggerEventHandler('change') // dateCreatedAfter.triggerEventHandler('change')
// TODO: why isn't ngModel triggering this on change? // TODO: why isn't ngModel triggering this on change?
component.dateCreatedAfter = '2023-05-14' component.dateCreatedFrom = '2023-05-14'
fixture.detectChanges() fixture.detectChanges()
tick(400) tick(400)
expect(component.filterRules).toEqual([ expect(component.filterRules).toEqual([
{ {
rule_type: FILTER_CREATED_AFTER, rule_type: FILTER_CREATED_FROM,
value: '2023-05-14', 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( const dateCreatedDropdown = fixture.debugElement.queryAll(
By.directive(DatesDropdownComponent) By.directive(DatesDropdownComponent)
)[0] )[0]
@ -1493,12 +1541,12 @@ describe('FilterEditorComponent', () => {
dateCreatedBefore.nativeElement.value = '05/14/2023' dateCreatedBefore.nativeElement.value = '05/14/2023'
// dateCreatedBefore.triggerEventHandler('change') // dateCreatedBefore.triggerEventHandler('change')
// TODO: why isn't ngModel triggering this on change? // TODO: why isn't ngModel triggering this on change?
component.dateCreatedBefore = '2023-05-14' component.dateCreatedTo = '2023-05-14'
fixture.detectChanges() fixture.detectChanges()
tick(400) tick(400)
expect(component.filterRules).toEqual([ expect(component.filterRules).toEqual([
{ {
rule_type: FILTER_CREATED_BEFORE, rule_type: FILTER_CREATED_TO,
value: '2023-05-14', value: '2023-05-14',
}, },
]) ])
@ -1578,12 +1626,12 @@ describe('FilterEditorComponent', () => {
dateAddedAfter.nativeElement.value = '05/14/2023' dateAddedAfter.nativeElement.value = '05/14/2023'
// dateAddedAfter.triggerEventHandler('change') // dateAddedAfter.triggerEventHandler('change')
// TODO: why isn't ngModel triggering this on change? // TODO: why isn't ngModel triggering this on change?
component.dateAddedAfter = '2023-05-14' component.dateAddedFrom = '2023-05-14'
fixture.detectChanges() fixture.detectChanges()
tick(400) tick(400)
expect(component.filterRules).toEqual([ expect(component.filterRules).toEqual([
{ {
rule_type: FILTER_ADDED_AFTER, rule_type: FILTER_ADDED_FROM,
value: '2023-05-14', value: '2023-05-14',
}, },
]) ])
@ -1598,12 +1646,12 @@ describe('FilterEditorComponent', () => {
dateAddedBefore.nativeElement.value = '05/14/2023' dateAddedBefore.nativeElement.value = '05/14/2023'
// dateAddedBefore.triggerEventHandler('change') // dateAddedBefore.triggerEventHandler('change')
// TODO: why isn't ngModel triggering this on change? // TODO: why isn't ngModel triggering this on change?
component.dateAddedBefore = '2023-05-14' component.dateAddedTo = '2023-05-14'
fixture.detectChanges() fixture.detectChanges()
tick(400) tick(400)
expect(component.filterRules).toEqual([ expect(component.filterRules).toEqual([
{ {
rule_type: FILTER_ADDED_BEFORE, rule_type: FILTER_ADDED_TO,
value: '2023-05-14', value: '2023-05-14',
}, },
]) ])

View File

@ -38,6 +38,8 @@ import { FilterRule } from 'src/app/data/filter-rule'
import { import {
FILTER_ADDED_AFTER, FILTER_ADDED_AFTER,
FILTER_ADDED_BEFORE, FILTER_ADDED_BEFORE,
FILTER_ADDED_FROM,
FILTER_ADDED_TO,
FILTER_ASN, FILTER_ASN,
FILTER_ASN_GT, FILTER_ASN_GT,
FILTER_ASN_ISNULL, FILTER_ASN_ISNULL,
@ -45,6 +47,8 @@ import {
FILTER_CORRESPONDENT, FILTER_CORRESPONDENT,
FILTER_CREATED_AFTER, FILTER_CREATED_AFTER,
FILTER_CREATED_BEFORE, FILTER_CREATED_BEFORE,
FILTER_CREATED_FROM,
FILTER_CREATED_TO,
FILTER_CUSTOM_FIELDS_QUERY, FILTER_CUSTOM_FIELDS_QUERY,
FILTER_CUSTOM_FIELDS_TEXT, FILTER_CUSTOM_FIELDS_TEXT,
FILTER_DOCUMENT_TYPE, 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_QUERY_REGEXP_ADDED = /added:\[([^\]]+)\]/g
const RELATIVE_DATE_QUERYSTRINGS = [ const RELATIVE_DATE_QUERYSTRINGS = [
{ {
relativeDate: RelativeDate.LAST_7_DAYS, relativeDate: RelativeDate.WITHIN_1_WEEK,
dateQuery: '-1 week to now', dateQuery: '-1 week to now',
}, },
{ {
relativeDate: RelativeDate.LAST_MONTH, relativeDate: RelativeDate.WITHIN_1_MONTH,
dateQuery: '-1 month to now', dateQuery: '-1 month to now',
}, },
{ {
relativeDate: RelativeDate.LAST_3_MONTHS, relativeDate: RelativeDate.WITHIN_3_MONTHS,
dateQuery: '-3 month to now', dateQuery: '-3 month to now',
}, },
{ {
relativeDate: RelativeDate.LAST_YEAR, relativeDate: RelativeDate.WITHIN_1_YEAR,
dateQuery: '-1 year to now', dateQuery: '-1 year to now',
}, },
] ]
@ -349,10 +353,10 @@ export class FilterEditorComponent
storagePathSelectionModel = new FilterableDropdownSelectionModel() storagePathSelectionModel = new FilterableDropdownSelectionModel()
customFieldQueriesModel = new CustomFieldQueriesModel() customFieldQueriesModel = new CustomFieldQueriesModel()
dateCreatedBefore: string dateCreatedTo: string
dateCreatedAfter: string dateCreatedFrom: string
dateAddedBefore: string dateAddedTo: string
dateAddedAfter: string dateAddedFrom: string
dateCreatedRelativeDate: RelativeDate dateCreatedRelativeDate: RelativeDate
dateAddedRelativeDate: RelativeDate dateAddedRelativeDate: RelativeDate
@ -385,10 +389,10 @@ export class FilterEditorComponent
this.customFieldQueriesModel.clear(false) this.customFieldQueriesModel.clear(false)
this._textFilter = null this._textFilter = null
this._moreLikeId = null this._moreLikeId = null
this.dateAddedBefore = null this.dateAddedTo = null
this.dateAddedAfter = null this.dateAddedFrom = null
this.dateCreatedBefore = null this.dateCreatedTo = null
this.dateCreatedAfter = null this.dateCreatedFrom = null
this.dateCreatedRelativeDate = null this.dateCreatedRelativeDate = null
this.dateAddedRelativeDate = null this.dateAddedRelativeDate = null
this.textFilterModifier = TEXT_FILTER_MODIFIER_EQUALS this.textFilterModifier = TEXT_FILTER_MODIFIER_EQUALS
@ -458,16 +462,40 @@ export class FilterEditorComponent
}) })
break break
case FILTER_CREATED_AFTER: 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 break
case FILTER_CREATED_BEFORE: 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 break
case FILTER_ADDED_AFTER: 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 break
case FILTER_ADDED_BEFORE: 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 break
case FILTER_HAS_TAGS_ALL: case FILTER_HAS_TAGS_ALL:
this.tagSelectionModel.logicalOperator = LogicalOperator.And this.tagSelectionModel.logicalOperator = LogicalOperator.And
@ -814,28 +842,28 @@ export class FilterEditorComponent
value: JSON.stringify(queries[0]), value: JSON.stringify(queries[0]),
}) })
} }
if (this.dateCreatedBefore) { if (this.dateCreatedTo) {
filterRules.push({ filterRules.push({
rule_type: FILTER_CREATED_BEFORE, rule_type: FILTER_CREATED_TO,
value: this.dateCreatedBefore, value: this.dateCreatedTo,
}) })
} }
if (this.dateCreatedAfter) { if (this.dateCreatedFrom) {
filterRules.push({ filterRules.push({
rule_type: FILTER_CREATED_AFTER, rule_type: FILTER_CREATED_FROM,
value: this.dateCreatedAfter, value: this.dateCreatedFrom,
}) })
} }
if (this.dateAddedBefore) { if (this.dateAddedTo) {
filterRules.push({ filterRules.push({
rule_type: FILTER_ADDED_BEFORE, rule_type: FILTER_ADDED_TO,
value: this.dateAddedBefore, value: this.dateAddedTo,
}) })
} }
if (this.dateAddedAfter) { if (this.dateAddedFrom) {
filterRules.push({ filterRules.push({
rule_type: FILTER_ADDED_AFTER, rule_type: FILTER_ADDED_FROM,
value: this.dateAddedAfter, value: this.dateAddedFrom,
}) })
} }
if ( if (

View File

@ -229,7 +229,7 @@ export class MailComponent
}, },
error: (e) => { error: (e) => {
this.toastService.showError( this.toastService.showError(
$localize`Error processing mail account "${account.name}")`, $localize`Error processing mail account "${account.name}"`,
e e
) )
}, },

View File

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

View File

@ -36,6 +36,11 @@ export const FILTER_CREATED_DAY = 12
export const FILTER_ADDED_BEFORE = 13 export const FILTER_ADDED_BEFORE = 13
export const FILTER_ADDED_AFTER = 14 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_BEFORE = 15
export const FILTER_MODIFIED_AFTER = 16 export const FILTER_MODIFIED_AFTER = 16
@ -179,6 +184,18 @@ export const FILTER_RULE_TYPES: FilterRuleType[] = [
datatype: 'date', datatype: 'date',
multi: false, 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, id: FILTER_CREATED_YEAR,
filtervar: 'created__year', filtervar: 'created__year',
@ -210,6 +227,18 @@ export const FILTER_RULE_TYPES: FilterRuleType[] = [
datatype: 'date', datatype: 'date',
multi: false, 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, id: FILTER_MODIFIED_BEFORE,
filtervar: 'modified__date__lt', filtervar: 'modified__date__lt',

View File

@ -33,6 +33,8 @@ export const SETTINGS_KEYS = {
DARK_MODE_THUMB_INVERTED: 'general-settings:dark-mode:thumb-inverted', DARK_MODE_THUMB_INVERTED: 'general-settings:dark-mode:thumb-inverted',
THEME_COLOR: 'general-settings:theme:color', THEME_COLOR: 'general-settings:theme:color',
USE_NATIVE_PDF_VIEWER: 'general-settings:document-details:native-pdf-viewer', 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_LOCALE: 'general-settings:date-display:date-locale',
DATE_FORMAT: 'general-settings:date-display:date-format', DATE_FORMAT: 'general-settings:date-display:date-format',
NOTIFICATIONS_CONSUMER_NEW_DOCUMENT: NOTIFICATIONS_CONSUMER_NEW_DOCUMENT:
@ -269,4 +271,9 @@ export const SETTINGS: UiSetting[] = [
type: 'boolean', type: 'boolean',
default: false, 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 filename?: string
task_id?: string task_id?: string
current_progress?: number current_progress?: number

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', () => { it('creates toasts with defaults on showInfo and showError', () => {
toastService.showInfo('Info toast') toastService.showInfo('Info toast')
toastService.showError('Error toast') toastService.showError('Error toast')
@ -54,4 +81,29 @@ describe('ToastService', () => {
expect(toasts).toHaveLength(0) 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 { Injectable } from '@angular/core'
import { Subject } from 'rxjs' import { Subject } from 'rxjs'
import { v4 as uuidv4 } from 'uuid'
export interface Toast { export interface Toast {
id?: string
content: string content: string
delay: number delay: number
@ -22,13 +25,32 @@ export interface Toast {
}) })
export class ToastService { export class ToastService {
constructor() {} constructor() {}
_suppressPopupToasts: boolean
set suppressPopupToasts(value: boolean) {
this._suppressPopupToasts = value
this.showToast.next(null)
}
private toasts: Toast[] = [] private toasts: Toast[] = []
private toastsSubject: Subject<Toast[]> = new Subject() private toastsSubject: Subject<Toast[]> = new Subject()
public showToast: Subject<Toast> = new Subject()
show(toast: Toast) { 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) this.toastsSubject.next(this.toasts)
} }
@ -46,7 +68,7 @@ export class ToastService {
} }
closeToast(toast: Toast) { closeToast(toast: Toast) {
let index = this.toasts.findIndex((t) => t == toast) let index = this.toasts.findIndex((t) => t.id == toast.id)
if (index > -1) { if (index > -1) {
this.toasts.splice(index, 1) this.toasts.splice(index, 1)
this.toastsSubject.next(this.toasts) this.toastsSubject.next(this.toasts)
@ -56,4 +78,10 @@ export class ToastService {
getToasts() { getToasts() {
return this.toastsSubject 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' } from '@angular/common/http/testing'
import { TestBed } from '@angular/core/testing' import { TestBed } from '@angular/core/testing'
import { environment } from 'src/environments/environment' import { environment } from 'src/environments/environment'
import {
ConsumerStatusService,
FileStatusPhase,
} from './consumer-status.service'
import { UploadDocumentsService } from './upload-documents.service' import { UploadDocumentsService } from './upload-documents.service'
import {
FileStatusPhase,
WebsocketStatusService,
} from './websocket-status.service'
const files = [ const files = [
{ {
@ -45,14 +45,14 @@ const fileList = {
describe('UploadDocumentsService', () => { describe('UploadDocumentsService', () => {
let httpTestingController: HttpTestingController let httpTestingController: HttpTestingController
let uploadDocumentsService: UploadDocumentsService let uploadDocumentsService: UploadDocumentsService
let consumerStatusService: ConsumerStatusService let websocketStatusService: WebsocketStatusService
beforeEach(() => { beforeEach(() => {
TestBed.configureTestingModule({ TestBed.configureTestingModule({
imports: [], imports: [],
providers: [ providers: [
UploadDocumentsService, UploadDocumentsService,
ConsumerStatusService, WebsocketStatusService,
provideHttpClient(withInterceptorsFromDi()), provideHttpClient(withInterceptorsFromDi()),
provideHttpClientTesting(), provideHttpClientTesting(),
], ],
@ -60,7 +60,7 @@ describe('UploadDocumentsService', () => {
httpTestingController = TestBed.inject(HttpTestingController) httpTestingController = TestBed.inject(HttpTestingController)
uploadDocumentsService = TestBed.inject(UploadDocumentsService) uploadDocumentsService = TestBed.inject(UploadDocumentsService)
consumerStatusService = TestBed.inject(ConsumerStatusService) websocketStatusService = TestBed.inject(WebsocketStatusService)
}) })
afterEach(() => { afterEach(() => {
@ -80,11 +80,11 @@ describe('UploadDocumentsService', () => {
it('updates progress during upload and failure', () => { it('updates progress during upload and failure', () => {
uploadDocumentsService.uploadFiles(fileList) uploadDocumentsService.uploadFiles(fileList)
expect(consumerStatusService.getConsumerStatusNotCompleted()).toHaveLength( expect(websocketStatusService.getConsumerStatusNotCompleted()).toHaveLength(
2 2
) )
expect( expect(
consumerStatusService.getConsumerStatus(FileStatusPhase.UPLOADING) websocketStatusService.getConsumerStatus(FileStatusPhase.UPLOADING)
).toHaveLength(0) ).toHaveLength(0)
const req = httpTestingController.match( const req = httpTestingController.match(
@ -98,7 +98,7 @@ describe('UploadDocumentsService', () => {
}) })
expect( expect(
consumerStatusService.getConsumerStatus(FileStatusPhase.UPLOADING) websocketStatusService.getConsumerStatus(FileStatusPhase.UPLOADING)
).toHaveLength(1) ).toHaveLength(1)
}) })
@ -110,7 +110,7 @@ describe('UploadDocumentsService', () => {
) )
expect( expect(
consumerStatusService.getConsumerStatus(FileStatusPhase.FAILED) websocketStatusService.getConsumerStatus(FileStatusPhase.FAILED)
).toHaveLength(0) ).toHaveLength(0)
req[0].flush( req[0].flush(
@ -122,7 +122,7 @@ describe('UploadDocumentsService', () => {
) )
expect( expect(
consumerStatusService.getConsumerStatus(FileStatusPhase.FAILED) websocketStatusService.getConsumerStatus(FileStatusPhase.FAILED)
).toHaveLength(1) ).toHaveLength(1)
uploadDocumentsService.uploadFiles(fileList) uploadDocumentsService.uploadFiles(fileList)
@ -140,7 +140,7 @@ describe('UploadDocumentsService', () => {
) )
expect( expect(
consumerStatusService.getConsumerStatus(FileStatusPhase.FAILED) websocketStatusService.getConsumerStatus(FileStatusPhase.FAILED)
).toHaveLength(2) ).toHaveLength(2)
}) })

View File

@ -2,11 +2,11 @@ import { HttpEventType } from '@angular/common/http'
import { Injectable } from '@angular/core' import { Injectable } from '@angular/core'
import { FileSystemFileEntry, NgxFileDropEntry } from 'ngx-file-drop' import { FileSystemFileEntry, NgxFileDropEntry } from 'ngx-file-drop'
import { Subscription } from 'rxjs' import { Subscription } from 'rxjs'
import {
ConsumerStatusService,
FileStatusPhase,
} from './consumer-status.service'
import { DocumentService } from './rest/document.service' import { DocumentService } from './rest/document.service'
import {
FileStatusPhase,
WebsocketStatusService,
} from './websocket-status.service'
@Injectable({ @Injectable({
providedIn: 'root', providedIn: 'root',
@ -16,7 +16,7 @@ export class UploadDocumentsService {
constructor( constructor(
private documentService: DocumentService, private documentService: DocumentService,
private consumerStatusService: ConsumerStatusService private websocketStatusService: WebsocketStatusService
) {} ) {}
onNgxFileDrop(files: NgxFileDropEntry[]) { onNgxFileDrop(files: NgxFileDropEntry[]) {
@ -37,7 +37,7 @@ export class UploadDocumentsService {
private uploadFile(file: File) { private uploadFile(file: File) {
let formData = new FormData() let formData = new FormData()
formData.append('document', file, file.name) formData.append('document', file, file.name)
let status = this.consumerStatusService.newFileUpload(file.name) let status = this.websocketStatusService.newFileUpload(file.name)
status.message = $localize`Connecting...` status.message = $localize`Connecting...`
@ -61,11 +61,11 @@ export class UploadDocumentsService {
error: (error) => { error: (error) => {
switch (error.status) { switch (error.status) {
case 400: { case 400: {
this.consumerStatusService.fail(status, error.error.document) this.websocketStatusService.fail(status, error.error.document)
break break
} }
default: { default: {
this.consumerStatusService.fail( this.websocketStatusService.fail(
status, status,
$localize`HTTP error: ${error.status} ${error.statusText}` $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 { Injectable } from '@angular/core'
import { Subject } from 'rxjs' import { Subject } from 'rxjs'
import { environment } from 'src/environments/environment' 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' import { SettingsService } from './settings.service'
export enum WebsocketStatusType {
STATUS_UPDATE = 'status_update',
DOCUMENTS_DELETED = 'documents_deleted',
}
// see ProgressStatusOptions in src/documents/plugins/helpers.py // see ProgressStatusOptions in src/documents/plugins/helpers.py
export enum FileStatusPhase { export enum FileStatusPhase {
STARTED = 0, STARTED = 0,
@ -85,7 +91,7 @@ export class FileStatus {
@Injectable({ @Injectable({
providedIn: 'root', providedIn: 'root',
}) })
export class ConsumerStatusService { export class WebsocketStatusService {
constructor(private settingsService: SettingsService) {} constructor(private settingsService: SettingsService) {}
private statusWebSocket: WebSocket private statusWebSocket: WebSocket
@ -95,6 +101,7 @@ export class ConsumerStatusService {
private documentDetectedSubject = new Subject<FileStatus>() private documentDetectedSubject = new Subject<FileStatus>()
private documentConsumptionFinishedSubject = new Subject<FileStatus>() private documentConsumptionFinishedSubject = new Subject<FileStatus>()
private documentConsumptionFailedSubject = new Subject<FileStatus>() private documentConsumptionFailedSubject = new Subject<FileStatus>()
private documentDeletedSubject = new Subject<boolean>()
private get(taskId: string, filename?: string) { private get(taskId: string, filename?: string) {
let status = let status =
@ -145,63 +152,75 @@ export class ConsumerStatusService {
this.statusWebSocket = new WebSocket( this.statusWebSocket = new WebSocket(
`${environment.webSocketProtocol}//${environment.webSocketHost}${environment.webSocketBaseUrl}status/` `${environment.webSocketProtocol}//${environment.webSocketHost}${environment.webSocketBaseUrl}status/`
) )
this.statusWebSocket.onmessage = (ev) => { this.statusWebSocket.onmessage = (ev: MessageEvent) => {
let statusMessage: WebsocketConsumerStatusMessage = JSON.parse(ev['data']) const {
type,
data: messageData,
}: {
type: WebsocketStatusType
data: WebsocketProgressMessage | WebsocketDocumentsDeletedMessage
} = JSON.parse(ev.data)
// fallback if backend didn't restrict message switch (type) {
if ( case WebsocketStatusType.DOCUMENTS_DELETED:
statusMessage.owner_id && this.documentDeletedSubject.next(true)
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)
break break
case FileStatusPhase.SUCCESS: case WebsocketStatusType.STATUS_UPDATE:
this.documentConsumptionFinishedSubject.next(status) this.handleProgressUpdate(messageData as WebsocketProgressMessage)
break
case FileStatusPhase.FAILED:
this.documentConsumptionFailedSubject.next(status)
break
default:
break 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) { fail(status: FileStatus, message: string) {
status.message = message status.message = message
status.phase = FileStatusPhase.FAILED status.phase = FileStatusPhase.FAILED
@ -250,4 +269,8 @@ export class ConsumerStatusService {
onDocumentDetected() { onDocumentDetected() {
return this.documentDetectedSubject return this.documentDetectedSubject
} }
onDocumentDeleted() {
return this.documentDeletedSubject
}
} }

View File

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

View File

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

View File

@ -24,6 +24,10 @@
--pngx-bg-alt2: var(--bs-gray-200); --pngx-bg-alt2: var(--bs-gray-200);
--pngx-bg-disabled: #f7f7f7; --pngx-bg-disabled: #f7f7f7;
--pngx-focus-alpha: 0.3; --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 // Dark text colors allow for maintain contrast with theme color changes

View File

@ -10,7 +10,7 @@ if TYPE_CHECKING:
class BulkArchiveStrategy: class BulkArchiveStrategy:
def __init__(self, zipf: ZipFile, follow_formatting: bool = False) -> None: def __init__(self, zipf: ZipFile, *, follow_formatting: bool = False) -> None:
self.zipf: ZipFile = zipf self.zipf: ZipFile = zipf
if follow_formatting: if follow_formatting:
self.make_unique_filename: Callable[..., Path | str] = ( self.make_unique_filename: Callable[..., Path | str] = (
@ -22,6 +22,7 @@ class BulkArchiveStrategy:
def _filename_only( def _filename_only(
self, self,
doc: Document, doc: Document,
*,
archive: bool = False, archive: bool = False,
folder: str = "", folder: str = "",
) -> str: ) -> str:
@ -33,7 +34,10 @@ class BulkArchiveStrategy:
""" """
counter = 0 counter = 0
while True: while True:
filename: str = folder + doc.get_public_filename(archive, counter) filename: str = folder + doc.get_public_filename(
archive=archive,
counter=counter,
)
if filename in self.zipf.namelist(): if filename in self.zipf.namelist():
counter += 1 counter += 1
else: else:
@ -42,6 +46,7 @@ class BulkArchiveStrategy:
def _formatted_filepath( def _formatted_filepath(
self, self,
doc: Document, doc: Document,
*,
archive: bool = False, archive: bool = False,
folder: str = "", folder: str = "",
) -> Path: ) -> Path:

View File

@ -24,6 +24,7 @@ from documents.models import Document
from documents.models import DocumentType from documents.models import DocumentType
from documents.models import StoragePath from documents.models import StoragePath
from documents.permissions import set_permissions_for_object from documents.permissions import set_permissions_for_object
from documents.plugins.helpers import DocumentsStatusManager
from documents.tasks import bulk_update_documents from documents.tasks import bulk_update_documents
from documents.tasks import consume_file from documents.tasks import consume_file
from documents.tasks import update_document_content_maybe_archive_file from documents.tasks import update_document_content_maybe_archive_file
@ -219,6 +220,9 @@ def delete(doc_ids: list[int]) -> Literal["OK"]:
with index.open_index_writer() as writer: with index.open_index_writer() as writer:
for id in doc_ids: for id in doc_ids:
index.remove_document_by_id(writer, id) index.remove_document_by_id(writer, id)
status_mgr = DocumentsStatusManager()
status_mgr.send_documents_deleted(doc_ids)
except Exception as e: except Exception as e:
if "Data too long for column" in str(e): if "Data too long for column" in str(e):
logger.warning( logger.warning(
@ -241,6 +245,7 @@ def reprocess(doc_ids: list[int]) -> Literal["OK"]:
def set_permissions( def set_permissions(
doc_ids: list[int], doc_ids: list[int],
set_permissions, set_permissions,
*,
owner=None, owner=None,
merge=False, merge=False,
) -> Literal["OK"]: ) -> Literal["OK"]:
@ -305,6 +310,7 @@ def rotate(doc_ids: list[int], degrees: int) -> Literal["OK"]:
def merge( def merge(
doc_ids: list[int], doc_ids: list[int],
*,
metadata_document_id: int | None = None, metadata_document_id: int | None = None,
delete_originals: bool = False, delete_originals: bool = False,
user: User | None = None, user: User | None = None,
@ -383,6 +389,7 @@ def merge(
def split( def split(
doc_ids: list[int], doc_ids: list[int],
pages: list[list[int]], pages: list[list[int]],
*,
delete_originals: bool = False, delete_originals: bool = False,
user: User | None = None, user: User | None = None,
) -> Literal["OK"]: ) -> Literal["OK"]:

View File

@ -1,6 +1,7 @@
import logging import logging
import pickle import pickle
import re import re
import time
import warnings import warnings
from collections.abc import Iterator from collections.abc import Iterator
from hashlib import sha256 from hashlib import sha256
@ -141,6 +142,19 @@ class DocumentClassifier:
): ):
raise IncompatibleClassifierVersionError("sklearn version update") raise IncompatibleClassifierVersionError("sklearn version update")
def set_last_checked(self) -> None:
# save a timestamp of the last time we checked for retraining to a file
with Path(settings.MODEL_FILE.with_suffix(".last_checked")).open("w") as f:
f.write(str(time.time()))
def get_last_checked(self) -> float | None:
# load the timestamp of the last time we checked for retraining
try:
with Path(settings.MODEL_FILE.with_suffix(".last_checked")).open("r") as f:
return float(f.read())
except FileNotFoundError: # pragma: no cover
return None
def save(self) -> None: def save(self) -> None:
target_file: Path = settings.MODEL_FILE target_file: Path = settings.MODEL_FILE
target_file_temp: Path = target_file.with_suffix(".pickle.part") target_file_temp: Path = target_file.with_suffix(".pickle.part")
@ -161,6 +175,7 @@ class DocumentClassifier:
pickle.dump(self.storage_path_classifier, f) pickle.dump(self.storage_path_classifier, f)
target_file_temp.rename(target_file) target_file_temp.rename(target_file)
self.set_last_checked()
def train(self) -> bool: def train(self) -> bool:
# Get non-inbox documents # Get non-inbox documents
@ -229,6 +244,7 @@ class DocumentClassifier:
and self.last_doc_change_time >= latest_doc_change and self.last_doc_change_time >= latest_doc_change
) and self.last_auto_type_hash == hasher.digest(): ) and self.last_auto_type_hash == hasher.digest():
logger.info("No updates since last training") logger.info("No updates since last training")
self.set_last_checked()
# Set the classifier information into the cache # Set the classifier information into the cache
# Caching for 50 minutes, so slightly less than the normal retrain time # Caching for 50 minutes, so slightly less than the normal retrain time
cache.set( cache.set(

View File

@ -43,7 +43,7 @@ def delete_empty_directories(directory, root):
directory = os.path.normpath(os.path.dirname(directory)) directory = os.path.normpath(os.path.dirname(directory))
def generate_unique_filename(doc, archive_filename=False): def generate_unique_filename(doc, *, archive_filename=False):
""" """
Generates a unique filename for doc in settings.ORIGINALS_DIR. Generates a unique filename for doc in settings.ORIGINALS_DIR.
@ -77,7 +77,7 @@ def generate_unique_filename(doc, archive_filename=False):
while True: while True:
new_filename = generate_filename( new_filename = generate_filename(
doc, doc,
counter, counter=counter,
archive_filename=archive_filename, archive_filename=archive_filename,
) )
if new_filename == old_filename: if new_filename == old_filename:
@ -92,6 +92,7 @@ def generate_unique_filename(doc, archive_filename=False):
def generate_filename( def generate_filename(
doc: Document, doc: Document,
*,
counter=0, counter=0,
append_gpg=True, append_gpg=True,
archive_filename=False, archive_filename=False,

View File

@ -41,7 +41,19 @@ from documents.models import Tag
CHAR_KWARGS = ["istartswith", "iendswith", "icontains", "iexact"] CHAR_KWARGS = ["istartswith", "iendswith", "icontains", "iexact"]
ID_KWARGS = ["in", "exact"] ID_KWARGS = ["in", "exact"]
INT_KWARGS = ["exact", "gt", "gte", "lt", "lte", "isnull"] INT_KWARGS = ["exact", "gt", "gte", "lt", "lte", "isnull"]
DATE_KWARGS = ["year", "month", "day", "date__gt", "gt", "date__lt", "lt"] DATE_KWARGS = [
"year",
"month",
"day",
"date__gt",
"date__gte",
"gt",
"gte",
"date__lt",
"date__lte",
"lt",
"lte",
]
CUSTOM_FIELD_QUERY_MAX_DEPTH = 10 CUSTOM_FIELD_QUERY_MAX_DEPTH = 10
CUSTOM_FIELD_QUERY_MAX_ATOMS = 20 CUSTOM_FIELD_QUERY_MAX_ATOMS = 20
@ -85,7 +97,7 @@ class StoragePathFilterSet(FilterSet):
class ObjectFilter(Filter): class ObjectFilter(Filter):
def __init__(self, exclude=False, in_list=False, field_name=""): def __init__(self, *, exclude=False, in_list=False, field_name=""):
super().__init__() super().__init__()
self.exclude = exclude self.exclude = exclude
self.in_list = in_list self.in_list = in_list

View File

@ -85,7 +85,7 @@ def get_schema() -> Schema:
) )
def open_index(recreate=False) -> FileIndex: def open_index(*, recreate=False) -> FileIndex:
try: try:
if exists_in(settings.INDEX_DIR) and not recreate: if exists_in(settings.INDEX_DIR) and not recreate:
return open_dir(settings.INDEX_DIR, schema=get_schema()) return open_dir(settings.INDEX_DIR, schema=get_schema())
@ -101,7 +101,7 @@ def open_index(recreate=False) -> FileIndex:
@contextmanager @contextmanager
def open_index_writer(optimize=False) -> AsyncWriter: def open_index_writer(*, optimize=False) -> AsyncWriter:
writer = AsyncWriter(open_index()) writer = AsyncWriter(open_index())
try: try:
@ -425,7 +425,7 @@ def autocomplete(
def get_permissions_criterias(user: User | None = None) -> list: def get_permissions_criterias(user: User | None = None) -> list:
user_criterias = [query.Term("has_owner", False)] user_criterias = [query.Term("has_owner", text=False)]
if user is not None: if user is not None:
if user.is_superuser: # superusers see all docs if user.is_superuser: # superusers see all docs
user_criterias = [] user_criterias = []

View File

@ -9,7 +9,7 @@ class Command(BaseCommand):
# This code is taken almost entirely from https://github.com/wagtail/wagtail/pull/11912 with all credit to the original author. # This code is taken almost entirely from https://github.com/wagtail/wagtail/pull/11912 with all credit to the original author.
help = "Converts UUID columns from char type to the native UUID type used in MariaDB 10.7+ and Django 5.0+." help = "Converts UUID columns from char type to the native UUID type used in MariaDB 10.7+ and Django 5.0+."
def convert_field(self, model, field_name, null=False): def convert_field(self, model, field_name, *, null=False):
if model._meta.get_field(field_name).model != model: # pragma: no cover if model._meta.get_field(field_name).model != model: # pragma: no cover
# Field is inherited from a parent model # Field is inherited from a parent model
return return

View File

@ -248,15 +248,15 @@ class Command(BaseCommand):
return return
if settings.CONSUMER_POLLING == 0 and INotify: if settings.CONSUMER_POLLING == 0 and INotify:
self.handle_inotify(directory, recursive, options["testing"]) self.handle_inotify(directory, recursive, is_testing=options["testing"])
else: else:
if INotify is None and settings.CONSUMER_POLLING == 0: # pragma: no cover if INotify is None and settings.CONSUMER_POLLING == 0: # pragma: no cover
logger.warning("Using polling as INotify import failed") logger.warning("Using polling as INotify import failed")
self.handle_polling(directory, recursive, options["testing"]) self.handle_polling(directory, recursive, is_testing=options["testing"])
logger.debug("Consumer exiting.") logger.debug("Consumer exiting.")
def handle_polling(self, directory, recursive, is_testing: bool): def handle_polling(self, directory, recursive, *, is_testing: bool):
logger.info(f"Polling directory for changes: {directory}") logger.info(f"Polling directory for changes: {directory}")
timeout = None timeout = None
@ -283,7 +283,7 @@ class Command(BaseCommand):
observer.stop() observer.stop()
observer.join() observer.join()
def handle_inotify(self, directory, recursive, is_testing: bool): def handle_inotify(self, directory, recursive, *, is_testing: bool):
logger.info(f"Using inotify to watch directory for changes: {directory}") logger.info(f"Using inotify to watch directory for changes: {directory}")
timeout_ms = None timeout_ms = None

View File

@ -84,7 +84,7 @@ def source_path(doc):
return os.path.join(settings.ORIGINALS_DIR, fname) return os.path.join(settings.ORIGINALS_DIR, fname)
def generate_unique_filename(doc, archive_filename=False): def generate_unique_filename(doc, *, archive_filename=False):
if archive_filename: if archive_filename:
old_filename = doc.archive_filename old_filename = doc.archive_filename
root = settings.ARCHIVE_DIR root = settings.ARCHIVE_DIR
@ -97,7 +97,7 @@ def generate_unique_filename(doc, archive_filename=False):
while True: while True:
new_filename = generate_filename( new_filename = generate_filename(
doc, doc,
counter, counter=counter,
archive_filename=archive_filename, archive_filename=archive_filename,
) )
if new_filename == old_filename: if new_filename == old_filename:
@ -110,7 +110,7 @@ def generate_unique_filename(doc, archive_filename=False):
return new_filename return new_filename
def generate_filename(doc, counter=0, append_gpg=True, archive_filename=False): def generate_filename(doc, *, counter=0, append_gpg=True, archive_filename=False):
path = "" path = ""
try: try:

View File

@ -0,0 +1,69 @@
# Generated by Django 5.1.4 on 2025-02-06 05:54
from django.db import migrations
from django.db import models
class Migration(migrations.Migration):
dependencies = [
("documents", "1061_workflowactionwebhook_as_json"),
]
operations = [
migrations.AlterField(
model_name="savedviewfilterrule",
name="rule_type",
field=models.PositiveIntegerField(
choices=[
(0, "title contains"),
(1, "content contains"),
(2, "ASN is"),
(3, "correspondent is"),
(4, "document type is"),
(5, "is in inbox"),
(6, "has tag"),
(7, "has any tag"),
(8, "created before"),
(9, "created after"),
(10, "created year is"),
(11, "created month is"),
(12, "created day is"),
(13, "added before"),
(14, "added after"),
(15, "modified before"),
(16, "modified after"),
(17, "does not have tag"),
(18, "does not have ASN"),
(19, "title or content contains"),
(20, "fulltext query"),
(21, "more like this"),
(22, "has tags in"),
(23, "ASN greater than"),
(24, "ASN less than"),
(25, "storage path is"),
(26, "has correspondent in"),
(27, "does not have correspondent in"),
(28, "has document type in"),
(29, "does not have document type in"),
(30, "has storage path in"),
(31, "does not have storage path in"),
(32, "owner is"),
(33, "has owner in"),
(34, "does not have owner"),
(35, "does not have owner in"),
(36, "has custom field value"),
(37, "is shared by me"),
(38, "has custom fields"),
(39, "has custom field in"),
(40, "does not have custom field in"),
(41, "does not have custom field"),
(42, "custom fields query"),
(43, "created to"),
(44, "created from"),
(45, "added to"),
(46, "added from"),
],
verbose_name="rule type",
),
),
]

View File

@ -337,7 +337,7 @@ class Document(SoftDeleteModel, ModelWithOwner):
def archive_file(self): def archive_file(self):
return open(self.archive_path, "rb") return open(self.archive_path, "rb")
def get_public_filename(self, archive=False, counter=0, suffix=None) -> str: def get_public_filename(self, *, archive=False, counter=0, suffix=None) -> str:
""" """
Returns a sanitized filename for the document, not including any paths. Returns a sanitized filename for the document, not including any paths.
""" """
@ -522,6 +522,10 @@ class SavedViewFilterRule(models.Model):
(40, _("does not have custom field in")), (40, _("does not have custom field in")),
(41, _("does not have custom field")), (41, _("does not have custom field")),
(42, _("custom fields query")), (42, _("custom fields query")),
(43, _("created to")),
(44, _("created from")),
(45, _("added to")),
(46, _("added from")),
] ]
saved_view = models.ForeignKey( saved_view = models.ForeignKey(

View File

@ -41,7 +41,7 @@ DATE_REGEX = re.compile(
r"(\b|(?!=([_-])))(\d{1,2}[\. ]+[a-zéûäëčžúřěáíóńźçŞğü]{3,9} \d{4}|[a-zéûäëčžúřěáíóńźçŞğü]{3,9} \d{1,2}, \d{4})(\b|(?=([_-])))|" r"(\b|(?!=([_-])))(\d{1,2}[\. ]+[a-zéûäëčžúřěáíóńźçŞğü]{3,9} \d{4}|[a-zéûäëčžúřěáíóńźçŞğü]{3,9} \d{1,2}, \d{4})(\b|(?=([_-])))|"
r"(\b|(?!=([_-])))([^\W\d_]{3,9} \d{1,2}, (\d{4}))(\b|(?=([_-])))|" r"(\b|(?!=([_-])))([^\W\d_]{3,9} \d{1,2}, (\d{4}))(\b|(?=([_-])))|"
r"(\b|(?!=([_-])))([^\W\d_]{3,9} \d{4})(\b|(?=([_-])))|" r"(\b|(?!=([_-])))([^\W\d_]{3,9} \d{4})(\b|(?=([_-])))|"
r"(\b|(?!=([_-])))(\d{1,2}[^ ]{2}[\. ]+[^ ]{3,9}[ \.\/-]\d{4})(\b|(?=([_-])))|" r"(\b|(?!=([_-])))(\d{1,2}[^ 0-9]{2}[\. ]+[^ ]{3,9}[ \.\/-]\d{4})(\b|(?=([_-])))|"
r"(\b|(?!=([_-])))(\b\d{1,2}[ \.\/-][a-zéûäëčžúřěáíóńźçŞğü]{3}[ \.\/-]\d{4})(\b|(?=([_-])))", r"(\b|(?!=([_-])))(\b\d{1,2}[ \.\/-][a-zéûäëčžúřěáíóńźçŞğü]{3}[ \.\/-]\d{4})(\b|(?=([_-])))",
re.IGNORECASE, re.IGNORECASE,
) )
@ -133,6 +133,7 @@ def get_parser_class_for_mime_type(mime_type: str) -> type["DocumentParser"] | N
def run_convert( def run_convert(
input_file, input_file,
output_file, output_file,
*,
density=None, density=None,
scale=None, scale=None,
alpha=None, alpha=None,

View File

@ -58,7 +58,7 @@ def get_groups_with_only_permission(obj, codename):
return Group.objects.filter(id__in=group_object_perm_group_ids).distinct() return Group.objects.filter(id__in=group_object_perm_group_ids).distinct()
def set_permissions_for_object(permissions: list[str], object, merge: bool = False): def set_permissions_for_object(permissions: list[str], object, *, merge: bool = False):
""" """
Set permissions for an object. The permissions are given as a list of strings Set permissions for an object. The permissions are given as a list of strings
in the format "action_modelname", e.g. "view_document". in the format "action_modelname", e.g. "view_document".

View File

@ -15,16 +15,14 @@ class ProgressStatusOptions(str, enum.Enum):
FAILED = "FAILED" FAILED = "FAILED"
class ProgressManager: class BaseStatusManager:
""" """
Handles sending of progress information via the channel layer, with proper management Handles sending of progress information via the channel layer, with proper management
of the open/close of the layer to ensure messages go out and everything is cleaned up of the open/close of the layer to ensure messages go out and everything is cleaned up
""" """
def __init__(self, filename: str, task_id: str | None = None) -> None: def __init__(self) -> None:
self.filename = filename
self._channel: RedisPubSubChannelLayer | None = None self._channel: RedisPubSubChannelLayer | None = None
self.task_id = task_id
def __enter__(self): def __enter__(self):
self.open() self.open()
@ -49,6 +47,24 @@ class ProgressManager:
async_to_sync(self._channel.flush) async_to_sync(self._channel.flush)
self._channel = None self._channel = None
def send(self, payload: dict[str, str | int | None]) -> None:
# Ensure the layer is open
self.open()
# Just for IDEs
if TYPE_CHECKING:
assert self._channel is not None
# Construct and send the update
async_to_sync(self._channel.group_send)("status_updates", payload)
class ProgressManager(BaseStatusManager):
def __init__(self, filename: str | None = None, task_id: str | None = None) -> None:
super().__init__()
self.filename = filename
self.task_id = task_id
def send_progress( def send_progress(
self, self,
status: ProgressStatusOptions, status: ProgressStatusOptions,
@ -57,13 +73,6 @@ class ProgressManager:
max_progress: int, max_progress: int,
extra_args: dict[str, str | int | None] | None = None, extra_args: dict[str, str | int | None] | None = None,
) -> None: ) -> None:
# Ensure the layer is open
self.open()
# Just for IDEs
if TYPE_CHECKING:
assert self._channel is not None
payload = { payload = {
"type": "status_update", "type": "status_update",
"data": { "data": {
@ -78,5 +87,16 @@ class ProgressManager:
if extra_args is not None: if extra_args is not None:
payload["data"].update(extra_args) payload["data"].update(extra_args)
# Construct and send the update self.send(payload)
async_to_sync(self._channel.group_send)("status_updates", payload)
class DocumentsStatusManager(BaseStatusManager):
def send_documents_deleted(self, documents: list[int]) -> None:
payload = {
"type": "documents_deleted",
"data": {
"documents": documents,
},
}
self.send(payload)

View File

@ -57,7 +57,7 @@ class SanityCheckFailedException(Exception):
pass pass
def check_sanity(progress=False) -> SanityCheckMessages: def check_sanity(*, progress=False) -> SanityCheckMessages:
messages = SanityCheckMessages() messages = SanityCheckMessages()
present_files = { present_files = {

View File

@ -85,6 +85,7 @@ def _suggestion_printer(
def set_correspondent( def set_correspondent(
sender, sender,
document: Document, document: Document,
*,
logging_group=None, logging_group=None,
classifier: DocumentClassifier | None = None, classifier: DocumentClassifier | None = None,
replace=False, replace=False,
@ -140,6 +141,7 @@ def set_correspondent(
def set_document_type( def set_document_type(
sender, sender,
document: Document, document: Document,
*,
logging_group=None, logging_group=None,
classifier: DocumentClassifier | None = None, classifier: DocumentClassifier | None = None,
replace=False, replace=False,
@ -196,6 +198,7 @@ def set_document_type(
def set_tags( def set_tags(
sender, sender,
document: Document, document: Document,
*,
logging_group=None, logging_group=None,
classifier: DocumentClassifier | None = None, classifier: DocumentClassifier | None = None,
replace=False, replace=False,
@ -251,6 +254,7 @@ def set_tags(
def set_storage_path( def set_storage_path(
sender, sender,
document: Document, document: Document,
*,
logging_group=None, logging_group=None,
classifier: DocumentClassifier | None = None, classifier: DocumentClassifier | None = None,
replace=False, replace=False,

View File

@ -63,7 +63,7 @@ def index_optimize():
writer.commit(optimize=True) writer.commit(optimize=True)
def index_reindex(progress_bar_disable=False): def index_reindex(*, progress_bar_disable=False):
documents = Document.objects.all() documents = Document.objects.all()
ix = index.open_index(recreate=True) ix = index.open_index(recreate=True)

View File

@ -165,6 +165,7 @@ class TestCustomFieldsSearch(DirectoriesMixin, APITestCase):
self, self,
query: list, query: list,
reference_predicate: Callable[[DocumentWrapper], bool], reference_predicate: Callable[[DocumentWrapper], bool],
*,
match_nothing_ok=False, match_nothing_ok=False,
): ):
""" """

View File

@ -535,7 +535,12 @@ class TestPDFActions(DirectoriesMixin, TestCase):
metadata_document_id = self.doc1.id metadata_document_id = self.doc1.id
user = User.objects.create(username="test_user") user = User.objects.create(username="test_user")
result = bulk_edit.merge(doc_ids, None, False, user) result = bulk_edit.merge(
doc_ids,
metadata_document_id=None,
delete_originals=False,
user=user,
)
expected_filename = ( expected_filename = (
f"{'_'.join([str(doc_id) for doc_id in doc_ids])[:100]}_merged.pdf" f"{'_'.join([str(doc_id) for doc_id in doc_ids])[:100]}_merged.pdf"
@ -638,7 +643,7 @@ class TestPDFActions(DirectoriesMixin, TestCase):
doc_ids = [self.doc2.id] doc_ids = [self.doc2.id]
pages = [[1, 2], [3]] pages = [[1, 2], [3]]
user = User.objects.create(username="test_user") user = User.objects.create(username="test_user")
result = bulk_edit.split(doc_ids, pages, False, user) result = bulk_edit.split(doc_ids, pages, delete_originals=False, user=user)
self.assertEqual(mock_consume_file.call_count, 2) self.assertEqual(mock_consume_file.call_count, 2)
consume_file_args, _ = mock_consume_file.call_args consume_file_args, _ = mock_consume_file.call_args
self.assertEqual(consume_file_args[1].title, "B (split 2)") self.assertEqual(consume_file_args[1].title, "B (split 2)")

View File

@ -233,7 +233,7 @@ class FaultyGenericExceptionParser(_BaseTestParser):
raise Exception("Generic exception.") raise Exception("Generic exception.")
def fake_magic_from_file(file, mime=False): def fake_magic_from_file(file, *, mime=False):
if mime: if mime:
if file.name.startswith("invalid_pdf"): if file.name.startswith("invalid_pdf"):
return "application/octet-stream" return "application/octet-stream"

View File

@ -10,7 +10,7 @@ class TestDelayedQuery(TestCase):
super().setUp() super().setUp()
# all tests run without permission criteria, so has_no_owner query will always # all tests run without permission criteria, so has_no_owner query will always
# be appended. # be appended.
self.has_no_owner = query.Or([query.Term("has_owner", False)]) self.has_no_owner = query.Or([query.Term("has_owner", text=False)])
def _get_testset__id__in(self, param, field): def _get_testset__id__in(self, param, field):
return ( return (
@ -43,12 +43,12 @@ class TestDelayedQuery(TestCase):
def test_get_permission_criteria(self): def test_get_permission_criteria(self):
# tests contains tuples of user instances and the expected filter # tests contains tuples of user instances and the expected filter
tests = ( tests = (
(None, [query.Term("has_owner", False)]), (None, [query.Term("has_owner", text=False)]),
(User(42, username="foo", is_superuser=True), []), (User(42, username="foo", is_superuser=True), []),
( (
User(42, username="foo", is_superuser=False), User(42, username="foo", is_superuser=False),
[ [
query.Term("has_owner", False), query.Term("has_owner", text=False),
query.Term("owner_id", 42), query.Term("owner_id", 42),
query.Term("viewer_id", "42"), query.Term("viewer_id", "42"),
], ],

View File

@ -93,7 +93,7 @@ class ConsumerThreadMixin(DocumentConsumeDelayMixin):
else: else:
print("Consumed a perfectly valid file.") # noqa: T201 print("Consumed a perfectly valid file.") # noqa: T201
def slow_write_file(self, target, incomplete=False): def slow_write_file(self, target, *, incomplete=False):
with open(self.sample_file, "rb") as f: with open(self.sample_file, "rb") as f:
pdf_bytes = f.read() pdf_bytes = f.read()

View File

@ -188,7 +188,7 @@ class TestExportImport(
return manifest return manifest
def test_exporter(self, use_filename_format=False): def test_exporter(self, *, use_filename_format=False):
shutil.rmtree(os.path.join(self.dirs.media_dir, "documents")) shutil.rmtree(os.path.join(self.dirs.media_dir, "documents"))
shutil.copytree( shutil.copytree(
os.path.join(os.path.dirname(__file__), "samples", "documents"), os.path.join(os.path.dirname(__file__), "samples", "documents"),

View File

@ -23,6 +23,7 @@ class _TestMatchingBase(TestCase):
match_algorithm: str, match_algorithm: str,
should_match: Iterable[str], should_match: Iterable[str],
no_match: Iterable[str], no_match: Iterable[str],
*,
case_sensitive: bool = False, case_sensitive: bool = False,
): ):
for klass in (Tag, Correspondent, DocumentType): for klass in (Tag, Correspondent, DocumentType):

View File

@ -15,7 +15,6 @@ from urllib.parse import quote
from urllib.parse import urlparse from urllib.parse import urlparse
import pathvalidate import pathvalidate
from django.apps import apps
from django.conf import settings from django.conf import settings
from django.contrib.auth.models import Group from django.contrib.auth.models import Group
from django.contrib.auth.models import User from django.contrib.auth.models import User
@ -1609,7 +1608,7 @@ class BulkDownloadView(GenericAPIView):
strategy_class = ArchiveOnlyStrategy strategy_class = ArchiveOnlyStrategy
with zipfile.ZipFile(temp.name, "w", compression) as zipf: with zipfile.ZipFile(temp.name, "w", compression) as zipf:
strategy = strategy_class(zipf, follow_filename_format) strategy = strategy_class(zipf, follow_formatting=follow_filename_format)
for document in documents: for document in documents:
strategy.add_document(document) strategy.add_document(document)
@ -1873,7 +1872,7 @@ class SharedLinkView(View):
) )
def serve_file(doc: Document, use_archive: bool, disposition: str): def serve_file(*, doc: Document, use_archive: bool, disposition: str):
if use_archive: if use_archive:
file_handle = doc.archive_file file_handle = doc.archive_file
filename = doc.get_public_filename(archive=True) filename = doc.get_public_filename(archive=True)
@ -2174,18 +2173,14 @@ class SystemStatusView(PassUserMixin):
classifier_status = "WARNING" classifier_status = "WARNING"
raise FileNotFoundError(classifier_error) raise FileNotFoundError(classifier_error)
classifier_status = "OK" classifier_status = "OK"
task_result_model = apps.get_model("django_celery_results", "taskresult") classifier_last_trained = (
result = ( make_aware(
task_result_model.objects.filter( datetime.fromtimestamp(classifier.get_last_checked()),
task_name="documents.tasks.train_classifier",
status="SUCCESS",
) )
.order_by( if settings.MODEL_FILE.exists()
"-date_done", and classifier.get_last_checked() is not None
) else None
.first()
) )
classifier_last_trained = result.date_done if result else None
except Exception as e: except Exception as e:
if classifier_status is None: if classifier_status is None:
classifier_status = "ERROR" classifier_status = "ERROR"

View File

@ -41,4 +41,10 @@ class StatusConsumer(WebsocketConsumer):
self.close() self.close()
else: else:
if self._is_owner_or_unowned(event["data"]): if self._is_owner_or_unowned(event["data"]):
self.send(json.dumps(event["data"])) self.send(json.dumps(event))
def documents_deleted(self, event):
if not self._authenticated():
self.close()
else:
self.send(json.dumps(event))

View File

@ -162,7 +162,7 @@ class SocialAccountSerializer(serializers.ModelSerializer):
class ProfileSerializer(serializers.ModelSerializer): class ProfileSerializer(serializers.ModelSerializer):
email = serializers.EmailField(allow_null=False) email = serializers.EmailField(allow_blank=True, required=False)
password = ObfuscatedUserPasswordField(required=False, allow_null=False) password = ObfuscatedUserPasswordField(required=False, allow_null=False)
auth_token = serializers.SlugRelatedField(read_only=True, slug_field="key") auth_token = serializers.SlugRelatedField(read_only=True, slug_field="key")
social_accounts = SocialAccountSerializer( social_accounts = SocialAccountSerializer(

View File

@ -5,6 +5,9 @@ from channels.testing import WebsocketCommunicator
from django.test import TestCase from django.test import TestCase
from django.test import override_settings from django.test import override_settings
from documents.plugins.helpers import DocumentsStatusManager
from documents.plugins.helpers import ProgressManager
from documents.plugins.helpers import ProgressStatusOptions
from paperless.asgi import application from paperless.asgi import application
TEST_CHANNEL_LAYERS = { TEST_CHANNEL_LAYERS = {
@ -22,6 +25,39 @@ class TestWebSockets(TestCase):
self.assertFalse(connected) self.assertFalse(connected)
await communicator.disconnect() await communicator.disconnect()
@mock.patch("paperless.consumers.StatusConsumer.close")
@mock.patch("paperless.consumers.StatusConsumer._authenticated")
async def test_close_on_no_auth(self, _authenticated, mock_close):
_authenticated.return_value = True
communicator = WebsocketCommunicator(application, "/ws/status/")
connected, subprotocol = await communicator.connect()
self.assertTrue(connected)
message = {"type": "status_update", "data": {"task_id": "test"}}
_authenticated.return_value = False
channel_layer = get_channel_layer()
await channel_layer.group_send(
"status_updates",
message,
)
await communicator.receive_nothing()
mock_close.assert_called_once()
mock_close.reset_mock()
message = {"type": "documents_deleted", "data": {"documents": [1, 2, 3]}}
await channel_layer.group_send(
"status_updates",
message,
)
await communicator.receive_nothing()
mock_close.assert_called_once()
@mock.patch("paperless.consumers.StatusConsumer._authenticated") @mock.patch("paperless.consumers.StatusConsumer._authenticated")
async def test_auth(self, _authenticated): async def test_auth(self, _authenticated):
_authenticated.return_value = True _authenticated.return_value = True
@ -33,19 +69,19 @@ class TestWebSockets(TestCase):
await communicator.disconnect() await communicator.disconnect()
@mock.patch("paperless.consumers.StatusConsumer._authenticated") @mock.patch("paperless.consumers.StatusConsumer._authenticated")
async def test_receive(self, _authenticated): async def test_receive_status_update(self, _authenticated):
_authenticated.return_value = True _authenticated.return_value = True
communicator = WebsocketCommunicator(application, "/ws/status/") communicator = WebsocketCommunicator(application, "/ws/status/")
connected, subprotocol = await communicator.connect() connected, subprotocol = await communicator.connect()
self.assertTrue(connected) self.assertTrue(connected)
message = {"task_id": "test"} message = {"type": "status_update", "data": {"task_id": "test"}}
channel_layer = get_channel_layer() channel_layer = get_channel_layer()
await channel_layer.group_send( await channel_layer.group_send(
"status_updates", "status_updates",
{"type": "status_update", "data": message}, message,
) )
response = await communicator.receive_json_from() response = await communicator.receive_json_from()
@ -53,3 +89,73 @@ class TestWebSockets(TestCase):
self.assertEqual(response, message) self.assertEqual(response, message)
await communicator.disconnect() await communicator.disconnect()
@mock.patch("paperless.consumers.StatusConsumer._authenticated")
async def test_receive_documents_deleted(self, _authenticated):
_authenticated.return_value = True
communicator = WebsocketCommunicator(application, "/ws/status/")
connected, subprotocol = await communicator.connect()
self.assertTrue(connected)
message = {"type": "documents_deleted", "data": {"documents": [1, 2, 3]}}
channel_layer = get_channel_layer()
await channel_layer.group_send(
"status_updates",
message,
)
response = await communicator.receive_json_from()
self.assertEqual(response, message)
await communicator.disconnect()
@mock.patch("channels.layers.InMemoryChannelLayer.group_send")
def test_manager_send_progress(self, mock_group_send):
with ProgressManager(task_id="test") as manager:
manager.send_progress(
ProgressStatusOptions.STARTED,
"Test message",
1,
10,
extra_args={
"foo": "bar",
},
)
message = mock_group_send.call_args[0][1]
self.assertEqual(
message,
{
"type": "status_update",
"data": {
"filename": None,
"task_id": "test",
"current_progress": 1,
"max_progress": 10,
"status": ProgressStatusOptions.STARTED,
"message": "Test message",
"foo": "bar",
},
},
)
@mock.patch("channels.layers.InMemoryChannelLayer.group_send")
def test_manager_send_documents_deleted(self, mock_group_send):
with DocumentsStatusManager() as manager:
manager.send_documents_deleted([1, 2, 3])
message = mock_group_send.call_args[0][1]
self.assertEqual(
message,
{
"type": "documents_deleted",
"data": {
"documents": [1, 2, 3],
},
},
)

View File

@ -148,7 +148,7 @@ class UserViewSet(ModelViewSet):
).first() ).first()
if authenticator is not None: if authenticator is not None:
delete_and_cleanup(request, authenticator) delete_and_cleanup(request, authenticator)
return Response(True) return Response(data=True)
else: else:
return HttpResponseNotFound("TOTP not found") return HttpResponseNotFound("TOTP not found")
@ -262,7 +262,7 @@ class TOTPView(GenericAPIView):
).first() ).first()
if authenticator is not None: if authenticator is not None:
delete_and_cleanup(request, authenticator) delete_and_cleanup(request, authenticator)
return Response(True) return Response(data=True)
else: else:
return HttpResponseNotFound("TOTP not found") return HttpResponseNotFound("TOTP not found")

View File

@ -121,7 +121,7 @@ class MarkReadMailAction(BaseMailAction):
return {"seen": False} return {"seen": False}
def post_consume(self, M: MailBox, message_uid: str, parameter: str): def post_consume(self, M: MailBox, message_uid: str, parameter: str):
M.flag(message_uid, [MailMessageFlags.SEEN], True) M.flag(message_uid, [MailMessageFlags.SEEN], value=True)
class MoveMailAction(BaseMailAction): class MoveMailAction(BaseMailAction):
@ -142,7 +142,7 @@ class FlagMailAction(BaseMailAction):
return {"flagged": False} return {"flagged": False}
def post_consume(self, M: MailBox, message_uid: str, parameter: str): def post_consume(self, M: MailBox, message_uid: str, parameter: str):
M.flag(message_uid, [MailMessageFlags.FLAGGED], True) M.flag(message_uid, [MailMessageFlags.FLAGGED], value=True)
class TagMailAction(BaseMailAction): class TagMailAction(BaseMailAction):
@ -150,7 +150,7 @@ class TagMailAction(BaseMailAction):
A mail action that tags mails after processing. A mail action that tags mails after processing.
""" """
def __init__(self, parameter: str, supports_gmail_labels: bool): def __init__(self, parameter: str, *, supports_gmail_labels: bool):
# The custom tag should look like "apple:<color>" # The custom tag should look like "apple:<color>"
if "apple:" in parameter.lower(): if "apple:" in parameter.lower():
_, self.color = parameter.split(":") _, self.color = parameter.split(":")
@ -188,19 +188,19 @@ class TagMailAction(BaseMailAction):
M.flag( M.flag(
message_uid, message_uid,
set(itertools.chain(*APPLE_MAIL_TAG_COLORS.values())), set(itertools.chain(*APPLE_MAIL_TAG_COLORS.values())),
False, value=False,
) )
# Set new $MailFlagBits # Set new $MailFlagBits
M.flag(message_uid, APPLE_MAIL_TAG_COLORS.get(self.color), True) M.flag(message_uid, APPLE_MAIL_TAG_COLORS.get(self.color), value=True)
# Set the general \Flagged # Set the general \Flagged
# This defaults to the "red" flag in AppleMail and # This defaults to the "red" flag in AppleMail and
# "stars" in Thunderbird or GMail # "stars" in Thunderbird or GMail
M.flag(message_uid, [MailMessageFlags.FLAGGED], True) M.flag(message_uid, [MailMessageFlags.FLAGGED], value=True)
elif self.keyword: elif self.keyword:
M.flag(message_uid, [self.keyword], True) M.flag(message_uid, [self.keyword], value=True)
else: else:
raise MailError("No keyword specified.") raise MailError("No keyword specified.")
@ -268,7 +268,7 @@ def apply_mail_action(
mailbox_login(M, account) mailbox_login(M, account)
M.folder.set(rule.folder) M.folder.set(rule.folder)
action = get_rule_action(rule, supports_gmail_labels) action = get_rule_action(rule, supports_gmail_labels=supports_gmail_labels)
try: try:
action.post_consume(M, message_uid, rule.action_parameter) action.post_consume(M, message_uid, rule.action_parameter)
except errors.ImapToolsError: except errors.ImapToolsError:
@ -356,7 +356,7 @@ def queue_consumption_tasks(
).delay() ).delay()
def get_rule_action(rule: MailRule, supports_gmail_labels: bool) -> BaseMailAction: def get_rule_action(rule: MailRule, *, supports_gmail_labels: bool) -> BaseMailAction:
""" """
Returns a BaseMailAction instance for the given rule. Returns a BaseMailAction instance for the given rule.
""" """
@ -370,12 +370,15 @@ def get_rule_action(rule: MailRule, supports_gmail_labels: bool) -> BaseMailActi
elif rule.action == MailRule.MailAction.MARK_READ: elif rule.action == MailRule.MailAction.MARK_READ:
return MarkReadMailAction() return MarkReadMailAction()
elif rule.action == MailRule.MailAction.TAG: elif rule.action == MailRule.MailAction.TAG:
return TagMailAction(rule.action_parameter, supports_gmail_labels) return TagMailAction(
rule.action_parameter,
supports_gmail_labels=supports_gmail_labels,
)
else: else:
raise NotImplementedError("Unknown action.") # pragma: no cover raise NotImplementedError("Unknown action.") # pragma: no cover
def make_criterias(rule: MailRule, supports_gmail_labels: bool): def make_criterias(rule: MailRule, *, supports_gmail_labels: bool):
""" """
Returns criteria to be applied to MailBox.fetch for the given rule. Returns criteria to be applied to MailBox.fetch for the given rule.
""" """
@ -393,7 +396,10 @@ def make_criterias(rule: MailRule, supports_gmail_labels: bool):
if rule.filter_body: if rule.filter_body:
criterias["body"] = rule.filter_body criterias["body"] = rule.filter_body
rule_query = get_rule_action(rule, supports_gmail_labels).get_criteria() rule_query = get_rule_action(
rule,
supports_gmail_labels=supports_gmail_labels,
).get_criteria()
if isinstance(rule_query, dict): if isinstance(rule_query, dict):
if len(rule_query) or len(criterias): if len(rule_query) or len(criterias):
return AND(**rule_query, **criterias) return AND(**rule_query, **criterias)
@ -563,7 +569,7 @@ class MailAccountHandler(LoggingMixin):
total_processed_files += self._handle_mail_rule( total_processed_files += self._handle_mail_rule(
M, M,
rule, rule,
supports_gmail_labels, supports_gmail_labels=supports_gmail_labels,
) )
except Exception as e: except Exception as e:
self.log.exception( self.log.exception(
@ -588,6 +594,7 @@ class MailAccountHandler(LoggingMixin):
self, self,
M: MailBox, M: MailBox,
rule: MailRule, rule: MailRule,
*,
supports_gmail_labels: bool, supports_gmail_labels: bool,
): ):
folders = [rule.folder] folders = [rule.folder]
@ -616,7 +623,7 @@ class MailAccountHandler(LoggingMixin):
f"does not exist in account {rule.account}", f"does not exist in account {rule.account}",
) from err ) from err
criterias = make_criterias(rule, supports_gmail_labels) criterias = make_criterias(rule, supports_gmail_labels=supports_gmail_labels)
self.log.debug( self.log.debug(
f"Rule {rule}: Searching folder with criteria {criterias}", f"Rule {rule}: Searching folder with criteria {criterias}",

View File

@ -124,7 +124,7 @@ class BogusMailBox(AbstractContextManager):
if username != self.USERNAME or access_token != self.ACCESS_TOKEN: if username != self.USERNAME or access_token != self.ACCESS_TOKEN:
raise MailboxLoginError("BAD", "OK") raise MailboxLoginError("BAD", "OK")
def fetch(self, criteria, mark_seen, charset="", bulk=True): def fetch(self, criteria, mark_seen, charset="", *, bulk=True):
msg = self.messages msg = self.messages
criteria = str(criteria).strip("()").split(" ") criteria = str(criteria).strip("()").split(" ")
@ -190,7 +190,7 @@ class BogusMailBox(AbstractContextManager):
raise Exception raise Exception
def fake_magic_from_buffer(buffer, mime=False): def fake_magic_from_buffer(buffer, *, mime=False):
if mime: if mime:
if "PDF" in str(buffer): if "PDF" in str(buffer):
return "application/pdf" return "application/pdf"
@ -206,6 +206,7 @@ class MessageBuilder:
def create_message( def create_message(
self, self,
*,
attachments: int | list[_AttachmentDef] = 1, attachments: int | list[_AttachmentDef] = 1,
body: str = "", body: str = "",
subject: str = "the subject", subject: str = "the subject",
@ -783,12 +784,18 @@ class TestMail(
) )
self.assertEqual(len(self.mailMocker.bogus_mailbox.messages), 3) self.assertEqual(len(self.mailMocker.bogus_mailbox.messages), 3)
self.assertEqual(len(self.mailMocker.bogus_mailbox.fetch("UNSEEN", False)), 2) self.assertEqual(
len(self.mailMocker.bogus_mailbox.fetch("UNSEEN", mark_seen=False)),
2,
)
self.mail_account_handler.handle_mail_account(account) self.mail_account_handler.handle_mail_account(account)
self.mailMocker.apply_mail_actions() self.mailMocker.apply_mail_actions()
self.assertEqual(len(self.mailMocker.bogus_mailbox.fetch("UNSEEN", False)), 0) self.assertEqual(
len(self.mailMocker.bogus_mailbox.fetch("UNSEEN", mark_seen=False)),
0,
)
self.assertEqual(len(self.mailMocker.bogus_mailbox.messages), 3) self.assertEqual(len(self.mailMocker.bogus_mailbox.messages), 3)
def test_handle_mail_account_delete(self): def test_handle_mail_account_delete(self):
@ -853,7 +860,7 @@ class TestMail(
self.assertEqual(len(self.mailMocker.bogus_mailbox.messages), 3) self.assertEqual(len(self.mailMocker.bogus_mailbox.messages), 3)
self.assertEqual( self.assertEqual(
len(self.mailMocker.bogus_mailbox.fetch("UNFLAGGED", False)), len(self.mailMocker.bogus_mailbox.fetch("UNFLAGGED", mark_seen=False)),
2, 2,
) )
@ -861,7 +868,7 @@ class TestMail(
self.mailMocker.apply_mail_actions() self.mailMocker.apply_mail_actions()
self.assertEqual( self.assertEqual(
len(self.mailMocker.bogus_mailbox.fetch("UNFLAGGED", False)), len(self.mailMocker.bogus_mailbox.fetch("UNFLAGGED", mark_seen=False)),
1, 1,
) )
self.assertEqual(len(self.mailMocker.bogus_mailbox.messages), 3) self.assertEqual(len(self.mailMocker.bogus_mailbox.messages), 3)
@ -934,7 +941,12 @@ class TestMail(
self.assertEqual(len(self.mailMocker.bogus_mailbox.messages), 3) self.assertEqual(len(self.mailMocker.bogus_mailbox.messages), 3)
self.assertEqual( self.assertEqual(
len(self.mailMocker.bogus_mailbox.fetch("UNKEYWORD processed", False)), len(
self.mailMocker.bogus_mailbox.fetch(
"UNKEYWORD processed",
mark_seen=False,
),
),
2, 2,
) )
@ -943,7 +955,12 @@ class TestMail(
self.assertEqual(len(self.mailMocker.bogus_mailbox.messages), 3) self.assertEqual(len(self.mailMocker.bogus_mailbox.messages), 3)
self.assertEqual( self.assertEqual(
len(self.mailMocker.bogus_mailbox.fetch("UNKEYWORD processed", False)), len(
self.mailMocker.bogus_mailbox.fetch(
"UNKEYWORD processed",
mark_seen=False,
),
),
0, 0,
) )
@ -967,12 +984,18 @@ class TestMail(
self.assertEqual(len(self.mailMocker.bogus_mailbox.messages), 3) self.assertEqual(len(self.mailMocker.bogus_mailbox.messages), 3)
criteria = NOT(gmail_label="processed") criteria = NOT(gmail_label="processed")
self.assertEqual(len(self.mailMocker.bogus_mailbox.fetch(criteria, False)), 2) self.assertEqual(
len(self.mailMocker.bogus_mailbox.fetch(criteria, mark_seen=False)),
2,
)
self.mail_account_handler.handle_mail_account(account) self.mail_account_handler.handle_mail_account(account)
self.mailMocker.apply_mail_actions() self.mailMocker.apply_mail_actions()
self.assertEqual(len(self.mailMocker.bogus_mailbox.fetch(criteria, False)), 0) self.assertEqual(
len(self.mailMocker.bogus_mailbox.fetch(criteria, mark_seen=False)),
0,
)
self.assertEqual(len(self.mailMocker.bogus_mailbox.messages), 3) self.assertEqual(len(self.mailMocker.bogus_mailbox.messages), 3)
def test_tag_mail_action_applemail_wrong_input(self): def test_tag_mail_action_applemail_wrong_input(self):
@ -980,7 +1003,7 @@ class TestMail(
MailError, MailError,
TagMailAction, TagMailAction,
"apple:black", "apple:black",
False, supports_gmail_labels=False,
) )
def test_handle_mail_account_tag_applemail(self): def test_handle_mail_account_tag_applemail(self):
@ -1002,7 +1025,7 @@ class TestMail(
self.assertEqual(len(self.mailMocker.bogus_mailbox.messages), 3) self.assertEqual(len(self.mailMocker.bogus_mailbox.messages), 3)
self.assertEqual( self.assertEqual(
len(self.mailMocker.bogus_mailbox.fetch("UNFLAGGED", False)), len(self.mailMocker.bogus_mailbox.fetch("UNFLAGGED", mark_seen=False)),
2, 2,
) )
@ -1010,7 +1033,7 @@ class TestMail(
self.mailMocker.apply_mail_actions() self.mailMocker.apply_mail_actions()
self.assertEqual( self.assertEqual(
len(self.mailMocker.bogus_mailbox.fetch("UNFLAGGED", False)), len(self.mailMocker.bogus_mailbox.fetch("UNFLAGGED", mark_seen=False)),
0, 0,
) )
self.assertEqual(len(self.mailMocker.bogus_mailbox.messages), 3) self.assertEqual(len(self.mailMocker.bogus_mailbox.messages), 3)
@ -1324,13 +1347,19 @@ class TestMail(
self.assertEqual(len(self.mailMocker.bogus_mailbox.messages), 3) self.assertEqual(len(self.mailMocker.bogus_mailbox.messages), 3)
self.mailMocker._queue_consumption_tasks_mock.assert_not_called() self.mailMocker._queue_consumption_tasks_mock.assert_not_called()
self.assertEqual(len(self.mailMocker.bogus_mailbox.fetch("UNSEEN", False)), 2) self.assertEqual(
len(self.mailMocker.bogus_mailbox.fetch("UNSEEN", mark_seen=False)),
2,
)
self.mail_account_handler.handle_mail_account(account) self.mail_account_handler.handle_mail_account(account)
self.mailMocker.apply_mail_actions() self.mailMocker.apply_mail_actions()
self.assertEqual(self.mailMocker._queue_consumption_tasks_mock.call_count, 2) self.assertEqual(self.mailMocker._queue_consumption_tasks_mock.call_count, 2)
self.assertEqual(len(self.mailMocker.bogus_mailbox.fetch("UNSEEN", False)), 0) self.assertEqual(
len(self.mailMocker.bogus_mailbox.fetch("UNSEEN", mark_seen=False)),
0,
)
self.assertEqual(len(self.mailMocker.bogus_mailbox.messages), 3) self.assertEqual(len(self.mailMocker.bogus_mailbox.messages), 3)
def test_auth_plain_fallback_fails_still(self): def test_auth_plain_fallback_fails_still(self):
@ -1390,13 +1419,19 @@ class TestMail(
self.assertEqual(len(self.mailMocker.bogus_mailbox.messages), 3) self.assertEqual(len(self.mailMocker.bogus_mailbox.messages), 3)
self.assertEqual(self.mailMocker._queue_consumption_tasks_mock.call_count, 0) self.assertEqual(self.mailMocker._queue_consumption_tasks_mock.call_count, 0)
self.assertEqual(len(self.mailMocker.bogus_mailbox.fetch("UNSEEN", False)), 2) self.assertEqual(
len(self.mailMocker.bogus_mailbox.fetch("UNSEEN", mark_seen=False)),
2,
)
self.mail_account_handler.handle_mail_account(account) self.mail_account_handler.handle_mail_account(account)
self.mailMocker.apply_mail_actions() self.mailMocker.apply_mail_actions()
self.assertEqual(self.mailMocker._queue_consumption_tasks_mock.call_count, 2) self.assertEqual(self.mailMocker._queue_consumption_tasks_mock.call_count, 2)
self.assertEqual(len(self.mailMocker.bogus_mailbox.fetch("UNSEEN", False)), 0) self.assertEqual(
len(self.mailMocker.bogus_mailbox.fetch("UNSEEN", mark_seen=False)),
0,
)
self.assertEqual(len(self.mailMocker.bogus_mailbox.messages), 3) self.assertEqual(len(self.mailMocker.bogus_mailbox.messages), 3)
def test_disabled_rule(self): def test_disabled_rule(self):
@ -1425,12 +1460,15 @@ class TestMail(
self.mailMocker.apply_mail_actions() self.mailMocker.apply_mail_actions()
self.assertEqual(len(self.mailMocker.bogus_mailbox.messages), 3) self.assertEqual(len(self.mailMocker.bogus_mailbox.messages), 3)
self.assertEqual(len(self.mailMocker.bogus_mailbox.fetch("UNSEEN", False)), 2) self.assertEqual(
len(self.mailMocker.bogus_mailbox.fetch("UNSEEN", mark_seen=False)),
2,
)
self.mail_account_handler.handle_mail_account(account) self.mail_account_handler.handle_mail_account(account)
self.mailMocker.apply_mail_actions() self.mailMocker.apply_mail_actions()
self.assertEqual( self.assertEqual(
len(self.mailMocker.bogus_mailbox.fetch("UNSEEN", False)), len(self.mailMocker.bogus_mailbox.fetch("UNSEEN", mark_seen=False)),
2, 2,
) # still 2 ) # still 2

Some files were not shown because too many files have changed in this diff Show More