Compare commits

..
Author SHA1 Message Date
shamoon ab7a546366 Performance: optimize dropdown filtering 2026-07-07 11:20:54 -07:00
442 changed files with 69768 additions and 110155 deletions
+10 -10
View File
@@ -24,7 +24,7 @@ jobs:
backend_changed: ${{ steps.force.outputs.run_all == 'true' || steps.filter.outputs.backend == 'true' }}
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
persist-credentials: false
@@ -87,7 +87,7 @@ jobs:
fail-fast: false
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Start containers
@@ -96,11 +96,11 @@ jobs:
docker compose --file docker/compose/docker-compose.ci-test.yml up --detach
- name: Set up Python
id: setup-python
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: "${{ matrix.python-version }}"
- name: Install uv
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
with:
version: ${{ env.DEFAULT_UV_VERSION }}
enable-cache: true
@@ -141,13 +141,13 @@ jobs:
pytest
- name: Upload test results to Codecov
if: always()
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1
with:
flags: backend-python-${{ matrix.python-version }}
files: junit.xml
report_type: test_results
- name: Upload coverage to Codecov
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1
with:
flags: backend-python-${{ matrix.python-version }}
files: coverage.xml
@@ -169,16 +169,16 @@ jobs:
PAPERLESS_SECRET_KEY: "ci-typing-not-a-real-secret"
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Set up Python
id: setup-python
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: "${{ env.DEFAULT_PYTHON }}"
- name: Install uv
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
with:
version: ${{ env.DEFAULT_UV_VERSION }}
enable-cache: true
@@ -202,7 +202,7 @@ jobs:
check \
src/
- name: Cache Mypy
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: .mypy_cache
# Keyed by OS, Python version, and dependency hashes
+1 -1
View File
@@ -41,7 +41,7 @@ jobs:
ref-name: ${{ steps.ref.outputs.name }}
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Determine ref name
+4 -4
View File
@@ -21,7 +21,7 @@ jobs:
docs_changed: ${{ steps.force.outputs.run_all == 'true' || steps.filter.outputs.docs == 'true' }}
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
persist-credentials: false
@@ -69,16 +69,16 @@ jobs:
steps:
- uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Set up Python
id: setup-python
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
- name: Install uv
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
with:
version: ${{ env.DEFAULT_UV_VERSION }}
enable-cache: true
+18 -18
View File
@@ -21,7 +21,7 @@ jobs:
frontend_changed: ${{ steps.force.outputs.run_all == 'true' || steps.filter.outputs.frontend == 'true' }}
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 0
persist-credentials: false
@@ -77,11 +77,11 @@ jobs:
contents: read
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Install pnpm
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8
with:
version: 10
- name: Use Node.js 24
@@ -92,7 +92,7 @@ jobs:
cache-dependency-path: 'src-ui/pnpm-lock.yaml'
- name: Cache frontend dependencies
id: cache-frontend-deps
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: |
~/.pnpm-store
@@ -109,11 +109,11 @@ jobs:
contents: read
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Install pnpm
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8
with:
version: 10
- name: Use Node.js 24
@@ -123,7 +123,7 @@ jobs:
cache: 'pnpm'
cache-dependency-path: 'src-ui/pnpm-lock.yaml'
- name: Cache frontend dependencies
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: |
~/.pnpm-store
@@ -148,11 +148,11 @@ jobs:
shard-count: [4]
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Install pnpm
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8
with:
version: 10
- name: Use Node.js 24
@@ -162,7 +162,7 @@ jobs:
cache: 'pnpm'
cache-dependency-path: 'src-ui/pnpm-lock.yaml'
- name: Cache frontend dependencies
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: |
~/.pnpm-store
@@ -174,13 +174,13 @@ jobs:
run: cd src-ui && pnpm run test --max-workers=2 --shard=${{ matrix.shard-index }}/${{ matrix.shard-count }}
- name: Upload test results to Codecov
if: always()
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1
with:
flags: frontend-node-${{ matrix.node-version }}
directory: src-ui/
report_type: test_results
- name: Upload coverage to Codecov
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
uses: codecov/codecov-action@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1
with:
flags: frontend-node-${{ matrix.node-version }}
directory: src-ui/coverage/
@@ -203,11 +203,11 @@ jobs:
shard-count: [2]
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Install pnpm
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8
with:
version: 10
- name: Use Node.js 24
@@ -217,7 +217,7 @@ jobs:
cache: 'pnpm'
cache-dependency-path: 'src-ui/pnpm-lock.yaml'
- name: Cache frontend dependencies
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: |
~/.pnpm-store
@@ -239,12 +239,12 @@ jobs:
contents: read
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
fetch-depth: 2
persist-credentials: false
- name: Install pnpm
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8
with:
version: 10
- name: Use Node.js 24
@@ -254,7 +254,7 @@ jobs:
cache: 'pnpm'
cache-dependency-path: 'src-ui/pnpm-lock.yaml'
- name: Cache frontend dependencies
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: |
~/.pnpm-store
+2 -2
View File
@@ -17,11 +17,11 @@ jobs:
runs-on: ubuntu-slim
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Install Python
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: "3.14"
- name: Run prek
+11 -12
View File
@@ -20,13 +20,12 @@ jobs:
statuses: read
steps:
- name: Wait for Docker build
uses: lewagon/wait-on-check-action@96d9100b431964d10e0136aff8b9ccb92470505e # v1.8.0
uses: lewagon/wait-on-check-action@9312864dfbc9fd208e9c0417843430751c042800 # v1.7.0
with:
ref: ${{ github.sha }}
check-name: 'Merge and Push Manifest'
repo-token: ${{ secrets.GITHUB_TOKEN }}
wait-interval: 60
checks-discovery-timeout: 1800
build-release:
name: Build Release
needs: wait-for-docker
@@ -35,12 +34,12 @@ jobs:
contents: read
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
# ---- Frontend Build ----
- name: Install pnpm
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8
with:
version: 10
- name: Use Node.js 24
@@ -55,11 +54,11 @@ jobs:
# ---- Backend Setup ----
- name: Set up Python
id: setup-python
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
- name: Install uv
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
with:
version: ${{ env.DEFAULT_UV_VERSION }}
enable-cache: false
@@ -86,7 +85,7 @@ jobs:
# ---- Prepare Release ----
- name: Generate requirements file
run: |
uv export --quiet --no-dev --all-extras --emit-index-url --format requirements-txt --output-file requirements.txt
uv export --quiet --no-dev --all-extras --format requirements-txt --output-file requirements.txt
- name: Compile messages
env:
PAPERLESS_SECRET_KEY: "ci-release-not-a-real-secret"
@@ -171,7 +170,7 @@ jobs:
fi
- name: Create release and changelog
id: create-release
uses: release-drafter/release-drafter@4d75298e00d9e34c483e5ff8c68d0ea1c1940c1e # v7.5.1
uses: release-drafter/release-drafter@693d20e7c1ce1a81d3a41962f85914253b518449 # v7.3.1
with:
name: Paperless-ngx ${{ steps.get-version.outputs.version }}
tag: ${{ steps.get-version.outputs.version }}
@@ -182,7 +181,7 @@ jobs:
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Upload release archive
uses: shogo82148/actions-upload-release-asset@394b3c11c3cfc038b5396ad265c074065cf875c3 # v1.10.2
uses: shogo82148/actions-upload-release-asset@ee2ae851dc5d938b90075b3ef12c540abfd1ee72 # v1.10.1
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
upload_url: ${{ steps.create-release.outputs.upload_url }}
@@ -202,17 +201,17 @@ jobs:
pull-requests: write
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: main
persist-credentials: true # for pushing changelog branch
- name: Set up Python
id: setup-python
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
- name: Install uv
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
with:
version: ${{ env.DEFAULT_UV_VERSION }}
enable-cache: false
+4 -4
View File
@@ -22,11 +22,11 @@ jobs:
security-events: write
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Run zizmor
uses: zizmorcore/zizmor-action@192e21d79ab29983730a13d1382995c2307fbcaa # v0.5.7
uses: zizmorcore/zizmor-action@5f14fd08f7cf1cb1609c1e344975f152c7ee938d # v0.5.6
semgrep:
name: Semgrep CE
runs-on: ubuntu-24.04
@@ -38,13 +38,13 @@ jobs:
security-events: write
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
- name: Run Semgrep
run: semgrep scan --config auto --sarif-output results.sarif
- name: Upload results to GitHub code scanning
uses: github/codeql-action/upload-sarif@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
uses: github/codeql-action/upload-sarif@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
if: always()
with:
sarif_file: results.sarif
+3 -3
View File
@@ -34,12 +34,12 @@ jobs:
# Learn more about CodeQL language support at https://git.io/codeql-language-support
steps:
- name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
uses: github/codeql-action/init@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
@@ -47,4 +47,4 @@ jobs:
# Prefix the list here with "+" to use these queries and those in the config file.
# queries: ./path/to/local/query, your-org/your-repo/queries@main
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
uses: github/codeql-action/analyze@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
+2 -2
View File
@@ -17,12 +17,12 @@ jobs:
environment: translation-sync
steps:
- name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
token: ${{ secrets.PNGX_BOT_PAT }}
persist-credentials: false
- name: crowdin action
uses: crowdin/github-action@52aa776766211d83d975df51f3b9c53c2f8ba35f # v2.16.3
uses: crowdin/github-action@8868a33591d21088edfc398968173a3b98d51706 # v2.16.2
with:
upload_translations: false
download_translations: true
+1 -1
View File
@@ -19,6 +19,6 @@ jobs:
if: github.event_name == 'pull_request_target' && (github.event.action == 'opened' || github.event.action == 'reopened') && github.event.pull_request.user.login != 'dependabot'
steps:
- name: Label PR with release-drafter
uses: release-drafter/release-drafter@4d75298e00d9e34c483e5ff8c68d0ea1c1940c1e # v7.5.1
uses: release-drafter/release-drafter@693d20e7c1ce1a81d3a41962f85914253b518449 # v7.3.1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+5 -5
View File
@@ -14,7 +14,7 @@ jobs:
contents: write
steps:
- name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
env:
GH_REF: ${{ github.ref }} # sonar rule:githubactions:S7630 - avoid injection
with:
@@ -23,13 +23,13 @@ jobs:
persist-credentials: true # for pushing translation branch
- name: Set up Python
id: setup-python
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
- name: Install system dependencies
run: |
sudo apt-get update -qq
sudo apt-get install -qq --no-install-recommends gettext
- name: Install uv
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
with:
version: ${{ env.DEFAULT_UV_VERSION }}
enable-cache: true
@@ -43,7 +43,7 @@ jobs:
PAPERLESS_SECRET_KEY: "ci-translate-not-a-real-secret"
run: cd src/ && uv run manage.py makemessages -l en_US -i "samples*"
- name: Install pnpm
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
uses: pnpm/action-setup@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8
with:
version: 10
- name: Use Node.js 24
@@ -54,7 +54,7 @@ jobs:
cache-dependency-path: 'src-ui/pnpm-lock.yaml'
- name: Cache frontend dependencies
id: cache-frontend-deps
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with:
path: |
~/.pnpm-store
+4 -4
View File
@@ -38,7 +38,7 @@ repos:
- json
# See https://github.com/prettier/prettier/issues/15742 for the fork reason
- repo: https://github.com/rbubley/mirrors-prettier
rev: 'v3.9.4'
rev: 'v3.8.4'
hooks:
- id: prettier
types_or:
@@ -46,16 +46,16 @@ repos:
- ts
- markdown
additional_dependencies:
- prettier@3.9.4
- prettier@3.8.3
- 'prettier-plugin-organize-imports@4.3.0'
# Python hooks
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.16.0
rev: v0.15.17
hooks:
- id: ruff-check
- id: ruff-format
- repo: https://github.com/tox-dev/pyproject-fmt
rev: "v2.25.1"
rev: "v2.24.1"
hooks:
- id: pyproject-fmt
additional_dependencies: [tomli]
+1 -5
View File
@@ -30,7 +30,7 @@ RUN set -eux \
# Purpose: Installs s6-overlay and rootfs
# Comments:
# - Don't leave anything extra in here either
FROM ghcr.io/astral-sh/uv:0.11.28-python3.12-trixie-slim AS s6-overlay-base
FROM ghcr.io/astral-sh/uv:0.11.19-python3.12-trixie-slim AS s6-overlay-base
WORKDIR /usr/src/s6
@@ -238,10 +238,6 @@ RUN set -eux \
&& chown --from root:root --changes --recursive paperless:paperless /usr/src/paperless \
&& echo "Making fontconfig cache writable for arbitrary container UIDs" \
&& chmod 1777 /var/cache/fontconfig \
&& echo "Making /run world-writable for rootless operation" \
&& chmod 1777 /run \
&& echo "Removing setuid from s6-overlay-suexec for rootless compat" \
&& chmod u-s /command/s6-overlay-suexec \
&& echo "Collecting static files" \
&& PAPERLESS_SECRET_KEY=build-time-dummy s6-setuidgid paperless python3 manage.py collectstatic --clear --no-input --link \
&& PAPERLESS_SECRET_KEY=build-time-dummy s6-setuidgid paperless python3 manage.py compilemessages \
+3 -3
View File
@@ -4,7 +4,7 @@
# correct networking for the tests
services:
gotenberg:
image: docker.io/gotenberg/gotenberg:8.34
image: docker.io/gotenberg/gotenberg:8.33
hostname: gotenberg
container_name: gotenberg
network_mode: host
@@ -24,7 +24,7 @@ services:
network_mode: host
restart: unless-stopped
greenmail:
image: docker.io/greenmail/standalone:2.1.9
image: docker.io/greenmail/standalone:2.1.8
hostname: greenmail
container_name: greenmail
environment:
@@ -35,7 +35,7 @@ services:
- "3143:3143" # IMAP
restart: unless-stopped
nginx:
image: docker.io/nginx:1.31.2-alpine
image: docker.io/nginx:1.31.1-alpine
hostname: nginx
container_name: nginx
ports:
@@ -72,7 +72,7 @@ services:
PAPERLESS_TIKA_GOTENBERG_ENDPOINT: http://gotenberg:3000
PAPERLESS_TIKA_ENDPOINT: http://tika:9998
gotenberg:
image: docker.io/gotenberg/gotenberg:8.34
image: docker.io/gotenberg/gotenberg:8.33
restart: unless-stopped
# The gotenberg chromium route is used to convert .eml files. We do not
# want to allow external content like tracking pixels or even javascript.
+1 -1
View File
@@ -56,7 +56,7 @@ services:
environment:
PAPERLESS_REDIS: redis://broker:6379
PAPERLESS_DBHOST: db
PAPERLESS_DBENGINE: postgresql
PAPERLESS_DBENGINE: postgres
env_file:
- stack.env
volumes:
@@ -67,7 +67,7 @@ services:
PAPERLESS_TIKA_GOTENBERG_ENDPOINT: http://gotenberg:3000
PAPERLESS_TIKA_ENDPOINT: http://tika:9998
gotenberg:
image: docker.io/gotenberg/gotenberg:8.34
image: docker.io/gotenberg/gotenberg:8.33
restart: unless-stopped
# The gotenberg chromium route is used to convert .eml files. We do not
# want to allow external content like tracking pixels or even javascript.
@@ -56,7 +56,7 @@ services:
PAPERLESS_TIKA_GOTENBERG_ENDPOINT: http://gotenberg:3000
PAPERLESS_TIKA_ENDPOINT: http://tika:9998
gotenberg:
image: docker.io/gotenberg/gotenberg:8.34
image: docker.io/gotenberg/gotenberg:8.33
restart: unless-stopped
# The gotenberg chromium route is used to convert .eml files. We do not
# want to allow external content like tracking pixels or even javascript.
+1 -1
View File
@@ -61,7 +61,7 @@ def replace_with_symlinks(
total_duplicates = 0
space_saved = 0
for file_list in duplicate_groups.values():
for file_hash, file_list in duplicate_groups.items():
# Keep the first file as the original, replace others with symlinks
original_file = file_list[0]
duplicates = file_list[1:]
+1 -926
View File
File diff suppressed because it is too large Load Diff
+9 -11
View File
@@ -1159,21 +1159,19 @@ still perform some basic text pre-processing before matching.
#### [`PAPERLESS_DATE_PARSER_LANGUAGES=<lang>`](#PAPERLESS_DATE_PARSER_LANGUAGES) {#PAPERLESS_DATE_PARSER_LANGUAGES}
: Specifies which language Paperless should use when parsing dates from documents.
Specifies which language Paperless should use when parsing dates from documents.
: This should be a language code supported by the dateparser library,
for example: "en", or a combination such as "en+de".
Locales are also supported (e.g., "en-AU").
Multiple languages can be combined using "+", for example: "en+de" or "en-AU+de".
For valid values, refer to the list of supported languages and locales in the [dateparser documentation](https://dateparser.readthedocs.io/en/latest/supported_locales.html).
: Set this to match the languages in which most of your documents are written.
This should be a language code supported by the dateparser library,
for example: "en", or a combination such as "en+de".
Locales are also supported (e.g., "en-AU").
Multiple languages can be combined using "+", for example: "en+de" or "en-AU+de".
For valid values, refer to the list of supported languages and locales in the [dateparser documentation](https://dateparser.readthedocs.io/en/latest/supported_locales.html).
Set this to match the languages in which most of your documents are written.
If not set, Paperless will attempt to infer the language(s) from the OCR configuration (`PAPERLESS_OCR_LANGUAGE`).
!!! note
This format differs from the `PAPERLESS_OCR_LANGUAGE` setting, which uses ISO 639-2 codes (3 letters, e.g., "eng+deu" for Tesseract OCR).
!!! note
This format differs from the `PAPERLESS_OCR_LANGUAGE` setting, which uses ISO 639-2 codes (3 letters, e.g., "eng+deu" for Tesseract OCR).
#### [`PAPERLESS_EMAIL_TASK_CRON=<cron expression>`](#PAPERLESS_EMAIL_TASK_CRON) {#PAPERLESS_EMAIL_TASK_CRON}
+4 -62
View File
@@ -133,7 +133,7 @@ PAPERLESS_DB_OPTIONS="sslmode=require,sslrootcert=/certs/ca.pem,pool.max_size=10
## OCR and Archive File Generation Settings
The settings that control OCR behaviour and archive file generation have been redesigned. The old settings that coupled these two concerns together are **removed** - old values are not silently honoured; a startup warning is logged if any removed variable is still set in your environment.
The settings that control OCR behaviour and archive file generation have been redesigned. The old settings that coupled these two concerns together are **removed** old values are not silently honoured; a startup warning is logged if any removed variable is still set in your environment.
### Removed settings
@@ -166,7 +166,7 @@ Remove any `PAPERLESS_OCR_SKIP_ARCHIVE_FILE` variable from your environment. If
# v2: skip OCR when text present, always archive
PAPERLESS_OCR_MODE=skip
# v3: equivalent (auto is the new default)
# No change needed - auto is the default
# No change needed auto is the default
# v2: skip OCR when text present, skip archive too
PAPERLESS_OCR_MODE=skip_noarchive
@@ -189,10 +189,10 @@ PAPERLESS_ARCHIVE_FILE_GENERATION=auto
If you use the **remote OCR parser** (Azure AI), note that it always produces a
searchable PDF and stores it as the archive copy. `ARCHIVE_FILE_GENERATION=never`
has no effect for documents handled by the remote parser - the archive is produced
has no effect for documents handled by the remote parser the archive is produced
unconditionally by the remote engine.
## Search Index (Whoosh -> Tantivy)
# Search Index (Whoosh -> Tantivy)
The full-text search backend has been replaced with [Tantivy](https://github.com/quickwit-oss/tantivy).
The index format is incompatible with Whoosh, so **the search index is automatically rebuilt from
@@ -327,64 +327,6 @@ behind a reverse proxy may need to set
[`PAPERLESS_ALLAUTH_TRUSTED_CLIENT_IP_HEADER`](configuration.md#PAPERLESS_ALLAUTH_TRUSTED_CLIENT_IP_HEADER),
or both, to avoid `403 Forbidden` errors on login.
## Minimum CPU Requirements (NumPy Baseline)
Starting with NumPy 2.4.0, official `manylinux` x86_64 wheels are compiled with a minimum
CPU baseline of `x86-64-v2`, which requires SSE3, SSSE3, SSE4.1, SSE4.2, POPCNT, and
CMPXCHG16B. This is a [deliberate upstream change](https://github.com/numpy/numpy/issues/27851)
citing that these instructions have been present in over 99.7% of CPUs since 2008
([Intel](<https://en.wikipedia.org/wiki/Nehalem_(microarchitecture)>)) or 2011
([AMD](<https://en.wikipedia.org/wiki/Bulldozer_(microarchitecture)>)).
NumPy is a dependency of the document classifier (via scikit-learn), so any CPU that
predates SSE4.2 support will crash with `SIGILL` (illegal instruction) when the classifier
is loaded or trained, regardless of whether AI features are enabled.
This differs from NumPy's optional SIMD dispatch (e.g. AVX2, AVX512), which is detected and
selected safely at runtime - the `x86-64-v2` requirement above is a hard floor baked into the
wheel, with no runtime fallback.
### Affected hardware
CPUs older than roughly 2008 (Intel) or 2011 (AMD) that lack SSE4.2 support. This is more
likely to affect low-power or embedded hardware - e.g. early Atom, Celeron, or pre-Bulldozer
AMD chips - than typical desktop or server hardware from the last decade.
Check for SSE4.2 support with:
```bash
grep -o -m1 sse4_2 /proc/cpuinfo
```
If this prints nothing, your CPU is affected.
### Symptoms
The Celery worker (and potentially the web server) repeatedly crashes and restarts with a
`SIGILL` error, typically visible in `dmesg`/`journalctl` as a `trap invalid opcode` inside
`_multiarray_umath...so`. Because the classifier is trained on a periodic schedule
(hourly, by default), affected instances see intermittent, hard-to-reproduce document
consumption failures whenever that scheduled task runs and takes down the worker process
mid-task.
### Action Required (for affected hardware only)
There is no way to make the classifier itself work on such CPUs - it requires an unofficial
NumPy build with `cpu-baseline=none`, which is not something we can ship. The practical
path forward is to stop the classifier from ever loading or training, which avoids
importing NumPy at all:
```bash
PAPERLESS_TRAIN_TASK_CRON=disable
```
This disables the periodic classifier training task (see
[`PAPERLESS_TRAIN_TASK_CRON`](configuration.md#PAPERLESS_TRAIN_TASK_CRON)). Automatic
matching based on the classifier (suggested correspondents, document types, tags, and
storage paths from trained rules) will no longer be available, but rule-based matching is
unaffected, and document consumption itself will no longer be at risk of crashing the
worker.
## Database Migrations
Some integer fields have been changed to smaller types to reduce database size. If you have any `MailRule` records with a `maximum_age` greater than 32767, they will be clamped to 32767 during the migration to avoid errors during migration.
+3 -3
View File
@@ -142,7 +142,7 @@ a [superuser](usage.md#superusers) account.
It is not possible to run the container rootless if additional languages are specified via `PAPERLESS_OCR_LANGUAGES`.
If you want to run Paperless as a rootless container, set `user:` in `docker-compose.yml` to the UID and GID of your host user (use `id -u` and `id -g` to find these values). The container process starts directly as that user with no internal privilege remapping. Any UID and GID is supported:
If you want to run Paperless as a rootless container, set `user:` in `docker-compose.yml` to the UID and GID of your host user (use `id -u` and `id -g` to find these values). The container process starts directly as that user with no internal privilege remapping:
```yaml
webserver:
@@ -157,8 +157,8 @@ Do not combine this with `USERMAP_UID` or `USERMAP_GID`, which are intended for
Some file systems, such as NFS network shares, don't support file system
notifications with `inotify`. When the consumption directory is on such a
file system, Paperless-ngx will not pick up new files with the default
configuration. Set [`PAPERLESS_CONSUMER_POLLING_INTERVAL`](configuration.md#PAPERLESS_CONSUMER_POLLING_INTERVAL)
to a positive number to enable polling and disable native filesystem notifications.
configuration. Use [`PAPERLESS_CONSUMER_POLLING`](configuration.md#PAPERLESS_CONSUMER_POLLING)
to enable polling and disable inotify. See [here](configuration.md#polling).
## Bare Metal Install {#bare_metal}
+11 -12
View File
@@ -458,8 +458,8 @@ For related metadata such as tags, correspondents, document types, and storage p
### Password reset
In order to enable the password reset feature you will need to setup an SMTP backend, see
[`PAPERLESS_EMAIL_HOST`](configuration.md#PAPERLESS_EMAIL_HOST). You should also set
[`PAPERLESS_URL`](configuration.md#PAPERLESS_URL) and / or its corresponding configuration settings.
[`PAPERLESS_EMAIL_HOST`](configuration.md#PAPERLESS_EMAIL_HOST). If your installation does not have
[`PAPERLESS_URL`](configuration.md#PAPERLESS_URL) set, the reset link included in emails will use the server host.
### Two-factor authentication
@@ -833,10 +833,10 @@ contract you signed 8 years ago).
When you search paperless for a document, it tries to match this query
against your documents. Paperless will look for matching documents by
inspecting their content, title, correspondent, type, and tags. Paperless
returns a scored list of results, so that documents matching your query
better will appear further up in the search results. Notes and custom field
values can be searched using the advanced search syntax described below.
inspecting their content, title, correspondent, type, tags, notes, and
custom field values. Paperless returns a scored list of results, so that
documents matching your query better will appear further up in the search
results.
By default, paperless returns only documents which contain all words
typed in the search bar. A few things to know about how matching works:
@@ -891,9 +891,9 @@ Supported date keywords: `today`, `yesterday`, `previous week`,
#### Searching custom fields
Custom field names and values are included in the full-text index, but they
are not searched by a plain, unqualified query. Use the advanced search syntax
to search by field name or value:
Custom field values are included in the full-text index, so a plain search
already matches documents whose custom field values contain your search terms.
To narrow by field name or value specifically:
```
custom_fields.value:policy
@@ -921,9 +921,8 @@ custom_fields.name:"Contract Number" custom_fields.value:1312
#### Searching notes
Notes are included in the full-text index, but they are not searched by a
plain, unqualified query. Use the advanced search syntax to search by note
author or content:
Notes content is included in full-text search automatically. To search
by note author or content specifically:
```
notes.user:alice
+7 -16
View File
@@ -1,6 +1,6 @@
[project]
name = "paperless-ngx"
version = "3.0.2"
version = "3.0.0"
description = "A community-supported supercharged document management system: scan, index and archive all your physical documents"
readme = "README.md"
requires-python = ">=3.11"
@@ -16,7 +16,7 @@ classifiers = [
dependencies = [
"azure-ai-documentintelligence>=1.0.2",
"babel>=2.17",
"bleach~=6.4.0",
"bleach~=6.3.0",
"celery[redis]~=5.6.2",
"channels~=4.2",
"channels-redis~=4.2",
@@ -56,7 +56,7 @@ dependencies = [
"llama-index-embeddings-openai-like>=0.2.2",
"llama-index-llms-ollama>=0.9.1",
"llama-index-llms-openai-like>=0.7.1",
"nltk~=3.10.0",
"nltk~=3.9.1",
"ocrmypdf~=17.4.2",
"openai>=2.32",
"pathvalidate~=3.3.1",
@@ -75,7 +75,7 @@ dependencies = [
"sqlite-vec==0.1.9",
"tantivy~=0.26.0",
"tika-client~=0.11.0",
"torch~=2.13.0",
"torch~=2.12.0",
"watchfiles>=1.1.1",
"whitenoise~=6.11",
"zxing-cpp~=3.0.0",
@@ -101,11 +101,11 @@ dev = [
{ include-group = "testing" },
]
docs = [
"zensical>=0.0.47",
"zensical>=0.0.43",
]
lint = [
"prek~=0.3.10",
"ruff~=0.16.0",
"ruff~=0.15.15",
]
testing = [
"daphne",
@@ -184,16 +184,12 @@ line-ending = "lf"
[tool.ruff.lint]
# https://docs.astral.sh/ruff/rules/
extend-select = [
"B", # https://docs.astral.sh/ruff/rules/#flake8-bugbear-b
"COM", # https://docs.astral.sh/ruff/rules/#flake8-commas-com
"DTZ", # https://docs.astral.sh/ruff/rules/#flake8-datetimez-dtz
"PERF", # https://docs.astral.sh/ruff/rules/#perflint-perf
"S324", # https://docs.astral.sh/ruff/rules/hashlib-insecure-hash-functions/
"DJ", # https://docs.astral.sh/ruff/rules/#flake8-django-dj
"EXE", # https://docs.astral.sh/ruff/rules/#flake8-executable-exe
"FBT", # https://docs.astral.sh/ruff/rules/#flake8-boolean-trap-fbt
"FLY", # https://docs.astral.sh/ruff/rules/#flynt-fly
"G", # https://docs.astral.sh/ruff/rules/#flake8-logging-format-g
"G201", # https://docs.astral.sh/ruff/rules/#flake8-logging-format-g
"I", # https://docs.astral.sh/ruff/rules/#isort-i
"ICN", # https://docs.astral.sh/ruff/rules/#flake8-import-conventions-icn
"INP", # https://docs.astral.sh/ruff/rules/#flake8-no-pep420-inp
@@ -213,15 +209,10 @@ extend-select = [
"W", # https://docs.astral.sh/ruff/rules/#pycodestyle-e-w
]
ignore = [
"BLE001", # blind except: too invasive to fix wholesale on ruff 0.16's default bump; revisit incrementally
"DJ001",
"G004", # f-strings in logging: accepted style in this codebase
"PLC0415",
"RUF012",
"SIM105",
"TRY002", # create-your-own-exception: too invasive to fix wholesale on ruff 0.16's default bump; revisit incrementally
"TRY201", # verbose-raise: too invasive to fix wholesale on ruff 0.16's default bump; revisit incrementally
"TRY401", # redundant exception in logging.exception: too invasive to fix wholesale on ruff 0.16's default bump; revisit incrementally
]
# Migrations
per-file-ignores."*/migrations/*.py" = [
+1 -13
View File
@@ -79,16 +79,6 @@
"glob": "{pdf.worker.min.mjs,pdf.min.mjs}",
"input": "node_modules/pdfjs-dist/legacy/build/",
"output": "/assets/js/"
},
{
"glob": "**/*",
"input": "node_modules/pdfjs-dist/wasm/",
"output": "/assets/wasm/"
},
{
"glob": "**/*",
"input": "node_modules/pdfjs-dist/iccs/",
"output": "/assets/iccs/"
}
],
"styles": [
@@ -165,9 +155,7 @@
"builder": "@angular-builders/jest:run",
"options": {
"tsConfig": "tsconfig.spec.json",
"zoneless": true,
"watch": false,
"coverage": true
"zoneless": false
}
},
"lint": {
+5 -24
View File
@@ -3,27 +3,6 @@ const angularTemplatePlugin = require('@angular-eslint/eslint-plugin-template')
const angularTemplateParser = require('@angular-eslint/template-parser')
const tsParser = require('@typescript-eslint/parser')
const angularTsRecommendedRules = {
'@angular-eslint/contextual-lifecycle': 'error',
'@angular-eslint/no-empty-lifecycle-method': 'error',
'@angular-eslint/no-input-rename': 'error',
'@angular-eslint/no-inputs-metadata-property': 'error',
'@angular-eslint/no-output-native': 'error',
'@angular-eslint/no-output-on-prefix': 'error',
'@angular-eslint/no-output-rename': 'error',
'@angular-eslint/no-outputs-metadata-property': 'error',
'@angular-eslint/prefer-inject': 'error',
'@angular-eslint/prefer-standalone': 'error',
'@angular-eslint/use-pipe-transform-interface': 'error',
'@angular-eslint/use-lifecycle-interface': 'warn',
}
const angularTemplateRecommendedRules = {
'@angular-eslint/template/banana-in-box': 'error',
'@angular-eslint/template/eqeqeq': 'error',
'@angular-eslint/template/no-negated-async': 'error',
'@angular-eslint/template/prefer-control-flow': 'error',
}
module.exports = [
{
ignores: ['projects/**/*', 'src/app/components/common/pdf-viewer/**'],
@@ -33,6 +12,8 @@ module.exports = [
languageOptions: {
parser: tsParser,
parserOptions: {
project: ['tsconfig.json'],
createDefaultProgram: true,
ecmaVersion: 2020,
sourceType: 'module',
},
@@ -41,9 +22,9 @@ module.exports = [
'@angular-eslint': angularEslintPlugin,
'@angular-eslint/template': angularTemplatePlugin,
},
processor: angularTemplatePlugin.processors['extract-inline-html'],
processor: '@angular-eslint/template/extract-inline-html',
rules: {
...angularTsRecommendedRules,
...angularEslintPlugin.configs.recommended.rules,
'@angular-eslint/directive-selector': [
'error',
{
@@ -71,7 +52,7 @@ module.exports = [
'@angular-eslint/template': angularTemplatePlugin,
},
rules: {
...angularTemplateRecommendedRules,
...angularTemplatePlugin.configs.recommended.rules,
},
},
]
+692 -748
View File
File diff suppressed because it is too large Load Diff
+41 -30
View File
@@ -1,26 +1,27 @@
{
"name": "paperless-ngx-ui",
"version": "3.0.2",
"version": "3.0.0",
"scripts": {
"preinstall": "npx only-allow pnpm",
"ng": "ng",
"start": "ng serve",
"build": "ng build",
"test": "ng test",
"test": "ng test --no-watch --coverage",
"lint": "ng lint"
},
"private": true,
"dependencies": {
"@angular/cdk": "^22.0.3",
"@angular/common": "~22.0.5",
"@angular/compiler": "~22.0.5",
"@angular/core": "~22.0.5",
"@angular/forms": "~22.0.5",
"@angular/localize": "~22.0.5",
"@angular/platform-browser": "~22.0.5",
"@angular/router": "~22.0.5",
"@ng-bootstrap/ng-bootstrap": "^21.0.0",
"@ng-select/ng-select": "^23.2.0",
"@angular/cdk": "^21.2.12",
"@angular/common": "~21.2.17",
"@angular/compiler": "~21.2.17",
"@angular/core": "~21.2.17",
"@angular/forms": "~21.2.14",
"@angular/localize": "~21.2.14",
"@angular/platform-browser": "~21.2.14",
"@angular/platform-browser-dynamic": "~21.2.14",
"@angular/router": "~21.2.14",
"@ng-bootstrap/ng-bootstrap": "^20.0.0",
"@ng-select/ng-select": "^21.8.2",
"@ngneat/dirty-check-forms": "^3.0.3",
"@popperjs/core": "^2.11.8",
"bootstrap": "^5.3.8",
@@ -28,29 +29,30 @@
"mime-names": "^1.0.0",
"ngx-bootstrap-icons": "^1.9.3",
"ngx-color": "^10.1.0",
"ngx-cookie-service": "^22.0.0",
"ngx-device-detector": "^12.0.0",
"ngx-ui-tour-ng-bootstrap": "^19.0.0",
"ngx-cookie-service": "^21.3.1",
"ngx-device-detector": "^11.0.0",
"ngx-ui-tour-ng-bootstrap": "^18.0.0",
"normalize-diacritics": "^5.0.0",
"pdfjs-dist": "^6.0.227",
"rxjs": "^7.8.2",
"tslib": "^2.8.1",
"utif": "^3.1.0",
"uuid": "^14.0.1"
"uuid": "^14.0.1",
"zone.js": "^0.16.2"
},
"devDependencies": {
"@angular-builders/custom-webpack": "^22.0.1",
"@angular-builders/jest": "^22.0.1",
"@angular-devkit/core": "^22.0.5",
"@angular-devkit/schematics": "^22.0.5",
"@angular-eslint/builder": "22.0.0",
"@angular-eslint/eslint-plugin": "22.0.0",
"@angular-eslint/eslint-plugin-template": "22.0.0",
"@angular-eslint/schematics": "22.0.0",
"@angular-eslint/template-parser": "22.0.0",
"@angular/build": "^22.0.5",
"@angular/cli": "~22.0.5",
"@angular/compiler-cli": "~22.0.5",
"@angular-builders/custom-webpack": "^21.0.3",
"@angular-builders/jest": "^21.0.3",
"@angular-devkit/core": "^21.2.12",
"@angular-devkit/schematics": "^21.2.12",
"@angular-eslint/builder": "21.4.0",
"@angular-eslint/eslint-plugin": "21.4.0",
"@angular-eslint/eslint-plugin-template": "21.4.0",
"@angular-eslint/schematics": "21.4.0",
"@angular-eslint/template-parser": "21.4.0",
"@angular/build": "^21.2.12",
"@angular/cli": "~21.2.12",
"@angular/compiler-cli": "~21.2.14",
"@codecov/webpack-plugin": "^2.0.1",
"@playwright/test": "^1.61.1",
"@types/jest": "^30.0.0",
@@ -66,8 +68,17 @@
"jest-websocket-mock": "^2.5.0",
"prettier-plugin-organize-imports": "^4.3.0",
"ts-node": "~10.9.1",
"typescript": "^6.0.3",
"typescript": "^5.9.3",
"webpack": "^5.107.2"
},
"packageManager": "pnpm@10.26.0"
"packageManager": "pnpm@10.17.1",
"pnpm": {
"onlyBuiltDependencies": [
"@parcel/watcher",
"canvas",
"esbuild",
"lmdb",
"msgpackr-extract"
]
}
}
+3855 -2703
View File
File diff suppressed because it is too large Load Diff
-21
View File
@@ -1,21 +0,0 @@
packages:
- "."
minimumReleaseAge: 10080
trustPolicy: no-downgrade
allowBuilds:
"@parcel/watcher": true
canvas: true
esbuild: true
lmdb: true
msgpackr-extract: true
normalize-diacritics: false
unrs-resolver: false
onlyBuiltDependencies:
- "@parcel/watcher"
- canvas
- esbuild
- lmdb
- msgpackr-extract
ignoredBuiltDependencies:
- normalize-diacritics
- unrs-resolver
+2 -2
View File
@@ -1,9 +1,9 @@
import '@angular/localize/init'
import { jest } from '@jest/globals'
import { setupZonelessTestEnv } from 'jest-preset-angular/setup-env/zoneless'
import { setupZoneTestEnv } from 'jest-preset-angular/setup-env/zone'
import { TextDecoder, TextEncoder } from 'node:util'
if (process.env.NODE_ENV === 'test') {
setupZonelessTestEnv()
setupZoneTestEnv()
}
;(globalThis as any).TextEncoder = TextEncoder as unknown as {
new (): TextEncoder
+9 -6
View File
@@ -1,6 +1,11 @@
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
import { provideHttpClientTesting } from '@angular/common/http/testing'
import { ComponentFixture, TestBed } from '@angular/core/testing'
import {
ComponentFixture,
fakeAsync,
TestBed,
tick,
} from '@angular/core/testing'
import { Router, RouterModule } from '@angular/router'
import { NgbModalModule } from '@ng-bootstrap/ng-bootstrap'
import { allIcons, NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
@@ -67,8 +72,7 @@ describe('AppComponent', () => {
component = fixture.componentInstance
})
it('should initialize the tour service & toggle class on body for styling', () => {
jest.useFakeTimers()
it('should initialize the tour service & toggle class on body for styling', fakeAsync(() => {
jest.spyOn(console, 'warn').mockImplementation(() => {})
fixture.detectChanges()
const tourSpy = jest.spyOn(tourService, 'initialize')
@@ -77,10 +81,9 @@ describe('AppComponent', () => {
tourService.start()
expect(document.body.classList).toContain('tour-active')
tourService.end()
jest.advanceTimersByTime(500)
tick(500)
expect(document.body.classList).not.toContain('tour-active')
jest.useRealTimers()
})
}))
it('should display toast on document consumed with link if user has access', () => {
const navigateSpy = jest.spyOn(router, 'navigate')
@@ -59,10 +59,10 @@
<div [ngbNavOutlet]="nav" class="border-start border-end border-bottom p-3 mb-3 shadow-sm"></div>
<div class="btn-toolbar" role="toolbar">
<div class="btn-group me-2">
<button type="button" (click)="discardChanges()" class="btn btn-outline-secondary" [disabled]="loading() || (isDirty$ | async) === false" i18n>Cancel</button>
<button type="button" (click)="discardChanges()" class="btn btn-outline-secondary" [disabled]="loading || (isDirty$ | async) === false" i18n>Cancel</button>
</div>
<div class="btn-group">
<button type="submit" class="btn btn-primary" [disabled]="loading() || !configForm.valid || (isDirty$ | async) === false" i18n>Save</button>
<button type="submit" class="btn btn-primary" [disabled]="loading || !configForm.valid || (isDirty$ | async) === false" i18n>Save</button>
</div>
</div>
</form>
@@ -97,11 +97,11 @@ export class ConfigComponent
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe({
next: (config) => {
this.loading.set(false)
this.loading = false
this.initialize(config)
},
error: (e) => {
this.loading.set(false)
this.loading = false
this.toastService.showError($localize`Error retrieving config`, e)
},
})
@@ -162,20 +162,20 @@ export class ConfigComponent
}
public saveConfig() {
this.loading.set(true)
this.loading = true
this.configService
.saveConfig(this.configForm.value as PaperlessConfig)
.pipe(takeUntil(this.unsubscribeNotifier), first())
.subscribe({
next: (config) => {
this.loading.set(false)
this.loading = false
this.initialize(config)
this.store.next(config)
this.settingsService.initializeSettings().subscribe()
this.toastService.showInfo($localize`Configuration updated`)
},
error: (e) => {
this.loading.set(false)
this.loading = false
this.toastService.showError(
$localize`An error occurred updating configuration`,
e
@@ -189,20 +189,20 @@ export class ConfigComponent
}
public uploadFile(file: File, key: string) {
this.loading.set(true)
this.loading = true
this.configService
.uploadFile(file, this.configForm.value['id'], key)
.pipe(takeUntil(this.unsubscribeNotifier), first())
.subscribe({
next: (config) => {
this.loading.set(false)
this.loading = false
this.initialize(config)
this.store.next(config)
this.settingsService.initializeSettings().subscribe()
this.toastService.showInfo($localize`File successfully updated`)
},
error: (e) => {
this.loading.set(false)
this.loading = false
this.toastService.showError(
$localize`An error occurred uploading file`,
e
@@ -5,37 +5,36 @@
i18n-info>
<div class="input-group input-group-sm align-items-center">
<div class="input-group input-group-sm me-3">
<label class="input-group-text text-muted" for="logLineLimit" i18n>Show</label>
<span class="input-group-text text-muted" i18n>Show</span>
<input
id="logLineLimit"
class="form-control"
type="number"
min="100"
step="100"
[ngModel]="limit()"
(ngModelChange)="limit.set($event); onLimitChange($event)"
[(ngModel)]="limit"
(ngModelChange)="onLimitChange($event)"
style="width: 100px;">
<span class="input-group-text text-muted" i18n>lines</span>
</div>
<div class="form-check form-switch mt-1">
<input id="autoRefreshSwitch" class="form-check-input" type="checkbox" role="switch" [ngModel]="autoRefreshEnabled()" (ngModelChange)="autoRefreshEnabled.set($event)">
<input class="form-check-input" type="checkbox" role="switch" [(ngModel)]="autoRefreshEnabled">
<label class="form-check-label" for="autoRefreshSwitch" i18n>Auto refresh</label>
</div>
</div>
</pngx-page-header>
<ul ngbNav #nav="ngbNav" [activeId]="activeLog()" (activeIdChange)="activeLog.set($event); reloadLogs()" class="nav-tabs">
@for (logFile of logFiles(); track logFile) {
<ul ngbNav #nav="ngbNav" [(activeId)]="activeLog" (activeIdChange)="reloadLogs()" class="nav-tabs">
@for (logFile of logFiles; track logFile) {
<li [ngbNavItem]="logFile">
<a ngbNavLink>
{{logFile}}.log
</a>
</li>
}
@if (loading() || !logFiles().length) {
@if (loading || !logFiles.length) {
<div class="ps-2 d-flex align-items-center">
<div class="spinner-border spinner-border-sm me-2" role="status"></div>
@if (!logFiles().length) {
@if (!logFiles.length) {
<ng-container i18n>Loading...</ng-container>
}
</div>
@@ -43,13 +42,13 @@
</ul>
<div #logContainer class="bg-dark text-light font-monospace log-container p-3" (scroll)="onScroll()">
@if (loading() && !logFiles().length) {
@if (loading && !logFiles.length) {
<div>
<div class="spinner-border spinner-border-sm me-2" role="status"></div>
<ng-container i18n>Loading...</ng-container>
</div>
} @else {
@for (log of logs(); track log) {
@for (log of logs; track log) {
<p class="m-0 p-0" [ngClass]="'log-entry-' + log.level">{{log.message}}</p>
}
}
@@ -57,7 +56,7 @@
<button
type="button"
class="btn btn-sm btn-secondary jump-to-bottom position-fixed bottom-0 end-0 m-5"
[class.visible]="showJumpToBottom()"
[class.visible]="showJumpToBottom"
(click)="scrollToBottom()"
>
<span i18n>Jump to bottom</span>
@@ -86,7 +86,7 @@ describe('LogsComponent', () => {
throwError(() => new Error('error getting logs'))
)
component.reloadLogs()
expect(component.logs()).toHaveLength(0)
expect(component.logs).toHaveLength(0)
})
it('should auto refresh, allow toggle', () => {
@@ -97,7 +97,7 @@ describe('LogsComponent', () => {
jest.advanceTimersByTime(6000)
expect(reloadSpy).toHaveBeenCalledTimes(2)
component.autoRefreshEnabled.set(false)
component.autoRefreshEnabled = false
jest.advanceTimersByTime(6000)
expect(reloadSpy).toHaveBeenCalledTimes(2)
})
@@ -112,9 +112,9 @@ describe('LogsComponent', () => {
})
it('should update jump to bottom visibility on scroll', () => {
component.showJumpToBottom.set(false)
component.showJumpToBottom = false
jest.spyOn(component as any, 'isNearBottom').mockReturnValue(false)
component.onScroll()
expect(component.showJumpToBottom()).toBe(true)
expect(component.showJumpToBottom).toBe(true)
})
})
@@ -7,7 +7,6 @@ import {
OnInit,
ViewChild,
inject,
signal,
} from '@angular/core'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { NgbNavModule } from '@ng-bootstrap/ng-bootstrap'
@@ -35,17 +34,17 @@ export class LogsComponent
private logService = inject(LogService)
private changedetectorRef = inject(ChangeDetectorRef)
readonly logs = signal<Array<{ message: string; level: number }>>([])
public logs: Array<{ message: string; level: number }> = []
readonly logFiles = signal<string[]>([])
public logFiles: string[] = []
readonly activeLog = signal<string>(undefined)
public activeLog: string
readonly autoRefreshEnabled = signal<boolean>(true)
public autoRefreshEnabled: boolean = true
readonly limit = signal<number>(5000)
public limit: number = 5000
readonly showJumpToBottom = signal<boolean>(false)
public showJumpToBottom = false
private readonly limitChange$ = new Subject<number>()
@@ -60,15 +59,15 @@ export class LogsComponent
.list()
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe((result) => {
this.logFiles.set(result)
this.loading.set(false)
if (this.logFiles().length > 0) {
this.activeLog.set(this.logFiles()[0])
this.logFiles = result
this.loading = false
if (this.logFiles.length > 0) {
this.activeLog = this.logFiles[0]
this.reloadLogs()
}
timer(5000, 5000)
.pipe(
filter(() => this.autoRefreshEnabled()),
filter(() => this.autoRefreshEnabled),
takeUntil(this.unsubscribeNotifier)
)
.subscribe(() => {
@@ -86,34 +85,36 @@ export class LogsComponent
}
reloadLogs() {
this.loading.set(true)
this.loading = true
const shouldStickToBottom = this.isNearBottom()
this.logService
.get(this.activeLog(), this.limit())
.get(this.activeLog, this.limit)
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe({
next: (result) => {
this.loading.set(false)
this.loading = false
const parsed = this.parseLogsWithLevel(result)
const hasChanges =
parsed.length !== this.logs().length ||
parsed.length !== this.logs.length ||
parsed.some((log, idx) => {
const current = this.logs()[idx]
const current = this.logs[idx]
return (
current?.message !== log.message || current?.level !== log.level
!current ||
current.message !== log.message ||
current.level !== log.level
)
})
if (hasChanges) {
this.logs.set(parsed)
this.logs = parsed
if (shouldStickToBottom) {
this.scrollToBottom()
}
this.showJumpToBottom.set(!shouldStickToBottom)
this.showJumpToBottom = !shouldStickToBottom
}
},
error: () => {
this.logs.set([])
this.loading.set(false)
this.logs = []
this.loading = false
},
})
}
@@ -148,7 +149,7 @@ export class LogsComponent
}
this.changedetectorRef.detectChanges()
viewport.scrollTop = viewport.scrollHeight
this.showJumpToBottom.set(false)
this.showJumpToBottom = false
}
private isNearBottom(): boolean {
@@ -161,6 +162,6 @@ export class LogsComponent
}
onScroll(): void {
this.showJumpToBottom.set(!this.isNearBottom())
this.showJumpToBottom = !this.isNearBottom()
}
}
@@ -9,8 +9,8 @@
</button>
@if (canViewSystemStatus) {
<button class="btn btn-sm btn-outline-primary position-relative ms-md-5 me-1" (click)="showSystemStatus()"
[disabled]="!systemStatus()">
@if (!systemStatus()) {
[disabled]="!systemStatus">
@if (!systemStatus) {
<div class="spinner-border spinner-border-sm me-2 h-75" role="status"></div>
} @else {
<i-bs class="me-2" name="card-checklist"></i-bs>
@@ -37,7 +37,7 @@
<form [formGroup]="settingsForm" (ngSubmit)="saveSettings()">
<ul ngbNav #nav="ngbNav" (navChange)="onNavChange($event)" [activeId]="activeNavID()" (activeIdChange)="activeNavID.set($event)" class="nav-tabs">
<ul ngbNav #nav="ngbNav" (navChange)="onNavChange($event)" [(activeId)]="activeNavID" class="nav-tabs">
<li [ngbNavItem]="SettingsNavIDs.General">
<a ngbNavLink i18n>General</a>
<ng-template ngbNavContent>
@@ -150,10 +150,10 @@
<div class="row mb-3">
<div class="col-md-3 col-form-label pt-0">
<label for="searchLink" i18n>Full search links to</label>
<span i18n>Full search links to</span>
</div>
<div class="col mb-3">
<select id="searchLink" class="form-select" formControlName="searchLink">
<select class="form-select" formControlName="searchLink">
<option [ngValue]="GlobalSearchType.TITLE_CONTENT" i18n>Title and content search</option>
<option [ngValue]="GlobalSearchType.ADVANCED" i18n>Advanced search</option>
</select>
@@ -199,10 +199,10 @@
<h5 i18n>Documents</h5>
<div class="row mb-3">
<div class="col-md-3 col-form-label pt-0">
<label for="documentListItemPerPage" i18n>Items per page</label>
<span i18n>Items per page</span>
</div>
<div class="col">
<select id="documentListItemPerPage" class="form-select" formControlName="documentListItemPerPage">
<select class="form-select" formControlName="documentListItemPerPage">
<option [ngValue]="10">10</option>
<option [ngValue]="25">25</option>
<option [ngValue]="50">50</option>
@@ -274,10 +274,10 @@
<h5 class="mt-3" i18n>PDF Editor</h5>
<div class="row">
<div class="col-md-3 col-form-label pt-0">
<label for="pdfEditorDefaultEditMode" i18n>Default editing mode</label>
<span i18n>Default editing mode</span>
</div>
<div class="col">
<select id="pdfEditorDefaultEditMode" class="form-select" formControlName="pdfEditorDefaultEditMode">
<select class="form-select" formControlName="pdfEditorDefaultEditMode">
<option [ngValue]="PdfEditorEditMode.Create" i18n>Create new document(s)</option>
<option [ngValue]="PdfEditorEditMode.Update" i18n>Add document version</option>
</select>
@@ -304,7 +304,7 @@
<div class="row mb-3">
<div class="col">
<p i18n>
Settings apply to this user account for objects (Tags, Mail Rules, etc.) created via the web UI. These settings do not apply to documents.
Settings apply to this user account for objects (Tags, Mail Rules, etc. but not documents) created via the web UI.
</p>
</div>
</div>
@@ -313,7 +313,7 @@
<span i18n>Default Owner</span>
</div>
<div class="col-md-5">
<pngx-input-select [items]="users()" bindLabel="username" formControlName="defaultPermsOwner" [allowNull]="true"></pngx-input-select>
<pngx-input-select [items]="users" bindLabel="username" formControlName="defaultPermsOwner" [allowNull]="true"></pngx-input-select>
<small class="form-text text-muted text-end d-block mt-n2" i18n>Objects without an owner can be viewed and edited by all users</small>
</div>
</div>
@@ -99,13 +99,6 @@ const status: SystemStatus = {
llmindex_status: SystemStatusItemStatus.DISABLED,
llmindex_last_modified: new Date().toISOString(),
llmindex_error: null,
summary: {
days: 30,
total_count: 12,
pending_count: 1,
success_count: 10,
failure_count: 1,
},
},
}
@@ -168,7 +161,7 @@ describe('SettingsComponent', () => {
viewportScroller = TestBed.inject(ViewportScroller)
toastService = TestBed.inject(ToastService)
settingsService = TestBed.inject(SettingsService)
settingsService.currentUser.set(users[0])
settingsService.currentUser = users[0]
userService = TestBed.inject(UserService)
permissionsService = TestBed.inject(PermissionsService)
modalService = TestBed.inject(NgbModal)
@@ -209,7 +202,7 @@ describe('SettingsComponent', () => {
fixture.detectChanges()
}
it('should support tabbed settings & change URL, prevent navigation if dirty confirmation rejected', async () => {
it('should support tabbed settings & change URL, prevent navigation if dirty confirmation rejected', () => {
completeSetup()
const navigateSpy = jest.spyOn(router, 'navigate')
const tabButtons = fixture.debugElement.queryAll(By.directive(NgbNavLink))
@@ -217,19 +210,16 @@ describe('SettingsComponent', () => {
expect(navigateSpy).toHaveBeenCalledWith(['settings', 'documents'])
tabButtons[2].nativeElement.dispatchEvent(new MouseEvent('click'))
expect(navigateSpy).toHaveBeenCalledWith(['settings', 'permissions'])
await fixture.whenStable()
const initSpy = jest.spyOn(component, 'initialize')
component.isDirty = true // mock dirty
navigateSpy.mockResolvedValueOnce(false) // nav rejected cause dirty
tabButtons[0].nativeElement.dispatchEvent(new MouseEvent('click'))
await fixture.whenStable()
expect(navigateSpy).toHaveBeenCalledWith(['settings', 'general'])
expect(initSpy).not.toHaveBeenCalled()
navigateSpy.mockResolvedValueOnce(true) // nav accepted even though dirty
tabButtons[2].nativeElement.dispatchEvent(new MouseEvent('click'))
await fixture.whenStable()
expect(navigateSpy).toHaveBeenCalledWith(['settings', 'permissions'])
expect(initSpy).toHaveBeenCalled()
})
@@ -242,7 +232,7 @@ describe('SettingsComponent', () => {
activatedRoute.snapshot.fragment = '#notifications'
const scrollSpy = jest.spyOn(viewportScroller, 'scrollToAnchor')
component.ngOnInit()
expect(component.activeNavID()).toEqual(4) // Notifications
expect(component.activeNavID).toEqual(4) // Notifications
component.ngAfterViewInit()
expect(scrollSpy).toHaveBeenCalledWith('#notifications')
})
@@ -350,13 +340,13 @@ describe('SettingsComponent', () => {
type === PermissionType.SystemMonitoring
)
completeSetup()
expect(component.systemStatus()).toEqual(status)
expect(component['systemStatus']).toEqual(status) // private
expect(component.systemStatusHasErrors).toBeTruthy()
// coverage
component.systemStatus().database.status = SystemStatusItemStatus.OK
component.systemStatus().tasks.redis_status = SystemStatusItemStatus.OK
component.systemStatus().tasks.celery_status = SystemStatusItemStatus.OK
component.systemStatus().tasks.sanity_check_status =
component['systemStatus'].database.status = SystemStatusItemStatus.OK
component['systemStatus'].tasks.redis_status = SystemStatusItemStatus.OK
component['systemStatus'].tasks.celery_status = SystemStatusItemStatus.OK
component['systemStatus'].tasks.sanity_check_status =
SystemStatusItemStatus.OK
expect(component.systemStatusHasErrors).toBeFalsy()
})
@@ -399,17 +389,17 @@ describe('SettingsComponent', () => {
completeSetup()
const field = 'storage_path'
expect(
component.settingsForm.get('documentDetailsHiddenFields').value
).toHaveLength(0)
component.settingsForm.get('documentDetailsHiddenFields').value.length
).toEqual(0)
component.toggleDocumentDetailField(field, false)
expect(
component.settingsForm.get('documentDetailsHiddenFields').value
).toHaveLength(1)
component.settingsForm.get('documentDetailsHiddenFields').value.length
).toEqual(1)
expect(component.isDocumentDetailFieldShown(field)).toBeFalsy()
component.toggleDocumentDetailField(field, true)
expect(
component.settingsForm.get('documentDetailsHiddenFields').value
).toHaveLength(0)
component.settingsForm.get('documentDetailsHiddenFields').value.length
).toEqual(0)
expect(component.isDocumentDetailFieldShown(field)).toBeTruthy()
})
})
@@ -6,7 +6,6 @@ import {
OnDestroy,
OnInit,
inject,
signal,
} from '@angular/core'
import {
FormControl,
@@ -142,7 +141,7 @@ export class SettingsComponent
private systemStatusService = inject(SystemStatusService)
private savedViewsService = inject(SavedViewService)
readonly activeNavID = signal<number>(undefined)
activeNavID: number
settingsForm = new FormGroup({
bulkEditConfirmationDialogs: new FormControl(null),
@@ -190,10 +189,10 @@ export class SettingsComponent
unsubscribeNotifier: Subject<any> = new Subject()
savePending: boolean = false
readonly users = signal<User[]>(undefined)
readonly groups = signal<Group[]>(undefined)
users: User[]
groups: Group[]
public readonly systemStatus = signal<SystemStatus>(undefined)
public systemStatus: SystemStatus
public readonly GlobalSearchType = GlobalSearchType
@@ -204,18 +203,16 @@ export class SettingsComponent
public readonly documentDetailFieldOptions = documentDetailFieldOptions
get systemStatusHasErrors(): boolean {
const status = this.systemStatus()
if (!status) {
return false
}
return (
status.database.status === SystemStatusItemStatus.ERROR ||
status.tasks.redis_status === SystemStatusItemStatus.ERROR ||
status.tasks.celery_status === SystemStatusItemStatus.ERROR ||
status.tasks.index_status === SystemStatusItemStatus.ERROR ||
status.tasks.classifier_status === SystemStatusItemStatus.ERROR ||
status.tasks.sanity_check_status === SystemStatusItemStatus.ERROR ||
status.websocket_connected === SystemStatusItemStatus.ERROR
this.systemStatus.database.status === SystemStatusItemStatus.ERROR ||
this.systemStatus.tasks.redis_status === SystemStatusItemStatus.ERROR ||
this.systemStatus.tasks.celery_status === SystemStatusItemStatus.ERROR ||
this.systemStatus.tasks.index_status === SystemStatusItemStatus.ERROR ||
this.systemStatus.tasks.classifier_status ===
SystemStatusItemStatus.ERROR ||
this.systemStatus.tasks.sanity_check_status ===
SystemStatusItemStatus.ERROR ||
this.systemStatus.websocket_connected === SystemStatusItemStatus.ERROR
)
}
@@ -249,7 +246,7 @@ export class SettingsComponent
.pipe(first())
.subscribe({
next: (r) => {
this.users.set(r.results)
this.users = r.results
},
error: (e) => {
this.toastService.showError($localize`Error retrieving users`, e)
@@ -268,7 +265,7 @@ export class SettingsComponent
.pipe(first())
.subscribe({
next: (r) => {
this.groups.set(r.results)
this.groups = r.results
},
error: (e) => {
this.toastService.showError($localize`Error retrieving groups`, e)
@@ -283,7 +280,7 @@ export class SettingsComponent
(navID) => navID.toLowerCase() == section
)
if (navIDKey) {
this.activeNavID.set(SettingsNavIDs[navIDKey])
this.activeNavID = SettingsNavIDs[navIDKey]
}
}
})
@@ -386,7 +383,7 @@ export class SettingsComponent
.navigate(['settings', foundNavIDkey.toLowerCase()])
.then((navigated) => {
if (!navigated && this.isDirty) {
this.activeNavID.set(navChangeEvent.activeId)
this.activeNavID = navChangeEvent.activeId
} else if (navigated && this.isDirty) {
this.initialize()
}
@@ -434,7 +431,7 @@ export class SettingsComponent
if (this.canViewSystemStatus) {
this.systemStatusService.get().subscribe((status) => {
this.systemStatus.set(status)
this.systemStatus = status
})
}
}
@@ -667,6 +664,6 @@ export class SettingsComponent
size: 'xl',
}
)
modal.componentInstance.status.set(this.systemStatus())
modal.componentInstance.status = this.systemStatus
}
}
@@ -11,11 +11,11 @@
<button class="btn btn-sm btn-outline-primary me-2" (click)="dismissTasks()" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.PaperlessTask }" [disabled]="visibleTasks.length === 0">
<i-bs name="check2-all" class="me-1"></i-bs>{{dismissButtonText}}
</button>
<button class="btn btn-sm btn-outline-primary me-2" (click)="dismissAllTasks()" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.PaperlessTask }" [disabled]="totalTasks() === 0">
<button class="btn btn-sm btn-outline-primary me-2" (click)="dismissAllTasks()" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.PaperlessTask }" [disabled]="totalTasks === 0">
<i-bs name="check2-all" class="me-1"></i-bs><ng-container i18n>Dismiss all</ng-container>
</button>
<div class="form-check form-switch mb-0 ms-2">
<input id="autoRefreshSwitch" class="form-check-input" type="checkbox" role="switch" [(ngModel)]="autoRefreshEnabled">
<input class="form-check-input" type="checkbox" role="switch" [(ngModel)]="autoRefreshEnabled">
<label class="form-check-label" for="autoRefreshSwitch" i18n>Auto refresh</label>
</div>
</div>
@@ -93,8 +93,7 @@
<i-bs width="1em" height="1em" name="x"></i-bs>
</button>
}
<label class="visually-hidden" for="taskSearch" i18n>Search tasks</label>
<input #filterInput id="taskSearch" class="form-control form-control-sm" type="text"
<input #filterInput class="form-control form-control-sm" type="text"
(keyup)="filterInputKeyup($event)"
[(ngModel)]="filterText">
</div>
@@ -108,7 +107,7 @@
<ngb-pagination
[pageSize]="pageSize"
[collectionSize]="totalTasks()"
[collectionSize]="totalTasks"
[page]="page"
[maxSize]="5"
[rotate]="true"
@@ -212,7 +211,7 @@
<i-bs width="1.2em" height="1.2em" name="info-circle"></i-bs>
</button>
</td>
<td class="actions-col">
<td scope="row" class="actions-col">
<div class="btn-group" role="group">
<button class="btn btn-sm btn-outline-secondary" (click)="dismissTask(task); $event.stopPropagation();" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.PaperlessTask }">
<i-bs name="check" class="me-1"></i-bs><ng-container i18n>Dismiss</ng-container>
@@ -362,7 +362,7 @@ describe('TasksComponent', () => {
)
req.flush({ count: 2, results: [tasks[0], tasks[1]] })
expect(component.totalTasks()).toBe(2)
expect(component.totalTasks).toBe(2)
})
it('should apply task type and trigger source filters to the server-side task query', () => {
@@ -435,8 +435,8 @@ describe('TasksComponent', () => {
.flush(pageTwoTasks)
expect(component.page).toBe(2)
expect(component.totalTasks()).toBe(30)
expect(component.pagedTasks()).toEqual([tasks[0]])
expect(component.totalTasks).toBe(30)
expect(component.pagedTasks).toEqual([tasks[0]])
})
it('should not replace section counts with current-page counts', () => {
@@ -1,5 +1,5 @@
import { JsonPipe, NgTemplateOutlet } from '@angular/common'
import { Component, inject, OnDestroy, OnInit, signal } from '@angular/core'
import { Component, inject, OnDestroy, OnInit } from '@angular/core'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { Router, RouterLink } from '@angular/router'
import {
@@ -166,14 +166,14 @@ export class TasksComponent
public autoRefreshEnabled: boolean = true
public readonly pageSize = 25
public page: number = 1
readonly totalTasks = signal(0)
readonly sectionCounts = signal<Record<TaskSection, number>>({
public totalTasks: number = 0
public sectionCounts: Record<TaskSection, number> = {
[TaskSection.All]: 0,
[TaskSection.NeedsAttention]: 0,
[TaskSection.InProgress]: 0,
[TaskSection.Completed]: 0,
})
readonly pagedTasks = signal<PaperlessTask[]>([])
}
public pagedTasks: PaperlessTask[] = []
public selectedSection: TaskSection = TaskSection.All
public selectedTaskType: PaperlessTaskType | null = null
public selectedTriggerSource: PaperlessTaskTriggerSource | null = null
@@ -346,7 +346,7 @@ export class TasksComponent
backdrop: 'static',
})
modal.componentInstance.title = $localize`Confirm Dismiss All`
modal.componentInstance.messageBold = $localize`Dismiss all ${this.totalTasks()} tasks?`
modal.componentInstance.messageBold = $localize`Dismiss all ${this.totalTasks} tasks?`
modal.componentInstance.btnClass = 'btn-warning'
modal.componentInstance.btnCaption = $localize`Dismiss`
modal.componentInstance.confirmClicked.pipe(first()).subscribe(() => {
@@ -465,7 +465,7 @@ export class TasksComponent
}
tasksForSection(section: TaskSection): PaperlessTask[] {
let tasks = this.pagedTasks().filter((task) =>
let tasks = this.pagedTasks.filter((task) =>
this.taskBelongsToSection(task, section)
)
@@ -477,14 +477,7 @@ export class TasksComponent
}
sectionCount(section: TaskSection): number {
return this.sectionCounts()[section]
}
private setSectionCount(section: TaskSection, count: number) {
this.sectionCounts.update((counts) => ({
...counts,
[section]: count,
}))
return this.sectionCounts[section]
}
sectionShowsResults(section: TaskSection): boolean {
@@ -659,7 +652,7 @@ export class TasksComponent
? this.sections
: [this.selectedSection]
return this.pagedTasks().filter(
return this.pagedTasks.filter(
(task) =>
sections.some((section) => this.taskBelongsToSection(task, section)) &&
this.taskMatchesFilters(task, { taskType, triggerSource })
@@ -671,12 +664,10 @@ export class TasksComponent
.statusCounts(this.getParamsForSection(TaskSection.All))
.pipe(first(), takeUntil(this.unsubscribeNotifier))
.subscribe((counts) => {
this.sectionCounts.set({
[TaskSection.All]: counts.all,
[TaskSection.NeedsAttention]: counts.needs_attention,
[TaskSection.InProgress]: counts.in_progress,
[TaskSection.Completed]: counts.completed,
})
this.sectionCounts[TaskSection.All] = counts.all
this.sectionCounts[TaskSection.NeedsAttention] = counts.needs_attention
this.sectionCounts[TaskSection.InProgress] = counts.in_progress
this.sectionCounts[TaskSection.Completed] = counts.completed
})
}
@@ -732,7 +723,7 @@ export class TasksComponent
this.reloadSectionCounts()
this.loading.set(true)
this.loading = true
this.tasksService
.list(
this.page,
@@ -742,24 +733,24 @@ export class TasksComponent
.pipe(first(), takeUntil(this.unsubscribeNotifier))
.subscribe({
next: (result) => {
this.pagedTasks.set(result.results)
this.totalTasks.set(result.count)
this.setSectionCount(TaskSection.All, result.count)
this.pagedTasks = result.results
this.totalTasks = result.count
this.sectionCounts[TaskSection.All] = result.count
if (this.selectedSection !== TaskSection.All) {
this.setSectionCount(this.selectedSection, result.count)
this.sectionCounts[this.selectedSection] = result.count
}
this.loading.set(false)
this.loading = false
if (
this.page > 1 &&
this.pagedTasks().length === 0 &&
this.totalTasks() > 0
this.pagedTasks.length === 0 &&
this.totalTasks > 0
) {
this.page -= 1
this.reloadPage()
}
},
error: () => {
this.loading.set(false)
this.loading = false
},
})
}
@@ -4,22 +4,22 @@
info="Manage trashed documents that are pending deletion."
i18n-info
infoLink="usage/#document-trash">
<button class="btn btn-sm btn-outline-secondary" (click)="clearSelection()" [hidden]="selectedDocuments().size === 0">
<button class="btn btn-sm btn-outline-secondary" (click)="clearSelection()" [hidden]="selectedDocuments.size === 0">
<i-bs name="x" class="me-1"></i-bs><ng-container i18n>Clear selection</ng-container>
</button>
<button type="button" class="btn btn-sm btn-outline-primary" (click)="restoreAll(selectedDocuments())" [disabled]="selectedDocuments().size === 0">
<button type="button" class="btn btn-sm btn-outline-primary" (click)="restoreAll(selectedDocuments)" [disabled]="selectedDocuments.size === 0">
<i-bs name="arrow-counterclockwise" class="me-1"></i-bs><ng-container i18n>Restore selected</ng-container>
</button>
<button type="button" class="btn btn-sm btn-outline-danger" (click)="emptyTrash(selectedDocuments())" [disabled]="selectedDocuments().size === 0">
<button type="button" class="btn btn-sm btn-outline-danger" (click)="emptyTrash(selectedDocuments)" [disabled]="selectedDocuments.size === 0">
<i-bs name="trash" class="me-1"></i-bs><ng-container i18n>Delete selected</ng-container>
</button>
<button type="button" class="btn btn-sm btn-outline-danger" (click)="emptyTrash()" [disabled]="documentsInTrash().length === 0">
<button type="button" class="btn btn-sm btn-outline-danger" (click)="emptyTrash()" [disabled]="documentsInTrash.length === 0">
<i-bs name="trash" class="me-1"></i-bs><ng-container i18n>Empty trash</ng-container>
</button>
</pngx-page-header>
<div class="row mb-3">
<ngb-pagination class="col-auto" [pageSize]="25" [collectionSize]="totalDocuments()" [page]="page()" [maxSize]="5" (pageChange)="page.set($event); reload()" size="sm" aria-label="Pagination"></ngb-pagination>
<ngb-pagination class="col-auto" [pageSize]="25" [collectionSize]="totalDocuments" [(page)]="page" [maxSize]="5" (pageChange)="reload()" size="sm" aria-label="Pagination"></ngb-pagination>
</div>
<div class="card border table-responsive mb-3">
@@ -28,7 +28,7 @@
<tr>
<th scope="col">
<div class="form-check m-0 ms-2 me-n2">
<input type="checkbox" class="form-check-input" id="all-objects" [ngModel]="allToggled()" (ngModelChange)="allToggled.set($event)" [disabled]="documentsInTrash().length === 0" (click)="toggleAll($event); $event.stopPropagation();">
<input type="checkbox" class="form-check-input" id="all-objects" [(ngModel)]="allToggled" [disabled]="documentsInTrash.length === 0" (click)="toggleAll($event); $event.stopPropagation();">
<label class="form-check-label" for="all-objects"></label>
</div>
</th>
@@ -38,7 +38,7 @@
</tr>
</thead>
<tbody>
@if (loading()) {
@if (loading) {
<tr>
<td colspan="5">
<div class="spinner-border spinner-border-sm me-2" role="status"></div>
@@ -46,11 +46,11 @@
</td>
</tr>
}
@for (document of documentsInTrash(); track document.id) {
<tr (click)="toggleSelected(document); $event.stopPropagation();" (mouseleave)="popupPreview.close()" class="data-row fade" [class.show]="show()">
@for (document of documentsInTrash; track document.id) {
<tr (click)="toggleSelected(document); $event.stopPropagation();" (mouseleave)="popupPreview.close()" class="data-row fade" [class.show]="show">
<td>
<div class="form-check m-0 ms-2 me-n2">
<input type="checkbox" class="form-check-input" id="{{document.id}}" [checked]="selectedDocuments().has(document.id)" (click)="toggleSelected(document); $event.stopPropagation();">
<input type="checkbox" class="form-check-input" id="{{document.id}}" [checked]="selectedDocuments.has(document.id)" (click)="toggleSelected(document); $event.stopPropagation();">
<label class="form-check-label" for="{{document.id}}"></label>
</div>
</td>
@@ -88,16 +88,16 @@
</table>
</div>
@if (!loading()) {
@if (!loading) {
<div class="d-flex mb-2">
<div>
<ng-container i18n>{totalDocuments(), plural, =1 {One document in trash} other {{{totalDocuments() || 0}} total documents in trash}}</ng-container>
@if (selectedDocuments().size > 0) {
&nbsp;({{selectedDocuments().size}} selected)
<ng-container i18n>{totalDocuments, plural, =1 {One document in trash} other {{{totalDocuments || 0}} total documents in trash}}</ng-container>
@if (selectedDocuments.size > 0) {
&nbsp;({{selectedDocuments.size}} selected)
}
</div>
@if (documentsInTrash().length > 20) {
<ngb-pagination class="ms-auto" [pageSize]="25" [collectionSize]="totalDocuments()" [page]="page()" [maxSize]="5" (pageChange)="page.set($event); reload()" size="sm" aria-label="Pagination"></ngb-pagination>
@if (documentsInTrash.length > 20) {
<ngb-pagination class="ms-auto" [pageSize]="25" [collectionSize]="totalDocuments" [(page)]="page" [maxSize]="5" (pageChange)="reload()" size="sm" aria-label="Pagination"></ngb-pagination>
}
</div>
}
@@ -60,15 +60,10 @@ describe('TrashComponent', () => {
modalService = TestBed.inject(NgbModal)
toastService = TestBed.inject(ToastService)
router = TestBed.inject(Router)
jest.spyOn(router, 'navigate').mockResolvedValue(true)
component = fixture.componentInstance
fixture.detectChanges()
})
afterEach(() => {
jest.useRealTimers()
})
it('should call correct service method on reload', () => {
jest.useFakeTimers()
const trashSpy = jest.spyOn(trashService, 'getTrash')
@@ -82,7 +77,7 @@ describe('TrashComponent', () => {
component.reload()
jest.advanceTimersByTime(100)
expect(trashSpy).toHaveBeenCalled()
expect(component.documentsInTrash()).toEqual(documentsInTrash)
expect(component.documentsInTrash).toEqual(documentsInTrash)
})
it('should support delete document, show error if needed', () => {
@@ -184,8 +179,8 @@ describe('TrashComponent', () => {
})
it('should support toggle all items in view', () => {
component.documentsInTrash.set(documentsInTrash)
expect(component.selectedDocuments().size).toEqual(0)
component.documentsInTrash = documentsInTrash
expect(component.selectedDocuments.size).toEqual(0)
const toggleAllSpy = jest.spyOn(component, 'toggleAll')
const checkButton = fixture.debugElement.queryAll(
By.css('input.form-check-input')
@@ -194,21 +189,21 @@ describe('TrashComponent', () => {
checkButton.nativeElement.checked = true
checkButton.nativeElement.dispatchEvent(new Event('click'))
expect(toggleAllSpy).toHaveBeenCalled()
expect(component.selectedDocuments().size).toEqual(documentsInTrash.length)
expect(component.selectedDocuments.size).toEqual(documentsInTrash.length)
})
it('should support toggle item', () => {
component.selectedDocuments.set(new Set([1]))
component.selectedDocuments = new Set([1])
component.toggleSelected(documentsInTrash[0])
expect(component.selectedDocuments().size).toEqual(0)
expect(component.selectedDocuments.size).toEqual(0)
component.toggleSelected(documentsInTrash[0])
expect(component.selectedDocuments().size).toEqual(1)
expect(component.selectedDocuments.size).toEqual(1)
})
it('should support clear selection', () => {
component.selectedDocuments.set(new Set([1]))
component.selectedDocuments = new Set([1])
component.clearSelection()
expect(component.selectedDocuments().size).toEqual(0)
expect(component.selectedDocuments.size).toEqual(0)
})
it('should correctly display days remaining', () => {
@@ -1,4 +1,4 @@
import { Component, OnDestroy, inject, signal } from '@angular/core'
import { Component, OnDestroy, inject } from '@angular/core'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { Router } from '@angular/router'
import {
@@ -7,7 +7,7 @@ import {
NgbPaginationModule,
} from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { takeUntil, tap } from 'rxjs'
import { delay, takeUntil, tap } from 'rxjs'
import { Document } from 'src/app/data/document'
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
import { SettingsService } from 'src/app/services/settings.service'
@@ -42,11 +42,11 @@ export class TrashComponent
private settingsService = inject(SettingsService)
private router = inject(Router)
readonly documentsInTrash = signal<Document[]>([])
readonly selectedDocuments = signal<Set<number>>(new Set())
readonly allToggled = signal(false)
readonly page = signal(1)
readonly totalDocuments = signal<number>(undefined)
public documentsInTrash: Document[] = []
public selectedDocuments: Set<number> = new Set()
public allToggled: boolean = false
public page: number = 1
public totalDocuments: number
constructor() {
super()
@@ -54,19 +54,20 @@ export class TrashComponent
}
reload() {
this.loading.set(true)
this.loading = true
this.trashService
.getTrash(this.page())
.getTrash(this.page)
.pipe(
tap((r) => {
this.documentsInTrash.set(r.results)
this.totalDocuments.set(r.count)
this.selectedDocuments.set(new Set())
this.loading.set(false)
})
this.documentsInTrash = r.results
this.totalDocuments = r.count
this.selectedDocuments.clear()
this.loading = false
}),
delay(100)
)
.subscribe(() => {
this.show.set(true)
this.show = true
})
}
@@ -121,7 +122,7 @@ export class TrashComponent
.subscribe({
next: () => {
this.toastService.showInfo($localize`Document(s) deleted`)
this.allToggled.set(false)
this.allToggled = false
modal.close()
this.reload()
},
@@ -164,7 +165,7 @@ export class TrashComponent
.subscribe({
next: () => {
this.toastService.showInfo($localize`Document(s) restored`)
this.allToggled.set(false)
this.allToggled = false
this.reload()
},
error: (err) => {
@@ -178,29 +179,24 @@ export class TrashComponent
toggleAll(event: PointerEvent) {
if ((event.target as HTMLInputElement).checked) {
this.selectedDocuments.set(
new Set(this.documentsInTrash().map((t) => t.id))
)
this.selectedDocuments = new Set(this.documentsInTrash.map((t) => t.id))
} else {
this.clearSelection()
}
}
toggleSelected(object: Document) {
const selectedDocuments = new Set(this.selectedDocuments())
selectedDocuments.has(object.id)
? selectedDocuments.delete(object.id)
: selectedDocuments.add(object.id)
this.selectedDocuments.set(selectedDocuments)
this.selectedDocuments.has(object.id)
? this.selectedDocuments.delete(object.id)
: this.selectedDocuments.add(object.id)
}
clearSelection() {
this.allToggled.set(false)
this.selectedDocuments.set(new Set())
this.allToggled = false
this.selectedDocuments.clear()
}
getDaysRemaining(document: Document): number {
this.settingsService.trackChanges()
const delay = this.settingsService.get(SETTINGS_KEYS.EMPTY_TRASH_DELAY)
const diff = new Date().getTime() - new Date(document.deleted_at).getTime()
const days = Math.ceil(diff / (1000 * 3600 * 24))
@@ -7,7 +7,7 @@
>
</pngx-page-header>
@if (canViewUsers && users()) {
@if (canViewUsers && users) {
<h4 class="d-flex">
<ng-container i18n>Users</ng-container>
<button type="button" class="btn btn-sm btn-outline-primary ms-4" (click)="editUser()" *pngxIfPermissions="{ action: PermissionAction.Add, type: PermissionType.User }">
@@ -23,7 +23,7 @@
<div class="col" i18n>Actions</div>
</div>
</li>
@for (user of users(); track user) {
@for (user of users; track user) {
<li class="list-group-item">
<div class="row">
<div class="col d-flex align-items-center" [class.opacity-50]="!user.is_active"><button class="btn btn-link p-0 text-start" type="button" (click)="editUser(user)" [disabled]="!permissionsService.currentUserCan(PermissionAction.Change, PermissionType.User)">{{user.username}}</button></div>
@@ -45,7 +45,7 @@
</ul>
}
@if (canViewGroups && groups()) {
@if (canViewGroups && groups) {
<h4 class="mt-4 d-flex">
<ng-container i18n>Groups</ng-container>
<button type="button" class="btn btn-sm btn-outline-primary ms-4" (click)="editGroup()" *pngxIfPermissions="{ action: PermissionAction.Add, type: PermissionType.Group }">
@@ -61,7 +61,7 @@
<div class="col" i18n>Actions</div>
</div>
</li>
@for (group of groups(); track group) {
@for (group of groups; track group) {
<li class="list-group-item">
<div class="row">
<div class="col d-flex align-items-center"><button class="btn btn-link p-0 text-start" type="button" (click)="editGroup(group)" [disabled]="!permissionsService.currentUserCan(PermissionAction.Change, PermissionType.Group)">{{group.name}}</button></div>
@@ -80,13 +80,13 @@
</div>
</li>
}
@if (groups().length === 0) {
@if (groups.length === 0) {
<li class="list-group-item" i18n>No groups defined</li>
}
</ul>
}
@if ((canViewUsers && !users()) || (canViewGroups && !groups())) {
@if ((canViewUsers && !users) || (canViewGroups && !groups)) {
<div>
<div class="spinner-border spinner-border-sm fw-normal ms-2 me-auto" role="status"></div>
<div class="visually-hidden" i18n>Loading...</div>
@@ -1,7 +1,12 @@
import { DatePipe } from '@angular/common'
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
import { provideHttpClientTesting } from '@angular/common/http/testing'
import { ComponentFixture, TestBed } from '@angular/core/testing'
import {
ComponentFixture,
TestBed,
fakeAsync,
tick,
} from '@angular/core/testing'
import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
import { of, throwError } from 'rxjs'
@@ -52,7 +57,7 @@ describe('UsersAndGroupsComponent', () => {
}).compileComponents()
fixture = TestBed.createComponent(UsersAndGroupsComponent)
settingsService = TestBed.inject(SettingsService)
settingsService.currentUser.set(users[0])
settingsService.currentUser = users[0]
userService = TestBed.inject(UserService)
modalService = TestBed.inject(NgbModal)
toastService = TestBed.inject(ToastService)
@@ -104,7 +109,7 @@ describe('UsersAndGroupsComponent', () => {
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
editDialog.failed.emit()
expect(toastErrorSpy).toHaveBeenCalled()
settingsService.currentUser.set(users[1]) // simulate logged in as different user
settingsService.currentUser = users[1] // simulate logged in as different user
editDialog.succeeded.emit(users[0])
expect(toastInfoSpy).toHaveBeenCalledWith(
`Saved user "${users[0].username}".`
@@ -133,8 +138,7 @@ describe('UsersAndGroupsComponent', () => {
expect(toastInfoSpy).toHaveBeenCalledWith('Deleted user "user1"')
})
it('should logout current user if password changed, after delay', () => {
jest.useFakeTimers()
it('should logout current user if password changed, after delay', fakeAsync(() => {
completeSetup()
let modal: NgbModalRef
modalService.activeInstances.subscribe((refs) => (modal = refs[0]))
@@ -144,15 +148,14 @@ describe('UsersAndGroupsComponent', () => {
.mockImplementation(() => {})
const editDialog = modal.componentInstance as UserEditDialogComponent
editDialog.passwordIsSet = true
settingsService.currentUser.set(users[0]) // simulate logged in as same user
settingsService.currentUser = users[0] // simulate logged in as same user
editDialog.succeeded.emit(users[0])
fixture.detectChanges()
jest.advanceTimersByTime(2600)
tick(2600)
expect(navSpy).toHaveBeenCalledWith(
`${window.location.origin}/accounts/logout/?next=/accounts/login/?next=/`
)
jest.useRealTimers()
})
}))
it('should support edit / create group, show error if needed', () => {
completeSetup()
@@ -1,4 +1,4 @@
import { Component, OnDestroy, OnInit, inject, signal } from '@angular/core'
import { Component, OnDestroy, OnInit, inject } from '@angular/core'
import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { Subject, first, takeUntil } from 'rxjs'
@@ -43,8 +43,8 @@ export class UsersAndGroupsComponent
permissionsService = inject(PermissionsService)
private settings = inject(SettingsService)
readonly users = signal<User[]>(null)
readonly groups = signal<Group[]>(null)
users: User[]
groups: Group[]
unsubscribeNotifier: Subject<any> = new Subject()
@@ -69,7 +69,7 @@ export class UsersAndGroupsComponent
.pipe(first(), takeUntil(this.unsubscribeNotifier))
.subscribe({
next: (r) => {
this.users.set(r.results)
this.users = r.results
},
error: (e) => {
this.toastService.showError($localize`Error retrieving users`, e)
@@ -83,7 +83,7 @@ export class UsersAndGroupsComponent
.pipe(first(), takeUntil(this.unsubscribeNotifier))
.subscribe({
next: (r) => {
this.groups.set(r.results)
this.groups = r.results
},
error: (e) => {
this.toastService.showError($localize`Error retrieving groups`, e)
@@ -101,15 +101,15 @@ export class UsersAndGroupsComponent
backdrop: 'static',
size: 'xl',
})
modal.componentInstance.dialogMode.set(
user ? EditDialogMode.EDIT : EditDialogMode.CREATE
)
modal.componentInstance.dialogMode = user
? EditDialogMode.EDIT
: EditDialogMode.CREATE
modal.componentInstance.object = user
modal.componentInstance.succeeded
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe((newUser: User) => {
if (
newUser.id === this.settings.currentUser().id &&
newUser.id === this.settings.currentUser.id &&
(modal.componentInstance as UserEditDialogComponent).passwordIsSet
) {
this.toastService.showInfo(
@@ -125,7 +125,7 @@ export class UsersAndGroupsComponent
$localize`Saved user "${newUser.username}".`
)
this.usersService.listAll().subscribe((r) => {
this.users.set(r.results)
this.users = r.results
})
}
})
@@ -152,7 +152,7 @@ export class UsersAndGroupsComponent
modal.close()
this.toastService.showInfo($localize`Deleted user "${user.username}"`)
this.usersService.listAll().subscribe((r) => {
this.users.set(r.results)
this.users = r.results
})
},
error: (e) => {
@@ -170,16 +170,16 @@ export class UsersAndGroupsComponent
backdrop: 'static',
size: 'lg',
})
modal.componentInstance.dialogMode.set(
group ? EditDialogMode.EDIT : EditDialogMode.CREATE
)
modal.componentInstance.dialogMode = group
? EditDialogMode.EDIT
: EditDialogMode.CREATE
modal.componentInstance.object = group
modal.componentInstance.succeeded
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe((newGroup) => {
this.toastService.showInfo($localize`Saved group "${newGroup.name}".`)
this.groupsService.listAll().subscribe((r) => {
this.groups.set(r.results)
this.groups = r.results
})
})
modal.componentInstance.failed
@@ -205,7 +205,7 @@ export class UsersAndGroupsComponent
modal.close()
this.toastService.showInfo($localize`Deleted group "${group.name}"`)
this.groupsService.listAll().subscribe((r) => {
this.groups.set(r.results)
this.groups = r.results
})
},
error: (e) => {
@@ -219,6 +219,6 @@ export class UsersAndGroupsComponent
}
getGroupName(id: number): string {
return this.groups()?.find((g) => g.id === id)?.name ?? ''
return this.groups?.find((g) => g.id === id)?.name ?? ''
}
}
@@ -1,7 +1,7 @@
<nav class="navbar navbar-dark fixed-top bg-primary flex-md-nowrap p-0 shadow-sm">
<button class="navbar-toggler d-md-none collapsed border-0" type="button" data-toggle="collapse"
data-target="#sidebarMenu" aria-controls="sidebarMenu" aria-expanded="false" aria-label="Toggle navigation"
(click)="closeMobileSearch(); toggleMenuCollapsed()">
(click)="mobileSearchHidden = false; isMenuCollapsed = !isMenuCollapsed">
<span class="navbar-toggler-icon"></span>
</button>
<a class="navbar-brand d-flex align-items-center me-0 px-3 py-3 order-sm-0"
@@ -23,7 +23,7 @@
</div>
</a>
<div class="search-container flex-grow-1 py-2 pb-3 pb-sm-2 px-3 ps-md-4 me-sm-auto order-3 order-sm-1"
[class.mobile-hidden]="mobileSearchHidden()">
[class.mobile-hidden]="mobileSearchHidden">
<div class="col-12 col-md-7">
<pngx-global-search></pngx-global-search>
</div>
@@ -68,8 +68,8 @@
<div class="container-fluid">
<div class="row">
<nav id="sidebarMenu" class="d-md-block bg-light sidebar collapse"
[ngClass]="slimSidebarEnabled ? 'slim' : 'col-md-3 col-lg-2 col-xxxl-1'" [class.animating]="slimSidebarAnimating()"
[ngbCollapse]="isMenuCollapsed()">
[ngClass]="slimSidebarEnabled ? 'slim' : 'col-md-3 col-lg-2 col-xxxl-1'" [class.animating]="slimSidebarAnimating"
[ngbCollapse]="isMenuCollapsed">
@if (canSaveSettings) {
<button class="btn btn-sm btn-dark sidebar-slim-toggler" (click)="toggleSlimSidebar()">
@if (slimSidebarEnabled) {
@@ -104,7 +104,7 @@
</h6>
<ul class="nav flex-column mb-2" cdkDropList (cdkDropListDropped)="onDrop($event)">
@for (view of savedViewService.sidebarViews; track view.id) {
<li class="nav-item w-100 app-link" cdkDrag [cdkDragDisabled]="!settingsService.organizingSidebarSavedViews()"
<li class="nav-item w-100 app-link" cdkDrag [cdkDragDisabled]="!settingsService.organizingSidebarSavedViews"
cdkDragPreviewContainer="parent" cdkDragPreviewClass="navItemDrag" (cdkDragStarted)="onDragStart($event)"
(cdkDragEnded)="onDragEnd($event)">
<a class="nav-link" routerLink="view/{{view.id}}"
@@ -120,7 +120,7 @@
<span class="badge bg-info text-dark position-absolute top-0 end-0 d-none d-md-block">{{ savedViewService.getDocumentCount(view) }}</span>
}
</a>
@if (settingsService.organizingSidebarSavedViews()) {
@if (settingsService.organizingSidebarSavedViews) {
<div class="position-absolute end-0 top-0 px-3 py-2" [class.me-n3]="slimSidebarEnabled" cdkDragHandle>
<i-bs name="grip-vertical"></i-bs>
</div>
@@ -150,8 +150,7 @@
[disablePopover]="!slimSidebarEnabled" placement="end" container="body" triggers="mouseenter:mouseleave"
popoverClass="popover-slim">
<i-bs class="me-2" name="file-text"></i-bs><span>{{d.title | documentTitle}}</span>
<span class="close flex-column justify-content-center"
(click)="$event.preventDefault(); $event.stopPropagation(); closeDocument(d)">
<span class="close flex-column justify-content-center" (click)="closeDocument(d); $event.preventDefault()">
<i-bs name="x"></i-bs>
</span>
</a>
@@ -330,10 +329,10 @@
{{ versionString }}
</a>
</div>
@if (!settingsService.updateCheckingIsSet || appRemoteVersion()) {
@if (!settingsService.updateCheckingIsSet || appRemoteVersion) {
<div class="version-check">
<ng-template #updateAvailablePopContent>
<span class="small">Paperless-ngx {{ appRemoteVersion().version }} <ng-container i18n>is
<span class="small">Paperless-ngx {{ appRemoteVersion.version }} <ng-container i18n>is
available.</ng-container><br /><ng-container i18n>Click to view.</ng-container></span>
</ng-template>
<ng-template #updateCheckingNotEnabledPopContent>
@@ -351,13 +350,13 @@
</p>
</ng-template>
@if (settingsService.updateCheckingIsSet) {
@if (appRemoteVersion().update_available) {
@if (appRemoteVersion.update_available) {
<a class="small text-decoration-none" target="_blank" rel="noopener noreferrer"
href="https://github.com/paperless-ngx/paperless-ngx/releases"
[ngbPopover]="updateAvailablePopContent" popoverClass="shadow" triggers="mouseenter:mouseleave"
container="body">
<i-bs width="1.2em" height="1.2em" name="info-circle" class="me-1"></i-bs>
@if (appRemoteVersion()?.update_available) {
@if (appRemoteVersion?.update_available) {
<ng-container i18n>Update available</ng-container>
}
</a>
@@ -378,7 +377,7 @@
</div>
</nav>
<main role="main" class="ms-sm-auto px-md-4" [class.mobile-search-hidden]="mobileSearchHidden()"
<main role="main" class="ms-sm-auto px-md-4" [class.mobile-search-hidden]="mobileSearchHidden"
[ngClass]="slimSidebarEnabled ? 'col-slim' : 'col-md-9 col-lg-10 col-xxxl-11'">
<router-outlet></router-outlet>
</main>
@@ -4,7 +4,12 @@ import {
HttpTestingController,
provideHttpClientTesting,
} from '@angular/common/http/testing'
import { ComponentFixture, TestBed } from '@angular/core/testing'
import {
ComponentFixture,
TestBed,
fakeAsync,
tick,
} from '@angular/core/testing'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { BrowserModule } from '@angular/platform-browser'
import { ActivatedRoute, Router } from '@angular/router'
@@ -239,12 +244,11 @@ describe('AppFrameComponent', () => {
expect(toastSpy).toHaveBeenCalled()
})
it('should support toggling slim sidebar and saving', () => {
jest.useFakeTimers()
it('should support toggling slim sidebar and saving', fakeAsync(() => {
const saveSettingSpy = jest.spyOn(settingsService, 'set')
settingsService.set(SETTINGS_KEYS.ATTRIBUTES_SECTIONS_COLLAPSED, [])
expect(component.slimSidebarEnabled).toBeFalsy()
expect(component.slimSidebarAnimating()).toBeFalsy()
expect(component.slimSidebarAnimating).toBeFalsy()
component.toggleSlimSidebar()
const requests = httpTestingController.match(
`${environment.apiBaseUrl}ui_settings/`
@@ -255,9 +259,9 @@ describe('AppFrameComponent', () => {
requests[0].request.body.settings.attributes_sections_collapsed
).toEqual(['attributes'])
requests[0].flush({ success: true })
expect(component.slimSidebarAnimating()).toBeTruthy()
jest.advanceTimersByTime(200)
expect(component.slimSidebarAnimating()).toBeFalsy()
expect(component.slimSidebarAnimating).toBeTruthy()
tick(200)
expect(component.slimSidebarAnimating).toBeFalsy()
expect(component.slimSidebarEnabled).toBeTruthy()
expect(saveSettingSpy).toHaveBeenCalledWith(
SETTINGS_KEYS.SLIM_SIDEBAR,
@@ -267,8 +271,7 @@ describe('AppFrameComponent', () => {
SETTINGS_KEYS.ATTRIBUTES_SECTIONS_COLLAPSED,
['attributes']
)
jest.useRealTimers()
})
}))
it('should show error on toggle slim sidebar if store settings fails', () => {
jest.spyOn(console, 'warn').mockImplementation(() => {})
@@ -288,9 +291,9 @@ describe('AppFrameComponent', () => {
fixture.nativeElement as HTMLDivElement
).querySelector('button[data-toggle=collapse]')
button.dispatchEvent(new MouseEvent('click'))
expect(component.isMenuCollapsed()).toBeFalsy()
expect(component.isMenuCollapsed).toBeFalsy()
component.closeMenu()
expect(component.isMenuCollapsed()).toBeTruthy()
expect(component.isMenuCollapsed).toBeTruthy()
})
it('should hide mobile search when scrolling down and show it when scrolling up', () => {
@@ -305,14 +308,14 @@ describe('AppFrameComponent', () => {
value: 40,
})
component.onWindowScroll()
expect(component.mobileSearchHidden()).toBe(true)
expect(component.mobileSearchHidden).toBe(true)
Object.defineProperty(globalThis, 'scrollY', {
configurable: true,
value: 0,
})
component.onWindowScroll()
expect(component.mobileSearchHidden()).toBe(false)
expect(component.mobileSearchHidden).toBe(false)
})
it('should keep mobile search visible on desktop scroll or resize', () => {
@@ -320,13 +323,13 @@ describe('AppFrameComponent', () => {
value: 1024,
})
component.ngOnInit()
component.mobileSearchHidden.set(true)
component.mobileSearchHidden = true
component.onWindowScroll()
expect(component.mobileSearchHidden()).toBe(false)
expect(component.mobileSearchHidden).toBe(false)
component.mobileSearchHidden.set(true)
component.mobileSearchHidden = true
component.onWindowResize()
})
@@ -335,7 +338,7 @@ describe('AppFrameComponent', () => {
value: 767,
})
component.ngOnInit()
component.isMenuCollapsed.set(false)
component.isMenuCollapsed = false
Object.defineProperty(globalThis, 'scrollY', {
configurable: true,
@@ -343,7 +346,7 @@ describe('AppFrameComponent', () => {
})
component.onWindowScroll()
expect(component.mobileSearchHidden()).toBe(false)
expect(component.mobileSearchHidden).toBe(false)
})
it('should support close document & navigate on close current doc', () => {
@@ -376,11 +379,11 @@ describe('AppFrameComponent', () => {
})
it('should disable global dropzone on start drag + drop, re-enable after', () => {
expect(settingsService.globalDropzoneEnabled()).toBeTruthy()
expect(settingsService.globalDropzoneEnabled).toBeTruthy()
component.onDragStart(null)
expect(settingsService.globalDropzoneEnabled()).toBeFalsy()
expect(settingsService.globalDropzoneEnabled).toBeFalsy()
component.onDragEnd(null)
expect(settingsService.globalDropzoneEnabled()).toBeTruthy()
expect(settingsService.globalDropzoneEnabled).toBeTruthy()
})
it('should update saved view sorting on drag + drop, show info', () => {
@@ -6,7 +6,7 @@ import {
moveItemInArray,
} from '@angular/cdk/drag-drop'
import { NgClass } from '@angular/common'
import { Component, HostListener, inject, OnInit, signal } from '@angular/core'
import { Component, HostListener, inject, OnInit } from '@angular/core'
import { ActivatedRoute, Router, RouterModule } from '@angular/router'
import {
NgbCollapseModule,
@@ -90,10 +90,14 @@ export class AppFrameComponent
permissionsService = inject(PermissionsService)
private djangoMessagesService = inject(DjangoMessagesService)
readonly appRemoteVersion = signal<AppRemoteVersion>(null)
readonly isMenuCollapsed = signal(true)
readonly slimSidebarAnimating = signal(false)
readonly mobileSearchHidden = signal(false)
appRemoteVersion: AppRemoteVersion
isMenuCollapsed: boolean = true
slimSidebarAnimating: boolean = false
public mobileSearchHidden: boolean = false
private lastScrollY: number = 0
constructor() {
@@ -143,7 +147,7 @@ export class AppFrameComponent
}
toggleSlimSidebar(): void {
this.slimSidebarAnimating.set(true)
this.slimSidebarAnimating = true
const slimSidebarEnabled = !this.slimSidebarEnabled
this.settingsService.set(SETTINGS_KEYS.SLIM_SIDEBAR, slimSidebarEnabled)
if (slimSidebarEnabled) {
@@ -163,7 +167,7 @@ export class AppFrameComponent
},
})
setTimeout(() => {
this.slimSidebarAnimating.set(false)
this.slimSidebarAnimating = false
}, 200) // slightly longer than css animation for slim sidebar
}
@@ -173,25 +177,11 @@ export class AppFrameComponent
this.attributesSectionsCollapsed = !this.attributesSectionsCollapsed
}
toggleMenuCollapsed(): void {
this.isMenuCollapsed.set(!this.isMenuCollapsed())
}
closeMobileSearch(): void {
this.mobileSearchHidden.set(false)
}
setMobileSearchHidden(hidden: boolean): void {
this.mobileSearchHidden.set(hidden)
}
get versionString(): string {
this.settingsService.trackChanges()
return `${environment.appTitle} v${this.settingsService.get(SETTINGS_KEYS.VERSION)}${environment.tag === 'prod' ? '' : ` #${environment.tag}`}`
}
get customAppTitle(): string {
this.settingsService.trackChanges()
return this.settingsService.get(SETTINGS_KEYS.APP_TITLE)
}
@@ -234,7 +224,6 @@ export class AppFrameComponent
}
get slimSidebarEnabled(): boolean {
this.settingsService.trackChanges()
return this.settingsService.get(SETTINGS_KEYS.SLIM_SIDEBAR)
}
@@ -254,7 +243,6 @@ export class AppFrameComponent
}
get attributesSectionsCollapsed(): boolean {
this.settingsService.trackChanges()
return this.settingsService
.get(SETTINGS_KEYS.ATTRIBUTES_SECTIONS_COLLAPSED)
?.includes(CollapsibleSection.ATTRIBUTES)
@@ -280,14 +268,13 @@ export class AppFrameComponent
}
get aiEnabled(): boolean {
this.settingsService.trackChanges()
return this.settingsService.get(SETTINGS_KEYS.AI_ENABLED)
}
@HostListener('window:resize')
onWindowResize(): void {
if (!this.isMobileViewport()) {
this.mobileSearchHidden.set(false)
this.mobileSearchHidden = false
}
}
@@ -295,8 +282,8 @@ export class AppFrameComponent
onWindowScroll(): void {
const currentScrollY = window.scrollY
if (!this.isMobileViewport() || this.isMenuCollapsed() === false) {
this.mobileSearchHidden.set(false)
if (!this.isMobileViewport() || this.isMenuCollapsed === false) {
this.mobileSearchHidden = false
this.lastScrollY = currentScrollY
return
}
@@ -304,9 +291,9 @@ export class AppFrameComponent
const delta = currentScrollY - this.lastScrollY
if (currentScrollY <= 0 || delta < -SCROLL_THRESHOLD) {
this.mobileSearchHidden.set(false)
this.mobileSearchHidden = false
} else if (currentScrollY > SCROLL_THRESHOLD && delta > SCROLL_THRESHOLD) {
this.mobileSearchHidden.set(true)
this.mobileSearchHidden = true
}
this.lastScrollY = currentScrollY
@@ -317,7 +304,7 @@ export class AppFrameComponent
}
closeMenu() {
this.isMenuCollapsed.set(true)
this.isMenuCollapsed = true
}
editProfile() {
@@ -380,11 +367,11 @@ export class AppFrameComponent
}
onDragStart(event: CdkDragStart) {
this.settingsService.globalDropzoneEnabled.set(false)
this.settingsService.globalDropzoneEnabled = false
}
onDragEnd(event: CdkDragEnd) {
this.settingsService.globalDropzoneEnabled.set(true)
this.settingsService.globalDropzoneEnabled = true
}
onDrop(event: CdkDragDrop<SavedView[]>) {
@@ -405,7 +392,7 @@ export class AppFrameComponent
this.remoteVersionService
.checkForUpdates()
.subscribe((appRemoteVersion: AppRemoteVersion) => {
this.appRemoteVersion.set(appRemoteVersion)
this.appRemoteVersion = appRemoteVersion
})
}
@@ -432,10 +419,9 @@ export class AppFrameComponent
}
get showSidebarCounts(): boolean {
this.settingsService.trackChanges()
return (
this.settingsService.get(SETTINGS_KEYS.SIDEBAR_VIEWS_SHOW_COUNT) &&
!this.settingsService.organizingSidebarSavedViews()
!this.settingsService.organizingSidebarSavedViews
)
}
}
@@ -8,17 +8,17 @@
placeholder="Search" aria-label="Search" i18n-placeholder
autocomplete="off"
spellcheck="false"
[ngModel]="query()"
(ngModelChange)="queryDebounce.next($event)"
[(ngModel)]="query"
(ngModelChange)="this.queryDebounce.next($event)"
(keydown)="searchInputKeyDown($event)"
ngbDropdownAnchor>
<div class="position-absolute top-50 end-0 translate-middle">
@if (loading()) {
@if (loading) {
<div class="spinner-border spinner-border-sm text-muted mt-1"></div>
}
</div>
</div>
@if (query()) {
@if (query) {
<button class="btn btn-sm btn-outline-secondary" type="button" (click)="runFullSearch()">
@if (useAdvancedForFullSearch) {
<ng-container i18n>Advanced search</ng-container>
@@ -77,88 +77,88 @@
<div ngbDropdownMenu class="w-100 mh-75 overflow-y-scroll shadow-lg">
<div (keydown)="dropdownKeyDown($event)">
@if (searchResults()?.total === 0) {
@if (searchResults?.total === 0) {
<h6 class="dropdown-header" i18n="@@searchResults.noResults">No results</h6>
} @else {
@if (searchResults()?.documents.length) {
@if (searchResults?.documents.length) {
<h6 class="dropdown-header" i18n="@@searchResults.documents">Documents</h6>
@for (document of searchResults().documents; track document.id) {
@for (document of searchResults.documents; track document.id) {
<ng-container *ngTemplateOutlet="resultItemTemplate; context: {item: document, nameProp: 'title', type: DataType.Document, icon: 'file-text', date: document.created}"></ng-container>
}
}
@if (searchResults()?.saved_views.length) {
@if (searchResults?.saved_views.length) {
<h6 class="dropdown-header" i18n="@@searchResults.saved_views">Saved Views</h6>
@for (saved_view of searchResults().saved_views; track saved_view.id) {
@for (saved_view of searchResults.saved_views; track saved_view.id) {
<ng-container *ngTemplateOutlet="resultItemTemplate; context: {item: saved_view, nameProp: 'name', type: DataType.SavedView, icon: 'funnel'}"></ng-container>
}
}
@if (searchResults()?.tags.length) {
@if (searchResults?.tags.length) {
<h6 class="dropdown-header" i18n="@@searchResults.tags">Tags</h6>
@for (tag of searchResults().tags; track tag.id) {
@for (tag of searchResults.tags; track tag.id) {
<ng-container *ngTemplateOutlet="resultItemTemplate; context: {item: tag, nameProp: 'name', type: DataType.Tag, icon: 'tag'}"></ng-container>
}
}
@if (searchResults()?.correspondents.length) {
@if (searchResults?.correspondents.length) {
<h6 class="dropdown-header" i18n="@@searchResults.correspondents">Correspondents</h6>
@for (correspondent of searchResults().correspondents; track correspondent.id) {
@for (correspondent of searchResults.correspondents; track correspondent.id) {
<ng-container *ngTemplateOutlet="resultItemTemplate; context: {item: correspondent, nameProp: 'name', type: DataType.Correspondent, icon: 'person'}"></ng-container>
}
}
@if (searchResults()?.document_types.length) {
@if (searchResults?.document_types.length) {
<h6 class="dropdown-header" i18n="@@searchResults.documentTypes">Document types</h6>
@for (documentType of searchResults().document_types; track documentType.id) {
@for (documentType of searchResults.document_types; track documentType.id) {
<ng-container *ngTemplateOutlet="resultItemTemplate; context: {item: documentType, nameProp: 'name', type: DataType.DocumentType, icon: 'file-earmark'}"></ng-container>
}
}
@if (searchResults()?.storage_paths.length) {
@if (searchResults?.storage_paths.length) {
<h6 class="dropdown-header" i18n="@@searchResults.storagePaths">Storage paths</h6>
@for (storagePath of searchResults().storage_paths; track storagePath.id) {
@for (storagePath of searchResults.storage_paths; track storagePath.id) {
<ng-container *ngTemplateOutlet="resultItemTemplate; context: {item: storagePath, nameProp: 'name', type: DataType.StoragePath, icon: 'folder'}"></ng-container>
}
}
@if (searchResults()?.users.length) {
@if (searchResults?.users.length) {
<h6 class="dropdown-header" i18n="@@searchResults.users">Users</h6>
@for (user of searchResults().users; track user.id) {
@for (user of searchResults.users; track user.id) {
<ng-container *ngTemplateOutlet="resultItemTemplate; context: {item: user, nameProp: 'username', type: DataType.User, icon: 'person-square'}"></ng-container>
}
}
@if (searchResults()?.groups.length) {
@if (searchResults?.groups.length) {
<h6 class="dropdown-header" i18n="@@searchResults.groups">Groups</h6>
@for (group of searchResults().groups; track group.id) {
@for (group of searchResults.groups; track group.id) {
<ng-container *ngTemplateOutlet="resultItemTemplate; context: {item: group, nameProp: 'name', type: DataType.Group, icon: 'people'}"></ng-container>
}
}
@if (searchResults()?.custom_fields.length) {
@if (searchResults?.custom_fields.length) {
<h6 class="dropdown-header" i18n="@@searchResults.customFields">Custom fields</h6>
@for (customField of searchResults().custom_fields; track customField.id) {
@for (customField of searchResults.custom_fields; track customField.id) {
<ng-container *ngTemplateOutlet="resultItemTemplate; context: {item: customField, nameProp: 'name', type: DataType.CustomField, icon: 'ui-radios'}"></ng-container>
}
}
@if (searchResults()?.mail_accounts.length) {
@if (searchResults?.mail_accounts.length) {
<h6 class="dropdown-header" i18n="@@searchResults.mailAccounts">Mail accounts</h6>
@for (mailAccount of searchResults().mail_accounts; track mailAccount.id) {
@for (mailAccount of searchResults.mail_accounts; track mailAccount.id) {
<ng-container *ngTemplateOutlet="resultItemTemplate; context: {item: mailAccount, nameProp: 'name', type: DataType.MailAccount, icon: 'envelope-at'}"></ng-container>
}
}
@if (searchResults()?.mail_rules.length) {
@if (searchResults?.mail_rules.length) {
<h6 class="dropdown-header" i18n="@@searchResults.mailRules">Mail rules</h6>
@for (mailRule of searchResults().mail_rules; track mailRule.id) {
@for (mailRule of searchResults.mail_rules; track mailRule.id) {
<ng-container *ngTemplateOutlet="resultItemTemplate; context: {item: mailRule, nameProp: 'name', type: DataType.MailRule, icon: 'envelope'}"></ng-container>
}
}
@if (searchResults()?.workflows.length) {
@if (searchResults?.workflows.length) {
<h6 class="dropdown-header" i18n="@@searchResults.workflows">Workflows</h6>
@for (workflow of searchResults().workflows; track workflow.id) {
@for (workflow of searchResults.workflows; track workflow.id) {
<ng-container *ngTemplateOutlet="resultItemTemplate; context: {item: workflow, nameProp: 'name', type: DataType.Workflow, icon: 'boxes'}"></ng-container>
}
}
@@ -1,7 +1,12 @@
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 {
ComponentFixture,
TestBed,
fakeAsync,
tick,
} from '@angular/core/testing'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { Router } from '@angular/router'
import {
@@ -149,7 +154,6 @@ describe('GlobalSearchComponent', () => {
searchService = TestBed.inject(SearchService)
router = TestBed.inject(Router)
jest.spyOn(router, 'navigate').mockResolvedValue(true)
modalService = TestBed.inject(NgbModal)
documentService = TestBed.inject(DocumentService)
documentListViewService = TestBed.inject(DocumentListViewService)
@@ -166,7 +170,7 @@ describe('GlobalSearchComponent', () => {
document.dispatchEvent(new KeyboardEvent('keydown', { key: '/' }))
expect(focusSpy).toHaveBeenCalled()
component.searchResults.set(searchResults as any)
component.searchResults = searchResults as any
component.resultsDropdown.open()
fixture.detectChanges()
@@ -234,26 +238,26 @@ describe('GlobalSearchComponent', () => {
)
expect(component['currentItemIndex']).toBe(0)
component.searchResults.set({ total: 1 } as any)
component.searchResults = { total: 1 } as any
const primaryActionSpy = jest.spyOn(component, 'primaryAction')
component.searchInputKeyDown(new KeyboardEvent('keydown', { key: 'Enter' }))
expect(primaryActionSpy).toHaveBeenCalled()
component.query.set('test')
component.query = 'test'
const resetSpy = jest.spyOn(GlobalSearchComponent.prototype as any, 'reset')
component.searchInputKeyDown(
new KeyboardEvent('keydown', { key: 'Escape' })
)
expect(resetSpy).toHaveBeenCalled()
component.query.set('')
component.query = ''
const blurSpy = jest.spyOn(component.searchInput.nativeElement, 'blur')
component.searchInputKeyDown(
new KeyboardEvent('keydown', { key: 'Escape' })
)
expect(blurSpy).toHaveBeenCalled()
component.searchResults.set({ total: 1 } as any)
component.searchResults = { total: 1 } as any
component.resultsDropdown.open()
component.searchInputKeyDown(
@@ -264,30 +268,28 @@ describe('GlobalSearchComponent', () => {
component.dropdownKeyDown(new KeyboardEvent('keydown', { key: 'Escape' }))
expect(closeSpy).toHaveBeenCalled()
component.searchResults.set(searchResults as any)
component.searchResults = searchResults as any
component.resultsDropdown.open()
component.query.set('test')
component.query = 'test'
const advancedSearchSpy = jest.spyOn(component, 'runFullSearch')
component.searchInputKeyDown(new KeyboardEvent('keydown', { key: 'Enter' }))
expect(advancedSearchSpy).toHaveBeenCalled()
})
it('should search on query debounce', () => {
jest.useFakeTimers()
it('should search on query debounce', fakeAsync(() => {
const query = 'test'
const searchSpy = jest.spyOn(searchService, 'globalSearch')
searchSpy.mockReturnValue(of({} as any))
const dropdownOpenSpy = jest.spyOn(component.resultsDropdown, 'open')
component.queryDebounce.next(query)
jest.advanceTimersByTime(401)
tick(401)
expect(searchSpy).toHaveBeenCalledWith(query)
expect(dropdownOpenSpy).toHaveBeenCalled()
jest.useRealTimers()
})
}))
it('should support primary action', () => {
const object = { id: 1 }
const routerSpy = jest.mocked(router.navigate)
const routerSpy = jest.spyOn(router, 'navigate')
const modalSpy = jest.spyOn(modalService, 'open')
let modal: NgbModalRef
@@ -463,13 +465,13 @@ describe('GlobalSearchComponent', () => {
const closeSpy = jest.spyOn(component.resultsDropdown, 'close')
component['reset'](true)
expect(debounce).toHaveBeenCalledWith(null)
expect(component.searchResults()).toBeNull()
expect(component.searchResults).toBeNull()
expect(component['currentItemIndex']).toBe(-1)
expect(closeSpy).toHaveBeenCalled()
})
it('should support focus current item', () => {
component.searchResults.set(searchResults as any)
component.searchResults = searchResults as any
fixture.detectChanges()
const focusSpy = jest.spyOn(
component.primaryButtons.get(0).nativeElement,
@@ -487,7 +489,7 @@ describe('GlobalSearchComponent', () => {
})
it('should focus button on dropdown item hover', () => {
component.searchResults.set(searchResults as any)
component.searchResults = searchResults as any
fixture.detectChanges()
const item: ElementRef = component.resultItems.first
const focusSpy = jest.spyOn(
@@ -513,7 +515,7 @@ describe('GlobalSearchComponent', () => {
component.primaryAction(DataType.Document, { id: 2 }, event as any)
expect(openSpy).toHaveBeenCalledWith('/documents/2', '_blank')
component.searchResults.set(searchResults as any)
component.searchResults = searchResults as any
component.resultsDropdown.open()
fixture.detectChanges()
@@ -540,7 +542,7 @@ describe('GlobalSearchComponent', () => {
it('should support title content search and advanced search', () => {
const qfSpy = jest.spyOn(documentListViewService, 'quickFilter')
component.query.set('test')
component.query = 'test'
component.runFullSearch()
expect(qfSpy).toHaveBeenCalledWith([
{ rule_type: FILTER_SIMPLE_TEXT, value: 'test' },
@@ -550,7 +552,7 @@ describe('GlobalSearchComponent', () => {
SETTINGS_KEYS.SEARCH_FULL_TYPE,
GlobalSearchType.ADVANCED
)
component.query.set('test')
component.query = 'test'
component.runFullSearch()
expect(qfSpy).toHaveBeenCalledWith([
{ rule_type: FILTER_FULLTEXT_QUERY, value: 'test' },
@@ -7,7 +7,6 @@ import {
ViewChild,
ViewChildren,
inject,
signal,
} from '@angular/core'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { Router } from '@angular/router'
@@ -83,12 +82,12 @@ export class GlobalSearchComponent implements OnInit {
private locationStrategy = inject(LocationStrategy)
public DataType = DataType
readonly query = signal<string>(null)
public query: string
public queryDebounce: Subject<string>
readonly searchResults = signal<GlobalSearchResult>(null)
public searchResults: GlobalSearchResult
private currentItemIndex: number = -1
private domIndex: number = -1
readonly loading = signal(false)
public loading: boolean = false
@ViewChild('searchInput') searchInput: ElementRef
@ViewChild('resultsDropdown') resultsDropdown: NgbDropdown
@@ -97,7 +96,6 @@ export class GlobalSearchComponent implements OnInit {
@ViewChildren('secondaryButton') secondaryButtons: QueryList<ElementRef>
get useAdvancedForFullSearch(): boolean {
this.settingsService.trackChanges()
return (
this.settingsService.get(SETTINGS_KEYS.SEARCH_FULL_TYPE) ===
GlobalSearchType.ADVANCED
@@ -114,7 +112,7 @@ export class GlobalSearchComponent implements OnInit {
distinctUntilChanged()
)
.subscribe((text) => {
this.query.set(text)
this.query = text
if (text) this.search(text)
})
}
@@ -128,10 +126,10 @@ export class GlobalSearchComponent implements OnInit {
}
private search(query: string) {
this.loading.set(true)
this.loading = true
this.searchService.globalSearch(query.trim()).subscribe((results) => {
this.searchResults.set(results)
this.loading.set(false)
this.searchResults = results
this.loading = false
this.resultsDropdown.open()
})
}
@@ -207,7 +205,7 @@ export class GlobalSearchComponent implements OnInit {
editDialogComponent,
{ size }
)
modalRef.componentInstance.dialogMode.set(EditDialogMode.EDIT)
modalRef.componentInstance.dialogMode = EditDialogMode.EDIT
modalRef.componentInstance.object = object
modalRef.componentInstance.succeeded.subscribe(() => {
this.toastService.showInfo($localize`Successfully updated object.`)
@@ -245,7 +243,7 @@ export class GlobalSearchComponent implements OnInit {
editDialogComponent,
{ size }
)
modalRef.componentInstance.dialogMode.set(EditDialogMode.EDIT)
modalRef.componentInstance.dialogMode = EditDialogMode.EDIT
modalRef.componentInstance.object = object
modalRef.componentInstance.succeeded.subscribe(() => {
this.toastService.showInfo($localize`Successfully updated object.`)
@@ -258,8 +256,8 @@ export class GlobalSearchComponent implements OnInit {
private reset(close: boolean = false) {
this.queryDebounce.next(null)
this.query.set(null)
this.searchResults.set(null)
this.query = null
this.searchResults = null
this.currentItemIndex = -1
if (close) {
this.resultsDropdown.close()
@@ -294,7 +292,7 @@ export class GlobalSearchComponent implements OnInit {
public searchInputKeyDown(event: KeyboardEvent) {
if (
event.key === 'ArrowDown' &&
this.searchResults()?.total &&
this.searchResults?.total &&
this.resultsDropdown.isOpen()
) {
event.preventDefault()
@@ -302,22 +300,22 @@ export class GlobalSearchComponent implements OnInit {
this.setCurrentItem()
} else if (
event.key === 'ArrowUp' &&
this.searchResults()?.total &&
this.searchResults?.total &&
this.resultsDropdown.isOpen()
) {
event.preventDefault()
this.currentItemIndex = this.searchResults()?.total - 1
this.currentItemIndex = this.searchResults.total - 1
this.setCurrentItem()
} else if (event.key === 'Enter') {
if (this.searchResults()?.total === 1 && this.resultsDropdown.isOpen()) {
if (this.searchResults?.total === 1 && this.resultsDropdown.isOpen()) {
this.primaryButtons.first.nativeElement.click()
this.searchInput.nativeElement.blur()
} else if (this.query()?.length) {
} else if (this.query?.length) {
this.runFullSearch()
this.reset(true)
}
} else if (event.key === 'Escape' && !this.resultsDropdown.isOpen()) {
if (this.query()?.length) {
if (this.query?.length) {
this.reset(true)
} else {
this.searchInput.nativeElement.blur()
@@ -327,14 +325,14 @@ export class GlobalSearchComponent implements OnInit {
public dropdownKeyDown(event: KeyboardEvent) {
if (
this.searchResults()?.total &&
this.searchResults?.total &&
this.resultsDropdown.isOpen() &&
document.activeElement !== this.searchInput.nativeElement
) {
if (event.key === 'ArrowDown') {
event.preventDefault()
event.stopImmediatePropagation()
if (this.currentItemIndex < this.searchResults()?.total - 1) {
if (this.currentItemIndex < this.searchResults.total - 1) {
this.currentItemIndex++
this.setCurrentItem()
} else {
@@ -414,10 +412,10 @@ export class GlobalSearchComponent implements OnInit {
? FILTER_FULLTEXT_QUERY
: FILTER_SIMPLE_TEXT
this.documentService.searchQuery = this.useAdvancedForFullSearch
? this.query()
? this.query
: ''
this.documentListViewService.quickFilter([
{ rule_type: ruleType, value: this.query() },
{ rule_type: ruleType, value: this.query },
])
this.reset(true)
}
@@ -1,7 +1,7 @@
<li ngbDropdown class="nav-item mx-1" (openChange)="onOpenChange($event)">
@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>
@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>
}
<button class="btn border-0" id="notificationsDropdown" ngbDropdownToggle>
<i-bs width="1.3em" height="1.3em" name="bell"></i-bs>
@@ -12,15 +12,15 @@
<div class="btn-group ms-auto">
<button class="btn btn-sm btn-outline-secondary mb-2 ms-auto"
(click)="toastService.clearToasts()"
[disabled]="toasts().length === 0"
[disabled]="toasts.length === 0"
i18n>Clear All</button>
</div>
</div>
@if (toasts().length === 0) {
@if (toasts.length === 0) {
<p class="text-center mb-0 small text-muted"><em i18n>No notifications</em></p>
}
<div class="scroll-list">
@for (toast of toasts(); track toast.id) {
@for (toast of toasts; track toast.id) {
<pngx-toast [autohide]="false" [toast]="toast" (hidden)="onHidden(toast)" (closed)="toastService.closeToast(toast)"></pngx-toast>
}
</div>
@@ -1,6 +1,12 @@
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
import { provideHttpClientTesting } from '@angular/common/http/testing'
import { ComponentFixture, TestBed } from '@angular/core/testing'
import {
ComponentFixture,
TestBed,
discardPeriodicTasks,
fakeAsync,
flush,
} from '@angular/core/testing'
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
import { Subject } from 'rxjs'
import { Toast, ToastService } from 'src/app/services/toast.service'
@@ -36,8 +42,7 @@ describe('ToastsDropdownComponent', () => {
let component: ToastsDropdownComponent
let fixture: ComponentFixture<ToastsDropdownComponent>
let toastService: ToastService
let toastsSubject: Subject<Toast[]>
let getToastsSpy: jest.SpyInstance
let toastsSubject: Subject<Toast[]> = new Subject()
beforeEach(async () => {
TestBed.configureTestingModule({
@@ -51,43 +56,57 @@ describe('ToastsDropdownComponent', () => {
],
}).compileComponents()
toastService = TestBed.inject(ToastService)
toastsSubject = new Subject()
getToastsSpy = jest
.spyOn(toastService, 'getToasts')
.mockReturnValue(toastsSubject)
fixture = TestBed.createComponent(ToastsDropdownComponent)
toastService = TestBed.inject(ToastService)
jest.spyOn(toastService, 'getToasts').mockReturnValue(toastsSubject)
component = fixture.componentInstance
fixture.detectChanges()
})
it('should call getToasts and return toasts', () => {
it('should call getToasts and return toasts', fakeAsync(() => {
const spy = jest.spyOn(toastService, 'getToasts')
component.ngOnInit()
toastsSubject.next(toasts)
fixture.detectChanges()
expect(getToastsSpy).toHaveBeenCalled()
expect(component.toasts()).toContainEqual({
expect(spy).toHaveBeenCalled()
expect(component.toasts).toContainEqual({
id: 'abc-123',
content: 'foo bar',
delay: 5000,
})
})
it('should show a toast', () => {
component.ngOnDestroy()
flush()
discardPeriodicTasks()
}))
it('should show a toast', fakeAsync(() => {
component.ngOnInit()
toastsSubject.next(toasts)
fixture.detectChanges()
expect(fixture.nativeElement.textContent).toContain('foo bar')
})
it('should toggle suppressPopupToasts', () => {
component.ngOnDestroy()
flush()
discardPeriodicTasks()
}))
it('should toggle suppressPopupToasts', fakeAsync((finish) => {
component.ngOnInit()
fixture.detectChanges()
toastsSubject.next(toasts)
const spy = jest.spyOn(toastService, 'suppressPopupToasts', 'set')
component.onOpenChange(true)
expect(spy).toHaveBeenCalledWith(true)
})
component.ngOnDestroy()
flush()
discardPeriodicTasks()
}))
})
@@ -1,11 +1,10 @@
import { Component, inject } from '@angular/core'
import { toSignal } from '@angular/core/rxjs-interop'
import { Component, OnDestroy, OnInit, inject } from '@angular/core'
import {
NgbDropdownModule,
NgbProgressbarModule,
} from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { map } from 'rxjs'
import { Subscription } from 'rxjs'
import { Toast, ToastService } from 'src/app/services/toast.service'
import { ToastComponent } from '../../common/toast/toast.component'
@@ -20,13 +19,22 @@ import { ToastComponent } from '../../common/toast/toast.component'
NgxBootstrapIconsModule,
],
})
export class ToastsDropdownComponent {
export class ToastsDropdownComponent implements OnInit, OnDestroy {
toastService = inject(ToastService)
readonly toasts = toSignal(
this.toastService.getToasts().pipe(map((toasts) => [...toasts])),
{ initialValue: [] as Toast[] }
)
private subscription: Subscription
public toasts: Toast[] = []
ngOnDestroy(): void {
this.subscription?.unsubscribe()
}
ngOnInit(): void {
this.subscription = this.toastService.getToasts().subscribe((toasts) => {
this.toasts = [...toasts]
})
}
onOpenChange(open: boolean): void {
this.toastService.suppressPopupToasts = open
@@ -6,17 +6,17 @@
<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) {
@for (message of messages; track message) {
<div class="message d-flex flex-row small" [class.justify-content-end]="message.role === 'user'">
<div class="p-2 m-2" [class.bg-body]="message.role === 'user'">
<span class="text-break">
<span>
{{ message.content }}
@if (message.isStreaming) { <span class="blinking-cursor">|</span> }
</span>
@if (message.role === 'assistant' && message.references?.length) {
<div class="chat-references list-group mt-3">
@for (reference of message.references; track reference.id) {
<a class="list-group-item list-group-item-action d-flex text-primary text-break" [routerLink]="['/documents', reference.id]">
<a class="list-group-item list-group-item-action text-primary" [routerLink]="['/documents', reference.id]">
<i-bs width="0.9em" height="0.9em" name="file-text" class="me-1"></i-bs><span>{{ reference.title }}</span>
</a>
}
@@ -29,19 +29,16 @@
</div>
<form class="chat-input">
<label class="visually-hidden" for="chatInput" i18n>Message</label>
<div class="input-group">
<input
#chatInput
id="chatInput"
class="form-control form-control-sm" name="chatInput" type="text"
[placeholder]="placeholder"
[disabled]="loading()"
[ngModel]="input()"
(ngModelChange)="input.set($event)"
[disabled]="loading"
[(ngModel)]="input"
(keydown)="searchInputKeyDown($event)"
/>
<button class="btn btn-sm btn-secondary" type="button" (click)="sendMessage()" [disabled]="loading()">Send</button>
<button class="btn btn-sm btn-secondary" type="button" (click)="sendMessage()" [disabled]="loading">Send</button>
</div>
</form>
</div>
@@ -5,7 +5,6 @@
.chat-messages {
max-height: 350px;
overflow-y: auto;
white-space: pre-wrap;
}
.chat-references {
@@ -56,53 +56,44 @@ describe('ChatComponent', () => {
it('should update documentId on initialization', () => {
jest.spyOn(router, 'url', 'get').mockReturnValue('/documents/123')
component.ngOnInit()
expect(component.documentId()).toBe(123)
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)
expect(component.documentId).toBe(456)
})
it('should return correct placeholder based on documentId', () => {
component.documentId.set(123)
component.documentId = 123
expect(component.placeholder).toBe('Ask a question about this document...')
component.documentId.set(undefined)
component.documentId = undefined
expect(component.placeholder).toBe('Ask a question about a document...')
})
it('should send a message and render the streaming response', async () => {
component.input.set('Hello')
it('should send a message and handle streaming response', () => {
component.input = 'Hello'
component.sendMessage()
expect(component.messages()).toHaveLength(2)
expect(component.messages()[0].content).toBe('Hello')
expect(component.loading()).toBe(true)
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')
expect(component.messages[1].content).toBe('H')
mockStream$.next('Hi there')
// advance time to process the typewriter effect
await jest.runAllTimersAsync()
await fixture.whenStable()
expect(component.messages()[1].content).toBe('Hi there')
expect(
fixture.nativeElement.querySelector('.chat-messages').textContent
).toContain('Hi there')
jest.advanceTimersByTime(1000)
expect(component.messages[1].content).toBe('Hi there')
mockStream$.complete()
await jest.runAllTimersAsync()
await fixture.whenStable()
expect(component.loading()).toBe(false)
expect(component.messages()[1].isStreaming).toBe(false)
expect(fixture.nativeElement.querySelector('#chatInput').disabled).toBe(
false
)
expect(component.loading).toBe(false)
expect(component.messages[1].isStreaming).toBe(false)
})
it('should parse references from the metadata trailer without showing it', () => {
component.input.set('Hello')
component.input = 'Hello'
component.sendMessage()
mockStream$.next(
@@ -110,14 +101,14 @@ describe('ChatComponent', () => {
)
jest.advanceTimersByTime(1000)
expect(component.messages()[1].content).toBe('Hi there')
expect(component.messages()[1].references).toEqual([
expect(component.messages[1].content).toBe('Hi there')
expect(component.messages[1].references).toEqual([
{ id: 42, title: 'Bread Recipe' },
])
})
it('should render document reference links under assistant messages', () => {
component.input.set('Hello')
component.input = 'Hello'
component.sendMessage()
mockStream$.next(
@@ -132,12 +123,12 @@ describe('ChatComponent', () => {
})
it('should remove delimiter fragments that were already streamed', () => {
component.input.set('Hello')
component.input = 'Hello'
component.sendMessage()
mockStream$.next(`Hi there${CHAT_METADATA_DELIMITER.slice(0, 8)}`)
jest.advanceTimersByTime(1000)
expect(component.messages()[1].content).toBe(
expect(component.messages[1].content).toBe(
`Hi there${CHAT_METADATA_DELIMITER.slice(0, 8)}`
)
@@ -146,28 +137,28 @@ describe('ChatComponent', () => {
)
jest.advanceTimersByTime(1000)
expect(component.messages()[1].content).toBe('Hi there')
expect(component.messages()[1].references).toEqual([
expect(component.messages[1].content).toBe('Hi there')
expect(component.messages[1].references).toEqual([
{ id: 42, title: 'Bread Recipe' },
])
})
it('should handle errors during streaming', () => {
component.input.set('Hello')
component.input = 'Hello'
component.sendMessage()
mockStream$.error('Error')
expect(component.messages()[1].content).toContain(
expect(component.messages[1].content).toContain(
'⚠️ Error receiving response.'
)
expect(component.loading()).toBe(false)
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']).toHaveLength(4)
expect(component['typewriterBuffer'].length).toBe(4)
})
it('should scroll to bottom after sending a message', () => {
@@ -175,7 +166,7 @@ describe('ChatComponent', () => {
ChatComponent.prototype as any,
'scrollToBottom'
)
component.input.set('Test')
component.input = 'Test'
component.sendMessage()
expect(scrollSpy).toHaveBeenCalled()
})
@@ -1,11 +1,4 @@
import {
Component,
ElementRef,
inject,
OnInit,
signal,
ViewChild,
} from '@angular/core'
import { Component, ElementRef, inject, OnInit, ViewChild } from '@angular/core'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { NavigationEnd, Router, RouterModule } from '@angular/router'
import { NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap'
@@ -30,10 +23,10 @@ import {
styleUrl: './chat.component.scss',
})
export class ChatComponent implements OnInit {
readonly messages = signal<ChatMessage[]>([])
readonly loading = signal(false)
readonly input = signal('')
readonly documentId = signal<number>(undefined)
public messages: ChatMessage[] = []
public loading = false
public input: string = ''
public documentId!: number
private chatService: ChatService = inject(ChatService)
private router: Router = inject(Router)
@@ -45,7 +38,7 @@ export class ChatComponent implements OnInit {
private typewriterActive = false
public get placeholder(): string {
return this.documentId()
return this.documentId
? $localize`Ask a question about this document...`
: $localize`Ask a question about a document...`
}
@@ -64,14 +57,14 @@ export class ChatComponent implements OnInit {
private updateDocumentId(url: string): void {
const docIdRe = url.match(/^\/documents\/(\d+)/)
this.documentId.set(docIdRe ? +docIdRe[1] : undefined)
this.documentId = docIdRe ? +docIdRe[1] : undefined
}
sendMessage(): void {
if (!this.input().trim()) return
if (!this.input.trim()) return
const userMessage: ChatMessage = { role: 'user', content: this.input() }
this.messages.update((messages) => [...messages, userMessage])
const userMessage: ChatMessage = { role: 'user', content: this.input }
this.messages.push(userMessage)
this.scrollToBottom()
const assistantMessage: ChatMessage = {
@@ -79,12 +72,12 @@ export class ChatComponent implements OnInit {
content: '',
isStreaming: true,
}
this.messages.update((messages) => [...messages, assistantMessage])
this.loading.set(true)
this.messages.push(assistantMessage)
this.loading = true
let lastVisibleContent = ''
this.chatService.streamChat(this.documentId(), this.input()).subscribe({
this.chatService.streamChat(this.documentId, this.input).subscribe({
next: (chunk) => {
const nextResponse = parseChatResponse(chunk)
@@ -100,30 +93,26 @@ export class ChatComponent implements OnInit {
}
assistantMessage.references = nextResponse.references
this.notifyMessagesChanged()
},
error: () => {
assistantMessage.content += '\n\n⚠️ Error receiving response.'
assistantMessage.isStreaming = false
this.notifyMessagesChanged()
this.loading.set(false)
this.loading = false
},
complete: () => {
assistantMessage.isStreaming = false
this.notifyMessagesChanged()
this.loading.set(false)
this.loading = false
this.scrollToBottom()
},
})
this.input.set('')
this.input = ''
}
private resetTypewriter(message: ChatMessage, content: string): void {
this.typewriterBuffer = []
this.typewriterActive = false
message.content = content
this.notifyMessagesChanged()
this.scrollToBottom()
}
@@ -146,16 +135,11 @@ export class ChatComponent implements OnInit {
const nextChar = this.typewriterBuffer.shift()
message.content += nextChar
this.notifyMessagesChanged()
this.scrollToBottom()
setTimeout(() => this.playTypewriter(message), 10) // 10ms per character
}
private notifyMessagesChanged(): void {
this.messages.update((messages) => [...messages])
}
private scrollToBottom(): void {
setTimeout(() => {
this.scrollAnchor?.nativeElement?.scrollIntoView({ behavior: 'smooth' })
@@ -1,10 +1,10 @@
@if (active()) {
@if (active) {
<button class="position-absolute top-0 start-100 translate-middle badge bg-secondary border border-light rounded-pill p-1" title="Clear" i18n-title (click)="onClick($event)">
@if (!isNumbered() && selected()) {
@if (!isNumbered && selected) {
<i-bs class="check" width="1em" height="1em" name="check-lg"></i-bs>
}
@if (isNumbered()) {
<div class="number">{{number()}}<span class="visually-hidden">selected</span></div>
@if (isNumbered) {
<div class="number">{{number}}<span class="visually-hidden">selected</span></div>
}
<i-bs class="x" width=".9em" height="1em" name="x-lg"></i-bs>
</button>
@@ -21,20 +21,20 @@ describe('ClearableBadgeComponent', () => {
})
it('should support selected', () => {
fixture.componentRef.setInput('selected', true)
expect(component.active()).toBeTruthy()
component.selected = true
expect(component.active).toBeTruthy()
})
it('should support numbered', () => {
fixture.componentRef.setInput('number', 3)
component.number = 3
fixture.detectChanges()
expect(component.active()).toBeTruthy()
expect(component.active).toBeTruthy()
expect((fixture.nativeElement as HTMLDivElement).textContent).toContain('3')
})
it('should support selected', () => {
let clearedResult
fixture.componentRef.setInput('selected', true)
component.selected = true
fixture.detectChanges()
component.cleared.subscribe((clear) => {
clearedResult = clear
@@ -1,4 +1,4 @@
import { Component, computed, EventEmitter, input, Output } from '@angular/core'
import { Component, EventEmitter, Input, Output } from '@angular/core'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
@Component({
@@ -8,15 +8,24 @@ import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
imports: [NgxBootstrapIconsModule],
})
export class ClearableBadgeComponent {
readonly number = input<number>(undefined)
readonly selected = input<boolean>(undefined)
constructor() {}
@Input()
number: number
@Input()
selected: boolean
@Output()
cleared: EventEmitter<boolean> = new EventEmitter()
readonly active = computed(() => this.selected() || this.number() > -1)
get active(): boolean {
return this.selected || this.number > -1
}
readonly isNumbered = computed(() => this.number() > -1)
get isNumbered(): boolean {
return this.number > -1
}
onClick(event: PointerEvent) {
this.cleared.emit(true)
@@ -17,6 +17,6 @@
<ng-template #popoverContent>
<div>
{{confirmMessage}}&nbsp;<button class="btn btn-link btn-sm text-danger p-0 m-0 lh-1" type="button" (click)="onConfirm($event)" i18n>Yes</button>
{{confirmMessage}}&nbsp;<button class="btn btn-link btn-sm text-danger p-0 m-0 lh-1" type="button" (click)="onConfirm($event)">Yes</button>
</div>
</ng-template>
@@ -23,10 +23,10 @@ export class ConfirmDialogComponent extends LoadingComponentWithPermissions {
title = $localize`Confirmation`
@Input()
messageBold: string
messageBold
@Input()
message: string
message
@Input()
btnClass = 'btn-primary'
@@ -38,7 +38,7 @@ export class ConfirmDialogComponent extends LoadingComponentWithPermissions {
alternativeBtnClass = 'btn-secondary'
@Input()
alternativeBtnCaption: string
alternativeBtnCaption
@Input()
cancelBtnClass = 'btn-outline-secondary'
@@ -9,9 +9,9 @@
<label class="form-label" for="metadataDocumentID" i18n>Documents:</label>
<ul class="list-group"
cdkDropList
[cdkDropListData]="documentIDs()"
[cdkDropListData]="documentIDs"
(cdkDropListDropped)="onDrop($event)">
@for (documentID of documentIDs(); track documentID) {
@for (documentID of documentIDs; track documentID) {
@let document = getDocument(documentID);
@if (document) {
<li class="list-group-item d-flex align-items-center" cdkDrag>
@@ -36,22 +36,22 @@
</div>
<div class="form-group mt-4">
<label class="form-label" for="metadataDocumentID" i18n>Use metadata from:</label>
<select class="form-select" [ngModel]="metadataDocumentID()" (ngModelChange)="metadataDocumentID.set($event)">
<select class="form-select" [(ngModel)]="metadataDocumentID">
<option [ngValue]="-1" i18n>Regenerate all metadata</option>
@for (document of documents(); track document.id) {
@for (document of documents; track document.id) {
<option [ngValue]="document.id">{{document.title}}</option>
}
</select>
</div>
<div class="form-check form-switch mt-4">
<input class="form-check-input" type="checkbox" role="switch" id="archiveFallbackSwitch" [ngModel]="archiveFallback()" (ngModelChange)="archiveFallback.set($event)">
<input class="form-check-input" type="checkbox" role="switch" id="archiveFallbackSwitch" [(ngModel)]="archiveFallback">
<label class="form-check-label" for="archiveFallbackSwitch" i18n>Try to include archive version in merge for non-PDF files</label>
</div>
<div class="form-check form-switch mt-2">
<input class="form-check-input" type="checkbox" role="switch" id="deleteOriginalsSwitch" [ngModel]="deleteOriginals()" (ngModelChange)="deleteOriginals.set($event)" [disabled]="!userOwnsAllDocuments">
<input class="form-check-input" type="checkbox" role="switch" id="deleteOriginalsSwitch" [(ngModel)]="deleteOriginals" [disabled]="!userOwnsAllDocuments">
<label class="form-check-label" for="deleteOriginalsSwitch" i18n>Delete original documents after successful merge</label>
</div>
@if (!archiveFallback()) {
@if (!archiveFallback) {
<p class="small text-muted fst-italic mt-4" i18n>Note that only PDFs will be included.</p>
}
</div>
@@ -50,12 +50,12 @@ describe('MergeConfirmDialogComponent', () => {
component.ngOnInit()
expect(component.documents()).toEqual(documents)
expect(documentService.getFew).toHaveBeenCalledWith(component.documentIDs())
expect(component.documents).toEqual(documents)
expect(documentService.getFew).toHaveBeenCalledWith(component.documentIDs)
})
it('should move documentIDs on drop', () => {
component.documentIDs.set([1, 2, 3])
component.documentIDs = [1, 2, 3]
const event = {
previousIndex: 1,
currentIndex: 2,
@@ -63,7 +63,7 @@ describe('MergeConfirmDialogComponent', () => {
component.onDrop(event as any)
expect(component.documentIDs()).toEqual([1, 3, 2])
expect(component.documentIDs).toEqual([1, 3, 2])
})
it('should get document by ID', () => {
@@ -4,7 +4,7 @@ import {
moveItemInArray,
} from '@angular/cdk/drag-drop'
import { AsyncPipe } from '@angular/common'
import { Component, OnInit, inject, signal } from '@angular/core'
import { Component, OnInit, inject } from '@angular/core'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { takeUntil } from 'rxjs'
@@ -36,11 +36,15 @@ export class MergeConfirmDialogComponent
private documentService = inject(DocumentService)
private permissionService = inject(PermissionsService)
readonly documentIDs = signal<number[]>([])
readonly archiveFallback = signal(false)
readonly deleteOriginals = signal(false)
readonly documents = signal<Document[]>([])
readonly metadataDocumentID = signal(-1)
public documentIDs: number[] = []
public archiveFallback: boolean = false
public deleteOriginals: boolean = false
private _documents: Document[] = []
get documents(): Document[] {
return this._documents
}
public metadataDocumentID: number = -1
constructor() {
super()
@@ -48,25 +52,23 @@ export class MergeConfirmDialogComponent
ngOnInit() {
this.documentService
.getFew(this.documentIDs())
.getFew(this.documentIDs)
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe((r) => {
this.documents.set(r.results)
this._documents = r.results
})
}
onDrop(event: CdkDragDrop<number[]>) {
const documentIDs = this.documentIDs().concat()
moveItemInArray(documentIDs, event.previousIndex, event.currentIndex)
this.documentIDs.set(documentIDs)
moveItemInArray(this.documentIDs, event.previousIndex, event.currentIndex)
}
getDocument(documentID: number): Document {
return this.documents().find((d) => d.id === documentID)
return this.documents.find((d) => d.id === documentID)
}
get userOwnsAllDocuments(): boolean {
return this.documents().every((d) =>
return this.documents.every((d) =>
this.permissionService.currentUserOwnsObject(d)
)
}
@@ -1,4 +1,4 @@
import { Component } from '@angular/core'
import { Component, Input } from '@angular/core'
import { FormsModule } from '@angular/forms'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { ConfirmDialogComponent } from '../confirm-dialog.component'
@@ -14,11 +14,18 @@ export class PasswordRemovalConfirmDialogComponent extends ConfirmDialogComponen
includeMetadata: boolean = true
deleteOriginal: boolean = false
@Input()
override title = $localize`Remove password protection`
@Input()
override message =
$localize`Create an unprotected copy or replace the existing file.`
@Input()
override btnCaption = $localize`Start`
constructor() {
super()
this.title = $localize`Remove password protection`
this.message = $localize`Create an unprotected copy or replace the existing file.`
this.btnCaption = $localize`Start`
}
onUpdateDocumentChange(updateDocument: boolean) {
@@ -11,8 +11,8 @@
</button>
</div>
<div class="col-8 d-flex align-items-center">
@if (documentID()) {
<img class="w-75 m-auto" [ngStyle]="{'transform': 'rotate('+rotation()+'deg)'}" [src]="documentService.getThumbUrl(documentID())" />
@if (documentID) {
<img class="w-75 m-auto" [ngStyle]="{'transform': 'rotate('+rotation+'deg)'}" [src]="documentService.getThumbUrl(documentID)" />
}
</div>
<div class="col-2 d-flex">
@@ -21,7 +21,7 @@
</button>
</div>
</div>
@if (showPDFNote()) {
@if (showPDFNote) {
<p class="small text-muted fst-italic mt-4" i18n>Note that only PDFs will be rotated.</p>
}
</div>
@@ -28,7 +28,7 @@ describe('RotateConfirmDialogComponent', () => {
})
it('should support rotating the image', () => {
component.documentID.set(1)
component.documentID = 1
fixture.detectChanges()
component.rotate()
fixture.detectChanges()
@@ -1,5 +1,5 @@
import { NgStyle } from '@angular/common'
import { Component, inject, signal } from '@angular/core'
import { Component, inject } from '@angular/core'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { DocumentService } from 'src/app/services/rest/document.service'
import { ConfirmDialogComponent } from '../confirm-dialog.component'
@@ -13,12 +13,14 @@ import { ConfirmDialogComponent } from '../confirm-dialog.component'
export class RotateConfirmDialogComponent extends ConfirmDialogComponent {
documentService = inject(DocumentService)
readonly documentID = signal<number>(undefined)
readonly showPDFNote = signal(true)
readonly rotation = signal(0)
public documentID: number
public showPDFNote: boolean = true
// animation is better if we dont normalize yet
public rotation: number = 0
public get degrees(): number {
let degrees = this.rotation() % 360
let degrees = this.rotation % 360
if (degrees < 0) degrees += 360
return degrees
}
@@ -28,6 +30,6 @@ export class RotateConfirmDialogComponent extends ConfirmDialogComponent {
}
rotate(clockwise: boolean = true) {
this.rotation.update((rotation) => rotation + (clockwise ? 90 : -90))
this.rotation += clockwise ? 90 : -90
}
}
@@ -1,12 +1,5 @@
import { CurrencyPipe, getLocaleCurrencyCode, SlicePipe } from '@angular/common'
import {
ChangeDetectorRef,
Component,
inject,
Input,
LOCALE_ID,
OnInit,
} from '@angular/core'
import { Component, inject, Input, LOCALE_ID, OnInit } from '@angular/core'
import { NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap'
import { takeUntil } from 'rxjs'
import { CustomField, CustomFieldDataType } from 'src/app/data/custom-field'
@@ -29,7 +22,6 @@ export class CustomFieldDisplayComponent
{
private customFieldService = inject(CustomFieldsService)
private documentService = inject(DocumentService)
private changeDetector = inject(ChangeDetectorRef)
CustomFieldDataType = CustomFieldDataType
@@ -82,7 +74,6 @@ export class CustomFieldDisplayComponent
this.customFieldService.listAll().subscribe((r) => {
this.customFields = r.results
this.init()
this.changeDetector.markForCheck()
})
}
@@ -120,7 +111,6 @@ export class CustomFieldDisplayComponent
this.docLinkDocuments = this.value
.map((id) => result.results.find((d) => d.id === id))
.filter((d) => d)
this.changeDetector.markForCheck()
})
}
@@ -1,6 +1,11 @@
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
import { provideHttpClientTesting } from '@angular/common/http/testing'
import { ComponentFixture, TestBed } from '@angular/core/testing'
import {
ComponentFixture,
TestBed,
fakeAsync,
tick,
} from '@angular/core/testing'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { By } from '@angular/platform-browser'
import {
@@ -69,7 +74,7 @@ describe('CustomFieldsDropdownComponent', () => {
})
)
settingsService = TestBed.inject(SettingsService)
settingsService.currentUser.set({ id: 1, username: 'test' })
settingsService.currentUser = { id: 1, username: 'test' }
fixture = TestBed.createComponent(CustomFieldsDropdownComponent)
component = fixture.componentInstance
fixture.detectChanges()
@@ -95,8 +100,8 @@ describe('CustomFieldsDropdownComponent', () => {
it('should support update unused fields', () => {
component.existingFields = [{ field: fields[0].id } as any]
component['updateUnusedFields']()
expect(component['unusedFields']().length).toEqual(1)
expect(component['unusedFields']()[0].name).toEqual('Field 2')
expect(component['unusedFields'].length).toEqual(1)
expect(component['unusedFields'][0].name).toEqual('Field 2')
})
it('should support getting data type label', () => {
@@ -105,8 +110,7 @@ describe('CustomFieldsDropdownComponent', () => {
)
})
it('should support creating field, show error if necessary, then add', () => {
jest.useFakeTimers()
it('should support creating field, show error if necessary, then add', fakeAsync(() => {
let modal: NgbModalRef
modalService.activeInstances.subscribe((m) => (modal = m[m.length - 1]))
const toastErrorSpy = jest.spyOn(toastService, 'showError')
@@ -130,12 +134,11 @@ describe('CustomFieldsDropdownComponent', () => {
// succeed
editDialog.succeeded.emit(fields[0])
jest.advanceTimersByTime(100)
tick(100)
expect(toastInfoSpy).toHaveBeenCalled()
expect(getFieldsSpy).toHaveBeenCalled()
expect(addFieldSpy).toHaveBeenCalled()
jest.useRealTimers()
})
}))
it('should support creating field with name', () => {
let modal: NgbModalRef
@@ -147,13 +150,12 @@ describe('CustomFieldsDropdownComponent', () => {
expect(editDialog.object.name).toEqual('Foo bar')
})
it('should support arrow keyboard navigation', () => {
jest.useFakeTimers()
it('should support arrow keyboard navigation', fakeAsync(() => {
fixture.nativeElement
.querySelector('button')
.dispatchEvent(new MouseEvent('click')) // open
fixture.detectChanges()
jest.advanceTimersByTime(100)
tick(100)
const filterInputEl: HTMLInputElement =
component.listFilterTextInput.nativeElement
expect(document.activeElement).toEqual(filterInputEl)
@@ -191,16 +193,14 @@ describe('CustomFieldsDropdownComponent', () => {
new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })
)
expect(document.activeElement).toEqual(itemButtons[0])
jest.useRealTimers()
})
}))
it('should support arrow keyboard navigation after tab keyboard navigation', () => {
jest.useFakeTimers()
it('should support arrow keyboard navigation after tab keyboard navigation', fakeAsync(() => {
fixture.nativeElement
.querySelector('button')
.dispatchEvent(new MouseEvent('click')) // open
fixture.detectChanges()
jest.advanceTimersByTime(100)
tick(100)
const filterInputEl: HTMLInputElement =
component.listFilterTextInput.nativeElement
expect(document.activeElement).toEqual(filterInputEl)
@@ -229,10 +229,9 @@ describe('CustomFieldsDropdownComponent', () => {
new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })
)
expect(document.activeElement).toEqual(itemButtons[1])
jest.useRealTimers()
})
}))
it('should support enter keyboard navigation', () => {
it('should support enter keyboard navigation', fakeAsync(() => {
jest.spyOn(component, 'canCreateFields', 'get').mockReturnValue(true)
const addFieldSpy = jest.spyOn(component, 'addField')
const createFieldSpy = jest.spyOn(component, 'createField')
@@ -251,5 +250,5 @@ describe('CustomFieldsDropdownComponent', () => {
component.listFilterEnter()
expect(createFieldSpy).not.toHaveBeenCalled()
expect(addFieldSpy).not.toHaveBeenCalled()
})
}))
})
@@ -8,7 +8,6 @@ import {
ViewChild,
ViewChildren,
inject,
signal,
} from '@angular/core'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { NgbDropdownModule, NgbModal } from '@ng-bootstrap/ng-bootstrap'
@@ -66,11 +65,11 @@ export class CustomFieldsDropdownComponent extends LoadingComponentWithPermissio
@ViewChildren('button') buttons: QueryList<ElementRef>
private customFields: CustomField[] = []
private readonly unusedFields = signal<CustomField[]>([])
private unusedFields: CustomField[] = []
private keyboardIndex: number
public get filteredFields(): CustomField[] {
return this.unusedFields().filter(
return this.unusedFields.filter(
(f) => !this.filterText || matchesSearchText(f.name, this.filterText)
)
}
@@ -100,10 +99,8 @@ export class CustomFieldsDropdownComponent extends LoadingComponentWithPermissio
}
private updateUnusedFields() {
this.unusedFields.set(
this.customFields.filter(
(f) => !this.existingFields?.find((e) => e.field === f.id)
)
this.unusedFields = this.customFields.filter(
(f) => !this.existingFields?.find((e) => e.field === f.id)
)
}
@@ -75,9 +75,9 @@
<ng-template #queryAtom let-atom="atom">
<div class="input-group input-group-sm">
<ng-select #fieldSelects
<ng-select
class="paperless-input-select"
[items]="customFields()"
[items]="customFields"
[(ngModel)]="atom.field"
[disabled]="disabled"
bindLabel="name"
@@ -1,6 +1,11 @@
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
import { provideHttpClientTesting } from '@angular/common/http/testing'
import { ComponentFixture, TestBed } from '@angular/core/testing'
import {
ComponentFixture,
fakeAsync,
TestBed,
tick,
} from '@angular/core/testing'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap'
import { NgSelectModule } from '@ng-select/ng-select'
@@ -79,7 +84,7 @@ describe('CustomFieldsQueryDropdownComponent', () => {
})
it('should initialize custom fields on creation', () => {
expect(component.customFields()).toEqual(customFields)
expect(component.customFields).toEqual(customFields)
})
it('should add an expression when opened if queries are empty', () => {
@@ -101,7 +106,7 @@ describe('CustomFieldsQueryDropdownComponent', () => {
data_type: CustomFieldDataType.String,
extra_data: {},
}
component.customFields.set([field])
component.customFields = [field]
const operators = component.getOperatorsForField(1)
expect(operators.length).toEqual(
[
@@ -138,7 +143,7 @@ describe('CustomFieldsQueryDropdownComponent', () => {
],
},
}
component.customFields.set([field])
component.customFields = [field]
const options = component.getSelectOptionsForField(1)
expect(options).toEqual([
{ label: 'Option 1', id: 'abc-123' },
@@ -205,13 +210,14 @@ describe('CustomFieldsQueryDropdownComponent', () => {
expect(component.name).toBe('test_title')
})
it('should add a default atom on open', async () => {
it('should add a default atom on open and focus the select field', fakeAsync(() => {
expect(component.selectionModel.queries.length).toBe(0)
component.onOpenChange(true)
fixture.detectChanges()
await fixture.whenStable()
tick()
expect(component.selectionModel.queries.length).toBe(1)
})
expect(window.document.activeElement.tagName).toBe('INPUT')
}))
describe('CustomFieldQueriesModel', () => {
let model: CustomFieldQueriesModel
@@ -6,7 +6,6 @@ import {
Input,
Output,
QueryList,
signal,
ViewChild,
ViewChildren,
} from '@angular/core'
@@ -279,7 +278,7 @@ export class CustomFieldsQueryDropdownComponent extends LoadingComponentWithPerm
@Output()
selectionModelChange = new EventEmitter<CustomFieldQueriesModel>()
readonly customFields = signal<CustomField[]>([])
customFields: CustomField[] = []
public readonly today: string = new Date().toLocaleDateString('en-CA')
@@ -326,12 +325,12 @@ export class CustomFieldsQueryDropdownComponent extends LoadingComponentWithPerm
.listAll()
.pipe(first(), takeUntil(this.unsubscribeNotifier))
.subscribe((result) => {
this.customFields.set(result.results)
this.customFields = result.results
})
}
public getCustomFieldByID(id: number): CustomField {
return this.customFields().find((field) => field.id === id)
return this.customFields.find((field) => field.id === id)
}
public addAtom(expression: CustomFieldQueryExpression) {
@@ -354,7 +353,7 @@ export class CustomFieldsQueryDropdownComponent extends LoadingComponentWithPerm
getOperatorsForField(
fieldID: number
): Array<{ value: string; label: string }> {
const field = this.customFields().find((field) => field.id === fieldID)
const field = this.customFields.find((field) => field.id === fieldID)
const groups: CustomFieldQueryOperatorGroups[] = field
? CUSTOM_FIELD_QUERY_OPERATOR_GROUPS_BY_TYPE[field.data_type]
: [CustomFieldQueryOperatorGroups.Basic]
@@ -370,7 +369,7 @@ export class CustomFieldsQueryDropdownComponent extends LoadingComponentWithPerm
getSelectOptionsForField(
fieldID: number
): Array<{ label: string; id: string }> {
const field = this.customFields().find((field) => field.id === fieldID)
const field = this.customFields.find((field) => field.id === fieldID)
if (field) {
return field.extra_data['select_options']
}
@@ -1,7 +1,12 @@
import { DatePipe } from '@angular/common'
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
import { provideHttpClientTesting } from '@angular/common/http/testing'
import { ComponentFixture, TestBed } from '@angular/core/testing'
import {
ComponentFixture,
TestBed,
fakeAsync,
tick,
} from '@angular/core/testing'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { NgbModule } from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
@@ -54,32 +59,27 @@ describe('DatesDropdownComponent', () => {
expect(settingsSpy).toHaveBeenCalled()
})
it('should support date input, emit change', () => {
jest.useFakeTimers()
it('should support date input, emit change', fakeAsync(() => {
let result: string
component.createdDateFromChange.subscribe((date) => (result = date))
const input: HTMLInputElement = fixture.nativeElement.querySelector('input')
input.value = '5/30/2023'
input.dispatchEvent(new Event('change'))
jest.advanceTimersByTime(500)
tick(500)
expect(result).not.toBeNull()
jest.useRealTimers()
})
}))
it('should support date select, emit datesSet change', () => {
jest.useFakeTimers()
it('should support date select, emit datesSet change', fakeAsync(() => {
let result: DateSelection
component.datesSet.subscribe((date) => (result = date))
const input: HTMLInputElement = fixture.nativeElement.querySelector('input')
input.value = '5/30/2023'
input.dispatchEvent(new Event('dateSelect'))
jest.advanceTimersByTime(500)
tick(500)
expect(result).not.toBeNull()
jest.useRealTimers()
})
}))
it('should support relative dates', () => {
jest.useFakeTimers()
it('should support relative dates', fakeAsync(() => {
let result: DateSelection
component.datesSet.subscribe((date) => (result = date))
component.createdRelativeDate = RelativeDate.WITHIN_1_WEEK // normally set by ngModel binding in dropdown
@@ -88,7 +88,7 @@ describe('DatesDropdownComponent', () => {
} as any)
component.addedRelativeDate = RelativeDate.WITHIN_1_WEEK // normally set by ngModel binding in dropdown
component.onSetAddedRelativeDate({ id: RelativeDate.WITHIN_1_WEEK } as any)
jest.advanceTimersByTime(500)
tick(500)
expect(result).toEqual({
createdFrom: null,
createdTo: null,
@@ -97,8 +97,7 @@ describe('DatesDropdownComponent', () => {
addedTo: null,
addedRelativeDateID: RelativeDate.WITHIN_1_WEEK,
})
jest.useRealTimers()
})
}))
it('should support report if active', () => {
component.createdRelativeDate = RelativeDate.WITHIN_1_WEEK
@@ -178,12 +177,11 @@ describe('DatesDropdownComponent', () => {
expect(eventSpy).toHaveBeenCalled()
})
it('should support debounce', () => {
jest.useFakeTimers()
it('should support debounce', fakeAsync(() => {
let result: DateSelection
component.datesSet.subscribe((date) => (result = date))
component.onChangeDebounce()
jest.advanceTimersByTime(500)
tick(500)
expect(result).toEqual({
createdFrom: null,
createdTo: null,
@@ -192,6 +190,5 @@ describe('DatesDropdownComponent', () => {
addedTo: null,
addedRelativeDateID: null,
})
jest.useRealTimers()
})
}))
})
@@ -41,20 +41,20 @@ describe('CorrespondentEditDialogComponent', () => {
fixture = TestBed.createComponent(CorrespondentEditDialogComponent)
settingsService = TestBed.inject(SettingsService)
settingsService.currentUser.set({ id: 99, username: 'user99' })
settingsService.currentUser = { id: 99, username: 'user99' }
component = fixture.componentInstance
fixture.detectChanges()
})
it('should support create and edit modes', () => {
component.dialogMode.set(EditDialogMode.CREATE)
component.dialogMode = EditDialogMode.CREATE
const createTitleSpy = jest.spyOn(component, 'getCreateTitle')
const editTitleSpy = jest.spyOn(component, 'getEditTitle')
fixture.detectChanges()
expect(createTitleSpy).toHaveBeenCalled()
expect(editTitleSpy).not.toHaveBeenCalled()
component.dialogMode.set(EditDialogMode.EDIT)
component.dialogMode = EditDialogMode.EDIT
fixture.detectChanges()
expect(editTitleSpy).toHaveBeenCalled()
})
@@ -44,33 +44,33 @@ describe('CustomFieldEditDialogComponent', () => {
fixture = TestBed.createComponent(CustomFieldEditDialogComponent)
settingsService = TestBed.inject(SettingsService)
settingsService.currentUser.set({ id: 99, username: 'user99' })
settingsService.currentUser = { id: 99, username: 'user99' }
component = fixture.componentInstance
fixture.detectChanges()
})
it('should support create and edit modes', () => {
component.dialogMode.set(EditDialogMode.CREATE)
component.dialogMode = EditDialogMode.CREATE
const createTitleSpy = jest.spyOn(component, 'getCreateTitle')
const editTitleSpy = jest.spyOn(component, 'getEditTitle')
fixture.detectChanges()
expect(createTitleSpy).toHaveBeenCalled()
expect(editTitleSpy).not.toHaveBeenCalled()
component.dialogMode.set(EditDialogMode.EDIT)
component.dialogMode = EditDialogMode.EDIT
fixture.detectChanges()
expect(editTitleSpy).toHaveBeenCalled()
})
it('should disable data type select on edit', () => {
component.dialogMode.set(EditDialogMode.EDIT)
component.dialogMode = EditDialogMode.EDIT
fixture.detectChanges()
component.ngOnInit()
expect(component.objectForm.get('data_type').disabled).toBeTruthy()
})
it('should initialize select options on edit', () => {
component.dialogMode.set(EditDialogMode.EDIT)
component.dialogMode = EditDialogMode.EDIT
component.object = {
id: 1,
name: 'Field 1',
@@ -91,7 +91,7 @@ describe('CustomFieldEditDialogComponent', () => {
})
it('should support add / remove select options', () => {
component.dialogMode.set(EditDialogMode.CREATE)
component.dialogMode = EditDialogMode.CREATE
fixture.detectChanges()
component.ngOnInit()
expect(
@@ -115,7 +115,7 @@ describe('CustomFieldEditDialogComponent', () => {
const selectOptionInputs = component[
'selectOptionInputs'
] as QueryList<ElementRef>
component.dialogMode.set(EditDialogMode.CREATE)
component.dialogMode = EditDialogMode.CREATE
component.objectForm.get('data_type').setValue(CustomFieldDataType.Select)
component.ngOnInit()
component.ngAfterViewInit()
@@ -125,7 +125,7 @@ describe('CustomFieldEditDialogComponent', () => {
})
it('should send all select options including those changed in form on save', () => {
component.dialogMode.set(EditDialogMode.EDIT)
component.dialogMode = EditDialogMode.EDIT
component.object = {
id: 1,
name: 'Field 1',
@@ -149,7 +149,7 @@ export class CustomFieldEditDialogComponent
}
get typeFieldDisabled(): boolean {
return this.dialogMode() === EditDialogMode.EDIT
return this.dialogMode === EditDialogMode.EDIT
}
private updateSelectOptions() {
@@ -41,20 +41,20 @@ describe('DocumentTypeEditDialogComponent', () => {
fixture = TestBed.createComponent(DocumentTypeEditDialogComponent)
settingsService = TestBed.inject(SettingsService)
settingsService.currentUser.set({ id: 99, username: 'user99' })
settingsService.currentUser = { id: 99, username: 'user99' }
component = fixture.componentInstance
fixture.detectChanges()
})
it('should support create and edit modes', () => {
component.dialogMode.set(EditDialogMode.CREATE)
component.dialogMode = EditDialogMode.CREATE
const createTitleSpy = jest.spyOn(component, 'getCreateTitle')
const editTitleSpy = jest.spyOn(component, 'getEditTitle')
fixture.detectChanges()
expect(createTitleSpy).toHaveBeenCalled()
expect(editTitleSpy).not.toHaveBeenCalled()
component.dialogMode.set(EditDialogMode.EDIT)
component.dialogMode = EditDialogMode.EDIT
fixture.detectChanges()
expect(editTitleSpy).toHaveBeenCalled()
})
@@ -4,7 +4,12 @@ import {
provideHttpClientTesting,
} from '@angular/common/http/testing'
import { Component } from '@angular/core'
import { ComponentFixture, TestBed } from '@angular/core/testing'
import {
ComponentFixture,
TestBed,
fakeAsync,
tick,
} from '@angular/core/testing'
import {
FormControl,
FormGroup,
@@ -117,7 +122,7 @@ describe('EditDialogComponent', () => {
tagService = TestBed.inject(TagService)
permissionsService = TestBed.inject(PermissionsService)
settingsService = TestBed.inject(SettingsService)
settingsService.currentUser.set(currentUser as any)
settingsService.currentUser = currentUser
permissionsService.initialize([], currentUser as any)
activeModal = TestBed.inject(NgbActiveModal)
httpTestingController = TestBed.inject(HttpTestingController)
@@ -131,7 +136,7 @@ describe('EditDialogComponent', () => {
it('should interpolate object permissions', () => {
component.getMatchingAlgorithms() // coverage
component.object = tag
component.dialogMode.set(EditDialogMode.EDIT)
component.dialogMode = EditDialogMode.EDIT
component.ngOnInit()
expect(component.objectForm.get('permissions_form').value).toEqual({
@@ -140,17 +145,15 @@ describe('EditDialogComponent', () => {
})
})
it('should delay close enabled', () => {
jest.useFakeTimers()
it('should delay close enabled', fakeAsync(() => {
expect(component.closeEnabled).toBeFalsy()
component.ngOnInit()
jest.advanceTimersByTime(100)
tick(100)
expect(component.closeEnabled).toBeTruthy()
jest.useRealTimers()
})
}))
it('should set default owner when in create mode if unset', () => {
component.dialogMode.set(EditDialogMode.CREATE)
component.dialogMode = EditDialogMode.CREATE
component.ngOnInit()
expect(component.objectForm.get('permissions_form').value.owner).toEqual(
currentUser.id
@@ -161,7 +164,7 @@ describe('EditDialogComponent', () => {
})
it('should set default perms when in create mode if set', () => {
component.dialogMode.set(EditDialogMode.CREATE)
component.dialogMode = EditDialogMode.CREATE
settingsService.set(SETTINGS_KEYS.DEFAULT_PERMS_OWNER, 11)
settingsService.set(SETTINGS_KEYS.DEFAULT_PERMS_VIEW_USERS, [1, 2])
settingsService.set(SETTINGS_KEYS.DEFAULT_PERMS_VIEW_GROUPS, [3])
@@ -200,18 +203,18 @@ describe('EditDialogComponent', () => {
})
it('should support create and edit modes', () => {
component.dialogMode.set(EditDialogMode.CREATE)
component.dialogMode = EditDialogMode.CREATE
const createTitleSpy = jest.spyOn(component, 'getCreateTitle')
const editTitleSpy = jest.spyOn(component, 'getEditTitle')
component.getTitle()
fixture.detectChanges()
expect(createTitleSpy).toHaveBeenCalled()
expect(editTitleSpy).not.toHaveBeenCalled()
component.dialogMode.set(EditDialogMode.EDIT)
component.getTitle()
component.dialogMode = EditDialogMode.EDIT
fixture.detectChanges()
expect(editTitleSpy).toHaveBeenCalled()
// coverage
component.dialogMode.set(null)
component.getTitle()
component.dialogMode = null
fixture.detectChanges()
})
it('should close on cancel', () => {
@@ -222,14 +225,14 @@ describe('EditDialogComponent', () => {
it('should update an object on save in edit mode', () => {
const updateSpy = jest.spyOn(tagService, 'update')
component.dialogMode.set(EditDialogMode.EDIT)
component.dialogMode = EditDialogMode.EDIT
component.save()
expect(updateSpy).toHaveBeenCalled()
})
it('should not submit owner or permissions for non-owner edits', () => {
component.object = tag
component.dialogMode.set(EditDialogMode.EDIT)
component.dialogMode = EditDialogMode.EDIT
component.ngOnInit()
component.objectForm.get('name').setValue('Updated tag')
@@ -248,7 +251,7 @@ describe('EditDialogComponent', () => {
it('should create an object on save in edit mode', () => {
const createSpy = jest.spyOn(tagService, 'create')
component.dialogMode.set(EditDialogMode.CREATE)
component.dialogMode = EditDialogMode.CREATE
component.save()
expect(createSpy).toHaveBeenCalled()
})
@@ -5,7 +5,6 @@ import {
OnInit,
Output,
inject,
model,
} from '@angular/core'
import { FormGroup } from '@angular/forms'
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
@@ -46,7 +45,10 @@ export abstract class EditDialogComponent<
protected settingsService = inject(SettingsService)
protected permissionsService = inject(PermissionsService)
dialogMode = model(EditDialogMode.CREATE)
users: User[]
@Input()
dialogMode: EditDialogMode = EditDialogMode.CREATE
@Input()
object: T
@@ -57,20 +59,18 @@ export abstract class EditDialogComponent<
@Output()
failed = new EventEmitter()
users: User[]
networkActive = false
closeEnabled = false
error: any = null
error = null
abstract getForm(): FormGroup
objectForm: FormGroup = this.getForm()
ngOnInit(): void {
if (this.object != null && this.dialogMode() !== EditDialogMode.CREATE) {
if (this.object != null && this.dialogMode !== EditDialogMode.CREATE) {
this.object['permissions_form'] = {
owner: (this.object as ObjectWithPermissions).owner,
set_permissions: (this.object as ObjectWithPermissions).permissions,
@@ -124,7 +124,7 @@ export abstract class EditDialogComponent<
}
getTitle() {
switch (this.dialogMode()) {
switch (this.dialogMode) {
case EditDialogMode.CREATE:
return this.getCreateTitle()
case EditDialogMode.EDIT:
@@ -151,7 +151,7 @@ export abstract class EditDialogComponent<
protected shouldSubmitPermissions(): boolean {
return (
this.dialogMode() === EditDialogMode.CREATE ||
this.dialogMode === EditDialogMode.CREATE ||
this.permissionsService.currentUserOwnsObject(this.object)
)
}
@@ -172,7 +172,7 @@ export abstract class EditDialogComponent<
delete newObject['set_permissions']
}
var serverResponse: Observable<T>
switch (this.dialogMode()) {
switch (this.dialogMode) {
case EditDialogMode.CREATE:
serverResponse = this.service.create(newObject)
break
@@ -45,20 +45,20 @@ describe('GroupEditDialogComponent', () => {
fixture = TestBed.createComponent(GroupEditDialogComponent)
settingsService = TestBed.inject(SettingsService)
settingsService.currentUser.set({ id: 99, username: 'user99' })
settingsService.currentUser = { id: 99, username: 'user99' }
component = fixture.componentInstance
fixture.detectChanges()
})
it('should support create and edit modes', () => {
component.dialogMode.set(EditDialogMode.CREATE)
component.dialogMode = EditDialogMode.CREATE
const createTitleSpy = jest.spyOn(component, 'getCreateTitle')
const editTitleSpy = jest.spyOn(component, 'getEditTitle')
fixture.detectChanges()
expect(createTitleSpy).toHaveBeenCalled()
expect(editTitleSpy).not.toHaveBeenCalled()
component.dialogMode.set(EditDialogMode.EDIT)
component.dialogMode = EditDialogMode.EDIT
fixture.detectChanges()
expect(editTitleSpy).toHaveBeenCalled()
})
@@ -25,8 +25,8 @@
</div>
<div class="modal-footer">
<div class="m-0 me-2">
@if (testResult()) {
<ngb-alert #testResultAlert [type]="testResult()" class="mb-0 py-2" (closed)="testResult.set(null)">{{testResultMessage}}</ngb-alert>
@if (testResult) {
<ngb-alert #testResultAlert [type]="testResult" class="mb-0 py-2" (closed)="testResult = null">{{testResultMessage}}</ngb-alert>
}
</div>
<button type="button" class="btn btn-outline-primary" (click)="test()" [disabled]="networkActive || testActive">
@@ -3,7 +3,12 @@ import {
HttpTestingController,
provideHttpClientTesting,
} from '@angular/common/http/testing'
import { ComponentFixture, TestBed } from '@angular/core/testing'
import {
ComponentFixture,
TestBed,
fakeAsync,
tick,
} from '@angular/core/testing'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { NgbActiveModal, NgbModule } from '@ng-bootstrap/ng-bootstrap'
import { NgSelectModule } from '@ng-select/ng-select'
@@ -53,26 +58,25 @@ describe('MailAccountEditDialogComponent', () => {
fixture = TestBed.createComponent(MailAccountEditDialogComponent)
settingsService = TestBed.inject(SettingsService)
settingsService.currentUser.set({ id: 99, username: 'user99' })
settingsService.currentUser = { id: 99, username: 'user99' }
component = fixture.componentInstance
fixture.detectChanges()
})
it('should support create and edit modes', () => {
component.dialogMode.set(EditDialogMode.CREATE)
component.dialogMode = EditDialogMode.CREATE
const createTitleSpy = jest.spyOn(component, 'getCreateTitle')
const editTitleSpy = jest.spyOn(component, 'getEditTitle')
fixture.detectChanges()
expect(createTitleSpy).toHaveBeenCalled()
expect(editTitleSpy).not.toHaveBeenCalled()
component.dialogMode.set(EditDialogMode.EDIT)
component.dialogMode = EditDialogMode.EDIT
fixture.detectChanges()
expect(editTitleSpy).toHaveBeenCalled()
})
it('should support test mail account and show appropriate expiring alert', () => {
jest.useFakeTimers()
it('should support test mail account and show appropriate expiring alert', fakeAsync(() => {
component.object = {
name: 'example',
imap_server: 'imap.example.com',
@@ -93,7 +97,7 @@ describe('MailAccountEditDialogComponent', () => {
expect(fixture.nativeElement.textContent).toContain(
'Successfully connected'
)
jest.advanceTimersByTime(6000)
tick(6000)
fixture.detectChanges()
expect(fixture.nativeElement.textContent).not.toContain(
'Successfully connected'
@@ -114,7 +118,6 @@ describe('MailAccountEditDialogComponent', () => {
.flush({}, { status: 500, statusText: 'error' })
fixture.detectChanges()
expect(fixture.nativeElement.textContent).toContain('Unable to connect')
jest.advanceTimersByTime(6000)
jest.useRealTimers()
})
tick(6000)
}))
})
@@ -1,4 +1,4 @@
import { Component, ViewChild, inject, signal } from '@angular/core'
import { Component, ViewChild, inject } from '@angular/core'
import {
FormControl,
FormGroup,
@@ -38,7 +38,7 @@ const IMAP_SECURITY_OPTIONS = [
})
export class MailAccountEditDialogComponent extends EditDialogComponent<MailAccount> {
testActive: boolean = false
readonly testResult = signal<string>(undefined)
testResult: string
alertTimeout
@ViewChild('testResultAlert', { static: false }) testResultAlert: NgbAlert
@@ -77,7 +77,7 @@ export class MailAccountEditDialogComponent extends EditDialogComponent<MailAcco
test() {
this.testActive = true
this.testResult.set(null)
this.testResult = null
clearTimeout(this.alertTimeout)
const mailService = this.service as MailAccountService
const newObject = Object.assign(
@@ -87,19 +87,19 @@ export class MailAccountEditDialogComponent extends EditDialogComponent<MailAcco
mailService.test(newObject).subscribe({
next: (result: { success: boolean }) => {
this.testActive = false
this.testResult.set(result.success ? 'success' : 'danger')
this.testResult = result.success ? 'success' : 'danger'
this.alertTimeout = setTimeout(() => this.testResultAlert.close(), 5000)
},
error: (e) => {
this.testActive = false
this.testResult.set('danger')
this.testResult = 'danger'
this.alertTimeout = setTimeout(() => this.testResultAlert.close(), 5000)
},
})
}
get testResultMessage() {
return this.testResult() === 'success'
return this.testResult === 'success'
? $localize`Successfully connected to the mail server`
: $localize`Unable to connect to the mail server`
}
@@ -13,7 +13,7 @@
<pngx-input-text [horizontal]="true" i18n-title title="Name" formControlName="name" [error]="error?.name" autocomplete="off"></pngx-input-text>
</div>
<div class="col-md-4">
<pngx-input-select [horizontal]="true" i18n-title title="Account" [items]="accounts()" formControlName="account"></pngx-input-select>
<pngx-input-select [horizontal]="true" i18n-title title="Account" [items]="accounts" formControlName="account"></pngx-input-select>
</div>
<div class="col-md-2 pt-2">
<pngx-input-switch [horizontal]="true" i18n-title title="Enabled" formControlName="enabled"></pngx-input-switch>
@@ -65,10 +65,10 @@
</div>
<div class="col-md-6">
<pngx-input-tags [horizontal]="true" [allowCreate]="false" formControlName="assign_tags"></pngx-input-tags>
<pngx-input-select [horizontal]="true" i18n-title title="Assign document type" [items]="documentTypes()" [allowNull]="true" formControlName="assign_document_type"></pngx-input-select>
<pngx-input-select [horizontal]="true" i18n-title title="Assign document type" [items]="documentTypes" [allowNull]="true" formControlName="assign_document_type"></pngx-input-select>
<pngx-input-select [horizontal]="true" i18n-title title="Assign correspondent from" [items]="metadataCorrespondentOptions" formControlName="assign_correspondent_from"></pngx-input-select>
@if (showCorrespondentField) {
<pngx-input-select [horizontal]="true" i18n-title title="Assign correspondent" [items]="correspondents()" [allowNull]="true" formControlName="assign_correspondent"></pngx-input-select>
<pngx-input-select [horizontal]="true" i18n-title title="Assign correspondent" [items]="correspondents" [allowNull]="true" formControlName="assign_correspondent"></pngx-input-select>
}
</div>
</div>
@@ -75,20 +75,20 @@ describe('MailRuleEditDialogComponent', () => {
fixture = TestBed.createComponent(MailRuleEditDialogComponent)
settingsService = TestBed.inject(SettingsService)
settingsService.currentUser.set({ id: 99, username: 'user99' })
settingsService.currentUser = { id: 99, username: 'user99' }
component = fixture.componentInstance
fixture.detectChanges()
})
it('should support create and edit modes', () => {
component.dialogMode.set(EditDialogMode.CREATE)
component.dialogMode = EditDialogMode.CREATE
const createTitleSpy = jest.spyOn(component, 'getCreateTitle')
const editTitleSpy = jest.spyOn(component, 'getEditTitle')
fixture.detectChanges()
expect(createTitleSpy).toHaveBeenCalled()
expect(editTitleSpy).not.toHaveBeenCalled()
component.dialogMode.set(EditDialogMode.EDIT)
component.dialogMode = EditDialogMode.EDIT
fixture.detectChanges()
expect(editTitleSpy).toHaveBeenCalled()
})
@@ -1,12 +1,11 @@
import { Component, inject } from '@angular/core'
import { toSignal } from '@angular/core/rxjs-interop'
import {
FormControl,
FormGroup,
FormsModule,
ReactiveFormsModule,
} from '@angular/forms'
import { map } from 'rxjs'
import { first } from 'rxjs'
import { EditDialogComponent } from 'src/app/components/common/edit-dialog/edit-dialog.component'
import { Correspondent } from 'src/app/data/correspondent'
import { DocumentType } from 'src/app/data/document-type'
@@ -155,28 +154,37 @@ const METADATA_CORRESPONDENT_OPTIONS = [
],
})
export class MailRuleEditDialogComponent extends EditDialogComponent<MailRule> {
private readonly accountService = inject(MailAccountService)
private readonly correspondentService = inject(CorrespondentService)
private readonly documentTypeService = inject(DocumentTypeService)
private accountService: MailAccountService
private correspondentService: CorrespondentService
private documentTypeService: DocumentTypeService
readonly accounts = toSignal(
this.accountService.listAll().pipe(map((result) => result.results)),
{ initialValue: undefined as MailAccount[] }
)
readonly correspondents = toSignal(
this.correspondentService.listAll().pipe(map((result) => result.results)),
{ initialValue: undefined as Correspondent[] }
)
readonly documentTypes = toSignal(
this.documentTypeService.listAll().pipe(map((result) => result.results)),
{ initialValue: undefined as DocumentType[] }
)
accounts: MailAccount[]
correspondents: Correspondent[]
documentTypes: DocumentType[]
constructor() {
super()
this.service = inject(MailRuleService)
this.accountService = inject(MailAccountService)
this.correspondentService = inject(CorrespondentService)
this.documentTypeService = inject(DocumentTypeService)
this.userService = inject(UserService)
this.settingsService = inject(SettingsService)
this.accountService
.listAll()
.pipe(first())
.subscribe((result) => (this.accounts = result.results))
this.correspondentService
.listAll()
.pipe(first())
.subscribe((result) => (this.correspondents = result.results))
this.documentTypeService
.listAll()
.pipe(first())
.subscribe((result) => (this.documentTypes = result.results))
}
getCreateTitle() {
@@ -22,11 +22,11 @@
<ng-template>
<div class="card mb-2">
<div class="card-body p-2">
@if (testLoading()) {
@if (testLoading) {
<ng-container [ngTemplateOutlet]="loadingTemplate"></ng-container>
} @else if (testResult()) {
<code>{{testResult()}}</code>
} @else if (testFailed()) {
} @else if (testResult) {
<code>{{testResult}}</code>
} @else if (testFailed) {
<div class="text-danger" i18n>Path test failed</div>
} @else {
<div class="text-muted small" i18n>No document selected</div>
@@ -42,7 +42,7 @@
[compareWith]="compareDocuments"
[trackByFn]="trackByFn"
[minTermLength]="2"
[loading]="loading()"
[loading]="loading"
[typeahead]="documentsInput$"
(change)="testPath($event)">
<ng-template #loadingTemplate ng-loadingspinner-tmp>
@@ -30,20 +30,20 @@ describe('StoragePathEditDialogComponent', () => {
documentService = TestBed.inject(DocumentService)
fixture = TestBed.createComponent(StoragePathEditDialogComponent)
settingsService = TestBed.inject(SettingsService)
settingsService.currentUser.set({ id: 99, username: 'user99' })
settingsService.currentUser = { id: 99, username: 'user99' }
component = fixture.componentInstance
fixture.detectChanges()
})
it('should support create and edit modes', () => {
component.dialogMode.set(EditDialogMode.CREATE)
component.dialogMode = EditDialogMode.CREATE
const createTitleSpy = jest.spyOn(component, 'getCreateTitle')
const editTitleSpy = jest.spyOn(component, 'getEditTitle')
fixture.detectChanges()
expect(createTitleSpy).toHaveBeenCalled()
expect(editTitleSpy).not.toHaveBeenCalled()
component.dialogMode.set(EditDialogMode.EDIT)
component.dialogMode = EditDialogMode.EDIT
fixture.detectChanges()
expect(editTitleSpy).toHaveBeenCalled()
})
@@ -58,17 +58,17 @@ describe('StoragePathEditDialogComponent', () => {
fixture.detectChanges()
component.testPath({ id: 1 })
expect(testSpy).toHaveBeenCalledWith('test/{{title}}', 1)
expect(component.testResult()).toBe('test/abc123')
expect(component.testFailed()).toBeFalsy()
expect(component.testResult).toBe('test/abc123')
expect(component.testFailed).toBeFalsy()
// test failed
testSpy.mockReturnValueOnce(of(''))
component.testPath({ id: 1 })
expect(component.testResult()).toBeNull()
expect(component.testFailed()).toBeTruthy()
expect(component.testResult).toBeNull()
expect(component.testFailed).toBeTruthy()
component.testPath(null)
expect(component.testResult()).toBeNull()
expect(component.testResult).toBeNull()
})
it('should compare two documents by id', () => {
@@ -1,5 +1,5 @@
import { AsyncPipe, NgTemplateOutlet } from '@angular/common'
import { Component, OnDestroy, inject, signal } from '@angular/core'
import { Component, OnDestroy, inject } from '@angular/core'
import {
FormControl,
FormGroup,
@@ -65,13 +65,13 @@ export class StoragePathEditDialogComponent
public documentsInput$ = new Subject<string>()
public foundDocuments$: Observable<Document[]>
private testDocument: Document
readonly testResult = signal<string>(undefined)
readonly testFailed = signal(false)
readonly testLoading = signal(false)
public testResult: string
public testFailed: boolean = false
public loading = false
public testLoading = false
constructor() {
super()
this.loading.set(false)
this.service = inject(StoragePathService)
this.userService = inject(UserService)
this.settingsService = inject(SettingsService)
@@ -99,22 +99,22 @@ export class StoragePathEditDialogComponent
public testPath(document: Document) {
if (!document) {
this.testResult.set(null)
this.testResult = null
return
}
this.testDocument = document
this.testLoading.set(true)
this.testLoading = true
;(this.service as StoragePathService)
.testPath(this.objectForm.get('path').value, document.id)
.subscribe((result) => {
if (result?.length) {
this.testResult.set(result)
this.testFailed.set(false)
this.testResult = result
this.testFailed = false
} else {
this.testResult.set(null)
this.testFailed.set(true)
this.testResult = null
this.testFailed = true
}
this.testLoading.set(false)
this.testLoading = false
})
}
@@ -138,7 +138,7 @@ export class StoragePathEditDialogComponent
this.documentsInput$.pipe(
distinctUntilChanged(),
takeUntil(this.unsubscribeNotifier),
tap(() => this.loading.set(true)),
tap(() => (this.loading = true)),
switchMap((title) =>
this.documentsService
.listFiltered(
@@ -152,7 +152,7 @@ export class StoragePathEditDialogComponent
.pipe(
map((result) => result.results),
catchError(() => of([])), // empty on error
tap(() => this.loading.set(false))
tap(() => (this.loading = false))
)
)
)
@@ -12,7 +12,7 @@
<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-select i18n-title title="Parent" formControlName="parent" [items]="tags" [allowNull]="true" [error]="error?.parent"></pngx-input-select>
<pngx-input-check i18n-title title="Inbox tag" formControlName="is_inbox_tag" i18n-hint hint="Inbox tags are automatically assigned to all consumed documents."></pngx-input-check>
<pngx-input-select i18n-title title="Matching algorithm" [items]="getMatchingAlgorithms()" formControlName="matching_algorithm"></pngx-input-select>
@@ -48,20 +48,20 @@ describe('TagEditDialogComponent', () => {
fixture = TestBed.createComponent(TagEditDialogComponent)
settingsService = TestBed.inject(SettingsService)
settingsService.currentUser.set({ id: 99, username: 'user99' })
settingsService.currentUser = { id: 99, username: 'user99' }
component = fixture.componentInstance
fixture.detectChanges()
})
it('should support create and edit modes', () => {
component.dialogMode.set(EditDialogMode.CREATE)
component.dialogMode = EditDialogMode.CREATE
const createTitleSpy = jest.spyOn(component, 'getCreateTitle')
const editTitleSpy = jest.spyOn(component, 'getEditTitle')
fixture.detectChanges()
expect(createTitleSpy).toHaveBeenCalled()
expect(editTitleSpy).not.toHaveBeenCalled()
component.dialogMode.set(EditDialogMode.EDIT)
component.dialogMode = EditDialogMode.EDIT
fixture.detectChanges()
expect(editTitleSpy).toHaveBeenCalled()
})

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