mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2025-10-10 02:16:12 -05:00
Compare commits
3 Commits
feature-wf
...
chore/disa
Author | SHA1 | Date | |
---|---|---|---|
![]() |
1f461a7ee4 | ||
![]() |
a2ce0af79f | ||
![]() |
ba40626838 |
@@ -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
|
||||
|
73
.github/workflows/ci.yml
vendored
73
.github/workflows/ci.yml
vendored
@@ -17,59 +17,18 @@ env:
|
||||
DEFAULT_PYTHON_VERSION: "3.11"
|
||||
NLTK_DATA: "/usr/share/nltk_data"
|
||||
jobs:
|
||||
detect-duplicate:
|
||||
name: Detect Duplicate Run
|
||||
runs-on: ubuntu-24.04
|
||||
outputs:
|
||||
should_run: ${{ steps.check.outputs.should_run }}
|
||||
steps:
|
||||
- name: Check if workflow should run
|
||||
id: check
|
||||
uses: actions/github-script@v8
|
||||
with:
|
||||
github-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
script: |
|
||||
if (context.eventName !== 'push') {
|
||||
core.info('Not a push event; running workflow.');
|
||||
core.setOutput('should_run', 'true');
|
||||
return;
|
||||
}
|
||||
|
||||
const ref = context.ref || '';
|
||||
if (!ref.startsWith('refs/heads/')) {
|
||||
core.info('Push is not to a branch; running workflow.');
|
||||
core.setOutput('should_run', 'true');
|
||||
return;
|
||||
}
|
||||
|
||||
const branch = ref.substring('refs/heads/'.length);
|
||||
const { owner, repo } = context.repo;
|
||||
const prs = await github.paginate(github.rest.pulls.list, {
|
||||
owner,
|
||||
repo,
|
||||
state: 'open',
|
||||
head: `${owner}:${branch}`,
|
||||
per_page: 100,
|
||||
});
|
||||
|
||||
if (prs.length === 0) {
|
||||
core.info(`No open PR found for ${branch}; running workflow.`);
|
||||
core.setOutput('should_run', 'true');
|
||||
} else {
|
||||
core.info(`Found ${prs.length} open PR(s) for ${branch}; skipping duplicate push run.`);
|
||||
core.setOutput('should_run', 'false');
|
||||
}
|
||||
pre-commit:
|
||||
needs:
|
||||
- detect-duplicate
|
||||
if: needs.detect-duplicate.outputs.should_run == 'true'
|
||||
# We want to run on external PRs, but not on our own internal PRs as they'll be run
|
||||
# by the push to the branch. Without this if check, checks are duplicated since
|
||||
# internal PRs match both the push and pull_request events.
|
||||
if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name != github.repository
|
||||
name: Linting Checks
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@v5
|
||||
- name: Install python
|
||||
uses: actions/setup-python@v6
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
|
||||
- name: Check files
|
||||
@@ -84,7 +43,7 @@ jobs:
|
||||
uses: actions/checkout@v5
|
||||
- name: Set up Python
|
||||
id: setup-python
|
||||
uses: actions/setup-python@v6
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
|
||||
- name: Install uv
|
||||
@@ -138,7 +97,7 @@ jobs:
|
||||
docker compose --file ${{ github.workspace }}/docker/compose/docker-compose.ci-test.yml up --detach
|
||||
- name: Set up Python
|
||||
id: setup-python
|
||||
uses: actions/setup-python@v6
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: "${{ matrix.python-version }}"
|
||||
- name: Install uv
|
||||
@@ -183,11 +142,13 @@ jobs:
|
||||
if: always()
|
||||
uses: codecov/test-results-action@v1
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
flags: backend-python-${{ matrix.python-version }}
|
||||
files: junit.xml
|
||||
- name: Upload backend coverage to Codecov
|
||||
uses: codecov/codecov-action@v5
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
flags: backend-python-${{ matrix.python-version }}
|
||||
files: coverage.xml
|
||||
- name: Stop containers
|
||||
@@ -207,7 +168,7 @@ jobs:
|
||||
with:
|
||||
version: 10
|
||||
- name: Use Node.js 20
|
||||
uses: actions/setup-node@v5
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20.x
|
||||
cache: 'pnpm'
|
||||
@@ -240,7 +201,7 @@ jobs:
|
||||
with:
|
||||
version: 10
|
||||
- name: Use Node.js 20
|
||||
uses: actions/setup-node@v5
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20.x
|
||||
cache: 'pnpm'
|
||||
@@ -263,11 +224,13 @@ jobs:
|
||||
uses: codecov/test-results-action@v1
|
||||
if: always()
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
flags: frontend-node-${{ matrix.node-version }}
|
||||
directory: src-ui/
|
||||
- name: Upload frontend coverage to Codecov
|
||||
uses: codecov/codecov-action@v5
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
flags: frontend-node-${{ matrix.node-version }}
|
||||
directory: src-ui/coverage/
|
||||
tests-frontend-e2e:
|
||||
@@ -288,7 +251,7 @@ jobs:
|
||||
with:
|
||||
version: 10
|
||||
- name: Use Node.js 20
|
||||
uses: actions/setup-node@v5
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20.x
|
||||
cache: 'pnpm'
|
||||
@@ -331,7 +294,7 @@ jobs:
|
||||
with:
|
||||
version: 10
|
||||
- name: Use Node.js 20
|
||||
uses: actions/setup-node@v5
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20.x
|
||||
cache: 'pnpm'
|
||||
@@ -473,7 +436,7 @@ jobs:
|
||||
uses: actions/checkout@v5
|
||||
- name: Set up Python
|
||||
id: setup-python
|
||||
uses: actions/setup-python@v6
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
|
||||
- name: Install uv
|
||||
@@ -621,7 +584,7 @@ jobs:
|
||||
ref: main
|
||||
- name: Set up Python
|
||||
id: setup-python
|
||||
uses: actions/setup-python@v6
|
||||
uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
|
||||
- name: Install uv
|
||||
@@ -653,7 +616,7 @@ jobs:
|
||||
git commit -am "Changelog ${{ needs.publish-release.outputs.version }} - GHA"
|
||||
git push origin ${{ needs.publish-release.outputs.version }}-changelog
|
||||
- name: Create Pull Request
|
||||
uses: actions/github-script@v8
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const { repo, owner } = context.repo;
|
||||
|
7
.github/workflows/cleanup-tags.yml
vendored
7
.github/workflows/cleanup-tags.yml
vendored
@@ -6,9 +6,10 @@
|
||||
# This workflow will not trigger runs on forked repos.
|
||||
name: Cleanup Image Tags
|
||||
on:
|
||||
workflow_dispatch:
|
||||
schedule:
|
||||
- cron: '0 0 * * 0'
|
||||
delete:
|
||||
push:
|
||||
paths:
|
||||
- ".github/workflows/cleanup-tags.yml"
|
||||
concurrency:
|
||||
group: registry-tags-cleanup
|
||||
cancel-in-progress: false
|
||||
|
8
.github/workflows/pr-bot.yml
vendored
8
.github/workflows/pr-bot.yml
vendored
@@ -12,7 +12,7 @@ jobs:
|
||||
steps:
|
||||
- name: Label PR by file path or branch name
|
||||
# see .github/labeler.yml for the labeler config
|
||||
uses: actions/labeler@v6
|
||||
uses: actions/labeler@v5
|
||||
with:
|
||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Label by size
|
||||
@@ -26,7 +26,7 @@ jobs:
|
||||
fail_if_xl: 'false'
|
||||
excluded_files: /\.lock$/ /\.txt$/ ^src-ui/pnpm-lock\.yaml$ ^src-ui/messages\.xlf$ ^src/locale/en_US/LC_MESSAGES/django\.po$
|
||||
- name: Label by PR title
|
||||
uses: actions/github-script@v8
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const pr = context.payload.pull_request;
|
||||
@@ -52,7 +52,7 @@ jobs:
|
||||
}
|
||||
- name: Label bot-generated PRs
|
||||
if: ${{ contains(github.actor, 'dependabot') || contains(github.actor, 'crowdin-bot') }}
|
||||
uses: actions/github-script@v8
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const pr = context.payload.pull_request;
|
||||
@@ -77,7 +77,7 @@ jobs:
|
||||
}
|
||||
- name: Welcome comment
|
||||
if: ${{ !contains(github.actor, 'bot') }}
|
||||
uses: actions/github-script@v8
|
||||
uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
const pr = context.payload.pull_request;
|
||||
|
9
.github/workflows/repo-maintenance.yml
vendored
9
.github/workflows/repo-maintenance.yml
vendored
@@ -15,7 +15,7 @@ jobs:
|
||||
if: github.repository_owner == 'paperless-ngx'
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/stale@v10
|
||||
- uses: actions/stale@v9
|
||||
with:
|
||||
days-before-stale: 7
|
||||
days-before-close: 14
|
||||
@@ -57,7 +57,7 @@ jobs:
|
||||
if: github.repository_owner == 'paperless-ngx'
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/github-script@v8
|
||||
- uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
function sleep(ms) {
|
||||
@@ -114,7 +114,7 @@ jobs:
|
||||
if: github.repository_owner == 'paperless-ngx'
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/github-script@v8
|
||||
- uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
function sleep(ms) {
|
||||
@@ -206,7 +206,7 @@ jobs:
|
||||
if: github.repository_owner == 'paperless-ngx'
|
||||
runs-on: ubuntu-24.04
|
||||
steps:
|
||||
- uses: actions/github-script@v8
|
||||
- uses: actions/github-script@v7
|
||||
with:
|
||||
script: |
|
||||
function sleep(ms) {
|
||||
@@ -241,7 +241,6 @@ jobs:
|
||||
) {
|
||||
nodes {
|
||||
id,
|
||||
createdAt,
|
||||
number,
|
||||
updatedAt,
|
||||
upvoteCount,
|
||||
|
4
.github/workflows/translate-strings.yml
vendored
4
.github/workflows/translate-strings.yml
vendored
@@ -17,7 +17,7 @@ jobs:
|
||||
ref: ${{ github.head_ref }}
|
||||
- name: Set up Python
|
||||
id: setup-python
|
||||
uses: actions/setup-python@v6
|
||||
uses: actions/setup-python@v5
|
||||
- name: Install system dependencies
|
||||
run: |
|
||||
sudo apt-get update -qq
|
||||
@@ -38,7 +38,7 @@ jobs:
|
||||
with:
|
||||
version: 10
|
||||
- name: Use Node.js 20
|
||||
uses: actions/setup-node@v5
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20.x
|
||||
cache: 'pnpm'
|
||||
|
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
|
||||
@@ -49,7 +49,7 @@ repos:
|
||||
- 'prettier-plugin-organize-imports@4.1.0'
|
||||
# Python hooks
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: v0.13.2
|
||||
rev: v0.12.2
|
||||
hooks:
|
||||
- id: ruff-check
|
||||
- id: ruff-format
|
||||
@@ -59,7 +59,7 @@ repos:
|
||||
- id: pyproject-fmt
|
||||
# Dockerfile hooks
|
||||
- repo: https://github.com/AleksaC/hadolint-py
|
||||
rev: v2.14.0
|
||||
rev: v2.12.1b3
|
||||
hooks:
|
||||
- id: hadolint
|
||||
# Shell script hooks
|
||||
@@ -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.15-python3.12-bookworm-slim AS s6-overlay-base
|
||||
|
||||
WORKDIR /usr/src/s6
|
||||
|
||||
|
@@ -32,7 +32,7 @@ services:
|
||||
volumes:
|
||||
- redisdata:/data
|
||||
db:
|
||||
image: docker.io/library/postgres:18
|
||||
image: docker.io/library/postgres:17
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
|
@@ -35,7 +35,7 @@ services:
|
||||
volumes:
|
||||
- redisdata:/data
|
||||
db:
|
||||
image: docker.io/library/postgres:18
|
||||
image: docker.io/library/postgres:17
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
|
@@ -31,7 +31,7 @@ services:
|
||||
volumes:
|
||||
- redisdata:/data
|
||||
db:
|
||||
image: docker.io/library/postgres:18
|
||||
image: docker.io/library/postgres:17
|
||||
restart: unless-stopped
|
||||
volumes:
|
||||
- pgdata:/var/lib/postgresql/data
|
||||
|
@@ -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
|
||||
|
@@ -170,11 +170,11 @@ Available options are `postgresql` and `mariadb`.
|
||||
|
||||
!!! note
|
||||
|
||||
A small pool is typically sufficient — for example, a size of 4.
|
||||
Make sure your PostgreSQL server's max_connections setting is large enough to handle:
|
||||
```(Paperless workers + Celery workers) × pool size + safety margin```
|
||||
For example, with 4 Paperless workers and 2 Celery workers, and a pool size of 4:
|
||||
(4 + 2) × 4 + 10 = 34 connections required.
|
||||
A small pool is typically sufficient — for example, a size of 4.
|
||||
Make sure your PostgreSQL server's max_connections setting is large enough to handle:
|
||||
```(Paperless workers + Celery workers) × pool size + safety margin```
|
||||
For example, with 4 Paperless workers and 2 Celery workers, and a pool size of 4:
|
||||
(4 + 2) × 4 + 10 = 34 connections required.
|
||||
|
||||
#### [`PAPERLESS_DB_READ_CACHE_ENABLED=<bool>`](#PAPERLESS_DB_READ_CACHE_ENABLED) {#PAPERLESS_DB_READ_CACHE_ENABLED}
|
||||
|
||||
@@ -184,9 +184,9 @@ Available options are `postgresql` and `mariadb`.
|
||||
|
||||
!!! danger
|
||||
|
||||
**Do not modify the database outside the application while it is running.**
|
||||
This includes actions such as restoring a backup, upgrading the database, or performing manual inserts. All external modifications must be done **only when the application is stopped**.
|
||||
After making any such changes, you **must invalidate the DB read cache** using the `invalidate_cachalot` management command.
|
||||
**Do not modify the database outside the application while it is running.**
|
||||
This includes actions such as restoring a backup, upgrading the database, or performing manual inserts. All external modifications must be done **only when the application is stopped**.
|
||||
After making any such changes, you **must invalidate the DB read cache** using the `invalidate_cachalot` management command.
|
||||
|
||||
#### [`PAPERLESS_READ_CACHE_TTL=<int>`](#PAPERLESS_READ_CACHE_TTL) {#PAPERLESS_READ_CACHE_TTL}
|
||||
|
||||
@@ -196,7 +196,7 @@ Available options are `postgresql` and `mariadb`.
|
||||
|
||||
!!! warning
|
||||
|
||||
A high TTL increases memory usage over time. Memory may be used until end of TTL, even if the cache is invalidated with the `invalidate_cachalot` command.
|
||||
A high TTL increases memory usage over time. Memory may be used until end of TTL, even if the cache is invalidated with the `invalidate_cachalot` command.
|
||||
|
||||
In case of an out-of-memory (OOM) situation, Redis may stop accepting new data — including cache entries, scheduled tasks, and documents to consume.
|
||||
If your system has limited RAM, consider configuring a dedicated Redis instance for the read cache, with a memory limit and the eviction policy set to `allkeys-lru`.
|
||||
@@ -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**
|
||||
|
109
docs/usage.md
109
docs/usage.md
@@ -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.
|
||||
@@ -414,7 +400,7 @@ fields and permissions, which will be merged.
|
||||
|
||||
#### Types {#workflow-trigger-types}
|
||||
|
||||
Currently, there are four events that correspond to workflow trigger 'types':
|
||||
Currently, there are three events that correspond to workflow trigger 'types':
|
||||
|
||||
1. **Consumption Started**: _before_ a document is consumed, so events can include filters by source (mail, consumption
|
||||
folder or API), file path, file name, mail rule
|
||||
@@ -422,12 +408,12 @@ Currently, there are four 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).
|
||||
|
||||
The following flow diagram illustrates the four document trigger types:
|
||||
The following flow diagram illustrates the three document trigger types:
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
@@ -462,24 +448,14 @@ flowchart TD
|
||||
Workflows allow you to filter by:
|
||||
|
||||
- Source, e.g. documents uploaded via consume folder, API (& the web UI) and mail fetch
|
||||
- File name, including wildcards e.g. \*.pdf will apply to all pdfs.
|
||||
- File name, including wildcards e.g. \*.pdf will apply to all pdfs
|
||||
- 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.
|
||||
|
||||
There are also 'advanced' filters available for `Added`, `Updated` and `Scheduled` triggers:
|
||||
|
||||
- Any Tags: Filter for documents with any of the specified tags.
|
||||
- All Tags: Filter for documents with all of the specified tags.
|
||||
- No Tags: Filter for documents with none of the specified tags.
|
||||
- Document type: Filter documents with this document type.
|
||||
- Not Document types: Filter documents without any of these document types.
|
||||
- Correspondent: Filter documents with this correspondent.
|
||||
- Not Correspondents: Filter documents without any of these correspondents.
|
||||
- Storage path: Filter documents with this storage path.
|
||||
- Not Storage paths: Filter documents without any of these storage paths.
|
||||
- Custom field query: Filter documents with a custom field query (the same as used for the document list filters).
|
||||
- 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
|
||||
|
||||
@@ -529,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
|
||||
|
||||
@@ -646,7 +605,7 @@ When you first delete a document it is moved to the 'trash' until either it is e
|
||||
You can set how long documents remain in the trash before being automatically deleted with [`PAPERLESS_EMPTY_TRASH_DELAY`](configuration.md#PAPERLESS_EMPTY_TRASH_DELAY), which defaults
|
||||
to 30 days. Until the file is actually deleted (e.g. the trash is emptied), all files and database content remains intact and can be restored at any point up until that time.
|
||||
|
||||
Additionally you may configure a directory where deleted files are moved to when the trash is emptied with [`PAPERLESS_EMPTY_TRASH_DIR`](configuration.md#PAPERLESS_EMPTY_TRASH_DIR).
|
||||
Additionally you may configure a directory where deleted files are moved to when they the trash is emptied with [`PAPERLESS_EMPTY_TRASH_DIR`](configuration.md#PAPERLESS_EMPTY_TRASH_DIR).
|
||||
Note that the empty trash directory only stores the original file, the archive file and all database information is permanently removed once a document is fully deleted.
|
||||
|
||||
## Best practices {#basic-searching}
|
||||
|
@@ -30,13 +30,12 @@ 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.8.0",
|
||||
"django-extensions~=4.1",
|
||||
"django-filter~=25.1",
|
||||
"django-guardian~=3.2.0",
|
||||
"django-guardian~=3.1.2",
|
||||
"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",
|
||||
@@ -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",
|
||||
"psycopg-pool",
|
||||
"python-dateutil~=2.9.0",
|
||||
"python-dotenv~=1.1.0",
|
||||
"python-gnupg~=0.5.4",
|
||||
@@ -116,7 +116,7 @@ testing = [
|
||||
lint = [
|
||||
"pre-commit~=4.3.0",
|
||||
"pre-commit-uv~=4.1.3",
|
||||
"ruff~=0.13.0",
|
||||
"ruff~=0.12.2",
|
||||
]
|
||||
|
||||
typing = [
|
||||
@@ -241,7 +241,7 @@ addopts = [
|
||||
"--numprocesses=auto",
|
||||
"--maxprocesses=16",
|
||||
"--quiet",
|
||||
"--durations=50",
|
||||
"--durations=0",
|
||||
"--junitxml=junit.xml",
|
||||
"-o junit_family=legacy",
|
||||
]
|
||||
@@ -249,6 +249,10 @@ norecursedirs = [ "src/locale/", ".venv/", "src-ui/" ]
|
||||
|
||||
DJANGO_SETTINGS_MODULE = "paperless.settings"
|
||||
|
||||
markers = [
|
||||
"use_whitenoise: mark test to run with Whitenoise middleware enabled",
|
||||
]
|
||||
|
||||
[tool.pytest_env]
|
||||
PAPERLESS_DISABLE_DBHANDLER = "true"
|
||||
PAPERLESS_CACHE_BACKEND = "django.core.cache.backends.locmem.LocMemCache"
|
||||
|
@@ -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()
|
||||
|
1087
src-ui/messages.xlf
1087
src-ui/messages.xlf
File diff suppressed because it is too large
Load Diff
@@ -11,17 +11,17 @@
|
||||
},
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@angular/cdk": "^20.2.6",
|
||||
"@angular/common": "~20.3.2",
|
||||
"@angular/compiler": "~20.3.2",
|
||||
"@angular/core": "~20.3.2",
|
||||
"@angular/forms": "~20.3.2",
|
||||
"@angular/localize": "~20.3.2",
|
||||
"@angular/platform-browser": "~20.3.2",
|
||||
"@angular/platform-browser-dynamic": "~20.3.2",
|
||||
"@angular/router": "~20.3.2",
|
||||
"@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",
|
||||
"@ng-bootstrap/ng-bootstrap": "^19.0.1",
|
||||
"@ng-select/ng-select": "^20.2.2",
|
||||
"@ng-select/ng-select": "^20.1.3",
|
||||
"@ngneat/dirty-check-forms": "^3.0.3",
|
||||
"@popperjs/core": "^2.11.8",
|
||||
"bootstrap": "^5.3.8",
|
||||
@@ -29,48 +29,47 @@
|
||||
"mime-names": "^1.0.0",
|
||||
"ng2-pdf-viewer": "^10.4.0",
|
||||
"ngx-bootstrap-icons": "^1.9.3",
|
||||
"ngx-color": "^10.1.0",
|
||||
"ngx-color": "^10.0.0",
|
||||
"ngx-cookie-service": "^20.1.0",
|
||||
"ngx-device-detector": "^10.1.0",
|
||||
"ngx-ui-tour-ng-bootstrap": "^17.0.1",
|
||||
"rxjs": "^7.8.2",
|
||||
"tslib": "^2.8.1",
|
||||
"utif": "^3.1.0",
|
||||
"uuid": "^13.0.0",
|
||||
"uuid": "^11.1.0",
|
||||
"zone.js": "^0.15.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@angular-builders/custom-webpack": "^20.0.0",
|
||||
"@angular-builders/jest": "^20.0.0",
|
||||
"@angular-devkit/core": "^20.3.3",
|
||||
"@angular-devkit/schematics": "^20.3.3",
|
||||
"@angular-eslint/builder": "20.3.0",
|
||||
"@angular-eslint/eslint-plugin": "20.3.0",
|
||||
"@angular-eslint/eslint-plugin-template": "20.3.0",
|
||||
"@angular-eslint/schematics": "20.3.0",
|
||||
"@angular-eslint/template-parser": "20.3.0",
|
||||
"@angular/build": "^20.3.3",
|
||||
"@angular/cli": "~20.3.3",
|
||||
"@angular/compiler-cli": "~20.3.2",
|
||||
"@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",
|
||||
"@codecov/webpack-plugin": "^1.9.1",
|
||||
"@playwright/test": "^1.55.1",
|
||||
"@playwright/test": "^1.55.0",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/node": "^24.6.1",
|
||||
"@typescript-eslint/eslint-plugin": "^8.45.0",
|
||||
"@typescript-eslint/parser": "^8.45.0",
|
||||
"@typescript-eslint/utils": "^8.45.0",
|
||||
"eslint": "^9.36.0",
|
||||
"jest": "30.2.0",
|
||||
"jest-environment-jsdom": "^30.2.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",
|
||||
"jest-junit": "^16.0.0",
|
||||
"jest-preset-angular": "^15.0.2",
|
||||
"jest-preset-angular": "^15.0.0",
|
||||
"jest-websocket-mock": "^2.5.0",
|
||||
"prettier-plugin-organize-imports": "^4.3.0",
|
||||
"prettier-plugin-organize-imports": "^4.2.0",
|
||||
"ts-node": "~10.9.1",
|
||||
"typescript": "^5.8.3",
|
||||
"webpack": "^5.102.0"
|
||||
"webpack": "^5.101.3"
|
||||
},
|
||||
"packageManager": "pnpm@10.17.1",
|
||||
"pnpm": {
|
||||
"onlyBuiltDependencies": [
|
||||
"@parcel/watcher",
|
||||
|
3494
src-ui/pnpm-lock.yaml
generated
3494
src-ui/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -145,14 +145,4 @@ HTMLCanvasElement.prototype.getContext = <
|
||||
typeof HTMLCanvasElement.prototype.getContext
|
||||
>jest.fn()
|
||||
|
||||
jest.mock('uuid', () => ({
|
||||
v4: jest.fn(() =>
|
||||
'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (char: string) => {
|
||||
const random = Math.floor(Math.random() * 16)
|
||||
const value = char === 'x' ? random : (random & 0x3) | 0x8
|
||||
return value.toString(16)
|
||||
})
|
||||
),
|
||||
}))
|
||||
|
||||
jest.mock('pdfjs-dist')
|
||||
|
@@ -16,7 +16,6 @@ import {
|
||||
NgbNavItem,
|
||||
} from '@ng-bootstrap/ng-bootstrap'
|
||||
import { allIcons, NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||
import { throwError } from 'rxjs'
|
||||
import { routes } from 'src/app/app-routing.module'
|
||||
import {
|
||||
PaperlessTask,
|
||||
@@ -29,7 +28,6 @@ import { PermissionsGuard } from 'src/app/guards/permissions.guard'
|
||||
import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
|
||||
import { PermissionsService } from 'src/app/services/permissions.service'
|
||||
import { TasksService } from 'src/app/services/tasks.service'
|
||||
import { ToastService } from 'src/app/services/toast.service'
|
||||
import { environment } from 'src/environments/environment'
|
||||
import { ConfirmDialogComponent } from '../../common/confirm-dialog/confirm-dialog.component'
|
||||
import { PageHeaderComponent } from '../../common/page-header/page-header.component'
|
||||
@@ -125,7 +123,6 @@ describe('TasksComponent', () => {
|
||||
let router: Router
|
||||
let httpTestingController: HttpTestingController
|
||||
let reloadSpy
|
||||
let toastService: ToastService
|
||||
|
||||
beforeEach(async () => {
|
||||
TestBed.configureTestingModule({
|
||||
@@ -160,7 +157,6 @@ describe('TasksComponent', () => {
|
||||
httpTestingController = TestBed.inject(HttpTestingController)
|
||||
modalService = TestBed.inject(NgbModal)
|
||||
router = TestBed.inject(Router)
|
||||
toastService = TestBed.inject(ToastService)
|
||||
fixture = TestBed.createComponent(TasksComponent)
|
||||
component = fixture.componentInstance
|
||||
jest.useFakeTimers()
|
||||
@@ -253,42 +249,6 @@ describe('TasksComponent', () => {
|
||||
expect(dismissSpy).toHaveBeenCalledWith(selected)
|
||||
})
|
||||
|
||||
it('should show an error and re-enable modal buttons when dismissing multiple tasks fails', () => {
|
||||
component.selectedTasks = new Set([tasks[0].id, tasks[1].id])
|
||||
const error = new Error('dismiss failed')
|
||||
const toastSpy = jest.spyOn(toastService, 'showError')
|
||||
const dismissSpy = jest
|
||||
.spyOn(tasksService, 'dismissTasks')
|
||||
.mockReturnValue(throwError(() => error))
|
||||
|
||||
let modal: NgbModalRef
|
||||
modalService.activeInstances.subscribe((m) => (modal = m[m.length - 1]))
|
||||
|
||||
component.dismissTasks()
|
||||
expect(modal).not.toBeUndefined()
|
||||
|
||||
modal.componentInstance.confirmClicked.emit()
|
||||
|
||||
expect(dismissSpy).toHaveBeenCalledWith(new Set([tasks[0].id, tasks[1].id]))
|
||||
expect(toastSpy).toHaveBeenCalledWith('Error dismissing tasks', error)
|
||||
expect(modal.componentInstance.buttonsEnabled).toBe(true)
|
||||
expect(component.selectedTasks.size).toBe(0)
|
||||
})
|
||||
|
||||
it('should show an error when dismissing a single task fails', () => {
|
||||
const error = new Error('dismiss failed')
|
||||
const toastSpy = jest.spyOn(toastService, 'showError')
|
||||
const dismissSpy = jest
|
||||
.spyOn(tasksService, 'dismissTasks')
|
||||
.mockReturnValue(throwError(() => error))
|
||||
|
||||
component.dismissTask(tasks[0])
|
||||
|
||||
expect(dismissSpy).toHaveBeenCalledWith(new Set([tasks[0].id]))
|
||||
expect(toastSpy).toHaveBeenCalledWith('Error dismissing task', error)
|
||||
expect(component.selectedTasks.size).toBe(0)
|
||||
})
|
||||
|
||||
it('should support dismiss all tasks', () => {
|
||||
let modal: NgbModalRef
|
||||
modalService.activeInstances.subscribe((m) => (modal = m[m.length - 1]))
|
||||
|
@@ -24,7 +24,6 @@ import { PaperlessTask } from 'src/app/data/paperless-task'
|
||||
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
||||
import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
|
||||
import { TasksService } from 'src/app/services/tasks.service'
|
||||
import { ToastService } from 'src/app/services/toast.service'
|
||||
import { ConfirmDialogComponent } from '../../common/confirm-dialog/confirm-dialog.component'
|
||||
import { PageHeaderComponent } from '../../common/page-header/page-header.component'
|
||||
import { LoadingComponentWithPermissions } from '../../loading-component/loading.component'
|
||||
@@ -73,7 +72,6 @@ export class TasksComponent
|
||||
tasksService = inject(TasksService)
|
||||
private modalService = inject(NgbModal)
|
||||
private readonly router = inject(Router)
|
||||
private readonly toastService = inject(ToastService)
|
||||
|
||||
public activeTab: TaskTab
|
||||
public selectedTasks: Set<number> = new Set()
|
||||
@@ -156,19 +154,11 @@ export class TasksComponent
|
||||
modal.componentInstance.confirmClicked.pipe(first()).subscribe(() => {
|
||||
modal.componentInstance.buttonsEnabled = false
|
||||
modal.close()
|
||||
this.tasksService.dismissTasks(tasks).subscribe({
|
||||
error: (e) => {
|
||||
this.toastService.showError($localize`Error dismissing tasks`, e)
|
||||
modal.componentInstance.buttonsEnabled = true
|
||||
},
|
||||
})
|
||||
this.tasksService.dismissTasks(tasks)
|
||||
this.clearSelection()
|
||||
})
|
||||
} else {
|
||||
this.tasksService.dismissTasks(tasks).subscribe({
|
||||
error: (e) =>
|
||||
this.toastService.showError($localize`Error dismissing task`, e),
|
||||
})
|
||||
this.tasksService.dismissTasks(tasks)
|
||||
this.clearSelection()
|
||||
}
|
||||
}
|
||||
|
@@ -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
|
||||
|
@@ -1,36 +1,28 @@
|
||||
@if (useDropdown) {
|
||||
<div class="btn-group w-100" role="group" ngbDropdown #dropdown="ngbDropdown" (openChange)="onOpenChange($event)" [popperOptions]="popperOptions">
|
||||
<button class="btn btn-sm btn-outline-primary" id="dropdown_toggle" ngbDropdownToggle [disabled]="disabled">
|
||||
<i-bs name="{{icon}}"></i-bs>
|
||||
<div class="d-none d-sm-inline"> {{title}}</div>
|
||||
@if (isActive) {
|
||||
<pngx-clearable-badge [selected]="isActive" (cleared)="reset()"></pngx-clearable-badge>
|
||||
<div class="btn-group w-100" role="group" ngbDropdown #dropdown="ngbDropdown" (openChange)="onOpenChange($event)" [popperOptions]="popperOptions">
|
||||
<button class="btn btn-sm btn-outline-primary" id="dropdown_toggle" ngbDropdownToggle [disabled]="disabled">
|
||||
<i-bs name="{{icon}}"></i-bs>
|
||||
<div class="d-none d-sm-inline"> {{title}}</div>
|
||||
@if (isActive) {
|
||||
<pngx-clearable-badge [selected]="isActive" (cleared)="reset()"></pngx-clearable-badge>
|
||||
}
|
||||
</button>
|
||||
<div class="px-3 shadow" ngbDropdownMenu attr.aria-labelledby="dropdown_{{name}}">
|
||||
<div class="list-group list-group-flush">
|
||||
@for (element of selectionModel.queries; track element.id; let i = $index) {
|
||||
<div class="list-group-item px-0 d-flex flex-nowrap">
|
||||
@switch (element.type) {
|
||||
@case (CustomFieldQueryComponentType.Atom) {
|
||||
<ng-container *ngTemplateOutlet="queryAtom; context: { atom: element }"></ng-container>
|
||||
}
|
||||
@case (CustomFieldQueryComponentType.Expression) {
|
||||
<ng-container *ngTemplateOutlet="queryExpression; context: { expression: element }"></ng-container>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</button>
|
||||
<div class="px-3 shadow" ngbDropdownMenu attr.aria-labelledby="dropdown_{{name}}">
|
||||
<ng-container *ngTemplateOutlet="list; context: { queries: selectionModel.queries }"></ng-container>
|
||||
</div>
|
||||
</div>
|
||||
} @else {
|
||||
<ng-container *ngTemplateOutlet="list; context: { queries: selectionModel.queries }"></ng-container>
|
||||
}
|
||||
|
||||
<ng-template #list let-queries="queries">
|
||||
<div class="list-group list-group-flush">
|
||||
@for (element of queries; track element.id; let i = $index) {
|
||||
<div class="list-group-item px-0 d-flex flex-nowrap">
|
||||
@switch (element.type) {
|
||||
@case (CustomFieldQueryComponentType.Atom) {
|
||||
<ng-container *ngTemplateOutlet="queryAtom; context: { atom: element }"></ng-container>
|
||||
}
|
||||
@case (CustomFieldQueryComponentType.Expression) {
|
||||
<ng-container *ngTemplateOutlet="queryExpression; context: { expression: element }"></ng-container>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</ng-template>
|
||||
</div>
|
||||
|
||||
<ng-template #comparisonValueTemplate let-atom="atom">
|
||||
@if (getCustomFieldByID(atom.field)?.data_type === CustomFieldDataType.Date) {
|
||||
|
@@ -41,3 +41,9 @@
|
||||
min-width: 140px;
|
||||
}
|
||||
}
|
||||
|
||||
.btn-group-xs {
|
||||
> .btn {
|
||||
border-radius: 0.15rem;
|
||||
}
|
||||
}
|
||||
|
@@ -120,12 +120,6 @@ export class CustomFieldQueriesModel {
|
||||
})
|
||||
}
|
||||
|
||||
addInitialAtom() {
|
||||
this.addAtom(
|
||||
new CustomFieldQueryAtom([null, CustomFieldQueryOperator.Exists, 'true'])
|
||||
)
|
||||
}
|
||||
|
||||
private findElement(
|
||||
queryElement: CustomFieldQueryElement,
|
||||
elements: any[]
|
||||
@@ -212,9 +206,6 @@ export class CustomFieldsQueryDropdownComponent extends LoadingComponentWithPerm
|
||||
@Input()
|
||||
applyOnClose = false
|
||||
|
||||
@Input()
|
||||
useDropdown: boolean = true
|
||||
|
||||
get name(): string {
|
||||
return this.title ? this.title.replace(/\s/g, '_').toLowerCase() : null
|
||||
}
|
||||
@@ -267,7 +258,13 @@ export class CustomFieldsQueryDropdownComponent extends LoadingComponentWithPerm
|
||||
public onOpenChange(open: boolean) {
|
||||
if (open) {
|
||||
if (this.selectionModel.queries.length === 0) {
|
||||
this.selectionModel.addInitialAtom()
|
||||
this.selectionModel.addAtom(
|
||||
new CustomFieldQueryAtom([
|
||||
null,
|
||||
CustomFieldQueryOperator.Exists,
|
||||
'true',
|
||||
])
|
||||
)
|
||||
}
|
||||
if (
|
||||
this.selectionModel.queries.length === 1 &&
|
||||
|
@@ -177,16 +177,10 @@ export class CustomFieldEditDialogComponent
|
||||
}
|
||||
|
||||
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)
|
||||
this.selectOptions.removeAt(index)
|
||||
this._allSelectOptions.splice(
|
||||
index + (this.selectOptionsPage - 1) * SELECT_OPTION_PAGE_SIZE,
|
||||
1
|
||||
)
|
||||
const targetPage = Math.min(this.selectOptionsPage, totalPages)
|
||||
|
||||
this.selectOptionsPage = targetPage
|
||||
}
|
||||
}
|
||||
|
@@ -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),
|
||||
|
@@ -156,97 +156,30 @@
|
||||
<p class="small" i18n>Trigger for documents that match <em>all</em> filters specified below.</p>
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<pngx-input-text i18n-title title="Filter filename" formControlName="filter_filename" horizontal="true" i18n-hint hint="Apply to documents that match this filename. Wildcards such as *.pdf or *invoice* are allowed. Case insensitive." [error]="error?.filter_filename"></pngx-input-text>
|
||||
<pngx-input-text i18n-title title="Filter filename" formControlName="filter_filename" i18n-hint hint="Apply to documents that match this filename. Wildcards such as *.pdf or *invoice* are allowed. Case insensitive." [error]="error?.filter_filename"></pngx-input-text>
|
||||
@if (formGroup.get('type').value === WorkflowTriggerType.Consumption) {
|
||||
<pngx-input-select i18n-title title="Filter sources" [items]="sourceOptions" horizontal="true" [multiple]="true" formControlName="sources" [error]="error?.sources"></pngx-input-select>
|
||||
<pngx-input-text i18n-title title="Filter path" formControlName="filter_path" horizontal="true" i18n-hint hint="Apply to documents that match this path. Wildcards specified as * are allowed. Case-normalized.</a>" [error]="error?.filter_path"></pngx-input-text>
|
||||
<pngx-input-select i18n-title title="Filter mail rule" [items]="mailRules" horizontal="true" [allowNull]="true" formControlName="filter_mailrule" i18n-hint hint="Apply to documents consumed via this mail rule." [error]="error?.filter_mailrule"></pngx-input-select>
|
||||
<pngx-input-select i18n-title title="Filter sources" [items]="sourceOptions" [multiple]="true" formControlName="sources" [error]="error?.sources"></pngx-input-select>
|
||||
<pngx-input-text i18n-title title="Filter path" formControlName="filter_path" i18n-hint hint="Apply to documents that match this path. Wildcards specified as * are allowed. Case-normalized.</a>" [error]="error?.filter_path"></pngx-input-text>
|
||||
<pngx-input-select i18n-title title="Filter mail rule" [items]="mailRules" [allowNull]="true" formControlName="filter_mailrule" i18n-hint hint="Apply to documents consumed via this mail rule." [error]="error?.filter_mailrule"></pngx-input-select>
|
||||
}
|
||||
@if (formGroup.get('type').value === WorkflowTriggerType.DocumentAdded || formGroup.get('type').value === WorkflowTriggerType.DocumentUpdated || formGroup.get('type').value === WorkflowTriggerType.Scheduled) {
|
||||
<pngx-input-select i18n-title title="Content matching algorithm" horizontal="true" [items]="getMatchingAlgorithms()" formControlName="matching_algorithm"></pngx-input-select>
|
||||
@if (matchingPatternRequired(formGroup)) {
|
||||
<pngx-input-text i18n-title title="Content matching pattern" horizontal="true" formControlName="match" [error]="error?.match"></pngx-input-text>
|
||||
<pngx-input-select i18n-title title="Content matching algorithm" [items]="getMatchingAlgorithms()" formControlName="matching_algorithm"></pngx-input-select>
|
||||
@if (patternRequired) {
|
||||
<pngx-input-text i18n-title title="Content matching pattern" formControlName="match" [error]="error?.match"></pngx-input-text>
|
||||
}
|
||||
@if (matchingPatternRequired(formGroup)) {
|
||||
<pngx-input-check i18n-title title="Case insensitive" horizontal="true" formControlName="is_insensitive"></pngx-input-check>
|
||||
@if (patternRequired) {
|
||||
<pngx-input-check i18n-title title="Case insensitive" formControlName="is_insensitive"></pngx-input-check>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@if (formGroup.get('type').value === WorkflowTriggerType.DocumentAdded || formGroup.get('type').value === WorkflowTriggerType.DocumentUpdated || formGroup.get('type').value === WorkflowTriggerType.Scheduled) {
|
||||
<div class="row mt-3">
|
||||
<div class="col">
|
||||
<div class="trigger-filters mb-3">
|
||||
<div class="d-flex align-items-center">
|
||||
<label class="form-label mb-0" i18n>Advanced Filters</label>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-sm btn-outline-primary ms-auto"
|
||||
(click)="addFilter(formGroup)"
|
||||
[disabled]="!canAddFilter(formGroup)"
|
||||
>
|
||||
<i-bs name="plus-circle"></i-bs> <span i18n>Add filter</span>
|
||||
</button>
|
||||
</div>
|
||||
<ul class="mt-2 list-group filters" formArrayName="filters">
|
||||
@if (getFiltersFormArray(formGroup).length === 0) {
|
||||
<p class="text-muted small" i18n>No advanced workflow filters defined.</p>
|
||||
}
|
||||
@for (filter of getFiltersFormArray(formGroup).controls; track filter; let filterIndex = $index) {
|
||||
<li [formGroupName]="filterIndex" class="list-group-item">
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
<div class="w-25">
|
||||
<pngx-input-select
|
||||
i18n-title
|
||||
[items]="getFilterTypeOptions(formGroup, filterIndex)"
|
||||
formControlName="type"
|
||||
[allowNull]="false"
|
||||
></pngx-input-select>
|
||||
</div>
|
||||
<div class="flex-grow-1">
|
||||
@if (isTagsFilter(filter.get('type').value)) {
|
||||
<pngx-input-tags
|
||||
[allowCreate]="false"
|
||||
[title]="null"
|
||||
formControlName="values"
|
||||
></pngx-input-tags>
|
||||
} @else if (
|
||||
isCustomFieldQueryFilter(filter.get('type').value)
|
||||
) {
|
||||
<pngx-custom-fields-query-dropdown
|
||||
[selectionModel]="getCustomFieldQueryModel(filter)"
|
||||
(selectionModelChange)="onCustomFieldQuerySelectionChange(filter, $event)"
|
||||
[useDropdown]="false"
|
||||
></pngx-custom-fields-query-dropdown>
|
||||
@if (!isCustomFieldQueryValid(filter)) {
|
||||
<div class="text-danger small" i18n>
|
||||
Complete the custom field query configuration.
|
||||
</div>
|
||||
}
|
||||
} @else {
|
||||
<pngx-input-select
|
||||
[items]="getFilterSelectItems(filter.get('type').value)"
|
||||
[allowNull]="true"
|
||||
[multiple]="isSelectMultiple(filter.get('type').value)"
|
||||
formControlName="values"
|
||||
></pngx-input-select>
|
||||
}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="btn btn-link text-danger p-0"
|
||||
(click)="removeFilter(formGroup, filterIndex)"
|
||||
>
|
||||
<i-bs name="trash"></i-bs><span class="ms-1" i18n>Delete</span>
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
@if (formGroup.get('type').value === WorkflowTriggerType.DocumentAdded || formGroup.get('type').value === WorkflowTriggerType.DocumentUpdated || formGroup.get('type').value === WorkflowTriggerType.Scheduled) {
|
||||
<div class="col-md-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>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</ng-template>
|
||||
|
||||
|
@@ -7,7 +7,3 @@
|
||||
.accordion-button {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
:host ::ng-deep .filters .paperless-input-select.mb-3 {
|
||||
margin-bottom: 0 !important;
|
||||
}
|
||||
|
@@ -11,14 +11,8 @@ import {
|
||||
import { NgbActiveModal, NgbModule } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { NgSelectModule } from '@ng-select/ng-select'
|
||||
import { of } from 'rxjs'
|
||||
import { CustomFieldQueriesModel } from 'src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component'
|
||||
import { CustomFieldDataType } from 'src/app/data/custom-field'
|
||||
import { CustomFieldQueryLogicalOperator } from 'src/app/data/custom-field-query'
|
||||
import {
|
||||
MATCHING_ALGORITHMS,
|
||||
MATCH_AUTO,
|
||||
MATCH_NONE,
|
||||
} from 'src/app/data/matching-model'
|
||||
import { MATCHING_ALGORITHMS, MATCH_AUTO } from 'src/app/data/matching-model'
|
||||
import { Workflow } from 'src/app/data/workflow'
|
||||
import {
|
||||
WorkflowAction,
|
||||
@@ -37,7 +31,6 @@ import { DocumentTypeService } from 'src/app/services/rest/document-type.service
|
||||
import { MailRuleService } from 'src/app/services/rest/mail-rule.service'
|
||||
import { StoragePathService } from 'src/app/services/rest/storage-path.service'
|
||||
import { SettingsService } from 'src/app/services/settings.service'
|
||||
import { CustomFieldQueryExpression } from 'src/app/utils/custom-field-query-element'
|
||||
import { ConfirmButtonComponent } from '../../confirm-button/confirm-button.component'
|
||||
import { NumberComponent } from '../../input/number/number.component'
|
||||
import { PermissionsGroupComponent } from '../../input/permissions/permissions-group/permissions-group.component'
|
||||
@@ -50,7 +43,6 @@ import { EditDialogMode } from '../edit-dialog.component'
|
||||
import {
|
||||
DOCUMENT_SOURCE_OPTIONS,
|
||||
SCHEDULE_DATE_FIELD_OPTIONS,
|
||||
TriggerFilterType,
|
||||
WORKFLOW_ACTION_OPTIONS,
|
||||
WORKFLOW_TYPE_OPTIONS,
|
||||
WorkflowEditDialogComponent,
|
||||
@@ -383,562 +375,6 @@ describe('WorkflowEditDialogComponent', () => {
|
||||
expect(component.objectForm.get('actions').value[0].webhook).toBeNull()
|
||||
})
|
||||
|
||||
it('should require matching pattern when algorithm is not none', () => {
|
||||
const triggerGroup = new FormGroup({
|
||||
matching_algorithm: new FormControl(MATCH_AUTO),
|
||||
match: new FormControl(''),
|
||||
})
|
||||
expect(component.matchingPatternRequired(triggerGroup)).toBe(true)
|
||||
triggerGroup.get('matching_algorithm').setValue(MATCHING_ALGORITHMS[0].id)
|
||||
expect(component.matchingPatternRequired(triggerGroup)).toBe(true)
|
||||
triggerGroup.get('matching_algorithm').setValue(MATCH_NONE)
|
||||
expect(component.matchingPatternRequired(triggerGroup)).toBe(false)
|
||||
})
|
||||
|
||||
it('should map filter builder values into trigger filters on save', () => {
|
||||
component.object = undefined
|
||||
component.addTrigger()
|
||||
const triggerGroup = component.triggerFields.at(0)
|
||||
component.addFilter(triggerGroup as FormGroup)
|
||||
component.addFilter(triggerGroup as FormGroup)
|
||||
component.addFilter(triggerGroup as FormGroup)
|
||||
|
||||
const filters = component.getFiltersFormArray(triggerGroup as FormGroup)
|
||||
expect(filters.length).toBe(3)
|
||||
|
||||
filters.at(0).get('values').setValue([1])
|
||||
filters.at(1).get('values').setValue([2, 3])
|
||||
filters.at(2).get('values').setValue([4])
|
||||
|
||||
const addFilterOfType = (type: TriggerFilterType) => {
|
||||
const newFilter = component.addFilter(triggerGroup as FormGroup)
|
||||
newFilter.get('type').setValue(type)
|
||||
return newFilter
|
||||
}
|
||||
|
||||
const correspondentIs = addFilterOfType(TriggerFilterType.CorrespondentIs)
|
||||
correspondentIs.get('values').setValue(1)
|
||||
|
||||
const correspondentNot = addFilterOfType(TriggerFilterType.CorrespondentNot)
|
||||
correspondentNot.get('values').setValue([1])
|
||||
|
||||
const documentTypeIs = addFilterOfType(TriggerFilterType.DocumentTypeIs)
|
||||
documentTypeIs.get('values').setValue(1)
|
||||
|
||||
const documentTypeNot = addFilterOfType(TriggerFilterType.DocumentTypeNot)
|
||||
documentTypeNot.get('values').setValue([1])
|
||||
|
||||
const storagePathIs = addFilterOfType(TriggerFilterType.StoragePathIs)
|
||||
storagePathIs.get('values').setValue(1)
|
||||
|
||||
const storagePathNot = addFilterOfType(TriggerFilterType.StoragePathNot)
|
||||
storagePathNot.get('values').setValue([1])
|
||||
|
||||
const customFieldFilter = addFilterOfType(
|
||||
TriggerFilterType.CustomFieldQuery
|
||||
)
|
||||
const customFieldQuery = JSON.stringify(['AND', [[1, 'exact', 'test']]])
|
||||
customFieldFilter.get('values').setValue(customFieldQuery)
|
||||
|
||||
const formValues = component['getFormValues']()
|
||||
|
||||
expect(formValues.triggers[0].filter_has_tags).toEqual([1])
|
||||
expect(formValues.triggers[0].filter_has_all_tags).toEqual([2, 3])
|
||||
expect(formValues.triggers[0].filter_has_not_tags).toEqual([4])
|
||||
expect(formValues.triggers[0].filter_has_correspondent).toEqual(1)
|
||||
expect(formValues.triggers[0].filter_has_not_correspondents).toEqual([1])
|
||||
expect(formValues.triggers[0].filter_has_document_type).toEqual(1)
|
||||
expect(formValues.triggers[0].filter_has_not_document_types).toEqual([1])
|
||||
expect(formValues.triggers[0].filter_has_storage_path).toEqual(1)
|
||||
expect(formValues.triggers[0].filter_has_not_storage_paths).toEqual([1])
|
||||
expect(formValues.triggers[0].filter_custom_field_query).toEqual(
|
||||
customFieldQuery
|
||||
)
|
||||
expect(formValues.triggers[0].filters).toBeUndefined()
|
||||
})
|
||||
|
||||
it('should ignore empty and null filter values when mapping filters', () => {
|
||||
component.object = undefined
|
||||
component.addTrigger()
|
||||
const triggerGroup = component.triggerFields.at(0) as FormGroup
|
||||
|
||||
const tagsFilter = component.addFilter(triggerGroup)
|
||||
tagsFilter.get('type').setValue(TriggerFilterType.TagsAny)
|
||||
tagsFilter.get('values').setValue([])
|
||||
|
||||
const correspondentFilter = component.addFilter(triggerGroup)
|
||||
correspondentFilter.get('type').setValue(TriggerFilterType.CorrespondentIs)
|
||||
correspondentFilter.get('values').setValue(null)
|
||||
|
||||
const formValues = component['getFormValues']()
|
||||
|
||||
expect(formValues.triggers[0].filter_has_tags).toEqual([])
|
||||
expect(formValues.triggers[0].filter_has_correspondent).toBeNull()
|
||||
})
|
||||
|
||||
it('should derive single select filters from array values', () => {
|
||||
component.object = undefined
|
||||
component.addTrigger()
|
||||
const triggerGroup = component.triggerFields.at(0) as FormGroup
|
||||
|
||||
const addFilterOfType = (type: TriggerFilterType, value: any) => {
|
||||
const filter = component.addFilter(triggerGroup)
|
||||
filter.get('type').setValue(type)
|
||||
filter.get('values').setValue(value)
|
||||
}
|
||||
|
||||
addFilterOfType(TriggerFilterType.CorrespondentIs, [5])
|
||||
addFilterOfType(TriggerFilterType.DocumentTypeIs, [6])
|
||||
addFilterOfType(TriggerFilterType.StoragePathIs, [7])
|
||||
|
||||
const formValues = component['getFormValues']()
|
||||
|
||||
expect(formValues.triggers[0].filter_has_correspondent).toEqual(5)
|
||||
expect(formValues.triggers[0].filter_has_document_type).toEqual(6)
|
||||
expect(formValues.triggers[0].filter_has_storage_path).toEqual(7)
|
||||
})
|
||||
|
||||
it('should convert multi-value filter values when aggregating filters', () => {
|
||||
component.object = undefined
|
||||
component.addTrigger()
|
||||
const triggerGroup = component.triggerFields.at(0) as FormGroup
|
||||
|
||||
const setFilter = (type: TriggerFilterType, value: number): void => {
|
||||
const filter = component.addFilter(triggerGroup) as FormGroup
|
||||
filter.get('type').setValue(type)
|
||||
filter.get('values').setValue(value)
|
||||
}
|
||||
|
||||
setFilter(TriggerFilterType.TagsAll, 11)
|
||||
setFilter(TriggerFilterType.TagsNone, 12)
|
||||
setFilter(TriggerFilterType.CorrespondentNot, 13)
|
||||
setFilter(TriggerFilterType.DocumentTypeNot, 14)
|
||||
setFilter(TriggerFilterType.StoragePathNot, 15)
|
||||
|
||||
const formValues = component['getFormValues']()
|
||||
|
||||
expect(formValues.triggers[0].filter_has_all_tags).toEqual([11])
|
||||
expect(formValues.triggers[0].filter_has_not_tags).toEqual([12])
|
||||
expect(formValues.triggers[0].filter_has_not_correspondents).toEqual([13])
|
||||
expect(formValues.triggers[0].filter_has_not_document_types).toEqual([14])
|
||||
expect(formValues.triggers[0].filter_has_not_storage_paths).toEqual([15])
|
||||
})
|
||||
|
||||
it('should reuse filter type options and update disabled state', () => {
|
||||
component.object = undefined
|
||||
component.addTrigger()
|
||||
const triggerGroup = component.triggerFields.at(0) as FormGroup
|
||||
component.addFilter(triggerGroup)
|
||||
|
||||
const optionsFirst = component.getFilterTypeOptions(triggerGroup, 0)
|
||||
const optionsSecond = component.getFilterTypeOptions(triggerGroup, 0)
|
||||
expect(optionsFirst).toBe(optionsSecond)
|
||||
|
||||
// to force disabled flag
|
||||
component.addFilter(triggerGroup)
|
||||
const filterArray = component.getFiltersFormArray(triggerGroup)
|
||||
const firstFilter = filterArray.at(0)
|
||||
firstFilter.get('type').setValue(TriggerFilterType.CorrespondentIs)
|
||||
|
||||
component.addFilter(triggerGroup)
|
||||
const updatedFilters = component.getFiltersFormArray(triggerGroup)
|
||||
const secondFilter = updatedFilters.at(1)
|
||||
const options = component.getFilterTypeOptions(triggerGroup, 1)
|
||||
const correspondentIsOption = options.find(
|
||||
(option) => option.id === TriggerFilterType.CorrespondentIs
|
||||
)
|
||||
expect(correspondentIsOption.disabled).toBe(true)
|
||||
|
||||
firstFilter.get('type').setValue(TriggerFilterType.DocumentTypeNot)
|
||||
secondFilter.get('type').setValue(TriggerFilterType.TagsAll)
|
||||
const postChangeOptions = component.getFilterTypeOptions(triggerGroup, 1)
|
||||
const correspondentOptionAfter = postChangeOptions.find(
|
||||
(option) => option.id === TriggerFilterType.CorrespondentIs
|
||||
)
|
||||
expect(correspondentOptionAfter.disabled).toBe(false)
|
||||
})
|
||||
|
||||
it('should keep multi-entry filter options enabled and allow duplicates', () => {
|
||||
component.object = undefined
|
||||
component.addTrigger()
|
||||
const triggerGroup = component.triggerFields.at(0) as FormGroup
|
||||
|
||||
component.filterDefinitions = [
|
||||
{
|
||||
id: TriggerFilterType.TagsAny,
|
||||
name: 'Any tags',
|
||||
inputType: 'tags',
|
||||
allowMultipleEntries: true,
|
||||
allowMultipleValues: true,
|
||||
} as any,
|
||||
{
|
||||
id: TriggerFilterType.CorrespondentIs,
|
||||
name: 'Correspondent is',
|
||||
inputType: 'select',
|
||||
allowMultipleEntries: false,
|
||||
allowMultipleValues: false,
|
||||
selectItems: 'correspondents',
|
||||
} as any,
|
||||
]
|
||||
|
||||
const firstFilter = component.addFilter(triggerGroup)
|
||||
firstFilter.get('type').setValue(TriggerFilterType.TagsAny)
|
||||
|
||||
const secondFilter = component.addFilter(triggerGroup)
|
||||
expect(secondFilter).not.toBeNull()
|
||||
|
||||
const options = component.getFilterTypeOptions(triggerGroup, 1)
|
||||
const multiEntryOption = options.find(
|
||||
(option) => option.id === TriggerFilterType.TagsAny
|
||||
)
|
||||
|
||||
expect(multiEntryOption.disabled).toBe(false)
|
||||
expect(component.canAddFilter(triggerGroup)).toBe(true)
|
||||
})
|
||||
|
||||
it('should return null when no filter definitions remain available', () => {
|
||||
component.object = undefined
|
||||
component.addTrigger()
|
||||
const triggerGroup = component.triggerFields.at(0) as FormGroup
|
||||
|
||||
component.filterDefinitions = [
|
||||
{
|
||||
id: TriggerFilterType.TagsAny,
|
||||
name: 'Any tags',
|
||||
inputType: 'tags',
|
||||
allowMultipleEntries: false,
|
||||
allowMultipleValues: true,
|
||||
} as any,
|
||||
{
|
||||
id: TriggerFilterType.CorrespondentIs,
|
||||
name: 'Correspondent is',
|
||||
inputType: 'select',
|
||||
allowMultipleEntries: false,
|
||||
allowMultipleValues: false,
|
||||
selectItems: 'correspondents',
|
||||
} as any,
|
||||
]
|
||||
|
||||
const firstFilter = component.addFilter(triggerGroup)
|
||||
firstFilter.get('type').setValue(TriggerFilterType.TagsAny)
|
||||
const secondFilter = component.addFilter(triggerGroup)
|
||||
secondFilter.get('type').setValue(TriggerFilterType.CorrespondentIs)
|
||||
|
||||
expect(component.canAddFilter(triggerGroup)).toBe(false)
|
||||
expect(component.addFilter(triggerGroup)).toBeNull()
|
||||
})
|
||||
|
||||
it('should skip filter definitions without handlers when building form array', () => {
|
||||
const originalDefinitions = component.filterDefinitions
|
||||
component.filterDefinitions = [
|
||||
{
|
||||
id: 999,
|
||||
name: 'Unsupported',
|
||||
inputType: 'text',
|
||||
allowMultipleEntries: false,
|
||||
allowMultipleValues: false,
|
||||
} as any,
|
||||
]
|
||||
|
||||
const trigger = {
|
||||
filter_has_tags: [],
|
||||
filter_has_all_tags: [],
|
||||
filter_has_not_tags: [],
|
||||
filter_has_not_correspondents: [],
|
||||
filter_has_not_document_types: [],
|
||||
filter_has_not_storage_paths: [],
|
||||
filter_has_correspondent: null,
|
||||
filter_has_document_type: null,
|
||||
filter_has_storage_path: null,
|
||||
filter_custom_field_query: null,
|
||||
} as any
|
||||
|
||||
const filters = component['buildFiltersFormArray'](trigger)
|
||||
expect(filters.length).toBe(0)
|
||||
|
||||
component.filterDefinitions = originalDefinitions
|
||||
})
|
||||
|
||||
it('should return null when adding filter for unknown trigger form group', () => {
|
||||
expect(component.addFilter(new FormGroup({}) as any)).toBeNull()
|
||||
})
|
||||
|
||||
it('should ignore remove filter calls for unknown trigger form group', () => {
|
||||
expect(() =>
|
||||
component.removeFilter(new FormGroup({}) as any, 0)
|
||||
).not.toThrow()
|
||||
})
|
||||
|
||||
it('should teardown custom field query model when removing a custom field filter', () => {
|
||||
component.object = undefined
|
||||
component.addTrigger()
|
||||
const triggerGroup = component.triggerFields.at(0) as FormGroup
|
||||
|
||||
component.addFilter(triggerGroup)
|
||||
const filters = component.getFiltersFormArray(triggerGroup)
|
||||
const filterGroup = filters.at(0) as FormGroup
|
||||
filterGroup.get('type').setValue(TriggerFilterType.CustomFieldQuery)
|
||||
|
||||
const model = component.getCustomFieldQueryModel(filterGroup)
|
||||
expect(model).toBeDefined()
|
||||
expect(
|
||||
component['getStoredCustomFieldQueryModel'](filterGroup as any)
|
||||
).toBe(model)
|
||||
|
||||
component.removeFilter(triggerGroup, 0)
|
||||
expect(
|
||||
component['getStoredCustomFieldQueryModel'](filterGroup as any)
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('should return readable filter names', () => {
|
||||
expect(component.getFilterName(TriggerFilterType.TagsAny)).toBe(
|
||||
'Has any of these tags'
|
||||
)
|
||||
expect(component.getFilterName(999 as any)).toBe('')
|
||||
})
|
||||
|
||||
it('should build filter form array from existing trigger filters', () => {
|
||||
const trigger = workflow.triggers[0]
|
||||
trigger.filter_has_tags = [1]
|
||||
trigger.filter_has_all_tags = [2, 3]
|
||||
trigger.filter_has_not_tags = [4]
|
||||
trigger.filter_has_correspondent = 5 as any
|
||||
trigger.filter_has_not_correspondents = [6] as any
|
||||
trigger.filter_has_document_type = 7 as any
|
||||
trigger.filter_has_not_document_types = [8] as any
|
||||
trigger.filter_has_storage_path = 9 as any
|
||||
trigger.filter_has_not_storage_paths = [10] as any
|
||||
trigger.filter_custom_field_query = JSON.stringify([
|
||||
'AND',
|
||||
[[1, 'exact', 'value']],
|
||||
]) as any
|
||||
|
||||
component.object = workflow
|
||||
component.ngOnInit()
|
||||
const triggerGroup = component.triggerFields.at(0) as FormGroup
|
||||
const filters = component.getFiltersFormArray(triggerGroup)
|
||||
expect(filters.length).toBe(10)
|
||||
const customFieldFilter = filters.at(9) as FormGroup
|
||||
expect(customFieldFilter.get('type').value).toBe(
|
||||
TriggerFilterType.CustomFieldQuery
|
||||
)
|
||||
const model = component.getCustomFieldQueryModel(customFieldFilter)
|
||||
expect(model.isValid()).toBe(true)
|
||||
})
|
||||
|
||||
it('should expose select metadata helpers', () => {
|
||||
expect(component.isSelectMultiple(TriggerFilterType.CorrespondentNot)).toBe(
|
||||
true
|
||||
)
|
||||
expect(component.isSelectMultiple(TriggerFilterType.CorrespondentIs)).toBe(
|
||||
false
|
||||
)
|
||||
|
||||
component.correspondents = [{ id: 1, name: 'C1' } as any]
|
||||
component.documentTypes = [{ id: 2, name: 'DT' } as any]
|
||||
component.storagePaths = [{ id: 3, name: 'SP' } as any]
|
||||
|
||||
expect(
|
||||
component.getFilterSelectItems(TriggerFilterType.CorrespondentIs)
|
||||
).toEqual(component.correspondents)
|
||||
expect(
|
||||
component.getFilterSelectItems(TriggerFilterType.DocumentTypeIs)
|
||||
).toEqual(component.documentTypes)
|
||||
expect(
|
||||
component.getFilterSelectItems(TriggerFilterType.StoragePathIs)
|
||||
).toEqual(component.storagePaths)
|
||||
expect(component.getFilterSelectItems(TriggerFilterType.TagsAll)).toEqual(
|
||||
[]
|
||||
)
|
||||
|
||||
expect(
|
||||
component.isCustomFieldQueryFilter(TriggerFilterType.CustomFieldQuery)
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('should return empty select items when definition is missing', () => {
|
||||
const originalDefinitions = component.filterDefinitions
|
||||
component.filterDefinitions = []
|
||||
|
||||
expect(
|
||||
component.getFilterSelectItems(TriggerFilterType.CorrespondentIs)
|
||||
).toEqual([])
|
||||
|
||||
component.filterDefinitions = originalDefinitions
|
||||
})
|
||||
|
||||
it('should return empty select items when definition has unknown source', () => {
|
||||
const originalDefinitions = component.filterDefinitions
|
||||
component.filterDefinitions = [
|
||||
{
|
||||
id: TriggerFilterType.CorrespondentIs,
|
||||
name: 'Correspondent is',
|
||||
inputType: 'select',
|
||||
allowMultipleEntries: false,
|
||||
allowMultipleValues: false,
|
||||
selectItems: 'unknown',
|
||||
} as any,
|
||||
]
|
||||
|
||||
expect(
|
||||
component.getFilterSelectItems(TriggerFilterType.CorrespondentIs)
|
||||
).toEqual([])
|
||||
|
||||
component.filterDefinitions = originalDefinitions
|
||||
})
|
||||
|
||||
it('should handle custom field query selection change and validation states', () => {
|
||||
const formGroup = new FormGroup({
|
||||
values: new FormControl(null),
|
||||
})
|
||||
const model = new CustomFieldQueriesModel()
|
||||
|
||||
const changeSpy = jest.spyOn(
|
||||
component as any,
|
||||
'onCustomFieldQueryModelChanged'
|
||||
)
|
||||
|
||||
component.onCustomFieldQuerySelectionChange(formGroup, model)
|
||||
expect(changeSpy).toHaveBeenCalledWith(formGroup, model)
|
||||
|
||||
expect(component.isCustomFieldQueryValid(formGroup)).toBe(true)
|
||||
component['setCustomFieldQueryModel'](formGroup as any, model as any)
|
||||
|
||||
const validSpy = jest.spyOn(model, 'isValid').mockReturnValue(false)
|
||||
const emptySpy = jest.spyOn(model, 'isEmpty').mockReturnValue(false)
|
||||
expect(component.isCustomFieldQueryValid(formGroup)).toBe(false)
|
||||
expect(validSpy).toHaveBeenCalled()
|
||||
|
||||
validSpy.mockReturnValue(true)
|
||||
emptySpy.mockReturnValue(true)
|
||||
expect(component.isCustomFieldQueryValid(formGroup)).toBe(true)
|
||||
|
||||
emptySpy.mockReturnValue(false)
|
||||
expect(component.isCustomFieldQueryValid(formGroup)).toBe(true)
|
||||
|
||||
component['clearCustomFieldQueryModel'](formGroup as any)
|
||||
})
|
||||
|
||||
it('should recover from invalid custom field query json and update control on changes', () => {
|
||||
const filterGroup = new FormGroup({
|
||||
values: new FormControl('not-json'),
|
||||
})
|
||||
|
||||
component['ensureCustomFieldQueryModel'](filterGroup, 'not-json')
|
||||
|
||||
const model = component['getStoredCustomFieldQueryModel'](
|
||||
filterGroup as any
|
||||
)
|
||||
expect(model).toBeDefined()
|
||||
expect(model.queries.length).toBeGreaterThan(0)
|
||||
|
||||
const valuesControl = filterGroup.get('values')
|
||||
expect(valuesControl.value).toBeNull()
|
||||
|
||||
const expression = new CustomFieldQueryExpression([
|
||||
CustomFieldQueryLogicalOperator.And,
|
||||
[[1, 'exact', 'value']],
|
||||
])
|
||||
model.queries = [expression]
|
||||
|
||||
jest.spyOn(model, 'isValid').mockReturnValue(true)
|
||||
jest.spyOn(model, 'isEmpty').mockReturnValue(false)
|
||||
|
||||
model.changed.next(model)
|
||||
|
||||
expect(valuesControl.value).toEqual(JSON.stringify(expression.serialize()))
|
||||
|
||||
component['clearCustomFieldQueryModel'](filterGroup as any)
|
||||
})
|
||||
|
||||
it('should handle custom field query model change edge cases', () => {
|
||||
const groupWithoutControl = new FormGroup({})
|
||||
const dummyModel = {
|
||||
isValid: jest.fn().mockReturnValue(true),
|
||||
isEmpty: jest.fn().mockReturnValue(false),
|
||||
}
|
||||
|
||||
expect(() =>
|
||||
component['onCustomFieldQueryModelChanged'](
|
||||
groupWithoutControl as any,
|
||||
dummyModel as any
|
||||
)
|
||||
).not.toThrow()
|
||||
|
||||
const groupWithControl = new FormGroup({
|
||||
values: new FormControl('initial'),
|
||||
})
|
||||
const emptyModel = {
|
||||
isValid: jest.fn().mockReturnValue(true),
|
||||
isEmpty: jest.fn().mockReturnValue(true),
|
||||
}
|
||||
|
||||
component['onCustomFieldQueryModelChanged'](
|
||||
groupWithControl as any,
|
||||
emptyModel as any
|
||||
)
|
||||
|
||||
expect(groupWithControl.get('values').value).toBeNull()
|
||||
})
|
||||
|
||||
it('should normalize filter values for single and multi selects', () => {
|
||||
expect(
|
||||
component['normalizeFilterValue'](TriggerFilterType.TagsAny)
|
||||
).toEqual([])
|
||||
expect(
|
||||
component['normalizeFilterValue'](TriggerFilterType.TagsAny, 5)
|
||||
).toEqual([5])
|
||||
expect(
|
||||
component['normalizeFilterValue'](TriggerFilterType.TagsAny, [5, 6])
|
||||
).toEqual([5, 6])
|
||||
expect(
|
||||
component['normalizeFilterValue'](TriggerFilterType.CorrespondentIs, [7])
|
||||
).toEqual(7)
|
||||
expect(
|
||||
component['normalizeFilterValue'](TriggerFilterType.CorrespondentIs, 8)
|
||||
).toEqual(8)
|
||||
const customFieldJson = JSON.stringify(['AND', [[1, 'exact', 'test']]])
|
||||
expect(
|
||||
component['normalizeFilterValue'](
|
||||
TriggerFilterType.CustomFieldQuery,
|
||||
customFieldJson
|
||||
)
|
||||
).toEqual(customFieldJson)
|
||||
|
||||
const customFieldObject = ['AND', [[1, 'exact', 'other']]]
|
||||
expect(
|
||||
component['normalizeFilterValue'](
|
||||
TriggerFilterType.CustomFieldQuery,
|
||||
customFieldObject
|
||||
)
|
||||
).toEqual(JSON.stringify(customFieldObject))
|
||||
|
||||
expect(
|
||||
component['normalizeFilterValue'](
|
||||
TriggerFilterType.CustomFieldQuery,
|
||||
false
|
||||
)
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('should add and remove filter form groups', () => {
|
||||
component['changeDetector'] = { detectChanges: jest.fn() } as any
|
||||
component.object = undefined
|
||||
component.addTrigger()
|
||||
const triggerGroup = component.triggerFields.at(0) as FormGroup
|
||||
|
||||
component.addFilter(triggerGroup)
|
||||
|
||||
component.removeFilter(triggerGroup, 0)
|
||||
expect(component.getFiltersFormArray(triggerGroup).length).toBe(0)
|
||||
|
||||
component.addFilter(triggerGroup)
|
||||
const filterArrayAfterAdd = component.getFiltersFormArray(triggerGroup)
|
||||
filterArrayAfterAdd.at(0).get('type').setValue(TriggerFilterType.TagsAll)
|
||||
expect(component.getFiltersFormArray(triggerGroup).length).toBe(1)
|
||||
})
|
||||
|
||||
it('should remove selected custom field from the form group', () => {
|
||||
const formGroup = new FormGroup({
|
||||
assign_custom_fields: new FormControl([1, 2, 3]),
|
||||
|
@@ -6,7 +6,6 @@ import {
|
||||
import { NgTemplateOutlet } from '@angular/common'
|
||||
import { Component, OnInit, inject } from '@angular/core'
|
||||
import {
|
||||
AbstractControl,
|
||||
FormArray,
|
||||
FormControl,
|
||||
FormGroup,
|
||||
@@ -15,7 +14,7 @@ import {
|
||||
} from '@angular/forms'
|
||||
import { NgbAccordionModule } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||
import { Subscription, first, takeUntil } from 'rxjs'
|
||||
import { first } from 'rxjs'
|
||||
import { Correspondent } from 'src/app/data/correspondent'
|
||||
import { CustomField, CustomFieldDataType } from 'src/app/data/custom-field'
|
||||
import { DocumentType } from 'src/app/data/document-type'
|
||||
@@ -46,12 +45,7 @@ import { StoragePathService } from 'src/app/services/rest/storage-path.service'
|
||||
import { UserService } from 'src/app/services/rest/user.service'
|
||||
import { WorkflowService } from 'src/app/services/rest/workflow.service'
|
||||
import { SettingsService } from 'src/app/services/settings.service'
|
||||
import { CustomFieldQueryExpression } from 'src/app/utils/custom-field-query-element'
|
||||
import { ConfirmButtonComponent } from '../../confirm-button/confirm-button.component'
|
||||
import {
|
||||
CustomFieldQueriesModel,
|
||||
CustomFieldsQueryDropdownComponent,
|
||||
} from '../../custom-fields-query-dropdown/custom-fields-query-dropdown.component'
|
||||
import { CheckComponent } from '../../input/check/check.component'
|
||||
import { CustomFieldsValuesComponent } from '../../input/custom-fields-values/custom-fields-values.component'
|
||||
import { EntriesComponent } from '../../input/entries/entries.component'
|
||||
@@ -141,235 +135,10 @@ export const WORKFLOW_ACTION_OPTIONS = [
|
||||
},
|
||||
]
|
||||
|
||||
export enum TriggerFilterType {
|
||||
TagsAny = 'tags_any',
|
||||
TagsAll = 'tags_all',
|
||||
TagsNone = 'tags_none',
|
||||
CorrespondentIs = 'correspondent_is',
|
||||
CorrespondentNot = 'correspondent_not',
|
||||
DocumentTypeIs = 'document_type_is',
|
||||
DocumentTypeNot = 'document_type_not',
|
||||
StoragePathIs = 'storage_path_is',
|
||||
StoragePathNot = 'storage_path_not',
|
||||
CustomFieldQuery = 'custom_field_query',
|
||||
}
|
||||
|
||||
interface TriggerFilterDefinition {
|
||||
id: TriggerFilterType
|
||||
name: string
|
||||
inputType: 'tags' | 'select' | 'customFieldQuery'
|
||||
allowMultipleEntries: boolean
|
||||
allowMultipleValues: boolean
|
||||
selectItems?: 'correspondents' | 'documentTypes' | 'storagePaths'
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
type TriggerFilterOption = TriggerFilterDefinition & {
|
||||
disabled?: boolean
|
||||
}
|
||||
|
||||
type TriggerFilterAggregate = {
|
||||
filter_has_tags: number[]
|
||||
filter_has_all_tags: number[]
|
||||
filter_has_not_tags: number[]
|
||||
filter_has_not_correspondents: number[]
|
||||
filter_has_not_document_types: number[]
|
||||
filter_has_not_storage_paths: number[]
|
||||
filter_has_correspondent: number | null
|
||||
filter_has_document_type: number | null
|
||||
filter_has_storage_path: number | null
|
||||
filter_custom_field_query: string | null
|
||||
}
|
||||
|
||||
interface FilterHandler {
|
||||
apply: (aggregate: TriggerFilterAggregate, values: any) => void
|
||||
extract: (trigger: WorkflowTrigger) => any
|
||||
hasValue: (value: any) => boolean
|
||||
}
|
||||
|
||||
const CUSTOM_FIELD_QUERY_MODEL_KEY = Symbol('customFieldQueryModel')
|
||||
const CUSTOM_FIELD_QUERY_SUBSCRIPTION_KEY = Symbol(
|
||||
'customFieldQuerySubscription'
|
||||
)
|
||||
|
||||
type CustomFieldFilterGroup = FormGroup & {
|
||||
[CUSTOM_FIELD_QUERY_MODEL_KEY]?: CustomFieldQueriesModel
|
||||
[CUSTOM_FIELD_QUERY_SUBSCRIPTION_KEY]?: Subscription
|
||||
}
|
||||
|
||||
const TRIGGER_FILTER_DEFINITIONS: TriggerFilterDefinition[] = [
|
||||
{
|
||||
id: TriggerFilterType.TagsAny,
|
||||
name: $localize`Has any of these tags`,
|
||||
inputType: 'tags',
|
||||
allowMultipleEntries: false,
|
||||
allowMultipleValues: true,
|
||||
},
|
||||
{
|
||||
id: TriggerFilterType.TagsAll,
|
||||
name: $localize`Has all of these tags`,
|
||||
inputType: 'tags',
|
||||
allowMultipleEntries: false,
|
||||
allowMultipleValues: true,
|
||||
},
|
||||
{
|
||||
id: TriggerFilterType.TagsNone,
|
||||
name: $localize`Does not have these tags`,
|
||||
inputType: 'tags',
|
||||
allowMultipleEntries: false,
|
||||
allowMultipleValues: true,
|
||||
},
|
||||
{
|
||||
id: TriggerFilterType.CorrespondentIs,
|
||||
name: $localize`Has correspondent`,
|
||||
inputType: 'select',
|
||||
allowMultipleEntries: false,
|
||||
allowMultipleValues: false,
|
||||
selectItems: 'correspondents',
|
||||
},
|
||||
{
|
||||
id: TriggerFilterType.CorrespondentNot,
|
||||
name: $localize`Does not have correspondents`,
|
||||
inputType: 'select',
|
||||
allowMultipleEntries: false,
|
||||
allowMultipleValues: true,
|
||||
selectItems: 'correspondents',
|
||||
},
|
||||
{
|
||||
id: TriggerFilterType.DocumentTypeIs,
|
||||
name: $localize`Has document type`,
|
||||
inputType: 'select',
|
||||
allowMultipleEntries: false,
|
||||
allowMultipleValues: false,
|
||||
selectItems: 'documentTypes',
|
||||
},
|
||||
{
|
||||
id: TriggerFilterType.DocumentTypeNot,
|
||||
name: $localize`Does not have document types`,
|
||||
inputType: 'select',
|
||||
allowMultipleEntries: false,
|
||||
allowMultipleValues: true,
|
||||
selectItems: 'documentTypes',
|
||||
},
|
||||
{
|
||||
id: TriggerFilterType.StoragePathIs,
|
||||
name: $localize`Has storage path`,
|
||||
inputType: 'select',
|
||||
allowMultipleEntries: false,
|
||||
allowMultipleValues: false,
|
||||
selectItems: 'storagePaths',
|
||||
},
|
||||
{
|
||||
id: TriggerFilterType.StoragePathNot,
|
||||
name: $localize`Does not have storage paths`,
|
||||
inputType: 'select',
|
||||
allowMultipleEntries: false,
|
||||
allowMultipleValues: true,
|
||||
selectItems: 'storagePaths',
|
||||
},
|
||||
{
|
||||
id: TriggerFilterType.CustomFieldQuery,
|
||||
name: $localize`Matches custom field query`,
|
||||
inputType: 'customFieldQuery',
|
||||
allowMultipleEntries: false,
|
||||
allowMultipleValues: false,
|
||||
},
|
||||
]
|
||||
|
||||
const TRIGGER_MATCHING_ALGORITHMS = MATCHING_ALGORITHMS.filter(
|
||||
(a) => a.id !== MATCH_AUTO
|
||||
)
|
||||
|
||||
const FILTER_HANDLERS: Record<TriggerFilterType, FilterHandler> = {
|
||||
[TriggerFilterType.TagsAny]: {
|
||||
apply: (aggregate, values) => {
|
||||
aggregate.filter_has_tags = Array.isArray(values) ? [...values] : [values]
|
||||
},
|
||||
extract: (trigger) => trigger.filter_has_tags,
|
||||
hasValue: (value) => Array.isArray(value) && value.length > 0,
|
||||
},
|
||||
[TriggerFilterType.TagsAll]: {
|
||||
apply: (aggregate, values) => {
|
||||
aggregate.filter_has_all_tags = Array.isArray(values)
|
||||
? [...values]
|
||||
: [values]
|
||||
},
|
||||
extract: (trigger) => trigger.filter_has_all_tags,
|
||||
hasValue: (value) => Array.isArray(value) && value.length > 0,
|
||||
},
|
||||
[TriggerFilterType.TagsNone]: {
|
||||
apply: (aggregate, values) => {
|
||||
aggregate.filter_has_not_tags = Array.isArray(values)
|
||||
? [...values]
|
||||
: [values]
|
||||
},
|
||||
extract: (trigger) => trigger.filter_has_not_tags,
|
||||
hasValue: (value) => Array.isArray(value) && value.length > 0,
|
||||
},
|
||||
[TriggerFilterType.CorrespondentIs]: {
|
||||
apply: (aggregate, values) => {
|
||||
aggregate.filter_has_correspondent = Array.isArray(values)
|
||||
? (values[0] ?? null)
|
||||
: values
|
||||
},
|
||||
extract: (trigger) => trigger.filter_has_correspondent,
|
||||
hasValue: (value) => value !== null && value !== undefined,
|
||||
},
|
||||
[TriggerFilterType.CorrespondentNot]: {
|
||||
apply: (aggregate, values) => {
|
||||
aggregate.filter_has_not_correspondents = Array.isArray(values)
|
||||
? [...values]
|
||||
: [values]
|
||||
},
|
||||
extract: (trigger) => trigger.filter_has_not_correspondents,
|
||||
hasValue: (value) => Array.isArray(value) && value.length > 0,
|
||||
},
|
||||
[TriggerFilterType.DocumentTypeIs]: {
|
||||
apply: (aggregate, values) => {
|
||||
aggregate.filter_has_document_type = Array.isArray(values)
|
||||
? (values[0] ?? null)
|
||||
: values
|
||||
},
|
||||
extract: (trigger) => trigger.filter_has_document_type,
|
||||
hasValue: (value) => value !== null && value !== undefined,
|
||||
},
|
||||
[TriggerFilterType.DocumentTypeNot]: {
|
||||
apply: (aggregate, values) => {
|
||||
aggregate.filter_has_not_document_types = Array.isArray(values)
|
||||
? [...values]
|
||||
: [values]
|
||||
},
|
||||
extract: (trigger) => trigger.filter_has_not_document_types,
|
||||
hasValue: (value) => Array.isArray(value) && value.length > 0,
|
||||
},
|
||||
[TriggerFilterType.StoragePathIs]: {
|
||||
apply: (aggregate, values) => {
|
||||
aggregate.filter_has_storage_path = Array.isArray(values)
|
||||
? (values[0] ?? null)
|
||||
: values
|
||||
},
|
||||
extract: (trigger) => trigger.filter_has_storage_path,
|
||||
hasValue: (value) => value !== null && value !== undefined,
|
||||
},
|
||||
[TriggerFilterType.StoragePathNot]: {
|
||||
apply: (aggregate, values) => {
|
||||
aggregate.filter_has_not_storage_paths = Array.isArray(values)
|
||||
? [...values]
|
||||
: [values]
|
||||
},
|
||||
extract: (trigger) => trigger.filter_has_not_storage_paths,
|
||||
hasValue: (value) => Array.isArray(value) && value.length > 0,
|
||||
},
|
||||
[TriggerFilterType.CustomFieldQuery]: {
|
||||
apply: (aggregate, values) => {
|
||||
aggregate.filter_custom_field_query = values as string
|
||||
},
|
||||
extract: (trigger) => trigger.filter_custom_field_query,
|
||||
hasValue: (value) =>
|
||||
typeof value === 'string' && value !== null && value.trim().length > 0,
|
||||
},
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'pngx-workflow-edit-dialog',
|
||||
templateUrl: './workflow-edit-dialog.component.html',
|
||||
@@ -384,7 +153,6 @@ const FILTER_HANDLERS: Record<TriggerFilterType, FilterHandler> = {
|
||||
TextAreaComponent,
|
||||
TagsComponent,
|
||||
CustomFieldsValuesComponent,
|
||||
CustomFieldsQueryDropdownComponent,
|
||||
PermissionsGroupComponent,
|
||||
PermissionsUserComponent,
|
||||
ConfirmButtonComponent,
|
||||
@@ -402,8 +170,6 @@ export class WorkflowEditDialogComponent
|
||||
{
|
||||
public WorkflowTriggerType = WorkflowTriggerType
|
||||
public WorkflowActionType = WorkflowActionType
|
||||
public TriggerFilterType = TriggerFilterType
|
||||
public filterDefinitions = TRIGGER_FILTER_DEFINITIONS
|
||||
|
||||
private correspondentService: CorrespondentService
|
||||
private documentTypeService: DocumentTypeService
|
||||
@@ -423,11 +189,6 @@ export class WorkflowEditDialogComponent
|
||||
|
||||
private allowedActionTypes = []
|
||||
|
||||
private readonly triggerFilterOptionsMap = new WeakMap<
|
||||
FormArray,
|
||||
TriggerFilterOption[]
|
||||
>()
|
||||
|
||||
constructor() {
|
||||
super()
|
||||
this.service = inject(WorkflowService)
|
||||
@@ -629,416 +390,6 @@ export class WorkflowEditDialogComponent
|
||||
return this.objectForm.get('actions') as FormArray
|
||||
}
|
||||
|
||||
protected override getFormValues(): any {
|
||||
const formValues = super.getFormValues()
|
||||
|
||||
if (formValues?.triggers?.length) {
|
||||
formValues.triggers = formValues.triggers.map(
|
||||
(trigger: any, index: number) => {
|
||||
const triggerFormGroup = this.triggerFields.at(index) as FormGroup
|
||||
const filters = this.getFiltersFormArray(triggerFormGroup)
|
||||
|
||||
const aggregate: TriggerFilterAggregate = {
|
||||
filter_has_tags: [],
|
||||
filter_has_all_tags: [],
|
||||
filter_has_not_tags: [],
|
||||
filter_has_not_correspondents: [],
|
||||
filter_has_not_document_types: [],
|
||||
filter_has_not_storage_paths: [],
|
||||
filter_has_correspondent: null,
|
||||
filter_has_document_type: null,
|
||||
filter_has_storage_path: null,
|
||||
filter_custom_field_query: null,
|
||||
}
|
||||
|
||||
for (const control of filters.controls) {
|
||||
const type = control.get('type').value as TriggerFilterType
|
||||
const values = control.get('values').value
|
||||
|
||||
if (values === null || values === undefined) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (Array.isArray(values) && values.length === 0) {
|
||||
continue
|
||||
}
|
||||
|
||||
const handler = FILTER_HANDLERS[type]
|
||||
handler?.apply(aggregate, values)
|
||||
}
|
||||
|
||||
trigger.filter_has_tags = aggregate.filter_has_tags
|
||||
trigger.filter_has_all_tags = aggregate.filter_has_all_tags
|
||||
trigger.filter_has_not_tags = aggregate.filter_has_not_tags
|
||||
trigger.filter_has_not_correspondents =
|
||||
aggregate.filter_has_not_correspondents
|
||||
trigger.filter_has_not_document_types =
|
||||
aggregate.filter_has_not_document_types
|
||||
trigger.filter_has_not_storage_paths =
|
||||
aggregate.filter_has_not_storage_paths
|
||||
trigger.filter_has_correspondent =
|
||||
aggregate.filter_has_correspondent ?? null
|
||||
trigger.filter_has_document_type =
|
||||
aggregate.filter_has_document_type ?? null
|
||||
trigger.filter_has_storage_path =
|
||||
aggregate.filter_has_storage_path ?? null
|
||||
trigger.filter_custom_field_query =
|
||||
aggregate.filter_custom_field_query ?? null
|
||||
|
||||
delete trigger.filters
|
||||
|
||||
return trigger
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
return formValues
|
||||
}
|
||||
|
||||
public matchingPatternRequired(formGroup: FormGroup): boolean {
|
||||
return formGroup.get('matching_algorithm').value !== MATCH_NONE
|
||||
}
|
||||
|
||||
private createFilterFormGroup(
|
||||
type: TriggerFilterType,
|
||||
initialValue?: any
|
||||
): FormGroup {
|
||||
const group = new FormGroup({
|
||||
type: new FormControl(type),
|
||||
values: new FormControl(this.normalizeFilterValue(type, initialValue)),
|
||||
})
|
||||
|
||||
group.get('type').valueChanges.subscribe((newType: TriggerFilterType) => {
|
||||
if (newType === TriggerFilterType.CustomFieldQuery) {
|
||||
this.ensureCustomFieldQueryModel(group)
|
||||
} else {
|
||||
this.clearCustomFieldQueryModel(group)
|
||||
group.get('values').setValue(this.getDefaultFilterValue(newType), {
|
||||
emitEvent: false,
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
if (type === TriggerFilterType.CustomFieldQuery) {
|
||||
this.ensureCustomFieldQueryModel(group, initialValue)
|
||||
}
|
||||
|
||||
return group
|
||||
}
|
||||
|
||||
private buildFiltersFormArray(trigger: WorkflowTrigger): FormArray {
|
||||
const filters = new FormArray([])
|
||||
|
||||
for (const definition of this.filterDefinitions) {
|
||||
const handler = FILTER_HANDLERS[definition.id]
|
||||
if (!handler) {
|
||||
continue
|
||||
}
|
||||
|
||||
const value = handler.extract(trigger)
|
||||
if (!handler.hasValue(value)) {
|
||||
continue
|
||||
}
|
||||
|
||||
filters.push(this.createFilterFormGroup(definition.id, value))
|
||||
}
|
||||
|
||||
return filters
|
||||
}
|
||||
|
||||
getFiltersFormArray(formGroup: FormGroup): FormArray {
|
||||
return formGroup.get('filters') as FormArray
|
||||
}
|
||||
|
||||
getFilterTypeOptions(formGroup: FormGroup, filterIndex: number) {
|
||||
const filters = this.getFiltersFormArray(formGroup)
|
||||
const options = this.getFilterTypeOptionsForArray(filters)
|
||||
const currentType = filters.at(filterIndex).get('type')
|
||||
.value as TriggerFilterType
|
||||
const usedTypes = new Set(
|
||||
filters.controls.map(
|
||||
(control) => control.get('type').value as TriggerFilterType
|
||||
)
|
||||
)
|
||||
|
||||
for (const option of options) {
|
||||
if (option.allowMultipleEntries) {
|
||||
option.disabled = false
|
||||
continue
|
||||
}
|
||||
|
||||
option.disabled = usedTypes.has(option.id) && option.id !== currentType
|
||||
}
|
||||
|
||||
return options
|
||||
}
|
||||
|
||||
canAddFilter(formGroup: FormGroup): boolean {
|
||||
const filters = this.getFiltersFormArray(formGroup)
|
||||
const usedTypes = new Set(
|
||||
filters.controls.map(
|
||||
(control) => control.get('type').value as TriggerFilterType
|
||||
)
|
||||
)
|
||||
|
||||
return this.filterDefinitions.some((definition) => {
|
||||
if (definition.allowMultipleEntries) {
|
||||
return true
|
||||
}
|
||||
return !usedTypes.has(definition.id)
|
||||
})
|
||||
}
|
||||
|
||||
addFilter(triggerFormGroup: FormGroup): FormGroup | null {
|
||||
const triggerIndex = this.triggerFields.controls.indexOf(triggerFormGroup)
|
||||
if (triggerIndex === -1) {
|
||||
return null
|
||||
}
|
||||
|
||||
const filters = this.getFiltersFormArray(triggerFormGroup)
|
||||
|
||||
const availableDefinition = this.filterDefinitions.find((definition) => {
|
||||
if (definition.allowMultipleEntries) {
|
||||
return true
|
||||
}
|
||||
return !filters.controls.some(
|
||||
(control) => control.get('type').value === definition.id
|
||||
)
|
||||
})
|
||||
|
||||
if (!availableDefinition) {
|
||||
return null
|
||||
}
|
||||
|
||||
filters.push(this.createFilterFormGroup(availableDefinition.id))
|
||||
triggerFormGroup.markAsDirty()
|
||||
triggerFormGroup.markAsTouched()
|
||||
|
||||
return filters.at(-1) as FormGroup
|
||||
}
|
||||
|
||||
removeFilter(triggerFormGroup: FormGroup, filterIndex: number) {
|
||||
const triggerIndex = this.triggerFields.controls.indexOf(triggerFormGroup)
|
||||
if (triggerIndex === -1) {
|
||||
return
|
||||
}
|
||||
|
||||
const filters = this.getFiltersFormArray(triggerFormGroup)
|
||||
const filterGroup = filters.at(filterIndex) as FormGroup
|
||||
if (filterGroup?.get('type').value === TriggerFilterType.CustomFieldQuery) {
|
||||
this.clearCustomFieldQueryModel(filterGroup)
|
||||
}
|
||||
filters.removeAt(filterIndex)
|
||||
triggerFormGroup.markAsDirty()
|
||||
triggerFormGroup.markAsTouched()
|
||||
}
|
||||
|
||||
getFilterDefinition(
|
||||
type: TriggerFilterType
|
||||
): TriggerFilterDefinition | undefined {
|
||||
return this.filterDefinitions.find((definition) => definition.id === type)
|
||||
}
|
||||
|
||||
getFilterName(type: TriggerFilterType): string {
|
||||
return this.getFilterDefinition(type)?.name ?? ''
|
||||
}
|
||||
|
||||
isTagsFilter(type: TriggerFilterType): boolean {
|
||||
return this.getFilterDefinition(type)?.inputType === 'tags'
|
||||
}
|
||||
|
||||
isCustomFieldQueryFilter(type: TriggerFilterType): boolean {
|
||||
return this.getFilterDefinition(type)?.inputType === 'customFieldQuery'
|
||||
}
|
||||
|
||||
isMultiValueFilter(type: TriggerFilterType): boolean {
|
||||
switch (type) {
|
||||
case TriggerFilterType.TagsAny:
|
||||
case TriggerFilterType.TagsAll:
|
||||
case TriggerFilterType.TagsNone:
|
||||
case TriggerFilterType.CorrespondentNot:
|
||||
case TriggerFilterType.DocumentTypeNot:
|
||||
case TriggerFilterType.StoragePathNot:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
isSelectMultiple(type: TriggerFilterType): boolean {
|
||||
return !this.isTagsFilter(type) && this.isMultiValueFilter(type)
|
||||
}
|
||||
|
||||
getFilterSelectItems(type: TriggerFilterType) {
|
||||
const definition = this.getFilterDefinition(type)
|
||||
if (!definition || definition.inputType !== 'select') {
|
||||
return []
|
||||
}
|
||||
|
||||
switch (definition.selectItems) {
|
||||
case 'correspondents':
|
||||
return this.correspondents
|
||||
case 'documentTypes':
|
||||
return this.documentTypes
|
||||
case 'storagePaths':
|
||||
return this.storagePaths
|
||||
default:
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
getCustomFieldQueryModel(control: AbstractControl): CustomFieldQueriesModel {
|
||||
return this.ensureCustomFieldQueryModel(control as FormGroup)
|
||||
}
|
||||
|
||||
onCustomFieldQuerySelectionChange(
|
||||
control: AbstractControl,
|
||||
model: CustomFieldQueriesModel
|
||||
) {
|
||||
this.onCustomFieldQueryModelChanged(control as FormGroup, model)
|
||||
}
|
||||
|
||||
isCustomFieldQueryValid(control: AbstractControl): boolean {
|
||||
const model = this.getStoredCustomFieldQueryModel(control as FormGroup)
|
||||
if (!model) {
|
||||
return true
|
||||
}
|
||||
|
||||
return model.isEmpty() || model.isValid()
|
||||
}
|
||||
|
||||
private getFilterTypeOptionsForArray(
|
||||
filters: FormArray
|
||||
): TriggerFilterOption[] {
|
||||
let cached = this.triggerFilterOptionsMap.get(filters)
|
||||
if (!cached) {
|
||||
cached = this.filterDefinitions.map((definition) => ({
|
||||
...definition,
|
||||
disabled: false,
|
||||
}))
|
||||
this.triggerFilterOptionsMap.set(filters, cached)
|
||||
}
|
||||
return cached
|
||||
}
|
||||
|
||||
private ensureCustomFieldQueryModel(
|
||||
filterGroup: FormGroup,
|
||||
initialValue?: any
|
||||
): CustomFieldQueriesModel {
|
||||
const existingModel = this.getStoredCustomFieldQueryModel(filterGroup)
|
||||
if (existingModel) {
|
||||
return existingModel
|
||||
}
|
||||
|
||||
const model = new CustomFieldQueriesModel()
|
||||
this.setCustomFieldQueryModel(filterGroup, model)
|
||||
|
||||
const rawValue =
|
||||
typeof initialValue === 'string'
|
||||
? initialValue
|
||||
: (filterGroup.get('values').value as string)
|
||||
|
||||
if (rawValue) {
|
||||
try {
|
||||
const parsed = JSON.parse(rawValue)
|
||||
const expression = new CustomFieldQueryExpression(parsed)
|
||||
model.queries = [expression]
|
||||
} catch {
|
||||
model.clear(false)
|
||||
model.addInitialAtom()
|
||||
}
|
||||
}
|
||||
|
||||
const subscription = model.changed
|
||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe(() => {
|
||||
this.onCustomFieldQueryModelChanged(filterGroup, model)
|
||||
})
|
||||
filterGroup[CUSTOM_FIELD_QUERY_SUBSCRIPTION_KEY]?.unsubscribe()
|
||||
filterGroup[CUSTOM_FIELD_QUERY_SUBSCRIPTION_KEY] = subscription
|
||||
|
||||
this.onCustomFieldQueryModelChanged(filterGroup, model)
|
||||
|
||||
return model
|
||||
}
|
||||
|
||||
private clearCustomFieldQueryModel(filterGroup: FormGroup) {
|
||||
const group = filterGroup as CustomFieldFilterGroup
|
||||
group[CUSTOM_FIELD_QUERY_SUBSCRIPTION_KEY]?.unsubscribe()
|
||||
delete group[CUSTOM_FIELD_QUERY_SUBSCRIPTION_KEY]
|
||||
delete group[CUSTOM_FIELD_QUERY_MODEL_KEY]
|
||||
}
|
||||
|
||||
private getStoredCustomFieldQueryModel(
|
||||
filterGroup: FormGroup
|
||||
): CustomFieldQueriesModel | null {
|
||||
return (
|
||||
(filterGroup as CustomFieldFilterGroup)[CUSTOM_FIELD_QUERY_MODEL_KEY] ??
|
||||
null
|
||||
)
|
||||
}
|
||||
|
||||
private setCustomFieldQueryModel(
|
||||
filterGroup: FormGroup,
|
||||
model: CustomFieldQueriesModel
|
||||
) {
|
||||
const group = filterGroup as CustomFieldFilterGroup
|
||||
group[CUSTOM_FIELD_QUERY_MODEL_KEY] = model
|
||||
}
|
||||
|
||||
private onCustomFieldQueryModelChanged(
|
||||
filterGroup: FormGroup,
|
||||
model: CustomFieldQueriesModel
|
||||
) {
|
||||
const control = filterGroup.get('values')
|
||||
if (!control) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!model.isValid()) {
|
||||
control.setValue(null, { emitEvent: false })
|
||||
return
|
||||
}
|
||||
|
||||
if (model.isEmpty()) {
|
||||
control.setValue(null, { emitEvent: false })
|
||||
return
|
||||
}
|
||||
|
||||
const serialized = JSON.stringify(model.queries[0].serialize())
|
||||
control.setValue(serialized, { emitEvent: false })
|
||||
}
|
||||
|
||||
private getDefaultFilterValue(type: TriggerFilterType) {
|
||||
if (type === TriggerFilterType.CustomFieldQuery) {
|
||||
return null
|
||||
}
|
||||
return this.isMultiValueFilter(type) ? [] : null
|
||||
}
|
||||
|
||||
private normalizeFilterValue(type: TriggerFilterType, value?: any) {
|
||||
if (value === undefined || value === null) {
|
||||
return this.getDefaultFilterValue(type)
|
||||
}
|
||||
|
||||
if (type === TriggerFilterType.CustomFieldQuery) {
|
||||
if (typeof value === 'string') {
|
||||
return value
|
||||
}
|
||||
return value ? JSON.stringify(value) : null
|
||||
}
|
||||
|
||||
if (this.isMultiValueFilter(type)) {
|
||||
return Array.isArray(value) ? [...value] : [value]
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return value.length > 0 ? value[0] : null
|
||||
}
|
||||
|
||||
return value
|
||||
}
|
||||
|
||||
private createTriggerField(
|
||||
trigger: WorkflowTrigger,
|
||||
emitEvent: boolean = false
|
||||
@@ -1054,7 +405,13 @@ export class WorkflowEditDialogComponent
|
||||
matching_algorithm: new FormControl(trigger.matching_algorithm),
|
||||
match: new FormControl(trigger.match),
|
||||
is_insensitive: new FormControl(trigger.is_insensitive),
|
||||
filters: this.buildFiltersFormArray(trigger),
|
||||
filter_has_tags: new FormControl(trigger.filter_has_tags),
|
||||
filter_has_correspondent: new FormControl(
|
||||
trigger.filter_has_correspondent
|
||||
),
|
||||
filter_has_document_type: new FormControl(
|
||||
trigger.filter_has_document_type
|
||||
),
|
||||
schedule_offset_days: new FormControl(trigger.schedule_offset_days),
|
||||
schedule_is_recurring: new FormControl(trigger.schedule_is_recurring),
|
||||
schedule_recurring_interval_days: new FormControl(
|
||||
@@ -1177,15 +534,8 @@ export class WorkflowEditDialogComponent
|
||||
filter_path: null,
|
||||
filter_mailrule: null,
|
||||
filter_has_tags: [],
|
||||
filter_has_all_tags: [],
|
||||
filter_has_not_tags: [],
|
||||
filter_has_not_correspondents: [],
|
||||
filter_has_not_document_types: [],
|
||||
filter_has_not_storage_paths: [],
|
||||
filter_custom_field_query: null,
|
||||
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
|
||||
}
|
||||
|
@@ -1,18 +1,19 @@
|
||||
<div class="mb-3">
|
||||
@if (title) {
|
||||
<label class="form-label" [for]="inputId">{{title}}</label>
|
||||
<label [for]="inputId">{{title}}</label>
|
||||
}
|
||||
|
||||
<div class="input-group" [class.is-invalid]="error">
|
||||
<button type="button" class="input-group-text" [style.background-color]="value" (click)="colorPicker.toggle()"> </button>
|
||||
<span class="input-group-text" [style.background-color]="value"> </span>
|
||||
|
||||
<ng-template #popContent>
|
||||
<div style="min-width: 200px;" class="pb-3">
|
||||
<color-slider [color]="value" (onChangeComplete)="colorChanged($event.color.hex)"></color-slider>
|
||||
</div>
|
||||
|
||||
</ng-template>
|
||||
|
||||
<input #inputField class="form-control" [class.is-invalid]="error" [id]="inputId" [(ngModel)]="value" (change)="onChange(value)" [autoClose]="'outside'" [ngbPopover]="popContent" #colorPicker="ngbPopover" placement="bottom" popoverClass="shadow">
|
||||
<input #inputField class="form-control" [class.is-invalid]="error" [id]="inputId" [(ngModel)]="value" (change)="onChange(value)" [autoClose]="'outside'" [ngbPopover]="popContent" placement="bottom" popoverClass="shadow">
|
||||
|
||||
<button class="btn btn-outline-secondary" type="button" (click)="randomize()">
|
||||
<i-bs name="dice5"></i-bs>
|
||||
|
@@ -42,8 +42,8 @@ describe('ColorComponent', () => {
|
||||
})
|
||||
|
||||
it('should set swatch color', () => {
|
||||
const swatch: HTMLButtonElement = fixture.nativeElement.querySelector(
|
||||
'button.input-group-text'
|
||||
const swatch: HTMLSpanElement = fixture.nativeElement.querySelector(
|
||||
'span.input-group-text'
|
||||
)
|
||||
expect(swatch.style.backgroundColor).toEqual('')
|
||||
component.value = '#ff0000'
|
||||
|
@@ -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> {
|
||||
|
@@ -1,68 +1,66 @@
|
||||
<div class="mb-3 paperless-input-select" [class.disabled]="disabled">
|
||||
<div class="row">
|
||||
@if (title || removable) {
|
||||
<div class="d-flex align-items-center position-relative hidden-button-container" [class.col-md-3]="horizontal">
|
||||
@if (title) {
|
||||
<label class="form-label" [class.mb-md-0]="horizontal" [for]="inputId">{{title}}</label>
|
||||
<div class="d-flex align-items-center position-relative hidden-button-container" [class.col-md-3]="horizontal">
|
||||
@if (title) {
|
||||
<label class="form-label" [class.mb-md-0]="horizontal" [for]="inputId">{{title}}</label>
|
||||
}
|
||||
@if (removable) {
|
||||
<button type="button" class="btn btn-sm btn-danger position-absolute left-0" (click)="removed.emit(this)">
|
||||
<i-bs name="x"></i-bs> <ng-container i18n>Remove</ng-container>
|
||||
</button>
|
||||
}
|
||||
@if (removable) {
|
||||
<button type="button" class="btn btn-sm btn-danger position-absolute left-0" (click)="removed.emit(this)">
|
||||
<i-bs name="x"></i-bs> <ng-container i18n>Remove</ng-container>
|
||||
</div>
|
||||
<div [class.col-md-9]="horizontal">
|
||||
<div [class.input-group]="allowCreateNew || showFilter" [class.is-invalid]="error">
|
||||
<ng-select name="inputId" [(ngModel)]="value"
|
||||
[disabled]="disabled"
|
||||
[style.color]="textColor"
|
||||
[style.background]="backgroundColor"
|
||||
[class.private]="isPrivate"
|
||||
[clearable]="allowNull"
|
||||
[items]="items"
|
||||
[addTag]="allowCreateNew && addItemRef"
|
||||
addTagText="Add item"
|
||||
i18n-addTagText="Used for both types, correspondents, storage paths"
|
||||
[placeholder]="placeholder"
|
||||
[notFoundText]="notFoundText"
|
||||
[multiple]="multiple"
|
||||
[bindLabel]="bindLabel"
|
||||
bindValue="id"
|
||||
(change)="onChange(value)"
|
||||
(search)="onSearch($event)"
|
||||
(focus)="clearLastSearchTerm()"
|
||||
(clear)="clearLastSearchTerm()"
|
||||
(blur)="onBlur()">
|
||||
<ng-template ng-option-tmp let-item="item">
|
||||
<span [title]="item[bindLabel]">{{item[bindLabel]}}</span>
|
||||
</ng-template>
|
||||
</ng-select>
|
||||
@if (allowCreateNew && !hideAddButton) {
|
||||
<button class="btn btn-outline-secondary" type="button" (click)="addItem()" [disabled]="disabled">
|
||||
<i-bs width="1.2em" height="1.2em" name="plus"></i-bs>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
<div [class.col-md-9]="horizontal">
|
||||
<div [class.input-group]="allowCreateNew || showFilter" [class.is-invalid]="error">
|
||||
<ng-select name="inputId" [(ngModel)]="value"
|
||||
[disabled]="disabled"
|
||||
[style.color]="textColor"
|
||||
[style.background]="backgroundColor"
|
||||
[class.private]="isPrivate"
|
||||
[clearable]="allowNull"
|
||||
[items]="items"
|
||||
[addTag]="allowCreateNew && addItemRef"
|
||||
addTagText="Add item"
|
||||
i18n-addTagText="Used for both types, correspondents, storage paths"
|
||||
[placeholder]="placeholder"
|
||||
[notFoundText]="notFoundText"
|
||||
[multiple]="multiple"
|
||||
[bindLabel]="bindLabel"
|
||||
bindValue="id"
|
||||
(change)="onChange(value)"
|
||||
(search)="onSearch($event)"
|
||||
(focus)="clearLastSearchTerm()"
|
||||
(clear)="clearLastSearchTerm()"
|
||||
(blur)="onBlur()">
|
||||
<ng-template ng-option-tmp let-item="item">
|
||||
<span [title]="item[bindLabel]">{{item[bindLabel]}}</span>
|
||||
</ng-template>
|
||||
</ng-select>
|
||||
@if (allowCreateNew && !hideAddButton) {
|
||||
<button class="btn btn-outline-secondary" type="button" (click)="addItem()" [disabled]="disabled">
|
||||
<i-bs width="1.2em" height="1.2em" name="plus"></i-bs>
|
||||
</button>
|
||||
}
|
||||
@if (showFilter) {
|
||||
<button class="btn btn-outline-secondary" type="button" (click)="onFilterDocuments()" [disabled]="isPrivate || this.value === null" title="{{ filterButtonTitle }}">
|
||||
<i-bs width="1.2em" height="1.2em" name="filter"></i-bs>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
<div class="invalid-feedback">
|
||||
{{error}}
|
||||
</div>
|
||||
@if (hint) {
|
||||
<small class="form-text text-muted">{{hint}}</small>
|
||||
}
|
||||
@if (getSuggestions().length > 0) {
|
||||
<small>
|
||||
<span i18n>Suggestions:</span>
|
||||
@for (s of getSuggestions(); track s) {
|
||||
<a (click)="value = s.id; onChange(value)" [routerLink]="[]">{{s.name}}</a>
|
||||
}
|
||||
</small>
|
||||
}
|
||||
@if (showFilter) {
|
||||
<button class="btn btn-outline-secondary" type="button" (click)="onFilterDocuments()" [disabled]="isPrivate || this.value === null" title="{{ filterButtonTitle }}">
|
||||
<i-bs width="1.2em" height="1.2em" name="filter"></i-bs>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
<div class="invalid-feedback">
|
||||
{{error}}
|
||||
</div>
|
||||
@if (hint) {
|
||||
<small class="form-text text-muted">{{hint}}</small>
|
||||
}
|
||||
@if (getSuggestions().length > 0) {
|
||||
<small>
|
||||
<span i18n>Suggestions:</span>
|
||||
@for (s of getSuggestions(); track s) {
|
||||
<a (click)="value = s.id; onChange(value)" [routerLink]="[]">{{s.name}}</a>
|
||||
}
|
||||
</small>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@@ -1,22 +1,19 @@
|
||||
<div class="mb-3 paperless-input-select paperless-input-tags" [class.disabled]="disabled" [class.pb-3]="getSuggestions().length > 0">
|
||||
<div class="row">
|
||||
@if (title) {
|
||||
<div class="d-flex align-items-center" [class.col-md-3]="horizontal">
|
||||
<label class="form-label" [class.mb-md-0]="horizontal" for="tags">{{title}}</label>
|
||||
</div>
|
||||
}
|
||||
<div class="d-flex align-items-center" [class.col-md-3]="horizontal">
|
||||
<label class="form-label" [class.mb-md-0]="horizontal" for="tags">{{title}}</label>
|
||||
</div>
|
||||
<div class="position-relative" [class.col-md-9]="horizontal">
|
||||
<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">
|
||||
@@ -28,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) {
|
||||
|
@@ -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>
|
||||
}
|
||||
|
@@ -1212,7 +1212,7 @@ describe('DocumentDetailComponent', () => {
|
||||
it('should support keyboard shortcuts', () => {
|
||||
initNormally()
|
||||
|
||||
const hasNextSpy = jest.spyOn(component, 'hasNext').mockReturnValue(true)
|
||||
jest.spyOn(component, 'hasNext').mockReturnValue(true)
|
||||
const nextSpy = jest.spyOn(component, 'nextDoc')
|
||||
document.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'arrowright', ctrlKey: true })
|
||||
@@ -1226,32 +1226,21 @@ describe('DocumentDetailComponent', () => {
|
||||
)
|
||||
expect(prevSpy).toHaveBeenCalled()
|
||||
|
||||
const isDirtySpy = jest
|
||||
.spyOn(openDocumentsService, 'isDirty')
|
||||
.mockReturnValue(true)
|
||||
jest.spyOn(openDocumentsService, 'isDirty').mockReturnValue(true)
|
||||
const saveSpy = jest.spyOn(component, 'save')
|
||||
document.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 's', ctrlKey: true })
|
||||
)
|
||||
expect(saveSpy).toHaveBeenCalled()
|
||||
|
||||
hasNextSpy.mockReturnValue(true)
|
||||
jest.spyOn(openDocumentsService, 'isDirty').mockReturnValue(true)
|
||||
jest.spyOn(component, 'hasNext').mockReturnValue(true)
|
||||
const saveNextSpy = jest.spyOn(component, 'saveEditNext')
|
||||
document.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 's', ctrlKey: true, shiftKey: true })
|
||||
)
|
||||
expect(saveNextSpy).toHaveBeenCalled()
|
||||
|
||||
saveSpy.mockClear()
|
||||
saveNextSpy.mockClear()
|
||||
isDirtySpy.mockReturnValue(true)
|
||||
hasNextSpy.mockReturnValue(false)
|
||||
document.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 's', ctrlKey: true, shiftKey: true })
|
||||
)
|
||||
expect(saveNextSpy).not.toHaveBeenCalled()
|
||||
expect(saveSpy).toHaveBeenCalledWith(true)
|
||||
|
||||
const closeSpy = jest.spyOn(component, 'close')
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'escape' }))
|
||||
expect(closeSpy).toHaveBeenCalled()
|
||||
@@ -1426,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')
|
||||
@@ -615,10 +609,7 @@ export class DocumentDetailComponent
|
||||
})
|
||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe(() => {
|
||||
if (this.openDocumentService.isDirty(this.document)) {
|
||||
if (this.hasNext()) this.saveEditNext()
|
||||
else this.save(true)
|
||||
}
|
||||
if (this.openDocumentService.isDirty(this.document)) this.saveEditNext()
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1428,44 +1419,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
|
||||
}
|
@@ -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
|
||||
}
|
||||
|
@@ -40,24 +40,10 @@ export interface WorkflowTrigger extends ObjectWithId {
|
||||
|
||||
filter_has_tags?: number[] // Tag.id[]
|
||||
|
||||
filter_has_all_tags?: number[] // Tag.id[]
|
||||
|
||||
filter_has_not_tags?: number[] // Tag.id[]
|
||||
|
||||
filter_has_not_correspondents?: number[] // Correspondent.id[]
|
||||
|
||||
filter_has_not_document_types?: number[] // DocumentType.id[]
|
||||
|
||||
filter_has_not_storage_paths?: number[] // StoragePath.id[]
|
||||
|
||||
filter_custom_field_query?: string
|
||||
|
||||
filter_has_correspondent?: number // Correspondent.id
|
||||
|
||||
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,
|
||||
})
|
||||
}
|
||||
}
|
@@ -51,7 +51,7 @@ describe('TasksService', () => {
|
||||
})
|
||||
|
||||
it('calls acknowledge_tasks api endpoint on dismiss and reloads', () => {
|
||||
tasksService.dismissTasks(new Set([1, 2, 3])).subscribe()
|
||||
tasksService.dismissTasks(new Set([1, 2, 3]))
|
||||
const req = httpTestingController.expectOne(
|
||||
`${environment.apiBaseUrl}tasks/acknowledge/`
|
||||
)
|
||||
|
@@ -1,7 +1,7 @@
|
||||
import { HttpClient } from '@angular/common/http'
|
||||
import { Injectable, inject } from '@angular/core'
|
||||
import { Observable, Subject } from 'rxjs'
|
||||
import { first, takeUntil, tap } from 'rxjs/operators'
|
||||
import { first, takeUntil } from 'rxjs/operators'
|
||||
import {
|
||||
PaperlessTask,
|
||||
PaperlessTaskName,
|
||||
@@ -68,17 +68,14 @@ export class TasksService {
|
||||
}
|
||||
|
||||
public dismissTasks(task_ids: Set<number>) {
|
||||
return this.http
|
||||
this.http
|
||||
.post(`${this.baseUrl}tasks/acknowledge/`, {
|
||||
tasks: [...task_ids],
|
||||
})
|
||||
.pipe(
|
||||
first(),
|
||||
takeUntil(this.unsubscribeNotifer),
|
||||
tap(() => {
|
||||
this.reload()
|
||||
})
|
||||
)
|
||||
.pipe(first())
|
||||
.subscribe((r) => {
|
||||
this.reload()
|
||||
})
|
||||
}
|
||||
|
||||
public cancelPending(): void {
|
||||
|
@@ -1,63 +0,0 @@
|
||||
import type { Tag } from '../data/tag'
|
||||
import { flattenTags } from './flatten-tags'
|
||||
|
||||
describe('flattenTags', () => {
|
||||
it('returns empty array for empty input', () => {
|
||||
expect(flattenTags([])).toEqual([])
|
||||
})
|
||||
|
||||
it('orders roots and children by name (case-insensitive, numeric) and sets depth/orderIndex', () => {
|
||||
const input: Tag[] = [
|
||||
{ id: 11, name: 'A-root' },
|
||||
{ id: 10, name: 'B-root' },
|
||||
{ id: 101, name: 'Child 10', parent: 11 },
|
||||
{ id: 102, name: 'child 2', parent: 11 },
|
||||
{ id: 201, name: 'beta', parent: 10 },
|
||||
{ id: 202, name: 'Alpha', parent: 10 },
|
||||
{ id: 103, name: 'Sub 1', parent: 102 },
|
||||
]
|
||||
|
||||
const flat = flattenTags(input)
|
||||
|
||||
const names = flat.map((t) => t.name)
|
||||
expect(names).toEqual([
|
||||
'A-root',
|
||||
'child 2',
|
||||
'Sub 1',
|
||||
'Child 10',
|
||||
'B-root',
|
||||
'Alpha',
|
||||
'beta',
|
||||
])
|
||||
|
||||
expect(flat.map((t) => t.depth)).toEqual([0, 1, 2, 1, 0, 1, 1])
|
||||
expect(flat.map((t) => t.orderIndex)).toEqual([0, 1, 2, 3, 4, 5, 6])
|
||||
|
||||
// Children are rebuilt
|
||||
const aRoot = flat.find((t) => t.name === 'A-root')
|
||||
expect(new Set(aRoot.children?.map((c) => c.name))).toEqual(
|
||||
new Set(['child 2', 'Child 10'])
|
||||
)
|
||||
|
||||
const bRoot = flat.find((t) => t.name === 'B-root')
|
||||
expect(new Set(bRoot.children?.map((c) => c.name))).toEqual(
|
||||
new Set(['Alpha', 'beta'])
|
||||
)
|
||||
|
||||
const child2 = flat.find((t) => t.name === 'child 2')
|
||||
expect(new Set(child2.children?.map((c) => c.name))).toEqual(
|
||||
new Set(['Sub 1'])
|
||||
)
|
||||
})
|
||||
|
||||
it('excludes orphaned nodes (with missing parent)', () => {
|
||||
const input: Tag[] = [
|
||||
{ id: 1, name: 'Root' },
|
||||
{ id: 2, name: 'Child', parent: 1 },
|
||||
{ id: 3, name: 'Orphan', parent: 999 }, // missing parent
|
||||
]
|
||||
|
||||
const flat = flattenTags(input)
|
||||
expect(flat.map((t) => t.name)).toEqual(['Root', 'Child'])
|
||||
})
|
||||
})
|
@@ -1,35 +0,0 @@
|
||||
import { Tag } from '../data/tag'
|
||||
|
||||
export function flattenTags(all: Tag[]): Tag[] {
|
||||
const map = new Map<number, Tag>(
|
||||
all.map((t) => [t.id, { ...t, children: [] }])
|
||||
)
|
||||
// rebuild children
|
||||
for (const t of map.values()) {
|
||||
if (t.parent) {
|
||||
const p = map.get(t.parent)
|
||||
p?.children.push(t)
|
||||
}
|
||||
}
|
||||
const roots = Array.from(map.values()).filter((t) => !t.parent)
|
||||
const sortByName = (a: Tag, b: Tag) =>
|
||||
a.name.localeCompare(b.name, undefined, {
|
||||
sensitivity: 'base',
|
||||
numeric: true,
|
||||
})
|
||||
const ordered: Tag[] = []
|
||||
let idx = 0
|
||||
const walk = (node: Tag, depth: number) => {
|
||||
node.depth = depth
|
||||
node.orderIndex = idx++
|
||||
ordered.push(node)
|
||||
if (node.children?.length) {
|
||||
for (const child of [...node.children].sort(sortByName)) {
|
||||
walk(child, depth + 1)
|
||||
}
|
||||
}
|
||||
}
|
||||
roots.sort(sortByName)
|
||||
roots.forEach((r) => walk(r, 0))
|
||||
return ordered
|
||||
}
|
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user