Compare commits

...

34 Commits

Author SHA1 Message Date
shamoon
eda0e61cec Update mypy baseline 2026-02-16 09:56:59 -08:00
shamoon
426c0a8974 Chore: typing fixes 2026-02-16 09:54:06 -08:00
shamoon
e54b69f7c4 Update mypy baseline 2026-02-16 09:39:07 -08:00
shamoon
4884b67714 Fix more typing failures 2026-02-16 09:37:33 -08:00
GitHub Actions
2ccb315291 Auto translate strings 2026-02-16 17:33:37 +00:00
shamoon
02896a15fd Fix: only pass user to SerializerWithPerms serializers 2026-02-16 09:31:33 -08:00
shamoon
d8e07b8d84 Fix typing issue 2026-02-16 09:17:20 -08:00
GitHub Actions
c08d3aa962 Auto translate strings 2026-02-16 17:05:24 +00:00
shamoon
be4e29a19c Merge branch 'main' into dev 2026-02-16 09:01:19 -08:00
github-actions[bot]
5c1bbcd06d Documentation: Add v2.20.7 changelog (#12100)
---------

Co-authored-by: github-actions <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: shamoon <4887959+shamoon@users.noreply.github.com>
2026-02-16 09:00:19 -08:00
shamoon
bc734798e3 Add permissions 2026-02-16 08:12:45 -08:00
shamoon
5ecbfc9df7 Split build vs deploy docs 2026-02-16 07:46:09 -08:00
shamoon
e63b62d531 Bump version to 2.20.7 2026-02-16 07:26:59 -08:00
shamoon
dd3ec83569 Fix: correct user dropdown button icon styling (#12092) 2026-02-16 07:26:52 -08:00
shamoon
7a23356898 Merge branch 'release/v2.20.x' 2026-02-16 07:24:37 -08:00
shamoon
afaf39e43a Fix/GHSA-x395-6h48-wr8v 2026-02-16 00:02:15 -08:00
GitHub Actions
84163f4197 Auto translate strings 2026-02-15 00:39:28 +00:00
shamoon
ae234d15c8 Chore: track down the margin + &nbsp; patterns 2026-02-14 16:37:35 -08:00
shamoon
118afa82b3 Fix: correct user dropdown button icon styling (#12092) 2026-02-14 08:15:19 -08:00
shamoon
56d1b5677a Fix migration conflict 2026-02-13 09:47:15 -08:00
GitHub Actions
6622349b5f Auto translate strings 2026-02-13 17:37:56 +00:00
shamoon
b050fab77f Enhancement: consolidate management lists into document attributes section (#12045) 2026-02-13 09:36:12 -08:00
shamoon
a467df0755 Enhancement: option to stop processing further mail rules (#12053) 2026-02-13 17:33:29 +00:00
GitHub Actions
728c5ea07b Auto translate strings 2026-02-13 16:40:48 +00:00
shamoon
4f2e16fdc7 Chore: Pngx pdf viewer fixes (#12083) 2026-02-13 08:38:49 -08:00
Trenton H
8db1c4e08b Breaking: Remove pybzar as a barcode reader (#12065) 2026-02-13 08:14:00 -08:00
dependabot[bot]
1c70d3f8a8 Chore(deps): Bump pillow from 12.1.0 to 12.1.1 (#12064)
Bumps [pillow](https://github.com/python-pillow/Pillow) from 12.1.0 to 12.1.1.
- [Release notes](https://github.com/python-pillow/Pillow/releases)
- [Changelog](https://github.com/python-pillow/Pillow/blob/main/CHANGES.rst)
- [Commits](https://github.com/python-pillow/Pillow/compare/12.1.0...12.1.1)

---
updated-dependencies:
- dependency-name: pillow
  dependency-version: 12.1.1
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-11 10:59:00 -08:00
dependabot[bot]
491b5a4355 Chore(deps): Bump cryptography from 46.0.3 to 46.0.5 (#12060)
Bumps [cryptography](https://github.com/pyca/cryptography) from 46.0.3 to 46.0.5.
- [Changelog](https://github.com/pyca/cryptography/blob/main/CHANGELOG.rst)
- [Commits](https://github.com/pyca/cryptography/compare/46.0.3...46.0.5)

---
updated-dependencies:
- dependency-name: cryptography
  dependency-version: 46.0.5
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-02-11 10:23:31 -08:00
GitHub Actions
d41d4e12bf Auto translate strings 2026-02-11 01:03:46 +00:00
shamoon
775e32bf3b Fix(dev): history causing infinite requests (#12059) 2026-02-10 17:01:47 -08:00
Trenton H
e8e027abc0 Chore: Optimizes the integer fields for choice types mostly, while leaving plenty of room to grow (#12057) 2026-02-10 15:11:44 -08:00
shamoon
5b45b89d35 Performance fix: use subqueries to improve object retrieval in large installs (#11950) 2026-02-05 08:46:32 -08:00
shamoon
5b9bb147cf Tweakhancement: tweak bulk delete text (#11967) 2026-02-01 12:16:30 -08:00
shamoon
c278f52fb2 Fix: fix broken docker create_classifier command in 2.20.6 (#11965) 2026-02-01 12:09:52 -08:00
122 changed files with 3585 additions and 2047 deletions

View File

@@ -64,8 +64,6 @@ ARG RUNTIME_PACKAGES="\
libmagic1 \
media-types \
zlib1g \
# Barcode splitter
libzbar0 \
poppler-utils \
htop \
sudo"

View File

@@ -69,7 +69,6 @@ updates:
patterns:
- "ocrmypdf"
- "pdf2image"
- "pyzbar"
- "zxing-cpp"
- "tika-client"
- "gotenberg-client"

View File

@@ -55,7 +55,7 @@ jobs:
run: |
sudo apt-get update -qq
sudo apt-get install -qq --no-install-recommends \
unpaper tesseract-ocr imagemagick ghostscript libzbar0 poppler-utils
unpaper tesseract-ocr imagemagick ghostscript poppler-utils
- name: Configure ImageMagick
run: |
sudo cp docker/rootfs/etc/ImageMagick-6/paperless-policy.xml /etc/ImageMagick-6/policy.xml

View File

@@ -26,8 +26,8 @@ permissions:
pages: write
id-token: write
env:
DEFAULT_UV_VERSION: "0.9.x"
DEFAULT_PYTHON_VERSION: "3.11"
DEFAULT_UV_VERSION: "0.10.x"
DEFAULT_PYTHON_VERSION: "3.12"
jobs:
build:
name: Build Documentation

View File

@@ -8,8 +8,8 @@ concurrency:
group: release-${{ github.ref }}
cancel-in-progress: false
env:
DEFAULT_UV_VERSION: "0.9.x"
DEFAULT_PYTHON_VERSION: "3.11"
DEFAULT_UV_VERSION: "0.10.x"
DEFAULT_PYTHON_VERSION: "3.12"
jobs:
wait-for-docker:
name: Wait for Docker Build

View File

@@ -20,7 +20,6 @@ src/documents/admin.py:0: error: Skipping analyzing "auditlog.models": module is
src/documents/admin.py:0: error: Skipping analyzing "treenode.admin": module is installed, but missing library stubs or py.typed marker [import-untyped]
src/documents/barcodes.py:0: error: "Image" has no attribute "filename" [attr-defined]
src/documents/barcodes.py:0: error: Cannot find implementation or library stub for module named "zxingcpp" [import-not-found]
src/documents/barcodes.py:0: error: Skipping analyzing "pyzbar": module is installed, but missing library stubs or py.typed marker [import-untyped]
src/documents/bulk_download.py:0: error: Return type "None" of "add_document" incompatible with return type "Never" in supertype "BulkArchiveStrategy" [override]
src/documents/bulk_download.py:0: error: Return type "None" of "add_document" incompatible with return type "Never" in supertype "BulkArchiveStrategy" [override]
src/documents/bulk_download.py:0: error: Return type "None" of "add_document" incompatible with return type "Never" in supertype "BulkArchiveStrategy" [override]
@@ -446,7 +445,6 @@ 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 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 "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]
@@ -561,8 +559,6 @@ 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 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]
@@ -939,7 +935,6 @@ src/documents/tests/test_barcodes.py:0: error: Argument 1 to "get_reader" of "Ge
src/documents/tests/test_barcodes.py:0: error: Argument 3 to "BarcodePlugin" has incompatible type "DummyProgressManager"; expected "ProgressManager" [arg-type]
src/documents/tests/test_barcodes.py:0: error: Argument 3 to "BarcodePlugin" has incompatible type "DummyProgressManager"; expected "ProgressManager" [arg-type]
src/documents/tests/test_barcodes.py:0: error: Argument 3 to "BarcodePlugin" has incompatible type "DummyProgressManager"; expected "ProgressManager" [arg-type]
src/documents/tests/test_barcodes.py:0: error: Cannot find implementation or library stub for module named "zxingcpp" [import-not-found]
src/documents/tests/test_barcodes.py:0: error: Function is missing a return type annotation [no-untyped-def]
src/documents/tests/test_barcodes.py:0: error: Incompatible types in assignment (expression has type "Path", variable has type "str") [assignment]
src/documents/tests/test_barcodes.py:0: error: Item "None" of "ApplicationConfiguration | None" has no attribute "barcode_string" [union-attr]
@@ -1575,8 +1570,6 @@ 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 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]
@@ -1830,7 +1823,6 @@ src/paperless/checks.py:0: error: Argument 2 to "path_check" has incompatible ty
src/paperless/checks.py:0: error: Function is missing a return type annotation [no-untyped-def]
src/paperless/checks.py:0: error: Function is missing a return type annotation [no-untyped-def]
src/paperless/checks.py:0: error: Function is missing a return type annotation [no-untyped-def]
src/paperless/checks.py:0: error: Function is missing a return type annotation [no-untyped-def]
src/paperless/checks.py:0: error: Function is missing a type annotation [no-untyped-def]
src/paperless/checks.py:0: error: Function is missing a type annotation [no-untyped-def]
src/paperless/checks.py:0: error: Function is missing a type annotation [no-untyped-def]

View File

@@ -154,8 +154,6 @@ ARG RUNTIME_PACKAGES="\
libmagic1 \
media-types \
zlib1g \
# Barcode splitter
libzbar0 \
poppler-utils"
# Install basic runtime packages.

View File

@@ -431,8 +431,10 @@ 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)
provided. The template is provided as a string, potentially multiline, and rendered into a single line.
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
with more complex logic.
In addition, a limited `document` object is available for advanced templates.
This object includes common metadata fields such as `id`, `pk`, `title`, `content`, `page_count`, `created`, `added`, `modified`, `mime_type`,
`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
@@ -774,7 +776,6 @@ At this time, the library utilized for detection of barcodes supports the follow
- QR Code
- SQ Code
You may check for updates on the [zbar library homepage](https://github.com/mchehab/zbar).
For usage in Paperless, the type of barcode does not matter, only the contents of it.
For how to enable barcode usage, see [the configuration](configuration.md#barcodes).

View File

@@ -1,5 +1,23 @@
# 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
### Bug Fixes

View File

@@ -1222,14 +1222,6 @@ using Python's `re.match()`, which anchors at the start of the filename.
The default ignores are `[.stfolder, .stversions, .localized, @eaDir, .Spotlight-V100, .Trashes, __MACOSX]` and cannot be overridden.
#### [`PAPERLESS_CONSUMER_BARCODE_SCANNER=<string>`](#PAPERLESS_CONSUMER_BARCODE_SCANNER) {#PAPERLESS_CONSUMER_BARCODE_SCANNER}
: Sets the barcode scanner used for barcode functionality.
Currently, "PYZBAR" (the default) or "ZXING" might be selected.
If you have problems that your Barcodes/QR-Codes are not detected
(especially with bad scan quality and/or small codes), try the other one.
#### [`PAPERLESS_PRE_CONSUME_SCRIPT=<filename>`](#PAPERLESS_PRE_CONSUME_SCRIPT) {#PAPERLESS_PRE_CONSUME_SCRIPT}
: After some initial validation, Paperless can trigger an arbitrary

View File

@@ -23,3 +23,28 @@ separating the directory ignore from the file ignore.
Document and thumbnail encryption is no longer supported. This was previously deprecated in [paperless-ng 0.9.3](https://github.com/paperless-ngx/paperless-ngx/blob/dev/docs/changelog.md#paperless-ng-093)
Users must decrypt their document using the `decrypt_documents` command before upgrading.
## Barcode Scanner Changes
Support for [pyzbar](https://github.com/NaturalHistoryMuseum/pyzbar) has been removed. The underlying libzbar library has
seen no updates in 16 years and is largely unmaintained, and the pyzbar Python wrapper last saw a release in March 2022. In
practice, pyzbar struggled with barcode detection reliability, particularly on skewed, low-contrast, or partially
obscured barcodes. [zxing-cpp](https://github.com/zxing-cpp/zxing-cpp) is actively maintained, significantly more
reliable at finding barcodes, and now ships pre-built wheels for both x86_64 and arm64, removing the need to build the library.
The `CONSUMER_BARCODE_SCANNER` setting has been removed. zxing-cpp is now the only backend.
### Summary
| Old Setting | New Setting | Notes |
| -------------------------- | ----------- | --------------------------------- |
| `CONSUMER_BARCODE_SCANNER` | _Removed_ | zxing-cpp is now the only backend |
### Action Required
- If you were already using `CONSUMER_BARCODE_SCANNER=ZXING`, simply remove the setting.
- If you had `CONSUMER_BARCODE_SCANNER=PYZBAR` or were using the default, no functional changes are needed beyond
removing the setting. zxing-cpp supports all the same barcode formats and you should see improved detection
reliability.
- The `libzbar0` / `libzbar-dev` system packages are no longer required and can be removed from any custom Docker
images or host installations.

View File

@@ -207,13 +207,12 @@ are released, dependency support is confirmed, etc.
- `libpq-dev` for PostgreSQL
- `libmagic-dev` for mime type detection
- `mariadb-client` for MariaDB compile time
- `libzbar0` for barcode detection
- `poppler-utils` for barcode detection
Use this list for your preferred package management:
```
python3 python3-pip python3-dev imagemagick fonts-liberation gnupg libpq-dev default-libmysqlclient-dev pkg-config libmagic-dev libzbar0 poppler-utils
python3 python3-pip python3-dev imagemagick fonts-liberation gnupg libpq-dev default-libmysqlclient-dev pkg-config libmagic-dev poppler-utils
```
These dependencies are required for OCRmyPDF, which is used for text

View File

@@ -1,6 +1,6 @@
[project]
name = "paperless-ngx"
version = "2.20.6"
version = "2.20.7"
description = "A community-supported supercharged document management system: scan, index and archive all your physical documents"
readme = "README.md"
requires-python = ">=3.10"
@@ -68,7 +68,6 @@ dependencies = [
"python-gnupg~=0.5.4",
"python-ipware~=3.0.0",
"python-magic~=0.4.27",
"pyzbar~=0.1.9",
"rapidfuzz~=3.14.0",
"redis[hiredis]~=5.2.1",
"regex>=2025.9.18",
@@ -81,7 +80,7 @@ dependencies = [
"watchfiles>=1.1.1",
"whitenoise~=6.11",
"whoosh-reloaded>=2.7.5",
"zxing-cpp~=2.3.0",
"zxing-cpp~=3.0.0",
]
optional-dependencies.mariadb = [
@@ -172,10 +171,6 @@ psycopg-c = [
{ url = "https://github.com/paperless-ngx/builder/releases/download/psycopg-trixie-3.3.0/psycopg_c-3.3.0-cp312-cp312-linux_x86_64.whl", marker = "sys_platform == 'linux' and platform_machine == 'x86_64' and python_version == '3.12'" },
{ url = "https://github.com/paperless-ngx/builder/releases/download/psycopg-trixie-3.3.0/psycopg_c-3.3.0-cp312-cp312-linux_aarch64.whl", marker = "sys_platform == 'linux' and platform_machine == 'aarch64' and python_version == '3.12'" },
]
zxing-cpp = [
{ url = "https://github.com/paperless-ngx/builder/releases/download/zxing-2.3.0/zxing_cpp-2.3.0-cp312-cp312-linux_x86_64.whl", marker = "sys_platform == 'linux' and platform_machine == 'x86_64' and python_version == '3.12'" },
{ url = "https://github.com/paperless-ngx/builder/releases/download/zxing-2.3.0/zxing_cpp-2.3.0-cp312-cp312-linux_aarch64.whl", marker = "sys_platform == 'linux' and platform_machine == 'aarch64' and python_version == '3.12'" },
]
torch = [
{ index = "pytorch-cpu" },

View File

@@ -52,11 +52,11 @@ test('dashboard saved view document links', async ({ page }) => {
test('test slim sidebar', async ({ page }) => {
await page.routeFromHAR(REQUESTS_HAR1, { notFound: 'fallback' })
await page.goto('/dashboard')
await page.locator('#sidebarMenu').getByRole('button').click()
await page.locator('.sidebar-slim-toggler').click()
await expect(
page.getByRole('link', { name: 'Dashboard' }).getByText('Dashboard')
).toBeHidden()
await page.locator('#sidebarMenu').getByRole('button').click()
await page.locator('.sidebar-slim-toggler').click()
await expect(
page.getByRole('link', { name: 'Dashboard' }).getByText('Dashboard')
).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.goto('/dashboard')
await expect(
page.getByRole('link', { name: 'Correspondents' })
page.getByRole('link', { name: 'Attributes' })
).not.toBeAttached()
await page.goto('/correspondents')
await page.goto('/attributes/correspondents')
await expect(page.locator('body')).toHaveText(
/You don't have permissions to do that/i
)
@@ -44,8 +44,10 @@ test('should not allow user to view correspondents', async ({ page }) => {
test('should not allow user to view tags', async ({ page }) => {
await page.routeFromHAR(REQUESTS_HAR, { notFound: 'fallback' })
await page.goto('/dashboard')
await expect(page.getByRole('link', { name: 'Tags' })).not.toBeAttached()
await page.goto('/tags')
await expect(
page.getByRole('link', { name: 'Attributes' })
).not.toBeAttached()
await page.goto('/attributes/tags')
await expect(page.locator('body')).toHaveText(
/You don't have permissions to do that/i
)
@@ -55,9 +57,9 @@ test('should not allow user to view document types', async ({ page }) => {
await page.routeFromHAR(REQUESTS_HAR, { notFound: 'fallback' })
await page.goto('/dashboard')
await expect(
page.getByRole('link', { name: 'Document Types' })
page.getByRole('link', { name: 'Attributes' })
).not.toBeAttached()
await page.goto('/documenttypes')
await page.goto('/attributes/documenttypes')
await expect(page.locator('body')).toHaveText(
/You don't have permissions to do that/i
)
@@ -67,9 +69,9 @@ test('should not allow user to view storage paths', async ({ page }) => {
await page.routeFromHAR(REQUESTS_HAR, { notFound: 'fallback' })
await page.goto('/dashboard')
await expect(
page.getByRole('link', { name: 'Storage Paths' })
page.getByRole('link', { name: 'Attributes' })
).not.toBeAttached()
await page.goto('/storagepaths')
await page.goto('/attributes/storagepaths')
await expect(page.locator('body')).toHaveText(
/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",
"version": "2.20.6",
"version": "2.20.7",
"scripts": {
"preinstall": "npx only-allow pnpm",
"ng": "ng",

View File

@@ -11,13 +11,9 @@ import { DashboardComponent } from './components/dashboard/dashboard.component'
import { DocumentAsnComponent } from './components/document-asn/document-asn.component'
import { DocumentDetailComponent } from './components/document-detail/document-detail.component'
import { DocumentListComponent } from './components/document-list/document-list.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 { DocumentAttributesComponent } from './components/manage/document-attributes/document-attributes.component'
import { MailComponent } from './components/manage/mail/mail.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 { NotFoundComponent } from './components/not-found/not-found.component'
import { DirtyDocGuard } from './guards/dirty-doc.guard'
@@ -106,52 +102,76 @@ export const routes: Routes = [
},
},
{
path: 'tags',
component: TagListComponent,
path: 'attributes',
component: DocumentAttributesComponent,
canActivate: [PermissionsGuard],
data: {
requiredPermission: {
action: PermissionAction.View,
type: PermissionType.Tag,
},
componentName: 'TagListComponent',
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: 'documenttypes',
component: DocumentTypeListComponent,
path: 'attributes/:section',
component: DocumentAttributesComponent,
canActivate: [PermissionsGuard],
data: {
requiredPermission: {
action: PermissionAction.View,
type: PermissionType.DocumentType,
},
componentName: 'DocumentTypeListComponent',
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',
redirectTo: '/attributes/tags',
pathMatch: 'full',
},
{
path: 'correspondents',
component: CorrespondentListComponent,
canActivate: [PermissionsGuard],
data: {
requiredPermission: {
action: PermissionAction.View,
type: PermissionType.Correspondent,
},
componentName: 'CorrespondentListComponent',
},
redirectTo: '/attributes/correspondents',
pathMatch: 'full',
},
{
path: 'documenttypes',
redirectTo: '/attributes/documenttypes',
pathMatch: 'full',
},
{
path: 'storagepaths',
component: StoragePathListComponent,
canActivate: [PermissionsGuard],
data: {
requiredPermission: {
action: PermissionAction.View,
type: PermissionType.StoragePath,
},
componentName: 'StoragePathListComponent',
},
redirectTo: '/attributes/storagepaths',
pathMatch: 'full',
},
{
path: 'logs',
@@ -239,15 +259,8 @@ export const routes: Routes = [
},
{
path: 'customfields',
component: CustomFieldsComponent,
canActivate: [PermissionsGuard],
data: {
requiredPermission: {
action: PermissionAction.View,
type: PermissionType.CustomField,
},
componentName: 'CustomFieldsComponent',
},
redirectTo: '/attributes/customfields',
pathMatch: 'full',
},
{
path: 'workflows',

View File

@@ -195,8 +195,8 @@ export class AppComponent implements OnInit, OnDestroy {
},
{
anchorId: 'tour.tags',
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: '/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.`,
route: '/attributes/tags',
backdropConfig: {
offset: 0,
},

View File

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

View File

@@ -6,10 +6,10 @@
>
<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">
<i-bs name="x"></i-bs>&nbsp;<ng-container i18n>Clear selection</ng-container>
<i-bs name="x" class="me-1"></i-bs><ng-container i18n>Clear selection</ng-container>
</button>
<button 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"></i-bs>&nbsp;{{dismissButtonText}}
<i-bs name="check2-all" class="me-1"></i-bs>{{dismissButtonText}}
</button>
<div class="form-inline d-flex align-items-center">
<div class="input-group input-group-sm flex-fill w-auto flex-nowrap">
@@ -113,12 +113,12 @@
<td scope="row">
<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 }">
<i-bs name="check"></i-bs>&nbsp;<ng-container i18n>Dismiss</ng-container>
<i-bs name="check" class="me-1"></i-bs><ng-container i18n>Dismiss</ng-container>
</button>
<ng-container *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }">
@if (task.related_document) {
<button class="btn btn-sm btn-outline-primary" (click)="dismissAndGo(task); $event.stopPropagation();">
<i-bs name="file-text"></i-bs>&nbsp;<ng-container i18n>Open Document</ng-container>
<i-bs name="file-text" class="me-1"></i-bs><ng-container i18n>Open Document</ng-container>
</button>
}
</ng-container>

View File

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

View File

@@ -11,7 +11,7 @@
<h4 class="d-flex">
<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 }">
<i-bs name="plus-circle"></i-bs>&nbsp;<ng-container i18n>Add User</ng-container>
<i-bs name="plus-circle" class="me-1"></i-bs><ng-container i18n>Add User</ng-container>
</button>
</h4>
<ul class="list-group">
@@ -32,10 +32,10 @@
<div class="col">
<div class="btn-group">
<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"></i-bs>&nbsp;<ng-container i18n>Edit</ng-container>
<i-bs width="1em" height="1em" name="pencil" class="me-1"></i-bs><ng-container i18n>Edit</ng-container>
</button>
<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"></i-bs>&nbsp;<ng-container i18n>Delete</ng-container>
<i-bs width="1em" height="1em" name="trash" class="me-1"></i-bs><ng-container i18n>Delete</ng-container>
</button>
</div>
</div>
@@ -49,7 +49,7 @@
<h4 class="mt-4 d-flex">
<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 }">
<i-bs name="plus-circle"></i-bs>&nbsp;<ng-container i18n>Add Group</ng-container>
<i-bs name="plus-circle" class="me-1"></i-bs><ng-container i18n>Add Group</ng-container>
</button>
</h4>
<ul class="list-group">
@@ -70,10 +70,10 @@
<div class="col">
<div class="btn-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"></i-bs>&nbsp;<ng-container i18n>Edit</ng-container>
<i-bs width="1em" height="1em" name="pencil" class="me-1"></i-bs><ng-container i18n>Edit</ng-container>
</button>
<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"></i-bs>&nbsp;<ng-container i18n>Delete</ng-container>
<i-bs width="1em" height="1em" name="trash" class="me-1"></i-bs><ng-container i18n>Delete</ng-container>
</button>
</div>
</div>

View File

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

View File

@@ -177,6 +177,15 @@ 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 {
font-size: 0.75rem;
text-transform: uppercase;
@@ -281,7 +290,7 @@ main {
.navbar .dropdown-menu {
font-size: 0.875rem; // body size
a i-bs {
a i-bs, button i-bs {
opacity: 0.6;
}
}

View File

@@ -28,7 +28,10 @@ import {
DjangoMessagesService,
} from 'src/app/services/django-messages.service'
import { OpenDocumentsService } from 'src/app/services/open-documents.service'
import { PermissionsService } from 'src/app/services/permissions.service'
import {
PermissionType,
PermissionsService,
} from 'src/app/services/permissions.service'
import { RemoteVersionService } from 'src/app/services/rest/remote-version.service'
import { SavedViewService } from 'src/app/services/rest/saved-view.service'
import { SearchService } from 'src/app/services/rest/search.service'
@@ -258,7 +261,7 @@ describe('AppFrameComponent', () => {
const toastSpy = jest.spyOn(toastService, 'showError')
component.toggleSlimSidebar()
httpTestingController
.expectOne(`${environment.apiBaseUrl}ui_settings/`)
.match(`${environment.apiBaseUrl}ui_settings/`)[0]
.flush('error', {
status: 500,
statusText: 'error',
@@ -373,4 +376,103 @@ describe('AppFrameComponent', () => {
it('should call maybeRefreshDocumentCounts after saved views reload', () => {
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 { Document } from 'src/app/data/document'
import { SavedView } from 'src/app/data/saved-view'
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
import { CollapsibleSection, SETTINGS_KEYS } from 'src/app/data/ui-settings'
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
import { ComponentCanDeactivate } from 'src/app/guards/dirty-doc.guard'
import { DocumentTitlePipe } from 'src/app/pipes/document-title.pipe'
@@ -141,11 +141,20 @@ export class AppFrameComponent
toggleSlimSidebar(): void {
this.slimSidebarAnimating = true
this.slimSidebarEnabled = !this.slimSidebarEnabled
if (this.slimSidebarEnabled) {
this.attributesSectionsCollapsed = true
}
setTimeout(() => {
this.slimSidebarAnimating = false
}, 200) // slightly longer than css animation for slim sidebar
}
toggleAttributesSections(event?: Event): void {
event?.preventDefault()
event?.stopPropagation()
this.attributesSectionsCollapsed = !this.attributesSectionsCollapsed
}
get versionString(): string {
return `${environment.appTitle} v${this.settingsService.get(SETTINGS_KEYS.VERSION)}${environment.tag === 'prod' ? '' : ` #${environment.tag}`}`
}
@@ -167,6 +176,31 @@ 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 {
return this.settingsService.get(SETTINGS_KEYS.SLIM_SIDEBAR)
}
@@ -186,6 +220,31 @@ 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 {
return this.settingsService.get(SETTINGS_KEYS.AI_ENABLED)
}

View File

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

View File

@@ -20,7 +20,7 @@
@for (docId of value; track docId) {
@if (getDocumentTitle(docId)) {
<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"></i-bs>&nbsp;<span>{{ getDocumentTitle(docId) }}</span>
<i-bs width="0.9em" height="0.9em" name="file-text" class="me-1"></i-bs><span>{{ getDocumentTitle(docId) }}</span>
</a>
}
}

View File

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

View File

@@ -1,8 +1,7 @@
@if (useDropdown) {
<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">
<i-bs name="{{icon}}"></i-bs>
<div class="d-none d-sm-inline">&nbsp;{{title}}</div>
<i-bs name="{{icon}}"></i-bs><div class="d-none d-sm-inline ms-1">{{title}}</div>
@if (isActive) {
<pngx-clearable-badge [selected]="isActive" (cleared)="reset()"></pngx-clearable-badge>
}

View File

@@ -1,7 +1,6 @@
<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">
<i-bs width="1em" height="1em" name="calendar-event-fill"></i-bs>
<div class="d-none d-sm-inline">&nbsp;{{title}}</div>
<i-bs width="1em" height="1em" name="calendar-event-fill"></i-bs><div class="d-none d-sm-inline ms-1">{{title}}</div>
<pngx-clearable-badge [selected]="isActive" (cleared)="reset()"></pngx-clearable-badge><span class="visually-hidden">selected</span>
</button>
<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) {
@case (CustomFieldDataType.Select) {
<button type="button" class="btn btn-sm btn-primary my-2" (click)="addSelectOption()">
<span i18n>Add option</span>&nbsp;<i-bs name="plus-circle"></i-bs>
<span i18n>Add option</span><i-bs class="ms-1" name="plus-circle"></i-bs>
</button>
<div formArrayName="select_options">
@for (option of objectForm.controls.extra_data.controls.select_options.controls; track option; let i = $index) {

View File

@@ -9,19 +9,24 @@
</div>
<div class="modal-body">
<div class="row">
<div class="col-md-4">
<div class="col-md-6">
<pngx-input-text [horizontal]="true" i18n-title title="Name" formControlName="name" [error]="error?.name" autocomplete="off"></pngx-input-text>
</div>
<div class="col-md-3">
<div class="col-md-4">
<pngx-input-select [horizontal]="true" i18n-title title="Account" [items]="accounts" formControlName="account"></pngx-input-select>
</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">
<pngx-input-switch [horizontal]="true" i18n-title title="Enabled" formControlName="enabled"></pngx-input-switch>
</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"/>
<div class="row">
<p class="small" i18n>Paperless will only process mails that match <em>all</em> of the criteria specified below.</p>

View File

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

View File

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

View File

@@ -1,7 +1,6 @@
<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">
<i-bs name="{{icon}}"></i-bs>
<div class="d-none d-sm-inline">&nbsp;{{title}}</div>
<i-bs name="{{icon}}"></i-bs><div class="d-none d-sm-inline ms-1">{{title}}</div>
@if (!editing && selectionModel.totalCount > 0) {
<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>
@if (removable) {
<button type="button" class="btn btn-sm btn-danger position-absolute left-0" (click)="removed.emit(this)">
<i-bs name="x"></i-bs>&nbsp;<ng-container i18n>Remove</ng-container>
<i-bs name="x" class="me-1"></i-bs><ng-container i18n>Remove</ng-container>
</button>
}
</div>

View File

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

View File

@@ -9,7 +9,7 @@
}
@if (removable) {
<button type="button" class="btn btn-sm btn-danger position-absolute left-0" (click)="removed.emit(this)">
<i-bs name="x"></i-bs>&nbsp;<ng-container i18n>Remove</ng-container>
<i-bs name="x" class="me-1"></i-bs><ng-container i18n>Remove</ng-container>
</button>
}
</div>
@@ -44,11 +44,11 @@
}
@if (document.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"></i-bs>&nbsp;<span>{{document.title}}</span>
<i-bs width="0.9em" height="0.9em" name="file-text" class="me-1"></i-bs><span>{{document.title}}</span>
</a>
} @else {
<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"></i-bs>&nbsp;<span i18n>Not found</span>
<i-bs width="0.9em" height="0.9em" name="exclamation-triangle-fill" class="me-1"></i-bs><span i18n>Not found</span>
</span>
}
</div>

View File

@@ -5,7 +5,7 @@
<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()">
<i-bs name="plus-circle"></i-bs>&nbsp;<ng-container i18n>Add</ng-container>
<i-bs name="plus-circle" class="me-1"></i-bs><ng-container i18n>Add</ng-container>
</button>
</div>
<div class="position-relative">

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -65,6 +65,13 @@ describe('PngxPdfViewerComponent', () => {
const pageSpy = jest.fn()
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.zoom = PdfZoomLevel.Two
component.rotation = 90
@@ -81,7 +88,6 @@ describe('PngxPdfViewerComponent', () => {
page: new SimpleChange(undefined, 2, false),
})
const viewer = (component as any).pdfViewer as PDFViewer
expect(viewer.pagesRotation).toBe(90)
expect(viewer.currentPageNumber).toBe(2)
expect(pageSpy).toHaveBeenCalledWith(2)
@@ -196,6 +202,8 @@ describe('PngxPdfViewerComponent', () => {
const scaleSpy = jest.spyOn(component as any, 'applyViewerState')
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({
src: new SimpleChange(undefined, 'test.pdf', true),
zoomScale: new SimpleChange(
@@ -211,6 +219,25 @@ describe('PngxPdfViewerComponent', () => {
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', () => {
const applySpy = jest.spyOn(component as any, 'applyViewerState')
;(component as any).hasLoaded = true

View File

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

View File

@@ -1,7 +1,6 @@
<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">
<i-bs name="person-fill-lock"></i-bs>
<div class="d-none d-sm-inline">&nbsp;{{title}}</div>
<i-bs name="person-fill-lock"></i-bs><div class="d-none d-sm-inline ms-1">{{title}}</div>
<pngx-clearable-badge [selected]="isActive" (cleared)="reset()"></pngx-clearable-badge><span class="visually-hidden">selected</span>
</button>
<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">
@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">
{{provider.name}}&nbsp;<i-bs class="pb-1 ps-1" name="box-arrow-up-right"></i-bs>
{{provider.name}}<i-bs class="pb-1 ms-2" name="box-arrow-up-right"></i-bs>
</a>
}
</div>
@@ -139,7 +139,7 @@
<label class="d-block mb-2" i18n>Two-factor Authentication</label>
@if (recoveryCodes) {
<div class="alert alert-warning" role="alert">
<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>
<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>
</div>
<div class="d-flex flex-row align-items-start mb-3">
<ul class="list-group w-50">
@@ -156,12 +156,10 @@
</ul>
<button type="button" class="btn btn-sm btn-outline-secondary ms-2" (click)="copyRecoveryCodes()" i18n-title title="Copy">
@if (!codesCopied) {
<i-bs width="1em" height="1em" name="clipboard-fill"></i-bs>
&nbsp;<span i18n>Copy codes</span>
<i-bs width="1em" height="1em" name="clipboard-fill" class="me-1"></i-bs><span i18n>Copy codes</span>
}
@if (codesCopied) {
<i-bs width="1em" height="1em" name="clipboard-check-fill" class="text-primary"></i-bs>
&nbsp;<span class="text-primary" i18n>Copied!</span>
<i-bs width="1em" height="1em" name="clipboard-check-fill" class="text-primary me-1"></i-bs><span class="text-primary" i18n>Copied!</span>
}
</button>
</div>

View File

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

View File

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

View File

@@ -2,7 +2,7 @@
<div content tourAnchor="tour.upload-widget">
<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()">
<i-bs class="text-primary" name="plus-circle"></i-bs>&nbsp;
<i-bs class="text-primary me-1" name="plus-circle"></i-bs>
<span class="text-primary" i18n>Upload documents</span>
<div class="text-muted smaller fst-italic" i18n>or drop files anywhere</div>
</button>

View File

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

View File

@@ -110,15 +110,16 @@ import { PDFEditorComponent } from '../common/pdf-editor/pdf-editor.component'
import { PngxPdfViewerComponent } from '../common/pdf-viewer/pdf-viewer.component'
import {
PdfRenderMode,
PdfSource,
PdfZoomLevel,
PdfZoomScale,
PngxPdfDocumentProxy,
} from '../common/pdf-viewer/pdf-viewer.types'
import { ShareLinksDialogComponent } from '../common/share-links-dialog/share-links-dialog.component'
import { SuggestionsDropdownComponent } from '../common/suggestions-dropdown/suggestions-dropdown.component'
import { DocumentHistoryComponent } from '../document-history/document-history.component'
import { DocumentNotesComponent } from '../document-notes/document-notes.component'
import { ComponentWithPermissions } from '../with-permissions/with-permissions.component'
import { DocumentHistoryComponent } from './document-history/document-history.component'
import { MetadataCollapseComponent } from './metadata-collapse/metadata-collapse.component'
enum DocumentDetailNavIDs {
@@ -227,6 +228,7 @@ export class DocumentDetailComponent
title: string
titleSubject: Subject<string> = new Subject()
previewUrl: string
pdfSource?: PdfSource
thumbUrl: string
previewText: string
previewLoaded: boolean = false
@@ -345,6 +347,17 @@ export class DocumentDetailComponent
return ContentRenderType.Other
}
private updatePdfSource() {
if (!this.previewUrl) {
this.pdfSource = undefined
return
}
this.pdfSource = {
url: this.previewUrl,
password: this.password || undefined,
}
}
get isRTL() {
if (!this.metadata || !this.metadata.lang) return false
else {
@@ -421,6 +434,7 @@ export class DocumentDetailComponent
private loadDocument(documentId: number): void {
this.previewUrl = this.documentsService.getPreviewUrl(documentId)
this.updatePdfSource()
this.http
.get(this.previewUrl, { responseType: 'text' })
.pipe(
@@ -1230,6 +1244,7 @@ export class DocumentDetailComponent
onPasswordKeyUp(event: KeyboardEvent) {
if ('Enter' == event.key) {
this.password = (event.target as HTMLInputElement).value
this.updatePdfSource()
}
}

View File

@@ -1,6 +1,6 @@
@if (loading) {
<div class="d-flex">
<div class="spinner-border spinner-border-sm fw-normal" role="status"></div>
<output class="spinner-border spinner-border-sm fw-normal" role="status"></output>
</div>
} @else {
<ul class="list-group">

View File

@@ -83,8 +83,22 @@ describe('DocumentHistoryComponent', () => {
expect(result).toBe(correspondentName)
})
expect(getCachedSpy).toHaveBeenCalledWith(parseInt(correspondentId))
// no correspondent found
getCachedSpy.mockReturnValue(of(null))
})
it('getPrettyName should memoize results to avoid resubscribe loops', () => {
const correspondentId = '1'
const getCachedSpy = jest
.spyOn(correspondentService, 'getCached')
.mockReturnValue(of({ name: 'John Doe' }))
const a = component.getPrettyName(DataType.Correspondent, correspondentId)
const b = component.getPrettyName(DataType.Correspondent, correspondentId)
expect(a).toBe(b)
expect(getCachedSpy).toHaveBeenCalledTimes(1)
})
it('getPrettyName should fall back to the correspondent id when missing', () => {
const correspondentId = '1'
jest.spyOn(correspondentService, 'getCached').mockReturnValue(of(null))
component
.getPrettyName(DataType.Correspondent, correspondentId)
.subscribe((result) => {
@@ -104,8 +118,11 @@ describe('DocumentHistoryComponent', () => {
expect(result).toBe(documentTypeName)
})
expect(getCachedSpy).toHaveBeenCalledWith(parseInt(documentTypeId))
// no document type found
getCachedSpy.mockReturnValue(of(null))
})
it('getPrettyName should fall back to the document type id when missing', () => {
const documentTypeId = '1'
jest.spyOn(documentTypeService, 'getCached').mockReturnValue(of(null))
component
.getPrettyName(DataType.DocumentType, documentTypeId)
.subscribe((result) => {
@@ -125,8 +142,11 @@ describe('DocumentHistoryComponent', () => {
expect(result).toBe(storagePath)
})
expect(getCachedSpy).toHaveBeenCalledWith(parseInt(storagePathId))
// no storage path found
getCachedSpy.mockReturnValue(of(null))
})
it('getPrettyName should fall back to the storage path id when missing', () => {
const storagePathId = '1'
jest.spyOn(storagePathService, 'getCached').mockReturnValue(of(null))
component
.getPrettyName(DataType.StoragePath, storagePathId)
.subscribe((result) => {
@@ -144,8 +164,11 @@ describe('DocumentHistoryComponent', () => {
expect(result).toBe(ownerUsername)
})
expect(getCachedSpy).toHaveBeenCalledWith(parseInt(ownerId))
// no user found
getCachedSpy.mockReturnValue(of(null))
})
it('getPrettyName should fall back to the owner id when missing', () => {
const ownerId = '1'
jest.spyOn(userService, 'getCached').mockReturnValue(of(null))
component.getPrettyName('owner', ownerId).subscribe((result) => {
expect(result).toBe(ownerId)
})

View File

@@ -0,0 +1,114 @@
import { AsyncPipe, KeyValuePipe, TitleCasePipe } from '@angular/common'
import { Component, Input, OnInit, inject } from '@angular/core'
import { NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { Observable, first, map, of, shareReplay } from 'rxjs'
import { AuditLogAction, AuditLogEntry } from 'src/app/data/auditlog-entry'
import { DataType } from 'src/app/data/datatype'
import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
import { CorrespondentService } from 'src/app/services/rest/correspondent.service'
import { DocumentTypeService } from 'src/app/services/rest/document-type.service'
import { DocumentService } from 'src/app/services/rest/document.service'
import { StoragePathService } from 'src/app/services/rest/storage-path.service'
import { UserService } from 'src/app/services/rest/user.service'
@Component({
selector: 'pngx-document-history',
templateUrl: './document-history.component.html',
styleUrl: './document-history.component.scss',
imports: [
CustomDatePipe,
NgbTooltipModule,
AsyncPipe,
KeyValuePipe,
TitleCasePipe,
NgxBootstrapIconsModule,
],
})
export class DocumentHistoryComponent implements OnInit {
private documentService = inject(DocumentService)
private correspondentService = inject(CorrespondentService)
private storagePathService = inject(StoragePathService)
private documentTypeService = inject(DocumentTypeService)
private userService = inject(UserService)
public AuditLogAction = AuditLogAction
private _documentId: number
@Input()
set documentId(id: number) {
if (this._documentId !== id) {
this._documentId = id
this.prettyNameCache.clear()
this.loadHistory()
}
}
public loading: boolean = true
public entries: AuditLogEntry[] = []
private readonly prettyNameCache = new Map<string, Observable<string>>()
ngOnInit(): void {
this.loadHistory()
}
private loadHistory(): void {
if (this._documentId) {
this.loading = true
this.documentService.getHistory(this._documentId).subscribe((entries) => {
this.entries = entries
this.loading = false
})
}
}
getPrettyName(type: DataType | string, id: string): Observable<string> {
const cacheKey = `${type}:${id}`
const cached = this.prettyNameCache.get(cacheKey)
if (cached) {
return cached
}
const idInt = parseInt(id, 10)
const fallback$ = of(id)
let result$: Observable<string>
if (!Number.isFinite(idInt)) {
result$ = fallback$
} else {
switch (type) {
case DataType.Correspondent:
result$ = this.correspondentService.getCached(idInt).pipe(
first(),
map((correspondent) => correspondent?.name ?? id)
)
break
case DataType.DocumentType:
result$ = this.documentTypeService.getCached(idInt).pipe(
first(),
map((documentType) => documentType?.name ?? id)
)
break
case DataType.StoragePath:
result$ = this.storagePathService.getCached(idInt).pipe(
first(),
map((storagePath) => storagePath?.path ?? id)
)
break
case 'owner':
result$ = this.userService.getCached(idInt).pipe(
first(),
map((user) => user?.username ?? id)
)
break
default:
result$ = fallback$
}
}
const shared$ = result$.pipe(shareReplay({ bufferSize: 1, refCount: true }))
this.prettyNameCache.set(cacheKey, shared$)
return shared$
}
}

View File

@@ -1,85 +0,0 @@
import { AsyncPipe, KeyValuePipe, TitleCasePipe } from '@angular/common'
import { Component, Input, OnInit, inject } from '@angular/core'
import { NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { Observable, first, map, of } from 'rxjs'
import { AuditLogAction, AuditLogEntry } from 'src/app/data/auditlog-entry'
import { DataType } from 'src/app/data/datatype'
import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
import { CorrespondentService } from 'src/app/services/rest/correspondent.service'
import { DocumentTypeService } from 'src/app/services/rest/document-type.service'
import { DocumentService } from 'src/app/services/rest/document.service'
import { StoragePathService } from 'src/app/services/rest/storage-path.service'
import { UserService } from 'src/app/services/rest/user.service'
@Component({
selector: 'pngx-document-history',
templateUrl: './document-history.component.html',
styleUrl: './document-history.component.scss',
imports: [
CustomDatePipe,
NgbTooltipModule,
AsyncPipe,
KeyValuePipe,
TitleCasePipe,
NgxBootstrapIconsModule,
],
})
export class DocumentHistoryComponent implements OnInit {
private documentService = inject(DocumentService)
private correspondentService = inject(CorrespondentService)
private storagePathService = inject(StoragePathService)
private documentTypeService = inject(DocumentTypeService)
private userService = inject(UserService)
public AuditLogAction = AuditLogAction
private _documentId: number
@Input()
set documentId(id: number) {
this._documentId = id
this.ngOnInit()
}
public loading: boolean = true
public entries: AuditLogEntry[] = []
ngOnInit(): void {
if (this._documentId) {
this.loading = true
this.documentService
.getHistory(this._documentId)
.subscribe((auditLogEntries) => {
this.entries = auditLogEntries
this.loading = false
})
}
}
getPrettyName(type: DataType | string, id: string): Observable<string> {
switch (type) {
case DataType.Correspondent:
return this.correspondentService.getCached(parseInt(id, 10)).pipe(
first(),
map((correspondent) => correspondent?.name ?? id)
)
case DataType.DocumentType:
return this.documentTypeService.getCached(parseInt(id, 10)).pipe(
first(),
map((documentType) => documentType?.name ?? id)
)
case DataType.StoragePath:
return this.storagePathService.getCached(parseInt(id, 10)).pipe(
first(),
map((storagePath) => storagePath?.path ?? id)
)
case 'owner':
return this.userService.getCached(parseInt(id, 10)).pipe(
first(),
map((user) => user?.username ?? id)
)
default:
return of(id)
}
}
}

View File

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

View File

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

View File

@@ -1,8 +1,7 @@
<pngx-page-header [title]="getTitle()">
<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>
<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 (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>
}
@@ -20,21 +19,20 @@
<div class="btn-group btn-group-sm flex-nowrap">
@if (list.selected.size > 0) {
<button class="btn btn-sm btn-outline-secondary" (click)="list.selectNone()">
<i-bs name="slash-circle"></i-bs>&nbsp;<ng-container i18n>None</ng-container>
<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)="list.selectPage()">
<i-bs name="file-earmark-check"></i-bs>&nbsp;<ng-container i18n>Page</ng-container>
<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)="list.selectAll()">
<i-bs name="check-all"></i-bs>&nbsp;<ng-container i18n>All</ng-container>
<i-bs name="check-all" class="me-1"></i-bs><ng-container i18n>All</ng-container>
</button>
</div>
</div>
<div ngbDropdown class="btn-group flex-fill">
<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">&nbsp;<ng-container i18n>Show</ng-container></div>
<i-bs name="card-heading"></i-bs><div class="d-none d-sm-inline ms-1"><ng-container i18n>Show</ng-container></div>
</button>
<div ngbDropdownMenu aria-labelledby="dropdownDisplayFields" class="shadow">
<div class="px-3">
@@ -64,8 +62,7 @@
<div ngbDropdown class="btn-group flex-fill">
<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">&nbsp;<ng-container i18n>Sort</ng-container></div>
<i-bs name="arrow-down-up"></i-bs><div class="d-none d-sm-inline ms-1"><ng-container i18n>Sort</ng-container></div>
</button>
<div ngbDropdownMenu aria-labelledby="dropdownBasic1" class="shadow dropdown-menu-right">
<div class="w-100 d-flex pb-2 mb-1 border-bottom">
@@ -90,8 +87,7 @@
<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>
<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>
<i-bs name="window-stack"></i-bs><div class="d-none d-sm-inline ms-1"><ng-container i18n>Views</ng-container></div>
@if (savedViewIsModified) {
<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>

View File

@@ -1,15 +1,3 @@
<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">
<li class="list-group-item">
@@ -55,10 +43,10 @@
</div>
<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)">
<i-bs width="1em" height="1em" name="pencil"></i-bs>&nbsp;<ng-container i18n>Edit</ng-container>
<i-bs width="1em" height="1em" name="pencil" class="me-1"></i-bs><ng-container i18n>Edit</ng-container>
</button>
<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"></i-bs>&nbsp;<ng-container i18n>Delete</ng-container>
<i-bs width="1em" height="1em" name="trash" class="me-1"></i-bs><ng-container i18n>Delete</ng-container>
</button>
</div>
@if (field.document_count > 0) {
@@ -67,7 +55,7 @@
class="btn btn-sm btn-outline-secondary"
[routerLink]="getDocumentFilterUrl(field)"
>
<i-bs width="1em" height="1em" name="filter"></i-bs>&nbsp;<ng-container i18n>Documents</ng-container
<i-bs width="1em" height="1em" name="filter" class="me-1"></i-bs><ng-container i18n>Documents</ng-container
><span class="badge bg-light text-secondary ms-2">{{ field.document_count }}</span>
</a>
</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 { SettingsService } from 'src/app/services/settings.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 { PageHeaderComponent } from '../../common/page-header/page-header.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 { PageHeaderComponent } from '../../../common/page-header/page-header.component'
import { CustomFieldsComponent } from './custom-fields.component'
const fields: CustomField[] = [
@@ -110,10 +110,7 @@ describe('CustomFieldsComponent', () => {
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
const reloadSpy = jest.spyOn(component, 'reload')
const createButton = fixture.debugElement
.queryAll(By.css('button'))
.find((btn) => btn.nativeElement.textContent.trim().includes('Add Field'))
createButton.triggerEventHandler('click')
component.editField()
expect(modal).not.toBeUndefined()
const editDialog = modal.componentInstance as CustomFieldEditDialogComponent

View File

@@ -7,6 +7,10 @@ import {
} from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
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 {
CustomFieldQueryLogicalOperator,
@@ -21,18 +25,12 @@ import { DocumentService } from 'src/app/services/rest/document.service'
import { SavedViewService } from 'src/app/services/rest/saved-view.service'
import { SettingsService } from 'src/app/services/settings.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({
selector: 'pngx-custom-fields',
templateUrl: './custom-fields.component.html',
styleUrls: ['./custom-fields.component.scss'],
imports: [
PageHeaderComponent,
IfPermissionsDirective,
NgbDropdownModule,
NgbPaginationModule,
@@ -44,14 +42,14 @@ export class CustomFieldsComponent
extends LoadingComponentWithPermissions
implements OnInit
{
private customFieldsService = inject(CustomFieldsService)
permissionsService = inject(PermissionsService)
private modalService = inject(NgbModal)
private toastService = inject(ToastService)
private documentListViewService = inject(DocumentListViewService)
private settingsService = inject(SettingsService)
private documentService = inject(DocumentService)
private savedViewService = inject(SavedViewService)
private readonly customFieldsService = inject(CustomFieldsService)
public readonly permissionsService = inject(PermissionsService)
private readonly modalService = inject(NgbModal)
private readonly toastService = inject(ToastService)
private readonly documentListViewService = inject(DocumentListViewService)
private readonly settingsService = inject(SettingsService)
private readonly documentService = inject(DocumentService)
private readonly savedViewService = inject(SavedViewService)
public fields: CustomField[] = []

View File

@@ -0,0 +1,77 @@
<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

@@ -0,0 +1,189 @@
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

@@ -0,0 +1,256 @@
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 { SortableDirective } from 'src/app/directives/sortable.directive'
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'
describe('CorrespondentListComponent', () => {

View File

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

View File

@@ -9,7 +9,7 @@ import { of } from 'rxjs'
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
import { SortableDirective } from 'src/app/directives/sortable.directive'
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'
describe('DocumentTypeListComponent', () => {

View File

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

View File

@@ -1,50 +1,3 @@
<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="col mb-2 mb-xl-0">
<div class="form-inline d-flex align-items-center">
@@ -76,19 +29,19 @@
<table class="table table-striped align-middle shadow-sm mb-0">
<thead>
<tr>
<th scope="col">
<th>
<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)="selectPage($event.target.checked); $event.stopPropagation();">
<input type="checkbox" class="form-check-input" id="all-objects" [(ngModel)]="togggleAll" [disabled]="data.length === 0" (change)="$event.target.checked ? selectPage() : clearSelection(); $event.stopPropagation();">
<label class="form-check-label" for="all-objects"></label>
</div>
</th>
<th scope="col" class="fw-normal" pngxSortable="name" [currentSortField]="sortField" [currentSortReverse]="sortReverse" (sort)="onSort($event)" i18n>Name</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 scope="col" class="fw-normal" pngxSortable="document_count" [currentSortField]="sortField" [currentSortReverse]="sortReverse" (sort)="onSort($event)" i18n>Document count</th>
<th 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 class="fw-normal" pngxSortable="document_count" [currentSortField]="sortField" [currentSortReverse]="sortReverse" (sort)="onSort($event)" i18n>Document count</th>
@for (column of extraColumns; track column) {
<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" [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" i18n>Actions</th>
<th class="fw-normal" i18n>Actions</th>
</tr>
</thead>
<tbody>
@@ -131,16 +84,16 @@
<label class="form-check-label" for="{{typeName}}{{object.id}}"></label>
</div>
</td>
<td scope="row" class="name-cell" style="--depth: {{depth}}">
<td class="name-cell" style="--depth: {{depth}}">
@if (depth > 0) {
<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>
</td>
<td scope="row" class="d-none d-sm-table-cell">{{ getMatching(object) }}</td>
<td scope="row">{{ getDocumentCount(object) }}</td>
<td class="d-none d-sm-table-cell">{{ getMatching(object) }}</td>
<td>{{ getDocumentCount(object) }}</td>
@for (column of extraColumns; track column) {
<td scope="row" [ngClass]="{ 'd-none d-sm-table-cell' : column.hideOnMobile }">
<td [ngClass]="{ 'd-none d-sm-table-cell' : column.hideOnMobile }">
@if (column.badgeFn) {
<span
class="badge"
@@ -156,7 +109,7 @@
}
</td>
}
<td scope="row">
<td>
<div class="btn-toolbar gap-2">
<div class="btn-group d-block d-sm-none">
<div ngbDropdown container="body" class="d-inline-block">
@@ -181,10 +134,10 @@
</div>
<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)">
<i-bs width="1em" height="1em" name="pencil"></i-bs>&nbsp;<ng-container i18n>Edit</ng-container>
<i-bs width="1em" height="1em" name="pencil" class="me-1"></i-bs><ng-container i18n>Edit</ng-container>
</button>
<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"></i-bs>&nbsp;<ng-container i18n>Delete</ng-container>
<i-bs width="1em" height="1em" name="trash" class="me-1"></i-bs><ng-container i18n>Delete</ng-container>
</button>
</div>
@if (getDocumentCount(object) > 0) {
@@ -195,7 +148,7 @@
[routerLink]="getDocumentFilterUrl(object)"
(click)="$event?.stopPropagation()"
>
<i-bs width="1em" height="1em" name="filter"></i-bs>&nbsp;<ng-container i18n>Documents</ng-container
<i-bs width="1em" height="1em" name="filter" class="me-1"></i-bs><ng-container i18n>Documents</ng-container
><span class="badge bg-light text-secondary ms-2">{{ getDocumentCount(object) }}</span>
</a>
</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 { SettingsService } from 'src/app/services/settings.service'
import { ToastService } from 'src/app/services/toast.service'
import { ConfirmDialogComponent } from '../../common/confirm-dialog/confirm-dialog.component'
import { EditDialogComponent } from '../../common/edit-dialog/edit-dialog.component'
import { PageHeaderComponent } from '../../common/page-header/page-header.component'
import { PermissionsDialogComponent } from '../../common/permissions-dialog/permissions-dialog.component'
import { TagListComponent } from '../tag-list/tag-list.component'
import { ConfirmDialogComponent } from '../../../common/confirm-dialog/confirm-dialog.component'
import { EditDialogComponent } from '../../../common/edit-dialog/edit-dialog.component'
import { PageHeaderComponent } from '../../../common/page-header/page-header.component'
import { PermissionsDialogComponent } from '../../../common/permissions-dialog/permissions-dialog.component'
import { ManagementListComponent } from './management-list.component'
import { TagListComponent } from './tag-list/tag-list.component'
const tags: Tag[] = [
{
@@ -304,12 +304,12 @@ describe('ManagementListComponent', () => {
})
it('selectPage should select current page items or clear selection', () => {
component.selectPage(true)
component.selectPage()
expect(component.selectedObjects).toEqual(new Set(tags.map((t) => t.id)))
expect(component.togggleAll).toBe(true)
component.togggleAll = true
component.selectPage(false)
component.clearSelection()
expect(component.selectedObjects.size).toBe(0)
expect(component.togggleAll).toBe(false)
})

View File

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

View File

@@ -1,4 +1,4 @@
import { NgClass, NgTemplateOutlet, TitleCasePipe } from '@angular/common'
import { NgClass, NgTemplateOutlet } from '@angular/common'
import { Component, inject } from '@angular/core'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { RouterModule } from '@angular/router'
@@ -7,25 +7,21 @@ import {
NgbPaginationModule,
} from '@ng-bootstrap/ng-bootstrap'
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 { StoragePath } from 'src/app/data/storage-path'
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
import { SortableDirective } from 'src/app/directives/sortable.directive'
import { PermissionType } from 'src/app/services/permissions.service'
import { StoragePathService } from 'src/app/services/rest/storage-path.service'
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'
import { ManagementListComponent } from '../management-list.component'
@Component({
selector: 'pngx-storage-path-list',
templateUrl: './../management-list/management-list.component.html',
styleUrls: ['./../management-list/management-list.component.scss'],
templateUrl: './../management-list.component.html',
styleUrls: ['./../management-list.component.scss'],
imports: [
SortableDirective,
PageHeaderComponent,
TitleCasePipe,
IfPermissionsDirective,
FormsModule,
ReactiveFormsModule,
@@ -35,7 +31,6 @@ import { ManagementListComponent } from '../management-list/management-list.comp
NgbDropdownModule,
NgbPaginationModule,
NgxBootstrapIconsModule,
ClearableBadgeComponent,
],
})
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 { SortableDirective } from 'src/app/directives/sortable.directive'
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'
describe('TagListComponent', () => {
@@ -138,12 +138,12 @@ describe('TagListComponent', () => {
}
component.data = [parent as any]
component.selectPage(true)
component.selectPage()
expect(component.selectedObjects.has(10)).toBe(true)
expect(component.selectedObjects.has(11)).toBe(true)
component.selectPage(false)
component.clearSelection()
expect(component.selectedObjects.size).toBe(0)
})
})

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -96,4 +96,52 @@ describe('PermissionsGuard', () => {
expect(canActivate).toHaveProperty('root') // returns UrlTree
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,12 +20,20 @@ export class PermissionsGuard {
route: ActivatedRouteSnapshot,
state: RouterStateSnapshot
): boolean | UrlTree {
const requiredPermissionAny: { action: any; type: any }[] =
route.data.requiredPermissionAny
if (
(route.data.requireAdmin && !this.permissionsService.isAdmin()) ||
(route.data.requiredPermission &&
!this.permissionsService.currentUserCan(
route.data.requiredPermission.action,
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

View File

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

View File

@@ -6,7 +6,7 @@ export const environment = {
apiVersion: '9', // match src/paperless/settings.py
appTitle: 'Paperless-ngx',
tag: 'prod',
version: '2.20.6',
version: '2.20.7',
webSocketHost: window.location.host,
webSocketProtocol: window.location.protocol == 'https:' ? 'wss:' : 'ws:',
webSocketBaseUrl: base_url.pathname + 'ws/',

View File

@@ -125,6 +125,7 @@ import {
sliders2Vertical,
sortAlphaDown,
sortAlphaUpAlt,
stack,
stars,
tag,
tagFill,
@@ -343,6 +344,7 @@ const icons = {
sliders2Vertical,
sortAlphaDown,
sortAlphaUpAlt,
stack,
stars,
tagFill,
tag,

View File

@@ -28,8 +28,6 @@ from documents.utils import maybe_override_pixel_limit
from paperless.config import BarcodeConfig
if TYPE_CHECKING:
from collections.abc import Callable
from PIL import Image
logger = logging.getLogger("paperless.barcodes")
@@ -262,26 +260,6 @@ class BarcodePlugin(ConsumeTaskPlugin):
return barcodes
@staticmethod
def read_barcodes_pyzbar(image: Image.Image) -> list[str]:
barcodes = []
from pyzbar import pyzbar
# Decode the barcode image
detected_barcodes = pyzbar.decode(image)
# Traverse through all the detected barcodes in image
for barcode in detected_barcodes:
if barcode.data:
decoded_barcode = barcode.data.decode("utf-8")
barcodes.append(decoded_barcode)
logger.debug(
f"Barcode of type {barcode.type} found: {decoded_barcode}",
)
return barcodes
def detect(self) -> None:
"""
Scan all pages of the PDF as images, updating barcodes and the pages
@@ -294,14 +272,6 @@ class BarcodePlugin(ConsumeTaskPlugin):
# No op if not a TIFF
self.convert_from_tiff_to_pdf()
# Choose the library for reading
if settings.CONSUMER_BARCODE_SCANNER == "PYZBAR":
reader: Callable[[Image.Image], list[str]] = self.read_barcodes_pyzbar
logger.debug("Scanning for barcodes using PYZBAR")
else:
reader = self.read_barcodes_zxing
logger.debug("Scanning for barcodes using ZXING")
try:
# Read number of pages from pdf
with Pdf.open(self.pdf_file) as pdf:
@@ -349,7 +319,7 @@ class BarcodePlugin(ConsumeTaskPlugin):
)
# Detect barcodes
for barcode_value in reader(page):
for barcode_value in self.read_barcodes_zxing(page):
self.barcodes.append(
Barcode(current_page_number, barcode_value, self.settings),
)

View File

@@ -0,0 +1,227 @@
# Generated by Django 5.2.11 on 2026-02-09 16:37
import django.core.validators
from django.db import migrations
from django.db import models
class Migration(migrations.Migration):
dependencies = [
("documents", "0010_alter_document_content_length"),
]
operations = [
migrations.AlterField(
model_name="correspondent",
name="matching_algorithm",
field=models.PositiveSmallIntegerField(
choices=[
(0, "None"),
(1, "Any word"),
(2, "All words"),
(3, "Exact match"),
(4, "Regular expression"),
(5, "Fuzzy word"),
(6, "Automatic"),
],
default=1,
verbose_name="matching algorithm",
),
),
migrations.AlterField(
model_name="documenttype",
name="matching_algorithm",
field=models.PositiveSmallIntegerField(
choices=[
(0, "None"),
(1, "Any word"),
(2, "All words"),
(3, "Exact match"),
(4, "Regular expression"),
(5, "Fuzzy word"),
(6, "Automatic"),
],
default=1,
verbose_name="matching algorithm",
),
),
migrations.AlterField(
model_name="savedviewfilterrule",
name="rule_type",
field=models.PositiveSmallIntegerField(
choices=[
(0, "title contains"),
(1, "content contains"),
(2, "ASN is"),
(3, "correspondent is"),
(4, "document type is"),
(5, "is in inbox"),
(6, "has tag"),
(7, "has any tag"),
(8, "created before"),
(9, "created after"),
(10, "created year is"),
(11, "created month is"),
(12, "created day is"),
(13, "added before"),
(14, "added after"),
(15, "modified before"),
(16, "modified after"),
(17, "does not have tag"),
(18, "does not have ASN"),
(19, "title or content contains"),
(20, "fulltext query"),
(21, "more like this"),
(22, "has tags in"),
(23, "ASN greater than"),
(24, "ASN less than"),
(25, "storage path is"),
(26, "has correspondent in"),
(27, "does not have correspondent in"),
(28, "has document type in"),
(29, "does not have document type in"),
(30, "has storage path in"),
(31, "does not have storage path in"),
(32, "owner is"),
(33, "has owner in"),
(34, "does not have owner"),
(35, "does not have owner in"),
(36, "has custom field value"),
(37, "is shared by me"),
(38, "has custom fields"),
(39, "has custom field in"),
(40, "does not have custom field in"),
(41, "does not have custom field"),
(42, "custom fields query"),
(43, "created to"),
(44, "created from"),
(45, "added to"),
(46, "added from"),
(47, "mime type is"),
],
verbose_name="rule type",
),
),
migrations.AlterField(
model_name="storagepath",
name="matching_algorithm",
field=models.PositiveSmallIntegerField(
choices=[
(0, "None"),
(1, "Any word"),
(2, "All words"),
(3, "Exact match"),
(4, "Regular expression"),
(5, "Fuzzy word"),
(6, "Automatic"),
],
default=1,
verbose_name="matching algorithm",
),
),
migrations.AlterField(
model_name="tag",
name="matching_algorithm",
field=models.PositiveSmallIntegerField(
choices=[
(0, "None"),
(1, "Any word"),
(2, "All words"),
(3, "Exact match"),
(4, "Regular expression"),
(5, "Fuzzy word"),
(6, "Automatic"),
],
default=1,
verbose_name="matching algorithm",
),
),
migrations.AlterField(
model_name="workflow",
name="order",
field=models.SmallIntegerField(default=0, verbose_name="order"),
),
migrations.AlterField(
model_name="workflowaction",
name="order",
field=models.PositiveSmallIntegerField(default=0, verbose_name="order"),
),
migrations.AlterField(
model_name="workflowaction",
name="type",
field=models.PositiveSmallIntegerField(
choices=[
(1, "Assignment"),
(2, "Removal"),
(3, "Email"),
(4, "Webhook"),
(5, "Password removal"),
],
default=1,
verbose_name="Workflow Action Type",
),
),
migrations.AlterField(
model_name="workflowrun",
name="type",
field=models.PositiveSmallIntegerField(
choices=[
(1, "Consumption Started"),
(2, "Document Added"),
(3, "Document Updated"),
(4, "Scheduled"),
],
null=True,
verbose_name="workflow trigger type",
),
),
migrations.AlterField(
model_name="workflowtrigger",
name="matching_algorithm",
field=models.PositiveSmallIntegerField(
choices=[
(0, "None"),
(1, "Any word"),
(2, "All words"),
(3, "Exact match"),
(4, "Regular expression"),
(5, "Fuzzy word"),
],
default=0,
verbose_name="matching algorithm",
),
),
migrations.AlterField(
model_name="workflowtrigger",
name="schedule_offset_days",
field=models.SmallIntegerField(
default=0,
help_text="The number of days to offset the schedule trigger by.",
verbose_name="schedule offset days",
),
),
migrations.AlterField(
model_name="workflowtrigger",
name="schedule_recurring_interval_days",
field=models.PositiveSmallIntegerField(
default=1,
help_text="The number of days between recurring schedule triggers.",
validators=[django.core.validators.MinValueValidator(1)],
verbose_name="schedule recurring delay in days",
),
),
migrations.AlterField(
model_name="workflowtrigger",
name="type",
field=models.PositiveSmallIntegerField(
choices=[
(1, "Consumption Started"),
(2, "Document Added"),
(3, "Document Updated"),
(4, "Scheduled"),
],
default=1,
verbose_name="Workflow Trigger Type",
),
),
]

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