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:
# General hooks
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.5.0
rev: v4.6.0
hooks:
- id: check-docstring-first
- id: check-json
@@ -47,11 +47,11 @@ repos:
exclude: "(^Pipfile\\.lock$)"
# Python hooks
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: 'v0.3.5'
rev: 'v0.4.1'
hooks:
- id: ruff
- repo: https://github.com/psf/black-pre-commit-mirror
rev: 24.3.0
rev: 24.4.0
hooks:
- id: black
# Dockerfile hooks

View File

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

View File

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

760
Pipfile.lock generated
View File

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

View File

@@ -80,7 +80,7 @@ django_checks() {
search_index() {
local -r index_version=8
local -r index_version=9
local -r index_version_file=${DATA_DIR}/.index_version
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>/share_links/`: Retrieve share links for a document.
- `/api/documents/<id>/history/`: Retrieve history of changes for a document.
## Authorization

View File

@@ -1,5 +1,25 @@
# Changelog
## paperless-ngx 2.7.2
### Bug Fixes
- Fix: select dropdown background colors not visible in light mode [@shamoon](https://github.com/shamoon) ([#6323](https://github.com/paperless-ngx/paperless-ngx/pull/6323))
- Fix: spacing in reset and incorrect display in saved views [@shamoon](https://github.com/shamoon) ([#6324](https://github.com/paperless-ngx/paperless-ngx/pull/6324))
- Fix: disable invalid create endpoints [@shamoon](https://github.com/shamoon) ([#6320](https://github.com/paperless-ngx/paperless-ngx/pull/6320))
- Fix: dont initialize page numbers, allow split with browser pdf viewer [@shamoon](https://github.com/shamoon) ([#6314](https://github.com/paperless-ngx/paperless-ngx/pull/6314))
### All App Changes
<details>
<summary>4 changes</summary>
- Fix: select dropdown background colors not visible in light mode [@shamoon](https://github.com/shamoon) ([#6323](https://github.com/paperless-ngx/paperless-ngx/pull/6323))
- Fix: spacing in reset and incorrect display in saved views [@shamoon](https://github.com/shamoon) ([#6324](https://github.com/paperless-ngx/paperless-ngx/pull/6324))
- Fix: disable invalid create endpoints [@shamoon](https://github.com/shamoon) ([#6320](https://github.com/paperless-ngx/paperless-ngx/pull/6320))
- Fix: dont initialize page numbers, allow split with browser pdf viewer [@shamoon](https://github.com/shamoon) ([#6314](https://github.com/paperless-ngx/paperless-ngx/pull/6314))
</details>
## paperless-ngx 2.7.1
### Bug Fixes

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.
#### 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}
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 |
| ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 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. |
| Correspondent | Grants global permissions to add, edit, delete or view Correspondents. |
| 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.
## 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}
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
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

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": {
"size": -1,
"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,
"bodySize": -1,

View File

@@ -124,7 +124,7 @@
"content": {
"size": -1,
"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,
"bodySize": -1,

View File

@@ -124,7 +124,7 @@
"content": {
"size": -1,
"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,
"bodySize": -1,

View File

@@ -124,7 +124,7 @@
"content": {
"size": -1,
"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,
"bodySize": -1,

View File

@@ -81,14 +81,15 @@ test('text filtering', async ({ page }) => {
test('date filtering', async ({ page }) => {
await page.routeFromHAR(REQUESTS_HAR3, { notFound: 'fallback' })
await page.goto('/documents')
await page.getByRole('button', { name: 'Created' }).click()
await page.getByRole('menuitem', { name: 'Last 3 months' }).click()
await page.getByRole('button', { name: 'Dates' }).click()
await page.getByRole('menuitem', { name: 'Last 3 months' }).first().click()
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: 'Created' }).click()
await page.getByRole('button', { name: 'Dates Clear selected' }).click()
await page.getByRole('button', { name: 'Dates' }).click()
await page
.getByRole('menuitem', { name: 'After mm/dd/yyyy' })
.getByRole('button')
.first()
.click()
await page.getByRole('combobox', { name: 'Select month' }).selectOption('12')
await page.getByRole('combobox', { name: 'Select year' }).selectOption('2022')
@@ -138,11 +139,11 @@ test('sorting', async ({ page }) => {
test('change views', async ({ page }) => {
await page.routeFromHAR(REQUESTS_HAR5, { notFound: 'fallback' })
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 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 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()
})

File diff suppressed because it is too large Load Diff

View File

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

View File

@@ -141,10 +141,7 @@ export const routes: Routes = [
component: LogsComponent,
canActivate: [PermissionsGuard],
data: {
requiredPermission: {
action: PermissionAction.View,
type: PermissionType.Admin,
},
requireAdmin: true,
},
},
// 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 { FilterableDropdownComponent } from './components/common/filterable-dropdown/filterable-dropdown.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 { DocumentCardSmallComponent } from './components/document-list/document-card-small/document-card-small.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 { MergeConfirmDialogComponent } from './components/common/confirm-dialog/merge-confirm-dialog/merge-confirm-dialog.component'
import { SplitConfirmDialogComponent } from './components/common/confirm-dialog/split-confirm-dialog/split-confirm-dialog.component'
import { DocumentHistoryComponent } from './components/document-history/document-history.component'
import { DragDropSelectComponent } from './components/common/input/drag-drop-select/drag-drop-select.component'
import { CustomFieldDisplayComponent } from './components/common/custom-field-display/custom-field-display.component'
import {
airplane,
archive,
@@ -137,7 +140,9 @@ import {
boxes,
calendar,
calendarEvent,
calendarEventFill,
cardChecklist,
cardHeading,
caretDown,
caretUp,
chatLeftText,
@@ -231,7 +236,9 @@ const icons = {
boxes,
calendar,
calendarEvent,
calendarEventFill,
cardChecklist,
cardHeading,
caretDown,
caretUp,
chatLeftText,
@@ -402,7 +409,7 @@ function initializeApp(settings: SettingsService) {
FilterEditorComponent,
FilterableDropdownComponent,
ToggleableDropdownButtonComponent,
DateDropdownComponent,
DatesDropdownComponent,
DocumentCardLargeComponent,
DocumentCardSmallComponent,
BulkEditorComponent,
@@ -472,6 +479,9 @@ function initializeApp(settings: SettingsService) {
RotateConfirmDialogComponent,
MergeConfirmDialogComponent,
SplitConfirmDialogComponent,
DocumentHistoryComponent,
DragDropSelectComponent,
CustomFieldDisplayComponent,
],
imports: [
BrowserModule,

View File

@@ -7,29 +7,30 @@
<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>
</button>
<button class="btn btn-sm btn-outline-primary position-relative ms-md-5 me-1" (click)="showSystemStatus()"
[disabled]="!systemStatus"
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Admin }">
@if (!systemStatus) {
<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>
@if (permissionsService.isAdmin()) {
<button class="btn btn-sm btn-outline-primary position-relative ms-md-5 me-1" (click)="showSystemStatus()"
[disabled]="!systemStatus">
@if (!systemStatus) {
<div class="spinner-border spinner-border-sm me-1 h-75" role="status"></div>
} @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>
<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 {
<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>
</button>
<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>
&nbsp;<i-bs name="arrow-up-right"></i-bs>
</a>
<ng-container i18n>System Status</ng-container>
</button>
<a class="btn btn-sm btn-primary" href="admin/" target="_blank">
<ng-container i18n>Open Django Admin</ng-container>
&nbsp;<i-bs name="arrow-up-right"></i-bs>
</a>
}
</pngx-page-header>
<form [formGroup]="settingsForm" (ngSubmit)="saveSettings()">
@@ -319,52 +320,71 @@
</div>
<h4 i18n>Views</h4>
<div formGroupName="savedViews">
<ul class="list-group" formGroupName="savedViews">
@for (view of savedViews; track view) {
<li class="list-group-item py-3">
<div [formGroupName]="view.id" class="row">
<div class="mb-3 col">
<label class="form-label" for="name_{{view.id}}" i18n>Name</label>
<input type="text" class="form-control" formControlName="name" id="name_{{view.id}}">
</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 class="row">
<div class="col">
<pngx-input-text title="Name" formControlName="name"></pngx-input-text>
</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 class="col">
<div class="form-check form-switch mt-3">
<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 class="mb-2 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 class="row">
<div class="col">
<pngx-input-number i18n-title title="Documents page size" [showAdd]="false" formControlName="page_size"></pngx-input-number>
</div>
<div class="col">
<label class="form-label" for="display_mode_{{view.id}}" i18n>Display as</label>
<select class="form-select" formControlName="display_mode">
<option [ngValue]="DisplayMode.TABLE" i18n>Table</option>
<option [ngValue]="DisplayMode.SMALL_CARDS" i18n>Small Cards</option>
<option [ngValue]="DisplayMode.LARGE_CARDS" i18n>Large Cards</option>
</select>
</div>
@if (displayFields) {
<pngx-input-drag-drop-select i18n-title title="Show" i18n-emptyText emptyText="Default" [items]="displayFields" formControlName="display_fields"></pngx-input-drag-drop-select>
}
</div>
</div>
</li>
}
@if (savedViews && savedViews.length === 0) {
<div i18n>No saved views defined.</div>
<li class="list-group-item">
<div i18n>No saved views defined.</div>
</li>
}
@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="visually-hidden" i18n>Loading...</div>
</div>
</li>
}
</div>
</ul>
</ng-template>
</li>
@@ -373,4 +393,5 @@
<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="button" (click)="reset()" class="btn btn-secondary ms-2 mb-2" [disabled]="(isDirty$ | async) === false" i18n>Cancel</button>
</form>

View File

@@ -48,6 +48,8 @@ import {
InstallType,
SystemStatusItemStatus,
} 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 = [
{ id: 1, name: 'view1', show_in_sidebar: true, show_on_dashboard: true },
@@ -96,6 +98,7 @@ describe('SettingsComponent', () => {
PermissionsGroupComponent,
IfOwnerDirective,
ConfirmButtonComponent,
DragDropSelectComponent,
],
providers: [CustomDatePipe, DatePipe, PermissionsGuard],
imports: [
@@ -108,6 +111,7 @@ describe('SettingsComponent', () => {
NgSelectModule,
NgxBootstrapIconsModule.pick(allIcons),
NgbModalModule,
DragDropModule,
],
}).compileComponents()
@@ -418,6 +422,7 @@ describe('SettingsComponent', () => {
},
}
jest.spyOn(systemStatusService, 'get').mockReturnValue(of(status))
jest.spyOn(permissionsService, 'isAdmin').mockReturnValue(true)
completeSetup()
expect(component['systemStatus']).toEqual(status) // private
expect(component.systemStatusHasErrors).toBeTruthy()
@@ -436,4 +441,11 @@ describe('SettingsComponent', () => {
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,
SystemStatus,
} from 'src/app/data/system-status'
import { DisplayMode } from 'src/app/data/document'
enum SettingsNavIDs {
General = 1,
@@ -73,8 +74,8 @@ export class SettingsComponent
extends ComponentWithPermissions
implements OnInit, AfterViewInit, OnDestroy, DirtyComponent
{
SettingsNavIDs = SettingsNavIDs
activeNavID: number
DisplayMode = DisplayMode
savedViewGroup = new FormGroup({})
@@ -110,6 +111,10 @@ export class SettingsComponent
})
savedViews: SavedView[]
SettingsNavIDs = SettingsNavIDs
get displayFields() {
return this.settings.allDisplayFields
}
store: BehaviorSubject<any>
storeSub: Subscription
@@ -121,7 +126,7 @@ export class SettingsComponent
users: User[]
groups: Group[]
private systemStatus: SystemStatus
public systemStatus: SystemStatus
get systemStatusHasErrors(): boolean {
return (
@@ -340,6 +345,9 @@ export class SettingsComponent
name: view.name,
show_on_dashboard: view.show_on_dashboard,
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(
view.id.toString(),
@@ -348,6 +356,9 @@ export class SettingsComponent
name: new FormControl(null),
show_on_dashboard: 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)
}
if (
this.permissionsService.currentUserCan(
PermissionAction.View,
PermissionType.Admin
)
) {
if (this.permissionsService.isAdmin()) {
this.systemStatusService.get().subscribe((status) => {
this.systemStatus = status
})
@@ -535,8 +541,8 @@ export class SettingsComponent
.subscribe({
next: () => {
this.store.next(this.settingsForm.value)
this.documentListViewService.updatePageSize()
this.settings.updateAppearanceSettings()
this.settings.initializeDisplayFields()
let savedToast: Toast = {
content: $localize`Settings were saved successfully.`,
delay: 5000,
@@ -597,6 +603,10 @@ export class SettingsComponent
}
}
reset() {
this.settingsForm.patchValue(this.store.getValue())
}
clearThemeColor() {
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>
</button>
</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">
<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" i18n>Actions</div>
</div>
</li>
@for (group of groups; track group) {
<li class="list-group-item">
<div class="row">
<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 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 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>
</li>
}
@if (groups.length === 0) {
<li class="list-group-item" i18n>No groups defined</li>
}
</ul>
}
</div>
</li>
}
@if (groups.length === 0) {
<li class="list-group-item" i18n>No groups defined</li>
}
</ul>
}
@if (!users || !groups) {

View File

@@ -111,7 +111,7 @@
</h6>
}
<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"
cdkDragPreviewContainer="parent" cdkDragPreviewClass="navItemDrag" (cdkDragStarted)="onDragStart($event)"
(cdkDragEnded)="onDragEnd($event)">
@@ -267,13 +267,15 @@
}
</a>
</li>
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Admin }">
<a class="nav-link" routerLink="logs" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Logs"
i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" container="body"
triggers="mouseenter:mouseleave" popoverClass="popover-slim">
<i-bs class="me-1" name="text-left"></i-bs><span>&nbsp;<ng-container i18n>Logs</ng-container></span>
</a>
</li>
@if (permissionsService.isAdmin()) {
<li class="nav-item app-link">
<a class="nav-link" routerLink="logs" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Logs"
i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" container="body"
triggers="mouseenter:mouseleave" popoverClass="popover-slim">
<i-bs class="me-1" name="text-left"></i-bs><span>&nbsp;<ng-container i18n>Logs</ng-container></span>
</a>
</li>
}
<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"
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: 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()
expect(component.documents).toEqual(documents)
expect(documentService.getCachedMany).toHaveBeenCalledWith(
component.documentIDs
)
expect(documentService.getFew).toHaveBeenCalledWith(component.documentIDs)
})
it('should move documentIDs on drop', () => {
@@ -64,7 +68,13 @@ describe('MergeConfirmDialogComponent', () => {
{ id: 2, name: 'Document 2' },
{ 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()

View File

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

View File

@@ -12,7 +12,7 @@
</div>
<div class="col-8 d-flex align-items-center">
@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 class="col-2 d-flex">

View File

@@ -6,7 +6,7 @@
<div class="modal-body">
<p>{{message}}</p>
<div class="row mb-2">
<div class="col-6">
<div class="col-8">
<div class="input-group input-group-sm">
<div class="input-group-text" i18n>Page</div>
<input class="form-control" type="number" min="1" [(ngModel)]="page" />
@@ -21,9 +21,9 @@
</pngx-pdf-viewer>
</div>
</div>
<div class="col-6">
<div class="col-4">
<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;
<span i18n>Add Split</span>
</button>
@@ -31,11 +31,11 @@
<ul class="list-group mt-3">
@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}}
@if (pagesString.split(',').length > 1) {
&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>
</button>
}

View File

@@ -1,6 +1,6 @@
.pdf-viewer-container {
background-color: gray;
height: 300px;
height: 350px;
pngx-pdf-viewer {
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">
<ul class="list-group list-group-flush">
<li class="list-group-item">
<pngx-input-select class="mb-3"
<pngx-input-select
[items]="unusedFields"
bindLabel="name"
[(ngModel)]="field"
@@ -14,14 +14,15 @@
[notFoundText]="notFoundText"
[disableCreateNew]="!canCreateFields"
(createNew)="createField($event)"
[hideAddButton]="true"
bindValue="id">
</pngx-input-select>
<div class="btn-toolbar" role="toolbar">
<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>
</button>
<button class="btn btn-sm btn-outline-primary me-1" 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>
<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 to document</ng-container>
</button>
</div>
</li>

View File

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

View File

@@ -1,10 +1,7 @@
import { ComponentFixture, TestBed } from '@angular/core/testing'
import { CustomFieldsDropdownComponent } from './custom-fields-dropdown.component'
import {
HttpClientTestingModule,
HttpTestingController,
} from '@angular/common/http/testing'
import { HttpClientTestingModule } from '@angular/common/http/testing'
import { ToastService } from 'src/app/services/toast.service'
import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
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 {
white-space: nowrap;
@media(min-width: 768px) {
--bs-dropdown-min-width: 40rem;
}
.btn-link {
line-height: 1;
}

View File

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

View File

@@ -56,6 +56,7 @@ export class UserEditDialogComponent
first_name: new FormControl(''),
last_name: new FormControl(''),
is_active: new FormControl(true),
is_staff: new FormControl(true),
is_superuser: new FormControl(false),
groups: 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 class="position-relative" [class.col-md-9]="horizontal">
<div class="input-group" [class.is-invalid]="error">
<span class="input-group-text fw-bold bg-light">{{monetaryValue | currency: currencyCode }}</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 #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">
<span class="input-group-text fw-bold bg-light">{{ monetaryValue | currency: currency }}</span>
<input #currencyField class="form-control text-muted mw-60" [(ngModel)]="currency" (input)="currencyChange()" maxlength="3" [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 class="invalid-feedback position-absolute top-100">
{{error}}

View File

@@ -11,7 +11,6 @@ import { MonetaryComponent } from './monetary.component'
describe('MonetaryComponent', () => {
let component: MonetaryComponent
let fixture: ComponentFixture<MonetaryComponent>
let input: HTMLInputElement
beforeEach(async () => {
await TestBed.configureTestingModule({
@@ -24,37 +23,22 @@ describe('MonetaryComponent', () => {
fixture.debugElement.injector.get(NG_VALUE_ACCESSOR)
component = fixture.componentInstance
fixture.detectChanges()
input = component.inputField.nativeElement
})
it('should set the currency code correctly', () => {
expect(component.currencyCode).toEqual('USD') // default
component.currencyCode = 'EUR'
expect(component.currencyCode).toEqual('EUR')
it('should set the currency code and monetary value correctly', () => {
expect(component.currency).toEqual('USD') // default
component.writeValue('G123.4')
expect(component.currency).toEqual('G')
component.value = 'G123.4'
jest
.spyOn(document, 'activeElement', 'get')
.mockReturnValue(component.currencyField.nativeElement)
expect(component.currencyCode).toEqual('G')
component.writeValue('EUR123.4')
expect(component.currency).toEqual('EUR')
expect(component.monetaryValue).toEqual('123.40')
})
it('should parse monetary value only when out of focus', () => {
component.monetaryValue = 10.5
jest.spyOn(document, 'activeElement', 'get').mockReturnValue(null)
it('should set monetary value to fixed decimals', () => {
component.monetaryValue = '10.5'
component.monetaryValueChange(true)
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', () => {
@@ -62,4 +46,32 @@ describe('MonetaryComponent', () => {
component = new MonetaryComponent('pt-BR')
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 {
Component,
ElementRef,
forwardRef,
Inject,
LOCALE_ID,
ViewChild,
} from '@angular/core'
import { Component, forwardRef, Inject, LOCALE_ID } from '@angular/core'
import { NG_VALUE_ACCESSOR } from '@angular/forms'
import { AbstractInputComponent } from '../abstract-input'
import { getLocaleCurrencyCode } from '@angular/common'
@@ -23,39 +16,50 @@ import { getLocaleCurrencyCode } from '@angular/common'
styleUrls: ['./monetary.component.scss'],
})
export class MonetaryComponent extends AbstractInputComponent<string> {
@ViewChild('currencyField')
currencyField: ElementRef
public currency: string = ''
public monetaryValue: string = ''
defaultCurrencyCode: string
constructor(@Inject(LOCALE_ID) currentLocale: string) {
super()
this.defaultCurrencyCode = getLocaleCurrencyCode(currentLocale)
this.currency = this.defaultCurrencyCode =
getLocaleCurrencyCode(currentLocale)
}
get currencyCode(): string {
const focused = document.activeElement === this.currencyField?.nativeElement
if (focused && this.value) return this.value.match(/^([A-Z]{0,3})/)?.[0]
writeValue(newValue: any): void {
this.currency = this.parseCurrencyCode(newValue)
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 (
this.value
value
?.toString()
.toUpperCase()
.match(/^([A-Z]{1,3})/)?.[0] ?? this.defaultCurrencyCode
)
}
set currencyCode(value: string) {
this.value = value + this.monetaryValue?.toString()
}
get monetaryValue(): string {
if (!this.value) return null
const focused = document.activeElement === this.inputField?.nativeElement
const val = parseFloat(this.value.toString().replace(/[^0-9.,]+/g, ''))
return focused ? val.toString() : val.toFixed(2)
}
set monetaryValue(value: number) {
this.value = this.currencyCode + value.toFixed(2)
private parseMonetaryValue(value: string, fixed: boolean = false): string {
if (!value) {
return ''
}
const val: number = parseFloat(value.toString().replace(/[^0-9.,-]+/g, ''))
return fixed ? val.toFixed(2) : val.toString()
}
}

View File

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

View File

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

View File

@@ -9,17 +9,17 @@
<div class="col" i18n>Delete</div>
<div class="col" i18n>View</div>
</li>
@for (type of PermissionType | keyvalue; track type) {
<li class="list-group-item d-flex" [formGroupName]="type.key">
<div class="col-3">{{type.key}}:</div>
<div class="col form-check form-check-inline form-switch" [ngbPopover]="inheritedWarning" [disablePopover]="!isInherited(type.key)" 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">
<label class="form-check-label visually-hidden" for="{{type.key}}_all" i18n>All</label>
@for (type of allowedTypes; track type) {
<li class="list-group-item d-flex" [formGroupName]="type">
<div class="col-3">{{type}}:</div>
<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}}_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}}_all" i18n>All</label>
</div>
@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">
<input type="checkbox" class="form-check-input" id="{{type.key}}_{{action.key}}" formControlName="{{action.key}}">
<label class="form-check-label visually-hidden" for="{{type.key}}_{{action.key}}" i18n>{{action.key}}</label>
<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}}_{{action.key}}" formControlName="{{action.key}}">
<label class="form-check-label visually-hidden" for="{{type}}_{{action.key}}" i18n>{{action.key}}</label>
</div>
}
</li>

View File

@@ -12,6 +12,9 @@ import {
} from 'src/app/services/permissions.service'
import { By } from '@angular/platform-browser'
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 = [
'add_document',
@@ -28,6 +31,7 @@ describe('PermissionsSelectComponent', () => {
let component: PermissionsSelectComponent
let fixture: ComponentFixture<PermissionsSelectComponent>
let permissionsChangeResult: Permissions
let settingsService: SettingsService
beforeEach(async () => {
TestBed.configureTestingModule({
@@ -38,9 +42,11 @@ describe('PermissionsSelectComponent', () => {
ReactiveFormsModule,
NgbModule,
NgxBootstrapIconsModule.pick(allIcons),
HttpClientTestingModule,
],
}).compileComponents()
settingsService = TestBed.inject(SettingsService)
fixture = TestBed.createComponent(PermissionsSelectComponent)
fixture.debugElement.injector.get(NG_VALUE_ACCESSOR)
component = fixture.componentInstance
@@ -99,4 +105,11 @@ describe('PermissionsSelectComponent', () => {
const input2 = fixture.debugElement.query(By.css('input#Tag_Change'))
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,
} from 'src/app/services/permissions.service'
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({
providers: [
@@ -60,15 +62,23 @@ export class PermissionsSelectComponent
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()
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({})
for (const action in PermissionAction) {
control.addControl(action, new FormControl(null))
}
this.form.addControl(type, control)
}
})
}
writeValue(permissions: string[]): void {
@@ -92,7 +102,7 @@ export class PermissionsSelectComponent
}
}
})
Object.keys(PermissionType).forEach((type) => {
this.allowedTypes.forEach((type) => {
if (
Object.values(this.form.get(type).value).every((val) => val == true)
) {
@@ -191,7 +201,7 @@ export class PermissionsSelectComponent
}
updateDisabledStates() {
for (const type in PermissionType) {
this.allowedTypes.forEach((type) => {
const control = this.form.get(type)
let actionControl: AbstractControl
for (const action in PermissionAction) {
@@ -200,6 +210,6 @@ export class PermissionsSelectComponent
? actionControl.disable()
: actionControl.enable()
}
}
})
}
}

View File

@@ -23,7 +23,7 @@
}
<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">
<pngx-saved-view-widget
[savedView]="v"

View File

@@ -9,58 +9,114 @@
<a class="btn-link text-decoration-none" header-buttons [routerLink]="[]" (click)="showAll()" i18n>Show all</a>
}
@if (documents.length) {
<table content class="table table-hover mb-0 align-middle">
@if (documents.length && displayMode === DisplayMode.TABLE) {
<table content class="table table-hover mb-0 mt-n2 align-middle">
<thead>
<tr>
<th scope="col" i18n>Created</th>
<th scope="col" i18n>Title</th>
@if (permissionsService.currentUserCan(PermissionAction.View, PermissionType.Tag)) {
<th scope="col" class="d-none d-md-table-cell" i18n>Tags</th>
}
@if (permissionsService.currentUserCan(PermissionAction.View, PermissionType.Correspondent)) {
<th scope="col" class="d-none d-md-table-cell" i18n>Correspondent</th>
} @else {
<th scope="col" class="d-none d-md-table-cell"></th>
@for (field of displayFields; track field; let i = $index) {
@if (displayFields.includes(field)) {
<th
scope="col"
[ngClass]="{
'd-none d-md-table-cell': i > 1,
'w-25': field === DisplayField.CREATED || field === DisplayField.ADDED
}">
{{ getColumnTitle(field) }}
</th>
}
}
</tr>
</thead>
<tbody>
@for (doc of documents; track doc) {
<tr (mouseleave)="maybeClosePopover()">
<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>
<td class="py-2 py-md-3">
<a routerLink="/documents/{{doc.id}}" title="Edit" i18n-title class="btn-link text-dark text-decoration-none py-2 py-md-3">{{doc.title | documentTitle}}</a>
</td>
@if (permissionsService.currentUserCan(PermissionAction.View, PermissionType.Tag)) {
<td class="py-2 py-md-3 d-none d-md-table-cell">
@for (t of doc.tags$ | async; track t) {
<pngx-tag [tag]="t" class="ms-1" (click)="clickTag(t, $event)"></pngx-tag>
@for (doc of documents; track doc.id) {
<tr>
@for (field of displayFields; track field; let i = $index) {
<td class="py-2 py-md-3 position-relative" [ngClass]="{ 'd-none d-md-table-cell': i > 1 }">
@switch (field) {
@case (DisplayField.ADDED) {
<a routerLink="/documents/{{doc.id}}" class="btn-link text-dark text-decoration-none py-2 py-md-3">{{doc.added | customDate}}</a>
}
@case (DisplayField.CREATED) {
<a routerLink="/documents/{{doc.id}}" class="btn-link text-dark text-decoration-none py-2 py-md-3">{{doc.created_date | customDate}}</a>
}
@case (DisplayField.TITLE) {
<a routerLink="/documents/{{doc.id}}" title="Edit" i18n-title class="btn-link text-dark text-decoration-none py-2 py-md-3">{{doc.title | documentTitle}}</a>
}
@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 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>
}
</tbody>
</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 {
<p i18n class="text-center text-muted mb-0 fst-italic">No documents</p>
}

View File

@@ -3,10 +3,9 @@ table {
table-layout: fixed;
}
th:first-child {
width: 25%;
@media (min-width: 768px) {
width: 15%;
@media (min-width: 768px) {
th.w-25 {
width: 15% !important;
}
}
@@ -30,3 +29,45 @@ td.py-3 {
padding-top: 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 { of, Subject } from 'rxjs'
import { routes } from 'src/app/app-routing.module'
import { FILTER_HAS_TAGS_ALL } from 'src/app/data/filter-rule-type'
import {
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 { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
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 { PreviewPopupComponent } from 'src/app/components/common/preview-popup/preview-popup.component'
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 = {
id: 1,
@@ -45,17 +55,53 @@ const savedView: SavedView = {
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 = [
{
id: 2,
title: 'doc2',
custom_fields: [
{ id: 1, field: 11, created: new Date(), value: 'custom', document: 2 },
],
},
{
id: 3,
title: 'doc3',
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,
SafeUrlPipe,
PreviewPopupComponent,
CustomFieldDisplayComponent,
],
providers: [
PermissionsGuard,
@@ -89,6 +136,33 @@ describe('SavedViewWidgetComponent', () => {
},
CustomDatePipe,
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: [
HttpClientTestingModule,
@@ -170,7 +244,7 @@ describe('SavedViewWidgetComponent', () => {
component.ngOnInit()
expect(listAllSpy).toHaveBeenCalledWith(
1,
10,
20,
savedView.sort_field,
savedView.sort_reverse,
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', () => {
const qfSpy = jest.spyOn(documentListViewService, 'quickFilter')
component.clickTag({ id: 11, name: 'Tag11' }, new MouseEvent('click'))
component.clickTag(11, new MouseEvent('click'))
expect(qfSpy).toHaveBeenCalledWith([
{ 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,
ViewChildren,
} from '@angular/core'
import { Params, Router } from '@angular/router'
import { Router } from '@angular/router'
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 { ConsumerStatusService } from 'src/app/services/consumer-status.service'
import { DocumentService } from 'src/app/services/rest/document.service'
import { Tag } from 'src/app/data/tag'
import {
FILTER_CORRESPONDENT,
FILTER_DOCUMENT_TYPE,
FILTER_FULLTEXT_MORELIKE,
FILTER_HAS_TAGS_ALL,
FILTER_STORAGE_PATH,
} from 'src/app/data/filter-rule-type'
import { OpenDocumentsService } from 'src/app/services/open-documents.service'
import { DocumentListViewService } from 'src/app/services/document-list-view.service'
import { ComponentWithPermissions } from 'src/app/components/with-permissions/with-permissions.component'
import { NgbPopover } from '@ng-bootstrap/ng-bootstrap'
import { queryParamsFromFilterRules } from 'src/app/utils/query-params'
import { PermissionsService } from 'src/app/services/permissions.service'
import {
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({
selector: 'pngx-saved-view-widget',
@@ -33,8 +48,14 @@ export class SavedViewWidgetComponent
extends ComponentWithPermissions
implements OnInit, OnDestroy
{
public DisplayMode = DisplayMode
public DisplayField = DisplayField
public CustomFieldDataType = CustomFieldDataType
loading: boolean = true
private customFields: CustomField[] = []
constructor(
private documentService: DocumentService,
private router: Router,
@@ -42,7 +63,9 @@ export class SavedViewWidgetComponent
private consumerStatusService: ConsumerStatusService,
public openDocumentsService: OpenDocumentsService,
public documentListViewService: DocumentListViewService,
public permissionsService: PermissionsService
public permissionsService: PermissionsService,
private settingsService: SettingsService,
private customFieldService: CustomFieldsService
) {
super()
}
@@ -60,14 +83,44 @@ export class SavedViewWidgetComponent
mouseOnPreview = false
popoverHidden = true
displayMode: DisplayMode
displayFields: DisplayField[] = DEFAULT_DASHBOARD_DISPLAY_FIELDS
ngOnInit(): void {
this.reload()
this.displayMode = this.savedView.display_mode ?? DisplayMode.TABLE
this.consumerStatusService
.onDocumentConsumptionFinished()
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe(() => {
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 {
@@ -80,7 +133,7 @@ export class SavedViewWidgetComponent
this.documentService
.listFiltered(
1,
10,
this.savedView.page_size ?? DEFAULT_DASHBOARD_VIEW_PAGE_SIZE,
this.savedView.sort_field,
this.savedView.sort_reverse,
this.savedView.filter_rules,
@@ -103,15 +156,52 @@ export class SavedViewWidgetComponent
}
}
clickTag(tag: Tag, event: MouseEvent) {
event.preventDefault()
event.stopImmediatePropagation()
clickTag(tagID: number, event: MouseEvent = null) {
event?.preventDefault()
event?.stopImmediatePropagation()
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 {
return this.documentService.getPreviewUrl(document.id)
}
@@ -161,14 +251,11 @@ export class SavedViewWidgetComponent
}, 300)
}
getCorrespondentQueryParams(correspondentId: number): Params {
return correspondentId !== undefined
? queryParamsFromFilterRules([
{
rule_type: FILTER_CORRESPONDENT,
value: correspondentId.toString(),
},
])
: null
public getColumnTitle(field: DisplayField): string {
if (field.startsWith(DisplayField.CUSTOM_FIELD)) {
const id = field.split('_')[2]
return this.customFields.find((f) => f.id === parseInt(id))?.name
}
return DEFAULT_DISPLAY_FIELDS.find((f) => f.id === field)?.name
}
}

View File

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

View File

@@ -115,12 +115,9 @@ export class UploadFileWidgetComponent extends ComponentWithPermissions {
}
dismissCompleted() {
this.alerts.forEach((a) => a.close())
if (this.alertsExpanded) {
this.getStatusCompleted().forEach((status) =>
this.consumerStatusService.dismiss(status)
)
}
this.getStatusCompleted().forEach((status) =>
this.consumerStatusService.dismiss(status)
)
}
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="visually-hidden" i18n>Loading...</div>
}
<ng-content select ="[header-buttons]"></ng-content>
<ng-content select="[header-buttons]"></ng-content>
</div>
</div>
<div class="card-body text-dark">
<ng-content select ="[content]"></ng-content>
<ng-content select="[content]"></ng-content>
</div>
</div>

View File

@@ -57,7 +57,7 @@
<i-bs width="1em" height="1em" name="scissors"></i-bs>&nbsp;<span i18n>Split</span>
</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>
</button>
</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)"
(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>
@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]">
@switch (getCustomFieldFromInstance(fieldInstance)?.data_type) {
@case (CustomFieldDataType.String) {
@@ -285,6 +285,17 @@
</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) {
<li [ngbNavItem]="DocumentDetailNavIDs.Permissions">
<a ngbNavLink i18n>Permissions</a>

View File

@@ -17,7 +17,7 @@
--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
}

View File

@@ -77,6 +77,7 @@ enum DocumentDetailNavIDs {
Preview = 4,
Notes = 5,
Permissions = 6,
History = 7,
}
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[]) {
this.document.notes = notes
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)">
</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 class="d-flex align-items-center gap-2 ms-auto">
<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 { RotateConfirmDialogComponent } from '../../common/confirm-dialog/rotate-confirm-dialog/rotate-confirm-dialog.component'
import { MergeConfirmDialogComponent } from '../../common/confirm-dialog/merge-confirm-dialog/merge-confirm-dialog.component'
import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
import { CustomField, CustomFieldDataType } from 'src/app/data/custom-field'
import { CustomFieldEditDialogComponent } from '../../common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component'
const selectionData: SelectionData = {
selected_tags: [
@@ -68,6 +71,10 @@ const selectionData: SelectionData = {
{ id: 66, document_count: 3 },
{ id: 55, document_count: 0 },
],
selected_custom_fields: [
{ id: 77, document_count: 3 },
{ id: 88, document_count: 0 },
],
}
describe('BulkEditorComponent', () => {
@@ -82,6 +89,7 @@ describe('BulkEditorComponent', () => {
let correspondentsService: CorrespondentService
let documentTypeService: DocumentTypeService
let storagePathService: StoragePathService
let customFieldsService: CustomFieldsService
let httpTestingController: HttpTestingController
beforeEach(async () => {
@@ -148,6 +156,18 @@ describe('BulkEditorComponent', () => {
}),
},
},
{
provide: CustomFieldsService,
useValue: {
listAll: () =>
of({
results: [
{ id: 77, name: 'customfield1' },
{ id: 88, name: 'customfield2' },
],
}),
},
},
FilterPipe,
SettingsService,
{
@@ -189,6 +209,7 @@ describe('BulkEditorComponent', () => {
correspondentsService = TestBed.inject(CorrespondentService)
documentTypeService = TestBed.inject(DocumentTypeService)
storagePathService = TestBed.inject(StoragePathService)
customFieldsService = TestBed.inject(CustomFieldsService)
httpTestingController = TestBed.inject(HttpTestingController)
fixture = TestBed.createComponent(BulkEditorComponent)
@@ -262,6 +283,22 @@ describe('BulkEditorComponent', () => {
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', () => {
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
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', () => {
component.setTags({
itemsToAdd: [],
@@ -696,6 +849,10 @@ describe('BulkEditorComponent', () => {
itemsToAdd: [],
itemsToRemove: [],
})
component.setCustomFields({
itemsToAdd: [],
itemsToRemove: [],
})
httpTestingController.expectNone(
`${environment.apiBaseUrl}documents/bulk_edit/`
)
@@ -866,9 +1023,13 @@ describe('BulkEditorComponent', () => {
jest
.spyOn(documentListViewService, 'documents', 'get')
.mockReturnValue([{ id: 3 }, { id: 4 }])
jest
.spyOn(documentService, 'getCachedMany')
.mockReturnValue(of([{ id: 3 }, { id: 4 }]))
jest.spyOn(documentService, 'getFew').mockReturnValue(
of({
all: [3, 4],
count: 2,
results: [{ id: 3 }, { id: 4 }],
})
)
jest
.spyOn(documentListViewService, 'selected', 'get')
.mockReturnValue(new Set([3, 4]))
@@ -1175,4 +1336,56 @@ describe('BulkEditorComponent', () => {
)
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 { RotateConfirmDialogComponent } from '../../common/confirm-dialog/rotate-confirm-dialog/rotate-confirm-dialog.component'
import { MergeConfirmDialogComponent } from '../../common/confirm-dialog/merge-confirm-dialog/merge-confirm-dialog.component'
import { CustomField } from 'src/app/data/custom-field'
import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
import { CustomFieldEditDialogComponent } from '../../common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component'
@Component({
selector: 'pngx-bulk-editor',
@@ -55,15 +58,18 @@ export class BulkEditorComponent
correspondents: Correspondent[]
documentTypes: DocumentType[]
storagePaths: StoragePath[]
customFields: CustomField[]
tagSelectionModel = new FilterableDropdownSelectionModel()
correspondentSelectionModel = new FilterableDropdownSelectionModel()
documentTypeSelectionModel = new FilterableDropdownSelectionModel()
storagePathsSelectionModel = new FilterableDropdownSelectionModel()
customFieldsSelectionModel = new FilterableDropdownSelectionModel()
tagDocumentCounts: SelectionDataItem[]
correspondentDocumentCounts: SelectionDataItem[]
documentTypeDocumentCounts: SelectionDataItem[]
storagePathDocumentCounts: SelectionDataItem[]
customFieldDocumentCounts: SelectionDataItem[]
awaitingDownload: boolean
unsubscribeNotifier: Subject<any> = new Subject()
@@ -85,6 +91,7 @@ export class BulkEditorComponent
private settings: SettingsService,
private toastService: ToastService,
private storagePathService: StoragePathService,
private customFieldService: CustomFieldsService,
private permissionService: PermissionsService
) {
super()
@@ -166,6 +173,17 @@ export class BulkEditorComponent
.pipe(first())
.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
.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[]) {
if (items.length == 0) {
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) {
let modal = this.modalService.open(TagEditDialogComponent, {
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() {
let modal = this.modalService.open(ConfirmDialogComponent, {
backdrop: 'static',

View File

@@ -15,7 +15,7 @@
<div class="card-body">
<div class="d-flex justify-content-between align-items-center">
<h5 class="card-title">
@if (document.correspondent) {
@if (displayFields.includes(DisplayField.CORRESPONDENT) && document.correspondent) {
@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>
} @else {
@@ -23,14 +23,18 @@
}
:
}
{{document.title | documentTitle}}
@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>
@if (displayFields.includes(DisplayField.TITLE)) {
{{document.title | documentTitle}}
}
@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>
</div>
<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>
}
@for (highlight of searchNoteHighlights; track highlight) {
@@ -39,7 +43,7 @@
<span [innerHtml]="highlight"></span>
</span>
}
@if (!document.__search_hit__) {
@if (!document.__search_hit__?.score) {
<span class="result-content">{{contentTrimmed}}</span>
}
</p>
@@ -66,44 +70,53 @@
</div>
<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>
<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>
}
@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
(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>
</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
(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>
</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">
<i-bs width=".9em" height=".9em" class="me-2 text-muted" name="upc-scan"></i-bs><small>#{{document.archive_serial_number}}</small>
</div>
}
<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="list-group-item bg-light text-dark p-1 border-0 d-flex align-items-center" [ngbTooltip]="dateTooltip">
<i-bs width=".9em" height=".9em" class="me-2 text-muted" name="calendar-event"></i-bs><small>{{document.created_date | customDate:'mediumDate'}}</small>
</div>
@if (document.owner && document.owner !== settingsService.currentUser.id) {
@if (displayFields.includes(DisplayField.CREATED) || displayFields.includes(DisplayField.ADDED)) {
<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>
@if (displayFields.includes(DisplayField.CREATED)) {
<div class="list-group-item bg-light text-dark p-1 border-0 d-flex align-items-center" [ngbTooltip]="dateTooltip">
<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">
<i-bs width=".9em" height=".9em" class="me-2 text-muted" name="person-fill-lock"></i-bs><small>{{document.owner | username}}</small>
</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">
<i-bs width=".9em" height=".9em" class="me-2 text-muted" name="people-fill"></i-bs><small i18n>Shared</small>
</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>
</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>

View File

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

View File

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

View File

@@ -10,19 +10,21 @@
</div>
</div>
<div class="tags d-flex flex-column text-end position-absolute me-1 fs-6">
@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>
<span class="badge text-dark">+ {{moreTags}}</span>
</div>
}
</div>
@if (displayFields?.includes(DisplayField.TAGS)) {
<div class="tags d-flex flex-column text-end position-absolute me-1 fs-6">
@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>
<span class="badge text-dark">+ {{moreTags}}</span>
</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">
<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>
@@ -32,59 +34,86 @@
<div class="card-body bg-light p-2">
<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>:
}
{{document.title | documentTitle}}
@if (displayFields.includes(DisplayField.TITLE)) {
{{document.title | documentTitle}}
}
</p>
</div>
<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">
@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
(click)="clickDocumentType.emit(document.document_type);$event.stopPropagation()">
<i-bs width="1em" height="1em" class="me-2 text-muted" name="file-earmark"></i-bs>
<small>{{(document.document_type$ | async)?.name ?? privateName}}</small>
</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
(click)="clickStoragePath.emit(document.storage_path);$event.stopPropagation()">
<i-bs width="1em" height="1em" class="me-2 text-muted" name="folder"></i-bs>
<small>{{(document.storage_path$ | async)?.name ?? privateName}}</small>
</button>
}
<div class="list-group-item bg-transparent p-0 border-0 d-flex flex-wrap-reverse justify-content-between">
<ng-template #dateTooltip>
<div class="d-flex flex-column text-light">
<span i18n>Created: {{ document.created | customDate }}</span>
<span i18n>Added: {{ document.added | customDate }}</span>
<span i18n>Modified: {{ document.modified | customDate }}</span>
@if (displayFields.includes(DisplayField.CREATED)) {
<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.created | customDate:'mediumDate'}}</small>
</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>
@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">
<i-bs width="1em" height="1em" class="me-2 text-muted" name="upc-scan"></i-bs>
<small>#{{document.archive_serial_number}}</small>
</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">
<i-bs width="1em" height="1em" class="me-2 text-muted" name="person-fill-lock"></i-bs>
<small>{{document.owner | username}}</small>
</div>
}
@if (document.is_shared_by_requester) {
@if (displayFields.includes(DisplayField.SHARED) && document.is_shared_by_requester) {
<div class="ps-0 p-1">
<i-bs width="1em" height="1em" class="me-2 text-muted" name="people-fill"></i-bs>
<small i18n>Shared</small>
</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 class="d-flex justify-content-between align-items-center">
<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 { PreviewPopupComponent } from '../../common/preview-popup/preview-popup.component'
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
import { CustomFieldDisplayComponent } from '../../common/custom-field-display/custom-field-display.component'
const doc = {
id: 10,
@@ -67,6 +68,7 @@ describe('DocumentCardSmallComponent', () => {
TagComponent,
IsNumberPipe,
PreviewPopupComponent,
CustomFieldDisplayComponent,
],
providers: [DatePipe],
imports: [

View File

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

View File

@@ -11,16 +11,32 @@
<button ngbDropdownItem (click)="list.selectAll()" i18n>Select all</button>
</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">
<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">
<i-bs name="list-ul"></i-bs>
</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">
<i-bs name="grid"></i-bs>
</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">
<i-bs name="hdd-stack"></i-bs>
</label>
@@ -41,7 +57,7 @@
</div>
<div>
@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}}
</button>
}
@@ -109,7 +125,7 @@
}
</div>
@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>
}
</div>
@@ -122,48 +138,68 @@
@if (list.error ) {
<div class="alert alert-danger" role="alert"><ng-container i18n>Error while loading documents</ng-container>: {{list.error}}</div>
} @else {
@if (displayMode === 'largeCards') {
@if (list.displayMode === DisplayMode.LARGE_CARDS) {
<div>
@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>
}
</div>
}
@if (displayMode === 'details') {
@if (list.displayMode === DisplayMode.TABLE) {
<table class="table table-sm align-middle border shadow-sm">
<thead>
<th></th>
<th class="d-none d-lg-table-cell"
pngxSortable="archive_serial_number"
title="Sort by ASN" i18n-title
[currentSortField]="list.sortField"
[currentSortReverse]="list.sortReverse"
(sort)="onSort($event)"
i18n>ASN</th>
<th class="d-none d-md-table-cell"
pngxSortable="correspondent__name"
title="Sort by correspondent" i18n-title
[currentSortField]="list.sortField"
[currentSortReverse]="list.sortReverse"
(sort)="onSort($event)"
i18n>Correspondent</th>
<th
pngxSortable="title"
title="Sort by title" i18n-title
class="w-40"
[currentSortField]="list.sortField"
[currentSortReverse]="list.sortReverse"
(sort)="onSort($event)"
i18n>Title</th>
<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 (notesEnabled) {
@if (activeDisplayFields.includes(DisplayField.ASN)) {
<th class="d-none d-lg-table-cell"
pngxSortable="archive_serial_number"
title="Sort by ASN" i18n-title
[currentSortField]="list.sortField"
[currentSortReverse]="list.sortReverse"
(sort)="onSort($event)"
i18n>ASN</th>
}
@if (activeDisplayFields.includes(DisplayField.CORRESPONDENT) && permissionService.currentUserCan(PermissionAction.View, PermissionType.Correspondent)) {
<th class="d-none d-md-table-cell"
pngxSortable="correspondent__name"
title="Sort by correspondent" i18n-title
[currentSortField]="list.sortField"
[currentSortReverse]="list.sortReverse"
(sort)="onSort($event)"
i18n>Correspondent</th>
}
@if (activeDisplayFields.includes(DisplayField.TITLE)) {
<th
pngxSortable="title"
title="Sort by title" i18n-title
[currentSortField]="list.sortField"
[currentSortReverse]="list.sortReverse"
(sort)="onSort($event)"
i18n>Title</th>
}
@if (activeDisplayFields.includes(DisplayField.TAGS) && !activeDisplayFields.includes(DisplayField.TITLE)) {
<th i18n>Tags</th>
}
@if (activeDisplayFields.includes(DisplayField.OWNER) && permissionService.currentUserCan(PermissionAction.View, PermissionType.User)) {
<th class="d-none d-xl-table-cell"
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"
pngxSortable="num_notes"
title="Sort by notes" i18n-title
@@ -172,34 +208,52 @@
(sort)="onSort($event)"
i18n>Notes</th>
}
<th class="d-none d-xl-table-cell"
pngxSortable="document_type__name"
title="Sort by document type" i18n-title
[currentSortField]="list.sortField"
[currentSortReverse]="list.sortReverse"
(sort)="onSort($event)"
i18n>Document type</th>
<th class="d-none d-xl-table-cell"
pngxSortable="storage_path__name"
title="Sort by storage path" i18n-title
[currentSortField]="list.sortField"
[currentSortReverse]="list.sortReverse"
(sort)="onSort($event)"
i18n>Storage path</th>
<th
pngxSortable="created"
title="Sort by created date" i18n-title
[currentSortField]="list.sortField"
[currentSortReverse]="list.sortReverse"
(sort)="onSort($event)"
i18n>Created</th>
<th class="d-none d-xl-table-cell"
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.DOCUMENT_TYPE) && permissionService.currentUserCan(PermissionAction.View, PermissionType.DocumentType)) {
<th class="d-none d-xl-table-cell"
pngxSortable="document_type__name"
title="Sort by document type" i18n-title
[currentSortField]="list.sortField"
[currentSortReverse]="list.sortReverse"
(sort)="onSort($event)"
i18n>Document type</th>
}
@if (activeDisplayFields.includes(DisplayField.STORAGE_PATH) && permissionService.currentUserCan(PermissionAction.View, PermissionType.StoragePath)) {
<th class="d-none d-xl-table-cell"
pngxSortable="storage_path__name"
title="Sort by storage path" i18n-title
[currentSortField]="list.sortField"
[currentSortReverse]="list.sortReverse"
(sort)="onSort($event)"
i18n>Storage path</th>
}
@if (activeDisplayFields.includes(DisplayField.CREATED)) {
<th
pngxSortable="created"
title="Sort by created date" i18n-title
[currentSortField]="list.sortField"
[currentSortReverse]="list.sortReverse"
(sort)="onSort($event)"
i18n>Created</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>
<tbody>
@for (d of list.documents; track trackByDocumentId($index, d)) {
@@ -210,24 +264,36 @@
<label class="form-check-label" for="docCheck{{d.id}}"></label>
</div>
</td>
<td class="d-none d-lg-table-cell">
{{d.archive_serial_number}}
</td>
<td class="d-none d-md-table-cell">
@if (d.correspondent) {
<a (click)="clickCorrespondent(d.correspondent);$event.stopPropagation()" title="Filter by correspondent" i18n-title>{{(d.correspondent$ | async)?.name}}</a>
}
</td>
<td>
<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) {
<pngx-tag [tag]="t" class="ms-1" clickable="true" linkTitle="Filter by tag" i18n-linkTitle (click)="clickTag(t.id);$event.stopPropagation()"></pngx-tag>
}
</td>
<td>
{{d.owner | username}}
</td>
@if (notesEnabled) {
@if (activeDisplayFields.includes(DisplayField.ASN)) {
<td class="d-none d-xl-table-cell">
{{d.archive_serial_number}}
</td>
}
@if (activeDisplayFields.includes(DisplayField.CORRESPONDENT) && permissionService.currentUserCan(PermissionAction.View, PermissionType.Correspondent)) {
<td class="d-none d-xl-table-cell">
@if (d.correspondent) {
<a (click)="clickCorrespondent(d.correspondent);$event.stopPropagation()" title="Filter by correspondent" i18n-title>{{(d.correspondent$ | async)?.name}}</a>
}
</td>
}
@if (activeDisplayFields.includes(DisplayField.TITLE) || activeDisplayFields.includes(DisplayField.TAGS)) {
<td>
@if (activeDisplayFields.includes(DisplayField.TITLE)) {
<a routerLink="/documents/{{d.id}}" title="Edit document" i18n-title style="overflow-wrap: anywhere;">{{d.title | documentTitle}}</a>
}
@if (activeDisplayFields.includes(DisplayField.TAGS)) {
@for (t of d.tags$ | async; track t) {
<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">
@if (d.notes.length) {
<a routerLink="/documents/{{d.id}}/notes" class="btn btn-sm p-0">
@@ -238,31 +304,59 @@
}
</td>
}
<td class="d-none d-xl-table-cell">
@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">
@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>
}
</td>
<td>
{{d.created_date | customDate}}
</td>
<td class="d-none d-xl-table-cell">
{{d.added | customDate}}
</td>
@if (activeDisplayFields.includes(DisplayField.DOCUMENT_TYPE) && permissionService.currentUserCan(PermissionAction.View, PermissionType.DocumentType)) {
<td class="d-none d-xl-table-cell">
@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>
}
@if (activeDisplayFields.includes(DisplayField.STORAGE_PATH) && permissionService.currentUserCan(PermissionAction.View, PermissionType.StoragePath)) {
<td class="d-none d-xl-table-cell">
@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>
}
</td>
}
@if (activeDisplayFields.includes(DisplayField.CREATED)) {
<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>
}
</tbody>
</table>
}
@if (displayMode === 'smallCards') {
@if (list.displayMode === DisplayMode.SMALL_CARDS) {
<div class="row row-cols-paperless-cards">
@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>
}

View File

@@ -10,10 +10,6 @@ th {
cursor: pointer;
}
th.w-40 {
width: 40%;
}
.table-row-selected {
background-color: var(--pngx-primary-faded);
}
@@ -84,3 +80,7 @@ $paperless-card-breakpoints: (
a {
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 { FilterEditorComponent } from './filter-editor/filter-editor.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 { PageHeaderComponent } from '../common/page-header/page-header.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 { DocumentTitlePipe } from 'src/app/pipes/document-title.pipe'
import { UsernamePipe } from 'src/app/pipes/username.pipe'
import { Document } from 'src/app/data/document'
import {
DOCUMENT_SORT_FIELDS,
DOCUMENT_SORT_FIELDS_FULLTEXT,
DocumentService,
} from 'src/app/services/rest/document.service'
DEFAULT_DISPLAY_FIELDS,
DisplayField,
DisplayMode,
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 { SafeHtmlPipe } from 'src/app/pipes/safehtml.pipe'
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 { IsNumberPipe } from 'src/app/pipes/is-number.pipe'
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[] = [
{
@@ -101,6 +104,7 @@ describe('DocumentListComponent', () => {
let toastService: ToastService
let modalService: NgbModal
let settingsService: SettingsService
let permissionService: PermissionsService
beforeEach(async () => {
TestBed.configureTestingModule({
@@ -109,7 +113,7 @@ describe('DocumentListComponent', () => {
PageHeaderComponent,
FilterEditorComponent,
FilterableDropdownComponent,
DateDropdownComponent,
DatesDropdownComponent,
PermissionsFilterDropdownComponent,
ToggleableDropdownButtonComponent,
BulkEditorComponent,
@@ -148,6 +152,7 @@ describe('DocumentListComponent', () => {
NgbPopoverModule,
NgbTooltipModule,
NgxBootstrapIconsModule.pick(allIcons),
NgSelectModule,
],
}).compileComponents()
@@ -160,21 +165,11 @@ describe('DocumentListComponent', () => {
toastService = TestBed.inject(ToastService)
modalService = TestBed.inject(NgbModal)
settingsService = TestBed.inject(SettingsService)
permissionService = TestBed.inject(PermissionsService)
fixture = TestBed.createComponent(DocumentListComponent)
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', () => {
const reloadSpy = jest.spyOn(documentListService, 'reload')
const fileStatusSubject = new Subject<FileStatus>()
@@ -194,7 +189,7 @@ describe('DocumentListComponent', () => {
},
]
fixture.detectChanges()
expect(component.getSortFields()).toEqual(DOCUMENT_SORT_FIELDS)
expect(component.getSortFields()).toEqual(documentListService.sortFields)
documentListService.filterRules = [
{
@@ -203,7 +198,9 @@ describe('DocumentListComponent', () => {
},
]
fixture.detectChanges()
expect(component.getSortFields()).toEqual(DOCUMENT_SORT_FIELDS_FULLTEXT)
expect(component.getSortFields()).toEqual(
documentListService.sortFieldsFullText
)
})
it('should determine if filtered, support reset', () => {
@@ -292,18 +289,18 @@ describe('DocumentListComponent', () => {
const displayModeButtons = fixture.debugElement.queryAll(
By.css('input[type="radio"]')
)
expect(component.displayMode).toEqual('smallCards')
expect(component.list.displayMode).toEqual('smallCards')
displayModeButtons[0].nativeElement.checked = true
displayModeButtons[0].triggerEventHandler('change')
fixture.detectChanges()
expect(component.displayMode).toEqual('details')
expect(component.list.displayMode).toEqual('table')
expect(fixture.debugElement.queryAll(By.css('tr'))).toHaveLength(3)
displayModeButtons[1].nativeElement.checked = true
displayModeButtons[1].triggerEventHandler('change')
fixture.detectChanges()
expect(component.displayMode).toEqual('smallCards')
expect(component.list.displayMode).toEqual('smallCards')
expect(
fixture.debugElement.queryAll(By.directive(DocumentCardSmallComponent))
).toHaveLength(3)
@@ -311,7 +308,7 @@ describe('DocumentListComponent', () => {
displayModeButtons[2].nativeElement.checked = true
displayModeButtons[2].triggerEventHandler('change')
fixture.detectChanges()
expect(component.displayMode).toEqual('largeCards')
expect(component.list.displayMode).toEqual('largeCards')
expect(
fixture.debugElement.queryAll(By.directive(DocumentCardLargeComponent))
).toHaveLength(3)
@@ -322,7 +319,7 @@ describe('DocumentListComponent', () => {
fixture.detectChanges()
const sortDropdown = fixture.debugElement.queryAll(
By.directive(NgbDropdown)
)[1]
)[2]
const asnSortFieldButton = sortDropdown.query(By.directive(NgbDropdownItem))
asnSortFieldButton.triggerEventHandler('click')
@@ -332,6 +329,7 @@ describe('DocumentListComponent', () => {
})
it('should support setting sort field by table head', () => {
component.activeDisplayFields = [DisplayField.ASN]
jest.spyOn(documentListService, 'documents', 'get').mockReturnValue(docs)
fixture.detectChanges()
expect(documentListService.sortField).toEqual('created')
@@ -342,7 +340,7 @@ describe('DocumentListComponent', () => {
detailsDisplayModeButton.nativeElement.checked = true
detailsDisplayModeButton.triggerEventHandler('change')
fixture.detectChanges()
expect(component.displayMode).toEqual('details')
expect(component.list.displayMode).toEqual(DisplayMode.TABLE)
const sortTh = fixture.debugElement.query(By.directive(SortableDirective))
sortTh.triggerEventHandler('click')
@@ -425,6 +423,8 @@ describe('DocumentListComponent', () => {
value: '20',
},
],
display_mode: DisplayMode.SMALL_CARDS,
display_fields: [DisplayField.TITLE],
}
jest.spyOn(savedViewService, 'getCached').mockReturnValue(of(view))
const queryParams = { view: view.id.toString() }
@@ -541,6 +541,42 @@ describe('DocumentListComponent', () => {
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', () => {
fixture.detectChanges()
const routerSpy = jest.spyOn(router, 'navigate')
@@ -548,18 +584,14 @@ describe('DocumentListComponent', () => {
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)
fixture.detectChanges()
expect(documentListService.sortField).toEqual('created')
const detailsDisplayModeButton = fixture.debugElement.query(
By.css('input[type="radio"]')
)
detailsDisplayModeButton.nativeElement.checked = true
detailsDisplayModeButton.triggerEventHandler('change')
component.list.displayMode = DisplayMode.TABLE
component.list.displayFields = DEFAULT_DISPLAY_FIELDS.map((f) => f.id)
fixture.detectChanges()
expect(component.displayMode).toEqual('details')
expect(
fixture.debugElement.queryAll(By.directive(SortableDirective))
@@ -572,6 +604,13 @@ describe('DocumentListComponent', () => {
expect(
fixture.debugElement.queryAll(By.directive(SortableDirective))
).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', () => {
@@ -591,4 +630,28 @@ describe('DocumentListComponent', () => {
{ 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,
} from 'src/app/utils/filter-rules'
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 { SETTINGS_KEYS } from 'src/app/data/ui-settings'
import {
@@ -25,10 +25,7 @@ import {
import { ConsumerStatusService } from 'src/app/services/consumer-status.service'
import { DocumentListViewService } from 'src/app/services/document-list-view.service'
import { OpenDocumentsService } from 'src/app/services/open-documents.service'
import {
DOCUMENT_SORT_FIELDS,
DOCUMENT_SORT_FIELDS_FULLTEXT,
} from 'src/app/services/rest/document.service'
import { PermissionsService } from 'src/app/services/permissions.service'
import { SavedViewService } from 'src/app/services/rest/saved-view.service'
import { SettingsService } from 'src/app/services/settings.service'
import { ToastService } from 'src/app/services/toast.service'
@@ -45,6 +42,9 @@ export class DocumentListComponent
extends ComponentWithPermissions
implements OnInit, OnDestroy
{
DisplayField = DisplayField
DisplayMode = DisplayMode
constructor(
public list: DocumentListViewService,
public savedViewService: SavedViewService,
@@ -54,7 +54,8 @@ export class DocumentListComponent
private modalService: NgbModal,
private consumerStatusService: ConsumerStatusService,
public openDocumentsService: OpenDocumentsService,
private settingsService: SettingsService
public settingsService: SettingsService,
public permissionService: PermissionsService
) {
super()
}
@@ -64,7 +65,25 @@ export class DocumentListComponent
@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[] = []
private unmodifiedSavedView: SavedView
@@ -77,6 +96,16 @@ export class DocumentListComponent
return (
this.unmodifiedSavedView.sort_field !== this.list.sortField ||
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(
this.unmodifiedSavedView.filter_rules,
this.list.filterRules
@@ -101,8 +130,8 @@ export class DocumentListComponent
getSortFields() {
return isFullTextFilterRule(this.list.filterRules)
? DOCUMENT_SORT_FIELDS_FULLTEXT
: DOCUMENT_SORT_FIELDS
? this.list.sortFieldsFullText
: this.list.sortFields
}
set listSortReverse(reverse: boolean) {
@@ -113,10 +142,6 @@ export class DocumentListComponent
return this.list.sortReverse
}
setSortField(field: string) {
this.list.sortField = field
}
onSort(event: SortEvent) {
this.list.setSort(event.column, event.reverse)
}
@@ -125,15 +150,23 @@ export class DocumentListComponent
return this.list.selected.size > 0
}
saveDisplayMode() {
localStorage.setItem('document-list:displayMode', this.displayMode)
toggleDisplayField(field: DisplayField) {
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 {
if (localStorage.getItem('document-list:displayMode') != null) {
this.displayMode = localStorage.getItem('document-list:displayMode')
}
this.consumerStatusService
.onDocumentConsumptionFinished()
.pipe(takeUntil(this.unsubscribeNotifier))
@@ -197,6 +230,8 @@ export class DocumentListComponent
filter_rules: this.list.filterRules,
sort_field: this.list.sortField,
sort_reverse: this.list.sortReverse,
display_mode: this.list.displayMode,
display_fields: this.activeDisplayFields,
}
this.savedViewService
.patch(savedView)
@@ -236,6 +271,8 @@ export class DocumentListComponent
filter_rules: this.list.filterRules,
sort_reverse: this.list.sortReverse,
sort_field: this.list.sortField,
display_mode: this.list.displayMode,
display_fields: this.activeDisplayFields,
}
this.savedViewService

View File

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

View File

@@ -49,8 +49,12 @@ import {
FILTER_OWNER_ANY,
FILTER_OWNER_DOES_NOT_INCLUDE,
FILTER_OWNER_ISNULL,
FILTER_CUSTOM_FIELDS,
FILTER_CUSTOM_FIELDS_TEXT,
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'
import { Correspondent } from 'src/app/data/correspondent'
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 { SettingsService } from 'src/app/services/settings.service'
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 {
FilterableDropdownComponent,
LogicalOperator,
@@ -86,6 +90,8 @@ import {
PermissionsService,
} from 'src/app/services/permissions.service'
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[] = [
{
@@ -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[] = [
{
id: 1,
@@ -156,7 +175,7 @@ describe('FilterEditorComponent', () => {
IfPermissionsDirective,
ClearableBadgeComponent,
ToggleableDropdownButtonComponent,
DateDropdownComponent,
DatesDropdownComponent,
CustomDatePipe,
],
providers: [
@@ -187,6 +206,12 @@ describe('FilterEditorComponent', () => {
listAll: () => of({ results: storage_paths }),
},
},
{
provide: CustomFieldsService,
useValue: {
listAll: () => of({ results: custom_fields }),
},
},
{
provide: UserService,
useValue: {
@@ -285,7 +310,7 @@ describe('FilterEditorComponent', () => {
expect(component.textFilter).toEqual(null)
component.filterRules = [
{
rule_type: FILTER_CUSTOM_FIELDS,
rule_type: FILTER_CUSTOM_FIELDS_TEXT,
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(() => {
expect(component.permissionsSelectionModel.ownerFilter).toEqual(
OwnerFilterType.NONE
@@ -1053,7 +1182,7 @@ describe('FilterEditorComponent', () => {
expect(component.textFilterTarget).toEqual('custom-fields')
expect(component.filterRules).toEqual([
{
rule_type: FILTER_CUSTOM_FIELDS,
rule_type: FILTER_CUSTOM_FIELDS_TEXT,
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(() => {
const dateCreatedDropdown = fixture.debugElement.queryAll(
By.directive(DateDropdownComponent)
By.directive(DatesDropdownComponent)
)[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(() => {
const dateCreatedDropdown = fixture.debugElement.queryAll(
By.directive(DateDropdownComponent)
By.directive(DatesDropdownComponent)
)[0]
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(() => {
const dateCreatedDropdown = fixture.debugElement.queryAll(
By.directive(DateDropdownComponent)
By.directive(DatesDropdownComponent)
)[0]
const dateCreatedBeforeRelativeButton = dateCreatedDropdown.queryAll(
By.css('button')
@@ -1378,7 +1576,7 @@ describe('FilterEditorComponent', () => {
it('should carry over text filtering on date created with relative date', fakeAsync(() => {
component.textFilter = 'foo'
const dateCreatedDropdown = fixture.debugElement.queryAll(
By.directive(DateDropdownComponent)
By.directive(DatesDropdownComponent)
)[0]
const dateCreatedBeforeRelativeButton = dateCreatedDropdown.queryAll(
By.css('button')
@@ -1423,10 +1621,10 @@ describe('FilterEditorComponent', () => {
}))
it('should convert user input to correct filter rules on date added after', fakeAsync(() => {
const dateAddedDropdown = fixture.debugElement.queryAll(
By.directive(DateDropdownComponent)
)[1]
const dateAddedAfter = dateAddedDropdown.queryAll(By.css('input'))[0]
const datesDropdown = fixture.debugElement.query(
By.directive(DatesDropdownComponent)
)
const dateAddedAfter = datesDropdown.queryAll(By.css('input'))[2]
dateAddedAfter.nativeElement.value = '05/14/2023'
// dateAddedAfter.triggerEventHandler('change')
@@ -1443,10 +1641,10 @@ describe('FilterEditorComponent', () => {
}))
it('should convert user input to correct filter rules on date added before', fakeAsync(() => {
const dateAddedDropdown = fixture.debugElement.queryAll(
By.directive(DateDropdownComponent)
)[1]
const dateAddedBefore = dateAddedDropdown.queryAll(By.css('input'))[1]
const datesDropdown = fixture.debugElement.query(
By.directive(DatesDropdownComponent)
)
const dateAddedBefore = datesDropdown.queryAll(By.css('input'))[2]
dateAddedBefore.nativeElement.value = '05/14/2023'
// 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(() => {
const dateAddedDropdown = fixture.debugElement.queryAll(
By.directive(DateDropdownComponent)
)[1]
const dateAddedBeforeRelativeButton = dateAddedDropdown.queryAll(
const datesDropdown = fixture.debugElement.query(
By.directive(DatesDropdownComponent)
)
const dateCreatedBeforeRelativeButton = datesDropdown.queryAll(
By.css('button')
)[1]
dateAddedBeforeRelativeButton.triggerEventHandler('click')
dateCreatedBeforeRelativeButton.triggerEventHandler('click')
fixture.detectChanges()
tick(400)
expect(component.filterRules).toEqual([
{
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(() => {
component.textFilter = 'foo'
const dateAddedDropdown = fixture.debugElement.queryAll(
By.directive(DateDropdownComponent)
)[1]
const dateAddedBeforeRelativeButton = dateAddedDropdown.queryAll(
const datesDropdown = fixture.debugElement.query(
By.directive(DatesDropdownComponent)
)
const dateCreatedBeforeRelativeButton = datesDropdown.queryAll(
By.css('button')
)[1]
dateAddedBeforeRelativeButton.triggerEventHandler('click')
dateCreatedBeforeRelativeButton.triggerEventHandler('click')
fixture.detectChanges()
tick(400)
expect(component.filterRules).toEqual([
{
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: 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')
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 = [
{
rule_type: FILTER_TITLE,

View File

@@ -48,8 +48,12 @@ import {
FILTER_OWNER_DOES_NOT_INCLUDE,
FILTER_OWNER_ISNULL,
FILTER_OWNER_ANY,
FILTER_CUSTOM_FIELDS,
FILTER_CUSTOM_FIELDS_TEXT,
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'
import {
FilterableDropdownSelectionModel,
@@ -65,7 +69,7 @@ import {
import { Document } from 'src/app/data/document'
import { StoragePath } from 'src/app/data/storage-path'
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 {
OwnerFilterType,
PermissionsSelectionModel,
@@ -76,6 +80,8 @@ import {
PermissionsService,
} from 'src/app/services/permissions.service'
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_CONTENT = 'title-content'
@@ -208,6 +214,16 @@ export class FilterEditorComponent
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:
return $localize`Title: ${rule.value}`
@@ -234,7 +250,8 @@ export class FilterEditorComponent
private correspondentService: CorrespondentService,
private documentService: DocumentService,
private storagePathService: StoragePathService,
public permissionsService: PermissionsService
public permissionsService: PermissionsService,
private customFieldService: CustomFieldsService
) {
super()
}
@@ -246,11 +263,13 @@ export class FilterEditorComponent
correspondents: Correspondent[] = []
documentTypes: DocumentType[] = []
storagePaths: StoragePath[] = []
customFields: CustomField[] = []
tagDocumentCounts: SelectionDataItem[]
correspondentDocumentCounts: SelectionDataItem[]
documentTypeDocumentCounts: SelectionDataItem[]
storagePathDocumentCounts: SelectionDataItem[]
customFieldDocumentCounts: SelectionDataItem[]
_textFilter = ''
_moreLikeId: number
@@ -288,6 +307,7 @@ export class FilterEditorComponent
correspondentSelectionModel = new FilterableDropdownSelectionModel()
documentTypeSelectionModel = new FilterableDropdownSelectionModel()
storagePathSelectionModel = new FilterableDropdownSelectionModel()
customFieldSelectionModel = new FilterableDropdownSelectionModel()
dateCreatedBefore: string
dateCreatedAfter: string
@@ -322,6 +342,7 @@ export class FilterEditorComponent
this.storagePathSelectionModel.clear(false)
this.tagSelectionModel.clear(false)
this.correspondentSelectionModel.clear(false)
this.customFieldSelectionModel.clear(false)
this._textFilter = null
this._moreLikeId = null
this.dateAddedBefore = null
@@ -347,7 +368,7 @@ export class FilterEditorComponent
this._textFilter = rule.value
this.textFilterTarget = TEXT_FILTER_TARGET_ASN
break
case FILTER_CUSTOM_FIELDS:
case FILTER_CUSTOM_FIELDS_TEXT:
this._textFilter = rule.value
this.textFilterTarget = TEXT_FILTER_TARGET_CUSTOM_FIELDS
break
@@ -488,6 +509,36 @@ export class FilterEditorComponent
false
)
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:
this.textFilterTarget = TEXT_FILTER_TARGET_ASN
this.textFilterModifier =
@@ -595,7 +646,7 @@ export class FilterEditorComponent
this.textFilterTarget == TEXT_FILTER_TARGET_CUSTOM_FIELDS
) {
filterRules.push({
rule_type: FILTER_CUSTOM_FIELDS,
rule_type: FILTER_CUSTOM_FIELDS_TEXT,
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) {
filterRules.push({
rule_type: FILTER_CREATED_BEFORE,
@@ -845,6 +925,8 @@ export class FilterEditorComponent
selectionData?.selected_correspondents ?? null
this.storagePathDocumentCounts =
selectionData?.selected_storage_paths ?? null
this.customFieldDocumentCounts =
selectionData?.selected_custom_fields ?? null
}
rulesModified: boolean = false
@@ -905,6 +987,16 @@ export class FilterEditorComponent
.listAll()
.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>()
@@ -961,6 +1053,10 @@ export class FilterEditorComponent
this.storagePathSelectionModel.apply()
}
onCustomFieldsDropdownOpen() {
this.customFieldSelectionModel.apply()
}
updateTextFilter(text) {
this._textFilter = 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>
</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">
<thead>
<tr>
@@ -74,7 +74,7 @@
}
<td scope="row">
<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>
<i-bs name="three-dots-vertical"></i-bs>
</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 { 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 {
score?: 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_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[] = [
{
@@ -281,11 +285,36 @@ export const FILTER_RULE_TYPES: FilterRuleType[] = [
multi: true,
},
{
id: FILTER_CUSTOM_FIELDS,
id: FILTER_CUSTOM_FIELDS_TEXT,
filtervar: 'custom_fields__icontains',
datatype: 'string',
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 {

View File

@@ -1,3 +1,4 @@
import { DisplayMode, DisplayField } from './document'
import { FilterRule } from './filter-rule'
import { ObjectWithPermissions } from './object-with-permissions'
@@ -13,4 +14,10 @@ export interface SavedView extends ObjectWithPermissions {
sort_reverse: boolean
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:
'general-settings:notifications:consumer-suppress-on-dashboard',
NOTES_ENABLED: 'general-settings:notes-enabled',
AUDITLOG_ENABLED: 'general-settings:auditlog-enabled',
SLIM_SIDEBAR: 'general-settings:slim-sidebar',
UPDATE_CHECKING_ENABLED: 'general-settings:update-checking:enabled',
UPDATE_CHECKING_BACKEND_SETTING:
@@ -143,6 +144,11 @@ export const SETTINGS: UiSetting[] = [
type: 'boolean',
default: true,
},
{
key: SETTINGS_KEYS.AUDITLOG_ENABLED,
type: 'boolean',
default: true,
},
{
key: SETTINGS_KEYS.UPDATE_CHECKING_ENABLED,
type: 'boolean',

View File

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

View File

@@ -1,10 +1,7 @@
import { TestBed } from '@angular/core/testing'
import { CustomDatePipe } from './custom-date.pipe'
import { SettingsService } from '../services/settings.service'
import {
HttpClientTestingModule,
HttpTestingController,
} from '@angular/common/http/testing'
import { HttpClientTestingModule } from '@angular/common/http/testing'
import { DatePipe } from '@angular/common'
describe('CustomDatePipe', () => {
@@ -30,4 +27,14 @@ describe('CustomDatePipe', () => {
)
).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',
}
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({
name: 'customDate',
})
@@ -34,6 +67,19 @@ export class CustomDatePipe implements PipeTransform {
this.settings.get(SETTINGS_KEYS.DATE_LOCALE) ||
this.defaultLocale
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') {
return this.datePipe.transform(value, FORMAT_TO_ISO_FORMAT[f], timezone)
} else {

View File

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

View File

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

View File

@@ -19,6 +19,11 @@ import { routes } from 'src/app/app-routing.module'
import { PermissionsGuard } from '../guards/permissions.guard'
import { SettingsService } from './settings.service'
import { SETTINGS_KEYS } from '../data/ui-settings'
import {
DisplayMode,
DisplayField,
DEFAULT_DISPLAY_FIELDS,
} from '../data/document'
const documents = [
{
@@ -213,7 +218,7 @@ describe('DocumentListViewService', () => {
documentListViewService.loadFromQueryParams(convertToParamMap(params))
const req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/?page=${page}&page_size=${
documentListViewService.currentPageSize
documentListViewService.pageSize
}&ordering=${reverse ? '-' : ''}${sort}&truncate_content=true`
)
expect(req.request.method).toEqual('GET')
@@ -231,7 +236,7 @@ describe('DocumentListViewService', () => {
}
documentListViewService.loadFromQueryParams(convertToParamMap(params))
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(documentListViewService.filterRules).toEqual([
@@ -249,7 +254,7 @@ describe('DocumentListViewService', () => {
it('should use filter rules to update query params', () => {
documentListViewService.filterRules = filterRules
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')
})
@@ -257,7 +262,7 @@ describe('DocumentListViewService', () => {
it('should support quick filter', () => {
documentListViewService.quickFilter(filterRules)
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')
})
@@ -280,7 +285,7 @@ describe('DocumentListViewService', () => {
convertToParamMap(params)
)
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')
// reset the list
@@ -305,8 +310,7 @@ describe('DocumentListViewService', () => {
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
)
expect(documentListViewService.currentPage).toEqual(1)
documentListViewService.currentPageSize = 3
documentListViewService.reload()
documentListViewService.pageSize = 3
req = httpTestingController.expectOne(
`${environment.apiBaseUrl}documents/?page=1&page_size=3&ordering=-created&truncate_content=true`
)
@@ -362,7 +366,10 @@ describe('DocumentListViewService', () => {
.spyOn(documentListViewService, 'documents', 'get')
.mockReturnValue(documents)
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
.spyOn(documentListViewService, 'getLastPage')
.mockReturnValue(Math.ceil(documents.length / 3))
@@ -410,7 +417,13 @@ describe('DocumentListViewService', () => {
.spyOn(documentListViewService, 'documents', 'get')
.mockReturnValue(documents)
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')
documentListViewService.getPrevious(1).subscribe({
next: () => {},
@@ -426,8 +439,7 @@ describe('DocumentListViewService', () => {
it('should update page size from settings', () => {
settingsService.set(SETTINGS_KEYS.DOCUMENT_LIST_SIZE, 10)
documentListViewService.updatePageSize()
expect(documentListViewService.currentPageSize).toEqual(10)
expect(documentListViewService.pageSize).toEqual(10)
})
it('should support select a document', () => {
@@ -459,8 +471,7 @@ describe('DocumentListViewService', () => {
})
it('should support select page', () => {
documentListViewService.currentPageSize = 3
documentListViewService.reload()
documentListViewService.pageSize = 3
const req = httpTestingController.expectOne(
`${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`
)
})
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,
isFullTextFilterRule,
} 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 { SETTINGS_KEYS } from '../data/ui-settings'
import { DOCUMENT_LIST_SERVICE } from '../data/storage-keys'
import { paramsFromViewState, paramsToViewState } from '../utils/query-params'
import {
DocumentService,
DOCUMENT_SORT_FIELDS,
SelectionData,
} from './rest/document.service'
import { DocumentService, SelectionData } from './rest/document.service'
import { SettingsService } from './settings.service'
/**
@@ -59,6 +60,21 @@ export interface ListViewState {
* Contains the IDs of all selected documents.
*/
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
currentPageSize: number = this.settings.get(SETTINGS_KEYS.DOCUMENT_LIST_SIZE)
private unsubscribeNotifier: Subject<any> = new Subject()
private listViewStates: Map<number, ListViewState> = new Map()
@@ -113,7 +127,7 @@ export class DocumentListViewService {
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)
this.listViewStates.set(null, newState)
} catch (e) {
@@ -176,6 +190,9 @@ export class DocumentListViewService {
if (this._activeSavedViewId) {
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()
@@ -220,7 +237,7 @@ export class DocumentListViewService {
this.documentService
.listFiltered(
activeListViewState.currentPage,
this.currentPageSize,
activeListViewState.pageSize ?? this.pageSize,
activeListViewState.sortField,
activeListViewState.sortReverse,
activeListViewState.filterRules,
@@ -281,9 +298,8 @@ export class DocumentListViewService {
errorMessage = Object.keys(error.error)
.map((fieldName) => {
const fieldError: Array<string> = error.error[fieldName]
return `${DOCUMENT_SORT_FIELDS.find(
(f) => f.field == fieldName
)?.name}: ${fieldError[0]}`
return `${this.sortFields.find((f) => f.field == fieldName)
?.name}: ${fieldError[0]}`
})
.join(', ')
} else {
@@ -312,6 +328,14 @@ export class DocumentListViewService {
return this.activeListViewState.filterRules
}
get sortFields(): any[] {
return this.documentService.sortFields
}
get sortFieldsFullText(): any[] {
return this.documentService.sortFieldsFullText
}
set sortField(field: string) {
this.activeListViewState.sortField = field
this.reload()
@@ -362,6 +386,51 @@ export class DocumentListViewService {
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() {
if (this._activeSavedViewId == null) {
let savedState: ListViewState = {
@@ -370,6 +439,8 @@ export class DocumentListViewService {
filterRules: this.activeListViewState.filterRules,
sortField: this.activeListViewState.sortField,
sortReverse: this.activeListViewState.sortReverse,
displayMode: this.activeListViewState.displayMode,
displayFields: this.activeListViewState.displayFields,
}
localStorage.setItem(
DOCUMENT_LIST_SERVICE.CURRENT_VIEW_CONFIG,
@@ -385,7 +456,7 @@ export class DocumentListViewService {
}
getLastPage(): number {
return Math.ceil(this.collectionSize / this.currentPageSize)
return Math.ceil(this.collectionSize / this.pageSize)
}
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() {
this.selected.clear()
this.rangeSelectionAnchorIndex = this.lastRangeSelectionToIndex = null

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