mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-10-20 23:08:14 +00:00
Compare commits
91
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c63a7e25e7 | ||
|
|
9171a1aa6a | ||
|
|
55c5d9121f | ||
|
|
e4efb62667 | ||
|
|
f0597a7c54 | ||
|
|
bbdc016a32 | ||
|
|
eae392469f | ||
|
|
bedefd85f2 | ||
|
|
bd73fe1e80 | ||
|
|
8b77a924dc | ||
|
|
1bdeb7ebcd | ||
|
|
fa3acfe13f | ||
|
|
2bc22b14bd | ||
|
|
dbce8c1409 | ||
|
|
e976b2ec7a | ||
|
|
05da0ba5a3 | ||
|
|
4ff2cb9c72 | ||
|
|
77a7fb40ec | ||
|
|
8100a8e1eb | ||
|
|
1e334903f3 | ||
|
|
a958d89bf4 | ||
|
|
8b3295fce0 | ||
|
|
9783c4995f | ||
|
|
e89495dc02 | ||
|
|
8bb2f88015 | ||
|
|
7d9b8c770d | ||
|
|
3296fd7cb5 | ||
|
|
bff41907c2 | ||
|
|
592f882394 | ||
|
|
29ea9cec66 | ||
|
|
123df2e826 | ||
|
|
639827ec65 | ||
|
|
b3106e408c | ||
|
|
ea31b6a043 | ||
|
|
0572b54579 | ||
|
|
2afec3e0b7 | ||
|
|
57019bccbe | ||
|
|
26b3964a20 | ||
|
|
a2eeadca7f | ||
|
|
ba5474ba81 | ||
|
|
e3592804bd | ||
|
|
2d54927be8 | ||
|
|
1d2832ac30 | ||
|
|
3321ba46d6 | ||
|
|
bf316cba8a | ||
|
|
36c31b3a73 | ||
|
|
d10d303980 | ||
|
|
a51e6a71f4 | ||
|
|
ebd8dba3bb | ||
|
|
3ed9586347 | ||
|
|
7055f7a745 | ||
|
|
8f7500ba64 | ||
|
|
816774815c | ||
|
|
c5e6748771 | ||
|
|
9b42170554 | ||
|
|
d2ed0c55a4 | ||
|
|
4d31063082 | ||
|
|
a4fb9bf2b0 | ||
|
|
e028257da5 | ||
|
|
68eb4fe96d | ||
|
|
d2d434535b | ||
|
|
1d75c0c99b | ||
|
|
1de1261728 | ||
|
|
f5f33e0744 | ||
|
|
c9d62afa50 | ||
|
|
b5a9353780 | ||
|
|
cf6d16edcd | ||
|
|
8bbca8a269 | ||
|
|
3374debd6a | ||
|
|
f8e93a535a | ||
|
|
98fb7c3b63 | ||
|
|
c657fceea5 | ||
|
|
d76943d57c | ||
|
|
a4003df7e8 | ||
|
|
f6f420a491 | ||
|
|
c105486b6a | ||
|
|
8ea31cb3ce | ||
|
|
4c124caee1 | ||
|
|
9e83864f83 | ||
|
|
7b53a4b316 | ||
|
|
20b7a116fd | ||
|
|
8d3902454e | ||
|
|
551445773a | ||
|
|
c54cb4a84b | ||
|
|
af61044b63 | ||
|
|
1ef5a6d3cf | ||
|
|
4da311f91f | ||
|
|
cbb84649c7 | ||
|
|
8da734a495 | ||
|
|
351671011d | ||
|
|
15c95c251d |
No files matched your search
@@ -1,5 +1,38 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## paperless-ngx 3.3.3
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- Fix: fix global search input layout shifting [@shamoon](https://github.com/shamoon) ([#14491](https://github.com/paperless-ngx/paperless-ngx/pull/14491))
|
||||||
|
- Fix: dont include doc content on global search results [@shamoon](https://github.com/shamoon) ([#14488](https://github.com/paperless-ngx/paperless-ngx/pull/14488))
|
||||||
|
- Fix: remove some redundant pagers [@shamoon](https://github.com/shamoon) ([#14487](https://github.com/paperless-ngx/paperless-ngx/pull/14487))
|
||||||
|
- Fix: prevent weird wrapping of dashboard widget column titles [@shamoon](https://github.com/shamoon) ([#14486](https://github.com/paperless-ngx/paperless-ngx/pull/14486))
|
||||||
|
- Fix: dont paginate 'all' tasks view across sections [@shamoon](https://github.com/shamoon) ([#14484](https://github.com/paperless-ngx/paperless-ngx/pull/14484))
|
||||||
|
- Fix: use float parsing for importer manifest records with a passphrase [@stumpylog](https://github.com/stumpylog) ([#14483](https://github.com/paperless-ngx/paperless-ngx/pull/14483))
|
||||||
|
- Fix: respect subpath in logout, share link and auto-login redirects [@jaimevelaz](https://github.com/jaimevelaz) ([#14476](https://github.com/paperless-ngx/paperless-ngx/pull/14476))
|
||||||
|
- Fix: dont tie suggestions button to ai enabled any more [@shamoon](https://github.com/shamoon) ([#14478](https://github.com/paperless-ngx/paperless-ngx/pull/14478))
|
||||||
|
- Fix: handle single-class classifier in threshold prediction (#<!---->14471) [@stumpylog](https://github.com/stumpylog) ([#14473](https://github.com/paperless-ngx/paperless-ngx/pull/14473))
|
||||||
|
- Fix: keep counter and extension when shortening long public filenames [@stumpylog](https://github.com/stumpylog) ([#14472](https://github.com/paperless-ngx/paperless-ngx/pull/14472))
|
||||||
|
|
||||||
|
### All App Changes
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>10 changes</summary>
|
||||||
|
|
||||||
|
- Fix: fix global search input layout shifting [@shamoon](https://github.com/shamoon) ([#14491](https://github.com/paperless-ngx/paperless-ngx/pull/14491))
|
||||||
|
- Fix: dont include doc content on global search results [@shamoon](https://github.com/shamoon) ([#14488](https://github.com/paperless-ngx/paperless-ngx/pull/14488))
|
||||||
|
- Fix: remove some redundant pagers [@shamoon](https://github.com/shamoon) ([#14487](https://github.com/paperless-ngx/paperless-ngx/pull/14487))
|
||||||
|
- Fix: prevent weird wrapping of dashboard widget column titles [@shamoon](https://github.com/shamoon) ([#14486](https://github.com/paperless-ngx/paperless-ngx/pull/14486))
|
||||||
|
- Fix: dont paginate 'all' tasks view across sections [@shamoon](https://github.com/shamoon) ([#14484](https://github.com/paperless-ngx/paperless-ngx/pull/14484))
|
||||||
|
- Fix: use float parsing for importer manifest records with a passphrase [@stumpylog](https://github.com/stumpylog) ([#14483](https://github.com/paperless-ngx/paperless-ngx/pull/14483))
|
||||||
|
- Fix: respect subpath in logout, share link and auto-login redirects [@jaimevelaz](https://github.com/jaimevelaz) ([#14476](https://github.com/paperless-ngx/paperless-ngx/pull/14476))
|
||||||
|
- Fix: dont tie suggestions button to ai enabled any more [@shamoon](https://github.com/shamoon) ([#14478](https://github.com/paperless-ngx/paperless-ngx/pull/14478))
|
||||||
|
- Fix: handle single-class classifier in threshold prediction (#<!---->14471) [@stumpylog](https://github.com/stumpylog) ([#14473](https://github.com/paperless-ngx/paperless-ngx/pull/14473))
|
||||||
|
- Fix: keep counter and extension when shortening long public filenames [@stumpylog](https://github.com/stumpylog) ([#14472](https://github.com/paperless-ngx/paperless-ngx/pull/14472))
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
## paperless-ngx 3.3.2
|
## paperless-ngx 3.3.2
|
||||||
|
|
||||||
### Bug Fixes
|
### Bug Fixes
|
||||||
|
|||||||
+79
-1
@@ -109,7 +109,7 @@ Think of versions as **file history** for a document.
|
|||||||
Paperless-ngx includes management lists for tags, correspondents, document types
|
Paperless-ngx includes management lists for tags, correspondents, document types
|
||||||
and more. These areas allow you to view, add, edit, delete and manage permissions
|
and more. These areas allow you to view, add, edit, delete and manage permissions
|
||||||
for these objects. You can also manage saved views, mail accounts, mail rules,
|
for these objects. You can also manage saved views, mail accounts, mail rules,
|
||||||
workflows and more from the management sections.
|
workflows, [OCR templates](#ocr-templates) and more from the management sections.
|
||||||
|
|
||||||
### Nested Tags
|
### Nested Tags
|
||||||
|
|
||||||
@@ -792,6 +792,84 @@ The following custom field types are supported:
|
|||||||
- `Document Link`: reference(s) to other document(s) displayed as links, automatically creates a symmetrical link in reverse
|
- `Document Link`: reference(s) to other document(s) displayed as links, automatically creates a symmetrical link in reverse
|
||||||
- `Select`: a pre-defined list of strings from which the user can choose
|
- `Select`: a pre-defined list of strings from which the user can choose
|
||||||
|
|
||||||
|
## OCR Templates {#ocr-templates}
|
||||||
|
|
||||||
|
OCR templates read text from fixed areas ("zones") of a document and write it to a
|
||||||
|
[custom field](#custom-fields), or to the document's title, archive serial number or created date. They are
|
||||||
|
useful for documents that always share the same layout, such as bills from a particular utility. They allow you
|
||||||
|
to draw a zone around the account number once, and every new bill gets its account number filled in.
|
||||||
|
|
||||||
|
Like tags or document types, each template has a [matching algorithm](advanced_usage.md#matching) that
|
||||||
|
decides which documents it automatically applies to (or not), based on their content.
|
||||||
|
|
||||||
|
### Creating a template
|
||||||
|
|
||||||
|
1. Open **OCR Templates** under "Manage" and choose **Add Template**.
|
||||||
|
2. Give the template a name and set up its matching, for example **Any word** with the utility's name. Choose
|
||||||
|
**None** for a template that should only ever be run manually.
|
||||||
|
3. Search for a sample document with the layout the template is for. Its pages are shown next to the template
|
||||||
|
settings.
|
||||||
|
4. Draw zones on the document. Click a zone to select and edit it.
|
||||||
|
5. Choose the field each zone fills.
|
||||||
|
6. Use **Test** to run OCR on the selected zone of the sample document and see the text it finds and the value
|
||||||
|
that would be written.
|
||||||
|
|
||||||
|
Zones are positioned relative to the sample document's page, so templates work best when the documents they
|
||||||
|
match have an identical layout. Keep the matching specific enough that it only catches documents with that
|
||||||
|
layout.
|
||||||
|
|
||||||
|
### Zone settings
|
||||||
|
|
||||||
|
- **Page**: the page the zone is on. Turn on **Last page** for zones that are always on the final page,
|
||||||
|
regardless of how many pages a document has.
|
||||||
|
- **OCR language**: the Tesseract languages used to read the zone. Languages other than the defaults must be
|
||||||
|
installed, see [`PAPERLESS_OCR_LANGUAGES`](configuration.md#PAPERLESS_OCR_LANGUAGES).
|
||||||
|
- **Transform**: post-processing applied to the text, such as stripping whitespace, keeping only numbers,
|
||||||
|
parsing a date or reading a QR code or barcode instead of text.
|
||||||
|
- **Date format**: for the "Parse date" transform, the format of the date using
|
||||||
|
[Python date codes](https://docs.python.org/3/library/datetime.html#format-codes), e.g. `%d.%m.%Y`.
|
||||||
|
Leave it empty to detect the format automatically.
|
||||||
|
- **Validation regex**: a regular expression the whole value must match. Values that do not match are not
|
||||||
|
written.
|
||||||
|
|
||||||
|
Zones can fill custom fields of type text, long text, URL, date, integer, number, monetary or boolean.
|
||||||
|
Document link and select fields are not supported.
|
||||||
|
|
||||||
|
### Combining zones
|
||||||
|
|
||||||
|
When several zones fill the same field, a **Combined value** setting appears for that field. Use it to arrange
|
||||||
|
the zones' values with `{Zone name}` tokens and any text in between, for example `{Start} - {End}`. If it
|
||||||
|
is left empty, the values are joined with spaces in zone order.
|
||||||
|
|
||||||
|
### When templates run
|
||||||
|
|
||||||
|
When a document is added, every enabled template that matches its content runs automatically. A template is
|
||||||
|
only used for a document if the document's owner can view the template, so users' templates apply to their own
|
||||||
|
documents, and unowned templates apply to everyone's. Templates run in name order, so if two templates fill the
|
||||||
|
same field, the later one wins.
|
||||||
|
|
||||||
|
Turn off **Enabled** to pause a template without deleting it. Disabled templates never run automatically, but
|
||||||
|
can still be run manually.
|
||||||
|
|
||||||
|
### Running templates manually
|
||||||
|
|
||||||
|
To run templates on documents that are already in Paperless-ngx, open the **Actions** menu on the document
|
||||||
|
details page:
|
||||||
|
|
||||||
|
- **Run Zone OCR** runs every enabled template that matches the document.
|
||||||
|
- The button next to it opens a dialog where you can choose one template to run
|
||||||
|
instead. A template you choose runs even if it does not match the document or is disabled.
|
||||||
|
|
||||||
|
The bulk editor's **Run Zone OCR** action opens the same dialog for the selected documents. Bulk runs are
|
||||||
|
processed in the background.
|
||||||
|
|
||||||
|
!!! warning
|
||||||
|
|
||||||
|
Zone OCR overwrites the fields it fills. A zone that finds no value leaves its field unchanged.
|
||||||
|
|
||||||
|
Managing OCR templates requires the OCR template permissions. Running zone OCR on documents requires
|
||||||
|
permission to change them, and choosing a template requires permission to view it.
|
||||||
|
|
||||||
## PDF Actions
|
## PDF Actions
|
||||||
|
|
||||||
Paperless-ngx supports basic editing operations for PDFs (these operations currently cannot be performed on non-PDF files). When viewing an individual document you can
|
Paperless-ngx supports basic editing operations for PDFs (these operations currently cannot be performed on non-PDF files). When viewing an individual document you can
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ module.exports = {
|
|||||||
'abstract-paperless-service',
|
'abstract-paperless-service',
|
||||||
],
|
],
|
||||||
transformIgnorePatterns: [
|
transformIgnorePatterns: [
|
||||||
'node_modules/(?!.*(\\.mjs$|tslib|lodash-es|normalize-diacritics|@angular/common/locales/.*\\.js$))',
|
'node_modules/(?!.*(\\.mjs$|tslib|lodash-es|normalize-diacritics|marked|@angular/common/locales/.*\\.js$))',
|
||||||
],
|
],
|
||||||
moduleNameMapper: {
|
moduleNameMapper: {
|
||||||
...esmPreset.moduleNameMapper,
|
...esmPreset.moduleNameMapper,
|
||||||
|
|||||||
+665
-561
File diff suppressed because it is too large.
Load diff
@@ -30,6 +30,7 @@
|
|||||||
"bootstrap": "^5.3.8",
|
"bootstrap": "^5.3.8",
|
||||||
"file-saver": "^2.0.5",
|
"file-saver": "^2.0.5",
|
||||||
"lodash-es": "^4.18.1",
|
"lodash-es": "^4.18.1",
|
||||||
|
"marked": "~18.0.14",
|
||||||
"mime-names": "^1.0.0",
|
"mime-names": "^1.0.0",
|
||||||
"ngx-bootstrap-icons": "^1.9.3",
|
"ngx-bootstrap-icons": "^1.9.3",
|
||||||
"ngx-color": "^10.1.0",
|
"ngx-color": "^10.1.0",
|
||||||
|
|||||||
Generated
+10
@@ -53,6 +53,9 @@ importers:
|
|||||||
lodash-es:
|
lodash-es:
|
||||||
specifier: ^4.18.1
|
specifier: ^4.18.1
|
||||||
version: 4.18.1
|
version: 4.18.1
|
||||||
|
marked:
|
||||||
|
specifier: ~18.0.14
|
||||||
|
version: 18.0.14
|
||||||
mime-names:
|
mime-names:
|
||||||
specifier: ^1.0.0
|
specifier: ^1.0.0
|
||||||
version: 1.0.0
|
version: 1.0.0
|
||||||
@@ -3226,6 +3229,11 @@ packages:
|
|||||||
make-error@1.3.6:
|
make-error@1.3.6:
|
||||||
resolution: {integrity: sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==}
|
resolution: {integrity: sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw==}
|
||||||
|
|
||||||
|
marked@18.0.14:
|
||||||
|
resolution: {integrity: sha512-mBHK6FBHuBAlhgRe88w9F0O1AbwwXJUcQibUbC/QcdTbVGAD7aWza+xt3N6oT/jCZx3/OMeS+8rnuiHZcQ9s7A==}
|
||||||
|
engines: {node: '>= 20'}
|
||||||
|
hasBin: true
|
||||||
|
|
||||||
material-colors@1.2.6:
|
material-colors@1.2.6:
|
||||||
resolution: {integrity: sha512-6qE4B9deFBIa9YSpOc9O0Sgc43zTeVYbgDT5veRKSlB2+ZuHNoVVxA1L/ckMUayV9Ay9y7Z/SZCLcGteW9i7bg==}
|
resolution: {integrity: sha512-6qE4B9deFBIa9YSpOc9O0Sgc43zTeVYbgDT5veRKSlB2+ZuHNoVVxA1L/ckMUayV9Ay9y7Z/SZCLcGteW9i7bg==}
|
||||||
|
|
||||||
@@ -7409,6 +7417,8 @@ snapshots:
|
|||||||
|
|
||||||
make-error@1.3.6: {}
|
make-error@1.3.6: {}
|
||||||
|
|
||||||
|
marked@18.0.14: {}
|
||||||
|
|
||||||
material-colors@1.2.6: {}
|
material-colors@1.2.6: {}
|
||||||
|
|
||||||
merge-stream@2.0.0: {}
|
merge-stream@2.0.0: {}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import { DocumentDetailComponent } from './components/document-detail/document-d
|
|||||||
import { DocumentListComponent } from './components/document-list/document-list.component'
|
import { DocumentListComponent } from './components/document-list/document-list.component'
|
||||||
import { DocumentAttributesComponent } from './components/manage/document-attributes/document-attributes.component'
|
import { DocumentAttributesComponent } from './components/manage/document-attributes/document-attributes.component'
|
||||||
import { MailComponent } from './components/manage/mail/mail.component'
|
import { MailComponent } from './components/manage/mail/mail.component'
|
||||||
|
import { OcrTemplatesComponent } from './components/manage/ocr-templates/ocr-templates.component'
|
||||||
import { SavedViewsComponent } from './components/manage/saved-views/saved-views.component'
|
import { SavedViewsComponent } from './components/manage/saved-views/saved-views.component'
|
||||||
import { ShareLinksComponent } from './components/manage/share-links/share-links.component'
|
import { ShareLinksComponent } from './components/manage/share-links/share-links.component'
|
||||||
import { WorkflowsComponent } from './components/manage/workflows/workflows.component'
|
import { WorkflowsComponent } from './components/manage/workflows/workflows.component'
|
||||||
@@ -275,6 +276,18 @@ export const routes: Routes = [
|
|||||||
componentName: 'WorkflowsComponent',
|
componentName: 'WorkflowsComponent',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'ocr-templates',
|
||||||
|
component: OcrTemplatesComponent,
|
||||||
|
canActivate: [PermissionsGuard],
|
||||||
|
data: {
|
||||||
|
requiredPermission: {
|
||||||
|
action: PermissionAction.View,
|
||||||
|
type: PermissionType.OcrTemplate,
|
||||||
|
},
|
||||||
|
componentName: 'OcrTemplatesComponent',
|
||||||
|
},
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'mail',
|
path: 'mail',
|
||||||
component: MailComponent,
|
component: MailComponent,
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ import { PermissionsGuard } from './guards/permissions.guard'
|
|||||||
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 { TasksService } from './services/tasks.service'
|
||||||
import { Toast, ToastService } from './services/toast.service'
|
import { Toast, ToastService } from './services/toast.service'
|
||||||
import {
|
import {
|
||||||
FileStatus,
|
FileStatus,
|
||||||
@@ -34,6 +35,7 @@ describe('AppComponent', () => {
|
|||||||
let toastService: ToastService
|
let toastService: ToastService
|
||||||
let router: Router
|
let router: Router
|
||||||
let settingsService: SettingsService
|
let settingsService: SettingsService
|
||||||
|
let tasksService: TasksService
|
||||||
let hotKeyService: HotKeyService
|
let hotKeyService: HotKeyService
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
@@ -60,6 +62,7 @@ describe('AppComponent', () => {
|
|||||||
websocketStatusService = TestBed.inject(WebsocketStatusService)
|
websocketStatusService = TestBed.inject(WebsocketStatusService)
|
||||||
permissionsService = TestBed.inject(PermissionsService)
|
permissionsService = TestBed.inject(PermissionsService)
|
||||||
settingsService = TestBed.inject(SettingsService)
|
settingsService = TestBed.inject(SettingsService)
|
||||||
|
tasksService = TestBed.inject(TasksService)
|
||||||
toastService = TestBed.inject(ToastService)
|
toastService = TestBed.inject(ToastService)
|
||||||
router = TestBed.inject(Router)
|
router = TestBed.inject(Router)
|
||||||
hotKeyService = TestBed.inject(HotKeyService)
|
hotKeyService = TestBed.inject(HotKeyService)
|
||||||
@@ -117,30 +120,33 @@ describe('AppComponent', () => {
|
|||||||
expect(toast.action).toBeUndefined()
|
expect(toast.action).toBeUndefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should display toast on document added', () => {
|
it('should only reload tasks when a document is detected', () => {
|
||||||
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
|
|
||||||
const toastSpy = jest.spyOn(toastService, 'show')
|
const toastSpy = jest.spyOn(toastService, 'show')
|
||||||
|
const reloadSpy = jest.spyOn(tasksService, 'reload')
|
||||||
const fileStatusSubject = new Subject<FileStatus>()
|
const fileStatusSubject = new Subject<FileStatus>()
|
||||||
jest
|
jest
|
||||||
.spyOn(websocketStatusService, 'onDocumentDetected')
|
.spyOn(websocketStatusService, 'onDocumentDetected')
|
||||||
.mockReturnValue(fileStatusSubject)
|
.mockReturnValue(fileStatusSubject)
|
||||||
component.ngOnInit()
|
component.ngOnInit()
|
||||||
fileStatusSubject.next(new FileStatus())
|
fileStatusSubject.next(new FileStatus())
|
||||||
expect(toastSpy).toHaveBeenCalled()
|
expect(reloadSpy).toHaveBeenCalled()
|
||||||
|
expect(toastSpy).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should suppress dashboard notifications if set', () => {
|
it('should add consumed documents to the bell without a popup', () => {
|
||||||
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
|
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
|
||||||
jest.spyOn(settingsService, 'get').mockReturnValue(true)
|
const popupSpy = jest.fn()
|
||||||
jest.spyOn(router, 'url', 'get').mockReturnValue('/dashboard')
|
toastService.showToast.subscribe(popupSpy)
|
||||||
const toastSpy = jest.spyOn(toastService, 'show')
|
let toasts: Toast[] = []
|
||||||
|
toastService.getToasts().subscribe((t) => (toasts = [...t]))
|
||||||
const fileStatusSubject = new Subject<FileStatus>()
|
const fileStatusSubject = new Subject<FileStatus>()
|
||||||
jest
|
jest
|
||||||
.spyOn(websocketStatusService, 'onDocumentDetected')
|
.spyOn(websocketStatusService, 'onDocumentConsumptionFinished')
|
||||||
.mockReturnValue(fileStatusSubject)
|
.mockReturnValue(fileStatusSubject)
|
||||||
component.ngOnInit()
|
component.ngOnInit()
|
||||||
fileStatusSubject.next(new FileStatus())
|
fileStatusSubject.next(new FileStatus())
|
||||||
expect(toastSpy).not.toHaveBeenCalled()
|
expect(toasts).toHaveLength(1)
|
||||||
|
expect(popupSpy).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should display toast on document failed', () => {
|
it('should display toast on document failed', () => {
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import {
|
|||||||
} from './services/permissions.service'
|
} from './services/permissions.service'
|
||||||
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 { ToastLevel, ToastService } from './services/toast.service'
|
||||||
import { WebsocketStatusService } from './services/websocket-status.service'
|
import { WebsocketStatusService } from './services/websocket-status.service'
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
@@ -61,18 +61,6 @@ export class AppComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private showNotification(key) {
|
|
||||||
if (
|
|
||||||
this.router.url == '/dashboard' &&
|
|
||||||
this.settings.get(
|
|
||||||
SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_SUPPRESS_ON_DASHBOARD
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return this.settings.get(key)
|
|
||||||
}
|
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
this.websocketStatusService.connect()
|
this.websocketStatusService.connect()
|
||||||
|
|
||||||
@@ -80,29 +68,22 @@ export class AppComponent implements OnInit, OnDestroy {
|
|||||||
.onDocumentConsumptionFinished()
|
.onDocumentConsumptionFinished()
|
||||||
.subscribe((status) => {
|
.subscribe((status) => {
|
||||||
this.tasksService.reload()
|
this.tasksService.reload()
|
||||||
if (
|
if (this.settings.get(SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_SUCCESS)) {
|
||||||
this.showNotification(SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_SUCCESS)
|
const canView = this.permissionsService.currentUserCan(
|
||||||
) {
|
PermissionAction.View,
|
||||||
if (
|
PermissionType.Document
|
||||||
this.permissionsService.currentUserCan(
|
)
|
||||||
PermissionAction.View,
|
// the upload status tray already shows this, so only keep a record
|
||||||
PermissionType.Document
|
this.toastService.show({
|
||||||
)
|
content: $localize`Document ${status.filename} was added to Paperless-ngx.`,
|
||||||
) {
|
delay: 10000,
|
||||||
this.toastService.show({
|
level: ToastLevel.Success,
|
||||||
content: $localize`Document ${status.filename} was added to Paperless-ngx.`,
|
silent: true,
|
||||||
delay: 10000,
|
actionName: canView ? $localize`Open document` : undefined,
|
||||||
actionName: $localize`Open document`,
|
action: canView
|
||||||
action: () => {
|
? () => this.router.navigate(['documents', status.documentId])
|
||||||
this.router.navigate(['documents', status.documentId])
|
: undefined,
|
||||||
},
|
})
|
||||||
})
|
|
||||||
} else {
|
|
||||||
this.toastService.show({
|
|
||||||
content: $localize`Document ${status.filename} was added to Paperless-ngx.`,
|
|
||||||
delay: 10000,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -110,9 +91,7 @@ export class AppComponent implements OnInit, OnDestroy {
|
|||||||
.onDocumentConsumptionFailed()
|
.onDocumentConsumptionFailed()
|
||||||
.subscribe((status) => {
|
.subscribe((status) => {
|
||||||
this.tasksService.reload()
|
this.tasksService.reload()
|
||||||
if (
|
if (this.settings.get(SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_FAILED)) {
|
||||||
this.showNotification(SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_FAILED)
|
|
||||||
) {
|
|
||||||
this.toastService.showError(
|
this.toastService.showError(
|
||||||
$localize`Could not add ${status.filename}\: ${status.message}`
|
$localize`Could not add ${status.filename}\: ${status.message}`
|
||||||
)
|
)
|
||||||
@@ -121,18 +100,8 @@ export class AppComponent implements OnInit, OnDestroy {
|
|||||||
|
|
||||||
this.newDocumentSubscription = this.websocketStatusService
|
this.newDocumentSubscription = this.websocketStatusService
|
||||||
.onDocumentDetected()
|
.onDocumentDetected()
|
||||||
.subscribe((status) => {
|
.subscribe(() => {
|
||||||
this.tasksService.reload()
|
this.tasksService.reload()
|
||||||
if (
|
|
||||||
this.showNotification(
|
|
||||||
SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_NEW_DOCUMENT
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
this.toastService.show({
|
|
||||||
content: $localize`Document ${status.filename} is being processed by Paperless-ngx.`,
|
|
||||||
delay: 5000,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
this.hotKeyService
|
this.hotKeyService
|
||||||
|
|||||||
@@ -190,7 +190,7 @@ export class ConfigComponent
|
|||||||
this.initialize(config)
|
this.initialize(config)
|
||||||
this.store.next(config)
|
this.store.next(config)
|
||||||
this.settingsService.initializeSettings().subscribe()
|
this.settingsService.initializeSettings().subscribe()
|
||||||
this.toastService.showInfo($localize`Configuration updated`)
|
this.toastService.showSuccess($localize`Configuration updated`)
|
||||||
},
|
},
|
||||||
error: (e) => {
|
error: (e) => {
|
||||||
this.loading.set(false)
|
this.loading.set(false)
|
||||||
@@ -217,7 +217,7 @@ export class ConfigComponent
|
|||||||
this.initialize(config)
|
this.initialize(config)
|
||||||
this.store.next(config)
|
this.store.next(config)
|
||||||
this.settingsService.initializeSettings().subscribe()
|
this.settingsService.initializeSettings().subscribe()
|
||||||
this.toastService.showInfo($localize`File successfully updated`)
|
this.toastService.showSuccess($localize`File successfully updated`)
|
||||||
},
|
},
|
||||||
error: (e) => {
|
error: (e) => {
|
||||||
this.loading.set(false)
|
this.loading.set(false)
|
||||||
|
|||||||
@@ -425,10 +425,8 @@
|
|||||||
|
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col">
|
<div class="col">
|
||||||
<pngx-input-check i18n-title title="Show notifications when new documents are detected" formControlName="notificationsConsumerNewDocument"></pngx-input-check>
|
<pngx-input-check i18n-title title="Add notifications when document processing completes successfully" formControlName="notificationsConsumerSuccess" i18n-hint hint="These are added to the notifications menu without a popup, processing progress is shown at the bottom of the screen."></pngx-input-check>
|
||||||
<pngx-input-check i18n-title title="Show notifications when document processing completes successfully" formControlName="notificationsConsumerSuccess"></pngx-input-check>
|
|
||||||
<pngx-input-check i18n-title title="Show notifications when document processing fails" formControlName="notificationsConsumerFailed"></pngx-input-check>
|
<pngx-input-check i18n-title title="Show notifications when document processing fails" formControlName="notificationsConsumerFailed"></pngx-input-check>
|
||||||
<pngx-input-check i18n-title title="Suppress notifications on dashboard" formControlName="notificationsConsumerSuppressOnDashboard" i18n-hint hint="This will suppress all messages about document processing status on the dashboard."></pngx-input-check>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -307,7 +307,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(35)
|
expect(setSpy).toHaveBeenCalledTimes(33)
|
||||||
expect(setSpy).toHaveBeenCalledWith(SETTINGS_KEYS.SIDEBAR_HIDDEN_ITEMS, [
|
expect(setSpy).toHaveBeenCalledWith(SETTINGS_KEYS.SIDEBAR_HIDDEN_ITEMS, [
|
||||||
HideableSidebarItemID.Workflows,
|
HideableSidebarItemID.Workflows,
|
||||||
])
|
])
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ import {
|
|||||||
SettingsService,
|
SettingsService,
|
||||||
} from 'src/app/services/settings.service'
|
} from 'src/app/services/settings.service'
|
||||||
import { SystemStatusService } from 'src/app/services/system-status.service'
|
import { SystemStatusService } from 'src/app/services/system-status.service'
|
||||||
import { Toast, ToastService } from 'src/app/services/toast.service'
|
import { Toast, ToastLevel, ToastService } from 'src/app/services/toast.service'
|
||||||
import { locationReload } from 'src/app/utils/navigation'
|
import { locationReload } from 'src/app/utils/navigation'
|
||||||
import { CheckComponent } from '../../common/input/check/check.component'
|
import { CheckComponent } from '../../common/input/check/check.component'
|
||||||
import { ColorComponent } from '../../common/input/color/color.component'
|
import { ColorComponent } from '../../common/input/color/color.component'
|
||||||
@@ -113,6 +113,7 @@ const sidebarItemLabels: Record<HideableSidebarItemID, string> = {
|
|||||||
[HideableSidebarItemID.SavedViews]: $localize`Saved Views`,
|
[HideableSidebarItemID.SavedViews]: $localize`Saved Views`,
|
||||||
[HideableSidebarItemID.ShareLinks]: $localize`Share Links`,
|
[HideableSidebarItemID.ShareLinks]: $localize`Share Links`,
|
||||||
[HideableSidebarItemID.Workflows]: $localize`Workflows`,
|
[HideableSidebarItemID.Workflows]: $localize`Workflows`,
|
||||||
|
[HideableSidebarItemID.OcrTemplates]: $localize`OCR Templates`,
|
||||||
[HideableSidebarItemID.Mail]: $localize`Mail`,
|
[HideableSidebarItemID.Mail]: $localize`Mail`,
|
||||||
[HideableSidebarItemID.Documentation]: $localize`Documentation`,
|
[HideableSidebarItemID.Documentation]: $localize`Documentation`,
|
||||||
}
|
}
|
||||||
@@ -190,10 +191,8 @@ export class SettingsComponent
|
|||||||
searchDbOnly: new FormControl(null),
|
searchDbOnly: new FormControl(null),
|
||||||
searchLink: new FormControl(null),
|
searchLink: new FormControl(null),
|
||||||
|
|
||||||
notificationsConsumerNewDocument: new FormControl(null),
|
|
||||||
notificationsConsumerSuccess: new FormControl(null),
|
notificationsConsumerSuccess: new FormControl(null),
|
||||||
notificationsConsumerFailed: new FormControl(null),
|
notificationsConsumerFailed: new FormControl(null),
|
||||||
notificationsConsumerSuppressOnDashboard: new FormControl(null),
|
|
||||||
|
|
||||||
savedViewsWarnOnUnsavedChange: new FormControl(null),
|
savedViewsWarnOnUnsavedChange: new FormControl(null),
|
||||||
sidebarViewsShowCount: new FormControl(null),
|
sidebarViewsShowCount: new FormControl(null),
|
||||||
@@ -371,18 +370,12 @@ export class SettingsComponent
|
|||||||
updateCheckingEnabled: this.settings.get(
|
updateCheckingEnabled: this.settings.get(
|
||||||
SETTINGS_KEYS.UPDATE_CHECKING_ENABLED
|
SETTINGS_KEYS.UPDATE_CHECKING_ENABLED
|
||||||
),
|
),
|
||||||
notificationsConsumerNewDocument: this.settings.get(
|
|
||||||
SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_NEW_DOCUMENT
|
|
||||||
),
|
|
||||||
notificationsConsumerSuccess: this.settings.get(
|
notificationsConsumerSuccess: this.settings.get(
|
||||||
SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_SUCCESS
|
SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_SUCCESS
|
||||||
),
|
),
|
||||||
notificationsConsumerFailed: this.settings.get(
|
notificationsConsumerFailed: this.settings.get(
|
||||||
SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_FAILED
|
SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_FAILED
|
||||||
),
|
),
|
||||||
notificationsConsumerSuppressOnDashboard: this.settings.get(
|
|
||||||
SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_SUPPRESS_ON_DASHBOARD
|
|
||||||
),
|
|
||||||
savedViewsWarnOnUnsavedChange: this.settings.get(
|
savedViewsWarnOnUnsavedChange: this.settings.get(
|
||||||
SETTINGS_KEYS.SAVED_VIEWS_WARN_ON_UNSAVED_CHANGE
|
SETTINGS_KEYS.SAVED_VIEWS_WARN_ON_UNSAVED_CHANGE
|
||||||
),
|
),
|
||||||
@@ -571,10 +564,6 @@ export class SettingsComponent
|
|||||||
SETTINGS_KEYS.DATE_FORMAT,
|
SETTINGS_KEYS.DATE_FORMAT,
|
||||||
this.settingsForm.value.dateFormat
|
this.settingsForm.value.dateFormat
|
||||||
)
|
)
|
||||||
this.settings.set(
|
|
||||||
SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_NEW_DOCUMENT,
|
|
||||||
this.settingsForm.value.notificationsConsumerNewDocument
|
|
||||||
)
|
|
||||||
this.settings.set(
|
this.settings.set(
|
||||||
SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_SUCCESS,
|
SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_SUCCESS,
|
||||||
this.settingsForm.value.notificationsConsumerSuccess
|
this.settingsForm.value.notificationsConsumerSuccess
|
||||||
@@ -583,10 +572,6 @@ export class SettingsComponent
|
|||||||
SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_FAILED,
|
SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_FAILED,
|
||||||
this.settingsForm.value.notificationsConsumerFailed
|
this.settingsForm.value.notificationsConsumerFailed
|
||||||
)
|
)
|
||||||
this.settings.set(
|
|
||||||
SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_SUPPRESS_ON_DASHBOARD,
|
|
||||||
this.settingsForm.value.notificationsConsumerSuppressOnDashboard
|
|
||||||
)
|
|
||||||
this.settings.set(
|
this.settings.set(
|
||||||
SETTINGS_KEYS.NOTES_ENABLED,
|
SETTINGS_KEYS.NOTES_ENABLED,
|
||||||
this.settingsForm.value.notesEnabled
|
this.settingsForm.value.notesEnabled
|
||||||
@@ -664,8 +649,11 @@ export class SettingsComponent
|
|||||||
let savedToast: Toast = {
|
let savedToast: Toast = {
|
||||||
content: $localize`Settings were saved successfully.`,
|
content: $localize`Settings were saved successfully.`,
|
||||||
delay: 5000,
|
delay: 5000,
|
||||||
|
level: ToastLevel.Success,
|
||||||
|
persistent: false,
|
||||||
}
|
}
|
||||||
if (reloadRequired) {
|
if (reloadRequired) {
|
||||||
|
savedToast.persistent = true
|
||||||
savedToast.content = $localize`Settings were saved successfully. Reload is required to apply some changes.`
|
savedToast.content = $localize`Settings were saved successfully. Reload is required to apply some changes.`
|
||||||
savedToast.actionName = $localize`Reload now`
|
savedToast.actionName = $localize`Reload now`
|
||||||
savedToast.action = () => {
|
savedToast.action = () => {
|
||||||
|
|||||||
@@ -99,11 +99,16 @@ describe('TrashComponent', () => {
|
|||||||
modal.componentInstance.confirmClicked.next()
|
modal.componentInstance.confirmClicked.next()
|
||||||
expect(toastErrorSpy).toHaveBeenCalled()
|
expect(toastErrorSpy).toHaveBeenCalled()
|
||||||
|
|
||||||
|
let toasts
|
||||||
|
toastService.getToasts().subscribe((allToasts) => {
|
||||||
|
toasts = [...allToasts]
|
||||||
|
})
|
||||||
trashSpy.mockReturnValue(of('OK'))
|
trashSpy.mockReturnValue(of('OK'))
|
||||||
component.delete(documentsInTrash[0])
|
component.delete(documentsInTrash[0])
|
||||||
expect(modal).toBeDefined()
|
expect(modal).toBeDefined()
|
||||||
modal.componentInstance.confirmClicked.next()
|
modal.componentInstance.confirmClicked.next()
|
||||||
expect(trashSpy).toHaveBeenCalled()
|
expect(trashSpy).toHaveBeenCalled()
|
||||||
|
expect(toasts[0].content).toContain('deleted')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should support empty trash, show error if needed', () => {
|
it('should support empty trash, show error if needed', () => {
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import { takeUntil, tap } from 'rxjs'
|
|||||||
import { Document } from 'src/app/data/document'
|
import { Document } from 'src/app/data/document'
|
||||||
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
||||||
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 { ToastLevel, ToastService } from 'src/app/services/toast.service'
|
||||||
import { TrashService } from 'src/app/services/trash.service'
|
import { TrashService } from 'src/app/services/trash.service'
|
||||||
import { ConfirmDialogComponent } from '../../common/confirm-dialog/confirm-dialog.component'
|
import { ConfirmDialogComponent } from '../../common/confirm-dialog/confirm-dialog.component'
|
||||||
import { PageHeaderComponent } from '../../common/page-header/page-header.component'
|
import { PageHeaderComponent } from '../../common/page-header/page-header.component'
|
||||||
@@ -87,9 +87,11 @@ export class TrashComponent
|
|||||||
modal.componentInstance.buttonsEnabled.set(false)
|
modal.componentInstance.buttonsEnabled.set(false)
|
||||||
this.trashService.emptyTrash([document.id]).subscribe({
|
this.trashService.emptyTrash([document.id]).subscribe({
|
||||||
next: () => {
|
next: () => {
|
||||||
this.toastService.showInfo(
|
this.toastService.show({
|
||||||
$localize`Document "${document.title}" deleted`
|
content: $localize`Document "${document.title}" deleted`,
|
||||||
)
|
delay: 5000,
|
||||||
|
level: ToastLevel.Success,
|
||||||
|
})
|
||||||
modal.close()
|
modal.close()
|
||||||
this.reload()
|
this.reload()
|
||||||
},
|
},
|
||||||
@@ -122,7 +124,11 @@ export class TrashComponent
|
|||||||
.emptyTrash(documents ? Array.from(documents) : null)
|
.emptyTrash(documents ? Array.from(documents) : null)
|
||||||
.subscribe({
|
.subscribe({
|
||||||
next: () => {
|
next: () => {
|
||||||
this.toastService.showInfo($localize`Document(s) deleted`)
|
this.toastService.show({
|
||||||
|
content: $localize`Document(s) deleted`,
|
||||||
|
delay: 5000,
|
||||||
|
level: ToastLevel.Success,
|
||||||
|
})
|
||||||
this.allToggled.set(false)
|
this.allToggled.set(false)
|
||||||
modal.close()
|
modal.close()
|
||||||
this.reload()
|
this.reload()
|
||||||
@@ -144,6 +150,7 @@ export class TrashComponent
|
|||||||
this.toastService.show({
|
this.toastService.show({
|
||||||
content: $localize`Document "${document.title}" restored`,
|
content: $localize`Document "${document.title}" restored`,
|
||||||
delay: 5000,
|
delay: 5000,
|
||||||
|
level: ToastLevel.Success,
|
||||||
actionName: $localize`Open document`,
|
actionName: $localize`Open document`,
|
||||||
action: () => {
|
action: () => {
|
||||||
this.router.navigate(['documents', document.id])
|
this.router.navigate(['documents', document.id])
|
||||||
@@ -165,7 +172,11 @@ export class TrashComponent
|
|||||||
.restoreDocuments(documents ? Array.from(documents) : null)
|
.restoreDocuments(documents ? Array.from(documents) : null)
|
||||||
.subscribe({
|
.subscribe({
|
||||||
next: () => {
|
next: () => {
|
||||||
this.toastService.showInfo($localize`Document(s) restored`)
|
this.toastService.show({
|
||||||
|
content: $localize`Document(s) restored`,
|
||||||
|
delay: 5000,
|
||||||
|
level: ToastLevel.Success,
|
||||||
|
})
|
||||||
this.allToggled.set(false)
|
this.allToggled.set(false)
|
||||||
this.reload()
|
this.reload()
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -101,7 +101,7 @@ describe('UsersAndGroupsComponent', () => {
|
|||||||
component.editUser(users[0])
|
component.editUser(users[0])
|
||||||
const editDialog = modal.componentInstance as UserEditDialogComponent
|
const editDialog = modal.componentInstance as UserEditDialogComponent
|
||||||
const toastErrorSpy = jest.spyOn(toastService, 'showError')
|
const toastErrorSpy = jest.spyOn(toastService, 'showError')
|
||||||
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
|
const toastInfoSpy = jest.spyOn(toastService, 'showSuccess')
|
||||||
editDialog.failed.emit()
|
editDialog.failed.emit()
|
||||||
expect(toastErrorSpy).toHaveBeenCalled()
|
expect(toastErrorSpy).toHaveBeenCalled()
|
||||||
settingsService.currentUser.set(users[1]) // simulate logged in as different user
|
settingsService.currentUser.set(users[1]) // simulate logged in as different user
|
||||||
@@ -120,7 +120,7 @@ describe('UsersAndGroupsComponent', () => {
|
|||||||
const deleteDialog = modal.componentInstance as ConfirmDialogComponent
|
const deleteDialog = modal.componentInstance as ConfirmDialogComponent
|
||||||
const deleteSpy = jest.spyOn(userService, 'delete')
|
const deleteSpy = jest.spyOn(userService, 'delete')
|
||||||
const toastErrorSpy = jest.spyOn(toastService, 'showError')
|
const toastErrorSpy = jest.spyOn(toastService, 'showError')
|
||||||
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
|
const toastInfoSpy = jest.spyOn(toastService, 'showSuccess')
|
||||||
const listAllSpy = jest.spyOn(userService, 'listAll')
|
const listAllSpy = jest.spyOn(userService, 'listAll')
|
||||||
deleteSpy.mockReturnValueOnce(
|
deleteSpy.mockReturnValueOnce(
|
||||||
throwError(() => new Error('error deleting user'))
|
throwError(() => new Error('error deleting user'))
|
||||||
@@ -186,7 +186,7 @@ describe('UsersAndGroupsComponent', () => {
|
|||||||
component.editGroup(groups[0])
|
component.editGroup(groups[0])
|
||||||
const editDialog = modal.componentInstance as GroupEditDialogComponent
|
const editDialog = modal.componentInstance as GroupEditDialogComponent
|
||||||
const toastErrorSpy = jest.spyOn(toastService, 'showError')
|
const toastErrorSpy = jest.spyOn(toastService, 'showError')
|
||||||
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
|
const toastInfoSpy = jest.spyOn(toastService, 'showSuccess')
|
||||||
editDialog.failed.emit()
|
editDialog.failed.emit()
|
||||||
expect(toastErrorSpy).toHaveBeenCalled()
|
expect(toastErrorSpy).toHaveBeenCalled()
|
||||||
editDialog.succeeded.emit(groups[0])
|
editDialog.succeeded.emit(groups[0])
|
||||||
@@ -204,7 +204,7 @@ describe('UsersAndGroupsComponent', () => {
|
|||||||
const deleteDialog = modal.componentInstance as ConfirmDialogComponent
|
const deleteDialog = modal.componentInstance as ConfirmDialogComponent
|
||||||
const deleteSpy = jest.spyOn(groupService, 'delete')
|
const deleteSpy = jest.spyOn(groupService, 'delete')
|
||||||
const toastErrorSpy = jest.spyOn(toastService, 'showError')
|
const toastErrorSpy = jest.spyOn(toastService, 'showError')
|
||||||
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
|
const toastInfoSpy = jest.spyOn(toastService, 'showSuccess')
|
||||||
const listAllSpy = jest.spyOn(groupService, 'listAll')
|
const listAllSpy = jest.spyOn(groupService, 'listAll')
|
||||||
deleteSpy.mockReturnValueOnce(
|
deleteSpy.mockReturnValueOnce(
|
||||||
throwError(() => new Error('error deleting group'))
|
throwError(() => new Error('error deleting group'))
|
||||||
|
|||||||
@@ -122,7 +122,7 @@ export class UsersAndGroupsComponent
|
|||||||
)
|
)
|
||||||
}, 2500)
|
}, 2500)
|
||||||
} else {
|
} else {
|
||||||
this.toastService.showInfo(
|
this.toastService.showSuccess(
|
||||||
$localize`Saved user "${newUser.username}".`
|
$localize`Saved user "${newUser.username}".`
|
||||||
)
|
)
|
||||||
this.usersService.listAll().subscribe((r) => {
|
this.usersService.listAll().subscribe((r) => {
|
||||||
@@ -151,7 +151,9 @@ export class UsersAndGroupsComponent
|
|||||||
this.usersService.delete(user).subscribe({
|
this.usersService.delete(user).subscribe({
|
||||||
next: () => {
|
next: () => {
|
||||||
modal.close()
|
modal.close()
|
||||||
this.toastService.showInfo($localize`Deleted user "${user.username}"`)
|
this.toastService.showSuccess(
|
||||||
|
$localize`Deleted user "${user.username}"`
|
||||||
|
)
|
||||||
this.usersService.listAll().subscribe((r) => {
|
this.usersService.listAll().subscribe((r) => {
|
||||||
this.users.set(r.results)
|
this.users.set(r.results)
|
||||||
})
|
})
|
||||||
@@ -178,7 +180,9 @@ export class UsersAndGroupsComponent
|
|||||||
modal.componentInstance.succeeded
|
modal.componentInstance.succeeded
|
||||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||||
.subscribe((newGroup) => {
|
.subscribe((newGroup) => {
|
||||||
this.toastService.showInfo($localize`Saved group "${newGroup.name}".`)
|
this.toastService.showSuccess(
|
||||||
|
$localize`Saved group "${newGroup.name}".`
|
||||||
|
)
|
||||||
this.groupsService.listAll().subscribe((r) => {
|
this.groupsService.listAll().subscribe((r) => {
|
||||||
this.groups.set(r.results)
|
this.groups.set(r.results)
|
||||||
})
|
})
|
||||||
@@ -204,7 +208,9 @@ export class UsersAndGroupsComponent
|
|||||||
this.groupsService.delete(group).subscribe({
|
this.groupsService.delete(group).subscribe({
|
||||||
next: () => {
|
next: () => {
|
||||||
modal.close()
|
modal.close()
|
||||||
this.toastService.showInfo($localize`Deleted group "${group.name}"`)
|
this.toastService.showSuccess(
|
||||||
|
$localize`Deleted group "${group.name}"`
|
||||||
|
)
|
||||||
this.groupsService.listAll().subscribe((r) => {
|
this.groupsService.listAll().subscribe((r) => {
|
||||||
this.groups.set(r.results)
|
this.groups.set(r.results)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -29,8 +29,20 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="search-container flex-grow-1 py-2 pb-3 pb-sm-2 me-sm-auto order-3 order-sm-1"
|
<div class="search-container flex-grow-1 py-2 pb-3 pb-sm-2 me-sm-auto order-3 order-sm-1"
|
||||||
[class.mobile-hidden]="mobileSearchHidden()">
|
[class.mobile-hidden]="mobileSearchHidden()">
|
||||||
<div class="col-12 header-search mx-auto">
|
<div class="col-12 header-search mx-auto d-flex align-items-stretch gap-3">
|
||||||
<pngx-global-search></pngx-global-search>
|
<pngx-global-search class="flex-grow-1"></pngx-global-search>
|
||||||
|
<div *pngxIfPermissions="{ action: PermissionAction.Add, type: PermissionType.Document }" class="btn-group header-upload-group flex-shrink-0">
|
||||||
|
<button class="btn btn-sm header-upload border-0 d-flex align-items-center justify-content-center py-0 px-2" type="button"
|
||||||
|
(click)="fileUpload.click()" aria-label="Upload documents" i18n-aria-label>
|
||||||
|
<i-bs class="d-flex" width="1.1em" height="1.1em" name="file-earmark-arrow-up"></i-bs><span class="ms-2 d-sm-none d-md-inline" i18n>Upload</span>
|
||||||
|
</button>
|
||||||
|
@if (uploadsInProgress > 0) {
|
||||||
|
<button class="btn btn-sm header-upload border-0 d-flex align-items-center py-0 px-2" type="button"
|
||||||
|
(click)="showUploadStatus()" aria-label="Show document processing status" i18n-aria-label>
|
||||||
|
<span class="spinner-border spinner-border-sm" aria-hidden="true"></span><span class="ms-2 small">{{ uploadsInProgress }}</span>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<ul ngbNav class="order-sm-3">
|
<ul ngbNav class="order-sm-3">
|
||||||
@@ -70,6 +82,9 @@
|
|||||||
</ul>
|
</ul>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
<input type="file" class="visually-hidden" id="navbar-file-upload" (change)="onFileSelected($event)" multiple #fileUpload tabindex="-1" aria-hidden="true">
|
||||||
|
<label for="navbar-file-upload" class="visually-hidden" i18n>Upload Files</label>
|
||||||
|
|
||||||
<div class="container-fluid">
|
<div class="container-fluid">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<nav id="sidebarMenu" class="d-md-block bg-light sidebar collapse"
|
<nav id="sidebarMenu" class="d-md-block bg-light sidebar collapse"
|
||||||
@@ -274,6 +289,17 @@
|
|||||||
<pngx-input-switch class="position-absolute top-50 end-0 translate-middle-y me-1" [class.d-none]="slimSidebarEnabled || slimSidebarAnimating()" [compact]="true" title="Workflows" i18n-title [ngModel]="!settingsService.sidebarItemIsHidden(HideableSidebarItemID.Workflows)" (ngModelChange)="toggleSidebarItem(HideableSidebarItemID.Workflows, $event)"></pngx-input-switch>
|
<pngx-input-switch class="position-absolute top-50 end-0 translate-middle-y me-1" [class.d-none]="slimSidebarEnabled || slimSidebarAnimating()" [compact]="true" title="Workflows" i18n-title [ngModel]="!settingsService.sidebarItemIsHidden(HideableSidebarItemID.Workflows)" (ngModelChange)="toggleSidebarItem(HideableSidebarItemID.Workflows, $event)"></pngx-input-switch>
|
||||||
}
|
}
|
||||||
</li>
|
</li>
|
||||||
|
<li class="nav-item app-link position-relative" [class.d-none]="settingsService.sidebarItemIsHidden(HideableSidebarItemID.OcrTemplates) && !settingsService.organizingSidebarItems()"
|
||||||
|
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.OcrTemplate }">
|
||||||
|
<a class="nav-link" [class.opacity-50]="settingsService.sidebarItemIsHidden(HideableSidebarItemID.OcrTemplates)" [class.pe-5]="settingsService.organizingSidebarItems() && !slimSidebarEnabled && !slimSidebarAnimating()" routerLink="ocr-templates" routerLinkActive="active" (click)="closeMenu()"
|
||||||
|
ngbPopover="OCR Templates" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end"
|
||||||
|
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||||
|
<i-bs class="me-2" name="bounding-box-circles"></i-bs><span class="nav-link-label"><ng-container i18n>OCR Templates</ng-container></span>
|
||||||
|
</a>
|
||||||
|
@if (settingsService.organizingSidebarItems()) {
|
||||||
|
<pngx-input-switch class="position-absolute top-50 end-0 translate-middle-y me-1" [class.d-none]="slimSidebarEnabled || slimSidebarAnimating()" [compact]="true" title="OCR Templates" i18n-title [ngModel]="!settingsService.sidebarItemIsHidden(HideableSidebarItemID.OcrTemplates)" (ngModelChange)="toggleSidebarItem(HideableSidebarItemID.OcrTemplates, $event)"></pngx-input-switch>
|
||||||
|
}
|
||||||
|
</li>
|
||||||
<li class="nav-item app-link position-relative" [class.d-none]="settingsService.sidebarItemIsHidden(HideableSidebarItemID.Mail) && !settingsService.organizingSidebarItems()" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.MailAccount }"
|
<li class="nav-item app-link position-relative" [class.d-none]="settingsService.sidebarItemIsHidden(HideableSidebarItemID.Mail) && !settingsService.organizingSidebarItems()" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.MailAccount }"
|
||||||
tourAnchor="tour.mail">
|
tourAnchor="tour.mail">
|
||||||
<a class="nav-link" [class.opacity-50]="settingsService.sidebarItemIsHidden(HideableSidebarItemID.Mail)" [class.pe-5]="settingsService.organizingSidebarItems() && !slimSidebarEnabled && !slimSidebarAnimating()" routerLink="mail" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Mail"
|
<a class="nav-link" [class.opacity-50]="settingsService.sidebarItemIsHidden(HideableSidebarItemID.Mail)" [class.pe-5]="settingsService.organizingSidebarItems() && !slimSidebarEnabled && !slimSidebarAnimating()" routerLink="mail" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Mail"
|
||||||
@@ -419,5 +445,6 @@
|
|||||||
[ngClass]="slimSidebarEnabled ? 'col-slim' : 'col-sidebar-expanded'">
|
[ngClass]="slimSidebarEnabled ? 'col-slim' : 'col-sidebar-expanded'">
|
||||||
<router-outlet></router-outlet>
|
<router-outlet></router-outlet>
|
||||||
</main>
|
</main>
|
||||||
|
<pngx-upload-status-tray></pngx-upload-status-tray>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -538,7 +538,36 @@ main {
|
|||||||
|
|
||||||
.header-search {
|
.header-search {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 55rem;
|
max-width: 50rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-search pngx-global-search {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// light w shadow to stand out against search
|
||||||
|
.header-upload {
|
||||||
|
--bs-btn-bg: rgba(255, 255, 255, .15);
|
||||||
|
--bs-btn-hover-bg: rgba(255, 255, 255, .22);
|
||||||
|
--bs-btn-active-bg: rgba(255, 255, 255, .3);
|
||||||
|
color: var(--pngx-primary-text-contrast) !important; // .navbar .dropdown > .btn in styles.scss
|
||||||
|
|
||||||
|
& + & {
|
||||||
|
border-left: 1px solid rgba(255, 255, 255, .2) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus-visible {
|
||||||
|
box-shadow: inset 0 0 0 2px rgba(255, 255, 255, .5);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-upload-group {
|
||||||
|
border-radius: var(--bs-border-radius-sm);
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, .35), 0 1px 1px rgba(0, 0, 0, .2);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media screen and (max-width: 575.98px) {
|
@media screen and (max-width: 575.98px) {
|
||||||
@@ -630,8 +659,7 @@ main {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.dropdown-toggle::after {
|
.dropdown-toggle::after {
|
||||||
margin-left: 0.4em;
|
display: none;
|
||||||
vertical-align: 0.155em;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.navbar .dropdown-menu {
|
.navbar .dropdown-menu {
|
||||||
@@ -668,3 +696,7 @@ main {
|
|||||||
::ng-deep .navItemDrag .position-absolute i-bs {
|
::ng-deep .navItemDrag .position-absolute i-bs {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.smaller {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
} from '@angular/common/http/testing'
|
} from '@angular/common/http/testing'
|
||||||
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||||
import { BrowserModule } from '@angular/platform-browser'
|
import { BrowserModule, By } from '@angular/platform-browser'
|
||||||
import { ActivatedRoute, Router } from '@angular/router'
|
import { ActivatedRoute, Router } from '@angular/router'
|
||||||
import { RouterTestingModule } from '@angular/router/testing'
|
import { RouterTestingModule } from '@angular/router/testing'
|
||||||
import { NgbModal, NgbModalModule, NgbModule } from '@ng-bootstrap/ng-bootstrap'
|
import { NgbModal, NgbModalModule, NgbModule } from '@ng-bootstrap/ng-bootstrap'
|
||||||
@@ -33,8 +33,11 @@ import { SearchService } from 'src/app/services/rest/search.service'
|
|||||||
import { SettingsService } from 'src/app/services/settings.service'
|
import { SettingsService } from 'src/app/services/settings.service'
|
||||||
import { TasksService } from 'src/app/services/tasks.service'
|
import { TasksService } from 'src/app/services/tasks.service'
|
||||||
import { ToastService } from 'src/app/services/toast.service'
|
import { ToastService } from 'src/app/services/toast.service'
|
||||||
|
import { UploadDocumentsService } from 'src/app/services/upload-documents.service'
|
||||||
|
import { WebsocketStatusService } from 'src/app/services/websocket-status.service'
|
||||||
import { environment } from 'src/environments/environment'
|
import { environment } from 'src/environments/environment'
|
||||||
import { ProfileEditDialogComponent } from '../common/profile-edit-dialog/profile-edit-dialog.component'
|
import { ProfileEditDialogComponent } from '../common/profile-edit-dialog/profile-edit-dialog.component'
|
||||||
|
import { UploadStatusTrayComponent } from '../common/upload-status-tray/upload-status-tray.component'
|
||||||
import { DocumentDetailComponent } from '../document-detail/document-detail.component'
|
import { DocumentDetailComponent } from '../document-detail/document-detail.component'
|
||||||
import { AppFrameComponent } from './app-frame.component'
|
import { AppFrameComponent } from './app-frame.component'
|
||||||
import { GlobalSearchComponent } from './global-search/global-search.component'
|
import { GlobalSearchComponent } from './global-search/global-search.component'
|
||||||
@@ -292,6 +295,7 @@ describe('AppFrameComponent', () => {
|
|||||||
HideableSidebarItemID.Dashboard,
|
HideableSidebarItemID.Dashboard,
|
||||||
HideableSidebarItemID.Workflows,
|
HideableSidebarItemID.Workflows,
|
||||||
HideableSidebarItemID.ShareLinks,
|
HideableSidebarItemID.ShareLinks,
|
||||||
|
HideableSidebarItemID.OcrTemplates,
|
||||||
])
|
])
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
|
|
||||||
@@ -307,6 +311,10 @@ describe('AppFrameComponent', () => {
|
|||||||
fixture.nativeElement.querySelector('[routerLink="share-links"]')
|
fixture.nativeElement.querySelector('[routerLink="share-links"]')
|
||||||
.parentElement.classList
|
.parentElement.classList
|
||||||
).toContain('d-none')
|
).toContain('d-none')
|
||||||
|
expect(
|
||||||
|
fixture.nativeElement.querySelector('[routerLink="ocr-templates"]')
|
||||||
|
.parentElement.classList
|
||||||
|
).toContain('d-none')
|
||||||
expect(
|
expect(
|
||||||
fixture.nativeElement.querySelector('[routerLink="mail"]').parentElement
|
fixture.nativeElement.querySelector('[routerLink="mail"]').parentElement
|
||||||
.classList
|
.classList
|
||||||
@@ -319,12 +327,21 @@ describe('AppFrameComponent', () => {
|
|||||||
])
|
])
|
||||||
settingsService.sidebarHiddenItemsEditing.set([
|
settingsService.sidebarHiddenItemsEditing.set([
|
||||||
HideableSidebarItemID.Dashboard,
|
HideableSidebarItemID.Dashboard,
|
||||||
|
HideableSidebarItemID.OcrTemplates,
|
||||||
])
|
])
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
fixture.nativeElement.querySelectorAll('pngx-input-switch').length
|
fixture.nativeElement.querySelectorAll('pngx-input-switch').length
|
||||||
).toBe(6)
|
).toBe(7)
|
||||||
|
const ocrTemplatesLink = fixture.nativeElement.querySelector(
|
||||||
|
'[routerLink="ocr-templates"]'
|
||||||
|
)
|
||||||
|
expect(ocrTemplatesLink.parentElement.classList).not.toContain('d-none')
|
||||||
|
expect(ocrTemplatesLink.classList).toContain('opacity-50')
|
||||||
|
expect(
|
||||||
|
ocrTemplatesLink.parentElement.querySelector('pngx-input-switch')
|
||||||
|
).not.toBeNull()
|
||||||
expect(
|
expect(
|
||||||
fixture.nativeElement.querySelector('[routerLink="dashboard"]')
|
fixture.nativeElement.querySelector('[routerLink="dashboard"]')
|
||||||
.parentElement.classList
|
.parentElement.classList
|
||||||
@@ -462,6 +479,45 @@ describe('AppFrameComponent', () => {
|
|||||||
expect(routerSpy).toHaveBeenCalled()
|
expect(routerSpy).toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should upload files selected from the navbar upload button', () => {
|
||||||
|
const uploadDocumentsService = TestBed.inject(UploadDocumentsService)
|
||||||
|
const uploadSpy = jest
|
||||||
|
.spyOn(uploadDocumentsService, 'uploadFiles')
|
||||||
|
.mockImplementation(() => {})
|
||||||
|
const toastSpy = jest.spyOn(toastService, 'showInfo')
|
||||||
|
const file = new File(['testing'], 'file.pdf')
|
||||||
|
const input = window.document.createElement('input')
|
||||||
|
jest.spyOn(input, 'files', 'get').mockReturnValue([file] as any)
|
||||||
|
|
||||||
|
component.onFileSelected({ target: input } as unknown as Event)
|
||||||
|
expect(toastSpy).not.toHaveBeenCalled()
|
||||||
|
expect(uploadSpy).toHaveBeenCalledWith([file])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should show in-progress uploads and expand the status tray', () => {
|
||||||
|
const websocketStatusService = TestBed.inject(WebsocketStatusService)
|
||||||
|
expect(component.uploadsInProgress).toEqual(0)
|
||||||
|
websocketStatusService.newFileUpload('file.pdf')
|
||||||
|
expect(component.uploadsInProgress).toEqual(1)
|
||||||
|
|
||||||
|
fixture.detectChanges()
|
||||||
|
const tray = fixture.debugElement.query(
|
||||||
|
By.directive(UploadStatusTrayComponent)
|
||||||
|
).componentInstance as UploadStatusTrayComponent
|
||||||
|
tray.toggleCollapsed()
|
||||||
|
expect(tray.collapsed()).toBe(true)
|
||||||
|
component.showUploadStatus()
|
||||||
|
expect(tray.collapsed()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not upload when no files are selected from the navbar', () => {
|
||||||
|
const uploadDocumentsService = TestBed.inject(UploadDocumentsService)
|
||||||
|
const uploadSpy = jest.spyOn(uploadDocumentsService, 'uploadFiles')
|
||||||
|
const input = window.document.createElement('input')
|
||||||
|
component.onFileSelected({ target: input } as unknown as Event)
|
||||||
|
expect(uploadSpy).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
it('should close all documents on logout', () => {
|
it('should close all documents on logout', () => {
|
||||||
const closeAllSpy = jest.spyOn(openDocumentsService, 'closeAll')
|
const closeAllSpy = jest.spyOn(openDocumentsService, 'closeAll')
|
||||||
component.onLogout()
|
component.onLogout()
|
||||||
@@ -483,7 +539,7 @@ describe('AppFrameComponent', () => {
|
|||||||
|
|
||||||
it('should update saved view sorting on drag + drop, show info', () => {
|
it('should update saved view sorting on drag + drop, show info', () => {
|
||||||
const settingsSpy = jest.spyOn(settingsService, 'updateSidebarViewsSort')
|
const settingsSpy = jest.spyOn(settingsService, 'updateSidebarViewsSort')
|
||||||
const toastSpy = jest.spyOn(toastService, 'showInfo')
|
const toastSpy = jest.spyOn(toastService, 'showSuccess')
|
||||||
jest.spyOn(settingsService, 'storeSettings').mockReturnValue(of(true))
|
jest.spyOn(settingsService, 'storeSettings').mockReturnValue(of(true))
|
||||||
component.onDrop({ previousIndex: 0, currentIndex: 1 } as CdkDragDrop<
|
component.onDrop({ previousIndex: 0, currentIndex: 1 } as CdkDragDrop<
|
||||||
SavedView[]
|
SavedView[]
|
||||||
@@ -525,6 +581,7 @@ describe('AppFrameComponent', () => {
|
|||||||
|
|
||||||
it('should show toasts for django messages', () => {
|
it('should show toasts for django messages', () => {
|
||||||
const toastErrorSpy = jest.spyOn(toastService, 'showError')
|
const toastErrorSpy = jest.spyOn(toastService, 'showError')
|
||||||
|
const toastSuccessSpy = jest.spyOn(toastService, 'showSuccess')
|
||||||
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
|
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
|
||||||
jest.spyOn(messagesService, 'get').mockReturnValue([
|
jest.spyOn(messagesService, 'get').mockReturnValue([
|
||||||
{ level: DjangoMessageLevel.WARNING, message: 'Test warning' },
|
{ level: DjangoMessageLevel.WARNING, message: 'Test warning' },
|
||||||
@@ -535,7 +592,8 @@ describe('AppFrameComponent', () => {
|
|||||||
])
|
])
|
||||||
component.ngOnInit()
|
component.ngOnInit()
|
||||||
expect(toastErrorSpy).toHaveBeenCalledTimes(2)
|
expect(toastErrorSpy).toHaveBeenCalledTimes(2)
|
||||||
expect(toastInfoSpy).toHaveBeenCalledTimes(3)
|
expect(toastSuccessSpy).toHaveBeenCalledTimes(1)
|
||||||
|
expect(toastInfoSpy).toHaveBeenCalledTimes(2)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should call maybeRefreshDocumentCounts after saved views reload', () => {
|
it('should call maybeRefreshDocumentCounts after saved views reload', () => {
|
||||||
|
|||||||
@@ -6,7 +6,14 @@ import {
|
|||||||
moveItemInArray,
|
moveItemInArray,
|
||||||
} from '@angular/cdk/drag-drop'
|
} from '@angular/cdk/drag-drop'
|
||||||
import { NgClass } from '@angular/common'
|
import { NgClass } from '@angular/common'
|
||||||
import { Component, HostListener, inject, OnInit, signal } from '@angular/core'
|
import {
|
||||||
|
Component,
|
||||||
|
HostListener,
|
||||||
|
inject,
|
||||||
|
OnInit,
|
||||||
|
signal,
|
||||||
|
viewChild,
|
||||||
|
} from '@angular/core'
|
||||||
import { FormsModule } from '@angular/forms'
|
import { FormsModule } from '@angular/forms'
|
||||||
import { ActivatedRoute, Router, RouterModule } from '@angular/router'
|
import { ActivatedRoute, Router, RouterModule } from '@angular/router'
|
||||||
import {
|
import {
|
||||||
@@ -48,12 +55,15 @@ 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 { TasksService } from 'src/app/services/tasks.service'
|
import { TasksService } from 'src/app/services/tasks.service'
|
||||||
import { ToastService } from 'src/app/services/toast.service'
|
import { ToastService } from 'src/app/services/toast.service'
|
||||||
|
import { UploadDocumentsService } from 'src/app/services/upload-documents.service'
|
||||||
|
import { WebsocketStatusService } from 'src/app/services/websocket-status.service'
|
||||||
import { environment } from 'src/environments/environment'
|
import { environment } from 'src/environments/environment'
|
||||||
import { ChatComponent } from '../chat/chat/chat.component'
|
import { ChatComponent } from '../chat/chat/chat.component'
|
||||||
|
import { SwitchComponent } from '../common/input/switch/switch.component'
|
||||||
import { BrandMarkComponent } from '../common/logo/brand-mark/brand-mark.component'
|
import { BrandMarkComponent } from '../common/logo/brand-mark/brand-mark.component'
|
||||||
import { LogoComponent } from '../common/logo/logo.component'
|
import { LogoComponent } from '../common/logo/logo.component'
|
||||||
import { ProfileEditDialogComponent } from '../common/profile-edit-dialog/profile-edit-dialog.component'
|
import { ProfileEditDialogComponent } from '../common/profile-edit-dialog/profile-edit-dialog.component'
|
||||||
import { SwitchComponent } from '../common/input/switch/switch.component'
|
import { UploadStatusTrayComponent } from '../common/upload-status-tray/upload-status-tray.component'
|
||||||
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'
|
||||||
@@ -84,6 +94,7 @@ const SCROLL_THRESHOLD = 16
|
|||||||
TourNgBootstrap,
|
TourNgBootstrap,
|
||||||
FormsModule,
|
FormsModule,
|
||||||
SwitchComponent,
|
SwitchComponent,
|
||||||
|
UploadStatusTrayComponent,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class AppFrameComponent
|
export class AppFrameComponent
|
||||||
@@ -101,6 +112,10 @@ export class AppFrameComponent
|
|||||||
private modalService = inject(NgbModal)
|
private modalService = inject(NgbModal)
|
||||||
permissionsService = inject(PermissionsService)
|
permissionsService = inject(PermissionsService)
|
||||||
private djangoMessagesService = inject(DjangoMessagesService)
|
private djangoMessagesService = inject(DjangoMessagesService)
|
||||||
|
private uploadDocumentsService = inject(UploadDocumentsService)
|
||||||
|
private websocketStatusService = inject(WebsocketStatusService)
|
||||||
|
|
||||||
|
private readonly uploadStatusTray = viewChild(UploadStatusTrayComponent)
|
||||||
|
|
||||||
readonly appRemoteVersion = signal<AppRemoteVersion>(null)
|
readonly appRemoteVersion = signal<AppRemoteVersion>(null)
|
||||||
readonly isMenuCollapsed = signal(true)
|
readonly isMenuCollapsed = signal(true)
|
||||||
@@ -171,6 +186,8 @@ export class AppFrameComponent
|
|||||||
this.toastService.showError(message.message)
|
this.toastService.showError(message.message)
|
||||||
break
|
break
|
||||||
case DjangoMessageLevel.SUCCESS:
|
case DjangoMessageLevel.SUCCESS:
|
||||||
|
this.toastService.showSuccess(message.message)
|
||||||
|
break
|
||||||
case DjangoMessageLevel.INFO:
|
case DjangoMessageLevel.INFO:
|
||||||
case DjangoMessageLevel.DEBUG:
|
case DjangoMessageLevel.DEBUG:
|
||||||
this.toastService.showInfo(message.message)
|
this.toastService.showInfo(message.message)
|
||||||
@@ -477,7 +494,7 @@ export class AppFrameComponent
|
|||||||
|
|
||||||
this.settingsService.updateSidebarViewsSort(sidebarViews).subscribe({
|
this.settingsService.updateSidebarViewsSort(sidebarViews).subscribe({
|
||||||
next: () => {
|
next: () => {
|
||||||
this.toastService.showInfo($localize`Sidebar views updated`)
|
this.toastService.showSuccess($localize`Sidebar views updated`)
|
||||||
},
|
},
|
||||||
error: (e) => {
|
error: (e) => {
|
||||||
this.toastService.showError($localize`Error updating sidebar views`, e)
|
this.toastService.showError($localize`Error updating sidebar views`, e)
|
||||||
@@ -511,6 +528,22 @@ export class AppFrameComponent
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onFileSelected(event: Event) {
|
||||||
|
const input = event.target as HTMLInputElement
|
||||||
|
const files = Array.from(input.files ?? [])
|
||||||
|
input.value = ''
|
||||||
|
if (!files.length) return
|
||||||
|
this.uploadDocumentsService.uploadFiles(files)
|
||||||
|
}
|
||||||
|
|
||||||
|
get uploadsInProgress(): number {
|
||||||
|
return this.websocketStatusService.getConsumerStatusNotCompleted().length
|
||||||
|
}
|
||||||
|
|
||||||
|
showUploadStatus() {
|
||||||
|
this.uploadStatusTray()?.expand()
|
||||||
|
}
|
||||||
|
|
||||||
onLogout() {
|
onLogout() {
|
||||||
this.openDocumentsService.closeAll()
|
this.openDocumentsService.closeAll()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -409,7 +409,7 @@ describe('GlobalSearchComponent', () => {
|
|||||||
|
|
||||||
const editDialog = modal.componentInstance as CustomFieldEditDialogComponent
|
const editDialog = modal.componentInstance as CustomFieldEditDialogComponent
|
||||||
const toastErrorSpy = jest.spyOn(toastService, 'showError')
|
const toastErrorSpy = jest.spyOn(toastService, 'showError')
|
||||||
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
|
const toastInfoSpy = jest.spyOn(toastService, 'showSuccess')
|
||||||
|
|
||||||
// fail first
|
// fail first
|
||||||
editDialog.failed.emit({ error: 'error creating item' })
|
editDialog.failed.emit({ error: 'error creating item' })
|
||||||
@@ -460,7 +460,7 @@ describe('GlobalSearchComponent', () => {
|
|||||||
|
|
||||||
const editDialog = modal.componentInstance as CustomFieldEditDialogComponent
|
const editDialog = modal.componentInstance as CustomFieldEditDialogComponent
|
||||||
const toastErrorSpy = jest.spyOn(toastService, 'showError')
|
const toastErrorSpy = jest.spyOn(toastService, 'showError')
|
||||||
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
|
const toastInfoSpy = jest.spyOn(toastService, 'showSuccess')
|
||||||
|
|
||||||
// fail first
|
// fail first
|
||||||
editDialog.failed.emit({ error: 'error creating item' })
|
editDialog.failed.emit({ error: 'error creating item' })
|
||||||
|
|||||||
@@ -216,7 +216,7 @@ export class GlobalSearchComponent implements OnInit {
|
|||||||
modalRef.componentInstance.dialogMode.set(EditDialogMode.EDIT)
|
modalRef.componentInstance.dialogMode.set(EditDialogMode.EDIT)
|
||||||
modalRef.componentInstance.object = object
|
modalRef.componentInstance.object = object
|
||||||
modalRef.componentInstance.succeeded.subscribe(() => {
|
modalRef.componentInstance.succeeded.subscribe(() => {
|
||||||
this.toastService.showInfo($localize`Successfully updated object.`)
|
this.toastService.showSuccess($localize`Successfully updated object.`)
|
||||||
})
|
})
|
||||||
modalRef.componentInstance.failed.subscribe((e) => {
|
modalRef.componentInstance.failed.subscribe((e) => {
|
||||||
this.toastService.showError($localize`Error occurred saving object.`, e)
|
this.toastService.showError($localize`Error occurred saving object.`, e)
|
||||||
@@ -254,7 +254,7 @@ export class GlobalSearchComponent implements OnInit {
|
|||||||
modalRef.componentInstance.dialogMode.set(EditDialogMode.EDIT)
|
modalRef.componentInstance.dialogMode.set(EditDialogMode.EDIT)
|
||||||
modalRef.componentInstance.object = object
|
modalRef.componentInstance.object = object
|
||||||
modalRef.componentInstance.succeeded.subscribe(() => {
|
modalRef.componentInstance.succeeded.subscribe(() => {
|
||||||
this.toastService.showInfo($localize`Successfully updated object.`)
|
this.toastService.showSuccess($localize`Successfully updated object.`)
|
||||||
})
|
})
|
||||||
modalRef.componentInstance.failed.subscribe((e) => {
|
modalRef.componentInstance.failed.subscribe((e) => {
|
||||||
this.toastService.showError($localize`Error occurred saving object.`, e)
|
this.toastService.showError($localize`Error occurred saving object.`, e)
|
||||||
|
|||||||
+20
-17
@@ -1,28 +1,31 @@
|
|||||||
|
|
||||||
<li ngbDropdown class="nav-item position-relative" (openChange)="onOpenChange($event)">
|
<li ngbDropdown class="nav-item position-relative" (openChange)="onOpenChange($event)">
|
||||||
@if (toasts().length) {
|
@if (unread().length) {
|
||||||
<span class="notification-count badge rounded-pill z-3 pe-none bg-secondary position-absolute">{{ toasts().length }}</span>
|
<span class="notification-count badge rounded-pill z-3 pe-none position-absolute" [class.bg-danger]="hasUnreadError()" [class.bg-secondary]="!hasUnreadError()">{{ unread().length }}</span>
|
||||||
}
|
}
|
||||||
<button class="btn navbar-action border-0" id="notificationsDropdown" ngbDropdownToggle aria-label="Notifications" i18n-aria-label>
|
<button class="btn navbar-action border-0" id="notificationsDropdown" ngbDropdownToggle aria-label="Notifications" i18n-aria-label>
|
||||||
<i-bs width="1.3em" height="1.3em" name="bell"></i-bs>
|
<i-bs width="1.3em" height="1.3em" name="bell"></i-bs>
|
||||||
</button>
|
</button>
|
||||||
<div ngbDropdownMenu class="dropdown-menu-end shadow p-3" aria-labelledby="notificationsDropdown">
|
<div ngbDropdownMenu class="dropdown-menu-end shadow p-2" aria-labelledby="notificationsDropdown">
|
||||||
<div class="btn-toolbar align-items-center" role="toolbar">
|
<div class="d-flex align-items-center px-1 mb-2">
|
||||||
<h6 i18n>Notifications</h6>
|
<h6 class="mb-0" i18n>Notifications</h6>
|
||||||
<div class="btn-group ms-auto">
|
@if (toasts().length) {
|
||||||
<button class="btn btn-sm btn-outline-secondary mb-2 ms-auto"
|
<button class="btn btn-link btn-sm p-0 ms-auto text-body-secondary text-decoration-none clear-all"
|
||||||
(click)="toastService.clearToasts()"
|
(click)="toastService.clearToasts()">
|
||||||
[disabled]="toasts().length === 0"
|
<i-bs width="1em" height="1em" name="check2-all" class="me-1"></i-bs><ng-container i18n>Clear All</ng-container>
|
||||||
i18n>Clear All</button>
|
</button>
|
||||||
</div>
|
}
|
||||||
</div>
|
</div>
|
||||||
@if (toasts().length === 0) {
|
@if (toasts().length === 0) {
|
||||||
<p class="text-center mb-0 small text-muted"><em i18n>No notifications</em></p>
|
<div class="text-center text-body-secondary small py-4">
|
||||||
}
|
<i-bs width="1.5em" height="1.5em" name="bell" class="d-block mx-auto mb-2 opacity-50"></i-bs>
|
||||||
<div class="scroll-list">
|
<ng-container i18n>No notifications</ng-container>
|
||||||
@for (toast of toasts(); track toast.id) {
|
|
||||||
<pngx-toast [autohide]="false" [toast]="toast" (hidden)="onHidden(toast)" (closed)="toastService.closeToast(toast)"></pngx-toast>
|
|
||||||
}
|
|
||||||
</div>
|
</div>
|
||||||
|
}
|
||||||
|
<div class="scroll-list d-flex flex-column gap-2">
|
||||||
|
@for (toast of toasts(); track toast.id) {
|
||||||
|
<pngx-toast [autohide]="false" [toast]="toast" [now]="now()" [isNew]="newIds().has(toast.id)" (closed)="toastService.closeToast(toast)" (actioned)="onActioned(toast)"></pngx-toast>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
@@ -30,3 +30,7 @@
|
|||||||
right: -3rem;
|
right: -3rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.clear-all:hover {
|
||||||
|
color: var(--bs-body-color) !important;
|
||||||
|
}
|
||||||
+70
-1
@@ -3,7 +3,7 @@ import { provideHttpClientTesting } from '@angular/common/http/testing'
|
|||||||
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||||
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
||||||
import { Subject } from 'rxjs'
|
import { Subject } from 'rxjs'
|
||||||
import { Toast, ToastService } from 'src/app/services/toast.service'
|
import { Toast, ToastLevel, ToastService } from 'src/app/services/toast.service'
|
||||||
import { ToastsDropdownComponent } from './toasts-dropdown.component'
|
import { ToastsDropdownComponent } from './toasts-dropdown.component'
|
||||||
|
|
||||||
const toasts = [
|
const toasts = [
|
||||||
@@ -90,4 +90,73 @@ describe('ToastsDropdownComponent', () => {
|
|||||||
component.onOpenChange(true)
|
component.onOpenChange(true)
|
||||||
expect(spy).toHaveBeenCalledWith(true)
|
expect(spy).toHaveBeenCalledWith(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should badge unread toasts, red when one is an error', () => {
|
||||||
|
toastsSubject.next([
|
||||||
|
{ id: 'a', content: 'Read', delay: 5000, read: true },
|
||||||
|
{ id: 'b', content: 'Unread', delay: 5000, read: false },
|
||||||
|
])
|
||||||
|
fixture.detectChanges()
|
||||||
|
let badge: HTMLElement = fixture.nativeElement.querySelector(
|
||||||
|
'.notification-count'
|
||||||
|
)
|
||||||
|
expect(badge.textContent.trim()).toEqual('1')
|
||||||
|
expect(badge.classList).toContain('bg-secondary')
|
||||||
|
|
||||||
|
toastsSubject.next([
|
||||||
|
{ id: 'c', content: 'Failed', delay: 5000, level: ToastLevel.Error },
|
||||||
|
])
|
||||||
|
fixture.detectChanges()
|
||||||
|
badge = fixture.nativeElement.querySelector('.notification-count')
|
||||||
|
expect(badge.classList).toContain('bg-danger')
|
||||||
|
|
||||||
|
toastsSubject.next([{ id: 'd', content: 'Seen', delay: 5000, read: true }])
|
||||||
|
fixture.detectChanges()
|
||||||
|
expect(
|
||||||
|
fixture.nativeElement.querySelector('.notification-count')
|
||||||
|
).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should mark all toasts read when opened', () => {
|
||||||
|
const markSpy = jest.spyOn(toastService, 'markAllRead')
|
||||||
|
component.onOpenChange(false)
|
||||||
|
expect(markSpy).not.toHaveBeenCalled()
|
||||||
|
component.onOpenChange(true)
|
||||||
|
expect(markSpy).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should tick the clock only while open', () => {
|
||||||
|
jest.useFakeTimers()
|
||||||
|
component.onOpenChange(true)
|
||||||
|
const opened = component.now()
|
||||||
|
jest.advanceTimersByTime(30000)
|
||||||
|
const ticked = component.now()
|
||||||
|
expect(ticked).not.toBe(opened)
|
||||||
|
|
||||||
|
component.onOpenChange(false)
|
||||||
|
jest.advanceTimersByTime(60000)
|
||||||
|
expect(component.now()).toBe(ticked)
|
||||||
|
jest.useRealTimers()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should mark toasts that were unread when opened as new', () => {
|
||||||
|
toastsSubject.next([
|
||||||
|
{ id: 'a', content: 'Seen', delay: 5000, read: true },
|
||||||
|
{ id: 'b', content: 'Unseen', delay: 5000, read: false },
|
||||||
|
])
|
||||||
|
fixture.detectChanges()
|
||||||
|
component.onOpenChange(true)
|
||||||
|
expect([...component.newIds()]).toEqual(['b'])
|
||||||
|
component.onOpenChange(false)
|
||||||
|
expect(component.newIds().size).toEqual(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should clear the toast and close the menu when an action is used', () => {
|
||||||
|
const toast = { id: 'a', content: 'Moved', delay: 5000 }
|
||||||
|
const closeToastSpy = jest.spyOn(toastService, 'closeToast')
|
||||||
|
const closeSpy = jest.spyOn(component.dropdown, 'close')
|
||||||
|
component.onActioned(toast)
|
||||||
|
expect(closeToastSpy).toHaveBeenCalledWith(toast)
|
||||||
|
expect(closeSpy).toHaveBeenCalled()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
@@ -1,12 +1,20 @@
|
|||||||
import { Component, inject } from '@angular/core'
|
|
||||||
import { toSignal } from '@angular/core/rxjs-interop'
|
|
||||||
import {
|
import {
|
||||||
|
Component,
|
||||||
|
DestroyRef,
|
||||||
|
ViewChild,
|
||||||
|
computed,
|
||||||
|
inject,
|
||||||
|
signal,
|
||||||
|
} from '@angular/core'
|
||||||
|
import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop'
|
||||||
|
import {
|
||||||
|
NgbDropdown,
|
||||||
NgbDropdownModule,
|
NgbDropdownModule,
|
||||||
NgbProgressbarModule,
|
NgbProgressbarModule,
|
||||||
} from '@ng-bootstrap/ng-bootstrap'
|
} from '@ng-bootstrap/ng-bootstrap'
|
||||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||||
import { map } from 'rxjs'
|
import { Subscription, interval, map } from 'rxjs'
|
||||||
import { Toast, ToastService } from 'src/app/services/toast.service'
|
import { Toast, ToastLevel, ToastService } from 'src/app/services/toast.service'
|
||||||
import { ToastComponent } from '../../common/toast/toast.component'
|
import { ToastComponent } from '../../common/toast/toast.component'
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
@@ -22,13 +30,44 @@ import { ToastComponent } from '../../common/toast/toast.component'
|
|||||||
})
|
})
|
||||||
export class ToastsDropdownComponent {
|
export class ToastsDropdownComponent {
|
||||||
toastService = inject(ToastService)
|
toastService = inject(ToastService)
|
||||||
|
private destroyRef = inject(DestroyRef)
|
||||||
|
private clock: Subscription
|
||||||
|
|
||||||
|
@ViewChild(NgbDropdown) dropdown: NgbDropdown
|
||||||
|
|
||||||
|
/** Drives the relative timestamps, ticks only while the dropdown is open */
|
||||||
|
readonly now = signal(new Date())
|
||||||
|
|
||||||
|
/** Notifications that were unread when the dropdown was opened */
|
||||||
|
readonly newIds = signal(new Set<string>())
|
||||||
|
|
||||||
readonly toasts = toSignal(
|
readonly toasts = toSignal(
|
||||||
this.toastService.getToasts().pipe(map((toasts) => [...toasts])),
|
this.toastService.getToasts().pipe(map((toasts) => [...toasts])),
|
||||||
{ initialValue: [] as Toast[] }
|
{ initialValue: [] as Toast[] }
|
||||||
)
|
)
|
||||||
|
|
||||||
|
readonly unread = computed(() => this.toasts().filter((t) => !t.read))
|
||||||
|
|
||||||
|
readonly hasUnreadError = computed(() =>
|
||||||
|
this.unread().some((t) => t.level === ToastLevel.Error)
|
||||||
|
)
|
||||||
|
|
||||||
onOpenChange(open: boolean): void {
|
onOpenChange(open: boolean): void {
|
||||||
this.toastService.suppressPopupToasts = open
|
this.toastService.suppressPopupToasts = open
|
||||||
|
this.clock?.unsubscribe()
|
||||||
|
this.newIds.set(new Set(open ? this.unread().map((t) => t.id) : []))
|
||||||
|
if (open) {
|
||||||
|
this.toastService.markAllRead()
|
||||||
|
this.now.set(new Date())
|
||||||
|
this.clock = interval(30000)
|
||||||
|
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||||
|
.subscribe(() => this.now.set(new Date()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Using an action (Undo, Open document...) clears it and closes the menu */
|
||||||
|
onActioned(toast: Toast): void {
|
||||||
|
this.toastService.closeToast(toast)
|
||||||
|
this.dropdown?.close()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,49 +1,85 @@
|
|||||||
|
|
||||||
<li ngbDropdown class="nav-item" (openChange)="onOpenChange($event)">
|
<li ngbDropdown class="nav-item" display="dynamic" placement="bottom-end" [popperOptions]="popperOptions" (openChange)="onOpenChange($event)">
|
||||||
<button class="btn navbar-action border-0" id="chatDropdown" ngbDropdownToggle aria-label="Chat" i18n-aria-label>
|
<button class="btn navbar-action border-0" id="chatDropdown" ngbDropdownToggle aria-label="Chat" i18n-aria-label>
|
||||||
<i-bs width="1.3em" height="1.3em" name="chatSquareDots"></i-bs>
|
<i-bs width="1.3em" height="1.3em" name="chatSquareDots"></i-bs>
|
||||||
</button>
|
</button>
|
||||||
<div ngbDropdownMenu class="dropdown-menu-end shadow p-3" aria-labelledby="chatDropdown">
|
<div ngbDropdownMenu class="dropdown-menu-end shadow p-0 overflow-hidden" aria-labelledby="chatDropdown">
|
||||||
<div class="chat-container bg-light p-2">
|
<div class="d-flex align-items-center border-bottom px-3 py-2">
|
||||||
<div class="chat-messages font-monospace small">
|
<i-bs width="1em" height="1em" name="stars" class="text-primary me-2"></i-bs>
|
||||||
@for (message of messages(); track message) {
|
<span class="fw-semibold small">
|
||||||
<div class="message d-flex flex-row small" [class.justify-content-end]="message.role === 'user'">
|
@if (documentId()) {
|
||||||
<div class="p-2 m-2" [class.bg-body]="message.role === 'user'">
|
<ng-container i18n>Ask about this document</ng-container>
|
||||||
<span class="text-break">
|
} @else {
|
||||||
{{ message.content }}
|
<ng-container i18n>Ask about your documents</ng-container>
|
||||||
@if (message.isStreaming) { <span class="blinking-cursor">|</span> }
|
|
||||||
</span>
|
|
||||||
@if (message.role === 'assistant' && message.references?.length) {
|
|
||||||
<div class="chat-references list-group mt-3">
|
|
||||||
@for (reference of message.references; track reference.id) {
|
|
||||||
<a class="list-group-item list-group-item-action d-flex text-primary text-break" [routerLink]="['/documents', reference.id]">
|
|
||||||
<i-bs width="0.9em" height="0.9em" name="file-text" class="me-1"></i-bs><span>{{ reference.title }}</span>
|
|
||||||
</a>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
}
|
||||||
<div #scrollAnchor></div>
|
</span>
|
||||||
</div>
|
@if (messages().length) {
|
||||||
|
<button class="btn btn-sm btn-link text-muted ms-auto p-0" type="button" (click)="clearChat()" title="Clear chat" i18n-title>
|
||||||
|
<i-bs width="1em" height="1em" name="arrow-counterclockwise"></i-bs>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
<form class="chat-input">
|
<div class="chat-messages small px-2">
|
||||||
<label class="visually-hidden" for="chatInput" i18n>Message</label>
|
@if (!messages().length) {
|
||||||
<div class="input-group">
|
<p class="text-muted text-center py-4 px-3 mb-0" i18n>Answers come from your documents, with links to their sources.</p>
|
||||||
<input
|
}
|
||||||
#chatInput
|
@for (message of messages(); track message) {
|
||||||
id="chatInput"
|
<div class="message d-flex flex-row" [class.justify-content-end]="message.role === 'user'" [class.mt-3]="message.role === 'user' && !$first">
|
||||||
class="form-control form-control-sm" name="chatInput" type="text"
|
<div class="p-2 m-2" [class.bg-body-secondary]="message.role === 'user'" [class.rounded-3]="message.role === 'user'" [class.flex-grow-1]="message.role === 'assistant'">
|
||||||
|
@if (message.isStreaming && !message.content) {
|
||||||
|
<div class="d-flex align-items-center text-muted">
|
||||||
|
<div class="spinner-border spinner-border-sm me-2" role="status"></div>
|
||||||
|
@if (slowResponse()) {
|
||||||
|
<ng-container i18n>Still thinking...</ng-container>
|
||||||
|
} @else {
|
||||||
|
<ng-container i18n>Thinking...</ng-container>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
} @else if (message.role === 'assistant') {
|
||||||
|
<div class="chat-markdown text-break text-wrap" [class.streaming]="message.isStreaming" [innerHTML]="message.content | markdown"></div>
|
||||||
|
} @else {
|
||||||
|
<span class="text-break">{{ message.content }}</span>
|
||||||
|
}
|
||||||
|
@if (message.role === 'assistant' && !message.isStreaming && message.references?.length) {
|
||||||
|
<div class="chat-references list-group mt-3">
|
||||||
|
<div class="list-group-item text-muted small py-1" i18n>Sources</div>
|
||||||
|
@for (reference of message.references; track reference.id) {
|
||||||
|
<a class="list-group-item list-group-item-action d-flex align-items-center text-primary" [routerLink]="['/documents', reference.id]" [title]="reference.title">
|
||||||
|
<i-bs width="0.9em" height="0.9em" name="file-text" class="me-2 flex-shrink-0"></i-bs><span class="text-truncate">{{ reference.title }}</span>
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
<div #scrollAnchor></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form class="chat-input border-top p-2">
|
||||||
|
<label class="visually-hidden" for="chatInput" i18n>Message</label>
|
||||||
|
<div class="input-group">
|
||||||
|
<input
|
||||||
|
#chatInput
|
||||||
|
id="chatInput"
|
||||||
|
class="form-control" name="chatInput" type="text"
|
||||||
[placeholder]="placeholder"
|
[placeholder]="placeholder"
|
||||||
[disabled]="loading()"
|
[disabled]="loading()"
|
||||||
[ngModel]="input()"
|
[ngModel]="input()"
|
||||||
(ngModelChange)="input.set($event)"
|
(ngModelChange)="input.set($event)"
|
||||||
(keydown)="searchInputKeyDown($event)"
|
(keydown)="searchInputKeyDown($event)"
|
||||||
/>
|
/>
|
||||||
<button class="btn btn-sm btn-secondary" type="button" (click)="sendMessage()" [disabled]="loading()">Send</button>
|
@if (loading()) {
|
||||||
</div>
|
<button class="btn btn-outline-secondary" type="button" (click)="stopStreaming()" title="Stop" i18n-title aria-label="Stop" i18n-aria-label>
|
||||||
</form>
|
<i-bs width="1em" height="1em" name="x-lg"></i-bs>
|
||||||
</div>
|
</button>
|
||||||
|
} @else {
|
||||||
|
<button class="btn btn-primary" type="button" (click)="sendMessage()" title="Send" i18n-title aria-label="Send" i18n-aria-label>
|
||||||
|
<i-bs width="1em" height="1em" name="arrow-return-left"></i-bs>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
@@ -1,17 +1,13 @@
|
|||||||
.dropdown-menu {
|
.dropdown-menu {
|
||||||
width: var(--pngx-toast-max-width);
|
width: min(36rem, calc(100vw - 20px));
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-messages {
|
.chat-messages {
|
||||||
max-height: 350px;
|
max-height: min(60dvh, 36rem);
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-references {
|
|
||||||
font-family: var(--bs-font-sans-serif);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dropdown-toggle::after {
|
.dropdown-toggle::after {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
@@ -20,23 +16,44 @@
|
|||||||
white-space: initial;
|
white-space: initial;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media screen and (max-width: 400px) {
|
@media screen and (max-width: 575.98px) {
|
||||||
:host ::ng-deep .dropdown-menu-end {
|
.chat-messages {
|
||||||
right: -3rem;
|
max-height: calc(100dvh - 12rem);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.blinking-cursor {
|
.chat-markdown.streaming ::ng-deep {
|
||||||
font-weight: bold;
|
> :last-child:not(ul, ol, table)::after,
|
||||||
font-size: 1.2em;
|
> :is(ul, ol):last-child > li:last-child::after {
|
||||||
animation: blink 1s step-end infinite;
|
content: '';
|
||||||
|
display: inline-block;
|
||||||
|
width: 2px;
|
||||||
|
height: 1.05em;
|
||||||
|
margin-left: 2px;
|
||||||
|
background-color: currentColor;
|
||||||
|
vertical-align: -0.15em;
|
||||||
|
animation: blink 1s step-end infinite;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes blink {
|
@keyframes blink {
|
||||||
from, to {
|
50% {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
50% {
|
}
|
||||||
opacity: 1;
|
|
||||||
|
.chat-markdown ::ng-deep {
|
||||||
|
h1, h2, h3, h4, h5, h6 {
|
||||||
|
font-size: 1em;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
th, td {
|
||||||
|
padding: 0.15rem 0.4rem;
|
||||||
|
border: 1px solid var(--bs-border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
> :last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -80,8 +80,16 @@ describe('ChatComponent', () => {
|
|||||||
expect(component.messages()[0].content).toBe('Hello')
|
expect(component.messages()[0].content).toBe('Hello')
|
||||||
expect(component.loading()).toBe(true)
|
expect(component.loading()).toBe(true)
|
||||||
|
|
||||||
|
fixture.detectChanges()
|
||||||
|
const messagesEl = fixture.nativeElement.querySelector('.chat-messages')
|
||||||
|
expect(messagesEl.querySelector('.spinner-border')).not.toBeNull()
|
||||||
|
expect(messagesEl.textContent).toContain('Thinking...')
|
||||||
|
|
||||||
mockStream$.next('Hi')
|
mockStream$.next('Hi')
|
||||||
expect(component.messages()[1].content).toBe('H')
|
expect(component.messages()[1].content).toBe('H')
|
||||||
|
fixture.detectChanges()
|
||||||
|
expect(messagesEl.querySelector('.spinner-border')).toBeNull()
|
||||||
|
expect(messagesEl.querySelector('.chat-markdown.streaming')).not.toBeNull()
|
||||||
mockStream$.next('Hi there')
|
mockStream$.next('Hi there')
|
||||||
// advance time to process the typewriter effect
|
// advance time to process the typewriter effect
|
||||||
await jest.runAllTimersAsync()
|
await jest.runAllTimersAsync()
|
||||||
@@ -96,6 +104,8 @@ describe('ChatComponent', () => {
|
|||||||
await fixture.whenStable()
|
await fixture.whenStable()
|
||||||
expect(component.loading()).toBe(false)
|
expect(component.loading()).toBe(false)
|
||||||
expect(component.messages()[1].isStreaming).toBe(false)
|
expect(component.messages()[1].isStreaming).toBe(false)
|
||||||
|
fixture.detectChanges()
|
||||||
|
expect(messagesEl.querySelector('.chat-markdown.streaming')).toBeNull()
|
||||||
expect(fixture.nativeElement.querySelector('#chatInput').disabled).toBe(
|
expect(fixture.nativeElement.querySelector('#chatInput').disabled).toBe(
|
||||||
false
|
false
|
||||||
)
|
)
|
||||||
@@ -123,6 +133,10 @@ describe('ChatComponent', () => {
|
|||||||
mockStream$.next(
|
mockStream$.next(
|
||||||
`Hi there${CHAT_METADATA_DELIMITER}{"references":[{"id":42,"title":"Bread Recipe"}]}`
|
`Hi there${CHAT_METADATA_DELIMITER}{"references":[{"id":42,"title":"Bread Recipe"}]}`
|
||||||
)
|
)
|
||||||
|
fixture.detectChanges()
|
||||||
|
expect(fixture.nativeElement.querySelector('.chat-references')).toBeNull()
|
||||||
|
|
||||||
|
mockStream$.complete()
|
||||||
jest.advanceTimersByTime(1000)
|
jest.advanceTimersByTime(1000)
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
|
|
||||||
@@ -156,13 +170,96 @@ describe('ChatComponent', () => {
|
|||||||
component.input.set('Hello')
|
component.input.set('Hello')
|
||||||
component.sendMessage()
|
component.sendMessage()
|
||||||
|
|
||||||
|
mockStream$.next('Partial')
|
||||||
mockStream$.error('Error')
|
mockStream$.error('Error')
|
||||||
expect(component.messages()[1].content).toContain(
|
expect(component.messages()[1].content).toBe(
|
||||||
'⚠️ Error receiving response.'
|
'Partial\n\n⚠️ Error receiving response.'
|
||||||
)
|
)
|
||||||
expect(component.loading()).toBe(false)
|
expect(component.loading()).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should keep streaming until the typewriter catches up', () => {
|
||||||
|
component.input.set('Hello')
|
||||||
|
component.sendMessage()
|
||||||
|
|
||||||
|
mockStream$.next('Hi there')
|
||||||
|
mockStream$.complete()
|
||||||
|
expect(component.messages()[1].isStreaming).toBe(true)
|
||||||
|
expect(component.loading()).toBe(true)
|
||||||
|
|
||||||
|
jest.advanceTimersByTime(1000)
|
||||||
|
expect(component.messages()[1].content).toBe('Hi there')
|
||||||
|
expect(component.messages()[1].isStreaming).toBe(false)
|
||||||
|
expect(component.loading()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should show a slow response message while waiting', () => {
|
||||||
|
component.input.set('Hello')
|
||||||
|
component.sendMessage()
|
||||||
|
fixture.detectChanges()
|
||||||
|
const messagesEl = fixture.nativeElement.querySelector('.chat-messages')
|
||||||
|
expect(messagesEl.textContent).not.toContain('Still thinking...')
|
||||||
|
|
||||||
|
jest.advanceTimersByTime(8000)
|
||||||
|
fixture.detectChanges()
|
||||||
|
expect(messagesEl.textContent).toContain('Still thinking...')
|
||||||
|
|
||||||
|
mockStream$.next('Hi')
|
||||||
|
expect(component.slowResponse()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should stop streaming and keep the partial response', () => {
|
||||||
|
component.input.set('Hello')
|
||||||
|
component.sendMessage()
|
||||||
|
fixture.detectChanges()
|
||||||
|
const stopButton = fixture.nativeElement.querySelector('.chat-input button')
|
||||||
|
expect(stopButton.getAttribute('aria-label')).toBe('Stop')
|
||||||
|
|
||||||
|
mockStream$.next('Hi there')
|
||||||
|
jest.advanceTimersByTime(20)
|
||||||
|
stopButton.click()
|
||||||
|
|
||||||
|
expect(mockStream$.observed).toBe(false)
|
||||||
|
expect(component.messages()[1].content).toBe('Hi there')
|
||||||
|
expect(component.messages()[1].isStreaming).toBe(false)
|
||||||
|
expect(component.loading()).toBe(false)
|
||||||
|
|
||||||
|
jest.advanceTimersByTime(1000)
|
||||||
|
expect(component.messages()[1].content).toBe('Hi there')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should drop the empty response when stopped before any content', () => {
|
||||||
|
component.input.set('Hello')
|
||||||
|
component.sendMessage()
|
||||||
|
component.stopStreaming()
|
||||||
|
|
||||||
|
expect(component.messages()).toHaveLength(1)
|
||||||
|
expect(component.loading()).toBe(false)
|
||||||
|
component.stopStreaming() // no-op once stopped
|
||||||
|
expect(component.messages()).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should clear the conversation', () => {
|
||||||
|
component.input.set('Hello')
|
||||||
|
component.sendMessage()
|
||||||
|
mockStream$.next('Hi there')
|
||||||
|
fixture.detectChanges()
|
||||||
|
|
||||||
|
fixture.nativeElement.querySelector('button[title="Clear chat"]').click()
|
||||||
|
expect(component.messages()).toHaveLength(0)
|
||||||
|
expect(component.loading()).toBe(false)
|
||||||
|
expect(mockStream$.observed).toBe(false)
|
||||||
|
|
||||||
|
fixture.detectChanges()
|
||||||
|
jest.advanceTimersByTime(10)
|
||||||
|
expect(document.activeElement).toBe(
|
||||||
|
fixture.nativeElement.querySelector('#chatInput')
|
||||||
|
)
|
||||||
|
expect(fixture.nativeElement.textContent).toContain(
|
||||||
|
'Answers come from your documents'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
it('should enqueue typewriter chunks correctly', () => {
|
it('should enqueue typewriter chunks correctly', () => {
|
||||||
const message = { content: '', role: 'assistant', isStreaming: true }
|
const message = { content: '', role: 'assistant', isStreaming: true }
|
||||||
component.enqueueTypewriter(null, message as any) // coverage for null
|
component.enqueueTypewriter(null, message as any) // coverage for null
|
||||||
@@ -207,4 +304,33 @@ describe('ChatComponent', () => {
|
|||||||
component.searchInputKeyDown(event)
|
component.searchInputKeyDown(event)
|
||||||
expect(component.sendMessage).not.toHaveBeenCalled()
|
expect(component.sendMessage).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should render markdown for assistant messages only', () => {
|
||||||
|
component.messages.set([
|
||||||
|
{ role: 'user', content: '**user**' },
|
||||||
|
{ role: 'assistant', content: '**assistant** <script>x</script>' },
|
||||||
|
])
|
||||||
|
fixture.detectChanges()
|
||||||
|
const el: HTMLElement = fixture.nativeElement
|
||||||
|
expect(el.querySelector('.chat-markdown strong')?.textContent).toBe(
|
||||||
|
'assistant'
|
||||||
|
)
|
||||||
|
expect(el.querySelector('.chat-markdown script')).toBeNull()
|
||||||
|
expect(el.textContent).toContain('**user**')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should keep the destination hostname visible after sanitizing chat links', () => {
|
||||||
|
component.messages.set([
|
||||||
|
{
|
||||||
|
role: 'assistant',
|
||||||
|
content:
|
||||||
|
'[View original document](https://otherdomain.example/?data=secret)',
|
||||||
|
},
|
||||||
|
])
|
||||||
|
fixture.detectChanges()
|
||||||
|
const el: HTMLElement = fixture.nativeElement
|
||||||
|
const link = el.querySelector('.chat-markdown a')
|
||||||
|
expect(link?.textContent).toBe('View original document')
|
||||||
|
expect(link?.nextElementSibling?.textContent).toBe('(otherdomain.example)')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
@@ -10,12 +10,16 @@ import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
|||||||
import { NavigationEnd, Router, RouterModule } from '@angular/router'
|
import { NavigationEnd, Router, RouterModule } from '@angular/router'
|
||||||
import { NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap'
|
import { NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap'
|
||||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||||
import { filter, map } from 'rxjs'
|
import { filter, map, Subscription } from 'rxjs'
|
||||||
|
import { MarkdownPipe } from 'src/app/pipes/markdown.pipe'
|
||||||
import {
|
import {
|
||||||
ChatMessage,
|
ChatMessage,
|
||||||
ChatService,
|
ChatService,
|
||||||
parseChatResponse,
|
parseChatResponse,
|
||||||
} from 'src/app/services/chat.service'
|
} from 'src/app/services/chat.service'
|
||||||
|
import { pngxPopperOptions } from 'src/app/utils/popper-options'
|
||||||
|
|
||||||
|
const SLOW_RESPONSE_DELAY_MS = 8000
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'pngx-chat',
|
selector: 'pngx-chat',
|
||||||
@@ -25,6 +29,7 @@ import {
|
|||||||
RouterModule,
|
RouterModule,
|
||||||
NgxBootstrapIconsModule,
|
NgxBootstrapIconsModule,
|
||||||
NgbDropdownModule,
|
NgbDropdownModule,
|
||||||
|
MarkdownPipe,
|
||||||
],
|
],
|
||||||
templateUrl: './chat.component.html',
|
templateUrl: './chat.component.html',
|
||||||
styleUrl: './chat.component.scss',
|
styleUrl: './chat.component.scss',
|
||||||
@@ -34,6 +39,8 @@ export class ChatComponent implements OnInit {
|
|||||||
readonly loading = signal(false)
|
readonly loading = signal(false)
|
||||||
readonly input = signal('')
|
readonly input = signal('')
|
||||||
readonly documentId = signal<number>(undefined)
|
readonly documentId = signal<number>(undefined)
|
||||||
|
readonly slowResponse = signal(false)
|
||||||
|
public popperOptions = pngxPopperOptions
|
||||||
|
|
||||||
private chatService: ChatService = inject(ChatService)
|
private chatService: ChatService = inject(ChatService)
|
||||||
private router: Router = inject(Router)
|
private router: Router = inject(Router)
|
||||||
@@ -43,6 +50,11 @@ export class ChatComponent implements OnInit {
|
|||||||
|
|
||||||
private typewriterBuffer: string[] = []
|
private typewriterBuffer: string[] = []
|
||||||
private typewriterActive = false
|
private typewriterActive = false
|
||||||
|
private typewriterTimeout: ReturnType<typeof setTimeout>
|
||||||
|
private streamSubscription: Subscription
|
||||||
|
private streamingMessage: ChatMessage
|
||||||
|
private streamFinished = false
|
||||||
|
private slowResponseTimeout: ReturnType<typeof setTimeout>
|
||||||
|
|
||||||
public get placeholder(): string {
|
public get placeholder(): string {
|
||||||
return this.documentId()
|
return this.documentId()
|
||||||
@@ -81,47 +93,96 @@ export class ChatComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
this.messages.update((messages) => [...messages, assistantMessage])
|
this.messages.update((messages) => [...messages, assistantMessage])
|
||||||
this.loading.set(true)
|
this.loading.set(true)
|
||||||
|
this.streamingMessage = assistantMessage
|
||||||
|
this.streamFinished = false
|
||||||
|
this.slowResponseTimeout = setTimeout(
|
||||||
|
() => this.slowResponse.set(true),
|
||||||
|
SLOW_RESPONSE_DELAY_MS
|
||||||
|
)
|
||||||
|
|
||||||
let lastVisibleContent = ''
|
let lastVisibleContent = ''
|
||||||
|
|
||||||
this.chatService.streamChat(this.documentId(), this.input()).subscribe({
|
this.streamSubscription = this.chatService
|
||||||
next: (chunk) => {
|
.streamChat(this.documentId(), this.input())
|
||||||
const nextResponse = parseChatResponse(chunk)
|
.subscribe({
|
||||||
|
next: (chunk) => {
|
||||||
|
this.clearSlowResponse()
|
||||||
|
const nextResponse = parseChatResponse(chunk)
|
||||||
|
|
||||||
if (nextResponse.content.length < lastVisibleContent.length) {
|
if (nextResponse.content.length < lastVisibleContent.length) {
|
||||||
this.resetTypewriter(assistantMessage, nextResponse.content)
|
this.resetTypewriter(assistantMessage, nextResponse.content)
|
||||||
lastVisibleContent = nextResponse.content
|
lastVisibleContent = nextResponse.content
|
||||||
} else {
|
} else {
|
||||||
const visibleDelta = nextResponse.content.substring(
|
const visibleDelta = nextResponse.content.substring(
|
||||||
lastVisibleContent.length
|
lastVisibleContent.length
|
||||||
)
|
)
|
||||||
lastVisibleContent = nextResponse.content
|
lastVisibleContent = nextResponse.content
|
||||||
this.enqueueTypewriter(visibleDelta, assistantMessage)
|
this.enqueueTypewriter(visibleDelta, assistantMessage)
|
||||||
}
|
}
|
||||||
|
|
||||||
assistantMessage.references = nextResponse.references
|
assistantMessage.references = nextResponse.references
|
||||||
this.notifyMessagesChanged()
|
this.notifyMessagesChanged()
|
||||||
},
|
},
|
||||||
error: () => {
|
error: () => {
|
||||||
assistantMessage.content += '\n\n⚠️ Error receiving response.'
|
this.flushTypewriter(assistantMessage)
|
||||||
assistantMessage.isStreaming = false
|
assistantMessage.content += '\n\n⚠️ Error receiving response.'
|
||||||
this.notifyMessagesChanged()
|
this.finishStreaming(assistantMessage)
|
||||||
this.loading.set(false)
|
},
|
||||||
},
|
complete: () => {
|
||||||
complete: () => {
|
// let the typewriter catch up before hiding the cursor
|
||||||
assistantMessage.isStreaming = false
|
this.streamFinished = true
|
||||||
this.notifyMessagesChanged()
|
if (!this.typewriterActive) this.finishStreaming(assistantMessage)
|
||||||
this.loading.set(false)
|
},
|
||||||
this.scrollToBottom()
|
})
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
this.input.set('')
|
this.input.set('')
|
||||||
}
|
}
|
||||||
|
|
||||||
private resetTypewriter(message: ChatMessage, content: string): void {
|
stopStreaming(): void {
|
||||||
|
const message = this.streamingMessage
|
||||||
|
if (!message?.isStreaming) return
|
||||||
|
|
||||||
|
this.streamSubscription?.unsubscribe()
|
||||||
|
this.flushTypewriter(message)
|
||||||
|
if (!message.content) {
|
||||||
|
this.messages.update((messages) => messages.filter((m) => m !== message))
|
||||||
|
}
|
||||||
|
this.finishStreaming(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
clearChat(): void {
|
||||||
|
this.stopStreaming()
|
||||||
|
this.messages.set([])
|
||||||
|
setTimeout(() => this.chatInput?.nativeElement.focus())
|
||||||
|
}
|
||||||
|
|
||||||
|
private finishStreaming(message: ChatMessage): void {
|
||||||
|
this.clearSlowResponse()
|
||||||
|
this.streamFinished = false
|
||||||
|
message.isStreaming = false
|
||||||
|
this.notifyMessagesChanged()
|
||||||
|
this.loading.set(false)
|
||||||
|
this.scrollToBottom()
|
||||||
|
}
|
||||||
|
|
||||||
|
private clearSlowResponse(): void {
|
||||||
|
clearTimeout(this.slowResponseTimeout)
|
||||||
|
this.slowResponse.set(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
private flushTypewriter(message: ChatMessage): void {
|
||||||
|
message.content += this.typewriterBuffer.join('')
|
||||||
|
this.cancelTypewriter()
|
||||||
|
}
|
||||||
|
|
||||||
|
private cancelTypewriter(): void {
|
||||||
|
clearTimeout(this.typewriterTimeout)
|
||||||
this.typewriterBuffer = []
|
this.typewriterBuffer = []
|
||||||
this.typewriterActive = false
|
this.typewriterActive = false
|
||||||
|
}
|
||||||
|
|
||||||
|
private resetTypewriter(message: ChatMessage, content: string): void {
|
||||||
|
this.cancelTypewriter()
|
||||||
message.content = content
|
message.content = content
|
||||||
this.notifyMessagesChanged()
|
this.notifyMessagesChanged()
|
||||||
this.scrollToBottom()
|
this.scrollToBottom()
|
||||||
@@ -141,6 +202,7 @@ export class ChatComponent implements OnInit {
|
|||||||
playTypewriter(message: ChatMessage): void {
|
playTypewriter(message: ChatMessage): void {
|
||||||
if (this.typewriterBuffer.length === 0) {
|
if (this.typewriterBuffer.length === 0) {
|
||||||
this.typewriterActive = false
|
this.typewriterActive = false
|
||||||
|
if (this.streamFinished) this.finishStreaming(message)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -149,7 +211,8 @@ export class ChatComponent implements OnInit {
|
|||||||
this.notifyMessagesChanged()
|
this.notifyMessagesChanged()
|
||||||
this.scrollToBottom()
|
this.scrollToBottom()
|
||||||
|
|
||||||
setTimeout(() => this.playTypewriter(message), 10) // 10ms per character
|
// 10ms per character
|
||||||
|
this.typewriterTimeout = setTimeout(() => this.playTypewriter(message), 10)
|
||||||
}
|
}
|
||||||
|
|
||||||
private notifyMessagesChanged(): void {
|
private notifyMessagesChanged(): void {
|
||||||
|
|||||||
+30
@@ -0,0 +1,30 @@
|
|||||||
|
<div class="modal-header">
|
||||||
|
<h4 class="modal-title" id="modal-basic-title">{{title}}</h4>
|
||||||
|
<button type="button" class="btn-close" aria-label="Close" (click)="cancel()">
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
@if (messageBold) {
|
||||||
|
<p class="text-break"><b>{{messageBold}}</b></p>
|
||||||
|
}
|
||||||
|
@if (message) {
|
||||||
|
<p class="text-break" [innerHTML]="message"></p>
|
||||||
|
}
|
||||||
|
<pngx-input-select
|
||||||
|
[items]="templates()"
|
||||||
|
[(ngModel)]="template"
|
||||||
|
[allowNull]="true"
|
||||||
|
title="OCR template"
|
||||||
|
i18n-title
|
||||||
|
placeholder="All matching templates"
|
||||||
|
i18n-placeholder
|
||||||
|
></pngx-input-select>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn" [class]="cancelBtnClass" (click)="cancel()" [disabled]="!buttonsEnabled()">
|
||||||
|
<span class="d-inline-block" style="padding-bottom: 1px;">{{cancelBtnCaption}}</span>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn" [class]="btnClass" (click)="confirm()" [disabled]="!confirmButtonEnabled || !buttonsEnabled()">
|
||||||
|
{{btnCaption}}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
+52
@@ -0,0 +1,52 @@
|
|||||||
|
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
|
||||||
|
import { provideHttpClientTesting } from '@angular/common/http/testing'
|
||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||||
|
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
|
||||||
|
import { of } from 'rxjs'
|
||||||
|
import { MATCH_NONE } from 'src/app/data/matching-model'
|
||||||
|
import { OcrTemplate } from 'src/app/data/ocr-template'
|
||||||
|
import { OcrTemplateService } from 'src/app/services/rest/ocr-template.service'
|
||||||
|
import { ZoneOcrConfirmDialogComponent } from './zone-ocr-confirm-dialog.component'
|
||||||
|
|
||||||
|
const templates: OcrTemplate[] = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: 'Invoices',
|
||||||
|
match: '',
|
||||||
|
matching_algorithm: MATCH_NONE,
|
||||||
|
sample_document: null,
|
||||||
|
source_width: 0,
|
||||||
|
source_height: 0,
|
||||||
|
enabled: true,
|
||||||
|
zones: [],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
describe('ZoneOcrConfirmDialogComponent', () => {
|
||||||
|
let component: ZoneOcrConfirmDialogComponent
|
||||||
|
let fixture: ComponentFixture<ZoneOcrConfirmDialogComponent>
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
providers: [
|
||||||
|
NgbActiveModal,
|
||||||
|
provideHttpClient(withInterceptorsFromDi()),
|
||||||
|
provideHttpClientTesting(),
|
||||||
|
],
|
||||||
|
imports: [ZoneOcrConfirmDialogComponent],
|
||||||
|
})
|
||||||
|
jest
|
||||||
|
.spyOn(TestBed.inject(OcrTemplateService), 'listAll')
|
||||||
|
.mockReturnValue(of({ count: 1, all: [1], results: templates }))
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(ZoneOcrConfirmDialogComponent)
|
||||||
|
component = fixture.componentInstance
|
||||||
|
fixture.detectChanges()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should offer templates and default to all matching templates', () => {
|
||||||
|
expect(component.templates()).toEqual(templates)
|
||||||
|
expect(component.template).toBeNull()
|
||||||
|
expect(fixture.nativeElement.textContent).toContain('OCR template')
|
||||||
|
})
|
||||||
|
})
|
||||||
+31
@@ -0,0 +1,31 @@
|
|||||||
|
import { Component, OnInit, inject, signal } from '@angular/core'
|
||||||
|
import { FormsModule } from '@angular/forms'
|
||||||
|
import { takeUntil } from 'rxjs'
|
||||||
|
import { OcrTemplate } from 'src/app/data/ocr-template'
|
||||||
|
import { OcrTemplateService } from 'src/app/services/rest/ocr-template.service'
|
||||||
|
import { SelectComponent } from '../../input/select/select.component'
|
||||||
|
import { ConfirmDialogComponent } from '../confirm-dialog.component'
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'pngx-zone-ocr-confirm-dialog',
|
||||||
|
templateUrl: './zone-ocr-confirm-dialog.component.html',
|
||||||
|
imports: [FormsModule, SelectComponent],
|
||||||
|
})
|
||||||
|
export class ZoneOcrConfirmDialogComponent
|
||||||
|
extends ConfirmDialogComponent
|
||||||
|
implements OnInit
|
||||||
|
{
|
||||||
|
private readonly ocrTemplateService = inject(OcrTemplateService)
|
||||||
|
|
||||||
|
public readonly templates = signal<OcrTemplate[]>([])
|
||||||
|
|
||||||
|
/** The template to run, or null for every template that matches. */
|
||||||
|
template: number | null = null
|
||||||
|
|
||||||
|
ngOnInit() {
|
||||||
|
this.ocrTemplateService
|
||||||
|
.listAll()
|
||||||
|
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||||
|
.subscribe((r) => this.templates.set(r.results))
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
@@ -110,7 +110,7 @@ describe('CustomFieldsDropdownComponent', () => {
|
|||||||
let modal: NgbModalRef
|
let modal: NgbModalRef
|
||||||
modalService.activeInstances.subscribe((m) => (modal = m[m.length - 1]))
|
modalService.activeInstances.subscribe((m) => (modal = m[m.length - 1]))
|
||||||
const toastErrorSpy = jest.spyOn(toastService, 'showError')
|
const toastErrorSpy = jest.spyOn(toastService, 'showError')
|
||||||
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
|
const toastInfoSpy = jest.spyOn(toastService, 'showSuccess')
|
||||||
const getFieldsSpy = jest.spyOn(
|
const getFieldsSpy = jest.spyOn(
|
||||||
CustomFieldsDropdownComponent.prototype as any,
|
CustomFieldsDropdownComponent.prototype as any,
|
||||||
'getFields'
|
'getFields'
|
||||||
|
|||||||
+3
-1
@@ -129,7 +129,9 @@ export class CustomFieldsDropdownComponent extends LoadingComponentWithPermissio
|
|||||||
modal.componentInstance.succeeded
|
modal.componentInstance.succeeded
|
||||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||||
.subscribe((newField) => {
|
.subscribe((newField) => {
|
||||||
this.toastService.showInfo($localize`Saved field "${newField.name}".`)
|
this.toastService.showSuccess(
|
||||||
|
$localize`Saved field "${newField.name}".`
|
||||||
|
)
|
||||||
this.customFieldsService.clearCache()
|
this.customFieldsService.clearCache()
|
||||||
this.getFields()
|
this.getFields()
|
||||||
this.created.emit(newField)
|
this.created.emit(newField)
|
||||||
|
|||||||
+1
-1
@@ -151,7 +151,7 @@ describe('UserEditDialogComponent', () => {
|
|||||||
'deactivateTotp'
|
'deactivateTotp'
|
||||||
)
|
)
|
||||||
const toastErrorSpy = jest.spyOn(toastService, 'showError')
|
const toastErrorSpy = jest.spyOn(toastService, 'showError')
|
||||||
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
|
const toastInfoSpy = jest.spyOn(toastService, 'showSuccess')
|
||||||
deactivateSpy.mockReturnValueOnce(throwError(() => new Error('error')))
|
deactivateSpy.mockReturnValueOnce(throwError(() => new Error('error')))
|
||||||
component.deactivateTotp()
|
component.deactivateTotp()
|
||||||
expect(deactivateSpy).toHaveBeenCalled()
|
expect(deactivateSpy).toHaveBeenCalled()
|
||||||
|
|||||||
+1
-1
@@ -132,7 +132,7 @@ export class UserEditDialogComponent
|
|||||||
next: (result) => {
|
next: (result) => {
|
||||||
this.totpLoading.set(false)
|
this.totpLoading.set(false)
|
||||||
if (result) {
|
if (result) {
|
||||||
this.toastService.showInfo($localize`Totp deactivated`)
|
this.toastService.showSuccess($localize`Totp deactivated`)
|
||||||
this.object.is_mfa_enabled = false
|
this.object.is_mfa_enabled = false
|
||||||
} else {
|
} else {
|
||||||
this.toastService.showError($localize`Totp deactivation failed`)
|
this.toastService.showError($localize`Totp deactivation failed`)
|
||||||
|
|||||||
+2
-1
@@ -209,6 +209,7 @@
|
|||||||
@if (isTagsFilter(filter.get('type').value)) {
|
@if (isTagsFilter(filter.get('type').value)) {
|
||||||
<pngx-input-tags
|
<pngx-input-tags
|
||||||
[allowCreate]="false"
|
[allowCreate]="false"
|
||||||
|
[preserveHierarchy]="false"
|
||||||
[title]="null"
|
[title]="null"
|
||||||
formControlName="values"
|
formControlName="values"
|
||||||
></pngx-input-tags>
|
></pngx-input-tags>
|
||||||
@@ -320,7 +321,7 @@
|
|||||||
<h6 class="form-label" i18n>Remove tags</h6>
|
<h6 class="form-label" i18n>Remove tags</h6>
|
||||||
<pngx-input-switch i18n-title title="Remove all" [horizontal]="true" formControlName="remove_all_tags"></pngx-input-switch>
|
<pngx-input-switch i18n-title title="Remove all" [horizontal]="true" formControlName="remove_all_tags"></pngx-input-switch>
|
||||||
<div class="mt-n3">
|
<div class="mt-n3">
|
||||||
<pngx-input-tags [allowCreate]="false" title="" formControlName="remove_tags"></pngx-input-tags>
|
<pngx-input-tags [allowCreate]="false" [preserveHierarchy]="false" title="" formControlName="remove_tags"></pngx-input-tags>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h6 class="form-label" i18n>Remove correspondents</h6>
|
<h6 class="form-label" i18n>Remove correspondents</h6>
|
||||||
|
|||||||
+2
-2
@@ -52,7 +52,7 @@ describe('EmailDocumentDialogComponent', () => {
|
|||||||
|
|
||||||
it('should support sending single document via email, showing error if needed', () => {
|
it('should support sending single document via email, showing error if needed', () => {
|
||||||
const toastErrorSpy = jest.spyOn(toastService, 'showError')
|
const toastErrorSpy = jest.spyOn(toastService, 'showError')
|
||||||
const toastSuccessSpy = jest.spyOn(toastService, 'showInfo')
|
const toastSuccessSpy = jest.spyOn(toastService, 'showSuccess')
|
||||||
component.documentIds.set([1])
|
component.documentIds.set([1])
|
||||||
component.emailAddress = 'hello@paperless-ngx.com'
|
component.emailAddress = 'hello@paperless-ngx.com'
|
||||||
component.emailSubject = 'Hello'
|
component.emailSubject = 'Hello'
|
||||||
@@ -73,7 +73,7 @@ describe('EmailDocumentDialogComponent', () => {
|
|||||||
|
|
||||||
it('should support sending multiple documents via email, showing appropriate messages', () => {
|
it('should support sending multiple documents via email, showing appropriate messages', () => {
|
||||||
const toastErrorSpy = jest.spyOn(toastService, 'showError')
|
const toastErrorSpy = jest.spyOn(toastService, 'showError')
|
||||||
const toastSuccessSpy = jest.spyOn(toastService, 'showInfo')
|
const toastSuccessSpy = jest.spyOn(toastService, 'showSuccess')
|
||||||
component.documentIds.set([1, 2, 3])
|
component.documentIds.set([1, 2, 3])
|
||||||
component.emailAddress = 'hello@paperless-ngx.com'
|
component.emailAddress = 'hello@paperless-ngx.com'
|
||||||
component.emailSubject = 'Hello'
|
component.emailSubject = 'Hello'
|
||||||
|
|||||||
+1
-1
@@ -50,7 +50,7 @@ export class EmailDocumentDialogComponent extends LoadingComponentWithPermission
|
|||||||
this.emailSubject = ''
|
this.emailSubject = ''
|
||||||
this.emailMessage = ''
|
this.emailMessage = ''
|
||||||
this.close()
|
this.close()
|
||||||
this.toastService.showInfo($localize`Email sent`)
|
this.toastService.showSuccess($localize`Email sent`)
|
||||||
},
|
},
|
||||||
error: (e) => {
|
error: (e) => {
|
||||||
this.loading.set(false)
|
this.loading.set(false)
|
||||||
|
|||||||
@@ -40,7 +40,7 @@
|
|||||||
(change)="onChange(selectedDocumentIDs)">
|
(change)="onChange(selectedDocumentIDs)">
|
||||||
<ng-template ng-label-tmp let-document="item">
|
<ng-template ng-label-tmp let-document="item">
|
||||||
<div class="d-flex align-items-center">
|
<div class="d-flex align-items-center">
|
||||||
@if (!disabled) {
|
@if (!disabled && multiple) {
|
||||||
<button class="btn p-0 lh-1" (click)="unselect(document)" (mousedown)="$event.stopImmediatePropagation()" type="button" title="Remove link" i18n-title><i-bs name="x"></i-bs></button>
|
<button class="btn p-0 lh-1" (click)="unselect(document)" (mousedown)="$event.stopImmediatePropagation()" type="button" title="Remove link" i18n-title><i-bs name="x"></i-bs></button>
|
||||||
}
|
}
|
||||||
@if (document.title) {
|
@if (document.title) {
|
||||||
|
|||||||
@@ -81,6 +81,9 @@ export class DocumentLinkComponent
|
|||||||
@Input()
|
@Input()
|
||||||
placeholder: string = $localize`Search for documents`
|
placeholder: string = $localize`Search for documents`
|
||||||
|
|
||||||
|
@Input()
|
||||||
|
multiple: boolean = true
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parent for ng-select dropdown, needed to prevent close on click.
|
* Parent for ng-select dropdown, needed to prevent close on click.
|
||||||
*/
|
*/
|
||||||
|
|||||||
+3
-1
@@ -1,5 +1,7 @@
|
|||||||
@if (!accordion) {
|
@if (!accordion) {
|
||||||
<h5 i18n>Permissions</h5>
|
@if (title?.length > 0) {
|
||||||
|
<h5>{{title}}</h5>
|
||||||
|
}
|
||||||
<ng-container [ngTemplateOutlet]="permissionsForm"></ng-container>
|
<ng-container [ngTemplateOutlet]="permissionsForm"></ng-container>
|
||||||
}
|
}
|
||||||
@if (accordion) {
|
@if (accordion) {
|
||||||
|
|||||||
+3
@@ -59,6 +59,9 @@ export class PermissionsFormComponent
|
|||||||
@Input()
|
@Input()
|
||||||
accordion: boolean = false
|
accordion: boolean = false
|
||||||
|
|
||||||
|
@Input()
|
||||||
|
title: string = $localize`Permissions`
|
||||||
|
|
||||||
form = new FormGroup({
|
form = new FormGroup({
|
||||||
owner: new FormControl(null),
|
owner: new FormControl(null),
|
||||||
set_permissions: new FormGroup({
|
set_permissions: new FormGroup({
|
||||||
|
|||||||
@@ -222,6 +222,22 @@ describe('TagsComponent', () => {
|
|||||||
expect(propagated).toEqual([2, 1])
|
expect(propagated).toEqual([2, 1])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should not add parents or remove children when not preserving hierarchy', () => {
|
||||||
|
const parent: Tag = { id: 1, name: 'parent' }
|
||||||
|
const child: Tag = { id: 2, name: 'child', parent: 1 }
|
||||||
|
parent.children = [child]
|
||||||
|
component.tags = [parent, child]
|
||||||
|
component.preserveHierarchy = false
|
||||||
|
|
||||||
|
component.value = []
|
||||||
|
component.addTag(2)
|
||||||
|
expect(component.value).toEqual([2])
|
||||||
|
|
||||||
|
component.value = [1, 2]
|
||||||
|
component.removeTag(1)
|
||||||
|
expect(component.value).toEqual([2])
|
||||||
|
})
|
||||||
|
|
||||||
it('should not duplicate parents when adding sibling nested tags', () => {
|
it('should not duplicate parents when adding sibling nested tags', () => {
|
||||||
const root: Tag = { id: 1, name: 'root' }
|
const root: Tag = { id: 1, name: 'root' }
|
||||||
const parent: Tag = { id: 2, name: 'parent', parent: 1 }
|
const parent: Tag = { id: 2, name: 'parent', parent: 1 }
|
||||||
|
|||||||
@@ -109,6 +109,9 @@ export class TagsComponent implements OnInit, ControlValueAccessor {
|
|||||||
@Input()
|
@Input()
|
||||||
multiple: boolean = true
|
multiple: boolean = true
|
||||||
|
|
||||||
|
@Input()
|
||||||
|
preserveHierarchy: boolean = true
|
||||||
|
|
||||||
@Output()
|
@Output()
|
||||||
filterDocuments = new EventEmitter<Tag[]>()
|
filterDocuments = new EventEmitter<Tag[]>()
|
||||||
|
|
||||||
@@ -148,7 +151,9 @@ export class TagsComponent implements OnInit, ControlValueAccessor {
|
|||||||
oldValue.splice(index, 1)
|
oldValue.splice(index, 1)
|
||||||
|
|
||||||
// remove children
|
// remove children
|
||||||
oldValue = this.removeChildren(oldValue, tag)
|
if (this.preserveHierarchy) {
|
||||||
|
oldValue = this.removeChildren(oldValue, tag)
|
||||||
|
}
|
||||||
|
|
||||||
this.value = [...oldValue]
|
this.value = [...oldValue]
|
||||||
this.onChange(this.value)
|
this.onChange(this.value)
|
||||||
@@ -167,7 +172,7 @@ export class TagsComponent implements OnInit, ControlValueAccessor {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public onAdd(tag: Tag) {
|
public onAdd(tag: Tag) {
|
||||||
if (tag?.parent) {
|
if (this.preserveHierarchy && tag?.parent) {
|
||||||
// add all parents recursively
|
// add all parents recursively
|
||||||
const parent = this.getTag(tag.parent)
|
const parent = this.getTag(tag.parent)
|
||||||
if (parent && !this.value.includes(parent.id)) {
|
if (parent && !this.value.includes(parent.id)) {
|
||||||
|
|||||||
@@ -3,13 +3,13 @@
|
|||||||
<h3 class="d-flex align-items-center mb-2 mb-md-1" style="line-height: 1.4">
|
<h3 class="d-flex align-items-center mb-2 mb-md-1" style="line-height: 1.4">
|
||||||
<span class="text-truncate">{{title()}}</span>
|
<span class="text-truncate">{{title()}}</span>
|
||||||
@if (id()) {
|
@if (id()) {
|
||||||
<span class="badge bg-primary text-primary-text-contrast ms-3 small fs-normal cursor-pointer" (click)="copyID()">
|
<button type="button" class="badge border-0 bg-primary text-primary-text-contrast ms-3 small fs-normal" (click)="copyID()" title="Copy ID" i18n-title>
|
||||||
@if (copied()) {
|
@if (copied()) {
|
||||||
<i-bs width="1em" height="1em" name="clipboard-check" class="me-1"></i-bs><ng-container i18n>Copied!</ng-container>
|
<i-bs width="1em" height="1em" name="clipboard-check" class="me-1"></i-bs><ng-container i18n>Copied!</ng-container>
|
||||||
} @else {
|
} @else {
|
||||||
ID: {{id()}}
|
ID: {{id()}}
|
||||||
}
|
}
|
||||||
</span>
|
</button>
|
||||||
}
|
}
|
||||||
@if (subTitle()) {
|
@if (subTitle()) {
|
||||||
<span class="page-subtitle h6 mb-0 mt-1 fw-normal ms-md-3 text-truncate" style="line-height: 1.4">{{subTitle()}}</span>
|
<span class="page-subtitle h6 mb-0 mt-1 fw-normal ms-md-3 text-truncate" style="line-height: 1.4">{{subTitle()}}</span>
|
||||||
|
|||||||
@@ -43,6 +43,13 @@ describe('PageHeaderComponent', () => {
|
|||||||
expect(titleSpy).toHaveBeenCalledWith(`Foo Bar - ${environment.appTitle}`)
|
expect(titleSpy).toHaveBeenCalledWith(`Foo Bar - ${environment.appTitle}`)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should set html title to just the app title until a title is available', () => {
|
||||||
|
const titleSpy = jest.spyOn(titleService, 'setTitle')
|
||||||
|
fixture.componentRef.setInput('title', undefined)
|
||||||
|
fixture.detectChanges()
|
||||||
|
expect(titleSpy).toHaveBeenCalledWith(environment.appTitle)
|
||||||
|
})
|
||||||
|
|
||||||
it('should copy id to clipboard, reset after 3 seconds', () => {
|
it('should copy id to clipboard, reset after 3 seconds', () => {
|
||||||
jest.useFakeTimers()
|
jest.useFakeTimers()
|
||||||
fixture.componentRef.setInput('id', 42)
|
fixture.componentRef.setInput('id', 42)
|
||||||
|
|||||||
@@ -27,7 +27,10 @@ export class PageHeaderComponent {
|
|||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
effect(() => {
|
effect(() => {
|
||||||
this.titleService.setTitle(`${this.title()} - ${environment.appTitle}`)
|
const title = this.title()
|
||||||
|
this.titleService.setTitle(
|
||||||
|
title ? `${title} - ${environment.appTitle}` : environment.appTitle
|
||||||
|
)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+3
-3
@@ -100,7 +100,7 @@ describe('ProfileEditDialogComponent', () => {
|
|||||||
expect(errorSpy).toHaveBeenCalled()
|
expect(errorSpy).toHaveBeenCalled()
|
||||||
|
|
||||||
updateSpy.mockClear()
|
updateSpy.mockClear()
|
||||||
const infoSpy = jest.spyOn(toastService, 'showInfo')
|
const infoSpy = jest.spyOn(toastService, 'showSuccess')
|
||||||
component.form.patchValue(newProfile)
|
component.form.patchValue(newProfile)
|
||||||
updateSpy.mockReturnValueOnce(of(newProfile))
|
updateSpy.mockReturnValueOnce(of(newProfile))
|
||||||
component.save()
|
component.save()
|
||||||
@@ -373,7 +373,7 @@ describe('ProfileEditDialogComponent', () => {
|
|||||||
it('should activate totp', () => {
|
it('should activate totp', () => {
|
||||||
const activateSpy = jest.spyOn(profileService, 'activateTotp')
|
const activateSpy = jest.spyOn(profileService, 'activateTotp')
|
||||||
const toastErrorSpy = jest.spyOn(toastService, 'showError')
|
const toastErrorSpy = jest.spyOn(toastService, 'showError')
|
||||||
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
|
const toastInfoSpy = jest.spyOn(toastService, 'showSuccess')
|
||||||
const error = new Error('failed to activate totp')
|
const error = new Error('failed to activate totp')
|
||||||
activateSpy.mockReturnValueOnce(throwError(() => error))
|
activateSpy.mockReturnValueOnce(throwError(() => error))
|
||||||
component.totpSettings.set({
|
component.totpSettings.set({
|
||||||
@@ -405,7 +405,7 @@ describe('ProfileEditDialogComponent', () => {
|
|||||||
it('should deactivate totp', () => {
|
it('should deactivate totp', () => {
|
||||||
const deactivateSpy = jest.spyOn(profileService, 'deactivateTotp')
|
const deactivateSpy = jest.spyOn(profileService, 'deactivateTotp')
|
||||||
const toastErrorSpy = jest.spyOn(toastService, 'showError')
|
const toastErrorSpy = jest.spyOn(toastService, 'showError')
|
||||||
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
|
const toastInfoSpy = jest.spyOn(toastService, 'showSuccess')
|
||||||
const error = new Error('failed to deactivate totp')
|
const error = new Error('failed to deactivate totp')
|
||||||
deactivateSpy.mockReturnValueOnce(throwError(() => error))
|
deactivateSpy.mockReturnValueOnce(throwError(() => error))
|
||||||
component.deactivateTotp()
|
component.deactivateTotp()
|
||||||
|
|||||||
+7
-3
@@ -204,7 +204,7 @@ export class ProfileEditDialogComponent
|
|||||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||||
.subscribe({
|
.subscribe({
|
||||||
next: () => {
|
next: () => {
|
||||||
this.toastService.showInfo($localize`Profile updated successfully`)
|
this.toastService.showSuccess($localize`Profile updated successfully`)
|
||||||
if (passwordChanged) {
|
if (passwordChanged) {
|
||||||
this.toastService.showInfo(
|
this.toastService.showInfo(
|
||||||
$localize`Password has been changed, you will be logged out momentarily.`
|
$localize`Password has been changed, you will be logged out momentarily.`
|
||||||
@@ -307,7 +307,9 @@ export class ProfileEditDialogComponent
|
|||||||
this.recoveryCodes.set(activationResponse.recovery_codes)
|
this.recoveryCodes.set(activationResponse.recovery_codes)
|
||||||
this.form.get('totp_code').enable()
|
this.form.get('totp_code').enable()
|
||||||
if (activationResponse.success) {
|
if (activationResponse.success) {
|
||||||
this.toastService.showInfo($localize`TOTP activated successfully`)
|
this.toastService.showSuccess(
|
||||||
|
$localize`TOTP activated successfully`
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
this.toastService.showError($localize`Error activating TOTP`)
|
this.toastService.showError($localize`Error activating TOTP`)
|
||||||
}
|
}
|
||||||
@@ -331,7 +333,9 @@ export class ProfileEditDialogComponent
|
|||||||
this.isTotpEnabled.set(!success)
|
this.isTotpEnabled.set(!success)
|
||||||
this.recoveryCodes.set(null)
|
this.recoveryCodes.set(null)
|
||||||
if (success) {
|
if (success) {
|
||||||
this.toastService.showInfo($localize`TOTP deactivated successfully`)
|
this.toastService.showSuccess(
|
||||||
|
$localize`TOTP deactivated successfully`
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
this.toastService.showError($localize`Error deactivating TOTP`)
|
this.toastService.showError($localize`Error deactivating TOTP`)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,22 +1,33 @@
|
|||||||
<ngb-toast
|
<ngb-toast
|
||||||
[autohide]="autohide"
|
[autohide]="false"
|
||||||
[delay]="toast.delay"
|
[class.mb-2]="autohide"
|
||||||
[class]="toast.classname"
|
[class.toast-list-item]="!autohide"
|
||||||
[class.mb-2]="true"
|
[class.toast-new]="isNew"
|
||||||
|
[class.toast-success]="toast.level === ToastLevel.Success"
|
||||||
|
[class.toast-error]="toast.level === ToastLevel.Error"
|
||||||
|
(mouseenter)="setHovered(true)"
|
||||||
|
(mouseleave)="setHovered(false)"
|
||||||
|
(focusin)="setFocused(true)"
|
||||||
|
(focusout)="setFocused(false)"
|
||||||
(shown)="onShown(toast)"
|
(shown)="onShown(toast)"
|
||||||
(hidden)="hidden.emit(toast)">
|
(hidden)="hidden.emit(toast)">
|
||||||
@if (autohide) {
|
@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>
|
<ngb-progressbar class="position-absolute w-100 start-0 bottom-0 pe-none" [max]="toast.delay" [value]="delayRemaining()"></ngb-progressbar>
|
||||||
<span class="visually-hidden">{{ toast.delayRemaining / 1000 | number: '1.0-0' }} seconds</span>
|
<span class="visually-hidden">{{ delayRemaining() / 1000 | number: '1.0-0' }} seconds</span>
|
||||||
}
|
}
|
||||||
<div class="d-flex align-items-top">
|
<div class="d-flex align-items-top">
|
||||||
@if (!toast.error) {
|
@switch (toast.level) {
|
||||||
<i-bs width="0.9em" height="0.9em" name="info-circle"></i-bs>
|
@case (ToastLevel.Success) {
|
||||||
|
<i-bs width="0.9em" height="0.9em" name="check-circle" class="toast-icon"></i-bs>
|
||||||
|
}
|
||||||
|
@case (ToastLevel.Error) {
|
||||||
|
<i-bs width="0.9em" height="0.9em" name="exclamation-triangle" class="toast-icon"></i-bs>
|
||||||
|
}
|
||||||
|
@default {
|
||||||
|
<i-bs width="0.9em" height="0.9em" name="info-circle" class="toast-icon"></i-bs>
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@if (toast.error) {
|
<div class="toast-content">
|
||||||
<i-bs width="0.9em" height="0.9em" name="exclamation-triangle"></i-bs>
|
|
||||||
}
|
|
||||||
<div>
|
|
||||||
<p class="ms-2 mb-0 text-break">{{toast.content}}</p>
|
<p class="ms-2 mb-0 text-break">{{toast.content}}</p>
|
||||||
@if (toast.error) {
|
@if (toast.error) {
|
||||||
<details class="ms-2">
|
<details class="ms-2">
|
||||||
@@ -48,9 +59,12 @@
|
|||||||
</details>
|
</details>
|
||||||
}
|
}
|
||||||
@if (toast.action) {
|
@if (toast.action) {
|
||||||
<p class="mb-0 mt-2"><button class="btn btn-sm btn-outline-secondary" (click)="closed.emit(toast); toast.action()">{{toast.actionName}}</button></p>
|
<button class="btn btn-sm toast-action ms-2 mt-2" (click)="actioned.emit(toast); toast.action()">{{toast.actionName}}</button>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
@if (!autohide && toast.timestamp) {
|
||||||
|
<span class="toast-time small text-body-secondary text-nowrap ms-auto ps-2">{{relativeTimestamp}}</span>
|
||||||
|
}
|
||||||
<button type="button" class="btn-close ms-auto flex-shrink-0" data-bs-dismiss="toast" aria-label="Close" (click)="closed.emit(toast);"></button>
|
<button type="button" class="btn-close ms-auto flex-shrink-0" data-bs-dismiss="toast" aria-label="Close" (click)="closed.emit(toast);"></button>
|
||||||
</div>
|
</div>
|
||||||
</ngb-toast>
|
</ngb-toast>
|
||||||
@@ -2,19 +2,105 @@
|
|||||||
position: relative;
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
::ng-deep .toast.error {
|
.toast {
|
||||||
border-color: hsla(350, 79%, 40%, 0.4); // bg-danger
|
--pngx-toast-accent: var(--pngx-toast-info);
|
||||||
|
background-color: var(--pngx-toast-bg);
|
||||||
|
color: var(--bs-body-color);
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
&.toast-success {
|
||||||
|
--pngx-toast-accent: var(--pngx-toast-success);
|
||||||
|
background-color: var(--pngx-toast-success-bg);
|
||||||
|
border-color: color-mix(in srgb, var(--pngx-toast-success) 30%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.toast-error {
|
||||||
|
--pngx-toast-accent: var(--pngx-toast-error);
|
||||||
|
background-color: var(--pngx-toast-error-bg);
|
||||||
|
border-color: color-mix(in srgb, var(--pngx-toast-error) 35%, transparent);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
::ng-deep .toast.error .toast-body {
|
.toast-icon {
|
||||||
background-color: hsla(350, 79%, 40%, 0.8); // bg-danger
|
color: var(--pngx-toast-accent);
|
||||||
border-top-left-radius: inherit;
|
|
||||||
border-top-right-radius: inherit;
|
|
||||||
border-bottom-left-radius: inherit;
|
|
||||||
border-bottom-right-radius: inherit;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.toast-content {
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast .btn-close {
|
||||||
|
font-size: .6rem;
|
||||||
|
margin-top: .3rem;
|
||||||
|
margin-left: .75rem !important;
|
||||||
|
opacity: .45;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast:hover .btn-close {
|
||||||
|
opacity: .85;
|
||||||
|
}
|
||||||
|
|
||||||
|
// compact rows in the notifications dropdown
|
||||||
|
.toast.toast-list-item {
|
||||||
|
width: 100%;
|
||||||
|
max-width: none;
|
||||||
|
background-color: var(--pngx-toast-row-bg);
|
||||||
|
border: 0;
|
||||||
|
box-shadow: none;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: var(--pngx-toast-row-hover-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.toast-error {
|
||||||
|
background-color: var(--pngx-toast-error-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
::ng-deep .toast-body {
|
||||||
|
padding: .625rem .75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-time {
|
||||||
|
margin-top: .1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
// marks notifications that arrived since the dropdown was last opened
|
||||||
|
&.toast-new .toast-time::before {
|
||||||
|
content: '';
|
||||||
|
display: inline-block;
|
||||||
|
width: .45rem;
|
||||||
|
height: .45rem;
|
||||||
|
margin-right: .4rem;
|
||||||
|
vertical-align: .05rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: var(--pngx-toast-info);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.toast-action {
|
||||||
|
--bs-btn-padding-y: .1rem;
|
||||||
|
--bs-btn-padding-x: .6rem;
|
||||||
|
--bs-btn-font-size: .8rem;
|
||||||
|
--bs-btn-font-weight: 600;
|
||||||
|
--bs-btn-color: var(--pngx-toast-accent);
|
||||||
|
--bs-btn-bg: color-mix(in srgb, var(--pngx-toast-accent) 14%, transparent);
|
||||||
|
--bs-btn-border-color: transparent;
|
||||||
|
--bs-btn-hover-color: var(--pngx-toast-accent);
|
||||||
|
--bs-btn-hover-bg: color-mix(in srgb, var(--pngx-toast-accent) 24%, transparent);
|
||||||
|
--bs-btn-hover-border-color: transparent;
|
||||||
|
--bs-btn-active-color: var(--pngx-toast-accent);
|
||||||
|
--bs-btn-active-bg: color-mix(in srgb, var(--pngx-toast-accent) 30%, transparent);
|
||||||
|
--bs-btn-active-border-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
// countdown thin line
|
||||||
.progress {
|
.progress {
|
||||||
background-color: var(--pngx-primary);
|
height: 2px;
|
||||||
opacity: .07;
|
background-color: transparent;
|
||||||
|
|
||||||
|
::ng-deep .progress-bar {
|
||||||
|
background-color: var(--bs-secondary-color);
|
||||||
|
opacity: .5;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,11 @@
|
|||||||
|
import { DatePipe } from '@angular/common'
|
||||||
|
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
|
||||||
|
import { provideHttpClientTesting } from '@angular/common/http/testing'
|
||||||
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||||
|
|
||||||
import { Clipboard } from '@angular/cdk/clipboard'
|
import { Clipboard } from '@angular/cdk/clipboard'
|
||||||
import { allIcons, NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
import { allIcons, NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||||
|
import { ToastLevel } from 'src/app/services/toast.service'
|
||||||
import { ToastComponent } from './toast.component'
|
import { ToastComponent } from './toast.component'
|
||||||
|
|
||||||
const toast1 = {
|
const toast1 = {
|
||||||
@@ -30,6 +34,11 @@ describe('ToastComponent', () => {
|
|||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
await TestBed.configureTestingModule({
|
await TestBed.configureTestingModule({
|
||||||
imports: [ToastComponent, NgxBootstrapIconsModule.pick(allIcons)],
|
imports: [ToastComponent, NgxBootstrapIconsModule.pick(allIcons)],
|
||||||
|
providers: [
|
||||||
|
DatePipe,
|
||||||
|
provideHttpClient(withInterceptorsFromDi()),
|
||||||
|
provideHttpClientTesting(),
|
||||||
|
],
|
||||||
}).compileComponents()
|
}).compileComponents()
|
||||||
|
|
||||||
fixture = TestBed.createComponent(ToastComponent)
|
fixture = TestBed.createComponent(ToastComponent)
|
||||||
@@ -46,11 +55,117 @@ describe('ToastComponent', () => {
|
|||||||
component.toast = toast2
|
component.toast = toast2
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
component.onShown(toast2)
|
component.onShown(toast2)
|
||||||
jest.advanceTimersByTime(5000)
|
jest.advanceTimersByTime(5000 + 150) // ticks every 150ms
|
||||||
expect(component.toast.delayRemaining).toEqual(0)
|
expect(component.delayRemaining()).toEqual(0)
|
||||||
jest.useRealTimers()
|
jest.useRealTimers()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should start the progress bar full before the countdown begins', () => {
|
||||||
|
component.toast = { content: 'Info', delay: 3000 }
|
||||||
|
fixture.detectChanges()
|
||||||
|
expect(component.delayRemaining()).toEqual(3000)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should hide the toast when the countdown ends', () => {
|
||||||
|
jest.useFakeTimers()
|
||||||
|
const toast = { content: 'Info', delay: 3000 }
|
||||||
|
component.toast = toast
|
||||||
|
fixture.detectChanges()
|
||||||
|
const hideSpy = jest.spyOn(component.ngbToast, 'hide')
|
||||||
|
component.onShown(toast)
|
||||||
|
jest.advanceTimersByTime(2850)
|
||||||
|
expect(hideSpy).not.toHaveBeenCalled()
|
||||||
|
jest.advanceTimersByTime(300)
|
||||||
|
expect(hideSpy).toHaveBeenCalled()
|
||||||
|
jest.useRealTimers()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should pause the countdown while hovered', () => {
|
||||||
|
jest.useFakeTimers()
|
||||||
|
const toast = { content: 'Info', delay: 3000 }
|
||||||
|
component.toast = toast
|
||||||
|
fixture.detectChanges()
|
||||||
|
const hideSpy = jest.spyOn(component.ngbToast, 'hide')
|
||||||
|
const el: HTMLElement = fixture.nativeElement.querySelector('ngb-toast')
|
||||||
|
component.onShown(toast)
|
||||||
|
jest.advanceTimersByTime(1500)
|
||||||
|
el.dispatchEvent(new MouseEvent('mouseenter'))
|
||||||
|
const remaining = component.delayRemaining()
|
||||||
|
jest.advanceTimersByTime(10000)
|
||||||
|
expect(component.delayRemaining()).toEqual(remaining)
|
||||||
|
expect(hideSpy).not.toHaveBeenCalled()
|
||||||
|
|
||||||
|
el.dispatchEvent(new MouseEvent('mouseleave'))
|
||||||
|
jest.advanceTimersByTime(remaining + 150)
|
||||||
|
expect(hideSpy).toHaveBeenCalled()
|
||||||
|
jest.useRealTimers()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should pause the countdown while focus is inside', () => {
|
||||||
|
jest.useFakeTimers()
|
||||||
|
const toast = { content: 'Info', delay: 3000 }
|
||||||
|
component.toast = toast
|
||||||
|
fixture.detectChanges()
|
||||||
|
const el: HTMLElement = fixture.nativeElement.querySelector('ngb-toast')
|
||||||
|
component.onShown(toast)
|
||||||
|
el.dispatchEvent(new FocusEvent('focusin'))
|
||||||
|
// mouse leaving doesn't resume while focus is still inside
|
||||||
|
el.dispatchEvent(new MouseEvent('mouseenter'))
|
||||||
|
el.dispatchEvent(new MouseEvent('mouseleave'))
|
||||||
|
jest.advanceTimersByTime(5000)
|
||||||
|
expect(component.delayRemaining()).toEqual(3000)
|
||||||
|
el.dispatchEvent(new FocusEvent('focusout'))
|
||||||
|
jest.advanceTimersByTime(3000)
|
||||||
|
expect(component.delayRemaining()).toEqual(0)
|
||||||
|
jest.useRealTimers()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not count down without autohide', () => {
|
||||||
|
jest.useFakeTimers()
|
||||||
|
const toast = { content: 'Info', delay: 3000 }
|
||||||
|
component.toast = toast
|
||||||
|
component.autohide = false
|
||||||
|
fixture.detectChanges()
|
||||||
|
component.onShown(toast)
|
||||||
|
jest.advanceTimersByTime(5000)
|
||||||
|
expect(component.delayRemaining()).toBeNull()
|
||||||
|
jest.useRealTimers()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should show an icon and colors for each level', () => {
|
||||||
|
component.toast = {
|
||||||
|
content: 'Saved',
|
||||||
|
delay: 5000,
|
||||||
|
level: ToastLevel.Success,
|
||||||
|
}
|
||||||
|
fixture.detectChanges()
|
||||||
|
const el: HTMLElement = fixture.nativeElement
|
||||||
|
expect(el.querySelector('i-bs[name="check-circle"]')).not.toBeNull()
|
||||||
|
expect(el.querySelector('ngb-toast').classList).not.toContain('toast-error')
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(ToastComponent)
|
||||||
|
fixture.componentInstance.toast = {
|
||||||
|
content: 'Failed',
|
||||||
|
delay: 5000,
|
||||||
|
level: ToastLevel.Error,
|
||||||
|
}
|
||||||
|
fixture.detectChanges()
|
||||||
|
const errorEl: HTMLElement = fixture.nativeElement
|
||||||
|
expect(
|
||||||
|
errorEl.querySelector('i-bs[name="exclamation-triangle"]')
|
||||||
|
).not.toBeNull()
|
||||||
|
expect(errorEl.querySelector('ngb-toast').classList).toContain(
|
||||||
|
'toast-error'
|
||||||
|
)
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(ToastComponent)
|
||||||
|
fixture.componentInstance.toast = { content: 'Started', delay: 5000 }
|
||||||
|
fixture.detectChanges()
|
||||||
|
expect(
|
||||||
|
fixture.nativeElement.querySelector('i-bs[name="info-circle"]')
|
||||||
|
).not.toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
it('should show an error if given with toast', () => {
|
it('should show an error if given with toast', () => {
|
||||||
component.toast = toast1
|
component.toast = toast1
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
@@ -88,4 +203,70 @@ describe('ToastComponent', () => {
|
|||||||
component.getErrorText({ error: new Array(205).join('a') })
|
component.getErrorText({ error: new Array(205).join('a') })
|
||||||
).toContain('...')
|
).toContain('...')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should emit actioned, not closed, when the action is clicked', () => {
|
||||||
|
const action = jest.fn()
|
||||||
|
component.toast = {
|
||||||
|
content: 'Moved',
|
||||||
|
delay: 5000,
|
||||||
|
action,
|
||||||
|
actionName: 'Undo',
|
||||||
|
}
|
||||||
|
fixture.detectChanges()
|
||||||
|
const closedSpy = jest.spyOn(component.closed, 'emit')
|
||||||
|
const actionedSpy = jest.spyOn(component.actioned, 'emit')
|
||||||
|
const button: HTMLButtonElement = [
|
||||||
|
...fixture.nativeElement.querySelectorAll('button'),
|
||||||
|
].find((b) => b.textContent.includes('Undo'))
|
||||||
|
button.click()
|
||||||
|
expect(actionedSpy).toHaveBeenCalled()
|
||||||
|
expect(closedSpy).not.toHaveBeenCalled()
|
||||||
|
expect(action).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should show a relative timestamp in the notifications list', () => {
|
||||||
|
component.toast = { content: 'Saved', delay: 5000, timestamp: new Date() }
|
||||||
|
component.autohide = false
|
||||||
|
fixture.detectChanges()
|
||||||
|
expect(fixture.nativeElement.textContent).toContain('Just now')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should update the relative timestamp as time passes', () => {
|
||||||
|
jest.useFakeTimers()
|
||||||
|
jest.setSystemTime(new Date('2026-10-19T12:00:00Z'))
|
||||||
|
component.toast = { content: 'Saved', delay: 5000, timestamp: new Date() }
|
||||||
|
component.autohide = false
|
||||||
|
fixture.componentRef.setInput('now', new Date())
|
||||||
|
fixture.detectChanges()
|
||||||
|
expect(fixture.nativeElement.textContent).toContain('Just now')
|
||||||
|
|
||||||
|
jest.setSystemTime(new Date('2026-10-19T12:03:00Z'))
|
||||||
|
fixture.componentRef.setInput('now', new Date())
|
||||||
|
fixture.detectChanges()
|
||||||
|
expect(fixture.nativeElement.textContent).toContain('3 minutes ago')
|
||||||
|
jest.useRealTimers()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should show a compact action link in the notifications list', () => {
|
||||||
|
const action = jest.fn()
|
||||||
|
component.toast = {
|
||||||
|
content: 'Moved',
|
||||||
|
delay: 5000,
|
||||||
|
action,
|
||||||
|
actionName: 'Undo',
|
||||||
|
timestamp: new Date(),
|
||||||
|
}
|
||||||
|
component.autohide = false
|
||||||
|
fixture.detectChanges()
|
||||||
|
const link: HTMLButtonElement =
|
||||||
|
fixture.nativeElement.querySelector('.toast-action')
|
||||||
|
expect(link.textContent).toContain('Undo')
|
||||||
|
expect(
|
||||||
|
fixture.nativeElement.querySelector('.btn-outline-secondary')
|
||||||
|
).toBeNull()
|
||||||
|
const actionedSpy = jest.spyOn(component.actioned, 'emit')
|
||||||
|
link.click()
|
||||||
|
expect(actionedSpy).toHaveBeenCalled()
|
||||||
|
expect(action).toHaveBeenCalled()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
@@ -1,20 +1,26 @@
|
|||||||
import { Clipboard } from '@angular/cdk/clipboard'
|
import { Clipboard } from '@angular/cdk/clipboard'
|
||||||
import { DecimalPipe } from '@angular/common'
|
import { DatePipe, DecimalPipe } from '@angular/common'
|
||||||
import {
|
import {
|
||||||
Component,
|
Component,
|
||||||
|
DestroyRef,
|
||||||
EventEmitter,
|
EventEmitter,
|
||||||
Input,
|
Input,
|
||||||
|
OnInit,
|
||||||
Output,
|
Output,
|
||||||
|
ViewChild,
|
||||||
inject,
|
inject,
|
||||||
signal,
|
signal,
|
||||||
} from '@angular/core'
|
} from '@angular/core'
|
||||||
|
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'
|
||||||
import {
|
import {
|
||||||
NgbProgressbarModule,
|
NgbProgressbarModule,
|
||||||
|
NgbToast,
|
||||||
NgbToastModule,
|
NgbToastModule,
|
||||||
} from '@ng-bootstrap/ng-bootstrap'
|
} from '@ng-bootstrap/ng-bootstrap'
|
||||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||||
import { interval, take } from 'rxjs'
|
import { Subscription, interval, takeWhile } from 'rxjs'
|
||||||
import { Toast } from 'src/app/services/toast.service'
|
import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
|
||||||
|
import { Toast, ToastLevel } from 'src/app/services/toast.service'
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'pngx-toast',
|
selector: 'pngx-toast',
|
||||||
@@ -26,32 +32,79 @@ import { Toast } from 'src/app/services/toast.service'
|
|||||||
],
|
],
|
||||||
templateUrl: './toast.component.html',
|
templateUrl: './toast.component.html',
|
||||||
styleUrl: './toast.component.scss',
|
styleUrl: './toast.component.scss',
|
||||||
|
providers: [DatePipe, CustomDatePipe],
|
||||||
})
|
})
|
||||||
export class ToastComponent {
|
export class ToastComponent implements OnInit {
|
||||||
private clipboard = inject(Clipboard)
|
private clipboard = inject(Clipboard)
|
||||||
|
private customDatePipe = inject(CustomDatePipe)
|
||||||
|
private destroyRef = inject(DestroyRef)
|
||||||
readonly copied = signal(false)
|
readonly copied = signal(false)
|
||||||
|
readonly ToastLevel = ToastLevel
|
||||||
|
readonly delayRemaining = signal<number>(null)
|
||||||
|
|
||||||
|
@ViewChild(NgbToast) ngbToast: NgbToast
|
||||||
|
|
||||||
|
private hovered = false
|
||||||
|
private focused = false
|
||||||
|
private countdown: Subscription
|
||||||
|
|
||||||
@Input() toast: Toast
|
@Input() toast: Toast
|
||||||
|
|
||||||
@Input() autohide: boolean = true
|
@Input() autohide: boolean = true
|
||||||
|
|
||||||
|
/** Each new value re-renders the relative timestamp */
|
||||||
|
@Input() now: Date
|
||||||
|
|
||||||
|
/** Marks a notification that arrived since the dropdown was last opened */
|
||||||
|
@Input() isNew: boolean = false
|
||||||
|
|
||||||
@Output() hidden: EventEmitter<Toast> = new EventEmitter<Toast>()
|
@Output() hidden: EventEmitter<Toast> = new EventEmitter<Toast>()
|
||||||
|
|
||||||
@Output() closed: EventEmitter<Toast> = new EventEmitter<Toast>()
|
@Output() closed: EventEmitter<Toast> = new EventEmitter<Toast>()
|
||||||
|
|
||||||
|
/** The toast's action button was clicked */
|
||||||
|
@Output() actioned: EventEmitter<Toast> = new EventEmitter<Toast>()
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
if (this.autohide) this.delayRemaining.set(this.toast.delay)
|
||||||
|
}
|
||||||
|
|
||||||
|
get relativeTimestamp(): string {
|
||||||
|
return this.customDatePipe.transform(this.toast.timestamp, 'relative')
|
||||||
|
}
|
||||||
|
|
||||||
|
get paused(): boolean {
|
||||||
|
return this.hovered || this.focused
|
||||||
|
}
|
||||||
|
|
||||||
|
setHovered(hovered: boolean) {
|
||||||
|
this.hovered = hovered
|
||||||
|
}
|
||||||
|
|
||||||
|
setFocused(focused: boolean) {
|
||||||
|
this.focused = focused
|
||||||
|
}
|
||||||
|
|
||||||
onShown(toast: Toast) {
|
onShown(toast: Toast) {
|
||||||
if (!this.autohide) return
|
if (!this.autohide) return
|
||||||
|
|
||||||
const refreshInterval = 150
|
const refreshInterval = 150
|
||||||
const delay = toast.delay - 500 // for fade animation
|
this.delayRemaining.set(toast.delay)
|
||||||
|
|
||||||
interval(refreshInterval)
|
this.countdown?.unsubscribe()
|
||||||
.pipe(take(Math.round(delay / refreshInterval)))
|
this.countdown = interval(refreshInterval)
|
||||||
.subscribe((count) => {
|
.pipe(
|
||||||
toast.delayRemaining = Math.max(
|
takeWhile(() => this.delayRemaining() > 0),
|
||||||
0,
|
takeUntilDestroyed(this.destroyRef)
|
||||||
delay - refreshInterval * (count + 1)
|
)
|
||||||
|
.subscribe(() => {
|
||||||
|
if (this.paused) return
|
||||||
|
this.delayRemaining.update((remaining) =>
|
||||||
|
Math.max(0, remaining - refreshInterval)
|
||||||
)
|
)
|
||||||
|
if (this.delayRemaining() === 0) {
|
||||||
|
this.ngbToast?.hide()
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,3 @@
|
|||||||
@for (toast of toasts(); track toast.id) {
|
@for (toast of toasts(); track toast.id) {
|
||||||
<pngx-toast [toast]="toast" [autohide]="true" (closed)="closeToast()"></pngx-toast>
|
<pngx-toast [toast]="toast" [autohide]="true" (closed)="removePopup(toast)" (actioned)="closeToast(toast)" (hidden)="removePopup(toast)"></pngx-toast>
|
||||||
}
|
}
|
||||||
@@ -1,10 +1,11 @@
|
|||||||
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 { ComponentFixture, TestBed } from '@angular/core/testing'
|
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||||
|
import { By } from '@angular/platform-browser'
|
||||||
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
||||||
import { Subject } from 'rxjs'
|
import { Subject } from 'rxjs'
|
||||||
import { Toast, ToastService } from 'src/app/services/toast.service'
|
import { Toast, ToastService } from 'src/app/services/toast.service'
|
||||||
import { ToastsComponent } from './toasts.component'
|
import { MAX_VISIBLE_TOASTS, ToastsComponent } from './toasts.component'
|
||||||
|
|
||||||
const toast = {
|
const toast = {
|
||||||
content: 'Error 2 content',
|
content: 'Error 2 content',
|
||||||
@@ -50,7 +51,7 @@ describe('ToastsComponent', () => {
|
|||||||
it('should close toast', () => {
|
it('should close toast', () => {
|
||||||
toastSubject.next(toast)
|
toastSubject.next(toast)
|
||||||
const closeToastSpy = jest.spyOn(toastService, 'closeToast')
|
const closeToastSpy = jest.spyOn(toastService, 'closeToast')
|
||||||
component.closeToast()
|
component.closeToast(toast)
|
||||||
expect(component.toasts()).toEqual([])
|
expect(component.toasts()).toEqual([])
|
||||||
expect(closeToastSpy).toHaveBeenCalledWith(toast)
|
expect(closeToastSpy).toHaveBeenCalledWith(toast)
|
||||||
})
|
})
|
||||||
@@ -59,4 +60,59 @@ describe('ToastsComponent', () => {
|
|||||||
toastSubject.next(toast)
|
toastSubject.next(toast)
|
||||||
expect(component.toasts()).toEqual([toast])
|
expect(component.toasts()).toEqual([toast])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should stack popups newest first, up to the maximum', () => {
|
||||||
|
const toasts: Toast[] = Array.from(
|
||||||
|
{ length: MAX_VISIBLE_TOASTS + 1 },
|
||||||
|
(_, i) => ({ id: `${i}`, content: `Toast ${i}`, delay: 5000 })
|
||||||
|
)
|
||||||
|
toasts.forEach((t) => toastSubject.next(t))
|
||||||
|
expect(component.toasts().map((t) => t.id)).toEqual(['3', '2', '1'])
|
||||||
|
|
||||||
|
// showing a visible toast again moves it to the top without duplicating
|
||||||
|
toastSubject.next(toasts[1])
|
||||||
|
expect(component.toasts().map((t) => t.id)).toEqual(['1', '3', '2'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should remove a popup when it hides without closing it', () => {
|
||||||
|
const closeToastSpy = jest.spyOn(toastService, 'closeToast')
|
||||||
|
const first = { id: 'a', content: 'A', delay: 5000 }
|
||||||
|
const second = { id: 'b', content: 'B', delay: 5000 }
|
||||||
|
toastSubject.next(first)
|
||||||
|
toastSubject.next(second)
|
||||||
|
component.removePopup(first)
|
||||||
|
expect(component.toasts()).toEqual([second])
|
||||||
|
expect(closeToastSpy).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should clear popups when toastService emits null', () => {
|
||||||
|
toastSubject.next(toast)
|
||||||
|
toastSubject.next(null)
|
||||||
|
expect(component.toasts()).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should render one toast component per popup', () => {
|
||||||
|
toastSubject.next({ id: 'a', content: 'A', delay: 5000 })
|
||||||
|
toastSubject.next({ id: 'b', content: 'B', delay: 5000 })
|
||||||
|
fixture.detectChanges()
|
||||||
|
expect(fixture.nativeElement.querySelectorAll('pngx-toast')).toHaveLength(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should only hide the popup on close, but clear it on action', () => {
|
||||||
|
const closeToastSpy = jest.spyOn(toastService, 'closeToast')
|
||||||
|
const first = { id: 'a', content: 'A', delay: 5000 }
|
||||||
|
const second = { id: 'b', content: 'B', delay: 5000 }
|
||||||
|
toastSubject.next(first)
|
||||||
|
toastSubject.next(second)
|
||||||
|
fixture.detectChanges()
|
||||||
|
const popups = fixture.debugElement.queryAll(By.css('pngx-toast'))
|
||||||
|
|
||||||
|
popups[1].triggerEventHandler('closed', first)
|
||||||
|
expect(closeToastSpy).not.toHaveBeenCalled()
|
||||||
|
expect(component.toasts()).toEqual([second])
|
||||||
|
|
||||||
|
popups[0].triggerEventHandler('actioned', second)
|
||||||
|
expect(closeToastSpy).toHaveBeenCalledWith(second)
|
||||||
|
expect(component.toasts()).toEqual([])
|
||||||
|
})
|
||||||
})
|
})
|
||||||
@@ -1,14 +1,18 @@
|
|||||||
import { Component, inject } from '@angular/core'
|
import { Component, inject, signal } from '@angular/core'
|
||||||
import { toSignal } from '@angular/core/rxjs-interop'
|
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'
|
||||||
import {
|
import {
|
||||||
NgbAccordionModule,
|
NgbAccordionModule,
|
||||||
NgbProgressbarModule,
|
NgbProgressbarModule,
|
||||||
} from '@ng-bootstrap/ng-bootstrap'
|
} from '@ng-bootstrap/ng-bootstrap'
|
||||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||||
import { map, merge, Subject } 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'
|
import { ToastComponent } from '../toast/toast.component'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maximum number of popup toasts shown at once
|
||||||
|
*/
|
||||||
|
export const MAX_VISIBLE_TOASTS = 3
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'pngx-toasts',
|
selector: 'pngx-toasts',
|
||||||
templateUrl: './toasts.component.html',
|
templateUrl: './toasts.component.html',
|
||||||
@@ -23,18 +27,31 @@ import { ToastComponent } from '../toast/toast.component'
|
|||||||
export class ToastsComponent {
|
export class ToastsComponent {
|
||||||
toastService = inject(ToastService)
|
toastService = inject(ToastService)
|
||||||
|
|
||||||
private readonly closedToast = new Subject<void>()
|
readonly toasts = signal<Toast[]>([])
|
||||||
|
|
||||||
readonly toasts = toSignal(
|
constructor() {
|
||||||
merge(
|
this.toastService.showToast
|
||||||
this.toastService.showToast.pipe(map((toast) => (toast ? [toast] : []))),
|
.pipe(takeUntilDestroyed())
|
||||||
this.closedToast.pipe(map(() => [] as Toast[]))
|
.subscribe((toast) => {
|
||||||
),
|
if (!toast) {
|
||||||
{ initialValue: [] as Toast[] }
|
this.toasts.set([])
|
||||||
)
|
return
|
||||||
|
}
|
||||||
|
this.toasts.update((toasts) =>
|
||||||
|
[toast, ...toasts.filter((t) => t.id !== toast.id)].slice(
|
||||||
|
0,
|
||||||
|
MAX_VISIBLE_TOASTS
|
||||||
|
)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
closeToast() {
|
closeToast(toast: Toast) {
|
||||||
this.toastService.closeToast(this.toasts()[0])
|
this.toastService.closeToast(toast)
|
||||||
this.closedToast.next()
|
this.removePopup(toast)
|
||||||
|
}
|
||||||
|
|
||||||
|
removePopup(toast: Toast) {
|
||||||
|
this.toasts.update((toasts) => toasts.filter((t) => t.id !== toast.id))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
@if (canAddDocuments && getStatus().length > 0) {
|
||||||
|
<div class="fixed-bottom p-2 p-md-4 d-flex justify-content-end pe-none consumer-status-list" [ngClass]="slimSidebarEnabled ? 'col-slim' : 'offset-sidebar-expanded'">
|
||||||
|
<div class="col col-lg-4 col-xl-3 ps-0 pe-0 ps-lg-3 pe-lg-0 pe-auto overflow-y-auto">
|
||||||
|
<div class="card shadow-sm consumer-status-card">
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="d-flex flex-wrap align-items-center column-gap-2">
|
||||||
|
<p class="m-0 small text-muted">{{getStatusSummary()}}</p>
|
||||||
|
<div class="d-flex align-items-center gap-2 ms-auto">
|
||||||
|
@if (getStatusCompleted().length > 0) {
|
||||||
|
<a class="btn-link small text-nowrap" (click)="dismissCompleted()" [routerLink]="[]">
|
||||||
|
<span class="me-1" i18n="This button dismisses all status messages about processed documents in the tray (failed and successful)">Dismiss completed</span>
|
||||||
|
<i-bs name="check2-all"></i-bs>
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
<button type="button" class="btn btn-sm btn-link p-0 text-muted" (click)="toggleCollapsed()"
|
||||||
|
[attr.aria-expanded]="!collapsed()" aria-label="Toggle document processing status" i18n-aria-label>
|
||||||
|
<i-bs [name]="collapsed() ? 'chevron-up' : 'chevron-down'"></i-bs>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
@if (!collapsed()) {
|
||||||
|
@for (status of getStatus(); track status) {
|
||||||
|
<ng-container [ngTemplateOutlet]="consumerAlert" [ngTemplateOutletContext]="{ $implicit: status }"></ng-container>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<ng-template #consumerAlert let-status>
|
||||||
|
<ngb-alert type="secondary" class="mt-2 mb-0" [dismissible]="isFinished(status)" (closed)="dismiss(status)">
|
||||||
|
<h6 class="alert-heading">{{status.filename}}</h6>
|
||||||
|
@if (!isFinished(status) || (isFinished(status) && !status.documentId)) {
|
||||||
|
<p class="mb-0 pb-1">{{status.message}}</p>
|
||||||
|
}
|
||||||
|
<ngb-progressbar [value]="status.getProgress()" [max]="1" [type]="getStatusColor(status)"></ngb-progressbar>
|
||||||
|
<div *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }">
|
||||||
|
@if (isFinished(status)) {
|
||||||
|
<div>
|
||||||
|
@if (status.documentId) {
|
||||||
|
<a class="btn btn-sm btn-outline-primary btn-open" [routerLink]="['/documents', status.documentId]" (click)="dismiss(status)">
|
||||||
|
<small i18n>Open document</small>
|
||||||
|
<i-bs name="arrow-right-short"></i-bs>
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</ngb-alert>
|
||||||
|
</ng-template>
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
:host ::ng-deep i-bs svg {
|
||||||
|
margin-top: -3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.alert-heading {
|
||||||
|
font-size: 80%;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-open {
|
||||||
|
line-height: 1;
|
||||||
|
|
||||||
|
i-bs {
|
||||||
|
margin-top: -1px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.consumer-status-card {
|
||||||
|
background-color: rgba(var(--bs-body-bg-rgb), .95) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.consumer-status-list {
|
||||||
|
max-height: 60vh;
|
||||||
|
z-index: 1030;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
left: 0;
|
||||||
|
height: auto;
|
||||||
|
mix-blend-mode: soft-light;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
+206
@@ -0,0 +1,206 @@
|
|||||||
|
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
|
||||||
|
import { provideHttpClientTesting } from '@angular/common/http/testing'
|
||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||||
|
import { RouterTestingModule } from '@angular/router/testing'
|
||||||
|
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
||||||
|
import { routes } from 'src/app/app-routing.module'
|
||||||
|
import { PermissionsGuard } from 'src/app/guards/permissions.guard'
|
||||||
|
import { PermissionsService } from 'src/app/services/permissions.service'
|
||||||
|
import { UploadDocumentsService } from 'src/app/services/upload-documents.service'
|
||||||
|
import {
|
||||||
|
FileStatus,
|
||||||
|
FileStatusPhase,
|
||||||
|
WebsocketStatusService,
|
||||||
|
} from 'src/app/services/websocket-status.service'
|
||||||
|
import { UploadStatusTrayComponent } from './upload-status-tray.component'
|
||||||
|
|
||||||
|
const FAILED_STATUSES = [new FileStatus()]
|
||||||
|
const WORKING_STATUSES = [new FileStatus(), new FileStatus()]
|
||||||
|
const STARTED_STATUSES = [new FileStatus(), new FileStatus(), new FileStatus()]
|
||||||
|
const SUCCESS_STATUSES = [
|
||||||
|
new FileStatus(),
|
||||||
|
new FileStatus(),
|
||||||
|
new FileStatus(),
|
||||||
|
new FileStatus(),
|
||||||
|
]
|
||||||
|
const DEFAULT_STATUSES = [
|
||||||
|
new FileStatus(),
|
||||||
|
new FileStatus(),
|
||||||
|
new FileStatus(),
|
||||||
|
new FileStatus(),
|
||||||
|
new FileStatus(),
|
||||||
|
new FileStatus(),
|
||||||
|
]
|
||||||
|
|
||||||
|
describe('UploadStatusTrayComponent', () => {
|
||||||
|
let component: UploadStatusTrayComponent
|
||||||
|
let fixture: ComponentFixture<UploadStatusTrayComponent>
|
||||||
|
let websocketStatusService: WebsocketStatusService
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
imports: [
|
||||||
|
RouterTestingModule.withRoutes(routes),
|
||||||
|
NgxBootstrapIconsModule.pick(allIcons),
|
||||||
|
UploadStatusTrayComponent,
|
||||||
|
],
|
||||||
|
providers: [
|
||||||
|
PermissionsGuard,
|
||||||
|
{
|
||||||
|
provide: PermissionsService,
|
||||||
|
useValue: {
|
||||||
|
currentUserCan: () => true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
provideHttpClient(withInterceptorsFromDi()),
|
||||||
|
provideHttpClientTesting(),
|
||||||
|
],
|
||||||
|
}).compileComponents()
|
||||||
|
|
||||||
|
websocketStatusService = TestBed.inject(WebsocketStatusService)
|
||||||
|
fixture = TestBed.createComponent(UploadStatusTrayComponent)
|
||||||
|
component = fixture.componentInstance
|
||||||
|
|
||||||
|
fixture.detectChanges()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not render without statuses', () => {
|
||||||
|
expect(
|
||||||
|
fixture.nativeElement.querySelector('.consumer-status-card')
|
||||||
|
).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should render statuses and support collapsing', () => {
|
||||||
|
const status = new FileStatus()
|
||||||
|
status.filename = 'invoice.pdf'
|
||||||
|
status.phase = FileStatusPhase.WORKING
|
||||||
|
jest
|
||||||
|
.spyOn(websocketStatusService, 'getConsumerStatus')
|
||||||
|
.mockReturnValue([status])
|
||||||
|
// a mocked return value doesn't mark the zoneless view dirty, so re-create it
|
||||||
|
fixture = TestBed.createComponent(UploadStatusTrayComponent)
|
||||||
|
component = fixture.componentInstance
|
||||||
|
fixture.detectChanges()
|
||||||
|
const el: HTMLElement = fixture.nativeElement
|
||||||
|
expect(el.querySelector('.consumer-status-card')).not.toBeNull()
|
||||||
|
expect(el.textContent).toContain('invoice.pdf')
|
||||||
|
|
||||||
|
component.toggleCollapsed()
|
||||||
|
fixture.detectChanges()
|
||||||
|
expect(el.querySelector('.consumer-status-card')).not.toBeNull()
|
||||||
|
expect(el.textContent).not.toContain('invoice.pdf')
|
||||||
|
|
||||||
|
component.toggleCollapsed()
|
||||||
|
fixture.detectChanges()
|
||||||
|
expect(el.textContent).toContain('invoice.pdf')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should expand again once all statuses are dismissed', () => {
|
||||||
|
const status = websocketStatusService.newFileUpload('invoice.pdf')
|
||||||
|
fixture.detectChanges()
|
||||||
|
component.toggleCollapsed()
|
||||||
|
expect(component.collapsed()).toBe(true)
|
||||||
|
websocketStatusService.dismiss(status)
|
||||||
|
fixture.detectChanges()
|
||||||
|
expect(component.collapsed()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should expand on request', () => {
|
||||||
|
component.toggleCollapsed()
|
||||||
|
expect(component.collapsed()).toBe(true)
|
||||||
|
component.expand()
|
||||||
|
expect(component.collapsed()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should expand when new uploads start', () => {
|
||||||
|
const uploadDocumentsService = TestBed.inject(UploadDocumentsService)
|
||||||
|
jest
|
||||||
|
.spyOn(uploadDocumentsService, 'uploadFile')
|
||||||
|
.mockImplementation(() => {})
|
||||||
|
websocketStatusService.newFileUpload('invoice.pdf')
|
||||||
|
fixture.detectChanges()
|
||||||
|
component.toggleCollapsed()
|
||||||
|
expect(component.collapsed()).toBe(true)
|
||||||
|
uploadDocumentsService.uploadFiles([new File(['one'], 'one.pdf')])
|
||||||
|
expect(component.collapsed()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should generate stats summary', () => {
|
||||||
|
mockConsumerStatuses(websocketStatusService)
|
||||||
|
expect(component.getStatusSummary()).toEqual(
|
||||||
|
'Processing: 6, Failed: 1, Added: 4'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should report an upload progress summary', () => {
|
||||||
|
mockConsumerStatuses(websocketStatusService)
|
||||||
|
expect(component.getTotalUploadProgress()).toEqual(0.75)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should change color by status phase', () => {
|
||||||
|
const processingStatus = new FileStatus()
|
||||||
|
processingStatus.phase = FileStatusPhase.WORKING
|
||||||
|
expect(component.getStatusColor(processingStatus)).toEqual('primary')
|
||||||
|
processingStatus.phase = FileStatusPhase.UPLOADING
|
||||||
|
expect(component.getStatusColor(processingStatus)).toEqual('primary')
|
||||||
|
const failedStatus = new FileStatus()
|
||||||
|
failedStatus.phase = FileStatusPhase.FAILED
|
||||||
|
expect(component.getStatusColor(failedStatus)).toEqual('danger')
|
||||||
|
const successStatus = new FileStatus()
|
||||||
|
successStatus.phase = FileStatusPhase.SUCCESS
|
||||||
|
expect(component.getStatusColor(successStatus)).toEqual('success')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should allow dismissing an alert', () => {
|
||||||
|
const dismissSpy = jest.spyOn(websocketStatusService, 'dismiss')
|
||||||
|
component.dismiss(new FileStatus())
|
||||||
|
expect(dismissSpy).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should allow dismissing completed alerts', () => {
|
||||||
|
jest.useFakeTimers()
|
||||||
|
mockConsumerStatuses(websocketStatusService)
|
||||||
|
fixture.detectChanges()
|
||||||
|
jest
|
||||||
|
.spyOn(component, 'getStatusCompleted')
|
||||||
|
.mockImplementation(() => SUCCESS_STATUSES)
|
||||||
|
const dismissSpy = jest.spyOn(websocketStatusService, 'dismiss')
|
||||||
|
component.dismissCompleted()
|
||||||
|
jest.advanceTimersByTime(1000)
|
||||||
|
fixture.detectChanges()
|
||||||
|
expect(dismissSpy).toHaveBeenCalledTimes(4)
|
||||||
|
jest.useRealTimers()
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
function mockConsumerStatuses(consumerStatusService) {
|
||||||
|
const partialUpload1 = new FileStatus()
|
||||||
|
partialUpload1.currentPhaseProgress = 50
|
||||||
|
partialUpload1.currentPhaseMaxProgress = 50
|
||||||
|
const partialUpload2 = new FileStatus()
|
||||||
|
partialUpload2.currentPhaseProgress = 25
|
||||||
|
partialUpload2.currentPhaseMaxProgress = 50
|
||||||
|
jest
|
||||||
|
.spyOn(consumerStatusService, 'getConsumerStatus')
|
||||||
|
.mockImplementation((phase) => {
|
||||||
|
switch (phase) {
|
||||||
|
case FileStatusPhase.FAILED:
|
||||||
|
return FAILED_STATUSES
|
||||||
|
case FileStatusPhase.WORKING:
|
||||||
|
return WORKING_STATUSES
|
||||||
|
case FileStatusPhase.STARTED:
|
||||||
|
return STARTED_STATUSES
|
||||||
|
case FileStatusPhase.SUCCESS:
|
||||||
|
return SUCCESS_STATUSES
|
||||||
|
case FileStatusPhase.UPLOADING:
|
||||||
|
return [partialUpload1, partialUpload2]
|
||||||
|
default:
|
||||||
|
return DEFAULT_STATUSES
|
||||||
|
}
|
||||||
|
})
|
||||||
|
jest
|
||||||
|
.spyOn(consumerStatusService, 'getConsumerStatusNotCompleted')
|
||||||
|
.mockImplementation(() => {
|
||||||
|
return DEFAULT_STATUSES
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -0,0 +1,165 @@
|
|||||||
|
import { NgClass, NgTemplateOutlet } from '@angular/common'
|
||||||
|
import { Component, effect, inject, signal } from '@angular/core'
|
||||||
|
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'
|
||||||
|
import { RouterModule } from '@angular/router'
|
||||||
|
import {
|
||||||
|
NgbAlertModule,
|
||||||
|
NgbProgressbarModule,
|
||||||
|
} from '@ng-bootstrap/ng-bootstrap'
|
||||||
|
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||||
|
import { ComponentWithPermissions } from 'src/app/components/with-permissions/with-permissions.component'
|
||||||
|
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
||||||
|
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
||||||
|
import {
|
||||||
|
PermissionAction,
|
||||||
|
PermissionsService,
|
||||||
|
PermissionType,
|
||||||
|
} from 'src/app/services/permissions.service'
|
||||||
|
import { SettingsService } from 'src/app/services/settings.service'
|
||||||
|
import { UploadDocumentsService } from 'src/app/services/upload-documents.service'
|
||||||
|
import {
|
||||||
|
FileStatus,
|
||||||
|
FileStatusPhase,
|
||||||
|
WebsocketStatusService,
|
||||||
|
} from 'src/app/services/websocket-status.service'
|
||||||
|
|
||||||
|
/** Floating list of document consumption statuses, shown on every page */
|
||||||
|
@Component({
|
||||||
|
selector: 'pngx-upload-status-tray',
|
||||||
|
templateUrl: './upload-status-tray.component.html',
|
||||||
|
styleUrls: ['./upload-status-tray.component.scss'],
|
||||||
|
imports: [
|
||||||
|
IfPermissionsDirective,
|
||||||
|
NgClass,
|
||||||
|
NgTemplateOutlet,
|
||||||
|
RouterModule,
|
||||||
|
NgbAlertModule,
|
||||||
|
NgbProgressbarModule,
|
||||||
|
NgxBootstrapIconsModule,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class UploadStatusTrayComponent extends ComponentWithPermissions {
|
||||||
|
private websocketStatusService = inject(WebsocketStatusService)
|
||||||
|
private settingsService = inject(SettingsService)
|
||||||
|
private permissionsService = inject(PermissionsService)
|
||||||
|
private uploadDocumentsService = inject(UploadDocumentsService)
|
||||||
|
|
||||||
|
readonly collapsed = signal(false)
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
super()
|
||||||
|
// start expanded again for the next uploads once everything is dismissed
|
||||||
|
effect(() => {
|
||||||
|
if (this.getStatus().length === 0) this.collapsed.set(false)
|
||||||
|
})
|
||||||
|
this.uploadDocumentsService
|
||||||
|
.onUploadsStarted()
|
||||||
|
.pipe(takeUntilDestroyed())
|
||||||
|
.subscribe(() => this.expand())
|
||||||
|
}
|
||||||
|
|
||||||
|
get canAddDocuments(): boolean {
|
||||||
|
return this.permissionsService.currentUserCan(
|
||||||
|
PermissionAction.Add,
|
||||||
|
PermissionType.Document
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
getStatus() {
|
||||||
|
return this.websocketStatusService.getConsumerStatus()
|
||||||
|
}
|
||||||
|
|
||||||
|
getStatusSummary() {
|
||||||
|
let strings = []
|
||||||
|
let countUploadingAndProcessing =
|
||||||
|
this.websocketStatusService.getConsumerStatusNotCompleted().length
|
||||||
|
let countFailed = this.getStatusFailed().length
|
||||||
|
let countSuccess = this.getStatusSuccess().length
|
||||||
|
if (countUploadingAndProcessing > 0) {
|
||||||
|
strings.push($localize`Processing: ${countUploadingAndProcessing}`)
|
||||||
|
}
|
||||||
|
if (countFailed > 0) {
|
||||||
|
strings.push($localize`Failed: ${countFailed}`)
|
||||||
|
}
|
||||||
|
if (countSuccess > 0) {
|
||||||
|
strings.push($localize`Added: ${countSuccess}`)
|
||||||
|
}
|
||||||
|
return strings.join(
|
||||||
|
$localize`:this string is used to separate processing, failed and added on the file upload widget:, `
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
getStatusUploading() {
|
||||||
|
return this.websocketStatusService.getConsumerStatus(
|
||||||
|
FileStatusPhase.UPLOADING
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
getStatusFailed() {
|
||||||
|
return this.websocketStatusService.getConsumerStatus(FileStatusPhase.FAILED)
|
||||||
|
}
|
||||||
|
|
||||||
|
getStatusSuccess() {
|
||||||
|
return this.websocketStatusService.getConsumerStatus(
|
||||||
|
FileStatusPhase.SUCCESS
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
getStatusCompleted() {
|
||||||
|
return this.websocketStatusService.getConsumerStatusCompleted()
|
||||||
|
}
|
||||||
|
|
||||||
|
getTotalUploadProgress() {
|
||||||
|
let current = 0
|
||||||
|
let max = 0
|
||||||
|
|
||||||
|
this.getStatusUploading().forEach((status) => {
|
||||||
|
current += status.currentPhaseProgress
|
||||||
|
max += status.currentPhaseMaxProgress
|
||||||
|
})
|
||||||
|
|
||||||
|
return current / Math.max(max, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
isFinished(status: FileStatus) {
|
||||||
|
return (
|
||||||
|
status.phase == FileStatusPhase.FAILED ||
|
||||||
|
status.phase == FileStatusPhase.SUCCESS
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
getStatusColor(status: FileStatus) {
|
||||||
|
switch (status.phase) {
|
||||||
|
case FileStatusPhase.UPLOADING:
|
||||||
|
case FileStatusPhase.STARTED:
|
||||||
|
case FileStatusPhase.WORKING:
|
||||||
|
return 'primary'
|
||||||
|
case FileStatusPhase.FAILED:
|
||||||
|
return 'danger'
|
||||||
|
case FileStatusPhase.SUCCESS:
|
||||||
|
return 'success'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
toggleCollapsed() {
|
||||||
|
this.collapsed.update((collapsed) => !collapsed)
|
||||||
|
}
|
||||||
|
|
||||||
|
expand() {
|
||||||
|
this.collapsed.set(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
dismiss(status: FileStatus) {
|
||||||
|
this.websocketStatusService.dismiss(status)
|
||||||
|
}
|
||||||
|
|
||||||
|
dismissCompleted() {
|
||||||
|
this.getStatusCompleted().forEach((status) =>
|
||||||
|
this.websocketStatusService.dismiss(status)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
get slimSidebarEnabled(): boolean {
|
||||||
|
return this.settingsService.get(SETTINGS_KEYS.SLIM_SIDEBAR)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -173,7 +173,7 @@ describe('DashboardComponent', () => {
|
|||||||
|
|
||||||
it('should update saved view sorting on drag + drop, show info', () => {
|
it('should update saved view sorting on drag + drop, show info', () => {
|
||||||
const settingsSpy = jest.spyOn(settingsService, 'updateDashboardViewsSort')
|
const settingsSpy = jest.spyOn(settingsService, 'updateDashboardViewsSort')
|
||||||
const toastSpy = jest.spyOn(toastService, 'showInfo')
|
const toastSpy = jest.spyOn(toastService, 'showSuccess')
|
||||||
jest.spyOn(settingsService, 'storeSettings').mockReturnValue(of(true))
|
jest.spyOn(settingsService, 'storeSettings').mockReturnValue(of(true))
|
||||||
component.onDrop({ previousIndex: 0, currentIndex: 1 } as CdkDragDrop<
|
component.onDrop({ previousIndex: 0, currentIndex: 1 } as CdkDragDrop<
|
||||||
SavedView[]
|
SavedView[]
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ export class DashboardComponent extends ComponentWithPermissions {
|
|||||||
.updateDashboardViewsSort(this.dashboardViews())
|
.updateDashboardViewsSort(this.dashboardViews())
|
||||||
.subscribe({
|
.subscribe({
|
||||||
next: () => {
|
next: () => {
|
||||||
this.toastService.showInfo($localize`Dashboard updated`)
|
this.toastService.showSuccess($localize`Dashboard updated`)
|
||||||
},
|
},
|
||||||
error: (e) => {
|
error: (e) => {
|
||||||
this.toastService.showError($localize`Error updating dashboard`, e)
|
this.toastService.showError($localize`Error updating dashboard`, e)
|
||||||
|
|||||||
-54
@@ -8,59 +8,5 @@
|
|||||||
</button>
|
</button>
|
||||||
<input type="file" class="visually-hidden" (change)="onFileSelected($event)" multiple #fileUpload>
|
<input type="file" class="visually-hidden" (change)="onFileSelected($event)" multiple #fileUpload>
|
||||||
</form>
|
</form>
|
||||||
@if (getStatus().length > 0) {
|
|
||||||
<div class="fixed-bottom p-2 p-md-4 d-flex justify-content-end pe-none consumer-status-list" [ngClass]="slimSidebarEnabled ? 'col-slim' : 'offset-sidebar-expanded'">
|
|
||||||
<div class="col col-lg-4 col-xl-3 ps-0 pe-0 ps-lg-3 pe-lg-0 pe-auto overflow-y-scroll">
|
|
||||||
<div class="card shadow-sm consumer-status-card">
|
|
||||||
<div class="card-body">
|
|
||||||
<div class="row">
|
|
||||||
<div class="col d-flex justify-content-between align-items-center">
|
|
||||||
@if (getStatus().length > 0) {
|
|
||||||
<p class="m-0 small text-muted">{{getStatusSummary()}}</p>
|
|
||||||
}
|
|
||||||
@if (getStatusCompleted().length > 0) {
|
|
||||||
<a class="btn-link" (click)="dismissCompleted()" [routerLink]="[]" >
|
|
||||||
<span class="me-1" i18n="This button dismisses all status messages about processed documents on the dashboard (failed and successful)">Dismiss completed</span>
|
|
||||||
<i-bs name="check2-all"></i-bs>
|
|
||||||
</a>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="row">
|
|
||||||
<div class="col">
|
|
||||||
@for (status of getStatus(); track status) {
|
|
||||||
<div>
|
|
||||||
<ng-container [ngTemplateOutlet]="consumerAlert" [ngTemplateOutletContext]="{ $implicit: status }"></ng-container>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
</div>
|
||||||
</pngx-widget-frame>
|
</pngx-widget-frame>
|
||||||
|
|
||||||
<ng-template #consumerAlert let-status>
|
|
||||||
<ngb-alert type="secondary" class="mt-2 mb-0" [dismissible]="isFinished(status)" (closed)="dismiss(status)">
|
|
||||||
<h6 class="alert-heading">{{status.filename}}</h6>
|
|
||||||
@if (!isFinished(status) || (isFinished(status) && !status.documentId)) {
|
|
||||||
<p class="mb-0 pb-1">{{status.message}}</p>
|
|
||||||
}
|
|
||||||
<ngb-progressbar [value]="status.getProgress()" [max]="1" [type]="getStatusColor(status)"></ngb-progressbar>
|
|
||||||
<div *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }">
|
|
||||||
@if (isFinished(status)) {
|
|
||||||
<div>
|
|
||||||
@if (status.documentId) {
|
|
||||||
<a class="btn btn-sm btn-outline-primary btn-open" [routerLink]="['/documents', status.documentId]" (click)="dismiss(status)">
|
|
||||||
<small i18n>Open document</small>
|
|
||||||
<i-bs name="arrow-right-short"></i-bs>
|
|
||||||
</a>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</ngb-alert>
|
|
||||||
</ng-template>
|
|
||||||
-42
@@ -20,48 +20,6 @@
|
|||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.alert-heading {
|
|
||||||
font-size: 80%;
|
|
||||||
font-weight: bold;
|
|
||||||
}
|
|
||||||
|
|
||||||
.alerts-hidden {
|
|
||||||
.btn {
|
|
||||||
line-height: 1;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-open {
|
|
||||||
line-height: 1;
|
|
||||||
|
|
||||||
i-bs {
|
|
||||||
margin-top: -1px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.progress {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
right: 0;
|
|
||||||
bottom: 0;
|
|
||||||
left: 0;
|
|
||||||
height: auto;
|
|
||||||
mix-blend-mode: soft-light;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
::ng-deep .ngx-file-drop__drop-zone--over {
|
::ng-deep .ngx-file-drop__drop-zone--over {
|
||||||
background-color: var(--pngx-primary-faded) !important;
|
background-color: var(--pngx-primary-faded) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.consumer-status-card {
|
|
||||||
background-color: rgba(var(--bs-body-bg-rgb), .95) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.consumer-status-list {
|
|
||||||
max-height: calc(100vh - 312px); // e.g. below the upload button, mobile
|
|
||||||
|
|
||||||
@media screen and (min-width: 768px) {
|
|
||||||
max-height: calc(100vh - 208px); // e.g. below the upload button
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+4
-112
@@ -9,35 +9,11 @@ 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 { 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 WORKING_STATUSES = [new FileStatus(), new FileStatus()]
|
|
||||||
const STARTED_STATUSES = [new FileStatus(), new FileStatus(), new FileStatus()]
|
|
||||||
const SUCCESS_STATUSES = [
|
|
||||||
new FileStatus(),
|
|
||||||
new FileStatus(),
|
|
||||||
new FileStatus(),
|
|
||||||
new FileStatus(),
|
|
||||||
]
|
|
||||||
const DEFAULT_STATUSES = [
|
|
||||||
new FileStatus(),
|
|
||||||
new FileStatus(),
|
|
||||||
new FileStatus(),
|
|
||||||
new FileStatus(),
|
|
||||||
new FileStatus(),
|
|
||||||
new FileStatus(),
|
|
||||||
]
|
|
||||||
|
|
||||||
describe('UploadFileWidgetComponent', () => {
|
describe('UploadFileWidgetComponent', () => {
|
||||||
let component: UploadFileWidgetComponent
|
let component: UploadFileWidgetComponent
|
||||||
let fixture: ComponentFixture<UploadFileWidgetComponent>
|
let fixture: ComponentFixture<UploadFileWidgetComponent>
|
||||||
let websocketStatusService: WebsocketStatusService
|
|
||||||
let uploadDocumentsService: UploadDocumentsService
|
let uploadDocumentsService: UploadDocumentsService
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
@@ -61,7 +37,6 @@ describe('UploadFileWidgetComponent', () => {
|
|||||||
],
|
],
|
||||||
}).compileComponents()
|
}).compileComponents()
|
||||||
|
|
||||||
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
|
||||||
@@ -85,93 +60,10 @@ describe('UploadFileWidgetComponent', () => {
|
|||||||
'file.pdf'
|
'file.pdf'
|
||||||
)
|
)
|
||||||
const fileInput = fixture.debugElement.query(By.css('input'))
|
const fileInput = fixture.debugElement.query(By.css('input'))
|
||||||
jest.spyOn(fileInput.nativeElement, 'files', 'get').mockReturnValue({
|
|
||||||
item: () => file,
|
|
||||||
length: 1,
|
|
||||||
[Symbol.iterator]: () => ({
|
|
||||||
next: () => ({ done: false, value: file }),
|
|
||||||
}),
|
|
||||||
} as any)
|
|
||||||
fileInput.nativeElement.dispatchEvent(new Event('change'))
|
|
||||||
expect(uploadSpy).toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should generate stats summary', () => {
|
|
||||||
mockConsumerStatuses(websocketStatusService)
|
|
||||||
expect(component.getStatusSummary()).toEqual(
|
|
||||||
'Processing: 6, Failed: 1, Added: 4'
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should report an upload progress summary', () => {
|
|
||||||
mockConsumerStatuses(websocketStatusService)
|
|
||||||
expect(component.getTotalUploadProgress()).toEqual(0.75)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should change color by status phase', () => {
|
|
||||||
const processingStatus = new FileStatus()
|
|
||||||
processingStatus.phase = FileStatusPhase.WORKING
|
|
||||||
expect(component.getStatusColor(processingStatus)).toEqual('primary')
|
|
||||||
processingStatus.phase = FileStatusPhase.UPLOADING
|
|
||||||
expect(component.getStatusColor(processingStatus)).toEqual('primary')
|
|
||||||
const failedStatus = new FileStatus()
|
|
||||||
failedStatus.phase = FileStatusPhase.FAILED
|
|
||||||
expect(component.getStatusColor(failedStatus)).toEqual('danger')
|
|
||||||
const successStatus = new FileStatus()
|
|
||||||
successStatus.phase = FileStatusPhase.SUCCESS
|
|
||||||
expect(component.getStatusColor(successStatus)).toEqual('success')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should allow dismissing an alert', () => {
|
|
||||||
const dismissSpy = jest.spyOn(websocketStatusService, 'dismiss')
|
|
||||||
component.dismiss(new FileStatus())
|
|
||||||
expect(dismissSpy).toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should allow dismissing completed alerts', () => {
|
|
||||||
jest.useFakeTimers()
|
|
||||||
mockConsumerStatuses(websocketStatusService)
|
|
||||||
fixture.detectChanges()
|
|
||||||
jest
|
jest
|
||||||
.spyOn(component, 'getStatusCompleted')
|
.spyOn(fileInput.nativeElement, 'files', 'get')
|
||||||
.mockImplementation(() => SUCCESS_STATUSES)
|
.mockReturnValue([file] as any)
|
||||||
const dismissSpy = jest.spyOn(websocketStatusService, 'dismiss')
|
fileInput.nativeElement.dispatchEvent(new Event('change'))
|
||||||
component.dismissCompleted()
|
expect(uploadSpy).toHaveBeenCalledWith(file)
|
||||||
jest.advanceTimersByTime(1000)
|
|
||||||
fixture.detectChanges()
|
|
||||||
expect(dismissSpy).toHaveBeenCalledTimes(4)
|
|
||||||
jest.useRealTimers()
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
function mockConsumerStatuses(consumerStatusService) {
|
|
||||||
const partialUpload1 = new FileStatus()
|
|
||||||
partialUpload1.currentPhaseProgress = 50
|
|
||||||
partialUpload1.currentPhaseMaxProgress = 50
|
|
||||||
const partialUpload2 = new FileStatus()
|
|
||||||
partialUpload2.currentPhaseProgress = 25
|
|
||||||
partialUpload2.currentPhaseMaxProgress = 50
|
|
||||||
jest
|
|
||||||
.spyOn(consumerStatusService, 'getConsumerStatus')
|
|
||||||
.mockImplementation((phase) => {
|
|
||||||
switch (phase) {
|
|
||||||
case FileStatusPhase.FAILED:
|
|
||||||
return FAILED_STATUSES
|
|
||||||
case FileStatusPhase.WORKING:
|
|
||||||
return WORKING_STATUSES
|
|
||||||
case FileStatusPhase.STARTED:
|
|
||||||
return STARTED_STATUSES
|
|
||||||
case FileStatusPhase.SUCCESS:
|
|
||||||
return SUCCESS_STATUSES
|
|
||||||
case FileStatusPhase.UPLOADING:
|
|
||||||
return [partialUpload1, partialUpload2]
|
|
||||||
default:
|
|
||||||
return DEFAULT_STATUSES
|
|
||||||
}
|
|
||||||
})
|
|
||||||
jest
|
|
||||||
.spyOn(consumerStatusService, 'getConsumerStatusNotCompleted')
|
|
||||||
.mockImplementation(() => {
|
|
||||||
return DEFAULT_STATUSES
|
|
||||||
})
|
|
||||||
}
|
|
||||||
+4
-119
@@ -1,23 +1,9 @@
|
|||||||
import { NgClass, NgTemplateOutlet } from '@angular/common'
|
import { Component, inject } from '@angular/core'
|
||||||
import { Component, QueryList, ViewChildren, inject } from '@angular/core'
|
|
||||||
import { RouterModule } from '@angular/router'
|
|
||||||
import {
|
|
||||||
NgbAlert,
|
|
||||||
NgbAlertModule,
|
|
||||||
NgbProgressbarModule,
|
|
||||||
} from '@ng-bootstrap/ng-bootstrap'
|
|
||||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||||
import { TourNgBootstrap } from 'ngx-ui-tour-ng-bootstrap'
|
import { TourNgBootstrap } 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 { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
||||||
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'
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
@@ -27,117 +13,16 @@ import { WidgetFrameComponent } from '../widget-frame/widget-frame.component'
|
|||||||
imports: [
|
imports: [
|
||||||
WidgetFrameComponent,
|
WidgetFrameComponent,
|
||||||
IfPermissionsDirective,
|
IfPermissionsDirective,
|
||||||
NgClass,
|
|
||||||
NgTemplateOutlet,
|
|
||||||
RouterModule,
|
|
||||||
NgbAlertModule,
|
|
||||||
NgbProgressbarModule,
|
|
||||||
NgxBootstrapIconsModule,
|
NgxBootstrapIconsModule,
|
||||||
TourNgBootstrap,
|
TourNgBootstrap,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class UploadFileWidgetComponent extends ComponentWithPermissions {
|
export class UploadFileWidgetComponent extends ComponentWithPermissions {
|
||||||
private websocketStatusService = inject(WebsocketStatusService)
|
|
||||||
private uploadDocumentsService = inject(UploadDocumentsService)
|
private uploadDocumentsService = inject(UploadDocumentsService)
|
||||||
settingsService = inject(SettingsService)
|
|
||||||
|
|
||||||
@ViewChildren(NgbAlert) alerts: QueryList<NgbAlert>
|
|
||||||
|
|
||||||
getStatus() {
|
|
||||||
return this.websocketStatusService.getConsumerStatus()
|
|
||||||
}
|
|
||||||
|
|
||||||
getStatusSummary() {
|
|
||||||
let strings = []
|
|
||||||
let countUploadingAndProcessing =
|
|
||||||
this.websocketStatusService.getConsumerStatusNotCompleted().length
|
|
||||||
let countFailed = this.getStatusFailed().length
|
|
||||||
let countSuccess = this.getStatusSuccess().length
|
|
||||||
if (countUploadingAndProcessing > 0) {
|
|
||||||
strings.push($localize`Processing: ${countUploadingAndProcessing}`)
|
|
||||||
}
|
|
||||||
if (countFailed > 0) {
|
|
||||||
strings.push($localize`Failed: ${countFailed}`)
|
|
||||||
}
|
|
||||||
if (countSuccess > 0) {
|
|
||||||
strings.push($localize`Added: ${countSuccess}`)
|
|
||||||
}
|
|
||||||
return strings.join(
|
|
||||||
$localize`:this string is used to separate processing, failed and added on the file upload widget:, `
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
getStatusUploading() {
|
|
||||||
return this.websocketStatusService.getConsumerStatus(
|
|
||||||
FileStatusPhase.UPLOADING
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
getStatusFailed() {
|
|
||||||
return this.websocketStatusService.getConsumerStatus(FileStatusPhase.FAILED)
|
|
||||||
}
|
|
||||||
|
|
||||||
getStatusSuccess() {
|
|
||||||
return this.websocketStatusService.getConsumerStatus(
|
|
||||||
FileStatusPhase.SUCCESS
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
getStatusCompleted() {
|
|
||||||
return this.websocketStatusService.getConsumerStatusCompleted()
|
|
||||||
}
|
|
||||||
|
|
||||||
getTotalUploadProgress() {
|
|
||||||
let current = 0
|
|
||||||
let max = 0
|
|
||||||
|
|
||||||
this.getStatusUploading().forEach((status) => {
|
|
||||||
current += status.currentPhaseProgress
|
|
||||||
max += status.currentPhaseMaxProgress
|
|
||||||
})
|
|
||||||
|
|
||||||
return current / Math.max(max, 1)
|
|
||||||
}
|
|
||||||
|
|
||||||
isFinished(status: FileStatus) {
|
|
||||||
return (
|
|
||||||
status.phase == FileStatusPhase.FAILED ||
|
|
||||||
status.phase == FileStatusPhase.SUCCESS
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
getStatusColor(status: FileStatus) {
|
|
||||||
switch (status.phase) {
|
|
||||||
case FileStatusPhase.UPLOADING:
|
|
||||||
case FileStatusPhase.STARTED:
|
|
||||||
case FileStatusPhase.WORKING:
|
|
||||||
return 'primary'
|
|
||||||
case FileStatusPhase.FAILED:
|
|
||||||
return 'danger'
|
|
||||||
case FileStatusPhase.SUCCESS:
|
|
||||||
return 'success'
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
dismiss(status: FileStatus) {
|
|
||||||
this.websocketStatusService.dismiss(status)
|
|
||||||
}
|
|
||||||
|
|
||||||
dismissCompleted() {
|
|
||||||
this.getStatusCompleted().forEach((status) =>
|
|
||||||
this.websocketStatusService.dismiss(status)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
public onFileSelected(event: Event) {
|
public onFileSelected(event: Event) {
|
||||||
const files = (event.target as HTMLInputElement).files
|
const input = event.target as HTMLInputElement
|
||||||
for (let i = 0; i < files?.length; i++) {
|
this.uploadDocumentsService.uploadFiles(Array.from(input.files ?? []))
|
||||||
const file = files.item(i)
|
input.value = ''
|
||||||
file && this.uploadDocumentsService.uploadFile(file)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
get slimSidebarEnabled(): boolean {
|
|
||||||
return this.settingsService.get(SETTINGS_KEYS.SLIM_SIDEBAR)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,25 +1,4 @@
|
|||||||
<pngx-page-header [title]="title()" [id]="documentId()">
|
<pngx-page-header [title]="title()" [id]="documentId()">
|
||||||
@if (archiveContentRenderType === ContentRenderType.PDF && !useNativePdfViewer) {
|
|
||||||
@if (previewNumPages()) {
|
|
||||||
<div class="input-group input-group-sm ms-2 d-none d-md-flex">
|
|
||||||
<div class="input-group-text" i18n>Page</div>
|
|
||||||
<input class="form-control flex-grow-0 w-auto" type="number" min="1" [max]="previewNumPages()" [ngModel]="previewCurrentPage()" (ngModelChange)="previewCurrentPage.set($event)" />
|
|
||||||
<div class="input-group-text" i18n>of {{previewNumPages()}}</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
<div class="input-group input-group-sm me-md-5 d-none d-md-flex">
|
|
||||||
<button class="btn btn-outline-secondary" (click)="decreaseZoom()" i18n>-</button>
|
|
||||||
<select class="form-select" (change)="setZoom($event.target.value)" [ngModel]="currentZoom">
|
|
||||||
@for (setting of zoomSettings; track setting) {
|
|
||||||
<option [value]="setting">
|
|
||||||
{{ getZoomSettingTitle(setting) }}
|
|
||||||
</option>
|
|
||||||
}
|
|
||||||
</select>
|
|
||||||
<button class="btn btn-outline-secondary" (click)="increaseZoom()" i18n>+</button>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
<button type="button" class="btn btn-sm btn-outline-danger me-md-4" (click)="delete()" [disabled]="!userIsOwner" *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.Document }" aria-label="Delete" i18n-aria-label>
|
<button type="button" class="btn btn-sm btn-outline-danger me-md-4" (click)="delete()" [disabled]="!userIsOwner" *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.Document }" aria-label="Delete" i18n-aria-label>
|
||||||
<i-bs width="1.2em" height="1.2em" name="trash"></i-bs><span class="d-none d-lg-inline ps-1" i18n>Delete</span>
|
<i-bs width="1.2em" height="1.2em" name="trash"></i-bs><span class="d-none d-lg-inline ps-1" i18n>Delete</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -34,7 +13,7 @@
|
|||||||
(versionsUpdated)="onVersionsUpdated($event)"
|
(versionsUpdated)="onVersionsUpdated($event)"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="btn-group">
|
<div class="btn-group ms-4">
|
||||||
<button (click)="download()" class="btn btn-sm btn-outline-primary" [disabled]="downloading()" aria-label="Download" i18n-aria-label>
|
<button (click)="download()" class="btn btn-sm btn-outline-primary" [disabled]="downloading()" aria-label="Download" i18n-aria-label>
|
||||||
@if (downloading()) {
|
@if (downloading()) {
|
||||||
<div class="spinner-border spinner-border-sm" role="status"></div>
|
<div class="spinner-border spinner-border-sm" role="status"></div>
|
||||||
@@ -82,6 +61,22 @@
|
|||||||
<i-bs name="pencil" class="me-1"></i-bs><ng-container i18n>PDF Editor</ng-container>
|
<i-bs name="pencil" class="me-1"></i-bs><ng-container i18n>PDF Editor</ng-container>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<div class="d-flex" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.OcrTemplate }">
|
||||||
|
<button ngbDropdownItem (click)="runZoneOcr()" [disabled]="!userCanEdit || networkActive()">
|
||||||
|
<i-bs width="1em" height="1em" name="bounding-box-circles" class="me-1"></i-bs><span i18n>Run Zone OCR</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
ngbDropdownItem
|
||||||
|
class="w-auto flex-shrink-0 px-2"
|
||||||
|
(click)="runZoneOcrWithOptions()"
|
||||||
|
[disabled]="!userCanEdit || networkActive()"
|
||||||
|
title="Choose OCR template"
|
||||||
|
i18n-title
|
||||||
|
>
|
||||||
|
<i-bs width="1em" height="1em" name="sliders2"></i-bs>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
@if (userIsOwner && (requiresPassword || password)) {
|
@if (userIsOwner && (requiresPassword || password)) {
|
||||||
<button ngbDropdownItem (click)="removePassword()" [disabled]="!password">
|
<button ngbDropdownItem (click)="removePassword()" [disabled]="!password">
|
||||||
<i-bs name="unlock" class="me-1"></i-bs><ng-container i18n>Remove Password</ng-container>
|
<i-bs name="unlock" class="me-1"></i-bs><ng-container i18n>Remove Password</ng-container>
|
||||||
@@ -94,7 +89,7 @@
|
|||||||
<button class="btn btn-sm btn-outline-primary" id="sendDropdown" ngbDropdownToggle aria-label="Send" i18n-aria-label>
|
<button class="btn btn-sm btn-outline-primary" id="sendDropdown" ngbDropdownToggle aria-label="Send" i18n-aria-label>
|
||||||
<i-bs name="send"></i-bs><div class="d-none d-sm-inline ms-1"><ng-container i18n>Send</ng-container></div>
|
<i-bs name="send"></i-bs><div class="d-none d-sm-inline ms-1"><ng-container i18n>Send</ng-container></div>
|
||||||
</button>
|
</button>
|
||||||
<div ngbDropdownMenu aria-labelledby="actionsDropdown" class="shadow">
|
<div ngbDropdownMenu aria-labelledby="sendDropdown" class="shadow">
|
||||||
<button ngbDropdownItem (click)="openShareLinks()" *pngxIfPermissions="{ action: PermissionAction.Add, type: PermissionType.ShareLink }">
|
<button ngbDropdownItem (click)="openShareLinks()" *pngxIfPermissions="{ action: PermissionAction.Add, type: PermissionType.ShareLink }">
|
||||||
<i-bs name="link" class="me-1"></i-bs><span i18n>Share Links</span>
|
<i-bs name="link" class="me-1"></i-bs><span i18n>Share Links</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -111,10 +106,10 @@
|
|||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-md-6 col-xl-5 mb-4">
|
<div class="col-md-6 col-xl-5 mb-4">
|
||||||
|
|
||||||
<form [formGroup]='documentForm' (ngSubmit)="save()">
|
<form [formGroup]='documentForm' (ngSubmit)="save()" [attr.inert]="zoneOcrRunning() ? '' : null" [attr.aria-busy]="zoneOcrRunning()">
|
||||||
|
|
||||||
<div class="btn-toolbar justify-content-end mb-1 pb-3 gap-2 row-gap-2 border-bottom">
|
<div class="btn-toolbar mb-1 pb-3 gap-2 row-gap-2 border-bottom">
|
||||||
<div class="btn-group me-auto">
|
<div class="btn-group">
|
||||||
<button type="button" class="btn btn-sm btn-outline-secondary" i18n-title title="Close" (click)="close()">
|
<button type="button" class="btn btn-sm btn-outline-secondary" i18n-title title="Close" (click)="close()">
|
||||||
<i-bs width="1.2em" height="1.2em" name="x"></i-bs>
|
<i-bs width="1.2em" height="1.2em" name="x"></i-bs>
|
||||||
</button>
|
</button>
|
||||||
@@ -127,43 +122,30 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ng-container *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.Document }">
|
<ng-container *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.Document }">
|
||||||
<div class="d-flex gap-2">
|
<div class="btn-group ms-3">
|
||||||
<div class="btn-group">
|
<pngx-suggestions-dropdown *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.Document }"
|
||||||
<pngx-suggestions-dropdown *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.Document }"
|
[disabled]="!userCanEdit || suggestionsLoading()"
|
||||||
[disabled]="!userCanEdit || suggestionsLoading()"
|
[loading]="suggestionsLoading()"
|
||||||
[loading]="suggestionsLoading()"
|
[suggestions]="suggestions()"
|
||||||
[suggestions]="suggestions()"
|
[aiEnabled]="aiEnabled"
|
||||||
[aiEnabled]="aiEnabled"
|
[source]="suggestionSource"
|
||||||
[source]="suggestionSource"
|
[defaultSource]="defaultSuggestionSource"
|
||||||
[defaultSource]="defaultSuggestionSource"
|
[fetchedSources]="fetchedSuggestionSources()"
|
||||||
[fetchedSources]="fetchedSuggestionSources()"
|
[appliedTags]="documentForm.value.tags"
|
||||||
[appliedTags]="documentForm.value.tags"
|
[appliedCorrespondent]="documentForm.value.correspondent"
|
||||||
[appliedCorrespondent]="documentForm.value.correspondent"
|
[appliedDocumentType]="documentForm.value.document_type"
|
||||||
[appliedDocumentType]="documentForm.value.document_type"
|
[appliedStoragePath]="documentForm.value.storage_path"
|
||||||
[appliedStoragePath]="documentForm.value.storage_path"
|
[appliedTitle]="documentForm.value.title"
|
||||||
[appliedTitle]="documentForm.value.title"
|
[appliedCreated]="documentForm.value.created"
|
||||||
[appliedCreated]="documentForm.value.created"
|
(getSuggestions)="getSuggestions($event)"
|
||||||
(getSuggestions)="getSuggestions($event)"
|
(sourceChange)="suggestionSourceOverride.set($event)"
|
||||||
(sourceChange)="suggestionSourceOverride.set($event)"
|
(addTag)="createTag($event)"
|
||||||
(addTag)="createTag($event)"
|
(addDocumentType)="createDocumentType($event)"
|
||||||
(addDocumentType)="createDocumentType($event)"
|
(addCorrespondent)="createCorrespondent($event)">
|
||||||
(addCorrespondent)="createCorrespondent($event)">
|
</pngx-suggestions-dropdown>
|
||||||
</pngx-suggestions-dropdown>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="btn-group">
|
|
||||||
<pngx-custom-fields-dropdown
|
|
||||||
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.CustomField }"
|
|
||||||
[documentId]="documentId()"
|
|
||||||
[disabled]="!userCanEdit"
|
|
||||||
[existingFields]="document()?.custom_fields"
|
|
||||||
(created)="refreshCustomFields()"
|
|
||||||
(added)="addField($event)">
|
|
||||||
</pngx-custom-fields-dropdown>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="ps-3">
|
<div class="ms-auto">
|
||||||
<ng-container *ngTemplateOutlet="saveButtons"></ng-container>
|
<ng-container *ngTemplateOutlet="saveButtons"></ng-container>
|
||||||
</div>
|
</div>
|
||||||
</ng-container>
|
</ng-container>
|
||||||
@@ -290,8 +272,20 @@
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="d-flex justify-content-end border-top pt-3">
|
<div class="d-flex border-top pt-3">
|
||||||
<ng-container *ngTemplateOutlet="saveButtons"></ng-container>
|
<ng-container *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.Document }">
|
||||||
|
<pngx-custom-fields-dropdown
|
||||||
|
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.CustomField }"
|
||||||
|
[documentId]="documentId()"
|
||||||
|
[disabled]="!userCanEdit"
|
||||||
|
[existingFields]="document()?.custom_fields"
|
||||||
|
(created)="refreshCustomFields()"
|
||||||
|
(added)="addField($event)">
|
||||||
|
</pngx-custom-fields-dropdown>
|
||||||
|
</ng-container>
|
||||||
|
<div class="ms-auto">
|
||||||
|
<ng-container *ngTemplateOutlet="saveButtons"></ng-container>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
</li>
|
</li>
|
||||||
@@ -328,9 +322,9 @@
|
|||||||
<td i18n>Original filename</td>
|
<td i18n>Original filename</td>
|
||||||
<td>{{metadata()?.original_filename}}</td>
|
<td>{{metadata()?.original_filename}}</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr class="align-middle">
|
||||||
<td i18n>Original SHA256 checksum</td>
|
<td i18n>Original SHA256 checksum</td>
|
||||||
<td>{{metadata()?.original_checksum}}</td>
|
<td><span class="badge bg-light font-monospace text-wrap text-break text-start">{{metadata()?.original_checksum}}</span></td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td i18n>Original file size</td>
|
<td i18n>Original file size</td>
|
||||||
@@ -341,9 +335,9 @@
|
|||||||
<td>{{metadata()?.original_mime_type}}</td>
|
<td>{{metadata()?.original_mime_type}}</td>
|
||||||
</tr>
|
</tr>
|
||||||
@if (metadata()?.has_archive_version) {
|
@if (metadata()?.has_archive_version) {
|
||||||
<tr>
|
<tr class="align-middle">
|
||||||
<td i18n>Archive SHA256 checksum</td>
|
<td i18n>Archive SHA256 checksum</td>
|
||||||
<td>{{metadata()?.archive_checksum}}</td>
|
<td><span class="badge bg-light font-monospace text-wrap text-break text-start">{{metadata()?.archive_checksum}}</span></td>
|
||||||
</tr>
|
</tr>
|
||||||
}
|
}
|
||||||
@if (metadata()?.has_archive_version) {
|
@if (metadata()?.has_archive_version) {
|
||||||
@@ -408,7 +402,7 @@
|
|||||||
<a ngbNavLink i18n>Permissions</a>
|
<a ngbNavLink i18n>Permissions</a>
|
||||||
<ng-template ngbNavContent>
|
<ng-template ngbNavContent>
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<pngx-permissions-form [users]="users()" formControlName="permissions_form"></pngx-permissions-form>
|
<pngx-permissions-form title="" [users]="users()" formControlName="permissions_form"></pngx-permissions-form>
|
||||||
</div>
|
</div>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
</li>
|
</li>
|
||||||
@@ -499,6 +493,29 @@
|
|||||||
(loadError)="onError($event)"
|
(loadError)="onError($event)"
|
||||||
(afterLoadComplete)="pdfPreviewLoaded($event)">
|
(afterLoadComplete)="pdfPreviewLoaded($event)">
|
||||||
</pngx-pdf-viewer>
|
</pngx-pdf-viewer>
|
||||||
|
<div class="position-absolute bottom-0 start-50 translate-middle-x z-1 mb-3 d-flex gap-2 p-1 bg-body border rounded shadow">
|
||||||
|
@if (previewNumPages()) {
|
||||||
|
<div class="input-group input-group-sm w-auto flex-nowrap">
|
||||||
|
<input class="form-control flex-grow-0 w-auto" type="number" min="1" [max]="previewNumPages()" [ngModelOptions]="{standalone: true}" [ngModel]="previewCurrentPage()" (ngModelChange)="previewCurrentPage.set($event)" aria-label="Page" i18n-aria-label />
|
||||||
|
<div class="input-group-text" i18n>of {{previewNumPages()}}</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
<div class="input-group input-group-sm w-auto flex-nowrap">
|
||||||
|
<button class="btn btn-outline-secondary" (click)="decreaseZoom()" aria-label="Zoom out" i18n-aria-label>
|
||||||
|
<i-bs width="1em" height="1em" name="dash"></i-bs>
|
||||||
|
</button>
|
||||||
|
<select class="form-select" (change)="setZoom($event.target.value)" [ngModelOptions]="{standalone: true}" [ngModel]="currentZoom" aria-label="Zoom" i18n-aria-label>
|
||||||
|
@for (setting of zoomSettings; track setting) {
|
||||||
|
<option [value]="setting">
|
||||||
|
{{ getZoomSettingTitle(setting) }}
|
||||||
|
</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
<button class="btn btn-outline-secondary" (click)="increaseZoom()" aria-label="Zoom in" i18n-aria-label>
|
||||||
|
<i-bs width="1em" height="1em" name="plus"></i-bs>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
} @else {
|
} @else {
|
||||||
<object [data]="previewUrl() | safeUrl" class="preview-sticky" width="100%"></object>
|
<object [data]="previewUrl() | safeUrl" class="preview-sticky" width="100%"></object>
|
||||||
|
|||||||
@@ -60,6 +60,7 @@ import { TagService } from 'src/app/services/rest/tag.service'
|
|||||||
import { UserService } from 'src/app/services/rest/user.service'
|
import { UserService } from 'src/app/services/rest/user.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 { TrashService } from 'src/app/services/trash.service'
|
||||||
import { WebsocketStatusService } from 'src/app/services/websocket-status.service'
|
import { WebsocketStatusService } from 'src/app/services/websocket-status.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'
|
||||||
@@ -137,6 +138,7 @@ describe('DocumentDetailComponent', () => {
|
|||||||
let openDocumentsService: OpenDocumentsService
|
let openDocumentsService: OpenDocumentsService
|
||||||
let modalService: NgbModal
|
let modalService: NgbModal
|
||||||
let toastService: ToastService
|
let toastService: ToastService
|
||||||
|
let trashService: TrashService
|
||||||
let documentListViewService: DocumentListViewService
|
let documentListViewService: DocumentListViewService
|
||||||
let settingsService: SettingsService
|
let settingsService: SettingsService
|
||||||
let customFieldsService: CustomFieldsService
|
let customFieldsService: CustomFieldsService
|
||||||
@@ -285,6 +287,7 @@ describe('DocumentDetailComponent', () => {
|
|||||||
documentService = TestBed.inject(DocumentService)
|
documentService = TestBed.inject(DocumentService)
|
||||||
modalService = TestBed.inject(NgbModal)
|
modalService = TestBed.inject(NgbModal)
|
||||||
toastService = TestBed.inject(ToastService)
|
toastService = TestBed.inject(ToastService)
|
||||||
|
trashService = TestBed.inject(TrashService)
|
||||||
documentListViewService = TestBed.inject(DocumentListViewService)
|
documentListViewService = TestBed.inject(DocumentListViewService)
|
||||||
settingsService = TestBed.inject(SettingsService)
|
settingsService = TestBed.inject(SettingsService)
|
||||||
settingsService.currentUser.set({ id: 1 })
|
settingsService.currentUser.set({ id: 1 })
|
||||||
@@ -775,7 +778,7 @@ describe('DocumentDetailComponent', () => {
|
|||||||
component.title.set('Foo Bar')
|
component.title.set('Foo Bar')
|
||||||
const closeSpy = jest.spyOn(component, 'close')
|
const closeSpy = jest.spyOn(component, 'close')
|
||||||
const patchSpy = jest.spyOn(documentService, 'patch')
|
const patchSpy = jest.spyOn(documentService, 'patch')
|
||||||
const toastSpy = jest.spyOn(toastService, 'showInfo')
|
const toastSpy = jest.spyOn(toastService, 'showSuccess')
|
||||||
patchSpy.mockImplementation((o) => of(doc))
|
patchSpy.mockImplementation((o) => of(doc))
|
||||||
component.save(true)
|
component.save(true)
|
||||||
expect(patchSpy).toHaveBeenCalled()
|
expect(patchSpy).toHaveBeenCalled()
|
||||||
@@ -790,7 +793,7 @@ describe('DocumentDetailComponent', () => {
|
|||||||
component.title.set('Foo Bar')
|
component.title.set('Foo Bar')
|
||||||
const closeSpy = jest.spyOn(component, 'close')
|
const closeSpy = jest.spyOn(component, 'close')
|
||||||
const patchSpy = jest.spyOn(documentService, 'patch')
|
const patchSpy = jest.spyOn(documentService, 'patch')
|
||||||
const toastSpy = jest.spyOn(toastService, 'showInfo')
|
const toastSpy = jest.spyOn(toastService, 'showSuccess')
|
||||||
patchSpy.mockImplementation((o) => of(doc))
|
patchSpy.mockImplementation((o) => of(doc))
|
||||||
component.save()
|
component.save()
|
||||||
expect(patchSpy).toHaveBeenCalled()
|
expect(patchSpy).toHaveBeenCalled()
|
||||||
@@ -836,7 +839,7 @@ describe('DocumentDetailComponent', () => {
|
|||||||
component.title.set('Foo Bar')
|
component.title.set('Foo Bar')
|
||||||
const closeSpy = jest.spyOn(component, 'close')
|
const closeSpy = jest.spyOn(component, 'close')
|
||||||
const patchSpy = jest.spyOn(documentService, 'patch')
|
const patchSpy = jest.spyOn(documentService, 'patch')
|
||||||
const toastSpy = jest.spyOn(toastService, 'showInfo')
|
const toastSpy = jest.spyOn(toastService, 'showSuccess')
|
||||||
patchSpy.mockImplementation(() =>
|
patchSpy.mockImplementation(() =>
|
||||||
throwError(() => new Error('failed to save'))
|
throwError(() => new Error('failed to save'))
|
||||||
)
|
)
|
||||||
@@ -957,12 +960,16 @@ describe('DocumentDetailComponent', () => {
|
|||||||
const modalSpy = jest.spyOn(modalService, 'open')
|
const modalSpy = jest.spyOn(modalService, 'open')
|
||||||
const deleteSpy = jest.spyOn(documentService, 'delete')
|
const deleteSpy = jest.spyOn(documentService, 'delete')
|
||||||
deleteSpy.mockReturnValue(of(true))
|
deleteSpy.mockReturnValue(of(true))
|
||||||
|
const notifySpy = jest
|
||||||
|
.spyOn(trashService, 'notifyMovedToTrash')
|
||||||
|
.mockImplementation(() => {})
|
||||||
component.delete()
|
component.delete()
|
||||||
expect(modalSpy).toHaveBeenCalled()
|
expect(modalSpy).toHaveBeenCalled()
|
||||||
const modalCloseSpy = jest.spyOn(openModal, 'close')
|
const modalCloseSpy = jest.spyOn(openModal, 'close')
|
||||||
openModal.componentInstance.confirmClicked.next()
|
openModal.componentInstance.confirmClicked.next()
|
||||||
expect(deleteSpy).toHaveBeenCalled()
|
expect(deleteSpy).toHaveBeenCalled()
|
||||||
expect(modalCloseSpy).toHaveBeenCalled()
|
expect(modalCloseSpy).toHaveBeenCalled()
|
||||||
|
expect(notifySpy).toHaveBeenCalledWith(expect.any(String), [doc.id])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should allow retry delete if error', () => {
|
it('should allow retry delete if error', () => {
|
||||||
@@ -1893,6 +1900,196 @@ describe('DocumentDetailComponent', () => {
|
|||||||
expect(toastSpy).toHaveBeenCalledWith('Document reloaded.')
|
expect(toastSpy).toHaveBeenCalledWith('Document reloaded.')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should run zone OCR, report results and reload the document', () => {
|
||||||
|
component.documentId.set(doc.id)
|
||||||
|
component.document.set(doc)
|
||||||
|
jest.spyOn(documentService, 'runZoneOcr').mockReturnValue(
|
||||||
|
of({
|
||||||
|
results: [
|
||||||
|
{ zone: 'Total', value: '102.10' },
|
||||||
|
{ zone: 'Due', value: '' },
|
||||||
|
],
|
||||||
|
} as any)
|
||||||
|
)
|
||||||
|
const loadSpy = jest
|
||||||
|
.spyOn(component as any, 'loadDocument')
|
||||||
|
.mockImplementation(() => {})
|
||||||
|
const notifySpy = jest.spyOn(component.docChangeNotifier, 'next')
|
||||||
|
const toastSpy = jest.spyOn(toastService, 'showInfo')
|
||||||
|
const modalSpy = jest.spyOn(modalService, 'open')
|
||||||
|
|
||||||
|
component.runZoneOcrWithOptions()
|
||||||
|
const dialog = modalSpy.mock.results[0].value.componentInstance
|
||||||
|
expect(dialog.message).toBe(
|
||||||
|
'Fields filled by the template will be overwritten.'
|
||||||
|
)
|
||||||
|
dialog.template = 3
|
||||||
|
dialog.confirmClicked.emit()
|
||||||
|
|
||||||
|
expect(documentService.runZoneOcr).toHaveBeenCalledWith(doc.id, 3)
|
||||||
|
expect(toastSpy).toHaveBeenCalledWith(
|
||||||
|
'Filled 1 of 2 fields. No value found for: Due'
|
||||||
|
)
|
||||||
|
expect(notifySpy).toHaveBeenCalledWith(doc.id)
|
||||||
|
expect(loadSpy).toHaveBeenCalledWith(doc.id, true, expect.any(Function))
|
||||||
|
expect(component.zoneOcrRunning()).toBe(true)
|
||||||
|
loadSpy.mock.calls[0][2]()
|
||||||
|
expect(component.zoneOcrRunning()).toBe(false)
|
||||||
|
expect(component.networkActive()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should summarize zone OCR results', () => {
|
||||||
|
component.documentId.set(doc.id)
|
||||||
|
component.document.set(doc)
|
||||||
|
jest.spyOn(component as any, 'loadDocument').mockImplementation(() => {})
|
||||||
|
const runSpy = jest.spyOn(documentService, 'runZoneOcr')
|
||||||
|
const toastSpy = jest.spyOn(toastService, 'showInfo')
|
||||||
|
const modalSpy = jest.spyOn(modalService, 'open')
|
||||||
|
|
||||||
|
runSpy.mockReturnValueOnce(of({ status: 'completed', results: [] }))
|
||||||
|
component.runZoneOcr()
|
||||||
|
expect(modalSpy).not.toHaveBeenCalled()
|
||||||
|
expect(runSpy).toHaveBeenCalledWith(doc.id, null)
|
||||||
|
expect(toastSpy).toHaveBeenLastCalledWith(
|
||||||
|
'Zone OCR found no fields to fill.'
|
||||||
|
)
|
||||||
|
|
||||||
|
component.networkActive.set(false)
|
||||||
|
runSpy.mockReturnValueOnce(
|
||||||
|
of({
|
||||||
|
status: 'completed',
|
||||||
|
results: [
|
||||||
|
{ template: 'T', zone: 'Total', custom_field: 'Amount', value: 0 },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
)
|
||||||
|
component.runZoneOcr()
|
||||||
|
expect(toastSpy).toHaveBeenLastCalledWith('Filled 1 of 1 fields.')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should confirm before running zone OCR over unsaved changes', () => {
|
||||||
|
component.documentId.set(doc.id)
|
||||||
|
component.document.set({ ...doc, __changedFields: ['title'] })
|
||||||
|
component.documentForm.patchValue({ title: 'Unsaved title' })
|
||||||
|
const runSpy = jest.spyOn(documentService, 'runZoneOcr')
|
||||||
|
const modalSpy = jest.spyOn(modalService, 'open')
|
||||||
|
|
||||||
|
component.runZoneOcr()
|
||||||
|
|
||||||
|
const modal = modalSpy.mock.results[0].value
|
||||||
|
expect(modal.componentInstance.message).toBe(
|
||||||
|
'Running Zone OCR will discard them.'
|
||||||
|
)
|
||||||
|
expect(runSpy).not.toHaveBeenCalled()
|
||||||
|
modal.componentInstance.cancel()
|
||||||
|
expect(runSpy).not.toHaveBeenCalled()
|
||||||
|
expect(component.documentForm.get('title').value).toBe('Unsaved title')
|
||||||
|
expect(component.zoneOcrRunning()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should warn about unsaved changes in the zone OCR options dialog', () => {
|
||||||
|
component.documentId.set(doc.id)
|
||||||
|
component.document.set({ ...doc, __changedFields: ['title'] })
|
||||||
|
const runSpy = jest.spyOn(documentService, 'runZoneOcr')
|
||||||
|
const modalSpy = jest.spyOn(modalService, 'open')
|
||||||
|
|
||||||
|
component.runZoneOcrWithOptions()
|
||||||
|
|
||||||
|
const dialog = modalSpy.mock.results[0].value.componentInstance
|
||||||
|
expect(dialog.message).toBe('Running Zone OCR will discard them.')
|
||||||
|
expect(dialog.btnClass).toBe('btn-warning')
|
||||||
|
dialog.cancel()
|
||||||
|
expect(runSpy).not.toHaveBeenCalled()
|
||||||
|
component.networkActive.set(true)
|
||||||
|
component.runZoneOcrWithOptions()
|
||||||
|
expect(modalSpy).toHaveBeenCalledTimes(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not allow saving or a second zone OCR run while one is running', () => {
|
||||||
|
component.documentId.set(doc.id)
|
||||||
|
component.document.set({ ...doc, __changedFields: ['title'] })
|
||||||
|
const pending = new Subject<any>()
|
||||||
|
const runSpy = jest
|
||||||
|
.spyOn(documentService, 'runZoneOcr')
|
||||||
|
.mockReturnValue(pending)
|
||||||
|
const modalSpy = jest.spyOn(modalService, 'open')
|
||||||
|
|
||||||
|
component.runZoneOcr()
|
||||||
|
modalSpy.mock.results[0].value.componentInstance.confirmClicked.emit()
|
||||||
|
|
||||||
|
expect(runSpy).toHaveBeenCalledWith(doc.id, null)
|
||||||
|
expect(component.zoneOcrRunning()).toBe(true)
|
||||||
|
expect(component.networkActive()).toBe(true)
|
||||||
|
const saveSpy = jest.spyOn(documentService, 'patch')
|
||||||
|
component.save()
|
||||||
|
expect(saveSpy).not.toHaveBeenCalled()
|
||||||
|
component.runZoneOcr()
|
||||||
|
expect(modalSpy).toHaveBeenCalledTimes(1)
|
||||||
|
expect(runSpy).toHaveBeenCalledTimes(1)
|
||||||
|
pending.error(new Error('failed'))
|
||||||
|
expect(component.zoneOcrRunning()).toBe(false)
|
||||||
|
expect(component.networkActive()).toBe(false)
|
||||||
|
expect(component.document().__changedFields).toEqual(['title'])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not reload after zone OCR if another document is open', () => {
|
||||||
|
component.documentId.set(doc.id)
|
||||||
|
component.document.set({ ...doc, __changedFields: [] })
|
||||||
|
const pending = new Subject<any>()
|
||||||
|
jest.spyOn(documentService, 'runZoneOcr').mockReturnValue(pending)
|
||||||
|
const loadSpy = jest.spyOn(component as any, 'loadDocument')
|
||||||
|
|
||||||
|
component.runZoneOcr()
|
||||||
|
component.documentId.set(99)
|
||||||
|
pending.next({ results: [] })
|
||||||
|
|
||||||
|
expect(loadSpy).not.toHaveBeenCalled()
|
||||||
|
expect(component.zoneOcrRunning()).toBe(false)
|
||||||
|
pending.complete()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should keep unsaved changes when zone OCR is skipped', () => {
|
||||||
|
component.documentId.set(doc.id)
|
||||||
|
component.document.set({ ...doc, __changedFields: ['title'] })
|
||||||
|
component.documentForm.patchValue({ title: 'Unsaved title' })
|
||||||
|
const modalSpy = jest.spyOn(modalService, 'open')
|
||||||
|
jest
|
||||||
|
.spyOn(documentService, 'runZoneOcr')
|
||||||
|
.mockReturnValue(of({ status: 'skipped', results: [] }))
|
||||||
|
const loadSpy = jest.spyOn(component as any, 'loadDocument')
|
||||||
|
const infoSpy = jest.spyOn(toastService, 'showInfo')
|
||||||
|
|
||||||
|
component.runZoneOcr()
|
||||||
|
modalSpy.mock.results[0].value.componentInstance.confirm()
|
||||||
|
|
||||||
|
expect(loadSpy).not.toHaveBeenCalled()
|
||||||
|
expect(component.documentForm.get('title').value).toBe('Unsaved title')
|
||||||
|
expect(component.zoneOcrRunning()).toBe(false)
|
||||||
|
expect(component.networkActive()).toBe(false)
|
||||||
|
expect(infoSpy).toHaveBeenCalledWith(
|
||||||
|
'No OCR template applies to this document.'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should show an error when zone OCR fails', () => {
|
||||||
|
component.document.set(doc)
|
||||||
|
jest
|
||||||
|
.spyOn(documentService, 'runZoneOcr')
|
||||||
|
.mockReturnValue(throwError(() => new Error('nope')))
|
||||||
|
const loadSpy = jest.spyOn(component as any, 'loadDocument')
|
||||||
|
const errorSpy = jest.spyOn(toastService, 'showError')
|
||||||
|
|
||||||
|
component.runZoneOcr()
|
||||||
|
|
||||||
|
expect(errorSpy).toHaveBeenCalledWith(
|
||||||
|
'Error running zone OCR.',
|
||||||
|
expect.any(Error)
|
||||||
|
)
|
||||||
|
expect(loadSpy).not.toHaveBeenCalled()
|
||||||
|
expect(component.zoneOcrRunning()).toBe(false)
|
||||||
|
expect(component.networkActive()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
it('should change preview element by render type', () => {
|
it('should change preview element by render type', () => {
|
||||||
initNormally()
|
initNormally()
|
||||||
component.document.update((document) => ({
|
component.document.update((document) => ({
|
||||||
|
|||||||
@@ -64,6 +64,7 @@ import {
|
|||||||
FILTER_STORAGE_PATH,
|
FILTER_STORAGE_PATH,
|
||||||
} from 'src/app/data/filter-rule-type'
|
} from 'src/app/data/filter-rule-type'
|
||||||
import { ObjectWithId } from 'src/app/data/object-with-id'
|
import { ObjectWithId } from 'src/app/data/object-with-id'
|
||||||
|
import { OcrZoneRunResult } from 'src/app/data/ocr-template'
|
||||||
import { StoragePath } from 'src/app/data/storage-path'
|
import { StoragePath } from 'src/app/data/storage-path'
|
||||||
import { Tag } from 'src/app/data/tag'
|
import { Tag } from 'src/app/data/tag'
|
||||||
import { SETTINGS_KEYS, SuggestionSource } from 'src/app/data/ui-settings'
|
import { SETTINGS_KEYS, SuggestionSource } from 'src/app/data/ui-settings'
|
||||||
@@ -95,6 +96,7 @@ import { TagService } from 'src/app/services/rest/tag.service'
|
|||||||
import { UserService } from 'src/app/services/rest/user.service'
|
import { UserService } from 'src/app/services/rest/user.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 { TrashService } from 'src/app/services/trash.service'
|
||||||
import { WebsocketStatusService } from 'src/app/services/websocket-status.service'
|
import { WebsocketStatusService } from 'src/app/services/websocket-status.service'
|
||||||
import { getFilenameFromContentDisposition } from 'src/app/utils/http'
|
import { getFilenameFromContentDisposition } from 'src/app/utils/http'
|
||||||
import { ISODateAdapter } from 'src/app/utils/ngb-iso-date-adapter'
|
import { ISODateAdapter } from 'src/app/utils/ngb-iso-date-adapter'
|
||||||
@@ -103,6 +105,7 @@ import { DocumentDetailFieldID } from '../admin/settings/settings.component'
|
|||||||
import { ConfirmDialogComponent } from '../common/confirm-dialog/confirm-dialog.component'
|
import { ConfirmDialogComponent } from '../common/confirm-dialog/confirm-dialog.component'
|
||||||
import { PasswordRemovalConfirmDialogComponent } from '../common/confirm-dialog/password-removal-confirm-dialog/password-removal-confirm-dialog.component'
|
import { PasswordRemovalConfirmDialogComponent } from '../common/confirm-dialog/password-removal-confirm-dialog/password-removal-confirm-dialog.component'
|
||||||
import { ReprocessConfirmDialogComponent } from '../common/confirm-dialog/reprocess-confirm-dialog/reprocess-confirm-dialog.component'
|
import { ReprocessConfirmDialogComponent } from '../common/confirm-dialog/reprocess-confirm-dialog/reprocess-confirm-dialog.component'
|
||||||
|
import { ZoneOcrConfirmDialogComponent } from '../common/confirm-dialog/zone-ocr-confirm-dialog/zone-ocr-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 { CorrespondentEditDialogComponent } from '../common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component'
|
import { CorrespondentEditDialogComponent } from '../common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component'
|
||||||
import { DocumentTypeEditDialogComponent } from '../common/edit-dialog/document-type-edit-dialog/document-type-edit-dialog.component'
|
import { DocumentTypeEditDialogComponent } from '../common/edit-dialog/document-type-edit-dialog/document-type-edit-dialog.component'
|
||||||
@@ -221,6 +224,7 @@ export class DocumentDetailComponent
|
|||||||
private documentListViewService = inject(DocumentListViewService)
|
private documentListViewService = inject(DocumentListViewService)
|
||||||
private documentTitlePipe = inject(DocumentTitlePipe)
|
private documentTitlePipe = inject(DocumentTitlePipe)
|
||||||
private toastService = inject(ToastService)
|
private toastService = inject(ToastService)
|
||||||
|
private trashService = inject(TrashService)
|
||||||
private settings = inject(SettingsService)
|
private settings = inject(SettingsService)
|
||||||
private storagePathService = inject(StoragePathService)
|
private storagePathService = inject(StoragePathService)
|
||||||
private permissionsService = inject(PermissionsService)
|
private permissionsService = inject(PermissionsService)
|
||||||
@@ -264,6 +268,7 @@ export class DocumentDetailComponent
|
|||||||
readonly error = signal<any>(undefined)
|
readonly error = signal<any>(undefined)
|
||||||
|
|
||||||
readonly networkActive = signal(false)
|
readonly networkActive = signal(false)
|
||||||
|
readonly zoneOcrRunning = signal(false)
|
||||||
|
|
||||||
readonly documentId = signal<number>(undefined)
|
readonly documentId = signal<number>(undefined)
|
||||||
readonly document = signal<Document>(undefined)
|
readonly document = signal<Document>(undefined)
|
||||||
@@ -541,7 +546,11 @@ export class DocumentDetailComponent
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private loadDocument(documentId: number, forceRemote: boolean = false): void {
|
private loadDocument(
|
||||||
|
documentId: number,
|
||||||
|
forceRemote: boolean = false,
|
||||||
|
onComplete?: () => void
|
||||||
|
): void {
|
||||||
let redirectedToRoot = false
|
let redirectedToRoot = false
|
||||||
this.closeIncomingUpdateModal()
|
this.closeIncomingUpdateModal()
|
||||||
this.pendingIncomingUpdate = null
|
this.pendingIncomingUpdate = null
|
||||||
@@ -598,7 +607,8 @@ export class DocumentDetailComponent
|
|||||||
}),
|
}),
|
||||||
first(),
|
first(),
|
||||||
takeUntil(this.unsubscribeNotifier),
|
takeUntil(this.unsubscribeNotifier),
|
||||||
takeUntil(this.docChangeNotifier)
|
takeUntil(this.docChangeNotifier),
|
||||||
|
finalize(() => onComplete?.())
|
||||||
)
|
)
|
||||||
.subscribe({
|
.subscribe({
|
||||||
next: (doc) => {
|
next: (doc) => {
|
||||||
@@ -1332,6 +1342,7 @@ export class DocumentDetailComponent
|
|||||||
}
|
}
|
||||||
|
|
||||||
save(close: boolean = false, savedCallback: () => void = null) {
|
save(close: boolean = false, savedCallback: () => void = null) {
|
||||||
|
if (this.zoneOcrRunning()) return
|
||||||
this.networkActive.set(true)
|
this.networkActive.set(true)
|
||||||
;(document.activeElement as HTMLElement)?.dispatchEvent(new Event('change'))
|
;(document.activeElement as HTMLElement)?.dispatchEvent(new Event('change'))
|
||||||
this.documentsService
|
this.documentsService
|
||||||
@@ -1349,7 +1360,7 @@ export class DocumentDetailComponent
|
|||||||
this.store.next(newValues)
|
this.store.next(newValues)
|
||||||
this.openDocumentService.setDirty(this.document(), false)
|
this.openDocumentService.setDirty(this.document(), false)
|
||||||
this.openDocumentService.save()
|
this.openDocumentService.save()
|
||||||
this.toastService.showInfo(
|
this.toastService.showSuccess(
|
||||||
$localize`Document "${newValues.title}" saved successfully.`
|
$localize`Document "${newValues.title}" saved successfully.`
|
||||||
)
|
)
|
||||||
this.networkActive.set(false)
|
this.networkActive.set(false)
|
||||||
@@ -1377,7 +1388,7 @@ export class DocumentDetailComponent
|
|||||||
if (!canEdit) {
|
if (!canEdit) {
|
||||||
// document was 'given away'
|
// document was 'given away'
|
||||||
this.openDocumentService.setDirty(this.document(), false)
|
this.openDocumentService.setDirty(this.document(), false)
|
||||||
this.toastService.showInfo(
|
this.toastService.showSuccess(
|
||||||
$localize`Document "${this.document().title}" saved successfully.`
|
$localize`Document "${this.document().title}" saved successfully.`
|
||||||
)
|
)
|
||||||
this.close()
|
this.close()
|
||||||
@@ -1490,6 +1501,10 @@ export class DocumentDetailComponent
|
|||||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||||
.subscribe({
|
.subscribe({
|
||||||
next: () => {
|
next: () => {
|
||||||
|
this.trashService.notifyMovedToTrash(
|
||||||
|
$localize`Moved "${this.document().title}" to the trash`,
|
||||||
|
[this.document().id]
|
||||||
|
)
|
||||||
modal.close()
|
modal.close()
|
||||||
this.close()
|
this.close()
|
||||||
},
|
},
|
||||||
@@ -1548,6 +1563,107 @@ export class DocumentDetailComponent
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Runs every template that matches, confirming only over unsaved changes. */
|
||||||
|
runZoneOcr() {
|
||||||
|
if (this.networkActive()) return
|
||||||
|
const documentId = this.documentId()
|
||||||
|
if (!this.hasLocalEdits(this.document())) {
|
||||||
|
this.executeZoneOcr(documentId, null)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const modal = this.modalService.open(ConfirmDialogComponent, {
|
||||||
|
backdrop: 'static',
|
||||||
|
})
|
||||||
|
modal.componentInstance.title = $localize`Run Zone OCR`
|
||||||
|
modal.componentInstance.messageBold = $localize`You have unsaved changes.`
|
||||||
|
modal.componentInstance.message = $localize`Running Zone OCR will discard them.`
|
||||||
|
modal.componentInstance.btnClass = 'btn-warning'
|
||||||
|
modal.componentInstance.btnCaption = $localize`Run Zone OCR`
|
||||||
|
modal.componentInstance.confirmClicked
|
||||||
|
.pipe(first(), takeUntil(this.unsubscribeNotifier))
|
||||||
|
.subscribe(() => {
|
||||||
|
modal.close()
|
||||||
|
if (this.documentId() === documentId && !this.networkActive()) {
|
||||||
|
this.executeZoneOcr(documentId, null)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Opens the dialog to pick which template to run. */
|
||||||
|
runZoneOcrWithOptions() {
|
||||||
|
if (this.networkActive()) return
|
||||||
|
const documentId = this.documentId()
|
||||||
|
const modal = this.modalService.open(ZoneOcrConfirmDialogComponent, {
|
||||||
|
backdrop: 'static',
|
||||||
|
})
|
||||||
|
const dialog = modal.componentInstance as ZoneOcrConfirmDialogComponent
|
||||||
|
dialog.title = $localize`Run Zone OCR`
|
||||||
|
dialog.btnCaption = $localize`Run Zone OCR`
|
||||||
|
if (this.hasLocalEdits(this.document())) {
|
||||||
|
dialog.messageBold = $localize`You have unsaved changes.`
|
||||||
|
dialog.message = $localize`Running Zone OCR will discard them.`
|
||||||
|
dialog.btnClass = 'btn-warning'
|
||||||
|
} else {
|
||||||
|
dialog.message = $localize`Fields filled by the template will be overwritten.`
|
||||||
|
}
|
||||||
|
dialog.confirmClicked
|
||||||
|
.pipe(first(), takeUntil(this.unsubscribeNotifier))
|
||||||
|
.subscribe(() => {
|
||||||
|
modal.close()
|
||||||
|
if (this.documentId() === documentId && !this.networkActive()) {
|
||||||
|
this.executeZoneOcr(documentId, dialog.template)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private executeZoneOcr(documentId: number, templateId: number | null) {
|
||||||
|
this.zoneOcrRunning.set(true)
|
||||||
|
this.networkActive.set(true)
|
||||||
|
this.documentsService
|
||||||
|
.runZoneOcr(documentId, templateId)
|
||||||
|
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||||
|
.subscribe({
|
||||||
|
next: (response) => {
|
||||||
|
if (
|
||||||
|
this.documentId() !== documentId ||
|
||||||
|
response.status === 'skipped'
|
||||||
|
) {
|
||||||
|
this.finishZoneOcr()
|
||||||
|
if (response.status === 'skipped') {
|
||||||
|
this.toastService.showInfo(
|
||||||
|
$localize`No OCR template applies to this document.`
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.toastService.showInfo(this.zoneOcrSummary(response.results))
|
||||||
|
this.docChangeNotifier.next(documentId)
|
||||||
|
this.loadDocument(documentId, true, () => this.finishZoneOcr())
|
||||||
|
},
|
||||||
|
error: (error) => {
|
||||||
|
this.finishZoneOcr()
|
||||||
|
this.toastService.showError($localize`Error running zone OCR.`, error)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private zoneOcrSummary(results: OcrZoneRunResult[]): string {
|
||||||
|
if (!results.length) return $localize`Zone OCR found no fields to fill.`
|
||||||
|
const missed = results
|
||||||
|
.filter((result) => !`${result.value ?? ''}`.trim())
|
||||||
|
.map((result) => result.zone)
|
||||||
|
const filled = results.length - missed.length
|
||||||
|
return missed.length
|
||||||
|
? $localize`Filled ${filled} of ${results.length} fields. No value found for: ${missed.join(', ')}`
|
||||||
|
: $localize`Filled ${filled} of ${results.length} fields.`
|
||||||
|
}
|
||||||
|
|
||||||
|
private finishZoneOcr() {
|
||||||
|
this.zoneOcrRunning.set(false)
|
||||||
|
this.networkActive.set(false)
|
||||||
|
this.flushPendingIncomingUpdate()
|
||||||
|
}
|
||||||
|
|
||||||
private getSelectedNonLatestVersionId(): number | null {
|
private getSelectedNonLatestVersionId(): number | null {
|
||||||
const versions = this.document()?.versions ?? []
|
const versions = this.document()?.versions ?? []
|
||||||
if (!versions.length || !this.selectedVersionId()) {
|
if (!versions.length || !this.selectedVersionId()) {
|
||||||
|
|||||||
+5
-2
@@ -33,7 +33,9 @@ describe('DocumentVersionDropdownComponent', () => {
|
|||||||
| 'updateVersionLabel'
|
| 'updateVersionLabel'
|
||||||
>
|
>
|
||||||
>
|
>
|
||||||
let toastService: jest.Mocked<Pick<ToastService, 'showError' | 'showInfo'>>
|
let toastService: jest.Mocked<
|
||||||
|
Pick<ToastService, 'showError' | 'showInfo' | 'showSuccess'>
|
||||||
|
>
|
||||||
let finished$: Subject<{ taskId: string }>
|
let finished$: Subject<{ taskId: string }>
|
||||||
let failed$: Subject<{ taskId: string; message?: string }>
|
let failed$: Subject<{ taskId: string; message?: string }>
|
||||||
let modalService: jest.Mocked<Pick<NgbModal, 'open'>>
|
let modalService: jest.Mocked<Pick<NgbModal, 'open'>>
|
||||||
@@ -55,6 +57,7 @@ describe('DocumentVersionDropdownComponent', () => {
|
|||||||
toastService = {
|
toastService = {
|
||||||
showError: jest.fn(),
|
showError: jest.fn(),
|
||||||
showInfo: jest.fn(),
|
showInfo: jest.fn(),
|
||||||
|
showSuccess: jest.fn(),
|
||||||
}
|
}
|
||||||
permissionsService = {
|
permissionsService = {
|
||||||
currentUserCan: jest.fn().mockReturnValue(true),
|
currentUserCan: jest.fn().mockReturnValue(true),
|
||||||
@@ -416,6 +419,6 @@ describe('DocumentVersionDropdownComponent', () => {
|
|||||||
expect(selectedEmitSpy).toHaveBeenCalledWith(2)
|
expect(selectedEmitSpy).toHaveBeenCalledWith(2)
|
||||||
expect(component.newVersionLabel).toEqual('')
|
expect(component.newVersionLabel).toEqual('')
|
||||||
expect(modal.close).toHaveBeenCalled()
|
expect(modal.close).toHaveBeenCalled()
|
||||||
expect(toastService.showInfo).toHaveBeenCalled()
|
expect(toastService.showSuccess).toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
+1
-1
@@ -320,7 +320,7 @@ export class DocumentVersionDropdownComponent
|
|||||||
}
|
}
|
||||||
this.newVersionLabel = ''
|
this.newVersionLabel = ''
|
||||||
modal.close()
|
modal.close()
|
||||||
this.toastService.showInfo(
|
this.toastService.showSuccess(
|
||||||
$localize`Existing document added as a version.`
|
$localize`Existing document added as a version.`
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -98,6 +98,11 @@
|
|||||||
<button ngbDropdownItem (click)="mergeSelectedAsVersions()" [disabled]="!userOwnsAll || !userCanEditAll || !userCanDelete || list.allSelected || list.selectedCount < 2">
|
<button ngbDropdownItem (click)="mergeSelectedAsVersions()" [disabled]="!userOwnsAll || !userCanEditAll || !userCanDelete || list.allSelected || list.selectedCount < 2">
|
||||||
<i-bs name="journal-bookmark-fill" class="me-1"></i-bs><ng-container i18n>Merge as versions</ng-container>
|
<i-bs name="journal-bookmark-fill" class="me-1"></i-bs><ng-container i18n>Merge as versions</ng-container>
|
||||||
</button>
|
</button>
|
||||||
|
@if (permissionService.currentUserCan(PermissionAction.View, PermissionType.OcrTemplate)) {
|
||||||
|
<button ngbDropdownItem (click)="runZoneOcrSelected()" [disabled]="!userCanEditAll">
|
||||||
|
<i-bs name="bounding-box-circles" class="me-1"></i-bs><ng-container i18n>Run Zone OCR</ng-container>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ import { TagService } from 'src/app/services/rest/tag.service'
|
|||||||
import { UserService } from 'src/app/services/rest/user.service'
|
import { UserService } from 'src/app/services/rest/user.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 { TrashService } from 'src/app/services/trash.service'
|
||||||
import { environment } from 'src/environments/environment'
|
import { environment } from 'src/environments/environment'
|
||||||
import { CorrespondentEditDialogComponent } from '../../common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component'
|
import { CorrespondentEditDialogComponent } from '../../common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component'
|
||||||
import { CustomFieldEditDialogComponent } from '../../common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component'
|
import { CustomFieldEditDialogComponent } from '../../common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component'
|
||||||
@@ -74,6 +75,7 @@ describe('BulkEditorComponent', () => {
|
|||||||
let documentListViewService: DocumentListViewService
|
let documentListViewService: DocumentListViewService
|
||||||
let documentService: DocumentService
|
let documentService: DocumentService
|
||||||
let toastService: ToastService
|
let toastService: ToastService
|
||||||
|
let trashService: TrashService
|
||||||
let modalService: NgbModal
|
let modalService: NgbModal
|
||||||
let tagService: TagService
|
let tagService: TagService
|
||||||
let correspondentsService: CorrespondentService
|
let correspondentsService: CorrespondentService
|
||||||
@@ -185,6 +187,7 @@ describe('BulkEditorComponent', () => {
|
|||||||
documentListViewService = TestBed.inject(DocumentListViewService)
|
documentListViewService = TestBed.inject(DocumentListViewService)
|
||||||
documentService = TestBed.inject(DocumentService)
|
documentService = TestBed.inject(DocumentService)
|
||||||
toastService = TestBed.inject(ToastService)
|
toastService = TestBed.inject(ToastService)
|
||||||
|
trashService = TestBed.inject(TrashService)
|
||||||
modalService = TestBed.inject(NgbModal)
|
modalService = TestBed.inject(NgbModal)
|
||||||
tagService = TestBed.inject(TagService)
|
tagService = TestBed.inject(TagService)
|
||||||
correspondentsService = TestBed.inject(CorrespondentService)
|
correspondentsService = TestBed.inject(CorrespondentService)
|
||||||
@@ -1164,10 +1167,14 @@ describe('BulkEditorComponent', () => {
|
|||||||
let req = httpTestingController.expectOne(
|
let req = httpTestingController.expectOne(
|
||||||
`${environment.apiBaseUrl}documents/delete/`
|
`${environment.apiBaseUrl}documents/delete/`
|
||||||
)
|
)
|
||||||
|
const notifySpy = jest
|
||||||
|
.spyOn(trashService, 'notifyMovedToTrash')
|
||||||
|
.mockImplementation(() => {})
|
||||||
req.flush(true)
|
req.flush(true)
|
||||||
expect(req.request.body).toEqual({
|
expect(req.request.body).toEqual({
|
||||||
documents: [3, 4],
|
documents: [3, 4],
|
||||||
})
|
})
|
||||||
|
expect(notifySpy).toHaveBeenCalledWith(expect.any(String), [3, 4])
|
||||||
httpTestingController.match(
|
httpTestingController.match(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
||||||
) // list reload
|
) // list reload
|
||||||
@@ -1177,10 +1184,19 @@ describe('BulkEditorComponent', () => {
|
|||||||
|
|
||||||
component.showConfirmationDialogs = false
|
component.showConfirmationDialogs = false
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
|
jest
|
||||||
|
.spyOn(documentListViewService, 'allSelected', 'get')
|
||||||
|
.mockReturnValue(true)
|
||||||
component.applyDelete()
|
component.applyDelete()
|
||||||
req = httpTestingController.expectOne(
|
req = httpTestingController.expectOne(
|
||||||
`${environment.apiBaseUrl}documents/delete/`
|
`${environment.apiBaseUrl}documents/delete/`
|
||||||
)
|
)
|
||||||
|
req.flush(true)
|
||||||
|
// ids aren't known when all matching documents are selected, so no undo
|
||||||
|
expect(notifySpy).toHaveBeenLastCalledWith(expect.any(String), null)
|
||||||
|
httpTestingController.match(
|
||||||
|
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
||||||
|
) // list reload
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should not be accessible with insufficient global permissions', () => {
|
it('should not be accessible with insufficient global permissions', () => {
|
||||||
@@ -1270,6 +1286,53 @@ describe('BulkEditorComponent', () => {
|
|||||||
) // listAllFilteredIds
|
) // listAllFilteredIds
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should support run zone ocr', () => {
|
||||||
|
let modal: NgbModalRef
|
||||||
|
modalService.activeInstances.subscribe((m) => (modal = m[0]))
|
||||||
|
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
|
||||||
|
jest
|
||||||
|
.spyOn(documentListViewService, 'documents', 'get')
|
||||||
|
.mockReturnValue([{ id: 3 }, { id: 4 }])
|
||||||
|
jest
|
||||||
|
.spyOn(documentListViewService, 'selected', 'get')
|
||||||
|
.mockReturnValue(new Set([3, 4]))
|
||||||
|
jest
|
||||||
|
.spyOn(permissionsService, 'currentUserHasObjectPermissions')
|
||||||
|
.mockReturnValue(true)
|
||||||
|
fixture.detectChanges()
|
||||||
|
component.runZoneOcrSelected()
|
||||||
|
expect(modal).not.toBeUndefined()
|
||||||
|
modal.componentInstance.confirm()
|
||||||
|
let req = httpTestingController.expectOne(
|
||||||
|
`${environment.apiBaseUrl}documents/run_zone_ocr/`
|
||||||
|
)
|
||||||
|
req.flush(true)
|
||||||
|
expect(req.request.body).toEqual({
|
||||||
|
documents: [3, 4],
|
||||||
|
})
|
||||||
|
|
||||||
|
component.runZoneOcrSelected()
|
||||||
|
modal.componentInstance.template = 3
|
||||||
|
modal.componentInstance.confirm()
|
||||||
|
req = httpTestingController.expectOne(
|
||||||
|
`${environment.apiBaseUrl}documents/run_zone_ocr/`
|
||||||
|
)
|
||||||
|
req.flush(true)
|
||||||
|
expect(req.request.body).toEqual({
|
||||||
|
documents: [3, 4],
|
||||||
|
template: 3,
|
||||||
|
})
|
||||||
|
httpTestingController.match(
|
||||||
|
`${environment.apiBaseUrl}ocr_templates/?page=1&page_size=100000`
|
||||||
|
) // template list
|
||||||
|
httpTestingController.match(
|
||||||
|
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
||||||
|
) // list reload
|
||||||
|
httpTestingController.match(
|
||||||
|
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
||||||
|
) // listAllFilteredIds
|
||||||
|
})
|
||||||
|
|
||||||
it('should support rotate', () => {
|
it('should support rotate', () => {
|
||||||
let modal: NgbModalRef
|
let modal: NgbModalRef
|
||||||
modalService.activeInstances.subscribe((m) => (modal = m[0]))
|
modalService.activeInstances.subscribe((m) => (modal = m[0]))
|
||||||
@@ -1416,7 +1479,7 @@ describe('BulkEditorComponent', () => {
|
|||||||
const mergeAsVersionsSpy = jest
|
const mergeAsVersionsSpy = jest
|
||||||
.spyOn(documentService, 'mergeDocumentsAsVersions')
|
.spyOn(documentService, 'mergeDocumentsAsVersions')
|
||||||
.mockReturnValue(of(true))
|
.mockReturnValue(of(true))
|
||||||
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
|
const toastSuccessSpy = jest.spyOn(toastService, 'showSuccess')
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
|
|
||||||
component.mergeSelectedAsVersions()
|
component.mergeSelectedAsVersions()
|
||||||
@@ -1432,7 +1495,9 @@ describe('BulkEditorComponent', () => {
|
|||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
||||||
)
|
)
|
||||||
expect(documentListViewService.selected.size).toEqual(0)
|
expect(documentListViewService.selected.size).toEqual(0)
|
||||||
expect(toastInfoSpy).toHaveBeenCalledWith('Documents merged as versions.')
|
expect(toastSuccessSpy).toHaveBeenCalledWith(
|
||||||
|
'Documents merged as versions.'
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should not report success when merging documents as versions fails', () => {
|
it('should not report success when merging documents as versions fails', () => {
|
||||||
@@ -1458,7 +1523,7 @@ describe('BulkEditorComponent', () => {
|
|||||||
jest
|
jest
|
||||||
.spyOn(documentService, 'mergeDocumentsAsVersions')
|
.spyOn(documentService, 'mergeDocumentsAsVersions')
|
||||||
.mockReturnValue(throwError(() => new Error('failed')))
|
.mockReturnValue(throwError(() => new Error('failed')))
|
||||||
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
|
const toastSuccessSpy = jest.spyOn(toastService, 'showSuccess')
|
||||||
const toastErrorSpy = jest.spyOn(toastService, 'showError')
|
const toastErrorSpy = jest.spyOn(toastService, 'showError')
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
|
|
||||||
@@ -1467,7 +1532,7 @@ describe('BulkEditorComponent', () => {
|
|||||||
modal.componentInstance.confirm()
|
modal.componentInstance.confirm()
|
||||||
|
|
||||||
expect(toastErrorSpy).toHaveBeenCalled()
|
expect(toastErrorSpy).toHaveBeenCalled()
|
||||||
expect(toastInfoSpy).not.toHaveBeenCalled()
|
expect(toastSuccessSpy).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
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', () => {
|
||||||
@@ -1846,7 +1911,7 @@ describe('BulkEditorComponent', () => {
|
|||||||
.spyOn(documentListViewService, 'selected', 'get')
|
.spyOn(documentListViewService, 'selected', 'get')
|
||||||
.mockReturnValue(new Set([3, 4]))
|
.mockReturnValue(new Set([3, 4]))
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
const toastServiceShowInfoSpy = jest.spyOn(toastService, 'showInfo')
|
const toastServiceShowInfoSpy = jest.spyOn(toastService, 'showSuccess')
|
||||||
const toastServiceShowErrorSpy = jest.spyOn(toastService, 'showError')
|
const toastServiceShowErrorSpy = jest.spyOn(toastService, 'showError')
|
||||||
const listReloadSpy = jest.spyOn(documentListViewService, 'reload')
|
const listReloadSpy = jest.spyOn(documentListViewService, 'reload')
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,15 @@ import {
|
|||||||
} from '@ng-bootstrap/ng-bootstrap'
|
} from '@ng-bootstrap/ng-bootstrap'
|
||||||
import { saveAs } from 'file-saver'
|
import { saveAs } from 'file-saver'
|
||||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||||
import { first, map, Observable, Subject, switchMap, takeUntil } from 'rxjs'
|
import {
|
||||||
|
first,
|
||||||
|
map,
|
||||||
|
Observable,
|
||||||
|
Subject,
|
||||||
|
switchMap,
|
||||||
|
takeUntil,
|
||||||
|
tap,
|
||||||
|
} from 'rxjs'
|
||||||
import { ConfirmDialogComponent } from 'src/app/components/common/confirm-dialog/confirm-dialog.component'
|
import { ConfirmDialogComponent } from 'src/app/components/common/confirm-dialog/confirm-dialog.component'
|
||||||
import { CustomField } from 'src/app/data/custom-field'
|
import { CustomField } from 'src/app/data/custom-field'
|
||||||
import { MatchingModel } from 'src/app/data/matching-model'
|
import { MatchingModel } from 'src/app/data/matching-model'
|
||||||
@@ -49,12 +57,14 @@ import { StoragePathService } from 'src/app/services/rest/storage-path.service'
|
|||||||
import { TagService } from 'src/app/services/rest/tag.service'
|
import { TagService } from 'src/app/services/rest/tag.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 { TrashService } from 'src/app/services/trash.service'
|
||||||
import { flattenTags } from 'src/app/utils/flatten-tags'
|
import { flattenTags } from 'src/app/utils/flatten-tags'
|
||||||
import { queryParamsFromFilterRules } from 'src/app/utils/query-params'
|
import { queryParamsFromFilterRules } from 'src/app/utils/query-params'
|
||||||
import { MergeAsVersionsConfirmDialogComponent } from '../../common/confirm-dialog/merge-as-versions-confirm-dialog/merge-as-versions-confirm-dialog.component'
|
import { MergeAsVersionsConfirmDialogComponent } from '../../common/confirm-dialog/merge-as-versions-confirm-dialog/merge-as-versions-confirm-dialog.component'
|
||||||
import { MergeConfirmDialogComponent } from '../../common/confirm-dialog/merge-confirm-dialog/merge-confirm-dialog.component'
|
import { MergeConfirmDialogComponent } from '../../common/confirm-dialog/merge-confirm-dialog/merge-confirm-dialog.component'
|
||||||
import { ReprocessConfirmDialogComponent } from '../../common/confirm-dialog/reprocess-confirm-dialog/reprocess-confirm-dialog.component'
|
import { ReprocessConfirmDialogComponent } from '../../common/confirm-dialog/reprocess-confirm-dialog/reprocess-confirm-dialog.component'
|
||||||
import { RotateConfirmDialogComponent } from '../../common/confirm-dialog/rotate-confirm-dialog/rotate-confirm-dialog.component'
|
import { RotateConfirmDialogComponent } from '../../common/confirm-dialog/rotate-confirm-dialog/rotate-confirm-dialog.component'
|
||||||
|
import { ZoneOcrConfirmDialogComponent } from '../../common/confirm-dialog/zone-ocr-confirm-dialog/zone-ocr-confirm-dialog.component'
|
||||||
import { CorrespondentEditDialogComponent } from '../../common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component'
|
import { CorrespondentEditDialogComponent } from '../../common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component'
|
||||||
import { CustomFieldEditDialogComponent } from '../../common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component'
|
import { CustomFieldEditDialogComponent } from '../../common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component'
|
||||||
import { DocumentTypeEditDialogComponent } from '../../common/edit-dialog/document-type-edit-dialog/document-type-edit-dialog.component'
|
import { DocumentTypeEditDialogComponent } from '../../common/edit-dialog/document-type-edit-dialog/document-type-edit-dialog.component'
|
||||||
@@ -99,6 +109,7 @@ export class BulkEditorComponent
|
|||||||
private openDocumentService = inject(OpenDocumentsService)
|
private openDocumentService = inject(OpenDocumentsService)
|
||||||
private settings = inject(SettingsService)
|
private settings = inject(SettingsService)
|
||||||
private toastService = inject(ToastService)
|
private toastService = inject(ToastService)
|
||||||
|
private trashService = inject(TrashService)
|
||||||
private storagePathService = inject(StoragePathService)
|
private storagePathService = inject(StoragePathService)
|
||||||
private customFieldService = inject(CustomFieldsService)
|
private customFieldService = inject(CustomFieldsService)
|
||||||
public readonly permissionService = inject(PermissionsService)
|
public readonly permissionService = inject(PermissionsService)
|
||||||
@@ -306,7 +317,7 @@ export class BulkEditorComponent
|
|||||||
next: () => {
|
next: () => {
|
||||||
this.handleOperationSuccess(modal, options.clearSelection ?? false)
|
this.handleOperationSuccess(modal, options.clearSelection ?? false)
|
||||||
if (options.successMessage) {
|
if (options.successMessage) {
|
||||||
this.toastService.showInfo(options.successMessage)
|
this.toastService.showSuccess(options.successMessage)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
error: (error) => this.handleOperationError(modal, error),
|
error: (error) => this.handleOperationError(modal, error),
|
||||||
@@ -884,19 +895,33 @@ export class BulkEditorComponent
|
|||||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||||
.subscribe(() => {
|
.subscribe(() => {
|
||||||
modal.componentInstance.buttonsEnabled.set(false)
|
modal.componentInstance.buttonsEnabled.set(false)
|
||||||
this.executeDocumentAction(
|
this.deleteSelected(modal)
|
||||||
modal,
|
|
||||||
this.documentService.deleteDocuments(this.getSelectionQuery())
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
this.executeDocumentAction(
|
this.deleteSelected(null)
|
||||||
null,
|
|
||||||
this.documentService.deleteDocuments(this.getSelectionQuery())
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private deleteSelected(modal: NgbModalRef) {
|
||||||
|
const documents = this.list.allSelected
|
||||||
|
? null
|
||||||
|
: Array.from(this.list.selected)
|
||||||
|
const count = this.getSelectionSize()
|
||||||
|
this.executeDocumentAction(
|
||||||
|
modal,
|
||||||
|
this.documentService
|
||||||
|
.deleteDocuments(this.getSelectionQuery())
|
||||||
|
.pipe(
|
||||||
|
tap(() =>
|
||||||
|
this.trashService.notifyMovedToTrash(
|
||||||
|
$localize`Moved ${count} document(s) to the trash`,
|
||||||
|
documents
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
downloadSelected() {
|
downloadSelected() {
|
||||||
this.awaitingDownload.set(true)
|
this.awaitingDownload.set(true)
|
||||||
let downloadFileType: string =
|
let downloadFileType: string =
|
||||||
@@ -942,6 +967,30 @@ export class BulkEditorComponent
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
runZoneOcrSelected() {
|
||||||
|
const modal = this.modalService.open(ZoneOcrConfirmDialogComponent, {
|
||||||
|
backdrop: 'static',
|
||||||
|
})
|
||||||
|
const dialog = modal.componentInstance as ZoneOcrConfirmDialogComponent
|
||||||
|
dialog.title = $localize`Run zone OCR confirm`
|
||||||
|
dialog.messageBold = $localize`This operation will run zone OCR on ${this.getSelectionSize()} selected document(s).`
|
||||||
|
dialog.message = $localize`Fields filled by the template will be overwritten.`
|
||||||
|
dialog.btnClass = 'btn-warning'
|
||||||
|
dialog.btnCaption = $localize`Proceed`
|
||||||
|
dialog.confirmClicked
|
||||||
|
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||||
|
.subscribe(() => {
|
||||||
|
dialog.buttonsEnabled.set(false)
|
||||||
|
this.executeDocumentAction(
|
||||||
|
modal,
|
||||||
|
this.documentService.runZoneOcrDocuments(
|
||||||
|
this.getSelectionQuery(),
|
||||||
|
dialog.template
|
||||||
|
)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
setPermissions() {
|
setPermissions() {
|
||||||
let modal = this.modalService.open(PermissionsDialogComponent, {
|
let modal = this.modalService.open(PermissionsDialogComponent, {
|
||||||
backdrop: 'static',
|
backdrop: 'static',
|
||||||
@@ -1065,7 +1114,7 @@ export class BulkEditorComponent
|
|||||||
dialog.selection = this.getSelectionQuery()
|
dialog.selection = this.getSelectionQuery()
|
||||||
dialog.selectionCount = this.getSelectionSize()
|
dialog.selectionCount = this.getSelectionSize()
|
||||||
dialog.succeeded.subscribe((result) => {
|
dialog.succeeded.subscribe((result) => {
|
||||||
this.toastService.showInfo($localize`Custom fields updated.`)
|
this.toastService.showSuccess($localize`Custom fields updated.`)
|
||||||
this.list.reload()
|
this.list.reload()
|
||||||
this.list.reduceSelectionToFilter()
|
this.list.reduceSelectionToFilter()
|
||||||
this.list.selected.forEach((id) => {
|
this.list.selected.forEach((id) => {
|
||||||
|
|||||||
@@ -487,7 +487,7 @@ describe('DocumentListComponent', () => {
|
|||||||
delete modifiedView.name
|
delete modifiedView.name
|
||||||
const savedViewServicePatch = jest.spyOn(savedViewService, 'patch')
|
const savedViewServicePatch = jest.spyOn(savedViewService, 'patch')
|
||||||
savedViewServicePatch.mockReturnValue(of(modifiedView))
|
savedViewServicePatch.mockReturnValue(of(modifiedView))
|
||||||
const toastSpy = jest.spyOn(toastService, 'showInfo')
|
const toastSpy = jest.spyOn(toastService, 'showSuccess')
|
||||||
|
|
||||||
component.saveViewConfig()
|
component.saveViewConfig()
|
||||||
expect(savedViewServicePatch).toHaveBeenCalledWith(modifiedView)
|
expect(savedViewServicePatch).toHaveBeenCalledWith(modifiedView)
|
||||||
@@ -592,7 +592,7 @@ describe('DocumentListComponent', () => {
|
|||||||
let openModal: NgbModalRef
|
let openModal: NgbModalRef
|
||||||
modalService.activeInstances.subscribe((modal) => (openModal = modal[0]))
|
modalService.activeInstances.subscribe((modal) => (openModal = modal[0]))
|
||||||
const modalSpy = jest.spyOn(modalService, 'open')
|
const modalSpy = jest.spyOn(modalService, 'open')
|
||||||
const toastSpy = jest.spyOn(toastService, 'showInfo')
|
const toastSpy = jest.spyOn(toastService, 'showSuccess')
|
||||||
const savedViewServiceCreate = jest.spyOn(savedViewService, 'create')
|
const savedViewServiceCreate = jest.spyOn(savedViewService, 'create')
|
||||||
jest
|
jest
|
||||||
.spyOn(savedViewService, 'dashboardViews', 'get')
|
.spyOn(savedViewService, 'dashboardViews', 'get')
|
||||||
|
|||||||
@@ -418,7 +418,7 @@ export class DocumentListComponent
|
|||||||
next: (view) => {
|
next: (view) => {
|
||||||
this.activeSavedView = view
|
this.activeSavedView = view
|
||||||
this.unmodifiedSavedView = view
|
this.unmodifiedSavedView = view
|
||||||
this.toastService.showInfo(
|
this.toastService.showSuccess(
|
||||||
$localize`View "${this.list.activeSavedViewTitle}" saved successfully.`
|
$localize`View "${this.list.activeSavedViewTitle}" saved successfully.`
|
||||||
)
|
)
|
||||||
this.unmodifiedFilterRules.set(this.list.filterRules)
|
this.unmodifiedFilterRules.set(this.list.filterRules)
|
||||||
@@ -493,7 +493,7 @@ export class DocumentListComponent
|
|||||||
.subscribe({
|
.subscribe({
|
||||||
next: () => {
|
next: () => {
|
||||||
modal.close()
|
modal.close()
|
||||||
this.toastService.showInfo(
|
this.toastService.showSuccess(
|
||||||
$localize`View "${savedView.name}" created successfully.`
|
$localize`View "${savedView.name}" created successfully.`
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -119,7 +119,7 @@ describe('FileDropComponent', () => {
|
|||||||
}
|
}
|
||||||
component.onDrop(dragEvent as DragEvent)
|
component.onDrop(dragEvent as DragEvent)
|
||||||
advanceTimers(3000)
|
advanceTimers(3000)
|
||||||
expect(toastSpy).toHaveBeenCalled()
|
expect(toastSpy).not.toHaveBeenCalled()
|
||||||
expect(uploadSpy).toHaveBeenCalled()
|
expect(uploadSpy).toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -159,7 +159,7 @@ describe('FileDropComponent', () => {
|
|||||||
component.onDrop(dragEvent as DragEvent)
|
component.onDrop(dragEvent as DragEvent)
|
||||||
await flushPromises()
|
await flushPromises()
|
||||||
advanceTimers(3000)
|
advanceTimers(3000)
|
||||||
expect(toastSpy).toHaveBeenCalled()
|
expect(toastSpy).not.toHaveBeenCalled()
|
||||||
expect(uploadSpy).toHaveBeenCalled()
|
expect(uploadSpy).toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -227,7 +227,7 @@ describe('FileDropComponent', () => {
|
|||||||
}
|
}
|
||||||
component.onDrop(dragEvent as DragEvent)
|
component.onDrop(dragEvent as DragEvent)
|
||||||
advanceTimers(3000)
|
advanceTimers(3000)
|
||||||
expect(toastSpy).toHaveBeenCalled()
|
expect(toastSpy).not.toHaveBeenCalled()
|
||||||
expect(uploadSpy).toHaveBeenCalled()
|
expect(uploadSpy).toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -134,8 +134,7 @@ export class FileDropComponent {
|
|||||||
Promise.all(promises)
|
Promise.all(promises)
|
||||||
.then((results) => {
|
.then((results) => {
|
||||||
files.push(...[].concat(...results))
|
files.push(...[].concat(...results))
|
||||||
this.toastService.showInfo($localize`Initiating upload...`, 3000)
|
this.uploadDocumentsService.uploadFiles(files)
|
||||||
files.forEach((file) => this.uploadDocumentsService.uploadFile(file))
|
|
||||||
})
|
})
|
||||||
.catch((e) => {
|
.catch((e) => {
|
||||||
this.toastService.showError(
|
this.toastService.showError(
|
||||||
@@ -143,8 +142,7 @@ export class FileDropComponent {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
} else if (files.length) {
|
} else if (files.length) {
|
||||||
this.toastService.showInfo($localize`Initiating upload...`, 3000)
|
this.uploadDocumentsService.uploadFiles(files)
|
||||||
files.forEach((file) => this.uploadDocumentsService.uploadFile(file))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
this.onDragLeave(event, true)
|
this.onDragLeave(event, true)
|
||||||
|
|||||||
+2
-2
@@ -107,7 +107,7 @@ describe('CustomFieldsComponent', () => {
|
|||||||
let modal: NgbModalRef
|
let modal: NgbModalRef
|
||||||
modalService.activeInstances.subscribe((m) => (modal = m[m.length - 1]))
|
modalService.activeInstances.subscribe((m) => (modal = m[m.length - 1]))
|
||||||
const toastErrorSpy = jest.spyOn(toastService, 'showError')
|
const toastErrorSpy = jest.spyOn(toastService, 'showError')
|
||||||
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
|
const toastInfoSpy = jest.spyOn(toastService, 'showSuccess')
|
||||||
const reloadSpy = jest.spyOn(component, 'reload')
|
const reloadSpy = jest.spyOn(component, 'reload')
|
||||||
|
|
||||||
component.editField()
|
component.editField()
|
||||||
@@ -131,7 +131,7 @@ describe('CustomFieldsComponent', () => {
|
|||||||
let modal: NgbModalRef
|
let modal: NgbModalRef
|
||||||
modalService.activeInstances.subscribe((m) => (modal = m[m.length - 1]))
|
modalService.activeInstances.subscribe((m) => (modal = m[m.length - 1]))
|
||||||
const toastErrorSpy = jest.spyOn(toastService, 'showError')
|
const toastErrorSpy = jest.spyOn(toastService, 'showError')
|
||||||
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
|
const toastInfoSpy = jest.spyOn(toastService, 'showSuccess')
|
||||||
const reloadSpy = jest.spyOn(component, 'reload')
|
const reloadSpy = jest.spyOn(component, 'reload')
|
||||||
|
|
||||||
const editButton = fixture.debugElement
|
const editButton = fixture.debugElement
|
||||||
|
|||||||
+6
-2
@@ -82,7 +82,9 @@ export class CustomFieldsComponent
|
|||||||
modal.componentInstance.succeeded
|
modal.componentInstance.succeeded
|
||||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||||
.subscribe((newField) => {
|
.subscribe((newField) => {
|
||||||
this.toastService.showInfo($localize`Saved field "${newField.name}".`)
|
this.toastService.showSuccess(
|
||||||
|
$localize`Saved field "${newField.name}".`
|
||||||
|
)
|
||||||
this.customFieldsService.clearCache()
|
this.customFieldsService.clearCache()
|
||||||
this.settingsService.initializeDisplayFields()
|
this.settingsService.initializeDisplayFields()
|
||||||
this.documentService.reload()
|
this.documentService.reload()
|
||||||
@@ -109,7 +111,9 @@ export class CustomFieldsComponent
|
|||||||
this.customFieldsService.delete(field).subscribe({
|
this.customFieldsService.delete(field).subscribe({
|
||||||
next: () => {
|
next: () => {
|
||||||
modal.close()
|
modal.close()
|
||||||
this.toastService.showInfo($localize`Deleted field "${field.name}"`)
|
this.toastService.showSuccess(
|
||||||
|
$localize`Deleted field "${field.name}"`
|
||||||
|
)
|
||||||
this.customFieldsService.clearCache()
|
this.customFieldsService.clearCache()
|
||||||
this.settingsService.initializeDisplayFields()
|
this.settingsService.initializeDisplayFields()
|
||||||
this.documentService.reload()
|
this.documentService.reload()
|
||||||
|
|||||||
+4
-4
@@ -160,7 +160,7 @@ describe('ManagementListComponent', () => {
|
|||||||
let modal: NgbModalRef
|
let modal: NgbModalRef
|
||||||
modalService.activeInstances.subscribe((m) => (modal = m[m.length - 1]))
|
modalService.activeInstances.subscribe((m) => (modal = m[m.length - 1]))
|
||||||
const toastErrorSpy = jest.spyOn(toastService, 'showError')
|
const toastErrorSpy = jest.spyOn(toastService, 'showError')
|
||||||
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
|
const toastInfoSpy = jest.spyOn(toastService, 'showSuccess')
|
||||||
const reloadSpy = jest.spyOn(component, 'reloadData')
|
const reloadSpy = jest.spyOn(component, 'reloadData')
|
||||||
|
|
||||||
component.openCreateDialog()
|
component.openCreateDialog()
|
||||||
@@ -183,7 +183,7 @@ describe('ManagementListComponent', () => {
|
|||||||
let modal: NgbModalRef
|
let modal: NgbModalRef
|
||||||
modalService.activeInstances.subscribe((m) => (modal = m[m.length - 1]))
|
modalService.activeInstances.subscribe((m) => (modal = m[m.length - 1]))
|
||||||
const toastErrorSpy = jest.spyOn(toastService, 'showError')
|
const toastErrorSpy = jest.spyOn(toastService, 'showError')
|
||||||
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
|
const toastInfoSpy = jest.spyOn(toastService, 'showSuccess')
|
||||||
const reloadSpy = jest.spyOn(component, 'reloadData')
|
const reloadSpy = jest.spyOn(component, 'reloadData')
|
||||||
|
|
||||||
component.openEditDialog(tags[0])
|
component.openEditDialog(tags[0])
|
||||||
@@ -387,7 +387,7 @@ describe('ManagementListComponent', () => {
|
|||||||
expect(bulkEditPermsSpy).toHaveBeenCalled()
|
expect(bulkEditPermsSpy).toHaveBeenCalled()
|
||||||
expect(errorToastSpy).toHaveBeenCalled()
|
expect(errorToastSpy).toHaveBeenCalled()
|
||||||
|
|
||||||
const successToastSpy = jest.spyOn(toastService, 'showInfo')
|
const successToastSpy = jest.spyOn(toastService, 'showSuccess')
|
||||||
bulkEditPermsSpy.mockReturnValueOnce(of('OK'))
|
bulkEditPermsSpy.mockReturnValueOnce(of('OK'))
|
||||||
modal.componentInstance.confirmClicked.emit({
|
modal.componentInstance.confirmClicked.emit({
|
||||||
permissions: {},
|
permissions: {},
|
||||||
@@ -452,7 +452,7 @@ describe('ManagementListComponent', () => {
|
|||||||
)
|
)
|
||||||
expect(errorToastSpy).toHaveBeenCalled()
|
expect(errorToastSpy).toHaveBeenCalled()
|
||||||
|
|
||||||
const successToastSpy = jest.spyOn(toastService, 'showInfo')
|
const successToastSpy = jest.spyOn(toastService, 'showSuccess')
|
||||||
bulkEditSpy.mockReturnValueOnce(of('OK'))
|
bulkEditSpy.mockReturnValueOnce(of('OK'))
|
||||||
modal.componentInstance.confirmClicked.emit(null)
|
modal.componentInstance.confirmClicked.emit(null)
|
||||||
expect(bulkEditSpy).toHaveBeenCalled()
|
expect(bulkEditSpy).toHaveBeenCalled()
|
||||||
|
|||||||
+6
-4
@@ -224,7 +224,7 @@ export abstract class ManagementListComponent<T extends MatchingModel>
|
|||||||
activeModal.componentInstance.dialogMode.set(EditDialogMode.CREATE)
|
activeModal.componentInstance.dialogMode.set(EditDialogMode.CREATE)
|
||||||
activeModal.componentInstance.succeeded.subscribe(() => {
|
activeModal.componentInstance.succeeded.subscribe(() => {
|
||||||
this.reloadData()
|
this.reloadData()
|
||||||
this.toastService.showInfo(
|
this.toastService.showSuccess(
|
||||||
$localize`Successfully created ${this.typeName}.`
|
$localize`Successfully created ${this.typeName}.`
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
@@ -244,7 +244,7 @@ export abstract class ManagementListComponent<T extends MatchingModel>
|
|||||||
activeModal.componentInstance.dialogMode.set(EditDialogMode.EDIT)
|
activeModal.componentInstance.dialogMode.set(EditDialogMode.EDIT)
|
||||||
activeModal.componentInstance.succeeded.subscribe(() => {
|
activeModal.componentInstance.succeeded.subscribe(() => {
|
||||||
this.reloadData()
|
this.reloadData()
|
||||||
this.toastService.showInfo(
|
this.toastService.showSuccess(
|
||||||
$localize`Successfully updated ${this.typeName} "${object.name}".`
|
$localize`Successfully updated ${this.typeName} "${object.name}".`
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
@@ -428,7 +428,7 @@ export abstract class ManagementListComponent<T extends MatchingModel>
|
|||||||
.subscribe({
|
.subscribe({
|
||||||
next: () => {
|
next: () => {
|
||||||
modal.close()
|
modal.close()
|
||||||
this.toastService.showInfo(
|
this.toastService.showSuccess(
|
||||||
$localize`Permissions updated successfully`
|
$localize`Permissions updated successfully`
|
||||||
)
|
)
|
||||||
this.reloadData()
|
this.reloadData()
|
||||||
@@ -468,7 +468,9 @@ export abstract class ManagementListComponent<T extends MatchingModel>
|
|||||||
.subscribe({
|
.subscribe({
|
||||||
next: () => {
|
next: () => {
|
||||||
modal.close()
|
modal.close()
|
||||||
this.toastService.showInfo($localize`Objects deleted successfully`)
|
this.toastService.showSuccess(
|
||||||
|
$localize`Objects deleted successfully`
|
||||||
|
)
|
||||||
this.reloadData()
|
this.reloadData()
|
||||||
},
|
},
|
||||||
error: (error) => {
|
error: (error) => {
|
||||||
|
|||||||
@@ -184,7 +184,7 @@ describe('MailComponent', () => {
|
|||||||
component.editMailAccount(mailAccounts[0] as MailAccount)
|
component.editMailAccount(mailAccounts[0] as MailAccount)
|
||||||
let editDialog = modal.componentInstance as MailAccountEditDialogComponent
|
let editDialog = modal.componentInstance as MailAccountEditDialogComponent
|
||||||
const toastErrorSpy = jest.spyOn(toastService, 'showError')
|
const toastErrorSpy = jest.spyOn(toastService, 'showError')
|
||||||
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
|
const toastInfoSpy = jest.spyOn(toastService, 'showSuccess')
|
||||||
editDialog.failed.emit()
|
editDialog.failed.emit()
|
||||||
expect(toastErrorSpy).toHaveBeenCalled()
|
expect(toastErrorSpy).toHaveBeenCalled()
|
||||||
editDialog.succeeded.emit(mailAccounts[0] as any)
|
editDialog.succeeded.emit(mailAccounts[0] as any)
|
||||||
@@ -203,7 +203,7 @@ describe('MailComponent', () => {
|
|||||||
const deleteDialog = modal.componentInstance as ConfirmDialogComponent
|
const deleteDialog = modal.componentInstance as ConfirmDialogComponent
|
||||||
const deleteSpy = jest.spyOn(mailAccountService, 'delete')
|
const deleteSpy = jest.spyOn(mailAccountService, 'delete')
|
||||||
const toastErrorSpy = jest.spyOn(toastService, 'showError')
|
const toastErrorSpy = jest.spyOn(toastService, 'showError')
|
||||||
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
|
const toastInfoSpy = jest.spyOn(toastService, 'showSuccess')
|
||||||
const listAllSpy = jest.spyOn(mailAccountService, 'listAll')
|
const listAllSpy = jest.spyOn(mailAccountService, 'listAll')
|
||||||
deleteSpy.mockReturnValueOnce(
|
deleteSpy.mockReturnValueOnce(
|
||||||
throwError(() => new Error('error deleting mail account'))
|
throwError(() => new Error('error deleting mail account'))
|
||||||
@@ -242,7 +242,7 @@ describe('MailComponent', () => {
|
|||||||
component.editMailRule(mailRules[0] as MailRule)
|
component.editMailRule(mailRules[0] as MailRule)
|
||||||
const editDialog = modal.componentInstance as MailRuleEditDialogComponent
|
const editDialog = modal.componentInstance as MailRuleEditDialogComponent
|
||||||
const toastErrorSpy = jest.spyOn(toastService, 'showError')
|
const toastErrorSpy = jest.spyOn(toastService, 'showError')
|
||||||
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
|
const toastInfoSpy = jest.spyOn(toastService, 'showSuccess')
|
||||||
editDialog.failed.emit()
|
editDialog.failed.emit()
|
||||||
expect(toastErrorSpy).toHaveBeenCalled()
|
expect(toastErrorSpy).toHaveBeenCalled()
|
||||||
editDialog.succeeded.emit(mailRules[0] as any)
|
editDialog.succeeded.emit(mailRules[0] as any)
|
||||||
@@ -272,7 +272,7 @@ describe('MailComponent', () => {
|
|||||||
const deleteDialog = modal.componentInstance as ConfirmDialogComponent
|
const deleteDialog = modal.componentInstance as ConfirmDialogComponent
|
||||||
const deleteSpy = jest.spyOn(mailRuleService, 'delete')
|
const deleteSpy = jest.spyOn(mailRuleService, 'delete')
|
||||||
const toastErrorSpy = jest.spyOn(toastService, 'showError')
|
const toastErrorSpy = jest.spyOn(toastService, 'showError')
|
||||||
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
|
const toastInfoSpy = jest.spyOn(toastService, 'showSuccess')
|
||||||
const listAllSpy = jest.spyOn(mailRuleService, 'listAll')
|
const listAllSpy = jest.spyOn(mailRuleService, 'listAll')
|
||||||
deleteSpy.mockReturnValueOnce(
|
deleteSpy.mockReturnValueOnce(
|
||||||
throwError(() => new Error('error deleting mail rule "rule1"'))
|
throwError(() => new Error('error deleting mail rule "rule1"'))
|
||||||
@@ -303,7 +303,7 @@ describe('MailComponent', () => {
|
|||||||
let modal: NgbModalRef
|
let modal: NgbModalRef
|
||||||
modalService.activeInstances.subscribe((refs) => (modal = refs[0]))
|
modalService.activeInstances.subscribe((refs) => (modal = refs[0]))
|
||||||
const toastErrorSpy = jest.spyOn(toastService, 'showError')
|
const toastErrorSpy = jest.spyOn(toastService, 'showError')
|
||||||
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
|
const toastInfoSpy = jest.spyOn(toastService, 'showSuccess')
|
||||||
const rulePatchSpy = jest.spyOn(mailRuleService, 'patch')
|
const rulePatchSpy = jest.spyOn(mailRuleService, 'patch')
|
||||||
component.editPermissions(mailRules[0] as MailRule)
|
component.editPermissions(mailRules[0] as MailRule)
|
||||||
expect(modal).not.toBeUndefined()
|
expect(modal).not.toBeUndefined()
|
||||||
@@ -356,7 +356,7 @@ describe('MailComponent', () => {
|
|||||||
By.css('input[type="checkbox"]')
|
By.css('input[type="checkbox"]')
|
||||||
)
|
)
|
||||||
const toastErrorSpy = jest.spyOn(toastService, 'showError')
|
const toastErrorSpy = jest.spyOn(toastService, 'showError')
|
||||||
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
|
const toastInfoSpy = jest.spyOn(toastService, 'showSuccess')
|
||||||
// fail first
|
// fail first
|
||||||
patchSpy.mockReturnValueOnce(
|
patchSpy.mockReturnValueOnce(
|
||||||
throwError(() => new Error('Error getting config'))
|
throwError(() => new Error('Error getting config'))
|
||||||
@@ -380,7 +380,7 @@ describe('MailComponent', () => {
|
|||||||
jest
|
jest
|
||||||
.spyOn(activatedRoute, 'queryParamMap', 'get')
|
.spyOn(activatedRoute, 'queryParamMap', 'get')
|
||||||
.mockReturnValue(of(convertToParamMap(queryParams)))
|
.mockReturnValue(of(convertToParamMap(queryParams)))
|
||||||
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
|
const toastInfoSpy = jest.spyOn(toastService, 'showSuccess')
|
||||||
completeSetup()
|
completeSetup()
|
||||||
expect(toastInfoSpy).toHaveBeenCalled()
|
expect(toastInfoSpy).toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -135,7 +135,9 @@ export class MailComponent
|
|||||||
if (params.get('oauth_success')) {
|
if (params.get('oauth_success')) {
|
||||||
const success = params.get('oauth_success') === '1'
|
const success = params.get('oauth_success') === '1'
|
||||||
if (success) {
|
if (success) {
|
||||||
this.toastService.showInfo($localize`OAuth2 authentication success`)
|
this.toastService.showSuccess(
|
||||||
|
$localize`OAuth2 authentication success`
|
||||||
|
)
|
||||||
this.oAuthAccountId = parseInt(params.get('account_id'))
|
this.oAuthAccountId = parseInt(params.get('account_id'))
|
||||||
if (this.mailAccounts().length > 0) {
|
if (this.mailAccounts().length > 0) {
|
||||||
this.editMailAccount(
|
this.editMailAccount(
|
||||||
@@ -169,7 +171,7 @@ export class MailComponent
|
|||||||
modal.componentInstance.succeeded
|
modal.componentInstance.succeeded
|
||||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||||
.subscribe((newMailAccount) => {
|
.subscribe((newMailAccount) => {
|
||||||
this.toastService.showInfo(
|
this.toastService.showSuccess(
|
||||||
$localize`Saved account "${newMailAccount.name}".`
|
$localize`Saved account "${newMailAccount.name}".`
|
||||||
)
|
)
|
||||||
this.mailAccountService.clearCache()
|
this.mailAccountService.clearCache()
|
||||||
@@ -200,7 +202,7 @@ export class MailComponent
|
|||||||
this.mailAccountService.delete(account).subscribe({
|
this.mailAccountService.delete(account).subscribe({
|
||||||
next: () => {
|
next: () => {
|
||||||
modal.close()
|
modal.close()
|
||||||
this.toastService.showInfo(
|
this.toastService.showSuccess(
|
||||||
$localize`Deleted mail account "${account.name}"`
|
$localize`Deleted mail account "${account.name}"`
|
||||||
)
|
)
|
||||||
this.mailAccountService.clearCache()
|
this.mailAccountService.clearCache()
|
||||||
@@ -248,7 +250,9 @@ export class MailComponent
|
|||||||
modal.componentInstance.succeeded
|
modal.componentInstance.succeeded
|
||||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||||
.subscribe((newMailRule) => {
|
.subscribe((newMailRule) => {
|
||||||
this.toastService.showInfo($localize`Saved rule "${newMailRule.name}".`)
|
this.toastService.showSuccess(
|
||||||
|
$localize`Saved rule "${newMailRule.name}".`
|
||||||
|
)
|
||||||
this.mailRuleService.clearCache()
|
this.mailRuleService.clearCache()
|
||||||
this.mailRuleService
|
this.mailRuleService
|
||||||
.listAll(null, null, { full_perms: true })
|
.listAll(null, null, { full_perms: true })
|
||||||
@@ -273,7 +277,7 @@ export class MailComponent
|
|||||||
onMailRuleEnableToggled(rule: MailRule) {
|
onMailRuleEnableToggled(rule: MailRule) {
|
||||||
this.mailRuleService.patch(rule).subscribe({
|
this.mailRuleService.patch(rule).subscribe({
|
||||||
next: () => {
|
next: () => {
|
||||||
this.toastService.showInfo(
|
this.toastService.showSuccess(
|
||||||
rule.enabled
|
rule.enabled
|
||||||
? $localize`Rule "${rule.name}" enabled.`
|
? $localize`Rule "${rule.name}" enabled.`
|
||||||
: $localize`Rule "${rule.name}" disabled.`
|
: $localize`Rule "${rule.name}" disabled.`
|
||||||
@@ -302,7 +306,7 @@ export class MailComponent
|
|||||||
this.mailRuleService.delete(rule).subscribe({
|
this.mailRuleService.delete(rule).subscribe({
|
||||||
next: () => {
|
next: () => {
|
||||||
modal.close()
|
modal.close()
|
||||||
this.toastService.showInfo(
|
this.toastService.showSuccess(
|
||||||
$localize`Deleted mail rule "${rule.name}"`
|
$localize`Deleted mail rule "${rule.name}"`
|
||||||
)
|
)
|
||||||
this.mailRuleService.clearCache()
|
this.mailRuleService.clearCache()
|
||||||
@@ -338,7 +342,7 @@ export class MailComponent
|
|||||||
object['set_permissions'] = permissions['set_permissions']
|
object['set_permissions'] = permissions['set_permissions']
|
||||||
service.patch(object).subscribe({
|
service.patch(object).subscribe({
|
||||||
next: () => {
|
next: () => {
|
||||||
this.toastService.showInfo($localize`Permissions updated`)
|
this.toastService.showSuccess($localize`Permissions updated`)
|
||||||
modal.close()
|
modal.close()
|
||||||
},
|
},
|
||||||
error: (e) => {
|
error: (e) => {
|
||||||
|
|||||||
+1
-1
@@ -96,7 +96,7 @@ describe('ProcessedMailDialogComponent', () => {
|
|||||||
// select a couple of mails and delete
|
// select a couple of mails and delete
|
||||||
component.selectedMailIds().add(5)
|
component.selectedMailIds().add(5)
|
||||||
component.selectedMailIds().add(6)
|
component.selectedMailIds().add(6)
|
||||||
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
|
const toastInfoSpy = jest.spyOn(toastService, 'showSuccess')
|
||||||
component.deleteSelected()
|
component.deleteSelected()
|
||||||
|
|
||||||
const delReq = httpTestingController.expectOne(
|
const delReq = httpTestingController.expectOne(
|
||||||
|
|||||||
+1
-1
@@ -67,7 +67,7 @@ export class ProcessedMailDialogComponent implements OnInit {
|
|||||||
this.processedMailService
|
this.processedMailService
|
||||||
.bulk_delete(Array.from(this.selectedMailIds()))
|
.bulk_delete(Array.from(this.selectedMailIds()))
|
||||||
.subscribe(() => {
|
.subscribe(() => {
|
||||||
this.toastService.showInfo($localize`Processed mail(s) deleted`)
|
this.toastService.showSuccess($localize`Processed mail(s) deleted`)
|
||||||
this.loadProcessedMails()
|
this.loadProcessedMails()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
+36
@@ -0,0 +1,36 @@
|
|||||||
|
@if (zones.length === 0) {
|
||||||
|
<p class="text-muted" i18n>No zones yet. Drag anywhere on the sample document to create one.</p>
|
||||||
|
} @else {
|
||||||
|
<div class="list-group">
|
||||||
|
@for (zone of zones; track $index; let i = $index) {
|
||||||
|
<div class="list-group-item list-group-item-action d-flex align-items-center gap-3 position-relative">
|
||||||
|
<span class="p-2 rounded flex-shrink-0" [style.background-color]="zoneColor(i)" aria-hidden="true"></span>
|
||||||
|
<div class="flex-grow-1 text-truncate">
|
||||||
|
<button type="button" class="btn btn-link p-0 fw-semibold text-reset text-decoration-none text-truncate mw-100 stretched-link" (click)="zoneSelected.emit(i)">{{ zone.name }}</button>
|
||||||
|
@if (!hasField(zone)) {
|
||||||
|
<div class="small text-warning text-truncate">
|
||||||
|
<i-bs name="exclamation-triangle" class="me-1" aria-hidden="true"></i-bs><span i18n>No field selected</span>
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<div class="small text-muted text-truncate">
|
||||||
|
{{ getZoneTargetName(zone) }}
|
||||||
|
@if (isCombined(zone)) {
|
||||||
|
<i-bs name="braces" class="ms-1" title="Combined with other zones" i18n-title></i-bs>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<small class="text-muted text-nowrap">
|
||||||
|
@if (zone.page === -1) {
|
||||||
|
<ng-container i18n>Last page</ng-container>
|
||||||
|
} @else {
|
||||||
|
<ng-container i18n>Page {{ zone.page ?? 1 }}</ng-container>
|
||||||
|
}
|
||||||
|
</small>
|
||||||
|
<button type="button" class="btn btn-sm btn-link text-danger position-relative z-2" (click)="zoneRemoved.emit(i)" title="Delete zone" i18n-title aria-label="Delete zone" i18n-aria-label>
|
||||||
|
<i-bs name="trash"></i-bs>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
+112
@@ -0,0 +1,112 @@
|
|||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||||
|
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
||||||
|
import { CustomField } from 'src/app/data/custom-field'
|
||||||
|
import {
|
||||||
|
OcrTemplateZone,
|
||||||
|
OcrZoneTarget,
|
||||||
|
OcrZoneTransform,
|
||||||
|
} from 'src/app/data/ocr-template'
|
||||||
|
import { OcrTemplateEditorZoneListComponent } from './ocr-template-editor-zone-list.component'
|
||||||
|
|
||||||
|
function zone(overrides: Partial<OcrTemplateZone> = {}): OcrTemplateZone {
|
||||||
|
return {
|
||||||
|
name: 'Zone 1',
|
||||||
|
target: OcrZoneTarget.CustomField,
|
||||||
|
custom_field: 7,
|
||||||
|
x: 10,
|
||||||
|
y: 20,
|
||||||
|
width: 30,
|
||||||
|
height: 40,
|
||||||
|
page: 1,
|
||||||
|
ocr_language: 'eng',
|
||||||
|
transform: OcrZoneTransform.Strip,
|
||||||
|
validation_regex: '',
|
||||||
|
order: 0,
|
||||||
|
...overrides,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('OcrTemplateEditorZoneListComponent', () => {
|
||||||
|
let fixture: ComponentFixture<OcrTemplateEditorZoneListComponent>
|
||||||
|
let component: OcrTemplateEditorZoneListComponent
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [
|
||||||
|
OcrTemplateEditorZoneListComponent,
|
||||||
|
NgxBootstrapIconsModule.pick(allIcons),
|
||||||
|
],
|
||||||
|
}).compileComponents()
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(OcrTemplateEditorZoneListComponent)
|
||||||
|
component = fixture.componentInstance
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows empty state when no zones are defined', () => {
|
||||||
|
fixture.detectChanges()
|
||||||
|
|
||||||
|
expect(fixture.nativeElement.textContent).toContain('No zones yet')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('renders zone name, target, and page', () => {
|
||||||
|
component.zones = [zone(), zone({ name: 'Zone 2', page: -1 })]
|
||||||
|
component.customFields = [{ id: 7, name: 'Invoice Number' } as CustomField]
|
||||||
|
fixture.detectChanges()
|
||||||
|
|
||||||
|
const text = fixture.nativeElement.textContent
|
||||||
|
expect(text).toContain('Zone 1')
|
||||||
|
expect(text).toContain('Invoice Number')
|
||||||
|
expect(text).toContain('Page 1')
|
||||||
|
expect(text).toContain('Last page')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('marks zones that share a field', () => {
|
||||||
|
component.zones = [
|
||||||
|
zone(),
|
||||||
|
zone({ name: 'Zone 2' }),
|
||||||
|
zone({ name: 'Zone 3', custom_field: 8 }),
|
||||||
|
]
|
||||||
|
fixture.detectChanges()
|
||||||
|
|
||||||
|
expect(component.isCombined(component.zones[0])).toBe(true)
|
||||||
|
expect(component.isCombined(component.zones[2])).toBe(false)
|
||||||
|
expect(
|
||||||
|
fixture.nativeElement.querySelectorAll(
|
||||||
|
'[title="Combined with other zones"]'
|
||||||
|
)
|
||||||
|
).toHaveLength(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('warns only for zones without a field and clears the warning when assigned', () => {
|
||||||
|
component.zones = [
|
||||||
|
zone({ custom_field: null }),
|
||||||
|
zone({ target: OcrZoneTarget.Title, custom_field: null }),
|
||||||
|
zone(),
|
||||||
|
]
|
||||||
|
fixture.detectChanges()
|
||||||
|
|
||||||
|
const warnings = () =>
|
||||||
|
fixture.nativeElement.querySelectorAll('.text-warning')
|
||||||
|
expect(warnings()).toHaveLength(1)
|
||||||
|
expect(warnings()[0].textContent).toContain('No field selected')
|
||||||
|
|
||||||
|
component.zones[0].custom_field = 7
|
||||||
|
fixture.componentRef.setInput('zones', [...component.zones])
|
||||||
|
fixture.detectChanges()
|
||||||
|
expect(warnings()).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('emits select and remove events', () => {
|
||||||
|
component.zones = [zone()]
|
||||||
|
const selectSpy = jest.spyOn(component.zoneSelected, 'emit')
|
||||||
|
const removeSpy = jest.spyOn(component.zoneRemoved, 'emit')
|
||||||
|
fixture.detectChanges()
|
||||||
|
|
||||||
|
const buttons = fixture.nativeElement.querySelectorAll('button')
|
||||||
|
buttons[0].click()
|
||||||
|
buttons[1].click()
|
||||||
|
|
||||||
|
expect(selectSpy).toHaveBeenCalledWith(0)
|
||||||
|
expect(removeSpy).toHaveBeenCalledWith(0)
|
||||||
|
})
|
||||||
|
})
|
||||||
+50
@@ -0,0 +1,50 @@
|
|||||||
|
import { Component, EventEmitter, Input, Output } from '@angular/core'
|
||||||
|
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||||
|
import { CustomField } from 'src/app/data/custom-field'
|
||||||
|
import {
|
||||||
|
getZoneFieldKey,
|
||||||
|
OCR_BUILTIN_TARGETS,
|
||||||
|
OcrTemplateZone,
|
||||||
|
OcrZoneTarget,
|
||||||
|
} from 'src/app/data/ocr-template'
|
||||||
|
import { getZoneColor } from '../zone-geometry'
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'pngx-ocr-template-zone-list',
|
||||||
|
imports: [NgxBootstrapIconsModule],
|
||||||
|
templateUrl: './ocr-template-editor-zone-list.component.html',
|
||||||
|
})
|
||||||
|
export class OcrTemplateEditorZoneListComponent {
|
||||||
|
@Input() zones: OcrTemplateZone[] = []
|
||||||
|
@Input() customFields: CustomField[] = []
|
||||||
|
|
||||||
|
@Output() public readonly zoneSelected = new EventEmitter<number>()
|
||||||
|
@Output() public readonly zoneRemoved = new EventEmitter<number>()
|
||||||
|
|
||||||
|
public readonly zoneColor = getZoneColor
|
||||||
|
|
||||||
|
hasField(zone: OcrTemplateZone): boolean {
|
||||||
|
return getZoneFieldKey(zone) !== null
|
||||||
|
}
|
||||||
|
|
||||||
|
isCombined(zone: OcrTemplateZone): boolean {
|
||||||
|
const key = getZoneFieldKey(zone)
|
||||||
|
return (
|
||||||
|
key !== null &&
|
||||||
|
this.zones.filter((z) => getZoneFieldKey(z) === key).length > 1
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
getZoneTargetName(zone: OcrTemplateZone): string {
|
||||||
|
if (zone.target === OcrZoneTarget.CustomField) {
|
||||||
|
return (
|
||||||
|
this.customFields.find((field) => field.id === zone.custom_field)
|
||||||
|
?.name ?? ''
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
OCR_BUILTIN_TARGETS.find((target) => target.id === zone.target)?.name ??
|
||||||
|
''
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
+313
@@ -0,0 +1,313 @@
|
|||||||
|
<div class="modal-header">
|
||||||
|
<h4 class="modal-title" id="ocr-template-editor-title">{{ dialogTitle }}</h4>
|
||||||
|
@if (template.id) {
|
||||||
|
<span class="badge bg-primary text-primary-text-contrast ms-2">ID: {{ template.id }}</span>
|
||||||
|
}
|
||||||
|
<button type="button" class="btn-close" (click)="activeModal.dismiss()" [disabled]="saving()" aria-label="Close" i18n-aria-label></button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-body d-flex flex-column">
|
||||||
|
<div class="row gx-3 editor-layout flex-grow-1 flex-lg-nowrap" [attr.inert]="saving() ? '' : null" [attr.aria-busy]="saving()">
|
||||||
|
<div class="col-lg-4 mb-3 mb-lg-0 overflow-auto">
|
||||||
|
<ul ngbNav #nav="ngbNav" [(activeId)]="activeTab" class="nav-underline flex-nowrap flex-md-wrap overflow-auto">
|
||||||
|
<li ngbNavItem="template">
|
||||||
|
<a ngbNavLink i18n>Template</a>
|
||||||
|
<ng-template ngbNavContent>
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-9">
|
||||||
|
<pngx-input-text [(ngModel)]="template.name" title="Template name" i18n-title></pngx-input-text>
|
||||||
|
</div>
|
||||||
|
<div class="col-3">
|
||||||
|
<pngx-input-switch [(ngModel)]="template.enabled" title="Enabled" i18n-title></pngx-input-switch>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<pngx-input-select [(ngModel)]="template.matching_algorithm" [items]="matchingAlgorithms" title="Matching algorithm" i18n-title></pngx-input-select>
|
||||||
|
@if (patternRequired) {
|
||||||
|
<pngx-input-text [(ngModel)]="template.match" title="Matching pattern" i18n-title></pngx-input-text>
|
||||||
|
<pngx-input-check [(ngModel)]="template.is_insensitive" title="Case insensitive" i18n-title></pngx-input-check>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div *pngxIfOwner="object">
|
||||||
|
<hr />
|
||||||
|
<pngx-permissions-form [users]="users()" [accordion]="true" [(ngModel)]="permissionsForm"></pngx-permissions-form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</ng-template>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
<li ngbNavItem="zones">
|
||||||
|
<a ngbNavLink><ng-container i18n>Zones</ng-container> <span class="badge bg-primary ms-2">{{ template.zones.length }}</span></a>
|
||||||
|
<ng-template ngbNavContent>
|
||||||
|
@if (selectedZone; as zone) {
|
||||||
|
<div class="d-flex align-items-center gap-2 mb-3">
|
||||||
|
<button type="button" class="btn btn-sm btn-link px-0 me-auto" (click)="deselectZone()">
|
||||||
|
<i-bs name="arrow-left" class="me-1"></i-bs><ng-container i18n>All zones</ng-container>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-primary" (click)="testZone()" [disabled]="!previewDocId || zoneTesting">
|
||||||
|
@if (zoneTesting) {
|
||||||
|
<span class="spinner-border spinner-border-sm me-1" aria-hidden="true"></span>
|
||||||
|
} @else {
|
||||||
|
<i-bs name="play-fill" class="me-1"></i-bs>
|
||||||
|
}
|
||||||
|
<ng-container i18n>Test</ng-container>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-danger" (click)="removeZone(selectedZoneIndex)" title="Delete zone" i18n-title aria-label="Delete zone" i18n-aria-label>
|
||||||
|
<i-bs name="trash"></i-bs>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (zoneTestResult; as result) {
|
||||||
|
@if (result.error) {
|
||||||
|
<div class="alert alert-warning py-2 mb-3 small" role="alert">{{ result.error }}</div>
|
||||||
|
} @else {
|
||||||
|
<dl class="row small border rounded mx-0 py-2 mb-3">
|
||||||
|
<dt class="col-4" i18n>OCR text</dt>
|
||||||
|
<dd class="col-8">
|
||||||
|
@if (result.raw_text) {
|
||||||
|
<code>{{ result.raw_text }}</code>
|
||||||
|
} @else {
|
||||||
|
<span class="text-muted" i18n>Nothing detected</span>
|
||||||
|
}
|
||||||
|
</dd>
|
||||||
|
<dt class="col-4" i18n>Value</dt>
|
||||||
|
<dd class="col-8" [class.mb-0]="!result.regex">
|
||||||
|
@if (result.value) {
|
||||||
|
<code>{{ result.value }}</code>
|
||||||
|
} @else {
|
||||||
|
<span class="text-muted" i18n>Empty</span>
|
||||||
|
}
|
||||||
|
</dd>
|
||||||
|
@if (result.regex) {
|
||||||
|
<dt class="col-4" i18n>Validation</dt>
|
||||||
|
<dd class="col-8 mb-0">
|
||||||
|
@if (result.regex_match) {
|
||||||
|
<span class="badge bg-success" i18n>Regex matches</span>
|
||||||
|
} @else {
|
||||||
|
<span class="badge bg-danger" i18n>Regex does not match</span>
|
||||||
|
}
|
||||||
|
</dd>
|
||||||
|
}
|
||||||
|
</dl>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
<pngx-input-text [ngModel]="zone.name" (ngModelChange)="renameZone(zone, $event)" title="Name" i18n-title></pngx-input-text>
|
||||||
|
|
||||||
|
<pngx-input-select
|
||||||
|
[items]="fieldItems()"
|
||||||
|
[ngModel]="zoneFieldValue(zone)"
|
||||||
|
(ngModelChange)="setZoneField(zone, $event)"
|
||||||
|
(createNew)="createField($event)"
|
||||||
|
title="Field"
|
||||||
|
i18n-title
|
||||||
|
placeholder="Choose a field"
|
||||||
|
i18n-placeholder
|
||||||
|
hint="Where the extracted value is written."
|
||||||
|
i18n-hint
|
||||||
|
></pngx-input-select>
|
||||||
|
|
||||||
|
@if (isFieldShared(zone)) {
|
||||||
|
<pngx-input-text
|
||||||
|
[ngModel]="getCombineFormat(zone)"
|
||||||
|
(ngModelChange)="setCombineFormat(zone, $event)"
|
||||||
|
[placeholder]="defaultCombineFormat(zone)"
|
||||||
|
title="Combined value"
|
||||||
|
i18n-title
|
||||||
|
hint="Several zones write to this field. Set the format or leave empty to join them with spaces."
|
||||||
|
i18n-hint
|
||||||
|
></pngx-input-text>
|
||||||
|
<div class="d-flex flex-wrap gap-1 mt-n2 mb-3">
|
||||||
|
@for (z of zonesForField(zone); track z) {
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary font-monospace" (click)="insertCombineToken(zone, z)">{{ '{' + z.name + '}' }}</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<div class="row align-items-end">
|
||||||
|
<div class="col">
|
||||||
|
@if (!isLastPageZone(zone)) {
|
||||||
|
<pngx-input-number [(ngModel)]="zone.page" [showAdd]="false" title="Page" i18n-title></pngx-input-number>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="col-auto">
|
||||||
|
<pngx-input-switch [ngModel]="isLastPageZone(zone)" (ngModelChange)="setLastPageZone(zone, $event)" [horizontal]="true" title="Last page" i18n-title></pngx-input-switch>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h6 class="border-top pt-3 mt-1 mb-3" i18n>Processing</h6>
|
||||||
|
|
||||||
|
<pngx-input-select
|
||||||
|
[items]="languageOptions"
|
||||||
|
[multiple]="true"
|
||||||
|
[ngModel]="ocrLanguageArray(zone)"
|
||||||
|
(ngModelChange)="setOcrLanguages(zone, $event)"
|
||||||
|
title="OCR language"
|
||||||
|
i18n-title
|
||||||
|
placeholder="Select languages"
|
||||||
|
i18n-placeholder
|
||||||
|
></pngx-input-select>
|
||||||
|
|
||||||
|
<pngx-input-select [items]="transformOptions" [(ngModel)]="zone.transform" title="Transform" i18n-title></pngx-input-select>
|
||||||
|
|
||||||
|
@if (zone.transform === OcrZoneTransform.Date) {
|
||||||
|
<pngx-input-text
|
||||||
|
[(ngModel)]="zone.date_format"
|
||||||
|
title="Date format"
|
||||||
|
i18n-title
|
||||||
|
placeholder="Auto-detect"
|
||||||
|
i18n-placeholder
|
||||||
|
hint="Python date codes, e.g. %d.%m.%Y. Leave empty to detect the format automatically."
|
||||||
|
i18n-hint
|
||||||
|
></pngx-input-text>
|
||||||
|
}
|
||||||
|
|
||||||
|
<pngx-input-text [(ngModel)]="zone.validation_regex" title="Validation regex" i18n-title placeholder="\d{2}\.\d{2}\.\d{4}" hint="The value must match this pattern to be applied." i18n-hint></pngx-input-text>
|
||||||
|
} @else {
|
||||||
|
<pngx-ocr-template-zone-list
|
||||||
|
[zones]="template.zones"
|
||||||
|
[customFields]="customFields()"
|
||||||
|
(zoneSelected)="selectZone($event)"
|
||||||
|
(zoneRemoved)="removeZone($event)"
|
||||||
|
></pngx-ocr-template-zone-list>
|
||||||
|
}
|
||||||
|
</ng-template>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div [ngbNavOutlet]="nav" class="mt-3"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col-lg-8 d-flex flex-column">
|
||||||
|
<div class="d-flex flex-wrap gap-2 mb-2 flex-shrink-0">
|
||||||
|
<pngx-input-document-link
|
||||||
|
class="flex-grow-1"
|
||||||
|
[ngModel]="sampleDocumentIds"
|
||||||
|
(ngModelChange)="setSampleDocument($event)"
|
||||||
|
[minimal]="true"
|
||||||
|
[multiple]="false"
|
||||||
|
placeholder="Search for a sample document"
|
||||||
|
i18n-placeholder
|
||||||
|
></pngx-input-document-link>
|
||||||
|
@if (previewDocId) {
|
||||||
|
<div class="input-group w-auto flex-nowrap">
|
||||||
|
<input class="form-control flex-grow-0 preview-page-input" type="number" min="1" [max]="previewPageCount()" [(ngModel)]="previewPageDisplay" [disabled]="!pageLoaded()" aria-label="Page" i18n-aria-label />
|
||||||
|
<span class="input-group-text" i18n>of {{ previewPageCount() ?? '?' }}</span>
|
||||||
|
</div>
|
||||||
|
<fieldset class="input-group w-auto flex-nowrap" aria-label="Zoom" i18n-aria-label>
|
||||||
|
<button type="button" class="btn btn-outline-secondary" aria-label="Zoom out" i18n-aria-label [disabled]="!pageLoaded() || zoom <= 0.5" (click)="zoomOut()"><i-bs name="dash"></i-bs></button>
|
||||||
|
<button type="button" class="btn btn-outline-secondary preview-zoom" title="Reset zoom" i18n-title [disabled]="!pageLoaded()" (click)="resetZoom()">{{ zoom | percent: '1.0-0' }}</button>
|
||||||
|
<button type="button" class="btn btn-outline-secondary" aria-label="Zoom in" i18n-aria-label [disabled]="!pageLoaded() || zoom >= 4" (click)="zoomIn()"><i-bs name="plus"></i-bs></button>
|
||||||
|
</fieldset>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="flex-grow-1 overflow-hidden">
|
||||||
|
<div class="h-100 overflow-auto border" [attr.aria-busy]="previewDocId && !pageLoaded()">
|
||||||
|
@if (pageFailed()) {
|
||||||
|
<div class="py-5 px-3 text-center" role="alert">
|
||||||
|
<i-bs name="exclamation-triangle" width="32" height="32" class="text-warning"></i-bs>
|
||||||
|
<p class="my-3" i18n>Could not load this page.</p>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary" (click)="setSampleDocument(sampleDocumentIds)"><i-bs name="arrow-clockwise" class="me-1"></i-bs><span i18n>Retry</span></button>
|
||||||
|
</div>
|
||||||
|
} @else if (previewDocId) {
|
||||||
|
<div class="position-relative" [class.d-none]="!pageLoaded()" [style.width.%]="zoom * 100">
|
||||||
|
<img
|
||||||
|
#pageImage
|
||||||
|
[src]="pageImageUrl"
|
||||||
|
(load)="onImageLoad()"
|
||||||
|
(error)="onImageError()"
|
||||||
|
alt="Sample document page"
|
||||||
|
i18n-alt
|
||||||
|
class="d-block w-100"
|
||||||
|
crossorigin="use-credentials"
|
||||||
|
/>
|
||||||
|
@if (pageLoaded()) {
|
||||||
|
<svg
|
||||||
|
#zoneOverlay
|
||||||
|
class="zone-overlay position-absolute top-0 start-0 w-100 h-100"
|
||||||
|
[attr.viewBox]="overlayViewBox()"
|
||||||
|
preserveAspectRatio="none"
|
||||||
|
[style.cursor]="overlayCursor"
|
||||||
|
(pointerdown)="onOverlayPointerDown($event)"
|
||||||
|
(pointermove)="onOverlayPointerMove($event)"
|
||||||
|
(pointerup)="onOverlayPointerUp($event)"
|
||||||
|
(pointercancel)="onOverlayPointerCancel($event)"
|
||||||
|
(lostpointercapture)="onOverlayPointerCancel($event)"
|
||||||
|
aria-label="Zone overlay" i18n-aria-label
|
||||||
|
>
|
||||||
|
@for (zone of template.zones; track $index; let i = $index) {
|
||||||
|
@if (zoneDisplayRect(i); as rect) {
|
||||||
|
<g>
|
||||||
|
<rect
|
||||||
|
class="zone-rect"
|
||||||
|
[class.zone-rect-selected]="selectedZoneIndex === i"
|
||||||
|
[attr.x]="rect.x"
|
||||||
|
[attr.y]="rect.y"
|
||||||
|
[attr.width]="rect.w"
|
||||||
|
[attr.height]="rect.h"
|
||||||
|
[attr.stroke]="zoneColor(i)"
|
||||||
|
[attr.fill]="zoneColor(i)"
|
||||||
|
></rect>
|
||||||
|
<text
|
||||||
|
class="zone-label fw-semibold pe-none"
|
||||||
|
[attr.x]="rect.x + overlayUnitSize(6)"
|
||||||
|
[attr.y]="zoneLabelY(rect)"
|
||||||
|
[attr.font-size]="overlayFontSize()"
|
||||||
|
[attr.fill]="zoneColor(i)"
|
||||||
|
>{{ zoneLabel(zone, i) }}</text>
|
||||||
|
|
||||||
|
@if (selectedZoneIndex === i) {
|
||||||
|
@for (handle of resizeHandles(rect); track handle.handle) {
|
||||||
|
<rect
|
||||||
|
class="zone-resize-handle"
|
||||||
|
[attr.x]="handle.x - overlayHandleSize() / 2"
|
||||||
|
[attr.y]="handle.y - overlayHandleSize() / 2"
|
||||||
|
[attr.width]="overlayHandleSize()"
|
||||||
|
[attr.height]="overlayHandleSize()"
|
||||||
|
[attr.fill]="zoneColor(i)"
|
||||||
|
></rect>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</g>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (drawingRect(); as rect) {
|
||||||
|
<rect
|
||||||
|
class="zone-drawing-rect"
|
||||||
|
[attr.x]="rect.x"
|
||||||
|
[attr.y]="rect.y"
|
||||||
|
[attr.width]="rect.w"
|
||||||
|
[attr.height]="rect.h"
|
||||||
|
></rect>
|
||||||
|
}
|
||||||
|
</svg>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
@if (!pageLoaded()) {
|
||||||
|
<div class="p-5 d-flex justify-content-center align-items-center">
|
||||||
|
<output class="spinner-border">
|
||||||
|
<span class="visually-hidden" i18n>Loading page...</span>
|
||||||
|
</output>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
} @else {
|
||||||
|
<div class="py-5 px-3 text-center text-muted">
|
||||||
|
<i-bs name="file-text" width="32" height="32"></i-bs>
|
||||||
|
<p class="mt-3 mb-0" i18n>No sample document selected.</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-outline-secondary" (click)="activeModal.dismiss()" [disabled]="saving()" i18n>Cancel</button>
|
||||||
|
<button type="button" class="btn btn-primary" (click)="save()" [disabled]="saving() || !isDirty">
|
||||||
|
@if (saving()) {
|
||||||
|
<span class="spinner-border spinner-border-sm me-1" aria-hidden="true"></span>
|
||||||
|
}
|
||||||
|
<span i18n>Save</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
+64
@@ -0,0 +1,64 @@
|
|||||||
|
:host {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 992px) {
|
||||||
|
.modal-body {
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-layout {
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-page-input {
|
||||||
|
width: 3.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-zoom {
|
||||||
|
min-width: 4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.zone-overlay {
|
||||||
|
touch-action: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.zone-rect,
|
||||||
|
.zone-drawing-rect {
|
||||||
|
vector-effect: non-scaling-stroke;
|
||||||
|
}
|
||||||
|
|
||||||
|
.zone-rect {
|
||||||
|
fill-opacity: 0.2;
|
||||||
|
stroke-width: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.zone-rect-selected {
|
||||||
|
stroke-width: 3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.zone-label {
|
||||||
|
font-family: var(--bs-font-sans-serif);
|
||||||
|
paint-order: stroke;
|
||||||
|
stroke: #fff;
|
||||||
|
stroke-linejoin: round;
|
||||||
|
stroke-width: 4px;
|
||||||
|
vector-effect: non-scaling-stroke;
|
||||||
|
}
|
||||||
|
|
||||||
|
.zone-resize-handle {
|
||||||
|
stroke: #fff;
|
||||||
|
stroke-width: 1;
|
||||||
|
vector-effect: non-scaling-stroke;
|
||||||
|
}
|
||||||
|
|
||||||
|
.zone-drawing-rect {
|
||||||
|
fill: var(--bs-primary);
|
||||||
|
fill-opacity: 0.2;
|
||||||
|
stroke: var(--bs-primary);
|
||||||
|
stroke-dasharray: 5 5;
|
||||||
|
stroke-width: 2;
|
||||||
|
}
|
||||||
+529
@@ -0,0 +1,529 @@
|
|||||||
|
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
|
||||||
|
import { provideHttpClientTesting } from '@angular/common/http/testing'
|
||||||
|
import { ElementRef } from '@angular/core'
|
||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||||
|
import { By } from '@angular/platform-browser'
|
||||||
|
import { NgbActiveModal, NgbModal } from '@ng-bootstrap/ng-bootstrap'
|
||||||
|
import { allIcons, NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||||
|
import { of, Subject, throwError } from 'rxjs'
|
||||||
|
import { PermissionsFormComponent } from 'src/app/components/common/input/permissions/permissions-form/permissions-form.component'
|
||||||
|
import { CustomField, CustomFieldDataType } from 'src/app/data/custom-field'
|
||||||
|
import { Document } from 'src/app/data/document'
|
||||||
|
import {
|
||||||
|
MATCH_ANY,
|
||||||
|
MATCH_AUTO,
|
||||||
|
MATCH_LITERAL,
|
||||||
|
MATCH_NONE,
|
||||||
|
} from 'src/app/data/matching-model'
|
||||||
|
import {
|
||||||
|
OcrTemplate,
|
||||||
|
OcrTemplateZone,
|
||||||
|
OcrZoneTarget,
|
||||||
|
OcrZoneTestResult,
|
||||||
|
OcrZoneTransform,
|
||||||
|
} from 'src/app/data/ocr-template'
|
||||||
|
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
||||||
|
import { User } from 'src/app/data/user'
|
||||||
|
import { PermissionsService } from 'src/app/services/permissions.service'
|
||||||
|
import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
|
||||||
|
import { DocumentService } from 'src/app/services/rest/document.service'
|
||||||
|
import { OcrTemplateService } from 'src/app/services/rest/ocr-template.service'
|
||||||
|
import { UserService } from 'src/app/services/rest/user.service'
|
||||||
|
import { SettingsService } from 'src/app/services/settings.service'
|
||||||
|
import { OcrTemplateEditorComponent } from './ocr-template-editor.component'
|
||||||
|
|
||||||
|
const customFields: CustomField[] = [
|
||||||
|
{ id: 1, name: 'Invoice number', data_type: CustomFieldDataType.String },
|
||||||
|
{ id: 2, name: 'Status', data_type: CustomFieldDataType.Select },
|
||||||
|
]
|
||||||
|
|
||||||
|
function zone(overrides: Partial<OcrTemplateZone> = {}): OcrTemplateZone {
|
||||||
|
return {
|
||||||
|
name: 'Invoice number',
|
||||||
|
target: OcrZoneTarget.CustomField,
|
||||||
|
custom_field: 1,
|
||||||
|
x: 100,
|
||||||
|
y: 100,
|
||||||
|
width: 200,
|
||||||
|
height: 100,
|
||||||
|
page: 1,
|
||||||
|
ocr_language: 'eng',
|
||||||
|
transform: OcrZoneTransform.Strip,
|
||||||
|
validation_regex: '',
|
||||||
|
order: 0,
|
||||||
|
...overrides,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function pointer(x: number, y: number, pointerId = 1): PointerEvent {
|
||||||
|
return Object.assign(
|
||||||
|
new MouseEvent('pointermove', { clientX: x, clientY: y, button: 0 }),
|
||||||
|
{ pointerId }
|
||||||
|
) as PointerEvent
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('OcrTemplateEditorComponent', () => {
|
||||||
|
let component: OcrTemplateEditorComponent
|
||||||
|
let fixture: ComponentFixture<OcrTemplateEditorComponent>
|
||||||
|
let documentService: DocumentService
|
||||||
|
let templateService: OcrTemplateService
|
||||||
|
let modalService: NgbModal
|
||||||
|
const activeModal = { close: jest.fn(), dismiss: jest.fn() }
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
imports: [
|
||||||
|
NgxBootstrapIconsModule.pick(allIcons),
|
||||||
|
OcrTemplateEditorComponent,
|
||||||
|
],
|
||||||
|
providers: [
|
||||||
|
{ provide: NgbActiveModal, useValue: activeModal },
|
||||||
|
provideHttpClient(withInterceptorsFromDi()),
|
||||||
|
provideHttpClientTesting(),
|
||||||
|
],
|
||||||
|
})
|
||||||
|
jest
|
||||||
|
.spyOn(TestBed.inject(CustomFieldsService), 'listAll')
|
||||||
|
.mockReturnValue(of({ count: 2, all: [1, 2], results: customFields }))
|
||||||
|
jest
|
||||||
|
.spyOn(TestBed.inject(UserService), 'listAll')
|
||||||
|
.mockReturnValue(of({ count: 0, all: [], results: [] }))
|
||||||
|
TestBed.inject(PermissionsService).initialize([], { id: 1 } as User)
|
||||||
|
documentService = TestBed.inject(DocumentService)
|
||||||
|
jest
|
||||||
|
.spyOn(documentService, 'get')
|
||||||
|
.mockReturnValue(of({ id: 5, page_count: 3 }))
|
||||||
|
templateService = TestBed.inject(OcrTemplateService)
|
||||||
|
modalService = TestBed.inject(NgbModal)
|
||||||
|
activeModal.close.mockReset()
|
||||||
|
fixture = TestBed.createComponent(OcrTemplateEditorComponent)
|
||||||
|
component = fixture.componentInstance
|
||||||
|
})
|
||||||
|
|
||||||
|
function prepareOverlay() {
|
||||||
|
const image = document.createElement('img')
|
||||||
|
Object.defineProperties(image, {
|
||||||
|
naturalWidth: { value: 1000 },
|
||||||
|
naturalHeight: { value: 1000 },
|
||||||
|
clientWidth: { value: 1000 },
|
||||||
|
})
|
||||||
|
const overlay = document.createElementNS(
|
||||||
|
'http://www.w3.org/2000/svg',
|
||||||
|
'svg'
|
||||||
|
)
|
||||||
|
Object.assign(overlay, {
|
||||||
|
getScreenCTM: () => ({ inverse: () => ({}) }),
|
||||||
|
createSVGPoint: () => ({
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
matrixTransform() {
|
||||||
|
return { x: this.x, y: this.y }
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
setPointerCapture: jest.fn(),
|
||||||
|
hasPointerCapture: () => true,
|
||||||
|
releasePointerCapture: jest.fn(),
|
||||||
|
})
|
||||||
|
component.imageRef = new ElementRef(image)
|
||||||
|
component.overlayRef = new ElementRef(overlay)
|
||||||
|
component.pageLoaded.set(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
it('should edit a copy of the template and load its sample document', () => {
|
||||||
|
const original: OcrTemplate = {
|
||||||
|
...component.template,
|
||||||
|
id: 3,
|
||||||
|
name: 'Invoices',
|
||||||
|
match: 'Northwind',
|
||||||
|
matching_algorithm: MATCH_LITERAL,
|
||||||
|
sample_document: 5,
|
||||||
|
zones: [zone()],
|
||||||
|
combine_formats: { '1': '{Invoice number}' },
|
||||||
|
}
|
||||||
|
component.object = original
|
||||||
|
fixture.detectChanges()
|
||||||
|
|
||||||
|
expect(component.dialogTitle).toEqual('Edit OCR Template')
|
||||||
|
expect(component.previewDocId).toEqual(5)
|
||||||
|
expect(component.previewPageCount()).toEqual(3)
|
||||||
|
expect(component.pageImageUrl).toContain('document-page-image/5/0/')
|
||||||
|
expect(component.template.match).toEqual('Northwind')
|
||||||
|
expect(component.isDirty).toBeFalsy()
|
||||||
|
|
||||||
|
component.template.zones[0].x = 500
|
||||||
|
component.template.combine_formats['1'] = 'Edited'
|
||||||
|
expect(original.zones[0].x).toEqual(100)
|
||||||
|
expect(original.combine_formats['1']).toEqual('{Invoice number}')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should offer every matching algorithm except automatic', () => {
|
||||||
|
expect(component.matchingAlgorithms.map((a) => a.id)).not.toContain(
|
||||||
|
MATCH_AUTO
|
||||||
|
)
|
||||||
|
expect(component.template.matching_algorithm).toEqual(MATCH_ANY)
|
||||||
|
expect(component.patternRequired).toBeTruthy()
|
||||||
|
|
||||||
|
component.template.matching_algorithm = MATCH_NONE
|
||||||
|
expect(component.patternRequired).toBeFalsy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should initialize new template permissions from saved defaults', () => {
|
||||||
|
const defaults = {
|
||||||
|
[SETTINGS_KEYS.DEFAULT_PERMS_OWNER]: 1,
|
||||||
|
[SETTINGS_KEYS.DEFAULT_PERMS_VIEW_USERS]: [2],
|
||||||
|
[SETTINGS_KEYS.DEFAULT_PERMS_VIEW_GROUPS]: [3],
|
||||||
|
[SETTINGS_KEYS.DEFAULT_PERMS_EDIT_USERS]: [],
|
||||||
|
[SETTINGS_KEYS.DEFAULT_PERMS_EDIT_GROUPS]: [4],
|
||||||
|
}
|
||||||
|
jest
|
||||||
|
.spyOn(TestBed.inject(SettingsService), 'get')
|
||||||
|
.mockImplementation((key) => defaults[key])
|
||||||
|
fixture.detectChanges()
|
||||||
|
expect(component.permissionsForm).toEqual({
|
||||||
|
owner: 1,
|
||||||
|
set_permissions: {
|
||||||
|
view: { users: [2], groups: [3] },
|
||||||
|
change: { users: [], groups: [4] },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
expect(
|
||||||
|
fixture.debugElement.query(By.directive(PermissionsFormComponent))
|
||||||
|
).toBeTruthy()
|
||||||
|
expect(component.isDirty).toBe(false)
|
||||||
|
const create = jest
|
||||||
|
.spyOn(templateService, 'create')
|
||||||
|
.mockReturnValue(of({ ...component.template, id: 3 }))
|
||||||
|
component.save()
|
||||||
|
expect(create).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining(component.permissionsForm)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should copy existing permissions and track changes without mutating the list item', () => {
|
||||||
|
component.object = {
|
||||||
|
...component.template,
|
||||||
|
id: 3,
|
||||||
|
owner: 1,
|
||||||
|
permissions: {
|
||||||
|
view: { users: [2], groups: [] },
|
||||||
|
change: { users: [], groups: [3] },
|
||||||
|
},
|
||||||
|
}
|
||||||
|
fixture.detectChanges()
|
||||||
|
expect(component.permissionsForm.owner).toBe(1)
|
||||||
|
expect(component.isDirty).toBe(false)
|
||||||
|
component.permissionsForm.set_permissions.view.users.push(4)
|
||||||
|
expect(component.isDirty).toBe(true)
|
||||||
|
expect(component.object.permissions.view.users).toEqual([2])
|
||||||
|
const update = jest
|
||||||
|
.spyOn(templateService, 'update')
|
||||||
|
.mockReturnValue(of(component.template))
|
||||||
|
component.save()
|
||||||
|
expect(update).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({
|
||||||
|
owner: 1,
|
||||||
|
set_permissions: {
|
||||||
|
view: { users: [2, 4], groups: [] },
|
||||||
|
change: { users: [], groups: [3] },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should hide sharing controls and omit permission changes for a shared editor', () => {
|
||||||
|
component.object = {
|
||||||
|
...component.template,
|
||||||
|
id: 3,
|
||||||
|
owner: 2,
|
||||||
|
user_can_change: true,
|
||||||
|
}
|
||||||
|
fixture.detectChanges()
|
||||||
|
expect(
|
||||||
|
fixture.debugElement.query(By.directive(PermissionsFormComponent))
|
||||||
|
).toBeNull()
|
||||||
|
component.permissionsForm = {
|
||||||
|
owner: 1,
|
||||||
|
set_permissions: { view: { users: [1] } },
|
||||||
|
}
|
||||||
|
const update = jest
|
||||||
|
.spyOn(templateService, 'update')
|
||||||
|
.mockReturnValue(of(component.template))
|
||||||
|
component.save()
|
||||||
|
expect(update.mock.calls[0][0].owner).toBe(2)
|
||||||
|
expect(update.mock.calls[0][0]).not.toHaveProperty('set_permissions')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should keep only the last picked sample document', () => {
|
||||||
|
component.setSampleDocument([5, 6])
|
||||||
|
expect(component.sampleDocumentIds).toEqual([6])
|
||||||
|
|
||||||
|
component.setSampleDocument([])
|
||||||
|
expect(component.previewDocId).toBeNull()
|
||||||
|
expect(component.pageImageUrl).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should ignore a stale sample document response', () => {
|
||||||
|
const pending = new Subject<Document>()
|
||||||
|
jest.spyOn(documentService, 'get').mockReturnValueOnce(pending)
|
||||||
|
component.setSampleDocument([5])
|
||||||
|
component.setSampleDocument([6])
|
||||||
|
pending.next({ id: 5, page_count: 99 })
|
||||||
|
|
||||||
|
expect(component.previewPageCount()).toEqual(3)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should show a failed page and retry it', () => {
|
||||||
|
jest
|
||||||
|
.spyOn(documentService, 'get')
|
||||||
|
.mockReturnValueOnce(throwError(() => new Error('offline')))
|
||||||
|
component.setSampleDocument([5])
|
||||||
|
expect(component.pageFailed()).toBeTruthy()
|
||||||
|
|
||||||
|
component.setSampleDocument(component.sampleDocumentIds)
|
||||||
|
expect(component.pageFailed()).toBeFalsy()
|
||||||
|
|
||||||
|
component.onImageError()
|
||||||
|
expect(component.pageFailed()).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should support create and update', () => {
|
||||||
|
const createSpy = jest
|
||||||
|
.spyOn(templateService, 'create')
|
||||||
|
.mockReturnValue(of({ ...component.template, id: 3 }))
|
||||||
|
const updateSpy = jest
|
||||||
|
.spyOn(templateService, 'update')
|
||||||
|
.mockReturnValue(of(component.template))
|
||||||
|
const succeededSpy = jest.spyOn(component.succeeded, 'emit')
|
||||||
|
|
||||||
|
component.save()
|
||||||
|
expect(createSpy).toHaveBeenCalled()
|
||||||
|
expect(succeededSpy).toHaveBeenCalledWith({ ...component.template, id: 3 })
|
||||||
|
expect(activeModal.close).toHaveBeenCalled()
|
||||||
|
|
||||||
|
component.saving.set(false)
|
||||||
|
component.template.id = 3
|
||||||
|
component.save()
|
||||||
|
expect(updateSpy).toHaveBeenCalledWith(component.template)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should emit failed and allow editing again after a failed save', () => {
|
||||||
|
prepareOverlay()
|
||||||
|
component.template.zones = [zone()]
|
||||||
|
const pending = new Subject<OcrTemplate>()
|
||||||
|
jest.spyOn(templateService, 'create').mockReturnValue(pending)
|
||||||
|
const failedSpy = jest.spyOn(component.failed, 'emit')
|
||||||
|
component.onOverlayPointerDown(pointer(150, 150))
|
||||||
|
|
||||||
|
component.save()
|
||||||
|
component.save()
|
||||||
|
expect(templateService.create).toHaveBeenCalledTimes(1)
|
||||||
|
component.onOverlayPointerMove(pointer(250, 250))
|
||||||
|
component.onOverlayPointerUp(pointer(250, 250))
|
||||||
|
expect(component.template.zones[0].x).toEqual(100)
|
||||||
|
|
||||||
|
pending.error(new Error('Save failed'))
|
||||||
|
expect(failedSpy).toHaveBeenCalled()
|
||||||
|
expect(component.saving()).toBeFalsy()
|
||||||
|
component.onOverlayPointerDown(pointer(150, 150))
|
||||||
|
component.onOverlayPointerUp(pointer(250, 250))
|
||||||
|
expect(component.template.zones[0].x).toEqual(200)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should track unsaved changes', () => {
|
||||||
|
fixture.detectChanges()
|
||||||
|
expect(component.isDirty).toBeFalsy()
|
||||||
|
|
||||||
|
component.template.combine_formats = { '1': '' }
|
||||||
|
expect(component.isDirty).toBeFalsy()
|
||||||
|
|
||||||
|
component.template.name = 'Invoices'
|
||||||
|
expect(component.isDirty).toBeTruthy()
|
||||||
|
component.template.name = ''
|
||||||
|
expect(component.isDirty).toBeFalsy()
|
||||||
|
|
||||||
|
component.setSampleDocument([5])
|
||||||
|
expect(component.isDirty).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should draw a zone with no field in empty space', () => {
|
||||||
|
prepareOverlay()
|
||||||
|
component.onOverlayPointerDown(pointer(100, 100))
|
||||||
|
component.onOverlayPointerUp(pointer(1200, 250))
|
||||||
|
|
||||||
|
expect(component.template.zones[0]).toMatchObject({
|
||||||
|
x: 100,
|
||||||
|
y: 100,
|
||||||
|
width: 900,
|
||||||
|
height: 150,
|
||||||
|
custom_field: null,
|
||||||
|
})
|
||||||
|
expect(component.selectedZoneIndex).toEqual(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should clear the selection on a click in empty space', () => {
|
||||||
|
prepareOverlay()
|
||||||
|
component.template.zones = [zone()]
|
||||||
|
component.selectZone(0)
|
||||||
|
component.onOverlayPointerDown(pointer(600, 600))
|
||||||
|
component.onOverlayPointerUp(pointer(602, 601))
|
||||||
|
|
||||||
|
expect(component.template.zones).toHaveLength(1)
|
||||||
|
expect(component.selectedZoneIndex).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should move a zone when dragging on it', () => {
|
||||||
|
prepareOverlay()
|
||||||
|
component.template.zones = [zone()]
|
||||||
|
component.onOverlayPointerDown(pointer(150, 150))
|
||||||
|
component.onOverlayPointerUp(pointer(250, 200))
|
||||||
|
|
||||||
|
expect(component.template.zones).toHaveLength(1)
|
||||||
|
expect(component.template.zones[0]).toMatchObject({ x: 200, y: 150 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should cancel a drawing on escape and ignore other pointers', () => {
|
||||||
|
prepareOverlay()
|
||||||
|
component.onOverlayPointerDown(pointer(100, 100))
|
||||||
|
component.onOverlayPointerUp(pointer(300, 200, 2))
|
||||||
|
expect(component.drawingRect()).not.toBeNull()
|
||||||
|
|
||||||
|
component.cancelDrawing()
|
||||||
|
component.onOverlayPointerUp(pointer(300, 200))
|
||||||
|
expect(component.template.zones).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should open a zone in the zones tab and go back to the list', () => {
|
||||||
|
component.template.zones = [zone(), zone()]
|
||||||
|
component.selectZone(1)
|
||||||
|
expect(component.activeTab).toEqual('zones')
|
||||||
|
expect(component.selectedZone).toBe(component.template.zones[1])
|
||||||
|
|
||||||
|
component.deselectZone()
|
||||||
|
expect(component.selectedZone).toBeNull()
|
||||||
|
|
||||||
|
component.selectZone(0)
|
||||||
|
component.removeZone(0)
|
||||||
|
expect(component.template.zones).toHaveLength(1)
|
||||||
|
expect(component.selectedZone).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should only offer custom fields OCR can write to', () => {
|
||||||
|
fixture.detectChanges()
|
||||||
|
expect(component.fieldItems().map((item) => item.id)).toEqual([
|
||||||
|
OcrZoneTarget.Title,
|
||||||
|
OcrZoneTarget.Asn,
|
||||||
|
OcrZoneTarget.Created,
|
||||||
|
1,
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should create a custom field and assign it to the zone', () => {
|
||||||
|
const succeeded = new Subject<CustomField>()
|
||||||
|
const dialog = {
|
||||||
|
dialogMode: { set: jest.fn() },
|
||||||
|
object: null,
|
||||||
|
succeeded,
|
||||||
|
failed: new Subject(),
|
||||||
|
}
|
||||||
|
jest
|
||||||
|
.spyOn(modalService, 'open')
|
||||||
|
.mockReturnValue({ componentInstance: dialog } as any)
|
||||||
|
component.template.zones = [zone({ custom_field: null })]
|
||||||
|
component.selectZone(0)
|
||||||
|
|
||||||
|
component.createField()
|
||||||
|
expect(dialog.object).toEqual({ name: 'Invoice number' })
|
||||||
|
succeeded.next(customFields[0])
|
||||||
|
|
||||||
|
expect(component.template.zones[0].custom_field).toEqual(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should build and prune combine formats', () => {
|
||||||
|
const first = zone({ name: 'First' })
|
||||||
|
const second = zone({ name: 'Second' })
|
||||||
|
const other = zone({ name: 'Other', custom_field: 2 })
|
||||||
|
component.template.zones = [first, second, other]
|
||||||
|
component.template.combine_formats = { '2': '{Other}' }
|
||||||
|
|
||||||
|
expect(component.isFieldShared(first)).toBeTruthy()
|
||||||
|
expect(component.isFieldShared(other)).toBeFalsy()
|
||||||
|
expect(component.defaultCombineFormat(first)).toEqual('{First} {Second}')
|
||||||
|
|
||||||
|
component.insertCombineToken(first, second)
|
||||||
|
component.insertCombineToken(first, first)
|
||||||
|
expect(component.getCombineFormat(second)).toEqual('{Second} {First}')
|
||||||
|
|
||||||
|
jest.spyOn(templateService, 'create').mockReturnValue(of(null))
|
||||||
|
component.save()
|
||||||
|
expect(component.template.combine_formats).toEqual({
|
||||||
|
'1': '{Second} {First}',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should update exact tokens when a zone is renamed', () => {
|
||||||
|
const first = zone({ name: 'First [value]' })
|
||||||
|
component.template.zones = [first, zone({ name: 'Second' })]
|
||||||
|
component.template.combine_formats = {
|
||||||
|
'1': 'First [value]: {First [value]} / { First [value] } / {Second} / {First [value] extra}',
|
||||||
|
'2': '{First [value]}',
|
||||||
|
}
|
||||||
|
|
||||||
|
component.renameZone(first, '')
|
||||||
|
component.renameZone(first, 'Invoice number')
|
||||||
|
|
||||||
|
expect(first.name).toEqual('Invoice number')
|
||||||
|
expect(component.template.combine_formats).toEqual({
|
||||||
|
'1': 'First [value]: {Invoice number} / {Invoice number} / {Second} / {First [value] extra}',
|
||||||
|
'2': '{First [value]}',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should keep the language array stable until the value changes', () => {
|
||||||
|
const selected = zone({ ocr_language: 'deu+eng' })
|
||||||
|
const languages = component.ocrLanguageArray(selected)
|
||||||
|
expect(languages).toEqual(['deu', 'eng'])
|
||||||
|
expect(component.ocrLanguageArray(selected)).toBe(languages)
|
||||||
|
|
||||||
|
component.setOcrLanguages(selected, ['fra'])
|
||||||
|
expect(selected.ocr_language).toEqual('fra')
|
||||||
|
expect(component.ocrLanguageArray(selected)).toEqual(['fra'])
|
||||||
|
|
||||||
|
component.setOcrLanguages(selected, null)
|
||||||
|
expect(component.ocrLanguageArray(selected)).toEqual([])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should hide a test result once the zone changes', () => {
|
||||||
|
const selected = zone()
|
||||||
|
component.template.zones = [selected]
|
||||||
|
component.selectZone(0)
|
||||||
|
component.setSampleDocument([5])
|
||||||
|
jest
|
||||||
|
.spyOn(templateService, 'testZone')
|
||||||
|
.mockReturnValue(of({ value: '123' }))
|
||||||
|
|
||||||
|
component.testZone()
|
||||||
|
expect(component.zoneTestResult.value).toEqual('123')
|
||||||
|
|
||||||
|
selected.transform = OcrZoneTransform.Numeric
|
||||||
|
expect(component.zoneTestResult).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should drop a pending test when the zone or sample document changes', () => {
|
||||||
|
const pending = new Subject<OcrZoneTestResult>()
|
||||||
|
component.template.zones = [zone(), zone()]
|
||||||
|
component.selectZone(0)
|
||||||
|
component.setSampleDocument([5])
|
||||||
|
jest.spyOn(templateService, 'testZone').mockReturnValue(pending)
|
||||||
|
|
||||||
|
component.testZone()
|
||||||
|
expect(component.zoneTesting).toBeTruthy()
|
||||||
|
component.selectZone(1)
|
||||||
|
pending.next({ value: 'Old zone' })
|
||||||
|
expect(component.zoneTestResult).toBeNull()
|
||||||
|
|
||||||
|
component.testZone()
|
||||||
|
component.setSampleDocument([])
|
||||||
|
pending.next({ value: 'Old document' })
|
||||||
|
expect(component.zoneTestResult).toBeNull()
|
||||||
|
})
|
||||||
|
})
|
||||||
+963
@@ -0,0 +1,963 @@
|
|||||||
|
import { CommonModule } from '@angular/common'
|
||||||
|
import {
|
||||||
|
Component,
|
||||||
|
computed,
|
||||||
|
ElementRef,
|
||||||
|
EventEmitter,
|
||||||
|
HostListener,
|
||||||
|
inject,
|
||||||
|
Input,
|
||||||
|
OnDestroy,
|
||||||
|
OnInit,
|
||||||
|
Output,
|
||||||
|
signal,
|
||||||
|
ViewChild,
|
||||||
|
} from '@angular/core'
|
||||||
|
import { FormsModule } from '@angular/forms'
|
||||||
|
import {
|
||||||
|
NgbActiveModal,
|
||||||
|
NgbModal,
|
||||||
|
NgbNavModule,
|
||||||
|
} from '@ng-bootstrap/ng-bootstrap'
|
||||||
|
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||||
|
import { Subject, takeUntil } from 'rxjs'
|
||||||
|
import { CustomFieldEditDialogComponent } from 'src/app/components/common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component'
|
||||||
|
import { EditDialogMode } from 'src/app/components/common/edit-dialog/edit-dialog.component'
|
||||||
|
import { DocumentLinkComponent } from 'src/app/components/common/input/document-link/document-link.component'
|
||||||
|
import { CheckComponent } from 'src/app/components/common/input/check/check.component'
|
||||||
|
import { NumberComponent } from 'src/app/components/common/input/number/number.component'
|
||||||
|
import {
|
||||||
|
PermissionsFormComponent,
|
||||||
|
PermissionsFormObject,
|
||||||
|
} from 'src/app/components/common/input/permissions/permissions-form/permissions-form.component'
|
||||||
|
import { SelectComponent } from 'src/app/components/common/input/select/select.component'
|
||||||
|
import { SwitchComponent } from 'src/app/components/common/input/switch/switch.component'
|
||||||
|
import { TextComponent } from 'src/app/components/common/input/text/text.component'
|
||||||
|
import { CustomField } from 'src/app/data/custom-field'
|
||||||
|
import {
|
||||||
|
MATCH_ANY,
|
||||||
|
MATCH_AUTO,
|
||||||
|
MATCH_NONE,
|
||||||
|
MATCHING_ALGORITHMS,
|
||||||
|
} from 'src/app/data/matching-model'
|
||||||
|
import {
|
||||||
|
DEFAULT_OCR_ZONE_LANGUAGE,
|
||||||
|
getZoneFieldKey,
|
||||||
|
OCR_BUILTIN_TARGETS,
|
||||||
|
OCR_SUPPORTED_FIELD_TYPES,
|
||||||
|
OcrTemplate,
|
||||||
|
OcrTemplateZone,
|
||||||
|
OcrZoneTarget,
|
||||||
|
OcrZoneTestResult,
|
||||||
|
OcrZoneTransform,
|
||||||
|
ZoneTestRequest,
|
||||||
|
} from 'src/app/data/ocr-template'
|
||||||
|
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
||||||
|
import { User } from 'src/app/data/user'
|
||||||
|
import { IfOwnerDirective } from 'src/app/directives/if-owner.directive'
|
||||||
|
import { PermissionsService } from 'src/app/services/permissions.service'
|
||||||
|
import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
|
||||||
|
import { DocumentService } from 'src/app/services/rest/document.service'
|
||||||
|
import { OcrTemplateService } from 'src/app/services/rest/ocr-template.service'
|
||||||
|
import { UserService } from 'src/app/services/rest/user.service'
|
||||||
|
import { SettingsService } from 'src/app/services/settings.service'
|
||||||
|
import { ToastService } from 'src/app/services/toast.service'
|
||||||
|
import { OcrTemplateEditorZoneListComponent } from './ocr-template-editor-zone-list/ocr-template-editor-zone-list.component'
|
||||||
|
import {
|
||||||
|
DisplayRect,
|
||||||
|
DrawingRect,
|
||||||
|
findHandleAt,
|
||||||
|
findZoneAt,
|
||||||
|
getResizeHandles,
|
||||||
|
getZoneColor,
|
||||||
|
getZoneDisplayRect,
|
||||||
|
getZonePage,
|
||||||
|
HANDLE_SIZE,
|
||||||
|
isZoneOnPage,
|
||||||
|
MIN_ZONE_SIZE,
|
||||||
|
MoveStart,
|
||||||
|
moveZone,
|
||||||
|
Point,
|
||||||
|
ResizeHandle,
|
||||||
|
resizeZone,
|
||||||
|
} from './zone-geometry'
|
||||||
|
|
||||||
|
type ActiveTab = 'template' | 'zones'
|
||||||
|
type ZoneFieldSelection = OcrZoneTarget | number | null
|
||||||
|
type OverlayInteraction =
|
||||||
|
| { kind: 'idle' }
|
||||||
|
| { kind: 'drawing'; pointerId: number; rect: DrawingRect }
|
||||||
|
| { kind: 'moving'; pointerId: number; zoneIndex: number; start: MoveStart }
|
||||||
|
| {
|
||||||
|
kind: 'resizing'
|
||||||
|
pointerId: number
|
||||||
|
zoneIndex: number
|
||||||
|
handle: ResizeHandle
|
||||||
|
}
|
||||||
|
interface ZoneTest {
|
||||||
|
zone: OcrTemplateZone
|
||||||
|
/** The sample document and zone settings the result was produced from. */
|
||||||
|
key: string
|
||||||
|
result: OcrZoneTestResult | null
|
||||||
|
}
|
||||||
|
|
||||||
|
export const ZONE_TRANSFORM_OPTIONS = [
|
||||||
|
{ id: OcrZoneTransform.None, name: $localize`None` },
|
||||||
|
{ id: OcrZoneTransform.Strip, name: $localize`Strip whitespace` },
|
||||||
|
{ id: OcrZoneTransform.Uppercase, name: $localize`Uppercase` },
|
||||||
|
{ id: OcrZoneTransform.Lowercase, name: $localize`Lowercase` },
|
||||||
|
{ id: OcrZoneTransform.Numeric, name: $localize`Numeric only` },
|
||||||
|
{
|
||||||
|
id: OcrZoneTransform.StripPunctuation,
|
||||||
|
name: $localize`Remove leading/trailing punctuation`,
|
||||||
|
},
|
||||||
|
{ id: OcrZoneTransform.Date, name: $localize`Parse date` },
|
||||||
|
{ id: OcrZoneTransform.QrCode, name: $localize`Read QR/barcode` },
|
||||||
|
]
|
||||||
|
|
||||||
|
export const ZONE_LANGUAGE_OPTIONS = [
|
||||||
|
{ id: 'eng', name: $localize`English` },
|
||||||
|
{ id: 'deu', name: $localize`German` },
|
||||||
|
{ id: 'fra', name: $localize`French` },
|
||||||
|
{ id: 'ita', name: $localize`Italian` },
|
||||||
|
{ id: 'spa', name: $localize`Spanish` },
|
||||||
|
{ id: 'por', name: $localize`Portuguese` },
|
||||||
|
{ id: 'nld', name: $localize`Dutch` },
|
||||||
|
]
|
||||||
|
|
||||||
|
const TEMPLATE_MATCHING_ALGORITHMS = MATCHING_ALGORITHMS.filter(
|
||||||
|
(algorithm) => algorithm.id !== MATCH_AUTO
|
||||||
|
)
|
||||||
|
|
||||||
|
const NO_OVERLAY_INTERACTION: OverlayInteraction = { kind: 'idle' }
|
||||||
|
const RESIZE_CURSOR: Record<ResizeHandle, string> = {
|
||||||
|
nw: 'nw-resize',
|
||||||
|
ne: 'ne-resize',
|
||||||
|
sw: 'sw-resize',
|
||||||
|
se: 'se-resize',
|
||||||
|
n: 'n-resize',
|
||||||
|
s: 's-resize',
|
||||||
|
w: 'w-resize',
|
||||||
|
e: 'e-resize',
|
||||||
|
}
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'pngx-ocr-template-editor',
|
||||||
|
imports: [
|
||||||
|
DocumentLinkComponent,
|
||||||
|
NumberComponent,
|
||||||
|
TextComponent,
|
||||||
|
CheckComponent,
|
||||||
|
SelectComponent,
|
||||||
|
SwitchComponent,
|
||||||
|
CommonModule,
|
||||||
|
FormsModule,
|
||||||
|
NgbNavModule,
|
||||||
|
NgxBootstrapIconsModule,
|
||||||
|
OcrTemplateEditorZoneListComponent,
|
||||||
|
PermissionsFormComponent,
|
||||||
|
IfOwnerDirective,
|
||||||
|
],
|
||||||
|
templateUrl: './ocr-template-editor.component.html',
|
||||||
|
styleUrls: ['./ocr-template-editor.component.scss'],
|
||||||
|
})
|
||||||
|
export class OcrTemplateEditorComponent implements OnInit, OnDestroy {
|
||||||
|
public readonly activeModal = inject(NgbActiveModal)
|
||||||
|
private readonly templateService = inject(OcrTemplateService)
|
||||||
|
private readonly customFieldsService = inject(CustomFieldsService)
|
||||||
|
private readonly documentService = inject(DocumentService)
|
||||||
|
private readonly toastService = inject(ToastService)
|
||||||
|
private readonly modalService = inject(NgbModal)
|
||||||
|
private readonly userService = inject(UserService)
|
||||||
|
private readonly settingsService = inject(SettingsService)
|
||||||
|
private readonly permissionsService = inject(PermissionsService)
|
||||||
|
private readonly destroy$ = new Subject<void>()
|
||||||
|
private readonly cancelZoneTest$ = new Subject<void>()
|
||||||
|
|
||||||
|
@Input() object: OcrTemplate
|
||||||
|
@Output() public readonly succeeded = new EventEmitter<OcrTemplate>()
|
||||||
|
@Output() public readonly failed = new EventEmitter()
|
||||||
|
|
||||||
|
@ViewChild('zoneOverlay') overlayRef: ElementRef<SVGSVGElement>
|
||||||
|
imageRef: ElementRef<HTMLImageElement>
|
||||||
|
private previewResizeObserver: ResizeObserver | null = null
|
||||||
|
private readonly overlayScale = signal(1)
|
||||||
|
|
||||||
|
@ViewChild('pageImage')
|
||||||
|
set pageImage(ref: ElementRef<HTMLImageElement> | undefined) {
|
||||||
|
this.previewResizeObserver?.disconnect()
|
||||||
|
this.imageRef = ref
|
||||||
|
if (!ref) return
|
||||||
|
this.previewResizeObserver = new ResizeObserver(() => {
|
||||||
|
const image = ref.nativeElement
|
||||||
|
if (image.naturalWidth && image.clientWidth) {
|
||||||
|
this.overlayScale.set(image.naturalWidth / image.clientWidth)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
this.previewResizeObserver.observe(ref.nativeElement)
|
||||||
|
}
|
||||||
|
|
||||||
|
template: OcrTemplate = {
|
||||||
|
id: null,
|
||||||
|
name: '',
|
||||||
|
match: '',
|
||||||
|
matching_algorithm: MATCH_ANY,
|
||||||
|
is_insensitive: true,
|
||||||
|
sample_document: null,
|
||||||
|
source_width: 0,
|
||||||
|
source_height: 0,
|
||||||
|
enabled: true,
|
||||||
|
combine_formats: {},
|
||||||
|
zones: [],
|
||||||
|
}
|
||||||
|
|
||||||
|
public readonly customFields = signal<CustomField[]>([])
|
||||||
|
public readonly fieldItems = computed<
|
||||||
|
{ id: ZoneFieldSelection; name: string }[]
|
||||||
|
>(() => [
|
||||||
|
...OCR_BUILTIN_TARGETS,
|
||||||
|
...this.customFields()
|
||||||
|
.filter((field) => OCR_SUPPORTED_FIELD_TYPES.includes(field.data_type))
|
||||||
|
.map((field) => ({ id: field.id, name: field.name })),
|
||||||
|
])
|
||||||
|
public readonly users = signal<User[]>([])
|
||||||
|
permissionsForm: PermissionsFormObject = {}
|
||||||
|
public readonly OcrZoneTransform = OcrZoneTransform
|
||||||
|
public readonly saving = signal(false)
|
||||||
|
|
||||||
|
/** Bound to the document picker, which works with id arrays. */
|
||||||
|
sampleDocumentIds: number[] = []
|
||||||
|
previewPage = 0
|
||||||
|
public readonly previewPageCount = signal<number | null>(null)
|
||||||
|
public readonly pageLoaded = signal(false)
|
||||||
|
public readonly pageFailed = signal(false)
|
||||||
|
zoom = 1
|
||||||
|
|
||||||
|
get previewDocId(): number | null {
|
||||||
|
return this.sampleDocumentIds[0] ?? null
|
||||||
|
}
|
||||||
|
|
||||||
|
get pageImageUrl(): string | null {
|
||||||
|
return this.previewDocId
|
||||||
|
? this.templateService.getPageImageUrl(
|
||||||
|
this.previewDocId,
|
||||||
|
this.previewPage
|
||||||
|
)
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
|
||||||
|
get transformOptions() {
|
||||||
|
return ZONE_TRANSFORM_OPTIONS
|
||||||
|
}
|
||||||
|
|
||||||
|
get languageOptions() {
|
||||||
|
return ZONE_LANGUAGE_OPTIONS
|
||||||
|
}
|
||||||
|
|
||||||
|
get matchingAlgorithms() {
|
||||||
|
return TEMPLATE_MATCHING_ALGORITHMS
|
||||||
|
}
|
||||||
|
|
||||||
|
get patternRequired(): boolean {
|
||||||
|
return (
|
||||||
|
this.template.matching_algorithm !== MATCH_AUTO &&
|
||||||
|
this.template.matching_algorithm !== MATCH_NONE
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
public get previewPageDisplay(): number {
|
||||||
|
return this.previewPage + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
public set previewPageDisplay(value: number) {
|
||||||
|
this.goToPage(value - 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
activeTab: ActiveTab = 'template'
|
||||||
|
|
||||||
|
selectedZoneIndex: number | null = null
|
||||||
|
private overlayInteraction: OverlayInteraction = NO_OVERLAY_INTERACTION
|
||||||
|
overlayCursor = 'crosshair'
|
||||||
|
|
||||||
|
private readonly zoneTest = signal<ZoneTest | null>(null)
|
||||||
|
|
||||||
|
get zoneTestResult(): OcrZoneTestResult | null {
|
||||||
|
return this.isCurrentZoneTest() ? this.zoneTest().result : null
|
||||||
|
}
|
||||||
|
|
||||||
|
get zoneTesting(): boolean {
|
||||||
|
return this.isCurrentZoneTest() && this.zoneTest().result === null
|
||||||
|
}
|
||||||
|
|
||||||
|
get selectedZone(): OcrTemplateZone | null {
|
||||||
|
return this.selectedZoneIndex !== null
|
||||||
|
? (this.template.zones[this.selectedZoneIndex] ?? null)
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
|
||||||
|
private initialState = ''
|
||||||
|
|
||||||
|
get isDirty(): boolean {
|
||||||
|
return this.editorState() !== this.initialState
|
||||||
|
}
|
||||||
|
|
||||||
|
private editorState(): string {
|
||||||
|
const formats = Object.entries(this.template.combine_formats ?? {}).filter(
|
||||||
|
([, format]) => format
|
||||||
|
)
|
||||||
|
return JSON.stringify({
|
||||||
|
...this.template,
|
||||||
|
combine_formats: Object.fromEntries(formats),
|
||||||
|
sample_document: this.previewDocId ?? null,
|
||||||
|
permissions_form: this.permissionsForm,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
get dialogTitle(): string {
|
||||||
|
return this.template.id
|
||||||
|
? $localize`Edit OCR Template`
|
||||||
|
: $localize`New OCR Template`
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnInit() {
|
||||||
|
this.userService
|
||||||
|
.listAll()
|
||||||
|
.pipe(takeUntil(this.destroy$))
|
||||||
|
.subscribe((r) => this.users.set(r.results))
|
||||||
|
this.customFieldsService
|
||||||
|
.listAll()
|
||||||
|
.pipe(takeUntil(this.destroy$))
|
||||||
|
.subscribe((r) => {
|
||||||
|
this.customFields.set(r.results)
|
||||||
|
})
|
||||||
|
|
||||||
|
if (this.object) {
|
||||||
|
this.template = {
|
||||||
|
...this.object,
|
||||||
|
zones: this.object.zones.map((zone) => ({ ...zone })),
|
||||||
|
combine_formats: { ...this.object.combine_formats },
|
||||||
|
}
|
||||||
|
if (this.template.sample_document) {
|
||||||
|
this.setSampleDocument([this.template.sample_document])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const permissions = this.object?.permissions
|
||||||
|
this.permissionsForm = this.object
|
||||||
|
? {
|
||||||
|
owner: this.object.owner,
|
||||||
|
set_permissions: {
|
||||||
|
view: {
|
||||||
|
users: [...(permissions?.view.users ?? [])],
|
||||||
|
groups: [...(permissions?.view.groups ?? [])],
|
||||||
|
},
|
||||||
|
change: {
|
||||||
|
users: [...(permissions?.change.users ?? [])],
|
||||||
|
groups: [...(permissions?.change.groups ?? [])],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: {
|
||||||
|
owner: this.settingsService.get(SETTINGS_KEYS.DEFAULT_PERMS_OWNER),
|
||||||
|
set_permissions: {
|
||||||
|
view: {
|
||||||
|
users: this.settingsService.get(
|
||||||
|
SETTINGS_KEYS.DEFAULT_PERMS_VIEW_USERS
|
||||||
|
),
|
||||||
|
groups: this.settingsService.get(
|
||||||
|
SETTINGS_KEYS.DEFAULT_PERMS_VIEW_GROUPS
|
||||||
|
),
|
||||||
|
},
|
||||||
|
change: {
|
||||||
|
users: this.settingsService.get(
|
||||||
|
SETTINGS_KEYS.DEFAULT_PERMS_EDIT_USERS
|
||||||
|
),
|
||||||
|
groups: this.settingsService.get(
|
||||||
|
SETTINGS_KEYS.DEFAULT_PERMS_EDIT_GROUPS
|
||||||
|
),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
this.initialState = this.editorState()
|
||||||
|
}
|
||||||
|
|
||||||
|
setSampleDocument(ids: number[]) {
|
||||||
|
const id = ids.at(-1)
|
||||||
|
this.sampleDocumentIds = id ? [id] : []
|
||||||
|
this.previewPage = 0
|
||||||
|
this.previewPageCount.set(null)
|
||||||
|
this.resetPage()
|
||||||
|
if (!id) return
|
||||||
|
this.documentService
|
||||||
|
.get(id)
|
||||||
|
.pipe(takeUntil(this.destroy$))
|
||||||
|
.subscribe({
|
||||||
|
next: (doc) => {
|
||||||
|
if (this.previewDocId !== id) return
|
||||||
|
this.previewPageCount.set(doc.page_count ?? null)
|
||||||
|
},
|
||||||
|
error: () => {
|
||||||
|
if (this.previewDocId === id) this.pageFailed.set(true)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private resetPage() {
|
||||||
|
this.stopOverlayInteraction()
|
||||||
|
this.clearZoneTest()
|
||||||
|
this.pageLoaded.set(false)
|
||||||
|
this.pageFailed.set(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
goToPage(page: number) {
|
||||||
|
if (!Number.isFinite(page)) return
|
||||||
|
const pageCount = this.previewPageCount()
|
||||||
|
const max = pageCount ? pageCount - 1 : page
|
||||||
|
const clamped = Math.max(0, Math.min(Math.trunc(page), max))
|
||||||
|
if (clamped === this.previewPage) return
|
||||||
|
this.previewPage = clamped
|
||||||
|
this.resetPage()
|
||||||
|
}
|
||||||
|
|
||||||
|
zoomIn() {
|
||||||
|
this.zoom = Math.min(4, Math.round((this.zoom + 0.25) * 100) / 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
zoomOut() {
|
||||||
|
this.zoom = Math.max(0.5, Math.round((this.zoom - 0.25) * 100) / 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
resetZoom() {
|
||||||
|
this.zoom = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
zonePage(zone: OcrTemplateZone): number {
|
||||||
|
return getZonePage(zone, this.previewPage, this.previewPageCount())
|
||||||
|
}
|
||||||
|
|
||||||
|
isLastPageZone(zone: OcrTemplateZone): boolean {
|
||||||
|
return zone.page === -1
|
||||||
|
}
|
||||||
|
|
||||||
|
setLastPageZone(zone: OcrTemplateZone, lastPage: boolean) {
|
||||||
|
zone.page = lastPage ? -1 : this.previewPage + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
private isOnCurrentPage(zone: OcrTemplateZone): boolean {
|
||||||
|
return isZoneOnPage(zone, this.previewPage, this.previewPageCount())
|
||||||
|
}
|
||||||
|
|
||||||
|
onImageLoad() {
|
||||||
|
this.pageLoaded.set(true)
|
||||||
|
const img = this.imageRef.nativeElement
|
||||||
|
this.template.source_width = img.naturalWidth
|
||||||
|
this.template.source_height = img.naturalHeight
|
||||||
|
}
|
||||||
|
|
||||||
|
onImageError() {
|
||||||
|
this.stopOverlayInteraction()
|
||||||
|
this.pageFailed.set(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
@HostListener('document:keydown.escape')
|
||||||
|
cancelDrawing() {
|
||||||
|
this.stopOverlayInteraction()
|
||||||
|
}
|
||||||
|
|
||||||
|
onOverlayPointerDown(event: PointerEvent) {
|
||||||
|
if (this.saving()) return
|
||||||
|
if (event.button !== 0 || this.overlayInteraction.kind !== 'idle') return
|
||||||
|
const point = this.svgPointFromEvent(event)
|
||||||
|
if (!this.pageLoaded() || !point) return
|
||||||
|
event.preventDefault()
|
||||||
|
|
||||||
|
if (this.selectedZoneIndex !== null) {
|
||||||
|
const handle = this.handleAt(point, this.selectedZoneIndex)
|
||||||
|
if (handle) {
|
||||||
|
this.overlayInteraction = {
|
||||||
|
kind: 'resizing',
|
||||||
|
pointerId: event.pointerId,
|
||||||
|
zoneIndex: this.selectedZoneIndex,
|
||||||
|
handle,
|
||||||
|
}
|
||||||
|
this.overlayRef.nativeElement.setPointerCapture(event.pointerId)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const clickedIdx = this.zoneIndexAt(point)
|
||||||
|
if (clickedIdx !== null) {
|
||||||
|
this.selectZone(clickedIdx)
|
||||||
|
const zone = this.template.zones[clickedIdx]
|
||||||
|
this.overlayInteraction = {
|
||||||
|
kind: 'moving',
|
||||||
|
pointerId: event.pointerId,
|
||||||
|
zoneIndex: clickedIdx,
|
||||||
|
start: {
|
||||||
|
mouseX: point.x,
|
||||||
|
mouseY: point.y,
|
||||||
|
zoneX: zone.x,
|
||||||
|
zoneY: zone.y,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
this.overlayRef.nativeElement.setPointerCapture(event.pointerId)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Empty space: start drawing a new zone. A click without a drag just clears
|
||||||
|
// the selection (see onOverlayPointerUp).
|
||||||
|
this.clearZoneTest()
|
||||||
|
this.selectedZoneIndex = null
|
||||||
|
this.overlayInteraction = {
|
||||||
|
kind: 'drawing',
|
||||||
|
pointerId: event.pointerId,
|
||||||
|
rect: {
|
||||||
|
startX: point.x,
|
||||||
|
startY: point.y,
|
||||||
|
endX: point.x,
|
||||||
|
endY: point.y,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
this.overlayRef.nativeElement.setPointerCapture(event.pointerId)
|
||||||
|
}
|
||||||
|
|
||||||
|
onOverlayPointerMove(event: PointerEvent) {
|
||||||
|
if (
|
||||||
|
this.overlayInteraction.kind !== 'idle' &&
|
||||||
|
this.overlayInteraction.pointerId !== event.pointerId
|
||||||
|
)
|
||||||
|
return
|
||||||
|
const point = this.svgPointFromEvent(event)
|
||||||
|
if (!point) return
|
||||||
|
|
||||||
|
if (this.overlayInteraction.kind === 'resizing') {
|
||||||
|
resizeZone(
|
||||||
|
this.template.zones[this.overlayInteraction.zoneIndex],
|
||||||
|
this.overlayInteraction.handle,
|
||||||
|
point,
|
||||||
|
this.imageNaturalSize()
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.overlayInteraction.kind === 'moving') {
|
||||||
|
moveZone(
|
||||||
|
this.template.zones[this.overlayInteraction.zoneIndex],
|
||||||
|
point,
|
||||||
|
this.overlayInteraction.start,
|
||||||
|
this.imageNaturalSize()
|
||||||
|
)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this.overlayInteraction.kind === 'drawing') {
|
||||||
|
this.overlayInteraction.rect.endX = point.x
|
||||||
|
this.overlayInteraction.rect.endY = point.y
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.updateOverlayCursor(point)
|
||||||
|
}
|
||||||
|
|
||||||
|
private updateOverlayCursor(point: Point) {
|
||||||
|
if (this.selectedZoneIndex !== null) {
|
||||||
|
const handle = this.handleAt(point, this.selectedZoneIndex)
|
||||||
|
if (handle) {
|
||||||
|
this.overlayCursor = RESIZE_CURSOR[handle]
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
this.overlayCursor = this.zoneIndexAt(point) !== null ? 'move' : 'crosshair'
|
||||||
|
}
|
||||||
|
|
||||||
|
onOverlayPointerUp(event: PointerEvent) {
|
||||||
|
if (this.overlayInteraction.kind === 'idle') return
|
||||||
|
if (this.overlayInteraction.pointerId !== event.pointerId) return
|
||||||
|
this.onOverlayPointerMove(event)
|
||||||
|
const interaction = this.overlayInteraction
|
||||||
|
this.stopOverlayInteraction()
|
||||||
|
if (interaction.kind !== 'drawing') return
|
||||||
|
|
||||||
|
const rect = this.sourceRectFromDrawing(interaction.rect)
|
||||||
|
|
||||||
|
// Ignore tiny accidental clicks.
|
||||||
|
if (rect.w < MIN_ZONE_SIZE || rect.h < MIN_ZONE_SIZE) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
this.template.zones.push(this.createZoneFromRect(rect))
|
||||||
|
this.selectZone(this.template.zones.length - 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
private createZoneFromRect(rect: DisplayRect): OcrTemplateZone {
|
||||||
|
const imageSize = this.imageNaturalSize()
|
||||||
|
return {
|
||||||
|
name: `Zone ${this.template.zones.length + 1}`,
|
||||||
|
target: OcrZoneTarget.CustomField,
|
||||||
|
custom_field: null,
|
||||||
|
x: rect.x,
|
||||||
|
y: rect.y,
|
||||||
|
width: rect.w,
|
||||||
|
height: rect.h,
|
||||||
|
page: this.previewPageDisplay,
|
||||||
|
ocr_language: DEFAULT_OCR_ZONE_LANGUAGE,
|
||||||
|
transform: OcrZoneTransform.Strip,
|
||||||
|
date_format: '',
|
||||||
|
validation_regex: '',
|
||||||
|
order: this.template.zones.length,
|
||||||
|
zone_source_width: imageSize.width,
|
||||||
|
zone_source_height: imageSize.height,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onOverlayPointerCancel(event: PointerEvent) {
|
||||||
|
if (this.overlayInteraction.kind === 'idle') return
|
||||||
|
if (this.overlayInteraction.pointerId !== event.pointerId) return
|
||||||
|
this.stopOverlayInteraction()
|
||||||
|
}
|
||||||
|
|
||||||
|
private stopOverlayInteraction() {
|
||||||
|
const interaction = this.overlayInteraction
|
||||||
|
this.overlayInteraction = NO_OVERLAY_INTERACTION
|
||||||
|
this.overlayCursor = 'crosshair'
|
||||||
|
const overlay = this.overlayRef?.nativeElement
|
||||||
|
if (
|
||||||
|
interaction.kind !== 'idle' &&
|
||||||
|
overlay?.hasPointerCapture(interaction.pointerId)
|
||||||
|
) {
|
||||||
|
overlay.releasePointerCapture(interaction.pointerId)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
drawingRect(): DisplayRect | null {
|
||||||
|
return this.overlayInteraction.kind === 'drawing'
|
||||||
|
? this.displayRectFromDrawing(this.overlayInteraction.rect)
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The zone's rectangle on the overlay, or null if it's on another page. */
|
||||||
|
zoneDisplayRect(zoneIndex: number): DisplayRect | null {
|
||||||
|
const zone = this.template.zones[zoneIndex]
|
||||||
|
if (!this.isOnCurrentPage(zone)) return null
|
||||||
|
return getZoneDisplayRect(zone, this.imageNaturalSize())
|
||||||
|
}
|
||||||
|
|
||||||
|
private handleAt(point: Point, zoneIndex: number): ResizeHandle | null {
|
||||||
|
const rect = this.zoneDisplayRect(zoneIndex)
|
||||||
|
return rect ? findHandleAt(point, rect, this.overlayHandleSize()) : null
|
||||||
|
}
|
||||||
|
|
||||||
|
private zoneIndexAt(point: Point): number | null {
|
||||||
|
return findZoneAt(
|
||||||
|
point,
|
||||||
|
this.template.zones,
|
||||||
|
this.previewPage,
|
||||||
|
this.previewPageCount(),
|
||||||
|
this.imageNaturalSize()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
overlayViewBox(): string {
|
||||||
|
const imageSize = this.imageNaturalSize()
|
||||||
|
return `0 0 ${imageSize.width} ${imageSize.height}`
|
||||||
|
}
|
||||||
|
|
||||||
|
public readonly zoneColor = getZoneColor
|
||||||
|
|
||||||
|
zoneLabel(zone: OcrTemplateZone, index: number): string {
|
||||||
|
return zone.name || `Zone ${index + 1}`
|
||||||
|
}
|
||||||
|
|
||||||
|
zoneLabelY(rect: DisplayRect): number {
|
||||||
|
return Math.max(this.overlayUnitSize(14), rect.y - this.overlayUnitSize(4))
|
||||||
|
}
|
||||||
|
|
||||||
|
public readonly resizeHandles = getResizeHandles
|
||||||
|
|
||||||
|
overlayHandleSize(): number {
|
||||||
|
return this.overlayUnitSize(HANDLE_SIZE)
|
||||||
|
}
|
||||||
|
|
||||||
|
overlayFontSize(): number {
|
||||||
|
return this.overlayUnitSize(12)
|
||||||
|
}
|
||||||
|
|
||||||
|
overlayUnitSize(screenPixels: number): number {
|
||||||
|
return screenPixels * this.overlayScale()
|
||||||
|
}
|
||||||
|
|
||||||
|
private svgPointFromEvent(event: MouseEvent): Point | null {
|
||||||
|
const svg = this.overlayRef?.nativeElement
|
||||||
|
const matrix = svg?.getScreenCTM()
|
||||||
|
if (!svg || !matrix) return null
|
||||||
|
|
||||||
|
const point = svg.createSVGPoint()
|
||||||
|
point.x = event.clientX
|
||||||
|
point.y = event.clientY
|
||||||
|
|
||||||
|
const svgPoint = point.matrixTransform(matrix.inverse())
|
||||||
|
const size = this.imageNaturalSize()
|
||||||
|
return {
|
||||||
|
x: Math.max(0, Math.min(svgPoint.x, size.width)),
|
||||||
|
y: Math.max(0, Math.min(svgPoint.y, size.height)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private displayRectFromDrawing(rect: DrawingRect): DisplayRect {
|
||||||
|
return {
|
||||||
|
x: Math.min(rect.startX, rect.endX),
|
||||||
|
y: Math.min(rect.startY, rect.endY),
|
||||||
|
w: Math.abs(rect.endX - rect.startX),
|
||||||
|
h: Math.abs(rect.endY - rect.startY),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private sourceRectFromDrawing(rect: DrawingRect): DisplayRect {
|
||||||
|
const displayRect = this.displayRectFromDrawing(rect)
|
||||||
|
return {
|
||||||
|
x: Math.round(displayRect.x),
|
||||||
|
y: Math.round(displayRect.y),
|
||||||
|
w: Math.round(displayRect.w),
|
||||||
|
h: Math.round(displayRect.h),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private imageNaturalSize() {
|
||||||
|
const img = this.imageRef.nativeElement
|
||||||
|
return { width: img.naturalWidth, height: img.naturalHeight }
|
||||||
|
}
|
||||||
|
|
||||||
|
removeZone(index: number) {
|
||||||
|
this.stopOverlayInteraction()
|
||||||
|
this.clearZoneTest()
|
||||||
|
this.template.zones.splice(index, 1)
|
||||||
|
if (this.selectedZoneIndex === index) {
|
||||||
|
this.selectedZoneIndex = null
|
||||||
|
} else if (this.selectedZoneIndex > index) {
|
||||||
|
this.selectedZoneIndex--
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
selectZone(index: number) {
|
||||||
|
this.selectedZoneIndex = index
|
||||||
|
this.activeTab = 'zones'
|
||||||
|
this.clearZoneTest()
|
||||||
|
const zone = this.template.zones[index]
|
||||||
|
if (zone) {
|
||||||
|
this.goToPage(this.zonePage(zone) - 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
deselectZone() {
|
||||||
|
this.selectedZoneIndex = null
|
||||||
|
this.clearZoneTest()
|
||||||
|
}
|
||||||
|
|
||||||
|
testZone() {
|
||||||
|
const zone = this.selectedZone
|
||||||
|
if (!zone || !this.previewDocId) return
|
||||||
|
this.clearZoneTest()
|
||||||
|
const test: ZoneTest = { zone, key: this.zoneTestKey(zone), result: null }
|
||||||
|
this.zoneTest.set(test)
|
||||||
|
this.templateService
|
||||||
|
.testZone(this.previewDocId, this.zoneTestRequest(zone))
|
||||||
|
.pipe(takeUntil(this.destroy$), takeUntil(this.cancelZoneTest$))
|
||||||
|
.subscribe({
|
||||||
|
next: (res) => this.zoneTest.set({ ...test, result: res }),
|
||||||
|
error: (err) =>
|
||||||
|
this.zoneTest.set({
|
||||||
|
...test,
|
||||||
|
result: { error: err.error?.error || $localize`Test failed` },
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private clearZoneTest() {
|
||||||
|
this.cancelZoneTest$.next()
|
||||||
|
this.zoneTest.set(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** A result is stale once its zone, settings or sample document change. */
|
||||||
|
private isCurrentZoneTest(): boolean {
|
||||||
|
const test = this.zoneTest()
|
||||||
|
return (
|
||||||
|
test?.zone === this.selectedZone &&
|
||||||
|
test.key === this.zoneTestKey(test.zone)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private zoneTestKey(zone: OcrTemplateZone): string {
|
||||||
|
return JSON.stringify([this.previewDocId, this.zoneTestRequest(zone)])
|
||||||
|
}
|
||||||
|
|
||||||
|
private zoneTestRequest(zone: OcrTemplateZone): ZoneTestRequest {
|
||||||
|
return {
|
||||||
|
name: zone.name,
|
||||||
|
x: zone.x,
|
||||||
|
y: zone.y,
|
||||||
|
width: zone.width,
|
||||||
|
height: zone.height,
|
||||||
|
page: zone.page ?? 1,
|
||||||
|
ocr_language: zone.ocr_language,
|
||||||
|
transform: zone.transform,
|
||||||
|
date_format: zone.date_format,
|
||||||
|
validation_regex: zone.validation_regex,
|
||||||
|
zone_source_width: zone.zone_source_width,
|
||||||
|
zone_source_height: zone.zone_source_height,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
save() {
|
||||||
|
if (this.saving()) return
|
||||||
|
this.stopOverlayInteraction()
|
||||||
|
this.saving.set(true)
|
||||||
|
this.pruneCombineFormats()
|
||||||
|
this.template.sample_document = this.previewDocId
|
||||||
|
const payload = this.permissionsService.currentUserOwnsObject(this.object)
|
||||||
|
? { ...this.template, ...this.permissionsForm }
|
||||||
|
: this.template
|
||||||
|
const obs = this.template.id
|
||||||
|
? this.templateService.update(payload)
|
||||||
|
: this.templateService.create(payload)
|
||||||
|
|
||||||
|
obs.pipe(takeUntil(this.destroy$)).subscribe({
|
||||||
|
next: (saved) => {
|
||||||
|
this.activeModal.close()
|
||||||
|
this.succeeded.emit(saved)
|
||||||
|
},
|
||||||
|
error: (e) => {
|
||||||
|
this.saving.set(false)
|
||||||
|
this.failed.emit(e)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ng-select needs a stable array between change detection runs, so the
|
||||||
|
// split language list is cached per zone until ocr_language changes.
|
||||||
|
private readonly ocrLanguageCache = new WeakMap<
|
||||||
|
OcrTemplateZone,
|
||||||
|
{ language: string; languages: string[] }
|
||||||
|
>()
|
||||||
|
|
||||||
|
ocrLanguageArray(zone: OcrTemplateZone): string[] {
|
||||||
|
const language = zone.ocr_language || ''
|
||||||
|
const cached = this.ocrLanguageCache.get(zone)
|
||||||
|
if (cached?.language === language) return cached.languages
|
||||||
|
const languages = language.split('+').filter(Boolean)
|
||||||
|
this.ocrLanguageCache.set(zone, { language, languages })
|
||||||
|
return languages
|
||||||
|
}
|
||||||
|
|
||||||
|
setOcrLanguages(zone: OcrTemplateZone, langs: string[]) {
|
||||||
|
zone.ocr_language = (langs ?? []).join('+')
|
||||||
|
}
|
||||||
|
|
||||||
|
/** The field select's value: a built-in target, or a custom field id. */
|
||||||
|
zoneFieldValue(zone: OcrTemplateZone): ZoneFieldSelection {
|
||||||
|
return zone.target === OcrZoneTarget.CustomField
|
||||||
|
? zone.custom_field
|
||||||
|
: zone.target
|
||||||
|
}
|
||||||
|
|
||||||
|
setZoneField(zone: OcrTemplateZone, value: ZoneFieldSelection) {
|
||||||
|
if (typeof value === 'string') {
|
||||||
|
zone.target = value
|
||||||
|
zone.custom_field = null
|
||||||
|
} else {
|
||||||
|
zone.target = OcrZoneTarget.CustomField
|
||||||
|
zone.custom_field = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
zonesForField(zone: OcrTemplateZone): OcrTemplateZone[] {
|
||||||
|
const key = getZoneFieldKey(zone)
|
||||||
|
if (!key) return []
|
||||||
|
return this.template.zones.filter((z) => getZoneFieldKey(z) === key)
|
||||||
|
}
|
||||||
|
|
||||||
|
isFieldShared(zone: OcrTemplateZone): boolean {
|
||||||
|
return this.zonesForField(zone).length > 1
|
||||||
|
}
|
||||||
|
|
||||||
|
renameZone(zone: OcrTemplateZone, name: string) {
|
||||||
|
const oldName = zone.name.trim()
|
||||||
|
const format = this.getCombineFormat(zone)
|
||||||
|
if (format) {
|
||||||
|
this.setCombineFormat(
|
||||||
|
zone,
|
||||||
|
format.replace(/\{([^{}]*)\}/g, (token, tokenName: string) =>
|
||||||
|
tokenName.trim() === oldName ? `{${name}}` : token
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
zone.name = name
|
||||||
|
}
|
||||||
|
|
||||||
|
getCombineFormat(zone: OcrTemplateZone): string {
|
||||||
|
return this.template.combine_formats?.[getZoneFieldKey(zone)] ?? ''
|
||||||
|
}
|
||||||
|
|
||||||
|
setCombineFormat(zone: OcrTemplateZone, value: string) {
|
||||||
|
const key = getZoneFieldKey(zone)
|
||||||
|
if (!key) return
|
||||||
|
this.template.combine_formats ??= {}
|
||||||
|
this.template.combine_formats[key] = value
|
||||||
|
}
|
||||||
|
|
||||||
|
defaultCombineFormat(zone: OcrTemplateZone): string {
|
||||||
|
return this.zonesForField(zone)
|
||||||
|
.map((z) => `{${z.name}}`)
|
||||||
|
.join(' ')
|
||||||
|
}
|
||||||
|
|
||||||
|
insertCombineToken(zone: OcrTemplateZone, tokenZone: OcrTemplateZone) {
|
||||||
|
const current = this.getCombineFormat(zone)
|
||||||
|
const sep = current && !current.endsWith(' ') ? ' ' : ''
|
||||||
|
this.setCombineFormat(zone, `${current}${sep}{${tokenZone.name}}`)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Drop formats that are empty or whose field no longer has several zones. */
|
||||||
|
private pruneCombineFormats() {
|
||||||
|
const formats = this.template.combine_formats
|
||||||
|
if (!formats) return
|
||||||
|
const keys = this.template.zones.map(getZoneFieldKey)
|
||||||
|
for (const [key, format] of Object.entries(formats)) {
|
||||||
|
if (!format || keys.filter((k) => k === key).length < 2) {
|
||||||
|
delete formats[key]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
createField(name?: string) {
|
||||||
|
const zone = this.selectedZone
|
||||||
|
const modal = this.modalService.open(CustomFieldEditDialogComponent)
|
||||||
|
modal.componentInstance.dialogMode.set(EditDialogMode.CREATE)
|
||||||
|
modal.componentInstance.object = { name: name || zone?.name }
|
||||||
|
modal.componentInstance.succeeded
|
||||||
|
.pipe(takeUntil(this.destroy$))
|
||||||
|
.subscribe((field: CustomField) => {
|
||||||
|
this.toastService.showSuccess($localize`Saved field "${field.name}".`)
|
||||||
|
this.customFieldsService.clearCache()
|
||||||
|
this.customFieldsService
|
||||||
|
.listAll()
|
||||||
|
.pipe(takeUntil(this.destroy$))
|
||||||
|
.subscribe((r) => {
|
||||||
|
if (zone && OCR_SUPPORTED_FIELD_TYPES.includes(field.data_type)) {
|
||||||
|
this.setZoneField(zone, field.id)
|
||||||
|
}
|
||||||
|
this.customFields.set(r.results)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
modal.componentInstance.failed
|
||||||
|
.pipe(takeUntil(this.destroy$))
|
||||||
|
.subscribe((e) => {
|
||||||
|
this.toastService.showError($localize`Error saving field.`, e)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnDestroy() {
|
||||||
|
this.previewResizeObserver?.disconnect()
|
||||||
|
this.cancelZoneTest$.complete()
|
||||||
|
this.destroy$.next()
|
||||||
|
this.destroy$.complete()
|
||||||
|
}
|
||||||
|
}
|
||||||
+167
@@ -0,0 +1,167 @@
|
|||||||
|
import {
|
||||||
|
OcrTemplateZone,
|
||||||
|
OcrZoneTarget,
|
||||||
|
OcrZoneTransform,
|
||||||
|
} from 'src/app/data/ocr-template'
|
||||||
|
import {
|
||||||
|
findHandleAt,
|
||||||
|
findZoneAt,
|
||||||
|
getResizeHandles,
|
||||||
|
getZoneDisplayRect,
|
||||||
|
getZonePage,
|
||||||
|
isZoneOnPage,
|
||||||
|
moveZone,
|
||||||
|
resizeZone,
|
||||||
|
} from './zone-geometry'
|
||||||
|
|
||||||
|
function zone(overrides: Partial<OcrTemplateZone> = {}): OcrTemplateZone {
|
||||||
|
return {
|
||||||
|
name: 'Zone',
|
||||||
|
target: OcrZoneTarget.CustomField,
|
||||||
|
custom_field: 1,
|
||||||
|
x: 100,
|
||||||
|
y: 200,
|
||||||
|
width: 300,
|
||||||
|
height: 400,
|
||||||
|
page: 1,
|
||||||
|
ocr_language: 'eng',
|
||||||
|
transform: OcrZoneTransform.Strip,
|
||||||
|
validation_regex: '',
|
||||||
|
order: 0,
|
||||||
|
...overrides,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('OCR template editor geometry', () => {
|
||||||
|
it('normalizes zone pages', () => {
|
||||||
|
expect(getZonePage(zone({ page: 2 }), 0, 5)).toBe(2)
|
||||||
|
expect(getZonePage(zone({ page: -1 }), 0, 5)).toBe(5)
|
||||||
|
expect(getZonePage(zone({ page: -1 }), 2, null)).toBe(3)
|
||||||
|
expect(getZonePage(zone({ page: 0 }), 0, 5)).toBe(1)
|
||||||
|
expect(getZonePage(zone({ page: undefined }), 0, 5)).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('checks whether a zone is on the current preview page', () => {
|
||||||
|
expect(isZoneOnPage(zone({ page: 2 }), 1, 5)).toBe(true)
|
||||||
|
expect(isZoneOnPage(zone({ page: 2 }), 0, 5)).toBe(false)
|
||||||
|
expect(isZoneOnPage(zone({ page: -1 }), 4, 5)).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses natural image coordinates when source dimensions are unset', () => {
|
||||||
|
expect(
|
||||||
|
getZoneDisplayRect(zone({ x: 100, y: 200, width: 300, height: 400 }), {
|
||||||
|
width: 1000,
|
||||||
|
height: 2000,
|
||||||
|
})
|
||||||
|
).toEqual({ x: 100, y: 200, w: 300, h: 400 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses per-zone source dimensions when present', () => {
|
||||||
|
expect(
|
||||||
|
getZoneDisplayRect(
|
||||||
|
zone({
|
||||||
|
x: 100,
|
||||||
|
y: 100,
|
||||||
|
width: 100,
|
||||||
|
height: 100,
|
||||||
|
zone_source_width: 1000,
|
||||||
|
zone_source_height: 1000,
|
||||||
|
}),
|
||||||
|
{ width: 500, height: 750 }
|
||||||
|
)
|
||||||
|
).toEqual({ x: 50, y: 75, w: 50, h: 75 })
|
||||||
|
})
|
||||||
|
|
||||||
|
it('finds zones from topmost to bottommost on the current page', () => {
|
||||||
|
const zones = [
|
||||||
|
zone({ name: 'first', x: 0, y: 0, width: 100, height: 100, page: 1 }),
|
||||||
|
zone({ name: 'second', x: 0, y: 0, width: 50, height: 50, page: 1 }),
|
||||||
|
zone({ name: 'third', x: 0, y: 0, width: 50, height: 50, page: 2 }),
|
||||||
|
]
|
||||||
|
|
||||||
|
expect(
|
||||||
|
findZoneAt({ x: 25, y: 25 }, zones, 0, 2, { width: 100, height: 100 })
|
||||||
|
).toBe(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('finds resize handles around a display rect', () => {
|
||||||
|
const rect = { x: 10, y: 20, w: 100, h: 200 }
|
||||||
|
|
||||||
|
expect(findHandleAt({ x: 10, y: 20 }, rect)).toBe('nw')
|
||||||
|
expect(findHandleAt({ x: 110, y: 220 }, rect)).toBe('se')
|
||||||
|
expect(findHandleAt({ x: 60, y: 20 }, rect)).toBe('n')
|
||||||
|
expect(findHandleAt({ x: 90, y: 160 }, rect)).toBeNull()
|
||||||
|
expect(findHandleAt({ x: 19, y: 20 }, rect, 8)).toBeNull()
|
||||||
|
expect(findHandleAt({ x: 19, y: 20 }, rect, 10)).toBe('nw')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('uses the rendered handle positions for hit testing', () => {
|
||||||
|
const rect = { x: 10, y: 20, w: 100, h: 200 }
|
||||||
|
const handles = getResizeHandles(rect)
|
||||||
|
|
||||||
|
expect(handles).toEqual([
|
||||||
|
{ handle: 'nw', x: 10, y: 20 },
|
||||||
|
{ handle: 'n', x: 60, y: 20 },
|
||||||
|
{ handle: 'ne', x: 110, y: 20 },
|
||||||
|
{ handle: 'w', x: 10, y: 120 },
|
||||||
|
{ handle: 'e', x: 110, y: 120 },
|
||||||
|
{ handle: 'sw', x: 10, y: 220 },
|
||||||
|
{ handle: 's', x: 60, y: 220 },
|
||||||
|
{ handle: 'se', x: 110, y: 220 },
|
||||||
|
])
|
||||||
|
for (const marker of handles) {
|
||||||
|
expect(findHandleAt(marker, rect)).toBe(marker.handle)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
it('moves zones without leaving source image bounds', () => {
|
||||||
|
const z = zone({ x: 50, y: 50, width: 100, height: 100 })
|
||||||
|
|
||||||
|
moveZone(
|
||||||
|
z,
|
||||||
|
{ x: 500, y: 500 },
|
||||||
|
{ mouseX: 50, mouseY: 50, zoneX: 50, zoneY: 50 },
|
||||||
|
{ width: 500, height: 500 }
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(z.x).toBe(400)
|
||||||
|
expect(z.y).toBe(400)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('resizes zones without leaving source image bounds', () => {
|
||||||
|
const z = zone({
|
||||||
|
x: 50,
|
||||||
|
y: 50,
|
||||||
|
width: 100,
|
||||||
|
height: 100,
|
||||||
|
zone_source_width: 200,
|
||||||
|
zone_source_height: 200,
|
||||||
|
})
|
||||||
|
|
||||||
|
resizeZone(z, 'se', { x: 500, y: 500 }, { width: 500, height: 500 })
|
||||||
|
|
||||||
|
expect(z.width).toBe(150)
|
||||||
|
expect(z.height).toBe(150)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('moves zones in their original source coordinates on a different-size preview', () => {
|
||||||
|
const z = zone({
|
||||||
|
x: 50,
|
||||||
|
y: 50,
|
||||||
|
width: 100,
|
||||||
|
height: 100,
|
||||||
|
zone_source_width: 1000,
|
||||||
|
zone_source_height: 2000,
|
||||||
|
})
|
||||||
|
|
||||||
|
moveZone(
|
||||||
|
z,
|
||||||
|
{ x: 75, y: 75 },
|
||||||
|
{ mouseX: 25, mouseY: 25, zoneX: 50, zoneY: 50 },
|
||||||
|
{ width: 500, height: 500 }
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(z.x).toBe(150)
|
||||||
|
expect(z.y).toBe(250)
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
import { OcrTemplateZone } from 'src/app/data/ocr-template'
|
||||||
|
|
||||||
|
export interface DrawingRect {
|
||||||
|
startX: number
|
||||||
|
startY: number
|
||||||
|
endX: number
|
||||||
|
endY: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Dimensions {
|
||||||
|
width: number
|
||||||
|
height: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Point {
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DisplayRect {
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
w: number
|
||||||
|
h: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MoveStart {
|
||||||
|
mouseX: number
|
||||||
|
mouseY: number
|
||||||
|
zoneX: number
|
||||||
|
zoneY: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ResizeHandle = 'n' | 's' | 'e' | 'w' | 'ne' | 'nw' | 'se' | 'sw'
|
||||||
|
|
||||||
|
export interface ResizeHandleMarker extends Point {
|
||||||
|
handle: ResizeHandle
|
||||||
|
}
|
||||||
|
|
||||||
|
export const HANDLE_SIZE = 8
|
||||||
|
export const MIN_ZONE_SIZE = 10
|
||||||
|
|
||||||
|
const ZONE_COLORS = [
|
||||||
|
'#4f8ff7',
|
||||||
|
'#ff6b6b',
|
||||||
|
'#51cf66',
|
||||||
|
'#ffd43b',
|
||||||
|
'#cc5de8',
|
||||||
|
'#ff922b',
|
||||||
|
'#20c997',
|
||||||
|
'#e599f7',
|
||||||
|
]
|
||||||
|
|
||||||
|
/** Outline color for a zone, shared by the preview overlay and the zone list. */
|
||||||
|
export function getZoneColor(index: number): string {
|
||||||
|
return ZONE_COLORS[index % ZONE_COLORS.length]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getZonePage(
|
||||||
|
zone: OcrTemplateZone,
|
||||||
|
previewPage: number,
|
||||||
|
previewPageCount: number | null
|
||||||
|
): number {
|
||||||
|
const page = zone.page ?? 1
|
||||||
|
if (page === -1) return previewPageCount ?? previewPage + 1
|
||||||
|
return Math.max(page, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isZoneOnPage(
|
||||||
|
zone: OcrTemplateZone,
|
||||||
|
previewPage: number,
|
||||||
|
previewPageCount: number | null
|
||||||
|
): boolean {
|
||||||
|
return getZonePage(zone, previewPage, previewPageCount) === previewPage + 1
|
||||||
|
}
|
||||||
|
|
||||||
|
function getZoneSourceSize(
|
||||||
|
zone: OcrTemplateZone,
|
||||||
|
imageSize: Dimensions
|
||||||
|
): Dimensions {
|
||||||
|
return {
|
||||||
|
width: zone.zone_source_width || imageSize.width,
|
||||||
|
height: zone.zone_source_height || imageSize.height,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getZoneDisplayRect(
|
||||||
|
zone: OcrTemplateZone,
|
||||||
|
imageSize: Dimensions
|
||||||
|
): DisplayRect {
|
||||||
|
const sourceSize = getZoneSourceSize(zone, imageSize)
|
||||||
|
// The SVG viewBox uses the preview image's natural dimensions.
|
||||||
|
const scaleX = imageSize.width / sourceSize.width
|
||||||
|
const scaleY = imageSize.height / sourceSize.height
|
||||||
|
|
||||||
|
return {
|
||||||
|
x: zone.x * scaleX,
|
||||||
|
y: zone.y * scaleY,
|
||||||
|
w: zone.width * scaleX,
|
||||||
|
h: zone.height * scaleY,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getResizeHandles(rect: DisplayRect): ResizeHandleMarker[] {
|
||||||
|
return [
|
||||||
|
{ handle: 'nw', x: rect.x, y: rect.y },
|
||||||
|
{ handle: 'n', x: rect.x + rect.w / 2, y: rect.y },
|
||||||
|
{ handle: 'ne', x: rect.x + rect.w, y: rect.y },
|
||||||
|
{ handle: 'w', x: rect.x, y: rect.y + rect.h / 2 },
|
||||||
|
{ handle: 'e', x: rect.x + rect.w, y: rect.y + rect.h / 2 },
|
||||||
|
{ handle: 'sw', x: rect.x, y: rect.y + rect.h },
|
||||||
|
{ handle: 's', x: rect.x + rect.w / 2, y: rect.y + rect.h },
|
||||||
|
{ handle: 'se', x: rect.x + rect.w, y: rect.y + rect.h },
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findHandleAt(
|
||||||
|
point: Point,
|
||||||
|
rect: DisplayRect,
|
||||||
|
handleSize = HANDLE_SIZE
|
||||||
|
): ResizeHandle | null {
|
||||||
|
return (
|
||||||
|
getResizeHandles(rect).find(
|
||||||
|
({ x, y }) =>
|
||||||
|
Math.abs(point.x - x) <= handleSize &&
|
||||||
|
Math.abs(point.y - y) <= handleSize
|
||||||
|
)?.handle ?? null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function findZoneAt(
|
||||||
|
point: Point,
|
||||||
|
zones: OcrTemplateZone[],
|
||||||
|
previewPage: number,
|
||||||
|
previewPageCount: number | null,
|
||||||
|
imageSize: Dimensions
|
||||||
|
): number | null {
|
||||||
|
for (let i = zones.length - 1; i >= 0; i--) {
|
||||||
|
const zone = zones[i]
|
||||||
|
if (!isZoneOnPage(zone, previewPage, previewPageCount)) continue
|
||||||
|
const rect = getZoneDisplayRect(zone, imageSize)
|
||||||
|
|
||||||
|
if (
|
||||||
|
point.x >= rect.x &&
|
||||||
|
point.x <= rect.x + rect.w &&
|
||||||
|
point.y >= rect.y &&
|
||||||
|
point.y <= rect.y + rect.h
|
||||||
|
) {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function moveZone(
|
||||||
|
zone: OcrTemplateZone,
|
||||||
|
point: Point,
|
||||||
|
moveStart: MoveStart,
|
||||||
|
imageSize: Dimensions
|
||||||
|
) {
|
||||||
|
const sourceSize = getZoneSourceSize(zone, imageSize)
|
||||||
|
const scaleX = sourceSize.width / imageSize.width
|
||||||
|
const scaleY = sourceSize.height / imageSize.height
|
||||||
|
const dx = Math.round((point.x - moveStart.mouseX) * scaleX)
|
||||||
|
const dy = Math.round((point.y - moveStart.mouseY) * scaleY)
|
||||||
|
|
||||||
|
zone.x = clamp(moveStart.zoneX + dx, 0, sourceSize.width - zone.width)
|
||||||
|
zone.y = clamp(moveStart.zoneY + dy, 0, sourceSize.height - zone.height)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function resizeZone(
|
||||||
|
zone: OcrTemplateZone,
|
||||||
|
handle: ResizeHandle,
|
||||||
|
point: Point,
|
||||||
|
imageSize: Dimensions
|
||||||
|
) {
|
||||||
|
const sourceSize = getZoneSourceSize(zone, imageSize)
|
||||||
|
const scaleX = sourceSize.width / imageSize.width
|
||||||
|
const scaleY = sourceSize.height / imageSize.height
|
||||||
|
const imageX = clamp(Math.round(point.x * scaleX), 0, sourceSize.width)
|
||||||
|
const imageY = clamp(Math.round(point.y * scaleY), 0, sourceSize.height)
|
||||||
|
|
||||||
|
if (handle.includes('w')) {
|
||||||
|
const right = Math.min(zone.x + zone.width, sourceSize.width)
|
||||||
|
zone.x = clamp(imageX, 0, right - MIN_ZONE_SIZE)
|
||||||
|
zone.width = right - zone.x
|
||||||
|
}
|
||||||
|
if (handle.includes('e')) {
|
||||||
|
zone.width = Math.max(MIN_ZONE_SIZE, imageX - zone.x)
|
||||||
|
}
|
||||||
|
if (handle.includes('n')) {
|
||||||
|
const bottom = Math.min(zone.y + zone.height, sourceSize.height)
|
||||||
|
zone.y = clamp(imageY, 0, bottom - MIN_ZONE_SIZE)
|
||||||
|
zone.height = bottom - zone.y
|
||||||
|
}
|
||||||
|
if (handle.includes('s')) {
|
||||||
|
zone.height = Math.max(MIN_ZONE_SIZE, imageY - zone.y)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function clamp(value: number, min: number, max: number): number {
|
||||||
|
return Math.max(min, Math.min(value, max))
|
||||||
|
}
|
||||||
@@ -0,0 +1,75 @@
|
|||||||
|
<pngx-page-header
|
||||||
|
title="OCR Templates"
|
||||||
|
i18n-title
|
||||||
|
info="Define extraction zones on document types to automatically populate custom fields via OCR."
|
||||||
|
i18n-info
|
||||||
|
>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-primary" (click)="editTemplate()" *pngxIfPermissions="{ action: PermissionAction.Add, type: PermissionType.OcrTemplate }">
|
||||||
|
<i-bs name="plus-circle" class="me-1"></i-bs><ng-container i18n>Add Template</ng-container>
|
||||||
|
</button>
|
||||||
|
</pngx-page-header>
|
||||||
|
|
||||||
|
<ul class="list-group">
|
||||||
|
|
||||||
|
<li class="list-group-item">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col" i18n>Name</div>
|
||||||
|
<div class="col d-none d-sm-flex" i18n>Matching</div>
|
||||||
|
<div class="col d-none d-sm-flex" i18n>Zones</div>
|
||||||
|
<div class="col" i18n>Status</div>
|
||||||
|
<div class="col" i18n>Actions</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
@if (loading() && templates().length === 0) {
|
||||||
|
<li class="list-group-item">
|
||||||
|
<output class="spinner-border spinner-border-sm me-2"></output>
|
||||||
|
<ng-container i18n>Loading...</ng-container>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
|
||||||
|
@for (template of templates(); track template.id) {
|
||||||
|
<li class="list-group-item">
|
||||||
|
<div class="row fade" [class.show]="show()">
|
||||||
|
<div class="col d-flex align-items-center"><button class="btn btn-link p-0 text-start" type="button" (click)="editTemplate(template)" [disabled]="!userCanEdit(template)">{{template.name}}</button></div>
|
||||||
|
<div class="col d-flex align-items-center d-none d-sm-flex">{{getMatching(template)}}</div>
|
||||||
|
<div class="col d-flex align-items-center d-none d-sm-flex"><code>{{template.zones.length}}</code></div>
|
||||||
|
<div class="col d-flex align-items-center">
|
||||||
|
<div class="form-check form-switch mb-0">
|
||||||
|
<input type="checkbox" class="form-check-input cursor-pointer" [id]="template.id+'_enable'" [(ngModel)]="template.enabled" (change)="toggleTemplateEnabled(template)" [disabled]="!userCanEdit(template)" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.OcrTemplate }">
|
||||||
|
<label class="form-check-label cursor-pointer" [for]="template.id+'_enable'">
|
||||||
|
<code> @if(template.enabled) { <ng-container i18n>Enabled</ng-container> } @else { <span i18n class="text-muted">Disabled</span> }</code>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col">
|
||||||
|
|
||||||
|
<div class="btn-group d-block d-sm-none">
|
||||||
|
<div ngbDropdown container="body" class="d-inline-block">
|
||||||
|
<button type="button" class="btn btn-link" id="actionsMenuMobile{{template.id}}" (click)="$event.stopPropagation()" ngbDropdownToggle>
|
||||||
|
<i-bs name="three-dots-vertical"></i-bs>
|
||||||
|
</button>
|
||||||
|
<div ngbDropdownMenu aria-labelledby="actionsMenuMobile{{template.id}}">
|
||||||
|
<button (click)="editTemplate(template)" [disabled]="!userCanEdit(template)" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.OcrTemplate }" ngbDropdownItem i18n>Edit</button>
|
||||||
|
<button (click)="deleteTemplate(template)" [disabled]="!permissionsService.currentUserOwnsObject(template)" *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.OcrTemplate }" ngbDropdownItem i18n>Delete</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="btn-toolbar d-none d-sm-flex gap-2" role="toolbar">
|
||||||
|
<div class="btn-group">
|
||||||
|
<button *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.OcrTemplate }" [disabled]="!userCanEdit(template)" class="btn btn-sm btn-outline-secondary" type="button" (click)="editTemplate(template)">
|
||||||
|
<i-bs width="1em" height="1em" name="pencil" class="me-1"></i-bs><ng-container i18n>Edit</ng-container>
|
||||||
|
</button>
|
||||||
|
<button *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.OcrTemplate }" [disabled]="!permissionsService.currentUserOwnsObject(template)" class="btn btn-sm btn-outline-danger" type="button" (click)="deleteTemplate(template)">
|
||||||
|
<i-bs width="1em" height="1em" name="trash" class="me-1"></i-bs><ng-container i18n>Delete</ng-container>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
@if (!loading() && templates().length === 0) {
|
||||||
|
<li class="list-group-item" [class.show]="show()" i18n>No OCR templates defined.</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
@@ -0,0 +1,287 @@
|
|||||||
|
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
|
||||||
|
import { provideHttpClientTesting } from '@angular/common/http/testing'
|
||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||||
|
import { By } from '@angular/platform-browser'
|
||||||
|
import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap'
|
||||||
|
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
||||||
|
import { of, throwError } from 'rxjs'
|
||||||
|
import { MATCH_LITERAL, MATCH_NONE } from 'src/app/data/matching-model'
|
||||||
|
import { OcrTemplate } from 'src/app/data/ocr-template'
|
||||||
|
import { User } from 'src/app/data/user'
|
||||||
|
import { DirtyFormGuard } from 'src/app/guards/dirty-form.guard'
|
||||||
|
import { PermissionsService } from 'src/app/services/permissions.service'
|
||||||
|
import { OcrTemplateService } from 'src/app/services/rest/ocr-template.service'
|
||||||
|
import { UserService } from 'src/app/services/rest/user.service'
|
||||||
|
import { ToastService } from 'src/app/services/toast.service'
|
||||||
|
import { ConfirmDialogComponent } from '../../common/confirm-dialog/confirm-dialog.component'
|
||||||
|
import { OcrTemplateEditorComponent } from './ocr-template-editor/ocr-template-editor.component'
|
||||||
|
import { OcrTemplatesComponent } from './ocr-templates.component'
|
||||||
|
|
||||||
|
const templates: OcrTemplate[] = [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
name: 'Invoices',
|
||||||
|
match: 'Northwind',
|
||||||
|
matching_algorithm: MATCH_LITERAL,
|
||||||
|
sample_document: null,
|
||||||
|
source_width: 0,
|
||||||
|
source_height: 0,
|
||||||
|
enabled: true,
|
||||||
|
zones: [],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
describe('OcrTemplatesComponent', () => {
|
||||||
|
let component: OcrTemplatesComponent
|
||||||
|
let fixture: ComponentFixture<OcrTemplatesComponent>
|
||||||
|
let ocrTemplateService: OcrTemplateService
|
||||||
|
let modalService: NgbModal
|
||||||
|
let toastService: ToastService
|
||||||
|
let modal: NgbModalRef
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
imports: [NgxBootstrapIconsModule.pick(allIcons), OcrTemplatesComponent],
|
||||||
|
providers: [
|
||||||
|
provideHttpClient(withInterceptorsFromDi()),
|
||||||
|
provideHttpClientTesting(),
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
TestBed.inject(PermissionsService).initialize(
|
||||||
|
[
|
||||||
|
'view_ocrtemplate',
|
||||||
|
'add_ocrtemplate',
|
||||||
|
'change_ocrtemplate',
|
||||||
|
'delete_ocrtemplate',
|
||||||
|
],
|
||||||
|
{ id: 1, groups: [], is_superuser: false } as User
|
||||||
|
)
|
||||||
|
jest
|
||||||
|
.spyOn(TestBed.inject(UserService), 'listAll')
|
||||||
|
.mockReturnValue(of({ count: 0, all: [], results: [] }))
|
||||||
|
|
||||||
|
ocrTemplateService = TestBed.inject(OcrTemplateService)
|
||||||
|
jest
|
||||||
|
.spyOn(ocrTemplateService, 'listAll')
|
||||||
|
.mockReturnValue(of({ count: 1, all: [1], results: templates }))
|
||||||
|
modalService = TestBed.inject(NgbModal)
|
||||||
|
modalService.activeInstances.subscribe((m) => (modal = m[m.length - 1]))
|
||||||
|
toastService = TestBed.inject(ToastService)
|
||||||
|
fixture = TestBed.createComponent(OcrTemplatesComponent)
|
||||||
|
component = fixture.componentInstance
|
||||||
|
fixture.detectChanges()
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => modalService.dismissAll())
|
||||||
|
|
||||||
|
it('should show templates with their matching', () => {
|
||||||
|
const text = fixture.nativeElement.textContent
|
||||||
|
expect(text).toContain('Invoices')
|
||||||
|
expect(text).toContain('Exact match: Northwind')
|
||||||
|
|
||||||
|
expect(
|
||||||
|
component.getMatching({ ...templates[0], matching_algorithm: MATCH_NONE })
|
||||||
|
).toEqual('None')
|
||||||
|
expect(component.getMatching({ ...templates[0], match: '' })).toEqual('-')
|
||||||
|
})
|
||||||
|
|
||||||
|
it.each([
|
||||||
|
{
|
||||||
|
name: 'owned',
|
||||||
|
owner: 1,
|
||||||
|
user_can_change: true,
|
||||||
|
editable: true,
|
||||||
|
deletable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'unowned',
|
||||||
|
owner: null,
|
||||||
|
user_can_change: true,
|
||||||
|
editable: true,
|
||||||
|
deletable: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'view-only shared',
|
||||||
|
owner: 2,
|
||||||
|
user_can_change: false,
|
||||||
|
editable: false,
|
||||||
|
deletable: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: 'editable shared',
|
||||||
|
owner: 2,
|
||||||
|
user_can_change: true,
|
||||||
|
editable: true,
|
||||||
|
deletable: false,
|
||||||
|
},
|
||||||
|
])(
|
||||||
|
'should respect permissions for $name templates',
|
||||||
|
async ({ owner, user_can_change, editable, deletable }) => {
|
||||||
|
const template = { ...templates[0], owner, user_can_change }
|
||||||
|
component.templates.set([template])
|
||||||
|
fixture.detectChanges()
|
||||||
|
await fixture.whenStable()
|
||||||
|
|
||||||
|
expect(fixture.nativeElement.querySelector('.text-start').disabled).toBe(
|
||||||
|
!editable
|
||||||
|
)
|
||||||
|
expect(
|
||||||
|
fixture.nativeElement.querySelector('.form-switch input').disabled
|
||||||
|
).toBe(!editable)
|
||||||
|
const buttons = Array.from<HTMLButtonElement>(
|
||||||
|
fixture.nativeElement.querySelectorAll('button')
|
||||||
|
)
|
||||||
|
for (const button of buttons.filter(
|
||||||
|
(button) => button.textContent.trim() === 'Edit'
|
||||||
|
)) {
|
||||||
|
expect(button.disabled).toBe(!editable)
|
||||||
|
}
|
||||||
|
for (const button of buttons.filter(
|
||||||
|
(button) => button.textContent.trim() === 'Delete'
|
||||||
|
)) {
|
||||||
|
expect(button.disabled).toBe(!deletable)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
it('should allow a superuser to manage another users template', () => {
|
||||||
|
TestBed.inject(PermissionsService).initialize([], {
|
||||||
|
id: 1,
|
||||||
|
is_superuser: true,
|
||||||
|
} as User)
|
||||||
|
component.templates.set([
|
||||||
|
{ ...templates[0], owner: 2, user_can_change: false },
|
||||||
|
])
|
||||||
|
fixture.detectChanges()
|
||||||
|
expect(fixture.nativeElement.querySelector('.text-start').disabled).toBe(
|
||||||
|
false
|
||||||
|
)
|
||||||
|
expect(
|
||||||
|
fixture.nativeElement.querySelector('.btn-outline-danger').disabled
|
||||||
|
).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not open mutation dialogs or patch a view-only template', () => {
|
||||||
|
const template = { ...templates[0], owner: 2, user_can_change: false }
|
||||||
|
const open = jest.spyOn(modalService, 'open')
|
||||||
|
const patch = jest.spyOn(ocrTemplateService, 'patch')
|
||||||
|
component.editTemplate(template)
|
||||||
|
component.toggleTemplateEnabled(template)
|
||||||
|
component.deleteTemplate(template)
|
||||||
|
expect(open).not.toHaveBeenCalled()
|
||||||
|
expect(patch).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should require global edit permission even for an owned template', () => {
|
||||||
|
TestBed.inject(PermissionsService).initialize(['view_ocrtemplate'], {
|
||||||
|
id: 1,
|
||||||
|
} as User)
|
||||||
|
fixture.destroy()
|
||||||
|
fixture = TestBed.createComponent(OcrTemplatesComponent)
|
||||||
|
component = fixture.componentInstance
|
||||||
|
fixture.detectChanges()
|
||||||
|
expect(fixture.nativeElement.querySelector('.text-start').disabled).toBe(
|
||||||
|
true
|
||||||
|
)
|
||||||
|
expect(fixture.nativeElement.querySelector('.form-switch input')).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should support create, show notification on error / success', () => {
|
||||||
|
const toastErrorSpy = jest.spyOn(toastService, 'showError')
|
||||||
|
const toastSuccessSpy = jest.spyOn(toastService, 'showSuccess')
|
||||||
|
const reloadSpy = jest.spyOn(component, 'reload')
|
||||||
|
|
||||||
|
fixture.debugElement
|
||||||
|
.queryAll(By.css('pngx-page-header button'))
|
||||||
|
.find((button) => button.nativeElement.textContent.includes('Add'))
|
||||||
|
.triggerEventHandler('click')
|
||||||
|
|
||||||
|
const editor = modal.componentInstance as OcrTemplateEditorComponent
|
||||||
|
expect(editor).toBeInstanceOf(OcrTemplateEditorComponent)
|
||||||
|
expect(editor.object).toBeUndefined()
|
||||||
|
|
||||||
|
editor.failed.emit({ error: 'error creating template' })
|
||||||
|
expect(toastErrorSpy).toHaveBeenCalled()
|
||||||
|
expect(reloadSpy).not.toHaveBeenCalled()
|
||||||
|
|
||||||
|
editor.succeeded.emit(templates[0])
|
||||||
|
expect(toastSuccessSpy).toHaveBeenCalled()
|
||||||
|
expect(reloadSpy).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should pass the template to the editor', () => {
|
||||||
|
component.editTemplate(templates[0])
|
||||||
|
expect(modal.componentInstance.object).toBe(templates[0])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should confirm closing the editor with unsaved changes', async () => {
|
||||||
|
const confirmSpy = jest
|
||||||
|
.spyOn(TestBed.inject(DirtyFormGuard), 'confirmChanges')
|
||||||
|
.mockReturnValue(of(false))
|
||||||
|
component.editTemplate()
|
||||||
|
const editor = modal.componentInstance as OcrTemplateEditorComponent
|
||||||
|
editor.ngOnInit()
|
||||||
|
|
||||||
|
modal.dismiss()
|
||||||
|
await fixture.whenStable()
|
||||||
|
expect(confirmSpy).not.toHaveBeenCalled()
|
||||||
|
|
||||||
|
component.editTemplate()
|
||||||
|
const dirtyEditor = modal.componentInstance as OcrTemplateEditorComponent
|
||||||
|
dirtyEditor.ngOnInit()
|
||||||
|
dirtyEditor.template.name = 'Changed'
|
||||||
|
modal.dismiss()
|
||||||
|
await fixture.whenStable()
|
||||||
|
expect(confirmSpy).toHaveBeenCalled()
|
||||||
|
expect(modalService.hasOpenModals()).toBe(true)
|
||||||
|
|
||||||
|
dirtyEditor.saving.set(true)
|
||||||
|
confirmSpy.mockClear()
|
||||||
|
modal.dismiss()
|
||||||
|
await fixture.whenStable()
|
||||||
|
expect(confirmSpy).not.toHaveBeenCalled()
|
||||||
|
expect(modalService.hasOpenModals()).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should only send the enabled flag when toggling', () => {
|
||||||
|
const patchSpy = jest
|
||||||
|
.spyOn(ocrTemplateService, 'patch')
|
||||||
|
.mockReturnValueOnce(throwError(() => new Error('error toggling')))
|
||||||
|
.mockReturnValueOnce(of(templates[0]))
|
||||||
|
const toastErrorSpy = jest.spyOn(toastService, 'showError')
|
||||||
|
const toastSuccessSpy = jest.spyOn(toastService, 'showSuccess')
|
||||||
|
const reloadSpy = jest.spyOn(component, 'reload')
|
||||||
|
|
||||||
|
const toggle = fixture.debugElement.query(By.css('.form-switch input'))
|
||||||
|
toggle.nativeElement.click()
|
||||||
|
expect(patchSpy).toHaveBeenCalledWith({ id: 1, enabled: false })
|
||||||
|
expect(toastErrorSpy).toHaveBeenCalled()
|
||||||
|
expect(reloadSpy).toHaveBeenCalledTimes(1)
|
||||||
|
|
||||||
|
component.toggleTemplateEnabled(templates[0])
|
||||||
|
expect(toastSuccessSpy).toHaveBeenCalled()
|
||||||
|
expect(reloadSpy).toHaveBeenCalledTimes(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should support delete, show notification on error / success', () => {
|
||||||
|
const toastErrorSpy = jest.spyOn(toastService, 'showError')
|
||||||
|
const toastSuccessSpy = jest.spyOn(toastService, 'showSuccess')
|
||||||
|
const deleteSpy = jest.spyOn(ocrTemplateService, 'delete')
|
||||||
|
const reloadSpy = jest.spyOn(component, 'reload')
|
||||||
|
|
||||||
|
component.deleteTemplate(templates[0])
|
||||||
|
const dialog = modal.componentInstance as ConfirmDialogComponent
|
||||||
|
|
||||||
|
deleteSpy.mockReturnValueOnce(throwError(() => new Error('error deleting')))
|
||||||
|
dialog.confirmClicked.emit()
|
||||||
|
expect(toastErrorSpy).toHaveBeenCalled()
|
||||||
|
expect(dialog.buttonsEnabled()).toBe(true)
|
||||||
|
expect(reloadSpy).not.toHaveBeenCalled()
|
||||||
|
|
||||||
|
deleteSpy.mockReturnValueOnce(of(true))
|
||||||
|
dialog.confirmClicked.emit()
|
||||||
|
expect(toastSuccessSpy).toHaveBeenCalled()
|
||||||
|
expect(reloadSpy).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
Loaded 100 of 148 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user