mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2025-04-09 09:58:20 -05:00
Merge branch 'dev' into patch-1
This commit is contained in:
commit
4cb2b97b71
@ -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
|
||||||
|
@ -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
79
Pipfile.lock
generated
@ -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"
|
||||||
},
|
},
|
||||||
|
@ -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')
|
||||||
|
@ -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"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
1115
src-ui/messages.xlf
1115
src-ui/messages.xlf
File diff suppressed because it is too large
Load Diff
@ -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())
|
||||||
|
@ -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()
|
||||||
|
@ -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">
|
||||||
|
@ -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))
|
||||||
|
@ -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
|
||||||
|
@ -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">
|
||||||
|
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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,
|
||||||
|
@ -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>
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
@ -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()
|
||||||
|
}))
|
||||||
|
})
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
@ -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) {
|
||||||
|
@ -41,3 +41,9 @@
|
|||||||
min-width: 140px;
|
min-width: 140px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.btn-group-xs {
|
||||||
|
> .btn {
|
||||||
|
border-radius: 0.15rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -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()
|
||||||
|
@ -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"> {{title}}</div>
|
<div class="d-none d-sm-inline"> {{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>
|
||||||
|
@ -41,3 +41,9 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.btn-group-xs {
|
||||||
|
> .btn {
|
||||||
|
border-radius: 0.15rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -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', () => {
|
||||||
|
@ -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()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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>
|
||||||
|
@ -0,0 +1,5 @@
|
|||||||
|
.btn-group-xs {
|
||||||
|
> .btn {
|
||||||
|
border-radius: 0.15rem;
|
||||||
|
}
|
||||||
|
}
|
@ -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
|
||||||
? []
|
? []
|
||||||
|
56
src-ui/src/app/components/common/toast/toast.component.html
Normal file
56
src-ui/src/app/components/common/toast/toast.component.html
Normal 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>
|
||||||
|
}
|
||||||
|
@if (copied) {
|
||||||
|
<i-bs name="clipboard-check"></i-bs>
|
||||||
|
}
|
||||||
|
<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>
|
20
src-ui/src/app/components/common/toast/toast.component.scss
Normal file
20
src-ui/src/app/components/common/toast/toast.component.scss
Normal 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;
|
||||||
|
}
|
104
src-ui/src/app/components/common/toast/toast.component.spec.ts
Normal file
104
src-ui/src/app/components/common/toast/toast.component.spec.ts
Normal 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('...')
|
||||||
|
})
|
||||||
|
})
|
76
src-ui/src/app/components/common/toast/toast.component.ts
Normal file
76
src-ui/src/app/components/common/toast/toast.component.ts
Normal 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 ? '...' : ''}`
|
||||||
|
}
|
||||||
|
}
|
@ -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>
|
|
||||||
}
|
|
||||||
@if (copied) {
|
|
||||||
<i-bs name="clipboard-check"></i-bs>
|
|
||||||
}
|
|
||||||
<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>
|
|
||||||
}
|
}
|
||||||
|
@ -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;
|
|
||||||
}
|
|
||||||
|
@ -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('...')
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
@ -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 ? '...' : ''}`
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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()
|
||||||
|
@ -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(() => {
|
||||||
|
@ -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
|
||||||
|
|
||||||
|
@ -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()
|
||||||
|
@ -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()
|
||||||
|
@ -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)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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>
|
||||||
|
@ -85,5 +85,8 @@ textarea.rtl {
|
|||||||
|
|
||||||
> img {
|
> img {
|
||||||
filter: blur(1px);
|
filter: blur(1px);
|
||||||
|
max-width: 100%;
|
||||||
|
object-fit: contain;
|
||||||
|
object-position: top;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -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 = [
|
||||||
{
|
{
|
||||||
|
@ -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:
|
||||||
|
@ -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', () => {
|
||||||
|
@ -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) => {
|
||||||
|
@ -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 = [
|
||||||
{
|
{
|
||||||
|
@ -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
|
||||||
|
@ -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"
|
||||||
|
@ -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',
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
|
@ -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 (
|
||||||
|
@ -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
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
@ -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() },
|
||||||
])
|
])
|
||||||
|
@ -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',
|
||||||
|
@ -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'
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
@ -0,0 +1,3 @@
|
|||||||
|
export interface WebsocketDocumentsDeletedMessage {
|
||||||
|
documents: number[]
|
||||||
|
}
|
@ -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
|
@ -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
|
|
||||||
)
|
|
||||||
})
|
|
||||||
})
|
|
@ -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')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
@ -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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -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)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@ -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}`
|
||||||
)
|
)
|
||||||
|
375
src-ui/src/app/services/websocket-status.service.spec.ts
Normal file
375
src-ui/src/app/services/websocket-status.service.spec.ts
Normal 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()
|
||||||
|
})
|
||||||
|
})
|
@ -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
|
||||||
|
}
|
||||||
}
|
}
|
@ -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,
|
||||||
|
@ -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);
|
||||||
|
@ -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
|
||||||
|
@ -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:
|
||||||
|
@ -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"]:
|
||||||
|
@ -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(
|
||||||
|
@ -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,
|
||||||
|
@ -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
|
||||||
|
@ -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 = []
|
||||||
|
@ -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
|
||||||
|
@ -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
|
||||||
|
@ -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:
|
||||||
|
@ -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",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
@ -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(
|
||||||
|
@ -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,
|
||||||
|
@ -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".
|
||||||
|
@ -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)
|
||||||
|
@ -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 = {
|
||||||
|
@ -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,
|
||||||
|
@ -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)
|
||||||
|
@ -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,
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
|
@ -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)")
|
||||||
|
@ -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"
|
||||||
|
@ -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"),
|
||||||
],
|
],
|
||||||
|
@ -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()
|
||||||
|
|
||||||
|
@ -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"),
|
||||||
|
@ -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):
|
||||||
|
@ -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"
|
||||||
|
@ -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))
|
||||||
|
@ -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(
|
||||||
|
@ -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],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
@ -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")
|
||||||
|
|
||||||
|
@ -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}",
|
||||||
|
@ -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
Loading…
x
Reference in New Issue
Block a user