mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2025-10-02 01:42:50 -05:00
Compare commits
3 Commits
feature-ai
...
chore/disa
Author | SHA1 | Date | |
---|---|---|---|
![]() |
1f461a7ee4 | ||
![]() |
a2ce0af79f | ||
![]() |
ba40626838 |
@@ -49,6 +49,7 @@ 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,59 +17,18 @@ 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:
|
||||||
needs:
|
# We want to run on external PRs, but not on our own internal PRs as they'll be run
|
||||||
- detect-duplicate
|
# by the push to the branch. Without this if check, checks are duplicated since
|
||||||
if: needs.detect-duplicate.outputs.should_run == 'true'
|
# internal PRs match both the push and pull_request events.
|
||||||
|
if: github.event_name == 'push' || github.event.pull_request.head.repo.full_name != github.repository
|
||||||
name: Linting Checks
|
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@v6
|
uses: actions/setup-python@v5
|
||||||
with:
|
with:
|
||||||
python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
|
python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
|
||||||
- name: Check files
|
- name: Check files
|
||||||
@@ -84,7 +43,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@v6
|
uses: actions/setup-python@v5
|
||||||
with:
|
with:
|
||||||
python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
|
python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
|
||||||
- name: Install uv
|
- name: Install uv
|
||||||
@@ -138,7 +97,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@v6
|
uses: actions/setup-python@v5
|
||||||
with:
|
with:
|
||||||
python-version: "${{ matrix.python-version }}"
|
python-version: "${{ matrix.python-version }}"
|
||||||
- name: Install uv
|
- name: Install uv
|
||||||
@@ -183,11 +142,13 @@ 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
|
||||||
@@ -207,7 +168,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
version: 10
|
version: 10
|
||||||
- name: Use Node.js 20
|
- name: Use Node.js 20
|
||||||
uses: actions/setup-node@v5
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 20.x
|
node-version: 20.x
|
||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
@@ -240,7 +201,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
version: 10
|
version: 10
|
||||||
- name: Use Node.js 20
|
- name: Use Node.js 20
|
||||||
uses: actions/setup-node@v5
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 20.x
|
node-version: 20.x
|
||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
@@ -263,11 +224,13 @@ 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:
|
||||||
@@ -288,7 +251,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
version: 10
|
version: 10
|
||||||
- name: Use Node.js 20
|
- name: Use Node.js 20
|
||||||
uses: actions/setup-node@v5
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 20.x
|
node-version: 20.x
|
||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
@@ -331,7 +294,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
version: 10
|
version: 10
|
||||||
- name: Use Node.js 20
|
- name: Use Node.js 20
|
||||||
uses: actions/setup-node@v5
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 20.x
|
node-version: 20.x
|
||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
@@ -473,7 +436,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@v6
|
uses: actions/setup-python@v5
|
||||||
with:
|
with:
|
||||||
python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
|
python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
|
||||||
- name: Install uv
|
- name: Install uv
|
||||||
@@ -621,7 +584,7 @@ jobs:
|
|||||||
ref: main
|
ref: main
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
id: setup-python
|
id: setup-python
|
||||||
uses: actions/setup-python@v6
|
uses: actions/setup-python@v5
|
||||||
with:
|
with:
|
||||||
python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
|
python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
|
||||||
- name: Install uv
|
- name: Install uv
|
||||||
@@ -653,7 +616,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@v8
|
uses: actions/github-script@v7
|
||||||
with:
|
with:
|
||||||
script: |
|
script: |
|
||||||
const { repo, owner } = context.repo;
|
const { repo, owner } = context.repo;
|
||||||
|
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@v6
|
uses: actions/labeler@v5
|
||||||
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@v8
|
uses: actions/github-script@v7
|
||||||
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@v8
|
uses: actions/github-script@v7
|
||||||
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@v8
|
uses: actions/github-script@v7
|
||||||
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@v10
|
- uses: actions/stale@v9
|
||||||
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@v8
|
- uses: actions/github-script@v7
|
||||||
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@v8
|
- uses: actions/github-script@v7
|
||||||
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@v8
|
- uses: actions/github-script@v7
|
||||||
with:
|
with:
|
||||||
script: |
|
script: |
|
||||||
function sleep(ms) {
|
function sleep(ms) {
|
||||||
@@ -241,7 +241,6 @@ 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@v6
|
uses: actions/setup-python@v5
|
||||||
- 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@v5
|
uses: actions/setup-node@v4
|
||||||
with:
|
with:
|
||||||
node-version: 20.x
|
node-version: 20.x
|
||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
|
3
.gitignore
vendored
3
.gitignore
vendored
@@ -107,6 +107,3 @@ 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: v6.0.0
|
rev: v5.0.0
|
||||||
hooks:
|
hooks:
|
||||||
- id: check-docstring-first
|
- id: check-docstring-first
|
||||||
- id: check-json
|
- id: check-json
|
||||||
@@ -49,7 +49,7 @@ 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.13.2
|
rev: v0.12.2
|
||||||
hooks:
|
hooks:
|
||||||
- id: ruff-check
|
- id: ruff-check
|
||||||
- id: ruff-format
|
- id: ruff-format
|
||||||
@@ -59,7 +59,7 @@ repos:
|
|||||||
- 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.14.0
|
rev: v2.12.1b3
|
||||||
hooks:
|
hooks:
|
||||||
- id: hadolint
|
- id: hadolint
|
||||||
# Shell script hooks
|
# Shell script hooks
|
||||||
@@ -72,7 +72,7 @@ repos:
|
|||||||
args:
|
args:
|
||||||
- "--tab"
|
- "--tab"
|
||||||
- repo: https://github.com/shellcheck-py/shellcheck-py
|
- repo: https://github.com/shellcheck-py/shellcheck-py
|
||||||
rev: "v0.11.0.1"
|
rev: "v0.10.0.1"
|
||||||
hooks:
|
hooks:
|
||||||
- id: shellcheck
|
- id: shellcheck
|
||||||
- repo: https://github.com/google/yamlfmt
|
- repo: https://github.com/google/yamlfmt
|
||||||
|
@@ -2,11 +2,9 @@
|
|||||||
|
|
||||||
If you feel like contributing to the project, please do! Bug fixes and improvements are always welcome.
|
If you feel like contributing to the project, please do! Bug fixes and improvements are always welcome.
|
||||||
|
|
||||||
⚠️ Please note: Pull requests that implement a new feature or enhancement _should almost always target an existing feature request_ with evidence of community interest and discussion. This is in order to balance the work of implementing and maintaining new features / enhancements. Pull requests that are opened without meeting this requirement may not be merged.
|
|
||||||
|
|
||||||
If you want to implement something big:
|
If you want to implement something big:
|
||||||
|
|
||||||
- As above, please start with a discussion! Maybe something similar is already in development and we can make it happen together.
|
- Please start a discussion about that in the issues! Maybe something similar is already in development and we can make it happen together.
|
||||||
- When making additions to the project, consider if the majority of users will benefit from your change. If not, you're probably better of forking the project.
|
- When making additions to the project, consider if the majority of users will benefit from your change. If not, you're probably better of forking the project.
|
||||||
- Also consider if your change will get in the way of other users. A good change is a change that enhances the experience of some users who want that change and does not affect users who do not care about the change.
|
- Also consider if your change will get in the way of other users. A good change is a change that enhances the experience of some users who want that change and does not affect users who do not care about the change.
|
||||||
- Please see the [paperless-ngx merge process](#merging-prs) below.
|
- Please see the [paperless-ngx merge process](#merging-prs) below.
|
||||||
@@ -135,7 +133,7 @@ community members. That said, in an effort to keep the repository organized and
|
|||||||
- Issues, pull requests and discussions that are closed will be locked after 30 days of inactivity.
|
- 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 with less than 80 "up-votes", < 5 "up-votes" after 180 days, < 20 "up-votes" after 1 year or < 40 "up-votes" at 2 years.
|
- Feature requests that do not meet the following thresholds will be closed: 180 days of inactivity, < 5 "up-votes" after 180 days, < 20 "up-votes" after 1 year or < 80 "up-votes" at 2 years.
|
||||||
|
|
||||||
In all cases, threads can be re-opened by project maintainers and, of course, users can always create a new discussion for related concerns.
|
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.22-python3.12-bookworm-slim AS s6-overlay-base
|
FROM ghcr.io/astral-sh/uv:0.8.15-python3.12-bookworm-slim AS s6-overlay-base
|
||||||
|
|
||||||
WORKDIR /usr/src/s6
|
WORKDIR /usr/src/s6
|
||||||
|
|
||||||
|
@@ -32,7 +32,7 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- redisdata:/data
|
- redisdata:/data
|
||||||
db:
|
db:
|
||||||
image: docker.io/library/postgres:18
|
image: docker.io/library/postgres:17
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
volumes:
|
volumes:
|
||||||
- pgdata:/var/lib/postgresql/data
|
- pgdata:/var/lib/postgresql/data
|
||||||
|
@@ -35,7 +35,7 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- redisdata:/data
|
- redisdata:/data
|
||||||
db:
|
db:
|
||||||
image: docker.io/library/postgres:18
|
image: docker.io/library/postgres:17
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
volumes:
|
volumes:
|
||||||
- pgdata:/var/lib/postgresql/data
|
- pgdata:/var/lib/postgresql/data
|
||||||
|
@@ -31,7 +31,7 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- redisdata:/data
|
- redisdata:/data
|
||||||
db:
|
db:
|
||||||
image: docker.io/library/postgres:18
|
image: docker.io/library/postgres:17
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
volumes:
|
volumes:
|
||||||
- pgdata:/var/lib/postgresql/data
|
- pgdata:/var/lib/postgresql/data
|
||||||
|
@@ -11,7 +11,6 @@ for command in decrypt_documents \
|
|||||||
mail_fetcher \
|
mail_fetcher \
|
||||||
document_create_classifier \
|
document_create_classifier \
|
||||||
document_index \
|
document_index \
|
||||||
document_llmindex \
|
|
||||||
document_renamer \
|
document_renamer \
|
||||||
document_retagger \
|
document_retagger \
|
||||||
document_thumbnails \
|
document_thumbnails \
|
||||||
|
@@ -1,14 +0,0 @@
|
|||||||
#!/command/with-contenv /usr/bin/bash
|
|
||||||
# shellcheck shell=bash
|
|
||||||
|
|
||||||
set -e
|
|
||||||
|
|
||||||
cd "${PAPERLESS_SRC_DIR}"
|
|
||||||
|
|
||||||
if [[ $(id -u) == 0 ]]; then
|
|
||||||
s6-setuidgid paperless python3 manage.py document_llmindex "$@"
|
|
||||||
elif [[ $(id -un) == "paperless" ]]; then
|
|
||||||
python3 manage.py document_llmindex "$@"
|
|
||||||
else
|
|
||||||
echo "Unknown user."
|
|
||||||
fi
|
|
@@ -506,7 +506,6 @@ for the possible codes and their meanings.
|
|||||||
The `localize_date` filter formats a date or datetime object into a localized string using Babel internationalization.
|
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
|
||||||
|
|
||||||
@@ -517,7 +516,7 @@ An ISO string can also be provided to control the output format.
|
|||||||
|
|
||||||
###### Parameters
|
###### Parameters
|
||||||
|
|
||||||
- `value` (date | datetime | str): Date, datetime object or ISO string to format (datetime should be timezone-aware)
|
- `value` (date | datetime): Date or datetime object to format (datetime should be timezone-aware)
|
||||||
- `format` (str): Format type - either a Babel preset ('short', 'medium', 'long', 'full') or custom pattern
|
- `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`: Either an array of custom field ids to assign (with an empty
|
- `custom_fields`: An array of custom field ids to assign (with an empty
|
||||||
value) to the document or an object mapping field id -> value.
|
value) to the document.
|
||||||
|
|
||||||
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
|
||||||
|
@@ -1759,11 +1759,6 @@ started by the container.
|
|||||||
|
|
||||||
: Path to an image file in the /media/logo directory, must include 'logo', e.g. `/logo/Atari_logo.svg`
|
: Path to an image file in the /media/logo directory, must include 'logo', e.g. `/logo/Atari_logo.svg`
|
||||||
|
|
||||||
!!! note
|
|
||||||
|
|
||||||
The logo file will be viewable by anyone with access to the Paperless instance login page,
|
|
||||||
so consider your choice of logo carefully and removing exif data from images before uploading.
|
|
||||||
|
|
||||||
#### [`PAPERLESS_ENABLE_UPDATE_CHECK=<bool>`](#PAPERLESS_ENABLE_UPDATE_CHECK) {#PAPERLESS_ENABLE_UPDATE_CHECK}
|
#### [`PAPERLESS_ENABLE_UPDATE_CHECK=<bool>`](#PAPERLESS_ENABLE_UPDATE_CHECK) {#PAPERLESS_ENABLE_UPDATE_CHECK}
|
||||||
|
|
||||||
!!! note
|
!!! note
|
||||||
@@ -1805,67 +1800,3 @@ password. All of these options come from their similarly-named [Django settings]
|
|||||||
#### [`PAPERLESS_EMAIL_USE_SSL=<bool>`](#PAPERLESS_EMAIL_USE_SSL) {#PAPERLESS_EMAIL_USE_SSL}
|
#### [`PAPERLESS_EMAIL_USE_SSL=<bool>`](#PAPERLESS_EMAIL_USE_SSL) {#PAPERLESS_EMAIL_USE_SSL}
|
||||||
|
|
||||||
: Defaults to false.
|
: Defaults to false.
|
||||||
|
|
||||||
## AI {#ai}
|
|
||||||
|
|
||||||
#### [`PAPERLESS_AI_ENABLED=<bool>`](#PAPERLESS_AI_ENABLED) {#PAPERLESS_AI_ENABLED}
|
|
||||||
|
|
||||||
: Enables the AI features in Paperless. This includes the AI-based
|
|
||||||
suggestions. This setting is required to be set to true in order to use the AI features.
|
|
||||||
|
|
||||||
Defaults to false.
|
|
||||||
|
|
||||||
#### [`PAPERLESS_AI_LLM_EMBEDDING_BACKEND=<str>`](#PAPERLESS_AI_LLM_EMBEDDING_BACKEND) {#PAPERLESS_AI_LLM_EMBEDDING_BACKEND}
|
|
||||||
|
|
||||||
: The embedding backend to use for RAG. This can be either "openai" or "huggingface".
|
|
||||||
|
|
||||||
Defaults to None.
|
|
||||||
|
|
||||||
#### [`PAPERLESS_AI_LLM_EMBEDDING_MODEL=<str>`](#PAPERLESS_AI_LLM_EMBEDDING_MODEL) {#PAPERLESS_AI_LLM_EMBEDDING_MODEL}
|
|
||||||
|
|
||||||
: The model to use for the embedding backend for RAG. This can be set to any of the embedding models supported by the current embedding backend. If not supplied, defaults to "text-embedding-3-small" for OpenAI and "sentence-transformers/all-MiniLM-L6-v2" for Huggingface.
|
|
||||||
|
|
||||||
Defaults to None.
|
|
||||||
|
|
||||||
#### [`PAPERLESS_AI_BACKEND=<str>`](#PAPERLESS_AI_BACKEND) {#PAPERLESS_AI_BACKEND}
|
|
||||||
|
|
||||||
: The AI backend to use. This can be either "openai" or "ollama". If set to "ollama", the AI
|
|
||||||
features will be run locally on your machine. If set to "openai", the AI features will be run
|
|
||||||
using the OpenAI API. This setting is required to be set to use the AI features.
|
|
||||||
|
|
||||||
Defaults to None.
|
|
||||||
|
|
||||||
!!! note
|
|
||||||
|
|
||||||
The OpenAI API is a paid service. You will need to set up an OpenAI account and
|
|
||||||
will be charged for usage incurred by Paperless-ngx features and your document data
|
|
||||||
will (of course) be sent to the OpenAI API. Paperless-ngx does not endorse the use of the
|
|
||||||
OpenAI API in any way.
|
|
||||||
|
|
||||||
Refer to the OpenAI terms of service, and use at your own risk.
|
|
||||||
|
|
||||||
#### [`PAPERLESS_AI_LLM_MODEL=<str>`](#PAPERLESS_AI_LLM_MODEL) {#PAPERLESS_AI_LLM_MODEL}
|
|
||||||
|
|
||||||
: The model to use for the AI backend, i.e. "gpt-3.5-turbo", "gpt-4" or any of the models supported by the
|
|
||||||
current backend. If not supplied, defaults to "gpt-3.5-turbo" for OpenAI and "llama3" for Ollama.
|
|
||||||
|
|
||||||
Defaults to None.
|
|
||||||
|
|
||||||
#### [`PAPERLESS_AI_LLM_API_KEY=<str>`](#PAPERLESS_AI_LLM_API_KEY) {#PAPERLESS_AI_LLM_API_KEY}
|
|
||||||
|
|
||||||
: The API key to use for the AI backend. This is required for the OpenAI backend only.
|
|
||||||
|
|
||||||
Defaults to None.
|
|
||||||
|
|
||||||
#### [`PAPERLESS_AI_LLM_ENDPOINT=<str>`](#PAPERLESS_AI_LLM_ENDPOINT) {#PAPERLESS_AI_LLM_ENDPOINT}
|
|
||||||
|
|
||||||
: The endpoint / url to use for the AI backend. This is required for the Ollama backend only.
|
|
||||||
|
|
||||||
Defaults to None.
|
|
||||||
|
|
||||||
#### [`PAPERLESS_AI_LLM_INDEX_TASK_CRON=<cron expression>`](#PAPERLESS_AI_LLM_INDEX_TASK_CRON) {#PAPERLESS_AI_LLM_INDEX_TASK_CRON}
|
|
||||||
|
|
||||||
: Configures the schedule to update the AI embeddings of text content and metadata for all documents. Only performed if
|
|
||||||
AI is enabled and the LLM embedding backend is set.
|
|
||||||
|
|
||||||
Defaults to `10 2 * * *`, once per day.
|
|
||||||
|
@@ -470,14 +470,9 @@ To get started:
|
|||||||
|
|
||||||
2. VS Code will prompt you with "Reopen in container". Do so and wait for the environment to start.
|
2. VS Code will prompt you with "Reopen in container". Do so and wait for the environment to start.
|
||||||
|
|
||||||
3. In case your host operating system is Windows:
|
3. Initialize the project by running the task **Project Setup: Run all Init Tasks**. This
|
||||||
|
|
||||||
- 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.
|
||||||
|
|
||||||
5. The project is ready for debugging, start either run the fullstack debug or individual debug
|
4. The project is ready for debugging, start either run the fullstack debug or individual debug
|
||||||
processes. Yo spin up the project without debugging run the task **Project Start: Run all Services**
|
processes. Yo spin up the project without debugging run the task **Project Start: Run all Services**
|
||||||
|
@@ -25,12 +25,11 @@ physical documents into a searchable online archive so you can keep, well, _less
|
|||||||
## Features
|
## Features
|
||||||
|
|
||||||
- **Organize and index** your scanned documents with tags, correspondents, types, and more.
|
- **Organize and index** your scanned documents with tags, correspondents, types, and more.
|
||||||
- _Your_ data is stored locally on _your_ server and is never transmitted or shared in any way, unless you explicitly choose to do so.
|
- _Your_ data is stored locally on _your_ server and is never transmitted or shared in any way.
|
||||||
- Performs **OCR** on your documents, adding searchable and selectable text, even to documents scanned with only images.
|
- Performs **OCR** on your documents, adding searchable and selectable text, even to documents scanned with only images.
|
||||||
- Utilizes the open-source Tesseract engine to recognize more than 100 languages.
|
- Utilizes the open-source Tesseract engine to recognize more than 100 languages.
|
||||||
- Documents are saved as PDF/A format which is designed for long term storage, alongside the unaltered originals.
|
- Documents are saved as PDF/A format which is designed for long term storage, alongside the unaltered originals.
|
||||||
- Uses machine-learning to automatically add tags, correspondents and document types to your documents.
|
- Uses machine-learning to automatically add tags, correspondents and document types to your documents.
|
||||||
- **New**: Paperless-ngx can now leverage AI (Large Language Models or LLMs) for document suggestions. This is an optional feature that can be enabled (and is disabled by default).
|
|
||||||
- Supports PDF documents, images, plain text files, Office documents (Word, Excel, PowerPoint, and LibreOffice equivalents)[^1] and more.
|
- Supports PDF documents, images, plain text files, Office documents (Word, Excel, PowerPoint, and LibreOffice equivalents)[^1] and more.
|
||||||
- Paperless stores your documents plain on disk. Filenames and folders are managed by paperless and their format can be configured freely with different configurations assigned to different documents.
|
- Paperless stores your documents plain on disk. Filenames and folders are managed by paperless and their format can be configured freely with different configurations assigned to different documents.
|
||||||
- **Beautiful, modern web application** that features:
|
- **Beautiful, modern web application** that features:
|
||||||
|
116
docs/usage.md
116
docs/usage.md
@@ -92,16 +92,6 @@ and more. These areas allow you to view, add, edit, delete and manage permission
|
|||||||
for these objects. You can also manage saved views, mail accounts, mail rules,
|
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
|
||||||
@@ -261,10 +251,6 @@ 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.
|
||||||
@@ -278,28 +264,6 @@ Once setup, navigating to the email settings page in Paperless-ngx will allow yo
|
|||||||
You can also submit a document using the REST API, see [POSTing documents](api.md#file-uploads)
|
You can also submit a document using the REST API, see [POSTing documents](api.md#file-uploads)
|
||||||
for details.
|
for details.
|
||||||
|
|
||||||
## Document Suggestions
|
|
||||||
|
|
||||||
Paperless-ngx can suggest tags, correspondents, document types and storage paths for documents based on the content of the document. This is done using a (non-LLM) machine learning model that is trained on the documents in your database. The suggestions are shown in the document detail page and can be accepted or rejected by the user.
|
|
||||||
|
|
||||||
## AI Features
|
|
||||||
|
|
||||||
Paperless-ngx includes several features that use AI to enhance the document management experience. These features are optional and can be enabled or disabled in the settings. If you are using the AI features, you may want to also enable the "LLM index" feature, which supports Retrieval-Augmented Generation (RAG) designed to improve the quality of AI responses. The LLM index feature is not enabled by default and requires additional configuration.
|
|
||||||
|
|
||||||
!!! warning
|
|
||||||
|
|
||||||
Remember that Paperless-ngx will send document content to the AI provider you have configured, so consider the privacy implications of using these features, especially if using a remote model (e.g. OpenAI), instead of the default local model.
|
|
||||||
|
|
||||||
The AI features work by creating an embedding of the text content and metadata of documents, which is then used for various tasks such as similarity search and question answering. This uses the FAISS vector store.
|
|
||||||
|
|
||||||
### AI-Enhanced Suggestions
|
|
||||||
|
|
||||||
If enabled, Paperless-ngx can use an AI LLM model to suggest document titles, dates, tags, correspondents and document types for documents. This feature will always be "opt-in" and does not disable the existing classifier-based suggestion system. Currently, both remote (via the OpenAI API) and local (via Ollama) models are supported, see [configuration](configuration.md#ai) for details.
|
|
||||||
|
|
||||||
### Document Chat
|
|
||||||
|
|
||||||
Paperless-ngx can use an AI LLM model to answer questions about a document or across multiple documents. Again, this feature works best when RAG is enabled. The chat feature is available in the upper app toolbar and will switch between chatting across multiple documents or a single document based on the current view.
|
|
||||||
|
|
||||||
## Sharing documents from Paperless-ngx
|
## Sharing documents from Paperless-ngx
|
||||||
|
|
||||||
Paperless-ngx supports sharing documents with other users by assigning them [permissions](#object-permissions)
|
Paperless-ngx supports sharing documents with other users by assigning them [permissions](#object-permissions)
|
||||||
@@ -444,7 +408,7 @@ Currently, there are three events that correspond to workflow trigger 'types':
|
|||||||
but the document content has been extracted and metadata such as document type, tags, etc. have been set, so these can now
|
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, correspondent or storage path.
|
tags, doc type, or correspondent.
|
||||||
4. **Scheduled**: a scheduled trigger that can be used to run workflows at a specific time. The date used can be either the document
|
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).
|
||||||
@@ -488,11 +452,10 @@ Workflows allow you to filter by:
|
|||||||
- 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`, `Updated` and `Scheduled` triggers only). Filter document content using the matching settings.
|
- Content matching (`Added` and `Updated` triggers only). Filter document content using the matching settings.
|
||||||
- Tags (`Added`, `Updated` and `Scheduled` triggers only). Filter for documents with any of the specified tags
|
- Tags (`Added` and `Updated` triggers only). Filter for documents with any of the specified tags
|
||||||
- Document type (`Added`, `Updated` and `Scheduled` triggers only). Filter documents with this doc type
|
- Document type (`Added` and `Updated` triggers only). Filter documents with this doc type
|
||||||
- Correspondent (`Added`, `Updated` and `Scheduled` triggers only). Filter documents with this correspondent
|
- Correspondent (`Added` and `Updated` triggers only). Filter documents with this correspondent
|
||||||
- Storage path (`Added`, `Updated` and `Scheduled` triggers only). Filter documents with this storage path
|
|
||||||
|
|
||||||
### Workflow Actions
|
### Workflow Actions
|
||||||
|
|
||||||
@@ -542,52 +505,35 @@ you may want to adjust these settings to prevent abuse.
|
|||||||
|
|
||||||
#### Workflow placeholders
|
#### Workflow placeholders
|
||||||
|
|
||||||
Titles can be assigned by workflows using [Jinja templates](https://jinja.palletsprojects.com/en/3.1.x/templates/).
|
Some workflow text can include placeholders but the available options differ depending on the type of
|
||||||
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)
|
workflow trigger. This is because at the time of consumption (when the text is to be set), no automatic tags etc. have been
|
||||||
and [filters](https://jinja.palletsprojects.com/en/3.1.x/templates/#id11).
|
applied. You can use the following placeholders with any trigger type:
|
||||||
The template is provided as a string.
|
|
||||||
|
|
||||||
Using Jinja2 Templates is also useful for [Date localization](advanced_usage.md#Date-Localization) in the title.
|
- `{correspondent}`: assigned correspondent name
|
||||||
|
- `{document_type}`: assigned document type name
|
||||||
The available inputs differ depending on the type of workflow trigger.
|
- `{owner_username}`: assigned owner username
|
||||||
This is because at the time of consumption (when the text is to be set), no automatic tags etc. have been
|
- `{added}`: added datetime
|
||||||
applied. You can use the following placeholders in the template with any trigger type:
|
- `{added_year}`: added year
|
||||||
|
- `{added_year_short}`: added year
|
||||||
- `{{correspondent}}`: assigned correspondent name
|
- `{added_month}`: added month
|
||||||
- `{{document_type}}`: assigned document type name
|
- `{added_month_name}`: added month name
|
||||||
- `{{owner_username}}`: assigned owner username
|
- `{added_month_name_short}`: added month short name
|
||||||
- `{{added}}`: added datetime
|
- `{added_day}`: added day
|
||||||
- `{{added_year}}`: added year
|
- `{added_time}`: added time in HH:MM format
|
||||||
- `{{added_year_short}}`: added year
|
- `{original_filename}`: original file name without extension
|
||||||
- `{{added_month}}`: added month
|
- `{filename}`: current file name without extension
|
||||||
- `{{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
|
||||||
|
|
||||||
@@ -659,7 +605,7 @@ When you first delete a document it is moved to the 'trash' until either it is e
|
|||||||
You can set how long documents remain in the trash before being automatically deleted with [`PAPERLESS_EMPTY_TRASH_DELAY`](configuration.md#PAPERLESS_EMPTY_TRASH_DELAY), which defaults
|
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 the trash is emptied with [`PAPERLESS_EMPTY_TRASH_DIR`](configuration.md#PAPERLESS_EMPTY_TRASH_DIR).
|
Additionally you may configure a directory where deleted files are moved to when they the trash is emptied with [`PAPERLESS_EMPTY_TRASH_DIR`](configuration.md#PAPERLESS_EMPTY_TRASH_DIR).
|
||||||
Note that the empty trash directory only stores the original file, the archive file and all database information is permanently removed once a document is fully deleted.
|
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}
|
||||||
|
@@ -30,19 +30,17 @@ dependencies = [
|
|||||||
"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.9.0",
|
"django-cors-headers~=4.8.0",
|
||||||
"django-extensions~=4.1",
|
"django-extensions~=4.1",
|
||||||
"django-filter~=25.1",
|
"django-filter~=25.1",
|
||||||
"django-guardian~=3.2.0",
|
"django-guardian~=3.1.2",
|
||||||
"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.9.1",
|
"drf-spectacular-sidecar~=2025.9.1",
|
||||||
"drf-writable-nested~=0.7.1",
|
"drf-writable-nested~=0.7.1",
|
||||||
"faiss-cpu>=1.10",
|
|
||||||
"filelock~=3.19.1",
|
"filelock~=3.19.1",
|
||||||
"flower~=2.0.1",
|
"flower~=2.0.1",
|
||||||
"gotenberg-client~=0.11.0",
|
"gotenberg-client~=0.11.0",
|
||||||
@@ -51,17 +49,11 @@ dependencies = [
|
|||||||
"inotifyrecursive~=0.3",
|
"inotifyrecursive~=0.3",
|
||||||
"jinja2~=3.1.5",
|
"jinja2~=3.1.5",
|
||||||
"langdetect~=1.0.9",
|
"langdetect~=1.0.9",
|
||||||
"llama-index-core>=0.12.33.post1",
|
|
||||||
"llama-index-embeddings-huggingface>=0.5.3",
|
|
||||||
"llama-index-embeddings-openai>=0.3.1",
|
|
||||||
"llama-index-llms-ollama>=0.5.4",
|
|
||||||
"llama-index-llms-openai>=0.3.38",
|
|
||||||
"llama-index-vector-stores-faiss>=0.3",
|
|
||||||
"nltk~=3.9.1",
|
"nltk~=3.9.1",
|
||||||
"ocrmypdf~=16.11.0",
|
"ocrmypdf~=16.10.0",
|
||||||
"openai>=1.76",
|
|
||||||
"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",
|
||||||
@@ -71,7 +63,6 @@ dependencies = [
|
|||||||
"rapidfuzz~=3.14.0",
|
"rapidfuzz~=3.14.0",
|
||||||
"redis[hiredis]~=5.2.1",
|
"redis[hiredis]~=5.2.1",
|
||||||
"scikit-learn~=1.7.0",
|
"scikit-learn~=1.7.0",
|
||||||
"sentence-transformers>=4.1",
|
|
||||||
"setproctitle~=1.3.4",
|
"setproctitle~=1.3.4",
|
||||||
"tika-client~=0.10.0",
|
"tika-client~=0.10.0",
|
||||||
"tqdm~=4.67.1",
|
"tqdm~=4.67.1",
|
||||||
@@ -125,7 +116,7 @@ testing = [
|
|||||||
lint = [
|
lint = [
|
||||||
"pre-commit~=4.3.0",
|
"pre-commit~=4.3.0",
|
||||||
"pre-commit-uv~=4.1.3",
|
"pre-commit-uv~=4.1.3",
|
||||||
"ruff~=0.13.0",
|
"ruff~=0.12.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
typing = [
|
typing = [
|
||||||
@@ -241,7 +232,6 @@ testpaths = [
|
|||||||
"src/paperless_tesseract/tests/",
|
"src/paperless_tesseract/tests/",
|
||||||
"src/paperless_tika/tests",
|
"src/paperless_tika/tests",
|
||||||
"src/paperless_text/tests/",
|
"src/paperless_text/tests/",
|
||||||
"src/paperless_ai/tests",
|
|
||||||
]
|
]
|
||||||
addopts = [
|
addopts = [
|
||||||
"--pythonwarnings=all",
|
"--pythonwarnings=all",
|
||||||
@@ -251,7 +241,7 @@ addopts = [
|
|||||||
"--numprocesses=auto",
|
"--numprocesses=auto",
|
||||||
"--maxprocesses=16",
|
"--maxprocesses=16",
|
||||||
"--quiet",
|
"--quiet",
|
||||||
"--durations=50",
|
"--durations=0",
|
||||||
"--junitxml=junit.xml",
|
"--junitxml=junit.xml",
|
||||||
"-o junit_family=legacy",
|
"-o junit_family=legacy",
|
||||||
]
|
]
|
||||||
@@ -259,6 +249,10 @@ norecursedirs = [ "src/locale/", ".venv/", "src-ui/" ]
|
|||||||
|
|
||||||
DJANGO_SETTINGS_MODULE = "paperless.settings"
|
DJANGO_SETTINGS_MODULE = "paperless.settings"
|
||||||
|
|
||||||
|
markers = [
|
||||||
|
"use_whitenoise: mark test to run with Whitenoise middleware enabled",
|
||||||
|
]
|
||||||
|
|
||||||
[tool.pytest_env]
|
[tool.pytest_env]
|
||||||
PAPERLESS_DISABLE_DBHANDLER = "true"
|
PAPERLESS_DISABLE_DBHANDLER = "true"
|
||||||
PAPERLESS_CACHE_BACKEND = "django.core.cache.backends.locmem.LocMemCache"
|
PAPERLESS_CACHE_BACKEND = "django.core.cache.backends.locmem.LocMemCache"
|
||||||
|
@@ -174,7 +174,7 @@ test('bulk edit', async ({ page }) => {
|
|||||||
await expect(page.locator('pngx-document-list')).toHaveText(
|
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: 'None' }).click()
|
await page.getByRole('button', { name: 'Cancel' }).click()
|
||||||
|
|
||||||
await page.locator('pngx-document-card-small').nth(1).click()
|
await page.locator('pngx-document-card-small').nth(1).click()
|
||||||
await page.locator('pngx-document-card-small').nth(2).click()
|
await page.locator('pngx-document-card-small').nth(2).click()
|
||||||
|
1051
src-ui/messages.xlf
1051
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.6",
|
"@angular/cdk": "^20.2.2",
|
||||||
"@angular/common": "~20.3.2",
|
"@angular/common": "~20.2.4",
|
||||||
"@angular/compiler": "~20.3.2",
|
"@angular/compiler": "~20.2.4",
|
||||||
"@angular/core": "~20.3.2",
|
"@angular/core": "~20.2.4",
|
||||||
"@angular/forms": "~20.3.2",
|
"@angular/forms": "~20.2.4",
|
||||||
"@angular/localize": "~20.3.2",
|
"@angular/localize": "~20.2.4",
|
||||||
"@angular/platform-browser": "~20.3.2",
|
"@angular/platform-browser": "~20.2.4",
|
||||||
"@angular/platform-browser-dynamic": "~20.3.2",
|
"@angular/platform-browser-dynamic": "~20.2.4",
|
||||||
"@angular/router": "~20.3.2",
|
"@angular/router": "~20.2.4",
|
||||||
"@ng-bootstrap/ng-bootstrap": "^19.0.1",
|
"@ng-bootstrap/ng-bootstrap": "^19.0.1",
|
||||||
"@ng-select/ng-select": "^20.2.2",
|
"@ng-select/ng-select": "^20.1.3",
|
||||||
"@ngneat/dirty-check-forms": "^3.0.3",
|
"@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,48 +29,47 @@
|
|||||||
"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.1.0",
|
"ngx-color": "^10.0.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": "^13.0.0",
|
"uuid": "^11.1.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.3.3",
|
"@angular-devkit/core": "^20.2.2",
|
||||||
"@angular-devkit/schematics": "^20.3.3",
|
"@angular-devkit/schematics": "^20.2.2",
|
||||||
"@angular-eslint/builder": "20.3.0",
|
"@angular-eslint/builder": "20.2.0",
|
||||||
"@angular-eslint/eslint-plugin": "20.3.0",
|
"@angular-eslint/eslint-plugin": "20.2.0",
|
||||||
"@angular-eslint/eslint-plugin-template": "20.3.0",
|
"@angular-eslint/eslint-plugin-template": "20.2.0",
|
||||||
"@angular-eslint/schematics": "20.3.0",
|
"@angular-eslint/schematics": "20.2.0",
|
||||||
"@angular-eslint/template-parser": "20.3.0",
|
"@angular-eslint/template-parser": "20.2.0",
|
||||||
"@angular/build": "^20.3.3",
|
"@angular/build": "^20.2.2",
|
||||||
"@angular/cli": "~20.3.3",
|
"@angular/cli": "~20.2.2",
|
||||||
"@angular/compiler-cli": "~20.3.2",
|
"@angular/compiler-cli": "~20.2.4",
|
||||||
"@codecov/webpack-plugin": "^1.9.1",
|
"@codecov/webpack-plugin": "^1.9.1",
|
||||||
"@playwright/test": "^1.55.1",
|
"@playwright/test": "^1.55.0",
|
||||||
"@types/jest": "^30.0.0",
|
"@types/jest": "^30.0.0",
|
||||||
"@types/node": "^24.6.1",
|
"@types/node": "^24.3.0",
|
||||||
"@typescript-eslint/eslint-plugin": "^8.45.0",
|
"@typescript-eslint/eslint-plugin": "^8.41.0",
|
||||||
"@typescript-eslint/parser": "^8.45.0",
|
"@typescript-eslint/parser": "^8.41.0",
|
||||||
"@typescript-eslint/utils": "^8.45.0",
|
"@typescript-eslint/utils": "^8.41.0",
|
||||||
"eslint": "^9.36.0",
|
"eslint": "^9.34.0",
|
||||||
"jest": "30.2.0",
|
"jest": "30.1.3",
|
||||||
"jest-environment-jsdom": "^30.2.0",
|
"jest-environment-jsdom": "^30.1.2",
|
||||||
"jest-junit": "^16.0.0",
|
"jest-junit": "^16.0.0",
|
||||||
"jest-preset-angular": "^15.0.2",
|
"jest-preset-angular": "^15.0.0",
|
||||||
"jest-websocket-mock": "^2.5.0",
|
"jest-websocket-mock": "^2.5.0",
|
||||||
"prettier-plugin-organize-imports": "^4.3.0",
|
"prettier-plugin-organize-imports": "^4.2.0",
|
||||||
"ts-node": "~10.9.1",
|
"ts-node": "~10.9.1",
|
||||||
"typescript": "^5.8.3",
|
"typescript": "^5.8.3",
|
||||||
"webpack": "^5.102.0"
|
"webpack": "^5.101.3"
|
||||||
},
|
},
|
||||||
"packageManager": "pnpm@10.17.1",
|
|
||||||
"pnpm": {
|
"pnpm": {
|
||||||
"onlyBuiltDependencies": [
|
"onlyBuiltDependencies": [
|
||||||
"@parcel/watcher",
|
"@parcel/watcher",
|
||||||
|
3494
src-ui/pnpm-lock.yaml
generated
3494
src-ui/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -145,14 +145,4 @@ HTMLCanvasElement.prototype.getContext = <
|
|||||||
typeof HTMLCanvasElement.prototype.getContext
|
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')
|
||||||
|
@@ -35,12 +35,8 @@
|
|||||||
@case (ConfigOptionType.String) { <pngx-input-text [formControlName]="option.key" [error]="errors[option.key]"></pngx-input-text> }
|
@case (ConfigOptionType.String) { <pngx-input-text [formControlName]="option.key" [error]="errors[option.key]"></pngx-input-text> }
|
||||||
@case (ConfigOptionType.JSON) { <pngx-input-text [formControlName]="option.key" [error]="errors[option.key]"></pngx-input-text> }
|
@case (ConfigOptionType.JSON) { <pngx-input-text [formControlName]="option.key" [error]="errors[option.key]"></pngx-input-text> }
|
||||||
@case (ConfigOptionType.File) { <pngx-input-file [formControlName]="option.key" (upload)="uploadFile($event, option.key)" [error]="errors[option.key]"></pngx-input-file> }
|
@case (ConfigOptionType.File) { <pngx-input-file [formControlName]="option.key" (upload)="uploadFile($event, option.key)" [error]="errors[option.key]"></pngx-input-file> }
|
||||||
@case (ConfigOptionType.Password) { <pngx-input-password [formControlName]="option.key" [error]="errors[option.key]"></pngx-input-password> }
|
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
@if (option.note) {
|
|
||||||
<div class="form-text fst-italic">{{option.note}}</div>
|
|
||||||
}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@@ -29,7 +29,6 @@ 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 { FileComponent } from '../../common/input/file/file.component'
|
import { FileComponent } from '../../common/input/file/file.component'
|
||||||
import { NumberComponent } from '../../common/input/number/number.component'
|
import { NumberComponent } from '../../common/input/number/number.component'
|
||||||
import { PasswordComponent } from '../../common/input/password/password.component'
|
|
||||||
import { SelectComponent } from '../../common/input/select/select.component'
|
import { SelectComponent } from '../../common/input/select/select.component'
|
||||||
import { SwitchComponent } from '../../common/input/switch/switch.component'
|
import { SwitchComponent } from '../../common/input/switch/switch.component'
|
||||||
import { TextComponent } from '../../common/input/text/text.component'
|
import { TextComponent } from '../../common/input/text/text.component'
|
||||||
@@ -47,7 +46,6 @@ import { LoadingComponentWithPermissions } from '../../loading-component/loading
|
|||||||
TextComponent,
|
TextComponent,
|
||||||
NumberComponent,
|
NumberComponent,
|
||||||
FileComponent,
|
FileComponent,
|
||||||
PasswordComponent,
|
|
||||||
AsyncPipe,
|
AsyncPipe,
|
||||||
NgbNavModule,
|
NgbNavModule,
|
||||||
FormsModule,
|
FormsModule,
|
||||||
|
@@ -92,9 +92,6 @@ const status: SystemStatus = {
|
|||||||
sanity_check_status: SystemStatusItemStatus.ERROR,
|
sanity_check_status: SystemStatusItemStatus.ERROR,
|
||||||
sanity_check_last_run: new Date().toISOString(),
|
sanity_check_last_run: new Date().toISOString(),
|
||||||
sanity_check_error: 'Error running sanity check.',
|
sanity_check_error: 'Error running sanity check.',
|
||||||
llmindex_status: SystemStatusItemStatus.DISABLED,
|
|
||||||
llmindex_last_modified: new Date().toISOString(),
|
|
||||||
llmindex_error: null,
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -30,9 +30,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<ul ngbNav class="order-sm-3">
|
<ul ngbNav class="order-sm-3">
|
||||||
@if (aiEnabled) {
|
|
||||||
<pngx-chat></pngx-chat>
|
|
||||||
}
|
|
||||||
<pngx-toasts-dropdown></pngx-toasts-dropdown>
|
<pngx-toasts-dropdown></pngx-toasts-dropdown>
|
||||||
<li ngbDropdown class="nav-item dropdown">
|
<li ngbDropdown class="nav-item dropdown">
|
||||||
<button class="btn ps-1 border-0" id="userDropdown" ngbDropdownToggle>
|
<button class="btn ps-1 border-0" id="userDropdown" ngbDropdownToggle>
|
||||||
|
@@ -44,7 +44,6 @@ import { SettingsService } from 'src/app/services/settings.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 { ToastService } from 'src/app/services/toast.service'
|
||||||
import { environment } from 'src/environments/environment'
|
import { environment } from 'src/environments/environment'
|
||||||
import { ChatComponent } from '../chat/chat/chat.component'
|
|
||||||
import { ProfileEditDialogComponent } from '../common/profile-edit-dialog/profile-edit-dialog.component'
|
import { ProfileEditDialogComponent } from '../common/profile-edit-dialog/profile-edit-dialog.component'
|
||||||
import { DocumentDetailComponent } from '../document-detail/document-detail.component'
|
import { DocumentDetailComponent } from '../document-detail/document-detail.component'
|
||||||
import { ComponentWithPermissions } from '../with-permissions/with-permissions.component'
|
import { ComponentWithPermissions } from '../with-permissions/with-permissions.component'
|
||||||
@@ -60,7 +59,6 @@ import { ToastsDropdownComponent } from './toasts-dropdown/toasts-dropdown.compo
|
|||||||
DocumentTitlePipe,
|
DocumentTitlePipe,
|
||||||
IfPermissionsDirective,
|
IfPermissionsDirective,
|
||||||
ToastsDropdownComponent,
|
ToastsDropdownComponent,
|
||||||
ChatComponent,
|
|
||||||
RouterModule,
|
RouterModule,
|
||||||
NgClass,
|
NgClass,
|
||||||
NgbDropdownModule,
|
NgbDropdownModule,
|
||||||
@@ -173,10 +171,6 @@ export class AppFrameComponent
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
get aiEnabled(): boolean {
|
|
||||||
return this.settingsService.get(SETTINGS_KEYS.AI_ENABLED)
|
|
||||||
}
|
|
||||||
|
|
||||||
closeMenu() {
|
closeMenu() {
|
||||||
this.isMenuCollapsed = true
|
this.isMenuCollapsed = true
|
||||||
}
|
}
|
||||||
|
@@ -1,5 +1,5 @@
|
|||||||
|
|
||||||
<li ngbDropdown class="nav-item mx-1" (openChange)="onOpenChange($event)">
|
<li ngbDropdown class="nav-item" (openChange)="onOpenChange($event)">
|
||||||
@if (toasts.length) {
|
@if (toasts.length) {
|
||||||
<span class="badge rounded-pill z-3 pe-none bg-secondary me-2 position-absolute top-0 left-0">{{ toasts.length }}</span>
|
<span class="badge rounded-pill z-3 pe-none bg-secondary me-2 position-absolute top-0 left-0">{{ toasts.length }}</span>
|
||||||
}
|
}
|
||||||
|
@@ -1,35 +0,0 @@
|
|||||||
|
|
||||||
<li ngbDropdown class="nav-item me-n2" (openChange)="onOpenChange($event)">
|
|
||||||
<button class="btn border-0" id="chatDropdown" ngbDropdownToggle>
|
|
||||||
<i-bs width="1.3em" height="1.3em" name="chatSquareDots"></i-bs>
|
|
||||||
</button>
|
|
||||||
<div ngbDropdownMenu class="dropdown-menu-end shadow p-3" aria-labelledby="chatDropdown">
|
|
||||||
<div class="chat-container bg-light p-2">
|
|
||||||
<div class="chat-messages font-monospace small">
|
|
||||||
@for (message of messages; track message) {
|
|
||||||
<div class="message d-flex flex-row small" [class.justify-content-end]="message.role === 'user'">
|
|
||||||
<span class="p-2 m-2" [class.bg-dark]="message.role === 'user'">
|
|
||||||
{{ message.content }}
|
|
||||||
@if (message.isStreaming) { <span class="blinking-cursor">|</span> }
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
<div #scrollAnchor></div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form class="chat-input">
|
|
||||||
<div class="input-group">
|
|
||||||
<input
|
|
||||||
#chatInput
|
|
||||||
class="form-control form-control-sm" name="chatInput" type="text"
|
|
||||||
[placeholder]="placeholder"
|
|
||||||
[disabled]="loading"
|
|
||||||
[(ngModel)]="input"
|
|
||||||
(keydown)="searchInputKeyDown($event)"
|
|
||||||
/>
|
|
||||||
<button class="btn btn-sm btn-secondary" type="button" (click)="sendMessage()" [disabled]="loading">Send</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
@@ -1,37 +0,0 @@
|
|||||||
.dropdown-menu {
|
|
||||||
width: var(--pngx-toast-max-width);
|
|
||||||
}
|
|
||||||
|
|
||||||
.chat-messages {
|
|
||||||
max-height: 350px;
|
|
||||||
overflow-y: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dropdown-toggle::after {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.dropdown-item {
|
|
||||||
white-space: initial;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media screen and (max-width: 400px) {
|
|
||||||
:host ::ng-deep .dropdown-menu-end {
|
|
||||||
right: -3rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.blinking-cursor {
|
|
||||||
font-weight: bold;
|
|
||||||
font-size: 1.2em;
|
|
||||||
animation: blink 1s step-end infinite;
|
|
||||||
}
|
|
||||||
|
|
||||||
@keyframes blink {
|
|
||||||
from, to {
|
|
||||||
opacity: 0;
|
|
||||||
}
|
|
||||||
50% {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
}
|
|
@@ -1,132 +0,0 @@
|
|||||||
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
|
|
||||||
import { provideHttpClientTesting } from '@angular/common/http/testing'
|
|
||||||
import { ElementRef } from '@angular/core'
|
|
||||||
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
|
||||||
import { NavigationEnd, Router } from '@angular/router'
|
|
||||||
import { allIcons, NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
|
||||||
import { Subject } from 'rxjs'
|
|
||||||
import { ChatService } from 'src/app/services/chat.service'
|
|
||||||
import { ChatComponent } from './chat.component'
|
|
||||||
|
|
||||||
describe('ChatComponent', () => {
|
|
||||||
let component: ChatComponent
|
|
||||||
let fixture: ComponentFixture<ChatComponent>
|
|
||||||
let chatService: ChatService
|
|
||||||
let router: Router
|
|
||||||
let routerEvents$: Subject<NavigationEnd>
|
|
||||||
let mockStream$: Subject<string>
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
TestBed.configureTestingModule({
|
|
||||||
imports: [NgxBootstrapIconsModule.pick(allIcons), ChatComponent],
|
|
||||||
providers: [
|
|
||||||
provideHttpClient(withInterceptorsFromDi()),
|
|
||||||
provideHttpClientTesting(),
|
|
||||||
],
|
|
||||||
}).compileComponents()
|
|
||||||
|
|
||||||
fixture = TestBed.createComponent(ChatComponent)
|
|
||||||
router = TestBed.inject(Router)
|
|
||||||
routerEvents$ = new Subject<any>()
|
|
||||||
jest
|
|
||||||
.spyOn(router, 'events', 'get')
|
|
||||||
.mockReturnValue(routerEvents$.asObservable())
|
|
||||||
chatService = TestBed.inject(ChatService)
|
|
||||||
mockStream$ = new Subject<string>()
|
|
||||||
jest
|
|
||||||
.spyOn(chatService, 'streamChat')
|
|
||||||
.mockReturnValue(mockStream$.asObservable())
|
|
||||||
component = fixture.componentInstance
|
|
||||||
|
|
||||||
jest.useFakeTimers()
|
|
||||||
|
|
||||||
fixture.detectChanges()
|
|
||||||
|
|
||||||
component.scrollAnchor.nativeElement.scrollIntoView = jest.fn()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should update documentId on initialization', () => {
|
|
||||||
jest.spyOn(router, 'url', 'get').mockReturnValue('/documents/123')
|
|
||||||
component.ngOnInit()
|
|
||||||
expect(component.documentId).toBe(123)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should update documentId on navigation', () => {
|
|
||||||
component.ngOnInit()
|
|
||||||
routerEvents$.next(new NavigationEnd(1, '/documents/456', '/documents/456'))
|
|
||||||
expect(component.documentId).toBe(456)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should return correct placeholder based on documentId', () => {
|
|
||||||
component.documentId = 123
|
|
||||||
expect(component.placeholder).toBe('Ask a question about this document...')
|
|
||||||
component.documentId = undefined
|
|
||||||
expect(component.placeholder).toBe('Ask a question about a document...')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should send a message and handle streaming response', () => {
|
|
||||||
component.input = 'Hello'
|
|
||||||
component.sendMessage()
|
|
||||||
|
|
||||||
expect(component.messages.length).toBe(2)
|
|
||||||
expect(component.messages[0].content).toBe('Hello')
|
|
||||||
expect(component.loading).toBe(true)
|
|
||||||
|
|
||||||
mockStream$.next('Hi')
|
|
||||||
expect(component.messages[1].content).toBe('H')
|
|
||||||
mockStream$.next('Hi there')
|
|
||||||
// advance time to process the typewriter effect
|
|
||||||
jest.advanceTimersByTime(1000)
|
|
||||||
expect(component.messages[1].content).toBe('Hi there')
|
|
||||||
|
|
||||||
mockStream$.complete()
|
|
||||||
expect(component.loading).toBe(false)
|
|
||||||
expect(component.messages[1].isStreaming).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should handle errors during streaming', () => {
|
|
||||||
component.input = 'Hello'
|
|
||||||
component.sendMessage()
|
|
||||||
|
|
||||||
mockStream$.error('Error')
|
|
||||||
expect(component.messages[1].content).toContain(
|
|
||||||
'⚠️ Error receiving response.'
|
|
||||||
)
|
|
||||||
expect(component.loading).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should enqueue typewriter chunks correctly', () => {
|
|
||||||
const message = { content: '', role: 'assistant', isStreaming: true }
|
|
||||||
component.enqueueTypewriter(null, message as any) // coverage for null
|
|
||||||
component.enqueueTypewriter('Hello', message as any)
|
|
||||||
expect(component['typewriterBuffer'].length).toBe(4)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should scroll to bottom after sending a message', () => {
|
|
||||||
const scrollSpy = jest.spyOn(
|
|
||||||
ChatComponent.prototype as any,
|
|
||||||
'scrollToBottom'
|
|
||||||
)
|
|
||||||
component.input = 'Test'
|
|
||||||
component.sendMessage()
|
|
||||||
expect(scrollSpy).toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should focus chat input when dropdown is opened', () => {
|
|
||||||
const focus = jest.fn()
|
|
||||||
component.chatInput = {
|
|
||||||
nativeElement: { focus: focus },
|
|
||||||
} as unknown as ElementRef<HTMLInputElement>
|
|
||||||
|
|
||||||
component.onOpenChange(true)
|
|
||||||
jest.advanceTimersByTime(15)
|
|
||||||
expect(focus).toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should send message on Enter key press', () => {
|
|
||||||
jest.spyOn(component, 'sendMessage')
|
|
||||||
const event = new KeyboardEvent('keydown', { key: 'Enter' })
|
|
||||||
component.searchInputKeyDown(event)
|
|
||||||
expect(component.sendMessage).toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
})
|
|
@@ -1,140 +0,0 @@
|
|||||||
import { Component, ElementRef, inject, OnInit, ViewChild } from '@angular/core'
|
|
||||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
|
||||||
import { NavigationEnd, Router } from '@angular/router'
|
|
||||||
import { NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap'
|
|
||||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
|
||||||
import { filter, map } from 'rxjs'
|
|
||||||
import { ChatMessage, ChatService } from 'src/app/services/chat.service'
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: 'pngx-chat',
|
|
||||||
imports: [
|
|
||||||
FormsModule,
|
|
||||||
ReactiveFormsModule,
|
|
||||||
NgxBootstrapIconsModule,
|
|
||||||
NgbDropdownModule,
|
|
||||||
],
|
|
||||||
templateUrl: './chat.component.html',
|
|
||||||
styleUrl: './chat.component.scss',
|
|
||||||
})
|
|
||||||
export class ChatComponent implements OnInit {
|
|
||||||
public messages: ChatMessage[] = []
|
|
||||||
public loading = false
|
|
||||||
public input: string = ''
|
|
||||||
public documentId!: number
|
|
||||||
|
|
||||||
private chatService: ChatService = inject(ChatService)
|
|
||||||
private router: Router = inject(Router)
|
|
||||||
|
|
||||||
@ViewChild('scrollAnchor') scrollAnchor!: ElementRef<HTMLDivElement>
|
|
||||||
@ViewChild('chatInput') chatInput!: ElementRef<HTMLInputElement>
|
|
||||||
|
|
||||||
private typewriterBuffer: string[] = []
|
|
||||||
private typewriterActive = false
|
|
||||||
|
|
||||||
public get placeholder(): string {
|
|
||||||
return this.documentId
|
|
||||||
? $localize`Ask a question about this document...`
|
|
||||||
: $localize`Ask a question about a document...`
|
|
||||||
}
|
|
||||||
|
|
||||||
ngOnInit(): void {
|
|
||||||
this.updateDocumentId(this.router.url)
|
|
||||||
this.router.events
|
|
||||||
.pipe(
|
|
||||||
filter((event) => event instanceof NavigationEnd),
|
|
||||||
map((event) => (event as NavigationEnd).url)
|
|
||||||
)
|
|
||||||
.subscribe((url) => {
|
|
||||||
this.updateDocumentId(url)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
private updateDocumentId(url: string): void {
|
|
||||||
const docIdRe = url.match(/^\/documents\/(\d+)/)
|
|
||||||
this.documentId = docIdRe ? +docIdRe[1] : undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
sendMessage(): void {
|
|
||||||
if (!this.input.trim()) return
|
|
||||||
|
|
||||||
const userMessage: ChatMessage = { role: 'user', content: this.input }
|
|
||||||
this.messages.push(userMessage)
|
|
||||||
this.scrollToBottom()
|
|
||||||
|
|
||||||
const assistantMessage: ChatMessage = {
|
|
||||||
role: 'assistant',
|
|
||||||
content: '',
|
|
||||||
isStreaming: true,
|
|
||||||
}
|
|
||||||
this.messages.push(assistantMessage)
|
|
||||||
this.loading = true
|
|
||||||
|
|
||||||
let lastPartialLength = 0
|
|
||||||
|
|
||||||
this.chatService.streamChat(this.documentId, this.input).subscribe({
|
|
||||||
next: (chunk) => {
|
|
||||||
const delta = chunk.substring(lastPartialLength)
|
|
||||||
lastPartialLength = chunk.length
|
|
||||||
this.enqueueTypewriter(delta, assistantMessage)
|
|
||||||
},
|
|
||||||
error: () => {
|
|
||||||
assistantMessage.content += '\n\n⚠️ Error receiving response.'
|
|
||||||
assistantMessage.isStreaming = false
|
|
||||||
this.loading = false
|
|
||||||
},
|
|
||||||
complete: () => {
|
|
||||||
assistantMessage.isStreaming = false
|
|
||||||
this.loading = false
|
|
||||||
this.scrollToBottom()
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
this.input = ''
|
|
||||||
}
|
|
||||||
|
|
||||||
enqueueTypewriter(chunk: string, message: ChatMessage): void {
|
|
||||||
if (!chunk) return
|
|
||||||
|
|
||||||
this.typewriterBuffer.push(...chunk.split(''))
|
|
||||||
|
|
||||||
if (!this.typewriterActive) {
|
|
||||||
this.typewriterActive = true
|
|
||||||
this.playTypewriter(message)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
playTypewriter(message: ChatMessage): void {
|
|
||||||
if (this.typewriterBuffer.length === 0) {
|
|
||||||
this.typewriterActive = false
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const nextChar = this.typewriterBuffer.shift()
|
|
||||||
message.content += nextChar
|
|
||||||
this.scrollToBottom()
|
|
||||||
|
|
||||||
setTimeout(() => this.playTypewriter(message), 10) // 10ms per character
|
|
||||||
}
|
|
||||||
|
|
||||||
private scrollToBottom(): void {
|
|
||||||
setTimeout(() => {
|
|
||||||
this.scrollAnchor?.nativeElement?.scrollIntoView({ behavior: 'smooth' })
|
|
||||||
}, 50)
|
|
||||||
}
|
|
||||||
|
|
||||||
public onOpenChange(open: boolean): void {
|
|
||||||
if (open) {
|
|
||||||
setTimeout(() => {
|
|
||||||
this.chatInput.nativeElement.focus()
|
|
||||||
}, 10)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public searchInputKeyDown(event: KeyboardEvent) {
|
|
||||||
if (event.key === 'Enter') {
|
|
||||||
event.preventDefault()
|
|
||||||
this.sendMessage()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
@@ -35,9 +35,6 @@
|
|||||||
@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, SlicePipe } from '@angular/common'
|
import { CurrencyPipe, getLocaleCurrencyCode } from '@angular/common'
|
||||||
import { Component, inject, Input, LOCALE_ID, OnInit } from '@angular/core'
|
import { Component, Input, LOCALE_ID, OnInit, inject } 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, SlicePipe],
|
imports: [CustomDatePipe, CurrencyPipe, NgbTooltipModule],
|
||||||
})
|
})
|
||||||
export class CustomFieldDisplayComponent
|
export class CustomFieldDisplayComponent
|
||||||
extends LoadingComponentWithPermissions
|
extends LoadingComponentWithPermissions
|
||||||
|
@@ -1,7 +1,7 @@
|
|||||||
<div ngbDropdown #fieldDropdown="ngbDropdown" (openChange)="onOpenClose($event)" [popperOptions]="popperOptions">
|
<div ngbDropdown #fieldDropdown="ngbDropdown" (openChange)="onOpenClose($event)" [popperOptions]="popperOptions" placement="bottom-end">
|
||||||
<button type="button" class="btn btn-sm btn-outline-primary" id="customFieldsDropdown" [disabled]="disabled" ngbDropdownToggle>
|
<button class="btn btn-sm btn-outline-primary" id="customFieldsDropdown" [disabled]="disabled" ngbDropdownToggle>
|
||||||
<i-bs name="ui-radios"></i-bs>
|
<i-bs name="ui-radios"></i-bs>
|
||||||
<div class="d-none d-lg-inline"> <ng-container i18n>Custom Fields</ng-container></div>
|
<div class="d-none d-sm-inline"> <ng-container i18n>Custom Fields</ng-container></div>
|
||||||
</button>
|
</button>
|
||||||
<div ngbDropdownMenu aria-labelledby="customFieldsDropdown" class="shadow custom-fields-dropdown">
|
<div ngbDropdownMenu aria-labelledby="customFieldsDropdown" class="shadow custom-fields-dropdown">
|
||||||
<div class="list-group list-group-flush" (keydown)="listKeyDown($event)">
|
<div class="list-group list-group-flush" (keydown)="listKeyDown($event)">
|
||||||
|
@@ -177,16 +177,10 @@ export class CustomFieldEditDialogComponent
|
|||||||
}
|
}
|
||||||
|
|
||||||
public removeSelectOption(index: number) {
|
public removeSelectOption(index: number) {
|
||||||
const globalIndex =
|
this.selectOptions.removeAt(index)
|
||||||
index + (this.selectOptionsPage - 1) * SELECT_OPTION_PAGE_SIZE
|
this._allSelectOptions.splice(
|
||||||
this._allSelectOptions.splice(globalIndex, 1)
|
index + (this.selectOptionsPage - 1) * SELECT_OPTION_PAGE_SIZE,
|
||||||
|
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,8 +12,6 @@
|
|||||||
|
|
||||||
<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,16 +35,11 @@ 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() {
|
||||||
@@ -60,7 +55,6 @@ 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),
|
||||||
|
@@ -177,7 +177,6 @@
|
|||||||
<pngx-input-tags [allowCreate]="false" i18n-title title="Has any of tags" formControlName="filter_has_tags"></pngx-input-tags>
|
<pngx-input-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 correspondent" [items]="correspondents" [allowNull]="true" formControlName="filter_has_correspondent"></pngx-input-select>
|
||||||
<pngx-input-select i18n-title title="Has document type" [items]="documentTypes" [allowNull]="true" formControlName="filter_has_document_type"></pngx-input-select>
|
<pngx-input-select i18n-title title="Has document type" [items]="documentTypes" [allowNull]="true" formControlName="filter_has_document_type"></pngx-input-select>
|
||||||
<pngx-input-select i18n-title title="Has storage path" [items]="storagePaths" [allowNull]="true" formControlName="filter_has_storage_path"></pngx-input-select>
|
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
@@ -412,9 +412,6 @@ export class WorkflowEditDialogComponent
|
|||||||
filter_has_document_type: new FormControl(
|
filter_has_document_type: new FormControl(
|
||||||
trigger.filter_has_document_type
|
trigger.filter_has_document_type
|
||||||
),
|
),
|
||||||
filter_has_storage_path: new FormControl(
|
|
||||||
trigger.filter_has_storage_path
|
|
||||||
),
|
|
||||||
schedule_offset_days: new FormControl(trigger.schedule_offset_days),
|
schedule_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(
|
||||||
@@ -539,7 +536,6 @@ export class WorkflowEditDialogComponent
|
|||||||
filter_has_tags: [],
|
filter_has_tags: [],
|
||||||
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,
|
||||||
|
@@ -114,13 +114,6 @@ 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,18 +15,13 @@
|
|||||||
<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 name-cell" [style.--depth]="isTag ? getDepth() + 1 : 1">
|
<div class="me-1">
|
||||||
@if (isTag && getDepth() > 0) {
|
|
||||||
<div class="indicator"></div>
|
|
||||||
}
|
|
||||||
<div>
|
|
||||||
@if (isTag) {
|
@if (isTag) {
|
||||||
<pngx-tag [tag]="item" [clickable]="false"></pngx-tag>
|
<pngx-tag [tag]="item" [clickable]="false"></pngx-tag>
|
||||||
} @else {
|
} @else {
|
||||||
<small>{{item.name}}</small>
|
<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,19 +2,3 @@
|
|||||||
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,7 +1,6 @@
|
|||||||
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 {
|
||||||
@@ -46,10 +45,6 @@ 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
|
||||||
}
|
}
|
||||||
|
@@ -68,11 +68,6 @@
|
|||||||
[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,7 +24,6 @@ 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({
|
||||||
@@ -52,7 +51,6 @@ 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> {
|
||||||
|
@@ -1,11 +1,5 @@
|
|||||||
<div class="mb-3" [class.pb-3]="error">
|
<div class="mb-3">
|
||||||
<div class="row">
|
<label class="form-label" [for]="inputId">{{title}}</label>
|
||||||
<div class="d-flex align-items-center position-relative hidden-button-container" [class.col-md-3]="horizontal">
|
|
||||||
@if (title) {
|
|
||||||
<label class="form-label" [class.mb-md-0]="horizontal" [for]="inputId">{{title}}</label>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
<div class="position-relative" [class.col-md-9]="horizontal">
|
|
||||||
<div class="input-group" [class.is-invalid]="error">
|
<div class="input-group" [class.is-invalid]="error">
|
||||||
<input #inputField [type]="showReveal && textVisible ? 'text' : 'password'" class="form-control" [class.is-invalid]="error" [id]="inputId" [(ngModel)]="value" (focus)="onFocus()" (focusout)="onFocusOut()" (change)="onChange(value)" [disabled]="disabled" [autocomplete]="autocomplete">
|
<input #inputField [type]="showReveal && textVisible ? 'text' : 'password'" class="form-control" [class.is-invalid]="error" [id]="inputId" [(ngModel)]="value" (focus)="onFocus()" (focusout)="onFocusOut()" (change)="onChange(value)" [disabled]="disabled" [autocomplete]="autocomplete">
|
||||||
@if (showReveal) {
|
@if (showReveal) {
|
||||||
@@ -21,4 +15,3 @@
|
|||||||
<small class="form-text text-muted" [innerHTML]="hint | safeHtml"></small>
|
<small class="form-text text-muted" [innerHTML]="hint | safeHtml"></small>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
|
@@ -7,14 +7,13 @@
|
|||||||
<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]="multiple"
|
[multiple]="true"
|
||||||
[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">
|
||||||
@@ -26,20 +25,9 @@
|
|||||||
</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-option-row d-flex align-items-center">
|
<div class="tag-wrap">
|
||||||
@if (item.id && tags) {
|
@if (item.id && tags) {
|
||||||
@if (getTag(item.id)?.parent) {
|
<pngx-tag class="me-2" [tag]="getTag(item.id)"></pngx-tag>
|
||||||
<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,33 +20,3 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Dropdown hierarchy reveal for ng-select options
|
|
||||||
::ng-deep .ng-dropdown-panel .ng-option {
|
|
||||||
overflow-x: scroll;
|
|
||||||
|
|
||||||
.tag-option-row {
|
|
||||||
font-size: 1rem;
|
|
||||||
width: max-content;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hierarchy-reveal {
|
|
||||||
overflow: hidden;
|
|
||||||
max-width: 0;
|
|
||||||
transition: max-width 200ms ease;
|
|
||||||
}
|
|
||||||
|
|
||||||
.parents .badge {
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
::ng-deep .ng-dropdown-panel .ng-option:hover .hierarchy-reveal,
|
|
||||||
::ng-deep .ng-dropdown-panel .ng-option.ng-option-marked .hierarchy-reveal {
|
|
||||||
max-width: 1000px;
|
|
||||||
}
|
|
||||||
|
|
||||||
::ng-deep .ng-dropdown-panel .ng-option:hover .hierarchy-indicator,
|
|
||||||
::ng-deep .ng-dropdown-panel .ng-option.ng-option-marked .hierarchy-indicator {
|
|
||||||
background: transparent;
|
|
||||||
}
|
|
||||||
|
@@ -177,59 +177,4 @@ describe('TagsComponent', () => {
|
|||||||
component.onFilterDocuments()
|
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,9 +100,6 @@ 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[]>()
|
||||||
|
|
||||||
@@ -127,40 +124,13 @@ 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',
|
||||||
@@ -196,7 +166,6 @@ 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)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -211,20 +180,4 @@ 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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@@ -15,12 +15,6 @@
|
|||||||
@if (hint) {
|
@if (hint) {
|
||||||
<small class="form-text text-muted" [innerHTML]="hint | safeHtml"></small>
|
<small class="form-text text-muted" [innerHTML]="hint | safeHtml"></small>
|
||||||
}
|
}
|
||||||
@if (getSuggestion()?.length > 0) {
|
|
||||||
<small>
|
|
||||||
<span i18n>Suggestion:</span>
|
|
||||||
<a (click)="applySuggestion(s)" [routerLink]="[]">{{getSuggestion()}}</a>
|
|
||||||
</small>
|
|
||||||
}
|
|
||||||
<div class="invalid-feedback position-absolute top-100">
|
<div class="invalid-feedback position-absolute top-100">
|
||||||
{{error}}
|
{{error}}
|
||||||
</div>
|
</div>
|
||||||
|
@@ -26,20 +26,10 @@ describe('TextComponent', () => {
|
|||||||
|
|
||||||
it('should support use of input field', () => {
|
it('should support use of input field', () => {
|
||||||
expect(component.value).toBeUndefined()
|
expect(component.value).toBeUndefined()
|
||||||
input.value = 'foo'
|
// TODO: why doesn't this work?
|
||||||
input.dispatchEvent(new Event('input'))
|
// input.value = 'foo'
|
||||||
fixture.detectChanges()
|
// input.dispatchEvent(new Event('change'))
|
||||||
expect(component.value).toBe('foo')
|
// fixture.detectChanges()
|
||||||
})
|
// expect(component.value).toEqual('foo')
|
||||||
|
|
||||||
it('should support suggestion', () => {
|
|
||||||
component.value = 'foo'
|
|
||||||
component.suggestion = 'foo'
|
|
||||||
expect(component.getSuggestion()).toBe('')
|
|
||||||
component.value = 'bar'
|
|
||||||
expect(component.getSuggestion()).toBe('foo')
|
|
||||||
component.applySuggestion()
|
|
||||||
fixture.detectChanges()
|
|
||||||
expect(component.value).toBe('foo')
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
@@ -4,7 +4,6 @@ import {
|
|||||||
NG_VALUE_ACCESSOR,
|
NG_VALUE_ACCESSOR,
|
||||||
ReactiveFormsModule,
|
ReactiveFormsModule,
|
||||||
} from '@angular/forms'
|
} from '@angular/forms'
|
||||||
import { RouterLink } from '@angular/router'
|
|
||||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
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'
|
||||||
@@ -25,7 +24,6 @@ import { AbstractInputComponent } from '../abstract-input'
|
|||||||
ReactiveFormsModule,
|
ReactiveFormsModule,
|
||||||
SafeHtmlPipe,
|
SafeHtmlPipe,
|
||||||
NgxBootstrapIconsModule,
|
NgxBootstrapIconsModule,
|
||||||
RouterLink,
|
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class TextComponent extends AbstractInputComponent<string> {
|
export class TextComponent extends AbstractInputComponent<string> {
|
||||||
@@ -35,19 +33,7 @@ export class TextComponent extends AbstractInputComponent<string> {
|
|||||||
@Input()
|
@Input()
|
||||||
placeholder: string = ''
|
placeholder: string = ''
|
||||||
|
|
||||||
@Input()
|
|
||||||
suggestion: string = ''
|
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super()
|
super()
|
||||||
}
|
}
|
||||||
|
|
||||||
getSuggestion() {
|
|
||||||
return this.value !== this.suggestion ? this.suggestion : ''
|
|
||||||
}
|
|
||||||
|
|
||||||
applySuggestion() {
|
|
||||||
this.value = this.suggestion
|
|
||||||
this.onChange(this.value)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
@@ -4,7 +4,6 @@ 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'
|
||||||
|
|
||||||
@@ -19,12 +18,7 @@ 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: [
|
imports: [FormsModule, ReactiveFormsModule, SafeHtmlPipe],
|
||||||
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, true); $event.stopPropagation()" title="Rotate page counter-clockwise" i18n-title>
|
<button class="btn btn-sm btn-dark" (click)="rotate(i); $event.stopPropagation()" title="Rotate page counter-clockwise" i18n-title>
|
||||||
<i-bs name="arrow-counterclockwise"></i-bs>
|
<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,9 +67,8 @@ export class PDFEditorComponent extends ConfirmDialogComponent {
|
|||||||
this.pages[i].selected = !this.pages[i].selected
|
this.pages[i].selected = !this.pages[i].selected
|
||||||
}
|
}
|
||||||
|
|
||||||
rotate(i: number, counterclockwise: boolean = false) {
|
rotate(i: number) {
|
||||||
this.pages[i].rotate =
|
this.pages[i].rotate = (this.pages[i].rotate + 90) % 360
|
||||||
(this.pages[i].rotate + (counterclockwise ? -90 : 90) + 360) % 360
|
|
||||||
}
|
}
|
||||||
|
|
||||||
rotateSelected(dir: number) {
|
rotateSelected(dir: number) {
|
||||||
|
@@ -1,49 +0,0 @@
|
|||||||
<div class="btn-group">
|
|
||||||
<button type="button" class="btn btn-sm btn-outline-primary" (click)="clickSuggest()" [disabled]="loading || (suggestions && !aiEnabled)">
|
|
||||||
@if (loading) {
|
|
||||||
<div class="spinner-border spinner-border-sm" role="status"></div>
|
|
||||||
} @else {
|
|
||||||
<i-bs width="1.2em" height="1.2em" name="stars"></i-bs>
|
|
||||||
}
|
|
||||||
<span class="d-none d-lg-inline ps-1" i18n>Suggest</span>
|
|
||||||
@if (totalSuggestions > 0) {
|
|
||||||
<span class="badge bg-primary ms-2">{{ totalSuggestions }}</span>
|
|
||||||
}
|
|
||||||
</button>
|
|
||||||
|
|
||||||
@if (aiEnabled) {
|
|
||||||
<div class="btn-group" ngbDropdown #dropdown="ngbDropdown" [popperOptions]="popperOptions">
|
|
||||||
<button type="button" class="btn btn-sm btn-outline-primary" ngbDropdownToggle [disabled]="loading || !suggestions" aria-expanded="false" aria-controls="suggestionsDropdown" aria-label="Suggestions dropdown">
|
|
||||||
<span class="visually-hidden" i18n>Show suggestions</span>
|
|
||||||
</button>
|
|
||||||
|
|
||||||
<div ngbDropdownMenu aria-labelledby="suggestionsDropdown" class="shadow suggestions-dropdown">
|
|
||||||
<div class="list-group list-group-flush small pb-0">
|
|
||||||
@if (!suggestions?.suggested_tags && !suggestions?.suggested_document_types && !suggestions?.suggested_correspondents) {
|
|
||||||
<div class="list-group-item text-muted fst-italic">
|
|
||||||
<small class="text-muted small fst-italic" i18n>No novel suggestions</small>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
@if (suggestions?.suggested_tags.length > 0) {
|
|
||||||
<small class="list-group-item text-uppercase text-muted small">Tags</small>
|
|
||||||
@for (tag of suggestions.suggested_tags; track tag) {
|
|
||||||
<button type="button" class="list-group-item list-group-item-action bg-light" (click)="addTag.emit(tag)" i18n>{{ tag }}</button>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@if (suggestions?.suggested_document_types.length > 0) {
|
|
||||||
<div class="list-group-item text-uppercase text-muted small">Document Types</div>
|
|
||||||
@for (type of suggestions.suggested_document_types; track type) {
|
|
||||||
<button type="button" class="list-group-item list-group-item-action bg-light" (click)="addDocumentType.emit(type)" i18n>{{ type }}</button>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@if (suggestions?.suggested_correspondents.length > 0) {
|
|
||||||
<div class="list-group-item text-uppercase text-muted small">Correspondents</div>
|
|
||||||
@for (correspondent of suggestions.suggested_correspondents; track correspondent) {
|
|
||||||
<button type="button" class="list-group-item list-group-item-action bg-light" (click)="addCorrespondent.emit(correspondent)" i18n>{{ correspondent }}</button>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
@@ -1,3 +0,0 @@
|
|||||||
.suggestions-dropdown {
|
|
||||||
min-width: 250px;
|
|
||||||
}
|
|
@@ -1,51 +0,0 @@
|
|||||||
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
|
||||||
import { NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap'
|
|
||||||
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
|
||||||
import { SuggestionsDropdownComponent } from './suggestions-dropdown.component'
|
|
||||||
|
|
||||||
describe('SuggestionsDropdownComponent', () => {
|
|
||||||
let component: SuggestionsDropdownComponent
|
|
||||||
let fixture: ComponentFixture<SuggestionsDropdownComponent>
|
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
TestBed.configureTestingModule({
|
|
||||||
imports: [
|
|
||||||
NgbDropdownModule,
|
|
||||||
NgxBootstrapIconsModule.pick(allIcons),
|
|
||||||
SuggestionsDropdownComponent,
|
|
||||||
],
|
|
||||||
providers: [],
|
|
||||||
})
|
|
||||||
fixture = TestBed.createComponent(SuggestionsDropdownComponent)
|
|
||||||
component = fixture.componentInstance
|
|
||||||
fixture.detectChanges()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should calculate totalSuggestions', () => {
|
|
||||||
component.suggestions = {
|
|
||||||
suggested_correspondents: ['John Doe'],
|
|
||||||
suggested_tags: ['Tag1', 'Tag2'],
|
|
||||||
suggested_document_types: ['Type1'],
|
|
||||||
}
|
|
||||||
expect(component.totalSuggestions).toBe(4)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should emit getSuggestions when clickSuggest is called and suggestions are null', () => {
|
|
||||||
jest.spyOn(component.getSuggestions, 'emit')
|
|
||||||
component.suggestions = null
|
|
||||||
component.clickSuggest()
|
|
||||||
expect(component.getSuggestions.emit).toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should toggle dropdown when clickSuggest is called and suggestions are not null', () => {
|
|
||||||
component.aiEnabled = true
|
|
||||||
fixture.detectChanges()
|
|
||||||
component.suggestions = {
|
|
||||||
suggested_correspondents: [],
|
|
||||||
suggested_tags: [],
|
|
||||||
suggested_document_types: [],
|
|
||||||
}
|
|
||||||
component.clickSuggest()
|
|
||||||
expect(component.dropdown.open).toBeTruthy()
|
|
||||||
})
|
|
||||||
})
|
|
@@ -1,64 +0,0 @@
|
|||||||
import {
|
|
||||||
Component,
|
|
||||||
EventEmitter,
|
|
||||||
Input,
|
|
||||||
Output,
|
|
||||||
ViewChild,
|
|
||||||
} from '@angular/core'
|
|
||||||
import { NgbDropdown, NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap'
|
|
||||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
|
||||||
import { DocumentSuggestions } from 'src/app/data/document-suggestions'
|
|
||||||
import { pngxPopperOptions } from 'src/app/utils/popper-options'
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: 'pngx-suggestions-dropdown',
|
|
||||||
imports: [NgbDropdownModule, NgxBootstrapIconsModule],
|
|
||||||
templateUrl: './suggestions-dropdown.component.html',
|
|
||||||
styleUrl: './suggestions-dropdown.component.scss',
|
|
||||||
})
|
|
||||||
export class SuggestionsDropdownComponent {
|
|
||||||
public popperOptions = pngxPopperOptions
|
|
||||||
|
|
||||||
@ViewChild('dropdown') dropdown: NgbDropdown
|
|
||||||
|
|
||||||
@Input()
|
|
||||||
suggestions: DocumentSuggestions = null
|
|
||||||
|
|
||||||
@Input()
|
|
||||||
aiEnabled: boolean = false
|
|
||||||
|
|
||||||
@Input()
|
|
||||||
loading: boolean = false
|
|
||||||
|
|
||||||
@Input()
|
|
||||||
disabled: boolean = false
|
|
||||||
|
|
||||||
@Output()
|
|
||||||
getSuggestions: EventEmitter<SuggestionsDropdownComponent> =
|
|
||||||
new EventEmitter()
|
|
||||||
|
|
||||||
@Output()
|
|
||||||
addTag: EventEmitter<string> = new EventEmitter()
|
|
||||||
|
|
||||||
@Output()
|
|
||||||
addDocumentType: EventEmitter<string> = new EventEmitter()
|
|
||||||
|
|
||||||
@Output()
|
|
||||||
addCorrespondent: EventEmitter<string> = new EventEmitter()
|
|
||||||
|
|
||||||
public clickSuggest(): void {
|
|
||||||
if (!this.suggestions) {
|
|
||||||
this.getSuggestions.emit(this)
|
|
||||||
} else {
|
|
||||||
this.dropdown?.toggle()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
get totalSuggestions(): number {
|
|
||||||
return (
|
|
||||||
this.suggestions?.suggested_correspondents?.length +
|
|
||||||
this.suggestions?.suggested_tags?.length +
|
|
||||||
this.suggestions?.suggested_document_types?.length || 0
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
@@ -266,43 +266,6 @@
|
|||||||
}
|
}
|
||||||
</span>
|
</span>
|
||||||
</dd>
|
</dd>
|
||||||
@if (aiEnabled) {
|
|
||||||
<dt i18n>AI Index</dt>
|
|
||||||
<dd class="d-flex align-items-center">
|
|
||||||
<button class="btn btn-sm d-flex align-items-center btn-dark text-uppercase small" [ngbPopover]="llmIndexStatus" triggers="click mouseenter:mouseleave">
|
|
||||||
{{status.tasks.llmindex_status}}
|
|
||||||
@if (status.tasks.llmindex_status === 'OK') {
|
|
||||||
@if (isStale(status.tasks.llmindex_last_modified)) {
|
|
||||||
<i-bs name="exclamation-triangle-fill" class="text-warning ms-2 lh-1"></i-bs>
|
|
||||||
} @else {
|
|
||||||
<i-bs name="check-circle-fill" class="text-primary ms-2 lh-1"></i-bs>
|
|
||||||
}
|
|
||||||
} @else {
|
|
||||||
<i-bs name="exclamation-triangle-fill" class="ms-2 lh-1"
|
|
||||||
[class.text-danger]="status.tasks.llmindex_status === SystemStatusItemStatus.ERROR"
|
|
||||||
[class.text-warning]="status.tasks.llmindex_status === SystemStatusItemStatus.WARNING"
|
|
||||||
[class.text-muted]="status.tasks.llmindex_status === SystemStatusItemStatus.DISABLED"></i-bs>
|
|
||||||
}
|
|
||||||
</button>
|
|
||||||
@if (currentUserIsSuperUser) {
|
|
||||||
@if (isRunning(PaperlessTaskName.LLMIndexUpdate)) {
|
|
||||||
<div class="spinner-border spinner-border-sm ms-2" role="status"></div>
|
|
||||||
} @else {
|
|
||||||
<button class="btn btn-sm d-flex align-items-center btn-dark small ms-2" (click)="runTask(PaperlessTaskName.LLMIndexUpdate)">
|
|
||||||
<i-bs name="play-fill"></i-bs>
|
|
||||||
<ng-container i18n>Run Task</ng-container>
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</dd>
|
|
||||||
<ng-template #llmIndexStatus>
|
|
||||||
@if (status.tasks.llmindex_status === 'OK') {
|
|
||||||
<h6><ng-container i18n>Last Run</ng-container>:</h6> <span class="font-monospace small">{{status.tasks.llmindex_last_modified | customDate:'medium'}}</span>
|
|
||||||
} @else {
|
|
||||||
<h6><ng-container i18n>Error</ng-container>:</h6> <span class="font-monospace small">{{status.tasks.llmindex_error}}</span>
|
|
||||||
}
|
|
||||||
</ng-template>
|
|
||||||
}
|
|
||||||
</dl>
|
</dl>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@@ -68,9 +68,6 @@ const status: SystemStatus = {
|
|||||||
sanity_check_status: SystemStatusItemStatus.OK,
|
sanity_check_status: SystemStatusItemStatus.OK,
|
||||||
sanity_check_last_run: new Date().toISOString(),
|
sanity_check_last_run: new Date().toISOString(),
|
||||||
sanity_check_error: null,
|
sanity_check_error: null,
|
||||||
llmindex_status: SystemStatusItemStatus.OK,
|
|
||||||
llmindex_last_modified: new Date().toISOString(),
|
|
||||||
llmindex_error: null,
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -13,11 +13,9 @@ import {
|
|||||||
SystemStatus,
|
SystemStatus,
|
||||||
SystemStatusItemStatus,
|
SystemStatusItemStatus,
|
||||||
} from 'src/app/data/system-status'
|
} from 'src/app/data/system-status'
|
||||||
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
|
||||||
import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
|
import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
|
||||||
import { FileSizePipe } from 'src/app/pipes/file-size.pipe'
|
import { FileSizePipe } from 'src/app/pipes/file-size.pipe'
|
||||||
import { PermissionsService } from 'src/app/services/permissions.service'
|
import { PermissionsService } from 'src/app/services/permissions.service'
|
||||||
import { SettingsService } from 'src/app/services/settings.service'
|
|
||||||
import { SystemStatusService } from 'src/app/services/system-status.service'
|
import { SystemStatusService } from 'src/app/services/system-status.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 { ToastService } from 'src/app/services/toast.service'
|
||||||
@@ -46,7 +44,6 @@ export class SystemStatusDialogComponent implements OnInit, OnDestroy {
|
|||||||
private toastService = inject(ToastService)
|
private toastService = inject(ToastService)
|
||||||
private permissionsService = inject(PermissionsService)
|
private permissionsService = inject(PermissionsService)
|
||||||
private websocketStatusService = inject(WebsocketStatusService)
|
private websocketStatusService = inject(WebsocketStatusService)
|
||||||
private settingsService = inject(SettingsService)
|
|
||||||
|
|
||||||
public SystemStatusItemStatus = SystemStatusItemStatus
|
public SystemStatusItemStatus = SystemStatusItemStatus
|
||||||
public PaperlessTaskName = PaperlessTaskName
|
public PaperlessTaskName = PaperlessTaskName
|
||||||
@@ -63,10 +60,6 @@ export class SystemStatusDialogComponent implements OnInit, OnDestroy {
|
|||||||
return this.permissionsService.isSuperUser()
|
return this.permissionsService.isSuperUser()
|
||||||
}
|
}
|
||||||
|
|
||||||
get aiEnabled(): boolean {
|
|
||||||
return this.settingsService.get(SETTINGS_KEYS.AI_ENABLED)
|
|
||||||
}
|
|
||||||
|
|
||||||
public ngOnInit() {
|
public ngOnInit() {
|
||||||
this.versionMismatch =
|
this.versionMismatch =
|
||||||
environment.production &&
|
environment.production &&
|
||||||
|
@@ -1,8 +1,4 @@
|
|||||||
@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,7 +50,4 @@ 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 text-break">{{toast.content}}</p>
|
<p class="ms-2 mb-0">{{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,10 +54,6 @@
|
|||||||
<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>
|
||||||
@@ -68,6 +64,16 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<pngx-custom-fields-dropdown
|
||||||
|
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.CustomField }"
|
||||||
|
[documentId]="documentId"
|
||||||
|
[disabled]="!userCanEdit"
|
||||||
|
[existingFields]="document?.custom_fields"
|
||||||
|
(created)="refreshCustomFields()"
|
||||||
|
(added)="addField($event)">
|
||||||
|
</pngx-custom-fields-dropdown>
|
||||||
|
|
||||||
|
|
||||||
<div class="ms-auto" ngbDropdown>
|
<div class="ms-auto" ngbDropdown>
|
||||||
<button class="btn btn-sm btn-outline-primary" id="sendDropdown" ngbDropdownToggle>
|
<button class="btn btn-sm btn-outline-primary" id="sendDropdown" ngbDropdownToggle>
|
||||||
<i-bs name="send"></i-bs>
|
<i-bs name="send"></i-bs>
|
||||||
@@ -88,7 +94,7 @@
|
|||||||
</pngx-page-header>
|
</pngx-page-header>
|
||||||
|
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-md-6 col-xl-5 mb-4">
|
<div class="col-md-6 col-xl-4 mb-4">
|
||||||
|
|
||||||
<form [formGroup]='documentForm' (ngSubmit)="save()">
|
<form [formGroup]='documentForm' (ngSubmit)="save()">
|
||||||
|
|
||||||
@@ -105,32 +111,6 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ng-container *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.Document }">
|
|
||||||
<div class="btn-group pb-3 ms-auto">
|
|
||||||
<pngx-suggestions-dropdown *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.Document }"
|
|
||||||
[disabled]="!userCanEdit || suggestionsLoading"
|
|
||||||
[loading]="suggestionsLoading"
|
|
||||||
[suggestions]="suggestions"
|
|
||||||
[aiEnabled]="aiEnabled"
|
|
||||||
(getSuggestions)="getSuggestions()"
|
|
||||||
(addTag)="createTag($event)"
|
|
||||||
(addDocumentType)="createDocumentType($event)"
|
|
||||||
(addCorrespondent)="createCorrespondent($event)">
|
|
||||||
</pngx-suggestions-dropdown>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="btn-group pb-3 ms-2">
|
|
||||||
<pngx-custom-fields-dropdown
|
|
||||||
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.CustomField }"
|
|
||||||
[documentId]="documentId"
|
|
||||||
[disabled]="!userCanEdit"
|
|
||||||
[existingFields]="document?.custom_fields"
|
|
||||||
(created)="refreshCustomFields()"
|
|
||||||
(added)="addField($event)">
|
|
||||||
</pngx-custom-fields-dropdown>
|
|
||||||
</div>
|
|
||||||
</ng-container>
|
|
||||||
|
|
||||||
<ng-container *ngTemplateOutlet="saveButtons"></ng-container>
|
<ng-container *ngTemplateOutlet="saveButtons"></ng-container>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -139,7 +119,7 @@
|
|||||||
<a ngbNavLink i18n>Details</a>
|
<a ngbNavLink i18n>Details</a>
|
||||||
<ng-template ngbNavContent>
|
<ng-template ngbNavContent>
|
||||||
<div>
|
<div>
|
||||||
<pngx-input-text #inputTitle i18n-title title="Title" formControlName="title" [horizontal]="true" [suggestion]="suggestions?.title" (keyup)="titleKeyUp($event)" [error]="error?.title"></pngx-input-text>
|
<pngx-input-text #inputTitle i18n-title title="Title" formControlName="title" [horizontal]="true" (keyup)="titleKeyUp($event)" [error]="error?.title"></pngx-input-text>
|
||||||
<pngx-input-number i18n-title title="Archive serial number" [error]="error?.archive_serial_number" [horizontal]="true" formControlName='archive_serial_number'></pngx-input-number>
|
<pngx-input-number i18n-title title="Archive serial number" [error]="error?.archive_serial_number" [horizontal]="true" formControlName='archive_serial_number'></pngx-input-number>
|
||||||
<pngx-input-date i18n-title title="Date created" formControlName="created" [suggestions]="suggestions?.dates" [showFilter]="true" [horizontal]="true" (filterDocuments)="filterDocuments($event)"
|
<pngx-input-date i18n-title title="Date created" formControlName="created" [suggestions]="suggestions?.dates" [showFilter]="true" [horizontal]="true" (filterDocuments)="filterDocuments($event)"
|
||||||
[error]="error?.created"></pngx-input-date>
|
[error]="error?.created"></pngx-input-date>
|
||||||
@@ -149,7 +129,7 @@
|
|||||||
(createNew)="createDocumentType($event)" [hideAddButton]="createDisabled(DataType.DocumentType)" [suggestions]="suggestions?.document_types" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.DocumentType }"></pngx-input-select>
|
(createNew)="createDocumentType($event)" [hideAddButton]="createDisabled(DataType.DocumentType)" [suggestions]="suggestions?.document_types" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.DocumentType }"></pngx-input-select>
|
||||||
<pngx-input-select [items]="storagePaths" i18n-title title="Storage path" formControlName="storage_path" [allowNull]="true" [showFilter]="true" [horizontal]="true" (filterDocuments)="filterDocuments($event, DataType.StoragePath)"
|
<pngx-input-select [items]="storagePaths" i18n-title title="Storage path" formControlName="storage_path" [allowNull]="true" [showFilter]="true" [horizontal]="true" (filterDocuments)="filterDocuments($event, DataType.StoragePath)"
|
||||||
(createNew)="createStoragePath($event)" [hideAddButton]="createDisabled(DataType.StoragePath)" [suggestions]="suggestions?.storage_paths" i18n-placeholder placeholder="Default" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.StoragePath }"></pngx-input-select>
|
(createNew)="createStoragePath($event)" [hideAddButton]="createDisabled(DataType.StoragePath)" [suggestions]="suggestions?.storage_paths" i18n-placeholder placeholder="Default" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.StoragePath }"></pngx-input-select>
|
||||||
<pngx-input-tags #tagsInput formControlName="tags" [suggestions]="suggestions?.tags" [showFilter]="true" [horizontal]="true" (filterDocuments)="filterDocuments($event, DataType.Tag)" [hideAddButton]="createDisabled(DataType.Tag)" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Tag }"></pngx-input-tags>
|
<pngx-input-tags formControlName="tags" [suggestions]="suggestions?.tags" [showFilter]="true" [horizontal]="true" (filterDocuments)="filterDocuments($event, DataType.Tag)" [hideAddButton]="createDisabled(DataType.Tag)" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Tag }"></pngx-input-tags>
|
||||||
@for (fieldInstance of document?.custom_fields; track fieldInstance.field; let i = $index) {
|
@for (fieldInstance of document?.custom_fields; track fieldInstance.field; let i = $index) {
|
||||||
<div [formGroup]="customFieldFormFields.controls[i]">
|
<div [formGroup]="customFieldFormFields.controls[i]">
|
||||||
@switch (getCustomFieldFromInstance(fieldInstance)?.data_type) {
|
@switch (getCustomFieldFromInstance(fieldInstance)?.data_type) {
|
||||||
@@ -232,14 +212,6 @@
|
|||||||
(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>
|
||||||
}
|
}
|
||||||
@@ -371,14 +343,14 @@
|
|||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-md-6 col-xl-7 mb-3 d-none d-md-block position-relative" #pdfPreview>
|
<div class="col-md-6 col-xl-8 mb-3 d-none d-md-block position-relative" #pdfPreview>
|
||||||
<ng-container *ngTemplateOutlet="previewContent"></ng-container>
|
<ng-container *ngTemplateOutlet="previewContent"></ng-container>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<ng-template #saveButtons>
|
<ng-template #saveButtons>
|
||||||
<div class="btn-group pb-3 ms-4">
|
<div class="btn-group pb-3 ms-auto">
|
||||||
<ng-container *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.Document }">
|
<ng-container *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.Document }">
|
||||||
<button type="submit" class="order-3 btn btn-sm btn-primary" i18n [disabled]="!userCanEdit || networkActive || (isDirty$ | async) !== true">Save</button>
|
<button type="submit" class="order-3 btn btn-sm btn-primary" i18n [disabled]="!userCanEdit || networkActive || (isDirty$ | async) !== true">Save</button>
|
||||||
@if (hasNext()) {
|
@if (hasNext()) {
|
||||||
|
@@ -156,16 +156,6 @@ describe('DocumentDetailComponent', () => {
|
|||||||
{
|
{
|
||||||
provide: TagService,
|
provide: TagService,
|
||||||
useValue: {
|
useValue: {
|
||||||
getCachedMany: (ids: number[]) =>
|
|
||||||
of(
|
|
||||||
ids.map((id) => ({
|
|
||||||
id,
|
|
||||||
name: `Tag${id}`,
|
|
||||||
is_inbox_tag: true,
|
|
||||||
color: '#ff0000',
|
|
||||||
text_color: '#000000',
|
|
||||||
}))
|
|
||||||
),
|
|
||||||
listAll: () =>
|
listAll: () =>
|
||||||
of({
|
of({
|
||||||
count: 3,
|
count: 3,
|
||||||
@@ -392,32 +382,8 @@ describe('DocumentDetailComponent', () => {
|
|||||||
currentUserCan = true
|
currentUserCan = true
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should support creating tag, remove from suggestions', () => {
|
it('should support creating document type', () => {
|
||||||
initNormally()
|
initNormally()
|
||||||
component.suggestions = {
|
|
||||||
suggested_tags: ['Tag1', 'NewTag12'],
|
|
||||||
}
|
|
||||||
let openModal: NgbModalRef
|
|
||||||
modalService.activeInstances.subscribe((modal) => (openModal = modal[0]))
|
|
||||||
const modalSpy = jest.spyOn(modalService, 'open')
|
|
||||||
component.createTag('NewTag12')
|
|
||||||
expect(modalSpy).toHaveBeenCalled()
|
|
||||||
openModal.componentInstance.succeeded.next({
|
|
||||||
id: 12,
|
|
||||||
name: 'NewTag12',
|
|
||||||
is_inbox_tag: true,
|
|
||||||
color: '#ff0000',
|
|
||||||
text_color: '#000000',
|
|
||||||
})
|
|
||||||
expect(component.tagsInput.value).toContain(12)
|
|
||||||
expect(component.suggestions.suggested_tags).not.toContain('NewTag12')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should support creating document type, remove from suggestions', () => {
|
|
||||||
initNormally()
|
|
||||||
component.suggestions = {
|
|
||||||
suggested_document_types: ['DocumentType1', 'NewDocType2'],
|
|
||||||
}
|
|
||||||
let openModal: NgbModalRef
|
let openModal: NgbModalRef
|
||||||
modalService.activeInstances.subscribe((modal) => (openModal = modal[0]))
|
modalService.activeInstances.subscribe((modal) => (openModal = modal[0]))
|
||||||
const modalSpy = jest.spyOn(modalService, 'open')
|
const modalSpy = jest.spyOn(modalService, 'open')
|
||||||
@@ -425,16 +391,10 @@ describe('DocumentDetailComponent', () => {
|
|||||||
expect(modalSpy).toHaveBeenCalled()
|
expect(modalSpy).toHaveBeenCalled()
|
||||||
openModal.componentInstance.succeeded.next({ id: 12, name: 'NewDocType12' })
|
openModal.componentInstance.succeeded.next({ id: 12, name: 'NewDocType12' })
|
||||||
expect(component.documentForm.get('document_type').value).toEqual(12)
|
expect(component.documentForm.get('document_type').value).toEqual(12)
|
||||||
expect(component.suggestions.suggested_document_types).not.toContain(
|
|
||||||
'NewDocType2'
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should support creating correspondent, remove from suggestions', () => {
|
it('should support creating correspondent', () => {
|
||||||
initNormally()
|
initNormally()
|
||||||
component.suggestions = {
|
|
||||||
suggested_correspondents: ['Correspondent1', 'NewCorrrespondent12'],
|
|
||||||
}
|
|
||||||
let openModal: NgbModalRef
|
let openModal: NgbModalRef
|
||||||
modalService.activeInstances.subscribe((modal) => (openModal = modal[0]))
|
modalService.activeInstances.subscribe((modal) => (openModal = modal[0]))
|
||||||
const modalSpy = jest.spyOn(modalService, 'open')
|
const modalSpy = jest.spyOn(modalService, 'open')
|
||||||
@@ -445,9 +405,6 @@ describe('DocumentDetailComponent', () => {
|
|||||||
name: 'NewCorrrespondent12',
|
name: 'NewCorrrespondent12',
|
||||||
})
|
})
|
||||||
expect(component.documentForm.get('correspondent').value).toEqual(12)
|
expect(component.documentForm.get('correspondent').value).toEqual(12)
|
||||||
expect(component.suggestions.suggested_correspondents).not.toContain(
|
|
||||||
'NewCorrrespondent12'
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should support creating storage path', () => {
|
it('should support creating storage path', () => {
|
||||||
@@ -1038,7 +995,7 @@ describe('DocumentDetailComponent', () => {
|
|||||||
expect(component.document.custom_fields).toHaveLength(initialLength - 1)
|
expect(component.document.custom_fields).toHaveLength(initialLength - 1)
|
||||||
expect(component.customFieldFormFields).toHaveLength(initialLength - 1)
|
expect(component.customFieldFormFields).toHaveLength(initialLength - 1)
|
||||||
expect(
|
expect(
|
||||||
fixture.debugElement.query(By.css('form ul')).nativeElement.textContent
|
fixture.debugElement.query(By.css('form')).nativeElement.textContent
|
||||||
).not.toContain('Field 1')
|
).not.toContain('Field 1')
|
||||||
const patchSpy = jest.spyOn(documentService, 'patch')
|
const patchSpy = jest.spyOn(documentService, 'patch')
|
||||||
component.save(true)
|
component.save(true)
|
||||||
@@ -1129,22 +1086,10 @@ describe('DocumentDetailComponent', () => {
|
|||||||
|
|
||||||
it('should get suggestions', () => {
|
it('should get suggestions', () => {
|
||||||
const suggestionsSpy = jest.spyOn(documentService, 'getSuggestions')
|
const suggestionsSpy = jest.spyOn(documentService, 'getSuggestions')
|
||||||
suggestionsSpy.mockReturnValue(
|
suggestionsSpy.mockReturnValue(of({ tags: [42, 43] }))
|
||||||
of({
|
|
||||||
tags: [42, 43],
|
|
||||||
suggested_tags: [],
|
|
||||||
suggested_document_types: [],
|
|
||||||
suggested_correspondents: [],
|
|
||||||
})
|
|
||||||
)
|
|
||||||
initNormally()
|
initNormally()
|
||||||
expect(suggestionsSpy).toHaveBeenCalled()
|
expect(suggestionsSpy).toHaveBeenCalled()
|
||||||
expect(component.suggestions).toEqual({
|
expect(component.suggestions).toEqual({ tags: [42, 43] })
|
||||||
tags: [42, 43],
|
|
||||||
suggested_tags: [],
|
|
||||||
suggested_document_types: [],
|
|
||||||
suggested_correspondents: [],
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should show error if needed for get suggestions', () => {
|
it('should show error if needed for get suggestions', () => {
|
||||||
@@ -1470,151 +1415,4 @@ 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()
|
|
||||||
}))
|
|
||||||
})
|
})
|
||||||
|
@@ -31,7 +31,6 @@ import {
|
|||||||
map,
|
map,
|
||||||
switchMap,
|
switchMap,
|
||||||
takeUntil,
|
takeUntil,
|
||||||
tap,
|
|
||||||
} from 'rxjs/operators'
|
} from 'rxjs/operators'
|
||||||
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'
|
||||||
@@ -77,7 +76,6 @@ import { DocumentTypeService } from 'src/app/services/rest/document-type.service
|
|||||||
import { DocumentService } from 'src/app/services/rest/document.service'
|
import { DocumentService } from 'src/app/services/rest/document.service'
|
||||||
import { SavedViewService } from 'src/app/services/rest/saved-view.service'
|
import { SavedViewService } from 'src/app/services/rest/saved-view.service'
|
||||||
import { StoragePathService } from 'src/app/services/rest/storage-path.service'
|
import { StoragePathService } from 'src/app/services/rest/storage-path.service'
|
||||||
import { TagService } from 'src/app/services/rest/tag.service'
|
|
||||||
import { UserService } from 'src/app/services/rest/user.service'
|
import { UserService } from 'src/app/services/rest/user.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'
|
||||||
@@ -90,7 +88,6 @@ import { CorrespondentEditDialogComponent } from '../common/edit-dialog/correspo
|
|||||||
import { DocumentTypeEditDialogComponent } from '../common/edit-dialog/document-type-edit-dialog/document-type-edit-dialog.component'
|
import { DocumentTypeEditDialogComponent } from '../common/edit-dialog/document-type-edit-dialog/document-type-edit-dialog.component'
|
||||||
import { EditDialogMode } from '../common/edit-dialog/edit-dialog.component'
|
import { 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 { EmailDocumentDialogComponent } from '../common/email-document-dialog/email-document-dialog.component'
|
import { EmailDocumentDialogComponent } from '../common/email-document-dialog/email-document-dialog.component'
|
||||||
import { CheckComponent } from '../common/input/check/check.component'
|
import { CheckComponent } from '../common/input/check/check.component'
|
||||||
import { DateComponent } from '../common/input/date/date.component'
|
import { DateComponent } from '../common/input/date/date.component'
|
||||||
@@ -101,7 +98,6 @@ 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 {
|
||||||
@@ -109,7 +105,6 @@ import {
|
|||||||
PdfEditorEditMode,
|
PdfEditorEditMode,
|
||||||
} from '../common/pdf-editor/pdf-editor.component'
|
} from '../common/pdf-editor/pdf-editor.component'
|
||||||
import { ShareLinksDialogComponent } from '../common/share-links-dialog/share-links-dialog.component'
|
import { ShareLinksDialogComponent } from '../common/share-links-dialog/share-links-dialog.component'
|
||||||
import { SuggestionsDropdownComponent } from '../common/suggestions-dropdown/suggestions-dropdown.component'
|
|
||||||
import { DocumentHistoryComponent } from '../document-history/document-history.component'
|
import { DocumentHistoryComponent } from '../document-history/document-history.component'
|
||||||
import { DocumentNotesComponent } from '../document-notes/document-notes.component'
|
import { DocumentNotesComponent } from '../document-notes/document-notes.component'
|
||||||
import { ComponentWithPermissions } from '../with-permissions/with-permissions.component'
|
import { ComponentWithPermissions } from '../with-permissions/with-permissions.component'
|
||||||
@@ -166,7 +161,6 @@ export enum ZoomSetting {
|
|||||||
NumberComponent,
|
NumberComponent,
|
||||||
MonetaryComponent,
|
MonetaryComponent,
|
||||||
UrlComponent,
|
UrlComponent,
|
||||||
SuggestionsDropdownComponent,
|
|
||||||
CustomDatePipe,
|
CustomDatePipe,
|
||||||
FileSizePipe,
|
FileSizePipe,
|
||||||
IfPermissionsDirective,
|
IfPermissionsDirective,
|
||||||
@@ -179,7 +173,6 @@ export enum ZoomSetting {
|
|||||||
NgbDropdownModule,
|
NgbDropdownModule,
|
||||||
NgxBootstrapIconsModule,
|
NgxBootstrapIconsModule,
|
||||||
PdfViewerModule,
|
PdfViewerModule,
|
||||||
TextAreaComponent,
|
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class DocumentDetailComponent
|
export class DocumentDetailComponent
|
||||||
@@ -188,7 +181,6 @@ export class DocumentDetailComponent
|
|||||||
{
|
{
|
||||||
private documentsService = inject(DocumentService)
|
private documentsService = inject(DocumentService)
|
||||||
private route = inject(ActivatedRoute)
|
private route = inject(ActivatedRoute)
|
||||||
private tagService = inject(TagService)
|
|
||||||
private correspondentService = inject(CorrespondentService)
|
private correspondentService = inject(CorrespondentService)
|
||||||
private documentTypeService = inject(DocumentTypeService)
|
private documentTypeService = inject(DocumentTypeService)
|
||||||
private router = inject(Router)
|
private router = inject(Router)
|
||||||
@@ -211,8 +203,6 @@ export class DocumentDetailComponent
|
|||||||
@ViewChild('inputTitle')
|
@ViewChild('inputTitle')
|
||||||
titleInput: TextComponent
|
titleInput: TextComponent
|
||||||
|
|
||||||
@ViewChild('tagsInput') tagsInput: TagsComponent
|
|
||||||
|
|
||||||
expandOriginalMetadata = false
|
expandOriginalMetadata = false
|
||||||
expandArchivedMetadata = false
|
expandArchivedMetadata = false
|
||||||
|
|
||||||
@@ -224,7 +214,6 @@ export class DocumentDetailComponent
|
|||||||
document: Document
|
document: Document
|
||||||
metadata: DocumentMetadata
|
metadata: DocumentMetadata
|
||||||
suggestions: DocumentSuggestions
|
suggestions: DocumentSuggestions
|
||||||
suggestionsLoading: boolean = false
|
|
||||||
users: User[]
|
users: User[]
|
||||||
|
|
||||||
title: string
|
title: string
|
||||||
@@ -302,14 +291,6 @@ 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 aiEnabled(): boolean {
|
|
||||||
return this.settings.get(SETTINGS_KEYS.AI_ENABLED)
|
|
||||||
}
|
|
||||||
|
|
||||||
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')
|
||||||
@@ -691,25 +672,8 @@ export class DocumentDetailComponent
|
|||||||
PermissionType.Document
|
PermissionType.Document
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
this.tagService.getCachedMany(doc.tags).subscribe((tags) => {
|
|
||||||
// only show suggestions if document has inbox tags
|
|
||||||
if (tags.some((tag) => tag.is_inbox_tag)) {
|
|
||||||
this.getSuggestions()
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
this.title = this.documentTitlePipe.transform(doc.title)
|
|
||||||
this.prepareForm(doc)
|
|
||||||
}
|
|
||||||
|
|
||||||
get customFieldFormFields(): FormArray {
|
|
||||||
return this.documentForm.get('custom_fields') as FormArray
|
|
||||||
}
|
|
||||||
|
|
||||||
getSuggestions() {
|
|
||||||
this.suggestionsLoading = true
|
|
||||||
this.documentsService
|
this.documentsService
|
||||||
.getSuggestions(this.documentId)
|
.getSuggestions(doc.id)
|
||||||
.pipe(
|
.pipe(
|
||||||
first(),
|
first(),
|
||||||
takeUntil(this.unsubscribeNotifier),
|
takeUntil(this.unsubscribeNotifier),
|
||||||
@@ -718,11 +682,9 @@ export class DocumentDetailComponent
|
|||||||
.subscribe({
|
.subscribe({
|
||||||
next: (result) => {
|
next: (result) => {
|
||||||
this.suggestions = result
|
this.suggestions = result
|
||||||
this.suggestionsLoading = false
|
|
||||||
},
|
},
|
||||||
error: (error) => {
|
error: (error) => {
|
||||||
this.suggestions = null
|
this.suggestions = null
|
||||||
this.suggestionsLoading = false
|
|
||||||
this.toastService.showError(
|
this.toastService.showError(
|
||||||
$localize`Error retrieving suggestions.`,
|
$localize`Error retrieving suggestions.`,
|
||||||
error
|
error
|
||||||
@@ -730,37 +692,12 @@ export class DocumentDetailComponent
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
this.title = this.documentTitlePipe.transform(doc.title)
|
||||||
|
this.prepareForm(doc)
|
||||||
|
}
|
||||||
|
|
||||||
createTag(newName: string) {
|
get customFieldFormFields(): FormArray {
|
||||||
var modal = this.modalService.open(TagEditDialogComponent, {
|
return this.documentForm.get('custom_fields') as FormArray
|
||||||
backdrop: 'static',
|
|
||||||
})
|
|
||||||
modal.componentInstance.dialogMode = EditDialogMode.CREATE
|
|
||||||
if (newName) modal.componentInstance.object = { name: newName }
|
|
||||||
modal.componentInstance.succeeded
|
|
||||||
.pipe(
|
|
||||||
tap((newTag: Tag) => {
|
|
||||||
// remove from suggestions if present
|
|
||||||
if (this.suggestions) {
|
|
||||||
this.suggestions = {
|
|
||||||
...this.suggestions,
|
|
||||||
suggested_tags: this.suggestions.suggested_tags.filter(
|
|
||||||
(tag) => tag !== newTag.name
|
|
||||||
),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}),
|
|
||||||
switchMap((newTag: Tag) => {
|
|
||||||
return this.tagService
|
|
||||||
.listAll()
|
|
||||||
.pipe(map((tags) => ({ newTag, tags })))
|
|
||||||
}),
|
|
||||||
takeUntil(this.unsubscribeNotifier)
|
|
||||||
)
|
|
||||||
.subscribe(({ newTag, tags }) => {
|
|
||||||
this.tagsInput.tags = tags.results
|
|
||||||
this.tagsInput.addTag(newTag.id)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
createDocumentType(newName: string) {
|
createDocumentType(newName: string) {
|
||||||
@@ -782,12 +719,6 @@ export class DocumentDetailComponent
|
|||||||
this.documentTypes = documentTypes.results
|
this.documentTypes = documentTypes.results
|
||||||
this.documentForm.get('document_type').setValue(newDocumentType.id)
|
this.documentForm.get('document_type').setValue(newDocumentType.id)
|
||||||
this.documentForm.get('document_type').markAsDirty()
|
this.documentForm.get('document_type').markAsDirty()
|
||||||
if (this.suggestions) {
|
|
||||||
this.suggestions.suggested_document_types =
|
|
||||||
this.suggestions.suggested_document_types.filter(
|
|
||||||
(dt) => dt !== newName
|
|
||||||
)
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -812,12 +743,6 @@ export class DocumentDetailComponent
|
|||||||
this.correspondents = correspondents.results
|
this.correspondents = correspondents.results
|
||||||
this.documentForm.get('correspondent').setValue(newCorrespondent.id)
|
this.documentForm.get('correspondent').setValue(newCorrespondent.id)
|
||||||
this.documentForm.get('correspondent').markAsDirty()
|
this.documentForm.get('correspondent').markAsDirty()
|
||||||
if (this.suggestions) {
|
|
||||||
this.suggestions.suggested_correspondents =
|
|
||||||
this.suggestions.suggested_correspondents.filter(
|
|
||||||
(c) => c !== newName
|
|
||||||
)
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1494,44 +1419,6 @@ export class DocumentDetailComponent
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
printDocument() {
|
|
||||||
const printUrl = this.documentsService.getDownloadUrl(
|
|
||||||
this.document.id,
|
|
||||||
false
|
|
||||||
)
|
|
||||||
this.http
|
|
||||||
.get(printUrl, { responseType: 'blob' })
|
|
||||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
|
||||||
.subscribe({
|
|
||||||
next: (blob) => {
|
|
||||||
const blobUrl = URL.createObjectURL(blob)
|
|
||||||
const iframe = document.createElement('iframe')
|
|
||||||
iframe.style.display = 'none'
|
|
||||||
iframe.src = blobUrl
|
|
||||||
document.body.appendChild(iframe)
|
|
||||||
iframe.onload = () => {
|
|
||||||
try {
|
|
||||||
iframe.contentWindow.focus()
|
|
||||||
iframe.contentWindow.print()
|
|
||||||
iframe.contentWindow.onafterprint = () => {
|
|
||||||
document.body.removeChild(iframe)
|
|
||||||
URL.revokeObjectURL(blobUrl)
|
|
||||||
}
|
|
||||||
} catch (err) {
|
|
||||||
this.toastService.showError($localize`Print failed.`, err)
|
|
||||||
document.body.removeChild(iframe)
|
|
||||||
URL.revokeObjectURL(blobUrl)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
error: () => {
|
|
||||||
this.toastService.showError(
|
|
||||||
$localize`Error loading document for printing.`
|
|
||||||
)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
public openShareLinks() {
|
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
|
||||||
|
@@ -1,5 +1,21 @@
|
|||||||
<div class="d-flex flex-wrap gap-4">
|
<div class="d-flex flex-wrap gap-4">
|
||||||
<div class="d-flex flex-wrap align-items-center gap-2" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.Document }">
|
<div class="d-flex align-items-center" role="group" aria-label="Select">
|
||||||
|
<button class="btn btn-sm btn-outline-secondary" (click)="list.selectNone()">
|
||||||
|
<i-bs name="slash-circle"></i-bs> <ng-container i18n>Cancel</ng-container>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex align-items-center gap-2" role="group" aria-label="Select">
|
||||||
|
<label class="me-2" i18n>Select:</label>
|
||||||
|
<div class="btn-group">
|
||||||
|
<button class="btn btn-sm btn-outline-primary" (click)="list.selectPage()">
|
||||||
|
<i-bs name="file-earmark-check"></i-bs> <ng-container i18n>Page</ng-container>
|
||||||
|
</button>
|
||||||
|
<button 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 class="d-flex align-items-center gap-2" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.Document }">
|
||||||
<label class="me-2" i18n>Edit:</label>
|
<label class="me-2" i18n>Edit:</label>
|
||||||
@if (permissionService.currentUserCan(PermissionAction.View, PermissionType.Tag)) {
|
@if (permissionService.currentUserCan(PermissionAction.View, PermissionType.Tag)) {
|
||||||
<pngx-filterable-dropdown title="Tags" icon="tag-fill" i18n-title
|
<pngx-filterable-dropdown title="Tags" icon="tag-fill" i18n-title
|
||||||
@@ -73,14 +89,14 @@
|
|||||||
(apply)="setCustomFields($event)">
|
(apply)="setCustomFields($event)">
|
||||||
</pngx-filterable-dropdown>
|
</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>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="d-flex align-items-center gap-2 ms-auto">
|
<div class="d-flex align-items-center gap-2 ms-auto">
|
||||||
<div class="btn-toolbar">
|
<div class="btn-toolbar">
|
||||||
|
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-primary me-2" (click)="setPermissions()" [disabled]="!userOwnsAll || !userCanEditAll">
|
||||||
|
<i-bs name="person-fill-lock"></i-bs><div class="d-none d-sm-inline"> <ng-container i18n>Permissions</ng-container></div>
|
||||||
|
</button>
|
||||||
|
|
||||||
<div ngbDropdown>
|
<div ngbDropdown>
|
||||||
<button class="btn btn-sm btn-outline-primary" id="dropdownSelect" [disabled]="!userCanEdit && !userCanAdd" ngbDropdownToggle>
|
<button class="btn btn-sm btn-outline-primary" id="dropdownSelect" [disabled]="!userCanEdit && !userCanAdd" ngbDropdownToggle>
|
||||||
<i-bs name="three-dots"></i-bs>
|
<i-bs name="three-dots"></i-bs>
|
||||||
@@ -99,6 +115,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="btn-group btn-group-sm">
|
<div class="btn-group btn-group-sm">
|
||||||
<button class="btn btn-sm btn-outline-primary" [disabled]="awaitingDownload" (click)="downloadSelected()">
|
<button class="btn btn-sm btn-outline-primary" [disabled]="awaitingDownload" (click)="downloadSelected()">
|
||||||
@if (!awaitingDownload) {
|
@if (!awaitingDownload) {
|
||||||
|
@@ -5,7 +5,3 @@
|
|||||||
.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).toMatchObject(
|
expect(component.tagSelectionModel.items).toEqual(
|
||||||
[{ id: null, name: 'Not assigned' }].concat(tags.results as any)
|
[{ id: null, name: 'Not assigned' }].concat(tags.results as any)
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
@@ -37,7 +37,6 @@ import { StoragePathService } from 'src/app/services/rest/storage-path.service'
|
|||||||
import { TagService } from 'src/app/services/rest/tag.service'
|
import { 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'
|
||||||
@@ -165,10 +164,7 @@ export class BulkEditorComponent
|
|||||||
this.tagService
|
this.tagService
|
||||||
.listAll()
|
.listAll()
|
||||||
.pipe(first())
|
.pipe(first())
|
||||||
.subscribe(
|
.subscribe((result) => (this.tagSelectionModel.items = result.results))
|
||||||
(result) =>
|
|
||||||
(this.tagSelectionModel.items = flattenTags(result.results))
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
this.permissionService.currentUserCan(
|
this.permissionService.currentUserCan(
|
||||||
@@ -652,7 +648,7 @@ export class BulkEditorComponent
|
|||||||
)
|
)
|
||||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||||
.subscribe(({ newTag, tags }) => {
|
.subscribe(({ newTag, tags }) => {
|
||||||
this.tagSelectionModel.items = flattenTags(tags.results)
|
this.tagSelectionModel.items = tags.results
|
||||||
this.tagSelectionModel.toggle(newTag.id)
|
this.tagSelectionModel.toggle(newTag.id)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
@@ -56,10 +56,6 @@
|
|||||||
[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,7 +18,6 @@ import { TextComponent } from 'src/app/components/common/input/text/text.compone
|
|||||||
import { UrlComponent } from 'src/app/components/common/input/url/url.component'
|
import { 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',
|
||||||
@@ -36,7 +35,6 @@ import { TextAreaComponent } from '../../../common/input/textarea/textarea.compo
|
|||||||
FormsModule,
|
FormsModule,
|
||||||
ReactiveFormsModule,
|
ReactiveFormsModule,
|
||||||
NgxBootstrapIconsModule,
|
NgxBootstrapIconsModule,
|
||||||
TextAreaComponent,
|
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class CustomFieldsBulkEditDialogComponent {
|
export class CustomFieldsBulkEditDialogComponent {
|
||||||
|
@@ -1,36 +1,16 @@
|
|||||||
<pngx-page-header [title]="getTitle()">
|
<pngx-page-header [title]="getTitle()">
|
||||||
<div ngbDropdown class="btn-group flex-fill d-sm-none">
|
|
||||||
<button class="btn btn-sm btn-outline-primary" id="dropdownSelectMobile" ngbDropdownToggle>
|
<div ngbDropdown class="btn-group flex-fill">
|
||||||
|
<button class="btn btn-sm btn-outline-primary" id="dropdownSelect" ngbDropdownToggle>
|
||||||
<i-bs name="text-indent-left"></i-bs>
|
<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="dropdownSelectMobile" class="shadow">
|
<div ngbDropdownMenu aria-labelledby="dropdownSelect" 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>
|
||||||
@@ -148,11 +128,6 @@
|
|||||||
<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,7 +56,6 @@ 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'
|
||||||
@@ -73,7 +72,6 @@ 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()).toMatchObject(tags)
|
expect(component.tagSelectionModel.getSelectedItems()).toEqual(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()).toMatchObject(tags)
|
expect(component.tagSelectionModel.getSelectedItems()).toEqual(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()).toMatchObject(tags)
|
expect(component.tagSelectionModel.getExcludedItems()).toEqual(tags)
|
||||||
// coverage
|
// coverage
|
||||||
component.filterRules = [
|
component.filterRules = [
|
||||||
{
|
{
|
||||||
|
@@ -97,7 +97,6 @@ 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,
|
||||||
@@ -1135,7 +1134,7 @@ export class FilterEditorComponent
|
|||||||
) {
|
) {
|
||||||
this.loadingCountTotal++
|
this.loadingCountTotal++
|
||||||
this.tagService.listAll().subscribe((result) => {
|
this.tagService.listAll().subscribe((result) => {
|
||||||
this.tagSelectionModel.items = flattenTags(result.results)
|
this.tagSelectionModel.items = result.results
|
||||||
this.maybeCompleteLoading()
|
this.maybeCompleteLoading()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
@@ -1,4 +1,4 @@
|
|||||||
import { NgClass, NgTemplateOutlet, TitleCasePipe } from '@angular/common'
|
import { NgClass, TitleCasePipe } from '@angular/common'
|
||||||
import { Component, inject } from '@angular/core'
|
import { Component, inject } from '@angular/core'
|
||||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||||
import {
|
import {
|
||||||
@@ -30,7 +30,6 @@ 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, NgTemplateOutlet, TitleCasePipe } from '@angular/common'
|
import { NgClass, 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,7 +28,6 @@ import { ManagementListComponent } from '../management-list/management-list.comp
|
|||||||
FormsModule,
|
FormsModule,
|
||||||
ReactiveFormsModule,
|
ReactiveFormsModule,
|
||||||
NgClass,
|
NgClass,
|
||||||
NgTemplateOutlet,
|
|
||||||
NgbDropdownModule,
|
NgbDropdownModule,
|
||||||
NgbPaginationModule,
|
NgbPaginationModule,
|
||||||
NgxBootstrapIconsModule,
|
NgxBootstrapIconsModule,
|
||||||
|
@@ -109,11 +109,10 @@
|
|||||||
<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-1 d-none d-sm-block" i18n>Sort Order</div>
|
<div class="col d-none d-sm-block" i18n>Sort Order</div>
|
||||||
<div class="col-2" i18n>Account</div>
|
<div class="col" i18n>Account</div>
|
||||||
<div class="col-2 d-none d-sm-block" i18n>Status</div>
|
<div class="col d-none d-sm-block" i18n>Status</div>
|
||||||
<div class="col d-none d-sm-block" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.ProcessedMail }">Processed Mail</div>
|
<div class="col" i18n>Actions</div>
|
||||||
<div class="col-3" i18n>Actions</div>
|
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
|
|
||||||
@@ -128,9 +127,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-1 d-flex align-items-center d-none d-sm-flex">{{rule.order}}</div>
|
<div class="col d-flex align-items-center d-none d-sm-flex">{{rule.order}}</div>
|
||||||
<div class="col-2 d-flex align-items-center">{{(mailAccountService.getCached(rule.account) | async)?.name}}</div>
|
<div class="col d-flex align-items-center">{{(mailAccountService.getCached(rule.account) | async)?.name}}</div>
|
||||||
<div class="col-2 d-flex align-items-center d-none d-sm-flex">
|
<div class="col d-flex align-items-center d-none d-sm-flex">
|
||||||
<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'">
|
||||||
@@ -138,12 +137,7 @@
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col d-flex align-items-center d-none d-sm-flex" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.ProcessedMail }">
|
<div class="col">
|
||||||
<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,13 +409,4 @@ 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,7 +27,6 @@ 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',
|
||||||
@@ -348,14 +347,6 @@ export class MailComponent
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
viewProcessedMail(rule: MailRule) {
|
|
||||||
const modal = this.modalService.open(ProcessedMailDialogComponent, {
|
|
||||||
backdrop: 'static',
|
|
||||||
size: 'xl',
|
|
||||||
})
|
|
||||||
modal.componentInstance.rule = rule
|
|
||||||
}
|
|
||||||
|
|
||||||
userCanEdit(obj: ObjectWithPermissions): boolean {
|
userCanEdit(obj: ObjectWithPermissions): boolean {
|
||||||
return this.permissionsService.currentUserHasObjectPermissions(
|
return this.permissionsService.currentUserHasObjectPermissions(
|
||||||
PermissionAction.Change,
|
PermissionAction.Change,
|
||||||
|
@@ -1,107 +0,0 @@
|
|||||||
<div class="modal-header">
|
|
||||||
<h6 class="modal-title" id="modal-basic-title" i18n>Processed Mail for <em>{{ rule.name }}</em></h6>
|
|
||||||
<button class="btn btn-sm btn-link text-muted me-auto p-0 p-md-2" title="What's this?" i18n-title type="button" [ngbPopover]="infoPopover" [autoClose]="true">
|
|
||||||
<i-bs name="question-circle"></i-bs>
|
|
||||||
</button>
|
|
||||||
<ng-template #infoPopover>
|
|
||||||
<a href="https://docs.paperless-ngx.com/usage#processed-mail" target="_blank" referrerpolicy="noopener noreferrer" i18n>Read more</a>
|
|
||||||
<i-bs class="ms-1" width=".8em" height=".8em" name="box-arrow-up-right"></i-bs>
|
|
||||||
</ng-template>
|
|
||||||
<button type="button" class="btn-close" aria-label="Close" (click)="close()"></button>
|
|
||||||
</div>
|
|
||||||
<div class="modal-body">
|
|
||||||
@if (loading) {
|
|
||||||
<div class="text-center my-5">
|
|
||||||
<div class="spinner-border" role="status">
|
|
||||||
<span class="visually-hidden" i18n>Loading...</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
} @else if (processedMails.length === 0) {
|
|
||||||
<span i18n>No processed email messages found.</span>
|
|
||||||
} @else {
|
|
||||||
<div class="table-responsive">
|
|
||||||
<table class="table table-hover table-sm align-middle">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th scope="col" style="width: 40px;">
|
|
||||||
<div class="form-check m-0 ms-2 me-n2">
|
|
||||||
<input type="checkbox" class="form-check-input" id="all-objects" [(ngModel)]="toggleAllEnabled" [disabled]="processedMails.length === 0" (click)="toggleAll($event); $event.stopPropagation();">
|
|
||||||
<label class="form-check-label" for="all-objects"></label>
|
|
||||||
</div>
|
|
||||||
</th>
|
|
||||||
<th scope="col" i18n>Subject</th>
|
|
||||||
<th scope="col" i18n>Received</th>
|
|
||||||
<th scope="col" i18n>Processed</th>
|
|
||||||
<th scope="col" i18n>Status</th>
|
|
||||||
<th scope="col" i18n>Error</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
@for (mail of processedMails; track mail.id) {
|
|
||||||
<ng-template #statusTooltip>
|
|
||||||
<div class="small text-light font-monospace">
|
|
||||||
{{mail.status}}
|
|
||||||
</div>
|
|
||||||
</ng-template>
|
|
||||||
<tr>
|
|
||||||
<td>
|
|
||||||
<div class="form-check m-0 ms-2 me-n2">
|
|
||||||
<input type="checkbox" class="form-check-input" [id]="mail.id" [checked]="selectedMailIds.has(mail.id)" (click)="toggleSelected(mail); $event.stopPropagation();">
|
|
||||||
<label class="form-check-label" [for]="mail.id"></label>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
<td>{{ mail.subject }}</td>
|
|
||||||
<td>{{ mail.received | customDate:'longDate' }}</td>
|
|
||||||
<td>{{ mail.processed | customDate:'longDate' }}</td>
|
|
||||||
<td>
|
|
||||||
@switch (mail.status) {
|
|
||||||
@case ('SUCCESS') {
|
|
||||||
<i-bs name="check-circle" title="SUCCESS" class="text-success" [ngbTooltip]="statusTooltip"></i-bs>
|
|
||||||
}
|
|
||||||
@case ('FAILED') {
|
|
||||||
<i-bs name="exclamation-triangle" title="FAILED" class="text-danger" [ngbTooltip]="statusTooltip"></i-bs>
|
|
||||||
}
|
|
||||||
@default {
|
|
||||||
<i-bs name="slash-circle" title="{{ mail.status }}" class="text-muted" [ngbTooltip]="statusTooltip"></i-bs>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<ng-template #errorPopover>
|
|
||||||
<pre class="small text-light">
|
|
||||||
{{ mail.error }}
|
|
||||||
</pre>
|
|
||||||
</ng-template>
|
|
||||||
@if (mail.error) {
|
|
||||||
<span class="text-danger" triggers="mouseenter:mouseleave" [ngbPopover]="errorPopover">{{ mail.error | slice:0:20 }}</span>
|
|
||||||
}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
<div class="btn-toolbar">
|
|
||||||
<button type="button" class="btn btn-outline-secondary me-2" (click)="clearSelection()" [disabled]="selectedMailIds.size === 0" i18n>Clear</button>
|
|
||||||
<pngx-confirm-button
|
|
||||||
label="Delete selected"
|
|
||||||
i18n-label
|
|
||||||
title="Delete selected"
|
|
||||||
i18n-title
|
|
||||||
buttonClasses="btn-outline-danger"
|
|
||||||
iconName="trash"
|
|
||||||
[disabled]="selectedMailIds.size === 0"
|
|
||||||
(confirm)="deleteSelected()">
|
|
||||||
</pngx-confirm-button>
|
|
||||||
<div class="ms-auto">
|
|
||||||
<ngb-pagination
|
|
||||||
[collectionSize]="processedMails.length"
|
|
||||||
[(page)]="page"
|
|
||||||
[pageSize]="50"
|
|
||||||
[maxSize]="5"
|
|
||||||
(pageChange)="loadProcessedMails()">
|
|
||||||
</ngb-pagination>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
@@ -1,8 +0,0 @@
|
|||||||
::ng-deep .popover {
|
|
||||||
max-width: 350px;
|
|
||||||
|
|
||||||
pre {
|
|
||||||
white-space: pre-wrap;
|
|
||||||
word-break: break-word;
|
|
||||||
}
|
|
||||||
}
|
|
@@ -1,150 +0,0 @@
|
|||||||
import { DatePipe } from '@angular/common'
|
|
||||||
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
|
|
||||||
import {
|
|
||||||
HttpTestingController,
|
|
||||||
provideHttpClientTesting,
|
|
||||||
} from '@angular/common/http/testing'
|
|
||||||
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
|
||||||
import { FormsModule } from '@angular/forms'
|
|
||||||
import { By } from '@angular/platform-browser'
|
|
||||||
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
|
|
||||||
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
|
||||||
import { ToastService } from 'src/app/services/toast.service'
|
|
||||||
import { environment } from 'src/environments/environment'
|
|
||||||
import { ProcessedMailDialogComponent } from './processed-mail-dialog.component'
|
|
||||||
|
|
||||||
describe('ProcessedMailDialogComponent', () => {
|
|
||||||
let component: ProcessedMailDialogComponent
|
|
||||||
let fixture: ComponentFixture<ProcessedMailDialogComponent>
|
|
||||||
let httpTestingController: HttpTestingController
|
|
||||||
let toastService: ToastService
|
|
||||||
|
|
||||||
const rule: any = { id: 10, name: 'Mail Rule' } // minimal rule object for tests
|
|
||||||
const mails = [
|
|
||||||
{
|
|
||||||
id: 1,
|
|
||||||
rule: rule.id,
|
|
||||||
folder: 'INBOX',
|
|
||||||
uid: 111,
|
|
||||||
subject: 'A',
|
|
||||||
received: new Date().toISOString(),
|
|
||||||
processed: new Date().toISOString(),
|
|
||||||
status: 'SUCCESS',
|
|
||||||
error: null,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 2,
|
|
||||||
rule: rule.id,
|
|
||||||
folder: 'INBOX',
|
|
||||||
uid: 222,
|
|
||||||
subject: 'B',
|
|
||||||
received: new Date().toISOString(),
|
|
||||||
processed: new Date().toISOString(),
|
|
||||||
status: 'FAILED',
|
|
||||||
error: 'Oops',
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
await TestBed.configureTestingModule({
|
|
||||||
imports: [
|
|
||||||
ProcessedMailDialogComponent,
|
|
||||||
FormsModule,
|
|
||||||
NgxBootstrapIconsModule.pick(allIcons),
|
|
||||||
],
|
|
||||||
providers: [
|
|
||||||
DatePipe,
|
|
||||||
NgbActiveModal,
|
|
||||||
provideHttpClient(withInterceptorsFromDi()),
|
|
||||||
provideHttpClientTesting(),
|
|
||||||
],
|
|
||||||
}).compileComponents()
|
|
||||||
|
|
||||||
httpTestingController = TestBed.inject(HttpTestingController)
|
|
||||||
toastService = TestBed.inject(ToastService)
|
|
||||||
fixture = TestBed.createComponent(ProcessedMailDialogComponent)
|
|
||||||
component = fixture.componentInstance
|
|
||||||
component.rule = rule
|
|
||||||
})
|
|
||||||
|
|
||||||
afterEach(() => {
|
|
||||||
httpTestingController.verify()
|
|
||||||
})
|
|
||||||
|
|
||||||
function expectListRequest(ruleId: number) {
|
|
||||||
const req = httpTestingController.expectOne(
|
|
||||||
`${environment.apiBaseUrl}processed_mail/?page=1&page_size=50&ordering=-processed_at&rule=${ruleId}`
|
|
||||||
)
|
|
||||||
expect(req.request.method).toEqual('GET')
|
|
||||||
return req
|
|
||||||
}
|
|
||||||
|
|
||||||
it('should load processed mails on init', () => {
|
|
||||||
fixture.detectChanges()
|
|
||||||
const req = expectListRequest(rule.id)
|
|
||||||
req.flush({ count: 2, results: mails })
|
|
||||||
expect(component.loading).toBeFalsy()
|
|
||||||
expect(component.processedMails).toEqual(mails)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should delete selected mails and reload', () => {
|
|
||||||
fixture.detectChanges()
|
|
||||||
// initial load
|
|
||||||
const initialReq = expectListRequest(rule.id)
|
|
||||||
initialReq.flush({ count: 0, results: [] })
|
|
||||||
|
|
||||||
// select a couple of mails and delete
|
|
||||||
component.selectedMailIds.add(5)
|
|
||||||
component.selectedMailIds.add(6)
|
|
||||||
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
|
|
||||||
component.deleteSelected()
|
|
||||||
|
|
||||||
const delReq = httpTestingController.expectOne(
|
|
||||||
`${environment.apiBaseUrl}processed_mail/bulk_delete/`
|
|
||||||
)
|
|
||||||
expect(delReq.request.method).toEqual('POST')
|
|
||||||
expect(delReq.request.body).toEqual({ mail_ids: [5, 6] })
|
|
||||||
delReq.flush({})
|
|
||||||
|
|
||||||
// reload after delete
|
|
||||||
const reloadReq = expectListRequest(rule.id)
|
|
||||||
reloadReq.flush({ count: 0, results: [] })
|
|
||||||
expect(toastInfoSpy).toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should toggle all, toggle selected, and clear selection', () => {
|
|
||||||
fixture.detectChanges()
|
|
||||||
// initial load with two mails
|
|
||||||
const req = expectListRequest(rule.id)
|
|
||||||
req.flush({ count: 2, results: mails })
|
|
||||||
fixture.detectChanges()
|
|
||||||
|
|
||||||
// toggle all via header checkbox
|
|
||||||
const inputs = fixture.debugElement.queryAll(
|
|
||||||
By.css('input.form-check-input')
|
|
||||||
)
|
|
||||||
const header = inputs[0].nativeElement as HTMLInputElement
|
|
||||||
header.dispatchEvent(new Event('click'))
|
|
||||||
header.checked = true
|
|
||||||
header.dispatchEvent(new Event('click'))
|
|
||||||
expect(component.selectedMailIds.size).toEqual(mails.length)
|
|
||||||
|
|
||||||
// toggle a single mail
|
|
||||||
component.toggleSelected(mails[0] as any)
|
|
||||||
expect(component.selectedMailIds.has(mails[0].id)).toBeFalsy()
|
|
||||||
component.toggleSelected(mails[0] as any)
|
|
||||||
expect(component.selectedMailIds.has(mails[0].id)).toBeTruthy()
|
|
||||||
|
|
||||||
// clear selection
|
|
||||||
component.clearSelection()
|
|
||||||
expect(component.selectedMailIds.size).toEqual(0)
|
|
||||||
expect(component.toggleAllEnabled).toBeFalsy()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should close the dialog', () => {
|
|
||||||
const activeModal = TestBed.inject(NgbActiveModal)
|
|
||||||
const closeSpy = jest.spyOn(activeModal, 'close')
|
|
||||||
component.close()
|
|
||||||
expect(closeSpy).toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
})
|
|
@@ -1,96 +0,0 @@
|
|||||||
import { SlicePipe } from '@angular/common'
|
|
||||||
import { Component, inject, Input, OnInit } from '@angular/core'
|
|
||||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
|
||||||
import {
|
|
||||||
NgbActiveModal,
|
|
||||||
NgbPagination,
|
|
||||||
NgbPopoverModule,
|
|
||||||
NgbTooltipModule,
|
|
||||||
} from '@ng-bootstrap/ng-bootstrap'
|
|
||||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
|
||||||
import { ConfirmButtonComponent } from 'src/app/components/common/confirm-button/confirm-button.component'
|
|
||||||
import { MailRule } from 'src/app/data/mail-rule'
|
|
||||||
import { ProcessedMail } from 'src/app/data/processed-mail'
|
|
||||||
import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
|
|
||||||
import { ProcessedMailService } from 'src/app/services/rest/processed-mail.service'
|
|
||||||
import { ToastService } from 'src/app/services/toast.service'
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: 'pngx-processed-mail-dialog',
|
|
||||||
imports: [
|
|
||||||
ConfirmButtonComponent,
|
|
||||||
CustomDatePipe,
|
|
||||||
NgbPagination,
|
|
||||||
NgbPopoverModule,
|
|
||||||
NgbTooltipModule,
|
|
||||||
NgxBootstrapIconsModule,
|
|
||||||
FormsModule,
|
|
||||||
ReactiveFormsModule,
|
|
||||||
SlicePipe,
|
|
||||||
],
|
|
||||||
templateUrl: './processed-mail-dialog.component.html',
|
|
||||||
styleUrl: './processed-mail-dialog.component.scss',
|
|
||||||
})
|
|
||||||
export class ProcessedMailDialogComponent implements OnInit {
|
|
||||||
private readonly activeModal = inject(NgbActiveModal)
|
|
||||||
private readonly processedMailService = inject(ProcessedMailService)
|
|
||||||
private readonly toastService = inject(ToastService)
|
|
||||||
|
|
||||||
public processedMails: ProcessedMail[] = []
|
|
||||||
|
|
||||||
public loading: boolean = true
|
|
||||||
public toggleAllEnabled: boolean = false
|
|
||||||
public readonly selectedMailIds: Set<number> = new Set<number>()
|
|
||||||
|
|
||||||
public page: number = 1
|
|
||||||
|
|
||||||
@Input() rule: MailRule
|
|
||||||
|
|
||||||
ngOnInit(): void {
|
|
||||||
this.loadProcessedMails()
|
|
||||||
}
|
|
||||||
|
|
||||||
public close() {
|
|
||||||
this.activeModal.close()
|
|
||||||
}
|
|
||||||
|
|
||||||
private loadProcessedMails(): void {
|
|
||||||
this.loading = true
|
|
||||||
this.clearSelection()
|
|
||||||
this.processedMailService
|
|
||||||
.list(this.page, 50, 'processed_at', true, { rule: this.rule.id })
|
|
||||||
.subscribe((result) => {
|
|
||||||
this.processedMails = result.results
|
|
||||||
this.loading = false
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
public deleteSelected(): void {
|
|
||||||
this.processedMailService
|
|
||||||
.bulk_delete(Array.from(this.selectedMailIds))
|
|
||||||
.subscribe(() => {
|
|
||||||
this.toastService.showInfo($localize`Processed mail(s) deleted`)
|
|
||||||
this.loadProcessedMails()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
public toggleAll(event: PointerEvent) {
|
|
||||||
if ((event.target as HTMLInputElement).checked) {
|
|
||||||
this.selectedMailIds.clear()
|
|
||||||
this.processedMails.forEach((mail) => this.selectedMailIds.add(mail.id))
|
|
||||||
} else {
|
|
||||||
this.clearSelection()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public clearSelection() {
|
|
||||||
this.toggleAllEnabled = false
|
|
||||||
this.selectedMailIds.clear()
|
|
||||||
}
|
|
||||||
|
|
||||||
public toggleSelected(mail: ProcessedMail) {
|
|
||||||
this.selectedMailIds.has(mail.id)
|
|
||||||
? this.selectedMailIds.delete(mail.id)
|
|
||||||
: this.selectedMailIds.add(mail.id)
|
|
||||||
}
|
|
||||||
}
|
|
@@ -54,29 +54,6 @@
|
|||||||
</tr>
|
</tr>
|
||||||
}
|
}
|
||||||
@for (object of data; track object) {
|
@for (object of data; track object) {
|
||||||
<ng-container [ngTemplateOutlet]="objectRow" [ngTemplateOutletContext]="{ object: object, depth: 0 }"></ng-container>
|
|
||||||
}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
@if (!loading) {
|
|
||||||
<div class="d-flex mb-2">
|
|
||||||
@if (collectionSize > 0) {
|
|
||||||
<div>
|
|
||||||
<ng-container i18n>{collectionSize, plural, =1 {One {{typeName}}} other {{{collectionSize || 0}} total {{typeNamePlural}}}}</ng-container>
|
|
||||||
@if (selectedObjects.size > 0) {
|
|
||||||
({{selectedObjects.size}} selected)
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
@if (collectionSize > 20) {
|
|
||||||
<ngb-pagination class="ms-auto" [pageSize]="25" [collectionSize]="collectionSize" [(page)]="page" [maxSize]="5" (pageChange)="reloadData()" size="sm" aria-label="Pagination"></ngb-pagination>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
<ng-template #objectRow let-object="object" let-depth="depth">
|
|
||||||
<tr (click)="toggleSelected(object); $event.stopPropagation();" class="data-row fade" [class.show]="show">
|
<tr (click)="toggleSelected(object); $event.stopPropagation();" class="data-row fade" [class.show]="show">
|
||||||
<td>
|
<td>
|
||||||
<div class="form-check m-0 ms-2 me-n2">
|
<div class="form-check m-0 ms-2 me-n2">
|
||||||
@@ -84,14 +61,9 @@
|
|||||||
<label class="form-check-label" for="{{typeName}}{{object.id}}"></label>
|
<label class="form-check-label" for="{{typeName}}{{object.id}}"></label>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td scope="row" class="name-cell" style="--depth: {{depth}}">
|
<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>
|
||||||
@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" class="d-none d-sm-table-cell">{{ getMatching(object) }}</td>
|
||||||
<td scope="row">{{ getDocumentCount(object) }}</td>
|
<td scope="row">{{ object.document_count }}</td>
|
||||||
@for (column of extraColumns; track column) {
|
@for (column of extraColumns; track column) {
|
||||||
<td scope="row" [ngClass]="{ 'd-none d-sm-table-cell' : column.hideOnMobile }">
|
<td scope="row" [ngClass]="{ 'd-none d-sm-table-cell' : column.hideOnMobile }">
|
||||||
@if (column.rendersHtml) {
|
@if (column.rendersHtml) {
|
||||||
@@ -113,8 +85,8 @@
|
|||||||
<div ngbDropdownMenu aria-labelledby="actionsMenuMobile">
|
<div ngbDropdownMenu aria-labelledby="actionsMenuMobile">
|
||||||
<button (click)="openEditDialog(object)" *pngxIfPermissions="{ action: PermissionAction.Change, type: permissionType }" ngbDropdownItem i18n>Edit</button>
|
<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>
|
<button class="text-danger" (click)="openDeleteDialog(object)" *pngxIfPermissions="{ action: PermissionAction.Delete, type: permissionType }" ngbDropdownItem i18n>Delete</button>
|
||||||
@if (getDocumentCount(object) > 0) {
|
@if (object.document_count > 0) {
|
||||||
<button (click)="filterDocuments(object)" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }" ngbDropdownItem i18n>Filter Documents ({{ getDocumentCount(object) }})</button>
|
<button (click)="filterDocuments(object)" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }" ngbDropdownItem i18n>Filter Documents ({{ object.document_count }})</button>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -127,20 +99,33 @@
|
|||||||
<i-bs width="1em" height="1em" name="trash"></i-bs> <ng-container i18n>Delete</ng-container>
|
<i-bs width="1em" height="1em" name="trash"></i-bs> <ng-container i18n>Delete</ng-container>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@if (getDocumentCount(object) > 0) {
|
@if (object.document_count > 0) {
|
||||||
<div class="btn-group d-none d-sm-inline-block">
|
<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 }">
|
<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>
|
<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>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
@if (object.children && object.children.length > 0) {
|
@if (!loading) {
|
||||||
@for (child of object.children; track child) {
|
<div class="d-flex mb-2">
|
||||||
<ng-container [ngTemplateOutlet]="objectRow" [ngTemplateOutletContext]="{ object: child, depth: depth + 1 }"></ng-container>
|
@if (collectionSize > 0) {
|
||||||
|
<div>
|
||||||
|
<ng-container i18n>{collectionSize, plural, =1 {One {{typeName}}} other {{{collectionSize || 0}} total {{typeNamePlural}}}}</ng-container>
|
||||||
|
@if (selectedObjects.size > 0) {
|
||||||
|
({{selectedObjects.size}} selected)
|
||||||
}
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
@if (collectionSize > 20) {
|
||||||
|
<ngb-pagination class="ms-auto" [pageSize]="25" [collectionSize]="collectionSize" [(page)]="page" [maxSize]="5" (pageChange)="reloadData()" size="sm" aria-label="Pagination"></ngb-pagination>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
</ng-template>
|
|
||||||
|
@@ -10,17 +10,3 @@ 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,7 +79,6 @@ 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
|
||||||
|
|
||||||
@@ -133,18 +132,6 @@ 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()
|
||||||
@@ -161,8 +148,7 @@ export abstract class ManagementListComponent<T extends MatchingModel>
|
|||||||
.pipe(
|
.pipe(
|
||||||
takeUntil(this.unsubscribeNotifier),
|
takeUntil(this.unsubscribeNotifier),
|
||||||
tap((c) => {
|
tap((c) => {
|
||||||
this.unfilteredData = c.results
|
this.data = 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, NgTemplateOutlet, TitleCasePipe } from '@angular/common'
|
import { NgClass, 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,7 +30,6 @@ import { ManagementListComponent } from '../management-list/management-list.comp
|
|||||||
FormsModule,
|
FormsModule,
|
||||||
ReactiveFormsModule,
|
ReactiveFormsModule,
|
||||||
NgClass,
|
NgClass,
|
||||||
NgTemplateOutlet,
|
|
||||||
NgbDropdownModule,
|
NgbDropdownModule,
|
||||||
NgbPaginationModule,
|
NgbPaginationModule,
|
||||||
NgxBootstrapIconsModule,
|
NgxBootstrapIconsModule,
|
||||||
|
@@ -71,20 +71,4 @@ 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, NgTemplateOutlet, TitleCasePipe } from '@angular/common'
|
import { NgClass, 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,7 +30,6 @@ import { ManagementListComponent } from '../management-list/management-list.comp
|
|||||||
FormsModule,
|
FormsModule,
|
||||||
ReactiveFormsModule,
|
ReactiveFormsModule,
|
||||||
NgClass,
|
NgClass,
|
||||||
NgTemplateOutlet,
|
|
||||||
NgbDropdownModule,
|
NgbDropdownModule,
|
||||||
NgbPaginationModule,
|
NgbPaginationModule,
|
||||||
NgxBootstrapIconsModule,
|
NgxBootstrapIconsModule,
|
||||||
@@ -60,10 +59,4 @@ 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,10 +114,6 @@ 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 = {
|
||||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user