Compare commits

..
153 changed files with 4902 additions and 9925 deletions
+35 -37
View File
@@ -1,51 +1,49 @@
categories:
- type: 'pre-include'
when:
- label: 'enhancement'
- label: 'bug'
- label: 'chore'
- label: 'deployment'
- label: 'translation'
- label: 'dependencies'
- label: 'documentation'
- label: 'frontend'
- label: 'backend'
- label: 'ci-cd'
- label: 'breaking-change'
- label: 'notable'
- type: 'pre-exclude'
when:
- label: 'skip-changelog'
- title: 'Breaking Changes'
when:
- label: 'breaking-change'
labels:
- 'breaking-change'
- title: 'Notable Changes'
when:
- label: 'notable'
labels:
- 'notable'
- title: 'Features / Enhancements'
when:
- label: 'enhancement'
labels:
- 'enhancement'
- title: 'Bug Fixes'
when:
- label: 'bug'
labels:
- 'bug'
- title: 'Documentation'
when:
- label: 'documentation'
labels:
- 'documentation'
- title: 'Maintenance'
when:
- label: 'chore'
- label: 'deployment'
- label: 'translation'
- label: 'ci-cd'
labels:
- 'chore'
- 'deployment'
- 'translation'
- 'ci-cd'
- title: 'Dependencies'
collapse-after: 3
when:
- label: 'dependencies'
labels:
- 'dependencies'
- title: 'All App Changes'
labels:
- 'frontend'
- 'backend'
collapse-after: 1
when:
- label: 'frontend'
- label: 'backend'
include-labels:
- 'enhancement'
- 'bug'
- 'chore'
- 'deployment'
- 'translation'
- 'dependencies'
- 'documentation'
- 'frontend'
- 'backend'
- 'ci-cd'
- 'breaking-change'
- 'notable'
exclude-labels:
- 'skip-changelog'
filter-by-commitish: true
category-template: '### $TITLE'
change-template: '- $TITLE @$AUTHOR ([#$NUMBER]($URL))'
+8 -8
View File
@@ -24,7 +24,7 @@ jobs:
backend_changed: ${{ steps.force.outputs.run_all == 'true' || steps.filter.outputs.backend == 'true' }}
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
fetch-depth: 0
persist-credentials: false
@@ -63,7 +63,7 @@ jobs:
- name: Detect changes
id: filter
if: steps.force.outputs.run_all != 'true'
uses: dorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706 # v4.0.2
uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
with:
base: ${{ steps.range.outputs.base }}
ref: ${{ steps.range.outputs.ref }}
@@ -87,7 +87,7 @@ jobs:
fail-fast: false
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Start containers
@@ -96,11 +96,11 @@ jobs:
docker compose --file docker/compose/docker-compose.ci-test.yml up --detach
- name: Set up Python
id: setup-python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
with:
python-version: "${{ matrix.python-version }}"
- name: Install uv
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
with:
version: ${{ env.DEFAULT_UV_VERSION }}
enable-cache: true
@@ -169,16 +169,16 @@ jobs:
PAPERLESS_SECRET_KEY: "ci-typing-not-a-real-secret"
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Set up Python
id: setup-python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
with:
python-version: "${{ env.DEFAULT_PYTHON }}"
- name: Install uv
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
with:
version: ${{ env.DEFAULT_UV_VERSION }}
enable-cache: true
+10 -10
View File
@@ -41,7 +41,7 @@ jobs:
ref-name: ${{ steps.ref.outputs.name }}
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Determine ref name
@@ -106,9 +106,9 @@ jobs:
echo "repository=${repo_name}"
echo "name=${repo_name}" >> $GITHUB_OUTPUT
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
- name: Login to GitHub Container Registry
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
@@ -121,7 +121,7 @@ jobs:
sudo rm -rf "$AGENT_TOOLSDIRECTORY"
- name: Docker metadata
id: docker-meta
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
with:
images: |
${{ env.REGISTRY }}/${{ steps.repo.outputs.name }}
@@ -132,7 +132,7 @@ jobs:
type=semver,pattern={{major}}.{{minor}}
- name: Build and push by digest
id: build
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
with:
context: .
file: ./Dockerfile
@@ -182,29 +182,29 @@ jobs:
echo "Downloaded digests:"
ls -la /tmp/digests/
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
- name: Login to GitHub Container Registry
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
with:
registry: ${{ env.REGISTRY }}
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- name: Login to Docker Hub
if: needs.build-arch.outputs.push-external == 'true'
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Login to Quay.io
if: needs.build-arch.outputs.push-external == 'true'
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
with:
registry: quay.io
username: ${{ secrets.QUAY_USERNAME }}
password: ${{ secrets.QUAY_ROBOT_TOKEN }}
- name: Docker metadata
id: docker-meta
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
with:
images: |
${{ env.REGISTRY }}/${{ needs.build-arch.outputs.repository }}
+5 -5
View File
@@ -21,7 +21,7 @@ jobs:
docs_changed: ${{ steps.force.outputs.run_all == 'true' || steps.filter.outputs.docs == 'true' }}
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
fetch-depth: 0
persist-credentials: false
@@ -50,7 +50,7 @@ jobs:
- name: Detect changes
id: filter
if: steps.force.outputs.run_all != 'true'
uses: dorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706 # v4.0.2
uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
with:
base: ${{ steps.range.outputs.base }}
ref: ${{ steps.range.outputs.ref }}
@@ -69,16 +69,16 @@ jobs:
steps:
- uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Set up Python
id: setup-python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
with:
python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
- name: Install uv
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
with:
version: ${{ env.DEFAULT_UV_VERSION }}
enable-cache: true
+32 -27
View File
@@ -21,7 +21,7 @@ jobs:
frontend_changed: ${{ steps.force.outputs.run_all == 'true' || steps.filter.outputs.frontend == 'true' }}
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
fetch-depth: 0
persist-credentials: false
@@ -60,7 +60,7 @@ jobs:
- name: Detect changes
id: filter
if: steps.force.outputs.run_all != 'true'
uses: dorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706 # v4.0.2
uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
with:
base: ${{ steps.range.outputs.base }}
ref: ${{ steps.range.outputs.ref }}
@@ -77,7 +77,7 @@ jobs:
contents: read
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Install pnpm
@@ -85,7 +85,7 @@ jobs:
with:
version: 10
- name: Use Node.js 24
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 24.x
cache: 'pnpm'
@@ -109,7 +109,7 @@ jobs:
contents: read
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Install pnpm
@@ -117,7 +117,7 @@ jobs:
with:
version: 10
- name: Use Node.js 24
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 24.x
cache: 'pnpm'
@@ -129,8 +129,8 @@ jobs:
~/.pnpm-store
~/.cache
key: ${{ runner.os }}-frontend-${{ hashFiles('src-ui/pnpm-lock.yaml') }}
- name: Install dependencies
run: cd src-ui && pnpm install --frozen-lockfile
- name: Re-link Angular CLI
run: cd src-ui && pnpm link @angular/cli
- name: Run lint
run: cd src-ui && pnpm run lint
unit-tests:
@@ -148,7 +148,7 @@ jobs:
shard-count: [4]
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Install pnpm
@@ -156,7 +156,7 @@ jobs:
with:
version: 10
- name: Use Node.js 24
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 24.x
cache: 'pnpm'
@@ -168,8 +168,8 @@ jobs:
~/.pnpm-store
~/.cache
key: ${{ runner.os }}-frontend-${{ hashFiles('src-ui/pnpm-lock.yaml') }}
- name: Install dependencies
run: cd src-ui && pnpm install --frozen-lockfile
- name: Re-link Angular CLI
run: cd src-ui && pnpm link @angular/cli
- name: Run Jest unit tests
run: cd src-ui && pnpm run test --max-workers=2 --shard=${{ matrix.shard-index }}/${{ matrix.shard-count }}
- name: Upload test results to Codecov
@@ -191,7 +191,7 @@ jobs:
runs-on: ubuntu-24.04
permissions:
contents: read
container: mcr.microsoft.com/playwright:v1.62.0-noble
container: mcr.microsoft.com/playwright:v1.61.1-noble
env:
PLAYWRIGHT_BROWSERS_PATH: /ms-playwright
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1
@@ -203,7 +203,7 @@ jobs:
shard-count: [2]
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Install pnpm
@@ -211,7 +211,7 @@ jobs:
with:
version: 10
- name: Use Node.js 24
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 24.x
cache: 'pnpm'
@@ -223,20 +223,23 @@ jobs:
~/.pnpm-store
~/.cache
key: ${{ runner.os }}-frontend-${{ hashFiles('src-ui/pnpm-lock.yaml') }}
- name: Re-link Angular CLI
run: cd src-ui && pnpm link @angular/cli
- name: Install dependencies
run: cd src-ui && pnpm install --frozen-lockfile
run: cd src-ui && pnpm install --no-frozen-lockfile
- name: Run Playwright E2E tests
run: cd src-ui && pnpm exec playwright test --shard ${{ matrix.shard-index }}/${{ matrix.shard-count }}
frontend-build:
name: Frontend Build
bundle-analysis:
name: Bundle Analysis
needs: [changes, unit-tests, e2e-tests]
if: needs.changes.outputs.frontend_changed == 'true'
runs-on: ubuntu-24.04
environment: bundle-analysis
permissions:
contents: read
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
fetch-depth: 2
persist-credentials: false
@@ -245,7 +248,7 @@ jobs:
with:
version: 10
- name: Use Node.js 24
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 24.x
cache: 'pnpm'
@@ -257,19 +260,21 @@ jobs:
~/.pnpm-store
~/.cache
key: ${{ runner.os }}-frontend-${{ hashFiles('src-ui/pnpm-lock.yaml') }}
- name: Install dependencies
run: cd src-ui && pnpm install --frozen-lockfile
- name: Build
- name: Re-link Angular CLI
run: cd src-ui && pnpm link @angular/cli
- name: Build and analyze
env:
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
run: cd src-ui && pnpm run build --configuration=production
gate:
name: Frontend CI Gate
needs: [changes, install-dependencies, lint, unit-tests, e2e-tests, frontend-build]
needs: [changes, install-dependencies, lint, unit-tests, e2e-tests, bundle-analysis]
if: always()
runs-on: ubuntu-slim
steps:
- name: Check gate
env:
BUILD_RESULT: ${{ needs['frontend-build'].result }}
BUNDLE_ANALYSIS_RESULT: ${{ needs['bundle-analysis'].result }}
E2E_RESULT: ${{ needs['e2e-tests'].result }}
FRONTEND_CHANGED: ${{ needs.changes.outputs.frontend_changed }}
INSTALL_RESULT: ${{ needs['install-dependencies'].result }}
@@ -301,8 +306,8 @@ jobs:
exit 1
fi
if [[ "${BUILD_RESULT}" != "success" ]]; then
echo "::error::Frontend build job result: ${BUILD_RESULT}"
if [[ "${BUNDLE_ANALYSIS_RESULT}" != "success" ]]; then
echo "::error::Frontend bundle-analysis job result: ${BUNDLE_ANALYSIS_RESULT}"
exit 1
fi
+3 -3
View File
@@ -17,12 +17,12 @@ jobs:
runs-on: ubuntu-slim
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Install Python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
with:
python-version: "3.14"
- name: Run prek
uses: j178/prek-action@5337cb91e0fa35a7ff31b9ca345126d8bbbcdf16 # v2.0.6
uses: j178/prek-action@bdca6f102f98e2b4c7029491a53dfd366469e33d # v2.0.4
+9 -9
View File
@@ -20,7 +20,7 @@ jobs:
statuses: read
steps:
- name: Wait for Docker build
uses: lewagon/wait-on-check-action@2271c86c146b96545b4e871b855e10ffa6f50773 # v1.9.0
uses: lewagon/wait-on-check-action@96d9100b431964d10e0136aff8b9ccb92470505e # v1.8.0
with:
ref: ${{ github.sha }}
check-name: 'Merge and Push Manifest'
@@ -35,7 +35,7 @@ jobs:
contents: read
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
# ---- Frontend Build ----
@@ -44,7 +44,7 @@ jobs:
with:
version: 10
- name: Use Node.js 24
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 24.x
package-manager-cache: false
@@ -55,11 +55,11 @@ jobs:
# ---- Backend Setup ----
- name: Set up Python
id: setup-python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
with:
python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
- name: Install uv
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
with:
version: ${{ env.DEFAULT_UV_VERSION }}
enable-cache: false
@@ -171,7 +171,7 @@ jobs:
fi
- name: Create release and changelog
id: create-release
uses: release-drafter/release-drafter@eada3c96a64734dd381cfbda23511034e328ddb0 # v7.6.0
uses: release-drafter/release-drafter@4d75298e00d9e34c483e5ff8c68d0ea1c1940c1e # v7.5.1
with:
name: Paperless-ngx ${{ steps.get-version.outputs.version }}
tag: ${{ steps.get-version.outputs.version }}
@@ -202,17 +202,17 @@ jobs:
pull-requests: write
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
ref: main
persist-credentials: true # for pushing changelog branch
- name: Set up Python
id: setup-python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
with:
python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
- name: Install uv
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
with:
version: ${{ env.DEFAULT_UV_VERSION }}
enable-cache: false
+4 -4
View File
@@ -22,11 +22,11 @@ jobs:
security-events: write
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Run zizmor
uses: zizmorcore/zizmor-action@6fc4b006235f201fdab3722e17240ab420d580e5 # v0.6.1
uses: zizmorcore/zizmor-action@192e21d79ab29983730a13d1382995c2307fbcaa # v0.5.7
semgrep:
name: Semgrep CE
runs-on: ubuntu-24.04
@@ -38,13 +38,13 @@ jobs:
security-events: write
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
- name: Run Semgrep
run: semgrep scan --config auto --sarif-output results.sarif
- name: Upload results to GitHub code scanning
uses: github/codeql-action/upload-sarif@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3
uses: github/codeql-action/upload-sarif@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
if: always()
with:
sarif_file: results.sarif
+3 -3
View File
@@ -34,12 +34,12 @@ jobs:
# Learn more about CodeQL language support at https://git.io/codeql-language-support
steps:
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
# Initializes the CodeQL tools for scanning.
- name: Initialize CodeQL
uses: github/codeql-action/init@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
with:
languages: ${{ matrix.language }}
# If you wish to specify custom queries, you can do so here or in a config file.
@@ -47,4 +47,4 @@ jobs:
# Prefix the list here with "+" to use these queries and those in the config file.
# queries: ./path/to/local/query, your-org/your-repo/queries@main
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3
uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
+2 -2
View File
@@ -17,12 +17,12 @@ jobs:
environment: translation-sync
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
token: ${{ secrets.PNGX_BOT_PAT }}
persist-credentials: false
- name: crowdin action
uses: crowdin/github-action@c7af9bc98b01694653031fef2a0dc6c7888ce9bc # v2.17.0
uses: crowdin/github-action@52aa776766211d83d975df51f3b9c53c2f8ba35f # v2.16.3
with:
upload_translations: false
download_translations: true
+1 -1
View File
@@ -31,7 +31,7 @@ jobs:
steps:
- name: Label PR by file path or branch name
# see .github/labeler.yml for the labeler config
uses: actions/labeler@bf12e9b00b37c5c0ca2b87b79b2daf7891dbda13 # v7.0.0
uses: actions/labeler@f27b608878404679385c85cfa523b85ccb86e213 # v6.1.0
with:
repo-token: ${{ secrets.GITHUB_TOKEN }}
- name: Label by size
+1 -1
View File
@@ -19,6 +19,6 @@ jobs:
if: github.event_name == 'pull_request_target' && (github.event.action == 'opened' || github.event.action == 'reopened') && github.event.pull_request.user.login != 'dependabot'
steps:
- name: Label PR with release-drafter
uses: release-drafter/release-drafter@eada3c96a64734dd381cfbda23511034e328ddb0 # v7.6.0
uses: release-drafter/release-drafter@4d75298e00d9e34c483e5ff8c68d0ea1c1940c1e # v7.5.1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+1 -1
View File
@@ -14,7 +14,7 @@ jobs:
issues: write
pull-requests: write
steps:
- uses: actions/stale@1e223db275d687790206a7acac4d1a11bd6fe629 # v10.4.0
- uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0
with:
days-before-stale: 7
days-before-close: 14
+9 -6
View File
@@ -14,7 +14,7 @@ jobs:
contents: write
steps:
- name: Checkout code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
env:
GH_REF: ${{ github.ref }} # sonar rule:githubactions:S7630 - avoid injection
with:
@@ -23,13 +23,13 @@ jobs:
persist-credentials: true # for pushing translation branch
- name: Set up Python
id: setup-python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
- name: Install system dependencies
run: |
sudo apt-get update -qq
sudo apt-get install -qq --no-install-recommends gettext
- name: Install uv
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
with:
version: ${{ env.DEFAULT_UV_VERSION }}
enable-cache: true
@@ -47,7 +47,7 @@ jobs:
with:
version: 10
- name: Use Node.js 24
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: 24.x
cache: 'pnpm'
@@ -61,13 +61,16 @@ jobs:
~/.cache
key: ${{ runner.os }}-frontenddeps-${{ hashFiles('src-ui/pnpm-lock.yaml') }}
- name: Install frontend dependencies
run: cd src-ui && pnpm install --frozen-lockfile
if: steps.cache-frontend-deps.outputs.cache-hit != 'true'
run: cd src-ui && pnpm install
- name: Re-link Angular cli
run: cd src-ui && pnpm link @angular/cli
- name: Generate frontend translation strings
run: |
cd src-ui
pnpm run ng extract-i18n
- name: Commit changes
uses: stefanzweifel/git-auto-commit-action@4a55954c782fc1ea30b9056cd3e7a2b40ca8887d # v7.2.0
uses: stefanzweifel/git-auto-commit-action@04702edda442b2e678b25b537cec683a1493fcb9 # v7.1.0
with:
file_pattern: 'src-ui/messages.xlf src/locale/en_US/LC_MESSAGES/django.po'
commit_message: "Auto translate strings"
+5 -5
View File
@@ -29,7 +29,7 @@ repos:
- id: check-case-conflict
- id: detect-private-key
- repo: https://github.com/codespell-project/codespell
rev: v2.4.3
rev: v2.4.2
hooks:
- id: codespell
additional_dependencies: [tomli]
@@ -38,7 +38,7 @@ repos:
- json
# See https://github.com/prettier/prettier/issues/15742 for the fork reason
- repo: https://github.com/rbubley/mirrors-prettier
rev: 'v3.9.6'
rev: 'v3.9.4'
hooks:
- id: prettier
types_or:
@@ -46,16 +46,16 @@ repos:
- ts
- markdown
additional_dependencies:
- prettier@3.9.6
- prettier@3.9.4
- 'prettier-plugin-organize-imports@4.3.0'
# Python hooks
- repo: https://github.com/astral-sh/ruff-pre-commit
rev: v0.16.1
rev: v0.15.20
hooks:
- id: ruff-check
- id: ruff-format
- repo: https://github.com/tox-dev/pyproject-fmt
rev: "v2.26.0"
rev: "v2.25.1"
hooks:
- id: pyproject-fmt
additional_dependencies: [tomli]
+1 -1
View File
@@ -30,7 +30,7 @@ RUN set -eux \
# Purpose: Installs s6-overlay and rootfs
# Comments:
# - Don't leave anything extra in here either
FROM ghcr.io/astral-sh/uv:0.11.32-python3.12-trixie-slim AS s6-overlay-base
FROM ghcr.io/astral-sh/uv:0.11.28-python3.12-trixie-slim AS s6-overlay-base
WORKDIR /usr/src/s6
+2 -2
View File
@@ -24,7 +24,7 @@ services:
network_mode: host
restart: unless-stopped
greenmail:
image: docker.io/greenmail/standalone:2.1.11
image: docker.io/greenmail/standalone:2.1.9
hostname: greenmail
container_name: greenmail
environment:
@@ -35,7 +35,7 @@ services:
- "3143:3143" # IMAP
restart: unless-stopped
nginx:
image: docker.io/nginx:1.31.3-alpine
image: docker.io/nginx:1.31.2-alpine
hostname: nginx
container_name: nginx
ports:
-16
View File
@@ -299,8 +299,6 @@ optional arguments:
-sm, --split-manifest
-z, --zip
-zn, --zip-name
--zip-compression
--zip-compression-level
--data-only
--no-progress-bar
--passphrase
@@ -363,19 +361,6 @@ If `-z` or `--zip` is provided, the export will be a zip file
in the target directory, named according to the current local date or the
value set in `-zn` or `--zip-name`.
The compression method for the zip can be set with `--zip-compression`
(`stored`, `deflated` (default), `bzip2`, `lzma`, or `zstd`) and tuned with
`--zip-compression-level` (deflated: 09, bzip2: 19, zstd: -2222; ignored
for `stored` and `lzma`). Both options require `--zip`.
!!! warning
`zstd` compression requires Python 3.14 or newer on **both** the machine
creating the export and any machine importing it. An archive compressed with
`zstd` (or `lzma`/`bzip2` where those modules are unavailable) cannot be
imported on a runtime that lacks the codec; the importer will refuse it with
a clear error. The default `deflated` is universally readable.
If `--data-only` is provided, only the database will be exported. This option is intended
to facilitate database upgrades without needing to clean documents and thumbnails from the media directory.
@@ -714,7 +699,6 @@ document_fuzzy_match [--ratio] [--processes N]
| --ratio | No | 85.0 | a number between 0 and 100, setting how similar a document must be for it to be reported. Higher numbers mean more similarity. |
| --processes | No | 1/4 of system cores | Number of processes to use for matching. Setting 1 disables multiple processes |
| --delete | No | False | If provided, one document of a matched pair above the ratio will be deleted. |
| --url | No | blank | If an instance URL is provided, the output table will show URLs to each documents instead of the document ID and name. |
!!! warning
+6 -1
View File
@@ -129,6 +129,10 @@ At a minimum you need to enable AI and choose an LLM backend:
and/or [`PAPERLESS_AI_LLM_ENDPOINT`](configuration.md#PAPERLESS_AI_LLM_ENDPOINT). Ollama
requires `PAPERLESS_AI_LLM_ENDPOINT` pointing at your Ollama server.
See the community-maintained wiki page on
[choosing AI models](https://github.com/paperless-ngx/paperless-ngx/wiki/AI-Model-Recommendations)
for suggested generation and embedding models.
### AI-assisted suggestions
With AI enabled, Paperless-ngx can suggest a title, tags, correspondent, document type,
@@ -808,7 +812,8 @@ Third-party parser plugins extend Paperless-ngx to support additional file
formats. A plugin is a Python package that advertises itself under the
`paperless_ngx.parsers` entry point group. Refer to the
[developer documentation](development.md#making-custom-parsers) for how to
create one.
create one, or see the wiki for a community-maintained list of
[parser plugins](https://github.com/paperless-ngx/paperless-ngx/wiki/Related-Projects#parser-plugins).
!!! warning "Third-party plugins are not officially supported"
+8 -5
View File
@@ -948,11 +948,10 @@ for display in the web interface.
!!! note
The **remote OCR parser** (Azure AI) also honors this setting: when
no archive is requested (`never`, or `auto` with a born-digital PDF),
the remote engine is skipped entirely and locally-extracted text is
used instead, avoiding an unnecessary API call and a duplicate text
layer.
The **remote OCR parser** (Azure AI) always produces a searchable
PDF and stores it as the archive copy, regardless of this setting.
`ARCHIVE_FILE_GENERATION=never` has no effect when the remote
parser handles a document.
#### [`PAPERLESS_OCR_CLEAN=<mode>`](#PAPERLESS_OCR_CLEAN) {#PAPERLESS_OCR_CLEAN}
@@ -2070,6 +2069,8 @@ suggestions. This setting is required to be set to true in order to use the AI f
models supported by the current embedding backend. If not supplied, defaults to
"text-embedding-3-small" for the OpenAI-compatible backend,
"sentence-transformers/all-MiniLM-L6-v2" for Huggingface, and "embeddinggemma" for Ollama.
See [choosing AI models](https://github.com/paperless-ngx/paperless-ngx/wiki/AI-Model-Recommendations)
for language and resource considerations.
Defaults to None.
@@ -2126,6 +2127,8 @@ setting is required to be set to use the AI features.
: The model to use for the AI backend, i.e. "gpt-3.5-turbo", "gpt-4" or any of the models supported
by the current backend. If not supplied, defaults to "gpt-3.5-turbo" for the OpenAI-compatible
backend and "llama3.1" for Ollama.
See [choosing AI models](https://github.com/paperless-ngx/paperless-ngx/wiki/AI-Model-Recommendations)
for local versus remote and model-size considerations.
Defaults to None.
+8 -8
View File
@@ -156,7 +156,7 @@ The new settings are independent:
### Database configuration
If you changed OCR settings via the admin UI (ApplicationConfiguration), the database values are **migrated automatically** during the upgrade. `mode` values (`skip` / `skip_noarchive`) are mapped to their new equivalents and `skip_archive_file` values are converted to the new `archive_file_generation` field. After upgrading, review the OCR settings in the admin UI to confirm the migrated values match your intent.
If you changed OCR settings via the admin UI (ApplicationConfiguration), the database values are **migrated automatically** during the upgrade. `mode` values (`skip` / `skip_noarchive`) are mapped to their new equivalents and explicit `skip_archive_file` values are converted to the new `archive_file_generation` field. Users who relied on the old defaults must set `archive_file_generation` to `always` to preserve the v2 behaviour of always creating an archive. After upgrading, review the OCR settings in the admin UI to confirm the migrated values match your intent.
### Action Required
@@ -165,8 +165,9 @@ Remove any `PAPERLESS_OCR_SKIP_ARCHIVE_FILE` variable from your environment. If
```bash
# v2: skip OCR when text present, always archive
PAPERLESS_OCR_MODE=skip
# v3: equivalent (auto is the new default)
# No change needed - auto is the default
# v3: equivalent
PAPERLESS_OCR_MODE=auto
PAPERLESS_ARCHIVE_FILE_GENERATION=always
# v2: skip OCR when text present, skip archive too
PAPERLESS_OCR_MODE=skip_noarchive
@@ -187,11 +188,10 @@ PAPERLESS_ARCHIVE_FILE_GENERATION=auto
### Remote OCR parser
If you use the **remote OCR parser** (Azure AI), `ARCHIVE_FILE_GENERATION` is
honored the same way as for the local engine: when no archive is requested
(`never`, or `auto` with a born-digital PDF), the remote engine is skipped
entirely and locally-extracted text is used instead, avoiding an unnecessary
API call and a duplicate text layer.
If you use the **remote OCR parser** (Azure AI), note that it always produces a
searchable PDF and stores it as the archive copy. `ARCHIVE_FILE_GENERATION=never`
has no effect for documents handled by the remote parser - the archive is produced
unconditionally by the remote engine.
## Search Index (Whoosh -> Tantivy)
+1 -3
View File
@@ -576,9 +576,7 @@ The following workflow action types are available:
- Tags, correspondent, document type and storage path
- Document owner
- View and / or edit permissions to users or groups
- Custom fields, optionally with a value. If no value is set, the field is only added to the
document and any value it may already have is left untouched. If a value is set, it will
overwrite an existing value of that field on the document.
- Custom fields. Note that no value for the field will be set
##### Removal {#workflow-action-removal}
+24 -24
View File
@@ -21,7 +21,7 @@ dependencies = [
"channels~=4.2",
"channels-redis~=4.2",
"concurrent-log-handler~=0.9.25",
"dateparser~=1.4",
"dateparser~=1.2",
# WARNING: django does not use semver.
# Only patch versions are guaranteed to not introduce breaking changes.
"django~=5.2.13",
@@ -32,32 +32,33 @@ dependencies = [
"django-cors-headers~=4.9.0",
"django-extensions~=4.1",
"django-filter~=25.1",
"django-guardian~=3.3.3",
"django-guardian~=3.3.0",
"django-multiselectfield~=1.0.1",
"django-rich~=2.2.0",
"django-soft-delete~=1.0.18",
"django-treenode>=0.24",
"djangorestframework~=3.16",
"drf-spectacular~=0.30",
"drf-spectacular-sidecar~=2026.7.1",
"djangorestframework-guardian~=0.4.0",
"drf-spectacular~=0.28",
"drf-spectacular-sidecar~=2026.5.1",
"drf-writable-nested~=0.7.1",
"filelock~=3.32.0",
"filelock~=3.29.0",
"flower~=2.0.1",
"gotenberg-client~=0.14.0",
"httpx-oauth~=0.17",
"ijson>=3.5.1",
"imap-tools~=1.14.0",
"httpx-oauth~=0.16",
"ijson>=3.2",
"imap-tools~=1.13.0",
"jinja2~=3.1.5",
"langdetect~=1.0.9",
"llama-index-core>=0.14.23",
"llama-index-core>=0.14.22",
"llama-index-embeddings-huggingface>=0.6.1",
"llama-index-embeddings-ollama>=0.9",
"llama-index-embeddings-openai-like>=0.2.2",
"llama-index-llms-ollama>=0.9.1",
"llama-index-llms-openai-like>=0.7.1",
"nltk~=3.10.0",
"ocrmypdf~=17.7.0",
"openai>=2.48",
"ocrmypdf~=17.4.2",
"openai>=2.32",
"pathvalidate~=3.3.1",
"pdf2image~=1.17.0",
"python-dateutil~=2.9.0",
@@ -67,17 +68,17 @@ dependencies = [
"python-magic~=0.4.27",
"rapidfuzz~=3.14.5",
"redis[hiredis]~=5.2.1",
"regex>=2026.7.19",
"scikit-learn~=1.9.0",
"sentence-transformers>=5.6.1",
"regex>=2026.4.4",
"scikit-learn~=1.8.0",
"sentence-transformers>=5.4.1",
"setproctitle~=1.3.4",
"sqlite-vec==0.1.9",
"tantivy~=0.26.0",
"tika-client~=0.11.0",
"torch~=2.13.0",
"watchfiles>=1.2",
"watchfiles>=1.1.1",
"whitenoise~=6.11",
"zxing-cpp~=3.1.0",
"zxing-cpp~=3.0.0",
]
[project.optional-dependencies]
mariadb = [
@@ -100,25 +101,25 @@ dev = [
{ include-group = "testing" },
]
docs = [
"zensical>=0.0.51",
"zensical>=0.0.47",
]
lint = [
"prek~=0.4.11",
"ruff~=0.16.1",
"prek~=0.3.10",
"ruff~=0.15.20",
]
testing = [
"daphne",
"factory-boy~=3.3.1",
"faker~=40.36.0",
"faker~=40.15.0",
"imagehash",
"pytest~=9.1.1",
"pytest~=9.0.3",
"pytest-cov~=7.1.0",
"pytest-django~=4.12.0",
"pytest-env~=1.7.0",
"pytest-env~=1.6.0",
"pytest-httpx",
"pytest-mock~=3.15.1",
# "pytest-randomly~=4.0.1",
"pytest-rerunfailures~=16.4",
"pytest-rerunfailures~=16.1",
"pytest-sugar",
"pytest-xdist~=3.8.0",
"time-machine>=2.13",
@@ -182,7 +183,6 @@ output-format = "grouped"
line-ending = "lf"
[tool.ruff.lint]
# https://docs.astral.sh/ruff/rules/
select = [ "E4", "E7", "E9", "F" ]
extend-select = [
"COM", # https://docs.astral.sh/ruff/rules/#flake8-commas-com
"DJ", # https://docs.astral.sh/ruff/rules/#flake8-django-dj
+9 -12
View File
@@ -56,13 +56,13 @@
},
"architect": {
"build": {
"builder": "@angular/build:application",
"builder": "@angular-builders/custom-webpack:browser",
"options": {
"outputPath": {
"base": "dist/paperless-ui",
"browser": ""
"customWebpackConfig": {
"path": "./extra-webpack.config.ts"
},
"browser": "src/main.ts",
"outputPath": "dist/paperless-ui",
"main": "src/main.ts",
"outputHashing": "none",
"index": "src/index.html",
"polyfills": [
@@ -97,7 +97,6 @@
"scripts": [],
"allowedCommonJsDependencies": [
"file-saver",
"mime-names",
"utif"
],
"extractLicenses": false,
@@ -118,13 +117,11 @@
"with": "src/environments/environment.prod.ts"
}
],
"outputPath": {
"base": "../src/documents/static/frontend/",
"browser": ""
},
"outputPath": "../src/documents/static/frontend/",
"optimization": true,
"outputHashing": "none",
"sourceMap": false,
"namedChunks": false,
"extractLicenses": true,
"budgets": [
{
@@ -148,7 +145,7 @@
"defaultConfiguration": ""
},
"serve": {
"builder": "@angular/build:dev-server",
"builder": "@angular-builders/custom-webpack:dev-server",
"options": {
"buildTarget": "paperless-ui:build:en-US"
},
@@ -159,7 +156,7 @@
}
},
"extract-i18n": {
"builder": "@angular/build:extract-i18n",
"builder": "@angular-builders/custom-webpack:extract-i18n",
"options": {
"buildTarget": "paperless-ui:build"
}
+24
View File
@@ -0,0 +1,24 @@
import {
CustomWebpackBrowserSchema,
TargetOptions,
} from '@angular-builders/custom-webpack'
import * as webpack from 'webpack'
const { codecovWebpackPlugin } = require('@codecov/webpack-plugin')
export default (
config: webpack.Configuration,
options: CustomWebpackBrowserSchema,
targetOptions: TargetOptions
) => {
if (config.plugins) {
config.plugins.push(
codecovWebpackPlugin({
enableBundleAnalysis: process.env.CODECOV_TOKEN !== undefined,
bundleName: 'paperless-ngx',
uploadToken: process.env.CODECOV_TOKEN,
})
)
}
return config
}
+893 -1340
View File
File diff suppressed because it is too large Load Diff
+31 -28
View File
@@ -11,16 +11,16 @@
},
"private": true,
"dependencies": {
"@angular/cdk": "^22.0.6",
"@angular/common": "~22.1.0",
"@angular/compiler": "~22.1.0",
"@angular/core": "~22.1.0",
"@angular/forms": "~22.1.0",
"@angular/localize": "~22.1.0",
"@angular/platform-browser": "~22.1.0",
"@angular/router": "~22.1.0",
"@angular/cdk": "^22.0.3",
"@angular/common": "~22.0.5",
"@angular/compiler": "~22.0.5",
"@angular/core": "~22.0.5",
"@angular/forms": "~22.0.5",
"@angular/localize": "~22.0.5",
"@angular/platform-browser": "~22.0.5",
"@angular/router": "~22.0.5",
"@ng-bootstrap/ng-bootstrap": "^21.0.0",
"@ng-select/ng-select": "^23.5.0",
"@ng-select/ng-select": "^23.2.0",
"@ngneat/dirty-check-forms": "^3.0.3",
"@popperjs/core": "^2.11.8",
"bootstrap": "^5.3.8",
@@ -32,31 +32,33 @@
"ngx-device-detector": "^12.0.0",
"ngx-ui-tour-ng-bootstrap": "^19.0.0",
"normalize-diacritics": "^5.0.0",
"pdfjs-dist": "^6.2.108",
"pdfjs-dist": "^6.0.227",
"rxjs": "^7.8.2",
"tslib": "^2.8.1",
"utif": "^3.1.0",
"uuid": "^14.0.1"
},
"devDependencies": {
"@angular-builders/custom-webpack": "^22.0.1",
"@angular-builders/jest": "^22.0.1",
"@angular-devkit/core": "^22.1.2",
"@angular-devkit/schematics": "^22.1.2",
"@angular-eslint/builder": "22.1.0",
"@angular-eslint/eslint-plugin": "22.1.0",
"@angular-eslint/eslint-plugin-template": "22.1.0",
"@angular-eslint/schematics": "22.1.0",
"@angular-eslint/template-parser": "22.1.0",
"@angular/build": "22.1.2",
"@angular/cli": "22.1.2",
"@angular/compiler-cli": "~22.1.0",
"@playwright/test": "^1.62.0",
"@angular-devkit/core": "^22.0.5",
"@angular-devkit/schematics": "^22.0.5",
"@angular-eslint/builder": "22.0.0",
"@angular-eslint/eslint-plugin": "22.0.0",
"@angular-eslint/eslint-plugin-template": "22.0.0",
"@angular-eslint/schematics": "22.0.0",
"@angular-eslint/template-parser": "22.0.0",
"@angular/build": "^22.0.5",
"@angular/cli": "~22.0.5",
"@angular/compiler-cli": "~22.0.5",
"@codecov/webpack-plugin": "^2.0.1",
"@playwright/test": "^1.61.1",
"@types/jest": "^30.0.0",
"@types/node": "^26.1.1",
"@typescript-eslint/eslint-plugin": "^8.65.0",
"@typescript-eslint/parser": "^8.65.0",
"@typescript-eslint/utils": "^8.65.0",
"eslint": "^10.8.0",
"@types/node": "^26.0.0",
"@typescript-eslint/eslint-plugin": "^8.62.0",
"@typescript-eslint/parser": "^8.62.0",
"@typescript-eslint/utils": "^8.62.0",
"eslint": "^10.5.0",
"jest": "30.4.2",
"jest-environment-jsdom": "^30.4.1",
"jest-junit": "^17.0.0",
@@ -64,7 +66,8 @@
"jest-websocket-mock": "^2.5.0",
"prettier-plugin-organize-imports": "^4.3.0",
"ts-node": "~10.9.1",
"typescript": "^6.0.3"
"typescript": "^6.0.3",
"webpack": "^5.107.2"
},
"packageManager": "pnpm@11.15.1"
"packageManager": "pnpm@10.26.0"
}
+2008 -2204
View File
File diff suppressed because it is too large Load Diff
-1
View File
@@ -5,7 +5,6 @@ trustPolicy: no-downgrade
trustPolicyExclude:
- "chokidar@4.0.3"
- "semver@6.3.1 || 5.7.2"
blockExoticSubdeps: true
allowBuilds:
"@parcel/watcher": true
canvas: true
@@ -576,7 +576,7 @@ describe('TasksComponent', () => {
expect(dismissSpy).toHaveBeenCalledWith(new Set([tasks[0].id, tasks[1].id]))
expect(toastSpy).toHaveBeenCalledWith('Error dismissing tasks', error)
expect(modal.componentInstance.buttonsEnabled()).toBe(true)
expect(modal.componentInstance.buttonsEnabled).toBe(true)
expect(component.selectedTasks.size).toBe(0)
})
@@ -642,7 +642,7 @@ describe('TasksComponent', () => {
expect(dismissSpy).toHaveBeenCalled()
expect(toastSpy).toHaveBeenCalledWith('Error dismissing tasks', error)
expect(modal.componentInstance.buttonsEnabled()).toBe(true)
expect(modal.componentInstance.buttonsEnabled).toBe(true)
})
it('should dismiss the currently visible scoped and filtered tasks', () => {
@@ -316,7 +316,7 @@ export class TasksComponent
modal.componentInstance.btnClass = 'btn-warning'
modal.componentInstance.btnCaption = $localize`Dismiss`
modal.componentInstance.confirmClicked.pipe(first()).subscribe(() => {
modal.componentInstance.buttonsEnabled.set(false)
modal.componentInstance.buttonsEnabled = false
modal.close()
this.tasksService.dismissTasks(tasks).subscribe({
next: () => {
@@ -324,7 +324,7 @@ export class TasksComponent
},
error: (e) => {
this.toastService.showError($localize`Error dismissing tasks`, e)
modal.componentInstance.buttonsEnabled.set(true)
modal.componentInstance.buttonsEnabled = true
},
})
this.clearSelection()
@@ -350,7 +350,7 @@ export class TasksComponent
modal.componentInstance.btnClass = 'btn-warning'
modal.componentInstance.btnCaption = $localize`Dismiss`
modal.componentInstance.confirmClicked.pipe(first()).subscribe(() => {
modal.componentInstance.buttonsEnabled.set(false)
modal.componentInstance.buttonsEnabled = false
modal.close()
this.tasksService.dismissAllTasks().subscribe({
next: () => {
@@ -358,7 +358,7 @@ export class TasksComponent
},
error: (e) => {
this.toastService.showError($localize`Error dismissing tasks`, e)
modal.componentInstance.buttonsEnabled.set(true)
modal.componentInstance.buttonsEnabled = true
},
})
this.clearSelection()
@@ -82,7 +82,7 @@ export class TrashComponent
modal.componentInstance.confirmClicked
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe(() => {
modal.componentInstance.buttonsEnabled.set(false)
modal.componentInstance.buttonsEnabled = false
this.trashService.emptyTrash([document.id]).subscribe({
next: () => {
this.toastService.showInfo(
@@ -146,7 +146,7 @@ export class UsersAndGroupsComponent
modal.componentInstance.btnClass = 'btn-danger'
modal.componentInstance.btnCaption = $localize`Proceed`
modal.componentInstance.confirmClicked.subscribe(() => {
modal.componentInstance.buttonsEnabled.set(false)
modal.componentInstance.buttonsEnabled = false
this.usersService.delete(user).subscribe({
next: () => {
modal.close()
@@ -199,7 +199,7 @@ export class UsersAndGroupsComponent
modal.componentInstance.btnClass = 'btn-danger'
modal.componentInstance.btnCaption = $localize`Proceed`
modal.componentInstance.confirmClicked.subscribe(() => {
modal.componentInstance.buttonsEnabled.set(false)
modal.componentInstance.buttonsEnabled = false
this.groupsService.delete(group).subscribe({
next: () => {
modal.close()
@@ -104,14 +104,14 @@
</h6>
<ul class="nav flex-column mb-2" cdkDropList (cdkDropListDropped)="onDrop($event)">
@for (view of savedViewService.sidebarViews; track view.id) {
<li class="nav-item w-100 app-link" cdkDrag [cdkDragDisabled]="!settingsService.organizingSidebarSavedViews() || !canSaveSettings"
<li class="nav-item w-100 app-link" cdkDrag [cdkDragDisabled]="!settingsService.organizingSidebarSavedViews()"
cdkDragPreviewContainer="parent" cdkDragPreviewClass="navItemDrag" (cdkDragStarted)="onDragStart($event)"
(cdkDragEnded)="onDragEnd($event)">
<a class="nav-link" routerLink="view/{{view.id}}"
routerLinkActive="active" (click)="closeMenu()" [ngbPopover]="view.name"
[disablePopover]="!slimSidebarEnabled" placement="end" container="body" triggers="mouseenter:mouseleave"
popoverClass="popover-slim">
<i-bs class="me-2" [name]="view.icon || 'funnel'"></i-bs><span><div class="d-inline-flex view-name"><span class="overflow-hidden" [class.text-wrap]="!slimSidebarEnabled">{{view.name}}</span></div>
<i-bs class="me-2" name="funnel"></i-bs><span><div class="d-inline-flex view-name"><span class="overflow-hidden" [class.text-wrap]="!slimSidebarEnabled">{{view.name}}</span></div>
@if (showSidebarCounts && !slimSidebarEnabled) {
<span class="badge bg-info text-dark ms-2 d-inline">{{ savedViewService.getDocumentCount(view) }}</span>
}
@@ -120,7 +120,7 @@
<span class="badge bg-info text-dark position-absolute top-0 end-0 d-none d-md-block">{{ savedViewService.getDocumentCount(view) }}</span>
}
</a>
@if (settingsService.organizingSidebarSavedViews() && canSaveSettings) {
@if (settingsService.organizingSidebarSavedViews()) {
<div class="position-absolute end-0 top-0 px-3 py-2" [class.me-n3]="slimSidebarEnabled" cdkDragHandle>
<i-bs name="grip-vertical"></i-bs>
</div>
@@ -47,12 +47,11 @@
.search-container {
max-height: 4.5rem;
overflow: visible;
overflow: hidden;
transition: max-height .2s ease, opacity .2s ease, padding-top .2s ease, padding-bottom .2s ease;
&.mobile-hidden {
max-height: 0;
overflow: hidden;
opacity: 0;
padding-top: 0 !important;
padding-bottom: 0 !important;
@@ -12,10 +12,10 @@
}
</div>
<div class="modal-footer">
<button type="button" class="btn" [class]="cancelBtnClass" (click)="cancel()" [disabled]="!buttonsEnabled()">
<button type="button" class="btn" [class]="cancelBtnClass" (click)="cancel()" [disabled]="!buttonsEnabled">
<span class="d-inline-block" style="padding-bottom: 1px;">{{cancelBtnCaption}}</span>
</button>
<button type="button" class="btn" [class]="btnClass" (click)="confirm()" [disabled]="!confirmButtonEnabled || !buttonsEnabled()">
<button type="button" class="btn" [class]="btnClass" (click)="confirm()" [disabled]="!confirmButtonEnabled || !buttonsEnabled">
<span>
{{btnCaption}}
<span class="visually-hidden">{{ seconds | number: '1.0-0' }} seconds</span>
@@ -25,7 +25,7 @@
}
</button>
@if (alternativeBtnCaption) {
<button type="button" class="btn" [class]="alternativeBtnClass" (click)="alternative()" [disabled]="!alternativeButtonEnabled || !buttonsEnabled()">
<button type="button" class="btn" [class]="alternativeBtnClass" (click)="alternative()" [disabled]="!alternativeButtonEnabled || !buttonsEnabled">
{{alternativeBtnCaption}}
</button>
}
@@ -64,22 +64,6 @@ describe('ConfirmDialogComponent', () => {
expect(confirmSubjectResult).toBeTruthy()
})
it('should re-render the buttons when they are toggled from outside', async () => {
const confirmButton: HTMLButtonElement =
fixture.nativeElement.querySelectorAll('.modal-footer button')[1]
expect(confirmButton.disabled).toBeFalsy()
// Deliberately no detectChanges: a request callback toggling this is all
// that happens, and nothing else schedules a render for the modal
component.buttonsEnabled.set(false)
await fixture.whenStable()
expect(confirmButton.disabled).toBeTruthy()
component.buttonsEnabled.set(true)
await fixture.whenStable()
expect(confirmButton.disabled).toBeFalsy()
})
it('should support cancel & close modal', () => {
let confirmSubjectResult
const closeModalSpy = jest.spyOn(modal, 'close')
@@ -1,12 +1,5 @@
import { DecimalPipe } from '@angular/common'
import {
Component,
EventEmitter,
Input,
Output,
inject,
signal,
} from '@angular/core'
import { Component, EventEmitter, Input, Output, inject } from '@angular/core'
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
import { Subject } from 'rxjs'
import { LoadingComponentWithPermissions } from '../../loading-component/loading.component'
@@ -53,7 +46,8 @@ export class ConfirmDialogComponent extends LoadingComponentWithPermissions {
@Input()
cancelBtnCaption = $localize`Cancel`
readonly buttonsEnabled = signal(true)
@Input()
buttonsEnabled = true
confirmButtonEnabled = true
alternativeButtonEnabled = true
@@ -56,10 +56,10 @@
}
</div>
<div class="modal-footer">
<button type="button" class="btn" [class]="cancelBtnClass" (click)="cancel()" [disabled]="!buttonsEnabled()">
<button type="button" class="btn" [class]="cancelBtnClass" (click)="cancel()" [disabled]="!buttonsEnabled">
<span class="d-inline-block" style="padding-bottom: 1px;">{{cancelBtnCaption}}</span>
</button>
<button type="button" class="btn" [class]="btnClass" (click)="confirm()" [disabled]="!confirmButtonEnabled || !buttonsEnabled()">
<button type="button" class="btn" [class]="btnClass" (click)="confirm()" [disabled]="!confirmButtonEnabled || !buttonsEnabled">
{{btnCaption}}
</button>
</div>
@@ -57,7 +57,7 @@
class="btn"
[class]="cancelBtnClass"
(click)="cancel()"
[disabled]="!buttonsEnabled()"
[disabled]="!buttonsEnabled"
>
<span class="d-inline-block" style="padding-bottom: 1px;">
{{cancelBtnCaption}}
@@ -68,7 +68,7 @@
class="btn"
[class]="btnClass"
(click)="confirm()"
[disabled]="!confirmButtonEnabled || !buttonsEnabled()"
[disabled]="!confirmButtonEnabled || !buttonsEnabled"
>
{{btnCaption}}
</button>
@@ -34,10 +34,10 @@
<p class="mb-0 small"><b>{{messageBold}}</b></p>
}
</div>
<button type="button" class="btn" [class]="cancelBtnClass" (click)="cancel()" [disabled]="!buttonsEnabled()">
<button type="button" class="btn" [class]="cancelBtnClass" (click)="cancel()" [disabled]="!buttonsEnabled">
<span class="d-inline-block" style="padding-bottom: 1px;">{{cancelBtnCaption}}</span>
</button>
<button type="button" class="btn" [class]="btnClass" (click)="confirm()" [disabled]="!confirmButtonEnabled || !buttonsEnabled() || degrees === 0">
<button type="button" class="btn" [class]="btnClass" (click)="confirm()" [disabled]="!confirmButtonEnabled || !buttonsEnabled || degrees === 0">
{{btnCaption}}
@if (!confirmButtonEnabled) {
<ngb-progressbar style="height: 1px;" type="dark" [max]="secondsTotal" [value]="seconds"></ngb-progressbar>
@@ -1,4 +1,4 @@
<div ngbDropdown #fieldDropdown="ngbDropdown" (openChange)="onOpenClose($event)" placement="bottom-end" [popperOptions]="popperOptions">
<div ngbDropdown #fieldDropdown="ngbDropdown" (openChange)="onOpenClose($event)" [popperOptions]="popperOptions">
<button type="button" class="btn btn-sm btn-outline-primary" id="customFieldsDropdown" [disabled]="disabled" ngbDropdownToggle>
<i-bs name="ui-radios"></i-bs><div class="d-none d-lg-inline ms-1"><ng-container i18n>Custom Fields</ng-container></div>
</button>
@@ -6,16 +6,5 @@
&.show {
margin-left: -245px !important;
}
.list-group {
max-height: min(50dvh, 20rem);
overflow-y: auto;
}
.list-group-item:first-child {
position: sticky;
top: 0;
z-index: 1;
}
}
}
@@ -1019,28 +1019,4 @@ describe('WorkflowEditDialogComponent', () => {
'pass3',
])
})
it('should parse passwords again when retrying a failed save', () => {
component.object = {
name: 'Workflow with blank Passwords',
id: 1,
order: null,
enabled: true,
triggers: [],
actions: [
{
id: 1,
type: WorkflowActionType.PasswordRemoval,
passwords: [],
},
],
}
component.ngOnInit()
component.save()
component.objectForm.get('order').setValue(1)
expect(() => component.save()).not.toThrow()
expect(component.objectForm.get('actions').value[0].passwords).toEqual([])
})
})
@@ -1207,8 +1207,9 @@ export class WorkflowEditDialogComponent
return passwords.join('\n')
}
private parsePasswords(value: string | string[] = ''): string[] {
return (Array.isArray(value) ? value : value.split(/[\n,]+/))
private parsePasswords(value: string = ''): string[] {
return value
.split(/[\n,]+/)
.map((entry) => entry.trim())
.filter((entry) => entry.length > 0)
}
@@ -49,7 +49,7 @@
</cdk-virtual-scroll-viewport>
}
@if (editing) {
@if (filteredItems.length === 0 && createRef !== undefined && filterText?.length > 0) {
@if (filteredItems.length === 0 && createRef !== undefined) {
<button class="list-group-item list-group-item-action bg-light" (click)="createClicked()" [disabled]="disabled">
<small class="ms-2"><ng-container i18n>Create</ng-container> "{{filterText}}"</small>
<i-bs width="1.5em" height="1em" name="plus"></i-bs>
@@ -62,7 +62,7 @@
</button>
}
}
@if (extraButtonTitle && (showExtraButtonIfEmpty || filteredItems?.length > 0)) {
@if (extraButtonTitle) {
<button class="list-group-item list-group-item-action bg-light d-flex align-items-center" (click)="extraButtonClicked($event)" [disabled]="disabled">
<small class="ms-2 fw-bold">{{extraButtonTitle}}</small>
<i-bs width="1.5em" height="1em" name="arrow-right"></i-bs>
@@ -911,25 +911,6 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
expect(createSpy).toHaveBeenCalled()
})
it('should only show create when a non-empty filter has no matches', () => {
component.selectionModel.items = []
component.icon = 'tag-fill'
component.editing = true
component.createRef = jest.fn()
fixture.detectChanges()
expect(fixture.nativeElement.textContent).not.toContain('Create')
component.listFilterEnter()
expect(component.createRef).not.toHaveBeenCalled()
const filterInput: HTMLInputElement =
fixture.nativeElement.querySelector('input[type="text"]')
filterInput.value = 'FooBar'
filterInput.dispatchEvent(new Event('input'))
fixture.detectChanges()
expect(fixture.nativeElement.textContent).toContain('Create "FooBar"')
})
it('should exclude item and trigger change event', () => {
const id = 1
const state = ToggleableItemState.Selected
@@ -989,18 +970,4 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
expect(extraButtonClicked).toBeTruthy()
expect(applied).toBeFalsy()
})
it('should only show the extra button for an empty result when enabled', () => {
component.selectionModel.items = items
component.icon = 'tag-fill'
component.extraButtonTitle = 'Extra'
component.filterText = 'FooBar'
fixture.detectChanges()
expect(fixture.nativeElement.textContent).not.toContain('Extra')
fixture.componentRef.setInput('showExtraButtonIfEmpty', true)
fixture.detectChanges()
expect(fixture.nativeElement.textContent).toContain('Extra')
})
})
@@ -774,9 +774,6 @@ export class FilterableDropdownComponent
@Input()
extraButtonTitle: string
@Input()
showExtraButtonIfEmpty: boolean = false
creating: boolean = false
@Output()
@@ -895,11 +892,7 @@ export class FilterableDropdownComponent
this.dropdown.close()
}
}, 200)
} else if (
filtered.length == 0 &&
this.createRef &&
this.filterText?.length > 0
) {
} else if (filtered.length == 0 && this.createRef) {
this.createClicked()
}
}
@@ -52,10 +52,10 @@ describe('CustomFieldsValuesComponent', () => {
})
it('should set selectedFields and map values correctly', () => {
component.value = { 1: 'value1', 3: 0, 4: false }
component.selectedFields = [1, 2, 3, 4]
expect(component.selectedFields).toEqual([1, 2, 3, 4])
expect(component.value).toEqual({ 1: 'value1', 2: null, 3: 0, 4: false })
component.value = { 1: 'value1' }
component.selectedFields = [1, 2]
expect(component.selectedFields).toEqual([1, 2])
expect(component.value).toEqual({ 1: 'value1', 2: null })
})
it('should return the correct custom field by id', () => {
@@ -77,7 +77,7 @@ export class CustomFieldsValuesComponent extends AbstractInputComponent<Object>
this._selectedFields = newFields
// map the selected fields to an object with field_id as key and value as value
this.value = newFields.reduce((acc, fieldId) => {
acc[fieldId] = this.value?.[fieldId] ?? null
acc[fieldId] = this.value?.[fieldId] || null
return acc
}, {})
this.onChange(this.value)
@@ -36,16 +36,7 @@
(focus)="clearLastSearchTerm()"
(clear)="clearLastSearchTerm()"
(blur)="onBlur()">
<ng-template ng-label-tmp let-item="item">
@if (iconField && item[iconField]) {
<i-bs class="me-2" [name]="item[iconField]"></i-bs>
}
<span [title]="item[bindLabel]">{{item[bindLabel]}}</span>
</ng-template>
<ng-template ng-option-tmp let-item="item">
@if (iconField && item[iconField]) {
<i-bs class="me-2" [name]="item[iconField]"></i-bs>
}
<span [title]="item[bindLabel]">{{item[bindLabel]}}</span>
</ng-template>
</ng-select>
@@ -35,7 +35,7 @@ import { AbstractInputComponent } from '../abstract-input'
NgxBootstrapIconsModule,
],
})
export class SelectComponent extends AbstractInputComponent<number | string> {
export class SelectComponent extends AbstractInputComponent<number> {
constructor() {
super()
this.addItemRef = this.addItem.bind(this)
@@ -100,9 +100,6 @@ export class SelectComponent extends AbstractInputComponent<number | string> {
@Input()
bindLabel: string = 'name'
@Input()
iconField: string
public searchFn = (term: string, item: any): boolean =>
matchesSearchText(item?.[this.bindLabel], term)
@@ -100,7 +100,7 @@
</div>
}
<div class="form-group ms-md-auto">
<button type="button" class="btn me-2" [class]="cancelBtnClass" (click)="cancel()" [disabled]="!buttonsEnabled()">{{ cancelBtnCaption }}</button>
<button type="button" class="btn me-2" [class]="cancelBtnClass" (click)="cancel()" [disabled]="!buttonsEnabled">{{ cancelBtnCaption }}</button>
<button type="button" class="btn" [class]="btnClass" (click)="confirm()" [disabled]="pages.length === 0">{{ btnCaption }}</button>
</div>
</div>
@@ -151,13 +151,6 @@
inset: 0;
pointer-events: none;
& section {
position: absolute;
text-align: initial;
box-sizing: border-box;
transform-origin: 0 0;
}
& .annotationTextContent {
opacity: 0;
}
@@ -90,7 +90,6 @@ describe('PngxPdfViewerComponent', () => {
}
expect(viewer).toBeInstanceOf(PDFSinglePageViewer)
expect(viewer.options.textLayerMode).toBe(0)
expect(viewer.options.enableSelectionRendering).toBe(false)
})
it('applies zoom, rotation, and page changes', async () => {
@@ -13,7 +13,6 @@ import {
ViewChild,
} from '@angular/core'
import {
AnnotationMode,
getDocument,
GlobalWorkerOptions,
PDFDocumentLoadingTask,
@@ -222,8 +221,6 @@ export class PngxPdfViewerComponent
linkService: this.linkService,
findController: this.findController,
textLayerMode,
annotationMode: AnnotationMode.ENABLE,
enableSelectionRendering: false,
removePageBorders: true,
}
@@ -22,7 +22,7 @@
}
</div>
<div class="me-1">
<small>{{ownerFilterLabel}}</small>
<small i18n>My documents</small>
</div>
</button>
<button class="list-group-item list-group-item-action d-flex align-items-center p-2 border-top-0 border-start-0 border-end-0 border-bottom" role="menuitem" (click)="setFilter(OwnerFilterType.NOT_SELF)" [disabled]="disabled">
@@ -32,7 +32,7 @@
}
</div>
<div class="me-1">
<small>{{ownerExclusionFilterLabel}}</small>
<small i18n>Shared with me</small>
</div>
</button>
<button class="list-group-item list-group-item-action d-flex align-items-center p-2 border-top-0 border-start-0 border-end-0 border-bottom" role="menuitem" (click)="setFilter(OwnerFilterType.SHARED_BY_ME)" [disabled]="disabled">
@@ -42,7 +42,7 @@
}
</div>
<div class="me-1">
<small>{{sharedByFilterLabel}}</small>
<small i18n>Shared by me</small>
</div>
</button>
<button class="list-group-item list-group-item-action d-flex align-items-center p-2 border-top-0 border-start-0 border-end-0 border-bottom" role="menuitem" (click)="setFilter(OwnerFilterType.UNOWNED)" [disabled]="disabled">
@@ -94,58 +94,6 @@ describe('PermissionsFilterDropdownComponent', () => {
expect(component.isActive).toBeTruthy()
})
it('should describe concrete user filters honestly', () => {
component.selectionModel.ownerFilter = OwnerFilterType.SELF
component.selectionModel.userID = 1
expect(component.ownerFilterLabel).toEqual('Owned by user1')
component.selectionModel.ownerFilter = OwnerFilterType.NOT_SELF
component.selectionModel.excludeUsers = [1]
expect(component.ownerExclusionFilterLabel).toEqual('Not owned by user1')
component.selectionModel.ownerFilter = OwnerFilterType.SHARED_BY_ME
component.selectionModel.userID = 1
expect(component.sharedByFilterLabel).toEqual('Shared by user1')
})
it('should describe concrete filters when usernames are unavailable', () => {
component.selectionModel.ownerFilter = OwnerFilterType.SELF
component.selectionModel.userID = 99
expect(component.ownerFilterLabel).toEqual('Owned by another user')
component.selectionModel.ownerFilter = OwnerFilterType.NOT_SELF
component.selectionModel.excludeUsers = [99]
expect(component.ownerExclusionFilterLabel).toEqual(
'Not owned by another user'
)
component.selectionModel.excludeUsers = [98, 99]
expect(component.ownerExclusionFilterLabel).toEqual(
'Not owned by selected users'
)
component.selectionModel.ownerFilter = OwnerFilterType.SHARED_BY_ME
component.selectionModel.userID = 99
expect(component.sharedByFilterLabel).toEqual('Shared by another user')
})
it('should retain relative labels for filters bound to the current user', () => {
component.selectionModel.userID = currentUserID
expect(component.ownerFilterLabel).toEqual('My documents')
expect(component.sharedByFilterLabel).toEqual('Shared by me')
component.selectionModel.excludeUsers = [currentUserID]
expect(component.ownerExclusionFilterLabel).toEqual('Shared with me')
})
it('should retain relative labels for inactive filter choices', () => {
component.selectionModel.ownerFilter = OwnerFilterType.NONE
expect(component.ownerFilterLabel).toEqual('My documents')
expect(component.ownerExclusionFilterLabel).toEqual('Shared with me')
expect(component.sharedByFilterLabel).toEqual('Shared by me')
})
it('should support reset', () => {
component.setFilter(OwnerFilterType.OTHERS)
expect(component.selectionModel.ownerFilter).not.toEqual(
@@ -93,55 +93,6 @@ export class PermissionsFilterDropdownComponent extends ComponentWithPermissions
)
}
get ownerFilterLabel(): string {
if (
this.selectionModel?.ownerFilter !== OwnerFilterType.SELF ||
this.selectionModel?.userID === this.settingsService.currentUser()?.id
) {
return $localize`My documents`
}
const username = this.getUsername(this.selectionModel?.userID)
return username
? $localize`Owned by ${username}`
: $localize`Owned by another user`
}
get ownerExclusionFilterLabel(): string {
const excludedUsers = this.selectionModel?.excludeUsers ?? []
if (
this.selectionModel?.ownerFilter !== OwnerFilterType.NOT_SELF ||
(excludedUsers.length === 1 &&
excludedUsers[0] === this.settingsService.currentUser()?.id)
) {
return $localize`Shared with me`
}
const usernames = excludedUsers
.map((id) => this.getUsername(id))
.filter(Boolean)
if (usernames.length === excludedUsers.length && usernames.length > 0) {
return $localize`Not owned by ${usernames.join(', ')}`
}
return excludedUsers.length === 1
? $localize`Not owned by another user`
: $localize`Not owned by selected users`
}
get sharedByFilterLabel(): string {
if (
this.selectionModel?.ownerFilter !== OwnerFilterType.SHARED_BY_ME ||
this.selectionModel?.userID === this.settingsService.currentUser()?.id
) {
return $localize`Shared by me`
}
const username = this.getUsername(this.selectionModel?.userID)
return username
? $localize`Shared by ${username}`
: $localize`Shared by another user`
}
constructor() {
const userService = inject(UserService)
@@ -213,8 +164,4 @@ export class PermissionsFilterDropdownComponent extends ComponentWithPermissions
}
this.onChange()
}
private getUsername(userID: number): string {
return this.users().find((user) => user.id === userID)?.username
}
}
@@ -17,10 +17,6 @@ const permissions = [
'view_document',
'change_document',
'delete_document',
'add_sharelinkbundle',
'view_sharelinkbundle',
'change_sharelinkbundle',
'delete_sharelinkbundle',
'change_tag',
'view_documenttype',
]
@@ -79,7 +75,6 @@ describe('PermissionsSelectComponent', () => {
component.ngOnInit()
component.writeValue(permissions)
expect(component.typesWithAllActions).toContain('Document')
expect(component.typesWithAllActions).toContain('ShareLinkBundle')
})
it('should update checkboxes on permissions set', () => {
@@ -90,10 +85,6 @@ describe('PermissionsSelectComponent', () => {
expect(input1.nativeElement.checked).toBeTruthy()
const input2 = fixture.debugElement.query(By.css('input#Tag_Change'))
expect(input2.nativeElement.checked).toBeTruthy()
const bundleInput = fixture.debugElement.query(
By.css('input#ShareLinkBundle_Add')
)
expect(bundleInput.nativeElement.checked).toBeTruthy()
})
it('disable checkboxes when permissions are inherited', () => {
@@ -119,7 +119,7 @@
type="button"
class="btn btn-primary btn-sm d-inline-flex align-items-center gap-2 text-nowrap"
(click)="submit()"
[disabled]="loading() || !buttonsEnabled()">
[disabled]="loading() || !buttonsEnabled">
@if (loading()) {
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
}
@@ -69,7 +69,7 @@ describe('ShareLinkBundleDialogComponent', () => {
file_version: FileVersion.Original,
expiration_days: 3,
})
expect(component.buttonsEnabled()).toBe(false)
expect(component.buttonsEnabled).toBe(false)
expect(confirmSpy).toHaveBeenCalled()
component.form.setValue({
@@ -78,7 +78,7 @@ export class ShareLinkBundleDialogComponent extends ConfirmDialogComponent {
: FileVersion.Original,
expiration_days: this.form.value.expirationDays,
}
this.buttonsEnabled.set(false)
this.buttonsEnabled = false
super.confirm()
}
@@ -2,16 +2,10 @@
<button type="button" class="btn btn-sm btn-outline-primary" (click)="clickSuggest()" [disabled]="disabled() || loading() || (suggestions() && !aiEnabled())">
@if (loading()) {
<div class="spinner-border spinner-border-sm" role="status"></div>
} @else if (noSuggestions) {
<i-bs width="1.2em" height="1.2em" name="check-circle"></i-bs>
} @else {
<i-bs width="1.2em" height="1.2em" name="stars"></i-bs>
}
@if (noSuggestions) {
<span class="d-none d-lg-inline ps-1" i18n>No suggestions</span>
} @else {
<span class="d-none d-lg-inline ps-1" i18n>Suggest</span>
}
<span class="d-none d-lg-inline ps-1" i18n>Suggest</span>
@if (totalSuggestions > 0) {
<span class="badge bg-primary ms-2">{{ totalSuggestions }}</span>
}
@@ -25,7 +19,7 @@
<div ngbDropdownMenu aria-labelledby="suggestionsDropdown" class="shadow suggestions-dropdown">
<div class="list-group list-group-flush small pb-0">
@if (totalSuggestions === 0) {
@if (!suggestions()?.suggested_tags && !suggestions()?.suggested_document_types && !suggestions()?.suggested_correspondents) {
<div class="list-group-item text-muted fst-italic">
<small class="text-muted small fst-italic" i18n>No novel suggestions</small>
</div>
@@ -30,34 +30,6 @@ describe('SuggestionsDropdownComponent', () => {
expect(component.totalSuggestions).toBe(4)
})
it('should show when a completed request returned no suggestions', () => {
fixture.componentRef.setInput('suggestions', {
correspondents: [],
tags: [],
document_types: [],
storage_paths: [],
dates: [],
})
fixture.detectChanges()
expect(component.noSuggestions).toBeTruthy()
expect(fixture.nativeElement.textContent).toContain('No suggestions')
})
it('should not show the empty state before a request or with suggestions', () => {
expect(component.noSuggestions).toBeFalsy()
fixture.componentRef.setInput('suggestions', {
correspondents: [],
tags: [42],
document_types: [],
storage_paths: [],
dates: [],
})
expect(component.noSuggestions).toBeFalsy()
})
it('should emit getSuggestions when clickSuggest is called and suggestions are null', () => {
jest.spyOn(component.getSuggestions, 'emit')
fixture.componentRef.setInput('suggestions', null)
@@ -87,6 +59,5 @@ describe('SuggestionsDropdownComponent', () => {
})
component.clickSuggest()
expect(component.dropdown.open).toBeTruthy()
expect(fixture.nativeElement.textContent).toContain('No novel suggestions')
})
})
@@ -61,21 +61,4 @@ export class SuggestionsDropdownComponent {
this.suggestions()?.suggested_document_types?.length || 0
)
}
get noSuggestions(): boolean {
const suggestions = this.suggestions()
return (
suggestions != null &&
!suggestions.title &&
!suggestions.tags?.length &&
!suggestions.suggested_tags?.length &&
!suggestions.correspondents?.length &&
!suggestions.suggested_correspondents?.length &&
!suggestions.document_types?.length &&
!suggestions.suggested_document_types?.length &&
!suggestions.storage_paths?.length &&
!suggestions.suggested_storage_paths?.length &&
!suggestions.dates?.length
)
}
}
@@ -1,7 +1,6 @@
<pngx-widget-frame
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }"
[title]="savedView.name"
[titleIcon]="savedView.icon || 'funnel'"
[loading]="false"
[draggable]="savedView"
>
@@ -8,12 +8,7 @@
<i-bs name="grip-vertical"></i-bs>
</div>
}
<h6 class="card-title mb-0">
@if (titleIcon()) {
<i-bs class="me-2" [name]="titleIcon()"></i-bs>
}
{{title()}}
</h6>
<h6 class="card-title mb-0">{{title()}}</h6>
<ng-content select="[title-badge]"></ng-content>
@if (badge() !== null && badge() !== undefined) {
<span class="badge bg-info text-dark ms-2">{{badge()}}</span>
@@ -16,8 +16,6 @@ export class WidgetFrameComponent implements AfterViewInit {
title = input<string>()
titleIcon = input<string>()
draggable = input<any>()
cardless = input(false)
@@ -1564,7 +1564,7 @@ describe('DocumentDetailComponent', () => {
dialog.confirmClicked.next()
await openModal.result
expect(dialog.buttonsEnabled()).toBe(false)
expect(dialog.buttonsEnabled).toBe(false)
expect(reloadSpy).toHaveBeenCalled()
expect((component as any).incomingUpdateModal).toBeNull()
})
@@ -1789,7 +1789,7 @@ describe('DocumentDetailComponent', () => {
expect(errorSpy).toHaveBeenCalled()
expect(component.networkActive()).toBe(false)
expect(dialog.buttonsEnabled()).toBe(true)
expect(dialog.buttonsEnabled).toBe(true)
})
it('should refresh the document when removing password in update mode', () => {
@@ -2161,14 +2161,8 @@ describe('DocumentDetailComponent', () => {
it('should support open share links and email modals', () => {
const modalSpy = jest.spyOn(modalService, 'open')
initNormally()
component.selectedVersionId.set(10)
component.openShareLinks()
expect(modalSpy).toHaveBeenCalled()
expect(
(
modalSpy.mock.results[0].value as NgbModalRef
).componentInstance.documentId()
).toBe(10)
component.openEmailDocument()
expect(modalSpy).toHaveBeenCalled()
})
@@ -2197,6 +2191,9 @@ describe('DocumentDetailComponent', () => {
const appendChildSpy = jest
.spyOn(document.body, 'appendChild')
.mockImplementation((node: Node) => node)
const removeChildSpy = jest
.spyOn(document.body, 'removeChild')
.mockImplementation((node: Node) => node)
const createObjectURLSpy = jest
.spyOn(URL, 'createObjectURL')
.mockReturnValue('blob:mock-url')
@@ -2215,7 +2212,6 @@ describe('DocumentDetailComponent', () => {
src: '',
onload: null,
contentWindow: mockContentWindow,
remove: jest.fn(),
}
const createElementSpy = jest
@@ -2259,11 +2255,12 @@ describe('DocumentDetailComponent', () => {
mockContentWindow.onafterprint(new Event('afterprint'))
}
expect(mockIframe.remove).toHaveBeenCalled()
expect(removeChildSpy).toHaveBeenCalledWith(mockIframe)
expect(revokeObjectURLSpy).toHaveBeenCalledWith('blob:mock-url')
createElementSpy.mockRestore()
appendChildSpy.mockRestore()
removeChildSpy.mockRestore()
createObjectURLSpy.mockRestore()
revokeObjectURLSpy.mockRestore()
})
@@ -2310,6 +2307,9 @@ describe('DocumentDetailComponent', () => {
const appendChildSpy = jest
.spyOn(document.body, 'appendChild')
.mockImplementation((node: Node) => node)
const removeChildSpy = jest
.spyOn(document.body, 'removeChild')
.mockImplementation((node: Node) => node)
const createObjectURLSpy = jest
.spyOn(URL, 'createObjectURL')
.mockReturnValue('blob:mock-url')
@@ -2332,7 +2332,6 @@ describe('DocumentDetailComponent', () => {
src: '',
onload: null,
contentWindow: mockContentWindow,
remove: jest.fn(),
}
const createElementSpy = jest
@@ -2355,21 +2354,15 @@ describe('DocumentDetailComponent', () => {
if (expectToast) {
expect(toastSpy).toHaveBeenCalled()
expect(mockIframe.remove).toHaveBeenCalled()
expect(revokeObjectURLSpy).toHaveBeenCalledWith('blob:mock-url')
} else {
expect(toastSpy).not.toHaveBeenCalled()
expect(mockIframe.remove).not.toHaveBeenCalled()
expect(revokeObjectURLSpy).not.toHaveBeenCalled()
component.ngOnDestroy()
expect(mockIframe.remove).toHaveBeenCalled()
expect(revokeObjectURLSpy).toHaveBeenCalledWith('blob:mock-url')
}
expect(removeChildSpy).toHaveBeenCalledWith(mockIframe)
expect(revokeObjectURLSpy).toHaveBeenCalledWith('blob:mock-url')
createElementSpy.mockRestore()
appendChildSpy.mockRestore()
removeChildSpy.mockRestore()
createObjectURLSpy.mockRestore()
revokeObjectURLSpy.mockRestore()
})
@@ -289,8 +289,6 @@ export class DocumentDetailComponent
private incomingUpdateModal: NgbModalRef
private pendingIncomingUpdate: IncomingDocumentUpdate
private lastLocalSaveModified: string | null = null
private printIframe: HTMLIFrameElement | null = null
private printBlobUrl: string | null = null
requiresPassword: boolean = false
password: string
@@ -659,7 +657,7 @@ export class DocumentDetailComponent
modal.componentInstance.cancelBtnCaption = $localize`Dismiss`
modal.componentInstance.confirmClicked.pipe(first()).subscribe(() => {
modal.componentInstance.buttonsEnabled.set(false)
modal.componentInstance.buttonsEnabled = false
modal.close()
this.reloadRemoteVersion()
})
@@ -870,7 +868,6 @@ export class DocumentDetailComponent
}
ngOnDestroy(): void {
this.cleanupPrintDocument()
this.unsubscribeNotifier.next(this)
this.unsubscribeNotifier.complete()
}
@@ -1374,7 +1371,7 @@ export class DocumentDetailComponent
modal.componentInstance.confirmClicked
.pipe(
switchMap(() => {
modal.componentInstance.buttonsEnabled.set(false)
modal.componentInstance.buttonsEnabled = false
return this.documentsService.delete(this.document())
})
)
@@ -1386,7 +1383,7 @@ export class DocumentDetailComponent
},
error: (error) => {
this.toastService.showError($localize`Error deleting document`, error)
modal.componentInstance.buttonsEnabled.set(true)
modal.componentInstance.buttonsEnabled = true
this.subscribeModalDelete(modal)
},
})
@@ -1411,7 +1408,7 @@ export class DocumentDetailComponent
modal.componentInstance.btnClass = 'btn-danger'
modal.componentInstance.btnCaption = $localize`Proceed`
modal.componentInstance.confirmClicked.subscribe(() => {
modal.componentInstance.buttonsEnabled.set(false)
modal.componentInstance.buttonsEnabled = false
this.documentsService
.reprocessDocuments({ documents: [this.document().id] })
.subscribe({
@@ -1425,7 +1422,7 @@ export class DocumentDetailComponent
},
error: (error) => {
if (modal) {
modal.componentInstance.buttonsEnabled.set(true)
modal.componentInstance.buttonsEnabled = true
}
this.toastService.showError(
$localize`Error executing operation`,
@@ -1798,7 +1795,7 @@ export class DocumentDetailComponent
modal.componentInstance.confirmClicked
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe(() => {
modal.componentInstance.buttonsEnabled.set(false)
modal.componentInstance.buttonsEnabled = false
this.documentsService
.editPdfDocuments([sourceDocumentId], {
operations: modal.componentInstance.getOperations(),
@@ -1821,7 +1818,7 @@ export class DocumentDetailComponent
},
error: (error) => {
if (modal) {
modal.componentInstance.buttonsEnabled.set(true)
modal.componentInstance.buttonsEnabled = true
}
this.toastService.showError(
$localize`Error executing PDF edit operation`,
@@ -1855,7 +1852,7 @@ export class DocumentDetailComponent
const sourceDocumentId = this.selectedVersionId() ?? this.document().id
const dialog =
modal.componentInstance as PasswordRemovalConfirmDialogComponent
dialog.buttonsEnabled.set(false)
dialog.buttonsEnabled = false
this.networkActive.set(true)
this.documentsService
.removePasswordDocuments([sourceDocumentId], {
@@ -1880,7 +1877,7 @@ export class DocumentDetailComponent
}
},
error: (error) => {
dialog.buttonsEnabled.set(true)
dialog.buttonsEnabled = true
this.networkActive.set(false)
this.toastService.showError(
$localize`Error executing password removal operation`,
@@ -1892,7 +1889,6 @@ export class DocumentDetailComponent
}
printDocument() {
this.cleanupPrintDocument()
const selectedVersionId = this.getSelectedNonLatestVersionId()
const printUrl = this.documentsService.getDownloadUrl(
this.document().id,
@@ -1906,8 +1902,6 @@ export class DocumentDetailComponent
next: (blob) => {
const blobUrl = URL.createObjectURL(blob)
const iframe = document.createElement('iframe')
this.printIframe = iframe
this.printBlobUrl = blobUrl
iframe.style.position = 'fixed'
iframe.style.right = '0'
iframe.style.bottom = '0'
@@ -1923,19 +1917,22 @@ export class DocumentDetailComponent
iframe.contentWindow.focus()
iframe.contentWindow.print()
iframe.contentWindow.onafterprint = () => {
this.cleanupPrintDocument()
document.body.removeChild(iframe)
URL.revokeObjectURL(blobUrl)
}
} catch (err) {
// FF throws cross-origin error on onafterprint
const isCrossOriginAfterPrintError =
err instanceof DOMException &&
err.message.includes('onafterprint')
// FF throws here while print preview is still reading the iframe
// so keep it alive until the next print or teardown
if (!isCrossOriginAfterPrintError) {
this.toastService.showError($localize`Print failed.`, err)
timer(100).subscribe(() => this.cleanupPrintDocument())
}
timer(100).subscribe(() => {
// delay to avoid FF print failure
document.body.removeChild(iframe)
URL.revokeObjectURL(blobUrl)
})
}
})
}
@@ -1948,20 +1945,9 @@ export class DocumentDetailComponent
})
}
private cleanupPrintDocument() {
if (this.printIframe) this.printIframe.remove()
this.printIframe = null
if (this.printBlobUrl) {
URL.revokeObjectURL(this.printBlobUrl)
this.printBlobUrl = null
}
}
public openShareLinks() {
const modal = this.modalService.open(ShareLinksDialogComponent)
modal.componentInstance.documentId.set(
this.selectedVersionId() ?? this.document().id
)
modal.componentInstance.documentId.set(this.document().id)
modal.componentInstance.hasArchiveVersion.set(
this.metadata()?.has_archive_version ??
!!this.document()?.archived_file_name
@@ -1683,7 +1683,7 @@ describe('BulkEditorComponent', () => {
expiration_days: 7,
},
loading: signal(false),
buttonsEnabled: signal(true),
buttonsEnabled: true,
copied: signal(false),
},
}
@@ -1715,7 +1715,7 @@ describe('BulkEditorComponent', () => {
expiration_days: 7,
})
expect(dialogInstance.loading()).toBe(false)
expect(dialogInstance.buttonsEnabled()).toBe(false)
expect(dialogInstance.buttonsEnabled).toBe(false)
expect(dialogInstance.createdBundle).toEqual({ id: 42 })
expect(typeof dialogInstance.onOpenManage).toBe('function')
expect(toastInfoSpy).toHaveBeenCalledWith(
@@ -1755,7 +1755,7 @@ describe('BulkEditorComponent', () => {
expiration_days: null,
},
loading: signal(false),
buttonsEnabled: signal(true),
buttonsEnabled: true,
},
}
@@ -1777,7 +1777,7 @@ describe('BulkEditorComponent', () => {
expect.any(Error)
)
expect(dialogInstance.loading()).toBe(false)
expect(dialogInstance.buttonsEnabled()).toBe(true)
expect(dialogInstance.buttonsEnabled).toBe(true)
openSpy.mockRestore()
})
@@ -273,7 +273,7 @@ export class BulkEditorComponent
overrideSelection?: DocumentSelectionQuery
) {
if (modal) {
modal.componentInstance.buttonsEnabled.set(false)
this.setModalButtonsEnabled(modal, false)
}
this.documentService
.bulkEdit(overrideSelection ?? this.getSelectionQuery(), method, args)
@@ -290,7 +290,7 @@ export class BulkEditorComponent
options: { deleteOriginals?: boolean } = {}
) {
if (modal) {
modal.componentInstance.buttonsEnabled.set(false)
this.setModalButtonsEnabled(modal, false)
}
request.pipe(first()).subscribe({
next: () => {
@@ -320,7 +320,7 @@ export class BulkEditorComponent
private handleOperationError(modal: NgbModalRef, error: any) {
if (modal) {
modal.componentInstance.buttonsEnabled.set(true)
this.setModalButtonsEnabled(modal, true)
}
this.toastService.showError(
$localize`Error executing bulk operation`,
@@ -328,6 +328,15 @@ export class BulkEditorComponent
)
}
private setModalButtonsEnabled(modal: NgbModalRef, enabled: boolean) {
const buttonsEnabled = modal.componentInstance.buttonsEnabled
if (typeof buttonsEnabled?.set === 'function') {
buttonsEnabled.set(enabled)
} else {
modal.componentInstance.buttonsEnabled = enabled
}
}
private applySelectionData(
items: SelectionDataItem[],
selectionModel: FilterableDropdownSelectionModel
@@ -863,7 +872,7 @@ export class BulkEditorComponent
modal.componentInstance.confirmClicked
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe(() => {
modal.componentInstance.buttonsEnabled.set(false)
modal.componentInstance.buttonsEnabled = false
this.executeDocumentAction(
modal,
this.documentService.deleteDocuments(this.getSelectionQuery())
@@ -911,7 +920,7 @@ export class BulkEditorComponent
modal.componentInstance.confirmClicked
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe(() => {
modal.componentInstance.buttonsEnabled.set(false)
modal.componentInstance.buttonsEnabled = false
this.executeDocumentAction(
modal,
this.documentService.reprocessDocuments(this.getSelectionQuery())
@@ -948,7 +957,7 @@ export class BulkEditorComponent
rotateDialog.confirmClicked
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe(() => {
rotateDialog.buttonsEnabled.set(false)
rotateDialog.buttonsEnabled = false
this.executeDocumentAction(
modal,
this.documentService.rotateDocuments(
@@ -981,7 +990,7 @@ export class BulkEditorComponent
if (mergeDialog.archiveFallback()) {
args.archive_fallback = true
}
mergeDialog.buttonsEnabled.set(false)
mergeDialog.buttonsEnabled = false
this.executeDocumentAction(
modal,
this.documentService.mergeDocuments(mergeDialog.documentIDs(), args),
@@ -1054,14 +1063,14 @@ export class BulkEditorComponent
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe(() => {
dialog.loading.set(true)
dialog.buttonsEnabled.set(false)
dialog.buttonsEnabled = false
this.shareLinkBundleService
.createBundle(dialog.payload)
.pipe(first())
.subscribe({
next: (result) => {
dialog.loading.set(false)
dialog.buttonsEnabled.set(false)
dialog.buttonsEnabled = false
dialog.createdBundle = result
dialog.copied.set(false)
dialog.payload = null
@@ -1075,7 +1084,7 @@ export class BulkEditorComponent
},
error: (error) => {
dialog.loading.set(false)
dialog.buttonsEnabled.set(true)
dialog.buttonsEnabled = true
this.toastService.showError(
$localize`Share link bundle creation is not available yet.`,
error
@@ -97,9 +97,7 @@
<div class="dropdown-menu shadow dropdown-menu-right" ngbDropdownMenu>
@if (!list.activeSavedViewId) {
@for (view of savedViewService.allViews; track view) {
<button ngbDropdownItem (click)="loadViewConfig(view.id)">
<i-bs class="me-2" [name]="view.icon || 'funnel'"></i-bs>{{view.name}}
</button>
<button ngbDropdownItem (click)="loadViewConfig(view.id)">{{view.name}}</button>
}
@if (savedViewService.allViews.length > 0) {
<div class="dropdown-divider"></div>
@@ -457,7 +457,6 @@ export class DocumentListComponent
modal.componentInstance.buttonsEnabled.set(false)
let savedView: SavedView = {
name: formValue.name,
icon: formValue.icon,
filter_rules: this.list.filterRules,
sort_reverse: this.list.sortReverse,
sort_field: this.list.sortField,
@@ -1697,24 +1697,6 @@ describe('FilterEditorComponent', () => {
])
})
it('should carry over text filtering once with created and added relative dates', () => {
component.textFilter = 'foo'
const datesDropdown = fixture.debugElement.query(
By.directive(DatesDropdownComponent)
)
component.dateCreatedRelativeDate = RelativeDate.WITHIN_1_WEEK
component.dateAddedRelativeDate = RelativeDate.WITHIN_1_MONTH
datesDropdown.triggerEventHandler('datesSet')
fixture.detectChanges()
tick(400)
expect(component.filterRules).toEqual([
{
rule_type: FILTER_FULLTEXT_QUERY,
value: 'foo,created:[-1 week to now],added:[-1 month to now]',
},
])
})
it('should convert legacy title filters into full text query when adding a created relative date', () => {
component.filterRules = [
{
@@ -2213,20 +2195,6 @@ describe('FilterEditorComponent', () => {
expect(blurSpy).toHaveBeenCalled()
})
it('should only dismiss open autocomplete suggestions on Escape, keeping the query', () => {
component.textFilter = 'foo bar'
component.textFilterInput.nativeElement.value = 'foo bar'
jest.spyOn(component.searchTypeahead, 'isPopupOpen').mockReturnValue(true)
const dismissSpy = jest
.spyOn(component.searchTypeahead, 'dismissPopup')
.mockImplementation(() => {})
component.textFilterInput.nativeElement.dispatchEvent(
new KeyboardEvent('keydown', { key: 'Escape' })
)
expect(dismissSpy).toHaveBeenCalled()
expect(component.textFilter).toEqual('foo bar')
})
it('should adjust text filter targets if more like search', () => {
const TEXT_FILTER_TARGET_FULLTEXT_MORELIKE = 'fulltext-morelike' // private const
component.textFilterTarget = TEXT_FILTER_TARGET_FULLTEXT_MORELIKE
@@ -15,7 +15,6 @@ import {
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import {
NgbDropdownModule,
NgbTypeahead,
NgbTypeaheadModule,
} from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
@@ -352,9 +351,6 @@ export class FilterEditorComponent
@ViewChild('textFilterInput')
textFilterInput: ElementRef
@ViewChild(NgbTypeahead)
searchTypeahead: NgbTypeahead
readonly customFields = signal<CustomField[]>([])
tagDocumentCounts: SelectionDataItem[]
@@ -1020,6 +1016,7 @@ export class FilterEditorComponent
this.dateAddedRelativeDate !== null ||
this.dateCreatedRelativeDate !== null
) {
let queryArgs: Array<string> = []
let existingRule = filterRules.find(
(fr) => fr.rule_type == FILTER_FULLTEXT_QUERY
)
@@ -1041,28 +1038,32 @@ export class FilterEditorComponent
existingRule.rule_type = FILTER_FULLTEXT_QUERY
}
let queryArgs = existingRule?.value.split(',') ?? []
let existingRuleArgs = existingRule?.value.split(',')
if (this.dateCreatedRelativeDate !== null) {
const rd = RELATIVE_DATE_QUERYSTRINGS.find(
(qS) => qS.relativeDate == this.dateCreatedRelativeDate
)
queryArgs = queryArgs.filter(
(arg) => !arg.match(RELATIVE_DATE_QUERY_REGEXP_CREATED)
)
queryArgs.push(
`created:${rd.isRange ? `[${rd.dateQuery}]` : `"${rd.dateQuery}"`}`
)
if (existingRule) {
queryArgs = existingRuleArgs
.filter((arg) => !arg.match(RELATIVE_DATE_QUERY_REGEXP_CREATED))
.concat(queryArgs)
}
}
if (this.dateAddedRelativeDate !== null) {
const rd = RELATIVE_DATE_QUERYSTRINGS.find(
(qS) => qS.relativeDate == this.dateAddedRelativeDate
)
queryArgs = queryArgs.filter(
(arg) => !arg.match(RELATIVE_DATE_QUERY_REGEXP_ADDED)
)
queryArgs.push(
`added:${rd.isRange ? `[${rd.dateQuery}]` : `"${rd.dateQuery}"`}`
)
if (existingRule) {
queryArgs = existingRuleArgs
.filter((arg) => !arg.match(RELATIVE_DATE_QUERY_REGEXP_ADDED))
.concat(queryArgs)
}
}
if (existingRule) {
@@ -1154,7 +1155,6 @@ export class FilterEditorComponent
}
set textFilter(value) {
this._textFilter = value // set immediately to prevent loss of keystrokes
this.textFilterDebounce.next(value)
}
@@ -1247,9 +1247,9 @@ export class FilterEditorComponent
distinctUntilChanged(),
filter((query) => !query.length || query.length > 2)
)
.subscribe(() =>
.subscribe((text) =>
this.updateTextFilter(
this._textFilter, // use the current value, not the debounced (possibly stale) one
text,
this.textFilterTarget !== TEXT_FILTER_TARGET_FULLTEXT_QUERY
)
)
@@ -1325,11 +1325,6 @@ export class FilterEditorComponent
this.updateTextFilter(filterString)
}
} else if (event.key === 'Escape') {
if (this.searchTypeahead?.isPopupOpen()) {
// only dismiss the suggestions, so longer query can use Enter
this.searchTypeahead.dismissPopup()
return
}
if (this._textFilter?.length) {
this.resetTextField()
} else {
@@ -6,14 +6,6 @@
</div>
<div class="modal-body">
<pngx-input-text i18n-title title="Name" formControlName="name" [error]="error()?.name" autocomplete="off"></pngx-input-text>
<pngx-input-select
i18n-title
title="Icon"
formControlName="icon"
[items]="savedViewIcons"
iconField="icon"
[error]="error()?.icon">
</pngx-input-select>
<pngx-input-check i18n-title title="Show in sidebar" formControlName="showInSideBar"></pngx-input-check>
<pngx-input-check i18n-title title="Show on dashboard" formControlName="showOnDashboard"></pngx-input-check>
<pngx-permissions-form accordion="true" formControlName="permissions_form"></pngx-permissions-form>
@@ -9,7 +9,6 @@ import { CheckComponent } from '../../common/input/check/check.component'
import { PermissionsFormComponent } from '../../common/input/permissions/permissions-form/permissions-form.component'
import { PermissionsGroupComponent } from '../../common/input/permissions/permissions-group/permissions-group.component'
import { PermissionsUserComponent } from '../../common/input/permissions/permissions-user/permissions-user.component'
import { SelectComponent } from '../../common/input/select/select.component'
import { TextComponent } from '../../common/input/text/text.component'
import { SaveViewConfigDialogComponent } from './save-view-config-dialog.component'
@@ -41,7 +40,6 @@ describe('SaveViewConfigDialogComponent', () => {
ReactiveFormsModule,
SaveViewConfigDialogComponent,
TextComponent,
SelectComponent,
CheckComponent,
PermissionsFormComponent,
PermissionsUserComponent,
@@ -65,7 +63,6 @@ describe('SaveViewConfigDialogComponent', () => {
expect(component.defaultName()).toEqual(name)
expect(result).toEqual({
name,
icon: 'funnel',
showInSideBar: false,
showOnDashboard: false,
})
@@ -97,7 +94,6 @@ describe('SaveViewConfigDialogComponent', () => {
component.save()
expect(result).toEqual({
name,
icon: 'funnel',
showInSideBar: true,
showOnDashboard: true,
})
@@ -117,7 +113,6 @@ describe('SaveViewConfigDialogComponent', () => {
component.save()
expect(result).toEqual({
name: '',
icon: 'funnel',
showInSideBar: false,
showOnDashboard: false,
permissions_form: permissions,
@@ -13,14 +13,9 @@ import {
ReactiveFormsModule,
} from '@angular/forms'
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
import {
DEFAULT_SAVED_VIEW_ICON,
SAVED_VIEW_ICONS,
} from 'src/app/data/saved-view-icons'
import { User } from 'src/app/data/user'
import { CheckComponent } from '../../common/input/check/check.component'
import { PermissionsFormComponent } from '../../common/input/permissions/permissions-form/permissions-form.component'
import { SelectComponent } from '../../common/input/select/select.component'
import { TextComponent } from '../../common/input/text/text.component'
@Component({
@@ -29,7 +24,6 @@ import { TextComponent } from '../../common/input/text/text.component'
styleUrls: ['./save-view-config-dialog.component.scss'],
imports: [
CheckComponent,
SelectComponent,
TextComponent,
PermissionsFormComponent,
FormsModule,
@@ -47,7 +41,6 @@ export class SaveViewConfigDialogComponent implements OnInit {
public saveClicked = new EventEmitter()
users: User[]
readonly savedViewIcons = SAVED_VIEW_ICONS
setDefaultName(value: string) {
this.defaultName.set(value)
@@ -56,7 +49,6 @@ export class SaveViewConfigDialogComponent implements OnInit {
saveViewConfigForm = new FormGroup({
name: new FormControl(''),
icon: new FormControl(DEFAULT_SAVED_VIEW_ICON),
showInSideBar: new FormControl(false),
showOnDashboard: new FormControl(false),
permissions_form: new FormControl(null),
@@ -73,7 +65,6 @@ export class SaveViewConfigDialogComponent implements OnInit {
const formValue = this.saveViewConfigForm.value
const saveViewConfig = {
name: formValue.name,
icon: formValue.icon,
showInSideBar: formValue.showInSideBar,
showOnDashboard: formValue.showOnDashboard,
}
@@ -105,7 +105,7 @@ export class CustomFieldsComponent
modal.componentInstance.btnClass = 'btn-danger'
modal.componentInstance.btnCaption = $localize`Proceed`
modal.componentInstance.confirmClicked.subscribe(() => {
modal.componentInstance.buttonsEnabled.set(false)
modal.componentInstance.buttonsEnabled = false
this.customFieldsService.delete(field).subscribe({
next: () => {
modal.close()
@@ -88,7 +88,7 @@
@if (depth > 0) {
<div class="indicator"></div>
}
<button class="btn btn-link ms-0 ps-0 text-start" style="user-select: text;" [disabled]="!userCanEdit(object)" (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 class="d-none d-sm-table-cell">{{ getMatching(object) }}</td>
<td>{{ getDocumentCount(object) }}</td>
@@ -274,7 +274,7 @@ export abstract class ManagementListComponent<T extends MatchingModel>
activeModal.componentInstance.btnClass = 'btn-danger'
activeModal.componentInstance.btnCaption = $localize`Delete`
activeModal.componentInstance.confirmClicked.subscribe(() => {
activeModal.componentInstance.buttonsEnabled.set(false)
activeModal.componentInstance.buttonsEnabled = false
this.service
.delete(object)
.pipe(takeUntil(this.unsubscribeNotifier))
@@ -284,7 +284,7 @@ export abstract class ManagementListComponent<T extends MatchingModel>
this.reloadData()
},
error: (error) => {
activeModal.componentInstance.buttonsEnabled.set(true)
activeModal.componentInstance.buttonsEnabled = true
this.toastService.showError(
$localize`Error while deleting element`,
error
@@ -455,7 +455,7 @@ export abstract class ManagementListComponent<T extends MatchingModel>
modal.componentInstance.btnClass = 'btn-danger'
modal.componentInstance.btnCaption = $localize`Proceed`
modal.componentInstance.confirmClicked.subscribe(() => {
modal.componentInstance.buttonsEnabled.set(false)
modal.componentInstance.buttonsEnabled = false
this.service
.bulk_edit_objects(
this.allSelectionActive ? [] : Array.from(this.selectedObjects),
@@ -472,7 +472,7 @@ export abstract class ManagementListComponent<T extends MatchingModel>
this.reloadData()
},
error: (error) => {
modal.componentInstance.buttonsEnabled.set(true)
modal.componentInstance.buttonsEnabled = true
this.toastService.showError(
$localize`Error deleting objects`,
error
@@ -196,7 +196,7 @@ export class MailComponent
modal.componentInstance.btnClass = 'btn-danger'
modal.componentInstance.btnCaption = $localize`Proceed`
modal.componentInstance.confirmClicked.subscribe(() => {
modal.componentInstance.buttonsEnabled.set(false)
modal.componentInstance.buttonsEnabled = false
this.mailAccountService.delete(account).subscribe({
next: () => {
modal.close()
@@ -298,7 +298,7 @@ export class MailComponent
modal.componentInstance.btnClass = 'btn-danger'
modal.componentInstance.btnCaption = $localize`Proceed`
modal.componentInstance.confirmClicked.subscribe(() => {
modal.componentInstance.buttonsEnabled.set(false)
modal.componentInstance.buttonsEnabled = false
this.mailRuleService.delete(rule).subscribe({
next: () => {
modal.close()
@@ -7,34 +7,23 @@
</pngx-page-header>
<form [formGroup]="savedViewsForm" (ngSubmit)="save()">
<ul class="list-group mb-3" formGroupName="savedViews">
@for (view of pagedSavedViews(); track view) {
@for (view of savedViews(); track view) {
<li class="list-group-item py-3">
<div [formGroupName]="view.id">
<div class="row">
<div class="col-md">
<div class="col">
<pngx-input-text title="Name" formControlName="name"></pngx-input-text>
</div>
<div class="col-md">
<pngx-input-select
i18n-title
title="Icon"
formControlName="icon"
[items]="savedViewIcons"
iconField="icon">
</pngx-input-select>
</div>
@if (canSaveSettings) {
<div class="col-md">
<div class="form-check form-switch mt-3">
<input type="checkbox" class="form-check-input" id="show_on_dashboard_{{view.id}}" formControlName="show_on_dashboard">
<label class="form-check-label" for="show_on_dashboard_{{view.id}}" i18n>Show on dashboard</label>
</div>
<div class="form-check form-switch">
<input type="checkbox" class="form-check-input" id="show_in_sidebar_{{view.id}}" formControlName="show_in_sidebar">
<label class="form-check-label" for="show_in_sidebar_{{view.id}}" i18n>Show in sidebar</label>
</div>
<div class="col">
<div class="form-check form-switch mt-3">
<input type="checkbox" class="form-check-input" id="show_on_dashboard_{{view.id}}" formControlName="show_on_dashboard">
<label class="form-check-label" for="show_on_dashboard_{{view.id}}" i18n>Show on dashboard</label>
</div>
}
<div class="form-check form-switch">
<input type="checkbox" class="form-check-input" id="show_in_sidebar_{{view.id}}" formControlName="show_in_sidebar">
<label class="form-check-label" for="show_in_sidebar_{{view.id}}" i18n>Show in sidebar</label>
</div>
</div>
<div class="col-auto">
@if (canDeleteSavedView(view)) {
<label class="form-label" for="name_{{view.id}}" i18n>Actions</label>
@@ -90,11 +79,6 @@
}
</ul>
<div class="d-flex align-items-center mb-3">
<button type="button" (click)="reset()" class="btn btn-outline-secondary mb-2" [disabled]="(isDirty$ | async) === false" i18n>Cancel</button>
<button type="submit" class="btn btn-primary ms-2 mb-2" [disabled]="(isDirty$ | async) === false" i18n>Save</button>
@if (savedViews()?.length > pageSize) {
<ngb-pagination class="ms-auto" [pageSize]="pageSize" [collectionSize]="savedViews().length" [page]="page()" [maxSize]="5" (pageChange)="page.set($event)" size="sm" aria-label="Pagination"></ngb-pagination>
}
</div>
<button type="button" (click)="reset()" class="btn btn-outline-secondary mb-2" [disabled]="(isDirty$ | async) === false" i18n>Cancel</button>
<button type="submit" class="btn btn-primary ms-2 mb-2" [disabled]="(isDirty$ | async) === false" i18n>Save</button>
</form>
@@ -4,7 +4,6 @@ import { provideHttpClientTesting } from '@angular/common/http/testing'
import { signal } from '@angular/core'
import { ComponentFixture, TestBed } from '@angular/core/testing'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { By } from '@angular/platform-browser'
import { NgbModal, NgbModule } from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
import { Subject, of, throwError } from 'rxjs'
@@ -26,20 +25,8 @@ import { PageHeaderComponent } from '../../common/page-header/page-header.compon
import { SavedViewsComponent } from './saved-views.component'
const savedViews = [
{
id: 1,
name: 'view1',
icon: 'archive',
show_in_sidebar: true,
show_on_dashboard: true,
},
{
id: 2,
name: 'view2',
icon: 'funnel',
show_in_sidebar: false,
show_on_dashboard: false,
},
{ id: 1, name: 'view1', show_in_sidebar: true, show_on_dashboard: true },
{ id: 2, name: 'view2', show_in_sidebar: false, show_on_dashboard: false },
]
describe('SavedViewsComponent', () => {
@@ -170,24 +157,6 @@ describe('SavedViewsComponent', () => {
expect(patchBody.show_in_sidebar).toBeUndefined()
})
it('should persist a changed icon', () => {
const patchSpy = jest.spyOn(savedViewService, 'patchMany')
const view = savedViews[0]
const iconControl = component.savedViewsForm
.get('savedViews')
.get(view.id.toString())
.get('icon')
iconControl.setValue('bell')
iconControl.markAsDirty()
component.save()
expect(patchSpy.mock.calls[0][0][0]).toMatchObject({
id: view.id,
icon: 'bell',
})
})
it('should persist visibility changes to user settings', () => {
const patchSpy = jest.spyOn(savedViewService, 'patchMany')
const updateVisibilitySpy = jest
@@ -253,44 +222,6 @@ describe('SavedViewsComponent', () => {
).toEqual(view.show_on_dashboard)
})
it('should page saved views, clamp the page if views are removed', () => {
const manyViews = Array.from({ length: 30 }, (_, i) => ({
id: i + 1,
name: `view${i + 1}`,
})) as SavedView[]
const listSpy = jest.spyOn(savedViewService, 'list').mockReturnValue(
of({
all: manyViews.map((v) => v.id),
count: manyViews.length,
results: manyViews.concat([]),
})
)
component.ngOnInit()
fixture.detectChanges()
expect(listSpy).toHaveBeenCalledWith(1, 100000, null, false, {
full_perms: true,
})
expect(component.pagedSavedViews()).toHaveLength(25)
expect(fixture.debugElement.query(By.css('ngb-pagination'))).not.toBeNull()
// all views have controls, not just the current page
expect(
Object.keys(component.savedViewsForm.get('savedViews').value)
).toHaveLength(30)
component.page.set(2)
expect(component.pagedSavedViews()).toHaveLength(5)
listSpy.mockReturnValue(
of({
all: manyViews.slice(0, 25).map((v) => v.id),
count: 25,
results: manyViews.slice(0, 25),
})
)
component.ngOnInit()
expect(component.page()).toEqual(1)
})
it('should support editing permissions', () => {
const confirmClicked = new Subject<any>()
const modalRef = {
@@ -1,34 +1,22 @@
import { AsyncPipe } from '@angular/common'
import {
Component,
OnDestroy,
OnInit,
computed,
inject,
signal,
} from '@angular/core'
import { Component, OnDestroy, OnInit, inject, signal } from '@angular/core'
import {
FormControl,
FormGroup,
FormsModule,
ReactiveFormsModule,
} from '@angular/forms'
import { NgbModal, NgbPaginationModule } from '@ng-bootstrap/ng-bootstrap'
import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
import { dirtyCheck } from '@ngneat/dirty-check-forms'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { BehaviorSubject, Observable, of, switchMap, takeUntil } from 'rxjs'
import { PermissionsDialogComponent } from 'src/app/components/common/permissions-dialog/permissions-dialog.component'
import { DisplayMode } from 'src/app/data/document'
import { SavedView } from 'src/app/data/saved-view'
import {
DEFAULT_SAVED_VIEW_ICON,
SAVED_VIEW_ICONS,
} from 'src/app/data/saved-view-icons'
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
import {
PermissionAction,
PermissionsService,
PermissionType,
} from 'src/app/services/permissions.service'
import { SavedViewService } from 'src/app/services/rest/saved-view.service'
import { SettingsService } from 'src/app/services/settings.service'
@@ -36,7 +24,6 @@ import { ToastService } from 'src/app/services/toast.service'
import { ConfirmButtonComponent } from '../../common/confirm-button/confirm-button.component'
import { DragDropSelectComponent } from '../../common/input/drag-drop-select/drag-drop-select.component'
import { NumberComponent } from '../../common/input/number/number.component'
import { SelectComponent } from '../../common/input/select/select.component'
import { TextComponent } from '../../common/input/text/text.component'
import { PageHeaderComponent } from '../../common/page-header/page-header.component'
import { LoadingComponentWithPermissions } from '../../loading-component/loading.component'
@@ -48,14 +35,12 @@ import { LoadingComponentWithPermissions } from '../../loading-component/loading
PageHeaderComponent,
ConfirmButtonComponent,
NumberComponent,
SelectComponent,
TextComponent,
IfPermissionsDirective,
DragDropSelectComponent,
FormsModule,
ReactiveFormsModule,
AsyncPipe,
NgbPaginationModule,
NgxBootstrapIconsModule,
],
})
@@ -70,17 +55,8 @@ export class SavedViewsComponent
private readonly modalService = inject(NgbModal)
DisplayMode = DisplayMode
readonly savedViewIcons = SAVED_VIEW_ICONS
readonly savedViews = signal<SavedView[]>(undefined)
readonly page = signal(1)
public readonly pageSize = 25
// All views are loaded at init, so paging is only for display
readonly pagedSavedViews = computed(() => {
const start = (this.page() - 1) * this.pageSize
return this.savedViews()?.slice(start, start + this.pageSize)
})
private savedViewsGroup = new FormGroup({})
public savedViewsForm: FormGroup = new FormGroup({
savedViews: this.savedViewsGroup,
@@ -95,9 +71,7 @@ export class SavedViewsComponent
constructor() {
super()
if (this.canSaveSettings) {
this.settings.organizingSidebarSavedViews.set(true)
}
this.settings.organizingSidebarSavedViews.set(true)
}
ngOnInit(): void {
@@ -107,19 +81,15 @@ export class SavedViewsComponent
private reloadViews(): void {
this.loading.set(true)
this.savedViewService
.list(1, 100000, null, false, { full_perms: true })
.list(null, null, null, false, { full_perms: true })
.subscribe((r) => {
this.savedViews.set(r.results)
const pageCount = Math.ceil(r.results.length / this.pageSize)
this.page.update((page) => Math.min(page, Math.max(1, pageCount)))
this.initialize()
})
}
ngOnDestroy(): void {
if (this.canSaveSettings) {
this.settings.organizingSidebarSavedViews.set(false)
}
this.settings.organizingSidebarSavedViews.set(false)
super.ngOnDestroy()
}
@@ -135,7 +105,6 @@ export class SavedViewsComponent
storeData.savedViews[view.id.toString()] = {
id: view.id,
name: view.name,
icon: view.icon ?? DEFAULT_SAVED_VIEW_ICON,
show_on_dashboard: view.show_on_dashboard,
show_in_sidebar: view.show_in_sidebar,
page_size: view.page_size,
@@ -148,7 +117,6 @@ export class SavedViewsComponent
new FormGroup({
id: new FormControl({ value: null, disabled: !canEdit }),
name: new FormControl({ value: null, disabled: !canEdit }),
icon: new FormControl({ value: null, disabled: !canEdit }),
show_on_dashboard: new FormControl({
value: null,
disabled: false,
@@ -227,7 +195,6 @@ export class SavedViewsComponent
const modelFieldsChanged =
group.get('name')?.dirty ||
group.get('icon')?.dirty ||
group.get('page_size')?.dirty ||
group.get('display_mode')?.dirty ||
group.get('display_fields')?.dirty
@@ -249,7 +216,7 @@ export class SavedViewsComponent
switchMap(() => this.savedViewService.patchMany(changed))
)
}
if (visibilityChanged && this.canSaveSettings) {
if (visibilityChanged) {
saveOperation = saveOperation.pipe(
switchMap(() =>
this.settings.updateSavedViewsVisibility(
@@ -283,19 +250,6 @@ export class SavedViewsComponent
return this.permissionsService.currentUserOwnsObject(view)
}
public get canSaveSettings(): boolean {
return (
this.permissionsService.currentUserCan(
PermissionAction.Change,
PermissionType.UISettings
) &&
this.permissionsService.currentUserCan(
PermissionAction.Add,
PermissionType.UISettings
)
)
}
public editPermissions(savedView: SavedView): void {
const modal = this.modalService.open(PermissionsDialogComponent, {
backdrop: 'static',
@@ -134,7 +134,7 @@ export class WorkflowsComponent
modal.componentInstance.btnClass = 'btn-danger'
modal.componentInstance.btnCaption = $localize`Proceed`
modal.componentInstance.confirmClicked.subscribe(() => {
modal.componentInstance.buttonsEnabled.set(false)
modal.componentInstance.buttonsEnabled = false
this.workflowService.delete(workflow).subscribe({
next: () => {
modal.close()
-89
View File
@@ -1,89 +0,0 @@
export const DEFAULT_SAVED_VIEW_ICON = 'funnel'
export const SAVED_VIEW_ICONS = [
{ id: 'archive', name: $localize`Archive`, icon: 'archive' },
{ id: 'bank', name: $localize`Bank`, icon: 'bank' },
{ id: 'basket', name: $localize`Basket`, icon: 'basket' },
{ id: 'bell', name: $localize`Bell`, icon: 'bell' },
{ id: 'bookmark', name: $localize`Bookmark`, icon: 'bookmark' },
{ id: 'boxes', name: $localize`Boxes`, icon: 'boxes' },
{ id: 'briefcase', name: $localize`Briefcase`, icon: 'briefcase' },
{ id: 'building', name: $localize`Building`, icon: 'building' },
{ id: 'calculator', name: $localize`Calculator`, icon: 'calculator' },
{ id: 'calendar', name: $localize`Calendar`, icon: 'calendar' },
{ id: 'camera', name: $localize`Camera`, icon: 'camera' },
{
id: 'card-checklist',
name: $localize`Checklist`,
icon: 'card-checklist',
},
{ id: 'cash', name: $localize`Cash`, icon: 'cash' },
{ id: 'chat-left-text', name: $localize`Chat`, icon: 'chat-left-text' },
{ id: 'check-circle', name: $localize`Check`, icon: 'check-circle' },
{ id: 'clipboard', name: $localize`Clipboard`, icon: 'clipboard' },
{ id: 'clock-history', name: $localize`Clock`, icon: 'clock-history' },
{ id: 'credit-card', name: $localize`Credit card`, icon: 'credit-card' },
{ id: 'download', name: $localize`Download`, icon: 'download' },
{ id: 'envelope', name: $localize`Envelope`, icon: 'envelope' },
{
id: 'exclamation-triangle',
name: $localize`Warning`,
icon: 'exclamation-triangle',
},
{ id: 'file-earmark', name: $localize`File`, icon: 'file-earmark' },
{
id: 'file-earmark-check',
name: $localize`Checked file`,
icon: 'file-earmark-check',
},
{
id: 'file-earmark-lock',
name: $localize`Locked file`,
icon: 'file-earmark-lock',
},
{
id: 'file-earmark-medical',
name: $localize`Medical file`,
icon: 'file-earmark-medical',
},
{
id: 'file-earmark-person',
name: $localize`Person file`,
icon: 'file-earmark-person',
},
{
id: 'file-earmark-spreadsheet',
name: $localize`Spreadsheet`,
icon: 'file-earmark-spreadsheet',
},
{ id: 'file-text', name: $localize`Text file`, icon: 'file-text' },
{ id: 'files', name: $localize`Files`, icon: 'files' },
{ id: 'folder', name: $localize`Folder`, icon: 'folder' },
{ id: 'funnel', name: $localize`Filter`, icon: 'funnel' },
{ id: 'gear', name: $localize`Gear`, icon: 'gear' },
{ id: 'globe2', name: $localize`Globe`, icon: 'globe2' },
{ id: 'hash', name: $localize`Hash`, icon: 'hash' },
{ id: 'heart', name: $localize`Heart`, icon: 'heart' },
{ id: 'house', name: $localize`House`, icon: 'house' },
{ id: 'inbox', name: $localize`Inbox`, icon: 'inbox' },
{ id: 'journals', name: $localize`Journals`, icon: 'journals' },
{ id: 'list-task', name: $localize`Task list`, icon: 'list-task' },
{ id: 'newspaper', name: $localize`Newspaper`, icon: 'newspaper' },
{ id: 'paperclip', name: $localize`Attachment`, icon: 'paperclip' },
{ id: 'people', name: $localize`People`, icon: 'people' },
{ id: 'person', name: $localize`Person`, icon: 'person' },
{ id: 'printer', name: $localize`Printer`, icon: 'printer' },
{ id: 'receipt', name: $localize`Receipt`, icon: 'receipt' },
{ id: 'safe', name: $localize`Safe`, icon: 'safe' },
{ id: 'search', name: $localize`Search`, icon: 'search' },
{ id: 'send', name: $localize`Send`, icon: 'send' },
{ id: 'shop', name: $localize`Shop`, icon: 'shop' },
{ id: 'stack', name: $localize`Stack`, icon: 'stack' },
{ id: 'stars', name: $localize`Stars`, icon: 'stars' },
{ id: 'tag', name: $localize`Tag`, icon: 'tag' },
{ id: 'tags', name: $localize`Tags`, icon: 'tags' },
{ id: 'telephone', name: $localize`Telephone`, icon: 'telephone' },
{ id: 'truck', name: $localize`Truck`, icon: 'truck' },
{ id: 'upc-scan', name: $localize`Barcode`, icon: 'upc-scan' },
{ id: 'wallet2', name: $localize`Wallet`, icon: 'wallet2' },
]
-2
View File
@@ -5,8 +5,6 @@ import { ObjectWithPermissions } from './object-with-permissions'
export interface SavedView extends ObjectWithPermissions {
name?: string
icon?: string
show_on_dashboard?: boolean
show_in_sidebar?: boolean
+1 -1
View File
@@ -18,7 +18,7 @@ export class DirtyFormGuard extends DirtyCheckGuard {
modal.componentInstance.btnClass = 'btn-warning'
modal.componentInstance.btnCaption = $localize`Leave page`
modal.componentInstance.confirmClicked.subscribe(() => {
modal.componentInstance.buttonsEnabled.set(false)
modal.componentInstance.buttonsEnabled = false
modal.close()
})
const subject = new Subject<boolean>()
@@ -36,12 +36,12 @@ export class DirtySavedViewGuard {
modal.componentInstance.alternativeBtnClass = 'btn-primary'
modal.componentInstance.alternativeBtnCaption = $localize`Save and close`
modal.componentInstance.alternativeClicked.pipe(first()).subscribe(() => {
modal.componentInstance.buttonsEnabled.set(false)
modal.componentInstance.buttonsEnabled = false
component.saveViewConfig()
modal.close()
})
modal.componentInstance.confirmClicked.pipe(first()).subscribe(() => {
modal.componentInstance.buttonsEnabled.set(false)
modal.componentInstance.buttonsEnabled = false
modal.close()
})
@@ -142,7 +142,7 @@ export class OpenDocumentsService {
modal.componentInstance.btnClass = 'btn-warning'
modal.componentInstance.btnCaption = $localize`Close document`
modal.componentInstance.confirmClicked.pipe(first()).subscribe(() => {
modal.componentInstance.buttonsEnabled.set(false)
modal.componentInstance.buttonsEnabled = false
modal.close()
this.openDocuments.splice(index, 1)
this.dirtyDocuments.delete(doc.id)
@@ -165,7 +165,7 @@ export class OpenDocumentsService {
modal.componentInstance.btnClass = 'btn-warning'
modal.componentInstance.btnCaption = $localize`Close documents`
modal.componentInstance.confirmClicked.pipe(first()).subscribe(() => {
modal.componentInstance.buttonsEnabled.set(false)
modal.componentInstance.buttonsEnabled = false
modal.close()
this.openDocuments.splice(0, this.openDocuments.length)
this.dirtyDocuments.clear()
@@ -120,12 +120,6 @@ describe('PermissionsService', () => {
actionKey: 'View', // PermissionAction.View
typeKey: 'SystemMonitoring', // PermissionType.SystemMonitoring
})
expect(permissionsService.getPermissionKeys('add_sharelinkbundle')).toEqual(
{
actionKey: 'Add', // PermissionAction.Add
typeKey: 'ShareLinkBundle', // PermissionType.ShareLinkBundle
}
)
})
it('correctly checks explicit global permissions', () => {
@@ -275,10 +269,6 @@ describe('PermissionsService', () => {
'view_sharelink',
'change_sharelink',
'delete_sharelink',
'add_sharelinkbundle',
'view_sharelinkbundle',
'change_sharelinkbundle',
'delete_sharelinkbundle',
'add_workflow',
'view_workflow',
'change_workflow',
@@ -26,7 +26,6 @@ export enum PermissionType {
User = '%s_user',
Group = '%s_group',
ShareLink = '%s_sharelink',
ShareLinkBundle = '%s_sharelinkbundle',
CustomField = '%s_customfield',
Workflow = '%s_workflow',
ProcessedMail = '%s_processedmail',
@@ -57,19 +57,6 @@ describe('LocalizedDateParserFormatter', () => {
expect(val).toEqual({ day: 4, month: 5, year: 2023 })
})
it('should parse yyyy-mm-dd input with unpadded month or day by locale', () => {
let val = dateParserFormatter.parse('2023-5-4')
expect(val).toEqual({ day: 4, month: 5, year: 2023 })
settingsService.setLanguage('de-de') // dd.mm.yyyy
val = dateParserFormatter.parse('2023-5-4')
expect(val).toEqual({ day: 4, month: 5, year: 2023 })
settingsService.setLanguage('tr-tr') // yyyy-mm-dd
val = dateParserFormatter.parse('2023-5-4')
expect(val).toEqual({ day: 4, month: 5, year: 2023 })
})
it('should parse date struct to string by locale', () => {
const dateStruct = {
day: 4,
@@ -52,11 +52,15 @@ export class LocalizedDateParserFormatter extends NgbDateParserFormatter {
let segments = value.split(this.separatorRegExp)
// always accept strict yyyy*mm*dd format even if that's not the input format since we can be certain its not yyyy*dd*mm
if (segments.length == 3 && segments[0].length == 4) {
if (
value.length == 10 &&
segments.length == 3 &&
segments[0].length == 4
) {
return inputFormat
.replace('yyyy', segments[0])
.replace('mm', segments[1].padStart(2, '0'))
.replace('dd', segments[2].padStart(2, '0'))
.replace('mm', segments[1])
.replace('dd', segments[2])
} else {
// otherwise pad & re-join without separator
value = segments.map((segment) => segment.padStart(2, '0')).join('')
-46
View File
@@ -35,27 +35,19 @@ import {
arrowRightShort,
arrowUpRight,
asterisk,
bank,
basket,
bell,
bodyText,
bookmark,
boxArrowUp,
boxArrowUpRight,
boxes,
braces,
briefcase,
building,
calculator,
calendar,
calendarEvent,
calendarEventFill,
camera,
cardChecklist,
cardHeading,
caretDown,
caretUp,
cash,
chatLeftText,
chatSquareDots,
check,
@@ -73,7 +65,6 @@ import {
clipboardCheckFill,
clipboardFill,
clockHistory,
creditCard,
dash,
dashCircle,
diagram3,
@@ -92,12 +83,9 @@ import {
fileEarmarkDiff,
fileEarmarkFill,
fileEarmarkLock,
fileEarmarkMedical,
fileEarmarkMinus,
fileEarmarkPerson,
fileEarmarkPlus,
fileEarmarkRichtext,
fileEarmarkSpreadsheet,
fileText,
files,
filter,
@@ -105,15 +93,12 @@ import {
folderFill,
funnel,
gear,
globe2,
google,
grid,
gripVertical,
hash,
hddStack,
heart,
house,
inbox,
infoCircle,
journals,
link,
@@ -121,9 +106,7 @@ import {
listTask,
listUl,
microsoft,
newspaper,
nodePlus,
paperclip,
pencil,
people,
peopleFill,
@@ -138,12 +121,9 @@ import {
plusCircle,
printer,
questionCircle,
receipt,
safe,
scissors,
search,
send,
shop,
slashCircle,
sliders2Vertical,
sortAlphaDown,
@@ -153,17 +133,14 @@ import {
tag,
tagFill,
tags,
telephone,
textIndentLeft,
textLeft,
threeDots,
threeDotsVertical,
trash,
truck,
uiRadios,
unlock,
upcScan,
wallet2,
windowStack,
x,
xCircle,
@@ -281,22 +258,15 @@ const icons = {
arrowRightShort,
arrowUpRight,
asterisk,
bank,
basket,
bell,
braces,
bodyText,
bookmark,
boxArrowUp,
boxArrowUpRight,
boxes,
briefcase,
building,
calculator,
calendar,
calendarEvent,
calendarEventFill,
camera,
cardChecklist,
cardHeading,
caretDown,
@@ -318,8 +288,6 @@ const icons = {
clipboardCheckFill,
clipboardFill,
clockHistory,
cash,
creditCard,
dash,
dashCircle,
diagram3,
@@ -338,12 +306,9 @@ const icons = {
fileEarmarkDiff,
fileEarmarkFill,
fileEarmarkLock,
fileEarmarkMedical,
fileEarmarkMinus,
fileEarmarkPerson,
fileEarmarkPlus,
fileEarmarkRichtext,
fileEarmarkSpreadsheet,
files,
fileText,
filter,
@@ -351,15 +316,12 @@ const icons = {
folderFill,
funnel,
gear,
globe2,
google,
grid,
gripVertical,
hash,
hddStack,
heart,
house,
inbox,
infoCircle,
journals,
link,
@@ -367,10 +329,8 @@ const icons = {
listTask,
listUl,
microsoft,
newspaper,
nodePlus,
pencil,
paperclip,
people,
peopleFill,
person,
@@ -384,13 +344,10 @@ const icons = {
plusCircle,
printer,
questionCircle,
receipt,
safe,
scissors,
search,
send,
slashCircle,
shop,
sliders2Vertical,
sortAlphaDown,
sortAlphaUpAlt,
@@ -401,15 +358,12 @@ const icons = {
tags,
textIndentLeft,
textLeft,
telephone,
threeDots,
threeDotsVertical,
trash,
truck,
uiRadios,
unlock,
upcScan,
wallet2,
windowStack,
x,
xCircle,
@@ -19,13 +19,6 @@ export const GlobalWorkerOptions = {
workerSrc: '',
}
export const AnnotationMode = {
DISABLE: 0,
ENABLE: 1,
ENABLE_FORMS: 2,
ENABLE_STORAGE: 3,
}
export const getDocument = (_src: unknown): PDFDocumentLoadingTask => {
return new PDFDocumentLoadingTask(Promise.resolve(new PDFDocumentProxy()))
}
View File

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