Compare commits

..
Author SHA1 Message Date
stumpylog 3af09f6d62 Upgrades psycopg to 3.3.4 2026-06-19 16:08:38 -07:00
420 changed files with 69837 additions and 110380 deletions
+10 -10
View File
@@ -24,7 +24,7 @@ jobs:
backend_changed: ${{ steps.force.outputs.run_all == 'true' || steps.filter.outputs.backend == 'true' }} backend_changed: ${{ steps.force.outputs.run_all == 'true' || steps.filter.outputs.backend == 'true' }}
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with: with:
fetch-depth: 0 fetch-depth: 0
persist-credentials: false persist-credentials: false
@@ -87,7 +87,7 @@ jobs:
fail-fast: false fail-fast: false
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with: with:
persist-credentials: false persist-credentials: false
- name: Start containers - name: Start containers
@@ -96,11 +96,11 @@ jobs:
docker compose --file docker/compose/docker-compose.ci-test.yml up --detach docker compose --file docker/compose/docker-compose.ci-test.yml up --detach
- name: Set up Python - name: Set up Python
id: setup-python id: setup-python
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with: with:
python-version: "${{ matrix.python-version }}" python-version: "${{ matrix.python-version }}"
- name: Install uv - name: Install uv
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
with: with:
version: ${{ env.DEFAULT_UV_VERSION }} version: ${{ env.DEFAULT_UV_VERSION }}
enable-cache: true enable-cache: true
@@ -141,13 +141,13 @@ jobs:
pytest pytest
- name: Upload test results to Codecov - name: Upload test results to Codecov
if: always() if: always()
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0
with: with:
flags: backend-python-${{ matrix.python-version }} flags: backend-python-${{ matrix.python-version }}
files: junit.xml files: junit.xml
report_type: test_results report_type: test_results
- name: Upload coverage to Codecov - name: Upload coverage to Codecov
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0
with: with:
flags: backend-python-${{ matrix.python-version }} flags: backend-python-${{ matrix.python-version }}
files: coverage.xml files: coverage.xml
@@ -169,16 +169,16 @@ jobs:
PAPERLESS_SECRET_KEY: "ci-typing-not-a-real-secret" PAPERLESS_SECRET_KEY: "ci-typing-not-a-real-secret"
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with: with:
persist-credentials: false persist-credentials: false
- name: Set up Python - name: Set up Python
id: setup-python id: setup-python
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with: with:
python-version: "${{ env.DEFAULT_PYTHON }}" python-version: "${{ env.DEFAULT_PYTHON }}"
- name: Install uv - name: Install uv
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
with: with:
version: ${{ env.DEFAULT_UV_VERSION }} version: ${{ env.DEFAULT_UV_VERSION }}
enable-cache: true enable-cache: true
@@ -202,7 +202,7 @@ jobs:
check \ check \
src/ src/
- name: Cache Mypy - name: Cache Mypy
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with: with:
path: .mypy_cache path: .mypy_cache
# Keyed by OS, Python version, and dependency hashes # Keyed by OS, Python version, and dependency hashes
+10 -10
View File
@@ -41,7 +41,7 @@ jobs:
ref-name: ${{ steps.ref.outputs.name }} ref-name: ${{ steps.ref.outputs.name }}
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with: with:
persist-credentials: false persist-credentials: false
- name: Determine ref name - name: Determine ref name
@@ -106,9 +106,9 @@ jobs:
echo "repository=${repo_name}" echo "repository=${repo_name}"
echo "name=${repo_name}" >> $GITHUB_OUTPUT echo "name=${repo_name}" >> $GITHUB_OUTPUT
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
- name: Login to GitHub Container Registry - name: Login to GitHub Container Registry
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with: with:
registry: ${{ env.REGISTRY }} registry: ${{ env.REGISTRY }}
username: ${{ github.actor }} username: ${{ github.actor }}
@@ -121,7 +121,7 @@ jobs:
sudo rm -rf "$AGENT_TOOLSDIRECTORY" sudo rm -rf "$AGENT_TOOLSDIRECTORY"
- name: Docker metadata - name: Docker metadata
id: docker-meta id: docker-meta
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0 uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
with: with:
images: | images: |
${{ env.REGISTRY }}/${{ steps.repo.outputs.name }} ${{ env.REGISTRY }}/${{ steps.repo.outputs.name }}
@@ -132,7 +132,7 @@ jobs:
type=semver,pattern={{major}}.{{minor}} type=semver,pattern={{major}}.{{minor}}
- name: Build and push by digest - name: Build and push by digest
id: build id: build
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0 uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
with: with:
context: . context: .
file: ./Dockerfile file: ./Dockerfile
@@ -182,29 +182,29 @@ jobs:
echo "Downloaded digests:" echo "Downloaded digests:"
ls -la /tmp/digests/ ls -la /tmp/digests/
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0 uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
- name: Login to GitHub Container Registry - name: Login to GitHub Container Registry
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with: with:
registry: ${{ env.REGISTRY }} registry: ${{ env.REGISTRY }}
username: ${{ github.actor }} username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }} password: ${{ secrets.GITHUB_TOKEN }}
- name: Login to Docker Hub - name: Login to Docker Hub
if: needs.build-arch.outputs.push-external == 'true' if: needs.build-arch.outputs.push-external == 'true'
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with: with:
username: ${{ secrets.DOCKERHUB_USERNAME }} username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }} password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to Quay.io - name: Login to Quay.io
if: needs.build-arch.outputs.push-external == 'true' if: needs.build-arch.outputs.push-external == 'true'
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0 uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with: with:
registry: quay.io registry: quay.io
username: ${{ secrets.QUAY_USERNAME }} username: ${{ secrets.QUAY_USERNAME }}
password: ${{ secrets.QUAY_ROBOT_TOKEN }} password: ${{ secrets.QUAY_ROBOT_TOKEN }}
- name: Docker metadata - name: Docker metadata
id: docker-meta id: docker-meta
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0 uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
with: with:
images: | images: |
${{ env.REGISTRY }}/${{ needs.build-arch.outputs.repository }} ${{ env.REGISTRY }}/${{ needs.build-arch.outputs.repository }}
+4 -4
View File
@@ -21,7 +21,7 @@ jobs:
docs_changed: ${{ steps.force.outputs.run_all == 'true' || steps.filter.outputs.docs == 'true' }} docs_changed: ${{ steps.force.outputs.run_all == 'true' || steps.filter.outputs.docs == 'true' }}
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with: with:
fetch-depth: 0 fetch-depth: 0
persist-credentials: false persist-credentials: false
@@ -69,16 +69,16 @@ jobs:
steps: steps:
- uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0 - uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0
- name: Checkout - name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with: with:
persist-credentials: false persist-credentials: false
- name: Set up Python - name: Set up Python
id: setup-python id: setup-python
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with: with:
python-version: ${{ env.DEFAULT_PYTHON_VERSION }} python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
- name: Install uv - name: Install uv
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
with: with:
version: ${{ env.DEFAULT_UV_VERSION }} version: ${{ env.DEFAULT_UV_VERSION }}
enable-cache: true enable-cache: true
+19 -19
View File
@@ -21,7 +21,7 @@ jobs:
frontend_changed: ${{ steps.force.outputs.run_all == 'true' || steps.filter.outputs.frontend == 'true' }} frontend_changed: ${{ steps.force.outputs.run_all == 'true' || steps.filter.outputs.frontend == 'true' }}
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with: with:
fetch-depth: 0 fetch-depth: 0
persist-credentials: false persist-credentials: false
@@ -77,11 +77,11 @@ jobs:
contents: read contents: read
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with: with:
persist-credentials: false persist-credentials: false
- name: Install pnpm - name: Install pnpm
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 uses: pnpm/action-setup@903f9c1a6ebcba6cf41d87230be49611ac97822e # v6.0.3
with: with:
version: 10 version: 10
- name: Use Node.js 24 - name: Use Node.js 24
@@ -92,7 +92,7 @@ jobs:
cache-dependency-path: 'src-ui/pnpm-lock.yaml' cache-dependency-path: 'src-ui/pnpm-lock.yaml'
- name: Cache frontend dependencies - name: Cache frontend dependencies
id: cache-frontend-deps id: cache-frontend-deps
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with: with:
path: | path: |
~/.pnpm-store ~/.pnpm-store
@@ -109,11 +109,11 @@ jobs:
contents: read contents: read
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with: with:
persist-credentials: false persist-credentials: false
- name: Install pnpm - name: Install pnpm
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 uses: pnpm/action-setup@903f9c1a6ebcba6cf41d87230be49611ac97822e # v6.0.3
with: with:
version: 10 version: 10
- name: Use Node.js 24 - name: Use Node.js 24
@@ -123,7 +123,7 @@ jobs:
cache: 'pnpm' cache: 'pnpm'
cache-dependency-path: 'src-ui/pnpm-lock.yaml' cache-dependency-path: 'src-ui/pnpm-lock.yaml'
- name: Cache frontend dependencies - name: Cache frontend dependencies
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with: with:
path: | path: |
~/.pnpm-store ~/.pnpm-store
@@ -148,11 +148,11 @@ jobs:
shard-count: [4] shard-count: [4]
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with: with:
persist-credentials: false persist-credentials: false
- name: Install pnpm - name: Install pnpm
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 uses: pnpm/action-setup@903f9c1a6ebcba6cf41d87230be49611ac97822e # v6.0.3
with: with:
version: 10 version: 10
- name: Use Node.js 24 - name: Use Node.js 24
@@ -162,7 +162,7 @@ jobs:
cache: 'pnpm' cache: 'pnpm'
cache-dependency-path: 'src-ui/pnpm-lock.yaml' cache-dependency-path: 'src-ui/pnpm-lock.yaml'
- name: Cache frontend dependencies - name: Cache frontend dependencies
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with: with:
path: | path: |
~/.pnpm-store ~/.pnpm-store
@@ -174,13 +174,13 @@ jobs:
run: cd src-ui && pnpm run test --max-workers=2 --shard=${{ matrix.shard-index }}/${{ matrix.shard-count }} run: cd src-ui && pnpm run test --max-workers=2 --shard=${{ matrix.shard-index }}/${{ matrix.shard-count }}
- name: Upload test results to Codecov - name: Upload test results to Codecov
if: always() if: always()
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0
with: with:
flags: frontend-node-${{ matrix.node-version }} flags: frontend-node-${{ matrix.node-version }}
directory: src-ui/ directory: src-ui/
report_type: test_results report_type: test_results
- name: Upload coverage to Codecov - name: Upload coverage to Codecov
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0 uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0
with: with:
flags: frontend-node-${{ matrix.node-version }} flags: frontend-node-${{ matrix.node-version }}
directory: src-ui/coverage/ directory: src-ui/coverage/
@@ -191,7 +191,7 @@ jobs:
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
permissions: permissions:
contents: read contents: read
container: mcr.microsoft.com/playwright:v1.61.1-noble container: mcr.microsoft.com/playwright:v1.60.0-noble
env: env:
PLAYWRIGHT_BROWSERS_PATH: /ms-playwright PLAYWRIGHT_BROWSERS_PATH: /ms-playwright
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1 PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1
@@ -203,11 +203,11 @@ jobs:
shard-count: [2] shard-count: [2]
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with: with:
persist-credentials: false persist-credentials: false
- name: Install pnpm - name: Install pnpm
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 uses: pnpm/action-setup@903f9c1a6ebcba6cf41d87230be49611ac97822e # v6.0.3
with: with:
version: 10 version: 10
- name: Use Node.js 24 - name: Use Node.js 24
@@ -217,7 +217,7 @@ jobs:
cache: 'pnpm' cache: 'pnpm'
cache-dependency-path: 'src-ui/pnpm-lock.yaml' cache-dependency-path: 'src-ui/pnpm-lock.yaml'
- name: Cache frontend dependencies - name: Cache frontend dependencies
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with: with:
path: | path: |
~/.pnpm-store ~/.pnpm-store
@@ -239,12 +239,12 @@ jobs:
contents: read contents: read
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with: with:
fetch-depth: 2 fetch-depth: 2
persist-credentials: false persist-credentials: false
- name: Install pnpm - name: Install pnpm
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 uses: pnpm/action-setup@903f9c1a6ebcba6cf41d87230be49611ac97822e # v6.0.3
with: with:
version: 10 version: 10
- name: Use Node.js 24 - name: Use Node.js 24
@@ -254,7 +254,7 @@ jobs:
cache: 'pnpm' cache: 'pnpm'
cache-dependency-path: 'src-ui/pnpm-lock.yaml' cache-dependency-path: 'src-ui/pnpm-lock.yaml'
- name: Cache frontend dependencies - name: Cache frontend dependencies
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with: with:
path: | path: |
~/.pnpm-store ~/.pnpm-store
+3 -3
View File
@@ -17,12 +17,12 @@ jobs:
runs-on: ubuntu-slim runs-on: ubuntu-slim
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with: with:
persist-credentials: false persist-credentials: false
- name: Install Python - name: Install Python
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with: with:
python-version: "3.14" python-version: "3.14"
- name: Run prek - name: Run prek
uses: j178/prek-action@bdca6f102f98e2b4c7029491a53dfd366469e33d # v2.0.4 uses: j178/prek-action@cbc2f23eb5539cf20d82d1aabd0d0ecbcc56f4e3 # v2.0.2
+10 -11
View File
@@ -20,13 +20,12 @@ jobs:
statuses: read statuses: read
steps: steps:
- name: Wait for Docker build - 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: with:
ref: ${{ github.sha }} ref: ${{ github.sha }}
check-name: 'Merge and Push Manifest' check-name: 'Merge and Push Manifest'
repo-token: ${{ secrets.GITHUB_TOKEN }} repo-token: ${{ secrets.GITHUB_TOKEN }}
wait-interval: 60 wait-interval: 60
checks-discovery-timeout: 1800
build-release: build-release:
name: Build Release name: Build Release
needs: wait-for-docker needs: wait-for-docker
@@ -35,12 +34,12 @@ jobs:
contents: read contents: read
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with: with:
persist-credentials: false persist-credentials: false
# ---- Frontend Build ---- # ---- Frontend Build ----
- name: Install pnpm - name: Install pnpm
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 uses: pnpm/action-setup@903f9c1a6ebcba6cf41d87230be49611ac97822e # v6.0.3
with: with:
version: 10 version: 10
- name: Use Node.js 24 - name: Use Node.js 24
@@ -55,11 +54,11 @@ jobs:
# ---- Backend Setup ---- # ---- Backend Setup ----
- name: Set up Python - name: Set up Python
id: setup-python id: setup-python
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with: with:
python-version: ${{ env.DEFAULT_PYTHON_VERSION }} python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
- name: Install uv - name: Install uv
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
with: with:
version: ${{ env.DEFAULT_UV_VERSION }} version: ${{ env.DEFAULT_UV_VERSION }}
enable-cache: false enable-cache: false
@@ -171,7 +170,7 @@ jobs:
fi fi
- name: Create release and changelog - name: Create release and changelog
id: create-release id: create-release
uses: release-drafter/release-drafter@4d75298e00d9e34c483e5ff8c68d0ea1c1940c1e # v7.5.1 uses: release-drafter/release-drafter@5de93583980a40bd78603b6dfdcda5b4df377b32 # v7.2.0
with: with:
name: Paperless-ngx ${{ steps.get-version.outputs.version }} name: Paperless-ngx ${{ steps.get-version.outputs.version }}
tag: ${{ steps.get-version.outputs.version }} tag: ${{ steps.get-version.outputs.version }}
@@ -182,7 +181,7 @@ jobs:
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Upload release archive - 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: with:
github_token: ${{ secrets.GITHUB_TOKEN }} github_token: ${{ secrets.GITHUB_TOKEN }}
upload_url: ${{ steps.create-release.outputs.upload_url }} upload_url: ${{ steps.create-release.outputs.upload_url }}
@@ -202,17 +201,17 @@ jobs:
pull-requests: write pull-requests: write
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with: with:
ref: main ref: main
persist-credentials: true # for pushing changelog branch persist-credentials: true # for pushing changelog branch
- name: Set up Python - name: Set up Python
id: setup-python id: setup-python
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with: with:
python-version: ${{ env.DEFAULT_PYTHON_VERSION }} python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
- name: Install uv - name: Install uv
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
with: with:
version: ${{ env.DEFAULT_UV_VERSION }} version: ${{ env.DEFAULT_UV_VERSION }}
enable-cache: false enable-cache: false
+4 -4
View File
@@ -22,11 +22,11 @@ jobs:
security-events: write security-events: write
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with: with:
persist-credentials: false persist-credentials: false
- name: Run zizmor - name: Run zizmor
uses: zizmorcore/zizmor-action@192e21d79ab29983730a13d1382995c2307fbcaa # v0.5.7 uses: zizmorcore/zizmor-action@b1d7e1fb5de872772f31590499237e7cce841e8e # v0.5.3
semgrep: semgrep:
name: Semgrep CE name: Semgrep CE
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
@@ -38,13 +38,13 @@ jobs:
security-events: write security-events: write
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with: with:
persist-credentials: false persist-credentials: false
- name: Run Semgrep - name: Run Semgrep
run: semgrep scan --config auto --sarif-output results.sarif run: semgrep scan --config auto --sarif-output results.sarif
- name: Upload results to GitHub code scanning - name: Upload results to GitHub code scanning
uses: github/codeql-action/upload-sarif@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2 uses: github/codeql-action/upload-sarif@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4.35.2
if: always() if: always()
with: with:
sarif_file: results.sarif 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 # Learn more about CodeQL language support at https://git.io/codeql-language-support
steps: steps:
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with: with:
persist-credentials: false persist-credentials: false
# Initializes the CodeQL tools for scanning. # Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL - name: Initialize CodeQL
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2 uses: github/codeql-action/init@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4.35.2
with: with:
languages: ${{ matrix.language }} languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file. # 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. # 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 # queries: ./path/to/local/query, your-org/your-repo/queries@main
- name: Perform CodeQL Analysis - name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2 uses: github/codeql-action/analyze@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4.35.2
+2 -2
View File
@@ -17,12 +17,12 @@ jobs:
environment: translation-sync environment: translation-sync
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with: with:
token: ${{ secrets.PNGX_BOT_PAT }} token: ${{ secrets.PNGX_BOT_PAT }}
persist-credentials: false persist-credentials: false
- name: crowdin action - name: crowdin action
uses: crowdin/github-action@52aa776766211d83d975df51f3b9c53c2f8ba35f # v2.16.3 uses: crowdin/github-action@8868a33591d21088edfc398968173a3b98d51706 # v2.16.2
with: with:
upload_translations: false upload_translations: false
download_translations: true download_translations: true
+1 -1
View File
@@ -31,7 +31,7 @@ jobs:
steps: steps:
- name: Label PR by file path or branch name - name: Label PR by file path or branch name
# see .github/labeler.yml for the labeler config # see .github/labeler.yml for the labeler config
uses: actions/labeler@f27b608878404679385c85cfa523b85ccb86e213 # v6.1.0 uses: actions/labeler@634933edcd8ababfe52f92936142cc22ac488b1b # v6.0.1
with: with:
repo-token: ${{ secrets.GITHUB_TOKEN }} repo-token: ${{ secrets.GITHUB_TOKEN }}
- name: Label by size - name: Label by size
+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' if: github.event_name == 'pull_request_target' && (github.event.action == 'opened' || github.event.action == 'reopened') && github.event.pull_request.user.login != 'dependabot'
steps: steps:
- name: Label PR with release-drafter - name: Label PR with release-drafter
uses: release-drafter/release-drafter@4d75298e00d9e34c483e5ff8c68d0ea1c1940c1e # v7.5.1 uses: release-drafter/release-drafter@5de93583980a40bd78603b6dfdcda5b4df377b32 # v7.2.0
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+1 -1
View File
@@ -14,7 +14,7 @@ jobs:
issues: write issues: write
pull-requests: write pull-requests: write
steps: steps:
- uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0 - uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0
with: with:
days-before-stale: 7 days-before-stale: 7
days-before-close: 14 days-before-close: 14
+5 -5
View File
@@ -14,7 +14,7 @@ jobs:
contents: write contents: write
steps: steps:
- name: Checkout code - name: Checkout code
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
env: env:
GH_REF: ${{ github.ref }} # sonar rule:githubactions:S7630 - avoid injection GH_REF: ${{ github.ref }} # sonar rule:githubactions:S7630 - avoid injection
with: with:
@@ -23,13 +23,13 @@ jobs:
persist-credentials: true # for pushing translation branch persist-credentials: true # for pushing translation branch
- name: Set up Python - name: Set up Python
id: setup-python id: setup-python
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0 uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
- name: Install system dependencies - name: Install system dependencies
run: | run: |
sudo apt-get update -qq sudo apt-get update -qq
sudo apt-get install -qq --no-install-recommends gettext sudo apt-get install -qq --no-install-recommends gettext
- name: Install uv - name: Install uv
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0 uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
with: with:
version: ${{ env.DEFAULT_UV_VERSION }} version: ${{ env.DEFAULT_UV_VERSION }}
enable-cache: true enable-cache: true
@@ -43,7 +43,7 @@ jobs:
PAPERLESS_SECRET_KEY: "ci-translate-not-a-real-secret" PAPERLESS_SECRET_KEY: "ci-translate-not-a-real-secret"
run: cd src/ && uv run manage.py makemessages -l en_US -i "samples*" run: cd src/ && uv run manage.py makemessages -l en_US -i "samples*"
- name: Install pnpm - name: Install pnpm
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9 uses: pnpm/action-setup@903f9c1a6ebcba6cf41d87230be49611ac97822e # v6.0.3
with: with:
version: 10 version: 10
- name: Use Node.js 24 - name: Use Node.js 24
@@ -54,7 +54,7 @@ jobs:
cache-dependency-path: 'src-ui/pnpm-lock.yaml' cache-dependency-path: 'src-ui/pnpm-lock.yaml'
- name: Cache frontend dependencies - name: Cache frontend dependencies
id: cache-frontend-deps id: cache-frontend-deps
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0 uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
with: with:
path: | path: |
~/.pnpm-store ~/.pnpm-store
+4 -5
View File
@@ -38,7 +38,7 @@ repos:
- json - json
# See https://github.com/prettier/prettier/issues/15742 for the fork reason # See https://github.com/prettier/prettier/issues/15742 for the fork reason
- repo: https://github.com/rbubley/mirrors-prettier - repo: https://github.com/rbubley/mirrors-prettier
rev: 'v3.9.4' rev: 'v3.8.3'
hooks: hooks:
- id: prettier - id: prettier
types_or: types_or:
@@ -46,19 +46,18 @@ repos:
- ts - ts
- markdown - markdown
additional_dependencies: additional_dependencies:
- prettier@3.9.4 - prettier@3.8.3
- 'prettier-plugin-organize-imports@4.3.0' - 'prettier-plugin-organize-imports@4.3.0'
# Python hooks # Python hooks
- repo: https://github.com/astral-sh/ruff-pre-commit - repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.15.20 rev: v0.15.12
hooks: hooks:
- id: ruff-check - id: ruff-check
- id: ruff-format - id: ruff-format
- repo: https://github.com/tox-dev/pyproject-fmt - repo: https://github.com/tox-dev/pyproject-fmt
rev: "v2.25.1" rev: "v2.21.1"
hooks: hooks:
- id: pyproject-fmt - id: pyproject-fmt
additional_dependencies: [tomli]
# Dockerfile hooks # Dockerfile hooks
- repo: https://github.com/AleksaC/hadolint-py - repo: https://github.com/AleksaC/hadolint-py
rev: v2.14.0 rev: v2.14.0
+1 -5
View File
@@ -30,7 +30,7 @@ RUN set -eux \
# Purpose: Installs s6-overlay and rootfs # Purpose: Installs s6-overlay and rootfs
# Comments: # Comments:
# - Don't leave anything extra in here either # - Don't leave anything extra in here either
FROM ghcr.io/astral-sh/uv:0.11.28-python3.12-trixie-slim AS s6-overlay-base FROM ghcr.io/astral-sh/uv:0.11.6-python3.12-trixie-slim AS s6-overlay-base
WORKDIR /usr/src/s6 WORKDIR /usr/src/s6
@@ -238,10 +238,6 @@ RUN set -eux \
&& chown --from root:root --changes --recursive paperless:paperless /usr/src/paperless \ && chown --from root:root --changes --recursive paperless:paperless /usr/src/paperless \
&& echo "Making fontconfig cache writable for arbitrary container UIDs" \ && echo "Making fontconfig cache writable for arbitrary container UIDs" \
&& chmod 1777 /var/cache/fontconfig \ && 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" \ && 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 collectstatic --clear --no-input --link \
&& PAPERLESS_SECRET_KEY=build-time-dummy s6-setuidgid paperless python3 manage.py compilemessages \ && PAPERLESS_SECRET_KEY=build-time-dummy s6-setuidgid paperless python3 manage.py compilemessages \
-1
View File
@@ -63,7 +63,6 @@ The following are not generally considered vulnerabilities unless accompanied by
- optional webhook, mail, AI, OCR, or integration behavior described without a product-level vulnerability - optional webhook, mail, AI, OCR, or integration behavior described without a product-level vulnerability
- missing limits or hardening settings presented without concrete impact - missing limits or hardening settings presented without concrete impact
- generic AI or static-analysis output that is not confirmed against the current codebase and a real deployment scenario - generic AI or static-analysis output that is not confirmed against the current codebase and a real deployment scenario
- the ability to attach objects that a user cannot access to a document by ID is an intentional design choice, and not considered a vulnerability
## Transparency ## Transparency
+3 -3
View File
@@ -4,7 +4,7 @@
# correct networking for the tests # correct networking for the tests
services: services:
gotenberg: gotenberg:
image: docker.io/gotenberg/gotenberg:8.34 image: docker.io/gotenberg/gotenberg:8.33
hostname: gotenberg hostname: gotenberg
container_name: gotenberg container_name: gotenberg
network_mode: host network_mode: host
@@ -24,7 +24,7 @@ services:
network_mode: host network_mode: host
restart: unless-stopped restart: unless-stopped
greenmail: greenmail:
image: docker.io/greenmail/standalone:2.1.9 image: docker.io/greenmail/standalone:2.1.8
hostname: greenmail hostname: greenmail
container_name: greenmail container_name: greenmail
environment: environment:
@@ -35,7 +35,7 @@ services:
- "3143:3143" # IMAP - "3143:3143" # IMAP
restart: unless-stopped restart: unless-stopped
nginx: nginx:
image: docker.io/nginx:1.31.2-alpine image: docker.io/nginx:1.31.1-alpine
hostname: nginx hostname: nginx
container_name: nginx container_name: nginx
ports: ports:
@@ -72,7 +72,7 @@ services:
PAPERLESS_TIKA_GOTENBERG_ENDPOINT: http://gotenberg:3000 PAPERLESS_TIKA_GOTENBERG_ENDPOINT: http://gotenberg:3000
PAPERLESS_TIKA_ENDPOINT: http://tika:9998 PAPERLESS_TIKA_ENDPOINT: http://tika:9998
gotenberg: gotenberg:
image: docker.io/gotenberg/gotenberg:8.34 image: docker.io/gotenberg/gotenberg:8.33
restart: unless-stopped restart: unless-stopped
# The gotenberg chromium route is used to convert .eml files. We do not # The gotenberg chromium route is used to convert .eml files. We do not
# want to allow external content like tracking pixels or even javascript. # want to allow external content like tracking pixels or even javascript.
+1 -1
View File
@@ -56,7 +56,7 @@ services:
environment: environment:
PAPERLESS_REDIS: redis://broker:6379 PAPERLESS_REDIS: redis://broker:6379
PAPERLESS_DBHOST: db PAPERLESS_DBHOST: db
PAPERLESS_DBENGINE: postgresql PAPERLESS_DBENGINE: postgres
env_file: env_file:
- stack.env - stack.env
volumes: volumes:
@@ -67,7 +67,7 @@ services:
PAPERLESS_TIKA_GOTENBERG_ENDPOINT: http://gotenberg:3000 PAPERLESS_TIKA_GOTENBERG_ENDPOINT: http://gotenberg:3000
PAPERLESS_TIKA_ENDPOINT: http://tika:9998 PAPERLESS_TIKA_ENDPOINT: http://tika:9998
gotenberg: gotenberg:
image: docker.io/gotenberg/gotenberg:8.34 image: docker.io/gotenberg/gotenberg:8.33
restart: unless-stopped restart: unless-stopped
# The gotenberg chromium route is used to convert .eml files. We do not # The gotenberg chromium route is used to convert .eml files. We do not
# want to allow external content like tracking pixels or even javascript. # want to allow external content like tracking pixels or even javascript.
@@ -56,7 +56,7 @@ services:
PAPERLESS_TIKA_GOTENBERG_ENDPOINT: http://gotenberg:3000 PAPERLESS_TIKA_GOTENBERG_ENDPOINT: http://gotenberg:3000
PAPERLESS_TIKA_ENDPOINT: http://tika:9998 PAPERLESS_TIKA_ENDPOINT: http://tika:9998
gotenberg: gotenberg:
image: docker.io/gotenberg/gotenberg:8.34 image: docker.io/gotenberg/gotenberg:8.33
restart: unless-stopped restart: unless-stopped
# The gotenberg chromium route is used to convert .eml files. We do not # The gotenberg chromium route is used to convert .eml files. We do not
# want to allow external content like tracking pixels or even javascript. # want to allow external content like tracking pixels or even javascript.
+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} #### [`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, This should be a language code supported by the dateparser library,
for example: "en", or a combination such as "en+de". for example: "en", or a combination such as "en+de".
Locales are also supported (e.g., "en-AU"). Locales are also supported (e.g., "en-AU").
Multiple languages can be combined using "+", for example: "en+de" or "en-AU+de". 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). 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.
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`). If not set, Paperless will attempt to infer the language(s) from the OCR configuration (`PAPERLESS_OCR_LANGUAGE`).
!!! note !!! 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).
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} #### [`PAPERLESS_EMAIL_TASK_CRON=<cron expression>`](#PAPERLESS_EMAIL_TASK_CRON) {#PAPERLESS_EMAIL_TASK_CRON}
+1 -9
View File
@@ -192,7 +192,7 @@ 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. 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 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 The index format is incompatible with Whoosh, so **the search index is automatically rebuilt from
@@ -326,11 +326,3 @@ behind a reverse proxy may need to set
[`PAPERLESS_TRUSTED_PROXIES`](configuration.md#PAPERLESS_TRUSTED_PROXIES), [`PAPERLESS_TRUSTED_PROXIES`](configuration.md#PAPERLESS_TRUSTED_PROXIES),
[`PAPERLESS_ALLAUTH_TRUSTED_CLIENT_IP_HEADER`](configuration.md#PAPERLESS_ALLAUTH_TRUSTED_CLIENT_IP_HEADER), [`PAPERLESS_ALLAUTH_TRUSTED_CLIENT_IP_HEADER`](configuration.md#PAPERLESS_ALLAUTH_TRUSTED_CLIENT_IP_HEADER),
or both, to avoid `403 Forbidden` errors on login. or both, to avoid `403 Forbidden` errors on login.
## 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.
### Action Required
No user action is required. The migration will automatically clamp any `MailRule.maximum_age` values greater than 32767 to 32767 during the migration process.
+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`. 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 ```yaml
webserver: 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 Some file systems, such as NFS network shares, don't support file system
notifications with `inotify`. When the consumption directory is on such a notifications with `inotify`. When the consumption directory is on such a
file system, Paperless-ngx will not pick up new files with the default 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) configuration. Use [`PAPERLESS_CONSUMER_POLLING`](configuration.md#PAPERLESS_CONSUMER_POLLING)
to a positive number to enable polling and disable native filesystem notifications. to enable polling and disable inotify. See [here](configuration.md#polling).
## Bare Metal Install {#bare_metal} ## 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 ### Password reset
In order to enable the password reset feature you will need to setup an SMTP backend, see 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_EMAIL_HOST`](configuration.md#PAPERLESS_EMAIL_HOST). If your installation does not have
[`PAPERLESS_URL`](configuration.md#PAPERLESS_URL) and / or its corresponding configuration settings. [`PAPERLESS_URL`](configuration.md#PAPERLESS_URL) set, the reset link included in emails will use the server host.
### Two-factor authentication ### 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 When you search paperless for a document, it tries to match this query
against your documents. Paperless will look for matching documents by against your documents. Paperless will look for matching documents by
inspecting their content, title, correspondent, type, and tags. Paperless inspecting their content, title, correspondent, type, tags, notes, and
returns a scored list of results, so that documents matching your query custom field values. Paperless returns a scored list of results, so that
better will appear further up in the search results. Notes and custom field documents matching your query better will appear further up in the search
values can be searched using the advanced search syntax described below. results.
By default, paperless returns only documents which contain all words By default, paperless returns only documents which contain all words
typed in the search bar. A few things to know about how matching works: 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 #### Searching custom fields
Custom field names and values are included in the full-text index, but they Custom field values are included in the full-text index, so a plain search
are not searched by a plain, unqualified query. Use the advanced search syntax already matches documents whose custom field values contain your search terms.
to search by field name or value: To narrow by field name or value specifically:
``` ```
custom_fields.value:policy custom_fields.value:policy
@@ -921,9 +921,8 @@ custom_fields.name:"Contract Number" custom_fields.value:1312
#### Searching notes #### Searching notes
Notes are included in the full-text index, but they are not searched by a Notes content is included in full-text search automatically. To search
plain, unqualified query. Use the advanced search syntax to search by note by note author or content specifically:
author or content:
``` ```
notes.user:alice notes.user:alice
+32 -32
View File
@@ -1,6 +1,6 @@
[project] [project]
name = "paperless-ngx" name = "paperless-ngx"
version = "3.0.1" version = "3.0.0"
description = "A community-supported supercharged document management system: scan, index and archive all your physical documents" description = "A community-supported supercharged document management system: scan, index and archive all your physical documents"
readme = "README.md" readme = "README.md"
requires-python = ">=3.11" requires-python = ">=3.11"
@@ -16,7 +16,7 @@ classifiers = [
dependencies = [ dependencies = [
"azure-ai-documentintelligence>=1.0.2", "azure-ai-documentintelligence>=1.0.2",
"babel>=2.17", "babel>=2.17",
"bleach~=6.4.0", "bleach~=6.3.0",
"celery[redis]~=5.6.2", "celery[redis]~=5.6.2",
"channels~=4.2", "channels~=4.2",
"channels-redis~=4.2", "channels-redis~=4.2",
@@ -50,13 +50,13 @@ dependencies = [
"imap-tools~=1.13.0", "imap-tools~=1.13.0",
"jinja2~=3.1.5", "jinja2~=3.1.5",
"langdetect~=1.0.9", "langdetect~=1.0.9",
"llama-index-core>=0.14.22", "llama-index-core>=0.14.21",
"llama-index-embeddings-huggingface>=0.6.1", "llama-index-embeddings-huggingface>=0.6.1",
"llama-index-embeddings-ollama>=0.9", "llama-index-embeddings-ollama>=0.9",
"llama-index-embeddings-openai-like>=0.2.2", "llama-index-embeddings-openai-like>=0.2.2",
"llama-index-llms-ollama>=0.9.1", "llama-index-llms-ollama>=0.9.1",
"llama-index-llms-openai-like>=0.7.1", "llama-index-llms-openai-like>=0.7.1",
"nltk~=3.10.0", "nltk~=3.9.1",
"ocrmypdf~=17.4.2", "ocrmypdf~=17.4.2",
"openai>=2.32", "openai>=2.32",
"pathvalidate~=3.3.1", "pathvalidate~=3.3.1",
@@ -75,7 +75,7 @@ dependencies = [
"sqlite-vec==0.1.9", "sqlite-vec==0.1.9",
"tantivy~=0.26.0", "tantivy~=0.26.0",
"tika-client~=0.11.0", "tika-client~=0.11.0",
"torch~=2.13.0", "torch~=2.11.0",
"watchfiles>=1.1.1", "watchfiles>=1.1.1",
"whitenoise~=6.11", "whitenoise~=6.11",
"zxing-cpp~=3.0.0", "zxing-cpp~=3.0.0",
@@ -85,9 +85,9 @@ mariadb = [
"mysqlclient~=2.2.7", "mysqlclient~=2.2.7",
] ]
postgres = [ postgres = [
"psycopg[c,pool]==3.3", "psycopg[c,pool]==3.3.4",
# Direct dependency for proper resolution of the pre-built wheels # Direct dependency for proper resolution of the pre-built wheels
"psycopg-c==3.3", "psycopg-c==3.3.4",
"psycopg-pool==3.3.1", "psycopg-pool==3.3.1",
] ]
webserver = [ webserver = [
@@ -101,11 +101,11 @@ dev = [
{ include-group = "testing" }, { include-group = "testing" },
] ]
docs = [ docs = [
"zensical>=0.0.47", "zensical>=0.0.36",
] ]
lint = [ lint = [
"prek~=0.3.10", "prek~=0.3.10",
"ruff~=0.15.20", "ruff~=0.15.12",
] ]
testing = [ testing = [
"daphne", "daphne",
@@ -161,8 +161,8 @@ explicit = true
[tool.uv.sources] [tool.uv.sources]
# Markers are chosen to select these almost exclusively when building the Docker image # Markers are chosen to select these almost exclusively when building the Docker image
psycopg-c = [ psycopg-c = [
{ url = "https://github.com/paperless-ngx/builder/releases/download/psycopg-trixie-3.3.0/psycopg_c-3.3.0-cp312-cp312-linux_x86_64.whl", marker = "sys_platform == 'linux' and platform_machine == 'x86_64' and python_version == '3.12'" }, { url = "https://github.com/paperless-ngx/builder/releases/download/psycopg-trixie-3.3.4/psycopg_c-3.3.4-cp312-cp312-linux_x86_64.whl", marker = "sys_platform == 'linux' and platform_machine == 'x86_64' and python_version == '3.12'" },
{ url = "https://github.com/paperless-ngx/builder/releases/download/psycopg-trixie-3.3.0/psycopg_c-3.3.0-cp312-cp312-linux_aarch64.whl", marker = "sys_platform == 'linux' and platform_machine == 'aarch64' and python_version == '3.12'" }, { url = "https://github.com/paperless-ngx/builder/releases/download/psycopg-trixie-3.3.4/psycopg_c-3.3.4-cp312-cp312-linux_aarch64.whl", marker = "sys_platform == 'linux' and platform_machine == 'aarch64' and python_version == '3.12'" },
] ]
torch = [ torch = [
{ index = "pytorch-cpu" }, { index = "pytorch-cpu" },
@@ -245,38 +245,50 @@ per-file-ignores."src/documents/models.py" = [
isort.force-single-line = true isort.force-single-line = true
[tool.codespell] [tool.codespell]
write-changes = true
ignore-words-list = "criterias,afterall,valeu,ureue,equest,ure,assertIn,Oktober,commitish" ignore-words-list = "criterias,afterall,valeu,ureue,equest,ure,assertIn,Oktober,commitish"
skip = """\ skip = """\
src-ui/src/locale/*,src-ui/pnpm-lock.yaml,src-ui/e2e/*,src/paperless_mail/tests/samples/*,src/paperless/tests/samples\ src-ui/src/locale/*,src-ui/pnpm-lock.yaml,src-ui/e2e/*,src/paperless_mail/tests/samples/*,src/paperless/tests/samples\
/mail/*,src/documents/tests/samples/*,*.po,*.json\ /mail/*,src/documents/tests/samples/*,*.po,*.json\
""" """
write-changes = true
[tool.pyproject-fmt] [tool.pyproject-fmt]
table_format = "long" table_format = "long"
[tool.mypy] [tool.mypy]
mypy_path = "src" mypy_path = "src"
disallow_any_generics = true
disallow_untyped_defs = true
disallow_incomplete_defs = true
check_untyped_defs = true
warn_redundant_casts = true
warn_unused_ignores = true
plugins = [ plugins = [
"mypy_django_plugin.main", "mypy_django_plugin.main",
"mypy_drf_plugin.main", "mypy_drf_plugin.main",
] ]
check_untyped_defs = true
disallow_any_generics = true
disallow_incomplete_defs = true
disallow_untyped_defs = true
warn_redundant_casts = true
warn_unused_ignores = true
[tool.pyrefly] [tool.pyrefly]
search-path = [ "src" ]
baseline = ".pyrefly-baseline.json" baseline = ".pyrefly-baseline.json"
python-platform = "linux" python-platform = "linux"
search-path = [ "src" ]
[tool.django-stubs] [tool.django-stubs]
django_settings_module = "paperless.settings" django_settings_module = "paperless.settings"
[tool.pytest] [tool.pytest]
minversion = "9.0"
pythonpath = [ "src" ]
strict_config = true
strict_markers = true
strict_parametrization_ids = true
strict_xfail = true
testpaths = [
"src/documents/tests/",
"src/paperless/tests/",
"src/paperless_mail/tests/",
"src/paperless_ai/tests",
]
addopts = [ addopts = [
"--pythonwarnings=all", "--pythonwarnings=all",
"--cov", "--cov",
@@ -291,6 +303,7 @@ addopts = [
"-o", "-o",
"junit_family=legacy", "junit_family=legacy",
] ]
norecursedirs = [ "src/locale/", ".venv/", "src-ui/" ]
DJANGO_SETTINGS_MODULE = "paperless.settings" DJANGO_SETTINGS_MODULE = "paperless.settings"
markers = [ markers = [
"live: Integration tests requiring external services (Gotenberg, Tika, nginx, etc)", "live: Integration tests requiring external services (Gotenberg, Tika, nginx, etc)",
@@ -303,19 +316,6 @@ markers = [
"search: Tests for the Tantivy search backend", "search: Tests for the Tantivy search backend",
"api: Tests for REST API endpoints", "api: Tests for REST API endpoints",
] ]
minversion = "9.0"
norecursedirs = [ "src/locale/", ".venv/", "src-ui/" ]
pythonpath = [ "src" ]
strict_config = true
strict_markers = true
strict_parametrization_ids = true
strict_xfail = true
testpaths = [
"src/documents/tests/",
"src/paperless/tests/",
"src/paperless_mail/tests/",
"src/paperless_ai/tests",
]
[tool.pytest_env] [tool.pytest_env]
PAPERLESS_SECRET_KEY = "test-secret-key-do-not-use-in-production" PAPERLESS_SECRET_KEY = "test-secret-key-do-not-use-in-production"
+1 -13
View File
@@ -79,16 +79,6 @@
"glob": "{pdf.worker.min.mjs,pdf.min.mjs}", "glob": "{pdf.worker.min.mjs,pdf.min.mjs}",
"input": "node_modules/pdfjs-dist/legacy/build/", "input": "node_modules/pdfjs-dist/legacy/build/",
"output": "/assets/js/" "output": "/assets/js/"
},
{
"glob": "**/*",
"input": "node_modules/pdfjs-dist/wasm/",
"output": "/assets/wasm/"
},
{
"glob": "**/*",
"input": "node_modules/pdfjs-dist/iccs/",
"output": "/assets/iccs/"
} }
], ],
"styles": [ "styles": [
@@ -165,9 +155,7 @@
"builder": "@angular-builders/jest:run", "builder": "@angular-builders/jest:run",
"options": { "options": {
"tsConfig": "tsconfig.spec.json", "tsConfig": "tsconfig.spec.json",
"zoneless": true, "zoneless": false
"watch": false,
"coverage": true
} }
}, },
"lint": { "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 angularTemplateParser = require('@angular-eslint/template-parser')
const tsParser = require('@typescript-eslint/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 = [ module.exports = [
{ {
ignores: ['projects/**/*', 'src/app/components/common/pdf-viewer/**'], ignores: ['projects/**/*', 'src/app/components/common/pdf-viewer/**'],
@@ -33,6 +12,8 @@ module.exports = [
languageOptions: { languageOptions: {
parser: tsParser, parser: tsParser,
parserOptions: { parserOptions: {
project: ['tsconfig.json'],
createDefaultProgram: true,
ecmaVersion: 2020, ecmaVersion: 2020,
sourceType: 'module', sourceType: 'module',
}, },
@@ -41,9 +22,9 @@ module.exports = [
'@angular-eslint': angularEslintPlugin, '@angular-eslint': angularEslintPlugin,
'@angular-eslint/template': angularTemplatePlugin, '@angular-eslint/template': angularTemplatePlugin,
}, },
processor: angularTemplatePlugin.processors['extract-inline-html'], processor: '@angular-eslint/template/extract-inline-html',
rules: { rules: {
...angularTsRecommendedRules, ...angularEslintPlugin.configs.recommended.rules,
'@angular-eslint/directive-selector': [ '@angular-eslint/directive-selector': [
'error', 'error',
{ {
@@ -71,7 +52,7 @@ module.exports = [
'@angular-eslint/template': angularTemplatePlugin, '@angular-eslint/template': angularTemplatePlugin,
}, },
rules: { rules: {
...angularTemplateRecommendedRules, ...angularTemplatePlugin.configs.recommended.rules,
}, },
}, },
] ]
+775 -885
View File
File diff suppressed because it is too large Load Diff
+49 -38
View File
@@ -1,26 +1,27 @@
{ {
"name": "paperless-ngx-ui", "name": "paperless-ngx-ui",
"version": "3.0.1", "version": "3.0.0",
"scripts": { "scripts": {
"preinstall": "npx only-allow pnpm", "preinstall": "npx only-allow pnpm",
"ng": "ng", "ng": "ng",
"start": "ng serve", "start": "ng serve",
"build": "ng build", "build": "ng build",
"test": "ng test", "test": "ng test --no-watch --coverage",
"lint": "ng lint" "lint": "ng lint"
}, },
"private": true, "private": true,
"dependencies": { "dependencies": {
"@angular/cdk": "^22.0.3", "@angular/cdk": "^21.2.12",
"@angular/common": "~22.0.5", "@angular/common": "~21.2.14",
"@angular/compiler": "~22.0.5", "@angular/compiler": "~21.2.14",
"@angular/core": "~22.0.5", "@angular/core": "~21.2.14",
"@angular/forms": "~22.0.5", "@angular/forms": "~21.2.14",
"@angular/localize": "~22.0.5", "@angular/localize": "~21.2.14",
"@angular/platform-browser": "~22.0.5", "@angular/platform-browser": "~21.2.14",
"@angular/router": "~22.0.5", "@angular/platform-browser-dynamic": "~21.2.14",
"@ng-bootstrap/ng-bootstrap": "^21.0.0", "@angular/router": "~21.2.14",
"@ng-select/ng-select": "^23.2.0", "@ng-bootstrap/ng-bootstrap": "^20.0.0",
"@ng-select/ng-select": "^21.8.2",
"@ngneat/dirty-check-forms": "^3.0.3", "@ngneat/dirty-check-forms": "^3.0.3",
"@popperjs/core": "^2.11.8", "@popperjs/core": "^2.11.8",
"bootstrap": "^5.3.8", "bootstrap": "^5.3.8",
@@ -28,46 +29,56 @@
"mime-names": "^1.0.0", "mime-names": "^1.0.0",
"ngx-bootstrap-icons": "^1.9.3", "ngx-bootstrap-icons": "^1.9.3",
"ngx-color": "^10.1.0", "ngx-color": "^10.1.0",
"ngx-cookie-service": "^22.0.0", "ngx-cookie-service": "^21.3.1",
"ngx-device-detector": "^12.0.0", "ngx-device-detector": "^11.0.0",
"ngx-ui-tour-ng-bootstrap": "^19.0.0", "ngx-ui-tour-ng-bootstrap": "^18.0.0",
"normalize-diacritics": "^5.0.0", "normalize-diacritics": "^5.0.0",
"pdfjs-dist": "^6.0.227", "pdfjs-dist": "^5.7.284",
"rxjs": "^7.8.2", "rxjs": "^7.8.2",
"tslib": "^2.8.1", "tslib": "^2.8.1",
"utif": "^3.1.0", "utif": "^3.1.0",
"uuid": "^14.0.1" "uuid": "^14.0.0",
"zone.js": "^0.16.2"
}, },
"devDependencies": { "devDependencies": {
"@angular-builders/custom-webpack": "^22.0.1", "@angular-builders/custom-webpack": "^21.0.3",
"@angular-builders/jest": "^22.0.1", "@angular-builders/jest": "^21.0.3",
"@angular-devkit/core": "^22.0.5", "@angular-devkit/core": "^21.2.12",
"@angular-devkit/schematics": "^22.0.5", "@angular-devkit/schematics": "^21.2.12",
"@angular-eslint/builder": "22.0.0", "@angular-eslint/builder": "21.4.0",
"@angular-eslint/eslint-plugin": "22.0.0", "@angular-eslint/eslint-plugin": "21.4.0",
"@angular-eslint/eslint-plugin-template": "22.0.0", "@angular-eslint/eslint-plugin-template": "21.4.0",
"@angular-eslint/schematics": "22.0.0", "@angular-eslint/schematics": "21.4.0",
"@angular-eslint/template-parser": "22.0.0", "@angular-eslint/template-parser": "21.4.0",
"@angular/build": "^22.0.5", "@angular/build": "^21.2.12",
"@angular/cli": "~22.0.5", "@angular/cli": "~21.2.12",
"@angular/compiler-cli": "~22.0.5", "@angular/compiler-cli": "~21.2.14",
"@codecov/webpack-plugin": "^2.0.1", "@codecov/webpack-plugin": "^2.0.1",
"@playwright/test": "^1.61.1", "@playwright/test": "^1.60.0",
"@types/jest": "^30.0.0", "@types/jest": "^30.0.0",
"@types/node": "^26.0.0", "@types/node": "^25.9.1",
"@typescript-eslint/eslint-plugin": "^8.62.0", "@typescript-eslint/eslint-plugin": "^8.60.0",
"@typescript-eslint/parser": "^8.62.0", "@typescript-eslint/parser": "^8.60.0",
"@typescript-eslint/utils": "^8.62.0", "@typescript-eslint/utils": "^8.60.0",
"eslint": "^10.5.0", "eslint": "^10.4.0",
"jest": "30.4.2", "jest": "30.4.2",
"jest-environment-jsdom": "^30.4.1", "jest-environment-jsdom": "^30.4.1",
"jest-junit": "^17.0.0", "jest-junit": "^17.0.0",
"jest-preset-angular": "^17.0.0", "jest-preset-angular": "^16.1.5",
"jest-websocket-mock": "^2.5.0", "jest-websocket-mock": "^2.5.0",
"prettier-plugin-organize-imports": "^4.3.0", "prettier-plugin-organize-imports": "^4.3.0",
"ts-node": "~10.9.1", "ts-node": "~10.9.1",
"typescript": "^6.0.3", "typescript": "^5.9.3",
"webpack": "^5.107.2" "webpack": "^5.107.2"
}, },
"packageManager": "pnpm@10.26.0" "packageManager": "pnpm@10.17.1",
"pnpm": {
"onlyBuiltDependencies": [
"@parcel/watcher",
"canvas",
"esbuild",
"lmdb",
"msgpackr-extract"
]
}
} }
+4029 -2930
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 '@angular/localize/init'
import { jest } from '@jest/globals' 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' import { TextDecoder, TextEncoder } from 'node:util'
if (process.env.NODE_ENV === 'test') { if (process.env.NODE_ENV === 'test') {
setupZonelessTestEnv() setupZoneTestEnv()
} }
;(globalThis as any).TextEncoder = TextEncoder as unknown as { ;(globalThis as any).TextEncoder = TextEncoder as unknown as {
new (): TextEncoder new (): TextEncoder
+9 -6
View File
@@ -1,6 +1,11 @@
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http' import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
import { provideHttpClientTesting } from '@angular/common/http/testing' 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 { Router, RouterModule } from '@angular/router'
import { NgbModalModule } from '@ng-bootstrap/ng-bootstrap' import { NgbModalModule } from '@ng-bootstrap/ng-bootstrap'
import { allIcons, NgxBootstrapIconsModule } from 'ngx-bootstrap-icons' import { allIcons, NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
@@ -67,8 +72,7 @@ describe('AppComponent', () => {
component = fixture.componentInstance component = fixture.componentInstance
}) })
it('should initialize the tour service & toggle class on body for styling', () => { it('should initialize the tour service & toggle class on body for styling', fakeAsync(() => {
jest.useFakeTimers()
jest.spyOn(console, 'warn').mockImplementation(() => {}) jest.spyOn(console, 'warn').mockImplementation(() => {})
fixture.detectChanges() fixture.detectChanges()
const tourSpy = jest.spyOn(tourService, 'initialize') const tourSpy = jest.spyOn(tourService, 'initialize')
@@ -77,10 +81,9 @@ describe('AppComponent', () => {
tourService.start() tourService.start()
expect(document.body.classList).toContain('tour-active') expect(document.body.classList).toContain('tour-active')
tourService.end() tourService.end()
jest.advanceTimersByTime(500) tick(500)
expect(document.body.classList).not.toContain('tour-active') expect(document.body.classList).not.toContain('tour-active')
jest.useRealTimers() }))
})
it('should display toast on document consumed with link if user has access', () => { it('should display toast on document consumed with link if user has access', () => {
const navigateSpy = jest.spyOn(router, 'navigate') 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 [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-toolbar" role="toolbar">
<div class="btn-group me-2"> <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>
<div class="btn-group"> <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>
</div> </div>
</form> </form>
@@ -97,11 +97,11 @@ export class ConfigComponent
.pipe(takeUntil(this.unsubscribeNotifier)) .pipe(takeUntil(this.unsubscribeNotifier))
.subscribe({ .subscribe({
next: (config) => { next: (config) => {
this.loading.set(false) this.loading = false
this.initialize(config) this.initialize(config)
}, },
error: (e) => { error: (e) => {
this.loading.set(false) this.loading = false
this.toastService.showError($localize`Error retrieving config`, e) this.toastService.showError($localize`Error retrieving config`, e)
}, },
}) })
@@ -162,20 +162,20 @@ export class ConfigComponent
} }
public saveConfig() { public saveConfig() {
this.loading.set(true) this.loading = true
this.configService this.configService
.saveConfig(this.configForm.value as PaperlessConfig) .saveConfig(this.configForm.value as PaperlessConfig)
.pipe(takeUntil(this.unsubscribeNotifier), first()) .pipe(takeUntil(this.unsubscribeNotifier), first())
.subscribe({ .subscribe({
next: (config) => { next: (config) => {
this.loading.set(false) this.loading = false
this.initialize(config) this.initialize(config)
this.store.next(config) this.store.next(config)
this.settingsService.initializeSettings().subscribe() this.settingsService.initializeSettings().subscribe()
this.toastService.showInfo($localize`Configuration updated`) this.toastService.showInfo($localize`Configuration updated`)
}, },
error: (e) => { error: (e) => {
this.loading.set(false) this.loading = false
this.toastService.showError( this.toastService.showError(
$localize`An error occurred updating configuration`, $localize`An error occurred updating configuration`,
e e
@@ -189,20 +189,20 @@ export class ConfigComponent
} }
public uploadFile(file: File, key: string) { public uploadFile(file: File, key: string) {
this.loading.set(true) this.loading = true
this.configService this.configService
.uploadFile(file, this.configForm.value['id'], key) .uploadFile(file, this.configForm.value['id'], key)
.pipe(takeUntil(this.unsubscribeNotifier), first()) .pipe(takeUntil(this.unsubscribeNotifier), first())
.subscribe({ .subscribe({
next: (config) => { next: (config) => {
this.loading.set(false) this.loading = false
this.initialize(config) this.initialize(config)
this.store.next(config) this.store.next(config)
this.settingsService.initializeSettings().subscribe() this.settingsService.initializeSettings().subscribe()
this.toastService.showInfo($localize`File successfully updated`) this.toastService.showInfo($localize`File successfully updated`)
}, },
error: (e) => { error: (e) => {
this.loading.set(false) this.loading = false
this.toastService.showError( this.toastService.showError(
$localize`An error occurred uploading file`, $localize`An error occurred uploading file`,
e e
@@ -5,37 +5,36 @@
i18n-info> i18n-info>
<div class="input-group input-group-sm align-items-center"> <div class="input-group input-group-sm align-items-center">
<div class="input-group input-group-sm me-3"> <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 <input
id="logLineLimit"
class="form-control" class="form-control"
type="number" type="number"
min="100" min="100"
step="100" step="100"
[ngModel]="limit()" [(ngModel)]="limit"
(ngModelChange)="limit.set($event); onLimitChange($event)" (ngModelChange)="onLimitChange($event)"
style="width: 100px;"> style="width: 100px;">
<span class="input-group-text text-muted" i18n>lines</span> <span class="input-group-text text-muted" i18n>lines</span>
</div> </div>
<div class="form-check form-switch mt-1"> <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> <label class="form-check-label" for="autoRefreshSwitch" i18n>Auto refresh</label>
</div> </div>
</div> </div>
</pngx-page-header> </pngx-page-header>
<ul ngbNav #nav="ngbNav" [activeId]="activeLog()" (activeIdChange)="activeLog.set($event); reloadLogs()" class="nav-tabs"> <ul ngbNav #nav="ngbNav" [(activeId)]="activeLog" (activeIdChange)="reloadLogs()" class="nav-tabs">
@for (logFile of logFiles(); track logFile) { @for (logFile of logFiles; track logFile) {
<li [ngbNavItem]="logFile"> <li [ngbNavItem]="logFile">
<a ngbNavLink> <a ngbNavLink>
{{logFile}}.log {{logFile}}.log
</a> </a>
</li> </li>
} }
@if (loading() || !logFiles().length) { @if (loading || !logFiles.length) {
<div class="ps-2 d-flex align-items-center"> <div class="ps-2 d-flex align-items-center">
<div class="spinner-border spinner-border-sm me-2" role="status"></div> <div class="spinner-border spinner-border-sm me-2" role="status"></div>
@if (!logFiles().length) { @if (!logFiles.length) {
<ng-container i18n>Loading...</ng-container> <ng-container i18n>Loading...</ng-container>
} }
</div> </div>
@@ -43,13 +42,13 @@
</ul> </ul>
<div #logContainer class="bg-dark text-light font-monospace log-container p-3" (scroll)="onScroll()"> <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>
<div class="spinner-border spinner-border-sm me-2" role="status"></div> <div class="spinner-border spinner-border-sm me-2" role="status"></div>
<ng-container i18n>Loading...</ng-container> <ng-container i18n>Loading...</ng-container>
</div> </div>
} @else { } @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> <p class="m-0 p-0" [ngClass]="'log-entry-' + log.level">{{log.message}}</p>
} }
} }
@@ -57,7 +56,7 @@
<button <button
type="button" type="button"
class="btn btn-sm btn-secondary jump-to-bottom position-fixed bottom-0 end-0 m-5" 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()" (click)="scrollToBottom()"
> >
<span i18n>Jump to bottom</span> <span i18n>Jump to bottom</span>
@@ -86,7 +86,7 @@ describe('LogsComponent', () => {
throwError(() => new Error('error getting logs')) throwError(() => new Error('error getting logs'))
) )
component.reloadLogs() component.reloadLogs()
expect(component.logs()).toHaveLength(0) expect(component.logs).toHaveLength(0)
}) })
it('should auto refresh, allow toggle', () => { it('should auto refresh, allow toggle', () => {
@@ -97,7 +97,7 @@ describe('LogsComponent', () => {
jest.advanceTimersByTime(6000) jest.advanceTimersByTime(6000)
expect(reloadSpy).toHaveBeenCalledTimes(2) expect(reloadSpy).toHaveBeenCalledTimes(2)
component.autoRefreshEnabled.set(false) component.autoRefreshEnabled = false
jest.advanceTimersByTime(6000) jest.advanceTimersByTime(6000)
expect(reloadSpy).toHaveBeenCalledTimes(2) expect(reloadSpy).toHaveBeenCalledTimes(2)
}) })
@@ -112,9 +112,9 @@ describe('LogsComponent', () => {
}) })
it('should update jump to bottom visibility on scroll', () => { it('should update jump to bottom visibility on scroll', () => {
component.showJumpToBottom.set(false) component.showJumpToBottom = false
jest.spyOn(component as any, 'isNearBottom').mockReturnValue(false) jest.spyOn(component as any, 'isNearBottom').mockReturnValue(false)
component.onScroll() component.onScroll()
expect(component.showJumpToBottom()).toBe(true) expect(component.showJumpToBottom).toBe(true)
}) })
}) })
@@ -7,7 +7,6 @@ import {
OnInit, OnInit,
ViewChild, ViewChild,
inject, inject,
signal,
} from '@angular/core' } from '@angular/core'
import { FormsModule, ReactiveFormsModule } from '@angular/forms' import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { NgbNavModule } from '@ng-bootstrap/ng-bootstrap' import { NgbNavModule } from '@ng-bootstrap/ng-bootstrap'
@@ -35,17 +34,17 @@ export class LogsComponent
private logService = inject(LogService) private logService = inject(LogService)
private changedetectorRef = inject(ChangeDetectorRef) 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>() private readonly limitChange$ = new Subject<number>()
@@ -60,15 +59,15 @@ export class LogsComponent
.list() .list()
.pipe(takeUntil(this.unsubscribeNotifier)) .pipe(takeUntil(this.unsubscribeNotifier))
.subscribe((result) => { .subscribe((result) => {
this.logFiles.set(result) this.logFiles = result
this.loading.set(false) this.loading = false
if (this.logFiles().length > 0) { if (this.logFiles.length > 0) {
this.activeLog.set(this.logFiles()[0]) this.activeLog = this.logFiles[0]
this.reloadLogs() this.reloadLogs()
} }
timer(5000, 5000) timer(5000, 5000)
.pipe( .pipe(
filter(() => this.autoRefreshEnabled()), filter(() => this.autoRefreshEnabled),
takeUntil(this.unsubscribeNotifier) takeUntil(this.unsubscribeNotifier)
) )
.subscribe(() => { .subscribe(() => {
@@ -86,34 +85,36 @@ export class LogsComponent
} }
reloadLogs() { reloadLogs() {
this.loading.set(true) this.loading = true
const shouldStickToBottom = this.isNearBottom() const shouldStickToBottom = this.isNearBottom()
this.logService this.logService
.get(this.activeLog(), this.limit()) .get(this.activeLog, this.limit)
.pipe(takeUntil(this.unsubscribeNotifier)) .pipe(takeUntil(this.unsubscribeNotifier))
.subscribe({ .subscribe({
next: (result) => { next: (result) => {
this.loading.set(false) this.loading = false
const parsed = this.parseLogsWithLevel(result) const parsed = this.parseLogsWithLevel(result)
const hasChanges = const hasChanges =
parsed.length !== this.logs().length || parsed.length !== this.logs.length ||
parsed.some((log, idx) => { parsed.some((log, idx) => {
const current = this.logs()[idx] const current = this.logs[idx]
return ( return (
current?.message !== log.message || current?.level !== log.level !current ||
current.message !== log.message ||
current.level !== log.level
) )
}) })
if (hasChanges) { if (hasChanges) {
this.logs.set(parsed) this.logs = parsed
if (shouldStickToBottom) { if (shouldStickToBottom) {
this.scrollToBottom() this.scrollToBottom()
} }
this.showJumpToBottom.set(!shouldStickToBottom) this.showJumpToBottom = !shouldStickToBottom
} }
}, },
error: () => { error: () => {
this.logs.set([]) this.logs = []
this.loading.set(false) this.loading = false
}, },
}) })
} }
@@ -148,7 +149,7 @@ export class LogsComponent
} }
this.changedetectorRef.detectChanges() this.changedetectorRef.detectChanges()
viewport.scrollTop = viewport.scrollHeight viewport.scrollTop = viewport.scrollHeight
this.showJumpToBottom.set(false) this.showJumpToBottom = false
} }
private isNearBottom(): boolean { private isNearBottom(): boolean {
@@ -161,6 +162,6 @@ export class LogsComponent
} }
onScroll(): void { onScroll(): void {
this.showJumpToBottom.set(!this.isNearBottom()) this.showJumpToBottom = !this.isNearBottom()
} }
} }
@@ -9,8 +9,8 @@
</button> </button>
@if (canViewSystemStatus) { @if (canViewSystemStatus) {
<button class="btn btn-sm btn-outline-primary position-relative ms-md-5 me-1" (click)="showSystemStatus()" <button class="btn btn-sm btn-outline-primary position-relative ms-md-5 me-1" (click)="showSystemStatus()"
[disabled]="!systemStatus()"> [disabled]="!systemStatus">
@if (!systemStatus()) { @if (!systemStatus) {
<div class="spinner-border spinner-border-sm me-2 h-75" role="status"></div> <div class="spinner-border spinner-border-sm me-2 h-75" role="status"></div>
} @else { } @else {
<i-bs class="me-2" name="card-checklist"></i-bs> <i-bs class="me-2" name="card-checklist"></i-bs>
@@ -37,7 +37,7 @@
<form [formGroup]="settingsForm" (ngSubmit)="saveSettings()"> <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"> <li [ngbNavItem]="SettingsNavIDs.General">
<a ngbNavLink i18n>General</a> <a ngbNavLink i18n>General</a>
<ng-template ngbNavContent> <ng-template ngbNavContent>
@@ -150,10 +150,10 @@
<div class="row mb-3"> <div class="row mb-3">
<div class="col-md-3 col-form-label pt-0"> <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>
<div class="col mb-3"> <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.TITLE_CONTENT" i18n>Title and content search</option>
<option [ngValue]="GlobalSearchType.ADVANCED" i18n>Advanced search</option> <option [ngValue]="GlobalSearchType.ADVANCED" i18n>Advanced search</option>
</select> </select>
@@ -199,10 +199,10 @@
<h5 i18n>Documents</h5> <h5 i18n>Documents</h5>
<div class="row mb-3"> <div class="row mb-3">
<div class="col-md-3 col-form-label pt-0"> <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>
<div class="col"> <div class="col">
<select id="documentListItemPerPage" class="form-select" formControlName="documentListItemPerPage"> <select class="form-select" formControlName="documentListItemPerPage">
<option [ngValue]="10">10</option> <option [ngValue]="10">10</option>
<option [ngValue]="25">25</option> <option [ngValue]="25">25</option>
<option [ngValue]="50">50</option> <option [ngValue]="50">50</option>
@@ -274,10 +274,10 @@
<h5 class="mt-3" i18n>PDF Editor</h5> <h5 class="mt-3" i18n>PDF Editor</h5>
<div class="row"> <div class="row">
<div class="col-md-3 col-form-label pt-0"> <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>
<div class="col"> <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.Create" i18n>Create new document(s)</option>
<option [ngValue]="PdfEditorEditMode.Update" i18n>Add document version</option> <option [ngValue]="PdfEditorEditMode.Update" i18n>Add document version</option>
</select> </select>
@@ -304,7 +304,7 @@
<div class="row mb-3"> <div class="row mb-3">
<div class="col"> <div class="col">
<p i18n> <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> </p>
</div> </div>
</div> </div>
@@ -313,7 +313,7 @@
<span i18n>Default Owner</span> <span i18n>Default Owner</span>
</div> </div>
<div class="col-md-5"> <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> <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>
</div> </div>
@@ -99,13 +99,6 @@ const status: SystemStatus = {
llmindex_status: SystemStatusItemStatus.DISABLED, llmindex_status: SystemStatusItemStatus.DISABLED,
llmindex_last_modified: new Date().toISOString(), llmindex_last_modified: new Date().toISOString(),
llmindex_error: null, 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) viewportScroller = TestBed.inject(ViewportScroller)
toastService = TestBed.inject(ToastService) toastService = TestBed.inject(ToastService)
settingsService = TestBed.inject(SettingsService) settingsService = TestBed.inject(SettingsService)
settingsService.currentUser.set(users[0]) settingsService.currentUser = users[0]
userService = TestBed.inject(UserService) userService = TestBed.inject(UserService)
permissionsService = TestBed.inject(PermissionsService) permissionsService = TestBed.inject(PermissionsService)
modalService = TestBed.inject(NgbModal) modalService = TestBed.inject(NgbModal)
@@ -209,7 +202,7 @@ describe('SettingsComponent', () => {
fixture.detectChanges() 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() completeSetup()
const navigateSpy = jest.spyOn(router, 'navigate') const navigateSpy = jest.spyOn(router, 'navigate')
const tabButtons = fixture.debugElement.queryAll(By.directive(NgbNavLink)) const tabButtons = fixture.debugElement.queryAll(By.directive(NgbNavLink))
@@ -217,19 +210,16 @@ describe('SettingsComponent', () => {
expect(navigateSpy).toHaveBeenCalledWith(['settings', 'documents']) expect(navigateSpy).toHaveBeenCalledWith(['settings', 'documents'])
tabButtons[2].nativeElement.dispatchEvent(new MouseEvent('click')) tabButtons[2].nativeElement.dispatchEvent(new MouseEvent('click'))
expect(navigateSpy).toHaveBeenCalledWith(['settings', 'permissions']) expect(navigateSpy).toHaveBeenCalledWith(['settings', 'permissions'])
await fixture.whenStable()
const initSpy = jest.spyOn(component, 'initialize') const initSpy = jest.spyOn(component, 'initialize')
component.isDirty = true // mock dirty component.isDirty = true // mock dirty
navigateSpy.mockResolvedValueOnce(false) // nav rejected cause dirty navigateSpy.mockResolvedValueOnce(false) // nav rejected cause dirty
tabButtons[0].nativeElement.dispatchEvent(new MouseEvent('click')) tabButtons[0].nativeElement.dispatchEvent(new MouseEvent('click'))
await fixture.whenStable()
expect(navigateSpy).toHaveBeenCalledWith(['settings', 'general']) expect(navigateSpy).toHaveBeenCalledWith(['settings', 'general'])
expect(initSpy).not.toHaveBeenCalled() expect(initSpy).not.toHaveBeenCalled()
navigateSpy.mockResolvedValueOnce(true) // nav accepted even though dirty navigateSpy.mockResolvedValueOnce(true) // nav accepted even though dirty
tabButtons[2].nativeElement.dispatchEvent(new MouseEvent('click')) tabButtons[2].nativeElement.dispatchEvent(new MouseEvent('click'))
await fixture.whenStable()
expect(navigateSpy).toHaveBeenCalledWith(['settings', 'permissions']) expect(navigateSpy).toHaveBeenCalledWith(['settings', 'permissions'])
expect(initSpy).toHaveBeenCalled() expect(initSpy).toHaveBeenCalled()
}) })
@@ -242,7 +232,7 @@ describe('SettingsComponent', () => {
activatedRoute.snapshot.fragment = '#notifications' activatedRoute.snapshot.fragment = '#notifications'
const scrollSpy = jest.spyOn(viewportScroller, 'scrollToAnchor') const scrollSpy = jest.spyOn(viewportScroller, 'scrollToAnchor')
component.ngOnInit() component.ngOnInit()
expect(component.activeNavID()).toEqual(4) // Notifications expect(component.activeNavID).toEqual(4) // Notifications
component.ngAfterViewInit() component.ngAfterViewInit()
expect(scrollSpy).toHaveBeenCalledWith('#notifications') expect(scrollSpy).toHaveBeenCalledWith('#notifications')
}) })
@@ -350,13 +340,13 @@ describe('SettingsComponent', () => {
type === PermissionType.SystemMonitoring type === PermissionType.SystemMonitoring
) )
completeSetup() completeSetup()
expect(component.systemStatus()).toEqual(status) expect(component['systemStatus']).toEqual(status) // private
expect(component.systemStatusHasErrors).toBeTruthy() expect(component.systemStatusHasErrors).toBeTruthy()
// coverage // coverage
component.systemStatus().database.status = SystemStatusItemStatus.OK component['systemStatus'].database.status = SystemStatusItemStatus.OK
component.systemStatus().tasks.redis_status = SystemStatusItemStatus.OK component['systemStatus'].tasks.redis_status = SystemStatusItemStatus.OK
component.systemStatus().tasks.celery_status = SystemStatusItemStatus.OK component['systemStatus'].tasks.celery_status = SystemStatusItemStatus.OK
component.systemStatus().tasks.sanity_check_status = component['systemStatus'].tasks.sanity_check_status =
SystemStatusItemStatus.OK SystemStatusItemStatus.OK
expect(component.systemStatusHasErrors).toBeFalsy() expect(component.systemStatusHasErrors).toBeFalsy()
}) })
@@ -399,17 +389,17 @@ describe('SettingsComponent', () => {
completeSetup() completeSetup()
const field = 'storage_path' const field = 'storage_path'
expect( expect(
component.settingsForm.get('documentDetailsHiddenFields').value component.settingsForm.get('documentDetailsHiddenFields').value.length
).toHaveLength(0) ).toEqual(0)
component.toggleDocumentDetailField(field, false) component.toggleDocumentDetailField(field, false)
expect( expect(
component.settingsForm.get('documentDetailsHiddenFields').value component.settingsForm.get('documentDetailsHiddenFields').value.length
).toHaveLength(1) ).toEqual(1)
expect(component.isDocumentDetailFieldShown(field)).toBeFalsy() expect(component.isDocumentDetailFieldShown(field)).toBeFalsy()
component.toggleDocumentDetailField(field, true) component.toggleDocumentDetailField(field, true)
expect( expect(
component.settingsForm.get('documentDetailsHiddenFields').value component.settingsForm.get('documentDetailsHiddenFields').value.length
).toHaveLength(0) ).toEqual(0)
expect(component.isDocumentDetailFieldShown(field)).toBeTruthy() expect(component.isDocumentDetailFieldShown(field)).toBeTruthy()
}) })
}) })
@@ -6,7 +6,6 @@ import {
OnDestroy, OnDestroy,
OnInit, OnInit,
inject, inject,
signal,
} from '@angular/core' } from '@angular/core'
import { import {
FormControl, FormControl,
@@ -142,7 +141,7 @@ export class SettingsComponent
private systemStatusService = inject(SystemStatusService) private systemStatusService = inject(SystemStatusService)
private savedViewsService = inject(SavedViewService) private savedViewsService = inject(SavedViewService)
readonly activeNavID = signal<number>(undefined) activeNavID: number
settingsForm = new FormGroup({ settingsForm = new FormGroup({
bulkEditConfirmationDialogs: new FormControl(null), bulkEditConfirmationDialogs: new FormControl(null),
@@ -190,10 +189,10 @@ export class SettingsComponent
unsubscribeNotifier: Subject<any> = new Subject() unsubscribeNotifier: Subject<any> = new Subject()
savePending: boolean = false savePending: boolean = false
readonly users = signal<User[]>(undefined) users: User[]
readonly groups = signal<Group[]>(undefined) groups: Group[]
public readonly systemStatus = signal<SystemStatus>(undefined) public systemStatus: SystemStatus
public readonly GlobalSearchType = GlobalSearchType public readonly GlobalSearchType = GlobalSearchType
@@ -204,18 +203,16 @@ export class SettingsComponent
public readonly documentDetailFieldOptions = documentDetailFieldOptions public readonly documentDetailFieldOptions = documentDetailFieldOptions
get systemStatusHasErrors(): boolean { get systemStatusHasErrors(): boolean {
const status = this.systemStatus()
if (!status) {
return false
}
return ( return (
status.database.status === SystemStatusItemStatus.ERROR || this.systemStatus.database.status === SystemStatusItemStatus.ERROR ||
status.tasks.redis_status === SystemStatusItemStatus.ERROR || this.systemStatus.tasks.redis_status === SystemStatusItemStatus.ERROR ||
status.tasks.celery_status === SystemStatusItemStatus.ERROR || this.systemStatus.tasks.celery_status === SystemStatusItemStatus.ERROR ||
status.tasks.index_status === SystemStatusItemStatus.ERROR || this.systemStatus.tasks.index_status === SystemStatusItemStatus.ERROR ||
status.tasks.classifier_status === SystemStatusItemStatus.ERROR || this.systemStatus.tasks.classifier_status ===
status.tasks.sanity_check_status === SystemStatusItemStatus.ERROR || SystemStatusItemStatus.ERROR ||
status.websocket_connected === 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()) .pipe(first())
.subscribe({ .subscribe({
next: (r) => { next: (r) => {
this.users.set(r.results) this.users = r.results
}, },
error: (e) => { error: (e) => {
this.toastService.showError($localize`Error retrieving users`, e) this.toastService.showError($localize`Error retrieving users`, e)
@@ -268,7 +265,7 @@ export class SettingsComponent
.pipe(first()) .pipe(first())
.subscribe({ .subscribe({
next: (r) => { next: (r) => {
this.groups.set(r.results) this.groups = r.results
}, },
error: (e) => { error: (e) => {
this.toastService.showError($localize`Error retrieving groups`, e) this.toastService.showError($localize`Error retrieving groups`, e)
@@ -283,7 +280,7 @@ export class SettingsComponent
(navID) => navID.toLowerCase() == section (navID) => navID.toLowerCase() == section
) )
if (navIDKey) { if (navIDKey) {
this.activeNavID.set(SettingsNavIDs[navIDKey]) this.activeNavID = SettingsNavIDs[navIDKey]
} }
} }
}) })
@@ -386,7 +383,7 @@ export class SettingsComponent
.navigate(['settings', foundNavIDkey.toLowerCase()]) .navigate(['settings', foundNavIDkey.toLowerCase()])
.then((navigated) => { .then((navigated) => {
if (!navigated && this.isDirty) { if (!navigated && this.isDirty) {
this.activeNavID.set(navChangeEvent.activeId) this.activeNavID = navChangeEvent.activeId
} else if (navigated && this.isDirty) { } else if (navigated && this.isDirty) {
this.initialize() this.initialize()
} }
@@ -434,7 +431,7 @@ export class SettingsComponent
if (this.canViewSystemStatus) { if (this.canViewSystemStatus) {
this.systemStatusService.get().subscribe((status) => { this.systemStatusService.get().subscribe((status) => {
this.systemStatus.set(status) this.systemStatus = status
}) })
} }
} }
@@ -667,6 +664,6 @@ export class SettingsComponent
size: 'xl', 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"> <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}} <i-bs name="check2-all" class="me-1"></i-bs>{{dismissButtonText}}
</button> </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> <i-bs name="check2-all" class="me-1"></i-bs><ng-container i18n>Dismiss all</ng-container>
</button> </button>
<div class="form-check form-switch mb-0 ms-2"> <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> <label class="form-check-label" for="autoRefreshSwitch" i18n>Auto refresh</label>
</div> </div>
</div> </div>
@@ -93,8 +93,7 @@
<i-bs width="1em" height="1em" name="x"></i-bs> <i-bs width="1em" height="1em" name="x"></i-bs>
</button> </button>
} }
<label class="visually-hidden" for="taskSearch" i18n>Search tasks</label> <input #filterInput class="form-control form-control-sm" type="text"
<input #filterInput id="taskSearch" class="form-control form-control-sm" type="text"
(keyup)="filterInputKeyup($event)" (keyup)="filterInputKeyup($event)"
[(ngModel)]="filterText"> [(ngModel)]="filterText">
</div> </div>
@@ -108,7 +107,7 @@
<ngb-pagination <ngb-pagination
[pageSize]="pageSize" [pageSize]="pageSize"
[collectionSize]="totalTasks()" [collectionSize]="totalTasks"
[page]="page" [page]="page"
[maxSize]="5" [maxSize]="5"
[rotate]="true" [rotate]="true"
@@ -212,7 +211,7 @@
<i-bs width="1.2em" height="1.2em" name="info-circle"></i-bs> <i-bs width="1.2em" height="1.2em" name="info-circle"></i-bs>
</button> </button>
</td> </td>
<td class="actions-col"> <td scope="row" class="actions-col">
<div class="btn-group" role="group"> <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 }"> <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> <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]] }) 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', () => { it('should apply task type and trigger source filters to the server-side task query', () => {
@@ -435,8 +435,8 @@ describe('TasksComponent', () => {
.flush(pageTwoTasks) .flush(pageTwoTasks)
expect(component.page).toBe(2) expect(component.page).toBe(2)
expect(component.totalTasks()).toBe(30) expect(component.totalTasks).toBe(30)
expect(component.pagedTasks()).toEqual([tasks[0]]) expect(component.pagedTasks).toEqual([tasks[0]])
}) })
it('should not replace section counts with current-page counts', () => { it('should not replace section counts with current-page counts', () => {
@@ -1,5 +1,5 @@
import { JsonPipe, NgTemplateOutlet } from '@angular/common' 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 { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { Router, RouterLink } from '@angular/router' import { Router, RouterLink } from '@angular/router'
import { import {
@@ -166,14 +166,14 @@ export class TasksComponent
public autoRefreshEnabled: boolean = true public autoRefreshEnabled: boolean = true
public readonly pageSize = 25 public readonly pageSize = 25
public page: number = 1 public page: number = 1
readonly totalTasks = signal(0) public totalTasks: number = 0
readonly sectionCounts = signal<Record<TaskSection, number>>({ public sectionCounts: Record<TaskSection, number> = {
[TaskSection.All]: 0, [TaskSection.All]: 0,
[TaskSection.NeedsAttention]: 0, [TaskSection.NeedsAttention]: 0,
[TaskSection.InProgress]: 0, [TaskSection.InProgress]: 0,
[TaskSection.Completed]: 0, [TaskSection.Completed]: 0,
}) }
readonly pagedTasks = signal<PaperlessTask[]>([]) public pagedTasks: PaperlessTask[] = []
public selectedSection: TaskSection = TaskSection.All public selectedSection: TaskSection = TaskSection.All
public selectedTaskType: PaperlessTaskType | null = null public selectedTaskType: PaperlessTaskType | null = null
public selectedTriggerSource: PaperlessTaskTriggerSource | null = null public selectedTriggerSource: PaperlessTaskTriggerSource | null = null
@@ -346,7 +346,7 @@ export class TasksComponent
backdrop: 'static', backdrop: 'static',
}) })
modal.componentInstance.title = $localize`Confirm Dismiss All` 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.btnClass = 'btn-warning'
modal.componentInstance.btnCaption = $localize`Dismiss` modal.componentInstance.btnCaption = $localize`Dismiss`
modal.componentInstance.confirmClicked.pipe(first()).subscribe(() => { modal.componentInstance.confirmClicked.pipe(first()).subscribe(() => {
@@ -465,7 +465,7 @@ export class TasksComponent
} }
tasksForSection(section: TaskSection): PaperlessTask[] { tasksForSection(section: TaskSection): PaperlessTask[] {
let tasks = this.pagedTasks().filter((task) => let tasks = this.pagedTasks.filter((task) =>
this.taskBelongsToSection(task, section) this.taskBelongsToSection(task, section)
) )
@@ -477,14 +477,7 @@ export class TasksComponent
} }
sectionCount(section: TaskSection): number { sectionCount(section: TaskSection): number {
return this.sectionCounts()[section] return this.sectionCounts[section]
}
private setSectionCount(section: TaskSection, count: number) {
this.sectionCounts.update((counts) => ({
...counts,
[section]: count,
}))
} }
sectionShowsResults(section: TaskSection): boolean { sectionShowsResults(section: TaskSection): boolean {
@@ -659,7 +652,7 @@ export class TasksComponent
? this.sections ? this.sections
: [this.selectedSection] : [this.selectedSection]
return this.pagedTasks().filter( return this.pagedTasks.filter(
(task) => (task) =>
sections.some((section) => this.taskBelongsToSection(task, section)) && sections.some((section) => this.taskBelongsToSection(task, section)) &&
this.taskMatchesFilters(task, { taskType, triggerSource }) this.taskMatchesFilters(task, { taskType, triggerSource })
@@ -671,12 +664,10 @@ export class TasksComponent
.statusCounts(this.getParamsForSection(TaskSection.All)) .statusCounts(this.getParamsForSection(TaskSection.All))
.pipe(first(), takeUntil(this.unsubscribeNotifier)) .pipe(first(), takeUntil(this.unsubscribeNotifier))
.subscribe((counts) => { .subscribe((counts) => {
this.sectionCounts.set({ this.sectionCounts[TaskSection.All] = counts.all
[TaskSection.All]: counts.all, this.sectionCounts[TaskSection.NeedsAttention] = counts.needs_attention
[TaskSection.NeedsAttention]: counts.needs_attention, this.sectionCounts[TaskSection.InProgress] = counts.in_progress
[TaskSection.InProgress]: counts.in_progress, this.sectionCounts[TaskSection.Completed] = counts.completed
[TaskSection.Completed]: counts.completed,
})
}) })
} }
@@ -732,7 +723,7 @@ export class TasksComponent
this.reloadSectionCounts() this.reloadSectionCounts()
this.loading.set(true) this.loading = true
this.tasksService this.tasksService
.list( .list(
this.page, this.page,
@@ -742,24 +733,24 @@ export class TasksComponent
.pipe(first(), takeUntil(this.unsubscribeNotifier)) .pipe(first(), takeUntil(this.unsubscribeNotifier))
.subscribe({ .subscribe({
next: (result) => { next: (result) => {
this.pagedTasks.set(result.results) this.pagedTasks = result.results
this.totalTasks.set(result.count) this.totalTasks = result.count
this.setSectionCount(TaskSection.All, result.count) this.sectionCounts[TaskSection.All] = result.count
if (this.selectedSection !== TaskSection.All) { 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 ( if (
this.page > 1 && this.page > 1 &&
this.pagedTasks().length === 0 && this.pagedTasks.length === 0 &&
this.totalTasks() > 0 this.totalTasks > 0
) { ) {
this.page -= 1 this.page -= 1
this.reloadPage() this.reloadPage()
} }
}, },
error: () => { error: () => {
this.loading.set(false) this.loading = false
}, },
}) })
} }
@@ -4,22 +4,22 @@
info="Manage trashed documents that are pending deletion." info="Manage trashed documents that are pending deletion."
i18n-info i18n-info
infoLink="usage/#document-trash"> 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> <i-bs name="x" class="me-1"></i-bs><ng-container i18n>Clear selection</ng-container>
</button> </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> <i-bs name="arrow-counterclockwise" class="me-1"></i-bs><ng-container i18n>Restore selected</ng-container>
</button> </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> <i-bs name="trash" class="me-1"></i-bs><ng-container i18n>Delete selected</ng-container>
</button> </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> <i-bs name="trash" class="me-1"></i-bs><ng-container i18n>Empty trash</ng-container>
</button> </button>
</pngx-page-header> </pngx-page-header>
<div class="row mb-3"> <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>
<div class="card border table-responsive mb-3"> <div class="card border table-responsive mb-3">
@@ -28,7 +28,7 @@
<tr> <tr>
<th scope="col"> <th scope="col">
<div class="form-check m-0 ms-2 me-n2"> <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> <label class="form-check-label" for="all-objects"></label>
</div> </div>
</th> </th>
@@ -38,7 +38,7 @@
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@if (loading()) { @if (loading) {
<tr> <tr>
<td colspan="5"> <td colspan="5">
<div class="spinner-border spinner-border-sm me-2" role="status"></div> <div class="spinner-border spinner-border-sm me-2" role="status"></div>
@@ -46,11 +46,11 @@
</td> </td>
</tr> </tr>
} }
@for (document of documentsInTrash(); track document.id) { @for (document of documentsInTrash; track document.id) {
<tr (click)="toggleSelected(document); $event.stopPropagation();" (mouseleave)="popupPreview.close()" class="data-row fade" [class.show]="show()"> <tr (click)="toggleSelected(document); $event.stopPropagation();" (mouseleave)="popupPreview.close()" class="data-row fade" [class.show]="show">
<td> <td>
<div class="form-check m-0 ms-2 me-n2"> <div class="form-check m-0 ms-2 me-n2">
<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> <label class="form-check-label" for="{{document.id}}"></label>
</div> </div>
</td> </td>
@@ -88,16 +88,16 @@
</table> </table>
</div> </div>
@if (!loading()) { @if (!loading) {
<div class="d-flex mb-2"> <div class="d-flex mb-2">
<div> <div>
<ng-container i18n>{totalDocuments(), plural, =1 {One document in trash} other {{{totalDocuments() || 0}} total documents in trash}}</ng-container> <ng-container i18n>{totalDocuments, plural, =1 {One document in trash} other {{{totalDocuments || 0}} total documents in trash}}</ng-container>
@if (selectedDocuments().size > 0) { @if (selectedDocuments.size > 0) {
&nbsp;({{selectedDocuments().size}} selected) &nbsp;({{selectedDocuments.size}} selected)
} }
</div> </div>
@if (documentsInTrash().length > 20) { @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> <ngb-pagination class="ms-auto" [pageSize]="25" [collectionSize]="totalDocuments" [(page)]="page" [maxSize]="5" (pageChange)="reload()" size="sm" aria-label="Pagination"></ngb-pagination>
} }
</div> </div>
} }
@@ -60,15 +60,10 @@ describe('TrashComponent', () => {
modalService = TestBed.inject(NgbModal) modalService = TestBed.inject(NgbModal)
toastService = TestBed.inject(ToastService) toastService = TestBed.inject(ToastService)
router = TestBed.inject(Router) router = TestBed.inject(Router)
jest.spyOn(router, 'navigate').mockResolvedValue(true)
component = fixture.componentInstance component = fixture.componentInstance
fixture.detectChanges() fixture.detectChanges()
}) })
afterEach(() => {
jest.useRealTimers()
})
it('should call correct service method on reload', () => { it('should call correct service method on reload', () => {
jest.useFakeTimers() jest.useFakeTimers()
const trashSpy = jest.spyOn(trashService, 'getTrash') const trashSpy = jest.spyOn(trashService, 'getTrash')
@@ -82,7 +77,7 @@ describe('TrashComponent', () => {
component.reload() component.reload()
jest.advanceTimersByTime(100) jest.advanceTimersByTime(100)
expect(trashSpy).toHaveBeenCalled() expect(trashSpy).toHaveBeenCalled()
expect(component.documentsInTrash()).toEqual(documentsInTrash) expect(component.documentsInTrash).toEqual(documentsInTrash)
}) })
it('should support delete document, show error if needed', () => { it('should support delete document, show error if needed', () => {
@@ -184,8 +179,8 @@ describe('TrashComponent', () => {
}) })
it('should support toggle all items in view', () => { it('should support toggle all items in view', () => {
component.documentsInTrash.set(documentsInTrash) component.documentsInTrash = documentsInTrash
expect(component.selectedDocuments().size).toEqual(0) expect(component.selectedDocuments.size).toEqual(0)
const toggleAllSpy = jest.spyOn(component, 'toggleAll') const toggleAllSpy = jest.spyOn(component, 'toggleAll')
const checkButton = fixture.debugElement.queryAll( const checkButton = fixture.debugElement.queryAll(
By.css('input.form-check-input') By.css('input.form-check-input')
@@ -194,21 +189,21 @@ describe('TrashComponent', () => {
checkButton.nativeElement.checked = true checkButton.nativeElement.checked = true
checkButton.nativeElement.dispatchEvent(new Event('click')) checkButton.nativeElement.dispatchEvent(new Event('click'))
expect(toggleAllSpy).toHaveBeenCalled() expect(toggleAllSpy).toHaveBeenCalled()
expect(component.selectedDocuments().size).toEqual(documentsInTrash.length) expect(component.selectedDocuments.size).toEqual(documentsInTrash.length)
}) })
it('should support toggle item', () => { it('should support toggle item', () => {
component.selectedDocuments.set(new Set([1])) component.selectedDocuments = new Set([1])
component.toggleSelected(documentsInTrash[0]) component.toggleSelected(documentsInTrash[0])
expect(component.selectedDocuments().size).toEqual(0) expect(component.selectedDocuments.size).toEqual(0)
component.toggleSelected(documentsInTrash[0]) component.toggleSelected(documentsInTrash[0])
expect(component.selectedDocuments().size).toEqual(1) expect(component.selectedDocuments.size).toEqual(1)
}) })
it('should support clear selection', () => { it('should support clear selection', () => {
component.selectedDocuments.set(new Set([1])) component.selectedDocuments = new Set([1])
component.clearSelection() component.clearSelection()
expect(component.selectedDocuments().size).toEqual(0) expect(component.selectedDocuments.size).toEqual(0)
}) })
it('should correctly display days remaining', () => { 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 { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { Router } from '@angular/router' import { Router } from '@angular/router'
import { import {
@@ -7,7 +7,7 @@ import {
NgbPaginationModule, NgbPaginationModule,
} from '@ng-bootstrap/ng-bootstrap' } from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons' 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 { Document } from 'src/app/data/document'
import { SETTINGS_KEYS } from 'src/app/data/ui-settings' import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
import { SettingsService } from 'src/app/services/settings.service' import { SettingsService } from 'src/app/services/settings.service'
@@ -42,11 +42,11 @@ export class TrashComponent
private settingsService = inject(SettingsService) private settingsService = inject(SettingsService)
private router = inject(Router) private router = inject(Router)
readonly documentsInTrash = signal<Document[]>([]) public documentsInTrash: Document[] = []
readonly selectedDocuments = signal<Set<number>>(new Set()) public selectedDocuments: Set<number> = new Set()
readonly allToggled = signal(false) public allToggled: boolean = false
readonly page = signal(1) public page: number = 1
readonly totalDocuments = signal<number>(undefined) public totalDocuments: number
constructor() { constructor() {
super() super()
@@ -54,19 +54,20 @@ export class TrashComponent
} }
reload() { reload() {
this.loading.set(true) this.loading = true
this.trashService this.trashService
.getTrash(this.page()) .getTrash(this.page)
.pipe( .pipe(
tap((r) => { tap((r) => {
this.documentsInTrash.set(r.results) this.documentsInTrash = r.results
this.totalDocuments.set(r.count) this.totalDocuments = r.count
this.selectedDocuments.set(new Set()) this.selectedDocuments.clear()
this.loading.set(false) this.loading = false
}) }),
delay(100)
) )
.subscribe(() => { .subscribe(() => {
this.show.set(true) this.show = true
}) })
} }
@@ -121,7 +122,7 @@ export class TrashComponent
.subscribe({ .subscribe({
next: () => { next: () => {
this.toastService.showInfo($localize`Document(s) deleted`) this.toastService.showInfo($localize`Document(s) deleted`)
this.allToggled.set(false) this.allToggled = false
modal.close() modal.close()
this.reload() this.reload()
}, },
@@ -164,7 +165,7 @@ export class TrashComponent
.subscribe({ .subscribe({
next: () => { next: () => {
this.toastService.showInfo($localize`Document(s) restored`) this.toastService.showInfo($localize`Document(s) restored`)
this.allToggled.set(false) this.allToggled = false
this.reload() this.reload()
}, },
error: (err) => { error: (err) => {
@@ -178,29 +179,24 @@ export class TrashComponent
toggleAll(event: PointerEvent) { toggleAll(event: PointerEvent) {
if ((event.target as HTMLInputElement).checked) { if ((event.target as HTMLInputElement).checked) {
this.selectedDocuments.set( this.selectedDocuments = new Set(this.documentsInTrash.map((t) => t.id))
new Set(this.documentsInTrash().map((t) => t.id))
)
} else { } else {
this.clearSelection() this.clearSelection()
} }
} }
toggleSelected(object: Document) { toggleSelected(object: Document) {
const selectedDocuments = new Set(this.selectedDocuments()) this.selectedDocuments.has(object.id)
selectedDocuments.has(object.id) ? this.selectedDocuments.delete(object.id)
? selectedDocuments.delete(object.id) : this.selectedDocuments.add(object.id)
: selectedDocuments.add(object.id)
this.selectedDocuments.set(selectedDocuments)
} }
clearSelection() { clearSelection() {
this.allToggled.set(false) this.allToggled = false
this.selectedDocuments.set(new Set()) this.selectedDocuments.clear()
} }
getDaysRemaining(document: Document): number { getDaysRemaining(document: Document): number {
this.settingsService.trackChanges()
const delay = this.settingsService.get(SETTINGS_KEYS.EMPTY_TRASH_DELAY) const delay = this.settingsService.get(SETTINGS_KEYS.EMPTY_TRASH_DELAY)
const diff = new Date().getTime() - new Date(document.deleted_at).getTime() const diff = new Date().getTime() - new Date(document.deleted_at).getTime()
const days = Math.ceil(diff / (1000 * 3600 * 24)) const days = Math.ceil(diff / (1000 * 3600 * 24))
@@ -7,7 +7,7 @@
> >
</pngx-page-header> </pngx-page-header>
@if (canViewUsers && users()) { @if (canViewUsers && users) {
<h4 class="d-flex"> <h4 class="d-flex">
<ng-container i18n>Users</ng-container> <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 }"> <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 class="col" i18n>Actions</div>
</div> </div>
</li> </li>
@for (user of users(); track user) { @for (user of users; track user) {
<li class="list-group-item"> <li class="list-group-item">
<div class="row"> <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> <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> </ul>
} }
@if (canViewGroups && groups()) { @if (canViewGroups && groups) {
<h4 class="mt-4 d-flex"> <h4 class="mt-4 d-flex">
<ng-container i18n>Groups</ng-container> <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 }"> <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 class="col" i18n>Actions</div>
</div> </div>
</li> </li>
@for (group of groups(); track group) { @for (group of groups; track group) {
<li class="list-group-item"> <li class="list-group-item">
<div class="row"> <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> <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> </div>
</li> </li>
} }
@if (groups().length === 0) { @if (groups.length === 0) {
<li class="list-group-item" i18n>No groups defined</li> <li class="list-group-item" i18n>No groups defined</li>
} }
</ul> </ul>
} }
@if ((canViewUsers && !users()) || (canViewGroups && !groups())) { @if ((canViewUsers && !users) || (canViewGroups && !groups)) {
<div> <div>
<div class="spinner-border spinner-border-sm fw-normal ms-2 me-auto" role="status"></div> <div class="spinner-border spinner-border-sm fw-normal ms-2 me-auto" role="status"></div>
<div class="visually-hidden" i18n>Loading...</div> <div class="visually-hidden" i18n>Loading...</div>
@@ -1,7 +1,12 @@
import { DatePipe } from '@angular/common' import { DatePipe } from '@angular/common'
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http' import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
import { provideHttpClientTesting } from '@angular/common/http/testing' 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 { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons' import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
import { of, throwError } from 'rxjs' import { of, throwError } from 'rxjs'
@@ -52,7 +57,7 @@ describe('UsersAndGroupsComponent', () => {
}).compileComponents() }).compileComponents()
fixture = TestBed.createComponent(UsersAndGroupsComponent) fixture = TestBed.createComponent(UsersAndGroupsComponent)
settingsService = TestBed.inject(SettingsService) settingsService = TestBed.inject(SettingsService)
settingsService.currentUser.set(users[0]) settingsService.currentUser = users[0]
userService = TestBed.inject(UserService) userService = TestBed.inject(UserService)
modalService = TestBed.inject(NgbModal) modalService = TestBed.inject(NgbModal)
toastService = TestBed.inject(ToastService) toastService = TestBed.inject(ToastService)
@@ -104,7 +109,7 @@ describe('UsersAndGroupsComponent', () => {
const toastInfoSpy = jest.spyOn(toastService, 'showInfo') const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
editDialog.failed.emit() editDialog.failed.emit()
expect(toastErrorSpy).toHaveBeenCalled() 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]) editDialog.succeeded.emit(users[0])
expect(toastInfoSpy).toHaveBeenCalledWith( expect(toastInfoSpy).toHaveBeenCalledWith(
`Saved user "${users[0].username}".` `Saved user "${users[0].username}".`
@@ -133,8 +138,7 @@ describe('UsersAndGroupsComponent', () => {
expect(toastInfoSpy).toHaveBeenCalledWith('Deleted user "user1"') expect(toastInfoSpy).toHaveBeenCalledWith('Deleted user "user1"')
}) })
it('should logout current user if password changed, after delay', () => { it('should logout current user if password changed, after delay', fakeAsync(() => {
jest.useFakeTimers()
completeSetup() completeSetup()
let modal: NgbModalRef let modal: NgbModalRef
modalService.activeInstances.subscribe((refs) => (modal = refs[0])) modalService.activeInstances.subscribe((refs) => (modal = refs[0]))
@@ -144,15 +148,14 @@ describe('UsersAndGroupsComponent', () => {
.mockImplementation(() => {}) .mockImplementation(() => {})
const editDialog = modal.componentInstance as UserEditDialogComponent const editDialog = modal.componentInstance as UserEditDialogComponent
editDialog.passwordIsSet = true 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]) editDialog.succeeded.emit(users[0])
fixture.detectChanges() fixture.detectChanges()
jest.advanceTimersByTime(2600) tick(2600)
expect(navSpy).toHaveBeenCalledWith( expect(navSpy).toHaveBeenCalledWith(
`${window.location.origin}/accounts/logout/?next=/accounts/login/?next=/` `${window.location.origin}/accounts/logout/?next=/accounts/login/?next=/`
) )
jest.useRealTimers() }))
})
it('should support edit / create group, show error if needed', () => { it('should support edit / create group, show error if needed', () => {
completeSetup() 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 { NgbModal } from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons' import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { Subject, first, takeUntil } from 'rxjs' import { Subject, first, takeUntil } from 'rxjs'
@@ -43,8 +43,8 @@ export class UsersAndGroupsComponent
permissionsService = inject(PermissionsService) permissionsService = inject(PermissionsService)
private settings = inject(SettingsService) private settings = inject(SettingsService)
readonly users = signal<User[]>(null) users: User[]
readonly groups = signal<Group[]>(null) groups: Group[]
unsubscribeNotifier: Subject<any> = new Subject() unsubscribeNotifier: Subject<any> = new Subject()
@@ -69,7 +69,7 @@ export class UsersAndGroupsComponent
.pipe(first(), takeUntil(this.unsubscribeNotifier)) .pipe(first(), takeUntil(this.unsubscribeNotifier))
.subscribe({ .subscribe({
next: (r) => { next: (r) => {
this.users.set(r.results) this.users = r.results
}, },
error: (e) => { error: (e) => {
this.toastService.showError($localize`Error retrieving users`, e) this.toastService.showError($localize`Error retrieving users`, e)
@@ -83,7 +83,7 @@ export class UsersAndGroupsComponent
.pipe(first(), takeUntil(this.unsubscribeNotifier)) .pipe(first(), takeUntil(this.unsubscribeNotifier))
.subscribe({ .subscribe({
next: (r) => { next: (r) => {
this.groups.set(r.results) this.groups = r.results
}, },
error: (e) => { error: (e) => {
this.toastService.showError($localize`Error retrieving groups`, e) this.toastService.showError($localize`Error retrieving groups`, e)
@@ -101,15 +101,15 @@ export class UsersAndGroupsComponent
backdrop: 'static', backdrop: 'static',
size: 'xl', size: 'xl',
}) })
modal.componentInstance.dialogMode.set( modal.componentInstance.dialogMode = user
user ? EditDialogMode.EDIT : EditDialogMode.CREATE ? EditDialogMode.EDIT
) : EditDialogMode.CREATE
modal.componentInstance.object = user modal.componentInstance.object = user
modal.componentInstance.succeeded modal.componentInstance.succeeded
.pipe(takeUntil(this.unsubscribeNotifier)) .pipe(takeUntil(this.unsubscribeNotifier))
.subscribe((newUser: User) => { .subscribe((newUser: User) => {
if ( if (
newUser.id === this.settings.currentUser().id && newUser.id === this.settings.currentUser.id &&
(modal.componentInstance as UserEditDialogComponent).passwordIsSet (modal.componentInstance as UserEditDialogComponent).passwordIsSet
) { ) {
this.toastService.showInfo( this.toastService.showInfo(
@@ -125,7 +125,7 @@ export class UsersAndGroupsComponent
$localize`Saved user "${newUser.username}".` $localize`Saved user "${newUser.username}".`
) )
this.usersService.listAll().subscribe((r) => { this.usersService.listAll().subscribe((r) => {
this.users.set(r.results) this.users = r.results
}) })
} }
}) })
@@ -152,7 +152,7 @@ export class UsersAndGroupsComponent
modal.close() modal.close()
this.toastService.showInfo($localize`Deleted user "${user.username}"`) this.toastService.showInfo($localize`Deleted user "${user.username}"`)
this.usersService.listAll().subscribe((r) => { this.usersService.listAll().subscribe((r) => {
this.users.set(r.results) this.users = r.results
}) })
}, },
error: (e) => { error: (e) => {
@@ -170,16 +170,16 @@ export class UsersAndGroupsComponent
backdrop: 'static', backdrop: 'static',
size: 'lg', size: 'lg',
}) })
modal.componentInstance.dialogMode.set( modal.componentInstance.dialogMode = group
group ? EditDialogMode.EDIT : EditDialogMode.CREATE ? EditDialogMode.EDIT
) : EditDialogMode.CREATE
modal.componentInstance.object = group modal.componentInstance.object = group
modal.componentInstance.succeeded modal.componentInstance.succeeded
.pipe(takeUntil(this.unsubscribeNotifier)) .pipe(takeUntil(this.unsubscribeNotifier))
.subscribe((newGroup) => { .subscribe((newGroup) => {
this.toastService.showInfo($localize`Saved group "${newGroup.name}".`) this.toastService.showInfo($localize`Saved group "${newGroup.name}".`)
this.groupsService.listAll().subscribe((r) => { this.groupsService.listAll().subscribe((r) => {
this.groups.set(r.results) this.groups = r.results
}) })
}) })
modal.componentInstance.failed modal.componentInstance.failed
@@ -205,7 +205,7 @@ export class UsersAndGroupsComponent
modal.close() modal.close()
this.toastService.showInfo($localize`Deleted group "${group.name}"`) this.toastService.showInfo($localize`Deleted group "${group.name}"`)
this.groupsService.listAll().subscribe((r) => { this.groupsService.listAll().subscribe((r) => {
this.groups.set(r.results) this.groups = r.results
}) })
}, },
error: (e) => { error: (e) => {
@@ -219,6 +219,6 @@ export class UsersAndGroupsComponent
} }
getGroupName(id: number): string { 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"> <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" <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" 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> <span class="navbar-toggler-icon"></span>
</button> </button>
<a class="navbar-brand d-flex align-items-center me-0 px-3 py-3 order-sm-0" <a class="navbar-brand d-flex align-items-center me-0 px-3 py-3 order-sm-0"
@@ -23,7 +23,7 @@
</div> </div>
</a> </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" <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"> <div class="col-12 col-md-7">
<pngx-global-search></pngx-global-search> <pngx-global-search></pngx-global-search>
</div> </div>
@@ -68,8 +68,8 @@
<div class="container-fluid"> <div class="container-fluid">
<div class="row"> <div class="row">
<nav id="sidebarMenu" class="d-md-block bg-light sidebar collapse" <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()" [ngClass]="slimSidebarEnabled ? 'slim' : 'col-md-3 col-lg-2 col-xxxl-1'" [class.animating]="slimSidebarAnimating"
[ngbCollapse]="isMenuCollapsed()"> [ngbCollapse]="isMenuCollapsed">
@if (canSaveSettings) { @if (canSaveSettings) {
<button class="btn btn-sm btn-dark sidebar-slim-toggler" (click)="toggleSlimSidebar()"> <button class="btn btn-sm btn-dark sidebar-slim-toggler" (click)="toggleSlimSidebar()">
@if (slimSidebarEnabled) { @if (slimSidebarEnabled) {
@@ -104,7 +104,7 @@
</h6> </h6>
<ul class="nav flex-column mb-2" cdkDropList (cdkDropListDropped)="onDrop($event)"> <ul class="nav flex-column mb-2" cdkDropList (cdkDropListDropped)="onDrop($event)">
@for (view of savedViewService.sidebarViews; track view.id) { @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)" cdkDragPreviewContainer="parent" cdkDragPreviewClass="navItemDrag" (cdkDragStarted)="onDragStart($event)"
(cdkDragEnded)="onDragEnd($event)"> (cdkDragEnded)="onDragEnd($event)">
<a class="nav-link" routerLink="view/{{view.id}}" <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> <span class="badge bg-info text-dark position-absolute top-0 end-0 d-none d-md-block">{{ savedViewService.getDocumentCount(view) }}</span>
} }
</a> </a>
@if (settingsService.organizingSidebarSavedViews()) { @if (settingsService.organizingSidebarSavedViews) {
<div class="position-absolute end-0 top-0 px-3 py-2" [class.me-n3]="slimSidebarEnabled" cdkDragHandle> <div class="position-absolute end-0 top-0 px-3 py-2" [class.me-n3]="slimSidebarEnabled" cdkDragHandle>
<i-bs name="grip-vertical"></i-bs> <i-bs name="grip-vertical"></i-bs>
</div> </div>
@@ -150,8 +150,7 @@
[disablePopover]="!slimSidebarEnabled" placement="end" container="body" triggers="mouseenter:mouseleave" [disablePopover]="!slimSidebarEnabled" placement="end" container="body" triggers="mouseenter:mouseleave"
popoverClass="popover-slim"> popoverClass="popover-slim">
<i-bs class="me-2" name="file-text"></i-bs><span>{{d.title | documentTitle}}</span> <i-bs class="me-2" name="file-text"></i-bs><span>{{d.title | documentTitle}}</span>
<span class="close flex-column justify-content-center" <span class="close flex-column justify-content-center" (click)="closeDocument(d); $event.preventDefault()">
(click)="$event.preventDefault(); $event.stopPropagation(); closeDocument(d)">
<i-bs name="x"></i-bs> <i-bs name="x"></i-bs>
</span> </span>
</a> </a>
@@ -330,10 +329,10 @@
{{ versionString }} {{ versionString }}
</a> </a>
</div> </div>
@if (!settingsService.updateCheckingIsSet || appRemoteVersion()) { @if (!settingsService.updateCheckingIsSet || appRemoteVersion) {
<div class="version-check"> <div class="version-check">
<ng-template #updateAvailablePopContent> <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> available.</ng-container><br /><ng-container i18n>Click to view.</ng-container></span>
</ng-template> </ng-template>
<ng-template #updateCheckingNotEnabledPopContent> <ng-template #updateCheckingNotEnabledPopContent>
@@ -351,13 +350,13 @@
</p> </p>
</ng-template> </ng-template>
@if (settingsService.updateCheckingIsSet) { @if (settingsService.updateCheckingIsSet) {
@if (appRemoteVersion().update_available) { @if (appRemoteVersion.update_available) {
<a class="small text-decoration-none" target="_blank" rel="noopener noreferrer" <a class="small text-decoration-none" target="_blank" rel="noopener noreferrer"
href="https://github.com/paperless-ngx/paperless-ngx/releases" href="https://github.com/paperless-ngx/paperless-ngx/releases"
[ngbPopover]="updateAvailablePopContent" popoverClass="shadow" triggers="mouseenter:mouseleave" [ngbPopover]="updateAvailablePopContent" popoverClass="shadow" triggers="mouseenter:mouseleave"
container="body"> container="body">
<i-bs width="1.2em" height="1.2em" name="info-circle" class="me-1"></i-bs> <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> <ng-container i18n>Update available</ng-container>
} }
</a> </a>
@@ -378,7 +377,7 @@
</div> </div>
</nav> </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'"> [ngClass]="slimSidebarEnabled ? 'col-slim' : 'col-md-9 col-lg-10 col-xxxl-11'">
<router-outlet></router-outlet> <router-outlet></router-outlet>
</main> </main>
@@ -4,7 +4,12 @@ import {
HttpTestingController, HttpTestingController,
provideHttpClientTesting, provideHttpClientTesting,
} from '@angular/common/http/testing' } 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 { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { BrowserModule } from '@angular/platform-browser' import { BrowserModule } from '@angular/platform-browser'
import { ActivatedRoute, Router } from '@angular/router' import { ActivatedRoute, Router } from '@angular/router'
@@ -239,12 +244,11 @@ describe('AppFrameComponent', () => {
expect(toastSpy).toHaveBeenCalled() expect(toastSpy).toHaveBeenCalled()
}) })
it('should support toggling slim sidebar and saving', () => { it('should support toggling slim sidebar and saving', fakeAsync(() => {
jest.useFakeTimers()
const saveSettingSpy = jest.spyOn(settingsService, 'set') const saveSettingSpy = jest.spyOn(settingsService, 'set')
settingsService.set(SETTINGS_KEYS.ATTRIBUTES_SECTIONS_COLLAPSED, []) settingsService.set(SETTINGS_KEYS.ATTRIBUTES_SECTIONS_COLLAPSED, [])
expect(component.slimSidebarEnabled).toBeFalsy() expect(component.slimSidebarEnabled).toBeFalsy()
expect(component.slimSidebarAnimating()).toBeFalsy() expect(component.slimSidebarAnimating).toBeFalsy()
component.toggleSlimSidebar() component.toggleSlimSidebar()
const requests = httpTestingController.match( const requests = httpTestingController.match(
`${environment.apiBaseUrl}ui_settings/` `${environment.apiBaseUrl}ui_settings/`
@@ -255,9 +259,9 @@ describe('AppFrameComponent', () => {
requests[0].request.body.settings.attributes_sections_collapsed requests[0].request.body.settings.attributes_sections_collapsed
).toEqual(['attributes']) ).toEqual(['attributes'])
requests[0].flush({ success: true }) requests[0].flush({ success: true })
expect(component.slimSidebarAnimating()).toBeTruthy() expect(component.slimSidebarAnimating).toBeTruthy()
jest.advanceTimersByTime(200) tick(200)
expect(component.slimSidebarAnimating()).toBeFalsy() expect(component.slimSidebarAnimating).toBeFalsy()
expect(component.slimSidebarEnabled).toBeTruthy() expect(component.slimSidebarEnabled).toBeTruthy()
expect(saveSettingSpy).toHaveBeenCalledWith( expect(saveSettingSpy).toHaveBeenCalledWith(
SETTINGS_KEYS.SLIM_SIDEBAR, SETTINGS_KEYS.SLIM_SIDEBAR,
@@ -267,8 +271,7 @@ describe('AppFrameComponent', () => {
SETTINGS_KEYS.ATTRIBUTES_SECTIONS_COLLAPSED, SETTINGS_KEYS.ATTRIBUTES_SECTIONS_COLLAPSED,
['attributes'] ['attributes']
) )
jest.useRealTimers() }))
})
it('should show error on toggle slim sidebar if store settings fails', () => { it('should show error on toggle slim sidebar if store settings fails', () => {
jest.spyOn(console, 'warn').mockImplementation(() => {}) jest.spyOn(console, 'warn').mockImplementation(() => {})
@@ -288,9 +291,9 @@ describe('AppFrameComponent', () => {
fixture.nativeElement as HTMLDivElement fixture.nativeElement as HTMLDivElement
).querySelector('button[data-toggle=collapse]') ).querySelector('button[data-toggle=collapse]')
button.dispatchEvent(new MouseEvent('click')) button.dispatchEvent(new MouseEvent('click'))
expect(component.isMenuCollapsed()).toBeFalsy() expect(component.isMenuCollapsed).toBeFalsy()
component.closeMenu() component.closeMenu()
expect(component.isMenuCollapsed()).toBeTruthy() expect(component.isMenuCollapsed).toBeTruthy()
}) })
it('should hide mobile search when scrolling down and show it when scrolling up', () => { it('should hide mobile search when scrolling down and show it when scrolling up', () => {
@@ -305,14 +308,14 @@ describe('AppFrameComponent', () => {
value: 40, value: 40,
}) })
component.onWindowScroll() component.onWindowScroll()
expect(component.mobileSearchHidden()).toBe(true) expect(component.mobileSearchHidden).toBe(true)
Object.defineProperty(globalThis, 'scrollY', { Object.defineProperty(globalThis, 'scrollY', {
configurable: true, configurable: true,
value: 0, value: 0,
}) })
component.onWindowScroll() component.onWindowScroll()
expect(component.mobileSearchHidden()).toBe(false) expect(component.mobileSearchHidden).toBe(false)
}) })
it('should keep mobile search visible on desktop scroll or resize', () => { it('should keep mobile search visible on desktop scroll or resize', () => {
@@ -320,13 +323,13 @@ describe('AppFrameComponent', () => {
value: 1024, value: 1024,
}) })
component.ngOnInit() component.ngOnInit()
component.mobileSearchHidden.set(true) component.mobileSearchHidden = true
component.onWindowScroll() component.onWindowScroll()
expect(component.mobileSearchHidden()).toBe(false) expect(component.mobileSearchHidden).toBe(false)
component.mobileSearchHidden.set(true) component.mobileSearchHidden = true
component.onWindowResize() component.onWindowResize()
}) })
@@ -335,7 +338,7 @@ describe('AppFrameComponent', () => {
value: 767, value: 767,
}) })
component.ngOnInit() component.ngOnInit()
component.isMenuCollapsed.set(false) component.isMenuCollapsed = false
Object.defineProperty(globalThis, 'scrollY', { Object.defineProperty(globalThis, 'scrollY', {
configurable: true, configurable: true,
@@ -343,7 +346,7 @@ describe('AppFrameComponent', () => {
}) })
component.onWindowScroll() component.onWindowScroll()
expect(component.mobileSearchHidden()).toBe(false) expect(component.mobileSearchHidden).toBe(false)
}) })
it('should support close document & navigate on close current doc', () => { 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', () => { it('should disable global dropzone on start drag + drop, re-enable after', () => {
expect(settingsService.globalDropzoneEnabled()).toBeTruthy() expect(settingsService.globalDropzoneEnabled).toBeTruthy()
component.onDragStart(null) component.onDragStart(null)
expect(settingsService.globalDropzoneEnabled()).toBeFalsy() expect(settingsService.globalDropzoneEnabled).toBeFalsy()
component.onDragEnd(null) component.onDragEnd(null)
expect(settingsService.globalDropzoneEnabled()).toBeTruthy() expect(settingsService.globalDropzoneEnabled).toBeTruthy()
}) })
it('should update saved view sorting on drag + drop, show info', () => { it('should update saved view sorting on drag + drop, show info', () => {
@@ -6,7 +6,7 @@ import {
moveItemInArray, moveItemInArray,
} from '@angular/cdk/drag-drop' } from '@angular/cdk/drag-drop'
import { NgClass } from '@angular/common' 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 { ActivatedRoute, Router, RouterModule } from '@angular/router'
import { import {
NgbCollapseModule, NgbCollapseModule,
@@ -90,10 +90,14 @@ export class AppFrameComponent
permissionsService = inject(PermissionsService) permissionsService = inject(PermissionsService)
private djangoMessagesService = inject(DjangoMessagesService) private djangoMessagesService = inject(DjangoMessagesService)
readonly appRemoteVersion = signal<AppRemoteVersion>(null) appRemoteVersion: AppRemoteVersion
readonly isMenuCollapsed = signal(true)
readonly slimSidebarAnimating = signal(false) isMenuCollapsed: boolean = true
readonly mobileSearchHidden = signal(false)
slimSidebarAnimating: boolean = false
public mobileSearchHidden: boolean = false
private lastScrollY: number = 0 private lastScrollY: number = 0
constructor() { constructor() {
@@ -143,7 +147,7 @@ export class AppFrameComponent
} }
toggleSlimSidebar(): void { toggleSlimSidebar(): void {
this.slimSidebarAnimating.set(true) this.slimSidebarAnimating = true
const slimSidebarEnabled = !this.slimSidebarEnabled const slimSidebarEnabled = !this.slimSidebarEnabled
this.settingsService.set(SETTINGS_KEYS.SLIM_SIDEBAR, slimSidebarEnabled) this.settingsService.set(SETTINGS_KEYS.SLIM_SIDEBAR, slimSidebarEnabled)
if (slimSidebarEnabled) { if (slimSidebarEnabled) {
@@ -163,7 +167,7 @@ export class AppFrameComponent
}, },
}) })
setTimeout(() => { setTimeout(() => {
this.slimSidebarAnimating.set(false) this.slimSidebarAnimating = false
}, 200) // slightly longer than css animation for slim sidebar }, 200) // slightly longer than css animation for slim sidebar
} }
@@ -173,25 +177,11 @@ export class AppFrameComponent
this.attributesSectionsCollapsed = !this.attributesSectionsCollapsed 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 { get versionString(): string {
this.settingsService.trackChanges()
return `${environment.appTitle} v${this.settingsService.get(SETTINGS_KEYS.VERSION)}${environment.tag === 'prod' ? '' : ` #${environment.tag}`}` return `${environment.appTitle} v${this.settingsService.get(SETTINGS_KEYS.VERSION)}${environment.tag === 'prod' ? '' : ` #${environment.tag}`}`
} }
get customAppTitle(): string { get customAppTitle(): string {
this.settingsService.trackChanges()
return this.settingsService.get(SETTINGS_KEYS.APP_TITLE) return this.settingsService.get(SETTINGS_KEYS.APP_TITLE)
} }
@@ -234,7 +224,6 @@ export class AppFrameComponent
} }
get slimSidebarEnabled(): boolean { get slimSidebarEnabled(): boolean {
this.settingsService.trackChanges()
return this.settingsService.get(SETTINGS_KEYS.SLIM_SIDEBAR) return this.settingsService.get(SETTINGS_KEYS.SLIM_SIDEBAR)
} }
@@ -254,7 +243,6 @@ export class AppFrameComponent
} }
get attributesSectionsCollapsed(): boolean { get attributesSectionsCollapsed(): boolean {
this.settingsService.trackChanges()
return this.settingsService return this.settingsService
.get(SETTINGS_KEYS.ATTRIBUTES_SECTIONS_COLLAPSED) .get(SETTINGS_KEYS.ATTRIBUTES_SECTIONS_COLLAPSED)
?.includes(CollapsibleSection.ATTRIBUTES) ?.includes(CollapsibleSection.ATTRIBUTES)
@@ -280,14 +268,13 @@ export class AppFrameComponent
} }
get aiEnabled(): boolean { get aiEnabled(): boolean {
this.settingsService.trackChanges()
return this.settingsService.get(SETTINGS_KEYS.AI_ENABLED) return this.settingsService.get(SETTINGS_KEYS.AI_ENABLED)
} }
@HostListener('window:resize') @HostListener('window:resize')
onWindowResize(): void { onWindowResize(): void {
if (!this.isMobileViewport()) { if (!this.isMobileViewport()) {
this.mobileSearchHidden.set(false) this.mobileSearchHidden = false
} }
} }
@@ -295,8 +282,8 @@ export class AppFrameComponent
onWindowScroll(): void { onWindowScroll(): void {
const currentScrollY = window.scrollY const currentScrollY = window.scrollY
if (!this.isMobileViewport() || this.isMenuCollapsed() === false) { if (!this.isMobileViewport() || this.isMenuCollapsed === false) {
this.mobileSearchHidden.set(false) this.mobileSearchHidden = false
this.lastScrollY = currentScrollY this.lastScrollY = currentScrollY
return return
} }
@@ -304,9 +291,9 @@ export class AppFrameComponent
const delta = currentScrollY - this.lastScrollY const delta = currentScrollY - this.lastScrollY
if (currentScrollY <= 0 || delta < -SCROLL_THRESHOLD) { if (currentScrollY <= 0 || delta < -SCROLL_THRESHOLD) {
this.mobileSearchHidden.set(false) this.mobileSearchHidden = false
} else if (currentScrollY > SCROLL_THRESHOLD && delta > SCROLL_THRESHOLD) { } else if (currentScrollY > SCROLL_THRESHOLD && delta > SCROLL_THRESHOLD) {
this.mobileSearchHidden.set(true) this.mobileSearchHidden = true
} }
this.lastScrollY = currentScrollY this.lastScrollY = currentScrollY
@@ -317,7 +304,7 @@ export class AppFrameComponent
} }
closeMenu() { closeMenu() {
this.isMenuCollapsed.set(true) this.isMenuCollapsed = true
} }
editProfile() { editProfile() {
@@ -380,11 +367,11 @@ export class AppFrameComponent
} }
onDragStart(event: CdkDragStart) { onDragStart(event: CdkDragStart) {
this.settingsService.globalDropzoneEnabled.set(false) this.settingsService.globalDropzoneEnabled = false
} }
onDragEnd(event: CdkDragEnd) { onDragEnd(event: CdkDragEnd) {
this.settingsService.globalDropzoneEnabled.set(true) this.settingsService.globalDropzoneEnabled = true
} }
onDrop(event: CdkDragDrop<SavedView[]>) { onDrop(event: CdkDragDrop<SavedView[]>) {
@@ -405,7 +392,7 @@ export class AppFrameComponent
this.remoteVersionService this.remoteVersionService
.checkForUpdates() .checkForUpdates()
.subscribe((appRemoteVersion: AppRemoteVersion) => { .subscribe((appRemoteVersion: AppRemoteVersion) => {
this.appRemoteVersion.set(appRemoteVersion) this.appRemoteVersion = appRemoteVersion
}) })
} }
@@ -432,10 +419,9 @@ export class AppFrameComponent
} }
get showSidebarCounts(): boolean { get showSidebarCounts(): boolean {
this.settingsService.trackChanges()
return ( return (
this.settingsService.get(SETTINGS_KEYS.SIDEBAR_VIEWS_SHOW_COUNT) && 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 placeholder="Search" aria-label="Search" i18n-placeholder
autocomplete="off" autocomplete="off"
spellcheck="false" spellcheck="false"
[ngModel]="query()" [(ngModel)]="query"
(ngModelChange)="queryDebounce.next($event)" (ngModelChange)="this.queryDebounce.next($event)"
(keydown)="searchInputKeyDown($event)" (keydown)="searchInputKeyDown($event)"
ngbDropdownAnchor> ngbDropdownAnchor>
<div class="position-absolute top-50 end-0 translate-middle"> <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 class="spinner-border spinner-border-sm text-muted mt-1"></div>
} }
</div> </div>
</div> </div>
@if (query()) { @if (query) {
<button class="btn btn-sm btn-outline-secondary" type="button" (click)="runFullSearch()"> <button class="btn btn-sm btn-outline-secondary" type="button" (click)="runFullSearch()">
@if (useAdvancedForFullSearch) { @if (useAdvancedForFullSearch) {
<ng-container i18n>Advanced search</ng-container> <ng-container i18n>Advanced search</ng-container>
@@ -77,88 +77,88 @@
<div ngbDropdownMenu class="w-100 mh-75 overflow-y-scroll shadow-lg"> <div ngbDropdownMenu class="w-100 mh-75 overflow-y-scroll shadow-lg">
<div (keydown)="dropdownKeyDown($event)"> <div (keydown)="dropdownKeyDown($event)">
@if (searchResults()?.total === 0) { @if (searchResults?.total === 0) {
<h6 class="dropdown-header" i18n="@@searchResults.noResults">No results</h6> <h6 class="dropdown-header" i18n="@@searchResults.noResults">No results</h6>
} @else { } @else {
@if (searchResults()?.documents.length) { @if (searchResults?.documents.length) {
<h6 class="dropdown-header" i18n="@@searchResults.documents">Documents</h6> <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> <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> <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> <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> <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> <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> <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> <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> <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> <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> <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> <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> <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> <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> <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> <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> <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> <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> <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> <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> <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> <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> <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> <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 { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
import { provideHttpClientTesting } from '@angular/common/http/testing' import { provideHttpClientTesting } from '@angular/common/http/testing'
import { ElementRef } from '@angular/core' 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 { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { Router } from '@angular/router' import { Router } from '@angular/router'
import { import {
@@ -149,7 +154,6 @@ describe('GlobalSearchComponent', () => {
searchService = TestBed.inject(SearchService) searchService = TestBed.inject(SearchService)
router = TestBed.inject(Router) router = TestBed.inject(Router)
jest.spyOn(router, 'navigate').mockResolvedValue(true)
modalService = TestBed.inject(NgbModal) modalService = TestBed.inject(NgbModal)
documentService = TestBed.inject(DocumentService) documentService = TestBed.inject(DocumentService)
documentListViewService = TestBed.inject(DocumentListViewService) documentListViewService = TestBed.inject(DocumentListViewService)
@@ -166,7 +170,7 @@ describe('GlobalSearchComponent', () => {
document.dispatchEvent(new KeyboardEvent('keydown', { key: '/' })) document.dispatchEvent(new KeyboardEvent('keydown', { key: '/' }))
expect(focusSpy).toHaveBeenCalled() expect(focusSpy).toHaveBeenCalled()
component.searchResults.set(searchResults as any) component.searchResults = searchResults as any
component.resultsDropdown.open() component.resultsDropdown.open()
fixture.detectChanges() fixture.detectChanges()
@@ -234,26 +238,26 @@ describe('GlobalSearchComponent', () => {
) )
expect(component['currentItemIndex']).toBe(0) expect(component['currentItemIndex']).toBe(0)
component.searchResults.set({ total: 1 } as any) component.searchResults = { total: 1 } as any
const primaryActionSpy = jest.spyOn(component, 'primaryAction') const primaryActionSpy = jest.spyOn(component, 'primaryAction')
component.searchInputKeyDown(new KeyboardEvent('keydown', { key: 'Enter' })) component.searchInputKeyDown(new KeyboardEvent('keydown', { key: 'Enter' }))
expect(primaryActionSpy).toHaveBeenCalled() expect(primaryActionSpy).toHaveBeenCalled()
component.query.set('test') component.query = 'test'
const resetSpy = jest.spyOn(GlobalSearchComponent.prototype as any, 'reset') const resetSpy = jest.spyOn(GlobalSearchComponent.prototype as any, 'reset')
component.searchInputKeyDown( component.searchInputKeyDown(
new KeyboardEvent('keydown', { key: 'Escape' }) new KeyboardEvent('keydown', { key: 'Escape' })
) )
expect(resetSpy).toHaveBeenCalled() expect(resetSpy).toHaveBeenCalled()
component.query.set('') component.query = ''
const blurSpy = jest.spyOn(component.searchInput.nativeElement, 'blur') const blurSpy = jest.spyOn(component.searchInput.nativeElement, 'blur')
component.searchInputKeyDown( component.searchInputKeyDown(
new KeyboardEvent('keydown', { key: 'Escape' }) new KeyboardEvent('keydown', { key: 'Escape' })
) )
expect(blurSpy).toHaveBeenCalled() expect(blurSpy).toHaveBeenCalled()
component.searchResults.set({ total: 1 } as any) component.searchResults = { total: 1 } as any
component.resultsDropdown.open() component.resultsDropdown.open()
component.searchInputKeyDown( component.searchInputKeyDown(
@@ -264,30 +268,28 @@ describe('GlobalSearchComponent', () => {
component.dropdownKeyDown(new KeyboardEvent('keydown', { key: 'Escape' })) component.dropdownKeyDown(new KeyboardEvent('keydown', { key: 'Escape' }))
expect(closeSpy).toHaveBeenCalled() expect(closeSpy).toHaveBeenCalled()
component.searchResults.set(searchResults as any) component.searchResults = searchResults as any
component.resultsDropdown.open() component.resultsDropdown.open()
component.query.set('test') component.query = 'test'
const advancedSearchSpy = jest.spyOn(component, 'runFullSearch') const advancedSearchSpy = jest.spyOn(component, 'runFullSearch')
component.searchInputKeyDown(new KeyboardEvent('keydown', { key: 'Enter' })) component.searchInputKeyDown(new KeyboardEvent('keydown', { key: 'Enter' }))
expect(advancedSearchSpy).toHaveBeenCalled() expect(advancedSearchSpy).toHaveBeenCalled()
}) })
it('should search on query debounce', () => { it('should search on query debounce', fakeAsync(() => {
jest.useFakeTimers()
const query = 'test' const query = 'test'
const searchSpy = jest.spyOn(searchService, 'globalSearch') const searchSpy = jest.spyOn(searchService, 'globalSearch')
searchSpy.mockReturnValue(of({} as any)) searchSpy.mockReturnValue(of({} as any))
const dropdownOpenSpy = jest.spyOn(component.resultsDropdown, 'open') const dropdownOpenSpy = jest.spyOn(component.resultsDropdown, 'open')
component.queryDebounce.next(query) component.queryDebounce.next(query)
jest.advanceTimersByTime(401) tick(401)
expect(searchSpy).toHaveBeenCalledWith(query) expect(searchSpy).toHaveBeenCalledWith(query)
expect(dropdownOpenSpy).toHaveBeenCalled() expect(dropdownOpenSpy).toHaveBeenCalled()
jest.useRealTimers() }))
})
it('should support primary action', () => { it('should support primary action', () => {
const object = { id: 1 } const object = { id: 1 }
const routerSpy = jest.mocked(router.navigate) const routerSpy = jest.spyOn(router, 'navigate')
const modalSpy = jest.spyOn(modalService, 'open') const modalSpy = jest.spyOn(modalService, 'open')
let modal: NgbModalRef let modal: NgbModalRef
@@ -463,13 +465,13 @@ describe('GlobalSearchComponent', () => {
const closeSpy = jest.spyOn(component.resultsDropdown, 'close') const closeSpy = jest.spyOn(component.resultsDropdown, 'close')
component['reset'](true) component['reset'](true)
expect(debounce).toHaveBeenCalledWith(null) expect(debounce).toHaveBeenCalledWith(null)
expect(component.searchResults()).toBeNull() expect(component.searchResults).toBeNull()
expect(component['currentItemIndex']).toBe(-1) expect(component['currentItemIndex']).toBe(-1)
expect(closeSpy).toHaveBeenCalled() expect(closeSpy).toHaveBeenCalled()
}) })
it('should support focus current item', () => { it('should support focus current item', () => {
component.searchResults.set(searchResults as any) component.searchResults = searchResults as any
fixture.detectChanges() fixture.detectChanges()
const focusSpy = jest.spyOn( const focusSpy = jest.spyOn(
component.primaryButtons.get(0).nativeElement, component.primaryButtons.get(0).nativeElement,
@@ -487,7 +489,7 @@ describe('GlobalSearchComponent', () => {
}) })
it('should focus button on dropdown item hover', () => { it('should focus button on dropdown item hover', () => {
component.searchResults.set(searchResults as any) component.searchResults = searchResults as any
fixture.detectChanges() fixture.detectChanges()
const item: ElementRef = component.resultItems.first const item: ElementRef = component.resultItems.first
const focusSpy = jest.spyOn( const focusSpy = jest.spyOn(
@@ -513,7 +515,7 @@ describe('GlobalSearchComponent', () => {
component.primaryAction(DataType.Document, { id: 2 }, event as any) component.primaryAction(DataType.Document, { id: 2 }, event as any)
expect(openSpy).toHaveBeenCalledWith('/documents/2', '_blank') expect(openSpy).toHaveBeenCalledWith('/documents/2', '_blank')
component.searchResults.set(searchResults as any) component.searchResults = searchResults as any
component.resultsDropdown.open() component.resultsDropdown.open()
fixture.detectChanges() fixture.detectChanges()
@@ -540,7 +542,7 @@ describe('GlobalSearchComponent', () => {
it('should support title content search and advanced search', () => { it('should support title content search and advanced search', () => {
const qfSpy = jest.spyOn(documentListViewService, 'quickFilter') const qfSpy = jest.spyOn(documentListViewService, 'quickFilter')
component.query.set('test') component.query = 'test'
component.runFullSearch() component.runFullSearch()
expect(qfSpy).toHaveBeenCalledWith([ expect(qfSpy).toHaveBeenCalledWith([
{ rule_type: FILTER_SIMPLE_TEXT, value: 'test' }, { rule_type: FILTER_SIMPLE_TEXT, value: 'test' },
@@ -550,7 +552,7 @@ describe('GlobalSearchComponent', () => {
SETTINGS_KEYS.SEARCH_FULL_TYPE, SETTINGS_KEYS.SEARCH_FULL_TYPE,
GlobalSearchType.ADVANCED GlobalSearchType.ADVANCED
) )
component.query.set('test') component.query = 'test'
component.runFullSearch() component.runFullSearch()
expect(qfSpy).toHaveBeenCalledWith([ expect(qfSpy).toHaveBeenCalledWith([
{ rule_type: FILTER_FULLTEXT_QUERY, value: 'test' }, { rule_type: FILTER_FULLTEXT_QUERY, value: 'test' },
@@ -7,7 +7,6 @@ import {
ViewChild, ViewChild,
ViewChildren, ViewChildren,
inject, inject,
signal,
} from '@angular/core' } from '@angular/core'
import { FormsModule, ReactiveFormsModule } from '@angular/forms' import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { Router } from '@angular/router' import { Router } from '@angular/router'
@@ -83,12 +82,12 @@ export class GlobalSearchComponent implements OnInit {
private locationStrategy = inject(LocationStrategy) private locationStrategy = inject(LocationStrategy)
public DataType = DataType public DataType = DataType
readonly query = signal<string>(null) public query: string
public queryDebounce: Subject<string> public queryDebounce: Subject<string>
readonly searchResults = signal<GlobalSearchResult>(null) public searchResults: GlobalSearchResult
private currentItemIndex: number = -1 private currentItemIndex: number = -1
private domIndex: number = -1 private domIndex: number = -1
readonly loading = signal(false) public loading: boolean = false
@ViewChild('searchInput') searchInput: ElementRef @ViewChild('searchInput') searchInput: ElementRef
@ViewChild('resultsDropdown') resultsDropdown: NgbDropdown @ViewChild('resultsDropdown') resultsDropdown: NgbDropdown
@@ -97,7 +96,6 @@ export class GlobalSearchComponent implements OnInit {
@ViewChildren('secondaryButton') secondaryButtons: QueryList<ElementRef> @ViewChildren('secondaryButton') secondaryButtons: QueryList<ElementRef>
get useAdvancedForFullSearch(): boolean { get useAdvancedForFullSearch(): boolean {
this.settingsService.trackChanges()
return ( return (
this.settingsService.get(SETTINGS_KEYS.SEARCH_FULL_TYPE) === this.settingsService.get(SETTINGS_KEYS.SEARCH_FULL_TYPE) ===
GlobalSearchType.ADVANCED GlobalSearchType.ADVANCED
@@ -114,7 +112,7 @@ export class GlobalSearchComponent implements OnInit {
distinctUntilChanged() distinctUntilChanged()
) )
.subscribe((text) => { .subscribe((text) => {
this.query.set(text) this.query = text
if (text) this.search(text) if (text) this.search(text)
}) })
} }
@@ -128,10 +126,10 @@ export class GlobalSearchComponent implements OnInit {
} }
private search(query: string) { private search(query: string) {
this.loading.set(true) this.loading = true
this.searchService.globalSearch(query.trim()).subscribe((results) => { this.searchService.globalSearch(query.trim()).subscribe((results) => {
this.searchResults.set(results) this.searchResults = results
this.loading.set(false) this.loading = false
this.resultsDropdown.open() this.resultsDropdown.open()
}) })
} }
@@ -207,7 +205,7 @@ export class GlobalSearchComponent implements OnInit {
editDialogComponent, editDialogComponent,
{ size } { size }
) )
modalRef.componentInstance.dialogMode.set(EditDialogMode.EDIT) modalRef.componentInstance.dialogMode = EditDialogMode.EDIT
modalRef.componentInstance.object = object modalRef.componentInstance.object = object
modalRef.componentInstance.succeeded.subscribe(() => { modalRef.componentInstance.succeeded.subscribe(() => {
this.toastService.showInfo($localize`Successfully updated object.`) this.toastService.showInfo($localize`Successfully updated object.`)
@@ -245,7 +243,7 @@ export class GlobalSearchComponent implements OnInit {
editDialogComponent, editDialogComponent,
{ size } { size }
) )
modalRef.componentInstance.dialogMode.set(EditDialogMode.EDIT) modalRef.componentInstance.dialogMode = EditDialogMode.EDIT
modalRef.componentInstance.object = object modalRef.componentInstance.object = object
modalRef.componentInstance.succeeded.subscribe(() => { modalRef.componentInstance.succeeded.subscribe(() => {
this.toastService.showInfo($localize`Successfully updated object.`) this.toastService.showInfo($localize`Successfully updated object.`)
@@ -258,8 +256,8 @@ export class GlobalSearchComponent implements OnInit {
private reset(close: boolean = false) { private reset(close: boolean = false) {
this.queryDebounce.next(null) this.queryDebounce.next(null)
this.query.set(null) this.query = null
this.searchResults.set(null) this.searchResults = null
this.currentItemIndex = -1 this.currentItemIndex = -1
if (close) { if (close) {
this.resultsDropdown.close() this.resultsDropdown.close()
@@ -294,7 +292,7 @@ export class GlobalSearchComponent implements OnInit {
public searchInputKeyDown(event: KeyboardEvent) { public searchInputKeyDown(event: KeyboardEvent) {
if ( if (
event.key === 'ArrowDown' && event.key === 'ArrowDown' &&
this.searchResults()?.total && this.searchResults?.total &&
this.resultsDropdown.isOpen() this.resultsDropdown.isOpen()
) { ) {
event.preventDefault() event.preventDefault()
@@ -302,22 +300,22 @@ export class GlobalSearchComponent implements OnInit {
this.setCurrentItem() this.setCurrentItem()
} else if ( } else if (
event.key === 'ArrowUp' && event.key === 'ArrowUp' &&
this.searchResults()?.total && this.searchResults?.total &&
this.resultsDropdown.isOpen() this.resultsDropdown.isOpen()
) { ) {
event.preventDefault() event.preventDefault()
this.currentItemIndex = this.searchResults()?.total - 1 this.currentItemIndex = this.searchResults.total - 1
this.setCurrentItem() this.setCurrentItem()
} else if (event.key === 'Enter') { } 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.primaryButtons.first.nativeElement.click()
this.searchInput.nativeElement.blur() this.searchInput.nativeElement.blur()
} else if (this.query()?.length) { } else if (this.query?.length) {
this.runFullSearch() this.runFullSearch()
this.reset(true) this.reset(true)
} }
} else if (event.key === 'Escape' && !this.resultsDropdown.isOpen()) { } else if (event.key === 'Escape' && !this.resultsDropdown.isOpen()) {
if (this.query()?.length) { if (this.query?.length) {
this.reset(true) this.reset(true)
} else { } else {
this.searchInput.nativeElement.blur() this.searchInput.nativeElement.blur()
@@ -327,14 +325,14 @@ export class GlobalSearchComponent implements OnInit {
public dropdownKeyDown(event: KeyboardEvent) { public dropdownKeyDown(event: KeyboardEvent) {
if ( if (
this.searchResults()?.total && this.searchResults?.total &&
this.resultsDropdown.isOpen() && this.resultsDropdown.isOpen() &&
document.activeElement !== this.searchInput.nativeElement document.activeElement !== this.searchInput.nativeElement
) { ) {
if (event.key === 'ArrowDown') { if (event.key === 'ArrowDown') {
event.preventDefault() event.preventDefault()
event.stopImmediatePropagation() event.stopImmediatePropagation()
if (this.currentItemIndex < this.searchResults()?.total - 1) { if (this.currentItemIndex < this.searchResults.total - 1) {
this.currentItemIndex++ this.currentItemIndex++
this.setCurrentItem() this.setCurrentItem()
} else { } else {
@@ -414,10 +412,10 @@ export class GlobalSearchComponent implements OnInit {
? FILTER_FULLTEXT_QUERY ? FILTER_FULLTEXT_QUERY
: FILTER_SIMPLE_TEXT : FILTER_SIMPLE_TEXT
this.documentService.searchQuery = this.useAdvancedForFullSearch this.documentService.searchQuery = this.useAdvancedForFullSearch
? this.query() ? this.query
: '' : ''
this.documentListViewService.quickFilter([ this.documentListViewService.quickFilter([
{ rule_type: ruleType, value: this.query() }, { rule_type: ruleType, value: this.query },
]) ])
this.reset(true) this.reset(true)
} }
@@ -1,7 +1,7 @@
<li ngbDropdown class="nav-item mx-1" (openChange)="onOpenChange($event)"> <li ngbDropdown class="nav-item mx-1" (openChange)="onOpenChange($event)">
@if (toasts().length) { @if (toasts.length) {
<span class="badge rounded-pill z-3 pe-none bg-secondary me-2 position-absolute top-0 left-0">{{ toasts().length }}</span> <span class="badge rounded-pill z-3 pe-none bg-secondary me-2 position-absolute top-0 left-0">{{ toasts.length }}</span>
} }
<button class="btn border-0" id="notificationsDropdown" ngbDropdownToggle> <button class="btn border-0" id="notificationsDropdown" ngbDropdownToggle>
<i-bs width="1.3em" height="1.3em" name="bell"></i-bs> <i-bs width="1.3em" height="1.3em" name="bell"></i-bs>
@@ -12,15 +12,15 @@
<div class="btn-group ms-auto"> <div class="btn-group ms-auto">
<button class="btn btn-sm btn-outline-secondary mb-2 ms-auto" <button class="btn btn-sm btn-outline-secondary mb-2 ms-auto"
(click)="toastService.clearToasts()" (click)="toastService.clearToasts()"
[disabled]="toasts().length === 0" [disabled]="toasts.length === 0"
i18n>Clear All</button> i18n>Clear All</button>
</div> </div>
</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> <p class="text-center mb-0 small text-muted"><em i18n>No notifications</em></p>
} }
<div class="scroll-list"> <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> <pngx-toast [autohide]="false" [toast]="toast" (hidden)="onHidden(toast)" (closed)="toastService.closeToast(toast)"></pngx-toast>
} }
</div> </div>
@@ -1,6 +1,12 @@
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http' import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
import { provideHttpClientTesting } from '@angular/common/http/testing' 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 { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
import { Subject } from 'rxjs' import { Subject } from 'rxjs'
import { Toast, ToastService } from 'src/app/services/toast.service' import { Toast, ToastService } from 'src/app/services/toast.service'
@@ -36,8 +42,7 @@ describe('ToastsDropdownComponent', () => {
let component: ToastsDropdownComponent let component: ToastsDropdownComponent
let fixture: ComponentFixture<ToastsDropdownComponent> let fixture: ComponentFixture<ToastsDropdownComponent>
let toastService: ToastService let toastService: ToastService
let toastsSubject: Subject<Toast[]> let toastsSubject: Subject<Toast[]> = new Subject()
let getToastsSpy: jest.SpyInstance
beforeEach(async () => { beforeEach(async () => {
TestBed.configureTestingModule({ TestBed.configureTestingModule({
@@ -51,43 +56,57 @@ describe('ToastsDropdownComponent', () => {
], ],
}).compileComponents() }).compileComponents()
toastService = TestBed.inject(ToastService)
toastsSubject = new Subject()
getToastsSpy = jest
.spyOn(toastService, 'getToasts')
.mockReturnValue(toastsSubject)
fixture = TestBed.createComponent(ToastsDropdownComponent) fixture = TestBed.createComponent(ToastsDropdownComponent)
toastService = TestBed.inject(ToastService)
jest.spyOn(toastService, 'getToasts').mockReturnValue(toastsSubject)
component = fixture.componentInstance component = fixture.componentInstance
fixture.detectChanges() 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) toastsSubject.next(toasts)
fixture.detectChanges() fixture.detectChanges()
expect(getToastsSpy).toHaveBeenCalled() expect(spy).toHaveBeenCalled()
expect(component.toasts()).toContainEqual({ expect(component.toasts).toContainEqual({
id: 'abc-123', id: 'abc-123',
content: 'foo bar', content: 'foo bar',
delay: 5000, delay: 5000,
}) })
})
it('should show a toast', () => { component.ngOnDestroy()
flush()
discardPeriodicTasks()
}))
it('should show a toast', fakeAsync(() => {
component.ngOnInit()
toastsSubject.next(toasts) toastsSubject.next(toasts)
fixture.detectChanges() fixture.detectChanges()
expect(fixture.nativeElement.textContent).toContain('foo bar') 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() fixture.detectChanges()
toastsSubject.next(toasts) toastsSubject.next(toasts)
const spy = jest.spyOn(toastService, 'suppressPopupToasts', 'set') const spy = jest.spyOn(toastService, 'suppressPopupToasts', 'set')
component.onOpenChange(true) component.onOpenChange(true)
expect(spy).toHaveBeenCalledWith(true) expect(spy).toHaveBeenCalledWith(true)
})
component.ngOnDestroy()
flush()
discardPeriodicTasks()
}))
}) })
@@ -1,11 +1,10 @@
import { Component, inject } from '@angular/core' import { Component, OnDestroy, OnInit, inject } from '@angular/core'
import { toSignal } from '@angular/core/rxjs-interop'
import { import {
NgbDropdownModule, NgbDropdownModule,
NgbProgressbarModule, NgbProgressbarModule,
} from '@ng-bootstrap/ng-bootstrap' } from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons' import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { map } from 'rxjs' import { Subscription } from 'rxjs'
import { Toast, ToastService } from 'src/app/services/toast.service' import { Toast, ToastService } from 'src/app/services/toast.service'
import { ToastComponent } from '../../common/toast/toast.component' import { ToastComponent } from '../../common/toast/toast.component'
@@ -20,13 +19,22 @@ import { ToastComponent } from '../../common/toast/toast.component'
NgxBootstrapIconsModule, NgxBootstrapIconsModule,
], ],
}) })
export class ToastsDropdownComponent { export class ToastsDropdownComponent implements OnInit, OnDestroy {
toastService = inject(ToastService) toastService = inject(ToastService)
readonly toasts = toSignal( private subscription: Subscription
this.toastService.getToasts().pipe(map((toasts) => [...toasts])),
{ initialValue: [] as Toast[] } public toasts: Toast[] = []
)
ngOnDestroy(): void {
this.subscription?.unsubscribe()
}
ngOnInit(): void {
this.subscription = this.toastService.getToasts().subscribe((toasts) => {
this.toasts = [...toasts]
})
}
onOpenChange(open: boolean): void { onOpenChange(open: boolean): void {
this.toastService.suppressPopupToasts = open this.toastService.suppressPopupToasts = open
@@ -6,17 +6,17 @@
<div ngbDropdownMenu class="dropdown-menu-end shadow p-3" aria-labelledby="chatDropdown"> <div ngbDropdownMenu class="dropdown-menu-end shadow p-3" aria-labelledby="chatDropdown">
<div class="chat-container bg-light p-2"> <div class="chat-container bg-light p-2">
<div class="chat-messages font-monospace small"> <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="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'"> <div class="p-2 m-2" [class.bg-body]="message.role === 'user'">
<span class="text-break"> <span>
{{ message.content }} {{ message.content }}
@if (message.isStreaming) { <span class="blinking-cursor">|</span> } @if (message.isStreaming) { <span class="blinking-cursor">|</span> }
</span> </span>
@if (message.role === 'assistant' && message.references?.length) { @if (message.role === 'assistant' && message.references?.length) {
<div class="chat-references list-group mt-3"> <div class="chat-references list-group mt-3">
@for (reference of message.references; track reference.id) { @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> <i-bs width="0.9em" height="0.9em" name="file-text" class="me-1"></i-bs><span>{{ reference.title }}</span>
</a> </a>
} }
@@ -29,19 +29,16 @@
</div> </div>
<form class="chat-input"> <form class="chat-input">
<label class="visually-hidden" for="chatInput" i18n>Message</label>
<div class="input-group"> <div class="input-group">
<input <input
#chatInput #chatInput
id="chatInput"
class="form-control form-control-sm" name="chatInput" type="text" class="form-control form-control-sm" name="chatInput" type="text"
[placeholder]="placeholder" [placeholder]="placeholder"
[disabled]="loading()" [disabled]="loading"
[ngModel]="input()" [(ngModel)]="input"
(ngModelChange)="input.set($event)"
(keydown)="searchInputKeyDown($event)" (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> </div>
</form> </form>
</div> </div>
@@ -5,7 +5,6 @@
.chat-messages { .chat-messages {
max-height: 350px; max-height: 350px;
overflow-y: auto; overflow-y: auto;
white-space: pre-wrap;
} }
.chat-references { .chat-references {
@@ -56,53 +56,44 @@ describe('ChatComponent', () => {
it('should update documentId on initialization', () => { it('should update documentId on initialization', () => {
jest.spyOn(router, 'url', 'get').mockReturnValue('/documents/123') jest.spyOn(router, 'url', 'get').mockReturnValue('/documents/123')
component.ngOnInit() component.ngOnInit()
expect(component.documentId()).toBe(123) expect(component.documentId).toBe(123)
}) })
it('should update documentId on navigation', () => { it('should update documentId on navigation', () => {
component.ngOnInit() component.ngOnInit()
routerEvents$.next(new NavigationEnd(1, '/documents/456', '/documents/456')) 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', () => { 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...') 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...') expect(component.placeholder).toBe('Ask a question about a document...')
}) })
it('should send a message and render the streaming response', async () => { it('should send a message and handle streaming response', () => {
component.input.set('Hello') component.input = 'Hello'
component.sendMessage() component.sendMessage()
expect(component.messages()).toHaveLength(2) expect(component.messages.length).toBe(2)
expect(component.messages()[0].content).toBe('Hello') expect(component.messages[0].content).toBe('Hello')
expect(component.loading()).toBe(true) expect(component.loading).toBe(true)
mockStream$.next('Hi') mockStream$.next('Hi')
expect(component.messages()[1].content).toBe('H') expect(component.messages[1].content).toBe('H')
mockStream$.next('Hi there') mockStream$.next('Hi there')
// advance time to process the typewriter effect // advance time to process the typewriter effect
await jest.runAllTimersAsync() jest.advanceTimersByTime(1000)
await fixture.whenStable() expect(component.messages[1].content).toBe('Hi there')
expect(component.messages()[1].content).toBe('Hi there')
expect(
fixture.nativeElement.querySelector('.chat-messages').textContent
).toContain('Hi there')
mockStream$.complete() mockStream$.complete()
await jest.runAllTimersAsync() expect(component.loading).toBe(false)
await fixture.whenStable() expect(component.messages[1].isStreaming).toBe(false)
expect(component.loading()).toBe(false)
expect(component.messages()[1].isStreaming).toBe(false)
expect(fixture.nativeElement.querySelector('#chatInput').disabled).toBe(
false
)
}) })
it('should parse references from the metadata trailer without showing it', () => { it('should parse references from the metadata trailer without showing it', () => {
component.input.set('Hello') component.input = 'Hello'
component.sendMessage() component.sendMessage()
mockStream$.next( mockStream$.next(
@@ -110,14 +101,14 @@ describe('ChatComponent', () => {
) )
jest.advanceTimersByTime(1000) jest.advanceTimersByTime(1000)
expect(component.messages()[1].content).toBe('Hi there') expect(component.messages[1].content).toBe('Hi there')
expect(component.messages()[1].references).toEqual([ expect(component.messages[1].references).toEqual([
{ id: 42, title: 'Bread Recipe' }, { id: 42, title: 'Bread Recipe' },
]) ])
}) })
it('should render document reference links under assistant messages', () => { it('should render document reference links under assistant messages', () => {
component.input.set('Hello') component.input = 'Hello'
component.sendMessage() component.sendMessage()
mockStream$.next( mockStream$.next(
@@ -132,12 +123,12 @@ describe('ChatComponent', () => {
}) })
it('should remove delimiter fragments that were already streamed', () => { it('should remove delimiter fragments that were already streamed', () => {
component.input.set('Hello') component.input = 'Hello'
component.sendMessage() component.sendMessage()
mockStream$.next(`Hi there${CHAT_METADATA_DELIMITER.slice(0, 8)}`) mockStream$.next(`Hi there${CHAT_METADATA_DELIMITER.slice(0, 8)}`)
jest.advanceTimersByTime(1000) jest.advanceTimersByTime(1000)
expect(component.messages()[1].content).toBe( expect(component.messages[1].content).toBe(
`Hi there${CHAT_METADATA_DELIMITER.slice(0, 8)}` `Hi there${CHAT_METADATA_DELIMITER.slice(0, 8)}`
) )
@@ -146,28 +137,28 @@ describe('ChatComponent', () => {
) )
jest.advanceTimersByTime(1000) jest.advanceTimersByTime(1000)
expect(component.messages()[1].content).toBe('Hi there') expect(component.messages[1].content).toBe('Hi there')
expect(component.messages()[1].references).toEqual([ expect(component.messages[1].references).toEqual([
{ id: 42, title: 'Bread Recipe' }, { id: 42, title: 'Bread Recipe' },
]) ])
}) })
it('should handle errors during streaming', () => { it('should handle errors during streaming', () => {
component.input.set('Hello') component.input = 'Hello'
component.sendMessage() component.sendMessage()
mockStream$.error('Error') mockStream$.error('Error')
expect(component.messages()[1].content).toContain( expect(component.messages[1].content).toContain(
'⚠️ Error receiving response.' '⚠️ Error receiving response.'
) )
expect(component.loading()).toBe(false) expect(component.loading).toBe(false)
}) })
it('should enqueue typewriter chunks correctly', () => { it('should enqueue typewriter chunks correctly', () => {
const message = { content: '', role: 'assistant', isStreaming: true } const message = { content: '', role: 'assistant', isStreaming: true }
component.enqueueTypewriter(null, message as any) // coverage for null component.enqueueTypewriter(null, message as any) // coverage for null
component.enqueueTypewriter('Hello', message as any) 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', () => { it('should scroll to bottom after sending a message', () => {
@@ -175,7 +166,7 @@ describe('ChatComponent', () => {
ChatComponent.prototype as any, ChatComponent.prototype as any,
'scrollToBottom' 'scrollToBottom'
) )
component.input.set('Test') component.input = 'Test'
component.sendMessage() component.sendMessage()
expect(scrollSpy).toHaveBeenCalled() expect(scrollSpy).toHaveBeenCalled()
}) })
@@ -1,11 +1,4 @@
import { import { Component, ElementRef, inject, OnInit, ViewChild } from '@angular/core'
Component,
ElementRef,
inject,
OnInit,
signal,
ViewChild,
} from '@angular/core'
import { FormsModule, ReactiveFormsModule } from '@angular/forms' import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { NavigationEnd, Router, RouterModule } from '@angular/router' import { NavigationEnd, Router, RouterModule } from '@angular/router'
import { NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap' import { NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap'
@@ -30,10 +23,10 @@ import {
styleUrl: './chat.component.scss', styleUrl: './chat.component.scss',
}) })
export class ChatComponent implements OnInit { export class ChatComponent implements OnInit {
readonly messages = signal<ChatMessage[]>([]) public messages: ChatMessage[] = []
readonly loading = signal(false) public loading = false
readonly input = signal('') public input: string = ''
readonly documentId = signal<number>(undefined) public documentId!: number
private chatService: ChatService = inject(ChatService) private chatService: ChatService = inject(ChatService)
private router: Router = inject(Router) private router: Router = inject(Router)
@@ -45,7 +38,7 @@ export class ChatComponent implements OnInit {
private typewriterActive = false private typewriterActive = false
public get placeholder(): string { public get placeholder(): string {
return this.documentId() return this.documentId
? $localize`Ask a question about this document...` ? $localize`Ask a question about this document...`
: $localize`Ask a question about a document...` : $localize`Ask a question about a document...`
} }
@@ -64,14 +57,14 @@ export class ChatComponent implements OnInit {
private updateDocumentId(url: string): void { private updateDocumentId(url: string): void {
const docIdRe = url.match(/^\/documents\/(\d+)/) const docIdRe = url.match(/^\/documents\/(\d+)/)
this.documentId.set(docIdRe ? +docIdRe[1] : undefined) this.documentId = docIdRe ? +docIdRe[1] : undefined
} }
sendMessage(): void { sendMessage(): void {
if (!this.input().trim()) return if (!this.input.trim()) return
const userMessage: ChatMessage = { role: 'user', content: this.input() } const userMessage: ChatMessage = { role: 'user', content: this.input }
this.messages.update((messages) => [...messages, userMessage]) this.messages.push(userMessage)
this.scrollToBottom() this.scrollToBottom()
const assistantMessage: ChatMessage = { const assistantMessage: ChatMessage = {
@@ -79,12 +72,12 @@ export class ChatComponent implements OnInit {
content: '', content: '',
isStreaming: true, isStreaming: true,
} }
this.messages.update((messages) => [...messages, assistantMessage]) this.messages.push(assistantMessage)
this.loading.set(true) this.loading = true
let lastVisibleContent = '' let lastVisibleContent = ''
this.chatService.streamChat(this.documentId(), this.input()).subscribe({ this.chatService.streamChat(this.documentId, this.input).subscribe({
next: (chunk) => { next: (chunk) => {
const nextResponse = parseChatResponse(chunk) const nextResponse = parseChatResponse(chunk)
@@ -100,30 +93,26 @@ export class ChatComponent implements OnInit {
} }
assistantMessage.references = nextResponse.references assistantMessage.references = nextResponse.references
this.notifyMessagesChanged()
}, },
error: () => { error: () => {
assistantMessage.content += '\n\n⚠️ Error receiving response.' assistantMessage.content += '\n\n⚠️ Error receiving response.'
assistantMessage.isStreaming = false assistantMessage.isStreaming = false
this.notifyMessagesChanged() this.loading = false
this.loading.set(false)
}, },
complete: () => { complete: () => {
assistantMessage.isStreaming = false assistantMessage.isStreaming = false
this.notifyMessagesChanged() this.loading = false
this.loading.set(false)
this.scrollToBottom() this.scrollToBottom()
}, },
}) })
this.input.set('') this.input = ''
} }
private resetTypewriter(message: ChatMessage, content: string): void { private resetTypewriter(message: ChatMessage, content: string): void {
this.typewriterBuffer = [] this.typewriterBuffer = []
this.typewriterActive = false this.typewriterActive = false
message.content = content message.content = content
this.notifyMessagesChanged()
this.scrollToBottom() this.scrollToBottom()
} }
@@ -146,16 +135,11 @@ export class ChatComponent implements OnInit {
const nextChar = this.typewriterBuffer.shift() const nextChar = this.typewriterBuffer.shift()
message.content += nextChar message.content += nextChar
this.notifyMessagesChanged()
this.scrollToBottom() this.scrollToBottom()
setTimeout(() => this.playTypewriter(message), 10) // 10ms per character setTimeout(() => this.playTypewriter(message), 10) // 10ms per character
} }
private notifyMessagesChanged(): void {
this.messages.update((messages) => [...messages])
}
private scrollToBottom(): void { private scrollToBottom(): void {
setTimeout(() => { setTimeout(() => {
this.scrollAnchor?.nativeElement?.scrollIntoView({ behavior: 'smooth' }) 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)"> <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> <i-bs class="check" width="1em" height="1em" name="check-lg"></i-bs>
} }
@if (isNumbered()) { @if (isNumbered) {
<div class="number">{{number()}}<span class="visually-hidden">selected</span></div> <div class="number">{{number}}<span class="visually-hidden">selected</span></div>
} }
<i-bs class="x" width=".9em" height="1em" name="x-lg"></i-bs> <i-bs class="x" width=".9em" height="1em" name="x-lg"></i-bs>
</button> </button>
@@ -21,20 +21,20 @@ describe('ClearableBadgeComponent', () => {
}) })
it('should support selected', () => { it('should support selected', () => {
fixture.componentRef.setInput('selected', true) component.selected = true
expect(component.active()).toBeTruthy() expect(component.active).toBeTruthy()
}) })
it('should support numbered', () => { it('should support numbered', () => {
fixture.componentRef.setInput('number', 3) component.number = 3
fixture.detectChanges() fixture.detectChanges()
expect(component.active()).toBeTruthy() expect(component.active).toBeTruthy()
expect((fixture.nativeElement as HTMLDivElement).textContent).toContain('3') expect((fixture.nativeElement as HTMLDivElement).textContent).toContain('3')
}) })
it('should support selected', () => { it('should support selected', () => {
let clearedResult let clearedResult
fixture.componentRef.setInput('selected', true) component.selected = true
fixture.detectChanges() fixture.detectChanges()
component.cleared.subscribe((clear) => { component.cleared.subscribe((clear) => {
clearedResult = 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' import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
@Component({ @Component({
@@ -8,15 +8,24 @@ import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
imports: [NgxBootstrapIconsModule], imports: [NgxBootstrapIconsModule],
}) })
export class ClearableBadgeComponent { export class ClearableBadgeComponent {
readonly number = input<number>(undefined) constructor() {}
readonly selected = input<boolean>(undefined)
@Input()
number: number
@Input()
selected: boolean
@Output() @Output()
cleared: EventEmitter<boolean> = new EventEmitter() 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) { onClick(event: PointerEvent) {
this.cleared.emit(true) this.cleared.emit(true)
@@ -17,6 +17,6 @@
<ng-template #popoverContent> <ng-template #popoverContent>
<div> <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> </div>
</ng-template> </ng-template>
@@ -23,10 +23,10 @@ export class ConfirmDialogComponent extends LoadingComponentWithPermissions {
title = $localize`Confirmation` title = $localize`Confirmation`
@Input() @Input()
messageBold: string messageBold
@Input() @Input()
message: string message
@Input() @Input()
btnClass = 'btn-primary' btnClass = 'btn-primary'
@@ -38,7 +38,7 @@ export class ConfirmDialogComponent extends LoadingComponentWithPermissions {
alternativeBtnClass = 'btn-secondary' alternativeBtnClass = 'btn-secondary'
@Input() @Input()
alternativeBtnCaption: string alternativeBtnCaption
@Input() @Input()
cancelBtnClass = 'btn-outline-secondary' cancelBtnClass = 'btn-outline-secondary'
@@ -9,9 +9,9 @@
<label class="form-label" for="metadataDocumentID" i18n>Documents:</label> <label class="form-label" for="metadataDocumentID" i18n>Documents:</label>
<ul class="list-group" <ul class="list-group"
cdkDropList cdkDropList
[cdkDropListData]="documentIDs()" [cdkDropListData]="documentIDs"
(cdkDropListDropped)="onDrop($event)"> (cdkDropListDropped)="onDrop($event)">
@for (documentID of documentIDs(); track documentID) { @for (documentID of documentIDs; track documentID) {
@let document = getDocument(documentID); @let document = getDocument(documentID);
@if (document) { @if (document) {
<li class="list-group-item d-flex align-items-center" cdkDrag> <li class="list-group-item d-flex align-items-center" cdkDrag>
@@ -36,22 +36,22 @@
</div> </div>
<div class="form-group mt-4"> <div class="form-group mt-4">
<label class="form-label" for="metadataDocumentID" i18n>Use metadata from:</label> <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> <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> <option [ngValue]="document.id">{{document.title}}</option>
} }
</select> </select>
</div> </div>
<div class="form-check form-switch mt-4"> <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> <label class="form-check-label" for="archiveFallbackSwitch" i18n>Try to include archive version in merge for non-PDF files</label>
</div> </div>
<div class="form-check form-switch mt-2"> <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> <label class="form-check-label" for="deleteOriginalsSwitch" i18n>Delete original documents after successful merge</label>
</div> </div>
@if (!archiveFallback()) { @if (!archiveFallback) {
<p class="small text-muted fst-italic mt-4" i18n>Note that only PDFs will be included.</p> <p class="small text-muted fst-italic mt-4" i18n>Note that only PDFs will be included.</p>
} }
</div> </div>
@@ -50,12 +50,12 @@ describe('MergeConfirmDialogComponent', () => {
component.ngOnInit() component.ngOnInit()
expect(component.documents()).toEqual(documents) expect(component.documents).toEqual(documents)
expect(documentService.getFew).toHaveBeenCalledWith(component.documentIDs()) expect(documentService.getFew).toHaveBeenCalledWith(component.documentIDs)
}) })
it('should move documentIDs on drop', () => { it('should move documentIDs on drop', () => {
component.documentIDs.set([1, 2, 3]) component.documentIDs = [1, 2, 3]
const event = { const event = {
previousIndex: 1, previousIndex: 1,
currentIndex: 2, currentIndex: 2,
@@ -63,7 +63,7 @@ describe('MergeConfirmDialogComponent', () => {
component.onDrop(event as any) 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', () => { it('should get document by ID', () => {
@@ -4,7 +4,7 @@ import {
moveItemInArray, moveItemInArray,
} from '@angular/cdk/drag-drop' } from '@angular/cdk/drag-drop'
import { AsyncPipe } from '@angular/common' 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 { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons' import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { takeUntil } from 'rxjs' import { takeUntil } from 'rxjs'
@@ -36,11 +36,15 @@ export class MergeConfirmDialogComponent
private documentService = inject(DocumentService) private documentService = inject(DocumentService)
private permissionService = inject(PermissionsService) private permissionService = inject(PermissionsService)
readonly documentIDs = signal<number[]>([]) public documentIDs: number[] = []
readonly archiveFallback = signal(false) public archiveFallback: boolean = false
readonly deleteOriginals = signal(false) public deleteOriginals: boolean = false
readonly documents = signal<Document[]>([]) private _documents: Document[] = []
readonly metadataDocumentID = signal(-1) get documents(): Document[] {
return this._documents
}
public metadataDocumentID: number = -1
constructor() { constructor() {
super() super()
@@ -48,25 +52,23 @@ export class MergeConfirmDialogComponent
ngOnInit() { ngOnInit() {
this.documentService this.documentService
.getFew(this.documentIDs()) .getFew(this.documentIDs)
.pipe(takeUntil(this.unsubscribeNotifier)) .pipe(takeUntil(this.unsubscribeNotifier))
.subscribe((r) => { .subscribe((r) => {
this.documents.set(r.results) this._documents = r.results
}) })
} }
onDrop(event: CdkDragDrop<number[]>) { onDrop(event: CdkDragDrop<number[]>) {
const documentIDs = this.documentIDs().concat() moveItemInArray(this.documentIDs, event.previousIndex, event.currentIndex)
moveItemInArray(documentIDs, event.previousIndex, event.currentIndex)
this.documentIDs.set(documentIDs)
} }
getDocument(documentID: number): Document { getDocument(documentID: number): Document {
return this.documents().find((d) => d.id === documentID) return this.documents.find((d) => d.id === documentID)
} }
get userOwnsAllDocuments(): boolean { get userOwnsAllDocuments(): boolean {
return this.documents().every((d) => return this.documents.every((d) =>
this.permissionService.currentUserOwnsObject(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 { FormsModule } from '@angular/forms'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons' import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { ConfirmDialogComponent } from '../confirm-dialog.component' import { ConfirmDialogComponent } from '../confirm-dialog.component'
@@ -14,11 +14,18 @@ export class PasswordRemovalConfirmDialogComponent extends ConfirmDialogComponen
includeMetadata: boolean = true includeMetadata: boolean = true
deleteOriginal: boolean = false 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() { constructor() {
super() 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) { onUpdateDocumentChange(updateDocument: boolean) {
@@ -11,8 +11,8 @@
</button> </button>
</div> </div>
<div class="col-8 d-flex align-items-center"> <div class="col-8 d-flex align-items-center">
@if (documentID()) { @if (documentID) {
<img class="w-75 m-auto" [ngStyle]="{'transform': 'rotate('+rotation()+'deg)'}" [src]="documentService.getThumbUrl(documentID())" /> <img class="w-75 m-auto" [ngStyle]="{'transform': 'rotate('+rotation+'deg)'}" [src]="documentService.getThumbUrl(documentID)" />
} }
</div> </div>
<div class="col-2 d-flex"> <div class="col-2 d-flex">
@@ -21,7 +21,7 @@
</button> </button>
</div> </div>
</div> </div>
@if (showPDFNote()) { @if (showPDFNote) {
<p class="small text-muted fst-italic mt-4" i18n>Note that only PDFs will be rotated.</p> <p class="small text-muted fst-italic mt-4" i18n>Note that only PDFs will be rotated.</p>
} }
</div> </div>
@@ -28,7 +28,7 @@ describe('RotateConfirmDialogComponent', () => {
}) })
it('should support rotating the image', () => { it('should support rotating the image', () => {
component.documentID.set(1) component.documentID = 1
fixture.detectChanges() fixture.detectChanges()
component.rotate() component.rotate()
fixture.detectChanges() fixture.detectChanges()
@@ -1,5 +1,5 @@
import { NgStyle } from '@angular/common' 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 { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { DocumentService } from 'src/app/services/rest/document.service' import { DocumentService } from 'src/app/services/rest/document.service'
import { ConfirmDialogComponent } from '../confirm-dialog.component' import { ConfirmDialogComponent } from '../confirm-dialog.component'
@@ -13,12 +13,14 @@ import { ConfirmDialogComponent } from '../confirm-dialog.component'
export class RotateConfirmDialogComponent extends ConfirmDialogComponent { export class RotateConfirmDialogComponent extends ConfirmDialogComponent {
documentService = inject(DocumentService) documentService = inject(DocumentService)
readonly documentID = signal<number>(undefined) public documentID: number
readonly showPDFNote = signal(true) public showPDFNote: boolean = true
readonly rotation = signal(0)
// animation is better if we dont normalize yet
public rotation: number = 0
public get degrees(): number { public get degrees(): number {
let degrees = this.rotation() % 360 let degrees = this.rotation % 360
if (degrees < 0) degrees += 360 if (degrees < 0) degrees += 360
return degrees return degrees
} }
@@ -28,6 +30,6 @@ export class RotateConfirmDialogComponent extends ConfirmDialogComponent {
} }
rotate(clockwise: boolean = true) { 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 { CurrencyPipe, getLocaleCurrencyCode, SlicePipe } from '@angular/common'
import { import { Component, inject, Input, LOCALE_ID, OnInit } from '@angular/core'
ChangeDetectorRef,
Component,
inject,
Input,
LOCALE_ID,
OnInit,
} from '@angular/core'
import { NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap' import { NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap'
import { takeUntil } from 'rxjs' import { takeUntil } from 'rxjs'
import { CustomField, CustomFieldDataType } from 'src/app/data/custom-field' import { CustomField, CustomFieldDataType } from 'src/app/data/custom-field'
@@ -29,7 +22,6 @@ export class CustomFieldDisplayComponent
{ {
private customFieldService = inject(CustomFieldsService) private customFieldService = inject(CustomFieldsService)
private documentService = inject(DocumentService) private documentService = inject(DocumentService)
private changeDetector = inject(ChangeDetectorRef)
CustomFieldDataType = CustomFieldDataType CustomFieldDataType = CustomFieldDataType
@@ -82,7 +74,6 @@ export class CustomFieldDisplayComponent
this.customFieldService.listAll().subscribe((r) => { this.customFieldService.listAll().subscribe((r) => {
this.customFields = r.results this.customFields = r.results
this.init() this.init()
this.changeDetector.markForCheck()
}) })
} }
@@ -120,7 +111,6 @@ export class CustomFieldDisplayComponent
this.docLinkDocuments = this.value this.docLinkDocuments = this.value
.map((id) => result.results.find((d) => d.id === id)) .map((id) => result.results.find((d) => d.id === id))
.filter((d) => d) .filter((d) => d)
this.changeDetector.markForCheck()
}) })
} }
@@ -1,6 +1,11 @@
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http' import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
import { provideHttpClientTesting } from '@angular/common/http/testing' 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 { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { By } from '@angular/platform-browser' import { By } from '@angular/platform-browser'
import { import {
@@ -69,7 +74,7 @@ describe('CustomFieldsDropdownComponent', () => {
}) })
) )
settingsService = TestBed.inject(SettingsService) settingsService = TestBed.inject(SettingsService)
settingsService.currentUser.set({ id: 1, username: 'test' }) settingsService.currentUser = { id: 1, username: 'test' }
fixture = TestBed.createComponent(CustomFieldsDropdownComponent) fixture = TestBed.createComponent(CustomFieldsDropdownComponent)
component = fixture.componentInstance component = fixture.componentInstance
fixture.detectChanges() fixture.detectChanges()
@@ -95,8 +100,8 @@ describe('CustomFieldsDropdownComponent', () => {
it('should support update unused fields', () => { it('should support update unused fields', () => {
component.existingFields = [{ field: fields[0].id } as any] component.existingFields = [{ field: fields[0].id } as any]
component['updateUnusedFields']() component['updateUnusedFields']()
expect(component['unusedFields']().length).toEqual(1) expect(component['unusedFields'].length).toEqual(1)
expect(component['unusedFields']()[0].name).toEqual('Field 2') expect(component['unusedFields'][0].name).toEqual('Field 2')
}) })
it('should support getting data type label', () => { it('should support getting data type label', () => {
@@ -105,8 +110,7 @@ describe('CustomFieldsDropdownComponent', () => {
) )
}) })
it('should support creating field, show error if necessary, then add', () => { it('should support creating field, show error if necessary, then add', fakeAsync(() => {
jest.useFakeTimers()
let modal: NgbModalRef let modal: NgbModalRef
modalService.activeInstances.subscribe((m) => (modal = m[m.length - 1])) modalService.activeInstances.subscribe((m) => (modal = m[m.length - 1]))
const toastErrorSpy = jest.spyOn(toastService, 'showError') const toastErrorSpy = jest.spyOn(toastService, 'showError')
@@ -130,12 +134,11 @@ describe('CustomFieldsDropdownComponent', () => {
// succeed // succeed
editDialog.succeeded.emit(fields[0]) editDialog.succeeded.emit(fields[0])
jest.advanceTimersByTime(100) tick(100)
expect(toastInfoSpy).toHaveBeenCalled() expect(toastInfoSpy).toHaveBeenCalled()
expect(getFieldsSpy).toHaveBeenCalled() expect(getFieldsSpy).toHaveBeenCalled()
expect(addFieldSpy).toHaveBeenCalled() expect(addFieldSpy).toHaveBeenCalled()
jest.useRealTimers() }))
})
it('should support creating field with name', () => { it('should support creating field with name', () => {
let modal: NgbModalRef let modal: NgbModalRef
@@ -147,13 +150,12 @@ describe('CustomFieldsDropdownComponent', () => {
expect(editDialog.object.name).toEqual('Foo bar') expect(editDialog.object.name).toEqual('Foo bar')
}) })
it('should support arrow keyboard navigation', () => { it('should support arrow keyboard navigation', fakeAsync(() => {
jest.useFakeTimers()
fixture.nativeElement fixture.nativeElement
.querySelector('button') .querySelector('button')
.dispatchEvent(new MouseEvent('click')) // open .dispatchEvent(new MouseEvent('click')) // open
fixture.detectChanges() fixture.detectChanges()
jest.advanceTimersByTime(100) tick(100)
const filterInputEl: HTMLInputElement = const filterInputEl: HTMLInputElement =
component.listFilterTextInput.nativeElement component.listFilterTextInput.nativeElement
expect(document.activeElement).toEqual(filterInputEl) expect(document.activeElement).toEqual(filterInputEl)
@@ -191,16 +193,14 @@ describe('CustomFieldsDropdownComponent', () => {
new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }) new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })
) )
expect(document.activeElement).toEqual(itemButtons[0]) expect(document.activeElement).toEqual(itemButtons[0])
jest.useRealTimers() }))
})
it('should support arrow keyboard navigation after tab keyboard navigation', () => { it('should support arrow keyboard navigation after tab keyboard navigation', fakeAsync(() => {
jest.useFakeTimers()
fixture.nativeElement fixture.nativeElement
.querySelector('button') .querySelector('button')
.dispatchEvent(new MouseEvent('click')) // open .dispatchEvent(new MouseEvent('click')) // open
fixture.detectChanges() fixture.detectChanges()
jest.advanceTimersByTime(100) tick(100)
const filterInputEl: HTMLInputElement = const filterInputEl: HTMLInputElement =
component.listFilterTextInput.nativeElement component.listFilterTextInput.nativeElement
expect(document.activeElement).toEqual(filterInputEl) expect(document.activeElement).toEqual(filterInputEl)
@@ -229,10 +229,9 @@ describe('CustomFieldsDropdownComponent', () => {
new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }) new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })
) )
expect(document.activeElement).toEqual(itemButtons[1]) 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) jest.spyOn(component, 'canCreateFields', 'get').mockReturnValue(true)
const addFieldSpy = jest.spyOn(component, 'addField') const addFieldSpy = jest.spyOn(component, 'addField')
const createFieldSpy = jest.spyOn(component, 'createField') const createFieldSpy = jest.spyOn(component, 'createField')
@@ -251,5 +250,5 @@ describe('CustomFieldsDropdownComponent', () => {
component.listFilterEnter() component.listFilterEnter()
expect(createFieldSpy).not.toHaveBeenCalled() expect(createFieldSpy).not.toHaveBeenCalled()
expect(addFieldSpy).not.toHaveBeenCalled() expect(addFieldSpy).not.toHaveBeenCalled()
}) }))
}) })
@@ -8,7 +8,6 @@ import {
ViewChild, ViewChild,
ViewChildren, ViewChildren,
inject, inject,
signal,
} from '@angular/core' } from '@angular/core'
import { FormsModule, ReactiveFormsModule } from '@angular/forms' import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { NgbDropdownModule, NgbModal } from '@ng-bootstrap/ng-bootstrap' import { NgbDropdownModule, NgbModal } from '@ng-bootstrap/ng-bootstrap'
@@ -66,11 +65,11 @@ export class CustomFieldsDropdownComponent extends LoadingComponentWithPermissio
@ViewChildren('button') buttons: QueryList<ElementRef> @ViewChildren('button') buttons: QueryList<ElementRef>
private customFields: CustomField[] = [] private customFields: CustomField[] = []
private readonly unusedFields = signal<CustomField[]>([]) private unusedFields: CustomField[] = []
private keyboardIndex: number private keyboardIndex: number
public get filteredFields(): CustomField[] { public get filteredFields(): CustomField[] {
return this.unusedFields().filter( return this.unusedFields.filter(
(f) => !this.filterText || matchesSearchText(f.name, this.filterText) (f) => !this.filterText || matchesSearchText(f.name, this.filterText)
) )
} }
@@ -100,10 +99,8 @@ export class CustomFieldsDropdownComponent extends LoadingComponentWithPermissio
} }
private updateUnusedFields() { private updateUnusedFields() {
this.unusedFields.set( this.unusedFields = this.customFields.filter(
this.customFields.filter( (f) => !this.existingFields?.find((e) => e.field === f.id)
(f) => !this.existingFields?.find((e) => e.field === f.id)
)
) )
} }
@@ -75,9 +75,9 @@
<ng-template #queryAtom let-atom="atom"> <ng-template #queryAtom let-atom="atom">
<div class="input-group input-group-sm"> <div class="input-group input-group-sm">
<ng-select #fieldSelects <ng-select
class="paperless-input-select" class="paperless-input-select"
[items]="customFields()" [items]="customFields"
[(ngModel)]="atom.field" [(ngModel)]="atom.field"
[disabled]="disabled" [disabled]="disabled"
bindLabel="name" bindLabel="name"
@@ -1,6 +1,11 @@
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http' import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
import { provideHttpClientTesting } from '@angular/common/http/testing' 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 { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap' import { NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap'
import { NgSelectModule } from '@ng-select/ng-select' import { NgSelectModule } from '@ng-select/ng-select'
@@ -79,7 +84,7 @@ describe('CustomFieldsQueryDropdownComponent', () => {
}) })
it('should initialize custom fields on creation', () => { 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', () => { it('should add an expression when opened if queries are empty', () => {
@@ -101,7 +106,7 @@ describe('CustomFieldsQueryDropdownComponent', () => {
data_type: CustomFieldDataType.String, data_type: CustomFieldDataType.String,
extra_data: {}, extra_data: {},
} }
component.customFields.set([field]) component.customFields = [field]
const operators = component.getOperatorsForField(1) const operators = component.getOperatorsForField(1)
expect(operators.length).toEqual( expect(operators.length).toEqual(
[ [
@@ -138,7 +143,7 @@ describe('CustomFieldsQueryDropdownComponent', () => {
], ],
}, },
} }
component.customFields.set([field]) component.customFields = [field]
const options = component.getSelectOptionsForField(1) const options = component.getSelectOptionsForField(1)
expect(options).toEqual([ expect(options).toEqual([
{ label: 'Option 1', id: 'abc-123' }, { label: 'Option 1', id: 'abc-123' },
@@ -205,13 +210,14 @@ describe('CustomFieldsQueryDropdownComponent', () => {
expect(component.name).toBe('test_title') 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) expect(component.selectionModel.queries.length).toBe(0)
component.onOpenChange(true) component.onOpenChange(true)
fixture.detectChanges() fixture.detectChanges()
await fixture.whenStable() tick()
expect(component.selectionModel.queries.length).toBe(1) expect(component.selectionModel.queries.length).toBe(1)
}) expect(window.document.activeElement.tagName).toBe('INPUT')
}))
describe('CustomFieldQueriesModel', () => { describe('CustomFieldQueriesModel', () => {
let model: CustomFieldQueriesModel let model: CustomFieldQueriesModel
@@ -6,7 +6,6 @@ import {
Input, Input,
Output, Output,
QueryList, QueryList,
signal,
ViewChild, ViewChild,
ViewChildren, ViewChildren,
} from '@angular/core' } from '@angular/core'
@@ -279,7 +278,7 @@ export class CustomFieldsQueryDropdownComponent extends LoadingComponentWithPerm
@Output() @Output()
selectionModelChange = new EventEmitter<CustomFieldQueriesModel>() selectionModelChange = new EventEmitter<CustomFieldQueriesModel>()
readonly customFields = signal<CustomField[]>([]) customFields: CustomField[] = []
public readonly today: string = new Date().toLocaleDateString('en-CA') public readonly today: string = new Date().toLocaleDateString('en-CA')
@@ -326,12 +325,12 @@ export class CustomFieldsQueryDropdownComponent extends LoadingComponentWithPerm
.listAll() .listAll()
.pipe(first(), takeUntil(this.unsubscribeNotifier)) .pipe(first(), takeUntil(this.unsubscribeNotifier))
.subscribe((result) => { .subscribe((result) => {
this.customFields.set(result.results) this.customFields = result.results
}) })
} }
public getCustomFieldByID(id: number): CustomField { 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) { public addAtom(expression: CustomFieldQueryExpression) {
@@ -354,7 +353,7 @@ export class CustomFieldsQueryDropdownComponent extends LoadingComponentWithPerm
getOperatorsForField( getOperatorsForField(
fieldID: number fieldID: number
): Array<{ value: string; label: string }> { ): 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 const groups: CustomFieldQueryOperatorGroups[] = field
? CUSTOM_FIELD_QUERY_OPERATOR_GROUPS_BY_TYPE[field.data_type] ? CUSTOM_FIELD_QUERY_OPERATOR_GROUPS_BY_TYPE[field.data_type]
: [CustomFieldQueryOperatorGroups.Basic] : [CustomFieldQueryOperatorGroups.Basic]
@@ -370,7 +369,7 @@ export class CustomFieldsQueryDropdownComponent extends LoadingComponentWithPerm
getSelectOptionsForField( getSelectOptionsForField(
fieldID: number fieldID: number
): Array<{ label: string; id: string }> { ): 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) { if (field) {
return field.extra_data['select_options'] return field.extra_data['select_options']
} }
@@ -1,7 +1,12 @@
import { DatePipe } from '@angular/common' import { DatePipe } from '@angular/common'
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http' import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
import { provideHttpClientTesting } from '@angular/common/http/testing' 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 { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { NgbModule } from '@ng-bootstrap/ng-bootstrap' import { NgbModule } from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons' import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
@@ -54,32 +59,27 @@ describe('DatesDropdownComponent', () => {
expect(settingsSpy).toHaveBeenCalled() expect(settingsSpy).toHaveBeenCalled()
}) })
it('should support date input, emit change', () => { it('should support date input, emit change', fakeAsync(() => {
jest.useFakeTimers()
let result: string let result: string
component.createdDateFromChange.subscribe((date) => (result = date)) component.createdDateFromChange.subscribe((date) => (result = date))
const input: HTMLInputElement = fixture.nativeElement.querySelector('input') const input: HTMLInputElement = fixture.nativeElement.querySelector('input')
input.value = '5/30/2023' input.value = '5/30/2023'
input.dispatchEvent(new Event('change')) input.dispatchEvent(new Event('change'))
jest.advanceTimersByTime(500) tick(500)
expect(result).not.toBeNull() expect(result).not.toBeNull()
jest.useRealTimers() }))
})
it('should support date select, emit datesSet change', () => { it('should support date select, emit datesSet change', fakeAsync(() => {
jest.useFakeTimers()
let result: DateSelection let result: DateSelection
component.datesSet.subscribe((date) => (result = date)) component.datesSet.subscribe((date) => (result = date))
const input: HTMLInputElement = fixture.nativeElement.querySelector('input') const input: HTMLInputElement = fixture.nativeElement.querySelector('input')
input.value = '5/30/2023' input.value = '5/30/2023'
input.dispatchEvent(new Event('dateSelect')) input.dispatchEvent(new Event('dateSelect'))
jest.advanceTimersByTime(500) tick(500)
expect(result).not.toBeNull() expect(result).not.toBeNull()
jest.useRealTimers() }))
})
it('should support relative dates', () => { it('should support relative dates', fakeAsync(() => {
jest.useFakeTimers()
let result: DateSelection let result: DateSelection
component.datesSet.subscribe((date) => (result = date)) component.datesSet.subscribe((date) => (result = date))
component.createdRelativeDate = RelativeDate.WITHIN_1_WEEK // normally set by ngModel binding in dropdown component.createdRelativeDate = RelativeDate.WITHIN_1_WEEK // normally set by ngModel binding in dropdown
@@ -88,7 +88,7 @@ describe('DatesDropdownComponent', () => {
} as any) } as any)
component.addedRelativeDate = RelativeDate.WITHIN_1_WEEK // normally set by ngModel binding in dropdown component.addedRelativeDate = RelativeDate.WITHIN_1_WEEK // normally set by ngModel binding in dropdown
component.onSetAddedRelativeDate({ id: RelativeDate.WITHIN_1_WEEK } as any) component.onSetAddedRelativeDate({ id: RelativeDate.WITHIN_1_WEEK } as any)
jest.advanceTimersByTime(500) tick(500)
expect(result).toEqual({ expect(result).toEqual({
createdFrom: null, createdFrom: null,
createdTo: null, createdTo: null,
@@ -97,8 +97,7 @@ describe('DatesDropdownComponent', () => {
addedTo: null, addedTo: null,
addedRelativeDateID: RelativeDate.WITHIN_1_WEEK, addedRelativeDateID: RelativeDate.WITHIN_1_WEEK,
}) })
jest.useRealTimers() }))
})
it('should support report if active', () => { it('should support report if active', () => {
component.createdRelativeDate = RelativeDate.WITHIN_1_WEEK component.createdRelativeDate = RelativeDate.WITHIN_1_WEEK
@@ -178,12 +177,11 @@ describe('DatesDropdownComponent', () => {
expect(eventSpy).toHaveBeenCalled() expect(eventSpy).toHaveBeenCalled()
}) })
it('should support debounce', () => { it('should support debounce', fakeAsync(() => {
jest.useFakeTimers()
let result: DateSelection let result: DateSelection
component.datesSet.subscribe((date) => (result = date)) component.datesSet.subscribe((date) => (result = date))
component.onChangeDebounce() component.onChangeDebounce()
jest.advanceTimersByTime(500) tick(500)
expect(result).toEqual({ expect(result).toEqual({
createdFrom: null, createdFrom: null,
createdTo: null, createdTo: null,
@@ -192,6 +190,5 @@ describe('DatesDropdownComponent', () => {
addedTo: null, addedTo: null,
addedRelativeDateID: null, addedRelativeDateID: null,
}) })
jest.useRealTimers() }))
})
}) })
@@ -41,20 +41,20 @@ describe('CorrespondentEditDialogComponent', () => {
fixture = TestBed.createComponent(CorrespondentEditDialogComponent) fixture = TestBed.createComponent(CorrespondentEditDialogComponent)
settingsService = TestBed.inject(SettingsService) settingsService = TestBed.inject(SettingsService)
settingsService.currentUser.set({ id: 99, username: 'user99' }) settingsService.currentUser = { id: 99, username: 'user99' }
component = fixture.componentInstance component = fixture.componentInstance
fixture.detectChanges() fixture.detectChanges()
}) })
it('should support create and edit modes', () => { it('should support create and edit modes', () => {
component.dialogMode.set(EditDialogMode.CREATE) component.dialogMode = EditDialogMode.CREATE
const createTitleSpy = jest.spyOn(component, 'getCreateTitle') const createTitleSpy = jest.spyOn(component, 'getCreateTitle')
const editTitleSpy = jest.spyOn(component, 'getEditTitle') const editTitleSpy = jest.spyOn(component, 'getEditTitle')
fixture.detectChanges() fixture.detectChanges()
expect(createTitleSpy).toHaveBeenCalled() expect(createTitleSpy).toHaveBeenCalled()
expect(editTitleSpy).not.toHaveBeenCalled() expect(editTitleSpy).not.toHaveBeenCalled()
component.dialogMode.set(EditDialogMode.EDIT) component.dialogMode = EditDialogMode.EDIT
fixture.detectChanges() fixture.detectChanges()
expect(editTitleSpy).toHaveBeenCalled() expect(editTitleSpy).toHaveBeenCalled()
}) })
@@ -44,33 +44,33 @@ describe('CustomFieldEditDialogComponent', () => {
fixture = TestBed.createComponent(CustomFieldEditDialogComponent) fixture = TestBed.createComponent(CustomFieldEditDialogComponent)
settingsService = TestBed.inject(SettingsService) settingsService = TestBed.inject(SettingsService)
settingsService.currentUser.set({ id: 99, username: 'user99' }) settingsService.currentUser = { id: 99, username: 'user99' }
component = fixture.componentInstance component = fixture.componentInstance
fixture.detectChanges() fixture.detectChanges()
}) })
it('should support create and edit modes', () => { it('should support create and edit modes', () => {
component.dialogMode.set(EditDialogMode.CREATE) component.dialogMode = EditDialogMode.CREATE
const createTitleSpy = jest.spyOn(component, 'getCreateTitle') const createTitleSpy = jest.spyOn(component, 'getCreateTitle')
const editTitleSpy = jest.spyOn(component, 'getEditTitle') const editTitleSpy = jest.spyOn(component, 'getEditTitle')
fixture.detectChanges() fixture.detectChanges()
expect(createTitleSpy).toHaveBeenCalled() expect(createTitleSpy).toHaveBeenCalled()
expect(editTitleSpy).not.toHaveBeenCalled() expect(editTitleSpy).not.toHaveBeenCalled()
component.dialogMode.set(EditDialogMode.EDIT) component.dialogMode = EditDialogMode.EDIT
fixture.detectChanges() fixture.detectChanges()
expect(editTitleSpy).toHaveBeenCalled() expect(editTitleSpy).toHaveBeenCalled()
}) })
it('should disable data type select on edit', () => { it('should disable data type select on edit', () => {
component.dialogMode.set(EditDialogMode.EDIT) component.dialogMode = EditDialogMode.EDIT
fixture.detectChanges() fixture.detectChanges()
component.ngOnInit() component.ngOnInit()
expect(component.objectForm.get('data_type').disabled).toBeTruthy() expect(component.objectForm.get('data_type').disabled).toBeTruthy()
}) })
it('should initialize select options on edit', () => { it('should initialize select options on edit', () => {
component.dialogMode.set(EditDialogMode.EDIT) component.dialogMode = EditDialogMode.EDIT
component.object = { component.object = {
id: 1, id: 1,
name: 'Field 1', name: 'Field 1',
@@ -91,7 +91,7 @@ describe('CustomFieldEditDialogComponent', () => {
}) })
it('should support add / remove select options', () => { it('should support add / remove select options', () => {
component.dialogMode.set(EditDialogMode.CREATE) component.dialogMode = EditDialogMode.CREATE
fixture.detectChanges() fixture.detectChanges()
component.ngOnInit() component.ngOnInit()
expect( expect(
@@ -115,7 +115,7 @@ describe('CustomFieldEditDialogComponent', () => {
const selectOptionInputs = component[ const selectOptionInputs = component[
'selectOptionInputs' 'selectOptionInputs'
] as QueryList<ElementRef> ] as QueryList<ElementRef>
component.dialogMode.set(EditDialogMode.CREATE) component.dialogMode = EditDialogMode.CREATE
component.objectForm.get('data_type').setValue(CustomFieldDataType.Select) component.objectForm.get('data_type').setValue(CustomFieldDataType.Select)
component.ngOnInit() component.ngOnInit()
component.ngAfterViewInit() component.ngAfterViewInit()
@@ -125,7 +125,7 @@ describe('CustomFieldEditDialogComponent', () => {
}) })
it('should send all select options including those changed in form on save', () => { it('should send all select options including those changed in form on save', () => {
component.dialogMode.set(EditDialogMode.EDIT) component.dialogMode = EditDialogMode.EDIT
component.object = { component.object = {
id: 1, id: 1,
name: 'Field 1', name: 'Field 1',
@@ -149,7 +149,7 @@ export class CustomFieldEditDialogComponent
} }
get typeFieldDisabled(): boolean { get typeFieldDisabled(): boolean {
return this.dialogMode() === EditDialogMode.EDIT return this.dialogMode === EditDialogMode.EDIT
} }
private updateSelectOptions() { private updateSelectOptions() {
@@ -41,20 +41,20 @@ describe('DocumentTypeEditDialogComponent', () => {
fixture = TestBed.createComponent(DocumentTypeEditDialogComponent) fixture = TestBed.createComponent(DocumentTypeEditDialogComponent)
settingsService = TestBed.inject(SettingsService) settingsService = TestBed.inject(SettingsService)
settingsService.currentUser.set({ id: 99, username: 'user99' }) settingsService.currentUser = { id: 99, username: 'user99' }
component = fixture.componentInstance component = fixture.componentInstance
fixture.detectChanges() fixture.detectChanges()
}) })
it('should support create and edit modes', () => { it('should support create and edit modes', () => {
component.dialogMode.set(EditDialogMode.CREATE) component.dialogMode = EditDialogMode.CREATE
const createTitleSpy = jest.spyOn(component, 'getCreateTitle') const createTitleSpy = jest.spyOn(component, 'getCreateTitle')
const editTitleSpy = jest.spyOn(component, 'getEditTitle') const editTitleSpy = jest.spyOn(component, 'getEditTitle')
fixture.detectChanges() fixture.detectChanges()
expect(createTitleSpy).toHaveBeenCalled() expect(createTitleSpy).toHaveBeenCalled()
expect(editTitleSpy).not.toHaveBeenCalled() expect(editTitleSpy).not.toHaveBeenCalled()
component.dialogMode.set(EditDialogMode.EDIT) component.dialogMode = EditDialogMode.EDIT
fixture.detectChanges() fixture.detectChanges()
expect(editTitleSpy).toHaveBeenCalled() expect(editTitleSpy).toHaveBeenCalled()
}) })
@@ -4,7 +4,12 @@ import {
provideHttpClientTesting, provideHttpClientTesting,
} from '@angular/common/http/testing' } from '@angular/common/http/testing'
import { Component } from '@angular/core' import { Component } from '@angular/core'
import { ComponentFixture, TestBed } from '@angular/core/testing' import {
ComponentFixture,
TestBed,
fakeAsync,
tick,
} from '@angular/core/testing'
import { import {
FormControl, FormControl,
FormGroup, FormGroup,
@@ -117,7 +122,7 @@ describe('EditDialogComponent', () => {
tagService = TestBed.inject(TagService) tagService = TestBed.inject(TagService)
permissionsService = TestBed.inject(PermissionsService) permissionsService = TestBed.inject(PermissionsService)
settingsService = TestBed.inject(SettingsService) settingsService = TestBed.inject(SettingsService)
settingsService.currentUser.set(currentUser as any) settingsService.currentUser = currentUser
permissionsService.initialize([], currentUser as any) permissionsService.initialize([], currentUser as any)
activeModal = TestBed.inject(NgbActiveModal) activeModal = TestBed.inject(NgbActiveModal)
httpTestingController = TestBed.inject(HttpTestingController) httpTestingController = TestBed.inject(HttpTestingController)
@@ -131,7 +136,7 @@ describe('EditDialogComponent', () => {
it('should interpolate object permissions', () => { it('should interpolate object permissions', () => {
component.getMatchingAlgorithms() // coverage component.getMatchingAlgorithms() // coverage
component.object = tag component.object = tag
component.dialogMode.set(EditDialogMode.EDIT) component.dialogMode = EditDialogMode.EDIT
component.ngOnInit() component.ngOnInit()
expect(component.objectForm.get('permissions_form').value).toEqual({ expect(component.objectForm.get('permissions_form').value).toEqual({
@@ -140,17 +145,15 @@ describe('EditDialogComponent', () => {
}) })
}) })
it('should delay close enabled', () => { it('should delay close enabled', fakeAsync(() => {
jest.useFakeTimers()
expect(component.closeEnabled).toBeFalsy() expect(component.closeEnabled).toBeFalsy()
component.ngOnInit() component.ngOnInit()
jest.advanceTimersByTime(100) tick(100)
expect(component.closeEnabled).toBeTruthy() expect(component.closeEnabled).toBeTruthy()
jest.useRealTimers() }))
})
it('should set default owner when in create mode if unset', () => { it('should set default owner when in create mode if unset', () => {
component.dialogMode.set(EditDialogMode.CREATE) component.dialogMode = EditDialogMode.CREATE
component.ngOnInit() component.ngOnInit()
expect(component.objectForm.get('permissions_form').value.owner).toEqual( expect(component.objectForm.get('permissions_form').value.owner).toEqual(
currentUser.id currentUser.id
@@ -161,7 +164,7 @@ describe('EditDialogComponent', () => {
}) })
it('should set default perms when in create mode if set', () => { 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_OWNER, 11)
settingsService.set(SETTINGS_KEYS.DEFAULT_PERMS_VIEW_USERS, [1, 2]) settingsService.set(SETTINGS_KEYS.DEFAULT_PERMS_VIEW_USERS, [1, 2])
settingsService.set(SETTINGS_KEYS.DEFAULT_PERMS_VIEW_GROUPS, [3]) settingsService.set(SETTINGS_KEYS.DEFAULT_PERMS_VIEW_GROUPS, [3])
@@ -200,18 +203,18 @@ describe('EditDialogComponent', () => {
}) })
it('should support create and edit modes', () => { it('should support create and edit modes', () => {
component.dialogMode.set(EditDialogMode.CREATE) component.dialogMode = EditDialogMode.CREATE
const createTitleSpy = jest.spyOn(component, 'getCreateTitle') const createTitleSpy = jest.spyOn(component, 'getCreateTitle')
const editTitleSpy = jest.spyOn(component, 'getEditTitle') const editTitleSpy = jest.spyOn(component, 'getEditTitle')
component.getTitle() fixture.detectChanges()
expect(createTitleSpy).toHaveBeenCalled() expect(createTitleSpy).toHaveBeenCalled()
expect(editTitleSpy).not.toHaveBeenCalled() expect(editTitleSpy).not.toHaveBeenCalled()
component.dialogMode.set(EditDialogMode.EDIT) component.dialogMode = EditDialogMode.EDIT
component.getTitle() fixture.detectChanges()
expect(editTitleSpy).toHaveBeenCalled() expect(editTitleSpy).toHaveBeenCalled()
// coverage // coverage
component.dialogMode.set(null) component.dialogMode = null
component.getTitle() fixture.detectChanges()
}) })
it('should close on cancel', () => { it('should close on cancel', () => {
@@ -222,14 +225,14 @@ describe('EditDialogComponent', () => {
it('should update an object on save in edit mode', () => { it('should update an object on save in edit mode', () => {
const updateSpy = jest.spyOn(tagService, 'update') const updateSpy = jest.spyOn(tagService, 'update')
component.dialogMode.set(EditDialogMode.EDIT) component.dialogMode = EditDialogMode.EDIT
component.save() component.save()
expect(updateSpy).toHaveBeenCalled() expect(updateSpy).toHaveBeenCalled()
}) })
it('should not submit owner or permissions for non-owner edits', () => { it('should not submit owner or permissions for non-owner edits', () => {
component.object = tag component.object = tag
component.dialogMode.set(EditDialogMode.EDIT) component.dialogMode = EditDialogMode.EDIT
component.ngOnInit() component.ngOnInit()
component.objectForm.get('name').setValue('Updated tag') component.objectForm.get('name').setValue('Updated tag')
@@ -248,7 +251,7 @@ describe('EditDialogComponent', () => {
it('should create an object on save in edit mode', () => { it('should create an object on save in edit mode', () => {
const createSpy = jest.spyOn(tagService, 'create') const createSpy = jest.spyOn(tagService, 'create')
component.dialogMode.set(EditDialogMode.CREATE) component.dialogMode = EditDialogMode.CREATE
component.save() component.save()
expect(createSpy).toHaveBeenCalled() expect(createSpy).toHaveBeenCalled()
}) })
@@ -5,7 +5,6 @@ import {
OnInit, OnInit,
Output, Output,
inject, inject,
model,
} from '@angular/core' } from '@angular/core'
import { FormGroup } from '@angular/forms' import { FormGroup } from '@angular/forms'
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap' import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
@@ -46,7 +45,10 @@ export abstract class EditDialogComponent<
protected settingsService = inject(SettingsService) protected settingsService = inject(SettingsService)
protected permissionsService = inject(PermissionsService) protected permissionsService = inject(PermissionsService)
dialogMode = model(EditDialogMode.CREATE) users: User[]
@Input()
dialogMode: EditDialogMode = EditDialogMode.CREATE
@Input() @Input()
object: T object: T
@@ -57,20 +59,18 @@ export abstract class EditDialogComponent<
@Output() @Output()
failed = new EventEmitter() failed = new EventEmitter()
users: User[]
networkActive = false networkActive = false
closeEnabled = false closeEnabled = false
error: any = null error = null
abstract getForm(): FormGroup abstract getForm(): FormGroup
objectForm: FormGroup = this.getForm() objectForm: FormGroup = this.getForm()
ngOnInit(): void { ngOnInit(): void {
if (this.object != null && this.dialogMode() !== EditDialogMode.CREATE) { if (this.object != null && this.dialogMode !== EditDialogMode.CREATE) {
this.object['permissions_form'] = { this.object['permissions_form'] = {
owner: (this.object as ObjectWithPermissions).owner, owner: (this.object as ObjectWithPermissions).owner,
set_permissions: (this.object as ObjectWithPermissions).permissions, set_permissions: (this.object as ObjectWithPermissions).permissions,
@@ -124,7 +124,7 @@ export abstract class EditDialogComponent<
} }
getTitle() { getTitle() {
switch (this.dialogMode()) { switch (this.dialogMode) {
case EditDialogMode.CREATE: case EditDialogMode.CREATE:
return this.getCreateTitle() return this.getCreateTitle()
case EditDialogMode.EDIT: case EditDialogMode.EDIT:
@@ -151,7 +151,7 @@ export abstract class EditDialogComponent<
protected shouldSubmitPermissions(): boolean { protected shouldSubmitPermissions(): boolean {
return ( return (
this.dialogMode() === EditDialogMode.CREATE || this.dialogMode === EditDialogMode.CREATE ||
this.permissionsService.currentUserOwnsObject(this.object) this.permissionsService.currentUserOwnsObject(this.object)
) )
} }
@@ -172,7 +172,7 @@ export abstract class EditDialogComponent<
delete newObject['set_permissions'] delete newObject['set_permissions']
} }
var serverResponse: Observable<T> var serverResponse: Observable<T>
switch (this.dialogMode()) { switch (this.dialogMode) {
case EditDialogMode.CREATE: case EditDialogMode.CREATE:
serverResponse = this.service.create(newObject) serverResponse = this.service.create(newObject)
break break
@@ -45,20 +45,20 @@ describe('GroupEditDialogComponent', () => {
fixture = TestBed.createComponent(GroupEditDialogComponent) fixture = TestBed.createComponent(GroupEditDialogComponent)
settingsService = TestBed.inject(SettingsService) settingsService = TestBed.inject(SettingsService)
settingsService.currentUser.set({ id: 99, username: 'user99' }) settingsService.currentUser = { id: 99, username: 'user99' }
component = fixture.componentInstance component = fixture.componentInstance
fixture.detectChanges() fixture.detectChanges()
}) })
it('should support create and edit modes', () => { it('should support create and edit modes', () => {
component.dialogMode.set(EditDialogMode.CREATE) component.dialogMode = EditDialogMode.CREATE
const createTitleSpy = jest.spyOn(component, 'getCreateTitle') const createTitleSpy = jest.spyOn(component, 'getCreateTitle')
const editTitleSpy = jest.spyOn(component, 'getEditTitle') const editTitleSpy = jest.spyOn(component, 'getEditTitle')
fixture.detectChanges() fixture.detectChanges()
expect(createTitleSpy).toHaveBeenCalled() expect(createTitleSpy).toHaveBeenCalled()
expect(editTitleSpy).not.toHaveBeenCalled() expect(editTitleSpy).not.toHaveBeenCalled()
component.dialogMode.set(EditDialogMode.EDIT) component.dialogMode = EditDialogMode.EDIT
fixture.detectChanges() fixture.detectChanges()
expect(editTitleSpy).toHaveBeenCalled() expect(editTitleSpy).toHaveBeenCalled()
}) })
@@ -25,8 +25,8 @@
</div> </div>
<div class="modal-footer"> <div class="modal-footer">
<div class="m-0 me-2"> <div class="m-0 me-2">
@if (testResult()) { @if (testResult) {
<ngb-alert #testResultAlert [type]="testResult()" class="mb-0 py-2" (closed)="testResult.set(null)">{{testResultMessage}}</ngb-alert> <ngb-alert #testResultAlert [type]="testResult" class="mb-0 py-2" (closed)="testResult = null">{{testResultMessage}}</ngb-alert>
} }
</div> </div>
<button type="button" class="btn btn-outline-primary" (click)="test()" [disabled]="networkActive || testActive"> <button type="button" class="btn btn-outline-primary" (click)="test()" [disabled]="networkActive || testActive">
@@ -3,7 +3,12 @@ import {
HttpTestingController, HttpTestingController,
provideHttpClientTesting, provideHttpClientTesting,
} from '@angular/common/http/testing' } 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 { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { NgbActiveModal, NgbModule } from '@ng-bootstrap/ng-bootstrap' import { NgbActiveModal, NgbModule } from '@ng-bootstrap/ng-bootstrap'
import { NgSelectModule } from '@ng-select/ng-select' import { NgSelectModule } from '@ng-select/ng-select'
@@ -53,26 +58,25 @@ describe('MailAccountEditDialogComponent', () => {
fixture = TestBed.createComponent(MailAccountEditDialogComponent) fixture = TestBed.createComponent(MailAccountEditDialogComponent)
settingsService = TestBed.inject(SettingsService) settingsService = TestBed.inject(SettingsService)
settingsService.currentUser.set({ id: 99, username: 'user99' }) settingsService.currentUser = { id: 99, username: 'user99' }
component = fixture.componentInstance component = fixture.componentInstance
fixture.detectChanges() fixture.detectChanges()
}) })
it('should support create and edit modes', () => { it('should support create and edit modes', () => {
component.dialogMode.set(EditDialogMode.CREATE) component.dialogMode = EditDialogMode.CREATE
const createTitleSpy = jest.spyOn(component, 'getCreateTitle') const createTitleSpy = jest.spyOn(component, 'getCreateTitle')
const editTitleSpy = jest.spyOn(component, 'getEditTitle') const editTitleSpy = jest.spyOn(component, 'getEditTitle')
fixture.detectChanges() fixture.detectChanges()
expect(createTitleSpy).toHaveBeenCalled() expect(createTitleSpy).toHaveBeenCalled()
expect(editTitleSpy).not.toHaveBeenCalled() expect(editTitleSpy).not.toHaveBeenCalled()
component.dialogMode.set(EditDialogMode.EDIT) component.dialogMode = EditDialogMode.EDIT
fixture.detectChanges() fixture.detectChanges()
expect(editTitleSpy).toHaveBeenCalled() expect(editTitleSpy).toHaveBeenCalled()
}) })
it('should support test mail account and show appropriate expiring alert', () => { it('should support test mail account and show appropriate expiring alert', fakeAsync(() => {
jest.useFakeTimers()
component.object = { component.object = {
name: 'example', name: 'example',
imap_server: 'imap.example.com', imap_server: 'imap.example.com',
@@ -93,7 +97,7 @@ describe('MailAccountEditDialogComponent', () => {
expect(fixture.nativeElement.textContent).toContain( expect(fixture.nativeElement.textContent).toContain(
'Successfully connected' 'Successfully connected'
) )
jest.advanceTimersByTime(6000) tick(6000)
fixture.detectChanges() fixture.detectChanges()
expect(fixture.nativeElement.textContent).not.toContain( expect(fixture.nativeElement.textContent).not.toContain(
'Successfully connected' 'Successfully connected'
@@ -114,7 +118,6 @@ describe('MailAccountEditDialogComponent', () => {
.flush({}, { status: 500, statusText: 'error' }) .flush({}, { status: 500, statusText: 'error' })
fixture.detectChanges() fixture.detectChanges()
expect(fixture.nativeElement.textContent).toContain('Unable to connect') expect(fixture.nativeElement.textContent).toContain('Unable to connect')
jest.advanceTimersByTime(6000) tick(6000)
jest.useRealTimers() }))
})
}) })
@@ -1,4 +1,4 @@
import { Component, ViewChild, inject, signal } from '@angular/core' import { Component, ViewChild, inject } from '@angular/core'
import { import {
FormControl, FormControl,
FormGroup, FormGroup,
@@ -38,7 +38,7 @@ const IMAP_SECURITY_OPTIONS = [
}) })
export class MailAccountEditDialogComponent extends EditDialogComponent<MailAccount> { export class MailAccountEditDialogComponent extends EditDialogComponent<MailAccount> {
testActive: boolean = false testActive: boolean = false
readonly testResult = signal<string>(undefined) testResult: string
alertTimeout alertTimeout
@ViewChild('testResultAlert', { static: false }) testResultAlert: NgbAlert @ViewChild('testResultAlert', { static: false }) testResultAlert: NgbAlert
@@ -77,7 +77,7 @@ export class MailAccountEditDialogComponent extends EditDialogComponent<MailAcco
test() { test() {
this.testActive = true this.testActive = true
this.testResult.set(null) this.testResult = null
clearTimeout(this.alertTimeout) clearTimeout(this.alertTimeout)
const mailService = this.service as MailAccountService const mailService = this.service as MailAccountService
const newObject = Object.assign( const newObject = Object.assign(
@@ -87,19 +87,19 @@ export class MailAccountEditDialogComponent extends EditDialogComponent<MailAcco
mailService.test(newObject).subscribe({ mailService.test(newObject).subscribe({
next: (result: { success: boolean }) => { next: (result: { success: boolean }) => {
this.testActive = false this.testActive = false
this.testResult.set(result.success ? 'success' : 'danger') this.testResult = result.success ? 'success' : 'danger'
this.alertTimeout = setTimeout(() => this.testResultAlert.close(), 5000) this.alertTimeout = setTimeout(() => this.testResultAlert.close(), 5000)
}, },
error: (e) => { error: (e) => {
this.testActive = false this.testActive = false
this.testResult.set('danger') this.testResult = 'danger'
this.alertTimeout = setTimeout(() => this.testResultAlert.close(), 5000) this.alertTimeout = setTimeout(() => this.testResultAlert.close(), 5000)
}, },
}) })
} }
get testResultMessage() { get testResultMessage() {
return this.testResult() === 'success' return this.testResult === 'success'
? $localize`Successfully connected to the mail server` ? $localize`Successfully connected to the mail server`
: $localize`Unable to connect 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> <pngx-input-text [horizontal]="true" i18n-title title="Name" formControlName="name" [error]="error?.name" autocomplete="off"></pngx-input-text>
</div> </div>
<div class="col-md-4"> <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>
<div class="col-md-2 pt-2"> <div class="col-md-2 pt-2">
<pngx-input-switch [horizontal]="true" i18n-title title="Enabled" formControlName="enabled"></pngx-input-switch> <pngx-input-switch [horizontal]="true" i18n-title title="Enabled" formControlName="enabled"></pngx-input-switch>
@@ -65,10 +65,10 @@
</div> </div>
<div class="col-md-6"> <div class="col-md-6">
<pngx-input-tags [horizontal]="true" [allowCreate]="false" formControlName="assign_tags"></pngx-input-tags> <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> <pngx-input-select [horizontal]="true" i18n-title title="Assign correspondent from" [items]="metadataCorrespondentOptions" formControlName="assign_correspondent_from"></pngx-input-select>
@if (showCorrespondentField) { @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>
</div> </div>
@@ -75,20 +75,20 @@ describe('MailRuleEditDialogComponent', () => {
fixture = TestBed.createComponent(MailRuleEditDialogComponent) fixture = TestBed.createComponent(MailRuleEditDialogComponent)
settingsService = TestBed.inject(SettingsService) settingsService = TestBed.inject(SettingsService)
settingsService.currentUser.set({ id: 99, username: 'user99' }) settingsService.currentUser = { id: 99, username: 'user99' }
component = fixture.componentInstance component = fixture.componentInstance
fixture.detectChanges() fixture.detectChanges()
}) })
it('should support create and edit modes', () => { it('should support create and edit modes', () => {
component.dialogMode.set(EditDialogMode.CREATE) component.dialogMode = EditDialogMode.CREATE
const createTitleSpy = jest.spyOn(component, 'getCreateTitle') const createTitleSpy = jest.spyOn(component, 'getCreateTitle')
const editTitleSpy = jest.spyOn(component, 'getEditTitle') const editTitleSpy = jest.spyOn(component, 'getEditTitle')
fixture.detectChanges() fixture.detectChanges()
expect(createTitleSpy).toHaveBeenCalled() expect(createTitleSpy).toHaveBeenCalled()
expect(editTitleSpy).not.toHaveBeenCalled() expect(editTitleSpy).not.toHaveBeenCalled()
component.dialogMode.set(EditDialogMode.EDIT) component.dialogMode = EditDialogMode.EDIT
fixture.detectChanges() fixture.detectChanges()
expect(editTitleSpy).toHaveBeenCalled() expect(editTitleSpy).toHaveBeenCalled()
}) })
@@ -1,12 +1,11 @@
import { Component, inject } from '@angular/core' import { Component, inject } from '@angular/core'
import { toSignal } from '@angular/core/rxjs-interop'
import { import {
FormControl, FormControl,
FormGroup, FormGroup,
FormsModule, FormsModule,
ReactiveFormsModule, ReactiveFormsModule,
} from '@angular/forms' } from '@angular/forms'
import { map } from 'rxjs' import { first } from 'rxjs'
import { EditDialogComponent } from 'src/app/components/common/edit-dialog/edit-dialog.component' import { EditDialogComponent } from 'src/app/components/common/edit-dialog/edit-dialog.component'
import { Correspondent } from 'src/app/data/correspondent' import { Correspondent } from 'src/app/data/correspondent'
import { DocumentType } from 'src/app/data/document-type' import { DocumentType } from 'src/app/data/document-type'
@@ -155,28 +154,37 @@ const METADATA_CORRESPONDENT_OPTIONS = [
], ],
}) })
export class MailRuleEditDialogComponent extends EditDialogComponent<MailRule> { export class MailRuleEditDialogComponent extends EditDialogComponent<MailRule> {
private readonly accountService = inject(MailAccountService) private accountService: MailAccountService
private readonly correspondentService = inject(CorrespondentService) private correspondentService: CorrespondentService
private readonly documentTypeService = inject(DocumentTypeService) private documentTypeService: DocumentTypeService
readonly accounts = toSignal( accounts: MailAccount[]
this.accountService.listAll().pipe(map((result) => result.results)), correspondents: Correspondent[]
{ initialValue: undefined as MailAccount[] } documentTypes: DocumentType[]
)
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[] }
)
constructor() { constructor() {
super() super()
this.service = inject(MailRuleService) this.service = inject(MailRuleService)
this.accountService = inject(MailAccountService)
this.correspondentService = inject(CorrespondentService)
this.documentTypeService = inject(DocumentTypeService)
this.userService = inject(UserService) this.userService = inject(UserService)
this.settingsService = inject(SettingsService) 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() { getCreateTitle() {
@@ -22,11 +22,11 @@
<ng-template> <ng-template>
<div class="card mb-2"> <div class="card mb-2">
<div class="card-body p-2"> <div class="card-body p-2">
@if (testLoading()) { @if (testLoading) {
<ng-container [ngTemplateOutlet]="loadingTemplate"></ng-container> <ng-container [ngTemplateOutlet]="loadingTemplate"></ng-container>
} @else if (testResult()) { } @else if (testResult) {
<code>{{testResult()}}</code> <code>{{testResult}}</code>
} @else if (testFailed()) { } @else if (testFailed) {
<div class="text-danger" i18n>Path test failed</div> <div class="text-danger" i18n>Path test failed</div>
} @else { } @else {
<div class="text-muted small" i18n>No document selected</div> <div class="text-muted small" i18n>No document selected</div>
@@ -42,7 +42,7 @@
[compareWith]="compareDocuments" [compareWith]="compareDocuments"
[trackByFn]="trackByFn" [trackByFn]="trackByFn"
[minTermLength]="2" [minTermLength]="2"
[loading]="loading()" [loading]="loading"
[typeahead]="documentsInput$" [typeahead]="documentsInput$"
(change)="testPath($event)"> (change)="testPath($event)">
<ng-template #loadingTemplate ng-loadingspinner-tmp> <ng-template #loadingTemplate ng-loadingspinner-tmp>
@@ -30,20 +30,20 @@ describe('StoragePathEditDialogComponent', () => {
documentService = TestBed.inject(DocumentService) documentService = TestBed.inject(DocumentService)
fixture = TestBed.createComponent(StoragePathEditDialogComponent) fixture = TestBed.createComponent(StoragePathEditDialogComponent)
settingsService = TestBed.inject(SettingsService) settingsService = TestBed.inject(SettingsService)
settingsService.currentUser.set({ id: 99, username: 'user99' }) settingsService.currentUser = { id: 99, username: 'user99' }
component = fixture.componentInstance component = fixture.componentInstance
fixture.detectChanges() fixture.detectChanges()
}) })
it('should support create and edit modes', () => { it('should support create and edit modes', () => {
component.dialogMode.set(EditDialogMode.CREATE) component.dialogMode = EditDialogMode.CREATE
const createTitleSpy = jest.spyOn(component, 'getCreateTitle') const createTitleSpy = jest.spyOn(component, 'getCreateTitle')
const editTitleSpy = jest.spyOn(component, 'getEditTitle') const editTitleSpy = jest.spyOn(component, 'getEditTitle')
fixture.detectChanges() fixture.detectChanges()
expect(createTitleSpy).toHaveBeenCalled() expect(createTitleSpy).toHaveBeenCalled()
expect(editTitleSpy).not.toHaveBeenCalled() expect(editTitleSpy).not.toHaveBeenCalled()
component.dialogMode.set(EditDialogMode.EDIT) component.dialogMode = EditDialogMode.EDIT
fixture.detectChanges() fixture.detectChanges()
expect(editTitleSpy).toHaveBeenCalled() expect(editTitleSpy).toHaveBeenCalled()
}) })
@@ -58,17 +58,17 @@ describe('StoragePathEditDialogComponent', () => {
fixture.detectChanges() fixture.detectChanges()
component.testPath({ id: 1 }) component.testPath({ id: 1 })
expect(testSpy).toHaveBeenCalledWith('test/{{title}}', 1) expect(testSpy).toHaveBeenCalledWith('test/{{title}}', 1)
expect(component.testResult()).toBe('test/abc123') expect(component.testResult).toBe('test/abc123')
expect(component.testFailed()).toBeFalsy() expect(component.testFailed).toBeFalsy()
// test failed // test failed
testSpy.mockReturnValueOnce(of('')) testSpy.mockReturnValueOnce(of(''))
component.testPath({ id: 1 }) component.testPath({ id: 1 })
expect(component.testResult()).toBeNull() expect(component.testResult).toBeNull()
expect(component.testFailed()).toBeTruthy() expect(component.testFailed).toBeTruthy()
component.testPath(null) component.testPath(null)
expect(component.testResult()).toBeNull() expect(component.testResult).toBeNull()
}) })
it('should compare two documents by id', () => { it('should compare two documents by id', () => {
@@ -1,5 +1,5 @@
import { AsyncPipe, NgTemplateOutlet } from '@angular/common' import { AsyncPipe, NgTemplateOutlet } from '@angular/common'
import { Component, OnDestroy, inject, signal } from '@angular/core' import { Component, OnDestroy, inject } from '@angular/core'
import { import {
FormControl, FormControl,
FormGroup, FormGroup,
@@ -65,13 +65,13 @@ export class StoragePathEditDialogComponent
public documentsInput$ = new Subject<string>() public documentsInput$ = new Subject<string>()
public foundDocuments$: Observable<Document[]> public foundDocuments$: Observable<Document[]>
private testDocument: Document private testDocument: Document
readonly testResult = signal<string>(undefined) public testResult: string
readonly testFailed = signal(false) public testFailed: boolean = false
readonly testLoading = signal(false) public loading = false
public testLoading = false
constructor() { constructor() {
super() super()
this.loading.set(false)
this.service = inject(StoragePathService) this.service = inject(StoragePathService)
this.userService = inject(UserService) this.userService = inject(UserService)
this.settingsService = inject(SettingsService) this.settingsService = inject(SettingsService)
@@ -99,22 +99,22 @@ export class StoragePathEditDialogComponent
public testPath(document: Document) { public testPath(document: Document) {
if (!document) { if (!document) {
this.testResult.set(null) this.testResult = null
return return
} }
this.testDocument = document this.testDocument = document
this.testLoading.set(true) this.testLoading = true
;(this.service as StoragePathService) ;(this.service as StoragePathService)
.testPath(this.objectForm.get('path').value, document.id) .testPath(this.objectForm.get('path').value, document.id)
.subscribe((result) => { .subscribe((result) => {
if (result?.length) { if (result?.length) {
this.testResult.set(result) this.testResult = result
this.testFailed.set(false) this.testFailed = false
} else { } else {
this.testResult.set(null) this.testResult = null
this.testFailed.set(true) this.testFailed = true
} }
this.testLoading.set(false) this.testLoading = false
}) })
} }
@@ -138,7 +138,7 @@ export class StoragePathEditDialogComponent
this.documentsInput$.pipe( this.documentsInput$.pipe(
distinctUntilChanged(), distinctUntilChanged(),
takeUntil(this.unsubscribeNotifier), takeUntil(this.unsubscribeNotifier),
tap(() => this.loading.set(true)), tap(() => (this.loading = true)),
switchMap((title) => switchMap((title) =>
this.documentsService this.documentsService
.listFiltered( .listFiltered(
@@ -152,7 +152,7 @@ export class StoragePathEditDialogComponent
.pipe( .pipe(
map((result) => result.results), map((result) => result.results),
catchError(() => of([])), // empty on error catchError(() => of([])), // empty on error
tap(() => this.loading.set(false)) tap(() => (this.loading = false))
) )
) )
) )

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