mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2025-09-30 01:32:43 -05:00
Compare commits
7 Commits
dependabot
...
fix-sugges
Author | SHA1 | Date | |
---|---|---|---|
![]() |
7ea4893e42 | ||
![]() |
78255d0a99 | ||
![]() |
fc4cb08bda | ||
![]() |
875dc6602b | ||
![]() |
8084ece274 | ||
![]() |
70b24c056b | ||
![]() |
26d2d63c26 |
@@ -3,7 +3,7 @@
|
||||
"dockerComposeFile": "docker-compose.devcontainer.sqlite-tika.yml",
|
||||
"service": "paperless-development",
|
||||
"workspaceFolder": "/usr/src/paperless/paperless-ngx",
|
||||
"postCreateCommand": "/bin/bash -c 'rm -rf .venv/.* && uv sync --group dev && uv run pre-commit install'",
|
||||
"postCreateCommand": "/bin/bash -c 'uv sync --group dev && uv run pre-commit install'",
|
||||
"customizations": {
|
||||
"vscode": {
|
||||
"extensions": [
|
||||
|
@@ -49,6 +49,7 @@ services:
|
||||
- ./data:/usr/src/paperless/paperless-ngx/data
|
||||
- ./media:/usr/src/paperless/paperless-ngx/media
|
||||
- ./consume:/usr/src/paperless/paperless-ngx/consume
|
||||
- ~/.gitconfig:/usr/src/paperless/.gitconfig:ro
|
||||
environment:
|
||||
PAPERLESS_REDIS: redis://broker:6379
|
||||
PAPERLESS_TIKA_ENABLED: 1
|
||||
|
1047
.github/workflows/ci.yml
vendored
1047
.github/workflows/ci.yml
vendored
File diff suppressed because it is too large
Load Diff
4
.github/workflows/cleanup-tags.yml
vendored
4
.github/workflows/cleanup-tags.yml
vendored
@@ -28,7 +28,7 @@ jobs:
|
||||
steps:
|
||||
- name: Clean temporary images
|
||||
if: "${{ env.TOKEN != '' }}"
|
||||
uses: stumpylog/image-cleaner-action/ephemeral@v0.11.0
|
||||
uses: stumpylog/image-cleaner-action/ephemeral@v0.10.0
|
||||
with:
|
||||
token: "${{ env.TOKEN }}"
|
||||
owner: "${{ github.repository_owner }}"
|
||||
@@ -54,7 +54,7 @@ jobs:
|
||||
steps:
|
||||
- name: Clean untagged images
|
||||
if: "${{ env.TOKEN != '' }}"
|
||||
uses: stumpylog/image-cleaner-action/untagged@v0.11.0
|
||||
uses: stumpylog/image-cleaner-action/untagged@v0.10.0
|
||||
with:
|
||||
token: "${{ env.TOKEN }}"
|
||||
owner: "${{ github.repository_owner }}"
|
||||
|
2
.github/workflows/codeql-analysis.yml
vendored
2
.github/workflows/codeql-analysis.yml
vendored
@@ -34,7 +34,7 @@ jobs:
|
||||
# Learn more about CodeQL language support at https://git.io/codeql-language-support
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v4
|
||||
# Initializes the CodeQL tools for scanning.
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@v3
|
||||
|
2
.github/workflows/crowdin.yml
vendored
2
.github/workflows/crowdin.yml
vendored
@@ -13,7 +13,7 @@ jobs:
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
token: ${{ secrets.PNGX_BOT_PAT }}
|
||||
- name: crowdin action
|
||||
|
2
.github/workflows/pr-bot.yml
vendored
2
.github/workflows/pr-bot.yml
vendored
@@ -37,7 +37,7 @@ jobs:
|
||||
labels.push('bug');
|
||||
} else if (/^feature/i.test(title)) {
|
||||
labels.push('enhancement');
|
||||
} else if (!/^(dependabot)/i.test(title) && !/^(chore)/i.test(title)) {
|
||||
} else if (!/^(dependabot)/i.test(title)) {
|
||||
labels.push('enhancement'); // Default fallback
|
||||
}
|
||||
|
||||
|
1
.github/workflows/repo-maintenance.yml
vendored
1
.github/workflows/repo-maintenance.yml
vendored
@@ -241,7 +241,6 @@ jobs:
|
||||
) {
|
||||
nodes {
|
||||
id,
|
||||
createdAt,
|
||||
number,
|
||||
updatedAt,
|
||||
upvoteCount,
|
||||
|
2
.github/workflows/translate-strings.yml
vendored
2
.github/workflows/translate-strings.yml
vendored
@@ -11,7 +11,7 @@ jobs:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@v5
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
token: ${{ secrets.PNGX_BOT_PAT }}
|
||||
ref: ${{ github.head_ref }}
|
||||
|
3
.gitignore
vendored
3
.gitignore
vendored
@@ -107,6 +107,3 @@ celerybeat-schedule*
|
||||
/.devcontainer/data/
|
||||
/.devcontainer/media/
|
||||
/.devcontainer/redisdata/
|
||||
|
||||
# ignore pnpm package store folder created when setting up the devcontainer
|
||||
.pnpm-store/
|
||||
|
@@ -4,7 +4,7 @@
|
||||
repos:
|
||||
# General hooks
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: v6.0.0
|
||||
rev: v5.0.0
|
||||
hooks:
|
||||
- id: check-docstring-first
|
||||
- id: check-json
|
||||
@@ -18,7 +18,7 @@ repos:
|
||||
exclude_types:
|
||||
- svg
|
||||
- pofile
|
||||
exclude: "(^LICENSE$|^src/documents/static/bootstrap.min.css$)"
|
||||
exclude: "(^LICENSE$)"
|
||||
- id: mixed-line-ending
|
||||
args:
|
||||
- "--fix=lf"
|
||||
@@ -49,9 +49,9 @@ repos:
|
||||
- 'prettier-plugin-organize-imports@4.1.0'
|
||||
# Python hooks
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.13.0
|
||||
rev: v0.12.2
|
||||
hooks:
|
||||
- id: ruff-check
|
||||
- id: ruff
|
||||
- id: ruff-format
|
||||
- repo: https://github.com/tox-dev/pyproject-fmt
|
||||
rev: "v2.6.0"
|
||||
@@ -72,7 +72,7 @@ repos:
|
||||
args:
|
||||
- "--tab"
|
||||
- repo: https://github.com/shellcheck-py/shellcheck-py
|
||||
rev: "v0.11.0.1"
|
||||
rev: "v0.10.0.1"
|
||||
hooks:
|
||||
- id: shellcheck
|
||||
- repo: https://github.com/google/yamlfmt
|
||||
|
@@ -2,11 +2,9 @@
|
||||
|
||||
If you feel like contributing to the project, please do! Bug fixes and improvements are always welcome.
|
||||
|
||||
⚠️ Please note: Pull requests that implement a new feature or enhancement _should almost always target an existing feature request_ with evidence of community interest and discussion. This is in order to balance the work of implementing and maintaining new features / enhancements. Pull requests that are opened without meeting this requirement may not be merged.
|
||||
|
||||
If you want to implement something big:
|
||||
|
||||
- As above, please start with a discussion! Maybe something similar is already in development and we can make it happen together.
|
||||
- Please start a discussion about that in the issues! Maybe something similar is already in development and we can make it happen together.
|
||||
- When making additions to the project, consider if the majority of users will benefit from your change. If not, you're probably better of forking the project.
|
||||
- Also consider if your change will get in the way of other users. A good change is a change that enhances the experience of some users who want that change and does not affect users who do not care about the change.
|
||||
- Please see the [paperless-ngx merge process](#merging-prs) below.
|
||||
@@ -135,7 +133,7 @@ community members. That said, in an effort to keep the repository organized and
|
||||
- Issues, pull requests and discussions that are closed will be locked after 30 days of inactivity.
|
||||
- Discussions with a marked answer will be automatically closed.
|
||||
- Discussions in the 'General' or 'Support' categories will be closed after 180 days of inactivity.
|
||||
- Feature requests that do not meet the following thresholds will be closed: 180 days of inactivity with less than 80 "up-votes", < 5 "up-votes" after 180 days, < 20 "up-votes" after 1 year or < 40 "up-votes" at 2 years.
|
||||
- Feature requests that do not meet the following thresholds will be closed: 180 days of inactivity, < 5 "up-votes" after 180 days, < 20 "up-votes" after 1 year or < 80 "up-votes" at 2 years.
|
||||
|
||||
In all cases, threads can be re-opened by project maintainers and, of course, users can always create a new discussion for related concerns.
|
||||
Finally, remember that all information remains searchable and 'closed' feature requests can still serve as inspiration for new features.
|
||||
|
@@ -32,7 +32,7 @@ RUN set -eux \
|
||||
# Purpose: Installs s6-overlay and rootfs
|
||||
# Comments:
|
||||
# - Don't leave anything extra in here either
|
||||
FROM ghcr.io/astral-sh/uv:0.8.22-python3.12-bookworm-slim AS s6-overlay-base
|
||||
FROM ghcr.io/astral-sh/uv:0.8.13-python3.12-bookworm-slim AS s6-overlay-base
|
||||
|
||||
WORKDIR /usr/src/s6
|
||||
|
||||
|
@@ -4,7 +4,7 @@
|
||||
# correct networking for the tests
|
||||
services:
|
||||
gotenberg:
|
||||
image: docker.io/gotenberg/gotenberg:8.23
|
||||
image: docker.io/gotenberg/gotenberg:8.22
|
||||
hostname: gotenberg
|
||||
container_name: gotenberg
|
||||
network_mode: host
|
||||
|
@@ -72,7 +72,7 @@ services:
|
||||
PAPERLESS_TIKA_GOTENBERG_ENDPOINT: http://gotenberg:3000
|
||||
PAPERLESS_TIKA_ENDPOINT: http://tika:9998
|
||||
gotenberg:
|
||||
image: docker.io/gotenberg/gotenberg:8.23
|
||||
image: docker.io/gotenberg/gotenberg:8.22
|
||||
restart: unless-stopped
|
||||
# The gotenberg chromium route is used to convert .eml files. We do not
|
||||
# want to allow external content like tracking pixels or even javascript.
|
||||
|
@@ -66,7 +66,7 @@ services:
|
||||
PAPERLESS_TIKA_GOTENBERG_ENDPOINT: http://gotenberg:3000
|
||||
PAPERLESS_TIKA_ENDPOINT: http://tika:9998
|
||||
gotenberg:
|
||||
image: docker.io/gotenberg/gotenberg:8.23
|
||||
image: docker.io/gotenberg/gotenberg:8.22
|
||||
restart: unless-stopped
|
||||
# The gotenberg chromium route is used to convert .eml files. We do not
|
||||
# want to allow external content like tracking pixels or even javascript.
|
||||
|
@@ -55,7 +55,7 @@ services:
|
||||
PAPERLESS_TIKA_GOTENBERG_ENDPOINT: http://gotenberg:3000
|
||||
PAPERLESS_TIKA_ENDPOINT: http://tika:9998
|
||||
gotenberg:
|
||||
image: docker.io/gotenberg/gotenberg:8.23
|
||||
image: docker.io/gotenberg/gotenberg:8.22
|
||||
restart: unless-stopped
|
||||
# The gotenberg chromium route is used to convert .eml files. We do not
|
||||
# want to allow external content like tracking pixels or even javascript.
|
||||
|
@@ -471,7 +471,7 @@ Failing to invalidate the cache after such modifications can lead to stale data
|
||||
Use the following management command to clear the cache:
|
||||
|
||||
```
|
||||
python3 manage.py invalidate_cachalot
|
||||
invalidate_cachalot
|
||||
```
|
||||
|
||||
!!! info
|
||||
|
@@ -506,7 +506,6 @@ for the possible codes and their meanings.
|
||||
The `localize_date` filter formats a date or datetime object into a localized string using Babel internationalization.
|
||||
This takes into account the provided locale for translation. Since this must be used on a date or datetime object,
|
||||
you must access the field directly, i.e. `document.created`.
|
||||
An ISO string can also be provided to control the output format.
|
||||
|
||||
###### Syntax
|
||||
|
||||
@@ -517,7 +516,7 @@ An ISO string can also be provided to control the output format.
|
||||
|
||||
###### Parameters
|
||||
|
||||
- `value` (date | datetime | str): Date, datetime object or ISO string to format (datetime should be timezone-aware)
|
||||
- `value` (date | datetime): Date or datetime object to format (datetime should be timezone-aware)
|
||||
- `format` (str): Format type - either a Babel preset ('short', 'medium', 'long', 'full') or custom pattern
|
||||
- `locale` (str): Locale code for localization (e.g., 'en_US', 'fr_FR', 'de_DE')
|
||||
|
||||
|
@@ -192,8 +192,8 @@ 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`: Either an array of custom field ids to assign (with an empty
|
||||
value) to the document or an object mapping field id -> value.
|
||||
- `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
|
||||
|
@@ -1,94 +1,5 @@
|
||||
# Changelog
|
||||
|
||||
## paperless-ngx 2.18.4
|
||||
|
||||
### Features / Enhancements
|
||||
|
||||
- Enhancement: report websocket status [@shamoon](https://github.com/shamoon) ([#10777](https://github.com/paperless-ngx/paperless-ngx/pull/10777))
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Revert "Performance: Enable virtual scrolling for large custom field … [@shamoon](https://github.com/shamoon) ([#10803](https://github.com/paperless-ngx/paperless-ngx/pull/10803))
|
||||
- Fixhancement: update sidebar view counts on save \& next also [@shamoon](https://github.com/shamoon) ([#10793](https://github.com/paperless-ngx/paperless-ngx/pull/10793))
|
||||
- Performance fix: add paging for custom field select options [@shamoon](https://github.com/shamoon) ([#10755](https://github.com/paperless-ngx/paperless-ngx/pull/10755))
|
||||
|
||||
### Dependencies
|
||||
|
||||
<details>
|
||||
<summary>8 changes</summary>
|
||||
|
||||
- Chore(deps-dev): Bump the frontend-jest-dependencies group in /src-ui with 2 updates [@shamoon](https://github.com/shamoon) ([#10770](https://github.com/paperless-ngx/paperless-ngx/pull/10770))
|
||||
- Chore(deps-dev): Bump the frontend-eslint-dependencies group in /src-ui with 4 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#10745](https://github.com/paperless-ngx/paperless-ngx/pull/10745))
|
||||
- Chore(deps): Bump the frontend-angular-dependencies group in /src-ui with 22 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#10744](https://github.com/paperless-ngx/paperless-ngx/pull/10744))
|
||||
- Chore(deps): Bump bootstrap from 5.3.7 to 5.3.8 in /src-ui @[dependabot[bot]](https://github.com/apps/dependabot) ([#10740](https://github.com/paperless-ngx/paperless-ngx/pull/10740))
|
||||
- Chore(deps-dev): Bump @<!---->playwright/test from 1.54.2 to 1.55.0 in /src-ui @[dependabot[bot]](https://github.com/apps/dependabot) ([#10743](https://github.com/paperless-ngx/paperless-ngx/pull/10743))
|
||||
- Chore(deps-dev): Bump webpack from 5.101.0 to 5.101.3 in /src-ui @[dependabot[bot]](https://github.com/apps/dependabot) ([#10751](https://github.com/paperless-ngx/paperless-ngx/pull/10751))
|
||||
- Chore(deps-dev): Bump @<!---->types/node from 24.1.0 to 24.3.0 in /src-ui @[dependabot[bot]](https://github.com/apps/dependabot) ([#10750](https://github.com/paperless-ngx/paperless-ngx/pull/10750))
|
||||
- Chore(deps): Bump the actions group with 3 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#10757](https://github.com/paperless-ngx/paperless-ngx/pull/10757))
|
||||
</details>
|
||||
|
||||
### All App Changes
|
||||
|
||||
<details>
|
||||
<summary>13 changes</summary>
|
||||
|
||||
- Revert "Performance: Enable virtual scrolling for large custom field … @shamoon ([#10803](https://github.com/paperless-ngx/paperless-ngx/pull/10803))
|
||||
- Fixhancement: update sidebar view counts on save \& next also @shamoon ([#10793](https://github.com/paperless-ngx/paperless-ngx/pull/10793))
|
||||
- Enhancement: report websocket status @shamoon ([#10777](https://github.com/paperless-ngx/paperless-ngx/pull/10777))
|
||||
- Chore(deps-dev): Bump the frontend-jest-dependencies group in /src-ui with 2 updates @shamoon ([#10770](https://github.com/paperless-ngx/paperless-ngx/pull/10770))
|
||||
- Chore(deps-dev): Bump the frontend-eslint-dependencies group in /src-ui with 4 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#10745](https://github.com/paperless-ngx/paperless-ngx/pull/10745))
|
||||
- Chore(deps): Bump the frontend-angular-dependencies group in /src-ui with 22 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#10744](https://github.com/paperless-ngx/paperless-ngx/pull/10744))
|
||||
- Chore(deps): Bump bootstrap from 5.3.7 to 5.3.8 in /src-ui @[dependabot[bot]](https://github.com/apps/dependabot) ([#10740](https://github.com/paperless-ngx/paperless-ngx/pull/10740))
|
||||
- Chore(deps-dev): Bump @<!---->playwright/test from 1.54.2 to 1.55.0 in /src-ui @[dependabot[bot]](https://github.com/apps/dependabot) ([#10743](https://github.com/paperless-ngx/paperless-ngx/pull/10743))
|
||||
- Chore(deps-dev): Bump webpack from 5.101.0 to 5.101.3 in /src-ui @[dependabot[bot]](https://github.com/apps/dependabot) ([#10751](https://github.com/paperless-ngx/paperless-ngx/pull/10751))
|
||||
- Chore(deps-dev): Bump @<!---->types/node from 24.1.0 to 24.3.0 in /src-ui @[dependabot[bot]](https://github.com/apps/dependabot) ([#10750](https://github.com/paperless-ngx/paperless-ngx/pull/10750))
|
||||
- Chore: switch from os.path to pathlib.Path @gothicVI ([#10539](https://github.com/paperless-ngx/paperless-ngx/pull/10539))
|
||||
- Performance fix: add paging for custom field select options @shamoon ([#10755](https://github.com/paperless-ngx/paperless-ngx/pull/10755))
|
||||
</details>
|
||||
|
||||
## paperless-ngx 2.18.3
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Fix: include application config language settings for dateparser auto-detection [@shamoon](https://github.com/shamoon) ([#10722](https://github.com/paperless-ngx/paperless-ngx/pull/10722))
|
||||
- Fix: hide sidebar counts during saved views organization [@shamoon](https://github.com/shamoon) ([#10716](https://github.com/paperless-ngx/paperless-ngx/pull/10716))
|
||||
- Fix: wrap long view titles in sidebar [@shamoon](https://github.com/shamoon) ([#10715](https://github.com/paperless-ngx/paperless-ngx/pull/10715))
|
||||
- Fixhancement: more saved view count refreshes [@shamoon](https://github.com/shamoon) ([#10694](https://github.com/paperless-ngx/paperless-ngx/pull/10694))
|
||||
- Fix: include pagination array items for valid openapi schema [@shamoon](https://github.com/shamoon) ([#10682](https://github.com/paperless-ngx/paperless-ngx/pull/10682))
|
||||
- Fix: prevent scroll for view name in sidebar [@shamoon](https://github.com/shamoon) ([#10676](https://github.com/paperless-ngx/paperless-ngx/pull/10676))
|
||||
- Tweak: center document close button in app frame [@shamoon](https://github.com/shamoon) ([#10661](https://github.com/paperless-ngx/paperless-ngx/pull/10661))
|
||||
- Performance: Enable virtual scrolling for large custom field selects @david-loe ([#10708](https://github.com/paperless-ngx/paperless-ngx/pull/10708))
|
||||
|
||||
### Dependencies
|
||||
|
||||
<details>
|
||||
<summary>5 changes</summary>
|
||||
|
||||
- Chore(deps): Update granian[uvloop] requirement from ~=2.4.1 to ~=2.5.1 @[dependabot[bot]](https://github.com/apps/dependabot) ([#10529](https://github.com/paperless-ngx/paperless-ngx/pull/10529))
|
||||
- Chore(deps): Bump the small-changes group across 1 directory with 6 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#10714](https://github.com/paperless-ngx/paperless-ngx/pull/10714))
|
||||
- docker-compose(deps): Bump library/mariadb from 11 to 12 in /docker/compose @[dependabot[bot]](https://github.com/apps/dependabot) ([#10621](https://github.com/paperless-ngx/paperless-ngx/pull/10621))
|
||||
- docker-compose(deps): Bump gotenberg/gotenberg from 8.20 to 8.22 in /docker/compose @[dependabot[bot]](https://github.com/apps/dependabot) ([#10687](https://github.com/paperless-ngx/paperless-ngx/pull/10687))
|
||||
- docker(deps): Bump astral-sh/uv from 0.8.8-python3.12-bookworm-slim to 0.8.13-python3.12-bookworm-slim @[dependabot[bot]](https://github.com/apps/dependabot) ([#10685](https://github.com/paperless-ngx/paperless-ngx/pull/10685))
|
||||
</details>
|
||||
|
||||
### All App Changes
|
||||
|
||||
<details>
|
||||
<summary>11 changes</summary>
|
||||
|
||||
- Fix: include application config language settings for dateparser auto-detection [@shamoon](https://github.com/shamoon) ([#10722](https://github.com/paperless-ngx/paperless-ngx/pull/10722))
|
||||
- Chore(deps): Update granian[uvloop] requirement from ~=2.4.1 to ~=2.5.1 @[dependabot[bot]](https://github.com/apps/dependabot) ([#10529](https://github.com/paperless-ngx/paperless-ngx/pull/10529))
|
||||
- Chore(deps): Bump the small-changes group across 1 directory with 6 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#10714](https://github.com/paperless-ngx/paperless-ngx/pull/10714))
|
||||
- Fix: hide sidebar counts during saved views organization [@shamoon](https://github.com/shamoon) ([#10716](https://github.com/paperless-ngx/paperless-ngx/pull/10716))
|
||||
- Fix: wrap long view titles in sidebar [@shamoon](https://github.com/shamoon) ([#10715](https://github.com/paperless-ngx/paperless-ngx/pull/10715))
|
||||
- Performance: Enable virtual scrolling for large custom field selects @david-loe ([#10708](https://github.com/paperless-ngx/paperless-ngx/pull/10708))
|
||||
- Chore: refactor document details component [@shamoon](https://github.com/shamoon) ([#10662](https://github.com/paperless-ngx/paperless-ngx/pull/10662))
|
||||
- Fixhancement: more saved view count refreshes [@shamoon](https://github.com/shamoon) ([#10694](https://github.com/paperless-ngx/paperless-ngx/pull/10694))
|
||||
- Fix: include pagination array items for valid openapi schema [@shamoon](https://github.com/shamoon) ([#10682](https://github.com/paperless-ngx/paperless-ngx/pull/10682))
|
||||
- Fix: prevent scroll for view name in sidebar [@shamoon](https://github.com/shamoon) ([#10676](https://github.com/paperless-ngx/paperless-ngx/pull/10676))
|
||||
- Tweak: center document close button in app frame [@shamoon](https://github.com/shamoon) ([#10661](https://github.com/paperless-ngx/paperless-ngx/pull/10661))
|
||||
</details>
|
||||
|
||||
## paperless-ngx 2.18.2
|
||||
|
||||
### Bug Fixes
|
||||
|
@@ -1759,11 +1759,6 @@ started by the container.
|
||||
|
||||
: Path to an image file in the /media/logo directory, must include 'logo', e.g. `/logo/Atari_logo.svg`
|
||||
|
||||
!!! note
|
||||
|
||||
The logo file will be viewable by anyone with access to the Paperless instance login page,
|
||||
so consider your choice of logo carefully and removing exif data from images before uploading.
|
||||
|
||||
#### [`PAPERLESS_ENABLE_UPDATE_CHECK=<bool>`](#PAPERLESS_ENABLE_UPDATE_CHECK) {#PAPERLESS_ENABLE_UPDATE_CHECK}
|
||||
|
||||
!!! note
|
||||
|
@@ -470,14 +470,9 @@ To get started:
|
||||
|
||||
2. VS Code will prompt you with "Reopen in container". Do so and wait for the environment to start.
|
||||
|
||||
3. In case your host operating system is Windows:
|
||||
|
||||
- The Source Control view in Visual Studio Code might show: "The detected Git repository is potentially unsafe as the folder is owned by someone other than the current user." Use "Manage Unsafe Repositories" to fix this.
|
||||
- Git might have detecteded modifications for all files, because Windows is using CRLF line endings. Run `git checkout .` in the containers terminal to fix this issue.
|
||||
|
||||
4. Initialize the project by running the task **Project Setup: Run all Init Tasks**. This
|
||||
3. Initialize the project by running the task **Project Setup: Run all Init Tasks**. This
|
||||
will initialize the database tables and create a superuser. Then you can compile the front end
|
||||
for production or run the frontend in debug mode.
|
||||
|
||||
5. The project is ready for debugging, start either run the fullstack debug or individual debug
|
||||
4. The project is ready for debugging, start either run the fullstack debug or individual debug
|
||||
processes. Yo spin up the project without debugging run the task **Project Start: Run all Services**
|
||||
|
@@ -92,16 +92,6 @@ and more. These areas allow you to view, add, edit, delete and manage permission
|
||||
for these objects. You can also manage saved views, mail accounts, mail rules,
|
||||
workflows and more from the management sections.
|
||||
|
||||
### Nested Tags
|
||||
|
||||
Paperless-ngx v2.19 introduces support for nested tags, allowing you to create a
|
||||
hierarchy of tags, which may be useful for organizing your documents. Tags can
|
||||
have a 'parent' tag, creating a tree-like structure, to a maximum depth of 5. When
|
||||
a tag is added to a document, all of its parent tags are also added automatically
|
||||
and similarly, when a tag is removed from a document, all of its child tags are
|
||||
also removed. Additionally, assigning a parent to an existing tag will automatically
|
||||
update all documents that have this tag assigned, adding the parent tag as well.
|
||||
|
||||
## Adding documents to Paperless-ngx
|
||||
|
||||
Once you've got Paperless setup, you need to start feeding documents
|
||||
@@ -261,10 +251,6 @@ different means. These are as follows:
|
||||
Paperless is set up to check your mails every 10 minutes. This can be
|
||||
configured via [`PAPERLESS_EMAIL_TASK_CRON`](configuration.md#PAPERLESS_EMAIL_TASK_CRON)
|
||||
|
||||
#### Processed Mail
|
||||
|
||||
Paperless keeps track of emails it has processed in order to avoid processing the same mail multiple times. This uses the message `UID` provided by the mail server, which should be unique for each message. You can view and manage processed mails from the web UI under Mail > Processed Mails. If you need to re-process a message, you can delete the corresponding processed mail entry, which will allow Paperless-ngx to process the email again the next time the mail fetch task runs.
|
||||
|
||||
#### OAuth Email Setup
|
||||
|
||||
Paperless-ngx supports OAuth2 authentication for Gmail and Outlook email accounts. To set up an email account with OAuth2, you will need to create a 'developer' app with the respective provider and obtain the client ID and client secret and set the appropriate [configuration variables](configuration.md#email_oauth). You will also need to set either [`PAPERLESS_OAUTH_CALLBACK_BASE_URL`](configuration.md#PAPERLESS_OAUTH_CALLBACK_BASE_URL) or [`PAPERLESS_URL`](configuration.md#PAPERLESS_URL) to the correct value for the OAuth2 flow to work correctly.
|
||||
@@ -422,7 +408,7 @@ Currently, there are three events that correspond to workflow trigger 'types':
|
||||
but the document content has been extracted and metadata such as document type, tags, etc. have been set, so these can now
|
||||
be used for filtering.
|
||||
3. **Document Updated**: when a document is updated. Similar to 'added' events, triggers can include filtering by content matching,
|
||||
tags, doc type, correspondent or storage path.
|
||||
tags, doc type, or correspondent.
|
||||
4. **Scheduled**: a scheduled trigger that can be used to run workflows at a specific time. The date used can be either the document
|
||||
added, created, updated date or you can specify a (date) custom field. You can also specify a day offset from the date (positive
|
||||
offsets will trigger after the date, negative offsets will trigger before).
|
||||
@@ -466,11 +452,10 @@ Workflows allow you to filter by:
|
||||
- File path, including wildcards. Note that enabling `PAPERLESS_CONSUMER_RECURSIVE` would allow, for
|
||||
example, automatically assigning documents to different owners based on the upload directory.
|
||||
- Mail rule. Choosing this option will force 'mail fetch' to be the workflow source.
|
||||
- Content matching (`Added`, `Updated` and `Scheduled` triggers only). Filter document content using the matching settings.
|
||||
- Tags (`Added`, `Updated` and `Scheduled` triggers only). Filter for documents with any of the specified tags
|
||||
- Document type (`Added`, `Updated` and `Scheduled` triggers only). Filter documents with this doc type
|
||||
- Correspondent (`Added`, `Updated` and `Scheduled` triggers only). Filter documents with this correspondent
|
||||
- Storage path (`Added`, `Updated` and `Scheduled` triggers only). Filter documents with this storage path
|
||||
- Content matching (`Added` and `Updated` triggers only). Filter document content using the matching settings.
|
||||
- Tags (`Added` and `Updated` triggers only). Filter for documents with any of the specified tags
|
||||
- Document type (`Added` and `Updated` triggers only). Filter documents with this doc type
|
||||
- Correspondent (`Added` and `Updated` triggers only). Filter documents with this correspondent
|
||||
|
||||
### Workflow Actions
|
||||
|
||||
@@ -520,52 +505,35 @@ you may want to adjust these settings to prevent abuse.
|
||||
|
||||
#### Workflow placeholders
|
||||
|
||||
Titles can be assigned by workflows using [Jinja templates](https://jinja.palletsprojects.com/en/3.1.x/templates/).
|
||||
This allows for complex logic to be used to generate the title, including [logical structures](https://jinja.palletsprojects.com/en/3.1.x/templates/#list-of-control-structures)
|
||||
and [filters](https://jinja.palletsprojects.com/en/3.1.x/templates/#id11).
|
||||
The template is provided as a string.
|
||||
Some workflow text can include placeholders but the available options differ depending on the type of
|
||||
workflow trigger. This is because at the time of consumption (when the text is to be set), no automatic tags etc. have been
|
||||
applied. You can use the following placeholders with any trigger type:
|
||||
|
||||
Using Jinja2 Templates is also useful for [Date localization](advanced_usage.md#Date-Localization) in the title.
|
||||
|
||||
The available inputs differ depending on the type of workflow trigger.
|
||||
This is because at the time of consumption (when the text is to be set), no automatic tags etc. have been
|
||||
applied. You can use the following placeholders in the template with any trigger type:
|
||||
|
||||
- `{{correspondent}}`: assigned correspondent name
|
||||
- `{{document_type}}`: assigned document type name
|
||||
- `{{owner_username}}`: assigned owner username
|
||||
- `{{added}}`: added datetime
|
||||
- `{{added_year}}`: added year
|
||||
- `{{added_year_short}}`: added year
|
||||
- `{{added_month}}`: added month
|
||||
- `{{added_month_name}}`: added month name
|
||||
- `{{added_month_name_short}}`: added month short name
|
||||
- `{{added_day}}`: added day
|
||||
- `{{added_time}}`: added time in HH:MM format
|
||||
- `{{original_filename}}`: original file name without extension
|
||||
- `{{filename}}`: current file name without extension
|
||||
- `{correspondent}`: assigned correspondent name
|
||||
- `{document_type}`: assigned document type name
|
||||
- `{owner_username}`: assigned owner username
|
||||
- `{added}`: added datetime
|
||||
- `{added_year}`: added year
|
||||
- `{added_year_short}`: added year
|
||||
- `{added_month}`: added month
|
||||
- `{added_month_name}`: added month name
|
||||
- `{added_month_name_short}`: added month short name
|
||||
- `{added_day}`: added day
|
||||
- `{added_time}`: added time in HH:MM format
|
||||
- `{original_filename}`: original file name without extension
|
||||
- `{filename}`: current file name without extension
|
||||
|
||||
The following placeholders are only available for "added" or "updated" triggers
|
||||
|
||||
- `{{created}}`: created datetime
|
||||
- `{{created_year}}`: created year
|
||||
- `{{created_year_short}}`: created year
|
||||
- `{{created_month}}`: created month
|
||||
- `{{created_month_name}}`: created month name
|
||||
- `{created_month_name_short}}`: created month short name
|
||||
- `{{created_day}}`: created day
|
||||
- `{{created_time}}`: created time in HH:MM format
|
||||
- `{{doc_url}}`: URL to the document in the web UI. Requires the `PAPERLESS_URL` setting to be set.
|
||||
|
||||
##### Examples
|
||||
|
||||
```jinja2
|
||||
{{ created | localize_date('MMMM', 'en_US') }}
|
||||
<!-- Output: "January" -->
|
||||
|
||||
{{ added | localize_date('MMMM', 'de_DE') }}
|
||||
<!-- Output: "Juni" --> # codespell:ignore
|
||||
```
|
||||
- `{created}`: created datetime
|
||||
- `{created_year}`: created year
|
||||
- `{created_year_short}`: created year
|
||||
- `{created_month}`: created month
|
||||
- `{created_month_name}`: created month name
|
||||
- `{created_month_name_short}`: created month short name
|
||||
- `{created_day}`: created day
|
||||
- `{created_time}`: created time in HH:MM format
|
||||
- `{doc_url}`: URL to the document in the web UI. Requires the `PAPERLESS_URL` setting to be set.
|
||||
|
||||
### Workflow permissions
|
||||
|
||||
|
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "paperless-ngx"
|
||||
version = "2.18.4"
|
||||
version = "2.18.2"
|
||||
description = "A community-supported supercharged document management system: scan, index and archive all your physical documents"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
@@ -30,17 +30,16 @@ dependencies = [
|
||||
"django-cachalot~=2.8.0",
|
||||
"django-celery-results~=2.6.0",
|
||||
"django-compression-middleware~=0.5.0",
|
||||
"django-cors-headers~=4.9.0",
|
||||
"django-cors-headers~=4.7.0",
|
||||
"django-extensions~=4.1",
|
||||
"django-filter~=25.1",
|
||||
"django-guardian~=3.2.0",
|
||||
"django-guardian~=3.0.3",
|
||||
"django-multiselectfield~=1.0.1",
|
||||
"django-soft-delete~=1.0.18",
|
||||
"django-treenode>=0.23.2",
|
||||
"djangorestframework~=3.16",
|
||||
"djangorestframework-guardian~=0.4.0",
|
||||
"drf-spectacular~=0.28",
|
||||
"drf-spectacular-sidecar~=2025.9.1",
|
||||
"drf-spectacular-sidecar~=2025.8.1",
|
||||
"drf-writable-nested~=0.7.1",
|
||||
"filelock~=3.19.1",
|
||||
"flower~=2.0.1",
|
||||
@@ -51,9 +50,10 @@ dependencies = [
|
||||
"jinja2~=3.1.5",
|
||||
"langdetect~=1.0.9",
|
||||
"nltk~=3.9.1",
|
||||
"ocrmypdf~=16.11.0",
|
||||
"ocrmypdf~=16.10.0",
|
||||
"pathvalidate~=3.3.1",
|
||||
"pdf2image~=1.17.0",
|
||||
"psutil>=7",
|
||||
"psycopg-pool",
|
||||
"python-dateutil~=2.9.0",
|
||||
"python-dotenv~=1.1.0",
|
||||
@@ -95,7 +95,7 @@ dev = [
|
||||
]
|
||||
|
||||
docs = [
|
||||
"mkdocs-glightbox~=0.5.1",
|
||||
"mkdocs-glightbox~=0.4.0",
|
||||
"mkdocs-material~=9.6.4",
|
||||
]
|
||||
|
||||
@@ -104,7 +104,7 @@ testing = [
|
||||
"factory-boy~=3.3.1",
|
||||
"imagehash",
|
||||
"pytest~=8.4.1",
|
||||
"pytest-cov~=7.0.0",
|
||||
"pytest-cov~=6.2.1",
|
||||
"pytest-django~=4.11.1",
|
||||
"pytest-env",
|
||||
"pytest-httpx",
|
||||
@@ -117,7 +117,7 @@ testing = [
|
||||
lint = [
|
||||
"pre-commit~=4.3.0",
|
||||
"pre-commit-uv~=4.1.3",
|
||||
"ruff~=0.13.0",
|
||||
"ruff~=0.12.2",
|
||||
]
|
||||
|
||||
typing = [
|
||||
@@ -125,7 +125,6 @@ typing = [
|
||||
"django-filter-stubs",
|
||||
"django-stubs[compatible-mypy]",
|
||||
"djangorestframework-stubs[compatible-mypy]",
|
||||
"lxml-stubs",
|
||||
"mypy",
|
||||
"types-bleach",
|
||||
"types-colorama",
|
||||
@@ -133,7 +132,6 @@ typing = [
|
||||
"types-markdown",
|
||||
"types-pygments",
|
||||
"types-python-dateutil",
|
||||
"types-pytz",
|
||||
"types-redis",
|
||||
"types-setuptools",
|
||||
"types-tqdm",
|
||||
@@ -208,9 +206,18 @@ lint.per-file-ignores."docker/wait-for-redis.py" = [
|
||||
"INP001",
|
||||
"T201",
|
||||
]
|
||||
lint.per-file-ignores."src/documents/management/commands/document_consumer.py" = [
|
||||
"PTH",
|
||||
] # TODO Enable & remove
|
||||
lint.per-file-ignores."src/documents/migrations/1012_fix_archive_files.py" = [
|
||||
"PTH",
|
||||
] # TODO Enable & remove
|
||||
lint.per-file-ignores."src/documents/models.py" = [
|
||||
"SIM115",
|
||||
]
|
||||
lint.per-file-ignores."src/documents/parsers.py" = [
|
||||
"PTH",
|
||||
] # TODO Enable & remove
|
||||
lint.per-file-ignores."src/paperless_tesseract/tests/test_parser.py" = [
|
||||
"RUF001",
|
||||
]
|
||||
@@ -273,10 +280,10 @@ exclude_also = [
|
||||
]
|
||||
|
||||
[tool.mypy]
|
||||
mypy_path = "src"
|
||||
plugins = [
|
||||
"mypy_django_plugin.main",
|
||||
"mypy_drf_plugin.main",
|
||||
"numpy.typing.mypy_plugin",
|
||||
]
|
||||
check_untyped_defs = true
|
||||
disallow_any_generics = true
|
||||
|
@@ -174,7 +174,7 @@ test('bulk edit', async ({ page }) => {
|
||||
await expect(page.locator('pngx-document-list')).toHaveText(
|
||||
/Selected 61 of 61 documents/i
|
||||
)
|
||||
await page.getByRole('button', { name: 'None' }).click()
|
||||
await page.getByRole('button', { name: 'Cancel' }).click()
|
||||
|
||||
await page.locator('pngx-document-card-small').nth(1).click()
|
||||
await page.locator('pngx-document-card-small').nth(2).click()
|
||||
|
1125
src-ui/messages.xlf
1125
src-ui/messages.xlf
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paperless-ngx-ui",
|
||||
"version": "2.18.4",
|
||||
"version": "2.18.2",
|
||||
"scripts": {
|
||||
"preinstall": "npx only-allow pnpm",
|
||||
"ng": "ng",
|
||||
@@ -11,27 +11,27 @@
|
||||
},
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@angular/cdk": "^20.2.2",
|
||||
"@angular/common": "~20.2.4",
|
||||
"@angular/compiler": "~20.2.4",
|
||||
"@angular/core": "~20.2.4",
|
||||
"@angular/forms": "~20.2.4",
|
||||
"@angular/localize": "~20.2.4",
|
||||
"@angular/platform-browser": "~20.2.4",
|
||||
"@angular/platform-browser-dynamic": "~20.2.4",
|
||||
"@angular/router": "~20.2.4",
|
||||
"@angular/cdk": "^20.1.4",
|
||||
"@angular/common": "~20.1.4",
|
||||
"@angular/compiler": "~20.1.4",
|
||||
"@angular/core": "~20.1.4",
|
||||
"@angular/forms": "~20.1.4",
|
||||
"@angular/localize": "~20.1.4",
|
||||
"@angular/platform-browser": "~20.1.4",
|
||||
"@angular/platform-browser-dynamic": "~20.1.4",
|
||||
"@angular/router": "~20.1.4",
|
||||
"@ng-bootstrap/ng-bootstrap": "^19.0.1",
|
||||
"@ng-select/ng-select": "^20.1.3",
|
||||
"@ng-select/ng-select": "^20.0.1",
|
||||
"@ngneat/dirty-check-forms": "^3.0.3",
|
||||
"@popperjs/core": "^2.11.8",
|
||||
"bootstrap": "^5.3.8",
|
||||
"bootstrap": "^5.3.7",
|
||||
"file-saver": "^2.0.5",
|
||||
"mime-names": "^1.0.0",
|
||||
"ng2-pdf-viewer": "^10.4.0",
|
||||
"ngx-bootstrap-icons": "^1.9.3",
|
||||
"ngx-color": "^10.0.0",
|
||||
"ngx-cookie-service": "^20.1.0",
|
||||
"ngx-device-detector": "^10.1.0",
|
||||
"ngx-cookie-service": "^20.0.1",
|
||||
"ngx-device-detector": "^10.0.2",
|
||||
"ngx-ui-tour-ng-bootstrap": "^17.0.1",
|
||||
"rxjs": "^7.8.2",
|
||||
"tslib": "^2.8.1",
|
||||
@@ -42,33 +42,33 @@
|
||||
"devDependencies": {
|
||||
"@angular-builders/custom-webpack": "^20.0.0",
|
||||
"@angular-builders/jest": "^20.0.0",
|
||||
"@angular-devkit/core": "^20.2.2",
|
||||
"@angular-devkit/schematics": "^20.2.2",
|
||||
"@angular-eslint/builder": "20.2.0",
|
||||
"@angular-eslint/eslint-plugin": "20.2.0",
|
||||
"@angular-eslint/eslint-plugin-template": "20.2.0",
|
||||
"@angular-eslint/schematics": "20.2.0",
|
||||
"@angular-eslint/template-parser": "20.2.0",
|
||||
"@angular/build": "^20.2.2",
|
||||
"@angular/cli": "~20.2.2",
|
||||
"@angular/compiler-cli": "~20.2.4",
|
||||
"@angular-devkit/core": "^20.1.4",
|
||||
"@angular-devkit/schematics": "^20.1.4",
|
||||
"@angular-eslint/builder": "20.1.1",
|
||||
"@angular-eslint/eslint-plugin": "20.1.1",
|
||||
"@angular-eslint/eslint-plugin-template": "20.1.1",
|
||||
"@angular-eslint/schematics": "20.1.1",
|
||||
"@angular-eslint/template-parser": "20.1.1",
|
||||
"@angular/build": "^20.1.4",
|
||||
"@angular/cli": "~20.1.4",
|
||||
"@angular/compiler-cli": "~20.1.4",
|
||||
"@codecov/webpack-plugin": "^1.9.1",
|
||||
"@playwright/test": "^1.55.0",
|
||||
"@playwright/test": "^1.54.2",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/node": "^24.3.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.41.0",
|
||||
"@typescript-eslint/parser": "^8.41.0",
|
||||
"@typescript-eslint/utils": "^8.41.0",
|
||||
"eslint": "^9.34.0",
|
||||
"jest": "30.1.3",
|
||||
"jest-environment-jsdom": "^30.1.2",
|
||||
"@types/node": "^24.1.0",
|
||||
"@typescript-eslint/eslint-plugin": "^8.38.0",
|
||||
"@typescript-eslint/parser": "^8.38.0",
|
||||
"@typescript-eslint/utils": "^8.38.0",
|
||||
"eslint": "^9.32.0",
|
||||
"jest": "30.0.5",
|
||||
"jest-environment-jsdom": "^30.0.5",
|
||||
"jest-junit": "^16.0.0",
|
||||
"jest-preset-angular": "^15.0.0",
|
||||
"jest-websocket-mock": "^2.5.0",
|
||||
"prettier-plugin-organize-imports": "^4.2.0",
|
||||
"ts-node": "~10.9.1",
|
||||
"typescript": "^5.8.3",
|
||||
"webpack": "^5.101.3"
|
||||
"webpack": "^5.101.0"
|
||||
},
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
|
3633
src-ui/pnpm-lock.yaml
generated
3633
src-ui/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -61,40 +61,6 @@ const groups = [
|
||||
{ id: 2, name: 'group2' },
|
||||
]
|
||||
|
||||
const status: SystemStatus = {
|
||||
pngx_version: '2.4.3',
|
||||
server_os: 'macOS-14.1.1-arm64-arm-64bit',
|
||||
install_type: InstallType.BareMetal,
|
||||
storage: { total: 494384795648, available: 13573525504 },
|
||||
database: {
|
||||
type: 'sqlite',
|
||||
url: '/paperless-ngx/data/db.sqlite3',
|
||||
status: SystemStatusItemStatus.ERROR,
|
||||
error: null,
|
||||
migration_status: {
|
||||
latest_migration: 'socialaccount.0006_alter_socialaccount_extra_data',
|
||||
unapplied_migrations: [],
|
||||
},
|
||||
},
|
||||
tasks: {
|
||||
redis_url: 'redis://localhost:6379',
|
||||
redis_status: SystemStatusItemStatus.ERROR,
|
||||
redis_error: 'Error 61 connecting to localhost:6379. Connection refused.',
|
||||
celery_status: SystemStatusItemStatus.ERROR,
|
||||
celery_url: 'celery@localhost',
|
||||
celery_error: 'Error connecting to celery@localhost',
|
||||
index_status: SystemStatusItemStatus.OK,
|
||||
index_last_modified: new Date().toISOString(),
|
||||
index_error: null,
|
||||
classifier_status: SystemStatusItemStatus.OK,
|
||||
classifier_last_trained: new Date().toISOString(),
|
||||
classifier_error: null,
|
||||
sanity_check_status: SystemStatusItemStatus.ERROR,
|
||||
sanity_check_last_run: new Date().toISOString(),
|
||||
sanity_check_error: 'Error running sanity check.',
|
||||
},
|
||||
}
|
||||
|
||||
describe('SettingsComponent', () => {
|
||||
let component: SettingsComponent
|
||||
let fixture: ComponentFixture<SettingsComponent>
|
||||
@@ -324,6 +290,40 @@ describe('SettingsComponent', () => {
|
||||
})
|
||||
|
||||
it('should load system status on initialize, show errors if needed', () => {
|
||||
const status: SystemStatus = {
|
||||
pngx_version: '2.4.3',
|
||||
server_os: 'macOS-14.1.1-arm64-arm-64bit',
|
||||
install_type: InstallType.BareMetal,
|
||||
storage: { total: 494384795648, available: 13573525504 },
|
||||
database: {
|
||||
type: 'sqlite',
|
||||
url: '/paperless-ngx/data/db.sqlite3',
|
||||
status: SystemStatusItemStatus.ERROR,
|
||||
error: null,
|
||||
migration_status: {
|
||||
latest_migration: 'socialaccount.0006_alter_socialaccount_extra_data',
|
||||
unapplied_migrations: [],
|
||||
},
|
||||
},
|
||||
tasks: {
|
||||
redis_url: 'redis://localhost:6379',
|
||||
redis_status: SystemStatusItemStatus.ERROR,
|
||||
redis_error:
|
||||
'Error 61 connecting to localhost:6379. Connection refused.',
|
||||
celery_status: SystemStatusItemStatus.ERROR,
|
||||
celery_url: 'celery@localhost',
|
||||
celery_error: 'Error connecting to celery@localhost',
|
||||
index_status: SystemStatusItemStatus.OK,
|
||||
index_last_modified: new Date().toISOString(),
|
||||
index_error: null,
|
||||
classifier_status: SystemStatusItemStatus.OK,
|
||||
classifier_last_trained: new Date().toISOString(),
|
||||
classifier_error: null,
|
||||
sanity_check_status: SystemStatusItemStatus.ERROR,
|
||||
sanity_check_last_run: new Date().toISOString(),
|
||||
sanity_check_error: 'Error running sanity check.',
|
||||
},
|
||||
}
|
||||
jest.spyOn(systemStatusService, 'get').mockReturnValue(of(status))
|
||||
jest.spyOn(permissionsService, 'isAdmin').mockReturnValue(true)
|
||||
completeSetup()
|
||||
@@ -340,8 +340,6 @@ describe('SettingsComponent', () => {
|
||||
|
||||
it('should open system status dialog', () => {
|
||||
const modalOpenSpy = jest.spyOn(modalService, 'open')
|
||||
jest.spyOn(systemStatusService, 'get').mockReturnValue(of(status))
|
||||
jest.spyOn(permissionsService, 'isAdmin').mockReturnValue(true)
|
||||
completeSetup()
|
||||
component.showSystemStatus()
|
||||
expect(modalOpenSpy).toHaveBeenCalledWith(SystemStatusDialogComponent, {
|
||||
|
@@ -185,8 +185,7 @@ export class SettingsComponent
|
||||
this.systemStatus.tasks.classifier_status ===
|
||||
SystemStatusItemStatus.ERROR ||
|
||||
this.systemStatus.tasks.sanity_check_status ===
|
||||
SystemStatusItemStatus.ERROR ||
|
||||
this.systemStatus.websocket_connected === SystemStatusItemStatus.ERROR
|
||||
SystemStatusItemStatus.ERROR
|
||||
)
|
||||
}
|
||||
|
||||
|
@@ -35,9 +35,6 @@
|
||||
@case (CustomFieldDataType.Select) {
|
||||
<span [ngbTooltip]="nameTooltip">{{getSelectValue(field, value)}}</span>
|
||||
}
|
||||
@case (CustomFieldDataType.LongText) {
|
||||
<p class="mb-0" [ngbTooltip]="nameTooltip">{{value | slice:0:20}}{{value.length > 20 ? '...' : ''}}</p>
|
||||
}
|
||||
@default {
|
||||
<span [ngbTooltip]="nameTooltip">{{value}}</span>
|
||||
}
|
||||
|
@@ -1,5 +1,5 @@
|
||||
import { CurrencyPipe, getLocaleCurrencyCode, SlicePipe } from '@angular/common'
|
||||
import { Component, inject, Input, LOCALE_ID, OnInit } from '@angular/core'
|
||||
import { CurrencyPipe, getLocaleCurrencyCode } from '@angular/common'
|
||||
import { Component, Input, LOCALE_ID, OnInit, inject } from '@angular/core'
|
||||
import { NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { takeUntil } from 'rxjs'
|
||||
import { CustomField, CustomFieldDataType } from 'src/app/data/custom-field'
|
||||
@@ -14,7 +14,7 @@ import { LoadingComponentWithPermissions } from '../../loading-component/loading
|
||||
selector: 'pngx-custom-field-display',
|
||||
templateUrl: './custom-field-display.component.html',
|
||||
styleUrl: './custom-field-display.component.scss',
|
||||
imports: [CustomDatePipe, CurrencyPipe, NgbTooltipModule, SlicePipe],
|
||||
imports: [CustomDatePipe, CurrencyPipe, NgbTooltipModule],
|
||||
})
|
||||
export class CustomFieldDisplayComponent
|
||||
extends LoadingComponentWithPermissions
|
||||
|
@@ -51,6 +51,7 @@
|
||||
<ng-select #fieldSelects
|
||||
class="paperless-input-select rounded-end"
|
||||
[items]="getSelectOptionsForField(atom.field)"
|
||||
[virtualScroll]="getSelectOptionsForField(atom.field)?.length > 100"
|
||||
bindLabel="label"
|
||||
bindValue="id"
|
||||
[(ngModel)]="atom.value"
|
||||
|
@@ -28,16 +28,6 @@
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@if (allSelectOptions.length > SELECT_OPTION_PAGE_SIZE) {
|
||||
<ngb-pagination
|
||||
class="d-flex justify-content-end"
|
||||
[pageSize]="SELECT_OPTION_PAGE_SIZE"
|
||||
[collectionSize]="allSelectOptions.length"
|
||||
[(page)]="selectOptionsPage"
|
||||
[maxSize]="5"
|
||||
size="sm"
|
||||
></ngb-pagination>
|
||||
}
|
||||
@if (object?.id) {
|
||||
<small class="d-block mt-2" i18n>Warning: existing instances of this field will retain their current value index (e.g. option #1, #2, #3) after editing the options here</small>
|
||||
}
|
||||
|
@@ -125,42 +125,4 @@ describe('CustomFieldEditDialogComponent', () => {
|
||||
fixture.detectChanges()
|
||||
expect(document.activeElement).toBe(selectOptionInputs.last.nativeElement)
|
||||
})
|
||||
|
||||
it('should send all select options including those changed in form on save', () => {
|
||||
component.dialogMode = EditDialogMode.EDIT
|
||||
component.object = {
|
||||
id: 1,
|
||||
name: 'Field 1',
|
||||
data_type: CustomFieldDataType.Select,
|
||||
extra_data: {
|
||||
select_options: Array.from({ length: 50 }, (_, i) => ({
|
||||
label: `Option ${i + 1}`,
|
||||
id: `${i + 1}-xyz`,
|
||||
})),
|
||||
},
|
||||
}
|
||||
fixture.detectChanges()
|
||||
component.ngOnInit()
|
||||
component.selectOptionsPage = 2
|
||||
fixture.detectChanges()
|
||||
component.objectForm
|
||||
.get('extra_data')
|
||||
.get('select_options')
|
||||
.get('0')
|
||||
.get('label')
|
||||
.setValue('Updated Option 9')
|
||||
const formValues = (component as any).getFormValues()
|
||||
// first item unchanged
|
||||
expect(formValues.extra_data.select_options[0]).toEqual({
|
||||
label: 'Option 1',
|
||||
id: '1-xyz',
|
||||
})
|
||||
// page 2 first item updated
|
||||
expect(
|
||||
formValues.extra_data.select_options[component.SELECT_OPTION_PAGE_SIZE]
|
||||
).toEqual({
|
||||
label: 'Updated Option 9',
|
||||
id: '9-xyz',
|
||||
})
|
||||
})
|
||||
})
|
||||
|
@@ -14,7 +14,6 @@ import {
|
||||
FormsModule,
|
||||
ReactiveFormsModule,
|
||||
} from '@angular/forms'
|
||||
import { NgbPaginationModule } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||
import { takeUntil } from 'rxjs'
|
||||
import {
|
||||
@@ -29,8 +28,6 @@ import { SelectComponent } from '../../input/select/select.component'
|
||||
import { TextComponent } from '../../input/text/text.component'
|
||||
import { EditDialogComponent, EditDialogMode } from '../edit-dialog.component'
|
||||
|
||||
const SELECT_OPTION_PAGE_SIZE = 8
|
||||
|
||||
@Component({
|
||||
selector: 'pngx-custom-field-edit-dialog',
|
||||
templateUrl: './custom-field-edit-dialog.component.html',
|
||||
@@ -40,7 +37,6 @@ const SELECT_OPTION_PAGE_SIZE = 8
|
||||
TextComponent,
|
||||
FormsModule,
|
||||
ReactiveFormsModule,
|
||||
NgbPaginationModule,
|
||||
NgxBootstrapIconsModule,
|
||||
],
|
||||
})
|
||||
@@ -49,21 +45,6 @@ export class CustomFieldEditDialogComponent
|
||||
implements OnInit, AfterViewInit
|
||||
{
|
||||
CustomFieldDataType = CustomFieldDataType
|
||||
SELECT_OPTION_PAGE_SIZE = SELECT_OPTION_PAGE_SIZE
|
||||
|
||||
private _allSelectOptions: any[] = []
|
||||
public get allSelectOptions(): any[] {
|
||||
return this._allSelectOptions
|
||||
}
|
||||
|
||||
private _selectOptionsPage: number
|
||||
public get selectOptionsPage(): number {
|
||||
return this._selectOptionsPage
|
||||
}
|
||||
public set selectOptionsPage(v: number) {
|
||||
this._selectOptionsPage = v
|
||||
this.updateSelectOptions()
|
||||
}
|
||||
|
||||
@ViewChildren('selectOption')
|
||||
private selectOptionInputs: QueryList<ElementRef>
|
||||
@@ -86,10 +67,17 @@ export class CustomFieldEditDialogComponent
|
||||
this.objectForm.get('data_type').disable()
|
||||
}
|
||||
if (this.object?.data_type === CustomFieldDataType.Select) {
|
||||
this._allSelectOptions = [
|
||||
...(this.object.extra_data.select_options ?? []),
|
||||
]
|
||||
this.selectOptionsPage = 1
|
||||
this.selectOptions.clear()
|
||||
this.object.extra_data.select_options
|
||||
.filter((option) => option)
|
||||
.forEach((option) =>
|
||||
this.selectOptions.push(
|
||||
new FormGroup({
|
||||
label: new FormControl(option.label),
|
||||
id: new FormControl(option.id),
|
||||
})
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -99,19 +87,6 @@ export class CustomFieldEditDialogComponent
|
||||
.subscribe(() => {
|
||||
this.selectOptionInputs.last?.nativeElement.focus()
|
||||
})
|
||||
|
||||
this.objectForm.valueChanges
|
||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe((change) => {
|
||||
// Update the relevant select options values if changed in the form, which is only a page of the entire list
|
||||
this.objectForm
|
||||
.get('extra_data.select_options')
|
||||
?.value.forEach((option, index) => {
|
||||
this._allSelectOptions[
|
||||
index + (this.selectOptionsPage - 1) * SELECT_OPTION_PAGE_SIZE
|
||||
] = option
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
getCreateTitle() {
|
||||
@@ -133,17 +108,6 @@ export class CustomFieldEditDialogComponent
|
||||
})
|
||||
}
|
||||
|
||||
protected getFormValues() {
|
||||
const formValues = super.getFormValues()
|
||||
if (
|
||||
this.objectForm.get('data_type')?.value === CustomFieldDataType.Select
|
||||
) {
|
||||
// Make sure we send all select options, with updated values
|
||||
formValues.extra_data.select_options = this._allSelectOptions
|
||||
}
|
||||
return formValues
|
||||
}
|
||||
|
||||
getDataTypes() {
|
||||
return DATA_TYPE_LABELS
|
||||
}
|
||||
@@ -152,41 +116,13 @@ export class CustomFieldEditDialogComponent
|
||||
return this.dialogMode === EditDialogMode.EDIT
|
||||
}
|
||||
|
||||
private updateSelectOptions() {
|
||||
this.selectOptions.clear()
|
||||
this._allSelectOptions
|
||||
.slice(
|
||||
(this.selectOptionsPage - 1) * SELECT_OPTION_PAGE_SIZE,
|
||||
this.selectOptionsPage * SELECT_OPTION_PAGE_SIZE
|
||||
)
|
||||
.forEach((option) =>
|
||||
this.selectOptions.push(
|
||||
new FormGroup({
|
||||
label: new FormControl(option.label),
|
||||
id: new FormControl(option.id),
|
||||
})
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
public addSelectOption() {
|
||||
this._allSelectOptions.push({ label: null, id: null })
|
||||
this.selectOptionsPage = Math.ceil(
|
||||
this.allSelectOptions.length / SELECT_OPTION_PAGE_SIZE
|
||||
this.selectOptions.push(
|
||||
new FormGroup({ label: new FormControl(null), id: new FormControl(null) })
|
||||
)
|
||||
}
|
||||
|
||||
public removeSelectOption(index: number) {
|
||||
const globalIndex =
|
||||
index + (this.selectOptionsPage - 1) * SELECT_OPTION_PAGE_SIZE
|
||||
this._allSelectOptions.splice(globalIndex, 1)
|
||||
|
||||
const totalPages = Math.max(
|
||||
1,
|
||||
Math.ceil(this._allSelectOptions.length / SELECT_OPTION_PAGE_SIZE)
|
||||
)
|
||||
const targetPage = Math.min(this.selectOptionsPage, totalPages)
|
||||
|
||||
this.selectOptionsPage = targetPage
|
||||
this.selectOptions.removeAt(index)
|
||||
}
|
||||
}
|
||||
|
@@ -147,13 +147,9 @@ export abstract class EditDialogComponent<
|
||||
)
|
||||
}
|
||||
|
||||
protected getFormValues(): any {
|
||||
return Object.assign({}, this.objectForm.value)
|
||||
}
|
||||
|
||||
save() {
|
||||
this.error = null
|
||||
const formValues = this.getFormValues()
|
||||
const formValues = Object.assign({}, this.objectForm.value)
|
||||
const permissionsObject: PermissionsFormObject =
|
||||
this.objectForm.get('permissions_form')?.value
|
||||
if (permissionsObject) {
|
||||
|
@@ -12,8 +12,6 @@
|
||||
|
||||
<pngx-input-color i18n-title title="Color" formControlName="color" [error]="error?.color"></pngx-input-color>
|
||||
|
||||
<pngx-input-select i18n-title title="Parent" formControlName="parent" [items]="tags" [allowNull]="true" [error]="error?.parent"></pngx-input-select>
|
||||
|
||||
<pngx-input-check i18n-title title="Inbox tag" formControlName="is_inbox_tag" i18n-hint hint="Inbox tags are automatically assigned to all consumed documents."></pngx-input-check>
|
||||
<pngx-input-select i18n-title title="Matching algorithm" [items]="getMatchingAlgorithms()" formControlName="matching_algorithm"></pngx-input-select>
|
||||
@if (patternRequired) {
|
||||
|
@@ -35,16 +35,11 @@ import { TextComponent } from '../../input/text/text.component'
|
||||
],
|
||||
})
|
||||
export class TagEditDialogComponent extends EditDialogComponent<Tag> {
|
||||
tags: Tag[]
|
||||
|
||||
constructor() {
|
||||
super()
|
||||
this.service = inject(TagService)
|
||||
this.userService = inject(UserService)
|
||||
this.settingsService = inject(SettingsService)
|
||||
this.service.listAll().subscribe((result) => {
|
||||
this.tags = result.results
|
||||
})
|
||||
}
|
||||
|
||||
getCreateTitle() {
|
||||
@@ -60,7 +55,6 @@ export class TagEditDialogComponent extends EditDialogComponent<Tag> {
|
||||
name: new FormControl(''),
|
||||
color: new FormControl(randomColor()),
|
||||
is_inbox_tag: new FormControl(false),
|
||||
parent: new FormControl(null),
|
||||
matching_algorithm: new FormControl(DEFAULT_MATCHING_ALGORITHM),
|
||||
match: new FormControl(''),
|
||||
is_insensitive: new FormControl(true),
|
||||
|
@@ -177,7 +177,6 @@
|
||||
<pngx-input-tags [allowCreate]="false" i18n-title title="Has any of tags" formControlName="filter_has_tags"></pngx-input-tags>
|
||||
<pngx-input-select i18n-title title="Has correspondent" [items]="correspondents" [allowNull]="true" formControlName="filter_has_correspondent"></pngx-input-select>
|
||||
<pngx-input-select i18n-title title="Has document type" [items]="documentTypes" [allowNull]="true" formControlName="filter_has_document_type"></pngx-input-select>
|
||||
<pngx-input-select i18n-title title="Has storage path" [items]="storagePaths" [allowNull]="true" formControlName="filter_has_storage_path"></pngx-input-select>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
@@ -412,9 +412,6 @@ export class WorkflowEditDialogComponent
|
||||
filter_has_document_type: new FormControl(
|
||||
trigger.filter_has_document_type
|
||||
),
|
||||
filter_has_storage_path: new FormControl(
|
||||
trigger.filter_has_storage_path
|
||||
),
|
||||
schedule_offset_days: new FormControl(trigger.schedule_offset_days),
|
||||
schedule_is_recurring: new FormControl(trigger.schedule_is_recurring),
|
||||
schedule_recurring_interval_days: new FormControl(
|
||||
@@ -539,7 +536,6 @@ export class WorkflowEditDialogComponent
|
||||
filter_has_tags: [],
|
||||
filter_has_correspondent: null,
|
||||
filter_has_document_type: null,
|
||||
filter_has_storage_path: null,
|
||||
matching_algorithm: MATCH_NONE,
|
||||
match: '',
|
||||
is_insensitive: true,
|
||||
|
@@ -114,13 +114,6 @@ export class FilterableDropdownSelectionModel {
|
||||
b.id == NEGATIVE_NULL_FILTER_VALUE)
|
||||
) {
|
||||
return 1
|
||||
}
|
||||
|
||||
// Preserve hierarchical order when provided (e.g., Tags)
|
||||
const ao = (a as any)['orderIndex']
|
||||
const bo = (b as any)['orderIndex']
|
||||
if (ao !== undefined && bo !== undefined) {
|
||||
return ao - bo
|
||||
} else if (
|
||||
this.getNonTemporary(a.id) == ToggleableItemState.NotSelected &&
|
||||
this.getNonTemporary(b.id) != ToggleableItemState.NotSelected
|
||||
|
@@ -15,17 +15,12 @@
|
||||
<i-bs width="1em" height="1em" name="x"></i-bs>
|
||||
}
|
||||
</div>
|
||||
<div class="me-1 name-cell" [style.--depth]="isTag ? getDepth() + 1 : 1">
|
||||
@if (isTag && getDepth() > 0) {
|
||||
<div class="indicator"></div>
|
||||
<div class="me-1">
|
||||
@if (isTag) {
|
||||
<pngx-tag [tag]="item" [clickable]="false"></pngx-tag>
|
||||
} @else {
|
||||
<small>{{item.name}}</small>
|
||||
}
|
||||
<div>
|
||||
@if (isTag) {
|
||||
<pngx-tag [tag]="item" [clickable]="false"></pngx-tag>
|
||||
} @else {
|
||||
<small>{{item.name}}</small>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@if (!hideCount) {
|
||||
<div class="badge bg-light text-dark rounded-pill ms-auto me-1">{{currentCount}}</div>
|
||||
|
@@ -2,19 +2,3 @@
|
||||
min-width: 1em;
|
||||
min-height: 1em;
|
||||
}
|
||||
|
||||
.name-cell {
|
||||
padding-left: calc(calc(var(--depth) - 2) * 1rem);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
.indicator {
|
||||
display: inline-block;
|
||||
width: .8rem;
|
||||
height: .8rem;
|
||||
border-left: 1px solid var(--bs-secondary);
|
||||
border-bottom: 1px solid var(--bs-secondary);
|
||||
margin-right: .25rem;
|
||||
margin-left: .5rem;
|
||||
}
|
||||
}
|
||||
|
@@ -1,7 +1,6 @@
|
||||
import { Component, EventEmitter, Input, Output } from '@angular/core'
|
||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||
import { MatchingModel } from 'src/app/data/matching-model'
|
||||
import { Tag } from 'src/app/data/tag'
|
||||
import { TagComponent } from '../../tag/tag.component'
|
||||
|
||||
export enum ToggleableItemState {
|
||||
@@ -46,10 +45,6 @@ export class ToggleableDropdownButtonComponent {
|
||||
return 'is_inbox_tag' in this.item
|
||||
}
|
||||
|
||||
getDepth(): number {
|
||||
return (this.item as Tag).depth ?? 0
|
||||
}
|
||||
|
||||
get currentCount(): number {
|
||||
return this.count ?? this.item.document_count
|
||||
}
|
||||
|
@@ -68,11 +68,6 @@
|
||||
[allowNull]="true"
|
||||
[horizontal]="true"></pngx-input-select>
|
||||
}
|
||||
@case (CustomFieldDataType.LongText) {
|
||||
<pngx-input-textarea [(ngModel)]="value[fieldId]" (ngModelChange)="onChange(value)"
|
||||
[title]="getCustomField(fieldId)?.name"
|
||||
class="flex-grow-1"></pngx-input-textarea>
|
||||
}
|
||||
}
|
||||
<button type="button" class="btn btn-link text-danger" (click)="removeSelectedField.next(fieldId)">
|
||||
<i-bs name="trash"></i-bs>
|
||||
|
@@ -24,7 +24,6 @@ import { MonetaryComponent } from '../monetary/monetary.component'
|
||||
import { NumberComponent } from '../number/number.component'
|
||||
import { SelectComponent } from '../select/select.component'
|
||||
import { TextComponent } from '../text/text.component'
|
||||
import { TextAreaComponent } from '../textarea/textarea.component'
|
||||
import { UrlComponent } from '../url/url.component'
|
||||
|
||||
@Component({
|
||||
@@ -52,7 +51,6 @@ import { UrlComponent } from '../url/url.component'
|
||||
ReactiveFormsModule,
|
||||
RouterModule,
|
||||
NgxBootstrapIconsModule,
|
||||
TextAreaComponent,
|
||||
],
|
||||
})
|
||||
export class CustomFieldsValuesComponent extends AbstractInputComponent<Object> {
|
||||
|
@@ -19,6 +19,7 @@
|
||||
[class.private]="isPrivate"
|
||||
[clearable]="allowNull"
|
||||
[items]="items"
|
||||
[virtualScroll]="items?.length > 100"
|
||||
[addTag]="allowCreateNew && addItemRef"
|
||||
addTagText="Add item"
|
||||
i18n-addTagText="Used for both types, correspondents, storage paths"
|
||||
|
@@ -7,14 +7,13 @@
|
||||
<div class="input-group flex-nowrap">
|
||||
<ng-select #tagSelect name="tags" [items]="tags" bindLabel="name" bindValue="id" [(ngModel)]="value"
|
||||
[disabled]="disabled"
|
||||
[multiple]="multiple"
|
||||
[multiple]="true"
|
||||
[closeOnSelect]="false"
|
||||
[clearSearchOnAdd]="true"
|
||||
[hideSelected]="tags.length > 0"
|
||||
[addTag]="allowCreate ? createTagRef : false"
|
||||
addTagText="Add tag"
|
||||
i18n-addTagText
|
||||
(add)="onAdd($event)"
|
||||
(change)="onChange(value)">
|
||||
|
||||
<ng-template ng-label-tmp let-item="item">
|
||||
@@ -26,20 +25,9 @@
|
||||
</button>
|
||||
</ng-template>
|
||||
<ng-template ng-option-tmp let-item="item" let-index="index" let-search="searchTerm">
|
||||
<div class="tag-option-row d-flex align-items-center">
|
||||
<div class="tag-wrap">
|
||||
@if (item.id && tags) {
|
||||
@if (getTag(item.id)?.parent) {
|
||||
<i-bs name="list-nested" class="me-1"></i-bs>
|
||||
<span class="hierarchy-reveal d-flex align-items-center">
|
||||
<span class="parents d-flex align-items-center">
|
||||
@for (p of getParentChain(item.id); track p.id) {
|
||||
<span class="badge me-1" [style.background]="p.color" [style.color]="p.text_color">{{p.name}}</span>
|
||||
<i-bs name="chevron-right" width=".8em" height=".8em" class="me-1"></i-bs>
|
||||
}
|
||||
</span>
|
||||
</span>
|
||||
}
|
||||
<pngx-tag class="current-tag d-flex" [tag]="getTag(item.id)"></pngx-tag>
|
||||
<pngx-tag class="me-2" [tag]="getTag(item.id)"></pngx-tag>
|
||||
}
|
||||
</div>
|
||||
</ng-template>
|
||||
|
@@ -20,33 +20,3 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Dropdown hierarchy reveal for ng-select options
|
||||
::ng-deep .ng-dropdown-panel .ng-option {
|
||||
overflow-x: scroll;
|
||||
|
||||
.tag-option-row {
|
||||
font-size: 1rem;
|
||||
width: max-content;
|
||||
}
|
||||
|
||||
.hierarchy-reveal {
|
||||
overflow: hidden;
|
||||
max-width: 0;
|
||||
transition: max-width 200ms ease;
|
||||
}
|
||||
|
||||
.parents .badge {
|
||||
white-space: nowrap;
|
||||
}
|
||||
}
|
||||
|
||||
::ng-deep .ng-dropdown-panel .ng-option:hover .hierarchy-reveal,
|
||||
::ng-deep .ng-dropdown-panel .ng-option.ng-option-marked .hierarchy-reveal {
|
||||
max-width: 1000px;
|
||||
}
|
||||
|
||||
::ng-deep .ng-dropdown-panel .ng-option:hover .hierarchy-indicator,
|
||||
::ng-deep .ng-dropdown-panel .ng-option.ng-option-marked .hierarchy-indicator {
|
||||
background: transparent;
|
||||
}
|
||||
|
@@ -177,59 +177,4 @@ describe('TagsComponent', () => {
|
||||
component.onFilterDocuments()
|
||||
expect(emitSpy).toHaveBeenCalledWith([tags[2]])
|
||||
})
|
||||
|
||||
it('should remove all descendants from selection', () => {
|
||||
const c: Tag = { id: 4, name: 'c' }
|
||||
const b: Tag = { id: 3, name: 'b', children: [c] }
|
||||
const a: Tag = { id: 2, name: 'a' }
|
||||
const root: Tag = { id: 1, name: 'root', children: [a, b] }
|
||||
|
||||
const inputIDs = [2, 3, 4, 99]
|
||||
const result = (component as any).removeChildren(inputIDs, root)
|
||||
expect(result).toEqual([99])
|
||||
})
|
||||
|
||||
it('should append all parents recursively', () => {
|
||||
const root: Tag = { id: 1, name: 'root' }
|
||||
const mid: Tag = { id: 2, name: 'mid', parent: 1 }
|
||||
const leaf: Tag = { id: 3, name: 'leaf', parent: 2 }
|
||||
component.tags = [root, mid, leaf]
|
||||
|
||||
component.value = []
|
||||
component.onAdd(leaf)
|
||||
expect(component.value).toEqual([2, 1])
|
||||
|
||||
// Calling onAdd on a root should not change value
|
||||
component.onAdd(root)
|
||||
expect(component.value).toEqual([2, 1])
|
||||
})
|
||||
|
||||
it('should return ancestors from root to parent using getParentChain', () => {
|
||||
const root: Tag = { id: 1, name: 'root' }
|
||||
const mid: Tag = { id: 2, name: 'mid', parent: 1 }
|
||||
const leaf: Tag = { id: 3, name: 'leaf', parent: 2 }
|
||||
component.tags = [root, mid, leaf]
|
||||
|
||||
expect(component.getParentChain(3).map((t) => t.id)).toEqual([1, 2])
|
||||
expect(component.getParentChain(2).map((t) => t.id)).toEqual([1])
|
||||
expect(component.getParentChain(1).map((t) => t.id)).toEqual([])
|
||||
// Non-existent id
|
||||
expect(component.getParentChain(999).map((t) => t.id)).toEqual([])
|
||||
})
|
||||
|
||||
it('should handle cyclic parents via guard in getParentChain', () => {
|
||||
const one: Tag = { id: 1, name: 'one', parent: 2 }
|
||||
const two: Tag = { id: 2, name: 'two', parent: 1 }
|
||||
component.tags = [one, two]
|
||||
|
||||
const chain = component.getParentChain(1)
|
||||
// Guard avoids infinite loop; chain contains both nodes once
|
||||
expect(chain.map((t) => t.id)).toEqual([1, 2])
|
||||
})
|
||||
|
||||
it('should stop when parent does not exist in getParentChain', () => {
|
||||
const lone: Tag = { id: 5, name: 'lone', parent: 999 }
|
||||
component.tags = [lone]
|
||||
expect(component.getParentChain(5)).toEqual([])
|
||||
})
|
||||
})
|
||||
|
@@ -100,9 +100,6 @@ export class TagsComponent implements OnInit, ControlValueAccessor {
|
||||
@Input()
|
||||
horizontal: boolean = false
|
||||
|
||||
@Input()
|
||||
multiple: boolean = true
|
||||
|
||||
@Output()
|
||||
filterDocuments = new EventEmitter<Tag[]>()
|
||||
|
||||
@@ -127,40 +124,13 @@ export class TagsComponent implements OnInit, ControlValueAccessor {
|
||||
|
||||
let index = this.value.indexOf(tagID)
|
||||
if (index > -1) {
|
||||
const tag = this.getTag(tagID)
|
||||
|
||||
// remove tag
|
||||
let oldValue = this.value
|
||||
oldValue.splice(index, 1)
|
||||
|
||||
// remove children
|
||||
oldValue = this.removeChildren(oldValue, tag)
|
||||
|
||||
this.value = [...oldValue]
|
||||
this.onChange(this.value)
|
||||
}
|
||||
}
|
||||
|
||||
private removeChildren(tagIDs: number[], tag: Tag) {
|
||||
if (tag.children?.length) {
|
||||
const childIDs = tag.children.map((child) => child.id)
|
||||
tagIDs = tagIDs.filter((id) => !childIDs.includes(id))
|
||||
for (const child of tag.children) {
|
||||
tagIDs = this.removeChildren(tagIDs, child)
|
||||
}
|
||||
}
|
||||
return tagIDs
|
||||
}
|
||||
|
||||
public onAdd(tag: Tag) {
|
||||
if (tag.parent) {
|
||||
// add all parents recursively
|
||||
const parent = this.getTag(tag.parent)
|
||||
this.value = [...this.value, parent.id]
|
||||
this.onAdd(parent)
|
||||
}
|
||||
}
|
||||
|
||||
createTag(name: string = null, add: boolean = false) {
|
||||
var modal = this.modalService.open(TagEditDialogComponent, {
|
||||
backdrop: 'static',
|
||||
@@ -196,7 +166,6 @@ export class TagsComponent implements OnInit, ControlValueAccessor {
|
||||
|
||||
addTag(id) {
|
||||
this.value = [...this.value, id]
|
||||
this.onAdd(this.getTag(id))
|
||||
this.onChange(this.value)
|
||||
}
|
||||
|
||||
@@ -211,20 +180,4 @@ export class TagsComponent implements OnInit, ControlValueAccessor {
|
||||
this.tags.filter((t) => this.value.includes(t.id))
|
||||
)
|
||||
}
|
||||
|
||||
getParentChain(id: number): Tag[] {
|
||||
// Returns ancestors from root → immediate parent for a tag id
|
||||
const chain: Tag[] = []
|
||||
let current = this.getTag(id)
|
||||
const guard = new Set<number>()
|
||||
while (current?.parent) {
|
||||
if (guard.has(current.parent)) break
|
||||
guard.add(current.parent)
|
||||
const parent = this.getTag(current.parent)
|
||||
if (!parent) break
|
||||
chain.unshift(parent)
|
||||
current = parent
|
||||
}
|
||||
return chain
|
||||
}
|
||||
}
|
||||
|
@@ -4,7 +4,6 @@ import {
|
||||
NG_VALUE_ACCESSOR,
|
||||
ReactiveFormsModule,
|
||||
} from '@angular/forms'
|
||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||
import { SafeHtmlPipe } from 'src/app/pipes/safehtml.pipe'
|
||||
import { AbstractInputComponent } from '../abstract-input'
|
||||
|
||||
@@ -19,12 +18,7 @@ import { AbstractInputComponent } from '../abstract-input'
|
||||
selector: 'pngx-input-textarea',
|
||||
templateUrl: './textarea.component.html',
|
||||
styleUrls: ['./textarea.component.scss'],
|
||||
imports: [
|
||||
FormsModule,
|
||||
ReactiveFormsModule,
|
||||
SafeHtmlPipe,
|
||||
NgxBootstrapIconsModule,
|
||||
],
|
||||
imports: [FormsModule, ReactiveFormsModule, SafeHtmlPipe],
|
||||
})
|
||||
export class TextAreaComponent extends AbstractInputComponent<string> {
|
||||
@Input()
|
||||
|
@@ -30,7 +30,7 @@
|
||||
<div class="page-item rounded p-2" cdkDrag (click)="toggleSelection(i)" [class.selected]="p.selected">
|
||||
<div class="btn-toolbar hover-actions z-10">
|
||||
<div class="btn-group me-2">
|
||||
<button class="btn btn-sm btn-dark" (click)="rotate(i, true); $event.stopPropagation()" title="Rotate page counter-clockwise" i18n-title>
|
||||
<button class="btn btn-sm btn-dark" (click)="rotate(i); $event.stopPropagation()" title="Rotate page counter-clockwise" i18n-title>
|
||||
<i-bs name="arrow-counterclockwise"></i-bs>
|
||||
</button>
|
||||
<button class="btn btn-sm btn-dark" (click)="rotate(i); $event.stopPropagation()" title="Rotate page clockwise" i18n-title>
|
||||
|
@@ -67,9 +67,8 @@ export class PDFEditorComponent extends ConfirmDialogComponent {
|
||||
this.pages[i].selected = !this.pages[i].selected
|
||||
}
|
||||
|
||||
rotate(i: number, counterclockwise: boolean = false) {
|
||||
this.pages[i].rotate =
|
||||
(this.pages[i].rotate + (counterclockwise ? -90 : 90) + 360) % 360
|
||||
rotate(i: number) {
|
||||
this.pages[i].rotate = (this.pages[i].rotate + 90) % 360
|
||||
}
|
||||
|
||||
rotateSelected(dir: number) {
|
||||
|
@@ -254,18 +254,6 @@
|
||||
<h6><ng-container i18n>Error</ng-container>:</h6> <span class="font-monospace small">{{status.tasks.sanity_check_error}}</span>
|
||||
}
|
||||
</ng-template>
|
||||
<dt i18n>WebSocket Connection</dt>
|
||||
<dd>
|
||||
<span class="btn btn-sm pe-none align-items-center btn-dark text-uppercase small">
|
||||
@if (status.websocket_connected === 'OK') {
|
||||
<ng-container i18n>OK</ng-container>
|
||||
<i-bs name="check-circle-fill" class="text-primary ms-2 lh-1"></i-bs>
|
||||
} @else {
|
||||
<ng-container i18n>Error</ng-container>
|
||||
<i-bs name="exclamation-triangle-fill" class="text-danger ms-2 lh-1"></i-bs>
|
||||
}
|
||||
</span>
|
||||
</dd>
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
|
@@ -24,7 +24,7 @@ import {
|
||||
} from '@angular/core/testing'
|
||||
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
||||
import { Subject, of, throwError } from 'rxjs'
|
||||
import { of, throwError } from 'rxjs'
|
||||
import { PaperlessTaskName } from 'src/app/data/paperless-task'
|
||||
import {
|
||||
InstallType,
|
||||
@@ -34,7 +34,6 @@ import {
|
||||
import { SystemStatusService } from 'src/app/services/system-status.service'
|
||||
import { TasksService } from 'src/app/services/tasks.service'
|
||||
import { ToastService } from 'src/app/services/toast.service'
|
||||
import { WebsocketStatusService } from 'src/app/services/websocket-status.service'
|
||||
import { SystemStatusDialogComponent } from './system-status-dialog.component'
|
||||
|
||||
const status: SystemStatus = {
|
||||
@@ -78,8 +77,6 @@ describe('SystemStatusDialogComponent', () => {
|
||||
let tasksService: TasksService
|
||||
let systemStatusService: SystemStatusService
|
||||
let toastService: ToastService
|
||||
let websocketStatusService: WebsocketStatusService
|
||||
let websocketSubject: Subject<boolean> = new Subject<boolean>()
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
@@ -101,12 +98,6 @@ describe('SystemStatusDialogComponent', () => {
|
||||
tasksService = TestBed.inject(TasksService)
|
||||
systemStatusService = TestBed.inject(SystemStatusService)
|
||||
toastService = TestBed.inject(ToastService)
|
||||
websocketStatusService = TestBed.inject(WebsocketStatusService)
|
||||
jest
|
||||
.spyOn(websocketStatusService, 'onConnectionStatus')
|
||||
.mockImplementation(() => {
|
||||
return websocketSubject.asObservable()
|
||||
})
|
||||
fixture.detectChanges()
|
||||
})
|
||||
|
||||
@@ -177,19 +168,4 @@ describe('SystemStatusDialogComponent', () => {
|
||||
component.ngOnInit()
|
||||
expect(component.versionMismatch).toBeFalsy()
|
||||
})
|
||||
|
||||
it('should update websocket connection status', () => {
|
||||
websocketSubject.next(true)
|
||||
expect(component.status.websocket_connected).toEqual(
|
||||
SystemStatusItemStatus.OK
|
||||
)
|
||||
websocketSubject.next(false)
|
||||
expect(component.status.websocket_connected).toEqual(
|
||||
SystemStatusItemStatus.ERROR
|
||||
)
|
||||
websocketSubject.next(true)
|
||||
expect(component.status.websocket_connected).toEqual(
|
||||
SystemStatusItemStatus.OK
|
||||
)
|
||||
})
|
||||
})
|
||||
|
@@ -1,5 +1,5 @@
|
||||
import { Clipboard, ClipboardModule } from '@angular/cdk/clipboard'
|
||||
import { Component, OnDestroy, OnInit, inject } from '@angular/core'
|
||||
import { Component, OnInit, inject } from '@angular/core'
|
||||
import {
|
||||
NgbActiveModal,
|
||||
NgbModalModule,
|
||||
@@ -7,7 +7,6 @@ import {
|
||||
NgbProgressbarModule,
|
||||
} from '@ng-bootstrap/ng-bootstrap'
|
||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||
import { Subject, takeUntil } from 'rxjs'
|
||||
import { PaperlessTaskName } from 'src/app/data/paperless-task'
|
||||
import {
|
||||
SystemStatus,
|
||||
@@ -19,7 +18,6 @@ import { PermissionsService } from 'src/app/services/permissions.service'
|
||||
import { SystemStatusService } from 'src/app/services/system-status.service'
|
||||
import { TasksService } from 'src/app/services/tasks.service'
|
||||
import { ToastService } from 'src/app/services/toast.service'
|
||||
import { WebsocketStatusService } from 'src/app/services/websocket-status.service'
|
||||
import { environment } from 'src/environments/environment'
|
||||
|
||||
@Component({
|
||||
@@ -36,14 +34,13 @@ import { environment } from 'src/environments/environment'
|
||||
NgxBootstrapIconsModule,
|
||||
],
|
||||
})
|
||||
export class SystemStatusDialogComponent implements OnInit, OnDestroy {
|
||||
export class SystemStatusDialogComponent implements OnInit {
|
||||
activeModal = inject(NgbActiveModal)
|
||||
private clipboard = inject(Clipboard)
|
||||
private systemStatusService = inject(SystemStatusService)
|
||||
private tasksService = inject(TasksService)
|
||||
private toastService = inject(ToastService)
|
||||
private permissionsService = inject(PermissionsService)
|
||||
private websocketStatusService = inject(WebsocketStatusService)
|
||||
|
||||
public SystemStatusItemStatus = SystemStatusItemStatus
|
||||
public PaperlessTaskName = PaperlessTaskName
|
||||
@@ -54,7 +51,6 @@ export class SystemStatusDialogComponent implements OnInit, OnDestroy {
|
||||
public copied: boolean = false
|
||||
|
||||
private runningTasks: Set<PaperlessTaskName> = new Set()
|
||||
private unsubscribeNotifier: Subject<any> = new Subject()
|
||||
|
||||
get currentUserIsSuperUser(): boolean {
|
||||
return this.permissionsService.isSuperUser()
|
||||
@@ -69,17 +65,6 @@ export class SystemStatusDialogComponent implements OnInit, OnDestroy {
|
||||
if (this.versionMismatch) {
|
||||
this.status.pngx_version = `${this.status.pngx_version} (frontend: ${this.frontendVersion})`
|
||||
}
|
||||
this.status.websocket_connected = this.websocketStatusService.isConnected()
|
||||
? SystemStatusItemStatus.OK
|
||||
: SystemStatusItemStatus.ERROR
|
||||
this.websocketStatusService
|
||||
.onConnectionStatus()
|
||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe((connected) => {
|
||||
this.status.websocket_connected = connected
|
||||
? SystemStatusItemStatus.OK
|
||||
: SystemStatusItemStatus.ERROR
|
||||
})
|
||||
}
|
||||
|
||||
public close() {
|
||||
@@ -112,7 +97,7 @@ export class SystemStatusDialogComponent implements OnInit, OnDestroy {
|
||||
this.runningTasks.delete(taskName)
|
||||
this.systemStatusService.get().subscribe({
|
||||
next: (status) => {
|
||||
Object.assign(this.status, status)
|
||||
this.status = status
|
||||
},
|
||||
})
|
||||
},
|
||||
@@ -125,9 +110,4 @@ export class SystemStatusDialogComponent implements OnInit, OnDestroy {
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.unsubscribeNotifier.next(this)
|
||||
this.unsubscribeNotifier.complete()
|
||||
}
|
||||
}
|
||||
|
@@ -1,8 +1,4 @@
|
||||
@if (tag) {
|
||||
@if (showParents && tag.parent) {
|
||||
<pngx-tag [tagID]="tag.parent" [clickable]="clickable" [linkTitle]="linkTitle"></pngx-tag>
|
||||
>
|
||||
}
|
||||
@if (!clickable) {
|
||||
<span class="badge" [style.background]="tag.color" [style.color]="tag.text_color">{{tag.name}}</span>
|
||||
}
|
||||
|
@@ -50,7 +50,4 @@ export class TagComponent {
|
||||
|
||||
@Input()
|
||||
clickable: boolean = false
|
||||
|
||||
@Input()
|
||||
showParents: boolean = false
|
||||
}
|
||||
|
@@ -17,7 +17,7 @@
|
||||
<i-bs width="0.9em" height="0.9em" name="exclamation-triangle"></i-bs>
|
||||
}
|
||||
<div>
|
||||
<p class="ms-2 mb-0 text-break">{{toast.content}}</p>
|
||||
<p class="ms-2 mb-0">{{toast.content}}</p>
|
||||
@if (toast.error) {
|
||||
<details class="ms-2">
|
||||
<div class="mt-2 ms-n4 me-n2 small">
|
||||
|
@@ -54,10 +54,6 @@
|
||||
<i-bs width="1em" height="1em" name="arrow-counterclockwise"></i-bs> <span i18n>Reprocess</span>
|
||||
</button>
|
||||
|
||||
<button ngbDropdownItem (click)="printDocument()" [hidden]="useNativePdfViewer || isMobile">
|
||||
<i-bs width="1em" height="1em" name="printer"></i-bs> <span i18n>Print</span>
|
||||
</button>
|
||||
|
||||
<button ngbDropdownItem (click)="moreLike()">
|
||||
<i-bs width="1em" height="1em" name="diagram-3"></i-bs> <span i18n>More like this</span>
|
||||
</button>
|
||||
@@ -216,14 +212,6 @@
|
||||
(removed)="removeField(fieldInstance)"
|
||||
[error]="getCustomFieldError(i)"></pngx-input-select>
|
||||
}
|
||||
@case (CustomFieldDataType.LongText) {
|
||||
<pngx-input-textarea formControlName="value"
|
||||
[title]="getCustomFieldFromInstance(fieldInstance)?.name"
|
||||
[removable]="userCanEdit"
|
||||
(removed)="removeField(fieldInstance)"
|
||||
[horizontal]="true"
|
||||
[error]="getCustomFieldError(i)"></pngx-input-textarea>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
@@ -1415,151 +1415,4 @@ describe('DocumentDetailComponent', () => {
|
||||
.flush('fail', { status: 500, statusText: 'Server Error' })
|
||||
expect(component.previewText).toContain('An error occurred loading content')
|
||||
})
|
||||
|
||||
it('should print document successfully', fakeAsync(() => {
|
||||
initNormally()
|
||||
|
||||
const appendChildSpy = jest
|
||||
.spyOn(document.body, 'appendChild')
|
||||
.mockImplementation((node: Node) => node)
|
||||
const removeChildSpy = jest
|
||||
.spyOn(document.body, 'removeChild')
|
||||
.mockImplementation((node: Node) => node)
|
||||
const createObjectURLSpy = jest
|
||||
.spyOn(URL, 'createObjectURL')
|
||||
.mockReturnValue('blob:mock-url')
|
||||
const revokeObjectURLSpy = jest
|
||||
.spyOn(URL, 'revokeObjectURL')
|
||||
.mockImplementation(() => {})
|
||||
|
||||
const mockContentWindow = {
|
||||
focus: jest.fn(),
|
||||
print: jest.fn(),
|
||||
onafterprint: null,
|
||||
}
|
||||
|
||||
const mockIframe = {
|
||||
style: {},
|
||||
src: '',
|
||||
onload: null,
|
||||
contentWindow: mockContentWindow,
|
||||
}
|
||||
|
||||
const createElementSpy = jest
|
||||
.spyOn(document, 'createElement')
|
||||
.mockReturnValue(mockIframe as any)
|
||||
|
||||
const blob = new Blob(['test'], { type: 'application/pdf' })
|
||||
component.printDocument()
|
||||
|
||||
const req = httpTestingController.expectOne(
|
||||
`${environment.apiBaseUrl}documents/${doc.id}/download/`
|
||||
)
|
||||
req.flush(blob)
|
||||
|
||||
tick()
|
||||
|
||||
expect(createElementSpy).toHaveBeenCalledWith('iframe')
|
||||
expect(appendChildSpy).toHaveBeenCalledWith(mockIframe)
|
||||
expect(createObjectURLSpy).toHaveBeenCalledWith(blob)
|
||||
|
||||
if (mockIframe.onload) {
|
||||
mockIframe.onload({} as any)
|
||||
}
|
||||
|
||||
expect(mockContentWindow.focus).toHaveBeenCalled()
|
||||
expect(mockContentWindow.print).toHaveBeenCalled()
|
||||
|
||||
if (mockIframe.onload) {
|
||||
mockIframe.onload(new Event('load'))
|
||||
}
|
||||
|
||||
if (mockContentWindow.onafterprint) {
|
||||
mockContentWindow.onafterprint(new Event('afterprint'))
|
||||
}
|
||||
|
||||
expect(removeChildSpy).toHaveBeenCalledWith(mockIframe)
|
||||
expect(revokeObjectURLSpy).toHaveBeenCalledWith('blob:mock-url')
|
||||
|
||||
createElementSpy.mockRestore()
|
||||
appendChildSpy.mockRestore()
|
||||
removeChildSpy.mockRestore()
|
||||
createObjectURLSpy.mockRestore()
|
||||
revokeObjectURLSpy.mockRestore()
|
||||
}))
|
||||
|
||||
it('should show error toast if print document fails', () => {
|
||||
initNormally()
|
||||
const toastSpy = jest.spyOn(toastService, 'showError')
|
||||
component.printDocument()
|
||||
const req = httpTestingController.expectOne(
|
||||
`${environment.apiBaseUrl}documents/${doc.id}/download/`
|
||||
)
|
||||
req.error(new ErrorEvent('failed'))
|
||||
expect(toastSpy).toHaveBeenCalledWith(
|
||||
'Error loading document for printing.'
|
||||
)
|
||||
})
|
||||
|
||||
it('should show error toast if printing throws inside iframe', fakeAsync(() => {
|
||||
initNormally()
|
||||
|
||||
const appendChildSpy = jest
|
||||
.spyOn(document.body, 'appendChild')
|
||||
.mockImplementation((node: Node) => node)
|
||||
const removeChildSpy = jest
|
||||
.spyOn(document.body, 'removeChild')
|
||||
.mockImplementation((node: Node) => node)
|
||||
const createObjectURLSpy = jest
|
||||
.spyOn(URL, 'createObjectURL')
|
||||
.mockReturnValue('blob:mock-url')
|
||||
const revokeObjectURLSpy = jest
|
||||
.spyOn(URL, 'revokeObjectURL')
|
||||
.mockImplementation(() => {})
|
||||
|
||||
const toastSpy = jest.spyOn(toastService, 'showError')
|
||||
|
||||
const mockContentWindow = {
|
||||
focus: jest.fn().mockImplementation(() => {
|
||||
throw new Error('focus failed')
|
||||
}),
|
||||
print: jest.fn(),
|
||||
onafterprint: null,
|
||||
}
|
||||
|
||||
const mockIframe: any = {
|
||||
style: {},
|
||||
src: '',
|
||||
onload: null,
|
||||
contentWindow: mockContentWindow,
|
||||
}
|
||||
|
||||
const createElementSpy = jest
|
||||
.spyOn(document, 'createElement')
|
||||
.mockReturnValue(mockIframe as any)
|
||||
|
||||
const blob = new Blob(['test'], { type: 'application/pdf' })
|
||||
component.printDocument()
|
||||
|
||||
const req = httpTestingController.expectOne(
|
||||
`${environment.apiBaseUrl}documents/${doc.id}/download/`
|
||||
)
|
||||
req.flush(blob)
|
||||
|
||||
tick()
|
||||
|
||||
if (mockIframe.onload) {
|
||||
mockIframe.onload(new Event('load'))
|
||||
}
|
||||
|
||||
expect(toastSpy).toHaveBeenCalled()
|
||||
expect(removeChildSpy).toHaveBeenCalledWith(mockIframe)
|
||||
expect(revokeObjectURLSpy).toHaveBeenCalledWith('blob:mock-url')
|
||||
|
||||
createElementSpy.mockRestore()
|
||||
appendChildSpy.mockRestore()
|
||||
removeChildSpy.mockRestore()
|
||||
createObjectURLSpy.mockRestore()
|
||||
revokeObjectURLSpy.mockRestore()
|
||||
}))
|
||||
})
|
||||
|
@@ -98,7 +98,6 @@ import { PermissionsFormComponent } from '../common/input/permissions/permission
|
||||
import { SelectComponent } from '../common/input/select/select.component'
|
||||
import { TagsComponent } from '../common/input/tags/tags.component'
|
||||
import { TextComponent } from '../common/input/text/text.component'
|
||||
import { TextAreaComponent } from '../common/input/textarea/textarea.component'
|
||||
import { UrlComponent } from '../common/input/url/url.component'
|
||||
import { PageHeaderComponent } from '../common/page-header/page-header.component'
|
||||
import {
|
||||
@@ -174,7 +173,6 @@ export enum ZoomSetting {
|
||||
NgbDropdownModule,
|
||||
NgxBootstrapIconsModule,
|
||||
PdfViewerModule,
|
||||
TextAreaComponent,
|
||||
],
|
||||
})
|
||||
export class DocumentDetailComponent
|
||||
@@ -293,10 +291,6 @@ export class DocumentDetailComponent
|
||||
return this.settings.get(SETTINGS_KEYS.USE_NATIVE_PDF_VIEWER)
|
||||
}
|
||||
|
||||
get isMobile(): boolean {
|
||||
return this.deviceDetectorService.isMobile()
|
||||
}
|
||||
|
||||
get archiveContentRenderType(): ContentRenderType {
|
||||
return this.document?.archived_file_name
|
||||
? this.getRenderType('application/pdf')
|
||||
@@ -909,7 +903,6 @@ export class DocumentDetailComponent
|
||||
.patch(this.getChangedFields())
|
||||
.pipe(
|
||||
switchMap((updateResult) => {
|
||||
this.savedViewService.maybeRefreshDocumentCounts()
|
||||
return this.documentListViewService.getNext(this.documentId).pipe(
|
||||
map((nextDocId) => ({ nextDocId, updateResult })),
|
||||
takeUntil(this.unsubscribeNotifier)
|
||||
@@ -1425,44 +1418,6 @@ export class DocumentDetailComponent
|
||||
})
|
||||
}
|
||||
|
||||
printDocument() {
|
||||
const printUrl = this.documentsService.getDownloadUrl(
|
||||
this.document.id,
|
||||
false
|
||||
)
|
||||
this.http
|
||||
.get(printUrl, { responseType: 'blob' })
|
||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe({
|
||||
next: (blob) => {
|
||||
const blobUrl = URL.createObjectURL(blob)
|
||||
const iframe = document.createElement('iframe')
|
||||
iframe.style.display = 'none'
|
||||
iframe.src = blobUrl
|
||||
document.body.appendChild(iframe)
|
||||
iframe.onload = () => {
|
||||
try {
|
||||
iframe.contentWindow.focus()
|
||||
iframe.contentWindow.print()
|
||||
iframe.contentWindow.onafterprint = () => {
|
||||
document.body.removeChild(iframe)
|
||||
URL.revokeObjectURL(blobUrl)
|
||||
}
|
||||
} catch (err) {
|
||||
this.toastService.showError($localize`Print failed.`, err)
|
||||
document.body.removeChild(iframe)
|
||||
URL.revokeObjectURL(blobUrl)
|
||||
}
|
||||
}
|
||||
},
|
||||
error: () => {
|
||||
this.toastService.showError(
|
||||
$localize`Error loading document for printing.`
|
||||
)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
public openShareLinks() {
|
||||
const modal = this.modalService.open(ShareLinksDialogComponent)
|
||||
modal.componentInstance.documentId = this.document.id
|
||||
|
@@ -1,144 +1,161 @@
|
||||
<div class="d-flex flex-wrap gap-4">
|
||||
<div class="d-flex flex-wrap align-items-center gap-2" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.Document }">
|
||||
<label class="me-2" i18n>Edit:</label>
|
||||
@if (permissionService.currentUserCan(PermissionAction.View, PermissionType.Tag)) {
|
||||
<pngx-filterable-dropdown title="Tags" icon="tag-fill" i18n-title
|
||||
filterPlaceholder="Filter tags" i18n-filterPlaceholder
|
||||
[disabled]="!userCanEditAll || disabled"
|
||||
[editing]="true"
|
||||
[applyOnClose]="applyOnClose"
|
||||
[createRef]="createTag.bind(this)"
|
||||
(opened)="openTagsDropdown()"
|
||||
[(selectionModel)]="tagSelectionModel"
|
||||
[documentCounts]="tagDocumentCounts"
|
||||
(apply)="setTags($event)"
|
||||
shortcutKey="t">
|
||||
</pngx-filterable-dropdown>
|
||||
}
|
||||
@if (permissionService.currentUserCan(PermissionAction.View, PermissionType.Correspondent)) {
|
||||
<pngx-filterable-dropdown title="Correspondent" icon="person-fill" i18n-title
|
||||
filterPlaceholder="Filter correspondents" i18n-filterPlaceholder
|
||||
[disabled]="!userCanEditAll || disabled"
|
||||
[editing]="true"
|
||||
[applyOnClose]="applyOnClose"
|
||||
[createRef]="createCorrespondent.bind(this)"
|
||||
(opened)="openCorrespondentDropdown()"
|
||||
[(selectionModel)]="correspondentSelectionModel"
|
||||
[documentCounts]="correspondentDocumentCounts"
|
||||
(apply)="setCorrespondents($event)"
|
||||
shortcutKey="y">
|
||||
</pngx-filterable-dropdown>
|
||||
}
|
||||
@if (permissionService.currentUserCan(PermissionAction.View, PermissionType.DocumentType)) {
|
||||
<pngx-filterable-dropdown title="Document type" icon="file-earmark-fill" i18n-title
|
||||
filterPlaceholder="Filter document types" i18n-filterPlaceholder
|
||||
[disabled]="!userCanEditAll || disabled"
|
||||
[editing]="true"
|
||||
[applyOnClose]="applyOnClose"
|
||||
[createRef]="createDocumentType.bind(this)"
|
||||
(opened)="openDocumentTypeDropdown()"
|
||||
[(selectionModel)]="documentTypeSelectionModel"
|
||||
[documentCounts]="documentTypeDocumentCounts"
|
||||
(apply)="setDocumentTypes($event)"
|
||||
shortcutKey="u">
|
||||
</pngx-filterable-dropdown>
|
||||
}
|
||||
@if (permissionService.currentUserCan(PermissionAction.View, PermissionType.StoragePath)) {
|
||||
<pngx-filterable-dropdown title="Storage path" icon="folder-fill" i18n-title
|
||||
filterPlaceholder="Filter storage paths" i18n-filterPlaceholder
|
||||
[disabled]="!userCanEditAll || disabled"
|
||||
[editing]="true"
|
||||
[applyOnClose]="applyOnClose"
|
||||
[createRef]="createStoragePath.bind(this)"
|
||||
(opened)="openStoragePathDropdown()"
|
||||
[(selectionModel)]="storagePathsSelectionModel"
|
||||
[documentCounts]="storagePathDocumentCounts"
|
||||
(apply)="setStoragePaths($event)"
|
||||
shortcutKey="i">
|
||||
</pngx-filterable-dropdown>
|
||||
}
|
||||
@if (permissionService.currentUserCan(PermissionAction.View, PermissionType.CustomField)) {
|
||||
<pngx-filterable-dropdown title="Custom fields" icon="ui-radios" i18n-title
|
||||
filterPlaceholder="Filter custom fields" i18n-filterPlaceholder
|
||||
[disabled]="!userCanEditAll"
|
||||
[editing]="true"
|
||||
[applyOnClose]="applyOnClose"
|
||||
[createRef]="createCustomField.bind(this)"
|
||||
(opened)="openCustomFieldsDropdown()"
|
||||
[(selectionModel)]="customFieldsSelectionModel"
|
||||
[documentCounts]="customFieldDocumentCounts"
|
||||
extraButtonTitle="Set values"
|
||||
i18n-extraButtonTitle
|
||||
(extraButton)="setCustomFieldValues($event)"
|
||||
(apply)="setCustomFields($event)">
|
||||
</pngx-filterable-dropdown>
|
||||
}
|
||||
<div class="btn-group">
|
||||
<button type="button" class="btn btn-sm btn-outline-primary me-2" (click)="setPermissions()" [disabled]="!userOwnsAll || !userCanEditAll">
|
||||
<i-bs name="person-fill-lock"></i-bs><div class="d-none d-sm-inline"> <ng-container i18n>Permissions</ng-container></div>
|
||||
<div class="d-flex align-items-center" role="group" aria-label="Select">
|
||||
<button class="btn btn-sm btn-outline-secondary" (click)="list.selectNone()">
|
||||
<i-bs name="slash-circle"></i-bs> <ng-container i18n>Cancel</ng-container>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex align-items-center gap-2 ms-auto">
|
||||
<div class="btn-toolbar">
|
||||
<div ngbDropdown>
|
||||
<button class="btn btn-sm btn-outline-primary" id="dropdownSelect" [disabled]="!userCanEdit && !userCanAdd" ngbDropdownToggle>
|
||||
<i-bs name="three-dots"></i-bs>
|
||||
<div class="d-none d-sm-inline"> <ng-container i18n>Actions</ng-container></div>
|
||||
</button>
|
||||
<div ngbDropdownMenu aria-labelledby="dropdownSelect" class="shadow">
|
||||
<button ngbDropdownItem (click)="reprocessSelected()" [disabled]="!userCanEditAll && !userCanEditAll">
|
||||
<i-bs name="body-text"></i-bs> <ng-container i18n>Reprocess</ng-container>
|
||||
<div class="d-flex align-items-center gap-2" role="group" aria-label="Select">
|
||||
<label class="me-2" i18n>Select:</label>
|
||||
<div class="btn-group">
|
||||
<button class="btn btn-sm btn-outline-primary" (click)="list.selectPage()">
|
||||
<i-bs name="file-earmark-check"></i-bs> <ng-container i18n>Page</ng-container>
|
||||
</button>
|
||||
<button ngbDropdownItem (click)="rotateSelected()" [disabled]="!userOwnsAll && !userCanEditAll">
|
||||
<i-bs name="arrow-clockwise"></i-bs> <ng-container i18n>Rotate</ng-container>
|
||||
</button>
|
||||
<button ngbDropdownItem (click)="mergeSelected()" [disabled]="!userCanAdd || list.selected.size < 2">
|
||||
<i-bs name="journals"></i-bs> <ng-container i18n>Merge</ng-container>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="btn-group btn-group-sm">
|
||||
<button class="btn btn-sm btn-outline-primary" [disabled]="awaitingDownload" (click)="downloadSelected()">
|
||||
@if (!awaitingDownload) {
|
||||
<i-bs name="arrow-down"></i-bs>
|
||||
}
|
||||
@if (awaitingDownload) {
|
||||
<div class="spinner-border spinner-border-sm" role="status">
|
||||
<span class="visually-hidden">Preparing download...</span>
|
||||
<button class="btn btn-sm btn-outline-primary" (click)="list.selectAll()">
|
||||
<i-bs name="check-all"></i-bs> <ng-container i18n>All</ng-container>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
<div class="d-none d-sm-inline"> <ng-container i18n>Download</ng-container></div>
|
||||
</button>
|
||||
<div ngbDropdown class="me-2 d-flex btn-group" role="group">
|
||||
<button type="button" class="btn btn-sm btn-outline-primary dropdown-toggle-split rounded-end" ngbDropdownToggle></button>
|
||||
<div ngbDropdownMenu aria-labelledby="dropdownSelect" class="shadow">
|
||||
<form [formGroup]="downloadForm" class="px-3 py-1">
|
||||
<p class="mb-1" i18n>Include:</p>
|
||||
<div class="form-group ps-3 mb-2">
|
||||
<div class="form-check">
|
||||
<input type="checkbox" class="form-check-input" id="downloadFileType_archive" formControlName="downloadFileTypeArchive" />
|
||||
<label class="form-check-label" for="downloadFileType_archive" i18n>Archived files</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input type="checkbox" class="form-check-input" id="downloadFileType_originals" formControlName="downloadFileTypeOriginals" />
|
||||
<label class="form-check-label" for="downloadFileType_originals" i18n>Original files</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input type="checkbox" class="form-check-input" id="downloadUseFormatting" formControlName="downloadUseFormatting" />
|
||||
<label class="form-check-label" for="downloadUseFormatting" i18n>Use formatted filename</label>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex align-items-center gap-2" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.Document }">
|
||||
<label class="me-2" i18n>Edit:</label>
|
||||
@if (permissionService.currentUserCan(PermissionAction.View, PermissionType.Tag)) {
|
||||
<pngx-filterable-dropdown title="Tags" icon="tag-fill" i18n-title
|
||||
filterPlaceholder="Filter tags" i18n-filterPlaceholder
|
||||
[disabled]="!userCanEditAll || disabled"
|
||||
[editing]="true"
|
||||
[applyOnClose]="applyOnClose"
|
||||
[createRef]="createTag.bind(this)"
|
||||
(opened)="openTagsDropdown()"
|
||||
[(selectionModel)]="tagSelectionModel"
|
||||
[documentCounts]="tagDocumentCounts"
|
||||
(apply)="setTags($event)"
|
||||
shortcutKey="t">
|
||||
</pngx-filterable-dropdown>
|
||||
}
|
||||
@if (permissionService.currentUserCan(PermissionAction.View, PermissionType.Correspondent)) {
|
||||
<pngx-filterable-dropdown title="Correspondent" icon="person-fill" i18n-title
|
||||
filterPlaceholder="Filter correspondents" i18n-filterPlaceholder
|
||||
[disabled]="!userCanEditAll || disabled"
|
||||
[editing]="true"
|
||||
[applyOnClose]="applyOnClose"
|
||||
[createRef]="createCorrespondent.bind(this)"
|
||||
(opened)="openCorrespondentDropdown()"
|
||||
[(selectionModel)]="correspondentSelectionModel"
|
||||
[documentCounts]="correspondentDocumentCounts"
|
||||
(apply)="setCorrespondents($event)"
|
||||
shortcutKey="y">
|
||||
</pngx-filterable-dropdown>
|
||||
}
|
||||
@if (permissionService.currentUserCan(PermissionAction.View, PermissionType.DocumentType)) {
|
||||
<pngx-filterable-dropdown title="Document type" icon="file-earmark-fill" i18n-title
|
||||
filterPlaceholder="Filter document types" i18n-filterPlaceholder
|
||||
[disabled]="!userCanEditAll || disabled"
|
||||
[editing]="true"
|
||||
[applyOnClose]="applyOnClose"
|
||||
[createRef]="createDocumentType.bind(this)"
|
||||
(opened)="openDocumentTypeDropdown()"
|
||||
[(selectionModel)]="documentTypeSelectionModel"
|
||||
[documentCounts]="documentTypeDocumentCounts"
|
||||
(apply)="setDocumentTypes($event)"
|
||||
shortcutKey="u">
|
||||
</pngx-filterable-dropdown>
|
||||
}
|
||||
@if (permissionService.currentUserCan(PermissionAction.View, PermissionType.StoragePath)) {
|
||||
<pngx-filterable-dropdown title="Storage path" icon="folder-fill" i18n-title
|
||||
filterPlaceholder="Filter storage paths" i18n-filterPlaceholder
|
||||
[disabled]="!userCanEditAll || disabled"
|
||||
[editing]="true"
|
||||
[applyOnClose]="applyOnClose"
|
||||
[createRef]="createStoragePath.bind(this)"
|
||||
(opened)="openStoragePathDropdown()"
|
||||
[(selectionModel)]="storagePathsSelectionModel"
|
||||
[documentCounts]="storagePathDocumentCounts"
|
||||
(apply)="setStoragePaths($event)"
|
||||
shortcutKey="i">
|
||||
</pngx-filterable-dropdown>
|
||||
}
|
||||
@if (permissionService.currentUserCan(PermissionAction.View, PermissionType.CustomField)) {
|
||||
<pngx-filterable-dropdown title="Custom fields" icon="ui-radios" i18n-title
|
||||
filterPlaceholder="Filter custom fields" i18n-filterPlaceholder
|
||||
[disabled]="!userCanEditAll"
|
||||
[editing]="true"
|
||||
[applyOnClose]="applyOnClose"
|
||||
[createRef]="createCustomField.bind(this)"
|
||||
(opened)="openCustomFieldsDropdown()"
|
||||
[(selectionModel)]="customFieldsSelectionModel"
|
||||
[documentCounts]="customFieldDocumentCounts"
|
||||
extraButtonTitle="Set values"
|
||||
i18n-extraButtonTitle
|
||||
(extraButton)="setCustomFieldValues($event)"
|
||||
(apply)="setCustomFields($event)">
|
||||
</pngx-filterable-dropdown>
|
||||
}
|
||||
</div>
|
||||
<div class="d-flex align-items-center gap-2 ms-auto">
|
||||
<div class="btn-toolbar">
|
||||
|
||||
<div class="btn-group btn-group-sm">
|
||||
<button type="button" class="btn btn-sm btn-outline-danger" (click)="applyDelete()" *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.Document }" [disabled]="!userOwnsAll">
|
||||
<i-bs name="trash"></i-bs> <ng-container i18n>Delete</ng-container>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="btn btn-sm btn-outline-primary me-2" (click)="setPermissions()" [disabled]="!userOwnsAll || !userCanEditAll">
|
||||
<i-bs name="person-fill-lock"></i-bs><div class="d-none d-sm-inline"> <ng-container i18n>Permissions</ng-container></div>
|
||||
</button>
|
||||
|
||||
<div ngbDropdown>
|
||||
<button class="btn btn-sm btn-outline-primary" id="dropdownSelect" [disabled]="!userCanEdit && !userCanAdd" ngbDropdownToggle>
|
||||
<i-bs name="three-dots"></i-bs>
|
||||
<div class="d-none d-sm-inline"> <ng-container i18n>Actions</ng-container></div>
|
||||
</button>
|
||||
<div ngbDropdownMenu aria-labelledby="dropdownSelect" class="shadow">
|
||||
<button ngbDropdownItem (click)="reprocessSelected()" [disabled]="!userCanEditAll && !userCanEditAll">
|
||||
<i-bs name="body-text"></i-bs> <ng-container i18n>Reprocess</ng-container>
|
||||
</button>
|
||||
<button ngbDropdownItem (click)="rotateSelected()" [disabled]="!userOwnsAll && !userCanEditAll">
|
||||
<i-bs name="arrow-clockwise"></i-bs> <ng-container i18n>Rotate</ng-container>
|
||||
</button>
|
||||
<button ngbDropdownItem (click)="mergeSelected()" [disabled]="!userCanAdd || list.selected.size < 2">
|
||||
<i-bs name="journals"></i-bs> <ng-container i18n>Merge</ng-container>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="btn-group btn-group-sm">
|
||||
<button class="btn btn-sm btn-outline-primary" [disabled]="awaitingDownload" (click)="downloadSelected()">
|
||||
@if (!awaitingDownload) {
|
||||
<i-bs name="arrow-down"></i-bs>
|
||||
}
|
||||
@if (awaitingDownload) {
|
||||
<div class="spinner-border spinner-border-sm" role="status">
|
||||
<span class="visually-hidden">Preparing download...</span>
|
||||
</div>
|
||||
}
|
||||
<div class="d-none d-sm-inline"> <ng-container i18n>Download</ng-container></div>
|
||||
</button>
|
||||
<div ngbDropdown class="me-2 d-flex btn-group" role="group">
|
||||
<button type="button" class="btn btn-sm btn-outline-primary dropdown-toggle-split rounded-end" ngbDropdownToggle></button>
|
||||
<div ngbDropdownMenu aria-labelledby="dropdownSelect" class="shadow">
|
||||
<form [formGroup]="downloadForm" class="px-3 py-1">
|
||||
<p class="mb-1" i18n>Include:</p>
|
||||
<div class="form-group ps-3 mb-2">
|
||||
<div class="form-check">
|
||||
<input type="checkbox" class="form-check-input" id="downloadFileType_archive" formControlName="downloadFileTypeArchive" />
|
||||
<label class="form-check-label" for="downloadFileType_archive" i18n>Archived files</label>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input type="checkbox" class="form-check-input" id="downloadFileType_originals" formControlName="downloadFileTypeOriginals" />
|
||||
<label class="form-check-label" for="downloadFileType_originals" i18n>Original files</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-check">
|
||||
<input type="checkbox" class="form-check-input" id="downloadUseFormatting" formControlName="downloadUseFormatting" />
|
||||
<label class="form-check-label" for="downloadUseFormatting" i18n>Use formatted filename</label>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="btn-group btn-group-sm">
|
||||
<button type="button" class="btn btn-sm btn-outline-danger" (click)="applyDelete()" *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.Document }" [disabled]="!userOwnsAll">
|
||||
<i-bs name="trash"></i-bs> <ng-container i18n>Delete</ng-container>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@@ -5,7 +5,3 @@
|
||||
.dropdown-menu{
|
||||
--bs-dropdown-min-width: 12rem;
|
||||
}
|
||||
|
||||
.btn-group .btn {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
@@ -1204,7 +1204,7 @@ describe('BulkEditorComponent', () => {
|
||||
expect(tagListAllSpy).toHaveBeenCalled()
|
||||
|
||||
expect(tagSelectionModelToggleSpy).toHaveBeenCalledWith(newTag.id)
|
||||
expect(component.tagSelectionModel.items).toMatchObject(
|
||||
expect(component.tagSelectionModel.items).toEqual(
|
||||
[{ id: null, name: 'Not assigned' }].concat(tags.results as any)
|
||||
)
|
||||
})
|
||||
|
@@ -37,7 +37,6 @@ import { StoragePathService } from 'src/app/services/rest/storage-path.service'
|
||||
import { TagService } from 'src/app/services/rest/tag.service'
|
||||
import { SettingsService } from 'src/app/services/settings.service'
|
||||
import { ToastService } from 'src/app/services/toast.service'
|
||||
import { flattenTags } from 'src/app/utils/flatten-tags'
|
||||
import { MergeConfirmDialogComponent } from '../../common/confirm-dialog/merge-confirm-dialog/merge-confirm-dialog.component'
|
||||
import { RotateConfirmDialogComponent } from '../../common/confirm-dialog/rotate-confirm-dialog/rotate-confirm-dialog.component'
|
||||
import { CorrespondentEditDialogComponent } from '../../common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component'
|
||||
@@ -165,10 +164,7 @@ export class BulkEditorComponent
|
||||
this.tagService
|
||||
.listAll()
|
||||
.pipe(first())
|
||||
.subscribe(
|
||||
(result) =>
|
||||
(this.tagSelectionModel.items = flattenTags(result.results))
|
||||
)
|
||||
.subscribe((result) => (this.tagSelectionModel.items = result.results))
|
||||
}
|
||||
if (
|
||||
this.permissionService.currentUserCan(
|
||||
@@ -652,7 +648,7 @@ export class BulkEditorComponent
|
||||
)
|
||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe(({ newTag, tags }) => {
|
||||
this.tagSelectionModel.items = flattenTags(tags.results)
|
||||
this.tagSelectionModel.items = tags.results
|
||||
this.tagSelectionModel.toggle(newTag.id)
|
||||
})
|
||||
}
|
||||
|
@@ -56,10 +56,6 @@
|
||||
[items]="field.extra_data.select_options" bindLabel="label" [allowNull]="true" [horizontal]="true">
|
||||
</pngx-input-select>
|
||||
}
|
||||
@case (CustomFieldDataType.LongText) {
|
||||
<pngx-input-textarea formControlName="{{field.id}}" class="w-100" [title]="field.name" [horizontal]="true">
|
||||
</pngx-input-textarea>
|
||||
}
|
||||
}
|
||||
<button type="button" class="btn btn-outline-danger mb-3" (click)="removeField(field.id)">
|
||||
<i-bs name="x"></i-bs>
|
||||
|
@@ -18,7 +18,6 @@ import { TextComponent } from 'src/app/components/common/input/text/text.compone
|
||||
import { UrlComponent } from 'src/app/components/common/input/url/url.component'
|
||||
import { CustomField, CustomFieldDataType } from 'src/app/data/custom-field'
|
||||
import { DocumentService } from 'src/app/services/rest/document.service'
|
||||
import { TextAreaComponent } from '../../../common/input/textarea/textarea.component'
|
||||
|
||||
@Component({
|
||||
selector: 'pngx-custom-fields-bulk-edit-dialog',
|
||||
@@ -36,7 +35,6 @@ import { TextAreaComponent } from '../../../common/input/textarea/textarea.compo
|
||||
FormsModule,
|
||||
ReactiveFormsModule,
|
||||
NgxBootstrapIconsModule,
|
||||
TextAreaComponent,
|
||||
],
|
||||
})
|
||||
export class CustomFieldsBulkEditDialogComponent {
|
||||
|
@@ -1,36 +1,16 @@
|
||||
<pngx-page-header [title]="getTitle()">
|
||||
<div ngbDropdown class="btn-group flex-fill d-sm-none">
|
||||
<button class="btn btn-sm btn-outline-primary" id="dropdownSelectMobile" ngbDropdownToggle>
|
||||
|
||||
<div ngbDropdown class="btn-group flex-fill">
|
||||
<button class="btn btn-sm btn-outline-primary" id="dropdownSelect" ngbDropdownToggle>
|
||||
<i-bs name="text-indent-left"></i-bs>
|
||||
<div class="d-none d-sm-inline"> <ng-container i18n>Select</ng-container></div>
|
||||
@if (list.selected.size > 0) {
|
||||
<pngx-clearable-badge [selected]="list.selected.size > 0" [number]="list.selected.size" (cleared)="list.selectNone()"></pngx-clearable-badge><span class="visually-hidden">selected</span>
|
||||
}
|
||||
</button>
|
||||
<div ngbDropdownMenu aria-labelledby="dropdownSelectMobile" class="shadow">
|
||||
<div ngbDropdownMenu aria-labelledby="dropdownSelect" class="shadow">
|
||||
<button ngbDropdownItem (click)="list.selectNone()" i18n>Select none</button>
|
||||
<button ngbDropdownItem (click)="list.selectPage()" i18n>Select page</button>
|
||||
<button ngbDropdownItem (click)="list.selectAll()" i18n>Select all</button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-none d-sm-flex flex-fill me-3">
|
||||
<div class="input-group input-group-sm">
|
||||
<span class="input-group-text border-0">Select:</span>
|
||||
</div>
|
||||
<div class="btn-group btn-group-sm flex-nowrap">
|
||||
@if (list.selected.size > 0) {
|
||||
<button class="btn btn-sm btn-outline-secondary" (click)="list.selectNone()">
|
||||
<i-bs name="slash-circle"></i-bs> <ng-container i18n>None</ng-container>
|
||||
</button>
|
||||
}
|
||||
<button class="btn btn-sm btn-outline-primary" (click)="list.selectPage()">
|
||||
<i-bs name="file-earmark-check"></i-bs> <ng-container i18n>Page</ng-container>
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-primary" (click)="list.selectAll()">
|
||||
<i-bs name="check-all"></i-bs> <ng-container i18n>All</ng-container>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div ngbDropdown class="btn-group flex-fill">
|
||||
<button class="btn btn-sm btn-outline-primary" id="dropdownDisplayFields" ngbDropdownToggle>
|
||||
<i-bs name="card-heading"></i-bs>
|
||||
@@ -146,13 +126,8 @@
|
||||
@if (!list.isReloading && isFiltered) {
|
||||
<button class="btn btn-link py-0" (click)="resetFilters()">
|
||||
<i-bs width="1em" height="1em" name="x"></i-bs><small i18n>Reset filters</small>
|
||||
</button>
|
||||
}
|
||||
@if (!list.isReloading && list.selected.size > 0) {
|
||||
<button class="btn btn-link py-0" (click)="list.selectNone()">
|
||||
<i-bs width="1em" height="1em" name="slash-circle" class="me-1"></i-bs><small i18n>Clear selection</small>
|
||||
</button>
|
||||
}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
@if (list.collectionSize) {
|
||||
<ngb-pagination [pageSize]="list.pageSize" [collectionSize]="list.collectionSize" [(page)]="list.currentPage" [maxSize]="5"
|
||||
|
@@ -56,7 +56,6 @@ import {
|
||||
filterRulesDiffer,
|
||||
isFullTextFilterRule,
|
||||
} from 'src/app/utils/filter-rules'
|
||||
import { ClearableBadgeComponent } from '../common/clearable-badge/clearable-badge.component'
|
||||
import { CustomFieldDisplayComponent } from '../common/custom-field-display/custom-field-display.component'
|
||||
import { PageHeaderComponent } from '../common/page-header/page-header.component'
|
||||
import { PreviewPopupComponent } from '../common/preview-popup/preview-popup.component'
|
||||
@@ -73,7 +72,6 @@ import { SaveViewConfigDialogComponent } from './save-view-config-dialog/save-vi
|
||||
templateUrl: './document-list.component.html',
|
||||
styleUrls: ['./document-list.component.scss'],
|
||||
imports: [
|
||||
ClearableBadgeComponent,
|
||||
CustomFieldDisplayComponent,
|
||||
PageHeaderComponent,
|
||||
BulkEditorComponent,
|
||||
|
@@ -589,7 +589,7 @@ describe('FilterEditorComponent', () => {
|
||||
expect(component.tagSelectionModel.logicalOperator).toEqual(
|
||||
LogicalOperator.And
|
||||
)
|
||||
expect(component.tagSelectionModel.getSelectedItems()).toMatchObject(tags)
|
||||
expect(component.tagSelectionModel.getSelectedItems()).toEqual(tags)
|
||||
// coverage
|
||||
component.filterRules = [
|
||||
{
|
||||
@@ -615,7 +615,7 @@ describe('FilterEditorComponent', () => {
|
||||
expect(component.tagSelectionModel.logicalOperator).toEqual(
|
||||
LogicalOperator.Or
|
||||
)
|
||||
expect(component.tagSelectionModel.getSelectedItems()).toMatchObject(tags)
|
||||
expect(component.tagSelectionModel.getSelectedItems()).toEqual(tags)
|
||||
// coverage
|
||||
component.filterRules = [
|
||||
{
|
||||
@@ -652,7 +652,7 @@ describe('FilterEditorComponent', () => {
|
||||
expect(component.tagSelectionModel.logicalOperator).toEqual(
|
||||
LogicalOperator.And
|
||||
)
|
||||
expect(component.tagSelectionModel.getExcludedItems()).toMatchObject(tags)
|
||||
expect(component.tagSelectionModel.getExcludedItems()).toEqual(tags)
|
||||
// coverage
|
||||
component.filterRules = [
|
||||
{
|
||||
|
@@ -97,7 +97,6 @@ import {
|
||||
CustomFieldQueryExpression,
|
||||
} from 'src/app/utils/custom-field-query-element'
|
||||
import { filterRulesDiffer } from 'src/app/utils/filter-rules'
|
||||
import { flattenTags } from 'src/app/utils/flatten-tags'
|
||||
import {
|
||||
CustomFieldQueriesModel,
|
||||
CustomFieldsQueryDropdownComponent,
|
||||
@@ -1135,7 +1134,7 @@ export class FilterEditorComponent
|
||||
) {
|
||||
this.loadingCountTotal++
|
||||
this.tagService.listAll().subscribe((result) => {
|
||||
this.tagSelectionModel.items = flattenTags(result.results)
|
||||
this.tagSelectionModel.items = result.results
|
||||
this.maybeCompleteLoading()
|
||||
})
|
||||
}
|
||||
|
@@ -1,4 +1,4 @@
|
||||
import { NgClass, NgTemplateOutlet, TitleCasePipe } from '@angular/common'
|
||||
import { NgClass, TitleCasePipe } from '@angular/common'
|
||||
import { Component, inject } from '@angular/core'
|
||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||
import {
|
||||
@@ -30,7 +30,6 @@ import { ManagementListComponent } from '../management-list/management-list.comp
|
||||
FormsModule,
|
||||
ReactiveFormsModule,
|
||||
NgClass,
|
||||
NgTemplateOutlet,
|
||||
NgbDropdownModule,
|
||||
NgbPaginationModule,
|
||||
NgxBootstrapIconsModule,
|
||||
|
@@ -1,4 +1,4 @@
|
||||
import { NgClass, NgTemplateOutlet, TitleCasePipe } from '@angular/common'
|
||||
import { NgClass, TitleCasePipe } from '@angular/common'
|
||||
import { Component, inject } from '@angular/core'
|
||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||
import {
|
||||
@@ -28,7 +28,6 @@ import { ManagementListComponent } from '../management-list/management-list.comp
|
||||
FormsModule,
|
||||
ReactiveFormsModule,
|
||||
NgClass,
|
||||
NgTemplateOutlet,
|
||||
NgbDropdownModule,
|
||||
NgbPaginationModule,
|
||||
NgxBootstrapIconsModule,
|
||||
|
@@ -109,11 +109,10 @@
|
||||
<li class="list-group-item">
|
||||
<div class="row">
|
||||
<div class="col" i18n>Name</div>
|
||||
<div class="col-1 d-none d-sm-block" i18n>Sort Order</div>
|
||||
<div class="col-2" i18n>Account</div>
|
||||
<div class="col-2 d-none d-sm-block" i18n>Status</div>
|
||||
<div class="col d-none d-sm-block" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.ProcessedMail }">Processed Mail</div>
|
||||
<div class="col-3" i18n>Actions</div>
|
||||
<div class="col d-none d-sm-block" i18n>Sort Order</div>
|
||||
<div class="col" i18n>Account</div>
|
||||
<div class="col d-none d-sm-block" i18n>Status</div>
|
||||
<div class="col" i18n>Actions</div>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
@@ -128,9 +127,9 @@
|
||||
<li class="list-group-item">
|
||||
<div class="row fade" [class.show]="showRules">
|
||||
<div class="col d-flex align-items-center"><button class="btn btn-link p-0 text-start" type="button" (click)="editMailRule(rule)" [disabled]="!permissionsService.currentUserCan(PermissionAction.Change, PermissionType.MailRule) || !userCanEdit(rule)">{{rule.name}}</button></div>
|
||||
<div class="col-1 d-flex align-items-center d-none d-sm-flex">{{rule.order}}</div>
|
||||
<div class="col-2 d-flex align-items-center">{{(mailAccountService.getCached(rule.account) | async)?.name}}</div>
|
||||
<div class="col-2 d-flex align-items-center d-none d-sm-flex">
|
||||
<div class="col d-flex align-items-center d-none d-sm-flex">{{rule.order}}</div>
|
||||
<div class="col d-flex align-items-center">{{(mailAccountService.getCached(rule.account) | async)?.name}}</div>
|
||||
<div class="col d-flex align-items-center d-none d-sm-flex">
|
||||
<div class="form-check form-switch mb-0">
|
||||
<input #inputField type="checkbox" class="form-check-input cursor-pointer" [id]="rule.id+'_enable'" [(ngModel)]="rule.enabled" (change)="onMailRuleEnableToggled(rule)" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.MailRule }">
|
||||
<label class="form-check-label cursor-pointer" [for]="rule.id+'_enable'">
|
||||
@@ -138,12 +137,7 @@
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col d-flex align-items-center d-none d-sm-flex" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.ProcessedMail }">
|
||||
<button class="btn btn-sm btn-outline-secondary" type="button" (click)="viewProcessedMail(rule)">
|
||||
<i-bs width="1em" height="1em" name="clock-history"></i-bs> <ng-container i18n>View Processed Mail</ng-container>
|
||||
</button>
|
||||
</div>
|
||||
<div class="col-3">
|
||||
<div class="col">
|
||||
<div class="btn-group d-block d-sm-none">
|
||||
<div ngbDropdown container="body" class="d-inline-block">
|
||||
<button type="button" class="btn btn-link" id="actionsMenuMobile" (click)="$event.stopPropagation()" ngbDropdownToggle>
|
||||
|
@@ -409,13 +409,4 @@ describe('MailComponent', () => {
|
||||
jest.advanceTimersByTime(200)
|
||||
expect(editSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should open processed mails dialog', () => {
|
||||
completeSetup()
|
||||
let modal: NgbModalRef
|
||||
modalService.activeInstances.subscribe((refs) => (modal = refs[0]))
|
||||
component.viewProcessedMail(mailRules[0] as MailRule)
|
||||
const dialog = modal.componentInstance as any
|
||||
expect(dialog.rule).toEqual(mailRules[0])
|
||||
})
|
||||
})
|
||||
|
@@ -27,7 +27,6 @@ import { MailRuleEditDialogComponent } from '../../common/edit-dialog/mail-rule-
|
||||
import { PageHeaderComponent } from '../../common/page-header/page-header.component'
|
||||
import { PermissionsDialogComponent } from '../../common/permissions-dialog/permissions-dialog.component'
|
||||
import { ComponentWithPermissions } from '../../with-permissions/with-permissions.component'
|
||||
import { ProcessedMailDialogComponent } from './processed-mail-dialog/processed-mail-dialog.component'
|
||||
|
||||
@Component({
|
||||
selector: 'pngx-mail',
|
||||
@@ -348,14 +347,6 @@ export class MailComponent
|
||||
)
|
||||
}
|
||||
|
||||
viewProcessedMail(rule: MailRule) {
|
||||
const modal = this.modalService.open(ProcessedMailDialogComponent, {
|
||||
backdrop: 'static',
|
||||
size: 'xl',
|
||||
})
|
||||
modal.componentInstance.rule = rule
|
||||
}
|
||||
|
||||
userCanEdit(obj: ObjectWithPermissions): boolean {
|
||||
return this.permissionsService.currentUserHasObjectPermissions(
|
||||
PermissionAction.Change,
|
||||
|
@@ -1,107 +0,0 @@
|
||||
<div class="modal-header">
|
||||
<h6 class="modal-title" id="modal-basic-title" i18n>Processed Mail for <em>{{ rule.name }}</em></h6>
|
||||
<button class="btn btn-sm btn-link text-muted me-auto p-0 p-md-2" title="What's this?" i18n-title type="button" [ngbPopover]="infoPopover" [autoClose]="true">
|
||||
<i-bs name="question-circle"></i-bs>
|
||||
</button>
|
||||
<ng-template #infoPopover>
|
||||
<a href="https://docs.paperless-ngx.com/usage#processed-mail" target="_blank" referrerpolicy="noopener noreferrer" i18n>Read more</a>
|
||||
<i-bs class="ms-1" width=".8em" height=".8em" name="box-arrow-up-right"></i-bs>
|
||||
</ng-template>
|
||||
<button type="button" class="btn-close" aria-label="Close" (click)="close()"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
@if (loading) {
|
||||
<div class="text-center my-5">
|
||||
<div class="spinner-border" role="status">
|
||||
<span class="visually-hidden" i18n>Loading...</span>
|
||||
</div>
|
||||
</div>
|
||||
} @else if (processedMails.length === 0) {
|
||||
<span i18n>No processed email messages found.</span>
|
||||
} @else {
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover table-sm align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col" style="width: 40px;">
|
||||
<div class="form-check m-0 ms-2 me-n2">
|
||||
<input type="checkbox" class="form-check-input" id="all-objects" [(ngModel)]="toggleAllEnabled" [disabled]="processedMails.length === 0" (click)="toggleAll($event); $event.stopPropagation();">
|
||||
<label class="form-check-label" for="all-objects"></label>
|
||||
</div>
|
||||
</th>
|
||||
<th scope="col" i18n>Subject</th>
|
||||
<th scope="col" i18n>Received</th>
|
||||
<th scope="col" i18n>Processed</th>
|
||||
<th scope="col" i18n>Status</th>
|
||||
<th scope="col" i18n>Error</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (mail of processedMails; track mail.id) {
|
||||
<ng-template #statusTooltip>
|
||||
<div class="small text-light font-monospace">
|
||||
{{mail.status}}
|
||||
</div>
|
||||
</ng-template>
|
||||
<tr>
|
||||
<td>
|
||||
<div class="form-check m-0 ms-2 me-n2">
|
||||
<input type="checkbox" class="form-check-input" [id]="mail.id" [checked]="selectedMailIds.has(mail.id)" (click)="toggleSelected(mail); $event.stopPropagation();">
|
||||
<label class="form-check-label" [for]="mail.id"></label>
|
||||
</div>
|
||||
</td>
|
||||
<td>{{ mail.subject }}</td>
|
||||
<td>{{ mail.received | customDate:'longDate' }}</td>
|
||||
<td>{{ mail.processed | customDate:'longDate' }}</td>
|
||||
<td>
|
||||
@switch (mail.status) {
|
||||
@case ('SUCCESS') {
|
||||
<i-bs name="check-circle" title="SUCCESS" class="text-success" [ngbTooltip]="statusTooltip"></i-bs>
|
||||
}
|
||||
@case ('FAILED') {
|
||||
<i-bs name="exclamation-triangle" title="FAILED" class="text-danger" [ngbTooltip]="statusTooltip"></i-bs>
|
||||
}
|
||||
@default {
|
||||
<i-bs name="slash-circle" title="{{ mail.status }}" class="text-muted" [ngbTooltip]="statusTooltip"></i-bs>
|
||||
}
|
||||
}
|
||||
</td>
|
||||
<td>
|
||||
<ng-template #errorPopover>
|
||||
<pre class="small text-light">
|
||||
{{ mail.error }}
|
||||
</pre>
|
||||
</ng-template>
|
||||
@if (mail.error) {
|
||||
<span class="text-danger" triggers="mouseenter:mouseleave" [ngbPopover]="errorPopover">{{ mail.error | slice:0:20 }}</span>
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="btn-toolbar">
|
||||
<button type="button" class="btn btn-outline-secondary me-2" (click)="clearSelection()" [disabled]="selectedMailIds.size === 0" i18n>Clear</button>
|
||||
<pngx-confirm-button
|
||||
label="Delete selected"
|
||||
i18n-label
|
||||
title="Delete selected"
|
||||
i18n-title
|
||||
buttonClasses="btn-outline-danger"
|
||||
iconName="trash"
|
||||
[disabled]="selectedMailIds.size === 0"
|
||||
(confirm)="deleteSelected()">
|
||||
</pngx-confirm-button>
|
||||
<div class="ms-auto">
|
||||
<ngb-pagination
|
||||
[collectionSize]="processedMails.length"
|
||||
[(page)]="page"
|
||||
[pageSize]="50"
|
||||
[maxSize]="5"
|
||||
(pageChange)="loadProcessedMails()">
|
||||
</ngb-pagination>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
@@ -1,8 +0,0 @@
|
||||
::ng-deep .popover {
|
||||
max-width: 350px;
|
||||
|
||||
pre {
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
}
|
@@ -1,150 +0,0 @@
|
||||
import { DatePipe } from '@angular/common'
|
||||
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
|
||||
import {
|
||||
HttpTestingController,
|
||||
provideHttpClientTesting,
|
||||
} from '@angular/common/http/testing'
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||
import { FormsModule } from '@angular/forms'
|
||||
import { By } from '@angular/platform-browser'
|
||||
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
||||
import { ToastService } from 'src/app/services/toast.service'
|
||||
import { environment } from 'src/environments/environment'
|
||||
import { ProcessedMailDialogComponent } from './processed-mail-dialog.component'
|
||||
|
||||
describe('ProcessedMailDialogComponent', () => {
|
||||
let component: ProcessedMailDialogComponent
|
||||
let fixture: ComponentFixture<ProcessedMailDialogComponent>
|
||||
let httpTestingController: HttpTestingController
|
||||
let toastService: ToastService
|
||||
|
||||
const rule: any = { id: 10, name: 'Mail Rule' } // minimal rule object for tests
|
||||
const mails = [
|
||||
{
|
||||
id: 1,
|
||||
rule: rule.id,
|
||||
folder: 'INBOX',
|
||||
uid: 111,
|
||||
subject: 'A',
|
||||
received: new Date().toISOString(),
|
||||
processed: new Date().toISOString(),
|
||||
status: 'SUCCESS',
|
||||
error: null,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
rule: rule.id,
|
||||
folder: 'INBOX',
|
||||
uid: 222,
|
||||
subject: 'B',
|
||||
received: new Date().toISOString(),
|
||||
processed: new Date().toISOString(),
|
||||
status: 'FAILED',
|
||||
error: 'Oops',
|
||||
},
|
||||
]
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [
|
||||
ProcessedMailDialogComponent,
|
||||
FormsModule,
|
||||
NgxBootstrapIconsModule.pick(allIcons),
|
||||
],
|
||||
providers: [
|
||||
DatePipe,
|
||||
NgbActiveModal,
|
||||
provideHttpClient(withInterceptorsFromDi()),
|
||||
provideHttpClientTesting(),
|
||||
],
|
||||
}).compileComponents()
|
||||
|
||||
httpTestingController = TestBed.inject(HttpTestingController)
|
||||
toastService = TestBed.inject(ToastService)
|
||||
fixture = TestBed.createComponent(ProcessedMailDialogComponent)
|
||||
component = fixture.componentInstance
|
||||
component.rule = rule
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
httpTestingController.verify()
|
||||
})
|
||||
|
||||
function expectListRequest(ruleId: number) {
|
||||
const req = httpTestingController.expectOne(
|
||||
`${environment.apiBaseUrl}processed_mail/?page=1&page_size=50&ordering=-processed_at&rule=${ruleId}`
|
||||
)
|
||||
expect(req.request.method).toEqual('GET')
|
||||
return req
|
||||
}
|
||||
|
||||
it('should load processed mails on init', () => {
|
||||
fixture.detectChanges()
|
||||
const req = expectListRequest(rule.id)
|
||||
req.flush({ count: 2, results: mails })
|
||||
expect(component.loading).toBeFalsy()
|
||||
expect(component.processedMails).toEqual(mails)
|
||||
})
|
||||
|
||||
it('should delete selected mails and reload', () => {
|
||||
fixture.detectChanges()
|
||||
// initial load
|
||||
const initialReq = expectListRequest(rule.id)
|
||||
initialReq.flush({ count: 0, results: [] })
|
||||
|
||||
// select a couple of mails and delete
|
||||
component.selectedMailIds.add(5)
|
||||
component.selectedMailIds.add(6)
|
||||
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
|
||||
component.deleteSelected()
|
||||
|
||||
const delReq = httpTestingController.expectOne(
|
||||
`${environment.apiBaseUrl}processed_mail/bulk_delete/`
|
||||
)
|
||||
expect(delReq.request.method).toEqual('POST')
|
||||
expect(delReq.request.body).toEqual({ mail_ids: [5, 6] })
|
||||
delReq.flush({})
|
||||
|
||||
// reload after delete
|
||||
const reloadReq = expectListRequest(rule.id)
|
||||
reloadReq.flush({ count: 0, results: [] })
|
||||
expect(toastInfoSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should toggle all, toggle selected, and clear selection', () => {
|
||||
fixture.detectChanges()
|
||||
// initial load with two mails
|
||||
const req = expectListRequest(rule.id)
|
||||
req.flush({ count: 2, results: mails })
|
||||
fixture.detectChanges()
|
||||
|
||||
// toggle all via header checkbox
|
||||
const inputs = fixture.debugElement.queryAll(
|
||||
By.css('input.form-check-input')
|
||||
)
|
||||
const header = inputs[0].nativeElement as HTMLInputElement
|
||||
header.dispatchEvent(new Event('click'))
|
||||
header.checked = true
|
||||
header.dispatchEvent(new Event('click'))
|
||||
expect(component.selectedMailIds.size).toEqual(mails.length)
|
||||
|
||||
// toggle a single mail
|
||||
component.toggleSelected(mails[0] as any)
|
||||
expect(component.selectedMailIds.has(mails[0].id)).toBeFalsy()
|
||||
component.toggleSelected(mails[0] as any)
|
||||
expect(component.selectedMailIds.has(mails[0].id)).toBeTruthy()
|
||||
|
||||
// clear selection
|
||||
component.clearSelection()
|
||||
expect(component.selectedMailIds.size).toEqual(0)
|
||||
expect(component.toggleAllEnabled).toBeFalsy()
|
||||
})
|
||||
|
||||
it('should close the dialog', () => {
|
||||
const activeModal = TestBed.inject(NgbActiveModal)
|
||||
const closeSpy = jest.spyOn(activeModal, 'close')
|
||||
component.close()
|
||||
expect(closeSpy).toHaveBeenCalled()
|
||||
})
|
||||
})
|
@@ -1,96 +0,0 @@
|
||||
import { SlicePipe } from '@angular/common'
|
||||
import { Component, inject, Input, OnInit } from '@angular/core'
|
||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||
import {
|
||||
NgbActiveModal,
|
||||
NgbPagination,
|
||||
NgbPopoverModule,
|
||||
NgbTooltipModule,
|
||||
} from '@ng-bootstrap/ng-bootstrap'
|
||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||
import { ConfirmButtonComponent } from 'src/app/components/common/confirm-button/confirm-button.component'
|
||||
import { MailRule } from 'src/app/data/mail-rule'
|
||||
import { ProcessedMail } from 'src/app/data/processed-mail'
|
||||
import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
|
||||
import { ProcessedMailService } from 'src/app/services/rest/processed-mail.service'
|
||||
import { ToastService } from 'src/app/services/toast.service'
|
||||
|
||||
@Component({
|
||||
selector: 'pngx-processed-mail-dialog',
|
||||
imports: [
|
||||
ConfirmButtonComponent,
|
||||
CustomDatePipe,
|
||||
NgbPagination,
|
||||
NgbPopoverModule,
|
||||
NgbTooltipModule,
|
||||
NgxBootstrapIconsModule,
|
||||
FormsModule,
|
||||
ReactiveFormsModule,
|
||||
SlicePipe,
|
||||
],
|
||||
templateUrl: './processed-mail-dialog.component.html',
|
||||
styleUrl: './processed-mail-dialog.component.scss',
|
||||
})
|
||||
export class ProcessedMailDialogComponent implements OnInit {
|
||||
private readonly activeModal = inject(NgbActiveModal)
|
||||
private readonly processedMailService = inject(ProcessedMailService)
|
||||
private readonly toastService = inject(ToastService)
|
||||
|
||||
public processedMails: ProcessedMail[] = []
|
||||
|
||||
public loading: boolean = true
|
||||
public toggleAllEnabled: boolean = false
|
||||
public readonly selectedMailIds: Set<number> = new Set<number>()
|
||||
|
||||
public page: number = 1
|
||||
|
||||
@Input() rule: MailRule
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadProcessedMails()
|
||||
}
|
||||
|
||||
public close() {
|
||||
this.activeModal.close()
|
||||
}
|
||||
|
||||
private loadProcessedMails(): void {
|
||||
this.loading = true
|
||||
this.clearSelection()
|
||||
this.processedMailService
|
||||
.list(this.page, 50, 'processed_at', true, { rule: this.rule.id })
|
||||
.subscribe((result) => {
|
||||
this.processedMails = result.results
|
||||
this.loading = false
|
||||
})
|
||||
}
|
||||
|
||||
public deleteSelected(): void {
|
||||
this.processedMailService
|
||||
.bulk_delete(Array.from(this.selectedMailIds))
|
||||
.subscribe(() => {
|
||||
this.toastService.showInfo($localize`Processed mail(s) deleted`)
|
||||
this.loadProcessedMails()
|
||||
})
|
||||
}
|
||||
|
||||
public toggleAll(event: PointerEvent) {
|
||||
if ((event.target as HTMLInputElement).checked) {
|
||||
this.selectedMailIds.clear()
|
||||
this.processedMails.forEach((mail) => this.selectedMailIds.add(mail.id))
|
||||
} else {
|
||||
this.clearSelection()
|
||||
}
|
||||
}
|
||||
|
||||
public clearSelection() {
|
||||
this.toggleAllEnabled = false
|
||||
this.selectedMailIds.clear()
|
||||
}
|
||||
|
||||
public toggleSelected(mail: ProcessedMail) {
|
||||
this.selectedMailIds.has(mail.id)
|
||||
? this.selectedMailIds.delete(mail.id)
|
||||
: this.selectedMailIds.add(mail.id)
|
||||
}
|
||||
}
|
@@ -54,7 +54,61 @@
|
||||
</tr>
|
||||
}
|
||||
@for (object of data; track object) {
|
||||
<ng-container [ngTemplateOutlet]="objectRow" [ngTemplateOutletContext]="{ object: object, depth: 0 }"></ng-container>
|
||||
<tr (click)="toggleSelected(object); $event.stopPropagation();" class="data-row fade" [class.show]="show">
|
||||
<td>
|
||||
<div class="form-check m-0 ms-2 me-n2">
|
||||
<input type="checkbox" class="form-check-input" id="{{typeName}}{{object.id}}" [checked]="selectedObjects.has(object.id)" (click)="toggleSelected(object); $event.stopPropagation();">
|
||||
<label class="form-check-label" for="{{typeName}}{{object.id}}"></label>
|
||||
</div>
|
||||
</td>
|
||||
<td scope="row"><button class="btn btn-link ms-0 ps-0 text-start" (click)="userCanEdit(object) ? openEditDialog(object) : null; $event.stopPropagation()">{{ object.name }}</button> </td>
|
||||
<td scope="row" class="d-none d-sm-table-cell">{{ getMatching(object) }}</td>
|
||||
<td scope="row">{{ object.document_count }}</td>
|
||||
@for (column of extraColumns; track column) {
|
||||
<td scope="row" [ngClass]="{ 'd-none d-sm-table-cell' : column.hideOnMobile }">
|
||||
@if (column.rendersHtml) {
|
||||
<div [innerHtml]="column.valueFn.call(null, object) | safeHtml"></div>
|
||||
} @else if (column.monospace) {
|
||||
<span class="font-monospace">{{ column.valueFn.call(null, object) }}</span>
|
||||
} @else {
|
||||
{{ column.valueFn.call(null, object) }}
|
||||
}
|
||||
</td>
|
||||
}
|
||||
<td scope="row">
|
||||
<div class="btn-toolbar gap-2">
|
||||
<div class="btn-group d-block d-sm-none">
|
||||
<div ngbDropdown container="body" class="d-inline-block">
|
||||
<button type="button" class="btn btn-link" id="actionsMenuMobile" (click)="$event.stopPropagation()" ngbDropdownToggle>
|
||||
<i-bs name="three-dots-vertical"></i-bs>
|
||||
</button>
|
||||
<div ngbDropdownMenu aria-labelledby="actionsMenuMobile">
|
||||
<button (click)="openEditDialog(object)" *pngxIfPermissions="{ action: PermissionAction.Change, type: permissionType }" ngbDropdownItem i18n>Edit</button>
|
||||
<button class="text-danger" (click)="openDeleteDialog(object)" *pngxIfPermissions="{ action: PermissionAction.Delete, type: permissionType }" ngbDropdownItem i18n>Delete</button>
|
||||
@if (object.document_count > 0) {
|
||||
<button (click)="filterDocuments(object)" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }" ngbDropdownItem i18n>Filter Documents ({{ object.document_count }})</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="btn-group d-none d-sm-inline-block">
|
||||
<button class="btn btn-sm btn-outline-secondary" (click)="openEditDialog(object); $event.stopPropagation();" *pngxIfPermissions="{ action: PermissionAction.Change, type: permissionType }" [disabled]="!userCanEdit(object)">
|
||||
<i-bs width="1em" height="1em" name="pencil"></i-bs> <ng-container i18n>Edit</ng-container>
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-danger" (click)="openDeleteDialog(object); $event.stopPropagation();" *pngxIfPermissions="{ action: PermissionAction.Delete, type: permissionType }" [disabled]="!userCanDelete(object)">
|
||||
<i-bs width="1em" height="1em" name="trash"></i-bs> <ng-container i18n>Delete</ng-container>
|
||||
</button>
|
||||
</div>
|
||||
@if (object.document_count > 0) {
|
||||
<div class="btn-group d-none d-sm-inline-block">
|
||||
<button class="btn btn-sm btn-outline-secondary" (click)="filterDocuments(object); $event.stopPropagation();" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }">
|
||||
<i-bs width="1em" height="1em" name="filter"></i-bs> <ng-container i18n>Documents</ng-container><span class="badge bg-light text-secondary ms-2">{{ object.document_count }}</span>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
@@ -75,72 +129,3 @@
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<ng-template #objectRow let-object="object" let-depth="depth">
|
||||
<tr (click)="toggleSelected(object); $event.stopPropagation();" class="data-row fade" [class.show]="show">
|
||||
<td>
|
||||
<div class="form-check m-0 ms-2 me-n2">
|
||||
<input type="checkbox" class="form-check-input" id="{{typeName}}{{object.id}}" [checked]="selectedObjects.has(object.id)" (click)="toggleSelected(object); $event.stopPropagation();">
|
||||
<label class="form-check-label" for="{{typeName}}{{object.id}}"></label>
|
||||
</div>
|
||||
</td>
|
||||
<td scope="row" class="name-cell" style="--depth: {{depth}}">
|
||||
@if (depth > 0) {
|
||||
<div class="indicator"></div>
|
||||
}
|
||||
<button class="btn btn-link ms-0 ps-0 text-start" (click)="userCanEdit(object) ? openEditDialog(object) : null; $event.stopPropagation()">{{ object.name }}</button>
|
||||
</td>
|
||||
<td scope="row" class="d-none d-sm-table-cell">{{ getMatching(object) }}</td>
|
||||
<td scope="row">{{ getDocumentCount(object) }}</td>
|
||||
@for (column of extraColumns; track column) {
|
||||
<td scope="row" [ngClass]="{ 'd-none d-sm-table-cell' : column.hideOnMobile }">
|
||||
@if (column.rendersHtml) {
|
||||
<div [innerHtml]="column.valueFn.call(null, object) | safeHtml"></div>
|
||||
} @else if (column.monospace) {
|
||||
<span class="font-monospace">{{ column.valueFn.call(null, object) }}</span>
|
||||
} @else {
|
||||
{{ column.valueFn.call(null, object) }}
|
||||
}
|
||||
</td>
|
||||
}
|
||||
<td scope="row">
|
||||
<div class="btn-toolbar gap-2">
|
||||
<div class="btn-group d-block d-sm-none">
|
||||
<div ngbDropdown container="body" class="d-inline-block">
|
||||
<button type="button" class="btn btn-link" id="actionsMenuMobile" (click)="$event.stopPropagation()" ngbDropdownToggle>
|
||||
<i-bs name="three-dots-vertical"></i-bs>
|
||||
</button>
|
||||
<div ngbDropdownMenu aria-labelledby="actionsMenuMobile">
|
||||
<button (click)="openEditDialog(object)" *pngxIfPermissions="{ action: PermissionAction.Change, type: permissionType }" ngbDropdownItem i18n>Edit</button>
|
||||
<button class="text-danger" (click)="openDeleteDialog(object)" *pngxIfPermissions="{ action: PermissionAction.Delete, type: permissionType }" ngbDropdownItem i18n>Delete</button>
|
||||
@if (getDocumentCount(object) > 0) {
|
||||
<button (click)="filterDocuments(object)" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }" ngbDropdownItem i18n>Filter Documents ({{ getDocumentCount(object) }})</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="btn-group d-none d-sm-inline-block">
|
||||
<button class="btn btn-sm btn-outline-secondary" (click)="openEditDialog(object); $event.stopPropagation();" *pngxIfPermissions="{ action: PermissionAction.Change, type: permissionType }" [disabled]="!userCanEdit(object)">
|
||||
<i-bs width="1em" height="1em" name="pencil"></i-bs> <ng-container i18n>Edit</ng-container>
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-danger" (click)="openDeleteDialog(object); $event.stopPropagation();" *pngxIfPermissions="{ action: PermissionAction.Delete, type: permissionType }" [disabled]="!userCanDelete(object)">
|
||||
<i-bs width="1em" height="1em" name="trash"></i-bs> <ng-container i18n>Delete</ng-container>
|
||||
</button>
|
||||
</div>
|
||||
@if (getDocumentCount(object) > 0) {
|
||||
<div class="btn-group d-none d-sm-inline-block">
|
||||
<button class="btn btn-sm btn-outline-secondary" (click)="filterDocuments(object); $event.stopPropagation();" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }">
|
||||
<i-bs width="1em" height="1em" name="filter"></i-bs> <ng-container i18n>Documents</ng-container><span class="badge bg-light text-secondary ms-2">{{ getDocumentCount(object) }}</span>
|
||||
</button>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@if (object.children && object.children.length > 0) {
|
||||
@for (child of object.children; track child) {
|
||||
<ng-container [ngTemplateOutlet]="objectRow" [ngTemplateOutletContext]="{ object: child, depth: depth + 1 }"></ng-container>
|
||||
}
|
||||
}
|
||||
</ng-template>
|
||||
|
@@ -10,17 +10,3 @@ tbody tr:last-child td {
|
||||
.form-check {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
td.name-cell {
|
||||
padding-left: calc(calc(var(--depth) - 1) * 1.1rem);
|
||||
|
||||
.indicator {
|
||||
display: inline-block;
|
||||
width: .8rem;
|
||||
height: .8rem;
|
||||
border-left: 1px solid var(--bs-secondary);
|
||||
border-bottom: 1px solid var(--bs-secondary);
|
||||
margin-right: .25rem;
|
||||
margin-left: .5rem;
|
||||
}
|
||||
}
|
||||
|
@@ -79,7 +79,6 @@ export abstract class ManagementListComponent<T extends MatchingModel>
|
||||
@ViewChildren(SortableDirective) headers: QueryList<SortableDirective>
|
||||
|
||||
public data: T[] = []
|
||||
private unfilteredData: T[] = []
|
||||
|
||||
public page = 1
|
||||
|
||||
@@ -133,18 +132,6 @@ export abstract class ManagementListComponent<T extends MatchingModel>
|
||||
this.reloadData()
|
||||
}
|
||||
|
||||
protected filterData(data: T[]): T[] {
|
||||
return data
|
||||
}
|
||||
|
||||
getDocumentCount(object: MatchingModel): number {
|
||||
return (
|
||||
object.document_count ??
|
||||
this.unfilteredData.find((d) => d.id == object.id)?.document_count ??
|
||||
0
|
||||
)
|
||||
}
|
||||
|
||||
reloadData(extraParams: { [key: string]: any } = null) {
|
||||
this.loading = true
|
||||
this.clearSelection()
|
||||
@@ -161,8 +148,7 @@ export abstract class ManagementListComponent<T extends MatchingModel>
|
||||
.pipe(
|
||||
takeUntil(this.unsubscribeNotifier),
|
||||
tap((c) => {
|
||||
this.unfilteredData = c.results
|
||||
this.data = this.filterData(c.results)
|
||||
this.data = c.results
|
||||
this.collectionSize = c.count
|
||||
}),
|
||||
delay(100)
|
||||
|
@@ -1,4 +1,4 @@
|
||||
import { NgClass, NgTemplateOutlet, TitleCasePipe } from '@angular/common'
|
||||
import { NgClass, TitleCasePipe } from '@angular/common'
|
||||
import { Component, inject } from '@angular/core'
|
||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||
import {
|
||||
@@ -30,7 +30,6 @@ import { ManagementListComponent } from '../management-list/management-list.comp
|
||||
FormsModule,
|
||||
ReactiveFormsModule,
|
||||
NgClass,
|
||||
NgTemplateOutlet,
|
||||
NgbDropdownModule,
|
||||
NgbPaginationModule,
|
||||
NgxBootstrapIconsModule,
|
||||
|
@@ -71,20 +71,4 @@ describe('TagListComponent', () => {
|
||||
'Do you really want to delete the tag "Tag1"?'
|
||||
)
|
||||
})
|
||||
|
||||
it('should filter out child tags if name filter is empty, otherwise show all', () => {
|
||||
const tags = [
|
||||
{ id: 1, name: 'Tag1', parent: null },
|
||||
{ id: 2, name: 'Tag2', parent: 1 },
|
||||
{ id: 3, name: 'Tag3', parent: null },
|
||||
]
|
||||
component['_nameFilter'] = null // Simulate empty name filter
|
||||
const filtered = component.filterData(tags as any)
|
||||
expect(filtered.length).toBe(2)
|
||||
expect(filtered.find((t) => t.id === 2)).toBeUndefined()
|
||||
|
||||
component['_nameFilter'] = 'Tag2' // Simulate non-empty name filter
|
||||
const filteredWithName = component.filterData(tags as any)
|
||||
expect(filteredWithName.length).toBe(3)
|
||||
})
|
||||
})
|
||||
|
@@ -1,4 +1,4 @@
|
||||
import { NgClass, NgTemplateOutlet, TitleCasePipe } from '@angular/common'
|
||||
import { NgClass, TitleCasePipe } from '@angular/common'
|
||||
import { Component, inject } from '@angular/core'
|
||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||
import {
|
||||
@@ -30,7 +30,6 @@ import { ManagementListComponent } from '../management-list/management-list.comp
|
||||
FormsModule,
|
||||
ReactiveFormsModule,
|
||||
NgClass,
|
||||
NgTemplateOutlet,
|
||||
NgbDropdownModule,
|
||||
NgbPaginationModule,
|
||||
NgxBootstrapIconsModule,
|
||||
@@ -60,10 +59,4 @@ export class TagListComponent extends ManagementListComponent<Tag> {
|
||||
getDeleteMessage(object: Tag) {
|
||||
return $localize`Do you really want to delete the tag "${object.name}"?`
|
||||
}
|
||||
|
||||
filterData(data: Tag[]) {
|
||||
return this.nameFilter?.length
|
||||
? [...data]
|
||||
: data.filter((tag) => !tag.parent)
|
||||
}
|
||||
}
|
||||
|
@@ -114,10 +114,6 @@ export const CUSTOM_FIELD_QUERY_OPERATOR_GROUPS_BY_TYPE = {
|
||||
CustomFieldQueryOperatorGroups.Exact,
|
||||
CustomFieldQueryOperatorGroups.Subset,
|
||||
],
|
||||
[CustomFieldDataType.LongText]: [
|
||||
CustomFieldQueryOperatorGroups.Basic,
|
||||
CustomFieldQueryOperatorGroups.String,
|
||||
],
|
||||
}
|
||||
|
||||
export const CUSTOM_FIELD_QUERY_VALUE_TYPES_BY_OPERATOR = {
|
||||
|
@@ -10,7 +10,6 @@ export enum CustomFieldDataType {
|
||||
Monetary = 'monetary',
|
||||
DocumentLink = 'documentlink',
|
||||
Select = 'select',
|
||||
LongText = 'longtext',
|
||||
}
|
||||
|
||||
export const DATA_TYPE_LABELS = [
|
||||
@@ -50,10 +49,6 @@ export const DATA_TYPE_LABELS = [
|
||||
id: CustomFieldDataType.Select,
|
||||
name: $localize`Select`,
|
||||
},
|
||||
{
|
||||
id: CustomFieldDataType.LongText,
|
||||
name: $localize`Long Text`,
|
||||
},
|
||||
]
|
||||
|
||||
export interface CustomField extends ObjectWithId {
|
||||
|
@@ -1,12 +0,0 @@
|
||||
import { ObjectWithId } from './object-with-id'
|
||||
|
||||
export interface ProcessedMail extends ObjectWithId {
|
||||
rule: number // MailRule.id
|
||||
folder: string
|
||||
uid: number
|
||||
subject: string
|
||||
received: Date
|
||||
processed: Date
|
||||
status: string
|
||||
error: string
|
||||
}
|
@@ -44,5 +44,4 @@ export interface SystemStatus {
|
||||
sanity_check_last_run: string // ISO date string
|
||||
sanity_check_error: string
|
||||
}
|
||||
websocket_connected?: SystemStatusItemStatus // added client-side
|
||||
}
|
||||
|
@@ -6,12 +6,4 @@ export interface Tag extends MatchingModel {
|
||||
text_color?: string
|
||||
|
||||
is_inbox_tag?: boolean
|
||||
|
||||
parent?: number // Tag ID
|
||||
|
||||
children?: Tag[] // read-only
|
||||
|
||||
// UI-only: computed depth and order for hierarchical dropdowns
|
||||
depth?: number
|
||||
orderIndex?: number
|
||||
}
|
||||
|
@@ -44,8 +44,6 @@ export interface WorkflowTrigger extends ObjectWithId {
|
||||
|
||||
filter_has_document_type?: number // DocumentType.id
|
||||
|
||||
filter_has_storage_path?: number // StoragePath.id
|
||||
|
||||
schedule_offset_days?: number
|
||||
|
||||
schedule_is_recurring?: boolean
|
||||
|
@@ -28,7 +28,6 @@ export enum PermissionType {
|
||||
ShareLink = '%s_sharelink',
|
||||
CustomField = '%s_customfield',
|
||||
Workflow = '%s_workflow',
|
||||
ProcessedMail = '%s_processedmail',
|
||||
}
|
||||
|
||||
@Injectable({
|
||||
|
@@ -1,39 +0,0 @@
|
||||
import { HttpTestingController } from '@angular/common/http/testing'
|
||||
import { TestBed } from '@angular/core/testing'
|
||||
import { Subscription } from 'rxjs'
|
||||
import { environment } from 'src/environments/environment'
|
||||
import { commonAbstractPaperlessServiceTests } from './abstract-paperless-service.spec'
|
||||
import { ProcessedMailService } from './processed-mail.service'
|
||||
|
||||
let httpTestingController: HttpTestingController
|
||||
let service: ProcessedMailService
|
||||
let subscription: Subscription
|
||||
const endpoint = 'processed_mail'
|
||||
|
||||
// run common tests
|
||||
commonAbstractPaperlessServiceTests(endpoint, ProcessedMailService)
|
||||
|
||||
describe('Additional service tests for ProcessedMailService', () => {
|
||||
beforeEach(() => {
|
||||
// Dont need to setup again
|
||||
|
||||
httpTestingController = TestBed.inject(HttpTestingController)
|
||||
service = TestBed.inject(ProcessedMailService)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
subscription?.unsubscribe()
|
||||
httpTestingController.verify()
|
||||
})
|
||||
|
||||
it('should call appropriate api endpoint for bulk delete', () => {
|
||||
const ids = [1, 2, 3]
|
||||
subscription = service.bulk_delete(ids).subscribe()
|
||||
const req = httpTestingController.expectOne(
|
||||
`${environment.apiBaseUrl}${endpoint}/bulk_delete/`
|
||||
)
|
||||
expect(req.request.method).toEqual('POST')
|
||||
expect(req.request.body).toEqual({ mail_ids: ids })
|
||||
req.flush({})
|
||||
})
|
||||
})
|
@@ -1,19 +0,0 @@
|
||||
import { Injectable } from '@angular/core'
|
||||
import { ProcessedMail } from 'src/app/data/processed-mail'
|
||||
import { AbstractPaperlessService } from './abstract-paperless-service'
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class ProcessedMailService extends AbstractPaperlessService<ProcessedMail> {
|
||||
constructor() {
|
||||
super()
|
||||
this.resourceName = 'processed_mail'
|
||||
}
|
||||
|
||||
public bulk_delete(mailIds: number[]) {
|
||||
return this.http.post(`${this.getResourceUrl()}bulk_delete/`, {
|
||||
mail_ids: mailIds,
|
||||
})
|
||||
}
|
||||
}
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user