Compare commits

..

1 Commits

Author SHA1 Message Date
Trenton H
6b3e36eee6 Locks down permissions to the job level with least privledge we can get away with 2026-02-13 08:44:27 -08:00
125 changed files with 1756 additions and 3294 deletions

View File

@@ -22,6 +22,7 @@ on:
concurrency: concurrency:
group: backend-${{ github.event.pull_request.number || github.ref }} group: backend-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true cancel-in-progress: true
permissions: {}
env: env:
DEFAULT_UV_VERSION: "0.10.x" DEFAULT_UV_VERSION: "0.10.x"
NLTK_DATA: "/usr/share/nltk_data" NLTK_DATA: "/usr/share/nltk_data"
@@ -29,6 +30,8 @@ jobs:
test: test:
name: "Python ${{ matrix.python-version }}" name: "Python ${{ matrix.python-version }}"
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
permissions:
contents: read
strategy: strategy:
matrix: matrix:
python-version: ['3.10', '3.11', '3.12'] python-version: ['3.10', '3.11', '3.12']
@@ -102,6 +105,8 @@ jobs:
typing: typing:
name: Check project typing name: Check project typing
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
permissions:
contents: read
env: env:
DEFAULT_PYTHON: "3.12" DEFAULT_PYTHON: "3.12"
steps: steps:

View File

@@ -15,6 +15,7 @@ on:
concurrency: concurrency:
group: docker-${{ github.event.pull_request.number || github.ref }} group: docker-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true cancel-in-progress: true
permissions: {}
env: env:
REGISTRY: ghcr.io REGISTRY: ghcr.io
jobs: jobs:

View File

@@ -21,10 +21,7 @@ on:
concurrency: concurrency:
group: docs-${{ github.event.pull_request.number || github.ref }} group: docs-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true cancel-in-progress: true
permissions: permissions: {}
contents: read
pages: write
id-token: write
env: env:
DEFAULT_UV_VERSION: "0.10.x" DEFAULT_UV_VERSION: "0.10.x"
DEFAULT_PYTHON_VERSION: "3.12" DEFAULT_PYTHON_VERSION: "3.12"
@@ -32,6 +29,8 @@ jobs:
build: build:
name: Build Documentation name: Build Documentation
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
permissions:
contents: read
steps: steps:
- uses: actions/configure-pages@v5 - uses: actions/configure-pages@v5
- name: Checkout - name: Checkout
@@ -67,6 +66,10 @@ jobs:
needs: build needs: build
if: github.event_name == 'push' && github.ref == 'refs/heads/main' if: github.event_name == 'push' && github.ref == 'refs/heads/main'
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
permissions:
contents: read
pages: write
id-token: write
environment: environment:
name: github-pages name: github-pages
url: ${{ steps.deployment.outputs.page_url }} url: ${{ steps.deployment.outputs.page_url }}

View File

@@ -16,10 +16,13 @@ on:
concurrency: concurrency:
group: frontend-${{ github.event.pull_request.number || github.ref }} group: frontend-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true cancel-in-progress: true
permissions: {}
jobs: jobs:
install-dependencies: install-dependencies:
name: Install Dependencies name: Install Dependencies
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
permissions:
contents: read
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v6 uses: actions/checkout@v6
@@ -47,6 +50,8 @@ jobs:
name: Lint name: Lint
needs: install-dependencies needs: install-dependencies
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
permissions:
contents: read
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v6 uses: actions/checkout@v6
@@ -75,6 +80,8 @@ jobs:
name: "Unit Tests (${{ matrix.shard-index }}/${{ matrix.shard-count }})" name: "Unit Tests (${{ matrix.shard-index }}/${{ matrix.shard-count }})"
needs: install-dependencies needs: install-dependencies
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
permissions:
contents: read
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
@@ -121,6 +128,8 @@ jobs:
name: "E2E Tests (${{ matrix.shard-index }}/${{ matrix.shard-count }})" name: "E2E Tests (${{ matrix.shard-index }}/${{ matrix.shard-count }})"
needs: install-dependencies needs: install-dependencies
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
permissions:
contents: read
container: mcr.microsoft.com/playwright:v1.58.2-noble container: mcr.microsoft.com/playwright:v1.58.2-noble
env: env:
PLAYWRIGHT_BROWSERS_PATH: /ms-playwright PLAYWRIGHT_BROWSERS_PATH: /ms-playwright
@@ -161,6 +170,8 @@ jobs:
name: Bundle Analysis name: Bundle Analysis
needs: [unit-tests, e2e-tests] needs: [unit-tests, e2e-tests]
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
permissions:
contents: read
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v6 uses: actions/checkout@v6

View File

@@ -9,10 +9,13 @@ on:
concurrency: concurrency:
group: lint-${{ github.event.pull_request.number || github.ref }} group: lint-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true cancel-in-progress: true
permissions: {}
jobs: jobs:
lint: lint:
name: Linting via prek name: Linting via prek
runs-on: ubuntu-slim runs-on: ubuntu-slim
permissions:
contents: read
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v6.0.2 uses: actions/checkout@v6.0.2

View File

@@ -7,6 +7,7 @@ on:
concurrency: concurrency:
group: release-${{ github.ref }} group: release-${{ github.ref }}
cancel-in-progress: false cancel-in-progress: false
permissions: {}
env: env:
DEFAULT_UV_VERSION: "0.10.x" DEFAULT_UV_VERSION: "0.10.x"
DEFAULT_PYTHON_VERSION: "3.12" DEFAULT_PYTHON_VERSION: "3.12"
@@ -14,6 +15,10 @@ jobs:
wait-for-docker: wait-for-docker:
name: Wait for Docker Build name: Wait for Docker Build
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
permissions:
# lewagon/wait-on-check-action reads workflow check runs
actions: read
contents: read
steps: steps:
- name: Wait for Docker build - name: Wait for Docker build
uses: lewagon/wait-on-check-action@v1.5.0 uses: lewagon/wait-on-check-action@v1.5.0
@@ -26,6 +31,8 @@ jobs:
name: Build Release name: Build Release
needs: wait-for-docker needs: wait-for-docker
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
permissions:
contents: read
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v6 uses: actions/checkout@v6
@@ -127,6 +134,10 @@ jobs:
name: Publish Release name: Publish Release
needs: build-release needs: build-release
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
permissions:
# release-drafter reads PRs to build the changelog and creates/publishes the release
contents: write
pull-requests: read
outputs: outputs:
prerelease: ${{ steps.get-version.outputs.prerelease }} prerelease: ${{ steps.get-version.outputs.prerelease }}
changelog: ${{ steps.create-release.outputs.body }} changelog: ${{ steps.create-release.outputs.body }}
@@ -174,6 +185,11 @@ jobs:
needs: publish-release needs: publish-release
if: needs.publish-release.outputs.prerelease == 'false' if: needs.publish-release.outputs.prerelease == 'false'
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
permissions:
# git push of the changelog branch requires contents: write
# github.rest.pulls.create() and github.rest.issues.addLabels() require pull-requests: write
contents: write
pull-requests: write
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v6 uses: actions/checkout@v6

View File

@@ -12,6 +12,7 @@ on:
concurrency: concurrency:
group: registry-tags-cleanup group: registry-tags-cleanup
cancel-in-progress: false cancel-in-progress: false
permissions: {}
jobs: jobs:
cleanup-images: cleanup-images:
name: Cleanup Image Tags for ${{ matrix.primary-name }} name: Cleanup Image Tags for ${{ matrix.primary-name }}

View File

@@ -18,6 +18,7 @@ on:
branches: [dev] branches: [dev]
schedule: schedule:
- cron: '28 13 * * 5' - cron: '28 13 * * 5'
permissions: {}
jobs: jobs:
analyze: analyze:
name: Analyze name: Analyze

View File

@@ -6,11 +6,16 @@ on:
push: push:
paths: ['src/locale/**', 'src-ui/messages.xlf', 'src-ui/src/locale/**'] paths: ['src/locale/**', 'src-ui/messages.xlf', 'src-ui/src/locale/**']
branches: [dev] branches: [dev]
permissions: {}
jobs: jobs:
synchronize-with-crowdin: synchronize-with-crowdin:
name: Crowdin Sync name: Crowdin Sync
if: github.repository_owner == 'paperless-ngx' if: github.repository_owner == 'paperless-ngx'
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
permissions:
# Crowdin action pushes translation branches and creates/updates PRs via GITHUB_TOKEN
contents: write
pull-requests: write
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v6 uses: actions/checkout@v6

View File

@@ -2,13 +2,15 @@ name: PR Bot
on: on:
pull_request_target: pull_request_target:
types: [opened] types: [opened]
permissions: permissions: {}
contents: read
pull-requests: write
jobs: jobs:
pr-bot: pr-bot:
name: Automated PR Bot name: Automated PR Bot
runs-on: ubuntu-latest runs-on: ubuntu-latest
permissions:
# labeler reads file paths; all steps add labels or post comments on PRs
contents: read
pull-requests: write
steps: steps:
- name: Label PR by file path or branch name - name: Label PR by file path or branch name
# see .github/labeler.yml for the labeler config # see .github/labeler.yml for the labeler config

View File

@@ -7,13 +7,14 @@ on:
branches: branches:
- main - main
- dev - dev
permissions: permissions: {}
contents: read
jobs: jobs:
pr_opened_or_reopened: pr_opened_or_reopened:
name: pr_opened_or_reopened name: pr_opened_or_reopened
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
permissions: permissions:
# release-drafter reads its config file from the repo
contents: read
# write permission is required for autolabeler # write permission is required for autolabeler
pull-requests: write pull-requests: write
if: github.event_name == 'pull_request_target' && (github.event.action == 'opened' || github.event.action == 'reopened') && github.event.pull_request.user.login != 'dependabot' if: github.event_name == 'pull_request_target' && (github.event.action == 'opened' || github.event.action == 'reopened') && github.event.pull_request.user.login != 'dependabot'

View File

@@ -3,10 +3,7 @@ on:
schedule: schedule:
- cron: '0 3 * * *' - cron: '0 3 * * *'
workflow_dispatch: workflow_dispatch:
permissions: permissions: {}
issues: write
pull-requests: write
discussions: write
concurrency: concurrency:
group: lock group: lock
jobs: jobs:
@@ -14,6 +11,9 @@ jobs:
name: 'Stale' name: 'Stale'
if: github.repository_owner == 'paperless-ngx' if: github.repository_owner == 'paperless-ngx'
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
permissions:
issues: write
pull-requests: write
steps: steps:
- uses: actions/stale@v10 - uses: actions/stale@v10
with: with:
@@ -36,6 +36,10 @@ jobs:
name: 'Lock Old Threads' name: 'Lock Old Threads'
if: github.repository_owner == 'paperless-ngx' if: github.repository_owner == 'paperless-ngx'
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
permissions:
issues: write
pull-requests: write
discussions: write
steps: steps:
- uses: dessant/lock-threads@v6 - uses: dessant/lock-threads@v6
with: with:
@@ -56,6 +60,8 @@ jobs:
name: 'Close Answered Discussions' name: 'Close Answered Discussions'
if: github.repository_owner == 'paperless-ngx' if: github.repository_owner == 'paperless-ngx'
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
permissions:
discussions: write
steps: steps:
- uses: actions/github-script@v8 - uses: actions/github-script@v8
with: with:
@@ -113,6 +119,8 @@ jobs:
name: 'Close Outdated Discussions' name: 'Close Outdated Discussions'
if: github.repository_owner == 'paperless-ngx' if: github.repository_owner == 'paperless-ngx'
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
permissions:
discussions: write
steps: steps:
- uses: actions/github-script@v8 - uses: actions/github-script@v8
with: with:
@@ -205,6 +213,8 @@ jobs:
name: 'Close Unsupported Feature Requests' name: 'Close Unsupported Feature Requests'
if: github.repository_owner == 'paperless-ngx' if: github.repository_owner == 'paperless-ngx'
runs-on: ubuntu-24.04 runs-on: ubuntu-24.04
permissions:
discussions: write
steps: steps:
- uses: actions/github-script@v8 - uses: actions/github-script@v8
with: with:

View File

@@ -3,6 +3,7 @@ on:
push: push:
branches: branches:
- dev - dev
permissions: {}
jobs: jobs:
generate-translate-strings: generate-translate-strings:
name: Generate Translation Strings name: Generate Translation Strings

View File

@@ -445,6 +445,7 @@ src/documents/permissions.py:0: error: Function is missing a type annotation [n
src/documents/permissions.py:0: error: Function is missing a type annotation [no-untyped-def] src/documents/permissions.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/permissions.py:0: error: Function is missing a type annotation [no-untyped-def] src/documents/permissions.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/permissions.py:0: error: Function is missing a type annotation for one or more arguments [no-untyped-def] src/documents/permissions.py:0: error: Function is missing a type annotation for one or more arguments [no-untyped-def]
src/documents/permissions.py:0: error: Function is missing a type annotation for one or more arguments [no-untyped-def]
src/documents/permissions.py:0: error: Item "list[str]" of "Any | list[str] | QuerySet[User, User]" has no attribute "exclude" [union-attr] src/documents/permissions.py:0: error: Item "list[str]" of "Any | list[str] | QuerySet[User, User]" has no attribute "exclude" [union-attr]
src/documents/permissions.py:0: error: Item "list[str]" of "Any | list[str] | QuerySet[User, User]" has no attribute "exists" [union-attr] src/documents/permissions.py:0: error: Item "list[str]" of "Any | list[str] | QuerySet[User, User]" has no attribute "exists" [union-attr]
src/documents/permissions.py:0: error: Item "list[str]" of "Any | list[str] | QuerySet[User, User]" has no attribute "exists" [union-attr] src/documents/permissions.py:0: error: Item "list[str]" of "Any | list[str] | QuerySet[User, User]" has no attribute "exists" [union-attr]
@@ -559,6 +560,8 @@ src/documents/serialisers.py:0: error: Function is missing a type annotation [n
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def] src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def] src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def] src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/serialisers.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/serialisers.py:0: error: Function is missing a type annotation for one or more arguments [no-untyped-def]
src/documents/serialisers.py:0: error: Function is missing a type annotation for one or more arguments [no-untyped-def] src/documents/serialisers.py:0: error: Function is missing a type annotation for one or more arguments [no-untyped-def]
src/documents/serialisers.py:0: error: Function is missing a type annotation for one or more arguments [no-untyped-def] src/documents/serialisers.py:0: error: Function is missing a type annotation for one or more arguments [no-untyped-def]
src/documents/serialisers.py:0: error: Function is missing a type annotation for one or more arguments [no-untyped-def] src/documents/serialisers.py:0: error: Function is missing a type annotation for one or more arguments [no-untyped-def]
@@ -1570,6 +1573,8 @@ src/documents/views.py:0: error: Function is missing a return type annotation [
src/documents/views.py:0: error: Function is missing a return type annotation [no-untyped-def] src/documents/views.py:0: error: Function is missing a return type annotation [no-untyped-def]
src/documents/views.py:0: error: Function is missing a return type annotation [no-untyped-def] src/documents/views.py:0: error: Function is missing a return type annotation [no-untyped-def]
src/documents/views.py:0: error: Function is missing a return type annotation [no-untyped-def] src/documents/views.py:0: error: Function is missing a return type annotation [no-untyped-def]
src/documents/views.py:0: error: Function is missing a return type annotation [no-untyped-def]
src/documents/views.py:0: error: Function is missing a return type annotation [no-untyped-def]
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def] src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def] src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def]
src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def] src/documents/views.py:0: error: Function is missing a type annotation [no-untyped-def]

View File

