mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-07-28 06:44:57 +00:00
Compare commits
146
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c573fa61cb | ||
|
|
5d0fb476cf | ||
|
|
e3d8bcb102 | ||
|
|
9558ede894 | ||
|
|
437a219658 | ||
|
|
396c0b539c | ||
|
|
50dec14653 | ||
|
|
cc4917efec | ||
|
|
672fc6a0d4 | ||
|
|
943d322196 | ||
|
|
4d956fa13b | ||
|
|
3963581af0 | ||
|
|
581fbab8f3 | ||
|
|
7044d8ae5f | ||
|
|
5e21ba891d | ||
|
|
c2c30541bb | ||
|
|
1ea9775ab4 | ||
|
|
02b97fbaaf | ||
|
|
0e538acdef | ||
|
|
7f58d16758 | ||
|
|
344414bb47 | ||
|
|
8263f75643 | ||
|
|
72d031a7e2 | ||
|
|
1a253d0f26 | ||
|
|
dc4cb829a6 | ||
|
|
cad2d2d151 | ||
|
|
8db09d7c92 | ||
|
|
b02a151e45 | ||
|
|
0083443e77 | ||
|
|
2cd9b94c43 | ||
|
|
b76bd9a7bb | ||
|
|
87a9fcadc8 | ||
|
|
1c4f6b6144 | ||
|
|
2d985961b0 | ||
|
|
08a2186de1 | ||
|
|
a89fdfb033 | ||
|
|
c0fc6c0475 | ||
|
|
28f291c503 | ||
|
|
8263460b7b | ||
|
|
f4e997e524 | ||
|
|
1944208de4 | ||
|
|
db14bd6b61 | ||
|
|
f41181f992 | ||
|
|
b4729ec5d2 | ||
|
|
3cd772d8e9 | ||
|
|
1203203e08 | ||
|
|
6cf3c47814 | ||
|
|
24aee98ad5 | ||
|
|
2ab1ac7cb8 | ||
|
|
c2d9c52cde | ||
|
|
cdb11b94b5 | ||
|
|
cfd7a3a7f0 | ||
|
|
c6329f4b76 | ||
|
|
5e06987102 | ||
|
|
3f93557934 | ||
|
|
afe6ad2192 | ||
|
|
a3f9fa0369 | ||
|
|
e106618c39 | ||
|
|
d113826cf8 | ||
|
|
c063b3799f | ||
|
|
81795bc93a | ||
|
|
6664f05543 | ||
|
|
8aab34ea73 | ||
|
|
8404198ec8 | ||
|
|
d8d8872414 | ||
|
|
ff609c2987 | ||
|
|
2b784e709b | ||
|
|
c9716252f0 | ||
|
|
5cf9152a40 | ||
|
|
80210bd3bf | ||
|
|
4e52dd5710 | ||
|
|
d34ef75786 | ||
|
|
b8c424c8e1 | ||
|
|
ec8991c2ec | ||
|
|
4f6d9fa93f | ||
|
|
2180b21c41 | ||
|
|
99bdfdfe7a | ||
|
|
73a0586a75 | ||
|
|
dcba44ad78 | ||
|
|
ed54f1a8b9 | ||
|
|
f8a641b402 | ||
|
|
e85223929c | ||
|
|
f2e3304576 | ||
|
|
6938fb0e01 | ||
|
|
255c214d0b | ||
|
|
a0ab05a9a6 | ||
|
|
df1ddb15cc | ||
|
|
71557d7c64 | ||
|
|
66f5111dc1 | ||
|
|
0addb448f5 | ||
|
|
5428ea5caa | ||
|
|
2de2dfdb81 | ||
|
|
f0d934259c | ||
|
|
735c3b073a | ||
|
|
e2cf3633fe | ||
|
|
c0589a9ce9 | ||
|
|
2135572adb | ||
|
|
697e262b5d | ||
|
|
5a65c4448d | ||
|
|
370a0a29ba | ||
|
|
536007f42b | ||
|
|
71e2dd4b08 | ||
|
|
dabd7826d0 | ||
|
|
054b234739 | ||
|
|
77dd4d1b0c | ||
|
|
56dc3e426e | ||
|
|
be8890cfaf | ||
|
|
e03b09e2ea | ||
|
|
5589a584b6 | ||
|
|
19960cc4fa | ||
|
|
ce844c2e5a | ||
|
|
106b41a15c | ||
|
|
f244442c65 | ||
|
|
e145fe8cc7 | ||
|
|
fce55a1609 | ||
|
|
1dabd2601d | ||
|
|
31d28d87db | ||
|
|
88f69841b3 | ||
|
|
8ad321b743 | ||
|
|
ea1102b7f2 | ||
|
|
627875b1cf | ||
|
|
7ffaba6aac | ||
|
|
fe81f3ee98 | ||
|
|
ed9372c808 | ||
|
|
b804da0140 | ||
|
|
5b8fbdcec7 | ||
|
|
67972a0740 | ||
|
|
beb048b94a | ||
|
|
a8bfa25efd | ||
|
|
00baacb26c | ||
|
|
b33d11778a | ||
|
|
63c19e7f75 | ||
|
|
bf70e597ee | ||
|
|
78824665aa | ||
|
|
e75946847e | ||
|
|
20a220ef6c | ||
|
|
e83996135a | ||
|
|
b0227dd080 | ||
|
|
40d927a9ff | ||
|
|
fd0168ac6f | ||
|
|
82aefe5870 | ||
|
|
1bef142fd6 | ||
|
|
f56f29111c | ||
|
|
e40e9eb0f9 | ||
|
|
0ec6610475 | ||
|
|
05bf334d37 |
@@ -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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
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@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
|
||||||
with:
|
with:
|
||||||
python-version: "${{ matrix.python-version }}"
|
python-version: "${{ matrix.python-version }}"
|
||||||
- name: Install uv
|
- name: Install uv
|
||||||
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.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@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0
|
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
|
||||||
with:
|
with:
|
||||||
flags: backend-python-${{ matrix.python-version }}
|
flags: backend-python-${{ matrix.python-version }}
|
||||||
files: junit.xml
|
files: junit.xml
|
||||||
report_type: test_results
|
report_type: test_results
|
||||||
- name: Upload coverage to Codecov
|
- name: Upload coverage to Codecov
|
||||||
uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0
|
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
|
||||||
with:
|
with:
|
||||||
flags: backend-python-${{ matrix.python-version }}
|
flags: backend-python-${{ matrix.python-version }}
|
||||||
files: coverage.xml
|
files: coverage.xml
|
||||||
@@ -169,16 +169,16 @@ jobs:
|
|||||||
PAPERLESS_SECRET_KEY: "ci-typing-not-a-real-secret"
|
PAPERLESS_SECRET_KEY: "ci-typing-not-a-real-secret"
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
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@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
|
||||||
with:
|
with:
|
||||||
python-version: "${{ env.DEFAULT_PYTHON }}"
|
python-version: "${{ env.DEFAULT_PYTHON }}"
|
||||||
- name: Install uv
|
- name: Install uv
|
||||||
uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.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@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||||
with:
|
with:
|
||||||
path: .mypy_cache
|
path: .mypy_cache
|
||||||
# Keyed by OS, Python version, and dependency hashes
|
# Keyed by OS, Python version, and dependency hashes
|
||||||
|
|||||||
@@ -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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
- name: Determine ref name
|
- name: Determine ref name
|
||||||
@@ -106,9 +106,9 @@ jobs:
|
|||||||
echo "repository=${repo_name}"
|
echo "repository=${repo_name}"
|
||||||
echo "name=${repo_name}" >> $GITHUB_OUTPUT
|
echo "name=${repo_name}" >> $GITHUB_OUTPUT
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
|
||||||
- name: Login to GitHub Container Registry
|
- name: Login to GitHub Container Registry
|
||||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||||
with:
|
with:
|
||||||
registry: ${{ env.REGISTRY }}
|
registry: ${{ env.REGISTRY }}
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
@@ -121,7 +121,7 @@ jobs:
|
|||||||
sudo rm -rf "$AGENT_TOOLSDIRECTORY"
|
sudo rm -rf "$AGENT_TOOLSDIRECTORY"
|
||||||
- name: Docker metadata
|
- name: Docker metadata
|
||||||
id: docker-meta
|
id: docker-meta
|
||||||
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
|
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
|
||||||
with:
|
with:
|
||||||
images: |
|
images: |
|
||||||
${{ env.REGISTRY }}/${{ steps.repo.outputs.name }}
|
${{ env.REGISTRY }}/${{ steps.repo.outputs.name }}
|
||||||
@@ -132,7 +132,7 @@ jobs:
|
|||||||
type=semver,pattern={{major}}.{{minor}}
|
type=semver,pattern={{major}}.{{minor}}
|
||||||
- name: Build and push by digest
|
- name: Build and push by digest
|
||||||
id: build
|
id: build
|
||||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: ./Dockerfile
|
file: ./Dockerfile
|
||||||
@@ -182,29 +182,29 @@ jobs:
|
|||||||
echo "Downloaded digests:"
|
echo "Downloaded digests:"
|
||||||
ls -la /tmp/digests/
|
ls -la /tmp/digests/
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@4d04d5d9486b7bd6fa91e7baf45bbb4f8b9deedd # v4.0.0
|
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
|
||||||
- name: Login to GitHub Container Registry
|
- name: Login to GitHub Container Registry
|
||||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||||
with:
|
with:
|
||||||
registry: ${{ env.REGISTRY }}
|
registry: ${{ env.REGISTRY }}
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
- name: Login to Docker Hub
|
- name: Login to Docker Hub
|
||||||
if: needs.build-arch.outputs.push-external == 'true'
|
if: needs.build-arch.outputs.push-external == 'true'
|
||||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
- name: Login to Quay.io
|
- name: Login to Quay.io
|
||||||
if: needs.build-arch.outputs.push-external == 'true'
|
if: needs.build-arch.outputs.push-external == 'true'
|
||||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
||||||
with:
|
with:
|
||||||
registry: quay.io
|
registry: quay.io
|
||||||
username: ${{ secrets.QUAY_USERNAME }}
|
username: ${{ secrets.QUAY_USERNAME }}
|
||||||
password: ${{ secrets.QUAY_ROBOT_TOKEN }}
|
password: ${{ secrets.QUAY_ROBOT_TOKEN }}
|
||||||
- name: Docker metadata
|
- name: Docker metadata
|
||||||
id: docker-meta
|
id: docker-meta
|
||||||
uses: docker/metadata-action@030e881283bb7a6894de51c315a6bfe6a94e05cf # v6.0.0
|
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
|
||||||
with:
|
with:
|
||||||
images: |
|
images: |
|
||||||
${{ env.REGISTRY }}/${{ needs.build-arch.outputs.repository }}
|
${{ env.REGISTRY }}/${{ needs.build-arch.outputs.repository }}
|
||||||
|
|||||||
@@ -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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
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@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.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@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
|
||||||
with:
|
with:
|
||||||
version: ${{ env.DEFAULT_UV_VERSION }}
|
version: ${{ env.DEFAULT_UV_VERSION }}
|
||||||
enable-cache: true
|
enable-cache: true
|
||||||
|
|||||||
@@ -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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
- name: Install pnpm
|
- name: Install pnpm
|
||||||
uses: pnpm/action-setup@903f9c1a6ebcba6cf41d87230be49611ac97822e # v6.0.3
|
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
||||||
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@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||||
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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
- name: Install pnpm
|
- name: Install pnpm
|
||||||
uses: pnpm/action-setup@903f9c1a6ebcba6cf41d87230be49611ac97822e # v6.0.3
|
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
||||||
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@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||||
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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
- name: Install pnpm
|
- name: Install pnpm
|
||||||
uses: pnpm/action-setup@903f9c1a6ebcba6cf41d87230be49611ac97822e # v6.0.3
|
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
||||||
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@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||||
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@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0
|
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
|
||||||
with:
|
with:
|
||||||
flags: frontend-node-${{ matrix.node-version }}
|
flags: frontend-node-${{ matrix.node-version }}
|
||||||
directory: src-ui/
|
directory: src-ui/
|
||||||
report_type: test_results
|
report_type: test_results
|
||||||
- name: Upload coverage to Codecov
|
- name: Upload coverage to Codecov
|
||||||
uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0
|
uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
|
||||||
with:
|
with:
|
||||||
flags: frontend-node-${{ matrix.node-version }}
|
flags: frontend-node-${{ matrix.node-version }}
|
||||||
directory: src-ui/coverage/
|
directory: src-ui/coverage/
|
||||||
@@ -191,7 +191,7 @@ jobs:
|
|||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
container: mcr.microsoft.com/playwright:v1.60.0-noble
|
container: mcr.microsoft.com/playwright:v1.61.1-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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
- name: Install pnpm
|
- name: Install pnpm
|
||||||
uses: pnpm/action-setup@903f9c1a6ebcba6cf41d87230be49611ac97822e # v6.0.3
|
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
||||||
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@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||||
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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
with:
|
with:
|
||||||
fetch-depth: 2
|
fetch-depth: 2
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
- name: Install pnpm
|
- name: Install pnpm
|
||||||
uses: pnpm/action-setup@903f9c1a6ebcba6cf41d87230be49611ac97822e # v6.0.3
|
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
||||||
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@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
~/.pnpm-store
|
~/.pnpm-store
|
||||||
|
|||||||
@@ -17,12 +17,12 @@ jobs:
|
|||||||
runs-on: ubuntu-slim
|
runs-on: ubuntu-slim
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
- name: Install Python
|
- name: Install Python
|
||||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
|
||||||
with:
|
with:
|
||||||
python-version: "3.14"
|
python-version: "3.14"
|
||||||
- name: Run prek
|
- name: Run prek
|
||||||
uses: j178/prek-action@cbc2f23eb5539cf20d82d1aabd0d0ecbcc56f4e3 # v2.0.2
|
uses: j178/prek-action@bdca6f102f98e2b4c7029491a53dfd366469e33d # v2.0.4
|
||||||
|
|||||||
@@ -20,12 +20,13 @@ jobs:
|
|||||||
statuses: read
|
statuses: read
|
||||||
steps:
|
steps:
|
||||||
- name: Wait for Docker build
|
- name: Wait for Docker build
|
||||||
uses: lewagon/wait-on-check-action@9312864dfbc9fd208e9c0417843430751c042800 # v1.7.0
|
uses: lewagon/wait-on-check-action@96d9100b431964d10e0136aff8b9ccb92470505e # v1.8.0
|
||||||
with:
|
with:
|
||||||
ref: ${{ github.sha }}
|
ref: ${{ github.sha }}
|
||||||
check-name: 'Merge and Push Manifest'
|
check-name: 'Merge and Push Manifest'
|
||||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
wait-interval: 60
|
wait-interval: 60
|
||||||
|
checks-discovery-timeout: 1800
|
||||||
build-release:
|
build-release:
|
||||||
name: Build Release
|
name: Build Release
|
||||||
needs: wait-for-docker
|
needs: wait-for-docker
|
||||||
@@ -34,12 +35,12 @@ jobs:
|
|||||||
contents: read
|
contents: read
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
# ---- Frontend Build ----
|
# ---- Frontend Build ----
|
||||||
- name: Install pnpm
|
- name: Install pnpm
|
||||||
uses: pnpm/action-setup@903f9c1a6ebcba6cf41d87230be49611ac97822e # v6.0.3
|
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
||||||
with:
|
with:
|
||||||
version: 10
|
version: 10
|
||||||
- name: Use Node.js 24
|
- name: Use Node.js 24
|
||||||
@@ -54,11 +55,11 @@ jobs:
|
|||||||
# ---- Backend Setup ----
|
# ---- Backend Setup ----
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
id: setup-python
|
id: setup-python
|
||||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.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@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
|
||||||
with:
|
with:
|
||||||
version: ${{ env.DEFAULT_UV_VERSION }}
|
version: ${{ env.DEFAULT_UV_VERSION }}
|
||||||
enable-cache: false
|
enable-cache: false
|
||||||
@@ -170,7 +171,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@5de93583980a40bd78603b6dfdcda5b4df377b32 # v7.2.0
|
uses: release-drafter/release-drafter@4d75298e00d9e34c483e5ff8c68d0ea1c1940c1e # v7.5.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 +182,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@ee2ae851dc5d938b90075b3ef12c540abfd1ee72 # v1.10.1
|
uses: shogo82148/actions-upload-release-asset@394b3c11c3cfc038b5396ad265c074065cf875c3 # v1.10.2
|
||||||
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 +202,17 @@ jobs:
|
|||||||
pull-requests: write
|
pull-requests: write
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
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@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.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@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
|
||||||
with:
|
with:
|
||||||
version: ${{ env.DEFAULT_UV_VERSION }}
|
version: ${{ env.DEFAULT_UV_VERSION }}
|
||||||
enable-cache: false
|
enable-cache: false
|
||||||
|
|||||||
@@ -22,11 +22,11 @@ jobs:
|
|||||||
security-events: write
|
security-events: write
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
- name: Run zizmor
|
- name: Run zizmor
|
||||||
uses: zizmorcore/zizmor-action@b1d7e1fb5de872772f31590499237e7cce841e8e # v0.5.3
|
uses: zizmorcore/zizmor-action@192e21d79ab29983730a13d1382995c2307fbcaa # v0.5.7
|
||||||
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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
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@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4.35.2
|
uses: github/codeql-action/upload-sarif@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
|
||||||
if: always()
|
if: always()
|
||||||
with:
|
with:
|
||||||
sarif_file: results.sarif
|
sarif_file: results.sarif
|
||||||
|
|||||||
@@ -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@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
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@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4.35.2
|
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
|
||||||
with:
|
with:
|
||||||
languages: ${{ matrix.language }}
|
languages: ${{ matrix.language }}
|
||||||
# If you wish to specify custom queries, you can do so here or in a config file.
|
# If you wish to specify custom queries, you can do so here or in a config file.
|
||||||
@@ -47,4 +47,4 @@ jobs:
|
|||||||
# Prefix the list here with "+" to use these queries and those in the config file.
|
# Prefix the list here with "+" to use these queries and those in the config file.
|
||||||
# queries: ./path/to/local/query, your-org/your-repo/queries@main
|
# queries: ./path/to/local/query, your-org/your-repo/queries@main
|
||||||
- name: Perform CodeQL Analysis
|
- name: Perform CodeQL Analysis
|
||||||
uses: github/codeql-action/analyze@95e58e9a2cdfd71adc6e0353d5c52f41a045d225 # v4.35.2
|
uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
|
||||||
|
|||||||
@@ -17,12 +17,12 @@ jobs:
|
|||||||
environment: translation-sync
|
environment: translation-sync
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
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@8868a33591d21088edfc398968173a3b98d51706 # v2.16.2
|
uses: crowdin/github-action@52aa776766211d83d975df51f3b9c53c2f8ba35f # v2.16.3
|
||||||
with:
|
with:
|
||||||
upload_translations: false
|
upload_translations: false
|
||||||
download_translations: true
|
download_translations: true
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Label PR by file path or branch name
|
- name: Label PR by file path or branch name
|
||||||
# see .github/labeler.yml for the labeler config
|
# see .github/labeler.yml for the labeler config
|
||||||
uses: actions/labeler@634933edcd8ababfe52f92936142cc22ac488b1b # v6.0.1
|
uses: actions/labeler@f27b608878404679385c85cfa523b85ccb86e213 # v6.1.0
|
||||||
with:
|
with:
|
||||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
- name: Label by size
|
- name: Label by size
|
||||||
|
|||||||
@@ -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@5de93583980a40bd78603b6dfdcda5b4df377b32 # v7.2.0
|
uses: release-drafter/release-drafter@4d75298e00d9e34c483e5ff8c68d0ea1c1940c1e # v7.5.1
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ jobs:
|
|||||||
issues: write
|
issues: write
|
||||||
pull-requests: write
|
pull-requests: write
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/stale@b5d41d4e1d5dceea10e7104786b73624c18a190f # v10.2.0
|
- uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0
|
||||||
with:
|
with:
|
||||||
days-before-stale: 7
|
days-before-stale: 7
|
||||||
days-before-close: 14
|
days-before-close: 14
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ jobs:
|
|||||||
contents: write
|
contents: write
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||||
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@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.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@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0
|
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.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@903f9c1a6ebcba6cf41d87230be49611ac97822e # v6.0.3
|
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
||||||
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@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
~/.pnpm-store
|
~/.pnpm-store
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ repos:
|
|||||||
- json
|
- json
|
||||||
# See https://github.com/prettier/prettier/issues/15742 for the fork reason
|
# See https://github.com/prettier/prettier/issues/15742 for the fork reason
|
||||||
- repo: https://github.com/rbubley/mirrors-prettier
|
- repo: https://github.com/rbubley/mirrors-prettier
|
||||||
rev: 'v3.8.3'
|
rev: 'v3.9.4'
|
||||||
hooks:
|
hooks:
|
||||||
- id: prettier
|
- id: prettier
|
||||||
types_or:
|
types_or:
|
||||||
@@ -46,18 +46,19 @@ repos:
|
|||||||
- ts
|
- ts
|
||||||
- markdown
|
- markdown
|
||||||
additional_dependencies:
|
additional_dependencies:
|
||||||
- prettier@3.8.3
|
- prettier@3.9.4
|
||||||
- 'prettier-plugin-organize-imports@4.3.0'
|
- 'prettier-plugin-organize-imports@4.3.0'
|
||||||
# Python hooks
|
# Python hooks
|
||||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||||
rev: v0.15.12
|
rev: v0.15.20
|
||||||
hooks:
|
hooks:
|
||||||
- id: ruff-check
|
- id: ruff-check
|
||||||
- id: ruff-format
|
- id: ruff-format
|
||||||
- repo: https://github.com/tox-dev/pyproject-fmt
|
- repo: https://github.com/tox-dev/pyproject-fmt
|
||||||
rev: "v2.21.1"
|
rev: "v2.25.1"
|
||||||
hooks:
|
hooks:
|
||||||
- id: pyproject-fmt
|
- id: pyproject-fmt
|
||||||
|
additional_dependencies: [tomli]
|
||||||
# Dockerfile hooks
|
# Dockerfile hooks
|
||||||
- repo: https://github.com/AleksaC/hadolint-py
|
- repo: https://github.com/AleksaC/hadolint-py
|
||||||
rev: v2.14.0
|
rev: v2.14.0
|
||||||
|
|||||||
+5
-1
@@ -30,7 +30,7 @@ RUN set -eux \
|
|||||||
# Purpose: Installs s6-overlay and rootfs
|
# Purpose: Installs s6-overlay and rootfs
|
||||||
# Comments:
|
# Comments:
|
||||||
# - Don't leave anything extra in here either
|
# - Don't leave anything extra in here either
|
||||||
FROM ghcr.io/astral-sh/uv:0.11.6-python3.12-trixie-slim AS s6-overlay-base
|
FROM ghcr.io/astral-sh/uv:0.11.28-python3.12-trixie-slim AS s6-overlay-base
|
||||||
|
|
||||||
WORKDIR /usr/src/s6
|
WORKDIR /usr/src/s6
|
||||||
|
|
||||||
@@ -238,6 +238,10 @@ RUN set -eux \
|
|||||||
&& chown --from root:root --changes --recursive paperless:paperless /usr/src/paperless \
|
&& chown --from root:root --changes --recursive paperless:paperless /usr/src/paperless \
|
||||||
&& echo "Making fontconfig cache writable for arbitrary container UIDs" \
|
&& echo "Making fontconfig cache writable for arbitrary container UIDs" \
|
||||||
&& chmod 1777 /var/cache/fontconfig \
|
&& chmod 1777 /var/cache/fontconfig \
|
||||||
|
&& echo "Making /run world-writable for rootless operation" \
|
||||||
|
&& chmod 1777 /run \
|
||||||
|
&& echo "Removing setuid from s6-overlay-suexec for rootless compat" \
|
||||||
|
&& chmod u-s /command/s6-overlay-suexec \
|
||||||
&& echo "Collecting static files" \
|
&& echo "Collecting static files" \
|
||||||
&& PAPERLESS_SECRET_KEY=build-time-dummy s6-setuidgid paperless python3 manage.py collectstatic --clear --no-input --link \
|
&& PAPERLESS_SECRET_KEY=build-time-dummy s6-setuidgid paperless python3 manage.py collectstatic --clear --no-input --link \
|
||||||
&& PAPERLESS_SECRET_KEY=build-time-dummy s6-setuidgid paperless python3 manage.py compilemessages \
|
&& PAPERLESS_SECRET_KEY=build-time-dummy s6-setuidgid paperless python3 manage.py compilemessages \
|
||||||
|
|||||||
@@ -63,6 +63,7 @@ The following are not generally considered vulnerabilities unless accompanied by
|
|||||||
- optional webhook, mail, AI, OCR, or integration behavior described without a product-level vulnerability
|
- optional webhook, mail, AI, OCR, or integration behavior described without a product-level vulnerability
|
||||||
- missing limits or hardening settings presented without concrete impact
|
- missing limits or hardening settings presented without concrete impact
|
||||||
- generic AI or static-analysis output that is not confirmed against the current codebase and a real deployment scenario
|
- generic AI or static-analysis output that is not confirmed against the current codebase and a real deployment scenario
|
||||||
|
- the ability to attach objects that a user cannot access to a document by ID is an intentional design choice, and not considered a vulnerability
|
||||||
|
|
||||||
## Transparency
|
## Transparency
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
# correct networking for the tests
|
# correct networking for the tests
|
||||||
services:
|
services:
|
||||||
gotenberg:
|
gotenberg:
|
||||||
image: docker.io/gotenberg/gotenberg:8.33
|
image: docker.io/gotenberg/gotenberg:8.34
|
||||||
hostname: gotenberg
|
hostname: gotenberg
|
||||||
container_name: gotenberg
|
container_name: gotenberg
|
||||||
network_mode: host
|
network_mode: host
|
||||||
@@ -24,7 +24,7 @@ services:
|
|||||||
network_mode: host
|
network_mode: host
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
greenmail:
|
greenmail:
|
||||||
image: docker.io/greenmail/standalone:2.1.8
|
image: docker.io/greenmail/standalone:2.1.9
|
||||||
hostname: greenmail
|
hostname: greenmail
|
||||||
container_name: greenmail
|
container_name: greenmail
|
||||||
environment:
|
environment:
|
||||||
@@ -35,7 +35,7 @@ services:
|
|||||||
- "3143:3143" # IMAP
|
- "3143:3143" # IMAP
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
nginx:
|
nginx:
|
||||||
image: docker.io/nginx:1.31.1-alpine
|
image: docker.io/nginx:1.31.2-alpine
|
||||||
hostname: nginx
|
hostname: nginx
|
||||||
container_name: nginx
|
container_name: nginx
|
||||||
ports:
|
ports:
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ services:
|
|||||||
PAPERLESS_TIKA_GOTENBERG_ENDPOINT: http://gotenberg:3000
|
PAPERLESS_TIKA_GOTENBERG_ENDPOINT: http://gotenberg:3000
|
||||||
PAPERLESS_TIKA_ENDPOINT: http://tika:9998
|
PAPERLESS_TIKA_ENDPOINT: http://tika:9998
|
||||||
gotenberg:
|
gotenberg:
|
||||||
image: docker.io/gotenberg/gotenberg:8.33
|
image: docker.io/gotenberg/gotenberg:8.34
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
# The gotenberg chromium route is used to convert .eml files. We do not
|
# The gotenberg chromium route is used to convert .eml files. We do not
|
||||||
# want to allow external content like tracking pixels or even javascript.
|
# want to allow external content like tracking pixels or even javascript.
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ services:
|
|||||||
PAPERLESS_TIKA_GOTENBERG_ENDPOINT: http://gotenberg:3000
|
PAPERLESS_TIKA_GOTENBERG_ENDPOINT: http://gotenberg:3000
|
||||||
PAPERLESS_TIKA_ENDPOINT: http://tika:9998
|
PAPERLESS_TIKA_ENDPOINT: http://tika:9998
|
||||||
gotenberg:
|
gotenberg:
|
||||||
image: docker.io/gotenberg/gotenberg:8.33
|
image: docker.io/gotenberg/gotenberg:8.34
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
# The gotenberg chromium route is used to convert .eml files. We do not
|
# The gotenberg chromium route is used to convert .eml files. We do not
|
||||||
# want to allow external content like tracking pixels or even javascript.
|
# want to allow external content like tracking pixels or even javascript.
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ services:
|
|||||||
PAPERLESS_TIKA_GOTENBERG_ENDPOINT: http://gotenberg:3000
|
PAPERLESS_TIKA_GOTENBERG_ENDPOINT: http://gotenberg:3000
|
||||||
PAPERLESS_TIKA_ENDPOINT: http://tika:9998
|
PAPERLESS_TIKA_ENDPOINT: http://tika:9998
|
||||||
gotenberg:
|
gotenberg:
|
||||||
image: docker.io/gotenberg/gotenberg:8.33
|
image: docker.io/gotenberg/gotenberg:8.34
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
# The gotenberg chromium route is used to convert .eml files. We do not
|
# The gotenberg chromium route is used to convert .eml files. We do not
|
||||||
# want to allow external content like tracking pixels or even javascript.
|
# want to allow external content like tracking pixels or even javascript.
|
||||||
|
|||||||
@@ -272,6 +272,8 @@ optional arguments:
|
|||||||
-sm, --split-manifest
|
-sm, --split-manifest
|
||||||
-z, --zip
|
-z, --zip
|
||||||
-zn, --zip-name
|
-zn, --zip-name
|
||||||
|
--zip-compression
|
||||||
|
--zip-compression-level
|
||||||
--data-only
|
--data-only
|
||||||
--no-progress-bar
|
--no-progress-bar
|
||||||
--passphrase
|
--passphrase
|
||||||
@@ -334,6 +336,19 @@ If `-z` or `--zip` is provided, the export will be a zip file
|
|||||||
in the target directory, named according to the current local date or the
|
in the target directory, named according to the current local date or the
|
||||||
value set in `-zn` or `--zip-name`.
|
value set in `-zn` or `--zip-name`.
|
||||||
|
|
||||||
|
The compression method for the zip can be set with `--zip-compression`
|
||||||
|
(`stored`, `deflated` (default), `bzip2`, `lzma`, or `zstd`) and tuned with
|
||||||
|
`--zip-compression-level` (deflated: 0–9, bzip2: 1–9, zstd: -22–22; ignored
|
||||||
|
for `stored` and `lzma`). Both options require `--zip`.
|
||||||
|
|
||||||
|
!!! warning
|
||||||
|
|
||||||
|
`zstd` compression requires Python 3.14 or newer on **both** the machine
|
||||||
|
creating the export and any machine importing it. An archive compressed with
|
||||||
|
`zstd` (or `lzma`/`bzip2` where those modules are unavailable) cannot be
|
||||||
|
imported on a runtime that lacks the codec; the importer will refuse it with
|
||||||
|
a clear error. The default `deflated` is universally readable.
|
||||||
|
|
||||||
If `--data-only` is provided, only the database will be exported. This option is intended
|
If `--data-only` is provided, only the database will be exported. This option is intended
|
||||||
to facilitate database upgrades without needing to clean documents and thumbnails from the media directory.
|
to facilitate database upgrades without needing to clean documents and thumbnails from the media directory.
|
||||||
|
|
||||||
|
|||||||
+926
-1
File diff suppressed because it is too large
Load Diff
+11
-9
@@ -1159,19 +1159,21 @@ still perform some basic text pre-processing before matching.
|
|||||||
|
|
||||||
#### [`PAPERLESS_DATE_PARSER_LANGUAGES=<lang>`](#PAPERLESS_DATE_PARSER_LANGUAGES) {#PAPERLESS_DATE_PARSER_LANGUAGES}
|
#### [`PAPERLESS_DATE_PARSER_LANGUAGES=<lang>`](#PAPERLESS_DATE_PARSER_LANGUAGES) {#PAPERLESS_DATE_PARSER_LANGUAGES}
|
||||||
|
|
||||||
Specifies which language Paperless should use when parsing dates from documents.
|
: Specifies which language Paperless should use when parsing dates from documents.
|
||||||
|
|
||||||
This should be a language code supported by the dateparser library,
|
: This should be a language code supported by the dateparser library,
|
||||||
for example: "en", or a combination such as "en+de".
|
for example: "en", or a combination such as "en+de".
|
||||||
Locales are also supported (e.g., "en-AU").
|
Locales are also supported (e.g., "en-AU").
|
||||||
Multiple languages can be combined using "+", for example: "en+de" or "en-AU+de".
|
Multiple languages can be combined using "+", for example: "en+de" or "en-AU+de".
|
||||||
For valid values, refer to the list of supported languages and locales in the [dateparser documentation](https://dateparser.readthedocs.io/en/latest/supported_locales.html).
|
For valid values, refer to the list of supported languages and locales in the [dateparser documentation](https://dateparser.readthedocs.io/en/latest/supported_locales.html).
|
||||||
|
|
||||||
|
: Set this to match the languages in which most of your documents are written.
|
||||||
|
|
||||||
Set this to match the languages in which most of your documents are written.
|
|
||||||
If not set, Paperless will attempt to infer the language(s) from the OCR configuration (`PAPERLESS_OCR_LANGUAGE`).
|
If not set, Paperless will attempt to infer the language(s) from the OCR configuration (`PAPERLESS_OCR_LANGUAGE`).
|
||||||
|
|
||||||
!!! note
|
!!! note
|
||||||
This format differs from the `PAPERLESS_OCR_LANGUAGE` setting, which uses ISO 639-2 codes (3 letters, e.g., "eng+deu" for Tesseract OCR).
|
|
||||||
|
This format differs from the `PAPERLESS_OCR_LANGUAGE` setting, which uses ISO 639-2 codes (3 letters, e.g., "eng+deu" for Tesseract OCR).
|
||||||
|
|
||||||
#### [`PAPERLESS_EMAIL_TASK_CRON=<cron expression>`](#PAPERLESS_EMAIL_TASK_CRON) {#PAPERLESS_EMAIL_TASK_CRON}
|
#### [`PAPERLESS_EMAIL_TASK_CRON=<cron expression>`](#PAPERLESS_EMAIL_TASK_CRON) {#PAPERLESS_EMAIL_TASK_CRON}
|
||||||
|
|
||||||
|
|||||||
@@ -192,7 +192,7 @@ searchable PDF and stores it as the archive copy. `ARCHIVE_FILE_GENERATION=never
|
|||||||
has no effect for documents handled by the remote parser — the archive is produced
|
has no effect for documents handled by the remote parser — the archive is produced
|
||||||
unconditionally by the remote engine.
|
unconditionally by the remote engine.
|
||||||
|
|
||||||
# Search Index (Whoosh -> Tantivy)
|
## Search Index (Whoosh -> Tantivy)
|
||||||
|
|
||||||
The full-text search backend has been replaced with [Tantivy](https://github.com/quickwit-oss/tantivy).
|
The full-text search backend has been replaced with [Tantivy](https://github.com/quickwit-oss/tantivy).
|
||||||
The index format is incompatible with Whoosh, so **the search index is automatically rebuilt from
|
The index format is incompatible with Whoosh, so **the search index is automatically rebuilt from
|
||||||
@@ -326,3 +326,11 @@ behind a reverse proxy may need to set
|
|||||||
[`PAPERLESS_TRUSTED_PROXIES`](configuration.md#PAPERLESS_TRUSTED_PROXIES),
|
[`PAPERLESS_TRUSTED_PROXIES`](configuration.md#PAPERLESS_TRUSTED_PROXIES),
|
||||||
[`PAPERLESS_ALLAUTH_TRUSTED_CLIENT_IP_HEADER`](configuration.md#PAPERLESS_ALLAUTH_TRUSTED_CLIENT_IP_HEADER),
|
[`PAPERLESS_ALLAUTH_TRUSTED_CLIENT_IP_HEADER`](configuration.md#PAPERLESS_ALLAUTH_TRUSTED_CLIENT_IP_HEADER),
|
||||||
or both, to avoid `403 Forbidden` errors on login.
|
or both, to avoid `403 Forbidden` errors on login.
|
||||||
|
|
||||||
|
## Database Migrations
|
||||||
|
|
||||||
|
Some integer fields have been changed to smaller types to reduce database size. If you have any `MailRule` records with a `maximum_age` greater than 32767, they will be clamped to 32767 during the migration to avoid errors during migration.
|
||||||
|
|
||||||
|
### Action Required
|
||||||
|
|
||||||
|
No user action is required. The migration will automatically clamp any `MailRule.maximum_age` values greater than 32767 to 32767 during the migration process.
|
||||||
|
|||||||
+3
-3
@@ -142,7 +142,7 @@ a [superuser](usage.md#superusers) account.
|
|||||||
|
|
||||||
It is not possible to run the container rootless if additional languages are specified via `PAPERLESS_OCR_LANGUAGES`.
|
It is not possible to run the container rootless if additional languages are specified via `PAPERLESS_OCR_LANGUAGES`.
|
||||||
|
|
||||||
If you want to run Paperless as a rootless container, set `user:` in `docker-compose.yml` to the UID and GID of your host user (use `id -u` and `id -g` to find these values). The container process starts directly as that user with no internal privilege remapping:
|
If you want to run Paperless as a rootless container, set `user:` in `docker-compose.yml` to the UID and GID of your host user (use `id -u` and `id -g` to find these values). The container process starts directly as that user with no internal privilege remapping. Any UID and GID is supported:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
webserver:
|
webserver:
|
||||||
@@ -157,8 +157,8 @@ Do not combine this with `USERMAP_UID` or `USERMAP_GID`, which are intended for
|
|||||||
Some file systems, such as NFS network shares, don't support file system
|
Some file systems, such as NFS network shares, don't support file system
|
||||||
notifications with `inotify`. When the consumption directory is on such a
|
notifications with `inotify`. When the consumption directory is on such a
|
||||||
file system, Paperless-ngx will not pick up new files with the default
|
file system, Paperless-ngx will not pick up new files with the default
|
||||||
configuration. Use [`PAPERLESS_CONSUMER_POLLING`](configuration.md#PAPERLESS_CONSUMER_POLLING)
|
configuration. Set [`PAPERLESS_CONSUMER_POLLING_INTERVAL`](configuration.md#PAPERLESS_CONSUMER_POLLING_INTERVAL)
|
||||||
to enable polling and disable inotify. See [here](configuration.md#polling).
|
to a positive number to enable polling and disable native filesystem notifications.
|
||||||
|
|
||||||
## Bare Metal Install {#bare_metal}
|
## Bare Metal Install {#bare_metal}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,839 @@
|
|||||||
|
# Export Zip Compression Control Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Add `--zip-compression {stored,deflated,bzip2,lzma,zstd}` and `--zip-compression-level N` flags to `document_exporter`, threaded into `ZipExportSink`, with import-side safety for codecs the running Python can't read.
|
||||||
|
|
||||||
|
**Architecture:** A new pure-data module `documents/export/compression.py` owns the method↔constant map, per-method level bounds, the runtime availability probe, and a compress-type readability check. `ZipExportSink` gains `compression`/`compresslevel` constructor params. The command validates flags up front (fail-fast `CommandError`) and constructs the sink; the importer pre-checks entry compress types before extracting.
|
||||||
|
|
||||||
|
**Tech Stack:** Python ≥3.11 (zstd only on 3.14+), `zipfile`, `compression.zstd` (PEP 784), pytest + pytest-mock + factory-boy. Backend tests run on the Linux VM (Python 3.11 — zstd positive tests are `skipif`-guarded); `ruff` runs locally.
|
||||||
|
|
||||||
|
**Spec:** `docs/superpowers/specs/2026-06-16-export-zip-compression-design.md`
|
||||||
|
|
||||||
|
**PREREQUISITE:** The base refactor `docs/superpowers/plans/2026-06-16-export-sink-architecture.md` MUST be merged first. This plan assumes `src/documents/export/sinks.py` exists with `ZipExportSink(target, zip_name, *, delete=False)` opening its `ZipFile` in `_open()`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Verified facts (CPython 3.14.3, via `uv run --python 3.14 --no-project`)
|
||||||
|
|
||||||
|
- Constants: `ZIP_STORED=0`, `ZIP_DEFLATED=8`, `ZIP_BZIP2=12`, `ZIP_LZMA=14`, `ZIP_ZSTANDARD=93` (zstd added 3.14; absent on < 3.14).
|
||||||
|
- `ZipFile(file, "w", compression=…, compresslevel=…)` applies both as the default for every `write`/`writestr` — no per-entry args needed (verified).
|
||||||
|
- Level bounds: `deflated` 0–9, `bzip2` 1–9, `lzma`/`stored` ignore level, `zstd` -131072…22 (`compression.zstd.CompressionParameter.compression_level.bounds() == (-131072, 22)`).
|
||||||
|
- An invalid level fails at the **first write** (`ValueError: Invalid initialization option` / `compresslevel must be between 1 and 9`), plus GC-time `AttributeError` noise on close — hence up-front validation.
|
||||||
|
- zstd is backed by `compression.zstd`; `zipfile` raises `RuntimeError` if it's unavailable.
|
||||||
|
|
||||||
|
## Conventions for every task
|
||||||
|
|
||||||
|
- **Run backend tests on the VM:** `bash /c/Users/tholmes/Documents/Coding/paperless/vmtest.sh "<targets>"` (never locally).
|
||||||
|
- **Lint locally:** `ruff check <paths> && ruff format <paths>` (global ruff, not `uv run`).
|
||||||
|
- **Tests are pytest-style:** classes, `@pytest.mark.django_db` on the class only where DB is needed (the `compression.py` and sink tests need no DB), factory-boy, `mocker`, `parametrize`, full type annotations.
|
||||||
|
- The VM runs Python 3.11, so **zstd positive tests must be `@pytest.mark.skipif(...)`-guarded**; they will simply not run there. zstd _rejection_ tests (the < 3.14 path) DO run on the VM.
|
||||||
|
|
||||||
|
## File structure
|
||||||
|
|
||||||
|
- **Create** `src/documents/export/compression.py` — method map, CLI choices, level bounds, `compression_available()`, `level_error()`, `compress_type_readable()`, `unreadable_method_names()`. Pure, no Django.
|
||||||
|
- **Create** `src/documents/tests/export/test_compression.py` — unit tests for the above.
|
||||||
|
- **Modify** `src/documents/export/sinks.py` — `ZipExportSink.__init__` gains `compression`/`compresslevel`; `_open()` passes them to `ZipFile`.
|
||||||
|
- **Modify** `src/documents/tests/export/test_sinks.py` — assert the chosen `compress_type` is applied.
|
||||||
|
- **Modify** `src/documents/management/commands/document_exporter.py` — add the two CLI flags, up-front validation, and pass resolved values to `ZipExportSink`.
|
||||||
|
- **Modify** `src/documents/tests/test_management_exporter.py` — flag validation + default-unchanged tests.
|
||||||
|
- **Modify** `src/documents/management/commands/document_importer.py` — pre-extract compress-type check.
|
||||||
|
- **Modify** `src/documents/tests/test_management_importer.py` — unsupported-codec → `CommandError`.
|
||||||
|
- **Modify** `docs/administration.md` — document both flags + zstd portability caveat.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 1: `documents/export/compression.py` (pure compression policy)
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Create: `src/documents/export/compression.py`
|
||||||
|
- Test: `src/documents/tests/export/test_compression.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing tests**
|
||||||
|
|
||||||
|
Create `src/documents/tests/export/test_compression.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
import sys
|
||||||
|
import zipfile
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from documents.export import compression
|
||||||
|
|
||||||
|
|
||||||
|
class TestCompressionMethods:
|
||||||
|
def test_choices_always_include_zstd(self) -> None:
|
||||||
|
# zstd is offered regardless of runtime; availability is checked separately
|
||||||
|
assert compression.COMPRESSION_CHOICES == (
|
||||||
|
"stored",
|
||||||
|
"deflated",
|
||||||
|
"bzip2",
|
||||||
|
"lzma",
|
||||||
|
"zstd",
|
||||||
|
)
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("name", "constant"),
|
||||||
|
[
|
||||||
|
("stored", zipfile.ZIP_STORED),
|
||||||
|
("deflated", zipfile.ZIP_DEFLATED),
|
||||||
|
("bzip2", zipfile.ZIP_BZIP2),
|
||||||
|
("lzma", zipfile.ZIP_LZMA),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_method_maps_to_zipfile_constant(self, name: str, constant: int) -> None:
|
||||||
|
assert compression.COMPRESSION_METHODS[name] == constant
|
||||||
|
|
||||||
|
def test_stored_and_deflated_always_available(self) -> None:
|
||||||
|
assert compression.compression_available("stored")
|
||||||
|
assert compression.compression_available("deflated")
|
||||||
|
|
||||||
|
def test_zstd_availability_tracks_runtime(self) -> None:
|
||||||
|
expected: bool = sys.version_info >= (3, 14)
|
||||||
|
assert compression.compression_available("zstd") == expected
|
||||||
|
|
||||||
|
|
||||||
|
class TestLevelError:
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("method", "level"),
|
||||||
|
[
|
||||||
|
("deflated", 0),
|
||||||
|
("deflated", 9),
|
||||||
|
("bzip2", 1),
|
||||||
|
("bzip2", 9),
|
||||||
|
("deflated", None),
|
||||||
|
("stored", None),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_valid_levels_return_none(self, method: str, level: int | None) -> None:
|
||||||
|
assert compression.level_error(method, level) is None
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("method", "level"),
|
||||||
|
[
|
||||||
|
("deflated", 10),
|
||||||
|
("deflated", -1),
|
||||||
|
("bzip2", 0),
|
||||||
|
("bzip2", 10),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_out_of_range_levels_return_message(
|
||||||
|
self,
|
||||||
|
method: str,
|
||||||
|
level: int,
|
||||||
|
) -> None:
|
||||||
|
msg: str | None = compression.level_error(method, level)
|
||||||
|
assert msg is not None
|
||||||
|
assert "between" in msg
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("method", ["stored", "lzma"])
|
||||||
|
def test_level_on_levelless_method_is_rejected(self, method: str) -> None:
|
||||||
|
msg: str | None = compression.level_error(method, 5)
|
||||||
|
assert msg is not None
|
||||||
|
assert "no effect" in msg
|
||||||
|
|
||||||
|
|
||||||
|
class TestCompressTypeReadable:
|
||||||
|
@pytest.mark.parametrize("ct", [zipfile.ZIP_STORED, zipfile.ZIP_DEFLATED])
|
||||||
|
def test_stored_and_deflated_always_readable(self, ct: int) -> None:
|
||||||
|
assert compression.compress_type_readable(ct)
|
||||||
|
|
||||||
|
def test_zstd_compress_type_readability_tracks_runtime(self) -> None:
|
||||||
|
# 93 = ZIP_ZSTANDARD; 20 = legacy zstd method id (read-only)
|
||||||
|
expected: bool = sys.version_info >= (3, 14)
|
||||||
|
assert compression.compress_type_readable(93) == expected
|
||||||
|
assert compression.compress_type_readable(20) == expected
|
||||||
|
|
||||||
|
def test_unknown_compress_type_is_unreadable(self) -> None:
|
||||||
|
assert not compression.compress_type_readable(9999)
|
||||||
|
|
||||||
|
def test_unreadable_method_names_lists_methods(self) -> None:
|
||||||
|
# An unknown method id maps to no name and is reported generically.
|
||||||
|
names: set[str] = compression.unreadable_method_names({9999})
|
||||||
|
assert names == {"method 9999"}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run to verify it fails**
|
||||||
|
|
||||||
|
Run: `bash /c/Users/tholmes/Documents/Coding/paperless/vmtest.sh "src/documents/tests/export/test_compression.py -v"`
|
||||||
|
Expected: FAIL with `ModuleNotFoundError: No module named 'documents.export.compression'`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement `compression.py`**
|
||||||
|
|
||||||
|
Create `src/documents/export/compression.py`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import importlib
|
||||||
|
import zipfile
|
||||||
|
|
||||||
|
# ZIP_ZSTANDARD exists only on Python 3.14+ (PEP 784). None elsewhere.
|
||||||
|
ZSTD: int | None = getattr(zipfile, "ZIP_ZSTANDARD", None)
|
||||||
|
|
||||||
|
# CLI choices are fixed across runtimes so argparse never hides zstd; runtime
|
||||||
|
# availability is enforced separately in compression_available().
|
||||||
|
COMPRESSION_CHOICES: tuple[str, ...] = (
|
||||||
|
"stored",
|
||||||
|
"deflated",
|
||||||
|
"bzip2",
|
||||||
|
"lzma",
|
||||||
|
"zstd",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Method name -> zipfile compression constant (zstd only when supported).
|
||||||
|
COMPRESSION_METHODS: dict[str, int] = {
|
||||||
|
"stored": zipfile.ZIP_STORED,
|
||||||
|
"deflated": zipfile.ZIP_DEFLATED,
|
||||||
|
"bzip2": zipfile.ZIP_BZIP2,
|
||||||
|
"lzma": zipfile.ZIP_LZMA,
|
||||||
|
}
|
||||||
|
if ZSTD is not None:
|
||||||
|
COMPRESSION_METHODS["zstd"] = ZSTD
|
||||||
|
|
||||||
|
# Inclusive (min, max) level bounds per method; None => level not applicable.
|
||||||
|
# Verified on CPython 3.14.3.
|
||||||
|
LEVEL_BOUNDS: dict[str, tuple[int, int] | None] = {
|
||||||
|
"stored": None,
|
||||||
|
"deflated": (0, 9),
|
||||||
|
"bzip2": (1, 9),
|
||||||
|
"lzma": None,
|
||||||
|
"zstd": (-131072, 22),
|
||||||
|
}
|
||||||
|
|
||||||
|
# zipfile compress_type id -> method name. 93 = current zstd id, 20 = legacy
|
||||||
|
# zstd id that zipfile can still read.
|
||||||
|
_COMPRESS_TYPE_TO_METHOD: dict[int, str] = {
|
||||||
|
zipfile.ZIP_STORED: "stored",
|
||||||
|
zipfile.ZIP_DEFLATED: "deflated",
|
||||||
|
zipfile.ZIP_BZIP2: "bzip2",
|
||||||
|
zipfile.ZIP_LZMA: "lzma",
|
||||||
|
93: "zstd",
|
||||||
|
20: "zstd",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def compression_available(method: str) -> bool:
|
||||||
|
"""Whether the running interpreter can actually use the given method."""
|
||||||
|
if method in ("stored", "deflated"):
|
||||||
|
# zlib is a hard CPython dependency; stored needs nothing.
|
||||||
|
return True
|
||||||
|
if method == "bzip2":
|
||||||
|
return _module_importable("bz2")
|
||||||
|
if method == "lzma":
|
||||||
|
return _module_importable("lzma")
|
||||||
|
if method == "zstd":
|
||||||
|
return ZSTD is not None and _module_importable("compression.zstd")
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _module_importable(name: str) -> bool:
|
||||||
|
try:
|
||||||
|
importlib.import_module(name)
|
||||||
|
except ImportError:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
|
def level_error(method: str, level: int | None) -> str | None:
|
||||||
|
"""Return a human message if (method, level) is invalid, else None."""
|
||||||
|
if level is None:
|
||||||
|
return None
|
||||||
|
bounds = LEVEL_BOUNDS[method]
|
||||||
|
if bounds is None:
|
||||||
|
return f"--zip-compression-level has no effect for '{method}'"
|
||||||
|
low, high = bounds
|
||||||
|
if not (low <= level <= high):
|
||||||
|
return (
|
||||||
|
f"--zip-compression-level for '{method}' must be between "
|
||||||
|
f"{low} and {high}"
|
||||||
|
)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def compress_type_readable(compress_type: int) -> bool:
|
||||||
|
"""Whether this interpreter can decompress an entry of the given type."""
|
||||||
|
method = _COMPRESS_TYPE_TO_METHOD.get(compress_type)
|
||||||
|
if method is None:
|
||||||
|
return False
|
||||||
|
return compression_available(method)
|
||||||
|
|
||||||
|
|
||||||
|
def unreadable_method_names(compress_types: set[int]) -> set[str]:
|
||||||
|
"""Map a set of compress_type ids to human method names for error messages."""
|
||||||
|
names: set[str] = set()
|
||||||
|
for ct in compress_types:
|
||||||
|
names.add(_COMPRESS_TYPE_TO_METHOD.get(ct, f"method {ct}"))
|
||||||
|
return names
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run to verify it passes**
|
||||||
|
|
||||||
|
Run: `bash /c/Users/tholmes/Documents/Coding/paperless/vmtest.sh "src/documents/tests/export/test_compression.py -v"`
|
||||||
|
Expected: PASS (on the 3.11 VM, `test_zstd_availability_tracks_runtime` and `test_zstd_compress_type_readability_tracks_runtime` assert `False`).
|
||||||
|
|
||||||
|
- [ ] **Step 5: Lint**
|
||||||
|
|
||||||
|
Run: `ruff check src/documents/export/compression.py src/documents/tests/export/test_compression.py && ruff format src/documents/export/compression.py src/documents/tests/export/test_compression.py`
|
||||||
|
Expected: no errors.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/documents/export/compression.py src/documents/tests/export/test_compression.py
|
||||||
|
git commit -m "Feature: add export compression policy module"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 2: `ZipExportSink` accepts compression method + level
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Modify: `src/documents/export/sinks.py`
|
||||||
|
- Test: `src/documents/tests/export/test_sinks.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
Append to `src/documents/tests/export/test_sinks.py` (the top-of-file block already imports `zipfile`, `Path`, `pytest`, `ZipExportSink`, `StreamingManifestWriter` from the base-refactor plan):
|
||||||
|
|
||||||
|
```python
|
||||||
|
class TestZipExportSinkCompression:
|
||||||
|
@pytest.fixture()
|
||||||
|
def source_file(self, tmp_path: Path) -> Path:
|
||||||
|
src: Path = tmp_path / "src" / "doc.pdf"
|
||||||
|
src.parent.mkdir(parents=True)
|
||||||
|
src.write_bytes(b"PDF-CONTENT" * 100)
|
||||||
|
return src
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("method", "constant"),
|
||||||
|
[
|
||||||
|
("stored", zipfile.ZIP_STORED),
|
||||||
|
("deflated", zipfile.ZIP_DEFLATED),
|
||||||
|
("bzip2", zipfile.ZIP_BZIP2),
|
||||||
|
("lzma", zipfile.ZIP_LZMA),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_compression_method_is_applied_to_file_entries(
|
||||||
|
self,
|
||||||
|
tmp_path: Path,
|
||||||
|
source_file: Path,
|
||||||
|
method: str,
|
||||||
|
constant: int,
|
||||||
|
) -> None:
|
||||||
|
target: Path = tmp_path / "out"
|
||||||
|
target.mkdir()
|
||||||
|
with ZipExportSink(
|
||||||
|
target,
|
||||||
|
"export",
|
||||||
|
delete=False,
|
||||||
|
compression=constant,
|
||||||
|
) as sink:
|
||||||
|
sink.add_file(source_file, "doc.pdf")
|
||||||
|
with zipfile.ZipFile(target / "export.zip") as zf:
|
||||||
|
info = zf.getinfo("doc.pdf")
|
||||||
|
assert info.compress_type == constant
|
||||||
|
|
||||||
|
def test_compressing_method_beats_stored(
|
||||||
|
self,
|
||||||
|
tmp_path: Path,
|
||||||
|
source_file: Path,
|
||||||
|
) -> None:
|
||||||
|
# Robust size invariant: a compressing method must be <= stored on
|
||||||
|
# compressible content (avoids flaky level-9-vs-level-1 comparisons).
|
||||||
|
sizes: dict[str, int] = {}
|
||||||
|
for name, constant in (("stored", zipfile.ZIP_STORED), ("deflated", zipfile.ZIP_DEFLATED)):
|
||||||
|
target: Path = tmp_path / name
|
||||||
|
target.mkdir()
|
||||||
|
with ZipExportSink(target, "export", delete=False, compression=constant) as sink:
|
||||||
|
sink.add_file(source_file, "doc.pdf")
|
||||||
|
sizes[name] = (target / "export.zip").stat().st_size
|
||||||
|
assert sizes["deflated"] <= sizes["stored"]
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run to verify it fails**
|
||||||
|
|
||||||
|
Run: `bash /c/Users/tholmes/Documents/Coding/paperless/vmtest.sh "src/documents/tests/export/test_sinks.py::TestZipExportSinkCompression -v"`
|
||||||
|
Expected: FAIL with `TypeError: __init__() got an unexpected keyword argument 'compression'`.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add the params to `ZipExportSink`**
|
||||||
|
|
||||||
|
In `src/documents/export/sinks.py`, change `ZipExportSink.__init__` to accept the new keyword-only params and store them, and pass them in `_open()`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
target: Path,
|
||||||
|
zip_name: str,
|
||||||
|
*,
|
||||||
|
delete: bool = False,
|
||||||
|
compression: int = zipfile.ZIP_DEFLATED,
|
||||||
|
compresslevel: int | None = None,
|
||||||
|
) -> None:
|
||||||
|
self._target = target.resolve()
|
||||||
|
self._zip_path = (self._target / zip_name).with_suffix(".zip")
|
||||||
|
self._tmp_path = self._zip_path.with_name(self._zip_path.name + ".tmp")
|
||||||
|
self._delete = delete
|
||||||
|
self._compression = compression
|
||||||
|
self._compresslevel = compresslevel
|
||||||
|
self._zip: zipfile.ZipFile | None = None
|
||||||
|
self._dirs: set[str] = set()
|
||||||
|
self._pending_manifest: tuple[Path, str] | None = None
|
||||||
|
self._stream_open = False
|
||||||
|
```
|
||||||
|
|
||||||
|
And in `_open()`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def _open(self) -> None:
|
||||||
|
settings.SCRATCH_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
|
self._zip = zipfile.ZipFile(
|
||||||
|
self._tmp_path,
|
||||||
|
"w",
|
||||||
|
compression=self._compression,
|
||||||
|
compresslevel=self._compresslevel,
|
||||||
|
allowZip64=True,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run to verify it passes**
|
||||||
|
|
||||||
|
Run: `bash /c/Users/tholmes/Documents/Coding/paperless/vmtest.sh "src/documents/tests/export/test_sinks.py -v"`
|
||||||
|
Expected: PASS (all sink tests, including the four method params and the size invariant). `bzip2`/`lzma` are present on the VM's CPython, so those params pass.
|
||||||
|
|
||||||
|
- [ ] **Step 5: Lint**
|
||||||
|
|
||||||
|
Run: `ruff check src/documents/export/sinks.py && ruff format src/documents/export/sinks.py`
|
||||||
|
Expected: no errors.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/documents/export/sinks.py src/documents/tests/export/test_sinks.py
|
||||||
|
git commit -m "Feature: ZipExportSink accepts compression method and level"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 3: Wire CLI flags + validation into `document_exporter`
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Modify: `src/documents/management/commands/document_exporter.py`
|
||||||
|
- Test: `src/documents/tests/test_management_exporter.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add the argparse flags**
|
||||||
|
|
||||||
|
In `document_exporter.py`, add the import near the other `documents.export` import:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from documents.export.compression import COMPRESSION_CHOICES
|
||||||
|
from documents.export.compression import COMPRESSION_METHODS
|
||||||
|
from documents.export.compression import compression_available
|
||||||
|
from documents.export.compression import level_error
|
||||||
|
from documents.export.compression import ZSTD
|
||||||
|
```
|
||||||
|
|
||||||
|
In `add_arguments`, after the `--zip-name` argument, add:
|
||||||
|
|
||||||
|
```python
|
||||||
|
parser.add_argument(
|
||||||
|
"--zip-compression",
|
||||||
|
choices=COMPRESSION_CHOICES,
|
||||||
|
default=None,
|
||||||
|
help=(
|
||||||
|
"Compression method for the export zip (requires --zip). "
|
||||||
|
"Default: deflated. 'zstd' requires Python 3.14+ on both the "
|
||||||
|
"exporting and importing machine."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
parser.add_argument(
|
||||||
|
"--zip-compression-level",
|
||||||
|
type=int,
|
||||||
|
default=None,
|
||||||
|
help=(
|
||||||
|
"Compression level for the export zip (requires --zip). "
|
||||||
|
"deflated: 0-9, bzip2: 1-9, zstd: -131072..22; ignored for "
|
||||||
|
"stored/lzma."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Read + validate the flags in `handle()`**
|
||||||
|
|
||||||
|
In `handle()`, after the existing `--compare-*` + `--zip` guard, add the compression flag handling. Insert before the sink construction:
|
||||||
|
|
||||||
|
```python
|
||||||
|
zip_compression: str | None = options["zip_compression"]
|
||||||
|
zip_compression_level: int | None = options["zip_compression_level"]
|
||||||
|
|
||||||
|
if not self.zip_export and (
|
||||||
|
zip_compression is not None or zip_compression_level is not None
|
||||||
|
):
|
||||||
|
raise CommandError(
|
||||||
|
"--zip-compression and --zip-compression-level require --zip",
|
||||||
|
)
|
||||||
|
|
||||||
|
compression_method = zip_compression or "deflated"
|
||||||
|
if self.zip_export:
|
||||||
|
if not compression_available(compression_method):
|
||||||
|
if compression_method == "zstd" and ZSTD is None:
|
||||||
|
raise CommandError(
|
||||||
|
"zstd compression requires Python 3.14 or newer",
|
||||||
|
)
|
||||||
|
raise CommandError(
|
||||||
|
f"Compression method '{compression_method}' is not "
|
||||||
|
f"available on this Python runtime",
|
||||||
|
)
|
||||||
|
level_msg = level_error(compression_method, zip_compression_level)
|
||||||
|
if level_msg is not None:
|
||||||
|
raise CommandError(level_msg)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Pass the resolved values into `ZipExportSink`**
|
||||||
|
|
||||||
|
Change the `ZipExportSink(...)` construction in `handle()` to:
|
||||||
|
|
||||||
|
```python
|
||||||
|
if self.zip_export:
|
||||||
|
sink = ZipExportSink(
|
||||||
|
self.target,
|
||||||
|
options["zip_name"],
|
||||||
|
delete=self.delete,
|
||||||
|
compression=COMPRESSION_METHODS[compression_method],
|
||||||
|
compresslevel=zip_compression_level,
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
sink = DirectoryExportSink(
|
||||||
|
self.target,
|
||||||
|
compare_checksums=self.compare_checksums,
|
||||||
|
compare_json=self.compare_json,
|
||||||
|
delete=self.delete,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Write the command-level tests**
|
||||||
|
|
||||||
|
Add to the `TestExportImport` class in `src/documents/tests/test_management_exporter.py` (imports `call_command`, `CommandError`, `ZipFile`, `timezone` already present):
|
||||||
|
|
||||||
|
```python
|
||||||
|
def test_compression_flags_require_zip(self) -> None:
|
||||||
|
for args in (
|
||||||
|
["--zip-compression", "lzma"],
|
||||||
|
["--zip-compression-level", "5"],
|
||||||
|
):
|
||||||
|
with self.assertRaises(CommandError):
|
||||||
|
call_command(
|
||||||
|
"document_exporter",
|
||||||
|
self.target,
|
||||||
|
*args,
|
||||||
|
skip_checks=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_zip_compression_level_out_of_range_raises(self) -> None:
|
||||||
|
with self.assertRaises(CommandError):
|
||||||
|
call_command(
|
||||||
|
"document_exporter",
|
||||||
|
self.target,
|
||||||
|
"--zip",
|
||||||
|
"--zip-compression",
|
||||||
|
"deflated",
|
||||||
|
"--zip-compression-level",
|
||||||
|
"99",
|
||||||
|
skip_checks=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_zip_compression_level_rejected_for_stored(self) -> None:
|
||||||
|
with self.assertRaises(CommandError):
|
||||||
|
call_command(
|
||||||
|
"document_exporter",
|
||||||
|
self.target,
|
||||||
|
"--zip",
|
||||||
|
"--zip-compression",
|
||||||
|
"stored",
|
||||||
|
"--zip-compression-level",
|
||||||
|
"5",
|
||||||
|
skip_checks=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_zip_lzma_compression_round_trips(self) -> None:
|
||||||
|
call_command(
|
||||||
|
"document_exporter",
|
||||||
|
self.target,
|
||||||
|
"--zip",
|
||||||
|
"--zip-compression",
|
||||||
|
"lzma",
|
||||||
|
skip_checks=True,
|
||||||
|
)
|
||||||
|
expected = str(
|
||||||
|
self.target / f"export-{timezone.localdate().isoformat()}.zip",
|
||||||
|
)
|
||||||
|
self.assertIsFile(expected)
|
||||||
|
with ZipFile(expected) as zip_file:
|
||||||
|
info = zip_file.getinfo("manifest.json")
|
||||||
|
# manifest.json carries the chosen method; deflated is the default
|
||||||
|
self.assertEqual(info.compress_type, 14) # ZIP_LZMA
|
||||||
|
|
||||||
|
def test_default_zip_uses_deflate(self) -> None:
|
||||||
|
call_command(
|
||||||
|
"document_exporter",
|
||||||
|
self.target,
|
||||||
|
"--zip",
|
||||||
|
skip_checks=True,
|
||||||
|
)
|
||||||
|
expected = str(
|
||||||
|
self.target / f"export-{timezone.localdate().isoformat()}.zip",
|
||||||
|
)
|
||||||
|
with ZipFile(expected) as zip_file:
|
||||||
|
info = zip_file.getinfo("manifest.json")
|
||||||
|
self.assertEqual(info.compress_type, 8) # ZIP_DEFLATED
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Run the tests**
|
||||||
|
|
||||||
|
Run: `bash /c/Users/tholmes/Documents/Coding/paperless/vmtest.sh "src/documents/tests/test_management_exporter.py -v"`
|
||||||
|
Expected: PASS — the new tests plus all existing exporter tests stay green.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Lint**
|
||||||
|
|
||||||
|
Run: `ruff check src/documents/management/commands/document_exporter.py src/documents/tests/test_management_exporter.py && ruff format src/documents/management/commands/document_exporter.py src/documents/tests/test_management_exporter.py`
|
||||||
|
Expected: no errors.
|
||||||
|
|
||||||
|
- [ ] **Step 7: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/documents/management/commands/document_exporter.py src/documents/tests/test_management_exporter.py
|
||||||
|
git commit -m "Feature: add --zip-compression and --zip-compression-level flags"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 4: Importer pre-check for unreadable codecs
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Modify: `src/documents/management/commands/document_importer.py`
|
||||||
|
- Test: `src/documents/tests/test_management_importer.py`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test**
|
||||||
|
|
||||||
|
The importer test file `src/documents/tests/test_management_importer.py` is
|
||||||
|
`TestCase`-style (`class TestCommandImport(... TestCase)`, `self.assertRaises`,
|
||||||
|
`DirectoriesMixin` gives `self.dirs.scratch_dir`). Match that style. Add this
|
||||||
|
method to `TestCommandImport`. It builds a valid zip and patches the readability
|
||||||
|
probe so the check fires deterministically on any runtime:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def test_import_rejects_unreadable_compression(self) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- A zip archive with an entry whose compression this Python can't read
|
||||||
|
WHEN:
|
||||||
|
- Import is attempted
|
||||||
|
THEN:
|
||||||
|
- A CommandError naming the issue is raised, before extraction
|
||||||
|
"""
|
||||||
|
import zipfile
|
||||||
|
from unittest import mock
|
||||||
|
|
||||||
|
archive = Path(self.dirs.scratch_dir) / "export.zip"
|
||||||
|
with zipfile.ZipFile(archive, "w") as zf:
|
||||||
|
zf.writestr("manifest.json", "[]")
|
||||||
|
|
||||||
|
with mock.patch(
|
||||||
|
"documents.management.commands.document_importer.compress_type_readable",
|
||||||
|
return_value=False,
|
||||||
|
):
|
||||||
|
with self.assertRaises(CommandError) as e:
|
||||||
|
call_command(
|
||||||
|
"document_importer",
|
||||||
|
str(archive),
|
||||||
|
"--no-progress-bar",
|
||||||
|
skip_checks=True,
|
||||||
|
)
|
||||||
|
self.assertIn("compression", str(e.exception))
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run to verify it fails**
|
||||||
|
|
||||||
|
Run: `bash /c/Users/tholmes/Documents/Coding/paperless/vmtest.sh "src/documents/tests/test_management_importer.py -k unreadable_compression -v"`
|
||||||
|
Expected: FAIL — no pre-check exists yet, so the import proceeds (or fails with a different error).
|
||||||
|
|
||||||
|
- [ ] **Step 3: Implement the pre-check**
|
||||||
|
|
||||||
|
In `document_importer.py`, add the import:
|
||||||
|
|
||||||
|
```python
|
||||||
|
from documents.export.compression import compress_type_readable
|
||||||
|
from documents.export.compression import unreadable_method_names
|
||||||
|
```
|
||||||
|
|
||||||
|
Find the zip-handling block (around `document_importer.py:453`):
|
||||||
|
|
||||||
|
```python
|
||||||
|
with ZipFile(self.source) as zf:
|
||||||
|
zf.extractall(tmp_dir)
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace it with a pre-check before extraction:
|
||||||
|
|
||||||
|
```python
|
||||||
|
with ZipFile(self.source) as zf:
|
||||||
|
unsupported = {
|
||||||
|
info.compress_type
|
||||||
|
for info in zf.infolist()
|
||||||
|
if not compress_type_readable(info.compress_type)
|
||||||
|
}
|
||||||
|
if unsupported:
|
||||||
|
names = ", ".join(sorted(unreadable_method_names(unsupported)))
|
||||||
|
raise CommandError(
|
||||||
|
f"This archive uses compression this Python cannot "
|
||||||
|
f"read ({names}). zstd archives require Python 3.14+.",
|
||||||
|
)
|
||||||
|
zf.extractall(tmp_dir)
|
||||||
|
```
|
||||||
|
|
||||||
|
Confirm `CommandError` is imported in `document_importer.py` (it is used elsewhere; if not, add `from django.core.management.base import CommandError`).
|
||||||
|
|
||||||
|
- [ ] **Step 4: Run to verify it passes**
|
||||||
|
|
||||||
|
Run: `bash /c/Users/tholmes/Documents/Coding/paperless/vmtest.sh "src/documents/tests/test_management_importer.py -v"`
|
||||||
|
Expected: PASS — the new test plus all existing importer tests (normal deflated/stored archives still import).
|
||||||
|
|
||||||
|
- [ ] **Step 5: Lint**
|
||||||
|
|
||||||
|
Run: `ruff check src/documents/management/commands/document_importer.py src/documents/tests/test_management_importer.py && ruff format src/documents/management/commands/document_importer.py src/documents/tests/test_management_importer.py`
|
||||||
|
Expected: no errors.
|
||||||
|
|
||||||
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add src/documents/management/commands/document_importer.py src/documents/tests/test_management_importer.py
|
||||||
|
git commit -m "Feature: importer rejects archives with unreadable compression"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 5: Document the flags
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
|
||||||
|
- Modify: `docs/administration.md`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Add the flags to the option list**
|
||||||
|
|
||||||
|
In `docs/administration.md`, update the usage block (around line 257) to include the new flags:
|
||||||
|
|
||||||
|
```
|
||||||
|
document_exporter target [-c] [-d] [-f] [-na] [-nt] [-p] [-sm] [-z]
|
||||||
|
|
||||||
|
optional arguments:
|
||||||
|
-c, --compare-checksums
|
||||||
|
-cj, --compare-json
|
||||||
|
-d, --delete
|
||||||
|
-f, --use-filename-format
|
||||||
|
-na, --no-archive
|
||||||
|
-nt, --no-thumbnail
|
||||||
|
-p, --use-folder-prefix
|
||||||
|
-sm, --split-manifest
|
||||||
|
-z, --zip
|
||||||
|
-zn, --zip-name
|
||||||
|
--zip-compression
|
||||||
|
--zip-compression-level
|
||||||
|
--data-only
|
||||||
|
--no-progress-bar
|
||||||
|
--passphrase
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Add the prose**
|
||||||
|
|
||||||
|
After the `-z`/`--zip` paragraph (around line 330), add:
|
||||||
|
|
||||||
|
```markdown
|
||||||
|
The compression method for the zip can be set with `--zip-compression`
|
||||||
|
(`stored`, `deflated` (default), `bzip2`, `lzma`, or `zstd`) and tuned with
|
||||||
|
`--zip-compression-level` (deflated: 0–9, bzip2: 1–9, zstd: -131072–22; ignored
|
||||||
|
for `stored` and `lzma`). Both options require `--zip`.
|
||||||
|
|
||||||
|
!!! warning
|
||||||
|
|
||||||
|
`zstd` compression requires Python 3.14 or newer on **both** the machine
|
||||||
|
creating the export and any machine importing it. An archive compressed with
|
||||||
|
`zstd` (or `lzma`/`bzip2` where those modules are unavailable) cannot be
|
||||||
|
imported on a runtime that lacks the codec; the importer will refuse it with
|
||||||
|
a clear error. The default `deflated` is universally readable.
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 3: Verify the docs build is not broken (lint markdown)**
|
||||||
|
|
||||||
|
Run: `ruff check docs/ 2>/dev/null; echo "docs are markdown; rely on prettier pre-commit"`
|
||||||
|
(No code to test. The prettier pre-commit hook will reformat on commit.)
|
||||||
|
|
||||||
|
- [ ] **Step 4: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add docs/administration.md
|
||||||
|
git commit -m "Docs: document --zip-compression and --zip-compression-level"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Task 6: Final verification
|
||||||
|
|
||||||
|
**Files:** none (verification only).
|
||||||
|
|
||||||
|
- [ ] **Step 1: Full backend suites on the VM**
|
||||||
|
|
||||||
|
Run: `bash /c/Users/tholmes/Documents/Coding/paperless/vmtest.sh "src/documents/tests/export/ src/documents/tests/test_management_exporter.py src/documents/tests/test_management_importer.py -v"`
|
||||||
|
Expected: PASS, no failures.
|
||||||
|
|
||||||
|
- [ ] **Step 2: Spot-check the zstd happy path on Python 3.14 (cannot run under Django on the 3.11 VM)**
|
||||||
|
|
||||||
|
The zstd positive round-trip can't run in the 3.11 test env. Confirm the policy module behaves on a real 3.14 interpreter with a standalone check (no Django needed):
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run --python 3.14 --no-project python -c "import sys; sys.path.insert(0,'src'); import django; print('skip')" 2>/dev/null || \
|
||||||
|
uv run --python 3.14 --no-project python -c "
|
||||||
|
import zipfile, io
|
||||||
|
from compression.zstd import CompressionParameter as CP
|
||||||
|
print('zstd const', zipfile.ZIP_ZSTANDARD, 'bounds', CP.compression_level.bounds())
|
||||||
|
buf = io.BytesIO()
|
||||||
|
with zipfile.ZipFile(buf,'w',compression=zipfile.ZIP_ZSTANDARD,compresslevel=19) as zf:
|
||||||
|
zf.writestr('a.txt','x'*1000)
|
||||||
|
with zipfile.ZipFile(buf) as zf:
|
||||||
|
assert zf.getinfo('a.txt').compress_type == zipfile.ZIP_ZSTANDARD
|
||||||
|
assert zf.read('a.txt') == b'x'*1000
|
||||||
|
print('zstd round-trip OK')
|
||||||
|
"
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: prints `zstd const 93 bounds (-131072, 22)` and `zstd round-trip OK`. This validates the constant, bounds, and that a zstd archive round-trips — the parts the 3.11 CI cannot exercise.
|
||||||
|
|
||||||
|
- [ ] **Step 3: Type-check on the VM (pyrefly)**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
tar czf - src pyproject.toml uv.lock .pyrefly-baseline.json | ssh -o BatchMode=yes -p 2244 trenton@localhost 'tar xzf - -C ~/projects/paperless-ngx'
|
||||||
|
ssh -o BatchMode=yes -p 2244 trenton@localhost 'bash -lc "cd ~/projects/paperless-ngx && uv run pyrefly check"'
|
||||||
|
```
|
||||||
|
|
||||||
|
Expected: no new type errors beyond the baseline. (Note: `import compression.zstd` is guarded behind `importlib.import_module`, so it is never statically resolved on the 3.11 baseline.)
|
||||||
|
|
||||||
|
- [ ] **Step 4: Final lint**
|
||||||
|
|
||||||
|
Run: `ruff check src/documents/export/ src/documents/management/commands/document_exporter.py src/documents/management/commands/document_importer.py && ruff format --check src/documents/export/ src/documents/management/commands/document_exporter.py src/documents/management/commands/document_importer.py`
|
||||||
|
Expected: clean.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes for the implementer
|
||||||
|
|
||||||
|
- **Default behavior is unchanged:** with no flags, the sink is constructed with `compression=ZIP_DEFLATED, compresslevel=None` — byte-method-identical to today (`shutil.make_archive` used `ZIP_DEFLATED` with no level). `test_default_zip_uses_deflate` pins this.
|
||||||
|
- **zstd availability is gated three ways and never imported statically:** the constant via `getattr`, the codec via `importlib.import_module("compression.zstd")`, and the CLI value rejected with a friendly message on < 3.14. The choices list always contains `zstd` so argparse doesn't hide it.
|
||||||
|
- **The importer pre-check is the safety net** for portability foot-guns — without it an unreadable entry raises a bare `NotImplementedError` mid-`extractall`. The check runs on `infolist()` (metadata only) before any extraction.
|
||||||
|
- **Why `--zip-compression` defaults to `None`, not `"deflated"`:** so `handle()` can detect "user passed it without `--zip`" and fail fast. The effective default is resolved as `zip_compression or "deflated"`.
|
||||||
@@ -0,0 +1,236 @@
|
|||||||
|
# Export Zip Compression Control — Design
|
||||||
|
|
||||||
|
**Date:** 2026-06-16
|
||||||
|
**Branch base:** `dev`
|
||||||
|
**Status:** Design complete (zstd facts verified on CPython 3.14.3) — **depends on**
|
||||||
|
`2026-06-16-export-sink-architecture-design.md` being implemented first.
|
||||||
|
|
||||||
|
## Prerequisite
|
||||||
|
|
||||||
|
This builds directly on the export sink refactor. It assumes `ZipExportSink`
|
||||||
|
already exists and is the single place that owns `zipfile.ZipFile` creation and
|
||||||
|
entry writes. Do not start this until that refactor has landed; without it, the
|
||||||
|
change would have to touch the command's zip branches again.
|
||||||
|
|
||||||
|
## Problem
|
||||||
|
|
||||||
|
Zip export is hardwired to `ZIP_DEFLATED` at the library default level. Users
|
||||||
|
have no way to trade speed against archive size — a fast `ZIP_STORED` pass for a
|
||||||
|
quick local copy, or a maximal `ZIP_LZMA` pass for the smallest off-site backup.
|
||||||
|
The sink refactor turns "which compression" into a single constructor argument,
|
||||||
|
so exposing it is now a small, isolated change.
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Let the operator choose the zip compression method and level from the CLI, with
|
||||||
|
behavior identical to today when the flags are omitted. All knowledge of
|
||||||
|
compression stays inside `ZipExportSink`; the command only parses flags and maps
|
||||||
|
them to sink arguments.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
In scope:
|
||||||
|
|
||||||
|
- `ZipExportSink` gains `compression: int` and `compresslevel: int | None`
|
||||||
|
constructor parameters (default `ZIP_DEFLATED`, `None` → library default),
|
||||||
|
passed straight to `zipfile.ZipFile(...)`.
|
||||||
|
- New `document_exporter` flags: `--zip-compression` and
|
||||||
|
`--zip-compression-level`, valid only with `--zip`.
|
||||||
|
- Validation: method availability, level range per method, and the
|
||||||
|
requires-`--zip` guard.
|
||||||
|
- Import-side: a pre-extract support check in `document_importer` that turns an
|
||||||
|
unsupported codec into a clear `CommandError` (the importer otherwise decompresses
|
||||||
|
transparently via `ZipFile.extractall`).
|
||||||
|
- Docs: add both flags and the zstd-portability caveat to `docs/administration.md`
|
||||||
|
(the `document_exporter` option list, lines ~257-270 and the `-z`/`-zn` section,
|
||||||
|
lines ~328-330). New flags are long-form only (`--zip-compression`,
|
||||||
|
`--zip-compression-level`) — no short aliases, to avoid `-zc`/`-zl` collisions
|
||||||
|
with the existing `-z`/`-zn`.
|
||||||
|
|
||||||
|
Out of scope:
|
||||||
|
|
||||||
|
- Compression for any non-zip sink (folder has none; a future S3 sink would
|
||||||
|
handle its own object storage compression separately).
|
||||||
|
- Changing the default. Omitting the flags must produce a byte-compatible-method
|
||||||
|
archive to today's (`ZIP_DEFLATED`, default level).
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
### `ZipExportSink` changes
|
||||||
|
|
||||||
|
The base sink's signature is `ZipExportSink(target, zip_name, *, delete)`; this
|
||||||
|
adds two keyword-only params after `delete`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
target: Path,
|
||||||
|
zip_name: str,
|
||||||
|
*,
|
||||||
|
delete: bool = False,
|
||||||
|
compression: int = zipfile.ZIP_DEFLATED,
|
||||||
|
compresslevel: int | None = None,
|
||||||
|
) -> None:
|
||||||
|
...
|
||||||
|
# opened in __enter__:
|
||||||
|
self._zip = zipfile.ZipFile(
|
||||||
|
self._tmp_path,
|
||||||
|
"w",
|
||||||
|
compression=compression,
|
||||||
|
compresslevel=compresslevel,
|
||||||
|
allowZip64=True,
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
`ZipFile` applies `compression`/`compresslevel` as the default for every
|
||||||
|
`write`/`writestr` (verified: a `ZipFile(..., compression=ZIP_BZIP2)` yields
|
||||||
|
entries with `compress_type == ZIP_BZIP2` without per-call args), so `add_file` /
|
||||||
|
`add_json` / the manifest entry need no changes. Directory marker entries are
|
||||||
|
empty so their compressed payload is zero, but they are still _tagged_ with the
|
||||||
|
chosen `compress_type` — harmless, but tests that read `infolist()` should filter
|
||||||
|
or account for marker entries (see Testing).
|
||||||
|
|
||||||
|
### CLI flags (`document_exporter`)
|
||||||
|
|
||||||
|
- `--zip-compression {stored,deflated,bzip2,lzma}` — and `zstd` **when the
|
||||||
|
runtime supports it** (see below). Maps to the matching `zipfile.ZIP_*`
|
||||||
|
constant. Default `deflated`.
|
||||||
|
- `--zip-compression-level N` — integer. Per-method accepted ranges (verified
|
||||||
|
against the [3.14 `zipfile` docs](https://docs.python.org/3.14/library/zipfile.html#zipfile.ZipFile)):
|
||||||
|
- `deflated`: **0–9** (`zlib` also accepts `-1` = "default", identical to
|
||||||
|
omitting the flag / `compresslevel=None`).
|
||||||
|
- `bzip2`: **1–9** (`0` is invalid for bzip2).
|
||||||
|
- `lzma`, `stored`: level has **no effect** — passing `--zip-compression-level`
|
||||||
|
with either is a `CommandError`, not a silent accept (consistent with the
|
||||||
|
base refactor's fail-fast posture).
|
||||||
|
- `zstd`: **-131072 … 22** (the documented commonly-accepted range; the
|
||||||
|
authoritative bounds are
|
||||||
|
`compression.zstd.CompressionParameter.compression_level.bounds()`).
|
||||||
|
|
||||||
|
Default: unset → library default (`compresslevel=None`).
|
||||||
|
|
||||||
|
Both flags require `--zip`; passing either without `--zip` raises a
|
||||||
|
`CommandError`, matching the incremental-flag rule from the base refactor.
|
||||||
|
|
||||||
|
**Why validate up front (not let `zipfile` raise) — verified on 3.14.3:** an
|
||||||
|
invalid level does _not_ fail at `ZipFile(...)` construction — it fails at the
|
||||||
|
**first `write`/`writestr` call**, with an opaque message
|
||||||
|
(`ValueError: Invalid initialization option` for deflated > 9, or
|
||||||
|
`ValueError: compresslevel must be between 1 and 9` for bzip2). Worse, on context
|
||||||
|
exit the half-initialized write handle emits a secondary
|
||||||
|
`AttributeError: '_ZipWriteFile' object has no attribute '_compressor'` during GC
|
||||||
|
finalization, so the user sees stack-trace noise unrelated to the real cause.
|
||||||
|
Up-front validation turns all of that into a single clean `CommandError`.
|
||||||
|
|
||||||
|
### Validation (in `handle()`, before constructing the sink)
|
||||||
|
|
||||||
|
1. **Requires `--zip`.** Either flag without `--zip` → `CommandError`.
|
||||||
|
2. **Method availability — via a named, patchable seam.** Expose a module-level
|
||||||
|
helper `compression_available(method: str) -> bool` that does
|
||||||
|
`try: import bz2 / import lzma / from compression import zstd except ImportError:
|
||||||
|
return False` — **not** `importlib.util.find_spec`, which can report a stdlib
|
||||||
|
C-extension as present when importing it actually fails. `stored`/`deflated`
|
||||||
|
are always available (`zlib` is a hard CPython dependency). For `zstd` the probe
|
||||||
|
must import `compression.zstd` (3.14+), not merely check that
|
||||||
|
`zipfile.ZIP_ZSTANDARD` exists. Making this a named function is also what lets
|
||||||
|
the test patch "method unavailable" with `mocker`. If the chosen method is
|
||||||
|
unavailable, raise a `CommandError` naming the missing capability — `zipfile`
|
||||||
|
itself would otherwise raise a bare `RuntimeError`
|
||||||
|
("Compression requires the (missing) … module").
|
||||||
|
3. **Level range.** Reject an out-of-range `--zip-compression-level` for the
|
||||||
|
chosen method with a clear `CommandError`; reject the flag entirely for
|
||||||
|
`stored`/`lzma` (see above).
|
||||||
|
|
||||||
|
### zstd (Python 3.14+)
|
||||||
|
|
||||||
|
**Verified empirically on CPython 3.14.3** (via `uv run --python 3.14 --no-project`)
|
||||||
|
and against [PEP 784](https://peps.python.org/pep-0784/) +
|
||||||
|
[the 3.14 `zipfile` docs](https://docs.python.org/3.14/library/zipfile.html):
|
||||||
|
|
||||||
|
- The compression-method constant is **`zipfile.ZIP_ZSTANDARD`** (added 3.14; its
|
||||||
|
numeric value is `93`). It does **not** exist on < 3.14.
|
||||||
|
- It is backed by the new **`compression.zstd`** stdlib module (PEP 784 added a
|
||||||
|
`compression` namespace package; legacy `bz2`/`lzma`/`zlib` imports are
|
||||||
|
unchanged). `zipfile` raises `RuntimeError` if `compression.zstd` is
|
||||||
|
unavailable when zstd is requested.
|
||||||
|
- Accepted `compresslevel` is **`-131072 … 22`**, confirmed at runtime via
|
||||||
|
`compression.zstd.CompressionParameter.compression_level.bounds() == (-131072, 22)`.
|
||||||
|
|
||||||
|
Gate everything zstd-related at runtime so nothing is imported or referenced on
|
||||||
|
< 3.14 (the project targets Python ≥ 3.11):
|
||||||
|
|
||||||
|
```python
|
||||||
|
_ZSTD: int | None = getattr(zipfile, "ZIP_ZSTANDARD", None) # None before 3.14
|
||||||
|
```
|
||||||
|
|
||||||
|
Presence of the _constant_ does not guarantee the _codec_ is usable, so the
|
||||||
|
availability probe (validation step 2) imports `compression.zstd`, not merely
|
||||||
|
checks the constant.
|
||||||
|
|
||||||
|
Keep `zstd` in the `--zip-compression` `choices` **always** (even on < 3.14), and
|
||||||
|
reject it in validation with a friendly "zstd requires Python 3.14+" message. If
|
||||||
|
it were dropped from `choices` on older runtimes, argparse would emit a generic
|
||||||
|
"invalid choice" that reads as though the option never existed — worse UX.
|
||||||
|
|
||||||
|
### Import-side compatibility
|
||||||
|
|
||||||
|
`document_importer` reads zips with `ZipFile(self.source).extractall(...)`
|
||||||
|
(`document_importer.py:453`), which decompresses each entry transparently using
|
||||||
|
whatever method it was stored with — **provided the matching module exists on the
|
||||||
|
importing machine.**
|
||||||
|
|
||||||
|
The failure mode when it doesn't is unfriendly and must be handled: a zstd (or
|
||||||
|
otherwise unsupported) entry raises a bare `NotImplementedError` **per-entry,
|
||||||
|
during `extractall`** — _not_ at `ZipFile(self.source)` open, and `is_zipfile()`
|
||||||
|
still returns true (a zstd archive is a valid zip container). So the importer
|
||||||
|
enters the zip branch, creates its temp dir, may partially extract other entries,
|
||||||
|
then blows up mid-extract with no context. **Mitigation (in scope here):** before
|
||||||
|
extracting, inspect `ZipFile(self.source).infolist()` compress types and, if any
|
||||||
|
is unsupported on this runtime, raise a `CommandError` naming the method and the
|
||||||
|
requirement (e.g. "this archive uses zstd, which needs Python 3.14+") instead of
|
||||||
|
letting `NotImplementedError` escape.
|
||||||
|
|
||||||
|
Per-method summary (document in help text + `administration.md`):
|
||||||
|
|
||||||
|
- `deflated`/`stored`: universally importable.
|
||||||
|
- `bzip2`/`lzma`: importable wherever the `bz2`/`lzma` modules are present
|
||||||
|
(essentially always).
|
||||||
|
- `zstd`: importable only on Python 3.14+. An archive compressed with `zstd` is
|
||||||
|
**not** importable on older runtimes.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
New cases in the sink tests and an export→import round-trip
|
||||||
|
(pytest classes, factory-boy, `mocker`, `parametrize`, typed; run on the VM):
|
||||||
|
|
||||||
|
- **Round-trip per method.** Parametrize over the available methods (skip `zstd`
|
||||||
|
below 3.14, skip `bzip2`/`lzma` if the module is somehow absent): export a
|
||||||
|
small library, import it back, assert documents/manifest match.
|
||||||
|
- **Method is applied.** Assert each written _file_ entry's `compress_type`
|
||||||
|
equals the requested method (read back via `ZipFile.infolist()`), filtering out
|
||||||
|
directory marker entries (which are tagged but empty).
|
||||||
|
- **Level affects size — robustly.** Do **not** compare deflate level 9 vs 1
|
||||||
|
(on small or incompressible fixtures level 9 can equal or slightly exceed level
|
||||||
|
1, causing flaky CI). Instead assert that a compressing method on a
|
||||||
|
moderately-compressible fixture yields a total smaller than `stored`
|
||||||
|
(`ZIP_STORED`), which is a stable invariant.
|
||||||
|
- **Validation.** Each flag without `--zip` → `CommandError`; out-of-range level
|
||||||
|
(`--zip-compression-level 99`) → a clean `CommandError` from validation
|
||||||
|
(asserting we never reach the `writestr` that would raise the masked
|
||||||
|
`ValueError`); `--zip-compression-level` with `stored`/`lzma` → `CommandError`;
|
||||||
|
unavailable method (patch the named availability seam with `mocker`) →
|
||||||
|
`CommandError`; on < 3.14, `--zip-compression zstd` → the friendly
|
||||||
|
"requires 3.14+" `CommandError`.
|
||||||
|
- **Import pre-check.** An archive containing an unsupported compress type
|
||||||
|
produces a `CommandError` from the importer naming the method, not a raw
|
||||||
|
`NotImplementedError` (simulate by patching the importer's support probe).
|
||||||
|
- **Default unchanged.** Omitting both flags yields file entries with
|
||||||
|
`compress_type == ZIP_DEFLATED`, identical to pre-feature behavior.
|
||||||
|
|
||||||
|
## Risks
|
||||||
|
|
||||||
|
- **Foot-gun archives.** A user could produce a `zstd`/`lzma` archive their
|
||||||
|
import target can't read. Mitigation: explicit help text and the import-side
|
||||||
|
notes above; the default stays the universally-readable `deflated`.
|
||||||
|
- **Optional-module assumptions.** Don't assume `bz2`/`lzma` are always compiled
|
||||||
|
in; probe and error clearly. Mitigation: the availability validation step.
|
||||||
+2
-2
@@ -458,8 +458,8 @@ For related metadata such as tags, correspondents, document types, and storage p
|
|||||||
### Password reset
|
### Password reset
|
||||||
|
|
||||||
In order to enable the password reset feature you will need to setup an SMTP backend, see
|
In order to enable the password reset feature you will need to setup an SMTP backend, see
|
||||||
[`PAPERLESS_EMAIL_HOST`](configuration.md#PAPERLESS_EMAIL_HOST). If your installation does not have
|
[`PAPERLESS_EMAIL_HOST`](configuration.md#PAPERLESS_EMAIL_HOST). You should also set
|
||||||
[`PAPERLESS_URL`](configuration.md#PAPERLESS_URL) set, the reset link included in emails will use the server host.
|
[`PAPERLESS_URL`](configuration.md#PAPERLESS_URL) and / or its corresponding configuration settings.
|
||||||
|
|
||||||
### Two-factor authentication
|
### Two-factor authentication
|
||||||
|
|
||||||
|
|||||||
+28
-28
@@ -16,7 +16,7 @@ classifiers = [
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"azure-ai-documentintelligence>=1.0.2",
|
"azure-ai-documentintelligence>=1.0.2",
|
||||||
"babel>=2.17",
|
"babel>=2.17",
|
||||||
"bleach~=6.3.0",
|
"bleach~=6.4.0",
|
||||||
"celery[redis]~=5.6.2",
|
"celery[redis]~=5.6.2",
|
||||||
"channels~=4.2",
|
"channels~=4.2",
|
||||||
"channels-redis~=4.2",
|
"channels-redis~=4.2",
|
||||||
@@ -50,13 +50,13 @@ dependencies = [
|
|||||||
"imap-tools~=1.13.0",
|
"imap-tools~=1.13.0",
|
||||||
"jinja2~=3.1.5",
|
"jinja2~=3.1.5",
|
||||||
"langdetect~=1.0.9",
|
"langdetect~=1.0.9",
|
||||||
"llama-index-core>=0.14.21",
|
"llama-index-core>=0.14.22",
|
||||||
"llama-index-embeddings-huggingface>=0.6.1",
|
"llama-index-embeddings-huggingface>=0.6.1",
|
||||||
"llama-index-embeddings-ollama>=0.9",
|
"llama-index-embeddings-ollama>=0.9",
|
||||||
"llama-index-embeddings-openai-like>=0.2.2",
|
"llama-index-embeddings-openai-like>=0.2.2",
|
||||||
"llama-index-llms-ollama>=0.9.1",
|
"llama-index-llms-ollama>=0.9.1",
|
||||||
"llama-index-llms-openai-like>=0.7.1",
|
"llama-index-llms-openai-like>=0.7.1",
|
||||||
"nltk~=3.9.1",
|
"nltk~=3.10.0",
|
||||||
"ocrmypdf~=17.4.2",
|
"ocrmypdf~=17.4.2",
|
||||||
"openai>=2.32",
|
"openai>=2.32",
|
||||||
"pathvalidate~=3.3.1",
|
"pathvalidate~=3.3.1",
|
||||||
@@ -75,7 +75,7 @@ dependencies = [
|
|||||||
"sqlite-vec==0.1.9",
|
"sqlite-vec==0.1.9",
|
||||||
"tantivy~=0.26.0",
|
"tantivy~=0.26.0",
|
||||||
"tika-client~=0.11.0",
|
"tika-client~=0.11.0",
|
||||||
"torch~=2.11.0",
|
"torch~=2.12.0",
|
||||||
"watchfiles>=1.1.1",
|
"watchfiles>=1.1.1",
|
||||||
"whitenoise~=6.11",
|
"whitenoise~=6.11",
|
||||||
"zxing-cpp~=3.0.0",
|
"zxing-cpp~=3.0.0",
|
||||||
@@ -88,7 +88,7 @@ postgres = [
|
|||||||
"psycopg[c,pool]==3.3",
|
"psycopg[c,pool]==3.3",
|
||||||
# Direct dependency for proper resolution of the pre-built wheels
|
# Direct dependency for proper resolution of the pre-built wheels
|
||||||
"psycopg-c==3.3",
|
"psycopg-c==3.3",
|
||||||
"psycopg-pool==3.3",
|
"psycopg-pool==3.3.1",
|
||||||
]
|
]
|
||||||
webserver = [
|
webserver = [
|
||||||
"granian[uvloop]~=2.7.0",
|
"granian[uvloop]~=2.7.0",
|
||||||
@@ -101,11 +101,11 @@ dev = [
|
|||||||
{ include-group = "testing" },
|
{ include-group = "testing" },
|
||||||
]
|
]
|
||||||
docs = [
|
docs = [
|
||||||
"zensical>=0.0.36",
|
"zensical>=0.0.47",
|
||||||
]
|
]
|
||||||
lint = [
|
lint = [
|
||||||
"prek~=0.3.10",
|
"prek~=0.3.10",
|
||||||
"ruff~=0.15.12",
|
"ruff~=0.15.20",
|
||||||
]
|
]
|
||||||
testing = [
|
testing = [
|
||||||
"daphne",
|
"daphne",
|
||||||
@@ -245,50 +245,38 @@ per-file-ignores."src/documents/models.py" = [
|
|||||||
isort.force-single-line = true
|
isort.force-single-line = true
|
||||||
|
|
||||||
[tool.codespell]
|
[tool.codespell]
|
||||||
write-changes = true
|
|
||||||
ignore-words-list = "criterias,afterall,valeu,ureue,equest,ure,assertIn,Oktober,commitish"
|
ignore-words-list = "criterias,afterall,valeu,ureue,equest,ure,assertIn,Oktober,commitish"
|
||||||
skip = """\
|
skip = """\
|
||||||
src-ui/src/locale/*,src-ui/pnpm-lock.yaml,src-ui/e2e/*,src/paperless_mail/tests/samples/*,src/paperless/tests/samples\
|
src-ui/src/locale/*,src-ui/pnpm-lock.yaml,src-ui/e2e/*,src/paperless_mail/tests/samples/*,src/paperless/tests/samples\
|
||||||
/mail/*,src/documents/tests/samples/*,*.po,*.json\
|
/mail/*,src/documents/tests/samples/*,*.po,*.json\
|
||||||
"""
|
"""
|
||||||
|
write-changes = true
|
||||||
|
|
||||||
[tool.pyproject-fmt]
|
[tool.pyproject-fmt]
|
||||||
table_format = "long"
|
table_format = "long"
|
||||||
|
|
||||||
[tool.mypy]
|
[tool.mypy]
|
||||||
mypy_path = "src"
|
mypy_path = "src"
|
||||||
|
disallow_any_generics = true
|
||||||
|
disallow_untyped_defs = true
|
||||||
|
disallow_incomplete_defs = true
|
||||||
|
check_untyped_defs = true
|
||||||
|
warn_redundant_casts = true
|
||||||
|
warn_unused_ignores = true
|
||||||
plugins = [
|
plugins = [
|
||||||
"mypy_django_plugin.main",
|
"mypy_django_plugin.main",
|
||||||
"mypy_drf_plugin.main",
|
"mypy_drf_plugin.main",
|
||||||
]
|
]
|
||||||
check_untyped_defs = true
|
|
||||||
disallow_any_generics = true
|
|
||||||
disallow_incomplete_defs = true
|
|
||||||
disallow_untyped_defs = true
|
|
||||||
warn_redundant_casts = true
|
|
||||||
warn_unused_ignores = true
|
|
||||||
|
|
||||||
[tool.pyrefly]
|
[tool.pyrefly]
|
||||||
search-path = [ "src" ]
|
|
||||||
baseline = ".pyrefly-baseline.json"
|
baseline = ".pyrefly-baseline.json"
|
||||||
python-platform = "linux"
|
python-platform = "linux"
|
||||||
|
search-path = [ "src" ]
|
||||||
|
|
||||||
[tool.django-stubs]
|
[tool.django-stubs]
|
||||||
django_settings_module = "paperless.settings"
|
django_settings_module = "paperless.settings"
|
||||||
|
|
||||||
[tool.pytest]
|
[tool.pytest]
|
||||||
minversion = "9.0"
|
|
||||||
pythonpath = [ "src" ]
|
|
||||||
strict_config = true
|
|
||||||
strict_markers = true
|
|
||||||
strict_parametrization_ids = true
|
|
||||||
strict_xfail = true
|
|
||||||
testpaths = [
|
|
||||||
"src/documents/tests/",
|
|
||||||
"src/paperless/tests/",
|
|
||||||
"src/paperless_mail/tests/",
|
|
||||||
"src/paperless_ai/tests",
|
|
||||||
]
|
|
||||||
addopts = [
|
addopts = [
|
||||||
"--pythonwarnings=all",
|
"--pythonwarnings=all",
|
||||||
"--cov",
|
"--cov",
|
||||||
@@ -303,7 +291,6 @@ addopts = [
|
|||||||
"-o",
|
"-o",
|
||||||
"junit_family=legacy",
|
"junit_family=legacy",
|
||||||
]
|
]
|
||||||
norecursedirs = [ "src/locale/", ".venv/", "src-ui/" ]
|
|
||||||
DJANGO_SETTINGS_MODULE = "paperless.settings"
|
DJANGO_SETTINGS_MODULE = "paperless.settings"
|
||||||
markers = [
|
markers = [
|
||||||
"live: Integration tests requiring external services (Gotenberg, Tika, nginx, etc)",
|
"live: Integration tests requiring external services (Gotenberg, Tika, nginx, etc)",
|
||||||
@@ -316,6 +303,19 @@ markers = [
|
|||||||
"search: Tests for the Tantivy search backend",
|
"search: Tests for the Tantivy search backend",
|
||||||
"api: Tests for REST API endpoints",
|
"api: Tests for REST API endpoints",
|
||||||
]
|
]
|
||||||
|
minversion = "9.0"
|
||||||
|
norecursedirs = [ "src/locale/", ".venv/", "src-ui/" ]
|
||||||
|
pythonpath = [ "src" ]
|
||||||
|
strict_config = true
|
||||||
|
strict_markers = true
|
||||||
|
strict_parametrization_ids = true
|
||||||
|
strict_xfail = true
|
||||||
|
testpaths = [
|
||||||
|
"src/documents/tests/",
|
||||||
|
"src/paperless/tests/",
|
||||||
|
"src/paperless_mail/tests/",
|
||||||
|
"src/paperless_ai/tests",
|
||||||
|
]
|
||||||
|
|
||||||
[tool.pytest_env]
|
[tool.pytest_env]
|
||||||
PAPERLESS_SECRET_KEY = "test-secret-key-do-not-use-in-production"
|
PAPERLESS_SECRET_KEY = "test-secret-key-do-not-use-in-production"
|
||||||
|
|||||||
+13
-1
@@ -79,6 +79,16 @@
|
|||||||
"glob": "{pdf.worker.min.mjs,pdf.min.mjs}",
|
"glob": "{pdf.worker.min.mjs,pdf.min.mjs}",
|
||||||
"input": "node_modules/pdfjs-dist/legacy/build/",
|
"input": "node_modules/pdfjs-dist/legacy/build/",
|
||||||
"output": "/assets/js/"
|
"output": "/assets/js/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"glob": "**/*",
|
||||||
|
"input": "node_modules/pdfjs-dist/wasm/",
|
||||||
|
"output": "/assets/wasm/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"glob": "**/*",
|
||||||
|
"input": "node_modules/pdfjs-dist/iccs/",
|
||||||
|
"output": "/assets/iccs/"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"styles": [
|
"styles": [
|
||||||
@@ -155,7 +165,9 @@
|
|||||||
"builder": "@angular-builders/jest:run",
|
"builder": "@angular-builders/jest:run",
|
||||||
"options": {
|
"options": {
|
||||||
"tsConfig": "tsconfig.spec.json",
|
"tsConfig": "tsconfig.spec.json",
|
||||||
"zoneless": false
|
"zoneless": true,
|
||||||
|
"watch": false,
|
||||||
|
"coverage": true
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"lint": {
|
"lint": {
|
||||||
|
|||||||
+24
-5
@@ -3,6 +3,27 @@ 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/**'],
|
||||||
@@ -12,8 +33,6 @@ module.exports = [
|
|||||||
languageOptions: {
|
languageOptions: {
|
||||||
parser: tsParser,
|
parser: tsParser,
|
||||||
parserOptions: {
|
parserOptions: {
|
||||||
project: ['tsconfig.json'],
|
|
||||||
createDefaultProgram: true,
|
|
||||||
ecmaVersion: 2020,
|
ecmaVersion: 2020,
|
||||||
sourceType: 'module',
|
sourceType: 'module',
|
||||||
},
|
},
|
||||||
@@ -22,9 +41,9 @@ module.exports = [
|
|||||||
'@angular-eslint': angularEslintPlugin,
|
'@angular-eslint': angularEslintPlugin,
|
||||||
'@angular-eslint/template': angularTemplatePlugin,
|
'@angular-eslint/template': angularTemplatePlugin,
|
||||||
},
|
},
|
||||||
processor: '@angular-eslint/template/extract-inline-html',
|
processor: angularTemplatePlugin.processors['extract-inline-html'],
|
||||||
rules: {
|
rules: {
|
||||||
...angularEslintPlugin.configs.recommended.rules,
|
...angularTsRecommendedRules,
|
||||||
'@angular-eslint/directive-selector': [
|
'@angular-eslint/directive-selector': [
|
||||||
'error',
|
'error',
|
||||||
{
|
{
|
||||||
@@ -52,7 +71,7 @@ module.exports = [
|
|||||||
'@angular-eslint/template': angularTemplatePlugin,
|
'@angular-eslint/template': angularTemplatePlugin,
|
||||||
},
|
},
|
||||||
rules: {
|
rules: {
|
||||||
...angularTemplatePlugin.configs.recommended.rules,
|
...angularTemplateRecommendedRules,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|||||||
+873
-767
File diff suppressed because it is too large
Load Diff
+37
-48
@@ -6,22 +6,21 @@
|
|||||||
"ng": "ng",
|
"ng": "ng",
|
||||||
"start": "ng serve",
|
"start": "ng serve",
|
||||||
"build": "ng build",
|
"build": "ng build",
|
||||||
"test": "ng test --no-watch --coverage",
|
"test": "ng test",
|
||||||
"lint": "ng lint"
|
"lint": "ng lint"
|
||||||
},
|
},
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@angular/cdk": "^21.2.12",
|
"@angular/cdk": "^22.0.3",
|
||||||
"@angular/common": "~21.2.14",
|
"@angular/common": "~22.0.5",
|
||||||
"@angular/compiler": "~21.2.14",
|
"@angular/compiler": "~22.0.5",
|
||||||
"@angular/core": "~21.2.14",
|
"@angular/core": "~22.0.5",
|
||||||
"@angular/forms": "~21.2.14",
|
"@angular/forms": "~22.0.5",
|
||||||
"@angular/localize": "~21.2.14",
|
"@angular/localize": "~22.0.5",
|
||||||
"@angular/platform-browser": "~21.2.14",
|
"@angular/platform-browser": "~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,56 +28,46 @@
|
|||||||
"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": "^21.3.1",
|
"ngx-cookie-service": "^22.0.0",
|
||||||
"ngx-device-detector": "^11.0.0",
|
"ngx-device-detector": "^12.0.0",
|
||||||
"ngx-ui-tour-ng-bootstrap": "^18.0.0",
|
"ngx-ui-tour-ng-bootstrap": "^19.0.0",
|
||||||
"normalize-diacritics": "^5.0.0",
|
"normalize-diacritics": "^5.0.0",
|
||||||
"pdfjs-dist": "^5.7.284",
|
"pdfjs-dist": "^6.0.227",
|
||||||
"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.0",
|
"uuid": "^14.0.1"
|
||||||
"zone.js": "^0.16.2"
|
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@angular-builders/custom-webpack": "^21.0.3",
|
"@angular-builders/custom-webpack": "^22.0.1",
|
||||||
"@angular-builders/jest": "^21.0.3",
|
"@angular-builders/jest": "^22.0.1",
|
||||||
"@angular-devkit/core": "^21.2.12",
|
"@angular-devkit/core": "^22.0.5",
|
||||||
"@angular-devkit/schematics": "^21.2.12",
|
"@angular-devkit/schematics": "^22.0.5",
|
||||||
"@angular-eslint/builder": "21.4.0",
|
"@angular-eslint/builder": "22.0.0",
|
||||||
"@angular-eslint/eslint-plugin": "21.4.0",
|
"@angular-eslint/eslint-plugin": "22.0.0",
|
||||||
"@angular-eslint/eslint-plugin-template": "21.4.0",
|
"@angular-eslint/eslint-plugin-template": "22.0.0",
|
||||||
"@angular-eslint/schematics": "21.4.0",
|
"@angular-eslint/schematics": "22.0.0",
|
||||||
"@angular-eslint/template-parser": "21.4.0",
|
"@angular-eslint/template-parser": "22.0.0",
|
||||||
"@angular/build": "^21.2.12",
|
"@angular/build": "^22.0.5",
|
||||||
"@angular/cli": "~21.2.12",
|
"@angular/cli": "~22.0.5",
|
||||||
"@angular/compiler-cli": "~21.2.14",
|
"@angular/compiler-cli": "~22.0.5",
|
||||||
"@codecov/webpack-plugin": "^2.0.1",
|
"@codecov/webpack-plugin": "^2.0.1",
|
||||||
"@playwright/test": "^1.60.0",
|
"@playwright/test": "^1.61.1",
|
||||||
"@types/jest": "^30.0.0",
|
"@types/jest": "^30.0.0",
|
||||||
"@types/node": "^25.9.1",
|
"@types/node": "^26.0.0",
|
||||||
"@typescript-eslint/eslint-plugin": "^8.60.0",
|
"@typescript-eslint/eslint-plugin": "^8.62.0",
|
||||||
"@typescript-eslint/parser": "^8.60.0",
|
"@typescript-eslint/parser": "^8.62.0",
|
||||||
"@typescript-eslint/utils": "^8.60.0",
|
"@typescript-eslint/utils": "^8.62.0",
|
||||||
"eslint": "^10.4.0",
|
"eslint": "^10.5.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": "^16.1.5",
|
"jest-preset-angular": "^17.0.0",
|
||||||
"jest-websocket-mock": "^2.5.0",
|
"jest-websocket-mock": "^2.5.0",
|
||||||
"prettier-plugin-organize-imports": "^4.3.0",
|
"prettier-plugin-organize-imports": "^4.3.0",
|
||||||
"ts-node": "~10.9.1",
|
"ts-node": "~10.9.1",
|
||||||
"typescript": "^5.9.3",
|
"typescript": "^6.0.3",
|
||||||
"webpack": "^5.107.2"
|
"webpack": "^5.107.2"
|
||||||
},
|
},
|
||||||
"packageManager": "pnpm@10.17.1",
|
"packageManager": "pnpm@10.26.0"
|
||||||
"pnpm": {
|
|
||||||
"onlyBuiltDependencies": [
|
|
||||||
"@parcel/watcher",
|
|
||||||
"canvas",
|
|
||||||
"esbuild",
|
|
||||||
"lmdb",
|
|
||||||
"msgpackr-extract"
|
|
||||||
]
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+2928
-4027
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,21 @@
|
|||||||
|
packages:
|
||||||
|
- "."
|
||||||
|
minimumReleaseAge: 10080
|
||||||
|
trustPolicy: no-downgrade
|
||||||
|
allowBuilds:
|
||||||
|
"@parcel/watcher": true
|
||||||
|
canvas: true
|
||||||
|
esbuild: true
|
||||||
|
lmdb: true
|
||||||
|
msgpackr-extract: true
|
||||||
|
normalize-diacritics: false
|
||||||
|
unrs-resolver: false
|
||||||
|
onlyBuiltDependencies:
|
||||||
|
- "@parcel/watcher"
|
||||||
|
- canvas
|
||||||
|
- esbuild
|
||||||
|
- lmdb
|
||||||
|
- msgpackr-extract
|
||||||
|
ignoredBuiltDependencies:
|
||||||
|
- normalize-diacritics
|
||||||
|
- unrs-resolver
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
import '@angular/localize/init'
|
import '@angular/localize/init'
|
||||||
import { jest } from '@jest/globals'
|
import { jest } from '@jest/globals'
|
||||||
import { setupZoneTestEnv } from 'jest-preset-angular/setup-env/zone'
|
import { setupZonelessTestEnv } from 'jest-preset-angular/setup-env/zoneless'
|
||||||
import { TextDecoder, TextEncoder } from 'node:util'
|
import { TextDecoder, TextEncoder } from 'node:util'
|
||||||
if (process.env.NODE_ENV === 'test') {
|
if (process.env.NODE_ENV === 'test') {
|
||||||
setupZoneTestEnv()
|
setupZonelessTestEnv()
|
||||||
}
|
}
|
||||||
;(globalThis as any).TextEncoder = TextEncoder as unknown as {
|
;(globalThis as any).TextEncoder = TextEncoder as unknown as {
|
||||||
new (): TextEncoder
|
new (): TextEncoder
|
||||||
|
|||||||
@@ -1,11 +1,6 @@
|
|||||||
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
|
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
|
||||||
import { provideHttpClientTesting } from '@angular/common/http/testing'
|
import { provideHttpClientTesting } from '@angular/common/http/testing'
|
||||||
import {
|
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||||
ComponentFixture,
|
|
||||||
fakeAsync,
|
|
||||||
TestBed,
|
|
||||||
tick,
|
|
||||||
} from '@angular/core/testing'
|
|
||||||
import { Router, RouterModule } from '@angular/router'
|
import { Router, RouterModule } from '@angular/router'
|
||||||
import { NgbModalModule } from '@ng-bootstrap/ng-bootstrap'
|
import { NgbModalModule } from '@ng-bootstrap/ng-bootstrap'
|
||||||
import { allIcons, NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
import { allIcons, NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||||
@@ -72,7 +67,8 @@ describe('AppComponent', () => {
|
|||||||
component = fixture.componentInstance
|
component = fixture.componentInstance
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should initialize the tour service & toggle class on body for styling', fakeAsync(() => {
|
it('should initialize the tour service & toggle class on body for styling', () => {
|
||||||
|
jest.useFakeTimers()
|
||||||
jest.spyOn(console, 'warn').mockImplementation(() => {})
|
jest.spyOn(console, 'warn').mockImplementation(() => {})
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
const tourSpy = jest.spyOn(tourService, 'initialize')
|
const tourSpy = jest.spyOn(tourService, 'initialize')
|
||||||
@@ -81,9 +77,10 @@ describe('AppComponent', () => {
|
|||||||
tourService.start()
|
tourService.start()
|
||||||
expect(document.body.classList).toContain('tour-active')
|
expect(document.body.classList).toContain('tour-active')
|
||||||
tourService.end()
|
tourService.end()
|
||||||
tick(500)
|
jest.advanceTimersByTime(500)
|
||||||
expect(document.body.classList).not.toContain('tour-active')
|
expect(document.body.classList).not.toContain('tour-active')
|
||||||
}))
|
jest.useRealTimers()
|
||||||
|
})
|
||||||
|
|
||||||
it('should display toast on document consumed with link if user has access', () => {
|
it('should display toast on document consumed with link if user has access', () => {
|
||||||
const navigateSpy = jest.spyOn(router, 'navigate')
|
const navigateSpy = jest.spyOn(router, 'navigate')
|
||||||
|
|||||||
@@ -59,10 +59,10 @@
|
|||||||
<div [ngbNavOutlet]="nav" class="border-start border-end border-bottom p-3 mb-3 shadow-sm"></div>
|
<div [ngbNavOutlet]="nav" class="border-start border-end border-bottom p-3 mb-3 shadow-sm"></div>
|
||||||
<div class="btn-toolbar" role="toolbar">
|
<div class="btn-toolbar" role="toolbar">
|
||||||
<div class="btn-group me-2">
|
<div class="btn-group me-2">
|
||||||
<button type="button" (click)="discardChanges()" class="btn btn-outline-secondary" [disabled]="loading || (isDirty$ | async) === false" i18n>Cancel</button>
|
<button type="button" (click)="discardChanges()" class="btn btn-outline-secondary" [disabled]="loading() || (isDirty$ | async) === false" i18n>Cancel</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="btn-group">
|
<div class="btn-group">
|
||||||
<button type="submit" class="btn btn-primary" [disabled]="loading || !configForm.valid || (isDirty$ | async) === false" i18n>Save</button>
|
<button type="submit" class="btn btn-primary" [disabled]="loading() || !configForm.valid || (isDirty$ | async) === false" i18n>Save</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -97,11 +97,11 @@ export class ConfigComponent
|
|||||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||||
.subscribe({
|
.subscribe({
|
||||||
next: (config) => {
|
next: (config) => {
|
||||||
this.loading = false
|
this.loading.set(false)
|
||||||
this.initialize(config)
|
this.initialize(config)
|
||||||
},
|
},
|
||||||
error: (e) => {
|
error: (e) => {
|
||||||
this.loading = false
|
this.loading.set(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 = true
|
this.loading.set(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 = false
|
this.loading.set(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 = false
|
this.loading.set(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 = true
|
this.loading.set(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 = false
|
this.loading.set(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 = false
|
this.loading.set(false)
|
||||||
this.toastService.showError(
|
this.toastService.showError(
|
||||||
$localize`An error occurred uploading file`,
|
$localize`An error occurred uploading file`,
|
||||||
e
|
e
|
||||||
|
|||||||
@@ -5,36 +5,37 @@
|
|||||||
i18n-info>
|
i18n-info>
|
||||||
<div class="input-group input-group-sm align-items-center">
|
<div class="input-group input-group-sm align-items-center">
|
||||||
<div class="input-group input-group-sm me-3">
|
<div class="input-group input-group-sm me-3">
|
||||||
<span class="input-group-text text-muted" i18n>Show</span>
|
<label class="input-group-text text-muted" for="logLineLimit" i18n>Show</label>
|
||||||
<input
|
<input
|
||||||
|
id="logLineLimit"
|
||||||
class="form-control"
|
class="form-control"
|
||||||
type="number"
|
type="number"
|
||||||
min="100"
|
min="100"
|
||||||
step="100"
|
step="100"
|
||||||
[(ngModel)]="limit"
|
[ngModel]="limit()"
|
||||||
(ngModelChange)="onLimitChange($event)"
|
(ngModelChange)="limit.set($event); 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">
|
<input id="autoRefreshSwitch" class="form-check-input" type="checkbox" role="switch" [ngModel]="autoRefreshEnabled()" (ngModelChange)="autoRefreshEnabled.set($event)">
|
||||||
<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)="reloadLogs()" class="nav-tabs">
|
<ul ngbNav #nav="ngbNav" [activeId]="activeLog()" (activeIdChange)="activeLog.set($event); 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 +43,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 +57,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 = false
|
component.autoRefreshEnabled.set(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 = false
|
component.showJumpToBottom.set(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,6 +7,7 @@ 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'
|
||||||
@@ -34,17 +35,17 @@ export class LogsComponent
|
|||||||
private logService = inject(LogService)
|
private logService = inject(LogService)
|
||||||
private changedetectorRef = inject(ChangeDetectorRef)
|
private changedetectorRef = inject(ChangeDetectorRef)
|
||||||
|
|
||||||
public logs: Array<{ message: string; level: number }> = []
|
readonly logs = signal<Array<{ message: string; level: number }>>([])
|
||||||
|
|
||||||
public logFiles: string[] = []
|
readonly logFiles = signal<string[]>([])
|
||||||
|
|
||||||
public activeLog: string
|
readonly activeLog = signal<string>(undefined)
|
||||||
|
|
||||||
public autoRefreshEnabled: boolean = true
|
readonly autoRefreshEnabled = signal<boolean>(true)
|
||||||
|
|
||||||
public limit: number = 5000
|
readonly limit = signal<number>(5000)
|
||||||
|
|
||||||
public showJumpToBottom = false
|
readonly showJumpToBottom = signal<boolean>(false)
|
||||||
|
|
||||||
private readonly limitChange$ = new Subject<number>()
|
private readonly limitChange$ = new Subject<number>()
|
||||||
|
|
||||||
@@ -59,15 +60,15 @@ export class LogsComponent
|
|||||||
.list()
|
.list()
|
||||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||||
.subscribe((result) => {
|
.subscribe((result) => {
|
||||||
this.logFiles = result
|
this.logFiles.set(result)
|
||||||
this.loading = false
|
this.loading.set(false)
|
||||||
if (this.logFiles.length > 0) {
|
if (this.logFiles().length > 0) {
|
||||||
this.activeLog = this.logFiles[0]
|
this.activeLog.set(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(() => {
|
||||||
@@ -85,36 +86,34 @@ export class LogsComponent
|
|||||||
}
|
}
|
||||||
|
|
||||||
reloadLogs() {
|
reloadLogs() {
|
||||||
this.loading = true
|
this.loading.set(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 = false
|
this.loading.set(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?.message !== log.message || current?.level !== log.level
|
||||||
current.message !== log.message ||
|
|
||||||
current.level !== log.level
|
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
if (hasChanges) {
|
if (hasChanges) {
|
||||||
this.logs = parsed
|
this.logs.set(parsed)
|
||||||
if (shouldStickToBottom) {
|
if (shouldStickToBottom) {
|
||||||
this.scrollToBottom()
|
this.scrollToBottom()
|
||||||
}
|
}
|
||||||
this.showJumpToBottom = !shouldStickToBottom
|
this.showJumpToBottom.set(!shouldStickToBottom)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
error: () => {
|
error: () => {
|
||||||
this.logs = []
|
this.logs.set([])
|
||||||
this.loading = false
|
this.loading.set(false)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -149,7 +148,7 @@ export class LogsComponent
|
|||||||
}
|
}
|
||||||
this.changedetectorRef.detectChanges()
|
this.changedetectorRef.detectChanges()
|
||||||
viewport.scrollTop = viewport.scrollHeight
|
viewport.scrollTop = viewport.scrollHeight
|
||||||
this.showJumpToBottom = false
|
this.showJumpToBottom.set(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
private isNearBottom(): boolean {
|
private isNearBottom(): boolean {
|
||||||
@@ -162,6 +161,6 @@ export class LogsComponent
|
|||||||
}
|
}
|
||||||
|
|
||||||
onScroll(): void {
|
onScroll(): void {
|
||||||
this.showJumpToBottom = !this.isNearBottom()
|
this.showJumpToBottom.set(!this.isNearBottom())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,8 +9,8 @@
|
|||||||
</button>
|
</button>
|
||||||
@if (canViewSystemStatus) {
|
@if (canViewSystemStatus) {
|
||||||
<button class="btn btn-sm btn-outline-primary position-relative ms-md-5 me-1" (click)="showSystemStatus()"
|
<button class="btn btn-sm btn-outline-primary position-relative ms-md-5 me-1" (click)="showSystemStatus()"
|
||||||
[disabled]="!systemStatus">
|
[disabled]="!systemStatus()">
|
||||||
@if (!systemStatus) {
|
@if (!systemStatus()) {
|
||||||
<div class="spinner-border spinner-border-sm me-2 h-75" role="status"></div>
|
<div class="spinner-border spinner-border-sm me-2 h-75" role="status"></div>
|
||||||
} @else {
|
} @else {
|
||||||
<i-bs class="me-2" name="card-checklist"></i-bs>
|
<i-bs class="me-2" name="card-checklist"></i-bs>
|
||||||
@@ -37,7 +37,7 @@
|
|||||||
|
|
||||||
<form [formGroup]="settingsForm" (ngSubmit)="saveSettings()">
|
<form [formGroup]="settingsForm" (ngSubmit)="saveSettings()">
|
||||||
|
|
||||||
<ul ngbNav #nav="ngbNav" (navChange)="onNavChange($event)" [(activeId)]="activeNavID" class="nav-tabs">
|
<ul ngbNav #nav="ngbNav" (navChange)="onNavChange($event)" [activeId]="activeNavID()" (activeIdChange)="activeNavID.set($event)" class="nav-tabs">
|
||||||
<li [ngbNavItem]="SettingsNavIDs.General">
|
<li [ngbNavItem]="SettingsNavIDs.General">
|
||||||
<a ngbNavLink i18n>General</a>
|
<a ngbNavLink i18n>General</a>
|
||||||
<ng-template ngbNavContent>
|
<ng-template ngbNavContent>
|
||||||
@@ -150,10 +150,10 @@
|
|||||||
|
|
||||||
<div class="row mb-3">
|
<div class="row mb-3">
|
||||||
<div class="col-md-3 col-form-label pt-0">
|
<div class="col-md-3 col-form-label pt-0">
|
||||||
<span i18n>Full search links to</span>
|
<label for="searchLink" i18n>Full search links to</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="col mb-3">
|
<div class="col mb-3">
|
||||||
<select class="form-select" formControlName="searchLink">
|
<select id="searchLink" class="form-select" formControlName="searchLink">
|
||||||
<option [ngValue]="GlobalSearchType.TITLE_CONTENT" i18n>Title and content search</option>
|
<option [ngValue]="GlobalSearchType.TITLE_CONTENT" i18n>Title and content search</option>
|
||||||
<option [ngValue]="GlobalSearchType.ADVANCED" i18n>Advanced search</option>
|
<option [ngValue]="GlobalSearchType.ADVANCED" i18n>Advanced search</option>
|
||||||
</select>
|
</select>
|
||||||
@@ -199,10 +199,10 @@
|
|||||||
<h5 i18n>Documents</h5>
|
<h5 i18n>Documents</h5>
|
||||||
<div class="row mb-3">
|
<div class="row mb-3">
|
||||||
<div class="col-md-3 col-form-label pt-0">
|
<div class="col-md-3 col-form-label pt-0">
|
||||||
<span i18n>Items per page</span>
|
<label for="documentListItemPerPage" i18n>Items per page</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="col">
|
<div class="col">
|
||||||
<select class="form-select" formControlName="documentListItemPerPage">
|
<select id="documentListItemPerPage" class="form-select" formControlName="documentListItemPerPage">
|
||||||
<option [ngValue]="10">10</option>
|
<option [ngValue]="10">10</option>
|
||||||
<option [ngValue]="25">25</option>
|
<option [ngValue]="25">25</option>
|
||||||
<option [ngValue]="50">50</option>
|
<option [ngValue]="50">50</option>
|
||||||
@@ -274,10 +274,10 @@
|
|||||||
<h5 class="mt-3" i18n>PDF Editor</h5>
|
<h5 class="mt-3" i18n>PDF Editor</h5>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-md-3 col-form-label pt-0">
|
<div class="col-md-3 col-form-label pt-0">
|
||||||
<span i18n>Default editing mode</span>
|
<label for="pdfEditorDefaultEditMode" i18n>Default editing mode</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="col">
|
<div class="col">
|
||||||
<select class="form-select" formControlName="pdfEditorDefaultEditMode">
|
<select id="pdfEditorDefaultEditMode" class="form-select" formControlName="pdfEditorDefaultEditMode">
|
||||||
<option [ngValue]="PdfEditorEditMode.Create" i18n>Create new document(s)</option>
|
<option [ngValue]="PdfEditorEditMode.Create" i18n>Create new document(s)</option>
|
||||||
<option [ngValue]="PdfEditorEditMode.Update" i18n>Add document version</option>
|
<option [ngValue]="PdfEditorEditMode.Update" i18n>Add document version</option>
|
||||||
</select>
|
</select>
|
||||||
@@ -313,7 +313,7 @@
|
|||||||
<span i18n>Default Owner</span>
|
<span i18n>Default Owner</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-5">
|
<div class="col-md-5">
|
||||||
<pngx-input-select [items]="users" bindLabel="username" formControlName="defaultPermsOwner" [allowNull]="true"></pngx-input-select>
|
<pngx-input-select [items]="users()" bindLabel="username" formControlName="defaultPermsOwner" [allowNull]="true"></pngx-input-select>
|
||||||
<small class="form-text text-muted text-end d-block mt-n2" i18n>Objects without an owner can be viewed and edited by all users</small>
|
<small class="form-text text-muted text-end d-block mt-n2" i18n>Objects without an owner can be viewed and edited by all users</small>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -99,6 +99,13 @@ const status: SystemStatus = {
|
|||||||
llmindex_status: SystemStatusItemStatus.DISABLED,
|
llmindex_status: SystemStatusItemStatus.DISABLED,
|
||||||
llmindex_last_modified: new Date().toISOString(),
|
llmindex_last_modified: new Date().toISOString(),
|
||||||
llmindex_error: null,
|
llmindex_error: null,
|
||||||
|
summary: {
|
||||||
|
days: 30,
|
||||||
|
total_count: 12,
|
||||||
|
pending_count: 1,
|
||||||
|
success_count: 10,
|
||||||
|
failure_count: 1,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -161,7 +168,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 = users[0]
|
settingsService.currentUser.set(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)
|
||||||
@@ -202,7 +209,7 @@ describe('SettingsComponent', () => {
|
|||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
}
|
}
|
||||||
|
|
||||||
it('should support tabbed settings & change URL, prevent navigation if dirty confirmation rejected', () => {
|
it('should support tabbed settings & change URL, prevent navigation if dirty confirmation rejected', async () => {
|
||||||
completeSetup()
|
completeSetup()
|
||||||
const navigateSpy = jest.spyOn(router, 'navigate')
|
const navigateSpy = jest.spyOn(router, 'navigate')
|
||||||
const tabButtons = fixture.debugElement.queryAll(By.directive(NgbNavLink))
|
const tabButtons = fixture.debugElement.queryAll(By.directive(NgbNavLink))
|
||||||
@@ -210,16 +217,19 @@ describe('SettingsComponent', () => {
|
|||||||
expect(navigateSpy).toHaveBeenCalledWith(['settings', 'documents'])
|
expect(navigateSpy).toHaveBeenCalledWith(['settings', 'documents'])
|
||||||
tabButtons[2].nativeElement.dispatchEvent(new MouseEvent('click'))
|
tabButtons[2].nativeElement.dispatchEvent(new MouseEvent('click'))
|
||||||
expect(navigateSpy).toHaveBeenCalledWith(['settings', 'permissions'])
|
expect(navigateSpy).toHaveBeenCalledWith(['settings', 'permissions'])
|
||||||
|
await fixture.whenStable()
|
||||||
|
|
||||||
const initSpy = jest.spyOn(component, 'initialize')
|
const initSpy = jest.spyOn(component, 'initialize')
|
||||||
component.isDirty = true // mock dirty
|
component.isDirty = true // mock dirty
|
||||||
navigateSpy.mockResolvedValueOnce(false) // nav rejected cause dirty
|
navigateSpy.mockResolvedValueOnce(false) // nav rejected cause dirty
|
||||||
tabButtons[0].nativeElement.dispatchEvent(new MouseEvent('click'))
|
tabButtons[0].nativeElement.dispatchEvent(new MouseEvent('click'))
|
||||||
|
await fixture.whenStable()
|
||||||
expect(navigateSpy).toHaveBeenCalledWith(['settings', 'general'])
|
expect(navigateSpy).toHaveBeenCalledWith(['settings', 'general'])
|
||||||
expect(initSpy).not.toHaveBeenCalled()
|
expect(initSpy).not.toHaveBeenCalled()
|
||||||
|
|
||||||
navigateSpy.mockResolvedValueOnce(true) // nav accepted even though dirty
|
navigateSpy.mockResolvedValueOnce(true) // nav accepted even though dirty
|
||||||
tabButtons[2].nativeElement.dispatchEvent(new MouseEvent('click'))
|
tabButtons[2].nativeElement.dispatchEvent(new MouseEvent('click'))
|
||||||
|
await fixture.whenStable()
|
||||||
expect(navigateSpy).toHaveBeenCalledWith(['settings', 'permissions'])
|
expect(navigateSpy).toHaveBeenCalledWith(['settings', 'permissions'])
|
||||||
expect(initSpy).toHaveBeenCalled()
|
expect(initSpy).toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
@@ -232,7 +242,7 @@ describe('SettingsComponent', () => {
|
|||||||
activatedRoute.snapshot.fragment = '#notifications'
|
activatedRoute.snapshot.fragment = '#notifications'
|
||||||
const scrollSpy = jest.spyOn(viewportScroller, 'scrollToAnchor')
|
const scrollSpy = jest.spyOn(viewportScroller, 'scrollToAnchor')
|
||||||
component.ngOnInit()
|
component.ngOnInit()
|
||||||
expect(component.activeNavID).toEqual(4) // Notifications
|
expect(component.activeNavID()).toEqual(4) // Notifications
|
||||||
component.ngAfterViewInit()
|
component.ngAfterViewInit()
|
||||||
expect(scrollSpy).toHaveBeenCalledWith('#notifications')
|
expect(scrollSpy).toHaveBeenCalledWith('#notifications')
|
||||||
})
|
})
|
||||||
@@ -340,13 +350,13 @@ describe('SettingsComponent', () => {
|
|||||||
type === PermissionType.SystemMonitoring
|
type === PermissionType.SystemMonitoring
|
||||||
)
|
)
|
||||||
completeSetup()
|
completeSetup()
|
||||||
expect(component['systemStatus']).toEqual(status) // private
|
expect(component.systemStatus()).toEqual(status)
|
||||||
expect(component.systemStatusHasErrors).toBeTruthy()
|
expect(component.systemStatusHasErrors).toBeTruthy()
|
||||||
// coverage
|
// coverage
|
||||||
component['systemStatus'].database.status = SystemStatusItemStatus.OK
|
component.systemStatus().database.status = SystemStatusItemStatus.OK
|
||||||
component['systemStatus'].tasks.redis_status = SystemStatusItemStatus.OK
|
component.systemStatus().tasks.redis_status = SystemStatusItemStatus.OK
|
||||||
component['systemStatus'].tasks.celery_status = SystemStatusItemStatus.OK
|
component.systemStatus().tasks.celery_status = SystemStatusItemStatus.OK
|
||||||
component['systemStatus'].tasks.sanity_check_status =
|
component.systemStatus().tasks.sanity_check_status =
|
||||||
SystemStatusItemStatus.OK
|
SystemStatusItemStatus.OK
|
||||||
expect(component.systemStatusHasErrors).toBeFalsy()
|
expect(component.systemStatusHasErrors).toBeFalsy()
|
||||||
})
|
})
|
||||||
@@ -389,17 +399,17 @@ describe('SettingsComponent', () => {
|
|||||||
completeSetup()
|
completeSetup()
|
||||||
const field = 'storage_path'
|
const field = 'storage_path'
|
||||||
expect(
|
expect(
|
||||||
component.settingsForm.get('documentDetailsHiddenFields').value.length
|
component.settingsForm.get('documentDetailsHiddenFields').value
|
||||||
).toEqual(0)
|
).toHaveLength(0)
|
||||||
component.toggleDocumentDetailField(field, false)
|
component.toggleDocumentDetailField(field, false)
|
||||||
expect(
|
expect(
|
||||||
component.settingsForm.get('documentDetailsHiddenFields').value.length
|
component.settingsForm.get('documentDetailsHiddenFields').value
|
||||||
).toEqual(1)
|
).toHaveLength(1)
|
||||||
expect(component.isDocumentDetailFieldShown(field)).toBeFalsy()
|
expect(component.isDocumentDetailFieldShown(field)).toBeFalsy()
|
||||||
component.toggleDocumentDetailField(field, true)
|
component.toggleDocumentDetailField(field, true)
|
||||||
expect(
|
expect(
|
||||||
component.settingsForm.get('documentDetailsHiddenFields').value.length
|
component.settingsForm.get('documentDetailsHiddenFields').value
|
||||||
).toEqual(0)
|
).toHaveLength(0)
|
||||||
expect(component.isDocumentDetailFieldShown(field)).toBeTruthy()
|
expect(component.isDocumentDetailFieldShown(field)).toBeTruthy()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
OnDestroy,
|
OnDestroy,
|
||||||
OnInit,
|
OnInit,
|
||||||
inject,
|
inject,
|
||||||
|
signal,
|
||||||
} from '@angular/core'
|
} from '@angular/core'
|
||||||
import {
|
import {
|
||||||
FormControl,
|
FormControl,
|
||||||
@@ -141,7 +142,7 @@ export class SettingsComponent
|
|||||||
private systemStatusService = inject(SystemStatusService)
|
private systemStatusService = inject(SystemStatusService)
|
||||||
private savedViewsService = inject(SavedViewService)
|
private savedViewsService = inject(SavedViewService)
|
||||||
|
|
||||||
activeNavID: number
|
readonly activeNavID = signal<number>(undefined)
|
||||||
|
|
||||||
settingsForm = new FormGroup({
|
settingsForm = new FormGroup({
|
||||||
bulkEditConfirmationDialogs: new FormControl(null),
|
bulkEditConfirmationDialogs: new FormControl(null),
|
||||||
@@ -189,10 +190,10 @@ export class SettingsComponent
|
|||||||
unsubscribeNotifier: Subject<any> = new Subject()
|
unsubscribeNotifier: Subject<any> = new Subject()
|
||||||
savePending: boolean = false
|
savePending: boolean = false
|
||||||
|
|
||||||
users: User[]
|
readonly users = signal<User[]>(undefined)
|
||||||
groups: Group[]
|
readonly groups = signal<Group[]>(undefined)
|
||||||
|
|
||||||
public systemStatus: SystemStatus
|
public readonly systemStatus = signal<SystemStatus>(undefined)
|
||||||
|
|
||||||
public readonly GlobalSearchType = GlobalSearchType
|
public readonly GlobalSearchType = GlobalSearchType
|
||||||
|
|
||||||
@@ -203,16 +204,18 @@ export class SettingsComponent
|
|||||||
public readonly documentDetailFieldOptions = documentDetailFieldOptions
|
public readonly documentDetailFieldOptions = documentDetailFieldOptions
|
||||||
|
|
||||||
get systemStatusHasErrors(): boolean {
|
get systemStatusHasErrors(): boolean {
|
||||||
|
const status = this.systemStatus()
|
||||||
|
if (!status) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
return (
|
return (
|
||||||
this.systemStatus.database.status === SystemStatusItemStatus.ERROR ||
|
status.database.status === SystemStatusItemStatus.ERROR ||
|
||||||
this.systemStatus.tasks.redis_status === SystemStatusItemStatus.ERROR ||
|
status.tasks.redis_status === SystemStatusItemStatus.ERROR ||
|
||||||
this.systemStatus.tasks.celery_status === SystemStatusItemStatus.ERROR ||
|
status.tasks.celery_status === SystemStatusItemStatus.ERROR ||
|
||||||
this.systemStatus.tasks.index_status === SystemStatusItemStatus.ERROR ||
|
status.tasks.index_status === SystemStatusItemStatus.ERROR ||
|
||||||
this.systemStatus.tasks.classifier_status ===
|
status.tasks.classifier_status === SystemStatusItemStatus.ERROR ||
|
||||||
SystemStatusItemStatus.ERROR ||
|
status.tasks.sanity_check_status === SystemStatusItemStatus.ERROR ||
|
||||||
this.systemStatus.tasks.sanity_check_status ===
|
status.websocket_connected === SystemStatusItemStatus.ERROR
|
||||||
SystemStatusItemStatus.ERROR ||
|
|
||||||
this.systemStatus.websocket_connected === SystemStatusItemStatus.ERROR
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -246,7 +249,7 @@ export class SettingsComponent
|
|||||||
.pipe(first())
|
.pipe(first())
|
||||||
.subscribe({
|
.subscribe({
|
||||||
next: (r) => {
|
next: (r) => {
|
||||||
this.users = r.results
|
this.users.set(r.results)
|
||||||
},
|
},
|
||||||
error: (e) => {
|
error: (e) => {
|
||||||
this.toastService.showError($localize`Error retrieving users`, e)
|
this.toastService.showError($localize`Error retrieving users`, e)
|
||||||
@@ -265,7 +268,7 @@ export class SettingsComponent
|
|||||||
.pipe(first())
|
.pipe(first())
|
||||||
.subscribe({
|
.subscribe({
|
||||||
next: (r) => {
|
next: (r) => {
|
||||||
this.groups = r.results
|
this.groups.set(r.results)
|
||||||
},
|
},
|
||||||
error: (e) => {
|
error: (e) => {
|
||||||
this.toastService.showError($localize`Error retrieving groups`, e)
|
this.toastService.showError($localize`Error retrieving groups`, e)
|
||||||
@@ -280,7 +283,7 @@ export class SettingsComponent
|
|||||||
(navID) => navID.toLowerCase() == section
|
(navID) => navID.toLowerCase() == section
|
||||||
)
|
)
|
||||||
if (navIDKey) {
|
if (navIDKey) {
|
||||||
this.activeNavID = SettingsNavIDs[navIDKey]
|
this.activeNavID.set(SettingsNavIDs[navIDKey])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -383,7 +386,7 @@ export class SettingsComponent
|
|||||||
.navigate(['settings', foundNavIDkey.toLowerCase()])
|
.navigate(['settings', foundNavIDkey.toLowerCase()])
|
||||||
.then((navigated) => {
|
.then((navigated) => {
|
||||||
if (!navigated && this.isDirty) {
|
if (!navigated && this.isDirty) {
|
||||||
this.activeNavID = navChangeEvent.activeId
|
this.activeNavID.set(navChangeEvent.activeId)
|
||||||
} else if (navigated && this.isDirty) {
|
} else if (navigated && this.isDirty) {
|
||||||
this.initialize()
|
this.initialize()
|
||||||
}
|
}
|
||||||
@@ -431,7 +434,7 @@ export class SettingsComponent
|
|||||||
|
|
||||||
if (this.canViewSystemStatus) {
|
if (this.canViewSystemStatus) {
|
||||||
this.systemStatusService.get().subscribe((status) => {
|
this.systemStatusService.get().subscribe((status) => {
|
||||||
this.systemStatus = status
|
this.systemStatus.set(status)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -664,6 +667,6 @@ export class SettingsComponent
|
|||||||
size: 'xl',
|
size: 'xl',
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
modal.componentInstance.status = this.systemStatus
|
modal.componentInstance.status.set(this.systemStatus())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,11 +11,11 @@
|
|||||||
<button class="btn btn-sm btn-outline-primary me-2" (click)="dismissTasks()" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.PaperlessTask }" [disabled]="visibleTasks.length === 0">
|
<button class="btn btn-sm btn-outline-primary me-2" (click)="dismissTasks()" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.PaperlessTask }" [disabled]="visibleTasks.length === 0">
|
||||||
<i-bs name="check2-all" class="me-1"></i-bs>{{dismissButtonText}}
|
<i-bs name="check2-all" class="me-1"></i-bs>{{dismissButtonText}}
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-sm btn-outline-primary me-2" (click)="dismissAllTasks()" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.PaperlessTask }" [disabled]="totalTasks === 0">
|
<button class="btn btn-sm btn-outline-primary me-2" (click)="dismissAllTasks()" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.PaperlessTask }" [disabled]="totalTasks() === 0">
|
||||||
<i-bs name="check2-all" class="me-1"></i-bs><ng-container i18n>Dismiss all</ng-container>
|
<i-bs name="check2-all" class="me-1"></i-bs><ng-container i18n>Dismiss all</ng-container>
|
||||||
</button>
|
</button>
|
||||||
<div class="form-check form-switch mb-0 ms-2">
|
<div class="form-check form-switch mb-0 ms-2">
|
||||||
<input class="form-check-input" type="checkbox" role="switch" [(ngModel)]="autoRefreshEnabled">
|
<input id="autoRefreshSwitch" class="form-check-input" type="checkbox" role="switch" [(ngModel)]="autoRefreshEnabled">
|
||||||
<label class="form-check-label" for="autoRefreshSwitch" i18n>Auto refresh</label>
|
<label class="form-check-label" for="autoRefreshSwitch" i18n>Auto refresh</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -93,7 +93,8 @@
|
|||||||
<i-bs width="1em" height="1em" name="x"></i-bs>
|
<i-bs width="1em" height="1em" name="x"></i-bs>
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
<input #filterInput class="form-control form-control-sm" type="text"
|
<label class="visually-hidden" for="taskSearch" i18n>Search tasks</label>
|
||||||
|
<input #filterInput id="taskSearch" class="form-control form-control-sm" type="text"
|
||||||
(keyup)="filterInputKeyup($event)"
|
(keyup)="filterInputKeyup($event)"
|
||||||
[(ngModel)]="filterText">
|
[(ngModel)]="filterText">
|
||||||
</div>
|
</div>
|
||||||
@@ -107,7 +108,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"
|
||||||
@@ -211,7 +212,7 @@
|
|||||||
<i-bs width="1.2em" height="1.2em" name="info-circle"></i-bs>
|
<i-bs width="1.2em" height="1.2em" name="info-circle"></i-bs>
|
||||||
</button>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
<td scope="row" class="actions-col">
|
<td class="actions-col">
|
||||||
<div class="btn-group" role="group">
|
<div class="btn-group" role="group">
|
||||||
<button class="btn btn-sm btn-outline-secondary" (click)="dismissTask(task); $event.stopPropagation();" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.PaperlessTask }">
|
<button class="btn btn-sm btn-outline-secondary" (click)="dismissTask(task); $event.stopPropagation();" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.PaperlessTask }">
|
||||||
<i-bs name="check" class="me-1"></i-bs><ng-container i18n>Dismiss</ng-container>
|
<i-bs name="check" class="me-1"></i-bs><ng-container i18n>Dismiss</ng-container>
|
||||||
|
|||||||
@@ -362,7 +362,7 @@ describe('TasksComponent', () => {
|
|||||||
)
|
)
|
||||||
|
|
||||||
req.flush({ count: 2, results: [tasks[0], tasks[1]] })
|
req.flush({ count: 2, results: [tasks[0], tasks[1]] })
|
||||||
expect(component.totalTasks).toBe(2)
|
expect(component.totalTasks()).toBe(2)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should apply task type and trigger source filters to the server-side task query', () => {
|
it('should apply task type and trigger source filters to the server-side task query', () => {
|
||||||
@@ -435,8 +435,8 @@ describe('TasksComponent', () => {
|
|||||||
.flush(pageTwoTasks)
|
.flush(pageTwoTasks)
|
||||||
|
|
||||||
expect(component.page).toBe(2)
|
expect(component.page).toBe(2)
|
||||||
expect(component.totalTasks).toBe(30)
|
expect(component.totalTasks()).toBe(30)
|
||||||
expect(component.pagedTasks).toEqual([tasks[0]])
|
expect(component.pagedTasks()).toEqual([tasks[0]])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should not replace section counts with current-page counts', () => {
|
it('should not replace section counts with current-page counts', () => {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { JsonPipe, NgTemplateOutlet } from '@angular/common'
|
import { JsonPipe, NgTemplateOutlet } from '@angular/common'
|
||||||
import { Component, inject, OnDestroy, OnInit } from '@angular/core'
|
import { Component, inject, OnDestroy, OnInit, signal } 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
|
||||||
public totalTasks: number = 0
|
readonly totalTasks = signal(0)
|
||||||
public sectionCounts: Record<TaskSection, number> = {
|
readonly sectionCounts = signal<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,
|
||||||
}
|
})
|
||||||
public pagedTasks: PaperlessTask[] = []
|
readonly pagedTasks = signal<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,7 +477,14 @@ 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 {
|
||||||
@@ -652,7 +659,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 })
|
||||||
@@ -664,10 +671,12 @@ 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[TaskSection.All] = counts.all
|
this.sectionCounts.set({
|
||||||
this.sectionCounts[TaskSection.NeedsAttention] = counts.needs_attention
|
[TaskSection.All]: counts.all,
|
||||||
this.sectionCounts[TaskSection.InProgress] = counts.in_progress
|
[TaskSection.NeedsAttention]: counts.needs_attention,
|
||||||
this.sectionCounts[TaskSection.Completed] = counts.completed
|
[TaskSection.InProgress]: counts.in_progress,
|
||||||
|
[TaskSection.Completed]: counts.completed,
|
||||||
|
})
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -723,7 +732,7 @@ export class TasksComponent
|
|||||||
|
|
||||||
this.reloadSectionCounts()
|
this.reloadSectionCounts()
|
||||||
|
|
||||||
this.loading = true
|
this.loading.set(true)
|
||||||
this.tasksService
|
this.tasksService
|
||||||
.list(
|
.list(
|
||||||
this.page,
|
this.page,
|
||||||
@@ -733,24 +742,24 @@ export class TasksComponent
|
|||||||
.pipe(first(), takeUntil(this.unsubscribeNotifier))
|
.pipe(first(), takeUntil(this.unsubscribeNotifier))
|
||||||
.subscribe({
|
.subscribe({
|
||||||
next: (result) => {
|
next: (result) => {
|
||||||
this.pagedTasks = result.results
|
this.pagedTasks.set(result.results)
|
||||||
this.totalTasks = result.count
|
this.totalTasks.set(result.count)
|
||||||
this.sectionCounts[TaskSection.All] = result.count
|
this.setSectionCount(TaskSection.All, result.count)
|
||||||
if (this.selectedSection !== TaskSection.All) {
|
if (this.selectedSection !== TaskSection.All) {
|
||||||
this.sectionCounts[this.selectedSection] = result.count
|
this.setSectionCount(this.selectedSection, result.count)
|
||||||
}
|
}
|
||||||
this.loading = false
|
this.loading.set(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 = false
|
this.loading.set(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)="reload()" size="sm" aria-label="Pagination"></ngb-pagination>
|
<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>
|
||||||
</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" [disabled]="documentsInTrash.length === 0" (click)="toggleAll($event); $event.stopPropagation();">
|
<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();">
|
||||||
<label class="form-check-label" for="all-objects"></label>
|
<label class="form-check-label" for="all-objects"></label>
|
||||||
</div>
|
</div>
|
||||||
</th>
|
</th>
|
||||||
@@ -38,7 +38,7 @@
|
|||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@if (loading) {
|
@if (loading()) {
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="5">
|
<td colspan="5">
|
||||||
<div class="spinner-border spinner-border-sm me-2" role="status"></div>
|
<div class="spinner-border spinner-border-sm me-2" role="status"></div>
|
||||||
@@ -46,11 +46,11 @@
|
|||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
}
|
}
|
||||||
@for (document of documentsInTrash; track document.id) {
|
@for (document of documentsInTrash(); track document.id) {
|
||||||
<tr (click)="toggleSelected(document); $event.stopPropagation();" (mouseleave)="popupPreview.close()" class="data-row fade" [class.show]="show">
|
<tr (click)="toggleSelected(document); $event.stopPropagation();" (mouseleave)="popupPreview.close()" class="data-row fade" [class.show]="show()">
|
||||||
<td>
|
<td>
|
||||||
<div class="form-check m-0 ms-2 me-n2">
|
<div class="form-check m-0 ms-2 me-n2">
|
||||||
<input type="checkbox" class="form-check-input" id="{{document.id}}" [checked]="selectedDocuments.has(document.id)" (click)="toggleSelected(document); $event.stopPropagation();">
|
<input type="checkbox" class="form-check-input" id="{{document.id}}" [checked]="selectedDocuments().has(document.id)" (click)="toggleSelected(document); $event.stopPropagation();">
|
||||||
<label class="form-check-label" for="{{document.id}}"></label>
|
<label class="form-check-label" for="{{document.id}}"></label>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
@@ -88,16 +88,16 @@
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if (!loading) {
|
@if (!loading()) {
|
||||||
<div class="d-flex mb-2">
|
<div class="d-flex mb-2">
|
||||||
<div>
|
<div>
|
||||||
<ng-container i18n>{totalDocuments, plural, =1 {One document in trash} other {{{totalDocuments || 0}} total documents in trash}}</ng-container>
|
<ng-container i18n>{totalDocuments(), plural, =1 {One document in trash} other {{{totalDocuments() || 0}} total documents in trash}}</ng-container>
|
||||||
@if (selectedDocuments.size > 0) {
|
@if (selectedDocuments().size > 0) {
|
||||||
({{selectedDocuments.size}} selected)
|
({{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)="reload()" size="sm" aria-label="Pagination"></ngb-pagination>
|
<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>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -60,10 +60,15 @@ describe('TrashComponent', () => {
|
|||||||
modalService = TestBed.inject(NgbModal)
|
modalService = TestBed.inject(NgbModal)
|
||||||
toastService = TestBed.inject(ToastService)
|
toastService = TestBed.inject(ToastService)
|
||||||
router = TestBed.inject(Router)
|
router = TestBed.inject(Router)
|
||||||
|
jest.spyOn(router, 'navigate').mockResolvedValue(true)
|
||||||
component = fixture.componentInstance
|
component = fixture.componentInstance
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.useRealTimers()
|
||||||
|
})
|
||||||
|
|
||||||
it('should call correct service method on reload', () => {
|
it('should call correct service method on reload', () => {
|
||||||
jest.useFakeTimers()
|
jest.useFakeTimers()
|
||||||
const trashSpy = jest.spyOn(trashService, 'getTrash')
|
const trashSpy = jest.spyOn(trashService, 'getTrash')
|
||||||
@@ -77,7 +82,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 +184,8 @@ describe('TrashComponent', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('should support toggle all items in view', () => {
|
it('should support toggle all items in view', () => {
|
||||||
component.documentsInTrash = documentsInTrash
|
component.documentsInTrash.set(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 +194,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 = new Set([1])
|
component.selectedDocuments.set(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 = new Set([1])
|
component.selectedDocuments.set(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 } from '@angular/core'
|
import { Component, OnDestroy, inject, signal } 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 { delay, takeUntil, tap } from 'rxjs'
|
import { 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)
|
||||||
|
|
||||||
public documentsInTrash: Document[] = []
|
readonly documentsInTrash = signal<Document[]>([])
|
||||||
public selectedDocuments: Set<number> = new Set()
|
readonly selectedDocuments = signal<Set<number>>(new Set())
|
||||||
public allToggled: boolean = false
|
readonly allToggled = signal(false)
|
||||||
public page: number = 1
|
readonly page = signal(1)
|
||||||
public totalDocuments: number
|
readonly totalDocuments = signal<number>(undefined)
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super()
|
super()
|
||||||
@@ -54,20 +54,19 @@ export class TrashComponent
|
|||||||
}
|
}
|
||||||
|
|
||||||
reload() {
|
reload() {
|
||||||
this.loading = true
|
this.loading.set(true)
|
||||||
this.trashService
|
this.trashService
|
||||||
.getTrash(this.page)
|
.getTrash(this.page())
|
||||||
.pipe(
|
.pipe(
|
||||||
tap((r) => {
|
tap((r) => {
|
||||||
this.documentsInTrash = r.results
|
this.documentsInTrash.set(r.results)
|
||||||
this.totalDocuments = r.count
|
this.totalDocuments.set(r.count)
|
||||||
this.selectedDocuments.clear()
|
this.selectedDocuments.set(new Set())
|
||||||
this.loading = false
|
this.loading.set(false)
|
||||||
}),
|
})
|
||||||
delay(100)
|
|
||||||
)
|
)
|
||||||
.subscribe(() => {
|
.subscribe(() => {
|
||||||
this.show = true
|
this.show.set(true)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,7 +121,7 @@ export class TrashComponent
|
|||||||
.subscribe({
|
.subscribe({
|
||||||
next: () => {
|
next: () => {
|
||||||
this.toastService.showInfo($localize`Document(s) deleted`)
|
this.toastService.showInfo($localize`Document(s) deleted`)
|
||||||
this.allToggled = false
|
this.allToggled.set(false)
|
||||||
modal.close()
|
modal.close()
|
||||||
this.reload()
|
this.reload()
|
||||||
},
|
},
|
||||||
@@ -165,7 +164,7 @@ export class TrashComponent
|
|||||||
.subscribe({
|
.subscribe({
|
||||||
next: () => {
|
next: () => {
|
||||||
this.toastService.showInfo($localize`Document(s) restored`)
|
this.toastService.showInfo($localize`Document(s) restored`)
|
||||||
this.allToggled = false
|
this.allToggled.set(false)
|
||||||
this.reload()
|
this.reload()
|
||||||
},
|
},
|
||||||
error: (err) => {
|
error: (err) => {
|
||||||
@@ -179,24 +178,29 @@ export class TrashComponent
|
|||||||
|
|
||||||
toggleAll(event: PointerEvent) {
|
toggleAll(event: PointerEvent) {
|
||||||
if ((event.target as HTMLInputElement).checked) {
|
if ((event.target as HTMLInputElement).checked) {
|
||||||
this.selectedDocuments = new Set(this.documentsInTrash.map((t) => t.id))
|
this.selectedDocuments.set(
|
||||||
|
new Set(this.documentsInTrash().map((t) => t.id))
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
this.clearSelection()
|
this.clearSelection()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
toggleSelected(object: Document) {
|
toggleSelected(object: Document) {
|
||||||
this.selectedDocuments.has(object.id)
|
const selectedDocuments = new Set(this.selectedDocuments())
|
||||||
? this.selectedDocuments.delete(object.id)
|
selectedDocuments.has(object.id)
|
||||||
: this.selectedDocuments.add(object.id)
|
? selectedDocuments.delete(object.id)
|
||||||
|
: selectedDocuments.add(object.id)
|
||||||
|
this.selectedDocuments.set(selectedDocuments)
|
||||||
}
|
}
|
||||||
|
|
||||||
clearSelection() {
|
clearSelection() {
|
||||||
this.allToggled = false
|
this.allToggled.set(false)
|
||||||
this.selectedDocuments.clear()
|
this.selectedDocuments.set(new Set())
|
||||||
}
|
}
|
||||||
|
|
||||||
getDaysRemaining(document: Document): number {
|
getDaysRemaining(document: Document): number {
|
||||||
|
this.settingsService.trackChanges()
|
||||||
const delay = this.settingsService.get(SETTINGS_KEYS.EMPTY_TRASH_DELAY)
|
const delay = this.settingsService.get(SETTINGS_KEYS.EMPTY_TRASH_DELAY)
|
||||||
const diff = new Date().getTime() - new Date(document.deleted_at).getTime()
|
const diff = new Date().getTime() - new Date(document.deleted_at).getTime()
|
||||||
const days = Math.ceil(diff / (1000 * 3600 * 24))
|
const days = Math.ceil(diff / (1000 * 3600 * 24))
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
>
|
>
|
||||||
</pngx-page-header>
|
</pngx-page-header>
|
||||||
|
|
||||||
@if (canViewUsers && users) {
|
@if (canViewUsers && users()) {
|
||||||
<h4 class="d-flex">
|
<h4 class="d-flex">
|
||||||
<ng-container i18n>Users</ng-container>
|
<ng-container i18n>Users</ng-container>
|
||||||
<button type="button" class="btn btn-sm btn-outline-primary ms-4" (click)="editUser()" *pngxIfPermissions="{ action: PermissionAction.Add, type: PermissionType.User }">
|
<button type="button" class="btn btn-sm btn-outline-primary ms-4" (click)="editUser()" *pngxIfPermissions="{ action: PermissionAction.Add, type: PermissionType.User }">
|
||||||
@@ -23,7 +23,7 @@
|
|||||||
<div class="col" i18n>Actions</div>
|
<div class="col" i18n>Actions</div>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
@for (user of users; track user) {
|
@for (user of users(); track user) {
|
||||||
<li class="list-group-item">
|
<li class="list-group-item">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col d-flex align-items-center" [class.opacity-50]="!user.is_active"><button class="btn btn-link p-0 text-start" type="button" (click)="editUser(user)" [disabled]="!permissionsService.currentUserCan(PermissionAction.Change, PermissionType.User)">{{user.username}}</button></div>
|
<div class="col d-flex align-items-center" [class.opacity-50]="!user.is_active"><button class="btn btn-link p-0 text-start" type="button" (click)="editUser(user)" [disabled]="!permissionsService.currentUserCan(PermissionAction.Change, PermissionType.User)">{{user.username}}</button></div>
|
||||||
@@ -45,7 +45,7 @@
|
|||||||
</ul>
|
</ul>
|
||||||
}
|
}
|
||||||
|
|
||||||
@if (canViewGroups && groups) {
|
@if (canViewGroups && groups()) {
|
||||||
<h4 class="mt-4 d-flex">
|
<h4 class="mt-4 d-flex">
|
||||||
<ng-container i18n>Groups</ng-container>
|
<ng-container i18n>Groups</ng-container>
|
||||||
<button type="button" class="btn btn-sm btn-outline-primary ms-4" (click)="editGroup()" *pngxIfPermissions="{ action: PermissionAction.Add, type: PermissionType.Group }">
|
<button type="button" class="btn btn-sm btn-outline-primary ms-4" (click)="editGroup()" *pngxIfPermissions="{ action: PermissionAction.Add, type: PermissionType.Group }">
|
||||||
@@ -61,7 +61,7 @@
|
|||||||
<div class="col" i18n>Actions</div>
|
<div class="col" i18n>Actions</div>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
@for (group of groups; track group) {
|
@for (group of groups(); track group) {
|
||||||
<li class="list-group-item">
|
<li class="list-group-item">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col d-flex align-items-center"><button class="btn btn-link p-0 text-start" type="button" (click)="editGroup(group)" [disabled]="!permissionsService.currentUserCan(PermissionAction.Change, PermissionType.Group)">{{group.name}}</button></div>
|
<div class="col d-flex align-items-center"><button class="btn btn-link p-0 text-start" type="button" (click)="editGroup(group)" [disabled]="!permissionsService.currentUserCan(PermissionAction.Change, PermissionType.Group)">{{group.name}}</button></div>
|
||||||
@@ -80,13 +80,13 @@
|
|||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
}
|
}
|
||||||
@if (groups.length === 0) {
|
@if (groups().length === 0) {
|
||||||
<li class="list-group-item" i18n>No groups defined</li>
|
<li class="list-group-item" i18n>No groups defined</li>
|
||||||
}
|
}
|
||||||
</ul>
|
</ul>
|
||||||
}
|
}
|
||||||
|
|
||||||
@if ((canViewUsers && !users) || (canViewGroups && !groups)) {
|
@if ((canViewUsers && !users()) || (canViewGroups && !groups())) {
|
||||||
<div>
|
<div>
|
||||||
<div class="spinner-border spinner-border-sm fw-normal ms-2 me-auto" role="status"></div>
|
<div class="spinner-border spinner-border-sm fw-normal ms-2 me-auto" role="status"></div>
|
||||||
<div class="visually-hidden" i18n>Loading...</div>
|
<div class="visually-hidden" i18n>Loading...</div>
|
||||||
|
|||||||
@@ -1,12 +1,7 @@
|
|||||||
import { DatePipe } from '@angular/common'
|
import { DatePipe } from '@angular/common'
|
||||||
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
|
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
|
||||||
import { provideHttpClientTesting } from '@angular/common/http/testing'
|
import { provideHttpClientTesting } from '@angular/common/http/testing'
|
||||||
import {
|
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||||
ComponentFixture,
|
|
||||||
TestBed,
|
|
||||||
fakeAsync,
|
|
||||||
tick,
|
|
||||||
} from '@angular/core/testing'
|
|
||||||
import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap'
|
import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap'
|
||||||
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
||||||
import { of, throwError } from 'rxjs'
|
import { of, throwError } from 'rxjs'
|
||||||
@@ -57,7 +52,7 @@ describe('UsersAndGroupsComponent', () => {
|
|||||||
}).compileComponents()
|
}).compileComponents()
|
||||||
fixture = TestBed.createComponent(UsersAndGroupsComponent)
|
fixture = TestBed.createComponent(UsersAndGroupsComponent)
|
||||||
settingsService = TestBed.inject(SettingsService)
|
settingsService = TestBed.inject(SettingsService)
|
||||||
settingsService.currentUser = users[0]
|
settingsService.currentUser.set(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 +104,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 = users[1] // simulate logged in as different user
|
settingsService.currentUser.set(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}".`
|
||||||
@@ -138,7 +133,8 @@ describe('UsersAndGroupsComponent', () => {
|
|||||||
expect(toastInfoSpy).toHaveBeenCalledWith('Deleted user "user1"')
|
expect(toastInfoSpy).toHaveBeenCalledWith('Deleted user "user1"')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should logout current user if password changed, after delay', fakeAsync(() => {
|
it('should logout current user if password changed, after delay', () => {
|
||||||
|
jest.useFakeTimers()
|
||||||
completeSetup()
|
completeSetup()
|
||||||
let modal: NgbModalRef
|
let modal: NgbModalRef
|
||||||
modalService.activeInstances.subscribe((refs) => (modal = refs[0]))
|
modalService.activeInstances.subscribe((refs) => (modal = refs[0]))
|
||||||
@@ -148,14 +144,15 @@ describe('UsersAndGroupsComponent', () => {
|
|||||||
.mockImplementation(() => {})
|
.mockImplementation(() => {})
|
||||||
const editDialog = modal.componentInstance as UserEditDialogComponent
|
const editDialog = modal.componentInstance as UserEditDialogComponent
|
||||||
editDialog.passwordIsSet = true
|
editDialog.passwordIsSet = true
|
||||||
settingsService.currentUser = users[0] // simulate logged in as same user
|
settingsService.currentUser.set(users[0]) // simulate logged in as same user
|
||||||
editDialog.succeeded.emit(users[0])
|
editDialog.succeeded.emit(users[0])
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
tick(2600)
|
jest.advanceTimersByTime(2600)
|
||||||
expect(navSpy).toHaveBeenCalledWith(
|
expect(navSpy).toHaveBeenCalledWith(
|
||||||
`${window.location.origin}/accounts/logout/?next=/accounts/login/?next=/`
|
`${window.location.origin}/accounts/logout/?next=/accounts/login/?next=/`
|
||||||
)
|
)
|
||||||
}))
|
jest.useRealTimers()
|
||||||
|
})
|
||||||
|
|
||||||
it('should support edit / create group, show error if needed', () => {
|
it('should support edit / create group, show error if needed', () => {
|
||||||
completeSetup()
|
completeSetup()
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Component, OnDestroy, OnInit, inject } from '@angular/core'
|
import { Component, OnDestroy, OnInit, inject, signal } 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)
|
||||||
|
|
||||||
users: User[]
|
readonly users = signal<User[]>(null)
|
||||||
groups: Group[]
|
readonly groups = signal<Group[]>(null)
|
||||||
|
|
||||||
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 = r.results
|
this.users.set(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 = r.results
|
this.groups.set(r.results)
|
||||||
},
|
},
|
||||||
error: (e) => {
|
error: (e) => {
|
||||||
this.toastService.showError($localize`Error retrieving groups`, e)
|
this.toastService.showError($localize`Error retrieving groups`, e)
|
||||||
@@ -101,15 +101,15 @@ export class UsersAndGroupsComponent
|
|||||||
backdrop: 'static',
|
backdrop: 'static',
|
||||||
size: 'xl',
|
size: 'xl',
|
||||||
})
|
})
|
||||||
modal.componentInstance.dialogMode = user
|
modal.componentInstance.dialogMode.set(
|
||||||
? EditDialogMode.EDIT
|
user ? EditDialogMode.EDIT : EditDialogMode.CREATE
|
||||||
: EditDialogMode.CREATE
|
)
|
||||||
modal.componentInstance.object = user
|
modal.componentInstance.object = user
|
||||||
modal.componentInstance.succeeded
|
modal.componentInstance.succeeded
|
||||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||||
.subscribe((newUser: User) => {
|
.subscribe((newUser: User) => {
|
||||||
if (
|
if (
|
||||||
newUser.id === this.settings.currentUser.id &&
|
newUser.id === this.settings.currentUser().id &&
|
||||||
(modal.componentInstance as UserEditDialogComponent).passwordIsSet
|
(modal.componentInstance as UserEditDialogComponent).passwordIsSet
|
||||||
) {
|
) {
|
||||||
this.toastService.showInfo(
|
this.toastService.showInfo(
|
||||||
@@ -125,7 +125,7 @@ export class UsersAndGroupsComponent
|
|||||||
$localize`Saved user "${newUser.username}".`
|
$localize`Saved user "${newUser.username}".`
|
||||||
)
|
)
|
||||||
this.usersService.listAll().subscribe((r) => {
|
this.usersService.listAll().subscribe((r) => {
|
||||||
this.users = r.results
|
this.users.set(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 = r.results
|
this.users.set(r.results)
|
||||||
})
|
})
|
||||||
},
|
},
|
||||||
error: (e) => {
|
error: (e) => {
|
||||||
@@ -170,16 +170,16 @@ export class UsersAndGroupsComponent
|
|||||||
backdrop: 'static',
|
backdrop: 'static',
|
||||||
size: 'lg',
|
size: 'lg',
|
||||||
})
|
})
|
||||||
modal.componentInstance.dialogMode = group
|
modal.componentInstance.dialogMode.set(
|
||||||
? EditDialogMode.EDIT
|
group ? EditDialogMode.EDIT : EditDialogMode.CREATE
|
||||||
: EditDialogMode.CREATE
|
)
|
||||||
modal.componentInstance.object = group
|
modal.componentInstance.object = group
|
||||||
modal.componentInstance.succeeded
|
modal.componentInstance.succeeded
|
||||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||||
.subscribe((newGroup) => {
|
.subscribe((newGroup) => {
|
||||||
this.toastService.showInfo($localize`Saved group "${newGroup.name}".`)
|
this.toastService.showInfo($localize`Saved group "${newGroup.name}".`)
|
||||||
this.groupsService.listAll().subscribe((r) => {
|
this.groupsService.listAll().subscribe((r) => {
|
||||||
this.groups = r.results
|
this.groups.set(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 = r.results
|
this.groups.set(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)="mobileSearchHidden = false; isMenuCollapsed = !isMenuCollapsed">
|
(click)="closeMobileSearch(); toggleMenuCollapsed()">
|
||||||
<span class="navbar-toggler-icon"></span>
|
<span class="navbar-toggler-icon"></span>
|
||||||
</button>
|
</button>
|
||||||
<a class="navbar-brand d-flex align-items-center me-0 px-3 py-3 order-sm-0"
|
<a class="navbar-brand d-flex align-items-center me-0 px-3 py-3 order-sm-0"
|
||||||
@@ -23,7 +23,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
<div class="search-container flex-grow-1 py-2 pb-3 pb-sm-2 px-3 ps-md-4 me-sm-auto order-3 order-sm-1"
|
<div class="search-container flex-grow-1 py-2 pb-3 pb-sm-2 px-3 ps-md-4 me-sm-auto order-3 order-sm-1"
|
||||||
[class.mobile-hidden]="mobileSearchHidden">
|
[class.mobile-hidden]="mobileSearchHidden()">
|
||||||
<div class="col-12 col-md-7">
|
<div class="col-12 col-md-7">
|
||||||
<pngx-global-search></pngx-global-search>
|
<pngx-global-search></pngx-global-search>
|
||||||
</div>
|
</div>
|
||||||
@@ -68,8 +68,8 @@
|
|||||||
<div class="container-fluid">
|
<div class="container-fluid">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<nav id="sidebarMenu" class="d-md-block bg-light sidebar collapse"
|
<nav id="sidebarMenu" class="d-md-block bg-light sidebar collapse"
|
||||||
[ngClass]="slimSidebarEnabled ? 'slim' : 'col-md-3 col-lg-2 col-xxxl-1'" [class.animating]="slimSidebarAnimating"
|
[ngClass]="slimSidebarEnabled ? 'slim' : 'col-md-3 col-lg-2 col-xxxl-1'" [class.animating]="slimSidebarAnimating()"
|
||||||
[ngbCollapse]="isMenuCollapsed">
|
[ngbCollapse]="isMenuCollapsed()">
|
||||||
@if (canSaveSettings) {
|
@if (canSaveSettings) {
|
||||||
<button class="btn btn-sm btn-dark sidebar-slim-toggler" (click)="toggleSlimSidebar()">
|
<button class="btn btn-sm btn-dark sidebar-slim-toggler" (click)="toggleSlimSidebar()">
|
||||||
@if (slimSidebarEnabled) {
|
@if (slimSidebarEnabled) {
|
||||||
@@ -104,7 +104,7 @@
|
|||||||
</h6>
|
</h6>
|
||||||
<ul class="nav flex-column mb-2" cdkDropList (cdkDropListDropped)="onDrop($event)">
|
<ul class="nav flex-column mb-2" cdkDropList (cdkDropListDropped)="onDrop($event)">
|
||||||
@for (view of savedViewService.sidebarViews; track view.id) {
|
@for (view of savedViewService.sidebarViews; track view.id) {
|
||||||
<li class="nav-item w-100 app-link" cdkDrag [cdkDragDisabled]="!settingsService.organizingSidebarSavedViews"
|
<li class="nav-item w-100 app-link" cdkDrag [cdkDragDisabled]="!settingsService.organizingSidebarSavedViews()"
|
||||||
cdkDragPreviewContainer="parent" cdkDragPreviewClass="navItemDrag" (cdkDragStarted)="onDragStart($event)"
|
cdkDragPreviewContainer="parent" cdkDragPreviewClass="navItemDrag" (cdkDragStarted)="onDragStart($event)"
|
||||||
(cdkDragEnded)="onDragEnd($event)">
|
(cdkDragEnded)="onDragEnd($event)">
|
||||||
<a class="nav-link" routerLink="view/{{view.id}}"
|
<a class="nav-link" routerLink="view/{{view.id}}"
|
||||||
@@ -120,7 +120,7 @@
|
|||||||
<span class="badge bg-info text-dark position-absolute top-0 end-0 d-none d-md-block">{{ savedViewService.getDocumentCount(view) }}</span>
|
<span class="badge bg-info text-dark position-absolute top-0 end-0 d-none d-md-block">{{ savedViewService.getDocumentCount(view) }}</span>
|
||||||
}
|
}
|
||||||
</a>
|
</a>
|
||||||
@if (settingsService.organizingSidebarSavedViews) {
|
@if (settingsService.organizingSidebarSavedViews()) {
|
||||||
<div class="position-absolute end-0 top-0 px-3 py-2" [class.me-n3]="slimSidebarEnabled" cdkDragHandle>
|
<div class="position-absolute end-0 top-0 px-3 py-2" [class.me-n3]="slimSidebarEnabled" cdkDragHandle>
|
||||||
<i-bs name="grip-vertical"></i-bs>
|
<i-bs name="grip-vertical"></i-bs>
|
||||||
</div>
|
</div>
|
||||||
@@ -150,7 +150,8 @@
|
|||||||
[disablePopover]="!slimSidebarEnabled" placement="end" container="body" triggers="mouseenter:mouseleave"
|
[disablePopover]="!slimSidebarEnabled" placement="end" container="body" triggers="mouseenter:mouseleave"
|
||||||
popoverClass="popover-slim">
|
popoverClass="popover-slim">
|
||||||
<i-bs class="me-2" name="file-text"></i-bs><span>{{d.title | documentTitle}}</span>
|
<i-bs class="me-2" name="file-text"></i-bs><span>{{d.title | documentTitle}}</span>
|
||||||
<span class="close flex-column justify-content-center" (click)="closeDocument(d); $event.preventDefault()">
|
<span class="close flex-column justify-content-center"
|
||||||
|
(click)="$event.preventDefault(); $event.stopPropagation(); closeDocument(d)">
|
||||||
<i-bs name="x"></i-bs>
|
<i-bs name="x"></i-bs>
|
||||||
</span>
|
</span>
|
||||||
</a>
|
</a>
|
||||||
@@ -329,10 +330,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 +351,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 +378,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<main role="main" class="ms-sm-auto px-md-4" [class.mobile-search-hidden]="mobileSearchHidden"
|
<main role="main" class="ms-sm-auto px-md-4" [class.mobile-search-hidden]="mobileSearchHidden()"
|
||||||
[ngClass]="slimSidebarEnabled ? 'col-slim' : 'col-md-9 col-lg-10 col-xxxl-11'">
|
[ngClass]="slimSidebarEnabled ? 'col-slim' : 'col-md-9 col-lg-10 col-xxxl-11'">
|
||||||
<router-outlet></router-outlet>
|
<router-outlet></router-outlet>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -4,12 +4,7 @@ import {
|
|||||||
HttpTestingController,
|
HttpTestingController,
|
||||||
provideHttpClientTesting,
|
provideHttpClientTesting,
|
||||||
} from '@angular/common/http/testing'
|
} from '@angular/common/http/testing'
|
||||||
import {
|
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||||
ComponentFixture,
|
|
||||||
TestBed,
|
|
||||||
fakeAsync,
|
|
||||||
tick,
|
|
||||||
} from '@angular/core/testing'
|
|
||||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||||
import { BrowserModule } from '@angular/platform-browser'
|
import { BrowserModule } from '@angular/platform-browser'
|
||||||
import { ActivatedRoute, Router } from '@angular/router'
|
import { ActivatedRoute, Router } from '@angular/router'
|
||||||
@@ -244,11 +239,12 @@ describe('AppFrameComponent', () => {
|
|||||||
expect(toastSpy).toHaveBeenCalled()
|
expect(toastSpy).toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should support toggling slim sidebar and saving', fakeAsync(() => {
|
it('should support toggling slim sidebar and saving', () => {
|
||||||
|
jest.useFakeTimers()
|
||||||
const saveSettingSpy = jest.spyOn(settingsService, 'set')
|
const saveSettingSpy = jest.spyOn(settingsService, 'set')
|
||||||
settingsService.set(SETTINGS_KEYS.ATTRIBUTES_SECTIONS_COLLAPSED, [])
|
settingsService.set(SETTINGS_KEYS.ATTRIBUTES_SECTIONS_COLLAPSED, [])
|
||||||
expect(component.slimSidebarEnabled).toBeFalsy()
|
expect(component.slimSidebarEnabled).toBeFalsy()
|
||||||
expect(component.slimSidebarAnimating).toBeFalsy()
|
expect(component.slimSidebarAnimating()).toBeFalsy()
|
||||||
component.toggleSlimSidebar()
|
component.toggleSlimSidebar()
|
||||||
const requests = httpTestingController.match(
|
const requests = httpTestingController.match(
|
||||||
`${environment.apiBaseUrl}ui_settings/`
|
`${environment.apiBaseUrl}ui_settings/`
|
||||||
@@ -259,9 +255,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)
|
jest.advanceTimersByTime(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,
|
||||||
@@ -271,7 +267,8 @@ describe('AppFrameComponent', () => {
|
|||||||
SETTINGS_KEYS.ATTRIBUTES_SECTIONS_COLLAPSED,
|
SETTINGS_KEYS.ATTRIBUTES_SECTIONS_COLLAPSED,
|
||||||
['attributes']
|
['attributes']
|
||||||
)
|
)
|
||||||
}))
|
jest.useRealTimers()
|
||||||
|
})
|
||||||
|
|
||||||
it('should show error on toggle slim sidebar if store settings fails', () => {
|
it('should show error on toggle slim sidebar if store settings fails', () => {
|
||||||
jest.spyOn(console, 'warn').mockImplementation(() => {})
|
jest.spyOn(console, 'warn').mockImplementation(() => {})
|
||||||
@@ -291,9 +288,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 +305,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 +320,13 @@ describe('AppFrameComponent', () => {
|
|||||||
value: 1024,
|
value: 1024,
|
||||||
})
|
})
|
||||||
component.ngOnInit()
|
component.ngOnInit()
|
||||||
component.mobileSearchHidden = true
|
component.mobileSearchHidden.set(true)
|
||||||
|
|
||||||
component.onWindowScroll()
|
component.onWindowScroll()
|
||||||
|
|
||||||
expect(component.mobileSearchHidden).toBe(false)
|
expect(component.mobileSearchHidden()).toBe(false)
|
||||||
|
|
||||||
component.mobileSearchHidden = true
|
component.mobileSearchHidden.set(true)
|
||||||
component.onWindowResize()
|
component.onWindowResize()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -338,7 +335,7 @@ describe('AppFrameComponent', () => {
|
|||||||
value: 767,
|
value: 767,
|
||||||
})
|
})
|
||||||
component.ngOnInit()
|
component.ngOnInit()
|
||||||
component.isMenuCollapsed = false
|
component.isMenuCollapsed.set(false)
|
||||||
|
|
||||||
Object.defineProperty(globalThis, 'scrollY', {
|
Object.defineProperty(globalThis, 'scrollY', {
|
||||||
configurable: true,
|
configurable: true,
|
||||||
@@ -346,7 +343,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 +376,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 } from '@angular/core'
|
import { Component, HostListener, inject, OnInit, signal } from '@angular/core'
|
||||||
import { ActivatedRoute, Router, RouterModule } from '@angular/router'
|
import { ActivatedRoute, Router, RouterModule } from '@angular/router'
|
||||||
import {
|
import {
|
||||||
NgbCollapseModule,
|
NgbCollapseModule,
|
||||||
@@ -90,14 +90,10 @@ export class AppFrameComponent
|
|||||||
permissionsService = inject(PermissionsService)
|
permissionsService = inject(PermissionsService)
|
||||||
private djangoMessagesService = inject(DjangoMessagesService)
|
private djangoMessagesService = inject(DjangoMessagesService)
|
||||||
|
|
||||||
appRemoteVersion: AppRemoteVersion
|
readonly appRemoteVersion = signal<AppRemoteVersion>(null)
|
||||||
|
readonly isMenuCollapsed = signal(true)
|
||||||
isMenuCollapsed: boolean = true
|
readonly slimSidebarAnimating = signal(false)
|
||||||
|
readonly mobileSearchHidden = signal(false)
|
||||||
slimSidebarAnimating: boolean = false
|
|
||||||
|
|
||||||
public mobileSearchHidden: boolean = false
|
|
||||||
|
|
||||||
private lastScrollY: number = 0
|
private lastScrollY: number = 0
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -147,7 +143,7 @@ export class AppFrameComponent
|
|||||||
}
|
}
|
||||||
|
|
||||||
toggleSlimSidebar(): void {
|
toggleSlimSidebar(): void {
|
||||||
this.slimSidebarAnimating = true
|
this.slimSidebarAnimating.set(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) {
|
||||||
@@ -167,7 +163,7 @@ export class AppFrameComponent
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
this.slimSidebarAnimating = false
|
this.slimSidebarAnimating.set(false)
|
||||||
}, 200) // slightly longer than css animation for slim sidebar
|
}, 200) // slightly longer than css animation for slim sidebar
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -177,11 +173,25 @@ 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)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -224,6 +234,7 @@ 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)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -243,6 +254,7 @@ 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)
|
||||||
@@ -268,13 +280,14 @@ 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 = false
|
this.mobileSearchHidden.set(false)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -282,8 +295,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 = false
|
this.mobileSearchHidden.set(false)
|
||||||
this.lastScrollY = currentScrollY
|
this.lastScrollY = currentScrollY
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -291,9 +304,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 = false
|
this.mobileSearchHidden.set(false)
|
||||||
} else if (currentScrollY > SCROLL_THRESHOLD && delta > SCROLL_THRESHOLD) {
|
} else if (currentScrollY > SCROLL_THRESHOLD && delta > SCROLL_THRESHOLD) {
|
||||||
this.mobileSearchHidden = true
|
this.mobileSearchHidden.set(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
this.lastScrollY = currentScrollY
|
this.lastScrollY = currentScrollY
|
||||||
@@ -304,7 +317,7 @@ export class AppFrameComponent
|
|||||||
}
|
}
|
||||||
|
|
||||||
closeMenu() {
|
closeMenu() {
|
||||||
this.isMenuCollapsed = true
|
this.isMenuCollapsed.set(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
editProfile() {
|
editProfile() {
|
||||||
@@ -367,11 +380,11 @@ export class AppFrameComponent
|
|||||||
}
|
}
|
||||||
|
|
||||||
onDragStart(event: CdkDragStart) {
|
onDragStart(event: CdkDragStart) {
|
||||||
this.settingsService.globalDropzoneEnabled = false
|
this.settingsService.globalDropzoneEnabled.set(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
onDragEnd(event: CdkDragEnd) {
|
onDragEnd(event: CdkDragEnd) {
|
||||||
this.settingsService.globalDropzoneEnabled = true
|
this.settingsService.globalDropzoneEnabled.set(true)
|
||||||
}
|
}
|
||||||
|
|
||||||
onDrop(event: CdkDragDrop<SavedView[]>) {
|
onDrop(event: CdkDragDrop<SavedView[]>) {
|
||||||
@@ -392,7 +405,7 @@ export class AppFrameComponent
|
|||||||
this.remoteVersionService
|
this.remoteVersionService
|
||||||
.checkForUpdates()
|
.checkForUpdates()
|
||||||
.subscribe((appRemoteVersion: AppRemoteVersion) => {
|
.subscribe((appRemoteVersion: AppRemoteVersion) => {
|
||||||
this.appRemoteVersion = appRemoteVersion
|
this.appRemoteVersion.set(appRemoteVersion)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -419,9 +432,10 @@ export class AppFrameComponent
|
|||||||
}
|
}
|
||||||
|
|
||||||
get showSidebarCounts(): boolean {
|
get showSidebarCounts(): boolean {
|
||||||
|
this.settingsService.trackChanges()
|
||||||
return (
|
return (
|
||||||
this.settingsService.get(SETTINGS_KEYS.SIDEBAR_VIEWS_SHOW_COUNT) &&
|
this.settingsService.get(SETTINGS_KEYS.SIDEBAR_VIEWS_SHOW_COUNT) &&
|
||||||
!this.settingsService.organizingSidebarSavedViews
|
!this.settingsService.organizingSidebarSavedViews()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,17 +8,17 @@
|
|||||||
placeholder="Search" aria-label="Search" i18n-placeholder
|
placeholder="Search" aria-label="Search" i18n-placeholder
|
||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
spellcheck="false"
|
spellcheck="false"
|
||||||
[(ngModel)]="query"
|
[ngModel]="query()"
|
||||||
(ngModelChange)="this.queryDebounce.next($event)"
|
(ngModelChange)="queryDebounce.next($event)"
|
||||||
(keydown)="searchInputKeyDown($event)"
|
(keydown)="searchInputKeyDown($event)"
|
||||||
ngbDropdownAnchor>
|
ngbDropdownAnchor>
|
||||||
<div class="position-absolute top-50 end-0 translate-middle">
|
<div class="position-absolute top-50 end-0 translate-middle">
|
||||||
@if (loading) {
|
@if (loading()) {
|
||||||
<div class="spinner-border spinner-border-sm text-muted mt-1"></div>
|
<div class="spinner-border spinner-border-sm text-muted mt-1"></div>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@if (query) {
|
@if (query()) {
|
||||||
<button class="btn btn-sm btn-outline-secondary" type="button" (click)="runFullSearch()">
|
<button class="btn btn-sm btn-outline-secondary" type="button" (click)="runFullSearch()">
|
||||||
@if (useAdvancedForFullSearch) {
|
@if (useAdvancedForFullSearch) {
|
||||||
<ng-container i18n>Advanced search</ng-container>
|
<ng-container i18n>Advanced search</ng-container>
|
||||||
@@ -77,88 +77,88 @@
|
|||||||
|
|
||||||
<div ngbDropdownMenu class="w-100 mh-75 overflow-y-scroll shadow-lg">
|
<div ngbDropdownMenu class="w-100 mh-75 overflow-y-scroll shadow-lg">
|
||||||
<div (keydown)="dropdownKeyDown($event)">
|
<div (keydown)="dropdownKeyDown($event)">
|
||||||
@if (searchResults?.total === 0) {
|
@if (searchResults()?.total === 0) {
|
||||||
<h6 class="dropdown-header" i18n="@@searchResults.noResults">No results</h6>
|
<h6 class="dropdown-header" i18n="@@searchResults.noResults">No results</h6>
|
||||||
} @else {
|
} @else {
|
||||||
@if (searchResults?.documents.length) {
|
@if (searchResults()?.documents.length) {
|
||||||
<h6 class="dropdown-header" i18n="@@searchResults.documents">Documents</h6>
|
<h6 class="dropdown-header" i18n="@@searchResults.documents">Documents</h6>
|
||||||
@for (document of searchResults.documents; track document.id) {
|
@for (document of searchResults().documents; track document.id) {
|
||||||
<ng-container *ngTemplateOutlet="resultItemTemplate; context: {item: document, nameProp: 'title', type: DataType.Document, icon: 'file-text', date: document.created}"></ng-container>
|
<ng-container *ngTemplateOutlet="resultItemTemplate; context: {item: document, nameProp: 'title', type: DataType.Document, icon: 'file-text', date: document.created}"></ng-container>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@if (searchResults?.saved_views.length) {
|
@if (searchResults()?.saved_views.length) {
|
||||||
<h6 class="dropdown-header" i18n="@@searchResults.saved_views">Saved Views</h6>
|
<h6 class="dropdown-header" i18n="@@searchResults.saved_views">Saved Views</h6>
|
||||||
@for (saved_view of searchResults.saved_views; track saved_view.id) {
|
@for (saved_view of searchResults().saved_views; track saved_view.id) {
|
||||||
<ng-container *ngTemplateOutlet="resultItemTemplate; context: {item: saved_view, nameProp: 'name', type: DataType.SavedView, icon: 'funnel'}"></ng-container>
|
<ng-container *ngTemplateOutlet="resultItemTemplate; context: {item: saved_view, nameProp: 'name', type: DataType.SavedView, icon: 'funnel'}"></ng-container>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@if (searchResults?.tags.length) {
|
@if (searchResults()?.tags.length) {
|
||||||
<h6 class="dropdown-header" i18n="@@searchResults.tags">Tags</h6>
|
<h6 class="dropdown-header" i18n="@@searchResults.tags">Tags</h6>
|
||||||
@for (tag of searchResults.tags; track tag.id) {
|
@for (tag of searchResults().tags; track tag.id) {
|
||||||
<ng-container *ngTemplateOutlet="resultItemTemplate; context: {item: tag, nameProp: 'name', type: DataType.Tag, icon: 'tag'}"></ng-container>
|
<ng-container *ngTemplateOutlet="resultItemTemplate; context: {item: tag, nameProp: 'name', type: DataType.Tag, icon: 'tag'}"></ng-container>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@if (searchResults?.correspondents.length) {
|
@if (searchResults()?.correspondents.length) {
|
||||||
<h6 class="dropdown-header" i18n="@@searchResults.correspondents">Correspondents</h6>
|
<h6 class="dropdown-header" i18n="@@searchResults.correspondents">Correspondents</h6>
|
||||||
@for (correspondent of searchResults.correspondents; track correspondent.id) {
|
@for (correspondent of searchResults().correspondents; track correspondent.id) {
|
||||||
<ng-container *ngTemplateOutlet="resultItemTemplate; context: {item: correspondent, nameProp: 'name', type: DataType.Correspondent, icon: 'person'}"></ng-container>
|
<ng-container *ngTemplateOutlet="resultItemTemplate; context: {item: correspondent, nameProp: 'name', type: DataType.Correspondent, icon: 'person'}"></ng-container>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@if (searchResults?.document_types.length) {
|
@if (searchResults()?.document_types.length) {
|
||||||
<h6 class="dropdown-header" i18n="@@searchResults.documentTypes">Document types</h6>
|
<h6 class="dropdown-header" i18n="@@searchResults.documentTypes">Document types</h6>
|
||||||
@for (documentType of searchResults.document_types; track documentType.id) {
|
@for (documentType of searchResults().document_types; track documentType.id) {
|
||||||
<ng-container *ngTemplateOutlet="resultItemTemplate; context: {item: documentType, nameProp: 'name', type: DataType.DocumentType, icon: 'file-earmark'}"></ng-container>
|
<ng-container *ngTemplateOutlet="resultItemTemplate; context: {item: documentType, nameProp: 'name', type: DataType.DocumentType, icon: 'file-earmark'}"></ng-container>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@if (searchResults?.storage_paths.length) {
|
@if (searchResults()?.storage_paths.length) {
|
||||||
<h6 class="dropdown-header" i18n="@@searchResults.storagePaths">Storage paths</h6>
|
<h6 class="dropdown-header" i18n="@@searchResults.storagePaths">Storage paths</h6>
|
||||||
@for (storagePath of searchResults.storage_paths; track storagePath.id) {
|
@for (storagePath of searchResults().storage_paths; track storagePath.id) {
|
||||||
<ng-container *ngTemplateOutlet="resultItemTemplate; context: {item: storagePath, nameProp: 'name', type: DataType.StoragePath, icon: 'folder'}"></ng-container>
|
<ng-container *ngTemplateOutlet="resultItemTemplate; context: {item: storagePath, nameProp: 'name', type: DataType.StoragePath, icon: 'folder'}"></ng-container>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@if (searchResults?.users.length) {
|
@if (searchResults()?.users.length) {
|
||||||
<h6 class="dropdown-header" i18n="@@searchResults.users">Users</h6>
|
<h6 class="dropdown-header" i18n="@@searchResults.users">Users</h6>
|
||||||
@for (user of searchResults.users; track user.id) {
|
@for (user of searchResults().users; track user.id) {
|
||||||
<ng-container *ngTemplateOutlet="resultItemTemplate; context: {item: user, nameProp: 'username', type: DataType.User, icon: 'person-square'}"></ng-container>
|
<ng-container *ngTemplateOutlet="resultItemTemplate; context: {item: user, nameProp: 'username', type: DataType.User, icon: 'person-square'}"></ng-container>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@if (searchResults?.groups.length) {
|
@if (searchResults()?.groups.length) {
|
||||||
<h6 class="dropdown-header" i18n="@@searchResults.groups">Groups</h6>
|
<h6 class="dropdown-header" i18n="@@searchResults.groups">Groups</h6>
|
||||||
@for (group of searchResults.groups; track group.id) {
|
@for (group of searchResults().groups; track group.id) {
|
||||||
<ng-container *ngTemplateOutlet="resultItemTemplate; context: {item: group, nameProp: 'name', type: DataType.Group, icon: 'people'}"></ng-container>
|
<ng-container *ngTemplateOutlet="resultItemTemplate; context: {item: group, nameProp: 'name', type: DataType.Group, icon: 'people'}"></ng-container>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@if (searchResults?.custom_fields.length) {
|
@if (searchResults()?.custom_fields.length) {
|
||||||
<h6 class="dropdown-header" i18n="@@searchResults.customFields">Custom fields</h6>
|
<h6 class="dropdown-header" i18n="@@searchResults.customFields">Custom fields</h6>
|
||||||
@for (customField of searchResults.custom_fields; track customField.id) {
|
@for (customField of searchResults().custom_fields; track customField.id) {
|
||||||
<ng-container *ngTemplateOutlet="resultItemTemplate; context: {item: customField, nameProp: 'name', type: DataType.CustomField, icon: 'ui-radios'}"></ng-container>
|
<ng-container *ngTemplateOutlet="resultItemTemplate; context: {item: customField, nameProp: 'name', type: DataType.CustomField, icon: 'ui-radios'}"></ng-container>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@if (searchResults?.mail_accounts.length) {
|
@if (searchResults()?.mail_accounts.length) {
|
||||||
<h6 class="dropdown-header" i18n="@@searchResults.mailAccounts">Mail accounts</h6>
|
<h6 class="dropdown-header" i18n="@@searchResults.mailAccounts">Mail accounts</h6>
|
||||||
@for (mailAccount of searchResults.mail_accounts; track mailAccount.id) {
|
@for (mailAccount of searchResults().mail_accounts; track mailAccount.id) {
|
||||||
<ng-container *ngTemplateOutlet="resultItemTemplate; context: {item: mailAccount, nameProp: 'name', type: DataType.MailAccount, icon: 'envelope-at'}"></ng-container>
|
<ng-container *ngTemplateOutlet="resultItemTemplate; context: {item: mailAccount, nameProp: 'name', type: DataType.MailAccount, icon: 'envelope-at'}"></ng-container>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@if (searchResults?.mail_rules.length) {
|
@if (searchResults()?.mail_rules.length) {
|
||||||
<h6 class="dropdown-header" i18n="@@searchResults.mailRules">Mail rules</h6>
|
<h6 class="dropdown-header" i18n="@@searchResults.mailRules">Mail rules</h6>
|
||||||
@for (mailRule of searchResults.mail_rules; track mailRule.id) {
|
@for (mailRule of searchResults().mail_rules; track mailRule.id) {
|
||||||
<ng-container *ngTemplateOutlet="resultItemTemplate; context: {item: mailRule, nameProp: 'name', type: DataType.MailRule, icon: 'envelope'}"></ng-container>
|
<ng-container *ngTemplateOutlet="resultItemTemplate; context: {item: mailRule, nameProp: 'name', type: DataType.MailRule, icon: 'envelope'}"></ng-container>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@if (searchResults?.workflows.length) {
|
@if (searchResults()?.workflows.length) {
|
||||||
<h6 class="dropdown-header" i18n="@@searchResults.workflows">Workflows</h6>
|
<h6 class="dropdown-header" i18n="@@searchResults.workflows">Workflows</h6>
|
||||||
@for (workflow of searchResults.workflows; track workflow.id) {
|
@for (workflow of searchResults().workflows; track workflow.id) {
|
||||||
<ng-container *ngTemplateOutlet="resultItemTemplate; context: {item: workflow, nameProp: 'name', type: DataType.Workflow, icon: 'boxes'}"></ng-container>
|
<ng-container *ngTemplateOutlet="resultItemTemplate; context: {item: workflow, nameProp: 'name', type: DataType.Workflow, icon: 'boxes'}"></ng-container>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,7 @@
|
|||||||
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
|
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
|
||||||
import { provideHttpClientTesting } from '@angular/common/http/testing'
|
import { provideHttpClientTesting } from '@angular/common/http/testing'
|
||||||
import { ElementRef } from '@angular/core'
|
import { ElementRef } from '@angular/core'
|
||||||
import {
|
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||||
ComponentFixture,
|
|
||||||
TestBed,
|
|
||||||
fakeAsync,
|
|
||||||
tick,
|
|
||||||
} from '@angular/core/testing'
|
|
||||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||||
import { Router } from '@angular/router'
|
import { Router } from '@angular/router'
|
||||||
import {
|
import {
|
||||||
@@ -154,6 +149,7 @@ describe('GlobalSearchComponent', () => {
|
|||||||
|
|
||||||
searchService = TestBed.inject(SearchService)
|
searchService = TestBed.inject(SearchService)
|
||||||
router = TestBed.inject(Router)
|
router = TestBed.inject(Router)
|
||||||
|
jest.spyOn(router, 'navigate').mockResolvedValue(true)
|
||||||
modalService = TestBed.inject(NgbModal)
|
modalService = TestBed.inject(NgbModal)
|
||||||
documentService = TestBed.inject(DocumentService)
|
documentService = TestBed.inject(DocumentService)
|
||||||
documentListViewService = TestBed.inject(DocumentListViewService)
|
documentListViewService = TestBed.inject(DocumentListViewService)
|
||||||
@@ -170,7 +166,7 @@ describe('GlobalSearchComponent', () => {
|
|||||||
document.dispatchEvent(new KeyboardEvent('keydown', { key: '/' }))
|
document.dispatchEvent(new KeyboardEvent('keydown', { key: '/' }))
|
||||||
expect(focusSpy).toHaveBeenCalled()
|
expect(focusSpy).toHaveBeenCalled()
|
||||||
|
|
||||||
component.searchResults = searchResults as any
|
component.searchResults.set(searchResults as any)
|
||||||
component.resultsDropdown.open()
|
component.resultsDropdown.open()
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
|
|
||||||
@@ -238,26 +234,26 @@ describe('GlobalSearchComponent', () => {
|
|||||||
)
|
)
|
||||||
expect(component['currentItemIndex']).toBe(0)
|
expect(component['currentItemIndex']).toBe(0)
|
||||||
|
|
||||||
component.searchResults = { total: 1 } as any
|
component.searchResults.set({ 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 = 'test'
|
component.query.set('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 = ''
|
component.query.set('')
|
||||||
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 = { total: 1 } as any
|
component.searchResults.set({ total: 1 } as any)
|
||||||
component.resultsDropdown.open()
|
component.resultsDropdown.open()
|
||||||
|
|
||||||
component.searchInputKeyDown(
|
component.searchInputKeyDown(
|
||||||
@@ -268,28 +264,30 @@ describe('GlobalSearchComponent', () => {
|
|||||||
component.dropdownKeyDown(new KeyboardEvent('keydown', { key: 'Escape' }))
|
component.dropdownKeyDown(new KeyboardEvent('keydown', { key: 'Escape' }))
|
||||||
expect(closeSpy).toHaveBeenCalled()
|
expect(closeSpy).toHaveBeenCalled()
|
||||||
|
|
||||||
component.searchResults = searchResults as any
|
component.searchResults.set(searchResults as any)
|
||||||
component.resultsDropdown.open()
|
component.resultsDropdown.open()
|
||||||
component.query = 'test'
|
component.query.set('test')
|
||||||
const advancedSearchSpy = jest.spyOn(component, 'runFullSearch')
|
const advancedSearchSpy = jest.spyOn(component, 'runFullSearch')
|
||||||
component.searchInputKeyDown(new KeyboardEvent('keydown', { key: 'Enter' }))
|
component.searchInputKeyDown(new KeyboardEvent('keydown', { key: 'Enter' }))
|
||||||
expect(advancedSearchSpy).toHaveBeenCalled()
|
expect(advancedSearchSpy).toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should search on query debounce', fakeAsync(() => {
|
it('should search on query debounce', () => {
|
||||||
|
jest.useFakeTimers()
|
||||||
const query = 'test'
|
const query = 'test'
|
||||||
const searchSpy = jest.spyOn(searchService, 'globalSearch')
|
const searchSpy = jest.spyOn(searchService, 'globalSearch')
|
||||||
searchSpy.mockReturnValue(of({} as any))
|
searchSpy.mockReturnValue(of({} as any))
|
||||||
const dropdownOpenSpy = jest.spyOn(component.resultsDropdown, 'open')
|
const dropdownOpenSpy = jest.spyOn(component.resultsDropdown, 'open')
|
||||||
component.queryDebounce.next(query)
|
component.queryDebounce.next(query)
|
||||||
tick(401)
|
jest.advanceTimersByTime(401)
|
||||||
expect(searchSpy).toHaveBeenCalledWith(query)
|
expect(searchSpy).toHaveBeenCalledWith(query)
|
||||||
expect(dropdownOpenSpy).toHaveBeenCalled()
|
expect(dropdownOpenSpy).toHaveBeenCalled()
|
||||||
}))
|
jest.useRealTimers()
|
||||||
|
})
|
||||||
|
|
||||||
it('should support primary action', () => {
|
it('should support primary action', () => {
|
||||||
const object = { id: 1 }
|
const object = { id: 1 }
|
||||||
const routerSpy = jest.spyOn(router, 'navigate')
|
const routerSpy = jest.mocked(router.navigate)
|
||||||
const modalSpy = jest.spyOn(modalService, 'open')
|
const modalSpy = jest.spyOn(modalService, 'open')
|
||||||
|
|
||||||
let modal: NgbModalRef
|
let modal: NgbModalRef
|
||||||
@@ -465,13 +463,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 = searchResults as any
|
component.searchResults.set(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 +487,7 @@ describe('GlobalSearchComponent', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('should focus button on dropdown item hover', () => {
|
it('should focus button on dropdown item hover', () => {
|
||||||
component.searchResults = searchResults as any
|
component.searchResults.set(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 +513,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 = searchResults as any
|
component.searchResults.set(searchResults as any)
|
||||||
component.resultsDropdown.open()
|
component.resultsDropdown.open()
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
|
|
||||||
@@ -542,7 +540,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 = 'test'
|
component.query.set('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 +550,7 @@ describe('GlobalSearchComponent', () => {
|
|||||||
SETTINGS_KEYS.SEARCH_FULL_TYPE,
|
SETTINGS_KEYS.SEARCH_FULL_TYPE,
|
||||||
GlobalSearchType.ADVANCED
|
GlobalSearchType.ADVANCED
|
||||||
)
|
)
|
||||||
component.query = 'test'
|
component.query.set('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,6 +7,7 @@ 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'
|
||||||
@@ -82,12 +83,12 @@ export class GlobalSearchComponent implements OnInit {
|
|||||||
private locationStrategy = inject(LocationStrategy)
|
private locationStrategy = inject(LocationStrategy)
|
||||||
|
|
||||||
public DataType = DataType
|
public DataType = DataType
|
||||||
public query: string
|
readonly query = signal<string>(null)
|
||||||
public queryDebounce: Subject<string>
|
public queryDebounce: Subject<string>
|
||||||
public searchResults: GlobalSearchResult
|
readonly searchResults = signal<GlobalSearchResult>(null)
|
||||||
private currentItemIndex: number = -1
|
private currentItemIndex: number = -1
|
||||||
private domIndex: number = -1
|
private domIndex: number = -1
|
||||||
public loading: boolean = false
|
readonly loading = signal(false)
|
||||||
|
|
||||||
@ViewChild('searchInput') searchInput: ElementRef
|
@ViewChild('searchInput') searchInput: ElementRef
|
||||||
@ViewChild('resultsDropdown') resultsDropdown: NgbDropdown
|
@ViewChild('resultsDropdown') resultsDropdown: NgbDropdown
|
||||||
@@ -96,6 +97,7 @@ 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
|
||||||
@@ -112,7 +114,7 @@ export class GlobalSearchComponent implements OnInit {
|
|||||||
distinctUntilChanged()
|
distinctUntilChanged()
|
||||||
)
|
)
|
||||||
.subscribe((text) => {
|
.subscribe((text) => {
|
||||||
this.query = text
|
this.query.set(text)
|
||||||
if (text) this.search(text)
|
if (text) this.search(text)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -126,10 +128,10 @@ export class GlobalSearchComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private search(query: string) {
|
private search(query: string) {
|
||||||
this.loading = true
|
this.loading.set(true)
|
||||||
this.searchService.globalSearch(query.trim()).subscribe((results) => {
|
this.searchService.globalSearch(query.trim()).subscribe((results) => {
|
||||||
this.searchResults = results
|
this.searchResults.set(results)
|
||||||
this.loading = false
|
this.loading.set(false)
|
||||||
this.resultsDropdown.open()
|
this.resultsDropdown.open()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -205,7 +207,7 @@ export class GlobalSearchComponent implements OnInit {
|
|||||||
editDialogComponent,
|
editDialogComponent,
|
||||||
{ size }
|
{ size }
|
||||||
)
|
)
|
||||||
modalRef.componentInstance.dialogMode = EditDialogMode.EDIT
|
modalRef.componentInstance.dialogMode.set(EditDialogMode.EDIT)
|
||||||
modalRef.componentInstance.object = object
|
modalRef.componentInstance.object = object
|
||||||
modalRef.componentInstance.succeeded.subscribe(() => {
|
modalRef.componentInstance.succeeded.subscribe(() => {
|
||||||
this.toastService.showInfo($localize`Successfully updated object.`)
|
this.toastService.showInfo($localize`Successfully updated object.`)
|
||||||
@@ -243,7 +245,7 @@ export class GlobalSearchComponent implements OnInit {
|
|||||||
editDialogComponent,
|
editDialogComponent,
|
||||||
{ size }
|
{ size }
|
||||||
)
|
)
|
||||||
modalRef.componentInstance.dialogMode = EditDialogMode.EDIT
|
modalRef.componentInstance.dialogMode.set(EditDialogMode.EDIT)
|
||||||
modalRef.componentInstance.object = object
|
modalRef.componentInstance.object = object
|
||||||
modalRef.componentInstance.succeeded.subscribe(() => {
|
modalRef.componentInstance.succeeded.subscribe(() => {
|
||||||
this.toastService.showInfo($localize`Successfully updated object.`)
|
this.toastService.showInfo($localize`Successfully updated object.`)
|
||||||
@@ -256,8 +258,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 = null
|
this.query.set(null)
|
||||||
this.searchResults = null
|
this.searchResults.set(null)
|
||||||
this.currentItemIndex = -1
|
this.currentItemIndex = -1
|
||||||
if (close) {
|
if (close) {
|
||||||
this.resultsDropdown.close()
|
this.resultsDropdown.close()
|
||||||
@@ -292,7 +294,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()
|
||||||
@@ -300,22 +302,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()
|
||||||
@@ -325,14 +327,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 {
|
||||||
@@ -412,10 +414,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>
|
||||||
|
|||||||
+16
-35
@@ -1,12 +1,6 @@
|
|||||||
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
|
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
|
||||||
import { provideHttpClientTesting } from '@angular/common/http/testing'
|
import { provideHttpClientTesting } from '@angular/common/http/testing'
|
||||||
import {
|
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||||
ComponentFixture,
|
|
||||||
TestBed,
|
|
||||||
discardPeriodicTasks,
|
|
||||||
fakeAsync,
|
|
||||||
flush,
|
|
||||||
} from '@angular/core/testing'
|
|
||||||
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
||||||
import { Subject } from 'rxjs'
|
import { Subject } from 'rxjs'
|
||||||
import { Toast, ToastService } from 'src/app/services/toast.service'
|
import { Toast, ToastService } from 'src/app/services/toast.service'
|
||||||
@@ -42,7 +36,8 @@ 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[]> = new Subject()
|
let toastsSubject: Subject<Toast[]>
|
||||||
|
let getToastsSpy: jest.SpyInstance
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
TestBed.configureTestingModule({
|
TestBed.configureTestingModule({
|
||||||
@@ -56,57 +51,43 @@ describe('ToastsDropdownComponent', () => {
|
|||||||
],
|
],
|
||||||
}).compileComponents()
|
}).compileComponents()
|
||||||
|
|
||||||
fixture = TestBed.createComponent(ToastsDropdownComponent)
|
|
||||||
toastService = TestBed.inject(ToastService)
|
toastService = TestBed.inject(ToastService)
|
||||||
jest.spyOn(toastService, 'getToasts').mockReturnValue(toastsSubject)
|
toastsSubject = new Subject()
|
||||||
|
getToastsSpy = jest
|
||||||
|
.spyOn(toastService, 'getToasts')
|
||||||
|
.mockReturnValue(toastsSubject)
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(ToastsDropdownComponent)
|
||||||
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', () => {
|
||||||
const spy = jest.spyOn(toastService, 'getToasts')
|
|
||||||
|
|
||||||
component.ngOnInit()
|
|
||||||
toastsSubject.next(toasts)
|
toastsSubject.next(toasts)
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
|
|
||||||
expect(spy).toHaveBeenCalled()
|
expect(getToastsSpy).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()
|
it('should show a toast', () => {
|
||||||
flush()
|
|
||||||
discardPeriodicTasks()
|
|
||||||
}))
|
|
||||||
|
|
||||||
it('should show a toast', fakeAsync(() => {
|
|
||||||
component.ngOnInit()
|
|
||||||
toastsSubject.next(toasts)
|
toastsSubject.next(toasts)
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
|
|
||||||
expect(fixture.nativeElement.textContent).toContain('foo bar')
|
expect(fixture.nativeElement.textContent).toContain('foo bar')
|
||||||
|
})
|
||||||
|
|
||||||
component.ngOnDestroy()
|
it('should toggle suppressPopupToasts', () => {
|
||||||
flush()
|
|
||||||
discardPeriodicTasks()
|
|
||||||
}))
|
|
||||||
|
|
||||||
it('should toggle suppressPopupToasts', fakeAsync((finish) => {
|
|
||||||
component.ngOnInit()
|
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
toastsSubject.next(toasts)
|
toastsSubject.next(toasts)
|
||||||
|
|
||||||
const spy = jest.spyOn(toastService, 'suppressPopupToasts', 'set')
|
const spy = jest.spyOn(toastService, 'suppressPopupToasts', 'set')
|
||||||
component.onOpenChange(true)
|
component.onOpenChange(true)
|
||||||
expect(spy).toHaveBeenCalledWith(true)
|
expect(spy).toHaveBeenCalledWith(true)
|
||||||
|
})
|
||||||
component.ngOnDestroy()
|
|
||||||
flush()
|
|
||||||
discardPeriodicTasks()
|
|
||||||
}))
|
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
import { Component, OnDestroy, OnInit, inject } from '@angular/core'
|
import { Component, 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 { Subscription } from 'rxjs'
|
import { map } 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'
|
||||||
|
|
||||||
@@ -19,22 +20,13 @@ import { ToastComponent } from '../../common/toast/toast.component'
|
|||||||
NgxBootstrapIconsModule,
|
NgxBootstrapIconsModule,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class ToastsDropdownComponent implements OnInit, OnDestroy {
|
export class ToastsDropdownComponent {
|
||||||
toastService = inject(ToastService)
|
toastService = inject(ToastService)
|
||||||
|
|
||||||
private subscription: Subscription
|
readonly toasts = toSignal(
|
||||||
|
this.toastService.getToasts().pipe(map((toasts) => [...toasts])),
|
||||||
public toasts: Toast[] = []
|
{ initialValue: [] as Toast[] }
|
||||||
|
)
|
||||||
ngOnDestroy(): void {
|
|
||||||
this.subscription?.unsubscribe()
|
|
||||||
}
|
|
||||||
|
|
||||||
ngOnInit(): void {
|
|
||||||
this.subscription = this.toastService.getToasts().subscribe((toasts) => {
|
|
||||||
this.toasts = [...toasts]
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
onOpenChange(open: boolean): void {
|
onOpenChange(open: boolean): void {
|
||||||
this.toastService.suppressPopupToasts = open
|
this.toastService.suppressPopupToasts = open
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
<div ngbDropdownMenu class="dropdown-menu-end shadow p-3" aria-labelledby="chatDropdown">
|
<div ngbDropdownMenu class="dropdown-menu-end shadow p-3" aria-labelledby="chatDropdown">
|
||||||
<div class="chat-container bg-light p-2">
|
<div class="chat-container bg-light p-2">
|
||||||
<div class="chat-messages font-monospace small">
|
<div class="chat-messages font-monospace small">
|
||||||
@for (message of messages; track message) {
|
@for (message of messages(); track message) {
|
||||||
<div class="message d-flex flex-row small" [class.justify-content-end]="message.role === 'user'">
|
<div class="message d-flex flex-row small" [class.justify-content-end]="message.role === 'user'">
|
||||||
<div class="p-2 m-2" [class.bg-body]="message.role === 'user'">
|
<div class="p-2 m-2" [class.bg-body]="message.role === 'user'">
|
||||||
<span>
|
<span>
|
||||||
@@ -29,16 +29,19 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form class="chat-input">
|
<form class="chat-input">
|
||||||
|
<label class="visually-hidden" for="chatInput" i18n>Message</label>
|
||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
<input
|
<input
|
||||||
#chatInput
|
#chatInput
|
||||||
|
id="chatInput"
|
||||||
class="form-control form-control-sm" name="chatInput" type="text"
|
class="form-control form-control-sm" name="chatInput" type="text"
|
||||||
[placeholder]="placeholder"
|
[placeholder]="placeholder"
|
||||||
[disabled]="loading"
|
[disabled]="loading()"
|
||||||
[(ngModel)]="input"
|
[ngModel]="input()"
|
||||||
|
(ngModelChange)="input.set($event)"
|
||||||
(keydown)="searchInputKeyDown($event)"
|
(keydown)="searchInputKeyDown($event)"
|
||||||
/>
|
/>
|
||||||
<button class="btn btn-sm btn-secondary" type="button" (click)="sendMessage()" [disabled]="loading">Send</button>
|
<button class="btn btn-sm btn-secondary" type="button" (click)="sendMessage()" [disabled]="loading()">Send</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -56,44 +56,53 @@ describe('ChatComponent', () => {
|
|||||||
it('should update documentId on initialization', () => {
|
it('should update documentId on initialization', () => {
|
||||||
jest.spyOn(router, 'url', 'get').mockReturnValue('/documents/123')
|
jest.spyOn(router, 'url', 'get').mockReturnValue('/documents/123')
|
||||||
component.ngOnInit()
|
component.ngOnInit()
|
||||||
expect(component.documentId).toBe(123)
|
expect(component.documentId()).toBe(123)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should update documentId on navigation', () => {
|
it('should update documentId on navigation', () => {
|
||||||
component.ngOnInit()
|
component.ngOnInit()
|
||||||
routerEvents$.next(new NavigationEnd(1, '/documents/456', '/documents/456'))
|
routerEvents$.next(new NavigationEnd(1, '/documents/456', '/documents/456'))
|
||||||
expect(component.documentId).toBe(456)
|
expect(component.documentId()).toBe(456)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should return correct placeholder based on documentId', () => {
|
it('should return correct placeholder based on documentId', () => {
|
||||||
component.documentId = 123
|
component.documentId.set(123)
|
||||||
expect(component.placeholder).toBe('Ask a question about this document...')
|
expect(component.placeholder).toBe('Ask a question about this document...')
|
||||||
component.documentId = undefined
|
component.documentId.set(undefined)
|
||||||
expect(component.placeholder).toBe('Ask a question about a document...')
|
expect(component.placeholder).toBe('Ask a question about a document...')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should send a message and handle streaming response', () => {
|
it('should send a message and render the streaming response', async () => {
|
||||||
component.input = 'Hello'
|
component.input.set('Hello')
|
||||||
component.sendMessage()
|
component.sendMessage()
|
||||||
|
|
||||||
expect(component.messages.length).toBe(2)
|
expect(component.messages()).toHaveLength(2)
|
||||||
expect(component.messages[0].content).toBe('Hello')
|
expect(component.messages()[0].content).toBe('Hello')
|
||||||
expect(component.loading).toBe(true)
|
expect(component.loading()).toBe(true)
|
||||||
|
|
||||||
mockStream$.next('Hi')
|
mockStream$.next('Hi')
|
||||||
expect(component.messages[1].content).toBe('H')
|
expect(component.messages()[1].content).toBe('H')
|
||||||
mockStream$.next('Hi there')
|
mockStream$.next('Hi there')
|
||||||
// advance time to process the typewriter effect
|
// advance time to process the typewriter effect
|
||||||
jest.advanceTimersByTime(1000)
|
await jest.runAllTimersAsync()
|
||||||
expect(component.messages[1].content).toBe('Hi there')
|
await fixture.whenStable()
|
||||||
|
expect(component.messages()[1].content).toBe('Hi there')
|
||||||
|
expect(
|
||||||
|
fixture.nativeElement.querySelector('.chat-messages').textContent
|
||||||
|
).toContain('Hi there')
|
||||||
|
|
||||||
mockStream$.complete()
|
mockStream$.complete()
|
||||||
expect(component.loading).toBe(false)
|
await jest.runAllTimersAsync()
|
||||||
expect(component.messages[1].isStreaming).toBe(false)
|
await fixture.whenStable()
|
||||||
|
expect(component.loading()).toBe(false)
|
||||||
|
expect(component.messages()[1].isStreaming).toBe(false)
|
||||||
|
expect(fixture.nativeElement.querySelector('#chatInput').disabled).toBe(
|
||||||
|
false
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should parse references from the metadata trailer without showing it', () => {
|
it('should parse references from the metadata trailer without showing it', () => {
|
||||||
component.input = 'Hello'
|
component.input.set('Hello')
|
||||||
component.sendMessage()
|
component.sendMessage()
|
||||||
|
|
||||||
mockStream$.next(
|
mockStream$.next(
|
||||||
@@ -101,14 +110,14 @@ describe('ChatComponent', () => {
|
|||||||
)
|
)
|
||||||
jest.advanceTimersByTime(1000)
|
jest.advanceTimersByTime(1000)
|
||||||
|
|
||||||
expect(component.messages[1].content).toBe('Hi there')
|
expect(component.messages()[1].content).toBe('Hi there')
|
||||||
expect(component.messages[1].references).toEqual([
|
expect(component.messages()[1].references).toEqual([
|
||||||
{ id: 42, title: 'Bread Recipe' },
|
{ id: 42, title: 'Bread Recipe' },
|
||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should render document reference links under assistant messages', () => {
|
it('should render document reference links under assistant messages', () => {
|
||||||
component.input = 'Hello'
|
component.input.set('Hello')
|
||||||
component.sendMessage()
|
component.sendMessage()
|
||||||
|
|
||||||
mockStream$.next(
|
mockStream$.next(
|
||||||
@@ -123,12 +132,12 @@ describe('ChatComponent', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('should remove delimiter fragments that were already streamed', () => {
|
it('should remove delimiter fragments that were already streamed', () => {
|
||||||
component.input = 'Hello'
|
component.input.set('Hello')
|
||||||
component.sendMessage()
|
component.sendMessage()
|
||||||
|
|
||||||
mockStream$.next(`Hi there${CHAT_METADATA_DELIMITER.slice(0, 8)}`)
|
mockStream$.next(`Hi there${CHAT_METADATA_DELIMITER.slice(0, 8)}`)
|
||||||
jest.advanceTimersByTime(1000)
|
jest.advanceTimersByTime(1000)
|
||||||
expect(component.messages[1].content).toBe(
|
expect(component.messages()[1].content).toBe(
|
||||||
`Hi there${CHAT_METADATA_DELIMITER.slice(0, 8)}`
|
`Hi there${CHAT_METADATA_DELIMITER.slice(0, 8)}`
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -137,28 +146,28 @@ describe('ChatComponent', () => {
|
|||||||
)
|
)
|
||||||
jest.advanceTimersByTime(1000)
|
jest.advanceTimersByTime(1000)
|
||||||
|
|
||||||
expect(component.messages[1].content).toBe('Hi there')
|
expect(component.messages()[1].content).toBe('Hi there')
|
||||||
expect(component.messages[1].references).toEqual([
|
expect(component.messages()[1].references).toEqual([
|
||||||
{ id: 42, title: 'Bread Recipe' },
|
{ id: 42, title: 'Bread Recipe' },
|
||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should handle errors during streaming', () => {
|
it('should handle errors during streaming', () => {
|
||||||
component.input = 'Hello'
|
component.input.set('Hello')
|
||||||
component.sendMessage()
|
component.sendMessage()
|
||||||
|
|
||||||
mockStream$.error('Error')
|
mockStream$.error('Error')
|
||||||
expect(component.messages[1].content).toContain(
|
expect(component.messages()[1].content).toContain(
|
||||||
'⚠️ Error receiving response.'
|
'⚠️ Error receiving response.'
|
||||||
)
|
)
|
||||||
expect(component.loading).toBe(false)
|
expect(component.loading()).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should enqueue typewriter chunks correctly', () => {
|
it('should enqueue typewriter chunks correctly', () => {
|
||||||
const message = { content: '', role: 'assistant', isStreaming: true }
|
const message = { content: '', role: 'assistant', isStreaming: true }
|
||||||
component.enqueueTypewriter(null, message as any) // coverage for null
|
component.enqueueTypewriter(null, message as any) // coverage for null
|
||||||
component.enqueueTypewriter('Hello', message as any)
|
component.enqueueTypewriter('Hello', message as any)
|
||||||
expect(component['typewriterBuffer'].length).toBe(4)
|
expect(component['typewriterBuffer']).toHaveLength(4)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should scroll to bottom after sending a message', () => {
|
it('should scroll to bottom after sending a message', () => {
|
||||||
@@ -166,7 +175,7 @@ describe('ChatComponent', () => {
|
|||||||
ChatComponent.prototype as any,
|
ChatComponent.prototype as any,
|
||||||
'scrollToBottom'
|
'scrollToBottom'
|
||||||
)
|
)
|
||||||
component.input = 'Test'
|
component.input.set('Test')
|
||||||
component.sendMessage()
|
component.sendMessage()
|
||||||
expect(scrollSpy).toHaveBeenCalled()
|
expect(scrollSpy).toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,4 +1,11 @@
|
|||||||
import { Component, ElementRef, inject, OnInit, ViewChild } from '@angular/core'
|
import {
|
||||||
|
Component,
|
||||||
|
ElementRef,
|
||||||
|
inject,
|
||||||
|
OnInit,
|
||||||
|
signal,
|
||||||
|
ViewChild,
|
||||||
|
} from '@angular/core'
|
||||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||||
import { NavigationEnd, Router, RouterModule } from '@angular/router'
|
import { NavigationEnd, Router, RouterModule } from '@angular/router'
|
||||||
import { NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap'
|
import { NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap'
|
||||||
@@ -23,10 +30,10 @@ import {
|
|||||||
styleUrl: './chat.component.scss',
|
styleUrl: './chat.component.scss',
|
||||||
})
|
})
|
||||||
export class ChatComponent implements OnInit {
|
export class ChatComponent implements OnInit {
|
||||||
public messages: ChatMessage[] = []
|
readonly messages = signal<ChatMessage[]>([])
|
||||||
public loading = false
|
readonly loading = signal(false)
|
||||||
public input: string = ''
|
readonly input = signal('')
|
||||||
public documentId!: number
|
readonly documentId = signal<number>(undefined)
|
||||||
|
|
||||||
private chatService: ChatService = inject(ChatService)
|
private chatService: ChatService = inject(ChatService)
|
||||||
private router: Router = inject(Router)
|
private router: Router = inject(Router)
|
||||||
@@ -38,7 +45,7 @@ export class ChatComponent implements OnInit {
|
|||||||
private typewriterActive = false
|
private typewriterActive = false
|
||||||
|
|
||||||
public get placeholder(): string {
|
public get placeholder(): string {
|
||||||
return this.documentId
|
return this.documentId()
|
||||||
? $localize`Ask a question about this document...`
|
? $localize`Ask a question about this document...`
|
||||||
: $localize`Ask a question about a document...`
|
: $localize`Ask a question about a document...`
|
||||||
}
|
}
|
||||||
@@ -57,14 +64,14 @@ export class ChatComponent implements OnInit {
|
|||||||
|
|
||||||
private updateDocumentId(url: string): void {
|
private updateDocumentId(url: string): void {
|
||||||
const docIdRe = url.match(/^\/documents\/(\d+)/)
|
const docIdRe = url.match(/^\/documents\/(\d+)/)
|
||||||
this.documentId = docIdRe ? +docIdRe[1] : undefined
|
this.documentId.set(docIdRe ? +docIdRe[1] : undefined)
|
||||||
}
|
}
|
||||||
|
|
||||||
sendMessage(): void {
|
sendMessage(): void {
|
||||||
if (!this.input.trim()) return
|
if (!this.input().trim()) return
|
||||||
|
|
||||||
const userMessage: ChatMessage = { role: 'user', content: this.input }
|
const userMessage: ChatMessage = { role: 'user', content: this.input() }
|
||||||
this.messages.push(userMessage)
|
this.messages.update((messages) => [...messages, userMessage])
|
||||||
this.scrollToBottom()
|
this.scrollToBottom()
|
||||||
|
|
||||||
const assistantMessage: ChatMessage = {
|
const assistantMessage: ChatMessage = {
|
||||||
@@ -72,12 +79,12 @@ export class ChatComponent implements OnInit {
|
|||||||
content: '',
|
content: '',
|
||||||
isStreaming: true,
|
isStreaming: true,
|
||||||
}
|
}
|
||||||
this.messages.push(assistantMessage)
|
this.messages.update((messages) => [...messages, assistantMessage])
|
||||||
this.loading = true
|
this.loading.set(true)
|
||||||
|
|
||||||
let lastVisibleContent = ''
|
let lastVisibleContent = ''
|
||||||
|
|
||||||
this.chatService.streamChat(this.documentId, this.input).subscribe({
|
this.chatService.streamChat(this.documentId(), this.input()).subscribe({
|
||||||
next: (chunk) => {
|
next: (chunk) => {
|
||||||
const nextResponse = parseChatResponse(chunk)
|
const nextResponse = parseChatResponse(chunk)
|
||||||
|
|
||||||
@@ -93,26 +100,30 @@ export class ChatComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
assistantMessage.references = nextResponse.references
|
assistantMessage.references = nextResponse.references
|
||||||
|
this.notifyMessagesChanged()
|
||||||
},
|
},
|
||||||
error: () => {
|
error: () => {
|
||||||
assistantMessage.content += '\n\n⚠️ Error receiving response.'
|
assistantMessage.content += '\n\n⚠️ Error receiving response.'
|
||||||
assistantMessage.isStreaming = false
|
assistantMessage.isStreaming = false
|
||||||
this.loading = false
|
this.notifyMessagesChanged()
|
||||||
|
this.loading.set(false)
|
||||||
},
|
},
|
||||||
complete: () => {
|
complete: () => {
|
||||||
assistantMessage.isStreaming = false
|
assistantMessage.isStreaming = false
|
||||||
this.loading = false
|
this.notifyMessagesChanged()
|
||||||
|
this.loading.set(false)
|
||||||
this.scrollToBottom()
|
this.scrollToBottom()
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
this.input = ''
|
this.input.set('')
|
||||||
}
|
}
|
||||||
|
|
||||||
private resetTypewriter(message: ChatMessage, content: string): void {
|
private resetTypewriter(message: ChatMessage, content: string): void {
|
||||||
this.typewriterBuffer = []
|
this.typewriterBuffer = []
|
||||||
this.typewriterActive = false
|
this.typewriterActive = false
|
||||||
message.content = content
|
message.content = content
|
||||||
|
this.notifyMessagesChanged()
|
||||||
this.scrollToBottom()
|
this.scrollToBottom()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,11 +146,16 @@ export class ChatComponent implements OnInit {
|
|||||||
|
|
||||||
const nextChar = this.typewriterBuffer.shift()
|
const nextChar = this.typewriterBuffer.shift()
|
||||||
message.content += nextChar
|
message.content += nextChar
|
||||||
|
this.notifyMessagesChanged()
|
||||||
this.scrollToBottom()
|
this.scrollToBottom()
|
||||||
|
|
||||||
setTimeout(() => this.playTypewriter(message), 10) // 10ms per character
|
setTimeout(() => this.playTypewriter(message), 10) // 10ms per character
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private notifyMessagesChanged(): void {
|
||||||
|
this.messages.update((messages) => [...messages])
|
||||||
|
}
|
||||||
|
|
||||||
private scrollToBottom(): void {
|
private scrollToBottom(): void {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
this.scrollAnchor?.nativeElement?.scrollIntoView({ behavior: 'smooth' })
|
this.scrollAnchor?.nativeElement?.scrollIntoView({ behavior: 'smooth' })
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
@if (active) {
|
@if (active()) {
|
||||||
<button class="position-absolute top-0 start-100 translate-middle badge bg-secondary border border-light rounded-pill p-1" title="Clear" i18n-title (click)="onClick($event)">
|
<button class="position-absolute top-0 start-100 translate-middle badge bg-secondary border border-light rounded-pill p-1" title="Clear" i18n-title (click)="onClick($event)">
|
||||||
@if (!isNumbered && selected) {
|
@if (!isNumbered() && selected()) {
|
||||||
<i-bs class="check" width="1em" height="1em" name="check-lg"></i-bs>
|
<i-bs class="check" width="1em" height="1em" name="check-lg"></i-bs>
|
||||||
}
|
}
|
||||||
@if (isNumbered) {
|
@if (isNumbered()) {
|
||||||
<div class="number">{{number}}<span class="visually-hidden">selected</span></div>
|
<div class="number">{{number()}}<span class="visually-hidden">selected</span></div>
|
||||||
}
|
}
|
||||||
<i-bs class="x" width=".9em" height="1em" name="x-lg"></i-bs>
|
<i-bs class="x" width=".9em" height="1em" name="x-lg"></i-bs>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -21,20 +21,20 @@ describe('ClearableBadgeComponent', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('should support selected', () => {
|
it('should support selected', () => {
|
||||||
component.selected = true
|
fixture.componentRef.setInput('selected', true)
|
||||||
expect(component.active).toBeTruthy()
|
expect(component.active()).toBeTruthy()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should support numbered', () => {
|
it('should support numbered', () => {
|
||||||
component.number = 3
|
fixture.componentRef.setInput('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
|
||||||
component.selected = true
|
fixture.componentRef.setInput('selected', true)
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
component.cleared.subscribe((clear) => {
|
component.cleared.subscribe((clear) => {
|
||||||
clearedResult = clear
|
clearedResult = clear
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Component, EventEmitter, Input, Output } from '@angular/core'
|
import { Component, computed, EventEmitter, input, Output } from '@angular/core'
|
||||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
@@ -8,24 +8,15 @@ import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
|||||||
imports: [NgxBootstrapIconsModule],
|
imports: [NgxBootstrapIconsModule],
|
||||||
})
|
})
|
||||||
export class ClearableBadgeComponent {
|
export class ClearableBadgeComponent {
|
||||||
constructor() {}
|
readonly number = input<number>(undefined)
|
||||||
|
readonly selected = input<boolean>(undefined)
|
||||||
@Input()
|
|
||||||
number: number
|
|
||||||
|
|
||||||
@Input()
|
|
||||||
selected: boolean
|
|
||||||
|
|
||||||
@Output()
|
@Output()
|
||||||
cleared: EventEmitter<boolean> = new EventEmitter()
|
cleared: EventEmitter<boolean> = new EventEmitter()
|
||||||
|
|
||||||
get active(): boolean {
|
readonly active = computed(() => this.selected() || this.number() > -1)
|
||||||
return this.selected || this.number > -1
|
|
||||||
}
|
|
||||||
|
|
||||||
get isNumbered(): boolean {
|
readonly isNumbered = computed(() => this.number() > -1)
|
||||||
return this.number > -1
|
|
||||||
}
|
|
||||||
|
|
||||||
onClick(event: PointerEvent) {
|
onClick(event: PointerEvent) {
|
||||||
this.cleared.emit(true)
|
this.cleared.emit(true)
|
||||||
|
|||||||
@@ -23,10 +23,10 @@ export class ConfirmDialogComponent extends LoadingComponentWithPermissions {
|
|||||||
title = $localize`Confirmation`
|
title = $localize`Confirmation`
|
||||||
|
|
||||||
@Input()
|
@Input()
|
||||||
messageBold
|
messageBold: string
|
||||||
|
|
||||||
@Input()
|
@Input()
|
||||||
message
|
message: string
|
||||||
|
|
||||||
@Input()
|
@Input()
|
||||||
btnClass = 'btn-primary'
|
btnClass = 'btn-primary'
|
||||||
@@ -38,7 +38,7 @@ export class ConfirmDialogComponent extends LoadingComponentWithPermissions {
|
|||||||
alternativeBtnClass = 'btn-secondary'
|
alternativeBtnClass = 'btn-secondary'
|
||||||
|
|
||||||
@Input()
|
@Input()
|
||||||
alternativeBtnCaption
|
alternativeBtnCaption: string
|
||||||
|
|
||||||
@Input()
|
@Input()
|
||||||
cancelBtnClass = 'btn-outline-secondary'
|
cancelBtnClass = 'btn-outline-secondary'
|
||||||
|
|||||||
+7
-7
@@ -9,9 +9,9 @@
|
|||||||
<label class="form-label" for="metadataDocumentID" i18n>Documents:</label>
|
<label class="form-label" for="metadataDocumentID" i18n>Documents:</label>
|
||||||
<ul class="list-group"
|
<ul class="list-group"
|
||||||
cdkDropList
|
cdkDropList
|
||||||
[cdkDropListData]="documentIDs"
|
[cdkDropListData]="documentIDs()"
|
||||||
(cdkDropListDropped)="onDrop($event)">
|
(cdkDropListDropped)="onDrop($event)">
|
||||||
@for (documentID of documentIDs; track documentID) {
|
@for (documentID of documentIDs(); track documentID) {
|
||||||
@let document = getDocument(documentID);
|
@let document = getDocument(documentID);
|
||||||
@if (document) {
|
@if (document) {
|
||||||
<li class="list-group-item d-flex align-items-center" cdkDrag>
|
<li class="list-group-item d-flex align-items-center" cdkDrag>
|
||||||
@@ -36,22 +36,22 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="form-group mt-4">
|
<div class="form-group mt-4">
|
||||||
<label class="form-label" for="metadataDocumentID" i18n>Use metadata from:</label>
|
<label class="form-label" for="metadataDocumentID" i18n>Use metadata from:</label>
|
||||||
<select class="form-select" [(ngModel)]="metadataDocumentID">
|
<select class="form-select" [ngModel]="metadataDocumentID()" (ngModelChange)="metadataDocumentID.set($event)">
|
||||||
<option [ngValue]="-1" i18n>Regenerate all metadata</option>
|
<option [ngValue]="-1" i18n>Regenerate all metadata</option>
|
||||||
@for (document of documents; track document.id) {
|
@for (document of documents(); track document.id) {
|
||||||
<option [ngValue]="document.id">{{document.title}}</option>
|
<option [ngValue]="document.id">{{document.title}}</option>
|
||||||
}
|
}
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-check form-switch mt-4">
|
<div class="form-check form-switch mt-4">
|
||||||
<input class="form-check-input" type="checkbox" role="switch" id="archiveFallbackSwitch" [(ngModel)]="archiveFallback">
|
<input class="form-check-input" type="checkbox" role="switch" id="archiveFallbackSwitch" [ngModel]="archiveFallback()" (ngModelChange)="archiveFallback.set($event)">
|
||||||
<label class="form-check-label" for="archiveFallbackSwitch" i18n>Try to include archive version in merge for non-PDF files</label>
|
<label class="form-check-label" for="archiveFallbackSwitch" i18n>Try to include archive version in merge for non-PDF files</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-check form-switch mt-2">
|
<div class="form-check form-switch mt-2">
|
||||||
<input class="form-check-input" type="checkbox" role="switch" id="deleteOriginalsSwitch" [(ngModel)]="deleteOriginals" [disabled]="!userOwnsAllDocuments">
|
<input class="form-check-input" type="checkbox" role="switch" id="deleteOriginalsSwitch" [ngModel]="deleteOriginals()" (ngModelChange)="deleteOriginals.set($event)" [disabled]="!userOwnsAllDocuments">
|
||||||
<label class="form-check-label" for="deleteOriginalsSwitch" i18n>Delete original documents after successful merge</label>
|
<label class="form-check-label" for="deleteOriginalsSwitch" i18n>Delete original documents after successful merge</label>
|
||||||
</div>
|
</div>
|
||||||
@if (!archiveFallback) {
|
@if (!archiveFallback()) {
|
||||||
<p class="small text-muted fst-italic mt-4" i18n>Note that only PDFs will be included.</p>
|
<p class="small text-muted fst-italic mt-4" i18n>Note that only PDFs will be included.</p>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+4
-4
@@ -50,12 +50,12 @@ describe('MergeConfirmDialogComponent', () => {
|
|||||||
|
|
||||||
component.ngOnInit()
|
component.ngOnInit()
|
||||||
|
|
||||||
expect(component.documents).toEqual(documents)
|
expect(component.documents()).toEqual(documents)
|
||||||
expect(documentService.getFew).toHaveBeenCalledWith(component.documentIDs)
|
expect(documentService.getFew).toHaveBeenCalledWith(component.documentIDs())
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should move documentIDs on drop', () => {
|
it('should move documentIDs on drop', () => {
|
||||||
component.documentIDs = [1, 2, 3]
|
component.documentIDs.set([1, 2, 3])
|
||||||
const event = {
|
const event = {
|
||||||
previousIndex: 1,
|
previousIndex: 1,
|
||||||
currentIndex: 2,
|
currentIndex: 2,
|
||||||
@@ -63,7 +63,7 @@ describe('MergeConfirmDialogComponent', () => {
|
|||||||
|
|
||||||
component.onDrop(event as any)
|
component.onDrop(event as any)
|
||||||
|
|
||||||
expect(component.documentIDs).toEqual([1, 3, 2])
|
expect(component.documentIDs()).toEqual([1, 3, 2])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should get document by ID', () => {
|
it('should get document by ID', () => {
|
||||||
|
|||||||
+13
-15
@@ -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 } from '@angular/core'
|
import { Component, OnInit, inject, signal } 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,15 +36,11 @@ export class MergeConfirmDialogComponent
|
|||||||
private documentService = inject(DocumentService)
|
private documentService = inject(DocumentService)
|
||||||
private permissionService = inject(PermissionsService)
|
private permissionService = inject(PermissionsService)
|
||||||
|
|
||||||
public documentIDs: number[] = []
|
readonly documentIDs = signal<number[]>([])
|
||||||
public archiveFallback: boolean = false
|
readonly archiveFallback = signal(false)
|
||||||
public deleteOriginals: boolean = false
|
readonly deleteOriginals = signal(false)
|
||||||
private _documents: Document[] = []
|
readonly documents = signal<Document[]>([])
|
||||||
get documents(): Document[] {
|
readonly metadataDocumentID = signal(-1)
|
||||||
return this._documents
|
|
||||||
}
|
|
||||||
|
|
||||||
public metadataDocumentID: number = -1
|
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super()
|
super()
|
||||||
@@ -52,23 +48,25 @@ export class MergeConfirmDialogComponent
|
|||||||
|
|
||||||
ngOnInit() {
|
ngOnInit() {
|
||||||
this.documentService
|
this.documentService
|
||||||
.getFew(this.documentIDs)
|
.getFew(this.documentIDs())
|
||||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||||
.subscribe((r) => {
|
.subscribe((r) => {
|
||||||
this._documents = r.results
|
this.documents.set(r.results)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
onDrop(event: CdkDragDrop<number[]>) {
|
onDrop(event: CdkDragDrop<number[]>) {
|
||||||
moveItemInArray(this.documentIDs, event.previousIndex, event.currentIndex)
|
const documentIDs = this.documentIDs().concat()
|
||||||
|
moveItemInArray(documentIDs, event.previousIndex, event.currentIndex)
|
||||||
|
this.documentIDs.set(documentIDs)
|
||||||
}
|
}
|
||||||
|
|
||||||
getDocument(documentID: number): Document {
|
getDocument(documentID: number): Document {
|
||||||
return this.documents.find((d) => d.id === documentID)
|
return this.documents().find((d) => d.id === documentID)
|
||||||
}
|
}
|
||||||
|
|
||||||
get userOwnsAllDocuments(): boolean {
|
get userOwnsAllDocuments(): boolean {
|
||||||
return this.documents.every((d) =>
|
return this.documents().every((d) =>
|
||||||
this.permissionService.currentUserOwnsObject(d)
|
this.permissionService.currentUserOwnsObject(d)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-11
@@ -1,4 +1,4 @@
|
|||||||
import { Component, Input } from '@angular/core'
|
import { Component } 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,18 +14,11 @@ 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) {
|
||||||
|
|||||||
+3
-3
@@ -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>
|
||||||
|
|||||||
+1
-1
@@ -28,7 +28,7 @@ describe('RotateConfirmDialogComponent', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('should support rotating the image', () => {
|
it('should support rotating the image', () => {
|
||||||
component.documentID = 1
|
component.documentID.set(1)
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
component.rotate()
|
component.rotate()
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
|
|||||||
+6
-8
@@ -1,5 +1,5 @@
|
|||||||
import { NgStyle } from '@angular/common'
|
import { NgStyle } from '@angular/common'
|
||||||
import { Component, inject } from '@angular/core'
|
import { Component, inject, signal } 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,14 +13,12 @@ import { ConfirmDialogComponent } from '../confirm-dialog.component'
|
|||||||
export class RotateConfirmDialogComponent extends ConfirmDialogComponent {
|
export class RotateConfirmDialogComponent extends ConfirmDialogComponent {
|
||||||
documentService = inject(DocumentService)
|
documentService = inject(DocumentService)
|
||||||
|
|
||||||
public documentID: number
|
readonly documentID = signal<number>(undefined)
|
||||||
public showPDFNote: boolean = true
|
readonly showPDFNote = signal(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
|
||||||
}
|
}
|
||||||
@@ -30,6 +28,6 @@ export class RotateConfirmDialogComponent extends ConfirmDialogComponent {
|
|||||||
}
|
}
|
||||||
|
|
||||||
rotate(clockwise: boolean = true) {
|
rotate(clockwise: boolean = true) {
|
||||||
this.rotation += clockwise ? 90 : -90
|
this.rotation.update((rotation) => rotation + (clockwise ? 90 : -90))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+11
-1
@@ -1,5 +1,12 @@
|
|||||||
import { CurrencyPipe, getLocaleCurrencyCode, SlicePipe } from '@angular/common'
|
import { CurrencyPipe, getLocaleCurrencyCode, SlicePipe } from '@angular/common'
|
||||||
import { Component, inject, Input, LOCALE_ID, OnInit } from '@angular/core'
|
import {
|
||||||
|
ChangeDetectorRef,
|
||||||
|
Component,
|
||||||
|
inject,
|
||||||
|
Input,
|
||||||
|
LOCALE_ID,
|
||||||
|
OnInit,
|
||||||
|
} from '@angular/core'
|
||||||
import { NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap'
|
import { NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap'
|
||||||
import { takeUntil } from 'rxjs'
|
import { takeUntil } from 'rxjs'
|
||||||
import { CustomField, CustomFieldDataType } from 'src/app/data/custom-field'
|
import { CustomField, CustomFieldDataType } from 'src/app/data/custom-field'
|
||||||
@@ -22,6 +29,7 @@ export class CustomFieldDisplayComponent
|
|||||||
{
|
{
|
||||||
private customFieldService = inject(CustomFieldsService)
|
private customFieldService = inject(CustomFieldsService)
|
||||||
private documentService = inject(DocumentService)
|
private documentService = inject(DocumentService)
|
||||||
|
private changeDetector = inject(ChangeDetectorRef)
|
||||||
|
|
||||||
CustomFieldDataType = CustomFieldDataType
|
CustomFieldDataType = CustomFieldDataType
|
||||||
|
|
||||||
@@ -74,6 +82,7 @@ export class CustomFieldDisplayComponent
|
|||||||
this.customFieldService.listAll().subscribe((r) => {
|
this.customFieldService.listAll().subscribe((r) => {
|
||||||
this.customFields = r.results
|
this.customFields = r.results
|
||||||
this.init()
|
this.init()
|
||||||
|
this.changeDetector.markForCheck()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,6 +120,7 @@ export class CustomFieldDisplayComponent
|
|||||||
this.docLinkDocuments = this.value
|
this.docLinkDocuments = this.value
|
||||||
.map((id) => result.results.find((d) => d.id === id))
|
.map((id) => result.results.find((d) => d.id === id))
|
||||||
.filter((d) => d)
|
.filter((d) => d)
|
||||||
|
this.changeDetector.markForCheck()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+21
-20
@@ -1,11 +1,6 @@
|
|||||||
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
|
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
|
||||||
import { provideHttpClientTesting } from '@angular/common/http/testing'
|
import { provideHttpClientTesting } from '@angular/common/http/testing'
|
||||||
import {
|
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||||
ComponentFixture,
|
|
||||||
TestBed,
|
|
||||||
fakeAsync,
|
|
||||||
tick,
|
|
||||||
} from '@angular/core/testing'
|
|
||||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||||
import { By } from '@angular/platform-browser'
|
import { By } from '@angular/platform-browser'
|
||||||
import {
|
import {
|
||||||
@@ -74,7 +69,7 @@ describe('CustomFieldsDropdownComponent', () => {
|
|||||||
})
|
})
|
||||||
)
|
)
|
||||||
settingsService = TestBed.inject(SettingsService)
|
settingsService = TestBed.inject(SettingsService)
|
||||||
settingsService.currentUser = { id: 1, username: 'test' }
|
settingsService.currentUser.set({ id: 1, username: 'test' })
|
||||||
fixture = TestBed.createComponent(CustomFieldsDropdownComponent)
|
fixture = TestBed.createComponent(CustomFieldsDropdownComponent)
|
||||||
component = fixture.componentInstance
|
component = fixture.componentInstance
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
@@ -100,8 +95,8 @@ describe('CustomFieldsDropdownComponent', () => {
|
|||||||
it('should support update unused fields', () => {
|
it('should support update unused fields', () => {
|
||||||
component.existingFields = [{ field: fields[0].id } as any]
|
component.existingFields = [{ field: fields[0].id } as any]
|
||||||
component['updateUnusedFields']()
|
component['updateUnusedFields']()
|
||||||
expect(component['unusedFields'].length).toEqual(1)
|
expect(component['unusedFields']().length).toEqual(1)
|
||||||
expect(component['unusedFields'][0].name).toEqual('Field 2')
|
expect(component['unusedFields']()[0].name).toEqual('Field 2')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should support getting data type label', () => {
|
it('should support getting data type label', () => {
|
||||||
@@ -110,7 +105,8 @@ describe('CustomFieldsDropdownComponent', () => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should support creating field, show error if necessary, then add', fakeAsync(() => {
|
it('should support creating field, show error if necessary, then add', () => {
|
||||||
|
jest.useFakeTimers()
|
||||||
let modal: NgbModalRef
|
let modal: NgbModalRef
|
||||||
modalService.activeInstances.subscribe((m) => (modal = m[m.length - 1]))
|
modalService.activeInstances.subscribe((m) => (modal = m[m.length - 1]))
|
||||||
const toastErrorSpy = jest.spyOn(toastService, 'showError')
|
const toastErrorSpy = jest.spyOn(toastService, 'showError')
|
||||||
@@ -134,11 +130,12 @@ describe('CustomFieldsDropdownComponent', () => {
|
|||||||
|
|
||||||
// succeed
|
// succeed
|
||||||
editDialog.succeeded.emit(fields[0])
|
editDialog.succeeded.emit(fields[0])
|
||||||
tick(100)
|
jest.advanceTimersByTime(100)
|
||||||
expect(toastInfoSpy).toHaveBeenCalled()
|
expect(toastInfoSpy).toHaveBeenCalled()
|
||||||
expect(getFieldsSpy).toHaveBeenCalled()
|
expect(getFieldsSpy).toHaveBeenCalled()
|
||||||
expect(addFieldSpy).toHaveBeenCalled()
|
expect(addFieldSpy).toHaveBeenCalled()
|
||||||
}))
|
jest.useRealTimers()
|
||||||
|
})
|
||||||
|
|
||||||
it('should support creating field with name', () => {
|
it('should support creating field with name', () => {
|
||||||
let modal: NgbModalRef
|
let modal: NgbModalRef
|
||||||
@@ -150,12 +147,13 @@ describe('CustomFieldsDropdownComponent', () => {
|
|||||||
expect(editDialog.object.name).toEqual('Foo bar')
|
expect(editDialog.object.name).toEqual('Foo bar')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should support arrow keyboard navigation', fakeAsync(() => {
|
it('should support arrow keyboard navigation', () => {
|
||||||
|
jest.useFakeTimers()
|
||||||
fixture.nativeElement
|
fixture.nativeElement
|
||||||
.querySelector('button')
|
.querySelector('button')
|
||||||
.dispatchEvent(new MouseEvent('click')) // open
|
.dispatchEvent(new MouseEvent('click')) // open
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
tick(100)
|
jest.advanceTimersByTime(100)
|
||||||
const filterInputEl: HTMLInputElement =
|
const filterInputEl: HTMLInputElement =
|
||||||
component.listFilterTextInput.nativeElement
|
component.listFilterTextInput.nativeElement
|
||||||
expect(document.activeElement).toEqual(filterInputEl)
|
expect(document.activeElement).toEqual(filterInputEl)
|
||||||
@@ -193,14 +191,16 @@ describe('CustomFieldsDropdownComponent', () => {
|
|||||||
new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })
|
new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })
|
||||||
)
|
)
|
||||||
expect(document.activeElement).toEqual(itemButtons[0])
|
expect(document.activeElement).toEqual(itemButtons[0])
|
||||||
}))
|
jest.useRealTimers()
|
||||||
|
})
|
||||||
|
|
||||||
it('should support arrow keyboard navigation after tab keyboard navigation', fakeAsync(() => {
|
it('should support arrow keyboard navigation after tab keyboard navigation', () => {
|
||||||
|
jest.useFakeTimers()
|
||||||
fixture.nativeElement
|
fixture.nativeElement
|
||||||
.querySelector('button')
|
.querySelector('button')
|
||||||
.dispatchEvent(new MouseEvent('click')) // open
|
.dispatchEvent(new MouseEvent('click')) // open
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
tick(100)
|
jest.advanceTimersByTime(100)
|
||||||
const filterInputEl: HTMLInputElement =
|
const filterInputEl: HTMLInputElement =
|
||||||
component.listFilterTextInput.nativeElement
|
component.listFilterTextInput.nativeElement
|
||||||
expect(document.activeElement).toEqual(filterInputEl)
|
expect(document.activeElement).toEqual(filterInputEl)
|
||||||
@@ -229,9 +229,10 @@ describe('CustomFieldsDropdownComponent', () => {
|
|||||||
new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })
|
new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })
|
||||||
)
|
)
|
||||||
expect(document.activeElement).toEqual(itemButtons[1])
|
expect(document.activeElement).toEqual(itemButtons[1])
|
||||||
}))
|
jest.useRealTimers()
|
||||||
|
})
|
||||||
|
|
||||||
it('should support enter keyboard navigation', fakeAsync(() => {
|
it('should support enter keyboard navigation', () => {
|
||||||
jest.spyOn(component, 'canCreateFields', 'get').mockReturnValue(true)
|
jest.spyOn(component, 'canCreateFields', 'get').mockReturnValue(true)
|
||||||
const addFieldSpy = jest.spyOn(component, 'addField')
|
const addFieldSpy = jest.spyOn(component, 'addField')
|
||||||
const createFieldSpy = jest.spyOn(component, 'createField')
|
const createFieldSpy = jest.spyOn(component, 'createField')
|
||||||
@@ -250,5 +251,5 @@ describe('CustomFieldsDropdownComponent', () => {
|
|||||||
component.listFilterEnter()
|
component.listFilterEnter()
|
||||||
expect(createFieldSpy).not.toHaveBeenCalled()
|
expect(createFieldSpy).not.toHaveBeenCalled()
|
||||||
expect(addFieldSpy).not.toHaveBeenCalled()
|
expect(addFieldSpy).not.toHaveBeenCalled()
|
||||||
}))
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
+7
-4
@@ -8,6 +8,7 @@ import {
|
|||||||
ViewChild,
|
ViewChild,
|
||||||
ViewChildren,
|
ViewChildren,
|
||||||
inject,
|
inject,
|
||||||
|
signal,
|
||||||
} from '@angular/core'
|
} from '@angular/core'
|
||||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||||
import { NgbDropdownModule, NgbModal } from '@ng-bootstrap/ng-bootstrap'
|
import { NgbDropdownModule, NgbModal } from '@ng-bootstrap/ng-bootstrap'
|
||||||
@@ -65,11 +66,11 @@ export class CustomFieldsDropdownComponent extends LoadingComponentWithPermissio
|
|||||||
@ViewChildren('button') buttons: QueryList<ElementRef>
|
@ViewChildren('button') buttons: QueryList<ElementRef>
|
||||||
|
|
||||||
private customFields: CustomField[] = []
|
private customFields: CustomField[] = []
|
||||||
private unusedFields: CustomField[] = []
|
private readonly unusedFields = signal<CustomField[]>([])
|
||||||
private keyboardIndex: number
|
private keyboardIndex: number
|
||||||
|
|
||||||
public get filteredFields(): CustomField[] {
|
public get filteredFields(): CustomField[] {
|
||||||
return this.unusedFields.filter(
|
return this.unusedFields().filter(
|
||||||
(f) => !this.filterText || matchesSearchText(f.name, this.filterText)
|
(f) => !this.filterText || matchesSearchText(f.name, this.filterText)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -99,8 +100,10 @@ export class CustomFieldsDropdownComponent extends LoadingComponentWithPermissio
|
|||||||
}
|
}
|
||||||
|
|
||||||
private updateUnusedFields() {
|
private updateUnusedFields() {
|
||||||
this.unusedFields = this.customFields.filter(
|
this.unusedFields.set(
|
||||||
(f) => !this.existingFields?.find((e) => e.field === f.id)
|
this.customFields.filter(
|
||||||
|
(f) => !this.existingFields?.find((e) => e.field === f.id)
|
||||||
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -75,9 +75,9 @@
|
|||||||
|
|
||||||
<ng-template #queryAtom let-atom="atom">
|
<ng-template #queryAtom let-atom="atom">
|
||||||
<div class="input-group input-group-sm">
|
<div class="input-group input-group-sm">
|
||||||
<ng-select
|
<ng-select #fieldSelects
|
||||||
class="paperless-input-select"
|
class="paperless-input-select"
|
||||||
[items]="customFields"
|
[items]="customFields()"
|
||||||
[(ngModel)]="atom.field"
|
[(ngModel)]="atom.field"
|
||||||
[disabled]="disabled"
|
[disabled]="disabled"
|
||||||
bindLabel="name"
|
bindLabel="name"
|
||||||
|
|||||||
+7
-13
@@ -1,11 +1,6 @@
|
|||||||
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
|
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
|
||||||
import { provideHttpClientTesting } from '@angular/common/http/testing'
|
import { provideHttpClientTesting } from '@angular/common/http/testing'
|
||||||
import {
|
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||||
ComponentFixture,
|
|
||||||
fakeAsync,
|
|
||||||
TestBed,
|
|
||||||
tick,
|
|
||||||
} from '@angular/core/testing'
|
|
||||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||||
import { NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap'
|
import { NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap'
|
||||||
import { NgSelectModule } from '@ng-select/ng-select'
|
import { NgSelectModule } from '@ng-select/ng-select'
|
||||||
@@ -84,7 +79,7 @@ describe('CustomFieldsQueryDropdownComponent', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('should initialize custom fields on creation', () => {
|
it('should initialize custom fields on creation', () => {
|
||||||
expect(component.customFields).toEqual(customFields)
|
expect(component.customFields()).toEqual(customFields)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should add an expression when opened if queries are empty', () => {
|
it('should add an expression when opened if queries are empty', () => {
|
||||||
@@ -106,7 +101,7 @@ describe('CustomFieldsQueryDropdownComponent', () => {
|
|||||||
data_type: CustomFieldDataType.String,
|
data_type: CustomFieldDataType.String,
|
||||||
extra_data: {},
|
extra_data: {},
|
||||||
}
|
}
|
||||||
component.customFields = [field]
|
component.customFields.set([field])
|
||||||
const operators = component.getOperatorsForField(1)
|
const operators = component.getOperatorsForField(1)
|
||||||
expect(operators.length).toEqual(
|
expect(operators.length).toEqual(
|
||||||
[
|
[
|
||||||
@@ -143,7 +138,7 @@ describe('CustomFieldsQueryDropdownComponent', () => {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
component.customFields = [field]
|
component.customFields.set([field])
|
||||||
const options = component.getSelectOptionsForField(1)
|
const options = component.getSelectOptionsForField(1)
|
||||||
expect(options).toEqual([
|
expect(options).toEqual([
|
||||||
{ label: 'Option 1', id: 'abc-123' },
|
{ label: 'Option 1', id: 'abc-123' },
|
||||||
@@ -210,14 +205,13 @@ describe('CustomFieldsQueryDropdownComponent', () => {
|
|||||||
expect(component.name).toBe('test_title')
|
expect(component.name).toBe('test_title')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should add a default atom on open and focus the select field', fakeAsync(() => {
|
it('should add a default atom on open', async () => {
|
||||||
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()
|
await fixture.whenStable()
|
||||||
expect(component.selectionModel.queries.length).toBe(1)
|
expect(component.selectionModel.queries.length).toBe(1)
|
||||||
expect(window.document.activeElement.tagName).toBe('INPUT')
|
})
|
||||||
}))
|
|
||||||
|
|
||||||
describe('CustomFieldQueriesModel', () => {
|
describe('CustomFieldQueriesModel', () => {
|
||||||
let model: CustomFieldQueriesModel
|
let model: CustomFieldQueriesModel
|
||||||
|
|||||||
+6
-5
@@ -6,6 +6,7 @@ import {
|
|||||||
Input,
|
Input,
|
||||||
Output,
|
Output,
|
||||||
QueryList,
|
QueryList,
|
||||||
|
signal,
|
||||||
ViewChild,
|
ViewChild,
|
||||||
ViewChildren,
|
ViewChildren,
|
||||||
} from '@angular/core'
|
} from '@angular/core'
|
||||||
@@ -278,7 +279,7 @@ export class CustomFieldsQueryDropdownComponent extends LoadingComponentWithPerm
|
|||||||
@Output()
|
@Output()
|
||||||
selectionModelChange = new EventEmitter<CustomFieldQueriesModel>()
|
selectionModelChange = new EventEmitter<CustomFieldQueriesModel>()
|
||||||
|
|
||||||
customFields: CustomField[] = []
|
readonly customFields = signal<CustomField[]>([])
|
||||||
|
|
||||||
public readonly today: string = new Date().toLocaleDateString('en-CA')
|
public readonly today: string = new Date().toLocaleDateString('en-CA')
|
||||||
|
|
||||||
@@ -325,12 +326,12 @@ export class CustomFieldsQueryDropdownComponent extends LoadingComponentWithPerm
|
|||||||
.listAll()
|
.listAll()
|
||||||
.pipe(first(), takeUntil(this.unsubscribeNotifier))
|
.pipe(first(), takeUntil(this.unsubscribeNotifier))
|
||||||
.subscribe((result) => {
|
.subscribe((result) => {
|
||||||
this.customFields = result.results
|
this.customFields.set(result.results)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
public getCustomFieldByID(id: number): CustomField {
|
public getCustomFieldByID(id: number): CustomField {
|
||||||
return this.customFields.find((field) => field.id === id)
|
return this.customFields().find((field) => field.id === id)
|
||||||
}
|
}
|
||||||
|
|
||||||
public addAtom(expression: CustomFieldQueryExpression) {
|
public addAtom(expression: CustomFieldQueryExpression) {
|
||||||
@@ -353,7 +354,7 @@ export class CustomFieldsQueryDropdownComponent extends LoadingComponentWithPerm
|
|||||||
getOperatorsForField(
|
getOperatorsForField(
|
||||||
fieldID: number
|
fieldID: number
|
||||||
): Array<{ value: string; label: string }> {
|
): Array<{ value: string; label: string }> {
|
||||||
const field = this.customFields.find((field) => field.id === fieldID)
|
const field = this.customFields().find((field) => field.id === fieldID)
|
||||||
const groups: CustomFieldQueryOperatorGroups[] = field
|
const groups: CustomFieldQueryOperatorGroups[] = field
|
||||||
? CUSTOM_FIELD_QUERY_OPERATOR_GROUPS_BY_TYPE[field.data_type]
|
? CUSTOM_FIELD_QUERY_OPERATOR_GROUPS_BY_TYPE[field.data_type]
|
||||||
: [CustomFieldQueryOperatorGroups.Basic]
|
: [CustomFieldQueryOperatorGroups.Basic]
|
||||||
@@ -369,7 +370,7 @@ export class CustomFieldsQueryDropdownComponent extends LoadingComponentWithPerm
|
|||||||
getSelectOptionsForField(
|
getSelectOptionsForField(
|
||||||
fieldID: number
|
fieldID: number
|
||||||
): Array<{ label: string; id: string }> {
|
): Array<{ label: string; id: string }> {
|
||||||
const field = this.customFields.find((field) => field.id === fieldID)
|
const field = this.customFields().find((field) => field.id === fieldID)
|
||||||
if (field) {
|
if (field) {
|
||||||
return field.extra_data['select_options']
|
return field.extra_data['select_options']
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +1,7 @@
|
|||||||
import { DatePipe } from '@angular/common'
|
import { DatePipe } from '@angular/common'
|
||||||
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
|
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
|
||||||
import { provideHttpClientTesting } from '@angular/common/http/testing'
|
import { provideHttpClientTesting } from '@angular/common/http/testing'
|
||||||
import {
|
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||||
ComponentFixture,
|
|
||||||
TestBed,
|
|
||||||
fakeAsync,
|
|
||||||
tick,
|
|
||||||
} from '@angular/core/testing'
|
|
||||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||||
import { NgbModule } from '@ng-bootstrap/ng-bootstrap'
|
import { NgbModule } from '@ng-bootstrap/ng-bootstrap'
|
||||||
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
||||||
@@ -59,27 +54,32 @@ describe('DatesDropdownComponent', () => {
|
|||||||
expect(settingsSpy).toHaveBeenCalled()
|
expect(settingsSpy).toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should support date input, emit change', fakeAsync(() => {
|
it('should support date input, emit change', () => {
|
||||||
|
jest.useFakeTimers()
|
||||||
let result: string
|
let result: string
|
||||||
component.createdDateFromChange.subscribe((date) => (result = date))
|
component.createdDateFromChange.subscribe((date) => (result = date))
|
||||||
const input: HTMLInputElement = fixture.nativeElement.querySelector('input')
|
const input: HTMLInputElement = fixture.nativeElement.querySelector('input')
|
||||||
input.value = '5/30/2023'
|
input.value = '5/30/2023'
|
||||||
input.dispatchEvent(new Event('change'))
|
input.dispatchEvent(new Event('change'))
|
||||||
tick(500)
|
jest.advanceTimersByTime(500)
|
||||||
expect(result).not.toBeNull()
|
expect(result).not.toBeNull()
|
||||||
}))
|
jest.useRealTimers()
|
||||||
|
})
|
||||||
|
|
||||||
it('should support date select, emit datesSet change', fakeAsync(() => {
|
it('should support date select, emit datesSet change', () => {
|
||||||
|
jest.useFakeTimers()
|
||||||
let result: DateSelection
|
let result: DateSelection
|
||||||
component.datesSet.subscribe((date) => (result = date))
|
component.datesSet.subscribe((date) => (result = date))
|
||||||
const input: HTMLInputElement = fixture.nativeElement.querySelector('input')
|
const input: HTMLInputElement = fixture.nativeElement.querySelector('input')
|
||||||
input.value = '5/30/2023'
|
input.value = '5/30/2023'
|
||||||
input.dispatchEvent(new Event('dateSelect'))
|
input.dispatchEvent(new Event('dateSelect'))
|
||||||
tick(500)
|
jest.advanceTimersByTime(500)
|
||||||
expect(result).not.toBeNull()
|
expect(result).not.toBeNull()
|
||||||
}))
|
jest.useRealTimers()
|
||||||
|
})
|
||||||
|
|
||||||
it('should support relative dates', fakeAsync(() => {
|
it('should support relative dates', () => {
|
||||||
|
jest.useFakeTimers()
|
||||||
let result: DateSelection
|
let result: DateSelection
|
||||||
component.datesSet.subscribe((date) => (result = date))
|
component.datesSet.subscribe((date) => (result = date))
|
||||||
component.createdRelativeDate = RelativeDate.WITHIN_1_WEEK // normally set by ngModel binding in dropdown
|
component.createdRelativeDate = RelativeDate.WITHIN_1_WEEK // normally set by ngModel binding in dropdown
|
||||||
@@ -88,7 +88,7 @@ describe('DatesDropdownComponent', () => {
|
|||||||
} as any)
|
} as any)
|
||||||
component.addedRelativeDate = RelativeDate.WITHIN_1_WEEK // normally set by ngModel binding in dropdown
|
component.addedRelativeDate = RelativeDate.WITHIN_1_WEEK // normally set by ngModel binding in dropdown
|
||||||
component.onSetAddedRelativeDate({ id: RelativeDate.WITHIN_1_WEEK } as any)
|
component.onSetAddedRelativeDate({ id: RelativeDate.WITHIN_1_WEEK } as any)
|
||||||
tick(500)
|
jest.advanceTimersByTime(500)
|
||||||
expect(result).toEqual({
|
expect(result).toEqual({
|
||||||
createdFrom: null,
|
createdFrom: null,
|
||||||
createdTo: null,
|
createdTo: null,
|
||||||
@@ -97,7 +97,8 @@ describe('DatesDropdownComponent', () => {
|
|||||||
addedTo: null,
|
addedTo: null,
|
||||||
addedRelativeDateID: RelativeDate.WITHIN_1_WEEK,
|
addedRelativeDateID: RelativeDate.WITHIN_1_WEEK,
|
||||||
})
|
})
|
||||||
}))
|
jest.useRealTimers()
|
||||||
|
})
|
||||||
|
|
||||||
it('should support report if active', () => {
|
it('should support report if active', () => {
|
||||||
component.createdRelativeDate = RelativeDate.WITHIN_1_WEEK
|
component.createdRelativeDate = RelativeDate.WITHIN_1_WEEK
|
||||||
@@ -177,11 +178,12 @@ describe('DatesDropdownComponent', () => {
|
|||||||
expect(eventSpy).toHaveBeenCalled()
|
expect(eventSpy).toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should support debounce', fakeAsync(() => {
|
it('should support debounce', () => {
|
||||||
|
jest.useFakeTimers()
|
||||||
let result: DateSelection
|
let result: DateSelection
|
||||||
component.datesSet.subscribe((date) => (result = date))
|
component.datesSet.subscribe((date) => (result = date))
|
||||||
component.onChangeDebounce()
|
component.onChangeDebounce()
|
||||||
tick(500)
|
jest.advanceTimersByTime(500)
|
||||||
expect(result).toEqual({
|
expect(result).toEqual({
|
||||||
createdFrom: null,
|
createdFrom: null,
|
||||||
createdTo: null,
|
createdTo: null,
|
||||||
@@ -190,5 +192,6 @@ describe('DatesDropdownComponent', () => {
|
|||||||
addedTo: null,
|
addedTo: null,
|
||||||
addedRelativeDateID: null,
|
addedRelativeDateID: null,
|
||||||
})
|
})
|
||||||
}))
|
jest.useRealTimers()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
+3
-3
@@ -41,20 +41,20 @@ describe('CorrespondentEditDialogComponent', () => {
|
|||||||
|
|
||||||
fixture = TestBed.createComponent(CorrespondentEditDialogComponent)
|
fixture = TestBed.createComponent(CorrespondentEditDialogComponent)
|
||||||
settingsService = TestBed.inject(SettingsService)
|
settingsService = TestBed.inject(SettingsService)
|
||||||
settingsService.currentUser = { id: 99, username: 'user99' }
|
settingsService.currentUser.set({ id: 99, username: 'user99' })
|
||||||
component = fixture.componentInstance
|
component = fixture.componentInstance
|
||||||
|
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should support create and edit modes', () => {
|
it('should support create and edit modes', () => {
|
||||||
component.dialogMode = EditDialogMode.CREATE
|
component.dialogMode.set(EditDialogMode.CREATE)
|
||||||
const createTitleSpy = jest.spyOn(component, 'getCreateTitle')
|
const createTitleSpy = jest.spyOn(component, 'getCreateTitle')
|
||||||
const editTitleSpy = jest.spyOn(component, 'getEditTitle')
|
const editTitleSpy = jest.spyOn(component, 'getEditTitle')
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
expect(createTitleSpy).toHaveBeenCalled()
|
expect(createTitleSpy).toHaveBeenCalled()
|
||||||
expect(editTitleSpy).not.toHaveBeenCalled()
|
expect(editTitleSpy).not.toHaveBeenCalled()
|
||||||
component.dialogMode = EditDialogMode.EDIT
|
component.dialogMode.set(EditDialogMode.EDIT)
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
expect(editTitleSpy).toHaveBeenCalled()
|
expect(editTitleSpy).toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|||||||
+8
-8
@@ -44,33 +44,33 @@ describe('CustomFieldEditDialogComponent', () => {
|
|||||||
|
|
||||||
fixture = TestBed.createComponent(CustomFieldEditDialogComponent)
|
fixture = TestBed.createComponent(CustomFieldEditDialogComponent)
|
||||||
settingsService = TestBed.inject(SettingsService)
|
settingsService = TestBed.inject(SettingsService)
|
||||||
settingsService.currentUser = { id: 99, username: 'user99' }
|
settingsService.currentUser.set({ id: 99, username: 'user99' })
|
||||||
component = fixture.componentInstance
|
component = fixture.componentInstance
|
||||||
|
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should support create and edit modes', () => {
|
it('should support create and edit modes', () => {
|
||||||
component.dialogMode = EditDialogMode.CREATE
|
component.dialogMode.set(EditDialogMode.CREATE)
|
||||||
const createTitleSpy = jest.spyOn(component, 'getCreateTitle')
|
const createTitleSpy = jest.spyOn(component, 'getCreateTitle')
|
||||||
const editTitleSpy = jest.spyOn(component, 'getEditTitle')
|
const editTitleSpy = jest.spyOn(component, 'getEditTitle')
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
expect(createTitleSpy).toHaveBeenCalled()
|
expect(createTitleSpy).toHaveBeenCalled()
|
||||||
expect(editTitleSpy).not.toHaveBeenCalled()
|
expect(editTitleSpy).not.toHaveBeenCalled()
|
||||||
component.dialogMode = EditDialogMode.EDIT
|
component.dialogMode.set(EditDialogMode.EDIT)
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
expect(editTitleSpy).toHaveBeenCalled()
|
expect(editTitleSpy).toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should disable data type select on edit', () => {
|
it('should disable data type select on edit', () => {
|
||||||
component.dialogMode = EditDialogMode.EDIT
|
component.dialogMode.set(EditDialogMode.EDIT)
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
component.ngOnInit()
|
component.ngOnInit()
|
||||||
expect(component.objectForm.get('data_type').disabled).toBeTruthy()
|
expect(component.objectForm.get('data_type').disabled).toBeTruthy()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should initialize select options on edit', () => {
|
it('should initialize select options on edit', () => {
|
||||||
component.dialogMode = EditDialogMode.EDIT
|
component.dialogMode.set(EditDialogMode.EDIT)
|
||||||
component.object = {
|
component.object = {
|
||||||
id: 1,
|
id: 1,
|
||||||
name: 'Field 1',
|
name: 'Field 1',
|
||||||
@@ -91,7 +91,7 @@ describe('CustomFieldEditDialogComponent', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('should support add / remove select options', () => {
|
it('should support add / remove select options', () => {
|
||||||
component.dialogMode = EditDialogMode.CREATE
|
component.dialogMode.set(EditDialogMode.CREATE)
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
component.ngOnInit()
|
component.ngOnInit()
|
||||||
expect(
|
expect(
|
||||||
@@ -115,7 +115,7 @@ describe('CustomFieldEditDialogComponent', () => {
|
|||||||
const selectOptionInputs = component[
|
const selectOptionInputs = component[
|
||||||
'selectOptionInputs'
|
'selectOptionInputs'
|
||||||
] as QueryList<ElementRef>
|
] as QueryList<ElementRef>
|
||||||
component.dialogMode = EditDialogMode.CREATE
|
component.dialogMode.set(EditDialogMode.CREATE)
|
||||||
component.objectForm.get('data_type').setValue(CustomFieldDataType.Select)
|
component.objectForm.get('data_type').setValue(CustomFieldDataType.Select)
|
||||||
component.ngOnInit()
|
component.ngOnInit()
|
||||||
component.ngAfterViewInit()
|
component.ngAfterViewInit()
|
||||||
@@ -125,7 +125,7 @@ describe('CustomFieldEditDialogComponent', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('should send all select options including those changed in form on save', () => {
|
it('should send all select options including those changed in form on save', () => {
|
||||||
component.dialogMode = EditDialogMode.EDIT
|
component.dialogMode.set(EditDialogMode.EDIT)
|
||||||
component.object = {
|
component.object = {
|
||||||
id: 1,
|
id: 1,
|
||||||
name: 'Field 1',
|
name: 'Field 1',
|
||||||
|
|||||||
+1
-1
@@ -149,7 +149,7 @@ export class CustomFieldEditDialogComponent
|
|||||||
}
|
}
|
||||||
|
|
||||||
get typeFieldDisabled(): boolean {
|
get typeFieldDisabled(): boolean {
|
||||||
return this.dialogMode === EditDialogMode.EDIT
|
return this.dialogMode() === EditDialogMode.EDIT
|
||||||
}
|
}
|
||||||
|
|
||||||
private updateSelectOptions() {
|
private updateSelectOptions() {
|
||||||
|
|||||||
+3
-3
@@ -41,20 +41,20 @@ describe('DocumentTypeEditDialogComponent', () => {
|
|||||||
|
|
||||||
fixture = TestBed.createComponent(DocumentTypeEditDialogComponent)
|
fixture = TestBed.createComponent(DocumentTypeEditDialogComponent)
|
||||||
settingsService = TestBed.inject(SettingsService)
|
settingsService = TestBed.inject(SettingsService)
|
||||||
settingsService.currentUser = { id: 99, username: 'user99' }
|
settingsService.currentUser.set({ id: 99, username: 'user99' })
|
||||||
component = fixture.componentInstance
|
component = fixture.componentInstance
|
||||||
|
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should support create and edit modes', () => {
|
it('should support create and edit modes', () => {
|
||||||
component.dialogMode = EditDialogMode.CREATE
|
component.dialogMode.set(EditDialogMode.CREATE)
|
||||||
const createTitleSpy = jest.spyOn(component, 'getCreateTitle')
|
const createTitleSpy = jest.spyOn(component, 'getCreateTitle')
|
||||||
const editTitleSpy = jest.spyOn(component, 'getEditTitle')
|
const editTitleSpy = jest.spyOn(component, 'getEditTitle')
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
expect(createTitleSpy).toHaveBeenCalled()
|
expect(createTitleSpy).toHaveBeenCalled()
|
||||||
expect(editTitleSpy).not.toHaveBeenCalled()
|
expect(editTitleSpy).not.toHaveBeenCalled()
|
||||||
component.dialogMode = EditDialogMode.EDIT
|
component.dialogMode.set(EditDialogMode.EDIT)
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
expect(editTitleSpy).toHaveBeenCalled()
|
expect(editTitleSpy).toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -4,12 +4,7 @@ import {
|
|||||||
provideHttpClientTesting,
|
provideHttpClientTesting,
|
||||||
} from '@angular/common/http/testing'
|
} from '@angular/common/http/testing'
|
||||||
import { Component } from '@angular/core'
|
import { Component } from '@angular/core'
|
||||||
import {
|
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||||
ComponentFixture,
|
|
||||||
TestBed,
|
|
||||||
fakeAsync,
|
|
||||||
tick,
|
|
||||||
} from '@angular/core/testing'
|
|
||||||
import {
|
import {
|
||||||
FormControl,
|
FormControl,
|
||||||
FormGroup,
|
FormGroup,
|
||||||
@@ -122,7 +117,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 = currentUser
|
settingsService.currentUser.set(currentUser as any)
|
||||||
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)
|
||||||
@@ -136,7 +131,7 @@ describe('EditDialogComponent', () => {
|
|||||||
it('should interpolate object permissions', () => {
|
it('should interpolate object permissions', () => {
|
||||||
component.getMatchingAlgorithms() // coverage
|
component.getMatchingAlgorithms() // coverage
|
||||||
component.object = tag
|
component.object = tag
|
||||||
component.dialogMode = EditDialogMode.EDIT
|
component.dialogMode.set(EditDialogMode.EDIT)
|
||||||
component.ngOnInit()
|
component.ngOnInit()
|
||||||
|
|
||||||
expect(component.objectForm.get('permissions_form').value).toEqual({
|
expect(component.objectForm.get('permissions_form').value).toEqual({
|
||||||
@@ -145,15 +140,17 @@ describe('EditDialogComponent', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should delay close enabled', fakeAsync(() => {
|
it('should delay close enabled', () => {
|
||||||
|
jest.useFakeTimers()
|
||||||
expect(component.closeEnabled).toBeFalsy()
|
expect(component.closeEnabled).toBeFalsy()
|
||||||
component.ngOnInit()
|
component.ngOnInit()
|
||||||
tick(100)
|
jest.advanceTimersByTime(100)
|
||||||
expect(component.closeEnabled).toBeTruthy()
|
expect(component.closeEnabled).toBeTruthy()
|
||||||
}))
|
jest.useRealTimers()
|
||||||
|
})
|
||||||
|
|
||||||
it('should set default owner when in create mode if unset', () => {
|
it('should set default owner when in create mode if unset', () => {
|
||||||
component.dialogMode = EditDialogMode.CREATE
|
component.dialogMode.set(EditDialogMode.CREATE)
|
||||||
component.ngOnInit()
|
component.ngOnInit()
|
||||||
expect(component.objectForm.get('permissions_form').value.owner).toEqual(
|
expect(component.objectForm.get('permissions_form').value.owner).toEqual(
|
||||||
currentUser.id
|
currentUser.id
|
||||||
@@ -164,7 +161,7 @@ describe('EditDialogComponent', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('should set default perms when in create mode if set', () => {
|
it('should set default perms when in create mode if set', () => {
|
||||||
component.dialogMode = EditDialogMode.CREATE
|
component.dialogMode.set(EditDialogMode.CREATE)
|
||||||
settingsService.set(SETTINGS_KEYS.DEFAULT_PERMS_OWNER, 11)
|
settingsService.set(SETTINGS_KEYS.DEFAULT_PERMS_OWNER, 11)
|
||||||
settingsService.set(SETTINGS_KEYS.DEFAULT_PERMS_VIEW_USERS, [1, 2])
|
settingsService.set(SETTINGS_KEYS.DEFAULT_PERMS_VIEW_USERS, [1, 2])
|
||||||
settingsService.set(SETTINGS_KEYS.DEFAULT_PERMS_VIEW_GROUPS, [3])
|
settingsService.set(SETTINGS_KEYS.DEFAULT_PERMS_VIEW_GROUPS, [3])
|
||||||
@@ -203,18 +200,18 @@ describe('EditDialogComponent', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('should support create and edit modes', () => {
|
it('should support create and edit modes', () => {
|
||||||
component.dialogMode = EditDialogMode.CREATE
|
component.dialogMode.set(EditDialogMode.CREATE)
|
||||||
const createTitleSpy = jest.spyOn(component, 'getCreateTitle')
|
const createTitleSpy = jest.spyOn(component, 'getCreateTitle')
|
||||||
const editTitleSpy = jest.spyOn(component, 'getEditTitle')
|
const editTitleSpy = jest.spyOn(component, 'getEditTitle')
|
||||||
fixture.detectChanges()
|
component.getTitle()
|
||||||
expect(createTitleSpy).toHaveBeenCalled()
|
expect(createTitleSpy).toHaveBeenCalled()
|
||||||
expect(editTitleSpy).not.toHaveBeenCalled()
|
expect(editTitleSpy).not.toHaveBeenCalled()
|
||||||
component.dialogMode = EditDialogMode.EDIT
|
component.dialogMode.set(EditDialogMode.EDIT)
|
||||||
fixture.detectChanges()
|
component.getTitle()
|
||||||
expect(editTitleSpy).toHaveBeenCalled()
|
expect(editTitleSpy).toHaveBeenCalled()
|
||||||
// coverage
|
// coverage
|
||||||
component.dialogMode = null
|
component.dialogMode.set(null)
|
||||||
fixture.detectChanges()
|
component.getTitle()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should close on cancel', () => {
|
it('should close on cancel', () => {
|
||||||
@@ -225,14 +222,14 @@ describe('EditDialogComponent', () => {
|
|||||||
|
|
||||||
it('should update an object on save in edit mode', () => {
|
it('should update an object on save in edit mode', () => {
|
||||||
const updateSpy = jest.spyOn(tagService, 'update')
|
const updateSpy = jest.spyOn(tagService, 'update')
|
||||||
component.dialogMode = EditDialogMode.EDIT
|
component.dialogMode.set(EditDialogMode.EDIT)
|
||||||
component.save()
|
component.save()
|
||||||
expect(updateSpy).toHaveBeenCalled()
|
expect(updateSpy).toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should not submit owner or permissions for non-owner edits', () => {
|
it('should not submit owner or permissions for non-owner edits', () => {
|
||||||
component.object = tag
|
component.object = tag
|
||||||
component.dialogMode = EditDialogMode.EDIT
|
component.dialogMode.set(EditDialogMode.EDIT)
|
||||||
component.ngOnInit()
|
component.ngOnInit()
|
||||||
|
|
||||||
component.objectForm.get('name').setValue('Updated tag')
|
component.objectForm.get('name').setValue('Updated tag')
|
||||||
@@ -251,7 +248,7 @@ describe('EditDialogComponent', () => {
|
|||||||
|
|
||||||
it('should create an object on save in edit mode', () => {
|
it('should create an object on save in edit mode', () => {
|
||||||
const createSpy = jest.spyOn(tagService, 'create')
|
const createSpy = jest.spyOn(tagService, 'create')
|
||||||
component.dialogMode = EditDialogMode.CREATE
|
component.dialogMode.set(EditDialogMode.CREATE)
|
||||||
component.save()
|
component.save()
|
||||||
expect(createSpy).toHaveBeenCalled()
|
expect(createSpy).toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
OnInit,
|
OnInit,
|
||||||
Output,
|
Output,
|
||||||
inject,
|
inject,
|
||||||
|
model,
|
||||||
} from '@angular/core'
|
} from '@angular/core'
|
||||||
import { FormGroup } from '@angular/forms'
|
import { FormGroup } from '@angular/forms'
|
||||||
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
|
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
|
||||||
@@ -45,10 +46,7 @@ export abstract class EditDialogComponent<
|
|||||||
protected settingsService = inject(SettingsService)
|
protected settingsService = inject(SettingsService)
|
||||||
protected permissionsService = inject(PermissionsService)
|
protected permissionsService = inject(PermissionsService)
|
||||||
|
|
||||||
users: User[]
|
dialogMode = model(EditDialogMode.CREATE)
|
||||||
|
|
||||||
@Input()
|
|
||||||
dialogMode: EditDialogMode = EditDialogMode.CREATE
|
|
||||||
|
|
||||||
@Input()
|
@Input()
|
||||||
object: T
|
object: T
|
||||||
@@ -59,18 +57,20 @@ export abstract class EditDialogComponent<
|
|||||||
@Output()
|
@Output()
|
||||||
failed = new EventEmitter()
|
failed = new EventEmitter()
|
||||||
|
|
||||||
|
users: User[]
|
||||||
|
|
||||||
networkActive = false
|
networkActive = false
|
||||||
|
|
||||||
closeEnabled = false
|
closeEnabled = false
|
||||||
|
|
||||||
error = null
|
error: any = null
|
||||||
|
|
||||||
abstract getForm(): FormGroup
|
abstract getForm(): FormGroup
|
||||||
|
|
||||||
objectForm: FormGroup = this.getForm()
|
objectForm: FormGroup = this.getForm()
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
if (this.object != null && this.dialogMode !== EditDialogMode.CREATE) {
|
if (this.object != null && this.dialogMode() !== EditDialogMode.CREATE) {
|
||||||
this.object['permissions_form'] = {
|
this.object['permissions_form'] = {
|
||||||
owner: (this.object as ObjectWithPermissions).owner,
|
owner: (this.object as ObjectWithPermissions).owner,
|
||||||
set_permissions: (this.object as ObjectWithPermissions).permissions,
|
set_permissions: (this.object as ObjectWithPermissions).permissions,
|
||||||
@@ -124,7 +124,7 @@ export abstract class EditDialogComponent<
|
|||||||
}
|
}
|
||||||
|
|
||||||
getTitle() {
|
getTitle() {
|
||||||
switch (this.dialogMode) {
|
switch (this.dialogMode()) {
|
||||||
case EditDialogMode.CREATE:
|
case EditDialogMode.CREATE:
|
||||||
return this.getCreateTitle()
|
return this.getCreateTitle()
|
||||||
case EditDialogMode.EDIT:
|
case EditDialogMode.EDIT:
|
||||||
@@ -151,7 +151,7 @@ export abstract class EditDialogComponent<
|
|||||||
|
|
||||||
protected shouldSubmitPermissions(): boolean {
|
protected shouldSubmitPermissions(): boolean {
|
||||||
return (
|
return (
|
||||||
this.dialogMode === EditDialogMode.CREATE ||
|
this.dialogMode() === EditDialogMode.CREATE ||
|
||||||
this.permissionsService.currentUserOwnsObject(this.object)
|
this.permissionsService.currentUserOwnsObject(this.object)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -172,7 +172,7 @@ export abstract class EditDialogComponent<
|
|||||||
delete newObject['set_permissions']
|
delete newObject['set_permissions']
|
||||||
}
|
}
|
||||||
var serverResponse: Observable<T>
|
var serverResponse: Observable<T>
|
||||||
switch (this.dialogMode) {
|
switch (this.dialogMode()) {
|
||||||
case EditDialogMode.CREATE:
|
case EditDialogMode.CREATE:
|
||||||
serverResponse = this.service.create(newObject)
|
serverResponse = this.service.create(newObject)
|
||||||
break
|
break
|
||||||
|
|||||||
+3
-3
@@ -45,20 +45,20 @@ describe('GroupEditDialogComponent', () => {
|
|||||||
|
|
||||||
fixture = TestBed.createComponent(GroupEditDialogComponent)
|
fixture = TestBed.createComponent(GroupEditDialogComponent)
|
||||||
settingsService = TestBed.inject(SettingsService)
|
settingsService = TestBed.inject(SettingsService)
|
||||||
settingsService.currentUser = { id: 99, username: 'user99' }
|
settingsService.currentUser.set({ id: 99, username: 'user99' })
|
||||||
component = fixture.componentInstance
|
component = fixture.componentInstance
|
||||||
|
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should support create and edit modes', () => {
|
it('should support create and edit modes', () => {
|
||||||
component.dialogMode = EditDialogMode.CREATE
|
component.dialogMode.set(EditDialogMode.CREATE)
|
||||||
const createTitleSpy = jest.spyOn(component, 'getCreateTitle')
|
const createTitleSpy = jest.spyOn(component, 'getCreateTitle')
|
||||||
const editTitleSpy = jest.spyOn(component, 'getEditTitle')
|
const editTitleSpy = jest.spyOn(component, 'getEditTitle')
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
expect(createTitleSpy).toHaveBeenCalled()
|
expect(createTitleSpy).toHaveBeenCalled()
|
||||||
expect(editTitleSpy).not.toHaveBeenCalled()
|
expect(editTitleSpy).not.toHaveBeenCalled()
|
||||||
component.dialogMode = EditDialogMode.EDIT
|
component.dialogMode.set(EditDialogMode.EDIT)
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
expect(editTitleSpy).toHaveBeenCalled()
|
expect(editTitleSpy).toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|||||||
+2
-2
@@ -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 = null">{{testResultMessage}}</ngb-alert>
|
<ngb-alert #testResultAlert [type]="testResult()" class="mb-0 py-2" (closed)="testResult.set(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">
|
||||||
|
|||||||
+10
-13
@@ -3,12 +3,7 @@ import {
|
|||||||
HttpTestingController,
|
HttpTestingController,
|
||||||
provideHttpClientTesting,
|
provideHttpClientTesting,
|
||||||
} from '@angular/common/http/testing'
|
} from '@angular/common/http/testing'
|
||||||
import {
|
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||||
ComponentFixture,
|
|
||||||
TestBed,
|
|
||||||
fakeAsync,
|
|
||||||
tick,
|
|
||||||
} from '@angular/core/testing'
|
|
||||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||||
import { NgbActiveModal, NgbModule } from '@ng-bootstrap/ng-bootstrap'
|
import { NgbActiveModal, NgbModule } from '@ng-bootstrap/ng-bootstrap'
|
||||||
import { NgSelectModule } from '@ng-select/ng-select'
|
import { NgSelectModule } from '@ng-select/ng-select'
|
||||||
@@ -58,25 +53,26 @@ describe('MailAccountEditDialogComponent', () => {
|
|||||||
|
|
||||||
fixture = TestBed.createComponent(MailAccountEditDialogComponent)
|
fixture = TestBed.createComponent(MailAccountEditDialogComponent)
|
||||||
settingsService = TestBed.inject(SettingsService)
|
settingsService = TestBed.inject(SettingsService)
|
||||||
settingsService.currentUser = { id: 99, username: 'user99' }
|
settingsService.currentUser.set({ id: 99, username: 'user99' })
|
||||||
component = fixture.componentInstance
|
component = fixture.componentInstance
|
||||||
|
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should support create and edit modes', () => {
|
it('should support create and edit modes', () => {
|
||||||
component.dialogMode = EditDialogMode.CREATE
|
component.dialogMode.set(EditDialogMode.CREATE)
|
||||||
const createTitleSpy = jest.spyOn(component, 'getCreateTitle')
|
const createTitleSpy = jest.spyOn(component, 'getCreateTitle')
|
||||||
const editTitleSpy = jest.spyOn(component, 'getEditTitle')
|
const editTitleSpy = jest.spyOn(component, 'getEditTitle')
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
expect(createTitleSpy).toHaveBeenCalled()
|
expect(createTitleSpy).toHaveBeenCalled()
|
||||||
expect(editTitleSpy).not.toHaveBeenCalled()
|
expect(editTitleSpy).not.toHaveBeenCalled()
|
||||||
component.dialogMode = EditDialogMode.EDIT
|
component.dialogMode.set(EditDialogMode.EDIT)
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
expect(editTitleSpy).toHaveBeenCalled()
|
expect(editTitleSpy).toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should support test mail account and show appropriate expiring alert', fakeAsync(() => {
|
it('should support test mail account and show appropriate expiring alert', () => {
|
||||||
|
jest.useFakeTimers()
|
||||||
component.object = {
|
component.object = {
|
||||||
name: 'example',
|
name: 'example',
|
||||||
imap_server: 'imap.example.com',
|
imap_server: 'imap.example.com',
|
||||||
@@ -97,7 +93,7 @@ describe('MailAccountEditDialogComponent', () => {
|
|||||||
expect(fixture.nativeElement.textContent).toContain(
|
expect(fixture.nativeElement.textContent).toContain(
|
||||||
'Successfully connected'
|
'Successfully connected'
|
||||||
)
|
)
|
||||||
tick(6000)
|
jest.advanceTimersByTime(6000)
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
expect(fixture.nativeElement.textContent).not.toContain(
|
expect(fixture.nativeElement.textContent).not.toContain(
|
||||||
'Successfully connected'
|
'Successfully connected'
|
||||||
@@ -118,6 +114,7 @@ describe('MailAccountEditDialogComponent', () => {
|
|||||||
.flush({}, { status: 500, statusText: 'error' })
|
.flush({}, { status: 500, statusText: 'error' })
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
expect(fixture.nativeElement.textContent).toContain('Unable to connect')
|
expect(fixture.nativeElement.textContent).toContain('Unable to connect')
|
||||||
tick(6000)
|
jest.advanceTimersByTime(6000)
|
||||||
}))
|
jest.useRealTimers()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
+6
-6
@@ -1,4 +1,4 @@
|
|||||||
import { Component, ViewChild, inject } from '@angular/core'
|
import { Component, ViewChild, inject, signal } 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
|
||||||
testResult: string
|
readonly testResult = signal<string>(undefined)
|
||||||
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 = null
|
this.testResult.set(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 = result.success ? 'success' : 'danger'
|
this.testResult.set(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 = 'danger'
|
this.testResult.set('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`
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-3
@@ -13,7 +13,7 @@
|
|||||||
<pngx-input-text [horizontal]="true" i18n-title title="Name" formControlName="name" [error]="error?.name" autocomplete="off"></pngx-input-text>
|
<pngx-input-text [horizontal]="true" i18n-title title="Name" formControlName="name" [error]="error?.name" autocomplete="off"></pngx-input-text>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-4">
|
<div class="col-md-4">
|
||||||
<pngx-input-select [horizontal]="true" i18n-title title="Account" [items]="accounts" formControlName="account"></pngx-input-select>
|
<pngx-input-select [horizontal]="true" i18n-title title="Account" [items]="accounts()" formControlName="account"></pngx-input-select>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-2 pt-2">
|
<div class="col-md-2 pt-2">
|
||||||
<pngx-input-switch [horizontal]="true" i18n-title title="Enabled" formControlName="enabled"></pngx-input-switch>
|
<pngx-input-switch [horizontal]="true" i18n-title title="Enabled" formControlName="enabled"></pngx-input-switch>
|
||||||
@@ -65,10 +65,10 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<pngx-input-tags [horizontal]="true" [allowCreate]="false" formControlName="assign_tags"></pngx-input-tags>
|
<pngx-input-tags [horizontal]="true" [allowCreate]="false" formControlName="assign_tags"></pngx-input-tags>
|
||||||
<pngx-input-select [horizontal]="true" i18n-title title="Assign document type" [items]="documentTypes" [allowNull]="true" formControlName="assign_document_type"></pngx-input-select>
|
<pngx-input-select [horizontal]="true" i18n-title title="Assign document type" [items]="documentTypes()" [allowNull]="true" formControlName="assign_document_type"></pngx-input-select>
|
||||||
<pngx-input-select [horizontal]="true" i18n-title title="Assign correspondent from" [items]="metadataCorrespondentOptions" formControlName="assign_correspondent_from"></pngx-input-select>
|
<pngx-input-select [horizontal]="true" i18n-title title="Assign correspondent from" [items]="metadataCorrespondentOptions" formControlName="assign_correspondent_from"></pngx-input-select>
|
||||||
@if (showCorrespondentField) {
|
@if (showCorrespondentField) {
|
||||||
<pngx-input-select [horizontal]="true" i18n-title title="Assign correspondent" [items]="correspondents" [allowNull]="true" formControlName="assign_correspondent"></pngx-input-select>
|
<pngx-input-select [horizontal]="true" i18n-title title="Assign correspondent" [items]="correspondents()" [allowNull]="true" formControlName="assign_correspondent"></pngx-input-select>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+3
-3
@@ -75,20 +75,20 @@ describe('MailRuleEditDialogComponent', () => {
|
|||||||
|
|
||||||
fixture = TestBed.createComponent(MailRuleEditDialogComponent)
|
fixture = TestBed.createComponent(MailRuleEditDialogComponent)
|
||||||
settingsService = TestBed.inject(SettingsService)
|
settingsService = TestBed.inject(SettingsService)
|
||||||
settingsService.currentUser = { id: 99, username: 'user99' }
|
settingsService.currentUser.set({ id: 99, username: 'user99' })
|
||||||
component = fixture.componentInstance
|
component = fixture.componentInstance
|
||||||
|
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should support create and edit modes', () => {
|
it('should support create and edit modes', () => {
|
||||||
component.dialogMode = EditDialogMode.CREATE
|
component.dialogMode.set(EditDialogMode.CREATE)
|
||||||
const createTitleSpy = jest.spyOn(component, 'getCreateTitle')
|
const createTitleSpy = jest.spyOn(component, 'getCreateTitle')
|
||||||
const editTitleSpy = jest.spyOn(component, 'getEditTitle')
|
const editTitleSpy = jest.spyOn(component, 'getEditTitle')
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
expect(createTitleSpy).toHaveBeenCalled()
|
expect(createTitleSpy).toHaveBeenCalled()
|
||||||
expect(editTitleSpy).not.toHaveBeenCalled()
|
expect(editTitleSpy).not.toHaveBeenCalled()
|
||||||
component.dialogMode = EditDialogMode.EDIT
|
component.dialogMode.set(EditDialogMode.EDIT)
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
expect(editTitleSpy).toHaveBeenCalled()
|
expect(editTitleSpy).toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|||||||
+17
-25
@@ -1,11 +1,12 @@
|
|||||||
import { Component, inject } from '@angular/core'
|
import { Component, inject } from '@angular/core'
|
||||||
|
import { toSignal } from '@angular/core/rxjs-interop'
|
||||||
import {
|
import {
|
||||||
FormControl,
|
FormControl,
|
||||||
FormGroup,
|
FormGroup,
|
||||||
FormsModule,
|
FormsModule,
|
||||||
ReactiveFormsModule,
|
ReactiveFormsModule,
|
||||||
} from '@angular/forms'
|
} from '@angular/forms'
|
||||||
import { first } from 'rxjs'
|
import { map } from 'rxjs'
|
||||||
import { EditDialogComponent } from 'src/app/components/common/edit-dialog/edit-dialog.component'
|
import { EditDialogComponent } from 'src/app/components/common/edit-dialog/edit-dialog.component'
|
||||||
import { Correspondent } from 'src/app/data/correspondent'
|
import { Correspondent } from 'src/app/data/correspondent'
|
||||||
import { DocumentType } from 'src/app/data/document-type'
|
import { DocumentType } from 'src/app/data/document-type'
|
||||||
@@ -154,37 +155,28 @@ const METADATA_CORRESPONDENT_OPTIONS = [
|
|||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class MailRuleEditDialogComponent extends EditDialogComponent<MailRule> {
|
export class MailRuleEditDialogComponent extends EditDialogComponent<MailRule> {
|
||||||
private accountService: MailAccountService
|
private readonly accountService = inject(MailAccountService)
|
||||||
private correspondentService: CorrespondentService
|
private readonly correspondentService = inject(CorrespondentService)
|
||||||
private documentTypeService: DocumentTypeService
|
private readonly documentTypeService = inject(DocumentTypeService)
|
||||||
|
|
||||||
accounts: MailAccount[]
|
readonly accounts = toSignal(
|
||||||
correspondents: Correspondent[]
|
this.accountService.listAll().pipe(map((result) => result.results)),
|
||||||
documentTypes: DocumentType[]
|
{ initialValue: undefined as MailAccount[] }
|
||||||
|
)
|
||||||
|
readonly correspondents = toSignal(
|
||||||
|
this.correspondentService.listAll().pipe(map((result) => result.results)),
|
||||||
|
{ initialValue: undefined as Correspondent[] }
|
||||||
|
)
|
||||||
|
readonly documentTypes = toSignal(
|
||||||
|
this.documentTypeService.listAll().pipe(map((result) => result.results)),
|
||||||
|
{ initialValue: undefined as DocumentType[] }
|
||||||
|
)
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super()
|
super()
|
||||||
this.service = inject(MailRuleService)
|
this.service = inject(MailRuleService)
|
||||||
this.accountService = inject(MailAccountService)
|
|
||||||
this.correspondentService = inject(CorrespondentService)
|
|
||||||
this.documentTypeService = inject(DocumentTypeService)
|
|
||||||
this.userService = inject(UserService)
|
this.userService = inject(UserService)
|
||||||
this.settingsService = inject(SettingsService)
|
this.settingsService = inject(SettingsService)
|
||||||
|
|
||||||
this.accountService
|
|
||||||
.listAll()
|
|
||||||
.pipe(first())
|
|
||||||
.subscribe((result) => (this.accounts = result.results))
|
|
||||||
|
|
||||||
this.correspondentService
|
|
||||||
.listAll()
|
|
||||||
.pipe(first())
|
|
||||||
.subscribe((result) => (this.correspondents = result.results))
|
|
||||||
|
|
||||||
this.documentTypeService
|
|
||||||
.listAll()
|
|
||||||
.pipe(first())
|
|
||||||
.subscribe((result) => (this.documentTypes = result.results))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
getCreateTitle() {
|
getCreateTitle() {
|
||||||
|
|||||||
+5
-5
@@ -22,11 +22,11 @@
|
|||||||
<ng-template>
|
<ng-template>
|
||||||
<div class="card mb-2">
|
<div class="card mb-2">
|
||||||
<div class="card-body p-2">
|
<div class="card-body p-2">
|
||||||
@if (testLoading) {
|
@if (testLoading()) {
|
||||||
<ng-container [ngTemplateOutlet]="loadingTemplate"></ng-container>
|
<ng-container [ngTemplateOutlet]="loadingTemplate"></ng-container>
|
||||||
} @else if (testResult) {
|
} @else if (testResult()) {
|
||||||
<code>{{testResult}}</code>
|
<code>{{testResult()}}</code>
|
||||||
} @else if (testFailed) {
|
} @else if (testFailed()) {
|
||||||
<div class="text-danger" i18n>Path test failed</div>
|
<div class="text-danger" i18n>Path test failed</div>
|
||||||
} @else {
|
} @else {
|
||||||
<div class="text-muted small" i18n>No document selected</div>
|
<div class="text-muted small" i18n>No document selected</div>
|
||||||
@@ -42,7 +42,7 @@
|
|||||||
[compareWith]="compareDocuments"
|
[compareWith]="compareDocuments"
|
||||||
[trackByFn]="trackByFn"
|
[trackByFn]="trackByFn"
|
||||||
[minTermLength]="2"
|
[minTermLength]="2"
|
||||||
[loading]="loading"
|
[loading]="loading()"
|
||||||
[typeahead]="documentsInput$"
|
[typeahead]="documentsInput$"
|
||||||
(change)="testPath($event)">
|
(change)="testPath($event)">
|
||||||
<ng-template #loadingTemplate ng-loadingspinner-tmp>
|
<ng-template #loadingTemplate ng-loadingspinner-tmp>
|
||||||
|
|||||||
+8
-8
@@ -30,20 +30,20 @@ describe('StoragePathEditDialogComponent', () => {
|
|||||||
documentService = TestBed.inject(DocumentService)
|
documentService = TestBed.inject(DocumentService)
|
||||||
fixture = TestBed.createComponent(StoragePathEditDialogComponent)
|
fixture = TestBed.createComponent(StoragePathEditDialogComponent)
|
||||||
settingsService = TestBed.inject(SettingsService)
|
settingsService = TestBed.inject(SettingsService)
|
||||||
settingsService.currentUser = { id: 99, username: 'user99' }
|
settingsService.currentUser.set({ id: 99, username: 'user99' })
|
||||||
component = fixture.componentInstance
|
component = fixture.componentInstance
|
||||||
|
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should support create and edit modes', () => {
|
it('should support create and edit modes', () => {
|
||||||
component.dialogMode = EditDialogMode.CREATE
|
component.dialogMode.set(EditDialogMode.CREATE)
|
||||||
const createTitleSpy = jest.spyOn(component, 'getCreateTitle')
|
const createTitleSpy = jest.spyOn(component, 'getCreateTitle')
|
||||||
const editTitleSpy = jest.spyOn(component, 'getEditTitle')
|
const editTitleSpy = jest.spyOn(component, 'getEditTitle')
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
expect(createTitleSpy).toHaveBeenCalled()
|
expect(createTitleSpy).toHaveBeenCalled()
|
||||||
expect(editTitleSpy).not.toHaveBeenCalled()
|
expect(editTitleSpy).not.toHaveBeenCalled()
|
||||||
component.dialogMode = EditDialogMode.EDIT
|
component.dialogMode.set(EditDialogMode.EDIT)
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
expect(editTitleSpy).toHaveBeenCalled()
|
expect(editTitleSpy).toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
@@ -58,17 +58,17 @@ describe('StoragePathEditDialogComponent', () => {
|
|||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
component.testPath({ id: 1 })
|
component.testPath({ id: 1 })
|
||||||
expect(testSpy).toHaveBeenCalledWith('test/{{title}}', 1)
|
expect(testSpy).toHaveBeenCalledWith('test/{{title}}', 1)
|
||||||
expect(component.testResult).toBe('test/abc123')
|
expect(component.testResult()).toBe('test/abc123')
|
||||||
expect(component.testFailed).toBeFalsy()
|
expect(component.testFailed()).toBeFalsy()
|
||||||
|
|
||||||
// test failed
|
// test failed
|
||||||
testSpy.mockReturnValueOnce(of(''))
|
testSpy.mockReturnValueOnce(of(''))
|
||||||
component.testPath({ id: 1 })
|
component.testPath({ id: 1 })
|
||||||
expect(component.testResult).toBeNull()
|
expect(component.testResult()).toBeNull()
|
||||||
expect(component.testFailed).toBeTruthy()
|
expect(component.testFailed()).toBeTruthy()
|
||||||
|
|
||||||
component.testPath(null)
|
component.testPath(null)
|
||||||
expect(component.testResult).toBeNull()
|
expect(component.testResult()).toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should compare two documents by id', () => {
|
it('should compare two documents by id', () => {
|
||||||
|
|||||||
+14
-14
@@ -1,5 +1,5 @@
|
|||||||
import { AsyncPipe, NgTemplateOutlet } from '@angular/common'
|
import { AsyncPipe, NgTemplateOutlet } from '@angular/common'
|
||||||
import { Component, OnDestroy, inject } from '@angular/core'
|
import { Component, OnDestroy, inject, signal } from '@angular/core'
|
||||||
import {
|
import {
|
||||||
FormControl,
|
FormControl,
|
||||||
FormGroup,
|
FormGroup,
|
||||||
@@ -65,13 +65,13 @@ export class StoragePathEditDialogComponent
|
|||||||
public documentsInput$ = new Subject<string>()
|
public documentsInput$ = new Subject<string>()
|
||||||
public foundDocuments$: Observable<Document[]>
|
public foundDocuments$: Observable<Document[]>
|
||||||
private testDocument: Document
|
private testDocument: Document
|
||||||
public testResult: string
|
readonly testResult = signal<string>(undefined)
|
||||||
public testFailed: boolean = false
|
readonly testFailed = signal(false)
|
||||||
public loading = false
|
readonly testLoading = signal(false)
|
||||||
public testLoading = false
|
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super()
|
super()
|
||||||
|
this.loading.set(false)
|
||||||
this.service = inject(StoragePathService)
|
this.service = inject(StoragePathService)
|
||||||
this.userService = inject(UserService)
|
this.userService = inject(UserService)
|
||||||
this.settingsService = inject(SettingsService)
|
this.settingsService = inject(SettingsService)
|
||||||
@@ -99,22 +99,22 @@ export class StoragePathEditDialogComponent
|
|||||||
|
|
||||||
public testPath(document: Document) {
|
public testPath(document: Document) {
|
||||||
if (!document) {
|
if (!document) {
|
||||||
this.testResult = null
|
this.testResult.set(null)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
this.testDocument = document
|
this.testDocument = document
|
||||||
this.testLoading = true
|
this.testLoading.set(true)
|
||||||
;(this.service as StoragePathService)
|
;(this.service as StoragePathService)
|
||||||
.testPath(this.objectForm.get('path').value, document.id)
|
.testPath(this.objectForm.get('path').value, document.id)
|
||||||
.subscribe((result) => {
|
.subscribe((result) => {
|
||||||
if (result?.length) {
|
if (result?.length) {
|
||||||
this.testResult = result
|
this.testResult.set(result)
|
||||||
this.testFailed = false
|
this.testFailed.set(false)
|
||||||
} else {
|
} else {
|
||||||
this.testResult = null
|
this.testResult.set(null)
|
||||||
this.testFailed = true
|
this.testFailed.set(true)
|
||||||
}
|
}
|
||||||
this.testLoading = false
|
this.testLoading.set(false)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,7 +138,7 @@ export class StoragePathEditDialogComponent
|
|||||||
this.documentsInput$.pipe(
|
this.documentsInput$.pipe(
|
||||||
distinctUntilChanged(),
|
distinctUntilChanged(),
|
||||||
takeUntil(this.unsubscribeNotifier),
|
takeUntil(this.unsubscribeNotifier),
|
||||||
tap(() => (this.loading = true)),
|
tap(() => this.loading.set(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 = false))
|
tap(() => this.loading.set(false))
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user