mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-02-11 23:59:31 -06:00
Compare commits
1 Commits
feature-do
...
feature-re
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aa8991b4ca |
@@ -91,12 +91,12 @@ Additional tasks are available for common maintenance operations:
|
|||||||
|
|
||||||
## Committing from the Host Machine
|
## Committing from the Host Machine
|
||||||
|
|
||||||
The DevContainer automatically installs Git pre-commit hooks during setup. However, these hooks are configured for use inside the container.
|
The DevContainer automatically installs pre-commit hooks during setup. However, these hooks are configured for use inside the container.
|
||||||
|
|
||||||
If you want to commit changes from your host machine (outside the DevContainer), you need to set up prek on your host. This installs it as a standalone tool.
|
If you want to commit changes from your host machine (outside the DevContainer), you need to set up pre-commit on your host. This installs it as a standalone tool.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
uv tool install prek && prek install
|
uv tool install pre-commit && pre-commit install
|
||||||
```
|
```
|
||||||
|
|
||||||
After this, you can commit either from inside the DevContainer or from your host machine.
|
After this, you can commit either from inside the DevContainer or from your host machine.
|
||||||
|
|||||||
@@ -7,7 +7,7 @@
|
|||||||
"containerEnv": {
|
"containerEnv": {
|
||||||
"UV_CACHE_DIR": "/usr/src/paperless/paperless-ngx/.uv-cache"
|
"UV_CACHE_DIR": "/usr/src/paperless/paperless-ngx/.uv-cache"
|
||||||
},
|
},
|
||||||
"postCreateCommand": "/bin/bash -c 'rm -rf .venv/.* && uv sync --group dev && uv run prek install'",
|
"postCreateCommand": "/bin/bash -c 'rm -rf .venv/.* && uv sync --group dev && uv run pre-commit install'",
|
||||||
"customizations": {
|
"customizations": {
|
||||||
"vscode": {
|
"vscode": {
|
||||||
"extensions": [
|
"extensions": [
|
||||||
|
|||||||
@@ -116,9 +116,9 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"label": "Maintenance: Build Documentation",
|
"label": "Maintenance: Build Documentation",
|
||||||
"description": "Build the documentation with Zensical",
|
"description": "Build the documentation with MkDocs",
|
||||||
"type": "shell",
|
"type": "shell",
|
||||||
"command": "uv run zensical build && uv run zensical serve",
|
"command": "uv run mkdocs build --config-file mkdocs.yml && uv run mkdocs serve",
|
||||||
"group": "none",
|
"group": "none",
|
||||||
"presentation": {
|
"presentation": {
|
||||||
"echo": true,
|
"echo": true,
|
||||||
|
|||||||
@@ -28,4 +28,3 @@
|
|||||||
./resources
|
./resources
|
||||||
# Other stuff
|
# Other stuff
|
||||||
**/*.drawio.png
|
**/*.drawio.png
|
||||||
.mypy_baseline
|
|
||||||
|
|||||||
2
.github/PULL_REQUEST_TEMPLATE.md
vendored
2
.github/PULL_REQUEST_TEMPLATE.md
vendored
@@ -37,6 +37,6 @@ NOTE: PRs that do not address the following will not be merged, please do not sk
|
|||||||
- [ ] If applicable, I have included testing coverage for new code in this PR, for [backend](https://docs.paperless-ngx.com/development/#testing) and / or [front-end](https://docs.paperless-ngx.com/development/#testing-and-code-style) changes.
|
- [ ] If applicable, I have included testing coverage for new code in this PR, for [backend](https://docs.paperless-ngx.com/development/#testing) and / or [front-end](https://docs.paperless-ngx.com/development/#testing-and-code-style) changes.
|
||||||
- [ ] If applicable, I have tested my code for breaking changes & regressions on both mobile & desktop devices, using the latest version of major browsers.
|
- [ ] If applicable, I have tested my code for breaking changes & regressions on both mobile & desktop devices, using the latest version of major browsers.
|
||||||
- [ ] If applicable, I have checked that all tests pass, see [documentation](https://docs.paperless-ngx.com/development/#back-end-development).
|
- [ ] If applicable, I have checked that all tests pass, see [documentation](https://docs.paperless-ngx.com/development/#back-end-development).
|
||||||
- [ ] I have run all Git `pre-commit` hooks, see [documentation](https://docs.paperless-ngx.com/development/#code-formatting-with-pre-commit-hooks).
|
- [ ] I have run all `pre-commit` hooks, see [documentation](https://docs.paperless-ngx.com/development/#code-formatting-with-pre-commit-hooks).
|
||||||
- [ ] I have made corresponding changes to the documentation as needed.
|
- [ ] I have made corresponding changes to the documentation as needed.
|
||||||
- [ ] In the description of the PR above I have disclosed the use of AI tools in the coding of this PR.
|
- [ ] In the description of the PR above I have disclosed the use of AI tools in the coding of this PR.
|
||||||
|
|||||||
4
.github/dependabot.yml
vendored
4
.github/dependabot.yml
vendored
@@ -46,8 +46,8 @@ updates:
|
|||||||
patterns:
|
patterns:
|
||||||
- "*pytest*"
|
- "*pytest*"
|
||||||
- "ruff"
|
- "ruff"
|
||||||
- "zensical"
|
- "mkdocs-material"
|
||||||
- "prek*"
|
- "pre-commit*"
|
||||||
# Django & DRF Ecosystem
|
# Django & DRF Ecosystem
|
||||||
django-ecosystem:
|
django-ecosystem:
|
||||||
patterns:
|
patterns:
|
||||||
|
|||||||
51
.github/workflows/ci-backend.yml
vendored
51
.github/workflows/ci-backend.yml
vendored
@@ -23,7 +23,7 @@ concurrency:
|
|||||||
group: backend-${{ github.event.pull_request.number || github.ref }}
|
group: backend-${{ github.event.pull_request.number || github.ref }}
|
||||||
cancel-in-progress: true
|
cancel-in-progress: true
|
||||||
env:
|
env:
|
||||||
DEFAULT_UV_VERSION: "0.10.x"
|
DEFAULT_UV_VERSION: "0.9.x"
|
||||||
NLTK_DATA: "/usr/share/nltk_data"
|
NLTK_DATA: "/usr/share/nltk_data"
|
||||||
jobs:
|
jobs:
|
||||||
test:
|
test:
|
||||||
@@ -99,52 +99,3 @@ jobs:
|
|||||||
run: |
|
run: |
|
||||||
docker compose --file docker/compose/docker-compose.ci-test.yml logs
|
docker compose --file docker/compose/docker-compose.ci-test.yml logs
|
||||||
docker compose --file docker/compose/docker-compose.ci-test.yml down
|
docker compose --file docker/compose/docker-compose.ci-test.yml down
|
||||||
typing:
|
|
||||||
name: Check project typing
|
|
||||||
runs-on: ubuntu-24.04
|
|
||||||
env:
|
|
||||||
DEFAULT_PYTHON: "3.12"
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v6.0.1
|
|
||||||
- name: Set up Python
|
|
||||||
id: setup-python
|
|
||||||
uses: actions/setup-python@v6.2.0
|
|
||||||
with:
|
|
||||||
python-version: "${{ env.DEFAULT_PYTHON }}"
|
|
||||||
- name: Install uv
|
|
||||||
uses: astral-sh/setup-uv@v7.2.1
|
|
||||||
with:
|
|
||||||
version: ${{ env.DEFAULT_UV_VERSION }}
|
|
||||||
enable-cache: true
|
|
||||||
python-version: ${{ steps.setup-python.outputs.python-version }}
|
|
||||||
- name: Install Python dependencies
|
|
||||||
run: |
|
|
||||||
uv sync \
|
|
||||||
--python ${{ steps.setup-python.outputs.python-version }} \
|
|
||||||
--group testing \
|
|
||||||
--group typing \
|
|
||||||
--frozen
|
|
||||||
- name: List installed Python dependencies
|
|
||||||
run: |
|
|
||||||
uv pip list
|
|
||||||
- name: Check typing (pyrefly)
|
|
||||||
run: |
|
|
||||||
uv run pyrefly \
|
|
||||||
check \
|
|
||||||
src/
|
|
||||||
- name: Cache Mypy
|
|
||||||
uses: actions/cache@v5.0.3
|
|
||||||
with:
|
|
||||||
path: .mypy_cache
|
|
||||||
# Keyed by OS, Python version, and dependency hashes
|
|
||||||
key: ${{ runner.os }}-mypy-py${{ env.DEFAULT_PYTHON }}-${{ hashFiles('pyproject.toml', 'uv.lock') }}
|
|
||||||
restore-keys: |
|
|
||||||
${{ runner.os }}-mypy-py${{ env.DEFAULT_PYTHON }}-
|
|
||||||
${{ runner.os }}-mypy-
|
|
||||||
- name: Check typing (mypy)
|
|
||||||
run: |
|
|
||||||
uv run mypy \
|
|
||||||
--show-error-codes \
|
|
||||||
--warn-unused-configs \
|
|
||||||
src/ | uv run mypy-baseline filter
|
|
||||||
|
|||||||
8
.github/workflows/ci-docker.yml
vendored
8
.github/workflows/ci-docker.yml
vendored
@@ -106,7 +106,7 @@ jobs:
|
|||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3.12.0
|
uses: docker/setup-buildx-action@v3.12.0
|
||||||
- name: Login to GitHub Container Registry
|
- name: Login to GitHub Container Registry
|
||||||
uses: docker/login-action@v3.7.0
|
uses: docker/login-action@v3.6.0
|
||||||
with:
|
with:
|
||||||
registry: ${{ env.REGISTRY }}
|
registry: ${{ env.REGISTRY }}
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
@@ -180,20 +180,20 @@ jobs:
|
|||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@v3.12.0
|
uses: docker/setup-buildx-action@v3.12.0
|
||||||
- name: Login to GitHub Container Registry
|
- name: Login to GitHub Container Registry
|
||||||
uses: docker/login-action@v3.7.0
|
uses: docker/login-action@v3.6.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@v3.7.0
|
uses: docker/login-action@v3.6.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@v3.7.0
|
uses: docker/login-action@v3.6.0
|
||||||
with:
|
with:
|
||||||
registry: quay.io
|
registry: quay.io
|
||||||
username: ${{ secrets.QUAY_USERNAME }}
|
username: ${{ secrets.QUAY_USERNAME }}
|
||||||
|
|||||||
56
.github/workflows/ci-docs.yml
vendored
56
.github/workflows/ci-docs.yml
vendored
@@ -6,25 +6,17 @@ on:
|
|||||||
- dev
|
- dev
|
||||||
paths:
|
paths:
|
||||||
- 'docs/**'
|
- 'docs/**'
|
||||||
- 'zensical.toml'
|
- 'mkdocs.yml'
|
||||||
- 'pyproject.toml'
|
|
||||||
- 'uv.lock'
|
|
||||||
- '.github/workflows/ci-docs.yml'
|
- '.github/workflows/ci-docs.yml'
|
||||||
pull_request:
|
pull_request:
|
||||||
paths:
|
paths:
|
||||||
- 'docs/**'
|
- 'docs/**'
|
||||||
- 'zensical.toml'
|
- 'mkdocs.yml'
|
||||||
- 'pyproject.toml'
|
|
||||||
- 'uv.lock'
|
|
||||||
- '.github/workflows/ci-docs.yml'
|
- '.github/workflows/ci-docs.yml'
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
concurrency:
|
concurrency:
|
||||||
group: docs-${{ github.event.pull_request.number || github.ref }}
|
group: docs-${{ github.event.pull_request.number || github.ref }}
|
||||||
cancel-in-progress: true
|
cancel-in-progress: true
|
||||||
permissions:
|
|
||||||
contents: read
|
|
||||||
pages: write
|
|
||||||
id-token: write
|
|
||||||
env:
|
env:
|
||||||
DEFAULT_UV_VERSION: "0.9.x"
|
DEFAULT_UV_VERSION: "0.9.x"
|
||||||
DEFAULT_PYTHON_VERSION: "3.11"
|
DEFAULT_PYTHON_VERSION: "3.11"
|
||||||
@@ -33,7 +25,6 @@ jobs:
|
|||||||
name: Build Documentation
|
name: Build Documentation
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/configure-pages@v5
|
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v6
|
uses: actions/checkout@v6
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
@@ -56,23 +47,42 @@ jobs:
|
|||||||
--python ${{ steps.setup-python.outputs.python-version }} \
|
--python ${{ steps.setup-python.outputs.python-version }} \
|
||||||
--dev \
|
--dev \
|
||||||
--frozen \
|
--frozen \
|
||||||
zensical build --clean
|
mkdocs build --config-file ./mkdocs.yml
|
||||||
- name: Upload GitHub Pages artifact
|
- name: Upload artifact
|
||||||
uses: actions/upload-pages-artifact@v4
|
uses: actions/upload-artifact@v6
|
||||||
with:
|
with:
|
||||||
path: site
|
name: documentation
|
||||||
name: github-pages-${{ github.run_id }}-${{ github.run_attempt }}
|
path: site/
|
||||||
|
retention-days: 7
|
||||||
deploy:
|
deploy:
|
||||||
name: Deploy Documentation
|
name: Deploy Documentation
|
||||||
needs: build
|
needs: build
|
||||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
environment:
|
|
||||||
name: github-pages
|
|
||||||
url: ${{ steps.deployment.outputs.page_url }}
|
|
||||||
steps:
|
steps:
|
||||||
- name: Deploy GitHub Pages
|
- name: Checkout
|
||||||
uses: actions/deploy-pages@v4
|
uses: actions/checkout@v6
|
||||||
id: deployment
|
- name: Set up Python
|
||||||
|
id: setup-python
|
||||||
|
uses: actions/setup-python@v6
|
||||||
with:
|
with:
|
||||||
artifact_name: github-pages-${{ github.run_id }}-${{ github.run_attempt }}
|
python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
|
||||||
|
- name: Install uv
|
||||||
|
uses: astral-sh/setup-uv@v7
|
||||||
|
with:
|
||||||
|
version: ${{ env.DEFAULT_UV_VERSION }}
|
||||||
|
enable-cache: true
|
||||||
|
python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
|
||||||
|
- name: Install Python dependencies
|
||||||
|
run: |
|
||||||
|
uv sync --python ${{ steps.setup-python.outputs.python-version }} --dev --frozen
|
||||||
|
- name: Deploy documentation
|
||||||
|
run: |
|
||||||
|
echo "docs.paperless-ngx.com" > "${{ github.workspace }}/docs/CNAME"
|
||||||
|
git config --global user.name "${{ github.actor }}"
|
||||||
|
git config --global user.email "${{ github.actor }}@users.noreply.github.com"
|
||||||
|
uv run \
|
||||||
|
--python ${{ steps.setup-python.outputs.python-version }} \
|
||||||
|
--dev \
|
||||||
|
--frozen \
|
||||||
|
mkdocs gh-deploy --force --no-history
|
||||||
|
|||||||
2
.github/workflows/ci-frontend.yml
vendored
2
.github/workflows/ci-frontend.yml
vendored
@@ -121,7 +121,7 @@ jobs:
|
|||||||
name: "E2E Tests (${{ matrix.shard-index }}/${{ matrix.shard-count }})"
|
name: "E2E Tests (${{ matrix.shard-index }}/${{ matrix.shard-count }})"
|
||||||
needs: install-dependencies
|
needs: install-dependencies
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
container: mcr.microsoft.com/playwright:v1.58.2-noble
|
container: mcr.microsoft.com/playwright:v1.58.1-noble
|
||||||
env:
|
env:
|
||||||
PLAYWRIGHT_BROWSERS_PATH: /ms-playwright
|
PLAYWRIGHT_BROWSERS_PATH: /ms-playwright
|
||||||
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1
|
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1
|
||||||
|
|||||||
16
.github/workflows/ci-lint.yml
vendored
16
.github/workflows/ci-lint.yml
vendored
@@ -10,15 +10,15 @@ concurrency:
|
|||||||
group: lint-${{ github.event.pull_request.number || github.ref }}
|
group: lint-${{ github.event.pull_request.number || github.ref }}
|
||||||
cancel-in-progress: true
|
cancel-in-progress: true
|
||||||
jobs:
|
jobs:
|
||||||
lint:
|
pre-commit:
|
||||||
name: Linting via prek
|
name: Pre-commit Checks
|
||||||
runs-on: ubuntu-slim
|
runs-on: ubuntu-24.04
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v6.0.2
|
uses: actions/checkout@v6
|
||||||
- name: Install Python
|
- name: Install Python
|
||||||
uses: actions/setup-python@v6.2.0
|
uses: actions/setup-python@v6
|
||||||
with:
|
with:
|
||||||
python-version: "3.14"
|
python-version: "3.11"
|
||||||
- name: Run prek
|
- name: Run pre-commit
|
||||||
uses: j178/prek-action@v1.1.1
|
uses: pre-commit/action@v3.0.1
|
||||||
|
|||||||
6
.github/workflows/ci-release.yml
vendored
6
.github/workflows/ci-release.yml
vendored
@@ -16,7 +16,7 @@ jobs:
|
|||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
steps:
|
steps:
|
||||||
- name: Wait for Docker build
|
- name: Wait for Docker build
|
||||||
uses: lewagon/wait-on-check-action@v1.5.0
|
uses: lewagon/wait-on-check-action@v1.4.1
|
||||||
with:
|
with:
|
||||||
ref: ${{ github.sha }}
|
ref: ${{ github.sha }}
|
||||||
check-name: 'Build Docker Image'
|
check-name: 'Build Docker Image'
|
||||||
@@ -70,7 +70,7 @@ jobs:
|
|||||||
--python ${{ steps.setup-python.outputs.python-version }} \
|
--python ${{ steps.setup-python.outputs.python-version }} \
|
||||||
--dev \
|
--dev \
|
||||||
--frozen \
|
--frozen \
|
||||||
zensical build --clean
|
mkdocs build --config-file ./mkdocs.yml
|
||||||
# ---- Prepare Release ----
|
# ---- Prepare Release ----
|
||||||
- name: Generate requirements file
|
- name: Generate requirements file
|
||||||
run: |
|
run: |
|
||||||
@@ -211,7 +211,7 @@ jobs:
|
|||||||
uv run \
|
uv run \
|
||||||
--python ${{ steps.setup-python.outputs.python-version }} \
|
--python ${{ steps.setup-python.outputs.python-version }} \
|
||||||
--dev \
|
--dev \
|
||||||
prek run --files changelog.md || true
|
pre-commit run --files changelog.md || true
|
||||||
|
|
||||||
git config --global user.name "github-actions"
|
git config --global user.name "github-actions"
|
||||||
git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||||
|
|||||||
2
.gitignore
vendored
2
.gitignore
vendored
@@ -54,7 +54,7 @@ junit.xml
|
|||||||
# Django stuff:
|
# Django stuff:
|
||||||
*.log
|
*.log
|
||||||
|
|
||||||
# Zensical documentation
|
# MkDocs documentation
|
||||||
site/
|
site/
|
||||||
|
|
||||||
# PyBuilder
|
# PyBuilder
|
||||||
|
|||||||
2470
.mypy-baseline.txt
2470
.mypy-baseline.txt
File diff suppressed because it is too large
Load Diff
@@ -1,7 +1,6 @@
|
|||||||
# This file configures pre-commit hooks.
|
# This file configures pre-commit hooks.
|
||||||
# See https://pre-commit.com/ for general information
|
# See https://pre-commit.com/ for general information
|
||||||
# See https://pre-commit.com/hooks.html for a listing of possible hooks
|
# See https://pre-commit.com/hooks.html for a listing of possible hooks
|
||||||
# We actually run via https://github.com/j178/prek which is compatible
|
|
||||||
repos:
|
repos:
|
||||||
# General hooks
|
# General hooks
|
||||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||||
@@ -50,12 +49,12 @@ repos:
|
|||||||
- 'prettier-plugin-organize-imports@4.1.0'
|
- 'prettier-plugin-organize-imports@4.1.0'
|
||||||
# Python hooks
|
# Python hooks
|
||||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||||
rev: v0.15.0
|
rev: v0.14.14
|
||||||
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.12.1"
|
rev: "v2.11.1"
|
||||||
hooks:
|
hooks:
|
||||||
- id: pyproject-fmt
|
- id: pyproject-fmt
|
||||||
# Dockerfile hooks
|
# Dockerfile hooks
|
||||||
|
|||||||
17368
.pyrefly-baseline.json
17368
.pyrefly-baseline.json
File diff suppressed because one or more lines are too long
@@ -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.10.0-python3.12-trixie-slim AS s6-overlay-base
|
FROM ghcr.io/astral-sh/uv:0.9.26-python3.12-trixie-slim AS s6-overlay-base
|
||||||
|
|
||||||
WORKDIR /usr/src/s6
|
WORKDIR /usr/src/s6
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
# correct networking for the tests
|
# correct networking for the tests
|
||||||
services:
|
services:
|
||||||
gotenberg:
|
gotenberg:
|
||||||
image: docker.io/gotenberg/gotenberg:8.26
|
image: docker.io/gotenberg/gotenberg:8.25
|
||||||
hostname: gotenberg
|
hostname: gotenberg
|
||||||
container_name: gotenberg
|
container_name: gotenberg
|
||||||
network_mode: host
|
network_mode: host
|
||||||
@@ -35,7 +35,7 @@ services:
|
|||||||
- "3143:3143" # IMAP
|
- "3143:3143" # IMAP
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
nginx:
|
nginx:
|
||||||
image: docker.io/nginx:1.29.5-alpine
|
image: docker.io/nginx:1.29-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.26
|
image: docker.io/gotenberg/gotenberg:8.25
|
||||||
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.
|
||||||
|
|||||||
@@ -66,7 +66,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.26
|
image: docker.io/gotenberg/gotenberg:8.25
|
||||||
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.
|
||||||
|
|||||||
@@ -55,7 +55,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.26
|
image: docker.io/gotenberg/gotenberg:8.25
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
# The gotenberg chromium route is used to convert .eml files. We do not
|
# The gotenberg chromium route is used to convert .eml files. We do not
|
||||||
# want to allow external content like tracking pixels or even javascript.
|
# want to allow external content like tracking pixels or even javascript.
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
# REST API
|
# The REST API
|
||||||
|
|
||||||
Paperless-ngx now ships with a fully-documented REST API and a browsable
|
Paperless-ngx now ships with a fully-documented REST API and a browsable
|
||||||
web interface to explore it. The API browsable interface is available at
|
web interface to explore it. The API browsable interface is available at
|
||||||
|
|||||||
@@ -1,31 +1,13 @@
|
|||||||
:root > * {
|
:root > * {
|
||||||
--paperless-green: #17541f;
|
--md-primary-fg-color: #17541f;
|
||||||
--paperless-green-accent: #2b8a38;
|
--md-primary-fg-color--dark: #17541f;
|
||||||
--md-primary-fg-color: var(--paperless-green);
|
--md-primary-fg-color--light: #17541f;
|
||||||
--md-primary-fg-color--dark: var(--paperless-green);
|
--md-accent-fg-color: #2b8a38;
|
||||||
--md-primary-fg-color--light: var(--paperless-green-accent);
|
|
||||||
--md-accent-fg-color: var(--paperless-green-accent);
|
|
||||||
--md-typeset-a-color: #21652a;
|
--md-typeset-a-color: #21652a;
|
||||||
}
|
}
|
||||||
|
|
||||||
.md-header,
|
|
||||||
.md-tabs {
|
|
||||||
background-color: var(--paperless-green);
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
.md-tabs__link {
|
|
||||||
color: rgba(255, 255, 255, 0.82);
|
|
||||||
}
|
|
||||||
|
|
||||||
.md-tabs__link:hover,
|
|
||||||
.md-tabs__link--active {
|
|
||||||
color: #fff;
|
|
||||||
}
|
|
||||||
|
|
||||||
[data-md-color-scheme="slate"] {
|
[data-md-color-scheme="slate"] {
|
||||||
--md-hue: 222;
|
--md-hue: 222;
|
||||||
--md-default-bg-color: hsla(var(--md-hue), 15%, 10%, 1);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 768px) {
|
@media (min-width: 768px) {
|
||||||
@@ -87,8 +69,8 @@ h4 code {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Hide config vars from sidebar, toc and move the border on mobile case their hidden */
|
/* Hide config vars from sidebar, toc and move the border on mobile case their hidden */
|
||||||
.md-nav.md-nav--secondary .md-nav__item:has(> .md-nav__link[href*="PAPERLESS_"]),
|
.md-nav.md-nav--secondary .md-nav__item .md-nav__link[href*="PAPERLESS_"],
|
||||||
.md-nav.md-nav--secondary .md-nav__item:has(> .md-nav__link[href*="USERMAP_"]) {
|
.md-nav.md-nav--secondary .md-nav__item .md-nav__link[href*="USERMAP_"] {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -101,3 +83,18 @@ h4 code {
|
|||||||
border-top: .05rem solid var(--md-default-fg-color--lightest);
|
border-top: .05rem solid var(--md-default-fg-color--lightest);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Show search shortcut key */
|
||||||
|
[data-md-toggle="search"]:not(:checked) ~ .md-header .md-search__form::after {
|
||||||
|
position: absolute;
|
||||||
|
top: .3rem;
|
||||||
|
right: .3rem;
|
||||||
|
display: block;
|
||||||
|
padding: .1rem .4rem;
|
||||||
|
color: var(--md-default-fg-color--lighter);
|
||||||
|
font-weight: bold;
|
||||||
|
font-size: .8rem;
|
||||||
|
border: .05rem solid var(--md-default-fg-color--lighter);
|
||||||
|
border-radius: .1rem;
|
||||||
|
content: "/";
|
||||||
|
}
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ first-time setup.
|
|||||||
5. Install pre-commit hooks:
|
5. Install pre-commit hooks:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
$ uv run prek install
|
$ uv run pre-commit install
|
||||||
```
|
```
|
||||||
|
|
||||||
6. Apply migrations and create a superuser (also can be done via the web UI) for your development instance:
|
6. Apply migrations and create a superuser (also can be done via the web UI) for your development instance:
|
||||||
@@ -217,7 +217,7 @@ commit. See [above](#code-formatting-with-pre-commit-hooks) for installation ins
|
|||||||
command such as
|
command such as
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
$ git ls-files -- '*.ts' | xargs prek run prettier --files
|
$ git ls-files -- '*.ts' | xargs pre-commit run prettier --files
|
||||||
```
|
```
|
||||||
|
|
||||||
Front end testing uses Jest and Playwright. Unit tests and e2e tests,
|
Front end testing uses Jest and Playwright. Unit tests and e2e tests,
|
||||||
@@ -338,13 +338,13 @@ LANGUAGES = [
|
|||||||
|
|
||||||
## Building the documentation
|
## Building the documentation
|
||||||
|
|
||||||
The documentation is built using Zensical, see their [documentation](https://zensical.org/docs/).
|
The documentation is built using material-mkdocs, see their [documentation](https://squidfunk.github.io/mkdocs-material/reference/).
|
||||||
If you want to build the documentation locally, this is how you do it:
|
If you want to build the documentation locally, this is how you do it:
|
||||||
|
|
||||||
1. Build the documentation
|
1. Build the documentation
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
$ uv run zensical build
|
$ uv run mkdocs build --config-file mkdocs.yml
|
||||||
```
|
```
|
||||||
|
|
||||||
_alternatively..._
|
_alternatively..._
|
||||||
@@ -355,7 +355,7 @@ If you want to build the documentation locally, this is how you do it:
|
|||||||
something.
|
something.
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
$ uv run zensical serve
|
$ uv run mkdocs serve
|
||||||
```
|
```
|
||||||
|
|
||||||
## Building the Docker image
|
## Building the Docker image
|
||||||
@@ -481,147 +481,3 @@ To get started:
|
|||||||
|
|
||||||
5. The project is ready for debugging, start either run the fullstack debug or individual debug
|
5. The project is ready for debugging, start either run the fullstack debug or individual debug
|
||||||
processes. Yo spin up the project without debugging run the task **Project Start: Run all Services**
|
processes. Yo spin up the project without debugging run the task **Project Start: Run all Services**
|
||||||
|
|
||||||
## Developing Date Parser Plugins
|
|
||||||
|
|
||||||
Paperless-ngx uses a plugin system for date parsing, allowing you to extend or replace the default date parsing behavior. Plugins are discovered using [Python entry points](https://setuptools.pypa.io/en/latest/userguide/entry_point.html).
|
|
||||||
|
|
||||||
### Creating a Date Parser Plugin
|
|
||||||
|
|
||||||
To create a custom date parser plugin, you need to:
|
|
||||||
|
|
||||||
1. Create a class that inherits from `DateParserPluginBase`
|
|
||||||
2. Implement the required abstract method
|
|
||||||
3. Register your plugin via an entry point
|
|
||||||
|
|
||||||
#### 1. Implementing the Parser Class
|
|
||||||
|
|
||||||
Your parser must extend `documents.plugins.date_parsing.DateParserPluginBase` and implement the `parse` method:
|
|
||||||
|
|
||||||
```python
|
|
||||||
from collections.abc import Iterator
|
|
||||||
import datetime
|
|
||||||
|
|
||||||
from documents.plugins.date_parsing import DateParserPluginBase
|
|
||||||
|
|
||||||
|
|
||||||
class MyDateParserPlugin(DateParserPluginBase):
|
|
||||||
"""
|
|
||||||
Custom date parser implementation.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def parse(self, filename: str, content: str) -> Iterator[datetime.datetime]:
|
|
||||||
"""
|
|
||||||
Parse dates from the document's filename and content.
|
|
||||||
|
|
||||||
Args:
|
|
||||||
filename: The original filename of the document
|
|
||||||
content: The extracted text content of the document
|
|
||||||
|
|
||||||
Yields:
|
|
||||||
datetime.datetime: Valid datetime objects found in the document
|
|
||||||
"""
|
|
||||||
# Your parsing logic here
|
|
||||||
# Use self.config to access configuration settings
|
|
||||||
|
|
||||||
# Example: parse dates from filename first
|
|
||||||
if self.config.filename_date_order:
|
|
||||||
# Your filename parsing logic
|
|
||||||
yield some_datetime
|
|
||||||
|
|
||||||
# Then parse dates from content
|
|
||||||
# Your content parsing logic
|
|
||||||
yield another_datetime
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 2. Configuration and Helper Methods
|
|
||||||
|
|
||||||
Your parser instance is initialized with a `DateParserConfig` object accessible via `self.config`. This provides:
|
|
||||||
|
|
||||||
- `languages: list[str]` - List of language codes for date parsing
|
|
||||||
- `timezone_str: str` - Timezone string for date localization
|
|
||||||
- `ignore_dates: set[datetime.date]` - Dates that should be filtered out
|
|
||||||
- `reference_time: datetime.datetime` - Current time for filtering future dates
|
|
||||||
- `filename_date_order: str | None` - Date order preference for filenames (e.g., "DMY", "MDY")
|
|
||||||
- `content_date_order: str` - Date order preference for content
|
|
||||||
|
|
||||||
The base class provides two helper methods you can use:
|
|
||||||
|
|
||||||
```python
|
|
||||||
def _parse_string(
|
|
||||||
self,
|
|
||||||
date_string: str,
|
|
||||||
date_order: str,
|
|
||||||
) -> datetime.datetime | None:
|
|
||||||
"""
|
|
||||||
Parse a single date string using dateparser with configured settings.
|
|
||||||
"""
|
|
||||||
|
|
||||||
def _filter_date(
|
|
||||||
self,
|
|
||||||
date: datetime.datetime | None,
|
|
||||||
) -> datetime.datetime | None:
|
|
||||||
"""
|
|
||||||
Validate a parsed datetime against configured rules.
|
|
||||||
Filters out dates before 1900, future dates, and ignored dates.
|
|
||||||
"""
|
|
||||||
```
|
|
||||||
|
|
||||||
#### 3. Resource Management (Optional)
|
|
||||||
|
|
||||||
If your plugin needs to acquire or release resources (database connections, API clients, etc.), override the context manager methods. Paperless-ngx will always use plugins as context managers, ensuring resources can be released even in the event of errors.
|
|
||||||
|
|
||||||
#### 4. Registering Your Plugin
|
|
||||||
|
|
||||||
Register your plugin using a setuptools entry point in your package's `pyproject.toml`:
|
|
||||||
|
|
||||||
```toml
|
|
||||||
[project.entry-points."paperless_ngx.date_parsers"]
|
|
||||||
my_parser = "my_package.parsers:MyDateParserPlugin"
|
|
||||||
```
|
|
||||||
|
|
||||||
The entry point name (e.g., `"my_parser"`) is used for sorting when multiple plugins are found. Paperless-ngx will use the first plugin alphabetically by name if multiple plugins are discovered.
|
|
||||||
|
|
||||||
### Plugin Discovery
|
|
||||||
|
|
||||||
Paperless-ngx automatically discovers and loads date parser plugins at runtime. The discovery process:
|
|
||||||
|
|
||||||
1. Queries the `paperless_ngx.date_parsers` entry point group
|
|
||||||
2. Validates that each plugin is a subclass of `DateParserPluginBase`
|
|
||||||
3. Sorts valid plugins alphabetically by entry point name
|
|
||||||
4. Uses the first valid plugin, or falls back to the default `RegexDateParserPlugin` if none are found
|
|
||||||
|
|
||||||
If multiple plugins are installed, a warning is logged indicating which plugin was selected.
|
|
||||||
|
|
||||||
### Example: Simple Date Parser
|
|
||||||
|
|
||||||
Here's a minimal example that only looks for ISO 8601 dates:
|
|
||||||
|
|
||||||
```python
|
|
||||||
import datetime
|
|
||||||
import re
|
|
||||||
from collections.abc import Iterator
|
|
||||||
|
|
||||||
from documents.plugins.date_parsing.base import DateParserPluginBase
|
|
||||||
|
|
||||||
|
|
||||||
class ISODateParserPlugin(DateParserPluginBase):
|
|
||||||
"""
|
|
||||||
Parser that only matches ISO 8601 formatted dates (YYYY-MM-DD).
|
|
||||||
"""
|
|
||||||
|
|
||||||
ISO_REGEX = re.compile(r"\b(\d{4}-\d{2}-\d{2})\b")
|
|
||||||
|
|
||||||
def parse(self, filename: str, content: str) -> Iterator[datetime.datetime]:
|
|
||||||
# Combine filename and content for searching
|
|
||||||
text = f"{filename} {content}"
|
|
||||||
|
|
||||||
for match in self.ISO_REGEX.finditer(text):
|
|
||||||
date_string = match.group(1)
|
|
||||||
# Use helper method to parse with configured timezone
|
|
||||||
date = self._parse_string(date_string, "YMD")
|
|
||||||
# Use helper method to validate the date
|
|
||||||
filtered_date = self._filter_date(date)
|
|
||||||
if filtered_date is not None:
|
|
||||||
yield filtered_date
|
|
||||||
```
|
|
||||||
|
|||||||
10
docs/faq.md
10
docs/faq.md
@@ -1,7 +1,3 @@
|
|||||||
---
|
|
||||||
title: FAQs
|
|
||||||
---
|
|
||||||
|
|
||||||
# Frequently Asked Questions
|
# Frequently Asked Questions
|
||||||
|
|
||||||
## _What's the general plan for Paperless-ngx?_
|
## _What's the general plan for Paperless-ngx?_
|
||||||
@@ -67,10 +63,8 @@ elsewhere. Here are a couple notes about that.
|
|||||||
Paperless also supports various Office documents (.docx, .doc, odt,
|
Paperless also supports various Office documents (.docx, .doc, odt,
|
||||||
.ppt, .pptx, .odp, .xls, .xlsx, .ods).
|
.ppt, .pptx, .odp, .xls, .xlsx, .ods).
|
||||||
|
|
||||||
Paperless-ngx determines the type of a file by inspecting its content
|
Paperless-ngx determines the type of a file by inspecting its content.
|
||||||
rather than its file extensions. However, files processed via the
|
The file extensions do not matter.
|
||||||
consumption directory will be rejected if they have a file extension that
|
|
||||||
not supported by any of the available parsers.
|
|
||||||
|
|
||||||
## _Will paperless-ngx run on Raspberry Pi?_
|
## _Will paperless-ngx run on Raspberry Pi?_
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,3 @@
|
|||||||
---
|
|
||||||
title: Home
|
|
||||||
---
|
|
||||||
|
|
||||||
<div class="grid-left" markdown>
|
<div class="grid-left" markdown>
|
||||||
{.index-logo}
|
{.index-logo}
|
||||||
{.index-logo}
|
{.index-logo}
|
||||||
|
|||||||
@@ -1,8 +1,4 @@
|
|||||||
---
|
## Installation
|
||||||
title: Setup
|
|
||||||
---
|
|
||||||
|
|
||||||
# Installation
|
|
||||||
|
|
||||||
You can go multiple routes to setup and run Paperless:
|
You can go multiple routes to setup and run Paperless:
|
||||||
|
|
||||||
|
|||||||
@@ -1,8 +1,4 @@
|
|||||||
---
|
# Usage Overview
|
||||||
title: Basic Usage
|
|
||||||
---
|
|
||||||
|
|
||||||
# Usage
|
|
||||||
|
|
||||||
Paperless-ngx is an application that manages your personal documents. With
|
Paperless-ngx is an application that manages your personal documents. With
|
||||||
the (optional) help of a document scanner (see [the scanners wiki](https://github.com/paperless-ngx/paperless-ngx/wiki/Scanner-&-Software-Recommendations)), Paperless-ngx transforms your unwieldy
|
the (optional) help of a document scanner (see [the scanners wiki](https://github.com/paperless-ngx/paperless-ngx/wiki/Scanner-&-Software-Recommendations)), Paperless-ngx transforms your unwieldy
|
||||||
@@ -566,8 +562,8 @@ you may want to adjust these settings to prevent abuse.
|
|||||||
|
|
||||||
#### Workflow placeholders
|
#### Workflow placeholders
|
||||||
|
|
||||||
Titles and webhook payloads can be generated by workflows using [Jinja templates](https://jinja.palletsprojects.com/en/3.1.x/templates/).
|
Titles can be assigned by workflows using [Jinja templates](https://jinja.palletsprojects.com/en/3.1.x/templates/).
|
||||||
This allows for complex logic to be used, including [logical structures](https://jinja.palletsprojects.com/en/3.1.x/templates/#list-of-control-structures)
|
This allows for complex logic to be used to generate the title, including [logical structures](https://jinja.palletsprojects.com/en/3.1.x/templates/#list-of-control-structures)
|
||||||
and [filters](https://jinja.palletsprojects.com/en/3.1.x/templates/#id11).
|
and [filters](https://jinja.palletsprojects.com/en/3.1.x/templates/#id11).
|
||||||
The template is provided as a string.
|
The template is provided as a string.
|
||||||
|
|
||||||
@@ -590,7 +586,7 @@ applied. You can use the following placeholders in the template with any trigger
|
|||||||
- `{{added_time}}`: added time in HH:MM format
|
- `{{added_time}}`: added time in HH:MM format
|
||||||
- `{{original_filename}}`: original file name without extension
|
- `{{original_filename}}`: original file name without extension
|
||||||
- `{{filename}}`: current file name without extension
|
- `{{filename}}`: current file name without extension
|
||||||
- `{{doc_title}}`: current document title (cannot be used in title assignment)
|
- `{{doc_title}}`: current document title
|
||||||
|
|
||||||
The following placeholders are only available for "added" or "updated" triggers
|
The following placeholders are only available for "added" or "updated" triggers
|
||||||
|
|
||||||
|
|||||||
87
mkdocs.yml
Normal file
87
mkdocs.yml
Normal file
@@ -0,0 +1,87 @@
|
|||||||
|
site_name: Paperless-ngx
|
||||||
|
theme:
|
||||||
|
name: material
|
||||||
|
logo: assets/logo.svg
|
||||||
|
font:
|
||||||
|
text: Roboto
|
||||||
|
code: Roboto Mono
|
||||||
|
palette:
|
||||||
|
# Palette toggle for automatic mode
|
||||||
|
- media: "(prefers-color-scheme)"
|
||||||
|
toggle:
|
||||||
|
icon: material/brightness-auto
|
||||||
|
name: Switch to light mode
|
||||||
|
# Palette toggle for light mode
|
||||||
|
- media: "(prefers-color-scheme: light)"
|
||||||
|
scheme: default
|
||||||
|
toggle:
|
||||||
|
icon: material/brightness-7
|
||||||
|
name: Switch to dark mode
|
||||||
|
# Palette toggle for dark mode
|
||||||
|
- media: "(prefers-color-scheme: dark)"
|
||||||
|
scheme: slate
|
||||||
|
toggle:
|
||||||
|
icon: material/brightness-4
|
||||||
|
name: Switch to system preference
|
||||||
|
features:
|
||||||
|
- navigation.tabs
|
||||||
|
- navigation.top
|
||||||
|
- toc.integrate
|
||||||
|
- content.code.annotate
|
||||||
|
icon:
|
||||||
|
repo: fontawesome/brands/github
|
||||||
|
favicon: assets/favicon.png
|
||||||
|
repo_url: https://github.com/paperless-ngx/paperless-ngx
|
||||||
|
repo_name: paperless-ngx/paperless-ngx
|
||||||
|
edit_uri: blob/main/docs/
|
||||||
|
extra_css:
|
||||||
|
- assets/extra.css
|
||||||
|
markdown_extensions:
|
||||||
|
- attr_list
|
||||||
|
- md_in_html
|
||||||
|
- def_list
|
||||||
|
- admonition
|
||||||
|
- tables
|
||||||
|
- pymdownx.highlight:
|
||||||
|
anchor_linenums: true
|
||||||
|
- pymdownx.superfences
|
||||||
|
- pymdownx.inlinehilite
|
||||||
|
- pymdownx.snippets
|
||||||
|
- pymdownx.tilde
|
||||||
|
- footnotes
|
||||||
|
- pymdownx.superfences:
|
||||||
|
custom_fences:
|
||||||
|
- name: mermaid
|
||||||
|
class: mermaid
|
||||||
|
format: !!python/name:pymdownx.superfences.fence_code_format
|
||||||
|
- pymdownx.emoji:
|
||||||
|
emoji_index: !!python/name:material.extensions.emoji.twemoji
|
||||||
|
emoji_generator: !!python/name:material.extensions.emoji.to_svg
|
||||||
|
strict: true
|
||||||
|
nav:
|
||||||
|
- index.md
|
||||||
|
- setup.md
|
||||||
|
- 'Basic Usage': usage.md
|
||||||
|
- configuration.md
|
||||||
|
- administration.md
|
||||||
|
- advanced_usage.md
|
||||||
|
- 'REST API': api.md
|
||||||
|
- development.md
|
||||||
|
- 'FAQs': faq.md
|
||||||
|
- troubleshooting.md
|
||||||
|
- 'Migration to v3': migration.md
|
||||||
|
- changelog.md
|
||||||
|
copyright: Copyright © 2016 - 2026 Daniel Quinn, Jonas Winkler, and the Paperless-ngx team
|
||||||
|
extra:
|
||||||
|
social:
|
||||||
|
- icon: fontawesome/brands/github
|
||||||
|
link: https://github.com/paperless-ngx/paperless-ngx
|
||||||
|
- icon: fontawesome/brands/docker
|
||||||
|
link: https://hub.docker.com/r/paperlessngx/paperless-ngx
|
||||||
|
- icon: material/chat
|
||||||
|
link: https://matrix.to/#/#paperless:matrix.org
|
||||||
|
plugins:
|
||||||
|
- search
|
||||||
|
- glightbox:
|
||||||
|
skip_classes:
|
||||||
|
- no-lightbox
|
||||||
@@ -27,9 +27,9 @@ dependencies = [
|
|||||||
# WARNING: django does not use semver.
|
# WARNING: django does not use semver.
|
||||||
# Only patch versions are guaranteed to not introduce breaking changes.
|
# Only patch versions are guaranteed to not introduce breaking changes.
|
||||||
"django~=5.2.10",
|
"django~=5.2.10",
|
||||||
"django-allauth[mfa,socialaccount]~=65.14.0",
|
"django-allauth[mfa,socialaccount]~=65.13.1",
|
||||||
"django-auditlog~=3.4.1",
|
"django-auditlog~=3.4.1",
|
||||||
"django-cachalot~=2.9.0",
|
"django-cachalot~=2.8.0",
|
||||||
"django-celery-results~=2.6.0",
|
"django-celery-results~=2.6.0",
|
||||||
"django-compression-middleware~=0.5.0",
|
"django-compression-middleware~=0.5.0",
|
||||||
"django-cors-headers~=4.9.0",
|
"django-cors-headers~=4.9.0",
|
||||||
@@ -42,7 +42,7 @@ dependencies = [
|
|||||||
"djangorestframework~=3.16",
|
"djangorestframework~=3.16",
|
||||||
"djangorestframework-guardian~=0.4.0",
|
"djangorestframework-guardian~=0.4.0",
|
||||||
"drf-spectacular~=0.28",
|
"drf-spectacular~=0.28",
|
||||||
"drf-spectacular-sidecar~=2026.1.1",
|
"drf-spectacular-sidecar~=2025.10.1",
|
||||||
"drf-writable-nested~=0.7.1",
|
"drf-writable-nested~=0.7.1",
|
||||||
"faiss-cpu>=1.10",
|
"faiss-cpu>=1.10",
|
||||||
"filelock~=3.20.0",
|
"filelock~=3.20.0",
|
||||||
@@ -76,7 +76,7 @@ dependencies = [
|
|||||||
"sentence-transformers>=4.1",
|
"sentence-transformers>=4.1",
|
||||||
"setproctitle~=1.3.4",
|
"setproctitle~=1.3.4",
|
||||||
"tika-client~=0.10.0",
|
"tika-client~=0.10.0",
|
||||||
"torch~=2.10.0",
|
"torch~=2.9.1",
|
||||||
"tqdm~=4.67.1",
|
"tqdm~=4.67.1",
|
||||||
"watchfiles>=1.1.1",
|
"watchfiles>=1.1.1",
|
||||||
"whitenoise~=6.11",
|
"whitenoise~=6.11",
|
||||||
@@ -94,7 +94,7 @@ optional-dependencies.postgres = [
|
|||||||
"psycopg-pool==3.3",
|
"psycopg-pool==3.3",
|
||||||
]
|
]
|
||||||
optional-dependencies.webserver = [
|
optional-dependencies.webserver = [
|
||||||
"granian[uvloop]~=2.7.0",
|
"granian[uvloop]~=2.6.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[dependency-groups]
|
[dependency-groups]
|
||||||
@@ -106,7 +106,8 @@ dev = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
docs = [
|
docs = [
|
||||||
"zensical>=0.0.21",
|
"mkdocs-glightbox~=0.5.1",
|
||||||
|
"mkdocs-material~=9.7.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
testing = [
|
testing = [
|
||||||
@@ -126,8 +127,9 @@ testing = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
lint = [
|
lint = [
|
||||||
"prek~=0.3.0",
|
"pre-commit~=4.5.1",
|
||||||
"ruff~=0.15.0",
|
"pre-commit-uv~=4.2.0",
|
||||||
|
"ruff~=0.14.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
typing = [
|
typing = [
|
||||||
@@ -136,12 +138,8 @@ typing = [
|
|||||||
"django-stubs[compatible-mypy]",
|
"django-stubs[compatible-mypy]",
|
||||||
"djangorestframework-stubs[compatible-mypy]",
|
"djangorestframework-stubs[compatible-mypy]",
|
||||||
"lxml-stubs",
|
"lxml-stubs",
|
||||||
"microsoft-python-type-stubs @ git+https://github.com/microsoft/python-type-stubs.git",
|
|
||||||
"mypy",
|
"mypy",
|
||||||
"mypy-baseline",
|
|
||||||
"pyrefly",
|
|
||||||
"types-bleach",
|
"types-bleach",
|
||||||
"types-channels",
|
|
||||||
"types-colorama",
|
"types-colorama",
|
||||||
"types-dateparser",
|
"types-dateparser",
|
||||||
"types-markdown",
|
"types-markdown",
|
||||||
@@ -161,11 +159,6 @@ environments = [
|
|||||||
"sys_platform == 'linux'",
|
"sys_platform == 'linux'",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[tool.uv.index]]
|
|
||||||
name = "pytorch-cpu"
|
|
||||||
url = "https://download.pytorch.org/whl/cpu"
|
|
||||||
explicit = true
|
|
||||||
|
|
||||||
[tool.uv.sources]
|
[tool.uv.sources]
|
||||||
# Markers are chosen to select these almost exclusively when building the Docker image
|
# Markers are chosen to select these almost exclusively when building the Docker image
|
||||||
psycopg-c = [
|
psycopg-c = [
|
||||||
@@ -181,6 +174,11 @@ torch = [
|
|||||||
{ index = "pytorch-cpu" },
|
{ index = "pytorch-cpu" },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[tool.uv.index]]
|
||||||
|
name = "pytorch-cpu"
|
||||||
|
url = "https://download.pytorch.org/whl/cpu"
|
||||||
|
explicit = true
|
||||||
|
|
||||||
[tool.ruff]
|
[tool.ruff]
|
||||||
target-version = "py310"
|
target-version = "py310"
|
||||||
line-length = 88
|
line-length = 88
|
||||||
@@ -308,20 +306,12 @@ markers = [
|
|||||||
"gotenberg: Tests requiring Gotenberg service",
|
"gotenberg: Tests requiring Gotenberg service",
|
||||||
"tika: Tests requiring Tika service",
|
"tika: Tests requiring Tika service",
|
||||||
"greenmail: Tests requiring Greenmail service",
|
"greenmail: Tests requiring Greenmail service",
|
||||||
"date_parsing: Tests which cover date parsing from content or filename",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
[tool.pytest_env]
|
[tool.pytest_env]
|
||||||
PAPERLESS_DISABLE_DBHANDLER = "true"
|
PAPERLESS_DISABLE_DBHANDLER = "true"
|
||||||
PAPERLESS_CACHE_BACKEND = "django.core.cache.backends.locmem.LocMemCache"
|
PAPERLESS_CACHE_BACKEND = "django.core.cache.backends.locmem.LocMemCache"
|
||||||
|
|
||||||
[tool.coverage.report]
|
|
||||||
exclude_also = [
|
|
||||||
"if settings.AUDIT_LOG_ENABLED:",
|
|
||||||
"if AUDIT_LOG_ENABLED:",
|
|
||||||
"if TYPE_CHECKING:",
|
|
||||||
]
|
|
||||||
|
|
||||||
[tool.coverage.run]
|
[tool.coverage.run]
|
||||||
source = [
|
source = [
|
||||||
"src/",
|
"src/",
|
||||||
@@ -333,6 +323,13 @@ omit = [
|
|||||||
"paperless/auth.py",
|
"paperless/auth.py",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[tool.coverage.report]
|
||||||
|
exclude_also = [
|
||||||
|
"if settings.AUDIT_LOG_ENABLED:",
|
||||||
|
"if AUDIT_LOG_ENABLED:",
|
||||||
|
"if TYPE_CHECKING:",
|
||||||
|
]
|
||||||
|
|
||||||
[tool.mypy]
|
[tool.mypy]
|
||||||
mypy_path = "src"
|
mypy_path = "src"
|
||||||
plugins = [
|
plugins = [
|
||||||
@@ -346,15 +343,5 @@ disallow_untyped_defs = true
|
|||||||
warn_redundant_casts = true
|
warn_redundant_casts = true
|
||||||
warn_unused_ignores = true
|
warn_unused_ignores = true
|
||||||
|
|
||||||
[tool.pyrefly]
|
|
||||||
search-path = [ "src" ]
|
|
||||||
baseline = ".pyrefly-baseline.json"
|
|
||||||
python-platform = "linux"
|
|
||||||
|
|
||||||
[tool.django-stubs]
|
[tool.django-stubs]
|
||||||
django_settings_module = "paperless.settings"
|
django_settings_module = "paperless.settings"
|
||||||
|
|
||||||
[tool.mypy-baseline]
|
|
||||||
baseline_path = ".mypy-baseline.txt"
|
|
||||||
sort_baseline = true
|
|
||||||
ignore_categories = [ "note" ]
|
|
||||||
|
|||||||
@@ -86,6 +86,7 @@
|
|||||||
],
|
],
|
||||||
"scripts": [],
|
"scripts": [],
|
||||||
"allowedCommonJsDependencies": [
|
"allowedCommonJsDependencies": [
|
||||||
|
"ng2-pdf-viewer",
|
||||||
"file-saver",
|
"file-saver",
|
||||||
"utif"
|
"utif"
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -52,11 +52,11 @@ test('dashboard saved view document links', async ({ page }) => {
|
|||||||
test('test slim sidebar', async ({ page }) => {
|
test('test slim sidebar', async ({ page }) => {
|
||||||
await page.routeFromHAR(REQUESTS_HAR1, { notFound: 'fallback' })
|
await page.routeFromHAR(REQUESTS_HAR1, { notFound: 'fallback' })
|
||||||
await page.goto('/dashboard')
|
await page.goto('/dashboard')
|
||||||
await page.locator('.sidebar-slim-toggler').click()
|
await page.locator('#sidebarMenu').getByRole('button').click()
|
||||||
await expect(
|
await expect(
|
||||||
page.getByRole('link', { name: 'Dashboard' }).getByText('Dashboard')
|
page.getByRole('link', { name: 'Dashboard' }).getByText('Dashboard')
|
||||||
).toBeHidden()
|
).toBeHidden()
|
||||||
await page.locator('.sidebar-slim-toggler').click()
|
await page.locator('#sidebarMenu').getByRole('button').click()
|
||||||
await expect(
|
await expect(
|
||||||
page.getByRole('link', { name: 'Dashboard' }).getByText('Dashboard')
|
page.getByRole('link', { name: 'Dashboard' }).getByText('Dashboard')
|
||||||
).toBeVisible()
|
).toBeVisible()
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ test('should show a mobile preview', async ({ page }) => {
|
|||||||
await page.setViewportSize({ width: 400, height: 1000 })
|
await page.setViewportSize({ width: 400, height: 1000 })
|
||||||
await expect(page.getByRole('tab', { name: 'Preview' })).toBeVisible()
|
await expect(page.getByRole('tab', { name: 'Preview' })).toBeVisible()
|
||||||
await page.getByRole('tab', { name: 'Preview' }).click()
|
await page.getByRole('tab', { name: 'Preview' }).click()
|
||||||
await page.waitForSelector('pngx-pdf-viewer')
|
await page.waitForSelector('pdf-viewer')
|
||||||
})
|
})
|
||||||
|
|
||||||
test('should show a list of notes', async ({ page }) => {
|
test('should show a list of notes', async ({ page }) => {
|
||||||
|
|||||||
@@ -33,9 +33,9 @@ test('should not allow user to view correspondents', async ({ page }) => {
|
|||||||
await page.routeFromHAR(REQUESTS_HAR, { notFound: 'fallback' })
|
await page.routeFromHAR(REQUESTS_HAR, { notFound: 'fallback' })
|
||||||
await page.goto('/dashboard')
|
await page.goto('/dashboard')
|
||||||
await expect(
|
await expect(
|
||||||
page.getByRole('link', { name: 'Attributes' })
|
page.getByRole('link', { name: 'Correspondents' })
|
||||||
).not.toBeAttached()
|
).not.toBeAttached()
|
||||||
await page.goto('/attributes/correspondents')
|
await page.goto('/correspondents')
|
||||||
await expect(page.locator('body')).toHaveText(
|
await expect(page.locator('body')).toHaveText(
|
||||||
/You don't have permissions to do that/i
|
/You don't have permissions to do that/i
|
||||||
)
|
)
|
||||||
@@ -44,10 +44,8 @@ test('should not allow user to view correspondents', async ({ page }) => {
|
|||||||
test('should not allow user to view tags', async ({ page }) => {
|
test('should not allow user to view tags', async ({ page }) => {
|
||||||
await page.routeFromHAR(REQUESTS_HAR, { notFound: 'fallback' })
|
await page.routeFromHAR(REQUESTS_HAR, { notFound: 'fallback' })
|
||||||
await page.goto('/dashboard')
|
await page.goto('/dashboard')
|
||||||
await expect(
|
await expect(page.getByRole('link', { name: 'Tags' })).not.toBeAttached()
|
||||||
page.getByRole('link', { name: 'Attributes' })
|
await page.goto('/tags')
|
||||||
).not.toBeAttached()
|
|
||||||
await page.goto('/attributes/tags')
|
|
||||||
await expect(page.locator('body')).toHaveText(
|
await expect(page.locator('body')).toHaveText(
|
||||||
/You don't have permissions to do that/i
|
/You don't have permissions to do that/i
|
||||||
)
|
)
|
||||||
@@ -57,9 +55,9 @@ test('should not allow user to view document types', async ({ page }) => {
|
|||||||
await page.routeFromHAR(REQUESTS_HAR, { notFound: 'fallback' })
|
await page.routeFromHAR(REQUESTS_HAR, { notFound: 'fallback' })
|
||||||
await page.goto('/dashboard')
|
await page.goto('/dashboard')
|
||||||
await expect(
|
await expect(
|
||||||
page.getByRole('link', { name: 'Attributes' })
|
page.getByRole('link', { name: 'Document Types' })
|
||||||
).not.toBeAttached()
|
).not.toBeAttached()
|
||||||
await page.goto('/attributes/documenttypes')
|
await page.goto('/documenttypes')
|
||||||
await expect(page.locator('body')).toHaveText(
|
await expect(page.locator('body')).toHaveText(
|
||||||
/You don't have permissions to do that/i
|
/You don't have permissions to do that/i
|
||||||
)
|
)
|
||||||
@@ -69,9 +67,9 @@ test('should not allow user to view storage paths', async ({ page }) => {
|
|||||||
await page.routeFromHAR(REQUESTS_HAR, { notFound: 'fallback' })
|
await page.routeFromHAR(REQUESTS_HAR, { notFound: 'fallback' })
|
||||||
await page.goto('/dashboard')
|
await page.goto('/dashboard')
|
||||||
await expect(
|
await expect(
|
||||||
page.getByRole('link', { name: 'Attributes' })
|
page.getByRole('link', { name: 'Storage Paths' })
|
||||||
).not.toBeAttached()
|
).not.toBeAttached()
|
||||||
await page.goto('/attributes/storagepaths')
|
await page.goto('/storagepaths')
|
||||||
await expect(page.locator('body')).toHaveText(
|
await expect(page.locator('body')).toHaveText(
|
||||||
/You don't have permissions to do that/i
|
/You don't have permissions to do that/i
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -31,10 +31,6 @@ module.exports = {
|
|||||||
moduleNameMapper: {
|
moduleNameMapper: {
|
||||||
...esmPreset.moduleNameMapper,
|
...esmPreset.moduleNameMapper,
|
||||||
'^src/(.*)': '<rootDir>/src/$1',
|
'^src/(.*)': '<rootDir>/src/$1',
|
||||||
'^pdfjs-dist/legacy/build/pdf\\.mjs$':
|
|
||||||
'<rootDir>/src/test/mocks/pdfjs-legacy-build-pdf.ts',
|
|
||||||
'^pdfjs-dist/web/pdf_viewer\\.mjs$':
|
|
||||||
'<rootDir>/src/test/mocks/pdfjs-web-pdf_viewer.ts',
|
|
||||||
},
|
},
|
||||||
workerIdleMemoryLimit: '512MB',
|
workerIdleMemoryLimit: '512MB',
|
||||||
reporters: [
|
reporters: [
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -11,15 +11,15 @@
|
|||||||
},
|
},
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@angular/cdk": "^21.1.3",
|
"@angular/cdk": "^21.1.2",
|
||||||
"@angular/common": "~21.1.3",
|
"@angular/common": "~21.1.2",
|
||||||
"@angular/compiler": "~21.1.3",
|
"@angular/compiler": "~21.1.2",
|
||||||
"@angular/core": "~21.1.3",
|
"@angular/core": "~21.1.2",
|
||||||
"@angular/forms": "~21.1.3",
|
"@angular/forms": "~21.1.2",
|
||||||
"@angular/localize": "~21.1.3",
|
"@angular/localize": "~21.1.2",
|
||||||
"@angular/platform-browser": "~21.1.3",
|
"@angular/platform-browser": "~21.1.2",
|
||||||
"@angular/platform-browser-dynamic": "~21.1.3",
|
"@angular/platform-browser-dynamic": "~21.1.2",
|
||||||
"@angular/router": "~21.1.3",
|
"@angular/router": "~21.1.2",
|
||||||
"@ng-bootstrap/ng-bootstrap": "^20.0.0",
|
"@ng-bootstrap/ng-bootstrap": "^20.0.0",
|
||||||
"@ng-select/ng-select": "^21.2.0",
|
"@ng-select/ng-select": "^21.2.0",
|
||||||
"@ngneat/dirty-check-forms": "^3.0.3",
|
"@ngneat/dirty-check-forms": "^3.0.3",
|
||||||
@@ -27,12 +27,12 @@
|
|||||||
"bootstrap": "^5.3.8",
|
"bootstrap": "^5.3.8",
|
||||||
"file-saver": "^2.0.5",
|
"file-saver": "^2.0.5",
|
||||||
"mime-names": "^1.0.0",
|
"mime-names": "^1.0.0",
|
||||||
|
"ng2-pdf-viewer": "^10.4.0",
|
||||||
"ngx-bootstrap-icons": "^1.9.3",
|
"ngx-bootstrap-icons": "^1.9.3",
|
||||||
"ngx-color": "^10.1.0",
|
"ngx-color": "^10.1.0",
|
||||||
"ngx-cookie-service": "^21.1.0",
|
"ngx-cookie-service": "^21.1.0",
|
||||||
"ngx-device-detector": "^11.0.0",
|
"ngx-device-detector": "^11.0.0",
|
||||||
"ngx-ui-tour-ng-bootstrap": "^18.0.0",
|
"ngx-ui-tour-ng-bootstrap": "^17.0.1",
|
||||||
"pdfjs-dist": "^5.4.624",
|
|
||||||
"rxjs": "^7.8.2",
|
"rxjs": "^7.8.2",
|
||||||
"tslib": "^2.8.1",
|
"tslib": "^2.8.1",
|
||||||
"utif": "^3.1.0",
|
"utif": "^3.1.0",
|
||||||
@@ -42,20 +42,20 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@angular-builders/custom-webpack": "^21.0.3",
|
"@angular-builders/custom-webpack": "^21.0.3",
|
||||||
"@angular-builders/jest": "^21.0.3",
|
"@angular-builders/jest": "^21.0.3",
|
||||||
"@angular-devkit/core": "^21.1.3",
|
"@angular-devkit/core": "^21.1.2",
|
||||||
"@angular-devkit/schematics": "^21.1.3",
|
"@angular-devkit/schematics": "^21.1.2",
|
||||||
"@angular-eslint/builder": "21.2.0",
|
"@angular-eslint/builder": "21.2.0",
|
||||||
"@angular-eslint/eslint-plugin": "21.2.0",
|
"@angular-eslint/eslint-plugin": "21.2.0",
|
||||||
"@angular-eslint/eslint-plugin-template": "21.2.0",
|
"@angular-eslint/eslint-plugin-template": "21.2.0",
|
||||||
"@angular-eslint/schematics": "21.2.0",
|
"@angular-eslint/schematics": "21.2.0",
|
||||||
"@angular-eslint/template-parser": "21.2.0",
|
"@angular-eslint/template-parser": "21.2.0",
|
||||||
"@angular/build": "^21.1.3",
|
"@angular/build": "^21.1.2",
|
||||||
"@angular/cli": "~21.1.3",
|
"@angular/cli": "~21.1.2",
|
||||||
"@angular/compiler-cli": "~21.1.3",
|
"@angular/compiler-cli": "~21.1.2",
|
||||||
"@codecov/webpack-plugin": "^1.9.1",
|
"@codecov/webpack-plugin": "^1.9.1",
|
||||||
"@playwright/test": "^1.58.2",
|
"@playwright/test": "^1.58.1",
|
||||||
"@types/jest": "^30.0.0",
|
"@types/jest": "^30.0.0",
|
||||||
"@types/node": "^25.2.1",
|
"@types/node": "^25.2.0",
|
||||||
"@typescript-eslint/eslint-plugin": "^8.54.0",
|
"@typescript-eslint/eslint-plugin": "^8.54.0",
|
||||||
"@typescript-eslint/parser": "^8.54.0",
|
"@typescript-eslint/parser": "^8.54.0",
|
||||||
"@typescript-eslint/utils": "^8.54.0",
|
"@typescript-eslint/utils": "^8.54.0",
|
||||||
@@ -68,7 +68,7 @@
|
|||||||
"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": "^5.9.3",
|
||||||
"webpack": "^5.105.0"
|
"webpack": "^5.103.0"
|
||||||
},
|
},
|
||||||
"packageManager": "pnpm@10.17.1",
|
"packageManager": "pnpm@10.17.1",
|
||||||
"pnpm": {
|
"pnpm": {
|
||||||
|
|||||||
1332
src-ui/pnpm-lock.yaml
generated
1332
src-ui/pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -100,10 +100,10 @@ const mock = () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Object.defineProperty(globalThis, 'open', { value: jest.fn() })
|
Object.defineProperty(window, 'open', { value: jest.fn() })
|
||||||
Object.defineProperty(globalThis, 'localStorage', { value: mock() })
|
Object.defineProperty(window, 'localStorage', { value: mock() })
|
||||||
Object.defineProperty(globalThis, 'sessionStorage', { value: mock() })
|
Object.defineProperty(window, 'sessionStorage', { value: mock() })
|
||||||
Object.defineProperty(globalThis, 'getComputedStyle', {
|
Object.defineProperty(window, 'getComputedStyle', {
|
||||||
value: () => ['-webkit-appearance'],
|
value: () => ['-webkit-appearance'],
|
||||||
})
|
})
|
||||||
Object.defineProperty(navigator, 'clipboard', {
|
Object.defineProperty(navigator, 'clipboard', {
|
||||||
@@ -115,33 +115,13 @@ Object.defineProperty(navigator, 'canShare', { value: () => true })
|
|||||||
if (!navigator.share) {
|
if (!navigator.share) {
|
||||||
Object.defineProperty(navigator, 'share', { value: jest.fn() })
|
Object.defineProperty(navigator, 'share', { value: jest.fn() })
|
||||||
}
|
}
|
||||||
if (!globalThis.URL.createObjectURL) {
|
if (!URL.createObjectURL) {
|
||||||
Object.defineProperty(globalThis.URL, 'createObjectURL', { value: jest.fn() })
|
Object.defineProperty(window.URL, 'createObjectURL', { value: jest.fn() })
|
||||||
}
|
}
|
||||||
if (!globalThis.URL.revokeObjectURL) {
|
if (!URL.revokeObjectURL) {
|
||||||
Object.defineProperty(globalThis.URL, 'revokeObjectURL', { value: jest.fn() })
|
Object.defineProperty(window.URL, 'revokeObjectURL', { value: jest.fn() })
|
||||||
}
|
}
|
||||||
class MockResizeObserver {
|
Object.defineProperty(window, 'ResizeObserver', { value: mock() })
|
||||||
private readonly callback: ResizeObserverCallback
|
|
||||||
|
|
||||||
constructor(callback: ResizeObserverCallback) {
|
|
||||||
this.callback = callback
|
|
||||||
}
|
|
||||||
|
|
||||||
observe = jest.fn()
|
|
||||||
unobserve = jest.fn()
|
|
||||||
disconnect = jest.fn()
|
|
||||||
|
|
||||||
trigger = (entries: ResizeObserverEntry[] = []) => {
|
|
||||||
this.callback(entries, this)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Object.defineProperty(globalThis, 'ResizeObserver', {
|
|
||||||
writable: true,
|
|
||||||
configurable: true,
|
|
||||||
value: MockResizeObserver,
|
|
||||||
})
|
|
||||||
|
|
||||||
if (typeof IntersectionObserver === 'undefined') {
|
if (typeof IntersectionObserver === 'undefined') {
|
||||||
class MockIntersectionObserver {
|
class MockIntersectionObserver {
|
||||||
@@ -156,7 +136,7 @@ if (typeof IntersectionObserver === 'undefined') {
|
|||||||
takeRecords = jest.fn()
|
takeRecords = jest.fn()
|
||||||
}
|
}
|
||||||
|
|
||||||
Object.defineProperty(globalThis, 'IntersectionObserver', {
|
Object.defineProperty(window, 'IntersectionObserver', {
|
||||||
writable: true,
|
writable: true,
|
||||||
configurable: true,
|
configurable: true,
|
||||||
value: MockIntersectionObserver,
|
value: MockIntersectionObserver,
|
||||||
|
|||||||
@@ -11,9 +11,13 @@ import { DashboardComponent } from './components/dashboard/dashboard.component'
|
|||||||
import { DocumentAsnComponent } from './components/document-asn/document-asn.component'
|
import { DocumentAsnComponent } from './components/document-asn/document-asn.component'
|
||||||
import { DocumentDetailComponent } from './components/document-detail/document-detail.component'
|
import { DocumentDetailComponent } from './components/document-detail/document-detail.component'
|
||||||
import { DocumentListComponent } from './components/document-list/document-list.component'
|
import { DocumentListComponent } from './components/document-list/document-list.component'
|
||||||
import { DocumentAttributesComponent } from './components/manage/document-attributes/document-attributes.component'
|
import { CorrespondentListComponent } from './components/manage/correspondent-list/correspondent-list.component'
|
||||||
|
import { CustomFieldsComponent } from './components/manage/custom-fields/custom-fields.component'
|
||||||
|
import { DocumentTypeListComponent } from './components/manage/document-type-list/document-type-list.component'
|
||||||
import { MailComponent } from './components/manage/mail/mail.component'
|
import { MailComponent } from './components/manage/mail/mail.component'
|
||||||
import { SavedViewsComponent } from './components/manage/saved-views/saved-views.component'
|
import { SavedViewsComponent } from './components/manage/saved-views/saved-views.component'
|
||||||
|
import { StoragePathListComponent } from './components/manage/storage-path-list/storage-path-list.component'
|
||||||
|
import { TagListComponent } from './components/manage/tag-list/tag-list.component'
|
||||||
import { WorkflowsComponent } from './components/manage/workflows/workflows.component'
|
import { WorkflowsComponent } from './components/manage/workflows/workflows.component'
|
||||||
import { NotFoundComponent } from './components/not-found/not-found.component'
|
import { NotFoundComponent } from './components/not-found/not-found.component'
|
||||||
import { DirtyDocGuard } from './guards/dirty-doc.guard'
|
import { DirtyDocGuard } from './guards/dirty-doc.guard'
|
||||||
@@ -101,77 +105,53 @@ export const routes: Routes = [
|
|||||||
componentName: 'DocumentAsnComponent',
|
componentName: 'DocumentAsnComponent',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: 'attributes',
|
|
||||||
component: DocumentAttributesComponent,
|
|
||||||
canActivate: [PermissionsGuard],
|
|
||||||
data: {
|
|
||||||
requiredPermissionAny: [
|
|
||||||
{ action: PermissionAction.View, type: PermissionType.Tag },
|
|
||||||
{
|
|
||||||
action: PermissionAction.View,
|
|
||||||
type: PermissionType.Correspondent,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
action: PermissionAction.View,
|
|
||||||
type: PermissionType.DocumentType,
|
|
||||||
},
|
|
||||||
{ action: PermissionAction.View, type: PermissionType.StoragePath },
|
|
||||||
{ action: PermissionAction.View, type: PermissionType.CustomField },
|
|
||||||
],
|
|
||||||
componentName: 'DocumentAttributesComponent',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: 'attributes/:section',
|
|
||||||
component: DocumentAttributesComponent,
|
|
||||||
canActivate: [PermissionsGuard],
|
|
||||||
data: {
|
|
||||||
requiredPermissionAny: [
|
|
||||||
{ action: PermissionAction.View, type: PermissionType.Tag },
|
|
||||||
{
|
|
||||||
action: PermissionAction.View,
|
|
||||||
type: PermissionType.Correspondent,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
action: PermissionAction.View,
|
|
||||||
type: PermissionType.DocumentType,
|
|
||||||
},
|
|
||||||
{ action: PermissionAction.View, type: PermissionType.StoragePath },
|
|
||||||
{ action: PermissionAction.View, type: PermissionType.CustomField },
|
|
||||||
],
|
|
||||||
componentName: 'DocumentAttributesComponent',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: 'documentproperties',
|
|
||||||
redirectTo: '/attributes',
|
|
||||||
pathMatch: 'full',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
path: 'documentproperties/:section',
|
|
||||||
redirectTo: '/attributes/:section',
|
|
||||||
pathMatch: 'full',
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
path: 'tags',
|
path: 'tags',
|
||||||
redirectTo: '/attributes/tags',
|
component: TagListComponent,
|
||||||
pathMatch: 'full',
|
canActivate: [PermissionsGuard],
|
||||||
|
data: {
|
||||||
|
requiredPermission: {
|
||||||
|
action: PermissionAction.View,
|
||||||
|
type: PermissionType.Tag,
|
||||||
|
},
|
||||||
|
componentName: 'TagListComponent',
|
||||||
},
|
},
|
||||||
{
|
|
||||||
path: 'correspondents',
|
|
||||||
redirectTo: '/attributes/correspondents',
|
|
||||||
pathMatch: 'full',
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'documenttypes',
|
path: 'documenttypes',
|
||||||
redirectTo: '/attributes/documenttypes',
|
component: DocumentTypeListComponent,
|
||||||
pathMatch: 'full',
|
canActivate: [PermissionsGuard],
|
||||||
|
data: {
|
||||||
|
requiredPermission: {
|
||||||
|
action: PermissionAction.View,
|
||||||
|
type: PermissionType.DocumentType,
|
||||||
|
},
|
||||||
|
componentName: 'DocumentTypeListComponent',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'correspondents',
|
||||||
|
component: CorrespondentListComponent,
|
||||||
|
canActivate: [PermissionsGuard],
|
||||||
|
data: {
|
||||||
|
requiredPermission: {
|
||||||
|
action: PermissionAction.View,
|
||||||
|
type: PermissionType.Correspondent,
|
||||||
|
},
|
||||||
|
componentName: 'CorrespondentListComponent',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'storagepaths',
|
path: 'storagepaths',
|
||||||
redirectTo: '/attributes/storagepaths',
|
component: StoragePathListComponent,
|
||||||
pathMatch: 'full',
|
canActivate: [PermissionsGuard],
|
||||||
|
data: {
|
||||||
|
requiredPermission: {
|
||||||
|
action: PermissionAction.View,
|
||||||
|
type: PermissionType.StoragePath,
|
||||||
|
},
|
||||||
|
componentName: 'StoragePathListComponent',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'logs',
|
path: 'logs',
|
||||||
@@ -259,8 +239,15 @@ export const routes: Routes = [
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'customfields',
|
path: 'customfields',
|
||||||
redirectTo: '/attributes/customfields',
|
component: CustomFieldsComponent,
|
||||||
pathMatch: 'full',
|
canActivate: [PermissionsGuard],
|
||||||
|
data: {
|
||||||
|
requiredPermission: {
|
||||||
|
action: PermissionAction.View,
|
||||||
|
type: PermissionType.CustomField,
|
||||||
|
},
|
||||||
|
componentName: 'CustomFieldsComponent',
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: 'workflows',
|
path: 'workflows',
|
||||||
|
|||||||
@@ -9,11 +9,7 @@ import {
|
|||||||
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'
|
||||||
import {
|
import { TourNgBootstrapModule, TourService } from 'ngx-ui-tour-ng-bootstrap'
|
||||||
provideUiTour,
|
|
||||||
TourNgBootstrap,
|
|
||||||
TourService,
|
|
||||||
} from 'ngx-ui-tour-ng-bootstrap'
|
|
||||||
import { Subject } from 'rxjs'
|
import { Subject } from 'rxjs'
|
||||||
import { routes } from './app-routing.module'
|
import { routes } from './app-routing.module'
|
||||||
import { AppComponent } from './app.component'
|
import { AppComponent } from './app.component'
|
||||||
@@ -44,12 +40,12 @@ describe('AppComponent', () => {
|
|||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
TestBed.configureTestingModule({
|
TestBed.configureTestingModule({
|
||||||
imports: [
|
imports: [
|
||||||
|
TourNgBootstrapModule,
|
||||||
RouterModule.forRoot(routes),
|
RouterModule.forRoot(routes),
|
||||||
NgbModalModule,
|
NgbModalModule,
|
||||||
AppComponent,
|
AppComponent,
|
||||||
ToastsComponent,
|
ToastsComponent,
|
||||||
FileDropComponent,
|
FileDropComponent,
|
||||||
TourNgBootstrap,
|
|
||||||
NgxBootstrapIconsModule.pick(allIcons),
|
NgxBootstrapIconsModule.pick(allIcons),
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
@@ -57,7 +53,6 @@ describe('AppComponent', () => {
|
|||||||
DirtySavedViewGuard,
|
DirtySavedViewGuard,
|
||||||
provideHttpClient(withInterceptorsFromDi()),
|
provideHttpClient(withInterceptorsFromDi()),
|
||||||
provideHttpClientTesting(),
|
provideHttpClientTesting(),
|
||||||
provideUiTour(),
|
|
||||||
],
|
],
|
||||||
}).compileComponents()
|
}).compileComponents()
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { Component, inject, OnDestroy, OnInit, Renderer2 } from '@angular/core'
|
import { Component, inject, OnDestroy, OnInit, Renderer2 } from '@angular/core'
|
||||||
import { Router, RouterOutlet } from '@angular/router'
|
import { Router, RouterOutlet } from '@angular/router'
|
||||||
import { TourNgBootstrap, TourService } from 'ngx-ui-tour-ng-bootstrap'
|
import { TourNgBootstrapModule, TourService } from 'ngx-ui-tour-ng-bootstrap'
|
||||||
import { first, Subscription } from 'rxjs'
|
import { first, Subscription } from 'rxjs'
|
||||||
import { ToastsComponent } from './components/common/toasts/toasts.component'
|
import { ToastsComponent } from './components/common/toasts/toasts.component'
|
||||||
import { FileDropComponent } from './components/file-drop/file-drop.component'
|
import { FileDropComponent } from './components/file-drop/file-drop.component'
|
||||||
@@ -21,7 +21,12 @@ import { WebsocketStatusService } from './services/websocket-status.service'
|
|||||||
selector: 'pngx-root',
|
selector: 'pngx-root',
|
||||||
templateUrl: './app.component.html',
|
templateUrl: './app.component.html',
|
||||||
styleUrls: ['./app.component.scss'],
|
styleUrls: ['./app.component.scss'],
|
||||||
imports: [FileDropComponent, ToastsComponent, TourNgBootstrap, RouterOutlet],
|
imports: [
|
||||||
|
FileDropComponent,
|
||||||
|
ToastsComponent,
|
||||||
|
TourNgBootstrapModule,
|
||||||
|
RouterOutlet,
|
||||||
|
],
|
||||||
})
|
})
|
||||||
export class AppComponent implements OnInit, OnDestroy {
|
export class AppComponent implements OnInit, OnDestroy {
|
||||||
private settings = inject(SettingsService)
|
private settings = inject(SettingsService)
|
||||||
@@ -162,7 +167,12 @@ export class AppComponent implements OnInit, OnDestroy {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
this.tourService.initialize([
|
const prevBtnTitle = $localize`Prev`
|
||||||
|
const nextBtnTitle = $localize`Next`
|
||||||
|
const endBtnTitle = $localize`End`
|
||||||
|
|
||||||
|
this.tourService.initialize(
|
||||||
|
[
|
||||||
{
|
{
|
||||||
anchorId: 'tour.dashboard',
|
anchorId: 'tour.dashboard',
|
||||||
content: $localize`The dashboard can be used to show saved views, such as an 'Inbox'. Views are found under Manage > Saved Views once you have created some.`,
|
content: $localize`The dashboard can be used to show saved views, such as an 'Inbox'. Views are found under Manage > Saved Views once you have created some.`,
|
||||||
@@ -195,8 +205,8 @@ export class AppComponent implements OnInit, OnDestroy {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
anchorId: 'tour.tags',
|
anchorId: 'tour.tags',
|
||||||
content: $localize`Attributes like tags, correspondents, document types, storage paths and custom fields can all be managed here. They can also be created from the document edit view.`,
|
content: $localize`Tags, correspondents, document types and storage paths can all be managed using these pages. They can also be created from the document edit view.`,
|
||||||
route: '/attributes/tags',
|
route: '/tags',
|
||||||
backdropConfig: {
|
backdropConfig: {
|
||||||
offset: 0,
|
offset: 0,
|
||||||
},
|
},
|
||||||
@@ -246,7 +256,19 @@ export class AppComponent implements OnInit, OnDestroy {
|
|||||||
offset: 0,
|
offset: 0,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
])
|
],
|
||||||
|
{
|
||||||
|
enableBackdrop: true,
|
||||||
|
backdropConfig: {
|
||||||
|
offset: 10,
|
||||||
|
},
|
||||||
|
prevBtnTitle,
|
||||||
|
nextBtnTitle,
|
||||||
|
endBtnTitle,
|
||||||
|
isOptional: true,
|
||||||
|
useLegacyTitle: true,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
this.tourService.start$.subscribe(() => {
|
this.tourService.start$.subscribe(() => {
|
||||||
this.renderer.addClass(document.body, 'tour-active')
|
this.renderer.addClass(document.body, 'tour-active')
|
||||||
|
|||||||
@@ -222,8 +222,8 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="col">
|
<div class="col">
|
||||||
<select class="form-select" formControlName="pdfViewerDefaultZoom">
|
<select class="form-select" formControlName="pdfViewerDefaultZoom">
|
||||||
<option [ngValue]="PdfZoomScale.PageWidth" i18n>Fit width</option>
|
<option [ngValue]="ZoomSetting.PageWidth" i18n>Fit width</option>
|
||||||
<option [ngValue]="PdfZoomScale.PageFit" i18n>Fit page</option>
|
<option [ngValue]="ZoomSetting.PageFit" i18n>Fit page</option>
|
||||||
</select>
|
</select>
|
||||||
<p class="small text-muted mt-1" i18n>Only applies to the Paperless-ngx PDF viewer.</p>
|
<p class="small text-muted mt-1" i18n>Only applies to the Paperless-ngx PDF viewer.</p>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ import {
|
|||||||
} from '@ng-bootstrap/ng-bootstrap'
|
} from '@ng-bootstrap/ng-bootstrap'
|
||||||
import { NgSelectModule } from '@ng-select/ng-select'
|
import { NgSelectModule } from '@ng-select/ng-select'
|
||||||
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
||||||
import { provideUiTour } from 'ngx-ui-tour-ng-bootstrap'
|
|
||||||
import { of, throwError } from 'rxjs'
|
import { of, throwError } from 'rxjs'
|
||||||
import { routes } from 'src/app/app-routing.module'
|
import { routes } from 'src/app/app-routing.module'
|
||||||
import {
|
import {
|
||||||
@@ -148,7 +147,6 @@ describe('SettingsComponent', () => {
|
|||||||
PermissionsGuard,
|
PermissionsGuard,
|
||||||
provideHttpClient(withInterceptorsFromDi()),
|
provideHttpClient(withInterceptorsFromDi()),
|
||||||
provideHttpClientTesting(),
|
provideHttpClientTesting(),
|
||||||
provideUiTour(),
|
|
||||||
],
|
],
|
||||||
}).compileComponents()
|
}).compileComponents()
|
||||||
|
|
||||||
|
|||||||
@@ -65,8 +65,8 @@ import { PermissionsUserComponent } from '../../common/input/permissions/permiss
|
|||||||
import { SelectComponent } from '../../common/input/select/select.component'
|
import { SelectComponent } from '../../common/input/select/select.component'
|
||||||
import { PageHeaderComponent } from '../../common/page-header/page-header.component'
|
import { PageHeaderComponent } from '../../common/page-header/page-header.component'
|
||||||
import { PdfEditorEditMode } from '../../common/pdf-editor/pdf-editor-edit-mode'
|
import { PdfEditorEditMode } from '../../common/pdf-editor/pdf-editor-edit-mode'
|
||||||
import { PdfZoomScale } from '../../common/pdf-viewer/pdf-viewer.types'
|
|
||||||
import { SystemStatusDialogComponent } from '../../common/system-status-dialog/system-status-dialog.component'
|
import { SystemStatusDialogComponent } from '../../common/system-status-dialog/system-status-dialog.component'
|
||||||
|
import { ZoomSetting } from '../../document-detail/zoom-setting'
|
||||||
import { ComponentWithPermissions } from '../../with-permissions/with-permissions.component'
|
import { ComponentWithPermissions } from '../../with-permissions/with-permissions.component'
|
||||||
|
|
||||||
enum SettingsNavIDs {
|
enum SettingsNavIDs {
|
||||||
@@ -196,7 +196,7 @@ export class SettingsComponent
|
|||||||
|
|
||||||
public readonly GlobalSearchType = GlobalSearchType
|
public readonly GlobalSearchType = GlobalSearchType
|
||||||
|
|
||||||
public readonly PdfZoomScale = PdfZoomScale
|
public readonly ZoomSetting = ZoomSetting
|
||||||
|
|
||||||
public readonly PdfEditorEditMode = PdfEditorEditMode
|
public readonly PdfEditorEditMode = PdfEditorEditMode
|
||||||
|
|
||||||
|
|||||||
@@ -175,60 +175,44 @@
|
|||||||
<span i18n>Manage</span>
|
<span i18n>Manage</span>
|
||||||
</h6>
|
</h6>
|
||||||
<ul class="nav flex-column mb-2">
|
<ul class="nav flex-column mb-2">
|
||||||
@if (canManageAttributes) {
|
<li class="nav-item app-link"
|
||||||
<li class="nav-item app-link" tourAnchor="tour.tags">
|
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Correspondent }">
|
||||||
<div class="d-flex align-items-center attributes-row">
|
<a class="nav-link" routerLink="correspondents" routerLinkActive="active" (click)="closeMenu()"
|
||||||
<a class="nav-link flex-fill" routerLink="attributes" routerLinkActive="active" (click)="closeMenu()"
|
ngbPopover="Correspondents" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
|
||||||
ngbPopover="Attributes" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
|
|
||||||
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||||
<i-bs class="me-1" name="stack"></i-bs><span> <ng-container i18n>Attributes</ng-container></span>
|
|
||||||
</a>
|
|
||||||
@if (!slimSidebarEnabled) {
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="btn btn-link btn-sm text-muted p-0 me-3 attributes-expand-btn"
|
|
||||||
(click)="toggleAttributesSections($event)"
|
|
||||||
[attr.aria-label]="attributesSectionsCollapsed ? 'Expand attributes sections' : 'Collapse attributes sections'"
|
|
||||||
i18n-aria-label
|
|
||||||
>
|
|
||||||
<i-bs [name]="attributesSectionsCollapsed ? 'plus-circle' : 'dash-circle'"></i-bs>
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
class="attributes-submenu ms-2"
|
|
||||||
[ngbCollapse]="slimSidebarEnabled || attributesSectionsCollapsed"
|
|
||||||
>
|
|
||||||
<ul class="nav flex-column">
|
|
||||||
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Tag }">
|
|
||||||
<a class="nav-link py-1" routerLink="attributes/tags" routerLinkActive="active" (click)="closeMenu()">
|
|
||||||
<i-bs class="me-1" name="tags"></i-bs><span> <ng-container i18n>Tags</ng-container></span>
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Correspondent }">
|
|
||||||
<a class="nav-link py-1" routerLink="attributes/correspondents" routerLinkActive="active" (click)="closeMenu()">
|
|
||||||
<i-bs class="me-1" name="person"></i-bs><span> <ng-container i18n>Correspondents</ng-container></span>
|
<i-bs class="me-1" name="person"></i-bs><span> <ng-container i18n>Correspondents</ng-container></span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.DocumentType }">
|
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Tag }"
|
||||||
<a class="nav-link py-1" routerLink="attributes/documenttypes" routerLinkActive="active" (click)="closeMenu()">
|
tourAnchor="tour.tags">
|
||||||
<i-bs class="me-1" name="hash"></i-bs><span> <ng-container i18n>Document types</ng-container></span>
|
<a class="nav-link" routerLink="tags" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Tags"
|
||||||
|
i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" container="body"
|
||||||
|
triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||||
|
<i-bs class="me-1" name="tags"></i-bs><span> <ng-container i18n>Tags</ng-container></span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item app-link"
|
||||||
|
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.DocumentType }">
|
||||||
|
<a class="nav-link" routerLink="documenttypes" routerLinkActive="active" (click)="closeMenu()"
|
||||||
|
ngbPopover="Document Types" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
|
||||||
|
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||||
|
<i-bs class="me-1" name="hash"></i-bs><span> <ng-container i18n>Document Types</ng-container></span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.StoragePath }">
|
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.StoragePath }">
|
||||||
<a class="nav-link py-1" routerLink="attributes/storagepaths" routerLinkActive="active" (click)="closeMenu()">
|
<a class="nav-link" routerLink="storagepaths" routerLinkActive="active" (click)="closeMenu()"
|
||||||
<i-bs class="me-1" name="folder"></i-bs><span> <ng-container i18n>Storage paths</ng-container></span>
|
ngbPopover="Storage Paths" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
|
||||||
|
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||||
|
<i-bs class="me-1" name="folder"></i-bs><span> <ng-container i18n>Storage Paths</ng-container></span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.CustomField }">
|
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.CustomField }">
|
||||||
<a class="nav-link py-1" routerLink="attributes/customfields" routerLinkActive="active" (click)="closeMenu()">
|
<a class="nav-link" routerLink="customfields" routerLinkActive="active" (click)="closeMenu()"
|
||||||
<i-bs class="me-1" name="ui-radios"></i-bs><span> <ng-container i18n>Custom fields</ng-container></span>
|
ngbPopover="Custom Fields" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
|
||||||
|
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||||
|
<i-bs class="me-1" name="ui-radios"></i-bs><span> <ng-container i18n>Custom Fields</ng-container></span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
}
|
|
||||||
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.SavedView }">
|
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.SavedView }">
|
||||||
<a class="nav-link" routerLink="savedviews" routerLinkActive="active" (click)="closeMenu()"
|
<a class="nav-link" routerLink="savedviews" routerLinkActive="active" (click)="closeMenu()"
|
||||||
ngbPopover="Saved Views" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
|
ngbPopover="Saved Views" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
|
||||||
|
|||||||
@@ -177,15 +177,6 @@ main {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.attributes-row .attributes-expand-btn {
|
|
||||||
opacity: 0.2;
|
|
||||||
transition: opacity 0.15s ease-in-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
.attributes-row:hover .attributes-expand-btn {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar-heading {
|
.sidebar-heading {
|
||||||
font-size: 0.75rem;
|
font-size: 0.75rem;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ import { ActivatedRoute, Router } from '@angular/router'
|
|||||||
import { RouterTestingModule } from '@angular/router/testing'
|
import { RouterTestingModule } from '@angular/router/testing'
|
||||||
import { NgbModal, NgbModalModule, NgbModule } from '@ng-bootstrap/ng-bootstrap'
|
import { NgbModal, NgbModalModule, NgbModule } from '@ng-bootstrap/ng-bootstrap'
|
||||||
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
||||||
import { provideUiTour } from 'ngx-ui-tour-ng-bootstrap'
|
|
||||||
import { of, throwError } from 'rxjs'
|
import { of, throwError } from 'rxjs'
|
||||||
import { routes } from 'src/app/app-routing.module'
|
import { routes } from 'src/app/app-routing.module'
|
||||||
import { SavedView } from 'src/app/data/saved-view'
|
import { SavedView } from 'src/app/data/saved-view'
|
||||||
@@ -28,10 +27,7 @@ import {
|
|||||||
DjangoMessagesService,
|
DjangoMessagesService,
|
||||||
} from 'src/app/services/django-messages.service'
|
} from 'src/app/services/django-messages.service'
|
||||||
import { OpenDocumentsService } from 'src/app/services/open-documents.service'
|
import { OpenDocumentsService } from 'src/app/services/open-documents.service'
|
||||||
import {
|
import { PermissionsService } from 'src/app/services/permissions.service'
|
||||||
PermissionType,
|
|
||||||
PermissionsService,
|
|
||||||
} from 'src/app/services/permissions.service'
|
|
||||||
import { RemoteVersionService } from 'src/app/services/rest/remote-version.service'
|
import { RemoteVersionService } from 'src/app/services/rest/remote-version.service'
|
||||||
import { SavedViewService } from 'src/app/services/rest/saved-view.service'
|
import { SavedViewService } from 'src/app/services/rest/saved-view.service'
|
||||||
import { SearchService } from 'src/app/services/rest/search.service'
|
import { SearchService } from 'src/app/services/rest/search.service'
|
||||||
@@ -161,7 +157,6 @@ describe('AppFrameComponent', () => {
|
|||||||
PermissionsGuard,
|
PermissionsGuard,
|
||||||
provideHttpClient(withInterceptorsFromDi()),
|
provideHttpClient(withInterceptorsFromDi()),
|
||||||
provideHttpClientTesting(),
|
provideHttpClientTesting(),
|
||||||
provideUiTour(),
|
|
||||||
],
|
],
|
||||||
}).compileComponents()
|
}).compileComponents()
|
||||||
|
|
||||||
@@ -261,7 +256,7 @@ describe('AppFrameComponent', () => {
|
|||||||
const toastSpy = jest.spyOn(toastService, 'showError')
|
const toastSpy = jest.spyOn(toastService, 'showError')
|
||||||
component.toggleSlimSidebar()
|
component.toggleSlimSidebar()
|
||||||
httpTestingController
|
httpTestingController
|
||||||
.match(`${environment.apiBaseUrl}ui_settings/`)[0]
|
.expectOne(`${environment.apiBaseUrl}ui_settings/`)
|
||||||
.flush('error', {
|
.flush('error', {
|
||||||
status: 500,
|
status: 500,
|
||||||
statusText: 'error',
|
statusText: 'error',
|
||||||
@@ -376,103 +371,4 @@ describe('AppFrameComponent', () => {
|
|||||||
it('should call maybeRefreshDocumentCounts after saved views reload', () => {
|
it('should call maybeRefreshDocumentCounts after saved views reload', () => {
|
||||||
expect(maybeRefreshSpy).toHaveBeenCalled()
|
expect(maybeRefreshSpy).toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should indicate attributes management availability when any permission is granted', () => {
|
|
||||||
jest
|
|
||||||
.spyOn(permissionsService, 'currentUserCan')
|
|
||||||
.mockImplementation((action, type) => {
|
|
||||||
return type === PermissionType.Tag
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(component.canManageAttributes).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should indicate attributes management availability for other permission types', () => {
|
|
||||||
const canSpy = jest
|
|
||||||
.spyOn(permissionsService, 'currentUserCan')
|
|
||||||
.mockImplementation((action, type) => {
|
|
||||||
return type === PermissionType.Correspondent
|
|
||||||
})
|
|
||||||
expect(component.canManageAttributes).toBe(true)
|
|
||||||
|
|
||||||
canSpy.mockImplementation((action, type) => {
|
|
||||||
return type === PermissionType.DocumentType
|
|
||||||
})
|
|
||||||
expect(component.canManageAttributes).toBe(true)
|
|
||||||
|
|
||||||
canSpy.mockImplementation((action, type) => {
|
|
||||||
return type === PermissionType.StoragePath
|
|
||||||
})
|
|
||||||
expect(component.canManageAttributes).toBe(true)
|
|
||||||
|
|
||||||
canSpy.mockImplementation((action, type) => {
|
|
||||||
return type === PermissionType.CustomField
|
|
||||||
})
|
|
||||||
expect(component.canManageAttributes).toBe(true)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should toggle attributes sections and stop event bubbling', () => {
|
|
||||||
const preventDefault = jest.fn()
|
|
||||||
const stopPropagation = jest.fn()
|
|
||||||
const setSpy = jest.spyOn(settingsService, 'set')
|
|
||||||
jest.spyOn(settingsService, 'storeSettings').mockReturnValue(of(true))
|
|
||||||
|
|
||||||
component.toggleAttributesSections({
|
|
||||||
preventDefault,
|
|
||||||
stopPropagation,
|
|
||||||
} as any)
|
|
||||||
|
|
||||||
expect(preventDefault).toHaveBeenCalled()
|
|
||||||
expect(stopPropagation).toHaveBeenCalled()
|
|
||||||
expect(setSpy).toHaveBeenCalledWith(
|
|
||||||
SETTINGS_KEYS.ATTRIBUTES_SECTIONS_COLLAPSED,
|
|
||||||
['attributes']
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should show error when saving slim sidebar setting fails', () => {
|
|
||||||
const toastSpy = jest.spyOn(toastService, 'showError')
|
|
||||||
jest.spyOn(console, 'warn').mockImplementation(() => {})
|
|
||||||
jest
|
|
||||||
.spyOn(settingsService, 'storeSettings')
|
|
||||||
.mockReturnValue(throwError(() => new Error('boom')))
|
|
||||||
|
|
||||||
component.slimSidebarEnabled = true
|
|
||||||
|
|
||||||
expect(toastSpy).toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should show error when saving attributes collapsed setting fails', () => {
|
|
||||||
const toastSpy = jest.spyOn(toastService, 'showError')
|
|
||||||
jest.spyOn(console, 'warn').mockImplementation(() => {})
|
|
||||||
jest
|
|
||||||
.spyOn(settingsService, 'storeSettings')
|
|
||||||
.mockReturnValue(throwError(() => new Error('boom')))
|
|
||||||
|
|
||||||
component.attributesSectionsCollapsed = true
|
|
||||||
|
|
||||||
expect(toastSpy).toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should persist attributes section collapse state', () => {
|
|
||||||
const setSpy = jest.spyOn(settingsService, 'set')
|
|
||||||
jest.spyOn(settingsService, 'storeSettings').mockReturnValue(of(true))
|
|
||||||
|
|
||||||
component.attributesSectionsCollapsed = true
|
|
||||||
|
|
||||||
expect(setSpy).toHaveBeenCalledWith(
|
|
||||||
SETTINGS_KEYS.ATTRIBUTES_SECTIONS_COLLAPSED,
|
|
||||||
['attributes']
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should collapse attributes sections when enabling slim sidebar', () => {
|
|
||||||
jest.spyOn(settingsService, 'storeSettings').mockReturnValue(of(true))
|
|
||||||
settingsService.set(SETTINGS_KEYS.ATTRIBUTES_SECTIONS_COLLAPSED, [])
|
|
||||||
settingsService.set(SETTINGS_KEYS.SLIM_SIDEBAR, false)
|
|
||||||
|
|
||||||
component.toggleSlimSidebar()
|
|
||||||
|
|
||||||
expect(component.attributesSectionsCollapsed).toBe(true)
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -16,12 +16,12 @@ import {
|
|||||||
NgbPopoverModule,
|
NgbPopoverModule,
|
||||||
} from '@ng-bootstrap/ng-bootstrap'
|
} from '@ng-bootstrap/ng-bootstrap'
|
||||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||||
import { TourNgBootstrap } from 'ngx-ui-tour-ng-bootstrap'
|
import { TourNgBootstrapModule } from 'ngx-ui-tour-ng-bootstrap'
|
||||||
import { Observable } from 'rxjs'
|
import { Observable } from 'rxjs'
|
||||||
import { first } from 'rxjs/operators'
|
import { first } from 'rxjs/operators'
|
||||||
import { Document } from 'src/app/data/document'
|
import { Document } from 'src/app/data/document'
|
||||||
import { SavedView } from 'src/app/data/saved-view'
|
import { SavedView } from 'src/app/data/saved-view'
|
||||||
import { CollapsibleSection, SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
||||||
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
||||||
import { ComponentCanDeactivate } from 'src/app/guards/dirty-doc.guard'
|
import { ComponentCanDeactivate } from 'src/app/guards/dirty-doc.guard'
|
||||||
import { DocumentTitlePipe } from 'src/app/pipes/document-title.pipe'
|
import { DocumentTitlePipe } from 'src/app/pipes/document-title.pipe'
|
||||||
@@ -69,7 +69,7 @@ import { ToastsDropdownComponent } from './toasts-dropdown/toasts-dropdown.compo
|
|||||||
NgbNavModule,
|
NgbNavModule,
|
||||||
NgxBootstrapIconsModule,
|
NgxBootstrapIconsModule,
|
||||||
DragDropModule,
|
DragDropModule,
|
||||||
TourNgBootstrap,
|
TourNgBootstrapModule,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class AppFrameComponent
|
export class AppFrameComponent
|
||||||
@@ -141,20 +141,11 @@ export class AppFrameComponent
|
|||||||
toggleSlimSidebar(): void {
|
toggleSlimSidebar(): void {
|
||||||
this.slimSidebarAnimating = true
|
this.slimSidebarAnimating = true
|
||||||
this.slimSidebarEnabled = !this.slimSidebarEnabled
|
this.slimSidebarEnabled = !this.slimSidebarEnabled
|
||||||
if (this.slimSidebarEnabled) {
|
|
||||||
this.attributesSectionsCollapsed = true
|
|
||||||
}
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
this.slimSidebarAnimating = false
|
this.slimSidebarAnimating = false
|
||||||
}, 200) // slightly longer than css animation for slim sidebar
|
}, 200) // slightly longer than css animation for slim sidebar
|
||||||
}
|
}
|
||||||
|
|
||||||
toggleAttributesSections(event?: Event): void {
|
|
||||||
event?.preventDefault()
|
|
||||||
event?.stopPropagation()
|
|
||||||
this.attributesSectionsCollapsed = !this.attributesSectionsCollapsed
|
|
||||||
}
|
|
||||||
|
|
||||||
get versionString(): string {
|
get versionString(): string {
|
||||||
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}`}`
|
||||||
}
|
}
|
||||||
@@ -176,31 +167,6 @@ export class AppFrameComponent
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
get canManageAttributes(): boolean {
|
|
||||||
return (
|
|
||||||
this.permissionsService.currentUserCan(
|
|
||||||
PermissionAction.View,
|
|
||||||
PermissionType.Tag
|
|
||||||
) ||
|
|
||||||
this.permissionsService.currentUserCan(
|
|
||||||
PermissionAction.View,
|
|
||||||
PermissionType.Correspondent
|
|
||||||
) ||
|
|
||||||
this.permissionsService.currentUserCan(
|
|
||||||
PermissionAction.View,
|
|
||||||
PermissionType.DocumentType
|
|
||||||
) ||
|
|
||||||
this.permissionsService.currentUserCan(
|
|
||||||
PermissionAction.View,
|
|
||||||
PermissionType.StoragePath
|
|
||||||
) ||
|
|
||||||
this.permissionsService.currentUserCan(
|
|
||||||
PermissionAction.View,
|
|
||||||
PermissionType.CustomField
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
get slimSidebarEnabled(): boolean {
|
get slimSidebarEnabled(): boolean {
|
||||||
return this.settingsService.get(SETTINGS_KEYS.SLIM_SIDEBAR)
|
return this.settingsService.get(SETTINGS_KEYS.SLIM_SIDEBAR)
|
||||||
}
|
}
|
||||||
@@ -220,31 +186,6 @@ export class AppFrameComponent
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
get attributesSectionsCollapsed(): boolean {
|
|
||||||
return this.settingsService
|
|
||||||
.get(SETTINGS_KEYS.ATTRIBUTES_SECTIONS_COLLAPSED)
|
|
||||||
?.includes(CollapsibleSection.ATTRIBUTES)
|
|
||||||
}
|
|
||||||
|
|
||||||
set attributesSectionsCollapsed(collapsed: boolean) {
|
|
||||||
// TODO: refactor to be able to toggle individual sections, if implemented
|
|
||||||
this.settingsService.set(
|
|
||||||
SETTINGS_KEYS.ATTRIBUTES_SECTIONS_COLLAPSED,
|
|
||||||
collapsed ? [CollapsibleSection.ATTRIBUTES] : []
|
|
||||||
)
|
|
||||||
this.settingsService
|
|
||||||
.storeSettings()
|
|
||||||
.pipe(first())
|
|
||||||
.subscribe({
|
|
||||||
error: (error) => {
|
|
||||||
this.toastService.showError(
|
|
||||||
$localize`An error occurred while saving settings.`
|
|
||||||
)
|
|
||||||
console.warn(error)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
get aiEnabled(): boolean {
|
get aiEnabled(): boolean {
|
||||||
return this.settingsService.get(SETTINGS_KEYS.AI_ENABLED)
|
return this.settingsService.get(SETTINGS_KEYS.AI_ENABLED)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -430,24 +430,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@case (WorkflowActionType.PasswordRemoval) {
|
|
||||||
<div class="row">
|
|
||||||
<div class="col">
|
|
||||||
<p class="small" i18n>
|
|
||||||
One password per line. The workflow will try them in order until one succeeds.
|
|
||||||
</p>
|
|
||||||
<pngx-input-textarea
|
|
||||||
i18n-title
|
|
||||||
title="Passwords"
|
|
||||||
formControlName="passwords"
|
|
||||||
rows="4"
|
|
||||||
[error]="error?.actions?.[i]?.passwords"
|
|
||||||
hint="Passwords are stored in plain text. Use with caution."
|
|
||||||
i18n-hint
|
|
||||||
></pngx-input-textarea>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
|
|||||||
import { provideHttpClientTesting } from '@angular/common/http/testing'
|
import { provideHttpClientTesting } from '@angular/common/http/testing'
|
||||||
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||||
import {
|
import {
|
||||||
FormArray,
|
|
||||||
FormControl,
|
FormControl,
|
||||||
FormGroup,
|
FormGroup,
|
||||||
FormsModule,
|
FormsModule,
|
||||||
@@ -995,32 +994,4 @@ describe('WorkflowEditDialogComponent', () => {
|
|||||||
component.removeSelectedCustomField(3, formGroup)
|
component.removeSelectedCustomField(3, formGroup)
|
||||||
expect(formGroup.get('assign_custom_fields').value).toEqual([])
|
expect(formGroup.get('assign_custom_fields').value).toEqual([])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should handle parsing of passwords from array to string and back on save', () => {
|
|
||||||
const passwordAction: WorkflowAction = {
|
|
||||||
id: 1,
|
|
||||||
type: WorkflowActionType.PasswordRemoval,
|
|
||||||
passwords: ['pass1', 'pass2'],
|
|
||||||
}
|
|
||||||
component.object = {
|
|
||||||
name: 'Workflow with Passwords',
|
|
||||||
id: 1,
|
|
||||||
order: 1,
|
|
||||||
enabled: true,
|
|
||||||
triggers: [],
|
|
||||||
actions: [passwordAction],
|
|
||||||
}
|
|
||||||
component.ngOnInit()
|
|
||||||
|
|
||||||
const formActions = component.objectForm.get('actions') as FormArray
|
|
||||||
expect(formActions.value[0].passwords).toBe('pass1\npass2')
|
|
||||||
formActions.at(0).get('passwords').setValue('pass1\npass2\npass3')
|
|
||||||
component.save()
|
|
||||||
|
|
||||||
expect(component.objectForm.get('actions').value[0].passwords).toEqual([
|
|
||||||
'pass1',
|
|
||||||
'pass2',
|
|
||||||
'pass3',
|
|
||||||
])
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -139,10 +139,6 @@ export const WORKFLOW_ACTION_OPTIONS = [
|
|||||||
id: WorkflowActionType.Webhook,
|
id: WorkflowActionType.Webhook,
|
||||||
name: $localize`Webhook`,
|
name: $localize`Webhook`,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
id: WorkflowActionType.PasswordRemoval,
|
|
||||||
name: $localize`Password removal`,
|
|
||||||
},
|
|
||||||
]
|
]
|
||||||
|
|
||||||
export enum TriggerFilterType {
|
export enum TriggerFilterType {
|
||||||
@@ -1206,25 +1202,11 @@ export class WorkflowEditDialogComponent
|
|||||||
headers: new FormControl(action.webhook?.headers),
|
headers: new FormControl(action.webhook?.headers),
|
||||||
include_document: new FormControl(!!action.webhook?.include_document),
|
include_document: new FormControl(!!action.webhook?.include_document),
|
||||||
}),
|
}),
|
||||||
passwords: new FormControl(
|
|
||||||
this.formatPasswords(action.passwords ?? [])
|
|
||||||
),
|
|
||||||
}),
|
}),
|
||||||
{ emitEvent }
|
{ emitEvent }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private formatPasswords(passwords: string[] = []): string {
|
|
||||||
return passwords.join('\n')
|
|
||||||
}
|
|
||||||
|
|
||||||
private parsePasswords(value: string = ''): string[] {
|
|
||||||
return value
|
|
||||||
.split(/[\n,]+/)
|
|
||||||
.map((entry) => entry.trim())
|
|
||||||
.filter((entry) => entry.length > 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
private updateAllTriggerActionFields(emitEvent: boolean = false) {
|
private updateAllTriggerActionFields(emitEvent: boolean = false) {
|
||||||
this.triggerFields.clear({ emitEvent: false })
|
this.triggerFields.clear({ emitEvent: false })
|
||||||
this.object?.triggers.forEach((trigger) => {
|
this.object?.triggers.forEach((trigger) => {
|
||||||
@@ -1349,7 +1331,6 @@ export class WorkflowEditDialogComponent
|
|||||||
headers: null,
|
headers: null,
|
||||||
include_document: false,
|
include_document: false,
|
||||||
},
|
},
|
||||||
passwords: [],
|
|
||||||
}
|
}
|
||||||
this.object.actions.push(action)
|
this.object.actions.push(action)
|
||||||
this.createActionField(action)
|
this.createActionField(action)
|
||||||
@@ -1386,7 +1367,6 @@ export class WorkflowEditDialogComponent
|
|||||||
if (action.type !== WorkflowActionType.Email) {
|
if (action.type !== WorkflowActionType.Email) {
|
||||||
action.email = null
|
action.email = null
|
||||||
}
|
}
|
||||||
action.passwords = this.parsePasswords(action.passwords as any)
|
|
||||||
})
|
})
|
||||||
super.save()
|
super.save()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
<span class="h6 mb-0 mt-1 d-block d-md-inline fw-normal ms-md-3 text-truncate" style="line-height: 1.4">{{subTitle}}</span>
|
<span class="h6 mb-0 mt-1 d-block d-md-inline fw-normal ms-md-3 text-truncate" style="line-height: 1.4">{{subTitle}}</span>
|
||||||
}
|
}
|
||||||
@if (info) {
|
@if (info) {
|
||||||
<button class="btn btn-sm btn-link text-muted p-0 p-md-2" title="What's this?" i18n-title type="button" [ngbPopover]="infoPopover" [autoClose]="true">
|
<button class="btn btn-sm btn-link text-muted me-auto p-0 p-md-2" title="What's this?" i18n-title type="button" [ngbPopover]="infoPopover" [autoClose]="true">
|
||||||
<i-bs name="question-circle"></i-bs>
|
<i-bs name="question-circle"></i-bs>
|
||||||
</button>
|
</button>
|
||||||
<ng-template #infoPopover>
|
<ng-template #infoPopover>
|
||||||
@@ -26,9 +26,6 @@
|
|||||||
}
|
}
|
||||||
</ng-template>
|
</ng-template>
|
||||||
}
|
}
|
||||||
@if (loading) {
|
|
||||||
<output class="spinner-border spinner-border-sm fs-6 fw-normal" aria-hidden="true"><span class="visually-hidden" i18n>Loading...</span></output>
|
|
||||||
}
|
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
<div class="btn-toolbar col col-md-auto gap-2">
|
<div class="btn-toolbar col col-md-auto gap-2">
|
||||||
|
|||||||
@@ -3,14 +3,14 @@ import { Component, Input, inject } from '@angular/core'
|
|||||||
import { Title } from '@angular/platform-browser'
|
import { Title } from '@angular/platform-browser'
|
||||||
import { NgbPopoverModule } from '@ng-bootstrap/ng-bootstrap'
|
import { NgbPopoverModule } from '@ng-bootstrap/ng-bootstrap'
|
||||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||||
import { TourNgBootstrap } from 'ngx-ui-tour-ng-bootstrap'
|
import { TourNgBootstrapModule } from 'ngx-ui-tour-ng-bootstrap'
|
||||||
import { environment } from 'src/environments/environment'
|
import { environment } from 'src/environments/environment'
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'pngx-page-header',
|
selector: 'pngx-page-header',
|
||||||
templateUrl: './page-header.component.html',
|
templateUrl: './page-header.component.html',
|
||||||
styleUrls: ['./page-header.component.scss'],
|
styleUrls: ['./page-header.component.scss'],
|
||||||
imports: [NgbPopoverModule, NgxBootstrapIconsModule, TourNgBootstrap],
|
imports: [NgbPopoverModule, NgxBootstrapIconsModule, TourNgBootstrapModule],
|
||||||
})
|
})
|
||||||
export class PageHeaderComponent {
|
export class PageHeaderComponent {
|
||||||
private titleService = inject(Title)
|
private titleService = inject(Title)
|
||||||
@@ -42,9 +42,6 @@ export class PageHeaderComponent {
|
|||||||
@Input()
|
@Input()
|
||||||
infoLink: string
|
infoLink: string
|
||||||
|
|
||||||
@Input()
|
|
||||||
loading: boolean = false
|
|
||||||
|
|
||||||
public copyID() {
|
public copyID() {
|
||||||
this.copied = this.clipboard.copy(this.id.toString())
|
this.copied = this.clipboard.copy(this.id.toString())
|
||||||
clearTimeout(this.copyTimeout)
|
clearTimeout(this.copyTimeout)
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
<pngx-pdf-viewer class="visually-hidden" [src]="pdfSrc" [renderMode]="PdfRenderMode.Single" [page]="1" [selectable]="false" (afterLoadComplete)="pdfLoaded($event)"></pngx-pdf-viewer>
|
<pdf-viewer [src]="pdfSrc" [render-text]="false" zoom="0.4" (after-load-complete)="pdfLoaded($event)"></pdf-viewer>
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<h4 class="modal-title">{{ title }}</h4>
|
<h4 class="modal-title">{{ title }}</h4>
|
||||||
<button type="button" class="btn-close" aria-label="Close" (click)="cancel()"></button>
|
<button type="button" class="btn-close" aria-label="Close" (click)="cancel()"></button>
|
||||||
@@ -59,7 +59,7 @@
|
|||||||
<span class="placeholder w-100 h-100"></span>
|
<span class="placeholder w-100 h-100"></span>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
<pngx-pdf-viewer class="fade" [class.show]="p.loaded" [src]="pdfSrc" [page]="p.page" [rotation]="p.rotate" [renderMode]="PdfRenderMode.Single" (rendered)="p.loaded = true"></pngx-pdf-viewer>
|
<pdf-viewer class="fade" [class.show]="p.loaded" [src]="pdfSrc" [page]="p.page" [rotation]="p.rotate" [original-size]="false" [show-all]="false" [render-text]="false" (page-rendered)="p.loaded = true"></pdf-viewer>
|
||||||
} @placeholder {
|
} @placeholder {
|
||||||
<div class="placeholder-glow w-100 h-100 z-10">
|
<div class="placeholder-glow w-100 h-100 z-10">
|
||||||
<span class="placeholder w-100 h-100"></span>
|
<span class="placeholder w-100 h-100"></span>
|
||||||
|
|||||||
@@ -15,13 +15,13 @@
|
|||||||
background-color: gray;
|
background-color: gray;
|
||||||
height: 240px;
|
height: 240px;
|
||||||
|
|
||||||
pngx-pdf-viewer {
|
pdf-viewer {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
::ng-deep .pngx-pdf-viewer-container {
|
::ng-deep .ng2-pdf-viewer-container {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,16 +6,12 @@ import {
|
|||||||
import { Component, inject } from '@angular/core'
|
import { Component, inject } from '@angular/core'
|
||||||
import { FormsModule } from '@angular/forms'
|
import { FormsModule } from '@angular/forms'
|
||||||
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
|
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
|
||||||
|
import { PDFDocumentProxy, PdfViewerModule } from 'ng2-pdf-viewer'
|
||||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||||
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
||||||
import { DocumentService } from 'src/app/services/rest/document.service'
|
import { DocumentService } from 'src/app/services/rest/document.service'
|
||||||
import { SettingsService } from 'src/app/services/settings.service'
|
import { SettingsService } from 'src/app/services/settings.service'
|
||||||
import { ConfirmDialogComponent } from '../confirm-dialog/confirm-dialog.component'
|
import { ConfirmDialogComponent } from '../confirm-dialog/confirm-dialog.component'
|
||||||
import { PngxPdfViewerComponent } from '../pdf-viewer/pdf-viewer.component'
|
|
||||||
import {
|
|
||||||
PdfRenderMode,
|
|
||||||
PngxPdfDocumentProxy,
|
|
||||||
} from '../pdf-viewer/pdf-viewer.types'
|
|
||||||
import { PdfEditorEditMode } from './pdf-editor-edit-mode'
|
import { PdfEditorEditMode } from './pdf-editor-edit-mode'
|
||||||
|
|
||||||
interface PageOperation {
|
interface PageOperation {
|
||||||
@@ -33,12 +29,11 @@ interface PageOperation {
|
|||||||
imports: [
|
imports: [
|
||||||
DragDropModule,
|
DragDropModule,
|
||||||
FormsModule,
|
FormsModule,
|
||||||
|
PdfViewerModule,
|
||||||
NgxBootstrapIconsModule,
|
NgxBootstrapIconsModule,
|
||||||
PngxPdfViewerComponent,
|
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class PDFEditorComponent extends ConfirmDialogComponent {
|
export class PDFEditorComponent extends ConfirmDialogComponent {
|
||||||
PdfRenderMode = PdfRenderMode
|
|
||||||
public PdfEditorEditMode = PdfEditorEditMode
|
public PdfEditorEditMode = PdfEditorEditMode
|
||||||
|
|
||||||
private documentService = inject(DocumentService)
|
private documentService = inject(DocumentService)
|
||||||
@@ -58,7 +53,7 @@ export class PDFEditorComponent extends ConfirmDialogComponent {
|
|||||||
return this.documentService.getPreviewUrl(this.documentID)
|
return this.documentService.getPreviewUrl(this.documentID)
|
||||||
}
|
}
|
||||||
|
|
||||||
pdfLoaded(pdf: PngxPdfDocumentProxy) {
|
pdfLoaded(pdf: PDFDocumentProxy) {
|
||||||
this.totalPages = pdf.numPages
|
this.totalPages = pdf.numPages
|
||||||
this.pages = Array.from({ length: this.totalPages }, (_, i) => ({
|
this.pages = Array.from({ length: this.totalPages }, (_, i) => ({
|
||||||
page: i + 1,
|
page: i + 1,
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
<div #container class="pngx-pdf-viewer-container">
|
|
||||||
<div #viewer class="pdfViewer"></div>
|
|
||||||
</div>
|
|
||||||
@@ -1,153 +0,0 @@
|
|||||||
:host {
|
|
||||||
display: block;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
position: relative;
|
|
||||||
}
|
|
||||||
|
|
||||||
:host ::ng-deep .pngx-pdf-viewer-container {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
overflow: auto;
|
|
||||||
}
|
|
||||||
|
|
||||||
:host ::ng-deep .pdfViewer {
|
|
||||||
--scale-factor: 1;
|
|
||||||
--page-bg-color: unset;
|
|
||||||
padding-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
:host ::ng-deep .pdfViewer .page {
|
|
||||||
--user-unit: 1;
|
|
||||||
--total-scale-factor: calc(var(--scale-factor) * var(--user-unit));
|
|
||||||
--scale-round-x: 1px;
|
|
||||||
--scale-round-y: 1px;
|
|
||||||
direction: ltr;
|
|
||||||
margin: 0 auto 10px;
|
|
||||||
border: 0;
|
|
||||||
position: relative;
|
|
||||||
overflow: visible;
|
|
||||||
background-clip: content-box;
|
|
||||||
background-color: var(--page-bg-color, rgb(255 255 255));
|
|
||||||
}
|
|
||||||
|
|
||||||
:host ::ng-deep .pdfViewer > .page:last-of-type {
|
|
||||||
margin-bottom: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
:host ::ng-deep .pdfViewer.singlePageView {
|
|
||||||
display: inline-block;
|
|
||||||
}
|
|
||||||
|
|
||||||
:host ::ng-deep .pdfViewer.singlePageView .page {
|
|
||||||
margin: 0;
|
|
||||||
border: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
:host ::ng-deep .pdfViewer .canvasWrapper {
|
|
||||||
overflow: hidden;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
|
|
||||||
:host ::ng-deep .pdfViewer .canvasWrapper canvas {
|
|
||||||
position: absolute;
|
|
||||||
top: 0;
|
|
||||||
left: 0;
|
|
||||||
margin: 0;
|
|
||||||
display: block;
|
|
||||||
width: 100%;
|
|
||||||
height: 100%;
|
|
||||||
contain: content;
|
|
||||||
}
|
|
||||||
|
|
||||||
:host ::ng-deep .textLayer {
|
|
||||||
position: absolute;
|
|
||||||
text-align: initial;
|
|
||||||
inset: 0;
|
|
||||||
overflow: clip;
|
|
||||||
opacity: 1;
|
|
||||||
line-height: 1;
|
|
||||||
text-size-adjust: none;
|
|
||||||
transform-origin: 0 0;
|
|
||||||
caret-color: CanvasText;
|
|
||||||
z-index: 0;
|
|
||||||
user-select: text;
|
|
||||||
--min-font-size: 1;
|
|
||||||
--text-scale-factor: calc(var(--total-scale-factor) * var(--min-font-size));
|
|
||||||
--min-font-size-inv: calc(1 / var(--min-font-size));
|
|
||||||
}
|
|
||||||
|
|
||||||
:host ::ng-deep .textLayer.highlighting {
|
|
||||||
touch-action: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
:host ::ng-deep .textLayer :is(span, br) {
|
|
||||||
position: absolute;
|
|
||||||
white-space: pre;
|
|
||||||
color: transparent;
|
|
||||||
cursor: text;
|
|
||||||
transform-origin: 0% 0%;
|
|
||||||
}
|
|
||||||
|
|
||||||
:host ::ng-deep .textLayer > :not(.markedContent),
|
|
||||||
:host ::ng-deep .textLayer .markedContent span:not(.markedContent) {
|
|
||||||
z-index: 1;
|
|
||||||
--font-height: 0;
|
|
||||||
font-size: calc(var(--text-scale-factor) * var(--font-height));
|
|
||||||
--scale-x: 1;
|
|
||||||
--rotate: 0deg;
|
|
||||||
transform: rotate(var(--rotate)) scaleX(var(--scale-x))
|
|
||||||
scale(var(--min-font-size-inv));
|
|
||||||
}
|
|
||||||
|
|
||||||
:host ::ng-deep .textLayer .markedContent {
|
|
||||||
display: contents;
|
|
||||||
}
|
|
||||||
|
|
||||||
:host ::ng-deep .textLayer span[role='img'] {
|
|
||||||
user-select: none;
|
|
||||||
cursor: default;
|
|
||||||
}
|
|
||||||
|
|
||||||
:host ::ng-deep .textLayer .highlight {
|
|
||||||
--highlight-bg-color: rgb(180 0 170 / 0.25);
|
|
||||||
--highlight-selected-bg-color: rgb(0 100 0 / 0.25);
|
|
||||||
--highlight-backdrop-filter: none;
|
|
||||||
--highlight-selected-backdrop-filter: none;
|
|
||||||
margin: -1px;
|
|
||||||
padding: 1px;
|
|
||||||
background-color: var(--highlight-bg-color);
|
|
||||||
backdrop-filter: var(--highlight-backdrop-filter);
|
|
||||||
border-radius: 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
:host ::ng-deep .appended:is(.textLayer .highlight) {
|
|
||||||
position: initial;
|
|
||||||
}
|
|
||||||
|
|
||||||
:host ::ng-deep .begin:is(.textLayer .highlight) {
|
|
||||||
border-radius: 4px 0 0 4px;
|
|
||||||
}
|
|
||||||
|
|
||||||
:host ::ng-deep .end:is(.textLayer .highlight) {
|
|
||||||
border-radius: 0 4px 4px 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
:host ::ng-deep .middle:is(.textLayer .highlight) {
|
|
||||||
border-radius: 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
:host ::ng-deep .selected:is(.textLayer .highlight) {
|
|
||||||
background-color: var(--highlight-selected-bg-color);
|
|
||||||
}
|
|
||||||
|
|
||||||
:host ::ng-deep .textLayer ::selection {
|
|
||||||
background: rgba(30, 100, 255, 0.35);
|
|
||||||
}
|
|
||||||
|
|
||||||
:host ::ng-deep .annotationLayer {
|
|
||||||
position: absolute;
|
|
||||||
inset: 0;
|
|
||||||
pointer-events: none;
|
|
||||||
}
|
|
||||||
@@ -1,299 +0,0 @@
|
|||||||
import { SimpleChange } from '@angular/core'
|
|
||||||
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
|
||||||
import * as pdfjs from 'pdfjs-dist/legacy/build/pdf.mjs'
|
|
||||||
import { PDFSinglePageViewer, PDFViewer } from 'pdfjs-dist/web/pdf_viewer.mjs'
|
|
||||||
import { PngxPdfViewerComponent } from './pdf-viewer.component'
|
|
||||||
import { PdfRenderMode, PdfZoomLevel, PdfZoomScale } from './pdf-viewer.types'
|
|
||||||
|
|
||||||
describe('PngxPdfViewerComponent', () => {
|
|
||||||
let fixture: ComponentFixture<PngxPdfViewerComponent>
|
|
||||||
let component: PngxPdfViewerComponent
|
|
||||||
|
|
||||||
const initComponent = async (src = 'test.pdf') => {
|
|
||||||
component.src = src
|
|
||||||
fixture.detectChanges()
|
|
||||||
await fixture.whenStable()
|
|
||||||
}
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
await TestBed.configureTestingModule({
|
|
||||||
imports: [PngxPdfViewerComponent],
|
|
||||||
}).compileComponents()
|
|
||||||
|
|
||||||
fixture = TestBed.createComponent(PngxPdfViewerComponent)
|
|
||||||
component = fixture.componentInstance
|
|
||||||
})
|
|
||||||
|
|
||||||
it('loads a document and emits events', async () => {
|
|
||||||
const loadSpy = jest.fn()
|
|
||||||
const renderedSpy = jest.fn()
|
|
||||||
component.afterLoadComplete.subscribe(loadSpy)
|
|
||||||
component.rendered.subscribe(renderedSpy)
|
|
||||||
|
|
||||||
await initComponent()
|
|
||||||
|
|
||||||
expect(pdfjs.GlobalWorkerOptions.workerSrc).toBe(
|
|
||||||
'/assets/js/pdf.worker.min.mjs'
|
|
||||||
)
|
|
||||||
const isVisible = (component as any).findController.onIsPageVisible as
|
|
||||||
| (() => boolean)
|
|
||||||
| undefined
|
|
||||||
expect(isVisible?.()).toBe(true)
|
|
||||||
expect(loadSpy).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({ numPages: 1 })
|
|
||||||
)
|
|
||||||
expect(renderedSpy).toHaveBeenCalled()
|
|
||||||
expect((component as any).pdfViewer).toBeInstanceOf(PDFViewer)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('initializes single-page viewer and disables text layer', async () => {
|
|
||||||
component.renderMode = PdfRenderMode.Single
|
|
||||||
component.selectable = false
|
|
||||||
|
|
||||||
await initComponent()
|
|
||||||
|
|
||||||
const viewer = (component as any).pdfViewer as PDFSinglePageViewer & {
|
|
||||||
options: Record<string, unknown>
|
|
||||||
}
|
|
||||||
expect(viewer).toBeInstanceOf(PDFSinglePageViewer)
|
|
||||||
expect(viewer.options.textLayerMode).toBe(0)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('applies zoom, rotation, and page changes', async () => {
|
|
||||||
await initComponent()
|
|
||||||
|
|
||||||
const pageSpy = jest.fn()
|
|
||||||
component.pageChange.subscribe(pageSpy)
|
|
||||||
|
|
||||||
component.zoomScale = PdfZoomScale.PageFit
|
|
||||||
component.zoom = PdfZoomLevel.Two
|
|
||||||
component.rotation = 90
|
|
||||||
component.page = 2
|
|
||||||
|
|
||||||
component.ngOnChanges({
|
|
||||||
zoomScale: new SimpleChange(
|
|
||||||
PdfZoomScale.PageWidth,
|
|
||||||
PdfZoomScale.PageFit,
|
|
||||||
false
|
|
||||||
),
|
|
||||||
zoom: new SimpleChange(PdfZoomLevel.One, PdfZoomLevel.Two, false),
|
|
||||||
rotation: new SimpleChange(undefined, 90, false),
|
|
||||||
page: new SimpleChange(undefined, 2, false),
|
|
||||||
})
|
|
||||||
|
|
||||||
const viewer = (component as any).pdfViewer as PDFViewer
|
|
||||||
expect(viewer.pagesRotation).toBe(90)
|
|
||||||
expect(viewer.currentPageNumber).toBe(2)
|
|
||||||
expect(pageSpy).toHaveBeenCalledWith(2)
|
|
||||||
|
|
||||||
viewer.currentScale = 1
|
|
||||||
;(component as any).applyScale()
|
|
||||||
expect(viewer.currentScaleValue).toBe(PdfZoomScale.PageFit)
|
|
||||||
expect(viewer.currentScale).toBe(2)
|
|
||||||
|
|
||||||
const applyScaleSpy = jest.spyOn(component as any, 'applyScale')
|
|
||||||
component.page = 2
|
|
||||||
;(component as any).lastViewerPage = 2
|
|
||||||
;(component as any).applyViewerState()
|
|
||||||
expect((component as any).lastViewerPage).toBeUndefined()
|
|
||||||
expect(applyScaleSpy).toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('dispatches find when search query changes after render', async () => {
|
|
||||||
await initComponent()
|
|
||||||
|
|
||||||
const eventBus = (component as any).eventBus as { dispatch: jest.Mock }
|
|
||||||
const dispatchSpy = jest.spyOn(eventBus, 'dispatch')
|
|
||||||
|
|
||||||
;(component as any).hasRenderedPage = true
|
|
||||||
component.searchQuery = 'needle'
|
|
||||||
component.ngOnChanges({
|
|
||||||
searchQuery: new SimpleChange('', 'needle', false),
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(dispatchSpy).toHaveBeenCalledWith('find', {
|
|
||||||
query: 'needle',
|
|
||||||
caseSensitive: false,
|
|
||||||
highlightAll: true,
|
|
||||||
phraseSearch: true,
|
|
||||||
})
|
|
||||||
|
|
||||||
component.ngOnChanges({
|
|
||||||
searchQuery: new SimpleChange('needle', 'needle', false),
|
|
||||||
})
|
|
||||||
expect(dispatchSpy).toHaveBeenCalledTimes(1)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('emits error when document load fails', async () => {
|
|
||||||
const errorSpy = jest.fn()
|
|
||||||
component.loadError.subscribe(errorSpy)
|
|
||||||
|
|
||||||
jest.spyOn(pdfjs, 'getDocument').mockImplementationOnce(() => {
|
|
||||||
return {
|
|
||||||
promise: Promise.reject(new Error('boom')),
|
|
||||||
destroy: jest.fn(),
|
|
||||||
} as any
|
|
||||||
})
|
|
||||||
|
|
||||||
await initComponent('bad.pdf')
|
|
||||||
|
|
||||||
expect(errorSpy).toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('cleans up resources on destroy', async () => {
|
|
||||||
await initComponent()
|
|
||||||
|
|
||||||
const viewer = (component as any).pdfViewer as { cleanup: jest.Mock }
|
|
||||||
const loadingTask = (component as any).loadingTask as unknown as {
|
|
||||||
destroy: jest.Mock
|
|
||||||
}
|
|
||||||
const resizeObserver = (component as any).resizeObserver as unknown as {
|
|
||||||
disconnect: jest.Mock
|
|
||||||
}
|
|
||||||
const eventBus = (component as any).eventBus as { off: jest.Mock }
|
|
||||||
|
|
||||||
jest.spyOn(viewer, 'cleanup')
|
|
||||||
jest.spyOn(loadingTask, 'destroy')
|
|
||||||
jest.spyOn(resizeObserver, 'disconnect')
|
|
||||||
jest.spyOn(eventBus, 'off')
|
|
||||||
|
|
||||||
component.ngOnDestroy()
|
|
||||||
|
|
||||||
expect(eventBus.off).toHaveBeenCalledWith(
|
|
||||||
'pagerendered',
|
|
||||||
expect.any(Function)
|
|
||||||
)
|
|
||||||
expect(eventBus.off).toHaveBeenCalledWith('pagesinit', expect.any(Function))
|
|
||||||
expect(eventBus.off).toHaveBeenCalledWith(
|
|
||||||
'pagechanging',
|
|
||||||
expect.any(Function)
|
|
||||||
)
|
|
||||||
expect(resizeObserver.disconnect).toHaveBeenCalled()
|
|
||||||
expect(loadingTask.destroy).toHaveBeenCalled()
|
|
||||||
expect(viewer.cleanup).toHaveBeenCalled()
|
|
||||||
expect((component as any).pdfViewer).toBeUndefined()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('skips work when viewer is missing or has no pages', () => {
|
|
||||||
const eventBus = (component as any).eventBus as { dispatch: jest.Mock }
|
|
||||||
const dispatchSpy = jest.spyOn(eventBus, 'dispatch')
|
|
||||||
;(component as any).dispatchFindIfReady()
|
|
||||||
expect(dispatchSpy).not.toHaveBeenCalled()
|
|
||||||
;(component as any).applyViewerState()
|
|
||||||
;(component as any).applyScale()
|
|
||||||
|
|
||||||
const viewer = new PDFViewer({ eventBus: undefined })
|
|
||||||
viewer.pagesCount = 0
|
|
||||||
;(component as any).pdfViewer = viewer
|
|
||||||
viewer.currentScale = 5
|
|
||||||
;(component as any).applyScale()
|
|
||||||
expect(viewer.currentScale).toBe(5)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('returns early on src change in ngOnChanges', () => {
|
|
||||||
const loadSpy = jest.spyOn(component as any, 'loadDocument')
|
|
||||||
const initSpy = jest.spyOn(component as any, 'initViewer')
|
|
||||||
const scaleSpy = jest.spyOn(component as any, 'applyViewerState')
|
|
||||||
const resizeSpy = jest.spyOn(component as any, 'setupResizeObserver')
|
|
||||||
|
|
||||||
component.ngOnChanges({
|
|
||||||
src: new SimpleChange(undefined, 'test.pdf', true),
|
|
||||||
zoomScale: new SimpleChange(
|
|
||||||
PdfZoomScale.PageWidth,
|
|
||||||
PdfZoomScale.PageFit,
|
|
||||||
false
|
|
||||||
),
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(loadSpy).toHaveBeenCalled()
|
|
||||||
expect(resizeSpy).not.toHaveBeenCalled()
|
|
||||||
expect(initSpy).not.toHaveBeenCalled()
|
|
||||||
expect(scaleSpy).not.toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('applies viewer state after view init when already loaded', () => {
|
|
||||||
const applySpy = jest.spyOn(component as any, 'applyViewerState')
|
|
||||||
;(component as any).hasLoaded = true
|
|
||||||
;(component as any).pdf = { numPages: 1 }
|
|
||||||
|
|
||||||
fixture.detectChanges()
|
|
||||||
|
|
||||||
expect(applySpy).toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('skips viewer state after view init when no pdf is available', () => {
|
|
||||||
const applySpy = jest.spyOn(component as any, 'applyViewerState')
|
|
||||||
;(component as any).hasLoaded = true
|
|
||||||
|
|
||||||
fixture.detectChanges()
|
|
||||||
|
|
||||||
expect(applySpy).not.toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('does not reload when already loaded', async () => {
|
|
||||||
await initComponent()
|
|
||||||
|
|
||||||
const getDocumentSpy = jest.spyOn(pdfjs, 'getDocument')
|
|
||||||
const callCount = getDocumentSpy.mock.calls.length
|
|
||||||
await (component as any).loadDocument()
|
|
||||||
|
|
||||||
expect(getDocumentSpy).toHaveBeenCalledTimes(callCount)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('runs applyScale on resize observer notifications', async () => {
|
|
||||||
await initComponent()
|
|
||||||
|
|
||||||
const applySpy = jest.spyOn(component as any, 'applyScale')
|
|
||||||
const resizeObserver = (component as any).resizeObserver as {
|
|
||||||
trigger: () => void
|
|
||||||
}
|
|
||||||
resizeObserver.trigger()
|
|
||||||
|
|
||||||
expect(applySpy).toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('skips page work when no pages are available', async () => {
|
|
||||||
await initComponent()
|
|
||||||
|
|
||||||
const viewer = (component as any).pdfViewer as PDFViewer
|
|
||||||
viewer.pagesCount = 0
|
|
||||||
const applyScaleSpy = jest.spyOn(component as any, 'applyScale')
|
|
||||||
|
|
||||||
component.page = undefined
|
|
||||||
;(component as any).lastViewerPage = 1
|
|
||||||
;(component as any).applyViewerState()
|
|
||||||
|
|
||||||
expect(applyScaleSpy).not.toHaveBeenCalled()
|
|
||||||
expect((component as any).lastViewerPage).toBe(1)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('falls back to a default zoom when input is invalid', async () => {
|
|
||||||
await initComponent()
|
|
||||||
|
|
||||||
const viewer = (component as any).pdfViewer as PDFViewer
|
|
||||||
viewer.currentScale = 3
|
|
||||||
component.zoom = 'not-a-number' as PdfZoomLevel
|
|
||||||
;(component as any).applyScale()
|
|
||||||
|
|
||||||
expect(viewer.currentScale).toBe(3)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('re-initializes viewer on selectable or render mode changes', async () => {
|
|
||||||
await initComponent()
|
|
||||||
|
|
||||||
const initSpy = jest.spyOn(component as any, 'initViewer')
|
|
||||||
component.selectable = false
|
|
||||||
component.renderMode = PdfRenderMode.Single
|
|
||||||
|
|
||||||
component.ngOnChanges({
|
|
||||||
selectable: new SimpleChange(true, false, false),
|
|
||||||
renderMode: new SimpleChange(
|
|
||||||
PdfRenderMode.All,
|
|
||||||
PdfRenderMode.Single,
|
|
||||||
false
|
|
||||||
),
|
|
||||||
})
|
|
||||||
|
|
||||||
expect(initSpy).toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,266 +0,0 @@
|
|||||||
import {
|
|
||||||
AfterViewInit,
|
|
||||||
Component,
|
|
||||||
ElementRef,
|
|
||||||
EventEmitter,
|
|
||||||
Input,
|
|
||||||
OnChanges,
|
|
||||||
OnDestroy,
|
|
||||||
Output,
|
|
||||||
SimpleChanges,
|
|
||||||
ViewChild,
|
|
||||||
} from '@angular/core'
|
|
||||||
import {
|
|
||||||
getDocument,
|
|
||||||
GlobalWorkerOptions,
|
|
||||||
PDFDocumentLoadingTask,
|
|
||||||
PDFDocumentProxy,
|
|
||||||
} from 'pdfjs-dist/legacy/build/pdf.mjs'
|
|
||||||
import {
|
|
||||||
EventBus,
|
|
||||||
PDFFindController,
|
|
||||||
PDFLinkService,
|
|
||||||
PDFSinglePageViewer,
|
|
||||||
PDFViewer,
|
|
||||||
} from 'pdfjs-dist/web/pdf_viewer.mjs'
|
|
||||||
import {
|
|
||||||
PdfRenderMode,
|
|
||||||
PdfSource,
|
|
||||||
PdfZoomLevel,
|
|
||||||
PdfZoomScale,
|
|
||||||
PngxPdfDocumentProxy,
|
|
||||||
} from './pdf-viewer.types'
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: 'pngx-pdf-viewer',
|
|
||||||
templateUrl: './pdf-viewer.component.html',
|
|
||||||
styleUrl: './pdf-viewer.component.scss',
|
|
||||||
})
|
|
||||||
export class PngxPdfViewerComponent
|
|
||||||
implements AfterViewInit, OnChanges, OnDestroy
|
|
||||||
{
|
|
||||||
@Input() src!: PdfSource
|
|
||||||
@Input() page?: number
|
|
||||||
@Output() pageChange = new EventEmitter<number>()
|
|
||||||
@Input() rotation?: number
|
|
||||||
@Input() renderMode: PdfRenderMode = PdfRenderMode.All
|
|
||||||
@Input() selectable = true
|
|
||||||
@Input() searchQuery = ''
|
|
||||||
@Input() zoom: PdfZoomLevel = PdfZoomLevel.One
|
|
||||||
@Input() zoomScale: PdfZoomScale = PdfZoomScale.PageWidth
|
|
||||||
|
|
||||||
@Output() afterLoadComplete = new EventEmitter<PngxPdfDocumentProxy>()
|
|
||||||
@Output() rendered = new EventEmitter<void>()
|
|
||||||
@Output() loadError = new EventEmitter<unknown>()
|
|
||||||
|
|
||||||
@ViewChild('container', { static: true })
|
|
||||||
private readonly container!: ElementRef<HTMLDivElement>
|
|
||||||
|
|
||||||
@ViewChild('viewer', { static: true })
|
|
||||||
private readonly viewer!: ElementRef<HTMLDivElement>
|
|
||||||
|
|
||||||
private hasLoaded = false
|
|
||||||
private loadingTask?: PDFDocumentLoadingTask
|
|
||||||
private resizeObserver?: ResizeObserver
|
|
||||||
private pdf?: PDFDocumentProxy
|
|
||||||
private pdfViewer?: PDFViewer | PDFSinglePageViewer
|
|
||||||
private hasRenderedPage = false
|
|
||||||
private lastFindQuery = ''
|
|
||||||
private lastViewerPage?: number
|
|
||||||
|
|
||||||
private readonly eventBus = new EventBus()
|
|
||||||
private readonly linkService = new PDFLinkService({ eventBus: this.eventBus })
|
|
||||||
private readonly findController = new PDFFindController({
|
|
||||||
eventBus: this.eventBus,
|
|
||||||
linkService: this.linkService,
|
|
||||||
updateMatchesCountOnProgress: false,
|
|
||||||
})
|
|
||||||
|
|
||||||
private readonly onPageRendered = () => {
|
|
||||||
this.hasRenderedPage = true
|
|
||||||
this.dispatchFindIfReady()
|
|
||||||
this.rendered.emit()
|
|
||||||
}
|
|
||||||
private readonly onPagesInit = () => this.applyScale()
|
|
||||||
private readonly onPageChanging = (evt: { pageNumber: number }) => {
|
|
||||||
// Avoid [(page)] two-way binding re-triggers navigation
|
|
||||||
this.lastViewerPage = evt.pageNumber
|
|
||||||
this.pageChange.emit(evt.pageNumber)
|
|
||||||
}
|
|
||||||
|
|
||||||
ngOnChanges(changes: SimpleChanges): void {
|
|
||||||
if (changes['src']) {
|
|
||||||
this.hasLoaded = false
|
|
||||||
this.loadDocument()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (changes['zoomScale']) {
|
|
||||||
this.setupResizeObserver()
|
|
||||||
}
|
|
||||||
|
|
||||||
if (changes['selectable'] || changes['renderMode']) {
|
|
||||||
this.initViewer()
|
|
||||||
}
|
|
||||||
|
|
||||||
if (
|
|
||||||
changes['page'] ||
|
|
||||||
changes['zoom'] ||
|
|
||||||
changes['zoomScale'] ||
|
|
||||||
changes['rotation']
|
|
||||||
) {
|
|
||||||
this.applyViewerState()
|
|
||||||
}
|
|
||||||
|
|
||||||
if (changes['searchQuery']) {
|
|
||||||
this.dispatchFindIfReady()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ngAfterViewInit(): void {
|
|
||||||
this.setupResizeObserver()
|
|
||||||
this.initViewer()
|
|
||||||
if (!this.hasLoaded) {
|
|
||||||
this.loadDocument()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (this.pdf) {
|
|
||||||
this.applyViewerState()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ngOnDestroy(): void {
|
|
||||||
this.eventBus.off('pagerendered', this.onPageRendered)
|
|
||||||
this.eventBus.off('pagesinit', this.onPagesInit)
|
|
||||||
this.eventBus.off('pagechanging', this.onPageChanging)
|
|
||||||
this.resizeObserver?.disconnect()
|
|
||||||
this.loadingTask?.destroy()
|
|
||||||
this.pdfViewer?.cleanup()
|
|
||||||
this.pdfViewer = undefined
|
|
||||||
}
|
|
||||||
|
|
||||||
private async loadDocument(): Promise<void> {
|
|
||||||
if (this.hasLoaded) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
this.hasLoaded = true
|
|
||||||
this.hasRenderedPage = false
|
|
||||||
this.lastFindQuery = ''
|
|
||||||
this.loadingTask?.destroy()
|
|
||||||
|
|
||||||
GlobalWorkerOptions.workerSrc = '/assets/js/pdf.worker.min.mjs'
|
|
||||||
this.loadingTask = getDocument(this.src)
|
|
||||||
|
|
||||||
try {
|
|
||||||
const pdf = await this.loadingTask.promise
|
|
||||||
this.pdf = pdf
|
|
||||||
this.linkService.setDocument(pdf)
|
|
||||||
this.findController.onIsPageVisible = () => true
|
|
||||||
this.pdfViewer?.setDocument(pdf)
|
|
||||||
this.applyViewerState()
|
|
||||||
this.afterLoadComplete.emit(pdf)
|
|
||||||
} catch (err) {
|
|
||||||
this.loadError.emit(err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private setupResizeObserver(): void {
|
|
||||||
this.resizeObserver?.disconnect()
|
|
||||||
this.resizeObserver = new ResizeObserver(() => {
|
|
||||||
this.applyScale()
|
|
||||||
})
|
|
||||||
this.resizeObserver.observe(this.container.nativeElement)
|
|
||||||
}
|
|
||||||
|
|
||||||
private initViewer(): void {
|
|
||||||
this.viewer.nativeElement.innerHTML = ''
|
|
||||||
this.pdfViewer?.cleanup()
|
|
||||||
this.hasRenderedPage = false
|
|
||||||
this.lastFindQuery = ''
|
|
||||||
|
|
||||||
const textLayerMode = this.selectable === false ? 0 : 1
|
|
||||||
const options = {
|
|
||||||
container: this.container.nativeElement,
|
|
||||||
viewer: this.viewer.nativeElement,
|
|
||||||
eventBus: this.eventBus,
|
|
||||||
linkService: this.linkService,
|
|
||||||
findController: this.findController,
|
|
||||||
textLayerMode,
|
|
||||||
removePageBorders: true,
|
|
||||||
}
|
|
||||||
|
|
||||||
this.pdfViewer =
|
|
||||||
this.renderMode === PdfRenderMode.Single
|
|
||||||
? new PDFSinglePageViewer(options)
|
|
||||||
: new PDFViewer(options)
|
|
||||||
this.linkService.setViewer(this.pdfViewer)
|
|
||||||
|
|
||||||
this.eventBus.off('pagerendered', this.onPageRendered)
|
|
||||||
this.eventBus.off('pagesinit', this.onPagesInit)
|
|
||||||
this.eventBus.off('pagechanging', this.onPageChanging)
|
|
||||||
this.eventBus.on('pagerendered', this.onPageRendered)
|
|
||||||
this.eventBus.on('pagesinit', this.onPagesInit)
|
|
||||||
this.eventBus.on('pagechanging', this.onPageChanging)
|
|
||||||
|
|
||||||
if (this.pdf) {
|
|
||||||
this.pdfViewer.setDocument(this.pdf)
|
|
||||||
this.applyViewerState()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private applyViewerState(): void {
|
|
||||||
if (!this.pdfViewer) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const hasPages = this.pdfViewer.pagesCount > 0
|
|
||||||
if (typeof this.rotation === 'number' && hasPages) {
|
|
||||||
this.pdfViewer.pagesRotation = this.rotation
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
typeof this.page === 'number' &&
|
|
||||||
hasPages &&
|
|
||||||
this.page !== this.lastViewerPage
|
|
||||||
) {
|
|
||||||
this.pdfViewer.currentPageNumber = this.page
|
|
||||||
}
|
|
||||||
if (this.page === this.lastViewerPage) {
|
|
||||||
this.lastViewerPage = undefined
|
|
||||||
}
|
|
||||||
if (hasPages) {
|
|
||||||
this.applyScale()
|
|
||||||
}
|
|
||||||
this.dispatchFindIfReady()
|
|
||||||
}
|
|
||||||
|
|
||||||
private applyScale(): void {
|
|
||||||
if (!this.pdfViewer) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if (this.pdfViewer.pagesCount === 0) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const zoomFactor = Number(this.zoom) || 1
|
|
||||||
this.pdfViewer.currentScaleValue = this.zoomScale
|
|
||||||
if (zoomFactor !== 1) {
|
|
||||||
this.pdfViewer.currentScale = this.pdfViewer.currentScale * zoomFactor
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private dispatchFindIfReady(): void {
|
|
||||||
if (!this.hasRenderedPage) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
const query = this.searchQuery.trim()
|
|
||||||
if (query === this.lastFindQuery) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
this.lastFindQuery = query
|
|
||||||
this.eventBus.dispatch('find', {
|
|
||||||
query,
|
|
||||||
caseSensitive: false,
|
|
||||||
highlightAll: query.length > 0,
|
|
||||||
phraseSearch: true,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,25 +0,0 @@
|
|||||||
export type PngxPdfDocumentProxy = {
|
|
||||||
numPages: number
|
|
||||||
}
|
|
||||||
|
|
||||||
export type PdfSource = string | { url: string; password?: string }
|
|
||||||
|
|
||||||
export enum PdfRenderMode {
|
|
||||||
Single = 'single',
|
|
||||||
All = 'all',
|
|
||||||
}
|
|
||||||
|
|
||||||
export enum PdfZoomScale {
|
|
||||||
PageFit = 'page-fit',
|
|
||||||
PageWidth = 'page-width',
|
|
||||||
}
|
|
||||||
|
|
||||||
export enum PdfZoomLevel {
|
|
||||||
Quarter = '.25',
|
|
||||||
Half = '.5',
|
|
||||||
ThreeQuarters = '.75',
|
|
||||||
One = '1',
|
|
||||||
OneAndHalf = '1.5',
|
|
||||||
Two = '2',
|
|
||||||
Three = '3',
|
|
||||||
}
|
|
||||||
@@ -23,12 +23,14 @@
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@if (!requiresPassword) {
|
@if (!requiresPassword) {
|
||||||
<pngx-pdf-viewer
|
<pdf-viewer
|
||||||
[src]="previewUrl"
|
[src]="previewUrl"
|
||||||
[renderMode]="PdfRenderMode.All"
|
[original-size]="false"
|
||||||
[searchQuery]="documentService.searchQuery"
|
[show-borders]="false"
|
||||||
(loadError)="onError($event)">
|
[show-all]="true"
|
||||||
</pngx-pdf-viewer>
|
(text-layer-rendered)="onPageRendered()"
|
||||||
|
(error)="onError($event)" #pdfViewer>
|
||||||
|
</pdf-viewer>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ import { of, throwError } from 'rxjs'
|
|||||||
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
||||||
import { DocumentService } from 'src/app/services/rest/document.service'
|
import { DocumentService } from 'src/app/services/rest/document.service'
|
||||||
import { SettingsService } from 'src/app/services/settings.service'
|
import { SettingsService } from 'src/app/services/settings.service'
|
||||||
import { PngxPdfViewerComponent } from '../pdf-viewer/pdf-viewer.component'
|
|
||||||
import { PreviewPopupComponent } from './preview-popup.component'
|
import { PreviewPopupComponent } from './preview-popup.component'
|
||||||
|
|
||||||
const doc = {
|
const doc = {
|
||||||
@@ -79,7 +78,7 @@ describe('PreviewPopupComponent', () => {
|
|||||||
component.popover.open()
|
component.popover.open()
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
expect(fixture.debugElement.query(By.css('object'))).toBeNull()
|
expect(fixture.debugElement.query(By.css('object'))).toBeNull()
|
||||||
expect(fixture.debugElement.query(By.css('pngx-pdf-viewer'))).not.toBeNull()
|
expect(fixture.debugElement.query(By.css('pdf-viewer'))).not.toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should show lock icon on password error', () => {
|
it('should show lock icon on password error', () => {
|
||||||
@@ -160,15 +159,23 @@ describe('PreviewPopupComponent', () => {
|
|||||||
expect(component.popover.isOpen()).toBeFalsy()
|
expect(component.popover.isOpen()).toBeFalsy()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should pass searchQuery to viewer', () => {
|
it('should dispatch find event on viewer loaded if searchQuery set', () => {
|
||||||
documentService.searchQuery = 'test'
|
documentService.searchQuery = 'test'
|
||||||
settingsService.set(SETTINGS_KEYS.USE_NATIVE_PDF_VIEWER, false)
|
settingsService.set(SETTINGS_KEYS.USE_NATIVE_PDF_VIEWER, false)
|
||||||
component.popover.open()
|
component.popover.open()
|
||||||
|
jest.advanceTimersByTime(1000)
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
const viewer = fixture.debugElement.query(
|
// normally setup by pdf-viewer
|
||||||
By.directive(PngxPdfViewerComponent)
|
jest.replaceProperty(component.pdfViewer, 'eventBus', {
|
||||||
)
|
dispatch: jest.fn(),
|
||||||
expect(viewer).not.toBeNull()
|
} as any)
|
||||||
expect(viewer.componentInstance.searchQuery).toBe('test')
|
const dispatchSpy = jest.spyOn(component.pdfViewer.eventBus, 'dispatch')
|
||||||
|
component.onPageRendered()
|
||||||
|
expect(dispatchSpy).toHaveBeenCalledWith('find', {
|
||||||
|
query: 'test',
|
||||||
|
caseSensitive: false,
|
||||||
|
highlightAll: true,
|
||||||
|
phraseSearch: true,
|
||||||
|
})
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { HttpClient } from '@angular/common/http'
|
import { HttpClient } from '@angular/common/http'
|
||||||
import { Component, inject, Input, OnDestroy, ViewChild } from '@angular/core'
|
import { Component, inject, Input, OnDestroy, ViewChild } from '@angular/core'
|
||||||
import { NgbPopover, NgbPopoverModule } from '@ng-bootstrap/ng-bootstrap'
|
import { NgbPopover, NgbPopoverModule } from '@ng-bootstrap/ng-bootstrap'
|
||||||
|
import { PdfViewerComponent, PdfViewerModule } from 'ng2-pdf-viewer'
|
||||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||||
import { first, Subject, takeUntil } from 'rxjs'
|
import { first, Subject, takeUntil } from 'rxjs'
|
||||||
import { Document } from 'src/app/data/document'
|
import { Document } from 'src/app/data/document'
|
||||||
@@ -9,8 +10,6 @@ import { DocumentTitlePipe } from 'src/app/pipes/document-title.pipe'
|
|||||||
import { SafeUrlPipe } from 'src/app/pipes/safeurl.pipe'
|
import { SafeUrlPipe } from 'src/app/pipes/safeurl.pipe'
|
||||||
import { DocumentService } from 'src/app/services/rest/document.service'
|
import { DocumentService } from 'src/app/services/rest/document.service'
|
||||||
import { SettingsService } from 'src/app/services/settings.service'
|
import { SettingsService } from 'src/app/services/settings.service'
|
||||||
import { PngxPdfViewerComponent } from '../pdf-viewer/pdf-viewer.component'
|
|
||||||
import { PdfRenderMode } from '../pdf-viewer/pdf-viewer.types'
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'pngx-preview-popup',
|
selector: 'pngx-preview-popup',
|
||||||
@@ -19,15 +18,14 @@ import { PdfRenderMode } from '../pdf-viewer/pdf-viewer.types'
|
|||||||
imports: [
|
imports: [
|
||||||
NgbPopoverModule,
|
NgbPopoverModule,
|
||||||
DocumentTitlePipe,
|
DocumentTitlePipe,
|
||||||
PngxPdfViewerComponent,
|
PdfViewerModule,
|
||||||
SafeUrlPipe,
|
SafeUrlPipe,
|
||||||
NgxBootstrapIconsModule,
|
NgxBootstrapIconsModule,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class PreviewPopupComponent implements OnDestroy {
|
export class PreviewPopupComponent implements OnDestroy {
|
||||||
PdfRenderMode = PdfRenderMode
|
|
||||||
private settingsService = inject(SettingsService)
|
private settingsService = inject(SettingsService)
|
||||||
public readonly documentService = inject(DocumentService)
|
private documentService = inject(DocumentService)
|
||||||
private http = inject(HttpClient)
|
private http = inject(HttpClient)
|
||||||
|
|
||||||
private _document: Document
|
private _document: Document
|
||||||
@@ -63,6 +61,8 @@ export class PreviewPopupComponent implements OnDestroy {
|
|||||||
|
|
||||||
@ViewChild('popover') popover: NgbPopover
|
@ViewChild('popover') popover: NgbPopover
|
||||||
|
|
||||||
|
@ViewChild('pdfViewer') pdfViewer: PdfViewerComponent
|
||||||
|
|
||||||
mouseOnPreview: boolean = false
|
mouseOnPreview: boolean = false
|
||||||
|
|
||||||
popoverClass: string = 'shadow popover-preview'
|
popoverClass: string = 'shadow popover-preview'
|
||||||
@@ -114,6 +114,18 @@ export class PreviewPopupComponent implements OnDestroy {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onPageRendered() {
|
||||||
|
// Only triggered by the pngx pdf viewer
|
||||||
|
if (this.documentService.searchQuery) {
|
||||||
|
this.pdfViewer.eventBus.dispatch('find', {
|
||||||
|
query: this.documentService.searchQuery,
|
||||||
|
caseSensitive: false,
|
||||||
|
highlightAll: true,
|
||||||
|
phraseSearch: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
mouseEnterPreview() {
|
mouseEnterPreview() {
|
||||||
this.mouseOnPreview = true
|
this.mouseOnPreview = true
|
||||||
if (!this.popover.isOpen()) {
|
if (!this.popover.isOpen()) {
|
||||||
|
|||||||
@@ -5,12 +5,8 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'
|
|||||||
import { By } from '@angular/platform-browser'
|
import { By } from '@angular/platform-browser'
|
||||||
import { RouterTestingModule } from '@angular/router/testing'
|
import { RouterTestingModule } from '@angular/router/testing'
|
||||||
import { NgbAlertModule } from '@ng-bootstrap/ng-bootstrap'
|
import { NgbAlertModule } from '@ng-bootstrap/ng-bootstrap'
|
||||||
import { allIcons, NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
||||||
import {
|
import { TourNgBootstrapModule, TourService } from 'ngx-ui-tour-ng-bootstrap'
|
||||||
provideUiTour,
|
|
||||||
TourNgBootstrap,
|
|
||||||
TourService,
|
|
||||||
} from 'ngx-ui-tour-ng-bootstrap'
|
|
||||||
import { of, throwError } from 'rxjs'
|
import { of, throwError } from 'rxjs'
|
||||||
import { SavedView } from 'src/app/data/saved-view'
|
import { SavedView } from 'src/app/data/saved-view'
|
||||||
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
||||||
@@ -79,7 +75,7 @@ describe('DashboardComponent', () => {
|
|||||||
imports: [
|
imports: [
|
||||||
NgbAlertModule,
|
NgbAlertModule,
|
||||||
RouterTestingModule,
|
RouterTestingModule,
|
||||||
TourNgBootstrap,
|
TourNgBootstrapModule,
|
||||||
DragDropModule,
|
DragDropModule,
|
||||||
NgxBootstrapIconsModule.pick(allIcons),
|
NgxBootstrapIconsModule.pick(allIcons),
|
||||||
DashboardComponent,
|
DashboardComponent,
|
||||||
@@ -115,7 +111,6 @@ describe('DashboardComponent', () => {
|
|||||||
},
|
},
|
||||||
provideHttpClient(withInterceptorsFromDi()),
|
provideHttpClient(withInterceptorsFromDi()),
|
||||||
provideHttpClientTesting(),
|
provideHttpClientTesting(),
|
||||||
provideUiTour(),
|
|
||||||
],
|
],
|
||||||
}).compileComponents()
|
}).compileComponents()
|
||||||
|
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import {
|
|||||||
import { Component, inject } from '@angular/core'
|
import { Component, inject } from '@angular/core'
|
||||||
import { RouterModule } from '@angular/router'
|
import { RouterModule } from '@angular/router'
|
||||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||||
import { TourNgBootstrap, TourService } from 'ngx-ui-tour-ng-bootstrap'
|
import { TourNgBootstrapModule, TourService } from 'ngx-ui-tour-ng-bootstrap'
|
||||||
import { SavedView } from 'src/app/data/saved-view'
|
import { SavedView } from 'src/app/data/saved-view'
|
||||||
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
||||||
import { SavedViewService } from 'src/app/services/rest/saved-view.service'
|
import { SavedViewService } from 'src/app/services/rest/saved-view.service'
|
||||||
@@ -36,7 +36,7 @@ import { WelcomeWidgetComponent } from './widgets/welcome-widget/welcome-widget.
|
|||||||
WelcomeWidgetComponent,
|
WelcomeWidgetComponent,
|
||||||
IfPermissionsDirective,
|
IfPermissionsDirective,
|
||||||
DragDropModule,
|
DragDropModule,
|
||||||
TourNgBootstrap,
|
TourNgBootstrapModule,
|
||||||
NgxBootstrapIconsModule,
|
NgxBootstrapIconsModule,
|
||||||
RouterModule,
|
RouterModule,
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import {
|
|||||||
import { By } from '@angular/platform-browser'
|
import { By } from '@angular/platform-browser'
|
||||||
import { RouterTestingModule } from '@angular/router/testing'
|
import { RouterTestingModule } from '@angular/router/testing'
|
||||||
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
||||||
import { provideUiTour } from 'ngx-ui-tour-ng-bootstrap'
|
|
||||||
import { routes } from 'src/app/app-routing.module'
|
import { routes } from 'src/app/app-routing.module'
|
||||||
import { PermissionsGuard } from 'src/app/guards/permissions.guard'
|
import { PermissionsGuard } from 'src/app/guards/permissions.guard'
|
||||||
import { PermissionsService } from 'src/app/services/permissions.service'
|
import { PermissionsService } from 'src/app/services/permissions.service'
|
||||||
@@ -62,7 +61,6 @@ describe('UploadFileWidgetComponent', () => {
|
|||||||
},
|
},
|
||||||
provideHttpClient(withInterceptorsFromDi()),
|
provideHttpClient(withInterceptorsFromDi()),
|
||||||
provideHttpClientTesting(),
|
provideHttpClientTesting(),
|
||||||
provideUiTour(),
|
|
||||||
],
|
],
|
||||||
}).compileComponents()
|
}).compileComponents()
|
||||||
|
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
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 { TourNgBootstrap } from 'ngx-ui-tour-ng-bootstrap'
|
import { TourNgBootstrapModule } from 'ngx-ui-tour-ng-bootstrap'
|
||||||
import { ComponentWithPermissions } from 'src/app/components/with-permissions/with-permissions.component'
|
import { ComponentWithPermissions } from 'src/app/components/with-permissions/with-permissions.component'
|
||||||
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
||||||
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
||||||
@@ -33,7 +33,7 @@ import { WidgetFrameComponent } from '../widget-frame/widget-frame.component'
|
|||||||
NgbAlertModule,
|
NgbAlertModule,
|
||||||
NgbProgressbarModule,
|
NgbProgressbarModule,
|
||||||
NgxBootstrapIconsModule,
|
NgxBootstrapIconsModule,
|
||||||
TourNgBootstrap,
|
TourNgBootstrapModule,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class UploadFileWidgetComponent extends ComponentWithPermissions {
|
export class UploadFileWidgetComponent extends ComponentWithPermissions {
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||||
import { By } from '@angular/platform-browser'
|
import { By } from '@angular/platform-browser'
|
||||||
import { NgbAlert, NgbAlertModule } from '@ng-bootstrap/ng-bootstrap'
|
import { NgbAlert, NgbAlertModule } from '@ng-bootstrap/ng-bootstrap'
|
||||||
import { provideUiTour } from 'ngx-ui-tour-ng-bootstrap'
|
|
||||||
import { PermissionsGuard } from 'src/app/guards/permissions.guard'
|
import { PermissionsGuard } from 'src/app/guards/permissions.guard'
|
||||||
import { WidgetFrameComponent } from '../widget-frame/widget-frame.component'
|
import { WidgetFrameComponent } from '../widget-frame/widget-frame.component'
|
||||||
import { WelcomeWidgetComponent } from './welcome-widget.component'
|
import { WelcomeWidgetComponent } from './welcome-widget.component'
|
||||||
@@ -12,7 +11,7 @@ describe('WelcomeWidgetComponent', () => {
|
|||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
TestBed.configureTestingModule({
|
TestBed.configureTestingModule({
|
||||||
providers: [PermissionsGuard, provideUiTour()],
|
providers: [PermissionsGuard],
|
||||||
imports: [NgbAlertModule, WelcomeWidgetComponent, WidgetFrameComponent],
|
imports: [NgbAlertModule, WelcomeWidgetComponent, WidgetFrameComponent],
|
||||||
}).compileComponents()
|
}).compileComponents()
|
||||||
|
|
||||||
|
|||||||
@@ -456,15 +456,17 @@
|
|||||||
@case (ContentRenderType.PDF) {
|
@case (ContentRenderType.PDF) {
|
||||||
@if (!useNativePdfViewer) {
|
@if (!useNativePdfViewer) {
|
||||||
<div class="preview-sticky pdf-viewer-container">
|
<div class="preview-sticky pdf-viewer-container">
|
||||||
<pngx-pdf-viewer
|
<pdf-viewer
|
||||||
[src]="{ url: previewUrl, password: password }"
|
[src]="{ url: previewUrl, password: password }"
|
||||||
[renderMode]="PdfRenderMode.All"
|
[original-size]="false"
|
||||||
|
[show-borders]="true"
|
||||||
|
[show-all]="true"
|
||||||
[(page)]="previewCurrentPage"
|
[(page)]="previewCurrentPage"
|
||||||
[zoomScale]="previewZoomScale"
|
[zoom-scale]="previewZoomScale"
|
||||||
[zoom]="previewZoomSetting"
|
[zoom]="previewZoomSetting"
|
||||||
(loadError)="onError($event)"
|
(error)="onError($event)"
|
||||||
(afterLoadComplete)="pdfPreviewLoaded($event)">
|
(after-load-complete)="pdfPreviewLoaded($event)">
|
||||||
</pngx-pdf-viewer>
|
</pdf-viewer>
|
||||||
</div>
|
</div>
|
||||||
} @else {
|
} @else {
|
||||||
<object [data]="previewUrl | safeUrl" class="preview-sticky" width="100%"></object>
|
<object [data]="previewUrl | safeUrl" class="preview-sticky" width="100%"></object>
|
||||||
|
|||||||
@@ -5,15 +5,20 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.pdf-viewer-container {
|
.pdf-viewer-container {
|
||||||
padding: 8px;
|
padding-top: 10px;
|
||||||
background-color: gray;
|
background-color: gray;
|
||||||
|
|
||||||
pngx-pdf-viewer {
|
pdf-viewer {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
::ng-deep .ng2-pdf-viewer-container .page {
|
||||||
|
--page-margin: 0 auto 10px;
|
||||||
|
--page-border: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.btn-group .dropdown-toggle-split {
|
.btn-group .dropdown-toggle-split {
|
||||||
border-top-right-radius: inherit;
|
border-top-right-radius: inherit;
|
||||||
border-bottom-right-radius: inherit;
|
border-bottom-right-radius: inherit;
|
||||||
|
|||||||
@@ -69,11 +69,8 @@ import { environment } from 'src/environments/environment'
|
|||||||
import { ConfirmDialogComponent } from '../common/confirm-dialog/confirm-dialog.component'
|
import { ConfirmDialogComponent } from '../common/confirm-dialog/confirm-dialog.component'
|
||||||
import { PasswordRemovalConfirmDialogComponent } from '../common/confirm-dialog/password-removal-confirm-dialog/password-removal-confirm-dialog.component'
|
import { PasswordRemovalConfirmDialogComponent } from '../common/confirm-dialog/password-removal-confirm-dialog/password-removal-confirm-dialog.component'
|
||||||
import { CustomFieldsDropdownComponent } from '../common/custom-fields-dropdown/custom-fields-dropdown.component'
|
import { CustomFieldsDropdownComponent } from '../common/custom-fields-dropdown/custom-fields-dropdown.component'
|
||||||
import {
|
|
||||||
PdfZoomLevel,
|
|
||||||
PdfZoomScale,
|
|
||||||
} from '../common/pdf-viewer/pdf-viewer.types'
|
|
||||||
import { DocumentDetailComponent } from './document-detail.component'
|
import { DocumentDetailComponent } from './document-detail.component'
|
||||||
|
import { ZoomSetting } from './zoom-setting'
|
||||||
|
|
||||||
const doc: Document = {
|
const doc: Document = {
|
||||||
id: 3,
|
id: 3,
|
||||||
@@ -863,7 +860,7 @@ describe('DocumentDetailComponent', () => {
|
|||||||
|
|
||||||
it('should support zoom controls', () => {
|
it('should support zoom controls', () => {
|
||||||
initNormally()
|
initNormally()
|
||||||
component.setZoom(PdfZoomLevel.One) // from select
|
component.setZoom(ZoomSetting.One) // from select
|
||||||
expect(component.previewZoomSetting).toEqual('1')
|
expect(component.previewZoomSetting).toEqual('1')
|
||||||
component.increaseZoom()
|
component.increaseZoom()
|
||||||
expect(component.previewZoomSetting).toEqual('1.5')
|
expect(component.previewZoomSetting).toEqual('1.5')
|
||||||
@@ -871,18 +868,18 @@ describe('DocumentDetailComponent', () => {
|
|||||||
expect(component.previewZoomSetting).toEqual('2')
|
expect(component.previewZoomSetting).toEqual('2')
|
||||||
component.decreaseZoom()
|
component.decreaseZoom()
|
||||||
expect(component.previewZoomSetting).toEqual('1.5')
|
expect(component.previewZoomSetting).toEqual('1.5')
|
||||||
component.setZoom(PdfZoomLevel.One) // from select
|
component.setZoom(ZoomSetting.One) // from select
|
||||||
component.decreaseZoom()
|
component.decreaseZoom()
|
||||||
expect(component.previewZoomSetting).toEqual('.75')
|
expect(component.previewZoomSetting).toEqual('.75')
|
||||||
|
|
||||||
component.setZoom(PdfZoomScale.PageFit) // from select
|
component.setZoom(ZoomSetting.PageFit) // from select
|
||||||
expect(component.previewZoomScale).toEqual('page-fit')
|
expect(component.previewZoomScale).toEqual('page-fit')
|
||||||
expect(component.previewZoomSetting).toEqual('1')
|
expect(component.previewZoomSetting).toEqual('1')
|
||||||
component.increaseZoom()
|
component.increaseZoom()
|
||||||
expect(component.previewZoomSetting).toEqual('1.5')
|
expect(component.previewZoomSetting).toEqual('1.5')
|
||||||
expect(component.previewZoomScale).toEqual('page-width')
|
expect(component.previewZoomScale).toEqual('page-width')
|
||||||
|
|
||||||
component.setZoom(PdfZoomScale.PageFit) // from select
|
component.setZoom(ZoomSetting.PageFit) // from select
|
||||||
expect(component.previewZoomScale).toEqual('page-fit')
|
expect(component.previewZoomScale).toEqual('page-fit')
|
||||||
expect(component.previewZoomSetting).toEqual('1')
|
expect(component.previewZoomSetting).toEqual('1')
|
||||||
component.decreaseZoom()
|
component.decreaseZoom()
|
||||||
@@ -892,10 +889,10 @@ describe('DocumentDetailComponent', () => {
|
|||||||
|
|
||||||
it('should select correct zoom setting in dropdown', () => {
|
it('should select correct zoom setting in dropdown', () => {
|
||||||
initNormally()
|
initNormally()
|
||||||
component.setZoom(PdfZoomScale.PageFit)
|
component.setZoom(ZoomSetting.PageFit)
|
||||||
expect(component.currentZoom).toEqual(PdfZoomScale.PageFit)
|
expect(component.currentZoom).toEqual(ZoomSetting.PageFit)
|
||||||
component.setZoom(PdfZoomLevel.Quarter)
|
component.setZoom(ZoomSetting.Quarter)
|
||||||
expect(component.currentZoom).toEqual(PdfZoomLevel.Quarter)
|
expect(component.currentZoom).toEqual(ZoomSetting.Quarter)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should support updating notes dynamically', () => {
|
it('should support updating notes dynamically', () => {
|
||||||
@@ -1020,7 +1017,7 @@ describe('DocumentDetailComponent', () => {
|
|||||||
settingsService.set(SETTINGS_KEYS.USE_NATIVE_PDF_VIEWER, false)
|
settingsService.set(SETTINGS_KEYS.USE_NATIVE_PDF_VIEWER, false)
|
||||||
expect(component.useNativePdfViewer).toBeFalsy()
|
expect(component.useNativePdfViewer).toBeFalsy()
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
expect(fixture.debugElement.query(By.css('pngx-pdf-viewer'))).not.toBeNull()
|
expect(fixture.debugElement.query(By.css('pdf-viewer'))).not.toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should display native pdf viewer if enabled', () => {
|
it('should display native pdf viewer if enabled', () => {
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
NgbNavModule,
|
NgbNavModule,
|
||||||
} from '@ng-bootstrap/ng-bootstrap'
|
} from '@ng-bootstrap/ng-bootstrap'
|
||||||
import { dirtyCheck, DirtyComponent } from '@ngneat/dirty-check-forms'
|
import { dirtyCheck, DirtyComponent } from '@ngneat/dirty-check-forms'
|
||||||
|
import { PDFDocumentProxy, PdfViewerModule } from 'ng2-pdf-viewer'
|
||||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||||
import { DeviceDetectorService } from 'ngx-device-detector'
|
import { DeviceDetectorService } from 'ngx-device-detector'
|
||||||
import { BehaviorSubject, Observable, of, Subject, timer } from 'rxjs'
|
import { BehaviorSubject, Observable, of, Subject, timer } from 'rxjs'
|
||||||
@@ -107,19 +108,13 @@ import { UrlComponent } from '../common/input/url/url.component'
|
|||||||
import { PageHeaderComponent } from '../common/page-header/page-header.component'
|
import { PageHeaderComponent } from '../common/page-header/page-header.component'
|
||||||
import { PdfEditorEditMode } from '../common/pdf-editor/pdf-editor-edit-mode'
|
import { PdfEditorEditMode } from '../common/pdf-editor/pdf-editor-edit-mode'
|
||||||
import { PDFEditorComponent } from '../common/pdf-editor/pdf-editor.component'
|
import { PDFEditorComponent } from '../common/pdf-editor/pdf-editor.component'
|
||||||
import { PngxPdfViewerComponent } from '../common/pdf-viewer/pdf-viewer.component'
|
|
||||||
import {
|
|
||||||
PdfRenderMode,
|
|
||||||
PdfZoomLevel,
|
|
||||||
PdfZoomScale,
|
|
||||||
PngxPdfDocumentProxy,
|
|
||||||
} from '../common/pdf-viewer/pdf-viewer.types'
|
|
||||||
import { ShareLinksDialogComponent } from '../common/share-links-dialog/share-links-dialog.component'
|
import { ShareLinksDialogComponent } from '../common/share-links-dialog/share-links-dialog.component'
|
||||||
import { SuggestionsDropdownComponent } from '../common/suggestions-dropdown/suggestions-dropdown.component'
|
import { SuggestionsDropdownComponent } from '../common/suggestions-dropdown/suggestions-dropdown.component'
|
||||||
|
import { DocumentHistoryComponent } from '../document-history/document-history.component'
|
||||||
import { DocumentNotesComponent } from '../document-notes/document-notes.component'
|
import { DocumentNotesComponent } from '../document-notes/document-notes.component'
|
||||||
import { ComponentWithPermissions } from '../with-permissions/with-permissions.component'
|
import { ComponentWithPermissions } from '../with-permissions/with-permissions.component'
|
||||||
import { DocumentHistoryComponent } from './document-history/document-history.component'
|
|
||||||
import { MetadataCollapseComponent } from './metadata-collapse/metadata-collapse.component'
|
import { MetadataCollapseComponent } from './metadata-collapse/metadata-collapse.component'
|
||||||
|
import { ZoomSetting } from './zoom-setting'
|
||||||
|
|
||||||
enum DocumentDetailNavIDs {
|
enum DocumentDetailNavIDs {
|
||||||
Details = 1,
|
Details = 1,
|
||||||
@@ -173,17 +168,16 @@ enum ContentRenderType {
|
|||||||
NgbNavModule,
|
NgbNavModule,
|
||||||
NgbDropdownModule,
|
NgbDropdownModule,
|
||||||
NgxBootstrapIconsModule,
|
NgxBootstrapIconsModule,
|
||||||
|
PdfViewerModule,
|
||||||
TextAreaComponent,
|
TextAreaComponent,
|
||||||
RouterModule,
|
RouterModule,
|
||||||
PngxPdfViewerComponent,
|
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class DocumentDetailComponent
|
export class DocumentDetailComponent
|
||||||
extends ComponentWithPermissions
|
extends ComponentWithPermissions
|
||||||
implements OnInit, OnDestroy, DirtyComponent
|
implements OnInit, OnDestroy, DirtyComponent
|
||||||
{
|
{
|
||||||
PdfRenderMode = PdfRenderMode
|
private documentsService = inject(DocumentService)
|
||||||
documentsService = inject(DocumentService)
|
|
||||||
private route = inject(ActivatedRoute)
|
private route = inject(ActivatedRoute)
|
||||||
private tagService = inject(TagService)
|
private tagService = inject(TagService)
|
||||||
private correspondentService = inject(CorrespondentService)
|
private correspondentService = inject(CorrespondentService)
|
||||||
@@ -252,8 +246,8 @@ export class DocumentDetailComponent
|
|||||||
|
|
||||||
previewCurrentPage: number = 1
|
previewCurrentPage: number = 1
|
||||||
previewNumPages: number
|
previewNumPages: number
|
||||||
previewZoomSetting: PdfZoomLevel = PdfZoomLevel.One
|
previewZoomSetting: ZoomSetting = ZoomSetting.One
|
||||||
previewZoomScale: PdfZoomScale = PdfZoomScale.PageWidth
|
previewZoomScale: ZoomSetting = ZoomSetting.PageWidth
|
||||||
|
|
||||||
store: BehaviorSubject<any>
|
store: BehaviorSubject<any>
|
||||||
isDirty$: Observable<boolean>
|
isDirty$: Observable<boolean>
|
||||||
@@ -509,9 +503,7 @@ export class DocumentDetailComponent
|
|||||||
}
|
}
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
this.setZoom(
|
this.setZoom(this.settings.get(SETTINGS_KEYS.PDF_VIEWER_ZOOM_SETTING))
|
||||||
this.settings.get(SETTINGS_KEYS.PDF_VIEWER_ZOOM_SETTING) as PdfZoomScale
|
|
||||||
)
|
|
||||||
this.documentForm.valueChanges
|
this.documentForm.valueChanges
|
||||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||||
.subscribe((values) => {
|
.subscribe((values) => {
|
||||||
@@ -1212,7 +1204,7 @@ export class DocumentDetailComponent
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
pdfPreviewLoaded(pdf: PngxPdfDocumentProxy) {
|
pdfPreviewLoaded(pdf: PDFDocumentProxy) {
|
||||||
this.previewNumPages = pdf.numPages
|
this.previewNumPages = pdf.numPages
|
||||||
if (this.password) this.requiresPassword = false
|
if (this.password) this.requiresPassword = false
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@@ -1233,33 +1225,31 @@ export class DocumentDetailComponent
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
setZoom(setting: PdfZoomScale | PdfZoomLevel) {
|
setZoom(setting: ZoomSetting) {
|
||||||
if (
|
if (ZoomSetting.PageFit === setting || ZoomSetting.PageWidth === setting) {
|
||||||
setting === PdfZoomScale.PageFit ||
|
|
||||||
setting === PdfZoomScale.PageWidth
|
|
||||||
) {
|
|
||||||
this.previewZoomScale = setting
|
this.previewZoomScale = setting
|
||||||
this.previewZoomSetting = PdfZoomLevel.One
|
this.previewZoomSetting = ZoomSetting.One
|
||||||
return
|
} else {
|
||||||
}
|
|
||||||
this.previewZoomSetting = setting
|
this.previewZoomSetting = setting
|
||||||
this.previewZoomScale = PdfZoomScale.PageWidth
|
this.previewZoomScale = ZoomSetting.PageWidth
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
get zoomSettings() {
|
get zoomSettings() {
|
||||||
return [PdfZoomScale.PageFit, ...Object.values(PdfZoomLevel)]
|
return Object.values(ZoomSetting).filter(
|
||||||
|
(setting) => setting !== ZoomSetting.PageWidth
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
get currentZoom() {
|
get currentZoom() {
|
||||||
if (this.previewZoomScale === PdfZoomScale.PageFit) {
|
if (this.previewZoomScale === ZoomSetting.PageFit) {
|
||||||
return PdfZoomScale.PageFit
|
return ZoomSetting.PageFit
|
||||||
}
|
} else return this.previewZoomSetting
|
||||||
return this.previewZoomSetting
|
|
||||||
}
|
}
|
||||||
|
|
||||||
getZoomSettingTitle(setting: PdfZoomScale | PdfZoomLevel): string {
|
getZoomSettingTitle(setting: ZoomSetting): string {
|
||||||
switch (setting) {
|
switch (setting) {
|
||||||
case PdfZoomScale.PageFit:
|
case ZoomSetting.PageFit:
|
||||||
return $localize`Page Fit`
|
return $localize`Page Fit`
|
||||||
default:
|
default:
|
||||||
return `${parseFloat(setting) * 100}%`
|
return `${parseFloat(setting) * 100}%`
|
||||||
@@ -1267,24 +1257,25 @@ export class DocumentDetailComponent
|
|||||||
}
|
}
|
||||||
|
|
||||||
increaseZoom(): void {
|
increaseZoom(): void {
|
||||||
const zoomLevels = Object.values(PdfZoomLevel)
|
let currentIndex = Object.values(ZoomSetting).indexOf(
|
||||||
let currentIndex = zoomLevels.indexOf(this.previewZoomSetting)
|
this.previewZoomSetting
|
||||||
if (this.previewZoomScale === PdfZoomScale.PageFit) {
|
)
|
||||||
currentIndex = zoomLevels.indexOf(PdfZoomLevel.One)
|
if (this.previewZoomScale === ZoomSetting.PageFit) currentIndex = 5
|
||||||
}
|
this.previewZoomScale = ZoomSetting.PageWidth
|
||||||
this.previewZoomScale = PdfZoomScale.PageWidth
|
|
||||||
this.previewZoomSetting =
|
this.previewZoomSetting =
|
||||||
zoomLevels[Math.min(zoomLevels.length - 1, currentIndex + 1)]
|
Object.values(ZoomSetting)[
|
||||||
|
Math.min(Object.values(ZoomSetting).length - 1, currentIndex + 1)
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
decreaseZoom(): void {
|
decreaseZoom(): void {
|
||||||
const zoomLevels = Object.values(PdfZoomLevel)
|
let currentIndex = Object.values(ZoomSetting).indexOf(
|
||||||
let currentIndex = zoomLevels.indexOf(this.previewZoomSetting)
|
this.previewZoomSetting
|
||||||
if (this.previewZoomScale === PdfZoomScale.PageFit) {
|
)
|
||||||
currentIndex = zoomLevels.indexOf(PdfZoomLevel.ThreeQuarters)
|
if (this.previewZoomScale === ZoomSetting.PageFit) currentIndex = 4
|
||||||
}
|
this.previewZoomScale = ZoomSetting.PageWidth
|
||||||
this.previewZoomScale = PdfZoomScale.PageWidth
|
this.previewZoomSetting =
|
||||||
this.previewZoomSetting = zoomLevels[Math.max(0, currentIndex - 1)]
|
Object.values(ZoomSetting)[Math.max(2, currentIndex - 1)]
|
||||||
}
|
}
|
||||||
|
|
||||||
get showPermissions(): boolean {
|
get showPermissions(): boolean {
|
||||||
|
|||||||
@@ -1,114 +0,0 @@
|
|||||||
import { AsyncPipe, KeyValuePipe, TitleCasePipe } from '@angular/common'
|
|
||||||
import { Component, Input, OnInit, inject } from '@angular/core'
|
|
||||||
import { NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap'
|
|
||||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
|
||||||
import { Observable, first, map, of, shareReplay } from 'rxjs'
|
|
||||||
import { AuditLogAction, AuditLogEntry } from 'src/app/data/auditlog-entry'
|
|
||||||
import { DataType } from 'src/app/data/datatype'
|
|
||||||
import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
|
|
||||||
import { CorrespondentService } from 'src/app/services/rest/correspondent.service'
|
|
||||||
import { DocumentTypeService } from 'src/app/services/rest/document-type.service'
|
|
||||||
import { DocumentService } from 'src/app/services/rest/document.service'
|
|
||||||
import { StoragePathService } from 'src/app/services/rest/storage-path.service'
|
|
||||||
import { UserService } from 'src/app/services/rest/user.service'
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: 'pngx-document-history',
|
|
||||||
templateUrl: './document-history.component.html',
|
|
||||||
styleUrl: './document-history.component.scss',
|
|
||||||
imports: [
|
|
||||||
CustomDatePipe,
|
|
||||||
NgbTooltipModule,
|
|
||||||
AsyncPipe,
|
|
||||||
KeyValuePipe,
|
|
||||||
TitleCasePipe,
|
|
||||||
NgxBootstrapIconsModule,
|
|
||||||
],
|
|
||||||
})
|
|
||||||
export class DocumentHistoryComponent implements OnInit {
|
|
||||||
private documentService = inject(DocumentService)
|
|
||||||
private correspondentService = inject(CorrespondentService)
|
|
||||||
private storagePathService = inject(StoragePathService)
|
|
||||||
private documentTypeService = inject(DocumentTypeService)
|
|
||||||
private userService = inject(UserService)
|
|
||||||
|
|
||||||
public AuditLogAction = AuditLogAction
|
|
||||||
|
|
||||||
private _documentId: number
|
|
||||||
@Input()
|
|
||||||
set documentId(id: number) {
|
|
||||||
if (this._documentId !== id) {
|
|
||||||
this._documentId = id
|
|
||||||
this.prettyNameCache.clear()
|
|
||||||
this.loadHistory()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
public loading: boolean = true
|
|
||||||
public entries: AuditLogEntry[] = []
|
|
||||||
|
|
||||||
private readonly prettyNameCache = new Map<string, Observable<string>>()
|
|
||||||
|
|
||||||
ngOnInit(): void {
|
|
||||||
this.loadHistory()
|
|
||||||
}
|
|
||||||
|
|
||||||
private loadHistory(): void {
|
|
||||||
if (this._documentId) {
|
|
||||||
this.loading = true
|
|
||||||
this.documentService.getHistory(this._documentId).subscribe((entries) => {
|
|
||||||
this.entries = entries
|
|
||||||
this.loading = false
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
getPrettyName(type: DataType | string, id: string): Observable<string> {
|
|
||||||
const cacheKey = `${type}:${id}`
|
|
||||||
const cached = this.prettyNameCache.get(cacheKey)
|
|
||||||
if (cached) {
|
|
||||||
return cached
|
|
||||||
}
|
|
||||||
|
|
||||||
const idInt = parseInt(id, 10)
|
|
||||||
const fallback$ = of(id)
|
|
||||||
|
|
||||||
let result$: Observable<string>
|
|
||||||
if (!Number.isFinite(idInt)) {
|
|
||||||
result$ = fallback$
|
|
||||||
} else {
|
|
||||||
switch (type) {
|
|
||||||
case DataType.Correspondent:
|
|
||||||
result$ = this.correspondentService.getCached(idInt).pipe(
|
|
||||||
first(),
|
|
||||||
map((correspondent) => correspondent?.name ?? id)
|
|
||||||
)
|
|
||||||
break
|
|
||||||
case DataType.DocumentType:
|
|
||||||
result$ = this.documentTypeService.getCached(idInt).pipe(
|
|
||||||
first(),
|
|
||||||
map((documentType) => documentType?.name ?? id)
|
|
||||||
)
|
|
||||||
break
|
|
||||||
case DataType.StoragePath:
|
|
||||||
result$ = this.storagePathService.getCached(idInt).pipe(
|
|
||||||
first(),
|
|
||||||
map((storagePath) => storagePath?.path ?? id)
|
|
||||||
)
|
|
||||||
break
|
|
||||||
case 'owner':
|
|
||||||
result$ = this.userService.getCached(idInt).pipe(
|
|
||||||
first(),
|
|
||||||
map((user) => user?.username ?? id)
|
|
||||||
)
|
|
||||||
break
|
|
||||||
default:
|
|
||||||
result$ = fallback$
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const shared$ = result$.pipe(shareReplay({ bufferSize: 1, refCount: true }))
|
|
||||||
this.prettyNameCache.set(cacheKey, shared$)
|
|
||||||
return shared$
|
|
||||||
}
|
|
||||||
}
|
|
||||||
11
src-ui/src/app/components/document-detail/zoom-setting.ts
Normal file
11
src-ui/src/app/components/document-detail/zoom-setting.ts
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
export enum ZoomSetting {
|
||||||
|
PageFit = 'page-fit',
|
||||||
|
PageWidth = 'page-width',
|
||||||
|
Quarter = '.25',
|
||||||
|
Half = '.5',
|
||||||
|
ThreeQuarters = '.75',
|
||||||
|
One = '1',
|
||||||
|
OneAndHalf = '1.5',
|
||||||
|
Two = '2',
|
||||||
|
Three = '3',
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
@if (loading) {
|
@if (loading) {
|
||||||
<div class="d-flex">
|
<div class="d-flex">
|
||||||
<output class="spinner-border spinner-border-sm fw-normal" role="status"></output>
|
<div class="spinner-border spinner-border-sm fw-normal" role="status"></div>
|
||||||
</div>
|
</div>
|
||||||
} @else {
|
} @else {
|
||||||
<ul class="list-group">
|
<ul class="list-group">
|
||||||
@@ -83,22 +83,8 @@ describe('DocumentHistoryComponent', () => {
|
|||||||
expect(result).toBe(correspondentName)
|
expect(result).toBe(correspondentName)
|
||||||
})
|
})
|
||||||
expect(getCachedSpy).toHaveBeenCalledWith(parseInt(correspondentId))
|
expect(getCachedSpy).toHaveBeenCalledWith(parseInt(correspondentId))
|
||||||
})
|
// no correspondent found
|
||||||
|
getCachedSpy.mockReturnValue(of(null))
|
||||||
it('getPrettyName should memoize results to avoid resubscribe loops', () => {
|
|
||||||
const correspondentId = '1'
|
|
||||||
const getCachedSpy = jest
|
|
||||||
.spyOn(correspondentService, 'getCached')
|
|
||||||
.mockReturnValue(of({ name: 'John Doe' }))
|
|
||||||
const a = component.getPrettyName(DataType.Correspondent, correspondentId)
|
|
||||||
const b = component.getPrettyName(DataType.Correspondent, correspondentId)
|
|
||||||
expect(a).toBe(b)
|
|
||||||
expect(getCachedSpy).toHaveBeenCalledTimes(1)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('getPrettyName should fall back to the correspondent id when missing', () => {
|
|
||||||
const correspondentId = '1'
|
|
||||||
jest.spyOn(correspondentService, 'getCached').mockReturnValue(of(null))
|
|
||||||
component
|
component
|
||||||
.getPrettyName(DataType.Correspondent, correspondentId)
|
.getPrettyName(DataType.Correspondent, correspondentId)
|
||||||
.subscribe((result) => {
|
.subscribe((result) => {
|
||||||
@@ -118,11 +104,8 @@ describe('DocumentHistoryComponent', () => {
|
|||||||
expect(result).toBe(documentTypeName)
|
expect(result).toBe(documentTypeName)
|
||||||
})
|
})
|
||||||
expect(getCachedSpy).toHaveBeenCalledWith(parseInt(documentTypeId))
|
expect(getCachedSpy).toHaveBeenCalledWith(parseInt(documentTypeId))
|
||||||
})
|
// no document type found
|
||||||
|
getCachedSpy.mockReturnValue(of(null))
|
||||||
it('getPrettyName should fall back to the document type id when missing', () => {
|
|
||||||
const documentTypeId = '1'
|
|
||||||
jest.spyOn(documentTypeService, 'getCached').mockReturnValue(of(null))
|
|
||||||
component
|
component
|
||||||
.getPrettyName(DataType.DocumentType, documentTypeId)
|
.getPrettyName(DataType.DocumentType, documentTypeId)
|
||||||
.subscribe((result) => {
|
.subscribe((result) => {
|
||||||
@@ -142,11 +125,8 @@ describe('DocumentHistoryComponent', () => {
|
|||||||
expect(result).toBe(storagePath)
|
expect(result).toBe(storagePath)
|
||||||
})
|
})
|
||||||
expect(getCachedSpy).toHaveBeenCalledWith(parseInt(storagePathId))
|
expect(getCachedSpy).toHaveBeenCalledWith(parseInt(storagePathId))
|
||||||
})
|
// no storage path found
|
||||||
|
getCachedSpy.mockReturnValue(of(null))
|
||||||
it('getPrettyName should fall back to the storage path id when missing', () => {
|
|
||||||
const storagePathId = '1'
|
|
||||||
jest.spyOn(storagePathService, 'getCached').mockReturnValue(of(null))
|
|
||||||
component
|
component
|
||||||
.getPrettyName(DataType.StoragePath, storagePathId)
|
.getPrettyName(DataType.StoragePath, storagePathId)
|
||||||
.subscribe((result) => {
|
.subscribe((result) => {
|
||||||
@@ -164,11 +144,8 @@ describe('DocumentHistoryComponent', () => {
|
|||||||
expect(result).toBe(ownerUsername)
|
expect(result).toBe(ownerUsername)
|
||||||
})
|
})
|
||||||
expect(getCachedSpy).toHaveBeenCalledWith(parseInt(ownerId))
|
expect(getCachedSpy).toHaveBeenCalledWith(parseInt(ownerId))
|
||||||
})
|
// no user found
|
||||||
|
getCachedSpy.mockReturnValue(of(null))
|
||||||
it('getPrettyName should fall back to the owner id when missing', () => {
|
|
||||||
const ownerId = '1'
|
|
||||||
jest.spyOn(userService, 'getCached').mockReturnValue(of(null))
|
|
||||||
component.getPrettyName('owner', ownerId).subscribe((result) => {
|
component.getPrettyName('owner', ownerId).subscribe((result) => {
|
||||||
expect(result).toBe(ownerId)
|
expect(result).toBe(ownerId)
|
||||||
})
|
})
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import { AsyncPipe, KeyValuePipe, TitleCasePipe } from '@angular/common'
|
||||||
|
import { Component, Input, OnInit, inject } from '@angular/core'
|
||||||
|
import { NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap'
|
||||||
|
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||||
|
import { Observable, first, map, of } from 'rxjs'
|
||||||
|
import { AuditLogAction, AuditLogEntry } from 'src/app/data/auditlog-entry'
|
||||||
|
import { DataType } from 'src/app/data/datatype'
|
||||||
|
import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
|
||||||
|
import { CorrespondentService } from 'src/app/services/rest/correspondent.service'
|
||||||
|
import { DocumentTypeService } from 'src/app/services/rest/document-type.service'
|
||||||
|
import { DocumentService } from 'src/app/services/rest/document.service'
|
||||||
|
import { StoragePathService } from 'src/app/services/rest/storage-path.service'
|
||||||
|
import { UserService } from 'src/app/services/rest/user.service'
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'pngx-document-history',
|
||||||
|
templateUrl: './document-history.component.html',
|
||||||
|
styleUrl: './document-history.component.scss',
|
||||||
|
imports: [
|
||||||
|
CustomDatePipe,
|
||||||
|
NgbTooltipModule,
|
||||||
|
AsyncPipe,
|
||||||
|
KeyValuePipe,
|
||||||
|
TitleCasePipe,
|
||||||
|
NgxBootstrapIconsModule,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class DocumentHistoryComponent implements OnInit {
|
||||||
|
private documentService = inject(DocumentService)
|
||||||
|
private correspondentService = inject(CorrespondentService)
|
||||||
|
private storagePathService = inject(StoragePathService)
|
||||||
|
private documentTypeService = inject(DocumentTypeService)
|
||||||
|
private userService = inject(UserService)
|
||||||
|
|
||||||
|
public AuditLogAction = AuditLogAction
|
||||||
|
|
||||||
|
private _documentId: number
|
||||||
|
@Input()
|
||||||
|
set documentId(id: number) {
|
||||||
|
this._documentId = id
|
||||||
|
this.ngOnInit()
|
||||||
|
}
|
||||||
|
|
||||||
|
public loading: boolean = true
|
||||||
|
public entries: AuditLogEntry[] = []
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
if (this._documentId) {
|
||||||
|
this.loading = true
|
||||||
|
this.documentService
|
||||||
|
.getHistory(this._documentId)
|
||||||
|
.subscribe((auditLogEntries) => {
|
||||||
|
this.entries = auditLogEntries
|
||||||
|
this.loading = false
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
getPrettyName(type: DataType | string, id: string): Observable<string> {
|
||||||
|
switch (type) {
|
||||||
|
case DataType.Correspondent:
|
||||||
|
return this.correspondentService.getCached(parseInt(id, 10)).pipe(
|
||||||
|
first(),
|
||||||
|
map((correspondent) => correspondent?.name ?? id)
|
||||||
|
)
|
||||||
|
case DataType.DocumentType:
|
||||||
|
return this.documentTypeService.getCached(parseInt(id, 10)).pipe(
|
||||||
|
first(),
|
||||||
|
map((documentType) => documentType?.name ?? id)
|
||||||
|
)
|
||||||
|
case DataType.StoragePath:
|
||||||
|
return this.storagePathService.getCached(parseInt(id, 10)).pipe(
|
||||||
|
first(),
|
||||||
|
map((storagePath) => storagePath?.path ?? id)
|
||||||
|
)
|
||||||
|
case 'owner':
|
||||||
|
return this.userService.getCached(parseInt(id, 10)).pipe(
|
||||||
|
first(),
|
||||||
|
map((user) => user?.username ?? id)
|
||||||
|
)
|
||||||
|
default:
|
||||||
|
return of(id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,7 +16,6 @@ import {
|
|||||||
NgbModalRef,
|
NgbModalRef,
|
||||||
} from '@ng-bootstrap/ng-bootstrap'
|
} from '@ng-bootstrap/ng-bootstrap'
|
||||||
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
||||||
import { provideUiTour } from 'ngx-ui-tour-ng-bootstrap'
|
|
||||||
import { Subject, of, throwError } from 'rxjs'
|
import { Subject, of, throwError } from 'rxjs'
|
||||||
import { routes } from 'src/app/app-routing.module'
|
import { routes } from 'src/app/app-routing.module'
|
||||||
import {
|
import {
|
||||||
@@ -106,7 +105,6 @@ describe('DocumentListComponent', () => {
|
|||||||
PermissionsGuard,
|
PermissionsGuard,
|
||||||
provideHttpClient(withInterceptorsFromDi()),
|
provideHttpClient(withInterceptorsFromDi()),
|
||||||
provideHttpClientTesting(),
|
provideHttpClientTesting(),
|
||||||
provideUiTour(),
|
|
||||||
],
|
],
|
||||||
}).compileComponents()
|
}).compileComponents()
|
||||||
|
|
||||||
|
|||||||
@@ -21,7 +21,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 { TourNgBootstrap } from 'ngx-ui-tour-ng-bootstrap'
|
import { TourNgBootstrapModule } from 'ngx-ui-tour-ng-bootstrap'
|
||||||
import { filter, first, map, Subject, switchMap, takeUntil } from 'rxjs'
|
import { filter, first, map, Subject, switchMap, takeUntil } from 'rxjs'
|
||||||
import {
|
import {
|
||||||
DEFAULT_DISPLAY_FIELDS,
|
DEFAULT_DISPLAY_FIELDS,
|
||||||
@@ -99,7 +99,7 @@ import { SaveViewConfigDialogComponent } from './save-view-config-dialog/save-vi
|
|||||||
NgbPaginationModule,
|
NgbPaginationModule,
|
||||||
NgClass,
|
NgClass,
|
||||||
RouterModule,
|
RouterModule,
|
||||||
TourNgBootstrap,
|
TourNgBootstrapModule,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class DocumentListComponent
|
export class DocumentListComponent
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ import {
|
|||||||
} from '@ng-bootstrap/ng-bootstrap'
|
} from '@ng-bootstrap/ng-bootstrap'
|
||||||
import { NgSelectComponent, NgSelectModule } from '@ng-select/ng-select'
|
import { NgSelectComponent, NgSelectModule } from '@ng-select/ng-select'
|
||||||
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
||||||
import { provideUiTour } from 'ngx-ui-tour-ng-bootstrap'
|
|
||||||
import { of, throwError } from 'rxjs'
|
import { of, throwError } from 'rxjs'
|
||||||
import { Correspondent } from 'src/app/data/correspondent'
|
import { Correspondent } from 'src/app/data/correspondent'
|
||||||
import { CustomField, CustomFieldDataType } from 'src/app/data/custom-field'
|
import { CustomField, CustomFieldDataType } from 'src/app/data/custom-field'
|
||||||
@@ -252,7 +251,6 @@ describe('FilterEditorComponent', () => {
|
|||||||
SettingsService,
|
SettingsService,
|
||||||
provideHttpClient(withInterceptorsFromDi()),
|
provideHttpClient(withInterceptorsFromDi()),
|
||||||
provideHttpClientTesting(),
|
provideHttpClientTesting(),
|
||||||
provideUiTour(),
|
|
||||||
],
|
],
|
||||||
}).compileComponents()
|
}).compileComponents()
|
||||||
|
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import {
|
|||||||
NgbTypeaheadModule,
|
NgbTypeaheadModule,
|
||||||
} from '@ng-bootstrap/ng-bootstrap'
|
} from '@ng-bootstrap/ng-bootstrap'
|
||||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||||
import { TourNgBootstrap } from 'ngx-ui-tour-ng-bootstrap'
|
import { TourNgBootstrapModule } from 'ngx-ui-tour-ng-bootstrap'
|
||||||
import { Observable, Subject, from } from 'rxjs'
|
import { Observable, Subject, from } from 'rxjs'
|
||||||
import {
|
import {
|
||||||
catchError,
|
catchError,
|
||||||
@@ -251,7 +251,7 @@ const DEFAULT_TEXT_FILTER_MODIFIER_OPTIONS = [
|
|||||||
NgbTypeaheadModule,
|
NgbTypeaheadModule,
|
||||||
FormsModule,
|
FormsModule,
|
||||||
ReactiveFormsModule,
|
ReactiveFormsModule,
|
||||||
TourNgBootstrap,
|
TourNgBootstrapModule,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class FilterEditorComponent
|
export class FilterEditorComponent
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import { of } from 'rxjs'
|
|||||||
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
||||||
import { SortableDirective } from 'src/app/directives/sortable.directive'
|
import { SortableDirective } from 'src/app/directives/sortable.directive'
|
||||||
import { CorrespondentService } from 'src/app/services/rest/correspondent.service'
|
import { CorrespondentService } from 'src/app/services/rest/correspondent.service'
|
||||||
import { PageHeaderComponent } from '../../../../common/page-header/page-header.component'
|
import { PageHeaderComponent } from '../../common/page-header/page-header.component'
|
||||||
import { CorrespondentListComponent } from './correspondent-list.component'
|
import { CorrespondentListComponent } from './correspondent-list.component'
|
||||||
|
|
||||||
describe('CorrespondentListComponent', () => {
|
describe('CorrespondentListComponent', () => {
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { NgClass, NgTemplateOutlet } from '@angular/common'
|
import { NgClass, NgTemplateOutlet, TitleCasePipe } from '@angular/common'
|
||||||
import { Component, inject } from '@angular/core'
|
import { Component, inject } from '@angular/core'
|
||||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||||
import { RouterModule } from '@angular/router'
|
import { RouterModule } from '@angular/router'
|
||||||
@@ -7,7 +7,6 @@ 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 { CorrespondentEditDialogComponent } from 'src/app/components/common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component'
|
|
||||||
import { Correspondent } from 'src/app/data/correspondent'
|
import { Correspondent } from 'src/app/data/correspondent'
|
||||||
import { FILTER_HAS_CORRESPONDENT_ANY } from 'src/app/data/filter-rule-type'
|
import { FILTER_HAS_CORRESPONDENT_ANY } from 'src/app/data/filter-rule-type'
|
||||||
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
||||||
@@ -15,16 +14,21 @@ import { SortableDirective } from 'src/app/directives/sortable.directive'
|
|||||||
import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
|
import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
|
||||||
import { PermissionType } from 'src/app/services/permissions.service'
|
import { PermissionType } from 'src/app/services/permissions.service'
|
||||||
import { CorrespondentService } from 'src/app/services/rest/correspondent.service'
|
import { CorrespondentService } from 'src/app/services/rest/correspondent.service'
|
||||||
import { ManagementListComponent } from '../management-list.component'
|
import { ClearableBadgeComponent } from '../../common/clearable-badge/clearable-badge.component'
|
||||||
|
import { CorrespondentEditDialogComponent } from '../../common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component'
|
||||||
|
import { PageHeaderComponent } from '../../common/page-header/page-header.component'
|
||||||
|
import { ManagementListComponent } from '../management-list/management-list.component'
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'pngx-correspondent-list',
|
selector: 'pngx-correspondent-list',
|
||||||
templateUrl: './../management-list.component.html',
|
templateUrl: './../management-list/management-list.component.html',
|
||||||
styleUrls: ['./../management-list.component.scss'],
|
styleUrls: ['./../management-list/management-list.component.scss'],
|
||||||
providers: [{ provide: CustomDatePipe }],
|
providers: [{ provide: CustomDatePipe }],
|
||||||
imports: [
|
imports: [
|
||||||
SortableDirective,
|
SortableDirective,
|
||||||
IfPermissionsDirective,
|
IfPermissionsDirective,
|
||||||
|
PageHeaderComponent,
|
||||||
|
TitleCasePipe,
|
||||||
FormsModule,
|
FormsModule,
|
||||||
ReactiveFormsModule,
|
ReactiveFormsModule,
|
||||||
RouterModule,
|
RouterModule,
|
||||||
@@ -33,10 +37,11 @@ import { ManagementListComponent } from '../management-list.component'
|
|||||||
NgbDropdownModule,
|
NgbDropdownModule,
|
||||||
NgbPaginationModule,
|
NgbPaginationModule,
|
||||||
NgxBootstrapIconsModule,
|
NgxBootstrapIconsModule,
|
||||||
|
ClearableBadgeComponent,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class CorrespondentListComponent extends ManagementListComponent<Correspondent> {
|
export class CorrespondentListComponent extends ManagementListComponent<Correspondent> {
|
||||||
private readonly datePipe = inject(CustomDatePipe)
|
private datePipe = inject(CustomDatePipe)
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super()
|
super()
|
||||||
@@ -1,3 +1,15 @@
|
|||||||
|
<pngx-page-header
|
||||||
|
title="Custom Fields"
|
||||||
|
i18n-title
|
||||||
|
info="Customize the data fields that can be attached to documents."
|
||||||
|
i18n-info
|
||||||
|
infoLink="usage/#custom-fields"
|
||||||
|
>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-primary" (click)="editField()" *pngxIfPermissions="{ action: PermissionAction.Add, type: PermissionType.CustomField }">
|
||||||
|
<i-bs name="plus-circle"></i-bs> <ng-container i18n>Add Field</ng-container>
|
||||||
|
</button>
|
||||||
|
</pngx-page-header>
|
||||||
|
|
||||||
<ul class="list-group">
|
<ul class="list-group">
|
||||||
|
|
||||||
<li class="list-group-item">
|
<li class="list-group-item">
|
||||||
@@ -26,9 +26,9 @@ import { PermissionsService } from 'src/app/services/permissions.service'
|
|||||||
import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
|
import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
|
||||||
import { SettingsService } from 'src/app/services/settings.service'
|
import { SettingsService } from 'src/app/services/settings.service'
|
||||||
import { ToastService } from 'src/app/services/toast.service'
|
import { ToastService } from 'src/app/services/toast.service'
|
||||||
import { ConfirmDialogComponent } from '../../../common/confirm-dialog/confirm-dialog.component'
|
import { ConfirmDialogComponent } from '../../common/confirm-dialog/confirm-dialog.component'
|
||||||
import { CustomFieldEditDialogComponent } from '../../../common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component'
|
import { CustomFieldEditDialogComponent } from '../../common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component'
|
||||||
import { PageHeaderComponent } from '../../../common/page-header/page-header.component'
|
import { PageHeaderComponent } from '../../common/page-header/page-header.component'
|
||||||
import { CustomFieldsComponent } from './custom-fields.component'
|
import { CustomFieldsComponent } from './custom-fields.component'
|
||||||
|
|
||||||
const fields: CustomField[] = [
|
const fields: CustomField[] = [
|
||||||
@@ -110,7 +110,10 @@ describe('CustomFieldsComponent', () => {
|
|||||||
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
|
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
|
||||||
const reloadSpy = jest.spyOn(component, 'reload')
|
const reloadSpy = jest.spyOn(component, 'reload')
|
||||||
|
|
||||||
component.editField()
|
const createButton = fixture.debugElement
|
||||||
|
.queryAll(By.css('button'))
|
||||||
|
.find((btn) => btn.nativeElement.textContent.trim().includes('Add Field'))
|
||||||
|
createButton.triggerEventHandler('click')
|
||||||
|
|
||||||
expect(modal).not.toBeUndefined()
|
expect(modal).not.toBeUndefined()
|
||||||
const editDialog = modal.componentInstance as CustomFieldEditDialogComponent
|
const editDialog = modal.componentInstance as CustomFieldEditDialogComponent
|
||||||
@@ -7,10 +7,6 @@ import {
|
|||||||
} 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 { delay, takeUntil, tap } from 'rxjs'
|
||||||
import { ConfirmDialogComponent } from 'src/app/components/common/confirm-dialog/confirm-dialog.component'
|
|
||||||
import { CustomFieldEditDialogComponent } from 'src/app/components/common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component'
|
|
||||||
import { EditDialogMode } from 'src/app/components/common/edit-dialog/edit-dialog.component'
|
|
||||||
import { LoadingComponentWithPermissions } from 'src/app/components/loading-component/loading.component'
|
|
||||||
import { CustomField, DATA_TYPE_LABELS } from 'src/app/data/custom-field'
|
import { CustomField, DATA_TYPE_LABELS } from 'src/app/data/custom-field'
|
||||||
import {
|
import {
|
||||||
CustomFieldQueryLogicalOperator,
|
CustomFieldQueryLogicalOperator,
|
||||||
@@ -25,12 +21,18 @@ import { DocumentService } from 'src/app/services/rest/document.service'
|
|||||||
import { SavedViewService } from 'src/app/services/rest/saved-view.service'
|
import { SavedViewService } from 'src/app/services/rest/saved-view.service'
|
||||||
import { SettingsService } from 'src/app/services/settings.service'
|
import { SettingsService } from 'src/app/services/settings.service'
|
||||||
import { ToastService } from 'src/app/services/toast.service'
|
import { ToastService } from 'src/app/services/toast.service'
|
||||||
|
import { ConfirmDialogComponent } from '../../common/confirm-dialog/confirm-dialog.component'
|
||||||
|
import { CustomFieldEditDialogComponent } from '../../common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component'
|
||||||
|
import { EditDialogMode } from '../../common/edit-dialog/edit-dialog.component'
|
||||||
|
import { PageHeaderComponent } from '../../common/page-header/page-header.component'
|
||||||
|
import { LoadingComponentWithPermissions } from '../../loading-component/loading.component'
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'pngx-custom-fields',
|
selector: 'pngx-custom-fields',
|
||||||
templateUrl: './custom-fields.component.html',
|
templateUrl: './custom-fields.component.html',
|
||||||
styleUrls: ['./custom-fields.component.scss'],
|
styleUrls: ['./custom-fields.component.scss'],
|
||||||
imports: [
|
imports: [
|
||||||
|
PageHeaderComponent,
|
||||||
IfPermissionsDirective,
|
IfPermissionsDirective,
|
||||||
NgbDropdownModule,
|
NgbDropdownModule,
|
||||||
NgbPaginationModule,
|
NgbPaginationModule,
|
||||||
@@ -42,14 +44,14 @@ export class CustomFieldsComponent
|
|||||||
extends LoadingComponentWithPermissions
|
extends LoadingComponentWithPermissions
|
||||||
implements OnInit
|
implements OnInit
|
||||||
{
|
{
|
||||||
private readonly customFieldsService = inject(CustomFieldsService)
|
private customFieldsService = inject(CustomFieldsService)
|
||||||
public readonly permissionsService = inject(PermissionsService)
|
permissionsService = inject(PermissionsService)
|
||||||
private readonly modalService = inject(NgbModal)
|
private modalService = inject(NgbModal)
|
||||||
private readonly toastService = inject(ToastService)
|
private toastService = inject(ToastService)
|
||||||
private readonly documentListViewService = inject(DocumentListViewService)
|
private documentListViewService = inject(DocumentListViewService)
|
||||||
private readonly settingsService = inject(SettingsService)
|
private settingsService = inject(SettingsService)
|
||||||
private readonly documentService = inject(DocumentService)
|
private documentService = inject(DocumentService)
|
||||||
private readonly savedViewService = inject(SavedViewService)
|
private savedViewService = inject(SavedViewService)
|
||||||
|
|
||||||
public fields: CustomField[] = []
|
public fields: CustomField[] = []
|
||||||
|
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
<pngx-page-header
|
|
||||||
[title]="activeTabLabel"
|
|
||||||
info="Manage tags, correspondents, document types, storage paths, and custom fields."
|
|
||||||
i18n-info
|
|
||||||
[infoLink]="activeInfoLink"
|
|
||||||
[loading]="activeHeaderLoading"
|
|
||||||
>
|
|
||||||
@if (activeManagementList) {
|
|
||||||
<div ngbDropdown class="btn-group flex-fill d-sm-none">
|
|
||||||
<button class="btn btn-sm btn-outline-primary" id="dropdownSelectMobile" ngbDropdownToggle>
|
|
||||||
<i-bs name="text-indent-left"></i-bs>
|
|
||||||
<div class="d-none d-sm-inline"> <ng-container i18n>Select</ng-container></div>
|
|
||||||
@if (activeManagementList.selectedObjects.size > 0) {
|
|
||||||
<pngx-clearable-badge [selected]="activeManagementList.selectedObjects.size > 0" [number]="activeManagementList.selectedObjects.size" (cleared)="activeManagementList.selectNone()"></pngx-clearable-badge><span class="visually-hidden">selected</span>
|
|
||||||
}
|
|
||||||
</button>
|
|
||||||
<div ngbDropdownMenu aria-labelledby="dropdownSelectMobile" class="shadow">
|
|
||||||
<button ngbDropdownItem (click)="activeManagementList.selectNone()" i18n>Select none</button>
|
|
||||||
<button ngbDropdownItem (click)="activeManagementList.selectPage(true)" i18n>Select page</button>
|
|
||||||
<button ngbDropdownItem (click)="activeManagementList.selectAll()" i18n>Select all</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="d-none d-sm-flex flex-fill me-3">
|
|
||||||
<div class="input-group input-group-sm">
|
|
||||||
<span class="input-group-text border-0" i18n>Select:</span>
|
|
||||||
</div>
|
|
||||||
<div class="btn-group btn-group-sm flex-nowrap">
|
|
||||||
@if (activeManagementList.selectedObjects.size > 0) {
|
|
||||||
<button class="btn btn-sm btn-outline-secondary" (click)="activeManagementList.selectNone()">
|
|
||||||
<i-bs name="slash-circle"></i-bs> <ng-container i18n>None</ng-container>
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
<button class="btn btn-sm btn-outline-primary" (click)="activeManagementList.selectPage(true)">
|
|
||||||
<i-bs name="file-earmark-check"></i-bs> <ng-container i18n>Page</ng-container>
|
|
||||||
</button>
|
|
||||||
<button class="btn btn-sm btn-outline-primary" (click)="activeManagementList.selectAll()">
|
|
||||||
<i-bs name="check-all"></i-bs> <ng-container i18n>All</ng-container>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button type="button" class="btn btn-sm btn-outline-primary" (click)="activeManagementList.setPermissions()"
|
|
||||||
[disabled]="!activeManagementList.userCanBulkEdit(PermissionAction.Change) || activeManagementList.selectedObjects.size === 0">
|
|
||||||
<i-bs name="person-fill-lock"></i-bs> <ng-container i18n>Permissions</ng-container>
|
|
||||||
</button>
|
|
||||||
<button type="button" class="btn btn-sm btn-outline-danger" (click)="activeManagementList.delete()"
|
|
||||||
[disabled]="!activeManagementList.userCanBulkEdit(PermissionAction.Delete) || activeManagementList.selectedObjects.size === 0">
|
|
||||||
<i-bs name="trash"></i-bs> <ng-container i18n>Delete</ng-container>
|
|
||||||
</button>
|
|
||||||
<button type="button" class="btn btn-sm btn-outline-primary ms-md-5" (click)="activeManagementList.openCreateDialog()"
|
|
||||||
*pngxIfPermissions="{ action: PermissionAction.Add, type: activeManagementList.permissionType }">
|
|
||||||
<i-bs name="plus-circle"></i-bs> <ng-container i18n>Create</ng-container>
|
|
||||||
</button>
|
|
||||||
} @else if (activeCustomFields) {
|
|
||||||
<button type="button" class="btn btn-sm btn-outline-primary" (click)="activeCustomFields.editField()"
|
|
||||||
*pngxIfPermissions="{ action: PermissionAction.Add, type: PermissionType.CustomField }">
|
|
||||||
<i-bs name="plus-circle"></i-bs> <ng-container i18n>Add Field</ng-container>
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
</pngx-page-header>
|
|
||||||
|
|
||||||
<ul ngbNav #nav="ngbNav" (navChange)="onNavChange($event)" [(activeId)]="activeNavID" class="nav-underline">
|
|
||||||
@for (section of visibleSections; track section.id) {
|
|
||||||
<li [ngbNavItem]="section.id">
|
|
||||||
<a ngbNavLink >
|
|
||||||
<i-bs class="me-2" [name]="section.icon"></i-bs>{{ section.label }}
|
|
||||||
</a>
|
|
||||||
</li>
|
|
||||||
}
|
|
||||||
</ul>
|
|
||||||
|
|
||||||
<div class="my-3 shadow-sm">
|
|
||||||
<ng-container
|
|
||||||
[ngComponentOutlet]="activeSection?.component"
|
|
||||||
#activeOutlet="ngComponentOutlet"
|
|
||||||
></ng-container>
|
|
||||||
</div>
|
|
||||||
@@ -1,189 +0,0 @@
|
|||||||
import { Component } from '@angular/core'
|
|
||||||
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
|
||||||
import {
|
|
||||||
ActivatedRoute,
|
|
||||||
convertToParamMap,
|
|
||||||
ParamMap,
|
|
||||||
Router,
|
|
||||||
} from '@angular/router'
|
|
||||||
import { RouterTestingModule } from '@angular/router/testing'
|
|
||||||
import { allIcons, NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
|
||||||
import { Subject } from 'rxjs'
|
|
||||||
import {
|
|
||||||
PermissionAction,
|
|
||||||
PermissionsService,
|
|
||||||
PermissionType,
|
|
||||||
} from 'src/app/services/permissions.service'
|
|
||||||
import {
|
|
||||||
DocumentAttributesComponent,
|
|
||||||
DocumentAttributesSectionKind,
|
|
||||||
} from './document-attributes.component'
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: 'pngx-dummy-section',
|
|
||||||
template: '',
|
|
||||||
standalone: true,
|
|
||||||
})
|
|
||||||
class DummySectionComponent {}
|
|
||||||
|
|
||||||
describe('DocumentAttributesComponent', () => {
|
|
||||||
let component: DocumentAttributesComponent
|
|
||||||
let fixture: ComponentFixture<DocumentAttributesComponent>
|
|
||||||
let router: Router
|
|
||||||
let paramMapSubject: Subject<ParamMap>
|
|
||||||
let permissionsService: PermissionsService
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
paramMapSubject = new Subject<ParamMap>()
|
|
||||||
|
|
||||||
TestBed.configureTestingModule({
|
|
||||||
imports: [
|
|
||||||
RouterTestingModule,
|
|
||||||
NgxBootstrapIconsModule.pick(allIcons),
|
|
||||||
DocumentAttributesComponent,
|
|
||||||
DummySectionComponent,
|
|
||||||
],
|
|
||||||
providers: [
|
|
||||||
{
|
|
||||||
provide: ActivatedRoute,
|
|
||||||
useValue: {
|
|
||||||
paramMap: paramMapSubject.asObservable(),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
provide: PermissionsService,
|
|
||||||
useValue: {
|
|
||||||
currentUserCan: jest.fn(),
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}).compileComponents()
|
|
||||||
|
|
||||||
fixture = TestBed.createComponent(DocumentAttributesComponent)
|
|
||||||
component = fixture.componentInstance
|
|
||||||
router = TestBed.inject(Router)
|
|
||||||
permissionsService = TestBed.inject(PermissionsService)
|
|
||||||
|
|
||||||
jest.spyOn(router, 'navigate').mockResolvedValue(true)
|
|
||||||
;(component as any).sections = [
|
|
||||||
{
|
|
||||||
id: 1,
|
|
||||||
path: 'tags',
|
|
||||||
label: 'Tags',
|
|
||||||
icon: 'tags',
|
|
||||||
permissionType: PermissionType.Tag,
|
|
||||||
kind: DocumentAttributesSectionKind.ManagementList,
|
|
||||||
component: DummySectionComponent,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 2,
|
|
||||||
path: 'customfields',
|
|
||||||
label: 'Custom fields',
|
|
||||||
icon: 'ui-radios',
|
|
||||||
permissionType: PermissionType.CustomField,
|
|
||||||
kind: DocumentAttributesSectionKind.CustomFields,
|
|
||||||
component: DummySectionComponent,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should navigate to default section when no section is provided', () => {
|
|
||||||
jest
|
|
||||||
.spyOn(permissionsService, 'currentUserCan')
|
|
||||||
.mockImplementation((action, type) => {
|
|
||||||
return action === PermissionAction.View && type === PermissionType.Tag
|
|
||||||
})
|
|
||||||
|
|
||||||
fixture.detectChanges()
|
|
||||||
paramMapSubject.next(convertToParamMap({}))
|
|
||||||
|
|
||||||
expect(router.navigate).toHaveBeenCalledWith(['attributes', 'tags'], {
|
|
||||||
replaceUrl: true,
|
|
||||||
})
|
|
||||||
expect(component.activeNavID).toBe(1)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should set active section from route param when valid', () => {
|
|
||||||
jest
|
|
||||||
.spyOn(permissionsService, 'currentUserCan')
|
|
||||||
.mockImplementation((action, type) => {
|
|
||||||
return (
|
|
||||||
action === PermissionAction.View &&
|
|
||||||
type === PermissionType.CustomField
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
fixture.detectChanges()
|
|
||||||
paramMapSubject.next(convertToParamMap({ section: 'customfields' }))
|
|
||||||
|
|
||||||
expect(component.activeNavID).toBe(2)
|
|
||||||
expect(router.navigate).not.toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should update active nav id when route section changes', () => {
|
|
||||||
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
|
|
||||||
|
|
||||||
fixture.detectChanges()
|
|
||||||
component.activeNavID = 1
|
|
||||||
paramMapSubject.next(convertToParamMap({ section: 'customfields' }))
|
|
||||||
|
|
||||||
expect(component.activeNavID).toBe(2)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should redirect to dashboard when no sections are visible', () => {
|
|
||||||
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(false)
|
|
||||||
|
|
||||||
fixture.detectChanges()
|
|
||||||
paramMapSubject.next(convertToParamMap({}))
|
|
||||||
|
|
||||||
expect(router.navigate).toHaveBeenCalledWith(['/dashboard'], {
|
|
||||||
replaceUrl: true,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should navigate when a nav change occurs', () => {
|
|
||||||
jest
|
|
||||||
.spyOn(permissionsService, 'currentUserCan')
|
|
||||||
.mockImplementation(() => true)
|
|
||||||
|
|
||||||
fixture.detectChanges()
|
|
||||||
paramMapSubject.next(convertToParamMap({ section: 'tags' }))
|
|
||||||
|
|
||||||
component.onNavChange({ nextId: 2 } as any)
|
|
||||||
|
|
||||||
expect(router.navigate).toHaveBeenCalledWith(['attributes', 'customfields'])
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should ignore nav changes for unknown sections', () => {
|
|
||||||
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
|
|
||||||
|
|
||||||
fixture.detectChanges()
|
|
||||||
paramMapSubject.next(convertToParamMap({ section: 'tags' }))
|
|
||||||
|
|
||||||
component.onNavChange({ nextId: 999 } as any)
|
|
||||||
|
|
||||||
expect(router.navigate).not.toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should return activeManagementList correctly', () => {
|
|
||||||
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
|
|
||||||
expect(component.activeManagementList).toBeNull()
|
|
||||||
|
|
||||||
component.activeNavID = 1
|
|
||||||
expect(component.activeSection.kind).toBe(
|
|
||||||
DocumentAttributesSectionKind.ManagementList
|
|
||||||
)
|
|
||||||
expect(component.activeManagementList).toBeDefined()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should return activeCustomFields correctly', () => {
|
|
||||||
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
|
|
||||||
expect(component.activeCustomFields).toBeNull()
|
|
||||||
|
|
||||||
component.activeNavID = 2
|
|
||||||
expect(component.activeSection.kind).toBe(
|
|
||||||
DocumentAttributesSectionKind.CustomFields
|
|
||||||
)
|
|
||||||
expect(component.activeCustomFields).toBeDefined()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
@@ -1,256 +0,0 @@
|
|||||||
import { NgComponentOutlet } from '@angular/common'
|
|
||||||
import {
|
|
||||||
AfterViewChecked,
|
|
||||||
ChangeDetectorRef,
|
|
||||||
Component,
|
|
||||||
inject,
|
|
||||||
OnDestroy,
|
|
||||||
OnInit,
|
|
||||||
Type,
|
|
||||||
ViewChild,
|
|
||||||
} from '@angular/core'
|
|
||||||
import { ActivatedRoute, Router } from '@angular/router'
|
|
||||||
import {
|
|
||||||
NgbDropdownModule,
|
|
||||||
NgbNavChangeEvent,
|
|
||||||
NgbNavModule,
|
|
||||||
} from '@ng-bootstrap/ng-bootstrap'
|
|
||||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
|
||||||
import { Subject, takeUntil } from 'rxjs'
|
|
||||||
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
|
||||||
import {
|
|
||||||
PermissionAction,
|
|
||||||
PermissionsService,
|
|
||||||
PermissionType,
|
|
||||||
} from 'src/app/services/permissions.service'
|
|
||||||
import { ClearableBadgeComponent } from '../../common/clearable-badge/clearable-badge.component'
|
|
||||||
import { PageHeaderComponent } from '../../common/page-header/page-header.component'
|
|
||||||
import { CustomFieldsComponent } from './custom-fields/custom-fields.component'
|
|
||||||
import { CorrespondentListComponent } from './management-list/correspondent-list/correspondent-list.component'
|
|
||||||
import { DocumentTypeListComponent } from './management-list/document-type-list/document-type-list.component'
|
|
||||||
import { ManagementListComponent } from './management-list/management-list.component'
|
|
||||||
import { StoragePathListComponent } from './management-list/storage-path-list/storage-path-list.component'
|
|
||||||
import { TagListComponent } from './management-list/tag-list/tag-list.component'
|
|
||||||
|
|
||||||
enum DocumentAttributesNavIDs {
|
|
||||||
Tags = 1,
|
|
||||||
Correspondents = 2,
|
|
||||||
DocumentTypes = 3,
|
|
||||||
StoragePaths = 4,
|
|
||||||
CustomFields = 5,
|
|
||||||
}
|
|
||||||
|
|
||||||
export enum DocumentAttributesSectionKind {
|
|
||||||
ManagementList = 'managementList',
|
|
||||||
CustomFields = 'customFields',
|
|
||||||
}
|
|
||||||
|
|
||||||
interface DocumentAttributesSection {
|
|
||||||
id: DocumentAttributesNavIDs
|
|
||||||
path: string
|
|
||||||
label: string
|
|
||||||
icon: string
|
|
||||||
infoLink?: string
|
|
||||||
permissionType: PermissionType
|
|
||||||
kind: DocumentAttributesSectionKind
|
|
||||||
component: Type<any>
|
|
||||||
}
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: 'pngx-document-attributes',
|
|
||||||
templateUrl: './document-attributes.component.html',
|
|
||||||
styleUrls: ['./document-attributes.component.scss'],
|
|
||||||
imports: [
|
|
||||||
PageHeaderComponent,
|
|
||||||
NgbNavModule,
|
|
||||||
NgbDropdownModule,
|
|
||||||
NgComponentOutlet,
|
|
||||||
NgxBootstrapIconsModule,
|
|
||||||
IfPermissionsDirective,
|
|
||||||
ClearableBadgeComponent,
|
|
||||||
],
|
|
||||||
})
|
|
||||||
export class DocumentAttributesComponent
|
|
||||||
implements OnInit, OnDestroy, AfterViewChecked
|
|
||||||
{
|
|
||||||
private readonly permissionsService = inject(PermissionsService)
|
|
||||||
private readonly activatedRoute = inject(ActivatedRoute)
|
|
||||||
private readonly router = inject(Router)
|
|
||||||
private readonly cdr = inject(ChangeDetectorRef)
|
|
||||||
private readonly unsubscribeNotifier = new Subject<void>()
|
|
||||||
|
|
||||||
protected readonly PermissionAction = PermissionAction
|
|
||||||
protected readonly PermissionType = PermissionType
|
|
||||||
|
|
||||||
readonly sections: DocumentAttributesSection[] = [
|
|
||||||
{
|
|
||||||
id: DocumentAttributesNavIDs.Tags,
|
|
||||||
path: 'tags',
|
|
||||||
label: $localize`Tags`,
|
|
||||||
icon: 'tags',
|
|
||||||
infoLink: 'usage/#terms-and-definitions',
|
|
||||||
permissionType: PermissionType.Tag,
|
|
||||||
kind: DocumentAttributesSectionKind.ManagementList,
|
|
||||||
component: TagListComponent,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: DocumentAttributesNavIDs.Correspondents,
|
|
||||||
path: 'correspondents',
|
|
||||||
label: $localize`Correspondents`,
|
|
||||||
icon: 'person',
|
|
||||||
infoLink: 'usage/#terms-and-definitions',
|
|
||||||
permissionType: PermissionType.Correspondent,
|
|
||||||
kind: DocumentAttributesSectionKind.ManagementList,
|
|
||||||
component: CorrespondentListComponent,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: DocumentAttributesNavIDs.DocumentTypes,
|
|
||||||
path: 'documenttypes',
|
|
||||||
label: $localize`Document types`,
|
|
||||||
icon: 'hash',
|
|
||||||
infoLink: 'usage/#terms-and-definitions',
|
|
||||||
permissionType: PermissionType.DocumentType,
|
|
||||||
kind: DocumentAttributesSectionKind.ManagementList,
|
|
||||||
component: DocumentTypeListComponent,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: DocumentAttributesNavIDs.StoragePaths,
|
|
||||||
path: 'storagepaths',
|
|
||||||
label: $localize`Storage paths`,
|
|
||||||
icon: 'folder',
|
|
||||||
infoLink: 'usage/#terms-and-definitions',
|
|
||||||
permissionType: PermissionType.StoragePath,
|
|
||||||
kind: DocumentAttributesSectionKind.ManagementList,
|
|
||||||
component: StoragePathListComponent,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: DocumentAttributesNavIDs.CustomFields,
|
|
||||||
path: 'customfields',
|
|
||||||
label: $localize`Custom fields`,
|
|
||||||
icon: 'ui-radios',
|
|
||||||
infoLink: 'usage/#custom-fields',
|
|
||||||
permissionType: PermissionType.CustomField,
|
|
||||||
kind: DocumentAttributesSectionKind.CustomFields,
|
|
||||||
component: CustomFieldsComponent,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
@ViewChild('activeOutlet', { read: NgComponentOutlet })
|
|
||||||
private readonly activeOutlet?: NgComponentOutlet
|
|
||||||
|
|
||||||
private lastHeaderLoading: boolean
|
|
||||||
|
|
||||||
activeNavID: number = null
|
|
||||||
|
|
||||||
get visibleSections(): DocumentAttributesSection[] {
|
|
||||||
return this.sections.filter((section) =>
|
|
||||||
this.permissionsService.currentUserCan(
|
|
||||||
PermissionAction.View,
|
|
||||||
section.permissionType
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
get activeSection(): DocumentAttributesSection | null {
|
|
||||||
return (
|
|
||||||
this.visibleSections.find((section) => section.id === this.activeNavID) ??
|
|
||||||
null
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
get activeManagementList(): ManagementListComponent<any> | null {
|
|
||||||
if (
|
|
||||||
this.activeSection?.kind !== DocumentAttributesSectionKind.ManagementList
|
|
||||||
)
|
|
||||||
return null
|
|
||||||
const instance = this.activeOutlet?.componentInstance
|
|
||||||
return instance instanceof ManagementListComponent ? instance : null
|
|
||||||
}
|
|
||||||
|
|
||||||
get activeCustomFields(): CustomFieldsComponent | null {
|
|
||||||
if (this.activeSection?.kind !== DocumentAttributesSectionKind.CustomFields)
|
|
||||||
return null
|
|
||||||
const instance = this.activeOutlet?.componentInstance
|
|
||||||
return instance instanceof CustomFieldsComponent ? instance : null
|
|
||||||
}
|
|
||||||
|
|
||||||
get activeTabLabel(): string {
|
|
||||||
return this.activeSection?.label ?? ''
|
|
||||||
}
|
|
||||||
|
|
||||||
get activeInfoLink(): string {
|
|
||||||
return this.activeSection?.infoLink ?? null
|
|
||||||
}
|
|
||||||
|
|
||||||
get activeHeaderLoading(): boolean {
|
|
||||||
return (
|
|
||||||
this.activeManagementList?.loading ??
|
|
||||||
this.activeCustomFields?.loading ??
|
|
||||||
false
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
ngOnInit(): void {
|
|
||||||
this.activatedRoute.paramMap
|
|
||||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
|
||||||
.subscribe((paramMap) => {
|
|
||||||
const section = paramMap.get('section')
|
|
||||||
const navIDFromSection =
|
|
||||||
this.getNavIDForSection(section) ?? this.getDefaultNavID()
|
|
||||||
|
|
||||||
if (navIDFromSection == null) {
|
|
||||||
this.router.navigate(['/dashboard'], { replaceUrl: true })
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.activeNavID !== navIDFromSection) {
|
|
||||||
this.activeNavID = navIDFromSection
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!section || this.getNavIDForSection(section) == null) {
|
|
||||||
this.router.navigate(
|
|
||||||
['attributes', this.getSectionForNavID(this.activeNavID)],
|
|
||||||
{ replaceUrl: true }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
ngOnDestroy(): void {
|
|
||||||
this.unsubscribeNotifier.next()
|
|
||||||
this.unsubscribeNotifier.complete()
|
|
||||||
}
|
|
||||||
|
|
||||||
ngAfterViewChecked(): void {
|
|
||||||
const current = this.activeHeaderLoading
|
|
||||||
if (this.lastHeaderLoading !== current) {
|
|
||||||
this.lastHeaderLoading = current
|
|
||||||
this.cdr.detectChanges()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
onNavChange(navChangeEvent: NgbNavChangeEvent): void {
|
|
||||||
const nextSection = this.getSectionForNavID(navChangeEvent.nextId)
|
|
||||||
if (!nextSection) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
this.router.navigate(['attributes', nextSection])
|
|
||||||
}
|
|
||||||
|
|
||||||
private getDefaultNavID(): DocumentAttributesNavIDs | null {
|
|
||||||
return this.visibleSections[0]?.id ?? null
|
|
||||||
}
|
|
||||||
|
|
||||||
private getNavIDForSection(section: string): DocumentAttributesNavIDs | null {
|
|
||||||
const path = section?.toLowerCase()
|
|
||||||
if (!path) return null
|
|
||||||
|
|
||||||
const found = this.visibleSections.find((s) => s.path === path)
|
|
||||||
return found?.id ?? null
|
|
||||||
}
|
|
||||||
|
|
||||||
private getSectionForNavID(navID: number): string | null {
|
|
||||||
const section = this.visibleSections.find((s) => s.id === navID)
|
|
||||||
return section?.path ?? null
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -9,7 +9,7 @@ import { of } from 'rxjs'
|
|||||||
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
||||||
import { SortableDirective } from 'src/app/directives/sortable.directive'
|
import { SortableDirective } from 'src/app/directives/sortable.directive'
|
||||||
import { DocumentTypeService } from 'src/app/services/rest/document-type.service'
|
import { DocumentTypeService } from 'src/app/services/rest/document-type.service'
|
||||||
import { PageHeaderComponent } from '../../../../common/page-header/page-header.component'
|
import { PageHeaderComponent } from '../../common/page-header/page-header.component'
|
||||||
import { DocumentTypeListComponent } from './document-type-list.component'
|
import { DocumentTypeListComponent } from './document-type-list.component'
|
||||||
|
|
||||||
describe('DocumentTypeListComponent', () => {
|
describe('DocumentTypeListComponent', () => {
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { NgClass, NgTemplateOutlet } from '@angular/common'
|
import { NgClass, NgTemplateOutlet, TitleCasePipe } from '@angular/common'
|
||||||
import { Component, inject } from '@angular/core'
|
import { Component, inject } from '@angular/core'
|
||||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||||
import { RouterModule } from '@angular/router'
|
import { RouterModule } from '@angular/router'
|
||||||
@@ -7,21 +7,25 @@ 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 { DocumentTypeEditDialogComponent } from 'src/app/components/common/edit-dialog/document-type-edit-dialog/document-type-edit-dialog.component'
|
|
||||||
import { DocumentType } from 'src/app/data/document-type'
|
import { DocumentType } from 'src/app/data/document-type'
|
||||||
import { FILTER_HAS_DOCUMENT_TYPE_ANY } from 'src/app/data/filter-rule-type'
|
import { FILTER_HAS_DOCUMENT_TYPE_ANY } from 'src/app/data/filter-rule-type'
|
||||||
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
||||||
import { SortableDirective } from 'src/app/directives/sortable.directive'
|
import { SortableDirective } from 'src/app/directives/sortable.directive'
|
||||||
import { PermissionType } from 'src/app/services/permissions.service'
|
import { PermissionType } from 'src/app/services/permissions.service'
|
||||||
import { DocumentTypeService } from 'src/app/services/rest/document-type.service'
|
import { DocumentTypeService } from 'src/app/services/rest/document-type.service'
|
||||||
import { ManagementListComponent } from '../management-list.component'
|
import { ClearableBadgeComponent } from '../../common/clearable-badge/clearable-badge.component'
|
||||||
|
import { DocumentTypeEditDialogComponent } from '../../common/edit-dialog/document-type-edit-dialog/document-type-edit-dialog.component'
|
||||||
|
import { PageHeaderComponent } from '../../common/page-header/page-header.component'
|
||||||
|
import { ManagementListComponent } from '../management-list/management-list.component'
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'pngx-document-type-list',
|
selector: 'pngx-document-type-list',
|
||||||
templateUrl: './../management-list.component.html',
|
templateUrl: './../management-list/management-list.component.html',
|
||||||
styleUrls: ['./../management-list.component.scss'],
|
styleUrls: ['./../management-list/management-list.component.scss'],
|
||||||
imports: [
|
imports: [
|
||||||
SortableDirective,
|
SortableDirective,
|
||||||
|
PageHeaderComponent,
|
||||||
|
TitleCasePipe,
|
||||||
IfPermissionsDirective,
|
IfPermissionsDirective,
|
||||||
FormsModule,
|
FormsModule,
|
||||||
ReactiveFormsModule,
|
ReactiveFormsModule,
|
||||||
@@ -31,6 +35,7 @@ import { ManagementListComponent } from '../management-list.component'
|
|||||||
NgbDropdownModule,
|
NgbDropdownModule,
|
||||||
NgbPaginationModule,
|
NgbPaginationModule,
|
||||||
NgxBootstrapIconsModule,
|
NgxBootstrapIconsModule,
|
||||||
|
ClearableBadgeComponent,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class DocumentTypeListComponent extends ManagementListComponent<DocumentType> {
|
export class DocumentTypeListComponent extends ManagementListComponent<DocumentType> {
|
||||||
@@ -129,7 +129,7 @@
|
|||||||
<div class="row fade" [class.show]="showRules">
|
<div class="row fade" [class.show]="showRules">
|
||||||
<div class="col d-flex align-items-center"><button class="btn btn-link p-0 text-start" type="button" (click)="editMailRule(rule)" [disabled]="!permissionsService.currentUserCan(PermissionAction.Change, PermissionType.MailRule) || !userCanEdit(rule)">{{rule.name}}</button></div>
|
<div class="col d-flex align-items-center"><button class="btn btn-link p-0 text-start" type="button" (click)="editMailRule(rule)" [disabled]="!permissionsService.currentUserCan(PermissionAction.Change, PermissionType.MailRule) || !userCanEdit(rule)">{{rule.name}}</button></div>
|
||||||
<div class="col-1 d-flex align-items-center d-none d-sm-flex">{{rule.order}}</div>
|
<div class="col-1 d-flex align-items-center d-none d-sm-flex">{{rule.order}}</div>
|
||||||
<div class="col-2 d-flex align-items-center">{{ mailAccountsById.get(rule.account)?.name }}</div>
|
<div class="col-2 d-flex align-items-center">{{(mailAccountService.getCached(rule.account) | async)?.name}}</div>
|
||||||
<div class="col-2 d-flex align-items-center d-none d-sm-flex">
|
<div class="col-2 d-flex align-items-center d-none d-sm-flex">
|
||||||
<div class="form-check form-switch mb-0">
|
<div class="form-check form-switch mb-0">
|
||||||
<input #inputField type="checkbox" class="form-check-input cursor-pointer" [id]="rule.id+'_enable'" [(ngModel)]="rule.enabled" (change)="onMailRuleEnableToggled(rule)" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.MailRule }">
|
<input #inputField type="checkbox" class="form-check-input cursor-pointer" [id]="rule.id+'_enable'" [(ngModel)]="rule.enabled" (change)="onMailRuleEnableToggled(rule)" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.MailRule }">
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { AsyncPipe } from '@angular/common'
|
||||||
import { Component, OnDestroy, OnInit, inject } from '@angular/core'
|
import { Component, OnDestroy, OnInit, inject } from '@angular/core'
|
||||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||||
import { ActivatedRoute } from '@angular/router'
|
import { ActivatedRoute } from '@angular/router'
|
||||||
@@ -36,6 +37,7 @@ import { ProcessedMailDialogComponent } from './processed-mail-dialog/processed-
|
|||||||
PageHeaderComponent,
|
PageHeaderComponent,
|
||||||
IfPermissionsDirective,
|
IfPermissionsDirective,
|
||||||
IfOwnerDirective,
|
IfOwnerDirective,
|
||||||
|
AsyncPipe,
|
||||||
FormsModule,
|
FormsModule,
|
||||||
ReactiveFormsModule,
|
ReactiveFormsModule,
|
||||||
NgbDropdownModule,
|
NgbDropdownModule,
|
||||||
@@ -46,8 +48,8 @@ export class MailComponent
|
|||||||
extends ComponentWithPermissions
|
extends ComponentWithPermissions
|
||||||
implements OnInit, OnDestroy
|
implements OnInit, OnDestroy
|
||||||
{
|
{
|
||||||
private readonly mailAccountService = inject(MailAccountService)
|
mailAccountService = inject(MailAccountService)
|
||||||
private readonly mailRuleService = inject(MailRuleService)
|
mailRuleService = inject(MailRuleService)
|
||||||
private toastService = inject(ToastService)
|
private toastService = inject(ToastService)
|
||||||
private modalService = inject(NgbModal)
|
private modalService = inject(NgbModal)
|
||||||
permissionsService = inject(PermissionsService)
|
permissionsService = inject(PermissionsService)
|
||||||
@@ -56,19 +58,8 @@ export class MailComponent
|
|||||||
|
|
||||||
public MailAccountType = MailAccountType
|
public MailAccountType = MailAccountType
|
||||||
|
|
||||||
private _mailAccounts: MailAccount[] = []
|
mailAccounts: MailAccount[] = []
|
||||||
|
mailRules: MailRule[] = []
|
||||||
public get mailAccounts() {
|
|
||||||
return this._mailAccounts
|
|
||||||
}
|
|
||||||
private set mailAccounts(accounts: MailAccount[]) {
|
|
||||||
this._mailAccounts = accounts
|
|
||||||
this.mailAccountsById = new Map(
|
|
||||||
accounts.map((account) => [account.id, account])
|
|
||||||
)
|
|
||||||
}
|
|
||||||
public mailAccountsById: Map<number, MailAccount> = new Map()
|
|
||||||
public mailRules: MailRule[] = []
|
|
||||||
|
|
||||||
unsubscribeNotifier: Subject<any> = new Subject()
|
unsubscribeNotifier: Subject<any> = new Subject()
|
||||||
oAuthAccountId: number
|
oAuthAccountId: number
|
||||||
|
|||||||
@@ -1,51 +1,82 @@
|
|||||||
|
<pngx-page-header title="{{ typeNamePlural | titlecase }}" info="View, add, edit and delete {{ typeNamePlural }}." infoLink="usage/#terms-and-definitions">
|
||||||
|
|
||||||
|
<div ngbDropdown class="btn-group flex-fill d-sm-none">
|
||||||
|
<button class="btn btn-sm btn-outline-primary" id="dropdownSelectMobile" ngbDropdownToggle>
|
||||||
|
<i-bs name="text-indent-left"></i-bs>
|
||||||
|
<div class="d-none d-sm-inline"> <ng-container i18n>Select</ng-container></div>
|
||||||
|
@if (selectedObjects.size > 0) {
|
||||||
|
<pngx-clearable-badge [selected]="selectedObjects.size > 0" [number]="selectedObjects.size" (cleared)="selectNone()"></pngx-clearable-badge><span class="visually-hidden">selected</span>
|
||||||
|
}
|
||||||
|
</button>
|
||||||
|
<div ngbDropdownMenu aria-labelledby="dropdownSelectMobile" class="shadow">
|
||||||
|
<button ngbDropdownItem (click)="selectNone()" i18n>Select none</button>
|
||||||
|
<button ngbDropdownItem (click)="selectPage(true)" i18n>Select page</button>
|
||||||
|
<button ngbDropdownItem (click)="selectAll()" i18n>Select all</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="d-none d-sm-flex flex-fill me-3">
|
||||||
|
<div class="input-group input-group-sm">
|
||||||
|
<span class="input-group-text border-0" i18n>Select:</span>
|
||||||
|
</div>
|
||||||
|
<div class="btn-group btn-group-sm flex-nowrap">
|
||||||
|
@if (selectedObjects.size > 0) {
|
||||||
|
<button class="btn btn-sm btn-outline-secondary" (click)="selectNone()">
|
||||||
|
<i-bs name="slash-circle"></i-bs> <ng-container i18n>None</ng-container>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
<button class="btn btn-sm btn-outline-primary" (click)="selectPage(true)">
|
||||||
|
<i-bs name="file-earmark-check"></i-bs> <ng-container i18n>Page</ng-container>
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-sm btn-outline-primary" (click)="selectAll()">
|
||||||
|
<i-bs name="check-all"></i-bs> <ng-container i18n>All</ng-container>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-primary" (click)="setPermissions()" [disabled]="!userCanBulkEdit(PermissionAction.Change) || selectedObjects.size === 0">
|
||||||
|
<i-bs name="person-fill-lock"></i-bs> <ng-container i18n>Permissions</ng-container>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-danger" (click)="delete()" [disabled]="!userCanBulkEdit(PermissionAction.Delete) || selectedObjects.size === 0">
|
||||||
|
<i-bs name="trash"></i-bs> <ng-container i18n>Delete</ng-container>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-primary ms-md-5" (click)="openCreateDialog()" *pngxIfPermissions="{ action: PermissionAction.Add, type: permissionType }">
|
||||||
|
<i-bs name="plus-circle"></i-bs> <ng-container i18n>Create</ng-container>
|
||||||
|
</button>
|
||||||
|
</pngx-page-header>
|
||||||
|
|
||||||
<div class="row mb-3">
|
<div class="row mb-3">
|
||||||
<div class="col mb-2 mb-xl-0">
|
<div class="col-md mb-2 mb-xl-0">
|
||||||
<div class="form-inline d-flex align-items-center">
|
<div class="form-inline d-flex align-items-center">
|
||||||
<label class="text-muted me-2 mb-0" i18n>Filter by:</label>
|
<label class="text-muted me-2 mb-0" i18n>Filter by:</label>
|
||||||
<input class="form-control form-control-sm flex-fill w-auto" type="text" autofocus [(ngModel)]="nameFilter" (keyup)="onNameFilterKeyUp($event)" placeholder="Name" i18n-placeholder>
|
<input class="form-control form-control-sm flex-fill w-auto" type="text" autofocus [(ngModel)]="nameFilter" (keyup)="onNameFilterKeyUp($event)" placeholder="Name" i18n-placeholder>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-auto mb-2 mb-xl-0">
|
<ngb-pagination class="col-auto" [pageSize]="25" [collectionSize]="collectionSize" [(page)]="page" [maxSize]="5" (pageChange)="reloadData()" size="sm" aria-label="Pagination"></ngb-pagination>
|
||||||
<div class="form-inline d-flex align-items-center">
|
|
||||||
<div class="input-group input-group-sm w-auto d-none d-md-flex">
|
|
||||||
<span class="input-group-text border-0" i18n>Show:</span>
|
|
||||||
</div>
|
|
||||||
<div class="input-group input-group-sm w-auto me-3">
|
|
||||||
<select class="form-select form-select-sm small" [(ngModel)]="pageSize">
|
|
||||||
<option [ngValue]="25">25</option>
|
|
||||||
<option [ngValue]="50">50</option>
|
|
||||||
<option [ngValue]="100">100</option>
|
|
||||||
</select>
|
|
||||||
<span class="input-group-text text-muted d-none d-md-flex" i18n>per page</span>
|
|
||||||
</div>
|
|
||||||
<ngb-pagination [pageSize]="pageSize" [collectionSize]="collectionSize" [(page)]="page" [maxSize]="5" (pageChange)="reloadData()" size="sm" aria-label="Pagination"></ngb-pagination>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="card border table-responsive mb-3">
|
<div class="card border table-responsive mb-3">
|
||||||
<table class="table table-striped align-middle shadow-sm mb-0">
|
<table class="table table-striped align-middle shadow-sm mb-0">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th>
|
<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)]="togggleAll" [disabled]="data.length === 0" (change)="$event.target.checked ? selectPage() : clearSelection(); $event.stopPropagation();">
|
<input type="checkbox" class="form-check-input" id="all-objects" [(ngModel)]="togggleAll" [disabled]="data.length === 0" (change)="selectPage($event.target.checked); $event.stopPropagation();">
|
||||||
<label class="form-check-label" for="all-objects"></label>
|
<label class="form-check-label" for="all-objects"></label>
|
||||||
</div>
|
</div>
|
||||||
</th>
|
</th>
|
||||||
<th class="fw-normal" pngxSortable="name" [currentSortField]="sortField" [currentSortReverse]="sortReverse" (sort)="onSort($event)" i18n>Name</th>
|
<th scope="col" class="fw-normal" pngxSortable="name" [currentSortField]="sortField" [currentSortReverse]="sortReverse" (sort)="onSort($event)" i18n>Name</th>
|
||||||
<th class="fw-normal d-none d-sm-table-cell" pngxSortable="matching_algorithm" [currentSortField]="sortField" [currentSortReverse]="sortReverse" (sort)="onSort($event)" i18n>Matching</th>
|
<th scope="col" class="fw-normal d-none d-sm-table-cell" pngxSortable="matching_algorithm" [currentSortField]="sortField" [currentSortReverse]="sortReverse" (sort)="onSort($event)" i18n>Matching</th>
|
||||||
<th class="fw-normal" pngxSortable="document_count" [currentSortField]="sortField" [currentSortReverse]="sortReverse" (sort)="onSort($event)" i18n>Document count</th>
|
<th scope="col" class="fw-normal" pngxSortable="document_count" [currentSortField]="sortField" [currentSortReverse]="sortReverse" (sort)="onSort($event)" i18n>Document count</th>
|
||||||
@for (column of extraColumns; track column) {
|
@for (column of extraColumns; track column) {
|
||||||
<th class="fw-normal" [ngClass]="{ 'd-none d-sm-table-cell' : column.hideOnMobile }" pngxSortable="{{column.key}}" [currentSortField]="sortField" [currentSortReverse]="sortReverse" (sort)="onSort($event)">{{column.name}}</th>
|
<th scope="col" class="fw-normal" [ngClass]="{ 'd-none d-sm-table-cell' : column.hideOnMobile }" pngxSortable="{{column.key}}" [currentSortField]="sortField" [currentSortReverse]="sortReverse" (sort)="onSort($event)">{{column.name}}</th>
|
||||||
}
|
}
|
||||||
<th class="fw-normal" i18n>Actions</th>
|
<th scope="col" class="fw-normal" i18n>Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@if (loading && data.length === 0) {
|
@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>
|
||||||
@@ -60,7 +91,7 @@
|
|||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@if (!loading || data.length > 0) {
|
@if (!loading) {
|
||||||
<div class="d-flex mb-2">
|
<div class="d-flex mb-2">
|
||||||
@if (collectionSize > 0) {
|
@if (collectionSize > 0) {
|
||||||
<div>
|
<div>
|
||||||
@@ -71,7 +102,7 @@
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@if (collectionSize > 20) {
|
@if (collectionSize > 20) {
|
||||||
<ngb-pagination class="ms-auto" [pageSize]="pageSize" [collectionSize]="collectionSize" [(page)]="page" [maxSize]="5" (pageChange)="reloadData()" size="sm" aria-label="Pagination"></ngb-pagination>
|
<ngb-pagination class="ms-auto" [pageSize]="25" [collectionSize]="collectionSize" [(page)]="page" [maxSize]="5" (pageChange)="reloadData()" size="sm" aria-label="Pagination"></ngb-pagination>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -84,16 +115,16 @@
|
|||||||
<label class="form-check-label" for="{{typeName}}{{object.id}}"></label>
|
<label class="form-check-label" for="{{typeName}}{{object.id}}"></label>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="name-cell" style="--depth: {{depth}}">
|
<td scope="row" class="name-cell" style="--depth: {{depth}}">
|
||||||
@if (depth > 0) {
|
@if (depth > 0) {
|
||||||
<div class="indicator"></div>
|
<div class="indicator"></div>
|
||||||
}
|
}
|
||||||
<button class="btn btn-link ms-0 ps-0 text-start" (click)="userCanEdit(object) ? openEditDialog(object) : null; $event.stopPropagation()">{{ object.name }}</button>
|
<button class="btn btn-link ms-0 ps-0 text-start" (click)="userCanEdit(object) ? openEditDialog(object) : null; $event.stopPropagation()">{{ object.name }}</button>
|
||||||
</td>
|
</td>
|
||||||
<td class="d-none d-sm-table-cell">{{ getMatching(object) }}</td>
|
<td scope="row" class="d-none d-sm-table-cell">{{ getMatching(object) }}</td>
|
||||||
<td>{{ getDocumentCount(object) }}</td>
|
<td scope="row">{{ getDocumentCount(object) }}</td>
|
||||||
@for (column of extraColumns; track column) {
|
@for (column of extraColumns; track column) {
|
||||||
<td [ngClass]="{ 'd-none d-sm-table-cell' : column.hideOnMobile }">
|
<td scope="row" [ngClass]="{ 'd-none d-sm-table-cell' : column.hideOnMobile }">
|
||||||
@if (column.badgeFn) {
|
@if (column.badgeFn) {
|
||||||
<span
|
<span
|
||||||
class="badge"
|
class="badge"
|
||||||
@@ -109,7 +140,7 @@
|
|||||||
}
|
}
|
||||||
</td>
|
</td>
|
||||||
}
|
}
|
||||||
<td>
|
<td scope="row">
|
||||||
<div class="btn-toolbar gap-2">
|
<div class="btn-toolbar gap-2">
|
||||||
<div class="btn-group d-block d-sm-none">
|
<div class="btn-group d-block d-sm-none">
|
||||||
<div ngbDropdown container="body" class="d-inline-block">
|
<div ngbDropdown container="body" class="d-inline-block">
|
||||||
@@ -24,7 +24,3 @@ td.name-cell {
|
|||||||
margin-left: .5rem;
|
margin-left: .5rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
select.small {
|
|
||||||
font-size: 0.875rem !important; // 14px
|
|
||||||
}
|
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user