Compare commits

..

96 Commits

Author SHA1 Message Date
shamoon
292d7762c4 Basic RTL tweaks 2024-04-27 22:28:50 -07:00
shamoon
63e1f9f5d3 Feature: custom fields filtering & bulk editing (#6484) 2024-04-26 15:10:03 -07:00
shamoon
bd4476d484 Feature: customizable fields display for documents, saved views & dashboard widgets (#6439) 2024-04-26 06:41:12 -07:00
shamoon
7a0334f353 Fix: cast custom fields values list to list for other DB types
Closes #6482
2024-04-24 01:35:45 -07:00
shamoon
d03e48ea88 Fix missing test import 2024-04-24 00:28:18 -07:00
shamoon
342e6d4679 Fix: include number placeholder in relative date strings 2024-04-24 00:22:08 -07:00
dependabot[bot]
584f1361ad Chore(deps): Bump python-ipware in the major-versions group (#6468)
Bumps the major-versions group with 1 update: [python-ipware](https://github.com/un33k/python-ipware).


Updates `python-ipware` from 2.0.3 to 3.0.0
- [Changelog](https://github.com/un33k/python-ipware/blob/main/CHANGELOG.md)
- [Commits](https://github.com/un33k/python-ipware/compare/v2.0.3...v3.0.0)

---
updated-dependencies:
- dependency-name: python-ipware
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: major-versions
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-04-23 09:08:46 -07:00
shamoon
05b1ff9738 Feature: document history (audit log UI) (#6388) 2024-04-23 15:16:28 +00:00
dependabot[bot]
d65fcf70f3 Chore(deps-dev): Bump the development group with 2 updates (#6466)
* Chore(deps-dev): Bump the development group with 2 updates

Bumps the development group with 2 updates: [ruff](https://github.com/astral-sh/ruff) and [mkdocs-material](https://github.com/squidfunk/mkdocs-material).


Updates `ruff` from 0.3.7 to 0.4.1
- [Release notes](https://github.com/astral-sh/ruff/releases)
- [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md)
- [Commits](https://github.com/astral-sh/ruff/compare/v0.3.7...v0.4.1)

Updates `mkdocs-material` from 9.5.17 to 9.5.18
- [Release notes](https://github.com/squidfunk/mkdocs-material/releases)
- [Changelog](https://github.com/squidfunk/mkdocs-material/blob/master/CHANGELOG)
- [Commits](https://github.com/squidfunk/mkdocs-material/compare/9.5.17...9.5.18)

---
updated-dependencies:
- dependency-name: ruff
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: development
- dependency-name: mkdocs-material
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: development
...

Signed-off-by: dependabot[bot] <support@github.com>

* Updates the ruff hook version to match

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Trenton H <797416+stumpylog@users.noreply.github.com>
2024-04-23 14:53:56 +00:00
shamoon
a5d3d51cc5 Fix: always check workflow filter_mailrule if set (#6474) 2024-04-23 07:37:14 -07:00
shamoon
f4489ca2e7 Enhancement: larger documents in rotate / split dialogs 2024-04-23 00:49:20 -07:00
Benedikt Schwering
e40893e74f Fix: scroll for large tables (#6460)
---------

Co-authored-by: shamoon <4887959+shamoon@users.noreply.github.com>
2024-04-21 23:31:33 +00:00
shamoon
d002ae2e05 Fix: monetary field with null values 2024-04-20 21:44:31 -07:00
shamoon
bf430865b4 Fix: check original file for rotate 2024-04-20 20:14:44 -07:00
shamoon
a47d36f5e5 Enhancement: speed up merge document list retrieval 2024-04-19 10:17:17 -07:00
shamoon
4392628bd7 Fix: password reset done template error (#6444) 2024-04-19 09:34:34 -07:00
shamoon
6d25eb26a1 Update translation strings 2024-04-19 01:13:11 -07:00
shamoon
95fd1ae879 Enhancement: refactor monetary field (#6370) 2024-04-19 08:08:46 +00:00
shamoon
78f338484f Enhancement: improve layout, button labels for custom fields dropdown (#6362) 2024-04-19 06:57:17 +00:00
Trenton H
40db1065dc Updates QPDF to 11.9.0 from trixie (#6423) 2024-04-18 22:16:44 +00:00
Trenton H
b720aa3cd1 Chore: Convert the consumer to a plugin (#6361) 2024-04-18 02:59:14 +00:00
dependabot[bot]
e837f1e85b Chore(deps): Bump gunicorn from 21.2.0 to 22.0.0 (#6416)
Bumps [gunicorn](https://github.com/benoitc/gunicorn) from 21.2.0 to 22.0.0.
- [Release notes](https://github.com/benoitc/gunicorn/releases)
- [Commits](https://github.com/benoitc/gunicorn/compare/21.2.0...22.0.0)

---
updated-dependencies:
- dependency-name: gunicorn
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-04-18 00:38:03 +00:00
dependabot[bot]
ea2012bc81 Chore(deps): Bump the small-changes group with 11 updates (#6405)
* Chore(deps): Bump the small-changes group with 11 updates

Bumps the small-changes group with 11 updates:

| Package | From | To |
| --- | --- | --- |
| [django-filter](https://github.com/carltongibson/django-filter) | `24.1` | `24.2` |
| [djangorestframework](https://github.com/encode/django-rest-framework) | `3.14.0` | `3.15.1` |
| [channels](https://github.com/django/channels) | `4.0.0` | `4.1.0` |
| [filelock](https://github.com/tox-dev/py-filelock) | `3.13.3` | `3.13.4` |
| [imap-tools](https://github.com/ikvk/imap_tools) | `1.5.0` | `1.6.0` |
| [python-ipware](https://github.com/un33k/python-ipware) | `2.0.2` | `2.0.3` |
| [rapidfuzz](https://github.com/rapidfuzz/RapidFuzz) | `3.7.0` | `3.8.1` |
| [scikit-learn](https://github.com/scikit-learn/scikit-learn) | `1.4.1.post1` | `1.4.2` |
| [black](https://github.com/psf/black) | `24.3.0` | `24.4.0` |
| [ruff](https://github.com/astral-sh/ruff) | `0.3.5` | `0.3.7` |
| [daphne](https://github.com/django/daphne) | `4.1.0` | `4.1.2` |


Updates `django-filter` from 24.1 to 24.2
- [Release notes](https://github.com/carltongibson/django-filter/releases)
- [Changelog](https://github.com/carltongibson/django-filter/blob/main/CHANGES.rst)
- [Commits](https://github.com/carltongibson/django-filter/compare/24.1...24.2)

Updates `djangorestframework` from 3.14.0 to 3.15.1
- [Release notes](https://github.com/encode/django-rest-framework/releases)
- [Commits](https://github.com/encode/django-rest-framework/compare/3.14.0...3.15.1)

Updates `channels` from 4.0.0 to 4.1.0
- [Changelog](https://github.com/django/channels/blob/main/CHANGELOG.txt)
- [Commits](https://github.com/django/channels/compare/4.0.0...4.1.0)

Updates `filelock` from 3.13.3 to 3.13.4
- [Release notes](https://github.com/tox-dev/py-filelock/releases)
- [Changelog](https://github.com/tox-dev/filelock/blob/main/docs/changelog.rst)
- [Commits](https://github.com/tox-dev/py-filelock/compare/3.13.3...3.13.4)

Updates `imap-tools` from 1.5.0 to 1.6.0
- [Release notes](https://github.com/ikvk/imap_tools/releases)
- [Changelog](https://github.com/ikvk/imap_tools/blob/master/docs/release_notes.rst)
- [Commits](https://github.com/ikvk/imap_tools/compare/v1.5.0...v1.6.0)

Updates `python-ipware` from 2.0.2 to 2.0.3
- [Changelog](https://github.com/un33k/python-ipware/blob/main/CHANGELOG.md)
- [Commits](https://github.com/un33k/python-ipware/compare/v2.0.2...v2.0.3)

Updates `rapidfuzz` from 3.7.0 to 3.8.1
- [Release notes](https://github.com/rapidfuzz/RapidFuzz/releases)
- [Changelog](https://github.com/rapidfuzz/RapidFuzz/blob/main/CHANGELOG.rst)
- [Commits](https://github.com/rapidfuzz/RapidFuzz/compare/v3.7.0...v3.8.1)

Updates `scikit-learn` from 1.4.1.post1 to 1.4.2
- [Release notes](https://github.com/scikit-learn/scikit-learn/releases)
- [Commits](https://github.com/scikit-learn/scikit-learn/compare/1.4.1.post1...1.4.2)

Updates `black` from 24.3.0 to 24.4.0
- [Release notes](https://github.com/psf/black/releases)
- [Changelog](https://github.com/psf/black/blob/main/CHANGES.md)
- [Commits](https://github.com/psf/black/compare/24.3.0...24.4.0)

Updates `ruff` from 0.3.5 to 0.3.7
- [Release notes](https://github.com/astral-sh/ruff/releases)
- [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md)
- [Commits](https://github.com/astral-sh/ruff/compare/v0.3.5...v0.3.7)

Updates `daphne` from 4.1.0 to 4.1.2
- [Changelog](https://github.com/django/daphne/blob/main/CHANGELOG.txt)
- [Commits](https://github.com/django/daphne/compare/4.1.0...4.1.2)

---
updated-dependencies:
- dependency-name: django-filter
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: small-changes
- dependency-name: djangorestframework
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: small-changes
- dependency-name: channels
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: small-changes
- dependency-name: filelock
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: small-changes
- dependency-name: imap-tools
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: small-changes
- dependency-name: python-ipware
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: small-changes
- dependency-name: rapidfuzz
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: small-changes
- dependency-name: scikit-learn
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: small-changes
- dependency-name: black
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: small-changes
- dependency-name: ruff
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: small-changes
- dependency-name: daphne
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: small-changes
...

Signed-off-by: dependabot[bot] <support@github.com>

* Reverts DRF update

* Also bumps the hook versions to match

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Trenton H <797416+stumpylog@users.noreply.github.com>
2024-04-18 00:26:10 +00:00
Dominik Bruhn
8e39315586 Enhancement: Hide columns in document list if user does not have permissions (#6415)
---------

Co-authored-by: Dominik Bruhn <dominik@menlo79.com>
Co-authored-by: shamoon <4887959+shamoon@users.noreply.github.com>
2024-04-16 21:41:15 +00:00
Benedikt Schwering
f009d9868e Fix: show message on empty group list (#6393) 2024-04-14 17:53:48 +00:00
shamoon
1bbcd0961b Fix select dropdown pixel alignment 2024-04-14 08:55:07 -07:00
shamoon
4fa2b54aed Update frontend translation strings 2024-04-13 20:12:54 -07:00
shamoon
7281c110c6 Fix: exclude admin perms from frontend 2024-04-13 20:12:54 -07:00
shamoon
f812f2af4d Fix: remove admin.logentry perm, use admin (staff) status (#6380) 2024-04-14 00:35:34 +00:00
dependabot[bot]
47b4a602a7 Chore(deps): Bump idna from 3.6 to 3.7 (#6377)
Bumps [idna](https://github.com/kjd/idna) from 3.6 to 3.7.
- [Release notes](https://github.com/kjd/idna/releases)
- [Changelog](https://github.com/kjd/idna/blob/master/HISTORY.rst)
- [Commits](https://github.com/kjd/idna/compare/v3.6...v3.7)

---
updated-dependencies:
- dependency-name: idna
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-04-12 07:38:28 -07:00
dependabot[bot]
ca73c0d1f3 Chore(deps): Bump tar from 6.2.0 to 6.2.1 in /src-ui (#6373)
Bumps [tar](https://github.com/isaacs/node-tar) from 6.2.0 to 6.2.1.
- [Release notes](https://github.com/isaacs/node-tar/releases)
- [Changelog](https://github.com/isaacs/node-tar/blob/main/CHANGELOG.md)
- [Commits](https://github.com/isaacs/node-tar/compare/v6.2.0...v6.2.1)

---
updated-dependencies:
- dependency-name: tar
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-04-11 06:51:53 -07:00
shamoon
7f6a50be5b Fix: dont dismiss active alerts on "dismiss completed" (#6364) 2024-04-10 10:55:37 -07:00
shamoon
10e10f9ff4 Fix: Allow lowercase letters in monetary currency code field (#6359) 2024-04-10 08:27:03 -07:00
Trenton H
95c24a50f7 Fix: Allow negative monetary values with a current code (#6358)
* Updates the currency validation to allow an optional negative

* Update frontend regex

---------

Co-authored-by: shamoon <4887959+shamoon@users.noreply.github.com>
2024-04-10 07:33:13 -07:00
Harald
d06faa2fcb Fix: add timezone fallback to install script (#6336) 2024-04-08 17:45:43 -07:00
shamoon
bed66cced0 Reset dev version string 2024-04-07 19:04:16 -07:00
github-actions[bot]
ceaf60e6ad Changelog v2.7.2 - GHA (#6329)
Co-authored-by: github-actions <41898282+github-actions[bot]@users.noreply.github.com>
2024-04-07 19:03:01 -07:00
shamoon
9885ca5103 Bump version to 2.7.2 2024-04-07 18:44:14 -07:00
shamoon
2f22beaaee Merge branch 'dev' 2024-04-07 18:43:43 -07:00
github-actions[bot]
fb2c6282a4 New Crowdin translations by GitHub Action (#6313)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2024-04-07 18:43:07 -07:00
shamoon
8c5b5d3948 Update translation strings 2024-04-07 18:36:14 -07:00
shamoon
4e5135fe70 Fix: hide page chooser if not loaded yet 2024-04-07 15:41:45 -07:00
shamoon
579c35a3fe Fix: isFiltered check before filter editor init 2024-04-07 15:38:38 -07:00
shamoon
4aedcb856d Fix navbar-brand width on large screens 2024-04-07 15:21:13 -07:00
shamoon
0b34e70f6c Fix: select dropdown background colors not visible in light mode (#6323) 2024-04-07 21:03:15 +00:00
shamoon
7afc91e7b1 Fix: spacing in reset and incorrect display in saved views (#6324) 2024-04-07 20:50:31 +00:00
shamoon
56b17ce6a2 Documentation: add more info to rotation note 2024-04-07 12:46:55 -07:00
shamoon
954912cac3 Update translation strings 2024-04-07 12:41:08 -07:00
shamoon
e46f6b1156 Fix frontend tests warning 2024-04-07 11:55:27 -07:00
shamoon
1d85caa8d0 Fix: disable invalid create endpoints (#6320) 2024-04-07 18:50:40 +00:00
shamoon
622fcf96a0 Fix: initialize current page to 1 2024-04-07 11:15:49 -07:00
shamoon
654cc05f0e Fix: also clear select all checkbox in tasks view 2024-04-07 10:47:27 -07:00
shamoon
974dd24e69 Fix: dont initialize page numbers, allow split with browser pdf viewer (#6314) 2024-04-07 08:16:33 -07:00
shamoon
fe824e0faa Reset dev version string 2024-04-06 18:19:59 -07:00
github-actions[bot]
377d89ae06 Changelog v2.7.1 - GHA (#6311)
Co-authored-by: github-actions <41898282+github-actions[bot]@users.noreply.github.com>
2024-04-06 18:19:22 -07:00
shamoon
629e24e031 Bump version to 2.7.1 2024-04-06 17:42:39 -07:00
shamoon
38414025c8 Fix: update notification spacing 2024-04-06 17:40:22 -07:00
github-actions[bot]
1dc5b7a707 New Crowdin translations by GitHub Action (#6301)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2024-04-06 17:35:48 -07:00
shamoon
f076418c50 Reset dev version string 2024-04-06 16:50:18 -07:00
shamoon
bbaad2cdfb Merge branch 'main' into dev 2024-04-06 16:50:11 -07:00
github-actions[bot]
ef01658335 Documentation: Add v2.7.0 changelog (#6293) 2024-04-06 16:49:41 -07:00
shamoon
9f4a6c3b42 Fix: Only disable split button if pages = 1 (#6304) 2024-04-06 23:49:00 +00:00
shamoon
5450bfb67b Fix: Use correct custom field id when splitting (#6303) 2024-04-06 23:39:59 +00:00
Trenton H
ae2b302962 Fix: Rotation fails due to celery chord (#6306)
Use Celery immutable signature so it does not take the result of the header task
2024-04-06 23:26:48 +00:00
shamoon
957691c454 Fix: split user / group objects error (#6302) 2024-04-06 13:51:50 -07:00
shamoon
6b17ba2934 Bump version to 2.7.0 2024-04-05 18:00:05 -07:00
shamoon
4d3616cda9 Merge branch 'dev' 2024-04-05 17:59:32 -07:00
github-actions[bot]
c4a9697e02 New Crowdin translations by GitHub Action (#6127)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2024-04-05 17:59:03 -07:00
shamoon
c57b7520b9 Update translation strings 2024-04-04 14:32:00 -07:00
dependabot[bot]
46bd09227f Chore(deps): Bump pillow from 10.2.0 to 10.3.0 (#6268)
Bumps [pillow](https://github.com/python-pillow/Pillow) from 10.2.0 to 10.3.0.
- [Release notes](https://github.com/python-pillow/Pillow/releases)
- [Changelog](https://github.com/python-pillow/Pillow/blob/main/CHANGES.rst)
- [Commits](https://github.com/python-pillow/Pillow/compare/10.2.0...10.3.0)

---
updated-dependencies:
- dependency-name: pillow
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-04-04 20:39:08 +00:00
dependabot[bot]
971f92a05c Chore(deps-dev): Bump the development group with 2 updates (#6276)
* Chore(deps-dev): Bump the development group with 2 updates

Bumps the development group with 2 updates: [ruff](https://github.com/astral-sh/ruff) and [mkdocs-material](https://github.com/squidfunk/mkdocs-material).


Updates `ruff` from 0.3.4 to 0.3.5
- [Release notes](https://github.com/astral-sh/ruff/releases)
- [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md)
- [Commits](https://github.com/astral-sh/ruff/compare/v0.3.4...v0.3.5)

Updates `mkdocs-material` from 9.5.15 to 9.5.17
- [Release notes](https://github.com/squidfunk/mkdocs-material/releases)
- [Changelog](https://github.com/squidfunk/mkdocs-material/blob/master/CHANGELOG)
- [Commits](https://github.com/squidfunk/mkdocs-material/compare/9.5.15...9.5.17)

---
updated-dependencies:
- dependency-name: ruff
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: development
- dependency-name: mkdocs-material
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: development
...

Signed-off-by: dependabot[bot] <support@github.com>

* Updates ruff hook version to match

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Trenton H <797416+stumpylog@users.noreply.github.com>
2024-04-04 20:26:40 +00:00
Trenton H
2c43b06910 Chore: Standardize subprocess running and logging (#6275) 2024-04-04 13:11:43 -07:00
shamoon
0f8b2e69c9 Change: enable auditlog by default, fix import / export (#6267) 2024-04-04 18:51:15 +00:00
Trenton H
00b04c2e86 Escape the secret key when writing it to the env file (#6243) 2024-04-02 03:02:27 +00:00
shamoon
b3c66cae06 Fix: hide sidebar labels if group is empty (#6254) 2024-04-01 22:00:51 +00:00
shamoon
6a79d417b4 Fix: management list clear all should clear header checkbox (#6253) 2024-04-01 21:43:40 +00:00
dependabot[bot]
98ef68f720 Chore(deps): Bump the frontend-angular-dependencies group (#6248)
Bumps the frontend-angular-dependencies group in /src-ui with 17 updates:

| Package | From | To |
| --- | --- | --- |
| [@angular/cdk](https://github.com/angular/components) | `17.2.1` | `17.3.2` |
| [@angular/common](https://github.com/angular/angular/tree/HEAD/packages/common) | `17.2.3` | `17.3.2` |
| [@angular/compiler](https://github.com/angular/angular/tree/HEAD/packages/compiler) | `17.2.3` | `17.3.2` |
| [@angular/core](https://github.com/angular/angular/tree/HEAD/packages/core) | `17.2.3` | `17.3.2` |
| [@angular/forms](https://github.com/angular/angular/tree/HEAD/packages/forms) | `17.2.3` | `17.3.2` |
| [@angular/localize](https://github.com/angular/angular) | `17.2.3` | `17.3.2` |
| [@angular/platform-browser](https://github.com/angular/angular/tree/HEAD/packages/platform-browser) | `17.2.3` | `17.3.2` |
| [@angular/platform-browser-dynamic](https://github.com/angular/angular/tree/HEAD/packages/platform-browser-dynamic) | `17.2.3` | `17.3.2` |
| [@angular/router](https://github.com/angular/angular/tree/HEAD/packages/router) | `17.2.3` | `17.3.2` |
| [@angular-devkit/build-angular](https://github.com/angular/angular-cli) | `17.2.2` | `17.3.2` |
| [@angular-eslint/builder](https://github.com/angular-eslint/angular-eslint/tree/HEAD/packages/builder) | `17.2.1` | `17.3.0` |
| [@angular-eslint/eslint-plugin](https://github.com/angular-eslint/angular-eslint/tree/HEAD/packages/eslint-plugin) | `17.2.1` | `17.3.0` |
| [@angular-eslint/eslint-plugin-template](https://github.com/angular-eslint/angular-eslint/tree/HEAD/packages/eslint-plugin-template) | `17.2.1` | `17.3.0` |
| [@angular-eslint/schematics](https://github.com/angular-eslint/angular-eslint/tree/HEAD/packages/schematics) | `17.2.1` | `17.3.0` |
| [@angular-eslint/template-parser](https://github.com/angular-eslint/angular-eslint/tree/HEAD/packages/template-parser) | `17.2.1` | `17.3.0` |
| [@angular/cli](https://github.com/angular/angular-cli) | `17.2.2` | `17.3.2` |
| [@angular/compiler-cli](https://github.com/angular/angular/tree/HEAD/packages/compiler-cli) | `17.2.3` | `17.3.2` |


Updates `@angular/cdk` from 17.2.1 to 17.3.2
- [Release notes](https://github.com/angular/components/releases)
- [Changelog](https://github.com/angular/components/blob/main/CHANGELOG.md)
- [Commits](https://github.com/angular/components/compare/17.2.1...17.3.2)

Updates `@angular/common` from 17.2.3 to 17.3.2
- [Release notes](https://github.com/angular/angular/releases)
- [Changelog](https://github.com/angular/angular/blob/main/CHANGELOG.md)
- [Commits](https://github.com/angular/angular/commits/17.3.2/packages/common)

Updates `@angular/compiler` from 17.2.3 to 17.3.2
- [Release notes](https://github.com/angular/angular/releases)
- [Changelog](https://github.com/angular/angular/blob/main/CHANGELOG.md)
- [Commits](https://github.com/angular/angular/commits/17.3.2/packages/compiler)

Updates `@angular/core` from 17.2.3 to 17.3.2
- [Release notes](https://github.com/angular/angular/releases)
- [Changelog](https://github.com/angular/angular/blob/main/CHANGELOG.md)
- [Commits](https://github.com/angular/angular/commits/17.3.2/packages/core)

Updates `@angular/forms` from 17.2.3 to 17.3.2
- [Release notes](https://github.com/angular/angular/releases)
- [Changelog](https://github.com/angular/angular/blob/main/CHANGELOG.md)
- [Commits](https://github.com/angular/angular/commits/17.3.2/packages/forms)

Updates `@angular/localize` from 17.2.3 to 17.3.2
- [Release notes](https://github.com/angular/angular/releases)
- [Changelog](https://github.com/angular/angular/blob/main/CHANGELOG.md)
- [Commits](https://github.com/angular/angular/compare/17.2.3...17.3.2)

Updates `@angular/platform-browser` from 17.2.3 to 17.3.2
- [Release notes](https://github.com/angular/angular/releases)
- [Changelog](https://github.com/angular/angular/blob/main/CHANGELOG.md)
- [Commits](https://github.com/angular/angular/commits/17.3.2/packages/platform-browser)

Updates `@angular/platform-browser-dynamic` from 17.2.3 to 17.3.2
- [Release notes](https://github.com/angular/angular/releases)
- [Changelog](https://github.com/angular/angular/blob/main/CHANGELOG.md)
- [Commits](https://github.com/angular/angular/commits/17.3.2/packages/platform-browser-dynamic)

Updates `@angular/router` from 17.2.3 to 17.3.2
- [Release notes](https://github.com/angular/angular/releases)
- [Changelog](https://github.com/angular/angular/blob/main/CHANGELOG.md)
- [Commits](https://github.com/angular/angular/commits/17.3.2/packages/router)

Updates `@angular-devkit/build-angular` from 17.2.2 to 17.3.2
- [Release notes](https://github.com/angular/angular-cli/releases)
- [Changelog](https://github.com/angular/angular-cli/blob/main/CHANGELOG.md)
- [Commits](https://github.com/angular/angular-cli/compare/17.2.2...17.3.2)

Updates `@angular-eslint/builder` from 17.2.1 to 17.3.0
- [Release notes](https://github.com/angular-eslint/angular-eslint/releases)
- [Changelog](https://github.com/angular-eslint/angular-eslint/blob/main/packages/builder/CHANGELOG.md)
- [Commits](https://github.com/angular-eslint/angular-eslint/commits/v17.3.0/packages/builder)

Updates `@angular-eslint/eslint-plugin` from 17.2.1 to 17.3.0
- [Release notes](https://github.com/angular-eslint/angular-eslint/releases)
- [Changelog](https://github.com/angular-eslint/angular-eslint/blob/main/packages/eslint-plugin/CHANGELOG.md)
- [Commits](https://github.com/angular-eslint/angular-eslint/commits/v17.3.0/packages/eslint-plugin)

Updates `@angular-eslint/eslint-plugin-template` from 17.2.1 to 17.3.0
- [Release notes](https://github.com/angular-eslint/angular-eslint/releases)
- [Changelog](https://github.com/angular-eslint/angular-eslint/blob/main/packages/eslint-plugin-template/CHANGELOG.md)
- [Commits](https://github.com/angular-eslint/angular-eslint/commits/v17.3.0/packages/eslint-plugin-template)

Updates `@angular-eslint/schematics` from 17.2.1 to 17.3.0
- [Release notes](https://github.com/angular-eslint/angular-eslint/releases)
- [Changelog](https://github.com/angular-eslint/angular-eslint/blob/main/packages/schematics/CHANGELOG.md)
- [Commits](https://github.com/angular-eslint/angular-eslint/commits/v17.3.0/packages/schematics)

Updates `@angular-eslint/template-parser` from 17.2.1 to 17.3.0
- [Release notes](https://github.com/angular-eslint/angular-eslint/releases)
- [Changelog](https://github.com/angular-eslint/angular-eslint/blob/main/packages/template-parser/CHANGELOG.md)
- [Commits](https://github.com/angular-eslint/angular-eslint/commits/v17.3.0/packages/template-parser)

Updates `@angular/cli` from 17.2.2 to 17.3.2
- [Release notes](https://github.com/angular/angular-cli/releases)
- [Changelog](https://github.com/angular/angular-cli/blob/main/CHANGELOG.md)
- [Commits](https://github.com/angular/angular-cli/compare/17.2.2...17.3.2)

Updates `@angular/compiler-cli` from 17.2.3 to 17.3.2
- [Release notes](https://github.com/angular/angular/releases)
- [Changelog](https://github.com/angular/angular/blob/main/CHANGELOG.md)
- [Commits](https://github.com/angular/angular/commits/17.3.2/packages/compiler-cli)

---
updated-dependencies:
- dependency-name: "@angular/cdk"
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: frontend-angular-dependencies
- dependency-name: "@angular/common"
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: frontend-angular-dependencies
- dependency-name: "@angular/compiler"
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: frontend-angular-dependencies
- dependency-name: "@angular/core"
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: frontend-angular-dependencies
- dependency-name: "@angular/forms"
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: frontend-angular-dependencies
- dependency-name: "@angular/localize"
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: frontend-angular-dependencies
- dependency-name: "@angular/platform-browser"
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: frontend-angular-dependencies
- dependency-name: "@angular/platform-browser-dynamic"
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: frontend-angular-dependencies
- dependency-name: "@angular/router"
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: frontend-angular-dependencies
- dependency-name: "@angular-devkit/build-angular"
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: frontend-angular-dependencies
- dependency-name: "@angular-eslint/builder"
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: frontend-angular-dependencies
- dependency-name: "@angular-eslint/eslint-plugin"
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: frontend-angular-dependencies
- dependency-name: "@angular-eslint/eslint-plugin-template"
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: frontend-angular-dependencies
- dependency-name: "@angular-eslint/schematics"
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: frontend-angular-dependencies
- dependency-name: "@angular-eslint/template-parser"
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: frontend-angular-dependencies
- dependency-name: "@angular/cli"
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: frontend-angular-dependencies
- dependency-name: "@angular/compiler-cli"
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: frontend-angular-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-04-01 14:36:48 -07:00
dependabot[bot]
ed3b7aa8f2 Chore(deps-dev): Bump @playwright/test from 1.42.0 to 1.42.1 in /src-ui (#6250)
Bumps [@playwright/test](https://github.com/microsoft/playwright) from 1.42.0 to 1.42.1.
- [Release notes](https://github.com/microsoft/playwright/releases)
- [Commits](https://github.com/microsoft/playwright/compare/v1.42.0...v1.42.1)

---
updated-dependencies:
- dependency-name: "@playwright/test"
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-04-01 21:16:18 +00:00
dependabot[bot]
e536600052 Chore(deps-dev): Bump @types/node from 20.11.24 to 20.12.2 in /src-ui (#6251)
Bumps [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) from 20.11.24 to 20.12.2.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

---
updated-dependencies:
- dependency-name: "@types/node"
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-04-01 21:05:56 +00:00
dependabot[bot]
bb820a2127 Chore(deps-dev): Bump the frontend-eslint-dependencies group (#6249)
Bumps the frontend-eslint-dependencies group in /src-ui with 2 updates: [@typescript-eslint/eslint-plugin](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/eslint-plugin) and [@typescript-eslint/parser](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/parser).


Updates `@typescript-eslint/eslint-plugin` from 7.1.0 to 7.4.0
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/eslint-plugin/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v7.4.0/packages/eslint-plugin)

Updates `@typescript-eslint/parser` from 7.1.0 to 7.4.0
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/parser/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v7.4.0/packages/parser)

---
updated-dependencies:
- dependency-name: "@typescript-eslint/eslint-plugin"
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: frontend-eslint-dependencies
- dependency-name: "@typescript-eslint/parser"
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: frontend-eslint-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-04-01 13:59:08 -07:00
shamoon
129933ff30 Enhancement: support custom fields in post_document endpoint (#6222) 2024-03-30 13:00:53 -07:00
Elias Probst
41fc11efff Enhancement: add ASN to consume rejection message (#6217) 2024-03-28 19:38:29 -07:00
Sander
a712bc72ca Documentation: document_importer does not directly support zip archives (#6209)
---------

Co-authored-by: Sander <biocoder-xbmc@xs4all.nl>
Co-authored-by: shamoon <4887959+shamoon@users.noreply.github.com>
Co-authored-by: Trenton H <797416+stumpylog@users.noreply.github.com>
2024-03-27 19:53:04 -07:00
dependabot[bot]
fbe7acc6b0 Chore(deps-dev): Bump express from 4.18.3 to 4.19.2 in /src-ui (#6207)
Bumps [express](https://github.com/expressjs/express) from 4.18.3 to 4.19.2.
- [Release notes](https://github.com/expressjs/express/releases)
- [Changelog](https://github.com/expressjs/express/blob/master/History.md)
- [Commits](https://github.com/expressjs/express/compare/4.18.3...4.19.2)

---
updated-dependencies:
- dependency-name: express
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-03-27 11:43:33 -05:00
Trenton H
c4153b6fbf Locks DRF version to 3.14, updates other dependencies (#6200) 2024-03-27 09:13:02 -07:00
shamoon
1f355a22e0 Documentation update info about logout URL 2024-03-27 00:59:18 -05:00
shamoon
4af8070450 Feature: PDF actions - merge, split & rotate (#6094) 2024-03-25 18:41:24 -07:00
shamoon
d6d0071175 Fix: start-align object names in some UI lists (#6188) 2024-03-25 10:01:54 -07:00
shamoon
ef51633b2c Fix: allow scroll long upload files alerts list (#6184) 2024-03-25 07:53:29 -07:00
shamoon
d4963b9cbe Fix: document_renamer fails with audit_log enabled (#6175) 2024-03-24 07:26:25 -07:00
dependabot[bot]
01dabf7c05 Chore(deps-dev): Bump webpack-dev-middleware in /src-ui (#6161)
Bumps [webpack-dev-middleware](https://github.com/webpack/webpack-dev-middleware) from 5.3.3 to 5.3.4.
- [Release notes](https://github.com/webpack/webpack-dev-middleware/releases)
- [Changelog](https://github.com/webpack/webpack-dev-middleware/blob/v5.3.4/CHANGELOG.md)
- [Commits](https://github.com/webpack/webpack-dev-middleware/compare/v5.3.3...v5.3.4)

---
updated-dependencies:
- dependency-name: webpack-dev-middleware
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-03-21 13:25:29 -07:00
shamoon
fc68f79cc8 Enhancement: always place search term first in autocomplete results (#6142) 2024-03-21 19:03:17 +00:00
shamoon
ebe1479503 Fix: catch sessionStorage errors for large documents (#6150) 2024-03-21 06:31:25 -07:00
dependabot[bot]
8c9fe4da06 Chore(deps-dev): Bump the development group with 4 updates (#6131)
* Chore(deps-dev): Bump the development group with 4 updates

Bumps the development group with 4 updates: [black](https://github.com/psf/black), [ruff](https://github.com/astral-sh/ruff), [pytest-rerunfailures](https://github.com/pytest-dev/pytest-rerunfailures) and [mkdocs-material](https://github.com/squidfunk/mkdocs-material).


Updates `black` from 24.2.0 to 24.3.0
- [Release notes](https://github.com/psf/black/releases)
- [Changelog](https://github.com/psf/black/blob/main/CHANGES.md)
- [Commits](https://github.com/psf/black/compare/24.2.0...24.3.0)

Updates `ruff` from 0.3.2 to 0.3.3
- [Release notes](https://github.com/astral-sh/ruff/releases)
- [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md)
- [Commits](https://github.com/astral-sh/ruff/compare/v0.3.2...v0.3.3)

Updates `pytest-rerunfailures` from 13.0 to 14.0
- [Changelog](https://github.com/pytest-dev/pytest-rerunfailures/blob/master/CHANGES.rst)
- [Commits](https://github.com/pytest-dev/pytest-rerunfailures/compare/13.0...14.0)

Updates `mkdocs-material` from 9.5.13 to 9.5.14
- [Release notes](https://github.com/squidfunk/mkdocs-material/releases)
- [Changelog](https://github.com/squidfunk/mkdocs-material/blob/master/CHANGELOG)
- [Commits](https://github.com/squidfunk/mkdocs-material/compare/9.5.13...9.5.14)

---
updated-dependencies:
- dependency-name: black
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: development
- dependency-name: ruff
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: development
- dependency-name: pytest-rerunfailures
  dependency-type: direct:development
  update-type: version-update:semver-major
  dependency-group: development
- dependency-name: mkdocs-material
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: development
...

Signed-off-by: dependabot[bot] <support@github.com>

* Also matches pre-commit hook versions to Pipfile

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Trenton Holmes <797416+stumpylog@users.noreply.github.com>
2024-03-21 02:09:37 +00:00
shamoon
b2ef51af55 Reset dev version string 2024-03-17 22:58:35 -07:00
github-actions[bot]
32b35d8e4b Changelog v2.6.3 - GHA (#6125)
Co-authored-by: github-actions <41898282+github-actions[bot]@users.noreply.github.com>
2024-03-17 21:52:09 -07:00
260 changed files with 29116 additions and 14412 deletions

View File

@@ -5,7 +5,7 @@
repos: repos:
# General hooks # General hooks
- repo: https://github.com/pre-commit/pre-commit-hooks - repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.5.0 rev: v4.6.0
hooks: hooks:
- id: check-docstring-first - id: check-docstring-first
- id: check-json - id: check-json
@@ -47,11 +47,11 @@ repos:
exclude: "(^Pipfile\\.lock$)" exclude: "(^Pipfile\\.lock$)"
# Python hooks # Python hooks
- repo: https://github.com/astral-sh/ruff-pre-commit - repo: https://github.com/astral-sh/ruff-pre-commit
rev: 'v0.3.2' rev: 'v0.4.1'
hooks: hooks:
- id: ruff - id: ruff
- repo: https://github.com/psf/black-pre-commit-mirror - repo: https://github.com/psf/black-pre-commit-mirror
rev: 24.2.0 rev: 24.4.0
hooks: hooks:
- id: black - id: black
# Dockerfile hooks # Dockerfile hooks
@@ -67,6 +67,6 @@ repos:
args: args:
- "--tab" - "--tab"
- repo: https://github.com/shellcheck-py/shellcheck-py - repo: https://github.com/shellcheck-py/shellcheck-py
rev: "v0.9.0.6" rev: "v0.10.0.1"
hooks: hooks:
- id: shellcheck - id: shellcheck

View File

@@ -52,7 +52,7 @@ ARG TARGETARCH
# Can be workflow provided, defaults set for manual building # Can be workflow provided, defaults set for manual building
ARG JBIG2ENC_VERSION=0.29 ARG JBIG2ENC_VERSION=0.29
ARG QPDF_VERSION=11.6.4 ARG QPDF_VERSION=11.9.0
ARG GS_VERSION=10.02.1 ARG GS_VERSION=10.02.1
# Set Python environment variables # Set Python environment variables

View File

@@ -14,15 +14,15 @@ django-celery-results = "*"
django-compression-middleware = "*" django-compression-middleware = "*"
django-cors-headers = "*" django-cors-headers = "*"
django-extensions = "*" django-extensions = "*"
django-filter = "~=24.1" django-filter = "~=24.2"
django-guardian = "*" django-guardian = "*"
django-multiselectfield = "*" django-multiselectfield = "*"
djangorestframework = "~=3.14" djangorestframework = "==3.14.0"
djangorestframework-guardian = "*" djangorestframework-guardian = "*"
drf-writable-nested = "*" drf-writable-nested = "*"
bleach = "*" bleach = "*"
celery = {extras = ["redis"], version = "*"} celery = {extras = ["redis"], version = "*"}
channels = "~=4.0" channels = "~=4.1"
channels-redis = "*" channels-redis = "*"
concurrent-log-handler = "*" concurrent-log-handler = "*"
filelock = "*" filelock = "*"

1269
Pipfile.lock generated

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -349,11 +349,17 @@ When you use the provided docker compose script, put the export inside
the `export` folder in your paperless source directory. Specify the `export` folder in your paperless source directory. Specify
`../export` as the `source`. `../export` as the `source`.
Note that .zip files (as can be generated from the exporter) are not supported.
!!! note !!! note
Importing from a previous version of Paperless may work, but for best Importing from a previous version of Paperless may work, but for best
results it is suggested to match the versions. results it is suggested to match the versions.
!!! warning
The importer should be run against a completely empty installation (database and directories) of Paperless-ngx.
### Document retagger {#retagger} ### Document retagger {#retagger}
Say you've imported a few hundred documents and now want to introduce a Say you've imported a few hundred documents and now want to introduce a

View File

@@ -649,8 +649,9 @@ external authentication solution using one of the following methods:
This is a simple option that uses remote user authentication made available by certain SSO This is a simple option that uses remote user authentication made available by certain SSO
applications. See the relevant configuration options for more information: applications. See the relevant configuration options for more information:
[PAPERLESS_ENABLE_HTTP_REMOTE_USER](configuration.md#PAPERLESS_ENABLE_HTTP_REMOTE_USER) and [PAPERLESS_ENABLE_HTTP_REMOTE_USER](configuration.md#PAPERLESS_ENABLE_HTTP_REMOTE_USER),
[PAPERLESS_HTTP_REMOTE_USER_HEADER_NAME](configuration.md#PAPERLESS_HTTP_REMOTE_USER_HEADER_NAME) [PAPERLESS_HTTP_REMOTE_USER_HEADER_NAME](configuration.md#PAPERLESS_HTTP_REMOTE_USER_HEADER_NAME)
and [PAPERLESS_LOGOUT_REDIRECT_URL](configuration.md#PAPERLESS_LOGOUT_REDIRECT_URL)
### OpenID Connect and social authentication ### OpenID Connect and social authentication

View File

@@ -140,6 +140,7 @@ document. Paperless only reports PDF metadata at this point.
- `/api/documents/<id>/notes/`: Retrieve notes for a document. - `/api/documents/<id>/notes/`: Retrieve notes for a document.
- `/api/documents/<id>/share_links/`: Retrieve share links for a document. - `/api/documents/<id>/share_links/`: Retrieve share links for a document.
- `/api/documents/<id>/history/`: Retrieve history of changes for a document.
## Authorization ## Authorization
@@ -288,6 +289,8 @@ The endpoint supports the following optional form fields:
- `tags`: Similar to correspondent. Specify this multiple times to - `tags`: Similar to correspondent. Specify this multiple times to
have multiple tags added to the document. have multiple tags added to the document.
- `archive_serial_number`: An optional archive serial number to set. - `archive_serial_number`: An optional archive serial number to set.
- `custom_fields`: An array of custom field ids to assign (with an empty
value) to the document.
The endpoint will immediately return HTTP 200 if the document consumption The endpoint will immediately return HTTP 200 if the document consumption
process was started successfully, with the UUID of the consumption task process was started successfully, with the UUID of the consumption task
@@ -376,6 +379,18 @@ The following methods are supported:
- `"merge": true or false` (defaults to false) - `"merge": true or false` (defaults to false)
- The `merge` flag determines if the supplied permissions will overwrite all existing permissions (including - The `merge` flag determines if the supplied permissions will overwrite all existing permissions (including
removing them) or be merged with existing permissions. removing them) or be merged with existing permissions.
- `merge`
- No additional `parameters` required.
- The ordering of the merged document is determined by the list of IDs.
- Optional `parameters`:
- `"metadata_document_id": DOC_ID` apply metadata (tags, correspondent, etc.) from this document to the merged document.
- `split`
- Requires `parameters`:
- `"pages": [..]` The list should be a list of pages and/or a ranges, separated by commas e.g. `"[1,2-3,4,5-7]"`
- The split operation only accepts a single document.
- `rotate`
- Requires `parameters`:
- `"degrees": DEGREES`. Must be an integer i.e. 90, 180, 270
### Objects ### Objects

View File

@@ -1,5 +1,146 @@
# Changelog # Changelog
## paperless-ngx 2.7.2
### Bug Fixes
- Fix: select dropdown background colors not visible in light mode [@shamoon](https://github.com/shamoon) ([#6323](https://github.com/paperless-ngx/paperless-ngx/pull/6323))
- Fix: spacing in reset and incorrect display in saved views [@shamoon](https://github.com/shamoon) ([#6324](https://github.com/paperless-ngx/paperless-ngx/pull/6324))
- Fix: disable invalid create endpoints [@shamoon](https://github.com/shamoon) ([#6320](https://github.com/paperless-ngx/paperless-ngx/pull/6320))
- Fix: dont initialize page numbers, allow split with browser pdf viewer [@shamoon](https://github.com/shamoon) ([#6314](https://github.com/paperless-ngx/paperless-ngx/pull/6314))
### All App Changes
<details>
<summary>4 changes</summary>
- Fix: select dropdown background colors not visible in light mode [@shamoon](https://github.com/shamoon) ([#6323](https://github.com/paperless-ngx/paperless-ngx/pull/6323))
- Fix: spacing in reset and incorrect display in saved views [@shamoon](https://github.com/shamoon) ([#6324](https://github.com/paperless-ngx/paperless-ngx/pull/6324))
- Fix: disable invalid create endpoints [@shamoon](https://github.com/shamoon) ([#6320](https://github.com/paperless-ngx/paperless-ngx/pull/6320))
- Fix: dont initialize page numbers, allow split with browser pdf viewer [@shamoon](https://github.com/shamoon) ([#6314](https://github.com/paperless-ngx/paperless-ngx/pull/6314))
</details>
## paperless-ngx 2.7.1
### Bug Fixes
- Fix: Only disable split button if pages = 1 [@shamoon](https://github.com/shamoon) ([#6304](https://github.com/paperless-ngx/paperless-ngx/pull/6304))
- Fix: Use correct custom field id when splitting [@shamoon](https://github.com/shamoon) ([#6303](https://github.com/paperless-ngx/paperless-ngx/pull/6303))
- Fix: Rotation fails due to celery chord [@stumpylog](https://github.com/stumpylog) ([#6306](https://github.com/paperless-ngx/paperless-ngx/pull/6306))
- Fix: split user / group objects error [@shamoon](https://github.com/shamoon) ([#6302](https://github.com/paperless-ngx/paperless-ngx/pull/6302))
### All App Changes
<details>
<summary>4 changes</summary>
- Fix: Only disable split button if pages = 1 [@shamoon](https://github.com/shamoon) ([#6304](https://github.com/paperless-ngx/paperless-ngx/pull/6304))
- Fix: Use correct custom field id when splitting [@shamoon](https://github.com/shamoon) ([#6303](https://github.com/paperless-ngx/paperless-ngx/pull/6303))
- Fix: Rotation fails due to celery chord [@stumpylog](https://github.com/stumpylog) ([#6306](https://github.com/paperless-ngx/paperless-ngx/pull/6306))
- Fix: split user / group objects error [@shamoon](https://github.com/shamoon) ([#6302](https://github.com/paperless-ngx/paperless-ngx/pull/6302))
</details>
## paperless-ngx 2.7.0
### Notable Changes
- Feature: PDF actions - merge, split \& rotate @shamoon ([#6094](https://github.com/paperless-ngx/paperless-ngx/pull/6094))
- Change: enable auditlog by default, fix import / export @shamoon ([#6267](https://github.com/paperless-ngx/paperless-ngx/pull/6267))
### Enhancements
- Enhancement: always place search term first in autocomplete results @shamoon ([#6142](https://github.com/paperless-ngx/paperless-ngx/pull/6142))
### Maintenance
- Chore: Standardize subprocess running and logging [@stumpylog](https://github.com/stumpylog) ([#6275](https://github.com/paperless-ngx/paperless-ngx/pull/6275))
### Bug Fixes
- Fix: Escape the secret key when writing it to the env file [@stumpylog](https://github.com/stumpylog) ([#6243](https://github.com/paperless-ngx/paperless-ngx/pull/6243))
- Fix: Hide sidebar labels if group is empty [@shamoon](https://github.com/shamoon) ([#6254](https://github.com/paperless-ngx/paperless-ngx/pull/6254))
- Fix: management list clear all should clear header checkbox [@shamoon](https://github.com/shamoon) ([#6253](https://github.com/paperless-ngx/paperless-ngx/pull/6253))
- Fix: start-align object names in some UI lists [@shamoon](https://github.com/shamoon) ([#6188](https://github.com/paperless-ngx/paperless-ngx/pull/6188))
- Fix: allow scroll long upload files alerts list [@shamoon](https://github.com/shamoon) ([#6184](https://github.com/paperless-ngx/paperless-ngx/pull/6184))
- Fix: document_renamer fails with audit_log enabled [@shamoon](https://github.com/shamoon) ([#6175](https://github.com/paperless-ngx/paperless-ngx/pull/6175))
- Fix: catch sessionStorage errors for large documents [@shamoon](https://github.com/shamoon) ([#6150](https://github.com/paperless-ngx/paperless-ngx/pull/6150))
### Dependencies
<details>
<summary>9 changes</summary>
- Chore(deps): Bump pillow from 10.2.0 to 10.3.0 [@dependabot](https://github.com/dependabot) ([#6268](https://github.com/paperless-ngx/paperless-ngx/pull/6268))
- Chore(deps-dev): Bump the development group with 2 updates [@dependabot](https://github.com/dependabot) ([#6276](https://github.com/paperless-ngx/paperless-ngx/pull/6276))
- Chore(deps): Bump the frontend-angular-dependencies group in /src-ui with 17 updates [@dependabot](https://github.com/dependabot) ([#6248](https://github.com/paperless-ngx/paperless-ngx/pull/6248))
- Chore(deps-dev): Bump [@<!---->playwright/test from 1.42.0 to 1.42.1 in /src-ui @dependabot](https://github.com/<!---->playwright/test from 1.42.0 to 1.42.1 in /src-ui @dependabot) ([#6250](https://github.com/paperless-ngx/paperless-ngx/pull/6250))
- Chore(deps-dev): Bump [@<!---->types/node from 20.11.24 to 20.12.2 in /src-ui @dependabot](https://github.com/<!---->types/node from 20.11.24 to 20.12.2 in /src-ui @dependabot) ([#6251](https://github.com/paperless-ngx/paperless-ngx/pull/6251))
- Chore(deps-dev): Bump the frontend-eslint-dependencies group in /src-ui with 2 updates [@dependabot](https://github.com/dependabot) ([#6249](https://github.com/paperless-ngx/paperless-ngx/pull/6249))
- Chore(deps-dev): Bump express from 4.18.3 to 4.19.2 in /src-ui [@dependabot](https://github.com/dependabot) ([#6207](https://github.com/paperless-ngx/paperless-ngx/pull/6207))
- Chore(deps-dev): Bump webpack-dev-middleware from 5.3.3 to 5.3.4 in /src-ui [@dependabot](https://github.com/dependabot) ([#6161](https://github.com/paperless-ngx/paperless-ngx/pull/6161))
- Chore(deps-dev): Bump the development group with 4 updates [@dependabot](https://github.com/dependabot) ([#6131](https://github.com/paperless-ngx/paperless-ngx/pull/6131))
</details>
### All App Changes
<details>
<summary>20 changes</summary>
- Chore(deps-dev): Bump the development group with 2 updates [@dependabot](https://github.com/dependabot) ([#6276](https://github.com/paperless-ngx/paperless-ngx/pull/6276))
- Chore: Standardize subprocess running and logging [@stumpylog](https://github.com/stumpylog) ([#6275](https://github.com/paperless-ngx/paperless-ngx/pull/6275))
- Change: enable auditlog by default, fix import / export [@shamoon](https://github.com/shamoon) ([#6267](https://github.com/paperless-ngx/paperless-ngx/pull/6267))
- Fix: Hide sidebar labels if group is empty [@shamoon](https://github.com/shamoon) ([#6254](https://github.com/paperless-ngx/paperless-ngx/pull/6254))
- Fix: management list clear all should clear header checkbox [@shamoon](https://github.com/shamoon) ([#6253](https://github.com/paperless-ngx/paperless-ngx/pull/6253))
- Chore(deps): Bump the frontend-angular-dependencies group in /src-ui with 17 updates [@dependabot](https://github.com/dependabot) ([#6248](https://github.com/paperless-ngx/paperless-ngx/pull/6248))
- Chore(deps-dev): Bump [@<!---->playwright/test from 1.42.0 to 1.42.1 in /src-ui @dependabot](https://github.com/<!---->playwright/test from 1.42.0 to 1.42.1 in /src-ui @dependabot) ([#6250](https://github.com/paperless-ngx/paperless-ngx/pull/6250))
- Chore(deps-dev): Bump [@<!---->types/node from 20.11.24 to 20.12.2 in /src-ui @dependabot](https://github.com/<!---->types/node from 20.11.24 to 20.12.2 in /src-ui @dependabot) ([#6251](https://github.com/paperless-ngx/paperless-ngx/pull/6251))
- Chore(deps-dev): Bump the frontend-eslint-dependencies group in /src-ui with 2 updates [@dependabot](https://github.com/dependabot) ([#6249](https://github.com/paperless-ngx/paperless-ngx/pull/6249))
- Enhancement: support custom fields in post_document endpoint [@shamoon](https://github.com/shamoon) ([#6222](https://github.com/paperless-ngx/paperless-ngx/pull/6222))
- Enhancement: add ASN to consume rejection message [@eliasp](https://github.com/eliasp) ([#6217](https://github.com/paperless-ngx/paperless-ngx/pull/6217))
- Chore(deps-dev): Bump express from 4.18.3 to 4.19.2 in /src-ui [@dependabot](https://github.com/dependabot) ([#6207](https://github.com/paperless-ngx/paperless-ngx/pull/6207))
- Feature: PDF actions - merge, split \& rotate [@shamoon](https://github.com/shamoon) ([#6094](https://github.com/paperless-ngx/paperless-ngx/pull/6094))
- Fix: start-align object names in some UI lists [@shamoon](https://github.com/shamoon) ([#6188](https://github.com/paperless-ngx/paperless-ngx/pull/6188))
- Fix: allow scroll long upload files alerts list [@shamoon](https://github.com/shamoon) ([#6184](https://github.com/paperless-ngx/paperless-ngx/pull/6184))
- Fix: document_renamer fails with audit_log enabled [@shamoon](https://github.com/shamoon) ([#6175](https://github.com/paperless-ngx/paperless-ngx/pull/6175))
- Chore(deps-dev): Bump webpack-dev-middleware from 5.3.3 to 5.3.4 in /src-ui [@dependabot](https://github.com/dependabot) ([#6161](https://github.com/paperless-ngx/paperless-ngx/pull/6161))
- Enhancement: always place search term first in autocomplete results [@shamoon](https://github.com/shamoon) ([#6142](https://github.com/paperless-ngx/paperless-ngx/pull/6142))
- Fix: catch sessionStorage errors for large documents [@shamoon](https://github.com/shamoon) ([#6150](https://github.com/paperless-ngx/paperless-ngx/pull/6150))
- Chore(deps-dev): Bump the development group with 4 updates [@dependabot](https://github.com/dependabot) ([#6131](https://github.com/paperless-ngx/paperless-ngx/pull/6131))
</details>
## paperless-ngx 2.6.3
### Bug Fixes
- Fix: allow setting allauth [@shamoon](https://github.com/shamoon) ([#6105](https://github.com/paperless-ngx/paperless-ngx/pull/6105))
- Change: dont require empty bulk edit parameters [@shamoon](https://github.com/shamoon) ([#6059](https://github.com/paperless-ngx/paperless-ngx/pull/6059))
### Dependencies
<details>
<summary>4 changes</summary>
- Chore(deps-dev): Bump follow-redirects from 1.15.5 to 1.15.6 in /src-ui [@dependabot](https://github.com/dependabot) ([#6120](https://github.com/paperless-ngx/paperless-ngx/pull/6120))
- Chore(deps-dev): Bump the development group with 3 updates [@dependabot](https://github.com/dependabot) ([#6079](https://github.com/paperless-ngx/paperless-ngx/pull/6079))
- Chore(deps): Bump the django group with 1 update [@dependabot](https://github.com/dependabot) ([#6080](https://github.com/paperless-ngx/paperless-ngx/pull/6080))
- Chore(deps): Bump the small-changes group with 2 updates [@dependabot](https://github.com/dependabot) ([#6081](https://github.com/paperless-ngx/paperless-ngx/pull/6081))
</details>
### All App Changes
<details>
<summary>8 changes</summary>
- Chore(deps-dev): Bump follow-redirects from 1.15.5 to 1.15.6 in /src-ui [@dependabot](https://github.com/dependabot) ([#6120](https://github.com/paperless-ngx/paperless-ngx/pull/6120))
- Fix: allow setting allauth [@shamoon](https://github.com/shamoon) ([#6105](https://github.com/paperless-ngx/paperless-ngx/pull/6105))
- Change: remove credentials from redis url in system status [@shamoon](https://github.com/shamoon) ([#6104](https://github.com/paperless-ngx/paperless-ngx/pull/6104))
- Chore(deps-dev): Bump the development group with 3 updates [@dependabot](https://github.com/dependabot) ([#6079](https://github.com/paperless-ngx/paperless-ngx/pull/6079))
- Chore(deps): Bump the django group with 1 update [@dependabot](https://github.com/dependabot) ([#6080](https://github.com/paperless-ngx/paperless-ngx/pull/6080))
- Chore(deps): Bump the small-changes group with 2 updates [@dependabot](https://github.com/dependabot) ([#6081](https://github.com/paperless-ngx/paperless-ngx/pull/6081))
- Change: dont require empty bulk edit parameters [@shamoon](https://github.com/shamoon) ([#6059](https://github.com/paperless-ngx/paperless-ngx/pull/6059))
- Fix: missing translation string [@DimitriDR](https://github.com/DimitriDR) ([#6054](https://github.com/paperless-ngx/paperless-ngx/pull/6054))
</details>
## paperless-ngx 2.6.2 ## paperless-ngx 2.6.2
### Features ### Features

View File

@@ -491,8 +491,9 @@ followed by the normalized actual header name.
#### [`PAPERLESS_LOGOUT_REDIRECT_URL=<str>`](#PAPERLESS_LOGOUT_REDIRECT_URL) {#PAPERLESS_LOGOUT_REDIRECT_URL} #### [`PAPERLESS_LOGOUT_REDIRECT_URL=<str>`](#PAPERLESS_LOGOUT_REDIRECT_URL) {#PAPERLESS_LOGOUT_REDIRECT_URL}
: URL to redirect the user to after a logout. This can be used : URL to redirect the user to after a logout. This can be used
together with PAPERLESS_ENABLE_HTTP_REMOTE_USER to together with PAPERLESS_ENABLE_HTTP_REMOTE_USER and SSO to
redirect the user back to the SSO application's logout page. redirect the user back to the SSO application's logout page to
complete the logout process.
Defaults to None, which disables this feature. Defaults to None, which disables this feature.
@@ -1311,11 +1312,9 @@ assigns or creates tags if a properly formatted barcode is detected.
#### [`PAPERLESS_AUDIT_LOG_ENABLED=<bool>`](#PAPERLESS_AUDIT_LOG_ENABLED) {#PAPERLESS_AUDIT_LOG_ENABLED} #### [`PAPERLESS_AUDIT_LOG_ENABLED=<bool>`](#PAPERLESS_AUDIT_LOG_ENABLED) {#PAPERLESS_AUDIT_LOG_ENABLED}
: Enables an audit trail for documents, document types, correspondents, and tags. Log entries can be viewed in the Django backend only. : Enables the audit trail for documents, document types, correspondents, and tags.
!!! warning Defaults to true.
Once enabled cannot be disabled
## Collate Double-Sided Documents {#collate} ## Collate Double-Sided Documents {#collate}

View File

@@ -241,6 +241,11 @@ permissions can be granted to limit access to certain parts of the UI (and corre
Superusers can access all parts of the front and backend application as well as any and all objects. Superusers can access all parts of the front and backend application as well as any and all objects.
#### Admin Status
Admin status (Django 'staff status') grants access to viewing the paperless logs and the system status dialog
as well as accessing the Django backend.
#### Detailed Explanation of Global Permissions {#global-permissions} #### Detailed Explanation of Global Permissions {#global-permissions}
Global permissions define what areas of the app and API endpoints the user can access. For example, they Global permissions define what areas of the app and API endpoints the user can access. For example, they
@@ -249,7 +254,6 @@ still have "object-level" permissions.
| Type | Details | | Type | Details |
| ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Admin | _View_ or higher permissions grants access to the logs view as well as the system status. |
| AppConfig | _Change_ or higher permissions grants access to the "Application Configuration" area. | | AppConfig | _Change_ or higher permissions grants access to the "Application Configuration" area. |
| Correspondent | Grants global permissions to add, edit, delete or view Correspondents. | | Correspondent | Grants global permissions to add, edit, delete or view Correspondents. |
| CustomField | Grants global permissions to add, edit, delete or view Custom Fields. | | CustomField | Grants global permissions to add, edit, delete or view Custom Fields. |
@@ -456,6 +460,24 @@ Paperless-ngx added the ability to create shareable links to files in version 2.
If your paperless-ngx instance is behind a reverse-proxy you may want to create an exception to bypass any authentication layers that are part of your setup in order to make links truly publicly-accessible. Of course, do so with caution. If your paperless-ngx instance is behind a reverse-proxy you may want to create an exception to bypass any authentication layers that are part of your setup in order to make links truly publicly-accessible. Of course, do so with caution.
## PDF Actions
Paperless-ngx supports 3 basic editing operations for PDFs (these operations cannot be performed on non-PDF files):
- Merging documents: available when selecting multiple documents for 'bulk editing'
- Rotating documents: available when selecting multiple documents for 'bulk editing' and from an individual document's details page.
- Splitting documents: available from an individual document's details page
!!! important
Note that rotation alters the Paperless-ngx _original_ file, which would, for example, invalidate a digital signature.
## Document History
As of version 2.7, Paperless-ngx automatically records all changes to a document and records this in an audit log. The feature requires [`PAPERLESS_AUDIT_LOG_ENABLED`](configuration.md#PAPERLESS_AUDIT_LOG_ENABLED) be enabled, which it is by default as of version 2.7.
Changes to documents are visible under the "History" tab. Note that certain changes such as those made by workflows, record the 'actor'
as "System".
## Best practices {#basic-searching} ## Best practices {#basic-searching}
Paperless offers a couple tools that help you organize your document Paperless offers a couple tools that help you organize your document

View File

@@ -71,7 +71,17 @@ if ! docker stats --no-stream &> /dev/null ; then
sleep 3 sleep 3
fi fi
# Added handling for timezone for busybox based linux, not having timedatectl available (i.e. QNAP QTS)
# if neither timedatectl nor /etc/TZ is succeeding, defaulting to GMT.
if command -v timedatectl &> /dev/null ; then
default_time_zone=$(timedatectl show -p Timezone --value) default_time_zone=$(timedatectl show -p Timezone --value)
elif [ -f /etc/TZ ] && [ -f /etc/tzlist ] ; then
TZ=$(cat /etc/TZ)
default_time_zone=$(grep -B 1 -m 1 "$TZ" /etc/tzlist | head -1 | cut -f 2 -d =)
else
echo "WARN: unable to detect timezone, defaulting to Etc/UTC"
default_time_zone="Etc/UTC"
fi
set -e set -e
@@ -335,7 +345,7 @@ read -r -a OCR_LANGUAGES_ARRAY <<< "${_split_langs}"
fi fi
echo "PAPERLESS_TIME_ZONE=$TIME_ZONE" echo "PAPERLESS_TIME_ZONE=$TIME_ZONE"
echo "PAPERLESS_OCR_LANGUAGE=$OCR_LANGUAGE" echo "PAPERLESS_OCR_LANGUAGE=$OCR_LANGUAGE"
echo "PAPERLESS_SECRET_KEY=$SECRET_KEY" echo "PAPERLESS_SECRET_KEY='$SECRET_KEY'"
if [[ ! ${DEFAULT_LANGUAGES[*]} =~ ${OCR_LANGUAGES_ARRAY[*]} ]] ; then if [[ ! ${DEFAULT_LANGUAGES[*]} =~ ${OCR_LANGUAGES_ARRAY[*]} ]] ; then
echo "PAPERLESS_OCR_LANGUAGES=${OCR_LANGUAGES_ARRAY[*]}" echo "PAPERLESS_OCR_LANGUAGES=${OCR_LANGUAGES_ARRAY[*]}"
fi fi

View File

@@ -0,0 +1,36 @@
{
"folders": [
{
"path": "."
},
{
"path": "./src",
"name": "Backend"
},
{
"path": "./src-ui",
"name": "Frontend"
},
{
"path": "./.github",
"name": "CI/CD"
},
{
"path": "./docs",
"name": "Documentation"
}
],
"settings": {
"files.exclude": {
"**/__pycache__": true,
"**/.mypy_cache": true,
"**/.ruff_cache": true,
"**/.pytest_cache": true,
"**/.idea": true,
"**/.venv": true,
"**/.coverage": true,
"**/coverage.json": true
}
}
}

View File

@@ -124,7 +124,7 @@
"content": { "content": {
"size": -1, "size": -1,
"mimeType": "application/json", "mimeType": "application/json",
"text": "{\"count\":6,\"next\":null,\"previous\":null,\"all\":[8,17,7,4,11,15],\"results\":[{\"id\":8,\"name\":\"Correspondent 2\",\"show_on_dashboard\":false,\"show_in_sidebar\":false,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[{\"rule_type\":3,\"value\":\"2\"}],\"owner\":\"2\",\"user_can_change\":true},{\"id\":17,\"name\":\"In the Last Month\",\"show_on_dashboard\":false,\"show_in_sidebar\":false,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[{\"rule_type\":20,\"value\":\"created:[-1 month to now]\"}],\"owner\":\"2\",\"user_can_change\":true},{\"id\":7,\"name\":\"Inbox\",\"show_on_dashboard\":true,\"show_in_sidebar\":true,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[{\"rule_type\":6,\"value\":\"9\"}],\"owner\":\"2\",\"user_can_change\":true},{\"id\":4,\"name\":\"Recently Added\",\"show_on_dashboard\":true,\"show_in_sidebar\":true,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[],\"owner\":\"2\",\"user_can_change\":true},{\"id\":11,\"name\":\"Tag: Another Sample Tag\",\"show_on_dashboard\":false,\"show_in_sidebar\":true,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[{\"rule_type\":6,\"value\":\"4\"}],\"owner\":\"2\",\"user_can_change\":true},{\"id\":15,\"name\":\"View ASN not empty\",\"show_on_dashboard\":false,\"show_in_sidebar\":false,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[{\"rule_type\":18,\"value\":\"false\"}],\"owner\":\"2\",\"user_can_change\":true}]}" "text": "{\"count\":6,\"next\":null,\"previous\":null,\"all\":[8,17,7,4,11,15],\"results\":[{\"id\":8,\"name\":\"Correspondent 2\",\"show_on_dashboard\":false,\"show_in_sidebar\":false,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[{\"rule_type\":3,\"value\":\"2\"}],\"owner\":\"2\",\"user_can_change\":true,\"page_size\":10,\"display_mode\":\"table\",\"display_fields\":[\"created\",\"title\",\"tag\",\"documenttype\"]},{\"id\":17,\"name\":\"In the Last Month\",\"show_on_dashboard\":false,\"show_in_sidebar\":false,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[{\"rule_type\":20,\"value\":\"created:[-1 month to now]\"}],\"owner\":\"2\",\"user_can_change\":true,\"page_size\":10,\"display_mode\":\"table\",\"display_fields\":[\"created\",\"title\",\"tag\",\"documenttype\"]},{\"id\":7,\"name\":\"Inbox\",\"show_on_dashboard\":true,\"show_in_sidebar\":true,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[{\"rule_type\":6,\"value\":\"9\"}],\"owner\":\"2\",\"user_can_change\":true,\"page_size\":10,\"display_mode\":\"table\",\"display_fields\":[\"created\",\"title\",\"tag\",\"documenttype\"]},{\"id\":4,\"name\":\"Recently Added\",\"show_on_dashboard\":true,\"show_in_sidebar\":true,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[],\"owner\":\"2\",\"user_can_change\":true,\"page_size\":10,\"display_mode\":\"table\",\"display_fields\":[\"created\",\"title\",\"tag\",\"documenttype\"]},{\"id\":11,\"name\":\"Tag: Another Sample Tag\",\"show_on_dashboard\":false,\"show_in_sidebar\":true,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[{\"rule_type\":6,\"value\":\"4\"}],\"owner\":\"2\",\"user_can_change\":true,\"page_size\":10,\"display_mode\":\"table\",\"display_fields\":[\"created\",\"title\",\"tag\",\"documenttype\"]},{\"id\":15,\"name\":\"View ASN not empty\",\"show_on_dashboard\":false,\"show_in_sidebar\":false,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[{\"rule_type\":18,\"value\":\"false\"}],\"owner\":\"2\",\"user_can_change\":true,\"page_size\":10,\"display_mode\":\"table\",\"display_fields\":[\"created\",\"title\",\"tag\",\"documenttype\"]}]}"
}, },
"headersSize": -1, "headersSize": -1,
"bodySize": -1, "bodySize": -1,

View File

@@ -124,7 +124,7 @@
"content": { "content": {
"size": -1, "size": -1,
"mimeType": "application/json", "mimeType": "application/json",
"text": "{\"count\":6,\"next\":null,\"previous\":null,\"all\":[8,17,7,4,11,15],\"results\":[{\"id\":8,\"name\":\"Correspondent 2\",\"show_on_dashboard\":false,\"show_in_sidebar\":false,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[{\"rule_type\":3,\"value\":\"2\"}],\"owner\":\"2\",\"user_can_change\":true},{\"id\":17,\"name\":\"In the Last Month\",\"show_on_dashboard\":false,\"show_in_sidebar\":false,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[{\"rule_type\":20,\"value\":\"created:[-1 month to now]\"}],\"owner\":\"2\",\"user_can_change\":true},{\"id\":7,\"name\":\"Inbox\",\"show_on_dashboard\":true,\"show_in_sidebar\":true,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[{\"rule_type\":6,\"value\":\"9\"}],\"owner\":\"2\",\"user_can_change\":true},{\"id\":4,\"name\":\"Recently Added\",\"show_on_dashboard\":true,\"show_in_sidebar\":true,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[],\"owner\":\"2\",\"user_can_change\":true},{\"id\":11,\"name\":\"Tag: Another Sample Tag\",\"show_on_dashboard\":false,\"show_in_sidebar\":true,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[{\"rule_type\":6,\"value\":\"4\"}],\"owner\":\"2\",\"user_can_change\":true},{\"id\":15,\"name\":\"View ASN not empty\",\"show_on_dashboard\":false,\"show_in_sidebar\":false,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[{\"rule_type\":18,\"value\":\"false\"}],\"owner\":\"2\",\"user_can_change\":true}]}" "text": "{\"count\":6,\"next\":null,\"previous\":null,\"all\":[8,17,7,4,11,15],\"results\":[{\"id\":8,\"name\":\"Correspondent 2\",\"show_on_dashboard\":false,\"show_in_sidebar\":false,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[{\"rule_type\":3,\"value\":\"2\"}],\"owner\":\"2\",\"user_can_change\":true,\"page_size\":10,\"display_mode\":\"table\",\"display_fields\":[\"created\",\"title\",\"tag\",\"documenttype\"]},{\"id\":17,\"name\":\"In the Last Month\",\"show_on_dashboard\":false,\"show_in_sidebar\":false,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[{\"rule_type\":20,\"value\":\"created:[-1 month to now]\"}],\"owner\":\"2\",\"user_can_change\":true,\"page_size\":10,\"display_mode\":\"table\",\"display_fields\":[\"created\",\"title\",\"tag\",\"documenttype\"]},{\"id\":7,\"name\":\"Inbox\",\"show_on_dashboard\":true,\"show_in_sidebar\":true,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[{\"rule_type\":6,\"value\":\"9\"}],\"owner\":\"2\",\"user_can_change\":true,\"page_size\":10,\"display_mode\":\"table\",\"display_fields\":[\"created\",\"title\",\"tag\",\"documenttype\"]},{\"id\":4,\"name\":\"Recently Added\",\"show_on_dashboard\":true,\"show_in_sidebar\":true,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[],\"owner\":\"2\",\"user_can_change\":true,\"page_size\":10,\"display_mode\":\"table\",\"display_fields\":[\"created\",\"title\",\"tag\",\"documenttype\"]},{\"id\":11,\"name\":\"Tag: Another Sample Tag\",\"show_on_dashboard\":false,\"show_in_sidebar\":true,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[{\"rule_type\":6,\"value\":\"4\"}],\"owner\":\"2\",\"user_can_change\":true,\"page_size\":10,\"display_mode\":\"table\",\"display_fields\":[\"created\",\"title\",\"tag\",\"documenttype\"]},{\"id\":15,\"name\":\"View ASN not empty\",\"show_on_dashboard\":false,\"show_in_sidebar\":false,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[{\"rule_type\":18,\"value\":\"false\"}],\"owner\":\"2\",\"user_can_change\":true,\"page_size\":10,\"display_mode\":\"table\",\"display_fields\":[\"created\",\"title\",\"tag\",\"documenttype\"]}]}"
}, },
"headersSize": -1, "headersSize": -1,
"bodySize": -1, "bodySize": -1,

View File

@@ -124,7 +124,7 @@
"content": { "content": {
"size": -1, "size": -1,
"mimeType": "application/json", "mimeType": "application/json",
"text": "{\"count\":6,\"next\":null,\"previous\":null,\"all\":[8,17,7,4,11,15],\"results\":[{\"id\":8,\"name\":\"Correspondent 2\",\"show_on_dashboard\":false,\"show_in_sidebar\":false,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[{\"rule_type\":3,\"value\":\"2\"}],\"owner\":\"2\",\"user_can_change\":true},{\"id\":17,\"name\":\"In the Last Month\",\"show_on_dashboard\":false,\"show_in_sidebar\":false,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[{\"rule_type\":20,\"value\":\"created:[-1 month to now]\"}],\"owner\":\"2\",\"user_can_change\":true},{\"id\":7,\"name\":\"Inbox\",\"show_on_dashboard\":true,\"show_in_sidebar\":true,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[{\"rule_type\":6,\"value\":\"9\"}],\"owner\":\"2\",\"user_can_change\":true},{\"id\":4,\"name\":\"Recently Added\",\"show_on_dashboard\":true,\"show_in_sidebar\":true,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[],\"owner\":\"2\",\"user_can_change\":true},{\"id\":11,\"name\":\"Tag: Another Sample Tag\",\"show_on_dashboard\":false,\"show_in_sidebar\":true,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[{\"rule_type\":6,\"value\":\"4\"}],\"owner\":\"2\",\"user_can_change\":true},{\"id\":15,\"name\":\"View ASN not empty\",\"show_on_dashboard\":false,\"show_in_sidebar\":false,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[{\"rule_type\":18,\"value\":\"false\"}],\"owner\":\"2\",\"user_can_change\":true}]}" "text": "{\"count\":6,\"next\":null,\"previous\":null,\"all\":[8,17,7,4,11,15],\"results\":[{\"id\":8,\"name\":\"Correspondent 2\",\"show_on_dashboard\":false,\"show_in_sidebar\":false,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[{\"rule_type\":3,\"value\":\"2\"}],\"owner\":\"2\",\"user_can_change\":true,\"page_size\":10,\"display_mode\":\"table\",\"display_fields\":[\"created\",\"title\",\"tag\",\"documenttype\"]},{\"id\":17,\"name\":\"In the Last Month\",\"show_on_dashboard\":false,\"show_in_sidebar\":false,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[{\"rule_type\":20,\"value\":\"created:[-1 month to now]\"}],\"owner\":\"2\",\"user_can_change\":true,\"page_size\":10,\"display_mode\":\"table\",\"display_fields\":[\"created\",\"title\",\"tag\",\"documenttype\"]},{\"id\":7,\"name\":\"Inbox\",\"show_on_dashboard\":true,\"show_in_sidebar\":true,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[{\"rule_type\":6,\"value\":\"9\"}],\"owner\":\"2\",\"user_can_change\":true,\"page_size\":10,\"display_mode\":\"table\",\"display_fields\":[\"created\",\"title\",\"tag\",\"documenttype\"]},{\"id\":4,\"name\":\"Recently Added\",\"show_on_dashboard\":true,\"show_in_sidebar\":true,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[],\"owner\":\"2\",\"user_can_change\":true,\"page_size\":10,\"display_mode\":\"table\",\"display_fields\":[\"created\",\"title\",\"tag\",\"documenttype\"]},{\"id\":11,\"name\":\"Tag: Another Sample Tag\",\"show_on_dashboard\":false,\"show_in_sidebar\":true,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[{\"rule_type\":6,\"value\":\"4\"}],\"owner\":\"2\",\"user_can_change\":true,\"page_size\":10,\"display_mode\":\"table\",\"display_fields\":[\"created\",\"title\",\"tag\",\"documenttype\"]},{\"id\":15,\"name\":\"View ASN not empty\",\"show_on_dashboard\":false,\"show_in_sidebar\":false,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[{\"rule_type\":18,\"value\":\"false\"}],\"owner\":\"2\",\"user_can_change\":true,\"page_size\":10,\"display_mode\":\"table\",\"display_fields\":[\"created\",\"title\",\"tag\",\"documenttype\"]}]}"
}, },
"headersSize": -1, "headersSize": -1,
"bodySize": -1, "bodySize": -1,

View File

@@ -124,7 +124,7 @@
"content": { "content": {
"size": -1, "size": -1,
"mimeType": "application/json", "mimeType": "application/json",
"text": "{\"count\":6,\"next\":null,\"previous\":null,\"all\":[8,17,7,4,11,15],\"results\":[{\"id\":8,\"name\":\"Correspondent 2\",\"show_on_dashboard\":false,\"show_in_sidebar\":false,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[{\"rule_type\":3,\"value\":\"2\"}],\"owner\":\"2\",\"user_can_change\":true},{\"id\":7,\"name\":\"Inbox\",\"show_on_dashboard\":true,\"show_in_sidebar\":true,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[{\"rule_type\":6,\"value\":\"9\"}],\"owner\":\"2\",\"user_can_change\":true},{\"id\":11,\"name\":\"Tag: Another Sample Tag\",\"show_on_dashboard\":false,\"show_in_sidebar\":true,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[{\"rule_type\":6,\"value\":\"4\"}],\"owner\":\"2\",\"user_can_change\":true}]}" "text": "{\"count\":6,\"next\":null,\"previous\":null,\"all\":[8,17,7,4,11,15],\"results\":[{\"id\":8,\"name\":\"Correspondent 2\",\"show_on_dashboard\":false,\"show_in_sidebar\":false,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[{\"rule_type\":3,\"value\":\"2\"}],\"owner\":\"2\",\"user_can_change\":true,\"page_size\":10,\"display_mode\":\"table\",\"display_fields\":[\"created\",\"title\",\"tag\",\"documenttype\"]},{\"id\":17,\"name\":\"In the Last Month\",\"show_on_dashboard\":false,\"show_in_sidebar\":false,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[{\"rule_type\":20,\"value\":\"created:[-1 month to now]\"}],\"owner\":\"2\",\"user_can_change\":true,\"page_size\":10,\"display_mode\":\"table\",\"display_fields\":[\"created\",\"title\",\"tag\",\"documenttype\"]},{\"id\":7,\"name\":\"Inbox\",\"show_on_dashboard\":true,\"show_in_sidebar\":true,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[{\"rule_type\":6,\"value\":\"9\"}],\"owner\":\"2\",\"user_can_change\":true,\"page_size\":10,\"display_mode\":\"table\",\"display_fields\":[\"created\",\"title\",\"tag\",\"documenttype\"]},{\"id\":4,\"name\":\"Recently Added\",\"show_on_dashboard\":true,\"show_in_sidebar\":true,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[],\"owner\":\"2\",\"user_can_change\":true,\"page_size\":10,\"display_mode\":\"table\",\"display_fields\":[\"created\",\"title\",\"tag\",\"documenttype\"]},{\"id\":11,\"name\":\"Tag: Another Sample Tag\",\"show_on_dashboard\":false,\"show_in_sidebar\":true,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[{\"rule_type\":6,\"value\":\"4\"}],\"owner\":\"2\",\"user_can_change\":true,\"page_size\":10,\"display_mode\":\"table\",\"display_fields\":[\"created\",\"title\",\"tag\",\"documenttype\"]},{\"id\":15,\"name\":\"View ASN not empty\",\"show_on_dashboard\":false,\"show_in_sidebar\":false,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[{\"rule_type\":18,\"value\":\"false\"}],\"owner\":\"2\",\"user_can_change\":true,\"page_size\":10,\"display_mode\":\"table\",\"display_fields\":[\"created\",\"title\",\"tag\",\"documenttype\"]}]}"
}, },
"headersSize": -1, "headersSize": -1,
"bodySize": -1, "bodySize": -1,

View File

@@ -81,14 +81,15 @@ test('text filtering', async ({ page }) => {
test('date filtering', async ({ page }) => { test('date filtering', async ({ page }) => {
await page.routeFromHAR(REQUESTS_HAR3, { notFound: 'fallback' }) await page.routeFromHAR(REQUESTS_HAR3, { notFound: 'fallback' })
await page.goto('/documents') await page.goto('/documents')
await page.getByRole('button', { name: 'Created' }).click() await page.getByRole('button', { name: 'Dates' }).click()
await page.getByRole('menuitem', { name: 'Last 3 months' }).click() await page.getByRole('menuitem', { name: 'Last 3 months' }).first().click()
await expect(page.locator('pngx-document-list')).toHaveText(/one document/i) await expect(page.locator('pngx-document-list')).toHaveText(/one document/i)
await page.getByRole('button', { name: 'Created Clear selected' }).click() await page.getByRole('button', { name: 'Dates Clear selected' }).click()
await page.getByRole('button', { name: 'Created' }).click() await page.getByRole('button', { name: 'Dates' }).click()
await page await page
.getByRole('menuitem', { name: 'After mm/dd/yyyy' }) .getByRole('menuitem', { name: 'After mm/dd/yyyy' })
.getByRole('button') .getByRole('button')
.first()
.click() .click()
await page.getByRole('combobox', { name: 'Select month' }).selectOption('12') await page.getByRole('combobox', { name: 'Select month' }).selectOption('12')
await page.getByRole('combobox', { name: 'Select year' }).selectOption('2022') await page.getByRole('combobox', { name: 'Select year' }).selectOption('2022')
@@ -138,11 +139,11 @@ test('sorting', async ({ page }) => {
test('change views', async ({ page }) => { test('change views', async ({ page }) => {
await page.routeFromHAR(REQUESTS_HAR5, { notFound: 'fallback' }) await page.routeFromHAR(REQUESTS_HAR5, { notFound: 'fallback' })
await page.goto('/documents') await page.goto('/documents')
await page.locator('pngx-page-header label').first().click() await page.locator('.btn-group label').first().click()
await expect(page.locator('pngx-document-list table')).toBeVisible() await expect(page.locator('pngx-document-list table')).toBeVisible()
await page.locator('pngx-page-header label').nth(1).click() await page.locator('.btn-group label').nth(1).click()
await expect(page.locator('pngx-document-card-small').first()).toBeAttached() await expect(page.locator('pngx-document-card-small').first()).toBeAttached()
await page.locator('pngx-page-header label').nth(2).click() await page.locator('.btn-group label').nth(2).click()
await expect(page.locator('pngx-document-card-large').first()).toBeAttached() await expect(page.locator('pngx-document-card-large').first()).toBeAttached()
}) })

File diff suppressed because it is too large Load Diff

1924
src-ui/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -11,15 +11,15 @@
}, },
"private": true, "private": true,
"dependencies": { "dependencies": {
"@angular/cdk": "^17.2.1", "@angular/cdk": "^17.3.2",
"@angular/common": "~17.2.3", "@angular/common": "~17.3.2",
"@angular/compiler": "~17.2.3", "@angular/compiler": "~17.3.2",
"@angular/core": "~17.2.3", "@angular/core": "~17.3.2",
"@angular/forms": "~17.2.3", "@angular/forms": "~17.3.2",
"@angular/localize": "~17.2.3", "@angular/localize": "~17.3.2",
"@angular/platform-browser": "~17.2.3", "@angular/platform-browser": "~17.3.2",
"@angular/platform-browser-dynamic": "~17.2.3", "@angular/platform-browser-dynamic": "~17.3.2",
"@angular/router": "~17.2.3", "@angular/router": "~17.3.2",
"@ng-bootstrap/ng-bootstrap": "^16.0.0", "@ng-bootstrap/ng-bootstrap": "^16.0.0",
"@ng-select/ng-select": "^12.0.7", "@ng-select/ng-select": "^12.0.7",
"@ngneat/dirty-check-forms": "^3.0.3", "@ngneat/dirty-check-forms": "^3.0.3",
@@ -41,19 +41,19 @@
}, },
"devDependencies": { "devDependencies": {
"@angular-builders/jest": "17.0.2", "@angular-builders/jest": "17.0.2",
"@angular-devkit/build-angular": "~17.2.2", "@angular-devkit/build-angular": "~17.3.2",
"@angular-eslint/builder": "17.2.1", "@angular-eslint/builder": "17.3.0",
"@angular-eslint/eslint-plugin": "17.2.1", "@angular-eslint/eslint-plugin": "17.3.0",
"@angular-eslint/eslint-plugin-template": "17.2.1", "@angular-eslint/eslint-plugin-template": "17.3.0",
"@angular-eslint/schematics": "17.2.1", "@angular-eslint/schematics": "17.3.0",
"@angular-eslint/template-parser": "17.2.1", "@angular-eslint/template-parser": "17.3.0",
"@angular/cli": "~17.2.2", "@angular/cli": "~17.3.2",
"@angular/compiler-cli": "~17.2.2", "@angular/compiler-cli": "~17.3.2",
"@playwright/test": "^1.42.0", "@playwright/test": "^1.42.1",
"@types/jest": "^29.5.12", "@types/jest": "^29.5.12",
"@types/node": "^20.11.24", "@types/node": "^20.12.2",
"@typescript-eslint/eslint-plugin": "^7.1.0", "@typescript-eslint/eslint-plugin": "^7.4.0",
"@typescript-eslint/parser": "^7.1.0", "@typescript-eslint/parser": "^7.4.0",
"concurrently": "^8.2.2", "concurrently": "^8.2.2",
"eslint": "^8.57.0", "eslint": "^8.57.0",
"jest": "29.7.0", "jest": "29.7.0",

View File

@@ -76,7 +76,10 @@ const mock = () => {
let storage: { [key: string]: string } = {} let storage: { [key: string]: string } = {}
return { return {
getItem: (key: string) => (key in storage ? storage[key] : null), getItem: (key: string) => (key in storage ? storage[key] : null),
setItem: (key: string, value: string) => (storage[key] = value || ''), setItem: (key: string, value: string) => {
if (value.length > 1000000) throw new Error('localStorage overflow')
storage[key] = value || ''
},
removeItem: (key: string) => delete storage[key], removeItem: (key: string) => delete storage[key],
clear: () => (storage = {}), clear: () => (storage = {}),
} }

View File

@@ -141,10 +141,7 @@ export const routes: Routes = [
component: LogsComponent, component: LogsComponent,
canActivate: [PermissionsGuard], canActivate: [PermissionsGuard],
data: { data: {
requiredPermission: { requireAdmin: true,
action: PermissionAction.View,
type: PermissionType.Admin,
},
}, },
}, },
// redirect old paths // redirect old paths

View File

@@ -31,7 +31,7 @@ import { ToastsComponent } from './components/common/toasts/toasts.component'
import { FilterEditorComponent } from './components/document-list/filter-editor/filter-editor.component' import { FilterEditorComponent } from './components/document-list/filter-editor/filter-editor.component'
import { FilterableDropdownComponent } from './components/common/filterable-dropdown/filterable-dropdown.component' import { FilterableDropdownComponent } from './components/common/filterable-dropdown/filterable-dropdown.component'
import { ToggleableDropdownButtonComponent } from './components/common/filterable-dropdown/toggleable-dropdown-button/toggleable-dropdown-button.component' import { ToggleableDropdownButtonComponent } from './components/common/filterable-dropdown/toggleable-dropdown-button/toggleable-dropdown-button.component'
import { DateDropdownComponent } from './components/common/date-dropdown/date-dropdown.component' import { DatesDropdownComponent } from './components/common/dates-dropdown/dates-dropdown.component'
import { DocumentCardLargeComponent } from './components/document-list/document-card-large/document-card-large.component' import { DocumentCardLargeComponent } from './components/document-list/document-card-large/document-card-large.component'
import { DocumentCardSmallComponent } from './components/document-list/document-card-small/document-card-small.component' import { DocumentCardSmallComponent } from './components/document-list/document-card-small/document-card-small.component'
import { BulkEditorComponent } from './components/document-list/bulk-editor/bulk-editor.component' import { BulkEditorComponent } from './components/document-list/bulk-editor/bulk-editor.component'
@@ -116,9 +116,16 @@ import { ConfirmButtonComponent } from './components/common/confirm-button/confi
import { MonetaryComponent } from './components/common/input/monetary/monetary.component' import { MonetaryComponent } from './components/common/input/monetary/monetary.component'
import { SystemStatusDialogComponent } from './components/common/system-status-dialog/system-status-dialog.component' import { SystemStatusDialogComponent } from './components/common/system-status-dialog/system-status-dialog.component'
import { NgxFilesizeModule } from 'ngx-filesize' import { NgxFilesizeModule } from 'ngx-filesize'
import { RotateConfirmDialogComponent } from './components/common/confirm-dialog/rotate-confirm-dialog/rotate-confirm-dialog.component'
import { MergeConfirmDialogComponent } from './components/common/confirm-dialog/merge-confirm-dialog/merge-confirm-dialog.component'
import { SplitConfirmDialogComponent } from './components/common/confirm-dialog/split-confirm-dialog/split-confirm-dialog.component'
import { DocumentHistoryComponent } from './components/document-history/document-history.component'
import { DragDropSelectComponent } from './components/common/input/drag-drop-select/drag-drop-select.component'
import { CustomFieldDisplayComponent } from './components/common/custom-field-display/custom-field-display.component'
import { import {
airplane, airplane,
archive, archive,
arrowClockwise,
arrowCounterclockwise, arrowCounterclockwise,
arrowDown, arrowDown,
arrowLeft, arrowLeft,
@@ -127,12 +134,15 @@ import {
arrowRightShort, arrowRightShort,
arrowUpRight, arrowUpRight,
asterisk, asterisk,
bodyText,
boxArrowUp, boxArrowUp,
boxArrowUpRight, boxArrowUpRight,
boxes, boxes,
calendar, calendar,
calendarEvent, calendarEvent,
calendarEventFill,
cardChecklist, cardChecklist,
cardHeading,
caretDown, caretDown,
caretUp, caretUp,
chatLeftText, chatLeftText,
@@ -174,6 +184,7 @@ import {
hddStack, hddStack,
house, house,
infoCircle, infoCircle,
journals,
link, link,
listTask, listTask,
listUl, listUl,
@@ -188,6 +199,7 @@ import {
plus, plus,
plusCircle, plusCircle,
questionCircle, questionCircle,
scissors,
search, search,
slashCircle, slashCircle,
sliders2Vertical, sliders2Vertical,
@@ -209,6 +221,7 @@ import {
const icons = { const icons = {
airplane, airplane,
archive, archive,
arrowClockwise,
arrowCounterclockwise, arrowCounterclockwise,
arrowDown, arrowDown,
arrowLeft, arrowLeft,
@@ -217,12 +230,15 @@ const icons = {
arrowRightShort, arrowRightShort,
arrowUpRight, arrowUpRight,
asterisk, asterisk,
bodyText,
boxArrowUp, boxArrowUp,
boxArrowUpRight, boxArrowUpRight,
boxes, boxes,
calendar, calendar,
calendarEvent, calendarEvent,
calendarEventFill,
cardChecklist, cardChecklist,
cardHeading,
caretDown, caretDown,
caretUp, caretUp,
chatLeftText, chatLeftText,
@@ -264,6 +280,7 @@ const icons = {
hddStack, hddStack,
house, house,
infoCircle, infoCircle,
journals,
link, link,
listTask, listTask,
listUl, listUl,
@@ -278,6 +295,7 @@ const icons = {
plus, plus,
plusCircle, plusCircle,
questionCircle, questionCircle,
scissors,
search, search,
slashCircle, slashCircle,
sliders2Vertical, sliders2Vertical,
@@ -391,7 +409,7 @@ function initializeApp(settings: SettingsService) {
FilterEditorComponent, FilterEditorComponent,
FilterableDropdownComponent, FilterableDropdownComponent,
ToggleableDropdownButtonComponent, ToggleableDropdownButtonComponent,
DateDropdownComponent, DatesDropdownComponent,
DocumentCardLargeComponent, DocumentCardLargeComponent,
DocumentCardSmallComponent, DocumentCardSmallComponent,
BulkEditorComponent, BulkEditorComponent,
@@ -458,6 +476,12 @@ function initializeApp(settings: SettingsService) {
ConfirmButtonComponent, ConfirmButtonComponent,
MonetaryComponent, MonetaryComponent,
SystemStatusDialogComponent, SystemStatusDialogComponent,
RotateConfirmDialogComponent,
MergeConfirmDialogComponent,
SplitConfirmDialogComponent,
DocumentHistoryComponent,
DragDropSelectComponent,
CustomFieldDisplayComponent,
], ],
imports: [ imports: [
BrowserModule, BrowserModule,

View File

@@ -7,9 +7,9 @@
<button class="btn btn-sm btn-outline-primary" (click)="tourService.start()"> <button class="btn btn-sm btn-outline-primary" (click)="tourService.start()">
<i-bs class="me-1" name="airplane"></i-bs>&nbsp;<ng-container i18n>Start tour</ng-container> <i-bs class="me-1" name="airplane"></i-bs>&nbsp;<ng-container i18n>Start tour</ng-container>
</button> </button>
@if (permissionsService.isAdmin()) {
<button class="btn btn-sm btn-outline-primary position-relative ms-md-5 me-1" (click)="showSystemStatus()" <button class="btn btn-sm btn-outline-primary position-relative ms-md-5 me-1" (click)="showSystemStatus()"
[disabled]="!systemStatus" [disabled]="!systemStatus">
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Admin }">
@if (!systemStatus) { @if (!systemStatus) {
<div class="spinner-border spinner-border-sm me-1 h-75" role="status"></div> <div class="spinner-border spinner-border-sm me-1 h-75" role="status"></div>
} @else { } @else {
@@ -26,10 +26,11 @@
} }
<ng-container i18n>System Status</ng-container> <ng-container i18n>System Status</ng-container>
</button> </button>
<a *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Admin }" class="btn btn-sm btn-primary" href="admin/" target="_blank"> <a class="btn btn-sm btn-primary" href="admin/" target="_blank">
<ng-container i18n>Open Django Admin</ng-container> <ng-container i18n>Open Django Admin</ng-container>
&nbsp;<i-bs name="arrow-up-right"></i-bs> &nbsp;<i-bs name="arrow-up-right"></i-bs>
</a> </a>
}
</pngx-page-header> </pngx-page-header>
<form [formGroup]="settingsForm" (ngSubmit)="saveSettings()"> <form [formGroup]="settingsForm" (ngSubmit)="saveSettings()">
@@ -319,17 +320,17 @@
</div> </div>
<h4 i18n>Views</h4> <h4 i18n>Views</h4>
<div formGroupName="savedViews"> <ul class="list-group" formGroupName="savedViews">
@for (view of savedViews; track view) { @for (view of savedViews; track view) {
<li class="list-group-item py-3">
<div [formGroupName]="view.id" class="row"> <div [formGroupName]="view.id" class="row">
<div class="mb-3 col"> <div class="row">
<label class="form-label" for="name_{{view.id}}" i18n>Name</label> <div class="col">
<input type="text" class="form-control" formControlName="name" id="name_{{view.id}}"> <pngx-input-text title="Name" formControlName="name"></pngx-input-text>
</div> </div>
<div class="mb-2 col"> <div class="col">
<label class="form-label" for="show_on_dashboard_{{view.id}}" i18n>&nbsp;<span class="visually-hidden">Appears on</span></label> <div class="form-check form-switch mt-3">
<div class="form-check form-switch">
<input type="checkbox" class="form-check-input" id="show_on_dashboard_{{view.id}}" formControlName="show_on_dashboard"> <input type="checkbox" class="form-check-input" id="show_on_dashboard_{{view.id}}" formControlName="show_on_dashboard">
<label class="form-check-label" for="show_on_dashboard_{{view.id}}" i18n>Show on dashboard</label> <label class="form-check-label" for="show_on_dashboard_{{view.id}}" i18n>Show on dashboard</label>
</div> </div>
@@ -338,9 +339,8 @@
<label class="form-check-label" for="show_in_sidebar_{{view.id}}" i18n>Show in sidebar</label> <label class="form-check-label" for="show_in_sidebar_{{view.id}}" i18n>Show in sidebar</label>
</div> </div>
</div> </div>
<div class="mb-2 col-auto"> <div class="col-auto">
<label class="form-label" for="name_{{view.id}}" i18n>Actions</label> <label class="form-label" for="name_{{view.id}}" i18n>Actions</label>
<pngx-confirm-button <pngx-confirm-button
label="Delete" label="Delete"
i18n-label i18n-label
@@ -351,20 +351,40 @@
</pngx-confirm-button> </pngx-confirm-button>
</div> </div>
</div> </div>
<div class="row">
<div class="col">
<pngx-input-number i18n-title title="Documents page size" [showAdd]="false" formControlName="page_size"></pngx-input-number>
</div>
<div class="col">
<label class="form-label" for="display_mode_{{view.id}}" i18n>Display as</label>
<select class="form-select" formControlName="display_mode">
<option [ngValue]="DisplayMode.TABLE" i18n>Table</option>
<option [ngValue]="DisplayMode.SMALL_CARDS" i18n>Small Cards</option>
<option [ngValue]="DisplayMode.LARGE_CARDS" i18n>Large Cards</option>
</select>
</div>
@if (displayFields) {
<pngx-input-drag-drop-select i18n-title title="Show" i18n-emptyText emptyText="Default" [items]="displayFields" formControlName="display_fields"></pngx-input-drag-drop-select>
}
</div>
</div>
</li>
} }
@if (savedViews && savedViews.length === 0) { @if (savedViews && savedViews.length === 0) {
<li class="list-group-item">
<div i18n>No saved views defined.</div> <div i18n>No saved views defined.</div>
</li>
} }
@if (!savedViews) { @if (!savedViews) {
<div> <li class="list-group-item">
<div class="spinner-border spinner-border-sm fw-normal ms-2 me-auto" role="status"></div> <div class="spinner-border spinner-border-sm fw-normal ms-2 me-auto" role="status"></div>
<div class="visually-hidden" i18n>Loading...</div> <div class="visually-hidden" i18n>Loading...</div>
</div> </li>
} }
</div> </ul>
</ng-template> </ng-template>
</li> </li>
@@ -373,4 +393,5 @@
<div [ngbNavOutlet]="nav" class="border-start border-end border-bottom p-3 mb-3 shadow-sm"></div> <div [ngbNavOutlet]="nav" class="border-start border-end border-bottom p-3 mb-3 shadow-sm"></div>
<button type="submit" class="btn btn-primary mb-2" [disabled]="(isDirty$ | async) === false" i18n>Save</button> <button type="submit" class="btn btn-primary mb-2" [disabled]="(isDirty$ | async) === false" i18n>Save</button>
<button type="button" (click)="reset()" class="btn btn-secondary ms-2 mb-2" [disabled]="(isDirty$ | async) === false" i18n>Cancel</button>
</form> </form>

View File

@@ -48,6 +48,8 @@ import {
InstallType, InstallType,
SystemStatusItemStatus, SystemStatusItemStatus,
} from 'src/app/data/system-status' } from 'src/app/data/system-status'
import { DragDropSelectComponent } from '../../common/input/drag-drop-select/drag-drop-select.component'
import { DragDropModule } from '@angular/cdk/drag-drop'
const savedViews = [ const savedViews = [
{ id: 1, name: 'view1', show_in_sidebar: true, show_on_dashboard: true }, { id: 1, name: 'view1', show_in_sidebar: true, show_on_dashboard: true },
@@ -96,6 +98,7 @@ describe('SettingsComponent', () => {
PermissionsGroupComponent, PermissionsGroupComponent,
IfOwnerDirective, IfOwnerDirective,
ConfirmButtonComponent, ConfirmButtonComponent,
DragDropSelectComponent,
], ],
providers: [CustomDatePipe, DatePipe, PermissionsGuard], providers: [CustomDatePipe, DatePipe, PermissionsGuard],
imports: [ imports: [
@@ -108,6 +111,7 @@ describe('SettingsComponent', () => {
NgSelectModule, NgSelectModule,
NgxBootstrapIconsModule.pick(allIcons), NgxBootstrapIconsModule.pick(allIcons),
NgbModalModule, NgbModalModule,
DragDropModule,
], ],
}).compileComponents() }).compileComponents()
@@ -418,6 +422,7 @@ describe('SettingsComponent', () => {
}, },
} }
jest.spyOn(systemStatusService, 'get').mockReturnValue(of(status)) jest.spyOn(systemStatusService, 'get').mockReturnValue(of(status))
jest.spyOn(permissionsService, 'isAdmin').mockReturnValue(true)
completeSetup() completeSetup()
expect(component['systemStatus']).toEqual(status) // private expect(component['systemStatus']).toEqual(status) // private
expect(component.systemStatusHasErrors).toBeTruthy() expect(component.systemStatusHasErrors).toBeTruthy()
@@ -436,4 +441,11 @@ describe('SettingsComponent', () => {
size: 'xl', size: 'xl',
}) })
}) })
it('should support reset', () => {
completeSetup()
component.settingsForm.get('themeColor').setValue('#ff0000')
component.reset()
expect(component.settingsForm.get('themeColor').value).toEqual('')
})
}) })

View File

@@ -50,6 +50,7 @@ import {
SystemStatusItemStatus, SystemStatusItemStatus,
SystemStatus, SystemStatus,
} from 'src/app/data/system-status' } from 'src/app/data/system-status'
import { DisplayMode } from 'src/app/data/document'
enum SettingsNavIDs { enum SettingsNavIDs {
General = 1, General = 1,
@@ -73,8 +74,8 @@ export class SettingsComponent
extends ComponentWithPermissions extends ComponentWithPermissions
implements OnInit, AfterViewInit, OnDestroy, DirtyComponent implements OnInit, AfterViewInit, OnDestroy, DirtyComponent
{ {
SettingsNavIDs = SettingsNavIDs
activeNavID: number activeNavID: number
DisplayMode = DisplayMode
savedViewGroup = new FormGroup({}) savedViewGroup = new FormGroup({})
@@ -110,6 +111,10 @@ export class SettingsComponent
}) })
savedViews: SavedView[] savedViews: SavedView[]
SettingsNavIDs = SettingsNavIDs
get displayFields() {
return this.settings.allDisplayFields
}
store: BehaviorSubject<any> store: BehaviorSubject<any>
storeSub: Subscription storeSub: Subscription
@@ -121,7 +126,7 @@ export class SettingsComponent
users: User[] users: User[]
groups: Group[] groups: Group[]
private systemStatus: SystemStatus public systemStatus: SystemStatus
get systemStatusHasErrors(): boolean { get systemStatusHasErrors(): boolean {
return ( return (
@@ -340,6 +345,9 @@ export class SettingsComponent
name: view.name, name: view.name,
show_on_dashboard: view.show_on_dashboard, show_on_dashboard: view.show_on_dashboard,
show_in_sidebar: view.show_in_sidebar, show_in_sidebar: view.show_in_sidebar,
page_size: view.page_size,
display_mode: view.display_mode,
display_fields: view.display_fields,
} }
this.savedViewGroup.addControl( this.savedViewGroup.addControl(
view.id.toString(), view.id.toString(),
@@ -348,6 +356,9 @@ export class SettingsComponent
name: new FormControl(null), name: new FormControl(null),
show_on_dashboard: new FormControl(null), show_on_dashboard: new FormControl(null),
show_in_sidebar: new FormControl(null), show_in_sidebar: new FormControl(null),
page_size: new FormControl(null),
display_mode: new FormControl(null),
display_fields: new FormControl([]),
}) })
) )
} }
@@ -385,12 +396,7 @@ export class SettingsComponent
this.settingsForm.patchValue(currentFormValue) this.settingsForm.patchValue(currentFormValue)
} }
if ( if (this.permissionsService.isAdmin()) {
this.permissionsService.currentUserCan(
PermissionAction.View,
PermissionType.Admin
)
) {
this.systemStatusService.get().subscribe((status) => { this.systemStatusService.get().subscribe((status) => {
this.systemStatus = status this.systemStatus = status
}) })
@@ -535,8 +541,8 @@ export class SettingsComponent
.subscribe({ .subscribe({
next: () => { next: () => {
this.store.next(this.settingsForm.value) this.store.next(this.settingsForm.value)
this.documentListViewService.updatePageSize()
this.settings.updateAppearanceSettings() this.settings.updateAppearanceSettings()
this.settings.initializeDisplayFields()
let savedToast: Toast = { let savedToast: Toast = {
content: $localize`Settings were saved successfully.`, content: $localize`Settings were saved successfully.`,
delay: 5000, delay: 5000,
@@ -597,6 +603,10 @@ export class SettingsComponent
} }
} }
reset() {
this.settingsForm.patchValue(this.store.getValue())
}
clearThemeColor() { clearThemeColor() {
this.settingsForm.get('themeColor').patchValue('') this.settingsForm.get('themeColor').patchValue('')
} }

View File

@@ -29,7 +29,7 @@
<tr> <tr>
<th scope="col"> <th scope="col">
<div class="form-check"> <div class="form-check">
<input type="checkbox" class="form-check-input" id="all-tasks" [disabled]="currentTasks.length === 0" (click)="toggleAll($event); $event.stopPropagation();"> <input type="checkbox" class="form-check-input" id="all-tasks" [disabled]="currentTasks.length === 0" [(ngModel)]="togggleAll" (click)="toggleAll($event); $event.stopPropagation();">
<label class="form-check-label" for="all-tasks"></label> <label class="form-check-label" for="all-tasks"></label>
</div> </div>
</th> </th>

View File

@@ -29,6 +29,7 @@ import { PageHeaderComponent } from '../../common/page-header/page-header.compon
import { TasksComponent } from './tasks.component' import { TasksComponent } from './tasks.component'
import { PermissionsGuard } from 'src/app/guards/permissions.guard' import { PermissionsGuard } from 'src/app/guards/permissions.guard'
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons' import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
import { FormsModule } from '@angular/forms'
const tasks: PaperlessTask[] = [ const tasks: PaperlessTask[] = [
{ {
@@ -140,6 +141,7 @@ describe('TasksComponent', () => {
HttpClientTestingModule, HttpClientTestingModule,
RouterTestingModule.withRoutes(routes), RouterTestingModule.withRoutes(routes),
NgxBootstrapIconsModule.pick(allIcons), NgxBootstrapIconsModule.pick(allIcons),
FormsModule,
], ],
}).compileComponents() }).compileComponents()

View File

@@ -18,6 +18,7 @@ export class TasksComponent
{ {
public activeTab: string public activeTab: string
public selectedTasks: Set<number> = new Set() public selectedTasks: Set<number> = new Set()
public togggleAll: boolean = false
public expandedTask: number public expandedTask: number
public pageSize: number = 25 public pageSize: number = 25
@@ -120,6 +121,7 @@ export class TasksComponent
} }
clearSelection() { clearSelection() {
this.togggleAll = false
this.selectedTasks.clear() this.selectedTasks.clear()
} }

View File

@@ -26,7 +26,7 @@
@for (user of users; track user) { @for (user of users; track user) {
<li class="list-group-item"> <li class="list-group-item">
<div class="row"> <div class="row">
<div class="col d-flex align-items-center"><button class="btn btn-link p-0" type="button" (click)="editUser(user)" [disabled]="!permissionsService.currentUserCan(PermissionAction.Change, PermissionType.User)">{{user.username}}</button></div> <div class="col d-flex align-items-center"><button class="btn btn-link p-0 text-start" type="button" (click)="editUser(user)" [disabled]="!permissionsService.currentUserCan(PermissionAction.Change, PermissionType.User)">{{user.username}}</button></div>
<div class="col d-flex align-items-center">{{user.first_name}} {{user.last_name}}</div> <div class="col d-flex align-items-center">{{user.first_name}} {{user.last_name}}</div>
<div class="col d-flex align-items-center">{{user.groups?.map(getGroupName, this).join(', ')}}</div> <div class="col d-flex align-items-center">{{user.groups?.map(getGroupName, this).join(', ')}}</div>
<div class="col"> <div class="col">
@@ -52,7 +52,6 @@
<i-bs name="plus-circle"></i-bs>&nbsp;<ng-container i18n>Add Group</ng-container> <i-bs name="plus-circle"></i-bs>&nbsp;<ng-container i18n>Add Group</ng-container>
</button> </button>
</h4> </h4>
@if (groups.length > 0) {
<ul class="list-group"> <ul class="list-group">
<li class="list-group-item"> <li class="list-group-item">
<div class="row"> <div class="row">
@@ -65,7 +64,7 @@
@for (group of groups; track group) { @for (group of groups; track group) {
<li class="list-group-item"> <li class="list-group-item">
<div class="row"> <div class="row">
<div class="col d-flex align-items-center"><button class="btn btn-link p-0" type="button" (click)="editGroup(group)" [disabled]="!permissionsService.currentUserCan(PermissionAction.Change, PermissionType.Group)">{{group.name}}</button></div> <div class="col d-flex align-items-center"><button class="btn btn-link p-0 text-start" type="button" (click)="editGroup(group)" [disabled]="!permissionsService.currentUserCan(PermissionAction.Change, PermissionType.Group)">{{group.name}}</button></div>
<div class="col"></div> <div class="col"></div>
<div class="col"></div> <div class="col"></div>
<div class="col"> <div class="col">
@@ -86,7 +85,6 @@
} }
</ul> </ul>
} }
}
@if (!users || !groups) { @if (!users || !groups) {
<div> <div>

View File

@@ -5,7 +5,7 @@
<span class="navbar-toggler-icon"></span> <span class="navbar-toggler-icon"></span>
</button> </button>
<a class="navbar-brand d-flex align-items-center me-0 px-3 py-3 order-sm-0" <a class="navbar-brand d-flex align-items-center me-0 px-3 py-3 order-sm-0"
[ngClass]="{ 'slim': slimSidebarEnabled, 'col-auto col-md-3 col-lg-2' : !slimSidebarEnabled, 'py-3' : !customAppTitle?.length || slimSidebarEnabled, 'py-2': customAppTitle?.length }" [ngClass]="{ 'slim': slimSidebarEnabled, 'col-auto col-md-3 col-lg-2 col-xxxl-1' : !slimSidebarEnabled, 'py-3' : !customAppTitle?.length || slimSidebarEnabled, 'py-2': customAppTitle?.length }"
routerLink="/dashboard" routerLink="/dashboard"
tourAnchor="tour.intro"> tourAnchor="tour.intro">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 198.43 238.91" width="1em" height="1.5em" fill="currentColor"> <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 198.43 238.91" width="1em" height="1.5em" fill="currentColor">
@@ -85,14 +85,14 @@
</button> </button>
<div class="sidebar-sticky pt-3 d-flex flex-column justify-space-around"> <div class="sidebar-sticky pt-3 d-flex flex-column justify-space-around">
<ul class="nav flex-column"> <ul class="nav flex-column">
<li class="nav-item"> <li class="nav-item app-link">
<a class="nav-link" routerLink="dashboard" routerLinkActive="active" (click)="closeMenu()" <a class="nav-link" routerLink="dashboard" routerLinkActive="active" (click)="closeMenu()"
ngbPopover="Dashboard" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" ngbPopover="Dashboard" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim"> container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
<i-bs class="me-1" name="house"></i-bs><span>&nbsp;<ng-container i18n>Dashboard</ng-container></span> <i-bs class="me-1" name="house"></i-bs><span>&nbsp;<ng-container i18n>Dashboard</ng-container></span>
</a> </a>
</li> </li>
<li class="nav-item" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }"> <li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }">
<a class="nav-link" routerLink="documents" routerLinkActive="active" (click)="closeMenu()" <a class="nav-link" routerLink="documents" routerLinkActive="active" (click)="closeMenu()"
ngbPopover="Documents" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" ngbPopover="Documents" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim"> container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
@@ -100,9 +100,10 @@
</a> </a>
</li> </li>
</ul> </ul>
<ng-container *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.SavedView }">
<div class="nav-group mt-3 mb-1" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.SavedView }">
@if (savedViewService.loading || savedViewService.sidebarViews?.length > 0) { @if (savedViewService.loading || savedViewService.sidebarViews?.length > 0) {
<h6 class="sidebar-heading px-3 mt-3 mb-1 text-muted"> <h6 class="sidebar-heading px-3 text-muted">
<span i18n>Saved views</span> <span i18n>Saved views</span>
@if (savedViewService.loading) { @if (savedViewService.loading) {
<div class="spinner-border spinner-border-sm fw-normal ms-2" role="status"></div> <div class="spinner-border spinner-border-sm fw-normal ms-2" role="status"></div>
@@ -110,8 +111,8 @@
</h6> </h6>
} }
<ul class="nav flex-column mb-2" cdkDropList (cdkDropListDropped)="onDrop($event)"> <ul class="nav flex-column mb-2" cdkDropList (cdkDropListDropped)="onDrop($event)">
@for (view of savedViewService.sidebarViews; track view) { @for (view of savedViewService.sidebarViews; track view.id) {
<li class="nav-item w-100" cdkDrag [cdkDragDisabled]="!settingsService.organizingSidebarSavedViews" <li class="nav-item w-100 app-link" cdkDrag [cdkDragDisabled]="!settingsService.organizingSidebarSavedViews"
cdkDragPreviewContainer="parent" cdkDragPreviewClass="navItemDrag" (cdkDragStarted)="onDragStart($event)" cdkDragPreviewContainer="parent" cdkDragPreviewClass="navItemDrag" (cdkDragStarted)="onDragStart($event)"
(cdkDragEnded)="onDragEnd($event)"> (cdkDragEnded)="onDragEnd($event)">
<a class="nav-link" [class.text-truncate]="!slimSidebarEnabled" routerLink="view/{{view.id}}" <a class="nav-link" [class.text-truncate]="!slimSidebarEnabled" routerLink="view/{{view.id}}"
@@ -128,18 +129,18 @@
</li> </li>
} }
</ul> </ul>
</ng-container> </div>
<ng-container *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }"> <div class="nav-group mt-3 mb-1" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }">
@if (openDocuments.length > 0) { @if (openDocuments.length > 0) {
<h6 class="sidebar-heading px-3 mt-3 mb-1 text-muted"> <h6 class="sidebar-heading px-3 text-muted">
<span i18n>Open documents</span> <span i18n>Open documents</span>
</h6> </h6>
} }
<ul class="nav flex-column mb-2"> <ul class="nav flex-column mb-2">
@for (d of openDocuments; track d) { @for (d of openDocuments; track d) {
<li class="nav-item w-100"> <li class="nav-item w-100 app-link">
<a class="nav-link" [class.text-truncate]="!slimSidebarEnabled" routerLink="documents/{{d.id}}" <a class="nav-link app-link" [class.text-truncate]="!slimSidebarEnabled" routerLink="documents/{{d.id}}"
routerLinkActive="active" (click)="closeMenu()" [ngbPopover]="d.title | documentTitle" routerLinkActive="active" (click)="closeMenu()" [ngbPopover]="d.title | documentTitle"
[disablePopover]="!slimSidebarEnabled" placement="end" container="body" triggers="mouseenter:mouseleave" [disablePopover]="!slimSidebarEnabled" placement="end" container="body" triggers="mouseenter:mouseleave"
popoverClass="popover-slim"> popoverClass="popover-slim">
@@ -151,8 +152,8 @@
</li> </li>
} }
@if (openDocuments.length >= 1) { @if (openDocuments.length >= 1) {
<li class="nav-item w-100"> <li class="nav-item w-100 app-link">
<a class="nav-link" [class.text-truncate]="!slimSidebarEnabled" [routerLink]="[]" (click)="closeAll()" <a class="nav-link app-link" [class.text-truncate]="!slimSidebarEnabled" [routerLink]="[]" (click)="closeAll()"
ngbPopover="Close all" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" ngbPopover="Close all" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim"> container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
<i-bs class="me-1" name="x"></i-bs><span>&nbsp;<ng-container i18n>Close all</ng-container></span> <i-bs class="me-1" name="x"></i-bs><span>&nbsp;<ng-container i18n>Close all</ng-container></span>
@@ -160,13 +161,14 @@
</li> </li>
} }
</ul> </ul>
</ng-container> </div>
<h6 class="sidebar-heading px-3 mt-3 mb-1 text-muted"> <div class="nav-group mt-3 mb-1">
<h6 class="sidebar-heading px-3 text-muted">
<span i18n>Manage</span> <span i18n>Manage</span>
</h6> </h6>
<ul class="nav flex-column mb-2"> <ul class="nav flex-column mb-2">
<li class="nav-item" <li class="nav-item app-link"
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Correspondent }"> *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Correspondent }">
<a class="nav-link" routerLink="correspondents" routerLinkActive="active" (click)="closeMenu()" <a class="nav-link" routerLink="correspondents" routerLinkActive="active" (click)="closeMenu()"
ngbPopover="Correspondents" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" ngbPopover="Correspondents" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
@@ -174,7 +176,7 @@
<i-bs class="me-1" name="person"></i-bs><span>&nbsp;<ng-container i18n>Correspondents</ng-container></span> <i-bs class="me-1" name="person"></i-bs><span>&nbsp;<ng-container i18n>Correspondents</ng-container></span>
</a> </a>
</li> </li>
<li class="nav-item" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Tag }" <li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Tag }"
tourAnchor="tour.tags"> tourAnchor="tour.tags">
<a class="nav-link" routerLink="tags" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Tags" <a class="nav-link" routerLink="tags" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Tags"
i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" container="body" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" container="body"
@@ -182,7 +184,7 @@
<i-bs class="me-1" name="tags"></i-bs><span>&nbsp;<ng-container i18n>Tags</ng-container></span> <i-bs class="me-1" name="tags"></i-bs><span>&nbsp;<ng-container i18n>Tags</ng-container></span>
</a> </a>
</li> </li>
<li class="nav-item" <li class="nav-item app-link"
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.DocumentType }"> *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.DocumentType }">
<a class="nav-link" routerLink="documenttypes" routerLinkActive="active" (click)="closeMenu()" <a class="nav-link" routerLink="documenttypes" routerLinkActive="active" (click)="closeMenu()"
ngbPopover="Document Types" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" ngbPopover="Document Types" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
@@ -190,21 +192,21 @@
<i-bs class="me-1" name="hash"></i-bs><span>&nbsp;<ng-container i18n>Document Types</ng-container></span> <i-bs class="me-1" name="hash"></i-bs><span>&nbsp;<ng-container i18n>Document Types</ng-container></span>
</a> </a>
</li> </li>
<li class="nav-item" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.StoragePath }"> <li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.StoragePath }">
<a class="nav-link" routerLink="storagepaths" routerLinkActive="active" (click)="closeMenu()" <a class="nav-link" routerLink="storagepaths" routerLinkActive="active" (click)="closeMenu()"
ngbPopover="Storage Paths" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" ngbPopover="Storage Paths" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim"> container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
<i-bs class="me-1" name="folder"></i-bs><span>&nbsp;<ng-container i18n>Storage Paths</ng-container></span> <i-bs class="me-1" name="folder"></i-bs><span>&nbsp;<ng-container i18n>Storage Paths</ng-container></span>
</a> </a>
</li> </li>
<li class="nav-item" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.CustomField }"> <li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.CustomField }">
<a class="nav-link" routerLink="customfields" routerLinkActive="active" (click)="closeMenu()" <a class="nav-link" routerLink="customfields" routerLinkActive="active" (click)="closeMenu()"
ngbPopover="Custom Fields" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" ngbPopover="Custom Fields" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim"> container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
<i-bs class="me-1" name="ui-radios"></i-bs><span>&nbsp;<ng-container i18n>Custom Fields</ng-container></span> <i-bs class="me-1" name="ui-radios"></i-bs><span>&nbsp;<ng-container i18n>Custom Fields</ng-container></span>
</a> </a>
</li> </li>
<li class="nav-item" <li class="nav-item app-link"
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Workflow }" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Workflow }"
tourAnchor="tour.workflows"> tourAnchor="tour.workflows">
<a class="nav-link" routerLink="workflows" routerLinkActive="active" (click)="closeMenu()" <a class="nav-link" routerLink="workflows" routerLinkActive="active" (click)="closeMenu()"
@@ -213,7 +215,7 @@
<i-bs class="me-1" name="boxes"></i-bs><span>&nbsp;<ng-container i18n>Workflows</ng-container></span> <i-bs class="me-1" name="boxes"></i-bs><span>&nbsp;<ng-container i18n>Workflows</ng-container></span>
</a> </a>
</li> </li>
<li class="nav-item" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.MailAccount }" <li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.MailAccount }"
tourAnchor="tour.mail"> tourAnchor="tour.mail">
<a class="nav-link" routerLink="mail" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Mail" <a class="nav-link" routerLink="mail" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Mail"
i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" container="body" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" container="body"
@@ -222,12 +224,14 @@
</a> </a>
</li> </li>
</ul> </ul>
</div>
<h6 class="sidebar-heading px-3 mt-auto pt-4 mb-1 text-muted"> <div class="nav-group mt-auto mb-1">
<h6 class="sidebar-heading px-3 pt-4 text-muted">
<span i18n>Administration</span> <span i18n>Administration</span>
</h6> </h6>
<ul class="nav flex-column mb-2"> <ul class="nav flex-column mb-2">
<li class="nav-item" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.UISettings }" <li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.UISettings }"
tourAnchor="tour.settings"> tourAnchor="tour.settings">
<a class="nav-link" routerLink="settings" routerLinkActive="active" (click)="closeMenu()" <a class="nav-link" routerLink="settings" routerLinkActive="active" (click)="closeMenu()"
ngbPopover="Settings" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" ngbPopover="Settings" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
@@ -235,21 +239,21 @@
<i-bs class="me-1" name="gear"></i-bs><span>&nbsp;<ng-container i18n>Settings</ng-container></span> <i-bs class="me-1" name="gear"></i-bs><span>&nbsp;<ng-container i18n>Settings</ng-container></span>
</a> </a>
</li> </li>
<li class="nav-item" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.AppConfig }"> <li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.AppConfig }">
<a class="nav-link" routerLink="config" routerLinkActive="active" (click)="closeMenu()" <a class="nav-link" routerLink="config" routerLinkActive="active" (click)="closeMenu()"
ngbPopover="Configuration" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" ngbPopover="Configuration" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim"> container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
<i-bs class="me-1" name="sliders2-vertical"></i-bs><span>&nbsp;<ng-container i18n>Configuration</ng-container></span> <i-bs class="me-1" name="sliders2-vertical"></i-bs><span>&nbsp;<ng-container i18n>Configuration</ng-container></span>
</a> </a>
</li> </li>
<li class="nav-item" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.User }"> <li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.User }">
<a class="nav-link" routerLink="usersgroups" routerLinkActive="active" (click)="closeMenu()" <a class="nav-link" routerLink="usersgroups" routerLinkActive="active" (click)="closeMenu()"
ngbPopover="Users & Groups" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" ngbPopover="Users & Groups" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim"> container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
<i-bs class="me-1" name="people"></i-bs><span>&nbsp;<ng-container i18n>Users & Groups</ng-container></span> <i-bs class="me-1" name="people"></i-bs><span>&nbsp;<ng-container i18n>Users & Groups</ng-container></span>
</a> </a>
</li> </li>
<li class="nav-item" <li class="nav-item app-link"
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.PaperlessTask }" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.PaperlessTask }"
tourAnchor="tour.file-tasks"> tourAnchor="tour.file-tasks">
<a class="nav-link" routerLink="tasks" routerLinkActive="active" (click)="closeMenu()" <a class="nav-link" routerLink="tasks" routerLinkActive="active" (click)="closeMenu()"
@@ -263,13 +267,15 @@
} }
</a> </a>
</li> </li>
<li class="nav-item" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Admin }"> @if (permissionsService.isAdmin()) {
<li class="nav-item app-link">
<a class="nav-link" routerLink="logs" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Logs" <a class="nav-link" routerLink="logs" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Logs"
i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" container="body" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" container="body"
triggers="mouseenter:mouseleave" popoverClass="popover-slim"> triggers="mouseenter:mouseleave" popoverClass="popover-slim">
<i-bs class="me-1" name="text-left"></i-bs><span>&nbsp;<ng-container i18n>Logs</ng-container></span> <i-bs class="me-1" name="text-left"></i-bs><span>&nbsp;<ng-container i18n>Logs</ng-container></span>
</a> </a>
</li> </li>
}
<li class="nav-item mt-2" tourAnchor="tour.outro"> <li class="nav-item mt-2" tourAnchor="tour.outro">
<a class="px-3 py-2 text-muted small d-flex align-items-center flex-wrap text-decoration-none" <a class="px-3 py-2 text-muted small d-flex align-items-center flex-wrap text-decoration-none"
target="_blank" rel="noopener noreferrer" href="https://docs.paperless-ngx.com" ngbPopover="Documentation" target="_blank" rel="noopener noreferrer" href="https://docs.paperless-ngx.com" ngbPopover="Documentation"
@@ -316,7 +322,7 @@
container="body"> container="body">
<i-bs width="1.2em" height="1.2em" name="info-circle"></i-bs> <i-bs width="1.2em" height="1.2em" name="info-circle"></i-bs>
@if (appRemoteVersion?.update_available) { @if (appRemoteVersion?.update_available) {
<ng-container i18n>Update available</ng-container> &nbsp;<ng-container i18n>Update available</ng-container>
} }
</a> </a>
} }
@@ -333,6 +339,7 @@
</li> </li>
</ul> </ul>
</div> </div>
</div>
</nav> </nav>
<main role="main" class="ms-sm-auto px-md-4" <main role="main" class="ms-sm-auto px-md-4"

View File

@@ -18,6 +18,10 @@
height: 0.8em; height: 0.8em;
} }
.nav-group:not(:has(.app-link)) .sidebar-heading {
display: none !important;
}
// These come from the col-* classes for non-slim sidebar, needed for animation // These come from the col-* classes for non-slim sidebar, needed for animation
@media (min-width: 768px) { @media (min-width: 768px) {
max-width: 25%; max-width: 25%;

View File

@@ -0,0 +1,39 @@
<div class="modal-header">
<h4 class="modal-title" id="modal-basic-title">{{title}}</h4>
<button type="button" class="btn-close" aria-label="Close" (click)="cancel()">
</button>
</div>
<div class="modal-body">
<p>{{message}}</p>
<div class="form-group">
<label class="form-label" for="metadataDocumentID" i18n>Documents:</label>
<ul class="list-group"
cdkDropList
(cdkDropListDropped)="onDrop($event)">
@for (documentID of documentIDs; track documentID) {
<li class="list-group-item" cdkDrag>
<i-bs name="grip-vertical" class="me-2"></i-bs>
{{getDocument(documentID)?.title}}
</li>
}
</ul>
</div>
<div class="form-group mt-4">
<label class="form-label" for="metadataDocumentID" i18n>Use metadata from:</label>
<select class="form-select" [(ngModel)]="metadataDocumentID">
<option [ngValue]="-1" i18n>Regenerate all metadata</option>
@for (document of documents; track document.id) {
<option [ngValue]="document.id">{{document.title}}</option>
}
</select>
</div>
<p class="small text-muted fst-italic mt-4" i18n>Note that only PDFs will be included.</p>
</div>
<div class="modal-footer">
<button type="button" class="btn" [class]="cancelBtnClass" (click)="cancel()" [disabled]="!buttonsEnabled">
<span class="d-inline-block" style="padding-bottom: 1px;">{{cancelBtnCaption}}</span>
</button>
<button type="button" class="btn" [class]="btnClass" (click)="confirm()" [disabled]="!confirmButtonEnabled || !buttonsEnabled">
{{btnCaption}}
</button>
</div>

View File

@@ -0,0 +1,3 @@
.list-group-item {
cursor: move;
}

View File

@@ -0,0 +1,83 @@
import { ComponentFixture, TestBed } from '@angular/core/testing'
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
import { MergeConfirmDialogComponent } from './merge-confirm-dialog.component'
import { HttpClientTestingModule } from '@angular/common/http/testing'
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
import { of } from 'rxjs'
import { DocumentService } from 'src/app/services/rest/document.service'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
describe('MergeConfirmDialogComponent', () => {
let component: MergeConfirmDialogComponent
let fixture: ComponentFixture<MergeConfirmDialogComponent>
let documentService: DocumentService
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [MergeConfirmDialogComponent],
providers: [NgbActiveModal],
imports: [
HttpClientTestingModule,
NgxBootstrapIconsModule.pick(allIcons),
ReactiveFormsModule,
FormsModule,
],
}).compileComponents()
fixture = TestBed.createComponent(MergeConfirmDialogComponent)
documentService = TestBed.inject(DocumentService)
component = fixture.componentInstance
fixture.detectChanges()
})
it('should fetch documents on ngOnInit', () => {
const documents = [
{ id: 1, name: 'Document 1' },
{ id: 2, name: 'Document 2' },
{ id: 3, name: 'Document 3' },
]
jest.spyOn(documentService, 'getFew').mockReturnValue(
of({
all: documents.map((d) => d.id),
count: documents.length,
results: documents,
})
)
component.ngOnInit()
expect(component.documents).toEqual(documents)
expect(documentService.getFew).toHaveBeenCalledWith(component.documentIDs)
})
it('should move documentIDs on drop', () => {
component.documentIDs = [1, 2, 3]
const event = {
previousIndex: 1,
currentIndex: 2,
}
component.onDrop(event as any)
expect(component.documentIDs).toEqual([1, 3, 2])
})
it('should get document by ID', () => {
const documents = [
{ id: 1, name: 'Document 1' },
{ id: 2, name: 'Document 2' },
{ id: 3, name: 'Document 3' },
]
jest.spyOn(documentService, 'getFew').mockReturnValue(
of({
all: documents.map((d) => d.id),
count: documents.length,
results: documents,
})
)
component.ngOnInit()
expect(component.getDocument(2)).toEqual({ id: 2, name: 'Document 2' })
})
})

View File

@@ -0,0 +1,51 @@
import { Component, OnInit } from '@angular/core'
import { ConfirmDialogComponent } from '../confirm-dialog.component'
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
import { DocumentService } from 'src/app/services/rest/document.service'
import { CdkDragDrop, moveItemInArray } from '@angular/cdk/drag-drop'
import { Subject, takeUntil } from 'rxjs'
import { Document } from 'src/app/data/document'
@Component({
selector: 'pngx-merge-confirm-dialog',
templateUrl: './merge-confirm-dialog.component.html',
styleUrl: './merge-confirm-dialog.component.scss',
})
export class MergeConfirmDialogComponent
extends ConfirmDialogComponent
implements OnInit
{
public documentIDs: number[] = []
private _documents: Document[] = []
get documents(): Document[] {
return this._documents
}
public metadataDocumentID: number = -1
private unsubscribeNotifier: Subject<any> = new Subject()
constructor(
activeModal: NgbActiveModal,
private documentService: DocumentService
) {
super(activeModal)
}
ngOnInit() {
this.documentService
.getFew(this.documentIDs)
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe((r) => {
this._documents = r.results
})
}
onDrop(event: CdkDragDrop<number[]>) {
moveItemInArray(this.documentIDs, event.previousIndex, event.currentIndex)
}
getDocument(documentID: number): Document {
return this.documents.find((d) => d.id === documentID)
}
}

View File

@@ -0,0 +1,48 @@
<div class="modal-header">
<h4 class="modal-title" id="modal-basic-title">{{title}}</h4>
<button type="button" class="btn-close" aria-label="Close" (click)="cancel()">
</button>
</div>
<div class="modal-body">
<div class="row">
<div class="col-2 d-flex justify-content-end">
<button class="btn btn-secondary mt-auto" (click)="rotate(false)">
<i-bs name="arrow-counterclockwise"></i-bs>
</button>
</div>
<div class="col-8 d-flex align-items-center">
@if (documentID) {
<img class="w-75 m-auto" [ngStyle]="{'transform': 'rotate('+rotation+'deg)'}" [src]="documentService.getThumbUrl(documentID)" />
}
</div>
<div class="col-2 d-flex">
<button class="btn btn-secondary mt-auto" (click)="rotate()">
<i-bs name="arrow-clockwise"></i-bs>
</button>
</div>
</div>
<div class="row mt-4">
<div class="col">
@if (messageBold) {
<p><b>{{messageBold}}</b></p>
}
@if (message) {
<p class="mb-0" [innerHTML]="message | safeHtml"></p>
}
</div>
</div>
@if (showPDFNote) {
<p class="small text-muted fst-italic mt-4" i18n>Note that only PDFs will be rotated.</p>
}
</div>
<div class="modal-footer">
<button type="button" class="btn" [class]="cancelBtnClass" (click)="cancel()" [disabled]="!buttonsEnabled">
<span class="d-inline-block" style="padding-bottom: 1px;">{{cancelBtnCaption}}</span>
</button>
<button type="button" class="btn" [class]="btnClass" (click)="confirm()" [disabled]="!confirmButtonEnabled || !buttonsEnabled || degrees === 0">
{{btnCaption}}
@if (!confirmButtonEnabled) {
<ngb-progressbar style="height: 1px;" type="dark" [max]="secondsTotal" [value]="seconds"></ngb-progressbar>
}
</button>
</div>

View File

@@ -0,0 +1,3 @@
img {
transition: all 0.25s ease;
}

View File

@@ -0,0 +1,60 @@
import { ComponentFixture, TestBed } from '@angular/core/testing'
import { RotateConfirmDialogComponent } from './rotate-confirm-dialog.component'
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
import { SafeHtmlPipe } from 'src/app/pipes/safehtml.pipe'
import { HttpClientTestingModule } from '@angular/common/http/testing'
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
describe('RotateConfirmDialogComponent', () => {
let component: RotateConfirmDialogComponent
let fixture: ComponentFixture<RotateConfirmDialogComponent>
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [RotateConfirmDialogComponent, SafeHtmlPipe],
providers: [NgbActiveModal, SafeHtmlPipe],
imports: [
HttpClientTestingModule,
NgxBootstrapIconsModule.pick(allIcons),
],
}).compileComponents()
fixture = TestBed.createComponent(RotateConfirmDialogComponent)
component = fixture.componentInstance
fixture.detectChanges()
})
it('should support rotating the image', () => {
component.documentID = 1
fixture.detectChanges()
component.rotate()
fixture.detectChanges()
expect(component.degrees).toBe(90)
expect(fixture.nativeElement.querySelector('img').style.transform).toBe(
'rotate(90deg)'
)
component.rotate()
fixture.detectChanges()
expect(fixture.nativeElement.querySelector('img').style.transform).toBe(
'rotate(180deg)'
)
})
it('should normalize degrees', () => {
expect(component.degrees).toBe(0)
component.rotate()
expect(component.degrees).toBe(90)
component.rotate()
expect(component.degrees).toBe(180)
component.rotate()
expect(component.degrees).toBe(270)
component.rotate()
expect(component.degrees).toBe(0)
component.rotate()
expect(component.degrees).toBe(90)
component.rotate(false)
expect(component.degrees).toBe(0)
component.rotate(false)
expect(component.degrees).toBe(270)
})
})

View File

@@ -0,0 +1,34 @@
import { Component } from '@angular/core'
import { ConfirmDialogComponent } from '../confirm-dialog.component'
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
import { DocumentService } from 'src/app/services/rest/document.service'
@Component({
selector: 'pngx-rotate-confirm-dialog',
templateUrl: './rotate-confirm-dialog.component.html',
styleUrl: './rotate-confirm-dialog.component.scss',
})
export class RotateConfirmDialogComponent extends ConfirmDialogComponent {
public documentID: number
public showPDFNote: boolean = true
// animation is better if we dont normalize yet
public rotation: number = 0
public get degrees(): number {
let degrees = this.rotation % 360
if (degrees < 0) degrees += 360
return degrees
}
constructor(
activeModal: NgbActiveModal,
public documentService: DocumentService
) {
super(activeModal)
}
rotate(clockwise: boolean = true) {
this.rotation += clockwise ? 90 : -90
}
}

View File

@@ -0,0 +1,55 @@
<div class="modal-header">
<h4 class="modal-title" id="modal-basic-title">{{title}}</h4>
<button type="button" class="btn-close" aria-label="Close" (click)="cancel()">
</button>
</div>
<div class="modal-body">
<p>{{message}}</p>
<div class="row mb-2">
<div class="col-8">
<div class="input-group input-group-sm">
<div class="input-group-text" i18n>Page</div>
<input class="form-control" type="number" min="1" [(ngModel)]="page" />
<div class="input-group-text" i18n>of {{totalPages}}</div>
</div>
<div class="pdf-viewer-container w-100 mt-3">
<pngx-pdf-viewer [src]="pdfSrc" [(page)]="page"
[original-size]="false"
[zoom]="1"
zoom-scale="page-fit"
(after-load-complete)="pdfPreviewLoaded($event)">
</pngx-pdf-viewer>
</div>
</div>
<div class="col-4">
<div class="d-grid">
<button class="btn btn-sm btn-primary" (click)="addSplit()" [disabled]="page === totalPages">
<i-bs name="plus-circle"></i-bs>&nbsp;
<span i18n>Add Split</span>
</button>
</div>
<ul class="list-group mt-3">
@for (pageStr of pagesString.split(','); track pageStr; let i = $index) {
<li class="list-group-item d-flex align-items-center">
{{pageStr}}
@if (pagesString.split(',').length > 1) {
&nbsp;
<button class="btn btn-sm btn-danger ms-auto" (click)="removeSplit(i)">
<i-bs name="trash"></i-bs>
</button>
}
</li>
}
</ul>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn" [class]="cancelBtnClass" (click)="cancel()" [disabled]="!buttonsEnabled">
<span class="d-inline-block" style="padding-bottom: 1px;">{{cancelBtnCaption}}</span>
</button>
<button type="button" class="btn" [class]="btnClass" (click)="confirm()" [disabled]="!confirmButtonEnabled || !buttonsEnabled">
{{btnCaption}}
</button>
</div>

View File

@@ -0,0 +1,9 @@
.pdf-viewer-container {
background-color: gray;
height: 350px;
pngx-pdf-viewer {
width: 100%;
height: 100%;
}
}

View File

@@ -0,0 +1,81 @@
import { ComponentFixture, TestBed } from '@angular/core/testing'
import { SplitConfirmDialogComponent } from './split-confirm-dialog.component'
import { HttpClientTestingModule } from '@angular/common/http/testing'
import { ReactiveFormsModule, FormsModule } from '@angular/forms'
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
import { DocumentService } from 'src/app/services/rest/document.service'
import { PdfViewerComponent } from '../../pdf-viewer/pdf-viewer.component'
describe('SplitConfirmDialogComponent', () => {
let component: SplitConfirmDialogComponent
let fixture: ComponentFixture<SplitConfirmDialogComponent>
let documentService: DocumentService
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [SplitConfirmDialogComponent, PdfViewerComponent],
providers: [NgbActiveModal],
imports: [
HttpClientTestingModule,
NgxBootstrapIconsModule.pick(allIcons),
ReactiveFormsModule,
FormsModule,
],
}).compileComponents()
fixture = TestBed.createComponent(SplitConfirmDialogComponent)
documentService = TestBed.inject(DocumentService)
component = fixture.componentInstance
fixture.detectChanges()
})
it('should update pagesString when pages are added', () => {
component.totalPages = 5
component.page = 2
component.addSplit()
expect(component.pagesString).toEqual('1-2,3-5')
component.page = 4
component.addSplit()
expect(component.pagesString).toEqual('1-2,3-4,5')
})
it('should update pagesString when pages are removed', () => {
component.totalPages = 5
component.page = 2
component.addSplit()
component.page = 4
component.addSplit()
expect(component.pagesString).toEqual('1-2,3-4,5')
component.removeSplit(0)
expect(component.pagesString).toEqual('1-4,5')
})
it('should enable confirm button when pages are added', () => {
component.totalPages = 5
component.page = 2
component.addSplit()
expect(component.confirmButtonEnabled).toBeTruthy()
})
it('should disable confirm button when all pages are removed', () => {
component.totalPages = 5
component.page = 2
component.addSplit()
component.removeSplit(0)
expect(component.confirmButtonEnabled).toBeFalsy()
})
it('should not add split if page is the last page', () => {
component.totalPages = 5
component.page = 5
component.addSplit()
expect(component.pagesString).toEqual('1-5')
})
it('should update totalPages when pdf is loaded', () => {
component.pdfPreviewLoaded({ numPages: 5 } as any)
expect(component.totalPages).toEqual(5)
})
})

View File

@@ -0,0 +1,66 @@
import { Component } from '@angular/core'
import { ConfirmDialogComponent } from '../confirm-dialog.component'
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
import { DocumentService } from 'src/app/services/rest/document.service'
import { PDFDocumentProxy } from '../../pdf-viewer/typings'
@Component({
selector: 'pngx-split-confirm-dialog',
templateUrl: './split-confirm-dialog.component.html',
styleUrl: './split-confirm-dialog.component.scss',
})
export class SplitConfirmDialogComponent extends ConfirmDialogComponent {
public get pagesString(): string {
let pagesStr = ''
let lastPage = 1
for (let i = 1; i <= this.totalPages; i++) {
if (this.pages.has(i) || i === this.totalPages) {
if (lastPage === i) {
pagesStr += `${i},`
lastPage = Math.min(i + 1, this.totalPages)
} else {
pagesStr += `${lastPage}-${i},`
lastPage = Math.min(i + 1, this.totalPages)
}
}
}
return pagesStr.replace(/,$/, '')
}
private pages: Set<number> = new Set()
public documentID: number
public page: number = 1
public totalPages: number
public get pdfSrc(): string {
return this.documentService.getPreviewUrl(this.documentID)
}
constructor(
activeModal: NgbActiveModal,
private documentService: DocumentService
) {
super(activeModal)
this.confirmButtonEnabled = this.pages.size > 0
}
pdfPreviewLoaded(pdf: PDFDocumentProxy) {
this.totalPages = pdf.numPages
}
addSplit() {
if (this.page === this.totalPages) return
this.pages.add(this.page)
this.pages = new Set(Array.from(this.pages).sort())
this.confirmButtonEnabled = this.pages.size > 0
}
removeSplit(i: number) {
let page = Array.from(this.pages)[Math.min(i, this.pages.size - 1)]
this.pages.delete(page)
this.confirmButtonEnabled = this.pages.size > 0
}
}

View File

@@ -0,0 +1,25 @@
@if (field) {
@switch (field.data_type) {
@case (CustomFieldDataType.Monetary) {
<span>{{value | currency: currency}}</span>
}
@case (CustomFieldDataType.Date) {
<span>{{value | customDate}}</span>
}
@case (CustomFieldDataType.Url) {
<a [href]="value" class="btn-link text-dark text-decoration-none" target="_blank">{{value}}</a>
}
@case (CustomFieldDataType.DocumentLink) {
<div class="d-flex gap-1 flex-wrap">
@for (docId of value; track docId) {
<a routerLink="/documents/{{docId}}" class="badge bg-dark text-primary" title="View" i18n-title>
<i-bs width="0.9em" height="0.9em" name="file-text"></i-bs>&nbsp;<span>{{ getDocumentTitle(docId) }}</span>
</a>
}
</div>
}
@default {
<span>{{value}}</span>
}
}
}

View File

@@ -0,0 +1,89 @@
import { ComponentFixture, TestBed } from '@angular/core/testing'
import { of } from 'rxjs'
import { CustomField, CustomFieldDataType } from 'src/app/data/custom-field'
import { DocumentService } from 'src/app/services/rest/document.service'
import { CustomFieldDisplayComponent } from './custom-field-display.component'
import { DisplayField, Document } from 'src/app/data/document'
import { HttpClientTestingModule } from '@angular/common/http/testing'
import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
const customFields: CustomField[] = [
{ id: 1, name: 'Field 1', data_type: CustomFieldDataType.String },
{ id: 2, name: 'Field 2', data_type: CustomFieldDataType.Monetary },
{ id: 3, name: 'Field 3', data_type: CustomFieldDataType.DocumentLink },
]
const document: Document = {
id: 1,
title: 'Doc 1',
custom_fields: [
{ field: 1, document: 1, created: null, value: 'Text value' },
{ field: 2, document: 1, created: null, value: '100 USD' },
{ field: 3, document: 1, created: null, value: '1,2,3' },
],
}
describe('CustomFieldDisplayComponent', () => {
let component: CustomFieldDisplayComponent
let fixture: ComponentFixture<CustomFieldDisplayComponent>
let documentService: DocumentService
let customFieldService: CustomFieldsService
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [CustomFieldDisplayComponent],
providers: [DocumentService],
imports: [HttpClientTestingModule],
}).compileComponents()
})
beforeEach(() => {
documentService = TestBed.inject(DocumentService)
customFieldService = TestBed.inject(CustomFieldsService)
jest
.spyOn(customFieldService, 'listAll')
.mockReturnValue(of({ results: customFields } as any))
fixture = TestBed.createComponent(CustomFieldDisplayComponent)
component = fixture.componentInstance
fixture.detectChanges()
})
it('should create', () => {
expect(component).toBeTruthy()
})
it('should initialize component', () => {
jest
.spyOn(documentService, 'getFew')
.mockReturnValue(of({ results: [] } as any))
component.fieldDisplayKey = DisplayField.CUSTOM_FIELD + '2'
expect(component.fieldId).toEqual(2)
component.document = document
expect(component.document.title).toEqual('Doc 1')
expect(component.field).toEqual(customFields[1])
expect(component.value).toEqual(100)
expect(component.currency).toEqual('USD')
})
it('should get document titles', () => {
const docLinkDocuments: Document[] = [
{ id: 1, title: 'Document 1' } as any,
{ id: 2, title: 'Document 2' } as any,
{ id: 3, title: 'Document 3' } as any,
]
jest
.spyOn(documentService, 'getFew')
.mockReturnValue(of({ results: docLinkDocuments } as any))
component.fieldId = 3
component.document = document
const title1 = component.getDocumentTitle(1)
const title2 = component.getDocumentTitle(2)
const title3 = component.getDocumentTitle(3)
expect(title1).toEqual('Document 1')
expect(title2).toEqual('Document 2')
expect(title3).toEqual('Document 3')
})
})

View File

@@ -0,0 +1,105 @@
import { Component, Input, OnDestroy, OnInit } from '@angular/core'
import { Subject, takeUntil } from 'rxjs'
import { CustomField, CustomFieldDataType } from 'src/app/data/custom-field'
import { DisplayField, Document } from 'src/app/data/document'
import { Results } from 'src/app/data/results'
import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
import { DocumentService } from 'src/app/services/rest/document.service'
@Component({
selector: 'pngx-custom-field-display',
templateUrl: './custom-field-display.component.html',
styleUrl: './custom-field-display.component.scss',
})
export class CustomFieldDisplayComponent implements OnInit, OnDestroy {
CustomFieldDataType = CustomFieldDataType
private _document: Document
@Input()
set document(document: Document) {
this._document = document
this.init()
}
get document(): Document {
return this._document
}
private _fieldId: number
@Input()
set fieldId(id: number) {
this._fieldId = id
this.init()
}
get fieldId(): number {
return this._fieldId
}
@Input()
set fieldDisplayKey(key: string) {
this.fieldId = parseInt(key.replace(DisplayField.CUSTOM_FIELD, ''), 10)
}
value: any
currency: string
private customFields: CustomField[] = []
public field: CustomField
private docLinkDocuments: Document[] = []
private unsubscribeNotifier: Subject<any> = new Subject()
constructor(
private customFieldService: CustomFieldsService,
private documentService: DocumentService
) {
this.customFieldService.listAll().subscribe((r) => {
this.customFields = r.results
this.init()
})
}
ngOnInit(): void {
this.init()
}
private init() {
if (this.value || !this._fieldId || !this._document || !this.customFields) {
return
}
this.field = this.customFields.find((f) => f.id === this._fieldId)
this.value = this._document.custom_fields.find(
(f) => f.field === this._fieldId
)?.value
if (this.value && this.field.data_type === CustomFieldDataType.Monetary) {
this.currency = this.value.match(/([A-Z]{3})/)?.[0]
this.value = parseFloat(this.value.replace(this.currency, ''))
} else if (
this.value?.length &&
this.field.data_type === CustomFieldDataType.DocumentLink
) {
this.getDocuments()
}
}
private getDocuments() {
this.documentService
.getFew(this.value, { fields: 'id,title' })
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe((result: Results<Document>) => {
this.docLinkDocuments = result.results
})
}
public getDocumentTitle(docId: number): string {
return this.docLinkDocuments?.find((d) => d.id === docId)?.title
}
ngOnDestroy(): void {
this.unsubscribeNotifier.next(true)
this.unsubscribeNotifier.complete()
}
}

View File

@@ -6,7 +6,7 @@
<div ngbDropdownMenu aria-labelledby="customFieldsDropdown" class="shadow custom-fields-dropdown"> <div ngbDropdownMenu aria-labelledby="customFieldsDropdown" class="shadow custom-fields-dropdown">
<ul class="list-group list-group-flush"> <ul class="list-group list-group-flush">
<li class="list-group-item"> <li class="list-group-item">
<pngx-input-select class="mb-3" <pngx-input-select
[items]="unusedFields" [items]="unusedFields"
bindLabel="name" bindLabel="name"
[(ngModel)]="field" [(ngModel)]="field"
@@ -14,14 +14,15 @@
[notFoundText]="notFoundText" [notFoundText]="notFoundText"
[disableCreateNew]="!canCreateFields" [disableCreateNew]="!canCreateFields"
(createNew)="createField($event)" (createNew)="createField($event)"
[hideAddButton]="true"
bindValue="id"> bindValue="id">
</pngx-input-select> </pngx-input-select>
<div class="btn-toolbar" role="toolbar"> <div class="btn-toolbar" role="toolbar">
<button class="btn btn-sm btn-outline-secondary me-auto" type="button" (click)="createField()" [disabled]="!canCreateFields"> <button class="btn btn-sm btn-outline-secondary me-auto" type="button" (click)="createField()" [disabled]="!canCreateFields">
<i-bs width="1em" height="1em" name="asterisk"></i-bs>&nbsp;<ng-container i18n>Create New Field</ng-container> <i-bs width="1em" height="1em" name="asterisk"></i-bs>&nbsp;<ng-container i18n>Create New Field</ng-container>
</button> </button>
<button class="btn btn-sm btn-outline-primary me-1" type="button" (click)="addField(); fieldDropdown.close()" [disabled]="field === undefined"> <button class="btn btn-sm btn-outline-primary" type="button" (click)="addField(); fieldDropdown.close()" [disabled]="field === undefined">
<i-bs width="1.2em" height="1.2em" name="plus-circle"></i-bs>&nbsp;<ng-container i18n>Add</ng-container> <i-bs width="1.2em" height="1.2em" name="plus-circle"></i-bs>&nbsp;<ng-container i18n>Add to document</ng-container>
</button> </button>
</div> </div>
</li> </li>

View File

@@ -1,5 +1,5 @@
.custom-fields-dropdown { .custom-fields-dropdown {
min-width: 350px; min-width: 380px;
// correct position on mobile // correct position on mobile
@media (max-width: 575.98px) { @media (max-width: 575.98px) {
@@ -9,16 +9,12 @@
} }
} }
::ng-deep .ng-select .ng-select-container .ng-value-container .ng-placeholder, ::ng-deep .custom-fields-dropdown .ng-select .ng-select-container .ng-value-container .ng-placeholder,
::ng-deep .ng-select .ng-option, ::ng-deep .custom-fields-dropdown .ng-select .ng-option,
::ng-deep .ng-select .ng-select-container .ng-value-container .ng-value { ::ng-deep .custom-fields-dropdown .ng-select .ng-select-container .ng-value-container .ng-value {
font-size: 0.875rem; font-size: 0.875rem;
} }
::ng-deep .paperless-input-select .ng-select { ::ng-deep .custom-fields-dropdown .paperless-input-select .ng-select .ng-select-container .ng-value-container .ng-input {
min-height: calc(1em + 0.75rem + 5px);
}
::ng-deep .paperless-input-select .ng-select .ng-select-container .ng-value-container .ng-input {
top: 4px; top: 4px;
} }

View File

@@ -1,10 +1,7 @@
import { ComponentFixture, TestBed } from '@angular/core/testing' import { ComponentFixture, TestBed } from '@angular/core/testing'
import { CustomFieldsDropdownComponent } from './custom-fields-dropdown.component' import { CustomFieldsDropdownComponent } from './custom-fields-dropdown.component'
import { import { HttpClientTestingModule } from '@angular/common/http/testing'
HttpClientTestingModule,
HttpTestingController,
} from '@angular/common/http/testing'
import { ToastService } from 'src/app/services/toast.service' import { ToastService } from 'src/app/services/toast.service'
import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service' import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
import { of } from 'rxjs' import { of } from 'rxjs'

View File

@@ -1,71 +0,0 @@
<div class="btn-group w-100" ngbDropdown role="group">
<button class="btn btn-sm" id="dropdown{{title}}" ngbDropdownToggle [ngClass]="dateBefore || dateAfter ? 'btn-primary' : 'btn-outline-primary'" [disabled]="disabled">
{{title}}
<pngx-clearable-badge [selected]="isActive" (cleared)="reset()"></pngx-clearable-badge><span class="visually-hidden">selected</span>
</button>
<div class="dropdown-menu date-dropdown shadow pt-0" ngbDropdownMenu attr.aria-labelledby="dropdown{{title}}">
<div class="list-group list-group-flush">
@for (rd of relativeDates; track rd) {
<button class="list-group-item small list-goup list-group-item-action d-flex p-2" role="menuitem" (click)="setRelativeDate(rd.id)">
<div class="selected-icon">
@if (relativeDate === rd.id) {
<i-bs width="1em" height="1em" name="check"></i-bs>
}
</div>
<div class="d-flex justify-content-between w-100 align-items-center ps-2">
<div class="pe-2 pe-lg-4">
{{rd.name}}
</div>
<div class="text-muted small pe-2">
<span class="small">
{{ rd.date | customDate:'mediumDate' }} &ndash; <ng-container i18n>now</ng-container>
</span>
</div>
</div>
</button>
}
<div class="list-group-item d-flex flex-column align-items-start" role="menuitem">
<div class="mb-2 d-flex flex-row w-100 justify-content-between small">
<div i18n>After</div>
@if (dateAfter) {
<a class="btn btn-link p-0 m-0" (click)="clearAfter()">
<i-bs width="1em" height="1em" name="x"></i-bs>
<small i18n>Clear</small>
</a>
}
</div>
<div class="input-group input-group-sm">
<input class="form-control" [placeholder]="datePlaceHolder" (dateSelect)="onChangeDebounce()" (change)="onChangeDebounce()" (keypress)="onKeyPress($event)"
maxlength="10" [(ngModel)]="dateAfter" ngbDatepicker #dateAfterPicker="ngbDatepicker">
<button class="btn btn-outline-secondary" (click)="dateAfterPicker.toggle()" type="button">
<i-bs width="1em" height="1em" name="calendar"></i-bs>
</button>
</div>
</div>
<div class="list-group-item d-flex flex-column align-items-start" role="menuitem">
<div class="mb-2 d-flex flex-row w-100 justify-content-between small">
<div i18n>Before</div>
@if (dateBefore) {
<a class="btn btn-link p-0 m-0" (click)="clearBefore()">
<i-bs width="1em" height="1em" name="x"></i-bs>
<small i18n>Clear</small>
</a>
}
</div>
<div class="input-group input-group-sm">
<input class="form-control" [placeholder]="datePlaceHolder" (dateSelect)="onChangeDebounce()" (change)="onChangeDebounce()" (keypress)="onKeyPress($event)"
maxlength="10" [(ngModel)]="dateBefore" ngbDatepicker #dateBeforePicker="ngbDatepicker">
<button class="btn btn-outline-secondary" (click)="dateBeforePicker.toggle()" type="button">
<i-bs width="1em" height="1em" name="calendar"></i-bs>
</button>
</div>
</div>
</div>
</div>
</div>

View File

@@ -1,164 +0,0 @@
import {
Component,
EventEmitter,
Input,
Output,
OnInit,
OnDestroy,
} from '@angular/core'
import { NgbDateAdapter } from '@ng-bootstrap/ng-bootstrap'
import { Subject, Subscription } from 'rxjs'
import { debounceTime } from 'rxjs/operators'
import { SettingsService } from 'src/app/services/settings.service'
import { ISODateAdapter } from 'src/app/utils/ngb-iso-date-adapter'
export interface DateSelection {
before?: string
after?: string
relativeDateID?: number
}
export enum RelativeDate {
LAST_7_DAYS = 0,
LAST_MONTH = 1,
LAST_3_MONTHS = 2,
LAST_YEAR = 3,
}
@Component({
selector: 'pngx-date-dropdown',
templateUrl: './date-dropdown.component.html',
styleUrls: ['./date-dropdown.component.scss'],
providers: [{ provide: NgbDateAdapter, useClass: ISODateAdapter }],
})
export class DateDropdownComponent implements OnInit, OnDestroy {
constructor(settings: SettingsService) {
this.datePlaceHolder = settings.getLocalizedDateInputFormat()
}
relativeDates = [
{
id: RelativeDate.LAST_7_DAYS,
name: $localize`Last 7 days`,
date: new Date().setDate(new Date().getDate() - 7),
},
{
id: RelativeDate.LAST_MONTH,
name: $localize`Last month`,
date: new Date().setMonth(new Date().getMonth() - 1),
},
{
id: RelativeDate.LAST_3_MONTHS,
name: $localize`Last 3 months`,
date: new Date().setMonth(new Date().getMonth() - 3),
},
{
id: RelativeDate.LAST_YEAR,
name: $localize`Last year`,
date: new Date().setFullYear(new Date().getFullYear() - 1),
},
]
datePlaceHolder: string
@Input()
dateBefore: string
@Output()
dateBeforeChange = new EventEmitter<string>()
@Input()
dateAfter: string
@Output()
dateAfterChange = new EventEmitter<string>()
@Input()
relativeDate: RelativeDate
@Output()
relativeDateChange = new EventEmitter<number>()
@Input()
title: string
@Output()
datesSet = new EventEmitter<DateSelection>()
@Input()
disabled: boolean = false
get isActive(): boolean {
return (
this.relativeDate !== null ||
this.dateAfter?.length > 0 ||
this.dateBefore?.length > 0
)
}
private datesSetDebounce$ = new Subject()
private sub: Subscription
ngOnInit() {
this.sub = this.datesSetDebounce$.pipe(debounceTime(400)).subscribe(() => {
this.onChange()
})
}
ngOnDestroy() {
if (this.sub) {
this.sub.unsubscribe()
}
}
reset() {
this.dateBefore = null
this.dateAfter = null
this.relativeDate = null
this.onChange()
}
setRelativeDate(rd: RelativeDate) {
this.dateBefore = null
this.dateAfter = null
this.relativeDate = this.relativeDate == rd ? null : rd
this.onChange()
}
onChange() {
this.dateBeforeChange.emit(this.dateBefore)
this.dateAfterChange.emit(this.dateAfter)
this.relativeDateChange.emit(this.relativeDate)
this.datesSet.emit({
after: this.dateAfter,
before: this.dateBefore,
relativeDateID: this.relativeDate,
})
}
onChangeDebounce() {
this.relativeDate = null
this.datesSetDebounce$.next({
after: this.dateAfter,
before: this.dateBefore,
})
}
clearBefore() {
this.dateBefore = null
this.onChange()
}
clearAfter() {
this.dateAfter = null
this.onChange()
}
// prevent chars other than numbers and separators
onKeyPress(event: KeyboardEvent) {
if ('Enter' !== event.key && !/[0-9,\.\/-]+/.test(event.key)) {
event.preventDefault()
}
}
}

View File

@@ -0,0 +1,143 @@
<div class="btn-group w-100" ngbDropdown role="group">
<button class="btn btn-sm" id="dropdown{{title}}" ngbDropdownToggle [ngClass]="createdDateBefore || createdDateAfter ? 'btn-primary' : 'btn-outline-primary'" [disabled]="disabled">
<i-bs width="1em" height="1em" name="calendar-event-fill"></i-bs>
<div class="d-none d-sm-inline">&nbsp;{{title}}</div>
<pngx-clearable-badge [selected]="isActive" (cleared)="reset()"></pngx-clearable-badge><span class="visually-hidden">selected</span>
</button>
<div class="dropdown-menu date-dropdown shadow p-2" ngbDropdownMenu attr.aria-labelledby="dropdown{{title}}">
<div class="row d-flex">
<div class="col border-end">
<div class="list-group list-group-flush">
<h6 class="dropdown-header border-bottom" i18n>Created</h6>
@for (rd of relativeDates; track rd) {
<button class="list-group-item small list-goup list-group-item-action d-flex p-2" role="menuitem" (click)="setCreatedRelativeDate(rd.id)">
<div class="selected-icon">
@if (createdRelativeDate === rd.id) {
<i-bs width="1em" height="1em" name="check"></i-bs>
}
</div>
<div class="d-flex justify-content-between w-100 align-items-center ps-2">
<div class="pe-2 pe-lg-4">
{{rd.name}}
</div>
<div class="text-muted small pe-2">
<span class="small">
{{ rd.date | customDate:'mediumDate' }} &ndash; <ng-container i18n>now</ng-container>
</span>
</div>
</div>
</button>
}
<div class="list-group-item d-flex flex-column align-items-start" role="menuitem">
<div class="mb-2 d-flex flex-row w-100 justify-content-between small">
<div i18n>After</div>
@if (createdDateAfter) {
<a class="btn btn-link p-0 m-0" (click)="clearCreatedAfter()">
<i-bs width="1em" height="1em" name="x"></i-bs>
<small i18n>Clear</small>
</a>
}
</div>
<div class="input-group input-group-sm">
<input class="form-control" [placeholder]="datePlaceHolder" (dateSelect)="onChangeDebounce()" (change)="onChangeDebounce()" (keypress)="onKeyPress($event)"
maxlength="10" [(ngModel)]="createdDateAfter" ngbDatepicker #createdDateAfterPicker="ngbDatepicker">
<button class="btn btn-outline-secondary" (click)="createdDateAfterPicker.toggle()" type="button">
<i-bs width="1em" height="1em" name="calendar"></i-bs>
</button>
</div>
</div>
<div class="list-group-item d-flex flex-column align-items-start" role="menuitem">
<div class="mb-2 d-flex flex-row w-100 justify-content-between small">
<div i18n>Before</div>
@if (createdDateBefore) {
<a class="btn btn-link p-0 m-0" (click)="clearCreatedBefore()">
<i-bs width="1em" height="1em" name="x"></i-bs>
<small i18n>Clear</small>
</a>
}
</div>
<div class="input-group input-group-sm">
<input class="form-control" [placeholder]="datePlaceHolder" (dateSelect)="onChangeDebounce()" (change)="onChangeDebounce()" (keypress)="onKeyPress($event)"
maxlength="10" [(ngModel)]="createdDateBefore" ngbDatepicker #createdDateBeforePicker="ngbDatepicker">
<button class="btn btn-outline-secondary" (click)="createdDateBeforePicker.toggle()" type="button">
<i-bs width="1em" height="1em" name="calendar"></i-bs>
</button>
</div>
</div>
</div>
</div>
<div class="col">
<h6 class="dropdown-header border-bottom" i18n>Added</h6>
<div class="list-group list-group-flush">
@for (rd of relativeDates; track rd) {
<button class="list-group-item small list-goup list-group-item-action d-flex p-2" role="menuitem" (click)="setAddedRelativeDate(rd.id)">
<div class="selected-icon">
@if (addedRelativeDate === rd.id) {
<i-bs width="1em" height="1em" name="check"></i-bs>
}
</div>
<div class="d-flex justify-content-between w-100 align-items-center ps-2">
<div class="pe-2 pe-lg-4">
{{rd.name}}
</div>
<div class="text-muted small pe-2">
<span class="small">
{{ rd.date | customDate:'mediumDate' }} &ndash; <ng-container i18n>now</ng-container>
</span>
</div>
</div>
</button>
}
<div class="list-group-item d-flex flex-column align-items-start" role="menuitem">
<div class="mb-2 d-flex flex-row w-100 justify-content-between small">
<div i18n>After</div>
@if (addedDateAfter) {
<a class="btn btn-link p-0 m-0" (click)="clearAddedAfter()">
<i-bs width="1em" height="1em" name="x"></i-bs>
<small i18n>Clear</small>
</a>
}
</div>
<div class="input-group input-group-sm">
<input class="form-control" [placeholder]="datePlaceHolder" (dateSelect)="onChangeDebounce()" (change)="onChangeDebounce()" (keypress)="onKeyPress($event)"
maxlength="10" [(ngModel)]="addedDateAfter" ngbDatepicker #addedDateAfterPicker="ngbDatepicker">
<button class="btn btn-outline-secondary" (click)="addedDateAfterPicker.toggle()" type="button">
<i-bs width="1em" height="1em" name="calendar"></i-bs>
</button>
</div>
</div>
<div class="list-group-item d-flex flex-column align-items-start" role="menuitem">
<div class="mb-2 d-flex flex-row w-100 justify-content-between small">
<div i18n>Before</div>
@if (addedDateBefore) {
<a class="btn btn-link p-0 m-0" (click)="clearAddedBefore()">
<i-bs width="1em" height="1em" name="x"></i-bs>
<small i18n>Clear</small>
</a>
}
</div>
<div class="input-group input-group-sm">
<input class="form-control" [placeholder]="datePlaceHolder" (dateSelect)="onChangeDebounce()" (change)="onChangeDebounce()" (keypress)="onKeyPress($event)"
maxlength="10" [(ngModel)]="addedDateBefore" ngbDatepicker #addedDateBeforePicker="ngbDatepicker">
<button class="btn btn-outline-secondary" (click)="addedDateBeforePicker.toggle()" type="button">
<i-bs width="1em" height="1em" name="calendar"></i-bs>
</button>
</div>
</div>
</div>
</div>
</div>
</div>
</div>

View File

@@ -1,6 +1,10 @@
.date-dropdown { .date-dropdown {
white-space: nowrap; white-space: nowrap;
@media(min-width: 768px) {
--bs-dropdown-min-width: 40rem;
}
.btn-link { .btn-link {
line-height: 1; line-height: 1;
} }

View File

@@ -4,12 +4,12 @@ import {
fakeAsync, fakeAsync,
tick, tick,
} from '@angular/core/testing' } from '@angular/core/testing'
let fixture: ComponentFixture<DateDropdownComponent> let fixture: ComponentFixture<DatesDropdownComponent>
import { import {
DateDropdownComponent, DatesDropdownComponent,
DateSelection, DateSelection,
RelativeDate, RelativeDate,
} from './date-dropdown.component' } from './dates-dropdown.component'
import { HttpClientTestingModule } from '@angular/common/http/testing' import { HttpClientTestingModule } from '@angular/common/http/testing'
import { NgbModule } from '@ng-bootstrap/ng-bootstrap' import { NgbModule } from '@ng-bootstrap/ng-bootstrap'
import { SettingsService } from 'src/app/services/settings.service' import { SettingsService } from 'src/app/services/settings.service'
@@ -19,15 +19,15 @@ import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
import { DatePipe } from '@angular/common' import { DatePipe } from '@angular/common'
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons' import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
describe('DateDropdownComponent', () => { describe('DatesDropdownComponent', () => {
let component: DateDropdownComponent let component: DatesDropdownComponent
let settingsService: SettingsService let settingsService: SettingsService
let settingsSpy let settingsSpy
beforeEach(async () => { beforeEach(async () => {
TestBed.configureTestingModule({ TestBed.configureTestingModule({
declarations: [ declarations: [
DateDropdownComponent, DatesDropdownComponent,
ClearableBadgeComponent, ClearableBadgeComponent,
CustomDatePipe, CustomDatePipe,
], ],
@@ -44,7 +44,7 @@ describe('DateDropdownComponent', () => {
settingsService = TestBed.inject(SettingsService) settingsService = TestBed.inject(SettingsService)
settingsSpy = jest.spyOn(settingsService, 'getLocalizedDateInputFormat') settingsSpy = jest.spyOn(settingsService, 'getLocalizedDateInputFormat')
fixture = TestBed.createComponent(DateDropdownComponent) fixture = TestBed.createComponent(DatesDropdownComponent)
component = fixture.componentInstance component = fixture.componentInstance
fixture.detectChanges() fixture.detectChanges()
@@ -57,7 +57,7 @@ describe('DateDropdownComponent', () => {
it('should support date input, emit change', fakeAsync(() => { it('should support date input, emit change', fakeAsync(() => {
let result: string let result: string
component.dateAfterChange.subscribe((date) => (result = date)) component.createdDateAfterChange.subscribe((date) => (result = date))
const input: HTMLInputElement = fixture.nativeElement.querySelector('input') const input: HTMLInputElement = fixture.nativeElement.querySelector('input')
input.value = '5/30/2023' input.value = '5/30/2023'
input.dispatchEvent(new Event('change')) input.dispatchEvent(new Event('change'))
@@ -78,45 +78,69 @@ describe('DateDropdownComponent', () => {
it('should support relative dates', fakeAsync(() => { it('should support relative dates', fakeAsync(() => {
let result: DateSelection let result: DateSelection
component.datesSet.subscribe((date) => (result = date)) component.datesSet.subscribe((date) => (result = date))
component.setRelativeDate(null) component.setCreatedRelativeDate(null)
component.setRelativeDate(RelativeDate.LAST_7_DAYS) component.setCreatedRelativeDate(RelativeDate.LAST_7_DAYS)
component.setAddedRelativeDate(null)
component.setAddedRelativeDate(RelativeDate.LAST_7_DAYS)
tick(500) tick(500)
expect(result).toEqual({ expect(result).toEqual({
after: null, createdAfter: null,
before: null, createdBefore: null,
relativeDateID: RelativeDate.LAST_7_DAYS, createdRelativeDateID: RelativeDate.LAST_7_DAYS,
addedAfter: null,
addedBefore: null,
addedRelativeDateID: RelativeDate.LAST_7_DAYS,
}) })
})) }))
it('should support report if active', () => { it('should support report if active', () => {
component.relativeDate = RelativeDate.LAST_7_DAYS component.createdRelativeDate = RelativeDate.LAST_7_DAYS
expect(component.isActive).toBeTruthy() expect(component.isActive).toBeTruthy()
component.relativeDate = null component.createdRelativeDate = null
component.dateAfter = '2023-05-30' component.createdDateAfter = '2023-05-30'
expect(component.isActive).toBeTruthy() expect(component.isActive).toBeTruthy()
component.dateAfter = null component.createdDateAfter = null
component.dateBefore = '2023-05-30' component.createdDateBefore = '2023-05-30'
expect(component.isActive).toBeTruthy() expect(component.isActive).toBeTruthy()
component.dateBefore = null component.createdDateBefore = null
component.addedRelativeDate = RelativeDate.LAST_7_DAYS
expect(component.isActive).toBeTruthy()
component.addedRelativeDate = null
component.addedDateAfter = '2023-05-30'
expect(component.isActive).toBeTruthy()
component.addedDateAfter = null
component.addedDateBefore = '2023-05-30'
expect(component.isActive).toBeTruthy()
component.addedDateBefore = null
expect(component.isActive).toBeFalsy() expect(component.isActive).toBeFalsy()
}) })
it('should support reset', () => { it('should support reset', () => {
component.dateAfter = '2023-05-30' component.createdDateAfter = '2023-05-30'
component.reset() component.reset()
expect(component.dateAfter).toBeNull() expect(component.createdDateAfter).toBeNull()
}) })
it('should support clearAfter', () => { it('should support clearAfter', () => {
component.dateAfter = '2023-05-30' component.createdDateAfter = '2023-05-30'
component.clearAfter() component.clearCreatedAfter()
expect(component.dateAfter).toBeNull() expect(component.createdDateAfter).toBeNull()
component.addedDateAfter = '2023-05-30'
component.clearAddedAfter()
expect(component.addedDateAfter).toBeNull()
}) })
it('should support clearBefore', () => { it('should support clearBefore', () => {
component.dateBefore = '2023-05-30' component.createdDateBefore = '2023-05-30'
component.clearBefore() component.clearCreatedBefore()
expect(component.dateBefore).toBeNull() expect(component.createdDateBefore).toBeNull()
component.addedDateBefore = '2023-05-30'
component.clearAddedBefore()
expect(component.addedDateBefore).toBeNull()
}) })
it('should limit keyboard events', () => { it('should limit keyboard events', () => {

View File

@@ -0,0 +1,219 @@
import {
Component,
EventEmitter,
Input,
Output,
OnInit,
OnDestroy,
} from '@angular/core'
import { NgbDateAdapter } from '@ng-bootstrap/ng-bootstrap'
import { Subject, Subscription } from 'rxjs'
import { debounceTime } from 'rxjs/operators'
import { SettingsService } from 'src/app/services/settings.service'
import { ISODateAdapter } from 'src/app/utils/ngb-iso-date-adapter'
export interface DateSelection {
createdBefore?: string
createdAfter?: string
createdRelativeDateID?: number
addedBefore?: string
addedAfter?: string
addedRelativeDateID?: number
}
export enum RelativeDate {
LAST_7_DAYS = 0,
LAST_MONTH = 1,
LAST_3_MONTHS = 2,
LAST_YEAR = 3,
}
@Component({
selector: 'pngx-dates-dropdown',
templateUrl: './dates-dropdown.component.html',
styleUrls: ['./dates-dropdown.component.scss'],
providers: [{ provide: NgbDateAdapter, useClass: ISODateAdapter }],
})
export class DatesDropdownComponent implements OnInit, OnDestroy {
constructor(settings: SettingsService) {
this.datePlaceHolder = settings.getLocalizedDateInputFormat()
}
relativeDates = [
{
id: RelativeDate.LAST_7_DAYS,
name: $localize`Last 7 days`,
date: new Date().setDate(new Date().getDate() - 7),
},
{
id: RelativeDate.LAST_MONTH,
name: $localize`Last month`,
date: new Date().setMonth(new Date().getMonth() - 1),
},
{
id: RelativeDate.LAST_3_MONTHS,
name: $localize`Last 3 months`,
date: new Date().setMonth(new Date().getMonth() - 3),
},
{
id: RelativeDate.LAST_YEAR,
name: $localize`Last year`,
date: new Date().setFullYear(new Date().getFullYear() - 1),
},
]
datePlaceHolder: string
// created
@Input()
createdDateBefore: string
@Output()
createdDateBeforeChange = new EventEmitter<string>()
@Input()
createdDateAfter: string
@Output()
createdDateAfterChange = new EventEmitter<string>()
@Input()
createdRelativeDate: RelativeDate
@Output()
createdRelativeDateChange = new EventEmitter<number>()
// added
@Input()
addedDateBefore: string
@Output()
addedDateBeforeChange = new EventEmitter<string>()
@Input()
addedDateAfter: string
@Output()
addedDateAfterChange = new EventEmitter<string>()
@Input()
addedRelativeDate: RelativeDate
@Output()
addedRelativeDateChange = new EventEmitter<number>()
@Input()
title: string
@Output()
datesSet = new EventEmitter<DateSelection>()
@Input()
disabled: boolean = false
get isActive(): boolean {
return (
this.createdRelativeDate !== null ||
this.createdDateAfter?.length > 0 ||
this.createdDateBefore?.length > 0 ||
this.addedRelativeDate !== null ||
this.addedDateAfter?.length > 0 ||
this.addedDateBefore?.length > 0
)
}
private datesSetDebounce$ = new Subject()
private sub: Subscription
ngOnInit() {
this.sub = this.datesSetDebounce$.pipe(debounceTime(400)).subscribe(() => {
this.onChange()
})
}
ngOnDestroy() {
if (this.sub) {
this.sub.unsubscribe()
}
}
reset() {
this.createdDateBefore = null
this.createdDateAfter = null
this.createdRelativeDate = null
this.addedDateBefore = null
this.addedDateAfter = null
this.addedRelativeDate = null
this.onChange()
}
setCreatedRelativeDate(rd: RelativeDate) {
this.createdDateBefore = null
this.createdDateAfter = null
this.createdRelativeDate = this.createdRelativeDate == rd ? null : rd
this.onChange()
}
setAddedRelativeDate(rd: RelativeDate) {
this.addedDateBefore = null
this.addedDateAfter = null
this.addedRelativeDate = this.addedRelativeDate == rd ? null : rd
this.onChange()
}
onChange() {
this.createdDateBeforeChange.emit(this.createdDateBefore)
this.createdDateAfterChange.emit(this.createdDateAfter)
this.createdRelativeDateChange.emit(this.createdRelativeDate)
this.addedDateBeforeChange.emit(this.addedDateBefore)
this.addedDateAfterChange.emit(this.addedDateAfter)
this.addedRelativeDateChange.emit(this.addedRelativeDate)
this.datesSet.emit({
createdAfter: this.createdDateAfter,
createdBefore: this.createdDateBefore,
createdRelativeDateID: this.createdRelativeDate,
addedAfter: this.addedDateAfter,
addedBefore: this.addedDateBefore,
addedRelativeDateID: this.addedRelativeDate,
})
}
onChangeDebounce() {
this.createdRelativeDate = null
this.addedRelativeDate = null
this.datesSetDebounce$.next({
createdAfter: this.createdDateAfter,
createdBefore: this.createdDateBefore,
addedAfter: this.addedDateAfter,
addedBefore: this.addedDateBefore,
})
}
clearCreatedBefore() {
this.createdDateBefore = null
this.onChange()
}
clearCreatedAfter() {
this.createdDateAfter = null
this.onChange()
}
clearAddedBefore() {
this.addedDateBefore = null
this.onChange()
}
clearAddedAfter() {
this.addedDateAfter = null
this.onChange()
}
// prevent chars other than numbers and separators
onKeyPress(event: KeyboardEvent) {
if ('Enter' !== event.key && !/[0-9,\.\/-]+/.test(event.key)) {
event.preventDefault()
}
}
}

View File

@@ -16,11 +16,15 @@
<pngx-input-text i18n-title title="First name" formControlName="first_name" [error]="error?.first_name"></pngx-input-text> <pngx-input-text i18n-title title="First name" formControlName="first_name" [error]="error?.first_name"></pngx-input-text>
<pngx-input-text i18n-title title="Last name" formControlName="last_name" [error]="error?.first_name"></pngx-input-text> <pngx-input-text i18n-title title="Last name" formControlName="last_name" [error]="error?.first_name"></pngx-input-text>
<div class="mb-2"> <div class="mb-2 d-flex flex-column">
<div class="form-check form-switch form-check-inline"> <div class="form-check form-switch form-check-inline">
<input type="checkbox" class="form-check-input" id="is_active" formControlName="is_active"> <input type="checkbox" class="form-check-input" id="is_active" formControlName="is_active">
<label class="form-check-label" for="is_active" i18n>Active</label> <label class="form-check-label" for="is_active" i18n>Active</label>
</div> </div>
<div class="form-check form-switch form-check-inline">
<input type="checkbox" class="form-check-input" id="is_staff" formControlName="is_staff">
<label class="form-check-label" for="is_staff"><ng-container i18n>Admin</ng-container> <small class="form-text text-muted ms-1" i18n>Access logs, Django backend</small></label>
</div>
<div class="form-check form-switch form-check-inline"> <div class="form-check form-switch form-check-inline">
<input type="checkbox" class="form-check-input" id="is_superuser" formControlName="is_superuser" (change)="onToggleSuperUser()"> <input type="checkbox" class="form-check-input" id="is_superuser" formControlName="is_superuser" (change)="onToggleSuperUser()">
<label class="form-check-label" for="is_superuser"><ng-container i18n>Superuser</ng-container> <small class="form-text text-muted ms-1" i18n>(Grants all permissions and can view objects)</small></label> <label class="form-check-label" for="is_superuser"><ng-container i18n>Superuser</ng-container> <small class="form-text text-muted ms-1" i18n>(Grants all permissions and can view objects)</small></label>

View File

@@ -56,6 +56,7 @@ export class UserEditDialogComponent
first_name: new FormControl(''), first_name: new FormControl(''),
last_name: new FormControl(''), last_name: new FormControl(''),
is_active: new FormControl(true), is_active: new FormControl(true),
is_staff: new FormControl(true),
is_superuser: new FormControl(false), is_superuser: new FormControl(false),
groups: new FormControl([]), groups: new FormControl([]),
user_permissions: new FormControl([]), user_permissions: new FormControl([]),

View File

@@ -0,0 +1,26 @@
<div class="d-flex flex-row mt-2 align-items-center">
<span class="me-2">{{title}}:</span>
<div class="d-flex flex-row gap-2 w-100 mh-1" style="min-height: 1em;"
cdkDropList #selectedList="cdkDropList"
cdkDropListOrientation="horizontal"
(cdkDropListDropped)="drop($event)"
[cdkDropListConnectedTo]="[unselectedList]">
@for (item of selectedItems; track item.id) {
<div class="badge bg-primary" cdkDrag>{{item.name}}</div>
}
@if (selectedItems.length === 0) {
<div class="badge bg-light fst-italic" i18n>{{emptyText}}</div>
}
</div>
</div>
<div class="d-flex flex-row mt-2 align-items-center bg-light p-2">
<div class="d-flex flex-row gap-2 w-100 mh-1" style="min-height: 1em;"
cdkDropList #unselectedList="cdkDropList"
cdkDropListOrientation="horizontal"
(cdkDropListDropped)="drop($event)"
[cdkDropListConnectedTo]="[selectedList]">
@for (item of unselectedItems; track item.id) {
<div class="badge bg-secondary opacity-50" cdkDrag>{{item.name}}</div>
}
</div>
</div>

View File

@@ -0,0 +1,7 @@
.badge {
cursor: move;
}
.d-flex {
overflow-x: scroll;
}

View File

@@ -0,0 +1,102 @@
import { ComponentFixture, TestBed } from '@angular/core/testing'
import { DragDropModule } from '@angular/cdk/drag-drop'
import { FormsModule, NG_VALUE_ACCESSOR } from '@angular/forms'
import { DragDropSelectComponent } from './drag-drop-select.component'
describe('DragDropSelectComponent', () => {
let component: DragDropSelectComponent
let fixture: ComponentFixture<DragDropSelectComponent>
beforeEach(async () => {
await TestBed.configureTestingModule({
imports: [DragDropModule, FormsModule],
declarations: [DragDropSelectComponent],
}).compileComponents()
fixture = TestBed.createComponent(DragDropSelectComponent)
component = fixture.componentInstance
fixture.debugElement.injector.get(NG_VALUE_ACCESSOR)
fixture.detectChanges()
})
it('should update selectedItems when writeValue is called', () => {
const newValue = ['1', '2', '3']
component.items = [
{ id: '1', name: 'Item 1' },
{ id: '2', name: 'Item 2' },
{ id: '3', name: 'Item 3' },
]
component.writeValue(newValue)
expect(component.selectedItems).toEqual([
{ id: '1', name: 'Item 1' },
{ id: '2', name: 'Item 2' },
{ id: '3', name: 'Item 3' },
])
component.writeValue(null)
expect(component.selectedItems).toEqual([])
})
it('should update selectedItems when an item is dropped within selectedList', () => {
component.items = [
{ id: '1', name: 'Item 1' },
{ id: '2', name: 'Item 2' },
{ id: '3', name: 'Item 3' },
{ id: '4', name: 'Item 4' },
]
component.writeValue(['1', '2', '3'])
const event = {
previousContainer: component.selectedList,
container: component.selectedList,
previousIndex: 1,
currentIndex: 2,
}
component.drop(event as any)
expect(component.selectedItems).toEqual([
{ id: '1', name: 'Item 1' },
{ id: '3', name: 'Item 3' },
{ id: '2', name: 'Item 2' },
])
})
it('should update selectedItems when an item is dropped from unselectedList to selectedList', () => {
component.items = [
{ id: '1', name: 'Item 1' },
{ id: '2', name: 'Item 2' },
{ id: '3', name: 'Item 3' },
]
component.writeValue(['1', '2'])
const event = {
previousContainer: component.unselectedList,
container: component.selectedList,
previousIndex: 0,
currentIndex: 2,
}
component.drop(event as any)
expect(component.selectedItems).toEqual([
{ id: '1', name: 'Item 1' },
{ id: '2', name: 'Item 2' },
{ id: '3', name: 'Item 3' },
])
})
it('should update selectedItems when an item is dropped from selectedList to unselectedList', () => {
component.items = [
{ id: '1', name: 'Item 1' },
{ id: '2', name: 'Item 2' },
{ id: '3', name: 'Item 3' },
]
component.writeValue(['1', '2', '3'])
const event = {
previousContainer: component.selectedList,
container: component.unselectedList,
previousIndex: 1,
currentIndex: 0,
}
component.drop(event as any)
expect(component.selectedItems).toEqual([
{ id: '1', name: 'Item 1' },
{ id: '3', name: 'Item 3' },
])
})
})

View File

@@ -0,0 +1,68 @@
import { Component, Input, ViewChild, forwardRef } from '@angular/core'
import { NG_VALUE_ACCESSOR } from '@angular/forms'
import { AbstractInputComponent } from '../abstract-input'
import {
CdkDragDrop,
CdkDropList,
moveItemInArray,
} from '@angular/cdk/drag-drop'
@Component({
providers: [
{
provide: NG_VALUE_ACCESSOR,
useExisting: forwardRef(() => DragDropSelectComponent),
multi: true,
},
],
selector: 'pngx-input-drag-drop-select',
templateUrl: './drag-drop-select.component.html',
styleUrl: './drag-drop-select.component.scss',
})
export class DragDropSelectComponent extends AbstractInputComponent<string[]> {
@Input() title: string = $localize`Selected items`
@Input() items: { id: string; name: string }[] = []
public selectedItems: { id: string; name: string }[] = []
@Input()
emptyText = $localize`No items selected`
@ViewChild('selectedList') selectedList: CdkDropList
@ViewChild('unselectedList') unselectedList: CdkDropList
get unselectedItems(): { id: string; name: string }[] {
return this.items.filter((i) => !this.selectedItems.includes(i))
}
writeValue(newValue: string[]): void {
super.writeValue(newValue)
this.selectedItems =
newValue?.map((id) => this.items.find((i) => i.id === id)) ?? []
}
public drop(event: CdkDragDrop<string[]>) {
if (
event.previousContainer === event.container &&
event.container === this.selectedList
) {
moveItemInArray(
this.selectedItems,
event.previousIndex,
event.currentIndex
)
} else if (event.container === this.selectedList) {
this.selectedItems.splice(
event.currentIndex,
0,
this.unselectedItems[event.previousIndex]
)
} else if (
event.container === this.unselectedList &&
event.previousContainer === this.selectedList
) {
this.selectedItems.splice(event.previousIndex, 1)
}
this.onChange(this.selectedItems.map((i) => i.id))
}
}

View File

@@ -12,9 +12,9 @@
</div> </div>
<div class="position-relative" [class.col-md-9]="horizontal"> <div class="position-relative" [class.col-md-9]="horizontal">
<div class="input-group" [class.is-invalid]="error"> <div class="input-group" [class.is-invalid]="error">
<span class="input-group-text fw-bold bg-light">{{monetaryValue | currency: currencyCode }}</span> <span class="input-group-text fw-bold bg-light">{{ monetaryValue | currency: currency }}</span>
<input #currencyField class="form-control text-muted mw-60" tabindex="0" [(ngModel)]="currencyCode" maxlength="3" [class.is-invalid]="error" (change)="onChange(value)" [disabled]="disabled"> <input #currencyField class="form-control text-muted mw-60" [(ngModel)]="currency" (input)="currencyChange()" maxlength="3" [class.is-invalid]="error" [disabled]="disabled">
<input #inputField type="number" tabindex="0" class="form-control text-muted" step=".01" [id]="inputId" [(ngModel)]="monetaryValue" (change)="onChange(value)" [class.is-invalid]="error" [disabled]="disabled"> <input #monetaryValueField type="number" class="form-control text-muted" step=".01" [(ngModel)]="monetaryValue" (input)="monetaryValueChange()" (change)="monetaryValueChange(true)" [class.is-invalid]="error" [disabled]="disabled">
</div> </div>
<div class="invalid-feedback position-absolute top-100"> <div class="invalid-feedback position-absolute top-100">
{{error}} {{error}}

View File

@@ -11,7 +11,6 @@ import { MonetaryComponent } from './monetary.component'
describe('MonetaryComponent', () => { describe('MonetaryComponent', () => {
let component: MonetaryComponent let component: MonetaryComponent
let fixture: ComponentFixture<MonetaryComponent> let fixture: ComponentFixture<MonetaryComponent>
let input: HTMLInputElement
beforeEach(async () => { beforeEach(async () => {
await TestBed.configureTestingModule({ await TestBed.configureTestingModule({
@@ -24,37 +23,22 @@ describe('MonetaryComponent', () => {
fixture.debugElement.injector.get(NG_VALUE_ACCESSOR) fixture.debugElement.injector.get(NG_VALUE_ACCESSOR)
component = fixture.componentInstance component = fixture.componentInstance
fixture.detectChanges() fixture.detectChanges()
input = component.inputField.nativeElement
}) })
it('should set the currency code correctly', () => { it('should set the currency code and monetary value correctly', () => {
expect(component.currencyCode).toEqual('USD') // default expect(component.currency).toEqual('USD') // default
component.currencyCode = 'EUR' component.writeValue('G123.4')
expect(component.currencyCode).toEqual('EUR') expect(component.currency).toEqual('G')
component.value = 'G123.4' component.writeValue('EUR123.4')
jest expect(component.currency).toEqual('EUR')
.spyOn(document, 'activeElement', 'get') expect(component.monetaryValue).toEqual('123.40')
.mockReturnValue(component.currencyField.nativeElement)
expect(component.currencyCode).toEqual('G')
}) })
it('should parse monetary value only when out of focus', () => { it('should set monetary value to fixed decimals', () => {
component.monetaryValue = 10.5 component.monetaryValue = '10.5'
jest.spyOn(document, 'activeElement', 'get').mockReturnValue(null) component.monetaryValueChange(true)
expect(component.monetaryValue).toEqual('10.50') expect(component.monetaryValue).toEqual('10.50')
component.value = 'GBP123.4'
jest
.spyOn(document, 'activeElement', 'get')
.mockReturnValue(component.inputField.nativeElement)
expect(component.monetaryValue).toEqual('123.4')
})
it('should report value including currency code and monetary value', () => {
component.currencyCode = 'EUR'
component.monetaryValue = 10.5
expect(component.value).toEqual('EUR10.50')
}) })
it('should set the default currency code based on LOCALE_ID', () => { it('should set the default currency code based on LOCALE_ID', () => {
@@ -62,4 +46,32 @@ describe('MonetaryComponent', () => {
component = new MonetaryComponent('pt-BR') component = new MonetaryComponent('pt-BR')
expect(component.defaultCurrencyCode).toEqual('BRL') expect(component.defaultCurrencyCode).toEqual('BRL')
}) })
it('should parse monetary value correctly', () => {
expect(component['parseMonetaryValue']('123.4')).toEqual('123.4')
expect(component['parseMonetaryValue']('123.4', true)).toEqual('123.40')
expect(component['parseMonetaryValue']('123.4', false)).toEqual('123.4')
})
it('should handle currency change', () => {
component.writeValue('USD123.4')
component.currency = 'EUR'
component.currencyChange()
expect(component.currency).toEqual('EUR')
expect(component.monetaryValue).toEqual('123.40')
})
it('should handle monetary value change', () => {
component.writeValue('USD123.4')
component.monetaryValue = '123.4'
component.monetaryValueChange()
expect(component.monetaryValue).toEqual('123.4')
expect(component.value).toEqual('USD123.40')
})
it('should handle null values', () => {
component.writeValue(null)
expect(component.currency).toEqual('USD')
expect(component.monetaryValue).toEqual('')
})
}) })

View File

@@ -1,11 +1,4 @@
import { import { Component, forwardRef, Inject, LOCALE_ID } from '@angular/core'
Component,
ElementRef,
forwardRef,
Inject,
LOCALE_ID,
ViewChild,
} from '@angular/core'
import { NG_VALUE_ACCESSOR } from '@angular/forms' import { NG_VALUE_ACCESSOR } from '@angular/forms'
import { AbstractInputComponent } from '../abstract-input' import { AbstractInputComponent } from '../abstract-input'
import { getLocaleCurrencyCode } from '@angular/common' import { getLocaleCurrencyCode } from '@angular/common'
@@ -23,39 +16,50 @@ import { getLocaleCurrencyCode } from '@angular/common'
styleUrls: ['./monetary.component.scss'], styleUrls: ['./monetary.component.scss'],
}) })
export class MonetaryComponent extends AbstractInputComponent<string> { export class MonetaryComponent extends AbstractInputComponent<string> {
@ViewChild('currencyField') public currency: string = ''
currencyField: ElementRef public monetaryValue: string = ''
defaultCurrencyCode: string defaultCurrencyCode: string
constructor(@Inject(LOCALE_ID) currentLocale: string) { constructor(@Inject(LOCALE_ID) currentLocale: string) {
super() super()
this.defaultCurrencyCode = getLocaleCurrencyCode(currentLocale) this.currency = this.defaultCurrencyCode =
getLocaleCurrencyCode(currentLocale)
} }
get currencyCode(): string { writeValue(newValue: any): void {
const focused = document.activeElement === this.currencyField?.nativeElement this.currency = this.parseCurrencyCode(newValue)
if (focused && this.value) return this.value.match(/^([A-Z]{0,3})/)?.[0] this.monetaryValue = this.parseMonetaryValue(newValue, true)
this.value = this.currency + this.monetaryValue
}
public monetaryValueChange(fixed: boolean = false): void {
this.monetaryValue = this.parseMonetaryValue(this.monetaryValue, fixed)
this.onChange(this.currency + this.monetaryValue)
}
public currencyChange(): void {
if (this.currency.length) {
this.currency = this.parseCurrencyCode(this.currency)
this.onChange(this.currency + this.monetaryValue?.toString())
}
}
private parseCurrencyCode(value: string): string {
return ( return (
this.value value
?.toString() ?.toString()
.toUpperCase() .toUpperCase()
.match(/^([A-Z]{1,3})/)?.[0] ?? this.defaultCurrencyCode .match(/^([A-Z]{1,3})/)?.[0] ?? this.defaultCurrencyCode
) )
} }
set currencyCode(value: string) { private parseMonetaryValue(value: string, fixed: boolean = false): string {
this.value = value + this.monetaryValue?.toString() if (!value) {
return ''
} }
const val: number = parseFloat(value.toString().replace(/[^0-9.,-]+/g, ''))
get monetaryValue(): string { return fixed ? val.toFixed(2) : val.toString()
if (!this.value) return null
const focused = document.activeElement === this.inputField?.nativeElement
const val = parseFloat(this.value.toString().replace(/[^0-9.,]+/g, ''))
return focused ? val.toString() : val.toFixed(2)
}
set monetaryValue(value: number) {
this.value = this.currencyCode + value.toFixed(2)
} }
} }

View File

@@ -36,7 +36,7 @@
<span [title]="item[bindLabel]">{{item[bindLabel]}}</span> <span [title]="item[bindLabel]">{{item[bindLabel]}}</span>
</ng-template> </ng-template>
</ng-select> </ng-select>
@if (allowCreateNew) { @if (allowCreateNew && !hideAddButton) {
<button class="btn btn-outline-secondary" type="button" (click)="addItem()" [disabled]="disabled"> <button class="btn btn-outline-secondary" type="button" (click)="addItem()" [disabled]="disabled">
<i-bs width="1.2em" height="1.2em" name="plus"></i-bs> <i-bs width="1.2em" height="1.2em" name="plus"></i-bs>
</button> </button>

View File

@@ -94,6 +94,9 @@ export class SelectComponent extends AbstractInputComponent<number> {
@Input() @Input()
disableCreateNew: boolean = false disableCreateNew: boolean = false
@Input()
hideAddButton: boolean = false
@Output() @Output()
createNew = new EventEmitter<string>() createNew = new EventEmitter<string>()

View File

@@ -9,17 +9,17 @@
<div class="col" i18n>Delete</div> <div class="col" i18n>Delete</div>
<div class="col" i18n>View</div> <div class="col" i18n>View</div>
</li> </li>
@for (type of PermissionType | keyvalue; track type) { @for (type of allowedTypes; track type) {
<li class="list-group-item d-flex" [formGroupName]="type.key"> <li class="list-group-item d-flex" [formGroupName]="type">
<div class="col-3">{{type.key}}:</div> <div class="col-3">{{type}}:</div>
<div class="col form-check form-check-inline form-switch" [ngbPopover]="inheritedWarning" [disablePopover]="!isInherited(type.key)" placement="left" triggers="mouseenter:mouseleave"> <div class="col form-check form-check-inline form-switch" [ngbPopover]="inheritedWarning" [disablePopover]="!isInherited(type)" placement="left" triggers="mouseenter:mouseleave">
<input type="checkbox" class="form-check-input" id="{{type.key}}_all" (change)="toggleAll($event, type.key)" [checked]="typesWithAllActions.has(type.key) || isInherited(type.key)" [attr.disabled]="disabled || isInherited(type.key) ? true : null"> <input type="checkbox" class="form-check-input" id="{{type}}_all" (change)="toggleAll($event, type)" [checked]="typesWithAllActions.has(type) || isInherited(type)" [attr.disabled]="disabled || isInherited(type) ? true : null">
<label class="form-check-label visually-hidden" for="{{type.key}}_all" i18n>All</label> <label class="form-check-label visually-hidden" for="{{type}}_all" i18n>All</label>
</div> </div>
@for (action of PermissionAction | keyvalue; track action) { @for (action of PermissionAction | keyvalue; track action) {
<div class="col form-check form-check-inline" [ngbPopover]="inheritedWarning" [disablePopover]="!isInherited(type.key, action.key)" placement="left" triggers="mouseenter:mouseleave"> <div class="col form-check form-check-inline" [ngbPopover]="inheritedWarning" [disablePopover]="!isInherited(type, action.key)" placement="left" triggers="mouseenter:mouseleave">
<input type="checkbox" class="form-check-input" id="{{type.key}}_{{action.key}}" formControlName="{{action.key}}"> <input type="checkbox" class="form-check-input" id="{{type}}_{{action.key}}" formControlName="{{action.key}}">
<label class="form-check-label visually-hidden" for="{{type.key}}_{{action.key}}" i18n>{{action.key}}</label> <label class="form-check-label visually-hidden" for="{{type}}_{{action.key}}" i18n>{{action.key}}</label>
</div> </div>
} }
</li> </li>

View File

@@ -12,6 +12,9 @@ import {
} from 'src/app/services/permissions.service' } from 'src/app/services/permissions.service'
import { By } from '@angular/platform-browser' import { By } from '@angular/platform-browser'
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons' import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
import { SettingsService } from 'src/app/services/settings.service'
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
import { HttpClientTestingModule } from '@angular/common/http/testing'
const permissions = [ const permissions = [
'add_document', 'add_document',
@@ -28,6 +31,7 @@ describe('PermissionsSelectComponent', () => {
let component: PermissionsSelectComponent let component: PermissionsSelectComponent
let fixture: ComponentFixture<PermissionsSelectComponent> let fixture: ComponentFixture<PermissionsSelectComponent>
let permissionsChangeResult: Permissions let permissionsChangeResult: Permissions
let settingsService: SettingsService
beforeEach(async () => { beforeEach(async () => {
TestBed.configureTestingModule({ TestBed.configureTestingModule({
@@ -38,9 +42,11 @@ describe('PermissionsSelectComponent', () => {
ReactiveFormsModule, ReactiveFormsModule,
NgbModule, NgbModule,
NgxBootstrapIconsModule.pick(allIcons), NgxBootstrapIconsModule.pick(allIcons),
HttpClientTestingModule,
], ],
}).compileComponents() }).compileComponents()
settingsService = TestBed.inject(SettingsService)
fixture = TestBed.createComponent(PermissionsSelectComponent) fixture = TestBed.createComponent(PermissionsSelectComponent)
fixture.debugElement.injector.get(NG_VALUE_ACCESSOR) fixture.debugElement.injector.get(NG_VALUE_ACCESSOR)
component = fixture.componentInstance component = fixture.componentInstance
@@ -99,4 +105,11 @@ describe('PermissionsSelectComponent', () => {
const input2 = fixture.debugElement.query(By.css('input#Tag_Change')) const input2 = fixture.debugElement.query(By.css('input#Tag_Change'))
expect(input2.nativeElement.disabled).toBeTruthy() expect(input2.nativeElement.disabled).toBeTruthy()
}) })
it('should exclude history permissions if disabled', () => {
settingsService.set(SETTINGS_KEYS.AUDITLOG_ENABLED, false)
fixture = TestBed.createComponent(PermissionsSelectComponent)
component = fixture.componentInstance
expect(component.allowedTypes).not.toContain('History')
})
}) })

View File

@@ -12,6 +12,8 @@ import {
PermissionType, PermissionType,
} from 'src/app/services/permissions.service' } from 'src/app/services/permissions.service'
import { ComponentWithPermissions } from '../../with-permissions/with-permissions.component' import { ComponentWithPermissions } from '../../with-permissions/with-permissions.component'
import { SettingsService } from 'src/app/services/settings.service'
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
@Component({ @Component({
providers: [ providers: [
@@ -60,15 +62,23 @@ export class PermissionsSelectComponent
inheritedWarning: string = $localize`Inherited from group` inheritedWarning: string = $localize`Inherited from group`
constructor(private readonly permissionsService: PermissionsService) { public allowedTypes = Object.keys(PermissionType)
constructor(
private readonly permissionsService: PermissionsService,
private readonly settingsService: SettingsService
) {
super() super()
for (const type in PermissionType) { if (!this.settingsService.get(SETTINGS_KEYS.AUDITLOG_ENABLED)) {
this.allowedTypes.splice(this.allowedTypes.indexOf('History'), 1)
}
this.allowedTypes.forEach((type) => {
const control = new FormGroup({}) const control = new FormGroup({})
for (const action in PermissionAction) { for (const action in PermissionAction) {
control.addControl(action, new FormControl(null)) control.addControl(action, new FormControl(null))
} }
this.form.addControl(type, control) this.form.addControl(type, control)
} })
} }
writeValue(permissions: string[]): void { writeValue(permissions: string[]): void {
@@ -92,7 +102,7 @@ export class PermissionsSelectComponent
} }
} }
}) })
Object.keys(PermissionType).forEach((type) => { this.allowedTypes.forEach((type) => {
if ( if (
Object.values(this.form.get(type).value).every((val) => val == true) Object.values(this.form.get(type).value).every((val) => val == true)
) { ) {
@@ -191,7 +201,7 @@ export class PermissionsSelectComponent
} }
updateDisabledStates() { updateDisabledStates() {
for (const type in PermissionType) { this.allowedTypes.forEach((type) => {
const control = this.form.get(type) const control = this.form.get(type)
let actionControl: AbstractControl let actionControl: AbstractControl
for (const action in PermissionAction) { for (const action in PermissionAction) {
@@ -200,6 +210,6 @@ export class PermissionsSelectComponent
? actionControl.disable() ? actionControl.disable()
: actionControl.enable() : actionControl.enable()
} }
} })
} }
} }

View File

@@ -23,7 +23,7 @@
} }
<ng-container *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.SavedView }"> <ng-container *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.SavedView }">
@for (v of dashboardViews; track v) { @for (v of dashboardViews; track v.id) {
<div class="col"> <div class="col">
<pngx-saved-view-widget <pngx-saved-view-widget
[savedView]="v" [savedView]="v"

View File

@@ -9,40 +9,64 @@
<a class="btn-link text-decoration-none" header-buttons [routerLink]="[]" (click)="showAll()" i18n>Show all</a> <a class="btn-link text-decoration-none" header-buttons [routerLink]="[]" (click)="showAll()" i18n>Show all</a>
} }
@if (documents.length) { @if (documents.length && displayMode === DisplayMode.TABLE) {
<table content class="table table-hover mb-0 align-middle"> <table content class="table table-hover mb-0 mt-n2 align-middle">
<thead> <thead>
<tr> <tr>
<th scope="col" i18n>Created</th> @for (field of displayFields; track field; let i = $index) {
<th scope="col" i18n>Title</th> @if (displayFields.includes(field)) {
@if (permissionsService.currentUserCan(PermissionAction.View, PermissionType.Tag)) { <th
<th scope="col" class="d-none d-md-table-cell" i18n>Tags</th> scope="col"
[ngClass]="{
'd-none d-md-table-cell': i > 1,
'w-25': field === DisplayField.CREATED || field === DisplayField.ADDED
}">
{{ getColumnTitle(field) }}
</th>
} }
@if (permissionsService.currentUserCan(PermissionAction.View, PermissionType.Correspondent)) {
<th scope="col" class="d-none d-md-table-cell" i18n>Correspondent</th>
} @else {
<th scope="col" class="d-none d-md-table-cell"></th>
} }
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@for (doc of documents; track doc) { @for (doc of documents; track doc.id) {
<tr (mouseleave)="maybeClosePopover()"> <tr>
<td class="py-2 py-md-3"><a routerLink="/documents/{{doc.id}}" class="btn-link text-dark text-decoration-none py-2 py-md-3">{{doc.created_date | customDate}}</a></td> @for (field of displayFields; track field; let i = $index) {
<td class="py-2 py-md-3"> <td class="py-2 py-md-3 position-relative" [ngClass]="{ 'd-none d-md-table-cell': i > 1 }">
@switch (field) {
@case (DisplayField.ADDED) {
<a routerLink="/documents/{{doc.id}}" class="btn-link text-dark text-decoration-none py-2 py-md-3">{{doc.added | customDate}}</a>
}
@case (DisplayField.CREATED) {
<a routerLink="/documents/{{doc.id}}" class="btn-link text-dark text-decoration-none py-2 py-md-3">{{doc.created_date | customDate}}</a>
}
@case (DisplayField.TITLE) {
<a routerLink="/documents/{{doc.id}}" title="Edit" i18n-title class="btn-link text-dark text-decoration-none py-2 py-md-3">{{doc.title | documentTitle}}</a> <a routerLink="/documents/{{doc.id}}" title="Edit" i18n-title class="btn-link text-dark text-decoration-none py-2 py-md-3">{{doc.title | documentTitle}}</a>
</td> }
@if (permissionsService.currentUserCan(PermissionAction.View, PermissionType.Tag)) { @case (DisplayField.CORRESPONDENT) {
<td class="py-2 py-md-3 d-none d-md-table-cell"> @if (doc.correspondent) {
<a class="btn-link text-dark text-decoration-none" type="button" (click)="clickCorrespondent(doc.correspondent, $event)">{{(doc.correspondent$ | async)?.name}}</a>
}
}
@case (DisplayField.TAGS) {
@for (t of doc.tags$ | async; track t) { @for (t of doc.tags$ | async; track t) {
<pngx-tag [tag]="t" class="ms-1" (click)="clickTag(t, $event)"></pngx-tag> <pngx-tag [tag]="t" class="ms-1" (click)="clickTag(t.id, $event)"></pngx-tag>
} }
</td>
} }
<td class="position-relative py-2 py-md-3 d-none d-md-table-cell"> @case (DisplayField.DOCUMENT_TYPE) {
@if (permissionsService.currentUserCan(PermissionAction.View, PermissionType.Correspondent) && doc.correspondent !== null) { @if (doc.document_type) {
<a class="btn-link text-dark text-decoration-none py-2 py-md-3" routerLink="/documents" [queryParams]="getCorrespondentQueryParams(doc.correspondent)">{{(doc.correspondent$ | async)?.name}}</a> <a class="btn-link text-dark text-decoration-none" type="button" (click)="clickDocType(doc.document_type, $event)">{{(doc.document_type$ | async)?.name}}</a>
} }
}
@case (DisplayField.STORAGE_PATH) {
@if (doc.storage_path) {
<a class="btn-link text-dark text-decoration-none" type="button" (click)="clickStoragePath(doc.storage_path, $event)">{{(doc.storage_path$ | async)?.name}}</a>
}
}
}
@if (field.startsWith(DisplayField.CUSTOM_FIELD)) {
<pngx-custom-field-display [document]="doc" [fieldDisplayKey]="field"></pngx-custom-field-display>
}
@if (i === displayFields.length - 1) {
<div class="btn-group position-absolute top-50 end-0 translate-middle-y"> <div class="btn-group position-absolute top-50 end-0 translate-middle-y">
<a [href]="getPreviewUrl(doc)" title="View Preview" i18n-title target="_blank" class="btn px-4 btn-dark border-dark-subtle" <a [href]="getPreviewUrl(doc)" title="View Preview" i18n-title target="_blank" class="btn px-4 btn-dark border-dark-subtle"
[ngbPopover]="previewContent" [popoverTitle]="doc.title | documentTitle" [ngbPopover]="previewContent" [popoverTitle]="doc.title | documentTitle"
@@ -56,11 +80,43 @@
<i-bs width="0.8em" height="0.8em" name="download"></i-bs> <i-bs width="0.8em" height="0.8em" name="download"></i-bs>
</a> </a>
</div> </div>
}
</td> </td>
}
</tr> </tr>
} }
</tbody> </tbody>
</table> </table>
} @else if (documents.length && displayMode === DisplayMode.SMALL_CARDS) {
<div class="row row-cols-paperless-cards my-n2">
@for (d of documents; track d.id) {
<pngx-document-card-small
class="p-0"
(dblClickDocument)="openDocumentDetail(d)"
[document]="d"
[displayFields]="displayFields"
(clickTag)="clickTag($event)"
(clickCorrespondent)="clickCorrespondent($event)"
(clickStoragePath)="clickStoragePath($event)"
(clickDocumentType)="clickDocumentType($event)">
</pngx-document-card-small>
}
</div>
} @else if (documents.length && displayMode === DisplayMode.LARGE_CARDS) {
<div class="row my-n2">
@for (d of documents; track d.id) {
<pngx-document-card-large
(dblClickDocument)="openDocumentDetail(d)"
[document]="d"
[displayFields]="displayFields"
(clickTag)="clickTag($event)"
(clickCorrespondent)="clickCorrespondent($event)"
(clickStoragePath)="clickStoragePath($event)"
(clickDocumentType)="clickDocumentType($event)"
(clickMoreLike)="clickMoreLike(d.id)">
</pngx-document-card-large>
}
</div>
} @else { } @else {
<p i18n class="text-center text-muted mb-0 fst-italic">No documents</p> <p i18n class="text-center text-muted mb-0 fst-italic">No documents</p>
} }

View File

@@ -3,10 +3,9 @@ table {
table-layout: fixed; table-layout: fixed;
} }
th:first-child {
width: 25%;
@media (min-width: 768px) { @media (min-width: 768px) {
width: 15%; th.w-25 {
width: 15% !important;
} }
} }
@@ -30,3 +29,45 @@ td.py-3 {
padding-top: 0.75em !important; padding-top: 0.75em !important;
padding-bottom: 0.75em !important; padding-bottom: 0.75em !important;
} }
$paperless-card-breakpoints: (
// 0: 2, // xs is manual for slim-sidebar
768px: 2, //md
992px: 2, //lg
1200px: 3, //xl
1600px: 4,
1800px: 5,
2000px: 6
);
.row-cols-paperless-cards {
// xs, we dont want in .col-slim block
> * {
flex: 0 0 auto;
width: calc(100% / 2);
}
@each $width, $n_cols in $paperless-card-breakpoints {
@media(min-width: $width) {
> * {
flex: 0 0 auto;
width: calc(100% / $n-cols);
}
}
}
}
::ng-deep .col-slim .row-cols-paperless-cards {
@each $width, $n_cols in $paperless-card-breakpoints {
@media(min-width: $width) {
> * {
flex: 0 0 auto;
width: calc(100% / ($n-cols + 1)) !important;
}
}
}
}
::ng-deep .document-card-check {
display: none !important; // override for dashboard
}

View File

@@ -11,7 +11,13 @@ import { RouterTestingModule } from '@angular/router/testing'
import { NgbModule } from '@ng-bootstrap/ng-bootstrap' import { NgbModule } from '@ng-bootstrap/ng-bootstrap'
import { of, Subject } from 'rxjs' import { of, Subject } from 'rxjs'
import { routes } from 'src/app/app-routing.module' import { routes } from 'src/app/app-routing.module'
import { FILTER_HAS_TAGS_ALL } from 'src/app/data/filter-rule-type' import {
FILTER_CORRESPONDENT,
FILTER_DOCUMENT_TYPE,
FILTER_FULLTEXT_MORELIKE,
FILTER_HAS_TAGS_ALL,
FILTER_STORAGE_PATH,
} from 'src/app/data/filter-rule-type'
import { SavedView } from 'src/app/data/saved-view' import { SavedView } from 'src/app/data/saved-view'
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive' import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
import { PermissionsGuard } from 'src/app/guards/permissions.guard' import { PermissionsGuard } from 'src/app/guards/permissions.guard'
@@ -31,6 +37,10 @@ import { SafeUrlPipe } from 'src/app/pipes/safeurl.pipe'
import { DragDropModule } from '@angular/cdk/drag-drop' import { DragDropModule } from '@angular/cdk/drag-drop'
import { PreviewPopupComponent } from 'src/app/components/common/preview-popup/preview-popup.component' import { PreviewPopupComponent } from 'src/app/components/common/preview-popup/preview-popup.component'
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons' import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
import { CustomFieldDataType } from 'src/app/data/custom-field'
import { CustomFieldDisplayComponent } from 'src/app/components/common/custom-field-display/custom-field-display.component'
import { DisplayMode, DisplayField } from 'src/app/data/document'
const savedView: SavedView = { const savedView: SavedView = {
id: 1, id: 1,
@@ -45,17 +55,53 @@ const savedView: SavedView = {
value: '1,2', value: '1,2',
}, },
], ],
page_size: 20,
display_mode: DisplayMode.TABLE,
display_fields: [
DisplayField.CREATED,
DisplayField.TITLE,
DisplayField.TAGS,
DisplayField.CORRESPONDENT,
DisplayField.DOCUMENT_TYPE,
DisplayField.STORAGE_PATH,
`${DisplayField.CUSTOM_FIELD}11` as any,
`${DisplayField.CUSTOM_FIELD}15` as any,
],
} }
const documentResults = [ const documentResults = [
{ {
id: 2, id: 2,
title: 'doc2', title: 'doc2',
custom_fields: [
{ id: 1, field: 11, created: new Date(), value: 'custom', document: 2 },
],
}, },
{ {
id: 3, id: 3,
title: 'doc3', title: 'doc3',
correspondent: 0, correspondent: 0,
custom_fields: [],
},
{
id: 4,
title: 'doc4',
custom_fields: [
{ id: 32, field: 3, created: new Date(), value: 'EUR123', document: 4 },
],
},
{
id: 5,
title: 'doc5',
custom_fields: [
{
id: 22,
field: 15,
created: new Date(),
value: [123, 456, 789],
document: 5,
},
],
}, },
] ]
@@ -77,6 +123,7 @@ describe('SavedViewWidgetComponent', () => {
DocumentTitlePipe, DocumentTitlePipe,
SafeUrlPipe, SafeUrlPipe,
PreviewPopupComponent, PreviewPopupComponent,
CustomFieldDisplayComponent,
], ],
providers: [ providers: [
PermissionsGuard, PermissionsGuard,
@@ -89,6 +136,33 @@ describe('SavedViewWidgetComponent', () => {
}, },
CustomDatePipe, CustomDatePipe,
DatePipe, DatePipe,
{
provide: CustomFieldsService,
useValue: {
listAll: () =>
of({
all: [3, 11, 15],
count: 3,
results: [
{
id: 3,
name: 'Custom field 3',
data_type: CustomFieldDataType.Monetary,
},
{
id: 11,
name: 'Custom Field 11',
data_type: CustomFieldDataType.String,
},
{
id: 15,
name: 'Custom Field 15',
data_type: CustomFieldDataType.DocumentLink,
},
],
}),
},
},
], ],
imports: [ imports: [
HttpClientTestingModule, HttpClientTestingModule,
@@ -170,7 +244,7 @@ describe('SavedViewWidgetComponent', () => {
component.ngOnInit() component.ngOnInit()
expect(listAllSpy).toHaveBeenCalledWith( expect(listAllSpy).toHaveBeenCalledWith(
1, 1,
10, 20,
savedView.sort_field, savedView.sort_field,
savedView.sort_reverse, savedView.sort_reverse,
savedView.filter_rules, savedView.filter_rules,
@@ -204,11 +278,78 @@ describe('SavedViewWidgetComponent', () => {
}) })
}) })
it('should navigate to document', () => {
const routerSpy = jest.spyOn(router, 'navigate')
component.openDocumentDetail(documentResults[0])
expect(routerSpy).toHaveBeenCalledWith(['documents', documentResults[0].id])
})
it('should navigate via quickfilter on click tag', () => { it('should navigate via quickfilter on click tag', () => {
const qfSpy = jest.spyOn(documentListViewService, 'quickFilter') const qfSpy = jest.spyOn(documentListViewService, 'quickFilter')
component.clickTag({ id: 11, name: 'Tag11' }, new MouseEvent('click')) component.clickTag(11, new MouseEvent('click'))
expect(qfSpy).toHaveBeenCalledWith([ expect(qfSpy).toHaveBeenCalledWith([
{ rule_type: FILTER_HAS_TAGS_ALL, value: '11' }, { rule_type: FILTER_HAS_TAGS_ALL, value: '11' },
]) ])
component.clickTag(11) // coverage
})
it('should navigate via quickfilter on click correspondent', () => {
const qfSpy = jest.spyOn(documentListViewService, 'quickFilter')
component.clickCorrespondent(11, new MouseEvent('click'))
expect(qfSpy).toHaveBeenCalledWith([
{ rule_type: FILTER_CORRESPONDENT, value: '11' },
])
component.clickCorrespondent(11) // coverage
})
it('should navigate via quickfilter on click doc type', () => {
const qfSpy = jest.spyOn(documentListViewService, 'quickFilter')
component.clickDocType(11, new MouseEvent('click'))
expect(qfSpy).toHaveBeenCalledWith([
{ rule_type: FILTER_DOCUMENT_TYPE, value: '11' },
])
component.clickDocType(11) // coverage
})
it('should navigate via quickfilter on click storage path', () => {
const qfSpy = jest.spyOn(documentListViewService, 'quickFilter')
component.clickStoragePath(11, new MouseEvent('click'))
expect(qfSpy).toHaveBeenCalledWith([
{ rule_type: FILTER_STORAGE_PATH, value: '11' },
])
component.clickStoragePath(11) // coverage
})
it('should navigate via quickfilter on click more like', () => {
const qfSpy = jest.spyOn(documentListViewService, 'quickFilter')
component.clickMoreLike(11)
expect(qfSpy).toHaveBeenCalledWith([
{ rule_type: FILTER_FULLTEXT_MORELIKE, value: '11' },
])
})
it('should get correct column title', () => {
expect(component.getColumnTitle(DisplayField.TITLE)).toEqual('Title')
expect(component.getColumnTitle(DisplayField.CREATED)).toEqual('Created')
expect(component.getColumnTitle(DisplayField.ADDED)).toEqual('Added')
expect(component.getColumnTitle(DisplayField.TAGS)).toEqual('Tags')
expect(component.getColumnTitle(DisplayField.CORRESPONDENT)).toEqual(
'Correspondent'
)
expect(component.getColumnTitle(DisplayField.DOCUMENT_TYPE)).toEqual(
'Document type'
)
expect(component.getColumnTitle(DisplayField.STORAGE_PATH)).toEqual(
'Storage path'
)
})
it('should get correct column title for custom field', () => {
expect(
component.getColumnTitle((DisplayField.CUSTOM_FIELD + 11) as any)
).toEqual('Custom Field 11')
expect(
component.getColumnTitle((DisplayField.CUSTOM_FIELD + 15) as any)
).toEqual('Custom Field 15')
}) })
}) })

View File

@@ -6,23 +6,38 @@ import {
QueryList, QueryList,
ViewChildren, ViewChildren,
} from '@angular/core' } from '@angular/core'
import { Params, Router } from '@angular/router' import { Router } from '@angular/router'
import { Subject, takeUntil } from 'rxjs' import { Subject, takeUntil } from 'rxjs'
import { Document } from 'src/app/data/document' import {
DEFAULT_DASHBOARD_DISPLAY_FIELDS,
DEFAULT_DASHBOARD_VIEW_PAGE_SIZE,
DEFAULT_DISPLAY_FIELDS,
DisplayField,
DisplayMode,
Document,
} from 'src/app/data/document'
import { SavedView } from 'src/app/data/saved-view' import { SavedView } from 'src/app/data/saved-view'
import { ConsumerStatusService } from 'src/app/services/consumer-status.service' import { ConsumerStatusService } from 'src/app/services/consumer-status.service'
import { DocumentService } from 'src/app/services/rest/document.service' import { DocumentService } from 'src/app/services/rest/document.service'
import { Tag } from 'src/app/data/tag'
import { import {
FILTER_CORRESPONDENT, FILTER_CORRESPONDENT,
FILTER_DOCUMENT_TYPE,
FILTER_FULLTEXT_MORELIKE,
FILTER_HAS_TAGS_ALL, FILTER_HAS_TAGS_ALL,
FILTER_STORAGE_PATH,
} from 'src/app/data/filter-rule-type' } from 'src/app/data/filter-rule-type'
import { OpenDocumentsService } from 'src/app/services/open-documents.service' import { OpenDocumentsService } from 'src/app/services/open-documents.service'
import { DocumentListViewService } from 'src/app/services/document-list-view.service' import { DocumentListViewService } from 'src/app/services/document-list-view.service'
import { ComponentWithPermissions } from 'src/app/components/with-permissions/with-permissions.component' import { ComponentWithPermissions } from 'src/app/components/with-permissions/with-permissions.component'
import { NgbPopover } from '@ng-bootstrap/ng-bootstrap' import { NgbPopover } from '@ng-bootstrap/ng-bootstrap'
import { queryParamsFromFilterRules } from 'src/app/utils/query-params' import {
import { PermissionsService } from 'src/app/services/permissions.service' PermissionAction,
PermissionType,
PermissionsService,
} from 'src/app/services/permissions.service'
import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
import { CustomField, CustomFieldDataType } from 'src/app/data/custom-field'
import { SettingsService } from 'src/app/services/settings.service'
@Component({ @Component({
selector: 'pngx-saved-view-widget', selector: 'pngx-saved-view-widget',
@@ -33,8 +48,14 @@ export class SavedViewWidgetComponent
extends ComponentWithPermissions extends ComponentWithPermissions
implements OnInit, OnDestroy implements OnInit, OnDestroy
{ {
public DisplayMode = DisplayMode
public DisplayField = DisplayField
public CustomFieldDataType = CustomFieldDataType
loading: boolean = true loading: boolean = true
private customFields: CustomField[] = []
constructor( constructor(
private documentService: DocumentService, private documentService: DocumentService,
private router: Router, private router: Router,
@@ -42,7 +63,9 @@ export class SavedViewWidgetComponent
private consumerStatusService: ConsumerStatusService, private consumerStatusService: ConsumerStatusService,
public openDocumentsService: OpenDocumentsService, public openDocumentsService: OpenDocumentsService,
public documentListViewService: DocumentListViewService, public documentListViewService: DocumentListViewService,
public permissionsService: PermissionsService public permissionsService: PermissionsService,
private settingsService: SettingsService,
private customFieldService: CustomFieldsService
) { ) {
super() super()
} }
@@ -60,14 +83,44 @@ export class SavedViewWidgetComponent
mouseOnPreview = false mouseOnPreview = false
popoverHidden = true popoverHidden = true
displayMode: DisplayMode
displayFields: DisplayField[] = DEFAULT_DASHBOARD_DISPLAY_FIELDS
ngOnInit(): void { ngOnInit(): void {
this.reload() this.reload()
this.displayMode = this.savedView.display_mode ?? DisplayMode.TABLE
this.consumerStatusService this.consumerStatusService
.onDocumentConsumptionFinished() .onDocumentConsumptionFinished()
.pipe(takeUntil(this.unsubscribeNotifier)) .pipe(takeUntil(this.unsubscribeNotifier))
.subscribe(() => { .subscribe(() => {
this.reload() this.reload()
}) })
if (
this.permissionsService.currentUserCan(
PermissionAction.View,
PermissionType.CustomField
)
) {
this.customFieldService
.listAll()
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe((customFields) => {
this.customFields = customFields.results
})
}
if (this.savedView.display_fields) {
this.displayFields = this.savedView.display_fields
}
// filter by perms etc
this.displayFields = this.displayFields.filter(
(field) =>
this.settingsService.allDisplayFields.find((f) => f.id === field) !==
undefined
)
} }
ngOnDestroy(): void { ngOnDestroy(): void {
@@ -80,7 +133,7 @@ export class SavedViewWidgetComponent
this.documentService this.documentService
.listFiltered( .listFiltered(
1, 1,
10, this.savedView.page_size ?? DEFAULT_DASHBOARD_VIEW_PAGE_SIZE,
this.savedView.sort_field, this.savedView.sort_field,
this.savedView.sort_reverse, this.savedView.sort_reverse,
this.savedView.filter_rules, this.savedView.filter_rules,
@@ -103,15 +156,52 @@ export class SavedViewWidgetComponent
} }
} }
clickTag(tag: Tag, event: MouseEvent) { clickTag(tagID: number, event: MouseEvent = null) {
event.preventDefault() event?.preventDefault()
event.stopImmediatePropagation() event?.stopImmediatePropagation()
this.list.quickFilter([ this.list.quickFilter([
{ rule_type: FILTER_HAS_TAGS_ALL, value: tag.id.toString() }, { rule_type: FILTER_HAS_TAGS_ALL, value: tagID.toString() },
]) ])
} }
clickCorrespondent(correspondentId: number, event: MouseEvent = null) {
event?.preventDefault()
event?.stopImmediatePropagation()
this.list.quickFilter([
{ rule_type: FILTER_CORRESPONDENT, value: correspondentId.toString() },
])
}
clickDocType(docTypeId: number, event: MouseEvent = null) {
event?.preventDefault()
event?.stopImmediatePropagation()
this.list.quickFilter([
{ rule_type: FILTER_DOCUMENT_TYPE, value: docTypeId.toString() },
])
}
clickStoragePath(storagePathId: number, event: MouseEvent = null) {
event?.preventDefault()
event?.stopImmediatePropagation()
this.list.quickFilter([
{ rule_type: FILTER_STORAGE_PATH, value: storagePathId.toString() },
])
}
clickMoreLike(documentID: number) {
this.list.quickFilter([
{ rule_type: FILTER_FULLTEXT_MORELIKE, value: documentID.toString() },
])
}
openDocumentDetail(document: Document) {
this.router.navigate(['documents', document.id])
}
getPreviewUrl(document: Document): string { getPreviewUrl(document: Document): string {
return this.documentService.getPreviewUrl(document.id) return this.documentService.getPreviewUrl(document.id)
} }
@@ -161,14 +251,11 @@ export class SavedViewWidgetComponent
}, 300) }, 300)
} }
getCorrespondentQueryParams(correspondentId: number): Params { public getColumnTitle(field: DisplayField): string {
return correspondentId !== undefined if (field.startsWith(DisplayField.CUSTOM_FIELD)) {
? queryParamsFromFilterRules([ const id = field.split('_')[2]
{ return this.customFields.find((f) => f.id === parseInt(id))?.name
rule_type: FILTER_CORRESPONDENT, }
value: correspondentId.toString(), return DEFAULT_DISPLAY_FIELDS.find((f) => f.id === field)?.name
},
])
: null
} }
} }

View File

@@ -6,7 +6,7 @@
<input type="file" class="visually-hidden" (change)="onFileSelected($event)" multiple #fileUpload> <input type="file" class="visually-hidden" (change)="onFileSelected($event)" multiple #fileUpload>
</form> </form>
@if (getStatus().length > 0) { @if (getStatus().length > 0) {
<div class="fixed-bottom p-2 p-md-4 d-flex justify-content-end pe-none" [ngClass]="slimSidebarEnabled ? 'col-slim' : 'offset-md-3 offset-lg-2'"> <div class="fixed-bottom p-2 p-md-4 d-flex justify-content-end pe-none max-vh100-40 overflow-y-scroll" [ngClass]="slimSidebarEnabled ? 'col-slim' : 'offset-md-3 offset-lg-2'">
<div class="col col-lg-4 col-xl-3 ps-0 pe-0 ps-lg-3 pe-lg-0 pe-auto"> <div class="col col-lg-4 col-xl-3 ps-0 pe-0 ps-lg-3 pe-lg-0 pe-auto">
<div class="card shadow-sm consumer-status-card"> <div class="card shadow-sm consumer-status-card">
<div class="card-body"> <div class="card-body">

View File

@@ -39,3 +39,7 @@ form {
.consumer-status-card { .consumer-status-card {
background-color: rgba(var(--bs-body-bg-rgb), .95) !important; background-color: rgba(var(--bs-body-bg-rgb), .95) !important;
} }
.max-vh100-40 {
max-height: calc(100vh - 40px);
}

View File

@@ -149,7 +149,7 @@ describe('UploadFileWidgetComponent', () => {
expect(dismissSpy).toHaveBeenCalled() expect(dismissSpy).toHaveBeenCalled()
}) })
it('should allow dismissing all alerts', fakeAsync(() => { it('should allow dismissing completed alerts', fakeAsync(() => {
mockConsumerStatuses(consumerStatusService) mockConsumerStatuses(consumerStatusService)
component.alertsExpanded = true component.alertsExpanded = true
fixture.detectChanges() fixture.detectChanges()
@@ -160,7 +160,7 @@ describe('UploadFileWidgetComponent', () => {
component.dismissCompleted() component.dismissCompleted()
tick(1000) tick(1000)
fixture.detectChanges() fixture.detectChanges()
expect(dismissSpy).toHaveBeenCalledTimes(10) expect(dismissSpy).toHaveBeenCalledTimes(4)
})) }))
}) })

View File

@@ -115,13 +115,10 @@ export class UploadFileWidgetComponent extends ComponentWithPermissions {
} }
dismissCompleted() { dismissCompleted() {
this.alerts.forEach((a) => a.close())
if (this.alertsExpanded) {
this.getStatusCompleted().forEach((status) => this.getStatusCompleted().forEach((status) =>
this.consumerStatusService.dismiss(status) this.consumerStatusService.dismiss(status)
) )
} }
}
public onFileSelected(event: Event) { public onFileSelected(event: Event) {
this.uploadDocumentsService.uploadFiles( this.uploadDocumentsService.uploadFiles(

View File

@@ -1,10 +1,12 @@
<pngx-page-header [(title)]="title"> <pngx-page-header [(title)]="title">
@if (contentRenderType === ContentRenderType.PDF && !useNativePdfViewer) { @if (contentRenderType === ContentRenderType.PDF && !useNativePdfViewer) {
@if (previewNumPages) {
<div class="input-group input-group-sm d-none d-md-flex"> <div class="input-group input-group-sm d-none d-md-flex">
<div class="input-group-text" i18n>Page</div> <div class="input-group-text" i18n>Page</div>
<input class="form-control flex-grow-0 w-auto" type="number" min="1" [max]="previewNumPages" [(ngModel)]="previewCurrentPage" /> <input class="form-control flex-grow-0 w-auto" type="number" min="1" [max]="previewNumPages" [(ngModel)]="previewCurrentPage" />
<div class="input-group-text" i18n>of {{previewNumPages}}</div> <div class="input-group-text" i18n>of {{previewNumPages}}</div>
</div> </div>
}
<div class="input-group input-group-sm me-md-5 d-none d-md-flex"> <div class="input-group input-group-sm me-md-5 d-none d-md-flex">
<button class="btn btn-outline-secondary" (click)="decreaseZoom()" i18n>-</button> <button class="btn btn-outline-secondary" (click)="decreaseZoom()" i18n>-</button>
<select class="form-select" (change)="onZoomSelect($event)"> <select class="form-select" (change)="onZoomSelect($event)">
@@ -44,11 +46,19 @@
</button> </button>
<div ngbDropdownMenu aria-labelledby="actionsDropdown" class="shadow"> <div ngbDropdownMenu aria-labelledby="actionsDropdown" class="shadow">
<button ngbDropdownItem (click)="redoOcr()" [disabled]="!userCanEdit"> <button ngbDropdownItem (click)="redoOcr()" [disabled]="!userCanEdit">
<i-bs width="1em" height="1em" name="arrow-counterclockwise"></i-bs><span class="ps-1" i18n>Redo OCR</span> <i-bs width="1em" height="1em" name="arrow-counterclockwise"></i-bs>&nbsp;<span i18n>Redo OCR</span>
</button> </button>
<button ngbDropdownItem (click)="moreLike()"> <button ngbDropdownItem (click)="moreLike()">
<i-bs width="1em" height="1em" name="diagram-3"></i-bs><span class="ps-1" i18n>More like this</span> <i-bs width="1em" height="1em" name="diagram-3"></i-bs>&nbsp;<span i18n>More like this</span>
</button>
<button ngbDropdownItem (click)="splitDocument()" [disabled]="contentRenderType !== ContentRenderType.PDF || previewNumPages === 1">
<i-bs width="1em" height="1em" name="scissors"></i-bs>&nbsp;<span i18n>Split</span>
</button>
<button ngbDropdownItem (click)="rotateDocument()" [disabled]="!userIsOwner || metadata?.original_mime_type !== 'application/pdf'">
<i-bs name="arrow-clockwise"></i-bs>&nbsp;<ng-container i18n>Rotate</ng-container>
</button> </button>
</div> </div>
</div> </div>
@@ -102,7 +112,7 @@
<pngx-input-select [items]="storagePaths" i18n-title title="Storage path" formControlName="storage_path" [allowNull]="true" [showFilter]="true" [horizontal]="true" (filterDocuments)="filterDocuments($event)" <pngx-input-select [items]="storagePaths" i18n-title title="Storage path" formControlName="storage_path" [allowNull]="true" [showFilter]="true" [horizontal]="true" (filterDocuments)="filterDocuments($event)"
(createNew)="createStoragePath($event)" [suggestions]="suggestions?.storage_paths" i18n-placeholder placeholder="Default" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.StoragePath }"></pngx-input-select> (createNew)="createStoragePath($event)" [suggestions]="suggestions?.storage_paths" i18n-placeholder placeholder="Default" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.StoragePath }"></pngx-input-select>
<pngx-input-tags formControlName="tags" [suggestions]="suggestions?.tags" [showFilter]="true" [horizontal]="true" (filterDocuments)="filterDocuments($event)" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Tag }"></pngx-input-tags> <pngx-input-tags formControlName="tags" [suggestions]="suggestions?.tags" [showFilter]="true" [horizontal]="true" (filterDocuments)="filterDocuments($event)" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Tag }"></pngx-input-tags>
@for (fieldInstance of document?.custom_fields; track fieldInstance; let i = $index) { @for (fieldInstance of document?.custom_fields; track fieldInstance.field; let i = $index) {
<div [formGroup]="customFieldFormFields.controls[i]"> <div [formGroup]="customFieldFormFields.controls[i]">
@switch (getCustomFieldFromInstance(fieldInstance)?.data_type) { @switch (getCustomFieldFromInstance(fieldInstance)?.data_type) {
@case (CustomFieldDataType.String) { @case (CustomFieldDataType.String) {
@@ -275,6 +285,17 @@
</li> </li>
} }
@if (historyEnabled) {
<li [ngbNavItem]="DocumentDetailNavIDs.History">
<a ngbNavLink i18n>History</a>
<ng-template ngbNavContent>
<div class="mb-3">
<pngx-document-history [documentId]="documentId"></pngx-document-history>
</div>
</ng-template>
</li>
}
@if (showPermissions) { @if (showPermissions) {
<li [ngbNavItem]="DocumentDetailNavIDs.Permissions"> <li [ngbNavItem]="DocumentDetailNavIDs.Permissions">
<a ngbNavLink i18n>Permissions</a> <a ngbNavLink i18n>Permissions</a>

View File

@@ -17,7 +17,7 @@
--page-margin: 10px auto; --page-margin: 10px auto;
} }
::ng-deep .ng-select-taggable { ::ng-deep form .ng-select-taggable {
max-width: calc(100% - 90px); // fudge factor for (2x) ng-select button width max-width: calc(100% - 90px); // fudge factor for (2x) ng-select button width
} }

View File

@@ -75,6 +75,8 @@ import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service
import { PdfViewerComponent } from '../common/pdf-viewer/pdf-viewer.component' import { PdfViewerComponent } from '../common/pdf-viewer/pdf-viewer.component'
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons' import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
import { environment } from 'src/environments/environment' import { environment } from 'src/environments/environment'
import { RotateConfirmDialogComponent } from '../common/confirm-dialog/rotate-confirm-dialog/rotate-confirm-dialog.component'
import { SplitConfirmDialogComponent } from '../common/confirm-dialog/split-confirm-dialog/split-confirm-dialog.component'
const doc: Document = { const doc: Document = {
id: 3, id: 3,
@@ -171,6 +173,8 @@ describe('DocumentDetailComponent', () => {
ShareLinksDropdownComponent, ShareLinksDropdownComponent,
CustomFieldsDropdownComponent, CustomFieldsDropdownComponent,
PdfViewerComponent, PdfViewerComponent,
SplitConfirmDialogComponent,
RotateConfirmDialogComponent,
], ],
providers: [ providers: [
DocumentTitlePipe, DocumentTitlePipe,
@@ -1070,6 +1074,58 @@ describe('DocumentDetailComponent', () => {
).not.toBeUndefined() ).not.toBeUndefined()
}) })
it('should support split', () => {
let modal: NgbModalRef
modalService.activeInstances.subscribe((m) => (modal = m[0]))
initNormally()
component.splitDocument()
expect(modal).not.toBeUndefined()
modal.componentInstance.documentID = doc.id
modal.componentInstance.totalPages = 5
modal.componentInstance.page = 2
modal.componentInstance.addSplit()
modal.componentInstance.confirm()
let req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/bulk_edit/`
)
expect(req.request.body).toEqual({
documents: [doc.id],
method: 'split',
parameters: { pages: '1-2,3-5' },
})
req.error(new ProgressEvent('failed'))
modal.componentInstance.confirm()
req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/bulk_edit/`
)
req.flush(true)
})
it('should support rotate', () => {
let modal: NgbModalRef
modalService.activeInstances.subscribe((m) => (modal = m[0]))
initNormally()
component.rotateDocument()
expect(modal).not.toBeUndefined()
modal.componentInstance.documentID = doc.id
modal.componentInstance.rotate()
modal.componentInstance.confirm()
let req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/bulk_edit/`
)
expect(req.request.body).toEqual({
documents: [doc.id],
method: 'rotate',
parameters: { degrees: 90 },
})
req.error(new ProgressEvent('failed'))
modal.componentInstance.confirm()
req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/bulk_edit/`
)
req.flush(true)
})
function initNormally() { function initNormally() {
jest jest
.spyOn(activatedRoute, 'paramMap', 'get') .spyOn(activatedRoute, 'paramMap', 'get')

View File

@@ -67,6 +67,8 @@ import { CustomField, CustomFieldDataType } from 'src/app/data/custom-field'
import { CustomFieldInstance } from 'src/app/data/custom-field-instance' import { CustomFieldInstance } from 'src/app/data/custom-field-instance'
import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service' import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
import { PDFDocumentProxy } from '../common/pdf-viewer/typings' import { PDFDocumentProxy } from '../common/pdf-viewer/typings'
import { SplitConfirmDialogComponent } from '../common/confirm-dialog/split-confirm-dialog/split-confirm-dialog.component'
import { RotateConfirmDialogComponent } from '../common/confirm-dialog/rotate-confirm-dialog/rotate-confirm-dialog.component'
enum DocumentDetailNavIDs { enum DocumentDetailNavIDs {
Details = 1, Details = 1,
@@ -75,6 +77,7 @@ enum DocumentDetailNavIDs {
Preview = 4, Preview = 4,
Notes = 5, Notes = 5,
Permissions = 6, Permissions = 6,
History = 7,
} }
enum ContentRenderType { enum ContentRenderType {
@@ -147,7 +150,7 @@ export class DocumentDetailComponent
}) })
previewCurrentPage: number = 1 previewCurrentPage: number = 1
previewNumPages: number = 1 previewNumPages: number
previewZoomSetting: ZoomSetting = ZoomSetting.One previewZoomSetting: ZoomSetting = ZoomSetting.One
previewZoomScale: ZoomSetting = ZoomSetting.PageWidth previewZoomScale: ZoomSetting = ZoomSetting.PageWidth
@@ -900,6 +903,17 @@ export class DocumentDetailComponent
) )
} }
get historyEnabled(): boolean {
return (
this.settings.get(SETTINGS_KEYS.AUDITLOG_ENABLED) &&
this.userIsOwner &&
this.permissionsService.currentUserCan(
PermissionAction.View,
PermissionType.History
)
)
}
notesUpdated(notes: DocumentNote[]) { notesUpdated(notes: DocumentNote[]) {
this.document.notes = notes this.document.notes = notes
this.openDocumentService.refreshDocument(this.documentId) this.openDocumentService.refreshDocument(this.documentId)
@@ -1040,4 +1054,83 @@ export class DocumentDetailComponent
this.updateFormForCustomFields(true) this.updateFormForCustomFields(true)
this.documentForm.updateValueAndValidity() this.documentForm.updateValueAndValidity()
} }
splitDocument() {
let modal = this.modalService.open(SplitConfirmDialogComponent, {
backdrop: 'static',
})
modal.componentInstance.title = $localize`Split confirm`
modal.componentInstance.messageBold = $localize`This operation will split the selected document(s) into new documents.`
modal.componentInstance.btnCaption = $localize`Proceed`
modal.componentInstance.documentID = this.document.id
modal.componentInstance.confirmClicked
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe(() => {
modal.componentInstance.buttonsEnabled = false
this.documentsService
.bulkEdit([this.document.id], 'split', {
pages: modal.componentInstance.pagesString,
})
.pipe(first(), takeUntil(this.unsubscribeNotifier))
.subscribe({
next: () => {
this.toastService.showInfo(
$localize`Split operation will begin in the background.`
)
modal.close()
},
error: (error) => {
if (modal) {
modal.componentInstance.buttonsEnabled = true
}
this.toastService.showError(
$localize`Error executing split operation`,
error
)
},
})
})
}
rotateDocument() {
let modal = this.modalService.open(RotateConfirmDialogComponent, {
backdrop: 'static',
})
modal.componentInstance.title = $localize`Rotate confirm`
modal.componentInstance.messageBold = $localize`This operation will permanently rotate the original version of the current document.`
modal.componentInstance.message = $localize`This will alter the original copy.`
modal.componentInstance.btnCaption = $localize`Proceed`
modal.componentInstance.documentID = this.document.id
modal.componentInstance.showPDFNote = false
modal.componentInstance.confirmClicked
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe(() => {
modal.componentInstance.buttonsEnabled = false
this.documentsService
.bulkEdit([this.document.id], 'rotate', {
degrees: modal.componentInstance.degrees,
})
.pipe(first(), takeUntil(this.unsubscribeNotifier))
.subscribe({
next: () => {
this.toastService.show({
content: $localize`Rotation will begin in the background. Close and re-open the document after the operation has completed to see the changes.`,
delay: 8000,
action: this.close.bind(this),
actionName: $localize`Close`,
})
modal.close()
},
error: (error) => {
if (modal) {
modal.componentInstance.buttonsEnabled = true
}
this.toastService.showError(
$localize`Error executing rotate operation`,
error
)
},
})
})
}
} }

View File

@@ -0,0 +1,59 @@
@if (loading) {
<div class="d-flex">
<div class="spinner-border spinner-border-sm fw-normal" role="status"></div>
</div>
} @else {
<ul class="list-group">
@if (entries.length === 0) {
<li class="list-group-item">
<div class="d-flex justify-content-center">
<span class="fst-italic" i18n>No entries found.</span>
</div>
</li>
} @else {
@for (entry of entries; track entry.id) {
<li class="list-group-item">
<div class="d-flex justify-content-between align-items-center">
<ng-template #timestamp>
<div class="text-light">
{{ entry.timestamp | customDate:'longDate' }} {{ entry.timestamp | date:'shortTime' }}
</div>
</ng-template>
<span class="text-muted" [ngbTooltip]="timestamp">{{ entry.timestamp | customDate:'relative' }}</span>
@if (entry.actor) {
<span class="ms-3 fst-italic">{{ entry.actor.username }}</span>
} @else {
<span class="ms-3 fst-italic">System</span>
}
<span class="badge bg-secondary ms-auto" [class.bg-primary]="entry.action === AuditLogAction.Create">{{ entry.action | titlecase }}</span>
</div>
@if (entry.action === AuditLogAction.Update) {
<ul class="mt-2">
@for (change of entry.changes | keyvalue; track change.key) {
@if (change.value["type"] === 'm2m') {
<li>
<span class="fst-italic" i18n>{{ change.value["operation"] | titlecase }}</span>&nbsp;
<span class="text-light">{{ change.key | titlecase }}</span>:&nbsp;
<code class="text-primary">{{ change.value["objects"].join(', ') }}</code>
</li>
}
@else if (change.value["type"] === 'custom_field') {
<li>
<span class="text-light">{{ change.value["field"] }}</span>:&nbsp;
<code class="text-primary">{{ change.value["value"] }}</code>
</li>
}
@else {
<li>
<span class="text-light">{{ change.key | titlecase }}</span>:&nbsp;
<code class="text-primary">{{ change.value[1] }}</code>
</li>
}
}
</ul>
}
</li>
}
}
</ul>
}

View File

@@ -0,0 +1,58 @@
import { ComponentFixture, TestBed } from '@angular/core/testing'
import { DocumentHistoryComponent } from './document-history.component'
import { DocumentService } from 'src/app/services/rest/document.service'
import { of } from 'rxjs'
import { AuditLogAction } from 'src/app/data/auditlog-entry'
import { HttpClientTestingModule } from '@angular/common/http/testing'
import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
import { DatePipe } from '@angular/common'
import { NgbCollapseModule, NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
describe('DocumentHistoryComponent', () => {
let component: DocumentHistoryComponent
let fixture: ComponentFixture<DocumentHistoryComponent>
let documentService: DocumentService
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [DocumentHistoryComponent, CustomDatePipe],
providers: [DatePipe],
imports: [
HttpClientTestingModule,
NgbCollapseModule,
NgxBootstrapIconsModule.pick(allIcons),
NgbTooltipModule,
],
}).compileComponents()
fixture = TestBed.createComponent(DocumentHistoryComponent)
documentService = TestBed.inject(DocumentService)
component = fixture.componentInstance
})
it('should get audit log entries on init', () => {
const getHistorySpy = jest.spyOn(documentService, 'getHistory')
getHistorySpy.mockReturnValue(
of([
{
id: 1,
actor: {
id: 1,
username: 'user1',
},
action: AuditLogAction.Create,
timestamp: '2021-01-01T00:00:00Z',
remote_addr: '1.2.3.4',
changes: {
title: ['old title', 'new title'],
},
},
])
)
component.documentId = 1
fixture.detectChanges()
expect(getHistorySpy).toHaveBeenCalledWith(1)
})
})

View File

@@ -0,0 +1,36 @@
import { Component, Input, OnInit } from '@angular/core'
import { AuditLogAction, AuditLogEntry } from 'src/app/data/auditlog-entry'
import { DocumentService } from 'src/app/services/rest/document.service'
@Component({
selector: 'pngx-document-history',
templateUrl: './document-history.component.html',
styleUrl: './document-history.component.scss',
})
export class DocumentHistoryComponent implements OnInit {
public AuditLogAction = AuditLogAction
private _documentId: number
@Input()
set documentId(id: number) {
this._documentId = id
this.ngOnInit()
}
public loading: boolean = true
public entries: AuditLogEntry[] = []
constructor(private documentService: DocumentService) {}
ngOnInit(): void {
if (this._documentId) {
this.loading = true
this.documentService
.getHistory(this._documentId)
.subscribe((auditLogEntries) => {
this.entries = auditLogEntries
this.loading = false
})
}
}
}

View File

@@ -74,6 +74,20 @@
(apply)="setStoragePaths($event)"> (apply)="setStoragePaths($event)">
</pngx-filterable-dropdown> </pngx-filterable-dropdown>
} }
@if (permissionService.currentUserCan(PermissionAction.View, PermissionType.CustomField)) {
<pngx-filterable-dropdown title="Custom fields" icon="ui-radios" i18n-title
filterPlaceholder="Filter custom fields" i18n-filterPlaceholder
[items]="customFields"
[disabled]="!userCanEditAll"
[editing]="true"
[applyOnClose]="applyOnClose"
[createRef]="createCustomField.bind(this)"
(opened)="openCustomFieldsDropdown()"
[(selectionModel)]="customFieldsSelectionModel"
[documentCounts]="customFieldDocumentCounts"
(apply)="setCustomFields($event)">
</pngx-filterable-dropdown>
}
</div> </div>
<div class="d-flex align-items-center gap-2 ms-auto"> <div class="d-flex align-items-center gap-2 ms-auto">
<div class="btn-toolbar"> <div class="btn-toolbar">
@@ -88,7 +102,15 @@
<div class="d-none d-sm-inline">&nbsp;<ng-container i18n>Actions</ng-container></div> <div class="d-none d-sm-inline">&nbsp;<ng-container i18n>Actions</ng-container></div>
</button> </button>
<div ngbDropdownMenu aria-labelledby="dropdownSelect" class="shadow"> <div ngbDropdownMenu aria-labelledby="dropdownSelect" class="shadow">
<button ngbDropdownItem (click)="redoOcrSelected()" [disabled]="!userCanEditAll" i18n>Redo OCR</button> <button ngbDropdownItem (click)="redoOcrSelected()" [disabled]="!userCanEditAll">
<i-bs name="body-text"></i-bs>&nbsp;<ng-container i18n>Redo OCR</ng-container>
</button>
<button ngbDropdownItem (click)="rotateSelected()" [disabled]="!userOwnsAll">
<i-bs name="arrow-clockwise"></i-bs>&nbsp;<ng-container i18n>Rotate</ng-container>
</button>
<button ngbDropdownItem (click)="mergeSelected()" [disabled]="!userCanEditAll || list.selected.size < 2">
<i-bs name="journals"></i-bs>&nbsp;<ng-container i18n>Merge</ng-container>
</button>
</div> </div>
</div> </div>
</div> </div>
@@ -113,22 +135,16 @@
<div class="form-group ps-3 mb-2"> <div class="form-group ps-3 mb-2">
<div class="form-check"> <div class="form-check">
<input type="checkbox" class="form-check-input" id="downloadFileType_archive" formControlName="downloadFileTypeArchive" /> <input type="checkbox" class="form-check-input" id="downloadFileType_archive" formControlName="downloadFileTypeArchive" />
<label class="form-check-label" for="downloadFileType_archive" i18n> <label class="form-check-label" for="downloadFileType_archive" i18n>Archived files</label>
Archived files
</label>
</div> </div>
<div class="form-check"> <div class="form-check">
<input type="checkbox" class="form-check-input" id="downloadFileType_originals" formControlName="downloadFileTypeOriginals" /> <input type="checkbox" class="form-check-input" id="downloadFileType_originals" formControlName="downloadFileTypeOriginals" />
<label class="form-check-label" for="downloadFileType_originals" i18n> <label class="form-check-label" for="downloadFileType_originals" i18n>Original files</label>
Original files
</label>
</div> </div>
</div> </div>
<div class="form-check"> <div class="form-check">
<input type="checkbox" class="form-check-input" id="downloadUseFormatting" formControlName="downloadUseFormatting" /> <input type="checkbox" class="form-check-input" id="downloadUseFormatting" formControlName="downloadUseFormatting" />
<label class="form-check-label" for="downloadUseFormatting" i18n> <label class="form-check-label" for="downloadUseFormatting" i18n>Use formatted filename</label>
Use formatted filename
</label>
</div> </div>
</form> </form>
</div> </div>

View File

@@ -52,6 +52,12 @@ import { StoragePath } from 'src/app/data/storage-path'
import { CorrespondentEditDialogComponent } from '../../common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component' import { CorrespondentEditDialogComponent } from '../../common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component'
import { DocumentTypeEditDialogComponent } from '../../common/edit-dialog/document-type-edit-dialog/document-type-edit-dialog.component' import { DocumentTypeEditDialogComponent } from '../../common/edit-dialog/document-type-edit-dialog/document-type-edit-dialog.component'
import { StoragePathEditDialogComponent } from '../../common/edit-dialog/storage-path-edit-dialog/storage-path-edit-dialog.component' import { StoragePathEditDialogComponent } from '../../common/edit-dialog/storage-path-edit-dialog/storage-path-edit-dialog.component'
import { IsNumberPipe } from 'src/app/pipes/is-number.pipe'
import { RotateConfirmDialogComponent } from '../../common/confirm-dialog/rotate-confirm-dialog/rotate-confirm-dialog.component'
import { MergeConfirmDialogComponent } from '../../common/confirm-dialog/merge-confirm-dialog/merge-confirm-dialog.component'
import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
import { CustomField, CustomFieldDataType } from 'src/app/data/custom-field'
import { CustomFieldEditDialogComponent } from '../../common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component'
const selectionData: SelectionData = { const selectionData: SelectionData = {
selected_tags: [ selected_tags: [
@@ -65,6 +71,10 @@ const selectionData: SelectionData = {
{ id: 66, document_count: 3 }, { id: 66, document_count: 3 },
{ id: 55, document_count: 0 }, { id: 55, document_count: 0 },
], ],
selected_custom_fields: [
{ id: 77, document_count: 3 },
{ id: 88, document_count: 0 },
],
} }
describe('BulkEditorComponent', () => { describe('BulkEditorComponent', () => {
@@ -79,6 +89,7 @@ describe('BulkEditorComponent', () => {
let correspondentsService: CorrespondentService let correspondentsService: CorrespondentService
let documentTypeService: DocumentTypeService let documentTypeService: DocumentTypeService
let storagePathService: StoragePathService let storagePathService: StoragePathService
let customFieldsService: CustomFieldsService
let httpTestingController: HttpTestingController let httpTestingController: HttpTestingController
beforeEach(async () => { beforeEach(async () => {
@@ -97,6 +108,9 @@ describe('BulkEditorComponent', () => {
PermissionsGroupComponent, PermissionsGroupComponent,
PermissionsUserComponent, PermissionsUserComponent,
SwitchComponent, SwitchComponent,
RotateConfirmDialogComponent,
IsNumberPipe,
MergeConfirmDialogComponent,
], ],
providers: [ providers: [
PermissionsService, PermissionsService,
@@ -142,6 +156,18 @@ describe('BulkEditorComponent', () => {
}), }),
}, },
}, },
{
provide: CustomFieldsService,
useValue: {
listAll: () =>
of({
results: [
{ id: 77, name: 'customfield1' },
{ id: 88, name: 'customfield2' },
],
}),
},
},
FilterPipe, FilterPipe,
SettingsService, SettingsService,
{ {
@@ -183,6 +209,7 @@ describe('BulkEditorComponent', () => {
correspondentsService = TestBed.inject(CorrespondentService) correspondentsService = TestBed.inject(CorrespondentService)
documentTypeService = TestBed.inject(DocumentTypeService) documentTypeService = TestBed.inject(DocumentTypeService)
storagePathService = TestBed.inject(StoragePathService) storagePathService = TestBed.inject(StoragePathService)
customFieldsService = TestBed.inject(CustomFieldsService)
httpTestingController = TestBed.inject(HttpTestingController) httpTestingController = TestBed.inject(HttpTestingController)
fixture = TestBed.createComponent(BulkEditorComponent) fixture = TestBed.createComponent(BulkEditorComponent)
@@ -256,6 +283,22 @@ describe('BulkEditorComponent', () => {
expect(component.storagePathsSelectionModel.selectionSize()).toEqual(1) expect(component.storagePathsSelectionModel.selectionSize()).toEqual(1)
}) })
it('should apply selection data to custom fields menu', () => {
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
fixture.detectChanges()
expect(
component.customFieldsSelectionModel.getSelectedItems()
).toHaveLength(0)
jest
.spyOn(documentListViewService, 'selected', 'get')
.mockReturnValue(new Set([3, 5, 7]))
jest
.spyOn(documentService, 'getSelectionData')
.mockReturnValue(of(selectionData))
component.openCustomFieldsDropdown()
expect(component.customFieldsSelectionModel.selectionSize()).toEqual(1)
})
it('should execute modify tags bulk operation', () => { it('should execute modify tags bulk operation', () => {
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true) jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
jest jest
@@ -673,6 +716,122 @@ describe('BulkEditorComponent', () => {
) )
}) })
it('should execute modify custom fields 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.setCustomFields({
itemsToAdd: [{ id: 101 }],
itemsToRemove: [{ id: 102 }],
})
let req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/bulk_edit/`
)
req.flush(true)
expect(req.request.body).toEqual({
documents: [3, 4],
method: 'modify_custom_fields',
parameters: { add_custom_fields: [101], remove_custom_fields: [102] },
})
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 custom fields 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.setCustomFields({
itemsToAdd: [{ id: 101 }],
itemsToRemove: [{ id: 102 }],
})
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
// coverage for modal messages
component.setCustomFields({
itemsToAdd: [{ id: 101 }],
itemsToRemove: [],
})
component.setCustomFields({
itemsToAdd: [{ id: 101 }, { id: 102 }],
itemsToRemove: [],
})
component.setCustomFields({
itemsToAdd: [],
itemsToRemove: [{ id: 101 }, { id: 102 }],
})
component.setCustomFields({
itemsToAdd: [{ id: 100 }],
itemsToRemove: [{ id: 101 }, { id: 102 }],
})
})
it('should set modal dialog text accordingly for custom fields 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.setCustomFields({
itemsToAdd: [],
itemsToRemove: [{ id: 101, name: 'CustomField 101' }],
})
expect(modal.componentInstance.message).toEqual(
'This operation will remove the custom field "CustomField 101" from 2 selected document(s).'
)
modal.close()
component.setCustomFields({
itemsToAdd: [{ id: 101, name: 'CustomField 101' }],
itemsToRemove: [],
})
expect(modal.componentInstance.message).toEqual(
'This operation will assign the custom field "CustomField 101" to 2 selected document(s).'
)
})
it('should only execute bulk operations when changes are detected', () => { it('should only execute bulk operations when changes are detected', () => {
component.setTags({ component.setTags({
itemsToAdd: [], itemsToAdd: [],
@@ -690,6 +849,10 @@ describe('BulkEditorComponent', () => {
itemsToAdd: [], itemsToAdd: [],
itemsToRemove: [], itemsToRemove: [],
}) })
component.setCustomFields({
itemsToAdd: [],
itemsToRemove: [],
})
httpTestingController.expectNone( httpTestingController.expectNone(
`${environment.apiBaseUrl}documents/bulk_edit/` `${environment.apiBaseUrl}documents/bulk_edit/`
) )
@@ -818,6 +981,83 @@ describe('BulkEditorComponent', () => {
) // listAllFilteredIds ) // listAllFilteredIds
}) })
it('should support rotate', () => {
let modal: NgbModalRef
modalService.activeInstances.subscribe((m) => (modal = m[0]))
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
jest
.spyOn(documentListViewService, 'documents', 'get')
.mockReturnValue([{ id: 3 }, { id: 4 }])
jest
.spyOn(documentListViewService, 'selected', 'get')
.mockReturnValue(new Set([3, 4]))
jest
.spyOn(permissionsService, 'currentUserHasObjectPermissions')
.mockReturnValue(true)
fixture.detectChanges()
component.rotateSelected()
expect(modal).not.toBeUndefined()
modal.componentInstance.rotate()
modal.componentInstance.confirm()
let req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/bulk_edit/`
)
req.flush(true)
expect(req.request.body).toEqual({
documents: [3, 4],
method: 'rotate',
parameters: { degrees: 90 },
})
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 merge', () => {
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(documentService, 'getFew').mockReturnValue(
of({
all: [3, 4],
count: 2,
results: [{ id: 3 }, { id: 4 }],
})
)
jest
.spyOn(documentListViewService, 'selected', 'get')
.mockReturnValue(new Set([3, 4]))
jest
.spyOn(permissionsService, 'currentUserHasObjectPermissions')
.mockReturnValue(true)
fixture.detectChanges()
component.mergeSelected()
expect(modal).not.toBeUndefined()
modal.componentInstance.metadataDocumentID = 3
modal.componentInstance.confirm()
let req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/bulk_edit/`
)
req.flush(true)
expect(req.request.body).toEqual({
documents: [3, 4],
method: 'merge',
parameters: { metadata_document_id: 3 },
})
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', () => { it('should support bulk download with archive, originals or both and file formatting', () => {
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true) jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
jest jest
@@ -1096,4 +1336,56 @@ describe('BulkEditorComponent', () => {
) )
expect(component.storagePaths).toEqual(storagePaths.results) expect(component.storagePaths).toEqual(storagePaths.results)
}) })
it('should support create new custom field', () => {
const name = 'New Custom Field'
const newCustomField = { id: 101, name: 'New Custom Field' }
const customFields: Results<CustomField> = {
results: [
{
id: 1,
name: 'Custom Field 1',
data_type: CustomFieldDataType.String,
},
{
id: 2,
name: 'Custom Field 2',
data_type: CustomFieldDataType.String,
},
],
count: 2,
all: [1, 2],
}
const modalInstance = {
componentInstance: {
dialogMode: EditDialogMode.CREATE,
object: { name },
succeeded: of(newCustomField),
},
}
const customFieldsListAllSpy = jest.spyOn(customFieldsService, 'listAll')
customFieldsListAllSpy.mockReturnValue(of(customFields))
const customFieldsSelectionModelToggleSpy = jest.spyOn(
component.customFieldsSelectionModel,
'toggle'
)
const modalServiceOpenSpy = jest.spyOn(modalService, 'open')
modalServiceOpenSpy.mockReturnValue(modalInstance as any)
component.createCustomField(name)
expect(modalServiceOpenSpy).toHaveBeenCalledWith(
CustomFieldEditDialogComponent,
{ backdrop: 'static' }
)
expect(customFieldsListAllSpy).toHaveBeenCalled()
expect(customFieldsSelectionModelToggleSpy).toHaveBeenCalledWith(
newCustomField.id
)
expect(component.customFields).toEqual(customFields.results)
})
}) })

View File

@@ -6,7 +6,7 @@ import { TagService } from 'src/app/services/rest/tag.service'
import { CorrespondentService } from 'src/app/services/rest/correspondent.service' import { CorrespondentService } from 'src/app/services/rest/correspondent.service'
import { DocumentTypeService } from 'src/app/services/rest/document-type.service' import { DocumentTypeService } from 'src/app/services/rest/document-type.service'
import { DocumentListViewService } from 'src/app/services/document-list-view.service' import { DocumentListViewService } from 'src/app/services/document-list-view.service'
import { NgbModal } from '@ng-bootstrap/ng-bootstrap' import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap'
import { import {
DocumentService, DocumentService,
SelectionDataItem, SelectionDataItem,
@@ -39,6 +39,11 @@ import { EditDialogMode } from '../../common/edit-dialog/edit-dialog.component'
import { TagEditDialogComponent } from '../../common/edit-dialog/tag-edit-dialog/tag-edit-dialog.component' import { TagEditDialogComponent } from '../../common/edit-dialog/tag-edit-dialog/tag-edit-dialog.component'
import { DocumentTypeEditDialogComponent } from '../../common/edit-dialog/document-type-edit-dialog/document-type-edit-dialog.component' import { DocumentTypeEditDialogComponent } from '../../common/edit-dialog/document-type-edit-dialog/document-type-edit-dialog.component'
import { StoragePathEditDialogComponent } from '../../common/edit-dialog/storage-path-edit-dialog/storage-path-edit-dialog.component' import { StoragePathEditDialogComponent } from '../../common/edit-dialog/storage-path-edit-dialog/storage-path-edit-dialog.component'
import { RotateConfirmDialogComponent } from '../../common/confirm-dialog/rotate-confirm-dialog/rotate-confirm-dialog.component'
import { MergeConfirmDialogComponent } from '../../common/confirm-dialog/merge-confirm-dialog/merge-confirm-dialog.component'
import { CustomField } from 'src/app/data/custom-field'
import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
import { CustomFieldEditDialogComponent } from '../../common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component'
@Component({ @Component({
selector: 'pngx-bulk-editor', selector: 'pngx-bulk-editor',
@@ -53,15 +58,18 @@ export class BulkEditorComponent
correspondents: Correspondent[] correspondents: Correspondent[]
documentTypes: DocumentType[] documentTypes: DocumentType[]
storagePaths: StoragePath[] storagePaths: StoragePath[]
customFields: CustomField[]
tagSelectionModel = new FilterableDropdownSelectionModel() tagSelectionModel = new FilterableDropdownSelectionModel()
correspondentSelectionModel = new FilterableDropdownSelectionModel() correspondentSelectionModel = new FilterableDropdownSelectionModel()
documentTypeSelectionModel = new FilterableDropdownSelectionModel() documentTypeSelectionModel = new FilterableDropdownSelectionModel()
storagePathsSelectionModel = new FilterableDropdownSelectionModel() storagePathsSelectionModel = new FilterableDropdownSelectionModel()
customFieldsSelectionModel = new FilterableDropdownSelectionModel()
tagDocumentCounts: SelectionDataItem[] tagDocumentCounts: SelectionDataItem[]
correspondentDocumentCounts: SelectionDataItem[] correspondentDocumentCounts: SelectionDataItem[]
documentTypeDocumentCounts: SelectionDataItem[] documentTypeDocumentCounts: SelectionDataItem[]
storagePathDocumentCounts: SelectionDataItem[] storagePathDocumentCounts: SelectionDataItem[]
customFieldDocumentCounts: SelectionDataItem[]
awaitingDownload: boolean awaitingDownload: boolean
unsubscribeNotifier: Subject<any> = new Subject() unsubscribeNotifier: Subject<any> = new Subject()
@@ -83,6 +91,7 @@ export class BulkEditorComponent
private settings: SettingsService, private settings: SettingsService,
private toastService: ToastService, private toastService: ToastService,
private storagePathService: StoragePathService, private storagePathService: StoragePathService,
private customFieldService: CustomFieldsService,
private permissionService: PermissionsService private permissionService: PermissionsService
) { ) {
super() super()
@@ -164,6 +173,17 @@ export class BulkEditorComponent
.pipe(first()) .pipe(first())
.subscribe((result) => (this.storagePaths = result.results)) .subscribe((result) => (this.storagePaths = result.results))
} }
if (
this.permissionService.currentUserCan(
PermissionAction.View,
PermissionType.CustomField
)
) {
this.customFieldService
.listAll()
.pipe(first())
.subscribe((result) => (this.customFields = result.results))
}
this.downloadForm this.downloadForm
.get('downloadFileTypeArchive') .get('downloadFileTypeArchive')
@@ -192,12 +212,21 @@ export class BulkEditorComponent
this.unsubscribeNotifier.complete() this.unsubscribeNotifier.complete()
} }
private executeBulkOperation(modal, method: string, args) { private executeBulkOperation(
modal: NgbModalRef,
method: string,
args: any,
overrideDocumentIDs?: number[]
) {
if (modal) { if (modal) {
modal.componentInstance.buttonsEnabled = false modal.componentInstance.buttonsEnabled = false
} }
this.documentService this.documentService
.bulkEdit(Array.from(this.list.selected), method, args) .bulkEdit(
overrideDocumentIDs ?? Array.from(this.list.selected),
method,
args
)
.pipe(first()) .pipe(first())
.subscribe({ .subscribe({
next: () => { next: () => {
@@ -286,6 +315,19 @@ export class BulkEditorComponent
}) })
} }
openCustomFieldsDropdown() {
this.documentService
.getSelectionData(Array.from(this.list.selected))
.pipe(first())
.subscribe((s) => {
this.customFieldDocumentCounts = s.selected_custom_fields
this.applySelectionData(
s.selected_custom_fields,
this.customFieldsSelectionModel
)
})
}
private _localizeList(items: MatchingModel[]) { private _localizeList(items: MatchingModel[]) {
if (items.length == 0) { if (items.length == 0) {
return '' return ''
@@ -484,6 +526,74 @@ export class BulkEditorComponent
} }
} }
setCustomFields(changedCustomFields: ChangedItems) {
if (
changedCustomFields.itemsToAdd.length == 0 &&
changedCustomFields.itemsToRemove.length == 0
)
return
if (this.showConfirmationDialogs) {
let modal = this.modalService.open(ConfirmDialogComponent, {
backdrop: 'static',
})
modal.componentInstance.title = $localize`Confirm custom field assignment`
if (
changedCustomFields.itemsToAdd.length == 1 &&
changedCustomFields.itemsToRemove.length == 0
) {
let customField = changedCustomFields.itemsToAdd[0]
modal.componentInstance.message = $localize`This operation will assign the custom field "${customField.name}" to ${this.list.selected.size} selected document(s).`
} else if (
changedCustomFields.itemsToAdd.length > 1 &&
changedCustomFields.itemsToRemove.length == 0
) {
modal.componentInstance.message = $localize`This operation will assign the custom fields ${this._localizeList(
changedCustomFields.itemsToAdd
)} to ${this.list.selected.size} selected document(s).`
} else if (
changedCustomFields.itemsToAdd.length == 0 &&
changedCustomFields.itemsToRemove.length == 1
) {
let customField = changedCustomFields.itemsToRemove[0]
modal.componentInstance.message = $localize`This operation will remove the custom field "${customField.name}" from ${this.list.selected.size} selected document(s).`
} else if (
changedCustomFields.itemsToAdd.length == 0 &&
changedCustomFields.itemsToRemove.length > 1
) {
modal.componentInstance.message = $localize`This operation will remove the custom fields ${this._localizeList(
changedCustomFields.itemsToRemove
)} from ${this.list.selected.size} selected document(s).`
} else {
modal.componentInstance.message = $localize`This operation will assign the custom fields ${this._localizeList(
changedCustomFields.itemsToAdd
)} and remove the custom fields ${this._localizeList(
changedCustomFields.itemsToRemove
)} on ${this.list.selected.size} selected document(s).`
}
modal.componentInstance.btnClass = 'btn-warning'
modal.componentInstance.btnCaption = $localize`Confirm`
modal.componentInstance.confirmClicked
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe(() => {
this.executeBulkOperation(modal, 'modify_custom_fields', {
add_custom_fields: changedCustomFields.itemsToAdd.map((f) => f.id),
remove_custom_fields: changedCustomFields.itemsToRemove.map(
(f) => f.id
),
})
})
} else {
this.executeBulkOperation(null, 'modify_custom_fields', {
add_custom_fields: changedCustomFields.itemsToAdd.map((f) => f.id),
remove_custom_fields: changedCustomFields.itemsToRemove.map(
(f) => f.id
),
})
}
}
createTag(name: string) { createTag(name: string) {
let modal = this.modalService.open(TagEditDialogComponent, { let modal = this.modalService.open(TagEditDialogComponent, {
backdrop: 'static', backdrop: 'static',
@@ -570,6 +680,27 @@ export class BulkEditorComponent
}) })
} }
createCustomField(name: string) {
let modal = this.modalService.open(CustomFieldEditDialogComponent, {
backdrop: 'static',
})
modal.componentInstance.dialogMode = EditDialogMode.CREATE
modal.componentInstance.object = { name }
modal.componentInstance.succeeded
.pipe(
switchMap((newCustomField) => {
return this.customFieldService
.listAll()
.pipe(map((customFields) => ({ newCustomField, customFields })))
})
)
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe(({ newCustomField, customFields }) => {
this.customFields = customFields.results
this.customFieldsSelectionModel.toggle(newCustomField.id)
})
}
applyDelete() { applyDelete() {
let modal = this.modalService.open(ConfirmDialogComponent, { let modal = this.modalService.open(ConfirmDialogComponent, {
backdrop: 'static', backdrop: 'static',
@@ -641,4 +772,49 @@ export class BulkEditorComponent
} }
) )
} }
rotateSelected() {
let modal = this.modalService.open(RotateConfirmDialogComponent, {
backdrop: 'static',
})
const rotateDialog = modal.componentInstance as RotateConfirmDialogComponent
rotateDialog.title = $localize`Rotate confirm`
rotateDialog.messageBold = $localize`This operation will permanently rotate the original version of ${this.list.selected.size} document(s).`
rotateDialog.message = $localize`This will alter the original copy.`
rotateDialog.btnClass = 'btn-danger'
rotateDialog.btnCaption = $localize`Proceed`
rotateDialog.documentID = Array.from(this.list.selected)[0]
rotateDialog.confirmClicked
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe(() => {
rotateDialog.buttonsEnabled = false
this.executeBulkOperation(modal, 'rotate', {
degrees: rotateDialog.degrees,
})
})
}
mergeSelected() {
let modal = this.modalService.open(MergeConfirmDialogComponent, {
backdrop: 'static',
})
const mergeDialog = modal.componentInstance as MergeConfirmDialogComponent
mergeDialog.title = $localize`Merge confirm`
mergeDialog.messageBold = $localize`This operation will merge ${this.list.selected.size} selected documents into a new document.`
mergeDialog.btnCaption = $localize`Proceed`
mergeDialog.documentIDs = Array.from(this.list.selected)
mergeDialog.confirmClicked
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe(() => {
const args = {}
if (mergeDialog.metadataDocumentID > -1) {
args['metadata_document_id'] = mergeDialog.metadataDocumentID
}
mergeDialog.buttonsEnabled = false
this.executeBulkOperation(modal, 'merge', args, mergeDialog.documentIDs)
this.toastService.showInfo(
$localize`Merged document will be queued for consumption.`
)
})
}
} }

View File

@@ -15,7 +15,7 @@
<div class="card-body"> <div class="card-body">
<div class="d-flex justify-content-between align-items-center"> <div class="d-flex justify-content-between align-items-center">
<h5 class="card-title"> <h5 class="card-title">
@if (document.correspondent) { @if (displayFields.includes(DisplayField.CORRESPONDENT) && document.correspondent) {
@if (clickCorrespondent.observers.length ) { @if (clickCorrespondent.observers.length ) {
<a title="Filter by correspondent" i18n-title (click)="clickCorrespondent.emit(document.correspondent);$event.stopPropagation()" class="fw-bold btn-link">{{(document.correspondent$ | async)?.name}}</a> <a title="Filter by correspondent" i18n-title (click)="clickCorrespondent.emit(document.correspondent);$event.stopPropagation()" class="fw-bold btn-link">{{(document.correspondent$ | async)?.name}}</a>
} @else { } @else {
@@ -23,14 +23,18 @@
} }
: :
} }
@if (displayFields.includes(DisplayField.TITLE)) {
{{document.title | documentTitle}} {{document.title | documentTitle}}
}
@if (displayFields.includes(DisplayField.TAGS)) {
@for (t of document.tags$ | async; track t) { @for (t of document.tags$ | async; track t) {
<pngx-tag [tag]="t" linkTitle="Filter by tag" i18n-linkTitle class="ms-1" (click)="clickTag.emit(t.id);$event.stopPropagation()" [clickable]="clickTag.observers.length"></pngx-tag> <pngx-tag [tag]="t" linkTitle="Filter by tag" i18n-linkTitle class="ms-1" (click)="clickTag.emit(t.id);$event.stopPropagation()" [clickable]="clickTag.observers.length"></pngx-tag>
} }
}
</h5> </h5>
</div> </div>
<p class="card-text"> <p class="card-text">
@if (document.__search_hit__ && document.__search_hit__.highlights) { @if (document.__search_hit__?.score && document.__search_hit__.highlights) {
<span [innerHtml]="document.__search_hit__.highlights"></span> <span [innerHtml]="document.__search_hit__.highlights"></span>
} }
@for (highlight of searchNoteHighlights; track highlight) { @for (highlight of searchNoteHighlights; track highlight) {
@@ -39,7 +43,7 @@
<span [innerHtml]="highlight"></span> <span [innerHtml]="highlight"></span>
</span> </span>
} }
@if (!document.__search_hit__) { @if (!document.__search_hit__?.score) {
<span class="result-content">{{contentTrimmed}}</span> <span class="result-content">{{contentTrimmed}}</span>
} }
</p> </p>
@@ -66,28 +70,29 @@
</div> </div>
<div class="list-group list-group-horizontal border-0 card-info ms-md-auto mt-2 mt-md-0"> <div class="list-group list-group-horizontal border-0 card-info ms-md-auto mt-2 mt-md-0">
@if (notesEnabled && document.notes.length) { @if (displayFields.includes(DisplayField.NOTES) && notesEnabled && document.notes.length) {
<button routerLink="/documents/{{document.id}}/notes" class="list-group-item btn btn-sm bg-light text-dark p-1 border-0 me-2 d-flex align-items-center" title="View notes" i18n-title> <button routerLink="/documents/{{document.id}}/notes" class="list-group-item btn btn-sm bg-light text-dark p-1 border-0 me-2 d-flex align-items-center" title="View notes" i18n-title>
<i-bs width=".9em" height=".9em" class="me-2 text-muted" name="chat-left-text"></i-bs><small i18n>{{document.notes.length}} Notes</small> <i-bs width=".9em" height=".9em" class="me-2 text-muted" name="chat-left-text"></i-bs><small i18n>{{document.notes.length}} Notes</small>
</button> </button>
} }
@if (document.document_type) { @if (displayFields.includes(DisplayField.DOCUMENT_TYPE) && document.document_type) {
<button type="button" class="list-group-item btn btn-sm bg-light text-dark p-1 border-0 me-2 d-flex align-items-center" title="Filter by document type" i18n-title <button type="button" class="list-group-item btn btn-sm bg-light text-dark p-1 border-0 me-2 d-flex align-items-center" title="Filter by document type" i18n-title
(click)="clickDocumentType.emit(document.document_type);$event.stopPropagation()"> (click)="clickDocumentType.emit(document.document_type);$event.stopPropagation()">
<i-bs width=".9em" height=".9em" class="me-2 text-muted" name="file-earmark"></i-bs><small>{{(document.document_type$ | async)?.name}}</small> <i-bs width=".9em" height=".9em" class="me-2 text-muted" name="file-earmark"></i-bs><small>{{(document.document_type$ | async)?.name}}</small>
</button> </button>
} }
@if (document.storage_path) { @if (displayFields.includes(DisplayField.STORAGE_PATH) && document.storage_path) {
<button type="button" class="list-group-item btn btn-sm bg-light text-dark p-1 border-0 me-2 d-flex align-items-center" title="Filter by storage path" i18n-title <button type="button" class="list-group-item btn btn-sm bg-light text-dark p-1 border-0 me-2 d-flex align-items-center" title="Filter by storage path" i18n-title
(click)="clickStoragePath.emit(document.storage_path);$event.stopPropagation()"> (click)="clickStoragePath.emit(document.storage_path);$event.stopPropagation()">
<i-bs width=".9em" height=".9em" class="me-2 text-muted" name="archive"></i-bs><small>{{(document.storage_path$ | async)?.name}}</small> <i-bs width=".9em" height=".9em" class="me-2 text-muted" name="archive"></i-bs><small>{{(document.storage_path$ | async)?.name}}</small>
</button> </button>
} }
@if (document.archive_serial_number | isNumber) { @if (displayFields.includes(DisplayField.ASN) && document.archive_serial_number | isNumber) {
<div class="list-group-item me-2 bg-light text-dark p-1 border-0 d-flex align-items-center"> <div class="list-group-item me-2 bg-light text-dark p-1 border-0 d-flex align-items-center">
<i-bs width=".9em" height=".9em" class="me-2 text-muted" name="upc-scan"></i-bs><small>#{{document.archive_serial_number}}</small> <i-bs width=".9em" height=".9em" class="me-2 text-muted" name="upc-scan"></i-bs><small>#{{document.archive_serial_number}}</small>
</div> </div>
} }
@if (displayFields.includes(DisplayField.CREATED) || displayFields.includes(DisplayField.ADDED)) {
<ng-template #dateTooltip> <ng-template #dateTooltip>
<div class="d-flex flex-column text-light"> <div class="d-flex flex-column text-light">
<span i18n>Created: {{ document.created | customDate }}</span> <span i18n>Created: {{ document.created | customDate }}</span>
@@ -95,15 +100,23 @@
<span i18n>Modified: {{ document.modified | customDate }}</span> <span i18n>Modified: {{ document.modified | customDate }}</span>
</div> </div>
</ng-template> </ng-template>
@if (displayFields.includes(DisplayField.CREATED)) {
<div class="list-group-item bg-light text-dark p-1 border-0 d-flex align-items-center" [ngbTooltip]="dateTooltip"> <div class="list-group-item bg-light text-dark p-1 border-0 d-flex align-items-center" [ngbTooltip]="dateTooltip">
<i-bs width=".9em" height=".9em" class="me-2 text-muted" name="calendar-event"></i-bs><small>{{document.created_date | customDate:'mediumDate'}}</small> <i-bs width=".9em" height=".9em" class="me-2 text-muted" name="calendar-event"></i-bs><small>{{document.created_date | customDate:'mediumDate'}}</small>
</div> </div>
@if (document.owner && document.owner !== settingsService.currentUser.id) { }
@if (displayFields.includes(DisplayField.ADDED)) {
<div class="list-group-item bg-light text-dark p-1 border-0 d-flex align-items-center" [ngbTooltip]="dateTooltip">
<i-bs width=".9em" height=".9em" class="me-2 text-muted" name="calendar-event"></i-bs><small>{{document.added | customDate:'mediumDate'}}</small>
</div>
}
}
@if (displayFields.includes(DisplayField.OWNER) && document.owner && document.owner !== settingsService.currentUser.id) {
<div class="list-group-item bg-light text-dark p-1 border-0 d-flex align-items-center"> <div class="list-group-item bg-light text-dark p-1 border-0 d-flex align-items-center">
<i-bs width=".9em" height=".9em" class="me-2 text-muted" name="person-fill-lock"></i-bs><small>{{document.owner | username}}</small> <i-bs width=".9em" height=".9em" class="me-2 text-muted" name="person-fill-lock"></i-bs><small>{{document.owner | username}}</small>
</div> </div>
} }
@if (document.is_shared_by_requester) { @if (displayFields.includes(DisplayField.SHARED) && document.is_shared_by_requester) {
<div class="list-group-item bg-light text-dark p-1 border-0 d-flex align-items-center"> <div class="list-group-item bg-light text-dark p-1 border-0 d-flex align-items-center">
<i-bs width=".9em" height=".9em" class="me-2 text-muted" name="people-fill"></i-bs><small i18n>Shared</small> <i-bs width=".9em" height=".9em" class="me-2 text-muted" name="people-fill"></i-bs><small i18n>Shared</small>
</div> </div>
@@ -114,6 +127,16 @@
<ngb-progressbar [type]="searchScoreClass" [value]="document.__search_hit__.score" class="search-score-bar mx-2 mt-1" [max]="1"></ngb-progressbar> <ngb-progressbar [type]="searchScoreClass" [value]="document.__search_hit__.score" class="search-score-bar mx-2 mt-1" [max]="1"></ngb-progressbar>
</div> </div>
} }
@for (field of document.custom_fields; track field.id) {
@if (displayFields.includes(DisplayField.CUSTOM_FIELD + field.field)) {
<div class="list-group-item bg-light text-dark p-1 border-0 d-flex align-items-center">
<i-bs width=".9em" height=".9em" class="me-2 text-muted" name="ui-radios"></i-bs>
<small>
<pngx-custom-field-display [document]="document" [fieldId]="field.field"></pngx-custom-field-display>
</small>
</div>
}
}
</div> </div>
</div> </div>
</div> </div>

View File

@@ -21,6 +21,7 @@ import { DocumentCardLargeComponent } from './document-card-large.component'
import { IsNumberPipe } from 'src/app/pipes/is-number.pipe' import { IsNumberPipe } from 'src/app/pipes/is-number.pipe'
import { PreviewPopupComponent } from '../../common/preview-popup/preview-popup.component' import { PreviewPopupComponent } from '../../common/preview-popup/preview-popup.component'
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons' import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
import { CustomFieldDisplayComponent } from '../../common/custom-field-display/custom-field-display.component'
const doc = { const doc = {
id: 10, id: 10,
@@ -53,6 +54,7 @@ describe('DocumentCardLargeComponent', () => {
SafeUrlPipe, SafeUrlPipe,
IsNumberPipe, IsNumberPipe,
PreviewPopupComponent, PreviewPopupComponent,
CustomFieldDisplayComponent,
], ],
providers: [DatePipe], providers: [DatePipe],
imports: [ imports: [

View File

@@ -5,7 +5,11 @@ import {
Output, Output,
ViewChild, ViewChild,
} from '@angular/core' } from '@angular/core'
import { Document } from 'src/app/data/document' import {
DEFAULT_DISPLAY_FIELDS,
DisplayField,
Document,
} from 'src/app/data/document'
import { DocumentService } from 'src/app/services/rest/document.service' import { DocumentService } from 'src/app/services/rest/document.service'
import { SettingsService } from 'src/app/services/settings.service' import { SettingsService } from 'src/app/services/settings.service'
import { NgbPopover } from '@ng-bootstrap/ng-bootstrap' import { NgbPopover } from '@ng-bootstrap/ng-bootstrap'
@@ -18,6 +22,8 @@ import { ComponentWithPermissions } from '../../with-permissions/with-permission
styleUrls: ['./document-card-large.component.scss'], styleUrls: ['./document-card-large.component.scss'],
}) })
export class DocumentCardLargeComponent extends ComponentWithPermissions { export class DocumentCardLargeComponent extends ComponentWithPermissions {
DisplayField = DisplayField
constructor( constructor(
private documentService: DocumentService, private documentService: DocumentService,
public settingsService: SettingsService public settingsService: SettingsService
@@ -28,6 +34,9 @@ export class DocumentCardLargeComponent extends ComponentWithPermissions {
@Input() @Input()
selected = false selected = false
@Input()
displayFields: string[] = DEFAULT_DISPLAY_FIELDS.map((f) => f.id)
@Output() @Output()
toggleSelected = new EventEmitter() toggleSelected = new EventEmitter()

View File

@@ -10,6 +10,7 @@
</div> </div>
</div> </div>
@if (displayFields?.includes(DisplayField.TAGS)) {
<div class="tags d-flex flex-column text-end position-absolute me-1 fs-6"> <div class="tags d-flex flex-column text-end position-absolute me-1 fs-6">
@for (t of getTagsLimited$() | async; track t) { @for (t of getTagsLimited$() | async; track t) {
<pngx-tag [tag]="t" (click)="clickTag.emit(t.id);$event.stopPropagation()" [clickable]="true" linkTitle="Toggle tag filter" i18n-linkTitle></pngx-tag> <pngx-tag [tag]="t" (click)="clickTag.emit(t.id);$event.stopPropagation()" [clickable]="true" linkTitle="Toggle tag filter" i18n-linkTitle></pngx-tag>
@@ -20,9 +21,10 @@
</div> </div>
} }
</div> </div>
}
</div> </div>
@if (notesEnabled && document.notes.length) { @if (displayFields.includes(DisplayField.NOTES) && notesEnabled && document.notes.length) {
<a routerLink="/documents/{{document.id}}/notes" class="document-card-notes py-2 px-1"> <a routerLink="/documents/{{document.id}}/notes" class="document-card-notes py-2 px-1">
<span class="badge rounded-pill bg-light border text-primary"> <span class="badge rounded-pill bg-light border text-primary">
<i-bs width="1.2em" height="1.2em" class="ms-1 me-1" name="chat-left-text"></i-bs> <i-bs width="1.2em" height="1.2em" class="ms-1 me-1" name="chat-left-text"></i-bs>
@@ -32,28 +34,31 @@
<div class="card-body bg-light p-2"> <div class="card-body bg-light p-2">
<p class="card-text"> <p class="card-text">
@if (document.correspondent) { @if (displayFields.includes(DisplayField.CORRESPONDENT) && 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 ?? privateName}}</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>:
} }
@if (displayFields.includes(DisplayField.TITLE)) {
{{document.title | documentTitle}} {{document.title | documentTitle}}
}
</p> </p>
</div> </div>
<div class="card-footer pt-0 pb-2 px-2"> <div class="card-footer pt-0 pb-2 px-2">
<div class="list-group list-group-flush border-0 pt-1 pb-2 card-info"> <div class="list-group list-group-flush border-0 pt-1 pb-2 card-info">
@if (document.document_type) { @if (displayFields.includes(DisplayField.DOCUMENT_TYPE) && document.document_type) {
<button type="button" class="list-group-item list-group-item-action bg-transparent ps-0 p-1 border-0" title="Toggle document type filter" i18n-title <button type="button" class="list-group-item list-group-item-action bg-transparent ps-0 p-1 border-0" title="Toggle document type filter" i18n-title
(click)="clickDocumentType.emit(document.document_type);$event.stopPropagation()"> (click)="clickDocumentType.emit(document.document_type);$event.stopPropagation()">
<i-bs width="1em" height="1em" class="me-2 text-muted" name="file-earmark"></i-bs> <i-bs width="1em" height="1em" class="me-2 text-muted" name="file-earmark"></i-bs>
<small>{{(document.document_type$ | async)?.name ?? privateName}}</small> <small>{{(document.document_type$ | async)?.name ?? privateName}}</small>
</button> </button>
} }
@if (document.storage_path) { @if (displayFields.includes(DisplayField.STORAGE_PATH) && document.storage_path) {
<button 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 <button 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()"> (click)="clickStoragePath.emit(document.storage_path);$event.stopPropagation()">
<i-bs width="1em" height="1em" class="me-2 text-muted" name="folder"></i-bs> <i-bs width="1em" height="1em" class="me-2 text-muted" name="folder"></i-bs>
<small>{{(document.storage_path$ | async)?.name ?? privateName}}</small> <small>{{(document.storage_path$ | async)?.name ?? privateName}}</small>
</button> </button>
} }
@if (displayFields.includes(DisplayField.CREATED)) {
<div class="list-group-item bg-transparent p-0 border-0 d-flex flex-wrap-reverse justify-content-between"> <div class="list-group-item bg-transparent p-0 border-0 d-flex flex-wrap-reverse justify-content-between">
<ng-template #dateTooltip> <ng-template #dateTooltip>
<div class="d-flex flex-column text-light"> <div class="d-flex flex-column text-light">
@@ -64,27 +69,51 @@
</ng-template> </ng-template>
<div class="ps-0 p-1" placement="top" [ngbTooltip]="dateTooltip"> <div class="ps-0 p-1" placement="top" [ngbTooltip]="dateTooltip">
<i-bs width="1em" height="1em" class="me-2 text-muted" name="calendar-event"></i-bs> <i-bs width="1em" height="1em" class="me-2 text-muted" name="calendar-event"></i-bs>
<small>{{document.created_date | customDate:'mediumDate'}}</small> <small>{{document.created | customDate:'mediumDate'}}</small>
</div> </div>
</div> </div>
@if (document.archive_serial_number | isNumber) { }
@if (displayFields.includes(DisplayField.ADDED)) {
<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 text-light">
<span i18n>Created: {{ document.created | customDate }}</span>
<span i18n>Added: {{ document.added | customDate }}</span>
<span i18n>Modified: {{ document.modified | customDate }}</span>
</div>
</ng-template>
<div class="ps-0 p-1" placement="top" [ngbTooltip]="dateTooltip">
<i-bs width="1em" height="1em" class="me-2 text-muted" name="calendar-event"></i-bs>
<small>{{document.added | customDate:'mediumDate'}}</small>
</div>
</div>
}
@if (displayFields.includes(DisplayField.ASN) && document.archive_serial_number | isNumber) {
<div class="ps-0 p-1"> <div class="ps-0 p-1">
<i-bs width="1em" height="1em" class="me-2 text-muted" name="upc-scan"></i-bs> <i-bs width="1em" height="1em" class="me-2 text-muted" name="upc-scan"></i-bs>
<small>#{{document.archive_serial_number}}</small> <small>#{{document.archive_serial_number}}</small>
</div> </div>
} }
@if (document.owner && document.owner !== settingsService.currentUser.id) { @if (displayFields.includes(DisplayField.OWNER) && document.owner && document.owner !== settingsService.currentUser.id) {
<div class="ps-0 p-1"> <div class="ps-0 p-1">
<i-bs width="1em" height="1em" class="me-2 text-muted" name="person-fill-lock"></i-bs> <i-bs width="1em" height="1em" class="me-2 text-muted" name="person-fill-lock"></i-bs>
<small>{{document.owner | username}}</small> <small>{{document.owner | username}}</small>
</div> </div>
} }
@if (document.is_shared_by_requester) { @if (displayFields.includes(DisplayField.SHARED) && document.is_shared_by_requester) {
<div class="ps-0 p-1"> <div class="ps-0 p-1">
<i-bs width="1em" height="1em" class="me-2 text-muted" name="people-fill"></i-bs> <i-bs width="1em" height="1em" class="me-2 text-muted" name="people-fill"></i-bs>
<small i18n>Shared</small> <small i18n>Shared</small>
</div> </div>
} }
@for (field of document.custom_fields; track field.id) {
@if (displayFields.includes(DisplayField.CUSTOM_FIELD + field.field)) {
<div class="ps-0 p-1 d-flex align-items-center overflow-hidden">
<i-bs width="1em" height="1em" class="me-2 text-muted" name="ui-radios"></i-bs>
<small><pngx-custom-field-display [document]="document" [fieldId]="field.field"></pngx-custom-field-display></small>
</div>
}
}
</div> </div>
<div class="d-flex justify-content-between align-items-center"> <div class="d-flex justify-content-between align-items-center">
<div class="btn-group w-100"> <div class="btn-group w-100">

View File

@@ -24,6 +24,7 @@ import { Tag } from 'src/app/data/tag'
import { IsNumberPipe } from 'src/app/pipes/is-number.pipe' import { IsNumberPipe } from 'src/app/pipes/is-number.pipe'
import { PreviewPopupComponent } from '../../common/preview-popup/preview-popup.component' import { PreviewPopupComponent } from '../../common/preview-popup/preview-popup.component'
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons' import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
import { CustomFieldDisplayComponent } from '../../common/custom-field-display/custom-field-display.component'
const doc = { const doc = {
id: 10, id: 10,
@@ -67,6 +68,7 @@ describe('DocumentCardSmallComponent', () => {
TagComponent, TagComponent,
IsNumberPipe, IsNumberPipe,
PreviewPopupComponent, PreviewPopupComponent,
CustomFieldDisplayComponent,
], ],
providers: [DatePipe], providers: [DatePipe],
imports: [ imports: [

View File

@@ -6,7 +6,11 @@ import {
ViewChild, ViewChild,
} from '@angular/core' } from '@angular/core'
import { map } from 'rxjs/operators' import { map } from 'rxjs/operators'
import { Document } from 'src/app/data/document' import {
DEFAULT_DISPLAY_FIELDS,
DisplayField,
Document,
} from 'src/app/data/document'
import { DocumentService } from 'src/app/services/rest/document.service' import { DocumentService } from 'src/app/services/rest/document.service'
import { SettingsService } from 'src/app/services/settings.service' import { SettingsService } from 'src/app/services/settings.service'
import { NgbPopover } from '@ng-bootstrap/ng-bootstrap' import { NgbPopover } from '@ng-bootstrap/ng-bootstrap'
@@ -19,6 +23,8 @@ import { ComponentWithPermissions } from '../../with-permissions/with-permission
styleUrls: ['./document-card-small.component.scss'], styleUrls: ['./document-card-small.component.scss'],
}) })
export class DocumentCardSmallComponent extends ComponentWithPermissions { export class DocumentCardSmallComponent extends ComponentWithPermissions {
DisplayField = DisplayField
constructor( constructor(
private documentService: DocumentService, private documentService: DocumentService,
public settingsService: SettingsService public settingsService: SettingsService
@@ -35,6 +41,9 @@ export class DocumentCardSmallComponent extends ComponentWithPermissions {
@Input() @Input()
document: Document document: Document
@Input()
displayFields: string[] = DEFAULT_DISPLAY_FIELDS.map((f) => f.id)
@Output() @Output()
dblClickDocument = new EventEmitter() dblClickDocument = new EventEmitter()

View File

@@ -11,16 +11,32 @@
<button ngbDropdownItem (click)="list.selectAll()" i18n>Select all</button> <button ngbDropdownItem (click)="list.selectAll()" i18n>Select all</button>
</div> </div>
</div> </div>
<div ngbDropdown class="d-flex">
<button class="btn btn-sm btn-outline-primary" id="dropdownDisplayFields" ngbDropdownToggle>
<i-bs name="card-heading"></i-bs>
<div class="d-none d-sm-inline">&nbsp;<ng-container i18n>Show</ng-container></div>
</button>
<div ngbDropdownMenu aria-labelledby="dropdownDisplayFields" class="shadow">
<div class="px-3">
@for (field of settingsService.allDisplayFields; track field.id) {
<div class="form-check my-1">
<input class="form-check-input mt-1" type="checkbox" id="displayField{{field.id}}" [checked]="activeDisplayFields.includes(field.id)" (change)="toggleDisplayField(field.id)">
<label class="form-check-label" for="displayField{{field.id}}">{{field.name}}</label>
</div>
}
</div>
</div>
</div>
<div class="btn-group flex-fill" role="group"> <div class="btn-group flex-fill" role="group">
<input type="radio" class="btn-check" [(ngModel)]="displayMode" value="details" (ngModelChange)="saveDisplayMode()" id="displayModeDetails" name="displayModeDetails"> <input type="radio" class="btn-check" [(ngModel)]="list.displayMode" value="table" id="displayModeDetails" name="displayModeDetails">
<label for="displayModeDetails" class="btn btn-outline-primary btn-sm"> <label for="displayModeDetails" class="btn btn-outline-primary btn-sm">
<i-bs name="list-ul"></i-bs> <i-bs name="list-ul"></i-bs>
</label> </label>
<input type="radio" class="btn-check" [(ngModel)]="displayMode" value="smallCards" (ngModelChange)="saveDisplayMode()" id="displayModeSmall" name="displayModeSmall"> <input type="radio" class="btn-check" [(ngModel)]="list.displayMode" value="smallCards" id="displayModeSmall" name="displayModeSmall">
<label for="displayModeSmall" class="btn btn-outline-primary btn-sm"> <label for="displayModeSmall" class="btn btn-outline-primary btn-sm">
<i-bs name="grid"></i-bs> <i-bs name="grid"></i-bs>
</label> </label>
<input type="radio" class="btn-check" [(ngModel)]="displayMode" value="largeCards" (ngModelChange)="saveDisplayMode()" id="displayModeLarge" name="displayModeLarge"> <input type="radio" class="btn-check" [(ngModel)]="list.displayMode" value="largeCards" id="displayModeLarge" name="displayModeLarge">
<label for="displayModeLarge" class="btn btn-outline-primary btn-sm"> <label for="displayModeLarge" class="btn btn-outline-primary btn-sm">
<i-bs name="hdd-stack"></i-bs> <i-bs name="hdd-stack"></i-bs>
</label> </label>
@@ -41,7 +57,7 @@
</div> </div>
<div> <div>
@for (f of getSortFields(); track f) { @for (f of getSortFields(); track f) {
<button ngbDropdownItem (click)="setSortField(f.field)" <button ngbDropdownItem (click)="list.sortField = f.field"
[class.active]="list.sortField === f.field">{{f.name}} [class.active]="list.sortField === f.field">{{f.name}}
</button> </button>
} }
@@ -99,7 +115,7 @@
@if (list.selected.size === 0) { @if (list.selected.size === 0) {
<span i18n>{list.collectionSize, plural, =1 {One document} other {{{list.collectionSize || 0}} documents}}</span> <span i18n>{list.collectionSize, plural, =1 {One document} other {{{list.collectionSize || 0}} documents}}</span>
}&nbsp;@if (isFiltered) { }&nbsp;@if (isFiltered) {
<span i18n>(filtered)</span> &nbsp;<span i18n>(filtered)</span>
} }
} }
@if (!list.isReloading && isFiltered) { @if (!list.isReloading && isFiltered) {
@@ -109,7 +125,7 @@
} }
</div> </div>
@if (list.collectionSize) { @if (list.collectionSize) {
<ngb-pagination [pageSize]="list.currentPageSize" [collectionSize]="list.collectionSize" [(page)]="list.currentPage" [maxSize]="5" <ngb-pagination [pageSize]="list.pageSize" [collectionSize]="list.collectionSize" [(page)]="list.currentPage" [maxSize]="5"
[rotate]="true" aria-label="Default pagination" size="sm"></ngb-pagination> [rotate]="true" aria-label="Default pagination" size="sm"></ngb-pagination>
} }
</div> </div>
@@ -122,18 +138,29 @@
@if (list.error ) { @if (list.error ) {
<div class="alert alert-danger" role="alert"><ng-container i18n>Error while loading documents</ng-container>: {{list.error}}</div> <div class="alert alert-danger" role="alert"><ng-container i18n>Error while loading documents</ng-container>: {{list.error}}</div>
} @else { } @else {
@if (displayMode === 'largeCards') { @if (list.displayMode === DisplayMode.LARGE_CARDS) {
<div> <div>
@for (d of list.documents; track trackByDocumentId($index, d)) { @for (d of list.documents; track trackByDocumentId($index, d)) {
<pngx-document-card-large [selected]="list.isSelected(d)" (toggleSelected)="toggleSelected(d, $event)" (dblClickDocument)="openDocumentDetail(d)" [document]="d" (clickTag)="clickTag($event)" (clickCorrespondent)="clickCorrespondent($event)" (clickDocumentType)="clickDocumentType($event)" (clickStoragePath)="clickStoragePath($event)" (clickMoreLike)="clickMoreLike(d.id)"> <pngx-document-card-large
[selected]="list.isSelected(d)"
(toggleSelected)="toggleSelected(d, $event)"
(dblClickDocument)="openDocumentDetail(d)"
[document]="d"
[displayFields]="activeDisplayFields"
(clickTag)="clickTag($event)"
(clickCorrespondent)="clickCorrespondent($event)"
(clickDocumentType)="clickDocumentType($event)"
(clickStoragePath)="clickStoragePath($event)"
(clickMoreLike)="clickMoreLike(d.id)">
</pngx-document-card-large> </pngx-document-card-large>
} }
</div> </div>
} }
@if (displayMode === 'details') { @if (list.displayMode === DisplayMode.TABLE) {
<table class="table table-sm align-middle border shadow-sm"> <table class="table table-sm align-middle border shadow-sm">
<thead> <thead>
<th></th> <th></th>
@if (activeDisplayFields.includes(DisplayField.ASN)) {
<th class="d-none d-lg-table-cell" <th class="d-none d-lg-table-cell"
pngxSortable="archive_serial_number" pngxSortable="archive_serial_number"
title="Sort by ASN" i18n-title title="Sort by ASN" i18n-title
@@ -141,6 +168,8 @@
[currentSortReverse]="list.sortReverse" [currentSortReverse]="list.sortReverse"
(sort)="onSort($event)" (sort)="onSort($event)"
i18n>ASN</th> i18n>ASN</th>
}
@if (activeDisplayFields.includes(DisplayField.CORRESPONDENT) && permissionService.currentUserCan(PermissionAction.View, PermissionType.Correspondent)) {
<th class="d-none d-md-table-cell" <th class="d-none d-md-table-cell"
pngxSortable="correspondent__name" pngxSortable="correspondent__name"
title="Sort by correspondent" i18n-title title="Sort by correspondent" i18n-title
@@ -148,14 +177,20 @@
[currentSortReverse]="list.sortReverse" [currentSortReverse]="list.sortReverse"
(sort)="onSort($event)" (sort)="onSort($event)"
i18n>Correspondent</th> i18n>Correspondent</th>
}
@if (activeDisplayFields.includes(DisplayField.TITLE)) {
<th <th
pngxSortable="title" pngxSortable="title"
title="Sort by title" i18n-title title="Sort by title" i18n-title
class="w-40"
[currentSortField]="list.sortField" [currentSortField]="list.sortField"
[currentSortReverse]="list.sortReverse" [currentSortReverse]="list.sortReverse"
(sort)="onSort($event)" (sort)="onSort($event)"
i18n>Title</th> i18n>Title</th>
}
@if (activeDisplayFields.includes(DisplayField.TAGS) && !activeDisplayFields.includes(DisplayField.TITLE)) {
<th i18n>Tags</th>
}
@if (activeDisplayFields.includes(DisplayField.OWNER) && permissionService.currentUserCan(PermissionAction.View, PermissionType.User)) {
<th class="d-none d-xl-table-cell" <th class="d-none d-xl-table-cell"
pngxSortable="owner" pngxSortable="owner"
title="Sort by owner" i18n-title title="Sort by owner" i18n-title
@@ -163,7 +198,8 @@
[currentSortReverse]="list.sortReverse" [currentSortReverse]="list.sortReverse"
(sort)="onSort($event)" (sort)="onSort($event)"
i18n>Owner</th> i18n>Owner</th>
@if (notesEnabled) { }
@if (activeDisplayFields.includes(DisplayField.NOTES) && notesEnabled) {
<th class="d-none d-xl-table-cell" <th class="d-none d-xl-table-cell"
pngxSortable="num_notes" pngxSortable="num_notes"
title="Sort by notes" i18n-title title="Sort by notes" i18n-title
@@ -172,6 +208,7 @@
(sort)="onSort($event)" (sort)="onSort($event)"
i18n>Notes</th> i18n>Notes</th>
} }
@if (activeDisplayFields.includes(DisplayField.DOCUMENT_TYPE) && permissionService.currentUserCan(PermissionAction.View, PermissionType.DocumentType)) {
<th class="d-none d-xl-table-cell" <th class="d-none d-xl-table-cell"
pngxSortable="document_type__name" pngxSortable="document_type__name"
title="Sort by document type" i18n-title title="Sort by document type" i18n-title
@@ -179,6 +216,8 @@
[currentSortReverse]="list.sortReverse" [currentSortReverse]="list.sortReverse"
(sort)="onSort($event)" (sort)="onSort($event)"
i18n>Document type</th> i18n>Document type</th>
}
@if (activeDisplayFields.includes(DisplayField.STORAGE_PATH) && permissionService.currentUserCan(PermissionAction.View, PermissionType.StoragePath)) {
<th class="d-none d-xl-table-cell" <th class="d-none d-xl-table-cell"
pngxSortable="storage_path__name" pngxSortable="storage_path__name"
title="Sort by storage path" i18n-title title="Sort by storage path" i18n-title
@@ -186,6 +225,8 @@
[currentSortReverse]="list.sortReverse" [currentSortReverse]="list.sortReverse"
(sort)="onSort($event)" (sort)="onSort($event)"
i18n>Storage path</th> i18n>Storage path</th>
}
@if (activeDisplayFields.includes(DisplayField.CREATED)) {
<th <th
pngxSortable="created" pngxSortable="created"
title="Sort by created date" i18n-title title="Sort by created date" i18n-title
@@ -193,13 +234,26 @@
[currentSortReverse]="list.sortReverse" [currentSortReverse]="list.sortReverse"
(sort)="onSort($event)" (sort)="onSort($event)"
i18n>Created</th> i18n>Created</th>
<th class="d-none d-xl-table-cell" }
@if (activeDisplayFields.includes(DisplayField.ADDED)) {
<th
pngxSortable="added" pngxSortable="added"
title="Sort by added date" i18n-title title="Sort by added date" i18n-title
[currentSortField]="list.sortField" [currentSortField]="list.sortField"
[currentSortReverse]="list.sortReverse" [currentSortReverse]="list.sortReverse"
(sort)="onSort($event)" (sort)="onSort($event)"
i18n>Added</th> i18n>Added</th>
}
@if (activeDisplayFields.includes(DisplayField.SHARED)) {
<th i18n>
Shared
</th>
}
@for (field of activeDisplayCustomFields; track field) {
<th>
{{getDisplayCustomFieldTitle(field)}}
</th>
}
</thead> </thead>
<tbody> <tbody>
@for (d of list.documents; track trackByDocumentId($index, d)) { @for (d of list.documents; track trackByDocumentId($index, d)) {
@@ -210,24 +264,36 @@
<label class="form-check-label" for="docCheck{{d.id}}"></label> <label class="form-check-label" for="docCheck{{d.id}}"></label>
</div> </div>
</td> </td>
<td class="d-none d-lg-table-cell"> @if (activeDisplayFields.includes(DisplayField.ASN)) {
<td class="d-none d-xl-table-cell">
{{d.archive_serial_number}} {{d.archive_serial_number}}
</td> </td>
<td class="d-none d-md-table-cell"> }
@if (activeDisplayFields.includes(DisplayField.CORRESPONDENT) && permissionService.currentUserCan(PermissionAction.View, PermissionType.Correspondent)) {
<td class="d-none d-xl-table-cell">
@if (d.correspondent) { @if (d.correspondent) {
<a (click)="clickCorrespondent(d.correspondent);$event.stopPropagation()" title="Filter by correspondent" i18n-title>{{(d.correspondent$ | async)?.name}}</a> <a (click)="clickCorrespondent(d.correspondent);$event.stopPropagation()" title="Filter by correspondent" i18n-title>{{(d.correspondent$ | async)?.name}}</a>
} }
</td> </td>
}
@if (activeDisplayFields.includes(DisplayField.TITLE) || activeDisplayFields.includes(DisplayField.TAGS)) {
<td> <td>
@if (activeDisplayFields.includes(DisplayField.TITLE)) {
<a routerLink="/documents/{{d.id}}" title="Edit document" i18n-title style="overflow-wrap: anywhere;">{{d.title | documentTitle}}</a> <a routerLink="/documents/{{d.id}}" title="Edit document" i18n-title style="overflow-wrap: anywhere;">{{d.title | documentTitle}}</a>
}
@if (activeDisplayFields.includes(DisplayField.TAGS)) {
@for (t of d.tags$ | async; track t) { @for (t of d.tags$ | async; track t) {
<pngx-tag [tag]="t" class="ms-1" clickable="true" linkTitle="Filter by tag" i18n-linkTitle (click)="clickTag(t.id);$event.stopPropagation()"></pngx-tag> <pngx-tag [tag]="t" class="ms-1" clickable="true" linkTitle="Filter by tag" i18n-linkTitle (click)="clickTag(t.id);$event.stopPropagation()"></pngx-tag>
} }
}
</td> </td>
}
@if (activeDisplayFields.includes(DisplayField.OWNER) && permissionService.currentUserCan(PermissionAction.View, PermissionType.User)) {
<td> <td>
{{d.owner | username}} {{d.owner | username}}
</td> </td>
@if (notesEnabled) { }
@if (activeDisplayFields.includes(DisplayField.NOTES) && notesEnabled) {
<td class="d-none d-xl-table-cell"> <td class="d-none d-xl-table-cell">
@if (d.notes.length) { @if (d.notes.length) {
<a routerLink="/documents/{{d.id}}/notes" class="btn btn-sm p-0"> <a routerLink="/documents/{{d.id}}/notes" class="btn btn-sm p-0">
@@ -238,31 +304,59 @@
} }
</td> </td>
} }
@if (activeDisplayFields.includes(DisplayField.DOCUMENT_TYPE) && permissionService.currentUserCan(PermissionAction.View, PermissionType.DocumentType)) {
<td class="d-none d-xl-table-cell"> <td class="d-none d-xl-table-cell">
@if (d.document_type) { @if (d.document_type) {
<a (click)="clickDocumentType(d.document_type);$event.stopPropagation()" title="Filter by document type" i18n-title>{{(d.document_type$ | async)?.name}}</a> <a (click)="clickDocumentType(d.document_type);$event.stopPropagation()" title="Filter by document type" i18n-title>{{(d.document_type$ | async)?.name}}</a>
} }
</td> </td>
}
@if (activeDisplayFields.includes(DisplayField.STORAGE_PATH) && permissionService.currentUserCan(PermissionAction.View, PermissionType.StoragePath)) {
<td class="d-none d-xl-table-cell"> <td class="d-none d-xl-table-cell">
@if (d.storage_path) { @if (d.storage_path) {
<a (click)="clickStoragePath(d.storage_path);$event.stopPropagation()" title="Filter by storage path" i18n-title>{{(d.storage_path$ | async)?.name}}</a> <a (click)="clickStoragePath(d.storage_path);$event.stopPropagation()" title="Filter by storage path" i18n-title>{{(d.storage_path$ | async)?.name}}</a>
} }
</td> </td>
}
@if (activeDisplayFields.includes(DisplayField.CREATED)) {
<td> <td>
{{d.created_date | customDate}} {{d.created_date | customDate}}
</td> </td>
<td class="d-none d-xl-table-cell"> }
@if (activeDisplayFields.includes(DisplayField.ADDED)) {
<td>
{{d.added | customDate}} {{d.added | customDate}}
</td> </td>
}
@if (activeDisplayFields.includes(DisplayField.SHARED)) {
<td>
@if (d.is_shared_by_requester) { <ng-container i18n>Yes</ng-container> } @else { <ng-container i18n>No</ng-container> }
</td>
}
@for (field of activeDisplayCustomFields; track field) {
<td class="d-none d-xl-table-cell">
<pngx-custom-field-display [document]="d" [fieldDisplayKey]="field"></pngx-custom-field-display>
</td>
}
</tr> </tr>
} }
</tbody> </tbody>
</table> </table>
} }
@if (displayMode === 'smallCards') { @if (list.displayMode === DisplayMode.SMALL_CARDS) {
<div class="row row-cols-paperless-cards"> <div class="row row-cols-paperless-cards">
@for (d of list.documents; track trackByDocumentId($index, d)) { @for (d of list.documents; track trackByDocumentId($index, d)) {
<pngx-document-card-small class="p-0" [selected]="list.isSelected(d)" (toggleSelected)="toggleSelected(d, $event)" (dblClickDocument)="openDocumentDetail(d)" [document]="d" (clickTag)="clickTag($event)" (clickCorrespondent)="clickCorrespondent($event)" (clickStoragePath)="clickStoragePath($event)" (clickDocumentType)="clickDocumentType($event)"></pngx-document-card-small> <pngx-document-card-small class="p-0"
[selected]="list.isSelected(d)"
(toggleSelected)="toggleSelected(d, $event)"
(dblClickDocument)="openDocumentDetail(d)"
[document]="d"
(clickTag)="clickTag($event)"
[displayFields]="activeDisplayFields"
(clickCorrespondent)="clickCorrespondent($event)"
(clickStoragePath)="clickStoragePath($event)"
(clickDocumentType)="clickDocumentType($event)">
</pngx-document-card-small>
} }
</div> </div>
} }

View File

@@ -10,10 +10,6 @@ th {
cursor: pointer; cursor: pointer;
} }
th.w-40 {
width: 40%;
}
.table-row-selected { .table-row-selected {
background-color: var(--pngx-primary-faded); background-color: var(--pngx-primary-faded);
} }
@@ -84,3 +80,7 @@ $paperless-card-breakpoints: (
a { a {
cursor: pointer; cursor: pointer;
} }
pngx-page-header .dropdown-menu {
--bs-dropdown-min-width: 12em;
}

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