mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-02-19 09:56:23 +00:00
Compare commits
35 Commits
chore/swit
...
l10n_dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9178c61be1 | ||
|
|
eda0e61cec | ||
|
|
426c0a8974 | ||
|
|
e54b69f7c4 | ||
|
|
4884b67714 | ||
|
|
2ccb315291 | ||
|
|
02896a15fd | ||
|
|
d8e07b8d84 | ||
|
|
c08d3aa962 | ||
|
|
be4e29a19c | ||
|
|
5c1bbcd06d | ||
|
|
bc734798e3 | ||
|
|
5ecbfc9df7 | ||
|
|
e63b62d531 | ||
|
|
dd3ec83569 | ||
|
|
7a23356898 | ||
|
|
afaf39e43a | ||
|
|
84163f4197 | ||
|
|
ae234d15c8 | ||
|
|
118afa82b3 | ||
|
|
56d1b5677a | ||
|
|
6622349b5f | ||
|
|
b050fab77f | ||
|
|
a467df0755 | ||
|
|
728c5ea07b | ||
|
|
4f2e16fdc7 | ||
|
|
8db1c4e08b | ||
|
|
1c70d3f8a8 | ||
|
|
491b5a4355 | ||
|
|
d41d4e12bf | ||
|
|
775e32bf3b | ||
|
|
e8e027abc0 | ||
|
|
5b45b89d35 | ||
|
|
5b9bb147cf | ||
|
|
c278f52fb2 |
@@ -64,8 +64,6 @@ ARG RUNTIME_PACKAGES="\
|
||||
libmagic1 \
|
||||
media-types \
|
||||
zlib1g \
|
||||
# Barcode splitter
|
||||
libzbar0 \
|
||||
poppler-utils \
|
||||
htop \
|
||||
sudo"
|
||||
|
||||
1
.github/dependabot.yml
vendored
1
.github/dependabot.yml
vendored
@@ -69,7 +69,6 @@ updates:
|
||||
patterns:
|
||||
- "ocrmypdf"
|
||||
- "pdf2image"
|
||||
- "pyzbar"
|
||||
- "zxing-cpp"
|
||||
- "tika-client"
|
||||
- "gotenberg-client"
|
||||
|
||||
2
.github/workflows/ci-backend.yml
vendored
2
.github/workflows/ci-backend.yml
vendored
@@ -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
|
||||
|
||||
4
.github/workflows/ci-docs.yml
vendored
4
.github/workflows/ci-docs.yml
vendored
@@ -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
|
||||
|
||||
4
.github/workflows/ci-release.yml
vendored
4
.github/workflows/ci-release.yml
vendored
@@ -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
|
||||
|
||||
@@ -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]
|
||||
@@ -277,6 +276,8 @@ src/documents/management/commands/document_exporter.py:0: error: Skipping analyz
|
||||
src/documents/management/commands/document_exporter.py:0: error: Skipping analyzing "auditlog.models": module is installed, but missing library stubs or py.typed marker [import-untyped]
|
||||
src/documents/management/commands/document_fuzzy_match.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||
src/documents/management/commands/document_fuzzy_match.py:0: error: Function is missing a type annotation [no-untyped-def]
|
||||
src/documents/management/commands/document_importer.py:0: error: Argument 1 to "create_source_path_directory" has incompatible type "Path | None"; expected "Path" [arg-type]
|
||||
src/documents/management/commands/document_importer.py:0: error: Argument 2 to "copy_file_with_basic_stats" has incompatible type "Path | None"; expected "Path | str" [arg-type]
|
||||
src/documents/management/commands/document_importer.py:0: error: Attribute "version" already defined on line 0 [no-redef]
|
||||
src/documents/management/commands/document_importer.py:0: error: Function is missing a type annotation for one or more arguments [no-untyped-def]
|
||||
src/documents/management/commands/document_importer.py:0: error: Function is missing a type annotation for one or more arguments [no-untyped-def]
|
||||
@@ -444,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]
|
||||
@@ -559,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]
|
||||
@@ -937,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]
|
||||
@@ -1573,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]
|
||||
@@ -1828,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]
|
||||
|
||||
@@ -154,8 +154,6 @@ ARG RUNTIME_PACKAGES="\
|
||||
libmagic1 \
|
||||
media-types \
|
||||
zlib1g \
|
||||
# Barcode splitter
|
||||
libzbar0 \
|
||||
poppler-utils"
|
||||
|
||||
# Install basic runtime packages.
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
@@ -77,10 +76,11 @@ dependencies = [
|
||||
"setproctitle~=1.3.4",
|
||||
"tika-client~=0.10.0",
|
||||
"torch~=2.10.0",
|
||||
"tqdm~=4.67.1",
|
||||
"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 = [
|
||||
@@ -149,6 +149,7 @@ typing = [
|
||||
"types-pytz",
|
||||
"types-redis",
|
||||
"types-setuptools",
|
||||
"types-tqdm",
|
||||
]
|
||||
|
||||
[tool.uv]
|
||||
@@ -170,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" },
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
2009
src-ui/messages.xlf
2009
src-ui/messages.xlf
File diff suppressed because it is too large
Load Diff
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "paperless-ngx-ui",
|
||||
"version": "2.20.6",
|
||||
"version": "2.20.7",
|
||||
"scripts": {
|
||||
"preinstall": "npx only-allow pnpm",
|
||||
"ng": "ng",
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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> <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>
|
||||
<i-bs name="arrow-up-right"></i-bs>
|
||||
<i-bs class="ms-2" name="arrow-up-right"></i-bs>
|
||||
</a>
|
||||
}
|
||||
</pngx-page-header>
|
||||
|
||||
@@ -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> <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> {{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> <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> <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>
|
||||
|
||||
@@ -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> <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> <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> <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> <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> <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> <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>
|
||||
|
||||
@@ -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> <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> <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> <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> <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> <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> <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>
|
||||
|
||||
@@ -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> <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> <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> <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> {{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> <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> <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> <ng-container i18n>Tags</ng-container></span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item app-link"
|
||||
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.DocumentType }">
|
||||
<a class="nav-link" routerLink="documenttypes" routerLinkActive="active" (click)="closeMenu()"
|
||||
ngbPopover="Document Types" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
|
||||
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||
<i-bs class="me-1" name="hash"></i-bs><span> <ng-container i18n>Document Types</ng-container></span>
|
||||
</a>
|
||||
</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> <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> <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> <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> <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> <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> <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> <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> <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> <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> <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> <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"> <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) {
|
||||
<ng-container i18n>Update available</ng-container>
|
||||
<ng-container i18n>Update available</ng-container>
|
||||
}
|
||||
</a>
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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> <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> <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> <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> <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> <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> <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>
|
||||
}
|
||||
|
||||
@@ -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> <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>
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"> <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> <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>
|
||||
}
|
||||
|
||||
@@ -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"> {{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>
|
||||
}
|
||||
|
||||
@@ -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"> {{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}}">
|
||||
|
||||
@@ -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> <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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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> <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> <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> <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">
|
||||
|
||||
@@ -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"> {{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>
|
||||
}
|
||||
|
||||
@@ -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> <ng-container i18n>Remove</ng-container>
|
||||
<i-bs name="x" class="me-1"></i-bs><ng-container i18n>Remove</ng-container>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -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> <ng-container i18n>Remove</ng-container>
|
||||
<i-bs name="x" class="me-1"></i-bs><ng-container i18n>Remove</ng-container>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -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> <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> <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> <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>
|
||||
|
||||
@@ -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> <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">
|
||||
|
||||
@@ -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> <ng-container i18n>Remove</ng-container>
|
||||
<i-bs name="x" class="me-1"></i-bs><ng-container i18n>Remove</ng-container>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -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> <ng-container i18n>Remove</ng-container>
|
||||
<i-bs name="x" class="me-1"></i-bs><ng-container i18n>Remove</ng-container>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -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> <ng-container i18n>Remove</ng-container>
|
||||
<i-bs name="x" class="me-1"></i-bs><ng-container i18n>Remove</ng-container>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -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> <ng-container i18n>Remove</ng-container>
|
||||
<i-bs name="x" class="me-1"></i-bs><ng-container i18n>Remove</ng-container>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -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> <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) {
|
||||
<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>
|
||||
}
|
||||
|
||||
@@ -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> <ng-container i18n>Remove</ng-container>
|
||||
<i-bs name="x" class="me-1"></i-bs><ng-container i18n>Remove</ng-container>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -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> <ng-container i18n>Remove</ng-container>
|
||||
<i-bs name="x" class="me-1"></i-bs><ng-container i18n>Remove</ng-container>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -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> <ng-container i18n>Remove</ng-container>
|
||||
<i-bs name="x" class="me-1"></i-bs><ng-container i18n>Remove</ng-container>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -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> <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}}
|
||||
}
|
||||
|
||||
@@ -150,4 +150,8 @@
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
pointer-events: none;
|
||||
|
||||
& .annotationTextContent {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"> {{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}}">
|
||||
|
||||
@@ -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}} <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> <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>
|
||||
<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>
|
||||
<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>
|
||||
|
||||
@@ -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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<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>
|
||||
<i-bs name="clipboard-fill" class="me-1"></i-bs>
|
||||
}
|
||||
@if (copied) {
|
||||
<i-bs name="clipboard-check-fill"></i-bs>
|
||||
<i-bs name="clipboard-check-fill" class="me-1"></i-bs>
|
||||
}
|
||||
<ng-container i18n>Copy</ng-container>
|
||||
</button>
|
||||
|
||||
@@ -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>
|
||||
<i-bs name="clipboard" class="me-1"></i-bs>
|
||||
}
|
||||
@if (copied) {
|
||||
<i-bs name="clipboard-check"></i-bs>
|
||||
<i-bs name="clipboard-check" class="me-1"></i-bs>
|
||||
}
|
||||
<ng-container i18n>Copy Raw Error</ng-container>
|
||||
</button>
|
||||
|
||||
@@ -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>
|
||||
<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>
|
||||
|
||||
@@ -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"> <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> <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> <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> <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> <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> <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"> <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> <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> <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"
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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">
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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$
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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"> <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"> <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> <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> <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> <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">
|
||||
<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> <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> <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> <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"> <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> <ng-container i18n>Delete</ng-container>
|
||||
<i-bs name="trash" class="me-1"></i-bs><ng-container i18n>Delete</ng-container>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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> <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> <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> <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> <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;"> </div>
|
||||
|
||||
@@ -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"> <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> <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> <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> <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"> <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"> <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"> <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>
|
||||
|
||||
@@ -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> <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> <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> <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> <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>
|
||||
@@ -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
|
||||
@@ -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[] = []
|
||||
|
||||
@@ -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>
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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', () => {
|
||||
@@ -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()
|
||||
@@ -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', () => {
|
||||
@@ -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> {
|
||||
@@ -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"> <ng-container i18n>Select</ng-container></div>
|
||||
@if (selectedObjects.size > 0) {
|
||||
<pngx-clearable-badge [selected]="selectedObjects.size > 0" [number]="selectedObjects.size" (cleared)="selectNone()"></pngx-clearable-badge><span class="visually-hidden">selected</span>
|
||||
}
|
||||
</button>
|
||||
<div ngbDropdownMenu aria-labelledby="dropdownSelectMobile" class="shadow">
|
||||
<button ngbDropdownItem (click)="selectNone()" i18n>Select none</button>
|
||||
<button ngbDropdownItem (click)="selectPage(true)" i18n>Select page</button>
|
||||
<button ngbDropdownItem (click)="selectAll()" i18n>Select all</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="d-none d-sm-flex flex-fill me-3">
|
||||
<div class="input-group input-group-sm">
|
||||
<span class="input-group-text border-0" i18n>Select:</span>
|
||||
</div>
|
||||
<div class="btn-group btn-group-sm flex-nowrap">
|
||||
@if (selectedObjects.size > 0) {
|
||||
<button class="btn btn-sm btn-outline-secondary" (click)="selectNone()">
|
||||
<i-bs name="slash-circle"></i-bs> <ng-container i18n>None</ng-container>
|
||||
</button>
|
||||
}
|
||||
<button class="btn btn-sm btn-outline-primary" (click)="selectPage(true)">
|
||||
<i-bs name="file-earmark-check"></i-bs> <ng-container i18n>Page</ng-container>
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-primary" (click)="selectAll()">
|
||||
<i-bs name="check-all"></i-bs> <ng-container i18n>All</ng-container>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="button" class="btn btn-sm btn-outline-primary" (click)="setPermissions()" [disabled]="!userCanBulkEdit(PermissionAction.Change) || selectedObjects.size === 0">
|
||||
<i-bs name="person-fill-lock"></i-bs> <ng-container i18n>Permissions</ng-container>
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-danger" (click)="delete()" [disabled]="!userCanBulkEdit(PermissionAction.Delete) || selectedObjects.size === 0">
|
||||
<i-bs name="trash"></i-bs> <ng-container i18n>Delete</ng-container>
|
||||
</button>
|
||||
<button type="button" class="btn btn-sm btn-outline-primary ms-md-5" (click)="openCreateDialog()" *pngxIfPermissions="{ action: PermissionAction.Add, type: permissionType }">
|
||||
<i-bs name="plus-circle"></i-bs> <ng-container i18n>Create</ng-container>
|
||||
</button>
|
||||
</pngx-page-header>
|
||||
|
||||
<div class="row mb-3">
|
||||
<div class="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> <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> <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> <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>
|
||||
@@ -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)
|
||||
})
|
||||
@@ -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() {
|
||||
@@ -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', () => {
|
||||
@@ -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> {
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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> {
|
||||
@@ -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> <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> <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> <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> <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> <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> <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> <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> <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> <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> <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> <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> <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> <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>
|
||||
|
||||
@@ -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> <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> <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> <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> <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>
|
||||
|
||||
@@ -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> <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>
|
||||
|
||||
@@ -84,4 +84,6 @@ export interface MailRule extends ObjectWithPermissions {
|
||||
assign_correspondent?: number // PaperlessCorrespondent.id
|
||||
|
||||
assign_owner_from_rule: boolean
|
||||
|
||||
stop_processing: boolean
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@@ -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/',
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user