@@ -431,10 +431,8 @@ This allows for complex logic to be included in the format, including [logical s
and [filters](https://jinja.palletsprojects.com/en/3.1.x/templates/#id11) to manipulate the [variables](#filename-format-variables) and [filters](https://jinja.palletsprojects.com/en/3.1.x/templates/#id11) to manipulate the [variables](#filename-format-variables)
provided. The template is provided as a string, potentially multiline, and rendered into a single line. provided. The template is provided as a string, potentially multiline, and rendered into a single line.
In addition, a limited `document` object is available for advanced templates. In addition, the entire Document instance is available to be utilized in a more advanced way, as well as some variables which only make sense to be accessed
This object includes common metadata fields such as `id`, `pk`, `title`, `content`, `page_count`, `created`, `added`, `modified`, `mime_type`, with more complex logic.
`checksum`, `archive_checksum`, `archive_serial_number`, `filename`, `archive_filename`, and `original_filename`.
Related values are available as nested objects with limited fields, for example document.correspondent.name, etc.
#### Custom Jinja2 Filters #### Custom Jinja2 Filters

View File

@@ -1,23 +1,5 @@
# Changelog # Changelog
## paperless-ngx 2.20.7
### Bug Fixes
- Performance fix: use subqueries to improve object retrieval in large installs [@shamoon](https://github.com/shamoon) ([#11950](https://github.com/paperless-ngx/paperless-ngx/pull/11950))
- Fix: correct user dropdown button icon styling [@shamoon](https://github.com/shamoon) ([#12092](https://github.com/paperless-ngx/paperless-ngx/issues/12092))
- Fix: fix broken docker create_classifier command in 2.20.6 [@shamoon](https://github.com/shamoon) ([#11965](https://github.com/paperless-ngx/paperless-ngx/issues/11965))
### All App Changes
<details>
<summary>3 changes</summary>
- Performance fix: use subqueries to improve object retrieval in large installs [@shamoon](https://github.com/shamoon) ([#11950](https://github.com/paperless-ngx/paperless-ngx/pull/11950))
- Fix: correct user dropdown button icon styling [@shamoon](https://github.com/shamoon) ([#12092](https://github.com/paperless-ngx/paperless-ngx/issues/12092))
- Fix: fix broken docker create_classifier command in 2.20.6 [@shamoon](https://github.com/shamoon) ([#11965](https://github.com/paperless-ngx/paperless-ngx/issues/11965))
</details>
## paperless-ngx 2.20.6 ## paperless-ngx 2.20.6
### Bug Fixes ### Bug Fixes

View File

@@ -1,6 +1,6 @@
[project] [project]
name = "paperless-ngx" name = "paperless-ngx"
version = "2.20.7" version = "2.20.6"
description = "A community-supported supercharged document management system: scan, index and archive all your physical documents" description = "A community-supported supercharged document management system: scan, index and archive all your physical documents"
readme = "README.md" readme = "README.md"
requires-python = ">=3.10" requires-python = ">=3.10"

View File

@@ -52,11 +52,11 @@ test('dashboard saved view document links', async ({ page }) => {
test('test slim sidebar', async ({ page }) => { test('test slim sidebar', async ({ page }) => {
await page.routeFromHAR(REQUESTS_HAR1, { notFound: 'fallback' }) await page.routeFromHAR(REQUESTS_HAR1, { notFound: 'fallback' })
await page.goto('/dashboard') await page.goto('/dashboard')
await page.locator('.sidebar-slim-toggler').click() await page.locator('#sidebarMenu').getByRole('button').click()
await expect( await expect(
page.getByRole('link', { name: 'Dashboard' }).getByText('Dashboard') page.getByRole('link', { name: 'Dashboard' }).getByText('Dashboard')
).toBeHidden() ).toBeHidden()
await page.locator('.sidebar-slim-toggler').click() await page.locator('#sidebarMenu').getByRole('button').click()
await expect( await expect(
page.getByRole('link', { name: 'Dashboard' }).getByText('Dashboard') page.getByRole('link', { name: 'Dashboard' }).getByText('Dashboard')
).toBeVisible() ).toBeVisible()

View File

@@ -33,9 +33,9 @@ test('should not allow user to view correspondents', async ({ page }) => {
await page.routeFromHAR(REQUESTS_HAR, { notFound: 'fallback' }) await page.routeFromHAR(REQUESTS_HAR, { notFound: 'fallback' })
await page.goto('/dashboard') await page.goto('/dashboard')
await expect( await expect(
page.getByRole('link', { name: 'Attributes' }) page.getByRole('link', { name: 'Correspondents' })
).not.toBeAttached() ).not.toBeAttached()
await page.goto('/attributes/correspondents') await page.goto('/correspondents')
await expect(page.locator('body')).toHaveText( await expect(page.locator('body')).toHaveText(
/You don't have permissions to do that/i /You don't have permissions to do that/i
) )
@@ -44,10 +44,8 @@ test('should not allow user to view correspondents', async ({ page }) => {
test('should not allow user to view tags', async ({ page }) => { test('should not allow user to view tags', async ({ page }) => {
await page.routeFromHAR(REQUESTS_HAR, { notFound: 'fallback' }) await page.routeFromHAR(REQUESTS_HAR, { notFound: 'fallback' })
await page.goto('/dashboard') await page.goto('/dashboard')
await expect( await expect(page.getByRole('link', { name: 'Tags' })).not.toBeAttached()
page.getByRole('link', { name: 'Attributes' }) await page.goto('/tags')
).not.toBeAttached()
await page.goto('/attributes/tags')
await expect(page.locator('body')).toHaveText( await expect(page.locator('body')).toHaveText(
/You don't have permissions to do that/i /You don't have permissions to do that/i
) )
@@ -57,9 +55,9 @@ test('should not allow user to view document types', async ({ page }) => {
await page.routeFromHAR(REQUESTS_HAR, { notFound: 'fallback' }) await page.routeFromHAR(REQUESTS_HAR, { notFound: 'fallback' })
await page.goto('/dashboard') await page.goto('/dashboard')
await expect( await expect(
page.getByRole('link', { name: 'Attributes' }) page.getByRole('link', { name: 'Document Types' })
).not.toBeAttached() ).not.toBeAttached()
await page.goto('/attributes/documenttypes') await page.goto('/documenttypes')
await expect(page.locator('body')).toHaveText( await expect(page.locator('body')).toHaveText(
/You don't have permissions to do that/i /You don't have permissions to do that/i
) )
@@ -69,9 +67,9 @@ test('should not allow user to view storage paths', async ({ page }) => {
await page.routeFromHAR(REQUESTS_HAR, { notFound: 'fallback' }) await page.routeFromHAR(REQUESTS_HAR, { notFound: 'fallback' })
await page.goto('/dashboard') await page.goto('/dashboard')
await expect( await expect(
page.getByRole('link', { name: 'Attributes' }) page.getByRole('link', { name: 'Storage Paths' })
).not.toBeAttached() ).not.toBeAttached()
await page.goto('/attributes/storagepaths') await page.goto('/storagepaths')
await expect(page.locator('body')).toHaveText( await expect(page.locator('body')).toHaveText(
/You don't have permissions to do that/i /You don't have permissions to do that/i
) )

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
{ {
"name": "paperless-ngx-ui", "name": "paperless-ngx-ui",
"version": "2.20.7", "version": "2.20.6",
"scripts": { "scripts": {
"preinstall": "npx only-allow pnpm", "preinstall": "npx only-allow pnpm",
"ng": "ng", "ng": "ng",

View File

@@ -11,9 +11,13 @@ import { DashboardComponent } from './components/dashboard/dashboard.component'
import { DocumentAsnComponent } from './components/document-asn/document-asn.component' import { DocumentAsnComponent } from './components/document-asn/document-asn.component'
import { DocumentDetailComponent } from './components/document-detail/document-detail.component' import { DocumentDetailComponent } from './components/document-detail/document-detail.component'
import { DocumentListComponent } from './components/document-list/document-list.component' import { DocumentListComponent } from './components/document-list/document-list.component'
import { DocumentAttributesComponent } from './components/manage/document-attributes/document-attributes.component' import { CorrespondentListComponent } from './components/manage/correspondent-list/correspondent-list.component'
import { CustomFieldsComponent } from './components/manage/custom-fields/custom-fields.component'
import { DocumentTypeListComponent } from './components/manage/document-type-list/document-type-list.component'
import { MailComponent } from './components/manage/mail/mail.component' import { MailComponent } from './components/manage/mail/mail.component'
import { SavedViewsComponent } from './components/manage/saved-views/saved-views.component' import { SavedViewsComponent } from './components/manage/saved-views/saved-views.component'
import { StoragePathListComponent } from './components/manage/storage-path-list/storage-path-list.component'
import { TagListComponent } from './components/manage/tag-list/tag-list.component'
import { WorkflowsComponent } from './components/manage/workflows/workflows.component' import { WorkflowsComponent } from './components/manage/workflows/workflows.component'
import { NotFoundComponent } from './components/not-found/not-found.component' import { NotFoundComponent } from './components/not-found/not-found.component'
import { DirtyDocGuard } from './guards/dirty-doc.guard' import { DirtyDocGuard } from './guards/dirty-doc.guard'
@@ -101,77 +105,53 @@ export const routes: Routes = [
componentName: 'DocumentAsnComponent', componentName: 'DocumentAsnComponent',
}, },
}, },
{
path: 'attributes',
component: DocumentAttributesComponent,
canActivate: [PermissionsGuard],
data: {
requiredPermissionAny: [
{ action: PermissionAction.View, type: PermissionType.Tag },
{
action: PermissionAction.View,
type: PermissionType.Correspondent,
},
{
action: PermissionAction.View,
type: PermissionType.DocumentType,
},
{ action: PermissionAction.View, type: PermissionType.StoragePath },
{ action: PermissionAction.View, type: PermissionType.CustomField },
],
componentName: 'DocumentAttributesComponent',
},
},
{
path: 'attributes/:section',
component: DocumentAttributesComponent,
canActivate: [PermissionsGuard],
data: {
requiredPermissionAny: [
{ action: PermissionAction.View, type: PermissionType.Tag },
{
action: PermissionAction.View,
type: PermissionType.Correspondent,
},
{
action: PermissionAction.View,
type: PermissionType.DocumentType,
},
{ action: PermissionAction.View, type: PermissionType.StoragePath },
{ action: PermissionAction.View, type: PermissionType.CustomField },
],
componentName: 'DocumentAttributesComponent',
},
},
{
path: 'documentproperties',
redirectTo: '/attributes',
pathMatch: 'full',
},
{
path: 'documentproperties/:section',
redirectTo: '/attributes/:section',
pathMatch: 'full',
},
{ {
path: 'tags', path: 'tags',
redirectTo: '/attributes/tags', component: TagListComponent,
pathMatch: 'full', canActivate: [PermissionsGuard],
data: {
requiredPermission: {
action: PermissionAction.View,
type: PermissionType.Tag,
},
componentName: 'TagListComponent',
}, },
{
path: 'correspondents',
redirectTo: '/attributes/correspondents',
pathMatch: 'full',
}, },
{ {
path: 'documenttypes', path: 'documenttypes',
redirectTo: '/attributes/documenttypes', component: DocumentTypeListComponent,
pathMatch: 'full', canActivate: [PermissionsGuard],
data: {
requiredPermission: {
action: PermissionAction.View,
type: PermissionType.DocumentType,
},
componentName: 'DocumentTypeListComponent',
},
},
{
path: 'correspondents',
component: CorrespondentListComponent,
canActivate: [PermissionsGuard],
data: {
requiredPermission: {
action: PermissionAction.View,
type: PermissionType.Correspondent,
},
componentName: 'CorrespondentListComponent',
},
}, },
{ {
path: 'storagepaths', path: 'storagepaths',
redirectTo: '/attributes/storagepaths', component: StoragePathListComponent,
pathMatch: 'full', canActivate: [PermissionsGuard],
data: {
requiredPermission: {
action: PermissionAction.View,
type: PermissionType.StoragePath,
},
componentName: 'StoragePathListComponent',
},
}, },
{ {
path: 'logs', path: 'logs',
@@ -259,8 +239,15 @@ export const routes: Routes = [
}, },
{ {
path: 'customfields', path: 'customfields',
redirectTo: '/attributes/customfields', component: CustomFieldsComponent,
pathMatch: 'full', canActivate: [PermissionsGuard],
data: {
requiredPermission: {
action: PermissionAction.View,
type: PermissionType.CustomField,
},
componentName: 'CustomFieldsComponent',
},
}, },
{ {
path: 'workflows', path: 'workflows',

View File

@@ -195,8 +195,8 @@ export class AppComponent implements OnInit, OnDestroy {
}, },
{ {
anchorId: 'tour.tags', anchorId: 'tour.tags',
content: $localize`Attributes like tags, correspondents, document types, storage paths and custom fields can all be managed here. They can also be created from the document edit view.`, content: $localize`Tags, correspondents, document types and storage paths can all be managed using these pages. They can also be created from the document edit view.`,
route: '/attributes/tags', route: '/tags',
backdropConfig: { backdropConfig: {
offset: 0, offset: 0,
}, },

View File

@@ -5,13 +5,13 @@
i18n-info i18n-info
> >
<button class="btn btn-sm btn-outline-primary" (click)="tourService.start()"> <button class="btn btn-sm btn-outline-primary" (click)="tourService.start()">
<i-bs class="me-2" name="airplane"></i-bs><ng-container i18n>Start tour</ng-container> <i-bs class="me-1" name="airplane"></i-bs>&nbsp;<ng-container i18n>Start tour</ng-container>
</button> </button>
@if (permissionsService.isAdmin()) { @if (permissionsService.isAdmin()) {
<button class="btn btn-sm btn-outline-primary position-relative ms-md-5 me-1" (click)="showSystemStatus()" <button class="btn btn-sm btn-outline-primary position-relative ms-md-5 me-1" (click)="showSystemStatus()"
[disabled]="!systemStatus"> [disabled]="!systemStatus">
@if (!systemStatus) { @if (!systemStatus) {
<div class="spinner-border spinner-border-sm me-2 h-75" role="status"></div> <div class="spinner-border spinner-border-sm me-1 h-75" role="status"></div>
} @else { } @else {
<i-bs class="me-2" name="card-checklist"></i-bs> <i-bs class="me-2" name="card-checklist"></i-bs>
@if (systemStatusHasErrors) { @if (systemStatusHasErrors) {
@@ -28,7 +28,7 @@
</button> </button>
<a class="btn btn-sm btn-primary" href="admin/" target="_blank"> <a class="btn btn-sm btn-primary" href="admin/" target="_blank">
<ng-container i18n>Open Django Admin</ng-container> <ng-container i18n>Open Django Admin</ng-container>
<i-bs class="ms-2" name="arrow-up-right"></i-bs> &nbsp;<i-bs name="arrow-up-right"></i-bs>
</a> </a>
} }
</pngx-page-header> </pngx-page-header>

View File

@@ -6,10 +6,10 @@
> >
<div class="btn-toolbar col col-md-auto align-items-center gap-2"> <div class="btn-toolbar col col-md-auto align-items-center gap-2">
<button class="btn btn-sm btn-outline-secondary me-2" (click)="clearSelection()" [hidden]="selectedTasks.size === 0"> <button class="btn btn-sm btn-outline-secondary me-2" (click)="clearSelection()" [hidden]="selectedTasks.size === 0">
<i-bs name="x" class="me-1"></i-bs><ng-container i18n>Clear selection</ng-container> <i-bs name="x"></i-bs>&nbsp;<ng-container i18n>Clear selection</ng-container>
</button> </button>
<button class="btn btn-sm btn-outline-primary me-2" (click)="dismissTasks()" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.PaperlessTask }" [disabled]="tasksService.total === 0"> <button class="btn btn-sm btn-outline-primary me-2" (click)="dismissTasks()" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.PaperlessTask }" [disabled]="tasksService.total === 0">
<i-bs name="check2-all" class="me-1"></i-bs>{{dismissButtonText}} <i-bs name="check2-all"></i-bs>&nbsp;{{dismissButtonText}}
</button> </button>
<div class="form-inline d-flex align-items-center"> <div class="form-inline d-flex align-items-center">
<div class="input-group input-group-sm flex-fill w-auto flex-nowrap"> <div class="input-group input-group-sm flex-fill w-auto flex-nowrap">
@@ -113,12 +113,12 @@
<td scope="row"> <td scope="row">
<div class="btn-group" role="group"> <div class="btn-group" role="group">
<button class="btn btn-sm btn-outline-secondary" (click)="dismissTask(task); $event.stopPropagation();" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.PaperlessTask }"> <button class="btn btn-sm btn-outline-secondary" (click)="dismissTask(task); $event.stopPropagation();" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.PaperlessTask }">
<i-bs name="check" class="me-1"></i-bs><ng-container i18n>Dismiss</ng-container> <i-bs name="check"></i-bs>&nbsp;<ng-container i18n>Dismiss</ng-container>
</button> </button>
<ng-container *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }"> <ng-container *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }">
@if (task.related_document) { @if (task.related_document) {
<button class="btn btn-sm btn-outline-primary" (click)="dismissAndGo(task); $event.stopPropagation();"> <button class="btn btn-sm btn-outline-primary" (click)="dismissAndGo(task); $event.stopPropagation();">
<i-bs name="file-text" class="me-1"></i-bs><ng-container i18n>Open Document</ng-container> <i-bs name="file-text"></i-bs>&nbsp;<ng-container i18n>Open Document</ng-container>
</button> </button>
} }
</ng-container> </ng-container>

View File

@@ -5,16 +5,16 @@
i18n-info i18n-info
infoLink="usage/#document-trash"> infoLink="usage/#document-trash">
<button class="btn btn-sm btn-outline-secondary" (click)="clearSelection()" [hidden]="selectedDocuments.size === 0"> <button class="btn btn-sm btn-outline-secondary" (click)="clearSelection()" [hidden]="selectedDocuments.size === 0">
<i-bs name="x" class="me-1"></i-bs><ng-container i18n>Clear selection</ng-container> <i-bs name="x"></i-bs>&nbsp;<ng-container i18n>Clear selection</ng-container>
</button> </button>
<button type="button" class="btn btn-sm btn-outline-primary" (click)="restoreAll(selectedDocuments)" [disabled]="selectedDocuments.size === 0"> <button type="button" class="btn btn-sm btn-outline-primary" (click)="restoreAll(selectedDocuments)" [disabled]="selectedDocuments.size === 0">
<i-bs name="arrow-counterclockwise" class="me-1"></i-bs><ng-container i18n>Restore selected</ng-container> <i-bs name="arrow-counterclockwise"></i-bs>&nbsp;<ng-container i18n>Restore selected</ng-container>
</button> </button>
<button type="button" class="btn btn-sm btn-outline-danger" (click)="emptyTrash(selectedDocuments)" [disabled]="selectedDocuments.size === 0"> <button type="button" class="btn btn-sm btn-outline-danger" (click)="emptyTrash(selectedDocuments)" [disabled]="selectedDocuments.size === 0">
<i-bs name="trash" class="me-1"></i-bs><ng-container i18n>Delete selected</ng-container> <i-bs name="trash"></i-bs>&nbsp;<ng-container i18n>Delete selected</ng-container>
</button> </button>
<button type="button" class="btn btn-sm btn-outline-danger" (click)="emptyTrash()" [disabled]="documentsInTrash.length === 0"> <button type="button" class="btn btn-sm btn-outline-danger" (click)="emptyTrash()" [disabled]="documentsInTrash.length === 0">
<i-bs name="trash" class="me-1"></i-bs><ng-container i18n>Empty trash</ng-container> <i-bs name="trash"></i-bs>&nbsp;<ng-container i18n>Empty trash</ng-container>
</button> </button>
</pngx-page-header> </pngx-page-header>
@@ -75,10 +75,10 @@
</div> </div>
<div class="btn-group d-none d-sm-block"> <div class="btn-group d-none d-sm-block">
<button class="btn btn-sm btn-outline-secondary" (click)="restore(document); $event.stopPropagation();"> <button class="btn btn-sm btn-outline-secondary" (click)="restore(document); $event.stopPropagation();">
<i-bs width="1em" height="1em" name="arrow-counterclockwise" class="me-1"></i-bs><ng-container i18n>Restore</ng-container> <i-bs width="1em" height="1em" name="arrow-counterclockwise"></i-bs>&nbsp;<ng-container i18n>Restore</ng-container>
</button> </button>
<button class="btn btn-sm btn-outline-danger" (click)="delete(document); $event.stopPropagation();"> <button class="btn btn-sm btn-outline-danger" (click)="delete(document); $event.stopPropagation();">
<i-bs width="1em" height="1em" name="trash" class="me-1"></i-bs><ng-container i18n>Delete</ng-container> <i-bs width="1em" height="1em" name="trash"></i-bs>&nbsp;<ng-container i18n>Delete</ng-container>
</button> </button>
</div> </div>
</td> </td>

View File

@@ -11,7 +11,7 @@
<h4 class="d-flex"> <h4 class="d-flex">
<ng-container i18n>Users</ng-container> <ng-container i18n>Users</ng-container>
<button type="button" class="btn btn-sm btn-outline-primary ms-4" (click)="editUser()" *pngxIfPermissions="{ action: PermissionAction.Add, type: PermissionType.User }"> <button type="button" class="btn btn-sm btn-outline-primary ms-4" (click)="editUser()" *pngxIfPermissions="{ action: PermissionAction.Add, type: PermissionType.User }">
<i-bs name="plus-circle" class="me-1"></i-bs><ng-container i18n>Add User</ng-container> <i-bs name="plus-circle"></i-bs>&nbsp;<ng-container i18n>Add User</ng-container>
</button> </button>
</h4> </h4>
<ul class="list-group"> <ul class="list-group">
@@ -32,10 +32,10 @@
<div class="col"> <div class="col">
<div class="btn-group"> <div class="btn-group">
<button class="btn btn-sm btn-outline-secondary" type="button" (click)="editUser(user)" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.User }"> <button class="btn btn-sm btn-outline-secondary" type="button" (click)="editUser(user)" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.User }">
<i-bs width="1em" height="1em" name="pencil" class="me-1"></i-bs><ng-container i18n>Edit</ng-container> <i-bs width="1em" height="1em" name="pencil"></i-bs>&nbsp;<ng-container i18n>Edit</ng-container>
</button> </button>
<button class="btn btn-sm btn-outline-danger" type="button" (click)="deleteUser(user)" *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.User }"> <button class="btn btn-sm btn-outline-danger" type="button" (click)="deleteUser(user)" *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.User }">
<i-bs width="1em" height="1em" name="trash" class="me-1"></i-bs><ng-container i18n>Delete</ng-container> <i-bs width="1em" height="1em" name="trash"></i-bs>&nbsp;<ng-container i18n>Delete</ng-container>
</button> </button>
</div> </div>
</div> </div>
@@ -49,7 +49,7 @@
<h4 class="mt-4 d-flex"> <h4 class="mt-4 d-flex">
<ng-container i18n>Groups</ng-container> <ng-container i18n>Groups</ng-container>
<button type="button" class="btn btn-sm btn-outline-primary ms-4" (click)="editGroup()" *pngxIfPermissions="{ action: PermissionAction.Add, type: PermissionType.Group }"> <button type="button" class="btn btn-sm btn-outline-primary ms-4" (click)="editGroup()" *pngxIfPermissions="{ action: PermissionAction.Add, type: PermissionType.Group }">
<i-bs name="plus-circle" class="me-1"></i-bs><ng-container i18n>Add Group</ng-container> <i-bs name="plus-circle"></i-bs>&nbsp;<ng-container i18n>Add Group</ng-container>
</button> </button>
</h4> </h4>
<ul class="list-group"> <ul class="list-group">
@@ -70,10 +70,10 @@
<div class="col"> <div class="col">
<div class="btn-group"> <div class="btn-group">
<button class="btn btn-sm btn-outline-secondary" type="button" (click)="editGroup(group)" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.Group }"> <button class="btn btn-sm btn-outline-secondary" type="button" (click)="editGroup(group)" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.Group }">
<i-bs width="1em" height="1em" name="pencil" class="me-1"></i-bs><ng-container i18n>Edit</ng-container> <i-bs width="1em" height="1em" name="pencil"></i-bs>&nbsp;<ng-container i18n>Edit</ng-container>
</button> </button>
<button class="btn btn-sm btn-outline-danger" type="button" (click)="deleteGroup(group)" *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.Group }"> <button class="btn btn-sm btn-outline-danger" type="button" (click)="deleteGroup(group)" *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.Group }">
<i-bs width="1em" height="1em" name="trash" class="me-1"></i-bs><ng-container i18n>Delete</ng-container> <i-bs width="1em" height="1em" name="trash"></i-bs>&nbsp;<ng-container i18n>Delete</ng-container>
</button> </button>
</div> </div>
</div> </div>

View File

@@ -86,14 +86,14 @@
<a class="nav-link" routerLink="dashboard" routerLinkActive="active" (click)="closeMenu()" <a class="nav-link" routerLink="dashboard" routerLinkActive="active" (click)="closeMenu()"
ngbPopover="Dashboard" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" ngbPopover="Dashboard" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim"> container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
<i-bs class="me-2" name="house"></i-bs><span><ng-container i18n>Dashboard</ng-container></span> <i-bs class="me-1" name="house"></i-bs><span>&nbsp;<ng-container i18n>Dashboard</ng-container></span>
</a> </a>
</li> </li>
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }"> <li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }">
<a class="nav-link" routerLink="documents" routerLinkActive="active" (click)="closeMenu()" <a class="nav-link" routerLink="documents" routerLinkActive="active" (click)="closeMenu()"
ngbPopover="Documents" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" ngbPopover="Documents" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim"> container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
<i-bs class="me-2" name="files"></i-bs><span><ng-container i18n>Documents</ng-container></span> <i-bs class="me-1" name="files"></i-bs><span>&nbsp;<ng-container i18n>Documents</ng-container></span>
</a> </a>
</li> </li>
</ul> </ul>
@@ -117,7 +117,8 @@
routerLinkActive="active" (click)="closeMenu()" [ngbPopover]="view.name" routerLinkActive="active" (click)="closeMenu()" [ngbPopover]="view.name"
[disablePopover]="!slimSidebarEnabled" placement="end" container="body" triggers="mouseenter:mouseleave" [disablePopover]="!slimSidebarEnabled" placement="end" container="body" triggers="mouseenter:mouseleave"
popoverClass="popover-slim"> popoverClass="popover-slim">
<i-bs class="me-2" name="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-1" name="funnel"></i-bs>
<span>&nbsp;<div class="d-inline-flex view-name"><span class="overflow-hidden" [class.text-wrap]="!slimSidebarEnabled">{{view.name}}</span></div>
@if (showSidebarCounts && !slimSidebarEnabled) { @if (showSidebarCounts && !slimSidebarEnabled) {
<span class="badge bg-info text-dark ms-2 d-inline">{{ savedViewService.getDocumentCount(view) }}</span> <span class="badge bg-info text-dark ms-2 d-inline">{{ savedViewService.getDocumentCount(view) }}</span>
} }
@@ -150,7 +151,7 @@
routerLinkActive="active" (click)="closeMenu()" [ngbPopover]="d.title | documentTitle" routerLinkActive="active" (click)="closeMenu()" [ngbPopover]="d.title | documentTitle"
[disablePopover]="!slimSidebarEnabled" placement="end" container="body" triggers="mouseenter:mouseleave" [disablePopover]="!slimSidebarEnabled" placement="end" container="body" triggers="mouseenter:mouseleave"
popoverClass="popover-slim"> popoverClass="popover-slim">
<i-bs class="me-2" name="file-text"></i-bs><span>{{d.title | documentTitle}}</span> <i-bs class="me-1" name="file-text"></i-bs><span>&nbsp;{{d.title | documentTitle}}</span>
<span class="close flex-column justify-content-center" (click)="closeDocument(d); $event.preventDefault()"> <span class="close flex-column justify-content-center" (click)="closeDocument(d); $event.preventDefault()">
<i-bs name="x"></i-bs> <i-bs name="x"></i-bs>
</span> </span>
@@ -162,7 +163,7 @@
<a class="nav-link app-link" [class.text-truncate]="!slimSidebarEnabled" [routerLink]="[]" (click)="closeAll()" <a class="nav-link app-link" [class.text-truncate]="!slimSidebarEnabled" [routerLink]="[]" (click)="closeAll()"
ngbPopover="Close all" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" ngbPopover="Close all" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim"> container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
<i-bs class="me-2" name="x"></i-bs><span><ng-container i18n>Close all</ng-container></span> <i-bs class="me-1" name="x"></i-bs><span>&nbsp;<ng-container i18n>Close all</ng-container></span>
</a> </a>
</li> </li>
} }
@@ -174,65 +175,49 @@
<span i18n>Manage</span> <span i18n>Manage</span>
</h6> </h6>
<ul class="nav flex-column mb-2"> <ul class="nav flex-column mb-2">
@if (canManageAttributes) { <li class="nav-item app-link"
<li class="nav-item app-link" tourAnchor="tour.tags"> *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Correspondent }">
<div class="d-flex align-items-center attributes-row"> <a class="nav-link" routerLink="correspondents" routerLinkActive="active" (click)="closeMenu()"
<a class="nav-link flex-fill" routerLink="attributes" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Correspondents" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
ngbPopover="Attributes" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim"> container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
<i-bs class="me-2" name="stack"></i-bs><span><ng-container i18n>Attributes</ng-container></span> <i-bs class="me-1" name="person"></i-bs><span>&nbsp;<ng-container i18n>Correspondents</ng-container></span>
</a>
@if (!slimSidebarEnabled) {
<button
type="button"
class="btn btn-link btn-sm text-muted p-0 me-3 attributes-expand-btn"
(click)="toggleAttributesSections($event)"
[attr.aria-label]="attributesSectionsCollapsed ? 'Expand attributes sections' : 'Collapse attributes sections'"
i18n-aria-label
>
<i-bs [name]="attributesSectionsCollapsed ? 'plus-circle' : 'dash-circle'"></i-bs>
</button>
}
</div>
<div
class="attributes-submenu ms-2"
[ngbCollapse]="slimSidebarEnabled || attributesSectionsCollapsed"
>
<ul class="nav flex-column">
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Tag }">
<a class="nav-link py-1" routerLink="attributes/tags" routerLinkActive="active" (click)="closeMenu()">
<i-bs class="me-2" name="tags"></i-bs><span><ng-container i18n>Tags</ng-container></span>
</a> </a>
</li> </li>
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Correspondent }"> <li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Tag }"
<a class="nav-link py-1" routerLink="attributes/correspondents" routerLinkActive="active" (click)="closeMenu()"> tourAnchor="tour.tags">
<i-bs class="me-2" name="person"></i-bs><span><ng-container i18n>Correspondents</ng-container></span> <a class="nav-link" routerLink="tags" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Tags"
i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" container="body"
triggers="mouseenter:mouseleave" popoverClass="popover-slim">
<i-bs class="me-1" name="tags"></i-bs><span>&nbsp;<ng-container i18n>Tags</ng-container></span>
</a> </a>
</li> </li>
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.DocumentType }"> <li class="nav-item app-link"
<a class="nav-link py-1" routerLink="attributes/documenttypes" routerLinkActive="active" (click)="closeMenu()"> *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.DocumentType }">
<i-bs class="me-2" name="hash"></i-bs><span><ng-container i18n>Document types</ng-container></span> <a class="nav-link" routerLink="documenttypes" routerLinkActive="active" (click)="closeMenu()"
ngbPopover="Document Types" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
<i-bs class="me-1" name="hash"></i-bs><span>&nbsp;<ng-container i18n>Document Types</ng-container></span>
</a> </a>
</li> </li>
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.StoragePath }"> <li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.StoragePath }">
<a class="nav-link py-1" routerLink="attributes/storagepaths" routerLinkActive="active" (click)="closeMenu()"> <a class="nav-link" routerLink="storagepaths" routerLinkActive="active" (click)="closeMenu()"
<i-bs class="me-2" name="folder"></i-bs><span><ng-container i18n>Storage paths</ng-container></span> ngbPopover="Storage Paths" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
<i-bs class="me-1" name="folder"></i-bs><span>&nbsp;<ng-container i18n>Storage Paths</ng-container></span>
</a> </a>
</li> </li>
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.CustomField }"> <li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.CustomField }">
<a class="nav-link py-1" routerLink="attributes/customfields" routerLinkActive="active" (click)="closeMenu()"> <a class="nav-link" routerLink="customfields" routerLinkActive="active" (click)="closeMenu()"
<i-bs class="me-2" name="ui-radios"></i-bs><span><ng-container i18n>Custom fields</ng-container></span> ngbPopover="Custom Fields" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
<i-bs class="me-1" name="ui-radios"></i-bs><span>&nbsp;<ng-container i18n>Custom Fields</ng-container></span>
</a> </a>
</li> </li>
</ul>
</div>
</li>
}
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.SavedView }"> <li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.SavedView }">
<a class="nav-link" routerLink="savedviews" routerLinkActive="active" (click)="closeMenu()" <a class="nav-link" routerLink="savedviews" routerLinkActive="active" (click)="closeMenu()"
ngbPopover="Saved Views" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" ngbPopover="Saved Views" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim"> container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
<i-bs class="me-2" name="window-stack"></i-bs><span><ng-container i18n>Saved Views</ng-container></span> <i-bs class="me-1" name="window-stack"></i-bs><span>&nbsp;<ng-container i18n>Saved Views</ng-container></span>
</a> </a>
</li> </li>
<li class="nav-item app-link" <li class="nav-item app-link"
@@ -241,7 +226,7 @@
<a class="nav-link" routerLink="workflows" routerLinkActive="active" (click)="closeMenu()" <a class="nav-link" routerLink="workflows" routerLinkActive="active" (click)="closeMenu()"
ngbPopover="Workflows" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" ngbPopover="Workflows" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim"> container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
<i-bs class="me-2" name="boxes"></i-bs><span><ng-container i18n>Workflows</ng-container></span> <i-bs class="me-1" name="boxes"></i-bs><span>&nbsp;<ng-container i18n>Workflows</ng-container></span>
</a> </a>
</li> </li>
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.MailAccount }" <li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.MailAccount }"
@@ -249,14 +234,14 @@
<a class="nav-link" routerLink="mail" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Mail" <a class="nav-link" routerLink="mail" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Mail"
i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" container="body" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" container="body"
triggers="mouseenter:mouseleave" popoverClass="popover-slim"> triggers="mouseenter:mouseleave" popoverClass="popover-slim">
<i-bs class="me-2" name="envelope"></i-bs><span><ng-container i18n>Mail</ng-container></span> <i-bs class="me-1" name="envelope"></i-bs><span>&nbsp;<ng-container i18n>Mail</ng-container></span>
</a> </a>
</li> </li>
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.Document }"> <li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.Document }">
<a class="nav-link" routerLink="trash" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Trash" <a class="nav-link" routerLink="trash" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Trash"
i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" container="body" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" container="body"
triggers="mouseenter:mouseleave" popoverClass="popover-slim"> triggers="mouseenter:mouseleave" popoverClass="popover-slim">
<i-bs class="me-2" name="trash"></i-bs><span><ng-container i18n>Trash</ng-container></span> <i-bs class="me-1" name="trash"></i-bs><span>&nbsp;<ng-container i18n>Trash</ng-container></span>
</a> </a>
</li> </li>
</ul> </ul>
@@ -272,21 +257,21 @@
<a class="nav-link" routerLink="settings" routerLinkActive="active" (click)="closeMenu()" <a class="nav-link" routerLink="settings" routerLinkActive="active" (click)="closeMenu()"
ngbPopover="Settings" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" ngbPopover="Settings" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim"> container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
<i-bs class="me-2" name="gear"></i-bs><span><ng-container i18n>Settings</ng-container></span> <i-bs class="me-1" name="gear"></i-bs><span>&nbsp;<ng-container i18n>Settings</ng-container></span>
</a> </a>
</li> </li>
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.AppConfig }"> <li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.AppConfig }">
<a class="nav-link" routerLink="config" routerLinkActive="active" (click)="closeMenu()" <a class="nav-link" routerLink="config" routerLinkActive="active" (click)="closeMenu()"
ngbPopover="Configuration" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" ngbPopover="Configuration" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim"> container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
<i-bs class="me-2" name="sliders2-vertical"></i-bs><span><ng-container i18n>Configuration</ng-container></span> <i-bs class="me-1" name="sliders2-vertical"></i-bs><span>&nbsp;<ng-container i18n>Configuration</ng-container></span>
</a> </a>
</li> </li>
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.User }"> <li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.User }">
<a class="nav-link" routerLink="usersgroups" routerLinkActive="active" (click)="closeMenu()" <a class="nav-link" routerLink="usersgroups" routerLinkActive="active" (click)="closeMenu()"
ngbPopover="Users & Groups" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" ngbPopover="Users & Groups" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim"> container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
<i-bs class="me-2" name="people"></i-bs><span><ng-container i18n>Users & Groups</ng-container></span> <i-bs class="me-1" name="people"></i-bs><span>&nbsp;<ng-container i18n>Users & Groups</ng-container></span>
</a> </a>
</li> </li>
<li class="nav-item app-link" <li class="nav-item app-link"
@@ -295,7 +280,7 @@
<a class="nav-link" routerLink="tasks" routerLinkActive="active" (click)="closeMenu()" <a class="nav-link" routerLink="tasks" routerLinkActive="active" (click)="closeMenu()"
ngbPopover="File Tasks" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" ngbPopover="File Tasks" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim"> container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
<i-bs class="me-2" name="list-task"></i-bs><span><ng-container i18n>File Tasks</ng-container>@if (tasksService.failedFileTasks.length > 0) { <i-bs class="me-1" name="list-task"></i-bs><span>&nbsp;<ng-container i18n>File Tasks</ng-container>@if (tasksService.failedFileTasks.length > 0) {
<span><span class="badge bg-danger ms-2 d-inline">{{tasksService.failedFileTasks.length}}</span></span> <span><span class="badge bg-danger ms-2 d-inline">{{tasksService.failedFileTasks.length}}</span></span>
}</span> }</span>
@if (tasksService.failedFileTasks.length > 0 && slimSidebarEnabled) { @if (tasksService.failedFileTasks.length > 0 && slimSidebarEnabled) {
@@ -308,7 +293,7 @@
<a class="nav-link" routerLink="logs" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Logs" <a class="nav-link" routerLink="logs" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Logs"
i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" container="body" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" container="body"
triggers="mouseenter:mouseleave" popoverClass="popover-slim"> triggers="mouseenter:mouseleave" popoverClass="popover-slim">
<i-bs class="me-2" name="text-left"></i-bs><span><ng-container i18n>Logs</ng-container></span> <i-bs class="me-1" name="text-left"></i-bs><span>&nbsp;<ng-container i18n>Logs</ng-container></span>
</a> </a>
</li> </li>
} }
@@ -317,7 +302,7 @@
target="_blank" rel="noopener noreferrer" href="https://docs.paperless-ngx.com" ngbPopover="Documentation" target="_blank" rel="noopener noreferrer" href="https://docs.paperless-ngx.com" ngbPopover="Documentation"
i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" container="body" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" container="body"
triggers="mouseenter:mouseleave" popoverClass="popover-slim"> triggers="mouseenter:mouseleave" popoverClass="popover-slim">
<i-bs class="d-flex me-2" name="question-circle"></i-bs><span><ng-container i18n>Documentation</ng-container></span> <i-bs class="d-flex" name="question-circle"></i-bs><span class="ms-1">&nbsp;<ng-container i18n>Documentation</ng-container></span>
</a> </a>
</li> </li>
<li class="nav-item" [class.visually-hidden]="slimSidebarEnabled"> <li class="nav-item" [class.visually-hidden]="slimSidebarEnabled">
@@ -356,9 +341,9 @@
href="https://github.com/paperless-ngx/paperless-ngx/releases" href="https://github.com/paperless-ngx/paperless-ngx/releases"
[ngbPopover]="updateAvailablePopContent" popoverClass="shadow" triggers="mouseenter:mouseleave" [ngbPopover]="updateAvailablePopContent" popoverClass="shadow" triggers="mouseenter:mouseleave"
container="body"> container="body">
<i-bs width="1.2em" height="1.2em" name="info-circle" class="me-1"></i-bs> <i-bs width="1.2em" height="1.2em" name="info-circle"></i-bs>
@if (appRemoteVersion?.update_available) { @if (appRemoteVersion?.update_available) {
<ng-container i18n>Update available</ng-container> &nbsp;<ng-container i18n>Update available</ng-container>
} }
</a> </a>
} }

View File

@@ -177,15 +177,6 @@ main {
} }
} }
.attributes-row .attributes-expand-btn {
opacity: 0.2;
transition: opacity 0.15s ease-in-out;
}
.attributes-row:hover .attributes-expand-btn {
opacity: 1;
}
.sidebar-heading { .sidebar-heading {
font-size: 0.75rem; font-size: 0.75rem;
text-transform: uppercase; text-transform: uppercase;
@@ -290,7 +281,7 @@ main {
.navbar .dropdown-menu { .navbar .dropdown-menu {
font-size: 0.875rem; // body size font-size: 0.875rem; // body size
a i-bs, button i-bs { a i-bs {
opacity: 0.6; opacity: 0.6;
} }
} }

View File

@@ -28,10 +28,7 @@ import {
DjangoMessagesService, DjangoMessagesService,
} from 'src/app/services/django-messages.service' } from 'src/app/services/django-messages.service'
import { OpenDocumentsService } from 'src/app/services/open-documents.service' import { OpenDocumentsService } from 'src/app/services/open-documents.service'
import { import { PermissionsService } from 'src/app/services/permissions.service'
PermissionType,
PermissionsService,
} from 'src/app/services/permissions.service'
import { RemoteVersionService } from 'src/app/services/rest/remote-version.service' import { RemoteVersionService } from 'src/app/services/rest/remote-version.service'
import { SavedViewService } from 'src/app/services/rest/saved-view.service' import { SavedViewService } from 'src/app/services/rest/saved-view.service'
import { SearchService } from 'src/app/services/rest/search.service' import { SearchService } from 'src/app/services/rest/search.service'
@@ -261,7 +258,7 @@ describe('AppFrameComponent', () => {
const toastSpy = jest.spyOn(toastService, 'showError') const toastSpy = jest.spyOn(toastService, 'showError')
component.toggleSlimSidebar() component.toggleSlimSidebar()
httpTestingController httpTestingController
.match(`${environment.apiBaseUrl}ui_settings/`)[0] .expectOne(`${environment.apiBaseUrl}ui_settings/`)
.flush('error', { .flush('error', {
status: 500, status: 500,
statusText: 'error', statusText: 'error',
@@ -376,103 +373,4 @@ describe('AppFrameComponent', () => {
it('should call maybeRefreshDocumentCounts after saved views reload', () => { it('should call maybeRefreshDocumentCounts after saved views reload', () => {
expect(maybeRefreshSpy).toHaveBeenCalled() expect(maybeRefreshSpy).toHaveBeenCalled()
}) })
it('should indicate attributes management availability when any permission is granted', () => {
jest
.spyOn(permissionsService, 'currentUserCan')
.mockImplementation((action, type) => {
return type === PermissionType.Tag
})
expect(component.canManageAttributes).toBe(true)
})
it('should indicate attributes management availability for other permission types', () => {
const canSpy = jest
.spyOn(permissionsService, 'currentUserCan')
.mockImplementation((action, type) => {
return type === PermissionType.Correspondent
})
expect(component.canManageAttributes).toBe(true)
canSpy.mockImplementation((action, type) => {
return type === PermissionType.DocumentType
})
expect(component.canManageAttributes).toBe(true)
canSpy.mockImplementation((action, type) => {
return type === PermissionType.StoragePath
})
expect(component.canManageAttributes).toBe(true)
canSpy.mockImplementation((action, type) => {
return type === PermissionType.CustomField
})
expect(component.canManageAttributes).toBe(true)
})
it('should toggle attributes sections and stop event bubbling', () => {
const preventDefault = jest.fn()
const stopPropagation = jest.fn()
const setSpy = jest.spyOn(settingsService, 'set')
jest.spyOn(settingsService, 'storeSettings').mockReturnValue(of(true))
component.toggleAttributesSections({
preventDefault,
stopPropagation,
} as any)
expect(preventDefault).toHaveBeenCalled()
expect(stopPropagation).toHaveBeenCalled()
expect(setSpy).toHaveBeenCalledWith(
SETTINGS_KEYS.ATTRIBUTES_SECTIONS_COLLAPSED,
['attributes']
)
})
it('should show error when saving slim sidebar setting fails', () => {
const toastSpy = jest.spyOn(toastService, 'showError')
jest.spyOn(console, 'warn').mockImplementation(() => {})
jest
.spyOn(settingsService, 'storeSettings')
.mockReturnValue(throwError(() => new Error('boom')))
component.slimSidebarEnabled = true
expect(toastSpy).toHaveBeenCalled()
})
it('should show error when saving attributes collapsed setting fails', () => {
const toastSpy = jest.spyOn(toastService, 'showError')
jest.spyOn(console, 'warn').mockImplementation(() => {})
jest
.spyOn(settingsService, 'storeSettings')
.mockReturnValue(throwError(() => new Error('boom')))
component.attributesSectionsCollapsed = true
expect(toastSpy).toHaveBeenCalled()
})
it('should persist attributes section collapse state', () => {
const setSpy = jest.spyOn(settingsService, 'set')
jest.spyOn(settingsService, 'storeSettings').mockReturnValue(of(true))
component.attributesSectionsCollapsed = true
expect(setSpy).toHaveBeenCalledWith(
SETTINGS_KEYS.ATTRIBUTES_SECTIONS_COLLAPSED,
['attributes']
)
})
it('should collapse attributes sections when enabling slim sidebar', () => {
jest.spyOn(settingsService, 'storeSettings').mockReturnValue(of(true))
settingsService.set(SETTINGS_KEYS.ATTRIBUTES_SECTIONS_COLLAPSED, [])
settingsService.set(SETTINGS_KEYS.SLIM_SIDEBAR, false)
component.toggleSlimSidebar()
expect(component.attributesSectionsCollapsed).toBe(true)
})
}) })

View File

@@ -21,7 +21,7 @@ import { Observable } from 'rxjs'
import { first } from 'rxjs/operators' import { first } from 'rxjs/operators'
import { Document } from 'src/app/data/document' import { Document } from 'src/app/data/document'
import { SavedView } from 'src/app/data/saved-view' import { SavedView } from 'src/app/data/saved-view'
import { CollapsibleSection, SETTINGS_KEYS } from 'src/app/data/ui-settings' import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive' import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
import { ComponentCanDeactivate } from 'src/app/guards/dirty-doc.guard' import { ComponentCanDeactivate } from 'src/app/guards/dirty-doc.guard'
import { DocumentTitlePipe } from 'src/app/pipes/document-title.pipe' import { DocumentTitlePipe } from 'src/app/pipes/document-title.pipe'
@@ -141,20 +141,11 @@ export class AppFrameComponent
toggleSlimSidebar(): void { toggleSlimSidebar(): void {
this.slimSidebarAnimating = true this.slimSidebarAnimating = true
this.slimSidebarEnabled = !this.slimSidebarEnabled this.slimSidebarEnabled = !this.slimSidebarEnabled
if (this.slimSidebarEnabled) {
this.attributesSectionsCollapsed = true
}
setTimeout(() => { setTimeout(() => {
this.slimSidebarAnimating = false this.slimSidebarAnimating = false
}, 200) // slightly longer than css animation for slim sidebar }, 200) // slightly longer than css animation for slim sidebar
} }
toggleAttributesSections(event?: Event): void {
event?.preventDefault()
event?.stopPropagation()
this.attributesSectionsCollapsed = !this.attributesSectionsCollapsed
}
get versionString(): string { get versionString(): string {
return `${environment.appTitle} v${this.settingsService.get(SETTINGS_KEYS.VERSION)}${environment.tag === 'prod' ? '' : ` #${environment.tag}`}` return `${environment.appTitle} v${this.settingsService.get(SETTINGS_KEYS.VERSION)}${environment.tag === 'prod' ? '' : ` #${environment.tag}`}`
} }
@@ -176,31 +167,6 @@ export class AppFrameComponent
) )
} }
get canManageAttributes(): boolean {
return (
this.permissionsService.currentUserCan(
PermissionAction.View,
PermissionType.Tag
) ||
this.permissionsService.currentUserCan(
PermissionAction.View,
PermissionType.Correspondent
) ||
this.permissionsService.currentUserCan(
PermissionAction.View,
PermissionType.DocumentType
) ||
this.permissionsService.currentUserCan(
PermissionAction.View,
PermissionType.StoragePath
) ||
this.permissionsService.currentUserCan(
PermissionAction.View,
PermissionType.CustomField
)
)
}
get slimSidebarEnabled(): boolean { get slimSidebarEnabled(): boolean {
return this.settingsService.get(SETTINGS_KEYS.SLIM_SIDEBAR) return this.settingsService.get(SETTINGS_KEYS.SLIM_SIDEBAR)
} }
@@ -220,31 +186,6 @@ export class AppFrameComponent
}) })
} }
get attributesSectionsCollapsed(): boolean {
return this.settingsService
.get(SETTINGS_KEYS.ATTRIBUTES_SECTIONS_COLLAPSED)
?.includes(CollapsibleSection.ATTRIBUTES)
}
set attributesSectionsCollapsed(collapsed: boolean) {
// TODO: refactor to be able to toggle individual sections, if implemented
this.settingsService.set(
SETTINGS_KEYS.ATTRIBUTES_SECTIONS_COLLAPSED,
collapsed ? [CollapsibleSection.ATTRIBUTES] : []
)
this.settingsService
.storeSettings()
.pipe(first())
.subscribe({
error: (error) => {
this.toastService.showError(
$localize`An error occurred while saving settings.`
)
console.warn(error)
},
})
}
get aiEnabled(): boolean { get aiEnabled(): boolean {
return this.settingsService.get(SETTINGS_KEYS.AI_ENABLED) return this.settingsService.get(SETTINGS_KEYS.AI_ENABLED)
} }

View File

@@ -49,13 +49,17 @@
[disabled]="disablePrimaryButton(type, item)" [disabled]="disablePrimaryButton(type, item)"
(mouseenter)="onButtonHover($event)"> (mouseenter)="onButtonHover($event)">
@if (type === DataType.Document) { @if (type === DataType.Document) {
<i-bs width="1em" height="1em" name="file-earmark-richtext" class="me-1"></i-bs><span><ng-container i18n>Open</ng-container></span> <i-bs width="1em" height="1em" name="file-earmark-richtext"></i-bs>
<span>&nbsp;<ng-container i18n>Open</ng-container></span>
} @else if (type === DataType.SavedView) { } @else if (type === DataType.SavedView) {
<i-bs width="1em" height="1em" name="eye" class="me-1"></i-bs><span><ng-container i18n>Open</ng-container></span> <i-bs width="1em" height="1em" name="eye"></i-bs>
<span>&nbsp;<ng-container i18n>Open</ng-container></span>
} @else if (type === DataType.Workflow || type === DataType.CustomField || type === DataType.Group || type === DataType.User || type === DataType.MailAccount || type === DataType.MailRule) { } @else if (type === DataType.Workflow || type === DataType.CustomField || type === DataType.Group || type === DataType.User || type === DataType.MailAccount || type === DataType.MailRule) {
<i-bs width="1em" height="1em" name="pencil" class="me-1"></i-bs><span><ng-container i18n>Open</ng-container></span> <i-bs width="1em" height="1em" name="pencil"></i-bs>
<span>&nbsp;<ng-container i18n>Open</ng-container></span>
} @else { } @else {
<i-bs width="1em" height="1em" name="filter" class="me-1"></i-bs><span><ng-container i18n>Filter documents</ng-container></span> <i-bs width="1em" height="1em" name="filter"></i-bs>
<span>&nbsp;<ng-container i18n>Filter documents</ng-container></span>
} }
</button> </button>
@if (type !== DataType.SavedView && type !== DataType.Workflow && type !== DataType.CustomField && type !== DataType.Group && type !== DataType.User && type !== DataType.MailAccount && type !== DataType.MailRule) { @if (type !== DataType.SavedView && type !== DataType.Workflow && type !== DataType.CustomField && type !== DataType.Group && type !== DataType.User && type !== DataType.MailAccount && type !== DataType.MailRule) {
@@ -65,9 +69,11 @@
[disabled]="disableSecondaryButton(type, item)" [disabled]="disableSecondaryButton(type, item)"
(mouseenter)="onButtonHover($event)"> (mouseenter)="onButtonHover($event)">
@if (type === DataType.Document) { @if (type === DataType.Document) {
<i-bs width="1em" height="1em" name="download" class="me-1"></i-bs><span><ng-container i18n>Download</ng-container></span> <i-bs width="1em" height="1em" name="download"></i-bs>
<span>&nbsp;<ng-container i18n>Download</ng-container></span>
} @else { } @else {
<i-bs width="1em" height="1em" name="file-earmark-richtext" class="me-1"></i-bs><span><ng-container i18n>Open</ng-container></span> <i-bs width="1em" height="1em" name="file-earmark-richtext"></i-bs>
<span>&nbsp;<ng-container i18n>Open</ng-container></span>
} }
</button> </button>
} }

View File

@@ -20,7 +20,7 @@
@for (docId of value; track docId) { @for (docId of value; track docId) {
@if (getDocumentTitle(docId)) { @if (getDocumentTitle(docId)) {
<a routerLink="/documents/{{docId}}" class="badge bg-body text-primary" title="View" i18n-title> <a routerLink="/documents/{{docId}}" class="badge bg-body text-primary" title="View" i18n-title>
<i-bs width="0.9em" height="0.9em" name="file-text" class="me-1"></i-bs><span>{{ getDocumentTitle(docId) }}</span> <i-bs width="0.9em" height="0.9em" name="file-text"></i-bs>&nbsp;<span>{{ getDocumentTitle(docId) }}</span>
</a> </a>
} }
} }

View File

@@ -1,6 +1,7 @@
<div ngbDropdown #fieldDropdown="ngbDropdown" (openChange)="onOpenClose($event)" [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> <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> <i-bs name="ui-radios"></i-bs>
<div class="d-none d-lg-inline">&nbsp;<ng-container i18n>Custom Fields</ng-container></div>
</button> </button>
<div ngbDropdownMenu aria-labelledby="customFieldsDropdown" class="shadow custom-fields-dropdown"> <div ngbDropdownMenu aria-labelledby="customFieldsDropdown" class="shadow custom-fields-dropdown">
<div class="list-group list-group-flush" (keydown)="listKeyDown($event)"> <div class="list-group list-group-flush" (keydown)="listKeyDown($event)">
@@ -17,7 +18,7 @@
@if (!filterText?.length || filteredFields.length === 0) { @if (!filterText?.length || filteredFields.length === 0) {
<button class="list-group-item list-group-item-action bg-light" (click)="createField(filterText)" [disabled]="!canCreateFields" #button> <button class="list-group-item list-group-item-action bg-light" (click)="createField(filterText)" [disabled]="!canCreateFields" #button>
<small> <small>
<i-bs width=".9em" height=".9em" name="asterisk" class="me-1"></i-bs><ng-container i18n>Create new field</ng-container> <i-bs width=".9em" height=".9em" name="asterisk"></i-bs>&nbsp;<ng-container i18n>Create new field</ng-container>
</small> </small>
</button> </button>
} }

View File

@@ -1,7 +1,8 @@
@if (useDropdown) { @if (useDropdown) {
<div class="btn-group w-100" role="group" ngbDropdown #dropdown="ngbDropdown" (openChange)="onOpenChange($event)" [popperOptions]="popperOptions"> <div class="btn-group w-100" role="group" ngbDropdown #dropdown="ngbDropdown" (openChange)="onOpenChange($event)" [popperOptions]="popperOptions">
<button class="btn btn-sm btn-outline-primary" id="dropdown_toggle" ngbDropdownToggle [disabled]="disabled"> <button class="btn btn-sm btn-outline-primary" id="dropdown_toggle" ngbDropdownToggle [disabled]="disabled">
<i-bs name="{{icon}}"></i-bs><div class="d-none d-sm-inline ms-1">{{title}}</div> <i-bs name="{{icon}}"></i-bs>
<div class="d-none d-sm-inline">&nbsp;{{title}}</div>
@if (isActive) { @if (isActive) {
<pngx-clearable-badge [selected]="isActive" (cleared)="reset()"></pngx-clearable-badge> <pngx-clearable-badge [selected]="isActive" (cleared)="reset()"></pngx-clearable-badge>
} }

View File

@@ -1,6 +1,7 @@
<div class="btn-group w-100" ngbDropdown role="group" [popperOptions]="popperOptions" [placement]="placement"> <div class="btn-group w-100" ngbDropdown role="group" [popperOptions]="popperOptions" [placement]="placement">
<button class="btn btn-sm" id="dropdown{{title}}" ngbDropdownToggle [ngClass]="createdDateTo || createdDateFrom ? 'btn-primary' : 'btn-outline-primary'" [disabled]="disabled"> <button class="btn btn-sm" id="dropdown{{title}}" ngbDropdownToggle [ngClass]="createdDateTo || createdDateFrom ? 'btn-primary' : 'btn-outline-primary'" [disabled]="disabled">
<i-bs width="1em" height="1em" name="calendar-event-fill"></i-bs><div class="d-none d-sm-inline ms-1">{{title}}</div> <i-bs width="1em" height="1em" name="calendar-event-fill"></i-bs>
<div class="d-none d-sm-inline">&nbsp;{{title}}</div>
<pngx-clearable-badge [selected]="isActive" (cleared)="reset()"></pngx-clearable-badge><span class="visually-hidden">selected</span> <pngx-clearable-badge [selected]="isActive" (cleared)="reset()"></pngx-clearable-badge><span class="visually-hidden">selected</span>
</button> </button>
<div class="dropdown-menu date-dropdown shadow p-2" ngbDropdownMenu attr.aria-labelledby="dropdown{{title}}"> <div class="dropdown-menu date-dropdown shadow p-2" ngbDropdownMenu attr.aria-labelledby="dropdown{{title}}">

View File

@@ -17,7 +17,7 @@
@switch (objectForm.get('data_type').value) { @switch (objectForm.get('data_type').value) {
@case (CustomFieldDataType.Select) { @case (CustomFieldDataType.Select) {
<button type="button" class="btn btn-sm btn-primary my-2" (click)="addSelectOption()"> <button type="button" class="btn btn-sm btn-primary my-2" (click)="addSelectOption()">
<span i18n>Add option</span><i-bs class="ms-1" name="plus-circle"></i-bs> <span i18n>Add option</span>&nbsp;<i-bs name="plus-circle"></i-bs>
</button> </button>
<div formArrayName="select_options"> <div formArrayName="select_options">
@for (option of objectForm.controls.extra_data.controls.select_options.controls; track option; let i = $index) { @for (option of objectForm.controls.extra_data.controls.select_options.controls; track option; let i = $index) {

View File

@@ -9,24 +9,19 @@
</div> </div>
<div class="modal-body"> <div class="modal-body">
<div class="row"> <div class="row">
<div class="col-md-6"> <div class="col-md-4">
<pngx-input-text [horizontal]="true" i18n-title title="Name" formControlName="name" [error]="error?.name" autocomplete="off"></pngx-input-text> <pngx-input-text [horizontal]="true" i18n-title title="Name" formControlName="name" [error]="error?.name" autocomplete="off"></pngx-input-text>
</div> </div>
<div class="col-md-4"> <div class="col-md-3">
<pngx-input-select [horizontal]="true" i18n-title title="Account" [items]="accounts" formControlName="account"></pngx-input-select> <pngx-input-select [horizontal]="true" i18n-title title="Account" [items]="accounts" formControlName="account"></pngx-input-select>
</div> </div>
<div class="col-md-3">
<pngx-input-number [horizontal]="true" i18n-title title="Order" formControlName="order" [showAdd]="false" [error]="error?.order"></pngx-input-number>
</div>
<div class="col-md-2 pt-2"> <div class="col-md-2 pt-2">
<pngx-input-switch [horizontal]="true" i18n-title title="Enabled" formControlName="enabled"></pngx-input-switch> <pngx-input-switch [horizontal]="true" i18n-title title="Enabled" formControlName="enabled"></pngx-input-switch>
</div> </div>
</div> </div>
<div class="row">
<div class="col-md-6">
<pngx-input-number [horizontal]="true" i18n-title title="Order" formControlName="order" [showAdd]="false" [error]="error?.order"></pngx-input-number>
</div>
<div class="col-md-6">
<pngx-input-switch [horizontal]="true" i18n-title title="Stop further processing" formControlName="stop_processing" i18n-hint hint="Stop processing further rules if this rule queues any document(s)."></pngx-input-switch>
</div>
</div>
<hr class="mt-0"/> <hr class="mt-0"/>
<div class="row"> <div class="row">
<p class="small" i18n>Paperless will only process mails that match <em>all</em> of the criteria specified below.</p> <p class="small" i18n>Paperless will only process mails that match <em>all</em> of the criteria specified below.</p>

View File

@@ -222,7 +222,6 @@ export class MailRuleEditDialogComponent extends EditDialogComponent<MailRule> {
), ),
assign_correspondent: new FormControl(null), assign_correspondent: new FormControl(null),
assign_owner_from_rule: new FormControl(true), assign_owner_from_rule: new FormControl(true),
stop_processing: new FormControl(false),
}) })
} }

View File

@@ -30,7 +30,7 @@
<div class="d-flex"> <div class="d-flex">
<p class="p-2" i18n>Trigger Workflow On:</p> <p class="p-2" i18n>Trigger Workflow On:</p>
<button type="button" class="btn btn-sm btn-outline-primary ms-auto mb-3" (click)="addTrigger()"> <button type="button" class="btn btn-sm btn-outline-primary ms-auto mb-3" (click)="addTrigger()">
<i-bs name="plus-circle" class="me-1"></i-bs><ng-container i18n>Add Trigger</ng-container> <i-bs name="plus-circle"></i-bs>&nbsp;<ng-container i18n>Add Trigger</ng-container>
</button> </button>
</div> </div>
<div ngbAccordion [closeOthers]="true"> <div ngbAccordion [closeOthers]="true">
@@ -72,7 +72,7 @@
<div class="d-flex"> <div class="d-flex">
<p class="p-2" i18n>Apply Actions:</p> <p class="p-2" i18n>Apply Actions:</p>
<button type="button" class="btn btn-sm btn-outline-primary ms-auto mb-3" (click)="addAction()"> <button type="button" class="btn btn-sm btn-outline-primary ms-auto mb-3" (click)="addAction()">
<i-bs name="plus-circle" class="me-1"></i-bs><ng-container i18n>Add Action</ng-container> <i-bs name="plus-circle"></i-bs>&nbsp;<ng-container i18n>Add Action</ng-container>
</button> </button>
</div> </div>
<div ngbAccordion [closeOthers]="true" cdkDropList (cdkDropListDropped)="onActionDrop($event)"> <div ngbAccordion [closeOthers]="true" cdkDropList (cdkDropListDropped)="onActionDrop($event)">
@@ -187,7 +187,7 @@
(click)="addFilter(formGroup)" (click)="addFilter(formGroup)"
[disabled]="!canAddFilter(formGroup)" [disabled]="!canAddFilter(formGroup)"
> >
<i-bs name="plus-circle" class="me-1"></i-bs><span i18n>Add filter</span> <i-bs name="plus-circle"></i-bs>&nbsp;<span i18n>Add filter</span>
</button> </button>
</div> </div>
<ul class="mt-2 list-group filters" formArrayName="filters"> <ul class="mt-2 list-group filters" formArrayName="filters">

View File

@@ -1,6 +1,7 @@
<div class="btn-group w-100" ngbDropdown role="group" (openChange)="dropdownOpenChange($event)" #dropdown="ngbDropdown" (keydown)="listKeyDown($event)" [popperOptions]="popperOptions"> <div class="btn-group w-100" ngbDropdown role="group" (openChange)="dropdownOpenChange($event)" #dropdown="ngbDropdown" (keydown)="listKeyDown($event)" [popperOptions]="popperOptions">
<button class="btn btn-sm" id="dropdown_{{name}}" ngbDropdownToggle [ngClass]="!editing && selectionModel.selectionSize() > 0 ? 'btn-primary' : 'btn-outline-primary'" [disabled]="disabled"> <button class="btn btn-sm" id="dropdown_{{name}}" ngbDropdownToggle [ngClass]="!editing && selectionModel.selectionSize() > 0 ? 'btn-primary' : 'btn-outline-primary'" [disabled]="disabled">
<i-bs name="{{icon}}"></i-bs><div class="d-none d-sm-inline ms-1">{{title}}</div> <i-bs name="{{icon}}"></i-bs>
<div class="d-none d-sm-inline">&nbsp;{{title}}</div>
@if (!editing && selectionModel.totalCount > 0) { @if (!editing && selectionModel.totalCount > 0) {
<pngx-clearable-badge [number]="selectionModel.totalCount" [selected]="selectionModel.selectionSize() > 0" (cleared)="reset()"></pngx-clearable-badge> <pngx-clearable-badge [number]="selectionModel.totalCount" [selected]="selectionModel.selectionSize() > 0" (cleared)="reset()"></pngx-clearable-badge>
} }

View File

@@ -5,7 +5,7 @@
<label class="form-label" [class.mb-md-0]="horizontal" [for]="inputId">{{title}}</label> <label class="form-label" [class.mb-md-0]="horizontal" [for]="inputId">{{title}}</label>
@if (removable) { @if (removable) {
<button type="button" class="btn btn-sm btn-danger position-absolute left-0" (click)="removed.emit(this)"> <button type="button" class="btn btn-sm btn-danger position-absolute left-0" (click)="removed.emit(this)">
<i-bs name="x" class="me-1"></i-bs><ng-container i18n>Remove</ng-container> <i-bs name="x"></i-bs>&nbsp;<ng-container i18n>Remove</ng-container>
</button> </button>
} }
</div> </div>

View File

@@ -4,7 +4,7 @@
<label class="form-label" [class.mb-md-0]="horizontal" [for]="inputId">{{title}}</label> <label class="form-label" [class.mb-md-0]="horizontal" [for]="inputId">{{title}}</label>
@if (removable) { @if (removable) {
<button type="button" class="btn btn-sm btn-danger position-absolute left-0" (click)="removed.emit(this)"> <button type="button" class="btn btn-sm btn-danger position-absolute left-0" (click)="removed.emit(this)">
<i-bs name="x" class="me-1"></i-bs><ng-container i18n>Remove</ng-container> <i-bs name="x"></i-bs>&nbsp;<ng-container i18n>Remove</ng-container>
</button> </button>
} }
</div> </div>

View File

@@ -9,7 +9,7 @@
} }
@if (removable) { @if (removable) {
<button type="button" class="btn btn-sm btn-danger position-absolute left-0" (click)="removed.emit(this)"> <button type="button" class="btn btn-sm btn-danger position-absolute left-0" (click)="removed.emit(this)">
<i-bs name="x" class="me-1"></i-bs><ng-container i18n>Remove</ng-container> <i-bs name="x"></i-bs>&nbsp;<ng-container i18n>Remove</ng-container>
</button> </button>
} }
</div> </div>
@@ -44,11 +44,11 @@
} }
@if (document.title) { @if (document.title) {
<a routerLink="/documents/{{document.id}}" class="badge bg-light text-primary" (mousedown)="$event.stopImmediatePropagation();" title="Open link" i18n-title> <a routerLink="/documents/{{document.id}}" class="badge bg-light text-primary" (mousedown)="$event.stopImmediatePropagation();" title="Open link" i18n-title>
<i-bs width="0.9em" height="0.9em" name="file-text" class="me-1"></i-bs><span>{{document.title}}</span> <i-bs width="0.9em" height="0.9em" name="file-text"></i-bs>&nbsp;<span>{{document.title}}</span>
</a> </a>
} @else { } @else {
<span class="badge bg-light text-muted" (click)="unselect(document)" (mousedown)="$event.stopImmediatePropagation()" type="button" title="Remove link" i18n-title> <span class="badge bg-light text-muted" (click)="unselect(document)" (mousedown)="$event.stopImmediatePropagation()" type="button" title="Remove link" i18n-title>
<i-bs width="0.9em" height="0.9em" name="exclamation-triangle-fill" class="me-1"></i-bs><span i18n>Not found</span> <i-bs width="0.9em" height="0.9em" name="exclamation-triangle-fill"></i-bs>&nbsp;<span i18n>Not found</span>
</span> </span>
} }
</div> </div>

View File

@@ -5,7 +5,7 @@
<label class="form-label mb-0" [class.mb-md-0]="horizontal" [for]="inputId">{{title}}</label> <label class="form-label mb-0" [class.mb-md-0]="horizontal" [for]="inputId">{{title}}</label>
} }
<button type="button" class="btn btn-sm btn-outline-primary ms-4" (click)="addEntry()"> <button type="button" class="btn btn-sm btn-outline-primary ms-4" (click)="addEntry()">
<i-bs name="plus-circle" class="me-1"></i-bs><ng-container i18n>Add</ng-container> <i-bs name="plus-circle"></i-bs>&nbsp;<ng-container i18n>Add</ng-container>
</button> </button>
</div> </div>
<div class="position-relative"> <div class="position-relative">

View File

@@ -6,7 +6,7 @@
} }
@if (removable) { @if (removable) {
<button type="button" class="btn btn-sm btn-danger position-absolute left-0" (click)="removed.emit(this)"> <button type="button" class="btn btn-sm btn-danger position-absolute left-0" (click)="removed.emit(this)">
<i-bs name="x" class="me-1"></i-bs><ng-container i18n>Remove</ng-container> <i-bs name="x"></i-bs>&nbsp;<ng-container i18n>Remove</ng-container>
</button> </button>
} }
</div> </div>

