Compare commits

..

1 Commits

Author SHA1 Message Date
dependabot[bot] 3cdb752668 Chore(deps): Bump ocrmypdf in the document-processing group
Bumps the document-processing group with 1 update: [ocrmypdf](https://github.com/ocrmypdf/OCRmyPDF).


Updates `ocrmypdf` from 17.4.2 to 17.7.0
- [Release notes](https://github.com/ocrmypdf/OCRmyPDF/releases)
- [Commits](https://github.com/ocrmypdf/OCRmyPDF/compare/v17.4.2...v17.7.0)

---
updated-dependencies:
- dependency-name: ocrmypdf
  dependency-version: 17.7.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: document-processing
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-01 20:17:22 +00:00
189 changed files with 4636 additions and 4973 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@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1
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@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1
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
+1 -1
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
+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@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8
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@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8
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@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8
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@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1
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@e79a6962e0d4c0c17b229090214935d2e33f8354 # v6.0.1
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@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8
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@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8
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
+2 -2
View File
@@ -17,11 +17,11 @@ 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
+10 -10
View File
@@ -20,7 +20,7 @@ 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'
@@ -34,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@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8
with: with:
version: 10 version: 10
- name: Use Node.js 24 - name: Use Node.js 24
@@ -54,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
@@ -170,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@693d20e7c1ce1a81d3a41962f85914253b518449 # v7.3.1
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 }}
@@ -181,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 }}
@@ -201,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@5f14fd08f7cf1cb1609c1e344975f152c7ee938d # v0.5.6
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@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
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@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
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@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4.36.0
+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
@@ -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@693d20e7c1ce1a81d3a41962f85914253b518449 # v7.3.1
env: env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+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@0e279bb959325dab635dd2c09392533439d90093 # v6.0.8
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
+1 -1
View File
@@ -57,7 +57,7 @@ dependencies = [
"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.9.1", "nltk~=3.9.1",
"ocrmypdf~=17.4.2", "ocrmypdf~=17.7.0",
"openai>=2.32", "openai>=2.32",
"pathvalidate~=3.3.1", "pathvalidate~=3.3.1",
"pdf2image~=1.17.0", "pdf2image~=1.17.0",
+1 -3
View File
@@ -155,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,
}, },
}, },
] ]
+49 -49
View File
@@ -1277,7 +1277,7 @@
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1760</context> <context context-type="linenumber">1762</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="1577733187050997705" datatype="html"> <trans-unit id="1577733187050997705" datatype="html">
@@ -2177,7 +2177,7 @@
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">635</context> <context context-type="linenumber">637</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-version-dropdown/document-version-dropdown.component.html</context> <context context-type="sourcefile">src/app/components/document-detail/document-version-dropdown/document-version-dropdown.component.html</context>
@@ -3080,11 +3080,11 @@
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1379</context> <context context-type="linenumber">1381</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1761</context> <context context-type="linenumber">1763</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context> <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
@@ -3655,7 +3655,7 @@
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1332</context> <context context-type="linenumber">1334</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context> <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
@@ -3767,7 +3767,7 @@
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1814</context> <context context-type="linenumber">1816</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="6661109599266152398" datatype="html"> <trans-unit id="6661109599266152398" datatype="html">
@@ -3778,7 +3778,7 @@
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1815</context> <context context-type="linenumber">1817</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="5162686434580248853" datatype="html"> <trans-unit id="5162686434580248853" datatype="html">
@@ -3789,7 +3789,7 @@
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1816</context> <context context-type="linenumber">1818</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="8157388568390631653" datatype="html"> <trans-unit id="8157388568390631653" datatype="html">
@@ -5691,7 +5691,7 @@
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1336</context> <context context-type="linenumber">1338</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context> <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
@@ -7933,88 +7933,88 @@
<source>Enter Password</source> <source>Enter Password</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.html</context>
<context context-type="linenumber">513</context> <context context-type="linenumber">512</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="5758784066858623886" datatype="html"> <trans-unit id="5758784066858623886" datatype="html">
<source>Error retrieving metadata</source> <source>Error retrieving metadata</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">409</context> <context context-type="linenumber">411</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="2218903673684131427" datatype="html"> <trans-unit id="2218903673684131427" datatype="html">
<source>An error occurred loading content: <x id="PH" equiv-text="err.message ?? err.toString()"/></source> <source>An error occurred loading content: <x id="PH" equiv-text="err.message ?? err.toString()"/></source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">510,512</context> <context context-type="linenumber">512,514</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">957,959</context> <context context-type="linenumber">959,961</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="6357361810318120957" datatype="html"> <trans-unit id="6357361810318120957" datatype="html">
<source>Document was updated</source> <source>Document was updated</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">630</context> <context context-type="linenumber">632</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="5154064822428631306" datatype="html"> <trans-unit id="5154064822428631306" datatype="html">
<source>Document was updated at <x id="PH" equiv-text="formattedModified"/>.</source> <source>Document was updated at <x id="PH" equiv-text="formattedModified"/>.</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">631</context> <context context-type="linenumber">633</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="8462497568316256794" datatype="html"> <trans-unit id="8462497568316256794" datatype="html">
<source>Reload to discard your local unsaved edits and load the latest remote version.</source> <source>Reload to discard your local unsaved edits and load the latest remote version.</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">632</context> <context context-type="linenumber">634</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="7967484035994732534" datatype="html"> <trans-unit id="7967484035994732534" datatype="html">
<source>Reload</source> <source>Reload</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">634</context> <context context-type="linenumber">636</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="2907037627372942104" datatype="html"> <trans-unit id="2907037627372942104" datatype="html">
<source>Document reloaded with latest changes.</source> <source>Document reloaded with latest changes.</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">690</context> <context context-type="linenumber">692</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="6435639868943916539" datatype="html"> <trans-unit id="6435639868943916539" datatype="html">
<source>Document reloaded.</source> <source>Document reloaded.</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">701</context> <context context-type="linenumber">703</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="6142395741265832184" datatype="html"> <trans-unit id="6142395741265832184" datatype="html">
<source>Next document</source> <source>Next document</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">803</context> <context context-type="linenumber">805</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="651985345816518480" datatype="html"> <trans-unit id="651985345816518480" datatype="html">
<source>Previous document</source> <source>Previous document</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">813</context> <context context-type="linenumber">815</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="2885986061416655600" datatype="html"> <trans-unit id="2885986061416655600" datatype="html">
<source>Close document</source> <source>Close document</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">821</context> <context context-type="linenumber">823</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/services/open-documents.service.ts</context> <context context-type="sourcefile">src/app/services/open-documents.service.ts</context>
@@ -8025,67 +8025,67 @@
<source>Save document</source> <source>Save document</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">828</context> <context context-type="linenumber">830</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="1784543155727940353" datatype="html"> <trans-unit id="1784543155727940353" datatype="html">
<source>Save and close / next</source> <source>Save and close / next</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">837</context> <context context-type="linenumber">839</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="7427704425579737895" datatype="html"> <trans-unit id="7427704425579737895" datatype="html">
<source>Error retrieving version content</source> <source>Error retrieving version content</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">941</context> <context context-type="linenumber">943</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="3456881259945295697" datatype="html"> <trans-unit id="3456881259945295697" datatype="html">
<source>Error retrieving suggestions.</source> <source>Error retrieving suggestions.</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1000</context> <context context-type="linenumber">1002</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="2194092841814123758" datatype="html"> <trans-unit id="2194092841814123758" datatype="html">
<source>Document &quot;<x id="PH" equiv-text="newValues.title"/>&quot; saved successfully.</source> <source>Document &quot;<x id="PH" equiv-text="newValues.title"/>&quot; saved successfully.</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1212</context> <context context-type="linenumber">1214</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1239</context> <context context-type="linenumber">1241</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="6626387786259219838" datatype="html"> <trans-unit id="6626387786259219838" datatype="html">
<source>Error saving document &quot;<x id="PH" equiv-text="this.document.title"/>&quot;</source> <source>Error saving document &quot;<x id="PH" equiv-text="this.document.title"/>&quot;</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1245</context> <context context-type="linenumber">1247</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="448882439049417053" datatype="html"> <trans-unit id="448882439049417053" datatype="html">
<source>Error saving document</source> <source>Error saving document</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1300</context> <context context-type="linenumber">1302</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="8410796510716511826" datatype="html"> <trans-unit id="8410796510716511826" datatype="html">
<source>Do you really want to move the document &quot;<x id="PH" equiv-text="this.document.title"/>&quot; to the trash?</source> <source>Do you really want to move the document &quot;<x id="PH" equiv-text="this.document.title"/>&quot; to the trash?</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1333</context> <context context-type="linenumber">1335</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="282586936710748252" datatype="html"> <trans-unit id="282586936710748252" datatype="html">
<source>Documents can be restored prior to permanent deletion.</source> <source>Documents can be restored prior to permanent deletion.</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1334</context> <context context-type="linenumber">1336</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context> <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
@@ -8096,14 +8096,14 @@
<source>Error deleting document</source> <source>Error deleting document</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1355</context> <context context-type="linenumber">1357</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="619486176823357521" datatype="html"> <trans-unit id="619486176823357521" datatype="html">
<source>Reprocess confirm</source> <source>Reprocess confirm</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1375</context> <context context-type="linenumber">1377</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context> <context context-type="sourcefile">src/app/components/document-list/bulk-editor/bulk-editor.component.ts</context>
@@ -8114,102 +8114,102 @@
<source>This operation will permanently recreate the archive file for this document.</source> <source>This operation will permanently recreate the archive file for this document.</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1376</context> <context context-type="linenumber">1378</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="302054111564709516" datatype="html"> <trans-unit id="302054111564709516" datatype="html">
<source>The archive file will be re-generated with the current settings.</source> <source>The archive file will be re-generated with the current settings.</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1377</context> <context context-type="linenumber">1379</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="4700389117298802932" datatype="html"> <trans-unit id="4700389117298802932" datatype="html">
<source>Reprocess operation for &quot;<x id="PH" equiv-text="this.document.title"/>&quot; will begin in the background.</source> <source>Reprocess operation for &quot;<x id="PH" equiv-text="this.document.title"/>&quot; will begin in the background.</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1387</context> <context context-type="linenumber">1389</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="4409560272830824468" datatype="html"> <trans-unit id="4409560272830824468" datatype="html">
<source>Error executing operation</source> <source>Error executing operation</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1398</context> <context context-type="linenumber">1400</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="6030453331794586802" datatype="html"> <trans-unit id="6030453331794586802" datatype="html">
<source>Error downloading document</source> <source>Error downloading document</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1461</context> <context context-type="linenumber">1463</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="4458954481601077369" datatype="html"> <trans-unit id="4458954481601077369" datatype="html">
<source>Page Fit</source> <source>Page Fit</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1541</context> <context context-type="linenumber">1543</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="4663705961777238777" datatype="html"> <trans-unit id="4663705961777238777" datatype="html">
<source>PDF edit operation for &quot;<x id="PH" equiv-text="this.document.title"/>&quot; will begin in the background.</source> <source>PDF edit operation for &quot;<x id="PH" equiv-text="this.document.title"/>&quot; will begin in the background.</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1781</context> <context context-type="linenumber">1783</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="9043972994040261999" datatype="html"> <trans-unit id="9043972994040261999" datatype="html">
<source>Error executing PDF edit operation</source> <source>Error executing PDF edit operation</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1793</context> <context context-type="linenumber">1795</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="6172690334763056188" datatype="html"> <trans-unit id="6172690334763056188" datatype="html">
<source>Please enter the current password before attempting to remove it.</source> <source>Please enter the current password before attempting to remove it.</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1804</context> <context context-type="linenumber">1806</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="968660764814228922" datatype="html"> <trans-unit id="968660764814228922" datatype="html">
<source>Password removal operation for &quot;<x id="PH" equiv-text="this.document.title"/>&quot; will begin in the background.</source> <source>Password removal operation for &quot;<x id="PH" equiv-text="this.document.title"/>&quot; will begin in the background.</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1838</context> <context context-type="linenumber">1840</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="2282118435712883014" datatype="html"> <trans-unit id="2282118435712883014" datatype="html">
<source>Error executing password removal operation</source> <source>Error executing password removal operation</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1852</context> <context context-type="linenumber">1854</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="3740891324955700797" datatype="html"> <trans-unit id="3740891324955700797" datatype="html">
<source>Print failed.</source> <source>Print failed.</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1898</context> <context context-type="linenumber">1900</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="6457245677384603573" datatype="html"> <trans-unit id="6457245677384603573" datatype="html">
<source>Error loading document for printing.</source> <source>Error loading document for printing.</source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1911</context> <context context-type="linenumber">1913</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="6085793215710522488" datatype="html"> <trans-unit id="6085793215710522488" datatype="html">
<source>An error occurred loading tiff: <x id="PH" equiv-text="err.toString()"/></source> <source>An error occurred loading tiff: <x id="PH" equiv-text="err.toString()"/></source>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1976</context> <context context-type="linenumber">1978</context>
</context-group> </context-group>
<context-group purpose="location"> <context-group purpose="location">
<context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context> <context context-type="sourcefile">src/app/components/document-detail/document-detail.component.ts</context>
<context context-type="linenumber">1980</context> <context context-type="linenumber">1982</context>
</context-group> </context-group>
</trans-unit> </trans-unit>
<trans-unit id="4958946940233632319" datatype="html"> <trans-unit id="4958946940233632319" datatype="html">
+49 -40
View File
@@ -6,22 +6,22 @@
"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.17",
"@angular/compiler": "~22.0.5", "@angular/compiler": "~21.2.17",
"@angular/core": "~22.0.5", "@angular/core": "~21.2.17",
"@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/platform-browser-dynamic": "~22.0.5", "@angular/platform-browser-dynamic": "~21.2.14",
"@angular/router": "~22.0.5", "@angular/router": "~21.2.14",
"@ng-bootstrap/ng-bootstrap": "^21.0.0", "@ng-bootstrap/ng-bootstrap": "^20.0.0",
"@ng-select/ng-select": "^23.2.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",
@@ -29,47 +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"
"zone.js": "^0.16.2"
}, },
"packageManager": "pnpm@10.17.1" "packageManager": "pnpm@10.17.1",
"pnpm": {
"onlyBuiltDependencies": [
"@parcel/watcher",
"canvas",
"esbuild",
"lmdb",
"msgpackr-extract"
]
}
} }
+2860 -2256
View File
File diff suppressed because it is too large Load Diff
-19
View File
@@ -1,19 +0,0 @@
packages:
- "."
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
@@ -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
@@ -11,30 +11,30 @@
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 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>
@@ -42,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>
} }
} }
@@ -56,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,19 +85,19 @@ 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 || !current ||
current.message !== log.message || current.message !== log.message ||
@@ -106,16 +105,16 @@ export class LogsComponent
) )
}) })
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
}, },
}) })
} }
@@ -150,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 {
@@ -163,6 +162,6 @@ export class LogsComponent
} }
onScroll(): void { onScroll(): void {
this.showJumpToBottom.set(!this.isNearBottom()) this.showJumpToBottom = !this.isNearBottom()
} }
} }
@@ -161,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)
@@ -11,7 +11,7 @@
<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">
@@ -107,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"
@@ -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>
@@ -91,13 +91,13 @@
@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>
} }
@@ -77,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', () => {
@@ -179,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')
@@ -189,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>
@@ -57,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)
@@ -109,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}".`
@@ -148,7 +148,7 @@ 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()
tick(2600) tick(2600)
@@ -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)
@@ -109,7 +109,7 @@ export class UsersAndGroupsComponent
.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) => {
@@ -179,7 +179,7 @@ export class UsersAndGroupsComponent
.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>
@@ -329,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>
@@ -350,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>
@@ -377,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>
@@ -248,7 +248,7 @@ describe('AppFrameComponent', () => {
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/`
@@ -259,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()
tick(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,
@@ -291,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', () => {
@@ -308,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', () => {
@@ -323,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()
}) })
@@ -338,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,
@@ -346,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', () => {
@@ -379,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
) )
} }
} }
@@ -9,16 +9,16 @@
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>
} }
} }
@@ -170,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()
@@ -238,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(
@@ -268,9 +268,9 @@ 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()
@@ -465,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,
@@ -489,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(
@@ -515,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()
@@ -542,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' },
@@ -552,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()
}) })
} }
@@ -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>
@@ -42,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({
@@ -57,43 +56,48 @@ 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', fakeAsync(() => { 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,
}) })
component.ngOnDestroy()
flush() flush()
discardPeriodicTasks() discardPeriodicTasks()
})) }))
it('should show a toast', fakeAsync(() => { 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')
component.ngOnDestroy()
flush() flush()
discardPeriodicTasks() discardPeriodicTasks()
})) }))
it('should toggle suppressPopupToasts', fakeAsync((finish) => { it('should toggle suppressPopupToasts', fakeAsync((finish) => {
component.ngOnInit()
fixture.detectChanges() fixture.detectChanges()
toastsSubject.next(toasts) toastsSubject.next(toasts)
@@ -101,6 +105,7 @@ describe('ToastsDropdownComponent', () => {
component.onOpenChange(true) component.onOpenChange(true)
expect(spy).toHaveBeenCalledWith(true) expect(spy).toHaveBeenCalledWith(true)
component.ngOnDestroy()
flush() flush()
discardPeriodicTasks() 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
@@ -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)
@@ -1,12 +1,5 @@
import { DecimalPipe } from '@angular/common' import { DecimalPipe } from '@angular/common'
import { import { Component, EventEmitter, Input, Output, inject } from '@angular/core'
Component,
EventEmitter,
Input,
Output,
inject,
signal,
} from '@angular/core'
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap' import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
import { Subject } from 'rxjs' import { Subject } from 'rxjs'
import { LoadingComponentWithPermissions } from '../../loading-component/loading.component' import { LoadingComponentWithPermissions } from '../../loading-component/loading.component'
@@ -20,17 +13,6 @@ import { LoadingComponentWithPermissions } from '../../loading-component/loading
export class ConfirmDialogComponent extends LoadingComponentWithPermissions { export class ConfirmDialogComponent extends LoadingComponentWithPermissions {
activeModal = inject(NgbActiveModal) activeModal = inject(NgbActiveModal)
private titleSignal = signal($localize`Confirmation`)
private messageBoldSignal = signal<string>(undefined)
private messageSignal = signal<string>(undefined)
private btnClassSignal = signal('btn-primary')
private btnCaptionSignal = signal($localize`Confirm`)
private alternativeBtnClassSignal = signal('btn-secondary')
private alternativeBtnCaptionSignal = signal<string>(undefined)
private cancelBtnClassSignal = signal('btn-outline-secondary')
private cancelBtnCaptionSignal = signal($localize`Cancel`)
private buttonsEnabledSignal = signal(true)
@Output() @Output()
public confirmClicked = new EventEmitter() public confirmClicked = new EventEmitter()
@@ -38,94 +20,34 @@ export class ConfirmDialogComponent extends LoadingComponentWithPermissions {
public alternativeClicked = new EventEmitter() public alternativeClicked = new EventEmitter()
@Input() @Input()
get title(): string { title = $localize`Confirmation`
return this.titleSignal()
}
set title(title: string) {
this.titleSignal.set(title)
}
@Input() @Input()
get messageBold(): string { messageBold
return this.messageBoldSignal()
}
set messageBold(messageBold: string) {
this.messageBoldSignal.set(messageBold)
}
@Input() @Input()
get message(): string { message
return this.messageSignal()
}
set message(message: string) {
this.messageSignal.set(message)
}
@Input() @Input()
get btnClass(): string { btnClass = 'btn-primary'
return this.btnClassSignal()
}
set btnClass(btnClass: string) {
this.btnClassSignal.set(btnClass)
}
@Input() @Input()
get btnCaption(): string { btnCaption = $localize`Confirm`
return this.btnCaptionSignal()
}
set btnCaption(btnCaption: string) {
this.btnCaptionSignal.set(btnCaption)
}
@Input() @Input()
get alternativeBtnClass(): string { alternativeBtnClass = 'btn-secondary'
return this.alternativeBtnClassSignal()
}
set alternativeBtnClass(alternativeBtnClass: string) {
this.alternativeBtnClassSignal.set(alternativeBtnClass)
}
@Input() @Input()
get alternativeBtnCaption(): string { alternativeBtnCaption
return this.alternativeBtnCaptionSignal()
}
set alternativeBtnCaption(alternativeBtnCaption: string) {
this.alternativeBtnCaptionSignal.set(alternativeBtnCaption)
}
@Input() @Input()
get cancelBtnClass(): string { cancelBtnClass = 'btn-outline-secondary'
return this.cancelBtnClassSignal()
}
set cancelBtnClass(cancelBtnClass: string) {
this.cancelBtnClassSignal.set(cancelBtnClass)
}
@Input() @Input()
get cancelBtnCaption(): string { cancelBtnCaption = $localize`Cancel`
return this.cancelBtnCaptionSignal()
}
set cancelBtnCaption(cancelBtnCaption: string) {
this.cancelBtnCaptionSignal.set(cancelBtnCaption)
}
@Input() @Input()
get buttonsEnabled(): boolean { buttonsEnabled = true
return this.buttonsEnabledSignal()
}
set buttonsEnabled(buttonsEnabled: boolean) {
this.buttonsEnabledSignal.set(buttonsEnabled)
}
confirmButtonEnabled = true confirmButtonEnabled = true
alternativeButtonEnabled = true alternativeButtonEnabled = true
@@ -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,47 +36,15 @@ export class MergeConfirmDialogComponent
private documentService = inject(DocumentService) private documentService = inject(DocumentService)
private permissionService = inject(PermissionsService) private permissionService = inject(PermissionsService)
private documentIDsSignal = signal<number[]>([]) public documentIDs: number[] = []
private archiveFallbackSignal = signal(false) public archiveFallback: boolean = false
private deleteOriginalsSignal = signal(false) public deleteOriginals: boolean = false
private documentsSignal = signal<Document[]>([]) private _documents: Document[] = []
private metadataDocumentIDSignal = signal(-1)
public get documentIDs(): number[] {
return this.documentIDsSignal()
}
public set documentIDs(documentIDs: number[]) {
this.documentIDsSignal.set(documentIDs)
}
public get archiveFallback(): boolean {
return this.archiveFallbackSignal()
}
public set archiveFallback(archiveFallback: boolean) {
this.archiveFallbackSignal.set(archiveFallback)
}
public get deleteOriginals(): boolean {
return this.deleteOriginalsSignal()
}
public set deleteOriginals(deleteOriginals: boolean) {
this.deleteOriginalsSignal.set(deleteOriginals)
}
get documents(): Document[] { get documents(): Document[] {
return this.documentsSignal() return this._documents
} }
public get metadataDocumentID(): number { public metadataDocumentID: number = -1
return this.metadataDocumentIDSignal()
}
public set metadataDocumentID(metadataDocumentID: number) {
this.metadataDocumentIDSignal.set(metadataDocumentID)
}
constructor() { constructor() {
super() super()
@@ -87,14 +55,12 @@ export class MergeConfirmDialogComponent
.getFew(this.documentIDs) .getFew(this.documentIDs)
.pipe(takeUntil(this.unsubscribeNotifier)) .pipe(takeUntil(this.unsubscribeNotifier))
.subscribe((r) => { .subscribe((r) => {
this.documentsSignal.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 = documentIDs
} }
getDocument(documentID: number): Document { getDocument(documentID: number): Document {
@@ -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
} }
} }
@@ -74,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()
@@ -75,7 +75,7 @@
<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"
@@ -210,12 +210,13 @@ describe('CustomFieldsQueryDropdownComponent', () => {
expect(component.name).toBe('test_title') expect(component.name).toBe('test_title')
}) })
it('should add a default atom on open', fakeAsync(() => { 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()
tick() 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', () => {
@@ -41,7 +41,7 @@ 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()
@@ -44,7 +44,7 @@ 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()
@@ -41,7 +41,7 @@ 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()
@@ -122,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)
@@ -5,7 +5,6 @@ import {
OnInit, OnInit,
Output, Output,
inject, inject,
signal,
} 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,20 +45,10 @@ export abstract class EditDialogComponent<
protected settingsService = inject(SettingsService) protected settingsService = inject(SettingsService)
protected permissionsService = inject(PermissionsService) protected permissionsService = inject(PermissionsService)
private usersSignal = signal<User[]>(undefined) users: User[]
private dialogModeSignal = signal(EditDialogMode.CREATE, {
equal: () => false,
})
@Input() @Input()
get dialogMode(): EditDialogMode { dialogMode: EditDialogMode = EditDialogMode.CREATE
return this.dialogModeSignal()
}
set dialogMode(dialogMode: EditDialogMode) {
this.dialogModeSignal.set(dialogMode)
}
@Input() @Input()
object: T object: T
@@ -70,43 +59,11 @@ export abstract class EditDialogComponent<
@Output() @Output()
failed = new EventEmitter() failed = new EventEmitter()
private networkActiveSignal = signal(false) networkActive = false
private closeEnabledSignal = signal(false) closeEnabled = false
private errorSignal = signal<any>(null) error = null
get users(): User[] {
return this.usersSignal()
}
set users(users: User[]) {
this.usersSignal.set(users)
}
get networkActive(): boolean {
return this.networkActiveSignal()
}
set networkActive(networkActive: boolean) {
this.networkActiveSignal.set(networkActive)
}
get closeEnabled(): boolean {
return this.closeEnabledSignal()
}
set closeEnabled(closeEnabled: boolean) {
this.closeEnabledSignal.set(closeEnabled)
}
get error(): any {
return this.errorSignal()
}
set error(error: any) {
this.errorSignal.set(error)
}
abstract getForm(): FormGroup abstract getForm(): FormGroup
@@ -45,7 +45,7 @@ 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()
@@ -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">
@@ -58,7 +58,7 @@ 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()
@@ -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`
} }
@@ -75,7 +75,7 @@ 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()
@@ -1,4 +1,4 @@
import { Component, inject, signal } from '@angular/core' import { Component, inject } from '@angular/core'
import { import {
FormControl, FormControl,
FormGroup, FormGroup,
@@ -158,33 +158,9 @@ export class MailRuleEditDialogComponent extends EditDialogComponent<MailRule> {
private correspondentService: CorrespondentService private correspondentService: CorrespondentService
private documentTypeService: DocumentTypeService private documentTypeService: DocumentTypeService
private accountsSignal = signal<MailAccount[]>(undefined) accounts: MailAccount[]
private correspondentsSignal = signal<Correspondent[]>(undefined) correspondents: Correspondent[]
private documentTypesSignal = signal<DocumentType[]>(undefined) documentTypes: DocumentType[]
get accounts(): MailAccount[] {
return this.accountsSignal()
}
set accounts(accounts: MailAccount[]) {
this.accountsSignal.set(accounts)
}
get correspondents(): Correspondent[] {
return this.correspondentsSignal()
}
set correspondents(correspondents: Correspondent[]) {
this.correspondentsSignal.set(correspondents)
}
get documentTypes(): DocumentType[] {
return this.documentTypesSignal()
}
set documentTypes(documentTypes: DocumentType[]) {
this.documentTypesSignal.set(documentTypes)
}
constructor() { constructor() {
super() super()
@@ -30,7 +30,7 @@ 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()
@@ -67,11 +67,11 @@ export class StoragePathEditDialogComponent
private testDocument: Document private testDocument: Document
public testResult: string public testResult: string
public testFailed: boolean = false public testFailed: boolean = false
public loading = false
public testLoading = 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)
@@ -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))
) )
) )
) )
@@ -48,7 +48,7 @@ describe('TagEditDialogComponent', () => {
fixture = TestBed.createComponent(TagEditDialogComponent) fixture = TestBed.createComponent(TagEditDialogComponent)
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()
@@ -73,7 +73,7 @@ describe('UserEditDialogComponent', () => {
fixture = TestBed.createComponent(UserEditDialogComponent) fixture = TestBed.createComponent(UserEditDialogComponent)
settingsService = TestBed.inject(SettingsService) settingsService = TestBed.inject(SettingsService)
settingsService.currentUser.set({ id: 99, username: 'user99' }) settingsService.currentUser = { id: 99, username: 'user99' }
permissionsService = TestBed.inject(PermissionsService) permissionsService = TestBed.inject(PermissionsService)
toastService = TestBed.inject(ToastService) toastService = TestBed.inject(ToastService)
component = fixture.componentInstance component = fixture.componentInstance
@@ -1,4 +1,4 @@
import { Component, OnInit, inject, signal } from '@angular/core' import { Component, OnInit, inject } from '@angular/core'
import { import {
FormControl, FormControl,
FormGroup, FormGroup,
@@ -40,33 +40,9 @@ export class UserEditDialogComponent
private toastService = inject(ToastService) private toastService = inject(ToastService)
private groupsService: GroupService private groupsService: GroupService
private groupsSignal = signal<Group[]>(undefined) groups: Group[]
private passwordIsSetSignal = signal(false) passwordIsSet: boolean = false
private totpLoadingSignal = signal(false) public totpLoading: boolean = false
get groups(): Group[] {
return this.groupsSignal()
}
set groups(groups: Group[]) {
this.groupsSignal.set(groups)
}
get passwordIsSet(): boolean {
return this.passwordIsSetSignal()
}
set passwordIsSet(passwordIsSet: boolean) {
this.passwordIsSetSignal.set(passwordIsSet)
}
public get totpLoading(): boolean {
return this.totpLoadingSignal()
}
public set totpLoading(totpLoading: boolean) {
this.totpLoadingSignal.set(totpLoading)
}
constructor() { constructor() {
super() super()
@@ -127,7 +103,7 @@ export class UserEditDialogComponent
if (!groupsVal) return [] if (!groupsVal) return []
else else
return groupsVal.flatMap( return groupsVal.flatMap(
(id) => this.groups?.find((g) => g.id == id)?.permissions (id) => this.groups.find((g) => g.id == id)?.permissions
) )
} }
@@ -187,7 +187,7 @@ describe('WorkflowEditDialogComponent', () => {
fixture = TestBed.createComponent(WorkflowEditDialogComponent) fixture = TestBed.createComponent(WorkflowEditDialogComponent)
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()
@@ -4,7 +4,7 @@ import {
moveItemInArray, moveItemInArray,
} from '@angular/cdk/drag-drop' } from '@angular/cdk/drag-drop'
import { NgTemplateOutlet } from '@angular/common' import { NgTemplateOutlet } from '@angular/common'
import { Component, OnInit, inject, signal } from '@angular/core' import { Component, OnInit, inject } from '@angular/core'
import { import {
AbstractControl, AbstractControl,
FormArray, FormArray,
@@ -476,73 +476,17 @@ export class WorkflowEditDialogComponent
private mailRuleService: MailRuleService private mailRuleService: MailRuleService
private customFieldsService: CustomFieldsService private customFieldsService: CustomFieldsService
private templatesSignal = signal<Workflow[]>(undefined) templates: Workflow[]
private correspondentsSignal = signal<Correspondent[]>(undefined) correspondents: Correspondent[]
private documentTypesSignal = signal<DocumentType[]>(undefined) documentTypes: DocumentType[]
private storagePathsSignal = signal<StoragePath[]>(undefined) storagePaths: StoragePath[]
private mailRulesSignal = signal<MailRule[]>(undefined) mailRules: MailRule[]
private customFieldsSignal = signal<CustomField[]>(undefined) customFields: CustomField[]
private dateCustomFieldsSignal = signal<CustomField[]>(undefined) dateCustomFields: CustomField[]
get templates(): Workflow[] {
return this.templatesSignal()
}
set templates(templates: Workflow[]) {
this.templatesSignal.set(templates)
}
get correspondents(): Correspondent[] {
return this.correspondentsSignal()
}
set correspondents(correspondents: Correspondent[]) {
this.correspondentsSignal.set(correspondents)
}
get documentTypes(): DocumentType[] {
return this.documentTypesSignal()
}
set documentTypes(documentTypes: DocumentType[]) {
this.documentTypesSignal.set(documentTypes)
}
get storagePaths(): StoragePath[] {
return this.storagePathsSignal()
}
set storagePaths(storagePaths: StoragePath[]) {
this.storagePathsSignal.set(storagePaths)
}
get mailRules(): MailRule[] {
return this.mailRulesSignal()
}
set mailRules(mailRules: MailRule[]) {
this.mailRulesSignal.set(mailRules)
}
get customFields(): CustomField[] {
return this.customFieldsSignal()
}
set customFields(customFields: CustomField[]) {
this.customFieldsSignal.set(customFields)
}
get dateCustomFields(): CustomField[] {
return this.dateCustomFieldsSignal()
}
set dateCustomFields(dateCustomFields: CustomField[]) {
this.dateCustomFieldsSignal.set(dateCustomFields)
}
expandedItem: number = null expandedItem: number = null
private allowedActionTypesSignal = signal([]) private allowedActionTypes = []
private readonly triggerFilterOptionsMap = new WeakMap< private readonly triggerFilterOptionsMap = new WeakMap<
FormArray, FormArray,
@@ -1350,18 +1294,9 @@ export class WorkflowEditDialogComponent
} }
get actionTypeOptions() { get actionTypeOptions() {
this.settingsService.trackChanges()
return this.allowedActionTypes return this.allowedActionTypes
} }
get allowedActionTypes() {
return this.allowedActionTypesSignal()
}
set allowedActionTypes(allowedActionTypes) {
this.allowedActionTypesSignal.set(allowedActionTypes)
}
getActionTypeOptionName(type: WorkflowActionType): string { getActionTypeOptionName(type: WorkflowActionType): string {
return this.actionTypeOptions.find((t) => t.id === type)?.name ?? '' return this.actionTypeOptions.find((t) => t.id === type)?.name ?? ''
} }
@@ -1,8 +1,8 @@
<div class="modal-header"> <div class="modal-header">
<h4 class="modal-title" id="modal-basic-title" i18n>{ <h4 class="modal-title" id="modal-basic-title" i18n>{
documentIds().length, documentIds.length,
plural, plural,
=1 {Email Document} other {Email {{documentIds().length}} Documents} =1 {Email Document} other {Email {{documentIds.length}} Documents}
}</h4> }</h4>
<button type="button" class="btn-close" aria-label="Close" (click)="close()"></button> <button type="button" class="btn-close" aria-label="Close" (click)="close()"></button>
</div> </div>
@@ -23,11 +23,11 @@
<div class="modal-footer"> <div class="modal-footer">
<div class="input-group"> <div class="input-group">
<div class="input-group-text flex-grow-1"> <div class="input-group-text flex-grow-1">
<input class="form-check-input mt-0 me-2" type="checkbox" role="switch" id="useArchiveVersion" [disabled]="!hasArchiveVersion()" [ngModel]="useArchiveVersion()" (ngModelChange)="useArchiveVersion.set($event)"> <input class="form-check-input mt-0 me-2" type="checkbox" role="switch" id="useArchiveVersion" [disabled]="!hasArchiveVersion" [(ngModel)]="useArchiveVersion">
<label class="form-check-label w-100 text-start" for="useArchiveVersion" i18n>Use archive version</label> <label class="form-check-label w-100 text-start" for="useArchiveVersion" i18n>Use archive version</label>
</div> </div>
<button type="submit" class="btn btn-outline-primary" (click)="emailDocuments()" [disabled]="loading() || emailAddress.length === 0 || emailMessage.length === 0 || emailSubject.length === 0"> <button type="submit" class="btn btn-outline-primary" (click)="emailDocuments()" [disabled]="loading || emailAddress.length === 0 || emailMessage.length === 0 || emailSubject.length === 0">
@if (loading()) { @if (loading) {
<div class="spinner-border spinner-border-sm me-2" role="status"></div> <div class="spinner-border spinner-border-sm me-2" role="status"></div>
} }
<ng-container i18n>Send email</ng-container> <ng-container i18n>Send email</ng-container>
@@ -36,24 +36,23 @@ describe('EmailDocumentDialogComponent', () => {
documentService = TestBed.inject(DocumentService) documentService = TestBed.inject(DocumentService)
toastService = TestBed.inject(ToastService) toastService = TestBed.inject(ToastService)
component = fixture.componentInstance component = fixture.componentInstance
component.documentIds.set([1]) component.documentIds = [1]
fixture.detectChanges() fixture.detectChanges()
}) })
it('should set hasArchiveVersion and useArchiveVersion', () => { it('should set hasArchiveVersion and useArchiveVersion', () => {
expect(component.hasArchiveVersion()).toBeTruthy() expect(component.hasArchiveVersion).toBeTruthy()
expect(component.useArchiveVersion()).toBeTruthy() expect(component.useArchiveVersion).toBeTruthy()
component.hasArchiveVersion.set(false) component.hasArchiveVersion = false
fixture.detectChanges() expect(component.hasArchiveVersion).toBeFalsy()
expect(component.hasArchiveVersion()).toBeFalsy() expect(component.useArchiveVersion).toBeFalsy()
expect(component.useArchiveVersion()).toBeFalsy()
}) })
it('should support sending single document via email, showing error if needed', () => { it('should support sending single document via email, showing error if needed', () => {
const toastErrorSpy = jest.spyOn(toastService, 'showError') const toastErrorSpy = jest.spyOn(toastService, 'showError')
const toastSuccessSpy = jest.spyOn(toastService, 'showInfo') const toastSuccessSpy = jest.spyOn(toastService, 'showInfo')
component.documentIds.set([1]) component.documentIds = [1]
component.emailAddress = 'hello@paperless-ngx.com' component.emailAddress = 'hello@paperless-ngx.com'
component.emailSubject = 'Hello' component.emailSubject = 'Hello'
component.emailMessage = 'World' component.emailMessage = 'World'
@@ -74,7 +73,7 @@ describe('EmailDocumentDialogComponent', () => {
it('should support sending multiple documents via email, showing appropriate messages', () => { it('should support sending multiple documents via email, showing appropriate messages', () => {
const toastErrorSpy = jest.spyOn(toastService, 'showError') const toastErrorSpy = jest.spyOn(toastService, 'showError')
const toastSuccessSpy = jest.spyOn(toastService, 'showInfo') const toastSuccessSpy = jest.spyOn(toastService, 'showInfo')
component.documentIds.set([1, 2, 3]) component.documentIds = [1, 2, 3]
component.emailAddress = 'hello@paperless-ngx.com' component.emailAddress = 'hello@paperless-ngx.com'
component.emailSubject = 'Hello' component.emailSubject = 'Hello'
component.emailMessage = 'World' component.emailMessage = 'World'
@@ -1,4 +1,4 @@
import { Component, effect, inject, signal } from '@angular/core' import { Component, Input, inject } from '@angular/core'
import { FormsModule } from '@angular/forms' import { FormsModule } from '@angular/forms'
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap' import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons' import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
@@ -17,9 +17,22 @@ export class EmailDocumentDialogComponent extends LoadingComponentWithPermission
private documentService = inject(DocumentService) private documentService = inject(DocumentService)
private toastService = inject(ToastService) private toastService = inject(ToastService)
readonly documentIds = signal<number[]>(undefined) @Input()
readonly hasArchiveVersion = signal(true) documentIds: number[]
readonly useArchiveVersion = signal(true)
private _hasArchiveVersion: boolean = true
@Input()
set hasArchiveVersion(value: boolean) {
this._hasArchiveVersion = value
this.useArchiveVersion = value
}
get hasArchiveVersion(): boolean {
return this._hasArchiveVersion
}
public useArchiveVersion: boolean = true
public emailAddress: string = '' public emailAddress: string = ''
public emailSubject: string = '' public emailSubject: string = ''
@@ -27,25 +40,22 @@ export class EmailDocumentDialogComponent extends LoadingComponentWithPermission
constructor() { constructor() {
super() super()
this.loading.set(false) this.loading = false
effect(() => {
this.useArchiveVersion.set(this.hasArchiveVersion())
})
} }
public emailDocuments() { public emailDocuments() {
this.loading.set(true) this.loading = true
this.documentService this.documentService
.emailDocuments( .emailDocuments(
this.documentIds(), this.documentIds,
this.emailAddress, this.emailAddress,
this.emailSubject, this.emailSubject,
this.emailMessage, this.emailMessage,
this.useArchiveVersion() this.useArchiveVersion
) )
.subscribe({ .subscribe({
next: () => { next: () => {
this.loading.set(false) this.loading = false
this.emailAddress = '' this.emailAddress = ''
this.emailSubject = '' this.emailSubject = ''
this.emailMessage = '' this.emailMessage = ''
@@ -53,9 +63,9 @@ export class EmailDocumentDialogComponent extends LoadingComponentWithPermission
this.toastService.showInfo($localize`Email sent`) this.toastService.showInfo($localize`Email sent`)
}, },
error: (e) => { error: (e) => {
this.loading.set(false) this.loading = false
const errorMessage = const errorMessage =
this.documentIds().length > 1 this.documentIds.length > 1
? $localize`Error emailing documents` ? $localize`Error emailing documents`
: $localize`Error emailing document` : $localize`Error emailing document`
this.toastService.showError(errorMessage, e) this.toastService.showError(errorMessage, e)
@@ -1,11 +1,11 @@
<div class="modal-header"> <div class="modal-header">
<h4 class="modal-title" id="modal-basic-title">{{title()}}</h4> <h4 class="modal-title" id="modal-basic-title">{{title}}</h4>
<button type="button" class="btn-close" aria-label="Close" (click)="close()"></button> <button type="button" class="btn-close" aria-label="Close" (click)="close()"></button>
</div> </div>
<div class="modal-body"> <div class="modal-body">
<table class="table"> <table class="table">
<tbody> <tbody>
@for (key of hotkeys().entries(); track key[0]) { @for (key of hotkeys.entries(); track key[0]) {
<tr> <tr>
<td>{{ key[1] }}</td> <td>{{ key[1] }}</td>
<td class="d-flex justify-content-end"> <td class="d-flex justify-content-end">
@@ -1,4 +1,4 @@
import { Component, inject, signal } from '@angular/core' import { Component, inject } from '@angular/core'
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap' import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
const SYMBOLS = { const SYMBOLS = {
@@ -20,8 +20,9 @@ const SYMBOLS = {
}) })
export class HotkeyDialogComponent { export class HotkeyDialogComponent {
activeModal = inject(NgbActiveModal) activeModal = inject(NgbActiveModal)
readonly title = signal($localize`Keyboard shortcuts`)
readonly hotkeys = signal<Map<string, string>>(new Map()) public title: string = $localize`Keyboard shortcuts`
public hotkeys: Map<string, string> = new Map()
public close(): void { public close(): void {
this.activeModal.close() this.activeModal.close()
@@ -5,7 +5,6 @@ import {
Input, Input,
OnInit, OnInit,
Output, Output,
signal,
ViewChild, ViewChild,
} from '@angular/core' } from '@angular/core'
import { ControlValueAccessor } from '@angular/forms' import { ControlValueAccessor } from '@angular/forms'
@@ -62,15 +61,7 @@ export class AbstractInputComponent<T> implements OnInit, ControlValueAccessor {
@Output() @Output()
removed: EventEmitter<AbstractInputComponent<any>> = new EventEmitter() removed: EventEmitter<AbstractInputComponent<any>> = new EventEmitter()
private valueSignal = signal<T>(undefined) value: T
get value(): T {
return this.valueSignal()
}
set value(value: T) {
this.valueSignal.set(value)
}
ngOnInit(): void { ngOnInit(): void {
this.inputId = uuidv4() this.inputId = uuidv4()
@@ -1,4 +1,4 @@
import { Component, forwardRef, inject, signal } from '@angular/core' import { Component, forwardRef, inject } from '@angular/core'
import { import {
FormsModule, FormsModule,
NG_VALUE_ACCESSOR, NG_VALUE_ACCESSOR,
@@ -24,15 +24,7 @@ import { AbstractInputComponent } from '../../abstract-input'
imports: [NgSelectComponent, FormsModule, ReactiveFormsModule], imports: [NgSelectComponent, FormsModule, ReactiveFormsModule],
}) })
export class PermissionsGroupComponent extends AbstractInputComponent<Group> { export class PermissionsGroupComponent extends AbstractInputComponent<Group> {
private groupsSignal = signal<Group[]>(undefined) groups: Group[]
get groups(): Group[] {
return this.groupsSignal()
}
set groups(groups: Group[]) {
this.groupsSignal.set(groups)
}
constructor() { constructor() {
const groupService = inject(GroupService) const groupService = inject(GroupService)
@@ -1,4 +1,4 @@
import { Component, forwardRef, inject, signal } from '@angular/core' import { Component, forwardRef, inject } from '@angular/core'
import { import {
FormsModule, FormsModule,
NG_VALUE_ACCESSOR, NG_VALUE_ACCESSOR,
@@ -24,15 +24,7 @@ import { AbstractInputComponent } from '../../abstract-input'
imports: [NgSelectComponent, FormsModule, ReactiveFormsModule], imports: [NgSelectComponent, FormsModule, ReactiveFormsModule],
}) })
export class PermissionsUserComponent extends AbstractInputComponent<User[]> { export class PermissionsUserComponent extends AbstractInputComponent<User[]> {
private usersSignal = signal<User[]>(undefined) users: User[]
get users(): User[] {
return this.usersSignal()
}
set users(users: User[]) {
this.usersSignal.set(users)
}
constructor() { constructor() {
const userService = inject(UserService) const userService = inject(UserService)
@@ -106,7 +106,7 @@ describe('TagsComponent', () => {
modalService = TestBed.inject(NgbModal) modalService = TestBed.inject(NgbModal)
settingsService = TestBed.inject(SettingsService) settingsService = TestBed.inject(SettingsService)
settingsService.currentUser.set({ id: 1 } as any) settingsService.currentUser = { id: 1 }
fixture = TestBed.createComponent(TagsComponent) fixture = TestBed.createComponent(TagsComponent)
fixture.debugElement.injector.get(NG_VALUE_ACCESSOR) fixture.debugElement.injector.get(NG_VALUE_ACCESSOR)
component = fixture.componentInstance component = fixture.componentInstance
@@ -136,7 +136,7 @@ describe('TagsComponent', () => {
}) })
it('should support create new using last search term and open a modal', () => { it('should support create new using last search term and open a modal', () => {
settingsService.currentUser.set({ id: 1 }) settingsService.currentUser = { id: 1 }
let activeInstances: NgbModalRef[] let activeInstances: NgbModalRef[]
modalService.activeInstances.subscribe((v) => (activeInstances = v)) modalService.activeInstances.subscribe((v) => (activeInstances = v))
component.select.filter('foobar') component.select.filter('foobar')
@@ -6,7 +6,6 @@ import {
Input, Input,
OnInit, OnInit,
Output, Output,
signal,
ViewChild, ViewChild,
} from '@angular/core' } from '@angular/core'
import { import {
@@ -110,15 +109,7 @@ export class TagsComponent implements OnInit, ControlValueAccessor {
@ViewChild('tagSelect') select: NgSelectComponent @ViewChild('tagSelect') select: NgSelectComponent
private valueSignal = signal<number[]>([]) value: number[] = []
get value(): number[] {
return this.valueSignal()
}
set value(value: number[]) {
this.valueSignal.set(value)
}
tags: Tag[] = [] tags: Tag[] = []
@@ -1,7 +1,7 @@
@if (customLogo) { @if (customLogo) {
<img src="{{customLogo}}" [class]="getClasses()" [attr.style]="'height:'+height()" /> <img src="{{customLogo}}" [class]="getClasses()" [attr.style]="'height:'+height" />
} @else { } @else {
<svg [class]="getClasses()" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 2670 860" [attr.style]="'height:'+height()"> <svg [class]="getClasses()" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 2670 860" [attr.style]="'height:'+height">
<path class="leaf" style="fill:#005616;" d="M2227.4,821.2c-6.1-17.8-18.1-53.6-19.2-53.4-174.7-77.8-159.8-201.2-117.5-304.2,26.3,120.1,235.3,130.3,128,294.1-.7,2,8.8,24.3,17.1,44.9,19.9-45.4,51.3-101.1,48.8-105.7-199.9-357.4,278.8-444.7,350.7-690.2,72.6,220.1,46.5,577.5-330.4,713.3-1.8,1.2-55.6,130-58.5,131.4-.2-1.9-29.1,2.5-26.4-7.6,1.4-6.2,4.2-14.2,7.2-22.4h0v-.2h.2,0ZM2211.7,731.2c42.3-62.9-11.1-105.7-49.8-133.2,71,94,58.1,105.7,49.8,133.2h0Z"/> <path class="leaf" style="fill:#005616;" d="M2227.4,821.2c-6.1-17.8-18.1-53.6-19.2-53.4-174.7-77.8-159.8-201.2-117.5-304.2,26.3,120.1,235.3,130.3,128,294.1-.7,2,8.8,24.3,17.1,44.9,19.9-45.4,51.3-101.1,48.8-105.7-199.9-357.4,278.8-444.7,350.7-690.2,72.6,220.1,46.5,577.5-330.4,713.3-1.8,1.2-55.6,130-58.5,131.4-.2-1.9-29.1,2.5-26.4-7.6,1.4-6.2,4.2-14.2,7.2-22.4h0v-.2h.2,0ZM2211.7,731.2c42.3-62.9-11.1-105.7-49.8-133.2,71,94,58.1,105.7,49.8,133.2h0Z"/>
<g class="text" style="fill: #000;"> <g class="text" style="fill: #000;">
<path class="st1" d="M654.6,393.2l-.7,137.7h-85.5V188.7h85.4c.4,11.3-.3,21.7,1.3,33.8,23.1-34.1,62.3-50,101.1-38.3,16.5,5,29.6,16.4,39.7,30,34.4,46.5,35.1,134,3.6,182.2-10.1,14.4-22.5,26.9-39,33.4-39.5,15.7-81,1.1-105.9-36.6h0ZM721,362.2c21-26.1,21-82.7-.4-108.4-13.2-15.9-36.4-16.1-49.9-.4-22.2,25.8-21.7,85.3.5,110.1,13.6,15.2,36.6,15,49.7-1.3h.1Z"/> <path class="st1" d="M654.6,393.2l-.7,137.7h-85.5V188.7h85.4c.4,11.3-.3,21.7,1.3,33.8,23.1-34.1,62.3-50,101.1-38.3,16.5,5,29.6,16.4,39.7,30,34.4,46.5,35.1,134,3.6,182.2-10.1,14.4-22.5,26.9-39,33.4-39.5,15.7-81,1.1-105.9-36.6h0ZM721,362.2c21-26.1,21-82.7-.4-108.4-13.2-15.9-36.4-16.1-49.9-.4-22.2,25.8-21.7,85.3.5,110.1,13.6,15.2,36.6,15,49.7-1.3h.1Z"/>
@@ -28,7 +28,7 @@ describe('LogoComponent', () => {
it('should support extra classes', () => { it('should support extra classes', () => {
expect(fixture.debugElement.queryAll(By.css('.foo'))).toHaveLength(0) expect(fixture.debugElement.queryAll(By.css('.foo'))).toHaveLength(0)
fixture.componentRef.setInput('extra_classes', 'foo') component.extra_classes = 'foo'
fixture.detectChanges() fixture.detectChanges()
expect(fixture.debugElement.queryAll(By.css('.foo'))).toHaveLength(1) expect(fixture.debugElement.queryAll(By.css('.foo'))).toHaveLength(1)
}) })
@@ -37,7 +37,7 @@ describe('LogoComponent', () => {
expect(fixture.debugElement.query(By.css('svg')).attributes.style).toEqual( expect(fixture.debugElement.query(By.css('svg')).attributes.style).toEqual(
'height:6em' 'height:6em'
) )
fixture.componentRef.setInput('height', '10em') component.height = '10em'
fixture.detectChanges() fixture.detectChanges()
expect(fixture.debugElement.query(By.css('svg')).attributes.style).toEqual( expect(fixture.debugElement.query(By.css('svg')).attributes.style).toEqual(
'height:10em' 'height:10em'
@@ -1,4 +1,4 @@
import { Component, inject, input } from '@angular/core' import { Component, Input, inject } from '@angular/core'
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'
import { environment } from 'src/environments/environment' import { environment } from 'src/environments/environment'
@@ -10,8 +10,12 @@ import { environment } from 'src/environments/environment'
}) })
export class LogoComponent { export class LogoComponent {
private settingsService = inject(SettingsService) private settingsService = inject(SettingsService)
readonly extra_classes = input<string>(undefined)
readonly height = input('6em') @Input()
extra_classes: string
@Input()
height = '6em'
get customLogo(): string { get customLogo(): string {
return this.settingsService.get(SETTINGS_KEYS.APP_LOGO)?.length return this.settingsService.get(SETTINGS_KEYS.APP_LOGO)?.length
@@ -23,6 +27,6 @@ export class LogoComponent {
} }
getClasses() { getClasses() {
return ['logo'].concat(this.extra_classes()).join(' ') return ['logo'].concat(this.extra_classes).join(' ')
} }
} }
@@ -1,32 +1,32 @@
<div class="row pt-3 pb-3 pb-md-2 align-items-center"> <div class="row pt-3 pb-3 pb-md-2 align-items-center">
<div class="col-md text-truncate"> <div class="col-md text-truncate">
<h3 class="d-flex align-items-center mb-1" style="line-height: 1.4"> <h3 class="d-flex align-items-center mb-1" style="line-height: 1.4">
<span class="text-truncate">{{title()}}</span> <span class="text-truncate">{{title}}</span>
@if (id()) { @if (id) {
<span class="badge bg-primary text-primary-text-contrast ms-3 small fs-normal cursor-pointer" (click)="copyID()"> <span class="badge bg-primary text-primary-text-contrast ms-3 small fs-normal cursor-pointer" (click)="copyID()">
@if (copied()) { @if (copied) {
<i-bs width="1em" height="1em" name="clipboard-check" class="me-1"></i-bs><ng-container i18n>Copied!</ng-container> <i-bs width="1em" height="1em" name="clipboard-check" class="me-1"></i-bs><ng-container i18n>Copied!</ng-container>
} @else { } @else {
ID: {{id()}} ID: {{id}}
} }
</span> </span>
} }
@if (subTitle()) { @if (subTitle) {
<span class="h6 mb-0 mt-1 d-block d-md-inline fw-normal ms-md-3 text-truncate" style="line-height: 1.4">{{subTitle()}}</span> <span class="h6 mb-0 mt-1 d-block d-md-inline fw-normal ms-md-3 text-truncate" style="line-height: 1.4">{{subTitle}}</span>
} }
@if (info()) { @if (info) {
<button class="btn btn-sm btn-link text-muted p-0 p-md-2" title="What's this?" i18n-title type="button" [ngbPopover]="infoPopover" [autoClose]="true"> <button class="btn btn-sm btn-link text-muted p-0 p-md-2" title="What's this?" i18n-title type="button" [ngbPopover]="infoPopover" [autoClose]="true">
<i-bs name="question-circle"></i-bs> <i-bs name="question-circle"></i-bs>
</button> </button>
<ng-template #infoPopover> <ng-template #infoPopover>
<p [class.mb-0]="!infoLink()" [innerHTML]="info()"></p> <p [class.mb-0]="!infoLink" [innerHTML]="info"></p>
@if (infoLink()) { @if (infoLink) {
<a href="https://docs.paperless-ngx.com/{{infoLink()}}" target="_blank" referrerpolicy="noopener noreferrer" i18n>Read more</a> <a href="https://docs.paperless-ngx.com/{{infoLink}}" target="_blank" referrerpolicy="noopener noreferrer" i18n>Read more</a>
<i-bs class="ms-1" width=".8em" height=".8em" name="box-arrow-up-right"></i-bs> <i-bs class="ms-1" width=".8em" height=".8em" name="box-arrow-up-right"></i-bs>
} }
</ng-template> </ng-template>
} }
@if (loading()) { @if (loading) {
<output class="spinner-border spinner-border-sm fs-6 fw-normal" aria-hidden="true"><span class="visually-hidden" i18n>Loading...</span></output> <output class="spinner-border spinner-border-sm fs-6 fw-normal" aria-hidden="true"><span class="visually-hidden" i18n>Loading...</span></output>
} }
</h3> </h3>
@@ -24,8 +24,8 @@ describe('PageHeaderComponent', () => {
}) })
it('should display title + subtitle', () => { it('should display title + subtitle', () => {
fixture.componentRef.setInput('title', 'Foo') component.title = 'Foo'
fixture.componentRef.setInput('subTitle', 'Bar') component.subTitle = 'Bar'
fixture.detectChanges() fixture.detectChanges()
expect(fixture.nativeElement.textContent).toContain('Foo') expect(fixture.nativeElement.textContent).toContain('Foo')
expect(fixture.nativeElement.textContent).toContain('Bar') expect(fixture.nativeElement.textContent).toContain('Bar')
@@ -33,20 +33,19 @@ describe('PageHeaderComponent', () => {
it('should set html title', () => { it('should set html title', () => {
const titleSpy = jest.spyOn(titleService, 'setTitle') const titleSpy = jest.spyOn(titleService, 'setTitle')
fixture.componentRef.setInput('title', 'Foo Bar') component.title = 'Foo Bar'
fixture.detectChanges()
expect(titleSpy).toHaveBeenCalledWith(`Foo Bar - ${environment.appTitle}`) expect(titleSpy).toHaveBeenCalledWith(`Foo Bar - ${environment.appTitle}`)
}) })
it('should copy id to clipboard, reset after 3 seconds', () => { it('should copy id to clipboard, reset after 3 seconds', () => {
jest.useFakeTimers() jest.useFakeTimers()
fixture.componentRef.setInput('id', 42) component.id = 42 as any
jest.spyOn(clipboard, 'copy').mockReturnValue(true) jest.spyOn(clipboard, 'copy').mockReturnValue(true)
component.copyID() component.copyID()
expect(clipboard.copy).toHaveBeenCalledWith('42') expect(clipboard.copy).toHaveBeenCalledWith('42')
expect(component.copied()).toBe(true) expect(component.copied).toBe(true)
jest.advanceTimersByTime(3000) jest.advanceTimersByTime(3000)
expect(component.copied()).toBe(false) expect(component.copied).toBe(false)
}) })
}) })
@@ -1,5 +1,5 @@
import { Clipboard } from '@angular/cdk/clipboard' import { Clipboard } from '@angular/cdk/clipboard'
import { Component, effect, inject, input, signal } from '@angular/core' import { Component, Input, inject } from '@angular/core'
import { Title } from '@angular/platform-browser' import { Title } from '@angular/platform-browser'
import { NgbPopoverModule } from '@ng-bootstrap/ng-bootstrap' import { NgbPopoverModule } from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons' import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
@@ -16,26 +16,40 @@ export class PageHeaderComponent {
private titleService = inject(Title) private titleService = inject(Title)
private clipboard = inject(Clipboard) private clipboard = inject(Clipboard)
readonly id = input<number>(undefined) private _title = ''
readonly subTitle = input('') public copied: boolean = false
readonly info = input<string>(undefined)
readonly infoLink = input<string>(undefined)
readonly loading = input(false)
readonly title = input('')
readonly copied = signal(false)
private copyTimeout: any private copyTimeout: any
constructor() { @Input()
effect(() => { set title(title: string) {
this.titleService.setTitle(`${this.title()} - ${environment.appTitle}`) this._title = title
}) this.titleService.setTitle(`${this.title} - ${environment.appTitle}`)
} }
get title() {
return this._title
}
@Input()
id: number
@Input()
subTitle: string = ''
@Input()
info: string
@Input()
infoLink: string
@Input()
loading: boolean = false
public copyID() { public copyID() {
this.copied.set(this.clipboard.copy(this.id().toString())) this.copied = this.clipboard.copy(this.id.toString())
clearTimeout(this.copyTimeout) clearTimeout(this.copyTimeout)
this.copyTimeout = setTimeout(() => { this.copyTimeout = setTimeout(() => {
this.copied.set(false) this.copied = false
}, 3000) }, 3000)
} }
} }
@@ -3,7 +3,7 @@ import {
DragDropModule, DragDropModule,
moveItemInArray, moveItemInArray,
} from '@angular/cdk/drag-drop' } from '@angular/cdk/drag-drop'
import { Component, inject, signal } from '@angular/core' import { Component, inject } from '@angular/core'
import { FormsModule } from '@angular/forms' import { FormsModule } from '@angular/forms'
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap' import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons' import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
@@ -45,25 +45,8 @@ export class PDFEditorComponent extends ConfirmDialogComponent {
private readonly settingsService = inject(SettingsService) private readonly settingsService = inject(SettingsService)
activeModal: NgbActiveModal = inject(NgbActiveModal) activeModal: NgbActiveModal = inject(NgbActiveModal)
private documentIDSignal = signal<number>(undefined) documentID: number
private versionIDSignal = signal<number>(undefined) versionID?: number
get documentID(): number {
return this.documentIDSignal()
}
set documentID(documentID: number) {
this.documentIDSignal.set(documentID)
}
get versionID(): number {
return this.versionIDSignal()
}
set versionID(versionID: number) {
this.versionIDSignal.set(versionID)
}
pages: PageOperation[] = [] pages: PageOperation[] = []
totalPages = 0 totalPages = 0
editMode: PdfEditorEditMode = this.settingsService.get( editMode: PdfEditorEditMode = this.settingsService.get(
@@ -27,6 +27,7 @@ import {
} from 'pdfjs-dist/web/pdf_viewer.mjs' } from 'pdfjs-dist/web/pdf_viewer.mjs'
import { import {
PdfRenderMode, PdfRenderMode,
PdfSource,
PdfZoomLevel, PdfZoomLevel,
PdfZoomScale, PdfZoomScale,
PngxPdfDocumentProxy, PngxPdfDocumentProxy,
@@ -42,8 +43,7 @@ export class PngxPdfViewerComponent
{ {
private readonly document = inject<Document>(DOCUMENT) private readonly document = inject<Document>(DOCUMENT)
@Input() src!: string @Input() src!: PdfSource
@Input() password?: string
@Input() page?: number @Input() page?: number
@Output() pageChange = new EventEmitter<number>() @Output() pageChange = new EventEmitter<number>()
@Input() rotation?: number @Input() rotation?: number
@@ -93,7 +93,7 @@ export class PngxPdfViewerComponent
} }
ngOnChanges(changes: SimpleChanges): void { ngOnChanges(changes: SimpleChanges): void {
if (changes['src'] || changes['password']) { if (changes['src']) {
this.resetViewerState() this.resetViewerState()
if (this.src) { if (this.src) {
this.loadDocument() this.loadDocument()
@@ -174,12 +174,8 @@ export class PngxPdfViewerComponent
'assets/js/pdf.worker.min.mjs', 'assets/js/pdf.worker.min.mjs',
this.document.baseURI this.document.baseURI
).toString() ).toString()
let initOptions = { this.loadingTask = getDocument(this.src)
url: this.src,
password: this.password,
withCredentials: true,
}
this.loadingTask = getDocument(initOptions)
try { try {
const pdf = await this.loadingTask.promise const pdf = await this.loadingTask.promise
this.pdf = pdf this.pdf = pdf
@@ -2,6 +2,8 @@ export type PngxPdfDocumentProxy = {
numPages: number numPages: number
} }
export type PdfSource = string | { url: string; password?: string }
export enum PdfRenderMode { export enum PdfRenderMode {
Single = 'single', Single = 'single',
All = 'all', All = 'all',
@@ -1,11 +1,4 @@
import { import { Component, EventEmitter, Input, Output, inject } from '@angular/core'
Component,
EventEmitter,
Input,
Output,
inject,
signal,
} from '@angular/core'
import { import {
FormControl, FormControl,
FormGroup, FormGroup,
@@ -34,44 +27,21 @@ export class PermissionsDialogComponent {
activeModal = inject(NgbActiveModal) activeModal = inject(NgbActiveModal)
private userService = inject(UserService) private userService = inject(UserService)
private usersSignal = signal<User[]>(undefined) users: User[]
private titleSignal = signal($localize`Set permissions`)
private noteSignal = signal<string>(null)
private buttonsEnabledSignal = signal(true)
private o: ObjectWithPermissions = undefined private o: ObjectWithPermissions = undefined
constructor() { constructor() {
this.userService.listAll().subscribe((r) => (this.users = r.results)) this.userService.listAll().subscribe((r) => (this.users = r.results))
} }
get users(): User[] {
return this.usersSignal()
}
set users(users: User[]) {
this.usersSignal.set(users)
}
@Output() @Output()
public confirmClicked = new EventEmitter() public confirmClicked = new EventEmitter()
@Input() @Input()
get title(): string { title = $localize`Set permissions`
return this.titleSignal()
}
set title(title: string) {
this.titleSignal.set(title)
}
@Input() @Input()
get note(): string { note: string = null
return this.noteSignal()
}
set note(note: string) {
this.noteSignal.set(note)
}
@Input() @Input()
set object(o: ObjectWithPermissions) { set object(o: ObjectWithPermissions) {
@@ -95,13 +65,7 @@ export class PermissionsDialogComponent {
merge: new FormControl(true), merge: new FormControl(true),
}) })
get buttonsEnabled(): boolean { buttonsEnabled: boolean = true
return this.buttonsEnabledSignal()
}
set buttonsEnabled(buttonsEnabled: boolean) {
this.buttonsEnabledSignal.set(buttonsEnabled)
}
get permissions() { get permissions() {
return { return {
@@ -64,9 +64,9 @@ describe('PermissionsFilterDropdownComponent', () => {
{ {
provide: SettingsService, provide: SettingsService,
useValue: { useValue: {
currentUser: () => ({ currentUser: {
id: currentUserID, id: currentUserID,
}), },
}, },
}, },
provideHttpClient(withInterceptorsFromDi()), provideHttpClient(withInterceptorsFromDi()),
@@ -117,12 +117,12 @@ export class PermissionsFilterDropdownComponent extends ComponentWithPermissions
if (this.selectionModel.ownerFilter === OwnerFilterType.SELF) { if (this.selectionModel.ownerFilter === OwnerFilterType.SELF) {
this.selectionModel.includeUsers = [] this.selectionModel.includeUsers = []
this.selectionModel.excludeUsers = [] this.selectionModel.excludeUsers = []
this.selectionModel.userID = this.settingsService.currentUser().id this.selectionModel.userID = this.settingsService.currentUser.id
this.selectionModel.hideUnowned = false this.selectionModel.hideUnowned = false
} else if (this.selectionModel.ownerFilter === OwnerFilterType.NOT_SELF) { } else if (this.selectionModel.ownerFilter === OwnerFilterType.NOT_SELF) {
this.selectionModel.userID = null this.selectionModel.userID = null
this.selectionModel.includeUsers = [] this.selectionModel.includeUsers = []
this.selectionModel.excludeUsers = [this.settingsService.currentUser().id] this.selectionModel.excludeUsers = [this.settingsService.currentUser.id]
this.selectionModel.hideUnowned = false this.selectionModel.hideUnowned = false
} else if (this.selectionModel.ownerFilter === OwnerFilterType.NONE) { } else if (this.selectionModel.ownerFilter === OwnerFilterType.NONE) {
this.selectionModel.userID = null this.selectionModel.userID = null
@@ -132,7 +132,7 @@ export class PermissionsFilterDropdownComponent extends ComponentWithPermissions
} else if ( } else if (
this.selectionModel.ownerFilter === OwnerFilterType.SHARED_BY_ME this.selectionModel.ownerFilter === OwnerFilterType.SHARED_BY_ME
) { ) {
this.selectionModel.userID = this.settingsService.currentUser()?.id this.selectionModel.userID = this.settingsService.currentUser.id
this.selectionModel.includeUsers = [] this.selectionModel.includeUsers = []
this.selectionModel.excludeUsers = [] this.selectionModel.excludeUsers = []
this.selectionModel.hideUnowned = false this.selectionModel.hideUnowned = false
@@ -7,38 +7,38 @@
<div class="modal-body"> <div class="modal-body">
<div class="row"> <div class="row">
<div class="col-12 col-md-6"> <div class="col-12 col-md-6">
<pngx-input-text i18n-title title="Email" formControlName="email" (keyup)="onEmailKeyUp($event)" [error]="error()?.email"></pngx-input-text> <pngx-input-text i18n-title title="Email" formControlName="email" (keyup)="onEmailKeyUp($event)" [error]="error?.email"></pngx-input-text>
<div ngbAccordion> <div ngbAccordion>
<div ngbAccordionItem="first" [collapsed]="!showEmailConfirm()" class="border-0 bg-transparent"> <div ngbAccordionItem="first" [collapsed]="!showEmailConfirm" class="border-0 bg-transparent">
<div ngbAccordionCollapse> <div ngbAccordionCollapse>
<div ngbAccordionBody class="p-0 pb-3"> <div ngbAccordionBody class="p-0 pb-3">
<pngx-input-text i18n-title title="Confirm Email" formControlName="email_confirm" (keyup)="onEmailConfirmKeyUp($event)" autocomplete="email" [error]="error()?.email_confirm"></pngx-input-text> <pngx-input-text i18n-title title="Confirm Email" formControlName="email_confirm" (keyup)="onEmailConfirmKeyUp($event)" autocomplete="email" [error]="error?.email_confirm"></pngx-input-text>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<pngx-input-password i18n-title title="Password" formControlName="password" (keyup)="onPasswordKeyUp($event)" [showReveal]="true" autocomplete="current-password" [error]="error()?.password"></pngx-input-password> <pngx-input-password i18n-title title="Password" formControlName="password" (keyup)="onPasswordKeyUp($event)" [showReveal]="true" autocomplete="current-password" [error]="error?.password"></pngx-input-password>
<div ngbAccordion> <div ngbAccordion>
<div ngbAccordionItem="first" [collapsed]="!showPasswordConfirm()" class="border-0 bg-transparent"> <div ngbAccordionItem="first" [collapsed]="!showPasswordConfirm" class="border-0 bg-transparent">
<div ngbAccordionCollapse> <div ngbAccordionCollapse>
<div ngbAccordionBody class="p-0 pb-3"> <div ngbAccordionBody class="p-0 pb-3">
<pngx-input-password i18n-title title="Confirm Password" formControlName="password_confirm" (keyup)="onPasswordConfirmKeyUp($event)" autocomplete="new-password" [error]="error()?.password_confirm"></pngx-input-password> <pngx-input-password i18n-title title="Confirm Password" formControlName="password_confirm" (keyup)="onPasswordConfirmKeyUp($event)" autocomplete="new-password" [error]="error?.password_confirm"></pngx-input-password>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
<pngx-input-text i18n-title title="First name" formControlName="first_name" [error]="error()?.first_name"></pngx-input-text> <pngx-input-text i18n-title title="First name" formControlName="first_name" [error]="error?.first_name"></pngx-input-text>
<pngx-input-text i18n-title title="Last name" formControlName="last_name" [error]="error()?.first_name"></pngx-input-text> <pngx-input-text i18n-title title="Last name" formControlName="last_name" [error]="error?.first_name"></pngx-input-text>
<div class="mb-3"> <div class="mb-3">
<label class="form-label" i18n>API Auth Token</label> <label class="form-label" i18n>API Auth Token</label>
<div class="position-relative"> <div class="position-relative">
<div class="input-group"> <div class="input-group">
<input type="text" class="form-control" formControlName="auth_token" readonly> <input type="text" class="form-control" formControlName="auth_token" readonly>
<button type="button" class="btn btn-outline-secondary" (click)="copyAuthToken()" i18n-title title="Copy"> <button type="button" class="btn btn-outline-secondary" (click)="copyAuthToken()" i18n-title title="Copy">
@if (!copied()) { @if (!copied) {
<i-bs width="1em" height="1em" name="clipboard-fill"></i-bs> <i-bs width="1em" height="1em" name="clipboard-fill"></i-bs>
} }
@if (copied()) { @if (copied) {
<i-bs width="1em" height="1em" name="clipboard-check-fill"></i-bs> <i-bs width="1em" height="1em" name="clipboard-check-fill"></i-bs>
} }
<span class="visually-hidden" i18n>Copy</span> <span class="visually-hidden" i18n>Copy</span>
@@ -51,7 +51,7 @@
(confirm)="generateAuthToken()"> (confirm)="generateAuthToken()">
</pngx-confirm-button> </pngx-confirm-button>
</div> </div>
<span class="badge copied-badge bg-primary small fade ms-4 position-absolute top-50 translate-middle-y pe-none z-3" [class.show]="copied()" i18n>Copied!</span> <span class="badge copied-badge bg-primary small fade ms-4 position-absolute top-50 translate-middle-y pe-none z-3" [class.show]="copied" i18n>Copied!</span>
</div> </div>
<div class="form-text text-muted text-end fst-italic" i18n>Warning: changing the token cannot be undone</div> <div class="form-text text-muted text-end fst-italic" i18n>Warning: changing the token cannot be undone</div>
</div> </div>
@@ -155,10 +155,10 @@
} }
</ul> </ul>
<button type="button" class="btn btn-sm btn-outline-secondary ms-2" (click)="copyRecoveryCodes()" i18n-title title="Copy"> <button type="button" class="btn btn-sm btn-outline-secondary ms-2" (click)="copyRecoveryCodes()" i18n-title title="Copy">
@if (!codesCopied()) { @if (!codesCopied) {
<i-bs width="1em" height="1em" name="clipboard-fill" class="me-1"></i-bs><span i18n>Copy codes</span> <i-bs width="1em" height="1em" name="clipboard-fill" class="me-1"></i-bs><span i18n>Copy codes</span>
} }
@if (codesCopied()) { @if (codesCopied) {
<i-bs width="1em" height="1em" name="clipboard-check-fill" class="text-primary me-1"></i-bs><span class="text-primary" i18n>Copied!</span> <i-bs width="1em" height="1em" name="clipboard-check-fill" class="text-primary me-1"></i-bs><span class="text-primary" i18n>Copied!</span>
} }
</button> </button>
@@ -229,9 +229,9 @@ describe('ProfileEditDialogComponent', () => {
const copySpy = jest.spyOn(clipboard, 'copy') const copySpy = jest.spyOn(clipboard, 'copy')
component.copyAuthToken() component.copyAuthToken()
expect(copySpy).toHaveBeenCalledWith(profile.auth_token) expect(copySpy).toHaveBeenCalledWith(profile.auth_token)
expect(component.copied()).toBeTruthy() expect(component.copied).toBeTruthy()
tick(3000) tick(3000)
expect(component.copied()).toBeFalsy() expect(component.copied).toBeFalsy()
})) }))
it('should support generate token, display error if needed', () => { it('should support generate token, display error if needed', () => {
@@ -1,5 +1,5 @@
import { Clipboard } from '@angular/cdk/clipboard' import { Clipboard } from '@angular/cdk/clipboard'
import { Component, OnInit, inject, signal } from '@angular/core' import { Component, OnInit, inject } from '@angular/core'
import { import {
FormControl, FormControl,
FormGroup, FormGroup,
@@ -50,12 +50,8 @@ export class ProfileEditDialogComponent
private toastService = inject(ToastService) private toastService = inject(ToastService)
private clipboard = inject(Clipboard) private clipboard = inject(Clipboard)
readonly networkActive = signal(false) public networkActive: boolean = false
readonly error = signal<any>(undefined) public error: any
readonly showPasswordConfirm = signal(false)
readonly showEmailConfirm = signal(false)
readonly copied = signal(false)
readonly codesCopied = signal(false)
public form = new FormGroup({ public form = new FormGroup({
email: new FormControl(''), email: new FormControl(''),
@@ -71,18 +67,23 @@ export class ProfileEditDialogComponent
private currentPassword: string private currentPassword: string
private newPassword: string private newPassword: string
private passwordConfirm: string private passwordConfirm: string
public showPasswordConfirm: boolean = false
public hasUsablePassword: boolean = false public hasUsablePassword: boolean = false
private currentEmail: string private currentEmail: string
private newEmail: string private newEmail: string
private emailConfirm: string private emailConfirm: string
public showEmailConfirm: boolean = false
public isTotpEnabled: boolean = false public isTotpEnabled: boolean = false
public totpSettings: TotpSettings public totpSettings: TotpSettings
public totpSettingsLoading: boolean = false public totpSettingsLoading: boolean = false
public totpLoading: boolean = false public totpLoading: boolean = false
public recoveryCodes: string[] public recoveryCodes: string[]
public copied: boolean = false
public codesCopied: boolean = false
public socialAccounts: SocialAccount[] = [] public socialAccounts: SocialAccount[] = []
public socialAccountProviders: SocialAccountProvider[] = [] public socialAccountProviders: SocialAccountProvider[] = []
@@ -94,12 +95,12 @@ export class ProfileEditDialogComponent
} }
ngOnInit(): void { ngOnInit(): void {
this.networkActive.set(true) this.networkActive = true
this.profileService this.profileService
.get() .get()
.pipe(takeUntil(this.unsubscribeNotifier)) .pipe(takeUntil(this.unsubscribeNotifier))
.subscribe((profile) => { .subscribe((profile) => {
this.networkActive.set(false) this.networkActive = false
this.form.patchValue(profile) this.form.patchValue(profile)
this.currentEmail = profile.email this.currentEmail = profile.email
this.form.get('email').valueChanges.subscribe((newEmail) => { this.form.get('email').valueChanges.subscribe((newEmail) => {
@@ -125,7 +126,7 @@ export class ProfileEditDialogComponent
} }
get saveDisabled(): boolean { get saveDisabled(): boolean {
return this.error()?.password_confirm || this.error()?.email_confirm return this.error?.password_confirm || this.error?.email_confirm
} }
onEmailKeyUp(event: KeyboardEvent): void { onEmailKeyUp(event: KeyboardEvent): void {
@@ -139,17 +140,18 @@ export class ProfileEditDialogComponent
} }
onEmailChange(): void { onEmailChange(): void {
this.showEmailConfirm.set(this.currentEmail !== this.newEmail) this.showEmailConfirm = this.currentEmail !== this.newEmail
if (this.showEmailConfirm()) { if (this.showEmailConfirm) {
this.form.get('email_confirm').enable() this.form.get('email_confirm').enable()
if (this.newEmail !== this.emailConfirm) { if (this.newEmail !== this.emailConfirm) {
this.setFieldError('email_confirm', $localize`Emails must match`) if (!this.error) this.error = {}
this.error.email_confirm = $localize`Emails must match`
} else { } else {
this.clearFieldError('email_confirm') delete this.error?.email_confirm
} }
} else { } else {
this.form.get('email_confirm').disable() this.form.get('email_confirm').disable()
this.clearFieldError('email_confirm') delete this.error?.email_confirm
} }
} }
@@ -165,42 +167,29 @@ export class ProfileEditDialogComponent
} }
onPasswordChange(): void { onPasswordChange(): void {
this.showPasswordConfirm.set(this.currentPassword !== this.newPassword) this.showPasswordConfirm = this.currentPassword !== this.newPassword
if (this.showPasswordConfirm()) { if (this.showPasswordConfirm) {
this.form.get('password_confirm').enable() this.form.get('password_confirm').enable()
if (this.newPassword !== this.passwordConfirm) { if (this.newPassword !== this.passwordConfirm) {
this.setFieldError('password_confirm', $localize`Passwords must match`) if (!this.error) this.error = {}
this.error.password_confirm = $localize`Passwords must match`
} else { } else {
this.clearFieldError('password_confirm') delete this.error?.password_confirm
} }
} else { } else {
this.form.get('password_confirm').disable() this.form.get('password_confirm').disable()
this.clearFieldError('password_confirm') delete this.error?.password_confirm
} }
} }
private setFieldError(fieldName: string, message: string): void {
this.error.set({
...(this.error() ?? {}),
[fieldName]: message,
})
}
private clearFieldError(fieldName: string): void {
if (!this.error()) return
const error = { ...this.error() }
delete error[fieldName]
this.error.set(Object.keys(error).length ? error : undefined)
}
save(): void { save(): void {
const passwordChanged = const passwordChanged =
this.newPassword && this.currentPassword !== this.newPassword this.newPassword && this.currentPassword !== this.newPassword
const profile = Object.assign({}, this.form.value) const profile = Object.assign({}, this.form.value)
delete profile.totp_code delete profile.totp_code
this.error.set(null) this.error = null
this.networkActive.set(true) this.networkActive = true
this.profileService this.profileService
.update(profile) .update(profile)
.pipe(takeUntil(this.unsubscribeNotifier)) .pipe(takeUntil(this.unsubscribeNotifier))
@@ -221,8 +210,8 @@ export class ProfileEditDialogComponent
}, },
error: (error) => { error: (error) => {
this.toastService.showError($localize`Error saving profile`, error) this.toastService.showError($localize`Error saving profile`, error)
this.error.set(error?.error) this.error = error?.error
this.networkActive.set(false) this.networkActive = false
}, },
}) })
} }
@@ -247,9 +236,9 @@ export class ProfileEditDialogComponent
copyAuthToken(): void { copyAuthToken(): void {
this.clipboard.copy(this.form.get('auth_token').value) this.clipboard.copy(this.form.get('auth_token').value)
this.copied.set(true) this.copied = true
setTimeout(() => { setTimeout(() => {
this.copied.set(false) this.copied = false
}, 3000) }, 3000)
} }
@@ -341,9 +330,9 @@ export class ProfileEditDialogComponent
public copyRecoveryCodes(): void { public copyRecoveryCodes(): void {
this.clipboard.copy(this.recoveryCodes.join('\n')) this.clipboard.copy(this.recoveryCodes.join('\n'))
this.codesCopied.set(true) this.codesCopied = true
setTimeout(() => { setTimeout(() => {
this.codesCopied.set(false) this.codesCopied = false
}, 3000) }, 3000)
} }
} }
@@ -119,7 +119,7 @@
class="btn btn-primary btn-sm d-inline-flex align-items-center gap-2 text-nowrap" class="btn btn-primary btn-sm d-inline-flex align-items-center gap-2 text-nowrap"
(click)="submit()" (click)="submit()"
[disabled]="loading || !buttonsEnabled"> [disabled]="loading || !buttonsEnabled">
@if (loading()) { @if (loading) {
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span> <span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
} }
{{ btnCaption }} {{ btnCaption }}
@@ -1,6 +1,6 @@
import { Clipboard } from '@angular/cdk/clipboard' import { Clipboard } from '@angular/cdk/clipboard'
import { CommonModule } from '@angular/common' import { CommonModule } from '@angular/common'
import { Component, Input, inject, signal } from '@angular/core' import { Component, Input, inject } from '@angular/core'
import { FormBuilder, FormGroup, ReactiveFormsModule } from '@angular/forms' import { FormBuilder, FormGroup, ReactiveFormsModule } from '@angular/forms'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons' import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { Document } from 'src/app/data/document' import { Document } from 'src/app/data/document'
@@ -38,27 +38,10 @@ export class ShareLinkBundleDialogComponent extends ConfirmDialogComponent {
private readonly clipboard = inject(Clipboard) private readonly clipboard = inject(Clipboard)
private readonly toastService = inject(ToastService) private readonly toastService = inject(ToastService)
private documentsSignal = signal<Document[]>([]) private _documents: Document[] = []
private selectionCountSignal = signal(0)
private documentPreviewSignal = signal<Document[]>([])
private copiedSignal = signal(false)
get selectionCount(): number {
return this.selectionCountSignal()
}
get documentPreview(): Document[] {
return this.documentPreviewSignal()
}
get copied(): boolean {
return this.copiedSignal()
}
set copied(copied: boolean) {
this.copiedSignal.set(copied)
}
selectionCount = 0
documentPreview: Document[] = []
form: FormGroup = this.formBuilder.group({ form: FormGroup = this.formBuilder.group({
shareArchiveVersion: true, shareArchiveVersion: true,
expirationDays: [7], expirationDays: [7],
@@ -68,28 +51,28 @@ export class ShareLinkBundleDialogComponent extends ConfirmDialogComponent {
readonly expirationOptions = SHARE_LINK_EXPIRATION_OPTIONS readonly expirationOptions = SHARE_LINK_EXPIRATION_OPTIONS
createdBundle: ShareLinkBundleSummary | null = null createdBundle: ShareLinkBundleSummary | null = null
copied = false
onOpenManage?: () => void onOpenManage?: () => void
readonly statuses = ShareLinkBundleStatus readonly statuses = ShareLinkBundleStatus
constructor() { constructor() {
super() super()
this.loading.set(false) this.loading = false
this.title = $localize`Create share link bundle` this.title = $localize`Create share link bundle`
this.btnCaption = $localize`Create link` this.btnCaption = $localize`Create link`
} }
@Input() @Input()
set documents(docs: Document[]) { set documents(docs: Document[]) {
const documents = docs.concat() this._documents = docs.concat()
this.documentsSignal.set(documents) this.selectionCount = this._documents.length
this.selectionCountSignal.set(documents.length) this.documentPreview = this._documents.slice(0, 10)
this.documentPreviewSignal.set(documents.slice(0, 10))
} }
submit() { submit() {
if (this.createdBundle) return if (this.createdBundle) return
this.payload = { this.payload = {
document_ids: this.documentsSignal().map((doc) => doc.id), document_ids: this._documents.map((doc) => doc.id),
file_version: this.form.value.shareArchiveVersion file_version: this.form.value.shareArchiveVersion
? FileVersion.Archive ? FileVersion.Archive
: FileVersion.Original, : FileVersion.Original,
@@ -4,7 +4,7 @@
</div> </div>
<div class="modal-body"> <div class="modal-body">
@if (loading()) { @if (loading) {
<div class="d-flex align-items-center gap-2"> <div class="d-flex align-items-center gap-2">
<div class="spinner-border spinner-border-sm" role="status"></div> <div class="spinner-border spinner-border-sm" role="status"></div>
<span i18n>Loading share link bundles…</span> <span i18n>Loading share link bundles…</span>
@@ -97,7 +97,7 @@ describe('ShareLinkBundleManageDialogComponent', () => {
expect(service.listAllBundles).toHaveBeenCalledTimes(1) expect(service.listAllBundles).toHaveBeenCalledTimes(1)
expect(component.bundles).toEqual(bundles) expect(component.bundles).toEqual(bundles)
expect(component.loading()).toBe(false) expect(component.loading).toBe(false)
expect(component.error).toBeNull() expect(component.error).toBeNull()
tick(5000) tick(5000)
@@ -115,7 +115,7 @@ describe('ShareLinkBundleManageDialogComponent', () => {
expect(component.error).toContain('Failed to load share link bundles.') expect(component.error).toContain('Failed to load share link bundles.')
expect(toastService.showError).toHaveBeenCalled() expect(toastService.showError).toHaveBeenCalled()
expect(component.loading()).toBe(false) expect(component.loading).toBe(false)
tick(5000) tick(5000)
expect(service.listAllBundles).toHaveBeenCalledTimes(2) expect(service.listAllBundles).toHaveBeenCalledTimes(2)
@@ -165,7 +165,7 @@ describe('ShareLinkBundleManageDialogComponent', () => {
expect.stringContaining('deleted.') expect.stringContaining('deleted.')
) )
expect(service.listAllBundles).toHaveBeenCalledTimes(2) expect(service.listAllBundles).toHaveBeenCalledTimes(2)
expect(component.loading()).toBe(false) expect(component.loading).toBe(false)
})) }))
it('handles delete errors gracefully', fakeAsync(() => { it('handles delete errors gracefully', fakeAsync(() => {
@@ -179,7 +179,7 @@ describe('ShareLinkBundleManageDialogComponent', () => {
tick() tick()
expect(toastService.showError).toHaveBeenCalled() expect(toastService.showError).toHaveBeenCalled()
expect(component.loading()).toBe(false) expect(component.loading).toBe(false)
})) }))
it('retries bundle build and replaces existing entry', fakeAsync(() => { it('retries bundle build and replaces existing entry', fakeAsync(() => {

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