Compare commits

..

37 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
145 changed files with 7062 additions and 2771 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.5' 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.3.0 rev: 24.4.0
hooks: hooks:
- id: black - id: black
# Dockerfile hooks # Dockerfile hooks

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,7 +14,7 @@ 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.0" djangorestframework = "==3.14.0"
@@ -22,7 +22,7 @@ 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 = "*"

760
Pipfile.lock generated
View File

@@ -1,7 +1,7 @@
{ {
"_meta": { "_meta": {
"hash": { "hash": {
"sha256": "81e90e43e2782425b1f3edb9fab7b5ddf9a81b5adc211b1102d9a584e9dc000d" "sha256": "e1ac865502fd8502e8534557675cc36133252871db228d97a3c0c151891600f3"
}, },
"pipfile-spec": 6, "pipfile-spec": 6,
"requires": {}, "requires": {},
@@ -230,12 +230,12 @@
}, },
"channels": { "channels": {
"hashes": [ "hashes": [
"sha256:0ce53507a7da7b148eaa454526e0e05f7da5e5d1c23440e4886cf146981d8420", "sha256:a3c4419307f582c3f71d67bfb6eff748ae819c2f360b9b141694d84f242baa48",
"sha256:2253334ac76f67cba68c2072273f7e0e67dbdac77eeb7e318f511d2f9a53c5e4" "sha256:e0ed375719f5c1851861f05ed4ce78b0166f9245ca0ecd836cb77d4bb531489d"
], ],
"index": "pypi", "index": "pypi",
"markers": "python_version >= '3.7'", "markers": "python_version >= '3.8'",
"version": "==4.0.0" "version": "==4.1.0"
}, },
"channels-redis": { "channels-redis": {
"hashes": [ "hashes": [
@@ -479,12 +479,12 @@
}, },
"django-auditlog": { "django-auditlog": {
"hashes": [ "hashes": [
"sha256:7bc2c87e4aff62dec9785d1b2359a2b27148f8c286f8a52b9114fc7876c5a9f7", "sha256:92db1cf4a51ceca5c26b3ff46997d9e3305a02da1bd435e2efb5b8b6d300ce1f",
"sha256:b9d3acebb64f3f2785157efe3f2f802e0929aafc579d85bbfb9827db4adab532" "sha256:9de49f80a4911135d136017123cd73461f869b4947eec14d5e76db4b88182f3f"
], ],
"index": "pypi", "index": "pypi",
"markers": "python_version >= '3.7'", "markers": "python_version >= '3.8'",
"version": "==2.3.0" "version": "==3.0.0"
}, },
"django-celery-results": { "django-celery-results": {
"hashes": [ "hashes": [
@@ -522,12 +522,12 @@
}, },
"django-filter": { "django-filter": {
"hashes": [ "hashes": [
"sha256:335bcae6cbd3e984b024841070f567b22faea57594f27d37c52f8f131f8d8621", "sha256:48e5fc1da3ccd6ca0d5f9bb550973518ce977a4edde9d2a8a154a7f4f0b9f96e",
"sha256:65cb43ce272077e5ac6aae1054d76c121cd6b552e296a82a13921e9371baf8c1" "sha256:df2ee9857e18d38bed203c8745f62a803fa0f31688c9fe6f8e868120b1848e48"
], ],
"index": "pypi", "index": "pypi",
"markers": "python_version >= '3.8'", "markers": "python_version >= '3.8'",
"version": "==24.1" "version": "==24.2"
}, },
"django-guardian": { "django-guardian": {
"hashes": [ "hashes": [
@@ -581,12 +581,12 @@
}, },
"filelock": { "filelock": {
"hashes": [ "hashes": [
"sha256:5ffa845303983e7a0b7ae17636509bc97997d58afeafa72fb141a17b152284cb", "sha256:404e5e9253aa60ad457cae1be07c0f0ca90a63931200a47d9b6a6af84fd7b45f",
"sha256:a79895a25bbefdf55d1a2a0a80968f7dbb28edcd6d4234a0afb3f37ecde4b546" "sha256:d13f466618bfde72bd2c18255e269f72542c6e70e7bac83a0232d6b1cc5c8cf4"
], ],
"index": "pypi", "index": "pypi",
"markers": "python_version >= '3.8'", "markers": "python_version >= '3.8'",
"version": "==3.13.3" "version": "==3.13.4"
}, },
"flower": { "flower": {
"hashes": [ "hashes": [
@@ -608,12 +608,12 @@
}, },
"gunicorn": { "gunicorn": {
"hashes": [ "hashes": [
"sha256:3213aa5e8c24949e792bcacfc176fef362e7aac80b76c56f6b5122bf350722f0", "sha256:350679f91b24062c86e386e198a15438d53a7a8207235a78ba1b53df4c4378d9",
"sha256:88ec8bff1d634f98e61b9f65bc4bf3cd918a90806c6f5c48bc5603849ec81033" "sha256:4a0b436239ff76fb33f11c07a16482c521a7e09c1ce3cc293c2330afe01bec63"
], ],
"index": "pypi", "index": "pypi",
"markers": "python_version >= '3.5'", "markers": "python_version >= '3.7'",
"version": "==21.2.0" "version": "==22.0.0"
}, },
"h11": { "h11": {
"hashes": [ "hashes": [
@@ -830,19 +830,20 @@
}, },
"idna": { "idna": {
"hashes": [ "hashes": [
"sha256:9ecdbbd083b06798ae1e86adcbfe8ab1479cf864e4ee30fe4e46a003d12491ca", "sha256:028ff3aadf0609c1fd278d8ea3089299412a7a8b9bd005dd08b9f8285bcb5cfc",
"sha256:c05567e9c24a6b9faaa835c4821bad0590fbb9d5779e7caa6e1cc4978e7eb24f" "sha256:82fee1fc78add43492d3a1898bfa6d8a904cc97d8427f683ed8e798d07761aa0"
], ],
"index": "pypi",
"markers": "python_version >= '3.5'", "markers": "python_version >= '3.5'",
"version": "==3.6" "version": "==3.7"
}, },
"imap-tools": { "imap-tools": {
"hashes": [ "hashes": [
"sha256:b6c2b94b9d168e1a52c419a2c10367d746694ca61b68fdb0c3ff211046a0760d", "sha256:1fc97a06a35e18a6d0e775d40b18198f52dae0fa46ee8b6fc6f82dbc7a701699",
"sha256:f42dfb1a7db666ef5df4c815e5b6e6571707b188f67c0a411fc5c9a9b4f1b85f" "sha256:6f6a354a6bb95ab83a24d3321d1a8b62b299ba76ba5774e9f45f0ad2544f093b"
], ],
"index": "pypi", "index": "pypi",
"version": "==1.5.0" "version": "==1.6.0"
}, },
"img2pdf": { "img2pdf": {
"hashes": [ "hashes": [
@@ -868,11 +869,11 @@
}, },
"joblib": { "joblib": {
"hashes": [ "hashes": [
"sha256:92f865e621e17784e7955080b6d042489e3b8e294949cc44c6eac304f59772b1", "sha256:1eb0dc091919cd384490de890cb5dfd538410a6d4b3b54eef09fb8c50b409b1c",
"sha256:ef4331c65f239985f3f2220ecc87db222f08fd22097a3dd5698f693875f8cbb9" "sha256:42942470d4062537be4d54c83511186da1fc14ba354961a2114da91efa9a4ed7"
], ],
"markers": "python_version >= '3.7'", "markers": "python_version >= '3.8'",
"version": "==1.3.2" "version": "==1.4.0"
}, },
"kombu": { "kombu": {
"hashes": [ "hashes": [
@@ -1406,12 +1407,12 @@
}, },
"python-ipware": { "python-ipware": {
"hashes": [ "hashes": [
"sha256:3a94fd073b93e12b13617e291f13eda3495d3ba68b580e3e30174ea84ac63041", "sha256:9117b1c4dddcb5d5ca49e6a9617de2fc66aec2ef35394563ac4eecabdf58c062",
"sha256:86e30cc3af62cec42284dedd49c8a14e436e73c96433c8645ec0b476ff4ad7ec" "sha256:fc936e6e7ec9fcc107f9315df40658f468ac72f739482a707181742882e36b60"
], ],
"index": "pypi", "index": "pypi",
"markers": "python_version >= '3.7'", "markers": "python_version >= '3.7'",
"version": "==2.0.2" "version": "==3.0.0"
}, },
"python-magic": { "python-magic": {
"hashes": [ "hashes": [
@@ -1503,100 +1504,100 @@
}, },
"rapidfuzz": { "rapidfuzz": {
"hashes": [ "hashes": [
"sha256:01581b688c5f4f6665b779135e32db0edab1d78028abf914bb91469928efa383", "sha256:00b5ee47b387fa3805f4038362a085ec58149135dc5bc640ca315a9893a16f9e",
"sha256:04bae4d9c16ce1bab6447d196fb8258d98139ed8f9b288a38b84887985e4227b", "sha256:0798e32304b8009d215026bf7e1c448f1831da0a03987b7de30059a41bee92f3",
"sha256:075a419a0ec29be44b3d7f4bcfa5cb7e91e419379a85fc05eb33de68315bd96f", "sha256:07d7d4a3c49a15146d65f06e44d7545628ca0437c929684e32ef122852f44d95",
"sha256:0828b55ec8ad084febdf4ab0c942eb1f81c97c0935f1cb0be0b4ea84ce755988", "sha256:14791324f0c753f5a0918df1249b91515f5ddc16281fbaa5ec48bff8fa659229",
"sha256:08671280e0c04d2bb3f39511f13cae5914e6690036fd1eefc3d47a47f9fae634", "sha256:16153a97efacadbd693ccc612a3285df2f072fd07c121f30c2c135a709537075",
"sha256:0b13a6823a1b83ae43f8bf35955df35032bee7bec0daf9b5ab836e0286067434", "sha256:17d79398849c1244f646425cf31d856eab9ebd67b7d6571273e53df724ca817e",
"sha256:0cc77237242303733de47829028a0a8b6ab9188b23ec9d9ff0a674fdcd3c8e7f", "sha256:1905d9319a97bed29f21584ca641190dbc9218a556202b77876f1e37618d2e03",
"sha256:1252ca156e1b053e84e5ae1c8e9e062ee80468faf23aa5c543708212a42795fd", "sha256:1b176f01490b48337183da5b4223005bc0c2354a4faee5118917d2fba0bedc1c",
"sha256:150c98b65faff17b917b9d36bff8a4d37b6173579c6bc2e38ff2044e209d37a4", "sha256:1c0264d03dcee1bb975975b77c2fe041820fb4d4a25a99e3cb74ddd083d671ca",
"sha256:1522eaab91b9400b3ef16eebe445940a19e70035b5bc5d98aef23d66e9ac1df0", "sha256:1d5592b08e3cadc9e06ef3af6a9d66b6ef1bf871ed5acd7f9b1e162d78806a65",
"sha256:16895dc62a7b92028f9c8b6d22830f1cbc77306ee794f461afc6028e1a8d7539", "sha256:1edafc0a2737df277d3ddf401f3a73f76e246b7502762c94a3916453ae67e9b1",
"sha256:187db4cc8fb54f8c49c67b7f38ef3a122ce23be273032fa2ff34112a2694c3d8", "sha256:1ef119fc127c982053fb9ec638dcc3277f83b034b5972eb05941984b9ec4a290",
"sha256:18bc2f13c73d5d34499ff6ada55b052c445d3aa64d22c2639e5ab45472568046", "sha256:2084193fd8fd346db496a2220363437eb9370a06d1d5a7a9dba00a64390c6a28",
"sha256:1dfceaa7c2914585bb8a043265c39ec09078f13fbf53b5525722fc074306b6fa", "sha256:209bb712c448cdec4def6260b9f059bd4681ec61a01568f5e70e37bfe9efe830",
"sha256:1efa2268b51b68156fb84d18ca1720311698a58051c4a19c40d670057ce60519", "sha256:231dc1cb63b1c8dd78c0597aa3ad3749a86a2b7e76af295dd81609522699a558",
"sha256:209dda6ae66b702f74a78cef555397cdc2a83d7f48771774a20d2fc30808b28c", "sha256:25498650e30122f4a5ad6b27c7614b4af8628c1d32b19d406410d33f77a86c80",
"sha256:20e7d729af2e5abb29caa070ec048aba042f134091923d9ca2ac662b5604577e", "sha256:267ff42370e031195e3020fff075420c136b69dc918ecb5542ec75c1e36af81f",
"sha256:2bc0b78572626af6ab134895e4dbfe4f4d615d18dcc43b8d902d8e45471aabba", "sha256:2a8a007fdc5cf646e48e361a39eabe725b93af7673c5ab90294e551cae72ff58",
"sha256:2f9070b42c0ba030b045bba16a35bdb498a0d6acb0bdb3ff4e325960e685e290", "sha256:2ba0e43e9a94d256a704a674c7010e6f8ef9225edf7287cf3e7f66c9894b06cd",
"sha256:358692f1df3f8aebcd48e69c77c948c9283b44c0efbaf1eeea01739efe3cd9a6", "sha256:2c6a43446f0cd8ff347b1fbb918dc0d657bebf484ddfa960ee069e422a477428",
"sha256:3a6a36c9299e059e0bee3409218bc5235a46570c20fc980cdee5ed21ea6110ad", "sha256:30c282612b7ebf2d7646ebebfd98dd308c582246a94d576734e4b0162f57baf4",
"sha256:3e55f02105c451ab6ff0edaaba57cab1b6c0a0241cfb2b306d4e8e1503adba50", "sha256:313bdcd16e9cd5e5568b4a31d18a631f0b04cc10a3fd916e4ef75b713e6f177e",
"sha256:40998c8dc35fdd221790b8b5134a8d7499adbfab9a5dd9ec626c7e92e17a43ed", "sha256:392582aa784737d95255ca122ebe7dca3c774da900d100c07b53d32cd221a60e",
"sha256:41851620d2900791d66d9b6092fc163441d7dd91a460c73b07957ff1c517bc30", "sha256:3aff3b829b0b04bdf78bd780ec9faf5f26eac3591df98c35a0ae216c925ae436",
"sha256:419c8961e861fb5fc5590056c66a279623d1ea27809baea17e00cdc313f1217a", "sha256:3fee62ae76e3b8b9fff8aa2ca4061575ee358927ffbdb2919a8c84a98da59f78",
"sha256:42c2e8a2341363c7caf276efdbe1a673fc5267a02568c47c8e980f12e9bc8727", "sha256:41219536634bd6f85419f38450ef080cfb519638125d805cf8626443e677dc61",
"sha256:4604dfc1098920c4eb6d0c6b5cc7bdd4bf95b48633e790c1d3f100a25870691d", "sha256:48b6e5a337a814aec7c6dda5d6460f947c9330860615301f35b519e16dde3c77",
"sha256:49b0c47860c733a3d73a4b70b97b35c8cbf24ef24f8743732f0d1c412a8c85de", "sha256:4969fe0eb179aedacee53ca8f8f1be3c655964a6d62db30f247fee444b9c52b4",
"sha256:4bb9285abeb0477cdb2f8ea0cf7fd4b5f72ed5a9a7d3f0c0bb4a5239db2fc1ed", "sha256:4d5cd86aca3f12e73bfc70015db7e8fc44122da03aa3761138b95112e83f66e4",
"sha256:4e09d81008e212fc824ea23603ff5270d75886e72372fa6c7c41c1880bcb57ed", "sha256:50db3867864422bf6a6435ea65b9ac9de71ef52ed1e05d62f498cd430189eece",
"sha256:4e50840a8a8e0229563eeaf22e21a203359859557db8829f4d0285c17126c5fb", "sha256:58999b21d01dd353f49511a61937eac20c7a5b22eab87612063947081855d85f",
"sha256:4efa9bfc5b955b6474ee077eee154e240441842fa304f280b06e6b6aa58a1d1e", "sha256:5f4174079dfe8ed1f13ece9bde7660f19f98ab17e0c0d002d90cc845c3a7e238",
"sha256:51a5b96d2081c3afbef1842a61d63e55d0a5a201473e6975a80190ff2d6f22ca", "sha256:63044a7b6791a2e945dce9d812a6886e93159deb0464984eb403617ded257f08",
"sha256:579cce49dfa57ffd8c8227b3fb53cced54b4df70cec502e63e9799b4d1f44004", "sha256:63db612bb6da1bb9f6aa7412739f0e714b1910ec07bc675943044fe683ef192c",
"sha256:594b9c33fc1a86784962043ee3fbaaed875fbaadff72e467c2f7a83cd6c5d69d", "sha256:68b185a0397aebe78bcc5d0e1efd96509d4e2f3c4a05996e5c843732f547e9ef",
"sha256:5a8ba64d72329a940ff6c74b721268c2004eecc48558f648a38e96915b5d1c1b", "sha256:6d4f1956fe1fc618e34ac79a6ed84fff5a6f23e41a8a476dd3e8570f0b12f02b",
"sha256:5bd394e28ff221557ea4d8152fcec3e66d9f620557feca5f2bedc4c21f8cf2f9", "sha256:6f34a541895627c2bc9ef7757f16f02428a08d960d33208adfb96b33338d0945",
"sha256:5f2075ac9ee5c15d33d24a1efc8368d095602b5fd9634c5b5f24d83e41903528", "sha256:6f7641992de44ec2ca54102422be44a8e3fb75b9690ccd74fff72b9ac7fc00ee",
"sha256:600b4d4315f33ec0356c0dab3991a5d5761102420bcff29e0773706aa48936e8", "sha256:6f8b62fdccc429e6643cefffd5df9c7bca65588d06e8925b78014ad9ad983bf5",
"sha256:611278ce3136f4544d596af18ab8849827d64372e1d8888d9a8d071bf4a3f44d", "sha256:718ea99f84b16c4bdbf6a93e53552cdccefa18e12ff9a02c5041e621460e2e61",
"sha256:620df112c39c6d27316dc1e22046dc0382d6d91fd60d7c51bd41ca0333d867e9", "sha256:747265f39978bbaad356f5c6b6c808f0e8f5e8994875af0119b82b4700c55387",
"sha256:63044c63565f50818d885bfcd40ac369947da4197de56b4d6c26408989d48edf", "sha256:77ea62879932b32aba77ab23a9296390a67d024bf2f048dee99143be80a4ce26",
"sha256:632f09e19365ace5ff2670008adc8bf23d03d668b03a30230e5b60ff9317ee93", "sha256:78a0d2a11bb3936463609777c6d6d4984a27ebb2360b58339c699899d85db036",
"sha256:74e692357dd324dff691d379ef2c094c9ec526c0ce83ed43a066e4e68fe70bf6", "sha256:799f5f221d639d1c2ed8a2348d1edf5e22aa489b58b2cc99f5bf0c1917e2d0f2",
"sha256:7ba14850cc8258b3764ea16b8a4409ac2ba16d229bde7a5f495dd479cd9ccd56", "sha256:81fd28389bedab28251f0535b3c034b0e63a618efc3ff1d338c81a3da723adb3",
"sha256:7bc944d7e830cfce0f8b4813875f05904207017b66e25ab7ee757507001310a9", "sha256:827ddf2d5d157ac3d1001b52e84c9e20366237a742946599ffc435af7fdd26d0",
"sha256:7be5f460ff42d7d27729115bfe8a02e83fa0284536d8630ee900d17b75c29e65", "sha256:8b76abfec195bf1ee6f9ec56c33ba5e9615ff2d0a9530a54001ed87e5a6ced3b",
"sha256:7c837f89d86a5affe9ee6574dad6b195475676a6ab171a67920fc99966f2ab2c", "sha256:8c40da44ca20235cda05751d6e828b6b348e7a7c5de2922fa0f9c63f564fd675",
"sha256:7e4eea225d2bff1aff4c85fcc44716596d3699374d99eb5906b7a7560297460e", "sha256:8e02425bfc7ebed617323a674974b70eaecd8f07b64a7d16e0bf3e766b93e3c9",
"sha256:7f9f3dc14fadbd553975f824ac48c381f42192cec9d7e5711b528357662a8d8e", "sha256:8e08b01dc9369941a24d7e512b0d81bf514e7d6add1b93d8aeec3c8fa08a824e",
"sha256:860f438238f1807532aa5c5c25e74c284232ccc115fe84697b78e25d48f364f7", "sha256:90167a48de3ed7f062058826608a80242b8561d0fb0cce2c610d741624811a61",
"sha256:86c7676a32d7524e40bc73546e511a408bc831ae5b163029d325ea3a2027d089", "sha256:9441aca94b21f7349cdb231cd0ce9ca251b2355836e8a02bf6ccbea5b442d7a9",
"sha256:86eea3e6c314a9238de568254a9c591ec73c2985f125675ed5f171d869c47773", "sha256:97c13f156f14f10667e1cfc4257069b775440ce005e896c09ce3aff21c9ae665",
"sha256:8e11c5e6593be41a555475c9c20320342c1f5585d635a064924956944c465ad4", "sha256:987cd277d27d14301019fdf61c17524f6127f5d364be5482228726049d8e0d10",
"sha256:8e70f876ca89a6df344f8157ac60384e8c05a0dfb442da2490c3f1c45238ccf5", "sha256:9a16ef3702cecf16056c5fd66398b7ea8622ff4e3afeb00a8db3e74427e850af",
"sha256:8fdc26e7863e0f63c2185d53bb61f5173ad4451c1c8287b535b30ea25a419a5a", "sha256:9ea3d2e41d8fac71cb63ee72f75bee0ed1e9c50709d4c58587f15437761c1858",
"sha256:91f798cc00cd94a0def43e9befc6e867c9bd8fa8f882d1eaa40042f528b7e2c7", "sha256:a02def2eb526cc934d2125533cf2f15aa71c72ed4397afca38427ab047901e88",
"sha256:92b8146fbfb37ac358ef7e0f6b79619e4f793fbbe894b99ea87920f9c0a9d77d", "sha256:a0643a25937fafe8d117f2907606e9940cd1cc905c66f16ece9ab93128299994",
"sha256:9b6167468f76779a14b9af66210f68741af94d32d086f19118de4e919f00585c", "sha256:a2ee3909f611cc5860cc8d9f92d039fd84241ce7360b49ea88e657181d2b45f6",
"sha256:9bad6a0fe3bc1753dacaa6229a8ba7d9844eb7ae24d44d17c5f4c51c91a8a95e", "sha256:a357aae6791118011ad3ab4f2a4aa7bd7a487e5f9981b390e9f3c2c5137ecadf",
"sha256:9e17a3092e74025d896ef1d67ac236c83494da37a78ef84c712e4e2273c115f1", "sha256:aa223c73c59cc45c12eaa9c439318084003beced0447ff92b578a890288e19eb",
"sha256:9ea720db8def684c1eb71dadad1f61c9b52f4d979263eb5d443f2b22b0d5430a", "sha256:ad4dbd06c1f579eb043b2dcfc635bc6c9fb858240a70f0abd3bed84d8ac79994",
"sha256:a1f268a2a37cd22573b4a06eccd481c04504b246d3cadc2d8e8dfa64b575636d", "sha256:b0ba20be465566264fa5580d874ccf5eabba6975dba45857e2c76e2df3359c6d",
"sha256:a9460d8fddac7ea46dff9298eee9aa950dbfe79f2eb509a9f18fbaefcd10894c", "sha256:b27cea618601ca5032ea98ee116ca6e0fe67be7b286bcb0b9f956d64db697472",
"sha256:a9acca34b34fb895ee6a84c436bb919f3b9cd8f43e7003d43e9573a1d990ff74", "sha256:b7b9cbc60e3eb08da6d18636c62c6eb6206cd9d0c7ad73996f7a1df3fc415b27",
"sha256:aa163257a0ac4e70f9009d25e5030bdd83a8541dfa3ba78dc86b35c9e16a80b4", "sha256:bb571dbd4cc93342be0ba632f0b8d7de4cbd9d959d76371d33716d2216090d41",
"sha256:b4a7e37fe136022d944374fcd8a2f72b8a19f7b648d2cdfb946667e9ede97f9f", "sha256:bbc15985c5658691f637a6b97651771147744edfad2a4be56b8a06755e3932fa",
"sha256:b5881856f830351aaabd869151124f64a80bf61560546d9588a630a4e933a5de", "sha256:bc5a1ec3bd05b55d3070d557c0cdd4412272d51b4966c79aa3e9da207bd33d65",
"sha256:b917764fd2b267addc9d03a96d26f751f6117a95f617428c44a069057653b528", "sha256:bca5acf77508d1822023a85118c2dd8d3c16abdd56d2762359a46deb14daa5e0",
"sha256:be08f39e397a618aab907887465d7fabc2d1a4d15d1a67cb8b526a7fb5202a3e", "sha256:c04ef83c9ca3162d200df36e933b3ea0327a2626cee2e01bbe55acbc004ce261",
"sha256:c0cc9d3c8261457af3f8756b1f71a9fdc4892978a9e8b967976d2803e08bf972", "sha256:c21d5c7cfa6078c79897e5e482a7e84ff927143d2f3fb020dd6edd27f5469574",
"sha256:c788b11565cc176fab8fab6dfcd469031e906927db94bf7e422afd8ef8f88a5a", "sha256:c22b32a57ab47afb207e8fe4bd7bb58c90f9291a63723cafd4e704742166e368",
"sha256:c86bc4b1d2380739e6485396195e30021df509b4923f3f757914e171587bce7c", "sha256:c458085e067c766112f089f78ce39eab2b69ba027d7bbb11d067a0b085774367",
"sha256:cda4550a98658f9a8bcdc03d0498ed1565c1563880e3564603a9eaae28d51b2a", "sha256:c4dbb1ebc9a811f38da33f32ed2bb5f58b149289b89eb11e384519e9ba7ca881",
"sha256:ce728e2b582fd396bc2559160ee2e391e6a4b5d2e455624044699d96abe8a396", "sha256:c754ce1fab41b731259f100d5d46529a38aa2c9b683c92aeb7e96ef5b2898cd8",
"sha256:d1b14489b038f007f425a06fcf28ac6313c02cb603b54e3a28d9cfae82198cc0", "sha256:c763d99cf087e7b2c5be0cf34ae9a0e1b031f5057d2341a0a0ed782458645b7e",
"sha256:d5a3872f35bec89f07b993fa1c5401d11b9e68bcdc1b9737494e279308a38a5f", "sha256:c9597a05d08e8103ad59ebdf29e3fbffb0d0dbf3b641f102cfbeadc3a77bde51",
"sha256:d7361608c8e73a1dc0203a87d151cddebdade0098a047c46da43c469c07df964", "sha256:cc4af7090a626c902c48db9b5d786c1faa0d8e141571e8a63a5350419ea575bd",
"sha256:d7878025248b99ccca3285891899373f98548f2ca13835d83619ffc42241c626", "sha256:ceb10039e7346927cec47eaa490b34abb602b537e738ee9914bb41b8de029fbc",
"sha256:dc3fdb4738a6b83ae27f1d8923b00d3a9c2b5c50da75b9f8b81841839c6e3e1f", "sha256:d1a15fef1938b43468002f2d81012dbc9e7b50eb8533af202b0559c2dc7865d9",
"sha256:dd5ad2c12dab2b98340c4b7b9592c8f349730bda9a2e49675ea592bbcbc1360b", "sha256:d4276c7ee061db0bac54846933b40339f60085523675f917f37de24a4b3ce0ee",
"sha256:dfd1e4819f1f3c47141f86159b44b7360ecb19bf675080b3b40437bf97273ab9", "sha256:d48657a404fab82b2754faa813a10c5ad6aa594cb1829dca168a49438b61b4ec",
"sha256:e499c823206c9ffd9d89aa11f813a4babdb9219417d4efe4c8a6f8272da00e98", "sha256:e3f882110f2f4894942e314451773c47e8b1b4920b5ea2b6dd2e2d4079dd3135",
"sha256:e8041c6b2d339766efe6298fa272f79d6dd799965df364ef4e50f488c101c899", "sha256:e4c647795c5b901091a68e210c76b769af70a33a8624ac496ac3e34d33366c0d",
"sha256:eace9fdde58a425d4c9a93021b24a0cac830df167a5b2fc73299e2acf9f41493", "sha256:e62bde7d5df3312acc528786ee801c472cae5078b1f1e42761c853ba7fe1072a",
"sha256:ecd70212fd9f1f8b1d3bdd8bcb05acc143defebd41148bdab43e573b043bb241", "sha256:e6ec696a268e8d730b42711537e500f7397afc06125c0e8fa9c8211386d315a5",
"sha256:ef6b6ab64c4c91c57a6b58e1d690b59453bfa1f1e9757a7e52e59b4079e36631", "sha256:f176867f438ff2a43e6a837930153ca78fddb3ca94e378603a1e7b860d7869bf",
"sha256:f332d61f51b0b9c8b55a0fb052b4764b6ad599ea8ce948ac47a4388e9083c35e", "sha256:f8af980695b866255447703bf634551e67e1a4e1c2d2d26501858d9233d886d7",
"sha256:f39eb1513ee139ba6b5c01fe47ddf2d87e9560dd7fdee1068f7f6efbae70de34", "sha256:f8e57f9c2367706a320b78e91f8bf9a3b03bf9069464eb7b54455fa340d03e4c",
"sha256:faded69ffe79adcefa8da08f414a0fd52375e2b47f57be79471691dad9656b5a" "sha256:f9d5d924970b07128c61c08eebee718686f4bd9838ef712a50468169520c953f"
], ],
"index": "pypi", "index": "pypi",
"markers": "python_version >= '3.8'", "markers": "python_version >= '3.8'",
"version": "==3.7.0" "version": "==3.8.1"
}, },
"redis": { "redis": {
"extras": [ "extras": [
@@ -1742,62 +1743,62 @@
}, },
"scikit-learn": { "scikit-learn": {
"hashes": [ "hashes": [
"sha256:0df87de9ce1c0140f2818beef310fb2e2afdc1e66fc9ad587965577f17733649", "sha256:1d0b25d9c651fd050555aadd57431b53d4cf664e749069da77f3d52c5ad14b3b",
"sha256:14e4c88436ac96bf69eb6d746ac76a574c314a23c6961b7d344b38877f20fee1", "sha256:36f0ea5d0f693cb247a073d21a4123bdf4172e470e6d163c12b74cbb1536cf38",
"sha256:1754b0c2409d6ed5a3380512d0adcf182a01363c669033a2b55cca429ed86a81", "sha256:426d258fddac674fdf33f3cb2d54d26f49406e2599dbf9a32b4d1696091d4256",
"sha256:1afed6951bc9d2053c6ee9a518a466cbc9b07c6a3f9d43bfe734192b6125d508", "sha256:44c62f2b124848a28fd695db5bc4da019287abf390bfce602ddc8aa1ec186aae",
"sha256:1d491ef66e37f4e812db7e6c8286520c2c3fc61b34bf5e59b67b4ce528de93af", "sha256:45dee87ac5309bb82e3ea633955030df9bbcb8d2cdb30383c6cd483691c546cc",
"sha256:234b6bda70fdcae9e4abbbe028582ce99c280458665a155eed0b820599377d25", "sha256:49d64ef6cb8c093d883e5a36c4766548d974898d378e395ba41a806d0e824db8",
"sha256:2a3ee19211ded1a52ee37b0a7b373a8bfc66f95353af058a210b692bd4cda0dd", "sha256:5460a1a5b043ae5ae4596b3126a4ec33ccba1b51e7ca2c5d36dac2169f62ab1d",
"sha256:4310bff71aa98b45b46cd26fa641309deb73a5d1c0461d181587ad4f30ea3c36", "sha256:5cd7b524115499b18b63f0c96f4224eb885564937a0b3477531b2b63ce331904",
"sha256:4ba516fcdc73d60e7f48cbb0bccb9acbdb21807de3651531208aac73c758e3ab", "sha256:671e2f0c3f2c15409dae4f282a3a619601fa824d2c820e5b608d9d775f91780c",
"sha256:6145dfd9605b0b50ae72cdf72b61a2acd87501369a763b0d73d004710ebb76b5", "sha256:68b8404841f944a4a1459b07198fa2edd41a82f189b44f3e1d55c104dbc2e40c",
"sha256:629e09f772ad42f657ca60a1a52342eef786218dd20cf1369a3b8d085e55ef8f", "sha256:81bf5d8bbe87643103334032dd82f7419bc8c8d02a763643a6b9a5c7288c5054",
"sha256:712c1c69c45b58ef21635360b3d0a680ff7d83ac95b6f9b82cf9294070cda710", "sha256:8539a41b3d6d1af82eb629f9c57f37428ff1481c1e34dddb3b9d7af8ede67ac5",
"sha256:78cd27b4669513b50db4f683ef41ea35b5dddc797bd2bbd990d49897fd1c8a46", "sha256:87440e2e188c87db80ea4023440923dccbd56fbc2d557b18ced00fef79da0727",
"sha256:93d3d496ff1965470f9977d05e5ec3376fb1e63b10e4fda5e39d23c2d8969a30", "sha256:90378e1747949f90c8f385898fff35d73193dfcaec3dd75d6b542f90c4e89755",
"sha256:9f43dd527dabff5521af2786a2f8de5ba381e182ec7292663508901cf6ceaf6e", "sha256:b0203c368058ab92efc6168a1507d388d41469c873e96ec220ca8e74079bf62e",
"sha256:a1e289f33f613cefe6707dead50db31930530dc386b6ccff176c786335a7b01c", "sha256:c97a50b05c194be9146d61fe87dbf8eac62b203d9e87a3ccc6ae9aed2dfaf361",
"sha256:aa0029b78ef59af22cfbd833e8ace8526e4df90212db7ceccbea582ebb5d6794", "sha256:d36d0bc983336bbc1be22f9b686b50c964f593c8a9a913a792442af9bf4f5e68",
"sha256:c02e27d65b0c7dc32f2c5eb601aaf5530b7a02bfbe92438188624524878336f2", "sha256:d762070980c17ba3e9a4a1e043ba0518ce4c55152032f1af0ca6f39b376b5928",
"sha256:c540aaf44729ab5cd4bd5e394f2b375e65ceaea9cdd8c195788e70433d91bbc5", "sha256:d9993d5e78a8148b1d0fdf5b15ed92452af5581734129998c26f481c46586d68",
"sha256:ce03506ccf5f96b7e9030fea7eb148999b254c44c10182ac55857bc9b5d4815f", "sha256:daa1c471d95bad080c6e44b4946c9390a4842adc3082572c20e4f8884e39e959",
"sha256:d7cd3a77c32879311f2aa93466d3c288c955ef71d191503cf0677c3340ae8ae0" "sha256:ff4effe5a1d4e8fed260a83a163f7dbf4f6087b54528d8880bab1d1377bd78be"
], ],
"index": "pypi", "index": "pypi",
"markers": "python_version >= '3.9'", "markers": "python_version >= '3.9'",
"version": "==1.4.1.post1" "version": "==1.4.2"
}, },
"scipy": { "scipy": {
"hashes": [ "hashes": [
"sha256:196ebad3a4882081f62a5bf4aeb7326aa34b110e533aab23e4374fcccb0890dc", "sha256:05f1432ba070e90d42d7fd836462c50bf98bd08bed0aa616c359eed8a04e3922",
"sha256:408c68423f9de16cb9e602528be4ce0d6312b05001f3de61fe9ec8b1263cad08", "sha256:09c74543c4fbeb67af6ce457f6a6a28e5d3739a87f62412e4a16e46f164f0ae5",
"sha256:4bf5abab8a36d20193c698b0f1fc282c1d083c94723902c447e5d2f1780936a3", "sha256:0fbcf8abaf5aa2dc8d6400566c1a727aed338b5fe880cde64907596a89d576fa",
"sha256:4c1020cad92772bf44b8e4cdabc1df5d87376cb219742549ef69fc9fd86282dd", "sha256:109d391d720fcebf2fbe008621952b08e52907cf4c8c7efc7376822151820820",
"sha256:5adfad5dbf0163397beb4aca679187d24aec085343755fcdbdeb32b3679f254c", "sha256:1d2f7bb14c178f8b13ebae93f67e42b0a6b0fc50eba1cd8021c9b6e08e8fb1cd",
"sha256:5e32847e08da8d895ce09d108a494d9eb78974cf6de23063f93306a3e419960c", "sha256:1e7626dfd91cdea5714f343ce1176b6c4745155d234f1033584154f60ef1ff42",
"sha256:6546dc2c11a9df6926afcbdd8a3edec28566e4e785b915e849348c6dd9f3f490", "sha256:22789b56a999265431c417d462e5b7f2b487e831ca7bef5edeb56efe4c93f86e",
"sha256:730badef9b827b368f351eacae2e82da414e13cf8bd5051b4bdfd720271a5371", "sha256:28e286bf9ac422d6beb559bc61312c348ca9b0f0dae0d7c5afde7f722d6ea13d",
"sha256:75ea2a144096b5e39402e2ff53a36fecfd3b960d786b7efd3c180e29c39e53f2", "sha256:33fde20efc380bd23a78a4d26d59fc8704e9b5fd9b08841693eb46716ba13d86",
"sha256:78e4402e140879387187f7f25d91cc592b3501a2e51dfb320f48dfb73565f10b", "sha256:45c08bec71d3546d606989ba6e7daa6f0992918171e2a6f7fbedfa7361c2de1e",
"sha256:8b8066bce124ee5531d12a74b617d9ac0ea59245246410e19bca549656d9a40a", "sha256:4dca18c3ffee287ddd3bc8f1dabaf45f5305c5afc9f8ab9cbfab855e70b2df5c",
"sha256:8bee4993817e204d761dba10dbab0774ba5a8612e57e81319ea04d84945375ba", "sha256:5407708195cb38d70fd2d6bb04b1b9dd5c92297d86e9f9daae1576bd9e06f602",
"sha256:913d6e7956c3a671de3b05ccb66b11bc293f56bfdef040583a7221d9e22a2e35", "sha256:58569af537ea29d3f78e5abd18398459f195546bb3be23d16677fb26616cc11e",
"sha256:95e5c750d55cf518c398a8240571b0e0782c2d5a703250872f36eaf737751338", "sha256:5e4a756355522eb60fcd61f8372ac2549073c8788f6114449b37e9e8104f15a5",
"sha256:9c39f92041f490422924dfdb782527a4abddf4707616e07b021de33467f917bc", "sha256:6bf9fe63e7a4bf01d3645b13ff2aa6dea023d38993f42aaac81a18b1bda7a82a",
"sha256:a24024d45ce9a675c1fb8494e8e5244efea1c7a09c60beb1eeb80373d0fecc70", "sha256:8930ae3ea371d6b91c203b1032b9600d69c568e537b7988a3073dfe4d4774f21",
"sha256:a7ebda398f86e56178c2fa94cad15bf457a218a54a35c2a7b4490b9f9cb2676c", "sha256:9ff7dad5d24a8045d836671e082a490848e8639cabb3dbdacb29f943a678683d",
"sha256:b360f1b6b2f742781299514e99ff560d1fe9bd1bff2712894b52abe528d1fd1e", "sha256:a2f471de4d01200718b2b8927f7d76b5d9bde18047ea0fa8bd15c5ba3f26a1d6",
"sha256:bba1b0c7256ad75401c73e4b3cf09d1f176e9bd4248f0d3112170fb2ec4db067", "sha256:ac38c4c92951ac0f729c4c48c9e13eb3675d9986cc0c83943784d7390d540c78",
"sha256:c3003652496f6e7c387b1cf63f4bb720951cfa18907e998ea551e6de51a04467", "sha256:b2a3ff461ec4756b7e8e42e1c681077349a038f0686132d623fa404c0bee2551",
"sha256:e53958531a7c695ff66c2e7bb7b79560ffdc562e2051644c5576c39ff8efb563", "sha256:b5acd8e1dbd8dbe38d0004b1497019b2dbbc3d70691e65d69615f8a7292865d7",
"sha256:e646d8571804a304e1da01040d21577685ce8e2db08ac58e543eaca063453e1c", "sha256:b8434f6f3fa49f631fae84afee424e2483289dfc30a47755b4b4e6b07b2633a4",
"sha256:e7e76cc48638228212c747ada851ef355c2bb5e7f939e10952bc504c11f4e372", "sha256:ba419578ab343a4e0a77c0ef82f088238a93eef141b2b8017e46149776dfad4d",
"sha256:f5f00ebaf8de24d14b8449981a2842d404152774c1a1d880c901bf454cb8e2a1", "sha256:d0de696f589681c2802f9090fff730c218f7c51ff49bf252b6a97ec4a5d19e8b",
"sha256:f7ce148dffcd64ade37b2df9315541f9adad6efcaa86866ee7dd5db0c8f041c3" "sha256:dcbb9ea49b0167de4167c40eeee6e167caeef11effb0670b554d10b1e693a8b9"
], ],
"markers": "python_version >= '3.9'", "markers": "python_version >= '3.9'",
"version": "==1.12.0" "version": "==1.13.0"
}, },
"setproctitle": { "setproctitle": {
"hashes": [ "hashes": [
@@ -1912,11 +1913,11 @@
}, },
"sqlparse": { "sqlparse": {
"hashes": [ "hashes": [
"sha256:5430a4fe2ac7d0f93e66f1efc6e1338a41884b7ddf2a350cedd20ccc4d9d28f3", "sha256:714d0a4932c059d16189f58ef5411ec2287a4360f17cdd0edd2d09d4c5087c93",
"sha256:d446183e84b8349fa3061f0fe7f06ca94ba65b426946ffebe6e3e8295332420c" "sha256:c204494cd97479d0e39f28c93d46c0b2d5959c7b9ab904762ea6c7af211c8663"
], ],
"markers": "python_version >= '3.5'", "markers": "python_version >= '3.8'",
"version": "==0.4.4" "version": "==0.5.0"
}, },
"threadpoolctl": { "threadpoolctl": {
"hashes": [ "hashes": [
@@ -1963,11 +1964,11 @@
}, },
"typing-extensions": { "typing-extensions": {
"hashes": [ "hashes": [
"sha256:69b1a937c3a517342112fb4c6df7e72fc39a38e7891a5730ed4985b5214b5475", "sha256:83f085bd5ca59c80295fc2a82ab5dac679cbe02b9f33f7d83af68e241bea51b0",
"sha256:b0abd7c89e8fb96f98db18d86106ff1d90ab692004eb746cf6eda2682f91b3cb" "sha256:c1f94d72897edaf4ce775bb7558d5b79d8126906a14ea5ed1635921406c0387a"
], ],
"markers": "python_version < '3.11'", "markers": "python_version < '3.11'",
"version": "==4.10.0" "version": "==4.11.0"
}, },
"tzdata": { "tzdata": {
"hashes": [ "hashes": [
@@ -2483,32 +2484,32 @@
}, },
"black": { "black": {
"hashes": [ "hashes": [
"sha256:2818cf72dfd5d289e48f37ccfa08b460bf469e67fb7c4abb07edc2e9f16fb63f", "sha256:1bb9ca06e556a09f7f7177bc7cb604e5ed2d2df1e9119e4f7d2f1f7071c32e5d",
"sha256:41622020d7120e01d377f74249e677039d20e6344ff5851de8a10f11f513bf93", "sha256:21f9407063ec71c5580b8ad975653c66508d6a9f57bd008bb8691d273705adcd",
"sha256:4acf672def7eb1725f41f38bf6bf425c8237248bb0804faa3965c036f7672d11", "sha256:4396ca365a4310beef84d446ca5016f671b10f07abdba3e4e4304218d2c71d33",
"sha256:4be5bb28e090456adfc1255e03967fb67ca846a03be7aadf6249096100ee32d0", "sha256:44d99dfdf37a2a00a6f7a8dcbd19edf361d056ee51093b2445de7ca09adac965",
"sha256:4f1373a7808a8f135b774039f61d59e4be7eb56b2513d3d2f02a8b9365b8a8a9", "sha256:5cd5b4f76056cecce3e69b0d4c228326d2595f506797f40b9233424e2524c070",
"sha256:56f52cfbd3dabe2798d76dbdd299faa046a901041faf2cf33288bc4e6dae57b5", "sha256:64578cf99b6b46a6301bc28bdb89f9d6f9b592b1c5837818a177c98525dbe397",
"sha256:65b76c275e4c1c5ce6e9870911384bff5ca31ab63d19c76811cb1fb162678213", "sha256:64e60a7edd71fd542a10a9643bf369bfd2644de95ec71e86790b063aa02ff745",
"sha256:65c02e4ea2ae09d16314d30912a58ada9a5c4fdfedf9512d23326128ac08ac3d", "sha256:652e55bb722ca026299eb74e53880ee2315b181dfdd44dca98e43448620ddec1",
"sha256:6905238a754ceb7788a73f02b45637d820b2f5478b20fec82ea865e4f5d4d9f7", "sha256:6644f97a7ef6f401a150cca551a1ff97e03c25d8519ee0bbc9b0058772882665",
"sha256:79dcf34b33e38ed1b17434693763301d7ccbd1c5860674a8f871bd15139e7837", "sha256:6ad001a9ddd9b8dfd1b434d566be39b1cd502802c8d38bbb1ba612afda2ef436",
"sha256:7bb041dca0d784697af4646d3b62ba4a6b028276ae878e53f6b4f74ddd6db99f", "sha256:71d998b73c957444fb7c52096c3843875f4b6b47a54972598741fe9a7f737fcb",
"sha256:7d5e026f8da0322b5662fa7a8e752b3fa2dac1c1cbc213c3d7ff9bdd0ab12395", "sha256:74eb9b5420e26b42c00a3ff470dc0cd144b80a766128b1771d07643165e08d0e",
"sha256:9f50ea1132e2189d8dff0115ab75b65590a3e97de1e143795adb4ce317934995", "sha256:75a2d0b4f5eb81f7eebc31f788f9830a6ce10a68c91fbe0fade34fff7a2836e6",
"sha256:a0c9c4a0771afc6919578cec71ce82a3e31e054904e7197deacbc9382671c41f", "sha256:7852b05d02b5b9a8c893ab95863ef8986e4dda29af80bbbda94d7aee1abf8702",
"sha256:aadf7a02d947936ee418777e0247ea114f78aff0d0959461057cae8a04f20597", "sha256:7f2966b9b2b3b7104fca9d75b2ee856fe3fdd7ed9e47c753a4bb1a675f2caab8",
"sha256:b5991d523eee14756f3c8d5df5231550ae8993e2286b8014e2fdea7156ed0959", "sha256:8e5537f456a22cf5cfcb2707803431d2feeb82ab3748ade280d6ccd0b40ed2e8",
"sha256:bf21b7b230718a5f08bd32d5e4f1db7fc8788345c8aea1d155fc17852b3410f5", "sha256:d4e71cdebdc8efeb6deaf5f2deb28325f8614d48426bed118ecc2dcaefb9ebf3",
"sha256:c45f8dff244b3c431b36e3224b6be4a127c6aca780853574c00faf99258041eb", "sha256:dae79397f367ac8d7adb6c779813328f6d690943f64b32983e896bcccd18cbad",
"sha256:c7ed6668cbbfcd231fa0dc1b137d3e40c04c7f786e626b405c62bcd5db5857e4", "sha256:e3a3a092b8b756c643fe45f4624dbd5a389f770a4ac294cf4d0fce6af86addaf",
"sha256:d7de8d330763c66663661a1ffd432274a2f92f07feeddd89ffd085b5744f85e7", "sha256:eb949f56a63c5e134dfdca12091e98ffb5fd446293ebae123d10fc1abad00b9e",
"sha256:e19cb1c6365fd6dc38a6eae2dcb691d7d83935c10215aef8e6c38edee3f77abd", "sha256:f07b69fda20578367eaebbd670ff8fc653ab181e1ff95d84497f9fa20e7d0641",
"sha256:e2af80566f43c85f5797365077fb64a393861a3730bd110971ab7a0c94e873e7" "sha256:f95cece33329dc4aa3b0e1a771c41075812e46cf3d6e3f1dfe3d91ff09826ed2"
], ],
"index": "pypi", "index": "pypi",
"markers": "python_version >= '3.8'", "markers": "python_version >= '3.8'",
"version": "==24.3.0" "version": "==24.4.0"
}, },
"certifi": { "certifi": {
"hashes": [ "hashes": [
@@ -2805,12 +2806,12 @@
}, },
"daphne": { "daphne": {
"hashes": [ "hashes": [
"sha256:7228cd6a3ca5a9b11c9a1c1c0414dab1bfb4ddc55ff234b545db8d71f6c24938", "sha256:618d1322bb4d875342b99dd2a10da2d9aae7ee3645f765965fdc1e658ea5290a",
"sha256:882fab39d0b90c6b2709b38116c95f660b6cf236600115dd7c13161fb98b3448" "sha256:fcbcace38eb86624ae247c7ffdc8ac12f155d7d19eafac4247381896d6f33761"
], ],
"index": "pypi", "index": "pypi",
"markers": "python_version >= '3.8'", "markers": "python_version >= '3.8'",
"version": "==4.1.0" "version": "==4.1.2"
}, },
"distlib": { "distlib": {
"hashes": [ "hashes": [
@@ -2912,11 +2913,11 @@
}, },
"idna": { "idna": {
"hashes": [ "hashes": [
"sha256:9ecdbbd083b06798ae1e86adcbfe8ab1479cf864e4ee30fe4e46a003d12491ca", "sha256:028ff3aadf0609c1fd278d8ea3089299412a7a8b9bd005dd08b9f8285bcb5cfc",
"sha256:c05567e9c24a6b9faaa835c4821bad0590fbb9d5779e7caa6e1cc4978e7eb24f" "sha256:82fee1fc78add43492d3a1898bfa6d8a904cc97d8427f683ed8e798d07761aa0"
], ],
"markers": "python_version >= '3.5'", "markers": "python_version >= '3.5'",
"version": "==3.6" "version": "==3.7"
}, },
"imagehash": { "imagehash": {
"hashes": [ "hashes": [
@@ -3057,12 +3058,12 @@
}, },
"mkdocs-material": { "mkdocs-material": {
"hashes": [ "hashes": [
"sha256:06ae1275a72db1989cf6209de9e9ecdfbcfdbc24c58353877b2bb927dbe413e4", "sha256:1e0e27fc9fe239f9064318acf548771a4629d5fd5dfd45444fd80a953fe21eb4",
"sha256:14a2a60119a785e70e765dd033e6211367aca9fc70230e577c1cf6a326949571" "sha256:a43f470947053fa2405c33995f282d24992c752a50114f23f30da9d8d0c57e62"
], ],
"index": "pypi", "index": "pypi",
"markers": "python_version >= '3.8'", "markers": "python_version >= '3.8'",
"version": "==9.5.17" "version": "==9.5.18"
}, },
"mkdocs-material-extensions": { "mkdocs-material-extensions": {
"hashes": [ "hashes": [
@@ -3253,26 +3254,27 @@
}, },
"pyasn1": { "pyasn1": {
"hashes": [ "hashes": [
"sha256:4439847c58d40b1d0a573d07e3856e95333f1976294494c325775aeca506eb58", "sha256:3a35ab2c4b5ef98e17dfdec8ab074046fbda76e281c5a706ccd82328cfc8f64c",
"sha256:6d391a96e59b23130a5cfa74d6fd7f388dbbe26cc8f1edf39fdddf08d9d6676c" "sha256:cca4bb0f2df5504f02f6f8a775b6e416ff9b0b3b16f7ee80b5a3153d9b804473"
], ],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5'", "markers": "python_version >= '3.8'",
"version": "==0.5.1" "version": "==0.6.0"
}, },
"pyasn1-modules": { "pyasn1-modules": {
"hashes": [ "hashes": [
"sha256:5bd01446b736eb9d31512a30d46c1ac3395d676c6f3cafa4c03eb54b9925631c", "sha256:831dbcea1b177b28c9baddf4c6d1013c24c3accd14a1873fffaa6a2e905f17b6",
"sha256:d3ccd6ed470d9ffbc716be08bd90efbd44d0734bc9303818f7336070984a162d" "sha256:be04f15b66c206eed667e0bb5ab27e2b1855ea54a842e5037738099e8ca4ae0b"
], ],
"markers": "python_version >= '2.7' and python_version not in '3.0, 3.1, 3.2, 3.3, 3.4, 3.5'", "markers": "python_version >= '3.8'",
"version": "==0.3.0" "version": "==0.4.0"
}, },
"pycparser": { "pycparser": {
"hashes": [ "hashes": [
"sha256:8ee45429555515e1f6b185e78100aea234072576aa43ab53aefcae078162fca9", "sha256:491c8be9c040f5390f5bf44a5b07752bd07f56edf992381b05c701439eec10f6",
"sha256:e644fdec12f7872f86c58ff790da456218b10f863970249516d60a5eaca77206" "sha256:c3702b6d3dd8c7abc1afa565d7e63d53a1d0bd86cdc24edd75470f4de499cfcc"
], ],
"version": "==2.21" "markers": "python_version >= '3.8'",
"version": "==2.22"
}, },
"pygments": { "pygments": {
"hashes": [ "hashes": [
@@ -3284,11 +3286,11 @@
}, },
"pymdown-extensions": { "pymdown-extensions": {
"hashes": [ "hashes": [
"sha256:c70e146bdd83c744ffc766b4671999796aba18842b268510a329f7f64700d584", "sha256:3539003ff0d5e219ba979d2dc961d18fcad5ac259e66c764482e8347b4c0503c",
"sha256:f5cc7000d7ff0d1ce9395d216017fa4df3dde800afb1fb72d1c7d3fd35e710f4" "sha256:91ca336caf414e1e5e0626feca86e145de9f85a3921a7bcbd32890b51738c428"
], ],
"markers": "python_version >= '3.8'", "markers": "python_version >= '3.8'",
"version": "==10.7.1" "version": "==10.8"
}, },
"pyopenssl": { "pyopenssl": {
"hashes": [ "hashes": [
@@ -3474,102 +3476,102 @@
}, },
"regex": { "regex": {
"hashes": [ "hashes": [
"sha256:0694219a1d54336fd0445ea382d49d36882415c0134ee1e8332afd1529f0baa5", "sha256:00169caa125f35d1bca6045d65a662af0202704489fada95346cfa092ec23f39",
"sha256:086dd15e9435b393ae06f96ab69ab2d333f5d65cbe65ca5a3ef0ec9564dfe770", "sha256:03576e3a423d19dda13e55598f0fd507b5d660d42c51b02df4e0d97824fdcae3",
"sha256:094ba386bb5c01e54e14434d4caabf6583334090865b23ef58e0424a6286d3dc", "sha256:03e68f44340528111067cecf12721c3df4811c67268b897fbe695c95f860ac42",
"sha256:09da66917262d9481c719599116c7dc0c321ffcec4b1f510c4f8a066f8768105", "sha256:0534b034fba6101611968fae8e856c1698da97ce2efb5c2b895fc8b9e23a5834",
"sha256:0ecf44ddf9171cd7566ef1768047f6e66975788258b1c6c6ca78098b95cf9a3d", "sha256:08dea89f859c3df48a440dbdcd7b7155bc675f2fa2ec8c521d02dc69e877db70",
"sha256:0fda75704357805eb953a3ee15a2b240694a9a514548cd49b3c5124b4e2ad01b", "sha256:0a38d151e2cdd66d16dab550c22f9521ba79761423b87c01dae0a6e9add79c0d",
"sha256:11a963f8e25ab5c61348d090bf1b07f1953929c13bd2309a0662e9ff680763c9", "sha256:0c8290b44d8b0af4e77048646c10c6e3aa583c1ca67f3b5ffb6e06cf0c6f0f89",
"sha256:150c39f5b964e4d7dba46a7962a088fbc91f06e606f023ce57bb347a3b2d4630", "sha256:10188fe732dec829c7acca7422cdd1bf57d853c7199d5a9e96bb4d40db239c73",
"sha256:1b9d811f72210fa9306aeb88385b8f8bcef0dfbf3873410413c00aa94c56c2b6", "sha256:1210365faba7c2150451eb78ec5687871c796b0f1fa701bfd2a4a25420482d26",
"sha256:1e0eabac536b4cc7f57a5f3d095bfa557860ab912f25965e08fe1545e2ed8b4c", "sha256:12f6a3f2f58bb7344751919a1876ee1b976fe08b9ffccb4bbea66f26af6017b9",
"sha256:22a86d9fff2009302c440b9d799ef2fe322416d2d58fc124b926aa89365ec482", "sha256:159dc4e59a159cb8e4e8f8961eb1fa5d58f93cb1acd1701d8aff38d45e1a84a6",
"sha256:22f3470f7524b6da61e2020672df2f3063676aff444db1daa283c2ea4ed259d6", "sha256:20b7a68444f536365af42a75ccecb7ab41a896a04acf58432db9e206f4e525d6",
"sha256:263ef5cc10979837f243950637fffb06e8daed7f1ac1e39d5910fd29929e489a", "sha256:23cff1b267038501b179ccbbd74a821ac4a7192a1852d1d558e562b507d46013",
"sha256:283fc8eed679758de38fe493b7d7d84a198b558942b03f017b1f94dda8efae80", "sha256:2c72608e70f053643437bd2be0608f7f1c46d4022e4104d76826f0839199347a",
"sha256:29171aa128da69afdf4bde412d5bedc335f2ca8fcfe4489038577d05f16181e5", "sha256:3399dd8a7495bbb2bacd59b84840eef9057826c664472e86c91d675d007137f5",
"sha256:298dc6354d414bc921581be85695d18912bea163a8b23cac9a2562bbcd5088b1", "sha256:34422d5a69a60b7e9a07a690094e824b66f5ddc662a5fc600d65b7c174a05f04",
"sha256:2aae8101919e8aa05ecfe6322b278f41ce2994c4a430303c4cd163fef746e04f", "sha256:370c68dc5570b394cbaadff50e64d705f64debed30573e5c313c360689b6aadc",
"sha256:2f4e475a80ecbd15896a976aa0b386c5525d0ed34d5c600b6d3ebac0a67c7ddf", "sha256:3a1018e97aeb24e4f939afcd88211ace472ba566efc5bdf53fd8fd7f41fa7170",
"sha256:34e4af5b27232f68042aa40a91c3b9bb4da0eeb31b7632e0091afc4310afe6cb", "sha256:3d5ac5234fb5053850d79dd8eb1015cb0d7d9ed951fa37aa9e6249a19aa4f336",
"sha256:37f8e93a81fc5e5bd8db7e10e62dc64261bcd88f8d7e6640aaebe9bc180d9ce2", "sha256:4313ab9bf6a81206c8ac28fdfcddc0435299dc88cad12cc6305fd0e78b81f9e4",
"sha256:3a17d3ede18f9cedcbe23d2daa8a2cd6f59fe2bf082c567e43083bba3fb00347", "sha256:445ca8d3c5a01309633a0c9db57150312a181146315693273e35d936472df912",
"sha256:3b1de218d5375cd6ac4b5493e0b9f3df2be331e86520f23382f216c137913d20", "sha256:479595a4fbe9ed8f8f72c59717e8cf222da2e4c07b6ae5b65411e6302af9708e",
"sha256:43f7cd5754d02a56ae4ebb91b33461dc67be8e3e0153f593c509e21d219c5060", "sha256:4918fd5f8b43aa7ec031e0fef1ee02deb80b6afd49c85f0790be1dc4ce34cb50",
"sha256:4558410b7a5607a645e9804a3e9dd509af12fb72b9825b13791a37cd417d73a5", "sha256:4aba818dcc7263852aabb172ec27b71d2abca02a593b95fa79351b2774eb1d2b",
"sha256:4719bb05094d7d8563a450cf8738d2e1061420f79cfcc1fa7f0a44744c4d8f73", "sha256:4e819a806420bc010489f4e741b3036071aba209f2e0989d4750b08b12a9343f",
"sha256:4bfc2b16e3ba8850e0e262467275dd4d62f0d045e0e9eda2bc65078c0110a11f", "sha256:4facc913e10bdba42ec0aee76d029aedda628161a7ce4116b16680a0413f658a",
"sha256:518440c991f514331f4850a63560321f833979d145d7d81186dbe2f19e27ae3d", "sha256:549c3584993772e25f02d0656ac48abdda73169fe347263948cf2b1cead622f3",
"sha256:51f4b32f793812714fd5307222a7f77e739b9bc566dc94a18126aba3b92b98a3", "sha256:5c02fcd2bf45162280613d2e4a1ca3ac558ff921ae4e308ecb307650d3a6ee51",
"sha256:531ac6cf22b53e0696f8e1d56ce2396311254eb806111ddd3922c9d937151dae", "sha256:5f580c651a72b75c39e311343fe6875d6f58cf51c471a97f15a938d9fe4e0d37",
"sha256:5cd05d0f57846d8ba4b71d9c00f6f37d6b97d5e5ef8b3c3840426a475c8f70f4", "sha256:62120ed0de69b3649cc68e2965376048793f466c5a6c4370fb27c16c1beac22d",
"sha256:5dd58946bce44b53b06d94aa95560d0b243eb2fe64227cba50017a8d8b3cd3e2", "sha256:6295004b2dd37b0835ea5c14a33e00e8cfa3c4add4d587b77287825f3418d310",
"sha256:60080bb3d8617d96f0fb7e19796384cc2467447ef1c491694850ebd3670bc457", "sha256:65436dce9fdc0aeeb0a0effe0839cb3d6a05f45aa45a4d9f9c60989beca78b9c",
"sha256:636ba0a77de609d6510235b7f0e77ec494d2657108f777e8765efc060094c98c", "sha256:684008ec44ad275832a5a152f6e764bbe1914bea10968017b6feaecdad5736e0",
"sha256:67d3ccfc590e5e7197750fcb3a2915b416a53e2de847a728cfa60141054123d4", "sha256:684e52023aec43bdf0250e843e1fdd6febbe831bd9d52da72333fa201aaa2335",
"sha256:68191f80a9bad283432385961d9efe09d783bcd36ed35a60fb1ff3f1ec2efe87", "sha256:6cc38067209354e16c5609b66285af17a2863a47585bcf75285cab33d4c3b8df",
"sha256:7502534e55c7c36c0978c91ba6f61703faf7ce733715ca48f499d3dbbd7657e0", "sha256:6f2f017c5be19984fbbf55f8af6caba25e62c71293213f044da3ada7091a4455",
"sha256:7aa47c2e9ea33a4a2a05f40fcd3ea36d73853a2aae7b4feab6fc85f8bf2c9704", "sha256:743deffdf3b3481da32e8a96887e2aa945ec6685af1cfe2bcc292638c9ba2f48",
"sha256:7d2af3f6b8419661a0c421584cfe8aaec1c0e435ce7e47ee2a97e344b98f794f", "sha256:7571f19f4a3fd00af9341c7801d1ad1967fc9c3f5e62402683047e7166b9f2b4",
"sha256:7e316026cc1095f2a3e8cc012822c99f413b702eaa2ca5408a513609488cb62f", "sha256:7731728b6568fc286d86745f27f07266de49603a6fdc4d19c87e8c247be452af",
"sha256:88ad44e220e22b63b0f8f81f007e8abbb92874d8ced66f32571ef8beb0643b2b", "sha256:785c071c982dce54d44ea0b79cd6dfafddeccdd98cfa5f7b86ef69b381b457d9",
"sha256:88d1f7bef20c721359d8675f7d9f8e414ec5003d8f642fdfd8087777ff7f94b5", "sha256:78fddb22b9ef810b63ef341c9fcf6455232d97cfe03938cbc29e2672c436670e",
"sha256:89723d2112697feaa320c9d351e5f5e7b841e83f8b143dba8e2d2b5f04e10923", "sha256:7bb966fdd9217e53abf824f437a5a2d643a38d4fd5fd0ca711b9da683d452969",
"sha256:8a0ccf52bb37d1a700375a6b395bff5dd15c50acb745f7db30415bae3c2b0715", "sha256:7cbc5d9e8a1781e7be17da67b92580d6ce4dcef5819c1b1b89f49d9678cc278c",
"sha256:8c2c19dae8a3eb0ea45a8448356ed561be843b13cbc34b840922ddf565498c1c", "sha256:803b8905b52de78b173d3c1e83df0efb929621e7b7c5766c0843704d5332682f",
"sha256:905466ad1702ed4acfd67a902af50b8db1feeb9781436372261808df7a2a7bca", "sha256:80b696e8972b81edf0af2a259e1b2a4a661f818fae22e5fa4fa1a995fb4a40fd",
"sha256:9852b76ab558e45b20bf1893b59af64a28bd3820b0c2efc80e0a70a4a3ea51c1", "sha256:81500ed5af2090b4a9157a59dbc89873a25c33db1bb9a8cf123837dcc9765047",
"sha256:98a2636994f943b871786c9e82bfe7883ecdaba2ef5df54e1450fa9869d1f756", "sha256:89ec7f2c08937421bbbb8b48c54096fa4f88347946d4747021ad85f1b3021b3c",
"sha256:9aa1a67bbf0f957bbe096375887b2505f5d8ae16bf04488e8b0f334c36e31360", "sha256:8ba6745440b9a27336443b0c285d705ce73adb9ec90e2f2004c64d95ab5a7598",
"sha256:9eda5f7a50141291beda3edd00abc2d4a5b16c29c92daf8d5bd76934150f3edc", "sha256:8c91e1763696c0eb66340c4df98623c2d4e77d0746b8f8f2bee2c6883fd1fe18",
"sha256:a6d1047952c0b8104a1d371f88f4ab62e6275567d4458c1e26e9627ad489b445", "sha256:8d015604ee6204e76569d2f44e5a210728fa917115bef0d102f4107e622b08d5",
"sha256:a9b6d73353f777630626f403b0652055ebfe8ff142a44ec2cf18ae470395766e", "sha256:8d1f86f3f4e2388aa3310b50694ac44daefbd1681def26b4519bd050a398dc5a",
"sha256:a9cc99d6946d750eb75827cb53c4371b8b0fe89c733a94b1573c9dd16ea6c9e4", "sha256:8f83b6fd3dc3ba94d2b22717f9c8b8512354fd95221ac661784df2769ea9bba9",
"sha256:ad83e7545b4ab69216cef4cc47e344d19622e28aabec61574b20257c65466d6a", "sha256:8fc6976a3395fe4d1fbeb984adaa8ec652a1e12f36b56ec8c236e5117b585427",
"sha256:b014333bd0217ad3d54c143de9d4b9a3ca1c5a29a6d0d554952ea071cff0f1f8", "sha256:904c883cf10a975b02ab3478bce652f0f5346a2c28d0a8521d97bb23c323cc8b",
"sha256:b43523d7bc2abd757119dbfb38af91b5735eea45537ec6ec3a5ec3f9562a1c53", "sha256:911742856ce98d879acbea33fcc03c1d8dc1106234c5e7d068932c945db209c0",
"sha256:b521dcecebc5b978b447f0f69b5b7f3840eac454862270406a39837ffae4e697", "sha256:91797b98f5e34b6a49f54be33f72e2fb658018ae532be2f79f7c63b4ae225145",
"sha256:b77e27b79448e34c2c51c09836033056a0547aa360c45eeeb67803da7b0eedaf", "sha256:95399831a206211d6bc40224af1c635cb8790ddd5c7493e0bd03b85711076a53",
"sha256:b7a635871143661feccce3979e1727c4e094f2bdfd3ec4b90dfd4f16f571a87a", "sha256:956b58d692f235cfbf5b4f3abd6d99bf102f161ccfe20d2fd0904f51c72c4c66",
"sha256:b7fca9205b59c1a3d5031f7e64ed627a1074730a51c2a80e97653e3e9fa0d415", "sha256:98c1165f3809ce7774f05cb74e5408cd3aa93ee8573ae959a97a53db3ca3180d",
"sha256:ba1b30765a55acf15dce3f364e4928b80858fa8f979ad41f862358939bdd1f2f", "sha256:9ab40412f8cd6f615bfedea40c8bf0407d41bf83b96f6fc9ff34976d6b7037fd",
"sha256:ba99d8077424501b9616b43a2d208095746fb1284fc5ba490139651f971d39d9", "sha256:9df1bfef97db938469ef0a7354b2d591a2d438bc497b2c489471bec0e6baf7c4",
"sha256:c25a8ad70e716f96e13a637802813f65d8a6760ef48672aa3502f4c24ea8b400", "sha256:a01fe2305e6232ef3e8f40bfc0f0f3a04def9aab514910fa4203bafbc0bb4682",
"sha256:c3c4a78615b7762740531c27cf46e2f388d8d727d0c0c739e72048beb26c8a9d", "sha256:a70b51f55fd954d1f194271695821dd62054d949efd6368d8be64edd37f55c86",
"sha256:c40281f7d70baf6e0db0c2f7472b31609f5bc2748fe7275ea65a0b4601d9b392", "sha256:a7ccdd1c4a3472a7533b0a7aa9ee34c9a2bef859ba86deec07aff2ad7e0c3b94",
"sha256:c7ad32824b7f02bb3c9f80306d405a1d9b7bb89362d68b3c5a9be53836caebdb", "sha256:b340cccad138ecb363324aa26893963dcabb02bb25e440ebdf42e30963f1a4e0",
"sha256:cb3fe77aec8f1995611f966d0c656fdce398317f850d0e6e7aebdfe61f40e1cd", "sha256:b74586dd0b039c62416034f811d7ee62810174bb70dffcca6439f5236249eb09",
"sha256:cc038b2d8b1470364b1888a98fd22d616fba2b6309c5b5f181ad4483e0017861", "sha256:b9d320b3bf82a39f248769fc7f188e00f93526cc0fe739cfa197868633d44701",
"sha256:cc37b9aeebab425f11f27e5e9e6cf580be7206c6582a64467a14dda211abc232", "sha256:ba2336d6548dee3117520545cfe44dc28a250aa091f8281d28804aa8d707d93d",
"sha256:cc6bb9aa69aacf0f6032c307da718f61a40cf970849e471254e0e91c56ffca95", "sha256:ba8122e3bb94ecda29a8de4cf889f600171424ea586847aa92c334772d200331",
"sha256:d126361607b33c4eb7b36debc173bf25d7805847346dd4d99b5499e1fef52bc7", "sha256:bd727ad276bb91928879f3aa6396c9a1d34e5e180dce40578421a691eeb77f47",
"sha256:d15b274f9e15b1a0b7a45d2ac86d1f634d983ca40d6b886721626c47a400bf39", "sha256:c21fc21a4c7480479d12fd8e679b699f744f76bb05f53a1d14182b31f55aac76",
"sha256:d166eafc19f4718df38887b2bbe1467a4f74a9830e8605089ea7a30dd4da8887", "sha256:c2d0e7cbb6341e830adcbfa2479fdeebbfbb328f11edd6b5675674e7a1e37730",
"sha256:d498eea3f581fbe1b34b59c697512a8baef88212f92e4c7830fcc1499f5b45a5", "sha256:c2ef6f7990b6e8758fe48ad08f7e2f66c8f11dc66e24093304b87cae9037bb4a",
"sha256:d6f7e255e5fa94642a0724e35406e6cb7001c09d476ab5fce002f652b36d0c39", "sha256:c4ed75ea6892a56896d78f11006161eea52c45a14994794bcfa1654430984b22",
"sha256:d78bd484930c1da2b9679290a41cdb25cc127d783768a0369d6b449e72f88beb", "sha256:cccc79a9be9b64c881f18305a7c715ba199e471a3973faeb7ba84172abb3f317",
"sha256:d865984b3f71f6d0af64d0d88f5733521698f6c16f445bb09ce746c92c97c586", "sha256:d0800631e565c47520aaa04ae38b96abc5196fe8b4aa9bd864445bd2b5848a7a",
"sha256:d902a43085a308cef32c0d3aea962524b725403fd9373dea18110904003bac97", "sha256:d2da13568eff02b30fd54fccd1e042a70fe920d816616fda4bf54ec705668d81",
"sha256:d94a1db462d5690ebf6ae86d11c5e420042b9898af5dcf278bd97d6bda065423", "sha256:d61ae114d2a2311f61d90c2ef1358518e8f05eafda76eaf9c772a077e0b465ec",
"sha256:da695d75ac97cb1cd725adac136d25ca687da4536154cdc2815f576e4da11c69", "sha256:d83c2bc678453646f1a18f8db1e927a2d3f4935031b9ad8a76e56760461105dd",
"sha256:db2a0b1857f18b11e3b0e54ddfefc96af46b0896fb678c85f63fb8c37518b3e7", "sha256:dd5acc0a7d38fdc7a3a6fd3ad14c880819008ecb3379626e56b163165162cc46",
"sha256:df26481f0c7a3f8739fecb3e81bc9da3fcfae34d6c094563b9d4670b047312e1", "sha256:df79012ebf6f4efb8d307b1328226aef24ca446b3ff8d0e30202d7ebcb977a8c",
"sha256:e14b73607d6231f3cc4622809c196b540a6a44e903bcfad940779c80dffa7be7", "sha256:e0a2df336d1135a0b3a67f3bbf78a75f69562c1199ed9935372b82215cddd6e2",
"sha256:e2610e9406d3b0073636a3a2e80db05a02f0c3169b5632022b4e81c0364bcda5", "sha256:e2f142b45c6fed48166faeb4303b4b58c9fcd827da63f4cf0a123c3480ae11fb",
"sha256:e692296c4cc2873967771345a876bcfc1c547e8dd695c6b89342488b0ea55cd8", "sha256:e697e1c0238133589e00c244a8b676bc2cfc3ab4961318d902040d099fec7483",
"sha256:e693e233ac92ba83a87024e1d32b5f9ab15ca55ddd916d878146f4e3406b5c91", "sha256:e757d475953269fbf4b441207bb7dbdd1c43180711b6208e129b637792ac0b93",
"sha256:e81469f7d01efed9b53740aedd26085f20d49da65f9c1f41e822a33992cb1590", "sha256:e87ab229332ceb127a165612d839ab87795972102cb9830e5f12b8c9a5c1b508",
"sha256:e8c7e08bb566de4faaf11984af13f6bcf6a08f327b13631d41d62592681d24fe", "sha256:ea355eb43b11764cf799dda62c658c4d2fdb16af41f59bb1ccfec517b60bcb07",
"sha256:ed19b3a05ae0c97dd8f75a5d8f21f7723a8c33bbc555da6bbe1f96c470139d3c", "sha256:ec7e0043b91115f427998febaa2beb82c82df708168b35ece3accb610b91fac1",
"sha256:efb2d82f33b2212898f1659fb1c2e9ac30493ac41e4d53123da374c3b5541e64", "sha256:eeaa0b5328b785abc344acc6241cffde50dc394a0644a968add75fcefe15b9d4",
"sha256:f44dd4d68697559d007462b0a3a1d9acd61d97072b71f6d1968daef26bc744bd", "sha256:f2d80a6749724b37853ece57988b39c4e79d2b5fe2869a86e8aeae3bbeef9eb0",
"sha256:f72cbae7f6b01591f90814250e636065850c5926751af02bb48da94dfced7baa", "sha256:fa454d26f2e87ad661c4f0c5a5fe4cf6aab1e307d1b94f16ffdfcb089ba685c0",
"sha256:f7bc09bc9c29ebead055bcba136a67378f03d66bf359e87d0f7c759d6d4ffa31", "sha256:fb83cc090eac63c006871fd24db5e30a1f282faa46328572661c0a24a2323a08",
"sha256:ff100b203092af77d1a5a7abe085b3506b7eaaf9abf65b73b7d6905b6cb76988" "sha256:fd80d1280d473500d8086d104962a82d77bfbf2b118053824b7be28cd5a79ea5"
], ],
"markers": "python_version >= '3.7'", "markers": "python_version >= '3.7'",
"version": "==2023.12.25" "version": "==2024.4.16"
}, },
"requests": { "requests": {
"hashes": [ "hashes": [
@@ -3581,27 +3583,27 @@
}, },
"ruff": { "ruff": {
"hashes": [ "hashes": [
"sha256:122de171a147c76ada00f76df533b54676f6e321e61bd8656ae54be326c10296", "sha256:0926cefb57fc5fced629603fbd1a23d458b25418681d96823992ba975f050c2b",
"sha256:3a05f3793ba25f194f395578579c546ca5d83e0195f992edc32e5907d142bfa3", "sha256:1c859f294f8633889e7d77de228b203eb0e9a03071b72b5989d89a0cf98ee262",
"sha256:5e55771559c89272c3ebab23326dc23e7f813e492052391fe7950c1a5a139d89", "sha256:2c6e37f2e3cd74496a74af9a4fa67b547ab3ca137688c484749189bf3a686ceb",
"sha256:712e71283fc7d9f95047ed5f793bc019b0b0a29849b14664a60fd66c23b96da1", "sha256:2d9ef6231e3fbdc0b8c72404a1a0c46fd0dcea84efca83beb4681c318ea6a953",
"sha256:87258e0d4b04046cf1d6cc1c56fadbf7a880cc3de1f7294938e923234cf9e498", "sha256:6e68d248ed688b9d69fd4d18737edcbb79c98b251bba5a2b031ce2470224bdf9",
"sha256:89b1e92b3bd9fca249153a97d23f29bed3992cff414b222fcd361d763fc53f12", "sha256:9485f54a7189e6f7433e0058cf8581bee45c31a25cd69009d2a040d1bd4bfaef",
"sha256:9d8605aa990045517c911726d21293ef4baa64f87265896e491a05461cae078d", "sha256:a1eaf03d87e6a7cd5e661d36d8c6e874693cb9bc3049d110bc9a97b350680c43",
"sha256:a067daaeb1dc2baf9b82a32dae67d154d95212080c80435eb052d95da647763d", "sha256:b34510141e393519a47f2d7b8216fec747ea1f2c81e85f076e9f2910588d4b64",
"sha256:a532a90b4a18d3f722c124c513ffb5e5eaff0cc4f6d3aa4bda38e691b8600c9f", "sha256:b90506f3d6d1f41f43f9b7b5ff845aeefabed6d2494307bc7b178360a8805252",
"sha256:a759d33a20c72f2dfa54dae6e85e1225b8e302e8ac655773aff22e542a300985", "sha256:b92f03b4aa9fa23e1799b40f15f8b95cdc418782a567d6c43def65e1bbb7f1cf",
"sha256:a7b6e63194c68bca8e71f81de30cfa6f58ff70393cf45aab4c20f158227d5936", "sha256:baa27d9d72a94574d250f42b7640b3bd2edc4c58ac8ac2778a8c82374bb27984",
"sha256:aef5bd3b89e657007e1be6b16553c8813b221ff6d92c7526b7e0227450981eac", "sha256:c7d391e5936af5c9e252743d767c564670dc3889aff460d35c518ee76e4b26d7",
"sha256:d80a6b18a6c3b6ed25b71b05eba183f37d9bc8b16ace9e3d700997f00b74660b", "sha256:d2921ac03ce1383e360e8a95442ffb0d757a6a7ddd9a5be68561a671e0e5807e",
"sha256:dabc62195bf54b8a7876add6e789caae0268f34582333cda340497c886111c39", "sha256:d592116cdbb65f8b1b7e2a2b48297eb865f6bdc20641879aa9d7b9c11d86db79",
"sha256:dc56bb16a63c1303bd47563c60482a1512721053d93231cf7e9e1c6954395a0e", "sha256:eec8d185fe193ad053eda3a6be23069e0c8ba8c5d20bc5ace6e3b9e37d246d3f",
"sha256:dfd3504e881082959b4160ab02f7a205f0fadc0a9619cc481982b6837b2fd4c0", "sha256:efd703a5975ac1998c2cc5e9494e13b28f31e66c616b0a76e206de2562e0843c",
"sha256:faeeae9905446b975dcf6d4499dc93439b131f1443ee264055c5716dd947af55" "sha256:f1ee41580bff1a651339eb3337c20c12f4037f6110a36ae4a2d864c52e5ef954"
], ],
"index": "pypi", "index": "pypi",
"markers": "python_version >= '3.7'", "markers": "python_version >= '3.7'",
"version": "==0.3.5" "version": "==0.4.1"
}, },
"scipy": { "scipy": {
"hashes": [ "hashes": [
@@ -3643,11 +3645,11 @@
}, },
"setuptools": { "setuptools": {
"hashes": [ "hashes": [
"sha256:0ff4183f8f42cd8fa3acea16c45205521a4ef28f73c6391d8a25e92893134f2e", "sha256:6c1fccdac05a97e598fb0ae3bbed5904ccb317337a51139dcd51453611bbb987",
"sha256:c21c49fb1042386df081cb5d86759792ab89efca84cf114889191cd09aacc80c" "sha256:c636ac361bc47580504644275c9ad802c50415c7522212252c033bd15f301f32"
], ],
"markers": "python_version >= '3.8'", "markers": "python_version >= '3.8'",
"version": "==69.2.0" "version": "==69.5.1"
}, },
"six": { "six": {
"hashes": [ "hashes": [
@@ -3702,11 +3704,11 @@
}, },
"typing-extensions": { "typing-extensions": {
"hashes": [ "hashes": [
"sha256:69b1a937c3a517342112fb4c6df7e72fc39a38e7891a5730ed4985b5214b5475", "sha256:83f085bd5ca59c80295fc2a82ab5dac679cbe02b9f33f7d83af68e241bea51b0",
"sha256:b0abd7c89e8fb96f98db18d86106ff1d90ab692004eb746cf6eda2682f91b3cb" "sha256:c1f94d72897edaf4ce775bb7558d5b79d8126906a14ea5ed1635921406c0387a"
], ],
"markers": "python_version < '3.11'", "markers": "python_version < '3.11'",
"version": "==4.10.0" "version": "==4.11.0"
}, },
"urllib3": { "urllib3": {
"hashes": [ "hashes": [
@@ -3769,45 +3771,45 @@
}, },
"zope-interface": { "zope-interface": {
"hashes": [ "hashes": [
"sha256:02adbab560683c4eca3789cc0ac487dcc5f5a81cc48695ec247f00803cafe2fe", "sha256:014bb94fe6bf1786da1aa044eadf65bc6437bcb81c451592987e5be91e70a91e",
"sha256:14e02a6fc1772b458ebb6be1c276528b362041217b9ca37e52ecea2cbdce9fac", "sha256:01a0b3dd012f584afcf03ed814bce0fc40ed10e47396578621509ac031be98bf",
"sha256:25e0af9663eeac6b61b231b43c52293c2cb7f0c232d914bdcbfd3e3bd5c182ad", "sha256:10cde8dc6b2fd6a1d0b5ca4be820063e46ddba417ab82bcf55afe2227337b130",
"sha256:2606955a06c6852a6cff4abeca38346ed01e83f11e960caa9a821b3626a4467b", "sha256:187f7900b63845dcdef1be320a523dbbdba94d89cae570edc2781eb55f8c2f86",
"sha256:396f5c94654301819a7f3a702c5830f0ea7468d7b154d124ceac823e2419d000", "sha256:1b0c4c90e5eefca2c3e045d9f9ed9f1e2cdbe70eb906bff6b247e17119ad89a1",
"sha256:3b240883fb43160574f8f738e6d09ddbdbf8fa3e8cea051603d9edfd947d9328", "sha256:22e8a218e8e2d87d4d9342aa973b7915297a08efbebea5b25900c73e78ed468e",
"sha256:3b6c62813c63c543a06394a636978b22dffa8c5410affc9331ce6cdb5bfa8565", "sha256:26c9a37fb395a703e39b11b00b9e921c48f82b6e32cc5851ad5d0618cd8876b5",
"sha256:4ae9793f114cee5c464cc0b821ae4d36e1eba961542c6086f391a61aee167b6f", "sha256:2bb78c12c1ad3a20c0d981a043d133299117b6854f2e14893b156979ed4e1d2c",
"sha256:4bce517b85f5debe07b186fc7102b332676760f2e0c92b7185dd49c138734b70", "sha256:2c3cfb272bcb83650e6695d49ae0d14dd06dc694789a3d929f23758557a23d92",
"sha256:4d45d2ba8195850e3e829f1f0016066a122bfa362cc9dc212527fc3d51369037", "sha256:2f32010ffb87759c6a3ad1c65ed4d2e38e51f6b430a1ca11cee901ec2b42e021",
"sha256:4dd374927c00764fcd6fe1046bea243ebdf403fba97a937493ae4be2c8912c2b", "sha256:3c8731596198198746f7ce2a4487a0edcbc9ea5e5918f0ab23c4859bce56055c",
"sha256:506f5410b36e5ba494136d9fa04c548eaf1a0d9c442b0b0e7a0944db7620e0ab", "sha256:40aa8c8e964d47d713b226c5baf5f13cdf3a3169c7a2653163b17ff2e2334d10",
"sha256:59f7374769b326a217d0b2366f1c176a45a4ff21e8f7cebb3b4a3537077eff85", "sha256:4137025731e824eee8d263b20682b28a0bdc0508de9c11d6c6be54163e5b7c83",
"sha256:5ee9789a20b0081dc469f65ff6c5007e67a940d5541419ca03ef20c6213dd099", "sha256:46034be614d1f75f06e7dcfefba21d609b16b38c21fc912b01a99cb29e58febb",
"sha256:6fc711acc4a1c702ca931fdbf7bf7c86f2a27d564c85c4964772dadf0e3c52f5", "sha256:483e118b1e075f1819b3c6ace082b9d7d3a6a5eb14b2b375f1b80a0868117920",
"sha256:75d2ec3d9b401df759b87bc9e19d1b24db73083147089b43ae748aefa63067ef", "sha256:4d6b229f5e1a6375f206455cc0a63a8e502ed190fe7eb15e94a312dc69d40299",
"sha256:76e0531d86523be7a46e15d379b0e975a9db84316617c0efe4af8338dc45b80c", "sha256:567d54c06306f9c5b6826190628d66753b9f2b0422f4c02d7c6d2b97ebf0a24e",
"sha256:8af82afc5998e1f307d5e72712526dba07403c73a9e287d906a8aa2b1f2e33dd", "sha256:5683aa8f2639016fd2b421df44301f10820e28a9b96382a6e438e5c6427253af",
"sha256:8f5d2c39f3283e461de3655e03faf10e4742bb87387113f787a7724f32db1e48", "sha256:600101f43a7582d5b9504a7c629a1185a849ce65e60fca0f6968dfc4b76b6d39",
"sha256:97785604824981ec8c81850dd25c8071d5ce04717a34296eeac771231fbdd5cd", "sha256:62e32f02b3f26204d9c02c3539c802afc3eefb19d601a0987836ed126efb1f21",
"sha256:a3046e8ab29b590d723821d0785598e0b2e32b636a0272a38409be43e3ae0550", "sha256:69dedb790530c7ca5345899a1b4cb837cc53ba669051ea51e8c18f82f9389061",
"sha256:abb0b3f2cb606981c7432f690db23506b1db5899620ad274e29dbbbdd740e797", "sha256:72d5efecad16c619a97744a4f0b67ce1bcc88115aa82fcf1dc5be9bb403bcc0b",
"sha256:ac7c2046d907e3b4e2605a130d162b1b783c170292a11216479bb1deb7cadebe", "sha256:8d407e0fd8015f6d5dfad481309638e1968d70e6644e0753f229154667dd6cd5",
"sha256:af27b3fe5b6bf9cd01b8e1c5ddea0a0d0a1b8c37dc1c7452f1e90bf817539c6d", "sha256:a058e6cf8d68a5a19cb5449f42a404f0d6c2778b897e6ce8fadda9cea308b1b0",
"sha256:b386b8b9d2b6a5e1e4eadd4e62335571244cb9193b7328c2b6e38b64cfda4f0e", "sha256:a1adc14a2a9d5e95f76df625a9b39f4709267a483962a572e3f3001ef90ea6e6",
"sha256:b66335bbdbb4c004c25ae01cc4a54fd199afbc1fd164233813c6d3c2293bb7e1", "sha256:a56fe1261230093bfeedc1c1a6cd6f3ec568f9b07f031c9a09f46b201f793a85",
"sha256:d54f66c511ea01b9ef1d1a57420a93fbb9d48a08ec239f7d9c581092033156d0", "sha256:ad4524289d8dbd6fb5aa17aedb18f5643e7d48358f42c007a5ee51a2afc2a7c5",
"sha256:de125151a53ecdb39df3cb3deb9951ed834dd6a110a9e795d985b10bb6db4532", "sha256:afa0491a9f154cf8519a02026dc85a416192f4cb1efbbf32db4a173ba28b289a",
"sha256:de7916380abaef4bb4891740879b1afcba2045aee51799dfd6d6ca9bdc71f35f", "sha256:bf34840e102d1d0b2d39b1465918d90b312b1119552cebb61a242c42079817b9",
"sha256:e2fefad268ff5c5b314794e27e359e48aeb9c8bb2cbb5748a071757a56f6bb8f", "sha256:c40df4aea777be321b7e68facb901bc67317e94b65d9ab20fb96e0eb3c0b60a1",
"sha256:e7b2bed4eea047a949296e618552d3fed00632dc1b795ee430289bdd0e3717f3", "sha256:d0e7321557c702bd92dac3c66a2f22b963155fdb4600133b6b29597f62b71b12",
"sha256:e87698e2fea5ca2f0a99dff0a64ce8110ea857b640de536c76d92aaa2a91ff3a", "sha256:d165d7774d558ea971cb867739fb334faf68fc4756a784e689e11efa3becd59e",
"sha256:ede888382882f07b9e4cd942255921ffd9f2901684198b88e247c7eabd27a000", "sha256:e78a183a3c2f555c2ad6aaa1ab572d1c435ba42f1dc3a7e8c82982306a19b785",
"sha256:f444de0565db46d26c9fa931ca14f497900a295bd5eba480fc3fad25af8c763e", "sha256:e8fa0fb05083a1a4216b4b881fdefa71c5d9a106e9b094cd4399af6b52873e91",
"sha256:fa994e8937e8ccc7e87395b7b35092818905cf27c651e3ff3e7f29729f5ce3ce", "sha256:f83d6b4b22262d9a826c3bd4b2fbfafe1d0000f085ef8e44cd1328eea274ae6a",
"sha256:febceb04ee7dd2aef08c2ff3d6f8a07de3052fc90137c507b0ede3ea80c21440" "sha256:f95bebd0afe86b2adc074df29edb6848fc4d474ff24075e2c263d698774e108d"
], ],
"markers": "python_version >= '3.7'", "markers": "python_version >= '3.7'",
"version": "==6.2" "version": "==6.3"
} }
}, },
"typing-dev": { "typing-dev": {

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

@@ -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

View File

@@ -1,5 +1,25 @@
# 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 ## paperless-ngx 2.7.1
### Bug Fixes ### Bug Fixes

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. |
@@ -468,6 +472,12 @@ Paperless-ngx supports 3 basic editing operations for PDFs (these operations can
Note that rotation alters the Paperless-ngx _original_ file, which would, for example, invalidate a digital signature. 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
default_time_zone=$(timedatectl show -p Timezone --value) # 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)
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

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

View File

@@ -17531,9 +17531,9 @@
} }
}, },
"node_modules/tar": { "node_modules/tar": {
"version": "6.2.0", "version": "6.2.1",
"resolved": "https://registry.npmjs.org/tar/-/tar-6.2.0.tgz", "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz",
"integrity": "sha512-/Wo7DcT0u5HUV486xg675HtjNd3BXZ6xDbzsCUZPt5iw8bTQ63bP0Raut3mvro9u+CUyq7YQd8Cx55fsZXxqLQ==", "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==",
"devOptional": true, "devOptional": true,
"dependencies": { "dependencies": {
"chownr": "^2.0.0", "chownr": "^2.0.0",

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'
@@ -119,6 +119,9 @@ import { NgxFilesizeModule } from 'ngx-filesize'
import { RotateConfirmDialogComponent } from './components/common/confirm-dialog/rotate-confirm-dialog/rotate-confirm-dialog.component' 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 { 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 { 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,
@@ -137,7 +140,9 @@ import {
boxes, boxes,
calendar, calendar,
calendarEvent, calendarEvent,
calendarEventFill,
cardChecklist, cardChecklist,
cardHeading,
caretDown, caretDown,
caretUp, caretUp,
chatLeftText, chatLeftText,
@@ -231,7 +236,9 @@ const icons = {
boxes, boxes,
calendar, calendar,
calendarEvent, calendarEvent,
calendarEventFill,
cardChecklist, cardChecklist,
cardHeading,
caretDown, caretDown,
caretUp, caretUp,
chatLeftText, chatLeftText,
@@ -402,7 +409,7 @@ function initializeApp(settings: SettingsService) {
FilterEditorComponent, FilterEditorComponent,
FilterableDropdownComponent, FilterableDropdownComponent,
ToggleableDropdownButtonComponent, ToggleableDropdownButtonComponent,
DateDropdownComponent, DatesDropdownComponent,
DocumentCardLargeComponent, DocumentCardLargeComponent,
DocumentCardSmallComponent, DocumentCardSmallComponent,
BulkEditorComponent, BulkEditorComponent,
@@ -472,6 +479,9 @@ function initializeApp(settings: SettingsService) {
RotateConfirmDialogComponent, RotateConfirmDialogComponent,
MergeConfirmDialogComponent, MergeConfirmDialogComponent,
SplitConfirmDialogComponent, SplitConfirmDialogComponent,
DocumentHistoryComponent,
DragDropSelectComponent,
CustomFieldDisplayComponent,
], ],
imports: [ imports: [
BrowserModule, BrowserModule,

View File

@@ -7,29 +7,30 @@
<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>
<button class="btn btn-sm btn-outline-primary position-relative ms-md-5 me-1" (click)="showSystemStatus()" @if (permissionsService.isAdmin()) {
[disabled]="!systemStatus" <button class="btn btn-sm btn-outline-primary position-relative ms-md-5 me-1" (click)="showSystemStatus()"
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Admin }"> [disabled]="!systemStatus">
@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 {
<i-bs class="me-2" name="card-checklist"></i-bs>
@if (systemStatusHasErrors) {
<span class="badge bg-body position-absolute top-0 start-100 translate-middle rounded-pill p-0">
<i-bs name="exclamation-circle-fill" class="text-danger" width="1.75em" height="1.75em"></i-bs>
</span>
} @else { } @else {
<span class="badge bg-body position-absolute top-0 start-100 translate-middle rounded-pill p-0"> <i-bs class="me-2" name="card-checklist"></i-bs>
<i-bs name="check-circle-fill" class="text-primary" width="1.75em" height="1.75em"></i-bs> @if (systemStatusHasErrors) {
</span> <span class="badge bg-body position-absolute top-0 start-100 translate-middle rounded-pill p-0">
<i-bs name="exclamation-circle-fill" class="text-danger" width="1.75em" height="1.75em"></i-bs>
</span>
} @else {
<span class="badge bg-body position-absolute top-0 start-100 translate-middle rounded-pill p-0">
<i-bs name="check-circle-fill" class="text-primary" width="1.75em" height="1.75em"></i-bs>
</span>
}
} }
} <ng-container i18n>System Status</ng-container>
<ng-container i18n>System Status</ng-container> </button>
</button> <a class="btn btn-sm btn-primary" href="admin/" target="_blank">
<a *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Admin }" 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,52 +320,71 @@
</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 class="mb-2 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">
<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>
</div> </div>
<div class="form-check form-switch"> <div class="col">
<input type="checkbox" class="form-check-input" id="show_in_sidebar_{{view.id}}" formControlName="show_in_sidebar"> <div class="form-check form-switch mt-3">
<label class="form-check-label" for="show_in_sidebar_{{view.id}}" i18n>Show in sidebar</label> <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>
</div>
<div class="form-check form-switch">
<input type="checkbox" class="form-check-input" id="show_in_sidebar_{{view.id}}" formControlName="show_in_sidebar">
<label class="form-check-label" for="show_in_sidebar_{{view.id}}" i18n>Show in sidebar</label>
</div>
</div>
<div class="col-auto">
<label class="form-label" for="name_{{view.id}}" i18n>Actions</label>
<pngx-confirm-button
label="Delete"
i18n-label
(confirm)="deleteSavedView(view)"
*pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.SavedView }"
buttonClasses="btn-sm btn-outline-danger form-control"
iconName="trash">
</pngx-confirm-button>
</div> </div>
</div> </div>
<div class="mb-2 col-auto"> <div class="row">
<label class="form-label" for="name_{{view.id}}" i18n>Actions</label> <div class="col">
<pngx-input-number i18n-title title="Documents page size" [showAdd]="false" formControlName="page_size"></pngx-input-number>
<pngx-confirm-button </div>
label="Delete" <div class="col">
i18n-label <label class="form-label" for="display_mode_{{view.id}}" i18n>Display as</label>
(confirm)="deleteSavedView(view)" <select class="form-select" formControlName="display_mode">
*pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.SavedView }" <option [ngValue]="DisplayMode.TABLE" i18n>Table</option>
buttonClasses="btn-sm btn-outline-danger form-control" <option [ngValue]="DisplayMode.SMALL_CARDS" i18n>Small Cards</option>
iconName="trash"> <option [ngValue]="DisplayMode.LARGE_CARDS" i18n>Large Cards</option>
</pngx-confirm-button> </select>
</div> </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> </div>
</li>
} }
@if (savedViews && savedViews.length === 0) { @if (savedViews && savedViews.length === 0) {
<div i18n>No saved views defined.</div> <li class="list-group-item">
<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

@@ -52,40 +52,38 @@
<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">
<div class="row">
<div class="col" i18n>Name</div>
<div class="col"></div>
<div class="col"></div>
<div class="col" i18n>Actions</div>
</div>
</li>
@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" i18n>Name</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" i18n>Actions</div> <div class="col">
</div> <div class="btn-group">
</li> <button class="btn btn-sm btn-outline-secondary" type="button" (click)="editGroup(group)" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.Group }">
@for (group of groups; track group) { <i-bs width="1em" height="1em" name="pencil"></i-bs>&nbsp;<ng-container i18n>Edit</ng-container>
<li class="list-group-item"> </button>
<div class="row"> <button class="btn btn-sm btn-outline-danger" type="button" (click)="deleteGroup(group)" *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.Group }">
<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> <i-bs width="1em" height="1em" name="trash"></i-bs>&nbsp;<ng-container i18n>Delete</ng-container>
<div class="col"></div> </button>
<div class="col"></div>
<div class="col">
<div class="btn-group">
<button class="btn btn-sm btn-outline-secondary" type="button" (click)="editGroup(group)" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.Group }">
<i-bs width="1em" height="1em" name="pencil"></i-bs>&nbsp;<ng-container i18n>Edit</ng-container>
</button>
<button class="btn btn-sm btn-outline-danger" type="button" (click)="deleteGroup(group)" *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.Group }">
<i-bs width="1em" height="1em" name="trash"></i-bs>&nbsp;<ng-container i18n>Delete</ng-container>
</button>
</div>
</div> </div>
</div> </div>
</li> </div>
} </li>
@if (groups.length === 0) { }
<li class="list-group-item" i18n>No groups defined</li> @if (groups.length === 0) {
} <li class="list-group-item" i18n>No groups defined</li>
</ul> }
} </ul>
} }
@if (!users || !groups) { @if (!users || !groups) {

View File

@@ -111,7 +111,7 @@
</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 app-link" 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)">
@@ -267,13 +267,15 @@
} }
</a> </a>
</li> </li>
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Admin }"> @if (permissionsService.isAdmin()) {
<a class="nav-link" routerLink="logs" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Logs" <li class="nav-item app-link">
i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" container="body" <a class="nav-link" routerLink="logs" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Logs"
triggers="mouseenter:mouseleave" popoverClass="popover-slim"> i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" container="body"
<i-bs class="me-1" name="text-left"></i-bs><span>&nbsp;<ng-container i18n>Logs</ng-container></span> triggers="mouseenter:mouseleave" popoverClass="popover-slim">
</a> <i-bs class="me-1" name="text-left"></i-bs><span>&nbsp;<ng-container i18n>Logs</ng-container></span>
</li> </a>
</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"

View File

@@ -36,14 +36,18 @@ describe('MergeConfirmDialogComponent', () => {
{ id: 2, name: 'Document 2' }, { id: 2, name: 'Document 2' },
{ id: 3, name: 'Document 3' }, { id: 3, name: 'Document 3' },
] ]
jest.spyOn(documentService, 'getCachedMany').mockReturnValue(of(documents)) jest.spyOn(documentService, 'getFew').mockReturnValue(
of({
all: documents.map((d) => d.id),
count: documents.length,
results: documents,
})
)
component.ngOnInit() component.ngOnInit()
expect(component.documents).toEqual(documents) expect(component.documents).toEqual(documents)
expect(documentService.getCachedMany).toHaveBeenCalledWith( expect(documentService.getFew).toHaveBeenCalledWith(component.documentIDs)
component.documentIDs
)
}) })
it('should move documentIDs on drop', () => { it('should move documentIDs on drop', () => {
@@ -64,7 +68,13 @@ describe('MergeConfirmDialogComponent', () => {
{ id: 2, name: 'Document 2' }, { id: 2, name: 'Document 2' },
{ id: 3, name: 'Document 3' }, { id: 3, name: 'Document 3' },
] ]
jest.spyOn(documentService, 'getCachedMany').mockReturnValue(of(documents)) jest.spyOn(documentService, 'getFew').mockReturnValue(
of({
all: documents.map((d) => d.id),
count: documents.length,
results: documents,
})
)
component.ngOnInit() component.ngOnInit()

View File

@@ -34,10 +34,10 @@ export class MergeConfirmDialogComponent
ngOnInit() { ngOnInit() {
this.documentService this.documentService
.getCachedMany(this.documentIDs) .getFew(this.documentIDs)
.pipe(takeUntil(this.unsubscribeNotifier)) .pipe(takeUntil(this.unsubscribeNotifier))
.subscribe((documents) => { .subscribe((r) => {
this._documents = documents this._documents = r.results
}) })
} }

View File

@@ -12,7 +12,7 @@
</div> </div>
<div class="col-8 d-flex align-items-center"> <div class="col-8 d-flex align-items-center">
@if (documentID) { @if (documentID) {
<img class="w-50 m-auto" [ngStyle]="{'transform': 'rotate('+rotation+'deg)'}" [src]="documentService.getThumbUrl(documentID)" /> <img class="w-75 m-auto" [ngStyle]="{'transform': 'rotate('+rotation+'deg)'}" [src]="documentService.getThumbUrl(documentID)" />
} }
</div> </div>
<div class="col-2 d-flex"> <div class="col-2 d-flex">

View File

@@ -6,7 +6,7 @@
<div class="modal-body"> <div class="modal-body">
<p>{{message}}</p> <p>{{message}}</p>
<div class="row mb-2"> <div class="row mb-2">
<div class="col-6"> <div class="col-8">
<div class="input-group input-group-sm"> <div class="input-group input-group-sm">
<div class="input-group-text" i18n>Page</div> <div class="input-group-text" i18n>Page</div>
<input class="form-control" type="number" min="1" [(ngModel)]="page" /> <input class="form-control" type="number" min="1" [(ngModel)]="page" />
@@ -21,9 +21,9 @@
</pngx-pdf-viewer> </pngx-pdf-viewer>
</div> </div>
</div> </div>
<div class="col-6"> <div class="col-4">
<div class="d-grid"> <div class="d-grid">
<button class="btn btn-sm btn-primary" (click)="addSplit()"> <button class="btn btn-sm btn-primary" (click)="addSplit()" [disabled]="page === totalPages">
<i-bs name="plus-circle"></i-bs>&nbsp; <i-bs name="plus-circle"></i-bs>&nbsp;
<span i18n>Add Split</span> <span i18n>Add Split</span>
</button> </button>
@@ -31,11 +31,11 @@
<ul class="list-group mt-3"> <ul class="list-group mt-3">
@for (pageStr of pagesString.split(','); track pageStr; let i = $index) { @for (pageStr of pagesString.split(','); track pageStr; let i = $index) {
<li class="list-group-item"> <li class="list-group-item d-flex align-items-center">
{{pageStr}} {{pageStr}}
@if (pagesString.split(',').length > 1) { @if (pagesString.split(',').length > 1) {
&nbsp; &nbsp;
<button class="btn btn-sm btn-danger" (click)="removeSplit(i)"> <button class="btn btn-sm btn-danger ms-auto" (click)="removeSplit(i)">
<i-bs name="trash"></i-bs> <i-bs name="trash"></i-bs>
</button> </button>
} }

View File

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

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 ''
}
get monetaryValue(): string { const val: number = parseFloat(value.toString().replace(/[^0-9.,-]+/g, ''))
if (!this.value) return null return fixed ? val.toFixed(2) : val.toString()
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,58 +9,114 @@
<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]="{
@if (permissionsService.currentUserCan(PermissionAction.View, PermissionType.Correspondent)) { 'd-none d-md-table-cell': i > 1,
<th scope="col" class="d-none d-md-table-cell" i18n>Correspondent</th> 'w-25': field === DisplayField.CREATED || field === DisplayField.ADDED
} @else { }">
<th scope="col" class="d-none d-md-table-cell"></th> {{ getColumnTitle(field) }}
</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 }">
<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> @switch (field) {
</td> @case (DisplayField.ADDED) {
@if (permissionsService.currentUserCan(PermissionAction.View, PermissionType.Tag)) { <a routerLink="/documents/{{doc.id}}" class="btn-link text-dark text-decoration-none py-2 py-md-3">{{doc.added | customDate}}</a>
<td class="py-2 py-md-3 d-none d-md-table-cell"> }
@for (t of doc.tags$ | async; track t) { @case (DisplayField.CREATED) {
<pngx-tag [tag]="t" class="ms-1" (click)="clickTag(t, $event)"></pngx-tag> <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>
}
@case (DisplayField.CORRESPONDENT) {
@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) {
<pngx-tag [tag]="t" class="ms-1" (click)="clickTag(t.id, $event)"></pngx-tag>
}
}
@case (DisplayField.DOCUMENT_TYPE) {
@if (doc.document_type) {
<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">
<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"
autoClose="true" popoverClass="shadow popover-preview" container="body" (mouseenter)="mouseEnterPreviewButton(doc)" (mouseleave)="mouseLeavePreviewButton()" #popover="ngbPopover">
<i-bs width="0.8em" height="0.8em" name="eye"></i-bs>
</a>
<ng-template #previewContent>
<pngx-preview-popup [document]="doc" (mouseenter)="mouseEnterPreview()" (mouseleave)="mouseLeavePreview()"></pngx-preview-popup>
</ng-template>
<a [href]="getDownloadUrl(doc)" class="btn px-4 btn-dark border-dark-subtle" title="Download" i18n-title (click)="$event.stopPropagation()">
<i-bs width="0.8em" height="0.8em" name="download"></i-bs>
</a>
</div>
} }
</td> </td>
} }
<td class="position-relative py-2 py-md-3 d-none d-md-table-cell">
@if (permissionsService.currentUserCan(PermissionAction.View, PermissionType.Correspondent) && doc.correspondent !== null) {
<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>
}
<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"
[ngbPopover]="previewContent" [popoverTitle]="doc.title | documentTitle"
autoClose="true" popoverClass="shadow popover-preview" container="body" (mouseenter)="mouseEnterPreviewButton(doc)" (mouseleave)="mouseLeavePreviewButton()" #popover="ngbPopover">
<i-bs width="0.8em" height="0.8em" name="eye"></i-bs>
</a>
<ng-template #previewContent>
<pngx-preview-popup [document]="doc" (mouseenter)="mouseEnterPreview()" (mouseleave)="mouseLeavePreview()"></pngx-preview-popup>
</ng-template>
<a [href]="getDownloadUrl(doc)" class="btn px-4 btn-dark border-dark-subtle" title="Download" i18n-title (click)="$event.stopPropagation()">
<i-bs width="0.8em" height="0.8em" name="download"></i-bs>
</a>
</div>
</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 { @media (min-width: 768px) {
width: 25%; th.w-25 {
@media (min-width: 768px) { width: 15% !important;
width: 15%;
} }
} }
@@ -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

@@ -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,12 +115,9 @@ export class UploadFileWidgetComponent extends ComponentWithPermissions {
} }
dismissCompleted() { dismissCompleted() {
this.alerts.forEach((a) => a.close()) this.getStatusCompleted().forEach((status) =>
if (this.alertsExpanded) { this.consumerStatusService.dismiss(status)
this.getStatusCompleted().forEach((status) => )
this.consumerStatusService.dismiss(status)
)
}
} }
public onFileSelected(event: Event) { public onFileSelected(event: Event) {

View File

@@ -13,11 +13,11 @@
<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>
} }
<ng-content select ="[header-buttons]"></ng-content> <ng-content select="[header-buttons]"></ng-content>
</div> </div>
</div> </div>
<div class="card-body text-dark"> <div class="card-body text-dark">
<ng-content select ="[content]"></ng-content> <ng-content select="[content]"></ng-content>
</div> </div>
</div> </div>

View File

@@ -57,7 +57,7 @@
<i-bs width="1em" height="1em" name="scissors"></i-bs>&nbsp;<span i18n>Split</span> <i-bs width="1em" height="1em" name="scissors"></i-bs>&nbsp;<span i18n>Split</span>
</button> </button>
<button ngbDropdownItem (click)="rotateDocument()" [disabled]="!userIsOwner || contentRenderType !== ContentRenderType.PDF"> <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> <i-bs name="arrow-clockwise"></i-bs>&nbsp;<ng-container i18n>Rotate</ng-container>
</button> </button>
</div> </div>
@@ -112,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) {
@@ -285,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

@@ -77,6 +77,7 @@ enum DocumentDetailNavIDs {
Preview = 4, Preview = 4,
Notes = 5, Notes = 5,
Permissions = 6, Permissions = 6,
History = 7,
} }
enum ContentRenderType { enum ContentRenderType {
@@ -902,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)

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">

View File

@@ -55,6 +55,9 @@ import { StoragePathEditDialogComponent } from '../../common/edit-dialog/storage
import { IsNumberPipe } from 'src/app/pipes/is-number.pipe' import { IsNumberPipe } from 'src/app/pipes/is-number.pipe'
import { RotateConfirmDialogComponent } from '../../common/confirm-dialog/rotate-confirm-dialog/rotate-confirm-dialog.component' import { RotateConfirmDialogComponent } from '../../common/confirm-dialog/rotate-confirm-dialog/rotate-confirm-dialog.component'
import { MergeConfirmDialogComponent } from '../../common/confirm-dialog/merge-confirm-dialog/merge-confirm-dialog.component' import { MergeConfirmDialogComponent } from '../../common/confirm-dialog/merge-confirm-dialog/merge-confirm-dialog.component'
import { 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: [
@@ -68,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', () => {
@@ -82,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 () => {
@@ -148,6 +156,18 @@ describe('BulkEditorComponent', () => {
}), }),
}, },
}, },
{
provide: CustomFieldsService,
useValue: {
listAll: () =>
of({
results: [
{ id: 77, name: 'customfield1' },
{ id: 88, name: 'customfield2' },
],
}),
},
},
FilterPipe, FilterPipe,
SettingsService, SettingsService,
{ {
@@ -189,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)
@@ -262,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
@@ -679,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: [],
@@ -696,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/`
) )
@@ -866,9 +1023,13 @@ describe('BulkEditorComponent', () => {
jest jest
.spyOn(documentListViewService, 'documents', 'get') .spyOn(documentListViewService, 'documents', 'get')
.mockReturnValue([{ id: 3 }, { id: 4 }]) .mockReturnValue([{ id: 3 }, { id: 4 }])
jest jest.spyOn(documentService, 'getFew').mockReturnValue(
.spyOn(documentService, 'getCachedMany') of({
.mockReturnValue(of([{ id: 3 }, { id: 4 }])) all: [3, 4],
count: 2,
results: [{ id: 3 }, { id: 4 }],
})
)
jest jest
.spyOn(documentListViewService, 'selected', 'get') .spyOn(documentListViewService, 'selected', 'get')
.mockReturnValue(new Set([3, 4])) .mockReturnValue(new Set([3, 4]))
@@ -1175,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

@@ -41,6 +41,9 @@ import { DocumentTypeEditDialogComponent } from '../../common/edit-dialog/docume
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 { 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 { 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',
@@ -55,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()
@@ -85,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()
@@ -166,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')
@@ -297,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 ''
@@ -495,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',
@@ -581,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',

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 @@
} }
: :
} }
{{document.title | documentTitle}} @if (displayFields.includes(DisplayField.TITLE)) {
@for (t of document.tags$ | async; track t) { {{document.title | documentTitle}}
<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> }
@if (displayFields.includes(DisplayField.TAGS)) {
@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>
}
} }
</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,44 +70,53 @@
</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>
} }
<ng-template #dateTooltip> @if (displayFields.includes(DisplayField.CREATED) || displayFields.includes(DisplayField.ADDED)) {
<div class="d-flex flex-column text-light"> <ng-template #dateTooltip>
<span i18n>Created: {{ document.created | customDate }}</span> <div class="d-flex flex-column text-light">
<span i18n>Added: {{ document.added | customDate }}</span> <span i18n>Created: {{ document.created | customDate }}</span>
<span i18n>Modified: {{ document.modified | customDate }}</span> <span i18n>Added: {{ document.added | customDate }}</span>
</div> <span i18n>Modified: {{ document.modified | customDate }}</span>
</ng-template> </div>
<div class="list-group-item bg-light text-dark p-1 border-0 d-flex align-items-center" [ngbTooltip]="dateTooltip"> </ng-template>
<i-bs width=".9em" height=".9em" class="me-2 text-muted" name="calendar-event"></i-bs><small>{{document.created_date | customDate:'mediumDate'}}</small> @if (displayFields.includes(DisplayField.CREATED)) {
</div> <div class="list-group-item bg-light text-dark p-1 border-0 d-flex align-items-center" [ngbTooltip]="dateTooltip">
@if (document.owner && document.owner !== settingsService.currentUser.id) { <i-bs width=".9em" height=".9em" class="me-2 text-muted" name="calendar-event"></i-bs><small>{{document.created_date | customDate:'mediumDate'}}</small>
</div>
}
@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,19 +10,21 @@
</div> </div>
</div> </div>
<div class="tags d-flex flex-column text-end position-absolute me-1 fs-6"> @if (displayFields?.includes(DisplayField.TAGS)) {
@for (t of getTagsLimited$() | async; track t) { <div class="tags d-flex flex-column text-end position-absolute me-1 fs-6">
<pngx-tag [tag]="t" (click)="clickTag.emit(t.id);$event.stopPropagation()" [clickable]="true" linkTitle="Toggle tag filter" i18n-linkTitle></pngx-tag> @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>
@if (moreTags) { }
<div> @if (moreTags) {
<span class="badge text-dark">+ {{moreTags}}</span> <div>
</div> <span class="badge text-dark">+ {{moreTags}}</span>
} </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,59 +34,86 @@
<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>:
} }
{{document.title | documentTitle}} @if (displayFields.includes(DisplayField.TITLE)) {
{{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>
} }
<div class="list-group-item bg-transparent p-0 border-0 d-flex flex-wrap-reverse justify-content-between"> @if (displayFields.includes(DisplayField.CREATED)) {
<ng-template #dateTooltip> <div class="list-group-item bg-transparent p-0 border-0 d-flex flex-wrap-reverse justify-content-between">
<div class="d-flex flex-column text-light"> <ng-template #dateTooltip>
<span i18n>Created: {{ document.created | customDate }}</span> <div class="d-flex flex-column text-light">
<span i18n>Added: {{ document.added | customDate }}</span> <span i18n>Created: {{ document.created | customDate }}</span>
<span i18n>Modified: {{ document.modified | 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.created | customDate:'mediumDate'}}</small>
</div> </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.created_date | customDate:'mediumDate'}}</small>
</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>
} }
@@ -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,48 +138,68 @@
@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>
<th class="d-none d-lg-table-cell" @if (activeDisplayFields.includes(DisplayField.ASN)) {
pngxSortable="archive_serial_number" <th class="d-none d-lg-table-cell"
title="Sort by ASN" i18n-title pngxSortable="archive_serial_number"
[currentSortField]="list.sortField" title="Sort by ASN" i18n-title
[currentSortReverse]="list.sortReverse" [currentSortField]="list.sortField"
(sort)="onSort($event)" [currentSortReverse]="list.sortReverse"
i18n>ASN</th> (sort)="onSort($event)"
<th class="d-none d-md-table-cell" i18n>ASN</th>
pngxSortable="correspondent__name" }
title="Sort by correspondent" i18n-title @if (activeDisplayFields.includes(DisplayField.CORRESPONDENT) && permissionService.currentUserCan(PermissionAction.View, PermissionType.Correspondent)) {
[currentSortField]="list.sortField" <th class="d-none d-md-table-cell"
[currentSortReverse]="list.sortReverse" pngxSortable="correspondent__name"
(sort)="onSort($event)" title="Sort by correspondent" i18n-title
i18n>Correspondent</th> [currentSortField]="list.sortField"
<th [currentSortReverse]="list.sortReverse"
pngxSortable="title" (sort)="onSort($event)"
title="Sort by title" i18n-title i18n>Correspondent</th>
class="w-40" }
[currentSortField]="list.sortField" @if (activeDisplayFields.includes(DisplayField.TITLE)) {
[currentSortReverse]="list.sortReverse" <th
(sort)="onSort($event)" pngxSortable="title"
i18n>Title</th> title="Sort by title" i18n-title
<th class="d-none d-xl-table-cell" [currentSortField]="list.sortField"
pngxSortable="owner" [currentSortReverse]="list.sortReverse"
title="Sort by owner" i18n-title (sort)="onSort($event)"
[currentSortField]="list.sortField" i18n>Title</th>
[currentSortReverse]="list.sortReverse" }
(sort)="onSort($event)" @if (activeDisplayFields.includes(DisplayField.TAGS) && !activeDisplayFields.includes(DisplayField.TITLE)) {
i18n>Owner</th> <th i18n>Tags</th>
@if (notesEnabled) { }
@if (activeDisplayFields.includes(DisplayField.OWNER) && permissionService.currentUserCan(PermissionAction.View, PermissionType.User)) {
<th class="d-none d-xl-table-cell"
pngxSortable="owner"
title="Sort by owner" i18n-title
[currentSortField]="list.sortField"
[currentSortReverse]="list.sortReverse"
(sort)="onSort($event)"
i18n>Owner</th>
}
@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,34 +208,52 @@
(sort)="onSort($event)" (sort)="onSort($event)"
i18n>Notes</th> i18n>Notes</th>
} }
<th class="d-none d-xl-table-cell" @if (activeDisplayFields.includes(DisplayField.DOCUMENT_TYPE) && permissionService.currentUserCan(PermissionAction.View, PermissionType.DocumentType)) {
pngxSortable="document_type__name" <th class="d-none d-xl-table-cell"
title="Sort by document type" i18n-title pngxSortable="document_type__name"
[currentSortField]="list.sortField" title="Sort by document type" i18n-title
[currentSortReverse]="list.sortReverse" [currentSortField]="list.sortField"
(sort)="onSort($event)" [currentSortReverse]="list.sortReverse"
i18n>Document type</th> (sort)="onSort($event)"
<th class="d-none d-xl-table-cell" i18n>Document type</th>
pngxSortable="storage_path__name" }
title="Sort by storage path" i18n-title @if (activeDisplayFields.includes(DisplayField.STORAGE_PATH) && permissionService.currentUserCan(PermissionAction.View, PermissionType.StoragePath)) {
[currentSortField]="list.sortField" <th class="d-none d-xl-table-cell"
[currentSortReverse]="list.sortReverse" pngxSortable="storage_path__name"
(sort)="onSort($event)" title="Sort by storage path" i18n-title
i18n>Storage path</th> [currentSortField]="list.sortField"
<th [currentSortReverse]="list.sortReverse"
pngxSortable="created" (sort)="onSort($event)"
title="Sort by created date" i18n-title i18n>Storage path</th>
[currentSortField]="list.sortField" }
[currentSortReverse]="list.sortReverse" @if (activeDisplayFields.includes(DisplayField.CREATED)) {
(sort)="onSort($event)" <th
i18n>Created</th> pngxSortable="created"
<th class="d-none d-xl-table-cell" title="Sort by created date" i18n-title
pngxSortable="added" [currentSortField]="list.sortField"
title="Sort by added date" i18n-title [currentSortReverse]="list.sortReverse"
[currentSortField]="list.sortField" (sort)="onSort($event)"
[currentSortReverse]="list.sortReverse" i18n>Created</th>
(sort)="onSort($event)" }
i18n>Added</th> @if (activeDisplayFields.includes(DisplayField.ADDED)) {
<th
pngxSortable="added"
title="Sort by added date" i18n-title
[currentSortField]="list.sortField"
[currentSortReverse]="list.sortReverse"
(sort)="onSort($event)"
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)) {
{{d.archive_serial_number}} <td class="d-none d-xl-table-cell">
</td> {{d.archive_serial_number}}
<td class="d-none d-md-table-cell"> </td>
@if (d.correspondent) { }
<a (click)="clickCorrespondent(d.correspondent);$event.stopPropagation()" title="Filter by correspondent" i18n-title>{{(d.correspondent$ | async)?.name}}</a> @if (activeDisplayFields.includes(DisplayField.CORRESPONDENT) && permissionService.currentUserCan(PermissionAction.View, PermissionType.Correspondent)) {
} <td class="d-none d-xl-table-cell">
</td> @if (d.correspondent) {
<td> <a (click)="clickCorrespondent(d.correspondent);$event.stopPropagation()" title="Filter by correspondent" i18n-title>{{(d.correspondent$ | async)?.name}}</a>
<a routerLink="/documents/{{d.id}}" title="Edit document" i18n-title style="overflow-wrap: anywhere;">{{d.title | documentTitle}}</a> }
@for (t of d.tags$ | async; track t) { </td>
<pngx-tag [tag]="t" class="ms-1" clickable="true" linkTitle="Filter by tag" i18n-linkTitle (click)="clickTag(t.id);$event.stopPropagation()"></pngx-tag> }
} @if (activeDisplayFields.includes(DisplayField.TITLE) || activeDisplayFields.includes(DisplayField.TAGS)) {
</td> <td>
<td> @if (activeDisplayFields.includes(DisplayField.TITLE)) {
{{d.owner | username}} <a routerLink="/documents/{{d.id}}" title="Edit document" i18n-title style="overflow-wrap: anywhere;">{{d.title | documentTitle}}</a>
</td> }
@if (notesEnabled) { @if (activeDisplayFields.includes(DisplayField.TAGS)) {
@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>
}
}
</td>
}
@if (activeDisplayFields.includes(DisplayField.OWNER) && permissionService.currentUserCan(PermissionAction.View, PermissionType.User)) {
<td>
{{d.owner | username}}
</td>
}
@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>
} }
<td class="d-none d-xl-table-cell"> @if (activeDisplayFields.includes(DisplayField.DOCUMENT_TYPE) && permissionService.currentUserCan(PermissionAction.View, PermissionType.DocumentType)) {
@if (d.document_type) { <td class="d-none d-xl-table-cell">
<a (click)="clickDocumentType(d.document_type);$event.stopPropagation()" title="Filter by document type" i18n-title>{{(d.document_type$ | async)?.name}}</a> @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>
</td> }
<td class="d-none d-xl-table-cell"> </td>
@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> @if (activeDisplayFields.includes(DisplayField.STORAGE_PATH) && permissionService.currentUserCan(PermissionAction.View, PermissionType.StoragePath)) {
} <td class="d-none d-xl-table-cell">
</td> @if (d.storage_path) {
<td> <a (click)="clickStoragePath(d.storage_path);$event.stopPropagation()" title="Filter by storage path" i18n-title>{{(d.storage_path$ | async)?.name}}</a>
{{d.created_date | customDate}} }
</td> </td>
<td class="d-none d-xl-table-cell"> }
{{d.added | customDate}} @if (activeDisplayFields.includes(DisplayField.CREATED)) {
</td> <td>
{{d.created_date | customDate}}
</td>
}
@if (activeDisplayFields.includes(DisplayField.ADDED)) {
<td>
{{d.added | customDate}}
</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;
}

View File

@@ -5,7 +5,7 @@ import { RouterTestingModule } from '@angular/router/testing'
import { routes } from 'src/app/app-routing.module' import { routes } from 'src/app/app-routing.module'
import { FilterEditorComponent } from './filter-editor/filter-editor.component' import { FilterEditorComponent } from './filter-editor/filter-editor.component'
import { PermissionsFilterDropdownComponent } from '../common/permissions-filter-dropdown/permissions-filter-dropdown.component' import { PermissionsFilterDropdownComponent } from '../common/permissions-filter-dropdown/permissions-filter-dropdown.component'
import { DateDropdownComponent } from '../common/date-dropdown/date-dropdown.component' import { DatesDropdownComponent } from '../common/dates-dropdown/dates-dropdown.component'
import { FilterableDropdownComponent } from '../common/filterable-dropdown/filterable-dropdown.component' import { FilterableDropdownComponent } from '../common/filterable-dropdown/filterable-dropdown.component'
import { PageHeaderComponent } from '../common/page-header/page-header.component' import { PageHeaderComponent } from '../common/page-header/page-header.component'
import { BulkEditorComponent } from './bulk-editor/bulk-editor.component' import { BulkEditorComponent } from './bulk-editor/bulk-editor.component'
@@ -47,12 +47,13 @@ import { DocumentCardSmallComponent } from './document-card-small/document-card-
import { DocumentCardLargeComponent } from './document-card-large/document-card-large.component' import { DocumentCardLargeComponent } from './document-card-large/document-card-large.component'
import { DocumentTitlePipe } from 'src/app/pipes/document-title.pipe' import { DocumentTitlePipe } from 'src/app/pipes/document-title.pipe'
import { UsernamePipe } from 'src/app/pipes/username.pipe' import { UsernamePipe } from 'src/app/pipes/username.pipe'
import { Document } from 'src/app/data/document'
import { import {
DOCUMENT_SORT_FIELDS, DEFAULT_DISPLAY_FIELDS,
DOCUMENT_SORT_FIELDS_FULLTEXT, DisplayField,
DocumentService, DisplayMode,
} from 'src/app/services/rest/document.service' Document,
} from 'src/app/data/document'
import { DocumentService } from 'src/app/services/rest/document.service'
import { ConfirmDialogComponent } from '../common/confirm-dialog/confirm-dialog.component' import { ConfirmDialogComponent } from '../common/confirm-dialog/confirm-dialog.component'
import { SafeHtmlPipe } from 'src/app/pipes/safehtml.pipe' import { SafeHtmlPipe } from 'src/app/pipes/safehtml.pipe'
import { SaveViewConfigDialogComponent } from './save-view-config-dialog/save-view-config-dialog.component' import { SaveViewConfigDialogComponent } from './save-view-config-dialog/save-view-config-dialog.component'
@@ -64,6 +65,8 @@ import { SettingsService } from 'src/app/services/settings.service'
import { SETTINGS_KEYS } from 'src/app/data/ui-settings' import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
import { IsNumberPipe } from 'src/app/pipes/is-number.pipe' import { IsNumberPipe } from 'src/app/pipes/is-number.pipe'
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons' import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
import { PermissionsService } from 'src/app/services/permissions.service'
import { NgSelectModule } from '@ng-select/ng-select'
const docs: Document[] = [ const docs: Document[] = [
{ {
@@ -101,6 +104,7 @@ describe('DocumentListComponent', () => {
let toastService: ToastService let toastService: ToastService
let modalService: NgbModal let modalService: NgbModal
let settingsService: SettingsService let settingsService: SettingsService
let permissionService: PermissionsService
beforeEach(async () => { beforeEach(async () => {
TestBed.configureTestingModule({ TestBed.configureTestingModule({
@@ -109,7 +113,7 @@ describe('DocumentListComponent', () => {
PageHeaderComponent, PageHeaderComponent,
FilterEditorComponent, FilterEditorComponent,
FilterableDropdownComponent, FilterableDropdownComponent,
DateDropdownComponent, DatesDropdownComponent,
PermissionsFilterDropdownComponent, PermissionsFilterDropdownComponent,
ToggleableDropdownButtonComponent, ToggleableDropdownButtonComponent,
BulkEditorComponent, BulkEditorComponent,
@@ -148,6 +152,7 @@ describe('DocumentListComponent', () => {
NgbPopoverModule, NgbPopoverModule,
NgbTooltipModule, NgbTooltipModule,
NgxBootstrapIconsModule.pick(allIcons), NgxBootstrapIconsModule.pick(allIcons),
NgSelectModule,
], ],
}).compileComponents() }).compileComponents()
@@ -160,21 +165,11 @@ describe('DocumentListComponent', () => {
toastService = TestBed.inject(ToastService) toastService = TestBed.inject(ToastService)
modalService = TestBed.inject(NgbModal) modalService = TestBed.inject(NgbModal)
settingsService = TestBed.inject(SettingsService) settingsService = TestBed.inject(SettingsService)
permissionService = TestBed.inject(PermissionsService)
fixture = TestBed.createComponent(DocumentListComponent) fixture = TestBed.createComponent(DocumentListComponent)
component = fixture.componentInstance component = fixture.componentInstance
}) })
it('should load display mode from local storage', () => {
window.localStorage.setItem('document-list:displayMode', 'largeCards')
fixture.detectChanges()
expect(component.displayMode).toEqual('largeCards')
component.displayMode = 'smallCards'
component.saveDisplayMode()
expect(window.localStorage.getItem('document-list:displayMode')).toEqual(
'smallCards'
)
})
it('should reload on new document consumed', () => { it('should reload on new document consumed', () => {
const reloadSpy = jest.spyOn(documentListService, 'reload') const reloadSpy = jest.spyOn(documentListService, 'reload')
const fileStatusSubject = new Subject<FileStatus>() const fileStatusSubject = new Subject<FileStatus>()
@@ -194,7 +189,7 @@ describe('DocumentListComponent', () => {
}, },
] ]
fixture.detectChanges() fixture.detectChanges()
expect(component.getSortFields()).toEqual(DOCUMENT_SORT_FIELDS) expect(component.getSortFields()).toEqual(documentListService.sortFields)
documentListService.filterRules = [ documentListService.filterRules = [
{ {
@@ -203,7 +198,9 @@ describe('DocumentListComponent', () => {
}, },
] ]
fixture.detectChanges() fixture.detectChanges()
expect(component.getSortFields()).toEqual(DOCUMENT_SORT_FIELDS_FULLTEXT) expect(component.getSortFields()).toEqual(
documentListService.sortFieldsFullText
)
}) })
it('should determine if filtered, support reset', () => { it('should determine if filtered, support reset', () => {
@@ -292,18 +289,18 @@ describe('DocumentListComponent', () => {
const displayModeButtons = fixture.debugElement.queryAll( const displayModeButtons = fixture.debugElement.queryAll(
By.css('input[type="radio"]') By.css('input[type="radio"]')
) )
expect(component.displayMode).toEqual('smallCards') expect(component.list.displayMode).toEqual('smallCards')
displayModeButtons[0].nativeElement.checked = true displayModeButtons[0].nativeElement.checked = true
displayModeButtons[0].triggerEventHandler('change') displayModeButtons[0].triggerEventHandler('change')
fixture.detectChanges() fixture.detectChanges()
expect(component.displayMode).toEqual('details') expect(component.list.displayMode).toEqual('table')
expect(fixture.debugElement.queryAll(By.css('tr'))).toHaveLength(3) expect(fixture.debugElement.queryAll(By.css('tr'))).toHaveLength(3)
displayModeButtons[1].nativeElement.checked = true displayModeButtons[1].nativeElement.checked = true
displayModeButtons[1].triggerEventHandler('change') displayModeButtons[1].triggerEventHandler('change')
fixture.detectChanges() fixture.detectChanges()
expect(component.displayMode).toEqual('smallCards') expect(component.list.displayMode).toEqual('smallCards')
expect( expect(
fixture.debugElement.queryAll(By.directive(DocumentCardSmallComponent)) fixture.debugElement.queryAll(By.directive(DocumentCardSmallComponent))
).toHaveLength(3) ).toHaveLength(3)
@@ -311,7 +308,7 @@ describe('DocumentListComponent', () => {
displayModeButtons[2].nativeElement.checked = true displayModeButtons[2].nativeElement.checked = true
displayModeButtons[2].triggerEventHandler('change') displayModeButtons[2].triggerEventHandler('change')
fixture.detectChanges() fixture.detectChanges()
expect(component.displayMode).toEqual('largeCards') expect(component.list.displayMode).toEqual('largeCards')
expect( expect(
fixture.debugElement.queryAll(By.directive(DocumentCardLargeComponent)) fixture.debugElement.queryAll(By.directive(DocumentCardLargeComponent))
).toHaveLength(3) ).toHaveLength(3)
@@ -322,7 +319,7 @@ describe('DocumentListComponent', () => {
fixture.detectChanges() fixture.detectChanges()
const sortDropdown = fixture.debugElement.queryAll( const sortDropdown = fixture.debugElement.queryAll(
By.directive(NgbDropdown) By.directive(NgbDropdown)
)[1] )[2]
const asnSortFieldButton = sortDropdown.query(By.directive(NgbDropdownItem)) const asnSortFieldButton = sortDropdown.query(By.directive(NgbDropdownItem))
asnSortFieldButton.triggerEventHandler('click') asnSortFieldButton.triggerEventHandler('click')
@@ -332,6 +329,7 @@ describe('DocumentListComponent', () => {
}) })
it('should support setting sort field by table head', () => { it('should support setting sort field by table head', () => {
component.activeDisplayFields = [DisplayField.ASN]
jest.spyOn(documentListService, 'documents', 'get').mockReturnValue(docs) jest.spyOn(documentListService, 'documents', 'get').mockReturnValue(docs)
fixture.detectChanges() fixture.detectChanges()
expect(documentListService.sortField).toEqual('created') expect(documentListService.sortField).toEqual('created')
@@ -342,7 +340,7 @@ describe('DocumentListComponent', () => {
detailsDisplayModeButton.nativeElement.checked = true detailsDisplayModeButton.nativeElement.checked = true
detailsDisplayModeButton.triggerEventHandler('change') detailsDisplayModeButton.triggerEventHandler('change')
fixture.detectChanges() fixture.detectChanges()
expect(component.displayMode).toEqual('details') expect(component.list.displayMode).toEqual(DisplayMode.TABLE)
const sortTh = fixture.debugElement.query(By.directive(SortableDirective)) const sortTh = fixture.debugElement.query(By.directive(SortableDirective))
sortTh.triggerEventHandler('click') sortTh.triggerEventHandler('click')
@@ -425,6 +423,8 @@ describe('DocumentListComponent', () => {
value: '20', value: '20',
}, },
], ],
display_mode: DisplayMode.SMALL_CARDS,
display_fields: [DisplayField.TITLE],
} }
jest.spyOn(savedViewService, 'getCached').mockReturnValue(of(view)) jest.spyOn(savedViewService, 'getCached').mockReturnValue(of(view))
const queryParams = { view: view.id.toString() } const queryParams = { view: view.id.toString() }
@@ -541,6 +541,42 @@ describe('DocumentListComponent', () => {
expect(openModal.componentInstance.error).toEqual({ filter_rules: ['11'] }) expect(openModal.componentInstance.error).toEqual({ filter_rules: ['11'] })
}) })
it('should detect saved view changes', () => {
const view: SavedView = {
id: 10,
name: 'Saved View 10',
sort_field: 'added',
sort_reverse: true,
filter_rules: [
{
rule_type: FILTER_HAS_TAGS_ANY,
value: '20',
},
],
page_size: 5,
display_mode: DisplayMode.SMALL_CARDS,
display_fields: [DisplayField.TITLE],
}
jest.spyOn(savedViewService, 'getCached').mockReturnValue(of(view))
const queryParams = { view: view.id.toString() }
jest
.spyOn(activatedRoute, 'queryParamMap', 'get')
.mockReturnValue(of(convertToParamMap(queryParams)))
activatedRoute.snapshot.queryParams = queryParams
router.routerState.snapshot.url = '/view/10/'
fixture.detectChanges()
expect(documentListService.activeSavedViewId).toEqual(10)
component.list.displayFields = [DisplayField.ASN]
expect(component.savedViewIsModified).toBeTruthy()
component.list.displayFields = [DisplayField.TITLE]
expect(component.savedViewIsModified).toBeFalsy()
component.list.displayMode = DisplayMode.TABLE
expect(component.savedViewIsModified).toBeTruthy()
component.list.displayMode = DisplayMode.SMALL_CARDS
expect(component.savedViewIsModified).toBeFalsy()
})
it('should navigate to a document', () => { it('should navigate to a document', () => {
fixture.detectChanges() fixture.detectChanges()
const routerSpy = jest.spyOn(router, 'navigate') const routerSpy = jest.spyOn(router, 'navigate')
@@ -548,18 +584,14 @@ describe('DocumentListComponent', () => {
expect(routerSpy).toHaveBeenCalledWith(['documents', 99]) expect(routerSpy).toHaveBeenCalledWith(['documents', 99])
}) })
it('should support checking if notes enabled to hide column', () => { it('should hide columns if no perms or notes disabled', () => {
jest.spyOn(permissionService, 'currentUserCan').mockReturnValue(true)
jest.spyOn(documentListService, 'documents', 'get').mockReturnValue(docs) jest.spyOn(documentListService, 'documents', 'get').mockReturnValue(docs)
fixture.detectChanges()
expect(documentListService.sortField).toEqual('created') expect(documentListService.sortField).toEqual('created')
const detailsDisplayModeButton = fixture.debugElement.query( component.list.displayMode = DisplayMode.TABLE
By.css('input[type="radio"]') component.list.displayFields = DEFAULT_DISPLAY_FIELDS.map((f) => f.id)
)
detailsDisplayModeButton.nativeElement.checked = true
detailsDisplayModeButton.triggerEventHandler('change')
fixture.detectChanges() fixture.detectChanges()
expect(component.displayMode).toEqual('details')
expect( expect(
fixture.debugElement.queryAll(By.directive(SortableDirective)) fixture.debugElement.queryAll(By.directive(SortableDirective))
@@ -572,6 +604,13 @@ describe('DocumentListComponent', () => {
expect( expect(
fixture.debugElement.queryAll(By.directive(SortableDirective)) fixture.debugElement.queryAll(By.directive(SortableDirective))
).toHaveLength(8) ).toHaveLength(8)
// insufficient perms
jest.spyOn(permissionService, 'currentUserCan').mockReturnValue(false)
fixture.detectChanges()
expect(
fixture.debugElement.queryAll(By.directive(SortableDirective))
).toHaveLength(4)
}) })
it('should support toggle on document objects', () => { it('should support toggle on document objects', () => {
@@ -591,4 +630,28 @@ describe('DocumentListComponent', () => {
{ rule_type: FILTER_FULLTEXT_MORELIKE, value: '99' }, { rule_type: FILTER_FULLTEXT_MORELIKE, value: '99' },
]) ])
}) })
it('should support toggling display fields', () => {
fixture.detectChanges()
component.activeDisplayFields = [DisplayField.ASN]
component.toggleDisplayField(DisplayField.TITLE)
expect(component.activeDisplayFields).toEqual([
DisplayField.ASN,
DisplayField.TITLE,
])
component.toggleDisplayField(DisplayField.ASN)
expect(component.activeDisplayFields).toEqual([DisplayField.TITLE])
})
it('should get custom field title', () => {
fixture.detectChanges()
jest
.spyOn(settingsService, 'allDisplayFields', 'get')
.mockReturnValue([
{ id: 'custom_field_1' as any, name: 'Custom Field 1' },
])
expect(component.getDisplayCustomFieldTitle('custom_field_1')).toEqual(
'Custom Field 1'
)
})
}) })

View File

@@ -15,7 +15,7 @@ import {
isFullTextFilterRule, isFullTextFilterRule,
} from 'src/app/utils/filter-rules' } from 'src/app/utils/filter-rules'
import { FILTER_FULLTEXT_MORELIKE } from 'src/app/data/filter-rule-type' import { FILTER_FULLTEXT_MORELIKE } from 'src/app/data/filter-rule-type'
import { Document } from 'src/app/data/document' import { 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 { SETTINGS_KEYS } from 'src/app/data/ui-settings' import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
import { import {
@@ -25,10 +25,7 @@ import {
import { ConsumerStatusService } from 'src/app/services/consumer-status.service' import { ConsumerStatusService } from 'src/app/services/consumer-status.service'
import { DocumentListViewService } from 'src/app/services/document-list-view.service' import { DocumentListViewService } from 'src/app/services/document-list-view.service'
import { OpenDocumentsService } from 'src/app/services/open-documents.service' import { OpenDocumentsService } from 'src/app/services/open-documents.service'
import { import { PermissionsService } from 'src/app/services/permissions.service'
DOCUMENT_SORT_FIELDS,
DOCUMENT_SORT_FIELDS_FULLTEXT,
} from 'src/app/services/rest/document.service'
import { SavedViewService } from 'src/app/services/rest/saved-view.service' import { SavedViewService } from 'src/app/services/rest/saved-view.service'
import { SettingsService } from 'src/app/services/settings.service' import { SettingsService } from 'src/app/services/settings.service'
import { ToastService } from 'src/app/services/toast.service' import { ToastService } from 'src/app/services/toast.service'
@@ -45,6 +42,9 @@ export class DocumentListComponent
extends ComponentWithPermissions extends ComponentWithPermissions
implements OnInit, OnDestroy implements OnInit, OnDestroy
{ {
DisplayField = DisplayField
DisplayMode = DisplayMode
constructor( constructor(
public list: DocumentListViewService, public list: DocumentListViewService,
public savedViewService: SavedViewService, public savedViewService: SavedViewService,
@@ -54,7 +54,8 @@ export class DocumentListComponent
private modalService: NgbModal, private modalService: NgbModal,
private consumerStatusService: ConsumerStatusService, private consumerStatusService: ConsumerStatusService,
public openDocumentsService: OpenDocumentsService, public openDocumentsService: OpenDocumentsService,
private settingsService: SettingsService public settingsService: SettingsService,
public permissionService: PermissionsService
) { ) {
super() super()
} }
@@ -64,7 +65,25 @@ export class DocumentListComponent
@ViewChildren(SortableDirective) headers: QueryList<SortableDirective> @ViewChildren(SortableDirective) headers: QueryList<SortableDirective>
displayMode = 'smallCards' // largeCards, smallCards, details get activeDisplayFields(): DisplayField[] {
return this.list.displayFields
}
set activeDisplayFields(fields: DisplayField[]) {
this.list.displayFields = fields
this.updateDisplayCustomFields()
}
activeDisplayCustomFields: Set<string> = new Set()
public updateDisplayCustomFields() {
this.activeDisplayCustomFields = new Set(
Array.from(this.activeDisplayFields).filter(
(field) =>
typeof field === 'string' &&
field.startsWith(DisplayField.CUSTOM_FIELD)
)
)
}
unmodifiedFilterRules: FilterRule[] = [] unmodifiedFilterRules: FilterRule[] = []
private unmodifiedSavedView: SavedView private unmodifiedSavedView: SavedView
@@ -77,6 +96,16 @@ export class DocumentListComponent
return ( return (
this.unmodifiedSavedView.sort_field !== this.list.sortField || this.unmodifiedSavedView.sort_field !== this.list.sortField ||
this.unmodifiedSavedView.sort_reverse !== this.list.sortReverse || this.unmodifiedSavedView.sort_reverse !== this.list.sortReverse ||
(this.unmodifiedSavedView.page_size &&
this.unmodifiedSavedView.page_size !== this.list.pageSize) ||
(this.unmodifiedSavedView.display_mode &&
this.unmodifiedSavedView.display_mode !== this.list.displayMode) ||
// if the saved view has no display mode, we assume it's small cards
(!this.unmodifiedSavedView.display_mode &&
this.list.displayMode !== DisplayMode.SMALL_CARDS) ||
(this.unmodifiedSavedView.display_fields &&
this.unmodifiedSavedView.display_fields.join(',') !==
this.activeDisplayFields.join(',')) ||
filterRulesDiffer( filterRulesDiffer(
this.unmodifiedSavedView.filter_rules, this.unmodifiedSavedView.filter_rules,
this.list.filterRules this.list.filterRules
@@ -101,8 +130,8 @@ export class DocumentListComponent
getSortFields() { getSortFields() {
return isFullTextFilterRule(this.list.filterRules) return isFullTextFilterRule(this.list.filterRules)
? DOCUMENT_SORT_FIELDS_FULLTEXT ? this.list.sortFieldsFullText
: DOCUMENT_SORT_FIELDS : this.list.sortFields
} }
set listSortReverse(reverse: boolean) { set listSortReverse(reverse: boolean) {
@@ -113,10 +142,6 @@ export class DocumentListComponent
return this.list.sortReverse return this.list.sortReverse
} }
setSortField(field: string) {
this.list.sortField = field
}
onSort(event: SortEvent) { onSort(event: SortEvent) {
this.list.setSort(event.column, event.reverse) this.list.setSort(event.column, event.reverse)
} }
@@ -125,15 +150,23 @@ export class DocumentListComponent
return this.list.selected.size > 0 return this.list.selected.size > 0
} }
saveDisplayMode() { toggleDisplayField(field: DisplayField) {
localStorage.setItem('document-list:displayMode', this.displayMode) if (this.activeDisplayFields.includes(field)) {
this.activeDisplayFields = this.activeDisplayFields.filter(
(f) => f !== field
)
} else {
this.activeDisplayFields = [...this.activeDisplayFields, field]
}
this.updateDisplayCustomFields()
}
public getDisplayCustomFieldTitle(field: string) {
return this.settingsService.allDisplayFields.find((f) => f.id === field)
?.name
} }
ngOnInit(): void { ngOnInit(): void {
if (localStorage.getItem('document-list:displayMode') != null) {
this.displayMode = localStorage.getItem('document-list:displayMode')
}
this.consumerStatusService this.consumerStatusService
.onDocumentConsumptionFinished() .onDocumentConsumptionFinished()
.pipe(takeUntil(this.unsubscribeNotifier)) .pipe(takeUntil(this.unsubscribeNotifier))
@@ -197,6 +230,8 @@ export class DocumentListComponent
filter_rules: this.list.filterRules, filter_rules: this.list.filterRules,
sort_field: this.list.sortField, sort_field: this.list.sortField,
sort_reverse: this.list.sortReverse, sort_reverse: this.list.sortReverse,
display_mode: this.list.displayMode,
display_fields: this.activeDisplayFields,
} }
this.savedViewService this.savedViewService
.patch(savedView) .patch(savedView)
@@ -236,6 +271,8 @@ export class DocumentListComponent
filter_rules: this.list.filterRules, filter_rules: this.list.filterRules,
sort_reverse: this.list.sortReverse, sort_reverse: this.list.sortReverse,
sort_field: this.list.sortField, sort_field: this.list.sortField,
display_mode: this.list.displayMode,
display_fields: this.activeDisplayFields,
} }
this.savedViewService this.savedViewService

View File

@@ -70,22 +70,28 @@
[documentCounts]="storagePathDocumentCounts" [documentCounts]="storagePathDocumentCounts"
[allowSelectNone]="true"></pngx-filterable-dropdown> [allowSelectNone]="true"></pngx-filterable-dropdown>
} }
</div>
<div class="d-flex flex-wrap gap-2"> @if (permissionsService.currentUserCan(PermissionAction.View, PermissionType.CustomField)) {
<pngx-date-dropdown <pngx-filterable-dropdown class="flex-fill" title="Custom fields" icon="ui-radios" i18n-title
title="Created" i18n-title filterPlaceholder="Filter custom fields" i18n-filterPlaceholder
[items]="customFields"
[manyToOne]="true"
[(selectionModel)]="customFieldSelectionModel"
(selectionModelChange)="updateRules()"
(opened)="onCustomFieldsDropdownOpen()"
[documentCounts]="customFieldDocumentCounts"
[allowSelectNone]="true"></pngx-filterable-dropdown>
}
<pngx-dates-dropdown
title="Dates" i18n-title
(datesSet)="updateRules()" (datesSet)="updateRules()"
[(dateBefore)]="dateCreatedBefore" [(createdDateBefore)]="dateCreatedBefore"
[(dateAfter)]="dateCreatedAfter" [(createdDateAfter)]="dateCreatedAfter"
[(relativeDate)]="dateCreatedRelativeDate"></pngx-date-dropdown> [(createdRelativeDate)]="dateCreatedRelativeDate"
<pngx-date-dropdown [(addedDateBefore)]="dateAddedBefore"
title="Added" i18n-title [(addedDateAfter)]="dateAddedAfter"
(datesSet)="updateRules()" [(addedRelativeDate)]="dateAddedRelativeDate">
[(dateBefore)]="dateAddedBefore" </pngx-dates-dropdown>
[(dateAfter)]="dateAddedAfter"
[(relativeDate)]="dateAddedRelativeDate"></pngx-date-dropdown>
</div>
<div class="d-flex flex-wrap">
<pngx-permissions-filter-dropdown <pngx-permissions-filter-dropdown
title="Permissions" i18n-title title="Permissions" i18n-title
(ownerFilterSet)="updateRules()" (ownerFilterSet)="updateRules()"

View File

@@ -49,8 +49,12 @@ import {
FILTER_OWNER_ANY, FILTER_OWNER_ANY,
FILTER_OWNER_DOES_NOT_INCLUDE, FILTER_OWNER_DOES_NOT_INCLUDE,
FILTER_OWNER_ISNULL, FILTER_OWNER_ISNULL,
FILTER_CUSTOM_FIELDS, FILTER_CUSTOM_FIELDS_TEXT,
FILTER_SHARED_BY_USER, FILTER_SHARED_BY_USER,
FILTER_HAS_CUSTOM_FIELDS_ANY,
FILTER_HAS_ANY_CUSTOM_FIELDS,
FILTER_DOES_NOT_HAVE_CUSTOM_FIELDS,
FILTER_HAS_CUSTOM_FIELDS_ALL,
} from 'src/app/data/filter-rule-type' } from 'src/app/data/filter-rule-type'
import { Correspondent } from 'src/app/data/correspondent' import { Correspondent } from 'src/app/data/correspondent'
import { DocumentType } from 'src/app/data/document-type' import { DocumentType } from 'src/app/data/document-type'
@@ -68,7 +72,7 @@ import { TagService } from 'src/app/services/rest/tag.service'
import { UserService } from 'src/app/services/rest/user.service' import { UserService } from 'src/app/services/rest/user.service'
import { SettingsService } from 'src/app/services/settings.service' import { SettingsService } from 'src/app/services/settings.service'
import { ClearableBadgeComponent } from '../../common/clearable-badge/clearable-badge.component' import { ClearableBadgeComponent } from '../../common/clearable-badge/clearable-badge.component'
import { DateDropdownComponent } from '../../common/date-dropdown/date-dropdown.component' import { DatesDropdownComponent } from '../../common/dates-dropdown/dates-dropdown.component'
import { import {
FilterableDropdownComponent, FilterableDropdownComponent,
LogicalOperator, LogicalOperator,
@@ -86,6 +90,8 @@ import {
PermissionsService, PermissionsService,
} from 'src/app/services/permissions.service' } from 'src/app/services/permissions.service'
import { environment } from 'src/environments/environment' import { environment } from 'src/environments/environment'
import { CustomField, CustomFieldDataType } from 'src/app/data/custom-field'
import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
const tags: Tag[] = [ const tags: Tag[] = [
{ {
@@ -131,6 +137,19 @@ const storage_paths: StoragePath[] = [
}, },
] ]
const custom_fields: CustomField[] = [
{
id: 42,
data_type: CustomFieldDataType.String,
name: 'CustomField42',
},
{
id: 43,
data_type: CustomFieldDataType.String,
name: 'CustomField43',
},
]
const users: User[] = [ const users: User[] = [
{ {
id: 1, id: 1,
@@ -156,7 +175,7 @@ describe('FilterEditorComponent', () => {
IfPermissionsDirective, IfPermissionsDirective,
ClearableBadgeComponent, ClearableBadgeComponent,
ToggleableDropdownButtonComponent, ToggleableDropdownButtonComponent,
DateDropdownComponent, DatesDropdownComponent,
CustomDatePipe, CustomDatePipe,
], ],
providers: [ providers: [
@@ -187,6 +206,12 @@ describe('FilterEditorComponent', () => {
listAll: () => of({ results: storage_paths }), listAll: () => of({ results: storage_paths }),
}, },
}, },
{
provide: CustomFieldsService,
useValue: {
listAll: () => of({ results: custom_fields }),
},
},
{ {
provide: UserService, provide: UserService,
useValue: { useValue: {
@@ -285,7 +310,7 @@ describe('FilterEditorComponent', () => {
expect(component.textFilter).toEqual(null) expect(component.textFilter).toEqual(null)
component.filterRules = [ component.filterRules = [
{ {
rule_type: FILTER_CUSTOM_FIELDS, rule_type: FILTER_CUSTOM_FIELDS_TEXT,
value: 'foo', value: 'foo',
}, },
] ]
@@ -806,6 +831,110 @@ describe('FilterEditorComponent', () => {
] ]
})) }))
it('should ingest filter rules for has all custom fields', fakeAsync(() => {
expect(component.customFieldSelectionModel.getSelectedItems()).toHaveLength(
0
)
component.filterRules = [
{
rule_type: FILTER_HAS_CUSTOM_FIELDS_ALL,
value: '42',
},
{
rule_type: FILTER_HAS_CUSTOM_FIELDS_ALL,
value: '43',
},
]
expect(component.customFieldSelectionModel.logicalOperator).toEqual(
LogicalOperator.And
)
expect(component.customFieldSelectionModel.getSelectedItems()).toEqual(
custom_fields
)
// coverage
component.filterRules = [
{
rule_type: FILTER_HAS_CUSTOM_FIELDS_ALL,
value: null,
},
]
component.toggleTag(2) // coverage
}))
it('should ingest filter rules for has any custom fields', fakeAsync(() => {
expect(component.customFieldSelectionModel.getSelectedItems()).toHaveLength(
0
)
component.filterRules = [
{
rule_type: FILTER_HAS_CUSTOM_FIELDS_ANY,
value: '42',
},
{
rule_type: FILTER_HAS_CUSTOM_FIELDS_ANY,
value: '43',
},
]
expect(component.customFieldSelectionModel.logicalOperator).toEqual(
LogicalOperator.Or
)
expect(component.customFieldSelectionModel.getSelectedItems()).toEqual(
custom_fields
)
// coverage
component.filterRules = [
{
rule_type: FILTER_HAS_CUSTOM_FIELDS_ANY,
value: null,
},
]
}))
it('should ingest filter rules for has any custom field', fakeAsync(() => {
expect(component.customFieldSelectionModel.getSelectedItems()).toHaveLength(
0
)
component.filterRules = [
{
rule_type: FILTER_HAS_ANY_CUSTOM_FIELDS,
value: '1',
},
]
expect(component.customFieldSelectionModel.getSelectedItems()).toHaveLength(
1
)
expect(component.customFieldSelectionModel.get(null)).toBeTruthy()
}))
it('should ingest filter rules for exclude tag(s)', fakeAsync(() => {
expect(component.customFieldSelectionModel.getExcludedItems()).toHaveLength(
0
)
component.filterRules = [
{
rule_type: FILTER_DOES_NOT_HAVE_CUSTOM_FIELDS,
value: '42',
},
{
rule_type: FILTER_DOES_NOT_HAVE_CUSTOM_FIELDS,
value: '43',
},
]
expect(component.customFieldSelectionModel.logicalOperator).toEqual(
LogicalOperator.And
)
expect(component.customFieldSelectionModel.getExcludedItems()).toEqual(
custom_fields
)
// coverage
component.filterRules = [
{
rule_type: FILTER_DOES_NOT_HAVE_CUSTOM_FIELDS,
value: null,
},
]
}))
it('should ingest filter rules for owner', fakeAsync(() => { it('should ingest filter rules for owner', fakeAsync(() => {
expect(component.permissionsSelectionModel.ownerFilter).toEqual( expect(component.permissionsSelectionModel.ownerFilter).toEqual(
OwnerFilterType.NONE OwnerFilterType.NONE
@@ -1053,7 +1182,7 @@ describe('FilterEditorComponent', () => {
expect(component.textFilterTarget).toEqual('custom-fields') expect(component.textFilterTarget).toEqual('custom-fields')
expect(component.filterRules).toEqual([ expect(component.filterRules).toEqual([
{ {
rule_type: FILTER_CUSTOM_FIELDS, rule_type: FILTER_CUSTOM_FIELDS_TEXT,
value: 'foo', value: 'foo',
}, },
]) ])
@@ -1317,9 +1446,78 @@ describe('FilterEditorComponent', () => {
]) ])
})) }))
it('should convert user input to correct filter rules on custom field select not assigned', fakeAsync(() => {
const customFieldsFilterableDropdown = fixture.debugElement.queryAll(
By.directive(FilterableDropdownComponent)
)[4]
customFieldsFilterableDropdown.triggerEventHandler('opened')
const customFieldButton = customFieldsFilterableDropdown.queryAll(
By.directive(ToggleableDropdownButtonComponent)
)[0]
customFieldButton.triggerEventHandler('toggle')
fixture.detectChanges()
expect(component.filterRules).toEqual([
{
rule_type: FILTER_HAS_ANY_CUSTOM_FIELDS,
value: 'false',
},
])
}))
it('should convert user input to correct filter rules on custom field selections', fakeAsync(() => {
const customFieldsFilterableDropdown = fixture.debugElement.queryAll(
By.directive(FilterableDropdownComponent)
)[4] // CF dropdown
customFieldsFilterableDropdown.triggerEventHandler('opened')
const customFieldButtons = customFieldsFilterableDropdown.queryAll(
By.directive(ToggleableDropdownButtonComponent)
)
customFieldButtons[1].triggerEventHandler('toggle')
customFieldButtons[2].triggerEventHandler('toggle')
fixture.detectChanges()
expect(component.filterRules).toEqual([
{
rule_type: FILTER_HAS_CUSTOM_FIELDS_ALL,
value: custom_fields[0].id.toString(),
},
{
rule_type: FILTER_HAS_CUSTOM_FIELDS_ALL,
value: custom_fields[1].id.toString(),
},
])
const toggleOperatorButtons = customFieldsFilterableDropdown.queryAll(
By.css('input[type=radio]')
)
toggleOperatorButtons[1].nativeElement.checked = true
toggleOperatorButtons[1].triggerEventHandler('change')
fixture.detectChanges()
expect(component.filterRules).toEqual([
{
rule_type: FILTER_HAS_CUSTOM_FIELDS_ANY,
value: custom_fields[0].id.toString(),
},
{
rule_type: FILTER_HAS_CUSTOM_FIELDS_ANY,
value: custom_fields[1].id.toString(),
},
])
customFieldButtons[2].triggerEventHandler('exclude')
fixture.detectChanges()
expect(component.filterRules).toEqual([
{
rule_type: FILTER_HAS_CUSTOM_FIELDS_ALL,
value: custom_fields[0].id.toString(),
},
{
rule_type: FILTER_DOES_NOT_HAVE_CUSTOM_FIELDS,
value: custom_fields[1].id.toString(),
},
])
}))
it('should convert user input to correct filter rules on date created after', fakeAsync(() => { it('should convert user input to correct filter rules on date created after', fakeAsync(() => {
const dateCreatedDropdown = fixture.debugElement.queryAll( const dateCreatedDropdown = fixture.debugElement.queryAll(
By.directive(DateDropdownComponent) By.directive(DatesDropdownComponent)
)[0] )[0]
const dateCreatedAfter = dateCreatedDropdown.queryAll(By.css('input'))[0] const dateCreatedAfter = dateCreatedDropdown.queryAll(By.css('input'))[0]
@@ -1339,7 +1537,7 @@ describe('FilterEditorComponent', () => {
it('should convert user input to correct filter rules on date created before', fakeAsync(() => { it('should convert user input to correct filter rules on date created before', fakeAsync(() => {
const dateCreatedDropdown = fixture.debugElement.queryAll( const dateCreatedDropdown = fixture.debugElement.queryAll(
By.directive(DateDropdownComponent) By.directive(DatesDropdownComponent)
)[0] )[0]
const dateCreatedBefore = dateCreatedDropdown.queryAll(By.css('input'))[1] const dateCreatedBefore = dateCreatedDropdown.queryAll(By.css('input'))[1]
@@ -1359,7 +1557,7 @@ describe('FilterEditorComponent', () => {
it('should convert user input to correct filter rules on date created with relative date', fakeAsync(() => { it('should convert user input to correct filter rules on date created with relative date', fakeAsync(() => {
const dateCreatedDropdown = fixture.debugElement.queryAll( const dateCreatedDropdown = fixture.debugElement.queryAll(
By.directive(DateDropdownComponent) By.directive(DatesDropdownComponent)
)[0] )[0]
const dateCreatedBeforeRelativeButton = dateCreatedDropdown.queryAll( const dateCreatedBeforeRelativeButton = dateCreatedDropdown.queryAll(
By.css('button') By.css('button')
@@ -1378,7 +1576,7 @@ describe('FilterEditorComponent', () => {
it('should carry over text filtering on date created with relative date', fakeAsync(() => { it('should carry over text filtering on date created with relative date', fakeAsync(() => {
component.textFilter = 'foo' component.textFilter = 'foo'
const dateCreatedDropdown = fixture.debugElement.queryAll( const dateCreatedDropdown = fixture.debugElement.queryAll(
By.directive(DateDropdownComponent) By.directive(DatesDropdownComponent)
)[0] )[0]
const dateCreatedBeforeRelativeButton = dateCreatedDropdown.queryAll( const dateCreatedBeforeRelativeButton = dateCreatedDropdown.queryAll(
By.css('button') By.css('button')
@@ -1423,10 +1621,10 @@ describe('FilterEditorComponent', () => {
})) }))
it('should convert user input to correct filter rules on date added after', fakeAsync(() => { it('should convert user input to correct filter rules on date added after', fakeAsync(() => {
const dateAddedDropdown = fixture.debugElement.queryAll( const datesDropdown = fixture.debugElement.query(
By.directive(DateDropdownComponent) By.directive(DatesDropdownComponent)
)[1] )
const dateAddedAfter = dateAddedDropdown.queryAll(By.css('input'))[0] const dateAddedAfter = datesDropdown.queryAll(By.css('input'))[2]
dateAddedAfter.nativeElement.value = '05/14/2023' dateAddedAfter.nativeElement.value = '05/14/2023'
// dateAddedAfter.triggerEventHandler('change') // dateAddedAfter.triggerEventHandler('change')
@@ -1443,10 +1641,10 @@ describe('FilterEditorComponent', () => {
})) }))
it('should convert user input to correct filter rules on date added before', fakeAsync(() => { it('should convert user input to correct filter rules on date added before', fakeAsync(() => {
const dateAddedDropdown = fixture.debugElement.queryAll( const datesDropdown = fixture.debugElement.query(
By.directive(DateDropdownComponent) By.directive(DatesDropdownComponent)
)[1] )
const dateAddedBefore = dateAddedDropdown.queryAll(By.css('input'))[1] const dateAddedBefore = datesDropdown.queryAll(By.css('input'))[2]
dateAddedBefore.nativeElement.value = '05/14/2023' dateAddedBefore.nativeElement.value = '05/14/2023'
// dateAddedBefore.triggerEventHandler('change') // dateAddedBefore.triggerEventHandler('change')
@@ -1463,38 +1661,38 @@ describe('FilterEditorComponent', () => {
})) }))
it('should convert user input to correct filter rules on date added with relative date', fakeAsync(() => { it('should convert user input to correct filter rules on date added with relative date', fakeAsync(() => {
const dateAddedDropdown = fixture.debugElement.queryAll( const datesDropdown = fixture.debugElement.query(
By.directive(DateDropdownComponent) By.directive(DatesDropdownComponent)
)[1] )
const dateAddedBeforeRelativeButton = dateAddedDropdown.queryAll( const dateCreatedBeforeRelativeButton = datesDropdown.queryAll(
By.css('button') By.css('button')
)[1] )[1]
dateAddedBeforeRelativeButton.triggerEventHandler('click') dateCreatedBeforeRelativeButton.triggerEventHandler('click')
fixture.detectChanges() fixture.detectChanges()
tick(400) tick(400)
expect(component.filterRules).toEqual([ expect(component.filterRules).toEqual([
{ {
rule_type: FILTER_FULLTEXT_QUERY, rule_type: FILTER_FULLTEXT_QUERY,
value: 'added:[-1 week to now]', value: 'created:[-1 week to now]',
}, },
]) ])
})) }))
it('should carry over text filtering on date added with relative date', fakeAsync(() => { it('should carry over text filtering on date added with relative date', fakeAsync(() => {
component.textFilter = 'foo' component.textFilter = 'foo'
const dateAddedDropdown = fixture.debugElement.queryAll( const datesDropdown = fixture.debugElement.query(
By.directive(DateDropdownComponent) By.directive(DatesDropdownComponent)
)[1] )
const dateAddedBeforeRelativeButton = dateAddedDropdown.queryAll( const dateCreatedBeforeRelativeButton = datesDropdown.queryAll(
By.css('button') By.css('button')
)[1] )[1]
dateAddedBeforeRelativeButton.triggerEventHandler('click') dateCreatedBeforeRelativeButton.triggerEventHandler('click')
fixture.detectChanges() fixture.detectChanges()
tick(400) tick(400)
expect(component.filterRules).toEqual([ expect(component.filterRules).toEqual([
{ {
rule_type: FILTER_FULLTEXT_QUERY, rule_type: FILTER_FULLTEXT_QUERY,
value: 'foo,added:[-1 week to now]', value: 'foo,created:[-1 week to now]',
}, },
]) ])
})) }))
@@ -1645,6 +1843,10 @@ describe('FilterEditorComponent', () => {
{ id: 32, document_count: 1 }, { id: 32, document_count: 1 },
{ id: 33, document_count: 0 }, { id: 33, document_count: 0 },
], ],
selected_custom_fields: [
{ id: 42, document_count: 1 },
{ id: 43, document_count: 0 },
],
} }
}) })
@@ -1719,6 +1921,24 @@ describe('FilterEditorComponent', () => {
] ]
expect(component.generateFilterName()).toEqual('Without any tag') expect(component.generateFilterName()).toEqual('Without any tag')
component.filterRules = [
{
rule_type: FILTER_HAS_CUSTOM_FIELDS_ALL,
value: '42',
},
]
expect(component.generateFilterName()).toEqual(
`Custom fields: ${custom_fields[0].name}`
)
component.filterRules = [
{
rule_type: FILTER_HAS_ANY_CUSTOM_FIELDS,
value: 'false',
},
]
expect(component.generateFilterName()).toEqual('Without any custom field')
component.filterRules = [ component.filterRules = [
{ {
rule_type: FILTER_TITLE, rule_type: FILTER_TITLE,

View File

@@ -48,8 +48,12 @@ import {
FILTER_OWNER_DOES_NOT_INCLUDE, FILTER_OWNER_DOES_NOT_INCLUDE,
FILTER_OWNER_ISNULL, FILTER_OWNER_ISNULL,
FILTER_OWNER_ANY, FILTER_OWNER_ANY,
FILTER_CUSTOM_FIELDS, FILTER_CUSTOM_FIELDS_TEXT,
FILTER_SHARED_BY_USER, FILTER_SHARED_BY_USER,
FILTER_HAS_CUSTOM_FIELDS_ANY,
FILTER_HAS_CUSTOM_FIELDS_ALL,
FILTER_HAS_ANY_CUSTOM_FIELDS,
FILTER_DOES_NOT_HAVE_CUSTOM_FIELDS,
} from 'src/app/data/filter-rule-type' } from 'src/app/data/filter-rule-type'
import { import {
FilterableDropdownSelectionModel, FilterableDropdownSelectionModel,
@@ -65,7 +69,7 @@ import {
import { Document } from 'src/app/data/document' import { Document } from 'src/app/data/document'
import { StoragePath } from 'src/app/data/storage-path' import { StoragePath } from 'src/app/data/storage-path'
import { StoragePathService } from 'src/app/services/rest/storage-path.service' import { StoragePathService } from 'src/app/services/rest/storage-path.service'
import { RelativeDate } from '../../common/date-dropdown/date-dropdown.component' import { RelativeDate } from '../../common/dates-dropdown/dates-dropdown.component'
import { import {
OwnerFilterType, OwnerFilterType,
PermissionsSelectionModel, PermissionsSelectionModel,
@@ -76,6 +80,8 @@ import {
PermissionsService, PermissionsService,
} 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 { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
import { CustomField } from 'src/app/data/custom-field'
const TEXT_FILTER_TARGET_TITLE = 'title' const TEXT_FILTER_TARGET_TITLE = 'title'
const TEXT_FILTER_TARGET_TITLE_CONTENT = 'title-content' const TEXT_FILTER_TARGET_TITLE_CONTENT = 'title-content'
@@ -208,6 +214,16 @@ export class FilterEditorComponent
return $localize`Without any tag` return $localize`Without any tag`
} }
case FILTER_HAS_CUSTOM_FIELDS_ALL:
return $localize`Custom fields: ${this.customFields.find(
(f) => f.id == +rule.value
)?.name}`
case FILTER_HAS_ANY_CUSTOM_FIELDS:
if (rule.value == 'false') {
return $localize`Without any custom field`
}
case FILTER_TITLE: case FILTER_TITLE:
return $localize`Title: ${rule.value}` return $localize`Title: ${rule.value}`
@@ -234,7 +250,8 @@ export class FilterEditorComponent
private correspondentService: CorrespondentService, private correspondentService: CorrespondentService,
private documentService: DocumentService, private documentService: DocumentService,
private storagePathService: StoragePathService, private storagePathService: StoragePathService,
public permissionsService: PermissionsService public permissionsService: PermissionsService,
private customFieldService: CustomFieldsService
) { ) {
super() super()
} }
@@ -246,11 +263,13 @@ export class FilterEditorComponent
correspondents: Correspondent[] = [] correspondents: Correspondent[] = []
documentTypes: DocumentType[] = [] documentTypes: DocumentType[] = []
storagePaths: StoragePath[] = [] storagePaths: StoragePath[] = []
customFields: CustomField[] = []
tagDocumentCounts: SelectionDataItem[] tagDocumentCounts: SelectionDataItem[]
correspondentDocumentCounts: SelectionDataItem[] correspondentDocumentCounts: SelectionDataItem[]
documentTypeDocumentCounts: SelectionDataItem[] documentTypeDocumentCounts: SelectionDataItem[]
storagePathDocumentCounts: SelectionDataItem[] storagePathDocumentCounts: SelectionDataItem[]
customFieldDocumentCounts: SelectionDataItem[]
_textFilter = '' _textFilter = ''
_moreLikeId: number _moreLikeId: number
@@ -288,6 +307,7 @@ export class FilterEditorComponent
correspondentSelectionModel = new FilterableDropdownSelectionModel() correspondentSelectionModel = new FilterableDropdownSelectionModel()
documentTypeSelectionModel = new FilterableDropdownSelectionModel() documentTypeSelectionModel = new FilterableDropdownSelectionModel()
storagePathSelectionModel = new FilterableDropdownSelectionModel() storagePathSelectionModel = new FilterableDropdownSelectionModel()
customFieldSelectionModel = new FilterableDropdownSelectionModel()
dateCreatedBefore: string dateCreatedBefore: string
dateCreatedAfter: string dateCreatedAfter: string
@@ -322,6 +342,7 @@ export class FilterEditorComponent
this.storagePathSelectionModel.clear(false) this.storagePathSelectionModel.clear(false)
this.tagSelectionModel.clear(false) this.tagSelectionModel.clear(false)
this.correspondentSelectionModel.clear(false) this.correspondentSelectionModel.clear(false)
this.customFieldSelectionModel.clear(false)
this._textFilter = null this._textFilter = null
this._moreLikeId = null this._moreLikeId = null
this.dateAddedBefore = null this.dateAddedBefore = null
@@ -347,7 +368,7 @@ export class FilterEditorComponent
this._textFilter = rule.value this._textFilter = rule.value
this.textFilterTarget = TEXT_FILTER_TARGET_ASN this.textFilterTarget = TEXT_FILTER_TARGET_ASN
break break
case FILTER_CUSTOM_FIELDS: case FILTER_CUSTOM_FIELDS_TEXT:
this._textFilter = rule.value this._textFilter = rule.value
this.textFilterTarget = TEXT_FILTER_TARGET_CUSTOM_FIELDS this.textFilterTarget = TEXT_FILTER_TARGET_CUSTOM_FIELDS
break break
@@ -488,6 +509,36 @@ export class FilterEditorComponent
false false
) )
break break
case FILTER_HAS_CUSTOM_FIELDS_ALL:
this.customFieldSelectionModel.logicalOperator = LogicalOperator.And
this.customFieldSelectionModel.set(
rule.value ? +rule.value : null,
ToggleableItemState.Selected,
false
)
break
case FILTER_HAS_CUSTOM_FIELDS_ANY:
this.customFieldSelectionModel.logicalOperator = LogicalOperator.Or
this.customFieldSelectionModel.set(
rule.value ? +rule.value : null,
ToggleableItemState.Selected,
false
)
break
case FILTER_HAS_ANY_CUSTOM_FIELDS:
this.customFieldSelectionModel.set(
null,
ToggleableItemState.Selected,
false
)
break
case FILTER_DOES_NOT_HAVE_CUSTOM_FIELDS:
this.customFieldSelectionModel.set(
rule.value ? +rule.value : null,
ToggleableItemState.Excluded,
false
)
break
case FILTER_ASN_ISNULL: case FILTER_ASN_ISNULL:
this.textFilterTarget = TEXT_FILTER_TARGET_ASN this.textFilterTarget = TEXT_FILTER_TARGET_ASN
this.textFilterModifier = this.textFilterModifier =
@@ -595,7 +646,7 @@ export class FilterEditorComponent
this.textFilterTarget == TEXT_FILTER_TARGET_CUSTOM_FIELDS this.textFilterTarget == TEXT_FILTER_TARGET_CUSTOM_FIELDS
) { ) {
filterRules.push({ filterRules.push({
rule_type: FILTER_CUSTOM_FIELDS, rule_type: FILTER_CUSTOM_FIELDS_TEXT,
value: this._textFilter, value: this._textFilter,
}) })
} }
@@ -703,6 +754,35 @@ export class FilterEditorComponent
}) })
}) })
} }
if (this.customFieldSelectionModel.isNoneSelected()) {
filterRules.push({
rule_type: FILTER_HAS_ANY_CUSTOM_FIELDS,
value: 'false',
})
} else {
const customFieldFilterType =
this.customFieldSelectionModel.logicalOperator == LogicalOperator.And
? FILTER_HAS_CUSTOM_FIELDS_ALL
: FILTER_HAS_CUSTOM_FIELDS_ANY
this.customFieldSelectionModel
.getSelectedItems()
.filter((field) => field.id)
.forEach((field) => {
filterRules.push({
rule_type: customFieldFilterType,
value: field.id?.toString(),
})
})
this.customFieldSelectionModel
.getExcludedItems()
.filter((field) => field.id)
.forEach((field) => {
filterRules.push({
rule_type: FILTER_DOES_NOT_HAVE_CUSTOM_FIELDS,
value: field.id?.toString(),
})
})
}
if (this.dateCreatedBefore) { if (this.dateCreatedBefore) {
filterRules.push({ filterRules.push({
rule_type: FILTER_CREATED_BEFORE, rule_type: FILTER_CREATED_BEFORE,
@@ -845,6 +925,8 @@ export class FilterEditorComponent
selectionData?.selected_correspondents ?? null selectionData?.selected_correspondents ?? null
this.storagePathDocumentCounts = this.storagePathDocumentCounts =
selectionData?.selected_storage_paths ?? null selectionData?.selected_storage_paths ?? null
this.customFieldDocumentCounts =
selectionData?.selected_custom_fields ?? null
} }
rulesModified: boolean = false rulesModified: boolean = false
@@ -905,6 +987,16 @@ export class FilterEditorComponent
.listAll() .listAll()
.subscribe((result) => (this.storagePaths = result.results)) .subscribe((result) => (this.storagePaths = result.results))
} }
if (
this.permissionsService.currentUserCan(
PermissionAction.View,
PermissionType.CustomField
)
) {
this.customFieldService
.listAll()
.subscribe((result) => (this.customFields = result.results))
}
this.textFilterDebounce = new Subject<string>() this.textFilterDebounce = new Subject<string>()
@@ -961,6 +1053,10 @@ export class FilterEditorComponent
this.storagePathSelectionModel.apply() this.storagePathSelectionModel.apply()
} }
onCustomFieldsDropdownOpen() {
this.customFieldSelectionModel.apply()
}
updateTextFilter(text) { updateTextFilter(text) {
this._textFilter = text this._textFilter = text
this.documentService.searchQuery = text this.documentService.searchQuery = text

View File

@@ -24,7 +24,7 @@
<ngb-pagination class="col-auto" [pageSize]="25" [collectionSize]="collectionSize" [(page)]="page" [maxSize]="5" (pageChange)="reloadData()" size="sm" aria-label="Pagination"></ngb-pagination> <ngb-pagination class="col-auto" [pageSize]="25" [collectionSize]="collectionSize" [(page)]="page" [maxSize]="5" (pageChange)="reloadData()" size="sm" aria-label="Pagination"></ngb-pagination>
</div> </div>
<div class="card border mb-3"> <div class="card border table-responsive mb-3">
<table class="table table-striped align-middle shadow-sm mb-0"> <table class="table table-striped align-middle shadow-sm mb-0">
<thead> <thead>
<tr> <tr>
@@ -74,7 +74,7 @@
} }
<td scope="row"> <td scope="row">
<div class="btn-group d-block d-sm-none"> <div class="btn-group d-block d-sm-none">
<div ngbDropdown class="d-inline-block"> <div ngbDropdown container="body" class="d-inline-block">
<button type="button" class="btn btn-link" id="actionsMenuMobile" (click)="$event.stopPropagation()" ngbDropdownToggle> <button type="button" class="btn btn-link" id="actionsMenuMobile" (click)="$event.stopPropagation()" ngbDropdownToggle>
<i-bs name="three-dots-vertical"></i-bs> <i-bs name="three-dots-vertical"></i-bs>
</button> </button>

View File

@@ -0,0 +1,18 @@
import { User } from './user'
export enum AuditLogAction {
Create = 'create',
Update = 'update',
Delete = 'delete',
}
export interface AuditLogEntry {
id: number
timestamp: string
action: AuditLogAction
changes: {
[key: string]: string[]
}
remote_addr: string
actor?: User
}

View File

@@ -7,6 +7,102 @@ import { ObjectWithPermissions } from './object-with-permissions'
import { DocumentNote } from './document-note' import { DocumentNote } from './document-note'
import { CustomFieldInstance } from './custom-field-instance' import { CustomFieldInstance } from './custom-field-instance'
export enum DisplayMode {
TABLE = 'table',
SMALL_CARDS = 'smallCards',
LARGE_CARDS = 'largeCards',
}
export enum DisplayField {
TITLE = 'title',
CREATED = 'created',
ADDED = 'added',
TAGS = 'tag',
CORRESPONDENT = 'correspondent',
DOCUMENT_TYPE = 'documenttype',
STORAGE_PATH = 'storagepath',
CUSTOM_FIELD = 'custom_field_',
NOTES = 'note',
OWNER = 'owner',
SHARED = 'shared',
ASN = 'asn',
}
export const DEFAULT_DISPLAY_FIELDS = [
{
id: DisplayField.TITLE,
name: $localize`Title`,
},
{
id: DisplayField.CREATED,
name: $localize`Created`,
},
{
id: DisplayField.ADDED,
name: $localize`Added`,
},
{
id: DisplayField.TAGS,
name: $localize`Tags`,
},
{
id: DisplayField.CORRESPONDENT,
name: $localize`Correspondent`,
},
{
id: DisplayField.DOCUMENT_TYPE,
name: $localize`Document type`,
},
{
id: DisplayField.STORAGE_PATH,
name: $localize`Storage path`,
},
{
id: DisplayField.NOTES,
name: $localize`Notes`,
},
{
id: DisplayField.OWNER,
name: $localize`Owner`,
},
{
id: DisplayField.SHARED,
name: $localize`Shared`,
},
{
id: DisplayField.ASN,
name: $localize`ASN`,
},
]
export const DEFAULT_DASHBOARD_VIEW_PAGE_SIZE = 10
export const DEFAULT_DASHBOARD_DISPLAY_FIELDS = [
DisplayField.CREATED,
DisplayField.TITLE,
DisplayField.TAGS,
DisplayField.CORRESPONDENT,
]
export const DOCUMENT_SORT_FIELDS = [
{ field: 'archive_serial_number', name: $localize`ASN` },
{ field: 'correspondent__name', name: $localize`Correspondent` },
{ field: 'title', name: $localize`Title` },
{ field: 'document_type__name', name: $localize`Document type` },
{ field: 'created', name: $localize`Created` },
{ field: 'added', name: $localize`Added` },
{ field: 'modified', name: $localize`Modified` },
{ field: 'num_notes', name: $localize`Notes` },
{ field: 'owner', name: $localize`Owner` },
]
export const DOCUMENT_SORT_FIELDS_FULLTEXT = [
{
field: 'score',
name: $localize`:Score is a value returned by the full text search engine and specifies how well a result matches the given query:Search score`,
},
]
export interface SearchHit { export interface SearchHit {
score?: number score?: number
rank?: number rank?: number

View File

@@ -47,7 +47,11 @@ export const FILTER_OWNER_ISNULL = 34
export const FILTER_OWNER_DOES_NOT_INCLUDE = 35 export const FILTER_OWNER_DOES_NOT_INCLUDE = 35
export const FILTER_SHARED_BY_USER = 37 export const FILTER_SHARED_BY_USER = 37
export const FILTER_CUSTOM_FIELDS = 36 export const FILTER_CUSTOM_FIELDS_TEXT = 36
export const FILTER_HAS_CUSTOM_FIELDS_ALL = 38
export const FILTER_HAS_CUSTOM_FIELDS_ANY = 39
export const FILTER_DOES_NOT_HAVE_CUSTOM_FIELDS = 40
export const FILTER_HAS_ANY_CUSTOM_FIELDS = 41
export const FILTER_RULE_TYPES: FilterRuleType[] = [ export const FILTER_RULE_TYPES: FilterRuleType[] = [
{ {
@@ -281,11 +285,36 @@ export const FILTER_RULE_TYPES: FilterRuleType[] = [
multi: true, multi: true,
}, },
{ {
id: FILTER_CUSTOM_FIELDS, id: FILTER_CUSTOM_FIELDS_TEXT,
filtervar: 'custom_fields__icontains', filtervar: 'custom_fields__icontains',
datatype: 'string', datatype: 'string',
multi: false, multi: false,
}, },
{
id: FILTER_HAS_CUSTOM_FIELDS_ALL,
filtervar: 'custom_fields__id__all',
datatype: 'number',
multi: true,
},
{
id: FILTER_HAS_CUSTOM_FIELDS_ANY,
filtervar: 'custom_fields__id__in',
datatype: 'number',
multi: true,
},
{
id: FILTER_DOES_NOT_HAVE_CUSTOM_FIELDS,
filtervar: 'custom_fields__id__none',
datatype: 'number',
multi: true,
},
{
id: FILTER_HAS_ANY_CUSTOM_FIELDS,
filtervar: 'has_custom_fields',
datatype: 'boolean',
multi: false,
default: true,
},
] ]
export interface FilterRuleType { export interface FilterRuleType {

View File

@@ -1,3 +1,4 @@
import { DisplayMode, DisplayField } from './document'
import { FilterRule } from './filter-rule' import { FilterRule } from './filter-rule'
import { ObjectWithPermissions } from './object-with-permissions' import { ObjectWithPermissions } from './object-with-permissions'
@@ -13,4 +14,10 @@ export interface SavedView extends ObjectWithPermissions {
sort_reverse: boolean sort_reverse: boolean
filter_rules: FilterRule[] filter_rules: FilterRule[]
page_size?: number
display_mode?: DisplayMode
display_fields?: DisplayField[]
} }

View File

@@ -37,6 +37,7 @@ export const SETTINGS_KEYS = {
NOTIFICATIONS_CONSUMER_SUPPRESS_ON_DASHBOARD: NOTIFICATIONS_CONSUMER_SUPPRESS_ON_DASHBOARD:
'general-settings:notifications:consumer-suppress-on-dashboard', 'general-settings:notifications:consumer-suppress-on-dashboard',
NOTES_ENABLED: 'general-settings:notes-enabled', NOTES_ENABLED: 'general-settings:notes-enabled',
AUDITLOG_ENABLED: 'general-settings:auditlog-enabled',
SLIM_SIDEBAR: 'general-settings:slim-sidebar', SLIM_SIDEBAR: 'general-settings:slim-sidebar',
UPDATE_CHECKING_ENABLED: 'general-settings:update-checking:enabled', UPDATE_CHECKING_ENABLED: 'general-settings:update-checking:enabled',
UPDATE_CHECKING_BACKEND_SETTING: UPDATE_CHECKING_BACKEND_SETTING:
@@ -143,6 +144,11 @@ export const SETTINGS: UiSetting[] = [
type: 'boolean', type: 'boolean',
default: true, default: true,
}, },
{
key: SETTINGS_KEYS.AUDITLOG_ENABLED,
type: 'boolean',
default: true,
},
{ {
key: SETTINGS_KEYS.UPDATE_CHECKING_ENABLED, key: SETTINGS_KEYS.UPDATE_CHECKING_ENABLED,
type: 'boolean', type: 'boolean',

View File

@@ -23,10 +23,12 @@ export class PermissionsGuard {
state: RouterStateSnapshot state: RouterStateSnapshot
): boolean | UrlTree { ): boolean | UrlTree {
if ( if (
!this.permissionsService.currentUserCan( (route.data.requireAdmin && !this.permissionsService.isAdmin()) ||
route.data.requiredPermission.action, (route.data.requiredPermission &&
route.data.requiredPermission.type !this.permissionsService.currentUserCan(
) route.data.requiredPermission.action,
route.data.requiredPermission.type
))
) { ) {
// Check if tour is running 1 = TourState.ON // Check if tour is running 1 = TourState.ON
if (this.tourService.getStatus() !== 1) { if (this.tourService.getStatus() !== 1) {

View File

@@ -1,10 +1,7 @@
import { TestBed } from '@angular/core/testing' import { TestBed } from '@angular/core/testing'
import { CustomDatePipe } from './custom-date.pipe' import { CustomDatePipe } from './custom-date.pipe'
import { SettingsService } from '../services/settings.service' import { SettingsService } from '../services/settings.service'
import { import { HttpClientTestingModule } from '@angular/common/http/testing'
HttpClientTestingModule,
HttpTestingController,
} from '@angular/common/http/testing'
import { DatePipe } from '@angular/common' import { DatePipe } from '@angular/common'
describe('CustomDatePipe', () => { describe('CustomDatePipe', () => {
@@ -30,4 +27,14 @@ describe('CustomDatePipe', () => {
) )
).toEqual('2023-05-04') ).toEqual('2023-05-04')
}) })
it('should support relative date formatting', () => {
const now = new Date()
const notNow = new Date(now)
notNow.setDate(now.getDate() - 1)
expect(datePipe.transform(notNow, 'relative')).toEqual('1 day ago')
notNow.setDate(now.getDate() - 2)
expect(datePipe.transform(notNow, 'relative')).toEqual('2 days ago')
expect(datePipe.transform(now, 'relative')).toEqual('Just now')
})
}) })

View File

@@ -9,6 +9,39 @@ const FORMAT_TO_ISO_FORMAT = {
shortDate: 'y-MM-dd', shortDate: 'y-MM-dd',
} }
const INTERVALS = {
year: {
label: $localize`%s year ago`,
labelPlural: $localize`%s years ago`,
interval: 31536000,
},
month: {
label: $localize`%s month ago`,
labelPlural: $localize`%s months ago`,
interval: 2592000,
},
week: {
label: $localize`%s week ago`,
labelPlural: $localize`%s weeks ago`,
interval: 604800,
},
day: {
label: $localize`%s day ago`,
labelPlural: $localize`%s days ago`,
interval: 86400,
},
hour: {
label: $localize`%s hour ago`,
labelPlural: $localize`%s hours ago`,
interval: 3600,
},
minute: {
label: $localize`%s minute ago`,
labelPlural: $localize`%s minutes ago`,
interval: 60,
},
}
@Pipe({ @Pipe({
name: 'customDate', name: 'customDate',
}) })
@@ -34,6 +67,19 @@ export class CustomDatePipe implements PipeTransform {
this.settings.get(SETTINGS_KEYS.DATE_LOCALE) || this.settings.get(SETTINGS_KEYS.DATE_LOCALE) ||
this.defaultLocale this.defaultLocale
let f = format || this.settings.get(SETTINGS_KEYS.DATE_FORMAT) let f = format || this.settings.get(SETTINGS_KEYS.DATE_FORMAT)
if (format === 'relative') {
const seconds = Math.floor((+new Date() - +new Date(value)) / 1000)
if (seconds < 60) return $localize`Just now`
let counter
for (const i in INTERVALS) {
counter = Math.floor(seconds / INTERVALS[i].interval)
if (counter > 0) {
const label =
counter > 1 ? INTERVALS[i].labelPlural : INTERVALS[i].label
return label.replace('%s', counter.toString())
}
}
}
if (l == 'iso-8601') { if (l == 'iso-8601') {
return this.datePipe.transform(value, FORMAT_TO_ISO_FORMAT[f], timezone) return this.datePipe.transform(value, FORMAT_TO_ISO_FORMAT[f], timezone)
} else { } else {

View File

@@ -18,6 +18,8 @@ describe('ConsumerStatusService', () => {
let httpTestingController: HttpTestingController let httpTestingController: HttpTestingController
let consumerStatusService: ConsumerStatusService let consumerStatusService: ConsumerStatusService
let documentService: DocumentService let documentService: DocumentService
let settingsService: SettingsService
const server = new WS( const server = new WS(
`${environment.webSocketProtocol}//${environment.webSocketHost}${environment.webSocketBaseUrl}status/`, `${environment.webSocketProtocol}//${environment.webSocketHost}${environment.webSocketBaseUrl}status/`,
{ jsonProtocol: true } { jsonProtocol: true }
@@ -25,25 +27,17 @@ describe('ConsumerStatusService', () => {
beforeEach(() => { beforeEach(() => {
TestBed.configureTestingModule({ TestBed.configureTestingModule({
providers: [ providers: [ConsumerStatusService, DocumentService, SettingsService],
ConsumerStatusService,
DocumentService,
SettingsService,
{
provide: SettingsService,
useValue: {
currentUser: {
id: 1,
username: 'testuser',
is_superuser: false,
},
},
},
],
imports: [HttpClientTestingModule], imports: [HttpClientTestingModule],
}) })
httpTestingController = TestBed.inject(HttpTestingController) httpTestingController = TestBed.inject(HttpTestingController)
settingsService = TestBed.inject(SettingsService)
settingsService.currentUser = {
id: 1,
username: 'testuser',
is_superuser: false,
}
consumerStatusService = TestBed.inject(ConsumerStatusService) consumerStatusService = TestBed.inject(ConsumerStatusService)
documentService = TestBed.inject(DocumentService) documentService = TestBed.inject(DocumentService)
}) })

View File

@@ -4,7 +4,7 @@ import { environment } from 'src/environments/environment'
import { WebsocketConsumerStatusMessage } from '../data/websocket-consumer-status-message' import { WebsocketConsumerStatusMessage } from '../data/websocket-consumer-status-message'
import { SettingsService } from './settings.service' import { SettingsService } from './settings.service'
// see ConsumerFilePhase in src/documents/consumer.py // see ProgressStatusOptions in src/documents/plugins/helpers.py
export enum FileStatusPhase { export enum FileStatusPhase {
STARTED = 0, STARTED = 0,
UPLOADING = 1, UPLOADING = 1,

View File

@@ -19,6 +19,11 @@ import { routes } from 'src/app/app-routing.module'
import { PermissionsGuard } from '../guards/permissions.guard' import { PermissionsGuard } from '../guards/permissions.guard'
import { SettingsService } from './settings.service' import { SettingsService } from './settings.service'
import { SETTINGS_KEYS } from '../data/ui-settings' import { SETTINGS_KEYS } from '../data/ui-settings'
import {
DisplayMode,
DisplayField,
DEFAULT_DISPLAY_FIELDS,
} from '../data/document'
const documents = [ const documents = [
{ {
@@ -213,7 +218,7 @@ describe('DocumentListViewService', () => {
documentListViewService.loadFromQueryParams(convertToParamMap(params)) documentListViewService.loadFromQueryParams(convertToParamMap(params))
const req = httpTestingController.expectOne( const req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/?page=${page}&page_size=${ `${environment.apiBaseUrl}documents/?page=${page}&page_size=${
documentListViewService.currentPageSize documentListViewService.pageSize
}&ordering=${reverse ? '-' : ''}${sort}&truncate_content=true` }&ordering=${reverse ? '-' : ''}${sort}&truncate_content=true`
) )
expect(req.request.method).toEqual('GET') expect(req.request.method).toEqual('GET')
@@ -231,7 +236,7 @@ describe('DocumentListViewService', () => {
} }
documentListViewService.loadFromQueryParams(convertToParamMap(params)) documentListViewService.loadFromQueryParams(convertToParamMap(params))
let req = httpTestingController.expectOne( let req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/?page=${documentListViewService.currentPage}&page_size=${documentListViewService.currentPageSize}&ordering=-added&truncate_content=true&tags__id__all=${tags__id__all}` `${environment.apiBaseUrl}documents/?page=${documentListViewService.currentPage}&page_size=${documentListViewService.pageSize}&ordering=-added&truncate_content=true&tags__id__all=${tags__id__all}`
) )
expect(req.request.method).toEqual('GET') expect(req.request.method).toEqual('GET')
expect(documentListViewService.filterRules).toEqual([ expect(documentListViewService.filterRules).toEqual([
@@ -249,7 +254,7 @@ describe('DocumentListViewService', () => {
it('should use filter rules to update query params', () => { it('should use filter rules to update query params', () => {
documentListViewService.filterRules = filterRules documentListViewService.filterRules = filterRules
const req = httpTestingController.expectOne( const req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/?page=${documentListViewService.currentPage}&page_size=${documentListViewService.currentPageSize}&ordering=-created&truncate_content=true&tags__id__all=${tags__id__all}` `${environment.apiBaseUrl}documents/?page=${documentListViewService.currentPage}&page_size=${documentListViewService.pageSize}&ordering=-created&truncate_content=true&tags__id__all=${tags__id__all}`
) )
expect(req.request.method).toEqual('GET') expect(req.request.method).toEqual('GET')
}) })
@@ -257,7 +262,7 @@ describe('DocumentListViewService', () => {
it('should support quick filter', () => { it('should support quick filter', () => {
documentListViewService.quickFilter(filterRules) documentListViewService.quickFilter(filterRules)
const req = httpTestingController.expectOne( const req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/?page=${documentListViewService.currentPage}&page_size=${documentListViewService.currentPageSize}&ordering=-created&truncate_content=true&tags__id__all=${tags__id__all}` `${environment.apiBaseUrl}documents/?page=${documentListViewService.currentPage}&page_size=${documentListViewService.pageSize}&ordering=-created&truncate_content=true&tags__id__all=${tags__id__all}`
) )
expect(req.request.method).toEqual('GET') expect(req.request.method).toEqual('GET')
}) })
@@ -280,7 +285,7 @@ describe('DocumentListViewService', () => {
convertToParamMap(params) convertToParamMap(params)
) )
let req = httpTestingController.expectOne( let req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/?page=${page}&page_size=${documentListViewService.currentPageSize}&ordering=-added&truncate_content=true&tags__id__all=${tags__id__all}` `${environment.apiBaseUrl}documents/?page=${page}&page_size=${documentListViewService.pageSize}&ordering=-added&truncate_content=true&tags__id__all=${tags__id__all}`
) )
expect(req.request.method).toEqual('GET') expect(req.request.method).toEqual('GET')
// reset the list // reset the list
@@ -305,8 +310,7 @@ describe('DocumentListViewService', () => {
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true` `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
) )
expect(documentListViewService.currentPage).toEqual(1) expect(documentListViewService.currentPage).toEqual(1)
documentListViewService.currentPageSize = 3 documentListViewService.pageSize = 3
documentListViewService.reload()
req = httpTestingController.expectOne( req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/?page=1&page_size=3&ordering=-created&truncate_content=true` `${environment.apiBaseUrl}documents/?page=1&page_size=3&ordering=-created&truncate_content=true`
) )
@@ -362,7 +366,10 @@ describe('DocumentListViewService', () => {
.spyOn(documentListViewService, 'documents', 'get') .spyOn(documentListViewService, 'documents', 'get')
.mockReturnValue(documents) .mockReturnValue(documents)
expect(documentListViewService.currentPage).toEqual(1) expect(documentListViewService.currentPage).toEqual(1)
documentListViewService.currentPageSize = 3 documentListViewService.pageSize = 3
httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=3&ordering=-created&truncate_content=true`
)
jest jest
.spyOn(documentListViewService, 'getLastPage') .spyOn(documentListViewService, 'getLastPage')
.mockReturnValue(Math.ceil(documents.length / 3)) .mockReturnValue(Math.ceil(documents.length / 3))
@@ -410,7 +417,13 @@ describe('DocumentListViewService', () => {
.spyOn(documentListViewService, 'documents', 'get') .spyOn(documentListViewService, 'documents', 'get')
.mockReturnValue(documents) .mockReturnValue(documents)
documentListViewService.currentPage = 2 documentListViewService.currentPage = 2
documentListViewService.currentPageSize = 3 httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=2&page_size=50&ordering=-created&truncate_content=true`
)
documentListViewService.pageSize = 3
httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=2&page_size=3&ordering=-created&truncate_content=true`
)
const reloadSpy = jest.spyOn(documentListViewService, 'reload') const reloadSpy = jest.spyOn(documentListViewService, 'reload')
documentListViewService.getPrevious(1).subscribe({ documentListViewService.getPrevious(1).subscribe({
next: () => {}, next: () => {},
@@ -426,8 +439,7 @@ describe('DocumentListViewService', () => {
it('should update page size from settings', () => { it('should update page size from settings', () => {
settingsService.set(SETTINGS_KEYS.DOCUMENT_LIST_SIZE, 10) settingsService.set(SETTINGS_KEYS.DOCUMENT_LIST_SIZE, 10)
documentListViewService.updatePageSize() expect(documentListViewService.pageSize).toEqual(10)
expect(documentListViewService.currentPageSize).toEqual(10)
}) })
it('should support select a document', () => { it('should support select a document', () => {
@@ -459,8 +471,7 @@ describe('DocumentListViewService', () => {
}) })
it('should support select page', () => { it('should support select page', () => {
documentListViewService.currentPageSize = 3 documentListViewService.pageSize = 3
documentListViewService.reload()
const req = httpTestingController.expectOne( const req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/?page=1&page_size=3&ordering=-created&truncate_content=true` `${environment.apiBaseUrl}documents/?page=1&page_size=3&ordering=-created&truncate_content=true`
) )
@@ -544,4 +555,40 @@ describe('DocumentListViewService', () => {
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true` `${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
) )
}) })
it('should update default view state when display mode changes', () => {
const localStorageSpy = jest.spyOn(localStorage, 'setItem')
expect(documentListViewService.displayMode).toEqual(DisplayMode.SMALL_CARDS)
documentListViewService.displayMode = DisplayMode.LARGE_CARDS
expect(documentListViewService.displayMode).toEqual(DisplayMode.LARGE_CARDS)
documentListViewService.displayMode = 'details' as any // legacy
expect(documentListViewService.displayMode).toEqual(DisplayMode.TABLE)
expect(localStorageSpy).toHaveBeenCalledTimes(2)
})
it('should update default view state when display fields change', () => {
const localStorageSpy = jest.spyOn(localStorage, 'setItem')
documentListViewService.displayFields = [
DisplayField.ADDED,
DisplayField.TITLE,
]
expect(documentListViewService.displayFields).toEqual([
DisplayField.ADDED,
DisplayField.TITLE,
])
expect(localStorageSpy).toHaveBeenCalled()
// reload triggered
httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
)
documentListViewService.displayFields = null
httpTestingController.match(
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
)
expect(documentListViewService.displayFields).toEqual(
DEFAULT_DISPLAY_FIELDS.filter((f) => f.id !== DisplayField.ADDED).map(
(f) => f.id
)
)
})
}) })

View File

@@ -7,16 +7,17 @@ import {
cloneFilterRules, cloneFilterRules,
isFullTextFilterRule, isFullTextFilterRule,
} from '../utils/filter-rules' } from '../utils/filter-rules'
import { Document } from '../data/document' import {
DEFAULT_DISPLAY_FIELDS,
DisplayField,
DisplayMode,
Document,
} from '../data/document'
import { SavedView } from '../data/saved-view' import { SavedView } from '../data/saved-view'
import { SETTINGS_KEYS } from '../data/ui-settings' import { SETTINGS_KEYS } from '../data/ui-settings'
import { DOCUMENT_LIST_SERVICE } from '../data/storage-keys' import { DOCUMENT_LIST_SERVICE } from '../data/storage-keys'
import { paramsFromViewState, paramsToViewState } from '../utils/query-params' import { paramsFromViewState, paramsToViewState } from '../utils/query-params'
import { import { DocumentService, SelectionData } from './rest/document.service'
DocumentService,
DOCUMENT_SORT_FIELDS,
SelectionData,
} from './rest/document.service'
import { SettingsService } from './settings.service' import { SettingsService } from './settings.service'
/** /**
@@ -59,6 +60,21 @@ export interface ListViewState {
* Contains the IDs of all selected documents. * Contains the IDs of all selected documents.
*/ */
selected?: Set<number> selected?: Set<number>
/**
* The page size of the list view.
*/
pageSize?: number
/**
* Display mode of the list view.
*/
displayMode?: DisplayMode
/**
* The fields to display in the document list.
*/
displayFields?: DisplayField[]
} }
/** /**
@@ -80,8 +96,6 @@ export class DocumentListViewService {
selectionData?: SelectionData selectionData?: SelectionData
currentPageSize: number = this.settings.get(SETTINGS_KEYS.DOCUMENT_LIST_SIZE)
private unsubscribeNotifier: Subject<any> = new Subject() private unsubscribeNotifier: Subject<any> = new Subject()
private listViewStates: Map<number, ListViewState> = new Map() private listViewStates: Map<number, ListViewState> = new Map()
@@ -113,7 +127,7 @@ export class DocumentListViewService {
delete savedState[k] delete savedState[k]
} }
}) })
//only use restored state attributes instead of defaults if they are not null // only use restored state attributes instead of defaults if they are not null
let newState = Object.assign(this.defaultListViewState(), savedState) let newState = Object.assign(this.defaultListViewState(), savedState)
this.listViewStates.set(null, newState) this.listViewStates.set(null, newState)
} catch (e) { } catch (e) {
@@ -176,6 +190,9 @@ export class DocumentListViewService {
if (this._activeSavedViewId) { if (this._activeSavedViewId) {
this.activeListViewState.title = view.name this.activeListViewState.title = view.name
} }
this.activeListViewState.displayMode = view.display_mode
this.activeListViewState.pageSize = view.page_size
this.activeListViewState.displayFields = view.display_fields
this.reduceSelectionToFilter() this.reduceSelectionToFilter()
@@ -220,7 +237,7 @@ export class DocumentListViewService {
this.documentService this.documentService
.listFiltered( .listFiltered(
activeListViewState.currentPage, activeListViewState.currentPage,
this.currentPageSize, activeListViewState.pageSize ?? this.pageSize,
activeListViewState.sortField, activeListViewState.sortField,
activeListViewState.sortReverse, activeListViewState.sortReverse,
activeListViewState.filterRules, activeListViewState.filterRules,
@@ -281,9 +298,8 @@ export class DocumentListViewService {
errorMessage = Object.keys(error.error) errorMessage = Object.keys(error.error)
.map((fieldName) => { .map((fieldName) => {
const fieldError: Array<string> = error.error[fieldName] const fieldError: Array<string> = error.error[fieldName]
return `${DOCUMENT_SORT_FIELDS.find( return `${this.sortFields.find((f) => f.field == fieldName)
(f) => f.field == fieldName ?.name}: ${fieldError[0]}`
)?.name}: ${fieldError[0]}`
}) })
.join(', ') .join(', ')
} else { } else {
@@ -312,6 +328,14 @@ export class DocumentListViewService {
return this.activeListViewState.filterRules return this.activeListViewState.filterRules
} }
get sortFields(): any[] {
return this.documentService.sortFields
}
get sortFieldsFullText(): any[] {
return this.documentService.sortFieldsFullText
}
set sortField(field: string) { set sortField(field: string) {
this.activeListViewState.sortField = field this.activeListViewState.sortField = field
this.reload() this.reload()
@@ -362,6 +386,51 @@ export class DocumentListViewService {
this.saveDocumentListView() this.saveDocumentListView()
} }
set displayMode(mode: DisplayMode) {
this.activeListViewState.displayMode = mode
this.saveDocumentListView()
}
get displayMode(): DisplayMode {
const mode = this.activeListViewState.displayMode ?? DisplayMode.SMALL_CARDS
if (mode === ('details' as any)) {
// legacy
return DisplayMode.TABLE
}
return mode
}
set pageSize(size: number) {
this.activeListViewState.pageSize = size
this.reload()
this.saveDocumentListView()
}
get pageSize(): number {
return (
this.activeListViewState.pageSize ??
this.settings.get(SETTINGS_KEYS.DOCUMENT_LIST_SIZE)
)
}
get displayFields(): DisplayField[] {
let fields =
this.activeListViewState.displayFields ??
DEFAULT_DISPLAY_FIELDS.map((f) => f.id)
if (!this.activeListViewState.displayFields) {
fields = fields.filter((f) => f !== DisplayField.ADDED)
}
return fields.filter(
(field) =>
this.settings.allDisplayFields.find((f) => f.id === field) !== undefined
)
}
set displayFields(fields: DisplayField[]) {
this.activeListViewState.displayFields = fields
this.saveDocumentListView()
}
private saveDocumentListView() { private saveDocumentListView() {
if (this._activeSavedViewId == null) { if (this._activeSavedViewId == null) {
let savedState: ListViewState = { let savedState: ListViewState = {
@@ -370,6 +439,8 @@ export class DocumentListViewService {
filterRules: this.activeListViewState.filterRules, filterRules: this.activeListViewState.filterRules,
sortField: this.activeListViewState.sortField, sortField: this.activeListViewState.sortField,
sortReverse: this.activeListViewState.sortReverse, sortReverse: this.activeListViewState.sortReverse,
displayMode: this.activeListViewState.displayMode,
displayFields: this.activeListViewState.displayFields,
} }
localStorage.setItem( localStorage.setItem(
DOCUMENT_LIST_SERVICE.CURRENT_VIEW_CONFIG, DOCUMENT_LIST_SERVICE.CURRENT_VIEW_CONFIG,
@@ -385,7 +456,7 @@ export class DocumentListViewService {
} }
getLastPage(): number { getLastPage(): number {
return Math.ceil(this.collectionSize / this.currentPageSize) return Math.ceil(this.collectionSize / this.pageSize)
} }
hasNext(doc: number) { hasNext(doc: number) {
@@ -452,13 +523,6 @@ export class DocumentListViewService {
}) })
} }
updatePageSize() {
let newPageSize = this.settings.get(SETTINGS_KEYS.DOCUMENT_LIST_SIZE)
if (newPageSize != this.currentPageSize) {
this.currentPageSize = newPageSize
}
}
selectNone() { selectNone() {
this.selected.clear() this.selected.clear()
this.rangeSelectionAnchorIndex = this.lastRangeSelectionToIndex = null this.rangeSelectionAnchorIndex = this.lastRangeSelectionToIndex = null

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