View File

@@ -6,7 +6,7 @@
} }
@if (removable) { @if (removable) {
<button type="button" class="btn btn-sm btn-danger position-absolute left-0" (click)="removed.emit(this)"> <button type="button" class="btn btn-sm btn-danger position-absolute left-0" (click)="removed.emit(this)">
<i-bs name="x" class="me-1"></i-bs><ng-container i18n>Remove</ng-container> <i-bs name="x"></i-bs>&nbsp;<ng-container i18n>Remove</ng-container>
</button> </button>
} }
</div> </div>

View File

@@ -6,7 +6,7 @@
} }
@if (removable) { @if (removable) {
<button type="button" class="btn btn-sm btn-danger position-absolute left-0" (click)="removed.emit(this)"> <button type="button" class="btn btn-sm btn-danger position-absolute left-0" (click)="removed.emit(this)">
<i-bs name="x" class="me-1"></i-bs><ng-container i18n>Remove</ng-container> <i-bs name="x"></i-bs>&nbsp;<ng-container i18n>Remove</ng-container>
</button> </button>
} }
</div> </div>

View File

@@ -7,7 +7,7 @@
} }
@if (removable) { @if (removable) {
<button type="button" class="btn btn-sm btn-danger position-absolute left-0" (click)="removed.emit(this)"> <button type="button" class="btn btn-sm btn-danger position-absolute left-0" (click)="removed.emit(this)">
<i-bs name="x" class="me-1"></i-bs><ng-container i18n>Remove</ng-container> <i-bs name="x"></i-bs>&nbsp;<ng-container i18n>Remove</ng-container>
</button> </button>
} }
</div> </div>

