Compare commits

..

100 Commits

Author SHA1 Message Date
shamoon
18299dafd2 Bumps version to 1.16.4 2023-06-26 11:25:18 -07:00
shamoon
817d09026e Merge branch 'dev' 2023-06-26 11:24:04 -07:00
Paperless-ngx Bot [bot]
d76b009390 New Crowdin updates (#3698)
* New translations messages.xlf (Spanish)
[ci skip]

* New translations messages.xlf (Arabic)
[ci skip]

* New translations messages.xlf (Catalan)
[ci skip]

* New translations messages.xlf (German)
[ci skip]

* New translations messages.xlf (Dutch)
[ci skip]

* New translations messages.xlf (Slovenian)
[ci skip]

* New translations messages.xlf (Swedish)
[ci skip]

* New translations messages.xlf (Afrikaans)
[ci skip]

* New translations messages.xlf (Slovak)
[ci skip]

* New translations messages.xlf (Greek)
[ci skip]

* New translations messages.xlf (Romanian)
[ci skip]

* New translations messages.xlf (French)
[ci skip]

* New translations messages.xlf (Belarusian)
[ci skip]

* New translations messages.xlf (Czech)
[ci skip]

* New translations messages.xlf (Danish)
[ci skip]

* New translations messages.xlf (Finnish)
[ci skip]

* New translations messages.xlf (Hebrew)
[ci skip]

* New translations messages.xlf (Hungarian)
[ci skip]

* New translations messages.xlf (Italian)
[ci skip]

* New translations messages.xlf (Norwegian)
[ci skip]

* New translations messages.xlf (Polish)
[ci skip]

* New translations messages.xlf (Portuguese)
[ci skip]

* New translations messages.xlf (Russian)
[ci skip]

* New translations messages.xlf (Turkish)
[ci skip]

* New translations messages.xlf (Chinese Simplified)
[ci skip]

* New translations messages.xlf (Portuguese, Brazilian)
[ci skip]

* New translations messages.xlf (Indonesian)
[ci skip]

* New translations messages.xlf (Croatian)
[ci skip]

* New translations messages.xlf (Luxembourgish)
[ci skip]

* New translations messages.xlf (Serbian (Latin))
[ci skip]
2023-06-26 11:23:42 -07:00
shamoon
9effed3ce1 Update messages.xlf 2023-06-26 11:21:20 -07:00
shamoon
c1bbfc5dcf Update api.md 2023-06-26 10:08:21 -07:00
shamoon
b6c9cfb76f Merge pull request #3697 from paperless-ngx/docs/update-api-perms-docs
Documentation: update API docs re permissions
2023-06-26 10:06:05 -07:00
shamoon
59ca7bbcf2 Update API docs re permissions 2023-06-26 10:03:44 -07:00
Paperless-ngx Bot [bot]
52c8d5e999 New translations messages.xlf (Spanish) (#3659)
[ci skip]
2023-06-26 09:58:12 -07:00
shamoon
5851e7f1b7 Merge pull request #3682 from paperless-ngx/fix/issue-3679
Fix: prevent button wrapping when sidebar narrows in MS Edge
2023-06-26 09:43:20 -07:00
Trenton H
e05b3441de Updates tika client library and handle the changes to it 2023-06-26 10:41:05 -06:00
Trenton H
0d6e79cb93 Fixes generation of thumbnails when the archive file hasn't already been created 2023-06-26 10:36:50 -06:00
shamoon
76a102d901 Prevent button wrapping 2023-06-25 07:25:07 -07:00
shamoon
0880420ef6 Merge pull request #3662 from kleinweby/fix-filter-row-gap
Fix: Use row gap for filter editor
2023-06-22 11:15:26 -07:00
Christian Speich
2351c79282 Use row gap for filter editor
Fixes other indentation in filter-editor
2023-06-22 11:13:17 -07:00
shamoon
d6016fc798 Update PULL_REQUEST_TEMPLATE.md 2023-06-22 09:46:18 -07:00
github-actions[bot]
bc17291006 Changelog v1.16.3 - GHA (#3661)
Co-authored-by: github-actions <41898282+github-actions[bot]@users.noreply.github.com>
2023-06-22 09:42:36 -07:00
Trenton H
ed6cb14c4d Updates codecov configuration for the flag settings and notification interaction 2023-06-22 08:20:04 -07:00
Trenton Holmes
08de8a04b8 Resets -dev versioning 2023-06-22 06:34:51 -07:00
Trenton Holmes
5c67de8b47 Bumps version to 1.16.3 2023-06-22 06:32:25 -07:00
Paperless-ngx Bot [bot]
38b0408b1a New Crowdin updates (#3654)
* New translations messages.xlf (Spanish)
[ci skip]

* New translations django.po (Chinese Simplified)
[ci skip]

* New translations messages.xlf (Chinese Simplified)
[ci skip]

* New translations django.po (Chinese Simplified)
[ci skip]
2023-06-21 21:02:14 -07:00
shamoon
9ccad7ea86 Fix date component incorrect translation unit 2023-06-21 20:59:29 -07:00
shamoon
4a4e810a14 Fix invalid translation unit 2023-06-21 20:40:29 -07:00
Trenton H
76d2df3bde Resets frontend version tag to -dev 2023-06-21 09:50:50 -07:00
Trenton H
c02563d894 Bumps version to 1.16.3 2023-06-21 09:49:36 -07:00
Trenton H
574ec6780b Merge remote-tracking branch 'origin/dev' 2023-06-21 09:48:33 -07:00
Paperless-ngx Bot [bot]
9e0f56982b New Crowdin updates (#3529)
* New translations messages.xlf (French)
[ci skip]

* New translations messages.xlf (Spanish)
[ci skip]

* New translations messages.xlf (Arabic)
[ci skip]

* New translations messages.xlf (Belarusian)
[ci skip]

* New translations messages.xlf (Catalan)
[ci skip]

* New translations messages.xlf (Czech)
[ci skip]

* New translations messages.xlf (Danish)
[ci skip]

* New translations messages.xlf (German)
[ci skip]

* New translations messages.xlf (Finnish)
[ci skip]

* New translations messages.xlf (Hebrew)
[ci skip]

* New translations messages.xlf (Italian)
[ci skip]

* New translations messages.xlf (Dutch)
[ci skip]

* New translations messages.xlf (Norwegian)
[ci skip]

* New translations messages.xlf (Polish)
[ci skip]

* New translations messages.xlf (Portuguese)
[ci skip]

* New translations messages.xlf (Russian)
[ci skip]

* New translations messages.xlf (Slovenian)
[ci skip]

* New translations messages.xlf (Swedish)
[ci skip]

* New translations messages.xlf (Turkish)
[ci skip]

* New translations messages.xlf (Chinese Simplified)
[ci skip]

* New translations messages.xlf (Portuguese, Brazilian)
[ci skip]

* New translations messages.xlf (Indonesian)
[ci skip]

* New translations messages.xlf (Croatian)
[ci skip]

* New translations messages.xlf (Luxembourgish)
[ci skip]

* New translations messages.xlf (Serbian (Latin))
[ci skip]

* New translations messages.xlf (Hungarian)
[ci skip]

* New translations messages.xlf (Romanian)
[ci skip]

* New translations messages.xlf (Afrikaans)
[ci skip]

* New translations messages.xlf (Slovak)
[ci skip]
2023-06-21 08:03:43 -07:00
Trenton H
1c66daf12b Ignore errors when trying to copy the original file's stats 2023-06-21 07:54:27 -07:00
shamoon
59d683849e Merge pull request #3645 from plu/test_3631
Add test for not moving default thumbnail
2023-06-20 16:43:16 -07:00
Johannes Plunien
9946acb1a0 Add test for not moving default thumbnail
See also #3632 and #3631
2023-06-20 20:54:15 +02:00
Johannes Plunien
83a760644d Copy default thumbnail if thumbnail generation fails
Fix #3631
2023-06-20 11:28:46 -07:00
shamoon
25ccff8640 Merge pull request #3641 from paperless-ngx/update-stale-labels
Chore: include 'not a bug' for stale action
2023-06-20 11:00:44 -07:00
Trenton H
5c4c5a7794 Explicitly set some environment for each supervised program, as it is not updated by supervisord 2023-06-20 10:53:33 -07:00
shamoon
cb6af97595 Include 'not a bug' for stale action 2023-06-20 10:53:05 -07:00
Trenton H
c4407dccf6 Updates the default Postgres to 15 for new installs 2023-06-20 10:35:48 -07:00
Trenton H
ecdea4c3c8 Updates the stale timing and close timing 2023-06-20 08:29:19 -07:00
Trenton H
26d6f302cf When starting with an external DB, start it for a bit first to allow its setup to complete 2023-06-20 08:20:44 -07:00
github-actions[bot]
ecf10622ef [Documentation] Add v1.16.2 changelog (#3629)
* Changelog v1.16.2 - GHA

* Update changelog.md

---------

Co-authored-by: github-actions <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: shamoon <4887959+shamoon@users.noreply.github.com>
2023-06-19 19:52:11 -07:00
Trenton H
0fb553675b Resets -dev version string 2023-06-19 09:39:50 -07:00
Trenton H
2080fde4f9 Bumps the version to v1.16.2 2023-06-19 09:07:11 -07:00
Trenton H
d10e67ce09 Merge remote-tracking branch 'origin/dev' 2023-06-19 09:01:15 -07:00
Trenton H
74fe7c586b Updates the httpx timeout to be 30s for all operations 2023-06-19 08:59:51 -07:00
Trenton Holmes
05188aed6d Bumps our locked pipenv version for CI and Docker image builds 2023-06-18 10:09:38 -07:00
Trenton Holmes
865efb7752 Sets the retention days for all uploaded artifacts to be 7 days after upload 2023-06-18 10:08:36 -07:00
Trenton Holmes
4782b4da07 Adds better error handling/checking around getting content of a document via Tika
Signed-off-by: Trenton Holmes <797416+stumpylog@users.noreply.github.com>
2023-06-18 08:39:17 -07:00
Daniel Dietzler
4693632c7d Feature: separate save / save & close buttons (#3575)
* Add setting to decide whether the edit dialog should automatically close on save

* Add the actual button to the ui

* Revert "Add the actual button to the ui"

This reverts commit e1f5a8bde0.

* Revert "Add setting to decide whether the edit dialog should automatically close on save"

This reverts commit feef3c909b.

* Add button for save without exit

* Correct save button ordering, ensure perms, update translation strings

* fix e2e tests

* Add unit testing for save / save & close button

---------

Update messages.xlf

Update document-detail.component.spec.ts

Co-Authored-By: shamoon <4887959+shamoon@users.noreply.github.com>
2023-06-18 08:06:54 -07:00
shamoon
4c4b571a88 Merge pull request #3597 from paperless-ngx/frontend-unit-testing
Development: frontend unit testing
2023-06-17 21:13:34 -07:00
Trenton H
328c87995b Experiment with a buildx cache mount for pip's directory. Does it increase image size? 2023-06-17 20:13:38 -07:00
shamoon
a1d10e7d4a Update stale.yml 2023-06-17 20:02:44 -07:00
shamoon
77d9a7e9d3 Upload frontend coverage to codecov 2023-06-16 19:49:01 -07:00
shamoon
981b090088 Update frontend testing dev documentation 2023-06-16 19:38:00 -07:00
shamoon
6d1c788ee0 Merge pull request #3608 from paperless-ngx/v1.16.1-changelog
[Documentation] Add v1.16.1 changelog
2023-06-16 12:24:35 -07:00
github-actions
02de773d5b Changelog v1.16.1 - GHA 2023-06-16 17:25:54 +00:00
Trenton H
2a240d83fd Reset -dev version tagging 2023-06-16 10:08:36 -07:00
Trenton H
25cdf7916d Bumps version to 1.16.1 2023-06-16 09:56:40 -07:00
Trenton H
11b5983a0d Merge remote-tracking branch 'origin/dev' 2023-06-16 09:55:32 -07:00
Trenton H
4964987245 Fixes the image cleaner not actually deleting the old images 2023-06-16 09:19:03 -07:00
Trenton H
ed129d6074 Updates to a build of Pillow which builds against libtiff6 instead of expecting libtiff5 2023-06-16 09:14:44 -07:00
shamoon
37e928d869 Run jest tests in ci & upload coverage
update playwright
2023-06-16 07:36:41 -07:00
shamoon
06def8c11e frontend unit tests
toasts component testing

conditional import of angular setup-jest for vscode-jest support

Update jest.config.js

Create open-documents.service.spec.ts

Add unit tests for all REST services

settings service test

Remove component from settings service test

Create permissions.service.spec.ts

upload documents service tests

Update package.json

Create toast.service.spec.ts

Tasks service test

Statistics widget component tests

Update permissions.service.ts

Create app.component.spec.ts

settings component testing

tasks component unit testing

Management list component generic tests

Some management component tests

document notes component unit tests

Create document-list.component.spec.ts

Create save-view-config-dialog.component.spec.ts

Create filter-editor.component.spec.ts

small and large document cards unit testing

Create bulk-editor.component.spec.ts

document detail unit tests

saving work on documentdetail component spec

Create document-asn.component.spec.ts

dashboard & widgets unit testing

Fix ResizeObserver mock

common component unit tests

fix some merge errors

Update app-frame.component.spec.ts

Create page-header.component.spec.ts

input component unit tests

FilterableDropdownComponent unit testing

and found minor errors

update taskservice unit tests

Edit dialogs unit tests

Create date-dropdown.component.spec.ts

Remove selectors from guard tests

confirm dialog component tests

app frame component test

Miscellaneous component tests

Update document-list-view.service.spec.ts

directives unit tests

Remove unused resizeobserver mock

guard unit tests

Update query-params.spec.ts

try to fix flaky playwright

filter rules utils & testing

Interceptor unit tests

Pipes unit testing

Utils unit tests

Update upload-documents.service.spec.ts

consumer status service tests

Update setup-jest.ts

Create document-list-view.service.spec.ts

Update app-routing.module.ts
2023-06-15 23:53:04 -07:00
github-actions[bot]
10571676a4 Changelog v1.16.0 - GHA (#3595)
Co-authored-by: github-actions <41898282+github-actions[bot]@users.noreply.github.com>
2023-06-15 13:57:16 -07:00
Trenton H
af5160237d Resets version tag to -dev 2023-06-15 10:30:18 -07:00
Trenton H
b86842ba73 Bumps version to 1.16.0 2023-06-15 08:54:51 -07:00
Trenton H
bfc271e743 Merge remote-tracking branch 'origin/dev' 2023-06-15 08:54:03 -07:00
Trenton H
ee88140fdd Adds back execute permissions to jbig2 2023-06-15 07:33:19 -07:00
Trenton H
51249a1dce Updates to wheels and binaries build using Bookworm 2023-06-15 07:33:19 -07:00
Trenton H
57ec9e6b13 Use Bookworm for the frontend build as well 2023-06-15 07:33:19 -07:00
Trenton H
1324d17d87 Don't install python3-dev, it's not the right version, and Python headers are included in the image 2023-06-15 07:33:19 -07:00
Trenton H
26b438a888 Updates the Docker base image to Debian Bookworm (from Bullseye) 2023-06-15 07:33:19 -07:00
Trenton H
70f3f98363 Let ruff autofix some things from the newest version 2023-06-13 20:15:18 -07:00
Trenton H
71e4be2d5e Sets broker connection retry settings for celery 2023-06-13 20:15:18 -07:00
Trenton H
5740806a28 Updates to celery 5.3.0 in particular, other minor updates 2023-06-13 20:15:18 -07:00
shamoon
9b50a1b7a6 Merge pull request #3579 from paperless-ngx/fix/issue-3578
Fix: return user first / last name from backend
2023-06-12 09:01:54 -07:00
shamoon
19caad832e Merge pull request #3576 from paperless-ngx/fix/issue-3569
Fix use of `PAPERLESS_DB_TIMEOUT` for all db types
2023-06-12 09:01:44 -07:00
Trenton H
dd6ae13281 Changes the type of the connection timeout to be an int, not a float 2023-06-12 08:45:57 -07:00
shamoon
077abbe961 Return user first & last name from backend 2023-06-12 08:15:59 -07:00
shamoon
3d85dc1127 Fix use of PAPERLESS_DB_TIMEOUT for all db types 2023-06-12 01:31:38 -07:00
Trenton H
e3ea5dd13c Silence a warning about setting this by setting it 2023-06-08 15:05:36 -07:00
shamoon
714b2ecd9c Merge pull request #3554 from paperless-ngx/fix/issue-3553
Fix: handle mail rules with no filters on some imap servers
2023-06-07 13:00:38 -07:00
Trenton H
883937bfd7 In cases where a temporary file is created or used, copy the original file stats to it 2023-06-07 09:02:19 -07:00
shamoon
0ebe08d796 Return default 'ALL' mailbox criterias for some imap servers 2023-06-06 20:00:31 -07:00
Trenton H
36b4fff5c7 Removes packages which are no longer built/published from the cleaning 2023-06-06 14:38:59 -07:00
shamoon
0684c8c388 Merge pull request #3552 from paperless-ngx/fix/issue-3548
Chore: clarify behavior of consumption dir in docs
2023-06-06 13:54:25 -07:00
shamoon
67744c877d Clarify behavior of consumption dir in docs 2023-06-06 13:37:54 -07:00
Trenton H
45d8c945e2 Small improvements to coverage 2023-06-06 13:18:13 -07:00
Trenton H
ee19307ea2 Restore pushing codecov in all cases. I don't think this was doing what I wanted 2023-06-06 09:05:26 -07:00
Trenton H
2c1cd25be4 Rewrites the email parsing to be more clear and concise.
Adds testing to use httpx mocked responses to stand in as a server even offline
2023-06-06 09:05:26 -07:00
Trenton H
6e65558ea4 Swapping out the tika and replaces requests with httpx 2023-06-06 09:05:26 -07:00
shamoon
304324ebd0 Update index.py 2023-06-04 10:41:45 -07:00
jayme-github
97cd06d2ba Feature: Allow to filter documents by original filename and checksum (#3485)
* Allow to filter documents by original filename and checksum

This adds filters for the original filename and checksum of documents to
be able to to lazy checks if the file is already stored in paperless.

* Add tests for DelayedQuery

* Add checksum and original_filename to whoosh index and DelayedQuery

* Refactored DelayedQuery to reduce duplicate code
* Choose icontains for checksums as whoosh has no exact match query term
* Bumped index version

* Revert whoosh filtering logic to simpler structure, remove redundant tests

Revert "Revert whoosh filtering logic to simpler structure, remove redundant tests"

This reverts commit 86792174bfbc697f42b72c4b39ee9eba483bb425.

---------

Co-authored-by: shamoon <4887959+shamoon@users.noreply.github.com>
2023-06-04 10:34:27 -07:00
shamoon
df948065a3 Merge pull request #3528 from paperless-ngx/v1.15.1-changelog
[Documentation] Add v1.15.1 changelog
2023-06-04 08:41:01 -07:00
github-actions
f92126b44f Changelog v1.15.1 - GHA 2023-06-03 23:24:40 +00:00
shamoon
e329f6cdf1 Fix display of private items in small cards 2023-06-03 16:16:05 -07:00
shamoon
2c96438d61 Update environment.prod.ts 2023-06-03 16:10:05 -07:00
shamoon
41a9aac75d v1.15.1 2023-06-03 16:06:37 -07:00
shamoon
8768168536 Merge branch 'dev' 2023-06-03 16:04:27 -07:00
shamoon
325809fbbf other minor css fixes after bootstrap update 2023-06-03 16:03:00 -07:00
shamoon
3dd47a9f5b Merge pull request #3523 from paperless-ngx/fix/issue-3522
Fix incorrect colors in v1.15.0
2023-06-03 15:08:42 -07:00
github-actions[bot]
00f16ef8f0 [Documentation] Add v1.15.0 changelog (#3521)
* Changelog v1.15.0 - GHA

* Update changelog.md

---------

Co-authored-by: github-actions <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: shamoon <4887959+shamoon@users.noreply.github.com>
2023-06-03 15:08:20 -07:00
shamoon
5e67aae83b Fix incorrect colors after last bootstrap update 2023-06-03 11:09:30 -07:00
shamoon
ae5c603c98 Update environment.prod.ts 2023-06-03 09:32:04 -07:00
245 changed files with 28320 additions and 10030 deletions

View File

@@ -1,3 +1,17 @@
codecov:
require_ci_to_pass: true
# https://docs.codecov.com/docs/flags#recommended-automatic-flag-management
# Require each flag to have 1 upload before notification
flag_management:
default_rules:
after_n_builds: 1
individual_flags:
- name: backend
paths:
- src/
- name: frontend
paths:
- src-ui/
# https://docs.codecov.com/docs/pull-request-comments
# codecov will only comment if coverage changes
comment:

View File

@@ -20,11 +20,16 @@ NOTE: Please check only one box!
- [ ] Bug fix (non-breaking change which fixes an issue)
- [ ] New feature (non-breaking change which adds functionality)
- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)
- [ ] Other (please explain)
- [ ] Other (please explain):
## Checklist:
<!--
NOTE: PRs that do not address the following will not be merged, please do not skip any relevant items.
-->
- [ ] I have read & agree with the [contributing guidelines](https://github.com/paperless-ngx/paperless-ngx/blob/main/CONTRIBUTING.md).
- [ ] If applicable, I have included testing coverage for new code in this PR, for [backend](https://docs.paperless-ngx.com/development/#testing) and / or [front-end](https://docs.paperless-ngx.com/development/#testing-and-code-style) changes.
- [ ] If applicable, I have tested my code for new features & regressions on both mobile & desktop devices, using the latest version of major browsers.
- [ ] If applicable, I have checked that all tests pass, see [documentation](https://docs.paperless-ngx.com/development/#back-end-development).
- [ ] I have run all `pre-commit` hooks, see [documentation](https://docs.paperless-ngx.com/development/#code-formatting-with-pre-commit-hooks).

23
.github/stale.yml vendored
View File

@@ -1,23 +0,0 @@
# Number of days of inactivity before an issue becomes stale
daysUntilStale: 30
# Number of days of inactivity before a stale issue is closed
daysUntilClose: 7
# Only issues or pull requests with all of these labels are check if stale. Defaults to `[]` (disabled)
onlyLabels: [cant-reproduce]
# Label to use when marking an issue as stale
staleLabel: stale
# Comment to post when marking an issue as stale. Set to `false` to disable
markComment: >
This issue has been automatically marked as stale because it has not had
recent activity. It will be closed if no further activity occurs. Thank you
for your contributions.
# Comment to post when closing a stale issue. Set to `false` to disable
closeComment: false
# See https://github.com/marketplace/stale for more info on the app
# and https://github.com/probot/stale for the configuration docs

View File

@@ -16,7 +16,7 @@ on:
env:
# This is the version of pipenv all the steps will use
# If changing this, change Dockerfile
DEFAULT_PIP_ENV_VERSION: "2023.4.20"
DEFAULT_PIP_ENV_VERSION: "2023.6.12"
# This is the default version of Python to use in most steps
# If changing this, change Dockerfile
DEFAULT_PYTHON_VERSION: "3.9"
@@ -77,6 +77,7 @@ jobs:
with:
name: documentation
path: site/
retention-days: 7
documentation-deploy:
name: "Deploy Documentation"
@@ -106,15 +107,6 @@ jobs:
matrix:
python-version: ['3.8', '3.9', '3.10']
fail-fast: false
env:
# Enable Tika end to end testing
TIKA_LIVE: 1
# Enable paperless_mail testing against real server
PAPERLESS_MAIL_TEST_HOST: ${{ secrets.TEST_MAIL_HOST }}
PAPERLESS_MAIL_TEST_USER: ${{ secrets.TEST_MAIL_USER }}
PAPERLESS_MAIL_TEST_PASSWD: ${{ secrets.TEST_MAIL_PASSWD }}
# Enable Gotenberg end to end testing
GOTENBERG_LIVE: 1
steps:
-
name: Checkout
@@ -156,12 +148,18 @@ jobs:
pipenv --python ${{ steps.setup-python.outputs.python-version }} run pip list
-
name: Tests
env:
PAPERLESS_CI_TEST: 1
# Enable paperless_mail testing against real server
PAPERLESS_MAIL_TEST_HOST: ${{ secrets.TEST_MAIL_HOST }}
PAPERLESS_MAIL_TEST_USER: ${{ secrets.TEST_MAIL_USER }}
PAPERLESS_MAIL_TEST_PASSWD: ${{ secrets.TEST_MAIL_PASSWD }}
run: |
cd src/
pipenv --python ${{ steps.setup-python.outputs.python-version }} run pytest -ra
-
name: Upload coverage to Codecov
if: ${{ matrix.python-version == env.DEFAULT_PYTHON_VERSION && github.event_name == 'push'}}
if: ${{ matrix.python-version == env.DEFAULT_PYTHON_VERSION }}
uses: codecov/codecov-action@v3
with:
# not required for public repos, but intermittently fails otherwise
@@ -202,15 +200,36 @@ jobs:
name: Linting checks
run: cd src-ui && npm run lint
-
name: Run Playwright tests
name: Run Jest unit tests
run: cd src-ui && npm run test
-
name: Upload Jest coverage
if: always()
uses: actions/upload-artifact@v3
with:
name: jest-coverage-report
path: src-ui/coverage
retention-days: 7
-
name: Upload frontend coverage to Codecov
if: always()
uses: codecov/codecov-action@v3
with:
# not required for public repos, but intermittently fails otherwise
token: ${{ secrets.CODECOV_TOKEN }}
# future expansion
flags: frontend
-
name: Run Playwright e2e tests
run: cd src-ui && npx playwright test
-
name: Upload test results
name: Upload Playwright test results
if: always()
uses: actions/upload-artifact@v3
with:
name: playwright-report
path: src-ui/playwright-report
retention-days: 7
build-docker-image:
name: Build Docker image for ${{ github.ref_name }}
@@ -309,7 +328,7 @@ jobs:
push: ${{ github.event_name != 'pull_request' }}
tags: ${{ steps.docker-meta.outputs.tags }}
labels: ${{ steps.docker-meta.outputs.labels }}
# Get cache layers from this branch, then dev, then main
# Get cache layers from this branch, then dev
# This allows new branches to get at least some cache benefits, generally from dev
cache-from: |
type=registry,ref=ghcr.io/${{ steps.set-ghcr-repository.outputs.ghcr-repository }}/builder/cache/app:${{ github.ref_name }}
@@ -331,6 +350,7 @@ jobs:
with:
name: frontend-compiled
path: src/documents/static/frontend/
retention-days: 7
build-release:
needs:
@@ -439,6 +459,7 @@ jobs:
with:
name: release
path: dist/paperless-ngx.tar.xz
retention-days: 7
publish-release:
runs-on: ubuntu-22.04

View File

@@ -38,6 +38,7 @@ jobs:
scheme: "branch"
repo_name: "paperless-ngx"
match_regex: "feature-"
do_delete: "true"
cleanup-untagged-images:
name: Cleanup Untagged Images Tags for ${{ matrix.primary-name }}
@@ -51,14 +52,6 @@ jobs:
include:
- primary-name: "paperless-ngx"
- primary-name: "paperless-ngx/builder/cache/app"
- primary-name: "paperless-ngx/builder/qpdf"
- primary-name: "paperless-ngx/builder/cache/qpdf"
- primary-name: "paperless-ngx/builder/pikepdf"
- primary-name: "paperless-ngx/builder/cache/pikepdf"
- primary-name: "paperless-ngx/builder/jbig2enc"
- primary-name: "paperless-ngx/builder/cache/jbig2enc"
- primary-name: "paperless-ngx/builder/psycopg2"
- primary-name: "paperless-ngx/builder/cache/psycopg2"
# TODO: Remove the above and replace with the below
# - primary-name: "builder/qpdf"
# - primary-name: "builder/cache/qpdf"
@@ -81,3 +74,4 @@ jobs:
owner: "${{ github.repository_owner }}"
is_org: "true"
package_name: "${{ matrix.primary-name }}"
do_delete: "true"

View File

@@ -19,9 +19,9 @@ jobs:
steps:
- uses: actions/stale@v8
with:
days-before-stale: 30
days-before-close: 7
only-labels: 'cant-reproduce'
days-before-stale: 7
days-before-close: 14
any-of-labels: 'cant-reproduce,not a bug'
stale-issue-label: stale
stale-pr-label: stale
stale-issue-message: >

View File

@@ -37,7 +37,7 @@ repos:
exclude: "(^Pipfile\\.lock$)"
# Python hooks
- repo: https://github.com/charliermarsh/ruff-pre-commit
rev: 'v0.0.265'
rev: 'v0.0.272'
hooks:
- id: ruff
- repo: https://github.com/psf/black
@@ -57,6 +57,6 @@ repos:
args:
- "--tab"
- repo: https://github.com/shellcheck-py/shellcheck-py
rev: "v0.9.0.2"
rev: "v0.9.0.5"
hooks:
- id: shellcheck

View File

@@ -1,11 +1,11 @@
# syntax=docker/dockerfile:1.4
# syntax=docker/dockerfile:1
# https://github.com/moby/buildkit/blob/master/frontend/dockerfile/docs/reference.md
# Stage: compile-frontend
# Purpose: Compiles the frontend
# Notes:
# - Does NPM stuff with Typescript and such
FROM --platform=$BUILDPLATFORM node:16-bullseye-slim AS compile-frontend
FROM --platform=$BUILDPLATFORM docker.io/node:16-bookworm-slim AS compile-frontend
COPY ./src-ui /src/src-ui
@@ -21,7 +21,7 @@ RUN set -eux \
# Comments:
# - pipenv dependencies are not left in the final image
# - pipenv can't touch the final image somehow
FROM --platform=$BUILDPLATFORM python:3.9-alpine as pipenv-base
FROM --platform=$BUILDPLATFORM docker.io/python:3.9-alpine as pipenv-base
WORKDIR /usr/src/pipenv
@@ -29,7 +29,7 @@ COPY Pipfile* ./
RUN set -eux \
&& echo "Installing pipenv" \
&& python3 -m pip install --no-cache-dir --upgrade pipenv==2023.4.20 \
&& python3 -m pip install --no-cache-dir --upgrade pipenv==2023.6.12 \
&& echo "Generating requirement.txt" \
&& pipenv requirements > requirements.txt
@@ -37,7 +37,7 @@ RUN set -eux \
# Purpose: The final image
# Comments:
# - Don't leave anything extra in here
FROM python:3.9-slim-bullseye as main-app
FROM docker.io/python:3.9-slim-bookworm as main-app
LABEL org.opencontainers.image.authors="paperless-ngx team <hello@paperless-ngx.com>"
LABEL org.opencontainers.image.documentation="https://docs.paperless-ngx.com/"
@@ -70,9 +70,9 @@ ARG RUNTIME_PACKAGES="\
# Image processing
liblept5 \
liblcms2-2 \
libtiff5 \
libtiff6 \
libfreetype6 \
libwebp6 \
libwebp7 \
libopenjp2-7 \
libimagequant0 \
libraqm0 \
@@ -98,6 +98,8 @@ ARG RUNTIME_PACKAGES="\
libxml2 \
libxslt1.1 \
libgnutls30 \
libqpdf29 \
qpdf \
# Mime type detection
file \
libmagic1 \
@@ -181,7 +183,7 @@ ARG PSYCOPG2_VERSION=2.9.6
RUN set -eux \
&& echo "Getting binaries" \
&& mkdir paperless-ngx \
&& curl --fail --silent --show-error --output paperless-ngx.tar.gz --location https://github.com/paperless-ngx/builder/archive/3d6574e2dbaa8b8cdced864a256b0de59015f605.tar.gz \
&& curl --fail --silent --show-error --output paperless-ngx.tar.gz --location https://github.com/paperless-ngx/builder/archive/58bb061b9b3b63009852d6d875f9a305d9ae6ac9.tar.gz \
&& tar -xf paperless-ngx.tar.gz --directory paperless-ngx --strip-components=1 \
&& cd paperless-ngx \
# Setting a specific revision ensures we know what this installed
@@ -189,9 +191,7 @@ RUN set -eux \
&& echo "Installing jbig2enc" \
&& cp ./jbig2enc/${JBIG2ENC_VERSION}/${TARGETARCH}${TARGETVARIANT}/jbig2 /usr/local/bin/ \
&& cp ./jbig2enc/${JBIG2ENC_VERSION}/${TARGETARCH}${TARGETVARIANT}/libjbig2enc* /usr/local/lib/ \
&& echo "Installing qpdf" \
&& apt-get install --yes --no-install-recommends ./qpdf/${QPDF_VERSION}/${TARGETARCH}${TARGETVARIANT}/libqpdf29_*.deb \
&& apt-get install --yes --no-install-recommends ./qpdf/${QPDF_VERSION}/${TARGETARCH}${TARGETVARIANT}/qpdf_*.deb \
&& chmod a+x /usr/local/bin/jbig2 \
&& echo "Installing pikepdf and dependencies" \
&& python3 -m pip install --no-cache-dir ./pikepdf/${PIKEPDF_VERSION}/${TARGETARCH}${TARGETVARIANT}/*.whl \
&& python3 -m pip list \
@@ -214,16 +214,17 @@ COPY --from=pipenv-base /usr/src/pipenv/requirements.txt ./
ARG BUILD_PACKAGES="\
build-essential \
git \
default-libmysqlclient-dev \
python3-dev"
default-libmysqlclient-dev"
RUN set -eux \
# hadolint ignore=DL3042
RUN --mount=type=cache,target=/root/.cache/pip/,id=pip-cache \
set -eux \
&& echo "Installing build system packages" \
&& apt-get update \
&& apt-get install --yes --quiet --no-install-recommends ${BUILD_PACKAGES} \
&& python3 -m pip install --no-cache-dir --upgrade wheel \
&& echo "Installing Python requirements" \
&& python3 -m pip install --default-timeout=1000 --no-cache-dir --requirement requirements.txt \
&& python3 -m pip install --default-timeout=1000 --requirement requirements.txt \
&& echo "Installing NLTK data" \
&& python3 -W ignore::RuntimeWarning -m nltk.downloader -d "/usr/share/nltk_data" snowball_data \
&& python3 -W ignore::RuntimeWarning -m nltk.downloader -d "/usr/share/nltk_data" stopwords \

View File

@@ -37,14 +37,13 @@ psycopg2 = "*"
rapidfuzz = "*"
redis = {extras = ["hiredis"], version = "*"}
scikit-learn = "~=1.2"
numpy = "*"
whitenoise = "~=6.3"
watchdog = "~=2.2"
whoosh="~=2.7"
inotifyrecursive = "~=0.3"
ocrmypdf = "~=14.0"
tqdm = "*"
tika = "*"
tika-client = "*"
channels = "~=4.0"
channels-redis = "*"
uvicorn = {extras = ["standard"], version = "*"}
@@ -67,6 +66,7 @@ scipy = "==1.8.1"
reportlab = "==3.6.12"
# Pin this until piwheels is building a newer version (see https://www.piwheels.org/project/cryptography/)
cryptography = "==40.0.1"
httpx = "*"
[dev-packages]
# Linting
@@ -78,6 +78,7 @@ factory-boy = "*"
pytest = "*"
pytest-cov = "*"
pytest-django = "*"
pytest-httpx = "*"
pytest-env = "*"
pytest-sugar = "*"
pytest-xdist = "*"

1080
Pipfile.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -37,7 +37,7 @@ services:
- redisdata:/data
db:
image: docker.io/library/postgres:13
image: docker.io/library/postgres:15
restart: unless-stopped
volumes:
- pgdata:/var/lib/postgresql/data

View File

@@ -39,7 +39,7 @@ services:
- redisdata:/data
db:
image: docker.io/library/postgres:13
image: docker.io/library/postgres:15
restart: unless-stopped
volumes:
- pgdata:/var/lib/postgresql/data

View File

@@ -35,7 +35,7 @@ services:
- redisdata:/data
db:
image: docker.io/library/postgres:13
image: docker.io/library/postgres:15
restart: unless-stopped
volumes:
- pgdata:/var/lib/postgresql/data

View File

@@ -80,7 +80,7 @@ django_checks() {
search_index() {
local -r index_version=5
local -r index_version=6
local -r index_version_file=${DATA_DIR}/.index_version
if [[ (! -f "${index_version_file}") || $(<"${index_version_file}") != "$index_version" ]]; then

View File

@@ -15,6 +15,7 @@ stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
environment = HOME="/usr/src/paperless",USER="paperless"
[program:consumer]
command=python3 manage.py document_consumer
@@ -25,6 +26,7 @@ stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
environment = HOME="/usr/src/paperless",USER="paperless"
[program:celery]
@@ -37,6 +39,7 @@ stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
environment = HOME="/usr/src/paperless",USER="paperless"
[program:celery-beat]
@@ -48,6 +51,7 @@ stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
environment = HOME="/usr/src/paperless",USER="paperless"
[program:celery-flower]
command = /usr/local/bin/flower-conditional.sh
@@ -58,3 +62,4 @@ stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0
environment = HOME="/usr/src/paperless",USER="paperless"

View File

@@ -28,7 +28,7 @@ if __name__ == "__main__":
except Exception as e:
print(
f"Redis ping #{attempt} failed.\n"
f"Error: {str(e)}.\n"
f"Error: {e!s}.\n"
f"Waiting {RETRY_SLEEP_SECONDS}s",
flush=True,
)

View File

@@ -167,6 +167,16 @@ following:
This might not actually do anything. Not every new paperless version
comes with new database migrations.
### Database Upgrades
In general, paperless does not require a specific version of PostgreSQL or MariaDB and it is
safe to update them to newer versions. However, you should always take a backup and follow
the instructions from your database's documentation for how to upgrade between major versions.
For PostgreSQL, refer to [Upgrading a PostgreSQL Cluster](https://www.postgresql.org/docs/current/upgrading.html).
For MariaDB, refer to [Upgrading MariaDB](https://mariadb.com/kb/en/upgrading/)
## Downgrading Paperless {#downgrade-paperless}
Downgrades are possible. However, some updates also contain database

View File

@@ -288,10 +288,23 @@ with an optional `set_permissions` parameter which is of the form:
}
```
!!! note
Arrays should contain user or group ID numbers.
If this parameter is supplied the object's permissions will be overwritten,
assuming the authenticated user has permission to do so (the user must be
the object owner or a superuser).
### Retrieving full permissions
By default, the API will return a truncated version of object-level
permissions, returning `user_can_change` indicating whether the current user
can edit the object (either because they are the object owner or have permissions
granted). You can pass the parameter `full_perms=true` to API calls to view the
full permissions of objects in a format that mirrors the `set_permissions`
parameter above.
## API Versioning
The REST API is versioned since Paperless-ngx 1.3.0.

View File

@@ -1,5 +1,187 @@
# Changelog
## paperless-ngx 1.16.3
### Bug Fixes
- Fix: Set user and home environment through supervisord [@stumpylog](https://github.com/stumpylog) ([#3638](https://github.com/paperless-ngx/paperless-ngx/pull/3638))
- Fix: Ignore errors when trying to copy the original file's stats [@stumpylog](https://github.com/stumpylog) ([#3652](https://github.com/paperless-ngx/paperless-ngx/pull/3652))
- Fix: Copy default thumbnail if thumbnail generation fails [@plu](https://github.com/plu) ([#3632](https://github.com/paperless-ngx/paperless-ngx/pull/3632))
- Fix: Set user and home environment through supervisord [@stumpylog](https://github.com/stumpylog) ([#3638](https://github.com/paperless-ngx/paperless-ngx/pull/3638))
- Fix: Fix quick install with external database not being fully ready [@stumpylog](https://github.com/stumpylog) ([#3637](https://github.com/paperless-ngx/paperless-ngx/pull/3637))
### Maintenance
- Chore: Update default Postgres version for new installs [@stumpylog](https://github.com/stumpylog) ([#3640](https://github.com/paperless-ngx/paperless-ngx/pull/3640))
### All App Changes
<details>
<summary>2 changes</summary>
- Fix: Ignore errors when trying to copy the original file's stats [@stumpylog](https://github.com/stumpylog) ([#3652](https://github.com/paperless-ngx/paperless-ngx/pull/3652))
- Fix: Copy default thumbnail if thumbnail generation fails [@plu](https://github.com/plu) ([#3632](https://github.com/paperless-ngx/paperless-ngx/pull/3632))
</details>
## paperless-ngx 1.16.2
### Bug Fixes
- Fix: Increase httpx operation timeouts to 30s [@stumpylog](https://github.com/stumpylog) ([#3627](https://github.com/paperless-ngx/paperless-ngx/pull/3627))
- Fix: Better error handling and checking when parsing documents via Tika [@stumpylog](https://github.com/stumpylog) ([#3617](https://github.com/paperless-ngx/paperless-ngx/pull/3617))
### Development
- Development: frontend unit testing [@shamoon](https://github.com/shamoon) ([#3597](https://github.com/paperless-ngx/paperless-ngx/pull/3597))
### Maintenance
- Chore: Bumps the CI/Docker pipenv version [@stumpylog](https://github.com/stumpylog) ([#3622](https://github.com/paperless-ngx/paperless-ngx/pull/3622))
- Chore: Set CI artifact retention days [@stumpylog](https://github.com/stumpylog) ([#3621](https://github.com/paperless-ngx/paperless-ngx/pull/3621))
### All App Changes
<details>
<summary>3 changes</summary>
- Fix: Increase httpx operation timeouts to 30s [@stumpylog](https://github.com/stumpylog) ([#3627](https://github.com/paperless-ngx/paperless-ngx/pull/3627))
- Fix: Better error handling and checking when parsing documents via Tika [@stumpylog](https://github.com/stumpylog) ([#3617](https://github.com/paperless-ngx/paperless-ngx/pull/3617))
- Development: frontend unit testing [@shamoon](https://github.com/shamoon) ([#3597](https://github.com/paperless-ngx/paperless-ngx/pull/3597))
</details>
## paperless-ngx 1.16.1
### Bug Fixes
- Fix: PIL ImportError on ARM devices with Docker [@stumpylog](https://github.com/stumpylog) ([#3605](https://github.com/paperless-ngx/paperless-ngx/pull/3605))
### Maintenance
- Chore: Enable the image cleanup action [@stumpylog](https://github.com/stumpylog) ([#3606](https://github.com/paperless-ngx/paperless-ngx/pull/3606))
## paperless-ngx 1.16.0
### Notable Changes
- Chore: Update base image to Debian bookworm [@stumpylog](https://github.com/stumpylog) ([#3469](https://github.com/paperless-ngx/paperless-ngx/pull/3469))
### Features
- Feature: Update to a simpler Tika library [@stumpylog](https://github.com/stumpylog) ([#3517](https://github.com/paperless-ngx/paperless-ngx/pull/3517))
- Feature: Allow to filter documents by original filename and checksum [@jayme-github](https://github.com/jayme-github) ([#3485](https://github.com/paperless-ngx/paperless-ngx/pull/3485))
### Bug Fixes
- Fix: return user first / last name from backend [@shamoon](https://github.com/shamoon) ([#3579](https://github.com/paperless-ngx/paperless-ngx/pull/3579))
- Fix use of `PAPERLESS_DB_TIMEOUT` for all db types [@shamoon](https://github.com/shamoon) ([#3576](https://github.com/paperless-ngx/paperless-ngx/pull/3576))
- Fix: handle mail rules with no filters on some imap servers [@shamoon](https://github.com/shamoon) ([#3554](https://github.com/paperless-ngx/paperless-ngx/pull/3554))
### Dependencies
- Chore: Python dependency updates (celery 5.3.0 in particular) [@stumpylog](https://github.com/stumpylog) ([#3584](https://github.com/paperless-ngx/paperless-ngx/pull/3584))
### All App Changes
<details>
<summary>8 changes</summary>
- Chore: Python dependency updates (celery 5.3.0 in particular) [@stumpylog](https://github.com/stumpylog) ([#3584](https://github.com/paperless-ngx/paperless-ngx/pull/3584))
- Fix: return user first / last name from backend [@shamoon](https://github.com/shamoon) ([#3579](https://github.com/paperless-ngx/paperless-ngx/pull/3579))
- Fix use of `PAPERLESS_DB_TIMEOUT` for all db types [@shamoon](https://github.com/shamoon) ([#3576](https://github.com/paperless-ngx/paperless-ngx/pull/3576))
- Fix: handle mail rules with no filters on some imap servers [@shamoon](https://github.com/shamoon) ([#3554](https://github.com/paperless-ngx/paperless-ngx/pull/3554))
- Chore: Copy file stats from original file [@stumpylog](https://github.com/stumpylog) ([#3551](https://github.com/paperless-ngx/paperless-ngx/pull/3551))
- Chore: Adds test for barcode ASN when it already exists [@stumpylog](https://github.com/stumpylog) ([#3550](https://github.com/paperless-ngx/paperless-ngx/pull/3550))
- Feature: Update to a simpler Tika library [@stumpylog](https://github.com/stumpylog) ([#3517](https://github.com/paperless-ngx/paperless-ngx/pull/3517))
- Feature: Allow to filter documents by original filename and checksum [@jayme-github](https://github.com/jayme-github) ([#3485](https://github.com/paperless-ngx/paperless-ngx/pull/3485))
</details>
## paperless-ngx 1.15.1
### Bug Fixes
- Fix incorrect colors in v1.15.0 [@shamoon](https://github.com/shamoon) ([#3523](https://github.com/paperless-ngx/paperless-ngx/pull/3523))
### All App Changes
- Fix incorrect colors in v1.15.0 [@shamoon](https://github.com/shamoon) ([#3523](https://github.com/paperless-ngx/paperless-ngx/pull/3523))
## paperless-ngx 1.15.0
### Features
- Feature: quick filters from document detail [@shamoon](https://github.com/shamoon) ([#3476](https://github.com/paperless-ngx/paperless-ngx/pull/3476))
- Feature: Add explanations to relative dates [@shamoon](https://github.com/shamoon) ([#3471](https://github.com/paperless-ngx/paperless-ngx/pull/3471))
- Enhancement: paginate frontend tasks [@shamoon](https://github.com/shamoon) ([#3445](https://github.com/paperless-ngx/paperless-ngx/pull/3445))
- Feature: Better encapsulation of barcode logic [@stumpylog](https://github.com/stumpylog) ([#3425](https://github.com/paperless-ngx/paperless-ngx/pull/3425))
- Enhancement: Improve frontend error handling [@shamoon](https://github.com/shamoon) ([#3413](https://github.com/paperless-ngx/paperless-ngx/pull/3413))
### Bug Fixes
- Fix: KeyError error on unauthenticated API calls \& persist authentication when enabled [@ajgon](https://github.com/ajgon) ([#3516](https://github.com/paperless-ngx/paperless-ngx/pull/3516))
- Fix: exclude consumer \& AnonymousUser users from export manifest [@shamoon](https://github.com/shamoon) ([#3487](https://github.com/paperless-ngx/paperless-ngx/pull/3487))
- Fix: prevent date suggestion search if disabled [@shamoon](https://github.com/shamoon) ([#3472](https://github.com/paperless-ngx/paperless-ngx/pull/3472))
- Sync Pipfile.lock based on latest Pipfile [@adamantike](https://github.com/adamantike) ([#3475](https://github.com/paperless-ngx/paperless-ngx/pull/3475))
- Fix: DocumentSerializer should return correct original filename [@jayme-github](https://github.com/jayme-github) ([#3473](https://github.com/paperless-ngx/paperless-ngx/pull/3473))
- consumer.py: read from original file (instead of temp copy) [@chrisblech](https://github.com/chrisblech) ([#3466](https://github.com/paperless-ngx/paperless-ngx/pull/3466))
- Bugfix: Catch an nltk AttributeError and handle it [@stumpylog](https://github.com/stumpylog) ([#3453](https://github.com/paperless-ngx/paperless-ngx/pull/3453))
### Documentation
- Adding doc on how to setup Fail2ban [@GuillaumeHullin](https://github.com/GuillaumeHullin) ([#3414](https://github.com/paperless-ngx/paperless-ngx/pull/3414))
- Docs: Fix typo [@MarcelBochtler](https://github.com/MarcelBochtler) ([#3437](https://github.com/paperless-ngx/paperless-ngx/pull/3437))
- [Documentation] Move nginx [@shamoon](https://github.com/shamoon) ([#3420](https://github.com/paperless-ngx/paperless-ngx/pull/3420))
- Documentation: Note possible dependency removal for bare metal [@stumpylog](https://github.com/stumpylog) ([#3408](https://github.com/paperless-ngx/paperless-ngx/pull/3408))
### Development
- Development: migrate frontend tests to playwright [@shamoon](https://github.com/shamoon) ([#3401](https://github.com/paperless-ngx/paperless-ngx/pull/3401))
### Dependencies
<details>
<summary>10 changes</summary>
- Bump eslint from 8.39.0 to 8.41.0 in /src-ui [@dependabot](https://github.com/dependabot) ([#3513](https://github.com/paperless-ngx/paperless-ngx/pull/3513))
- Bump concurrently from 8.0.1 to 8.1.0 in /src-ui [@dependabot](https://github.com/dependabot) ([#3510](https://github.com/paperless-ngx/paperless-ngx/pull/3510))
- Bump [@<!---->ng-bootstrap/ng-bootstrap from 14.1.0 to 14.2.0 in /src-ui @dependabot](https://github.com/<!---->ng-bootstrap/ng-bootstrap from 14.1.0 to 14.2.0 in /src-ui @dependabot) ([#3507](https://github.com/paperless-ngx/paperless-ngx/pull/3507))
- Bump [@<!---->popperjs/core from 2.11.7 to 2.11.8 in /src-ui @dependabot](https://github.com/<!---->popperjs/core from 2.11.7 to 2.11.8 in /src-ui @dependabot) ([#3508](https://github.com/paperless-ngx/paperless-ngx/pull/3508))
- Bump [@<!---->typescript-eslint/parser from 5.59.2 to 5.59.8 in /src-ui @dependabot](https://github.com/<!---->typescript-eslint/parser from 5.59.2 to 5.59.8 in /src-ui @dependabot) ([#3505](https://github.com/paperless-ngx/paperless-ngx/pull/3505))
- Bump bootstrap from 5.2.3 to 5.3.0 in /src-ui [@dependabot](https://github.com/dependabot) ([#3497](https://github.com/paperless-ngx/paperless-ngx/pull/3497))
- Bump [@<!---->typescript-eslint/eslint-plugin from 5.59.2 to 5.59.8 in /src-ui @dependabot](https://github.com/<!---->typescript-eslint/eslint-plugin from 5.59.2 to 5.59.8 in /src-ui @dependabot) ([#3500](https://github.com/paperless-ngx/paperless-ngx/pull/3500))
- Bump tslib from 2.5.0 to 2.5.2 in /src-ui [@dependabot](https://github.com/dependabot) ([#3501](https://github.com/paperless-ngx/paperless-ngx/pull/3501))
- Bump [@<!---->types/node from 18.16.3 to 20.2.5 in /src-ui @dependabot](https://github.com/<!---->types/node from 18.16.3 to 20.2.5 in /src-ui @dependabot) ([#3498](https://github.com/paperless-ngx/paperless-ngx/pull/3498))
- Bump [@<!---->playwright/test from 1.33.0 to 1.34.3 in /src-ui @dependabot](https://github.com/<!---->playwright/test from 1.33.0 to 1.34.3 in /src-ui @dependabot) ([#3499](https://github.com/paperless-ngx/paperless-ngx/pull/3499))
</details>
### All App Changes
<details>
<summary>22 changes</summary>
- Fix: KeyError error on unauthenticated API calls \& persist authentication when enabled [@ajgon](https://github.com/ajgon) ([#3516](https://github.com/paperless-ngx/paperless-ngx/pull/3516))
- Bump eslint from 8.39.0 to 8.41.0 in /src-ui [@dependabot](https://github.com/dependabot) ([#3513](https://github.com/paperless-ngx/paperless-ngx/pull/3513))
- Bump concurrently from 8.0.1 to 8.1.0 in /src-ui [@dependabot](https://github.com/dependabot) ([#3510](https://github.com/paperless-ngx/paperless-ngx/pull/3510))
- Bump [@<!---->ng-bootstrap/ng-bootstrap from 14.1.0 to 14.2.0 in /src-ui @dependabot](https://github.com/<!---->ng-bootstrap/ng-bootstrap from 14.1.0 to 14.2.0 in /src-ui @dependabot) ([#3507](https://github.com/paperless-ngx/paperless-ngx/pull/3507))
- Bump [@<!---->popperjs/core from 2.11.7 to 2.11.8 in /src-ui @dependabot](https://github.com/<!---->popperjs/core from 2.11.7 to 2.11.8 in /src-ui @dependabot) ([#3508](https://github.com/paperless-ngx/paperless-ngx/pull/3508))
- Bump [@<!---->typescript-eslint/parser from 5.59.2 to 5.59.8 in /src-ui @dependabot](https://github.com/<!---->typescript-eslint/parser from 5.59.2 to 5.59.8 in /src-ui @dependabot) ([#3505](https://github.com/paperless-ngx/paperless-ngx/pull/3505))
- Bump bootstrap from 5.2.3 to 5.3.0 in /src-ui [@dependabot](https://github.com/dependabot) ([#3497](https://github.com/paperless-ngx/paperless-ngx/pull/3497))
- Bump [@<!---->typescript-eslint/eslint-plugin from 5.59.2 to 5.59.8 in /src-ui @dependabot](https://github.com/<!---->typescript-eslint/eslint-plugin from 5.59.2 to 5.59.8 in /src-ui @dependabot) ([#3500](https://github.com/paperless-ngx/paperless-ngx/pull/3500))
- Bump tslib from 2.5.0 to 2.5.2 in /src-ui [@dependabot](https://github.com/dependabot) ([#3501](https://github.com/paperless-ngx/paperless-ngx/pull/3501))
- Bump [@<!---->types/node from 18.16.3 to 20.2.5 in /src-ui @dependabot](https://github.com/<!---->types/node from 18.16.3 to 20.2.5 in /src-ui @dependabot) ([#3498](https://github.com/paperless-ngx/paperless-ngx/pull/3498))
- Bump [@<!---->playwright/test from 1.33.0 to 1.34.3 in /src-ui @dependabot](https://github.com/<!---->playwright/test from 1.33.0 to 1.34.3 in /src-ui @dependabot) ([#3499](https://github.com/paperless-ngx/paperless-ngx/pull/3499))
- Feature: quick filters from document detail [@shamoon](https://github.com/shamoon) ([#3476](https://github.com/paperless-ngx/paperless-ngx/pull/3476))
- Fix: exclude consumer \& AnonymousUser users from export manifest [@shamoon](https://github.com/shamoon) ([#3487](https://github.com/paperless-ngx/paperless-ngx/pull/3487))
- Fix: prevent date suggestion search if disabled [@shamoon](https://github.com/shamoon) ([#3472](https://github.com/paperless-ngx/paperless-ngx/pull/3472))
- Feature: Add explanations to relative dates [@shamoon](https://github.com/shamoon) ([#3471](https://github.com/paperless-ngx/paperless-ngx/pull/3471))
- Fix: DocumentSerializer should return correct original filename [@jayme-github](https://github.com/jayme-github) ([#3473](https://github.com/paperless-ngx/paperless-ngx/pull/3473))
- consumer.py: read from original file (instead of temp copy) [@chrisblech](https://github.com/chrisblech) ([#3466](https://github.com/paperless-ngx/paperless-ngx/pull/3466))
- Bugfix: Catch an nltk AttributeError and handle it [@stumpylog](https://github.com/stumpylog) ([#3453](https://github.com/paperless-ngx/paperless-ngx/pull/3453))
- Chore: Improves the logging mixin and allows it to be typed better [@stumpylog](https://github.com/stumpylog) ([#3451](https://github.com/paperless-ngx/paperless-ngx/pull/3451))
- Enhancement: paginate frontend tasks [@shamoon](https://github.com/shamoon) ([#3445](https://github.com/paperless-ngx/paperless-ngx/pull/3445))
- Add SSL Support for MariaDB [@kimdre](https://github.com/kimdre) ([#3444](https://github.com/paperless-ngx/paperless-ngx/pull/3444))
- Enhancement: Improve frontend error handling [@shamoon](https://github.com/shamoon) ([#3413](https://github.com/paperless-ngx/paperless-ngx/pull/3413))
</details>
## paperless-ngx 1.14.5
### Features

View File

@@ -136,11 +136,11 @@ changed here.
Defaults to unset, using the documented path in the home directory.
`PAPERLESS_DB_TIMEOUT=<float>`
`PAPERLESS_DB_TIMEOUT=<int>`
: Amount of time for a database connection to wait for the database to
unlock. Mostly applicable for an sqlite based installation, consider
changing to postgresql if you need to increase this.
unlock. Mostly applicable for sqlite based installation. Consider changing
to postgresql if you are having concurrency problems with sqlite.
Defaults to unset, keeping the Django defaults.

View File

@@ -216,19 +216,18 @@ The front end is built using AngularJS. In order to get started, you need Node.j
$ git ls-files -- '*.ts' | xargs pre-commit run prettier --files
```
- Front end testing uses jest and cypress. There is currently a need
for significantly more front end tests. Unit tests and e2e tests,
- Front end testing uses Jest and Playwright. Unit tests and e2e tests,
respectively, can be run non-interactively with:
```bash
$ ng test
$ npm run e2e:ci
$ npx playwright test
```
- Cypress also includes a UI which can be run with:
- Playwright also includes a UI which can be run with:
```bash
$ ./node_modules/.bin/cypress open
$ npx playwright test --ui
```
- In order to build the front end and serve it as part of Django, execute:

View File

@@ -27,6 +27,12 @@ system. On Linux, chances are high that this location is
files around manually. This folder is meant to be entirely managed by
docker and paperless.
!!! note
Files consumed from the consumption directory are re-created inside
this media directory and are removed from the consumption directory
itself.
## Let's say I want to switch tools in a year. Can I easily move to other systems?
**A:** Your documents are stored as plain files inside the media folder.

View File

@@ -69,7 +69,9 @@ following operations on your documents:
No matter which options you choose, Paperless will always store the
original document that it found in the consumption directory or in the
mail and will never overwrite that document. Archived versions are
stored alongside the original versions.
stored alongside the original versions. Any files found in the
consumption directory will stored inside the Paperless-ngx file
structure and will not be retained in the consumption directory.
### The consumption directory
@@ -77,7 +79,9 @@ The primary method of getting documents into your database is by putting
them in the consumption directory. The consumer waits patiently, looking
for new additions to this directory. When it finds them,
the consumer goes about the process of parsing them with the OCR,
indexing what it finds, and storing it in the media directory.
indexing what it finds, and storing it in the media directory. You should
think of this folder as a temporary location, as files will be re-created
inside Paperless-ngx and removed from the consumption folder.
Getting stuff into this directory is up to you. If you're running
Paperless on your local computer, you might just want to drag and drop
@@ -88,6 +92,15 @@ Typically, you're looking at an FTP server like
[Proftpd](http://www.proftpd.org/) or a Windows folder share with
[Samba](https://www.samba.org/).
!!! warning
Files found in the consumption directory that are consumed will be
removed from the consumption directory and stored inside the
Paperless-ngx file structure using any settings / storage paths
you have specified. This action is performed as safely as possible
but this means it is expected that files in the consumption
directory will no longer exist (there) after being consumed.
### Web UI Upload
The dashboard has a file drop field to upload documents to paperless.

View File

@@ -384,6 +384,14 @@ fi
${DOCKER_COMPOSE_CMD} pull
if [ "$DATABASE_BACKEND" == "postgres" ] || [ "$DATABASE_BACKEND" == "mariadb" ] ; then
echo "Starting DB first for initilzation"
${DOCKER_COMPOSE_CMD} up --detach db
# hopefully enough time for even the slower systems
sleep 15
${DOCKER_COMPOSE_CMD} stop
fi
${DOCKER_COMPOSE_CMD} run --rm -e DJANGO_SUPERUSER_PASSWORD="$PASSWORD" webserver createsuperuser --noinput --username "$USERNAME" --email "$EMAIL"
${DOCKER_COMPOSE_CMD} up --detach

View File

@@ -1,6 +1,6 @@
#!/usr/bin/env bash
docker run -p 5432:5432 -e POSTGRES_PASSWORD=password -v paperless_pgdata:/var/lib/postgresql/data -d postgres:13
docker run -p 5432:5432 -e POSTGRES_PASSWORD=password -v paperless_pgdata:/var/lib/postgresql/data -d postgres:15
docker run -d -p 6379:6379 redis:latest
docker run -p 3000:3000 -d gotenberg/gotenberg:7.8 gotenberg --chromium-disable-javascript=true --chromium-allow-list="file:///tmp/.*"
docker run -p 9998:9998 -d ghcr.io/paperless-ngx/tika:latest

View File

@@ -12,9 +12,13 @@ test('should activate / deactivate save button when changes are saved', async ({
await expect(page.getByTitle('Storage path', { exact: true })).toHaveText(
/\w+/
)
await expect(page.getByRole('button', { name: 'Save' })).toBeDisabled()
await expect(
page.getByRole('button', { name: 'Save', exact: true })
).toBeDisabled()
await page.getByTitle('Storage path').getByTitle('Clear all').click()
await expect(page.getByRole('button', { name: 'Save' })).toBeEnabled()
await expect(
page.getByRole('button', { name: 'Save', exact: true })
).toBeEnabled()
})
test('should warn on unsaved changes', async ({ page }) => {
@@ -23,13 +27,17 @@ test('should warn on unsaved changes', async ({ page }) => {
await expect(page.getByTitle('Correspondent', { exact: true })).toHaveText(
/\w+/
)
await expect(page.getByRole('button', { name: 'Save' })).toBeDisabled()
await expect(
page.getByRole('button', { name: 'Save', exact: true })
).toBeDisabled()
await page
.getByTitle('Storage path', { exact: true })
.getByTitle('Clear all')
.click()
await expect(page.getByRole('button', { name: 'Save' })).toBeEnabled()
await page.getByRole('button', { name: 'Close' }).click()
await expect(
page.getByRole('button', { name: 'Save', exact: true })
).toBeEnabled()
await page.getByRole('button', { name: 'Close', exact: true }).click()
await expect(page.getByRole('dialog')).toHaveText(/unsaved changes/)
await page.getByRole('button', { name: 'Cancel' }).click()
await page.getByRole('link', { name: 'Close all' }).click()

View File

@@ -1,8 +1,14 @@
module.exports = {
moduleNameMapper: {
'@core/(.*)': '<rootDir>/src/app/core/$1',
},
preset: 'jest-preset-angular',
setupFilesAfterEnv: ['<rootDir>/setup-jest.ts'],
testPathIgnorePatterns: ['/node_modules/', '/cypress/'],
testPathIgnorePatterns: [
'/node_modules/',
'/e2e/',
'abstract-name-filter-service',
'abstract-paperless-service',
],
transformIgnorePatterns: [`<rootDir>/node_modules/(?!.*\\.mjs$|lodash-es)`],
moduleNameMapper: {
'^src/(.*)': '<rootDir>/src/$1',
},
}

File diff suppressed because it is too large Load Diff

View File

@@ -43,7 +43,7 @@
"@angular-eslint/template-parser": "15.2.1",
"@angular/cli": "~15.2.7",
"@angular/compiler-cli": "~15.2.8",
"@playwright/test": "^1.34.3",
"@playwright/test": "^1.35.1",
"@types/jest": "^29.5.0",
"@types/node": "^20.2.5",
"@typescript-eslint/eslint-plugin": "^5.59.8",
@@ -53,6 +53,7 @@
"jest": "28.1.3",
"jest-environment-jsdom": "^29.5.0",
"jest-preset-angular": "^12.2.6",
"jest-websocket-mock": "^2.4.0",
"ts-node": "~10.9.1",
"typescript": "~4.9.5",
"wait-on": "^7.0.1"
@@ -4228,19 +4229,19 @@
}
},
"node_modules/@playwright/test": {
"version": "1.34.3",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.34.3.tgz",
"integrity": "sha512-zPLef6w9P6T/iT6XDYG3mvGOqOyb6eHaV9XtkunYs0+OzxBtrPAAaHotc0X+PJ00WPPnLfFBTl7mf45Mn8DBmw==",
"version": "1.35.1",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.35.1.tgz",
"integrity": "sha512-b5YoFe6J9exsMYg0pQAobNDR85T1nLumUYgUTtKm4d21iX2L7WqKq9dW8NGJ+2vX0etZd+Y7UeuqsxDXm9+5ZA==",
"dev": true,
"dependencies": {
"@types/node": "*",
"playwright-core": "1.34.3"
"playwright-core": "1.35.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=14"
"node": ">=16"
},
"optionalDependencies": {
"fsevents": "2.3.2"
@@ -12307,6 +12308,16 @@
"node": ">=8"
}
},
"node_modules/jest-websocket-mock": {
"version": "2.4.0",
"resolved": "https://registry.npmjs.org/jest-websocket-mock/-/jest-websocket-mock-2.4.0.tgz",
"integrity": "sha512-AOwyuRw6fgROXHxMOiTDl1/T4dh3fV4jDquha5N0csS/PNp742HeTZWPAuKppVRSQ8s3fUGgJHoyZT9JDO0hMA==",
"dev": true,
"dependencies": {
"jest-diff": "^28.0.2",
"mock-socket": "^9.1.0"
}
},
"node_modules/jest-worker": {
"version": "28.1.3",
"resolved": "https://registry.npmjs.org/jest-worker/-/jest-worker-28.1.3.tgz",
@@ -13454,6 +13465,15 @@
"node": ">=10"
}
},
"node_modules/mock-socket": {
"version": "9.2.1",
"resolved": "https://registry.npmjs.org/mock-socket/-/mock-socket-9.2.1.tgz",
"integrity": "sha512-aw9F9T9G2zpGipLLhSNh6ZpgUyUl4frcVmRN08uE1NWPWg43Wx6+sGPDbQ7E5iFZZDJW5b5bypMeAEHqTbIFag==",
"dev": true,
"engines": {
"node": ">= 8"
}
},
"node_modules/ms": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
@@ -14595,15 +14615,15 @@
}
},
"node_modules/playwright-core": {
"version": "1.34.3",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.34.3.tgz",
"integrity": "sha512-2pWd6G7OHKemc5x1r1rp8aQcpvDh7goMBZlJv6Co5vCNLVcQJdhxRL09SGaY6HcyHH9aT4tiynZabMofVasBYw==",
"version": "1.35.1",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.35.1.tgz",
"integrity": "sha512-pNXb6CQ7OqmGDRspEjlxE49w+4YtR6a3X6mT1hZXeJHWmsEz7SunmvZeiG/+y1yyMZdHnnn73WKYdtV1er0Xyg==",
"dev": true,
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=14"
"node": ">=16"
}
},
"node_modules/postcss": {

View File

@@ -5,7 +5,7 @@
"ng": "ng",
"start": "ng serve",
"build": "ng build",
"test": "ng test",
"test": "ng test --no-watch --coverage",
"lint": "ng lint"
},
"private": true,
@@ -45,7 +45,7 @@
"@angular-eslint/template-parser": "15.2.1",
"@angular/cli": "~15.2.7",
"@angular/compiler-cli": "~15.2.8",
"@playwright/test": "^1.34.3",
"@playwright/test": "^1.35.1",
"@types/jest": "^29.5.0",
"@types/node": "^20.2.5",
"@typescript-eslint/eslint-plugin": "^5.59.8",
@@ -55,6 +55,7 @@
"jest": "28.1.3",
"jest-environment-jsdom": "^29.5.0",
"jest-preset-angular": "^12.2.6",
"jest-websocket-mock": "^2.4.0",
"ts-node": "~10.9.1",
"typescript": "~4.9.5",
"wait-on": "^7.0.1"

View File

@@ -1,4 +1,59 @@
import { jest } from '@jest/globals'
if (process.env.NODE_ENV === 'test') {
require('jest-preset-angular/setup-jest')
}
import '@angular/localize/init'
import { TextEncoder, TextDecoder } from 'util'
global.TextEncoder = TextEncoder
global.TextDecoder = TextDecoder
import { registerLocaleData } from '@angular/common'
import localeAr from '@angular/common/locales/ar'
import localeBe from '@angular/common/locales/be'
import localeCa from '@angular/common/locales/ca'
import localeCs from '@angular/common/locales/cs'
import localeDa from '@angular/common/locales/da'
import localeDe from '@angular/common/locales/de'
import localeEnGb from '@angular/common/locales/en-GB'
import localeEs from '@angular/common/locales/es'
import localeFi from '@angular/common/locales/fi'
import localeFr from '@angular/common/locales/fr'
import localeIt from '@angular/common/locales/it'
import localeLb from '@angular/common/locales/lb'
import localeNl from '@angular/common/locales/nl'
import localePl from '@angular/common/locales/pl'
import localePt from '@angular/common/locales/pt'
import localeRo from '@angular/common/locales/ro'
import localeRu from '@angular/common/locales/ru'
import localeSl from '@angular/common/locales/sl'
import localeSr from '@angular/common/locales/sr'
import localeSv from '@angular/common/locales/sv'
import localeTr from '@angular/common/locales/tr'
import localeZh from '@angular/common/locales/zh'
registerLocaleData(localeAr)
registerLocaleData(localeBe)
registerLocaleData(localeCa)
registerLocaleData(localeCs)
registerLocaleData(localeDa)
registerLocaleData(localeDe)
registerLocaleData(localeEnGb)
registerLocaleData(localeEs)
registerLocaleData(localeFi)
registerLocaleData(localeFr)
registerLocaleData(localeIt)
registerLocaleData(localeLb)
registerLocaleData(localeNl)
registerLocaleData(localePl)
registerLocaleData(localePt, 'pt-BR')
registerLocaleData(localePt, 'pt-PT')
registerLocaleData(localeRo)
registerLocaleData(localeRu)
registerLocaleData(localeSl)
registerLocaleData(localeSr)
registerLocaleData(localeSv)
registerLocaleData(localeTr)
registerLocaleData(localeZh)
/* global mocks for jsdom */
const mock = () => {
@@ -17,6 +72,8 @@ Object.defineProperty(window, 'getComputedStyle', {
value: () => ['-webkit-appearance'],
})
Object.defineProperty(window, 'ResizeObserver', { value: mock() })
Object.defineProperty(document.body.style, 'transform', {
value: () => {
return {

View File

@@ -22,7 +22,7 @@ import {
PermissionType,
} from './services/permissions.service'
const routes: Routes = [
export const routes: Routes = [
{ path: '', redirectTo: 'dashboard', pathMatch: 'full' },
{
path: '',

View File

@@ -0,0 +1,182 @@
import { HttpClientTestingModule } from '@angular/common/http/testing'
import {
ComponentFixture,
TestBed,
discardPeriodicTasks,
fakeAsync,
tick,
} from '@angular/core/testing'
import { By } from '@angular/platform-browser'
import { Router } from '@angular/router'
import { RouterTestingModule } from '@angular/router/testing'
import { NgxFileDropModule } from 'ngx-file-drop'
import { TourService, TourNgBootstrapModule } from 'ngx-ui-tour-ng-bootstrap'
import { Subject } from 'rxjs'
import { routes } from './app-routing.module'
import { AppComponent } from './app.component'
import { ToastsComponent } from './components/common/toasts/toasts.component'
import {
ConsumerStatusService,
FileStatus,
} from './services/consumer-status.service'
import { PermissionsService } from './services/permissions.service'
import { ToastService, Toast } from './services/toast.service'
import { UploadDocumentsService } from './services/upload-documents.service'
import { SettingsService } from './services/settings.service'
describe('AppComponent', () => {
let component: AppComponent
let fixture: ComponentFixture<AppComponent>
let tourService: TourService
let consumerStatusService: ConsumerStatusService
let permissionsService: PermissionsService
let toastService: ToastService
let router: Router
let settingsService: SettingsService
let uploadDocumentsService: UploadDocumentsService
beforeEach(async () => {
TestBed.configureTestingModule({
declarations: [AppComponent, ToastsComponent],
providers: [],
imports: [
HttpClientTestingModule,
TourNgBootstrapModule,
RouterTestingModule.withRoutes(routes),
NgxFileDropModule,
],
}).compileComponents()
tourService = TestBed.inject(TourService)
consumerStatusService = TestBed.inject(ConsumerStatusService)
permissionsService = TestBed.inject(PermissionsService)
settingsService = TestBed.inject(SettingsService)
toastService = TestBed.inject(ToastService)
router = TestBed.inject(Router)
uploadDocumentsService = TestBed.inject(UploadDocumentsService)
fixture = TestBed.createComponent(AppComponent)
component = fixture.componentInstance
})
it('should initialize the tour service & toggle class on body for styling', fakeAsync(() => {
jest.spyOn(console, 'warn').mockImplementation(() => {})
fixture.detectChanges()
const tourSpy = jest.spyOn(tourService, 'initialize')
component.ngOnInit()
expect(tourSpy).toHaveBeenCalled()
tourService.start()
expect(document.body.classList).toContain('tour-active')
tourService.end()
tick(500)
expect(document.body.classList).not.toContain('tour-active')
}))
it('should display toast on document consumed with link if user has access', () => {
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
let toast: Toast
toastService.getToasts().subscribe((toasts) => (toast = toasts[0]))
const toastSpy = jest.spyOn(toastService, 'show')
const fileStatusSubject = new Subject<FileStatus>()
jest
.spyOn(consumerStatusService, 'onDocumentConsumptionFinished')
.mockReturnValue(fileStatusSubject)
component.ngOnInit()
fileStatusSubject.next(new FileStatus())
expect(toastSpy).toHaveBeenCalled()
expect(toast.action).not.toBeUndefined()
})
it('should display toast on document consumed without link if user does not have access', () => {
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(false)
let toast: Toast
toastService.getToasts().subscribe((toasts) => (toast = toasts[0]))
const toastSpy = jest.spyOn(toastService, 'show')
const fileStatusSubject = new Subject<FileStatus>()
jest
.spyOn(consumerStatusService, 'onDocumentConsumptionFinished')
.mockReturnValue(fileStatusSubject)
component.ngOnInit()
fileStatusSubject.next(new FileStatus())
expect(toastSpy).toHaveBeenCalled()
expect(toast.action).toBeUndefined()
})
it('should display toast on document added', () => {
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
const toastSpy = jest.spyOn(toastService, 'show')
const fileStatusSubject = new Subject<FileStatus>()
jest
.spyOn(consumerStatusService, 'onDocumentDetected')
.mockReturnValue(fileStatusSubject)
component.ngOnInit()
fileStatusSubject.next(new FileStatus())
expect(toastSpy).toHaveBeenCalled()
})
it('should suppress dashboard notifications if set', () => {
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
jest.spyOn(settingsService, 'get').mockReturnValue(true)
jest.spyOn(router, 'url', 'get').mockReturnValue('/dashboard')
const toastSpy = jest.spyOn(toastService, 'show')
const fileStatusSubject = new Subject<FileStatus>()
jest
.spyOn(consumerStatusService, 'onDocumentDetected')
.mockReturnValue(fileStatusSubject)
component.ngOnInit()
fileStatusSubject.next(new FileStatus())
expect(toastSpy).not.toHaveBeenCalled()
})
it('should display toast on document failed', () => {
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
const toastSpy = jest.spyOn(toastService, 'showError')
const fileStatusSubject = new Subject<FileStatus>()
jest
.spyOn(consumerStatusService, 'onDocumentConsumptionFailed')
.mockReturnValue(fileStatusSubject)
component.ngOnInit()
fileStatusSubject.next(new FileStatus())
expect(toastSpy).toHaveBeenCalled()
})
it('should disable drag-drop if on dashboard', () => {
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
jest.spyOn(router, 'url', 'get').mockReturnValueOnce('/dashboard')
expect(component.dragDropEnabled).toBeFalsy()
jest.spyOn(router, 'url', 'get').mockReturnValueOnce('/documents')
expect(component.dragDropEnabled).toBeTruthy()
})
it('should enable drag-drop if user has permissions', () => {
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
expect(component.dragDropEnabled).toBeTruthy()
})
it('should disable drag-drop if user does not have permissions', () => {
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(false)
expect(component.dragDropEnabled).toBeFalsy()
})
it('should support drag drop', fakeAsync(() => {
expect(component.fileIsOver).toBeFalsy()
component.fileOver()
tick(1)
fixture.detectChanges()
expect(component.fileIsOver).toBeTruthy()
const dropzone = fixture.debugElement.query(
By.css('.global-dropzone-overlay')
)
expect(dropzone).not.toBeNull()
component.fileLeave()
tick(700)
fixture.detectChanges()
expect(dropzone.classes['hide']).toBeTruthy()
// drop
const toastSpy = jest.spyOn(toastService, 'show')
const uploadSpy = jest.spyOn(uploadDocumentsService, 'uploadFiles')
component.dropped([])
tick(3000)
expect(toastSpy).toHaveBeenCalled()
expect(uploadSpy).toHaveBeenCalled()
}))
})

View File

@@ -137,6 +137,7 @@ main {
.sidebar .nav-link {
font-weight: 500;
white-space: nowrap;
&:hover, &.active, &:focus {
color: var(--bs-primary);

View File

@@ -0,0 +1,272 @@
import {
HttpClientTestingModule,
HttpTestingController,
} from '@angular/common/http/testing'
import { AppFrameComponent } from './app-frame.component'
import {
ComponentFixture,
TestBed,
fakeAsync,
tick,
} from '@angular/core/testing'
import { NgbModule } from '@ng-bootstrap/ng-bootstrap'
import { BrowserModule } from '@angular/platform-browser'
import { RouterTestingModule } from '@angular/router/testing'
import { SettingsService } from 'src/app/services/settings.service'
import { SavedViewService } from 'src/app/services/rest/saved-view.service'
import { PermissionsService } from 'src/app/services/permissions.service'
import { SETTINGS_KEYS } from 'src/app/data/paperless-uisettings'
import { RemoteVersionService } from 'src/app/services/rest/remote-version.service'
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { of } from 'rxjs'
import { ToastService } from 'src/app/services/toast.service'
import { environment } from 'src/environments/environment'
import { OpenDocumentsService } from 'src/app/services/open-documents.service'
import { ActivatedRoute, Router } from '@angular/router'
import { DocumentDetailComponent } from '../document-detail/document-detail.component'
import { SearchService } from 'src/app/services/rest/search.service'
import { DocumentListViewService } from 'src/app/services/document-list-view.service'
import { FILTER_FULLTEXT_QUERY } from 'src/app/data/filter-rule-type'
import { routes } from 'src/app/app-routing.module'
import { PermissionsGuard } from 'src/app/guards/permissions.guard'
const document = { id: 2, title: 'Hello world' }
describe('AppFrameComponent', () => {
let component: AppFrameComponent
let fixture: ComponentFixture<AppFrameComponent>
let httpTestingController: HttpTestingController
let settingsService: SettingsService
let permissionsService: PermissionsService
let remoteVersionService: RemoteVersionService
let toastService: ToastService
let openDocumentsService: OpenDocumentsService
let searchService: SearchService
let documentListViewService: DocumentListViewService
let router: Router
let savedViewSpy
beforeEach(async () => {
TestBed.configureTestingModule({
declarations: [AppFrameComponent, IfPermissionsDirective],
imports: [
HttpClientTestingModule,
BrowserModule,
RouterTestingModule.withRoutes(routes),
NgbModule,
FormsModule,
ReactiveFormsModule,
],
providers: [
SettingsService,
SavedViewService,
PermissionsService,
RemoteVersionService,
IfPermissionsDirective,
ToastService,
OpenDocumentsService,
SearchService,
{
provide: ActivatedRoute,
useValue: {
firstChild: {
component: DocumentDetailComponent,
},
snapshot: {
firstChild: {
component: DocumentDetailComponent,
params: {
id: document.id,
},
},
},
},
},
PermissionsGuard,
],
}).compileComponents()
settingsService = TestBed.inject(SettingsService)
const savedViewService = TestBed.inject(SavedViewService)
permissionsService = TestBed.inject(PermissionsService)
remoteVersionService = TestBed.inject(RemoteVersionService)
toastService = TestBed.inject(ToastService)
openDocumentsService = TestBed.inject(OpenDocumentsService)
searchService = TestBed.inject(SearchService)
documentListViewService = TestBed.inject(DocumentListViewService)
router = TestBed.inject(Router)
jest
.spyOn(settingsService, 'displayName', 'get')
.mockReturnValue('Hello World')
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
savedViewSpy = jest.spyOn(savedViewService, 'initialize')
fixture = TestBed.createComponent(AppFrameComponent)
component = fixture.componentInstance
httpTestingController = TestBed.inject(HttpTestingController)
fixture.detectChanges()
})
it('should initialize the saved view service', () => {
expect(savedViewSpy).toHaveBeenCalled()
})
it('should check for update if enabled', () => {
const updateCheckSpy = jest.spyOn(remoteVersionService, 'checkForUpdates')
updateCheckSpy.mockImplementation(() => {
return of({
version: 'v100.0',
update_available: true,
})
})
settingsService.set(SETTINGS_KEYS.UPDATE_CHECKING_ENABLED, true)
component.ngOnInit()
expect(updateCheckSpy).toHaveBeenCalled()
fixture.detectChanges()
expect(fixture.nativeElement.textContent).toContain('Update available')
})
it('should check not for update if disabled', () => {
const updateCheckSpy = jest.spyOn(remoteVersionService, 'checkForUpdates')
settingsService.set(SETTINGS_KEYS.UPDATE_CHECKING_ENABLED, false)
component.ngOnInit()
fixture.detectChanges()
expect(updateCheckSpy).not.toHaveBeenCalled()
expect(fixture.nativeElement.textContent).not.toContain('Update available')
})
it('should check for update if was disabled and then enabled', () => {
const updateCheckSpy = jest.spyOn(remoteVersionService, 'checkForUpdates')
settingsService.set(SETTINGS_KEYS.UPDATE_CHECKING_ENABLED, false)
component.setUpdateChecking(true)
fixture.detectChanges()
expect(updateCheckSpy).toHaveBeenCalled()
})
it('should show error on toggle update checking if store settings fails', () => {
jest.spyOn(console, 'warn').mockImplementation(() => {})
const toastSpy = jest.spyOn(toastService, 'showError')
settingsService.set(SETTINGS_KEYS.UPDATE_CHECKING_ENABLED, false)
component.setUpdateChecking(true)
httpTestingController
.expectOne(`${environment.apiBaseUrl}ui_settings/`)
.flush('error', {
status: 500,
statusText: 'error',
})
expect(toastSpy).toHaveBeenCalled()
})
it('should support toggling slim sidebar and saving', fakeAsync(() => {
const saveSettingSpy = jest.spyOn(settingsService, 'set')
expect(component.slimSidebarEnabled).toBeFalsy()
expect(component.slimSidebarAnimating).toBeFalsy()
component.toggleSlimSidebar()
expect(component.slimSidebarAnimating).toBeTruthy()
tick(200)
expect(component.slimSidebarAnimating).toBeFalsy()
expect(component.slimSidebarEnabled).toBeTruthy()
expect(saveSettingSpy).toHaveBeenCalledWith(
SETTINGS_KEYS.SLIM_SIDEBAR,
true
)
}))
it('should show error on toggle slim sidebar if store settings fails', () => {
jest.spyOn(console, 'warn').mockImplementation(() => {})
const toastSpy = jest.spyOn(toastService, 'showError')
component.toggleSlimSidebar()
httpTestingController
.expectOne(`${environment.apiBaseUrl}ui_settings/`)
.flush('error', {
status: 500,
statusText: 'error',
})
expect(toastSpy).toHaveBeenCalled()
})
it('should support collapsable menu', () => {
const button: HTMLButtonElement = (
fixture.nativeElement as HTMLDivElement
).querySelector('button[data-toggle=collapse]')
button.dispatchEvent(new MouseEvent('click'))
expect(component.isMenuCollapsed).toBeFalsy()
component.closeMenu()
expect(component.isMenuCollapsed).toBeTruthy()
})
it('should support close document & navigate on close current doc', () => {
const closeSpy = jest.spyOn(openDocumentsService, 'closeDocument')
closeSpy.mockReturnValue(of(true))
const routerSpy = jest.spyOn(router, 'navigate')
component.closeDocument(document)
expect(closeSpy).toHaveBeenCalledWith(document)
expect(routerSpy).toHaveBeenCalled()
})
it('should support close all documents & navigate on close current doc', () => {
const closeAllSpy = jest.spyOn(openDocumentsService, 'closeAll')
closeAllSpy.mockReturnValue(of(true))
const routerSpy = jest.spyOn(router, 'navigate')
component.closeAll()
expect(closeAllSpy).toHaveBeenCalled()
expect(routerSpy).toHaveBeenCalled()
})
it('should close all documents on logout', () => {
const closeAllSpy = jest.spyOn(openDocumentsService, 'closeAll')
component.onLogout()
expect(closeAllSpy).toHaveBeenCalled()
})
it('should warn before close if dirty documents', () => {
jest.spyOn(openDocumentsService, 'hasDirty').mockReturnValue(true)
expect(component.canDeactivate()).toBeFalsy()
})
it('should call autocomplete endpoint on input', fakeAsync(() => {
const autocompleteSpy = jest.spyOn(searchService, 'autocomplete')
component.searchAutoComplete(of('hello')).subscribe()
tick(250)
expect(autocompleteSpy).toHaveBeenCalled()
component.searchAutoComplete(of('hello world 1')).subscribe()
tick(250)
expect(autocompleteSpy).toHaveBeenCalled()
}))
it('should support reset search field', () => {
const resetSpy = jest.spyOn(component, 'resetSearchField')
const input = (fixture.nativeElement as HTMLDivElement).querySelector(
'input'
) as HTMLInputElement
input.dispatchEvent(new KeyboardEvent('keyup', { key: 'Escape' }))
expect(resetSpy).toHaveBeenCalled()
})
it('should support choosing a search item', () => {
expect(component.searchField.value).toEqual('')
component.itemSelected({ item: 'hello', preventDefault: () => true })
expect(component.searchField.value).toEqual('hello ')
component.itemSelected({ item: 'world', preventDefault: () => true })
expect(component.searchField.value).toEqual('hello world ')
})
it('should navigate via quickFilter on search', () => {
const str = 'hello world '
component.searchField.patchValue(str)
const qfSpy = jest.spyOn(documentListViewService, 'quickFilter')
component.search()
expect(qfSpy).toHaveBeenCalledWith([
{
rule_type: FILTER_FULLTEXT_QUERY,
value: str.trim(),
},
])
})
})

View File

@@ -53,7 +53,7 @@ export class AppFrameComponent
public settingsService: SettingsService,
public tasksService: TasksService,
private readonly toastService: ToastService,
private permissionsService: PermissionsService
permissionsService: PermissionsService
) {
super()
@@ -75,7 +75,7 @@ export class AppFrameComponent
}
versionString = `${environment.appTitle} ${environment.version}`
appRemoteVersion
appRemoteVersion: AppRemoteVersion
isMenuCollapsed: boolean = true
@@ -103,7 +103,7 @@ export class AppFrameComponent
this.toastService.showError(
$localize`An error occurred while saving settings.`
)
console.log(error)
console.warn(error)
},
})
}
@@ -236,7 +236,7 @@ export class AppFrameComponent
this.toastService.showError(
$localize`An error occurred while saving update checking settings.`
)
console.log(error)
console.warn(error)
},
})
if (enable) {

View File

@@ -0,0 +1,43 @@
import { ComponentFixture, TestBed } from '@angular/core/testing'
import { ClearableBadgeComponent } from './clearable-badge.component'
describe('ClearableBadgeComponent', () => {
let component: ClearableBadgeComponent
let fixture: ComponentFixture<ClearableBadgeComponent>
beforeEach(async () => {
TestBed.configureTestingModule({
declarations: [ClearableBadgeComponent],
}).compileComponents()
fixture = TestBed.createComponent(ClearableBadgeComponent)
component = fixture.componentInstance
fixture.detectChanges()
})
it('should support selected', () => {
component.selected = true
expect(component.active).toBeTruthy()
})
it('should support numbered', () => {
component.number = 3
fixture.detectChanges()
expect(component.active).toBeTruthy()
expect((fixture.nativeElement as HTMLDivElement).textContent).toContain('3')
})
it('should support selected', () => {
let clearedResult
component.selected = true
fixture.detectChanges()
component.cleared.subscribe((clear) => {
clearedResult = clear
})
fixture.nativeElement
.querySelectorAll('button')[0]
.dispatchEvent(new MouseEvent('click'))
expect(clearedResult).toBeTruthy()
})
})

View File

@@ -0,0 +1,99 @@
import {
ComponentFixture,
TestBed,
discardPeriodicTasks,
fakeAsync,
tick,
} from '@angular/core/testing'
import { ConfirmDialogComponent } from './confirm-dialog.component'
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
import { SafeHtmlPipe } from 'src/app/pipes/safehtml.pipe'
import { Subject } from 'rxjs'
describe('ConfirmDialogComponent', () => {
let component: ConfirmDialogComponent
let modal: NgbActiveModal
let fixture: ComponentFixture<ConfirmDialogComponent>
beforeEach(async () => {
TestBed.configureTestingModule({
declarations: [ConfirmDialogComponent, SafeHtmlPipe],
providers: [NgbActiveModal, SafeHtmlPipe],
imports: [],
}).compileComponents()
modal = TestBed.inject(NgbActiveModal)
fixture = TestBed.createComponent(ConfirmDialogComponent)
component = fixture.componentInstance
component.title = 'Confirm delete'
component.messageBold = 'Do you really want to delete document file.pdf?'
component.message =
'The files for this document will be deleted permanently. This operation cannot be undone.'
component.btnClass = 'btn-danger'
component.btnCaption = 'Delete document'
fixture.detectChanges()
})
it('should support alternative', () => {
let alternativeClickedResult
let alternativeSubjectResult
component.alternativeClicked.subscribe((result) => {
alternativeClickedResult = true
})
component.alternative()
// with subject
const subject = new Subject<boolean>()
component.alternativeSubject = subject
subject.asObservable().subscribe((result) => {
alternativeSubjectResult = result
})
component.alternative()
expect(alternativeClickedResult).toBeTruthy()
expect(alternativeSubjectResult).toBeTruthy()
})
it('should support confirm', () => {
let confirmClickedResult
let confirmSubjectResult
component.confirmClicked.subscribe((result) => {
confirmClickedResult = true
})
component.confirm()
// with subject
const subject = new Subject<boolean>()
component.confirmSubject = subject
subject.asObservable().subscribe((result) => {
confirmSubjectResult = result
})
component.confirm()
expect(confirmClickedResult).toBeTruthy()
expect(confirmSubjectResult).toBeTruthy()
})
it('should support cancel & close modal', () => {
let confirmSubjectResult
const closeModalSpy = jest.spyOn(modal, 'close')
component.cancel()
const subject = new Subject<boolean>()
component.confirmSubject = subject
subject.asObservable().subscribe((result) => {
confirmSubjectResult = result
})
component.cancel()
// with subject
expect(closeModalSpy).toHaveBeenCalled()
expect(confirmSubjectResult).toBeFalsy()
})
it('should support delay confirm', fakeAsync(() => {
component.confirmButtonEnabled = false
component.delayConfirm(1)
expect(component.confirmButtonEnabled).toBeFalsy()
tick(1500)
fixture.detectChanges()
expect(component.confirmButtonEnabled).toBeTruthy()
discardPeriodicTasks()
}))
})

View File

@@ -0,0 +1,141 @@
import {
ComponentFixture,
TestBed,
fakeAsync,
tick,
} from '@angular/core/testing'
let fixture: ComponentFixture<DateDropdownComponent>
import {
DateDropdownComponent,
DateSelection,
RelativeDate,
} from './date-dropdown.component'
import {
HttpClientTestingModule,
HttpTestingController,
} from '@angular/common/http/testing'
import { NgbModule } from '@ng-bootstrap/ng-bootstrap'
import { SettingsService } from 'src/app/services/settings.service'
import { ClearableBadgeComponent } from '../clearable-badge/clearable-badge.component'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
import { DatePipe } from '@angular/common'
describe('DateDropdownComponent', () => {
let component: DateDropdownComponent
let httpTestingController: HttpTestingController
let settingsService: SettingsService
let settingsSpy
beforeEach(async () => {
TestBed.configureTestingModule({
declarations: [
DateDropdownComponent,
ClearableBadgeComponent,
CustomDatePipe,
],
providers: [SettingsService, CustomDatePipe, DatePipe],
imports: [
HttpClientTestingModule,
NgbModule,
FormsModule,
ReactiveFormsModule,
],
}).compileComponents()
httpTestingController = TestBed.inject(HttpTestingController)
settingsService = TestBed.inject(SettingsService)
settingsSpy = jest.spyOn(settingsService, 'getLocalizedDateInputFormat')
fixture = TestBed.createComponent(DateDropdownComponent)
component = fixture.componentInstance
fixture.detectChanges()
})
it('should use a localized date placeholder', () => {
expect(component.datePlaceHolder).toEqual('mm/dd/yyyy')
expect(settingsSpy).toHaveBeenCalled()
})
it('should support date input, emit change', fakeAsync(() => {
let result: string
component.dateAfterChange.subscribe((date) => (result = date))
const input: HTMLInputElement = fixture.nativeElement.querySelector('input')
input.value = '5/30/2023'
input.dispatchEvent(new Event('change'))
tick(500)
expect(result).not.toBeNull()
}))
it('should support date select, emit datesSet change', fakeAsync(() => {
let result: DateSelection
component.datesSet.subscribe((date) => (result = date))
const input: HTMLInputElement = fixture.nativeElement.querySelector('input')
input.value = '5/30/2023'
input.dispatchEvent(new Event('dateSelect'))
tick(500)
expect(result).not.toBeNull()
}))
it('should support relative dates', fakeAsync(() => {
let result: DateSelection
component.datesSet.subscribe((date) => (result = date))
component.setRelativeDate(null)
component.setRelativeDate(RelativeDate.LAST_7_DAYS)
tick(500)
expect(result).toEqual({
after: null,
before: null,
relativeDateID: RelativeDate.LAST_7_DAYS,
})
}))
it('should support report if active', () => {
component.relativeDate = RelativeDate.LAST_7_DAYS
expect(component.isActive).toBeTruthy()
component.relativeDate = null
component.dateAfter = '2023-05-30'
expect(component.isActive).toBeTruthy()
component.dateAfter = null
component.dateBefore = '2023-05-30'
expect(component.isActive).toBeTruthy()
component.dateBefore = null
expect(component.isActive).toBeFalsy()
})
it('should support reset', () => {
component.dateAfter = '2023-05-30'
component.reset()
expect(component.dateAfter).toBeNull()
})
it('should support clearAfter', () => {
component.dateAfter = '2023-05-30'
component.clearAfter()
expect(component.dateAfter).toBeNull()
})
it('should support clearBefore', () => {
component.dateBefore = '2023-05-30'
component.clearBefore()
expect(component.dateBefore).toBeNull()
})
it('should limit keyboard events', () => {
const input: HTMLInputElement = fixture.nativeElement.querySelector('input')
let event: KeyboardEvent = new KeyboardEvent('keypress', {
key: '9',
})
let eventSpy = jest.spyOn(event, 'preventDefault')
input.dispatchEvent(event)
expect(eventSpy).not.toHaveBeenCalled()
event = new KeyboardEvent('keypress', {
key: '{',
})
eventSpy = jest.spyOn(event, 'preventDefault')
input.dispatchEvent(event)
expect(eventSpy).toHaveBeenCalled()
})
})

View File

@@ -1,4 +1,3 @@
import { formatDate } from '@angular/common'
import {
Component,
EventEmitter,

View File

@@ -0,0 +1,55 @@
import { ComponentFixture, TestBed } from '@angular/core/testing'
import { NgbActiveModal, NgbModule } from '@ng-bootstrap/ng-bootstrap'
import { CorrespondentEditDialogComponent } from './correspondent-edit-dialog.component'
import { HttpClientTestingModule } from '@angular/common/http/testing'
import { EditDialogMode } from '../edit-dialog.component'
import { IfOwnerDirective } from 'src/app/directives/if-owner.directive'
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
import { SelectComponent } from '../../input/select/select.component'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { TextComponent } from '../../input/text/text.component'
import { NgSelectModule } from '@ng-select/ng-select'
import { PermissionsFormComponent } from '../../input/permissions/permissions-form/permissions-form.component'
describe('CorrespondentEditDialogComponent', () => {
let component: CorrespondentEditDialogComponent
let fixture: ComponentFixture<CorrespondentEditDialogComponent>
beforeEach(async () => {
TestBed.configureTestingModule({
declarations: [
CorrespondentEditDialogComponent,
IfPermissionsDirective,
IfOwnerDirective,
SelectComponent,
TextComponent,
PermissionsFormComponent,
],
providers: [NgbActiveModal],
imports: [
HttpClientTestingModule,
FormsModule,
ReactiveFormsModule,
NgSelectModule,
NgbModule,
],
}).compileComponents()
fixture = TestBed.createComponent(CorrespondentEditDialogComponent)
component = fixture.componentInstance
fixture.detectChanges()
})
it('should support create and edit modes', () => {
component.dialogMode = EditDialogMode.CREATE
const createTitleSpy = jest.spyOn(component, 'getCreateTitle')
const editTitleSpy = jest.spyOn(component, 'getEditTitle')
fixture.detectChanges()
expect(createTitleSpy).toHaveBeenCalled()
expect(editTitleSpy).not.toHaveBeenCalled()
component.dialogMode = EditDialogMode.EDIT
fixture.detectChanges()
expect(editTitleSpy).toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,55 @@
import { ComponentFixture, TestBed } from '@angular/core/testing'
import { NgbActiveModal, NgbModule } from '@ng-bootstrap/ng-bootstrap'
import { DocumentTypeEditDialogComponent } from './document-type-edit-dialog.component'
import { HttpClientTestingModule } from '@angular/common/http/testing'
import { EditDialogMode } from '../edit-dialog.component'
import { IfOwnerDirective } from 'src/app/directives/if-owner.directive'
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
import { SelectComponent } from '../../input/select/select.component'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { TextComponent } from '../../input/text/text.component'
import { NgSelectModule } from '@ng-select/ng-select'
import { PermissionsFormComponent } from '../../input/permissions/permissions-form/permissions-form.component'
describe('DocumentTypeEditDialogComponent', () => {
let component: DocumentTypeEditDialogComponent
let fixture: ComponentFixture<DocumentTypeEditDialogComponent>
beforeEach(async () => {
TestBed.configureTestingModule({
declarations: [
DocumentTypeEditDialogComponent,
IfPermissionsDirective,
IfOwnerDirective,
SelectComponent,
TextComponent,
PermissionsFormComponent,
],
providers: [NgbActiveModal],
imports: [
HttpClientTestingModule,
FormsModule,
ReactiveFormsModule,
NgSelectModule,
NgbModule,
],
}).compileComponents()
fixture = TestBed.createComponent(DocumentTypeEditDialogComponent)
component = fixture.componentInstance
fixture.detectChanges()
})
it('should support create and edit modes', () => {
component.dialogMode = EditDialogMode.CREATE
const createTitleSpy = jest.spyOn(component, 'getCreateTitle')
const editTitleSpy = jest.spyOn(component, 'getEditTitle')
fixture.detectChanges()
expect(createTitleSpy).toHaveBeenCalled()
expect(editTitleSpy).not.toHaveBeenCalled()
component.dialogMode = EditDialogMode.EDIT
fixture.detectChanges()
expect(editTitleSpy).toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,234 @@
import {
HttpClientTestingModule,
HttpTestingController,
} from '@angular/common/http/testing'
import { Component } from '@angular/core'
import {
ComponentFixture,
TestBed,
fakeAsync,
tick,
} from '@angular/core/testing'
import {
FormGroup,
FormControl,
FormsModule,
ReactiveFormsModule,
} from '@angular/forms'
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
import { PaperlessTag } from 'src/app/data/paperless-tag'
import { TagService } from 'src/app/services/rest/tag.service'
import { UserService } from 'src/app/services/rest/user.service'
import { SettingsService } from 'src/app/services/settings.service'
import { EditDialogComponent, EditDialogMode } from './edit-dialog.component'
import {
DEFAULT_MATCHING_ALGORITHM,
MATCH_ALL,
MATCH_AUTO,
MATCH_NONE,
} from 'src/app/data/matching-model'
import { of } from 'rxjs'
import { environment } from 'src/environments/environment'
@Component({
template: `
<div>
<h4 class="modal-title" id="modal-basic-title">{{ getTitle() }}</h4>
</div>
`,
})
class TestComponent extends EditDialogComponent<PaperlessTag> {
constructor(
service: TagService,
activeModal: NgbActiveModal,
userService: UserService,
settingsService: SettingsService
) {
super(service, activeModal, userService, settingsService)
}
getForm(): FormGroup<any> {
return new FormGroup({
name: new FormControl(''),
color: new FormControl(''),
is_inbox_tag: new FormControl(false),
permissions_form: new FormControl(null),
matching_algorithm: new FormControl(DEFAULT_MATCHING_ALGORITHM),
})
}
}
const currentUser = {
id: 99,
username: 'user99',
}
const permissions = {
view: {
users: [11],
groups: [],
},
change: {
users: [],
groups: [2],
},
}
const tag = {
id: 1,
name: 'Tag 1',
color: '#fff000',
is_inbox_tag: false,
matching_algorithm: MATCH_AUTO,
owner: 10,
permissions,
}
describe('EditDialogComponent', () => {
let component: TestComponent
let fixture: ComponentFixture<TestComponent>
let tagService: TagService
let activeModal: NgbActiveModal
let httpTestingController: HttpTestingController
beforeEach(async () => {
TestBed.configureTestingModule({
declarations: [TestComponent],
providers: [
NgbActiveModal,
{
provide: UserService,
useValue: {
listAll: () =>
of({
results: [
{
id: 13,
username: 'user1',
},
],
}),
},
},
{
provide: SettingsService,
useValue: {
currentUser,
},
},
TagService,
],
imports: [HttpClientTestingModule, FormsModule, ReactiveFormsModule],
}).compileComponents()
tagService = TestBed.inject(TagService)
activeModal = TestBed.inject(NgbActiveModal)
httpTestingController = TestBed.inject(HttpTestingController)
fixture = TestBed.createComponent(TestComponent)
component = fixture.componentInstance
fixture.detectChanges()
})
it('should interpolate object permissions', () => {
component.object = tag
component.dialogMode = EditDialogMode.EDIT
component.ngOnInit()
expect(component.objectForm.get('permissions_form').value).toEqual({
owner: tag.owner,
set_permissions: permissions,
})
})
it('should delay close enabled', fakeAsync(() => {
expect(component.closeEnabled).toBeFalsy()
component.ngOnInit()
tick(100)
expect(component.closeEnabled).toBeTruthy()
}))
it('should set default owner when in create mode', () => {
component.dialogMode = EditDialogMode.CREATE
component.ngOnInit()
expect(component.objectForm.get('permissions_form').value.owner).toEqual(
currentUser.id
)
// cover optional chaining
component.objectForm.removeControl('permissions_form')
component.ngOnInit()
})
it('should detect if pattern required', () => {
expect(component.patternRequired).toBeFalsy()
component.objectForm.get('matching_algorithm').setValue(MATCH_AUTO)
expect(component.patternRequired).toBeFalsy()
component.objectForm.get('matching_algorithm').setValue(MATCH_NONE)
expect(component.patternRequired).toBeFalsy()
component.objectForm.get('matching_algorithm').setValue(MATCH_ALL)
expect(component.patternRequired).toBeTruthy()
// coverage
component.objectForm = null
expect(component.patternRequired).toBeTruthy()
})
it('should support create and edit modes', () => {
component.dialogMode = EditDialogMode.CREATE
const createTitleSpy = jest.spyOn(component, 'getCreateTitle')
const editTitleSpy = jest.spyOn(component, 'getEditTitle')
fixture.detectChanges()
expect(createTitleSpy).toHaveBeenCalled()
expect(editTitleSpy).not.toHaveBeenCalled()
component.dialogMode = EditDialogMode.EDIT
fixture.detectChanges()
expect(editTitleSpy).toHaveBeenCalled()
// coverage
component.dialogMode = null
fixture.detectChanges()
})
it('should close on cancel', () => {
const closeSpy = jest.spyOn(activeModal, 'close')
component.cancel()
expect(closeSpy).toHaveBeenCalled()
})
it('should update an object on save in edit mode', () => {
const updateSpy = jest.spyOn(tagService, 'update')
component.dialogMode = EditDialogMode.EDIT
component.save()
expect(updateSpy).toHaveBeenCalled()
})
it('should create an object on save in edit mode', () => {
const createSpy = jest.spyOn(tagService, 'create')
component.dialogMode = EditDialogMode.CREATE
component.save()
expect(createSpy).toHaveBeenCalled()
})
it('should close on successful save', () => {
const closeSpy = jest.spyOn(activeModal, 'close')
const successSpy = jest.spyOn(component.succeeded, 'emit')
component.save()
httpTestingController.expectOne(`${environment.apiBaseUrl}tags/`).flush({})
expect(closeSpy).toHaveBeenCalled()
expect(successSpy).toHaveBeenCalled()
})
it('should not close on failed save', () => {
const closeSpy = jest.spyOn(activeModal, 'close')
const failedSpy = jest.spyOn(component.failed, 'next')
component.save()
httpTestingController
.expectOne(`${environment.apiBaseUrl}tags/`)
.flush('error', {
status: 500,
statusText: 'error',
})
expect(closeSpy).not.toHaveBeenCalled()
expect(failedSpy).toHaveBeenCalled()
expect(component.error).toEqual('error')
})
})

View File

@@ -15,6 +15,11 @@ import { UserService } from 'src/app/services/rest/user.service'
import { PermissionsFormObject } from '../input/permissions/permissions-form/permissions-form.component'
import { SettingsService } from 'src/app/services/settings.service'
export enum EditDialogMode {
CREATE = 0,
EDIT = 1,
}
@Directive()
export abstract class EditDialogComponent<
T extends ObjectWithPermissions | ObjectWithId
@@ -30,7 +35,7 @@ export abstract class EditDialogComponent<
users: PaperlessUser[]
@Input()
dialogMode: string = 'create'
dialogMode: EditDialogMode = EditDialogMode.CREATE
@Input()
object: T
@@ -71,7 +76,7 @@ export abstract class EditDialogComponent<
this.userService.listAll().subscribe((r) => {
this.users = r.results
if (this.dialogMode === 'create') {
if (this.dialogMode === EditDialogMode.CREATE) {
this.objectForm.get('permissions_form')?.setValue({
owner: this.settingsService.currentUser.id,
})
@@ -87,15 +92,11 @@ export abstract class EditDialogComponent<
return $localize`Edit item`
}
getSaveErrorMessage(error: string) {
return $localize`Could not save element: ${error}`
}
getTitle() {
switch (this.dialogMode) {
case 'create':
case EditDialogMode.CREATE:
return this.getCreateTitle()
case 'edit':
case EditDialogMode.EDIT:
return this.getEditTitle()
default:
break
@@ -127,10 +128,10 @@ export abstract class EditDialogComponent<
var newObject = Object.assign(Object.assign({}, this.object), formValues)
var serverResponse: Observable<T>
switch (this.dialogMode) {
case 'create':
case EditDialogMode.CREATE:
serverResponse = this.service.create(newObject)
break
case 'edit':
case EditDialogMode.EDIT:
serverResponse = this.service.update(newObject)
default:
break

View File

@@ -0,0 +1,57 @@
import { ComponentFixture, TestBed } from '@angular/core/testing'
import { NgbActiveModal, NgbModule } from '@ng-bootstrap/ng-bootstrap'
import { HttpClientTestingModule } from '@angular/common/http/testing'
import { EditDialogMode } from '../edit-dialog.component'
import { IfOwnerDirective } from 'src/app/directives/if-owner.directive'
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
import { SelectComponent } from '../../input/select/select.component'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { TextComponent } from '../../input/text/text.component'
import { NgSelectModule } from '@ng-select/ng-select'
import { PermissionsFormComponent } from '../../input/permissions/permissions-form/permissions-form.component'
import { GroupEditDialogComponent } from './group-edit-dialog.component'
import { PermissionsSelectComponent } from '../../permissions-select/permissions-select.component'
describe('GroupEditDialogComponent', () => {
let component: GroupEditDialogComponent
let fixture: ComponentFixture<GroupEditDialogComponent>
beforeEach(async () => {
TestBed.configureTestingModule({
declarations: [
GroupEditDialogComponent,
IfPermissionsDirective,
IfOwnerDirective,
SelectComponent,
TextComponent,
PermissionsFormComponent,
PermissionsSelectComponent,
],
providers: [NgbActiveModal],
imports: [
HttpClientTestingModule,
FormsModule,
ReactiveFormsModule,
NgSelectModule,
NgbModule,
],
}).compileComponents()
fixture = TestBed.createComponent(GroupEditDialogComponent)
component = fixture.componentInstance
fixture.detectChanges()
})
it('should support create and edit modes', () => {
component.dialogMode = EditDialogMode.CREATE
const createTitleSpy = jest.spyOn(component, 'getCreateTitle')
const editTitleSpy = jest.spyOn(component, 'getEditTitle')
fixture.detectChanges()
expect(createTitleSpy).toHaveBeenCalled()
expect(editTitleSpy).not.toHaveBeenCalled()
component.dialogMode = EditDialogMode.EDIT
fixture.detectChanges()
expect(editTitleSpy).toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,117 @@
import {
ComponentFixture,
TestBed,
discardPeriodicTasks,
fakeAsync,
tick,
} from '@angular/core/testing'
import { NgbActiveModal, NgbModule } from '@ng-bootstrap/ng-bootstrap'
import {
HttpClientTestingModule,
HttpTestingController,
} from '@angular/common/http/testing'
import { EditDialogMode } from '../edit-dialog.component'
import { IfOwnerDirective } from 'src/app/directives/if-owner.directive'
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
import { SelectComponent } from '../../input/select/select.component'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { TextComponent } from '../../input/text/text.component'
import { NgSelectModule } from '@ng-select/ng-select'
import { PermissionsFormComponent } from '../../input/permissions/permissions-form/permissions-form.component'
import { MailAccountEditDialogComponent } from './mail-account-edit-dialog.component'
import { PasswordComponent } from '../../input/password/password.component'
import { CheckComponent } from '../../input/check/check.component'
import { IMAPSecurity } from 'src/app/data/paperless-mail-account'
import { environment } from 'src/environments/environment'
describe('MailAccountEditDialogComponent', () => {
let component: MailAccountEditDialogComponent
let fixture: ComponentFixture<MailAccountEditDialogComponent>
let httpController: HttpTestingController
beforeEach(async () => {
TestBed.configureTestingModule({
declarations: [
MailAccountEditDialogComponent,
IfPermissionsDirective,
IfOwnerDirective,
SelectComponent,
TextComponent,
CheckComponent,
PermissionsFormComponent,
PasswordComponent,
],
providers: [NgbActiveModal],
imports: [
HttpClientTestingModule,
FormsModule,
ReactiveFormsModule,
NgSelectModule,
NgbModule,
],
}).compileComponents()
httpController = TestBed.inject(HttpTestingController)
fixture = TestBed.createComponent(MailAccountEditDialogComponent)
component = fixture.componentInstance
fixture.detectChanges()
})
it('should support create and edit modes', () => {
component.dialogMode = EditDialogMode.CREATE
const createTitleSpy = jest.spyOn(component, 'getCreateTitle')
const editTitleSpy = jest.spyOn(component, 'getEditTitle')
fixture.detectChanges()
expect(createTitleSpy).toHaveBeenCalled()
expect(editTitleSpy).not.toHaveBeenCalled()
component.dialogMode = EditDialogMode.EDIT
fixture.detectChanges()
expect(editTitleSpy).toHaveBeenCalled()
})
it('should support test mail account and show appropriate expiring alert', fakeAsync(() => {
component.object = {
name: 'example',
imap_server: 'imap.example.com',
username: 'user',
password: 'pass',
imap_port: 443,
imap_security: IMAPSecurity.SSL,
is_token: false,
}
// success
component.test()
httpController
.expectOne(`${environment.apiBaseUrl}mail_accounts/test/`)
.flush({ success: true })
fixture.detectChanges()
expect(fixture.nativeElement.textContent).toContain(
'Successfully connected'
)
tick(6000)
fixture.detectChanges()
expect(fixture.nativeElement.textContent).not.toContain(
'Successfully connected'
)
// not success
component.test()
httpController
.expectOne(`${environment.apiBaseUrl}mail_accounts/test/`)
.flush({ success: false })
fixture.detectChanges()
expect(fixture.nativeElement.textContent).toContain('Unable to connect')
// error
component.test()
httpController
.expectOne(`${environment.apiBaseUrl}mail_accounts/test/`)
.flush({}, { status: 500, statusText: 'error' })
fixture.detectChanges()
expect(fixture.nativeElement.textContent).toContain('Unable to connect')
tick(6000)
}))
})

View File

@@ -0,0 +1,113 @@
import { ComponentFixture, TestBed } from '@angular/core/testing'
import { NgbActiveModal, NgbModule } from '@ng-bootstrap/ng-bootstrap'
import { HttpClientTestingModule } from '@angular/common/http/testing'
import { EditDialogMode } from '../edit-dialog.component'
import { IfOwnerDirective } from 'src/app/directives/if-owner.directive'
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
import { SelectComponent } from '../../input/select/select.component'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { TextComponent } from '../../input/text/text.component'
import { NgSelectModule } from '@ng-select/ng-select'
import { PermissionsFormComponent } from '../../input/permissions/permissions-form/permissions-form.component'
import { MailRuleEditDialogComponent } from './mail-rule-edit-dialog.component'
import { NumberComponent } from '../../input/number/number.component'
import { TagsComponent } from '../../input/tags/tags.component'
import { SafeHtmlPipe } from 'src/app/pipes/safehtml.pipe'
import { MailAccountService } from 'src/app/services/rest/mail-account.service'
import { CorrespondentService } from 'src/app/services/rest/correspondent.service'
import { DocumentTypeService } from 'src/app/services/rest/document-type.service'
import { of } from 'rxjs'
import {
MailAction,
MailMetadataCorrespondentOption,
} from 'src/app/data/paperless-mail-rule'
describe('MailRuleEditDialogComponent', () => {
let component: MailRuleEditDialogComponent
let fixture: ComponentFixture<MailRuleEditDialogComponent>
let accountService: MailAccountService
let correspondentService: CorrespondentService
let documentTypeService: DocumentTypeService
beforeEach(async () => {
TestBed.configureTestingModule({
declarations: [
MailRuleEditDialogComponent,
IfPermissionsDirective,
IfOwnerDirective,
SelectComponent,
TextComponent,
PermissionsFormComponent,
NumberComponent,
TagsComponent,
SafeHtmlPipe,
],
providers: [
NgbActiveModal,
{
provide: MailAccountService,
useValue: {
listAll: () => of([]),
},
},
{
provide: CorrespondentService,
useValue: {
listAll: () => of([]),
},
},
{
provide: DocumentTypeService,
useValue: {
listAll: () => of([]),
},
},
],
imports: [
HttpClientTestingModule,
FormsModule,
ReactiveFormsModule,
NgSelectModule,
NgbModule,
],
}).compileComponents()
fixture = TestBed.createComponent(MailRuleEditDialogComponent)
component = fixture.componentInstance
fixture.detectChanges()
})
it('should support create and edit modes', () => {
component.dialogMode = EditDialogMode.CREATE
const createTitleSpy = jest.spyOn(component, 'getCreateTitle')
const editTitleSpy = jest.spyOn(component, 'getEditTitle')
fixture.detectChanges()
expect(createTitleSpy).toHaveBeenCalled()
expect(editTitleSpy).not.toHaveBeenCalled()
component.dialogMode = EditDialogMode.EDIT
fixture.detectChanges()
expect(editTitleSpy).toHaveBeenCalled()
})
it('should support optional fields', () => {
expect(component.showCorrespondentField).toBeFalsy()
component.objectForm
.get('assign_correspondent_from')
.setValue(MailMetadataCorrespondentOption.FromCustom)
expect(component.showCorrespondentField).toBeTruthy()
expect(component.showActionParamField).toBeFalsy()
component.objectForm.get('action').setValue(MailAction.Move)
expect(component.showActionParamField).toBeTruthy()
component.objectForm.get('action').setValue('')
expect(component.showActionParamField).toBeFalsy()
component.objectForm.get('action').setValue(MailAction.Tag)
expect(component.showActionParamField).toBeTruthy()
// coverage of optional chaining
component.objectForm = null
expect(component.showCorrespondentField).toBeFalsy()
expect(component.showActionParamField).toBeFalsy()
})
})

View File

@@ -0,0 +1,57 @@
import { ComponentFixture, TestBed } from '@angular/core/testing'
import { NgbActiveModal, NgbModule } from '@ng-bootstrap/ng-bootstrap'
import { HttpClientTestingModule } from '@angular/common/http/testing'
import { EditDialogMode } from '../edit-dialog.component'
import { IfOwnerDirective } from 'src/app/directives/if-owner.directive'
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
import { SelectComponent } from '../../input/select/select.component'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { TextComponent } from '../../input/text/text.component'
import { NgSelectModule } from '@ng-select/ng-select'
import { PermissionsFormComponent } from '../../input/permissions/permissions-form/permissions-form.component'
import { StoragePathEditDialogComponent } from './storage-path-edit-dialog.component'
import { SafeHtmlPipe } from 'src/app/pipes/safehtml.pipe'
describe('StoragePathEditDialogComponent', () => {
let component: StoragePathEditDialogComponent
let fixture: ComponentFixture<StoragePathEditDialogComponent>
beforeEach(async () => {
TestBed.configureTestingModule({
declarations: [
StoragePathEditDialogComponent,
IfPermissionsDirective,
IfOwnerDirective,
SelectComponent,
TextComponent,
PermissionsFormComponent,
SafeHtmlPipe,
],
providers: [NgbActiveModal],
imports: [
HttpClientTestingModule,
FormsModule,
ReactiveFormsModule,
NgSelectModule,
NgbModule,
],
}).compileComponents()
fixture = TestBed.createComponent(StoragePathEditDialogComponent)
component = fixture.componentInstance
fixture.detectChanges()
})
it('should support create and edit modes', () => {
component.dialogMode = EditDialogMode.CREATE
const createTitleSpy = jest.spyOn(component, 'getCreateTitle')
const editTitleSpy = jest.spyOn(component, 'getEditTitle')
fixture.detectChanges()
expect(createTitleSpy).toHaveBeenCalled()
expect(editTitleSpy).not.toHaveBeenCalled()
component.dialogMode = EditDialogMode.EDIT
fixture.detectChanges()
expect(editTitleSpy).toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,59 @@
import { ComponentFixture, TestBed } from '@angular/core/testing'
import { NgbActiveModal, NgbModule } from '@ng-bootstrap/ng-bootstrap'
import { HttpClientTestingModule } from '@angular/common/http/testing'
import { EditDialogMode } from '../edit-dialog.component'
import { IfOwnerDirective } from 'src/app/directives/if-owner.directive'
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
import { SelectComponent } from '../../input/select/select.component'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { TextComponent } from '../../input/text/text.component'
import { NgSelectModule } from '@ng-select/ng-select'
import { PermissionsFormComponent } from '../../input/permissions/permissions-form/permissions-form.component'
import { TagEditDialogComponent } from './tag-edit-dialog.component'
import { ColorComponent } from '../../input/color/color.component'
import { CheckComponent } from '../../input/check/check.component'
describe('TagEditDialogComponent', () => {
let component: TagEditDialogComponent
let fixture: ComponentFixture<TagEditDialogComponent>
beforeEach(async () => {
TestBed.configureTestingModule({
declarations: [
TagEditDialogComponent,
IfPermissionsDirective,
IfOwnerDirective,
SelectComponent,
TextComponent,
PermissionsFormComponent,
ColorComponent,
CheckComponent,
],
providers: [NgbActiveModal],
imports: [
HttpClientTestingModule,
FormsModule,
ReactiveFormsModule,
NgSelectModule,
NgbModule,
],
}).compileComponents()
fixture = TestBed.createComponent(TagEditDialogComponent)
component = fixture.componentInstance
fixture.detectChanges()
})
it('should support create and edit modes', () => {
component.dialogMode = EditDialogMode.CREATE
const createTitleSpy = jest.spyOn(component, 'getCreateTitle')
const editTitleSpy = jest.spyOn(component, 'getEditTitle')
fixture.detectChanges()
expect(createTitleSpy).toHaveBeenCalled()
expect(editTitleSpy).not.toHaveBeenCalled()
component.dialogMode = EditDialogMode.EDIT
fixture.detectChanges()
expect(editTitleSpy).toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,115 @@
import { ComponentFixture, TestBed } from '@angular/core/testing'
import { NgbActiveModal, NgbModule } from '@ng-bootstrap/ng-bootstrap'
import { HttpClientTestingModule } from '@angular/common/http/testing'
import { EditDialogMode } from '../edit-dialog.component'
import { IfOwnerDirective } from 'src/app/directives/if-owner.directive'
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
import { SelectComponent } from '../../input/select/select.component'
import {
AbstractControl,
FormsModule,
ReactiveFormsModule,
} from '@angular/forms'
import { TextComponent } from '../../input/text/text.component'
import { NgSelectModule } from '@ng-select/ng-select'
import { PermissionsFormComponent } from '../../input/permissions/permissions-form/permissions-form.component'
import { UserEditDialogComponent } from './user-edit-dialog.component'
import { PasswordComponent } from '../../input/password/password.component'
import { PermissionsSelectComponent } from '../../permissions-select/permissions-select.component'
import { GroupService } from 'src/app/services/rest/group.service'
import { of } from 'rxjs'
describe('UserEditDialogComponent', () => {
let component: UserEditDialogComponent
let fixture: ComponentFixture<UserEditDialogComponent>
beforeEach(async () => {
TestBed.configureTestingModule({
declarations: [
UserEditDialogComponent,
IfPermissionsDirective,
IfOwnerDirective,
SelectComponent,
TextComponent,
PasswordComponent,
PermissionsFormComponent,
PermissionsSelectComponent,
],
providers: [
NgbActiveModal,
{
provide: GroupService,
useValue: {
listAll: () =>
of({
results: [
{
id: 1,
permissions: ['dummy_perms'],
},
],
}),
},
},
],
imports: [
HttpClientTestingModule,
FormsModule,
ReactiveFormsModule,
NgSelectModule,
NgbModule,
],
}).compileComponents()
fixture = TestBed.createComponent(UserEditDialogComponent)
component = fixture.componentInstance
fixture.detectChanges()
})
it('should support create and edit modes', () => {
component.dialogMode = EditDialogMode.CREATE
const createTitleSpy = jest.spyOn(component, 'getCreateTitle')
const editTitleSpy = jest.spyOn(component, 'getEditTitle')
fixture.detectChanges()
expect(createTitleSpy).toHaveBeenCalled()
expect(editTitleSpy).not.toHaveBeenCalled()
component.dialogMode = EditDialogMode.EDIT
fixture.detectChanges()
expect(editTitleSpy).toHaveBeenCalled()
})
it('should disable user permissions select on toggle superuser', () => {
const control: AbstractControl =
component.objectForm.get('user_permissions')
expect(control.disabled).toBeFalsy()
component.objectForm.get('is_superuser').setValue(true)
component.onToggleSuperUser()
expect(control.disabled).toBeTruthy()
})
it('should update inherited permissions', () => {
component.objectForm.get('groups').setValue(null)
expect(component.inheritedPermissions).toEqual([])
component.objectForm.get('groups').setValue([1])
expect(component.inheritedPermissions).toEqual(['dummy_perms'])
component.objectForm.get('groups').setValue([2])
expect(component.inheritedPermissions).toEqual([])
})
it('should detect whether password was changed in form on save', () => {
component.objectForm.get('password').setValue(null)
component.save()
expect(component.passwordIsSet).toBeFalsy()
// unchanged pw
component.objectForm.get('password').setValue('*******')
component.save()
expect(component.passwordIsSet).toBeFalsy()
// unchanged pw
component.objectForm.get('password').setValue('helloworld')
component.save()
expect(component.passwordIsSet).toBeTruthy()
})
})

View File

@@ -34,7 +34,7 @@
<div *ngIf="selectionModel.items" class="items" #buttonItems>
<ng-container *ngFor="let item of selectionModel.itemsSorted | filter: filterText; let i = index">
<app-toggleable-dropdown-button
*ngIf="allowSelectNone || item.id" [item]="item" [hideCount]="hideCount(item)" [state]="selectionModel.get(item.id)" [count]="getUpdatedDocumentCount(item.id)" (toggle)="selectionModel.toggle(item.id)" (exclude)="excludeClicked(item.id)" (click)="setButtonItemIndex(i)" [disabled]="disabled">
*ngIf="allowSelectNone || item.id" [item]="item" [hideCount]="hideCount(item)" [state]="selectionModel.get(item.id)" [count]="getUpdatedDocumentCount(item.id)" (toggle)="selectionModel.toggle(item.id)" (exclude)="excludeClicked(item.id)" (click)="setButtonItemIndex(i - 1)" [disabled]="disabled">
</app-toggleable-dropdown-button>
</ng-container>
</div>

View File

@@ -0,0 +1,487 @@
import {
ComponentFixture,
TestBed,
fakeAsync,
tick,
} from '@angular/core/testing'
import {
ChangedItems,
FilterableDropdownComponent,
FilterableDropdownSelectionModel,
Intersection,
LogicalOperator,
} from './filterable-dropdown.component'
import { FilterPipe } from 'src/app/pipes/filter.pipe'
import { NgbModule } from '@ng-bootstrap/ng-bootstrap'
import { PaperlessTag } from 'src/app/data/paperless-tag'
import {
DEFAULT_MATCHING_ALGORITHM,
MATCH_ALL,
} from 'src/app/data/matching-model'
import {
ToggleableDropdownButtonComponent,
ToggleableItemState,
} from './toggleable-dropdown-button/toggleable-dropdown-button.component'
import { TagComponent } from '../tag/tag.component'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { ClearableBadgeComponent } from '../clearable-badge/clearable-badge.component'
const items: PaperlessTag[] = [
{
id: 1,
name: 'Tag1',
is_inbox_tag: false,
matching_algorithm: DEFAULT_MATCHING_ALGORITHM,
},
{
id: 2,
name: 'Tag2',
is_inbox_tag: true,
matching_algorithm: MATCH_ALL,
match: 'str',
},
]
const nullItem = {
id: null,
name: 'Not assigned',
}
let selectionModel: FilterableDropdownSelectionModel
describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () => {
let component: FilterableDropdownComponent
let fixture: ComponentFixture<FilterableDropdownComponent>
beforeEach(async () => {
TestBed.configureTestingModule({
declarations: [
FilterableDropdownComponent,
FilterPipe,
ToggleableDropdownButtonComponent,
TagComponent,
ClearableBadgeComponent,
],
providers: [FilterPipe],
imports: [NgbModule, FormsModule, ReactiveFormsModule],
}).compileComponents()
fixture = TestBed.createComponent(FilterableDropdownComponent)
component = fixture.componentInstance
selectionModel = new FilterableDropdownSelectionModel()
})
it('should sanitize title', () => {
expect(component.name).toBeNull()
component.title = 'Foo Bar'
expect(component.name).toEqual('foo_bar')
})
it('should support reset', () => {
component.items = items
component.selectionModel = selectionModel
selectionModel.set(items[0].id, ToggleableItemState.Selected)
expect(selectionModel.getSelectedItems()).toHaveLength(1)
expect(selectionModel.isDirty()).toBeTruthy()
component.reset()
expect(selectionModel.getSelectedItems()).toHaveLength(0)
expect(selectionModel.isDirty()).toBeFalsy()
})
it('should report document counts', () => {
component.documentCounts = [
{
id: items[0].id,
document_count: 12,
},
]
expect(component.getUpdatedDocumentCount(items[0].id)).toEqual(12)
expect(component.getUpdatedDocumentCount(items[1].id)).toBeUndefined() // coverate of optional chaining
})
it('should emit change when items selected', () => {
component.items = items
component.selectionModel = selectionModel
let newModel: FilterableDropdownSelectionModel
component.selectionModelChange.subscribe((model) => (newModel = model))
expect(newModel).toBeUndefined()
selectionModel.set(items[0].id, ToggleableItemState.Selected)
expect(selectionModel.isDirty()).toBeTruthy()
expect(newModel.getSelectedItems()).toEqual([items[0]])
expect(newModel.getExcludedItems()).toEqual([])
selectionModel.set(items[0].id, ToggleableItemState.NotSelected)
expect(newModel.getSelectedItems()).toEqual([])
expect(component.items).toEqual([nullItem, ...items])
})
it('should emit change when items excluded', () => {
component.items = items
component.selectionModel = selectionModel
let newModel: FilterableDropdownSelectionModel
component.selectionModelChange.subscribe((model) => (newModel = model))
expect(newModel).toBeUndefined()
selectionModel.toggle(items[0].id)
expect(newModel.getSelectedItems()).toEqual([items[0]])
})
it('should emit change when items excluded', () => {
component.items = items
component.selectionModel = selectionModel
let newModel: FilterableDropdownSelectionModel
component.selectionModelChange.subscribe((model) => (newModel = model))
selectionModel.set(items[0].id, ToggleableItemState.Excluded)
expect(newModel.getSelectedItems()).toEqual([])
expect(newModel.getExcludedItems()).toEqual([items[0]])
selectionModel.set(items[0].id, ToggleableItemState.NotSelected)
expect(newModel.getSelectedItems()).toEqual([])
expect(newModel.getExcludedItems()).toEqual([])
})
it('should exclude items when excluded and not editing', () => {
component.items = items
component.manyToOne = true
component.selectionModel = selectionModel
selectionModel.set(items[0].id, ToggleableItemState.Selected)
component.excludeClicked(items[0].id)
expect(selectionModel.getSelectedItems()).toEqual([])
expect(selectionModel.getExcludedItems()).toEqual([items[0]])
})
it('should toggle when items excluded and editing', () => {
component.items = items
component.manyToOne = true
component.editing = true
component.selectionModel = selectionModel
selectionModel.set(items[0].id, ToggleableItemState.NotSelected)
component.excludeClicked(items[0].id)
expect(selectionModel.getSelectedItems()).toEqual([items[0]])
expect(selectionModel.getExcludedItems()).toEqual([])
})
it('should hide count for item if adding will increase size of set', () => {
component.items = items
component.manyToOne = true
component.selectionModel = selectionModel
expect(component.hideCount(items[0])).toBeFalsy()
selectionModel.logicalOperator = LogicalOperator.Or
expect(component.hideCount(items[0])).toBeTruthy()
})
it('should enforce single select when editing', () => {
component.editing = true
component.items = items
component.selectionModel = selectionModel
let newModel: FilterableDropdownSelectionModel
component.selectionModelChange.subscribe((model) => (newModel = model))
expect(selectionModel.singleSelect).toEqual(true)
selectionModel.toggle(items[0].id)
selectionModel.toggle(items[1].id)
expect(newModel.getSelectedItems()).toEqual([items[1]])
})
it('should support manyToOne selecting', () => {
component.items = items
selectionModel.manyToOne = false
component.selectionModel = selectionModel
component.manyToOne = true
expect(component.manyToOne).toBeTruthy()
let newModel: FilterableDropdownSelectionModel
component.selectionModelChange.subscribe((model) => (newModel = model))
expect(selectionModel.singleSelect).toEqual(false)
selectionModel.toggle(items[0].id)
selectionModel.toggle(items[1].id)
expect(newModel.getSelectedItems()).toEqual([items[0], items[1]])
})
it('should dynamically enable / disable modifier toggle', () => {
component.items = items
component.selectionModel = selectionModel
expect(component.modifierToggleEnabled).toBeTruthy()
selectionModel.toggle(null)
expect(component.modifierToggleEnabled).toBeFalsy()
component.manyToOne = true
expect(component.modifierToggleEnabled).toBeFalsy()
selectionModel.toggle(items[0].id)
selectionModel.toggle(items[1].id)
expect(component.modifierToggleEnabled).toBeTruthy()
})
it('should apply changes and close when apply button clicked', () => {
component.items = items
component.editing = true
component.selectionModel = selectionModel
fixture.nativeElement
.querySelector('button')
.dispatchEvent(new MouseEvent('click')) // open
selectionModel.toggle(items[0].id)
fixture.detectChanges()
expect(component.modelIsDirty).toBeTruthy()
let applyResult: ChangedItems
const closeSpy = jest.spyOn(component.dropdown, 'close')
component.apply.subscribe((result) => (applyResult = result))
const applyButton = Array.from(
(fixture.nativeElement as HTMLDivElement).querySelectorAll('button')
).find((b) => b.textContent.includes('Apply'))
applyButton.dispatchEvent(new MouseEvent('click'))
expect(closeSpy).toHaveBeenCalled()
expect(applyResult).toEqual({ itemsToAdd: [items[0]], itemsToRemove: [] })
})
it('should apply on close if enabled', () => {
component.items = items
component.editing = true
component.applyOnClose = true
component.selectionModel = selectionModel
fixture.nativeElement
.querySelector('button')
.dispatchEvent(new MouseEvent('click')) // open
selectionModel.toggle(items[0].id)
fixture.detectChanges()
expect(component.modelIsDirty).toBeTruthy()
let applyResult: ChangedItems
component.apply.subscribe((result) => (applyResult = result))
component.dropdown.close()
expect(applyResult).toEqual({ itemsToAdd: [items[0]], itemsToRemove: [] })
})
it('should focus text filter on open, support filtering, clear on close', fakeAsync(() => {
component.items = items
fixture.nativeElement
.querySelector('button')
.dispatchEvent(new MouseEvent('click')) // open
fixture.detectChanges()
tick(100)
expect(document.activeElement).toEqual(
component.listFilterTextInput.nativeElement
)
expect(
Array.from(
(fixture.nativeElement as HTMLDivElement).querySelectorAll('button')
).filter((b) => b.textContent.includes('Tag'))
).toHaveLength(2)
component.filterText = 'Tag2'
fixture.detectChanges()
expect(
Array.from(
(fixture.nativeElement as HTMLDivElement).querySelectorAll('button')
).filter((b) => b.textContent.includes('Tag'))
).toHaveLength(1)
component.dropdown.close()
expect(component.filterText).toHaveLength(0)
}))
it('should toggle & close on enter inside filter field if 1 item remains', fakeAsync(() => {
component.items = items
expect(component.selectionModel.getSelectedItems()).toEqual([])
fixture.nativeElement
.querySelector('button')
.dispatchEvent(new MouseEvent('click')) // open
fixture.detectChanges()
tick(100)
component.filterText = 'Tag2'
fixture.detectChanges()
const closeSpy = jest.spyOn(component.dropdown, 'close')
component.listFilterTextInput.nativeElement.dispatchEvent(
new KeyboardEvent('keyup', { key: 'Enter' })
)
expect(component.selectionModel.getSelectedItems()).toEqual([items[1]])
tick(300)
expect(closeSpy).toHaveBeenCalled()
}))
it('should apply & close on enter inside filter field if 1 item remains if editing', fakeAsync(() => {
component.items = items
component.editing = true
let applyResult: ChangedItems
component.apply.subscribe((result) => (applyResult = result))
expect(component.selectionModel.getSelectedItems()).toEqual([])
fixture.nativeElement
.querySelector('button')
.dispatchEvent(new MouseEvent('click')) // open
fixture.detectChanges()
tick(100)
component.filterText = 'Tag2'
fixture.detectChanges()
component.listFilterTextInput.nativeElement.dispatchEvent(
new KeyboardEvent('keyup', { key: 'Enter' })
)
expect(component.selectionModel.getSelectedItems()).toEqual([items[1]])
tick(300)
expect(applyResult).toEqual({ itemsToAdd: [items[1]], itemsToRemove: [] })
}))
it('should support arrow keyboard navigation', fakeAsync(() => {
component.items = items
fixture.nativeElement
.querySelector('button')
.dispatchEvent(new MouseEvent('click')) // open
fixture.detectChanges()
tick(100)
const filterInputEl: HTMLInputElement =
component.listFilterTextInput.nativeElement
expect(document.activeElement).toEqual(filterInputEl)
const itemButtons = Array.from(
(fixture.nativeElement as HTMLDivElement).querySelectorAll('button')
).filter((b) => b.textContent.includes('Tag'))
filterInputEl.dispatchEvent(
new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })
)
expect(document.activeElement).toEqual(itemButtons[0])
itemButtons[0].dispatchEvent(
new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })
)
expect(document.activeElement).toEqual(itemButtons[1])
itemButtons[1].dispatchEvent(
new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true })
)
expect(document.activeElement).toEqual(itemButtons[0])
itemButtons[0].dispatchEvent(
new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true })
)
expect(document.activeElement).toEqual(filterInputEl)
filterInputEl.value = 'foo'
component.filterText = 'foo'
// dont move focus if we're traversing the field
filterInputEl.selectionStart = 1
expect(document.activeElement).toEqual(filterInputEl)
// now we're at end, so move focus
filterInputEl.selectionStart = 3
filterInputEl.dispatchEvent(
new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })
)
expect(document.activeElement).toEqual(itemButtons[0])
}))
it('should support arrow keyboard navigation after tab keyboard navigation', fakeAsync(() => {
component.items = items
fixture.nativeElement
.querySelector('button')
.dispatchEvent(new MouseEvent('click')) // open
fixture.detectChanges()
tick(100)
const filterInputEl: HTMLInputElement =
component.listFilterTextInput.nativeElement
expect(document.activeElement).toEqual(filterInputEl)
const itemButtons = Array.from(
(fixture.nativeElement as HTMLDivElement).querySelectorAll('button')
).filter((b) => b.textContent.includes('Tag'))
filterInputEl.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Tab', bubbles: true })
)
itemButtons[0].focus() // normally handled by browser
itemButtons[0].dispatchEvent(
new KeyboardEvent('keydown', { key: 'Tab', bubbles: true })
)
itemButtons[1].focus() // normally handled by browser
itemButtons[1].dispatchEvent(
new KeyboardEvent('keydown', {
key: 'Tab',
shiftKey: true,
bubbles: true,
})
)
itemButtons[0].focus() // normally handled by browser
itemButtons[0].dispatchEvent(
new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })
)
expect(document.activeElement).toEqual(itemButtons[1])
}))
it('should support arrow keyboard navigation after click', fakeAsync(() => {
component.items = items
fixture.nativeElement
.querySelector('button')
.dispatchEvent(new MouseEvent('click')) // open
fixture.detectChanges()
tick(100)
const filterInputEl: HTMLInputElement =
component.listFilterTextInput.nativeElement
expect(document.activeElement).toEqual(filterInputEl)
const itemButtons = Array.from(
(fixture.nativeElement as HTMLDivElement).querySelectorAll('button')
).filter((b) => b.textContent.includes('Tag'))
fixture.nativeElement
.querySelector('app-toggleable-dropdown-button')
.dispatchEvent(new MouseEvent('click'))
itemButtons[0].focus() // normally handled by browser
expect(document.activeElement).toEqual(itemButtons[0])
itemButtons[0].dispatchEvent(
new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })
)
expect(document.activeElement).toEqual(itemButtons[1])
}))
it('should toggle logical operator', fakeAsync(() => {
component.items = items
component.manyToOne = true
selectionModel.set(items[0].id, ToggleableItemState.Selected)
selectionModel.set(items[1].id, ToggleableItemState.Selected)
component.selectionModel = selectionModel
let changedResult: FilterableDropdownSelectionModel
component.selectionModelChange.subscribe(
(result) => (changedResult = result)
)
fixture.nativeElement
.querySelector('button')
.dispatchEvent(new MouseEvent('click')) // open
fixture.detectChanges()
tick(100)
expect(component.modifierToggleEnabled).toBeTruthy()
const operatorButtons: HTMLInputElement[] = Array.from(
(fixture.nativeElement as HTMLDivElement).querySelectorAll('input')
).filter((b) => ['and', 'or'].includes(b.value))
expect(operatorButtons[0].checked).toBeTruthy()
operatorButtons[1].dispatchEvent(new MouseEvent('click'))
fixture.detectChanges()
expect(selectionModel.logicalOperator).toEqual(LogicalOperator.Or)
expect(changedResult.logicalOperator).toEqual(LogicalOperator.Or)
}))
it('should toggle intersection include / exclude', fakeAsync(() => {
component.items = items
selectionModel.set(items[0].id, ToggleableItemState.Selected)
selectionModel.set(items[1].id, ToggleableItemState.Selected)
component.selectionModel = selectionModel
let changedResult: FilterableDropdownSelectionModel
component.selectionModelChange.subscribe(
(result) => (changedResult = result)
)
fixture.nativeElement
.querySelector('button')
.dispatchEvent(new MouseEvent('click')) // open
fixture.detectChanges()
tick(100)
expect(component.modifierToggleEnabled).toBeTruthy()
const intersectionButtons: HTMLInputElement[] = Array.from(
(fixture.nativeElement as HTMLDivElement).querySelectorAll('input')
).filter((b) => ['include', 'exclude'].includes(b.value))
expect(intersectionButtons[0].checked).toBeTruthy()
intersectionButtons[1].dispatchEvent(new MouseEvent('click'))
fixture.detectChanges()
expect(selectionModel.intersection).toEqual(Intersection.Exclude)
expect(changedResult.intersection).toEqual(Intersection.Exclude)
expect(changedResult.getSelectedItems()).toEqual([])
expect(changedResult.getExcludedItems()).toEqual(items)
}))
it('FilterableDropdownSelectionModel should sort items by state', () => {
component.items = items
component.selectionModel = selectionModel
selectionModel.toggle(items[1].id)
selectionModel.apply()
expect(selectionModel.itemsSorted).toEqual([nullItem, items[1], items[0]])
})
})

View File

@@ -96,7 +96,7 @@ export class FilterableDropdownSelectionModel {
toggle(id: number, fireEvent = true) {
let state = this.temporarySelectionStates.get(id)
if (
state == null ||
state == undefined ||
(state != ToggleableItemState.Selected &&
state != ToggleableItemState.Excluded)
) {

View File

@@ -0,0 +1,79 @@
import { ComponentFixture, TestBed } from '@angular/core/testing'
import {
ToggleableDropdownButtonComponent,
ToggleableItemState,
} from './toggleable-dropdown-button.component'
import { TagComponent } from '../../tag/tag.component'
import { PaperlessTag } from 'src/app/data/paperless-tag'
describe('ToggleableDropdownButtonComponent', () => {
let component: ToggleableDropdownButtonComponent
let fixture: ComponentFixture<ToggleableDropdownButtonComponent>
beforeEach(async () => {
TestBed.configureTestingModule({
declarations: [ToggleableDropdownButtonComponent, TagComponent],
providers: [],
imports: [],
}).compileComponents()
fixture = TestBed.createComponent(ToggleableDropdownButtonComponent)
component = fixture.componentInstance
})
it('should recognize a tag', () => {
component.item = {
id: 1,
name: 'Test Tag',
is_inbox_tag: false,
} as PaperlessTag
fixture.detectChanges()
expect(component.isTag).toBeTruthy()
})
it('should report toggled state', () => {
expect(component.isChecked()).toBeFalsy()
expect(component.isPartiallyChecked()).toBeFalsy()
expect(component.isExcluded()).toBeFalsy()
component.state = ToggleableItemState.Selected
expect(component.isChecked()).toBeTruthy()
expect(component.isPartiallyChecked()).toBeFalsy()
expect(component.isExcluded()).toBeFalsy()
component.state = ToggleableItemState.PartiallySelected
expect(component.isPartiallyChecked()).toBeTruthy()
expect(component.isChecked()).toBeFalsy()
expect(component.isExcluded()).toBeFalsy()
component.state = ToggleableItemState.Excluded
expect(component.isExcluded()).toBeTruthy()
expect(component.isChecked()).toBeFalsy()
expect(component.isPartiallyChecked()).toBeFalsy()
})
it('should emit exclude event when selected and then toggled', () => {
let excludeResult
let toggleResult
component.state = ToggleableItemState.Selected
component.exclude.subscribe(() => (excludeResult = true))
component.toggle.subscribe(() => (toggleResult = true))
const button = fixture.nativeElement.querySelector('button')
button.dispatchEvent(new MouseEvent('click'))
expect(excludeResult).toBeTruthy()
expect(toggleResult).toBeFalsy()
})
it('should emit toggle event when not selected and then toggled', () => {
let excludeResult
let toggleResult
component.state = ToggleableItemState.Excluded
component.exclude.subscribe(() => (excludeResult = true))
component.toggle.subscribe(() => (toggleResult = true))
const button = fixture.nativeElement.querySelector('button')
button.dispatchEvent(new MouseEvent('click'))
expect(excludeResult).toBeFalsy()
expect(toggleResult).toBeTruthy()
})
})

View File

@@ -0,0 +1,55 @@
import { Component } from '@angular/core'
import { AbstractInputComponent } from './abstract-input'
import { ComponentFixture, TestBed } from '@angular/core/testing'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
@Component({
template: `
<div>
<input
#inputField
type="text"
class="form-control"
[class.is-invalid]="error"
[id]="inputId"
[(ngModel)]="value"
(change)="onChange(value)"
[disabled]="disabled"
/>
</div>
`,
})
class TestComponent extends AbstractInputComponent<string> {
constructor() {
super()
}
}
describe(`AbstractInputComponent`, () => {
let component: TestComponent
let fixture: ComponentFixture<TestComponent>
beforeEach(async () => {
TestBed.configureTestingModule({
declarations: [TestComponent],
providers: [],
imports: [FormsModule, ReactiveFormsModule],
}).compileComponents()
fixture = TestBed.createComponent(TestComponent)
component = fixture.componentInstance
fixture.detectChanges()
})
it('should assign uuid', () => {
component.ngOnInit()
expect(component.inputId).not.toBeUndefined()
})
it('should support focus', () => {
const focusSpy = jest.spyOn(component.inputField.nativeElement, 'focus')
component.focus()
expect(focusSpy).toHaveBeenCalled()
})
})

View File

@@ -1,5 +1,5 @@
<div class="mb-3 form-check">
<input type="checkbox" class="form-check-input" [id]="inputId" [(ngModel)]="value" (change)="onChange(value)" (blur)="onTouched()" [disabled]="disabled">
<input #inputField type="checkbox" class="form-check-input" [id]="inputId" [(ngModel)]="value" (change)="onChange(value)" (blur)="onTouched()" [disabled]="disabled">
<label class="form-check-label" [for]="inputId">{{title}}</label>
<div *ngIf="hint" class="form-text text-muted">{{hint}}</div>
</div>

View File

@@ -0,0 +1,39 @@
import { ComponentFixture, TestBed } from '@angular/core/testing'
import { CheckComponent } from './check.component'
import {
FormsModule,
NG_VALUE_ACCESSOR,
ReactiveFormsModule,
} from '@angular/forms'
describe('CheckComponent', () => {
let component: CheckComponent
let fixture: ComponentFixture<CheckComponent>
let input: HTMLInputElement
beforeEach(async () => {
TestBed.configureTestingModule({
declarations: [CheckComponent],
providers: [],
imports: [FormsModule, ReactiveFormsModule],
}).compileComponents()
fixture = TestBed.createComponent(CheckComponent)
fixture.debugElement.injector.get(NG_VALUE_ACCESSOR)
component = fixture.componentInstance
fixture.detectChanges()
input = component.inputField.nativeElement
})
it('should support use of checkbox', () => {
input.checked = true
input.dispatchEvent(new Event('change'))
fixture.detectChanges()
expect(component.value).toBeTruthy()
input.checked = false
input.dispatchEvent(new Event('change'))
fixture.detectChanges()
expect(component.value).toBeFalsy()
})
})

View File

@@ -1,6 +1,5 @@
import { Component, forwardRef, Input, OnInit } from '@angular/core'
import { ControlValueAccessor, NG_VALUE_ACCESSOR } from '@angular/forms'
import { v4 as uuidv4 } from 'uuid'
import { Component, forwardRef } from '@angular/core'
import { NG_VALUE_ACCESSOR } from '@angular/forms'
import { AbstractInputComponent } from '../abstract-input'
@Component({

View File

@@ -11,7 +11,7 @@
</ng-template>
<input class="form-control" [class.is-invalid]="error" [id]="inputId" [(ngModel)]="value" (change)="onChange(value)" [autoClose]="'outside'" [ngbPopover]="popContent" placement="bottom" popoverClass="shadow">
<input #inputField class="form-control" [class.is-invalid]="error" [id]="inputId" [(ngModel)]="value" (change)="onChange(value)" [autoClose]="'outside'" [ngbPopover]="popContent" placement="bottom" popoverClass="shadow">
<button class="btn btn-outline-secondary" type="button" (click)="randomize()">
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" fill="currentColor" class="bi bi-dice-5" viewBox="0 0 16 16">

View File

@@ -0,0 +1,72 @@
import { ComponentFixture, TestBed } from '@angular/core/testing'
import {
FormsModule,
NG_VALUE_ACCESSOR,
ReactiveFormsModule,
} from '@angular/forms'
import { ColorComponent } from './color.component'
import { NgbPopoverModule } from '@ng-bootstrap/ng-bootstrap'
import { ColorSliderModule } from 'ngx-color/slider'
describe('ColorComponent', () => {
let component: ColorComponent
let fixture: ComponentFixture<ColorComponent>
let input: HTMLInputElement
beforeEach(async () => {
TestBed.configureTestingModule({
declarations: [ColorComponent],
providers: [],
imports: [
FormsModule,
ReactiveFormsModule,
NgbPopoverModule,
ColorSliderModule,
],
}).compileComponents()
fixture = TestBed.createComponent(ColorComponent)
fixture.debugElement.injector.get(NG_VALUE_ACCESSOR)
component = fixture.componentInstance
fixture.detectChanges()
input = component.inputField.nativeElement
})
it('should support use of input', () => {
input.value = '#ff0000'
component.colorChanged(input.value)
fixture.detectChanges()
expect(component.value).toEqual('#ff0000')
})
it('should set swatch color', () => {
const swatch: HTMLSpanElement = fixture.nativeElement.querySelector(
'span.input-group-text'
)
expect(swatch.style.backgroundColor).toEqual('')
component.value = '#ff0000'
fixture.detectChanges()
expect(swatch.style.backgroundColor).toEqual('rgb(255, 0, 0)')
})
it('should show color slider popover', () => {
component.value = '#ff0000'
input.dispatchEvent(new MouseEvent('click'))
fixture.detectChanges()
expect(
fixture.nativeElement.querySelector('ngb-popover-window')
).not.toBeUndefined()
expect(
fixture.nativeElement.querySelector('color-slider')
).not.toBeUndefined()
fixture.nativeElement
.querySelector('color-slider')
.dispatchEvent(new Event('change'))
})
it('should allow randomize color and update value', () => {
expect(component.value).toBeUndefined()
component.randomize()
expect(component.value).not.toBeUndefined()
})
})

View File

@@ -1,7 +1,7 @@
<div class="mb-3">
<label class="form-label" [for]="inputId">{{title}}</label>
<div class="input-group" [class.is-invalid]="error">
<input class="form-control" [class.is-invalid]="error" [placeholder]="placeholder" [id]="inputId" maxlength="10"
<input #inputField class="form-control" [class.is-invalid]="error" [placeholder]="placeholder" [id]="inputId" maxlength="10"
(dateSelect)="onChange(value)" (change)="onChange(value)" (keypress)="onKeyPress($event)" (paste)="onPaste($event)"
name="dp" [(ngModel)]="value" ngbDatepicker #datePicker="ngbDatepicker" #datePickerContent="ngModel" [disabled]="disabled">
<button class="btn btn-outline-secondary calendar" (click)="datePicker.toggle()" type="button" [disabled]="disabled">
@@ -9,7 +9,7 @@
<path d="M3.5 0a.5.5 0 0 1 .5.5V1h8V.5a.5.5 0 0 1 1 0V1h1a2 2 0 0 1 2 2v11a2 2 0 0 1-2 2H2a2 2 0 0 1-2-2V3a2 2 0 0 1 2-2h1V.5a.5.5 0 0 1 .5-.5zM1 4v10a1 1 0 0 0 1 1h12a1 1 0 0 0 1-1V4H1z"/>
</svg>
</button>
<button *ngIf="showFilter" class="btn btn-outline-secondary" type="button" (click)="onFilterDocuments()" [disabled]="this.value === null" i18n-title title="Filter documents with this {{title}}">
<button *ngIf="showFilter" class="btn btn-outline-secondary" type="button" (click)="onFilterDocuments()" [disabled]="this.value === null" title="{{ fitlerButtonTitle }}">
<svg class="buttonicon" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#filter" />
</svg>

View File

@@ -0,0 +1,103 @@
import { ComponentFixture, TestBed } from '@angular/core/testing'
import {
FormsModule,
NG_VALUE_ACCESSOR,
ReactiveFormsModule,
} from '@angular/forms'
import { DateComponent } from './date.component'
import { HttpClientTestingModule } from '@angular/common/http/testing'
import {
NgbDateParserFormatter,
NgbDatepickerModule,
} from '@ng-bootstrap/ng-bootstrap'
import { RouterTestingModule } from '@angular/router/testing'
import { LocalizedDateParserFormatter } from 'src/app/utils/ngb-date-parser-formatter'
describe('DateComponent', () => {
let component: DateComponent
let fixture: ComponentFixture<DateComponent>
let input: HTMLInputElement
beforeEach(async () => {
TestBed.configureTestingModule({
declarations: [DateComponent],
providers: [
{
provide: NgbDateParserFormatter,
useClass: LocalizedDateParserFormatter,
},
],
imports: [
FormsModule,
ReactiveFormsModule,
HttpClientTestingModule,
NgbDatepickerModule,
RouterTestingModule,
],
}).compileComponents()
fixture = TestBed.createComponent(DateComponent)
fixture.debugElement.injector.get(NG_VALUE_ACCESSOR)
component = fixture.componentInstance
fixture.detectChanges()
input = component.inputField.nativeElement
})
it('should support use of input field', () => {
input.value = '5/14/20'
input.dispatchEvent(new Event('change'))
fixture.detectChanges()
expect(component.value).toEqual({ day: 14, month: 5, year: 2020 })
})
it('should use localzed placeholder from settings', () => {
component.ngOnInit()
expect(component.placeholder).toEqual('mm/dd/yyyy')
})
it('should support suggestions', () => {
expect(component.value).toBeUndefined()
component.suggestions = ['2023-05-31', '2014-05-14']
fixture.detectChanges()
const suggestionAnchor: HTMLAnchorElement =
fixture.nativeElement.querySelector('a')
suggestionAnchor.click()
expect(component.value).toEqual({ day: 31, month: 5, year: 2023 })
})
it('should limit keyboard events', () => {
let event: KeyboardEvent = new KeyboardEvent('keypress', {
key: '9',
})
let eventSpy = jest.spyOn(event, 'preventDefault')
input.dispatchEvent(event)
expect(eventSpy).not.toHaveBeenCalled()
event = new KeyboardEvent('keypress', {
key: '{',
})
eventSpy = jest.spyOn(event, 'preventDefault')
input.dispatchEvent(event)
expect(eventSpy).toHaveBeenCalled()
})
it('should support paste', () => {
expect(component.value).toBeUndefined()
const date = '5/4/20'
const clipboardData = {
dropEffect: null,
effectAllowed: null,
files: null,
items: null,
types: null,
clearData: null,
getData: () => date,
setData: null,
setDragImage: null,
}
const event = new Event('paste')
event['clipboardData'] = clipboardData
input.dispatchEvent(event)
expect(component.value).toEqual({ day: 4, month: 5, year: 2020 })
})
})

View File

@@ -98,4 +98,8 @@ export class DateComponent
onFilterDocuments() {
this.filterDocuments.emit([this.ngbDateParserFormatter.parse(this.value)])
}
get filterButtonTitle() {
return $localize`Filter documents with this ${this.title}`
}
}

View File

@@ -1,7 +1,7 @@
<div class="mb-3">
<label class="form-label" [for]="inputId">{{title}}</label>
<div class="input-group" [class.is-invalid]="error">
<input type="number" class="form-control" [id]="inputId" [(ngModel)]="value" (change)="onChange(value)" [class.is-invalid]="error" [disabled]="disabled">
<input #inputField type="number" class="form-control" [id]="inputId" [(ngModel)]="value" (change)="onChange(value)" [class.is-invalid]="error" [disabled]="disabled">
<button *ngIf="showAdd" class="btn btn-outline-secondary" type="button" id="button-addon1" (click)="nextAsn()" [disabled]="disabled">+1</button>
</div>
<div class="invalid-feedback">

View File

@@ -0,0 +1,79 @@
import { ComponentFixture, TestBed } from '@angular/core/testing'
import {
FormsModule,
NG_VALUE_ACCESSOR,
ReactiveFormsModule,
} from '@angular/forms'
import { NumberComponent } from './number.component'
import { DocumentService } from 'src/app/services/rest/document.service'
import { HttpClientTestingModule } from '@angular/common/http/testing'
import { of } from 'rxjs'
describe('NumberComponent', () => {
let component: NumberComponent
let fixture: ComponentFixture<NumberComponent>
let input: HTMLInputElement
let documentService: DocumentService
beforeEach(async () => {
TestBed.configureTestingModule({
declarations: [NumberComponent],
providers: [DocumentService],
imports: [FormsModule, ReactiveFormsModule, HttpClientTestingModule],
}).compileComponents()
fixture = TestBed.createComponent(NumberComponent)
fixture.debugElement.injector.get(NG_VALUE_ACCESSOR)
component = fixture.componentInstance
documentService = TestBed.inject(DocumentService)
fixture.detectChanges()
input = component.inputField.nativeElement
})
// TODO: why doesnt this work?
// it('should support use of input field', () => {
// expect(component.value).toBeUndefined()
// input.stepUp()
// console.log(input.value);
// input.dispatchEvent(new Event('change'))
// fixture.detectChanges()
// expect(component.value).toEqual('3')
// })
it('should support +1 ASN', () => {
const listAllSpy = jest.spyOn(documentService, 'listFiltered')
listAllSpy
.mockReturnValueOnce(
of({
count: 1,
all: [1],
results: [
{
id: 1,
archive_serial_number: 1000,
},
],
})
)
.mockReturnValueOnce(
of({
count: 0,
all: [],
results: [],
})
)
expect(component.value).toBeUndefined()
component.nextAsn()
expect(component.value).toEqual(1001)
// this time results are empty
component.value = undefined
component.nextAsn()
expect(component.value).toEqual(1)
component.value = 1002
component.nextAsn()
expect(component.value).toEqual(1002)
})
})

View File

@@ -0,0 +1,36 @@
import { ComponentFixture, TestBed } from '@angular/core/testing'
import {
FormsModule,
ReactiveFormsModule,
NG_VALUE_ACCESSOR,
} from '@angular/forms'
import { PasswordComponent } from './password.component'
describe('PasswordComponent', () => {
let component: PasswordComponent
let fixture: ComponentFixture<PasswordComponent>
let input: HTMLInputElement
beforeEach(async () => {
TestBed.configureTestingModule({
declarations: [PasswordComponent],
providers: [],
imports: [FormsModule, ReactiveFormsModule],
}).compileComponents()
fixture = TestBed.createComponent(PasswordComponent)
fixture.debugElement.injector.get(NG_VALUE_ACCESSOR)
component = fixture.componentInstance
fixture.detectChanges()
input = component.inputField.nativeElement
})
it('should support use of input field', () => {
expect(component.value).toBeUndefined()
// TODO: why doesnt this work?
// input.value = 'foo'
// input.dispatchEvent(new Event('change'))
// fixture.detectChanges()
// expect(component.value).toEqual('foo')
})
})

View File

@@ -0,0 +1,66 @@
import { ComponentFixture, TestBed } from '@angular/core/testing'
import {
FormsModule,
ReactiveFormsModule,
NG_VALUE_ACCESSOR,
} from '@angular/forms'
import { PermissionsFormComponent } from './permissions-form.component'
import { SelectComponent } from '../../select/select.component'
import { NgbAccordionModule } from '@ng-bootstrap/ng-bootstrap'
import { PermissionsGroupComponent } from '../permissions-group/permissions-group.component'
import { PermissionsUserComponent } from '../permissions-user/permissions-user.component'
import { HttpClientTestingModule } from '@angular/common/http/testing'
import { NgSelectModule } from '@ng-select/ng-select'
describe('PermissionsFormComponent', () => {
let component: PermissionsFormComponent
let fixture: ComponentFixture<PermissionsFormComponent>
beforeEach(async () => {
TestBed.configureTestingModule({
declarations: [
PermissionsFormComponent,
SelectComponent,
PermissionsGroupComponent,
PermissionsUserComponent,
],
providers: [],
imports: [
FormsModule,
ReactiveFormsModule,
NgbAccordionModule,
HttpClientTestingModule,
NgSelectModule,
],
}).compileComponents()
fixture = TestBed.createComponent(PermissionsFormComponent)
fixture.debugElement.injector.get(NG_VALUE_ACCESSOR)
component = fixture.componentInstance
fixture.detectChanges()
})
it('should support use of select for owner', () => {
const changeSpy = jest.spyOn(component, 'onChange')
component.ngOnInit()
component.users = [
{
id: 2,
username: 'foo',
},
{
id: 3,
username: 'bar',
},
]
component.form.get('owner').patchValue(2)
fixture.detectChanges()
expect(changeSpy).toHaveBeenCalledWith({
owner: 2,
set_permissions: {
view: { users: [], groups: [] },
change: { users: [], groups: [] },
},
})
})
})

View File

@@ -0,0 +1,59 @@
import { ComponentFixture, TestBed } from '@angular/core/testing'
import {
FormsModule,
NG_VALUE_ACCESSOR,
ReactiveFormsModule,
} from '@angular/forms'
import { PermissionsGroupComponent } from './permissions-group.component'
import { HttpClientTestingModule } from '@angular/common/http/testing'
import { NgSelectModule } from '@ng-select/ng-select'
import { GroupService } from 'src/app/services/rest/group.service'
import { of } from 'rxjs'
describe('PermissionsGroupComponent', () => {
let component: PermissionsGroupComponent
let fixture: ComponentFixture<PermissionsGroupComponent>
let groupService: GroupService
let groupServiceSpy
beforeEach(async () => {
TestBed.configureTestingModule({
declarations: [PermissionsGroupComponent],
providers: [GroupService],
imports: [
FormsModule,
ReactiveFormsModule,
HttpClientTestingModule,
NgSelectModule,
],
}).compileComponents()
groupService = TestBed.inject(GroupService)
groupServiceSpy = jest.spyOn(groupService, 'listAll').mockReturnValue(
of({
count: 2,
all: [2, 3],
results: [
{
id: 2,
name: 'Group 2',
},
{
id: 3,
name: 'Group 3',
},
],
})
)
fixture = TestBed.createComponent(PermissionsGroupComponent)
fixture.debugElement.injector.get(NG_VALUE_ACCESSOR)
component = fixture.componentInstance
fixture.detectChanges()
})
it('should get groups, support use of select', () => {
component.writeValue({ id: 2, name: 'Group 2' })
expect(component.value).toEqual({ id: 2, name: 'Group 2' })
expect(groupServiceSpy).toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,60 @@
import { ComponentFixture, TestBed } from '@angular/core/testing'
import {
FormsModule,
NG_VALUE_ACCESSOR,
ReactiveFormsModule,
} from '@angular/forms'
import { PermissionsUserComponent } from './permissions-user.component'
import { HttpClientTestingModule } from '@angular/common/http/testing'
import { NgSelectModule } from '@ng-select/ng-select'
import { GroupService } from 'src/app/services/rest/group.service'
import { of } from 'rxjs'
import { UserService } from 'src/app/services/rest/user.service'
describe('PermissionsUserComponent', () => {
let component: PermissionsUserComponent
let fixture: ComponentFixture<PermissionsUserComponent>
let userService: UserService
let userServiceSpy
beforeEach(async () => {
TestBed.configureTestingModule({
declarations: [PermissionsUserComponent],
providers: [UserService],
imports: [
FormsModule,
ReactiveFormsModule,
HttpClientTestingModule,
NgSelectModule,
],
}).compileComponents()
userService = TestBed.inject(UserService)
userServiceSpy = jest.spyOn(userService, 'listAll').mockReturnValue(
of({
count: 2,
all: [2, 3],
results: [
{
id: 2,
name: 'User 2',
},
{
id: 3,
name: 'User 3',
},
],
})
)
fixture = TestBed.createComponent(PermissionsUserComponent)
fixture.debugElement.injector.get(NG_VALUE_ACCESSOR)
component = fixture.componentInstance
fixture.detectChanges()
})
it('should get users, support use of select', () => {
component.writeValue({ id: 2, name: 'User 2' })
expect(component.value).toEqual({ id: 2, name: 'User 2' })
expect(userServiceSpy).toHaveBeenCalled()
})
})

View File

@@ -26,7 +26,7 @@
<use xlink:href="assets/bootstrap-icons.svg#plus" />
</svg>
</button>
<button *ngIf="showFilter" class="btn btn-outline-secondary" type="button" (click)="onFilterDocuments()" [disabled]="isPrivate || this.value === null" i18n-title title="Filter documents with this {{title}}">
<button *ngIf="showFilter" class="btn btn-outline-secondary" type="button" (click)="onFilterDocuments()" [disabled]="isPrivate || this.value === null" title="{{ filterButtonTitle }}">
<svg class="buttonicon" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#filter" />
</svg>

View File

@@ -0,0 +1,121 @@
import {
ComponentFixture,
TestBed,
fakeAsync,
tick,
} from '@angular/core/testing'
import {
FormsModule,
ReactiveFormsModule,
NG_VALUE_ACCESSOR,
} from '@angular/forms'
import { SelectComponent } from './select.component'
import { PaperlessTag } from 'src/app/data/paperless-tag'
import {
DEFAULT_MATCHING_ALGORITHM,
MATCH_ALL,
} from 'src/app/data/matching-model'
import { NgSelectModule } from '@ng-select/ng-select'
import { RouterTestingModule } from '@angular/router/testing'
const items: PaperlessTag[] = [
{
id: 1,
name: 'Tag1',
is_inbox_tag: false,
matching_algorithm: DEFAULT_MATCHING_ALGORITHM,
},
{
id: 2,
name: 'Tag2',
is_inbox_tag: true,
matching_algorithm: MATCH_ALL,
match: 'str',
},
{
id: 10,
name: 'Tag10',
is_inbox_tag: false,
matching_algorithm: DEFAULT_MATCHING_ALGORITHM,
},
]
describe('SelectComponent', () => {
let component: SelectComponent
let fixture: ComponentFixture<SelectComponent>
let input: HTMLInputElement
beforeEach(async () => {
TestBed.configureTestingModule({
declarations: [SelectComponent],
providers: [],
imports: [
FormsModule,
ReactiveFormsModule,
NgSelectModule,
RouterTestingModule,
],
}).compileComponents()
fixture = TestBed.createComponent(SelectComponent)
fixture.debugElement.injector.get(NG_VALUE_ACCESSOR)
component = fixture.componentInstance
fixture.detectChanges()
})
it('should support private items', () => {
component.value = 3
component.items = items
expect(component.items).toContainEqual({
id: 3,
name: 'Private',
private: true,
})
component.checkForPrivateItems([4, 5])
expect(component.items).toContainEqual({
id: 4,
name: 'Private',
private: true,
})
expect(component.items).toContainEqual({
id: 5,
name: 'Private',
private: true,
})
})
it('should support suggestions', () => {
expect(component.value).toBeUndefined()
component.items = items
component.suggestions = [1, 2]
fixture.detectChanges()
const suggestionAnchor: HTMLAnchorElement =
fixture.nativeElement.querySelector('a')
suggestionAnchor.click()
expect(component.value).toEqual(1)
})
it('should support create new and emit the value', () => {
expect(component.allowCreateNew).toBeFalsy()
component.items = items
let createNewVal
component.createNew.subscribe((v) => (createNewVal = v))
expect(component.allowCreateNew).toBeTruthy()
component.onSearch({ term: 'foo' })
component.addItem(undefined)
expect(createNewVal).toEqual('foo')
component.addItem('bar')
expect(createNewVal).toEqual('bar')
component.onSearch({ term: 'baz' })
component.clickNew()
expect(createNewVal).toEqual('baz')
})
it('should clear search term on blur after delay', fakeAsync(() => {
const clearSpy = jest.spyOn(component, 'clearLastSearchTerm')
component.onBlur()
tick(3000)
expect(clearSpy).toHaveBeenCalled()
}))
})

View File

@@ -144,4 +144,8 @@ export class SelectComponent extends AbstractInputComponent<number> {
onFilterDocuments() {
this.filterDocuments.emit([this.items.find((i) => i.id === this.value)])
}
get filterButtonTitle() {
return $localize`Filter documents with this ${this.title}`
}
}

View File

@@ -0,0 +1,140 @@
import {
ComponentFixture,
TestBed,
fakeAsync,
tick,
} from '@angular/core/testing'
import {
FormsModule,
ReactiveFormsModule,
NG_VALUE_ACCESSOR,
} from '@angular/forms'
import { TagsComponent } from './tags.component'
import { PaperlessTag } from 'src/app/data/paperless-tag'
import {
DEFAULT_MATCHING_ALGORITHM,
MATCH_ALL,
} from 'src/app/data/matching-model'
import { NgSelectModule } from '@ng-select/ng-select'
import { RouterTestingModule } from '@angular/router/testing'
import { HttpClientTestingModule } from '@angular/common/http/testing'
import { of } from 'rxjs'
import { TagService } from 'src/app/services/rest/tag.service'
import {
NgbModal,
NgbModalModule,
NgbModalRef,
} from '@ng-bootstrap/ng-bootstrap'
const tags: PaperlessTag[] = [
{
id: 1,
name: 'Tag1',
is_inbox_tag: false,
matching_algorithm: DEFAULT_MATCHING_ALGORITHM,
},
{
id: 2,
name: 'Tag2',
is_inbox_tag: true,
matching_algorithm: MATCH_ALL,
match: 'str',
},
{
id: 10,
name: 'Tag10',
is_inbox_tag: false,
matching_algorithm: DEFAULT_MATCHING_ALGORITHM,
},
]
describe('TagsComponent', () => {
let component: TagsComponent
let fixture: ComponentFixture<TagsComponent>
let input: HTMLInputElement
let modalService: NgbModal
beforeEach(async () => {
TestBed.configureTestingModule({
declarations: [TagsComponent],
providers: [
{
provide: TagService,
useValue: {
listAll: () => of(tags),
},
},
],
imports: [
FormsModule,
ReactiveFormsModule,
NgSelectModule,
RouterTestingModule,
HttpClientTestingModule,
NgbModalModule,
],
}).compileComponents()
modalService = TestBed.inject(NgbModal)
fixture = TestBed.createComponent(TagsComponent)
fixture.debugElement.injector.get(NG_VALUE_ACCESSOR)
component = fixture.componentInstance
fixture.detectChanges()
window.PointerEvent = MouseEvent as any
})
it('should support suggestions', () => {
expect(component.value).toBeUndefined()
component.value = []
component.tags = tags
component.suggestions = [1, 2]
fixture.detectChanges()
const suggestionAnchor: HTMLAnchorElement =
fixture.nativeElement.querySelector('a')
suggestionAnchor.click()
expect(component.value).toEqual([1])
})
it('should support create new and open a modal', () => {
let activeInstances: NgbModalRef[]
modalService.activeInstances.subscribe((v) => (activeInstances = v))
component.createTag('foo')
expect(modalService.hasOpenModals()).toBeTruthy()
expect(activeInstances[0].componentInstance.object.name).toEqual('foo')
})
it('should support create new using last search term and open a modal', () => {
let activeInstances: NgbModalRef[]
modalService.activeInstances.subscribe((v) => (activeInstances = v))
component.onSearch({ term: 'bar' })
component.createTag()
expect(modalService.hasOpenModals()).toBeTruthy()
expect(activeInstances[0].componentInstance.object.name).toEqual('bar')
})
it('should clear search term on blur after delay', fakeAsync(() => {
const clearSpy = jest.spyOn(component, 'clearLastSearchTerm')
component.onBlur()
tick(3000)
expect(clearSpy).toHaveBeenCalled()
}))
it('support remove tags', () => {
component.tags = tags
component.value = [1, 2]
component.removeTag(new PointerEvent('point'), 2)
expect(component.value).toEqual([1])
component.disabled = true
component.removeTag(new PointerEvent('point'), 1)
expect(component.value).toEqual([1])
})
it('should get tags', () => {
expect(component.getTag(2)).toBeNull()
component.tags = tags
expect(component.getTag(2)).toEqual(tags[1])
expect(component.getTag(4)).toBeUndefined()
})
})

View File

@@ -11,6 +11,7 @@ import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
import { PaperlessTag } from 'src/app/data/paperless-tag'
import { TagEditDialogComponent } from '../../edit-dialog/tag-edit-dialog/tag-edit-dialog.component'
import { TagService } from 'src/app/services/rest/tag.service'
import { EditDialogMode } from '../../edit-dialog/edit-dialog.component'
@Component({
providers: [
@@ -105,7 +106,7 @@ export class TagsComponent implements OnInit, ControlValueAccessor {
var modal = this.modalService.open(TagEditDialogComponent, {
backdrop: 'static',
})
modal.componentInstance.dialogMode = 'create'
modal.componentInstance.dialogMode = EditDialogMode.CREATE
if (name) modal.componentInstance.object = { name: name }
else if (this._lastSearchTerm)
modal.componentInstance.object = { name: this._lastSearchTerm }

View File

@@ -0,0 +1,36 @@
import { ComponentFixture, TestBed } from '@angular/core/testing'
import {
FormsModule,
ReactiveFormsModule,
NG_VALUE_ACCESSOR,
} from '@angular/forms'
import { TextComponent } from './text.component'
describe('TextComponent', () => {
let component: TextComponent
let fixture: ComponentFixture<TextComponent>
let input: HTMLInputElement
beforeEach(async () => {
TestBed.configureTestingModule({
declarations: [TextComponent],
providers: [],
imports: [FormsModule, ReactiveFormsModule],
}).compileComponents()
fixture = TestBed.createComponent(TextComponent)
fixture.debugElement.injector.get(NG_VALUE_ACCESSOR)
component = fixture.componentInstance
fixture.detectChanges()
input = component.inputField.nativeElement
})
it('should support use of input field', () => {
expect(component.value).toBeUndefined()
// TODO: why doesnt this work?
// input.value = 'foo'
// input.dispatchEvent(new Event('change'))
// fixture.detectChanges()
// expect(component.value).toEqual('foo')
})
})

View File

@@ -0,0 +1,36 @@
import { ComponentFixture, TestBed } from '@angular/core/testing'
import { Title } from '@angular/platform-browser'
import { PageHeaderComponent } from './page-header.component'
import { environment } from 'src/environments/environment'
describe('PageHeaderComponent', () => {
let component: PageHeaderComponent
let fixture: ComponentFixture<PageHeaderComponent>
let titleService: Title
beforeEach(async () => {
TestBed.configureTestingModule({
declarations: [PageHeaderComponent],
providers: [],
imports: [],
}).compileComponents()
titleService = TestBed.inject(Title)
fixture = TestBed.createComponent(PageHeaderComponent)
component = fixture.componentInstance
fixture.detectChanges()
})
it('should display title + subtitle', () => {
component.title = 'Foo'
component.subTitle = 'Bar'
fixture.detectChanges()
expect(fixture.nativeElement.textContent).toContain('FooBar')
})
it('should set html title', () => {
const titleSpy = jest.spyOn(titleService, 'setTitle')
component.title = 'Foo Bar'
expect(titleSpy).toHaveBeenCalledWith(`Foo Bar - ${environment.appTitle}`)
})
})

View File

@@ -0,0 +1,90 @@
import { ComponentFixture, TestBed } from '@angular/core/testing'
import { PermissionsDialogComponent } from './permissions-dialog.component'
import { NgbActiveModal, NgbModule } from '@ng-bootstrap/ng-bootstrap'
import { HttpClientTestingModule } from '@angular/common/http/testing'
import { SafeHtmlPipe } from 'src/app/pipes/safehtml.pipe'
import { UserService } from 'src/app/services/rest/user.service'
import { of } from 'rxjs'
import { PermissionsFormComponent } from '../input/permissions/permissions-form/permissions-form.component'
import { SelectComponent } from '../input/select/select.component'
import { NgSelectModule } from '@ng-select/ng-select'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { PermissionsUserComponent } from '../input/permissions/permissions-user/permissions-user.component'
import { PermissionsGroupComponent } from '../input/permissions/permissions-group/permissions-group.component'
const set_permissions = {
owner: 10,
set_permissions: {
view: {
users: [1],
groups: [],
},
edit: {
users: [1],
groups: [],
},
},
}
describe('PermissionsDialogComponent', () => {
let component: PermissionsDialogComponent
let fixture: ComponentFixture<PermissionsDialogComponent>
let modal: NgbActiveModal
beforeEach(async () => {
TestBed.configureTestingModule({
declarations: [
PermissionsDialogComponent,
SafeHtmlPipe,
SelectComponent,
PermissionsFormComponent,
PermissionsUserComponent,
PermissionsGroupComponent,
],
providers: [
NgbActiveModal,
{
provide: UserService,
useValue: {
listAll: () =>
of({
results: [
{
id: 1,
username: 'user1',
},
{
id: 10,
username: 'user10',
},
],
}),
},
},
],
imports: [
HttpClientTestingModule,
NgSelectModule,
FormsModule,
ReactiveFormsModule,
NgbModule,
],
}).compileComponents()
modal = TestBed.inject(NgbActiveModal)
fixture = TestBed.createComponent(PermissionsDialogComponent)
component = fixture.componentInstance
fixture.detectChanges()
})
it('should return permissions', () => {
component.form.get('permissions_form').setValue(set_permissions)
expect(component.permissions).toEqual(set_permissions)
})
it('should close modal on cancel', () => {
const closeSpy = jest.spyOn(modal, 'close')
component.cancelClicked()
expect(closeSpy).toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,157 @@
import { HttpClientTestingModule } from '@angular/common/http/testing'
import { ComponentFixture, TestBed } from '@angular/core/testing'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { NgbModule } from '@ng-bootstrap/ng-bootstrap'
import { NgSelectModule } from '@ng-select/ng-select'
import { of } from 'rxjs'
import { PermissionsService } from 'src/app/services/permissions.service'
import { UserService } from 'src/app/services/rest/user.service'
import {
OwnerFilterType,
PermissionsFilterDropdownComponent,
PermissionsSelectionModel,
} from './permissions-filter-dropdown.component'
import { ClearableBadgeComponent } from '../clearable-badge/clearable-badge.component'
import { SettingsService } from 'src/app/services/settings.service'
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
const currentUserID = 13
describe('PermissionsFilterDropdownComponent', () => {
let component: PermissionsFilterDropdownComponent
let fixture: ComponentFixture<PermissionsFilterDropdownComponent>
let ownerFilterSetResult: PermissionsSelectionModel
beforeEach(async () => {
TestBed.configureTestingModule({
declarations: [
PermissionsFilterDropdownComponent,
ClearableBadgeComponent,
IfPermissionsDirective,
],
providers: [
{
provide: UserService,
useValue: {
listAll: () =>
of({
results: [
{
id: 1,
username: 'user1',
},
{
id: 10,
username: 'user10',
},
],
}),
},
},
{
provide: PermissionsService,
useValue: {
currentUserCan: () => true,
},
},
{
provide: SettingsService,
useValue: {
currentUser: {
id: currentUserID,
},
},
},
],
imports: [
HttpClientTestingModule,
NgSelectModule,
FormsModule,
ReactiveFormsModule,
NgbModule,
],
}).compileComponents()
fixture = TestBed.createComponent(PermissionsFilterDropdownComponent)
component = fixture.componentInstance
component.ownerFilterSet.subscribe(
(model) => (ownerFilterSetResult = model)
)
component.selectionModel = new PermissionsSelectionModel()
fixture.detectChanges()
})
it('should report is active', () => {
component.setFilter(OwnerFilterType.NONE)
expect(component.isActive).toBeFalsy()
component.setFilter(OwnerFilterType.OTHERS)
expect(component.isActive).toBeTruthy()
component.setFilter(OwnerFilterType.NONE)
component.selectionModel.hideUnowned = true
expect(component.isActive).toBeTruthy()
})
it('should support reset', () => {
component.setFilter(OwnerFilterType.OTHERS)
expect(component.selectionModel.ownerFilter).not.toEqual(
OwnerFilterType.NONE
)
component.reset()
expect(component.selectionModel.ownerFilter).toEqual(OwnerFilterType.NONE)
})
it('should toggle owner filter type when users selected', () => {
component.selectionModel.ownerFilter = OwnerFilterType.NONE
// this would normally be done by select component
component.selectionModel.includeUsers = [12]
component.onUserSelect()
expect(component.selectionModel.ownerFilter).toEqual(OwnerFilterType.OTHERS)
// this would normally be done by select component
component.selectionModel.includeUsers = null
component.onUserSelect()
expect(component.selectionModel.ownerFilter).toEqual(OwnerFilterType.NONE)
})
it('should emit a selection model depending on the type of owner filter set', () => {
component.selectionModel.ownerFilter = OwnerFilterType.NONE
component.setFilter(OwnerFilterType.SELF)
expect(ownerFilterSetResult).toEqual({
excludeUsers: [],
hideUnowned: false,
includeUsers: [],
ownerFilter: OwnerFilterType.SELF,
userID: currentUserID,
})
component.setFilter(OwnerFilterType.NOT_SELF)
expect(ownerFilterSetResult).toEqual({
excludeUsers: [currentUserID],
hideUnowned: false,
includeUsers: [],
ownerFilter: OwnerFilterType.NOT_SELF,
userID: null,
})
component.setFilter(OwnerFilterType.NONE)
expect(ownerFilterSetResult).toEqual({
excludeUsers: [],
hideUnowned: false,
includeUsers: [],
ownerFilter: OwnerFilterType.NONE,
userID: null,
})
component.setFilter(OwnerFilterType.UNOWNED)
expect(ownerFilterSetResult).toEqual({
excludeUsers: [],
hideUnowned: false,
includeUsers: [],
ownerFilter: OwnerFilterType.UNOWNED,
userID: null,
})
})
})

View File

@@ -0,0 +1,96 @@
import { ComponentFixture, TestBed } from '@angular/core/testing'
import { PermissionsSelectComponent } from './permissions-select.component'
import {
FormsModule,
NG_VALUE_ACCESSOR,
ReactiveFormsModule,
} from '@angular/forms'
import { NgbModule } from '@ng-bootstrap/ng-bootstrap'
import {
PermissionAction,
PermissionType,
} from 'src/app/services/permissions.service'
import { By } from '@angular/platform-browser'
const permissions = [
'add_document',
'view_document',
'change_document',
'delete_document',
'change_tag',
'view_documenttype',
]
const inheritedPermissions = ['change_tag', 'view_documenttype']
describe('PermissionsSelectComponent', () => {
let component: PermissionsSelectComponent
let fixture: ComponentFixture<PermissionsSelectComponent>
let permissionsChangeResult: Permissions
beforeEach(async () => {
TestBed.configureTestingModule({
declarations: [PermissionsSelectComponent],
providers: [],
imports: [FormsModule, ReactiveFormsModule, NgbModule],
}).compileComponents()
fixture = TestBed.createComponent(PermissionsSelectComponent)
fixture.debugElement.injector.get(NG_VALUE_ACCESSOR)
component = fixture.componentInstance
component.registerOnChange((r) => (permissionsChangeResult = r))
fixture.detectChanges()
})
it('should create controls for all PermissionType and PermissionAction', () => {
expect(Object.values(component.form.controls)).toHaveLength(
Object.keys(PermissionType).length
)
for (var type in component.form.controls) {
expect(
Object.values(component.form.controls[type].controls)
).toHaveLength(Object.keys(PermissionAction).length)
}
// coverage
component.registerOnTouched(() => {})
component.setDisabledState(true)
})
it('should allow toggle all on / off', () => {
component.ngOnInit()
expect(component.typesWithAllActions.values).toHaveLength(0)
component.toggleAll({ target: { checked: true } }, 'Tag')
expect(component.typesWithAllActions).toContain('Tag')
component.toggleAll({ target: { checked: false } }, 'Tag')
expect(component.typesWithAllActions.values).toHaveLength(0)
})
it('should update on permissions set', () => {
component.ngOnInit()
component.writeValue(permissions)
expect(permissionsChangeResult).toEqual(permissions)
expect(component.typesWithAllActions).toContain('Document')
})
it('should update checkboxes on permissions set', () => {
component.ngOnInit()
component.writeValue(permissions)
fixture.detectChanges()
const input1 = fixture.debugElement.query(By.css('input#Document_Add'))
expect(input1.nativeElement.checked).toBeTruthy()
const input2 = fixture.debugElement.query(By.css('input#Tag_Change'))
expect(input2.nativeElement.checked).toBeTruthy()
})
it('disable checkboxes when permissions are inherited', () => {
component.ngOnInit()
component.inheritedPermissions = inheritedPermissions
expect(component.isInherited('Document', 'Add')).toBeFalsy()
expect(component.isInherited('Document')).toBeFalsy()
expect(component.isInherited('Tag', 'Change')).toBeTruthy()
const input1 = fixture.debugElement.query(By.css('input#Document_Add'))
expect(input1.nativeElement.disabled).toBeFalsy()
const input2 = fixture.debugElement.query(By.css('input#Tag_Change'))
expect(input2.nativeElement.disabled).toBeTruthy()
})
})

View File

@@ -0,0 +1,31 @@
import { ComponentFixture, TestBed } from '@angular/core/testing'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
import { NgSelectModule } from '@ng-select/ng-select'
import { SelectComponent } from '../input/select/select.component'
import { SelectDialogComponent } from './select-dialog.component'
describe('SelectDialogComponent', () => {
let component: SelectDialogComponent
let fixture: ComponentFixture<SelectDialogComponent>
let modal: NgbActiveModal
beforeEach(async () => {
TestBed.configureTestingModule({
declarations: [SelectDialogComponent, SelectComponent],
providers: [NgbActiveModal],
imports: [NgSelectModule, FormsModule, ReactiveFormsModule],
}).compileComponents()
modal = TestBed.inject(NgbActiveModal)
fixture = TestBed.createComponent(SelectDialogComponent)
component = fixture.componentInstance
fixture.detectChanges()
})
it('should close modal on cancel', () => {
const closeSpy = jest.spyOn(modal, 'close')
component.cancelClicked()
expect(closeSpy).toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,51 @@
import { ComponentFixture, TestBed } from '@angular/core/testing'
import { TagComponent } from './tag.component'
import { PaperlessTag } from 'src/app/data/paperless-tag'
import { By } from '@angular/platform-browser'
const tag: PaperlessTag = {
id: 1,
color: '#ff0000',
name: 'Tag1',
}
describe('TagComponent', () => {
let component: TagComponent
let fixture: ComponentFixture<TagComponent>
beforeEach(async () => {
TestBed.configureTestingModule({
declarations: [TagComponent],
providers: [],
imports: [],
}).compileComponents()
fixture = TestBed.createComponent(TagComponent)
component = fixture.componentInstance
fixture.detectChanges()
})
it('should create tag with background color', () => {
component.tag = tag
fixture.detectChanges()
expect(
fixture.debugElement.query(By.css('span')).nativeElement.style
.backgroundColor
).toEqual('rgb(255, 0, 0)')
})
it('should handle private tags', () => {
expect(
fixture.debugElement.query(By.css('span')).nativeElement.textContent
).toEqual('Private')
})
it('should support clickable option', () => {
component.tag = tag
fixture.detectChanges()
expect(fixture.debugElement.query(By.css('a.badge'))).toBeNull()
component.clickable = true
fixture.detectChanges()
expect(fixture.debugElement.query(By.css('a.badge'))).not.toBeNull()
})
})

View File

@@ -0,0 +1,94 @@
import {
TestBed,
discardPeriodicTasks,
fakeAsync,
flush,
} from '@angular/core/testing'
import { ToastService } from 'src/app/services/toast.service'
import { ToastsComponent } from './toasts.component'
import { ComponentFixture } from '@angular/core/testing'
import { HttpClientTestingModule } from '@angular/common/http/testing'
import { of } from 'rxjs'
import { NgbModule } from '@ng-bootstrap/ng-bootstrap'
describe('ToastsComponent', () => {
let component: ToastsComponent
let fixture: ComponentFixture<ToastsComponent>
let toastService: ToastService
beforeEach(async () => {
TestBed.configureTestingModule({
declarations: [ToastsComponent],
imports: [HttpClientTestingModule, NgbModule],
providers: [
{
provide: ToastService,
useValue: {
getToasts: () =>
of([
{
title: 'Title',
content: 'content',
delay: 5000,
},
{
title: 'Error',
content: 'Error content',
delay: 5000,
error: new Error('Error message'),
},
]),
},
},
],
}).compileComponents()
fixture = TestBed.createComponent(ToastsComponent)
component = fixture.componentInstance
toastService = TestBed.inject(ToastService)
fixture.detectChanges()
})
it('should call getToasts and return toasts', fakeAsync(() => {
const spy = jest.spyOn(toastService, 'getToasts').mockReset()
component.ngOnInit()
fixture.detectChanges()
expect(spy).toHaveBeenCalled()
expect(component.toasts).toContainEqual({
title: 'Title',
content: 'content',
delay: 5000,
})
component.ngOnDestroy()
flush()
discardPeriodicTasks()
}))
it('should show a toast', fakeAsync(() => {
component.ngOnInit()
fixture.detectChanges()
expect(fixture.nativeElement.textContent).toContain('Title')
component.ngOnDestroy()
flush()
discardPeriodicTasks()
}))
it('should show an error if given with toast', fakeAsync(() => {
component.ngOnInit()
fixture.detectChanges()
expect(fixture.nativeElement.querySelector('details')).not.toBeNull()
expect(fixture.nativeElement.textContent).toContain('Error message')
component.ngOnDestroy()
flush()
discardPeriodicTasks()
}))
})

View File

@@ -15,7 +15,7 @@ export class ToastsComponent implements OnInit, OnDestroy {
toasts: Toast[] = []
ngOnDestroy(): void {
this.subscription.unsubscribe()
this.subscription?.unsubscribe()
}
ngOnInit(): void {

View File

@@ -0,0 +1,117 @@
import { ComponentFixture, TestBed } from '@angular/core/testing'
import { NgbAlertModule, NgbAlert } from '@ng-bootstrap/ng-bootstrap'
import { PermissionsGuard } from 'src/app/guards/permissions.guard'
import { DashboardComponent } from './dashboard.component'
import { HttpClientTestingModule } from '@angular/common/http/testing'
import { SettingsService } from 'src/app/services/settings.service'
import { StatisticsWidgetComponent } from './widgets/statistics-widget/statistics-widget.component'
import { PageHeaderComponent } from '../common/page-header/page-header.component'
import { WidgetFrameComponent } from './widgets/widget-frame/widget-frame.component'
import { UploadFileWidgetComponent } from './widgets/upload-file-widget/upload-file-widget.component'
import { SavedViewService } from 'src/app/services/rest/saved-view.service'
import { PermissionsService } from 'src/app/services/permissions.service'
import { By } from '@angular/platform-browser'
import { SavedViewWidgetComponent } from './widgets/saved-view-widget/saved-view-widget.component'
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
import { NgxFileDropModule } from 'ngx-file-drop'
import { RouterTestingModule } from '@angular/router/testing'
import { TourNgBootstrapModule, TourService } from 'ngx-ui-tour-ng-bootstrap'
describe('DashboardComponent', () => {
let component: DashboardComponent
let fixture: ComponentFixture<DashboardComponent>
let settingsService: SettingsService
let tourService: TourService
beforeEach(async () => {
TestBed.configureTestingModule({
declarations: [
DashboardComponent,
StatisticsWidgetComponent,
PageHeaderComponent,
WidgetFrameComponent,
UploadFileWidgetComponent,
IfPermissionsDirective,
SavedViewWidgetComponent,
],
providers: [
PermissionsGuard,
{
provide: PermissionsService,
useValue: {
currentUserCan: () => true,
},
},
{
provide: SavedViewService,
useValue: {
dashboardViews: [
{
id: 1,
name: 'saved view 1',
show_on_dashboard: true,
sort_field: 'added',
sort_reverse: true,
filter_rules: [],
},
{
id: 2,
name: 'saved view 2',
show_on_dashboard: true,
sort_field: 'created',
sort_reverse: true,
filter_rules: [],
},
],
},
},
],
imports: [
NgbAlertModule,
HttpClientTestingModule,
NgxFileDropModule,
RouterTestingModule,
TourNgBootstrapModule,
],
}).compileComponents()
settingsService = TestBed.inject(SettingsService)
settingsService.currentUser = {
first_name: 'Foo',
last_name: 'Bar',
}
tourService = TestBed.inject(TourService)
fixture = TestBed.createComponent(DashboardComponent)
component = fixture.componentInstance
fixture.detectChanges()
})
it('should show a welcome message', () => {
expect(component.subtitle).toEqual(`Hello Foo, welcome to Paperless-ngx`)
settingsService.currentUser = {
id: 1,
}
expect(component.subtitle).toEqual(`Welcome to Paperless-ngx`)
})
it('should show dashboard widgets', () => {
expect(
fixture.debugElement.queryAll(By.directive(SavedViewWidgetComponent))
).toHaveLength(2)
})
it('should end tour service if still running and welcome widget dismissed', () => {
jest.spyOn(tourService, 'getStatus').mockReturnValueOnce(1)
const endSpy = jest.spyOn(tourService, 'end')
component.completeTour()
expect(endSpy).toHaveBeenCalled()
})
it('should save tour completion if it was stopped and welcome widget dismissed', () => {
jest.spyOn(tourService, 'getStatus').mockReturnValueOnce(0)
const settingsCompleteTourSpy = jest.spyOn(settingsService, 'completeTour')
component.completeTour()
expect(settingsCompleteTourSpy).toHaveBeenCalled()
})
})

View File

@@ -0,0 +1,165 @@
import { DatePipe } from '@angular/common'
import { HttpClientTestingModule } from '@angular/common/http/testing'
import { ComponentFixture, TestBed } from '@angular/core/testing'
import { Router } from '@angular/router'
import { RouterTestingModule } from '@angular/router/testing'
import { NgbModule } from '@ng-bootstrap/ng-bootstrap'
import { of, Subject } from 'rxjs'
import { routes } from 'src/app/app-routing.module'
import { FILTER_HAS_TAGS_ALL } from 'src/app/data/filter-rule-type'
import { PaperlessSavedView } from 'src/app/data/paperless-saved-view'
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
import { PermissionsGuard } from 'src/app/guards/permissions.guard'
import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
import { DocumentTitlePipe } from 'src/app/pipes/document-title.pipe'
import {
ConsumerStatusService,
FileStatus,
} from 'src/app/services/consumer-status.service'
import { DocumentListViewService } from 'src/app/services/document-list-view.service'
import { PermissionsService } from 'src/app/services/permissions.service'
import { DocumentService } from 'src/app/services/rest/document.service'
import { WidgetFrameComponent } from '../widget-frame/widget-frame.component'
import { SavedViewWidgetComponent } from './saved-view-widget.component'
const savedView: PaperlessSavedView = {
id: 1,
name: 'Saved View 1',
sort_field: 'added',
sort_reverse: true,
show_in_sidebar: true,
show_on_dashboard: true,
filter_rules: [
{
rule_type: FILTER_HAS_TAGS_ALL,
value: '1,2',
},
],
}
const documentResults = [
{
id: 2,
title: 'doc2',
},
{
id: 3,
title: 'doc3',
},
]
describe('SavedViewWidgetComponent', () => {
let component: SavedViewWidgetComponent
let fixture: ComponentFixture<SavedViewWidgetComponent>
let documentService: DocumentService
let consumerStatusService: ConsumerStatusService
let documentListViewService: DocumentListViewService
let router: Router
beforeEach(async () => {
TestBed.configureTestingModule({
declarations: [
SavedViewWidgetComponent,
WidgetFrameComponent,
IfPermissionsDirective,
CustomDatePipe,
DocumentTitlePipe,
],
providers: [
PermissionsGuard,
DocumentService,
{
provide: PermissionsService,
useValue: {
currentUserCan: () => true,
},
},
CustomDatePipe,
DatePipe,
],
imports: [
HttpClientTestingModule,
NgbModule,
RouterTestingModule.withRoutes(routes),
],
}).compileComponents()
documentService = TestBed.inject(DocumentService)
consumerStatusService = TestBed.inject(ConsumerStatusService)
documentListViewService = TestBed.inject(DocumentListViewService)
router = TestBed.inject(Router)
fixture = TestBed.createComponent(SavedViewWidgetComponent)
component = fixture.componentInstance
component.savedView = savedView
fixture.detectChanges()
})
it('should show a list of documents', () => {
jest.spyOn(documentService, 'listFiltered').mockReturnValue(
of({
all: [2, 3],
count: 2,
results: documentResults,
})
)
component.ngOnInit()
fixture.detectChanges()
expect(fixture.debugElement.nativeElement.textContent).toContain('doc2')
expect(fixture.debugElement.nativeElement.textContent).toContain('doc3')
})
it('should call api endpoint and load results', () => {
const listAllSpy = jest.spyOn(documentService, 'listFiltered')
listAllSpy.mockReturnValue(
of({
all: [2, 3],
count: 2,
results: documentResults,
})
)
component.ngOnInit()
expect(listAllSpy).toHaveBeenCalledWith(
1,
10,
savedView.sort_field,
savedView.sort_reverse,
savedView.filter_rules,
{
truncate_content: true,
}
)
fixture.detectChanges()
expect(component.documents).toEqual(documentResults)
})
it('should reload on document consumption finished', () => {
const fileStatusSubject = new Subject<FileStatus>()
jest
.spyOn(consumerStatusService, 'onDocumentConsumptionFinished')
.mockReturnValue(fileStatusSubject)
const reloadSpy = jest.spyOn(component, 'reload')
component.ngOnInit()
fileStatusSubject.next(new FileStatus())
expect(reloadSpy).toHaveBeenCalled()
})
it('should navigate on showAll', () => {
const routerSpy = jest.spyOn(router, 'navigate')
component.showAll()
expect(routerSpy).toHaveBeenCalledWith(['view', savedView.id])
savedView.show_in_sidebar = false
component.showAll()
expect(routerSpy).toHaveBeenCalledWith(['documents'], {
queryParams: { view: savedView.id },
})
})
it('should navigate via quickfilter on click tag', () => {
const qfSpy = jest.spyOn(documentListViewService, 'quickFilter')
component.clickTag({ id: 11, name: 'Tag11' }, new MouseEvent('click'))
expect(qfSpy).toHaveBeenCalledWith([
{ rule_type: FILTER_HAS_TAGS_ALL, value: '11' },
])
})
})

View File

@@ -0,0 +1,110 @@
import { TestBed } from '@angular/core/testing'
import { StatisticsWidgetComponent } from './statistics-widget.component'
import { ComponentFixture } from '@angular/core/testing'
import {
HttpClientTestingModule,
HttpTestingController,
} from '@angular/common/http/testing'
import { NgbModule } from '@ng-bootstrap/ng-bootstrap'
import { WidgetFrameComponent } from '../widget-frame/widget-frame.component'
import { environment } from 'src/environments/environment'
import { RouterTestingModule } from '@angular/router/testing'
import { routes } from 'src/app/app-routing.module'
import { PermissionsGuard } from 'src/app/guards/permissions.guard'
describe('StatisticsWidgetComponent', () => {
let component: StatisticsWidgetComponent
let fixture: ComponentFixture<StatisticsWidgetComponent>
let httpTestingController: HttpTestingController
beforeEach(async () => {
TestBed.configureTestingModule({
declarations: [StatisticsWidgetComponent, WidgetFrameComponent],
providers: [PermissionsGuard],
imports: [
HttpClientTestingModule,
NgbModule,
RouterTestingModule.withRoutes(routes),
],
}).compileComponents()
fixture = TestBed.createComponent(StatisticsWidgetComponent)
component = fixture.componentInstance
httpTestingController = TestBed.inject(HttpTestingController)
fixture.detectChanges()
})
it('should call api statistics endpoint', () => {
const req = httpTestingController.expectOne(
`${environment.apiBaseUrl}statistics/`
)
expect(req.request.method).toEqual('GET')
})
it('should display inbox link with count', () => {
const mockStats = {
documents_total: 200,
documents_inbox: 18,
inbox_tag: 10,
}
const req = httpTestingController.expectOne(
`${environment.apiBaseUrl}statistics/`
)
req.flush(mockStats)
fixture.detectChanges()
const goToInboxSpy = jest.spyOn(component, 'goToInbox')
expect(fixture.nativeElement.textContent.replace(/\s/g, '')).toContain(
'inbox:18'
)
const link = fixture.nativeElement.querySelector('a') as HTMLAnchorElement
expect(link).not.toBeNull()
link.click()
expect(goToInboxSpy).toHaveBeenCalled()
})
it('should display mime types with counts', () => {
const mockStats = {
documents_total: 200,
documents_inbox: 18,
inbox_tag: 10,
document_file_type_counts: [
{
mime_type: 'application/pdf',
mime_type_count: 160,
},
{
mime_type: 'text/plain',
mime_type_count: 20,
},
{
mime_type: 'text/csv',
mime_type_count: 20,
},
],
character_count: 162312,
}
const req = httpTestingController.expectOne(
`${environment.apiBaseUrl}statistics/`
)
req.flush(mockStats)
fixture.detectChanges()
expect(fixture.nativeElement.textContent.replace(/\s/g, '')).toContain(
'PDF(80%)'
)
expect(fixture.nativeElement.textContent.replace(/\s/g, '')).toContain(
'TXT(10%)'
)
expect(fixture.nativeElement.textContent.replace(/\s/g, '')).toContain(
'CSV(10%)'
)
})
})

View File

@@ -0,0 +1,173 @@
import { HttpClientTestingModule } from '@angular/common/http/testing'
import { ComponentFixture, TestBed } from '@angular/core/testing'
import { By } from '@angular/platform-browser'
import { RouterTestingModule } from '@angular/router/testing'
import {
NgbModule,
NgbAlertModule,
NgbAlert,
NgbCollapse,
} from '@ng-bootstrap/ng-bootstrap'
import { NgxFileDropModule } from 'ngx-file-drop'
import { routes } from 'src/app/app-routing.module'
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
import { PermissionsGuard } from 'src/app/guards/permissions.guard'
import {
ConsumerStatusService,
FileStatus,
FileStatusPhase,
} from 'src/app/services/consumer-status.service'
import { PermissionsService } from 'src/app/services/permissions.service'
import { UploadDocumentsService } from 'src/app/services/upload-documents.service'
import { WidgetFrameComponent } from '../widget-frame/widget-frame.component'
import { UploadFileWidgetComponent } from './upload-file-widget.component'
describe('UploadFileWidgetComponent', () => {
let component: UploadFileWidgetComponent
let fixture: ComponentFixture<UploadFileWidgetComponent>
let consumerStatusService: ConsumerStatusService
let uploadDocumentsService: UploadDocumentsService
beforeEach(async () => {
TestBed.configureTestingModule({
declarations: [
UploadFileWidgetComponent,
WidgetFrameComponent,
IfPermissionsDirective,
],
providers: [
PermissionsGuard,
{
provide: PermissionsService,
useValue: {
currentUserCan: () => true,
},
},
],
imports: [
HttpClientTestingModule,
NgbModule,
RouterTestingModule.withRoutes(routes),
NgxFileDropModule,
NgbAlertModule,
],
}).compileComponents()
consumerStatusService = TestBed.inject(ConsumerStatusService)
uploadDocumentsService = TestBed.inject(UploadDocumentsService)
fixture = TestBed.createComponent(UploadFileWidgetComponent)
component = fixture.componentInstance
fixture.detectChanges()
})
it('should support drop files', () => {
const uploadSpy = jest.spyOn(uploadDocumentsService, 'uploadFiles')
component.dropped([])
expect(uploadSpy).toHaveBeenCalled()
// coverage
component.fileLeave(null)
component.fileOver(null)
})
it('should generate stats summary', () => {
mockConsumerStatuses(consumerStatusService)
expect(component.getStatusSummary()).toEqual(
'Processing: 6, Failed: 1, Added: 4'
)
})
it('should report an upload progress summary', () => {
mockConsumerStatuses(consumerStatusService)
expect(component.getTotalUploadProgress()).toEqual(0.75)
})
it('should change color by status phase', () => {
const processingStatus = new FileStatus()
processingStatus.phase = FileStatusPhase.PROCESSING
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 enforce a maximum number of alerts', () => {
mockConsumerStatuses(consumerStatusService)
fixture.detectChanges()
// 5 total, 1 hidden
expect(fixture.debugElement.queryAll(By.directive(NgbAlert))).toHaveLength(
6
)
expect(
fixture.debugElement
.query(By.directive(NgbCollapse))
.queryAll(By.directive(NgbAlert))
).toHaveLength(1)
})
it('should allow dismissing an alert', () => {
const dismissSpy = jest.spyOn(consumerStatusService, 'dismiss')
component.dismiss(new FileStatus())
expect(dismissSpy).toHaveBeenCalled()
})
it('should allow dismissing all alerts', () => {
const dismissSpy = jest.spyOn(consumerStatusService, 'dismissCompleted')
component.dismissCompleted()
expect(dismissSpy).toHaveBeenCalled()
})
})
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 [new FileStatus()]
case FileStatusPhase.PROCESSING:
return [new FileStatus(), new FileStatus()]
case FileStatusPhase.STARTED:
return [new FileStatus(), new FileStatus(), new FileStatus()]
case FileStatusPhase.SUCCESS:
return [
new FileStatus(),
new FileStatus(),
new FileStatus(),
new FileStatus(),
]
case FileStatusPhase.UPLOADING:
return [partialUpload1, partialUpload2]
default:
return [
new FileStatus(),
new FileStatus(),
new FileStatus(),
new FileStatus(),
new FileStatus(),
new FileStatus(),
]
}
})
jest
.spyOn(consumerStatusService, 'getConsumerStatusNotCompleted')
.mockImplementation(() => {
return [
new FileStatus(),
new FileStatus(),
new FileStatus(),
new FileStatus(),
new FileStatus(),
new FileStatus(),
]
})
}

View File

@@ -69,9 +69,6 @@ export class UploadFileWidgetComponent extends ComponentWithPermissions {
return this.consumerStatusService.getConsumerStatus(FileStatusPhase.SUCCESS)
}
getStatusCompleted() {
return this.consumerStatusService.getConsumerStatusCompleted()
}
getTotalUploadProgress() {
let current = 0
let max = 0

View File

@@ -0,0 +1,33 @@
import { ComponentFixture, TestBed } from '@angular/core/testing'
import { By } from '@angular/platform-browser'
import { NgbAlertModule, NgbAlert } from '@ng-bootstrap/ng-bootstrap'
import { PermissionsGuard } from 'src/app/guards/permissions.guard'
import { WidgetFrameComponent } from '../widget-frame/widget-frame.component'
import { WelcomeWidgetComponent } from './welcome-widget.component'
describe('WelcomeWidgetComponent', () => {
let component: WelcomeWidgetComponent
let fixture: ComponentFixture<WelcomeWidgetComponent>
beforeEach(async () => {
TestBed.configureTestingModule({
declarations: [WelcomeWidgetComponent, WidgetFrameComponent],
providers: [PermissionsGuard],
imports: [NgbAlertModule],
}).compileComponents()
fixture = TestBed.createComponent(WelcomeWidgetComponent)
component = fixture.componentInstance
fixture.detectChanges()
})
it('should be dismissable', () => {
let dismissResult
component.dismiss.subscribe(() => (dismissResult = true))
fixture.debugElement
.query(By.directive(NgbAlert))
.triggerEventHandler('closed')
expect(dismissResult).toBeTruthy()
})
})

View File

@@ -0,0 +1,53 @@
import { Component } from '@angular/core'
import { ComponentFixture, TestBed } from '@angular/core/testing'
import { By } from '@angular/platform-browser'
import { NgbAlertModule, NgbAlert } from '@ng-bootstrap/ng-bootstrap'
import { PermissionsGuard } from 'src/app/guards/permissions.guard'
import { WidgetFrameComponent } from './widget-frame.component'
@Component({
template: `
<div>
<button
*appIfObjectPermissions="{
object: { id: 2, owner: user1 },
action: 'view'
}"
>
Some Text
</button>
</div>
`,
})
class TestComponent extends WidgetFrameComponent {}
describe('WidgetFrameComponent', () => {
let component: WidgetFrameComponent
let fixture: ComponentFixture<WidgetFrameComponent>
beforeEach(async () => {
TestBed.configureTestingModule({
declarations: [WidgetFrameComponent, WidgetFrameComponent],
providers: [PermissionsGuard],
imports: [NgbAlertModule],
}).compileComponents()
fixture = TestBed.createComponent(WidgetFrameComponent)
component = fixture.componentInstance
fixture.detectChanges()
})
it('should show title', () => {
component.title = 'Foo'
fixture.detectChanges()
expect(fixture.debugElement.nativeElement.textContent).toContain('Foo')
})
it('should show loading indicator', () => {
expect(fixture.debugElement.query(By.css('.spinner-border'))).toBeNull()
component.loading = true
fixture.detectChanges()
expect(fixture.debugElement.query(By.css('.spinner-border'))).not.toBeNull()
})
})

View File

@@ -0,0 +1,58 @@
import { ComponentFixture, TestBed } from '@angular/core/testing'
import { ActivatedRoute, Router, convertToParamMap } from '@angular/router'
import { of } from 'rxjs'
import { DocumentService } from 'src/app/services/rest/document.service'
import { DocumentAsnComponent } from './document-asn.component'
import { RouterTestingModule } from '@angular/router/testing'
import { FilterRule } from 'src/app/data/filter-rule'
import { routes } from 'src/app/app-routing.module'
import { PermissionsGuard } from 'src/app/guards/permissions.guard'
describe('DocumentAsnComponent', () => {
let component: DocumentAsnComponent
let fixture: ComponentFixture<DocumentAsnComponent>
let router: Router
let activatedRoute: ActivatedRoute
beforeEach(async () => {
TestBed.configureTestingModule({
declarations: [DocumentAsnComponent],
providers: [
{
provide: DocumentService,
useValue: {
listAllFilteredIds: (rules: FilterRule[]) =>
rules[0].value === '1234' ? of([1]) : of([]),
},
},
PermissionsGuard,
],
imports: [RouterTestingModule.withRoutes(routes)],
}).compileComponents()
router = TestBed.inject(Router)
activatedRoute = TestBed.inject(ActivatedRoute)
fixture = TestBed.createComponent(DocumentAsnComponent)
component = fixture.componentInstance
fixture.detectChanges()
})
it('should navigate on valid asn', () => {
jest
.spyOn(activatedRoute, 'paramMap', 'get')
.mockReturnValue(of(convertToParamMap({ id: '1234' })))
const navigateSpy = jest.spyOn(router, 'navigate')
component.ngOnInit()
expect(navigateSpy).toHaveBeenCalledWith(['documents', 1])
})
it('should 404 on invalid asn', () => {
jest
.spyOn(activatedRoute, 'paramMap', 'get')
.mockReturnValue(of(convertToParamMap({ id: '5578' })))
const navigateSpy = jest.spyOn(router, 'navigate')
component.ngOnInit()
expect(navigateSpy).toHaveBeenCalledWith(['404'])
})
})

View File

@@ -191,9 +191,12 @@
<div [ngbNavOutlet]="nav" class="mt-2"></div>
<ng-container>
<button type="button" class="btn btn-outline-secondary" (click)="discard()" i18n [disabled]="!userCanEdit || networkActive || (isDirty$ | async) !== true">Discard</button>&nbsp;
<button type="button" class="btn btn-outline-primary" (click)="saveEditNext()" *ngIf="hasNext()" i18n [disabled]="!userCanEdit || networkActive || (isDirty$ | async) !== true">Save & next</button>&nbsp;
<button type="submit" class="btn btn-primary" *appIfPermissions="{ action: PermissionAction.Change, type: PermissionType.Document }" i18n [disabled]="!userCanEdit || networkActive || (isDirty$ | async) !== true">Save</button>&nbsp;
<button type="button" class="btn btn-outline-secondary me-2" (click)="discard()" i18n [disabled]="!userCanEdit || networkActive || (isDirty$ | async) !== true">Discard</button>
<ng-container *appIfPermissions="{ action: PermissionAction.Change, type: PermissionType.Document }">
<button *ngIf="hasNext()" type="button" class="btn btn-outline-primary me-2" (click)="saveEditNext()" i18n [disabled]="!userCanEdit || networkActive || (isDirty$ | async) !== true">Save &amp; next</button>
<button *ngIf="!hasNext()" type="button" class="btn btn-outline-primary me-2" (click)="save(true)" i18n [disabled]="!userCanEdit || networkActive || (isDirty$ | async) !== true">Save &amp; close</button>
<button type="submit" class="btn btn-primary" i18n [disabled]="!userCanEdit || networkActive || (isDirty$ | async) !== true">Save</button>
</ng-container>
</ng-container>
</form>
</div>

View File

@@ -0,0 +1,816 @@
import { DatePipe } from '@angular/common'
import { HttpClientTestingModule } from '@angular/common/http/testing'
import {
ComponentFixture,
TestBed,
fakeAsync,
tick,
discardPeriodicTasks,
} from '@angular/core/testing'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { By } from '@angular/platform-browser'
import { Router, ActivatedRoute, convertToParamMap } from '@angular/router'
import { RouterTestingModule } from '@angular/router/testing'
import {
NgbModal,
NgbModule,
NgbModalModule,
NgbModalRef,
NgbDateStruct,
} from '@ng-bootstrap/ng-bootstrap'
import { NgSelectModule } from '@ng-select/ng-select'
import { PdfViewerComponent } from 'ng2-pdf-viewer'
import { of, throwError } from 'rxjs'
import { routes } from 'src/app/app-routing.module'
import {
FILTER_FULLTEXT_MORELIKE,
FILTER_CORRESPONDENT,
FILTER_DOCUMENT_TYPE,
FILTER_STORAGE_PATH,
FILTER_HAS_TAGS_ALL,
FILTER_CREATED_AFTER,
FILTER_CREATED_BEFORE,
} from 'src/app/data/filter-rule-type'
import { PaperlessCorrespondent } from 'src/app/data/paperless-correspondent'
import { PaperlessDocument } from 'src/app/data/paperless-document'
import { PaperlessDocumentType } from 'src/app/data/paperless-document-type'
import { PaperlessStoragePath } from 'src/app/data/paperless-storage-path'
import { PaperlessTag } from 'src/app/data/paperless-tag'
import { IfOwnerDirective } from 'src/app/directives/if-owner.directive'
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
import { PermissionsGuard } from 'src/app/guards/permissions.guard'
import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
import { DocumentTitlePipe } from 'src/app/pipes/document-title.pipe'
import { SafeHtmlPipe } from 'src/app/pipes/safehtml.pipe'
import { SafeUrlPipe } from 'src/app/pipes/safeurl.pipe'
import { DocumentListViewService } from 'src/app/services/document-list-view.service'
import { OpenDocumentsService } from 'src/app/services/open-documents.service'
import { PermissionsService } from 'src/app/services/permissions.service'
import { CorrespondentService } from 'src/app/services/rest/correspondent.service'
import { DocumentTypeService } from 'src/app/services/rest/document-type.service'
import { DocumentService } from 'src/app/services/rest/document.service'
import { StoragePathService } from 'src/app/services/rest/storage-path.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 { ConfirmDialogComponent } from '../common/confirm-dialog/confirm-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 { StoragePathEditDialogComponent } from '../common/edit-dialog/storage-path-edit-dialog/storage-path-edit-dialog.component'
import { DateComponent } from '../common/input/date/date.component'
import { NumberComponent } from '../common/input/number/number.component'
import { PermissionsFormComponent } from '../common/input/permissions/permissions-form/permissions-form.component'
import { SelectComponent } from '../common/input/select/select.component'
import { TagsComponent } from '../common/input/tags/tags.component'
import { TextComponent } from '../common/input/text/text.component'
import { PageHeaderComponent } from '../common/page-header/page-header.component'
import { DocumentNotesComponent } from '../document-notes/document-notes.component'
import { DocumentDetailComponent } from './document-detail.component'
const doc: PaperlessDocument = {
id: 3,
title: 'Doc 3',
correspondent: 11,
document_type: 21,
storage_path: 31,
tags: [41, 42, 43],
content: 'text content',
added: new Date(),
created: new Date(),
archive_serial_number: null,
original_file_name: 'file.pdf',
owner: null,
user_can_change: true,
notes: [
{
created: new Date(),
note: 'note 1',
user: 1,
},
{
created: new Date(),
note: 'note 2',
user: 2,
},
],
}
describe('DocumentDetailComponent', () => {
let component: DocumentDetailComponent
let fixture: ComponentFixture<DocumentDetailComponent>
let router: Router
let activatedRoute: ActivatedRoute
let documentService: DocumentService
let openDocumentsService: OpenDocumentsService
let modalService: NgbModal
let toastService: ToastService
let documentListViewService: DocumentListViewService
let settingsService: SettingsService
let currentUserCan = true
let currentUserHasObjectPermissions = true
let currentUserOwnsObject = true
beforeEach(async () => {
TestBed.configureTestingModule({
declarations: [
DocumentDetailComponent,
DocumentTitlePipe,
PageHeaderComponent,
IfPermissionsDirective,
TagsComponent,
SelectComponent,
TextComponent,
NumberComponent,
DateComponent,
DocumentNotesComponent,
CustomDatePipe,
DocumentTypeEditDialogComponent,
CorrespondentEditDialogComponent,
StoragePathEditDialogComponent,
IfOwnerDirective,
PermissionsFormComponent,
SafeHtmlPipe,
ConfirmDialogComponent,
PdfViewerComponent,
SafeUrlPipe,
],
providers: [
DocumentTitlePipe,
{
provide: CorrespondentService,
useValue: {
listAll: () =>
of({
results: [
{
id: 11,
name: 'Correspondent11',
},
],
}),
},
},
{
provide: DocumentTypeService,
useValue: {
listAll: () =>
of({
results: [
{
id: 21,
name: 'DocumentType21',
},
],
}),
},
},
{
provide: StoragePathService,
useValue: {
listAll: () =>
of({
results: [
{
id: 31,
name: 'StoragePath31',
},
],
}),
},
},
{
provide: UserService,
useValue: {
listAll: () =>
of({
results: [
{
id: 1,
username: 'user1',
},
{
id: 2,
username: 'user2',
},
],
}),
},
},
{
provide: PermissionsService,
useValue: {
currentUserCan: () => currentUserCan,
currentUserHasObjectPermissions: () =>
currentUserHasObjectPermissions,
currentUserOwnsObject: () => currentUserOwnsObject,
},
},
PermissionsGuard,
CustomDatePipe,
DatePipe,
],
imports: [
RouterTestingModule.withRoutes(routes),
HttpClientTestingModule,
NgbModule,
NgSelectModule,
FormsModule,
ReactiveFormsModule,
NgbModalModule,
],
}).compileComponents()
router = TestBed.inject(Router)
activatedRoute = TestBed.inject(ActivatedRoute)
jest
.spyOn(activatedRoute, 'paramMap', 'get')
.mockReturnValue(of(convertToParamMap({ id: 3 })))
openDocumentsService = TestBed.inject(OpenDocumentsService)
documentService = TestBed.inject(DocumentService)
modalService = TestBed.inject(NgbModal)
toastService = TestBed.inject(ToastService)
documentListViewService = TestBed.inject(DocumentListViewService)
settingsService = TestBed.inject(SettingsService)
fixture = TestBed.createComponent(DocumentDetailComponent)
component = fixture.componentInstance
})
it('should load four tabs via url params', () => {
jest
.spyOn(activatedRoute, 'paramMap', 'get')
.mockReturnValue(of(convertToParamMap({ id: 3, section: 'notes' })))
jest.spyOn(openDocumentsService, 'getOpenDocument').mockReturnValue(null)
jest
.spyOn(openDocumentsService, 'openDocument')
.mockReturnValueOnce(of(true))
fixture.detectChanges()
expect(component.activeNavID).toEqual(5) // DocumentDetailNavIDs.Notes
})
it('should change url on tab switch', () => {
initNormally()
const navigateSpy = jest.spyOn(router, 'navigate')
component.nav.select(5)
component.nav.navChange.next({
activeId: 1,
nextId: 5,
preventDefault: () => {},
})
fixture.detectChanges()
expect(navigateSpy).toHaveBeenCalledWith(['documents', 3, 'notes'])
})
it('should update title after debounce', fakeAsync(() => {
initNormally()
component.titleInput.value = 'Foo Bar'
component.titleSubject.next('Foo Bar')
tick(1000)
expect(component.documentForm.get('title').value).toEqual('Foo Bar')
discardPeriodicTasks()
}))
it('should update title before doc change if wasnt updated via debounce', fakeAsync(() => {
initNormally()
component.titleInput.value = 'Foo Bar'
component.titleInput.inputField.nativeElement.dispatchEvent(
new Event('change')
)
tick(1000)
expect(component.documentForm.get('title').value).toEqual('Foo Bar')
}))
it('should load non-open document via param', () => {
initNormally()
expect(component.document).toEqual(doc)
})
it('should load already-opened document via param', () => {
jest.spyOn(documentService, 'get').mockReturnValueOnce(of(doc))
jest.spyOn(openDocumentsService, 'getOpenDocument').mockReturnValue(doc)
fixture.detectChanges() // calls ngOnInit
expect(component.document).toEqual(doc)
})
it('should disable form if user cannot edit', () => {
currentUserHasObjectPermissions = false
initNormally()
expect(component.documentForm.disabled).toBeTruthy()
})
it('should support creating document type', () => {
initNormally()
let openModal: NgbModalRef
modalService.activeInstances.subscribe((modal) => (openModal = modal[0]))
const modalSpy = jest.spyOn(modalService, 'open')
component.createDocumentType('NewDocType2')
expect(modalSpy).toHaveBeenCalled()
openModal.componentInstance.succeeded.next({ id: 12, name: 'NewDocType12' })
expect(component.documentForm.get('document_type').value).toEqual(12)
})
it('should support creating correspondent', () => {
initNormally()
let openModal: NgbModalRef
modalService.activeInstances.subscribe((modal) => (openModal = modal[0]))
const modalSpy = jest.spyOn(modalService, 'open')
component.createCorrespondent('NewCorrrespondent12')
expect(modalSpy).toHaveBeenCalled()
openModal.componentInstance.succeeded.next({
id: 12,
name: 'NewCorrrespondent12',
})
expect(component.documentForm.get('correspondent').value).toEqual(12)
})
it('should support creating storage path', () => {
initNormally()
let openModal: NgbModalRef
modalService.activeInstances.subscribe((modal) => (openModal = modal[0]))
const modalSpy = jest.spyOn(modalService, 'open')
component.createStoragePath('NewStoragePath12')
expect(modalSpy).toHaveBeenCalled()
openModal.componentInstance.succeeded.next({
id: 12,
name: 'NewStoragePath12',
})
expect(component.documentForm.get('storage_path').value).toEqual(12)
})
it('should allow dischard changes', () => {
initNormally()
component.title = 'Foo Bar'
fixture.detectChanges()
jest.spyOn(documentService, 'get').mockReturnValueOnce(of(doc))
component.discard()
fixture.detectChanges()
expect(component.title).toEqual(doc.title)
expect(openDocumentsService.hasDirty()).toBeFalsy()
// this time with error, mostly for coverage
component.title = 'Foo Bar'
fixture.detectChanges()
const navigateSpy = jest.spyOn(router, 'navigate')
jest
.spyOn(documentService, 'get')
.mockReturnValueOnce(throwError(() => new Error('unable to discard')))
component.discard()
fixture.detectChanges()
expect(navigateSpy).toHaveBeenCalledWith(['404'])
})
it('should 404 on invalid id', () => {
jest.spyOn(documentService, 'get').mockReturnValueOnce(of(null))
const navigateSpy = jest.spyOn(router, 'navigate')
fixture.detectChanges()
expect(navigateSpy).toHaveBeenCalledWith(['404'])
})
it('should support save, close and show success toast', () => {
initNormally()
component.title = 'Foo Bar'
const closeSpy = jest.spyOn(component, 'close')
const updateSpy = jest.spyOn(documentService, 'update')
const toastSpy = jest.spyOn(toastService, 'showInfo')
updateSpy.mockImplementation((o) => of(doc))
component.save(true)
expect(updateSpy).toHaveBeenCalled()
expect(closeSpy).toHaveBeenCalled()
expect(toastSpy).toHaveBeenCalledWith('Document saved successfully.')
})
it('should support save without close and show success toast', () => {
initNormally()
component.title = 'Foo Bar'
const closeSpy = jest.spyOn(component, 'close')
const updateSpy = jest.spyOn(documentService, 'update')
const toastSpy = jest.spyOn(toastService, 'showInfo')
updateSpy.mockImplementation((o) => of(doc))
component.save()
expect(updateSpy).toHaveBeenCalled()
expect(closeSpy).not.toHaveBeenCalled()
expect(toastSpy).toHaveBeenCalledWith('Document saved successfully.')
})
it('should show toast error on save if error occurs', () => {
currentUserHasObjectPermissions = true
initNormally()
component.title = 'Foo Bar'
const closeSpy = jest.spyOn(component, 'close')
const updateSpy = jest.spyOn(documentService, 'update')
const toastSpy = jest.spyOn(toastService, 'showError')
updateSpy.mockImplementation(() =>
throwError(() => new Error('failed to save'))
)
component.save()
expect(updateSpy).toHaveBeenCalled()
expect(closeSpy).not.toHaveBeenCalled()
expect(toastSpy).toHaveBeenCalledWith(
'Error saving document: failed to save'
)
})
it('should show error toast on save but close if user can no longer edit', () => {
currentUserHasObjectPermissions = false
initNormally()
component.title = 'Foo Bar'
const closeSpy = jest.spyOn(component, 'close')
const updateSpy = jest.spyOn(documentService, 'update')
const toastSpy = jest.spyOn(toastService, 'showInfo')
updateSpy.mockImplementation(() =>
throwError(() => new Error('failed to save'))
)
component.save(true)
expect(updateSpy).toHaveBeenCalled()
expect(closeSpy).toHaveBeenCalled()
expect(toastSpy).toHaveBeenCalledWith('Document saved successfully.')
})
it('should allow save and next', () => {
initNormally()
const nextDocId = 100
component.title = 'Foo Bar'
const updateSpy = jest.spyOn(documentService, 'update')
updateSpy.mockReturnValue(of(doc))
const nextSpy = jest.spyOn(documentListViewService, 'getNext')
nextSpy.mockReturnValue(of(nextDocId))
const closeSpy = jest.spyOn(openDocumentsService, 'closeDocument')
closeSpy.mockReturnValue(of(true))
const navigateSpy = jest.spyOn(router, 'navigate')
component.saveEditNext()
expect(updateSpy).toHaveBeenCalled()
expect(navigateSpy).toHaveBeenCalledWith(['documents', nextDocId])
expect
})
it('should show toast error on save & next if error occurs', () => {
currentUserHasObjectPermissions = true
initNormally()
component.title = 'Foo Bar'
const closeSpy = jest.spyOn(component, 'close')
const updateSpy = jest.spyOn(documentService, 'update')
const toastSpy = jest.spyOn(toastService, 'showError')
updateSpy.mockImplementation(() =>
throwError(() => new Error('failed to save'))
)
component.saveEditNext()
expect(updateSpy).toHaveBeenCalled()
expect(closeSpy).not.toHaveBeenCalled()
expect(toastSpy).toHaveBeenCalledWith(
'Error saving document: failed to save'
)
})
it('should show save button and save & close or save & next', () => {
const nextSpy = jest.spyOn(component, 'hasNext')
nextSpy.mockReturnValueOnce(false)
fixture.detectChanges()
expect(
fixture.debugElement
.queryAll(By.css('button'))
.find((b) => b.nativeElement.textContent === 'Save')
).not.toBeUndefined()
expect(
fixture.debugElement
.queryAll(By.css('button'))
.find((b) => b.nativeElement.textContent === 'Save & close')
).not.toBeUndefined()
expect(
fixture.debugElement
.queryAll(By.css('button'))
.find((b) => b.nativeElement.textContent === 'Save & next')
).toBeUndefined()
nextSpy.mockReturnValue(true)
fixture.detectChanges()
expect(
fixture.debugElement
.queryAll(By.css('button'))
.find((b) => b.nativeElement.textContent === 'Save & close')
).toBeUndefined()
expect(
fixture.debugElement
.queryAll(By.css('button'))
.find((b) => b.nativeElement.textContent === 'Save & next')
).not.toBeUndefined()
})
it('should allow close and navigate to documents by default', () => {
initNormally()
const navigateSpy = jest.spyOn(router, 'navigate')
component.close()
expect(navigateSpy).toHaveBeenCalledWith(['documents'])
})
it('should allow close and navigate to documents by default', () => {
initNormally()
jest
.spyOn(documentListViewService, 'activeSavedViewId', 'get')
.mockReturnValue(77)
const navigateSpy = jest.spyOn(router, 'navigate')
component.close()
expect(navigateSpy).toHaveBeenCalledWith(['view', 77])
})
it('should not close if e.g. user-cancelled', () => {
initNormally()
jest.spyOn(openDocumentsService, 'closeDocument').mockReturnValue(of(false))
const navigateSpy = jest.spyOn(router, 'navigate')
component.close()
expect(navigateSpy).not.toHaveBeenCalled()
})
it('should support delete, ask for confirmation', () => {
initNormally()
let openModal: NgbModalRef
modalService.activeInstances.subscribe((modal) => (openModal = modal[0]))
const modalSpy = jest.spyOn(modalService, 'open')
const deleteSpy = jest.spyOn(documentService, 'delete')
deleteSpy.mockReturnValue(of(true))
component.delete()
expect(modalSpy).toHaveBeenCalled()
const modalCloseSpy = jest.spyOn(openModal, 'close')
openModal.componentInstance.confirmClicked.next()
expect(deleteSpy).toHaveBeenCalled()
expect(modalCloseSpy).toHaveBeenCalled()
})
it('should allow retry delete if error', () => {
initNormally()
let openModal: NgbModalRef
modalService.activeInstances.subscribe((modal) => (openModal = modal[0]))
const modalSpy = jest.spyOn(modalService, 'open')
const deleteSpy = jest.spyOn(documentService, 'delete')
deleteSpy.mockReturnValueOnce(throwError(() => new Error('one time')))
component.delete()
expect(modalSpy).toHaveBeenCalled()
const modalCloseSpy = jest.spyOn(openModal, 'close')
openModal.componentInstance.confirmClicked.next()
expect(deleteSpy).toHaveBeenCalled()
expect(modalCloseSpy).not.toHaveBeenCalled()
deleteSpy.mockReturnValueOnce(of(true))
// retry
openModal.componentInstance.confirmClicked.next()
expect(deleteSpy).toHaveBeenCalled()
expect(modalCloseSpy).toHaveBeenCalled()
})
it('should support more like quick filter', () => {
initNormally()
const qfSpy = jest.spyOn(documentListViewService, 'quickFilter')
component.moreLike()
expect(qfSpy).toHaveBeenCalledWith([
{
rule_type: FILTER_FULLTEXT_MORELIKE,
value: doc.id.toString(),
},
])
})
it('should support redo ocr, confirm and close modal after started', () => {
initNormally()
const bulkEditSpy = jest.spyOn(documentService, 'bulkEdit')
bulkEditSpy.mockReturnValue(of(true))
let openModal: NgbModalRef
modalService.activeInstances.subscribe((modal) => (openModal = modal[0]))
const modalSpy = jest.spyOn(modalService, 'open')
const toastSpy = jest.spyOn(toastService, 'showInfo')
component.redoOcr()
const modalCloseSpy = jest.spyOn(openModal, 'close')
openModal.componentInstance.confirmClicked.next()
expect(bulkEditSpy).toHaveBeenCalledWith([doc.id], 'redo_ocr', {})
expect(modalSpy).toHaveBeenCalled()
expect(toastSpy).toHaveBeenCalled()
expect(modalCloseSpy).toHaveBeenCalled()
})
it('should show error if redo ocr call fails', () => {
initNormally()
const bulkEditSpy = jest.spyOn(documentService, 'bulkEdit')
let openModal: NgbModalRef
modalService.activeInstances.subscribe((modal) => (openModal = modal[0]))
const toastSpy = jest.spyOn(toastService, 'showError')
component.redoOcr()
const modalCloseSpy = jest.spyOn(openModal, 'close')
bulkEditSpy.mockReturnValue(throwError(() => new Error('error occurred')))
openModal.componentInstance.confirmClicked.next()
expect(toastSpy).toHaveBeenCalled()
expect(modalCloseSpy).not.toHaveBeenCalled()
})
it('should support next doc', () => {
initNormally()
const serviceSpy = jest.spyOn(documentListViewService, 'getNext')
const routerSpy = jest.spyOn(router, 'navigate')
serviceSpy.mockReturnValue(of(100))
component.nextDoc()
expect(serviceSpy).toHaveBeenCalled()
expect(routerSpy).toHaveBeenCalledWith(['documents', 100])
})
it('should support previous doc', () => {
initNormally()
const serviceSpy = jest.spyOn(documentListViewService, 'getPrevious')
const routerSpy = jest.spyOn(router, 'navigate')
serviceSpy.mockReturnValue(of(100))
component.previousDoc()
expect(serviceSpy).toHaveBeenCalled()
expect(routerSpy).toHaveBeenCalledWith(['documents', 100])
})
it('should support password-protected PDFs with a password field', () => {
initNormally()
component.onError({ name: 'PasswordException' }) // normally dispatched by pdf viewer
expect(component.requiresPassword).toBeTruthy()
fixture.detectChanges()
expect(
fixture.debugElement.query(By.css('input[type=password]'))
).not.toBeUndefined()
component.password = 'foo'
component.pdfPreviewLoaded({ numPages: 1000 } as any)
expect(component.requiresPassword).toBeFalsy()
})
it('should support Enter key in password field', () => {
initNormally()
component.onError({ name: 'PasswordException' }) // normally dispatched by pdf viewer
fixture.detectChanges()
expect(component.password).toBeUndefined()
const pwField = fixture.debugElement.query(By.css('input[type=password]'))
pwField.nativeElement.value = 'foobar'
pwField.nativeElement.dispatchEvent(
new KeyboardEvent('keyup', { key: 'Enter' })
)
expect(component.password).toEqual('foobar')
})
it('should update n pages after pdf loaded', () => {
initNormally()
component.pdfPreviewLoaded({ numPages: 1000 } as any)
expect(component.previewNumPages).toEqual(1000)
})
it('should support updating notes dynamically', () => {
const notes = [
{
id: 1,
note: 'hello world',
},
]
initNormally()
const refreshSpy = jest.spyOn(openDocumentsService, 'refreshDocument')
component.notesUpdated(notes) // called by notes component
expect(component.document.notes).toEqual(notes)
expect(refreshSpy).toHaveBeenCalled()
})
it('should support quick filtering by correspondent', () => {
initNormally()
const object = {
id: 22,
name: 'Correspondent22',
last_correspondence: new Date(),
} as PaperlessCorrespondent
const qfSpy = jest.spyOn(documentListViewService, 'quickFilter')
component.filterDocuments([object])
expect(qfSpy).toHaveBeenCalledWith([
{
rule_type: FILTER_CORRESPONDENT,
value: object.id.toString(),
},
])
})
it('should support quick filtering by doc type', () => {
initNormally()
const object = { id: 22, name: 'DocumentType22' } as PaperlessDocumentType
const qfSpy = jest.spyOn(documentListViewService, 'quickFilter')
component.filterDocuments([object])
expect(qfSpy).toHaveBeenCalledWith([
{
rule_type: FILTER_DOCUMENT_TYPE,
value: object.id.toString(),
},
])
})
it('should support quick filtering by storage path', () => {
initNormally()
const object = {
id: 22,
name: 'StoragePath22',
path: '/foo/bar/',
} as PaperlessStoragePath
const qfSpy = jest.spyOn(documentListViewService, 'quickFilter')
component.filterDocuments([object])
expect(qfSpy).toHaveBeenCalledWith([
{
rule_type: FILTER_STORAGE_PATH,
value: object.id.toString(),
},
])
})
it('should support quick filtering by all tags', () => {
initNormally()
const object1 = {
id: 22,
name: 'Tag22',
is_inbox_tag: true,
color: '#ff0000',
text_color: '#000000',
} as PaperlessTag
const object2 = {
id: 23,
name: 'Tag22',
is_inbox_tag: true,
color: '#ff0000',
text_color: '#000000',
} as PaperlessTag
const qfSpy = jest.spyOn(documentListViewService, 'quickFilter')
component.filterDocuments([object1, object2])
expect(qfSpy).toHaveBeenCalledWith([
{
rule_type: FILTER_HAS_TAGS_ALL,
value: object1.id.toString(),
},
{
rule_type: FILTER_HAS_TAGS_ALL,
value: object2.id.toString(),
},
])
})
it('should support quick filtering by date after - 1d and before +1d', () => {
initNormally()
const object = { year: 2023, month: 5, day: 14 } as NgbDateStruct
const qfSpy = jest.spyOn(documentListViewService, 'quickFilter')
component.filterDocuments([object])
expect(qfSpy).toHaveBeenCalledWith([
{
rule_type: FILTER_CREATED_AFTER,
value: '2023-05-13',
},
{
rule_type: FILTER_CREATED_BEFORE,
value: '2023-05-15',
},
])
})
it('should detect RTL languages and add css class to content textarea', () => {
initNormally()
component.metadata = { lang: 'he' }
component.nav.select(2) // content
fixture.detectChanges()
expect(component.isRTL).toBeTruthy()
expect(fixture.debugElement.queryAll(By.css('textarea.rtl'))).not.toBeNull()
})
it('should display built-in pdf viewer if not disabled', () => {
initNormally()
component.metadata = { has_archive_version: true }
jest.spyOn(settingsService, 'get').mockReturnValue(false)
expect(component.useNativePdfViewer).toBeFalsy()
fixture.detectChanges()
expect(fixture.debugElement.query(By.css('pdf-viewer'))).not.toBeNull()
})
it('should display native pdf viewer if enabled', () => {
initNormally()
component.metadata = { has_archive_version: true }
jest.spyOn(settingsService, 'get').mockReturnValue(true)
expect(component.useNativePdfViewer).toBeTruthy()
fixture.detectChanges()
expect(fixture.debugElement.query(By.css('object'))).not.toBeNull()
})
it('should attempt to retrieve metadata', () => {
const metadataSpy = jest.spyOn(documentService, 'getMetadata')
metadataSpy.mockReturnValue(of({ has_archive_version: true }))
initNormally()
expect(metadataSpy).toHaveBeenCalled()
})
it('should show an error if failed metadata retrieval', () => {
const error = new Error('metadata error')
jest
.spyOn(documentService, 'getMetadata')
.mockReturnValue(throwError(() => error))
const toastSpy = jest.spyOn(toastService, 'showError')
initNormally()
expect(toastSpy).toHaveBeenCalledWith(
'Error retrieving metadata',
10000,
error
)
})
function initNormally() {
jest.spyOn(documentService, 'get').mockReturnValueOnce(of(doc))
jest.spyOn(openDocumentsService, 'getOpenDocument').mockReturnValue(null)
jest
.spyOn(openDocumentsService, 'openDocument')
.mockReturnValueOnce(of(true))
fixture.detectChanges()
}
})

View File

@@ -40,9 +40,6 @@ import {
FILTER_CORRESPONDENT,
FILTER_CREATED_AFTER,
FILTER_CREATED_BEFORE,
FILTER_CREATED_DAY,
FILTER_CREATED_MONTH,
FILTER_CREATED_YEAR,
FILTER_DOCUMENT_TYPE,
FILTER_FULLTEXT_MORELIKE,
FILTER_HAS_TAGS_ALL,
@@ -62,8 +59,9 @@ import { UserService } from 'src/app/services/rest/user.service'
import { PaperlessDocumentNote } from 'src/app/data/paperless-document-note'
import { HttpClient } from '@angular/common/http'
import { ComponentWithPermissions } from '../with-permissions/with-permissions.component'
import { FilterRule } from 'src/app/data/filter-rule'
import { EditDialogMode } from '../common/edit-dialog/edit-dialog.component'
import { ObjectWithId } from 'src/app/data/object-with-id'
import { FilterRule } from 'src/app/data/filter-rule'
import { ISODateAdapter } from 'src/app/utils/ngb-iso-date-adapter'
enum DocumentDetailNavIDs {
@@ -438,7 +436,7 @@ export class DocumentDetailComponent
var modal = this.modalService.open(DocumentTypeEditDialogComponent, {
backdrop: 'static',
})
modal.componentInstance.dialogMode = 'create'
modal.componentInstance.dialogMode = EditDialogMode.CREATE
if (newName) modal.componentInstance.object = { name: newName }
modal.componentInstance.succeeded
.pipe(
@@ -459,7 +457,7 @@ export class DocumentDetailComponent
var modal = this.modalService.open(CorrespondentEditDialogComponent, {
backdrop: 'static',
})
modal.componentInstance.dialogMode = 'create'
modal.componentInstance.dialogMode = EditDialogMode.CREATE
if (newName) modal.componentInstance.object = { name: newName }
modal.componentInstance.succeeded
.pipe(
@@ -482,7 +480,7 @@ export class DocumentDetailComponent
var modal = this.modalService.open(StoragePathEditDialogComponent, {
backdrop: 'static',
})
modal.componentInstance.dialogMode = 'create'
modal.componentInstance.dialogMode = EditDialogMode.CREATE
if (newName) modal.componentInstance.object = { name: newName }
modal.componentInstance.succeeded
.pipe(
@@ -520,7 +518,7 @@ export class DocumentDetailComponent
})
}
save() {
save(close: boolean = false) {
this.networkActive = true
this.documentsService
.update(this.document)
@@ -529,7 +527,7 @@ export class DocumentDetailComponent
next: () => {
this.store.next(this.documentForm.value)
this.toastService.showInfo($localize`Document saved successfully.`)
this.close()
close && this.close()
this.networkActive = false
this.error = null
},
@@ -537,7 +535,7 @@ export class DocumentDetailComponent
this.networkActive = false
if (!this.userCanEdit) {
this.toastService.showInfo($localize`Document saved successfully.`)
this.close()
close && this.close()
} else {
this.error = error.error
this.toastService.showError(

View File

@@ -0,0 +1,51 @@
import { ComponentFixture, TestBed } from '@angular/core/testing'
import { MetadataCollapseComponent } from './metadata-collapse.component'
import { NgbCollapseModule } from '@ng-bootstrap/ng-bootstrap'
const metadata = [
{
namespace: 'http://ns.adobe.com/pdf/1.3/',
prefix: 'pdf',
key: 'Producer',
value: 'pikepdf 2.2.0',
},
{
namespace: 'http://ns.adobe.com/xap/1.0/',
prefix: 'xmp',
key: 'ModifyDate',
value: '2020-12-21T08:42:26+00:00',
},
]
describe('MetadataCollapseComponent', () => {
let component: MetadataCollapseComponent
let fixture: ComponentFixture<MetadataCollapseComponent>
beforeEach(async () => {
TestBed.configureTestingModule({
declarations: [MetadataCollapseComponent],
providers: [],
imports: [NgbCollapseModule],
}).compileComponents()
fixture = TestBed.createComponent(MetadataCollapseComponent)
component = fixture.componentInstance
})
it('should display metadata', () => {
component.title = 'Foo'
fixture.detectChanges()
expect(fixture.debugElement.nativeElement.textContent).toContain('Foo')
})
it('should display metadata', () => {
component.metadata = metadata
fixture.detectChanges()
expect(fixture.debugElement.nativeElement.textContent).toContain(
'pikepdf 2.2.0'
)
expect(fixture.debugElement.nativeElement.textContent).toContain(
'ModifyDate'
)
})
})

View File

@@ -0,0 +1,869 @@
import {
HttpTestingController,
HttpClientTestingModule,
} from '@angular/common/http/testing'
import { ComponentFixture, TestBed } from '@angular/core/testing'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { By } from '@angular/platform-browser'
import {
NgbModal,
NgbModule,
NgbModalModule,
NgbModalRef,
} from '@ng-bootstrap/ng-bootstrap'
import { of, throwError } from 'rxjs'
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
import { FilterPipe } from 'src/app/pipes/filter.pipe'
import { SafeHtmlPipe } from 'src/app/pipes/safehtml.pipe'
import { DocumentListViewService } from 'src/app/services/document-list-view.service'
import { PermissionsService } from 'src/app/services/permissions.service'
import { CorrespondentService } from 'src/app/services/rest/correspondent.service'
import { DocumentTypeService } from 'src/app/services/rest/document-type.service'
import {
SelectionData,
DocumentService,
} from 'src/app/services/rest/document.service'
import { StoragePathService } from 'src/app/services/rest/storage-path.service'
import { TagService } from 'src/app/services/rest/tag.service'
import { SettingsService } from 'src/app/services/settings.service'
import { ToastService } from 'src/app/services/toast.service'
import { environment } from 'src/environments/environment'
import { ConfirmDialogComponent } from '../../common/confirm-dialog/confirm-dialog.component'
import { FilterableDropdownComponent } from '../../common/filterable-dropdown/filterable-dropdown.component'
import { ToggleableDropdownButtonComponent } from '../../common/filterable-dropdown/toggleable-dropdown-button/toggleable-dropdown-button.component'
import { PermissionsDialogComponent } from '../../common/permissions-dialog/permissions-dialog.component'
import { PermissionsFormComponent } from '../../common/input/permissions/permissions-form/permissions-form.component'
import { BulkEditorComponent } from './bulk-editor.component'
import { SelectComponent } from '../../common/input/select/select.component'
import { UserService } from 'src/app/services/rest/user.service'
import { PermissionsGroupComponent } from '../../common/input/permissions/permissions-group/permissions-group.component'
import { PermissionsUserComponent } from '../../common/input/permissions/permissions-user/permissions-user.component'
import { NgSelectModule } from '@ng-select/ng-select'
import { GroupService } from 'src/app/services/rest/group.service'
const selectionData: SelectionData = {
selected_tags: [
{ id: 12, document_count: 3 },
{ id: 22, document_count: 1 },
{ id: 19, document_count: 0 },
],
selected_correspondents: [{ id: 33, document_count: 1 }],
selected_document_types: [{ id: 44, document_count: 3 }],
selected_storage_paths: [
{ id: 66, document_count: 3 },
{ id: 55, document_count: 0 },
],
}
describe('BulkEditorComponent', () => {
let component: BulkEditorComponent
let fixture: ComponentFixture<BulkEditorComponent>
let permissionsService: PermissionsService
let documentListViewService: DocumentListViewService
let documentService: DocumentService
let toastService: ToastService
let modalService: NgbModal
let httpTestingController: HttpTestingController
beforeEach(async () => {
TestBed.configureTestingModule({
declarations: [
BulkEditorComponent,
IfPermissionsDirective,
FilterableDropdownComponent,
ToggleableDropdownButtonComponent,
FilterPipe,
ConfirmDialogComponent,
SafeHtmlPipe,
PermissionsDialogComponent,
PermissionsFormComponent,
SelectComponent,
PermissionsGroupComponent,
PermissionsUserComponent,
],
providers: [
PermissionsService,
{
provide: TagService,
useValue: {
listAll: () =>
of({
results: [
{ id: 12, name: 'tag12' },
{ id: 22, name: 'tag22' },
],
}),
},
},
{
provide: CorrespondentService,
useValue: {
listAll: () =>
of({
results: [{ id: 33, name: 'correspondent33' }],
}),
},
},
{
provide: DocumentTypeService,
useValue: {
listAll: () =>
of({
results: [{ id: 44, name: 'doctype44' }],
}),
},
},
{
provide: StoragePathService,
useValue: {
listAll: () =>
of({
results: [
{ id: 66, name: 'storagepath66' },
{ id: 55, name: 'storagepath55' },
],
}),
},
},
FilterPipe,
SettingsService,
{
provide: UserService,
useValue: {
listAll: () =>
of({
results: [{ id: 1, username: 'user1' }],
}),
},
},
{
provide: GroupService,
useValue: {
listAll: () =>
of({
results: [],
}),
},
},
],
imports: [
HttpClientTestingModule,
FormsModule,
ReactiveFormsModule,
NgbModule,
NgbModalModule,
NgSelectModule,
],
}).compileComponents()
permissionsService = TestBed.inject(PermissionsService)
documentListViewService = TestBed.inject(DocumentListViewService)
documentService = TestBed.inject(DocumentService)
toastService = TestBed.inject(ToastService)
modalService = TestBed.inject(NgbModal)
httpTestingController = TestBed.inject(HttpTestingController)
fixture = TestBed.createComponent(BulkEditorComponent)
component = fixture.componentInstance
})
afterEach(async () => {
httpTestingController.verify()
})
it('should apply selection data to tags menu', () => {
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
fixture.detectChanges()
expect(component.tagSelectionModel.getSelectedItems()).toHaveLength(0)
jest
.spyOn(documentListViewService, 'selected', 'get')
.mockReturnValue(new Set([3, 5, 7]))
jest
.spyOn(documentService, 'getSelectionData')
.mockReturnValue(of(selectionData))
component.openTagsDropdown()
expect(component.tagSelectionModel.selectionSize()).toEqual(1)
})
it('should apply selection data to correspondents menu', () => {
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
fixture.detectChanges()
expect(
component.correspondentSelectionModel.getSelectedItems()
).toHaveLength(0)
jest
.spyOn(documentListViewService, 'selected', 'get')
.mockReturnValue(new Set([3, 5, 7]))
jest
.spyOn(documentService, 'getSelectionData')
.mockReturnValue(of(selectionData))
component.openCorrespondentDropdown()
expect(component.correspondentSelectionModel.items).toHaveLength(2)
expect(component.correspondentSelectionModel.selectionSize()).toEqual(0)
})
it('should apply selection data to doc types menu', () => {
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
fixture.detectChanges()
expect(
component.documentTypeSelectionModel.getSelectedItems()
).toHaveLength(0)
jest
.spyOn(documentListViewService, 'selected', 'get')
.mockReturnValue(new Set([3, 5, 7]))
jest
.spyOn(documentService, 'getSelectionData')
.mockReturnValue(of(selectionData))
component.openDocumentTypeDropdown()
expect(component.documentTypeSelectionModel.selectionSize()).toEqual(1)
})
it('should apply selection data to storage path menu', () => {
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
fixture.detectChanges()
expect(
component.storagePathsSelectionModel.getSelectedItems()
).toHaveLength(0)
jest
.spyOn(documentListViewService, 'selected', 'get')
.mockReturnValue(new Set([3, 5, 7]))
jest
.spyOn(documentService, 'getSelectionData')
.mockReturnValue(of(selectionData))
component.openStoragePathDropdown()
expect(component.storagePathsSelectionModel.selectionSize()).toEqual(1)
})
it('should execute modify tags bulk operation', () => {
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)
component.showConfirmationDialogs = false
fixture.detectChanges()
component.setTags({
itemsToAdd: [{ id: 101 }],
itemsToRemove: [],
})
let req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/bulk_edit/`
)
req.flush(true)
expect(req.request.body).toEqual({
documents: [3, 4],
method: 'modify_tags',
parameters: { add_tags: [101], remove_tags: [] },
})
httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
) // list reload
httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
) // listAllFilteredIds
})
it('should execute modify tags bulk operation with confirmation dialog if enabled', () => {
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)
component.showConfirmationDialogs = true
fixture.detectChanges()
component.setTags({
itemsToAdd: [{ id: 101 }],
itemsToRemove: [],
})
expect(modal).not.toBeUndefined()
modal.componentInstance.confirm()
httpTestingController
.expectOne(`${environment.apiBaseUrl}documents/bulk_edit/`)
.flush(true)
httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
) // list reload
httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
) // listAllFilteredIds
})
it('should set modal dialog text accordingly for tag edit confirmation', () => {
let modal: NgbModalRef
modalService.activeInstances.subscribe((m) => (modal = m[m.length - 1]))
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)
component.showConfirmationDialogs = true
fixture.detectChanges()
component.setTags({
itemsToAdd: [],
itemsToRemove: [{ id: 101, name: 'Tag 101' }],
})
expect(modal.componentInstance.message).toEqual(
'This operation will remove the tag "Tag 101" from 2 selected document(s).'
)
modal.close()
component.setTags({
itemsToAdd: [],
itemsToRemove: [
{ id: 101, name: 'Tag 101' },
{ id: 102, name: 'Tag 102' },
],
})
expect(modal.componentInstance.message).toEqual(
'This operation will remove the tags "Tag 101" and "Tag 102" from 2 selected document(s).'
)
modal.close()
component.setTags({
itemsToAdd: [
{ id: 101, name: 'Tag 101' },
{ id: 102, name: 'Tag 102' },
],
itemsToRemove: [],
})
expect(modal.componentInstance.message).toEqual(
'This operation will add the tags "Tag 101" and "Tag 102" to 2 selected document(s).'
)
modal.close()
component.setTags({
itemsToAdd: [
{ id: 101, name: 'Tag 101' },
{ id: 102, name: 'Tag 102' },
],
itemsToRemove: [{ id: 103, name: 'Tag 103' }],
})
expect(modal.componentInstance.message).toEqual(
'This operation will add the tags "Tag 101" and "Tag 102" and remove the tags "Tag 103" on 2 selected document(s).'
)
})
it('should execute modify correspondent bulk operation', () => {
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)
component.showConfirmationDialogs = false
fixture.detectChanges()
component.setCorrespondents({
itemsToAdd: [{ id: 101 }],
itemsToRemove: [],
})
let req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/bulk_edit/`
)
req.flush(true)
expect(req.request.body).toEqual({
documents: [3, 4],
method: 'set_correspondent',
parameters: { correspondent: 101 },
})
httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
) // list reload
httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
) // listAllFilteredIds
})
it('should execute modify correspondent bulk operation with confirmation dialog if enabled', () => {
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)
component.showConfirmationDialogs = true
fixture.detectChanges()
component.setCorrespondents({
itemsToAdd: [{ id: 101 }],
itemsToRemove: [],
})
expect(modal).not.toBeUndefined()
modal.componentInstance.confirm()
httpTestingController
.expectOne(`${environment.apiBaseUrl}documents/bulk_edit/`)
.flush(true)
httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
) // list reload
httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
) // listAllFilteredIds
})
it('should set modal dialog text accordingly for correspondent edit confirmation', () => {
let modal: NgbModalRef
modalService.activeInstances.subscribe((m) => (modal = m[m.length - 1]))
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)
component.showConfirmationDialogs = true
fixture.detectChanges()
component.setCorrespondents({
itemsToAdd: [],
itemsToRemove: [{ id: 101, name: 'Correspondent 101' }],
})
expect(modal.componentInstance.message).toEqual(
'This operation will remove the correspondent from 2 selected document(s).'
)
modal.close()
component.setCorrespondents({
itemsToAdd: [{ id: 101, name: 'Correspondent 101' }],
itemsToRemove: [],
})
expect(modal.componentInstance.message).toEqual(
'This operation will assign the correspondent "Correspondent 101" to 2 selected document(s).'
)
})
it('should execute modify document type bulk operation', () => {
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)
component.showConfirmationDialogs = false
fixture.detectChanges()
component.setDocumentTypes({
itemsToAdd: [{ id: 101 }],
itemsToRemove: [],
})
let req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/bulk_edit/`
)
req.flush(true)
expect(req.request.body).toEqual({
documents: [3, 4],
method: 'set_document_type',
parameters: { document_type: 101 },
})
httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
) // list reload
httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
) // listAllFilteredIds
})
it('should execute modify document type bulk operation with confirmation dialog if enabled', () => {
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)
component.showConfirmationDialogs = true
fixture.detectChanges()
component.setDocumentTypes({
itemsToAdd: [{ id: 101 }],
itemsToRemove: [],
})
expect(modal).not.toBeUndefined()
modal.componentInstance.confirm()
httpTestingController
.expectOne(`${environment.apiBaseUrl}documents/bulk_edit/`)
.flush(true)
httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
) // list reload
httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
) // listAllFilteredIds
})
it('should set modal dialog text accordingly for document type edit confirmation', () => {
let modal: NgbModalRef
modalService.activeInstances.subscribe((m) => (modal = m[m.length - 1]))
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)
component.showConfirmationDialogs = true
fixture.detectChanges()
component.setDocumentTypes({
itemsToAdd: [],
itemsToRemove: [{ id: 101, name: 'DocType 101' }],
})
expect(modal.componentInstance.message).toEqual(
'This operation will remove the document type from 2 selected document(s).'
)
modal.close()
component.setDocumentTypes({
itemsToAdd: [{ id: 101, name: 'DocType 101' }],
itemsToRemove: [],
})
expect(modal.componentInstance.message).toEqual(
'This operation will assign the document type "DocType 101" to 2 selected document(s).'
)
})
it('should execute modify storage path bulk operation', () => {
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)
component.showConfirmationDialogs = false
fixture.detectChanges()
component.setStoragePaths({
itemsToAdd: [{ id: 101 }],
itemsToRemove: [],
})
let req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/bulk_edit/`
)
req.flush(true)
expect(req.request.body).toEqual({
documents: [3, 4],
method: 'set_storage_path',
parameters: { storage_path: 101 },
})
httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
) // list reload
httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
) // listAllFilteredIds
})
it('should execute modify storage path bulk operation with confirmation dialog if enabled', () => {
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)
component.showConfirmationDialogs = true
fixture.detectChanges()
component.setStoragePaths({
itemsToAdd: [{ id: 101 }],
itemsToRemove: [],
})
expect(modal).not.toBeUndefined()
modal.componentInstance.confirm()
httpTestingController
.expectOne(`${environment.apiBaseUrl}documents/bulk_edit/`)
.flush(true)
httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
) // list reload
httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
) // listAllFilteredIds
})
it('should set modal dialog text accordingly for storage path edit confirmation', () => {
let modal: NgbModalRef
modalService.activeInstances.subscribe((m) => (modal = m[m.length - 1]))
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)
component.showConfirmationDialogs = true
fixture.detectChanges()
component.setStoragePaths({
itemsToAdd: [],
itemsToRemove: [{ id: 101, name: 'StoragePath 101' }],
})
expect(modal.componentInstance.message).toEqual(
'This operation will remove the storage path from 2 selected document(s).'
)
modal.close()
component.setStoragePaths({
itemsToAdd: [{ id: 101, name: 'StoragePath 101' }],
itemsToRemove: [],
})
expect(modal.componentInstance.message).toEqual(
'This operation will assign the storage path "StoragePath 101" to 2 selected document(s).'
)
})
it('should only execute bulk operations when changes are detected', () => {
component.setTags({
itemsToAdd: [],
itemsToRemove: [],
})
component.setCorrespondents({
itemsToAdd: [],
itemsToRemove: [],
})
component.setDocumentTypes({
itemsToAdd: [],
itemsToRemove: [],
})
component.setStoragePaths({
itemsToAdd: [],
itemsToRemove: [],
})
httpTestingController.expectNone(
`${environment.apiBaseUrl}documents/bulk_edit/`
)
})
it('should support bulk delete with confirmation', () => {
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)
component.showConfirmationDialogs = true
fixture.detectChanges()
component.applyDelete()
expect(modal).not.toBeUndefined()
modal.componentInstance.confirm()
let req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/bulk_edit/`
)
req.flush(true)
expect(req.request.body).toEqual({
documents: [3, 4],
method: 'delete',
parameters: {},
})
httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
) // list reload
httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
) // listAllFilteredIds
})
it('should not be accessible with insufficient global permissions', () => {
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(false)
fixture.detectChanges()
const dropdown = fixture.debugElement.query(
By.directive(FilterableDropdownComponent)
)
expect(dropdown).toBeNull()
})
it('should disable with insufficient object permissions', () => {
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(false)
fixture.detectChanges()
const button = fixture.debugElement
.query(By.directive(FilterableDropdownComponent))
.query(By.css('button'))
expect(button.nativeElement.disabled).toBeTruthy()
})
it('should show a warning toast on bulk edit error', () => {
jest
.spyOn(documentService, 'bulkEdit')
.mockReturnValue(
throwError(() => new Error('error executing bulk operation'))
)
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)
component.showConfirmationDialogs = false
fixture.detectChanges()
const toastSpy = jest.spyOn(toastService, 'showError')
component.setTags({
itemsToAdd: [{ id: 0 }],
itemsToRemove: [],
})
expect(toastSpy).toHaveBeenCalled()
})
it('should support redo 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)
component.showConfirmationDialogs = true
fixture.detectChanges()
component.redoOcrSelected()
expect(modal).not.toBeUndefined()
modal.componentInstance.confirm()
let req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/bulk_edit/`
)
req.flush(true)
expect(req.request.body).toEqual({
documents: [3, 4],
method: 'redo_ocr',
parameters: {},
})
httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
) // list reload
httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
) // listAllFilteredIds
})
it('should support bulk download with archive, originals or both and file formatting', () => {
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)
component.downloadForm.get('downloadFileTypeArchive').patchValue(true)
fixture.detectChanges()
let downloadSpy = jest.spyOn(documentService, 'bulkDownload')
//archive
component.downloadSelected()
expect(downloadSpy).toHaveBeenCalledWith([3, 4], 'archive', false)
//originals
component.downloadForm.get('downloadFileTypeArchive').patchValue(false)
component.downloadForm.get('downloadFileTypeOriginals').patchValue(true)
component.downloadSelected()
expect(downloadSpy).toHaveBeenCalledWith([3, 4], 'originals', false)
//both
component.downloadForm.get('downloadFileTypeArchive').patchValue(true)
component.downloadSelected()
expect(downloadSpy).toHaveBeenCalledWith([3, 4], 'both', false)
//formatting
component.downloadForm.get('downloadUseFormatting').patchValue(true)
component.downloadSelected()
expect(downloadSpy).toHaveBeenCalledWith([3, 4], 'both', true)
httpTestingController.match(
`${environment.apiBaseUrl}documents/bulk_download/`
)
})
it('should support bulk permissions update', () => {
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)
component.showConfirmationDialogs = true
fixture.detectChanges()
component.setPermissions()
expect(modal).not.toBeUndefined()
modal.componentInstance.confirmClicked.next()
let req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/bulk_edit/`
)
req.flush(true)
expect(req.request.body).toEqual({
documents: [3, 4],
method: 'set_permissions',
parameters: undefined,
})
httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
) // list reload
httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
) // listAllFilteredIds
})
})

View File

@@ -0,0 +1,129 @@
import { DatePipe } from '@angular/common'
import { HttpClientTestingModule } from '@angular/common/http/testing'
import {
ComponentFixture,
TestBed,
fakeAsync,
tick,
} from '@angular/core/testing'
import { By } from '@angular/platform-browser'
import { RouterTestingModule } from '@angular/router/testing'
import {
NgbPopoverModule,
NgbTooltipModule,
NgbProgressbarModule,
} from '@ng-bootstrap/ng-bootstrap'
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
import { DocumentTitlePipe } from 'src/app/pipes/document-title.pipe'
import { SafeUrlPipe } from 'src/app/pipes/safeurl.pipe'
import { DocumentCardLargeComponent } from './document-card-large.component'
const doc = {
id: 10,
title: 'Document 10',
tags: [3, 4, 5],
correspondent: 8,
document_type: 10,
storage_path: null,
notes: [
{
id: 11,
note: 'This is some note content bananas',
},
],
content:
'Cupcake ipsum dolor sit amet ice cream. Donut shortbread cheesecake caramels tiramisu pastry caramels chocolate bar. Tart tootsie roll muffin icing cotton candy topping sweet roll. Pie lollipop dragée sesame snaps donut tart pudding. Oat cake apple pie danish danish candy canes. Shortbread candy canes sesame snaps muffin tiramisu marshmallow chocolate bar halvah. Cake lemon drops candy apple pie carrot cake bonbon halvah pastry gummi bears. Sweet roll candy ice cream sesame snaps marzipan cookie ice cream. Cake cheesecake apple pie muffin candy toffee lollipop. Carrot cake oat cake cookie biscuit cupcake cake marshmallow. Sweet roll jujubes carrot cake cheesecake cake candy canes sweet roll gingerbread jelly beans. Apple pie sugar plum oat cake halvah cake. Pie oat cake chocolate cake cookie gingerbread marzipan. Lemon drops cheesecake lollipop danish marzipan candy.',
}
describe('DocumentCardLargeComponent', () => {
let component: DocumentCardLargeComponent
let fixture: ComponentFixture<DocumentCardLargeComponent>
beforeEach(async () => {
TestBed.configureTestingModule({
declarations: [
DocumentCardLargeComponent,
DocumentTitlePipe,
CustomDatePipe,
IfPermissionsDirective,
SafeUrlPipe,
],
providers: [DatePipe],
imports: [
HttpClientTestingModule,
RouterTestingModule,
NgbPopoverModule,
NgbTooltipModule,
NgbProgressbarModule,
],
}).compileComponents()
fixture = TestBed.createComponent(DocumentCardLargeComponent)
component = fixture.componentInstance
component.document = doc
fixture.detectChanges()
})
it('should display a document', () => {
expect(fixture.nativeElement.textContent).toContain('Document 10')
expect(fixture.nativeElement.textContent).toContain('Cupcake ipsum')
})
it('should show preview on mouseover after delay to preload content', fakeAsync(() => {
component.mouseEnterPreview()
expect(component.popover.isOpen()).toBeTruthy()
expect(component.popoverHidden).toBeTruthy()
tick(600)
expect(component.popoverHidden).toBeFalsy()
component.mouseLeaveCard()
component.mouseEnterPreview()
tick(100)
component.mouseLeavePreview()
tick(600)
expect(component.popover.isOpen()).toBeFalsy()
}))
it('should trim content', () => {
expect(component.contentTrimmed).toHaveLength(503) // includes ...
})
it('should display search hits with colored score', () => {
// high
component.document.__search_hit__ = {
score: 0.9,
rank: 1,
highlights: 'cheesecake',
}
fixture.detectChanges()
let search_hit = fixture.debugElement.query(By.css('.search-score'))
expect(search_hit).not.toBeUndefined()
expect(component.searchScoreClass).toEqual('success')
// medium
component.document.__search_hit__.score = 0.6
fixture.detectChanges()
search_hit = fixture.debugElement.query(By.css('.search-score'))
expect(search_hit).not.toBeUndefined()
expect(component.searchScoreClass).toEqual('warning')
// low
component.document.__search_hit__.score = 0.1
fixture.detectChanges()
search_hit = fixture.debugElement.query(By.css('.search-score'))
expect(search_hit).not.toBeUndefined()
expect(component.searchScoreClass).toEqual('danger')
})
it('should display note highlights', () => {
component.document.__search_hit__ = {
score: 0.9,
rank: 1,
note_highlights: '<span>bananas</span>',
}
fixture.detectChanges()
expect(fixture.nativeElement.textContent).toContain('bananas')
expect(component.searchNoteHighlights).toContain('<span>bananas</span>')
})
})

View File

@@ -133,7 +133,7 @@ export class DocumentCardLargeComponent extends ComponentWithPermissions {
get contentTrimmed() {
return (
this.document.content.substr(0, 500) +
this.document.content.substring(0, 500) +
(this.document.content.length > 500 ? '...' : '')
)
}

View File

@@ -29,7 +29,7 @@
<div class="card-body bg-light p-2">
<p class="card-text">
<ng-container *ngIf="document.correspondent">
<a title="Toggle correspondent filter" i18n-title (click)="clickCorrespondent.emit(document.correspondent);$event.stopPropagation()" class="fw-bold btn-link">{{(document.correspondent$ | async)?.name}}</a>:
<a title="Toggle correspondent filter" i18n-title (click)="clickCorrespondent.emit(document.correspondent);$event.stopPropagation()" class="fw-bold btn-link">{{(document.correspondent$ | async)?.name ?? privateName}}</a>:
</ng-container>
{{document.title | documentTitle}}
</p>
@@ -41,18 +41,18 @@
<svg class="metadata-icon me-2 text-muted" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#file-earmark"/>
</svg>
<small>{{(document.document_type$ | async)?.name}}</small>
<small>{{(document.document_type$ | async)?.name ?? privateName}}</small>
</button>
<button *ngIf="document.storage_path" type="button" class="list-group-item list-group-item-action bg-transparent ps-0 p-1 border-0" title="Toggle storage path filter" i18n-title
(click)="clickStoragePath.emit(document.storage_path);$event.stopPropagation()">
<svg class="metadata-icon me-2 text-muted" fill="currentColor">
<use xlink:href="assets/bootstrap-icons.svg#folder"/>
</svg>
<small>{{(document.storage_path$ | async)?.name}}</small>
<small>{{(document.storage_path$ | async)?.name ?? privateName}}</small>
</button>
<div class="list-group-item bg-transparent p-0 border-0 d-flex flex-wrap-reverse justify-content-between">
<ng-template #dateTooltip>
<div class="d-flex flex-column">
<div class="d-flex flex-column text-light">
<span i18n>Created: {{ document.created | customDate }}</span>
<span i18n>Added: {{ document.added | customDate }}</span>
<span i18n>Modified: {{ document.modified | customDate }}</span>

View File

@@ -0,0 +1,120 @@
import { DatePipe } from '@angular/common'
import { HttpClientTestingModule } from '@angular/common/http/testing'
import {
ComponentFixture,
TestBed,
fakeAsync,
tick,
} from '@angular/core/testing'
import { RouterTestingModule } from '@angular/router/testing'
import {
NgbPopoverModule,
NgbTooltipModule,
NgbProgressbarModule,
} from '@ng-bootstrap/ng-bootstrap'
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
import { DocumentTitlePipe } from 'src/app/pipes/document-title.pipe'
import { SafeUrlPipe } from 'src/app/pipes/safeurl.pipe'
import { DocumentCardSmallComponent } from './document-card-small.component'
import { of } from 'rxjs'
import { By } from '@angular/platform-browser'
import { TagComponent } from '../../common/tag/tag.component'
import { PaperlessTag } from 'src/app/data/paperless-tag'
const doc = {
id: 10,
title: 'Document 10',
tags: [1, 2, 3, 4, 5, 6, 7, 8],
correspondent: 8,
document_type: 10,
storage_path: null,
notes: [
{
id: 11,
note: 'This is some note content bananas',
},
],
tags$: of([
{ id: 1, name: 'Tag1' },
{ id: 2, name: 'Tag2' },
{ id: 3, name: 'Tag3' },
{ id: 4, name: 'Tag4' },
{ id: 5, name: 'Tag5' },
{ id: 6, name: 'Tag6' },
{ id: 7, name: 'Tag7' },
{ id: 8, name: 'Tag8' },
]),
content:
'Cupcake ipsum dolor sit amet ice cream. Donut shortbread cheesecake caramels tiramisu pastry caramels chocolate bar. Tart tootsie roll muffin icing cotton candy topping sweet roll. Pie lollipop dragée sesame snaps donut tart pudding. Oat cake apple pie danish danish candy canes. Shortbread candy canes sesame snaps muffin tiramisu marshmallow chocolate bar halvah. Cake lemon drops candy apple pie carrot cake bonbon halvah pastry gummi bears. Sweet roll candy ice cream sesame snaps marzipan cookie ice cream. Cake cheesecake apple pie muffin candy toffee lollipop. Carrot cake oat cake cookie biscuit cupcake cake marshmallow. Sweet roll jujubes carrot cake cheesecake cake candy canes sweet roll gingerbread jelly beans. Apple pie sugar plum oat cake halvah cake. Pie oat cake chocolate cake cookie gingerbread marzipan. Lemon drops cheesecake lollipop danish marzipan candy.',
}
describe('DocumentCardSmallComponent', () => {
let component: DocumentCardSmallComponent
let fixture: ComponentFixture<DocumentCardSmallComponent>
beforeEach(async () => {
TestBed.configureTestingModule({
declarations: [
DocumentCardSmallComponent,
DocumentTitlePipe,
CustomDatePipe,
IfPermissionsDirective,
SafeUrlPipe,
TagComponent,
],
providers: [DatePipe],
imports: [
HttpClientTestingModule,
RouterTestingModule,
NgbPopoverModule,
NgbTooltipModule,
NgbProgressbarModule,
],
}).compileComponents()
fixture = TestBed.createComponent(DocumentCardSmallComponent)
component = fixture.componentInstance
component.document = Object.assign({}, doc)
fixture.detectChanges()
})
it('should display a document, limit tags to 5', () => {
expect(fixture.nativeElement.textContent).toContain('Document 10')
expect(
fixture.debugElement.queryAll(By.directive(TagComponent))
).toHaveLength(5)
component.document.tags = [1, 2]
component.document.tags$ = of([
{ id: 1 } as PaperlessTag,
{ id: 2 } as PaperlessTag,
])
fixture.detectChanges()
expect(
fixture.debugElement.queryAll(By.directive(TagComponent))
).toHaveLength(2)
})
it('should increase limit tags to 6 if no notes', () => {
component.document.notes = []
fixture.detectChanges()
expect(
fixture.debugElement.queryAll(By.directive(TagComponent))
).toHaveLength(6)
})
it('should show preview on mouseover after delay to preload content', fakeAsync(() => {
component.mouseEnterPreview()
expect(component.popover.isOpen()).toBeTruthy()
expect(component.popoverHidden).toBeTruthy()
tick(600)
expect(component.popoverHidden).toBeFalsy()
component.mouseLeaveCard()
component.mouseEnterPreview()
tick(100)
component.mouseLeavePreview()
tick(600)
expect(component.popover.isOpen()).toBeFalsy()
}))
})

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