mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-08-20 09:43:29 +00:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b5e2aae0e8 |
@@ -11,7 +11,7 @@ concurrency:
|
|||||||
group: backend-${{ github.event.pull_request.number || github.ref }}
|
group: backend-${{ github.event.pull_request.number || github.ref }}
|
||||||
cancel-in-progress: true
|
cancel-in-progress: true
|
||||||
env:
|
env:
|
||||||
DEFAULT_UV_VERSION: "0.12.x"
|
DEFAULT_UV_VERSION: "0.11.x"
|
||||||
NLTK_DATA: "/usr/share/nltk_data"
|
NLTK_DATA: "/usr/share/nltk_data"
|
||||||
permissions: {}
|
permissions: {}
|
||||||
jobs:
|
jobs:
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ concurrency:
|
|||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
env:
|
env:
|
||||||
DEFAULT_UV_VERSION: "0.12.x"
|
DEFAULT_UV_VERSION: "0.11.x"
|
||||||
DEFAULT_PYTHON_VERSION: "3.12"
|
DEFAULT_PYTHON_VERSION: "3.12"
|
||||||
jobs:
|
jobs:
|
||||||
changes:
|
changes:
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ jobs:
|
|||||||
- name: Install pnpm
|
- name: Install pnpm
|
||||||
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
||||||
with:
|
with:
|
||||||
package_json_file: src-ui/package.json
|
version: 10
|
||||||
- name: Use Node.js 24
|
- name: Use Node.js 24
|
||||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||||
with:
|
with:
|
||||||
@@ -115,7 +115,7 @@ jobs:
|
|||||||
- name: Install pnpm
|
- name: Install pnpm
|
||||||
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
||||||
with:
|
with:
|
||||||
package_json_file: src-ui/package.json
|
version: 10
|
||||||
- name: Use Node.js 24
|
- name: Use Node.js 24
|
||||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||||
with:
|
with:
|
||||||
@@ -154,7 +154,7 @@ jobs:
|
|||||||
- name: Install pnpm
|
- name: Install pnpm
|
||||||
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
||||||
with:
|
with:
|
||||||
package_json_file: src-ui/package.json
|
version: 10
|
||||||
- name: Use Node.js 24
|
- name: Use Node.js 24
|
||||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||||
with:
|
with:
|
||||||
@@ -209,7 +209,7 @@ jobs:
|
|||||||
- name: Install pnpm
|
- name: Install pnpm
|
||||||
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
||||||
with:
|
with:
|
||||||
package_json_file: src-ui/package.json
|
version: 10
|
||||||
- name: Use Node.js 24
|
- name: Use Node.js 24
|
||||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||||
with:
|
with:
|
||||||
@@ -243,7 +243,7 @@ jobs:
|
|||||||
- name: Install pnpm
|
- name: Install pnpm
|
||||||
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
||||||
with:
|
with:
|
||||||
package_json_file: src-ui/package.json
|
version: 10
|
||||||
- name: Use Node.js 24
|
- name: Use Node.js 24
|
||||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||||
with:
|
with:
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ concurrency:
|
|||||||
group: release-${{ github.ref }}
|
group: release-${{ github.ref }}
|
||||||
cancel-in-progress: false
|
cancel-in-progress: false
|
||||||
env:
|
env:
|
||||||
DEFAULT_UV_VERSION: "0.12.x"
|
DEFAULT_UV_VERSION: "0.11.x"
|
||||||
DEFAULT_PYTHON_VERSION: "3.12"
|
DEFAULT_PYTHON_VERSION: "3.12"
|
||||||
permissions: {}
|
permissions: {}
|
||||||
jobs:
|
jobs:
|
||||||
@@ -42,7 +42,7 @@ jobs:
|
|||||||
- name: Install pnpm
|
- name: Install pnpm
|
||||||
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
||||||
with:
|
with:
|
||||||
package_json_file: src-ui/package.json
|
version: 10
|
||||||
- name: Use Node.js 24
|
- name: Use Node.js 24
|
||||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||||
with:
|
with:
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ on:
|
|||||||
branches:
|
branches:
|
||||||
- dev
|
- dev
|
||||||
env:
|
env:
|
||||||
DEFAULT_UV_VERSION: "0.12.x"
|
DEFAULT_UV_VERSION: "0.11.x"
|
||||||
jobs:
|
jobs:
|
||||||
generate-translate-strings:
|
generate-translate-strings:
|
||||||
name: Generate Translation Strings
|
name: Generate Translation Strings
|
||||||
@@ -45,7 +45,7 @@ jobs:
|
|||||||
- name: Install pnpm
|
- name: Install pnpm
|
||||||
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
||||||
with:
|
with:
|
||||||
package_json_file: src-ui/package.json
|
version: 10
|
||||||
- name: Use Node.js 24
|
- name: Use Node.js 24
|
||||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||||
with:
|
with:
|
||||||
|
|||||||
+5
-1
@@ -30,7 +30,7 @@ RUN set -eux \
|
|||||||
# Purpose: Installs s6-overlay and rootfs
|
# Purpose: Installs s6-overlay and rootfs
|
||||||
# Comments:
|
# Comments:
|
||||||
# - Don't leave anything extra in here either
|
# - Don't leave anything extra in here either
|
||||||
FROM ghcr.io/astral-sh/uv:0.12.5-python3.14-trixie-slim AS s6-overlay-base
|
FROM ghcr.io/astral-sh/uv:0.11.32-python3.12-trixie-slim AS s6-overlay-base
|
||||||
|
|
||||||
WORKDIR /usr/src/s6
|
WORKDIR /usr/src/s6
|
||||||
|
|
||||||
@@ -173,6 +173,10 @@ RUN set -eux \
|
|||||||
&& rm --force --verbose *.deb \
|
&& rm --force --verbose *.deb \
|
||||||
&& rm --recursive --force --verbose /var/lib/apt/lists/*
|
&& rm --recursive --force --verbose /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# Ensure interactive shells (docker exec bash) see resolved *_FILE secrets,
|
||||||
|
# mirroring what with-contenv already does for s6 services.
|
||||||
|
RUN echo '. /etc/profile.d/contenv.sh' >> /etc/bash.bashrc
|
||||||
|
|
||||||
WORKDIR /usr/src/paperless/src/
|
WORKDIR /usr/src/paperless/src/
|
||||||
|
|
||||||
# Python dependencies
|
# Python dependencies
|
||||||
|
|||||||
@@ -68,14 +68,7 @@
|
|||||||
<!-- <policy domain="resource" name="thread" value="4"/> -->
|
<!-- <policy domain="resource" name="thread" value="4"/> -->
|
||||||
<!-- <policy domain="resource" name="throttle" value="0"/> -->
|
<!-- <policy domain="resource" name="throttle" value="0"/> -->
|
||||||
<!-- <policy domain="resource" name="time" value="3600"/> -->
|
<!-- <policy domain="resource" name="time" value="3600"/> -->
|
||||||
<!-- Paperless does not process SVG or ImageMagick scripting formats. -->
|
<!-- <policy domain="coder" rights="none" pattern="MVG" /> -->
|
||||||
<policy domain="coder" rights="none" pattern="SVG" />
|
|
||||||
<policy domain="coder" rights="none" pattern="SVGZ" />
|
|
||||||
<policy domain="coder" rights="none" pattern="MSVG" />
|
|
||||||
<policy domain="coder" rights="none" pattern="RSVG" />
|
|
||||||
<policy domain="coder" rights="none" pattern="MSL" />
|
|
||||||
<policy domain="coder" rights="none" pattern="MVG" />
|
|
||||||
<policy domain="coder" rights="none" pattern="EPHEMERAL" />
|
|
||||||
<!-- <policy domain="module" rights="none" pattern="{PS,PDF,XPS}" /> -->
|
<!-- <policy domain="module" rights="none" pattern="{PS,PDF,XPS}" /> -->
|
||||||
<!-- <policy domain="delegate" rights="none" pattern="HTTPS" /> -->
|
<!-- <policy domain="delegate" rights="none" pattern="HTTPS" /> -->
|
||||||
<!-- <policy domain="path" rights="none" pattern="@*" /> -->
|
<!-- <policy domain="path" rights="none" pattern="@*" /> -->
|
||||||
@@ -85,6 +78,8 @@
|
|||||||
<!-- <policy domain="system" name="pixel-cache-memory" value="anonymous"/> -->
|
<!-- <policy domain="system" name="pixel-cache-memory" value="anonymous"/> -->
|
||||||
<!-- <policy domain="system" name="shred" value="2"/> -->
|
<!-- <policy domain="system" name="shred" value="2"/> -->
|
||||||
<!-- <policy domain="system" name="precision" value="6"/> -->
|
<!-- <policy domain="system" name="precision" value="6"/> -->
|
||||||
|
<!-- not needed due to the need to use explicitly by mvg: -->
|
||||||
|
<!-- <policy domain="delegate" rights="none" pattern="MVG" /> -->
|
||||||
<!-- use curl -->
|
<!-- use curl -->
|
||||||
<policy domain="delegate" rights="none" pattern="URL" />
|
<policy domain="delegate" rights="none" pattern="URL" />
|
||||||
<policy domain="delegate" rights="none" pattern="HTTPS" />
|
<policy domain="delegate" rights="none" pattern="HTTPS" />
|
||||||
|
|||||||
Executable
+18
@@ -0,0 +1,18 @@
|
|||||||
|
#!/bin/sh
|
||||||
|
# Source s6 container environment for interactive shells.
|
||||||
|
# Ensures variables resolved from *_FILE secret injection are visible
|
||||||
|
# when using 'docker exec bash'. Does not affect s6 services (those
|
||||||
|
# use with-contenv directly). Has no effect in non-container contexts
|
||||||
|
# because the directory will not exist.
|
||||||
|
# Note: sh/dash shells opened via 'docker exec sh' are not covered;
|
||||||
|
# only bash-based sessions benefit from this file.
|
||||||
|
_pngx_contenv="/run/s6/container_environment"
|
||||||
|
if [ -d "${_pngx_contenv}" ]; then
|
||||||
|
for _pngx_f in "${_pngx_contenv}"/*; do
|
||||||
|
[ -f "${_pngx_f}" ] || continue
|
||||||
|
_pngx_name=$(basename "${_pngx_f}")
|
||||||
|
_pngx_val=$(cat "${_pngx_f}")
|
||||||
|
export "${_pngx_name}=${_pngx_val}"
|
||||||
|
done
|
||||||
|
fi
|
||||||
|
unset _pngx_contenv _pngx_f _pngx_name _pngx_val
|
||||||
@@ -299,8 +299,6 @@ optional arguments:
|
|||||||
-sm, --split-manifest
|
-sm, --split-manifest
|
||||||
-z, --zip
|
-z, --zip
|
||||||
-zn, --zip-name
|
-zn, --zip-name
|
||||||
--zip-compression
|
|
||||||
--zip-compression-level
|
|
||||||
--data-only
|
--data-only
|
||||||
--no-progress-bar
|
--no-progress-bar
|
||||||
--passphrase
|
--passphrase
|
||||||
@@ -363,19 +361,6 @@ If `-z` or `--zip` is provided, the export will be a zip file
|
|||||||
in the target directory, named according to the current local date or the
|
in the target directory, named according to the current local date or the
|
||||||
value set in `-zn` or `--zip-name`.
|
value set in `-zn` or `--zip-name`.
|
||||||
|
|
||||||
The compression method for the zip can be set with `--zip-compression`
|
|
||||||
(`stored`, `deflated` (default), `bzip2`, `lzma`, or `zstd`) and tuned with
|
|
||||||
`--zip-compression-level` (deflated: 0–9, bzip2: 1–9, zstd: -22–22; ignored
|
|
||||||
for `stored` and `lzma`). Both options require `--zip`.
|
|
||||||
|
|
||||||
!!! warning
|
|
||||||
|
|
||||||
`zstd` compression requires Python 3.14 or newer on **both** the machine
|
|
||||||
creating the export and any machine importing it. An archive compressed with
|
|
||||||
`zstd` (or `lzma`/`bzip2` where those modules are unavailable) cannot be
|
|
||||||
imported on a runtime that lacks the codec; the importer will refuse it with
|
|
||||||
a clear error. The default `deflated` is universally readable.
|
|
||||||
|
|
||||||
If `--data-only` is provided, only the database will be exported. This option is intended
|
If `--data-only` is provided, only the database will be exported. This option is intended
|
||||||
to facilitate database upgrades without needing to clean documents and thumbnails from the media directory.
|
to facilitate database upgrades without needing to clean documents and thumbnails from the media directory.
|
||||||
|
|
||||||
@@ -714,7 +699,6 @@ document_fuzzy_match [--ratio] [--processes N]
|
|||||||
| --ratio | No | 85.0 | a number between 0 and 100, setting how similar a document must be for it to be reported. Higher numbers mean more similarity. |
|
| --ratio | No | 85.0 | a number between 0 and 100, setting how similar a document must be for it to be reported. Higher numbers mean more similarity. |
|
||||||
| --processes | No | 1/4 of system cores | Number of processes to use for matching. Setting 1 disables multiple processes |
|
| --processes | No | 1/4 of system cores | Number of processes to use for matching. Setting 1 disables multiple processes |
|
||||||
| --delete | No | False | If provided, one document of a matched pair above the ratio will be deleted. |
|
| --delete | No | False | If provided, one document of a matched pair above the ratio will be deleted. |
|
||||||
| --url | No | blank | If an instance URL is provided, the output table will show URLs to each documents instead of the document ID and name. |
|
|
||||||
|
|
||||||
!!! warning
|
!!! warning
|
||||||
|
|
||||||
|
|||||||
@@ -227,7 +227,6 @@ Version-aware endpoints:
|
|||||||
- `PATCH /api/documents/{id}/`: content updates target the selected version (`?version={version_id}`) or latest version by default; non-content metadata updates target the root document.
|
- `PATCH /api/documents/{id}/`: content updates target the selected version (`?version={version_id}`) or latest version by default; non-content metadata updates target the root document.
|
||||||
- `GET /api/documents/{id}/download/`, `GET /api/documents/{id}/preview/`, `GET /api/documents/{id}/thumb/`, `GET /api/documents/{id}/metadata/`: accept `?version={version_id}`.
|
- `GET /api/documents/{id}/download/`, `GET /api/documents/{id}/preview/`, `GET /api/documents/{id}/thumb/`, `GET /api/documents/{id}/metadata/`: accept `?version={version_id}`.
|
||||||
- `POST /api/documents/{id}/update_version/`: uploads a new version using multipart form field `document` and optional `version_label`.
|
- `POST /api/documents/{id}/update_version/`: uploads a new version using multipart form field `document` and optional `version_label`.
|
||||||
- `POST /api/documents/merge_as_versions/`: merges existing top-level documents as versions of a selected root. The JSON body must contain `documents` (at least two document IDs) and `root_document_id` (one of those IDs). When merging one source document, an optional `version_label` may be provided.
|
|
||||||
- `PATCH /api/documents/{id}/versions/{version_id}/`: updates the `version_label` of a specific version.
|
- `PATCH /api/documents/{id}/versions/{version_id}/`: updates the `version_label` of a specific version.
|
||||||
- `DELETE /api/documents/{root_id}/versions/{version_id}/`: deletes a non-root version.
|
- `DELETE /api/documents/{root_id}/versions/{version_id}/`: deletes a non-root version.
|
||||||
|
|
||||||
|
|||||||
+5
-24
@@ -776,24 +776,6 @@ system. See the corresponding
|
|||||||
|
|
||||||
Defaults to "groups"
|
Defaults to "groups"
|
||||||
|
|
||||||
#### [`PAPERLESS_SOCIAL_ACCOUNT_SYNC_SUPERUSER_GROUP=<str>`](#PAPERLESS_SOCIAL_ACCOUNT_SYNC_SUPERUSER_GROUP) {#PAPERLESS_SOCIAL_ACCOUNT_SYNC_SUPERUSER_GROUP}
|
|
||||||
|
|
||||||
: Allows you to define a group name that, if present in the third-party authentication system's groups claim, will grant the user superuser (admin) and staff status in Paperless-ngx. If the group is not present in the claim, superuser status will be revoked upon next login.
|
|
||||||
|
|
||||||
!!! warning
|
|
||||||
This is a direct reflection of the claim on every login, including the connecting user, with no exemption for the last remaining admin. If the group is missing or misconfigured on the identity provider side, the logged-in user will immediately lose their own superuser access. Fix the group membership or claim mapping on the identity provider to restore it. If the identity provider itself is unreachable or misconfigured and you are locked out, you can recover admin access locally with `manage.py createsuperuser`.
|
|
||||||
|
|
||||||
Defaults to None
|
|
||||||
|
|
||||||
#### [`PAPERLESS_SOCIAL_ACCOUNT_SYNC_STAFF_GROUP=<str>`](#PAPERLESS_SOCIAL_ACCOUNT_SYNC_STAFF_GROUP) {#PAPERLESS_SOCIAL_ACCOUNT_SYNC_STAFF_GROUP}
|
|
||||||
|
|
||||||
: Allows you to define a group name that, if present in the third-party authentication system's groups claim, will grant the user staff status in Paperless-ngx. If the group is not present in the claim and the user is not a superuser, staff status will be revoked upon next login.
|
|
||||||
|
|
||||||
!!! warning
|
|
||||||
As with [`PAPERLESS_SOCIAL_ACCOUNT_SYNC_SUPERUSER_GROUP`](#PAPERLESS_SOCIAL_ACCOUNT_SYNC_SUPERUSER_GROUP), this is applied on every login unconditionally, including for the connecting user themselves.
|
|
||||||
|
|
||||||
Defaults to None
|
|
||||||
|
|
||||||
#### [`PAPERLESS_SOCIAL_ACCOUNT_DEFAULT_GROUPS=<comma-separated-list>`](#PAPERLESS_SOCIAL_ACCOUNT_DEFAULT_GROUPS) {#PAPERLESS_SOCIAL_ACCOUNT_DEFAULT_GROUPS}
|
#### [`PAPERLESS_SOCIAL_ACCOUNT_DEFAULT_GROUPS=<comma-separated-list>`](#PAPERLESS_SOCIAL_ACCOUNT_DEFAULT_GROUPS) {#PAPERLESS_SOCIAL_ACCOUNT_DEFAULT_GROUPS}
|
||||||
|
|
||||||
: A list of group names that users who signup via social accounts will be added to upon signup. Groups listed here must already exist.
|
: A list of group names that users who signup via social accounts will be added to upon signup. Groups listed here must already exist.
|
||||||
@@ -966,11 +948,10 @@ for display in the web interface.
|
|||||||
|
|
||||||
!!! note
|
!!! note
|
||||||
|
|
||||||
The **remote OCR parser** (Azure AI) also honors this setting: when
|
The **remote OCR parser** (Azure AI) always produces a searchable
|
||||||
no archive is requested (`never`, or `auto` with a born-digital PDF),
|
PDF and stores it as the archive copy, regardless of this setting.
|
||||||
the remote engine is skipped entirely and locally-extracted text is
|
`ARCHIVE_FILE_GENERATION=never` has no effect when the remote
|
||||||
used instead, avoiding an unnecessary API call and a duplicate text
|
parser handles a document.
|
||||||
layer.
|
|
||||||
|
|
||||||
#### [`PAPERLESS_OCR_CLEAN=<mode>`](#PAPERLESS_OCR_CLEAN) {#PAPERLESS_OCR_CLEAN}
|
#### [`PAPERLESS_OCR_CLEAN=<mode>`](#PAPERLESS_OCR_CLEAN) {#PAPERLESS_OCR_CLEAN}
|
||||||
|
|
||||||
@@ -1215,7 +1196,7 @@ should be a valid crontab(5) expression describing when to run.
|
|||||||
|
|
||||||
: If set to the string "disable", no emails will be fetched automatically.
|
: If set to the string "disable", no emails will be fetched automatically.
|
||||||
|
|
||||||
Defaults to every ten minutes, with an installation-specific minute offset.
|
Defaults to `*/10 * * * *` or every ten minutes.
|
||||||
|
|
||||||
#### [`PAPERLESS_TRAIN_TASK_CRON=<cron expression>`](#PAPERLESS_TRAIN_TASK_CRON) {#PAPERLESS_TRAIN_TASK_CRON}
|
#### [`PAPERLESS_TRAIN_TASK_CRON=<cron expression>`](#PAPERLESS_TRAIN_TASK_CRON) {#PAPERLESS_TRAIN_TASK_CRON}
|
||||||
|
|
||||||
|
|||||||
@@ -187,11 +187,10 @@ PAPERLESS_ARCHIVE_FILE_GENERATION=auto
|
|||||||
|
|
||||||
### Remote OCR parser
|
### Remote OCR parser
|
||||||
|
|
||||||
If you use the **remote OCR parser** (Azure AI), `ARCHIVE_FILE_GENERATION` is
|
If you use the **remote OCR parser** (Azure AI), note that it always produces a
|
||||||
honored the same way as for the local engine: when no archive is requested
|
searchable PDF and stores it as the archive copy. `ARCHIVE_FILE_GENERATION=never`
|
||||||
(`never`, or `auto` with a born-digital PDF), the remote engine is skipped
|
has no effect for documents handled by the remote parser - the archive is produced
|
||||||
entirely and locally-extracted text is used instead, avoiding an unnecessary
|
unconditionally by the remote engine.
|
||||||
API call and a duplicate text layer.
|
|
||||||
|
|
||||||
## Search Index (Whoosh -> Tantivy)
|
## Search Index (Whoosh -> Tantivy)
|
||||||
|
|
||||||
|
|||||||
+15
-8
@@ -416,15 +416,22 @@ to a positive number to enable polling and disable native filesystem notificatio
|
|||||||
You may need to change the path in the files. Example:
|
You may need to change the path in the files. Example:
|
||||||
`ExecStart=/opt/paperless/.local/bin/celery --app paperless worker --loglevel INFO`
|
`ExecStart=/opt/paperless/.local/bin/celery --app paperless worker --loglevel INFO`
|
||||||
|
|
||||||
12. Configure ImageMagick to allow processing of PDF documents and disable
|
12. Configure ImageMagick to allow processing of PDF documents. Most
|
||||||
formats that Paperless-ngx does not use. Most distributions disable PDF
|
distributions have this disabled by default, since PDF documents can
|
||||||
processing by default, since PDF documents can contain malware. If you
|
contain malware. If you don't do this, Paperless-ngx will fall back to
|
||||||
don't enable it, Paperless-ngx will fall back to Ghostscript for certain
|
Ghostscript for certain steps such as thumbnail generation.
|
||||||
steps such as thumbnail generation.
|
|
||||||
|
|
||||||
Configure the active ImageMagick policy file (commonly
|
Edit `/etc/ImageMagick-6/policy.xml` and adjust
|
||||||
`/etc/ImageMagick-6/policy.xml` or `/etc/ImageMagick-7/policy.xml`) and
|
|
||||||
adjust similar to [the docker policy file](https://raw.githubusercontent.com/paperless-ngx/paperless-ngx/refs/heads/main/docker/rootfs/etc/ImageMagick-6/paperless-policy.xml). You should also include restrictions as noted there.
|
```
|
||||||
|
<policy domain="coder" rights="none" pattern="PDF" />
|
||||||
|
```
|
||||||
|
|
||||||
|
to
|
||||||
|
|
||||||
|
```
|
||||||
|
<policy domain="coder" rights="read|write" pattern="PDF" />
|
||||||
|
```
|
||||||
|
|
||||||
**Optional: Install the [jbig2enc](https://ocrmypdf.readthedocs.io/en/latest/jbig2.html) encoder.**
|
**Optional: Install the [jbig2enc](https://ocrmypdf.readthedocs.io/en/latest/jbig2.html) encoder.**
|
||||||
This will reduce the size of generated PDF documents. You'll most likely need to compile this yourself, because this
|
This will reduce the size of generated PDF documents. You'll most likely need to compile this yourself, because this
|
||||||
|
|||||||
+1
-7
@@ -99,10 +99,6 @@ Think of versions as **file history** for a document.
|
|||||||
- By default, search and document content use the latest version.
|
- By default, search and document content use the latest version.
|
||||||
- In document detail, selecting a version switches the preview, file metadata and content (and download etc buttons) to that version.
|
- In document detail, selecting a version switches the preview, file metadata and content (and download etc buttons) to that version.
|
||||||
- Deleting a non-root version keeps metadata and falls back to the latest remaining version.
|
- Deleting a non-root version keeps metadata and falls back to the latest remaining version.
|
||||||
- From the document list, select two or more documents and choose **Merge as versions** to combine them under one entry. Select the root document whose metadata and permissions should be retained; the other selected documents become file versions. The root may already have versions, but documents being added as versions must not have version histories of their own.
|
|
||||||
- From a document's **Versions** menu, choose **Existing** to search for another document and add it as a version of the current document.
|
|
||||||
- Documents merged as versions give up their archive serial number. If the root has no ASN of its own it takes the first one, otherwise the ASNs are released and the removal is logged.
|
|
||||||
- Merging as versions cannot be undone from the UI, and deleting the root document moves its versions to the trash as well.
|
|
||||||
|
|
||||||
### Management Lists
|
### Management Lists
|
||||||
|
|
||||||
@@ -580,9 +576,7 @@ The following workflow action types are available:
|
|||||||
- Tags, correspondent, document type and storage path
|
- Tags, correspondent, document type and storage path
|
||||||
- Document owner
|
- Document owner
|
||||||
- View and / or edit permissions to users or groups
|
- View and / or edit permissions to users or groups
|
||||||
- Custom fields, optionally with a value. If no value is set, the field is only added to the
|
- Custom fields. Note that no value for the field will be set
|
||||||
document and any value it may already have is left untouched. If a value is set, it will
|
|
||||||
overwrite an existing value of that field on the document.
|
|
||||||
|
|
||||||
##### Removal {#workflow-action-removal}
|
##### Removal {#workflow-action-removal}
|
||||||
|
|
||||||
|
|||||||
@@ -34,8 +34,6 @@ PAPERLESS_SECRET_KEY=change-me
|
|||||||
#PAPERLESS_AUTO_LOGIN_USERNAME=
|
#PAPERLESS_AUTO_LOGIN_USERNAME=
|
||||||
#PAPERLESS_COOKIE_PREFIX=
|
#PAPERLESS_COOKIE_PREFIX=
|
||||||
#PAPERLESS_ENABLE_HTTP_REMOTE_USER=false
|
#PAPERLESS_ENABLE_HTTP_REMOTE_USER=false
|
||||||
#PAPERLESS_SOCIAL_ACCOUNT_SYNC_SUPERUSER_GROUP=
|
|
||||||
#PAPERLESS_SOCIAL_ACCOUNT_SYNC_STAFF_GROUP=
|
|
||||||
|
|
||||||
# OCR settings
|
# OCR settings
|
||||||
|
|
||||||
|
|||||||
+4
-6
@@ -84,9 +84,9 @@ mariadb = [
|
|||||||
"mysqlclient~=2.2.7",
|
"mysqlclient~=2.2.7",
|
||||||
]
|
]
|
||||||
postgres = [
|
postgres = [
|
||||||
"psycopg[c,pool]==3.3.4",
|
"psycopg[c,pool]==3.3",
|
||||||
# Direct dependency for proper resolution of the pre-built wheels
|
# Direct dependency for proper resolution of the pre-built wheels
|
||||||
"psycopg-c==3.3.4",
|
"psycopg-c==3.3",
|
||||||
"psycopg-pool==3.3.1",
|
"psycopg-pool==3.3.1",
|
||||||
]
|
]
|
||||||
webserver = [
|
webserver = [
|
||||||
@@ -160,10 +160,8 @@ explicit = true
|
|||||||
[tool.uv.sources]
|
[tool.uv.sources]
|
||||||
# Markers are chosen to select these almost exclusively when building the Docker image
|
# Markers are chosen to select these almost exclusively when building the Docker image
|
||||||
psycopg-c = [
|
psycopg-c = [
|
||||||
{ url = "https://github.com/paperless-ngx/builder/releases/download/psycopg-trixie-3.3.4/psycopg_c-3.3.4-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_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.4/psycopg_c-3.3.4-cp312-cp312-linux_aarch64.whl", marker = "sys_platform == 'linux' and platform_machine == 'aarch64' 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'" },
|
||||||
{ url = "https://github.com/paperless-ngx/builder/releases/download/psycopg-trixie-3.3.4/psycopg_c-3.3.4-cp314-cp314-linux_x86_64.whl", marker = "sys_platform == 'linux' and platform_machine == 'x86_64' and python_version == '3.14'" },
|
|
||||||
{ url = "https://github.com/paperless-ngx/builder/releases/download/psycopg-trixie-3.3.4/psycopg_c-3.3.4-cp314-cp314-linux_aarch64.whl", marker = "sys_platform == 'linux' and platform_machine == 'aarch64' and python_version == '3.14'" },
|
|
||||||
]
|
]
|
||||||
torch = [
|
torch = [
|
||||||
{ index = "pytorch-cpu" },
|
{ index = "pytorch-cpu" },
|
||||||
|
|||||||
+287
-850
File diff suppressed because it is too large
Load Diff
+22
-24
@@ -11,21 +11,20 @@
|
|||||||
},
|
},
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@angular/cdk": "^22.1.1",
|
"@angular/cdk": "^22.0.6",
|
||||||
"@angular/common": "~22.1.1",
|
"@angular/common": "~22.1.0",
|
||||||
"@angular/compiler": "~22.1.1",
|
"@angular/compiler": "~22.1.0",
|
||||||
"@angular/core": "~22.1.1",
|
"@angular/core": "~22.1.0",
|
||||||
"@angular/forms": "~22.1.1",
|
"@angular/forms": "~22.1.0",
|
||||||
"@angular/localize": "~22.1.1",
|
"@angular/localize": "~22.1.0",
|
||||||
"@angular/platform-browser": "~22.1.1",
|
"@angular/platform-browser": "~22.1.0",
|
||||||
"@angular/router": "~22.1.1",
|
"@angular/router": "~22.1.0",
|
||||||
"@ng-bootstrap/ng-bootstrap": "^21.0.0",
|
"@ng-bootstrap/ng-bootstrap": "^21.0.0",
|
||||||
"@ng-select/ng-select": "~23.6.0",
|
"@ng-select/ng-select": "^23.5.0",
|
||||||
"@ngneat/dirty-check-forms": "^3.0.3",
|
"@ngneat/dirty-check-forms": "^3.0.3",
|
||||||
"@popperjs/core": "^2.11.8",
|
"@popperjs/core": "^2.11.8",
|
||||||
"bootstrap": "^5.3.8",
|
"bootstrap": "^5.3.8",
|
||||||
"file-saver": "^2.0.5",
|
"file-saver": "^2.0.5",
|
||||||
"lodash-es": "^4.18.1",
|
|
||||||
"mime-names": "^1.0.0",
|
"mime-names": "^1.0.0",
|
||||||
"ngx-bootstrap-icons": "^1.9.3",
|
"ngx-bootstrap-icons": "^1.9.3",
|
||||||
"ngx-color": "^10.1.0",
|
"ngx-color": "^10.1.0",
|
||||||
@@ -41,32 +40,31 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@angular-builders/jest": "^22.0.1",
|
"@angular-builders/jest": "^22.0.1",
|
||||||
"@angular-devkit/core": "^22.1.3",
|
"@angular-devkit/core": "^22.1.2",
|
||||||
"@angular-devkit/schematics": "^22.1.3",
|
"@angular-devkit/schematics": "^22.1.2",
|
||||||
"@angular-eslint/builder": "22.1.0",
|
"@angular-eslint/builder": "22.1.0",
|
||||||
"@angular-eslint/eslint-plugin": "22.1.0",
|
"@angular-eslint/eslint-plugin": "22.1.0",
|
||||||
"@angular-eslint/eslint-plugin-template": "22.1.0",
|
"@angular-eslint/eslint-plugin-template": "22.1.0",
|
||||||
"@angular-eslint/schematics": "22.1.0",
|
"@angular-eslint/schematics": "22.1.0",
|
||||||
"@angular-eslint/template-parser": "22.1.0",
|
"@angular-eslint/template-parser": "22.1.0",
|
||||||
"@angular/build": "22.1.3",
|
"@angular/build": "22.1.2",
|
||||||
"@angular/cli": "22.1.3",
|
"@angular/cli": "22.1.2",
|
||||||
"@angular/compiler-cli": "~22.1.1",
|
"@angular/compiler-cli": "~22.1.0",
|
||||||
"@playwright/test": "^1.62.1",
|
"@playwright/test": "^1.62.0",
|
||||||
"@types/jest": "^30.0.0",
|
"@types/jest": "^30.0.0",
|
||||||
"@types/node": "^26.2.0",
|
"@types/node": "^26.1.1",
|
||||||
"@typescript-eslint/eslint-plugin": "^8.67.0",
|
"@typescript-eslint/eslint-plugin": "^8.65.0",
|
||||||
"@typescript-eslint/parser": "^8.67.0",
|
"@typescript-eslint/parser": "^8.65.0",
|
||||||
"@typescript-eslint/utils": "^8.67.0",
|
"@typescript-eslint/utils": "^8.65.0",
|
||||||
"eslint": "^10.8.1",
|
"eslint": "^10.8.0",
|
||||||
"jest": "30.4.2",
|
"jest": "30.4.2",
|
||||||
"jest-environment-jsdom": "^30.4.1",
|
"jest-environment-jsdom": "^30.4.1",
|
||||||
"jest-junit": "^17.0.0",
|
"jest-junit": "^17.0.0",
|
||||||
"jest-preset-angular": "^17.0.0",
|
"jest-preset-angular": "^17.0.0",
|
||||||
"jest-websocket-mock": "^2.5.0",
|
"jest-websocket-mock": "^2.5.0",
|
||||||
"prettier": "^3.9.6",
|
|
||||||
"prettier-plugin-organize-imports": "^4.3.0",
|
"prettier-plugin-organize-imports": "^4.3.0",
|
||||||
"ts-node": "~10.9.2",
|
"ts-node": "~10.9.1",
|
||||||
"typescript": "^6.0.3"
|
"typescript": "^6.0.3"
|
||||||
},
|
},
|
||||||
"packageManager": "pnpm@11.15.1"
|
"packageManager": "pnpm@10.26.0"
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+5881
-1023
File diff suppressed because it is too large
Load Diff
@@ -5,8 +5,6 @@ trustPolicy: no-downgrade
|
|||||||
trustPolicyExclude:
|
trustPolicyExclude:
|
||||||
- "chokidar@4.0.3"
|
- "chokidar@4.0.3"
|
||||||
- "semver@6.3.1 || 5.7.2"
|
- "semver@6.3.1 || 5.7.2"
|
||||||
blockExoticSubdeps: true
|
|
||||||
autoInstallPeers: false
|
|
||||||
allowBuilds:
|
allowBuilds:
|
||||||
"@parcel/watcher": true
|
"@parcel/watcher": true
|
||||||
canvas: true
|
canvas: true
|
||||||
|
|||||||
@@ -576,7 +576,7 @@ describe('TasksComponent', () => {
|
|||||||
|
|
||||||
expect(dismissSpy).toHaveBeenCalledWith(new Set([tasks[0].id, tasks[1].id]))
|
expect(dismissSpy).toHaveBeenCalledWith(new Set([tasks[0].id, tasks[1].id]))
|
||||||
expect(toastSpy).toHaveBeenCalledWith('Error dismissing tasks', error)
|
expect(toastSpy).toHaveBeenCalledWith('Error dismissing tasks', error)
|
||||||
expect(modal.componentInstance.buttonsEnabled()).toBe(true)
|
expect(modal.componentInstance.buttonsEnabled).toBe(true)
|
||||||
expect(component.selectedTasks.size).toBe(0)
|
expect(component.selectedTasks.size).toBe(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -642,7 +642,7 @@ describe('TasksComponent', () => {
|
|||||||
|
|
||||||
expect(dismissSpy).toHaveBeenCalled()
|
expect(dismissSpy).toHaveBeenCalled()
|
||||||
expect(toastSpy).toHaveBeenCalledWith('Error dismissing tasks', error)
|
expect(toastSpy).toHaveBeenCalledWith('Error dismissing tasks', error)
|
||||||
expect(modal.componentInstance.buttonsEnabled()).toBe(true)
|
expect(modal.componentInstance.buttonsEnabled).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should dismiss the currently visible scoped and filtered tasks', () => {
|
it('should dismiss the currently visible scoped and filtered tasks', () => {
|
||||||
|
|||||||
@@ -316,7 +316,7 @@ export class TasksComponent
|
|||||||
modal.componentInstance.btnClass = 'btn-warning'
|
modal.componentInstance.btnClass = 'btn-warning'
|
||||||
modal.componentInstance.btnCaption = $localize`Dismiss`
|
modal.componentInstance.btnCaption = $localize`Dismiss`
|
||||||
modal.componentInstance.confirmClicked.pipe(first()).subscribe(() => {
|
modal.componentInstance.confirmClicked.pipe(first()).subscribe(() => {
|
||||||
modal.componentInstance.buttonsEnabled.set(false)
|
modal.componentInstance.buttonsEnabled = false
|
||||||
modal.close()
|
modal.close()
|
||||||
this.tasksService.dismissTasks(tasks).subscribe({
|
this.tasksService.dismissTasks(tasks).subscribe({
|
||||||
next: () => {
|
next: () => {
|
||||||
@@ -324,7 +324,7 @@ export class TasksComponent
|
|||||||
},
|
},
|
||||||
error: (e) => {
|
error: (e) => {
|
||||||
this.toastService.showError($localize`Error dismissing tasks`, e)
|
this.toastService.showError($localize`Error dismissing tasks`, e)
|
||||||
modal.componentInstance.buttonsEnabled.set(true)
|
modal.componentInstance.buttonsEnabled = true
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
this.clearSelection()
|
this.clearSelection()
|
||||||
@@ -350,7 +350,7 @@ export class TasksComponent
|
|||||||
modal.componentInstance.btnClass = 'btn-warning'
|
modal.componentInstance.btnClass = 'btn-warning'
|
||||||
modal.componentInstance.btnCaption = $localize`Dismiss`
|
modal.componentInstance.btnCaption = $localize`Dismiss`
|
||||||
modal.componentInstance.confirmClicked.pipe(first()).subscribe(() => {
|
modal.componentInstance.confirmClicked.pipe(first()).subscribe(() => {
|
||||||
modal.componentInstance.buttonsEnabled.set(false)
|
modal.componentInstance.buttonsEnabled = false
|
||||||
modal.close()
|
modal.close()
|
||||||
this.tasksService.dismissAllTasks().subscribe({
|
this.tasksService.dismissAllTasks().subscribe({
|
||||||
next: () => {
|
next: () => {
|
||||||
@@ -358,7 +358,7 @@ export class TasksComponent
|
|||||||
},
|
},
|
||||||
error: (e) => {
|
error: (e) => {
|
||||||
this.toastService.showError($localize`Error dismissing tasks`, e)
|
this.toastService.showError($localize`Error dismissing tasks`, e)
|
||||||
modal.componentInstance.buttonsEnabled.set(true)
|
modal.componentInstance.buttonsEnabled = true
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
this.clearSelection()
|
this.clearSelection()
|
||||||
|
|||||||
@@ -2,8 +2,3 @@
|
|||||||
.d-block.d-sm-none .dropdown-toggle::after {
|
.d-block.d-sm-none .dropdown-toggle::after {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
tbody tr:last-child td,
|
|
||||||
table:not(:has(tbody tr)) thead th {
|
|
||||||
border-bottom: none;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ export class TrashComponent
|
|||||||
modal.componentInstance.confirmClicked
|
modal.componentInstance.confirmClicked
|
||||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||||
.subscribe(() => {
|
.subscribe(() => {
|
||||||
modal.componentInstance.buttonsEnabled.set(false)
|
modal.componentInstance.buttonsEnabled = false
|
||||||
this.trashService.emptyTrash([document.id]).subscribe({
|
this.trashService.emptyTrash([document.id]).subscribe({
|
||||||
next: () => {
|
next: () => {
|
||||||
this.toastService.showInfo(
|
this.toastService.showInfo(
|
||||||
|
|||||||
@@ -146,7 +146,7 @@ export class UsersAndGroupsComponent
|
|||||||
modal.componentInstance.btnClass = 'btn-danger'
|
modal.componentInstance.btnClass = 'btn-danger'
|
||||||
modal.componentInstance.btnCaption = $localize`Proceed`
|
modal.componentInstance.btnCaption = $localize`Proceed`
|
||||||
modal.componentInstance.confirmClicked.subscribe(() => {
|
modal.componentInstance.confirmClicked.subscribe(() => {
|
||||||
modal.componentInstance.buttonsEnabled.set(false)
|
modal.componentInstance.buttonsEnabled = false
|
||||||
this.usersService.delete(user).subscribe({
|
this.usersService.delete(user).subscribe({
|
||||||
next: () => {
|
next: () => {
|
||||||
modal.close()
|
modal.close()
|
||||||
@@ -199,7 +199,7 @@ export class UsersAndGroupsComponent
|
|||||||
modal.componentInstance.btnClass = 'btn-danger'
|
modal.componentInstance.btnClass = 'btn-danger'
|
||||||
modal.componentInstance.btnCaption = $localize`Proceed`
|
modal.componentInstance.btnCaption = $localize`Proceed`
|
||||||
modal.componentInstance.confirmClicked.subscribe(() => {
|
modal.componentInstance.confirmClicked.subscribe(() => {
|
||||||
modal.componentInstance.buttonsEnabled.set(false)
|
modal.componentInstance.buttonsEnabled = false
|
||||||
this.groupsService.delete(group).subscribe({
|
this.groupsService.delete(group).subscribe({
|
||||||
next: () => {
|
next: () => {
|
||||||
modal.close()
|
modal.close()
|
||||||
|
|||||||
@@ -111,7 +111,7 @@
|
|||||||
routerLinkActive="active" (click)="closeMenu()" [ngbPopover]="view.name"
|
routerLinkActive="active" (click)="closeMenu()" [ngbPopover]="view.name"
|
||||||
[disablePopover]="!slimSidebarEnabled" placement="end" container="body" triggers="mouseenter:mouseleave"
|
[disablePopover]="!slimSidebarEnabled" placement="end" container="body" triggers="mouseenter:mouseleave"
|
||||||
popoverClass="popover-slim">
|
popoverClass="popover-slim">
|
||||||
<i-bs class="me-2" [name]="view.icon || 'funnel'"></i-bs><span><div class="d-inline-flex view-name"><span class="overflow-hidden" [class.text-wrap]="!slimSidebarEnabled">{{view.name}}</span></div>
|
<i-bs class="me-2" name="funnel"></i-bs><span><div class="d-inline-flex view-name"><span class="overflow-hidden" [class.text-wrap]="!slimSidebarEnabled">{{view.name}}</span></div>
|
||||||
@if (showSidebarCounts && !slimSidebarEnabled) {
|
@if (showSidebarCounts && !slimSidebarEnabled) {
|
||||||
<span class="badge bg-info text-dark ms-2 d-inline">{{ savedViewService.getDocumentCount(view) }}</span>
|
<span class="badge bg-info text-dark ms-2 d-inline">{{ savedViewService.getDocumentCount(view) }}</span>
|
||||||
}
|
}
|
||||||
@@ -195,32 +195,32 @@
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="attributes-submenu ms-3"
|
class="attributes-submenu ms-2"
|
||||||
[ngbCollapse]="slimSidebarEnabled || attributesSectionsCollapsed"
|
[ngbCollapse]="slimSidebarEnabled || attributesSectionsCollapsed"
|
||||||
>
|
>
|
||||||
<ul class="nav flex-column">
|
<ul class="nav flex-column">
|
||||||
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Tag }">
|
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Tag }">
|
||||||
<a class="nav-link" routerLink="attributes/tags" routerLinkActive="active" (click)="closeMenu()">
|
<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>
|
<i-bs class="me-2" name="tags"></i-bs><span><ng-container i18n>Tags</ng-container></span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Correspondent }">
|
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Correspondent }">
|
||||||
<a class="nav-link" routerLink="attributes/correspondents" routerLinkActive="active" (click)="closeMenu()">
|
<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>
|
<i-bs class="me-2" name="person"></i-bs><span><ng-container i18n>Correspondents</ng-container></span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.DocumentType }">
|
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.DocumentType }">
|
||||||
<a class="nav-link" routerLink="attributes/documenttypes" routerLinkActive="active" (click)="closeMenu()">
|
<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>
|
<i-bs class="me-2" name="hash"></i-bs><span><ng-container i18n>Document types</ng-container></span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.StoragePath }">
|
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.StoragePath }">
|
||||||
<a class="nav-link" routerLink="attributes/storagepaths" routerLinkActive="active" (click)="closeMenu()">
|
<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>
|
<i-bs class="me-2" name="folder"></i-bs><span><ng-container i18n>Storage paths</ng-container></span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.CustomField }">
|
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.CustomField }">
|
||||||
<a class="nav-link" routerLink="attributes/customfields" routerLinkActive="active" (click)="closeMenu()">
|
<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>
|
<i-bs class="me-2" name="ui-radios"></i-bs><span><ng-container i18n>Custom fields</ng-container></span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -19,11 +19,6 @@
|
|||||||
height: 0.8em;
|
height: 0.8em;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-heading,
|
|
||||||
.text-uppercase {
|
|
||||||
letter-spacing: 0.06em;
|
|
||||||
}
|
|
||||||
|
|
||||||
.view-name {
|
.view-name {
|
||||||
max-width: calc(100% - 50px)
|
max-width: calc(100% - 50px)
|
||||||
}
|
}
|
||||||
@@ -52,12 +47,11 @@
|
|||||||
|
|
||||||
.search-container {
|
.search-container {
|
||||||
max-height: 4.5rem;
|
max-height: 4.5rem;
|
||||||
overflow: visible;
|
overflow: hidden;
|
||||||
transition: max-height .2s ease, opacity .2s ease, padding-top .2s ease, padding-bottom .2s ease;
|
transition: max-height .2s ease, opacity .2s ease, padding-top .2s ease, padding-bottom .2s ease;
|
||||||
|
|
||||||
&.mobile-hidden {
|
&.mobile-hidden {
|
||||||
max-height: 0;
|
max-height: 0;
|
||||||
overflow: hidden;
|
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
padding-top: 0 !important;
|
padding-top: 0 !important;
|
||||||
padding-bottom: 0 !important;
|
padding-bottom: 0 !important;
|
||||||
@@ -86,7 +80,7 @@ main {
|
|||||||
|
|
||||||
.sidebar li.nav-item span,
|
.sidebar li.nav-item span,
|
||||||
.sidebar .sidebar-heading span {
|
.sidebar .sidebar-heading span {
|
||||||
transition: opacity .1s ease;
|
transition: all .1s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media(min-width: 768px) {
|
@media(min-width: 768px) {
|
||||||
@@ -154,7 +148,7 @@ main {
|
|||||||
z-index: 996;
|
z-index: 996;
|
||||||
--bs-btn-padding-x: 0.35rem;
|
--bs-btn-padding-x: 0.35rem;
|
||||||
--bs-btn-padding-y: 0.125rem;
|
--bs-btn-padding-y: 0.125rem;
|
||||||
transition: left .2s ease;
|
transition: all .2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar.slim .sidebar-slim-toggler {
|
.sidebar.slim .sidebar-slim-toggler {
|
||||||
@@ -186,8 +180,6 @@ main {
|
|||||||
.sidebar .nav-link {
|
.sidebar .nav-link {
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
border-left: 2px solid transparent;
|
|
||||||
transition: color .15s ease-in-out;
|
|
||||||
|
|
||||||
&:hover, &.active, &:focus {
|
&:hover, &.active, &:focus {
|
||||||
color: var(--bs-primary);
|
color: var(--bs-primary);
|
||||||
@@ -200,7 +192,6 @@ main {
|
|||||||
|
|
||||||
&.active {
|
&.active {
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
border-left-color: var(--bs-primary);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
i-bs {
|
i-bs {
|
||||||
@@ -209,17 +200,6 @@ main {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// sub-page gets marker only
|
|
||||||
.nav-item:has(.attributes-submenu.show .nav-link.active) > .attributes-row > .nav-link.active {
|
|
||||||
border-left-color: transparent;
|
|
||||||
}
|
|
||||||
|
|
||||||
// bring sub-menu markers back out to L edge
|
|
||||||
.attributes-submenu .nav-link {
|
|
||||||
margin-left: -1rem;
|
|
||||||
padding-left: calc(var(--bs-nav-link-padding-x) + 1rem);
|
|
||||||
}
|
|
||||||
|
|
||||||
.attributes-row .attributes-expand-btn {
|
.attributes-row .attributes-expand-btn {
|
||||||
opacity: 0.2;
|
opacity: 0.2;
|
||||||
transition: opacity 0.15s ease-in-out;
|
transition: opacity 0.15s ease-in-out;
|
||||||
|
|||||||
@@ -12,10 +12,10 @@
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<button type="button" class="btn" [class]="cancelBtnClass" (click)="cancel()" [disabled]="!buttonsEnabled()">
|
<button type="button" class="btn" [class]="cancelBtnClass" (click)="cancel()" [disabled]="!buttonsEnabled">
|
||||||
<span class="d-inline-block" style="padding-bottom: 1px;">{{cancelBtnCaption}}</span>
|
<span class="d-inline-block" style="padding-bottom: 1px;">{{cancelBtnCaption}}</span>
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="btn" [class]="btnClass" (click)="confirm()" [disabled]="!confirmButtonEnabled || !buttonsEnabled()">
|
<button type="button" class="btn" [class]="btnClass" (click)="confirm()" [disabled]="!confirmButtonEnabled || !buttonsEnabled">
|
||||||
<span>
|
<span>
|
||||||
{{btnCaption}}
|
{{btnCaption}}
|
||||||
<span class="visually-hidden">{{ seconds | number: '1.0-0' }} seconds</span>
|
<span class="visually-hidden">{{ seconds | number: '1.0-0' }} seconds</span>
|
||||||
@@ -25,7 +25,7 @@
|
|||||||
}
|
}
|
||||||
</button>
|
</button>
|
||||||
@if (alternativeBtnCaption) {
|
@if (alternativeBtnCaption) {
|
||||||
<button type="button" class="btn" [class]="alternativeBtnClass" (click)="alternative()" [disabled]="!alternativeButtonEnabled || !buttonsEnabled()">
|
<button type="button" class="btn" [class]="alternativeBtnClass" (click)="alternative()" [disabled]="!alternativeButtonEnabled || !buttonsEnabled">
|
||||||
{{alternativeBtnCaption}}
|
{{alternativeBtnCaption}}
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,22 +64,6 @@ describe('ConfirmDialogComponent', () => {
|
|||||||
expect(confirmSubjectResult).toBeTruthy()
|
expect(confirmSubjectResult).toBeTruthy()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should re-render the buttons when they are toggled from outside', async () => {
|
|
||||||
const confirmButton: HTMLButtonElement =
|
|
||||||
fixture.nativeElement.querySelectorAll('.modal-footer button')[1]
|
|
||||||
expect(confirmButton.disabled).toBeFalsy()
|
|
||||||
|
|
||||||
// Deliberately no detectChanges: a request callback toggling this is all
|
|
||||||
// that happens, and nothing else schedules a render for the modal
|
|
||||||
component.buttonsEnabled.set(false)
|
|
||||||
await fixture.whenStable()
|
|
||||||
expect(confirmButton.disabled).toBeTruthy()
|
|
||||||
|
|
||||||
component.buttonsEnabled.set(true)
|
|
||||||
await fixture.whenStable()
|
|
||||||
expect(confirmButton.disabled).toBeFalsy()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should support cancel & close modal', () => {
|
it('should support cancel & close modal', () => {
|
||||||
let confirmSubjectResult
|
let confirmSubjectResult
|
||||||
const closeModalSpy = jest.spyOn(modal, 'close')
|
const closeModalSpy = jest.spyOn(modal, 'close')
|
||||||
|
|||||||
@@ -1,12 +1,5 @@
|
|||||||
import { DecimalPipe } from '@angular/common'
|
import { DecimalPipe } from '@angular/common'
|
||||||
import {
|
import { Component, EventEmitter, Input, Output, inject } from '@angular/core'
|
||||||
Component,
|
|
||||||
EventEmitter,
|
|
||||||
Input,
|
|
||||||
Output,
|
|
||||||
inject,
|
|
||||||
signal,
|
|
||||||
} from '@angular/core'
|
|
||||||
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
|
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
|
||||||
import { Subject } from 'rxjs'
|
import { Subject } from 'rxjs'
|
||||||
import { LoadingComponentWithPermissions } from '../../loading-component/loading.component'
|
import { LoadingComponentWithPermissions } from '../../loading-component/loading.component'
|
||||||
@@ -53,7 +46,8 @@ export class ConfirmDialogComponent extends LoadingComponentWithPermissions {
|
|||||||
@Input()
|
@Input()
|
||||||
cancelBtnCaption = $localize`Cancel`
|
cancelBtnCaption = $localize`Cancel`
|
||||||
|
|
||||||
readonly buttonsEnabled = signal(true)
|
@Input()
|
||||||
|
buttonsEnabled = true
|
||||||
|
|
||||||
confirmButtonEnabled = true
|
confirmButtonEnabled = true
|
||||||
alternativeButtonEnabled = true
|
alternativeButtonEnabled = true
|
||||||
|
|||||||
-58
@@ -1,58 +0,0 @@
|
|||||||
<div class="modal-header">
|
|
||||||
<h4 class="modal-title" id="modal-basic-title">{{title}}</h4>
|
|
||||||
<button type="button" class="btn-close" aria-label="Close" (click)="cancel()"></button>
|
|
||||||
</div>
|
|
||||||
<div class="modal-body">
|
|
||||||
<p>{{message}}</p>
|
|
||||||
<div class="form-group">
|
|
||||||
<label class="form-label" for="rootDocumentID" i18n>Root document:</label>
|
|
||||||
<select id="rootDocumentID" class="form-select" [ngModel]="rootDocumentID()" (ngModelChange)="rootDocumentID.set($event)">
|
|
||||||
@for (document of documents(); track document.id) {
|
|
||||||
<option [ngValue]="document.id">{{document.title}}</option>
|
|
||||||
}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
<div class="form-group mt-4">
|
|
||||||
<span class="form-label d-inline-block" i18n>Versions (oldest first):</span>
|
|
||||||
<ul class="list-group"
|
|
||||||
cdkDropList
|
|
||||||
[cdkDropListData]="versionDocumentIDs()"
|
|
||||||
(cdkDropListDropped)="onDrop($event)">
|
|
||||||
@for (documentID of versionDocumentIDs(); track documentID) {
|
|
||||||
@let document = getDocument(documentID);
|
|
||||||
@if (document) {
|
|
||||||
<li class="list-group-item d-flex align-items-center" cdkDrag>
|
|
||||||
<i-bs name="grip-vertical" class="me-2"></i-bs>
|
|
||||||
<div class="d-flex flex-column">
|
|
||||||
<div>
|
|
||||||
@if (document.correspondent) {
|
|
||||||
<b>{{document.correspondent | correspondentName | async}}: </b>
|
|
||||||
}{{document.title}}
|
|
||||||
</div>
|
|
||||||
<small class="text-muted">
|
|
||||||
{{document.created | customDate:'mediumDate'}}
|
|
||||||
@if (document.page_count) {
|
|
||||||
| {document.page_count, plural, =1 {One page} other {{{document.page_count}} pages}}
|
|
||||||
}
|
|
||||||
</small>
|
|
||||||
</div>
|
|
||||||
@if ($last) {
|
|
||||||
<span class="badge bg-primary ms-auto" i18n>Current version</span>
|
|
||||||
}
|
|
||||||
</li>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</ul>
|
|
||||||
@if (versionDocumentIDs().length > 1) {
|
|
||||||
<div class="form-text" i18n>Drag to reorder.</div>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="modal-footer">
|
|
||||||
<button type="button" class="btn" [class]="cancelBtnClass" (click)="cancel()" [disabled]="!buttonsEnabled()">
|
|
||||||
<span class="d-inline-block" style="padding-bottom: 1px;">{{cancelBtnCaption}}</span>
|
|
||||||
</button>
|
|
||||||
<button type="button" class="btn" [class]="btnClass" (click)="confirm()" [disabled]="!confirmButtonEnabled || !buttonsEnabled()">
|
|
||||||
{{btnCaption}}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
-3
@@ -1,3 +0,0 @@
|
|||||||
.list-group-item {
|
|
||||||
cursor: move;
|
|
||||||
}
|
|
||||||
-70
@@ -1,70 +0,0 @@
|
|||||||
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
|
|
||||||
import { provideHttpClientTesting } from '@angular/common/http/testing'
|
|
||||||
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
|
||||||
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
|
|
||||||
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
|
||||||
import { of } from 'rxjs'
|
|
||||||
import { DocumentService } from 'src/app/services/rest/document.service'
|
|
||||||
import { MergeAsVersionsConfirmDialogComponent } from './merge-as-versions-confirm-dialog.component'
|
|
||||||
|
|
||||||
describe('MergeAsVersionsConfirmDialogComponent', () => {
|
|
||||||
let component: MergeAsVersionsConfirmDialogComponent
|
|
||||||
let fixture: ComponentFixture<MergeAsVersionsConfirmDialogComponent>
|
|
||||||
let documentService: DocumentService
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
await TestBed.configureTestingModule({
|
|
||||||
imports: [
|
|
||||||
NgxBootstrapIconsModule.pick(allIcons),
|
|
||||||
MergeAsVersionsConfirmDialogComponent,
|
|
||||||
],
|
|
||||||
providers: [
|
|
||||||
NgbActiveModal,
|
|
||||||
provideHttpClient(withInterceptorsFromDi()),
|
|
||||||
provideHttpClientTesting(),
|
|
||||||
],
|
|
||||||
}).compileComponents()
|
|
||||||
|
|
||||||
fixture = TestBed.createComponent(MergeAsVersionsConfirmDialogComponent)
|
|
||||||
documentService = TestBed.inject(DocumentService)
|
|
||||||
component = fixture.componentInstance
|
|
||||||
fixture.detectChanges()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should fetch selected documents', () => {
|
|
||||||
const documents = [
|
|
||||||
{ id: 1, title: 'Document 1' },
|
|
||||||
{ id: 2, title: 'Document 2' },
|
|
||||||
]
|
|
||||||
jest.spyOn(documentService, 'getFew').mockReturnValue(
|
|
||||||
of({
|
|
||||||
all: [1, 2],
|
|
||||||
count: 2,
|
|
||||||
results: documents,
|
|
||||||
})
|
|
||||||
)
|
|
||||||
component.documentIDs.set([1, 2])
|
|
||||||
|
|
||||||
component.ngOnInit()
|
|
||||||
|
|
||||||
expect(component.documents()).toEqual(documents)
|
|
||||||
expect(documentService.getFew).toHaveBeenCalledWith([1, 2])
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should exclude the root from the draggable documents', () => {
|
|
||||||
component.documentIDs.set([1, 2, 3])
|
|
||||||
component.rootDocumentID.set(2)
|
|
||||||
|
|
||||||
expect(component.versionDocumentIDs()).toEqual([1, 3])
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should move draggable documents while keeping the root fixed', () => {
|
|
||||||
component.documentIDs.set([1, 2, 3])
|
|
||||||
component.rootDocumentID.set(1)
|
|
||||||
|
|
||||||
component.onDrop({ previousIndex: 1, currentIndex: 0 } as any)
|
|
||||||
|
|
||||||
expect(component.documentIDs()).toEqual([1, 3, 2])
|
|
||||||
expect(component.versionDocumentIDs()).toEqual([3, 2])
|
|
||||||
})
|
|
||||||
})
|
|
||||||
-70
@@ -1,70 +0,0 @@
|
|||||||
import {
|
|
||||||
CdkDragDrop,
|
|
||||||
DragDropModule,
|
|
||||||
moveItemInArray,
|
|
||||||
} from '@angular/cdk/drag-drop'
|
|
||||||
import { AsyncPipe } from '@angular/common'
|
|
||||||
import { Component, OnInit, computed, inject, signal } from '@angular/core'
|
|
||||||
import { FormsModule } from '@angular/forms'
|
|
||||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
|
||||||
import { takeUntil } from 'rxjs'
|
|
||||||
import { Document } from 'src/app/data/document'
|
|
||||||
import { CorrespondentNamePipe } from 'src/app/pipes/correspondent-name.pipe'
|
|
||||||
import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
|
|
||||||
import { DocumentService } from 'src/app/services/rest/document.service'
|
|
||||||
import { ConfirmDialogComponent } from '../confirm-dialog.component'
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: 'pngx-merge-as-versions-confirm-dialog',
|
|
||||||
templateUrl: './merge-as-versions-confirm-dialog.component.html',
|
|
||||||
styleUrl: './merge-as-versions-confirm-dialog.component.scss',
|
|
||||||
imports: [
|
|
||||||
AsyncPipe,
|
|
||||||
CorrespondentNamePipe,
|
|
||||||
CustomDatePipe,
|
|
||||||
DragDropModule,
|
|
||||||
FormsModule,
|
|
||||||
NgxBootstrapIconsModule,
|
|
||||||
],
|
|
||||||
})
|
|
||||||
export class MergeAsVersionsConfirmDialogComponent
|
|
||||||
extends ConfirmDialogComponent
|
|
||||||
implements OnInit
|
|
||||||
{
|
|
||||||
private readonly documentService = inject(DocumentService)
|
|
||||||
|
|
||||||
readonly documentIDs = signal<number[]>([])
|
|
||||||
readonly documents = signal<Document[]>([])
|
|
||||||
readonly rootDocumentID = signal(-1)
|
|
||||||
readonly versionDocumentIDs = computed(() =>
|
|
||||||
this.documentIDs().filter(
|
|
||||||
(documentID) => documentID !== this.rootDocumentID()
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
ngOnInit() {
|
|
||||||
this.documentService
|
|
||||||
.getFew(this.documentIDs())
|
|
||||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
|
||||||
.subscribe((response) => this.documents.set(response.results))
|
|
||||||
}
|
|
||||||
|
|
||||||
onDrop(event: CdkDragDrop<number[]>) {
|
|
||||||
const versionDocumentIDs = this.versionDocumentIDs().concat()
|
|
||||||
moveItemInArray(versionDocumentIDs, event.previousIndex, event.currentIndex)
|
|
||||||
|
|
||||||
// The root keeps its place in the list, only the versions move around it
|
|
||||||
let versionIndex = 0
|
|
||||||
this.documentIDs.update((documentIDs) =>
|
|
||||||
documentIDs.map((documentID) =>
|
|
||||||
documentID === this.rootDocumentID()
|
|
||||||
? documentID
|
|
||||||
: versionDocumentIDs[versionIndex++]
|
|
||||||
)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
getDocument(documentID: number): Document | undefined {
|
|
||||||
return this.documents().find((document) => document.id === documentID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+3
-3
@@ -36,7 +36,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="form-group mt-4">
|
<div class="form-group mt-4">
|
||||||
<label class="form-label" for="metadataDocumentID" i18n>Use metadata from:</label>
|
<label class="form-label" for="metadataDocumentID" i18n>Use metadata from:</label>
|
||||||
<select id="metadataDocumentID" class="form-select" [ngModel]="metadataDocumentID()" (ngModelChange)="metadataDocumentID.set($event)">
|
<select class="form-select" [ngModel]="metadataDocumentID()" (ngModelChange)="metadataDocumentID.set($event)">
|
||||||
<option [ngValue]="-1" i18n>Regenerate all metadata</option>
|
<option [ngValue]="-1" i18n>Regenerate all metadata</option>
|
||||||
@for (document of documents(); track document.id) {
|
@for (document of documents(); track document.id) {
|
||||||
<option [ngValue]="document.id">{{document.title}}</option>
|
<option [ngValue]="document.id">{{document.title}}</option>
|
||||||
@@ -56,10 +56,10 @@
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<button type="button" class="btn" [class]="cancelBtnClass" (click)="cancel()" [disabled]="!buttonsEnabled()">
|
<button type="button" class="btn" [class]="cancelBtnClass" (click)="cancel()" [disabled]="!buttonsEnabled">
|
||||||
<span class="d-inline-block" style="padding-bottom: 1px;">{{cancelBtnCaption}}</span>
|
<span class="d-inline-block" style="padding-bottom: 1px;">{{cancelBtnCaption}}</span>
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="btn" [class]="btnClass" (click)="confirm()" [disabled]="!confirmButtonEnabled || !buttonsEnabled()">
|
<button type="button" class="btn" [class]="btnClass" (click)="confirm()" [disabled]="!confirmButtonEnabled || !buttonsEnabled">
|
||||||
{{btnCaption}}
|
{{btnCaption}}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+2
-2
@@ -57,7 +57,7 @@
|
|||||||
class="btn"
|
class="btn"
|
||||||
[class]="cancelBtnClass"
|
[class]="cancelBtnClass"
|
||||||
(click)="cancel()"
|
(click)="cancel()"
|
||||||
[disabled]="!buttonsEnabled()"
|
[disabled]="!buttonsEnabled"
|
||||||
>
|
>
|
||||||
<span class="d-inline-block" style="padding-bottom: 1px;">
|
<span class="d-inline-block" style="padding-bottom: 1px;">
|
||||||
{{cancelBtnCaption}}
|
{{cancelBtnCaption}}
|
||||||
@@ -68,7 +68,7 @@
|
|||||||
class="btn"
|
class="btn"
|
||||||
[class]="btnClass"
|
[class]="btnClass"
|
||||||
(click)="confirm()"
|
(click)="confirm()"
|
||||||
[disabled]="!confirmButtonEnabled || !buttonsEnabled()"
|
[disabled]="!confirmButtonEnabled || !buttonsEnabled"
|
||||||
>
|
>
|
||||||
{{btnCaption}}
|
{{btnCaption}}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
+2
-2
@@ -34,10 +34,10 @@
|
|||||||
<p class="mb-0 small"><b>{{messageBold}}</b></p>
|
<p class="mb-0 small"><b>{{messageBold}}</b></p>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<button type="button" class="btn" [class]="cancelBtnClass" (click)="cancel()" [disabled]="!buttonsEnabled()">
|
<button type="button" class="btn" [class]="cancelBtnClass" (click)="cancel()" [disabled]="!buttonsEnabled">
|
||||||
<span class="d-inline-block" style="padding-bottom: 1px;">{{cancelBtnCaption}}</span>
|
<span class="d-inline-block" style="padding-bottom: 1px;">{{cancelBtnCaption}}</span>
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="btn" [class]="btnClass" (click)="confirm()" [disabled]="!confirmButtonEnabled || !buttonsEnabled() || degrees === 0">
|
<button type="button" class="btn" [class]="btnClass" (click)="confirm()" [disabled]="!confirmButtonEnabled || !buttonsEnabled || degrees === 0">
|
||||||
{{btnCaption}}
|
{{btnCaption}}
|
||||||
@if (!confirmButtonEnabled) {
|
@if (!confirmButtonEnabled) {
|
||||||
<ngb-progressbar style="height: 1px;" type="dark" [max]="secondsTotal" [value]="seconds"></ngb-progressbar>
|
<ngb-progressbar style="height: 1px;" type="dark" [max]="secondsTotal" [value]="seconds"></ngb-progressbar>
|
||||||
|
|||||||
+3
-3
@@ -1,4 +1,4 @@
|
|||||||
<div class="btn-group w-100" ngbDropdown role="group" (openChange)="dropdownOpenChange($event)" #dropdown="ngbDropdown" (keydown)="listKeyDown($event)" [popperOptions]="popperOptions" [autoClose]="!creating()">
|
<div class="btn-group w-100" ngbDropdown role="group" (openChange)="dropdownOpenChange($event)" #dropdown="ngbDropdown" (keydown)="listKeyDown($event)" [popperOptions]="popperOptions">
|
||||||
<button class="btn btn-sm" id="dropdown_{{name}}" ngbDropdownToggle [ngClass]="!editing && selectionModel.selectionSize() > 0 ? 'btn-primary' : 'btn-outline-primary'" [disabled]="disabled">
|
<button class="btn btn-sm" id="dropdown_{{name}}" ngbDropdownToggle [ngClass]="!editing && selectionModel.selectionSize() > 0 ? 'btn-primary' : 'btn-outline-primary'" [disabled]="disabled">
|
||||||
<i-bs name="{{icon}}"></i-bs><div class="d-none d-sm-inline ms-1">{{title}}</div>
|
<i-bs name="{{icon}}"></i-bs><div class="d-none d-sm-inline ms-1">{{title}}</div>
|
||||||
@if (!editing && selectionModel.totalCount > 0) {
|
@if (!editing && selectionModel.totalCount > 0) {
|
||||||
@@ -49,7 +49,7 @@
|
|||||||
</cdk-virtual-scroll-viewport>
|
</cdk-virtual-scroll-viewport>
|
||||||
}
|
}
|
||||||
@if (editing) {
|
@if (editing) {
|
||||||
@if (filteredItems.length === 0 && createRef !== undefined && filterText?.length > 0) {
|
@if (filteredItems.length === 0 && createRef !== undefined) {
|
||||||
<button class="list-group-item list-group-item-action bg-light" (click)="createClicked()" [disabled]="disabled">
|
<button class="list-group-item list-group-item-action bg-light" (click)="createClicked()" [disabled]="disabled">
|
||||||
<small class="ms-2"><ng-container i18n>Create</ng-container> "{{filterText}}"</small>
|
<small class="ms-2"><ng-container i18n>Create</ng-container> "{{filterText}}"</small>
|
||||||
<i-bs width="1.5em" height="1em" name="plus"></i-bs>
|
<i-bs width="1.5em" height="1em" name="plus"></i-bs>
|
||||||
@@ -62,7 +62,7 @@
|
|||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@if (extraButtonTitle && (showExtraButtonIfEmpty || filteredItems?.length > 0)) {
|
@if (extraButtonTitle) {
|
||||||
<button class="list-group-item list-group-item-action bg-light d-flex align-items-center" (click)="extraButtonClicked($event)" [disabled]="disabled">
|
<button class="list-group-item list-group-item-action bg-light d-flex align-items-center" (click)="extraButtonClicked($event)" [disabled]="disabled">
|
||||||
<small class="ms-2 fw-bold">{{extraButtonTitle}}</small>
|
<small class="ms-2 fw-bold">{{extraButtonTitle}}</small>
|
||||||
<i-bs width="1.5em" height="1em" name="arrow-right"></i-bs>
|
<i-bs width="1.5em" height="1em" name="arrow-right"></i-bs>
|
||||||
|
|||||||
+4
-63
@@ -3,7 +3,6 @@ import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
|
|||||||
import { provideHttpClientTesting } from '@angular/common/http/testing'
|
import { provideHttpClientTesting } from '@angular/common/http/testing'
|
||||||
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||||
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
||||||
import { NEVER, Subject } from 'rxjs'
|
|
||||||
import { NEGATIVE_NULL_FILTER_VALUE } from 'src/app/data/filter-rule-type'
|
import { NEGATIVE_NULL_FILTER_VALUE } from 'src/app/data/filter-rule-type'
|
||||||
import {
|
import {
|
||||||
DEFAULT_MATCHING_ALGORITHM,
|
DEFAULT_MATCHING_ALGORITHM,
|
||||||
@@ -49,7 +48,6 @@ const negativeNullItem = {
|
|||||||
|
|
||||||
let selectionModel: FilterableDropdownSelectionModel
|
let selectionModel: FilterableDropdownSelectionModel
|
||||||
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
|
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
|
||||||
const createModalRef = () => ({ closed: NEVER, dismissed: NEVER }) as any
|
|
||||||
|
|
||||||
describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () => {
|
describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () => {
|
||||||
let component: FilterableDropdownComponent
|
let component: FilterableDropdownComponent
|
||||||
@@ -870,7 +868,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
|
|||||||
expect(getRootDocCount(rootWithoutCounts.id)).toEqual(0)
|
expect(getRootDocCount(rootWithoutCounts.id)).toEqual(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should keep the dropdown open while the create modal is active', async () => {
|
it('should set support create, keep open model and call createRef method', async () => {
|
||||||
component.selectionModel.items = items
|
component.selectionModel.items = items
|
||||||
component.icon = 'tag-fill'
|
component.icon = 'tag-fill'
|
||||||
component.selectionModel = selectionModel
|
component.selectionModel = selectionModel
|
||||||
@@ -884,44 +882,20 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
|
|||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
|
|
||||||
component.filterText = 'Test Filter Text'
|
component.filterText = 'Test Filter Text'
|
||||||
const modalClosed = new Subject<void>()
|
component.createRef = jest.fn()
|
||||||
component.createRef = jest.fn(
|
|
||||||
() =>
|
|
||||||
({
|
|
||||||
closed: modalClosed,
|
|
||||||
dismissed: NEVER,
|
|
||||||
}) as any
|
|
||||||
)
|
|
||||||
component.createClicked()
|
component.createClicked()
|
||||||
expect(component.creating()).toBeTruthy()
|
expect(component.creating).toBeTruthy()
|
||||||
expect(component.createRef).toHaveBeenCalledWith('Test Filter Text')
|
expect(component.createRef).toHaveBeenCalledWith('Test Filter Text')
|
||||||
fixture.detectChanges()
|
|
||||||
expect(component.dropdown.autoClose).toBeFalsy()
|
|
||||||
|
|
||||||
document.body.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }))
|
|
||||||
document.body.dispatchEvent(new MouseEvent('mouseup', { bubbles: true }))
|
|
||||||
await wait(10)
|
|
||||||
expect(component.dropdown.isOpen()).toBeTruthy()
|
|
||||||
|
|
||||||
// Also cover a close that was already scheduled before autoClose changed.
|
|
||||||
const openSpy = jest.spyOn(component.dropdown, 'open')
|
const openSpy = jest.spyOn(component.dropdown, 'open')
|
||||||
component.dropdownOpenChange(false)
|
component.dropdownOpenChange(false)
|
||||||
expect(openSpy).toHaveBeenCalled() // should keep open
|
expect(openSpy).toHaveBeenCalled() // should keep open
|
||||||
component.dropdownOpenChange(false)
|
|
||||||
expect(openSpy).toHaveBeenCalledTimes(2) // modal interactions keep it open
|
|
||||||
|
|
||||||
modalClosed.next()
|
|
||||||
fixture.detectChanges()
|
|
||||||
expect(component.creating()).toBeFalsy()
|
|
||||||
expect(component.dropdown.autoClose).toBeTruthy()
|
|
||||||
expect(component.dropdown.isOpen()).toBeTruthy()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should call create on enter inside filter field if 0 items remain while editing', async () => {
|
it('should call create on enter inside filter field if 0 items remain while editing', async () => {
|
||||||
component.selectionModel.items = items
|
component.selectionModel.items = items
|
||||||
component.icon = 'tag-fill'
|
component.icon = 'tag-fill'
|
||||||
component.editing = true
|
component.editing = true
|
||||||
component.createRef = jest.fn(createModalRef)
|
component.createRef = jest.fn()
|
||||||
const createSpy = jest.spyOn(component, 'createClicked')
|
const createSpy = jest.spyOn(component, 'createClicked')
|
||||||
expect(component.selectionModel.getSelectedItems()).toEqual([])
|
expect(component.selectionModel.getSelectedItems()).toEqual([])
|
||||||
fixture.nativeElement
|
fixture.nativeElement
|
||||||
@@ -937,25 +911,6 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
|
|||||||
expect(createSpy).toHaveBeenCalled()
|
expect(createSpy).toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should only show create when a non-empty filter has no matches', () => {
|
|
||||||
component.selectionModel.items = []
|
|
||||||
component.icon = 'tag-fill'
|
|
||||||
component.editing = true
|
|
||||||
component.createRef = jest.fn(createModalRef)
|
|
||||||
|
|
||||||
fixture.detectChanges()
|
|
||||||
expect(fixture.nativeElement.textContent).not.toContain('Create')
|
|
||||||
component.listFilterEnter()
|
|
||||||
expect(component.createRef).not.toHaveBeenCalled()
|
|
||||||
|
|
||||||
const filterInput: HTMLInputElement =
|
|
||||||
fixture.nativeElement.querySelector('input[type="text"]')
|
|
||||||
filterInput.value = 'FooBar'
|
|
||||||
filterInput.dispatchEvent(new Event('input'))
|
|
||||||
fixture.detectChanges()
|
|
||||||
expect(fixture.nativeElement.textContent).toContain('Create "FooBar"')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should exclude item and trigger change event', () => {
|
it('should exclude item and trigger change event', () => {
|
||||||
const id = 1
|
const id = 1
|
||||||
const state = ToggleableItemState.Selected
|
const state = ToggleableItemState.Selected
|
||||||
@@ -1015,18 +970,4 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
|
|||||||
expect(extraButtonClicked).toBeTruthy()
|
expect(extraButtonClicked).toBeTruthy()
|
||||||
expect(applied).toBeFalsy()
|
expect(applied).toBeFalsy()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should only show the extra button for an empty result when enabled', () => {
|
|
||||||
component.selectionModel.items = items
|
|
||||||
component.icon = 'tag-fill'
|
|
||||||
component.extraButtonTitle = 'Extra'
|
|
||||||
component.filterText = 'FooBar'
|
|
||||||
|
|
||||||
fixture.detectChanges()
|
|
||||||
expect(fixture.nativeElement.textContent).not.toContain('Extra')
|
|
||||||
|
|
||||||
fixture.componentRef.setInput('showExtraButtonIfEmpty', true)
|
|
||||||
fixture.detectChanges()
|
|
||||||
expect(fixture.nativeElement.textContent).toContain('Extra')
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|||||||
+9
-25
@@ -15,13 +15,9 @@ import {
|
|||||||
signal,
|
signal,
|
||||||
} from '@angular/core'
|
} from '@angular/core'
|
||||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||||
import {
|
import { NgbDropdown, NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap'
|
||||||
NgbDropdown,
|
|
||||||
NgbDropdownModule,
|
|
||||||
NgbModalRef,
|
|
||||||
} from '@ng-bootstrap/ng-bootstrap'
|
|
||||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||||
import { Subject, filter, first, merge, takeUntil } from 'rxjs'
|
import { Subject, filter, takeUntil } from 'rxjs'
|
||||||
import { NEGATIVE_NULL_FILTER_VALUE } from 'src/app/data/filter-rule-type'
|
import { NEGATIVE_NULL_FILTER_VALUE } from 'src/app/data/filter-rule-type'
|
||||||
import { MatchingModel } from 'src/app/data/matching-model'
|
import { MatchingModel } from 'src/app/data/matching-model'
|
||||||
import { ObjectWithPermissions } from 'src/app/data/object-with-permissions'
|
import { ObjectWithPermissions } from 'src/app/data/object-with-permissions'
|
||||||
@@ -763,7 +759,7 @@ export class FilterableDropdownComponent
|
|||||||
disabled = false
|
disabled = false
|
||||||
|
|
||||||
@Input()
|
@Input()
|
||||||
createRef: (name: string) => NgbModalRef
|
createRef: (name) => void
|
||||||
|
|
||||||
@Input()
|
@Input()
|
||||||
set documentCounts(counts: SelectionDataItem[]) {
|
set documentCounts(counts: SelectionDataItem[]) {
|
||||||
@@ -778,10 +774,7 @@ export class FilterableDropdownComponent
|
|||||||
@Input()
|
@Input()
|
||||||
extraButtonTitle: string
|
extraButtonTitle: string
|
||||||
|
|
||||||
@Input()
|
creating: boolean = false
|
||||||
showExtraButtonIfEmpty: boolean = false
|
|
||||||
|
|
||||||
readonly creating = signal(false)
|
|
||||||
|
|
||||||
@Output()
|
@Output()
|
||||||
apply = new EventEmitter<ChangedItems>()
|
apply = new EventEmitter<ChangedItems>()
|
||||||
@@ -858,18 +851,12 @@ export class FilterableDropdownComponent
|
|||||||
}
|
}
|
||||||
|
|
||||||
createClicked() {
|
createClicked() {
|
||||||
this.creating.set(true)
|
this.creating = true
|
||||||
const modal = this.createRef(this.filterText)
|
this.createRef(this.filterText)
|
||||||
merge(modal.closed, modal.dismissed)
|
|
||||||
.pipe(first(), takeUntil(this.unsubscribeNotifier))
|
|
||||||
.subscribe(() => this.creating.set(false))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
dropdownOpenChange(open: boolean): void {
|
dropdownOpenChange(open: boolean): void {
|
||||||
if (open) {
|
if (open) {
|
||||||
// Dont let a create modal close this
|
|
||||||
if (this.creating()) return
|
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
this.listFilterTextInput?.nativeElement.focus()
|
this.listFilterTextInput?.nativeElement.focus()
|
||||||
this.buttonsViewport?.checkViewportSize()
|
this.buttonsViewport?.checkViewportSize()
|
||||||
@@ -882,8 +869,9 @@ export class FilterableDropdownComponent
|
|||||||
this.editing && !this.selectionModel.manyToOne
|
this.editing && !this.selectionModel.manyToOne
|
||||||
this.opened.next(this)
|
this.opened.next(this)
|
||||||
} else {
|
} else {
|
||||||
if (this.creating()) {
|
if (this.creating) {
|
||||||
this.dropdown?.open()
|
this.dropdown?.open()
|
||||||
|
this.creating = false
|
||||||
} else {
|
} else {
|
||||||
this.filterText = ''
|
this.filterText = ''
|
||||||
if (this.applyOnClose && this.selectionModel.isDirty()) {
|
if (this.applyOnClose && this.selectionModel.isDirty()) {
|
||||||
@@ -904,11 +892,7 @@ export class FilterableDropdownComponent
|
|||||||
this.dropdown.close()
|
this.dropdown.close()
|
||||||
}
|
}
|
||||||
}, 200)
|
}, 200)
|
||||||
} else if (
|
} else if (filtered.length == 0 && this.createRef) {
|
||||||
filtered.length == 0 &&
|
|
||||||
this.createRef &&
|
|
||||||
this.filterText?.length > 0
|
|
||||||
) {
|
|
||||||
this.createClicked()
|
this.createClicked()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-4
@@ -52,10 +52,10 @@ describe('CustomFieldsValuesComponent', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('should set selectedFields and map values correctly', () => {
|
it('should set selectedFields and map values correctly', () => {
|
||||||
component.value = { 1: 'value1', 3: 0, 4: false }
|
component.value = { 1: 'value1' }
|
||||||
component.selectedFields = [1, 2, 3, 4]
|
component.selectedFields = [1, 2]
|
||||||
expect(component.selectedFields).toEqual([1, 2, 3, 4])
|
expect(component.selectedFields).toEqual([1, 2])
|
||||||
expect(component.value).toEqual({ 1: 'value1', 2: null, 3: 0, 4: false })
|
expect(component.value).toEqual({ 1: 'value1', 2: null })
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should return the correct custom field by id', () => {
|
it('should return the correct custom field by id', () => {
|
||||||
|
|||||||
+1
-1
@@ -77,7 +77,7 @@ export class CustomFieldsValuesComponent extends AbstractInputComponent<Object>
|
|||||||
this._selectedFields = newFields
|
this._selectedFields = newFields
|
||||||
// map the selected fields to an object with field_id as key and value as value
|
// map the selected fields to an object with field_id as key and value as value
|
||||||
this.value = newFields.reduce((acc, fieldId) => {
|
this.value = newFields.reduce((acc, fieldId) => {
|
||||||
acc[fieldId] = this.value?.[fieldId] ?? null
|
acc[fieldId] = this.value?.[fieldId] || null
|
||||||
return acc
|
return acc
|
||||||
}, {})
|
}, {})
|
||||||
this.onChange(this.value)
|
this.onChange(this.value)
|
||||||
|
|||||||
@@ -36,16 +36,7 @@
|
|||||||
(focus)="clearLastSearchTerm()"
|
(focus)="clearLastSearchTerm()"
|
||||||
(clear)="clearLastSearchTerm()"
|
(clear)="clearLastSearchTerm()"
|
||||||
(blur)="onBlur()">
|
(blur)="onBlur()">
|
||||||
<ng-template ng-label-tmp let-item="item">
|
|
||||||
@if (iconField && item[iconField]) {
|
|
||||||
<i-bs class="me-2" [name]="item[iconField]"></i-bs>
|
|
||||||
}
|
|
||||||
<span [title]="item[bindLabel]">{{item[bindLabel]}}</span>
|
|
||||||
</ng-template>
|
|
||||||
<ng-template ng-option-tmp let-item="item">
|
<ng-template ng-option-tmp let-item="item">
|
||||||
@if (iconField && item[iconField]) {
|
|
||||||
<i-bs class="me-2" [name]="item[iconField]"></i-bs>
|
|
||||||
}
|
|
||||||
<span [title]="item[bindLabel]">{{item[bindLabel]}}</span>
|
<span [title]="item[bindLabel]">{{item[bindLabel]}}</span>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
</ng-select>
|
</ng-select>
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ import { AbstractInputComponent } from '../abstract-input'
|
|||||||
NgxBootstrapIconsModule,
|
NgxBootstrapIconsModule,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class SelectComponent extends AbstractInputComponent<number | string> {
|
export class SelectComponent extends AbstractInputComponent<number> {
|
||||||
constructor() {
|
constructor() {
|
||||||
super()
|
super()
|
||||||
this.addItemRef = this.addItem.bind(this)
|
this.addItemRef = this.addItem.bind(this)
|
||||||
@@ -100,9 +100,6 @@ export class SelectComponent extends AbstractInputComponent<number | string> {
|
|||||||
@Input()
|
@Input()
|
||||||
bindLabel: string = 'name'
|
bindLabel: string = 'name'
|
||||||
|
|
||||||
@Input()
|
|
||||||
iconField: string
|
|
||||||
|
|
||||||
public searchFn = (term: string, item: any): boolean =>
|
public searchFn = (term: string, item: any): boolean =>
|
||||||
matchesSearchText(item?.[this.bindLabel], term)
|
matchesSearchText(item?.[this.bindLabel], term)
|
||||||
|
|
||||||
|
|||||||
@@ -100,7 +100,7 @@
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
<div class="form-group ms-md-auto">
|
<div class="form-group ms-md-auto">
|
||||||
<button type="button" class="btn me-2" [class]="cancelBtnClass" (click)="cancel()" [disabled]="!buttonsEnabled()">{{ cancelBtnCaption }}</button>
|
<button type="button" class="btn me-2" [class]="cancelBtnClass" (click)="cancel()" [disabled]="!buttonsEnabled">{{ cancelBtnCaption }}</button>
|
||||||
<button type="button" class="btn" [class]="btnClass" (click)="confirm()" [disabled]="pages.length === 0">{{ btnCaption }}</button>
|
<button type="button" class="btn" [class]="btnClass" (click)="confirm()" [disabled]="pages.length === 0">{{ btnCaption }}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+3
-3
@@ -22,7 +22,7 @@
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<div class="me-1">
|
<div class="me-1">
|
||||||
<small>{{ownerFilterLabel}}</small>
|
<small i18n>My documents</small>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
<button class="list-group-item list-group-item-action d-flex align-items-center p-2 border-top-0 border-start-0 border-end-0 border-bottom" role="menuitem" (click)="setFilter(OwnerFilterType.NOT_SELF)" [disabled]="disabled">
|
<button class="list-group-item list-group-item-action d-flex align-items-center p-2 border-top-0 border-start-0 border-end-0 border-bottom" role="menuitem" (click)="setFilter(OwnerFilterType.NOT_SELF)" [disabled]="disabled">
|
||||||
@@ -32,7 +32,7 @@
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<div class="me-1">
|
<div class="me-1">
|
||||||
<small>{{ownerExclusionFilterLabel}}</small>
|
<small i18n>Shared with me</small>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
<button class="list-group-item list-group-item-action d-flex align-items-center p-2 border-top-0 border-start-0 border-end-0 border-bottom" role="menuitem" (click)="setFilter(OwnerFilterType.SHARED_BY_ME)" [disabled]="disabled">
|
<button class="list-group-item list-group-item-action d-flex align-items-center p-2 border-top-0 border-start-0 border-end-0 border-bottom" role="menuitem" (click)="setFilter(OwnerFilterType.SHARED_BY_ME)" [disabled]="disabled">
|
||||||
@@ -42,7 +42,7 @@
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<div class="me-1">
|
<div class="me-1">
|
||||||
<small>{{sharedByFilterLabel}}</small>
|
<small i18n>Shared by me</small>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
<button class="list-group-item list-group-item-action d-flex align-items-center p-2 border-top-0 border-start-0 border-end-0 border-bottom" role="menuitem" (click)="setFilter(OwnerFilterType.UNOWNED)" [disabled]="disabled">
|
<button class="list-group-item list-group-item-action d-flex align-items-center p-2 border-top-0 border-start-0 border-end-0 border-bottom" role="menuitem" (click)="setFilter(OwnerFilterType.UNOWNED)" [disabled]="disabled">
|
||||||
|
|||||||
-52
@@ -94,58 +94,6 @@ describe('PermissionsFilterDropdownComponent', () => {
|
|||||||
expect(component.isActive).toBeTruthy()
|
expect(component.isActive).toBeTruthy()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should describe concrete user filters honestly', () => {
|
|
||||||
component.selectionModel.ownerFilter = OwnerFilterType.SELF
|
|
||||||
component.selectionModel.userID = 1
|
|
||||||
expect(component.ownerFilterLabel).toEqual('Owned by user1')
|
|
||||||
|
|
||||||
component.selectionModel.ownerFilter = OwnerFilterType.NOT_SELF
|
|
||||||
component.selectionModel.excludeUsers = [1]
|
|
||||||
expect(component.ownerExclusionFilterLabel).toEqual('Not owned by user1')
|
|
||||||
|
|
||||||
component.selectionModel.ownerFilter = OwnerFilterType.SHARED_BY_ME
|
|
||||||
component.selectionModel.userID = 1
|
|
||||||
expect(component.sharedByFilterLabel).toEqual('Shared by user1')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should describe concrete filters when usernames are unavailable', () => {
|
|
||||||
component.selectionModel.ownerFilter = OwnerFilterType.SELF
|
|
||||||
component.selectionModel.userID = 99
|
|
||||||
expect(component.ownerFilterLabel).toEqual('Owned by another user')
|
|
||||||
|
|
||||||
component.selectionModel.ownerFilter = OwnerFilterType.NOT_SELF
|
|
||||||
component.selectionModel.excludeUsers = [99]
|
|
||||||
expect(component.ownerExclusionFilterLabel).toEqual(
|
|
||||||
'Not owned by another user'
|
|
||||||
)
|
|
||||||
|
|
||||||
component.selectionModel.excludeUsers = [98, 99]
|
|
||||||
expect(component.ownerExclusionFilterLabel).toEqual(
|
|
||||||
'Not owned by selected users'
|
|
||||||
)
|
|
||||||
|
|
||||||
component.selectionModel.ownerFilter = OwnerFilterType.SHARED_BY_ME
|
|
||||||
component.selectionModel.userID = 99
|
|
||||||
expect(component.sharedByFilterLabel).toEqual('Shared by another user')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should retain relative labels for filters bound to the current user', () => {
|
|
||||||
component.selectionModel.userID = currentUserID
|
|
||||||
expect(component.ownerFilterLabel).toEqual('My documents')
|
|
||||||
expect(component.sharedByFilterLabel).toEqual('Shared by me')
|
|
||||||
|
|
||||||
component.selectionModel.excludeUsers = [currentUserID]
|
|
||||||
expect(component.ownerExclusionFilterLabel).toEqual('Shared with me')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should retain relative labels for inactive filter choices', () => {
|
|
||||||
component.selectionModel.ownerFilter = OwnerFilterType.NONE
|
|
||||||
|
|
||||||
expect(component.ownerFilterLabel).toEqual('My documents')
|
|
||||||
expect(component.ownerExclusionFilterLabel).toEqual('Shared with me')
|
|
||||||
expect(component.sharedByFilterLabel).toEqual('Shared by me')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should support reset', () => {
|
it('should support reset', () => {
|
||||||
component.setFilter(OwnerFilterType.OTHERS)
|
component.setFilter(OwnerFilterType.OTHERS)
|
||||||
expect(component.selectionModel.ownerFilter).not.toEqual(
|
expect(component.selectionModel.ownerFilter).not.toEqual(
|
||||||
|
|||||||
-53
@@ -93,55 +93,6 @@ export class PermissionsFilterDropdownComponent extends ComponentWithPermissions
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
get ownerFilterLabel(): string {
|
|
||||||
if (
|
|
||||||
this.selectionModel?.ownerFilter !== OwnerFilterType.SELF ||
|
|
||||||
this.selectionModel?.userID === this.settingsService.currentUser()?.id
|
|
||||||
) {
|
|
||||||
return $localize`My documents`
|
|
||||||
}
|
|
||||||
|
|
||||||
const username = this.getUsername(this.selectionModel?.userID)
|
|
||||||
return username
|
|
||||||
? $localize`Owned by ${username}`
|
|
||||||
: $localize`Owned by another user`
|
|
||||||
}
|
|
||||||
|
|
||||||
get ownerExclusionFilterLabel(): string {
|
|
||||||
const excludedUsers = this.selectionModel?.excludeUsers ?? []
|
|
||||||
if (
|
|
||||||
this.selectionModel?.ownerFilter !== OwnerFilterType.NOT_SELF ||
|
|
||||||
(excludedUsers.length === 1 &&
|
|
||||||
excludedUsers[0] === this.settingsService.currentUser()?.id)
|
|
||||||
) {
|
|
||||||
return $localize`Shared with me`
|
|
||||||
}
|
|
||||||
|
|
||||||
const usernames = excludedUsers
|
|
||||||
.map((id) => this.getUsername(id))
|
|
||||||
.filter(Boolean)
|
|
||||||
if (usernames.length === excludedUsers.length && usernames.length > 0) {
|
|
||||||
return $localize`Not owned by ${usernames.join(', ')}`
|
|
||||||
}
|
|
||||||
return excludedUsers.length === 1
|
|
||||||
? $localize`Not owned by another user`
|
|
||||||
: $localize`Not owned by selected users`
|
|
||||||
}
|
|
||||||
|
|
||||||
get sharedByFilterLabel(): string {
|
|
||||||
if (
|
|
||||||
this.selectionModel?.ownerFilter !== OwnerFilterType.SHARED_BY_ME ||
|
|
||||||
this.selectionModel?.userID === this.settingsService.currentUser()?.id
|
|
||||||
) {
|
|
||||||
return $localize`Shared by me`
|
|
||||||
}
|
|
||||||
|
|
||||||
const username = this.getUsername(this.selectionModel?.userID)
|
|
||||||
return username
|
|
||||||
? $localize`Shared by ${username}`
|
|
||||||
: $localize`Shared by another user`
|
|
||||||
}
|
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
const userService = inject(UserService)
|
const userService = inject(UserService)
|
||||||
|
|
||||||
@@ -213,8 +164,4 @@ export class PermissionsFilterDropdownComponent extends ComponentWithPermissions
|
|||||||
}
|
}
|
||||||
this.onChange()
|
this.onChange()
|
||||||
}
|
}
|
||||||
|
|
||||||
private getUsername(userID: number): string {
|
|
||||||
return this.users().find((user) => user.id === userID)?.username
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
-9
@@ -17,10 +17,6 @@ const permissions = [
|
|||||||
'view_document',
|
'view_document',
|
||||||
'change_document',
|
'change_document',
|
||||||
'delete_document',
|
'delete_document',
|
||||||
'add_sharelinkbundle',
|
|
||||||
'view_sharelinkbundle',
|
|
||||||
'change_sharelinkbundle',
|
|
||||||
'delete_sharelinkbundle',
|
|
||||||
'change_tag',
|
'change_tag',
|
||||||
'view_documenttype',
|
'view_documenttype',
|
||||||
]
|
]
|
||||||
@@ -79,7 +75,6 @@ describe('PermissionsSelectComponent', () => {
|
|||||||
component.ngOnInit()
|
component.ngOnInit()
|
||||||
component.writeValue(permissions)
|
component.writeValue(permissions)
|
||||||
expect(component.typesWithAllActions).toContain('Document')
|
expect(component.typesWithAllActions).toContain('Document')
|
||||||
expect(component.typesWithAllActions).toContain('ShareLinkBundle')
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should update checkboxes on permissions set', () => {
|
it('should update checkboxes on permissions set', () => {
|
||||||
@@ -90,10 +85,6 @@ describe('PermissionsSelectComponent', () => {
|
|||||||
expect(input1.nativeElement.checked).toBeTruthy()
|
expect(input1.nativeElement.checked).toBeTruthy()
|
||||||
const input2 = fixture.debugElement.query(By.css('input#Tag_Change'))
|
const input2 = fixture.debugElement.query(By.css('input#Tag_Change'))
|
||||||
expect(input2.nativeElement.checked).toBeTruthy()
|
expect(input2.nativeElement.checked).toBeTruthy()
|
||||||
const bundleInput = fixture.debugElement.query(
|
|
||||||
By.css('input#ShareLinkBundle_Add')
|
|
||||||
)
|
|
||||||
expect(bundleInput.nativeElement.checked).toBeTruthy()
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('disable checkboxes when permissions are inherited', () => {
|
it('disable checkboxes when permissions are inherited', () => {
|
||||||
|
|||||||
+1
-1
@@ -119,7 +119,7 @@
|
|||||||
type="button"
|
type="button"
|
||||||
class="btn btn-primary btn-sm d-inline-flex align-items-center gap-2 text-nowrap"
|
class="btn btn-primary btn-sm d-inline-flex align-items-center gap-2 text-nowrap"
|
||||||
(click)="submit()"
|
(click)="submit()"
|
||||||
[disabled]="loading() || !buttonsEnabled()">
|
[disabled]="loading() || !buttonsEnabled">
|
||||||
@if (loading()) {
|
@if (loading()) {
|
||||||
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
|
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -69,7 +69,7 @@ describe('ShareLinkBundleDialogComponent', () => {
|
|||||||
file_version: FileVersion.Original,
|
file_version: FileVersion.Original,
|
||||||
expiration_days: 3,
|
expiration_days: 3,
|
||||||
})
|
})
|
||||||
expect(component.buttonsEnabled()).toBe(false)
|
expect(component.buttonsEnabled).toBe(false)
|
||||||
expect(confirmSpy).toHaveBeenCalled()
|
expect(confirmSpy).toHaveBeenCalled()
|
||||||
|
|
||||||
component.form.setValue({
|
component.form.setValue({
|
||||||
|
|||||||
+1
-1
@@ -78,7 +78,7 @@ export class ShareLinkBundleDialogComponent extends ConfirmDialogComponent {
|
|||||||
: FileVersion.Original,
|
: FileVersion.Original,
|
||||||
expiration_days: this.form.value.expirationDays,
|
expiration_days: this.form.value.expirationDays,
|
||||||
}
|
}
|
||||||
this.buttonsEnabled.set(false)
|
this.buttonsEnabled = false
|
||||||
super.confirm()
|
super.confirm()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
-1
@@ -1,7 +1,6 @@
|
|||||||
<pngx-widget-frame
|
<pngx-widget-frame
|
||||||
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }"
|
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }"
|
||||||
[title]="savedView.name"
|
[title]="savedView.name"
|
||||||
[titleIcon]="savedView.icon || 'funnel'"
|
|
||||||
[loading]="false"
|
[loading]="false"
|
||||||
[draggable]="savedView"
|
[draggable]="savedView"
|
||||||
>
|
>
|
||||||
|
|||||||
+1
-6
@@ -8,12 +8,7 @@
|
|||||||
<i-bs name="grip-vertical"></i-bs>
|
<i-bs name="grip-vertical"></i-bs>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
<h6 class="card-title mb-0">
|
<h6 class="card-title mb-0">{{title()}}</h6>
|
||||||
@if (titleIcon()) {
|
|
||||||
<i-bs class="me-2" [name]="titleIcon()"></i-bs>
|
|
||||||
}
|
|
||||||
{{title()}}
|
|
||||||
</h6>
|
|
||||||
<ng-content select="[title-badge]"></ng-content>
|
<ng-content select="[title-badge]"></ng-content>
|
||||||
@if (badge() !== null && badge() !== undefined) {
|
@if (badge() !== null && badge() !== undefined) {
|
||||||
<span class="badge bg-info text-dark ms-2">{{badge()}}</span>
|
<span class="badge bg-info text-dark ms-2">{{badge()}}</span>
|
||||||
|
|||||||
@@ -16,8 +16,6 @@ export class WidgetFrameComponent implements AfterViewInit {
|
|||||||
|
|
||||||
title = input<string>()
|
title = input<string>()
|
||||||
|
|
||||||
titleIcon = input<string>()
|
|
||||||
|
|
||||||
draggable = input<any>()
|
draggable = input<any>()
|
||||||
|
|
||||||
cardless = input(false)
|
cardless = input(false)
|
||||||
|
|||||||
@@ -467,13 +467,6 @@ describe('DocumentDetailComponent', () => {
|
|||||||
const docWithVersions = {
|
const docWithVersions = {
|
||||||
...doc,
|
...doc,
|
||||||
versions: [
|
versions: [
|
||||||
{
|
|
||||||
id: 10,
|
|
||||||
added: new Date('2024-01-02T00:00:00Z'),
|
|
||||||
version_label: 'Edited',
|
|
||||||
checksum: 'bbbb',
|
|
||||||
is_root: false,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
id: doc.id,
|
id: doc.id,
|
||||||
added: new Date('2024-01-01T00:00:00Z'),
|
added: new Date('2024-01-01T00:00:00Z'),
|
||||||
@@ -481,6 +474,13 @@ describe('DocumentDetailComponent', () => {
|
|||||||
checksum: 'aaaa',
|
checksum: 'aaaa',
|
||||||
is_root: true,
|
is_root: true,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 10,
|
||||||
|
added: new Date('2024-01-02T00:00:00Z'),
|
||||||
|
version_label: 'Edited',
|
||||||
|
checksum: 'bbbb',
|
||||||
|
is_root: false,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
} as Document
|
} as Document
|
||||||
|
|
||||||
@@ -1232,8 +1232,8 @@ describe('DocumentDetailComponent', () => {
|
|||||||
|
|
||||||
metadataSpy.mockClear()
|
metadataSpy.mockClear()
|
||||||
component.document().versions = [
|
component.document().versions = [
|
||||||
{ id: 10, is_root: false },
|
|
||||||
{ id: doc.id, is_root: true },
|
{ id: doc.id, is_root: true },
|
||||||
|
{ id: 10, is_root: false },
|
||||||
] as any
|
] as any
|
||||||
jest.spyOn(documentService, 'getPreviewUrl').mockReturnValue('preview-root')
|
jest.spyOn(documentService, 'getPreviewUrl').mockReturnValue('preview-root')
|
||||||
jest.spyOn(documentService, 'getThumbUrl').mockReturnValue('thumb-root')
|
jest.spyOn(documentService, 'getThumbUrl').mockReturnValue('thumb-root')
|
||||||
@@ -1564,7 +1564,7 @@ describe('DocumentDetailComponent', () => {
|
|||||||
dialog.confirmClicked.next()
|
dialog.confirmClicked.next()
|
||||||
await openModal.result
|
await openModal.result
|
||||||
|
|
||||||
expect(dialog.buttonsEnabled()).toBe(false)
|
expect(dialog.buttonsEnabled).toBe(false)
|
||||||
expect(reloadSpy).toHaveBeenCalled()
|
expect(reloadSpy).toHaveBeenCalled()
|
||||||
expect((component as any).incomingUpdateModal).toBeNull()
|
expect((component as any).incomingUpdateModal).toBeNull()
|
||||||
})
|
})
|
||||||
@@ -1789,7 +1789,7 @@ describe('DocumentDetailComponent', () => {
|
|||||||
|
|
||||||
expect(errorSpy).toHaveBeenCalled()
|
expect(errorSpy).toHaveBeenCalled()
|
||||||
expect(component.networkActive()).toBe(false)
|
expect(component.networkActive()).toBe(false)
|
||||||
expect(dialog.buttonsEnabled()).toBe(true)
|
expect(dialog.buttonsEnabled).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should refresh the document when removing password in update mode', () => {
|
it('should refresh the document when removing password in update mode', () => {
|
||||||
@@ -1929,8 +1929,8 @@ describe('DocumentDetailComponent', () => {
|
|||||||
component.documentId.set(doc.id)
|
component.documentId.set(doc.id)
|
||||||
component.document.set({ ...doc, versions: [] } as Document)
|
component.document.set({ ...doc, versions: [] } as Document)
|
||||||
const updatedVersions = [
|
const updatedVersions = [
|
||||||
{ id: 10, is_root: false },
|
|
||||||
{ id: doc.id, is_root: true },
|
{ id: doc.id, is_root: true },
|
||||||
|
{ id: 10, is_root: false },
|
||||||
] as any
|
] as any
|
||||||
const openDoc = { ...doc, versions: [] } as Document
|
const openDoc = { ...doc, versions: [] } as Document
|
||||||
jest.spyOn(openDocumentsService, 'getOpenDocument').mockReturnValue(openDoc)
|
jest.spyOn(openDocumentsService, 'getOpenDocument').mockReturnValue(openDoc)
|
||||||
@@ -2046,8 +2046,8 @@ describe('DocumentDetailComponent', () => {
|
|||||||
it('should include version in download and print only for non-latest selected version', () => {
|
it('should include version in download and print only for non-latest selected version', () => {
|
||||||
initNormally()
|
initNormally()
|
||||||
component.document().versions = [
|
component.document().versions = [
|
||||||
{ id: 10, is_root: false },
|
|
||||||
{ id: doc.id, is_root: true },
|
{ id: doc.id, is_root: true },
|
||||||
|
{ id: 10, is_root: false },
|
||||||
] as any
|
] as any
|
||||||
|
|
||||||
const getDownloadUrlSpy = jest
|
const getDownloadUrlSpy = jest
|
||||||
|
|||||||
@@ -659,7 +659,7 @@ export class DocumentDetailComponent
|
|||||||
modal.componentInstance.cancelBtnCaption = $localize`Dismiss`
|
modal.componentInstance.cancelBtnCaption = $localize`Dismiss`
|
||||||
|
|
||||||
modal.componentInstance.confirmClicked.pipe(first()).subscribe(() => {
|
modal.componentInstance.confirmClicked.pipe(first()).subscribe(() => {
|
||||||
modal.componentInstance.buttonsEnabled.set(false)
|
modal.componentInstance.buttonsEnabled = false
|
||||||
modal.close()
|
modal.close()
|
||||||
this.reloadRemoteVersion()
|
this.reloadRemoteVersion()
|
||||||
})
|
})
|
||||||
@@ -889,9 +889,13 @@ export class DocumentDetailComponent
|
|||||||
|
|
||||||
updateComponent(doc: Document) {
|
updateComponent(doc: Document) {
|
||||||
this.document.set(doc)
|
this.document.set(doc)
|
||||||
// Default selected version is the newest version, which the API returns first
|
// Default selected version is the newest version
|
||||||
const versions = doc.versions ?? []
|
const versions = doc.versions ?? []
|
||||||
this.selectedVersionId.set(versions.length ? versions[0].id : doc.id)
|
this.selectedVersionId.set(
|
||||||
|
versions.length
|
||||||
|
? Math.max(...versions.map((version) => version.id))
|
||||||
|
: doc.id
|
||||||
|
)
|
||||||
this.previewLoaded.set(false)
|
this.previewLoaded.set(false)
|
||||||
this.requiresPassword = false
|
this.requiresPassword = false
|
||||||
this.updateFormForCustomFields()
|
this.updateFormForCustomFields()
|
||||||
@@ -1370,7 +1374,7 @@ export class DocumentDetailComponent
|
|||||||
modal.componentInstance.confirmClicked
|
modal.componentInstance.confirmClicked
|
||||||
.pipe(
|
.pipe(
|
||||||
switchMap(() => {
|
switchMap(() => {
|
||||||
modal.componentInstance.buttonsEnabled.set(false)
|
modal.componentInstance.buttonsEnabled = false
|
||||||
return this.documentsService.delete(this.document())
|
return this.documentsService.delete(this.document())
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
@@ -1382,7 +1386,7 @@ export class DocumentDetailComponent
|
|||||||
},
|
},
|
||||||
error: (error) => {
|
error: (error) => {
|
||||||
this.toastService.showError($localize`Error deleting document`, error)
|
this.toastService.showError($localize`Error deleting document`, error)
|
||||||
modal.componentInstance.buttonsEnabled.set(true)
|
modal.componentInstance.buttonsEnabled = true
|
||||||
this.subscribeModalDelete(modal)
|
this.subscribeModalDelete(modal)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -1407,7 +1411,7 @@ export class DocumentDetailComponent
|
|||||||
modal.componentInstance.btnClass = 'btn-danger'
|
modal.componentInstance.btnClass = 'btn-danger'
|
||||||
modal.componentInstance.btnCaption = $localize`Proceed`
|
modal.componentInstance.btnCaption = $localize`Proceed`
|
||||||
modal.componentInstance.confirmClicked.subscribe(() => {
|
modal.componentInstance.confirmClicked.subscribe(() => {
|
||||||
modal.componentInstance.buttonsEnabled.set(false)
|
modal.componentInstance.buttonsEnabled = false
|
||||||
this.documentsService
|
this.documentsService
|
||||||
.reprocessDocuments({ documents: [this.document().id] })
|
.reprocessDocuments({ documents: [this.document().id] })
|
||||||
.subscribe({
|
.subscribe({
|
||||||
@@ -1421,7 +1425,7 @@ export class DocumentDetailComponent
|
|||||||
},
|
},
|
||||||
error: (error) => {
|
error: (error) => {
|
||||||
if (modal) {
|
if (modal) {
|
||||||
modal.componentInstance.buttonsEnabled.set(true)
|
modal.componentInstance.buttonsEnabled = true
|
||||||
}
|
}
|
||||||
this.toastService.showError(
|
this.toastService.showError(
|
||||||
$localize`Error executing operation`,
|
$localize`Error executing operation`,
|
||||||
@@ -1437,8 +1441,7 @@ export class DocumentDetailComponent
|
|||||||
if (!versions.length || !this.selectedVersionId()) {
|
if (!versions.length || !this.selectedVersionId()) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
// The API returns versions newest first
|
const latestVersionId = Math.max(...versions.map((version) => version.id))
|
||||||
const latestVersionId = versions[0].id
|
|
||||||
return this.selectedVersionId() === latestVersionId
|
return this.selectedVersionId() === latestVersionId
|
||||||
? null
|
? null
|
||||||
: this.selectedVersionId()
|
: this.selectedVersionId()
|
||||||
@@ -1795,7 +1798,7 @@ export class DocumentDetailComponent
|
|||||||
modal.componentInstance.confirmClicked
|
modal.componentInstance.confirmClicked
|
||||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||||
.subscribe(() => {
|
.subscribe(() => {
|
||||||
modal.componentInstance.buttonsEnabled.set(false)
|
modal.componentInstance.buttonsEnabled = false
|
||||||
this.documentsService
|
this.documentsService
|
||||||
.editPdfDocuments([sourceDocumentId], {
|
.editPdfDocuments([sourceDocumentId], {
|
||||||
operations: modal.componentInstance.getOperations(),
|
operations: modal.componentInstance.getOperations(),
|
||||||
@@ -1818,7 +1821,7 @@ export class DocumentDetailComponent
|
|||||||
},
|
},
|
||||||
error: (error) => {
|
error: (error) => {
|
||||||
if (modal) {
|
if (modal) {
|
||||||
modal.componentInstance.buttonsEnabled.set(true)
|
modal.componentInstance.buttonsEnabled = true
|
||||||
}
|
}
|
||||||
this.toastService.showError(
|
this.toastService.showError(
|
||||||
$localize`Error executing PDF edit operation`,
|
$localize`Error executing PDF edit operation`,
|
||||||
@@ -1852,7 +1855,7 @@ export class DocumentDetailComponent
|
|||||||
const sourceDocumentId = this.selectedVersionId() ?? this.document().id
|
const sourceDocumentId = this.selectedVersionId() ?? this.document().id
|
||||||
const dialog =
|
const dialog =
|
||||||
modal.componentInstance as PasswordRemovalConfirmDialogComponent
|
modal.componentInstance as PasswordRemovalConfirmDialogComponent
|
||||||
dialog.buttonsEnabled.set(false)
|
dialog.buttonsEnabled = false
|
||||||
this.networkActive.set(true)
|
this.networkActive.set(true)
|
||||||
this.documentsService
|
this.documentsService
|
||||||
.removePasswordDocuments([sourceDocumentId], {
|
.removePasswordDocuments([sourceDocumentId], {
|
||||||
@@ -1877,7 +1880,7 @@ export class DocumentDetailComponent
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
error: (error) => {
|
error: (error) => {
|
||||||
dialog.buttonsEnabled.set(true)
|
dialog.buttonsEnabled = true
|
||||||
this.networkActive.set(false)
|
this.networkActive.set(false)
|
||||||
this.toastService.showError(
|
this.toastService.showError(
|
||||||
$localize`Error executing password removal operation`,
|
$localize`Error executing password removal operation`,
|
||||||
|
|||||||
-18
@@ -1,18 +0,0 @@
|
|||||||
<div class="modal-header">
|
|
||||||
<h4 class="modal-title" i18n>Add existing document as version</h4>
|
|
||||||
<button type="button" class="btn-close" aria-label="Close" (click)="cancel()"></button>
|
|
||||||
</div>
|
|
||||||
<div class="modal-body">
|
|
||||||
<pngx-input-document-link
|
|
||||||
[(ngModel)]="selectedDocumentIDs"
|
|
||||||
[parentDocumentID]="rootDocumentID"
|
|
||||||
[minimal]="true"
|
|
||||||
placeholder="Search for a document"
|
|
||||||
i18n-placeholder
|
|
||||||
></pngx-input-document-link>
|
|
||||||
<div class="form-text mt-2" i18n>Select one document to add as a version.</div>
|
|
||||||
</div>
|
|
||||||
<div class="modal-footer">
|
|
||||||
<button type="button" class="btn btn-outline-secondary" (click)="cancel()" [disabled]="!buttonsEnabled()" i18n>Cancel</button>
|
|
||||||
<button type="button" class="btn btn-primary" (click)="confirm()" [disabled]="!buttonsEnabled() || selectedDocumentIDs.length !== 1" i18n>Add version</button>
|
|
||||||
</div>
|
|
||||||
-73
@@ -1,73 +0,0 @@
|
|||||||
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
|
||||||
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
|
|
||||||
import { DocumentService } from 'src/app/services/rest/document.service'
|
|
||||||
import { AddExistingDocumentVersionDialogComponent } from './add-existing-document-version-dialog.component'
|
|
||||||
|
|
||||||
describe('AddExistingDocumentVersionDialogComponent', () => {
|
|
||||||
let component: AddExistingDocumentVersionDialogComponent
|
|
||||||
let fixture: ComponentFixture<AddExistingDocumentVersionDialogComponent>
|
|
||||||
let activeModal: jest.Mocked<Pick<NgbActiveModal, 'dismiss'>>
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
activeModal = { dismiss: jest.fn() }
|
|
||||||
await TestBed.configureTestingModule({
|
|
||||||
imports: [AddExistingDocumentVersionDialogComponent],
|
|
||||||
providers: [
|
|
||||||
{
|
|
||||||
provide: NgbActiveModal,
|
|
||||||
useValue: activeModal,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
provide: DocumentService,
|
|
||||||
useValue: {},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
}).compileComponents()
|
|
||||||
|
|
||||||
fixture = TestBed.createComponent(AddExistingDocumentVersionDialogComponent)
|
|
||||||
component = fixture.componentInstance
|
|
||||||
component.rootDocumentID = 3
|
|
||||||
fixture.detectChanges()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should emit the single selected document', () => {
|
|
||||||
const emitSpy = jest.spyOn(component.confirmClicked, 'emit')
|
|
||||||
component.selectedDocumentIDs = [20]
|
|
||||||
|
|
||||||
component.confirm()
|
|
||||||
|
|
||||||
expect(emitSpy).toHaveBeenCalledWith(20)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should require exactly one selected document', () => {
|
|
||||||
const emitSpy = jest.spyOn(component.confirmClicked, 'emit')
|
|
||||||
component.selectedDocumentIDs = [20, 21]
|
|
||||||
|
|
||||||
component.confirm()
|
|
||||||
|
|
||||||
expect(emitSpy).not.toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should dismiss on cancel', () => {
|
|
||||||
component.cancel()
|
|
||||||
|
|
||||||
expect(activeModal.dismiss).toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should re-render the buttons when they are toggled from outside', async () => {
|
|
||||||
const cancelButton: HTMLButtonElement = fixture.nativeElement.querySelector(
|
|
||||||
'.modal-footer button'
|
|
||||||
)
|
|
||||||
expect(cancelButton.disabled).toBeFalsy()
|
|
||||||
|
|
||||||
// No detectChanges: the dropdown toggling this from a request callback is
|
|
||||||
// all that happens, and nothing else schedules a render for the modal
|
|
||||||
component.buttonsEnabled.set(false)
|
|
||||||
await fixture.whenStable()
|
|
||||||
expect(cancelButton.disabled).toBeTruthy()
|
|
||||||
|
|
||||||
component.buttonsEnabled.set(true)
|
|
||||||
await fixture.whenStable()
|
|
||||||
expect(cancelButton.disabled).toBeFalsy()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
-35
@@ -1,35 +0,0 @@
|
|||||||
import {
|
|
||||||
Component,
|
|
||||||
EventEmitter,
|
|
||||||
Input,
|
|
||||||
Output,
|
|
||||||
inject,
|
|
||||||
signal,
|
|
||||||
} from '@angular/core'
|
|
||||||
import { FormsModule } from '@angular/forms'
|
|
||||||
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
|
|
||||||
import { DocumentLinkComponent } from 'src/app/components/common/input/document-link/document-link.component'
|
|
||||||
|
|
||||||
@Component({
|
|
||||||
selector: 'pngx-add-existing-document-version-dialog',
|
|
||||||
templateUrl: './add-existing-document-version-dialog.component.html',
|
|
||||||
imports: [DocumentLinkComponent, FormsModule],
|
|
||||||
})
|
|
||||||
export class AddExistingDocumentVersionDialogComponent {
|
|
||||||
private readonly activeModal = inject(NgbActiveModal)
|
|
||||||
|
|
||||||
@Input() rootDocumentID: number
|
|
||||||
@Output() confirmClicked = new EventEmitter<number>()
|
|
||||||
|
|
||||||
selectedDocumentIDs: number[] = []
|
|
||||||
readonly buttonsEnabled = signal(true)
|
|
||||||
|
|
||||||
confirm(): void {
|
|
||||||
if (this.selectedDocumentIDs.length !== 1) return
|
|
||||||
this.confirmClicked.emit(this.selectedDocumentIDs[0])
|
|
||||||
}
|
|
||||||
|
|
||||||
cancel(): void {
|
|
||||||
this.activeModal.dismiss()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+7
-21
@@ -24,26 +24,13 @@
|
|||||||
class="visually-hidden"
|
class="visually-hidden"
|
||||||
(change)="onVersionFileSelected($event)"
|
(change)="onVersionFileSelected($event)"
|
||||||
/>
|
/>
|
||||||
<div class="btn-group btn-group-sm w-100">
|
<button
|
||||||
<button
|
class="btn btn-sm btn-outline-secondary w-100"
|
||||||
class="btn btn-sm btn-outline-secondary w-100"
|
(click)="versionFileInput.click()"
|
||||||
(click)="versionFileInput.click()"
|
[disabled]="!userIsOwner || !userCanEdit"
|
||||||
[disabled]="!userIsOwner || !userCanEdit"
|
>
|
||||||
title="Upload a new version"
|
<i-bs name="file-earmark-plus"></i-bs><span class="ps-1" i18n>Add new version</span>
|
||||||
i18n-title
|
</button>
|
||||||
>
|
|
||||||
<i-bs name="file-earmark-plus"></i-bs><span class="ps-1" i18n>Upload</span>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="btn btn-sm btn-outline-secondary w-100"
|
|
||||||
(click)="addExistingDocumentAsVersion()"
|
|
||||||
[disabled]="!userIsOwner || !userCanEdit"
|
|
||||||
title="Use an existing document"
|
|
||||||
i18n-title
|
|
||||||
>
|
|
||||||
<i-bs name="file-earmark"></i-bs><span class="ps-1" i18n>Existing</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
} @else {
|
} @else {
|
||||||
@switch (versionUploadState()) {
|
@switch (versionUploadState()) {
|
||||||
@case (UploadState.Uploading) {
|
@case (UploadState.Uploading) {
|
||||||
@@ -141,7 +128,6 @@
|
|||||||
i18n-confirmMessage
|
i18n-confirmMessage
|
||||||
[disabled]="!userIsOwner || !userCanEdit"
|
[disabled]="!userIsOwner || !userCanEdit"
|
||||||
(confirm)="deleteVersion(version.id)"
|
(confirm)="deleteVersion(version.id)"
|
||||||
*pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.Document }"
|
|
||||||
>
|
>
|
||||||
<span class="visually-hidden" i18n>Delete version</span>
|
<span class="visually-hidden" i18n>Delete version</span>
|
||||||
</pngx-confirm-button>
|
</pngx-confirm-button>
|
||||||
|
|||||||
+3
-98
@@ -1,16 +1,9 @@
|
|||||||
import { DatePipe } from '@angular/common'
|
import { DatePipe } from '@angular/common'
|
||||||
import { SimpleChange, signal } from '@angular/core'
|
import { SimpleChange } from '@angular/core'
|
||||||
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||||
import { By } from '@angular/platform-browser'
|
|
||||||
import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
|
|
||||||
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
||||||
import { Subject, of, throwError } from 'rxjs'
|
import { Subject, of, throwError } from 'rxjs'
|
||||||
import { DocumentVersionInfo } from 'src/app/data/document'
|
import { DocumentVersionInfo } from 'src/app/data/document'
|
||||||
import {
|
|
||||||
PermissionAction,
|
|
||||||
PermissionsService,
|
|
||||||
PermissionType,
|
|
||||||
} from 'src/app/services/permissions.service'
|
|
||||||
import { DocumentService } from 'src/app/services/rest/document.service'
|
import { DocumentService } from 'src/app/services/rest/document.service'
|
||||||
import { SettingsService } from 'src/app/services/settings.service'
|
import { SettingsService } from 'src/app/services/settings.service'
|
||||||
import { ToastService } from 'src/app/services/toast.service'
|
import { ToastService } from 'src/app/services/toast.service'
|
||||||
@@ -26,20 +19,12 @@ describe('DocumentVersionDropdownComponent', () => {
|
|||||||
let documentService: jest.Mocked<
|
let documentService: jest.Mocked<
|
||||||
Pick<
|
Pick<
|
||||||
DocumentService,
|
DocumentService,
|
||||||
| 'deleteVersion'
|
'deleteVersion' | 'getVersions' | 'uploadVersion' | 'updateVersionLabel'
|
||||||
| 'getVersions'
|
|
||||||
| 'mergeDocumentsAsVersions'
|
|
||||||
| 'uploadVersion'
|
|
||||||
| 'updateVersionLabel'
|
|
||||||
>
|
>
|
||||||
>
|
>
|
||||||
let toastService: jest.Mocked<Pick<ToastService, 'showError' | 'showInfo'>>
|
let toastService: jest.Mocked<Pick<ToastService, 'showError' | 'showInfo'>>
|
||||||
let finished$: Subject<{ taskId: string }>
|
let finished$: Subject<{ taskId: string }>
|
||||||
let failed$: Subject<{ taskId: string; message?: string }>
|
let failed$: Subject<{ taskId: string; message?: string }>
|
||||||
let modalService: jest.Mocked<Pick<NgbModal, 'open'>>
|
|
||||||
let permissionsService: jest.Mocked<
|
|
||||||
Pick<PermissionsService, 'currentUserCan'>
|
|
||||||
>
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
finished$ = new Subject<{ taskId: string }>()
|
finished$ = new Subject<{ taskId: string }>()
|
||||||
@@ -47,18 +32,13 @@ describe('DocumentVersionDropdownComponent', () => {
|
|||||||
documentService = {
|
documentService = {
|
||||||
deleteVersion: jest.fn(),
|
deleteVersion: jest.fn(),
|
||||||
getVersions: jest.fn(),
|
getVersions: jest.fn(),
|
||||||
mergeDocumentsAsVersions: jest.fn(),
|
|
||||||
uploadVersion: jest.fn(),
|
uploadVersion: jest.fn(),
|
||||||
updateVersionLabel: jest.fn(),
|
updateVersionLabel: jest.fn(),
|
||||||
}
|
}
|
||||||
modalService = { open: jest.fn() }
|
|
||||||
toastService = {
|
toastService = {
|
||||||
showError: jest.fn(),
|
showError: jest.fn(),
|
||||||
showInfo: jest.fn(),
|
showInfo: jest.fn(),
|
||||||
}
|
}
|
||||||
permissionsService = {
|
|
||||||
currentUserCan: jest.fn().mockReturnValue(true),
|
|
||||||
}
|
|
||||||
|
|
||||||
await TestBed.configureTestingModule({
|
await TestBed.configureTestingModule({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -81,14 +61,6 @@ describe('DocumentVersionDropdownComponent', () => {
|
|||||||
provide: ToastService,
|
provide: ToastService,
|
||||||
useValue: toastService,
|
useValue: toastService,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
provide: NgbModal,
|
|
||||||
useValue: modalService,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
provide: PermissionsService,
|
|
||||||
useValue: permissionsService,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
provide: WebsocketStatusService,
|
provide: WebsocketStatusService,
|
||||||
useValue: {
|
useValue: {
|
||||||
@@ -159,31 +131,6 @@ describe('DocumentVersionDropdownComponent', () => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should not show version delete buttons without document delete permission', () => {
|
|
||||||
fixture.destroy()
|
|
||||||
permissionsService.currentUserCan.mockReturnValue(false)
|
|
||||||
fixture = TestBed.createComponent(DocumentVersionDropdownComponent)
|
|
||||||
component = fixture.componentInstance
|
|
||||||
component.documentId = 3
|
|
||||||
component.selectedVersionId = 3
|
|
||||||
component.userIsOwner = true
|
|
||||||
component.userCanEdit = true
|
|
||||||
component.versions = [
|
|
||||||
{ id: 3, is_root: true, checksum: 'aaaa' },
|
|
||||||
{ id: 10, is_root: false, checksum: 'bbbb' },
|
|
||||||
]
|
|
||||||
|
|
||||||
fixture.detectChanges()
|
|
||||||
|
|
||||||
expect(permissionsService.currentUserCan).toHaveBeenCalledWith(
|
|
||||||
PermissionAction.Delete,
|
|
||||||
PermissionType.Document
|
|
||||||
)
|
|
||||||
expect(
|
|
||||||
fixture.debugElement.queryAll(By.css('pngx-confirm-button'))
|
|
||||||
).toHaveLength(0)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('beginEditingVersion should set active row and draft label', () => {
|
it('beginEditingVersion should set active row and draft label', () => {
|
||||||
component.userCanEdit = true
|
component.userCanEdit = true
|
||||||
component.userIsOwner = true
|
component.userIsOwner = true
|
||||||
@@ -275,10 +222,9 @@ describe('DocumentVersionDropdownComponent', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('onVersionFileSelected should upload and update versions after websocket success', () => {
|
it('onVersionFileSelected should upload and update versions after websocket success', () => {
|
||||||
// Newest first, as the API returns them
|
|
||||||
const versions: DocumentVersionInfo[] = [
|
const versions: DocumentVersionInfo[] = [
|
||||||
{ id: 20, is_root: false, checksum: 'cccc' },
|
|
||||||
{ id: 3, is_root: true, checksum: 'aaaa' },
|
{ id: 3, is_root: true, checksum: 'aaaa' },
|
||||||
|
{ id: 20, is_root: false, checksum: 'cccc' },
|
||||||
]
|
]
|
||||||
const file = new File(['test'], 'new-version.pdf', {
|
const file = new File(['test'], 'new-version.pdf', {
|
||||||
type: 'application/pdf',
|
type: 'application/pdf',
|
||||||
@@ -377,45 +323,4 @@ describe('DocumentVersionDropdownComponent', () => {
|
|||||||
expect(component.editingVersionId).toBeNull()
|
expect(component.editingVersionId).toBeNull()
|
||||||
expect(component.versionLabelDraft).toEqual('')
|
expect(component.versionLabelDraft).toEqual('')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('addExistingDocumentAsVersion should merge with a label and refresh versions', () => {
|
|
||||||
const confirmClicked = new Subject<number>()
|
|
||||||
const modal = {
|
|
||||||
componentInstance: {
|
|
||||||
rootDocumentID: null,
|
|
||||||
buttonsEnabled: signal(true),
|
|
||||||
confirmClicked,
|
|
||||||
},
|
|
||||||
close: jest.fn(),
|
|
||||||
}
|
|
||||||
modalService.open.mockReturnValue(modal as any)
|
|
||||||
documentService.mergeDocumentsAsVersions.mockReturnValue(of({} as any))
|
|
||||||
// Newest first, as the API returns them. The merged document has a lower id
|
|
||||||
// than the root, which is the whole point of merging an existing document.
|
|
||||||
const versions: DocumentVersionInfo[] = [
|
|
||||||
{ id: 2, is_root: false, checksum: 'cccc' },
|
|
||||||
{ id: 3, is_root: true, checksum: 'aaaa' },
|
|
||||||
]
|
|
||||||
documentService.getVersions.mockReturnValue(of({ id: 3, versions } as any))
|
|
||||||
component.newVersionLabel = ' Imported '
|
|
||||||
const versionsEmitSpy = jest.spyOn(component.versionsUpdated, 'emit')
|
|
||||||
const selectedEmitSpy = jest.spyOn(component.versionSelected, 'emit')
|
|
||||||
|
|
||||||
component.addExistingDocumentAsVersion()
|
|
||||||
expect(modal.componentInstance.rootDocumentID).toEqual(3)
|
|
||||||
confirmClicked.next(2)
|
|
||||||
|
|
||||||
expect(documentService.mergeDocumentsAsVersions).toHaveBeenCalledWith(
|
|
||||||
[3, 2],
|
|
||||||
3,
|
|
||||||
'Imported'
|
|
||||||
)
|
|
||||||
expect(documentService.updateVersionLabel).not.toHaveBeenCalled()
|
|
||||||
expect(documentService.getVersions).toHaveBeenCalledWith(3)
|
|
||||||
expect(versionsEmitSpy).toHaveBeenCalledWith(versions)
|
|
||||||
expect(selectedEmitSpy).toHaveBeenCalledWith(2)
|
|
||||||
expect(component.newVersionLabel).toEqual('')
|
|
||||||
expect(modal.close).toHaveBeenCalled()
|
|
||||||
expect(toastService.showInfo).toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|||||||
+6
-62
@@ -11,7 +11,7 @@ import {
|
|||||||
SimpleChanges,
|
SimpleChanges,
|
||||||
} from '@angular/core'
|
} from '@angular/core'
|
||||||
import { FormsModule } from '@angular/forms'
|
import { FormsModule } from '@angular/forms'
|
||||||
import { NgbDropdownModule, NgbModal } from '@ng-bootstrap/ng-bootstrap'
|
import { NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap'
|
||||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||||
import { merge, of, Subject } from 'rxjs'
|
import { merge, of, Subject } from 'rxjs'
|
||||||
import {
|
import {
|
||||||
@@ -25,7 +25,6 @@ import {
|
|||||||
tap,
|
tap,
|
||||||
} from 'rxjs/operators'
|
} from 'rxjs/operators'
|
||||||
import { DocumentVersionInfo } from 'src/app/data/document'
|
import { DocumentVersionInfo } from 'src/app/data/document'
|
||||||
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
|
||||||
import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
|
import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
|
||||||
import { DocumentService } from 'src/app/services/rest/document.service'
|
import { DocumentService } from 'src/app/services/rest/document.service'
|
||||||
import { ToastService } from 'src/app/services/toast.service'
|
import { ToastService } from 'src/app/services/toast.service'
|
||||||
@@ -34,8 +33,6 @@ import {
|
|||||||
WebsocketStatusService,
|
WebsocketStatusService,
|
||||||
} from 'src/app/services/websocket-status.service'
|
} from 'src/app/services/websocket-status.service'
|
||||||
import { ConfirmButtonComponent } from '../../common/confirm-button/confirm-button.component'
|
import { ConfirmButtonComponent } from '../../common/confirm-button/confirm-button.component'
|
||||||
import { ComponentWithPermissions } from '../../with-permissions/with-permissions.component'
|
|
||||||
import { AddExistingDocumentVersionDialogComponent } from './add-existing-document-version-dialog/add-existing-document-version-dialog.component'
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'pngx-document-version-dropdown',
|
selector: 'pngx-document-version-dropdown',
|
||||||
@@ -46,15 +43,11 @@ import { AddExistingDocumentVersionDialogComponent } from './add-existing-docume
|
|||||||
NgbDropdownModule,
|
NgbDropdownModule,
|
||||||
NgxBootstrapIconsModule,
|
NgxBootstrapIconsModule,
|
||||||
ConfirmButtonComponent,
|
ConfirmButtonComponent,
|
||||||
IfPermissionsDirective,
|
|
||||||
SlicePipe,
|
SlicePipe,
|
||||||
CustomDatePipe,
|
CustomDatePipe,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class DocumentVersionDropdownComponent
|
export class DocumentVersionDropdownComponent implements OnChanges, OnDestroy {
|
||||||
extends ComponentWithPermissions
|
|
||||||
implements OnChanges, OnDestroy
|
|
||||||
{
|
|
||||||
UploadState = UploadState
|
UploadState = UploadState
|
||||||
|
|
||||||
@Input() documentId: number
|
@Input() documentId: number
|
||||||
@@ -76,7 +69,6 @@ export class DocumentVersionDropdownComponent
|
|||||||
private readonly documentsService = inject(DocumentService)
|
private readonly documentsService = inject(DocumentService)
|
||||||
private readonly toastService = inject(ToastService)
|
private readonly toastService = inject(ToastService)
|
||||||
private readonly websocketStatusService = inject(WebsocketStatusService)
|
private readonly websocketStatusService = inject(WebsocketStatusService)
|
||||||
private readonly modalService = inject(NgbModal)
|
|
||||||
private readonly destroy$ = new Subject<void>()
|
private readonly destroy$ = new Subject<void>()
|
||||||
private readonly documentChange$ = new Subject<void>()
|
private readonly documentChange$ = new Subject<void>()
|
||||||
|
|
||||||
@@ -264,10 +256,11 @@ export class DocumentVersionDropdownComponent
|
|||||||
.subscribe({
|
.subscribe({
|
||||||
next: (doc) => {
|
next: (doc) => {
|
||||||
if (uploadDocumentId !== this.documentId) return
|
if (uploadDocumentId !== this.documentId) return
|
||||||
if (doc?.versions?.length) {
|
if (doc?.versions) {
|
||||||
this.versionsUpdated.emit(doc.versions)
|
this.versionsUpdated.emit(doc.versions)
|
||||||
// The API returns versions newest first
|
this.versionSelected.emit(
|
||||||
this.versionSelected.emit(doc.versions[0].id)
|
Math.max(...doc.versions.map((version) => version.id))
|
||||||
|
)
|
||||||
this.clearVersionUploadStatus()
|
this.clearVersionUploadStatus()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -285,55 +278,6 @@ export class DocumentVersionDropdownComponent
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
addExistingDocumentAsVersion(): void {
|
|
||||||
const modal = this.modalService.open(
|
|
||||||
AddExistingDocumentVersionDialogComponent,
|
|
||||||
{ backdrop: 'static' }
|
|
||||||
)
|
|
||||||
const dialog =
|
|
||||||
modal.componentInstance as AddExistingDocumentVersionDialogComponent
|
|
||||||
dialog.rootDocumentID = this.documentId
|
|
||||||
dialog.confirmClicked
|
|
||||||
.pipe(takeUntil(this.destroy$), takeUntil(this.documentChange$))
|
|
||||||
.subscribe((existingDocumentID) => {
|
|
||||||
dialog.buttonsEnabled.set(false)
|
|
||||||
const versionLabel = this.newVersionLabel?.trim()
|
|
||||||
this.documentsService
|
|
||||||
.mergeDocumentsAsVersions(
|
|
||||||
[this.documentId, existingDocumentID],
|
|
||||||
this.documentId,
|
|
||||||
versionLabel
|
|
||||||
)
|
|
||||||
.pipe(
|
|
||||||
switchMap(() => this.documentsService.getVersions(this.documentId)),
|
|
||||||
first(),
|
|
||||||
finalize(() => dialog.buttonsEnabled.set(true)),
|
|
||||||
takeUntil(this.destroy$),
|
|
||||||
takeUntil(this.documentChange$)
|
|
||||||
)
|
|
||||||
.subscribe({
|
|
||||||
next: (document) => {
|
|
||||||
if (document?.versions?.length) {
|
|
||||||
this.versionsUpdated.emit(document.versions)
|
|
||||||
// The API returns versions newest first
|
|
||||||
this.versionSelected.emit(document.versions[0].id)
|
|
||||||
}
|
|
||||||
this.newVersionLabel = ''
|
|
||||||
modal.close()
|
|
||||||
this.toastService.showInfo(
|
|
||||||
$localize`Existing document added as a version.`
|
|
||||||
)
|
|
||||||
},
|
|
||||||
error: (error) => {
|
|
||||||
this.toastService.showError(
|
|
||||||
$localize`Error adding existing document as a version`,
|
|
||||||
error
|
|
||||||
)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
clearVersionUploadStatus(): void {
|
clearVersionUploadStatus(): void {
|
||||||
this.versionUploadState.set(UploadState.Idle)
|
this.versionUploadState.set(UploadState.Idle)
|
||||||
this.versionUploadError.set(null)
|
this.versionUploadError.set(null)
|
||||||
|
|||||||
@@ -95,9 +95,6 @@
|
|||||||
<button ngbDropdownItem (click)="mergeSelected()" [disabled]="!userCanAdd || list.allSelected || list.selectedCount < 2">
|
<button ngbDropdownItem (click)="mergeSelected()" [disabled]="!userCanAdd || list.allSelected || list.selectedCount < 2">
|
||||||
<i-bs name="journals" class="me-1"></i-bs><ng-container i18n>Merge</ng-container>
|
<i-bs name="journals" class="me-1"></i-bs><ng-container i18n>Merge</ng-container>
|
||||||
</button>
|
</button>
|
||||||
<button ngbDropdownItem (click)="mergeSelectedAsVersions()" [disabled]="!userOwnsAll || !userCanEditAll || !userCanDelete || list.allSelected || list.selectedCount < 2">
|
|
||||||
<i-bs name="journal-bookmark-fill" class="me-1"></i-bs><ng-container i18n>Merge as versions</ng-container>
|
|
||||||
</button>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1248,89 +1248,6 @@ describe('BulkEditorComponent', () => {
|
|||||||
expect(documentListViewService.selected.size).toEqual(0)
|
expect(documentListViewService.selected.size).toEqual(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should support merging documents as versions', () => {
|
|
||||||
let modal: NgbModalRef
|
|
||||||
modalService.activeInstances.subscribe((m) => (modal = m[0]))
|
|
||||||
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
|
|
||||||
jest
|
|
||||||
.spyOn(documentListViewService, 'documents', 'get')
|
|
||||||
.mockReturnValue([{ id: 3 }, { id: 4 }])
|
|
||||||
jest.spyOn(documentService, 'getFew').mockReturnValue(
|
|
||||||
of({
|
|
||||||
all: [3, 4],
|
|
||||||
count: 2,
|
|
||||||
results: [
|
|
||||||
{ id: 3, title: 'Document 3' },
|
|
||||||
{ id: 4, title: 'Document 4' },
|
|
||||||
],
|
|
||||||
})
|
|
||||||
)
|
|
||||||
jest
|
|
||||||
.spyOn(documentListViewService, 'selected', 'get')
|
|
||||||
.mockReturnValue(new Set([3, 4]))
|
|
||||||
jest
|
|
||||||
.spyOn(permissionsService, 'currentUserHasObjectPermissions')
|
|
||||||
.mockReturnValue(true)
|
|
||||||
jest
|
|
||||||
.spyOn(permissionsService, 'currentUserOwnsObject')
|
|
||||||
.mockReturnValue(true)
|
|
||||||
const mergeAsVersionsSpy = jest
|
|
||||||
.spyOn(documentService, 'mergeDocumentsAsVersions')
|
|
||||||
.mockReturnValue(of(true))
|
|
||||||
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
|
|
||||||
fixture.detectChanges()
|
|
||||||
|
|
||||||
component.mergeSelectedAsVersions()
|
|
||||||
expect(modal).not.toBeUndefined()
|
|
||||||
modal.componentInstance.rootDocumentID.set(4)
|
|
||||||
modal.componentInstance.confirm()
|
|
||||||
|
|
||||||
expect(mergeAsVersionsSpy).toHaveBeenCalledWith([3, 4], 4)
|
|
||||||
httpTestingController.match(
|
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
|
||||||
)
|
|
||||||
httpTestingController.match(
|
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
|
||||||
)
|
|
||||||
expect(documentListViewService.selected.size).toEqual(0)
|
|
||||||
expect(toastInfoSpy).toHaveBeenCalledWith('Documents merged as versions.')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should not report success when merging documents as versions fails', () => {
|
|
||||||
let modal: NgbModalRef
|
|
||||||
modalService.activeInstances.subscribe((m) => (modal = m[0]))
|
|
||||||
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
|
|
||||||
jest
|
|
||||||
.spyOn(documentListViewService, 'documents', 'get')
|
|
||||||
.mockReturnValue([{ id: 3 }, { id: 4 }])
|
|
||||||
jest.spyOn(documentService, 'getFew').mockReturnValue(
|
|
||||||
of({
|
|
||||||
all: [3, 4],
|
|
||||||
count: 2,
|
|
||||||
results: [
|
|
||||||
{ id: 3, title: 'Document 3' },
|
|
||||||
{ id: 4, title: 'Document 4' },
|
|
||||||
],
|
|
||||||
})
|
|
||||||
)
|
|
||||||
jest
|
|
||||||
.spyOn(documentListViewService, 'selected', 'get')
|
|
||||||
.mockReturnValue(new Set([3, 4]))
|
|
||||||
jest
|
|
||||||
.spyOn(documentService, 'mergeDocumentsAsVersions')
|
|
||||||
.mockReturnValue(throwError(() => new Error('failed')))
|
|
||||||
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
|
|
||||||
const toastErrorSpy = jest.spyOn(toastService, 'showError')
|
|
||||||
fixture.detectChanges()
|
|
||||||
|
|
||||||
component.mergeSelectedAsVersions()
|
|
||||||
modal.componentInstance.rootDocumentID.set(4)
|
|
||||||
modal.componentInstance.confirm()
|
|
||||||
|
|
||||||
expect(toastErrorSpy).toHaveBeenCalled()
|
|
||||||
expect(toastInfoSpy).not.toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should support bulk download with archive, originals or both and file formatting', () => {
|
it('should support bulk download with archive, originals or both and file formatting', () => {
|
||||||
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
|
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
|
||||||
jest
|
jest
|
||||||
@@ -1766,7 +1683,7 @@ describe('BulkEditorComponent', () => {
|
|||||||
expiration_days: 7,
|
expiration_days: 7,
|
||||||
},
|
},
|
||||||
loading: signal(false),
|
loading: signal(false),
|
||||||
buttonsEnabled: signal(true),
|
buttonsEnabled: true,
|
||||||
copied: signal(false),
|
copied: signal(false),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -1798,7 +1715,7 @@ describe('BulkEditorComponent', () => {
|
|||||||
expiration_days: 7,
|
expiration_days: 7,
|
||||||
})
|
})
|
||||||
expect(dialogInstance.loading()).toBe(false)
|
expect(dialogInstance.loading()).toBe(false)
|
||||||
expect(dialogInstance.buttonsEnabled()).toBe(false)
|
expect(dialogInstance.buttonsEnabled).toBe(false)
|
||||||
expect(dialogInstance.createdBundle).toEqual({ id: 42 })
|
expect(dialogInstance.createdBundle).toEqual({ id: 42 })
|
||||||
expect(typeof dialogInstance.onOpenManage).toBe('function')
|
expect(typeof dialogInstance.onOpenManage).toBe('function')
|
||||||
expect(toastInfoSpy).toHaveBeenCalledWith(
|
expect(toastInfoSpy).toHaveBeenCalledWith(
|
||||||
@@ -1838,7 +1755,7 @@ describe('BulkEditorComponent', () => {
|
|||||||
expiration_days: null,
|
expiration_days: null,
|
||||||
},
|
},
|
||||||
loading: signal(false),
|
loading: signal(false),
|
||||||
buttonsEnabled: signal(true),
|
buttonsEnabled: true,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1860,7 +1777,7 @@ describe('BulkEditorComponent', () => {
|
|||||||
expect.any(Error)
|
expect.any(Error)
|
||||||
)
|
)
|
||||||
expect(dialogInstance.loading()).toBe(false)
|
expect(dialogInstance.loading()).toBe(false)
|
||||||
expect(dialogInstance.buttonsEnabled()).toBe(true)
|
expect(dialogInstance.buttonsEnabled).toBe(true)
|
||||||
openSpy.mockRestore()
|
openSpy.mockRestore()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -50,7 +50,6 @@ import { SettingsService } from 'src/app/services/settings.service'
|
|||||||
import { ToastService } from 'src/app/services/toast.service'
|
import { ToastService } from 'src/app/services/toast.service'
|
||||||
import { flattenTags } from 'src/app/utils/flatten-tags'
|
import { flattenTags } from 'src/app/utils/flatten-tags'
|
||||||
import { queryParamsFromFilterRules } from 'src/app/utils/query-params'
|
import { queryParamsFromFilterRules } from 'src/app/utils/query-params'
|
||||||
import { MergeAsVersionsConfirmDialogComponent } from '../../common/confirm-dialog/merge-as-versions-confirm-dialog/merge-as-versions-confirm-dialog.component'
|
|
||||||
import { MergeConfirmDialogComponent } from '../../common/confirm-dialog/merge-confirm-dialog/merge-confirm-dialog.component'
|
import { MergeConfirmDialogComponent } from '../../common/confirm-dialog/merge-confirm-dialog/merge-confirm-dialog.component'
|
||||||
import { RotateConfirmDialogComponent } from '../../common/confirm-dialog/rotate-confirm-dialog/rotate-confirm-dialog.component'
|
import { RotateConfirmDialogComponent } from '../../common/confirm-dialog/rotate-confirm-dialog/rotate-confirm-dialog.component'
|
||||||
import { CorrespondentEditDialogComponent } from '../../common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component'
|
import { CorrespondentEditDialogComponent } from '../../common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component'
|
||||||
@@ -172,13 +171,6 @@ export class BulkEditorComponent
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
get userCanDelete(): boolean {
|
|
||||||
return this.permissionService.currentUserCan(
|
|
||||||
PermissionAction.Delete,
|
|
||||||
PermissionType.Document
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
ngOnInit() {
|
ngOnInit() {
|
||||||
if (
|
if (
|
||||||
this.permissionService.currentUserCan(
|
this.permissionService.currentUserCan(
|
||||||
@@ -281,7 +273,7 @@ export class BulkEditorComponent
|
|||||||
overrideSelection?: DocumentSelectionQuery
|
overrideSelection?: DocumentSelectionQuery
|
||||||
) {
|
) {
|
||||||
if (modal) {
|
if (modal) {
|
||||||
modal.componentInstance.buttonsEnabled.set(false)
|
this.setModalButtonsEnabled(modal, false)
|
||||||
}
|
}
|
||||||
this.documentService
|
this.documentService
|
||||||
.bulkEdit(overrideSelection ?? this.getSelectionQuery(), method, args)
|
.bulkEdit(overrideSelection ?? this.getSelectionQuery(), method, args)
|
||||||
@@ -295,17 +287,14 @@ export class BulkEditorComponent
|
|||||||
private executeDocumentAction(
|
private executeDocumentAction(
|
||||||
modal: NgbModalRef,
|
modal: NgbModalRef,
|
||||||
request: Observable<any>,
|
request: Observable<any>,
|
||||||
options: { clearSelection?: boolean; successMessage?: string } = {}
|
options: { deleteOriginals?: boolean } = {}
|
||||||
) {
|
) {
|
||||||
if (modal) {
|
if (modal) {
|
||||||
modal.componentInstance.buttonsEnabled.set(false)
|
this.setModalButtonsEnabled(modal, false)
|
||||||
}
|
}
|
||||||
request.pipe(first()).subscribe({
|
request.pipe(first()).subscribe({
|
||||||
next: () => {
|
next: () => {
|
||||||
this.handleOperationSuccess(modal, options.clearSelection ?? false)
|
this.handleOperationSuccess(modal, options.deleteOriginals ?? false)
|
||||||
if (options.successMessage) {
|
|
||||||
this.toastService.showInfo(options.successMessage)
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
error: (error) => this.handleOperationError(modal, error),
|
error: (error) => this.handleOperationError(modal, error),
|
||||||
})
|
})
|
||||||
@@ -331,7 +320,7 @@ export class BulkEditorComponent
|
|||||||
|
|
||||||
private handleOperationError(modal: NgbModalRef, error: any) {
|
private handleOperationError(modal: NgbModalRef, error: any) {
|
||||||
if (modal) {
|
if (modal) {
|
||||||
modal.componentInstance.buttonsEnabled.set(true)
|
this.setModalButtonsEnabled(modal, true)
|
||||||
}
|
}
|
||||||
this.toastService.showError(
|
this.toastService.showError(
|
||||||
$localize`Error executing bulk operation`,
|
$localize`Error executing bulk operation`,
|
||||||
@@ -339,6 +328,15 @@ export class BulkEditorComponent
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private setModalButtonsEnabled(modal: NgbModalRef, enabled: boolean) {
|
||||||
|
const buttonsEnabled = modal.componentInstance.buttonsEnabled
|
||||||
|
if (typeof buttonsEnabled?.set === 'function') {
|
||||||
|
buttonsEnabled.set(enabled)
|
||||||
|
} else {
|
||||||
|
modal.componentInstance.buttonsEnabled = enabled
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private applySelectionData(
|
private applySelectionData(
|
||||||
items: SelectionDataItem[],
|
items: SelectionDataItem[],
|
||||||
selectionModel: FilterableDropdownSelectionModel
|
selectionModel: FilterableDropdownSelectionModel
|
||||||
@@ -773,7 +771,6 @@ export class BulkEditorComponent
|
|||||||
this.tagSelectionModel.items = flattenTags(tags.results)
|
this.tagSelectionModel.items = flattenTags(tags.results)
|
||||||
this.tagSelectionModel.toggle(newTag.id)
|
this.tagSelectionModel.toggle(newTag.id)
|
||||||
})
|
})
|
||||||
return modal
|
|
||||||
}
|
}
|
||||||
|
|
||||||
createCorrespondent(name: string) {
|
createCorrespondent(name: string) {
|
||||||
@@ -797,7 +794,6 @@ export class BulkEditorComponent
|
|||||||
this.correspondentSelectionModel.items = correspondents.results
|
this.correspondentSelectionModel.items = correspondents.results
|
||||||
this.correspondentSelectionModel.toggle(newCorrespondent.id)
|
this.correspondentSelectionModel.toggle(newCorrespondent.id)
|
||||||
})
|
})
|
||||||
return modal
|
|
||||||
}
|
}
|
||||||
|
|
||||||
createDocumentType(name: string) {
|
createDocumentType(name: string) {
|
||||||
@@ -819,7 +815,6 @@ export class BulkEditorComponent
|
|||||||
this.documentTypeSelectionModel.items = documentTypes.results
|
this.documentTypeSelectionModel.items = documentTypes.results
|
||||||
this.documentTypeSelectionModel.toggle(newDocumentType.id)
|
this.documentTypeSelectionModel.toggle(newDocumentType.id)
|
||||||
})
|
})
|
||||||
return modal
|
|
||||||
}
|
}
|
||||||
|
|
||||||
createStoragePath(name: string) {
|
createStoragePath(name: string) {
|
||||||
@@ -841,7 +836,6 @@ export class BulkEditorComponent
|
|||||||
this.storagePathsSelectionModel.items = storagePaths.results
|
this.storagePathsSelectionModel.items = storagePaths.results
|
||||||
this.storagePathsSelectionModel.toggle(newStoragePath.id)
|
this.storagePathsSelectionModel.toggle(newStoragePath.id)
|
||||||
})
|
})
|
||||||
return modal
|
|
||||||
}
|
}
|
||||||
|
|
||||||
createCustomField(name: string) {
|
createCustomField(name: string) {
|
||||||
@@ -863,7 +857,6 @@ export class BulkEditorComponent
|
|||||||
this.customFieldsSelectionModel.items = customFields.results
|
this.customFieldsSelectionModel.items = customFields.results
|
||||||
this.customFieldsSelectionModel.toggle(newCustomField.id)
|
this.customFieldsSelectionModel.toggle(newCustomField.id)
|
||||||
})
|
})
|
||||||
return modal
|
|
||||||
}
|
}
|
||||||
|
|
||||||
applyDelete() {
|
applyDelete() {
|
||||||
@@ -879,7 +872,7 @@ export class BulkEditorComponent
|
|||||||
modal.componentInstance.confirmClicked
|
modal.componentInstance.confirmClicked
|
||||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||||
.subscribe(() => {
|
.subscribe(() => {
|
||||||
modal.componentInstance.buttonsEnabled.set(false)
|
modal.componentInstance.buttonsEnabled = false
|
||||||
this.executeDocumentAction(
|
this.executeDocumentAction(
|
||||||
modal,
|
modal,
|
||||||
this.documentService.deleteDocuments(this.getSelectionQuery())
|
this.documentService.deleteDocuments(this.getSelectionQuery())
|
||||||
@@ -927,7 +920,7 @@ export class BulkEditorComponent
|
|||||||
modal.componentInstance.confirmClicked
|
modal.componentInstance.confirmClicked
|
||||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||||
.subscribe(() => {
|
.subscribe(() => {
|
||||||
modal.componentInstance.buttonsEnabled.set(false)
|
modal.componentInstance.buttonsEnabled = false
|
||||||
this.executeDocumentAction(
|
this.executeDocumentAction(
|
||||||
modal,
|
modal,
|
||||||
this.documentService.reprocessDocuments(this.getSelectionQuery())
|
this.documentService.reprocessDocuments(this.getSelectionQuery())
|
||||||
@@ -964,7 +957,7 @@ export class BulkEditorComponent
|
|||||||
rotateDialog.confirmClicked
|
rotateDialog.confirmClicked
|
||||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||||
.subscribe(() => {
|
.subscribe(() => {
|
||||||
rotateDialog.buttonsEnabled.set(false)
|
rotateDialog.buttonsEnabled = false
|
||||||
this.executeDocumentAction(
|
this.executeDocumentAction(
|
||||||
modal,
|
modal,
|
||||||
this.documentService.rotateDocuments(
|
this.documentService.rotateDocuments(
|
||||||
@@ -997,11 +990,11 @@ export class BulkEditorComponent
|
|||||||
if (mergeDialog.archiveFallback()) {
|
if (mergeDialog.archiveFallback()) {
|
||||||
args.archive_fallback = true
|
args.archive_fallback = true
|
||||||
}
|
}
|
||||||
mergeDialog.buttonsEnabled.set(false)
|
mergeDialog.buttonsEnabled = false
|
||||||
this.executeDocumentAction(
|
this.executeDocumentAction(
|
||||||
modal,
|
modal,
|
||||||
this.documentService.mergeDocuments(mergeDialog.documentIDs(), args),
|
this.documentService.mergeDocuments(mergeDialog.documentIDs(), args),
|
||||||
{ clearSelection: !!args.delete_originals }
|
{ deleteOriginals: !!args.delete_originals }
|
||||||
)
|
)
|
||||||
this.toastService.showInfo(
|
this.toastService.showInfo(
|
||||||
$localize`Merged document will be queued for consumption.`
|
$localize`Merged document will be queued for consumption.`
|
||||||
@@ -1009,35 +1002,6 @@ export class BulkEditorComponent
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
mergeSelectedAsVersions() {
|
|
||||||
let modal = this.modalService.open(MergeAsVersionsConfirmDialogComponent, {
|
|
||||||
backdrop: 'static',
|
|
||||||
})
|
|
||||||
const mergeDialog =
|
|
||||||
modal.componentInstance as MergeAsVersionsConfirmDialogComponent
|
|
||||||
const documentIDs = Array.from(this.list.selected)
|
|
||||||
mergeDialog.title = $localize`Merge as versions`
|
|
||||||
mergeDialog.message = $localize`The selected documents will become versions of the root document.`
|
|
||||||
mergeDialog.btnCaption = $localize`Proceed`
|
|
||||||
mergeDialog.documentIDs.set(documentIDs)
|
|
||||||
mergeDialog.rootDocumentID.set(documentIDs[0])
|
|
||||||
mergeDialog.confirmClicked
|
|
||||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
|
||||||
.subscribe(() => {
|
|
||||||
this.executeDocumentAction(
|
|
||||||
modal,
|
|
||||||
this.documentService.mergeDocumentsAsVersions(
|
|
||||||
mergeDialog.documentIDs(),
|
|
||||||
mergeDialog.rootDocumentID()
|
|
||||||
),
|
|
||||||
{
|
|
||||||
clearSelection: true,
|
|
||||||
successMessage: $localize`Documents merged as versions.`,
|
|
||||||
}
|
|
||||||
)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
public setCustomFieldValues(changedCustomFields: ChangedItems) {
|
public setCustomFieldValues(changedCustomFields: ChangedItems) {
|
||||||
const modal = this.modalService.open(CustomFieldsBulkEditDialogComponent, {
|
const modal = this.modalService.open(CustomFieldsBulkEditDialogComponent, {
|
||||||
backdrop: 'static',
|
backdrop: 'static',
|
||||||
@@ -1099,14 +1063,14 @@ export class BulkEditorComponent
|
|||||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||||
.subscribe(() => {
|
.subscribe(() => {
|
||||||
dialog.loading.set(true)
|
dialog.loading.set(true)
|
||||||
dialog.buttonsEnabled.set(false)
|
dialog.buttonsEnabled = false
|
||||||
this.shareLinkBundleService
|
this.shareLinkBundleService
|
||||||
.createBundle(dialog.payload)
|
.createBundle(dialog.payload)
|
||||||
.pipe(first())
|
.pipe(first())
|
||||||
.subscribe({
|
.subscribe({
|
||||||
next: (result) => {
|
next: (result) => {
|
||||||
dialog.loading.set(false)
|
dialog.loading.set(false)
|
||||||
dialog.buttonsEnabled.set(false)
|
dialog.buttonsEnabled = false
|
||||||
dialog.createdBundle = result
|
dialog.createdBundle = result
|
||||||
dialog.copied.set(false)
|
dialog.copied.set(false)
|
||||||
dialog.payload = null
|
dialog.payload = null
|
||||||
@@ -1120,7 +1084,7 @@ export class BulkEditorComponent
|
|||||||
},
|
},
|
||||||
error: (error) => {
|
error: (error) => {
|
||||||
dialog.loading.set(false)
|
dialog.loading.set(false)
|
||||||
dialog.buttonsEnabled.set(true)
|
dialog.buttonsEnabled = true
|
||||||
this.toastService.showError(
|
this.toastService.showError(
|
||||||
$localize`Share link bundle creation is not available yet.`,
|
$localize`Share link bundle creation is not available yet.`,
|
||||||
error
|
error
|
||||||
|
|||||||
@@ -97,9 +97,7 @@
|
|||||||
<div class="dropdown-menu shadow dropdown-menu-right" ngbDropdownMenu>
|
<div class="dropdown-menu shadow dropdown-menu-right" ngbDropdownMenu>
|
||||||
@if (!list.activeSavedViewId) {
|
@if (!list.activeSavedViewId) {
|
||||||
@for (view of savedViewService.allViews; track view) {
|
@for (view of savedViewService.allViews; track view) {
|
||||||
<button ngbDropdownItem (click)="loadViewConfig(view.id)">
|
<button ngbDropdownItem (click)="loadViewConfig(view.id)">{{view.name}}</button>
|
||||||
<i-bs class="me-2" [name]="view.icon || 'funnel'"></i-bs>{{view.name}}
|
|
||||||
</button>
|
|
||||||
}
|
}
|
||||||
@if (savedViewService.allViews.length > 0) {
|
@if (savedViewService.allViews.length > 0) {
|
||||||
<div class="dropdown-divider"></div>
|
<div class="dropdown-divider"></div>
|
||||||
|
|||||||
@@ -64,13 +64,6 @@ $paperless-card-breakpoints: (
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Popper may place a dropdown above its toggle when the virtual keyboard
|
|
||||||
// reduces the available viewport, increase the z-index so navbar doesn't
|
|
||||||
// obscure it. See github.com/paperless-ngx/paperless-ngx/pull/13694
|
|
||||||
:host ::ng-deep .sticky-top:has(.dropdown-menu.show) {
|
|
||||||
z-index: 1040;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 579.98px) {
|
@media (max-width: 579.98px) {
|
||||||
:host-context(main.mobile-search-hidden) .sticky-top {
|
:host-context(main.mobile-search-hidden) .sticky-top {
|
||||||
top: calc(3.5rem - 2px); // height of navbar only when search is hidden
|
top: calc(3.5rem - 2px); // height of navbar only when search is hidden
|
||||||
|
|||||||
@@ -457,7 +457,6 @@ export class DocumentListComponent
|
|||||||
modal.componentInstance.buttonsEnabled.set(false)
|
modal.componentInstance.buttonsEnabled.set(false)
|
||||||
let savedView: SavedView = {
|
let savedView: SavedView = {
|
||||||
name: formValue.name,
|
name: formValue.name,
|
||||||
icon: formValue.icon,
|
|
||||||
filter_rules: this.list.filterRules,
|
filter_rules: this.list.filterRules,
|
||||||
sort_reverse: this.list.sortReverse,
|
sort_reverse: this.list.sortReverse,
|
||||||
sort_field: this.list.sortField,
|
sort_field: this.list.sortField,
|
||||||
|
|||||||
-8
@@ -6,14 +6,6 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<pngx-input-text i18n-title title="Name" formControlName="name" [error]="error()?.name" autocomplete="off"></pngx-input-text>
|
<pngx-input-text i18n-title title="Name" formControlName="name" [error]="error()?.name" autocomplete="off"></pngx-input-text>
|
||||||
<pngx-input-select
|
|
||||||
i18n-title
|
|
||||||
title="Icon"
|
|
||||||
formControlName="icon"
|
|
||||||
[items]="savedViewIcons"
|
|
||||||
iconField="icon"
|
|
||||||
[error]="error()?.icon">
|
|
||||||
</pngx-input-select>
|
|
||||||
<pngx-input-check i18n-title title="Show in sidebar" formControlName="showInSideBar"></pngx-input-check>
|
<pngx-input-check i18n-title title="Show in sidebar" formControlName="showInSideBar"></pngx-input-check>
|
||||||
<pngx-input-check i18n-title title="Show on dashboard" formControlName="showOnDashboard"></pngx-input-check>
|
<pngx-input-check i18n-title title="Show on dashboard" formControlName="showOnDashboard"></pngx-input-check>
|
||||||
<pngx-permissions-form accordion="true" formControlName="permissions_form"></pngx-permissions-form>
|
<pngx-permissions-form accordion="true" formControlName="permissions_form"></pngx-permissions-form>
|
||||||
|
|||||||
-5
@@ -9,7 +9,6 @@ import { CheckComponent } from '../../common/input/check/check.component'
|
|||||||
import { PermissionsFormComponent } from '../../common/input/permissions/permissions-form/permissions-form.component'
|
import { PermissionsFormComponent } from '../../common/input/permissions/permissions-form/permissions-form.component'
|
||||||
import { PermissionsGroupComponent } from '../../common/input/permissions/permissions-group/permissions-group.component'
|
import { PermissionsGroupComponent } from '../../common/input/permissions/permissions-group/permissions-group.component'
|
||||||
import { PermissionsUserComponent } from '../../common/input/permissions/permissions-user/permissions-user.component'
|
import { PermissionsUserComponent } from '../../common/input/permissions/permissions-user/permissions-user.component'
|
||||||
import { SelectComponent } from '../../common/input/select/select.component'
|
|
||||||
import { TextComponent } from '../../common/input/text/text.component'
|
import { TextComponent } from '../../common/input/text/text.component'
|
||||||
import { SaveViewConfigDialogComponent } from './save-view-config-dialog.component'
|
import { SaveViewConfigDialogComponent } from './save-view-config-dialog.component'
|
||||||
|
|
||||||
@@ -41,7 +40,6 @@ describe('SaveViewConfigDialogComponent', () => {
|
|||||||
ReactiveFormsModule,
|
ReactiveFormsModule,
|
||||||
SaveViewConfigDialogComponent,
|
SaveViewConfigDialogComponent,
|
||||||
TextComponent,
|
TextComponent,
|
||||||
SelectComponent,
|
|
||||||
CheckComponent,
|
CheckComponent,
|
||||||
PermissionsFormComponent,
|
PermissionsFormComponent,
|
||||||
PermissionsUserComponent,
|
PermissionsUserComponent,
|
||||||
@@ -65,7 +63,6 @@ describe('SaveViewConfigDialogComponent', () => {
|
|||||||
expect(component.defaultName()).toEqual(name)
|
expect(component.defaultName()).toEqual(name)
|
||||||
expect(result).toEqual({
|
expect(result).toEqual({
|
||||||
name,
|
name,
|
||||||
icon: 'funnel',
|
|
||||||
showInSideBar: false,
|
showInSideBar: false,
|
||||||
showOnDashboard: false,
|
showOnDashboard: false,
|
||||||
})
|
})
|
||||||
@@ -97,7 +94,6 @@ describe('SaveViewConfigDialogComponent', () => {
|
|||||||
component.save()
|
component.save()
|
||||||
expect(result).toEqual({
|
expect(result).toEqual({
|
||||||
name,
|
name,
|
||||||
icon: 'funnel',
|
|
||||||
showInSideBar: true,
|
showInSideBar: true,
|
||||||
showOnDashboard: true,
|
showOnDashboard: true,
|
||||||
})
|
})
|
||||||
@@ -117,7 +113,6 @@ describe('SaveViewConfigDialogComponent', () => {
|
|||||||
component.save()
|
component.save()
|
||||||
expect(result).toEqual({
|
expect(result).toEqual({
|
||||||
name: '',
|
name: '',
|
||||||
icon: 'funnel',
|
|
||||||
showInSideBar: false,
|
showInSideBar: false,
|
||||||
showOnDashboard: false,
|
showOnDashboard: false,
|
||||||
permissions_form: permissions,
|
permissions_form: permissions,
|
||||||
|
|||||||
-9
@@ -13,14 +13,9 @@ import {
|
|||||||
ReactiveFormsModule,
|
ReactiveFormsModule,
|
||||||
} from '@angular/forms'
|
} from '@angular/forms'
|
||||||
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
|
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
|
||||||
import {
|
|
||||||
DEFAULT_SAVED_VIEW_ICON,
|
|
||||||
SAVED_VIEW_ICONS,
|
|
||||||
} from 'src/app/data/saved-view-icons'
|
|
||||||
import { User } from 'src/app/data/user'
|
import { User } from 'src/app/data/user'
|
||||||
import { CheckComponent } from '../../common/input/check/check.component'
|
import { CheckComponent } from '../../common/input/check/check.component'
|
||||||
import { PermissionsFormComponent } from '../../common/input/permissions/permissions-form/permissions-form.component'
|
import { PermissionsFormComponent } from '../../common/input/permissions/permissions-form/permissions-form.component'
|
||||||
import { SelectComponent } from '../../common/input/select/select.component'
|
|
||||||
import { TextComponent } from '../../common/input/text/text.component'
|
import { TextComponent } from '../../common/input/text/text.component'
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
@@ -29,7 +24,6 @@ import { TextComponent } from '../../common/input/text/text.component'
|
|||||||
styleUrls: ['./save-view-config-dialog.component.scss'],
|
styleUrls: ['./save-view-config-dialog.component.scss'],
|
||||||
imports: [
|
imports: [
|
||||||
CheckComponent,
|
CheckComponent,
|
||||||
SelectComponent,
|
|
||||||
TextComponent,
|
TextComponent,
|
||||||
PermissionsFormComponent,
|
PermissionsFormComponent,
|
||||||
FormsModule,
|
FormsModule,
|
||||||
@@ -47,7 +41,6 @@ export class SaveViewConfigDialogComponent implements OnInit {
|
|||||||
public saveClicked = new EventEmitter()
|
public saveClicked = new EventEmitter()
|
||||||
|
|
||||||
users: User[]
|
users: User[]
|
||||||
readonly savedViewIcons = SAVED_VIEW_ICONS
|
|
||||||
|
|
||||||
setDefaultName(value: string) {
|
setDefaultName(value: string) {
|
||||||
this.defaultName.set(value)
|
this.defaultName.set(value)
|
||||||
@@ -56,7 +49,6 @@ export class SaveViewConfigDialogComponent implements OnInit {
|
|||||||
|
|
||||||
saveViewConfigForm = new FormGroup({
|
saveViewConfigForm = new FormGroup({
|
||||||
name: new FormControl(''),
|
name: new FormControl(''),
|
||||||
icon: new FormControl(DEFAULT_SAVED_VIEW_ICON),
|
|
||||||
showInSideBar: new FormControl(false),
|
showInSideBar: new FormControl(false),
|
||||||
showOnDashboard: new FormControl(false),
|
showOnDashboard: new FormControl(false),
|
||||||
permissions_form: new FormControl(null),
|
permissions_form: new FormControl(null),
|
||||||
@@ -73,7 +65,6 @@ export class SaveViewConfigDialogComponent implements OnInit {
|
|||||||
const formValue = this.saveViewConfigForm.value
|
const formValue = this.saveViewConfigForm.value
|
||||||
const saveViewConfig = {
|
const saveViewConfig = {
|
||||||
name: formValue.name,
|
name: formValue.name,
|
||||||
icon: formValue.icon,
|
|
||||||
showInSideBar: formValue.showInSideBar,
|
showInSideBar: formValue.showInSideBar,
|
||||||
showOnDashboard: formValue.showOnDashboard,
|
showOnDashboard: formValue.showOnDashboard,
|
||||||
}
|
}
|
||||||
|
|||||||
+36
-38
@@ -21,47 +21,45 @@
|
|||||||
<div class="col d-flex align-items-center"><button class="btn btn-link p-0 text-start" type="button" (click)="editField(field)" [disabled]="!permissionsService.currentUserCan(PermissionAction.Change, PermissionType.CustomField)">{{field.name}}</button></div>
|
<div class="col d-flex align-items-center"><button class="btn btn-link p-0 text-start" type="button" (click)="editField(field)" [disabled]="!permissionsService.currentUserCan(PermissionAction.Change, PermissionType.CustomField)">{{field.name}}</button></div>
|
||||||
<div class="col d-flex align-items-center">{{getDataType(field)}}</div>
|
<div class="col d-flex align-items-center">{{getDataType(field)}}</div>
|
||||||
<div class="col">
|
<div class="col">
|
||||||
<div class="btn-toolbar gap-2">
|
<div class="btn-group d-block d-sm-none">
|
||||||
<div class="btn-group d-block d-sm-none">
|
<div ngbDropdown container="body" class="d-inline-block">
|
||||||
<div ngbDropdown container="body" class="d-inline-block">
|
<button type="button" class="btn btn-link" id="actionsMenuMobile" (click)="$event.stopPropagation()" ngbDropdownToggle>
|
||||||
<button type="button" class="btn btn-link" id="actionsMenuMobile" (click)="$event.stopPropagation()" ngbDropdownToggle>
|
<i-bs name="three-dots-vertical"></i-bs>
|
||||||
<i-bs name="three-dots-vertical"></i-bs>
|
</button>
|
||||||
</button>
|
<div ngbDropdownMenu aria-labelledby="actionsMenuMobile">
|
||||||
<div ngbDropdownMenu aria-labelledby="actionsMenuMobile">
|
<button (click)="editField(field)" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.CustomField }" ngbDropdownItem i18n>Edit</button>
|
||||||
<button (click)="editField(field)" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.CustomField }" ngbDropdownItem i18n>Edit</button>
|
<button class="text-danger" (click)="deleteField(field)" *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.CustomField }" ngbDropdownItem i18n>Delete</button>
|
||||||
<button class="text-danger" (click)="deleteField(field)" *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.CustomField }" ngbDropdownItem i18n>Delete</button>
|
@if (field.document_count > 0) {
|
||||||
@if (field.document_count > 0) {
|
<a
|
||||||
<a
|
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }"
|
||||||
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }"
|
ngbDropdownItem
|
||||||
ngbDropdownItem
|
[routerLink]="getDocumentFilterUrl(field)"
|
||||||
[routerLink]="getDocumentFilterUrl(field)"
|
i18n
|
||||||
i18n
|
>Filter Documents ({{ field.document_count }})</a
|
||||||
>Filter Documents ({{ field.document_count }})</a
|
>
|
||||||
>
|
}
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</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" 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" class="me-1"></i-bs><ng-container i18n>Delete</ng-container>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
@if (field.document_count > 0) {
|
|
||||||
<div class="btn-group d-none d-sm-inline-block">
|
|
||||||
<a
|
|
||||||
class="btn btn-sm btn-outline-secondary"
|
|
||||||
[routerLink]="getDocumentFilterUrl(field)"
|
|
||||||
>
|
|
||||||
<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>
|
|
||||||
}
|
|
||||||
</div>
|
</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" 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" class="me-1"></i-bs><ng-container i18n>Delete</ng-container>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
@if (field.document_count > 0) {
|
||||||
|
<div class="btn-group d-none d-sm-inline-block ms-2">
|
||||||
|
<a
|
||||||
|
class="btn btn-sm btn-outline-secondary"
|
||||||
|
[routerLink]="getDocumentFilterUrl(field)"
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
+1
-1
@@ -105,7 +105,7 @@ export class CustomFieldsComponent
|
|||||||
modal.componentInstance.btnClass = 'btn-danger'
|
modal.componentInstance.btnClass = 'btn-danger'
|
||||||
modal.componentInstance.btnCaption = $localize`Proceed`
|
modal.componentInstance.btnCaption = $localize`Proceed`
|
||||||
modal.componentInstance.confirmClicked.subscribe(() => {
|
modal.componentInstance.confirmClicked.subscribe(() => {
|
||||||
modal.componentInstance.buttonsEnabled.set(false)
|
modal.componentInstance.buttonsEnabled = false
|
||||||
this.customFieldsService.delete(field).subscribe({
|
this.customFieldsService.delete(field).subscribe({
|
||||||
next: () => {
|
next: () => {
|
||||||
modal.close()
|
modal.close()
|
||||||
|
|||||||
+1
-1
@@ -69,7 +69,7 @@
|
|||||||
}
|
}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<div class="my-3">
|
<div class="my-3 shadow-sm">
|
||||||
<ng-container
|
<ng-container
|
||||||
[ngComponentOutlet]="activeSection?.component"
|
[ngComponentOutlet]="activeSection?.component"
|
||||||
#activeOutlet="ngComponentOutlet"
|
#activeOutlet="ngComponentOutlet"
|
||||||
|
|||||||
+4
-4
@@ -274,7 +274,7 @@ export abstract class ManagementListComponent<T extends MatchingModel>
|
|||||||
activeModal.componentInstance.btnClass = 'btn-danger'
|
activeModal.componentInstance.btnClass = 'btn-danger'
|
||||||
activeModal.componentInstance.btnCaption = $localize`Delete`
|
activeModal.componentInstance.btnCaption = $localize`Delete`
|
||||||
activeModal.componentInstance.confirmClicked.subscribe(() => {
|
activeModal.componentInstance.confirmClicked.subscribe(() => {
|
||||||
activeModal.componentInstance.buttonsEnabled.set(false)
|
activeModal.componentInstance.buttonsEnabled = false
|
||||||
this.service
|
this.service
|
||||||
.delete(object)
|
.delete(object)
|
||||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||||
@@ -284,7 +284,7 @@ export abstract class ManagementListComponent<T extends MatchingModel>
|
|||||||
this.reloadData()
|
this.reloadData()
|
||||||
},
|
},
|
||||||
error: (error) => {
|
error: (error) => {
|
||||||
activeModal.componentInstance.buttonsEnabled.set(true)
|
activeModal.componentInstance.buttonsEnabled = true
|
||||||
this.toastService.showError(
|
this.toastService.showError(
|
||||||
$localize`Error while deleting element`,
|
$localize`Error while deleting element`,
|
||||||
error
|
error
|
||||||
@@ -455,7 +455,7 @@ export abstract class ManagementListComponent<T extends MatchingModel>
|
|||||||
modal.componentInstance.btnClass = 'btn-danger'
|
modal.componentInstance.btnClass = 'btn-danger'
|
||||||
modal.componentInstance.btnCaption = $localize`Proceed`
|
modal.componentInstance.btnCaption = $localize`Proceed`
|
||||||
modal.componentInstance.confirmClicked.subscribe(() => {
|
modal.componentInstance.confirmClicked.subscribe(() => {
|
||||||
modal.componentInstance.buttonsEnabled.set(false)
|
modal.componentInstance.buttonsEnabled = false
|
||||||
this.service
|
this.service
|
||||||
.bulk_edit_objects(
|
.bulk_edit_objects(
|
||||||
this.allSelectionActive ? [] : Array.from(this.selectedObjects),
|
this.allSelectionActive ? [] : Array.from(this.selectedObjects),
|
||||||
@@ -472,7 +472,7 @@ export abstract class ManagementListComponent<T extends MatchingModel>
|
|||||||
this.reloadData()
|
this.reloadData()
|
||||||
},
|
},
|
||||||
error: (error) => {
|
error: (error) => {
|
||||||
modal.componentInstance.buttonsEnabled.set(true)
|
modal.componentInstance.buttonsEnabled = true
|
||||||
this.toastService.showError(
|
this.toastService.showError(
|
||||||
$localize`Error deleting objects`,
|
$localize`Error deleting objects`,
|
||||||
error
|
error
|
||||||
|
|||||||
@@ -196,7 +196,7 @@ export class MailComponent
|
|||||||
modal.componentInstance.btnClass = 'btn-danger'
|
modal.componentInstance.btnClass = 'btn-danger'
|
||||||
modal.componentInstance.btnCaption = $localize`Proceed`
|
modal.componentInstance.btnCaption = $localize`Proceed`
|
||||||
modal.componentInstance.confirmClicked.subscribe(() => {
|
modal.componentInstance.confirmClicked.subscribe(() => {
|
||||||
modal.componentInstance.buttonsEnabled.set(false)
|
modal.componentInstance.buttonsEnabled = false
|
||||||
this.mailAccountService.delete(account).subscribe({
|
this.mailAccountService.delete(account).subscribe({
|
||||||
next: () => {
|
next: () => {
|
||||||
modal.close()
|
modal.close()
|
||||||
@@ -298,7 +298,7 @@ export class MailComponent
|
|||||||
modal.componentInstance.btnClass = 'btn-danger'
|
modal.componentInstance.btnClass = 'btn-danger'
|
||||||
modal.componentInstance.btnCaption = $localize`Proceed`
|
modal.componentInstance.btnCaption = $localize`Proceed`
|
||||||
modal.componentInstance.confirmClicked.subscribe(() => {
|
modal.componentInstance.confirmClicked.subscribe(() => {
|
||||||
modal.componentInstance.buttonsEnabled.set(false)
|
modal.componentInstance.buttonsEnabled = false
|
||||||
this.mailRuleService.delete(rule).subscribe({
|
this.mailRuleService.delete(rule).subscribe({
|
||||||
next: () => {
|
next: () => {
|
||||||
modal.close()
|
modal.close()
|
||||||
|
|||||||
@@ -7,24 +7,15 @@
|
|||||||
</pngx-page-header>
|
</pngx-page-header>
|
||||||
<form [formGroup]="savedViewsForm" (ngSubmit)="save()">
|
<form [formGroup]="savedViewsForm" (ngSubmit)="save()">
|
||||||
<ul class="list-group mb-3" formGroupName="savedViews">
|
<ul class="list-group mb-3" formGroupName="savedViews">
|
||||||
@for (view of pagedSavedViews(); track view) {
|
@for (view of savedViews(); track view) {
|
||||||
<li class="list-group-item py-3">
|
<li class="list-group-item py-3">
|
||||||
<div [formGroupName]="view.id">
|
<div [formGroupName]="view.id">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-md">
|
<div class="col">
|
||||||
<pngx-input-text title="Name" formControlName="name"></pngx-input-text>
|
<pngx-input-text title="Name" formControlName="name"></pngx-input-text>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md">
|
|
||||||
<pngx-input-select
|
|
||||||
i18n-title
|
|
||||||
title="Icon"
|
|
||||||
formControlName="icon"
|
|
||||||
[items]="savedViewIcons"
|
|
||||||
iconField="icon">
|
|
||||||
</pngx-input-select>
|
|
||||||
</div>
|
|
||||||
@if (canSaveSettings) {
|
@if (canSaveSettings) {
|
||||||
<div class="col-md">
|
<div class="col">
|
||||||
<div class="form-check form-switch mt-3">
|
<div class="form-check form-switch mt-3">
|
||||||
<input type="checkbox" class="form-check-input" id="show_on_dashboard_{{view.id}}" formControlName="show_on_dashboard">
|
<input type="checkbox" class="form-check-input" id="show_on_dashboard_{{view.id}}" formControlName="show_on_dashboard">
|
||||||
<label class="form-check-label" for="show_on_dashboard_{{view.id}}" i18n>Show on dashboard</label>
|
<label class="form-check-label" for="show_on_dashboard_{{view.id}}" i18n>Show on dashboard</label>
|
||||||
@@ -90,11 +81,6 @@
|
|||||||
}
|
}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<div class="d-flex align-items-center mb-3">
|
<button type="button" (click)="reset()" class="btn btn-outline-secondary mb-2" [disabled]="(isDirty$ | async) === false" i18n>Cancel</button>
|
||||||
<button type="button" (click)="reset()" class="btn btn-outline-secondary mb-2" [disabled]="(isDirty$ | async) === false" i18n>Cancel</button>
|
<button type="submit" class="btn btn-primary ms-2 mb-2" [disabled]="(isDirty$ | async) === false" i18n>Save</button>
|
||||||
<button type="submit" class="btn btn-primary ms-2 mb-2" [disabled]="(isDirty$ | async) === false" i18n>Save</button>
|
|
||||||
@if (savedViews()?.length > pageSize) {
|
|
||||||
<ngb-pagination class="ms-auto" [pageSize]="pageSize" [collectionSize]="savedViews().length" [page]="page()" [maxSize]="5" (pageChange)="page.set($event)" size="sm" aria-label="Pagination"></ngb-pagination>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { provideHttpClientTesting } from '@angular/common/http/testing'
|
|||||||
import { signal } from '@angular/core'
|
import { signal } from '@angular/core'
|
||||||
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||||
import { By } from '@angular/platform-browser'
|
|
||||||
import { NgbModal, NgbModule } from '@ng-bootstrap/ng-bootstrap'
|
import { NgbModal, NgbModule } from '@ng-bootstrap/ng-bootstrap'
|
||||||
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
||||||
import { Subject, of, throwError } from 'rxjs'
|
import { Subject, of, throwError } from 'rxjs'
|
||||||
@@ -26,20 +25,8 @@ import { PageHeaderComponent } from '../../common/page-header/page-header.compon
|
|||||||
import { SavedViewsComponent } from './saved-views.component'
|
import { SavedViewsComponent } from './saved-views.component'
|
||||||
|
|
||||||
const savedViews = [
|
const savedViews = [
|
||||||
{
|
{ id: 1, name: 'view1', show_in_sidebar: true, show_on_dashboard: true },
|
||||||
id: 1,
|
{ id: 2, name: 'view2', show_in_sidebar: false, show_on_dashboard: false },
|
||||||
name: 'view1',
|
|
||||||
icon: 'archive',
|
|
||||||
show_in_sidebar: true,
|
|
||||||
show_on_dashboard: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 2,
|
|
||||||
name: 'view2',
|
|
||||||
icon: 'funnel',
|
|
||||||
show_in_sidebar: false,
|
|
||||||
show_on_dashboard: false,
|
|
||||||
},
|
|
||||||
]
|
]
|
||||||
|
|
||||||
describe('SavedViewsComponent', () => {
|
describe('SavedViewsComponent', () => {
|
||||||
@@ -170,24 +157,6 @@ describe('SavedViewsComponent', () => {
|
|||||||
expect(patchBody.show_in_sidebar).toBeUndefined()
|
expect(patchBody.show_in_sidebar).toBeUndefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should persist a changed icon', () => {
|
|
||||||
const patchSpy = jest.spyOn(savedViewService, 'patchMany')
|
|
||||||
const view = savedViews[0]
|
|
||||||
const iconControl = component.savedViewsForm
|
|
||||||
.get('savedViews')
|
|
||||||
.get(view.id.toString())
|
|
||||||
.get('icon')
|
|
||||||
|
|
||||||
iconControl.setValue('bell')
|
|
||||||
iconControl.markAsDirty()
|
|
||||||
component.save()
|
|
||||||
|
|
||||||
expect(patchSpy.mock.calls[0][0][0]).toMatchObject({
|
|
||||||
id: view.id,
|
|
||||||
icon: 'bell',
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should persist visibility changes to user settings', () => {
|
it('should persist visibility changes to user settings', () => {
|
||||||
const patchSpy = jest.spyOn(savedViewService, 'patchMany')
|
const patchSpy = jest.spyOn(savedViewService, 'patchMany')
|
||||||
const updateVisibilitySpy = jest
|
const updateVisibilitySpy = jest
|
||||||
@@ -253,44 +222,6 @@ describe('SavedViewsComponent', () => {
|
|||||||
).toEqual(view.show_on_dashboard)
|
).toEqual(view.show_on_dashboard)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should page saved views, clamp the page if views are removed', () => {
|
|
||||||
const manyViews = Array.from({ length: 30 }, (_, i) => ({
|
|
||||||
id: i + 1,
|
|
||||||
name: `view${i + 1}`,
|
|
||||||
})) as SavedView[]
|
|
||||||
const listSpy = jest.spyOn(savedViewService, 'list').mockReturnValue(
|
|
||||||
of({
|
|
||||||
all: manyViews.map((v) => v.id),
|
|
||||||
count: manyViews.length,
|
|
||||||
results: manyViews.concat([]),
|
|
||||||
})
|
|
||||||
)
|
|
||||||
component.ngOnInit()
|
|
||||||
fixture.detectChanges()
|
|
||||||
expect(listSpy).toHaveBeenCalledWith(1, 100000, null, false, {
|
|
||||||
full_perms: true,
|
|
||||||
})
|
|
||||||
expect(component.pagedSavedViews()).toHaveLength(25)
|
|
||||||
expect(fixture.debugElement.query(By.css('ngb-pagination'))).not.toBeNull()
|
|
||||||
// all views have controls, not just the current page
|
|
||||||
expect(
|
|
||||||
Object.keys(component.savedViewsForm.get('savedViews').value)
|
|
||||||
).toHaveLength(30)
|
|
||||||
|
|
||||||
component.page.set(2)
|
|
||||||
expect(component.pagedSavedViews()).toHaveLength(5)
|
|
||||||
|
|
||||||
listSpy.mockReturnValue(
|
|
||||||
of({
|
|
||||||
all: manyViews.slice(0, 25).map((v) => v.id),
|
|
||||||
count: 25,
|
|
||||||
results: manyViews.slice(0, 25),
|
|
||||||
})
|
|
||||||
)
|
|
||||||
component.ngOnInit()
|
|
||||||
expect(component.page()).toEqual(1)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should support editing permissions', () => {
|
it('should support editing permissions', () => {
|
||||||
const confirmClicked = new Subject<any>()
|
const confirmClicked = new Subject<any>()
|
||||||
const modalRef = {
|
const modalRef = {
|
||||||
|
|||||||
@@ -1,29 +1,18 @@
|
|||||||
import { AsyncPipe } from '@angular/common'
|
import { AsyncPipe } from '@angular/common'
|
||||||
import {
|
import { Component, OnDestroy, OnInit, inject, signal } from '@angular/core'
|
||||||
Component,
|
|
||||||
OnDestroy,
|
|
||||||
OnInit,
|
|
||||||
computed,
|
|
||||||
inject,
|
|
||||||
signal,
|
|
||||||
} from '@angular/core'
|
|
||||||
import {
|
import {
|
||||||
FormControl,
|
FormControl,
|
||||||
FormGroup,
|
FormGroup,
|
||||||
FormsModule,
|
FormsModule,
|
||||||
ReactiveFormsModule,
|
ReactiveFormsModule,
|
||||||
} from '@angular/forms'
|
} from '@angular/forms'
|
||||||
import { NgbModal, NgbPaginationModule } from '@ng-bootstrap/ng-bootstrap'
|
import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
|
||||||
import { dirtyCheck } from '@ngneat/dirty-check-forms'
|
import { dirtyCheck } from '@ngneat/dirty-check-forms'
|
||||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||||
import { BehaviorSubject, Observable, of, switchMap, takeUntil } from 'rxjs'
|
import { BehaviorSubject, Observable, of, switchMap, takeUntil } from 'rxjs'
|
||||||
import { PermissionsDialogComponent } from 'src/app/components/common/permissions-dialog/permissions-dialog.component'
|
import { PermissionsDialogComponent } from 'src/app/components/common/permissions-dialog/permissions-dialog.component'
|
||||||
import { DisplayMode } from 'src/app/data/document'
|
import { DisplayMode } from 'src/app/data/document'
|
||||||
import { SavedView } from 'src/app/data/saved-view'
|
import { SavedView } from 'src/app/data/saved-view'
|
||||||
import {
|
|
||||||
DEFAULT_SAVED_VIEW_ICON,
|
|
||||||
SAVED_VIEW_ICONS,
|
|
||||||
} from 'src/app/data/saved-view-icons'
|
|
||||||
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
||||||
import {
|
import {
|
||||||
PermissionAction,
|
PermissionAction,
|
||||||
@@ -36,7 +25,6 @@ import { ToastService } from 'src/app/services/toast.service'
|
|||||||
import { ConfirmButtonComponent } from '../../common/confirm-button/confirm-button.component'
|
import { ConfirmButtonComponent } from '../../common/confirm-button/confirm-button.component'
|
||||||
import { DragDropSelectComponent } from '../../common/input/drag-drop-select/drag-drop-select.component'
|
import { DragDropSelectComponent } from '../../common/input/drag-drop-select/drag-drop-select.component'
|
||||||
import { NumberComponent } from '../../common/input/number/number.component'
|
import { NumberComponent } from '../../common/input/number/number.component'
|
||||||
import { SelectComponent } from '../../common/input/select/select.component'
|
|
||||||
import { TextComponent } from '../../common/input/text/text.component'
|
import { TextComponent } from '../../common/input/text/text.component'
|
||||||
import { PageHeaderComponent } from '../../common/page-header/page-header.component'
|
import { PageHeaderComponent } from '../../common/page-header/page-header.component'
|
||||||
import { LoadingComponentWithPermissions } from '../../loading-component/loading.component'
|
import { LoadingComponentWithPermissions } from '../../loading-component/loading.component'
|
||||||
@@ -48,14 +36,12 @@ import { LoadingComponentWithPermissions } from '../../loading-component/loading
|
|||||||
PageHeaderComponent,
|
PageHeaderComponent,
|
||||||
ConfirmButtonComponent,
|
ConfirmButtonComponent,
|
||||||
NumberComponent,
|
NumberComponent,
|
||||||
SelectComponent,
|
|
||||||
TextComponent,
|
TextComponent,
|
||||||
IfPermissionsDirective,
|
IfPermissionsDirective,
|
||||||
DragDropSelectComponent,
|
DragDropSelectComponent,
|
||||||
FormsModule,
|
FormsModule,
|
||||||
ReactiveFormsModule,
|
ReactiveFormsModule,
|
||||||
AsyncPipe,
|
AsyncPipe,
|
||||||
NgbPaginationModule,
|
|
||||||
NgxBootstrapIconsModule,
|
NgxBootstrapIconsModule,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
@@ -70,17 +56,8 @@ export class SavedViewsComponent
|
|||||||
private readonly modalService = inject(NgbModal)
|
private readonly modalService = inject(NgbModal)
|
||||||
|
|
||||||
DisplayMode = DisplayMode
|
DisplayMode = DisplayMode
|
||||||
readonly savedViewIcons = SAVED_VIEW_ICONS
|
|
||||||
|
|
||||||
readonly savedViews = signal<SavedView[]>(undefined)
|
readonly savedViews = signal<SavedView[]>(undefined)
|
||||||
readonly page = signal(1)
|
|
||||||
public readonly pageSize = 25
|
|
||||||
// All views are loaded at init, so paging is only for display
|
|
||||||
readonly pagedSavedViews = computed(() => {
|
|
||||||
const start = (this.page() - 1) * this.pageSize
|
|
||||||
return this.savedViews()?.slice(start, start + this.pageSize)
|
|
||||||
})
|
|
||||||
|
|
||||||
private savedViewsGroup = new FormGroup({})
|
private savedViewsGroup = new FormGroup({})
|
||||||
public savedViewsForm: FormGroup = new FormGroup({
|
public savedViewsForm: FormGroup = new FormGroup({
|
||||||
savedViews: this.savedViewsGroup,
|
savedViews: this.savedViewsGroup,
|
||||||
@@ -107,11 +84,9 @@ export class SavedViewsComponent
|
|||||||
private reloadViews(): void {
|
private reloadViews(): void {
|
||||||
this.loading.set(true)
|
this.loading.set(true)
|
||||||
this.savedViewService
|
this.savedViewService
|
||||||
.list(1, 100000, null, false, { full_perms: true })
|
.list(null, null, null, false, { full_perms: true })
|
||||||
.subscribe((r) => {
|
.subscribe((r) => {
|
||||||
this.savedViews.set(r.results)
|
this.savedViews.set(r.results)
|
||||||
const pageCount = Math.ceil(r.results.length / this.pageSize)
|
|
||||||
this.page.update((page) => Math.min(page, Math.max(1, pageCount)))
|
|
||||||
this.initialize()
|
this.initialize()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -135,7 +110,6 @@ export class SavedViewsComponent
|
|||||||
storeData.savedViews[view.id.toString()] = {
|
storeData.savedViews[view.id.toString()] = {
|
||||||
id: view.id,
|
id: view.id,
|
||||||
name: view.name,
|
name: view.name,
|
||||||
icon: view.icon ?? DEFAULT_SAVED_VIEW_ICON,
|
|
||||||
show_on_dashboard: view.show_on_dashboard,
|
show_on_dashboard: view.show_on_dashboard,
|
||||||
show_in_sidebar: view.show_in_sidebar,
|
show_in_sidebar: view.show_in_sidebar,
|
||||||
page_size: view.page_size,
|
page_size: view.page_size,
|
||||||
@@ -148,7 +122,6 @@ export class SavedViewsComponent
|
|||||||
new FormGroup({
|
new FormGroup({
|
||||||
id: new FormControl({ value: null, disabled: !canEdit }),
|
id: new FormControl({ value: null, disabled: !canEdit }),
|
||||||
name: new FormControl({ value: null, disabled: !canEdit }),
|
name: new FormControl({ value: null, disabled: !canEdit }),
|
||||||
icon: new FormControl({ value: null, disabled: !canEdit }),
|
|
||||||
show_on_dashboard: new FormControl({
|
show_on_dashboard: new FormControl({
|
||||||
value: null,
|
value: null,
|
||||||
disabled: false,
|
disabled: false,
|
||||||
@@ -227,7 +200,6 @@ export class SavedViewsComponent
|
|||||||
|
|
||||||
const modelFieldsChanged =
|
const modelFieldsChanged =
|
||||||
group.get('name')?.dirty ||
|
group.get('name')?.dirty ||
|
||||||
group.get('icon')?.dirty ||
|
|
||||||
group.get('page_size')?.dirty ||
|
group.get('page_size')?.dirty ||
|
||||||
group.get('display_mode')?.dirty ||
|
group.get('display_mode')?.dirty ||
|
||||||
group.get('display_fields')?.dirty
|
group.get('display_fields')?.dirty
|
||||||
|
|||||||
@@ -134,7 +134,7 @@ export class WorkflowsComponent
|
|||||||
modal.componentInstance.btnClass = 'btn-danger'
|
modal.componentInstance.btnClass = 'btn-danger'
|
||||||
modal.componentInstance.btnCaption = $localize`Proceed`
|
modal.componentInstance.btnCaption = $localize`Proceed`
|
||||||
modal.componentInstance.confirmClicked.subscribe(() => {
|
modal.componentInstance.confirmClicked.subscribe(() => {
|
||||||
modal.componentInstance.buttonsEnabled.set(false)
|
modal.componentInstance.buttonsEnabled = false
|
||||||
this.workflowService.delete(workflow).subscribe({
|
this.workflowService.delete(workflow).subscribe({
|
||||||
next: () => {
|
next: () => {
|
||||||
modal.close()
|
modal.close()
|
||||||
|
|||||||
@@ -1,89 +0,0 @@
|
|||||||
export const DEFAULT_SAVED_VIEW_ICON = 'funnel'
|
|
||||||
|
|
||||||
export const SAVED_VIEW_ICONS = [
|
|
||||||
{ id: 'archive', name: $localize`Archive`, icon: 'archive' },
|
|
||||||
{ id: 'bank', name: $localize`Bank`, icon: 'bank' },
|
|
||||||
{ id: 'basket', name: $localize`Basket`, icon: 'basket' },
|
|
||||||
{ id: 'bell', name: $localize`Bell`, icon: 'bell' },
|
|
||||||
{ id: 'bookmark', name: $localize`Bookmark`, icon: 'bookmark' },
|
|
||||||
{ id: 'boxes', name: $localize`Boxes`, icon: 'boxes' },
|
|
||||||
{ id: 'briefcase', name: $localize`Briefcase`, icon: 'briefcase' },
|
|
||||||
{ id: 'building', name: $localize`Building`, icon: 'building' },
|
|
||||||
{ id: 'calculator', name: $localize`Calculator`, icon: 'calculator' },
|
|
||||||
{ id: 'calendar', name: $localize`Calendar`, icon: 'calendar' },
|
|
||||||
{ id: 'camera', name: $localize`Camera`, icon: 'camera' },
|
|
||||||
{
|
|
||||||
id: 'card-checklist',
|
|
||||||
name: $localize`Checklist`,
|
|
||||||
icon: 'card-checklist',
|
|
||||||
},
|
|
||||||
{ id: 'cash', name: $localize`Cash`, icon: 'cash' },
|
|
||||||
{ id: 'chat-left-text', name: $localize`Chat`, icon: 'chat-left-text' },
|
|
||||||
{ id: 'check-circle', name: $localize`Check`, icon: 'check-circle' },
|
|
||||||
{ id: 'clipboard', name: $localize`Clipboard`, icon: 'clipboard' },
|
|
||||||
{ id: 'clock-history', name: $localize`Clock`, icon: 'clock-history' },
|
|
||||||
{ id: 'credit-card', name: $localize`Credit card`, icon: 'credit-card' },
|
|
||||||
{ id: 'download', name: $localize`Download`, icon: 'download' },
|
|
||||||
{ id: 'envelope', name: $localize`Envelope`, icon: 'envelope' },
|
|
||||||
{
|
|
||||||
id: 'exclamation-triangle',
|
|
||||||
name: $localize`Warning`,
|
|
||||||
icon: 'exclamation-triangle',
|
|
||||||
},
|
|
||||||
{ id: 'file-earmark', name: $localize`File`, icon: 'file-earmark' },
|
|
||||||
{
|
|
||||||
id: 'file-earmark-check',
|
|
||||||
name: $localize`Checked file`,
|
|
||||||
icon: 'file-earmark-check',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'file-earmark-lock',
|
|
||||||
name: $localize`Locked file`,
|
|
||||||
icon: 'file-earmark-lock',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'file-earmark-medical',
|
|
||||||
name: $localize`Medical file`,
|
|
||||||
icon: 'file-earmark-medical',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'file-earmark-person',
|
|
||||||
name: $localize`Person file`,
|
|
||||||
icon: 'file-earmark-person',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'file-earmark-spreadsheet',
|
|
||||||
name: $localize`Spreadsheet`,
|
|
||||||
icon: 'file-earmark-spreadsheet',
|
|
||||||
},
|
|
||||||
{ id: 'file-text', name: $localize`Text file`, icon: 'file-text' },
|
|
||||||
{ id: 'files', name: $localize`Files`, icon: 'files' },
|
|
||||||
{ id: 'folder', name: $localize`Folder`, icon: 'folder' },
|
|
||||||
{ id: 'funnel', name: $localize`Filter`, icon: 'funnel' },
|
|
||||||
{ id: 'gear', name: $localize`Gear`, icon: 'gear' },
|
|
||||||
{ id: 'globe2', name: $localize`Globe`, icon: 'globe2' },
|
|
||||||
{ id: 'hash', name: $localize`Hash`, icon: 'hash' },
|
|
||||||
{ id: 'heart', name: $localize`Heart`, icon: 'heart' },
|
|
||||||
{ id: 'house', name: $localize`House`, icon: 'house' },
|
|
||||||
{ id: 'inbox', name: $localize`Inbox`, icon: 'inbox' },
|
|
||||||
{ id: 'journals', name: $localize`Journals`, icon: 'journals' },
|
|
||||||
{ id: 'list-task', name: $localize`Task list`, icon: 'list-task' },
|
|
||||||
{ id: 'newspaper', name: $localize`Newspaper`, icon: 'newspaper' },
|
|
||||||
{ id: 'paperclip', name: $localize`Attachment`, icon: 'paperclip' },
|
|
||||||
{ id: 'people', name: $localize`People`, icon: 'people' },
|
|
||||||
{ id: 'person', name: $localize`Person`, icon: 'person' },
|
|
||||||
{ id: 'printer', name: $localize`Printer`, icon: 'printer' },
|
|
||||||
{ id: 'receipt', name: $localize`Receipt`, icon: 'receipt' },
|
|
||||||
{ id: 'safe', name: $localize`Safe`, icon: 'safe' },
|
|
||||||
{ id: 'search', name: $localize`Search`, icon: 'search' },
|
|
||||||
{ id: 'send', name: $localize`Send`, icon: 'send' },
|
|
||||||
{ id: 'shop', name: $localize`Shop`, icon: 'shop' },
|
|
||||||
{ id: 'stack', name: $localize`Stack`, icon: 'stack' },
|
|
||||||
{ id: 'stars', name: $localize`Stars`, icon: 'stars' },
|
|
||||||
{ id: 'tag', name: $localize`Tag`, icon: 'tag' },
|
|
||||||
{ id: 'tags', name: $localize`Tags`, icon: 'tags' },
|
|
||||||
{ id: 'telephone', name: $localize`Telephone`, icon: 'telephone' },
|
|
||||||
{ id: 'truck', name: $localize`Truck`, icon: 'truck' },
|
|
||||||
{ id: 'upc-scan', name: $localize`Barcode`, icon: 'upc-scan' },
|
|
||||||
{ id: 'wallet2', name: $localize`Wallet`, icon: 'wallet2' },
|
|
||||||
]
|
|
||||||
@@ -5,8 +5,6 @@ import { ObjectWithPermissions } from './object-with-permissions'
|
|||||||
export interface SavedView extends ObjectWithPermissions {
|
export interface SavedView extends ObjectWithPermissions {
|
||||||
name?: string
|
name?: string
|
||||||
|
|
||||||
icon?: string
|
|
||||||
|
|
||||||
show_on_dashboard?: boolean
|
show_on_dashboard?: boolean
|
||||||
|
|
||||||
show_in_sidebar?: boolean
|
show_in_sidebar?: boolean
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ export class DirtyFormGuard extends DirtyCheckGuard {
|
|||||||
modal.componentInstance.btnClass = 'btn-warning'
|
modal.componentInstance.btnClass = 'btn-warning'
|
||||||
modal.componentInstance.btnCaption = $localize`Leave page`
|
modal.componentInstance.btnCaption = $localize`Leave page`
|
||||||
modal.componentInstance.confirmClicked.subscribe(() => {
|
modal.componentInstance.confirmClicked.subscribe(() => {
|
||||||
modal.componentInstance.buttonsEnabled.set(false)
|
modal.componentInstance.buttonsEnabled = false
|
||||||
modal.close()
|
modal.close()
|
||||||
})
|
})
|
||||||
const subject = new Subject<boolean>()
|
const subject = new Subject<boolean>()
|
||||||
|
|||||||
@@ -36,12 +36,12 @@ export class DirtySavedViewGuard {
|
|||||||
modal.componentInstance.alternativeBtnClass = 'btn-primary'
|
modal.componentInstance.alternativeBtnClass = 'btn-primary'
|
||||||
modal.componentInstance.alternativeBtnCaption = $localize`Save and close`
|
modal.componentInstance.alternativeBtnCaption = $localize`Save and close`
|
||||||
modal.componentInstance.alternativeClicked.pipe(first()).subscribe(() => {
|
modal.componentInstance.alternativeClicked.pipe(first()).subscribe(() => {
|
||||||
modal.componentInstance.buttonsEnabled.set(false)
|
modal.componentInstance.buttonsEnabled = false
|
||||||
component.saveViewConfig()
|
component.saveViewConfig()
|
||||||
modal.close()
|
modal.close()
|
||||||
})
|
})
|
||||||
modal.componentInstance.confirmClicked.pipe(first()).subscribe(() => {
|
modal.componentInstance.confirmClicked.pipe(first()).subscribe(() => {
|
||||||
modal.componentInstance.buttonsEnabled.set(false)
|
modal.componentInstance.buttonsEnabled = false
|
||||||
modal.close()
|
modal.close()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -142,7 +142,7 @@ export class OpenDocumentsService {
|
|||||||
modal.componentInstance.btnClass = 'btn-warning'
|
modal.componentInstance.btnClass = 'btn-warning'
|
||||||
modal.componentInstance.btnCaption = $localize`Close document`
|
modal.componentInstance.btnCaption = $localize`Close document`
|
||||||
modal.componentInstance.confirmClicked.pipe(first()).subscribe(() => {
|
modal.componentInstance.confirmClicked.pipe(first()).subscribe(() => {
|
||||||
modal.componentInstance.buttonsEnabled.set(false)
|
modal.componentInstance.buttonsEnabled = false
|
||||||
modal.close()
|
modal.close()
|
||||||
this.openDocuments.splice(index, 1)
|
this.openDocuments.splice(index, 1)
|
||||||
this.dirtyDocuments.delete(doc.id)
|
this.dirtyDocuments.delete(doc.id)
|
||||||
@@ -165,7 +165,7 @@ export class OpenDocumentsService {
|
|||||||
modal.componentInstance.btnClass = 'btn-warning'
|
modal.componentInstance.btnClass = 'btn-warning'
|
||||||
modal.componentInstance.btnCaption = $localize`Close documents`
|
modal.componentInstance.btnCaption = $localize`Close documents`
|
||||||
modal.componentInstance.confirmClicked.pipe(first()).subscribe(() => {
|
modal.componentInstance.confirmClicked.pipe(first()).subscribe(() => {
|
||||||
modal.componentInstance.buttonsEnabled.set(false)
|
modal.componentInstance.buttonsEnabled = false
|
||||||
modal.close()
|
modal.close()
|
||||||
this.openDocuments.splice(0, this.openDocuments.length)
|
this.openDocuments.splice(0, this.openDocuments.length)
|
||||||
this.dirtyDocuments.clear()
|
this.dirtyDocuments.clear()
|
||||||
|
|||||||
@@ -120,12 +120,6 @@ describe('PermissionsService', () => {
|
|||||||
actionKey: 'View', // PermissionAction.View
|
actionKey: 'View', // PermissionAction.View
|
||||||
typeKey: 'SystemMonitoring', // PermissionType.SystemMonitoring
|
typeKey: 'SystemMonitoring', // PermissionType.SystemMonitoring
|
||||||
})
|
})
|
||||||
expect(permissionsService.getPermissionKeys('add_sharelinkbundle')).toEqual(
|
|
||||||
{
|
|
||||||
actionKey: 'Add', // PermissionAction.Add
|
|
||||||
typeKey: 'ShareLinkBundle', // PermissionType.ShareLinkBundle
|
|
||||||
}
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
it('correctly checks explicit global permissions', () => {
|
it('correctly checks explicit global permissions', () => {
|
||||||
@@ -275,10 +269,6 @@ describe('PermissionsService', () => {
|
|||||||
'view_sharelink',
|
'view_sharelink',
|
||||||
'change_sharelink',
|
'change_sharelink',
|
||||||
'delete_sharelink',
|
'delete_sharelink',
|
||||||
'add_sharelinkbundle',
|
|
||||||
'view_sharelinkbundle',
|
|
||||||
'change_sharelinkbundle',
|
|
||||||
'delete_sharelinkbundle',
|
|
||||||
'add_workflow',
|
'add_workflow',
|
||||||
'view_workflow',
|
'view_workflow',
|
||||||
'change_workflow',
|
'change_workflow',
|
||||||
|
|||||||
@@ -26,7 +26,6 @@ export enum PermissionType {
|
|||||||
User = '%s_user',
|
User = '%s_user',
|
||||||
Group = '%s_group',
|
Group = '%s_group',
|
||||||
ShareLink = '%s_sharelink',
|
ShareLink = '%s_sharelink',
|
||||||
ShareLinkBundle = '%s_sharelinkbundle',
|
|
||||||
CustomField = '%s_customfield',
|
CustomField = '%s_customfield',
|
||||||
Workflow = '%s_workflow',
|
Workflow = '%s_workflow',
|
||||||
ProcessedMail = '%s_processedmail',
|
ProcessedMail = '%s_processedmail',
|
||||||
|
|||||||
@@ -316,34 +316,6 @@ describe(`DocumentService`, () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should call appropriate api endpoint for merging documents as versions', () => {
|
|
||||||
const ids = [1, 2, 3]
|
|
||||||
subscription = service.mergeDocumentsAsVersions(ids, 2).subscribe()
|
|
||||||
const req = httpTestingController.expectOne(
|
|
||||||
`${environment.apiBaseUrl}${endpoint}/merge_as_versions/`
|
|
||||||
)
|
|
||||||
expect(req.request.method).toEqual('POST')
|
|
||||||
expect(req.request.body).toEqual({
|
|
||||||
documents: ids,
|
|
||||||
root_document_id: 2,
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should include an optional label when merging one document as a version', () => {
|
|
||||||
const ids = [1, 2]
|
|
||||||
subscription = service
|
|
||||||
.mergeDocumentsAsVersions(ids, 2, 'Imported')
|
|
||||||
.subscribe()
|
|
||||||
const req = httpTestingController.expectOne(
|
|
||||||
`${environment.apiBaseUrl}${endpoint}/merge_as_versions/`
|
|
||||||
)
|
|
||||||
expect(req.request.body).toEqual({
|
|
||||||
documents: ids,
|
|
||||||
root_document_id: 2,
|
|
||||||
version_label: 'Imported',
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should call appropriate api endpoint for edit pdf', () => {
|
it('should call appropriate api endpoint for edit pdf', () => {
|
||||||
const ids = [1]
|
const ids = [1]
|
||||||
const args = { operations: [{ page: 1, rotate: 90, doc: 0 }] }
|
const args = { operations: [{ page: 1, rotate: 90, doc: 0 }] }
|
||||||
|
|||||||
@@ -374,18 +374,6 @@ export class DocumentService extends AbstractPaperlessService<Document> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
mergeDocumentsAsVersions(
|
|
||||||
ids: number[],
|
|
||||||
rootDocumentId: number,
|
|
||||||
versionLabel?: string
|
|
||||||
) {
|
|
||||||
return this.http.post(this.getResourceUrl(null, 'merge_as_versions'), {
|
|
||||||
documents: ids,
|
|
||||||
root_document_id: rootDocumentId,
|
|
||||||
...(versionLabel ? { version_label: versionLabel } : {}),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
editPdfDocuments(ids: number[], request: EditPdfDocumentsRequest) {
|
editPdfDocuments(ids: number[], request: EditPdfDocumentsRequest) {
|
||||||
return this.http.post(this.getResourceUrl(null, 'edit_pdf'), {
|
return this.http.post(this.getResourceUrl(null, 'edit_pdf'), {
|
||||||
documents: ids,
|
documents: ids,
|
||||||
|
|||||||
@@ -35,27 +35,19 @@ import {
|
|||||||
arrowRightShort,
|
arrowRightShort,
|
||||||
arrowUpRight,
|
arrowUpRight,
|
||||||
asterisk,
|
asterisk,
|
||||||
bank,
|
|
||||||
basket,
|
|
||||||
bell,
|
bell,
|
||||||
bodyText,
|
bodyText,
|
||||||
bookmark,
|
|
||||||
boxArrowUp,
|
boxArrowUp,
|
||||||
boxArrowUpRight,
|
boxArrowUpRight,
|
||||||
boxes,
|
boxes,
|
||||||
braces,
|
braces,
|
||||||
briefcase,
|
|
||||||
building,
|
|
||||||
calculator,
|
|
||||||
calendar,
|
calendar,
|
||||||
calendarEvent,
|
calendarEvent,
|
||||||
calendarEventFill,
|
calendarEventFill,
|
||||||
camera,
|
|
||||||
cardChecklist,
|
cardChecklist,
|
||||||
cardHeading,
|
cardHeading,
|
||||||
caretDown,
|
caretDown,
|
||||||
caretUp,
|
caretUp,
|
||||||
cash,
|
|
||||||
chatLeftText,
|
chatLeftText,
|
||||||
chatSquareDots,
|
chatSquareDots,
|
||||||
check,
|
check,
|
||||||
@@ -73,7 +65,6 @@ import {
|
|||||||
clipboardCheckFill,
|
clipboardCheckFill,
|
||||||
clipboardFill,
|
clipboardFill,
|
||||||
clockHistory,
|
clockHistory,
|
||||||
creditCard,
|
|
||||||
dash,
|
dash,
|
||||||
dashCircle,
|
dashCircle,
|
||||||
diagram3,
|
diagram3,
|
||||||
@@ -92,12 +83,9 @@ import {
|
|||||||
fileEarmarkDiff,
|
fileEarmarkDiff,
|
||||||
fileEarmarkFill,
|
fileEarmarkFill,
|
||||||
fileEarmarkLock,
|
fileEarmarkLock,
|
||||||
fileEarmarkMedical,
|
|
||||||
fileEarmarkMinus,
|
fileEarmarkMinus,
|
||||||
fileEarmarkPerson,
|
|
||||||
fileEarmarkPlus,
|
fileEarmarkPlus,
|
||||||
fileEarmarkRichtext,
|
fileEarmarkRichtext,
|
||||||
fileEarmarkSpreadsheet,
|
|
||||||
fileText,
|
fileText,
|
||||||
files,
|
files,
|
||||||
filter,
|
filter,
|
||||||
@@ -105,26 +93,20 @@ import {
|
|||||||
folderFill,
|
folderFill,
|
||||||
funnel,
|
funnel,
|
||||||
gear,
|
gear,
|
||||||
globe2,
|
|
||||||
google,
|
google,
|
||||||
grid,
|
grid,
|
||||||
gripVertical,
|
gripVertical,
|
||||||
hash,
|
hash,
|
||||||
hddStack,
|
hddStack,
|
||||||
heart,
|
|
||||||
house,
|
house,
|
||||||
inbox,
|
|
||||||
infoCircle,
|
infoCircle,
|
||||||
journalBookmarkFill,
|
|
||||||
journals,
|
journals,
|
||||||
link,
|
link,
|
||||||
listNested,
|
listNested,
|
||||||
listTask,
|
listTask,
|
||||||
listUl,
|
listUl,
|
||||||
microsoft,
|
microsoft,
|
||||||
newspaper,
|
|
||||||
nodePlus,
|
nodePlus,
|
||||||
paperclip,
|
|
||||||
pencil,
|
pencil,
|
||||||
people,
|
people,
|
||||||
peopleFill,
|
peopleFill,
|
||||||
@@ -139,12 +121,9 @@ import {
|
|||||||
plusCircle,
|
plusCircle,
|
||||||
printer,
|
printer,
|
||||||
questionCircle,
|
questionCircle,
|
||||||
receipt,
|
|
||||||
safe,
|
|
||||||
scissors,
|
scissors,
|
||||||
search,
|
search,
|
||||||
send,
|
send,
|
||||||
shop,
|
|
||||||
slashCircle,
|
slashCircle,
|
||||||
sliders2Vertical,
|
sliders2Vertical,
|
||||||
sortAlphaDown,
|
sortAlphaDown,
|
||||||
@@ -154,17 +133,14 @@ import {
|
|||||||
tag,
|
tag,
|
||||||
tagFill,
|
tagFill,
|
||||||
tags,
|
tags,
|
||||||
telephone,
|
|
||||||
textIndentLeft,
|
textIndentLeft,
|
||||||
textLeft,
|
textLeft,
|
||||||
threeDots,
|
threeDots,
|
||||||
threeDotsVertical,
|
threeDotsVertical,
|
||||||
trash,
|
trash,
|
||||||
truck,
|
|
||||||
uiRadios,
|
uiRadios,
|
||||||
unlock,
|
unlock,
|
||||||
upcScan,
|
upcScan,
|
||||||
wallet2,
|
|
||||||
windowStack,
|
windowStack,
|
||||||
x,
|
x,
|
||||||
xCircle,
|
xCircle,
|
||||||
@@ -282,22 +258,15 @@ const icons = {
|
|||||||
arrowRightShort,
|
arrowRightShort,
|
||||||
arrowUpRight,
|
arrowUpRight,
|
||||||
asterisk,
|
asterisk,
|
||||||
bank,
|
|
||||||
basket,
|
|
||||||
bell,
|
bell,
|
||||||
braces,
|
braces,
|
||||||
bodyText,
|
bodyText,
|
||||||
bookmark,
|
|
||||||
boxArrowUp,
|
boxArrowUp,
|
||||||
boxArrowUpRight,
|
boxArrowUpRight,
|
||||||
boxes,
|
boxes,
|
||||||
briefcase,
|
|
||||||
building,
|
|
||||||
calculator,
|
|
||||||
calendar,
|
calendar,
|
||||||
calendarEvent,
|
calendarEvent,
|
||||||
calendarEventFill,
|
calendarEventFill,
|
||||||
camera,
|
|
||||||
cardChecklist,
|
cardChecklist,
|
||||||
cardHeading,
|
cardHeading,
|
||||||
caretDown,
|
caretDown,
|
||||||
@@ -319,8 +288,6 @@ const icons = {
|
|||||||
clipboardCheckFill,
|
clipboardCheckFill,
|
||||||
clipboardFill,
|
clipboardFill,
|
||||||
clockHistory,
|
clockHistory,
|
||||||
cash,
|
|
||||||
creditCard,
|
|
||||||
dash,
|
dash,
|
||||||
dashCircle,
|
dashCircle,
|
||||||
diagram3,
|
diagram3,
|
||||||
@@ -339,12 +306,9 @@ const icons = {
|
|||||||
fileEarmarkDiff,
|
fileEarmarkDiff,
|
||||||
fileEarmarkFill,
|
fileEarmarkFill,
|
||||||
fileEarmarkLock,
|
fileEarmarkLock,
|
||||||
fileEarmarkMedical,
|
|
||||||
fileEarmarkMinus,
|
fileEarmarkMinus,
|
||||||
fileEarmarkPerson,
|
|
||||||
fileEarmarkPlus,
|
fileEarmarkPlus,
|
||||||
fileEarmarkRichtext,
|
fileEarmarkRichtext,
|
||||||
fileEarmarkSpreadsheet,
|
|
||||||
files,
|
files,
|
||||||
fileText,
|
fileText,
|
||||||
filter,
|
filter,
|
||||||
@@ -352,27 +316,21 @@ const icons = {
|
|||||||
folderFill,
|
folderFill,
|
||||||
funnel,
|
funnel,
|
||||||
gear,
|
gear,
|
||||||
globe2,
|
|
||||||
google,
|
google,
|
||||||
grid,
|
grid,
|
||||||
gripVertical,
|
gripVertical,
|
||||||
hash,
|
hash,
|
||||||
hddStack,
|
hddStack,
|
||||||
heart,
|
|
||||||
house,
|
house,
|
||||||
inbox,
|
|
||||||
infoCircle,
|
infoCircle,
|
||||||
journalBookmarkFill,
|
|
||||||
journals,
|
journals,
|
||||||
link,
|
link,
|
||||||
listNested,
|
listNested,
|
||||||
listTask,
|
listTask,
|
||||||
listUl,
|
listUl,
|
||||||
microsoft,
|
microsoft,
|
||||||
newspaper,
|
|
||||||
nodePlus,
|
nodePlus,
|
||||||
pencil,
|
pencil,
|
||||||
paperclip,
|
|
||||||
people,
|
people,
|
||||||
peopleFill,
|
peopleFill,
|
||||||
person,
|
person,
|
||||||
@@ -386,13 +344,10 @@ const icons = {
|
|||||||
plusCircle,
|
plusCircle,
|
||||||
printer,
|
printer,
|
||||||
questionCircle,
|
questionCircle,
|
||||||
receipt,
|
|
||||||
safe,
|
|
||||||
scissors,
|
scissors,
|
||||||
search,
|
search,
|
||||||
send,
|
send,
|
||||||
slashCircle,
|
slashCircle,
|
||||||
shop,
|
|
||||||
sliders2Vertical,
|
sliders2Vertical,
|
||||||
sortAlphaDown,
|
sortAlphaDown,
|
||||||
sortAlphaUpAlt,
|
sortAlphaUpAlt,
|
||||||
@@ -403,15 +358,12 @@ const icons = {
|
|||||||
tags,
|
tags,
|
||||||
textIndentLeft,
|
textIndentLeft,
|
||||||
textLeft,
|
textLeft,
|
||||||
telephone,
|
|
||||||
threeDots,
|
threeDots,
|
||||||
threeDotsVertical,
|
threeDotsVertical,
|
||||||
trash,
|
trash,
|
||||||
truck,
|
|
||||||
uiRadios,
|
uiRadios,
|
||||||
unlock,
|
unlock,
|
||||||
upcScan,
|
upcScan,
|
||||||
wallet2,
|
|
||||||
windowStack,
|
windowStack,
|
||||||
x,
|
x,
|
||||||
xCircle,
|
xCircle,
|
||||||
|
|||||||
+29
-117
@@ -127,27 +127,6 @@ table .btn-link {
|
|||||||
background-color: var(--bs-body-bg);
|
background-color: var(--bs-body-bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
html {
|
|
||||||
-webkit-font-smoothing: antialiased;
|
|
||||||
-moz-osx-font-smoothing: grayscale;
|
|
||||||
}
|
|
||||||
|
|
||||||
h1, h2, h3, h4, h5, h6,
|
|
||||||
.h1, .h2, .h3, .h4, .h5, .h6 {
|
|
||||||
font-weight: 600;
|
|
||||||
}
|
|
||||||
|
|
||||||
pngx-page-header h3 {
|
|
||||||
font-size: 1.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
table,
|
|
||||||
.badge,
|
|
||||||
.card-info,
|
|
||||||
.pagination {
|
|
||||||
font-variant-numeric: tabular-nums;
|
|
||||||
}
|
|
||||||
|
|
||||||
.bg-primary {
|
.bg-primary {
|
||||||
background-color: var(--bs-primary) !important;
|
background-color: var(--bs-primary) !important;
|
||||||
color: var(--pngx-primary-text-contrast);
|
color: var(--pngx-primary-text-contrast);
|
||||||
@@ -234,8 +213,7 @@ table,
|
|||||||
}
|
}
|
||||||
|
|
||||||
.form-switch .form-check-input:focus {
|
.form-switch .form-check-input:focus {
|
||||||
// neutral knob in place of bootstrap's blue, which clashes with the theme colour
|
background-image: escape-svg(url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'><circle r='3' fill='#bbb'/></svg>"));
|
||||||
--bs-form-switch-bg: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='3' fill='%23bbb'/%3e%3c/svg%3e");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-item a:focus-visible {
|
.nav-item a:focus-visible {
|
||||||
@@ -291,44 +269,33 @@ a.btn-link:focus-visible,
|
|||||||
box-shadow: 0 0 0 3px rgba(255, 255, 255, .25);
|
box-shadow: 0 0 0 3px rgba(255, 255, 255, .25);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Sortable table headers
|
.asc {
|
||||||
th[pngxSortable] {
|
background-color: #f8f9fa!important;
|
||||||
cursor: pointer;
|
|
||||||
user-select: none;
|
|
||||||
white-space: nowrap;
|
|
||||||
|
|
||||||
&::after {
|
|
||||||
content: '';
|
|
||||||
display: inline-block;
|
|
||||||
vertical-align: -0.15em;
|
|
||||||
width: 0.8rem;
|
|
||||||
height: 0.8rem;
|
|
||||||
margin-left: 0.25rem;
|
|
||||||
// chevron, matched to the bootstrap-icons set used elsewhere in the app
|
|
||||||
mask: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill-rule='evenodd' d='M1.646 4.646a.5.5 0 0 1 .708 0L8 10.293l5.646-5.647a.5.5 0 0 1 .708.708l-6 6a.5.5 0 0 1-.708 0l-6-6a.5.5 0 0 1 0-.708z'/%3e%3c/svg%3e") no-repeat center / 0.8rem;
|
|
||||||
background-color: currentColor;
|
|
||||||
opacity: 0;
|
|
||||||
transition: opacity 0.15s ease-in-out;
|
|
||||||
}
|
|
||||||
|
|
||||||
// hint that an unsorted column can be sorted
|
|
||||||
&:hover::after {
|
|
||||||
opacity: 0.35;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.asc,
|
.asc:after {
|
||||||
.des {
|
content: '';
|
||||||
color: var(--bs-primary);
|
|
||||||
--bs-table-color-state: var(--bs-primary); // bootstrap sets cell color at higher specificity
|
|
||||||
|
|
||||||
&::after {
|
|
||||||
opacity: 1 !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.asc::after {
|
|
||||||
transform: rotate(180deg);
|
transform: rotate(180deg);
|
||||||
|
background: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAAAXNSR0IArs4c6QAAAmxJREFUeAHtmksrRVEUx72fH8CIGQNJkpGUUmakDEiZSJRIZsRQmCkTJRmZmJgQE0kpX0D5DJKJgff7v+ru2u3O3vvc67TOvsdatdrnnP1Y///v7HvvubdbUiIhBISAEBACQkAICAEhIAQ4CXSh2DnyDfmCPEG2Iv9F9MPlM/LHyAecdyMzHYNwR3fdNK/OH9HXl1UCozD24TCvILxizEDWIEzA0FcM8woCgRrJCoS5PIwrANQSMAJX1LEI9bqpQo4JYNFFKRSvIgsxHDVnqZgIkPnNBM0rIGtYk9YOOsqgbgepRCfdbmFtqhFkVEDVPjJp0+Z6e6hRHhqBKgg6ZDCvYBygVmUoEGoh5JTRvIJwhJo1aUOoh4CLPMyvxxi7EWOMgnCGsXXI1GIXlZUYX7ucU+kbR8NW8lh3O7cue0Pk32MKndfUxQFAwxdirk3fHappAnc0oqDPzDfGTBrCfHP04dM4oTV8cxr0SVzH9FF07xD3ib6xCDE+M+aUcVygtWzzbtGX2rPBrEUYfecfQkaFzYi6HjVnGBdtL7epqAlc1+jRdAap74RrnPc4BCijttY2tRcdN0g17w7HqZrXhdJTYAuS3hd8z+vKgK3V1zWPae0mZDMykadBn1hTQBLnZNwVrJpSe/NwEeDsEwCctEOsJTsgxLvCqUl2ACftEGvJDgjxrnBqkh3ASTvEWrIDQrwrnJpkB3DSDrGW7IAQ7wqnJtkBnLRztejXXVu4+mxz/nQ9jR1w5VB86ejLTFcnnDwhzV+F6T+CHZlx6THSjn76eyyBIOPHyDakhBAQAkJACAgBISAEhIAQYCLwC8JxpAmsEGt6AAAAAElFTkSuQmCC") no-repeat;
|
||||||
|
height: 1rem;
|
||||||
|
width: 1rem;
|
||||||
|
display: block;
|
||||||
|
background-size: 1rem;
|
||||||
|
float: right;
|
||||||
|
}
|
||||||
|
|
||||||
|
.des {
|
||||||
|
background-color: #f8f9fa!important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.des:after {
|
||||||
|
content: '';
|
||||||
|
background: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAAAXNSR0IArs4c6QAAAmxJREFUeAHtmksrRVEUx72fH8CIGQNJkpGUUmakDEiZSJRIZsRQmCkTJRmZmJgQE0kpX0D5DJKJgff7v+ru2u3O3vvc67TOvsdatdrnnP1Y///v7HvvubdbUiIhBISAEBACQkAICAEhIAQ4CXSh2DnyDfmCPEG2Iv9F9MPlM/LHyAecdyMzHYNwR3fdNK/OH9HXl1UCozD24TCvILxizEDWIEzA0FcM8woCgRrJCoS5PIwrANQSMAJX1LEI9bqpQo4JYNFFKRSvIgsxHDVnqZgIkPnNBM0rIGtYk9YOOsqgbgepRCfdbmFtqhFkVEDVPjJp0+Z6e6hRHhqBKgg6ZDCvYBygVmUoEGoh5JTRvIJwhJo1aUOoh4CLPMyvxxi7EWOMgnCGsXXI1GIXlZUYX7ucU+kbR8NW8lh3O7cue0Pk32MKndfUxQFAwxdirk3fHappAnc0oqDPzDfGTBrCfHP04dM4oTV8cxr0SVzH9FF07xD3ib6xCDE+M+aUcVygtWzzbtGX2rPBrEUYfecfQkaFzYi6HjVnGBdtL7epqAlc1+jRdAap74RrnPc4BCijttY2tRcdN0g17w7HqZrXhdJTYAuS3hd8z+vKgK3V1zWPae0mZDMykadBn1hTQBLnZNwVrJpSe/NwEeDsEwCctEOsJTsgxLvCqUl2ACftEGvJDgjxrnBqkh3ASTvEWrIDQrwrnJpkB3DSDrGW7IAQ7wqnJtkBnLRztejXXVu4+mxz/nQ9jR1w5VB86ejLTFcnnDwhzV+F6T+CHZlx6THSjn76eyyBIOPHyDakhBAQAkJACAgBISAEhIAQYCLwC8JxpAmsEGt6AAAAAElFTkSuQmCC") no-repeat;
|
||||||
|
height: 1rem;
|
||||||
|
width: 1rem;
|
||||||
|
display: block;
|
||||||
|
background-size: 1rem;
|
||||||
|
float: right;
|
||||||
}
|
}
|
||||||
|
|
||||||
.paperless-input-select {
|
.paperless-input-select {
|
||||||
@@ -597,6 +564,10 @@ ul.pagination {
|
|||||||
table.table {
|
table.table {
|
||||||
--bs-table-color: var(--bs-body-color);
|
--bs-table-color: var(--bs-body-color);
|
||||||
--bs-table-bg: var(--bs-light-rgb);
|
--bs-table-bg: var(--bs-light-rgb);
|
||||||
|
|
||||||
|
.des,.asc {
|
||||||
|
background-color: var(--bs-body-bg) !important;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.close {
|
.close {
|
||||||
@@ -607,55 +578,6 @@ table.table {
|
|||||||
color: var(--bs-body-color);
|
color: var(--bs-body-color);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Tighten horizontal spacing in modals on small viewports
|
|
||||||
@media (max-width: 575.98px) {
|
|
||||||
.modal {
|
|
||||||
--bs-modal-margin: 0.25rem;
|
|
||||||
--bs-modal-header-padding-x: 0.5rem;
|
|
||||||
--bs-modal-header-padding: var(--bs-modal-header-padding-y) var(--bs-modal-header-padding-x);
|
|
||||||
|
|
||||||
.modal-body {
|
|
||||||
padding-inline: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-footer {
|
|
||||||
padding-inline: 0.25rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.accordion {
|
|
||||||
--bs-accordion-btn-padding-x: 0.75rem;
|
|
||||||
--bs-accordion-body-padding-x: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card {
|
|
||||||
--bs-card-spacer-x: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.list-group {
|
|
||||||
--bs-list-group-item-padding-x: 0.5rem;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Wider-width modals on small viewports when the content is wide (e.g. landscape)
|
|
||||||
@media (min-width: 576px) and (max-height: 700px) {
|
|
||||||
.modal {
|
|
||||||
--bs-modal-margin: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-dialog {
|
|
||||||
max-width: min(var(--bs-modal-width), calc(100% - 1rem));
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-lg {
|
|
||||||
--bs-modal-width: 800px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.modal-xl {
|
|
||||||
--bs-modal-width: 1140px;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
.toast {
|
.toast {
|
||||||
--bs-toast-max-width: var(--pngx-toast-max-width);
|
--bs-toast-max-width: var(--pngx-toast-max-width);
|
||||||
}
|
}
|
||||||
@@ -854,16 +776,6 @@ canvas.hiddenCanvasElement {
|
|||||||
|
|
||||||
.document-card {
|
.document-card {
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;
|
|
||||||
|
|
||||||
&:hover:not(.card-selected) {
|
|
||||||
border-color: var(--pngx-card-hover-border);
|
|
||||||
box-shadow: 0 0.125rem 0.5rem rgba(0, 0, 0, 0.1);
|
|
||||||
}
|
|
||||||
|
|
||||||
.document-card-check {
|
|
||||||
border-color: var(--pngx-card-hover-border) !important;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card-footer i-bs svg {
|
.card-footer i-bs svg {
|
||||||
vertical-align: middle;
|
vertical-align: middle;
|
||||||
|
|||||||
+12
-20
@@ -23,7 +23,6 @@
|
|||||||
--pngx-bg-darker: var(--bs-gray-100);
|
--pngx-bg-darker: var(--bs-gray-100);
|
||||||
--pngx-bg-alt2: var(--bs-gray-200); // #e9ecef
|
--pngx-bg-alt2: var(--bs-gray-200); // #e9ecef
|
||||||
--pngx-bg-disabled: #f7f7f7;
|
--pngx-bg-disabled: #f7f7f7;
|
||||||
--pngx-card-hover-border: var(--bs-tertiary-color);
|
|
||||||
--pngx-focus-alpha: 0.3;
|
--pngx-focus-alpha: 0.3;
|
||||||
--pngx-toast-max-width: 340px;
|
--pngx-toast-max-width: 340px;
|
||||||
--bs-info: var(--pngx-bg-alt2);
|
--bs-info: var(--pngx-bg-alt2);
|
||||||
@@ -37,22 +36,20 @@
|
|||||||
$text-color-light-bg: #212529;
|
$text-color-light-bg: #212529;
|
||||||
$text-color-dark-bg: #abb2bf;
|
$text-color-dark-bg: #abb2bf;
|
||||||
$text-color-dark-bg-accent: color.adjust($text-color-dark-bg, $lightness: 10%);
|
$text-color-dark-bg-accent: color.adjust($text-color-dark-bg, $lightness: 10%);
|
||||||
// url-encoded $text-color-light-bg
|
// Taken from bootstrap
|
||||||
$text-color-light-bg-esc: "%23212529";
|
$form-check-input-checked-bg-image-dark: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'><path fill='none' stroke='#{$text-color-light-bg}' stroke-linecap='round' stroke-linejoin='round' stroke-width='3' d='M6 10l3 3l6-6'/></svg>");
|
||||||
// Taken from bootstrap, pre-encoded
|
$form-check-radio-checked-bg-image-dark: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'><circle r='2' fill='#{$text-color-light-bg}'/></svg>");
|
||||||
$form-check-input-checked-bg-image-dark: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20'%3e%3cpath fill='none' stroke='#{$text-color-light-bg-esc}' stroke-linecap='round' stroke-linejoin='round' stroke-width='3' d='M6 10l3 3l6-6'/%3e%3c/svg%3e");
|
|
||||||
$form-check-radio-checked-bg-image-dark: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='-4 -4 8 8'%3e%3ccircle r='2' fill='#{$text-color-light-bg-esc}'/%3e%3c/svg%3e");
|
|
||||||
|
|
||||||
.primary-light {
|
.primary-light {
|
||||||
--pngx-primary-text-contrast: #{$text-color-light-bg} !important;
|
--pngx-primary-text-contrast: #{$text-color-light-bg} !important;
|
||||||
|
|
||||||
.form-check:not(.form-switch) {
|
.form-check:not(.form-switch) {
|
||||||
.form-check-input:checked[type=checkbox] {
|
.form-check-input:checked[type=checkbox] {
|
||||||
--bs-form-check-bg-image: #{$form-check-input-checked-bg-image-dark};
|
background-image: escape-svg($form-check-input-checked-bg-image-dark);
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-check-input:checked[type=radio] {
|
.form-check-input:checked[type=radio] {
|
||||||
--bs-form-check-bg-image: #{$form-check-radio-checked-bg-image-dark};
|
background-image: escape-svg($form-check-radio-checked-bg-image-dark);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -69,17 +66,6 @@ $form-check-radio-checked-bg-image-dark: url("data:image/svg+xml,%3csvg xmlns='h
|
|||||||
color: var(--pngx-primary-text-contrast);
|
color: var(--pngx-primary-text-contrast);
|
||||||
}
|
}
|
||||||
|
|
||||||
.dropdown-menu > .list-group-flush:only-child {
|
|
||||||
> .list-group-item:first-child {
|
|
||||||
border-top-left-radius: var(--bs-dropdown-border-radius);
|
|
||||||
border-top-right-radius: var(--bs-dropdown-border-radius);
|
|
||||||
}
|
|
||||||
> .list-group-item:last-child {
|
|
||||||
border-bottom-left-radius: var(--bs-dropdown-border-radius);
|
|
||||||
border-bottom-right-radius: var(--bs-dropdown-border-radius);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Dark mode
|
// Dark mode
|
||||||
@mixin paperless-green-dark-mode {
|
@mixin paperless-green-dark-mode {
|
||||||
--pngx-primary-lightness: 31%;
|
--pngx-primary-lightness: 31%;
|
||||||
@@ -93,7 +79,6 @@ $form-check-radio-checked-bg-image-dark: url("data:image/svg+xml,%3csvg xmlns='h
|
|||||||
--pngx-bg-alt2: #232323;
|
--pngx-bg-alt2: #232323;
|
||||||
--pngx-bg-darker: #101216;
|
--pngx-bg-darker: #101216;
|
||||||
--pngx-bg-disabled: var(--pngx-bg-alt);
|
--pngx-bg-disabled: var(--pngx-bg-alt);
|
||||||
--pngx-card-hover-border: var(--bs-border-color);
|
|
||||||
--pngx-focus-alpha: 0.6;
|
--pngx-focus-alpha: 0.6;
|
||||||
--pngx-primary-faded: var(--pngx-primary-darken-15);
|
--pngx-primary-faded: var(--pngx-primary-darken-15);
|
||||||
--pngx-primary-text-contrast: var(--bs-body-color);
|
--pngx-primary-text-contrast: var(--bs-body-color);
|
||||||
@@ -253,6 +238,13 @@ $form-check-radio-checked-bg-image-dark: url("data:image/svg+xml,%3csvg xmlns='h
|
|||||||
}
|
}
|
||||||
|
|
||||||
table {
|
table {
|
||||||
|
.des,
|
||||||
|
.asc {
|
||||||
|
&::after {
|
||||||
|
filter: invert(0.8); /* arrow is a black inline png bkgd image (!) so use filter */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
&.table-hover > tbody > tr:hover > * {
|
&.table-hover > tbody > tr:hover > * {
|
||||||
background-color: var(--bs-light);
|
background-color: var(--bs-light);
|
||||||
color: var(--pngx-body-color-accent);
|
color: var(--pngx-body-color-accent);
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ from celery import group
|
|||||||
from celery import shared_task
|
from celery import shared_task
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.db import transaction
|
from django.db import transaction
|
||||||
from django.db.models import Max
|
|
||||||
from django.db.models import Q
|
from django.db.models import Q
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
|
|
||||||
@@ -31,7 +30,6 @@ from documents.permissions import set_permissions_for_object
|
|||||||
from documents.plugins.helpers import DocumentsStatusManager
|
from documents.plugins.helpers import DocumentsStatusManager
|
||||||
from documents.tasks import bulk_update_documents
|
from documents.tasks import bulk_update_documents
|
||||||
from documents.tasks import consume_file
|
from documents.tasks import consume_file
|
||||||
from documents.tasks import remove_document_from_index
|
|
||||||
from documents.tasks import update_document_content_maybe_archive_file
|
from documents.tasks import update_document_content_maybe_archive_file
|
||||||
from documents.versioning import get_latest_version_for_root
|
from documents.versioning import get_latest_version_for_root
|
||||||
from documents.versioning import get_root_document
|
from documents.versioning import get_root_document
|
||||||
@@ -41,9 +39,6 @@ if TYPE_CHECKING:
|
|||||||
|
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
|
|
||||||
if settings.AUDIT_LOG_ENABLED:
|
|
||||||
from auditlog.models import LogEntry
|
|
||||||
|
|
||||||
logger: logging.Logger = logging.getLogger("paperless.bulk_edit")
|
logger: logging.Logger = logging.getLogger("paperless.bulk_edit")
|
||||||
|
|
||||||
SourceMode = Literal["latest_version", "explicit_selection"]
|
SourceMode = Literal["latest_version", "explicit_selection"]
|
||||||
@@ -617,115 +612,6 @@ def merge(
|
|||||||
return "OK"
|
return "OK"
|
||||||
|
|
||||||
|
|
||||||
def merge_as_versions(
|
|
||||||
doc_ids: list[int],
|
|
||||||
*,
|
|
||||||
root_document_id: int,
|
|
||||||
version_label: str | None = None,
|
|
||||||
user: User | None = None,
|
|
||||||
) -> Literal["OK"]:
|
|
||||||
with transaction.atomic():
|
|
||||||
documents = list(
|
|
||||||
# Ordered by pk so concurrent merges take the row locks in the same order
|
|
||||||
Document.objects.select_for_update()
|
|
||||||
.filter(id__in=doc_ids)
|
|
||||||
.order_by("id")
|
|
||||||
.defer("content"),
|
|
||||||
)
|
|
||||||
documents_by_id = {document.id: document for document in documents}
|
|
||||||
|
|
||||||
source_ids = [doc_id for doc_id in doc_ids if doc_id != root_document_id]
|
|
||||||
root_document = documents_by_id[root_document_id]
|
|
||||||
next_version_index = (
|
|
||||||
Document.global_objects.filter(
|
|
||||||
root_document_id=root_document_id,
|
|
||||||
).aggregate(max_index=Max("version_index"))["max_index"]
|
|
||||||
or 0
|
|
||||||
)
|
|
||||||
|
|
||||||
# A version gives up its ASN
|
|
||||||
source_asns = [
|
|
||||||
documents_by_id[source_id].archive_serial_number
|
|
||||||
for source_id in source_ids
|
|
||||||
if documents_by_id[source_id].archive_serial_number is not None
|
|
||||||
]
|
|
||||||
|
|
||||||
updated_fields = ["root_document", "version_index", "archive_serial_number"]
|
|
||||||
if version_label is not None:
|
|
||||||
updated_fields.append("version_label")
|
|
||||||
|
|
||||||
for source_id in source_ids:
|
|
||||||
next_version_index += 1
|
|
||||||
source_document = documents_by_id[source_id]
|
|
||||||
source_document.root_document_id = root_document.pk
|
|
||||||
source_document.version_index = next_version_index
|
|
||||||
source_document.archive_serial_number = None
|
|
||||||
if version_label is not None:
|
|
||||||
source_document.version_label = version_label
|
|
||||||
|
|
||||||
# bulk_update and not save() to avoid post_save now
|
|
||||||
Document.objects.bulk_update(
|
|
||||||
[documents_by_id[source_id] for source_id in source_ids],
|
|
||||||
updated_fields,
|
|
||||||
)
|
|
||||||
|
|
||||||
root_updates = {"modified": timezone.now()}
|
|
||||||
if source_asns and root_document.archive_serial_number is None:
|
|
||||||
# If a version had one, hand the ASN over, the same as merge() does
|
|
||||||
root_updates["archive_serial_number"] = source_asns.pop(0)
|
|
||||||
logger.info(
|
|
||||||
f"Document {root_document.id} took archive serial number "
|
|
||||||
f"{root_updates['archive_serial_number']} from a document merged into it",
|
|
||||||
)
|
|
||||||
if source_asns:
|
|
||||||
logger.warning(
|
|
||||||
f"Archive serial number(s) {source_asns} were removed by merging "
|
|
||||||
f"those documents as versions of document {root_document.id}",
|
|
||||||
)
|
|
||||||
|
|
||||||
Document.objects.filter(pk=root_document.pk).update(**root_updates)
|
|
||||||
|
|
||||||
if settings.AUDIT_LOG_ENABLED:
|
|
||||||
# update() doesn't fire auditlog signals, so manual
|
|
||||||
LogEntry.objects.log_create(
|
|
||||||
instance=root_document,
|
|
||||||
changes={"Merged As Versions": ["None", source_ids]},
|
|
||||||
action=LogEntry.Action.UPDATE,
|
|
||||||
actor=user,
|
|
||||||
additional_data={
|
|
||||||
"reason": "Merged as versions",
|
|
||||||
"version_ids": source_ids,
|
|
||||||
},
|
|
||||||
)
|
|
||||||
|
|
||||||
# One batch rather than a task each
|
|
||||||
from documents.search import SearchIndexLockError
|
|
||||||
from documents.search import get_backend
|
|
||||||
|
|
||||||
try:
|
|
||||||
with get_backend().batch_update() as batch:
|
|
||||||
for source_id in source_ids:
|
|
||||||
batch.remove(source_id)
|
|
||||||
except SearchIndexLockError:
|
|
||||||
logger.error(
|
|
||||||
f"Search index lock exhausted removing {source_ids}, "
|
|
||||||
f"scheduling deferred index removal",
|
|
||||||
)
|
|
||||||
for source_id in source_ids:
|
|
||||||
remove_document_from_index.apply_async(args=[source_id], countdown=60)
|
|
||||||
|
|
||||||
bulk_update_documents.apply_async(
|
|
||||||
kwargs={"document_ids": [root_document_id]},
|
|
||||||
headers={"trigger_source": PaperlessTask.TriggerSource.SYSTEM},
|
|
||||||
)
|
|
||||||
|
|
||||||
# And as far as the frontend is concerned, they're deleted
|
|
||||||
status_mgr = DocumentsStatusManager()
|
|
||||||
status_mgr.send_documents_deleted(source_ids)
|
|
||||||
|
|
||||||
return "OK"
|
|
||||||
|
|
||||||
|
|
||||||
def split(
|
def split(
|
||||||
doc_ids: list[int],
|
doc_ids: list[int],
|
||||||
pages: list[list[int]],
|
pages: list[list[int]],
|
||||||
|
|||||||
@@ -41,16 +41,7 @@ class SuggestionCacheData:
|
|||||||
CLASSIFIER_VERSION_KEY: Final[str] = "classifier_version"
|
CLASSIFIER_VERSION_KEY: Final[str] = "classifier_version"
|
||||||
CLASSIFIER_HASH_KEY: Final[str] = "classifier_hash"
|
CLASSIFIER_HASH_KEY: Final[str] = "classifier_hash"
|
||||||
CLASSIFIER_MODIFIED_KEY: Final[str] = "classifier_modified"
|
CLASSIFIER_MODIFIED_KEY: Final[str] = "classifier_modified"
|
||||||
# Marker distinguishing LLM suggestions from classifier-generated ones (whose
|
LLM_CACHE_CLASSIFIER_VERSION: Final[int] = 1000 # Marker distinguishing LLM suggestions
|
||||||
# FORMAT_VERSION lives in a much lower range - see DocumentClassifier). Bump
|
|
||||||
# this whenever the *shape* of the cached `suggestions` dict changes, so a
|
|
||||||
# cache entry written by a previous release can never be read back by code
|
|
||||||
# that expects a different shape:
|
|
||||||
# 1000 - initial LLM suggestions cache (flat lists of resolved object ids
|
|
||||||
# per taxonomy field)
|
|
||||||
# 1001 - suggestions reshaped to {"existing_ids": [...], "new_names":
|
|
||||||
# [...]} per taxonomy field (#13676)
|
|
||||||
LLM_CACHE_CLASSIFIER_VERSION: Final[int] = 1001
|
|
||||||
|
|
||||||
CACHE_1_MINUTE: Final[int] = 60
|
CACHE_1_MINUTE: Final[int] = 60
|
||||||
CACHE_5_MINUTES: Final[int] = 5 * CACHE_1_MINUTE
|
CACHE_5_MINUTES: Final[int] = 5 * CACHE_1_MINUTE
|
||||||
@@ -213,11 +204,7 @@ def get_llm_suggestion_cache(
|
|||||||
doc_key = get_suggestion_cache_key(document_id)
|
doc_key = get_suggestion_cache_key(document_id)
|
||||||
data: SuggestionCacheData = cache.get(doc_key)
|
data: SuggestionCacheData = cache.get(doc_key)
|
||||||
|
|
||||||
if (
|
if data and data.classifier_hash == backend:
|
||||||
data
|
|
||||||
and data.classifier_version == LLM_CACHE_CLASSIFIER_VERSION
|
|
||||||
and data.classifier_hash == backend
|
|
||||||
):
|
|
||||||
return data
|
return data
|
||||||
|
|
||||||
return None
|
return None
|
||||||
|
|||||||
@@ -1,106 +0,0 @@
|
|||||||
from __future__ import annotations
|
|
||||||
|
|
||||||
import importlib
|
|
||||||
import zipfile
|
|
||||||
|
|
||||||
# ZIP_ZSTANDARD exists only on Python 3.14+ (PEP 784). None elsewhere.
|
|
||||||
ZSTD: int | None = getattr(zipfile, "ZIP_ZSTANDARD", None)
|
|
||||||
|
|
||||||
# CLI choices are fixed across runtimes so argparse never hides zstd; runtime
|
|
||||||
# availability is enforced separately in compression_available().
|
|
||||||
COMPRESSION_CHOICES: tuple[str, ...] = (
|
|
||||||
"stored",
|
|
||||||
"deflated",
|
|
||||||
"bzip2",
|
|
||||||
"lzma",
|
|
||||||
"zstd",
|
|
||||||
)
|
|
||||||
|
|
||||||
# Method name -> zipfile compression constant (zstd only when supported).
|
|
||||||
COMPRESSION_METHODS: dict[str, int] = {
|
|
||||||
"stored": zipfile.ZIP_STORED,
|
|
||||||
"deflated": zipfile.ZIP_DEFLATED,
|
|
||||||
"bzip2": zipfile.ZIP_BZIP2,
|
|
||||||
"lzma": zipfile.ZIP_LZMA,
|
|
||||||
}
|
|
||||||
if ZSTD is not None:
|
|
||||||
COMPRESSION_METHODS["zstd"] = ZSTD
|
|
||||||
|
|
||||||
# Inclusive (min, max) level bounds per method; None => level not applicable.
|
|
||||||
# Verified on CPython 3.14.3.
|
|
||||||
#
|
|
||||||
# zstd's raw library bounds are (-131072, 22)
|
|
||||||
# (compression.zstd.CompressionParameter.compression_level.bounds()) — the
|
|
||||||
# minimum is an internal implementation constant (-ZSTD_TARGETLENGTH_MAX),
|
|
||||||
# not a meaningful distinct "level"; deeper negative values than -22 buy
|
|
||||||
# nothing over -22 in practice. We expose the conventional zstd CLI range
|
|
||||||
# instead of the raw library bounds.
|
|
||||||
LEVEL_BOUNDS: dict[str, tuple[int, int] | None] = {
|
|
||||||
"stored": None,
|
|
||||||
"deflated": (0, 9),
|
|
||||||
"bzip2": (1, 9),
|
|
||||||
"lzma": None,
|
|
||||||
"zstd": (-22, 22),
|
|
||||||
}
|
|
||||||
|
|
||||||
# zipfile compress_type id -> method name.
|
|
||||||
_COMPRESS_TYPE_TO_METHOD: dict[int, str] = {
|
|
||||||
zipfile.ZIP_STORED: "stored",
|
|
||||||
zipfile.ZIP_DEFLATED: "deflated",
|
|
||||||
zipfile.ZIP_BZIP2: "bzip2",
|
|
||||||
zipfile.ZIP_LZMA: "lzma",
|
|
||||||
93: "zstd",
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
def compression_available(method: str) -> bool:
|
|
||||||
"""Whether the running interpreter can actually use the given method."""
|
|
||||||
if method in ("stored", "deflated"):
|
|
||||||
# zlib is a hard CPython dependency; stored needs nothing.
|
|
||||||
return True
|
|
||||||
if method == "bzip2":
|
|
||||||
return _module_importable("bz2")
|
|
||||||
if method == "lzma":
|
|
||||||
return _module_importable("lzma")
|
|
||||||
if method == "zstd":
|
|
||||||
return ZSTD is not None and _module_importable("compression.zstd")
|
|
||||||
return False # pragma: no cover -- method is always one of COMPRESSION_CHOICES
|
|
||||||
|
|
||||||
|
|
||||||
def _module_importable(name: str) -> bool:
|
|
||||||
try:
|
|
||||||
importlib.import_module(name)
|
|
||||||
except ImportError:
|
|
||||||
return False
|
|
||||||
return True
|
|
||||||
|
|
||||||
|
|
||||||
def level_error(method: str, level: int | None) -> str | None:
|
|
||||||
"""Return a human message if (method, level) is invalid, else None."""
|
|
||||||
if level is None:
|
|
||||||
return None
|
|
||||||
bounds = LEVEL_BOUNDS[method]
|
|
||||||
if bounds is None:
|
|
||||||
return f"--zip-compression-level has no effect for '{method}'"
|
|
||||||
low, high = bounds
|
|
||||||
if not (low <= level <= high):
|
|
||||||
return (
|
|
||||||
f"--zip-compression-level for '{method}' must be between {low} and {high}"
|
|
||||||
)
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def compress_type_readable(compress_type: int) -> bool:
|
|
||||||
"""Whether this interpreter can decompress an entry of the given type."""
|
|
||||||
method = _COMPRESS_TYPE_TO_METHOD.get(compress_type)
|
|
||||||
if method is None:
|
|
||||||
return False
|
|
||||||
return compression_available(method)
|
|
||||||
|
|
||||||
|
|
||||||
def unreadable_method_names(compress_types: set[int]) -> set[str]:
|
|
||||||
"""Map a set of compress_type ids to human method names for error messages."""
|
|
||||||
names: set[str] = set()
|
|
||||||
for ct in compress_types:
|
|
||||||
names.add(_COMPRESS_TYPE_TO_METHOD.get(ct, f"method {ct}"))
|
|
||||||
return names
|
|
||||||
@@ -243,21 +243,11 @@ class ZipExportSink(ExportSink):
|
|||||||
added as an entry at finalize (a zip entry cannot be interleaved with others).
|
added as an entry at finalize (a zip entry cannot be interleaved with others).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(
|
def __init__(self, target: Path, zip_name: str, *, delete: bool = False) -> None:
|
||||||
self,
|
|
||||||
target: Path,
|
|
||||||
zip_name: str,
|
|
||||||
*,
|
|
||||||
delete: bool = False,
|
|
||||||
compression: int = zipfile.ZIP_DEFLATED,
|
|
||||||
compresslevel: int | None = None,
|
|
||||||
) -> None:
|
|
||||||
self._target = target.resolve()
|
self._target = target.resolve()
|
||||||
self._zip_path = (self._target / zip_name).with_suffix(".zip")
|
self._zip_path = (self._target / zip_name).with_suffix(".zip")
|
||||||
self._tmp_path = self._zip_path.with_name(self._zip_path.name + ".tmp")
|
self._tmp_path = self._zip_path.with_name(self._zip_path.name + ".tmp")
|
||||||
self._delete = delete
|
self._delete = delete
|
||||||
self._compression = compression
|
|
||||||
self._compresslevel = compresslevel
|
|
||||||
self._zip: zipfile.ZipFile | None = None
|
self._zip: zipfile.ZipFile | None = None
|
||||||
self._dirs: set[str] = set()
|
self._dirs: set[str] = set()
|
||||||
self._pending_manifest: tuple[Path, str] | None = None
|
self._pending_manifest: tuple[Path, str] | None = None
|
||||||
@@ -268,8 +258,7 @@ class ZipExportSink(ExportSink):
|
|||||||
self._zip = zipfile.ZipFile(
|
self._zip = zipfile.ZipFile(
|
||||||
self._tmp_path,
|
self._tmp_path,
|
||||||
"w",
|
"w",
|
||||||
compression=self._compression,
|
compression=zipfile.ZIP_DEFLATED,
|
||||||
compresslevel=self._compresslevel,
|
|
||||||
allowZip64=True,
|
allowZip64=True,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -62,17 +62,18 @@ def generate_unique_filename(doc, *, archive_filename=False) -> Path:
|
|||||||
old_filename = Path(doc.filename) if doc.filename else None
|
old_filename = Path(doc.filename) if doc.filename else None
|
||||||
root = settings.ORIGINALS_DIR
|
root = settings.ORIGINALS_DIR
|
||||||
|
|
||||||
base_filename = generate_filename(doc, archive_filename=archive_filename)
|
|
||||||
|
|
||||||
# If generating archive filenames, try to make a name that is similar to
|
# If generating archive filenames, try to make a name that is similar to
|
||||||
# the original filename first.
|
# the original filename first.
|
||||||
|
|
||||||
if archive_filename and doc.filename:
|
if archive_filename and doc.filename:
|
||||||
|
# Generate the full path using the same logic as generate_filename
|
||||||
|
base_generated = generate_filename(doc, archive_filename=archive_filename)
|
||||||
|
|
||||||
# Try to create a simple PDF version based on the original filename
|
# Try to create a simple PDF version based on the original filename
|
||||||
# but preserve any directory structure from the template
|
# but preserve any directory structure from the template
|
||||||
if str(base_filename.parent) != ".":
|
if str(base_generated.parent) != ".":
|
||||||
# Has directory structure, preserve it
|
# Has directory structure, preserve it
|
||||||
simple_pdf_name = base_filename.parent / (Path(doc.filename).stem + ".pdf")
|
simple_pdf_name = base_generated.parent / (Path(doc.filename).stem + ".pdf")
|
||||||
else:
|
else:
|
||||||
# No directory structure
|
# No directory structure
|
||||||
simple_pdf_name = Path(Path(doc.filename).stem + ".pdf")
|
simple_pdf_name = Path(Path(doc.filename).stem + ".pdf")
|
||||||
@@ -80,17 +81,14 @@ def generate_unique_filename(doc, *, archive_filename=False) -> Path:
|
|||||||
if simple_pdf_name == old_filename or not (root / simple_pdf_name).exists():
|
if simple_pdf_name == old_filename or not (root / simple_pdf_name).exists():
|
||||||
return simple_pdf_name
|
return simple_pdf_name
|
||||||
|
|
||||||
file_extension = ".pdf" if archive_filename else doc.file_type
|
|
||||||
filename_stem = base_filename.name.removesuffix(file_extension)
|
|
||||||
counter = 0
|
counter = 0
|
||||||
|
|
||||||
while True:
|
while True:
|
||||||
new_filename = base_filename
|
new_filename = generate_filename(
|
||||||
if counter:
|
doc,
|
||||||
new_filename = base_filename.with_name(
|
counter=counter,
|
||||||
f"{filename_stem}_{counter:02}{file_extension}",
|
archive_filename=archive_filename,
|
||||||
)
|
)
|
||||||
|
|
||||||
if new_filename == old_filename:
|
if new_filename == old_filename:
|
||||||
# still the same as before.
|
# still the same as before.
|
||||||
return new_filename
|
return new_filename
|
||||||
|
|||||||
@@ -1047,12 +1047,6 @@ class PermittedObjectsFilter(BaseFilterBackend):
|
|||||||
perm_codename: str | None = None
|
perm_codename: str | None = None
|
||||||
|
|
||||||
def filter_queryset(self, request, queryset, view):
|
def filter_queryset(self, request, queryset, view):
|
||||||
# Before the superuser and owner-only paths, neither of which consults
|
|
||||||
# permitted_object_ids. Scoped to authenticated users so anonymous
|
|
||||||
# access (AnonymousUser.is_active is False) keeps its existing
|
|
||||||
# unowned-only behaviour.
|
|
||||||
if request.user.is_authenticated and not request.user.is_active:
|
|
||||||
return queryset.none()
|
|
||||||
if request.user.is_superuser:
|
if request.user.is_superuser:
|
||||||
return queryset
|
return queryset
|
||||||
if not self.include_granted:
|
if not self.include_granted:
|
||||||
|
|||||||
@@ -29,11 +29,6 @@ if TYPE_CHECKING:
|
|||||||
if settings.AUDIT_LOG_ENABLED:
|
if settings.AUDIT_LOG_ENABLED:
|
||||||
from auditlog.models import LogEntry
|
from auditlog.models import LogEntry
|
||||||
|
|
||||||
from documents.export.compression import COMPRESSION_CHOICES
|
|
||||||
from documents.export.compression import COMPRESSION_METHODS
|
|
||||||
from documents.export.compression import ZSTD
|
|
||||||
from documents.export.compression import compression_available
|
|
||||||
from documents.export.compression import level_error
|
|
||||||
from documents.export.sinks import DirectoryExportSink
|
from documents.export.sinks import DirectoryExportSink
|
||||||
from documents.export.sinks import ExportSink
|
from documents.export.sinks import ExportSink
|
||||||
from documents.export.sinks import StreamingManifestWriter
|
from documents.export.sinks import StreamingManifestWriter
|
||||||
@@ -197,28 +192,6 @@ class Command(CryptMixin, PaperlessCommand):
|
|||||||
help="Sets the export zip file name",
|
help="Sets the export zip file name",
|
||||||
)
|
)
|
||||||
|
|
||||||
parser.add_argument(
|
|
||||||
"--zip-compression",
|
|
||||||
choices=COMPRESSION_CHOICES,
|
|
||||||
default=None,
|
|
||||||
help=(
|
|
||||||
"Compression method for the export zip (requires --zip). "
|
|
||||||
"Default: deflated. 'zstd' requires Python 3.14+ on both the "
|
|
||||||
"exporting and importing machine."
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
parser.add_argument(
|
|
||||||
"--zip-compression-level",
|
|
||||||
type=int,
|
|
||||||
default=None,
|
|
||||||
help=(
|
|
||||||
"Compression level for the export zip (requires --zip). "
|
|
||||||
"deflated: 0-9, bzip2: 1-9, zstd: -22..22; ignored for "
|
|
||||||
"stored/lzma."
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--data-only",
|
"--data-only",
|
||||||
default=False,
|
default=False,
|
||||||
@@ -274,39 +247,12 @@ class Command(CryptMixin, PaperlessCommand):
|
|||||||
if not os.access(self.target, os.W_OK):
|
if not os.access(self.target, os.W_OK):
|
||||||
raise CommandError("That path doesn't appear to be writable")
|
raise CommandError("That path doesn't appear to be writable")
|
||||||
|
|
||||||
zip_compression: str | None = options["zip_compression"]
|
|
||||||
zip_compression_level: int | None = options["zip_compression_level"]
|
|
||||||
|
|
||||||
if not self.zip_export and (
|
|
||||||
zip_compression is not None or zip_compression_level is not None
|
|
||||||
):
|
|
||||||
raise CommandError(
|
|
||||||
"--zip-compression and --zip-compression-level require --zip",
|
|
||||||
)
|
|
||||||
|
|
||||||
compression_method = zip_compression or "deflated"
|
|
||||||
if self.zip_export:
|
|
||||||
if not compression_available(compression_method):
|
|
||||||
if compression_method == "zstd" and ZSTD is None:
|
|
||||||
raise CommandError(
|
|
||||||
"zstd compression requires Python 3.14 or newer",
|
|
||||||
)
|
|
||||||
raise CommandError(
|
|
||||||
f"Compression method '{compression_method}' is not "
|
|
||||||
f"available on this Python runtime",
|
|
||||||
)
|
|
||||||
level_msg = level_error(compression_method, zip_compression_level)
|
|
||||||
if level_msg is not None:
|
|
||||||
raise CommandError(level_msg)
|
|
||||||
|
|
||||||
sink: ExportSink
|
sink: ExportSink
|
||||||
if self.zip_export:
|
if self.zip_export:
|
||||||
sink = ZipExportSink(
|
sink = ZipExportSink(
|
||||||
self.target,
|
self.target,
|
||||||
options["zip_name"],
|
options["zip_name"],
|
||||||
delete=self.delete,
|
delete=self.delete,
|
||||||
compression=COMPRESSION_METHODS[compression_method],
|
|
||||||
compresslevel=zip_compression_level,
|
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
sink = DirectoryExportSink(
|
sink = DirectoryExportSink(
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user