View File

@@ -10,7 +10,7 @@
</label> </label>
@if (removable) { @if (removable) {
<button type="button" class="btn btn-sm btn-danger position-absolute left-0" (click)="removed.emit(this)"> <button type="button" class="btn btn-sm btn-danger position-absolute left-0" (click)="removed.emit(this)">
<i-bs name="x" class="me-1"></i-bs><ng-container i18n>Remove</ng-container> <i-bs name="x"></i-bs>&nbsp;<ng-container i18n>Remove</ng-container>
</button> </button>
} }
</div> </div>
@@ -22,7 +22,7 @@
<label class="form-check-label" [class.text-muted]="showUnsetNote && isUnset" [for]="inputId" [ngbTooltip]="showUnsetNote && isUnset ? tipContent: null" placement="end"> <label class="form-check-label" [class.text-muted]="showUnsetNote && isUnset" [for]="inputId" [ngbTooltip]="showUnsetNote && isUnset ? tipContent: null" placement="end">
{{title}} {{title}}
@if (showUnsetNote && isUnset) { @if (showUnsetNote && isUnset) {
<i-bs class="ms-1" width="0.9em" height="0.9em" name="exclamation-triangle"></i-bs> &nbsp;<i-bs width="0.9em" height="0.9em" name="exclamation-triangle"></i-bs>
} }
</label> </label>
} }

View File

@@ -6,7 +6,7 @@
} }
@if (removable) { @if (removable) {
<button type="button" class="btn btn-sm btn-danger position-absolute left-0" (click)="removed.emit(this)"> <button type="button" class="btn btn-sm btn-danger position-absolute left-0" (click)="removed.emit(this)">
<i-bs name="x" class="me-1"></i-bs><ng-container i18n>Remove</ng-container> <i-bs name="x"></i-bs>&nbsp;<ng-container i18n>Remove</ng-container>
</button> </button>
} }
</div> </div>

View File

@@ -6,7 +6,7 @@
} }
@if (removable) { @if (removable) {
<button type="button" class="btn btn-sm btn-danger position-absolute left-0" (click)="removed.emit(this)"> <button type="button" class="btn btn-sm btn-danger position-absolute left-0" (click)="removed.emit(this)">
<i-bs name="x" class="me-1"></i-bs><ng-container i18n>Remove</ng-container> <i-bs name="x"></i-bs>&nbsp;<ng-container i18n>Remove</ng-container>
</button> </button>
} }
</div> </div>

View File

@@ -4,7 +4,7 @@
<label class="form-label" [class.mb-md-0]="horizontal" [for]="inputId">{{title}}</label> <label class="form-label" [class.mb-md-0]="horizontal" [for]="inputId">{{title}}</label>
@if (removable) { @if (removable) {
<button type="button" class="btn btn-sm btn-danger position-absolute left-0" (click)="removed.emit(this)"> <button type="button" class="btn btn-sm btn-danger position-absolute left-0" (click)="removed.emit(this)">
<i-bs name="x" class="me-1"></i-bs><ng-container i18n>Remove</ng-container> <i-bs name="x"></i-bs>&nbsp;<ng-container i18n>Remove</ng-container>
</button> </button>
} }
</div> </div>

View File

@@ -5,7 +5,7 @@
@if (id) { @if (id) {
<span class="badge bg-primary text-primary-text-contrast ms-3 small fs-normal cursor-pointer" (click)="copyID()"> <span class="badge bg-primary text-primary-text-contrast ms-3 small fs-normal cursor-pointer" (click)="copyID()">
@if (copied) { @if (copied) {
<i-bs width="1em" height="1em" name="clipboard-check" class="me-1"></i-bs><ng-container i18n>Copied!</ng-container> <i-bs width="1em" height="1em" name="clipboard-check"></i-bs>&nbsp;<ng-container i18n>Copied!</ng-container>
} @else { } @else {
ID: {{id}} ID: {{id}}
} }

View File

@@ -150,8 +150,4 @@
position: absolute; position: absolute;
inset: 0; inset: 0;
pointer-events: none; pointer-events: none;
& .annotationTextContent {
opacity: 0;
}
} }

View File

