mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2025-10-20 03:06:10 -05:00
Compare commits
90 Commits
main
...
postgres-m
Author | SHA1 | Date | |
---|---|---|---|
![]() |
61d6663094 | ||
![]() |
15e6809a71 | ||
![]() |
7326224888 | ||
![]() |
04a01fb9f4 | ||
![]() |
340754d865 | ||
![]() |
39c429bb87 | ||
![]() |
8686f264cf | ||
![]() |
f6c004183e | ||
![]() |
d394053ddc | ||
![]() |
a36c28418c | ||
![]() |
f0d1c75fac | ||
![]() |
495159f0b2 | ||
![]() |
33fd8a6579 | ||
![]() |
e08e34fb90 | ||
![]() |
6164bac66e | ||
![]() |
df86882e8e | ||
![]() |
79b30fbade | ||
![]() |
d609b386fe | ||
![]() |
502bbb2420 | ||
![]() |
27574009e1 | ||
![]() |
bd73555ecc | ||
![]() |
613c922dd2 | ||
![]() |
1659aa08e4 | ||
![]() |
68dfb4a930 | ||
![]() |
3c439b970f | ||
![]() |
962f7994d1 | ||
![]() |
93eea80f3e | ||
![]() |
5bc27eb4b2 | ||
![]() |
b19701cb96 | ||
![]() |
9c552bc2d7 | ||
![]() |
80fabb0b56 | ||
![]() |
af1c235af5 | ||
![]() |
92ee906701 | ||
![]() |
d6710de486 | ||
![]() |
f71b13b82a | ||
![]() |
3df43d828a | ||
![]() |
643e2b4a8e | ||
![]() |
6fa896df39 | ||
![]() |
6aeb5a5503 | ||
![]() |
86dbeb3a27 | ||
![]() |
e97217f267 | ||
![]() |
05d5d7e796 | ||
![]() |
e8957de4a7 | ||
![]() |
1717517e70 | ||
![]() |
af544177d4 | ||
![]() |
766af6a48a | ||
![]() |
e985051890 | ||
![]() |
764ad059d1 | ||
![]() |
5e47069934 | ||
![]() |
4ff09c4cf4 | ||
![]() |
53b393dab5 | ||
![]() |
6119c215e7 | ||
![]() |
8d1f23e9d6 | ||
![]() |
c8850fa752 | ||
![]() |
19a54b3b23 | ||
![]() |
1cdd8d9ba8 | ||
![]() |
4449dbadb5 | ||
![]() |
0e35acaef5 | ||
![]() |
19ff339804 | ||
![]() |
6b868a5ecb | ||
![]() |
6231211f9b | ||
![]() |
6dbd32759d | ||
![]() |
e0512e35a2 | ||
![]() |
4cff907ba0 | ||
![]() |
4b32c3228e | ||
![]() |
4ddac79f0f | ||
![]() |
d4be3bd31d | ||
![]() |
d5aba09de9 | ||
![]() |
f2ef9af291 | ||
![]() |
4905edbf79 | ||
![]() |
feb5d534b5 | ||
![]() |
d230514dd3 | ||
![]() |
1709aee903 | ||
![]() |
c4346124c3 | ||
![]() |
44b8c4881a | ||
![]() |
d3d8eef0b6 | ||
![]() |
a283c1c320 | ||
![]() |
f3220ce981 | ||
![]() |
2dc4f1f49b | ||
![]() |
17509171bb | ||
![]() |
9e11e7fd05 | ||
![]() |
84942a4e69 | ||
![]() |
48168df320 | ||
![]() |
cec665f8d5 | ||
![]() |
8adc26e09d | ||
![]() |
84d85d7a23 | ||
![]() |
71f20f62d0 | ||
![]() |
a94a8e4c6f | ||
![]() |
7a1aae7749 | ||
![]() |
894939e492 |
@@ -49,7 +49,6 @@ services:
|
|||||||
- ./data:/usr/src/paperless/paperless-ngx/data
|
- ./data:/usr/src/paperless/paperless-ngx/data
|
||||||
- ./media:/usr/src/paperless/paperless-ngx/media
|
- ./media:/usr/src/paperless/paperless-ngx/media
|
||||||
- ./consume:/usr/src/paperless/paperless-ngx/consume
|
- ./consume:/usr/src/paperless/paperless-ngx/consume
|
||||||
- ~/.gitconfig:/usr/src/paperless/.gitconfig:ro
|
|
||||||
environment:
|
environment:
|
||||||
PAPERLESS_REDIS: redis://broker:6379
|
PAPERLESS_REDIS: redis://broker:6379
|
||||||
PAPERLESS_TIKA_ENABLED: 1
|
PAPERLESS_TIKA_ENABLED: 1
|
||||||
|
73
.github/workflows/ci.yml
vendored
73
.github/workflows/ci.yml
vendored
@@ -17,18 +17,59 @@ env:
|
|||||||
DEFAULT_PYTHON_VERSION: "3.11"
|
DEFAULT_PYTHON_VERSION: "3.11"
|
||||||
NLTK_DATA: "/usr/share/nltk_data"
|
NLTK_DATA: "/usr/share/nltk_data"
|
||||||
jobs:
|
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:
|
pre-commit:
|
||||||
# We want to run on external PRs, but not on our own internal PRs as they'll be run
|
needs:
|
||||||
# by the push to the branch. Without this if check, checks are duplicated since
|
- detect-duplicate
|
||||||
# internal PRs match both the push and pull_request events.
|
if: needs.detect-duplicate.outputs.should_run == 'true'
|
||||||
if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name != github.repository
|
|
||||||
name: Linting Checks
|
name: Linting Checks
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@v5
|
uses: actions/checkout@v5
|
||||||
- name: Install python
|
- name: Install python
|
||||||
uses: actions/setup-python@v5
|
uses: actions/setup-python@v6
|
||||||
with:
|
with:
|
||||||
python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
|
python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
|
||||||
- name: Check files
|
- name: Check files
|
||||||
@@ -43,7 +84,7 @@ jobs:
|
|||||||
uses: actions/checkout@v5
|
uses: actions/checkout@v5
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
id: setup-python
|
id: setup-python
|
||||||
uses: actions/setup-python@v5
|
uses: actions/setup-python@v6
|
||||||
with:
|
with:
|
||||||
python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
|
python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
|
||||||
- name: Install uv
|
- name: Install uv
|
||||||
@@ -97,7 +138,7 @@ jobs:
|
|||||||
docker compose --file ${{ github.workspace }}/docker/compose/docker-compose.ci-test.yml up --detach
|
docker compose --file ${{ github.workspace }}/docker/compose/docker-compose.ci-test.yml up --detach
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
id: setup-python
|
id: setup-python
|
||||||
uses: actions/setup-python@v5
|
uses: actions/setup-python@v6
|
||||||
with:
|
with:
|
||||||
python-version: "${{ matrix.python-version }}"
|
python-version: "${{ matrix.python-version }}"
|
||||||
- name: Install uv
|
- name: Install uv
|
||||||
@@ -142,13 +183,11 @@ jobs:
|
|||||||
if: always()
|
if: always()
|
||||||
uses: codecov/test-results-action@v1
|
uses: codecov/test-results-action@v1
|
||||||
with:
|
with:
|
||||||
token: ${{ secrets.CODECOV_TOKEN }}
|
|
||||||
flags: backend-python-${{ matrix.python-version }}
|
flags: backend-python-${{ matrix.python-version }}
|
||||||
files: junit.xml
|
files: junit.xml
|
||||||
- name: Upload backend coverage to Codecov
|
- name: Upload backend coverage to Codecov
|
||||||
uses: codecov/codecov-action@v5
|
uses: codecov/codecov-action@v5
|
||||||
with:
|
with:
|
||||||
token: ${{ secrets.CODECOV_TOKEN }}
|
|
||||||
flags: backend-python-${{ matrix.python-version }}
|
flags: backend-python-${{ matrix.python-version }}
|
||||||
files: coverage.xml
|
files: coverage.xml
|
||||||
- name: Stop containers
|
- name: Stop containers
|
||||||
@@ -168,7 +207,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
version: 10
|
version: 10
|
||||||
- name: Use Node.js 20
|
- name: Use Node.js 20
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v5
|
||||||
with:
|
with:
|
||||||
node-version: 20.x
|
node-version: 20.x
|
||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
@@ -201,7 +240,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
version: 10
|
version: 10
|
||||||
- name: Use Node.js 20
|
- name: Use Node.js 20
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v5
|
||||||
with:
|
with:
|
||||||
node-version: 20.x
|
node-version: 20.x
|
||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
@@ -224,13 +263,11 @@ jobs:
|
|||||||
uses: codecov/test-results-action@v1
|
uses: codecov/test-results-action@v1
|
||||||
if: always()
|
if: always()
|
||||||
with:
|
with:
|
||||||
token: ${{ secrets.CODECOV_TOKEN }}
|
|
||||||
flags: frontend-node-${{ matrix.node-version }}
|
flags: frontend-node-${{ matrix.node-version }}
|
||||||
directory: src-ui/
|
directory: src-ui/
|
||||||
- name: Upload frontend coverage to Codecov
|
- name: Upload frontend coverage to Codecov
|
||||||
uses: codecov/codecov-action@v5
|
uses: codecov/codecov-action@v5
|
||||||
with:
|
with:
|
||||||
token: ${{ secrets.CODECOV_TOKEN }}
|
|
||||||
flags: frontend-node-${{ matrix.node-version }}
|
flags: frontend-node-${{ matrix.node-version }}
|
||||||
directory: src-ui/coverage/
|
directory: src-ui/coverage/
|
||||||
tests-frontend-e2e:
|
tests-frontend-e2e:
|
||||||
@@ -251,7 +288,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
version: 10
|
version: 10
|
||||||
- name: Use Node.js 20
|
- name: Use Node.js 20
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v5
|
||||||
with:
|
with:
|
||||||
node-version: 20.x
|
node-version: 20.x
|
||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
@@ -294,7 +331,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
version: 10
|
version: 10
|
||||||
- name: Use Node.js 20
|
- name: Use Node.js 20
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v5
|
||||||
with:
|
with:
|
||||||
node-version: 20.x
|
node-version: 20.x
|
||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
@@ -436,7 +473,7 @@ jobs:
|
|||||||
uses: actions/checkout@v5
|
uses: actions/checkout@v5
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
id: setup-python
|
id: setup-python
|
||||||
uses: actions/setup-python@v5
|
uses: actions/setup-python@v6
|
||||||
with:
|
with:
|
||||||
python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
|
python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
|
||||||
- name: Install uv
|
- name: Install uv
|
||||||
@@ -584,7 +621,7 @@ jobs:
|
|||||||
ref: main
|
ref: main
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
id: setup-python
|
id: setup-python
|
||||||
uses: actions/setup-python@v5
|
uses: actions/setup-python@v6
|
||||||
with:
|
with:
|
||||||
python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
|
python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
|
||||||
- name: Install uv
|
- name: Install uv
|
||||||
@@ -616,7 +653,7 @@ jobs:
|
|||||||
git commit -am "Changelog ${{ needs.publish-release.outputs.version }} - GHA"
|
git commit -am "Changelog ${{ needs.publish-release.outputs.version }} - GHA"
|
||||||
git push origin ${{ needs.publish-release.outputs.version }}-changelog
|
git push origin ${{ needs.publish-release.outputs.version }}-changelog
|
||||||
- name: Create Pull Request
|
- name: Create Pull Request
|
||||||
uses: actions/github-script@v7
|
uses: actions/github-script@v8
|
||||||
with:
|
with:
|
||||||
script: |
|
script: |
|
||||||
const { repo, owner } = context.repo;
|
const { repo, owner } = context.repo;
|
||||||
|
7
.github/workflows/cleanup-tags.yml
vendored
7
.github/workflows/cleanup-tags.yml
vendored
@@ -6,10 +6,9 @@
|
|||||||
# This workflow will not trigger runs on forked repos.
|
# This workflow will not trigger runs on forked repos.
|
||||||
name: Cleanup Image Tags
|
name: Cleanup Image Tags
|
||||||
on:
|
on:
|
||||||
delete:
|
workflow_dispatch:
|
||||||
push:
|
schedule:
|
||||||
paths:
|
- cron: '0 0 * * 0'
|
||||||
- ".github/workflows/cleanup-tags.yml"
|
|
||||||
concurrency:
|
concurrency:
|
||||||
group: registry-tags-cleanup
|
group: registry-tags-cleanup
|
||||||
cancel-in-progress: false
|
cancel-in-progress: false
|
||||||
|
8
.github/workflows/pr-bot.yml
vendored
8
.github/workflows/pr-bot.yml
vendored
@@ -12,7 +12,7 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Label PR by file path or branch name
|
- name: Label PR by file path or branch name
|
||||||
# see .github/labeler.yml for the labeler config
|
# see .github/labeler.yml for the labeler config
|
||||||
uses: actions/labeler@v5
|
uses: actions/labeler@v6
|
||||||
with:
|
with:
|
||||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
- name: Label by size
|
- name: Label by size
|
||||||
@@ -26,7 +26,7 @@ jobs:
|
|||||||
fail_if_xl: 'false'
|
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$
|
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
|
- name: Label by PR title
|
||||||
uses: actions/github-script@v7
|
uses: actions/github-script@v8
|
||||||
with:
|
with:
|
||||||
script: |
|
script: |
|
||||||
const pr = context.payload.pull_request;
|
const pr = context.payload.pull_request;
|
||||||
@@ -52,7 +52,7 @@ jobs:
|
|||||||
}
|
}
|
||||||
- name: Label bot-generated PRs
|
- name: Label bot-generated PRs
|
||||||
if: ${{ contains(github.actor, 'dependabot') || contains(github.actor, 'crowdin-bot') }}
|
if: ${{ contains(github.actor, 'dependabot') || contains(github.actor, 'crowdin-bot') }}
|
||||||
uses: actions/github-script@v7
|
uses: actions/github-script@v8
|
||||||
with:
|
with:
|
||||||
script: |
|
script: |
|
||||||
const pr = context.payload.pull_request;
|
const pr = context.payload.pull_request;
|
||||||
@@ -77,7 +77,7 @@ jobs:
|
|||||||
}
|
}
|
||||||
- name: Welcome comment
|
- name: Welcome comment
|
||||||
if: ${{ !contains(github.actor, 'bot') }}
|
if: ${{ !contains(github.actor, 'bot') }}
|
||||||
uses: actions/github-script@v7
|
uses: actions/github-script@v8
|
||||||
with:
|
with:
|
||||||
script: |
|
script: |
|
||||||
const pr = context.payload.pull_request;
|
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'
|
if: github.repository_owner == 'paperless-ngx'
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/stale@v9
|
- uses: actions/stale@v10
|
||||||
with:
|
with:
|
||||||
days-before-stale: 7
|
days-before-stale: 7
|
||||||
days-before-close: 14
|
days-before-close: 14
|
||||||
@@ -57,7 +57,7 @@ jobs:
|
|||||||
if: github.repository_owner == 'paperless-ngx'
|
if: github.repository_owner == 'paperless-ngx'
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/github-script@v7
|
- uses: actions/github-script@v8
|
||||||
with:
|
with:
|
||||||
script: |
|
script: |
|
||||||
function sleep(ms) {
|
function sleep(ms) {
|
||||||
@@ -114,7 +114,7 @@ jobs:
|
|||||||
if: github.repository_owner == 'paperless-ngx'
|
if: github.repository_owner == 'paperless-ngx'
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/github-script@v7
|
- uses: actions/github-script@v8
|
||||||
with:
|
with:
|
||||||
script: |
|
script: |
|
||||||
function sleep(ms) {
|
function sleep(ms) {
|
||||||
@@ -206,7 +206,7 @@ jobs:
|
|||||||
if: github.repository_owner == 'paperless-ngx'
|
if: github.repository_owner == 'paperless-ngx'
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/github-script@v7
|
- uses: actions/github-script@v8
|
||||||
with:
|
with:
|
||||||
script: |
|
script: |
|
||||||
function sleep(ms) {
|
function sleep(ms) {
|
||||||
@@ -241,6 +241,7 @@ jobs:
|
|||||||
) {
|
) {
|
||||||
nodes {
|
nodes {
|
||||||
id,
|
id,
|
||||||
|
createdAt,
|
||||||
number,
|
number,
|
||||||
updatedAt,
|
updatedAt,
|
||||||
upvoteCount,
|
upvoteCount,
|
||||||
|
4
.github/workflows/translate-strings.yml
vendored
4
.github/workflows/translate-strings.yml
vendored
@@ -17,7 +17,7 @@ jobs:
|
|||||||
ref: ${{ github.head_ref }}
|
ref: ${{ github.head_ref }}
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
id: setup-python
|
id: setup-python
|
||||||
uses: actions/setup-python@v5
|
uses: actions/setup-python@v6
|
||||||
- name: Install system dependencies
|
- name: Install system dependencies
|
||||||
run: |
|
run: |
|
||||||
sudo apt-get update -qq
|
sudo apt-get update -qq
|
||||||
@@ -38,7 +38,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
version: 10
|
version: 10
|
||||||
- name: Use Node.js 20
|
- name: Use Node.js 20
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v5
|
||||||
with:
|
with:
|
||||||
node-version: 20.x
|
node-version: 20.x
|
||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
|
3
.gitignore
vendored
3
.gitignore
vendored
@@ -107,3 +107,6 @@ celerybeat-schedule*
|
|||||||
/.devcontainer/data/
|
/.devcontainer/data/
|
||||||
/.devcontainer/media/
|
/.devcontainer/media/
|
||||||
/.devcontainer/redisdata/
|
/.devcontainer/redisdata/
|
||||||
|
|
||||||
|
# ignore pnpm package store folder created when setting up the devcontainer
|
||||||
|
.pnpm-store/
|
||||||
|
@@ -4,7 +4,7 @@
|
|||||||
repos:
|
repos:
|
||||||
# General hooks
|
# General hooks
|
||||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||||
rev: v5.0.0
|
rev: v6.0.0
|
||||||
hooks:
|
hooks:
|
||||||
- id: check-docstring-first
|
- id: check-docstring-first
|
||||||
- id: check-json
|
- id: check-json
|
||||||
@@ -49,17 +49,17 @@ repos:
|
|||||||
- 'prettier-plugin-organize-imports@4.1.0'
|
- 'prettier-plugin-organize-imports@4.1.0'
|
||||||
# Python hooks
|
# Python hooks
|
||||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||||
rev: v0.12.2
|
rev: v0.14.0
|
||||||
hooks:
|
hooks:
|
||||||
- id: ruff-check
|
- id: ruff-check
|
||||||
- id: ruff-format
|
- id: ruff-format
|
||||||
- repo: https://github.com/tox-dev/pyproject-fmt
|
- repo: https://github.com/tox-dev/pyproject-fmt
|
||||||
rev: "v2.6.0"
|
rev: "v2.11.0"
|
||||||
hooks:
|
hooks:
|
||||||
- id: pyproject-fmt
|
- id: pyproject-fmt
|
||||||
# Dockerfile hooks
|
# Dockerfile hooks
|
||||||
- repo: https://github.com/AleksaC/hadolint-py
|
- repo: https://github.com/AleksaC/hadolint-py
|
||||||
rev: v2.12.1b3
|
rev: v2.14.0
|
||||||
hooks:
|
hooks:
|
||||||
- id: hadolint
|
- id: hadolint
|
||||||
# Shell script hooks
|
# Shell script hooks
|
||||||
@@ -72,11 +72,13 @@ repos:
|
|||||||
args:
|
args:
|
||||||
- "--tab"
|
- "--tab"
|
||||||
- repo: https://github.com/shellcheck-py/shellcheck-py
|
- repo: https://github.com/shellcheck-py/shellcheck-py
|
||||||
rev: "v0.10.0.1"
|
rev: "v0.11.0.1"
|
||||||
hooks:
|
hooks:
|
||||||
- id: shellcheck
|
- id: shellcheck
|
||||||
- repo: https://github.com/google/yamlfmt
|
- repo: https://github.com/google/yamlfmt
|
||||||
rev: v0.17.2
|
rev: v0.18.0
|
||||||
hooks:
|
hooks:
|
||||||
- id: yamlfmt
|
- id: yamlfmt
|
||||||
exclude: "^src-ui/pnpm-lock.yaml"
|
exclude: "^src-ui/pnpm-lock.yaml"
|
||||||
|
types:
|
||||||
|
- yaml
|
||||||
|
@@ -135,7 +135,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.
|
- 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 with a marked answer will be automatically closed.
|
||||||
- Discussions in the 'General' or 'Support' categories will be closed after 180 days of inactivity.
|
- 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, < 5 "up-votes" after 180 days, < 20 "up-votes" after 1 year or < 80 "up-votes" at 2 years.
|
- 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.
|
||||||
|
|
||||||
In all cases, threads can be re-opened by project maintainers and, of course, users can always create a new discussion for related concerns.
|
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.
|
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
|
# Purpose: Installs s6-overlay and rootfs
|
||||||
# Comments:
|
# Comments:
|
||||||
# - Don't leave anything extra in here either
|
# - Don't leave anything extra in here either
|
||||||
FROM ghcr.io/astral-sh/uv:0.8.13-python3.12-bookworm-slim AS s6-overlay-base
|
FROM ghcr.io/astral-sh/uv:0.9.2-python3.12-bookworm-slim AS s6-overlay-base
|
||||||
|
|
||||||
WORKDIR /usr/src/s6
|
WORKDIR /usr/src/s6
|
||||||
|
|
||||||
|
@@ -4,7 +4,7 @@
|
|||||||
# correct networking for the tests
|
# correct networking for the tests
|
||||||
services:
|
services:
|
||||||
gotenberg:
|
gotenberg:
|
||||||
image: docker.io/gotenberg/gotenberg:8.22
|
image: docker.io/gotenberg/gotenberg:8.24
|
||||||
hostname: gotenberg
|
hostname: gotenberg
|
||||||
container_name: gotenberg
|
container_name: gotenberg
|
||||||
network_mode: host
|
network_mode: host
|
||||||
|
@@ -72,7 +72,7 @@ services:
|
|||||||
PAPERLESS_TIKA_GOTENBERG_ENDPOINT: http://gotenberg:3000
|
PAPERLESS_TIKA_GOTENBERG_ENDPOINT: http://gotenberg:3000
|
||||||
PAPERLESS_TIKA_ENDPOINT: http://tika:9998
|
PAPERLESS_TIKA_ENDPOINT: http://tika:9998
|
||||||
gotenberg:
|
gotenberg:
|
||||||
image: docker.io/gotenberg/gotenberg:8.22
|
image: docker.io/gotenberg/gotenberg:8.24
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
# The gotenberg chromium route is used to convert .eml files. We do not
|
# The gotenberg chromium route is used to convert .eml files. We do not
|
||||||
# want to allow external content like tracking pixels or even javascript.
|
# want to allow external content like tracking pixels or even javascript.
|
||||||
|
@@ -32,10 +32,10 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- redisdata:/data
|
- redisdata:/data
|
||||||
db:
|
db:
|
||||||
image: docker.io/library/postgres:17
|
image: docker.io/library/postgres:18
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
volumes:
|
volumes:
|
||||||
- pgdata:/var/lib/postgresql/data
|
- pgdata:/var/lib/postgresql
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_DB: paperless
|
POSTGRES_DB: paperless
|
||||||
POSTGRES_USER: paperless
|
POSTGRES_USER: paperless
|
||||||
|
@@ -35,10 +35,10 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- redisdata:/data
|
- redisdata:/data
|
||||||
db:
|
db:
|
||||||
image: docker.io/library/postgres:17
|
image: docker.io/library/postgres:18
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
volumes:
|
volumes:
|
||||||
- pgdata:/var/lib/postgresql/data
|
- pgdata:/var/lib/postgresql
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_DB: paperless
|
POSTGRES_DB: paperless
|
||||||
POSTGRES_USER: paperless
|
POSTGRES_USER: paperless
|
||||||
@@ -66,7 +66,7 @@ services:
|
|||||||
PAPERLESS_TIKA_GOTENBERG_ENDPOINT: http://gotenberg:3000
|
PAPERLESS_TIKA_GOTENBERG_ENDPOINT: http://gotenberg:3000
|
||||||
PAPERLESS_TIKA_ENDPOINT: http://tika:9998
|
PAPERLESS_TIKA_ENDPOINT: http://tika:9998
|
||||||
gotenberg:
|
gotenberg:
|
||||||
image: docker.io/gotenberg/gotenberg:8.22
|
image: docker.io/gotenberg/gotenberg:8.24
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
# The gotenberg chromium route is used to convert .eml files. We do not
|
# The gotenberg chromium route is used to convert .eml files. We do not
|
||||||
# want to allow external content like tracking pixels or even javascript.
|
# want to allow external content like tracking pixels or even javascript.
|
||||||
|
@@ -31,10 +31,10 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- redisdata:/data
|
- redisdata:/data
|
||||||
db:
|
db:
|
||||||
image: docker.io/library/postgres:17
|
image: docker.io/library/postgres:18
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
volumes:
|
volumes:
|
||||||
- pgdata:/var/lib/postgresql/data
|
- pgdata:/var/lib/postgresql
|
||||||
environment:
|
environment:
|
||||||
POSTGRES_DB: paperless
|
POSTGRES_DB: paperless
|
||||||
POSTGRES_USER: paperless
|
POSTGRES_USER: paperless
|
||||||
|
@@ -55,7 +55,7 @@ services:
|
|||||||
PAPERLESS_TIKA_GOTENBERG_ENDPOINT: http://gotenberg:3000
|
PAPERLESS_TIKA_GOTENBERG_ENDPOINT: http://gotenberg:3000
|
||||||
PAPERLESS_TIKA_ENDPOINT: http://tika:9998
|
PAPERLESS_TIKA_ENDPOINT: http://tika:9998
|
||||||
gotenberg:
|
gotenberg:
|
||||||
image: docker.io/gotenberg/gotenberg:8.22
|
image: docker.io/gotenberg/gotenberg:8.24
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
# The gotenberg chromium route is used to convert .eml files. We do not
|
# The gotenberg chromium route is used to convert .eml files. We do not
|
||||||
# want to allow external content like tracking pixels or even javascript.
|
# want to allow external content like tracking pixels or even javascript.
|
||||||
|
@@ -506,6 +506,7 @@ for the possible codes and their meanings.
|
|||||||
The `localize_date` filter formats a date or datetime object into a localized string using Babel internationalization.
|
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,
|
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`.
|
you must access the field directly, i.e. `document.created`.
|
||||||
|
An ISO string can also be provided to control the output format.
|
||||||
|
|
||||||
###### Syntax
|
###### Syntax
|
||||||
|
|
||||||
@@ -516,7 +517,7 @@ you must access the field directly, i.e. `document.created`.
|
|||||||
|
|
||||||
###### Parameters
|
###### Parameters
|
||||||
|
|
||||||
- `value` (date | datetime): Date or datetime object to format (datetime should be timezone-aware)
|
- `value` (date | datetime | str): Date, datetime object or ISO string to format (datetime should be timezone-aware)
|
||||||
- `format` (str): Format type - either a Babel preset ('short', 'medium', 'long', 'full') or custom pattern
|
- `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')
|
- `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
|
- `tags`: Similar to correspondent. Specify this multiple times to
|
||||||
have multiple tags added to the document.
|
have multiple tags added to the document.
|
||||||
- `archive_serial_number`: An optional archive serial number to set.
|
- `archive_serial_number`: An optional archive serial number to set.
|
||||||
- `custom_fields`: An array of custom field ids to assign (with an empty
|
- `custom_fields`: Either an array of custom field ids to assign (with an empty
|
||||||
value) to the document.
|
value) to the document or an object mapping field id -> value.
|
||||||
|
|
||||||
The endpoint will immediately return HTTP 200 if the document consumption
|
The endpoint will immediately return HTTP 200 if the document consumption
|
||||||
process was started successfully, with the UUID of the consumption task
|
process was started successfully, with the UUID of the consumption task
|
||||||
|
@@ -470,9 +470,14 @@ To get started:
|
|||||||
|
|
||||||
2. VS Code will prompt you with "Reopen in container". Do so and wait for the environment to start.
|
2. VS Code will prompt you with "Reopen in container". Do so and wait for the environment to start.
|
||||||
|
|
||||||
3. Initialize the project by running the task **Project Setup: Run all Init Tasks**. This
|
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
|
||||||
will initialize the database tables and create a superuser. Then you can compile the front end
|
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.
|
for production or run the frontend in debug mode.
|
||||||
|
|
||||||
4. The project is ready for debugging, start either run the fullstack debug or individual debug
|
5. 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**
|
processes. Yo spin up the project without debugging run the task **Project Start: Run all Services**
|
||||||
|
105
docs/usage.md
105
docs/usage.md
@@ -92,6 +92,16 @@ 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,
|
for these objects. You can also manage saved views, mail accounts, mail rules,
|
||||||
workflows and more from the management sections.
|
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
|
## Adding documents to Paperless-ngx
|
||||||
|
|
||||||
Once you've got Paperless setup, you need to start feeding documents
|
Once you've got Paperless setup, you need to start feeding documents
|
||||||
@@ -251,6 +261,10 @@ different means. These are as follows:
|
|||||||
Paperless is set up to check your mails every 10 minutes. This can be
|
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)
|
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
|
#### 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.
|
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.
|
||||||
@@ -408,7 +422,7 @@ 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
|
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.
|
be used for filtering.
|
||||||
3. **Document Updated**: when a document is updated. Similar to 'added' events, triggers can include filtering by content matching,
|
3. **Document Updated**: when a document is updated. Similar to 'added' events, triggers can include filtering by content matching,
|
||||||
tags, doc type, or correspondent.
|
tags, doc type, correspondent or storage path.
|
||||||
4. **Scheduled**: a scheduled trigger that can be used to run workflows at a specific time. The date used can be either the document
|
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
|
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).
|
offsets will trigger after the date, negative offsets will trigger before).
|
||||||
@@ -448,14 +462,24 @@ flowchart TD
|
|||||||
Workflows allow you to filter by:
|
Workflows allow you to filter by:
|
||||||
|
|
||||||
- Source, e.g. documents uploaded via consume folder, API (& the web UI) and mail fetch
|
- 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
|
- 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.
|
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.
|
- Mail rule. Choosing this option will force 'mail fetch' to be the workflow source.
|
||||||
- Content matching (`Added` and `Updated` triggers only). Filter document content using the matching settings.
|
- Content matching (`Added`, `Updated` and `Scheduled` 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
|
There are also 'advanced' filters available for `Added`, `Updated` and `Scheduled` triggers:
|
||||||
- Correspondent (`Added` and `Updated` triggers only). Filter documents with this correspondent
|
|
||||||
|
- 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).
|
||||||
|
|
||||||
### Workflow Actions
|
### Workflow Actions
|
||||||
|
|
||||||
@@ -505,35 +529,52 @@ you may want to adjust these settings to prevent abuse.
|
|||||||
|
|
||||||
#### Workflow placeholders
|
#### Workflow placeholders
|
||||||
|
|
||||||
Some workflow text can include placeholders but the available options differ depending on the type of
|
Titles can be assigned by workflows using [Jinja templates](https://jinja.palletsprojects.com/en/3.1.x/templates/).
|
||||||
workflow trigger. This is because at the time of consumption (when the text is to be set), no automatic tags etc. have been
|
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)
|
||||||
applied. You can use the following placeholders with any trigger type:
|
and [filters](https://jinja.palletsprojects.com/en/3.1.x/templates/#id11).
|
||||||
|
The template is provided as a string.
|
||||||
|
|
||||||
- `{correspondent}`: assigned correspondent name
|
Using Jinja2 Templates is also useful for [Date localization](advanced_usage.md#Date-Localization) in the title.
|
||||||
- `{document_type}`: assigned document type name
|
|
||||||
- `{owner_username}`: assigned owner username
|
The available inputs differ depending on the type of workflow trigger.
|
||||||
- `{added}`: added datetime
|
This is because at the time of consumption (when the text is to be set), no automatic tags etc. have been
|
||||||
- `{added_year}`: added year
|
applied. You can use the following placeholders in the template with any trigger type:
|
||||||
- `{added_year_short}`: added year
|
|
||||||
- `{added_month}`: added month
|
- `{{correspondent}}`: assigned correspondent name
|
||||||
- `{added_month_name}`: added month name
|
- `{{document_type}}`: assigned document type name
|
||||||
- `{added_month_name_short}`: added month short name
|
- `{{owner_username}}`: assigned owner username
|
||||||
- `{added_day}`: added day
|
- `{{added}}`: added datetime
|
||||||
- `{added_time}`: added time in HH:MM format
|
- `{{added_year}}`: added year
|
||||||
- `{original_filename}`: original file name without extension
|
- `{{added_year_short}}`: added year
|
||||||
- `{filename}`: current file name without extension
|
- `{{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
|
The following placeholders are only available for "added" or "updated" triggers
|
||||||
|
|
||||||
- `{created}`: created datetime
|
- `{{created}}`: created datetime
|
||||||
- `{created_year}`: created year
|
- `{{created_year}}`: created year
|
||||||
- `{created_year_short}`: created year
|
- `{{created_year_short}}`: created year
|
||||||
- `{created_month}`: created month
|
- `{{created_month}}`: created month
|
||||||
- `{created_month_name}`: created month name
|
- `{{created_month_name}}`: created month name
|
||||||
- `{created_month_name_short}`: created month short name
|
- `{created_month_name_short}}`: created month short name
|
||||||
- `{created_day}`: created day
|
- `{{created_day}}`: created day
|
||||||
- `{created_time}`: created time in HH:MM format
|
- `{{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.
|
- `{{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
|
||||||
|
```
|
||||||
|
|
||||||
### Workflow permissions
|
### Workflow permissions
|
||||||
|
|
||||||
@@ -605,7 +646,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
|
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.
|
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 they 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 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.
|
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}
|
## Best practices {#basic-searching}
|
||||||
|
@@ -10,6 +10,7 @@ classifiers = [
|
|||||||
"Programming Language :: Python :: 3.11",
|
"Programming Language :: Python :: 3.11",
|
||||||
"Programming Language :: Python :: 3.12",
|
"Programming Language :: Python :: 3.12",
|
||||||
"Programming Language :: Python :: 3.13",
|
"Programming Language :: Python :: 3.13",
|
||||||
|
"Programming Language :: Python :: 3.14",
|
||||||
]
|
]
|
||||||
# TODO: Move certain things to groups and then utilize that further
|
# TODO: Move certain things to groups and then utilize that further
|
||||||
# This will allow testing to not install a webserver, mysql, etc
|
# This will allow testing to not install a webserver, mysql, etc
|
||||||
@@ -25,35 +26,35 @@ dependencies = [
|
|||||||
# WARNING: django does not use semver.
|
# WARNING: django does not use semver.
|
||||||
# Only patch versions are guaranteed to not introduce breaking changes.
|
# Only patch versions are guaranteed to not introduce breaking changes.
|
||||||
"django~=5.2.5",
|
"django~=5.2.5",
|
||||||
"django-allauth[socialaccount,mfa]~=65.4.0",
|
"django-allauth[mfa,socialaccount]~=65.4.0",
|
||||||
"django-auditlog~=3.2.1",
|
"django-auditlog~=3.2.1",
|
||||||
"django-cachalot~=2.8.0",
|
"django-cachalot~=2.8.0",
|
||||||
"django-celery-results~=2.6.0",
|
"django-celery-results~=2.6.0",
|
||||||
"django-compression-middleware~=0.5.0",
|
"django-compression-middleware~=0.5.0",
|
||||||
"django-cors-headers~=4.7.0",
|
"django-cors-headers~=4.9.0",
|
||||||
"django-extensions~=4.1",
|
"django-extensions~=4.1",
|
||||||
"django-filter~=25.1",
|
"django-filter~=25.1",
|
||||||
"django-guardian~=3.0.3",
|
"django-guardian~=3.2.0",
|
||||||
"django-multiselectfield~=1.0.1",
|
"django-multiselectfield~=1.0.1",
|
||||||
"django-soft-delete~=1.0.18",
|
"django-soft-delete~=1.0.18",
|
||||||
|
"django-treenode>=0.23.2",
|
||||||
"djangorestframework~=3.16",
|
"djangorestframework~=3.16",
|
||||||
"djangorestframework-guardian~=0.4.0",
|
"djangorestframework-guardian~=0.4.0",
|
||||||
"drf-spectacular~=0.28",
|
"drf-spectacular~=0.28",
|
||||||
"drf-spectacular-sidecar~=2025.8.1",
|
"drf-spectacular-sidecar~=2025.9.1",
|
||||||
"drf-writable-nested~=0.7.1",
|
"drf-writable-nested~=0.7.1",
|
||||||
"filelock~=3.19.1",
|
"filelock~=3.20.0",
|
||||||
"flower~=2.0.1",
|
"flower~=2.0.1",
|
||||||
"gotenberg-client~=0.11.0",
|
"gotenberg-client~=0.12.0",
|
||||||
"httpx-oauth~=0.16",
|
"httpx-oauth~=0.16",
|
||||||
"imap-tools~=1.11.0",
|
"imap-tools~=1.11.0",
|
||||||
"inotifyrecursive~=0.3",
|
"inotifyrecursive~=0.3",
|
||||||
"jinja2~=3.1.5",
|
"jinja2~=3.1.5",
|
||||||
"langdetect~=1.0.9",
|
"langdetect~=1.0.9",
|
||||||
"nltk~=3.9.1",
|
"nltk~=3.9.1",
|
||||||
"ocrmypdf~=16.10.0",
|
"ocrmypdf~=16.11.0",
|
||||||
"pathvalidate~=3.3.1",
|
"pathvalidate~=3.3.1",
|
||||||
"pdf2image~=1.17.0",
|
"pdf2image~=1.17.0",
|
||||||
"psycopg-pool",
|
|
||||||
"python-dateutil~=2.9.0",
|
"python-dateutil~=2.9.0",
|
||||||
"python-dotenv~=1.1.0",
|
"python-dotenv~=1.1.0",
|
||||||
"python-gnupg~=0.5.4",
|
"python-gnupg~=0.5.4",
|
||||||
@@ -94,7 +95,7 @@ dev = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
docs = [
|
docs = [
|
||||||
"mkdocs-glightbox~=0.4.0",
|
"mkdocs-glightbox~=0.5.1",
|
||||||
"mkdocs-material~=9.6.4",
|
"mkdocs-material~=9.6.4",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -103,7 +104,7 @@ testing = [
|
|||||||
"factory-boy~=3.3.1",
|
"factory-boy~=3.3.1",
|
||||||
"imagehash",
|
"imagehash",
|
||||||
"pytest~=8.4.1",
|
"pytest~=8.4.1",
|
||||||
"pytest-cov~=6.2.1",
|
"pytest-cov~=7.0.0",
|
||||||
"pytest-django~=4.11.1",
|
"pytest-django~=4.11.1",
|
||||||
"pytest-env",
|
"pytest-env",
|
||||||
"pytest-httpx",
|
"pytest-httpx",
|
||||||
@@ -115,8 +116,8 @@ testing = [
|
|||||||
|
|
||||||
lint = [
|
lint = [
|
||||||
"pre-commit~=4.3.0",
|
"pre-commit~=4.3.0",
|
||||||
"pre-commit-uv~=4.1.3",
|
"pre-commit-uv~=4.2.0",
|
||||||
"ruff~=0.12.2",
|
"ruff~=0.14.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
typing = [
|
typing = [
|
||||||
@@ -138,6 +139,25 @@ typing = [
|
|||||||
"types-tqdm",
|
"types-tqdm",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[tool.uv]
|
||||||
|
required-version = ">=0.5.14"
|
||||||
|
package = false
|
||||||
|
environments = [
|
||||||
|
"sys_platform == 'darwin'",
|
||||||
|
"sys_platform == 'linux'",
|
||||||
|
]
|
||||||
|
|
||||||
|
[tool.uv.sources]
|
||||||
|
# Markers are chosen to select these almost exclusively when building the Docker image
|
||||||
|
psycopg-c = [
|
||||||
|
{ url = "https://github.com/paperless-ngx/builder/releases/download/psycopg-3.2.9/psycopg_c-3.2.9-cp312-cp312-linux_x86_64.whl", marker = "sys_platform == 'linux' and platform_machine == 'x86_64' and python_version == '3.12'" },
|
||||||
|
{ url = "https://github.com/paperless-ngx/builder/releases/download/psycopg-3.2.9/psycopg_c-3.2.9-cp312-cp312-linux_aarch64.whl", marker = "sys_platform == 'linux' and platform_machine == 'aarch64' and python_version == '3.12'" },
|
||||||
|
]
|
||||||
|
zxing-cpp = [
|
||||||
|
{ url = "https://github.com/paperless-ngx/builder/releases/download/zxing-2.3.0/zxing_cpp-2.3.0-cp312-cp312-linux_x86_64.whl", marker = "sys_platform == 'linux' and platform_machine == 'x86_64' and python_version == '3.12'" },
|
||||||
|
{ url = "https://github.com/paperless-ngx/builder/releases/download/zxing-2.3.0/zxing_cpp-2.3.0-cp312-cp312-linux_aarch64.whl", marker = "sys_platform == 'linux' and platform_machine == 'aarch64' and python_version == '3.12'" },
|
||||||
|
]
|
||||||
|
|
||||||
[tool.ruff]
|
[tool.ruff]
|
||||||
target-version = "py310"
|
target-version = "py310"
|
||||||
line-length = 88
|
line-length = 88
|
||||||
@@ -284,24 +304,5 @@ disallow_untyped_defs = true
|
|||||||
warn_redundant_casts = true
|
warn_redundant_casts = true
|
||||||
warn_unused_ignores = true
|
warn_unused_ignores = true
|
||||||
|
|
||||||
[tool.uv]
|
|
||||||
required-version = ">=0.5.14"
|
|
||||||
package = false
|
|
||||||
environments = [
|
|
||||||
"sys_platform == 'darwin'",
|
|
||||||
"sys_platform == 'linux'",
|
|
||||||
]
|
|
||||||
|
|
||||||
[tool.uv.sources]
|
|
||||||
# Markers are chosen to select these almost exclusively when building the Docker image
|
|
||||||
psycopg-c = [
|
|
||||||
{ url = "https://github.com/paperless-ngx/builder/releases/download/psycopg-3.2.9/psycopg_c-3.2.9-cp312-cp312-linux_x86_64.whl", marker = "sys_platform == 'linux' and platform_machine == 'x86_64' and python_version == '3.12'" },
|
|
||||||
{ url = "https://github.com/paperless-ngx/builder/releases/download/psycopg-3.2.9/psycopg_c-3.2.9-cp312-cp312-linux_aarch64.whl", marker = "sys_platform == 'linux' and platform_machine == 'aarch64' and python_version == '3.12'" },
|
|
||||||
]
|
|
||||||
zxing-cpp = [
|
|
||||||
{ url = "https://github.com/paperless-ngx/builder/releases/download/zxing-2.3.0/zxing_cpp-2.3.0-cp312-cp312-linux_x86_64.whl", marker = "sys_platform == 'linux' and platform_machine == 'x86_64' and python_version == '3.12'" },
|
|
||||||
{ url = "https://github.com/paperless-ngx/builder/releases/download/zxing-2.3.0/zxing_cpp-2.3.0-cp312-cp312-linux_aarch64.whl", marker = "sys_platform == 'linux' and platform_machine == 'aarch64' and python_version == '3.12'" },
|
|
||||||
]
|
|
||||||
|
|
||||||
[tool.django-stubs]
|
[tool.django-stubs]
|
||||||
django_settings_module = "paperless.settings"
|
django_settings_module = "paperless.settings"
|
||||||
|
@@ -174,7 +174,7 @@ test('bulk edit', async ({ page }) => {
|
|||||||
await expect(page.locator('pngx-document-list')).toHaveText(
|
await expect(page.locator('pngx-document-list')).toHaveText(
|
||||||
/Selected 61 of 61 documents/i
|
/Selected 61 of 61 documents/i
|
||||||
)
|
)
|
||||||
await page.getByRole('button', { name: 'Cancel' }).click()
|
await page.getByRole('button', { name: 'None' }).click()
|
||||||
|
|
||||||
await page.locator('pngx-document-card-small').nth(1).click()
|
await page.locator('pngx-document-card-small').nth(1).click()
|
||||||
await page.locator('pngx-document-card-small').nth(2).click()
|
await page.locator('pngx-document-card-small').nth(2).click()
|
||||||
|
1299
src-ui/messages.xlf
1299
src-ui/messages.xlf
File diff suppressed because it is too large
Load Diff
@@ -11,17 +11,17 @@
|
|||||||
},
|
},
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@angular/cdk": "^20.2.2",
|
"@angular/cdk": "^20.2.6",
|
||||||
"@angular/common": "~20.2.4",
|
"@angular/common": "~20.3.2",
|
||||||
"@angular/compiler": "~20.2.4",
|
"@angular/compiler": "~20.3.2",
|
||||||
"@angular/core": "~20.2.4",
|
"@angular/core": "~20.3.2",
|
||||||
"@angular/forms": "~20.2.4",
|
"@angular/forms": "~20.3.2",
|
||||||
"@angular/localize": "~20.2.4",
|
"@angular/localize": "~20.3.2",
|
||||||
"@angular/platform-browser": "~20.2.4",
|
"@angular/platform-browser": "~20.3.2",
|
||||||
"@angular/platform-browser-dynamic": "~20.2.4",
|
"@angular/platform-browser-dynamic": "~20.3.2",
|
||||||
"@angular/router": "~20.2.4",
|
"@angular/router": "~20.3.2",
|
||||||
"@ng-bootstrap/ng-bootstrap": "^19.0.1",
|
"@ng-bootstrap/ng-bootstrap": "^19.0.1",
|
||||||
"@ng-select/ng-select": "^20.1.3",
|
"@ng-select/ng-select": "^20.2.2",
|
||||||
"@ngneat/dirty-check-forms": "^3.0.3",
|
"@ngneat/dirty-check-forms": "^3.0.3",
|
||||||
"@popperjs/core": "^2.11.8",
|
"@popperjs/core": "^2.11.8",
|
||||||
"bootstrap": "^5.3.8",
|
"bootstrap": "^5.3.8",
|
||||||
@@ -29,47 +29,48 @@
|
|||||||
"mime-names": "^1.0.0",
|
"mime-names": "^1.0.0",
|
||||||
"ng2-pdf-viewer": "^10.4.0",
|
"ng2-pdf-viewer": "^10.4.0",
|
||||||
"ngx-bootstrap-icons": "^1.9.3",
|
"ngx-bootstrap-icons": "^1.9.3",
|
||||||
"ngx-color": "^10.0.0",
|
"ngx-color": "^10.1.0",
|
||||||
"ngx-cookie-service": "^20.1.0",
|
"ngx-cookie-service": "^20.1.0",
|
||||||
"ngx-device-detector": "^10.1.0",
|
"ngx-device-detector": "^10.1.0",
|
||||||
"ngx-ui-tour-ng-bootstrap": "^17.0.1",
|
"ngx-ui-tour-ng-bootstrap": "^17.0.1",
|
||||||
"rxjs": "^7.8.2",
|
"rxjs": "^7.8.2",
|
||||||
"tslib": "^2.8.1",
|
"tslib": "^2.8.1",
|
||||||
"utif": "^3.1.0",
|
"utif": "^3.1.0",
|
||||||
"uuid": "^11.1.0",
|
"uuid": "^13.0.0",
|
||||||
"zone.js": "^0.15.1"
|
"zone.js": "^0.15.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@angular-builders/custom-webpack": "^20.0.0",
|
"@angular-builders/custom-webpack": "^20.0.0",
|
||||||
"@angular-builders/jest": "^20.0.0",
|
"@angular-builders/jest": "^20.0.0",
|
||||||
"@angular-devkit/core": "^20.2.2",
|
"@angular-devkit/core": "^20.3.3",
|
||||||
"@angular-devkit/schematics": "^20.2.2",
|
"@angular-devkit/schematics": "^20.3.3",
|
||||||
"@angular-eslint/builder": "20.2.0",
|
"@angular-eslint/builder": "20.3.0",
|
||||||
"@angular-eslint/eslint-plugin": "20.2.0",
|
"@angular-eslint/eslint-plugin": "20.3.0",
|
||||||
"@angular-eslint/eslint-plugin-template": "20.2.0",
|
"@angular-eslint/eslint-plugin-template": "20.3.0",
|
||||||
"@angular-eslint/schematics": "20.2.0",
|
"@angular-eslint/schematics": "20.3.0",
|
||||||
"@angular-eslint/template-parser": "20.2.0",
|
"@angular-eslint/template-parser": "20.3.0",
|
||||||
"@angular/build": "^20.2.2",
|
"@angular/build": "^20.3.3",
|
||||||
"@angular/cli": "~20.2.2",
|
"@angular/cli": "~20.3.3",
|
||||||
"@angular/compiler-cli": "~20.2.4",
|
"@angular/compiler-cli": "~20.3.2",
|
||||||
"@codecov/webpack-plugin": "^1.9.1",
|
"@codecov/webpack-plugin": "^1.9.1",
|
||||||
"@playwright/test": "^1.55.0",
|
"@playwright/test": "^1.55.1",
|
||||||
"@types/jest": "^30.0.0",
|
"@types/jest": "^30.0.0",
|
||||||
"@types/node": "^24.3.0",
|
"@types/node": "^24.6.1",
|
||||||
"@typescript-eslint/eslint-plugin": "^8.41.0",
|
"@typescript-eslint/eslint-plugin": "^8.45.0",
|
||||||
"@typescript-eslint/parser": "^8.41.0",
|
"@typescript-eslint/parser": "^8.45.0",
|
||||||
"@typescript-eslint/utils": "^8.41.0",
|
"@typescript-eslint/utils": "^8.45.0",
|
||||||
"eslint": "^9.34.0",
|
"eslint": "^9.36.0",
|
||||||
"jest": "30.1.3",
|
"jest": "30.2.0",
|
||||||
"jest-environment-jsdom": "^30.1.2",
|
"jest-environment-jsdom": "^30.2.0",
|
||||||
"jest-junit": "^16.0.0",
|
"jest-junit": "^16.0.0",
|
||||||
"jest-preset-angular": "^15.0.0",
|
"jest-preset-angular": "^15.0.2",
|
||||||
"jest-websocket-mock": "^2.5.0",
|
"jest-websocket-mock": "^2.5.0",
|
||||||
"prettier-plugin-organize-imports": "^4.2.0",
|
"prettier-plugin-organize-imports": "^4.3.0",
|
||||||
"ts-node": "~10.9.1",
|
"ts-node": "~10.9.1",
|
||||||
"typescript": "^5.8.3",
|
"typescript": "^5.8.3",
|
||||||
"webpack": "^5.101.3"
|
"webpack": "^5.102.0"
|
||||||
},
|
},
|
||||||
|
"packageManager": "pnpm@10.17.1",
|
||||||
"pnpm": {
|
"pnpm": {
|
||||||
"onlyBuiltDependencies": [
|
"onlyBuiltDependencies": [
|
||||||
"@parcel/watcher",
|
"@parcel/watcher",
|
||||||
|
3498
src-ui/pnpm-lock.yaml
generated
3498
src-ui/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -145,4 +145,14 @@ HTMLCanvasElement.prototype.getContext = <
|
|||||||
typeof HTMLCanvasElement.prototype.getContext
|
typeof HTMLCanvasElement.prototype.getContext
|
||||||
>jest.fn()
|
>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')
|
jest.mock('pdfjs-dist')
|
||||||
|
@@ -16,6 +16,7 @@ import {
|
|||||||
NgbNavItem,
|
NgbNavItem,
|
||||||
} from '@ng-bootstrap/ng-bootstrap'
|
} from '@ng-bootstrap/ng-bootstrap'
|
||||||
import { allIcons, NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
import { allIcons, NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||||
|
import { throwError } from 'rxjs'
|
||||||
import { routes } from 'src/app/app-routing.module'
|
import { routes } from 'src/app/app-routing.module'
|
||||||
import {
|
import {
|
||||||
PaperlessTask,
|
PaperlessTask,
|
||||||
@@ -28,6 +29,7 @@ import { PermissionsGuard } from 'src/app/guards/permissions.guard'
|
|||||||
import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
|
import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
|
||||||
import { PermissionsService } from 'src/app/services/permissions.service'
|
import { PermissionsService } from 'src/app/services/permissions.service'
|
||||||
import { TasksService } from 'src/app/services/tasks.service'
|
import { TasksService } from 'src/app/services/tasks.service'
|
||||||
|
import { ToastService } from 'src/app/services/toast.service'
|
||||||
import { environment } from 'src/environments/environment'
|
import { environment } from 'src/environments/environment'
|
||||||
import { ConfirmDialogComponent } from '../../common/confirm-dialog/confirm-dialog.component'
|
import { ConfirmDialogComponent } from '../../common/confirm-dialog/confirm-dialog.component'
|
||||||
import { PageHeaderComponent } from '../../common/page-header/page-header.component'
|
import { PageHeaderComponent } from '../../common/page-header/page-header.component'
|
||||||
@@ -123,6 +125,7 @@ describe('TasksComponent', () => {
|
|||||||
let router: Router
|
let router: Router
|
||||||
let httpTestingController: HttpTestingController
|
let httpTestingController: HttpTestingController
|
||||||
let reloadSpy
|
let reloadSpy
|
||||||
|
let toastService: ToastService
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
TestBed.configureTestingModule({
|
TestBed.configureTestingModule({
|
||||||
@@ -157,6 +160,7 @@ describe('TasksComponent', () => {
|
|||||||
httpTestingController = TestBed.inject(HttpTestingController)
|
httpTestingController = TestBed.inject(HttpTestingController)
|
||||||
modalService = TestBed.inject(NgbModal)
|
modalService = TestBed.inject(NgbModal)
|
||||||
router = TestBed.inject(Router)
|
router = TestBed.inject(Router)
|
||||||
|
toastService = TestBed.inject(ToastService)
|
||||||
fixture = TestBed.createComponent(TasksComponent)
|
fixture = TestBed.createComponent(TasksComponent)
|
||||||
component = fixture.componentInstance
|
component = fixture.componentInstance
|
||||||
jest.useFakeTimers()
|
jest.useFakeTimers()
|
||||||
@@ -249,6 +253,42 @@ describe('TasksComponent', () => {
|
|||||||
expect(dismissSpy).toHaveBeenCalledWith(selected)
|
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', () => {
|
it('should support dismiss all tasks', () => {
|
||||||
let modal: NgbModalRef
|
let modal: NgbModalRef
|
||||||
modalService.activeInstances.subscribe((m) => (modal = m[m.length - 1]))
|
modalService.activeInstances.subscribe((m) => (modal = m[m.length - 1]))
|
||||||
|
@@ -24,6 +24,7 @@ import { PaperlessTask } from 'src/app/data/paperless-task'
|
|||||||
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
||||||
import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
|
import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
|
||||||
import { TasksService } from 'src/app/services/tasks.service'
|
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 { ConfirmDialogComponent } from '../../common/confirm-dialog/confirm-dialog.component'
|
||||||
import { PageHeaderComponent } from '../../common/page-header/page-header.component'
|
import { PageHeaderComponent } from '../../common/page-header/page-header.component'
|
||||||
import { LoadingComponentWithPermissions } from '../../loading-component/loading.component'
|
import { LoadingComponentWithPermissions } from '../../loading-component/loading.component'
|
||||||
@@ -72,6 +73,7 @@ export class TasksComponent
|
|||||||
tasksService = inject(TasksService)
|
tasksService = inject(TasksService)
|
||||||
private modalService = inject(NgbModal)
|
private modalService = inject(NgbModal)
|
||||||
private readonly router = inject(Router)
|
private readonly router = inject(Router)
|
||||||
|
private readonly toastService = inject(ToastService)
|
||||||
|
|
||||||
public activeTab: TaskTab
|
public activeTab: TaskTab
|
||||||
public selectedTasks: Set<number> = new Set()
|
public selectedTasks: Set<number> = new Set()
|
||||||
@@ -154,11 +156,19 @@ export class TasksComponent
|
|||||||
modal.componentInstance.confirmClicked.pipe(first()).subscribe(() => {
|
modal.componentInstance.confirmClicked.pipe(first()).subscribe(() => {
|
||||||
modal.componentInstance.buttonsEnabled = false
|
modal.componentInstance.buttonsEnabled = false
|
||||||
modal.close()
|
modal.close()
|
||||||
this.tasksService.dismissTasks(tasks)
|
this.tasksService.dismissTasks(tasks).subscribe({
|
||||||
|
error: (e) => {
|
||||||
|
this.toastService.showError($localize`Error dismissing tasks`, e)
|
||||||
|
modal.componentInstance.buttonsEnabled = true
|
||||||
|
},
|
||||||
|
})
|
||||||
this.clearSelection()
|
this.clearSelection()
|
||||||
})
|
})
|
||||||
} else {
|
} else {
|
||||||
this.tasksService.dismissTasks(tasks)
|
this.tasksService.dismissTasks(tasks).subscribe({
|
||||||
|
error: (e) =>
|
||||||
|
this.toastService.showError($localize`Error dismissing task`, e),
|
||||||
|
})
|
||||||
this.clearSelection()
|
this.clearSelection()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -35,6 +35,9 @@
|
|||||||
@case (CustomFieldDataType.Select) {
|
@case (CustomFieldDataType.Select) {
|
||||||
<span [ngbTooltip]="nameTooltip">{{getSelectValue(field, value)}}</span>
|
<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 {
|
@default {
|
||||||
<span [ngbTooltip]="nameTooltip">{{value}}</span>
|
<span [ngbTooltip]="nameTooltip">{{value}}</span>
|
||||||
}
|
}
|
||||||
|
@@ -1,5 +1,5 @@
|
|||||||
import { CurrencyPipe, getLocaleCurrencyCode } from '@angular/common'
|
import { CurrencyPipe, getLocaleCurrencyCode, SlicePipe } from '@angular/common'
|
||||||
import { Component, Input, LOCALE_ID, OnInit, inject } from '@angular/core'
|
import { Component, inject, Input, LOCALE_ID, OnInit } from '@angular/core'
|
||||||
import { NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap'
|
import { NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap'
|
||||||
import { takeUntil } from 'rxjs'
|
import { takeUntil } from 'rxjs'
|
||||||
import { CustomField, CustomFieldDataType } from 'src/app/data/custom-field'
|
import { CustomField, CustomFieldDataType } from 'src/app/data/custom-field'
|
||||||
@@ -14,7 +14,7 @@ import { LoadingComponentWithPermissions } from '../../loading-component/loading
|
|||||||
selector: 'pngx-custom-field-display',
|
selector: 'pngx-custom-field-display',
|
||||||
templateUrl: './custom-field-display.component.html',
|
templateUrl: './custom-field-display.component.html',
|
||||||
styleUrl: './custom-field-display.component.scss',
|
styleUrl: './custom-field-display.component.scss',
|
||||||
imports: [CustomDatePipe, CurrencyPipe, NgbTooltipModule],
|
imports: [CustomDatePipe, CurrencyPipe, NgbTooltipModule, SlicePipe],
|
||||||
})
|
})
|
||||||
export class CustomFieldDisplayComponent
|
export class CustomFieldDisplayComponent
|
||||||
extends LoadingComponentWithPermissions
|
extends LoadingComponentWithPermissions
|
||||||
|
@@ -1,28 +1,36 @@
|
|||||||
<div class="btn-group w-100" role="group" ngbDropdown #dropdown="ngbDropdown" (openChange)="onOpenChange($event)" [popperOptions]="popperOptions">
|
@if (useDropdown) {
|
||||||
<button class="btn btn-sm btn-outline-primary" id="dropdown_toggle" ngbDropdownToggle [disabled]="disabled">
|
<div class="btn-group w-100" role="group" ngbDropdown #dropdown="ngbDropdown" (openChange)="onOpenChange($event)" [popperOptions]="popperOptions">
|
||||||
<i-bs name="{{icon}}"></i-bs>
|
<button class="btn btn-sm btn-outline-primary" id="dropdown_toggle" ngbDropdownToggle [disabled]="disabled">
|
||||||
<div class="d-none d-sm-inline"> {{title}}</div>
|
<i-bs name="{{icon}}"></i-bs>
|
||||||
@if (isActive) {
|
<div class="d-none d-sm-inline"> {{title}}</div>
|
||||||
<pngx-clearable-badge [selected]="isActive" (cleared)="reset()"></pngx-clearable-badge>
|
@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>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<ng-template #comparisonValueTemplate let-atom="atom">
|
<ng-template #comparisonValueTemplate let-atom="atom">
|
||||||
@if (getCustomFieldByID(atom.field)?.data_type === CustomFieldDataType.Date) {
|
@if (getCustomFieldByID(atom.field)?.data_type === CustomFieldDataType.Date) {
|
||||||
|
@@ -41,9 +41,3 @@
|
|||||||
min-width: 140px;
|
min-width: 140px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-group-xs {
|
|
||||||
> .btn {
|
|
||||||
border-radius: 0.15rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
@@ -120,6 +120,12 @@ export class CustomFieldQueriesModel {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
addInitialAtom() {
|
||||||
|
this.addAtom(
|
||||||
|
new CustomFieldQueryAtom([null, CustomFieldQueryOperator.Exists, 'true'])
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
private findElement(
|
private findElement(
|
||||||
queryElement: CustomFieldQueryElement,
|
queryElement: CustomFieldQueryElement,
|
||||||
elements: any[]
|
elements: any[]
|
||||||
@@ -206,6 +212,9 @@ export class CustomFieldsQueryDropdownComponent extends LoadingComponentWithPerm
|
|||||||
@Input()
|
@Input()
|
||||||
applyOnClose = false
|
applyOnClose = false
|
||||||
|
|
||||||
|
@Input()
|
||||||
|
useDropdown: boolean = true
|
||||||
|
|
||||||
get name(): string {
|
get name(): string {
|
||||||
return this.title ? this.title.replace(/\s/g, '_').toLowerCase() : null
|
return this.title ? this.title.replace(/\s/g, '_').toLowerCase() : null
|
||||||
}
|
}
|
||||||
@@ -258,13 +267,7 @@ export class CustomFieldsQueryDropdownComponent extends LoadingComponentWithPerm
|
|||||||
public onOpenChange(open: boolean) {
|
public onOpenChange(open: boolean) {
|
||||||
if (open) {
|
if (open) {
|
||||||
if (this.selectionModel.queries.length === 0) {
|
if (this.selectionModel.queries.length === 0) {
|
||||||
this.selectionModel.addAtom(
|
this.selectionModel.addInitialAtom()
|
||||||
new CustomFieldQueryAtom([
|
|
||||||
null,
|
|
||||||
CustomFieldQueryOperator.Exists,
|
|
||||||
'true',
|
|
||||||
])
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
this.selectionModel.queries.length === 1 &&
|
this.selectionModel.queries.length === 1 &&
|
||||||
|
@@ -177,10 +177,16 @@ export class CustomFieldEditDialogComponent
|
|||||||
}
|
}
|
||||||
|
|
||||||
public removeSelectOption(index: number) {
|
public removeSelectOption(index: number) {
|
||||||
this.selectOptions.removeAt(index)
|
const globalIndex =
|
||||||
this._allSelectOptions.splice(
|
index + (this.selectOptionsPage - 1) * SELECT_OPTION_PAGE_SIZE
|
||||||
index + (this.selectOptionsPage - 1) * SELECT_OPTION_PAGE_SIZE,
|
this._allSelectOptions.splice(globalIndex, 1)
|
||||||
1
|
|
||||||
|
const totalPages = Math.max(
|
||||||
|
1,
|
||||||
|
Math.ceil(this._allSelectOptions.length / SELECT_OPTION_PAGE_SIZE)
|
||||||
)
|
)
|
||||||
|
const targetPage = Math.min(this.selectOptionsPage, totalPages)
|
||||||
|
|
||||||
|
this.selectOptionsPage = targetPage
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -12,6 +12,8 @@
|
|||||||
|
|
||||||
<pngx-input-color i18n-title title="Color" formControlName="color" [error]="error?.color"></pngx-input-color>
|
<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-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>
|
<pngx-input-select i18n-title title="Matching algorithm" [items]="getMatchingAlgorithms()" formControlName="matching_algorithm"></pngx-input-select>
|
||||||
@if (patternRequired) {
|
@if (patternRequired) {
|
||||||
|
@@ -35,11 +35,16 @@ import { TextComponent } from '../../input/text/text.component'
|
|||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class TagEditDialogComponent extends EditDialogComponent<Tag> {
|
export class TagEditDialogComponent extends EditDialogComponent<Tag> {
|
||||||
|
tags: Tag[]
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super()
|
super()
|
||||||
this.service = inject(TagService)
|
this.service = inject(TagService)
|
||||||
this.userService = inject(UserService)
|
this.userService = inject(UserService)
|
||||||
this.settingsService = inject(SettingsService)
|
this.settingsService = inject(SettingsService)
|
||||||
|
this.service.listAll().subscribe((result) => {
|
||||||
|
this.tags = result.results
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
getCreateTitle() {
|
getCreateTitle() {
|
||||||
@@ -55,6 +60,7 @@ export class TagEditDialogComponent extends EditDialogComponent<Tag> {
|
|||||||
name: new FormControl(''),
|
name: new FormControl(''),
|
||||||
color: new FormControl(randomColor()),
|
color: new FormControl(randomColor()),
|
||||||
is_inbox_tag: new FormControl(false),
|
is_inbox_tag: new FormControl(false),
|
||||||
|
parent: new FormControl(null),
|
||||||
matching_algorithm: new FormControl(DEFAULT_MATCHING_ALGORITHM),
|
matching_algorithm: new FormControl(DEFAULT_MATCHING_ALGORITHM),
|
||||||
match: new FormControl(''),
|
match: new FormControl(''),
|
||||||
is_insensitive: new FormControl(true),
|
is_insensitive: new FormControl(true),
|
||||||
|
@@ -156,30 +156,97 @@
|
|||||||
<p class="small" i18n>Trigger for documents that match <em>all</em> filters specified below.</p>
|
<p class="small" i18n>Trigger for documents that match <em>all</em> filters specified below.</p>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col">
|
<div class="col">
|
||||||
<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>
|
<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>
|
||||||
@if (formGroup.get('type').value === WorkflowTriggerType.Consumption) {
|
@if (formGroup.get('type').value === WorkflowTriggerType.Consumption) {
|
||||||
<pngx-input-select i18n-title title="Filter sources" [items]="sourceOptions" [multiple]="true" formControlName="sources" [error]="error?.sources"></pngx-input-select>
|
<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" 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-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" [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 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>
|
||||||
}
|
}
|
||||||
@if (formGroup.get('type').value === WorkflowTriggerType.DocumentAdded || formGroup.get('type').value === WorkflowTriggerType.DocumentUpdated || formGroup.get('type').value === WorkflowTriggerType.Scheduled) {
|
@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" [items]="getMatchingAlgorithms()" formControlName="matching_algorithm"></pngx-input-select>
|
<pngx-input-select i18n-title title="Content matching algorithm" horizontal="true" [items]="getMatchingAlgorithms()" formControlName="matching_algorithm"></pngx-input-select>
|
||||||
@if (patternRequired) {
|
@if (matchingPatternRequired(formGroup)) {
|
||||||
<pngx-input-text i18n-title title="Content matching pattern" formControlName="match" [error]="error?.match"></pngx-input-text>
|
<pngx-input-text i18n-title title="Content matching pattern" horizontal="true" formControlName="match" [error]="error?.match"></pngx-input-text>
|
||||||
}
|
}
|
||||||
@if (patternRequired) {
|
@if (matchingPatternRequired(formGroup)) {
|
||||||
<pngx-input-check i18n-title title="Case insensitive" formControlName="is_insensitive"></pngx-input-check>
|
<pngx-input-check i18n-title title="Case insensitive" horizontal="true" 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="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>
|
||||||
|
@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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
|
|
||||||
|
@@ -7,3 +7,7 @@
|
|||||||
.accordion-button {
|
.accordion-button {
|
||||||
font-size: 1rem;
|
font-size: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
:host ::ng-deep .filters .paperless-input-select.mb-3 {
|
||||||
|
margin-bottom: 0 !important;
|
||||||
|
}
|
||||||
|
@@ -11,8 +11,14 @@ import {
|
|||||||
import { NgbActiveModal, NgbModule } from '@ng-bootstrap/ng-bootstrap'
|
import { NgbActiveModal, NgbModule } from '@ng-bootstrap/ng-bootstrap'
|
||||||
import { NgSelectModule } from '@ng-select/ng-select'
|
import { NgSelectModule } from '@ng-select/ng-select'
|
||||||
import { of } from 'rxjs'
|
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 { CustomFieldDataType } from 'src/app/data/custom-field'
|
||||||
import { MATCHING_ALGORITHMS, MATCH_AUTO } from 'src/app/data/matching-model'
|
import { CustomFieldQueryLogicalOperator } from 'src/app/data/custom-field-query'
|
||||||
|
import {
|
||||||
|
MATCHING_ALGORITHMS,
|
||||||
|
MATCH_AUTO,
|
||||||
|
MATCH_NONE,
|
||||||
|
} from 'src/app/data/matching-model'
|
||||||
import { Workflow } from 'src/app/data/workflow'
|
import { Workflow } from 'src/app/data/workflow'
|
||||||
import {
|
import {
|
||||||
WorkflowAction,
|
WorkflowAction,
|
||||||
@@ -31,6 +37,7 @@ import { DocumentTypeService } from 'src/app/services/rest/document-type.service
|
|||||||
import { MailRuleService } from 'src/app/services/rest/mail-rule.service'
|
import { MailRuleService } from 'src/app/services/rest/mail-rule.service'
|
||||||
import { StoragePathService } from 'src/app/services/rest/storage-path.service'
|
import { StoragePathService } from 'src/app/services/rest/storage-path.service'
|
||||||
import { SettingsService } from 'src/app/services/settings.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 { ConfirmButtonComponent } from '../../confirm-button/confirm-button.component'
|
||||||
import { NumberComponent } from '../../input/number/number.component'
|
import { NumberComponent } from '../../input/number/number.component'
|
||||||
import { PermissionsGroupComponent } from '../../input/permissions/permissions-group/permissions-group.component'
|
import { PermissionsGroupComponent } from '../../input/permissions/permissions-group/permissions-group.component'
|
||||||
@@ -43,6 +50,7 @@ import { EditDialogMode } from '../edit-dialog.component'
|
|||||||
import {
|
import {
|
||||||
DOCUMENT_SOURCE_OPTIONS,
|
DOCUMENT_SOURCE_OPTIONS,
|
||||||
SCHEDULE_DATE_FIELD_OPTIONS,
|
SCHEDULE_DATE_FIELD_OPTIONS,
|
||||||
|
TriggerFilterType,
|
||||||
WORKFLOW_ACTION_OPTIONS,
|
WORKFLOW_ACTION_OPTIONS,
|
||||||
WORKFLOW_TYPE_OPTIONS,
|
WORKFLOW_TYPE_OPTIONS,
|
||||||
WorkflowEditDialogComponent,
|
WorkflowEditDialogComponent,
|
||||||
@@ -375,6 +383,562 @@ describe('WorkflowEditDialogComponent', () => {
|
|||||||
expect(component.objectForm.get('actions').value[0].webhook).toBeNull()
|
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', () => {
|
it('should remove selected custom field from the form group', () => {
|
||||||
const formGroup = new FormGroup({
|
const formGroup = new FormGroup({
|
||||||
assign_custom_fields: new FormControl([1, 2, 3]),
|
assign_custom_fields: new FormControl([1, 2, 3]),
|
||||||
|
@@ -6,6 +6,7 @@ import {
|
|||||||
import { NgTemplateOutlet } from '@angular/common'
|
import { NgTemplateOutlet } from '@angular/common'
|
||||||
import { Component, OnInit, inject } from '@angular/core'
|
import { Component, OnInit, inject } from '@angular/core'
|
||||||
import {
|
import {
|
||||||
|
AbstractControl,
|
||||||
FormArray,
|
FormArray,
|
||||||
FormControl,
|
FormControl,
|
||||||
FormGroup,
|
FormGroup,
|
||||||
@@ -14,7 +15,7 @@ import {
|
|||||||
} from '@angular/forms'
|
} from '@angular/forms'
|
||||||
import { NgbAccordionModule } from '@ng-bootstrap/ng-bootstrap'
|
import { NgbAccordionModule } from '@ng-bootstrap/ng-bootstrap'
|
||||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||||
import { first } from 'rxjs'
|
import { Subscription, first, takeUntil } from 'rxjs'
|
||||||
import { Correspondent } from 'src/app/data/correspondent'
|
import { Correspondent } from 'src/app/data/correspondent'
|
||||||
import { CustomField, CustomFieldDataType } from 'src/app/data/custom-field'
|
import { CustomField, CustomFieldDataType } from 'src/app/data/custom-field'
|
||||||
import { DocumentType } from 'src/app/data/document-type'
|
import { DocumentType } from 'src/app/data/document-type'
|
||||||
@@ -45,7 +46,12 @@ import { StoragePathService } from 'src/app/services/rest/storage-path.service'
|
|||||||
import { UserService } from 'src/app/services/rest/user.service'
|
import { UserService } from 'src/app/services/rest/user.service'
|
||||||
import { WorkflowService } from 'src/app/services/rest/workflow.service'
|
import { WorkflowService } from 'src/app/services/rest/workflow.service'
|
||||||
import { SettingsService } from 'src/app/services/settings.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 { 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 { CheckComponent } from '../../input/check/check.component'
|
||||||
import { CustomFieldsValuesComponent } from '../../input/custom-fields-values/custom-fields-values.component'
|
import { CustomFieldsValuesComponent } from '../../input/custom-fields-values/custom-fields-values.component'
|
||||||
import { EntriesComponent } from '../../input/entries/entries.component'
|
import { EntriesComponent } from '../../input/entries/entries.component'
|
||||||
@@ -135,10 +141,235 @@ 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(
|
const TRIGGER_MATCHING_ALGORITHMS = MATCHING_ALGORITHMS.filter(
|
||||||
(a) => a.id !== MATCH_AUTO
|
(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({
|
@Component({
|
||||||
selector: 'pngx-workflow-edit-dialog',
|
selector: 'pngx-workflow-edit-dialog',
|
||||||
templateUrl: './workflow-edit-dialog.component.html',
|
templateUrl: './workflow-edit-dialog.component.html',
|
||||||
@@ -153,6 +384,7 @@ const TRIGGER_MATCHING_ALGORITHMS = MATCHING_ALGORITHMS.filter(
|
|||||||
TextAreaComponent,
|
TextAreaComponent,
|
||||||
TagsComponent,
|
TagsComponent,
|
||||||
CustomFieldsValuesComponent,
|
CustomFieldsValuesComponent,
|
||||||
|
CustomFieldsQueryDropdownComponent,
|
||||||
PermissionsGroupComponent,
|
PermissionsGroupComponent,
|
||||||
PermissionsUserComponent,
|
PermissionsUserComponent,
|
||||||
ConfirmButtonComponent,
|
ConfirmButtonComponent,
|
||||||
@@ -170,6 +402,8 @@ export class WorkflowEditDialogComponent
|
|||||||
{
|
{
|
||||||
public WorkflowTriggerType = WorkflowTriggerType
|
public WorkflowTriggerType = WorkflowTriggerType
|
||||||
public WorkflowActionType = WorkflowActionType
|
public WorkflowActionType = WorkflowActionType
|
||||||
|
public TriggerFilterType = TriggerFilterType
|
||||||
|
public filterDefinitions = TRIGGER_FILTER_DEFINITIONS
|
||||||
|
|
||||||
private correspondentService: CorrespondentService
|
private correspondentService: CorrespondentService
|
||||||
private documentTypeService: DocumentTypeService
|
private documentTypeService: DocumentTypeService
|
||||||
@@ -189,6 +423,11 @@ export class WorkflowEditDialogComponent
|
|||||||
|
|
||||||
private allowedActionTypes = []
|
private allowedActionTypes = []
|
||||||
|
|
||||||
|
private readonly triggerFilterOptionsMap = new WeakMap<
|
||||||
|
FormArray,
|
||||||
|
TriggerFilterOption[]
|
||||||
|
>()
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super()
|
super()
|
||||||
this.service = inject(WorkflowService)
|
this.service = inject(WorkflowService)
|
||||||
@@ -390,6 +629,416 @@ export class WorkflowEditDialogComponent
|
|||||||
return this.objectForm.get('actions') as FormArray
|
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(
|
private createTriggerField(
|
||||||
trigger: WorkflowTrigger,
|
trigger: WorkflowTrigger,
|
||||||
emitEvent: boolean = false
|
emitEvent: boolean = false
|
||||||
@@ -405,13 +1054,7 @@ export class WorkflowEditDialogComponent
|
|||||||
matching_algorithm: new FormControl(trigger.matching_algorithm),
|
matching_algorithm: new FormControl(trigger.matching_algorithm),
|
||||||
match: new FormControl(trigger.match),
|
match: new FormControl(trigger.match),
|
||||||
is_insensitive: new FormControl(trigger.is_insensitive),
|
is_insensitive: new FormControl(trigger.is_insensitive),
|
||||||
filter_has_tags: new FormControl(trigger.filter_has_tags),
|
filters: this.buildFiltersFormArray(trigger),
|
||||||
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_offset_days: new FormControl(trigger.schedule_offset_days),
|
||||||
schedule_is_recurring: new FormControl(trigger.schedule_is_recurring),
|
schedule_is_recurring: new FormControl(trigger.schedule_is_recurring),
|
||||||
schedule_recurring_interval_days: new FormControl(
|
schedule_recurring_interval_days: new FormControl(
|
||||||
@@ -534,8 +1177,15 @@ export class WorkflowEditDialogComponent
|
|||||||
filter_path: null,
|
filter_path: null,
|
||||||
filter_mailrule: null,
|
filter_mailrule: null,
|
||||||
filter_has_tags: [],
|
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_correspondent: null,
|
||||||
filter_has_document_type: null,
|
filter_has_document_type: null,
|
||||||
|
filter_has_storage_path: null,
|
||||||
matching_algorithm: MATCH_NONE,
|
matching_algorithm: MATCH_NONE,
|
||||||
match: '',
|
match: '',
|
||||||
is_insensitive: true,
|
is_insensitive: true,
|
||||||
|
@@ -1,5 +1,9 @@
|
|||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<h4 class="modal-title" id="modal-basic-title">{{title}}</h4>
|
<h4 class="modal-title" id="modal-basic-title" i18n>{
|
||||||
|
documentIds.length,
|
||||||
|
plural,
|
||||||
|
=1 {Email Document} other {Email {{documentIds.length}} Documents}
|
||||||
|
}</h4>
|
||||||
<button type="button" class="btn-close" aria-label="Close" (click)="close()"></button>
|
<button type="button" class="btn-close" aria-label="Close" (click)="close()"></button>
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
@@ -22,11 +26,14 @@
|
|||||||
<input class="form-check-input mt-0 me-2" type="checkbox" role="switch" id="useArchiveVersion" [disabled]="!hasArchiveVersion" [(ngModel)]="useArchiveVersion">
|
<input class="form-check-input mt-0 me-2" type="checkbox" role="switch" id="useArchiveVersion" [disabled]="!hasArchiveVersion" [(ngModel)]="useArchiveVersion">
|
||||||
<label class="form-check-label w-100 text-start" for="useArchiveVersion" i18n>Use archive version</label>
|
<label class="form-check-label w-100 text-start" for="useArchiveVersion" i18n>Use archive version</label>
|
||||||
</div>
|
</div>
|
||||||
<button type="submit" class="btn btn-outline-primary" (click)="emailDocument()" [disabled]="loading || emailAddress.length === 0 || emailMessage.length === 0 || emailSubject.length === 0">
|
<button type="submit" class="btn btn-outline-primary" (click)="emailDocuments()" [disabled]="loading || emailAddress.length === 0 || emailMessage.length === 0 || emailSubject.length === 0">
|
||||||
@if (loading) {
|
@if (loading) {
|
||||||
<div class="spinner-border spinner-border-sm me-2" role="status"></div>
|
<div class="spinner-border spinner-border-sm me-2" role="status"></div>
|
||||||
}
|
}
|
||||||
<ng-container i18n>Send email</ng-container>
|
<ng-container i18n>Send email</ng-container>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="text-light fst-italic small mt-2">
|
||||||
|
<ng-container i18n>Some email servers may reject messages with large attachments.</ng-container>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@@ -36,31 +36,59 @@ describe('EmailDocumentDialogComponent', () => {
|
|||||||
documentService = TestBed.inject(DocumentService)
|
documentService = TestBed.inject(DocumentService)
|
||||||
toastService = TestBed.inject(ToastService)
|
toastService = TestBed.inject(ToastService)
|
||||||
component = fixture.componentInstance
|
component = fixture.componentInstance
|
||||||
|
component.documentIds = [1]
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should set hasArchiveVersion and useArchiveVersion', () => {
|
it('should set hasArchiveVersion and useArchiveVersion', () => {
|
||||||
expect(component.hasArchiveVersion).toBeTruthy()
|
expect(component.hasArchiveVersion).toBeTruthy()
|
||||||
|
expect(component.useArchiveVersion).toBeTruthy()
|
||||||
|
|
||||||
component.hasArchiveVersion = false
|
component.hasArchiveVersion = false
|
||||||
expect(component.hasArchiveVersion).toBeFalsy()
|
expect(component.hasArchiveVersion).toBeFalsy()
|
||||||
expect(component.useArchiveVersion).toBeFalsy()
|
expect(component.useArchiveVersion).toBeFalsy()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should support sending document via email, showing error if needed', () => {
|
it('should support sending single document via email, showing error if needed', () => {
|
||||||
const toastErrorSpy = jest.spyOn(toastService, 'showError')
|
const toastErrorSpy = jest.spyOn(toastService, 'showError')
|
||||||
const toastSuccessSpy = jest.spyOn(toastService, 'showInfo')
|
const toastSuccessSpy = jest.spyOn(toastService, 'showInfo')
|
||||||
|
component.documentIds = [1]
|
||||||
component.emailAddress = 'hello@paperless-ngx.com'
|
component.emailAddress = 'hello@paperless-ngx.com'
|
||||||
component.emailSubject = 'Hello'
|
component.emailSubject = 'Hello'
|
||||||
component.emailMessage = 'World'
|
component.emailMessage = 'World'
|
||||||
jest
|
jest
|
||||||
.spyOn(documentService, 'emailDocument')
|
.spyOn(documentService, 'emailDocuments')
|
||||||
.mockReturnValue(throwError(() => new Error('Unable to email document')))
|
.mockReturnValue(throwError(() => new Error('Unable to email document')))
|
||||||
component.emailDocument()
|
component.emailDocuments()
|
||||||
expect(toastErrorSpy).toHaveBeenCalled()
|
expect(toastErrorSpy).toHaveBeenCalledWith(
|
||||||
|
'Error emailing document',
|
||||||
|
expect.any(Error)
|
||||||
|
)
|
||||||
|
|
||||||
jest.spyOn(documentService, 'emailDocument').mockReturnValue(of(true))
|
jest.spyOn(documentService, 'emailDocuments').mockReturnValue(of(true))
|
||||||
component.emailDocument()
|
component.emailDocuments()
|
||||||
expect(toastSuccessSpy).toHaveBeenCalled()
|
expect(toastSuccessSpy).toHaveBeenCalledWith('Email sent')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should support sending multiple documents via email, showing appropriate messages', () => {
|
||||||
|
const toastErrorSpy = jest.spyOn(toastService, 'showError')
|
||||||
|
const toastSuccessSpy = jest.spyOn(toastService, 'showInfo')
|
||||||
|
component.documentIds = [1, 2, 3]
|
||||||
|
component.emailAddress = 'hello@paperless-ngx.com'
|
||||||
|
component.emailSubject = 'Hello'
|
||||||
|
component.emailMessage = 'World'
|
||||||
|
jest
|
||||||
|
.spyOn(documentService, 'emailDocuments')
|
||||||
|
.mockReturnValue(throwError(() => new Error('Unable to email documents')))
|
||||||
|
component.emailDocuments()
|
||||||
|
expect(toastErrorSpy).toHaveBeenCalledWith(
|
||||||
|
'Error emailing documents',
|
||||||
|
expect.any(Error)
|
||||||
|
)
|
||||||
|
|
||||||
|
jest.spyOn(documentService, 'emailDocuments').mockReturnValue(of(true))
|
||||||
|
component.emailDocuments()
|
||||||
|
expect(toastSuccessSpy).toHaveBeenCalledWith('Email sent')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should close the dialog', () => {
|
it('should close the dialog', () => {
|
||||||
|
@@ -18,10 +18,7 @@ export class EmailDocumentDialogComponent extends LoadingComponentWithPermission
|
|||||||
private toastService = inject(ToastService)
|
private toastService = inject(ToastService)
|
||||||
|
|
||||||
@Input()
|
@Input()
|
||||||
title = $localize`Email Document`
|
documentIds: number[]
|
||||||
|
|
||||||
@Input()
|
|
||||||
documentId: number
|
|
||||||
|
|
||||||
private _hasArchiveVersion: boolean = true
|
private _hasArchiveVersion: boolean = true
|
||||||
|
|
||||||
@@ -46,11 +43,11 @@ export class EmailDocumentDialogComponent extends LoadingComponentWithPermission
|
|||||||
this.loading = false
|
this.loading = false
|
||||||
}
|
}
|
||||||
|
|
||||||
public emailDocument() {
|
public emailDocuments() {
|
||||||
this.loading = true
|
this.loading = true
|
||||||
this.documentService
|
this.documentService
|
||||||
.emailDocument(
|
.emailDocuments(
|
||||||
this.documentId,
|
this.documentIds,
|
||||||
this.emailAddress,
|
this.emailAddress,
|
||||||
this.emailSubject,
|
this.emailSubject,
|
||||||
this.emailMessage,
|
this.emailMessage,
|
||||||
@@ -67,7 +64,11 @@ export class EmailDocumentDialogComponent extends LoadingComponentWithPermission
|
|||||||
},
|
},
|
||||||
error: (e) => {
|
error: (e) => {
|
||||||
this.loading = false
|
this.loading = false
|
||||||
this.toastService.showError($localize`Error emailing document`, e)
|
const errorMessage =
|
||||||
|
this.documentIds.length > 1
|
||||||
|
? $localize`Error emailing documents`
|
||||||
|
: $localize`Error emailing document`
|
||||||
|
this.toastService.showError(errorMessage, e)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
@@ -114,6 +114,13 @@ export class FilterableDropdownSelectionModel {
|
|||||||
b.id == NEGATIVE_NULL_FILTER_VALUE)
|
b.id == NEGATIVE_NULL_FILTER_VALUE)
|
||||||
) {
|
) {
|
||||||
return 1
|
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 (
|
} else if (
|
||||||
this.getNonTemporary(a.id) == ToggleableItemState.NotSelected &&
|
this.getNonTemporary(a.id) == ToggleableItemState.NotSelected &&
|
||||||
this.getNonTemporary(b.id) != ToggleableItemState.NotSelected
|
this.getNonTemporary(b.id) != ToggleableItemState.NotSelected
|
||||||
|
@@ -15,12 +15,17 @@
|
|||||||
<i-bs width="1em" height="1em" name="x"></i-bs>
|
<i-bs width="1em" height="1em" name="x"></i-bs>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<div class="me-1">
|
<div class="me-1 name-cell" [style.--depth]="isTag ? getDepth() + 1 : 1">
|
||||||
@if (isTag) {
|
@if (isTag && getDepth() > 0) {
|
||||||
<pngx-tag [tag]="item" [clickable]="false"></pngx-tag>
|
<div class="indicator"></div>
|
||||||
} @else {
|
|
||||||
<small>{{item.name}}</small>
|
|
||||||
}
|
}
|
||||||
|
<div>
|
||||||
|
@if (isTag) {
|
||||||
|
<pngx-tag [tag]="item" [clickable]="false"></pngx-tag>
|
||||||
|
} @else {
|
||||||
|
<small>{{item.name}}</small>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@if (!hideCount) {
|
@if (!hideCount) {
|
||||||
<div class="badge bg-light text-dark rounded-pill ms-auto me-1">{{currentCount}}</div>
|
<div class="badge bg-light text-dark rounded-pill ms-auto me-1">{{currentCount}}</div>
|
||||||
|
@@ -2,3 +2,19 @@
|
|||||||
min-width: 1em;
|
min-width: 1em;
|
||||||
min-height: 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,6 +1,7 @@
|
|||||||
import { Component, EventEmitter, Input, Output } from '@angular/core'
|
import { Component, EventEmitter, Input, Output } from '@angular/core'
|
||||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||||
import { MatchingModel } from 'src/app/data/matching-model'
|
import { MatchingModel } from 'src/app/data/matching-model'
|
||||||
|
import { Tag } from 'src/app/data/tag'
|
||||||
import { TagComponent } from '../../tag/tag.component'
|
import { TagComponent } from '../../tag/tag.component'
|
||||||
|
|
||||||
export enum ToggleableItemState {
|
export enum ToggleableItemState {
|
||||||
@@ -45,6 +46,10 @@ export class ToggleableDropdownButtonComponent {
|
|||||||
return 'is_inbox_tag' in this.item
|
return 'is_inbox_tag' in this.item
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getDepth(): number {
|
||||||
|
return (this.item as Tag).depth ?? 0
|
||||||
|
}
|
||||||
|
|
||||||
get currentCount(): number {
|
get currentCount(): number {
|
||||||
return this.count ?? this.item.document_count
|
return this.count ?? this.item.document_count
|
||||||
}
|
}
|
||||||
|
@@ -1,19 +1,18 @@
|
|||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
@if (title) {
|
@if (title) {
|
||||||
<label [for]="inputId">{{title}}</label>
|
<label class="form-label" [for]="inputId">{{title}}</label>
|
||||||
}
|
}
|
||||||
|
|
||||||
<div class="input-group" [class.is-invalid]="error">
|
<div class="input-group" [class.is-invalid]="error">
|
||||||
<span class="input-group-text" [style.background-color]="value"> </span>
|
<button type="button" class="input-group-text" [style.background-color]="value" (click)="colorPicker.toggle()"> </button>
|
||||||
|
|
||||||
<ng-template #popContent>
|
<ng-template #popContent>
|
||||||
<div style="min-width: 200px;" class="pb-3">
|
<div style="min-width: 200px;" class="pb-3">
|
||||||
<color-slider [color]="value" (onChangeComplete)="colorChanged($event.color.hex)"></color-slider>
|
<color-slider [color]="value" (onChangeComplete)="colorChanged($event.color.hex)"></color-slider>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</ng-template>
|
</ng-template>
|
||||||
|
|
||||||
<input #inputField class="form-control" [class.is-invalid]="error" [id]="inputId" [(ngModel)]="value" (change)="onChange(value)" [autoClose]="'outside'" [ngbPopover]="popContent" placement="bottom" popoverClass="shadow">
|
<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">
|
||||||
|
|
||||||
<button class="btn btn-outline-secondary" type="button" (click)="randomize()">
|
<button class="btn btn-outline-secondary" type="button" (click)="randomize()">
|
||||||
<i-bs name="dice5"></i-bs>
|
<i-bs name="dice5"></i-bs>
|
||||||
|
@@ -42,8 +42,8 @@ describe('ColorComponent', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('should set swatch color', () => {
|
it('should set swatch color', () => {
|
||||||
const swatch: HTMLSpanElement = fixture.nativeElement.querySelector(
|
const swatch: HTMLButtonElement = fixture.nativeElement.querySelector(
|
||||||
'span.input-group-text'
|
'button.input-group-text'
|
||||||
)
|
)
|
||||||
expect(swatch.style.backgroundColor).toEqual('')
|
expect(swatch.style.backgroundColor).toEqual('')
|
||||||
component.value = '#ff0000'
|
component.value = '#ff0000'
|
||||||
|
@@ -68,6 +68,11 @@
|
|||||||
[allowNull]="true"
|
[allowNull]="true"
|
||||||
[horizontal]="true"></pngx-input-select>
|
[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)">
|
<button type="button" class="btn btn-link text-danger" (click)="removeSelectedField.next(fieldId)">
|
||||||
<i-bs name="trash"></i-bs>
|
<i-bs name="trash"></i-bs>
|
||||||
|
@@ -24,6 +24,7 @@ import { MonetaryComponent } from '../monetary/monetary.component'
|
|||||||
import { NumberComponent } from '../number/number.component'
|
import { NumberComponent } from '../number/number.component'
|
||||||
import { SelectComponent } from '../select/select.component'
|
import { SelectComponent } from '../select/select.component'
|
||||||
import { TextComponent } from '../text/text.component'
|
import { TextComponent } from '../text/text.component'
|
||||||
|
import { TextAreaComponent } from '../textarea/textarea.component'
|
||||||
import { UrlComponent } from '../url/url.component'
|
import { UrlComponent } from '../url/url.component'
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
@@ -51,6 +52,7 @@ import { UrlComponent } from '../url/url.component'
|
|||||||
ReactiveFormsModule,
|
ReactiveFormsModule,
|
||||||
RouterModule,
|
RouterModule,
|
||||||
NgxBootstrapIconsModule,
|
NgxBootstrapIconsModule,
|
||||||
|
TextAreaComponent,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class CustomFieldsValuesComponent extends AbstractInputComponent<Object> {
|
export class CustomFieldsValuesComponent extends AbstractInputComponent<Object> {
|
||||||
|
@@ -14,7 +14,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-2 align-items-center bg-light p-2">
|
<div class="mt-2 align-items-center bg-light p-2">
|
||||||
<div class="d-flex flex-wrap flex-row gap-2 w-100"
|
<div class="d-flex flex-wrap flex-row gap-2 w-100" style="min-height: 1em;"
|
||||||
cdkDropList #unselectedList="cdkDropList"
|
cdkDropList #unselectedList="cdkDropList"
|
||||||
cdkDropListOrientation="mixed"
|
cdkDropListOrientation="mixed"
|
||||||
(cdkDropListDropped)="drop($event)"
|
(cdkDropListDropped)="drop($event)"
|
||||||
|
@@ -1,66 +1,68 @@
|
|||||||
<div class="mb-3 paperless-input-select" [class.disabled]="disabled">
|
<div class="mb-3 paperless-input-select" [class.disabled]="disabled">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="d-flex align-items-center position-relative hidden-button-container" [class.col-md-3]="horizontal">
|
@if (title || removable) {
|
||||||
@if (title) {
|
<div class="d-flex align-items-center position-relative hidden-button-container" [class.col-md-3]="horizontal">
|
||||||
<label class="form-label" [class.mb-md-0]="horizontal" [for]="inputId">{{title}}</label>
|
@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)">
|
@if (removable) {
|
||||||
<i-bs name="x"></i-bs> <ng-container i18n>Remove</ng-container>
|
<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>
|
||||||
|
}
|
||||||
|
</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>
|
</button>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<div [class.col-md-9]="horizontal">
|
<div class="invalid-feedback">
|
||||||
<div [class.input-group]="allowCreateNew || showFilter" [class.is-invalid]="error">
|
{{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>
|
|
||||||
}
|
|
||||||
</div>
|
</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>
|
||||||
|
</div>
|
||||||
|
@@ -1,19 +1,22 @@
|
|||||||
<div class="mb-3 paperless-input-select paperless-input-tags" [class.disabled]="disabled" [class.pb-3]="getSuggestions().length > 0">
|
<div class="mb-3 paperless-input-select paperless-input-tags" [class.disabled]="disabled" [class.pb-3]="getSuggestions().length > 0">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="d-flex align-items-center" [class.col-md-3]="horizontal">
|
@if (title) {
|
||||||
<label class="form-label" [class.mb-md-0]="horizontal" for="tags">{{title}}</label>
|
<div class="d-flex align-items-center" [class.col-md-3]="horizontal">
|
||||||
</div>
|
<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="position-relative" [class.col-md-9]="horizontal">
|
||||||
<div class="input-group flex-nowrap">
|
<div class="input-group flex-nowrap">
|
||||||
<ng-select #tagSelect name="tags" [items]="tags" bindLabel="name" bindValue="id" [(ngModel)]="value"
|
<ng-select #tagSelect name="tags" [items]="tags" bindLabel="name" bindValue="id" [(ngModel)]="value"
|
||||||
[disabled]="disabled"
|
[disabled]="disabled"
|
||||||
[multiple]="true"
|
[multiple]="multiple"
|
||||||
[closeOnSelect]="false"
|
[closeOnSelect]="false"
|
||||||
[clearSearchOnAdd]="true"
|
[clearSearchOnAdd]="true"
|
||||||
[hideSelected]="tags.length > 0"
|
[hideSelected]="tags.length > 0"
|
||||||
[addTag]="allowCreate ? createTagRef : false"
|
[addTag]="allowCreate ? createTagRef : false"
|
||||||
addTagText="Add tag"
|
addTagText="Add tag"
|
||||||
i18n-addTagText
|
i18n-addTagText
|
||||||
|
(add)="onAdd($event)"
|
||||||
(change)="onChange(value)">
|
(change)="onChange(value)">
|
||||||
|
|
||||||
<ng-template ng-label-tmp let-item="item">
|
<ng-template ng-label-tmp let-item="item">
|
||||||
@@ -25,9 +28,20 @@
|
|||||||
</button>
|
</button>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
<ng-template ng-option-tmp let-item="item" let-index="index" let-search="searchTerm">
|
<ng-template ng-option-tmp let-item="item" let-index="index" let-search="searchTerm">
|
||||||
<div class="tag-wrap">
|
<div class="tag-option-row d-flex align-items-center">
|
||||||
@if (item.id && tags) {
|
@if (item.id && tags) {
|
||||||
<pngx-tag class="me-2" [tag]="getTag(item.id)"></pngx-tag>
|
@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>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
|
@@ -20,3 +20,33 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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,4 +177,59 @@ describe('TagsComponent', () => {
|
|||||||
component.onFilterDocuments()
|
component.onFilterDocuments()
|
||||||
expect(emitSpy).toHaveBeenCalledWith([tags[2]])
|
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,6 +100,9 @@ export class TagsComponent implements OnInit, ControlValueAccessor {
|
|||||||
@Input()
|
@Input()
|
||||||
horizontal: boolean = false
|
horizontal: boolean = false
|
||||||
|
|
||||||
|
@Input()
|
||||||
|
multiple: boolean = true
|
||||||
|
|
||||||
@Output()
|
@Output()
|
||||||
filterDocuments = new EventEmitter<Tag[]>()
|
filterDocuments = new EventEmitter<Tag[]>()
|
||||||
|
|
||||||
@@ -124,13 +127,40 @@ export class TagsComponent implements OnInit, ControlValueAccessor {
|
|||||||
|
|
||||||
let index = this.value.indexOf(tagID)
|
let index = this.value.indexOf(tagID)
|
||||||
if (index > -1) {
|
if (index > -1) {
|
||||||
|
const tag = this.getTag(tagID)
|
||||||
|
|
||||||
|
// remove tag
|
||||||
let oldValue = this.value
|
let oldValue = this.value
|
||||||
oldValue.splice(index, 1)
|
oldValue.splice(index, 1)
|
||||||
|
|
||||||
|
// remove children
|
||||||
|
oldValue = this.removeChildren(oldValue, tag)
|
||||||
|
|
||||||
this.value = [...oldValue]
|
this.value = [...oldValue]
|
||||||
this.onChange(this.value)
|
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) {
|
createTag(name: string = null, add: boolean = false) {
|
||||||
var modal = this.modalService.open(TagEditDialogComponent, {
|
var modal = this.modalService.open(TagEditDialogComponent, {
|
||||||
backdrop: 'static',
|
backdrop: 'static',
|
||||||
@@ -166,6 +196,7 @@ export class TagsComponent implements OnInit, ControlValueAccessor {
|
|||||||
|
|
||||||
addTag(id) {
|
addTag(id) {
|
||||||
this.value = [...this.value, id]
|
this.value = [...this.value, id]
|
||||||
|
this.onAdd(this.getTag(id))
|
||||||
this.onChange(this.value)
|
this.onChange(this.value)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -180,4 +211,20 @@ export class TagsComponent implements OnInit, ControlValueAccessor {
|
|||||||
this.tags.filter((t) => this.value.includes(t.id))
|
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,6 +4,7 @@ import {
|
|||||||
NG_VALUE_ACCESSOR,
|
NG_VALUE_ACCESSOR,
|
||||||
ReactiveFormsModule,
|
ReactiveFormsModule,
|
||||||
} from '@angular/forms'
|
} from '@angular/forms'
|
||||||
|
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||||
import { SafeHtmlPipe } from 'src/app/pipes/safehtml.pipe'
|
import { SafeHtmlPipe } from 'src/app/pipes/safehtml.pipe'
|
||||||
import { AbstractInputComponent } from '../abstract-input'
|
import { AbstractInputComponent } from '../abstract-input'
|
||||||
|
|
||||||
@@ -18,7 +19,12 @@ import { AbstractInputComponent } from '../abstract-input'
|
|||||||
selector: 'pngx-input-textarea',
|
selector: 'pngx-input-textarea',
|
||||||
templateUrl: './textarea.component.html',
|
templateUrl: './textarea.component.html',
|
||||||
styleUrls: ['./textarea.component.scss'],
|
styleUrls: ['./textarea.component.scss'],
|
||||||
imports: [FormsModule, ReactiveFormsModule, SafeHtmlPipe],
|
imports: [
|
||||||
|
FormsModule,
|
||||||
|
ReactiveFormsModule,
|
||||||
|
SafeHtmlPipe,
|
||||||
|
NgxBootstrapIconsModule,
|
||||||
|
],
|
||||||
})
|
})
|
||||||
export class TextAreaComponent extends AbstractInputComponent<string> {
|
export class TextAreaComponent extends AbstractInputComponent<string> {
|
||||||
@Input()
|
@Input()
|
||||||
|
@@ -30,7 +30,7 @@
|
|||||||
<div class="page-item rounded p-2" cdkDrag (click)="toggleSelection(i)" [class.selected]="p.selected">
|
<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-toolbar hover-actions z-10">
|
||||||
<div class="btn-group me-2">
|
<div class="btn-group me-2">
|
||||||
<button class="btn btn-sm btn-dark" (click)="rotate(i); $event.stopPropagation()" title="Rotate page counter-clockwise" i18n-title>
|
<button class="btn btn-sm btn-dark" (click)="rotate(i, true); $event.stopPropagation()" title="Rotate page counter-clockwise" i18n-title>
|
||||||
<i-bs name="arrow-counterclockwise"></i-bs>
|
<i-bs name="arrow-counterclockwise"></i-bs>
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-sm btn-dark" (click)="rotate(i); $event.stopPropagation()" title="Rotate page clockwise" i18n-title>
|
<button class="btn btn-sm btn-dark" (click)="rotate(i); $event.stopPropagation()" title="Rotate page clockwise" i18n-title>
|
||||||
|
@@ -67,8 +67,9 @@ export class PDFEditorComponent extends ConfirmDialogComponent {
|
|||||||
this.pages[i].selected = !this.pages[i].selected
|
this.pages[i].selected = !this.pages[i].selected
|
||||||
}
|
}
|
||||||
|
|
||||||
rotate(i: number) {
|
rotate(i: number, counterclockwise: boolean = false) {
|
||||||
this.pages[i].rotate = (this.pages[i].rotate + 90) % 360
|
this.pages[i].rotate =
|
||||||
|
(this.pages[i].rotate + (counterclockwise ? -90 : 90) + 360) % 360
|
||||||
}
|
}
|
||||||
|
|
||||||
rotateSelected(dir: number) {
|
rotateSelected(dir: number) {
|
||||||
|
@@ -1,4 +1,8 @@
|
|||||||
@if (tag) {
|
@if (tag) {
|
||||||
|
@if (showParents && tag.parent) {
|
||||||
|
<pngx-tag [tagID]="tag.parent" [clickable]="clickable" [linkTitle]="linkTitle"></pngx-tag>
|
||||||
|
>
|
||||||
|
}
|
||||||
@if (!clickable) {
|
@if (!clickable) {
|
||||||
<span class="badge" [style.background]="tag.color" [style.color]="tag.text_color">{{tag.name}}</span>
|
<span class="badge" [style.background]="tag.color" [style.color]="tag.text_color">{{tag.name}}</span>
|
||||||
}
|
}
|
||||||
|
@@ -50,4 +50,7 @@ export class TagComponent {
|
|||||||
|
|
||||||
@Input()
|
@Input()
|
||||||
clickable: boolean = false
|
clickable: boolean = false
|
||||||
|
|
||||||
|
@Input()
|
||||||
|
showParents: boolean = false
|
||||||
}
|
}
|
||||||
|
@@ -17,7 +17,7 @@
|
|||||||
<i-bs width="0.9em" height="0.9em" name="exclamation-triangle"></i-bs>
|
<i-bs width="0.9em" height="0.9em" name="exclamation-triangle"></i-bs>
|
||||||
}
|
}
|
||||||
<div>
|
<div>
|
||||||
<p class="ms-2 mb-0">{{toast.content}}</p>
|
<p class="ms-2 mb-0 text-break">{{toast.content}}</p>
|
||||||
@if (toast.error) {
|
@if (toast.error) {
|
||||||
<details class="ms-2">
|
<details class="ms-2">
|
||||||
<div class="mt-2 ms-n4 me-n2 small">
|
<div class="mt-2 ms-n4 me-n2 small">
|
||||||
|
@@ -54,6 +54,10 @@
|
|||||||
<i-bs width="1em" height="1em" name="arrow-counterclockwise"></i-bs> <span i18n>Reprocess</span>
|
<i-bs width="1em" height="1em" name="arrow-counterclockwise"></i-bs> <span i18n>Reprocess</span>
|
||||||
</button>
|
</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()">
|
<button ngbDropdownItem (click)="moreLike()">
|
||||||
<i-bs width="1em" height="1em" name="diagram-3"></i-bs> <span i18n>More like this</span>
|
<i-bs width="1em" height="1em" name="diagram-3"></i-bs> <span i18n>More like this</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -212,6 +216,14 @@
|
|||||||
(removed)="removeField(fieldInstance)"
|
(removed)="removeField(fieldInstance)"
|
||||||
[error]="getCustomFieldError(i)"></pngx-input-select>
|
[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>
|
</div>
|
||||||
}
|
}
|
||||||
|
@@ -1212,7 +1212,7 @@ describe('DocumentDetailComponent', () => {
|
|||||||
it('should support keyboard shortcuts', () => {
|
it('should support keyboard shortcuts', () => {
|
||||||
initNormally()
|
initNormally()
|
||||||
|
|
||||||
jest.spyOn(component, 'hasNext').mockReturnValue(true)
|
const hasNextSpy = jest.spyOn(component, 'hasNext').mockReturnValue(true)
|
||||||
const nextSpy = jest.spyOn(component, 'nextDoc')
|
const nextSpy = jest.spyOn(component, 'nextDoc')
|
||||||
document.dispatchEvent(
|
document.dispatchEvent(
|
||||||
new KeyboardEvent('keydown', { key: 'arrowright', ctrlKey: true })
|
new KeyboardEvent('keydown', { key: 'arrowright', ctrlKey: true })
|
||||||
@@ -1226,21 +1226,32 @@ describe('DocumentDetailComponent', () => {
|
|||||||
)
|
)
|
||||||
expect(prevSpy).toHaveBeenCalled()
|
expect(prevSpy).toHaveBeenCalled()
|
||||||
|
|
||||||
jest.spyOn(openDocumentsService, 'isDirty').mockReturnValue(true)
|
const isDirtySpy = jest
|
||||||
|
.spyOn(openDocumentsService, 'isDirty')
|
||||||
|
.mockReturnValue(true)
|
||||||
const saveSpy = jest.spyOn(component, 'save')
|
const saveSpy = jest.spyOn(component, 'save')
|
||||||
document.dispatchEvent(
|
document.dispatchEvent(
|
||||||
new KeyboardEvent('keydown', { key: 's', ctrlKey: true })
|
new KeyboardEvent('keydown', { key: 's', ctrlKey: true })
|
||||||
)
|
)
|
||||||
expect(saveSpy).toHaveBeenCalled()
|
expect(saveSpy).toHaveBeenCalled()
|
||||||
|
|
||||||
jest.spyOn(openDocumentsService, 'isDirty').mockReturnValue(true)
|
hasNextSpy.mockReturnValue(true)
|
||||||
jest.spyOn(component, 'hasNext').mockReturnValue(true)
|
|
||||||
const saveNextSpy = jest.spyOn(component, 'saveEditNext')
|
const saveNextSpy = jest.spyOn(component, 'saveEditNext')
|
||||||
document.dispatchEvent(
|
document.dispatchEvent(
|
||||||
new KeyboardEvent('keydown', { key: 's', ctrlKey: true, shiftKey: true })
|
new KeyboardEvent('keydown', { key: 's', ctrlKey: true, shiftKey: true })
|
||||||
)
|
)
|
||||||
expect(saveNextSpy).toHaveBeenCalled()
|
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')
|
const closeSpy = jest.spyOn(component, 'close')
|
||||||
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'escape' }))
|
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'escape' }))
|
||||||
expect(closeSpy).toHaveBeenCalled()
|
expect(closeSpy).toHaveBeenCalled()
|
||||||
@@ -1415,4 +1426,151 @@ describe('DocumentDetailComponent', () => {
|
|||||||
.flush('fail', { status: 500, statusText: 'Server Error' })
|
.flush('fail', { status: 500, statusText: 'Server Error' })
|
||||||
expect(component.previewText).toContain('An error occurred loading content')
|
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,6 +98,7 @@ import { PermissionsFormComponent } from '../common/input/permissions/permission
|
|||||||
import { SelectComponent } from '../common/input/select/select.component'
|
import { SelectComponent } from '../common/input/select/select.component'
|
||||||
import { TagsComponent } from '../common/input/tags/tags.component'
|
import { TagsComponent } from '../common/input/tags/tags.component'
|
||||||
import { TextComponent } from '../common/input/text/text.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 { UrlComponent } from '../common/input/url/url.component'
|
||||||
import { PageHeaderComponent } from '../common/page-header/page-header.component'
|
import { PageHeaderComponent } from '../common/page-header/page-header.component'
|
||||||
import {
|
import {
|
||||||
@@ -173,6 +174,7 @@ export enum ZoomSetting {
|
|||||||
NgbDropdownModule,
|
NgbDropdownModule,
|
||||||
NgxBootstrapIconsModule,
|
NgxBootstrapIconsModule,
|
||||||
PdfViewerModule,
|
PdfViewerModule,
|
||||||
|
TextAreaComponent,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class DocumentDetailComponent
|
export class DocumentDetailComponent
|
||||||
@@ -291,6 +293,10 @@ export class DocumentDetailComponent
|
|||||||
return this.settings.get(SETTINGS_KEYS.USE_NATIVE_PDF_VIEWER)
|
return this.settings.get(SETTINGS_KEYS.USE_NATIVE_PDF_VIEWER)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get isMobile(): boolean {
|
||||||
|
return this.deviceDetectorService.isMobile()
|
||||||
|
}
|
||||||
|
|
||||||
get archiveContentRenderType(): ContentRenderType {
|
get archiveContentRenderType(): ContentRenderType {
|
||||||
return this.document?.archived_file_name
|
return this.document?.archived_file_name
|
||||||
? this.getRenderType('application/pdf')
|
? this.getRenderType('application/pdf')
|
||||||
@@ -609,7 +615,10 @@ export class DocumentDetailComponent
|
|||||||
})
|
})
|
||||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||||
.subscribe(() => {
|
.subscribe(() => {
|
||||||
if (this.openDocumentService.isDirty(this.document)) this.saveEditNext()
|
if (this.openDocumentService.isDirty(this.document)) {
|
||||||
|
if (this.hasNext()) this.saveEditNext()
|
||||||
|
else this.save(true)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1419,6 +1428,44 @@ 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() {
|
public openShareLinks() {
|
||||||
const modal = this.modalService.open(ShareLinksDialogComponent)
|
const modal = this.modalService.open(ShareLinksDialogComponent)
|
||||||
modal.componentInstance.documentId = this.document.id
|
modal.componentInstance.documentId = this.document.id
|
||||||
@@ -1434,7 +1481,7 @@ export class DocumentDetailComponent
|
|||||||
const modal = this.modalService.open(EmailDocumentDialogComponent, {
|
const modal = this.modalService.open(EmailDocumentDialogComponent, {
|
||||||
backdrop: 'static',
|
backdrop: 'static',
|
||||||
})
|
})
|
||||||
modal.componentInstance.documentId = this.document.id
|
modal.componentInstance.documentIds = [this.document.id]
|
||||||
modal.componentInstance.hasArchiveVersion =
|
modal.componentInstance.hasArchiveVersion =
|
||||||
!!this.document?.archived_file_name
|
!!this.document?.archived_file_name
|
||||||
}
|
}
|
||||||
|
@@ -1,161 +1,147 @@
|
|||||||
<div class="d-flex flex-wrap gap-4">
|
<div class="d-flex flex-wrap gap-4">
|
||||||
<div class="d-flex align-items-center" role="group" aria-label="Select">
|
<div class="d-flex flex-wrap align-items-center gap-2" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.Document }">
|
||||||
<button class="btn btn-sm btn-outline-secondary" (click)="list.selectNone()">
|
<label class="me-2" i18n>Edit:</label>
|
||||||
<i-bs name="slash-circle"></i-bs> <ng-container i18n>Cancel</ng-container>
|
@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>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="d-flex align-items-center gap-2" role="group" aria-label="Select">
|
</div>
|
||||||
<label class="me-2" i18n>Select:</label>
|
<div class="d-flex align-items-center gap-2 ms-auto">
|
||||||
<div class="btn-group">
|
<div class="btn-toolbar">
|
||||||
<button class="btn btn-sm btn-outline-primary" (click)="list.selectPage()">
|
<div ngbDropdown>
|
||||||
<i-bs name="file-earmark-check"></i-bs> <ng-container i18n>Page</ng-container>
|
<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>
|
||||||
|
<button ngbDropdownItem (click)="emailSelected()" [disabled]="!userCanEdit">
|
||||||
|
<i-bs name="envelope"></i-bs> <ng-container i18n>Email</ng-container>
|
||||||
</button>
|
</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>
|
||||||
<div class="d-flex align-items-center gap-2" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.Document }">
|
</div>
|
||||||
<label class="me-2" i18n>Edit:</label>
|
</div>
|
||||||
@if (permissionService.currentUserCan(PermissionAction.View, PermissionType.Tag)) {
|
<div class="btn-group btn-group-sm">
|
||||||
<pngx-filterable-dropdown title="Tags" icon="tag-fill" i18n-title
|
<button class="btn btn-sm btn-outline-primary" [disabled]="awaitingDownload" (click)="downloadSelected()">
|
||||||
filterPlaceholder="Filter tags" i18n-filterPlaceholder
|
@if (!awaitingDownload) {
|
||||||
[disabled]="!userCanEditAll || disabled"
|
<i-bs name="arrow-down"></i-bs>
|
||||||
[editing]="true"
|
}
|
||||||
[applyOnClose]="applyOnClose"
|
@if (awaitingDownload) {
|
||||||
[createRef]="createTag.bind(this)"
|
<div class="spinner-border spinner-border-sm" role="status">
|
||||||
(opened)="openTagsDropdown()"
|
<span class="visually-hidden">Preparing download...</span>
|
||||||
[(selectionModel)]="tagSelectionModel"
|
</div>
|
||||||
[documentCounts]="tagDocumentCounts"
|
}
|
||||||
(apply)="setTags($event)"
|
<div class="d-none d-sm-inline"> <ng-container i18n>Download</ng-container></div>
|
||||||
shortcutKey="t">
|
</button>
|
||||||
</pngx-filterable-dropdown>
|
<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>
|
||||||
@if (permissionService.currentUserCan(PermissionAction.View, PermissionType.Correspondent)) {
|
<div ngbDropdownMenu aria-labelledby="dropdownSelect" class="shadow">
|
||||||
<pngx-filterable-dropdown title="Correspondent" icon="person-fill" i18n-title
|
<form [formGroup]="downloadForm" class="px-3 py-1">
|
||||||
filterPlaceholder="Filter correspondents" i18n-filterPlaceholder
|
<p class="mb-1" i18n>Include:</p>
|
||||||
[disabled]="!userCanEditAll || disabled"
|
<div class="form-group ps-3 mb-2">
|
||||||
[editing]="true"
|
<div class="form-check">
|
||||||
[applyOnClose]="applyOnClose"
|
<input type="checkbox" class="form-check-input" id="downloadFileType_archive" formControlName="downloadFileTypeArchive" />
|
||||||
[createRef]="createCorrespondent.bind(this)"
|
<label class="form-check-label" for="downloadFileType_archive" i18n>Archived files</label>
|
||||||
(opened)="openCorrespondentDropdown()"
|
</div>
|
||||||
[(selectionModel)]="correspondentSelectionModel"
|
<div class="form-check">
|
||||||
[documentCounts]="correspondentDocumentCounts"
|
<input type="checkbox" class="form-check-input" id="downloadFileType_originals" formControlName="downloadFileTypeOriginals" />
|
||||||
(apply)="setCorrespondents($event)"
|
<label class="form-check-label" for="downloadFileType_originals" i18n>Original files</label>
|
||||||
shortcutKey="y">
|
</div>
|
||||||
</pngx-filterable-dropdown>
|
</div>
|
||||||
}
|
<div class="form-check">
|
||||||
@if (permissionService.currentUserCan(PermissionAction.View, PermissionType.DocumentType)) {
|
<input type="checkbox" class="form-check-input" id="downloadUseFormatting" formControlName="downloadUseFormatting" />
|
||||||
<pngx-filterable-dropdown title="Document type" icon="file-earmark-fill" i18n-title
|
<label class="form-check-label" for="downloadUseFormatting" i18n>Use formatted filename</label>
|
||||||
filterPlaceholder="Filter document types" i18n-filterPlaceholder
|
</div>
|
||||||
[disabled]="!userCanEditAll || disabled"
|
</form>
|
||||||
[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>
|
||||||
<div class="d-flex align-items-center gap-2 ms-auto">
|
</div>
|
||||||
<div class="btn-toolbar">
|
</div>
|
||||||
|
|
||||||
<button type="button" class="btn btn-sm btn-outline-primary me-2" (click)="setPermissions()" [disabled]="!userOwnsAll || !userCanEditAll">
|
<div class="btn-group btn-group-sm">
|
||||||
<i-bs name="person-fill-lock"></i-bs><div class="d-none d-sm-inline"> <ng-container i18n>Permissions</ng-container></div>
|
<button type="button" class="btn btn-sm btn-outline-danger" (click)="applyDelete()" *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.Document }" [disabled]="!userOwnsAll">
|
||||||
</button>
|
<i-bs name="trash"></i-bs> <ng-container i18n>Delete</ng-container>
|
||||||
|
</button>
|
||||||
<div ngbDropdown>
|
</div>
|
||||||
<button class="btn btn-sm btn-outline-primary" id="dropdownSelect" [disabled]="!userCanEdit && !userCanAdd" ngbDropdownToggle>
|
</div>
|
||||||
<i-bs name="three-dots"></i-bs>
|
</div>
|
||||||
<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,3 +5,7 @@
|
|||||||
.dropdown-menu{
|
.dropdown-menu{
|
||||||
--bs-dropdown-min-width: 12rem;
|
--bs-dropdown-min-width: 12rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.btn-group .btn {
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
@@ -1204,7 +1204,7 @@ describe('BulkEditorComponent', () => {
|
|||||||
expect(tagListAllSpy).toHaveBeenCalled()
|
expect(tagListAllSpy).toHaveBeenCalled()
|
||||||
|
|
||||||
expect(tagSelectionModelToggleSpy).toHaveBeenCalledWith(newTag.id)
|
expect(tagSelectionModelToggleSpy).toHaveBeenCalledWith(newTag.id)
|
||||||
expect(component.tagSelectionModel.items).toEqual(
|
expect(component.tagSelectionModel.items).toMatchObject(
|
||||||
[{ id: null, name: 'Not assigned' }].concat(tags.results as any)
|
[{ id: null, name: 'Not assigned' }].concat(tags.results as any)
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
@@ -37,6 +37,7 @@ import { StoragePathService } from 'src/app/services/rest/storage-path.service'
|
|||||||
import { TagService } from 'src/app/services/rest/tag.service'
|
import { TagService } from 'src/app/services/rest/tag.service'
|
||||||
import { SettingsService } from 'src/app/services/settings.service'
|
import { SettingsService } from 'src/app/services/settings.service'
|
||||||
import { ToastService } from 'src/app/services/toast.service'
|
import { ToastService } from 'src/app/services/toast.service'
|
||||||
|
import { flattenTags } from 'src/app/utils/flatten-tags'
|
||||||
import { MergeConfirmDialogComponent } from '../../common/confirm-dialog/merge-confirm-dialog/merge-confirm-dialog.component'
|
import { MergeConfirmDialogComponent } from '../../common/confirm-dialog/merge-confirm-dialog/merge-confirm-dialog.component'
|
||||||
import { RotateConfirmDialogComponent } from '../../common/confirm-dialog/rotate-confirm-dialog/rotate-confirm-dialog.component'
|
import { RotateConfirmDialogComponent } from '../../common/confirm-dialog/rotate-confirm-dialog/rotate-confirm-dialog.component'
|
||||||
import { CorrespondentEditDialogComponent } from '../../common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component'
|
import { CorrespondentEditDialogComponent } from '../../common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component'
|
||||||
@@ -45,6 +46,7 @@ import { DocumentTypeEditDialogComponent } from '../../common/edit-dialog/docume
|
|||||||
import { EditDialogMode } from '../../common/edit-dialog/edit-dialog.component'
|
import { EditDialogMode } from '../../common/edit-dialog/edit-dialog.component'
|
||||||
import { StoragePathEditDialogComponent } from '../../common/edit-dialog/storage-path-edit-dialog/storage-path-edit-dialog.component'
|
import { StoragePathEditDialogComponent } from '../../common/edit-dialog/storage-path-edit-dialog/storage-path-edit-dialog.component'
|
||||||
import { TagEditDialogComponent } from '../../common/edit-dialog/tag-edit-dialog/tag-edit-dialog.component'
|
import { TagEditDialogComponent } from '../../common/edit-dialog/tag-edit-dialog/tag-edit-dialog.component'
|
||||||
|
import { EmailDocumentDialogComponent } from '../../common/email-document-dialog/email-document-dialog.component'
|
||||||
import {
|
import {
|
||||||
ChangedItems,
|
ChangedItems,
|
||||||
FilterableDropdownComponent,
|
FilterableDropdownComponent,
|
||||||
@@ -164,7 +166,10 @@ export class BulkEditorComponent
|
|||||||
this.tagService
|
this.tagService
|
||||||
.listAll()
|
.listAll()
|
||||||
.pipe(first())
|
.pipe(first())
|
||||||
.subscribe((result) => (this.tagSelectionModel.items = result.results))
|
.subscribe(
|
||||||
|
(result) =>
|
||||||
|
(this.tagSelectionModel.items = flattenTags(result.results))
|
||||||
|
)
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
this.permissionService.currentUserCan(
|
this.permissionService.currentUserCan(
|
||||||
@@ -648,7 +653,7 @@ export class BulkEditorComponent
|
|||||||
)
|
)
|
||||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||||
.subscribe(({ newTag, tags }) => {
|
.subscribe(({ newTag, tags }) => {
|
||||||
this.tagSelectionModel.items = tags.results
|
this.tagSelectionModel.items = flattenTags(tags.results)
|
||||||
this.tagSelectionModel.toggle(newTag.id)
|
this.tagSelectionModel.toggle(newTag.id)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -898,4 +903,16 @@ export class BulkEditorComponent
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
emailSelected() {
|
||||||
|
const allHaveArchiveVersion = this.list.documents
|
||||||
|
.filter((d) => this.list.selected.has(d.id))
|
||||||
|
.every((doc) => !!doc.archived_file_name)
|
||||||
|
|
||||||
|
const modal = this.modalService.open(EmailDocumentDialogComponent, {
|
||||||
|
backdrop: 'static',
|
||||||
|
})
|
||||||
|
modal.componentInstance.documentIds = Array.from(this.list.selected)
|
||||||
|
modal.componentInstance.hasArchiveVersion = allHaveArchiveVersion
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@@ -56,6 +56,10 @@
|
|||||||
[items]="field.extra_data.select_options" bindLabel="label" [allowNull]="true" [horizontal]="true">
|
[items]="field.extra_data.select_options" bindLabel="label" [allowNull]="true" [horizontal]="true">
|
||||||
</pngx-input-select>
|
</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)">
|
<button type="button" class="btn btn-outline-danger mb-3" (click)="removeField(field.id)">
|
||||||
<i-bs name="x"></i-bs>
|
<i-bs name="x"></i-bs>
|
||||||
|
@@ -18,6 +18,7 @@ import { TextComponent } from 'src/app/components/common/input/text/text.compone
|
|||||||
import { UrlComponent } from 'src/app/components/common/input/url/url.component'
|
import { UrlComponent } from 'src/app/components/common/input/url/url.component'
|
||||||
import { CustomField, CustomFieldDataType } from 'src/app/data/custom-field'
|
import { CustomField, CustomFieldDataType } from 'src/app/data/custom-field'
|
||||||
import { DocumentService } from 'src/app/services/rest/document.service'
|
import { DocumentService } from 'src/app/services/rest/document.service'
|
||||||
|
import { TextAreaComponent } from '../../../common/input/textarea/textarea.component'
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'pngx-custom-fields-bulk-edit-dialog',
|
selector: 'pngx-custom-fields-bulk-edit-dialog',
|
||||||
@@ -35,6 +36,7 @@ import { DocumentService } from 'src/app/services/rest/document.service'
|
|||||||
FormsModule,
|
FormsModule,
|
||||||
ReactiveFormsModule,
|
ReactiveFormsModule,
|
||||||
NgxBootstrapIconsModule,
|
NgxBootstrapIconsModule,
|
||||||
|
TextAreaComponent,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class CustomFieldsBulkEditDialogComponent {
|
export class CustomFieldsBulkEditDialogComponent {
|
||||||
|
@@ -1,16 +1,36 @@
|
|||||||
<pngx-page-header [title]="getTitle()">
|
<pngx-page-header [title]="getTitle()">
|
||||||
|
<div ngbDropdown class="btn-group flex-fill d-sm-none">
|
||||||
<div ngbDropdown class="btn-group flex-fill">
|
<button class="btn btn-sm btn-outline-primary" id="dropdownSelectMobile" ngbDropdownToggle>
|
||||||
<button class="btn btn-sm btn-outline-primary" id="dropdownSelect" ngbDropdownToggle>
|
|
||||||
<i-bs name="text-indent-left"></i-bs>
|
<i-bs name="text-indent-left"></i-bs>
|
||||||
<div class="d-none d-sm-inline"> <ng-container i18n>Select</ng-container></div>
|
<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>
|
</button>
|
||||||
<div ngbDropdownMenu aria-labelledby="dropdownSelect" class="shadow">
|
<div ngbDropdownMenu aria-labelledby="dropdownSelectMobile" class="shadow">
|
||||||
<button ngbDropdownItem (click)="list.selectNone()" i18n>Select none</button>
|
<button ngbDropdownItem (click)="list.selectNone()" i18n>Select none</button>
|
||||||
<button ngbDropdownItem (click)="list.selectPage()" i18n>Select page</button>
|
<button ngbDropdownItem (click)="list.selectPage()" i18n>Select page</button>
|
||||||
<button ngbDropdownItem (click)="list.selectAll()" i18n>Select all</button>
|
<button ngbDropdownItem (click)="list.selectAll()" i18n>Select all</button>
|
||||||
</div>
|
</div>
|
||||||
</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">
|
<div ngbDropdown class="btn-group flex-fill">
|
||||||
<button class="btn btn-sm btn-outline-primary" id="dropdownDisplayFields" ngbDropdownToggle>
|
<button class="btn btn-sm btn-outline-primary" id="dropdownDisplayFields" ngbDropdownToggle>
|
||||||
<i-bs name="card-heading"></i-bs>
|
<i-bs name="card-heading"></i-bs>
|
||||||
@@ -126,8 +146,13 @@
|
|||||||
@if (!list.isReloading && isFiltered) {
|
@if (!list.isReloading && isFiltered) {
|
||||||
<button class="btn btn-link py-0" (click)="resetFilters()">
|
<button class="btn btn-link py-0" (click)="resetFilters()">
|
||||||
<i-bs width="1em" height="1em" name="x"></i-bs><small i18n>Reset filters</small>
|
<i-bs width="1em" height="1em" name="x"></i-bs><small i18n>Reset filters</small>
|
||||||
</button>
|
</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>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
@if (list.collectionSize) {
|
@if (list.collectionSize) {
|
||||||
<ngb-pagination [pageSize]="list.pageSize" [collectionSize]="list.collectionSize" [(page)]="list.currentPage" [maxSize]="5"
|
<ngb-pagination [pageSize]="list.pageSize" [collectionSize]="list.collectionSize" [(page)]="list.currentPage" [maxSize]="5"
|
||||||
|
@@ -56,6 +56,7 @@ import {
|
|||||||
filterRulesDiffer,
|
filterRulesDiffer,
|
||||||
isFullTextFilterRule,
|
isFullTextFilterRule,
|
||||||
} from 'src/app/utils/filter-rules'
|
} 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 { CustomFieldDisplayComponent } from '../common/custom-field-display/custom-field-display.component'
|
||||||
import { PageHeaderComponent } from '../common/page-header/page-header.component'
|
import { PageHeaderComponent } from '../common/page-header/page-header.component'
|
||||||
import { PreviewPopupComponent } from '../common/preview-popup/preview-popup.component'
|
import { PreviewPopupComponent } from '../common/preview-popup/preview-popup.component'
|
||||||
@@ -72,6 +73,7 @@ import { SaveViewConfigDialogComponent } from './save-view-config-dialog/save-vi
|
|||||||
templateUrl: './document-list.component.html',
|
templateUrl: './document-list.component.html',
|
||||||
styleUrls: ['./document-list.component.scss'],
|
styleUrls: ['./document-list.component.scss'],
|
||||||
imports: [
|
imports: [
|
||||||
|
ClearableBadgeComponent,
|
||||||
CustomFieldDisplayComponent,
|
CustomFieldDisplayComponent,
|
||||||
PageHeaderComponent,
|
PageHeaderComponent,
|
||||||
BulkEditorComponent,
|
BulkEditorComponent,
|
||||||
|
@@ -589,7 +589,7 @@ describe('FilterEditorComponent', () => {
|
|||||||
expect(component.tagSelectionModel.logicalOperator).toEqual(
|
expect(component.tagSelectionModel.logicalOperator).toEqual(
|
||||||
LogicalOperator.And
|
LogicalOperator.And
|
||||||
)
|
)
|
||||||
expect(component.tagSelectionModel.getSelectedItems()).toEqual(tags)
|
expect(component.tagSelectionModel.getSelectedItems()).toMatchObject(tags)
|
||||||
// coverage
|
// coverage
|
||||||
component.filterRules = [
|
component.filterRules = [
|
||||||
{
|
{
|
||||||
@@ -615,7 +615,7 @@ describe('FilterEditorComponent', () => {
|
|||||||
expect(component.tagSelectionModel.logicalOperator).toEqual(
|
expect(component.tagSelectionModel.logicalOperator).toEqual(
|
||||||
LogicalOperator.Or
|
LogicalOperator.Or
|
||||||
)
|
)
|
||||||
expect(component.tagSelectionModel.getSelectedItems()).toEqual(tags)
|
expect(component.tagSelectionModel.getSelectedItems()).toMatchObject(tags)
|
||||||
// coverage
|
// coverage
|
||||||
component.filterRules = [
|
component.filterRules = [
|
||||||
{
|
{
|
||||||
@@ -652,7 +652,7 @@ describe('FilterEditorComponent', () => {
|
|||||||
expect(component.tagSelectionModel.logicalOperator).toEqual(
|
expect(component.tagSelectionModel.logicalOperator).toEqual(
|
||||||
LogicalOperator.And
|
LogicalOperator.And
|
||||||
)
|
)
|
||||||
expect(component.tagSelectionModel.getExcludedItems()).toEqual(tags)
|
expect(component.tagSelectionModel.getExcludedItems()).toMatchObject(tags)
|
||||||
// coverage
|
// coverage
|
||||||
component.filterRules = [
|
component.filterRules = [
|
||||||
{
|
{
|
||||||
|
@@ -97,6 +97,7 @@ import {
|
|||||||
CustomFieldQueryExpression,
|
CustomFieldQueryExpression,
|
||||||
} from 'src/app/utils/custom-field-query-element'
|
} from 'src/app/utils/custom-field-query-element'
|
||||||
import { filterRulesDiffer } from 'src/app/utils/filter-rules'
|
import { filterRulesDiffer } from 'src/app/utils/filter-rules'
|
||||||
|
import { flattenTags } from 'src/app/utils/flatten-tags'
|
||||||
import {
|
import {
|
||||||
CustomFieldQueriesModel,
|
CustomFieldQueriesModel,
|
||||||
CustomFieldsQueryDropdownComponent,
|
CustomFieldsQueryDropdownComponent,
|
||||||
@@ -1134,7 +1135,7 @@ export class FilterEditorComponent
|
|||||||
) {
|
) {
|
||||||
this.loadingCountTotal++
|
this.loadingCountTotal++
|
||||||
this.tagService.listAll().subscribe((result) => {
|
this.tagService.listAll().subscribe((result) => {
|
||||||
this.tagSelectionModel.items = result.results
|
this.tagSelectionModel.items = flattenTags(result.results)
|
||||||
this.maybeCompleteLoading()
|
this.maybeCompleteLoading()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
@@ -1,4 +1,4 @@
|
|||||||
import { NgClass, TitleCasePipe } from '@angular/common'
|
import { NgClass, NgTemplateOutlet, TitleCasePipe } from '@angular/common'
|
||||||
import { Component, inject } from '@angular/core'
|
import { Component, inject } from '@angular/core'
|
||||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||||
import {
|
import {
|
||||||
@@ -30,6 +30,7 @@ import { ManagementListComponent } from '../management-list/management-list.comp
|
|||||||
FormsModule,
|
FormsModule,
|
||||||
ReactiveFormsModule,
|
ReactiveFormsModule,
|
||||||
NgClass,
|
NgClass,
|
||||||
|
NgTemplateOutlet,
|
||||||
NgbDropdownModule,
|
NgbDropdownModule,
|
||||||
NgbPaginationModule,
|
NgbPaginationModule,
|
||||||
NgxBootstrapIconsModule,
|
NgxBootstrapIconsModule,
|
||||||
|
@@ -1,4 +1,4 @@
|
|||||||
import { NgClass, TitleCasePipe } from '@angular/common'
|
import { NgClass, NgTemplateOutlet, TitleCasePipe } from '@angular/common'
|
||||||
import { Component, inject } from '@angular/core'
|
import { Component, inject } from '@angular/core'
|
||||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||||
import {
|
import {
|
||||||
@@ -28,6 +28,7 @@ import { ManagementListComponent } from '../management-list/management-list.comp
|
|||||||
FormsModule,
|
FormsModule,
|
||||||
ReactiveFormsModule,
|
ReactiveFormsModule,
|
||||||
NgClass,
|
NgClass,
|
||||||
|
NgTemplateOutlet,
|
||||||
NgbDropdownModule,
|
NgbDropdownModule,
|
||||||
NgbPaginationModule,
|
NgbPaginationModule,
|
||||||
NgxBootstrapIconsModule,
|
NgxBootstrapIconsModule,
|
||||||
|
@@ -3,7 +3,7 @@
|
|||||||
i18n-title
|
i18n-title
|
||||||
info="Manage e-mail accounts and rules for automatically importing documents."
|
info="Manage e-mail accounts and rules for automatically importing documents."
|
||||||
i18n-info
|
i18n-info
|
||||||
infoLink="usage/#usage-email"
|
infoLink="usage/#incoming-mail"
|
||||||
>
|
>
|
||||||
</pngx-page-header>
|
</pngx-page-header>
|
||||||
|
|
||||||
@@ -109,10 +109,11 @@
|
|||||||
<li class="list-group-item">
|
<li class="list-group-item">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col" i18n>Name</div>
|
<div class="col" i18n>Name</div>
|
||||||
<div class="col d-none d-sm-block" i18n>Sort Order</div>
|
<div class="col-1 d-none d-sm-block" i18n>Sort Order</div>
|
||||||
<div class="col" i18n>Account</div>
|
<div class="col-2" i18n>Account</div>
|
||||||
<div class="col d-none d-sm-block" i18n>Status</div>
|
<div class="col-2 d-none d-sm-block" i18n>Status</div>
|
||||||
<div class="col" i18n>Actions</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>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
@@ -127,9 +128,9 @@
|
|||||||
<li class="list-group-item">
|
<li class="list-group-item">
|
||||||
<div class="row fade" [class.show]="showRules">
|
<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 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 d-flex align-items-center d-none d-sm-flex">{{rule.order}}</div>
|
<div class="col-1 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-2 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="col-2 d-flex align-items-center d-none d-sm-flex">
|
||||||
<div class="form-check form-switch mb-0">
|
<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 }">
|
<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'">
|
<label class="form-check-label cursor-pointer" [for]="rule.id+'_enable'">
|
||||||
@@ -137,7 +138,12 @@
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col">
|
<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="btn-group d-block d-sm-none">
|
<div class="btn-group d-block d-sm-none">
|
||||||
<div ngbDropdown container="body" class="d-inline-block">
|
<div ngbDropdown container="body" class="d-inline-block">
|
||||||
<button type="button" class="btn btn-link" id="actionsMenuMobile" (click)="$event.stopPropagation()" ngbDropdownToggle>
|
<button type="button" class="btn btn-link" id="actionsMenuMobile" (click)="$event.stopPropagation()" ngbDropdownToggle>
|
||||||
|
@@ -409,4 +409,13 @@ describe('MailComponent', () => {
|
|||||||
jest.advanceTimersByTime(200)
|
jest.advanceTimersByTime(200)
|
||||||
expect(editSpy).toHaveBeenCalled()
|
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,6 +27,7 @@ import { MailRuleEditDialogComponent } from '../../common/edit-dialog/mail-rule-
|
|||||||
import { PageHeaderComponent } from '../../common/page-header/page-header.component'
|
import { PageHeaderComponent } from '../../common/page-header/page-header.component'
|
||||||
import { PermissionsDialogComponent } from '../../common/permissions-dialog/permissions-dialog.component'
|
import { PermissionsDialogComponent } from '../../common/permissions-dialog/permissions-dialog.component'
|
||||||
import { ComponentWithPermissions } from '../../with-permissions/with-permissions.component'
|
import { ComponentWithPermissions } from '../../with-permissions/with-permissions.component'
|
||||||
|
import { ProcessedMailDialogComponent } from './processed-mail-dialog/processed-mail-dialog.component'
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'pngx-mail',
|
selector: 'pngx-mail',
|
||||||
@@ -347,6 +348,14 @@ export class MailComponent
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
viewProcessedMail(rule: MailRule) {
|
||||||
|
const modal = this.modalService.open(ProcessedMailDialogComponent, {
|
||||||
|
backdrop: 'static',
|
||||||
|
size: 'xl',
|
||||||
|
})
|
||||||
|
modal.componentInstance.rule = rule
|
||||||
|
}
|
||||||
|
|
||||||
userCanEdit(obj: ObjectWithPermissions): boolean {
|
userCanEdit(obj: ObjectWithPermissions): boolean {
|
||||||
return this.permissionsService.currentUserHasObjectPermissions(
|
return this.permissionsService.currentUserHasObjectPermissions(
|
||||||
PermissionAction.Change,
|
PermissionAction.Change,
|
||||||
|
@@ -0,0 +1,107 @@
|
|||||||
|
<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>
|
@@ -0,0 +1,8 @@
|
|||||||
|
::ng-deep .popover {
|
||||||
|
max-width: 350px;
|
||||||
|
|
||||||
|
pre {
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-break: break-word;
|
||||||
|
}
|
||||||
|
}
|
@@ -0,0 +1,150 @@
|
|||||||
|
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()
|
||||||
|
})
|
||||||
|
})
|
@@ -0,0 +1,96 @@
|
|||||||
|
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,61 +54,7 @@
|
|||||||
</tr>
|
</tr>
|
||||||
}
|
}
|
||||||
@for (object of data; track object) {
|
@for (object of data; track object) {
|
||||||
<tr (click)="toggleSelected(object); $event.stopPropagation();" class="data-row fade" [class.show]="show">
|
<ng-container [ngTemplateOutlet]="objectRow" [ngTemplateOutletContext]="{ object: object, depth: 0 }"></ng-container>
|
||||||
<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>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
@@ -129,3 +75,72 @@
|
|||||||
}
|
}
|
||||||
</div>
|
</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,3 +10,17 @@ tbody tr:last-child td {
|
|||||||
.form-check {
|
.form-check {
|
||||||
min-height: 0;
|
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,6 +79,7 @@ export abstract class ManagementListComponent<T extends MatchingModel>
|
|||||||
@ViewChildren(SortableDirective) headers: QueryList<SortableDirective>
|
@ViewChildren(SortableDirective) headers: QueryList<SortableDirective>
|
||||||
|
|
||||||
public data: T[] = []
|
public data: T[] = []
|
||||||
|
private unfilteredData: T[] = []
|
||||||
|
|
||||||
public page = 1
|
public page = 1
|
||||||
|
|
||||||
@@ -132,6 +133,18 @@ export abstract class ManagementListComponent<T extends MatchingModel>
|
|||||||
this.reloadData()
|
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) {
|
reloadData(extraParams: { [key: string]: any } = null) {
|
||||||
this.loading = true
|
this.loading = true
|
||||||
this.clearSelection()
|
this.clearSelection()
|
||||||
@@ -148,7 +161,8 @@ export abstract class ManagementListComponent<T extends MatchingModel>
|
|||||||
.pipe(
|
.pipe(
|
||||||
takeUntil(this.unsubscribeNotifier),
|
takeUntil(this.unsubscribeNotifier),
|
||||||
tap((c) => {
|
tap((c) => {
|
||||||
this.data = c.results
|
this.unfilteredData = c.results
|
||||||
|
this.data = this.filterData(c.results)
|
||||||
this.collectionSize = c.count
|
this.collectionSize = c.count
|
||||||
}),
|
}),
|
||||||
delay(100)
|
delay(100)
|
||||||
|
@@ -1,4 +1,4 @@
|
|||||||
import { NgClass, TitleCasePipe } from '@angular/common'
|
import { NgClass, NgTemplateOutlet, TitleCasePipe } from '@angular/common'
|
||||||
import { Component, inject } from '@angular/core'
|
import { Component, inject } from '@angular/core'
|
||||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||||
import {
|
import {
|
||||||
@@ -30,6 +30,7 @@ import { ManagementListComponent } from '../management-list/management-list.comp
|
|||||||
FormsModule,
|
FormsModule,
|
||||||
ReactiveFormsModule,
|
ReactiveFormsModule,
|
||||||
NgClass,
|
NgClass,
|
||||||
|
NgTemplateOutlet,
|
||||||
NgbDropdownModule,
|
NgbDropdownModule,
|
||||||
NgbPaginationModule,
|
NgbPaginationModule,
|
||||||
NgxBootstrapIconsModule,
|
NgxBootstrapIconsModule,
|
||||||
|
@@ -71,4 +71,20 @@ describe('TagListComponent', () => {
|
|||||||
'Do you really want to delete the tag "Tag1"?'
|
'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, TitleCasePipe } from '@angular/common'
|
import { NgClass, NgTemplateOutlet, TitleCasePipe } from '@angular/common'
|
||||||
import { Component, inject } from '@angular/core'
|
import { Component, inject } from '@angular/core'
|
||||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||||
import {
|
import {
|
||||||
@@ -30,6 +30,7 @@ import { ManagementListComponent } from '../management-list/management-list.comp
|
|||||||
FormsModule,
|
FormsModule,
|
||||||
ReactiveFormsModule,
|
ReactiveFormsModule,
|
||||||
NgClass,
|
NgClass,
|
||||||
|
NgTemplateOutlet,
|
||||||
NgbDropdownModule,
|
NgbDropdownModule,
|
||||||
NgbPaginationModule,
|
NgbPaginationModule,
|
||||||
NgxBootstrapIconsModule,
|
NgxBootstrapIconsModule,
|
||||||
@@ -59,4 +60,10 @@ export class TagListComponent extends ManagementListComponent<Tag> {
|
|||||||
getDeleteMessage(object: Tag) {
|
getDeleteMessage(object: Tag) {
|
||||||
return $localize`Do you really want to delete the tag "${object.name}"?`
|
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,6 +114,10 @@ export const CUSTOM_FIELD_QUERY_OPERATOR_GROUPS_BY_TYPE = {
|
|||||||
CustomFieldQueryOperatorGroups.Exact,
|
CustomFieldQueryOperatorGroups.Exact,
|
||||||
CustomFieldQueryOperatorGroups.Subset,
|
CustomFieldQueryOperatorGroups.Subset,
|
||||||
],
|
],
|
||||||
|
[CustomFieldDataType.LongText]: [
|
||||||
|
CustomFieldQueryOperatorGroups.Basic,
|
||||||
|
CustomFieldQueryOperatorGroups.String,
|
||||||
|
],
|
||||||
}
|
}
|
||||||
|
|
||||||
export const CUSTOM_FIELD_QUERY_VALUE_TYPES_BY_OPERATOR = {
|
export const CUSTOM_FIELD_QUERY_VALUE_TYPES_BY_OPERATOR = {
|
||||||
|
@@ -10,6 +10,7 @@ export enum CustomFieldDataType {
|
|||||||
Monetary = 'monetary',
|
Monetary = 'monetary',
|
||||||
DocumentLink = 'documentlink',
|
DocumentLink = 'documentlink',
|
||||||
Select = 'select',
|
Select = 'select',
|
||||||
|
LongText = 'longtext',
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DATA_TYPE_LABELS = [
|
export const DATA_TYPE_LABELS = [
|
||||||
@@ -49,6 +50,10 @@ export const DATA_TYPE_LABELS = [
|
|||||||
id: CustomFieldDataType.Select,
|
id: CustomFieldDataType.Select,
|
||||||
name: $localize`Select`,
|
name: $localize`Select`,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: CustomFieldDataType.LongText,
|
||||||
|
name: $localize`Long Text`,
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
export interface CustomField extends ObjectWithId {
|
export interface CustomField extends ObjectWithId {
|
||||||
|
12
src-ui/src/app/data/processed-mail.ts
Normal file
12
src-ui/src/app/data/processed-mail.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
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,4 +6,12 @@ export interface Tag extends MatchingModel {
|
|||||||
text_color?: string
|
text_color?: string
|
||||||
|
|
||||||
is_inbox_tag?: boolean
|
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,10 +40,24 @@ export interface WorkflowTrigger extends ObjectWithId {
|
|||||||
|
|
||||||
filter_has_tags?: number[] // Tag.id[]
|
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_correspondent?: number // Correspondent.id
|
||||||
|
|
||||||
filter_has_document_type?: number // DocumentType.id
|
filter_has_document_type?: number // DocumentType.id
|
||||||
|
|
||||||
|
filter_has_storage_path?: number // StoragePath.id
|
||||||
|
|
||||||
schedule_offset_days?: number
|
schedule_offset_days?: number
|
||||||
|
|
||||||
schedule_is_recurring?: boolean
|
schedule_is_recurring?: boolean
|
||||||
|
@@ -28,6 +28,7 @@ export enum PermissionType {
|
|||||||
ShareLink = '%s_sharelink',
|
ShareLink = '%s_sharelink',
|
||||||
CustomField = '%s_customfield',
|
CustomField = '%s_customfield',
|
||||||
Workflow = '%s_workflow',
|
Workflow = '%s_workflow',
|
||||||
|
ProcessedMail = '%s_processedmail',
|
||||||
}
|
}
|
||||||
|
|
||||||
@Injectable({
|
@Injectable({
|
||||||
|
@@ -357,17 +357,15 @@ it('should include custom fields in sort fields if user has permission', () => {
|
|||||||
|
|
||||||
it('should call appropriate api endpoint for email document', () => {
|
it('should call appropriate api endpoint for email document', () => {
|
||||||
subscription = service
|
subscription = service
|
||||||
.emailDocument(
|
.emailDocuments(
|
||||||
documents[0].id,
|
[documents[0].id],
|
||||||
'hello@paperless-ngx.com',
|
'hello@paperless-ngx.com',
|
||||||
'hello',
|
'hello',
|
||||||
'world',
|
'world',
|
||||||
true
|
true
|
||||||
)
|
)
|
||||||
.subscribe()
|
.subscribe()
|
||||||
httpTestingController.expectOne(
|
httpTestingController.expectOne(`${environment.apiBaseUrl}${endpoint}/email/`)
|
||||||
`${environment.apiBaseUrl}${endpoint}/${documents[0].id}/email/`
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
|
@@ -256,14 +256,15 @@ export class DocumentService extends AbstractPaperlessService<Document> {
|
|||||||
return this._searchQuery
|
return this._searchQuery
|
||||||
}
|
}
|
||||||
|
|
||||||
emailDocument(
|
emailDocuments(
|
||||||
documentId: number,
|
documentIds: number[],
|
||||||
addresses: string,
|
addresses: string,
|
||||||
subject: string,
|
subject: string,
|
||||||
message: string,
|
message: string,
|
||||||
useArchiveVersion: boolean
|
useArchiveVersion: boolean
|
||||||
): Observable<any> {
|
): Observable<any> {
|
||||||
return this.http.post(this.getResourceUrl(documentId, 'email'), {
|
return this.http.post(this.getResourceUrl(null, 'email'), {
|
||||||
|
documents: documentIds,
|
||||||
addresses: addresses,
|
addresses: addresses,
|
||||||
subject: subject,
|
subject: subject,
|
||||||
message: message,
|
message: message,
|
||||||
|
39
src-ui/src/app/services/rest/processed-mail.service.spec.ts
Normal file
39
src-ui/src/app/services/rest/processed-mail.service.spec.ts
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
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({})
|
||||||
|
})
|
||||||
|
})
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user