Compare commits

..
Author SHA1 Message Date
shamoon 0e9d2f6831 Messing with conversion of azure output to hocr 2024-03-08 22:28:14 -08:00
shamoon ec505e41fa Working GCV 2024-03-08 21:19:15 -08:00
shamoon 24c40bbc5e Rename to remote ocr 2024-03-08 21:19:15 -08:00
shamoon fba4ce9147 Try deps update again 2024-03-08 21:19:15 -08:00
shamoon a0c6d25d9a Add aws textract, remove chatgpt 2024-03-08 21:18:02 -08:00
shamoon 6e7e40e7a2 Add (non-working) Google cloud vision 2024-03-08 21:17:27 -08:00
shamoon eacafbcb36 Oh wow this works for azure, not chatgpt 2024-03-08 21:17:27 -08:00
363 changed files with 67879 additions and 115561 deletions
+1 -1
View File
@@ -1,3 +1,3 @@
[codespell]
write-changes = True
ignore-words-list = criterias,afterall,valeu,ureue,equest,ure,assertIn
ignore-words-list = criterias,afterall,valeu,ureue,equest,ure
+5 -6
View File
@@ -86,12 +86,6 @@ body:
description: Note there are significant differences from the official image and linuxserver.io, please check if your issue is specific to the third-party image.
validations:
required: true
- type: textarea
id: system-status
attributes:
label: System status
description: If available, copy & paste the system status output from Settings > System Status > Copy
render: json
- type: input
id: browser
attributes:
@@ -103,6 +97,11 @@ body:
attributes:
label: Configuration changes
description: Any configuration changes you made in `docker-compose.yml`, `docker-compose.env` or `paperless.conf`.
- type: input
id: other
attributes:
label: Other
description: Any other relevant details.
- type: checkboxes
id: required-checks
attributes:
+1
View File
@@ -53,6 +53,7 @@ updates:
development:
patterns:
- "*pytest*"
- "black"
- "ruff"
- "mkdocs-material"
django:
+2 -2
View File
@@ -33,7 +33,7 @@ jobs:
-
name: Clean temporary images
if: "${{ env.TOKEN != '' }}"
uses: stumpylog/image-cleaner-action/ephemeral@v0.6.0
uses: stumpylog/image-cleaner-action/ephemeral@v0.5.0
with:
token: "${{ env.TOKEN }}"
owner: "${{ github.repository_owner }}"
@@ -61,7 +61,7 @@ jobs:
-
name: Clean untagged images
if: "${{ env.TOKEN != '' }}"
uses: stumpylog/image-cleaner-action/untagged@v0.6.0
uses: stumpylog/image-cleaner-action/untagged@v0.5.0
with:
token: "${{ env.TOKEN }}"
owner: "${{ github.repository_owner }}"
+1 -1
View File
@@ -21,7 +21,7 @@ jobs:
- name: Checkout
uses: actions/checkout@v4
- name: crowdin action
uses: crowdin/github-action@v2
uses: crowdin/github-action@v1
with:
upload_translations: false
download_translations: true
+1 -1
View File
@@ -22,7 +22,7 @@ jobs:
with:
days-before-stale: 7
days-before-close: 14
any-of-labels: 'stale,cant-reproduce,not a bug'
any-of-labels: 'cant-reproduce,not a bug'
stale-issue-label: stale
stale-pr-label: stale
stale-issue-message: >
+8 -5
View File
@@ -5,7 +5,7 @@
repos:
# General hooks
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v4.6.0
rev: v4.5.0
hooks:
- id: check-docstring-first
- id: check-json
@@ -29,7 +29,7 @@ repos:
- id: check-case-conflict
- id: detect-private-key
- repo: https://github.com/codespell-project/codespell
rev: v2.3.0
rev: v2.2.6
hooks:
- id: codespell
exclude: "(^src-ui/src/locale/)|(^src-ui/e2e/)|(^src/paperless_mail/tests/samples/)"
@@ -47,10 +47,13 @@ repos:
exclude: "(^Pipfile\\.lock$)"
# Python hooks
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: 'v0.4.7'
rev: 'v0.3.0'
hooks:
- id: ruff
- id: ruff-format
- repo: https://github.com/psf/black-pre-commit-mirror
rev: 24.2.0
hooks:
- id: black
# Dockerfile hooks
- repo: https://github.com/AleksaC/hadolint-py
rev: v2.12.0.3
@@ -64,6 +67,6 @@ repos:
args:
- "--tab"
- repo: https://github.com/shellcheck-py/shellcheck-py
rev: "v0.10.0.1"
rev: "v0.9.0.6"
hooks:
- id: shellcheck
+1 -1
View File
@@ -5,7 +5,7 @@
We as members, contributors, and leaders pledge to make participation in our
community a harassment-free experience for everyone, regardless of age, body
size, visible or invisible disability, ethnicity, sex characteristics, gender
identity and expression, level of experience, education, socioeconomic status,
identity and expression, level of experience, education, socio-economic status,
nationality, personal appearance, race, religion, or sexual identity
and orientation.
+1 -1
View File
@@ -11,7 +11,7 @@ If you want to implement something big:
## Python
Paperless supports python 3.9 - 3.11. We format Python code with [ruff](https://docs.astral.sh/ruff/formatter/).
Paperless supports python 3.9 - 3.11. We format Python code with [Black](https://github.com/psf/black).
## Branches
+13 -19
View File
@@ -52,8 +52,8 @@ ARG TARGETARCH
# Can be workflow provided, defaults set for manual building
ARG JBIG2ENC_VERSION=0.29
ARG QPDF_VERSION=11.9.0
ARG GS_VERSION=10.03.1
ARG QPDF_VERSION=11.6.4
ARG GS_VERSION=10.02.1
# Set Python environment variables
ENV PYTHONDONTWRITEBYTECODE=1 \
@@ -83,6 +83,7 @@ ARG RUNTIME_PACKAGES="\
icc-profiles-free \
imagemagick \
# PostgreSQL
libpq5 \
postgresql-client \
# MySQL / MariaDB
mariadb-client \
@@ -128,17 +129,17 @@ RUN set -eux \
&& dpkg --install ./qpdf_${QPDF_VERSION}-1_${TARGETARCH}.deb \
&& echo "Installing Ghostscript ${GS_VERSION}" \
&& curl --fail --silent --show-error --location \
--output libgs10_${GS_VERSION}.dfsg.git20240518-1_${TARGETARCH}.deb \
https://github.com/paperless-ngx/builder/releases/download/ghostscript-${GS_VERSION}/libgs10_${GS_VERSION}.dfsg.git20240518-1_${TARGETARCH}.deb \
--output libgs10_${GS_VERSION}.dfsg-2_${TARGETARCH}.deb \
https://github.com/paperless-ngx/builder/releases/download/ghostscript-${GS_VERSION}/libgs10_${GS_VERSION}.dfsg-1_${TARGETARCH}.deb \
&& curl --fail --silent --show-error --location \
--output ghostscript_${GS_VERSION}.dfsg.git20240518-1_${TARGETARCH}.deb \
https://github.com/paperless-ngx/builder/releases/download/ghostscript-${GS_VERSION}/ghostscript_${GS_VERSION}.dfsg.git20240518-1_${TARGETARCH}.deb \
--output ghostscript_${GS_VERSION}.dfsg-2_${TARGETARCH}.deb \
https://github.com/paperless-ngx/builder/releases/download/ghostscript-${GS_VERSION}/ghostscript_${GS_VERSION}.dfsg-1_${TARGETARCH}.deb \
&& curl --fail --silent --show-error --location \
--output libgs10-common_${GS_VERSION}.dfsg.git20240518-1_all.deb \
https://github.com/paperless-ngx/builder/releases/download/ghostscript-${GS_VERSION}/libgs10-common_${GS_VERSION}.dfsg.git20240518-1_all.deb \
&& dpkg --install ./libgs10-common_${GS_VERSION}.dfsg.git20240518-1_all.deb \
&& dpkg --install ./libgs10_${GS_VERSION}.dfsg.git20240518-1_${TARGETARCH}.deb \
&& dpkg --install ./ghostscript_${GS_VERSION}.dfsg.git20240518-1_${TARGETARCH}.deb \
--output libgs10-common_${GS_VERSION}.dfsg-2_all.deb \
https://github.com/paperless-ngx/builder/releases/download/ghostscript-${GS_VERSION}/libgs10-common_${GS_VERSION}.dfsg-1_all.deb \
&& dpkg --install ./libgs10-common_${GS_VERSION}.dfsg-2_all.deb \
&& dpkg --install ./libgs10_${GS_VERSION}.dfsg-2_${TARGETARCH}.deb \
&& dpkg --install ./ghostscript_${GS_VERSION}.dfsg-2_${TARGETARCH}.deb \
&& echo "Installing jbig2enc" \
&& curl --fail --silent --show-error --location \
--output jbig2enc_${JBIG2ENC_VERSION}-1_${TARGETARCH}.deb \
@@ -222,13 +223,7 @@ RUN --mount=type=cache,target=/root/.cache/pip/,id=pip-cache \
&& apt-get install --yes --quiet --no-install-recommends ${BUILD_PACKAGES} \
&& python3 -m pip install --no-cache-dir --upgrade wheel \
&& echo "Installing Python requirements" \
&& curl --fail --silent --show-error --location \
--output psycopg_c-3.1.19-cp311-cp311-linux_x86_64.whl \
https://github.com/paperless-ngx/builder/releases/download/psycopg-3.1.19/psycopg_c-3.1.19-cp311-cp311-linux_x86_64.whl \
&& curl --fail --silent --show-error --location \
--output psycopg_c-3.1.19-cp311-cp311-linux_aarch64.whl \
https://github.com/paperless-ngx/builder/releases/download/psycopg-3.1.19/psycopg_c-3.1.19-cp311-cp311-linux_aarch64.whl \
&& python3 -m pip install --default-timeout=1000 --find-links . --requirement requirements.txt \
&& python3 -m pip install --default-timeout=1000 --requirement requirements.txt \
&& echo "Patching whitenoise for compression speedup" \
&& curl --fail --silent --show-error --location --output 484.patch https://github.com/evansd/whitenoise/pull/484.patch \
&& patch -d /usr/local/lib/python3.11/site-packages --verbose -p2 < 484.patch \
@@ -241,7 +236,6 @@ RUN --mount=type=cache,target=/root/.cache/pip/,id=pip-cache \
&& apt-get --yes purge ${BUILD_PACKAGES} \
&& apt-get --yes autoremove --purge \
&& apt-get clean --yes \
&& rm --recursive --force --verbose *.whl \
&& rm --recursive --force --verbose /var/lib/apt/lists/* \
&& rm --recursive --force --verbose /tmp/* \
&& rm --recursive --force --verbose /var/tmp/* \
+12 -8
View File
@@ -4,29 +4,33 @@ verify_ssl = true
name = "pypi"
[packages]
azure-ai-formrecognizer = "*"
boto3 = "*"
dateparser = "~=1.2"
# WARNING: django does not use semver.
# Only patch versions are guaranteed to not introduce breaking changes.
django = "~=4.2.13"
django-allauth = {extras = ["socialaccount"], version = "*"}
django = "~=4.2.11"
django-allauth = "*"
django-auditlog = "*"
django-celery-results = "*"
django-compression-middleware = "*"
django-cors-headers = "*"
django-extensions = "*"
django-filter = "~=24.2"
django-filter = "~=23.5"
django-guardian = "*"
django-multiselectfield = "*"
djangorestframework = "==3.14.0"
djangorestframework = "~=3.14"
djangorestframework-guardian = "*"
drf-writable-nested = "*"
bleach = "*"
celery = {extras = ["redis"], version = "*"}
channels = "~=4.1"
channels = "~=4.0"
channels-redis = "*"
concurrent-log-handler = "*"
filelock = "*"
flower = "*"
google-cloud-vision = "*"
google-cloud-storage = "*"
gotenberg-client = "*"
gunicorn = "*"
imap-tools = "*"
@@ -37,7 +41,7 @@ nltk = "*"
ocrmypdf = "~=15.4"
pathvalidate = "*"
pdf2image = "*"
psycopg = {version = "*", extras = ["c"]}
psycopg2 = "*"
python-dateutil = "*"
python-dotenv = "*"
python-gnupg = "*"
@@ -46,11 +50,10 @@ python-magic = "*"
pyzbar = "*"
rapidfuzz = "*"
redis = {extras = ["hiredis"], version = "*"}
scikit-learn = "~=1.5"
scikit-learn = "~=1.4"
setproctitle = "*"
tika-client = "*"
tqdm = "*"
# See https://github.com/paperless-ngx/paperless-ngx/issues/5494
uvicorn = {extras = ["standard"], version = "==0.25.0"}
watchdog = "~=4.0"
whitenoise = "~=6.6"
@@ -59,6 +62,7 @@ zxing-cpp = {version = "*", platform_machine = "== 'x86_64'"}
[dev-packages]
# Linting
black = "*"
pre-commit = "*"
ruff = "*"
# Testing
Generated
+1762 -1440
View File
File diff suppressed because it is too large Load Diff
+4 -4
View File
@@ -30,7 +30,7 @@ Thanks to the generous folks at [DigitalOcean](https://m.do.co/c/8d70b916d462),
- [Translation](#translation)
- [Feature Requests](#feature-requests)
- [Bugs](#bugs)
- [Related Projects](#related-projects)
- [Affiliated Projects](#affiliated-projects)
- [Important Note](#important-note)
<p align="right">This project is supported by:<br/>
@@ -63,7 +63,7 @@ If you'd like to jump right in, you can configure a `docker compose` environment
bash -c "$(curl -L https://raw.githubusercontent.com/paperless-ngx/paperless-ngx/main/install-paperless-ngx.sh)"
```
More details and step-by-step guides for alternative installation methods can be found in [the documentation](https://docs.paperless-ngx.com/setup/#installation).
Alternatively, you can install the dependencies and setup apache and a database server yourself. The [documentation](https://docs.paperless-ngx.com/setup/#installation) has a step by step guide on how to do it.
Migrating from Paperless-ng is easy, just drop in the new docker image! See the [documentation on migrating](https://docs.paperless-ngx.com/setup/#migrating-to-paperless-ngx) for more details.
@@ -93,9 +93,9 @@ Feature requests can be submitted via [GitHub Discussions](https://github.com/pa
For bugs please [open an issue](https://github.com/paperless-ngx/paperless-ngx/issues) or [start a discussion](https://github.com/paperless-ngx/paperless-ngx/discussions) if you have questions.
# Related Projects
# Affiliated Projects
Please see [the wiki](https://github.com/paperless-ngx/paperless-ngx/wiki/Related-Projects) for a user-maintained list of related projects and software that is compatible with Paperless-ngx.
Please see [the wiki](https://github.com/paperless-ngx/paperless-ngx/wiki/Affiliated-Projects) for a user-maintained list of affiliated projects and software that is compatible with Paperless-ngx.
# Important Note
+2 -1
View File
@@ -3,6 +3,7 @@
# Can be used locally or by the CI to start the necessary containers with the
# correct networking for the tests
version: "3.7"
services:
gotenberg:
image: docker.io/gotenberg/gotenberg:7.10
@@ -19,7 +20,7 @@ services:
- "--log-level=warn"
- "--log-format=text"
tika:
image: docker.io/apache/tika:latest
image: ghcr.io/paperless-ngx/tika:latest
hostname: tika
container_name: tika
network_mode: host
@@ -30,6 +30,7 @@
# For more extensive installation and update instructions, refer to the
# documentation.
version: "3.4"
services:
broker:
image: docker.io/library/redis:7
@@ -38,7 +39,7 @@ services:
- redisdata:/data
db:
image: docker.io/library/mariadb:11
image: docker.io/library/mariadb:10
restart: unless-stopped
volumes:
- dbdata:/var/lib/mysql
@@ -87,7 +88,7 @@ services:
- "--chromium-allow-list=file:///tmp/.*"
tika:
image: docker.io/apache/tika:latest
image: ghcr.io/paperless-ngx/tika:latest
restart: unless-stopped
volumes:
+2 -1
View File
@@ -26,6 +26,7 @@
# For more extensive installation and update instructions, refer to the
# documentation.
version: "3.4"
services:
broker:
image: docker.io/library/redis:7
@@ -34,7 +35,7 @@ services:
- redisdata:/data
db:
image: docker.io/library/mariadb:11
image: docker.io/library/mariadb:10
restart: unless-stopped
volumes:
- dbdata:/var/lib/mysql
+2 -1
View File
@@ -28,6 +28,7 @@
# For more extensive installation and update instructions, refer to the
# documentation.
version: "3.4"
services:
broker:
image: docker.io/library/redis:7
@@ -36,7 +37,7 @@ services:
- redisdata:/data
db:
image: docker.io/library/postgres:16
image: docker.io/library/postgres:15
restart: unless-stopped
volumes:
- pgdata:/var/lib/postgresql/data
@@ -30,6 +30,7 @@
# For more extensive installation and update instructions, refer to the
# documentation.
version: "3.4"
services:
broker:
image: docker.io/library/redis:7
@@ -38,7 +39,7 @@ services:
- redisdata:/data
db:
image: docker.io/library/postgres:16
image: docker.io/library/postgres:15
restart: unless-stopped
volumes:
- pgdata:/var/lib/postgresql/data
@@ -82,7 +83,7 @@ services:
- "--chromium-allow-list=file:///tmp/.*"
tika:
image: docker.io/apache/tika:latest
image: ghcr.io/paperless-ngx/tika:latest
restart: unless-stopped
volumes:
+2 -1
View File
@@ -26,6 +26,7 @@
# For more extensive installation and update instructions, refer to the
# documentation.
version: "3.4"
services:
broker:
image: docker.io/library/redis:7
@@ -34,7 +35,7 @@ services:
- redisdata:/data
db:
image: docker.io/library/postgres:16
image: docker.io/library/postgres:15
restart: unless-stopped
volumes:
- pgdata:/var/lib/postgresql/data
@@ -30,6 +30,7 @@
# For more extensive installation and update instructions, refer to the
# documentation.
version: "3.4"
services:
broker:
image: docker.io/library/redis:7
@@ -70,7 +71,7 @@ services:
- "--chromium-allow-list=file:///tmp/.*"
tika:
image: docker.io/apache/tika:latest
image: ghcr.io/paperless-ngx/tika:latest
restart: unless-stopped
volumes:
+1
View File
@@ -23,6 +23,7 @@
# For more extensive installation and update instructions, refer to the
# documentation.
version: "3.4"
services:
broker:
image: docker.io/library/redis:7
+1 -1
View File
@@ -80,7 +80,7 @@ django_checks() {
search_index() {
local -r index_version=9
local -r index_version=8
local -r index_version_file=${DATA_DIR}/.index_version
if [[ (! -f "${index_version_file}") || $(<"${index_version_file}") != "$index_version" ]]; then
-1
View File
@@ -4,7 +4,6 @@ Simple script which attempts to ping the Redis broker as set in the environment
a certain number of times, waiting a little bit in between
"""
import os
import sys
import time
+28 -21
View File
@@ -185,12 +185,34 @@ For PostgreSQL, refer to [Upgrading a PostgreSQL Cluster](https://www.postgresql
For MariaDB, refer to [Upgrading MariaDB](https://mariadb.com/kb/en/upgrading/)
You may also use the exporter and importer with the `--data-only` flag, after creating a new database with the updated version of PostgreSQL or MariaDB.
## Downgrading Paperless {#downgrade-paperless}
!!! warning
Downgrades are possible. However, some updates also contain database
migrations (these change the layout of the database and may move data).
In order to move back from a version that applied database migrations,
you'll have to revert the database migration _before_ downgrading, and
then downgrade paperless.
You should not change any settings, especially paths, when doing this or there is a
risk of data loss
This table lists the compatible versions for each database migration
number.
| Migration number | Version range |
| ---------------- | --------------- |
| 1011 | 1.0.0 |
| 1012 | 1.1.0 - 1.2.1 |
| 1014 | 1.3.0 - 1.3.1 |
| 1016 | 1.3.2 - current |
Execute the following management command to migrate your database:
```shell-session
$ python3 manage.py migrate documents <migration number>
```
!!! note
Some migrations cannot be undone. The command will issue errors if that
happens.
## Management utilities {#management-commands}
@@ -247,7 +269,6 @@ optional arguments:
-sm, --split-manifest
-z, --zip
-zn, --zip-name
--data-only
```
`target` is a folder to which the data gets written. This includes
@@ -306,9 +327,6 @@ If `-z` or `--zip` is provided, the export will be a zip file
in the target directory, named according to the current local date or the
value set in `-zn` or `--zip-name`.
If `--data-only` is provided, only the database will be exported. This option is intended
to facilitate database upgrades without needing to clean documents and thumbnails from the media directory.
!!! warning
If exporting with the file name format, there may be errors due to
@@ -323,30 +341,19 @@ exporter](#exporter) and imports it into paperless.
The importer works just like the exporter. You point it at a directory,
and the script does the rest of the work:
```shell
```
document_importer source
```
| Option | Required | Default | Description |
| ----------- | -------- | ------- | ------------------------------------------------------------------------- |
| source | Yes | N/A | The directory containing an export |
| --data-only | No | False | If provided, only import data, do not import document files or thumbnails |
When you use the provided docker compose script, put the export inside
the `export` folder in your paperless source directory. Specify
`../export` as the `source`.
Note that .zip files (as can be generated from the exporter) are not supported.
!!! note
Importing from a previous version of Paperless may work, but for best
results it is suggested to match the versions.
!!! warning
The importer should be run against a completely empty installation (database and directories) of Paperless-ngx.
### Document retagger {#retagger}
Say you've imported a few hundred documents and now want to introduce a
@@ -573,7 +580,7 @@ Enabling encryption is no longer supported.
Basic usage to disable encryption of your document store:
(Note: If `PAPERLESS_PASSPHRASE` isn't set already, you need to specify
(Note: If [`PAPERLESS_PASSPHRASE`](configuration.md#PAPERLESS_PASSPHRASE) isn't set already, you need to specify
it here)
```
+3 -4
View File
@@ -293,7 +293,7 @@ will create a directory structure as follows:
!!! tip
Paperless checks the filename of a document whenever it is saved. Changing (or deleting)
a [storage path](#storage-paths) will automatically be reflected in the file system. However,
a [storage paths](#storage-paths) will automatically be reflected in the file system. However,
when changing `PAPERLESS_FILENAME_FORMAT` you will need to manually run the
[`document renamer`](administration.md#renamer) to move any existing documents.
@@ -348,7 +348,7 @@ This happens if all the placeholders in a filename evaluate to the same
value.
If there are any errors in the placeholders included in `PAPERLESS_FILENAME_FORMAT`,
paperless will fall back to using the default naming scheme instead.
paperless will fallback to using the default naming scheme instead.
!!! caution
@@ -649,9 +649,8 @@ external authentication solution using one of the following methods:
This is a simple option that uses remote user authentication made available by certain SSO
applications. See the relevant configuration options for more information:
[PAPERLESS_ENABLE_HTTP_REMOTE_USER](configuration.md#PAPERLESS_ENABLE_HTTP_REMOTE_USER),
[PAPERLESS_ENABLE_HTTP_REMOTE_USER](configuration.md#PAPERLESS_ENABLE_HTTP_REMOTE_USER) and
[PAPERLESS_HTTP_REMOTE_USER_HEADER_NAME](configuration.md#PAPERLESS_HTTP_REMOTE_USER_HEADER_NAME)
and [PAPERLESS_LOGOUT_REDIRECT_URL](configuration.md#PAPERLESS_LOGOUT_REDIRECT_URL)
### OpenID Connect and social authentication
+4 -56
View File
@@ -11,7 +11,7 @@ The API provides the following main endpoints:
- `/api/correspondents/`: Full CRUD support.
- `/api/custom_fields/`: Full CRUD support.
- `/api/documents/`: Full CRUD support, except POSTing new documents.
See [below](#file-uploads).
See below.
- `/api/document_types/`: Full CRUD support.
- `/api/groups/`: Full CRUD support.
- `/api/logs/`: Read-Only.
@@ -24,7 +24,6 @@ The API provides the following main endpoints:
- `/api/tasks/`: Read-only.
- `/api/users/`: Full CRUD support.
- `/api/workflows/`: Full CRUD support.
- `/api/search/` GET, see [below](#global-search).
All of these endpoints except for the logging endpoint allow you to
fetch (and edit and delete where appropriate) individual objects by
@@ -141,7 +140,6 @@ 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
@@ -189,38 +187,6 @@ The REST api provides four different forms of authentication.
[configuration](configuration.md#PAPERLESS_ENABLE_HTTP_REMOTE_USER_API)),
you can authenticate against the API using Remote User auth.
## Global search
A global search endpoint is available at `/api/search/` and requires a search term
of > 2 characters e.g. `?query=foo`. This endpoint returns a maximum of 3 results
across nearly all objects, e.g. documents, tags, saved views, mail rules, etc.
Results are only included if the requesting user has the appropriate permissions.
Results are returned in the following format:
```json
{
total: number
documents: []
saved_views: []
correspondents: []
document_types: []
storage_paths: []
tags: []
users: []
groups: []
mail_accounts: []
mail_rules: []
custom_fields: []
workflows: []
}
```
Global search first searches objects by name (or title for documents) matching the query.
If the optional `db_only` parameter is set, only document titles will be searched. Otherwise,
if the amount of documents returned by a simple title string search is < 3, results from the
search index will also be included.
## Searching for documents
Full text searching is available on the `/api/documents/` endpoint. Two
@@ -322,8 +288,6 @@ The endpoint supports the following optional form fields:
- `tags`: Similar to correspondent. Specify this multiple times to
have multiple tags added to the document.
- `archive_serial_number`: An optional archive serial number to set.
- `custom_fields`: An array of custom field ids to assign (with an empty
value) to the document.
The endpoint will immediately return HTTP 200 if the document consumption
process was started successfully, with the UUID of the consumption task
@@ -376,7 +340,7 @@ The API supports various bulk-editing operations which are executed asynchronous
### Documents
For bulk operations on documents, use the endpoint `/api/documents/bulk_edit/` which accepts
For bulk operations on documents, use the endpoint `/api/bulk_edit/` which accepts
a json payload of the format:
```json
@@ -403,31 +367,15 @@ The following methods are supported:
- Requires `parameters`: `{ "add_tags": [LIST_OF_TAG_IDS] }` and / or `{ "remove_tags": [LIST_OF_TAG_IDS] }`
- `delete`
- No `parameters` required
- `reprocess`
- `redo_ocr`
- No `parameters` required
- `set_permissions`
- Requires `parameters`:
- `"set_permissions": PERMISSIONS_OBJ` (see format [above](#permissions)) and / or
- `"permissions": PERMISSIONS_OBJ` (see format [above](#permissions)) and / or
- `"owner": OWNER_ID or null`
- `"merge": true or false` (defaults to false)
- The `merge` flag determines if the supplied permissions will overwrite all existing permissions (including
removing them) or be merged with existing permissions.
- `merge`
- No additional `parameters` required.
- The ordering of the merged document is determined by the list of IDs.
- Optional `parameters`:
- `"metadata_document_id": DOC_ID` apply metadata (tags, correspondent, etc.) from this document to the merged document.
- `split`
- Requires `parameters`:
- `"pages": [..]` The list should be a list of pages and/or a ranges, separated by commas e.g. `"[1,2-3,4,5-7]"`
- The split operation only accepts a single document.
- `rotate`
- Requires `parameters`:
- `"degrees": DEGREES`. Must be an integer i.e. 90, 180, 270
- `delete_pages`
- Requires `parameters`:
- `"pages": [..]` The list should be a list of integers e.g. `"[2,3,4]"`
- The delete_pages operation only accepts a single document.
### Objects
-371
View File
@@ -1,376 +1,5 @@
# Changelog
## paperless-ngx 2.8.6
### Bug Fixes
- Security: disallow API remote-user auth if disabled [@shamoon](https://github.com/shamoon) ([#6739](https://github.com/paperless-ngx/paperless-ngx/pull/6739))
- Fix: retain sort field from global search filtering, use FILTER_HAS_TAGS_ALL [@shamoon](https://github.com/shamoon) ([#6737](https://github.com/paperless-ngx/paperless-ngx/pull/6737))
### All App Changes
<details>
<summary>2 changes</summary>
- Security: disallow API remote-user auth if disabled [@shamoon](https://github.com/shamoon) ([#6739](https://github.com/paperless-ngx/paperless-ngx/pull/6739))
- Fix: retain sort field from global search filtering, use FILTER_HAS_TAGS_ALL [@shamoon](https://github.com/shamoon) ([#6737](https://github.com/paperless-ngx/paperless-ngx/pull/6737))
</details>
## paperless-ngx 2.8.5
### Bug Fixes
- Fix: restore search highlighting on large cards results [@shamoon](https://github.com/shamoon) ([#6728](https://github.com/paperless-ngx/paperless-ngx/pull/6728))
- Fix: global search filtering links broken in 2.8.4 [@shamoon](https://github.com/shamoon) ([#6726](https://github.com/paperless-ngx/paperless-ngx/pull/6726))
- Fix: some buttons incorrectly aligned in 2.8.4 [@shamoon](https://github.com/shamoon) ([#6715](https://github.com/paperless-ngx/paperless-ngx/pull/6715))
- Fix: don't format ASN as number on dashboard [@shamoon](https://github.com/shamoon) ([#6708](https://github.com/paperless-ngx/paperless-ngx/pull/6708))
### All App Changes
<details>
<summary>4 changes</summary>
- Fix: restore search highlighting on large cards results [@shamoon](https://github.com/shamoon) ([#6728](https://github.com/paperless-ngx/paperless-ngx/pull/6728))
- Fix: global search filtering links broken in 2.8.4 [@shamoon](https://github.com/shamoon) ([#6726](https://github.com/paperless-ngx/paperless-ngx/pull/6726))
- Fix: some buttons incorrectly aligned in 2.8.4 [@shamoon](https://github.com/shamoon) ([#6715](https://github.com/paperless-ngx/paperless-ngx/pull/6715))
- Fix: don't format ASN as number on dashboard [@shamoon](https://github.com/shamoon) ([#6708](https://github.com/paperless-ngx/paperless-ngx/pull/6708))
</details>
## paperless-ngx 2.8.4
### Features
- Enhancement: display current ASN in statistics [@darmiel](https://github.com/darmiel) ([#6692](https://github.com/paperless-ngx/paperless-ngx/pull/6692))
- Enhancement: global search tweaks [@shamoon](https://github.com/shamoon) ([#6674](https://github.com/paperless-ngx/paperless-ngx/pull/6674))
### Bug Fixes
- Security: Correctly disable in pdfjs [@shamoon](https://github.com/shamoon) ([#6702](https://github.com/paperless-ngx/paperless-ngx/pull/6702))
- Fix: history timestamp tooltip illegible in dark mode [@shamoon](https://github.com/shamoon) ([#6696](https://github.com/paperless-ngx/paperless-ngx/pull/6696))
- Fix: only count inbox documents from inbox tags with permissions [@shamoon](https://github.com/shamoon) ([#6670](https://github.com/paperless-ngx/paperless-ngx/pull/6670))
### All App Changes
<details>
<summary>5 changes</summary>
- Enhancement: global search tweaks [@shamoon](https://github.com/shamoon) ([#6674](https://github.com/paperless-ngx/paperless-ngx/pull/6674))
- Security: Correctly disable in pdfjs [@shamoon](https://github.com/shamoon) ([#6702](https://github.com/paperless-ngx/paperless-ngx/pull/6702))
- Fix: history timestamp tooltip illegible in dark mode [@shamoon](https://github.com/shamoon) ([#6696](https://github.com/paperless-ngx/paperless-ngx/pull/6696))
- Enhancement: display current ASN in statistics [@darmiel](https://github.com/darmiel) ([#6692](https://github.com/paperless-ngx/paperless-ngx/pull/6692))
- Fix: only count inbox documents from inbox tags with permissions [@shamoon](https://github.com/shamoon) ([#6670](https://github.com/paperless-ngx/paperless-ngx/pull/6670))
</details>
## paperless-ngx 2.8.3
### Bug Fixes
- Fix: respect superuser for document history [@shamoon](https://github.com/shamoon) ([#6661](https://github.com/paperless-ngx/paperless-ngx/pull/6661))
- Fix: allow 0 in monetary field [@shamoon](https://github.com/shamoon) ([#6658](https://github.com/paperless-ngx/paperless-ngx/pull/6658))
- Fix: custom field removal doesn't always trigger change detection [@shamoon](https://github.com/shamoon) ([#6653](https://github.com/paperless-ngx/paperless-ngx/pull/6653))
- Fix: Downgrade and lock lxml [@stumpylog](https://github.com/stumpylog) ([#6655](https://github.com/paperless-ngx/paperless-ngx/pull/6655))
- Fix: correctly handle global search esc key when open and button foucsed [@shamoon](https://github.com/shamoon) ([#6644](https://github.com/paperless-ngx/paperless-ngx/pull/6644))
- Fix: consistent monetary field display in list and cards [@shamoon](https://github.com/shamoon) ([#6645](https://github.com/paperless-ngx/paperless-ngx/pull/6645))
- Fix: doc links and more illegible in light mode [@shamoon](https://github.com/shamoon) ([#6643](https://github.com/paperless-ngx/paperless-ngx/pull/6643))
- Fix: Allow auditlog to be disabled [@stumpylog](https://github.com/stumpylog) ([#6638](https://github.com/paperless-ngx/paperless-ngx/pull/6638))
### Documentation
- Chore(docs): Update the sample Compose file to latest database [@stumpylog](https://github.com/stumpylog) ([#6639](https://github.com/paperless-ngx/paperless-ngx/pull/6639))
### All App Changes
<details>
<summary>7 changes</summary>
- Fix: respect superuser for document history [@shamoon](https://github.com/shamoon) ([#6661](https://github.com/paperless-ngx/paperless-ngx/pull/6661))
- Fix: allow 0 in monetary field [@shamoon](https://github.com/shamoon) ([#6658](https://github.com/paperless-ngx/paperless-ngx/pull/6658))
- Fix: custom field removal doesn't always trigger change detection [@shamoon](https://github.com/shamoon) ([#6653](https://github.com/paperless-ngx/paperless-ngx/pull/6653))
- Fix: correctly handle global search esc key when open and button foucsed [@shamoon](https://github.com/shamoon) ([#6644](https://github.com/paperless-ngx/paperless-ngx/pull/6644))
- Fix: consistent monetary field display in list and cards [@shamoon](https://github.com/shamoon) ([#6645](https://github.com/paperless-ngx/paperless-ngx/pull/6645))
- Fix: doc links and more illegible in light mode [@shamoon](https://github.com/shamoon) ([#6643](https://github.com/paperless-ngx/paperless-ngx/pull/6643))
- Fix: Allow auditlog to be disabled [@stumpylog](https://github.com/stumpylog) ([#6638](https://github.com/paperless-ngx/paperless-ngx/pull/6638))
</details>
## paperless-ngx 2.8.2
### Bug Fixes
- Fix: Restore the compression of static files for x86_64 [@stumpylog](https://github.com/stumpylog) ([#6627](https://github.com/paperless-ngx/paperless-ngx/pull/6627))
- Fix: make backend monetary validation accept unpadded decimals [@shamoon](https://github.com/shamoon) ([#6626](https://github.com/paperless-ngx/paperless-ngx/pull/6626))
- Fix: allow bulk edit with existing fields [@shamoon](https://github.com/shamoon) ([#6625](https://github.com/paperless-ngx/paperless-ngx/pull/6625))
- Fix: table view doesn't immediately display custom fields on app startup [@shamoon](https://github.com/shamoon) ([#6600](https://github.com/paperless-ngx/paperless-ngx/pull/6600))
- Fix: dont use limit in subqueries in global search for mariadb compatibility [@shamoon](https://github.com/shamoon) ([#6611](https://github.com/paperless-ngx/paperless-ngx/pull/6611))
- Fix: exclude admin perms from group permissions serializer [@shamoon](https://github.com/shamoon) ([#6608](https://github.com/paperless-ngx/paperless-ngx/pull/6608))
- Fix: global search text illegible in light mode [@shamoon](https://github.com/shamoon) ([#6602](https://github.com/paperless-ngx/paperless-ngx/pull/6602))
- Fix: document history text color illegible in light mode [@shamoon](https://github.com/shamoon) ([#6601](https://github.com/paperless-ngx/paperless-ngx/pull/6601))
### All App Changes
<details>
<summary>10 changes</summary>
- Fix: Restore the compression of static files for x86_64 [@stumpylog](https://github.com/stumpylog) ([#6627](https://github.com/paperless-ngx/paperless-ngx/pull/6627))
- Fix: make backend monetary validation accept unpadded decimals [@shamoon](https://github.com/shamoon) ([#6626](https://github.com/paperless-ngx/paperless-ngx/pull/6626))
- Fix: allow bulk edit with existing fields [@shamoon](https://github.com/shamoon) ([#6625](https://github.com/paperless-ngx/paperless-ngx/pull/6625))
- Enhancement: show custom field name on cards if empty, add tooltip [@shamoon](https://github.com/shamoon) ([#6620](https://github.com/paperless-ngx/paperless-ngx/pull/6620))
- Security: Disable in pdfjs [@shamoon](https://github.com/shamoon) ([#6615](https://github.com/paperless-ngx/paperless-ngx/pull/6615))
- Fix: table view doesn't immediately display custom fields on app startup [@shamoon](https://github.com/shamoon) ([#6600](https://github.com/paperless-ngx/paperless-ngx/pull/6600))
- Fix: dont use limit in subqueries in global search for mariadb compatibility [@shamoon](https://github.com/shamoon) ([#6611](https://github.com/paperless-ngx/paperless-ngx/pull/6611))
- Fix: exclude admin perms from group permissions serializer [@shamoon](https://github.com/shamoon) ([#6608](https://github.com/paperless-ngx/paperless-ngx/pull/6608))
- Fix: global search text illegible in light mode [@shamoon](https://github.com/shamoon) ([#6602](https://github.com/paperless-ngx/paperless-ngx/pull/6602))
- Fix: document history text color illegible in light mode [@shamoon](https://github.com/shamoon) ([#6601](https://github.com/paperless-ngx/paperless-ngx/pull/6601))
</details>
## paperless-ngx 2.8.1
### Bug Fixes
- Fix: saved views dont immediately display custom fields in table view [@shamoon](https://github.com/shamoon) ([#6594](https://github.com/paperless-ngx/paperless-ngx/pull/6594))
- Fix: bulk edit custom fields should support multiple items [@shamoon](https://github.com/shamoon) ([#6589](https://github.com/paperless-ngx/paperless-ngx/pull/6589))
### Dependencies
- Chore(deps-dev): Bump jinja2 from 3.1.3 to 3.1.4 [@dependabot](https://github.com/dependabot) ([#6579](https://github.com/paperless-ngx/paperless-ngx/pull/6579))
- Chore(deps-dev): Bump mkdocs-glightbox from 0.3.7 to 0.4.0 in the small-changes group [@dependabot](https://github.com/dependabot) ([#6581](https://github.com/paperless-ngx/paperless-ngx/pull/6581))
### All App Changes
<details>
<summary>3 changes</summary>
- Fix: saved views dont immediately display custom fields in table view [@shamoon](https://github.com/shamoon) ([#6594](https://github.com/paperless-ngx/paperless-ngx/pull/6594))
- Chore(deps-dev): Bump mkdocs-glightbox from 0.3.7 to 0.4.0 in the small-changes group [@dependabot](https://github.com/dependabot) ([#6581](https://github.com/paperless-ngx/paperless-ngx/pull/6581))
- Fix: bulk edit custom fields should support multiple items [@shamoon](https://github.com/shamoon) ([#6589](https://github.com/paperless-ngx/paperless-ngx/pull/6589))
</details>
## paperless-ngx 2.8.0
### Breaking Changes
- Fix: remove admin.logentry perm, use admin (staff) status [@shamoon](https://github.com/shamoon) ([#6380](https://github.com/paperless-ngx/paperless-ngx/pull/6380))
### Notable Changes
- Feature: global search, keyboard shortcuts / hotkey support [@shamoon](https://github.com/shamoon) ([#6449](https://github.com/paperless-ngx/paperless-ngx/pull/6449))
- Feature: custom fields filtering \& bulk editing [@shamoon](https://github.com/shamoon) ([#6484](https://github.com/paperless-ngx/paperless-ngx/pull/6484))
- Feature: customizable fields display for documents, saved views \& dashboard widgets [@shamoon](https://github.com/shamoon) ([#6439](https://github.com/paperless-ngx/paperless-ngx/pull/6439))
- Feature: document history (audit log UI) [@shamoon](https://github.com/shamoon) ([#6388](https://github.com/paperless-ngx/paperless-ngx/pull/6388))
- Chore: Convert the consumer to a plugin [@stumpylog](https://github.com/stumpylog) ([#6361](https://github.com/paperless-ngx/paperless-ngx/pull/6361))
### Features
- Feature: global search, keyboard shortcuts / hotkey support [@shamoon](https://github.com/shamoon) ([#6449](https://github.com/paperless-ngx/paperless-ngx/pull/6449))
- Feature: customizable fields display for documents, saved views \& dashboard widgets [@shamoon](https://github.com/shamoon) ([#6439](https://github.com/paperless-ngx/paperless-ngx/pull/6439))
- Feature: document history (audit log UI) [@shamoon](https://github.com/shamoon) ([#6388](https://github.com/paperless-ngx/paperless-ngx/pull/6388))
- Enhancement: refactor monetary field [@shamoon](https://github.com/shamoon) ([#6370](https://github.com/paperless-ngx/paperless-ngx/pull/6370))
- Chore: Convert the consumer to a plugin [@stumpylog](https://github.com/stumpylog) ([#6361](https://github.com/paperless-ngx/paperless-ngx/pull/6361))
### Bug Fixes
- Fix: always check workflow if set [@shamoon](https://github.com/shamoon) ([#6474](https://github.com/paperless-ngx/paperless-ngx/pull/6474))
- Fix: use responsive tables for management lists [@DlieBG](https://github.com/DlieBG) ([#6460](https://github.com/paperless-ngx/paperless-ngx/pull/6460))
- Fix: password reset done template [@shamoon](https://github.com/shamoon) ([#6444](https://github.com/paperless-ngx/paperless-ngx/pull/6444))
- Fix: show message on empty group list [@DlieBG](https://github.com/DlieBG) ([#6393](https://github.com/paperless-ngx/paperless-ngx/pull/6393))
- Fix: remove admin.logentry perm, use admin (staff) status [@shamoon](https://github.com/shamoon) ([#6380](https://github.com/paperless-ngx/paperless-ngx/pull/6380))
- Fix: dont dismiss active alerts on dismiss completed [@shamoon](https://github.com/shamoon) ([#6364](https://github.com/paperless-ngx/paperless-ngx/pull/6364))
- Fix: Allow lowercase letters in monetary currency code field [@shamoon](https://github.com/shamoon) ([#6359](https://github.com/paperless-ngx/paperless-ngx/pull/6359))
- Fix: Allow negative monetary values with a current code [@stumpylog](https://github.com/stumpylog) ([#6358](https://github.com/paperless-ngx/paperless-ngx/pull/6358))
- Fix: add timezone fallback to install script [@Harald-Berghoff](https://github.com/Harald-Berghoff) ([#6336](https://github.com/paperless-ngx/paperless-ngx/pull/6336))
### Maintenance
- Chore(deps): Bump stumpylog/image-cleaner-action from 0.5.0 to 0.6.0 in the actions group [@dependabot](https://github.com/dependabot) ([#6541](https://github.com/paperless-ngx/paperless-ngx/pull/6541))
- Chore(deps): Bump all allowed backend packages [@stumpylog](https://github.com/stumpylog) ([#6562](https://github.com/paperless-ngx/paperless-ngx/pull/6562))
### Dependencies
<details>
<summary>10 changes</summary>
- Chore(deps): Bump stumpylog/image-cleaner-action from 0.5.0 to 0.6.0 in the actions group [@dependabot](https://github.com/dependabot) ([#6541](https://github.com/paperless-ngx/paperless-ngx/pull/6541))
- Chore(deps-dev): Bump ejs from 3.1.9 to 3.1.10 in /src-ui [@dependabot](https://github.com/dependabot) ([#6540](https://github.com/paperless-ngx/paperless-ngx/pull/6540))
- Chore(deps): Bump the frontend-angular-dependencies group in /src-ui with 13 updates [@dependabot](https://github.com/dependabot) ([#6539](https://github.com/paperless-ngx/paperless-ngx/pull/6539))
- Chore(deps): Bump python-ipware from 2.0.3 to 3.0.0 in the major-versions group [@dependabot](https://github.com/dependabot) ([#6468](https://github.com/paperless-ngx/paperless-ngx/pull/6468))
- Chore(deps-dev): Bump the development group with 2 updates [@dependabot](https://github.com/dependabot) ([#6466](https://github.com/paperless-ngx/paperless-ngx/pull/6466))
- Chore: Updates Docker bundled QPDF to 11.9.0 [@stumpylog](https://github.com/stumpylog) ([#6423](https://github.com/paperless-ngx/paperless-ngx/pull/6423))
- Chore(deps): Bump gunicorn from 21.2.0 to 22.0.0 [@dependabot](https://github.com/dependabot) ([#6416](https://github.com/paperless-ngx/paperless-ngx/pull/6416))
- Chore(deps): Bump the small-changes group with 11 updates [@dependabot](https://github.com/dependabot) ([#6405](https://github.com/paperless-ngx/paperless-ngx/pull/6405))
- Chore(deps): Bump idna from 3.6 to 3.7 [@dependabot](https://github.com/dependabot) ([#6377](https://github.com/paperless-ngx/paperless-ngx/pull/6377))
- Chore(deps): Bump tar from 6.2.0 to 6.2.1 in /src-ui [@dependabot](https://github.com/dependabot) ([#6373](https://github.com/paperless-ngx/paperless-ngx/pull/6373))
</details>
### All App Changes
<details>
<summary>23 changes</summary>
- Feature: global search, keyboard shortcuts / hotkey support [@shamoon](https://github.com/shamoon) ([#6449](https://github.com/paperless-ngx/paperless-ngx/pull/6449))
- Chore(deps-dev): Bump ejs from 3.1.9 to 3.1.10 in /src-ui [@dependabot](https://github.com/dependabot) ([#6540](https://github.com/paperless-ngx/paperless-ngx/pull/6540))
- Chore(deps): Bump the frontend-angular-dependencies group in /src-ui with 13 updates [@dependabot](https://github.com/dependabot) ([#6539](https://github.com/paperless-ngx/paperless-ngx/pull/6539))
- Chore: Hand craft SQL queries [@stumpylog](https://github.com/stumpylog) ([#6489](https://github.com/paperless-ngx/paperless-ngx/pull/6489))
- Feature: custom fields filtering \& bulk editing [@shamoon](https://github.com/shamoon) ([#6484](https://github.com/paperless-ngx/paperless-ngx/pull/6484))
- Feature: customizable fields display for documents, saved views \& dashboard widgets [@shamoon](https://github.com/shamoon) ([#6439](https://github.com/paperless-ngx/paperless-ngx/pull/6439))
- Chore(deps): Bump python-ipware from 2.0.3 to 3.0.0 in the major-versions group [@dependabot](https://github.com/dependabot) ([#6468](https://github.com/paperless-ngx/paperless-ngx/pull/6468))
- Feature: document history (audit log UI) [@shamoon](https://github.com/shamoon) ([#6388](https://github.com/paperless-ngx/paperless-ngx/pull/6388))
- Chore(deps-dev): Bump the development group with 2 updates [@dependabot](https://github.com/dependabot) ([#6466](https://github.com/paperless-ngx/paperless-ngx/pull/6466))
- Fix: always check workflow if set [@shamoon](https://github.com/shamoon) ([#6474](https://github.com/paperless-ngx/paperless-ngx/pull/6474))
- Fix: use responsive tables for management lists [@DlieBG](https://github.com/DlieBG) ([#6460](https://github.com/paperless-ngx/paperless-ngx/pull/6460))
- Fix: password reset done template [@shamoon](https://github.com/shamoon) ([#6444](https://github.com/paperless-ngx/paperless-ngx/pull/6444))
- Enhancement: refactor monetary field [@shamoon](https://github.com/shamoon) ([#6370](https://github.com/paperless-ngx/paperless-ngx/pull/6370))
- Enhancement: improve layout, button labels for custom fields dropdown [@shamoon](https://github.com/shamoon) ([#6362](https://github.com/paperless-ngx/paperless-ngx/pull/6362))
- Chore: Convert the consumer to a plugin [@stumpylog](https://github.com/stumpylog) ([#6361](https://github.com/paperless-ngx/paperless-ngx/pull/6361))
- Chore(deps): Bump the small-changes group with 11 updates [@dependabot](https://github.com/dependabot) ([#6405](https://github.com/paperless-ngx/paperless-ngx/pull/6405))
- Enhancement: Hide columns in document list if user does not have permissions [@theomega](https://github.com/theomega) ([#6415](https://github.com/paperless-ngx/paperless-ngx/pull/6415))
- Fix: show message on empty group list [@DlieBG](https://github.com/DlieBG) ([#6393](https://github.com/paperless-ngx/paperless-ngx/pull/6393))
- Fix: remove admin.logentry perm, use admin (staff) status [@shamoon](https://github.com/shamoon) ([#6380](https://github.com/paperless-ngx/paperless-ngx/pull/6380))
- Chore(deps): Bump tar from 6.2.0 to 6.2.1 in /src-ui [@dependabot](https://github.com/dependabot) ([#6373](https://github.com/paperless-ngx/paperless-ngx/pull/6373))
- Fix: dont dismiss active alerts on dismiss completed [@shamoon](https://github.com/shamoon) ([#6364](https://github.com/paperless-ngx/paperless-ngx/pull/6364))
- Fix: Allow lowercase letters in monetary currency code field [@shamoon](https://github.com/shamoon) ([#6359](https://github.com/paperless-ngx/paperless-ngx/pull/6359))
- Fix: Allow negative monetary values with a current code [@stumpylog](https://github.com/stumpylog) ([#6358](https://github.com/paperless-ngx/paperless-ngx/pull/6358))
</details>
## paperless-ngx 2.7.2
### Bug Fixes
- Fix: select dropdown background colors not visible in light mode [@shamoon](https://github.com/shamoon) ([#6323](https://github.com/paperless-ngx/paperless-ngx/pull/6323))
- Fix: spacing in reset and incorrect display in saved views [@shamoon](https://github.com/shamoon) ([#6324](https://github.com/paperless-ngx/paperless-ngx/pull/6324))
- Fix: disable invalid create endpoints [@shamoon](https://github.com/shamoon) ([#6320](https://github.com/paperless-ngx/paperless-ngx/pull/6320))
- Fix: dont initialize page numbers, allow split with browser pdf viewer [@shamoon](https://github.com/shamoon) ([#6314](https://github.com/paperless-ngx/paperless-ngx/pull/6314))
### All App Changes
<details>
<summary>4 changes</summary>
- Fix: select dropdown background colors not visible in light mode [@shamoon](https://github.com/shamoon) ([#6323](https://github.com/paperless-ngx/paperless-ngx/pull/6323))
- Fix: spacing in reset and incorrect display in saved views [@shamoon](https://github.com/shamoon) ([#6324](https://github.com/paperless-ngx/paperless-ngx/pull/6324))
- Fix: disable invalid create endpoints [@shamoon](https://github.com/shamoon) ([#6320](https://github.com/paperless-ngx/paperless-ngx/pull/6320))
- Fix: dont initialize page numbers, allow split with browser pdf viewer [@shamoon](https://github.com/shamoon) ([#6314](https://github.com/paperless-ngx/paperless-ngx/pull/6314))
</details>
## paperless-ngx 2.7.1
### Bug Fixes
- Fix: Only disable split button if pages = 1 [@shamoon](https://github.com/shamoon) ([#6304](https://github.com/paperless-ngx/paperless-ngx/pull/6304))
- Fix: Use correct custom field id when splitting [@shamoon](https://github.com/shamoon) ([#6303](https://github.com/paperless-ngx/paperless-ngx/pull/6303))
- Fix: Rotation fails due to celery chord [@stumpylog](https://github.com/stumpylog) ([#6306](https://github.com/paperless-ngx/paperless-ngx/pull/6306))
- Fix: split user / group objects error [@shamoon](https://github.com/shamoon) ([#6302](https://github.com/paperless-ngx/paperless-ngx/pull/6302))
### All App Changes
<details>
<summary>4 changes</summary>
- Fix: Only disable split button if pages = 1 [@shamoon](https://github.com/shamoon) ([#6304](https://github.com/paperless-ngx/paperless-ngx/pull/6304))
- Fix: Use correct custom field id when splitting [@shamoon](https://github.com/shamoon) ([#6303](https://github.com/paperless-ngx/paperless-ngx/pull/6303))
- Fix: Rotation fails due to celery chord [@stumpylog](https://github.com/stumpylog) ([#6306](https://github.com/paperless-ngx/paperless-ngx/pull/6306))
- Fix: split user / group objects error [@shamoon](https://github.com/shamoon) ([#6302](https://github.com/paperless-ngx/paperless-ngx/pull/6302))
</details>
## paperless-ngx 2.7.0
### Notable Changes
- Feature: PDF actions - merge, split \& rotate @shamoon ([#6094](https://github.com/paperless-ngx/paperless-ngx/pull/6094))
- Change: enable auditlog by default, fix import / export @shamoon ([#6267](https://github.com/paperless-ngx/paperless-ngx/pull/6267))
### Enhancements
- Enhancement: always place search term first in autocomplete results @shamoon ([#6142](https://github.com/paperless-ngx/paperless-ngx/pull/6142))
### Maintenance
- Chore: Standardize subprocess running and logging [@stumpylog](https://github.com/stumpylog) ([#6275](https://github.com/paperless-ngx/paperless-ngx/pull/6275))
### Bug Fixes
- Fix: Escape the secret key when writing it to the env file [@stumpylog](https://github.com/stumpylog) ([#6243](https://github.com/paperless-ngx/paperless-ngx/pull/6243))
- Fix: Hide sidebar labels if group is empty [@shamoon](https://github.com/shamoon) ([#6254](https://github.com/paperless-ngx/paperless-ngx/pull/6254))
- Fix: management list clear all should clear header checkbox [@shamoon](https://github.com/shamoon) ([#6253](https://github.com/paperless-ngx/paperless-ngx/pull/6253))
- Fix: start-align object names in some UI lists [@shamoon](https://github.com/shamoon) ([#6188](https://github.com/paperless-ngx/paperless-ngx/pull/6188))
- Fix: allow scroll long upload files alerts list [@shamoon](https://github.com/shamoon) ([#6184](https://github.com/paperless-ngx/paperless-ngx/pull/6184))
- Fix: document_renamer fails with audit_log enabled [@shamoon](https://github.com/shamoon) ([#6175](https://github.com/paperless-ngx/paperless-ngx/pull/6175))
- Fix: catch sessionStorage errors for large documents [@shamoon](https://github.com/shamoon) ([#6150](https://github.com/paperless-ngx/paperless-ngx/pull/6150))
### Dependencies
<details>
<summary>9 changes</summary>
- Chore(deps): Bump pillow from 10.2.0 to 10.3.0 [@dependabot](https://github.com/dependabot) ([#6268](https://github.com/paperless-ngx/paperless-ngx/pull/6268))
- Chore(deps-dev): Bump the development group with 2 updates [@dependabot](https://github.com/dependabot) ([#6276](https://github.com/paperless-ngx/paperless-ngx/pull/6276))
- Chore(deps): Bump the frontend-angular-dependencies group in /src-ui with 17 updates [@dependabot](https://github.com/dependabot) ([#6248](https://github.com/paperless-ngx/paperless-ngx/pull/6248))
- Chore(deps-dev): Bump [@<!---->playwright/test from 1.42.0 to 1.42.1 in /src-ui @dependabot](https://github.com/<!---->playwright/test from 1.42.0 to 1.42.1 in /src-ui @dependabot) ([#6250](https://github.com/paperless-ngx/paperless-ngx/pull/6250))
- Chore(deps-dev): Bump [@<!---->types/node from 20.11.24 to 20.12.2 in /src-ui @dependabot](https://github.com/<!---->types/node from 20.11.24 to 20.12.2 in /src-ui @dependabot) ([#6251](https://github.com/paperless-ngx/paperless-ngx/pull/6251))
- Chore(deps-dev): Bump the frontend-eslint-dependencies group in /src-ui with 2 updates [@dependabot](https://github.com/dependabot) ([#6249](https://github.com/paperless-ngx/paperless-ngx/pull/6249))
- Chore(deps-dev): Bump express from 4.18.3 to 4.19.2 in /src-ui [@dependabot](https://github.com/dependabot) ([#6207](https://github.com/paperless-ngx/paperless-ngx/pull/6207))
- Chore(deps-dev): Bump webpack-dev-middleware from 5.3.3 to 5.3.4 in /src-ui [@dependabot](https://github.com/dependabot) ([#6161](https://github.com/paperless-ngx/paperless-ngx/pull/6161))
- Chore(deps-dev): Bump the development group with 4 updates [@dependabot](https://github.com/dependabot) ([#6131](https://github.com/paperless-ngx/paperless-ngx/pull/6131))
</details>
### All App Changes
<details>
<summary>20 changes</summary>
- Chore(deps-dev): Bump the development group with 2 updates [@dependabot](https://github.com/dependabot) ([#6276](https://github.com/paperless-ngx/paperless-ngx/pull/6276))
- Chore: Standardize subprocess running and logging [@stumpylog](https://github.com/stumpylog) ([#6275](https://github.com/paperless-ngx/paperless-ngx/pull/6275))
- Change: enable auditlog by default, fix import / export [@shamoon](https://github.com/shamoon) ([#6267](https://github.com/paperless-ngx/paperless-ngx/pull/6267))
- Fix: Hide sidebar labels if group is empty [@shamoon](https://github.com/shamoon) ([#6254](https://github.com/paperless-ngx/paperless-ngx/pull/6254))
- Fix: management list clear all should clear header checkbox [@shamoon](https://github.com/shamoon) ([#6253](https://github.com/paperless-ngx/paperless-ngx/pull/6253))
- Chore(deps): Bump the frontend-angular-dependencies group in /src-ui with 17 updates [@dependabot](https://github.com/dependabot) ([#6248](https://github.com/paperless-ngx/paperless-ngx/pull/6248))
- Chore(deps-dev): Bump [@<!---->playwright/test from 1.42.0 to 1.42.1 in /src-ui @dependabot](https://github.com/<!---->playwright/test from 1.42.0 to 1.42.1 in /src-ui @dependabot) ([#6250](https://github.com/paperless-ngx/paperless-ngx/pull/6250))
- Chore(deps-dev): Bump [@<!---->types/node from 20.11.24 to 20.12.2 in /src-ui @dependabot](https://github.com/<!---->types/node from 20.11.24 to 20.12.2 in /src-ui @dependabot) ([#6251](https://github.com/paperless-ngx/paperless-ngx/pull/6251))
- Chore(deps-dev): Bump the frontend-eslint-dependencies group in /src-ui with 2 updates [@dependabot](https://github.com/dependabot) ([#6249](https://github.com/paperless-ngx/paperless-ngx/pull/6249))
- Enhancement: support custom fields in post_document endpoint [@shamoon](https://github.com/shamoon) ([#6222](https://github.com/paperless-ngx/paperless-ngx/pull/6222))
- Enhancement: add ASN to consume rejection message [@eliasp](https://github.com/eliasp) ([#6217](https://github.com/paperless-ngx/paperless-ngx/pull/6217))
- Chore(deps-dev): Bump express from 4.18.3 to 4.19.2 in /src-ui [@dependabot](https://github.com/dependabot) ([#6207](https://github.com/paperless-ngx/paperless-ngx/pull/6207))
- Feature: PDF actions - merge, split \& rotate [@shamoon](https://github.com/shamoon) ([#6094](https://github.com/paperless-ngx/paperless-ngx/pull/6094))
- Fix: start-align object names in some UI lists [@shamoon](https://github.com/shamoon) ([#6188](https://github.com/paperless-ngx/paperless-ngx/pull/6188))
- Fix: allow scroll long upload files alerts list [@shamoon](https://github.com/shamoon) ([#6184](https://github.com/paperless-ngx/paperless-ngx/pull/6184))
- Fix: document_renamer fails with audit_log enabled [@shamoon](https://github.com/shamoon) ([#6175](https://github.com/paperless-ngx/paperless-ngx/pull/6175))
- Chore(deps-dev): Bump webpack-dev-middleware from 5.3.3 to 5.3.4 in /src-ui [@dependabot](https://github.com/dependabot) ([#6161](https://github.com/paperless-ngx/paperless-ngx/pull/6161))
- Enhancement: always place search term first in autocomplete results [@shamoon](https://github.com/shamoon) ([#6142](https://github.com/paperless-ngx/paperless-ngx/pull/6142))
- Fix: catch sessionStorage errors for large documents [@shamoon](https://github.com/shamoon) ([#6150](https://github.com/paperless-ngx/paperless-ngx/pull/6150))
- Chore(deps-dev): Bump the development group with 4 updates [@dependabot](https://github.com/dependabot) ([#6131](https://github.com/paperless-ngx/paperless-ngx/pull/6131))
</details>
## paperless-ngx 2.6.3
### Bug Fixes
- Fix: allow setting allauth [@shamoon](https://github.com/shamoon) ([#6105](https://github.com/paperless-ngx/paperless-ngx/pull/6105))
- Change: dont require empty bulk edit parameters [@shamoon](https://github.com/shamoon) ([#6059](https://github.com/paperless-ngx/paperless-ngx/pull/6059))
### Dependencies
<details>
<summary>4 changes</summary>
- Chore(deps-dev): Bump follow-redirects from 1.15.5 to 1.15.6 in /src-ui [@dependabot](https://github.com/dependabot) ([#6120](https://github.com/paperless-ngx/paperless-ngx/pull/6120))
- Chore(deps-dev): Bump the development group with 3 updates [@dependabot](https://github.com/dependabot) ([#6079](https://github.com/paperless-ngx/paperless-ngx/pull/6079))
- Chore(deps): Bump the django group with 1 update [@dependabot](https://github.com/dependabot) ([#6080](https://github.com/paperless-ngx/paperless-ngx/pull/6080))
- Chore(deps): Bump the small-changes group with 2 updates [@dependabot](https://github.com/dependabot) ([#6081](https://github.com/paperless-ngx/paperless-ngx/pull/6081))
</details>
### All App Changes
<details>
<summary>8 changes</summary>
- Chore(deps-dev): Bump follow-redirects from 1.15.5 to 1.15.6 in /src-ui [@dependabot](https://github.com/dependabot) ([#6120](https://github.com/paperless-ngx/paperless-ngx/pull/6120))
- Fix: allow setting allauth [@shamoon](https://github.com/shamoon) ([#6105](https://github.com/paperless-ngx/paperless-ngx/pull/6105))
- Change: remove credentials from redis url in system status [@shamoon](https://github.com/shamoon) ([#6104](https://github.com/paperless-ngx/paperless-ngx/pull/6104))
- Chore(deps-dev): Bump the development group with 3 updates [@dependabot](https://github.com/dependabot) ([#6079](https://github.com/paperless-ngx/paperless-ngx/pull/6079))
- Chore(deps): Bump the django group with 1 update [@dependabot](https://github.com/dependabot) ([#6080](https://github.com/paperless-ngx/paperless-ngx/pull/6080))
- Chore(deps): Bump the small-changes group with 2 updates [@dependabot](https://github.com/dependabot) ([#6081](https://github.com/paperless-ngx/paperless-ngx/pull/6081))
- Change: dont require empty bulk edit parameters [@shamoon](https://github.com/shamoon) ([#6059](https://github.com/paperless-ngx/paperless-ngx/pull/6059))
- Fix: missing translation string [@DimitriDR](https://github.com/DimitriDR) ([#6054](https://github.com/paperless-ngx/paperless-ngx/pull/6054))
</details>
## paperless-ngx 2.6.2
### Features
+20 -32
View File
@@ -177,13 +177,13 @@ configure their endpoints, and enable the feature.
#### [`PAPERLESS_TIKA_ENDPOINT=<url>`](#PAPERLESS_TIKA_ENDPOINT) {#PAPERLESS_TIKA_ENDPOINT}
: Set the endpoint URL where Paperless can reach your Tika server.
: Set the endpoint URL were Paperless can reach your Tika server.
Defaults to "<http://localhost:9998>".
#### [`PAPERLESS_TIKA_GOTENBERG_ENDPOINT=<url>`](#PAPERLESS_TIKA_GOTENBERG_ENDPOINT) {#PAPERLESS_TIKA_GOTENBERG_ENDPOINT}
: Set the endpoint URL where Paperless can reach your Gotenberg server.
: Set the endpoint URL were Paperless can reach your Gotenberg server.
Defaults to "<http://localhost:3000>".
@@ -202,7 +202,7 @@ and watch out for indentation if editing the YAML file.
#### [`PAPERLESS_CONSUMPTION_DIR=<path>`](#PAPERLESS_CONSUMPTION_DIR) {#PAPERLESS_CONSUMPTION_DIR}
: This is where your documents should go to be consumed. Make sure that
: This where your documents should go to be consumed. Make sure that
it exists and that the user running the paperless service can
read/write its contents before you start Paperless.
@@ -288,12 +288,6 @@ this folder is no longer needed and can be removed manually.
Defaults to `/usr/share/nltk_data`
#### [`PAPERLESS_MODEL_FILE=<path>`](#PAPERLESS_MODEL_FILE) {#PAPERLESS_MODEL_FILE}
: This is where paperless will store the classification model.
Defaults to `PAPERLESS_DATA_DIR/classification_model.pickle`.
## Logging
#### [`PAPERLESS_LOGROTATE_MAX_SIZE=<num>`](#PAPERLESS_LOGROTATE_MAX_SIZE) {#PAPERLESS_LOGROTATE_MAX_SIZE}
@@ -497,9 +491,8 @@ followed by the normalized actual header name.
#### [`PAPERLESS_LOGOUT_REDIRECT_URL=<str>`](#PAPERLESS_LOGOUT_REDIRECT_URL) {#PAPERLESS_LOGOUT_REDIRECT_URL}
: URL to redirect the user to after a logout. This can be used
together with PAPERLESS_ENABLE_HTTP_REMOTE_USER and SSO to
redirect the user back to the SSO application's logout page to
complete the logout process.
together with PAPERLESS_ENABLE_HTTP_REMOTE_USER to
redirect the user back to the SSO application's logout page.
Defaults to None, which disables this feature.
@@ -596,11 +589,6 @@ system. See the corresponding
Defaults to False
#### [`PAPERLESS_ACCOUNT_SESSION_REMEMBER=<bool>`](#PAPERLESS_ACCOUNT_SESSION_REMEMBER) {#PAPERLESS_ACCOUNT_SESSION_REMEMBER}
: See the corresponding
[django-allauth documentation](https://docs.allauth.org/en/latest/account/configuration.html)
## OCR settings {#ocr}
Paperless uses [OCRmyPDF](https://ocrmypdf.readthedocs.io/en/latest/)
@@ -622,8 +610,6 @@ parsing documents.
Keep in mind that Tesseract uses much more CPU time with multiple
languages enabled.
If you are including languages that are not installed by default, you will need to also set [`PAPERLESS_OCR_LANGUAGES`](configuration.md#PAPERLESS_OCR_LANGUAGES) for docker deployments or install the tesseract language packages manually for bare metal installations.
Defaults to "eng".
!!! note
@@ -1094,7 +1080,7 @@ document text will be checked as normal.
: Paperless searches an entire document for dates. The first date
found will be used as the initial value for the created date. When
this variable is greater than 0 (or left to its default value),
this variable is greater than 0 (or left to it's default value),
paperless will also suggest other dates found in the document, up to
a maximum of this setting. Note that duplicates will be removed,
which can result in fewer dates displayed in the frontend than this
@@ -1119,11 +1105,11 @@ This font can be changed here.
#### [`PAPERLESS_IGNORE_DATES=<string>`](#PAPERLESS_IGNORE_DATES) {#PAPERLESS_IGNORE_DATES}
: Paperless parses a document's creation date from filename and file
: Paperless parses a documents creation date from filename and file
content. You may specify a comma separated list of dates that should
be ignored during this process. This is useful for special dates
(like date of birth) that appear in documents regularly but are very
unlikely to be the document's creation date.
unlikely to be the documents creation date.
The date is parsed using the order specified in PAPERLESS_DATE_ORDER
@@ -1250,7 +1236,7 @@ barcode.
: Defines the upscale factor used in barcode detection.
Improves the detection of small barcodes, i.e. with a value of 1.5 by
upscaling the document before the detection process. Upscaling will
upscaling the document beforce the detection process. Upscaling will
only take place if value is bigger than 1.0. Otherwise upscaling will
not be performed to save resources. Try using in combination with
PAPERLESS_CONSUMER_BARCODE_DPI set to a value higher than default.
@@ -1284,7 +1270,7 @@ assigns or creates tags if a properly formatted barcode is detected.
: Defines a dictionary of filter regex and substitute expressions.
Syntax: `{"<regex>": "<substitute>" [,...]]}`
Syntax: {"<regex>": "<substitute>" [,...]]}
A barcode is considered for tagging if the barcode text matches
at least one of the provided <regex> pattern.
@@ -1296,20 +1282,20 @@ assigns or creates tags if a properly formatted barcode is detected.
Defaults to:
`{"TAG:(.*)": "\\g<1>"}` which defines
{"TAG:(.*)": "\\g<1>"} which defines
- a regex TAG:(.*) which includes barcodes beginning with TAG:
followed by any text that gets stored into match group #1 and
- a substitute `\\g<1>` that replaces the original barcode text
- a substitute \\g<1> that replaces the original barcode text
by the content in match group #1.
Consequently, the tag is the barcode text without its TAG: prefix.
More examples:
`{"ASN12.*": "JOHN", "ASN13.*": "SMITH"}` for example maps
{"ASN12.*": "JOHN", "ASN13.*": "SMITH"} for example maps
- ASN12nnnn barcodes to the tag JOHN and
- ASN13nnnn barcodes to the tag SMITH.
`{"T-J": "JOHN", "T-S": "SMITH", "T-D": "DOE"}` directly maps
{"T-J": "JOHN", "T-S": "SMITH", "T-D": "DOE"} directly maps
- T-J barcodes to the tag JOHN,
- T-S barcodes to the tag SMITH and
- T-D barcodes to the tag DOE.
@@ -1320,9 +1306,11 @@ assigns or creates tags if a properly formatted barcode is detected.
#### [`PAPERLESS_AUDIT_LOG_ENABLED=<bool>`](#PAPERLESS_AUDIT_LOG_ENABLED) {#PAPERLESS_AUDIT_LOG_ENABLED}
: Enables the audit trail for documents, document types, correspondents, and tags.
: Enables an audit trail for documents, document types, correspondents, and tags. Log entries can be viewed in the Django backend only.
Defaults to true.
!!! warning
Once enabled cannot be disabled
## Collate Double-Sided Documents {#collate}
@@ -1461,7 +1449,7 @@ specified as "chi-tra".
PAPERLESS_OCR_LANGUAGES=tur ces chi-tra
```
Make sure it's a space-separated list when using several values.
Make sure it's a space separated list when using several values.
To actually use these languages, also set the default OCR language
of paperless:
@@ -1492,7 +1480,7 @@ started by the container.
## Frontend Settings
#### [`PAPERLESS_APP_TITLE=<str>`](#PAPERLESS_APP_TITLE) {#PAPERLESS_APP_TITLE}
#### [`PAPERLESS_APP_TITLE=<bool>`](#PAPERLESS_APP_TITLE) {#PAPERLESS_APP_TITLE}
: If set, overrides the default name "Paperless-ngx"
+1 -1
View File
@@ -47,7 +47,7 @@ early on.
Once installed, hooks will run when you commit. If the formatting isn't
quite right or a linter catches something, the commit will be rejected.
You'll need to look at the output and fix the issue. Some hooks, such
as the Python linting and formatting tool `ruff`, will format failing
as the Python formatting tool `black`, will format failing
files, so all you need to do is `git add` those files again
and retry your commit.
+105 -41
View File
@@ -6,7 +6,6 @@ You can go multiple routes to setup and run Paperless:
- [Pull the image from Docker Hub](#docker_hub)
- [Build the Docker image yourself](#docker_build)
- [Install Paperless directly on your system manually (bare metal)](#bare_metal)
- A user-maintained list of commercial hosting providers can be found [in the wiki](https://github.com/paperless-ngx/paperless-ngx/wiki/Related-Projects)
The Docker routes are quick & easy. These are the recommended routes.
This configures all the stuff from the above automatically so that it
@@ -300,17 +299,8 @@ supported.
- `libatlas-base-dev`
- `libxslt1-dev`
You will also need these for installing some of the python dependencies:
- `build-essential`
- `python3-setuptools`
- `python3-wheel`
Use this list for your preferred package management:
```
build-essential python3-setuptools python3-wheel
```
You will also need `build-essential`, `python3-setuptools` and
`python3-wheel` for installing some of the python dependencies.
2. Install `redis` >= 6.0 and configure it to start automatically.
@@ -676,37 +666,24 @@ commands as well.
1. Stop and remove the paperless container
2. If using an external database, stop the container
3. Update Redis configuration
1. If `REDIS_URL` is already set, change it to [`PAPERLESS_REDIS`](configuration.md#PAPERLESS_REDIS)
and continue to step 4.
1. Otherwise, in the `docker-compose.yml` add a new service for
Redis, following [the example compose
files](https://github.com/paperless-ngx/paperless-ngx/tree/main/docker/compose)
1. Set the environment variable [`PAPERLESS_REDIS`](configuration.md#PAPERLESS_REDIS) so it points to
the new Redis container
a) If `REDIS_URL` is already set, change it to [`PAPERLESS_REDIS`](configuration.md#PAPERLESS_REDIS)
and continue to step 4.
b) Otherwise, in the `docker-compose.yml` add a new service for
Redis, following [the example compose
files](https://github.com/paperless-ngx/paperless-ngx/tree/main/docker/compose)
c) Set the environment variable [`PAPERLESS_REDIS`](configuration.md#PAPERLESS_REDIS) so it points to
the new Redis container
4. Update user mapping
1. If set, change the environment variable `PUID` to `USERMAP_UID`
1. If set, change the environment variable `PGID` to `USERMAP_GID`
a) If set, change the environment variable `PUID` to `USERMAP_UID`
b) If set, change the environment variable `PGID` to `USERMAP_GID`
5. Update configuration paths
1. Set the environment variable [`PAPERLESS_DATA_DIR`](configuration.md#PAPERLESS_DATA_DIR) to `/config`
a) Set the environment variable [`PAPERLESS_DATA_DIR`](configuration.md#PAPERLESS_DATA_DIR) to `/config`
6. Update media paths
1. Set the environment variable [`PAPERLESS_MEDIA_ROOT`](configuration.md#PAPERLESS_MEDIA_ROOT) to
`/data/media`
a) Set the environment variable [`PAPERLESS_MEDIA_ROOT`](configuration.md#PAPERLESS_MEDIA_ROOT) to
`/data/media`
7. Update timezone
1. Set the environment variable [`PAPERLESS_TIME_ZONE`](configuration.md#PAPERLESS_TIME_ZONE) to the same
value as `TZ`
a) Set the environment variable [`PAPERLESS_TIME_ZONE`](configuration.md#PAPERLESS_TIME_ZONE) to the same
value as `TZ`
8. Modify the `image:` to point to
`ghcr.io/paperless-ngx/paperless-ngx:latest` or a specific version
if preferred.
@@ -714,8 +691,95 @@ commands as well.
## Moving data from SQLite to PostgreSQL or MySQL/MariaDB {#sqlite_to_psql}
The best way to migrate between database types is to perform an [export](administration.md#exporter) and then
[import](administration.md#importer) into a clean installation of Paperless-ngx.
Moving your data from SQLite to PostgreSQL or MySQL/MariaDB is done via
executing a series of django management commands as below. The commands
below use PostgreSQL, but are applicable to MySQL/MariaDB with the
!!! warning
Make sure that your SQLite database is migrated to the latest version.
Starting paperless will make sure that this is the case. If your try to
load data from an old database schema in SQLite into a newer database
schema in PostgreSQL, you will run into trouble.
!!! warning
On some database fields, PostgreSQL enforces predefined limits on
maximum length, whereas SQLite does not. The fields in question are the
title of documents (128 characters), names of document types, tags and
correspondents (128 characters), and filenames (1024 characters). If you
have data in these fields that surpasses these limits, migration to
PostgreSQL is not possible and will fail with an error.
!!! warning
MySQL is case insensitive by default, treating values like "Name" and
"NAME" as identical. See [MySQL caveats](advanced_usage.md#mysql-caveats) for details.
!!! warning
MySQL also enforces limits on maximum lengths, but does so differently than
PostgreSQL. It may not be possible to migrate to MySQL due to this.
!!! warning
Using mariadb version 10.4+ is recommended. Using the `utf8mb3` character set on
an older system may fix issues that can arise while setting up Paperless-ngx but
`utf8mb3` can cause issues with consumption (where `utf8mb4` does not).
1. Stop paperless, if it is running.
2. Tell paperless to use PostgreSQL:
a) With docker, copy the provided `docker-compose.postgres.yml`
file to `docker-compose.yml`. Remember to adjust the consumption
directory, if necessary.
b) Without docker, configure the database in your `paperless.conf`
file. See [configuration](configuration.md) for
details.
3. Open a shell and initialize the database:
a) With docker, run the following command to open a shell within
the paperless container:
``` shell-session
$ cd /path/to/paperless
$ docker compose run --rm webserver /bin/bash
```
This will launch the container and initialize the PostgreSQL
database.
b) Without docker, remember to activate any virtual environment,
switch to the `src` directory and create the database schema:
``` shell-session
$ cd /path/to/paperless/src
$ python3 manage.py migrate
```
This will not copy any data yet.
4. Dump your data from SQLite:
```shell-session
$ python3 manage.py dumpdata --database=sqlite --exclude=contenttypes --exclude=auth.Permission > data.json
```
5. Load your data into PostgreSQL:
```shell-session
$ python3 manage.py loaddata data.json
```
6. If operating inside Docker, you may exit the shell now.
```shell-session
$ exit
```
7. Start paperless.
## Moving back to Paperless
+30 -94
View File
@@ -109,7 +109,7 @@ process.
### Mobile upload {#usage-mobile_upload}
Please see [the wiki](https://github.com/paperless-ngx/paperless-ngx/wiki/Related-Projects) for a user-maintained list of related projects and
Please see [the wiki](https://github.com/paperless-ngx/paperless-ngx/wiki/Affiliated-Projects) for a user-maintained list of affiliated projects and
software (e.g. for mobile devices) that is compatible with Paperless-ngx.
### IMAP (Email) {#usage-email}
@@ -206,12 +206,12 @@ for details.
## Permissions
Permissions in Paperless-ngx are based around ['global' permissions](#global-permissions) as well as
['object-level' permissions](#object-permissions). Global permissions determine which parts of the
application a user can access (e.g. Documents, Tags, Settings) and object-level determine which
objects are visible or editable. All objects have an 'owner' and 'view' and 'edit' permissions which
can be granted to other users or groups. The paperless-ngx permissions system uses the built-in user
model of the backend framework, Django.
As of version 1.14.0 Paperless-ngx added core support for user / group permissions. Permissions is
based around 'global' permissions as well as 'object-level' permissions. Global permissions designate
which parts of the application a user can access (e.g. Documents, Tags, Settings) and object-level
determine which objects are visible or editable. All objects have an 'owner' and 'view' and 'edit'
permissions which can be granted to other users or groups. The paperless-ngx permissions system uses
the built-in user model of the backend framework, Django.
!!! tip
@@ -219,66 +219,36 @@ model of the backend framework, Django.
for a Tag will _not_ affect the permissions of documents that have the Tag.
Permissions can be set using the new "Permissions" tab when editing documents, or bulk-applied
in the UI by selecting documents and choosing the "Permissions" button.
### Default permissions
[Workflows](#workflows) provide advanced ways to control permissions.
For objects created via the web UI (tags, doc types, etc.) the default is to set the current user
as owner and no extra permissions, but you can explicitly set these under Settings > Permissions.
Documents consumed via the consumption directory do not have an owner or additional permissions set by default, but again, can be controlled with [Workflows](#workflows).
### Users and Groups
Paperless-ngx supports editing users and groups via the 'frontend' UI, which can be found under
Settings > Users & Groups, assuming the user has access. If a user is designated
as a member of a group those permissions will be inherited and this is reflected in the UI. Explicit
permissions can be granted to limit access to certain parts of the UI (and corresponding API endpoints).
in the UI by selecting documents and choosing the "Permissions" button. Owner can also optionally
be set for documents uploaded via the API. Documents consumed via the consumption dir currently
do not have an owner set.
!!! note
Superusers can access all parts of the front and backend application as well as any and all objects.
After migration to version 1.14.0 all existing documents, tags etc. will have no explicit owner
set which means they will be visible / editable by all users. Once an object has an owner set,
only the owner can explicitly grant / revoke permissions.
#### Admin Status
!!! note
Admin status (Django 'staff status') grants access to viewing the paperless logs and the system status dialog
as well as accessing the Django backend.
When first migrating to permissions it is recommended to use a 'superuser' account (which
would usually have been setup during installation) to ensure you have full permissions.
#### Detailed Explanation of Global Permissions {#global-permissions}
Note that superusers have access to all objects.
Global permissions define what areas of the app and API endpoints the user can access. For example, they
determine if a user can create, edit, delete or view _any_ documents, but individual documents themselves
still have "object-level" permissions.
### Default permissions
| Type | Details |
| ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| 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. |
| Document | Grants global permissions to add, edit, delete or view Documents. |
| DocumentType | Grants global permissions to add, edit, delete or view Document Types. |
| Group | Grants global permissions to add, edit, delete or view Groups. |
| MailAccount | Grants global permissions to add, edit, delete or view Mail Accounts. |
| MailRule | Grants global permissions to add, edit, delete or view Mail Rules. |
| Note | Grants global permissions to add, edit, delete or view Notes. |
| PaperlessTask | Grants global permissions to view or dismiss (_Change_) File Tasks. |
| SavedView | Grants global permissions to add, edit, delete or view Saved Views. |
| ShareLink | Grants global permissions to add, delete or view Share Links. |
| StoragePath | Grants global permissions to add, edit, delete or view Storage Paths. |
| Tag | Grants global permissions to add, edit, delete or view Tags. |
| UISettings | Grants global permissions to add, edit, delete or view the UI settings that are used by the web app.<br/>Users expected to access the web UI should usually be granted at least _View_ permissions. |
| User | Grants global permissions to add, edit, delete or view Users. |
| Workflow | Grants global permissions to add, edit, delete or view Workflows.<br/>Note that Workflows are global, in other words all users who can access workflows have access to the same set of them. |
Default permissions for documents can be set using workflows.
#### Detailed Explanation of Object Permissions {#object-permissions}
For objects created via the web UI (tags, doc types, etc.) the default is to set the current user
as owner and no extra permissions, but you explicitly set these under Settings > Permissions.
| Type | Details |
| ----- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Owner | By default objects are only visible and editable by their owner.<br/>Only the object owner can grant permissions to other users or groups.<br/>Additionally, only document owners can create share links and add / remove custom fields.<br/>For backwards compatibility objects can have no owner which makes them visible to any user. |
| View | Confers the ability to view (not edit) a document, tag, etc.<br/>Users without 'view' (or higher) permissions will be shown _'Private'_ in place of the object name for example when viewing a document with a tag for which the user doesn't have permissions. |
| Edit | Confers the ability to edit (and view) a document, tag, etc. |
### Users and Groups
Paperless-ngx versions after 1.14.0 allow creating and editing users and groups via the 'frontend' UI.
These can be found under Settings > Users & Groups, assuming the user has access. If a user is designated
as a member of a group those permissions will be inherited and this is reflected in the UI. Explicit
permissions can be granted to limit access to certain parts of the UI (and corresponding API endpoints).
### Password reset
@@ -421,12 +391,13 @@ to optionally attach data to documents which does not fit in the existing set of
Paperless-ngx provides.
1. First, create a custom field (under "Manage"), with a given name and data type. This could be something like "Invoice Number" or "Date Paid", with a data type of "Number", "Date", "String", etc.
2. Once created, a field can be used with documents and data stored. To do so, use the "Custom Fields" menu on the document detail page, choose your existing field from the dropdown. Once the field is visible in the form you can enter the appropriate data which will be validated according to the custom field "data type".
2. Once created, a field can be used with documents and data stored. To do so, use the "Custom Fields" menu on the document detail page, choose your existing field and click "Add". Once the field is visible in the form you can enter the appropriate
data which will be validated according to the custom field "data type".
3. Fields can be removed by hovering over the field name revealing a "Remove" button.
!!! important
Added / removed fields, as well as any data, is not saved to the document until you
Added / removed fields, as well as any data is not saved to the document until you
actually hit the "Save" button, similar to other changes on the document details page.
!!! note
@@ -459,25 +430,6 @@ Paperless-ngx added the ability to create shareable links to files in version 2.
If your paperless-ngx instance is behind a reverse-proxy you may want to create an exception to bypass any authentication layers that are part of your setup in order to make links truly publicly-accessible. Of course, do so with caution.
## PDF Actions
Paperless-ngx supports four basic editing operations for PDFs (these operations currently cannot be performed on non-PDF files):
- Merging documents: available when selecting multiple documents for 'bulk editing'.
- Rotating documents: available when selecting multiple documents for 'bulk editing' and from an individual document's details page.
- Splitting documents: available from an individual document's details page.
- Deleting pages: available from an individual document's details page.
!!! important
Note that rotation and deleting pages alter 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
@@ -550,16 +502,6 @@ collection.
## Searching {#basic-usage_searching}
### Global search
The top search bar in the web UI performs a "global" search of the various
objects Paperless-ngx uses, including documents, tags, workflows, etc. Only
objects for which the user has appropriate permissions are returned. For
documents, if there are < 3 results, "advanced" search results (which use
the document index) will also be included. This can be disabled under settings.
### Document searches
Paperless offers an extensive searching mechanism that is designed to
allow you to quickly find a document you're looking for (for example,
that thing that just broke and you bought a couple months ago, that
@@ -615,12 +557,6 @@ language](https://whoosh.readthedocs.io/en/latest/querylang.html). For
details on what date parsing utilities are available, see [Date
parsing](https://whoosh.readthedocs.io/en/latest/dates.html#parsing-date-queries).
## Keyboard shortcuts / hotkeys
A list of available hotkeys can be shown on any page using <kbd>Shift</kbd> +
<kbd>?</kbd>. The help dialog shows only the keys that are currently available
based on which area of Paperless-ngx you are using.
## The recommended workflow {#usage-recommended-workflow}
Once you have familiarized yourself with paperless and are ready to use
+3 -3
View File
@@ -37,11 +37,11 @@ def worker_int(worker):
id2name = {th.ident: th.name for th in threading.enumerate()}
code = []
for threadId, stack in sys._current_frames().items():
code.append(f"\n# Thread: {id2name.get(threadId, '')}({threadId})")
code.append("\n# Thread: %s(%d)" % (id2name.get(threadId, ""), threadId))
for filename, lineno, name, line in traceback.extract_stack(stack):
code.append(f'File: "{filename}", line {lineno}, in {name}')
code.append('File: "%s", line %d, in %s' % (filename, lineno, name))
if line:
code.append(f" {line.strip()}")
code.append(" %s" % (line.strip()))
worker.log.debug("\n".join(code))
+2 -12
View File
@@ -71,17 +71,7 @@ if ! docker stats --no-stream &> /dev/null ; then
sleep 3
fi
# Added handling for timezone for busybox based linux, not having timedatectl available (i.e. QNAP QTS)
# if neither timedatectl nor /etc/TZ is succeeding, defaulting to GMT.
if command -v timedatectl &> /dev/null ; then
default_time_zone=$(timedatectl show -p Timezone --value)
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
default_time_zone=$(timedatectl show -p Timezone --value)
set -e
@@ -345,7 +335,7 @@ read -r -a OCR_LANGUAGES_ARRAY <<< "${_split_langs}"
fi
echo "PAPERLESS_TIME_ZONE=$TIME_ZONE"
echo "PAPERLESS_OCR_LANGUAGE=$OCR_LANGUAGE"
echo "PAPERLESS_SECRET_KEY='$SECRET_KEY'"
echo "PAPERLESS_SECRET_KEY=$SECRET_KEY"
if [[ ! ${DEFAULT_LANGUAGES[*]} =~ ${OCR_LANGUAGES_ARRAY[*]} ]] ; then
echo "PAPERLESS_OCR_LANGUAGES=${OCR_LANGUAGES_ARRAY[*]}"
fi
-41
View File
@@ -1,41 +0,0 @@
{
"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
},
"python.defaultInterpreterPath": ".venv/bin/python3",
},
"extensions": {
"recommendations": ["ms-python.python", "charliermarsh.ruff", "editorconfig.editorconfig"],
"unwantedRecommendations": ["ms-python.black-formatter"]
}
}
+1 -1
View File
@@ -3,4 +3,4 @@
docker run -p 5432:5432 -e POSTGRES_PASSWORD=password -v paperless_pgdata:/var/lib/postgresql/data -d postgres:15
docker run -d -p 6379:6379 redis:latest
docker run -p 3000:3000 -d gotenberg/gotenberg:7.8 gotenberg --chromium-disable-javascript=true --chromium-allow-list="file:///tmp/.*"
docker run -p 9998:9998 -d docker.io/apache/tika:latest
docker run -p 9998:9998 -d ghcr.io/paperless-ngx/tika:latest
+2 -1
View File
@@ -76,7 +76,8 @@
],
"scripts": [],
"allowedCommonJsDependencies": [
"ng2-pdf-viewer",
"pdfjs-dist",
"pdfjs-dist/web/pdf_viewer",
"filesize",
"file-saver"
],
@@ -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,\"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\"]}]}"
"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}]}"
},
"headersSize": -1,
"bodySize": -1,
@@ -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,\"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\"]}]}"
"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}]}"
},
"headersSize": -1,
"bodySize": -1,
@@ -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,\"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\"]}]}"
"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}]}"
},
"headersSize": -1,
"bodySize": -1,
@@ -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,\"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\"]}]}"
"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}]}"
},
"headersSize": -1,
"bodySize": -1,
@@ -71,7 +71,7 @@ test('should show a mobile preview', async ({ page }) => {
await page.setViewportSize({ width: 400, height: 1000 })
await expect(page.getByRole('tab', { name: 'Preview' })).toBeVisible()
await page.getByRole('tab', { name: 'Preview' }).click()
await page.waitForSelector('pdf-viewer')
await page.waitForSelector('pngx-pdf-viewer')
})
test('should show a list of notes', async ({ page }) => {
+12 -13
View File
@@ -45,8 +45,8 @@ test('basic filtering', async ({ page }) => {
test('text filtering', async ({ page }) => {
await page.routeFromHAR(REQUESTS_HAR2, { notFound: 'fallback' })
await page.goto('/documents')
await page.getByRole('main').getByRole('combobox').click()
await page.getByRole('main').getByRole('combobox').fill('test')
await page.getByRole('textbox').click()
await page.getByRole('textbox').fill('test')
await expect(page.locator('pngx-document-list')).toHaveText(/32 documents/)
await expect(page).toHaveURL(/title_content=test/)
await page.getByRole('button', { name: 'Title & content' }).click()
@@ -59,12 +59,12 @@ test('text filtering', async ({ page }) => {
await expect(page.locator('pngx-document-list')).toHaveText(/26 documents/)
await page.getByRole('button', { name: 'Advanced search' }).click()
await page.getByRole('button', { name: 'ASN' }).click()
await page.getByRole('main').getByRole('combobox').nth(1).fill('1123')
await page.getByRole('textbox').fill('1123')
await expect(page).toHaveURL(/archive_serial_number=1123/)
await expect(page.locator('pngx-document-list')).toHaveText(/one document/i)
await page.locator('select').selectOption('greater')
await page.getByRole('main').getByRole('combobox').nth(1).click()
await page.getByRole('main').getByRole('combobox').nth(1).fill('1123')
await page.getByRole('textbox').click()
await page.getByRole('textbox').fill('1123')
await expect(page).toHaveURL(/archive_serial_number__gt=1123/)
await expect(page.locator('pngx-document-list')).toHaveText(/5 documents/)
await page.locator('select').selectOption('less')
@@ -81,15 +81,14 @@ 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: 'Dates' }).click()
await page.getByRole('menuitem', { name: 'Last 3 months' }).first().click()
await page.getByRole('button', { name: 'Created' }).click()
await page.getByRole('menuitem', { name: 'Last 3 months' }).click()
await expect(page.locator('pngx-document-list')).toHaveText(/one document/i)
await page.getByRole('button', { name: 'Dates Clear selected' }).click()
await page.getByRole('button', { name: 'Dates' }).click()
await page.getByRole('button', { name: 'Created Clear selected' }).click()
await page.getByRole('button', { name: 'Created' }).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')
@@ -139,11 +138,11 @@ test('sorting', async ({ page }) => {
test('change views', async ({ page }) => {
await page.routeFromHAR(REQUESTS_HAR5, { notFound: 'fallback' })
await page.goto('/documents')
await page.locator('.btn-group label').first().click()
await page.locator('pngx-page-header label').first().click()
await expect(page.locator('pngx-document-list table')).toBeVisible()
await page.locator('.btn-group label').nth(1).click()
await page.locator('pngx-page-header label').nth(1).click()
await expect(page.locator('pngx-document-card-small').first()).toBeAttached()
await page.locator('.btn-group label').nth(2).click()
await page.locator('pngx-page-header label').nth(2).click()
await expect(page.locator('pngx-document-card-large').first()).toBeAttached()
})
+1
View File
@@ -7,6 +7,7 @@ module.exports = {
'abstract-name-filter-service',
'abstract-paperless-service',
],
coveragePathIgnorePatterns: ['/src/app/components/common/pdf-viewer/*'],
transformIgnorePatterns: [`<rootDir>/node_modules/(?!.*\\.mjs$|lodash-es)`],
moduleNameMapper: {
'^src/(.*)': '<rootDir>/src/$1',
+1162 -2039
View File
File diff suppressed because it is too large Load Diff
+919 -1027
View File
File diff suppressed because it is too large Load Diff
+25 -25
View File
@@ -11,15 +11,15 @@
},
"private": true,
"dependencies": {
"@angular/cdk": "^17.3.10",
"@angular/common": "~17.3.9",
"@angular/compiler": "~17.3.9",
"@angular/core": "~17.3.9",
"@angular/forms": "~17.3.9",
"@angular/localize": "~17.3.9",
"@angular/platform-browser": "~17.3.9",
"@angular/platform-browser-dynamic": "~17.3.9",
"@angular/router": "~17.3.9",
"@angular/cdk": "^17.2.1",
"@angular/common": "~17.2.3",
"@angular/compiler": "~17.2.3",
"@angular/core": "~17.2.3",
"@angular/forms": "~17.2.3",
"@angular/localize": "~17.2.3",
"@angular/platform-browser": "~17.2.3",
"@angular/platform-browser-dynamic": "~17.2.3",
"@angular/router": "~17.2.3",
"@ng-bootstrap/ng-bootstrap": "^16.0.0",
"@ng-select/ng-select": "^12.0.7",
"@ngneat/dirty-check-forms": "^3.0.3",
@@ -27,38 +27,38 @@
"bootstrap": "^5.3.3",
"file-saver": "^2.0.5",
"mime-names": "^1.0.0",
"ng2-pdf-viewer": "^10.2.2",
"ngx-bootstrap-icons": "^1.9.3",
"ngx-color": "^9.0.0",
"ngx-cookie-service": "^17.1.0",
"ngx-file-drop": "^16.0.0",
"ngx-filesize": "^3.0.3",
"ngx-ui-tour-ng-bootstrap": "^14.0.3",
"ngx-ui-tour-ng-bootstrap": "^14.0.2",
"pdfjs-dist": "^3.11.174",
"rxjs": "^7.8.1",
"tslib": "^2.6.2",
"uuid": "^9.0.1",
"zone.js": "^0.14.4"
},
"devDependencies": {
"@angular-builders/jest": "17.0.3",
"@angular-devkit/build-angular": "~17.3.7",
"@angular-eslint/builder": "17.4.1",
"@angular-eslint/eslint-plugin": "17.4.1",
"@angular-eslint/eslint-plugin-template": "17.4.1",
"@angular-eslint/schematics": "17.4.1",
"@angular-eslint/template-parser": "17.4.1",
"@angular/cli": "~17.3.7",
"@angular/compiler-cli": "~17.3.2",
"@playwright/test": "^1.42.1",
"@angular-builders/jest": "17.0.2",
"@angular-devkit/build-angular": "~17.2.2",
"@angular-eslint/builder": "17.2.1",
"@angular-eslint/eslint-plugin": "17.2.1",
"@angular-eslint/eslint-plugin-template": "17.2.1",
"@angular-eslint/schematics": "17.2.1",
"@angular-eslint/template-parser": "17.2.1",
"@angular/cli": "~17.2.2",
"@angular/compiler-cli": "~17.2.2",
"@playwright/test": "^1.42.0",
"@types/jest": "^29.5.12",
"@types/node": "^20.12.2",
"@typescript-eslint/eslint-plugin": "^7.4.0",
"@typescript-eslint/parser": "^7.4.0",
"@types/node": "^20.11.24",
"@typescript-eslint/eslint-plugin": "^7.1.0",
"@typescript-eslint/parser": "^7.1.0",
"concurrently": "^8.2.2",
"eslint": "^8.57.0",
"jest": "29.7.0",
"jest-environment-jsdom": "^29.7.0",
"jest-preset-angular": "^14.1.0",
"jest-preset-angular": "^14.0.0",
"jest-websocket-mock": "^2.5.0",
"patch-package": "^8.0.0",
"ts-node": "~10.9.1",
+1 -5
View File
@@ -76,16 +76,12 @@ const mock = () => {
let storage: { [key: string]: string } = {}
return {
getItem: (key: string) => (key in storage ? storage[key] : null),
setItem: (key: string, value: string) => {
if (value.length > 1000000) throw new Error('localStorage overflow')
storage[key] = value || ''
},
setItem: (key: string, value: string) => (storage[key] = value || ''),
removeItem: (key: string) => delete storage[key],
clear: () => (storage = {}),
}
}
Object.defineProperty(window, 'open', { value: jest.fn() })
Object.defineProperty(window, 'localStorage', { value: mock() })
Object.defineProperty(window, 'sessionStorage', { value: mock() })
Object.defineProperty(window, 'getComputedStyle', {
+4 -1
View File
@@ -141,7 +141,10 @@ export const routes: Routes = [
component: LogsComponent,
canActivate: [PermissionsGuard],
data: {
requireAdmin: true,
requiredPermission: {
action: PermissionAction.View,
type: PermissionType.Admin,
},
},
},
// redirect old paths
+4 -26
View File
@@ -5,7 +5,8 @@ import {
fakeAsync,
tick,
} from '@angular/core/testing'
import { Router, RouterModule } from '@angular/router'
import { Router } from '@angular/router'
import { RouterTestingModule } from '@angular/router/testing'
import { TourService, TourNgBootstrapModule } from 'ngx-ui-tour-ng-bootstrap'
import { Subject } from 'rxjs'
import { routes } from './app-routing.module'
@@ -20,10 +21,6 @@ import { ToastService, Toast } from './services/toast.service'
import { SettingsService } from './services/settings.service'
import { FileDropComponent } from './components/file-drop/file-drop.component'
import { NgxFileDropModule } from 'ngx-file-drop'
import { NgbModalModule } from '@ng-bootstrap/ng-bootstrap'
import { HotKeyService } from './services/hot-key.service'
import { PermissionsGuard } from './guards/permissions.guard'
import { DirtySavedViewGuard } from './guards/dirty-saved-view.guard'
describe('AppComponent', () => {
let component: AppComponent
@@ -34,18 +31,16 @@ describe('AppComponent', () => {
let toastService: ToastService
let router: Router
let settingsService: SettingsService
let hotKeyService: HotKeyService
beforeEach(async () => {
TestBed.configureTestingModule({
declarations: [AppComponent, ToastsComponent, FileDropComponent],
providers: [PermissionsGuard, DirtySavedViewGuard],
providers: [],
imports: [
HttpClientTestingModule,
TourNgBootstrapModule,
RouterModule.forRoot(routes),
RouterTestingModule.withRoutes(routes),
NgxFileDropModule,
NgbModalModule,
],
}).compileComponents()
@@ -55,7 +50,6 @@ describe('AppComponent', () => {
settingsService = TestBed.inject(SettingsService)
toastService = TestBed.inject(ToastService)
router = TestBed.inject(Router)
hotKeyService = TestBed.inject(HotKeyService)
fixture = TestBed.createComponent(AppComponent)
component = fixture.componentInstance
})
@@ -145,20 +139,4 @@ describe('AppComponent', () => {
fileStatusSubject.next(new FileStatus())
expect(toastSpy).toHaveBeenCalled()
})
it('should support hotkeys', () => {
const addShortcutSpy = jest.spyOn(hotKeyService, 'addShortcut')
const routerSpy = jest.spyOn(router, 'navigate')
// prevent actual navigation
routerSpy.mockReturnValue(new Promise(() => {}))
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
component.ngOnInit()
expect(addShortcutSpy).toHaveBeenCalled()
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'h' }))
expect(routerSpy).toHaveBeenCalledWith(['/dashboard'])
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'd' }))
expect(routerSpy).toHaveBeenCalledWith(['/documents'])
document.dispatchEvent(new KeyboardEvent('keydown', { key: 's' }))
expect(routerSpy).toHaveBeenCalledWith(['/settings'])
})
})
+1 -33
View File
@@ -12,7 +12,6 @@ import {
PermissionsService,
PermissionType,
} from './services/permissions.service'
import { HotKeyService } from './services/hot-key.service'
@Component({
selector: 'pngx-root',
@@ -32,8 +31,7 @@ export class AppComponent implements OnInit, OnDestroy {
private tasksService: TasksService,
public tourService: TourService,
private renderer: Renderer2,
private permissionsService: PermissionsService,
private hotKeyService: HotKeyService
private permissionsService: PermissionsService
) {
this.settings.updateAppearanceSettings()
}
@@ -125,36 +123,6 @@ export class AppComponent implements OnInit, OnDestroy {
}
})
this.hotKeyService
.addShortcut({ keys: 'h', description: $localize`Dashboard` })
.subscribe(() => {
this.router.navigate(['/dashboard'])
})
if (
this.permissionsService.currentUserCan(
PermissionAction.View,
PermissionType.Document
)
) {
this.hotKeyService
.addShortcut({ keys: 'd', description: $localize`Documents` })
.subscribe(() => {
this.router.navigate(['/documents'])
})
}
if (
this.permissionsService.currentUserCan(
PermissionAction.Change,
PermissionType.UISettings
)
) {
this.hotKeyService
.addShortcut({ keys: 's', description: $localize`Settings` })
.subscribe(() => {
this.router.navigate(['/settings'])
})
}
const prevBtnTitle = $localize`Prev`
const nextBtnTitle = $localize`Next`
const endBtnTitle = $localize`End`
+4 -44
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 { DatesDropdownComponent } from './components/common/dates-dropdown/dates-dropdown.component'
import { DateDropdownComponent } from './components/common/date-dropdown/date-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'
@@ -105,7 +105,7 @@ import { CustomFieldsComponent } from './components/manage/custom-fields/custom-
import { CustomFieldEditDialogComponent } from './components/common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component'
import { CustomFieldsDropdownComponent } from './components/common/custom-fields-dropdown/custom-fields-dropdown.component'
import { ProfileEditDialogComponent } from './components/common/profile-edit-dialog/profile-edit-dialog.component'
import { PdfViewerModule } from 'ng2-pdf-viewer'
import { PdfViewerComponent } from './components/common/pdf-viewer/pdf-viewer.component'
import { DocumentLinkComponent } from './components/common/input/document-link/document-link.component'
import { PreviewPopupComponent } from './components/common/preview-popup/preview-popup.component'
import { SwitchComponent } from './components/common/input/switch/switch.component'
@@ -116,19 +116,9 @@ import { ConfirmButtonComponent } from './components/common/confirm-button/confi
import { MonetaryComponent } from './components/common/input/monetary/monetary.component'
import { SystemStatusDialogComponent } from './components/common/system-status-dialog/system-status-dialog.component'
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 { GlobalSearchComponent } from './components/app-frame/global-search/global-search.component'
import { HotkeyDialogComponent } from './components/common/hotkey-dialog/hotkey-dialog.component'
import { DeletePagesConfirmDialogComponent } from './components/common/confirm-dialog/delete-pages-confirm-dialog/delete-pages-confirm-dialog.component'
import {
airplane,
archive,
arrowClockwise,
arrowCounterclockwise,
arrowDown,
arrowLeft,
@@ -137,15 +127,12 @@ import {
arrowRightShort,
arrowUpRight,
asterisk,
bodyText,
boxArrowUp,
boxArrowUpRight,
boxes,
calendar,
calendarEvent,
calendarEventFill,
cardChecklist,
cardHeading,
caretDown,
caretUp,
chatLeftText,
@@ -161,13 +148,11 @@ import {
clipboardCheckFill,
clipboardFill,
dash,
dashCircle,
diagram3,
dice5,
doorOpen,
download,
envelope,
envelopeAt,
exclamationCircleFill,
exclamationTriangle,
exclamationTriangleFill,
@@ -176,7 +161,6 @@ import {
fileEarmarkCheck,
fileEarmarkFill,
fileEarmarkLock,
fileEarmarkMinus,
files,
fileText,
filter,
@@ -190,7 +174,6 @@ import {
hddStack,
house,
infoCircle,
journals,
link,
listTask,
listUl,
@@ -202,18 +185,15 @@ import {
personFill,
personFillLock,
personLock,
personSquare,
plus,
plusCircle,
questionCircle,
scissors,
search,
slashCircle,
sliders2Vertical,
sortAlphaDown,
sortAlphaUpAlt,
tagFill,
tag,
tags,
textIndentLeft,
textLeft,
@@ -229,7 +209,6 @@ import {
const icons = {
airplane,
archive,
arrowClockwise,
arrowCounterclockwise,
arrowDown,
arrowLeft,
@@ -238,15 +217,12 @@ const icons = {
arrowRightShort,
arrowUpRight,
asterisk,
bodyText,
boxArrowUp,
boxArrowUpRight,
boxes,
calendar,
calendarEvent,
calendarEventFill,
cardChecklist,
cardHeading,
caretDown,
caretUp,
chatLeftText,
@@ -262,13 +238,11 @@ const icons = {
clipboardCheckFill,
clipboardFill,
dash,
dashCircle,
diagram3,
dice5,
doorOpen,
download,
envelope,
envelopeAt,
exclamationCircleFill,
exclamationTriangle,
exclamationTriangleFill,
@@ -277,7 +251,6 @@ const icons = {
fileEarmarkCheck,
fileEarmarkFill,
fileEarmarkLock,
fileEarmarkMinus,
files,
fileText,
filter,
@@ -291,7 +264,6 @@ const icons = {
hddStack,
house,
infoCircle,
journals,
link,
listTask,
listUl,
@@ -303,18 +275,15 @@ const icons = {
personFill,
personFillLock,
personLock,
personSquare,
plus,
plusCircle,
questionCircle,
scissors,
search,
slashCircle,
sliders2Vertical,
sortAlphaDown,
sortAlphaUpAlt,
tagFill,
tag,
tags,
textIndentLeft,
textLeft,
@@ -422,7 +391,7 @@ function initializeApp(settings: SettingsService) {
FilterEditorComponent,
FilterableDropdownComponent,
ToggleableDropdownButtonComponent,
DatesDropdownComponent,
DateDropdownComponent,
DocumentCardLargeComponent,
DocumentCardSmallComponent,
BulkEditorComponent,
@@ -480,6 +449,7 @@ function initializeApp(settings: SettingsService) {
CustomFieldEditDialogComponent,
CustomFieldsDropdownComponent,
ProfileEditDialogComponent,
PdfViewerComponent,
DocumentLinkComponent,
PreviewPopupComponent,
SwitchComponent,
@@ -488,15 +458,6 @@ function initializeApp(settings: SettingsService) {
ConfirmButtonComponent,
MonetaryComponent,
SystemStatusDialogComponent,
RotateConfirmDialogComponent,
MergeConfirmDialogComponent,
SplitConfirmDialogComponent,
DocumentHistoryComponent,
DragDropSelectComponent,
CustomFieldDisplayComponent,
GlobalSearchComponent,
HotkeyDialogComponent,
DeletePagesConfirmDialogComponent,
],
imports: [
BrowserModule,
@@ -505,7 +466,6 @@ function initializeApp(settings: SettingsService) {
HttpClientModule,
FormsModule,
ReactiveFormsModule,
PdfViewerModule,
NgxFileDropModule,
NgSelectModule,
ColorSliderModule,
@@ -11,7 +11,7 @@
<ul ngbNav #nav="ngbNav" class="nav-tabs">
@for (category of optionCategories; track category) {
<li [ngbNavItem]="category">
<a ngbNavLink>{{category}}</a>
<a ngbNavLink i18n>{{category}}</a>
<ng-template ngbNavContent>
<div class="p-3">
<div class="row row-cols-1 row-cols-md-2 row-cols-lg-3 g-2">
@@ -7,30 +7,29 @@
<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>
@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>
<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>
} @else {
<i-bs class="me-2" name="card-checklist"></i-bs>
@if (systemStatusHasErrors) {
<span class="badge bg-body position-absolute top-0 start-100 translate-middle rounded-pill p-0">
<i-bs name="exclamation-circle-fill" class="text-danger" width="1.75em" height="1.75em"></i-bs>
</span>
} @else {
<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>
}
<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 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 *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>
</pngx-page-header>
<form [formGroup]="settingsForm" (ngSubmit)="saveSettings()">
@@ -197,30 +196,6 @@
</div>
</div>
<h4 class="mt-4" i18n>Global search</h4>
<div class="row mb-3">
<div class="offset-md-3 col">
<pngx-input-check i18n-title title="Do not include advanced search results" formControlName="searchDbOnly"></pngx-input-check>
</div>
</div>
<div class="row mb-3">
<div class="offset-md-3 col">
<div class="row">
<div class="col-md-2 col-form-label pt-0">
<span i18n>Full search links to</span>
</div>
<div class="col">
<select class="form-select" formControlName="searchLink">
<option [ngValue]="GlobalSearchType.TITLE_CONTENT" i18n>Title and content search</option>
<option [ngValue]="GlobalSearchType.ADVANCED" i18n>Advanced search</option>
</select>
</div>
</div>
</div>
</div>
<h4 class="mt-4" i18n>Notes</h4>
<div class="row mb-3">
@@ -344,71 +319,52 @@
</div>
<h4 i18n>Views</h4>
<ul class="list-group" formGroupName="savedViews">
<div formGroupName="savedViews">
@for (view of savedViews; track view) {
<li class="list-group-item py-3">
<div [formGroupName]="view.id" class="row">
<div class="row">
<div class="col">
<pngx-input-text title="Name" formControlName="name"></pngx-input-text>
<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>
<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 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="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 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>
</li>
}
@if (savedViews && savedViews.length === 0) {
<li class="list-group-item">
<div i18n>No saved views defined.</div>
</li>
<div i18n>No saved views defined.</div>
}
@if (!savedViews) {
<li class="list-group-item">
<div>
<div class="spinner-border spinner-border-sm fw-normal ms-2 me-auto" role="status"></div>
<div class="visually-hidden" i18n>Loading...</div>
</li>
</div>
}
</ul>
</div>
</ng-template>
</li>
@@ -417,5 +373,4 @@
<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>
@@ -48,8 +48,6 @@ 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 },
@@ -98,7 +96,6 @@ describe('SettingsComponent', () => {
PermissionsGroupComponent,
IfOwnerDirective,
ConfirmButtonComponent,
DragDropSelectComponent,
],
providers: [CustomDatePipe, DatePipe, PermissionsGuard],
imports: [
@@ -111,7 +108,6 @@ describe('SettingsComponent', () => {
NgSelectModule,
NgxBootstrapIconsModule.pick(allIcons),
NgbModalModule,
DragDropModule,
],
}).compileComponents()
@@ -309,7 +305,7 @@ describe('SettingsComponent', () => {
expect(toastErrorSpy).toHaveBeenCalled()
expect(storeSpy).toHaveBeenCalled()
expect(appearanceSettingsSpy).not.toHaveBeenCalled()
expect(setSpy).toHaveBeenCalledTimes(27)
expect(setSpy).toHaveBeenCalledTimes(25)
// succeed
storeSpy.mockReturnValueOnce(of(true))
@@ -422,7 +418,6 @@ 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()
@@ -441,11 +436,4 @@ describe('SettingsComponent', () => {
size: 'xl',
})
})
it('should support reset', () => {
completeSetup()
component.settingsForm.get('themeColor').setValue('#ff0000')
component.reset()
expect(component.settingsForm.get('themeColor').value).toEqual('')
})
})
@@ -27,7 +27,7 @@ import {
} from 'rxjs'
import { Group } from 'src/app/data/group'
import { SavedView } from 'src/app/data/saved-view'
import { GlobalSearchType, SETTINGS_KEYS } from 'src/app/data/ui-settings'
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
import { User } from 'src/app/data/user'
import { DocumentListViewService } from 'src/app/services/document-list-view.service'
import {
@@ -50,7 +50,6 @@ import {
SystemStatusItemStatus,
SystemStatus,
} from 'src/app/data/system-status'
import { DisplayMode } from 'src/app/data/document'
enum SettingsNavIDs {
General = 1,
@@ -74,8 +73,8 @@ export class SettingsComponent
extends ComponentWithPermissions
implements OnInit, AfterViewInit, OnDestroy, DirtyComponent
{
SettingsNavIDs = SettingsNavIDs
activeNavID: number
DisplayMode = DisplayMode
savedViewGroup = new FormGroup({})
@@ -100,8 +99,6 @@ export class SettingsComponent
defaultPermsEditUsers: new FormControl(null),
defaultPermsEditGroups: new FormControl(null),
documentEditingRemoveInboxTags: new FormControl(null),
searchDbOnly: new FormControl(null),
searchLink: new FormControl(null),
notificationsConsumerNewDocument: new FormControl(null),
notificationsConsumerSuccess: new FormControl(null),
@@ -113,10 +110,6 @@ export class SettingsComponent
})
savedViews: SavedView[]
SettingsNavIDs = SettingsNavIDs
get displayFields() {
return this.settings.allDisplayFields
}
store: BehaviorSubject<any>
storeSub: Subscription
@@ -128,9 +121,7 @@ export class SettingsComponent
users: User[]
groups: Group[]
public systemStatus: SystemStatus
public readonly GlobalSearchType = GlobalSearchType
private systemStatus: SystemStatus
get systemStatusHasErrors(): boolean {
return (
@@ -308,8 +299,6 @@ export class SettingsComponent
documentEditingRemoveInboxTags: this.settings.get(
SETTINGS_KEYS.DOCUMENT_EDITING_REMOVE_INBOX_TAGS
),
searchDbOnly: this.settings.get(SETTINGS_KEYS.SEARCH_DB_ONLY),
searchLink: this.settings.get(SETTINGS_KEYS.SEARCH_FULL_TYPE),
savedViews: {},
}
}
@@ -351,9 +340,6 @@ 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(),
@@ -362,9 +348,6 @@ 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([]),
})
)
}
@@ -402,7 +385,12 @@ export class SettingsComponent
this.settingsForm.patchValue(currentFormValue)
}
if (this.permissionsService.isAdmin()) {
if (
this.permissionsService.currentUserCan(
PermissionAction.View,
PermissionType.Admin
)
) {
this.systemStatusService.get().subscribe((status) => {
this.systemStatus = status
})
@@ -539,14 +527,6 @@ export class SettingsComponent
SETTINGS_KEYS.DOCUMENT_EDITING_REMOVE_INBOX_TAGS,
this.settingsForm.value.documentEditingRemoveInboxTags
)
this.settings.set(
SETTINGS_KEYS.SEARCH_DB_ONLY,
this.settingsForm.value.searchDbOnly
)
this.settings.set(
SETTINGS_KEYS.SEARCH_FULL_TYPE,
this.settingsForm.value.searchLink
)
this.settings.setLanguage(this.settingsForm.value.displayLanguage)
this.settings
.storeSettings()
@@ -555,8 +535,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,
@@ -617,10 +597,6 @@ export class SettingsComponent
}
}
reset() {
this.settingsForm.patchValue(this.store.getValue())
}
clearThemeColor() {
this.settingsForm.get('themeColor').patchValue('')
}
@@ -29,7 +29,7 @@
<tr>
<th scope="col">
<div class="form-check">
<input type="checkbox" class="form-check-input" id="all-tasks" [disabled]="currentTasks.length === 0" [(ngModel)]="togggleAll" (click)="toggleAll($event); $event.stopPropagation();">
<input type="checkbox" class="form-check-input" id="all-tasks" [disabled]="currentTasks.length === 0" (click)="toggleAll($event); $event.stopPropagation();">
<label class="form-check-label" for="all-tasks"></label>
</div>
</th>
@@ -29,7 +29,6 @@ import { PageHeaderComponent } from '../../common/page-header/page-header.compon
import { TasksComponent } from './tasks.component'
import { PermissionsGuard } from 'src/app/guards/permissions.guard'
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
import { FormsModule } from '@angular/forms'
const tasks: PaperlessTask[] = [
{
@@ -141,7 +140,6 @@ describe('TasksComponent', () => {
HttpClientTestingModule,
RouterTestingModule.withRoutes(routes),
NgxBootstrapIconsModule.pick(allIcons),
FormsModule,
],
}).compileComponents()
@@ -18,7 +18,6 @@ export class TasksComponent
{
public activeTab: string
public selectedTasks: Set<number> = new Set()
public togggleAll: boolean = false
public expandedTask: number
public pageSize: number = 25
@@ -121,7 +120,6 @@ export class TasksComponent
}
clearSelection() {
this.togggleAll = false
this.selectedTasks.clear()
}
@@ -26,7 +26,7 @@
@for (user of users; track user) {
<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)="editUser(user)" [disabled]="!permissionsService.currentUserCan(PermissionAction.Change, PermissionType.User)">{{user.username}}</button></div>
<div class="col d-flex align-items-center"><button class="btn btn-link p-0" type="button" (click)="editUser(user)" [disabled]="!permissionsService.currentUserCan(PermissionAction.Change, PermissionType.User)">{{user.username}}</button></div>
<div class="col d-flex align-items-center">{{user.first_name}} {{user.last_name}}</div>
<div class="col d-flex align-items-center">{{user.groups?.map(getGroupName, this).join(', ')}}</div>
<div class="col">
@@ -52,38 +52,40 @@
<i-bs name="plus-circle"></i-bs>&nbsp;<ng-container i18n>Add Group</ng-container>
</button>
</h4>
<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) {
@if (groups.length > 0) {
<ul class="list-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" i18n>Name</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>
<div class="col" i18n>Actions</div>
</div>
</li>
}
@if (groups.length === 0) {
<li class="list-group-item" i18n>No groups defined</li>
}
</ul>
@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" 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>
</div>
</li>
}
@if (groups.length === 0) {
<li class="list-group-item" i18n>No groups defined</li>
}
</ul>
}
}
@if (!users || !groups) {
@@ -5,7 +5,7 @@
<span class="navbar-toggler-icon"></span>
</button>
<a class="navbar-brand d-flex align-items-center me-0 px-3 py-3 order-sm-0"
[ngClass]="{ 'slim': slimSidebarEnabled, 'col-auto col-md-3 col-lg-2 col-xxxl-1' : !slimSidebarEnabled, 'py-3' : !customAppTitle?.length || slimSidebarEnabled, 'py-2': customAppTitle?.length }"
[ngClass]="{ 'slim': slimSidebarEnabled, 'col-auto col-md-3 col-lg-2' : !slimSidebarEnabled, 'py-3' : !customAppTitle?.length || slimSidebarEnabled, 'py-2': customAppTitle?.length }"
routerLink="/dashboard"
tourAnchor="tour.intro">
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 198.43 238.91" width="1em" height="1.5em" fill="currentColor">
@@ -24,10 +24,19 @@
}
</div>
</a>
<div class="search-container flex-grow-1 py-2 pb-3 pb-sm-2 px-3 ps-md-4 me-sm-auto order-3 order-sm-1">
<div class="col-12 col-md-7">
<pngx-global-search></pngx-global-search>
</div>
<div class="search-form-container flex-grow-1 py-2 pb-3 pb-sm-2 px-3 ps-md-4 me-sm-auto order-3 order-sm-1"
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }">
<form (ngSubmit)="search()" class="form-inline flex-grow-1">
<i-bs width="1em" height="1em" name="search"></i-bs>
<input class="form-control form-control-sm" type="text" placeholder="Search documents" aria-label="Search"
[formControl]="searchField" [ngbTypeahead]="searchAutoComplete" (keyup)="searchFieldKeyup($event)"
(selectItem)="itemSelected($event)" i18n-placeholder>
@if (!searchFieldEmpty) {
<button type="button" class="btn btn-link btn-sm ps-0 pe-1 position-absolute top-0 end-0" (click)="resetSearchField()">
<i-bs width="1em" height="1em" name="x"></i-bs>
</button>
}
</form>
</div>
<ul ngbNav class="order-sm-3">
<li ngbDropdown class="nav-item dropdown">
@@ -76,14 +85,14 @@
</button>
<div class="sidebar-sticky pt-3 d-flex flex-column justify-space-around">
<ul class="nav flex-column">
<li class="nav-item app-link">
<li class="nav-item">
<a class="nav-link" routerLink="dashboard" routerLinkActive="active" (click)="closeMenu()"
ngbPopover="Dashboard" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
<i-bs class="me-1" name="house"></i-bs><span>&nbsp;<ng-container i18n>Dashboard</ng-container></span>
</a>
</li>
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }">
<li class="nav-item" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }">
<a class="nav-link" routerLink="documents" routerLinkActive="active" (click)="closeMenu()"
ngbPopover="Documents" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
@@ -91,10 +100,9 @@
</a>
</li>
</ul>
<div class="nav-group mt-3 mb-1" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.SavedView }">
<ng-container *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.SavedView }">
@if (savedViewService.loading || savedViewService.sidebarViews?.length > 0) {
<h6 class="sidebar-heading px-3 text-muted">
<h6 class="sidebar-heading px-3 mt-3 mb-1 text-muted">
<span i18n>Saved views</span>
@if (savedViewService.loading) {
<div class="spinner-border spinner-border-sm fw-normal ms-2" role="status"></div>
@@ -102,8 +110,8 @@
</h6>
}
<ul class="nav flex-column mb-2" cdkDropList (cdkDropListDropped)="onDrop($event)">
@for (view of savedViewService.sidebarViews; track view.id) {
<li class="nav-item w-100 app-link" cdkDrag [cdkDragDisabled]="!settingsService.organizingSidebarSavedViews"
@for (view of savedViewService.sidebarViews; track view) {
<li class="nav-item w-100" cdkDrag [cdkDragDisabled]="!settingsService.organizingSidebarSavedViews"
cdkDragPreviewContainer="parent" cdkDragPreviewClass="navItemDrag" (cdkDragStarted)="onDragStart($event)"
(cdkDragEnded)="onDragEnd($event)">
<a class="nav-link" [class.text-truncate]="!slimSidebarEnabled" routerLink="view/{{view.id}}"
@@ -120,18 +128,18 @@
</li>
}
</ul>
</div>
</ng-container>
<div class="nav-group mt-3 mb-1" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }">
<ng-container *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }">
@if (openDocuments.length > 0) {
<h6 class="sidebar-heading px-3 text-muted">
<h6 class="sidebar-heading px-3 mt-3 mb-1 text-muted">
<span i18n>Open documents</span>
</h6>
}
<ul class="nav flex-column mb-2">
@for (d of openDocuments; track d) {
<li class="nav-item w-100 app-link">
<a class="nav-link app-link" [class.text-truncate]="!slimSidebarEnabled" routerLink="documents/{{d.id}}"
<li class="nav-item w-100">
<a class="nav-link" [class.text-truncate]="!slimSidebarEnabled" routerLink="documents/{{d.id}}"
routerLinkActive="active" (click)="closeMenu()" [ngbPopover]="d.title | documentTitle"
[disablePopover]="!slimSidebarEnabled" placement="end" container="body" triggers="mouseenter:mouseleave"
popoverClass="popover-slim">
@@ -143,8 +151,8 @@
</li>
}
@if (openDocuments.length >= 1) {
<li class="nav-item w-100 app-link">
<a class="nav-link app-link" [class.text-truncate]="!slimSidebarEnabled" [routerLink]="[]" (click)="closeAll()"
<li class="nav-item w-100">
<a class="nav-link" [class.text-truncate]="!slimSidebarEnabled" [routerLink]="[]" (click)="closeAll()"
ngbPopover="Close all" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
<i-bs class="me-1" name="x"></i-bs><span>&nbsp;<ng-container i18n>Close all</ng-container></span>
@@ -152,184 +160,178 @@
</li>
}
</ul>
</div>
</ng-container>
<div class="nav-group mt-3 mb-1">
<h6 class="sidebar-heading px-3 text-muted">
<span i18n>Manage</span>
</h6>
<ul class="nav flex-column mb-2">
<li class="nav-item app-link"
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Correspondent }">
<a class="nav-link" routerLink="correspondents" routerLinkActive="active" (click)="closeMenu()"
ngbPopover="Correspondents" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
<i-bs class="me-1" name="person"></i-bs><span>&nbsp;<ng-container i18n>Correspondents</ng-container></span>
</a>
</li>
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Tag }"
tourAnchor="tour.tags">
<a class="nav-link" routerLink="tags" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Tags"
i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" container="body"
triggers="mouseenter:mouseleave" popoverClass="popover-slim">
<i-bs class="me-1" name="tags"></i-bs><span>&nbsp;<ng-container i18n>Tags</ng-container></span>
</a>
</li>
<li class="nav-item app-link"
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.DocumentType }">
<a class="nav-link" routerLink="documenttypes" routerLinkActive="active" (click)="closeMenu()"
ngbPopover="Document Types" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
<i-bs class="me-1" name="hash"></i-bs><span>&nbsp;<ng-container i18n>Document Types</ng-container></span>
</a>
</li>
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.StoragePath }">
<a class="nav-link" routerLink="storagepaths" routerLinkActive="active" (click)="closeMenu()"
ngbPopover="Storage Paths" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
<i-bs class="me-1" name="folder"></i-bs><span>&nbsp;<ng-container i18n>Storage Paths</ng-container></span>
</a>
</li>
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.CustomField }">
<a class="nav-link" routerLink="customfields" routerLinkActive="active" (click)="closeMenu()"
ngbPopover="Custom Fields" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
<i-bs class="me-1" name="ui-radios"></i-bs><span>&nbsp;<ng-container i18n>Custom Fields</ng-container></span>
</a>
</li>
<li class="nav-item app-link"
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Workflow }"
tourAnchor="tour.workflows">
<a class="nav-link" routerLink="workflows" routerLinkActive="active" (click)="closeMenu()"
ngbPopover="Workflows" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
<i-bs class="me-1" name="boxes"></i-bs><span>&nbsp;<ng-container i18n>Workflows</ng-container></span>
</a>
</li>
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.MailAccount }"
tourAnchor="tour.mail">
<a class="nav-link" routerLink="mail" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Mail"
i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" container="body"
triggers="mouseenter:mouseleave" popoverClass="popover-slim">
<i-bs class="me-1" name="envelope"></i-bs><span>&nbsp;<ng-container i18n>Mail</ng-container></span>
</a>
</li>
</ul>
</div>
<h6 class="sidebar-heading px-3 mt-3 mb-1 text-muted">
<span i18n>Manage</span>
</h6>
<ul class="nav flex-column mb-2">
<li class="nav-item"
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Correspondent }">
<a class="nav-link" routerLink="correspondents" routerLinkActive="active" (click)="closeMenu()"
ngbPopover="Correspondents" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
<i-bs class="me-1" name="person"></i-bs><span>&nbsp;<ng-container i18n>Correspondents</ng-container></span>
</a>
</li>
<li class="nav-item" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Tag }"
tourAnchor="tour.tags">
<a class="nav-link" routerLink="tags" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Tags"
i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" container="body"
triggers="mouseenter:mouseleave" popoverClass="popover-slim">
<i-bs class="me-1" name="tags"></i-bs><span>&nbsp;<ng-container i18n>Tags</ng-container></span>
</a>
</li>
<li class="nav-item"
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.DocumentType }">
<a class="nav-link" routerLink="documenttypes" routerLinkActive="active" (click)="closeMenu()"
ngbPopover="Document Types" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
<i-bs class="me-1" name="hash"></i-bs><span>&nbsp;<ng-container i18n>Document Types</ng-container></span>
</a>
</li>
<li class="nav-item" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.StoragePath }">
<a class="nav-link" routerLink="storagepaths" routerLinkActive="active" (click)="closeMenu()"
ngbPopover="Storage Paths" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
<i-bs class="me-1" name="folder"></i-bs><span>&nbsp;<ng-container i18n>Storage Paths</ng-container></span>
</a>
</li>
<li class="nav-item" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.CustomField }">
<a class="nav-link" routerLink="customfields" routerLinkActive="active" (click)="closeMenu()"
ngbPopover="Custom Fields" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
<i-bs class="me-1" name="ui-radios"></i-bs><span>&nbsp;<ng-container i18n>Custom Fields</ng-container></span>
</a>
</li>
<li class="nav-item"
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Workflow }"
tourAnchor="tour.workflows">
<a class="nav-link" routerLink="workflows" routerLinkActive="active" (click)="closeMenu()"
ngbPopover="Workflows" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
<i-bs class="me-1" name="boxes"></i-bs><span>&nbsp;<ng-container i18n>Workflows</ng-container></span>
</a>
</li>
<li class="nav-item" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.MailAccount }"
tourAnchor="tour.mail">
<a class="nav-link" routerLink="mail" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Mail"
i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" container="body"
triggers="mouseenter:mouseleave" popoverClass="popover-slim">
<i-bs class="me-1" name="envelope"></i-bs><span>&nbsp;<ng-container i18n>Mail</ng-container></span>
</a>
</li>
</ul>
<div class="nav-group mt-auto mb-1">
<h6 class="sidebar-heading px-3 pt-4 text-muted">
<span i18n>Administration</span>
</h6>
<ul class="nav flex-column mb-2">
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.UISettings }"
tourAnchor="tour.settings">
<a class="nav-link" routerLink="settings" routerLinkActive="active" (click)="closeMenu()"
ngbPopover="Settings" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
<i-bs class="me-1" name="gear"></i-bs><span>&nbsp;<ng-container i18n>Settings</ng-container></span>
</a>
</li>
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.AppConfig }">
<a class="nav-link" routerLink="config" routerLinkActive="active" (click)="closeMenu()"
ngbPopover="Configuration" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
<i-bs class="me-1" name="sliders2-vertical"></i-bs><span>&nbsp;<ng-container i18n>Configuration</ng-container></span>
</a>
</li>
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.User }">
<a class="nav-link" routerLink="usersgroups" routerLinkActive="active" (click)="closeMenu()"
ngbPopover="Users & Groups" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
<i-bs class="me-1" name="people"></i-bs><span>&nbsp;<ng-container i18n>Users & Groups</ng-container></span>
</a>
</li>
<li class="nav-item app-link"
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.PaperlessTask }"
tourAnchor="tour.file-tasks">
<a class="nav-link" routerLink="tasks" routerLinkActive="active" (click)="closeMenu()"
ngbPopover="File Tasks" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
<i-bs class="me-1" name="list-task"></i-bs><span>&nbsp;<ng-container i18n>File Tasks</ng-container>@if (tasksService.failedFileTasks.length > 0) {
<span><span class="badge bg-danger ms-2 d-inline">{{tasksService.failedFileTasks.length}}</span></span>
}</span>
@if (tasksService.failedFileTasks.length > 0 && slimSidebarEnabled) {
<span class="badge bg-danger position-absolute top-0 end-0 d-none d-md-block">{{tasksService.failedFileTasks.length}}</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"
<h6 class="sidebar-heading px-3 mt-auto pt-4 mb-1 text-muted">
<span i18n>Administration</span>
</h6>
<ul class="nav flex-column mb-2">
<li class="nav-item" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.UISettings }"
tourAnchor="tour.settings">
<a class="nav-link" routerLink="settings" routerLinkActive="active" (click)="closeMenu()"
ngbPopover="Settings" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
<i-bs class="me-1" name="gear"></i-bs><span>&nbsp;<ng-container i18n>Settings</ng-container></span>
</a>
</li>
<li class="nav-item" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.AppConfig }">
<a class="nav-link" routerLink="config" routerLinkActive="active" (click)="closeMenu()"
ngbPopover="Configuration" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
<i-bs class="me-1" name="sliders2-vertical"></i-bs><span>&nbsp;<ng-container i18n>Configuration</ng-container></span>
</a>
</li>
<li class="nav-item" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.User }">
<a class="nav-link" routerLink="usersgroups" routerLinkActive="active" (click)="closeMenu()"
ngbPopover="Users & Groups" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
<i-bs class="me-1" name="people"></i-bs><span>&nbsp;<ng-container i18n>Users & Groups</ng-container></span>
</a>
</li>
<li class="nav-item"
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.PaperlessTask }"
tourAnchor="tour.file-tasks">
<a class="nav-link" routerLink="tasks" routerLinkActive="active" (click)="closeMenu()"
ngbPopover="File Tasks" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
<i-bs class="me-1" name="list-task"></i-bs><span>&nbsp;<ng-container i18n>File Tasks</ng-container>@if (tasksService.failedFileTasks.length > 0) {
<span><span class="badge bg-danger ms-2 d-inline">{{tasksService.failedFileTasks.length}}</span></span>
}</span>
@if (tasksService.failedFileTasks.length > 0 && slimSidebarEnabled) {
<span class="badge bg-danger position-absolute top-0 end-0 d-none d-md-block">{{tasksService.failedFileTasks.length}}</span>
}
</a>
</li>
<li class="nav-item" *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>
<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"
i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" container="body"
triggers="mouseenter:mouseleave" popoverClass="popover-slim">
<i-bs class="d-flex" name="question-circle"></i-bs><span class="ms-1">&nbsp;<ng-container i18n>Documentation</ng-container></span>
</a>
</li>
<li class="nav-item" [class.visually-hidden]="slimSidebarEnabled">
<div class="px-3 py-0 text-muted small d-flex align-items-center flex-wrap">
<div class="me-3">
<a class="text-muted text-decoration-none" target="_blank" rel="noopener noreferrer"
href="https://github.com/paperless-ngx/paperless-ngx" ngbPopover="GitHub" 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>
{{ versionString }}
</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"
i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" container="body"
triggers="mouseenter:mouseleave" popoverClass="popover-slim">
<i-bs class="d-flex" name="question-circle"></i-bs><span class="ms-1">&nbsp;<ng-container i18n>Documentation</ng-container></span>
</a>
</li>
<li class="nav-item" [class.visually-hidden]="slimSidebarEnabled">
<div class="px-3 py-0 text-muted small d-flex align-items-center flex-wrap">
<div class="me-3">
<a class="text-muted text-decoration-none" target="_blank" rel="noopener noreferrer"
href="https://github.com/paperless-ngx/paperless-ngx" ngbPopover="GitHub" i18n-ngbPopover
[disablePopover]="!slimSidebarEnabled" placement="end" container="body"
triggers="mouseenter:mouseleave" popoverClass="popover-slim">
{{ versionString }}
</a>
</div>
@if (!settingsService.updateCheckingIsSet || appRemoteVersion) {
<div class="version-check">
<ng-template #updateAvailablePopContent>
<span class="small">Paperless-ngx {{ appRemoteVersion.version }} <ng-container i18n>is
available.</ng-container><br /><ng-container i18n>Click to view.</ng-container></span>
</ng-template>
<ng-template #updateCheckingNotEnabledPopContent>
<p class="small mb-2">
<ng-container i18n>Paperless-ngx can automatically check for updates</ng-container>
</p>
<div class="btn-group btn-group-xs flex-fill w-100">
<button class="btn btn-outline-primary" (click)="setUpdateChecking(true)">Enable</button>
<button class="btn btn-outline-secondary" (click)="setUpdateChecking(false)">Disable</button>
</div>
<p class="small mb-0 mt-2">
<a class="small text-decoration-none fst-italic" routerLink="/settings" fragment="update-checking" i18n>
How does this work?
</a>
</p>
</ng-template>
@if (settingsService.updateCheckingIsSet) {
@if (appRemoteVersion.update_available) {
<a class="small text-decoration-none" target="_blank" rel="noopener noreferrer"
href="https://github.com/paperless-ngx/paperless-ngx/releases"
[ngbPopover]="updateAvailablePopContent" popoverClass="shadow" triggers="mouseenter:mouseleave"
container="body">
<i-bs width="1.2em" height="1.2em" name="info-circle"></i-bs>
@if (appRemoteVersion?.update_available) {
&nbsp;<ng-container i18n>Update available</ng-container>
}
</a>
}
} @else {
<a class="small text-decoration-none" routerLink="/settings" fragment="update-checking"
[ngbPopover]="updateCheckingNotEnabledPopContent" popoverClass="shadow" triggers="mouseenter"
</div>
@if (!settingsService.updateCheckingIsSet || appRemoteVersion) {
<div class="version-check">
<ng-template #updateAvailablePopContent>
<span class="small">Paperless-ngx {{ appRemoteVersion.version }} <ng-container i18n>is
available.</ng-container><br /><ng-container i18n>Click to view.</ng-container></span>
</ng-template>
<ng-template #updateCheckingNotEnabledPopContent>
<p class="small mb-2">
<ng-container i18n>Paperless-ngx can automatically check for updates</ng-container>
</p>
<div class="btn-group btn-group-xs flex-fill w-100">
<button class="btn btn-outline-primary" (click)="setUpdateChecking(true)">Enable</button>
<button class="btn btn-outline-secondary" (click)="setUpdateChecking(false)">Disable</button>
</div>
<p class="small mb-0 mt-2">
<a class="small text-decoration-none fst-italic" routerLink="/settings" fragment="update-checking" i18n>
How does this work?
</a>
</p>
</ng-template>
@if (settingsService.updateCheckingIsSet) {
@if (appRemoteVersion.update_available) {
<a class="small text-decoration-none" target="_blank" rel="noopener noreferrer"
href="https://github.com/paperless-ngx/paperless-ngx/releases"
[ngbPopover]="updateAvailablePopContent" popoverClass="shadow" triggers="mouseenter:mouseleave"
container="body">
<i-bs width="1.2em" height="1.2em" name="info-circle"></i-bs>
@if (appRemoteVersion?.update_available) {
<ng-container i18n>Update available</ng-container>
}
</a>
}
</div>
}
</div>
</li>
</ul>
</div>
} @else {
<a class="small text-decoration-none" routerLink="/settings" fragment="update-checking"
[ngbPopover]="updateCheckingNotEnabledPopContent" popoverClass="shadow" triggers="mouseenter"
container="body">
<i-bs width="1.2em" height="1.2em" name="info-circle"></i-bs>
</a>
}
</div>
}
</div>
</li>
</ul>
</div>
</nav>
@@ -18,10 +18,6 @@
height: 0.8em;
}
.nav-group:not(:has(.app-link)) .sidebar-heading {
display: none !important;
}
// These come from the col-* classes for non-slim sidebar, needed for animation
@media (min-width: 768px) {
max-width: 25%;
@@ -257,6 +253,59 @@ main {
}
}
.navbar .search-form-container {
max-width: 550px;
form {
position: relative;
> i-bs {
position: absolute;
left: 0.6rem;
top: .35rem;
color: rgba(255, 255, 255, 0.6);
@media screen and (min-width: 768px) {
// adjust for smaller font size on non-mobile
top: 0.25rem;
}
}
}
&:focus-within {
form > i-bs {
display: none;
}
.form-control::placeholder {
color: rgba(255, 255, 255, 0);
}
}
.form-control {
color: rgba(255, 255, 255, 0.3);
background-color: rgba(0, 0, 0, 0.15);
padding-left: 1.8rem;
border-color: rgba(255, 255, 255, 0.2);
transition: all .3s ease, padding-left 0s ease, background-color 0s ease; // Safari requires all
max-width: 600px;
min-width: 300px; // 1/2 max
&::placeholder {
color: rgba(255, 255, 255, 0.4);
}
&:focus {
background-color: rgba(0, 0, 0, 0.3);
color: var(--bs-light);
flex-grow: 1;
padding-left: 0.5rem;
}
}
}
.version-check {
animation: pulse 2s ease-in-out 0s 1;
}
@@ -30,13 +30,14 @@ import { OpenDocumentsService } from 'src/app/services/open-documents.service'
import { ActivatedRoute, Router } from '@angular/router'
import { DocumentDetailComponent } from '../document-detail/document-detail.component'
import { SearchService } from 'src/app/services/rest/search.service'
import { DocumentListViewService } from 'src/app/services/document-list-view.service'
import { FILTER_FULLTEXT_QUERY } from 'src/app/data/filter-rule-type'
import { routes } from 'src/app/app-routing.module'
import { PermissionsGuard } from 'src/app/guards/permissions.guard'
import { CdkDragDrop, DragDropModule } from '@angular/cdk/drag-drop'
import { SavedView } from 'src/app/data/saved-view'
import { ProfileEditDialogComponent } from '../common/profile-edit-dialog/profile-edit-dialog.component'
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
import { GlobalSearchComponent } from './global-search/global-search.component'
const saved_views = [
{
@@ -88,17 +89,15 @@ describe('AppFrameComponent', () => {
let toastService: ToastService
let messagesService: DjangoMessagesService
let openDocumentsService: OpenDocumentsService
let searchService: SearchService
let documentListViewService: DocumentListViewService
let router: Router
let savedViewSpy
let modalService: NgbModal
beforeEach(async () => {
TestBed.configureTestingModule({
declarations: [
AppFrameComponent,
IfPermissionsDirective,
GlobalSearchComponent,
],
declarations: [AppFrameComponent, IfPermissionsDirective],
imports: [
HttpClientTestingModule,
BrowserModule,
@@ -160,6 +159,8 @@ describe('AppFrameComponent', () => {
toastService = TestBed.inject(ToastService)
messagesService = TestBed.inject(DjangoMessagesService)
openDocumentsService = TestBed.inject(OpenDocumentsService)
searchService = TestBed.inject(SearchService)
documentListViewService = TestBed.inject(DocumentListViewService)
modalService = TestBed.inject(NgbModal)
router = TestBed.inject(Router)
@@ -295,6 +296,62 @@ describe('AppFrameComponent', () => {
expect(component.canDeactivate()).toBeFalsy()
})
it('should call autocomplete endpoint on input', fakeAsync(() => {
const autocompleteSpy = jest.spyOn(searchService, 'autocomplete')
component.searchAutoComplete(of('hello')).subscribe()
tick(250)
expect(autocompleteSpy).toHaveBeenCalled()
component.searchAutoComplete(of('hello world 1')).subscribe()
tick(250)
expect(autocompleteSpy).toHaveBeenCalled()
}))
it('should handle autocomplete backend failure gracefully', fakeAsync(() => {
const serviceAutocompleteSpy = jest.spyOn(searchService, 'autocomplete')
serviceAutocompleteSpy.mockReturnValue(
throwError(() => new Error('autcomplete failed'))
)
// serviceAutocompleteSpy.mockReturnValue(of([' world']))
let result
component.searchAutoComplete(of('hello')).subscribe((res) => {
result = res
})
tick(250)
expect(serviceAutocompleteSpy).toHaveBeenCalled()
expect(result).toEqual([])
}))
it('should support reset search field', () => {
const resetSpy = jest.spyOn(component, 'resetSearchField')
const input = (fixture.nativeElement as HTMLDivElement).querySelector(
'input'
) as HTMLInputElement
input.dispatchEvent(new KeyboardEvent('keyup', { key: 'Escape' }))
expect(resetSpy).toHaveBeenCalled()
})
it('should support choosing a search item', () => {
expect(component.searchField.value).toEqual('')
component.itemSelected({ item: 'hello', preventDefault: () => true })
expect(component.searchField.value).toEqual('hello ')
component.itemSelected({ item: 'world', preventDefault: () => true })
expect(component.searchField.value).toEqual('hello world ')
})
it('should navigate via quickFilter on search', () => {
const str = 'hello world '
component.searchField.patchValue(str)
const qfSpy = jest.spyOn(documentListViewService, 'quickFilter')
component.search()
expect(qfSpy).toHaveBeenCalledWith([
{
rule_type: FILTER_FULLTEXT_QUERY,
value: str.trim(),
},
])
})
it('should disable global dropzone on start drag + drop, re-enable after', () => {
expect(settingsService.globalDropzoneEnabled).toBeTruthy()
component.onDragStart(null)
@@ -1,7 +1,15 @@
import { Component, HostListener, OnInit } from '@angular/core'
import { FormControl } from '@angular/forms'
import { ActivatedRoute, Router } from '@angular/router'
import { Observable } from 'rxjs'
import { first } from 'rxjs/operators'
import { from, Observable } from 'rxjs'
import {
debounceTime,
distinctUntilChanged,
map,
switchMap,
first,
catchError,
} from 'rxjs/operators'
import { Document } from 'src/app/data/document'
import { OpenDocumentsService } from 'src/app/services/open-documents.service'
import {
@@ -9,8 +17,11 @@ import {
DjangoMessagesService,
} from 'src/app/services/django-messages.service'
import { SavedViewService } from 'src/app/services/rest/saved-view.service'
import { SearchService } from 'src/app/services/rest/search.service'
import { environment } from 'src/environments/environment'
import { DocumentDetailComponent } from '../document-detail/document-detail.component'
import { DocumentListViewService } from 'src/app/services/document-list-view.service'
import { FILTER_FULLTEXT_QUERY } from 'src/app/data/filter-rule-type'
import {
RemoteVersionService,
AppRemoteVersion,
@@ -35,7 +46,6 @@ import {
} from '@angular/cdk/drag-drop'
import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
import { ProfileEditDialogComponent } from '../common/profile-edit-dialog/profile-edit-dialog.component'
import { ObjectWithId } from 'src/app/data/object-with-id'
@Component({
selector: 'pngx-app-frame',
@@ -53,12 +63,16 @@ export class AppFrameComponent
slimSidebarAnimating: boolean = false
searchField = new FormControl('')
constructor(
public router: Router,
private activatedRoute: ActivatedRoute,
private openDocumentsService: OpenDocumentsService,
private searchService: SearchService,
public savedViewService: SavedViewService,
private remoteVersionService: RemoteVersionService,
private list: DocumentListViewService,
public settingsService: SettingsService,
public tasksService: TasksService,
private readonly toastService: ToastService,
@@ -150,6 +164,65 @@ export class AppFrameComponent
return !this.openDocumentsService.hasDirty()
}
get searchFieldEmpty(): boolean {
return this.searchField.value.trim().length == 0
}
resetSearchField() {
this.searchField.reset('')
}
searchFieldKeyup(event: KeyboardEvent) {
if (event.key == 'Escape') {
this.resetSearchField()
}
}
searchAutoComplete = (text$: Observable<string>) =>
text$.pipe(
debounceTime(200),
distinctUntilChanged(),
map((term) => {
if (term.lastIndexOf(' ') != -1) {
return term.substring(term.lastIndexOf(' ') + 1)
} else {
return term
}
}),
switchMap((term) =>
term.length < 2
? from([[]])
: this.searchService.autocomplete(term).pipe(
catchError(() => {
return from([[]])
})
)
)
)
itemSelected(event) {
event.preventDefault()
let currentSearch: string = this.searchField.value
let lastSpaceIndex = currentSearch.lastIndexOf(' ')
if (lastSpaceIndex != -1) {
currentSearch = currentSearch.substring(0, lastSpaceIndex + 1)
currentSearch += event.item + ' '
} else {
currentSearch = event.item + ' '
}
this.searchField.patchValue(currentSearch)
}
search() {
this.closeMenu()
this.list.quickFilter([
{
rule_type: FILTER_FULLTEXT_QUERY,
value: (this.searchField.value as string).trim(),
},
])
}
closeDocument(d: Document) {
this.openDocumentsService
.closeDocument(d)
@@ -1,174 +0,0 @@
<div ngbDropdown #resultsDropdown="ngbDropdown" (openChange)="onDropdownOpenChange">
<form class="form-inline position-relative">
<i-bs width="1em" height="1em" name="search"></i-bs>
<div class="input-group">
<div class="form-control form-control-sm">
<input class="bg-transparent border-0 w-100 h-100" #searchInput type="text" name="query"
placeholder="Search" aria-label="Search" i18n-placeholder
autocomplete="off"
spellcheck="false"
[(ngModel)]="query"
(ngModelChange)="this.queryDebounce.next($event)"
(keydown)="searchInputKeyDown($event)"
ngbDropdownAnchor>
<div class="position-absolute top-50 end-0 translate-middle">
@if (loading) {
<div class="spinner-border spinner-border-sm text-muted mt-1"></div>
}
</div>
</div>
@if (query) {
<button class="btn btn-sm btn-outline-secondary" type="button" (click)="runFullSearch()">
@if (useAdvancedForFullSearch) {
<ng-container i18n>Advanced search</ng-container>
} @else {
<ng-container i18n>Search</ng-container>
}
<i-bs width="1em" height="1em" name="arrow-right-short"></i-bs>
</button>
}
</div>
</form>
<ng-template #resultItemTemplate let-item="item" let-nameProp="nameProp" let-type="type" let-icon="icon" let-date="date">
<div #resultItem ngbDropdownItem class="py-2 d-flex align-items-center focus-ring border-0 cursor-pointer" tabindex="-1"
(click)="primaryAction(type, item, $event)"
(mouseenter)="onItemHover($event)">
<i-bs width="1.2em" height="1.2em" name="{{icon}}" class="me-2 text-muted"></i-bs>
<div class="text-truncate">
{{item[nameProp]}}
@if (date) {
<small class="small text-muted">{{date | customDate}}</small>
}
</div>
<div class="btn-group ms-auto">
<button #primaryButton type="button" class="btn btn-sm btn-outline-primary d-flex"
(click)="primaryAction(type, item, $event); $event.stopImmediatePropagation()"
(keydown)="onButtonKeyDown($event)"
[disabled]="disablePrimaryButton(type, item)"
(mouseenter)="onButtonHover($event)">
@if (type === DataType.Document) {
<i-bs width="1em" height="1em" name="pencil"></i-bs>
<span>&nbsp;<ng-container i18n>Open</ng-container></span>
} @else if (type === DataType.SavedView) {
<i-bs width="1em" height="1em" name="eye"></i-bs>
<span>&nbsp;<ng-container i18n>Open</ng-container></span>
} @else if (type === DataType.Workflow || type === DataType.CustomField || type === DataType.Group || type === DataType.User || type === DataType.MailAccount || type === DataType.MailRule) {
<i-bs width="1em" height="1em" name="pencil"></i-bs>
<span>&nbsp;<ng-container i18n>Edit</ng-container></span>
} @else {
<i-bs width="1em" height="1em" name="filter"></i-bs>
<span>&nbsp;<ng-container i18n>Filter documents</ng-container></span>
}
</button>
@if (type !== DataType.SavedView && type !== DataType.Workflow && type !== DataType.CustomField && type !== DataType.Group && type !== DataType.User && type !== DataType.MailAccount && type !== DataType.MailRule) {
<button #secondaryButton type="button" class="btn btn-sm btn-outline-primary d-flex"
(click)="secondaryAction(type, item, $event); $event.stopImmediatePropagation()"
(keydown)="onButtonKeyDown($event)"
[disabled]="disableSecondaryButton(type, item)"
(mouseenter)="onButtonHover($event)">
@if (type === DataType.Document) {
<i-bs width="1em" height="1em" name="download"></i-bs>
<span>&nbsp;<ng-container i18n>Download</ng-container></span>
} @else {
<i-bs width="1em" height="1em" name="pencil"></i-bs>
<span>&nbsp;<ng-container i18n>Edit</ng-container></span>
}
</button>
}
</div>
</div>
</ng-template>
<div ngbDropdownMenu class="w-100 mh-75 overflow-y-scroll shadow-lg">
<div (keydown)="dropdownKeyDown($event)">
@if (searchResults?.total === 0) {
<h6 class="dropdown-header" i18n="@@searchResults.noResults">No results</h6>
} @else {
@if (searchResults?.documents.length) {
<h6 class="dropdown-header" i18n="@@searchResults.documents">Documents</h6>
@for (document of searchResults.documents; track document.id) {
<ng-container *ngTemplateOutlet="resultItemTemplate; context: {item: document, nameProp: 'title', type: DataType.Document, icon: 'file-text', date: document.added}"></ng-container>
}
}
@if (searchResults?.saved_views.length) {
<h6 class="dropdown-header" i18n="@@searchResults.saved_views">Saved Views</h6>
@for (saved_view of searchResults.saved_views; track saved_view.id) {
<ng-container *ngTemplateOutlet="resultItemTemplate; context: {item: saved_view, nameProp: 'name', type: DataType.SavedView, icon: 'funnel'}"></ng-container>
}
}
@if (searchResults?.tags.length) {
<h6 class="dropdown-header" i18n="@@searchResults.tags">Tags</h6>
@for (tag of searchResults.tags; track tag.id) {
<ng-container *ngTemplateOutlet="resultItemTemplate; context: {item: tag, nameProp: 'name', type: DataType.Tag, icon: 'tag'}"></ng-container>
}
}
@if (searchResults?.correspondents.length) {
<h6 class="dropdown-header" i18n="@@searchResults.correspondents">Correspondents</h6>
@for (correspondent of searchResults.correspondents; track correspondent.id) {
<ng-container *ngTemplateOutlet="resultItemTemplate; context: {item: correspondent, nameProp: 'name', type: DataType.Correspondent, icon: 'person'}"></ng-container>
}
}
@if (searchResults?.document_types.length) {
<h6 class="dropdown-header" i18n="@@searchResults.documentTypes">Document types</h6>
@for (documentType of searchResults.document_types; track documentType.id) {
<ng-container *ngTemplateOutlet="resultItemTemplate; context: {item: documentType, nameProp: 'name', type: DataType.DocumentType, icon: 'file-earmark'}"></ng-container>
}
}
@if (searchResults?.storage_paths.length) {
<h6 class="dropdown-header" i18n="@@searchResults.storagePaths">Storage paths</h6>
@for (storagePath of searchResults.storage_paths; track storagePath.id) {
<ng-container *ngTemplateOutlet="resultItemTemplate; context: {item: storagePath, nameProp: 'name', type: DataType.StoragePath, icon: 'folder'}"></ng-container>
}
}
@if (searchResults?.users.length) {
<h6 class="dropdown-header" i18n="@@searchResults.users">Users</h6>
@for (user of searchResults.users; track user.id) {
<ng-container *ngTemplateOutlet="resultItemTemplate; context: {item: user, nameProp: 'username', type: DataType.User, icon: 'person-square'}"></ng-container>
}
}
@if (searchResults?.groups.length) {
<h6 class="dropdown-header" i18n="@@searchResults.groups">Groups</h6>
@for (group of searchResults.groups; track group.id) {
<ng-container *ngTemplateOutlet="resultItemTemplate; context: {item: group, nameProp: 'name', type: DataType.Group, icon: 'people'}"></ng-container>
}
}
@if (searchResults?.custom_fields.length) {
<h6 class="dropdown-header" i18n="@@searchResults.customFields">Custom fields</h6>
@for (customField of searchResults.custom_fields; track customField.id) {
<ng-container *ngTemplateOutlet="resultItemTemplate; context: {item: customField, nameProp: 'name', type: DataType.CustomField, icon: 'ui-radios'}"></ng-container>
}
}
@if (searchResults?.mail_accounts.length) {
<h6 class="dropdown-header" i18n="@@searchResults.mailAccounts">Mail accounts</h6>
@for (mailAccount of searchResults.mail_accounts; track mailAccount.id) {
<ng-container *ngTemplateOutlet="resultItemTemplate; context: {item: mailAccount, nameProp: 'name', type: DataType.MailAccount, icon: 'envelope-at'}"></ng-container>
}
}
@if (searchResults?.mail_rules.length) {
<h6 class="dropdown-header" i18n="@@searchResults.mailRules">Mail rules</h6>
@for (mailRule of searchResults.mail_rules; track mailRule.id) {
<ng-container *ngTemplateOutlet="resultItemTemplate; context: {item: mailRule, nameProp: 'name', type: DataType.MailRule, icon: 'envelope'}"></ng-container>
}
}
@if (searchResults?.workflows.length) {
<h6 class="dropdown-header" i18n="@@searchResults.workflows">Workflows</h6>
@for (workflow of searchResults.workflows; track workflow.id) {
<ng-container *ngTemplateOutlet="resultItemTemplate; context: {item: workflow, nameProp: 'name', type: DataType.Workflow, icon: 'boxes'}"></ng-container>
}
}
}
</div>
</div>
</div>
@@ -1,101 +0,0 @@
form {
position: relative;
> i-bs[name="search"] {
position: absolute;
left: 0.6rem;
top: .35rem;
color: rgba(255, 255, 255, 0.6);
@media screen and (min-width: 768px) {
// adjust for smaller font size on non-mobile
top: 0.25rem;
}
}
&:focus-within {
i-bs[name="search"],
.badge {
display: none !important;
}
.form-control::placeholder {
color: rgba(255, 255, 255, 0);
}
}
.badge {
font-size: 0.8rem;
}
.input-group .btn {
border-color: rgba(255, 255, 255, 0.2);
color: var(--pngx-primary-text-contrast);
padding-top: .15rem;
padding-bottom: .15rem;
min-height: calc(1.3em + 0.5rem + calc(var(--bs-border-width) * 2)) !important;
}
.form-control {
color: rgba(255, 255, 255, 0.3);
background-color: rgba(0, 0, 0, 0.15);
padding-left: 1.8rem;
border-color: rgba(255, 255, 255, 0.2);
transition: all .3s ease, padding-left 0s ease, background-color 0s ease; // Safari requires all
> input {
outline: none;
color: var(--pngx-primary-text-contrast);
&::placeholder {
color: rgba(255, 255, 255, 0.4);
}
}
&:focus-within {
background-color: rgba(0, 0, 0, 0.3);
color: var(--bs-light);
flex-grow: 1;
padding-left: 0.5rem;
}
}
}
* {
--pngx-focus-alpha: 0;
}
.cursor-pointer {
cursor: pointer;
}
.mh-75 {
max-height: 75vh;
}
.dropdown-item {
&:has(button:focus) {
background-color: var(--pngx-bg-darker);
}
& button {
transition: all 0.3s ease, color 0.15s ease;
max-width: 2rem;
overflow: hidden;
}
& button span {
opacity: 0;
transition: inherit;
}
&:hover button,
&:has(button:focus) button {
max-width: 10rem;
}
&:hover button span,
&:has(button:focus) span {
opacity: 1;
}
}
@@ -1,546 +0,0 @@
import {
ComponentFixture,
TestBed,
fakeAsync,
tick,
} from '@angular/core/testing'
import { GlobalSearchComponent } from './global-search.component'
import { of } from 'rxjs'
import { SearchService } from 'src/app/services/rest/search.service'
import { Router } from '@angular/router'
import {
NgbDropdownModule,
NgbModal,
NgbModalModule,
NgbModalRef,
} from '@ng-bootstrap/ng-bootstrap'
import { CorrespondentEditDialogComponent } from '../../common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component'
import { UserEditDialogComponent } from '../../common/edit-dialog/user-edit-dialog/user-edit-dialog.component'
import { DocumentListViewService } from 'src/app/services/document-list-view.service'
import { HttpClientTestingModule } from '@angular/common/http/testing'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import {
FILTER_FULLTEXT_QUERY,
FILTER_HAS_CORRESPONDENT_ANY,
FILTER_HAS_DOCUMENT_TYPE_ANY,
FILTER_HAS_STORAGE_PATH_ANY,
FILTER_HAS_TAGS_ALL,
FILTER_TITLE_CONTENT,
} from 'src/app/data/filter-rule-type'
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
import { DocumentService } from 'src/app/services/rest/document.service'
import { MailRuleEditDialogComponent } from '../../common/edit-dialog/mail-rule-edit-dialog/mail-rule-edit-dialog.component'
import { MailAccountEditDialogComponent } from '../../common/edit-dialog/mail-account-edit-dialog/mail-account-edit-dialog.component'
import { GroupEditDialogComponent } from '../../common/edit-dialog/group-edit-dialog/group-edit-dialog.component'
import { CustomFieldEditDialogComponent } from '../../common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component'
import { WorkflowEditDialogComponent } from '../../common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component'
import { ElementRef } from '@angular/core'
import { ToastService } from 'src/app/services/toast.service'
import { DataType } from 'src/app/data/datatype'
import { queryParamsFromFilterRules } from 'src/app/utils/query-params'
import { SettingsService } from 'src/app/services/settings.service'
import { GlobalSearchType, SETTINGS_KEYS } from 'src/app/data/ui-settings'
const searchResults = {
total: 11,
documents: [
{
id: 1,
title: 'Test',
created_at: new Date(),
updated_at: new Date(),
document_type: { id: 1, name: 'Test' },
storage_path: { id: 1, path: 'Test' },
tags: [],
correspondents: [],
custom_fields: [],
},
],
saved_views: [
{
id: 1,
name: 'TestSavedView',
},
],
correspondents: [
{
id: 1,
name: 'TestCorrespondent',
},
],
document_types: [
{
id: 1,
name: 'TestDocumentType',
},
],
storage_paths: [
{
id: 1,
name: 'TestStoragePath',
},
],
tags: [
{
id: 1,
name: 'TestTag',
},
],
users: [
{
id: 1,
username: 'TestUser',
},
],
groups: [
{
id: 1,
name: 'TestGroup',
},
],
mail_accounts: [
{
id: 1,
name: 'TestMailAccount',
},
],
mail_rules: [
{
id: 1,
name: 'TestMailRule',
},
],
custom_fields: [
{
id: 1,
name: 'TestCustomField',
},
],
workflows: [
{
id: 1,
name: 'TestWorkflow',
},
],
}
describe('GlobalSearchComponent', () => {
let component: GlobalSearchComponent
let fixture: ComponentFixture<GlobalSearchComponent>
let searchService: SearchService
let router: Router
let modalService: NgbModal
let documentService: DocumentService
let documentListViewService: DocumentListViewService
let toastService: ToastService
let settingsService: SettingsService
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [GlobalSearchComponent],
imports: [
HttpClientTestingModule,
NgbModalModule,
NgbDropdownModule,
FormsModule,
ReactiveFormsModule,
NgxBootstrapIconsModule.pick(allIcons),
],
}).compileComponents()
searchService = TestBed.inject(SearchService)
router = TestBed.inject(Router)
modalService = TestBed.inject(NgbModal)
documentService = TestBed.inject(DocumentService)
documentListViewService = TestBed.inject(DocumentListViewService)
toastService = TestBed.inject(ToastService)
settingsService = TestBed.inject(SettingsService)
fixture = TestBed.createComponent(GlobalSearchComponent)
component = fixture.componentInstance
fixture.detectChanges()
})
it('should handle keyboard nav', () => {
const focusSpy = jest.spyOn(component.searchInput.nativeElement, 'focus')
document.dispatchEvent(new KeyboardEvent('keydown', { key: '/' }))
expect(focusSpy).toHaveBeenCalled()
component.searchResults = searchResults as any
component.resultsDropdown.open()
fixture.detectChanges()
component['currentItemIndex'] = 0
component['setCurrentItem']()
const firstItemFocusSpy = jest.spyOn(
component.primaryButtons.get(1).nativeElement,
'focus'
)
component.dropdownKeyDown(
new KeyboardEvent('keydown', { key: 'ArrowDown' })
)
expect(component['currentItemIndex']).toBe(1)
expect(firstItemFocusSpy).toHaveBeenCalled()
const secondaryItemFocusSpy = jest.spyOn(
component.secondaryButtons.get(1).nativeElement,
'focus'
)
component.dropdownKeyDown(
new KeyboardEvent('keydown', { key: 'ArrowRight' })
)
expect(secondaryItemFocusSpy).toHaveBeenCalled()
component.dropdownKeyDown(
new KeyboardEvent('keydown', { key: 'ArrowLeft' })
)
expect(firstItemFocusSpy).toHaveBeenCalled()
const zeroItemSpy = jest.spyOn(
component.primaryButtons.get(0).nativeElement,
'focus'
)
component.dropdownKeyDown(new KeyboardEvent('keydown', { key: 'ArrowUp' }))
expect(component['currentItemIndex']).toBe(0)
expect(zeroItemSpy).toHaveBeenCalled()
const inputFocusSpy = jest.spyOn(
component.searchInput.nativeElement,
'focus'
)
component.dropdownKeyDown(new KeyboardEvent('keydown', { key: 'ArrowUp' }))
expect(component['currentItemIndex']).toBe(-1)
expect(inputFocusSpy).toHaveBeenCalled()
component.dropdownKeyDown(
new KeyboardEvent('keydown', { key: 'ArrowDown' })
)
component['currentItemIndex'] = searchResults.total - 1
component['setCurrentItem']()
component.dropdownKeyDown(
new KeyboardEvent('keydown', { key: 'ArrowDown' })
)
expect(component['currentItemIndex']).toBe(-1)
// Search input
component.searchInputKeyDown(
new KeyboardEvent('keydown', { key: 'ArrowUp' })
)
expect(component['currentItemIndex']).toBe(searchResults.total - 1)
component.searchInputKeyDown(
new KeyboardEvent('keydown', { key: 'ArrowDown' })
)
expect(component['currentItemIndex']).toBe(0)
component.searchResults = { total: 1 } as any
const primaryActionSpy = jest.spyOn(component, 'primaryAction')
component.searchInputKeyDown(new KeyboardEvent('keydown', { key: 'Enter' }))
expect(primaryActionSpy).toHaveBeenCalled()
component.query = 'test'
const resetSpy = jest.spyOn(GlobalSearchComponent.prototype as any, 'reset')
component.searchInputKeyDown(
new KeyboardEvent('keydown', { key: 'Escape' })
)
expect(resetSpy).toHaveBeenCalled()
component.query = ''
const blurSpy = jest.spyOn(component.searchInput.nativeElement, 'blur')
component.searchInputKeyDown(
new KeyboardEvent('keydown', { key: 'Escape' })
)
expect(blurSpy).toHaveBeenCalled()
component.searchResults = { total: 1 } as any
component.resultsDropdown.open()
component.searchInputKeyDown(
new KeyboardEvent('keydown', { key: 'ArrowDown' })
)
expect(component['currentItemIndex']).toBe(0)
const closeSpy = jest.spyOn(component.resultsDropdown, 'close')
component.dropdownKeyDown(new KeyboardEvent('keydown', { key: 'Escape' }))
expect(closeSpy).toHaveBeenCalled()
component.searchResults = searchResults as any
component.resultsDropdown.open()
component.query = 'test'
const advancedSearchSpy = jest.spyOn(component, 'runFullSearch')
component.searchInputKeyDown(new KeyboardEvent('keydown', { key: 'Enter' }))
expect(advancedSearchSpy).toHaveBeenCalled()
})
it('should search on query debounce', fakeAsync(() => {
const query = 'test'
const searchSpy = jest.spyOn(searchService, 'globalSearch')
searchSpy.mockReturnValue(of({} as any))
const dropdownOpenSpy = jest.spyOn(component.resultsDropdown, 'open')
component.queryDebounce.next(query)
tick(401)
expect(searchSpy).toHaveBeenCalledWith(query)
expect(dropdownOpenSpy).toHaveBeenCalled()
}))
it('should support primary action', () => {
const object = { id: 1 }
const routerSpy = jest.spyOn(router, 'navigate')
const modalSpy = jest.spyOn(modalService, 'open')
let modal: NgbModalRef
modalService.activeInstances.subscribe((m) => (modal = m[m.length - 1]))
component.primaryAction(DataType.Document, object)
expect(routerSpy).toHaveBeenCalledWith(['/documents', object.id], {})
component.primaryAction(DataType.SavedView, object)
expect(routerSpy).toHaveBeenCalledWith(['/view', object.id], {})
component.primaryAction(DataType.Correspondent, object)
expect(routerSpy).toHaveBeenCalledWith(['/documents'], {
queryParams: Object.assign(
{
page: 1,
reverse: 1,
sort: 'created',
},
queryParamsFromFilterRules([
{
rule_type: FILTER_HAS_CORRESPONDENT_ANY,
value: object.id.toString(),
},
])
),
})
component.primaryAction(DataType.DocumentType, object)
expect(routerSpy).toHaveBeenCalledWith(['/documents'], {
queryParams: Object.assign(
{
page: 1,
reverse: 1,
sort: 'created',
},
queryParamsFromFilterRules([
{
rule_type: FILTER_HAS_DOCUMENT_TYPE_ANY,
value: object.id.toString(),
},
])
),
})
component.primaryAction(DataType.StoragePath, object)
expect(routerSpy).toHaveBeenCalledWith(['/documents'], {
queryParams: Object.assign(
{
page: 1,
reverse: 1,
sort: 'created',
},
queryParamsFromFilterRules([
{
rule_type: FILTER_HAS_STORAGE_PATH_ANY,
value: object.id.toString(),
},
])
),
})
component.primaryAction(DataType.Tag, object)
expect(routerSpy).toHaveBeenCalledWith(['/documents'], {
queryParams: Object.assign(
{
page: 1,
reverse: 1,
sort: 'created',
},
queryParamsFromFilterRules([
{ rule_type: FILTER_HAS_TAGS_ALL, value: object.id.toString() },
])
),
})
component.primaryAction(DataType.User, object)
expect(modalSpy).toHaveBeenCalledWith(UserEditDialogComponent, {
size: 'lg',
})
component.primaryAction(DataType.Group, object)
expect(modalSpy).toHaveBeenCalledWith(GroupEditDialogComponent, {
size: 'lg',
})
component.primaryAction(DataType.MailAccount, object)
expect(modalSpy).toHaveBeenCalledWith(MailAccountEditDialogComponent, {
size: 'xl',
})
component.primaryAction(DataType.MailRule, object)
expect(modalSpy).toHaveBeenCalledWith(MailRuleEditDialogComponent, {
size: 'xl',
})
component.primaryAction(DataType.CustomField, object)
expect(modalSpy).toHaveBeenCalledWith(CustomFieldEditDialogComponent, {
size: 'md',
})
component.primaryAction(DataType.Workflow, object)
expect(modalSpy).toHaveBeenCalledWith(WorkflowEditDialogComponent, {
size: 'xl',
})
const editDialog = modal.componentInstance as CustomFieldEditDialogComponent
const toastErrorSpy = jest.spyOn(toastService, 'showError')
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
// fail first
editDialog.failed.emit({ error: 'error creating item' })
expect(toastErrorSpy).toHaveBeenCalled()
// succeed
editDialog.succeeded.emit(true)
expect(toastInfoSpy).toHaveBeenCalled()
})
it('should support secondary action', () => {
const doc = searchResults.documents[0]
const openSpy = jest.spyOn(window, 'open')
component.secondaryAction('document', doc)
expect(openSpy).toHaveBeenCalledWith(documentService.getDownloadUrl(doc.id))
const correspondent = searchResults.correspondents[0]
const modalSpy = jest.spyOn(modalService, 'open')
let modal: NgbModalRef
modalService.activeInstances.subscribe((m) => (modal = m[m.length - 1]))
component.secondaryAction(DataType.Correspondent, correspondent)
expect(modalSpy).toHaveBeenCalledWith(CorrespondentEditDialogComponent, {
size: 'md',
})
component.secondaryAction(
DataType.DocumentType,
searchResults.document_types[0]
)
expect(modalSpy).toHaveBeenCalledWith(CorrespondentEditDialogComponent, {
size: 'md',
})
component.secondaryAction(
DataType.StoragePath,
searchResults.storage_paths[0]
)
expect(modalSpy).toHaveBeenCalledWith(CorrespondentEditDialogComponent, {
size: 'md',
})
component.secondaryAction(DataType.Tag, searchResults.tags[0])
expect(modalSpy).toHaveBeenCalledWith(CorrespondentEditDialogComponent, {
size: 'md',
})
const editDialog = modal.componentInstance as CustomFieldEditDialogComponent
const toastErrorSpy = jest.spyOn(toastService, 'showError')
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
// fail first
editDialog.failed.emit({ error: 'error creating item' })
expect(toastErrorSpy).toHaveBeenCalled()
// succeed
editDialog.succeeded.emit(true)
expect(toastInfoSpy).toHaveBeenCalled()
})
it('should support reset', () => {
const debounce = jest.spyOn(component.queryDebounce, 'next')
const closeSpy = jest.spyOn(component.resultsDropdown, 'close')
component['reset'](true)
expect(debounce).toHaveBeenCalledWith(null)
expect(component.searchResults).toBeNull()
expect(component['currentItemIndex']).toBe(-1)
expect(closeSpy).toHaveBeenCalled()
})
it('should support focus current item', () => {
component.searchResults = searchResults as any
fixture.detectChanges()
const focusSpy = jest.spyOn(
component.primaryButtons.get(0).nativeElement,
'focus'
)
component['currentItemIndex'] = 0
component['setCurrentItem']()
expect(focusSpy).toHaveBeenCalled()
})
it('should reset on dropdown close', () => {
const resetSpy = jest.spyOn(GlobalSearchComponent.prototype as any, 'reset')
component.onDropdownOpenChange(false)
expect(resetSpy).toHaveBeenCalled()
})
it('should focus button on dropdown item hover', () => {
component.searchResults = searchResults as any
fixture.detectChanges()
const item: ElementRef = component.resultItems.first
const focusSpy = jest.spyOn(
component.primaryButtons.first.nativeElement,
'focus'
)
component.onItemHover({ currentTarget: item.nativeElement } as any)
expect(component['currentItemIndex']).toBe(0)
expect(focusSpy).toHaveBeenCalled()
})
it('should focus on button hover', () => {
const event = { currentTarget: { focus: jest.fn() } }
const focusSpy = jest.spyOn(event.currentTarget, 'focus')
component.onButtonHover(event as any)
expect(focusSpy).toHaveBeenCalled()
})
it('should support open in new window', () => {
const openSpy = jest.spyOn(window, 'open')
const event = new Event('click')
event['ctrlKey'] = true
component.primaryAction(DataType.Document, { id: 2 }, event as any)
expect(openSpy).toHaveBeenCalledWith('/documents/2', '_blank')
component.searchResults = searchResults as any
component.resultsDropdown.open()
fixture.detectChanges()
const button = component.primaryButtons.get(0).nativeElement
const keyboardEvent = new KeyboardEvent('keydown', {
key: 'Enter',
ctrlKey: true,
})
const dispatchSpy = jest.spyOn(button, 'dispatchEvent')
button.dispatchEvent(keyboardEvent)
expect(dispatchSpy).toHaveBeenCalledTimes(2) // once for keydown, second for click
})
it('should support title content search and advanced search', () => {
const qfSpy = jest.spyOn(documentListViewService, 'quickFilter')
component.query = 'test'
component.runFullSearch()
expect(qfSpy).toHaveBeenCalledWith([
{ rule_type: FILTER_TITLE_CONTENT, value: 'test' },
])
settingsService.set(
SETTINGS_KEYS.SEARCH_FULL_TYPE,
GlobalSearchType.ADVANCED
)
component.query = 'test'
component.runFullSearch()
expect(qfSpy).toHaveBeenCalledWith([
{ rule_type: FILTER_FULLTEXT_QUERY, value: 'test' },
])
})
})
@@ -1,416 +0,0 @@
import {
Component,
ViewChild,
ElementRef,
ViewChildren,
QueryList,
OnInit,
} from '@angular/core'
import { Router } from '@angular/router'
import { NgbDropdown, NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap'
import { Subject, debounceTime, distinctUntilChanged, filter, map } from 'rxjs'
import {
FILTER_FULLTEXT_QUERY,
FILTER_HAS_CORRESPONDENT_ANY,
FILTER_HAS_DOCUMENT_TYPE_ANY,
FILTER_HAS_STORAGE_PATH_ANY,
FILTER_HAS_TAGS_ALL,
FILTER_TITLE_CONTENT,
} from 'src/app/data/filter-rule-type'
import { DataType } from 'src/app/data/datatype'
import { ObjectWithId } from 'src/app/data/object-with-id'
import { DocumentListViewService } from 'src/app/services/document-list-view.service'
import {
PermissionsService,
PermissionAction,
} from 'src/app/services/permissions.service'
import { DocumentService } from 'src/app/services/rest/document.service'
import {
GlobalSearchResult,
SearchService,
} from 'src/app/services/rest/search.service'
import { ToastService } from 'src/app/services/toast.service'
import { CorrespondentEditDialogComponent } from '../../common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component'
import { CustomFieldEditDialogComponent } from '../../common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component'
import { DocumentTypeEditDialogComponent } from '../../common/edit-dialog/document-type-edit-dialog/document-type-edit-dialog.component'
import { EditDialogMode } from '../../common/edit-dialog/edit-dialog.component'
import { GroupEditDialogComponent } from '../../common/edit-dialog/group-edit-dialog/group-edit-dialog.component'
import { MailAccountEditDialogComponent } from '../../common/edit-dialog/mail-account-edit-dialog/mail-account-edit-dialog.component'
import { MailRuleEditDialogComponent } from '../../common/edit-dialog/mail-rule-edit-dialog/mail-rule-edit-dialog.component'
import { StoragePathEditDialogComponent } from '../../common/edit-dialog/storage-path-edit-dialog/storage-path-edit-dialog.component'
import { TagEditDialogComponent } from '../../common/edit-dialog/tag-edit-dialog/tag-edit-dialog.component'
import { UserEditDialogComponent } from '../../common/edit-dialog/user-edit-dialog/user-edit-dialog.component'
import { WorkflowEditDialogComponent } from '../../common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component'
import { HotKeyService } from 'src/app/services/hot-key.service'
import { paramsFromViewState } from 'src/app/utils/query-params'
import { SettingsService } from 'src/app/services/settings.service'
import { GlobalSearchType, SETTINGS_KEYS } from 'src/app/data/ui-settings'
@Component({
selector: 'pngx-global-search',
templateUrl: './global-search.component.html',
styleUrl: './global-search.component.scss',
})
export class GlobalSearchComponent implements OnInit {
public DataType = DataType
public query: string
public queryDebounce: Subject<string>
public searchResults: GlobalSearchResult
private currentItemIndex: number = -1
private domIndex: number = -1
public loading: boolean = false
@ViewChild('searchInput') searchInput: ElementRef
@ViewChild('resultsDropdown') resultsDropdown: NgbDropdown
@ViewChildren('resultItem') resultItems: QueryList<ElementRef>
@ViewChildren('primaryButton') primaryButtons: QueryList<ElementRef>
@ViewChildren('secondaryButton') secondaryButtons: QueryList<ElementRef>
get useAdvancedForFullSearch(): boolean {
return (
this.settingsService.get(SETTINGS_KEYS.SEARCH_FULL_TYPE) ===
GlobalSearchType.ADVANCED
)
}
constructor(
public searchService: SearchService,
private router: Router,
private modalService: NgbModal,
private documentService: DocumentService,
private documentListViewService: DocumentListViewService,
private permissionsService: PermissionsService,
private toastService: ToastService,
private hotkeyService: HotKeyService,
private settingsService: SettingsService
) {
this.queryDebounce = new Subject<string>()
this.queryDebounce
.pipe(
debounceTime(400),
map((query) => query?.trim()),
filter((query) => !query?.length || query?.length > 2),
distinctUntilChanged()
)
.subscribe((text) => {
this.query = text
if (text) this.search(text)
})
}
public ngOnInit() {
this.hotkeyService
.addShortcut({ keys: '/', description: $localize`Global search` })
.subscribe(() => {
this.searchInput.nativeElement.focus()
})
}
private search(query: string) {
this.loading = true
this.searchService.globalSearch(query).subscribe((results) => {
this.searchResults = results
this.loading = false
this.resultsDropdown.open()
})
}
public primaryAction(
type: string,
object: ObjectWithId,
event: PointerEvent = null
) {
const newWindow = event?.metaKey || event?.ctrlKey
this.reset(true)
let filterRuleType: number
let editDialogComponent: any
let size: string = 'md'
switch (type) {
case DataType.Document:
this.navigateOrOpenInNewWindow(['/documents', object.id], newWindow)
return
case DataType.SavedView:
this.navigateOrOpenInNewWindow(['/view', object.id], newWindow)
return
case DataType.Correspondent:
filterRuleType = FILTER_HAS_CORRESPONDENT_ANY
break
case DataType.DocumentType:
filterRuleType = FILTER_HAS_DOCUMENT_TYPE_ANY
break
case DataType.StoragePath:
filterRuleType = FILTER_HAS_STORAGE_PATH_ANY
break
case DataType.Tag:
filterRuleType = FILTER_HAS_TAGS_ALL
break
case DataType.User:
editDialogComponent = UserEditDialogComponent
size = 'lg'
break
case DataType.Group:
editDialogComponent = GroupEditDialogComponent
size = 'lg'
break
case DataType.MailAccount:
editDialogComponent = MailAccountEditDialogComponent
size = 'xl'
break
case DataType.MailRule:
editDialogComponent = MailRuleEditDialogComponent
size = 'xl'
break
case DataType.CustomField:
editDialogComponent = CustomFieldEditDialogComponent
break
case DataType.Workflow:
editDialogComponent = WorkflowEditDialogComponent
size = 'xl'
break
}
if (filterRuleType) {
let params = paramsFromViewState({
filterRules: [
{ rule_type: filterRuleType, value: object.id.toString() },
],
currentPage: 1,
sortField: this.documentListViewService.sortField ?? 'created',
sortReverse: this.documentListViewService.sortReverse,
})
this.navigateOrOpenInNewWindow(['/documents'], newWindow, {
queryParams: params,
})
} else if (editDialogComponent) {
const modalRef: NgbModalRef = this.modalService.open(
editDialogComponent,
{ size }
)
modalRef.componentInstance.dialogMode = EditDialogMode.EDIT
modalRef.componentInstance.object = object
modalRef.componentInstance.succeeded.subscribe(() => {
this.toastService.showInfo($localize`Successfully updated object.`)
})
modalRef.componentInstance.failed.subscribe((e) => {
this.toastService.showError($localize`Error occurred saving object.`, e)
})
}
}
public secondaryAction(type: string, object: ObjectWithId) {
this.reset(true)
let editDialogComponent: any
let size: string = 'md'
switch (type) {
case DataType.Document:
window.open(this.documentService.getDownloadUrl(object.id))
break
case DataType.Correspondent:
editDialogComponent = CorrespondentEditDialogComponent
break
case DataType.DocumentType:
editDialogComponent = DocumentTypeEditDialogComponent
break
case DataType.StoragePath:
editDialogComponent = StoragePathEditDialogComponent
break
case DataType.Tag:
editDialogComponent = TagEditDialogComponent
break
}
if (editDialogComponent) {
const modalRef: NgbModalRef = this.modalService.open(
editDialogComponent,
{ size }
)
modalRef.componentInstance.dialogMode = EditDialogMode.EDIT
modalRef.componentInstance.object = object
modalRef.componentInstance.succeeded.subscribe(() => {
this.toastService.showInfo($localize`Successfully updated object.`)
})
modalRef.componentInstance.failed.subscribe((e) => {
this.toastService.showError($localize`Error occurred saving object.`, e)
})
}
}
private reset(close: boolean = false) {
this.queryDebounce.next(null)
this.query = null
this.searchResults = null
this.currentItemIndex = -1
if (close) {
this.resultsDropdown.close()
}
}
private setCurrentItem() {
// QueryLists do not always reflect the current DOM order, so we need to find the actual element
// Yes, using some vanilla JS
const result: HTMLElement = this.resultItems.first.nativeElement.parentNode
.querySelectorAll('.dropdown-item')
.item(this.currentItemIndex)
this.domIndex = this.resultItems
.toArray()
.indexOf(this.resultItems.find((item) => item.nativeElement === result))
const item: ElementRef = this.primaryButtons.get(this.domIndex)
item.nativeElement.focus()
}
public onItemHover(event: MouseEvent) {
const item: ElementRef = this.resultItems
.toArray()
.find((item) => item.nativeElement === event.currentTarget)
this.currentItemIndex = this.resultItems.toArray().indexOf(item)
this.setCurrentItem()
}
public onButtonHover(event: MouseEvent) {
;(event.currentTarget as HTMLElement).focus()
}
public searchInputKeyDown(event: KeyboardEvent) {
if (
event.key === 'ArrowDown' &&
this.searchResults?.total &&
this.resultsDropdown.isOpen()
) {
event.preventDefault()
this.currentItemIndex = 0
this.setCurrentItem()
} else if (
event.key === 'ArrowUp' &&
this.searchResults?.total &&
this.resultsDropdown.isOpen()
) {
event.preventDefault()
this.currentItemIndex = this.searchResults.total - 1
this.setCurrentItem()
} else if (event.key === 'Enter') {
if (this.searchResults?.total === 1 && this.resultsDropdown.isOpen()) {
this.primaryButtons.first.nativeElement.click()
this.searchInput.nativeElement.blur()
} else if (this.query?.length) {
this.runFullSearch()
this.reset(true)
}
} else if (event.key === 'Escape' && !this.resultsDropdown.isOpen()) {
if (this.query?.length) {
this.reset(true)
} else {
this.searchInput.nativeElement.blur()
}
}
}
public dropdownKeyDown(event: KeyboardEvent) {
if (
this.searchResults?.total &&
this.resultsDropdown.isOpen() &&
document.activeElement !== this.searchInput.nativeElement
) {
if (event.key === 'ArrowDown') {
event.preventDefault()
event.stopImmediatePropagation()
if (this.currentItemIndex < this.searchResults.total - 1) {
this.currentItemIndex++
this.setCurrentItem()
} else {
this.searchInput.nativeElement.focus()
this.currentItemIndex = -1
}
} else if (event.key === 'ArrowUp') {
event.preventDefault()
event.stopImmediatePropagation()
if (this.currentItemIndex > 0) {
this.currentItemIndex--
this.setCurrentItem()
} else {
this.searchInput.nativeElement.focus()
this.currentItemIndex = -1
}
} else if (event.key === 'ArrowRight') {
event.preventDefault()
event.stopImmediatePropagation()
this.secondaryButtons.get(this.domIndex)?.nativeElement.focus()
} else if (event.key === 'ArrowLeft') {
event.preventDefault()
event.stopImmediatePropagation()
this.primaryButtons.get(this.domIndex).nativeElement.focus()
} else if (event.key === 'Escape') {
event.preventDefault()
event.stopImmediatePropagation()
this.reset(true)
this.searchInput.nativeElement.focus()
}
}
}
public onButtonKeyDown(event: KeyboardEvent) {
if (event.key === 'Enter' && (event.metaKey || event.ctrlKey)) {
event.target.dispatchEvent(new MouseEvent('click', { ctrlKey: true }))
}
}
public onDropdownOpenChange(open: boolean) {
if (!open) {
this.reset()
}
}
public disablePrimaryButton(type: DataType, object: ObjectWithId): boolean {
if (
[
DataType.Workflow,
DataType.CustomField,
DataType.Group,
DataType.User,
].includes(type)
) {
return !this.permissionsService.currentUserHasObjectPermissions(
PermissionAction.Change,
object
)
}
return false
}
public disableSecondaryButton(type: DataType, object: ObjectWithId): boolean {
if (DataType.Document === type) {
return false
}
return !this.permissionsService.currentUserHasObjectPermissions(
PermissionAction.Change,
object
)
}
public runFullSearch() {
const ruleType = this.useAdvancedForFullSearch
? FILTER_FULLTEXT_QUERY
: FILTER_TITLE_CONTENT
this.documentListViewService.quickFilter([
{ rule_type: ruleType, value: this.query },
])
this.reset(true)
}
private navigateOrOpenInNewWindow(
commands: any,
newWindow: boolean = false,
extras: Object = {}
) {
if (newWindow) {
const url = this.router.serializeUrl(
this.router.createUrlTree(commands, extras)
)
window.open(url, '_blank')
} else {
this.router.navigate(commands, extras)
}
}
}
@@ -1,54 +0,0 @@
<div class="modal-header">
<h4 class="modal-title" id="modal-basic-title">{{title}}</h4>
<button type="button" class="btn-close" aria-label="Close" (click)="cancel()">
</button>
</div>
<div class="modal-body">
<div class="row">
<div class="col">
<div class="btn-toolbar flex-nowrap">
<div class="input-group input-group-sm">
<div class="input-group-text" i18n>Page</div>
<input class="form-control mw-60" type="number" min="1" [(ngModel)]="currentPage" />
<div class="input-group-text" i18n>of {{totalPages}}</div>
</div>
<div class="input-group input-group-sm ms-auto">
<span class="input-group-text" i18n>Pages to remove</span>
<input [ngModel]="pagesString" class="form-control" disabled />
</div>
</div>
<div class="pdf-viewer-container w-100 mt-3">
<pdf-viewer #pdfViewer [src]="pdfSrc" [(page)]="currentPage"
[original-size]="false"
[zoom]="1"
zoom-scale="page-fit"
[render-text]="false"
(pagerendered)="pageRendered($event)"
(after-load-complete)="pdfPreviewLoaded($event)">
</pdf-viewer>
</div>
</div>
</div>
</div>
<div class="modal-footer flex-nowrap">
<div>
@if (message) {
<p [innerHTML]="message | safeHtml"></p>
}
@if (messageBold) {
<p class="mb-0 small"><b [innerHTML]="messageBold | safeHtml"></b></p>
}
</div>
<button type="button" class="btn" [class]="cancelBtnClass" (click)="cancel()" [disabled]="!buttonsEnabled">
<span class="d-inline-block" style="padding-bottom: 1px;">{{cancelBtnCaption}}</span>
</button>
<button type="button" class="btn" [class]="btnClass" (click)="confirm()" [disabled]="!confirmButtonEnabled || !buttonsEnabled">
{{btnCaption}}
</button>
</div>
<ng-template #pageCheckOverlay let-page="page" let-pages="pages">
<div class="position-absolute top-0 start-0 w-100 h-100 p-2" (click)="pageCheckChanged(page)">
<input type="checkbox" class="form-check-input" />
</div>
</ng-template>
@@ -1,28 +0,0 @@
.pdf-viewer-container {
background-color: gray;
height: 350px;
pdf-viewer {
width: 100%;
height: 100%;
}
}
.mw-60 {
max-width: 60px;
}
div.position-absolute:has(.form-check-input:checked) {
background-color: rgba(var(--bs-dark-rgb), 0.4);
}
.form-check-input {
&:checked {
background-color: var(--bs-danger);
border-color: var(--bs-danger);
}
&:focus {
box-shadow: 0 0 0 0.25rem rgba(var(--bs-danger-rgb), var(--pngx-focus-alpha));
border-color: var(--bs-danger);
}
}
@@ -1,55 +0,0 @@
import { ComponentFixture, TestBed } from '@angular/core/testing'
import { DeletePagesConfirmDialogComponent } from './delete-pages-confirm-dialog.component'
import { HttpClientTestingModule } from '@angular/common/http/testing'
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
import { SafeHtmlPipe } from 'src/app/pipes/safehtml.pipe'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { PdfViewerComponent } from 'ng2-pdf-viewer'
describe('DeletePagesConfirmDialogComponent', () => {
let component: DeletePagesConfirmDialogComponent
let fixture: ComponentFixture<DeletePagesConfirmDialogComponent>
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [DeletePagesConfirmDialogComponent, PdfViewerComponent],
providers: [NgbActiveModal, SafeHtmlPipe],
imports: [
HttpClientTestingModule,
NgxBootstrapIconsModule.pick(allIcons),
FormsModule,
ReactiveFormsModule,
],
}).compileComponents()
fixture = TestBed.createComponent(DeletePagesConfirmDialogComponent)
component = fixture.componentInstance
fixture.detectChanges()
})
it('should return a string with comma-separated pages', () => {
component.pages = [1, 2, 3, 4]
expect(component.pagesString).toEqual('1, 2, 3, 4')
})
it('should update totalPages when pdf is loaded', () => {
component.pdfPreviewLoaded({ numPages: 5 } as any)
expect(component.totalPages).toEqual(5)
})
it('should update checks when page is rendered', () => {
const event = {
target: document.createElement('div'),
detail: { pageNumber: 1 },
} as any
component.pageRendered(event)
expect(component['checks'].length).toEqual(1)
})
it('should update pages when page check is changed', () => {
component.pageCheckChanged(1)
expect(component.pages).toEqual([1])
component.pageCheckChanged(1)
expect(component.pages).toEqual([])
})
})
@@ -1,64 +0,0 @@
import { Component, TemplateRef, ViewChild } from '@angular/core'
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
import { DocumentService } from 'src/app/services/rest/document.service'
import { ConfirmDialogComponent } from '../confirm-dialog.component'
import { PDFDocumentProxy, PdfViewerComponent } from 'ng2-pdf-viewer'
@Component({
selector: 'pngx-delete-pages-confirm-dialog',
templateUrl: './delete-pages-confirm-dialog.component.html',
styleUrl: './delete-pages-confirm-dialog.component.scss',
})
export class DeletePagesConfirmDialogComponent extends ConfirmDialogComponent {
public documentID: number
public pages: number[] = []
public currentPage: number = 1
public totalPages: number
@ViewChild('pdfViewer') pdfViewer: PdfViewerComponent
@ViewChild('pageCheckOverlay') pageCheckOverlay!: TemplateRef<any>
private checks: HTMLElement[] = []
public get pagesString(): string {
return this.pages.join(', ')
}
public get pdfSrc(): string {
return this.documentService.getPreviewUrl(this.documentID)
}
constructor(
activeModal: NgbActiveModal,
private documentService: DocumentService
) {
super(activeModal)
}
public pdfPreviewLoaded(pdf: PDFDocumentProxy) {
this.totalPages = pdf.numPages
}
pageRendered(event: CustomEvent) {
const pageDiv = event.target as HTMLDivElement
const check = this.pageCheckOverlay.createEmbeddedView({
page: event.detail.pageNumber,
})
this.checks[event.detail.pageNumber - 1] = check.rootNodes[0]
pageDiv?.insertBefore(check.rootNodes[0], pageDiv.firstChild)
this.updateChecks()
}
pageCheckChanged(pageNumber: number) {
if (!this.pages.includes(pageNumber)) this.pages.push(pageNumber)
else if (this.pages.includes(pageNumber))
this.pages.splice(this.pages.indexOf(pageNumber), 1)
this.updateChecks()
}
private updateChecks() {
this.checks.forEach((check, i) => {
const input = check.getElementsByTagName('input')[0]
input.checked = this.pages.includes(i + 1)
})
}
}
@@ -1,39 +0,0 @@
<div class="modal-header">
<h4 class="modal-title" id="modal-basic-title">{{title}}</h4>
<button type="button" class="btn-close" aria-label="Close" (click)="cancel()">
</button>
</div>
<div class="modal-body">
<p>{{message}}</p>
<div class="form-group">
<label class="form-label" for="metadataDocumentID" i18n>Documents:</label>
<ul class="list-group"
cdkDropList
(cdkDropListDropped)="onDrop($event)">
@for (documentID of documentIDs; track documentID) {
<li class="list-group-item" cdkDrag>
<i-bs name="grip-vertical" class="me-2"></i-bs>
{{getDocument(documentID)?.title}}
</li>
}
</ul>
</div>
<div class="form-group mt-4">
<label class="form-label" for="metadataDocumentID" i18n>Use metadata from:</label>
<select class="form-select" [(ngModel)]="metadataDocumentID">
<option [ngValue]="-1" i18n>Regenerate all metadata</option>
@for (document of documents; track document.id) {
<option [ngValue]="document.id">{{document.title}}</option>
}
</select>
</div>
<p class="small text-muted fst-italic mt-4" i18n>Note that only PDFs will be included.</p>
</div>
<div class="modal-footer">
<button type="button" class="btn" [class]="cancelBtnClass" (click)="cancel()" [disabled]="!buttonsEnabled">
<span class="d-inline-block" style="padding-bottom: 1px;">{{cancelBtnCaption}}</span>
</button>
<button type="button" class="btn" [class]="btnClass" (click)="confirm()" [disabled]="!confirmButtonEnabled || !buttonsEnabled">
{{btnCaption}}
</button>
</div>
@@ -1,3 +0,0 @@
.list-group-item {
cursor: move;
}
@@ -1,83 +0,0 @@
import { ComponentFixture, TestBed } from '@angular/core/testing'
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
import { MergeConfirmDialogComponent } from './merge-confirm-dialog.component'
import { HttpClientTestingModule } from '@angular/common/http/testing'
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
import { of } from 'rxjs'
import { DocumentService } from 'src/app/services/rest/document.service'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
describe('MergeConfirmDialogComponent', () => {
let component: MergeConfirmDialogComponent
let fixture: ComponentFixture<MergeConfirmDialogComponent>
let documentService: DocumentService
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [MergeConfirmDialogComponent],
providers: [NgbActiveModal],
imports: [
HttpClientTestingModule,
NgxBootstrapIconsModule.pick(allIcons),
ReactiveFormsModule,
FormsModule,
],
}).compileComponents()
fixture = TestBed.createComponent(MergeConfirmDialogComponent)
documentService = TestBed.inject(DocumentService)
component = fixture.componentInstance
fixture.detectChanges()
})
it('should fetch documents on ngOnInit', () => {
const documents = [
{ id: 1, name: 'Document 1' },
{ id: 2, name: 'Document 2' },
{ id: 3, name: 'Document 3' },
]
jest.spyOn(documentService, 'getFew').mockReturnValue(
of({
all: documents.map((d) => d.id),
count: documents.length,
results: documents,
})
)
component.ngOnInit()
expect(component.documents).toEqual(documents)
expect(documentService.getFew).toHaveBeenCalledWith(component.documentIDs)
})
it('should move documentIDs on drop', () => {
component.documentIDs = [1, 2, 3]
const event = {
previousIndex: 1,
currentIndex: 2,
}
component.onDrop(event as any)
expect(component.documentIDs).toEqual([1, 3, 2])
})
it('should get document by ID', () => {
const documents = [
{ id: 1, name: 'Document 1' },
{ id: 2, name: 'Document 2' },
{ id: 3, name: 'Document 3' },
]
jest.spyOn(documentService, 'getFew').mockReturnValue(
of({
all: documents.map((d) => d.id),
count: documents.length,
results: documents,
})
)
component.ngOnInit()
expect(component.getDocument(2)).toEqual({ id: 2, name: 'Document 2' })
})
})
@@ -1,51 +0,0 @@
import { Component, OnInit } from '@angular/core'
import { ConfirmDialogComponent } from '../confirm-dialog.component'
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
import { DocumentService } from 'src/app/services/rest/document.service'
import { CdkDragDrop, moveItemInArray } from '@angular/cdk/drag-drop'
import { Subject, takeUntil } from 'rxjs'
import { Document } from 'src/app/data/document'
@Component({
selector: 'pngx-merge-confirm-dialog',
templateUrl: './merge-confirm-dialog.component.html',
styleUrl: './merge-confirm-dialog.component.scss',
})
export class MergeConfirmDialogComponent
extends ConfirmDialogComponent
implements OnInit
{
public documentIDs: number[] = []
private _documents: Document[] = []
get documents(): Document[] {
return this._documents
}
public metadataDocumentID: number = -1
private unsubscribeNotifier: Subject<any> = new Subject()
constructor(
activeModal: NgbActiveModal,
private documentService: DocumentService
) {
super(activeModal)
}
ngOnInit() {
this.documentService
.getFew(this.documentIDs)
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe((r) => {
this._documents = r.results
})
}
onDrop(event: CdkDragDrop<number[]>) {
moveItemInArray(this.documentIDs, event.previousIndex, event.currentIndex)
}
getDocument(documentID: number): Document {
return this.documents.find((d) => d.id === documentID)
}
}
@@ -1,46 +0,0 @@
<div class="modal-header">
<h4 class="modal-title" id="modal-basic-title">{{title}}</h4>
<button type="button" class="btn-close" aria-label="Close" (click)="cancel()">
</button>
</div>
<div class="modal-body">
<div class="row">
<div class="col-2 d-flex justify-content-end">
<button class="btn btn-secondary mt-auto" (click)="rotate(false)">
<i-bs name="arrow-counterclockwise"></i-bs>
</button>
</div>
<div class="col-8 d-flex align-items-center">
@if (documentID) {
<img class="w-75 m-auto" [ngStyle]="{'transform': 'rotate('+rotation+'deg)'}" [src]="documentService.getThumbUrl(documentID)" />
}
</div>
<div class="col-2 d-flex">
<button class="btn btn-secondary mt-auto" (click)="rotate()">
<i-bs name="arrow-clockwise"></i-bs>
</button>
</div>
</div>
@if (showPDFNote) {
<p class="small text-muted fst-italic mt-4" i18n>Note that only PDFs will be rotated.</p>
}
</div>
<div class="modal-footer flex-nowrap">
<div class="col">
@if (message) {
<p [innerHTML]="message | safeHtml"></p>
}
@if (messageBold) {
<p class="mb-0 small"><b [innerHTML]="messageBold | safeHtml"></b></p>
}
</div>
<button type="button" class="btn" [class]="cancelBtnClass" (click)="cancel()" [disabled]="!buttonsEnabled">
<span class="d-inline-block" style="padding-bottom: 1px;">{{cancelBtnCaption}}</span>
</button>
<button type="button" class="btn" [class]="btnClass" (click)="confirm()" [disabled]="!confirmButtonEnabled || !buttonsEnabled || degrees === 0">
{{btnCaption}}
@if (!confirmButtonEnabled) {
<ngb-progressbar style="height: 1px;" type="dark" [max]="secondsTotal" [value]="seconds"></ngb-progressbar>
}
</button>
</div>
@@ -1,3 +0,0 @@
img {
transition: all 0.25s ease;
}
@@ -1,60 +0,0 @@
import { ComponentFixture, TestBed } from '@angular/core/testing'
import { RotateConfirmDialogComponent } from './rotate-confirm-dialog.component'
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
import { SafeHtmlPipe } from 'src/app/pipes/safehtml.pipe'
import { HttpClientTestingModule } from '@angular/common/http/testing'
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
describe('RotateConfirmDialogComponent', () => {
let component: RotateConfirmDialogComponent
let fixture: ComponentFixture<RotateConfirmDialogComponent>
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [RotateConfirmDialogComponent, SafeHtmlPipe],
providers: [NgbActiveModal, SafeHtmlPipe],
imports: [
HttpClientTestingModule,
NgxBootstrapIconsModule.pick(allIcons),
],
}).compileComponents()
fixture = TestBed.createComponent(RotateConfirmDialogComponent)
component = fixture.componentInstance
fixture.detectChanges()
})
it('should support rotating the image', () => {
component.documentID = 1
fixture.detectChanges()
component.rotate()
fixture.detectChanges()
expect(component.degrees).toBe(90)
expect(fixture.nativeElement.querySelector('img').style.transform).toBe(
'rotate(90deg)'
)
component.rotate()
fixture.detectChanges()
expect(fixture.nativeElement.querySelector('img').style.transform).toBe(
'rotate(180deg)'
)
})
it('should normalize degrees', () => {
expect(component.degrees).toBe(0)
component.rotate()
expect(component.degrees).toBe(90)
component.rotate()
expect(component.degrees).toBe(180)
component.rotate()
expect(component.degrees).toBe(270)
component.rotate()
expect(component.degrees).toBe(0)
component.rotate()
expect(component.degrees).toBe(90)
component.rotate(false)
expect(component.degrees).toBe(0)
component.rotate(false)
expect(component.degrees).toBe(270)
})
})
@@ -1,34 +0,0 @@
import { Component } from '@angular/core'
import { ConfirmDialogComponent } from '../confirm-dialog.component'
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
import { DocumentService } from 'src/app/services/rest/document.service'
@Component({
selector: 'pngx-rotate-confirm-dialog',
templateUrl: './rotate-confirm-dialog.component.html',
styleUrl: './rotate-confirm-dialog.component.scss',
})
export class RotateConfirmDialogComponent extends ConfirmDialogComponent {
public documentID: number
public showPDFNote: boolean = true
// animation is better if we dont normalize yet
public rotation: number = 0
public get degrees(): number {
let degrees = this.rotation % 360
if (degrees < 0) degrees += 360
return degrees
}
constructor(
activeModal: NgbActiveModal,
public documentService: DocumentService
) {
super(activeModal)
}
rotate(clockwise: boolean = true) {
this.rotation += clockwise ? 90 : -90
}
}
@@ -1,55 +0,0 @@
<div class="modal-header">
<h4 class="modal-title" id="modal-basic-title">{{title}}</h4>
<button type="button" class="btn-close" aria-label="Close" (click)="cancel()">
</button>
</div>
<div class="modal-body">
<p>{{message}}</p>
<div class="row mb-2">
<div class="col-8">
<div class="input-group input-group-sm">
<div class="input-group-text" i18n>Page</div>
<input class="form-control" type="number" min="1" [(ngModel)]="page" />
<div class="input-group-text" i18n>of {{totalPages}}</div>
</div>
<div class="pdf-viewer-container w-100 mt-3">
<pdf-viewer [src]="pdfSrc" [(page)]="page"
[original-size]="false"
[zoom]="1"
zoom-scale="page-fit"
(after-load-complete)="pdfPreviewLoaded($event)">
</pdf-viewer>
</div>
</div>
<div class="col-4">
<div class="d-grid">
<button class="btn btn-sm btn-primary" (click)="addSplit()" [disabled]="page === totalPages">
<i-bs name="plus-circle"></i-bs>&nbsp;
<span i18n>Add Split</span>
</button>
</div>
<ul class="list-group mt-3">
@for (pageStr of pagesString.split(','); track pageStr; let i = $index) {
<li class="list-group-item d-flex align-items-center">
{{pageStr}}
@if (pagesString.split(',').length > 1) {
&nbsp;
<button class="btn btn-sm btn-danger ms-auto" (click)="removeSplit(i)">
<i-bs name="trash"></i-bs>
</button>
}
</li>
}
</ul>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn" [class]="cancelBtnClass" (click)="cancel()" [disabled]="!buttonsEnabled">
<span class="d-inline-block" style="padding-bottom: 1px;">{{cancelBtnCaption}}</span>
</button>
<button type="button" class="btn" [class]="btnClass" (click)="confirm()" [disabled]="!confirmButtonEnabled || !buttonsEnabled">
{{btnCaption}}
</button>
</div>
@@ -1,9 +0,0 @@
.pdf-viewer-container {
background-color: gray;
height: 350px;
pdf-viewer {
width: 100%;
height: 100%;
}
}
@@ -1,82 +0,0 @@
import { ComponentFixture, TestBed } from '@angular/core/testing'
import { SplitConfirmDialogComponent } from './split-confirm-dialog.component'
import { HttpClientTestingModule } from '@angular/common/http/testing'
import { ReactiveFormsModule, FormsModule } from '@angular/forms'
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
import { DocumentService } from 'src/app/services/rest/document.service'
import { PdfViewerModule } from 'ng2-pdf-viewer'
describe('SplitConfirmDialogComponent', () => {
let component: SplitConfirmDialogComponent
let fixture: ComponentFixture<SplitConfirmDialogComponent>
let documentService: DocumentService
beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [SplitConfirmDialogComponent],
providers: [NgbActiveModal],
imports: [
HttpClientTestingModule,
NgxBootstrapIconsModule.pick(allIcons),
ReactiveFormsModule,
FormsModule,
PdfViewerModule,
],
}).compileComponents()
fixture = TestBed.createComponent(SplitConfirmDialogComponent)
documentService = TestBed.inject(DocumentService)
component = fixture.componentInstance
fixture.detectChanges()
})
it('should update pagesString when pages are added', () => {
component.totalPages = 5
component.page = 2
component.addSplit()
expect(component.pagesString).toEqual('1-2,3-5')
component.page = 4
component.addSplit()
expect(component.pagesString).toEqual('1-2,3-4,5')
})
it('should update pagesString when pages are removed', () => {
component.totalPages = 5
component.page = 2
component.addSplit()
component.page = 4
component.addSplit()
expect(component.pagesString).toEqual('1-2,3-4,5')
component.removeSplit(0)
expect(component.pagesString).toEqual('1-4,5')
})
it('should enable confirm button when pages are added', () => {
component.totalPages = 5
component.page = 2
component.addSplit()
expect(component.confirmButtonEnabled).toBeTruthy()
})
it('should disable confirm button when all pages are removed', () => {
component.totalPages = 5
component.page = 2
component.addSplit()
component.removeSplit(0)
expect(component.confirmButtonEnabled).toBeFalsy()
})
it('should not add split if page is the last page', () => {
component.totalPages = 5
component.page = 5
component.addSplit()
expect(component.pagesString).toEqual('1-5')
})
it('should update totalPages when pdf is loaded', () => {
component.pdfPreviewLoaded({ numPages: 5 } as any)
expect(component.totalPages).toEqual(5)
})
})
@@ -1,66 +0,0 @@
import { Component } from '@angular/core'
import { ConfirmDialogComponent } from '../confirm-dialog.component'
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
import { DocumentService } from 'src/app/services/rest/document.service'
import { PDFDocumentProxy } from 'ng2-pdf-viewer'
@Component({
selector: 'pngx-split-confirm-dialog',
templateUrl: './split-confirm-dialog.component.html',
styleUrl: './split-confirm-dialog.component.scss',
})
export class SplitConfirmDialogComponent extends ConfirmDialogComponent {
public get pagesString(): string {
let pagesStr = ''
let lastPage = 1
for (let i = 1; i <= this.totalPages; i++) {
if (this.pages.has(i) || i === this.totalPages) {
if (lastPage === i) {
pagesStr += `${i},`
lastPage = Math.min(i + 1, this.totalPages)
} else {
pagesStr += `${lastPage}-${i},`
lastPage = Math.min(i + 1, this.totalPages)
}
}
}
return pagesStr.replace(/,$/, '')
}
private pages: Set<number> = new Set()
public documentID: number
public page: number = 1
public totalPages: number
public get pdfSrc(): string {
return this.documentService.getPreviewUrl(this.documentID)
}
constructor(
activeModal: NgbActiveModal,
private documentService: DocumentService
) {
super(activeModal)
this.confirmButtonEnabled = this.pages.size > 0
}
pdfPreviewLoaded(pdf: PDFDocumentProxy) {
this.totalPages = pdf.numPages
}
addSplit() {
if (this.page === this.totalPages) return
this.pages.add(this.page)
this.pages = new Set(Array.from(this.pages).sort())
this.confirmButtonEnabled = this.pages.size > 0
}
removeSplit(i: number) {
let page = Array.from(this.pages)[Math.min(i, this.pages.size - 1)]
this.pages.delete(page)
this.confirmButtonEnabled = this.pages.size > 0
}
}
@@ -1,34 +0,0 @@
@if (field) {
@if (value?.toString().length > 0) {
<ng-template #nameTooltip>
<div class="d-flex flex-column text-light">
{{field.name}}
</div>
</ng-template>
@switch (field.data_type) {
@case (CustomFieldDataType.Monetary) {
<span [ngbTooltip]="nameTooltip">{{value | currency: currency}}</span>
}
@case (CustomFieldDataType.Date) {
<span [ngbTooltip]="nameTooltip">{{value | customDate}}</span>
}
@case (CustomFieldDataType.Url) {
<a [ngbTooltip]="nameTooltip" [href]="value" class="btn-link text-dark text-decoration-none" target="_blank">{{value}}</a>
}
@case (CustomFieldDataType.DocumentLink) {
<div [ngbTooltip]="nameTooltip" class="d-flex gap-1 flex-wrap">
@for (docId of value; track docId) {
<a routerLink="/documents/{{docId}}" class="badge bg-body 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 [ngbTooltip]="nameTooltip">{{value}}</span>
}
}
} @else if (showNameIfEmpty) {
<span class="fst-italic text-muted">{{field.name}}</span>
}
}
@@ -1,101 +0,0 @@
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: 'USD100' },
{ 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')
})
it('should fallback to default currency', () => {
component['defaultCurrencyCode'] = 'EUR' // mock default locale injection
component.fieldId = 2
component.document = {
id: 1,
title: 'Doc 1',
custom_fields: [{ field: 2, document: 1, created: null, value: '100' }],
}
expect(component.currency).toEqual('EUR')
expect(component.value).toEqual(100)
})
})
@@ -1,122 +0,0 @@
import { getLocaleCurrencyCode } from '@angular/common'
import {
Component,
Inject,
Input,
LOCALE_ID,
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)
}
@Input()
showNameIfEmpty: boolean = false
value: any
currency: string
private customFields: CustomField[] = []
public field: CustomField
private docLinkDocuments: Document[] = []
private unsubscribeNotifier: Subject<any> = new Subject()
private defaultCurrencyCode: any
constructor(
private customFieldService: CustomFieldsService,
private documentService: DocumentService,
@Inject(LOCALE_ID) currentLocale: string
) {
this.defaultCurrencyCode = getLocaleCurrencyCode(currentLocale)
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.defaultCurrencyCode
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 = this.value.map((id) =>
result.results.find((d) => d.id === id)
)
})
}
public getDocumentTitle(docId: number): string {
return this.docLinkDocuments?.find((d) => d.id === docId)?.title
}
ngOnDestroy(): void {
this.unsubscribeNotifier.next(true)
this.unsubscribeNotifier.complete()
}
}
@@ -1,27 +1,30 @@
<div ngbDropdown #fieldDropdown="ngbDropdown" (openChange)="onOpenClose($event)">
<div ngbDropdown #fieldDropdown="ngbDropdown" (openChange)="onOpenClose()">
<button class="btn btn-sm btn-outline-primary" id="customFieldsDropdown" [disabled]="disabled" ngbDropdownToggle>
<i-bs name="ui-radios"></i-bs>
<div class="d-none d-sm-inline">&nbsp;<ng-container i18n>Custom Fields</ng-container></div>
</button>
<div ngbDropdownMenu aria-labelledby="customFieldsDropdown" class="shadow custom-fields-dropdown">
<div class="list-group list-group-flush" (keydown)="listKeyDown($event)">
<div class="list-group-item">
<div class="input-group input-group-sm">
<input class="form-control" type="text" [(ngModel)]="filterText" placeholder="Search fields" i18n-placeholder (keyup.enter)="listFilterEnter()" #listFilterTextInput>
<ul class="list-group list-group-flush">
<li class="list-group-item">
<pngx-input-select class="mb-3"
[items]="unusedFields"
bindLabel="name"
[(ngModel)]="field"
[placeholder]="placeholderText"
[notFoundText]="notFoundText"
[disableCreateNew]="!canCreateFields"
(createNew)="createField($event)"
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>
</div>
</div>
@for (field of filteredFields; track field.id) {
<button class="list-group-item list-group-item-action bg-light" (click)="addField(field)" #button>
<small class="d-flex">{{field.name}} <small class="ms-auto text-muted">{{getDataTypeLabel(field.data_type)}}</small></small>
</button>
}
@if (!filterText?.length || filteredFields.length === 0) {
<button class="list-group-item list-group-item-action bg-light" (click)="createField(filterText)" [disabled]="!canCreateFields" #button>
<small>
<i-bs width=".9em" height=".9em" name="asterisk"></i-bs>&nbsp;<ng-container i18n>Create new field</ng-container>
</small>
</button>
}
</div>
</li>
</ul>
</div>
</div>
@@ -1,5 +1,5 @@
.custom-fields-dropdown {
min-width: 300px;
min-width: 350px;
// correct position on mobile
@media (max-width: 575.98px) {
@@ -8,3 +8,17 @@
}
}
}
::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 {
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 {
top: 4px;
}
@@ -1,11 +1,10 @@
import {
ComponentFixture,
TestBed,
fakeAsync,
tick,
} from '@angular/core/testing'
import { ComponentFixture, TestBed } from '@angular/core/testing'
import { CustomFieldsDropdownComponent } from './custom-fields-dropdown.component'
import { HttpClientTestingModule } from '@angular/common/http/testing'
import {
HttpClientTestingModule,
HttpTestingController,
} 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'
@@ -75,33 +74,28 @@ describe('CustomFieldsDropdownComponent', () => {
let addedField
component.added.subscribe((f) => (addedField = f))
component.documentId = 11
component.addField({ field: fields[0].id } as any)
component.field = fields[0].id
component.addField()
expect(addedField).not.toBeUndefined()
})
it('should support filtering fields', () => {
const input = fixture.debugElement.query(By.css('input'))
input.nativeElement.value = 'Field 1'
input.triggerEventHandler('input', { target: input.nativeElement })
fixture.detectChanges()
expect(component.filteredFields.length).toEqual(1)
expect(component.filteredFields[0].name).toEqual('Field 1')
})
it('should clear field on open / close, updated unused fields', () => {
component.field = fields[1].id
component.onOpenClose()
expect(component.field).toBeUndefined()
it('should support update unused fields', () => {
component.existingFields = [{ field: fields[0].id } as any]
component['updateUnusedFields']()
expect(component['unusedFields'].length).toEqual(1)
expect(component['unusedFields'][0].name).toEqual('Field 2')
})
it('should support getting data type label', () => {
expect(component.getDataTypeLabel(CustomFieldDataType.Integer)).toEqual(
'Integer'
expect(component.unusedFields).toEqual(fields)
const updateSpy = jest.spyOn(
CustomFieldsDropdownComponent.prototype as any,
'updateUnusedFields'
)
component.existingFields = [{ field: fields[1].id } as any]
component.onOpenClose()
expect(updateSpy).toHaveBeenCalled()
expect(component.unusedFields).toEqual([fields[0]])
})
it('should support creating field, show error if necessary, then add', fakeAsync(() => {
it('should support creating field, show error if necessary', () => {
let modal: NgbModalRef
modalService.activeInstances.subscribe((m) => (modal = m[m.length - 1]))
const toastErrorSpy = jest.spyOn(toastService, 'showError')
@@ -110,9 +104,8 @@ describe('CustomFieldsDropdownComponent', () => {
CustomFieldsDropdownComponent.prototype as any,
'getFields'
)
const addFieldSpy = jest.spyOn(component, 'addField')
const createButton = fixture.debugElement.queryAll(By.css('button'))[3]
const createButton = fixture.debugElement.queryAll(By.css('button'))[1]
createButton.triggerEventHandler('click')
expect(modal).not.toBeUndefined()
@@ -125,11 +118,9 @@ describe('CustomFieldsDropdownComponent', () => {
// succeed
editDialog.succeeded.emit(fields[0])
tick(100)
expect(toastInfoSpy).toHaveBeenCalled()
expect(getFieldsSpy).toHaveBeenCalled()
expect(addFieldSpy).toHaveBeenCalled()
}))
})
it('should support creating field with name', () => {
let modal: NgbModalRef
@@ -140,106 +131,4 @@ describe('CustomFieldsDropdownComponent', () => {
const editDialog = modal.componentInstance as CustomFieldEditDialogComponent
expect(editDialog.object.name).toEqual('Foo bar')
})
it('should support arrow keyboard navigation', fakeAsync(() => {
fixture.nativeElement
.querySelector('button')
.dispatchEvent(new MouseEvent('click')) // open
fixture.detectChanges()
tick(100)
const filterInputEl: HTMLInputElement =
component.listFilterTextInput.nativeElement
expect(document.activeElement).toEqual(filterInputEl)
const itemButtons = Array.from(
(fixture.nativeElement as HTMLDivElement).querySelectorAll(
'.custom-fields-dropdown button'
)
).filter((b) => b.textContent.includes('Field'))
filterInputEl.dispatchEvent(
new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })
)
expect(document.activeElement).toEqual(itemButtons[0])
itemButtons[0].dispatchEvent(
new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })
)
expect(document.activeElement).toEqual(itemButtons[1])
itemButtons[1].dispatchEvent(
new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true })
)
expect(document.activeElement).toEqual(itemButtons[0])
itemButtons[0].dispatchEvent(
new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true })
)
expect(document.activeElement).toEqual(filterInputEl)
filterInputEl.value = 'foo'
component.filterText = 'foo'
// dont move focus if we're traversing the field
filterInputEl.selectionStart = 1
expect(document.activeElement).toEqual(filterInputEl)
// now we're at end, so move focus
filterInputEl.selectionStart = 3
filterInputEl.dispatchEvent(
new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })
)
expect(document.activeElement).toEqual(itemButtons[0])
}))
it('should support arrow keyboard navigation after tab keyboard navigation', fakeAsync(() => {
fixture.nativeElement
.querySelector('button')
.dispatchEvent(new MouseEvent('click')) // open
fixture.detectChanges()
tick(100)
const filterInputEl: HTMLInputElement =
component.listFilterTextInput.nativeElement
expect(document.activeElement).toEqual(filterInputEl)
const itemButtons = Array.from(
(fixture.nativeElement as HTMLDivElement).querySelectorAll(
'.custom-fields-dropdown button'
)
).filter((b) => b.textContent.includes('Field'))
filterInputEl.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Tab', bubbles: true })
)
itemButtons[0]['focus']() // normally handled by browser
itemButtons[0].dispatchEvent(
new KeyboardEvent('keydown', { key: 'Tab', bubbles: true })
)
itemButtons[1]['focus']() // normally handled by browser
itemButtons[1].dispatchEvent(
new KeyboardEvent('keydown', {
key: 'Tab',
shiftKey: true,
bubbles: true,
})
)
itemButtons[0]['focus']() // normally handled by browser
itemButtons[0].dispatchEvent(
new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })
)
expect(document.activeElement).toEqual(itemButtons[1])
}))
it('should support enter keyboard navigation', fakeAsync(() => {
jest.spyOn(component, 'canCreateFields', 'get').mockReturnValue(true)
const addFieldSpy = jest.spyOn(component, 'addField')
const createFieldSpy = jest.spyOn(component, 'createField')
component.filterText = 'Field 1'
component.listFilterEnter()
expect(addFieldSpy).toHaveBeenCalled()
component.filterText = 'Field 3'
component.listFilterEnter()
expect(createFieldSpy).toHaveBeenCalledWith('Field 3')
addFieldSpy.mockClear()
createFieldSpy.mockClear()
component.filterText = undefined
component.listFilterEnter()
expect(createFieldSpy).not.toHaveBeenCalled()
expect(addFieldSpy).not.toHaveBeenCalled()
}))
})
@@ -1,17 +1,13 @@
import {
Component,
ElementRef,
EventEmitter,
Input,
OnDestroy,
Output,
QueryList,
ViewChild,
ViewChildren,
} from '@angular/core'
import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
import { Subject, first, takeUntil } from 'rxjs'
import { CustomField, DATA_TYPE_LABELS } from 'src/app/data/custom-field'
import { CustomField } from 'src/app/data/custom-field'
import { CustomFieldInstance } from 'src/app/data/custom-field-instance'
import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
import { ToastService } from 'src/app/services/toast.service'
@@ -43,25 +39,23 @@ export class CustomFieldsDropdownComponent implements OnDestroy {
@Output()
created: EventEmitter<CustomField> = new EventEmitter()
@ViewChild('listFilterTextInput') listFilterTextInput: ElementRef
@ViewChildren('button') buttons: QueryList<ElementRef>
private customFields: CustomField[] = []
private unusedFields: CustomField[] = []
private keyboardIndex: number
public unusedFields: CustomField[]
public get filteredFields(): CustomField[] {
return this.unusedFields.filter(
(f) =>
!this.filterText ||
f.name.toLowerCase().includes(this.filterText.toLowerCase())
)
}
public name: string
public filterText: string
public field: number
private unsubscribeNotifier: Subject<any> = new Subject()
get placeholderText(): string {
return $localize`Choose field`
}
get notFoundText(): string {
return $localize`No unused fields found`
}
get canCreateFields(): boolean {
return this.permissionsService.currentUserCan(
PermissionAction.Add,
@@ -93,26 +87,28 @@ export class CustomFieldsDropdownComponent implements OnDestroy {
})
}
public getCustomFieldFromInstance(
instance: CustomFieldInstance
): CustomField {
return this.customFields.find((f) => f.id === instance.field)
}
private updateUnusedFields() {
this.unusedFields = this.customFields.filter(
(f) => !this.existingFields?.find((e) => e.field === f.id)
(f) =>
!this.existingFields?.find(
(e) => this.getCustomFieldFromInstance(e)?.id === f.id
)
)
}
onOpenClose(open: boolean) {
if (open) {
setTimeout(() => {
this.listFilterTextInput.nativeElement.focus()
}, 100)
} else {
this.filterText = undefined
}
onOpenClose() {
this.field = undefined
this.updateUnusedFields()
}
addField(field: CustomField) {
this.added.emit(field)
this.updateUnusedFields()
addField() {
this.added.emit(this.customFields.find((f) => f.id === this.field))
}
createField(newName: string = null) {
@@ -125,7 +121,6 @@ export class CustomFieldsDropdownComponent implements OnDestroy {
this.customFieldsService.clearCache()
this.getFields()
this.created.emit(newField)
setTimeout(() => this.addField(newField), 100)
})
modal.componentInstance.failed
.pipe(takeUntil(this.unsubscribeNotifier))
@@ -133,82 +128,4 @@ export class CustomFieldsDropdownComponent implements OnDestroy {
this.toastService.showError($localize`Error saving field.`, e)
})
}
getDataTypeLabel(dataType: string) {
return DATA_TYPE_LABELS.find((l) => l.id === dataType)?.name
}
public listFilterEnter() {
if (this.filteredFields.length === 1) {
this.addField(this.filteredFields[0])
} else if (
this.filterText &&
this.filteredFields.length === 0 &&
this.canCreateFields
) {
this.createField(this.filterText)
}
}
private focusNextButtonItem(setFocus: boolean = true) {
this.keyboardIndex = Math.min(
this.buttons.length - 1,
this.keyboardIndex + 1
)
if (setFocus) this.setButtonItemFocus()
}
focusPreviousButtonItem(setFocus: boolean = true) {
this.keyboardIndex = Math.max(0, this.keyboardIndex - 1)
if (setFocus) this.setButtonItemFocus()
}
setButtonItemFocus() {
this.buttons.get(this.keyboardIndex)?.nativeElement.focus()
}
public listKeyDown(event: KeyboardEvent) {
switch (event.key) {
case 'ArrowDown':
if (event.target instanceof HTMLInputElement) {
if (
!this.filterText ||
event.target.selectionStart === this.filterText.length
) {
this.keyboardIndex = -1
this.focusNextButtonItem()
event.preventDefault()
}
} else if (event.target instanceof HTMLButtonElement) {
this.focusNextButtonItem()
event.preventDefault()
}
break
case 'ArrowUp':
if (event.target instanceof HTMLButtonElement) {
if (this.keyboardIndex === 0) {
this.listFilterTextInput.nativeElement.focus()
} else {
this.focusPreviousButtonItem()
}
event.preventDefault()
}
break
case 'Tab':
// just track the index in case user uses arrows
if (event.target instanceof HTMLInputElement) {
this.keyboardIndex = 0
} else if (event.target instanceof HTMLButtonElement) {
if (event.shiftKey) {
if (this.keyboardIndex > 0) {
this.focusPreviousButtonItem(false)
}
} else {
this.focusNextButtonItem(false)
}
}
default:
break
}
}
}
@@ -0,0 +1,71 @@
<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>
@@ -1,10 +1,6 @@
.date-dropdown {
white-space: nowrap;
@media(min-width: 768px) {
--bs-dropdown-min-width: 40rem;
}
.btn-link {
line-height: 1;
}
@@ -4,12 +4,12 @@ import {
fakeAsync,
tick,
} from '@angular/core/testing'
let fixture: ComponentFixture<DatesDropdownComponent>
let fixture: ComponentFixture<DateDropdownComponent>
import {
DatesDropdownComponent,
DateDropdownComponent,
DateSelection,
RelativeDate,
} from './dates-dropdown.component'
} from './date-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('DatesDropdownComponent', () => {
let component: DatesDropdownComponent
describe('DateDropdownComponent', () => {
let component: DateDropdownComponent
let settingsService: SettingsService
let settingsSpy
beforeEach(async () => {
TestBed.configureTestingModule({
declarations: [
DatesDropdownComponent,
DateDropdownComponent,
ClearableBadgeComponent,
CustomDatePipe,
],
@@ -44,7 +44,7 @@ describe('DatesDropdownComponent', () => {
settingsService = TestBed.inject(SettingsService)
settingsSpy = jest.spyOn(settingsService, 'getLocalizedDateInputFormat')
fixture = TestBed.createComponent(DatesDropdownComponent)
fixture = TestBed.createComponent(DateDropdownComponent)
component = fixture.componentInstance
fixture.detectChanges()
@@ -57,7 +57,7 @@ describe('DatesDropdownComponent', () => {
it('should support date input, emit change', fakeAsync(() => {
let result: string
component.createdDateAfterChange.subscribe((date) => (result = date))
component.dateAfterChange.subscribe((date) => (result = date))
const input: HTMLInputElement = fixture.nativeElement.querySelector('input')
input.value = '5/30/2023'
input.dispatchEvent(new Event('change'))
@@ -78,69 +78,45 @@ describe('DatesDropdownComponent', () => {
it('should support relative dates', fakeAsync(() => {
let result: DateSelection
component.datesSet.subscribe((date) => (result = date))
component.setCreatedRelativeDate(null)
component.setCreatedRelativeDate(RelativeDate.LAST_7_DAYS)
component.setAddedRelativeDate(null)
component.setAddedRelativeDate(RelativeDate.LAST_7_DAYS)
component.setRelativeDate(null)
component.setRelativeDate(RelativeDate.LAST_7_DAYS)
tick(500)
expect(result).toEqual({
createdAfter: null,
createdBefore: null,
createdRelativeDateID: RelativeDate.LAST_7_DAYS,
addedAfter: null,
addedBefore: null,
addedRelativeDateID: RelativeDate.LAST_7_DAYS,
after: null,
before: null,
relativeDateID: RelativeDate.LAST_7_DAYS,
})
}))
it('should support report if active', () => {
component.createdRelativeDate = RelativeDate.LAST_7_DAYS
component.relativeDate = RelativeDate.LAST_7_DAYS
expect(component.isActive).toBeTruthy()
component.createdRelativeDate = null
component.createdDateAfter = '2023-05-30'
component.relativeDate = null
component.dateAfter = '2023-05-30'
expect(component.isActive).toBeTruthy()
component.createdDateAfter = null
component.createdDateBefore = '2023-05-30'
component.dateAfter = null
component.dateBefore = '2023-05-30'
expect(component.isActive).toBeTruthy()
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
component.dateBefore = null
expect(component.isActive).toBeFalsy()
})
it('should support reset', () => {
component.createdDateAfter = '2023-05-30'
component.dateAfter = '2023-05-30'
component.reset()
expect(component.createdDateAfter).toBeNull()
expect(component.dateAfter).toBeNull()
})
it('should support clearAfter', () => {
component.createdDateAfter = '2023-05-30'
component.clearCreatedAfter()
expect(component.createdDateAfter).toBeNull()
component.addedDateAfter = '2023-05-30'
component.clearAddedAfter()
expect(component.addedDateAfter).toBeNull()
component.dateAfter = '2023-05-30'
component.clearAfter()
expect(component.dateAfter).toBeNull()
})
it('should support clearBefore', () => {
component.createdDateBefore = '2023-05-30'
component.clearCreatedBefore()
expect(component.createdDateBefore).toBeNull()
component.addedDateBefore = '2023-05-30'
component.clearAddedBefore()
expect(component.addedDateBefore).toBeNull()
component.dateBefore = '2023-05-30'
component.clearBefore()
expect(component.dateBefore).toBeNull()
})
it('should limit keyboard events', () => {
@@ -0,0 +1,164 @@
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()
}
}
}
@@ -1,143 +0,0 @@
<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>
@@ -1,219 +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 {
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()
}
}
}
@@ -16,15 +16,11 @@
<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 d-flex flex-column">
<div class="mb-2">
<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>
@@ -56,7 +56,6 @@ 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([]),
@@ -481,8 +481,5 @@ export class WorkflowEditDialogComponent
this.actionFields.insert(event.currentIndex, actionField)
// removing id will effectively re-create the actions in this order
this.object.actions.forEach((a) => (a.id = null))
this.actionFields.controls.forEach((c) =>
c.get('id').setValue(null, { emitEvent: false })
)
}
}
@@ -26,7 +26,6 @@ import { TagComponent } from '../tag/tag.component'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { ClearableBadgeComponent } from '../clearable-badge/clearable-badge.component'
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
import { HotKeyService } from 'src/app/services/hot-key.service'
const items: Tag[] = [
{
@@ -54,7 +53,6 @@ let selectionModel: FilterableDropdownSelectionModel
describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () => {
let component: FilterableDropdownComponent
let fixture: ComponentFixture<FilterableDropdownComponent>
let hotkeyService: HotKeyService
beforeEach(async () => {
TestBed.configureTestingModule({
@@ -74,7 +72,6 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
],
}).compileComponents()
hotkeyService = TestBed.inject(HotKeyService)
fixture = TestBed.createComponent(FilterableDropdownComponent)
component = fixture.componentInstance
selectionModel = new FilterableDropdownSelectionModel()
@@ -580,14 +577,4 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
expect(selectionModel.getSelectedItems()).toEqual([items[0]])
expect(selectionModel.getExcludedItems()).toEqual([items[1]])
})
it('should support shortcut keys', () => {
component.items = items
component.icon = 'tag-fill'
component.shortcutKey = 't'
fixture.detectChanges()
const openSpy = jest.spyOn(component.dropdown, 'open')
document.dispatchEvent(new KeyboardEvent('keydown', { key: 't' }))
expect(openSpy).toHaveBeenCalled()
})
})

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