@@ -65,13 +65,6 @@ describe('PngxPdfViewerComponent', () => {
const pageSpy = jest.fn() const pageSpy = jest.fn()
component.pageChange.subscribe(pageSpy) component.pageChange.subscribe(pageSpy)
// In real usage the viewer may have multiple pages; our pdfjs mock defaults
// to a single page, so explicitly simulate a multi-page document here.
const pdf = (component as any).pdf as { numPages: number }
pdf.numPages = 3
const viewer = (component as any).pdfViewer as PDFViewer
viewer.setDocument(pdf)
component.zoomScale = PdfZoomScale.PageFit component.zoomScale = PdfZoomScale.PageFit
component.zoom = PdfZoomLevel.Two component.zoom = PdfZoomLevel.Two
component.rotation = 90 component.rotation = 90
@@ -88,6 +81,7 @@ describe('PngxPdfViewerComponent', () => {
page: new SimpleChange(undefined, 2, false), page: new SimpleChange(undefined, 2, false),
}) })
const viewer = (component as any).pdfViewer as PDFViewer
expect(viewer.pagesRotation).toBe(90) expect(viewer.pagesRotation).toBe(90)
expect(viewer.currentPageNumber).toBe(2) expect(viewer.currentPageNumber).toBe(2)
expect(pageSpy).toHaveBeenCalledWith(2) expect(pageSpy).toHaveBeenCalledWith(2)
@@ -202,8 +196,6 @@ describe('PngxPdfViewerComponent', () => {
const scaleSpy = jest.spyOn(component as any, 'applyViewerState') const scaleSpy = jest.spyOn(component as any, 'applyViewerState')
const resizeSpy = jest.spyOn(component as any, 'setupResizeObserver') const resizeSpy = jest.spyOn(component as any, 'setupResizeObserver')
// Angular sets the input value before calling ngOnChanges; mirror that here.
component.src = 'test.pdf'
component.ngOnChanges({ component.ngOnChanges({
src: new SimpleChange(undefined, 'test.pdf', true), src: new SimpleChange(undefined, 'test.pdf', true),
zoomScale: new SimpleChange( zoomScale: new SimpleChange(
@@ -219,25 +211,6 @@ describe('PngxPdfViewerComponent', () => {
expect(scaleSpy).not.toHaveBeenCalled() expect(scaleSpy).not.toHaveBeenCalled()
}) })
it('resets viewer state on src change', () => {
const mockViewer = {
setDocument: jest.fn(),
currentPageNumber: 7,
cleanup: jest.fn(),
}
;(component as any).pdfViewer = mockViewer
;(component as any).loadingTask = { destroy: jest.fn() }
jest.spyOn(component as any, 'loadDocument').mockImplementation(() => {})
component.src = 'test.pdf'
component.ngOnChanges({
src: new SimpleChange(undefined, 'test.pdf', true),
})
expect(mockViewer.setDocument).toHaveBeenCalledWith(null)
expect(mockViewer.currentPageNumber).toBe(1)
})
it('applies viewer state after view init when already loaded', () => { it('applies viewer state after view init when already loaded', () => {
const applySpy = jest.spyOn(component as any, 'applyViewerState') const applySpy = jest.spyOn(component as any, 'applyViewerState')
;(component as any).hasLoaded = true ;(component as any).hasLoaded = true

View File

@@ -81,7 +81,7 @@ export class PngxPdfViewerComponent
this.dispatchFindIfReady() this.dispatchFindIfReady()
this.rendered.emit() this.rendered.emit()
} }
private readonly onPagesInit = () => this.applyViewerState() private readonly onPagesInit = () => this.applyScale()
private readonly onPageChanging = (evt: { pageNumber: number }) => { private readonly onPageChanging = (evt: { pageNumber: number }) => {
// Avoid [(page)] two-way binding re-triggers navigation // Avoid [(page)] two-way binding re-triggers navigation
this.lastViewerPage = evt.pageNumber this.lastViewerPage = evt.pageNumber
@@ -90,10 +90,8 @@ export class PngxPdfViewerComponent
ngOnChanges(changes: SimpleChanges): void { ngOnChanges(changes: SimpleChanges): void {
if (changes['src']) { if (changes['src']) {
this.resetViewerState() this.hasLoaded = false
if (this.src) {
this.loadDocument() this.loadDocument()
}
return return
} }
@@ -141,21 +139,6 @@ export class PngxPdfViewerComponent
this.pdfViewer = undefined this.pdfViewer = undefined
} }
private resetViewerState(): void {
this.hasLoaded = false
this.hasRenderedPage = false
this.lastFindQuery = ''
this.lastViewerPage = undefined
this.loadingTask?.destroy()
this.loadingTask = undefined
this.pdf = undefined
this.linkService.setDocument(null)
if (this.pdfViewer) {
this.pdfViewer.setDocument(null)
this.pdfViewer.currentPageNumber = 1
}
}
private async loadDocument(): Promise<void> { private async loadDocument(): Promise<void> {
if (this.hasLoaded) { if (this.hasLoaded) {
return return
@@ -239,11 +222,7 @@ export class PngxPdfViewerComponent
hasPages && hasPages &&
this.page !== this.lastViewerPage this.page !== this.lastViewerPage
) { ) {
const nextPage = Math.min( this.pdfViewer.currentPageNumber = this.page
Math.max(Math.trunc(this.page), 1),
this.pdfViewer.pagesCount
)
this.pdfViewer.currentPageNumber = nextPage
} }
if (this.page === this.lastViewerPage) { if (this.page === this.lastViewerPage) {
this.lastViewerPage = undefined this.lastViewerPage = undefined

View File

@@ -1,6 +1,7 @@
<div class="btn-group w-100" ngbDropdown role="group"> <div class="btn-group w-100" ngbDropdown role="group">
<button class="btn btn-sm" id="dropdown{{title}}" ngbDropdownToggle [ngClass]="isActive ? 'btn-primary' : 'btn-outline-primary'" [disabled]="disabled"> <button class="btn btn-sm" id="dropdown{{title}}" ngbDropdownToggle [ngClass]="isActive ? 'btn-primary' : 'btn-outline-primary'" [disabled]="disabled">
<i-bs name="person-fill-lock"></i-bs><div class="d-none d-sm-inline ms-1">{{title}}</div> <i-bs name="person-fill-lock"></i-bs>
<div class="d-none d-sm-inline">&nbsp;{{title}}</div>
<pngx-clearable-badge [selected]="isActive" (cleared)="reset()"></pngx-clearable-badge><span class="visually-hidden">selected</span> <pngx-clearable-badge [selected]="isActive" (cleared)="reset()"></pngx-clearable-badge><span class="visually-hidden">selected</span>
</button> </button>
<div class="dropdown-menu permission-filter-dropdown shadow py-0 w-2" ngbDropdownMenu attr.aria-labelledby="dropdown{{title}}"> <div class="dropdown-menu permission-filter-dropdown shadow py-0 w-2" ngbDropdownMenu attr.aria-labelledby="dropdown{{title}}">

View File

@@ -90,7 +90,7 @@
<div class="list-group"> <div class="list-group">
@for (provider of socialAccountProviders; track provider.name) { @for (provider of socialAccountProviders; track provider.name) {
<a class="list-group-item list-group-item-action text-primary d-flex align-items-center" href="{{ provider.login_url }}" rel="noopener noreferrer"> <a class="list-group-item list-group-item-action text-primary d-flex align-items-center" href="{{ provider.login_url }}" rel="noopener noreferrer">
{{provider.name}}<i-bs class="pb-1 ms-2" name="box-arrow-up-right"></i-bs> {{provider.name}}&nbsp;<i-bs class="pb-1 ps-1" name="box-arrow-up-right"></i-bs>
</a> </a>
} }
</div> </div>
@@ -139,7 +139,7 @@
<label class="d-block mb-2" i18n>Two-factor Authentication</label> <label class="d-block mb-2" i18n>Two-factor Authentication</label>
@if (recoveryCodes) { @if (recoveryCodes) {
<div class="alert alert-warning" role="alert"> <div class="alert alert-warning" role="alert">
<i-bs name="exclamation-triangle" class="me-1"></i-bs><ng-container i18n>Recovery codes will not be shown again, make sure to save them.</ng-container> <i-bs name="exclamation-triangle"></i-bs>&nbsp;<ng-container i18n>Recovery codes will not be shown again, make sure to save them.</ng-container>
</div> </div>
<div class="d-flex flex-row align-items-start mb-3"> <div class="d-flex flex-row align-items-start mb-3">
<ul class="list-group w-50"> <ul class="list-group w-50">
@@ -156,10 +156,12 @@
</ul> </ul>
<button type="button" class="btn btn-sm btn-outline-secondary ms-2" (click)="copyRecoveryCodes()" i18n-title title="Copy"> <button type="button" class="btn btn-sm btn-outline-secondary ms-2" (click)="copyRecoveryCodes()" i18n-title title="Copy">
@if (!codesCopied) { @if (!codesCopied) {
<i-bs width="1em" height="1em" name="clipboard-fill" class="me-1"></i-bs><span i18n>Copy codes</span> <i-bs width="1em" height="1em" name="clipboard-fill"></i-bs>
&nbsp;<span i18n>Copy codes</span>
} }
@if (codesCopied) { @if (codesCopied) {
<i-bs width="1em" height="1em" name="clipboard-check-fill" class="text-primary me-1"></i-bs><span class="text-primary" i18n>Copied!</span> <i-bs width="1em" height="1em" name="clipboard-check-fill" class="text-primary"></i-bs>
&nbsp;<span class="text-primary" i18n>Copied!</span>
} }
</button> </button>
</div> </div>

View File

@@ -173,7 +173,7 @@
<div class="spinner-border spinner-border-sm ms-2" role="status"></div> <div class="spinner-border spinner-border-sm ms-2" role="status"></div>
} @else { } @else {
<button class="btn btn-sm d-flex align-items-center btn-dark small ms-2" (click)="runTask(PaperlessTaskName.IndexOptimize)"> <button class="btn btn-sm d-flex align-items-center btn-dark small ms-2" (click)="runTask(PaperlessTaskName.IndexOptimize)">
<i-bs name="play-fill" class="me-1"></i-bs> <i-bs name="play-fill"></i-bs>&nbsp;
<ng-container i18n>Run Task</ng-container> <ng-container i18n>Run Task</ng-container>
</button> </button>
} }
@@ -207,7 +207,7 @@
<div class="spinner-border spinner-border-sm ms-2" role="status"></div> <div class="spinner-border spinner-border-sm ms-2" role="status"></div>
} @else { } @else {
<button class="btn btn-sm d-flex align-items-center btn-dark small ms-2" (click)="runTask(PaperlessTaskName.TrainClassifier)"> <button class="btn btn-sm d-flex align-items-center btn-dark small ms-2" (click)="runTask(PaperlessTaskName.TrainClassifier)">
<i-bs name="play-fill" class="me-1"></i-bs> <i-bs name="play-fill"></i-bs>&nbsp;
<ng-container i18n>Run Task</ng-container> <ng-container i18n>Run Task</ng-container>
</button> </button>
} }
@@ -241,7 +241,7 @@
<div class="spinner-border spinner-border-sm ms-2" role="status"></div> <div class="spinner-border spinner-border-sm ms-2" role="status"></div>
} @else { } @else {
<button class="btn btn-sm d-flex align-items-center btn-dark small ms-2" (click)="runTask(PaperlessTaskName.SanityCheck)"> <button class="btn btn-sm d-flex align-items-center btn-dark small ms-2" (click)="runTask(PaperlessTaskName.SanityCheck)">
<i-bs name="play-fill" class="me-1"></i-bs> <i-bs name="play-fill"></i-bs>&nbsp;
<ng-container i18n>Run Task</ng-container> <ng-container i18n>Run Task</ng-container>
</button> </button>
} }
@@ -289,7 +289,7 @@
<div class="spinner-border spinner-border-sm ms-2" role="status"></div> <div class="spinner-border spinner-border-sm ms-2" role="status"></div>
} @else { } @else {
<button class="btn btn-sm d-flex align-items-center btn-dark small ms-2" (click)="runTask(PaperlessTaskName.LLMIndexUpdate)"> <button class="btn btn-sm d-flex align-items-center btn-dark small ms-2" (click)="runTask(PaperlessTaskName.LLMIndexUpdate)">
<i-bs name="play-fill" class="me-1"></i-bs> <i-bs name="play-fill"></i-bs>&nbsp;
<ng-container i18n>Run Task</ng-container> <ng-container i18n>Run Task</ng-container>
</button> </button>
} }
@@ -313,10 +313,10 @@
<div class="modal-footer"> <div class="modal-footer">
<button class="btn btn-sm d-flex align-items-center btn-dark btn btn-sm d-flex align-items-center btn-dark btn-outline-secondary" (click)="copy()"> <button class="btn btn-sm d-flex align-items-center btn-dark btn btn-sm d-flex align-items-center btn-dark btn-outline-secondary" (click)="copy()">
@if (!copied) { @if (!copied) {
<i-bs name="clipboard-fill" class="me-1"></i-bs> <i-bs name="clipboard-fill"></i-bs>&nbsp;
} }
@if (copied) { @if (copied) {
<i-bs name="clipboard-check-fill" class="me-1"></i-bs> <i-bs name="clipboard-check-fill"></i-bs>&nbsp;
} }
<ng-container i18n>Copy</ng-container> <ng-container i18n>Copy</ng-container>
</button> </button>

View File

@@ -35,10 +35,10 @@
<div class="col offset-sm-3"> <div class="col offset-sm-3">
<button class="btn btn-sm btn-outline-secondary" (click)="copyError(toast.error)"> <button class="btn btn-sm btn-outline-secondary" (click)="copyError(toast.error)">
@if (!copied) { @if (!copied) {
<i-bs name="clipboard" class="me-1"></i-bs> <i-bs name="clipboard"></i-bs>&nbsp;
} }
@if (copied) { @if (copied) {
<i-bs name="clipboard-check" class="me-1"></i-bs> <i-bs name="clipboard-check"></i-bs>&nbsp;
} }
<ng-container i18n>Copy Raw Error</ng-container> <ng-container i18n>Copy Raw Error</ng-container>
</button> </button>

View File

@@ -2,7 +2,7 @@
<div content tourAnchor="tour.upload-widget"> <div content tourAnchor="tour.upload-widget">
<form class="justify-content-center d-flex flex-column align-items-center"> <form class="justify-content-center d-flex flex-column align-items-center">
<button type="button" class="btn btn-outline-dark bg-light shadow-sm w-100 h-100 pt-3 pb-3" (click)="fileUpload.click()"> <button type="button" class="btn btn-outline-dark bg-light shadow-sm w-100 h-100 pt-3 pb-3" (click)="fileUpload.click()">
<i-bs class="text-primary me-1" name="plus-circle"></i-bs> <i-bs class="text-primary" name="plus-circle"></i-bs>&nbsp;
<span class="text-primary" i18n>Upload documents</span> <span class="text-primary" i18n>Upload documents</span>
<div class="text-muted smaller fst-italic" i18n>or drop files anywhere</div> <div class="text-muted smaller fst-italic" i18n>or drop files anywhere</div>
</button> </button>

View File

@@ -46,28 +46,29 @@
<div class="ms-auto" ngbDropdown> <div class="ms-auto" ngbDropdown>
<button class="btn btn-sm btn-outline-primary" id="actionsDropdown" ngbDropdownToggle> <button class="btn btn-sm btn-outline-primary" id="actionsDropdown" ngbDropdownToggle>
<i-bs name="three-dots"></i-bs><div class="d-none d-sm-inline ms-1"><ng-container i18n>Actions</ng-container></div> <i-bs name="three-dots"></i-bs>
<div class="d-none d-sm-inline">&nbsp;<ng-container i18n>Actions</ng-container></div>
</button> </button>
<div ngbDropdownMenu aria-labelledby="actionsDropdown" class="shadow"> <div ngbDropdownMenu aria-labelledby="actionsDropdown" class="shadow">
<button ngbDropdownItem (click)="reprocess()" [disabled]="!userCanEdit || !userIsOwner"> <button ngbDropdownItem (click)="reprocess()" [disabled]="!userCanEdit || !userIsOwner">
<i-bs width="1em" height="1em" name="arrow-counterclockwise" class="me-1"></i-bs><span i18n>Reprocess</span> <i-bs width="1em" height="1em" name="arrow-counterclockwise"></i-bs>&nbsp;<span i18n>Reprocess</span>
</button> </button>
<button ngbDropdownItem (click)="printDocument()" [hidden]="useNativePdfViewer || isMobile"> <button ngbDropdownItem (click)="printDocument()" [hidden]="useNativePdfViewer || isMobile">
<i-bs width="1em" height="1em" name="printer" class="me-1"></i-bs><span i18n>Print</span> <i-bs width="1em" height="1em" name="printer"></i-bs>&nbsp;<span i18n>Print</span>
</button> </button>
<button ngbDropdownItem (click)="moreLike()"> <button ngbDropdownItem (click)="moreLike()">
<i-bs width="1em" height="1em" name="diagram-3" class="me-1"></i-bs><span i18n>More like this</span> <i-bs width="1em" height="1em" name="diagram-3"></i-bs>&nbsp;<span i18n>More like this</span>
</button> </button>
<button ngbDropdownItem (click)="editPdf()" [disabled]="!userIsOwner || !userCanEdit || originalContentRenderType !== ContentRenderType.PDF"> <button ngbDropdownItem (click)="editPdf()" [disabled]="!userIsOwner || !userCanEdit || originalContentRenderType !== ContentRenderType.PDF">
<i-bs name="pencil" class="me-1"></i-bs><ng-container i18n>PDF Editor</ng-container> <i-bs name="pencil"></i-bs>&nbsp;<ng-container i18n>PDF Editor</ng-container>
</button> </button>
@if (userIsOwner && (requiresPassword || password)) { @if (userIsOwner && (requiresPassword || password)) {
<button ngbDropdownItem (click)="removePassword()" [disabled]="!password"> <button ngbDropdownItem (click)="removePassword()" [disabled]="!password">
<i-bs name="unlock" class="me-1"></i-bs><ng-container i18n>Remove Password</ng-container> <i-bs name="unlock"></i-bs>&nbsp;<ng-container i18n>Remove Password</ng-container>
</button> </button>
} }
</div> </div>
@@ -75,15 +76,16 @@
<div class="ms-auto" ngbDropdown> <div class="ms-auto" ngbDropdown>
<button class="btn btn-sm btn-outline-primary" id="sendDropdown" ngbDropdownToggle> <button class="btn btn-sm btn-outline-primary" id="sendDropdown" ngbDropdownToggle>
<i-bs name="send"></i-bs><div class="d-none d-sm-inline ms-1"><ng-container i18n>Send</ng-container></div> <i-bs name="send"></i-bs>
<div class="d-none d-sm-inline">&nbsp;<ng-container i18n>Send</ng-container></div>
</button> </button>
<div ngbDropdownMenu aria-labelledby="actionsDropdown" class="shadow"> <div ngbDropdownMenu aria-labelledby="actionsDropdown" class="shadow">
<button ngbDropdownItem (click)="openShareLinks()" *pngxIfPermissions="{ action: PermissionAction.Add, type: PermissionType.ShareLink }"> <button ngbDropdownItem (click)="openShareLinks()" *pngxIfPermissions="{ action: PermissionAction.Add, type: PermissionType.ShareLink }">
<i-bs name="link" class="me-1"></i-bs><span i18n>Share Links</span> <i-bs name="link"></i-bs>&nbsp;<span i18n>Share Links</span>
</button> </button>
@if (emailEnabled) { @if (emailEnabled) {
<button ngbDropdownItem (click)="openEmailDocument()"> <button ngbDropdownItem (click)="openEmailDocument()">
<i-bs name="envelope" class="me-1"></i-bs><span i18n>Email</span> <i-bs name="envelope"></i-bs>&nbsp;<span i18n>Email</span>
</button> </button>
} }
</div> </div>
@@ -95,6 +97,7 @@
<div class="col-md-6 col-xl-5 mb-4"> <div class="col-md-6 col-xl-5 mb-4">
<form [formGroup]='documentForm' (ngSubmit)="save()"> <form [formGroup]='documentForm' (ngSubmit)="save()">
<div class="btn-toolbar mb-1 border-bottom"> <div class="btn-toolbar mb-1 border-bottom">
<div class="btn-group pb-3"> <div class="btn-group pb-3">
<button type="button" class="btn btn-sm btn-outline-secondary" i18n-title title="Close" (click)="close()"> <button type="button" class="btn btn-sm btn-outline-secondary" i18n-title title="Close" (click)="close()">
@@ -454,7 +457,7 @@
@if (!useNativePdfViewer) { @if (!useNativePdfViewer) {
<div class="preview-sticky pdf-viewer-container"> <div class="preview-sticky pdf-viewer-container">
<pngx-pdf-viewer <pngx-pdf-viewer
[src]="pdfSource" [src]="{ url: previewUrl, password: password }"
[renderMode]="PdfRenderMode.All" [renderMode]="PdfRenderMode.All"
[(page)]="previewCurrentPage" [(page)]="previewCurrentPage"
[zoomScale]="previewZoomScale" [zoomScale]="previewZoomScale"

View File

@@ -83,9 +83,9 @@ const doc: Document = {
storage_path: 31, storage_path: 31,
tags: [41, 42, 43], tags: [41, 42, 43],
content: 'text content', content: 'text content',
added: new Date('May 4, 2014 03:24:00').toISOString(), added: new Date('May 4, 2014 03:24:00'),
created: new Date('May 4, 2014 03:24:00').toISOString(), created: new Date('May 4, 2014 03:24:00'),
modified: new Date('May 4, 2014 03:24:00').toISOString(), modified: new Date('May 4, 2014 03:24:00'),
archive_serial_number: null, archive_serial_number: null,
original_file_name: 'file.pdf', original_file_name: 'file.pdf',
owner: null, owner: null,
@@ -392,7 +392,7 @@ describe('DocumentDetailComponent', () => {
jest.spyOn(documentService, 'get').mockReturnValue( jest.spyOn(documentService, 'get').mockReturnValue(
of({ of({
...doc, ...doc,
modified: '2024-01-02T00:00:00Z', modified: new Date('2024-01-02T00:00:00Z'),
duplicate_documents: updatedDuplicates, duplicate_documents: updatedDuplicates,
}) })
) )
@@ -1205,21 +1205,17 @@ describe('DocumentDetailComponent', () => {
expect(errorSpy).toHaveBeenCalled() expect(errorSpy).toHaveBeenCalled()
}) })
it('should show incoming update modal when open local draft is older than backend on init', () => { it('should warn when open document does not match doc retrieved from backend on init', () => {
let openModal: NgbModalRef let openModal: NgbModalRef
modalService.activeInstances.subscribe((modals) => (openModal = modals[0])) modalService.activeInstances.subscribe((modals) => (openModal = modals[0]))
const modalSpy = jest.spyOn(modalService, 'open') const modalSpy = jest.spyOn(modalService, 'open')
const openDoc = Object.assign({}, doc, { const openDoc = Object.assign({}, doc)
__changedFields: ['title'],
})
// simulate a document being modified elsewhere and db updated // simulate a document being modified elsewhere and db updated
const remoteDoc = Object.assign({}, doc, { doc.modified = new Date()
modified: new Date(new Date(doc.modified).getTime() + 1000).toISOString(),
})
jest jest
.spyOn(activatedRoute, 'paramMap', 'get') .spyOn(activatedRoute, 'paramMap', 'get')
.mockReturnValue(of(convertToParamMap({ id: 3, section: 'details' }))) .mockReturnValue(of(convertToParamMap({ id: 3, section: 'details' })))
jest.spyOn(documentService, 'get').mockReturnValueOnce(of(remoteDoc)) jest.spyOn(documentService, 'get').mockReturnValueOnce(of(doc))
jest.spyOn(openDocumentsService, 'getOpenDocument').mockReturnValue(openDoc) jest.spyOn(openDocumentsService, 'getOpenDocument').mockReturnValue(openDoc)
jest.spyOn(customFieldsService, 'listAll').mockReturnValue( jest.spyOn(customFieldsService, 'listAll').mockReturnValue(
of({ of({
@@ -1229,52 +1225,11 @@ describe('DocumentDetailComponent', () => {
}) })
) )
fixture.detectChanges() // calls ngOnInit fixture.detectChanges() // calls ngOnInit
expect(modalSpy).toHaveBeenCalledWith(ConfirmDialogComponent, { expect(modalSpy).toHaveBeenCalledWith(ConfirmDialogComponent)
backdrop: 'static', const closeSpy = jest.spyOn(openModal, 'close')
})
const confirmDialog = openModal.componentInstance as ConfirmDialogComponent const confirmDialog = openModal.componentInstance as ConfirmDialogComponent
expect(confirmDialog.messageBold).toContain('Document was updated at') confirmDialog.confirmClicked.next(confirmDialog)
}) expect(closeSpy).toHaveBeenCalled()
it('should queue incoming update while network is active and flush after', () => {
initNormally()
const loadSpy = jest.spyOn(component as any, 'loadDocument')
const toastSpy = jest.spyOn(toastService, 'showInfo')
component.networkActive = true
;(component as any).handleIncomingDocumentUpdated({
document_id: component.documentId,
modified: '2026-02-17T00:00:00Z',
})
expect(loadSpy).not.toHaveBeenCalled()
component.networkActive = false
;(component as any).flushPendingIncomingUpdate()
expect(loadSpy).toHaveBeenCalledWith(component.documentId, true)
expect(toastSpy).toHaveBeenCalledWith(
'Document reloaded with latest changes.'
)
})
it('should ignore queued incoming update matching local save modified', () => {
initNormally()
const loadSpy = jest.spyOn(component as any, 'loadDocument')
const toastSpy = jest.spyOn(toastService, 'showInfo')
component.networkActive = true
;(component as any).lastLocalSaveModified = '2026-02-17T00:00:00+00:00'
;(component as any).handleIncomingDocumentUpdated({
document_id: component.documentId,
modified: '2026-02-17T00:00:00+00:00',
})
component.networkActive = false
;(component as any).flushPendingIncomingUpdate()
expect(loadSpy).not.toHaveBeenCalled()
expect(toastSpy).not.toHaveBeenCalled()
}) })
it('should change preview element by render type', () => { it('should change preview element by render type', () => {

View File

@@ -13,7 +13,6 @@ import {
NgbDateStruct, NgbDateStruct,
NgbDropdownModule, NgbDropdownModule,
NgbModal, NgbModal,
NgbModalRef,
NgbNav, NgbNav,
NgbNavChangeEvent, NgbNavChangeEvent,
NgbNavModule, NgbNavModule,
@@ -81,7 +80,6 @@ import { TagService } from 'src/app/services/rest/tag.service'
import { UserService } from 'src/app/services/rest/user.service' import { UserService } from 'src/app/services/rest/user.service'
import { SettingsService } from 'src/app/services/settings.service' import { SettingsService } from 'src/app/services/settings.service'
import { ToastService } from 'src/app/services/toast.service' import { ToastService } from 'src/app/services/toast.service'
import { WebsocketStatusService } from 'src/app/services/websocket-status.service'
import { getFilenameFromContentDisposition } from 'src/app/utils/http' import { getFilenameFromContentDisposition } from 'src/app/utils/http'
import { ISODateAdapter } from 'src/app/utils/ngb-iso-date-adapter' import { ISODateAdapter } from 'src/app/utils/ngb-iso-date-adapter'
import * as UTIF from 'utif' import * as UTIF from 'utif'
@@ -112,7 +110,6 @@ import { PDFEditorComponent } from '../common/pdf-editor/pdf-editor.component'
import { PngxPdfViewerComponent } from '../common/pdf-viewer/pdf-viewer.component' import { PngxPdfViewerComponent } from '../common/pdf-viewer/pdf-viewer.component'
import { import {
PdfRenderMode, PdfRenderMode,
PdfSource,
PdfZoomLevel, PdfZoomLevel,
PdfZoomScale, PdfZoomScale,
PngxPdfDocumentProxy, PngxPdfDocumentProxy,
@@ -144,11 +141,6 @@ enum ContentRenderType {
TIFF = 'tiff', TIFF = 'tiff',
} }
interface IncomingDocumentUpdate {
document_id: number
modified?: string
}
@Component({ @Component({
selector: 'pngx-document-detail', selector: 'pngx-document-detail',
templateUrl: './document-detail.component.html', templateUrl: './document-detail.component.html',
@@ -212,7 +204,6 @@ export class DocumentDetailComponent
private componentRouterService = inject(ComponentRouterService) private componentRouterService = inject(ComponentRouterService)
private deviceDetectorService = inject(DeviceDetectorService) private deviceDetectorService = inject(DeviceDetectorService)
private savedViewService = inject(SavedViewService) private savedViewService = inject(SavedViewService)
private websocketStatusService = inject(WebsocketStatusService)
@ViewChild('inputTitle') @ViewChild('inputTitle')
titleInput: TextComponent titleInput: TextComponent
@@ -236,7 +227,6 @@ export class DocumentDetailComponent
title: string title: string
titleSubject: Subject<string> = new Subject() titleSubject: Subject<string> = new Subject()
previewUrl: string previewUrl: string
pdfSource?: PdfSource
thumbUrl: string thumbUrl: string
previewText: string previewText: string
previewLoaded: boolean = false previewLoaded: boolean = false
@@ -269,9 +259,6 @@ export class DocumentDetailComponent
isDirty$: Observable<boolean> isDirty$: Observable<boolean>
unsubscribeNotifier: Subject<any> = new Subject() unsubscribeNotifier: Subject<any> = new Subject()
docChangeNotifier: Subject<any> = new Subject() docChangeNotifier: Subject<any> = new Subject()
private incomingUpdateModal: NgbModalRef
private pendingIncomingUpdate: IncomingDocumentUpdate
private lastLocalSaveModified: string | null = null
requiresPassword: boolean = false requiresPassword: boolean = false
password: string password: string
@@ -358,17 +345,6 @@ export class DocumentDetailComponent
return ContentRenderType.Other return ContentRenderType.Other
} }
private updatePdfSource() {
if (!this.previewUrl) {
this.pdfSource = undefined
return
}
this.pdfSource = {
url: this.previewUrl,
password: this.password || undefined,
}
}
get isRTL() { get isRTL() {
if (!this.metadata || !this.metadata.lang) return false if (!this.metadata || !this.metadata.lang) return false
else { else {
@@ -443,66 +419,8 @@ export class DocumentDetailComponent
) )
} }
private hasLocalEdits(doc: Document): boolean { private loadDocument(documentId: number): void {
return (
this.openDocumentService.isDirty(doc) || !!doc.__changedFields?.length
)
}
private showIncomingUpdateModal(modified?: string): void {
if (this.incomingUpdateModal) return
const modal = this.modalService.open(ConfirmDialogComponent, {
backdrop: 'static',
})
this.incomingUpdateModal = modal
let formattedModified = null
if (modified) {
const parsed = new Date(modified)
if (!isNaN(parsed.getTime())) {
formattedModified = parsed.toLocaleString()
}
}
modal.componentInstance.title = $localize`Document was updated.`
modal.componentInstance.messageBold = formattedModified
? $localize`Document was updated at ${formattedModified}.`
: $localize`This document was updated elsewhere.`
modal.componentInstance.message = $localize`Reload to discard your local unsaved edits and load the latest remote version.`
modal.componentInstance.btnClass = 'btn-warning'
modal.componentInstance.btnCaption = $localize`Reload`
modal.componentInstance.cancelBtnCaption = $localize`Dismiss`
modal.componentInstance.confirmClicked.pipe(first()).subscribe(() => {
modal.componentInstance.buttonsEnabled = false
modal.close()
this.reloadRemoteVersion()
})
modal.result.finally(() => {
this.incomingUpdateModal = null
})
}
private closeIncomingUpdateModal() {
if (!this.incomingUpdateModal) return
this.incomingUpdateModal.close()
this.incomingUpdateModal = null
}
private flushPendingIncomingUpdate() {
if (!this.pendingIncomingUpdate || this.networkActive) return
const pendingUpdate = this.pendingIncomingUpdate
this.pendingIncomingUpdate = null
this.handleIncomingDocumentUpdated(pendingUpdate)
}
private loadDocument(documentId: number, forceRemote: boolean = false): void {
this.closeIncomingUpdateModal()
this.pendingIncomingUpdate = null
this.lastLocalSaveModified = null
this.previewUrl = this.documentsService.getPreviewUrl(documentId) this.previewUrl = this.documentsService.getPreviewUrl(documentId)
this.updatePdfSource()
this.http this.http
.get(this.previewUrl, { responseType: 'text' }) .get(this.previewUrl, { responseType: 'text' })
.pipe( .pipe(
@@ -545,25 +463,21 @@ export class DocumentDetailComponent
openDocument.duplicate_documents = doc.duplicate_documents openDocument.duplicate_documents = doc.duplicate_documents
this.openDocumentService.save() this.openDocumentService.save()
} }
let useDoc = openDocument || doc const useDoc = openDocument || doc
if (openDocument && forceRemote) { if (openDocument) {
Object.assign(openDocument, doc) if (
openDocument.__changedFields = [] new Date(doc.modified) > new Date(openDocument.modified) &&
this.openDocumentService.setDirty(openDocument, false) !this.modalService.hasOpenModals()
this.openDocumentService.save() ) {
useDoc = openDocument const modal = this.modalService.open(ConfirmDialogComponent)
} else if (openDocument) { modal.componentInstance.title = $localize`Document changes detected`
if (new Date(doc.modified) > new Date(openDocument.modified)) { modal.componentInstance.messageBold = $localize`The version of this document in your browser session appears older than the existing version.`
if (this.hasLocalEdits(openDocument)) { modal.componentInstance.message = $localize`Saving the document here may overwrite other changes that were made. To restore the existing version, discard your changes or close the document.`
this.showIncomingUpdateModal(doc.modified) modal.componentInstance.cancelBtnClass = 'visually-hidden'
} else { modal.componentInstance.btnCaption = $localize`Ok`
// No local edits to preserve, so keep the tab in sync automatically. modal.componentInstance.confirmClicked.subscribe(() =>
Object.assign(openDocument, doc) modal.close()
openDocument.__changedFields = [] )
this.openDocumentService.setDirty(openDocument, false)
this.openDocumentService.save()
useDoc = openDocument
}
} }
} else { } else {
this.openDocumentService this.openDocumentService
@@ -594,50 +508,6 @@ export class DocumentDetailComponent
}) })
} }
private handleIncomingDocumentUpdated(data: IncomingDocumentUpdate): void {
if (
!this.documentId ||
!this.document ||
data.document_id !== this.documentId
)
return
if (this.networkActive) {
this.pendingIncomingUpdate = data
return
}
// If modified timestamp of the incoming update is the same as the last local save,
// we assume this update is from our own save and dont notify
const incomingModified = data.modified
if (
incomingModified &&
this.lastLocalSaveModified &&
incomingModified === this.lastLocalSaveModified
) {
this.lastLocalSaveModified = null
return
}
this.lastLocalSaveModified = null
if (this.openDocumentService.isDirty(this.document)) {
this.showIncomingUpdateModal(data.modified)
} else {
this.docChangeNotifier.next(this.documentId)
this.loadDocument(this.documentId, true)
this.toastService.showInfo(
$localize`Document reloaded with latest changes.`
)
}
}
private reloadRemoteVersion() {
if (!this.documentId) return
this.closeIncomingUpdateModal()
this.docChangeNotifier.next(this.documentId)
this.loadDocument(this.documentId, true)
this.toastService.showInfo($localize`Document reloaded.`)
}
ngOnInit(): void { ngOnInit(): void {
this.setZoom( this.setZoom(
this.settings.get(SETTINGS_KEYS.PDF_VIEWER_ZOOM_SETTING) as PdfZoomScale this.settings.get(SETTINGS_KEYS.PDF_VIEWER_ZOOM_SETTING) as PdfZoomScale
@@ -696,11 +566,6 @@ export class DocumentDetailComponent
this.getCustomFields() this.getCustomFields()
this.websocketStatusService
.onDocumentUpdated()
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe((data) => this.handleIncomingDocumentUpdated(data))
this.route.paramMap this.route.paramMap
.pipe( .pipe(
filter( filter(
@@ -1035,7 +900,6 @@ export class DocumentDetailComponent
) )
.subscribe({ .subscribe({
next: (doc) => { next: (doc) => {
this.closeIncomingUpdateModal()
Object.assign(this.document, doc) Object.assign(this.document, doc)
doc['permissions_form'] = { doc['permissions_form'] = {
owner: doc.owner, owner: doc.owner,
@@ -1082,8 +946,6 @@ export class DocumentDetailComponent
.pipe(first()) .pipe(first())
.subscribe({ .subscribe({
next: (docValues) => { next: (docValues) => {
this.closeIncomingUpdateModal()
this.lastLocalSaveModified = docValues.modified ?? null
// in case data changed while saving eg removing inbox_tags // in case data changed while saving eg removing inbox_tags
this.documentForm.patchValue(docValues) this.documentForm.patchValue(docValues)
const newValues = Object.assign({}, this.documentForm.value) const newValues = Object.assign({}, this.documentForm.value)
@@ -1098,19 +960,16 @@ export class DocumentDetailComponent
this.networkActive = false this.networkActive = false
this.error = null this.error = null
if (close) { if (close) {
this.pendingIncomingUpdate = null
this.close(() => this.close(() =>
this.openDocumentService.refreshDocument(this.documentId) this.openDocumentService.refreshDocument(this.documentId)
) )
} else { } else {
this.openDocumentService.refreshDocument(this.documentId) this.openDocumentService.refreshDocument(this.documentId)
this.flushPendingIncomingUpdate()
} }
this.savedViewService.maybeRefreshDocumentCounts() this.savedViewService.maybeRefreshDocumentCounts()
}, },
error: (error) => { error: (error) => {
this.networkActive = false this.networkActive = false
this.lastLocalSaveModified = null
const canEdit = const canEdit =
this.permissionsService.currentUserHasObjectPermissions( this.permissionsService.currentUserHasObjectPermissions(
PermissionAction.Change, PermissionAction.Change,
@@ -1130,7 +989,6 @@ export class DocumentDetailComponent
error error
) )
} }
this.flushPendingIncomingUpdate()
}, },
}) })
} }
@@ -1167,11 +1025,8 @@ export class DocumentDetailComponent
.pipe(first()) .pipe(first())
.subscribe({ .subscribe({
next: ({ updateResult, nextDocId, closeResult }) => { next: ({ updateResult, nextDocId, closeResult }) => {
this.closeIncomingUpdateModal()
this.error = null this.error = null
this.networkActive = false this.networkActive = false
this.pendingIncomingUpdate = null
this.lastLocalSaveModified = null
if (closeResult && updateResult && nextDocId) { if (closeResult && updateResult && nextDocId) {
this.router.navigate(['documents', nextDocId]) this.router.navigate(['documents', nextDocId])
this.titleInput?.focus() this.titleInput?.focus()
@@ -1179,10 +1034,8 @@ export class DocumentDetailComponent
}, },
error: (error) => { error: (error) => {
this.networkActive = false this.networkActive = false
this.lastLocalSaveModified = null
this.error = error.error this.error = error.error
this.toastService.showError($localize`Error saving document`, error) this.toastService.showError($localize`Error saving document`, error)
this.flushPendingIncomingUpdate()
}, },
}) })
} }
@@ -1268,7 +1121,7 @@ export class DocumentDetailComponent
.subscribe({ .subscribe({
next: () => { next: () => {
this.toastService.showInfo( this.toastService.showInfo(
$localize`Reprocess operation for "${this.document.title}" will begin in the background.` $localize`Reprocess operation for "${this.document.title}" will begin in the background. Close and re-open or reload this document after the operation has completed to see new content.`
) )
if (modal) { if (modal) {
modal.close() modal.close()
@@ -1377,7 +1230,6 @@ export class DocumentDetailComponent
onPasswordKeyUp(event: KeyboardEvent) { onPasswordKeyUp(event: KeyboardEvent) {
if ('Enter' == event.key) { if ('Enter' == event.key) {
this.password = (event.target as HTMLInputElement).value this.password = (event.target as HTMLInputElement).value
this.updatePdfSource()
} }
} }

View File

@@ -75,7 +75,7 @@
} }
<div class="btn-group"> <div class="btn-group">
<button type="button" class="btn btn-sm btn-outline-primary me-2" (click)="setPermissions()" [disabled]="!userOwnsAll || !userCanEditAll"> <button type="button" class="btn btn-sm btn-outline-primary me-2" (click)="setPermissions()" [disabled]="!userOwnsAll || !userCanEditAll">
<i-bs name="person-fill-lock"></i-bs><div class="d-none d-sm-inline ms-1"><ng-container i18n>Permissions</ng-container></div> <i-bs name="person-fill-lock"></i-bs><div class="d-none d-sm-inline">&nbsp;<ng-container i18n>Permissions</ng-container></div>
</button> </button>
</div> </div>
</div> </div>
@@ -83,17 +83,18 @@
<div class="btn-toolbar"> <div class="btn-toolbar">
<div ngbDropdown> <div ngbDropdown>
<button class="btn btn-sm btn-outline-primary" id="dropdownSelect" [disabled]="!userCanEdit && !userCanAdd" ngbDropdownToggle> <button class="btn btn-sm btn-outline-primary" id="dropdownSelect" [disabled]="!userCanEdit && !userCanAdd" ngbDropdownToggle>
<i-bs name="three-dots"></i-bs><div class="d-none d-sm-inline ms-1"><ng-container i18n>Actions</ng-container></div> <i-bs name="three-dots"></i-bs>
<div class="d-none d-sm-inline">&nbsp;<ng-container i18n>Actions</ng-container></div>
</button> </button>
<div ngbDropdownMenu aria-labelledby="dropdownSelect" class="shadow"> <div ngbDropdownMenu aria-labelledby="dropdownSelect" class="shadow">
<button ngbDropdownItem (click)="reprocessSelected()" [disabled]="!userCanEditAll && !userCanEditAll"> <button ngbDropdownItem (click)="reprocessSelected()" [disabled]="!userCanEditAll && !userCanEditAll">
<i-bs name="body-text" class="me-1"></i-bs><ng-container i18n>Reprocess</ng-container> <i-bs name="body-text"></i-bs>&nbsp;<ng-container i18n>Reprocess</ng-container>
</button> </button>
<button ngbDropdownItem (click)="rotateSelected()" [disabled]="!userOwnsAll && !userCanEditAll"> <button ngbDropdownItem (click)="rotateSelected()" [disabled]="!userOwnsAll && !userCanEditAll">
<i-bs name="arrow-clockwise" class="me-1"></i-bs><ng-container i18n>Rotate</ng-container> <i-bs name="arrow-clockwise"></i-bs>&nbsp;<ng-container i18n>Rotate</ng-container>
</button> </button>
<button ngbDropdownItem (click)="mergeSelected()" [disabled]="!userCanAdd || list.selected.size < 2"> <button ngbDropdownItem (click)="mergeSelected()" [disabled]="!userCanAdd || list.selected.size < 2">
<i-bs name="journals" class="me-1"></i-bs><ng-container i18n>Merge</ng-container> <i-bs name="journals"></i-bs>&nbsp;<ng-container i18n>Merge</ng-container>
</button> </button>
</div> </div>
</div> </div>
@@ -105,20 +106,22 @@
ngbDropdownToggle ngbDropdownToggle
[disabled]="disabled || list.selected.size === 0" [disabled]="disabled || list.selected.size === 0"
> >
<i-bs name="send"></i-bs><div class="d-none d-sm-inline ms-1"><ng-container i18n>Send</ng-container> <i-bs name="send"></i-bs>
<div class="d-none d-sm-inline">
&nbsp;<ng-container i18n>Send</ng-container>
</div> </div>
</button> </button>
<div ngbDropdownMenu aria-labelledby="dropdownSend" class="shadow"> <div ngbDropdownMenu aria-labelledby="dropdownSend" class="shadow">
<button ngbDropdownItem (click)="createShareLinkBundle()"> <button ngbDropdownItem (click)="createShareLinkBundle()">
<i-bs name="link" class="me-1"></i-bs><ng-container i18n>Create a share link bundle</ng-container> <i-bs name="link"></i-bs>&nbsp;<ng-container i18n>Create a share link bundle</ng-container>
</button> </button>
<button ngbDropdownItem (click)="manageShareLinkBundles()"> <button ngbDropdownItem (click)="manageShareLinkBundles()">
<i-bs name="list-ul" class="me-1"></i-bs><ng-container i18n>Manage share link bundles</ng-container> <i-bs name="list-ul"></i-bs>&nbsp;<ng-container i18n>Manage share link bundles</ng-container>
</button> </button>
<div class="dropdown-divider"></div> <div class="dropdown-divider"></div>
@if (emailEnabled) { @if (emailEnabled) {
<button ngbDropdownItem (click)="emailSelected()"> <button ngbDropdownItem (click)="emailSelected()">
<i-bs name="envelope" class="me-1"></i-bs><ng-container i18n>Email</ng-container> <i-bs name="envelope"></i-bs>&nbsp;<ng-container i18n>Email</ng-container>
</button> </button>
} }
</div> </div>
@@ -133,7 +136,7 @@
<span class="visually-hidden">Preparing download...</span> <span class="visually-hidden">Preparing download...</span>
</div> </div>
} }
<div class="d-none d-sm-inline ms-1"><ng-container i18n>Download</ng-container></div> <div class="d-none d-sm-inline">&nbsp;<ng-container i18n>Download</ng-container></div>
</button> </button>
<div ngbDropdown class="me-2 d-flex btn-group" role="group"> <div ngbDropdown class="me-2 d-flex btn-group" role="group">
<button type="button" class="btn btn-sm btn-outline-primary dropdown-toggle-split rounded-end" ngbDropdownToggle></button> <button type="button" class="btn btn-sm btn-outline-primary dropdown-toggle-split rounded-end" ngbDropdownToggle></button>
@@ -161,7 +164,7 @@
<div class="btn-group btn-group-sm"> <div class="btn-group btn-group-sm">
<button type="button" class="btn btn-sm btn-outline-danger" (click)="applyDelete()" *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.Document }" [disabled]="!userOwnsAll"> <button type="button" class="btn btn-sm btn-outline-danger" (click)="applyDelete()" *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.Document }" [disabled]="!userOwnsAll">
<i-bs name="trash" class="me-1"></i-bs><ng-container i18n>Delete</ng-container> <i-bs name="trash"></i-bs>&nbsp;<ng-container i18n>Delete</ng-container>
</button> </button>
</div> </div>
</div> </div>

View File

@@ -66,16 +66,16 @@
<div class="btn-group"> <div class="btn-group">
@if (document) { @if (document) {
<a class="btn btn-sm btn-outline-secondary" (click)="clickMoreLike.emit()"> <a class="btn btn-sm btn-outline-secondary" (click)="clickMoreLike.emit()">
<i-bs name="diagram-3" class="me-1"></i-bs><span class="d-none d-md-inline" i18n>More like this</span> <i-bs name="diagram-3"></i-bs>&nbsp;<span class="d-none d-md-inline" i18n>More like this</span>
</a> </a>
<a routerLink="/documents/{{document.id}}" class="btn btn-sm btn-outline-secondary" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.Document }"> <a routerLink="/documents/{{document.id}}" class="btn btn-sm btn-outline-secondary" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.Document }">
<i-bs name="file-earmark-richtext" class="me-1"></i-bs><span class="d-none d-md-inline" i18n>Open</span> <i-bs name="file-earmark-richtext"></i-bs>&nbsp;<span class="d-none d-md-inline" i18n>Open</span>
</a> </a>
<pngx-preview-popup [document]="document" #popupPreview> <pngx-preview-popup [document]="document" #popupPreview>
<i-bs name="eye" class="me-1"></i-bs><span class="d-none d-md-inline" i18n>View</span> <i-bs name="eye"></i-bs>&nbsp;<span class="d-none d-md-inline" i18n>View</span>
</pngx-preview-popup> </pngx-preview-popup>
<a class="btn btn-sm btn-outline-secondary" [href]="getDownloadUrl()"> <a class="btn btn-sm btn-outline-secondary" [href]="getDownloadUrl()">
<i-bs name="download" class="me-1"></i-bs><span class="d-none d-md-inline" i18n>Download</span> <i-bs name="download"></i-bs>&nbsp;<span class="d-none d-md-inline" i18n>Download</span>
</a> </a>
} @else { } @else {
<div class="placeholder btn btn-sm btn-outline-secondary bg-secondary" style="width: 60px;">&nbsp;</div> <div class="placeholder btn btn-sm btn-outline-secondary bg-secondary" style="width: 60px;">&nbsp;</div>

View File

@@ -1,7 +1,8 @@
<pngx-page-header [title]="getTitle()"> <pngx-page-header [title]="getTitle()">
<div ngbDropdown class="btn-group flex-fill d-sm-none"> <div ngbDropdown class="btn-group flex-fill d-sm-none">
<button class="btn btn-sm btn-outline-primary" id="dropdownSelectMobile" ngbDropdownToggle> <button class="btn btn-sm btn-outline-primary" id="dropdownSelectMobile" ngbDropdownToggle>
<i-bs name="text-indent-left"></i-bs><div class="d-none d-sm-inline ms-1"><ng-container i18n>Select</ng-container></div> <i-bs name="text-indent-left"></i-bs>
<div class="d-none d-sm-inline">&nbsp;<ng-container i18n>Select</ng-container></div>
@if (list.selected.size > 0) { @if (list.selected.size > 0) {
<pngx-clearable-badge [selected]="list.selected.size > 0" [number]="list.selected.size" (cleared)="list.selectNone()"></pngx-clearable-badge><span class="visually-hidden">selected</span> <pngx-clearable-badge [selected]="list.selected.size > 0" [number]="list.selected.size" (cleared)="list.selectNone()"></pngx-clearable-badge><span class="visually-hidden">selected</span>
} }
@@ -19,20 +20,21 @@
<div class="btn-group btn-group-sm flex-nowrap"> <div class="btn-group btn-group-sm flex-nowrap">
@if (list.selected.size > 0) { @if (list.selected.size > 0) {
<button class="btn btn-sm btn-outline-secondary" (click)="list.selectNone()"> <button class="btn btn-sm btn-outline-secondary" (click)="list.selectNone()">
<i-bs name="slash-circle" class="me-1"></i-bs><ng-container i18n>None</ng-container> <i-bs name="slash-circle"></i-bs>&nbsp;<ng-container i18n>None</ng-container>
</button> </button>
} }
<button class="btn btn-sm btn-outline-primary" (click)="list.selectPage()"> <button class="btn btn-sm btn-outline-primary" (click)="list.selectPage()">
<i-bs name="file-earmark-check" class="me-1"></i-bs><ng-container i18n>Page</ng-container> <i-bs name="file-earmark-check"></i-bs>&nbsp;<ng-container i18n>Page</ng-container>
</button> </button>
<button class="btn btn-sm btn-outline-primary" (click)="list.selectAll()"> <button class="btn btn-sm btn-outline-primary" (click)="list.selectAll()">
<i-bs name="check-all" class="me-1"></i-bs><ng-container i18n>All</ng-container> <i-bs name="check-all"></i-bs>&nbsp;<ng-container i18n>All</ng-container>
</button> </button>
</div> </div>
</div> </div>
<div ngbDropdown class="btn-group flex-fill"> <div ngbDropdown class="btn-group flex-fill">
<button class="btn btn-sm btn-outline-primary" id="dropdownDisplayFields" ngbDropdownToggle> <button class="btn btn-sm btn-outline-primary" id="dropdownDisplayFields" ngbDropdownToggle>
<i-bs name="card-heading"></i-bs><div class="d-none d-sm-inline ms-1"><ng-container i18n>Show</ng-container></div> <i-bs name="card-heading"></i-bs>
<div class="d-none d-sm-inline">&nbsp;<ng-container i18n>Show</ng-container></div>
</button> </button>
<div ngbDropdownMenu aria-labelledby="dropdownDisplayFields" class="shadow"> <div ngbDropdownMenu aria-labelledby="dropdownDisplayFields" class="shadow">
<div class="px-3"> <div class="px-3">
@@ -62,7 +64,8 @@
<div ngbDropdown class="btn-group flex-fill"> <div ngbDropdown class="btn-group flex-fill">
<button class="btn btn-outline-primary btn-sm" id="dropdownBasic1" ngbDropdownToggle> <button class="btn btn-outline-primary btn-sm" id="dropdownBasic1" ngbDropdownToggle>
<i-bs name="arrow-down-up"></i-bs><div class="d-none d-sm-inline ms-1"><ng-container i18n>Sort</ng-container></div> <i-bs name="arrow-down-up"></i-bs>
<div class="d-none d-sm-inline">&nbsp;<ng-container i18n>Sort</ng-container></div>
</button> </button>
<div ngbDropdownMenu aria-labelledby="dropdownBasic1" class="shadow dropdown-menu-right"> <div ngbDropdownMenu aria-labelledby="dropdownBasic1" class="shadow dropdown-menu-right">
<div class="w-100 d-flex pb-2 mb-1 border-bottom"> <div class="w-100 d-flex pb-2 mb-1 border-bottom">
@@ -87,7 +90,8 @@
<div class="btn-group flex-fill" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.SavedView }" ngbDropdown role="group"> <div class="btn-group flex-fill" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.SavedView }" ngbDropdown role="group">
<button class="btn btn-sm btn-outline-primary dropdown-toggle flex-fill" tourAnchor="tour.documents-views" ngbDropdownToggle> <button class="btn btn-sm btn-outline-primary dropdown-toggle flex-fill" tourAnchor="tour.documents-views" ngbDropdownToggle>
<i-bs name="window-stack"></i-bs><div class="d-none d-sm-inline ms-1"><ng-container i18n>Views</ng-container></div> <i-bs class="me-1" name="window-stack"></i-bs>
<div class="d-none d-sm-inline">&nbsp;<ng-container i18n>Views</ng-container></div>
@if (savedViewIsModified) { @if (savedViewIsModified) {
<div class="position-absolute top-0 start-100 p-2 translate-middle badge bg-secondary border border-light rounded-circle"> <div class="position-absolute top-0 start-100 p-2 translate-middle badge bg-secondary border border-light rounded-circle">
<span class="visually-hidden">selected</span> <span class="visually-hidden">selected</span>

View File

@@ -9,7 +9,7 @@ import { of } from 'rxjs'
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive' import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
import { SortableDirective } from 'src/app/directives/sortable.directive' import { SortableDirective } from 'src/app/directives/sortable.directive'
import { CorrespondentService } from 'src/app/services/rest/correspondent.service' import { CorrespondentService } from 'src/app/services/rest/correspondent.service'
import { PageHeaderComponent } from '../../../../common/page-header/page-header.component' import { PageHeaderComponent } from '../../common/page-header/page-header.component'
import { CorrespondentListComponent } from './correspondent-list.component' import { CorrespondentListComponent } from './correspondent-list.component'
describe('CorrespondentListComponent', () => { describe('CorrespondentListComponent', () => {

View File

@@ -1,4 +1,4 @@
import { NgClass, NgTemplateOutlet } from '@angular/common' import { NgClass, NgTemplateOutlet, TitleCasePipe } from '@angular/common'
import { Component, inject } from '@angular/core' import { Component, inject } from '@angular/core'
import { FormsModule, ReactiveFormsModule } from '@angular/forms' import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { RouterModule } from '@angular/router' import { RouterModule } from '@angular/router'
@@ -7,7 +7,6 @@ import {
NgbPaginationModule, NgbPaginationModule,
} from '@ng-bootstrap/ng-bootstrap' } from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons' import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { CorrespondentEditDialogComponent } from 'src/app/components/common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component'
import { Correspondent } from 'src/app/data/correspondent' import { Correspondent } from 'src/app/data/correspondent'
import { FILTER_HAS_CORRESPONDENT_ANY } from 'src/app/data/filter-rule-type' import { FILTER_HAS_CORRESPONDENT_ANY } from 'src/app/data/filter-rule-type'
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive' import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
@@ -15,16 +14,21 @@ import { SortableDirective } from 'src/app/directives/sortable.directive'
import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe' import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
import { PermissionType } from 'src/app/services/permissions.service' import { PermissionType } from 'src/app/services/permissions.service'
import { CorrespondentService } from 'src/app/services/rest/correspondent.service' import { CorrespondentService } from 'src/app/services/rest/correspondent.service'
import { ManagementListComponent } from '../management-list.component' import { ClearableBadgeComponent } from '../../common/clearable-badge/clearable-badge.component'
import { CorrespondentEditDialogComponent } from '../../common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component'
import { PageHeaderComponent } from '../../common/page-header/page-header.component'
import { ManagementListComponent } from '../management-list/management-list.component'
@Component({ @Component({
selector: 'pngx-correspondent-list', selector: 'pngx-correspondent-list',
templateUrl: './../management-list.component.html', templateUrl: './../management-list/management-list.component.html',
styleUrls: ['./../management-list.component.scss'], styleUrls: ['./../management-list/management-list.component.scss'],
providers: [{ provide: CustomDatePipe }], providers: [{ provide: CustomDatePipe }],
imports: [ imports: [
SortableDirective, SortableDirective,
IfPermissionsDirective, IfPermissionsDirective,
PageHeaderComponent,
TitleCasePipe,
FormsModule, FormsModule,
ReactiveFormsModule, ReactiveFormsModule,
RouterModule, RouterModule,
@@ -33,10 +37,11 @@ import { ManagementListComponent } from '../management-list.component'
NgbDropdownModule, NgbDropdownModule,
NgbPaginationModule, NgbPaginationModule,
NgxBootstrapIconsModule, NgxBootstrapIconsModule,
ClearableBadgeComponent,
], ],
}) })
export class CorrespondentListComponent extends ManagementListComponent<Correspondent> { export class CorrespondentListComponent extends ManagementListComponent<Correspondent> {
private readonly datePipe = inject(CustomDatePipe) private datePipe = inject(CustomDatePipe)
constructor() { constructor() {
super() super()

View File

@@ -1,3 +1,15 @@
<pngx-page-header
title="Custom Fields"
i18n-title
info="Customize the data fields that can be attached to documents."
i18n-info
infoLink="usage/#custom-fields"
>
<button type="button" class="btn btn-sm btn-outline-primary" (click)="editField()" *pngxIfPermissions="{ action: PermissionAction.Add, type: PermissionType.CustomField }">
<i-bs name="plus-circle"></i-bs>&nbsp;<ng-container i18n>Add Field</ng-container>
</button>
</pngx-page-header>
<ul class="list-group"> <ul class="list-group">
<li class="list-group-item"> <li class="list-group-item">
@@ -43,10 +55,10 @@
</div> </div>
<div class="btn-group d-none d-sm-inline-block"> <div class="btn-group d-none d-sm-inline-block">
<button *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.CustomField }" class="btn btn-sm btn-outline-secondary" type="button" (click)="editField(field)"> <button *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.CustomField }" class="btn btn-sm btn-outline-secondary" type="button" (click)="editField(field)">
<i-bs width="1em" height="1em" name="pencil" class="me-1"></i-bs><ng-container i18n>Edit</ng-container> <i-bs width="1em" height="1em" name="pencil"></i-bs>&nbsp;<ng-container i18n>Edit</ng-container>
</button> </button>
<button *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.CustomField }" class="btn btn-sm btn-outline-danger" type="button" (click)="deleteField(field)"> <button *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.CustomField }" class="btn btn-sm btn-outline-danger" type="button" (click)="deleteField(field)">
<i-bs width="1em" height="1em" name="trash" class="me-1"></i-bs><ng-container i18n>Delete</ng-container> <i-bs width="1em" height="1em" name="trash"></i-bs>&nbsp;<ng-container i18n>Delete</ng-container>
</button> </button>
</div> </div>
@if (field.document_count > 0) { @if (field.document_count > 0) {
@@ -55,7 +67,7 @@
class="btn btn-sm btn-outline-secondary" class="btn btn-sm btn-outline-secondary"
[routerLink]="getDocumentFilterUrl(field)" [routerLink]="getDocumentFilterUrl(field)"
> >
<i-bs width="1em" height="1em" name="filter" class="me-1"></i-bs><ng-container i18n>Documents</ng-container <i-bs width="1em" height="1em" name="filter"></i-bs>&nbsp;<ng-container i18n>Documents</ng-container
><span class="badge bg-light text-secondary ms-2">{{ field.document_count }}</span> ><span class="badge bg-light text-secondary ms-2">{{ field.document_count }}</span>
</a> </a>
</div> </div>

View File

@@ -26,9 +26,9 @@ import { PermissionsService } from 'src/app/services/permissions.service'
import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service' import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
import { SettingsService } from 'src/app/services/settings.service' import { SettingsService } from 'src/app/services/settings.service'
import { ToastService } from 'src/app/services/toast.service' import { ToastService } from 'src/app/services/toast.service'
import { ConfirmDialogComponent } from '../../../common/confirm-dialog/confirm-dialog.component' import { ConfirmDialogComponent } from '../../common/confirm-dialog/confirm-dialog.component'
import { CustomFieldEditDialogComponent } from '../../../common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component' import { CustomFieldEditDialogComponent } from '../../common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component'
import { PageHeaderComponent } from '../../../common/page-header/page-header.component' import { PageHeaderComponent } from '../../common/page-header/page-header.component'
import { CustomFieldsComponent } from './custom-fields.component' import { CustomFieldsComponent } from './custom-fields.component'
const fields: CustomField[] = [ const fields: CustomField[] = [
@@ -110,7 +110,10 @@ describe('CustomFieldsComponent', () => {
const toastInfoSpy = jest.spyOn(toastService, 'showInfo') const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
const reloadSpy = jest.spyOn(component, 'reload') const reloadSpy = jest.spyOn(component, 'reload')
component.editField() const createButton = fixture.debugElement
.queryAll(By.css('button'))
.find((btn) => btn.nativeElement.textContent.trim().includes('Add Field'))
createButton.triggerEventHandler('click')
expect(modal).not.toBeUndefined() expect(modal).not.toBeUndefined()
const editDialog = modal.componentInstance as CustomFieldEditDialogComponent const editDialog = modal.componentInstance as CustomFieldEditDialogComponent

View File

@@ -7,10 +7,6 @@ import {
} from '@ng-bootstrap/ng-bootstrap' } from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons' import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { delay, takeUntil, tap } from 'rxjs' import { delay, takeUntil, tap } from 'rxjs'
import { ConfirmDialogComponent } from 'src/app/components/common/confirm-dialog/confirm-dialog.component'
import { CustomFieldEditDialogComponent } from 'src/app/components/common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component'
import { EditDialogMode } from 'src/app/components/common/edit-dialog/edit-dialog.component'
import { LoadingComponentWithPermissions } from 'src/app/components/loading-component/loading.component'
import { CustomField, DATA_TYPE_LABELS } from 'src/app/data/custom-field' import { CustomField, DATA_TYPE_LABELS } from 'src/app/data/custom-field'
import { import {
CustomFieldQueryLogicalOperator, CustomFieldQueryLogicalOperator,
@@ -25,12 +21,18 @@ import { DocumentService } from 'src/app/services/rest/document.service'
import { SavedViewService } from 'src/app/services/rest/saved-view.service' import { SavedViewService } from 'src/app/services/rest/saved-view.service'
import { SettingsService } from 'src/app/services/settings.service' import { SettingsService } from 'src/app/services/settings.service'
import { ToastService } from 'src/app/services/toast.service' import { ToastService } from 'src/app/services/toast.service'
import { ConfirmDialogComponent } from '../../common/confirm-dialog/confirm-dialog.component'
import { CustomFieldEditDialogComponent } from '../../common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component'
import { EditDialogMode } from '../../common/edit-dialog/edit-dialog.component'
import { PageHeaderComponent } from '../../common/page-header/page-header.component'
import { LoadingComponentWithPermissions } from '../../loading-component/loading.component'
@Component({ @Component({
selector: 'pngx-custom-fields', selector: 'pngx-custom-fields',
templateUrl: './custom-fields.component.html', templateUrl: './custom-fields.component.html',
styleUrls: ['./custom-fields.component.scss'], styleUrls: ['./custom-fields.component.scss'],
imports: [ imports: [
PageHeaderComponent,
IfPermissionsDirective, IfPermissionsDirective,
NgbDropdownModule, NgbDropdownModule,
NgbPaginationModule, NgbPaginationModule,
@@ -42,14 +44,14 @@ export class CustomFieldsComponent
extends LoadingComponentWithPermissions extends LoadingComponentWithPermissions
implements OnInit implements OnInit
{ {
private readonly customFieldsService = inject(CustomFieldsService) private customFieldsService = inject(CustomFieldsService)
public readonly permissionsService = inject(PermissionsService) permissionsService = inject(PermissionsService)
private readonly modalService = inject(NgbModal) private modalService = inject(NgbModal)
private readonly toastService = inject(ToastService) private toastService = inject(ToastService)
private readonly documentListViewService = inject(DocumentListViewService) private documentListViewService = inject(DocumentListViewService)
private readonly settingsService = inject(SettingsService) private settingsService = inject(SettingsService)
private readonly documentService = inject(DocumentService) private documentService = inject(DocumentService)
private readonly savedViewService = inject(SavedViewService) private savedViewService = inject(SavedViewService)
public fields: CustomField[] = [] public fields: CustomField[] = []

View File

@@ -1,77 +0,0 @@
<pngx-page-header
[title]="activeTabLabel"
info="Manage tags, correspondents, document types, storage paths, and custom fields."
i18n-info
[infoLink]="activeInfoLink"
[loading]="activeHeaderLoading"
>
@if (activeManagementList) {
<div ngbDropdown class="btn-group flex-fill d-sm-none">
<button class="btn btn-sm btn-outline-primary" id="dropdownSelectMobile" ngbDropdownToggle>
<i-bs name="text-indent-left"></i-bs><div class="d-none d-sm-inline ms-1"><ng-container i18n>Select</ng-container></div>
@if (activeManagementList.selectedObjects.size > 0) {
<pngx-clearable-badge [selected]="activeManagementList.selectedObjects.size > 0" [number]="activeManagementList.selectedObjects.size" (cleared)="activeManagementList.selectNone()"></pngx-clearable-badge><span class="visually-hidden">selected</span>
}
</button>
<div ngbDropdownMenu aria-labelledby="dropdownSelectMobile" class="shadow">
<button ngbDropdownItem (click)="activeManagementList.selectNone()" i18n>Select none</button>
<button ngbDropdownItem (click)="activeManagementList.selectPage(true)" i18n>Select page</button>
<button ngbDropdownItem (click)="activeManagementList.selectAll()" i18n>Select all</button>
</div>
</div>
<div class="d-none d-sm-flex flex-fill me-3">
<div class="input-group input-group-sm">
<span class="input-group-text border-0" i18n>Select:</span>
</div>
<div class="btn-group btn-group-sm flex-nowrap">
@if (activeManagementList.selectedObjects.size > 0) {
<button class="btn btn-sm btn-outline-secondary" (click)="activeManagementList.selectNone()">
<i-bs name="slash-circle" class="me-1"></i-bs><ng-container i18n>None</ng-container>
</button>
}
<button class="btn btn-sm btn-outline-primary" (click)="activeManagementList.selectPage(true)">
<i-bs name="file-earmark-check" class="me-1"></i-bs><ng-container i18n>Page</ng-container>
</button>
<button class="btn btn-sm btn-outline-primary" (click)="activeManagementList.selectAll()">
<i-bs name="check-all" class="me-1"></i-bs><ng-container i18n>All</ng-container>
</button>
</div>
</div>
<button type="button" class="btn btn-sm btn-outline-primary" (click)="activeManagementList.setPermissions()"
[disabled]="!activeManagementList.userCanBulkEdit(PermissionAction.Change) || activeManagementList.selectedObjects.size === 0">
<i-bs name="person-fill-lock" class="me-1"></i-bs><ng-container i18n>Permissions</ng-container>
</button>
<button type="button" class="btn btn-sm btn-outline-danger" (click)="activeManagementList.delete()"
[disabled]="!activeManagementList.userCanBulkEdit(PermissionAction.Delete) || activeManagementList.selectedObjects.size === 0">
<i-bs name="trash" class="me-1"></i-bs><ng-container i18n>Delete</ng-container>
</button>
<button type="button" class="btn btn-sm btn-outline-primary ms-md-5" (click)="activeManagementList.openCreateDialog()"
*pngxIfPermissions="{ action: PermissionAction.Add, type: activeManagementList.permissionType }">
<i-bs name="plus-circle" class="me-1"></i-bs><ng-container i18n>Create</ng-container>
</button>
} @else if (activeCustomFields) {
<button type="button" class="btn btn-sm btn-outline-primary" (click)="activeCustomFields.editField()"
*pngxIfPermissions="{ action: PermissionAction.Add, type: PermissionType.CustomField }">
<i-bs name="plus-circle" class="me-1"></i-bs><ng-container i18n>Add Field</ng-container>
</button>
}
</pngx-page-header>
<ul ngbNav #nav="ngbNav" (navChange)="onNavChange($event)" [(activeId)]="activeNavID" class="nav-underline">
@for (section of visibleSections; track section.id) {
<li [ngbNavItem]="section.id">
<a ngbNavLink >
<i-bs class="me-2" [name]="section.icon"></i-bs>{{ section.label }}
</a>
</li>
}
</ul>
<div class="my-3 shadow-sm">
<ng-container
[ngComponentOutlet]="activeSection?.component"
#activeOutlet="ngComponentOutlet"
></ng-container>
</div>

View File

@@ -1,189 +0,0 @@
import { Component } from '@angular/core'
import { ComponentFixture, TestBed } from '@angular/core/testing'
import {
ActivatedRoute,
convertToParamMap,
ParamMap,
Router,
} from '@angular/router'
import { RouterTestingModule } from '@angular/router/testing'
import { allIcons, NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { Subject } from 'rxjs'
import {
PermissionAction,
PermissionsService,
PermissionType,
} from 'src/app/services/permissions.service'
import {
DocumentAttributesComponent,
DocumentAttributesSectionKind,
} from './document-attributes.component'
@Component({
selector: 'pngx-dummy-section',
template: '',
standalone: true,
})
class DummySectionComponent {}
describe('DocumentAttributesComponent', () => {
let component: DocumentAttributesComponent
let fixture: ComponentFixture<DocumentAttributesComponent>
let router: Router
let paramMapSubject: Subject<ParamMap>
let permissionsService: PermissionsService
beforeEach(async () => {
paramMapSubject = new Subject<ParamMap>()
TestBed.configureTestingModule({
imports: [
RouterTestingModule,
NgxBootstrapIconsModule.pick(allIcons),
DocumentAttributesComponent,
DummySectionComponent,
],
providers: [
{
provide: ActivatedRoute,
useValue: {
paramMap: paramMapSubject.asObservable(),
},
},
{
provide: PermissionsService,
useValue: {
currentUserCan: jest.fn(),
},
},
],
}).compileComponents()
fixture = TestBed.createComponent(DocumentAttributesComponent)
component = fixture.componentInstance
router = TestBed.inject(Router)
permissionsService = TestBed.inject(PermissionsService)
jest.spyOn(router, 'navigate').mockResolvedValue(true)
;(component as any).sections = [
{
id: 1,
path: 'tags',
label: 'Tags',
icon: 'tags',
permissionType: PermissionType.Tag,
kind: DocumentAttributesSectionKind.ManagementList,
component: DummySectionComponent,
},
{
id: 2,
path: 'customfields',
label: 'Custom fields',
icon: 'ui-radios',
permissionType: PermissionType.CustomField,
kind: DocumentAttributesSectionKind.CustomFields,
component: DummySectionComponent,
},
]
})
it('should navigate to default section when no section is provided', () => {
jest
.spyOn(permissionsService, 'currentUserCan')
.mockImplementation((action, type) => {
return action === PermissionAction.View && type === PermissionType.Tag
})
fixture.detectChanges()
paramMapSubject.next(convertToParamMap({}))
expect(router.navigate).toHaveBeenCalledWith(['attributes', 'tags'], {
replaceUrl: true,
})
expect(component.activeNavID).toBe(1)
})
it('should set active section from route param when valid', () => {
jest
.spyOn(permissionsService, 'currentUserCan')
.mockImplementation((action, type) => {
return (
action === PermissionAction.View &&
type === PermissionType.CustomField
)
})
fixture.detectChanges()
paramMapSubject.next(convertToParamMap({ section: 'customfields' }))
expect(component.activeNavID).toBe(2)
expect(router.navigate).not.toHaveBeenCalled()
})
it('should update active nav id when route section changes', () => {
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
fixture.detectChanges()
component.activeNavID = 1
paramMapSubject.next(convertToParamMap({ section: 'customfields' }))
expect(component.activeNavID).toBe(2)
})
it('should redirect to dashboard when no sections are visible', () => {
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(false)
fixture.detectChanges()
paramMapSubject.next(convertToParamMap({}))
expect(router.navigate).toHaveBeenCalledWith(['/dashboard'], {
replaceUrl: true,
})
})
it('should navigate when a nav change occurs', () => {
jest
.spyOn(permissionsService, 'currentUserCan')
.mockImplementation(() => true)
fixture.detectChanges()
paramMapSubject.next(convertToParamMap({ section: 'tags' }))
component.onNavChange({ nextId: 2 } as any)
expect(router.navigate).toHaveBeenCalledWith(['attributes', 'customfields'])
})
it('should ignore nav changes for unknown sections', () => {
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
fixture.detectChanges()
paramMapSubject.next(convertToParamMap({ section: 'tags' }))
component.onNavChange({ nextId: 999 } as any)
expect(router.navigate).not.toHaveBeenCalled()
})
it('should return activeManagementList correctly', () => {
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
expect(component.activeManagementList).toBeNull()
component.activeNavID = 1
expect(component.activeSection.kind).toBe(
DocumentAttributesSectionKind.ManagementList
)
expect(component.activeManagementList).toBeDefined()
})
it('should return activeCustomFields correctly', () => {
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
expect(component.activeCustomFields).toBeNull()
component.activeNavID = 2
expect(component.activeSection.kind).toBe(
DocumentAttributesSectionKind.CustomFields
)
expect(component.activeCustomFields).toBeDefined()
})
})

View File

@@ -1,256 +0,0 @@
import { NgComponentOutlet } from '@angular/common'
import {
AfterViewChecked,
ChangeDetectorRef,
Component,
inject,
OnDestroy,
OnInit,
Type,
ViewChild,
} from '@angular/core'
import { ActivatedRoute, Router } from '@angular/router'
import {
NgbDropdownModule,
NgbNavChangeEvent,
NgbNavModule,
} from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { Subject, takeUntil } from 'rxjs'
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
import {
PermissionAction,
PermissionsService,
PermissionType,
} from 'src/app/services/permissions.service'
import { ClearableBadgeComponent } from '../../common/clearable-badge/clearable-badge.component'
import { PageHeaderComponent } from '../../common/page-header/page-header.component'
import { CustomFieldsComponent } from './custom-fields/custom-fields.component'
import { CorrespondentListComponent } from './management-list/correspondent-list/correspondent-list.component'
import { DocumentTypeListComponent } from './management-list/document-type-list/document-type-list.component'
import { ManagementListComponent } from './management-list/management-list.component'
import { StoragePathListComponent } from './management-list/storage-path-list/storage-path-list.component'
import { TagListComponent } from './management-list/tag-list/tag-list.component'
enum DocumentAttributesNavIDs {
Tags = 1,
Correspondents = 2,
DocumentTypes = 3,
StoragePaths = 4,
CustomFields = 5,
}
export enum DocumentAttributesSectionKind {
ManagementList = 'managementList',
CustomFields = 'customFields',
}
interface DocumentAttributesSection {
id: DocumentAttributesNavIDs
path: string
label: string
icon: string
infoLink?: string
permissionType: PermissionType
kind: DocumentAttributesSectionKind
component: Type<any>
}
@Component({
selector: 'pngx-document-attributes',
templateUrl: './document-attributes.component.html',
styleUrls: ['./document-attributes.component.scss'],
imports: [
PageHeaderComponent,
NgbNavModule,
NgbDropdownModule,
NgComponentOutlet,
NgxBootstrapIconsModule,
IfPermissionsDirective,
ClearableBadgeComponent,
],
})
export class DocumentAttributesComponent
implements OnInit, OnDestroy, AfterViewChecked
{
private readonly permissionsService = inject(PermissionsService)
private readonly activatedRoute = inject(ActivatedRoute)
private readonly router = inject(Router)
private readonly cdr = inject(ChangeDetectorRef)
private readonly unsubscribeNotifier = new Subject<void>()
protected readonly PermissionAction = PermissionAction
protected readonly PermissionType = PermissionType
readonly sections: DocumentAttributesSection[] = [
{
id: DocumentAttributesNavIDs.Tags,
path: 'tags',
label: $localize`Tags`,
icon: 'tags',
infoLink: 'usage/#terms-and-definitions',
permissionType: PermissionType.Tag,
kind: DocumentAttributesSectionKind.ManagementList,
component: TagListComponent,
},
{
id: DocumentAttributesNavIDs.Correspondents,
path: 'correspondents',
label: $localize`Correspondents`,
icon: 'person',
infoLink: 'usage/#terms-and-definitions',
permissionType: PermissionType.Correspondent,
kind: DocumentAttributesSectionKind.ManagementList,
component: CorrespondentListComponent,
},
{
id: DocumentAttributesNavIDs.DocumentTypes,
path: 'documenttypes',
label: $localize`Document types`,
icon: 'hash',
infoLink: 'usage/#terms-and-definitions',
permissionType: PermissionType.DocumentType,
kind: DocumentAttributesSectionKind.ManagementList,
component: DocumentTypeListComponent,
},
{
id: DocumentAttributesNavIDs.StoragePaths,
path: 'storagepaths',
label: $localize`Storage paths`,
icon: 'folder',
infoLink: 'usage/#terms-and-definitions',
permissionType: PermissionType.StoragePath,
kind: DocumentAttributesSectionKind.ManagementList,
component: StoragePathListComponent,
},
{
id: DocumentAttributesNavIDs.CustomFields,
path: 'customfields',
label: $localize`Custom fields`,
icon: 'ui-radios',
infoLink: 'usage/#custom-fields',
permissionType: PermissionType.CustomField,
kind: DocumentAttributesSectionKind.CustomFields,
component: CustomFieldsComponent,
},
]
@ViewChild('activeOutlet', { read: NgComponentOutlet })
private readonly activeOutlet?: NgComponentOutlet
private lastHeaderLoading: boolean
activeNavID: number = null
get visibleSections(): DocumentAttributesSection[] {
return this.sections.filter((section) =>
this.permissionsService.currentUserCan(
PermissionAction.View,
section.permissionType
)
)
}
get activeSection(): DocumentAttributesSection | null {
return (
this.visibleSections.find((section) => section.id === this.activeNavID) ??
null
)
}
get activeManagementList(): ManagementListComponent<any> | null {
if (
this.activeSection?.kind !== DocumentAttributesSectionKind.ManagementList
)
return null
const instance = this.activeOutlet?.componentInstance
return instance instanceof ManagementListComponent ? instance : null
}
get activeCustomFields(): CustomFieldsComponent | null {
if (this.activeSection?.kind !== DocumentAttributesSectionKind.CustomFields)
return null
const instance = this.activeOutlet?.componentInstance
return instance instanceof CustomFieldsComponent ? instance : null
}
get activeTabLabel(): string {
return this.activeSection?.label ?? ''
}
get activeInfoLink(): string {
return this.activeSection?.infoLink ?? null
}
get activeHeaderLoading(): boolean {
return (
this.activeManagementList?.loading ??
this.activeCustomFields?.loading ??
false
)
}
ngOnInit(): void {
this.activatedRoute.paramMap
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe((paramMap) => {
const section = paramMap.get('section')
const navIDFromSection =
this.getNavIDForSection(section) ?? this.getDefaultNavID()
if (navIDFromSection == null) {
this.router.navigate(['/dashboard'], { replaceUrl: true })
return
}
if (this.activeNavID !== navIDFromSection) {
this.activeNavID = navIDFromSection
}
if (!section || this.getNavIDForSection(section) == null) {
this.router.navigate(
['attributes', this.getSectionForNavID(this.activeNavID)],
{ replaceUrl: true }
)
}
})
}
ngOnDestroy(): void {
this.unsubscribeNotifier.next()
this.unsubscribeNotifier.complete()
}
ngAfterViewChecked(): void {
const current = this.activeHeaderLoading
if (this.lastHeaderLoading !== current) {
this.lastHeaderLoading = current
this.cdr.detectChanges()
}
}
onNavChange(navChangeEvent: NgbNavChangeEvent): void {
const nextSection = this.getSectionForNavID(navChangeEvent.nextId)
if (!nextSection) {
return
}
this.router.navigate(['attributes', nextSection])
}
private getDefaultNavID(): DocumentAttributesNavIDs | null {
return this.visibleSections[0]?.id ?? null
}
private getNavIDForSection(section: string): DocumentAttributesNavIDs | null {
const path = section?.toLowerCase()
if (!path) return null
const found = this.visibleSections.find((s) => s.path === path)
return found?.id ?? null
}
private getSectionForNavID(navID: number): string | null {
const section = this.visibleSections.find((s) => s.id === navID)
return section?.path ?? null
}
}

View File

@@ -9,7 +9,7 @@ import { of } from 'rxjs'
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive' import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
import { SortableDirective } from 'src/app/directives/sortable.directive' import { SortableDirective } from 'src/app/directives/sortable.directive'
import { DocumentTypeService } from 'src/app/services/rest/document-type.service' import { DocumentTypeService } from 'src/app/services/rest/document-type.service'
import { PageHeaderComponent } from '../../../../common/page-header/page-header.component' import { PageHeaderComponent } from '../../common/page-header/page-header.component'
import { DocumentTypeListComponent } from './document-type-list.component' import { DocumentTypeListComponent } from './document-type-list.component'
describe('DocumentTypeListComponent', () => { describe('DocumentTypeListComponent', () => {

View File

@@ -1,4 +1,4 @@
import { NgClass, NgTemplateOutlet } from '@angular/common' import { NgClass, NgTemplateOutlet, TitleCasePipe } from '@angular/common'
import { Component, inject } from '@angular/core' import { Component, inject } from '@angular/core'
import { FormsModule, ReactiveFormsModule } from '@angular/forms' import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { RouterModule } from '@angular/router' import { RouterModule } from '@angular/router'
@@ -7,21 +7,25 @@ import {
NgbPaginationModule, NgbPaginationModule,
} from '@ng-bootstrap/ng-bootstrap' } from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons' import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { DocumentTypeEditDialogComponent } from 'src/app/components/common/edit-dialog/document-type-edit-dialog/document-type-edit-dialog.component'
import { DocumentType } from 'src/app/data/document-type' import { DocumentType } from 'src/app/data/document-type'
import { FILTER_HAS_DOCUMENT_TYPE_ANY } from 'src/app/data/filter-rule-type' import { FILTER_HAS_DOCUMENT_TYPE_ANY } from 'src/app/data/filter-rule-type'
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive' import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
import { SortableDirective } from 'src/app/directives/sortable.directive' import { SortableDirective } from 'src/app/directives/sortable.directive'
import { PermissionType } from 'src/app/services/permissions.service' import { PermissionType } from 'src/app/services/permissions.service'
import { DocumentTypeService } from 'src/app/services/rest/document-type.service' import { DocumentTypeService } from 'src/app/services/rest/document-type.service'
import { ManagementListComponent } from '../management-list.component' import { ClearableBadgeComponent } from '../../common/clearable-badge/clearable-badge.component'
import { DocumentTypeEditDialogComponent } from '../../common/edit-dialog/document-type-edit-dialog/document-type-edit-dialog.component'
import { PageHeaderComponent } from '../../common/page-header/page-header.component'
import { ManagementListComponent } from '../management-list/management-list.component'
@Component({ @Component({
selector: 'pngx-document-type-list', selector: 'pngx-document-type-list',
templateUrl: './../management-list.component.html', templateUrl: './../management-list/management-list.component.html',
styleUrls: ['./../management-list.component.scss'], styleUrls: ['./../management-list/management-list.component.scss'],
imports: [ imports: [
SortableDirective, SortableDirective,
PageHeaderComponent,
TitleCasePipe,
IfPermissionsDirective, IfPermissionsDirective,
FormsModule, FormsModule,
ReactiveFormsModule, ReactiveFormsModule,
@@ -31,6 +35,7 @@ import { ManagementListComponent } from '../management-list.component'
NgbDropdownModule, NgbDropdownModule,
NgbPaginationModule, NgbPaginationModule,
NgxBootstrapIconsModule, NgxBootstrapIconsModule,
ClearableBadgeComponent,
], ],
}) })
export class DocumentTypeListComponent extends ManagementListComponent<DocumentType> { export class DocumentTypeListComponent extends ManagementListComponent<DocumentType> {

View File

@@ -11,16 +11,16 @@
<h4> <h4>
<ng-container i18n>Mail accounts</ng-container> <ng-container i18n>Mail accounts</ng-container>
<button type="button" class="btn btn-sm btn-outline-primary ms-4" (click)="editMailAccount()" *pngxIfPermissions="{ action: PermissionAction.Add, type: PermissionType.MailAccount }"> <button type="button" class="btn btn-sm btn-outline-primary ms-4" (click)="editMailAccount()" *pngxIfPermissions="{ action: PermissionAction.Add, type: PermissionType.MailAccount }">
<i-bs name="plus-circle" class="me-1"></i-bs><ng-container i18n>Add Account</ng-container> <i-bs name="plus-circle"></i-bs>&nbsp;<ng-container i18n>Add Account</ng-container>
</button> </button>
@if (gmailOAuthUrl) { @if (gmailOAuthUrl) {
<a class="btn btn-sm btn-outline-secondary ms-2" [href]="gmailOAuthUrl" *pngxIfPermissions="{ action: PermissionAction.Add, type: PermissionType.MailAccount }"> <a class="btn btn-sm btn-outline-secondary ms-2" [href]="gmailOAuthUrl" *pngxIfPermissions="{ action: PermissionAction.Add, type: PermissionType.MailAccount }">
<i-bs name="google" class="me-1"></i-bs><ng-container i18n>Connect Gmail Account</ng-container> <i-bs name="google"></i-bs>&nbsp;<ng-container i18n>Connect Gmail Account</ng-container>
</a> </a>
} }
@if (outlookOAuthUrl) { @if (outlookOAuthUrl) {
<a class="btn btn-sm btn-outline-secondary ms-2" [href]="outlookOAuthUrl" *pngxIfPermissions="{ action: PermissionAction.Add, type: PermissionType.MailAccount }"> <a class="btn btn-sm btn-outline-secondary ms-2" [href]="outlookOAuthUrl" *pngxIfPermissions="{ action: PermissionAction.Add, type: PermissionType.MailAccount }">
<i-bs name="microsoft" class="me-1"></i-bs><ng-container i18n>Connect Outlook Account</ng-container> <i-bs name="microsoft"></i-bs>&nbsp;<ng-container i18n>Connect Outlook Account</ng-container>
</a> </a>
} }
</h4> </h4>
@@ -72,18 +72,18 @@
<div class="btn-toolbar d-none d-sm-flex gap-2" role="toolbar"> <div class="btn-toolbar d-none d-sm-flex gap-2" role="toolbar">
<div class="btn-group"> <div class="btn-group">
<button *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.MailAccount }" [disabled]="!userCanEdit(account)" class="btn btn-sm btn-outline-secondary" type="button" (click)="editMailAccount(account)"> <button *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.MailAccount }" [disabled]="!userCanEdit(account)" class="btn btn-sm btn-outline-secondary" type="button" (click)="editMailAccount(account)">
<i-bs width="1em" height="1em" name="pencil" class="me-1"></i-bs><ng-container i18n>Edit</ng-container> <i-bs width="1em" height="1em" name="pencil"></i-bs>&nbsp;<ng-container i18n>Edit</ng-container>
</button> </button>
<button *pngxIfOwner="account" class="btn btn-sm btn-outline-secondary" type="button" (click)="editPermissions(account)"> <button *pngxIfOwner="account" class="btn btn-sm btn-outline-secondary" type="button" (click)="editPermissions(account)">
<i-bs width="1em" height="1em" name="person-lock" class="me-1"></i-bs><ng-container i18n>Permissions</ng-container> <i-bs width="1em" height="1em" name="person-lock"></i-bs>&nbsp;<ng-container i18n>Permissions</ng-container>
</button> </button>
<button *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.MailAccount }" [disabled]="!userIsOwner(account)" class="btn btn-sm btn-outline-danger" type="button" (click)="deleteMailAccount(account)"> <button *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.MailAccount }" [disabled]="!userIsOwner(account)" class="btn btn-sm btn-outline-danger" type="button" (click)="deleteMailAccount(account)">
<i-bs width="1em" height="1em" name="trash" class="me-1"></i-bs><ng-container i18n>Delete</ng-container> <i-bs width="1em" height="1em" name="trash"></i-bs>&nbsp;<ng-container i18n>Delete</ng-container>
</button> </button>
</div> </div>
<div class="btn-group"> <div class="btn-group">
<button *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.MailAccount }" [disabled]="!userIsOwner(account)" class="btn btn-sm btn-outline-secondary" type="button" (click)="processAccount(account)"> <button *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.MailAccount }" [disabled]="!userIsOwner(account)" class="btn btn-sm btn-outline-secondary" type="button" (click)="processAccount(account)">
<i-bs width="1em" height="1em" name="arrow-clockwise" class="me-1"></i-bs><ng-container i18n>Process Mail</ng-container> <i-bs width="1em" height="1em" name="arrow-clockwise"></i-bs>&nbsp;<ng-container i18n>Process Mail</ng-container>
</button> </button>
</div> </div>
</div> </div>
@@ -102,7 +102,7 @@
<h4 class="mt-4"> <h4 class="mt-4">
<ng-container i18n>Mail rules</ng-container> <ng-container i18n>Mail rules</ng-container>
<button type="button" class="btn btn-sm btn-outline-primary ms-4" (click)="editMailRule()" *pngxIfPermissions="{ action: PermissionAction.Add, type: PermissionType.MailRule }"> <button type="button" class="btn btn-sm btn-outline-primary ms-4" (click)="editMailRule()" *pngxIfPermissions="{ action: PermissionAction.Add, type: PermissionType.MailRule }">
<i-bs name="plus-circle" class="me-1"></i-bs><ng-container i18n>Add Rule</ng-container> <i-bs name="plus-circle"></i-bs>&nbsp;<ng-container i18n>Add Rule</ng-container>
</button> </button>
</h4> </h4>
<ul class="list-group"> <ul class="list-group">
@@ -140,7 +140,7 @@
</div> </div>
<div class="col d-flex align-items-center d-none d-sm-flex" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.ProcessedMail }"> <div class="col d-flex align-items-center d-none d-sm-flex" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.ProcessedMail }">
<button class="btn btn-sm btn-outline-secondary" type="button" (click)="viewProcessedMail(rule)"> <button class="btn btn-sm btn-outline-secondary" type="button" (click)="viewProcessedMail(rule)">
<i-bs width="1em" height="1em" name="clock-history" class="me-1"></i-bs><ng-container i18n>View Processed Mail</ng-container> <i-bs width="1em" height="1em" name="clock-history"></i-bs>&nbsp;<ng-container i18n>View Processed Mail</ng-container>
</button> </button>
</div> </div>
<div class="col-3"> <div class="col-3">
@@ -160,18 +160,18 @@
<div class="btn-toolbar d-none d-sm-flex gap-2" role="toolbar"> <div class="btn-toolbar d-none d-sm-flex gap-2" role="toolbar">
<div class="btn-group"> <div class="btn-group">
<button *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.MailRule }" [disabled]="!userCanEdit(rule)" class="btn btn-sm btn-outline-secondary" type="button" (click)="editMailRule(rule)"> <button *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.MailRule }" [disabled]="!userCanEdit(rule)" class="btn btn-sm btn-outline-secondary" type="button" (click)="editMailRule(rule)">
<i-bs width="1em" height="1em" name="pencil" class="me-1"></i-bs><ng-container i18n>Edit</ng-container> <i-bs width="1em" height="1em" name="pencil"></i-bs>&nbsp;<ng-container i18n>Edit</ng-container>
</button> </button>
<button *pngxIfOwner="rule" class="btn btn-sm btn-outline-secondary" type="button" (click)="editPermissions(rule)"> <button *pngxIfOwner="rule" class="btn btn-sm btn-outline-secondary" type="button" (click)="editPermissions(rule)">
<i-bs width="1em" height="1em" name="person-lock" class="me-1"></i-bs><ng-container i18n>Permissions</ng-container> <i-bs width="1em" height="1em" name="person-lock"></i-bs>&nbsp;<ng-container i18n>Permissions</ng-container>
</button> </button>
<button *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.MailRule }" [disabled]="!userIsOwner(rule)" class="btn btn-sm btn-outline-danger" type="button" (click)="deleteMailRule(rule)"> <button *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.MailRule }" [disabled]="!userIsOwner(rule)" class="btn btn-sm btn-outline-danger" type="button" (click)="deleteMailRule(rule)">
<i-bs width="1em" height="1em" name="trash" class="me-1"></i-bs><ng-container i18n>Delete</ng-container> <i-bs width="1em" height="1em" name="trash"></i-bs>&nbsp;<ng-container i18n>Delete</ng-container>
</button> </button>
</div> </div>
<div class="btn-group"> <div class="btn-group">
<button *pngxIfPermissions="{ action: PermissionAction.Add, type: PermissionType.MailRule }" class="btn btn-sm btn-outline-secondary" type="button" (click)="copyMailRule(rule)"> <button *pngxIfPermissions="{ action: PermissionAction.Add, type: PermissionType.MailRule }" class="btn btn-sm btn-outline-secondary" type="button" (click)="copyMailRule(rule)">
<i-bs width="1em" height="1em" name="files" class="me-1"></i-bs><ng-container i18n>Copy</ng-container> <i-bs width="1em" height="1em" name="files"></i-bs>&nbsp;<ng-container i18n>Copy</ng-container>
</button> </button>
</div> </div>
</div> </div>

View File

@@ -1,3 +1,50 @@
<pngx-page-header title="{{ typeNamePlural | titlecase }}" info="View, add, edit and delete {{ typeNamePlural }}." infoLink="usage/#terms-and-definitions" [loading]="loading">
<div ngbDropdown class="btn-group flex-fill d-sm-none">
<button class="btn btn-sm btn-outline-primary" id="dropdownSelectMobile" ngbDropdownToggle>
<i-bs name="text-indent-left"></i-bs>
<div class="d-none d-sm-inline">&nbsp;<ng-container i18n>Select</ng-container></div>
@if (selectedObjects.size > 0) {
<pngx-clearable-badge [selected]="selectedObjects.size > 0" [number]="selectedObjects.size" (cleared)="selectNone()"></pngx-clearable-badge><span class="visually-hidden">selected</span>
}
</button>
<div ngbDropdownMenu aria-labelledby="dropdownSelectMobile" class="shadow">
<button ngbDropdownItem (click)="selectNone()" i18n>Select none</button>
<button ngbDropdownItem (click)="selectPage(true)" i18n>Select page</button>
<button ngbDropdownItem (click)="selectAll()" i18n>Select all</button>
</div>
</div>
<div class="d-none d-sm-flex flex-fill me-3">
<div class="input-group input-group-sm">
<span class="input-group-text border-0" i18n>Select:</span>
</div>
<div class="btn-group btn-group-sm flex-nowrap">
@if (selectedObjects.size > 0) {
<button class="btn btn-sm btn-outline-secondary" (click)="selectNone()">
<i-bs name="slash-circle"></i-bs>&nbsp;<ng-container i18n>None</ng-container>
</button>
}
<button class="btn btn-sm btn-outline-primary" (click)="selectPage(true)">
<i-bs name="file-earmark-check"></i-bs>&nbsp;<ng-container i18n>Page</ng-container>
</button>
<button class="btn btn-sm btn-outline-primary" (click)="selectAll()">
<i-bs name="check-all"></i-bs>&nbsp;<ng-container i18n>All</ng-container>
</button>
</div>
</div>
<button type="button" class="btn btn-sm btn-outline-primary" (click)="setPermissions()" [disabled]="!userCanBulkEdit(PermissionAction.Change) || selectedObjects.size === 0">
<i-bs name="person-fill-lock"></i-bs>&nbsp;<ng-container i18n>Permissions</ng-container>
</button>
<button type="button" class="btn btn-sm btn-outline-danger" (click)="delete()" [disabled]="!userCanBulkEdit(PermissionAction.Delete) || selectedObjects.size === 0">
<i-bs name="trash"></i-bs>&nbsp;<ng-container i18n>Delete</ng-container>
</button>
<button type="button" class="btn btn-sm btn-outline-primary ms-md-5" (click)="openCreateDialog()" *pngxIfPermissions="{ action: PermissionAction.Add, type: permissionType }">
<i-bs name="plus-circle"></i-bs>&nbsp;<ng-container i18n>Create</ng-container>
</button>
</pngx-page-header>
<div class="row mb-3"> <div class="row mb-3">
<div class="col mb-2 mb-xl-0"> <div class="col mb-2 mb-xl-0">
<div class="form-inline d-flex align-items-center"> <div class="form-inline d-flex align-items-center">
@@ -29,19 +76,19 @@
<table class="table table-striped align-middle shadow-sm mb-0"> <table class="table table-striped align-middle shadow-sm mb-0">
<thead> <thead>
<tr> <tr>
<th> <th scope="col">
<div class="form-check m-0 ms-2 me-n2"> <div class="form-check m-0 ms-2 me-n2">
<input type="checkbox" class="form-check-input" id="all-objects" [(ngModel)]="togggleAll" [disabled]="data.length === 0" (change)="$event.target.checked ? selectPage() : clearSelection(); $event.stopPropagation();"> <input type="checkbox" class="form-check-input" id="all-objects" [(ngModel)]="togggleAll" [disabled]="data.length === 0" (change)="selectPage($event.target.checked); $event.stopPropagation();">
<label class="form-check-label" for="all-objects"></label> <label class="form-check-label" for="all-objects"></label>
</div> </div>
</th> </th>
<th class="fw-normal" pngxSortable="name" [currentSortField]="sortField" [currentSortReverse]="sortReverse" (sort)="onSort($event)" i18n>Name</th> <th scope="col" class="fw-normal" pngxSortable="name" [currentSortField]="sortField" [currentSortReverse]="sortReverse" (sort)="onSort($event)" i18n>Name</th>
<th class="fw-normal d-none d-sm-table-cell" pngxSortable="matching_algorithm" [currentSortField]="sortField" [currentSortReverse]="sortReverse" (sort)="onSort($event)" i18n>Matching</th> <th scope="col" class="fw-normal d-none d-sm-table-cell" pngxSortable="matching_algorithm" [currentSortField]="sortField" [currentSortReverse]="sortReverse" (sort)="onSort($event)" i18n>Matching</th>
<th class="fw-normal" pngxSortable="document_count" [currentSortField]="sortField" [currentSortReverse]="sortReverse" (sort)="onSort($event)" i18n>Document count</th> <th scope="col" class="fw-normal" pngxSortable="document_count" [currentSortField]="sortField" [currentSortReverse]="sortReverse" (sort)="onSort($event)" i18n>Document count</th>
@for (column of extraColumns; track column) { @for (column of extraColumns; track column) {
<th class="fw-normal" [ngClass]="{ 'd-none d-sm-table-cell' : column.hideOnMobile }" pngxSortable="{{column.key}}" [currentSortField]="sortField" [currentSortReverse]="sortReverse" (sort)="onSort($event)">{{column.name}}</th> <th scope="col" class="fw-normal" [ngClass]="{ 'd-none d-sm-table-cell' : column.hideOnMobile }" pngxSortable="{{column.key}}" [currentSortField]="sortField" [currentSortReverse]="sortReverse" (sort)="onSort($event)">{{column.name}}</th>
} }
<th class="fw-normal" i18n>Actions</th> <th scope="col" class="fw-normal" i18n>Actions</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -84,16 +131,16 @@
<label class="form-check-label" for="{{typeName}}{{object.id}}"></label> <label class="form-check-label" for="{{typeName}}{{object.id}}"></label>
</div> </div>
</td> </td>
<td class="name-cell" style="--depth: {{depth}}"> <td scope="row" class="name-cell" style="--depth: {{depth}}">
@if (depth > 0) { @if (depth > 0) {
<div class="indicator"></div> <div class="indicator"></div>
} }
<button class="btn btn-link ms-0 ps-0 text-start" (click)="userCanEdit(object) ? openEditDialog(object) : null; $event.stopPropagation()">{{ object.name }}</button> <button class="btn btn-link ms-0 ps-0 text-start" (click)="userCanEdit(object) ? openEditDialog(object) : null; $event.stopPropagation()">{{ object.name }}</button>
</td> </td>
<td class="d-none d-sm-table-cell">{{ getMatching(object) }}</td> <td scope="row" class="d-none d-sm-table-cell">{{ getMatching(object) }}</td>
<td>{{ getDocumentCount(object) }}</td> <td scope="row">{{ getDocumentCount(object) }}</td>
@for (column of extraColumns; track column) { @for (column of extraColumns; track column) {
<td [ngClass]="{ 'd-none d-sm-table-cell' : column.hideOnMobile }"> <td scope="row" [ngClass]="{ 'd-none d-sm-table-cell' : column.hideOnMobile }">
@if (column.badgeFn) { @if (column.badgeFn) {
<span <span
class="badge" class="badge"
@@ -109,7 +156,7 @@
} }
</td> </td>
} }
<td> <td scope="row">
<div class="btn-toolbar gap-2"> <div class="btn-toolbar gap-2">
<div class="btn-group d-block d-sm-none"> <div class="btn-group d-block d-sm-none">
<div ngbDropdown container="body" class="d-inline-block"> <div ngbDropdown container="body" class="d-inline-block">
@@ -134,10 +181,10 @@
</div> </div>
<div class="btn-group d-none d-sm-inline-block"> <div class="btn-group d-none d-sm-inline-block">
<button class="btn btn-sm btn-outline-secondary" (click)="openEditDialog(object); $event.stopPropagation();" *pngxIfPermissions="{ action: PermissionAction.Change, type: permissionType }" [disabled]="!userCanEdit(object)"> <button class="btn btn-sm btn-outline-secondary" (click)="openEditDialog(object); $event.stopPropagation();" *pngxIfPermissions="{ action: PermissionAction.Change, type: permissionType }" [disabled]="!userCanEdit(object)">
<i-bs width="1em" height="1em" name="pencil" class="me-1"></i-bs><ng-container i18n>Edit</ng-container> <i-bs width="1em" height="1em" name="pencil"></i-bs>&nbsp;<ng-container i18n>Edit</ng-container>
</button> </button>
<button class="btn btn-sm btn-outline-danger" (click)="openDeleteDialog(object); $event.stopPropagation();" *pngxIfPermissions="{ action: PermissionAction.Delete, type: permissionType }" [disabled]="!userCanDelete(object)"> <button class="btn btn-sm btn-outline-danger" (click)="openDeleteDialog(object); $event.stopPropagation();" *pngxIfPermissions="{ action: PermissionAction.Delete, type: permissionType }" [disabled]="!userCanDelete(object)">
<i-bs width="1em" height="1em" name="trash" class="me-1"></i-bs><ng-container i18n>Delete</ng-container> <i-bs width="1em" height="1em" name="trash"></i-bs>&nbsp;<ng-container i18n>Delete</ng-container>
</button> </button>
</div> </div>
@if (getDocumentCount(object) > 0) { @if (getDocumentCount(object) > 0) {
@@ -148,7 +195,7 @@
[routerLink]="getDocumentFilterUrl(object)" [routerLink]="getDocumentFilterUrl(object)"
(click)="$event?.stopPropagation()" (click)="$event?.stopPropagation()"
> >
<i-bs width="1em" height="1em" name="filter" class="me-1"></i-bs><ng-container i18n>Documents</ng-container <i-bs width="1em" height="1em" name="filter"></i-bs>&nbsp;<ng-container i18n>Documents</ng-container
><span class="badge bg-light text-secondary ms-2">{{ getDocumentCount(object) }}</span> ><span class="badge bg-light text-secondary ms-2">{{ getDocumentCount(object) }}</span>
</a> </a>
</div> </div>

View File

@@ -44,12 +44,12 @@ import { BulkEditObjectOperation } from 'src/app/services/rest/abstract-name-fil
import { TagService } from 'src/app/services/rest/tag.service' import { TagService } from 'src/app/services/rest/tag.service'
import { SettingsService } from 'src/app/services/settings.service' import { SettingsService } from 'src/app/services/settings.service'
import { ToastService } from 'src/app/services/toast.service' import { ToastService } from 'src/app/services/toast.service'
import { ConfirmDialogComponent } from '../../../common/confirm-dialog/confirm-dialog.component' import { ConfirmDialogComponent } from '../../common/confirm-dialog/confirm-dialog.component'
import { EditDialogComponent } from '../../../common/edit-dialog/edit-dialog.component' import { EditDialogComponent } from '../../common/edit-dialog/edit-dialog.component'
import { PageHeaderComponent } from '../../../common/page-header/page-header.component' import { PageHeaderComponent } from '../../common/page-header/page-header.component'
import { PermissionsDialogComponent } from '../../../common/permissions-dialog/permissions-dialog.component' import { PermissionsDialogComponent } from '../../common/permissions-dialog/permissions-dialog.component'
import { TagListComponent } from '../tag-list/tag-list.component'
import { ManagementListComponent } from './management-list.component' import { ManagementListComponent } from './management-list.component'
import { TagListComponent } from './tag-list/tag-list.component'
const tags: Tag[] = [ const tags: Tag[] = [
{ {
@@ -304,12 +304,12 @@ describe('ManagementListComponent', () => {
}) })
it('selectPage should select current page items or clear selection', () => { it('selectPage should select current page items or clear selection', () => {
component.selectPage() component.selectPage(true)
expect(component.selectedObjects).toEqual(new Set(tags.map((t) => t.id))) expect(component.selectedObjects).toEqual(new Set(tags.map((t) => t.id)))
expect(component.togggleAll).toBe(true) expect(component.togggleAll).toBe(true)
component.togggleAll = true component.togggleAll = true
component.clearSelection() component.selectPage(false)
expect(component.selectedObjects.size).toBe(0) expect(component.selectedObjects.size).toBe(0)
expect(component.togggleAll).toBe(false) expect(component.togggleAll).toBe(false)
}) })

View File

@@ -16,10 +16,6 @@ import {
takeUntil, takeUntil,
tap, tap,
} from 'rxjs/operators' } from 'rxjs/operators'
import { ConfirmDialogComponent } from 'src/app/components/common/confirm-dialog/confirm-dialog.component'
import { EditDialogMode } from 'src/app/components/common/edit-dialog/edit-dialog.component'
import { PermissionsDialogComponent } from 'src/app/components/common/permissions-dialog/permissions-dialog.component'
import { LoadingComponentWithPermissions } from 'src/app/components/loading-component/loading.component'
import { import {
MATCH_AUTO, MATCH_AUTO,
MATCH_NONE, MATCH_NONE,
@@ -44,6 +40,10 @@ import {
} from 'src/app/services/rest/abstract-name-filter-service' } from 'src/app/services/rest/abstract-name-filter-service'
import { SettingsService } from 'src/app/services/settings.service' import { SettingsService } from 'src/app/services/settings.service'
import { ToastService } from 'src/app/services/toast.service' import { ToastService } from 'src/app/services/toast.service'
import { ConfirmDialogComponent } from '../../common/confirm-dialog/confirm-dialog.component'
import { EditDialogMode } from '../../common/edit-dialog/edit-dialog.component'
import { PermissionsDialogComponent } from '../../common/permissions-dialog/permissions-dialog.component'
import { LoadingComponentWithPermissions } from '../../loading-component/loading.component'
export interface ManagementListColumn { export interface ManagementListColumn {
key: string key: string
@@ -69,14 +69,13 @@ export abstract class ManagementListComponent<T extends MatchingModel>
implements OnInit, OnDestroy implements OnInit, OnDestroy
{ {
protected service: AbstractNameFilterService<T> protected service: AbstractNameFilterService<T>
private readonly modalService: NgbModal = inject(NgbModal) private modalService: NgbModal = inject(NgbModal)
protected editDialogComponent: any protected editDialogComponent: any
private readonly toastService: ToastService = inject(ToastService) private toastService: ToastService = inject(ToastService)
private readonly documentListViewService: DocumentListViewService = inject( private documentListViewService: DocumentListViewService = inject(
DocumentListViewService DocumentListViewService
) )
private readonly permissionsService: PermissionsService = private permissionsService: PermissionsService = inject(PermissionsService)
inject(PermissionsService)
protected filterRuleType: number protected filterRuleType: number
public typeName: string public typeName: string
public typeNamePlural: string public typeNamePlural: string
@@ -197,7 +196,7 @@ export abstract class ManagementListComponent<T extends MatchingModel>
} }
openCreateDialog() { openCreateDialog() {
const activeModal = this.modalService.open(this.editDialogComponent, { var activeModal = this.modalService.open(this.editDialogComponent, {
backdrop: 'static', backdrop: 'static',
}) })
activeModal.componentInstance.dialogMode = EditDialogMode.CREATE activeModal.componentInstance.dialogMode = EditDialogMode.CREATE
@@ -216,7 +215,7 @@ export abstract class ManagementListComponent<T extends MatchingModel>
} }
openEditDialog(object: T) { openEditDialog(object: T) {
const activeModal = this.modalService.open(this.editDialogComponent, { var activeModal = this.modalService.open(this.editDialogComponent, {
backdrop: 'static', backdrop: 'static',
}) })
activeModal.componentInstance.object = object activeModal.componentInstance.object = object
@@ -244,7 +243,7 @@ export abstract class ManagementListComponent<T extends MatchingModel>
} }
openDeleteDialog(object: T) { openDeleteDialog(object: T) {
const activeModal = this.modalService.open(ConfirmDialogComponent, { var activeModal = this.modalService.open(ConfirmDialogComponent, {
backdrop: 'static', backdrop: 'static',
}) })
activeModal.componentInstance.title = $localize`Confirm delete` activeModal.componentInstance.title = $localize`Confirm delete`
@@ -344,9 +343,13 @@ export abstract class ManagementListComponent<T extends MatchingModel>
this.clearSelection() this.clearSelection()
} }
selectPage() { selectPage(select: boolean) {
if (select) {
this.selectedObjects = new Set(this.getSelectableIDs(this.data)) this.selectedObjects = new Set(this.getSelectableIDs(this.data))
this.togggleAll = this.areAllPageItemsSelected() this.togggleAll = this.areAllPageItemsSelected()
} else {
this.clearSelection()
}
} }
selectAll() { selectAll() {

View File

@@ -10,7 +10,7 @@ import { StoragePath } from 'src/app/data/storage-path'
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive' import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
import { SortableDirective } from 'src/app/directives/sortable.directive' import { SortableDirective } from 'src/app/directives/sortable.directive'
import { StoragePathService } from 'src/app/services/rest/storage-path.service' import { StoragePathService } from 'src/app/services/rest/storage-path.service'
import { PageHeaderComponent } from '../../../../common/page-header/page-header.component' import { PageHeaderComponent } from '../../common/page-header/page-header.component'
import { StoragePathListComponent } from './storage-path-list.component' import { StoragePathListComponent } from './storage-path-list.component'
describe('StoragePathListComponent', () => { describe('StoragePathListComponent', () => {

View File

@@ -1,4 +1,4 @@
import { NgClass, NgTemplateOutlet } from '@angular/common' import { NgClass, NgTemplateOutlet, TitleCasePipe } from '@angular/common'
import { Component, inject } from '@angular/core' import { Component, inject } from '@angular/core'
import { FormsModule, ReactiveFormsModule } from '@angular/forms' import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { RouterModule } from '@angular/router' import { RouterModule } from '@angular/router'
@@ -7,21 +7,25 @@ import {
NgbPaginationModule, NgbPaginationModule,
} from '@ng-bootstrap/ng-bootstrap' } from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons' import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { StoragePathEditDialogComponent } from 'src/app/components/common/edit-dialog/storage-path-edit-dialog/storage-path-edit-dialog.component'
import { FILTER_HAS_STORAGE_PATH_ANY } from 'src/app/data/filter-rule-type' import { FILTER_HAS_STORAGE_PATH_ANY } from 'src/app/data/filter-rule-type'
import { StoragePath } from 'src/app/data/storage-path' import { StoragePath } from 'src/app/data/storage-path'
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive' import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
import { SortableDirective } from 'src/app/directives/sortable.directive' import { SortableDirective } from 'src/app/directives/sortable.directive'
import { PermissionType } from 'src/app/services/permissions.service' import { PermissionType } from 'src/app/services/permissions.service'
import { StoragePathService } from 'src/app/services/rest/storage-path.service' import { StoragePathService } from 'src/app/services/rest/storage-path.service'
import { ManagementListComponent } from '../management-list.component' import { ClearableBadgeComponent } from '../../common/clearable-badge/clearable-badge.component'
import { StoragePathEditDialogComponent } from '../../common/edit-dialog/storage-path-edit-dialog/storage-path-edit-dialog.component'
import { PageHeaderComponent } from '../../common/page-header/page-header.component'
import { ManagementListComponent } from '../management-list/management-list.component'
@Component({ @Component({
selector: 'pngx-storage-path-list', selector: 'pngx-storage-path-list',
templateUrl: './../management-list.component.html', templateUrl: './../management-list/management-list.component.html',
styleUrls: ['./../management-list.component.scss'], styleUrls: ['./../management-list/management-list.component.scss'],
imports: [ imports: [
SortableDirective, SortableDirective,
PageHeaderComponent,
TitleCasePipe,
IfPermissionsDirective, IfPermissionsDirective,
FormsModule, FormsModule,
ReactiveFormsModule, ReactiveFormsModule,
@@ -31,6 +35,7 @@ import { ManagementListComponent } from '../management-list.component'
NgbDropdownModule, NgbDropdownModule,
NgbPaginationModule, NgbPaginationModule,
NgxBootstrapIconsModule, NgxBootstrapIconsModule,
ClearableBadgeComponent,
], ],
}) })
export class StoragePathListComponent extends ManagementListComponent<StoragePath> { export class StoragePathListComponent extends ManagementListComponent<StoragePath> {

View File

@@ -9,7 +9,7 @@ import { of } from 'rxjs'
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive' import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
import { SortableDirective } from 'src/app/directives/sortable.directive' import { SortableDirective } from 'src/app/directives/sortable.directive'
import { TagService } from 'src/app/services/rest/tag.service' import { TagService } from 'src/app/services/rest/tag.service'
import { PageHeaderComponent } from '../../../../common/page-header/page-header.component' import { PageHeaderComponent } from '../../common/page-header/page-header.component'
import { TagListComponent } from './tag-list.component' import { TagListComponent } from './tag-list.component'
describe('TagListComponent', () => { describe('TagListComponent', () => {
@@ -138,12 +138,12 @@ describe('TagListComponent', () => {
} }
component.data = [parent as any] component.data = [parent as any]
component.selectPage() component.selectPage(true)
expect(component.selectedObjects.has(10)).toBe(true) expect(component.selectedObjects.has(10)).toBe(true)
expect(component.selectedObjects.has(11)).toBe(true) expect(component.selectedObjects.has(11)).toBe(true)
component.clearSelection() component.selectPage(false)
expect(component.selectedObjects.size).toBe(0) expect(component.selectedObjects.size).toBe(0)
}) })
}) })

View File

@@ -1,4 +1,4 @@
import { NgClass, NgTemplateOutlet } from '@angular/common' import { NgClass, NgTemplateOutlet, TitleCasePipe } from '@angular/common'
import { Component, inject } from '@angular/core' import { Component, inject } from '@angular/core'
import { FormsModule, ReactiveFormsModule } from '@angular/forms' import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { RouterModule } from '@angular/router' import { RouterModule } from '@angular/router'
@@ -7,21 +7,25 @@ import {
NgbPaginationModule, NgbPaginationModule,
} from '@ng-bootstrap/ng-bootstrap' } from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons' import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { TagEditDialogComponent } from 'src/app/components/common/edit-dialog/tag-edit-dialog/tag-edit-dialog.component'
import { FILTER_HAS_TAGS_ALL } from 'src/app/data/filter-rule-type' import { FILTER_HAS_TAGS_ALL } from 'src/app/data/filter-rule-type'
import { Tag } from 'src/app/data/tag' import { Tag } from 'src/app/data/tag'
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive' import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
import { SortableDirective } from 'src/app/directives/sortable.directive' import { SortableDirective } from 'src/app/directives/sortable.directive'
import { PermissionType } from 'src/app/services/permissions.service' import { PermissionType } from 'src/app/services/permissions.service'
import { TagService } from 'src/app/services/rest/tag.service' import { TagService } from 'src/app/services/rest/tag.service'
import { ManagementListComponent } from '../management-list.component' import { ClearableBadgeComponent } from '../../common/clearable-badge/clearable-badge.component'
import { TagEditDialogComponent } from '../../common/edit-dialog/tag-edit-dialog/tag-edit-dialog.component'
import { PageHeaderComponent } from '../../common/page-header/page-header.component'
import { ManagementListComponent } from '../management-list/management-list.component'
@Component({ @Component({
selector: 'pngx-tag-list', selector: 'pngx-tag-list',
templateUrl: './../management-list.component.html', templateUrl: './../management-list/management-list.component.html',
styleUrls: ['./../management-list.component.scss'], styleUrls: ['./../management-list/management-list.component.scss'],
imports: [ imports: [
SortableDirective, SortableDirective,
PageHeaderComponent,
TitleCasePipe,
IfPermissionsDirective, IfPermissionsDirective,
FormsModule, FormsModule,
ReactiveFormsModule, ReactiveFormsModule,
@@ -31,6 +35,7 @@ import { ManagementListComponent } from '../management-list.component'
NgbDropdownModule, NgbDropdownModule,
NgbPaginationModule, NgbPaginationModule,
NgxBootstrapIconsModule, NgxBootstrapIconsModule,
ClearableBadgeComponent,
], ],
}) })
export class TagListComponent extends ManagementListComponent<Tag> { export class TagListComponent extends ManagementListComponent<Tag> {

View File

@@ -6,7 +6,7 @@
infoLink="usage/#workflows" infoLink="usage/#workflows"
> >
<button type="button" class="btn btn-sm btn-outline-primary" (click)="editWorkflow()" *pngxIfPermissions="{ action: PermissionAction.Add, type: PermissionType.Workflow }"> <button type="button" class="btn btn-sm btn-outline-primary" (click)="editWorkflow()" *pngxIfPermissions="{ action: PermissionAction.Add, type: PermissionType.Workflow }">
<i-bs name="plus-circle" class="me-1"></i-bs><ng-container i18n>Add Workflow</ng-container> <i-bs name="plus-circle"></i-bs>&nbsp;<ng-container i18n>Add Workflow</ng-container>
</button> </button>
</pngx-page-header> </pngx-page-header>
@@ -60,15 +60,15 @@
<div class="btn-toolbar d-none d-sm-flex gap-2" role="toolbar"> <div class="btn-toolbar d-none d-sm-flex gap-2" role="toolbar">
<div class="btn-group"> <div class="btn-group">
<button *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.Workflow }" class="btn btn-sm btn-outline-secondary" type="button" (click)="editWorkflow(workflow)"> <button *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.Workflow }" class="btn btn-sm btn-outline-secondary" type="button" (click)="editWorkflow(workflow)">
<i-bs width="1em" height="1em" name="pencil" class="me-1"></i-bs><ng-container i18n>Edit</ng-container> <i-bs width="1em" height="1em" name="pencil"></i-bs>&nbsp;<ng-container i18n>Edit</ng-container>
</button> </button>
<button *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.Workflow }" class="btn btn-sm btn-outline-danger" type="button" (click)="deleteWorkflow(workflow)"> <button *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.Workflow }" class="btn btn-sm btn-outline-danger" type="button" (click)="deleteWorkflow(workflow)">
<i-bs width="1em" height="1em" name="trash" class="me-1"></i-bs><ng-container i18n>Delete</ng-container> <i-bs width="1em" height="1em" name="trash"></i-bs>&nbsp;<ng-container i18n>Delete</ng-container>
</button> </button>
</div> </div>
<div class="btn-group"> <div class="btn-group">
<button *pngxIfPermissions="{ action: PermissionAction.Add, type: PermissionType.Workflow }" class="btn btn-sm btn-outline-secondary" type="button" (click)="copyWorkflow(workflow)"> <button *pngxIfPermissions="{ action: PermissionAction.Add, type: PermissionType.Workflow }" class="btn btn-sm btn-outline-secondary" type="button" (click)="copyWorkflow(workflow)">
<i-bs width="1em" height="1em" name="files" class="me-1"></i-bs><ng-container i18n>Copy</ng-container> <i-bs width="1em" height="1em" name="files"></i-bs>&nbsp;<ng-container i18n>Copy</ng-container>
</button> </button>
</div> </div>
</div> </div>

View File

@@ -6,7 +6,7 @@
<h1 class="display-6" i18n>Not Found</h1> <h1 class="display-6" i18n>Not Found</h1>
<p> <p>
<a class="btn btn-primary" routerLink="/dashboard"> <a class="btn btn-primary" routerLink="/dashboard">
<i-bs width="1.2em" height="1.2em" name="house" class="me-1"></i-bs><ng-container i18n>Go to Dashboard</ng-container> <i-bs width="1.2em" height="1.2em" name="house"></i-bs>&nbsp;<ng-container i18n>Go to Dashboard</ng-container>
</a> </a>
</p> </p>
</div> </div>

View File

@@ -128,15 +128,15 @@ export interface Document extends ObjectWithPermissions {
checksum?: string checksum?: string
// UTC // UTC
created?: string // ISO string created?: Date
modified?: string // ISO string modified?: Date
added?: string // ISO string added?: Date
mime_type?: string mime_type?: string
deleted_at?: string // ISO string deleted_at?: Date
original_file_name?: string original_file_name?: string

View File

@@ -84,6 +84,4 @@ export interface MailRule extends ObjectWithPermissions {
assign_correspondent?: number // PaperlessCorrespondent.id assign_correspondent?: number // PaperlessCorrespondent.id
assign_owner_from_rule: boolean assign_owner_from_rule: boolean
stop_processing: boolean
} }

View File

@@ -19,10 +19,6 @@ export enum GlobalSearchType {
TITLE_CONTENT = 'title-content', TITLE_CONTENT = 'title-content',
} }
export enum CollapsibleSection {
ATTRIBUTES = 'attributes',
}
export const PAPERLESS_GREEN_HEX = '#17541f' export const PAPERLESS_GREEN_HEX = '#17541f'
export const SETTINGS_KEYS = { export const SETTINGS_KEYS = {
@@ -55,8 +51,6 @@ export const SETTINGS_KEYS = {
NOTES_ENABLED: 'general-settings:notes-enabled', NOTES_ENABLED: 'general-settings:notes-enabled',
AUDITLOG_ENABLED: 'general-settings:auditlog-enabled', AUDITLOG_ENABLED: 'general-settings:auditlog-enabled',
SLIM_SIDEBAR: 'general-settings:slim-sidebar', SLIM_SIDEBAR: 'general-settings:slim-sidebar',
ATTRIBUTES_SECTIONS_COLLAPSED:
'general-settings:attributes-sections-collapsed',
UPDATE_CHECKING_ENABLED: 'general-settings:update-checking:enabled', UPDATE_CHECKING_ENABLED: 'general-settings:update-checking:enabled',
UPDATE_CHECKING_BACKEND_SETTING: UPDATE_CHECKING_BACKEND_SETTING:
'general-settings:update-checking:backend-setting', 'general-settings:update-checking:backend-setting',
@@ -118,11 +112,6 @@ export const SETTINGS: UiSetting[] = [
type: 'boolean', type: 'boolean',
default: false, default: false,
}, },
{
key: SETTINGS_KEYS.ATTRIBUTES_SECTIONS_COLLAPSED,
type: 'array',
default: [],
},
{ {
key: SETTINGS_KEYS.DOCUMENT_LIST_SIZE, key: SETTINGS_KEYS.DOCUMENT_LIST_SIZE,
type: 'number', type: 'number',

View File

@@ -1,7 +0,0 @@
export interface WebsocketDocumentUpdatedMessage {
document_id: number
modified?: string
owner_id?: number
users_can_view?: number[]
groups_can_view?: number[]
}

View File

@@ -96,52 +96,4 @@ describe('PermissionsGuard', () => {
expect(canActivate).toHaveProperty('root') // returns UrlTree expect(canActivate).toHaveProperty('root') // returns UrlTree
expect(toastSpy).toHaveBeenCalled() expect(toastSpy).toHaveBeenCalled()
}) })
it('should activate when any required permission is granted', () => {
jest
.spyOn(permissionsService, 'currentUserCan')
.mockImplementation((action, type) => {
return type === PermissionType.Tag
})
const canActivate = guard.canActivate(
{
data: {
requiredPermissionAny: [
{ action: PermissionAction.View, type: PermissionType.Tag },
{
action: PermissionAction.View,
type: PermissionType.DocumentType,
},
],
},
} as any,
routerState.snapshot
)
expect(canActivate).toBeTruthy()
})
it('should not activate when no required permission is granted', () => {
jest
.spyOn(permissionsService, 'currentUserCan')
.mockImplementation(() => false)
const canActivate = guard.canActivate(
{
data: {
requiredPermissionAny: [
{ action: PermissionAction.View, type: PermissionType.Tag },
{
action: PermissionAction.View,
type: PermissionType.DocumentType,
},
],
},
} as any,
routerState.snapshot
)
expect(canActivate).toHaveProperty('root')
})
}) })

View File

@@ -20,20 +20,12 @@ export class PermissionsGuard {
route: ActivatedRouteSnapshot, route: ActivatedRouteSnapshot,
state: RouterStateSnapshot state: RouterStateSnapshot
): boolean | UrlTree { ): boolean | UrlTree {
const requiredPermissionAny: { action: any; type: any }[] =
route.data.requiredPermissionAny
if ( if (
(route.data.requireAdmin && !this.permissionsService.isAdmin()) || (route.data.requireAdmin && !this.permissionsService.isAdmin()) ||
(route.data.requiredPermission && (route.data.requiredPermission &&
!this.permissionsService.currentUserCan( !this.permissionsService.currentUserCan(
route.data.requiredPermission.action, route.data.requiredPermission.action,
route.data.requiredPermission.type route.data.requiredPermission.type
)) ||
(Array.isArray(requiredPermissionAny) &&
requiredPermissionAny.length > 0 &&
!requiredPermissionAny.some((p) =>
this.permissionsService.currentUserCan(p.action, p.type)
)) ))
) { ) {
// Check if tour is running 1 = TourState.ON // Check if tour is running 1 = TourState.ON

View File

@@ -33,7 +33,6 @@ const mail_rules = [
action: MailAction.MarkRead, action: MailAction.MarkRead,
assign_title_from: MailMetadataTitleOption.FromSubject, assign_title_from: MailMetadataTitleOption.FromSubject,
assign_owner_from_rule: true, assign_owner_from_rule: true,
stop_processing: false,
}, },
{ {
name: 'Mail Rule 2', name: 'Mail Rule 2',
@@ -53,7 +52,6 @@ const mail_rules = [
action: MailAction.Delete, action: MailAction.Delete,
assign_title_from: MailMetadataTitleOption.FromSubject, assign_title_from: MailMetadataTitleOption.FromSubject,
assign_owner_from_rule: true, assign_owner_from_rule: true,
stop_processing: false,
}, },
{ {
name: 'Mail Rule 3', name: 'Mail Rule 3',
@@ -73,7 +71,6 @@ const mail_rules = [
action: MailAction.Flag, action: MailAction.Flag,
assign_title_from: MailMetadataTitleOption.FromSubject, assign_title_from: MailMetadataTitleOption.FromSubject,
assign_owner_from_rule: false, assign_owner_from_rule: false,
stop_processing: false,
}, },
] ]

View File

@@ -416,25 +416,4 @@ describe('ConsumerStatusService', () => {
websocketStatusService.disconnect() websocketStatusService.disconnect()
expect(deleted).toBeTruthy() expect(deleted).toBeTruthy()
}) })
it('should trigger updated subject on document updated', () => {
let updated = false
websocketStatusService.onDocumentUpdated().subscribe((data) => {
updated = true
expect(data.document_id).toEqual(12)
})
websocketStatusService.connect()
server.send({
type: WebsocketStatusType.DOCUMENT_UPDATED,
data: {
document_id: 12,
modified: '2026-02-17T00:00:00Z',
owner_id: 1,
},
})
websocketStatusService.disconnect()
expect(updated).toBeTruthy()
})
}) })

View File

@@ -2,7 +2,6 @@ import { Injectable, inject } from '@angular/core'
import { Subject } from 'rxjs' import { Subject } from 'rxjs'
import { environment } from 'src/environments/environment' import { environment } from 'src/environments/environment'
import { User } from '../data/user' import { User } from '../data/user'
import { WebsocketDocumentUpdatedMessage } from '../data/websocket-document-updated-message'
import { WebsocketDocumentsDeletedMessage } from '../data/websocket-documents-deleted-message' import { WebsocketDocumentsDeletedMessage } from '../data/websocket-documents-deleted-message'
import { WebsocketProgressMessage } from '../data/websocket-progress-message' import { WebsocketProgressMessage } from '../data/websocket-progress-message'
import { SettingsService } from './settings.service' import { SettingsService } from './settings.service'
@@ -10,7 +9,6 @@ import { SettingsService } from './settings.service'
export enum WebsocketStatusType { export enum WebsocketStatusType {
STATUS_UPDATE = 'status_update', STATUS_UPDATE = 'status_update',
DOCUMENTS_DELETED = 'documents_deleted', DOCUMENTS_DELETED = 'documents_deleted',
DOCUMENT_UPDATED = 'document_updated',
} }
// see ProgressStatusOptions in src/documents/plugins/helpers.py // see ProgressStatusOptions in src/documents/plugins/helpers.py
@@ -105,8 +103,6 @@ export class WebsocketStatusService {
private documentConsumptionFinishedSubject = new Subject<FileStatus>() private documentConsumptionFinishedSubject = new Subject<FileStatus>()
private documentConsumptionFailedSubject = new Subject<FileStatus>() private documentConsumptionFailedSubject = new Subject<FileStatus>()
private documentDeletedSubject = new Subject<boolean>() private documentDeletedSubject = new Subject<boolean>()
private documentUpdatedSubject =
new Subject<WebsocketDocumentUpdatedMessage>()
private connectionStatusSubject = new Subject<boolean>() private connectionStatusSubject = new Subject<boolean>()
private get(taskId: string, filename?: string) { private get(taskId: string, filename?: string) {
@@ -173,10 +169,7 @@ export class WebsocketStatusService {
data: messageData, data: messageData,
}: { }: {
type: WebsocketStatusType type: WebsocketStatusType
data: data: WebsocketProgressMessage | WebsocketDocumentsDeletedMessage
| WebsocketProgressMessage
| WebsocketDocumentsDeletedMessage
| WebsocketDocumentUpdatedMessage
} = JSON.parse(ev.data) } = JSON.parse(ev.data)
switch (type) { switch (type) {
@@ -184,12 +177,6 @@ export class WebsocketStatusService {
this.documentDeletedSubject.next(true) this.documentDeletedSubject.next(true)
break break
case WebsocketStatusType.DOCUMENT_UPDATED:
this.handleDocumentUpdated(
messageData as WebsocketDocumentUpdatedMessage
)
break
case WebsocketStatusType.STATUS_UPDATE: case WebsocketStatusType.STATUS_UPDATE:
this.handleProgressUpdate(messageData as WebsocketProgressMessage) this.handleProgressUpdate(messageData as WebsocketProgressMessage)
break break
@@ -197,11 +184,7 @@ export class WebsocketStatusService {
} }
} }
private canViewMessage(messageData: { private canViewMessage(messageData: WebsocketProgressMessage): boolean {
owner_id?: number
users_can_view?: number[]
groups_can_view?: number[]
}): boolean {
// see paperless.consumers.StatusConsumer._can_view // see paperless.consumers.StatusConsumer._can_view
const user: User = this.settingsService.currentUser const user: User = this.settingsService.currentUser
return ( return (
@@ -261,15 +244,6 @@ export class WebsocketStatusService {
} }
} }
handleDocumentUpdated(messageData: WebsocketDocumentUpdatedMessage) {
// fallback if backend didn't restrict message
if (!this.canViewMessage(messageData)) {
return
}
this.documentUpdatedSubject.next(messageData)
}
fail(status: FileStatus, message: string) { fail(status: FileStatus, message: string) {
status.message = message status.message = message
status.phase = FileStatusPhase.FAILED status.phase = FileStatusPhase.FAILED
@@ -323,10 +297,6 @@ export class WebsocketStatusService {
return this.documentDeletedSubject return this.documentDeletedSubject
} }
onDocumentUpdated() {
return this.documentUpdatedSubject
}
onConnectionStatus() { onConnectionStatus() {
return this.connectionStatusSubject.asObservable() return this.connectionStatusSubject.asObservable()
} }

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