mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-08-26 12:43:19 +00:00
Compare commits
58
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cbac71c165 | ||
|
|
8780bcd5c7 | ||
|
|
01c40aa0bd | ||
|
|
5f70b6eee7 | ||
|
|
e3e4b26944 | ||
|
|
a0908f6b4a | ||
|
|
bab9129ff8 | ||
|
|
1e13f86174 | ||
|
|
22c57fb392 | ||
|
|
08a7f6ccc0 | ||
|
|
3f4d327f64 | ||
|
|
bd326540fa | ||
|
|
33f9adb05a | ||
|
|
d24606a03b | ||
|
|
294328f174 | ||
|
|
0458bad5f2 | ||
|
|
a510d03c77 | ||
|
|
d7b3612a41 | ||
|
|
7e4a644714 | ||
|
|
bbcd6af2fe | ||
|
|
0431939f18 | ||
|
|
bed95ea301 | ||
|
|
42034c3c77 | ||
|
|
705220fb5a | ||
|
|
a424dace43 | ||
|
|
751299895e | ||
|
|
5f9bc5de88 | ||
|
|
f1c8a72f26 | ||
|
|
fd3c525f03 | ||
|
|
e389298aab | ||
|
|
c5c5cc0b1d | ||
|
|
b17a512539 | ||
|
|
4cf027de40 | ||
|
|
41953c7846 | ||
|
|
e5abe5cf32 | ||
|
|
643d0205bc | ||
|
|
2865a095b0 | ||
|
|
f0eb77c405 | ||
|
|
e5cc7c6c40 | ||
|
|
f2806179a2 | ||
|
|
36c736a9f9 | ||
|
|
be35079d54 | ||
|
|
a75465b734 | ||
|
|
0af1e41753 | ||
|
|
d88b281eb2 | ||
|
|
8446036777 | ||
|
|
8ad5e8cca3 | ||
|
|
1606a46b53 | ||
|
|
f647f304da | ||
|
|
31746371f4 | ||
|
|
0e5fbc973a | ||
|
|
3322c92837 | ||
|
|
db15c82804 | ||
|
|
fe5d09a123 | ||
|
|
a0feb827c9 | ||
|
|
b599b13f72 | ||
|
|
2d084983c8 | ||
|
|
e2c284f64e |
@@ -68,7 +68,7 @@ services:
|
|||||||
- "--chromium-disable-javascript=true"
|
- "--chromium-disable-javascript=true"
|
||||||
- "--chromium-allow-list=file:///tmp/.*"
|
- "--chromium-allow-list=file:///tmp/.*"
|
||||||
tika:
|
tika:
|
||||||
image: docker.io/apache/tika:latest
|
image: docker.io/apache/tika:3.3.1.0
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
volumes:
|
volumes:
|
||||||
data:
|
data:
|
||||||
|
|||||||
@@ -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.11.x"
|
DEFAULT_UV_VERSION: "0.12.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.11.x"
|
DEFAULT_UV_VERSION: "0.12.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:
|
||||||
version: 10
|
package_json_file: src-ui/package.json
|
||||||
- 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:
|
||||||
version: 10
|
package_json_file: src-ui/package.json
|
||||||
- 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:
|
||||||
version: 10
|
package_json_file: src-ui/package.json
|
||||||
- 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:
|
||||||
version: 10
|
package_json_file: src-ui/package.json
|
||||||
- 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:
|
||||||
version: 10
|
package_json_file: src-ui/package.json
|
||||||
- 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.11.x"
|
DEFAULT_UV_VERSION: "0.12.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:
|
||||||
version: 10
|
package_json_file: src-ui/package.json
|
||||||
- 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.11.x"
|
DEFAULT_UV_VERSION: "0.12.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:
|
||||||
version: 10
|
package_json_file: src-ui/package.json
|
||||||
- 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:
|
||||||
|
|||||||
+1
-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.11.32-python3.12-trixie-slim AS s6-overlay-base
|
FROM ghcr.io/astral-sh/uv:0.12.5-python3.14-trixie-slim AS s6-overlay-base
|
||||||
|
|
||||||
WORKDIR /usr/src/s6
|
WORKDIR /usr/src/s6
|
||||||
|
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ services:
|
|||||||
- "--chromium-disable-javascript=true"
|
- "--chromium-disable-javascript=true"
|
||||||
- "--chromium-allow-list=file:///tmp/.*"
|
- "--chromium-allow-list=file:///tmp/.*"
|
||||||
tika:
|
tika:
|
||||||
image: docker.io/apache/tika:latest
|
image: docker.io/apache/tika:3.3.1.0
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
volumes:
|
volumes:
|
||||||
data:
|
data:
|
||||||
|
|||||||
@@ -76,7 +76,7 @@ services:
|
|||||||
- "--chromium-disable-javascript=true"
|
- "--chromium-disable-javascript=true"
|
||||||
- "--chromium-allow-list=file:///tmp/.*"
|
- "--chromium-allow-list=file:///tmp/.*"
|
||||||
tika:
|
tika:
|
||||||
image: docker.io/apache/tika:latest
|
image: docker.io/apache/tika:3.3.1.0
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
volumes:
|
volumes:
|
||||||
data:
|
data:
|
||||||
|
|||||||
@@ -65,7 +65,7 @@ services:
|
|||||||
- "--chromium-disable-javascript=true"
|
- "--chromium-disable-javascript=true"
|
||||||
- "--chromium-allow-list=file:///tmp/.*"
|
- "--chromium-allow-list=file:///tmp/.*"
|
||||||
tika:
|
tika:
|
||||||
image: docker.io/apache/tika:latest
|
image: docker.io/apache/tika:3.3.1.0
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
volumes:
|
volumes:
|
||||||
data:
|
data:
|
||||||
|
|||||||
@@ -68,7 +68,14 @@
|
|||||||
<!-- <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"/> -->
|
||||||
<!-- <policy domain="coder" rights="none" pattern="MVG" /> -->
|
<!-- Paperless does not process SVG or ImageMagick scripting formats. -->
|
||||||
|
<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="@*" /> -->
|
||||||
@@ -78,8 +85,6 @@
|
|||||||
<!-- <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" />
|
||||||
|
|||||||
@@ -129,6 +129,10 @@ At a minimum you need to enable AI and choose an LLM backend:
|
|||||||
and/or [`PAPERLESS_AI_LLM_ENDPOINT`](configuration.md#PAPERLESS_AI_LLM_ENDPOINT). Ollama
|
and/or [`PAPERLESS_AI_LLM_ENDPOINT`](configuration.md#PAPERLESS_AI_LLM_ENDPOINT). Ollama
|
||||||
requires `PAPERLESS_AI_LLM_ENDPOINT` pointing at your Ollama server.
|
requires `PAPERLESS_AI_LLM_ENDPOINT` pointing at your Ollama server.
|
||||||
|
|
||||||
|
See the community-maintained wiki page on
|
||||||
|
[choosing AI models](https://github.com/paperless-ngx/paperless-ngx/wiki/AI-Model-Recommendations)
|
||||||
|
for suggested generation and embedding models.
|
||||||
|
|
||||||
### AI-assisted suggestions
|
### AI-assisted suggestions
|
||||||
|
|
||||||
With AI enabled, Paperless-ngx can suggest a title, tags, correspondent, document type,
|
With AI enabled, Paperless-ngx can suggest a title, tags, correspondent, document type,
|
||||||
@@ -808,7 +812,8 @@ Third-party parser plugins extend Paperless-ngx to support additional file
|
|||||||
formats. A plugin is a Python package that advertises itself under the
|
formats. A plugin is a Python package that advertises itself under the
|
||||||
`paperless_ngx.parsers` entry point group. Refer to the
|
`paperless_ngx.parsers` entry point group. Refer to the
|
||||||
[developer documentation](development.md#making-custom-parsers) for how to
|
[developer documentation](development.md#making-custom-parsers) for how to
|
||||||
create one.
|
create one, or see the wiki for a community-maintained list of
|
||||||
|
[parser plugins](https://github.com/paperless-ngx/paperless-ngx/wiki/Related-Projects#parser-plugins).
|
||||||
|
|
||||||
!!! warning "Third-party plugins are not officially supported"
|
!!! warning "Third-party plugins are not officially supported"
|
||||||
|
|
||||||
|
|||||||
@@ -227,6 +227,7 @@ 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.
|
||||||
|
|
||||||
|
|||||||
+23
-1
@@ -776,6 +776,24 @@ 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.
|
||||||
@@ -1197,7 +1215,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 `*/10 * * * *` or every ten minutes.
|
Defaults to every ten minutes, with an installation-specific minute offset.
|
||||||
|
|
||||||
#### [`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}
|
||||||
|
|
||||||
@@ -2070,6 +2088,8 @@ suggestions. This setting is required to be set to true in order to use the AI f
|
|||||||
models supported by the current embedding backend. If not supplied, defaults to
|
models supported by the current embedding backend. If not supplied, defaults to
|
||||||
"text-embedding-3-small" for the OpenAI-compatible backend,
|
"text-embedding-3-small" for the OpenAI-compatible backend,
|
||||||
"sentence-transformers/all-MiniLM-L6-v2" for Huggingface, and "embeddinggemma" for Ollama.
|
"sentence-transformers/all-MiniLM-L6-v2" for Huggingface, and "embeddinggemma" for Ollama.
|
||||||
|
See [choosing AI models](https://github.com/paperless-ngx/paperless-ngx/wiki/AI-Model-Recommendations)
|
||||||
|
for language and resource considerations.
|
||||||
|
|
||||||
Defaults to None.
|
Defaults to None.
|
||||||
|
|
||||||
@@ -2126,6 +2146,8 @@ setting is required to be set to use the AI features.
|
|||||||
: The model to use for the AI backend, i.e. "gpt-3.5-turbo", "gpt-4" or any of the models supported
|
: The model to use for the AI backend, i.e. "gpt-3.5-turbo", "gpt-4" or any of the models supported
|
||||||
by the current backend. If not supplied, defaults to "gpt-3.5-turbo" for the OpenAI-compatible
|
by the current backend. If not supplied, defaults to "gpt-3.5-turbo" for the OpenAI-compatible
|
||||||
backend and "llama3.1" for Ollama.
|
backend and "llama3.1" for Ollama.
|
||||||
|
See [choosing AI models](https://github.com/paperless-ngx/paperless-ngx/wiki/AI-Model-Recommendations)
|
||||||
|
for local versus remote and model-size considerations.
|
||||||
|
|
||||||
Defaults to None.
|
Defaults to None.
|
||||||
|
|
||||||
|
|||||||
@@ -156,7 +156,7 @@ The new settings are independent:
|
|||||||
|
|
||||||
### Database configuration
|
### Database configuration
|
||||||
|
|
||||||
If you changed OCR settings via the admin UI (ApplicationConfiguration), the database values are **migrated automatically** during the upgrade. `mode` values (`skip` / `skip_noarchive`) are mapped to their new equivalents and `skip_archive_file` values are converted to the new `archive_file_generation` field. After upgrading, review the OCR settings in the admin UI to confirm the migrated values match your intent.
|
If you changed OCR settings via the admin UI (ApplicationConfiguration), the database values are **migrated automatically** during the upgrade. `mode` values (`skip` / `skip_noarchive`) are mapped to their new equivalents and explicit `skip_archive_file` values are converted to the new `archive_file_generation` field. Users who relied on the old defaults must set `archive_file_generation` to `always` to preserve the v2 behaviour of always creating an archive. After upgrading, review the OCR settings in the admin UI to confirm the migrated values match your intent.
|
||||||
|
|
||||||
### Action Required
|
### Action Required
|
||||||
|
|
||||||
@@ -165,8 +165,9 @@ Remove any `PAPERLESS_OCR_SKIP_ARCHIVE_FILE` variable from your environment. If
|
|||||||
```bash
|
```bash
|
||||||
# v2: skip OCR when text present, always archive
|
# v2: skip OCR when text present, always archive
|
||||||
PAPERLESS_OCR_MODE=skip
|
PAPERLESS_OCR_MODE=skip
|
||||||
# v3: equivalent (auto is the new default)
|
# v3: equivalent
|
||||||
# No change needed - auto is the default
|
PAPERLESS_OCR_MODE=auto
|
||||||
|
PAPERLESS_ARCHIVE_FILE_GENERATION=always
|
||||||
|
|
||||||
# v2: skip OCR when text present, skip archive too
|
# v2: skip OCR when text present, skip archive too
|
||||||
PAPERLESS_OCR_MODE=skip_noarchive
|
PAPERLESS_OCR_MODE=skip_noarchive
|
||||||
|
|||||||
+8
-15
@@ -416,22 +416,15 @@ 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. Most
|
12. Configure ImageMagick to allow processing of PDF documents and disable
|
||||||
distributions have this disabled by default, since PDF documents can
|
formats that Paperless-ngx does not use. Most distributions disable PDF
|
||||||
contain malware. If you don't do this, Paperless-ngx will fall back to
|
processing by default, since PDF documents can contain malware. If you
|
||||||
Ghostscript for certain steps such as thumbnail generation.
|
don't enable it, Paperless-ngx will fall back to Ghostscript for certain
|
||||||
|
steps such as thumbnail generation.
|
||||||
|
|
||||||
Edit `/etc/ImageMagick-6/policy.xml` and adjust
|
Configure the active ImageMagick policy file (commonly
|
||||||
|
`/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,428 +0,0 @@
|
|||||||
# Split views.py and serialisers.py Implementation Plan
|
|
||||||
|
|
||||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
||||||
|
|
||||||
**Goal:** Split `src/documents/views.py` (5,395 lines) and `src/documents/serialisers.py` (3,532 lines) into domain-based module packages, with zero behavior change.
|
|
||||||
|
|
||||||
**Architecture:** Both files become packages (`documents/views/`, `documents/serialisers/`), one module per domain area. Serialisers split first (views depend on serialisers, never the reverse), then views, then the three external call sites (`paperless/urls.py`, `paperless_mail/views.py`, `paperless_mail/serialisers.py`) are pointed at the new submodules. No `__init__.py` re-exports in either package — every internal and external consumer imports the exact submodule.
|
|
||||||
|
|
||||||
**Tech Stack:** Django REST Framework (viewsets/serializers), ruff (lint/format), pytest via the project's VM test runner.
|
|
||||||
|
|
||||||
**Spec:** `docs/superpowers/specs/2026-08-13-views-serialisers-split-design.md`
|
|
||||||
|
|
||||||
## Global Constraints
|
|
||||||
|
|
||||||
- No behavior change: class/function bodies, names, and public API responses are unchanged — pure move/reorganize. (spec: Non-goals)
|
|
||||||
- Domain module names are identical across both packages (`bulk_edit.py` exists in both, etc.). (spec: Import direction)
|
|
||||||
- Import direction is one-way: `documents/views/*` may import from `documents/serialisers/*`; `documents/serialisers/*` must never import from `documents/views/*`. (spec: Import direction)
|
|
||||||
- Neither package's `__init__.py` re-exports submodule contents — every consumer, internal or external, imports the specific submodule (e.g. `from documents.views.workflows import WorkflowViewSet`). (spec: Architecture)
|
|
||||||
- `src/documents/tests/test_views.py` and `src/documents/tests/test_api_documents.py` are not modified — they must pass unchanged, proving the move didn't alter behavior. (spec: Non-goals, Testing)
|
|
||||||
- This branch targets `dev` and is separate from `feature-ai-taxonomy-hints-v2`. (spec: Non-goals)
|
|
||||||
- Backend tests run on the Linux VM via the helper script, never locally: `bash /c/Users/tholmes/Documents/Coding/paperless/vmtest.sh "<pytest targets>"`. `ruff check` / `ruff format` run locally (global binary, not `uv run ruff`).
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Reference: symbol-to-module maps
|
|
||||||
|
|
||||||
These tables (from the spec) are the authoritative source for which class/function goes to which new file. Copy them exactly — do not improvise groupings.
|
|
||||||
|
|
||||||
### `documents/serialisers/` map
|
|
||||||
|
|
||||||
| Module | Symbols |
|
|
||||||
| ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
||||||
| `base.py` | `DynamicFieldsModelSerializer`, `DocumentUpdateFieldsModelSerializer`, `MatchingModelSerializer`, `SetPermissionsMixin`, `SerializerWithPerms`, `SetPermissionsSerializer`, `OwnedObjectSerializer`, `OwnedObjectListSerializer`, `ReadWriteSerializerMethodField`, `DocumentListSerializer`, `DocumentSelectionSerializer`, `SourceModeValidationMixin`, `BasicUserSerializer`, `NotesSerializer` |
|
|
||||||
| `metadata.py` | `CorrespondentSerializer`, `DocumentTypeSerializer`, `DeprecatedColors`, `ColorField`, `TagSerializer`, `CorrespondentField`, `TagsField`, `DocumentTypeField`, `StoragePathField`, `StoragePathSerializer`, `StoragePathTestSerializer`, `CustomFieldSerializer`, `CustomFieldInstanceSerializer`, `validate_documentlink_targets` |
|
|
||||||
| `documents.py` | `DocumentSerializer`, `SearchResultListSerializer`, `SearchResultSerializer`, `DuplicateDocumentSummarySerializer`, `_DocumentVersionInfo`, `DocumentVersionInfoSerializer`, `DocumentVersionSerializer`, `DocumentVersionLabelSerializer`, `_get_viewable_duplicates` |
|
|
||||||
| `upload.py` | `PostDocumentSerializer` |
|
|
||||||
| `saved_views.py` | `SavedViewFilterRuleSerializer`, `SavedViewSerializer` |
|
|
||||||
| `bulk_edit.py` | `RotateDocumentsSerializer`, `MergeDocumentsSerializer`, `EditPdfDocumentsSerializer`, `RemovePasswordDocumentsSerializer`, `DeleteDocumentsSerializer`, `ReprocessDocumentsSerializer`, `BulkEditSerializer`, `BulkDownloadSerializer`, `BulkEditObjectsSerializer` |
|
|
||||||
| `sharing.py` | `EmailSerializer`, `ShareLinkSerializer`, `ShareLinkBundleSerializer` |
|
|
||||||
| `tasks.py` | `TaskSerializerV10`, `TaskSerializerV9`, `TaskSummarySerializer`, `RunTaskSerializer`, `AcknowledgeTasksViewSerializer` |
|
|
||||||
| `workflows.py` | `WorkflowTriggerSerializer`, `WorkflowActionEmailSerializer`, `WorkflowActionWebhookSerializer`, `WorkflowActionSerializer`, `WorkflowSerializer` |
|
|
||||||
| `system.py` | `UiSettingsViewSerializer`, `TrashSerializer` |
|
|
||||||
|
|
||||||
Extraction order matters (later modules reference earlier ones): `base` → `metadata` → `documents` → `upload` → `saved_views` → `bulk_edit` → `sharing` → `tasks` → `workflows` → `system`.
|
|
||||||
|
|
||||||
### `documents/views/` map
|
|
||||||
|
|
||||||
| Module | Symbols |
|
|
||||||
| ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
||||||
| `base.py` | `PassUserMixin`, `BulkPermissionMixin`, `PermissionsAwareDocumentCountMixin`, `DocumentSelectionMixin`, `DocumentOperationPermissionMixin`, `SearchParams`, `SearchResultPage`, `ResolvedRequestDocs`, `_get_tantivy_query_and_mode`, `_get_more_like_id`, `serve_file` |
|
|
||||||
| `index.py` | `IndexView`, `serve_logo` |
|
|
||||||
| `metadata.py` | `CorrespondentViewSet`, `TagViewSet`, `DocumentTypeViewSet`, `StoragePathViewSet`, `CustomFieldViewSet`, `_get_llm_output_language` |
|
|
||||||
| `documents.py` | `EmailDocumentDetailSchema`, `DocumentViewSet`, `UnifiedSearchViewSet` |
|
|
||||||
| `upload.py` | `PostDocumentView` |
|
|
||||||
| `chat.py` | `ChatStreamingSerializer`, `ChatStreamingView` |
|
|
||||||
| `search.py` | `SearchAutoCompleteView`, `GlobalSearchView`, `SelectionDataView`, `StatisticsView` |
|
|
||||||
| `bulk_edit.py` | `BulkEditView`, `RotateDocumentsView`, `MergeDocumentsView`, `DeleteDocumentsView`, `ReprocessDocumentsView`, `EditPdfDocumentsView`, `RemovePasswordDocumentsView`, `BulkEditObjectsView`, `BulkDownloadView` |
|
|
||||||
| `sharing.py` | `ShareLinkViewSet`, `ShareLinkBundleViewSet`, `SharedLinkView` |
|
|
||||||
| `saved_views.py` | `SavedViewViewSet` |
|
|
||||||
| `tasks.py` | `_TasksViewSetSchema`, `TasksViewSet` |
|
|
||||||
| `workflows.py` | `WorkflowTriggerViewSet`, `WorkflowActionViewSet`, `WorkflowViewSet` |
|
|
||||||
| `system.py` | `UiSettingsView`, `RemoteVersionView`, `SystemStatusView`, `TrashView` |
|
|
||||||
| `logs.py` | `LogViewSet` |
|
|
||||||
|
|
||||||
Extraction order: `base` → `index` → `metadata` → `documents` → `upload` → `chat` → `search` → `bulk_edit` → `sharing` → `saved_views` → `tasks` → `workflows` → `system` → `logs`. Note `ChatStreamingSerializer` is defined in `views.py` today, directly above `ChatStreamingView` — it moves with it into `views/chat.py`, not into the serialisers package.
|
|
||||||
|
|
||||||
### Mechanical extraction recipe (applies to every task below)
|
|
||||||
|
|
||||||
For each module being created:
|
|
||||||
|
|
||||||
1. `grep -n "^class |^def " src/documents/<serialisers|views>.py` to get current line numbers for every symbol still in the monolith (numbers shift as earlier modules are extracted, so re-run this each time, don't reuse stale numbers).
|
|
||||||
2. Create the new file. Start it by copying the **entire top-of-file import block** from the monolith verbatim, plus a relative `from .base import ...` line if the module isn't `base.py` itself.
|
|
||||||
3. Cut each listed symbol (including any decorators/comments immediately above it) from the monolith and paste it into the new file, preserving original order.
|
|
||||||
4. Remove the cut symbols from the monolith.
|
|
||||||
5. Run `ruff check --select F401,F811,F821 <new file> <monolith file>` and fix everything reported:
|
|
||||||
- `F401` (unused import) → delete the import line.
|
|
||||||
- `F821` (undefined name) → the symbol lives in a sibling module already extracted; add `from .<sibling> import <Symbol>`. If it hasn't been extracted yet, that's an ordering bug — stop and re-check the extraction order table.
|
|
||||||
- `F811` (redefinition) → duplicate import, delete one.
|
|
||||||
6. Run `ruff format <new file> <monolith file>`.
|
|
||||||
|
|
||||||
## Task 1: Scaffold `documents/serialisers/` and extract `base.py`
|
|
||||||
|
|
||||||
**Agent:** django-expert — **Model:** sonnet (mechanical extraction, but sets the foundation every later serialiser module imports from — get the base set right or every later task inherits the mistake)
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
|
|
||||||
- Create: `src/documents/serialisers/__init__.py` (empty — no re-exports, per Global Constraints)
|
|
||||||
- Create: `src/documents/serialisers/base.py`
|
|
||||||
- Modify: `src/documents/serialisers.py` (shrinks; stays in place as the monolith for the remaining tasks in this phase — it is only deleted in Task 2 once empty)
|
|
||||||
- Test: `src/documents/tests/` (full app suite), `src/paperless_mail/tests/`
|
|
||||||
|
|
||||||
**Interfaces:**
|
|
||||||
|
|
||||||
- Produces: `documents.serialisers.base` exporting `DynamicFieldsModelSerializer`, `DocumentUpdateFieldsModelSerializer`, `MatchingModelSerializer`, `SetPermissionsMixin`, `SerializerWithPerms`, `SetPermissionsSerializer`, `OwnedObjectSerializer`, `OwnedObjectListSerializer`, `ReadWriteSerializerMethodField`, `DocumentListSerializer`, `DocumentSelectionSerializer`, `SourceModeValidationMixin`, `BasicUserSerializer`, `NotesSerializer` — every later serialiser/view module that needs one of these imports `from documents.serialisers.base import <Symbol>`.
|
|
||||||
|
|
||||||
- [ ] **Step 1: Create the package directory and empty `__init__.py`**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
mkdir -p src/documents/serialisers
|
|
||||||
touch src/documents/serialisers/__init__.py
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Extract `base.py` per the mechanical extraction recipe above**
|
|
||||||
|
|
||||||
Move exactly these 14 symbols (in their current relative order) out of `src/documents/serialisers.py` into `src/documents/serialisers/base.py`: `DynamicFieldsModelSerializer`, `DocumentUpdateFieldsModelSerializer`, `MatchingModelSerializer`, `SetPermissionsMixin`, `SerializerWithPerms`, `SetPermissionsSerializer`, `OwnedObjectSerializer`, `OwnedObjectListSerializer`, `ReadWriteSerializerMethodField`, `DocumentListSerializer`, `DocumentSelectionSerializer`, `SourceModeValidationMixin`, `BasicUserSerializer`, `NotesSerializer`.
|
|
||||||
|
|
||||||
Run the ruff fix-up (`ruff check --select F401,F811,F821 src/documents/serialisers/base.py src/documents/serialisers.py` then `ruff format` both files) as described in the recipe.
|
|
||||||
|
|
||||||
- [ ] **Step 3: Verify `documents.serialisers` (the monolith module, still at `src/documents/serialisers.py`) still imports cleanly and the app still boots**
|
|
||||||
|
|
||||||
Note: at this point Python resolves `documents.serialisers` to the package `src/documents/serialisers/__init__.py` (empty), **not** to `src/documents/serialisers.py` — having both a `serialisers.py` file and a `serialisers/` directory in the same parent package is invalid and Python will pick the package. So before running anything, rename the monolith out of the way so it's importable as a submodule of the new package for the rest of Phase A:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git mv src/documents/serialisers.py src/documents/serialisers/_monolith.py
|
|
||||||
```
|
|
||||||
|
|
||||||
Everywhere else in this phase, "the monolith file" now means `src/documents/serialisers/_monolith.py`. Because nothing outside this package imports the monolith directly by its old dotted path (`documents.serialisers` resolved to the file before; now it's the package), you must update every consumer of `documents.serialisers` symbols still owned by the monolith to import from `documents.serialisers._monolith` for the remainder of this phase. Concretely, in `src/documents/views.py`, change every `from documents.serialisers import <Symbol>` line for a symbol _not yet extracted_ (i.e., not one of the 14 `base.py` symbols) to `from documents.serialisers._monolith import <Symbol>`, and change the 14 now-extracted symbols' import lines to `from documents.serialisers.base import <Symbol>`. Do the same in `src/paperless_mail/serialisers.py` for `OwnedObjectSerializer` (→ `documents.serialisers.base`); its other three imports (`CorrespondentField`, `DocumentTypeField`, `TagsField`) stay pointed at `documents.serialisers._monolith` until Task 2 moves them into `metadata.py`.
|
|
||||||
|
|
||||||
This `_monolith` re-pointing is scaffolding only — Task 2 finishes emptying and deletes `_monolith.py`, and every import that currently says `._monolith` gets its final home then.
|
|
||||||
|
|
||||||
- [ ] **Step 4: Run the full test suite for this app boundary**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
bash /c/Users/tholmes/Documents/Coding/paperless/vmtest.sh "src/documents/tests src/paperless_mail/tests -v"
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: PASS, no collection errors (a collection error here almost always means a missed import update in `views.py` or `paperless_mail/serialisers.py`).
|
|
||||||
|
|
||||||
- [ ] **Step 5: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add src/documents/serialisers src/documents/views.py src/paperless_mail/serialisers.py
|
|
||||||
git commit -m "refactor: extract documents/serialisers/base.py from the serialisers monolith"
|
|
||||||
```
|
|
||||||
|
|
||||||
## Task 2: Extract the remaining 8 serialiser domain modules and delete the monolith
|
|
||||||
|
|
||||||
**Agent:** django-expert — **Model:** sonnet (repetitive but each of the 8 modules needs its own cross-reference check against `base.py` and previously-extracted siblings; DocumentSerializer in particular is large and central)
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
|
|
||||||
- Create: `src/documents/serialisers/metadata.py`, `src/documents/serialisers/documents.py`, `src/documents/serialisers/upload.py`, `src/documents/serialisers/saved_views.py`, `src/documents/serialisers/bulk_edit.py`, `src/documents/serialisers/sharing.py`, `src/documents/serialisers/tasks.py`, `src/documents/serialisers/workflows.py`, `src/documents/serialisers/system.py`
|
|
||||||
- Delete: `src/documents/serialisers/_monolith.py` (once empty)
|
|
||||||
- Modify: `src/documents/views.py` (finish re-pointing every `from documents.serialisers._monolith import X` line at the correct new submodule), `src/paperless_mail/serialisers.py` (re-point `CorrespondentField`, `DocumentTypeField`, `TagsField` at `documents.serialisers.metadata`)
|
|
||||||
- Test: `src/documents/tests/` (full app suite), `src/paperless_mail/tests/`
|
|
||||||
|
|
||||||
**Interfaces:**
|
|
||||||
|
|
||||||
- Consumes: `documents.serialisers.base` from Task 1 (relative import `.base` within the package).
|
|
||||||
- Produces: the full `documents/serialisers/` package as specified in the Reference map above — this is what Task 3/4 (views split) and Task 5 (external call sites) import from.
|
|
||||||
|
|
||||||
- [ ] **Step 1: Extract the 8 remaining domain modules in order**
|
|
||||||
|
|
||||||
Following the mechanical extraction recipe, and in this exact order (each may depend on symbols extracted earlier in this same order, plus anything in `base.py`):
|
|
||||||
|
|
||||||
1. `metadata.py` — `CorrespondentSerializer`, `DocumentTypeSerializer`, `DeprecatedColors`, `ColorField`, `TagSerializer`, `CorrespondentField`, `TagsField`, `DocumentTypeField`, `StoragePathField`, `StoragePathSerializer`, `StoragePathTestSerializer`, `CustomFieldSerializer`, `CustomFieldInstanceSerializer`, `validate_documentlink_targets`
|
|
||||||
2. `documents.py` — `DocumentSerializer`, `SearchResultListSerializer`, `SearchResultSerializer`, `DuplicateDocumentSummarySerializer`, `_DocumentVersionInfo`, `DocumentVersionInfoSerializer`, `DocumentVersionSerializer`, `DocumentVersionLabelSerializer`, `_get_viewable_duplicates`
|
|
||||||
3. `upload.py` — `PostDocumentSerializer`
|
|
||||||
4. `saved_views.py` — `SavedViewFilterRuleSerializer`, `SavedViewSerializer`
|
|
||||||
5. `bulk_edit.py` — `RotateDocumentsSerializer`, `MergeDocumentsSerializer`, `EditPdfDocumentsSerializer`, `RemovePasswordDocumentsSerializer`, `DeleteDocumentsSerializer`, `ReprocessDocumentsSerializer`, `BulkEditSerializer`, `BulkDownloadSerializer`, `BulkEditObjectsSerializer`
|
|
||||||
6. `sharing.py` — `EmailSerializer`, `ShareLinkSerializer`, `ShareLinkBundleSerializer`
|
|
||||||
7. `tasks.py` — `TaskSerializerV10`, `TaskSerializerV9`, `TaskSummarySerializer`, `RunTaskSerializer`, `AcknowledgeTasksViewSerializer`
|
|
||||||
8. `workflows.py` — `WorkflowTriggerSerializer`, `WorkflowActionEmailSerializer`, `WorkflowActionWebhookSerializer`, `WorkflowActionSerializer`, `WorkflowSerializer`
|
|
||||||
9. `system.py` — `UiSettingsViewSerializer`, `TrashSerializer`
|
|
||||||
|
|
||||||
After each individual module extraction, run the ruff fix-up from the recipe against that new file and `_monolith.py` before moving to the next module (don't batch all 8 and fix imports once at the end — F821 errors compound and get harder to attribute to the right module).
|
|
||||||
|
|
||||||
- [ ] **Step 2: Confirm the monolith is empty and delete it**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
grep -n "^class |^def " src/documents/serialisers/_monolith.py
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: no output. If anything remains, it wasn't in the Reference map — stop and reconcile with the spec rather than deleting a symbol.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git rm src/documents/serialisers/_monolith.py
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 3: Re-point every remaining `._monolith` import**
|
|
||||||
|
|
||||||
Search for any import left pointing at the now-deleted module:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
grep -rn "serialisers\._monolith\|serialisers/_monolith" src/
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: no output. Fix any that remain by pointing them at the correct submodule per the Reference map (e.g. `from documents.serialisers._monolith import DocumentSerializer` → `from documents.serialisers.documents import DocumentSerializer`).
|
|
||||||
|
|
||||||
- [ ] **Step 4: Update `paperless_mail/serialisers.py`'s remaining imports**
|
|
||||||
|
|
||||||
```python
|
|
||||||
# was: from documents.serialisers import CorrespondentField, DocumentTypeField, OwnedObjectSerializer, TagsField
|
|
||||||
from documents.serialisers.base import OwnedObjectSerializer
|
|
||||||
from documents.serialisers.metadata import CorrespondentField, DocumentTypeField, TagsField
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 5: Ruff and full test suite**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
ruff check src/documents/serialisers src/documents/views.py src/paperless_mail/serialisers.py
|
|
||||||
ruff format src/documents/serialisers src/documents/views.py src/paperless_mail/serialisers.py
|
|
||||||
```
|
|
||||||
|
|
||||||
```bash
|
|
||||||
bash /c/Users/tholmes/Documents/Coding/paperless/vmtest.sh "src/documents/tests src/paperless_mail/tests -v"
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: PASS.
|
|
||||||
|
|
||||||
- [ ] **Step 6: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add src/documents/serialisers src/documents/views.py src/paperless_mail/serialisers.py
|
|
||||||
git commit -m "refactor: finish splitting serialisers.py into documents/serialisers/"
|
|
||||||
```
|
|
||||||
|
|
||||||
## Task 3: Scaffold `documents/views/` and extract `base.py`
|
|
||||||
|
|
||||||
**Agent:** django-expert — **Model:** sonnet (same shape as Task 1, one level up — views/base.py is imported by every other view module)
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
|
|
||||||
- Create: `src/documents/views/__init__.py` (empty), `src/documents/views/base.py`
|
|
||||||
- Modify: `src/documents/views.py` → `src/documents/views/_monolith.py` (renamed, same reasoning as Task 1 Step 3)
|
|
||||||
- Modify: `src/paperless/urls.py`, `src/paperless_mail/views.py` (re-point the 1 symbol each currently pulls from `documents.views` that now lives in `base.py`, if any — see step 3)
|
|
||||||
- Test: `src/documents/tests/` (full app suite, includes URL-resolution-dependent tests), `src/paperless_mail/tests/`
|
|
||||||
|
|
||||||
**Interfaces:**
|
|
||||||
|
|
||||||
- Consumes: `documents.serialisers.*` submodules from Tasks 1–2 (already at final locations — import these directly, e.g. `from documents.serialisers.documents import DocumentSerializer`, never through a monolith or shim).
|
|
||||||
- Produces: `documents.views.base` exporting `PassUserMixin`, `BulkPermissionMixin`, `PermissionsAwareDocumentCountMixin`, `DocumentSelectionMixin`, `DocumentOperationPermissionMixin`, `SearchParams`, `SearchResultPage`, `ResolvedRequestDocs`, `_get_tantivy_query_and_mode`, `_get_more_like_id`, `serve_file`.
|
|
||||||
|
|
||||||
- [ ] **Step 1: Create the package directory, empty `__init__.py`, and rename the monolith**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
mkdir -p src/documents/views
|
|
||||||
touch src/documents/views/__init__.py
|
|
||||||
git mv src/documents/views.py src/documents/views/_monolith.py
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Extract `base.py` per the mechanical extraction recipe**
|
|
||||||
|
|
||||||
Move exactly these 11 symbols out of `_monolith.py` into `views/base.py`: `PassUserMixin`, `BulkPermissionMixin`, `PermissionsAwareDocumentCountMixin`, `DocumentSelectionMixin`, `DocumentOperationPermissionMixin`, `SearchParams`, `SearchResultPage`, `ResolvedRequestDocs`, `_get_tantivy_query_and_mode`, `_get_more_like_id`, `serve_file`.
|
|
||||||
|
|
||||||
Within `_monolith.py`, every reference to these 11 symbols needs `from .base import <Symbol>` added (they're used throughout the rest of the file by the not-yet-extracted viewsets).
|
|
||||||
|
|
||||||
- [ ] **Step 3: Re-point external consumers of the now-moved symbol**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
grep -n "from documents.views import PassUserMixin" src/paperless_mail/views.py
|
|
||||||
```
|
|
||||||
|
|
||||||
Update it to `from documents.views.base import PassUserMixin`.
|
|
||||||
|
|
||||||
`paperless/urls.py` doesn't import any of the 11 `base.py` symbols directly (it only imports viewsets/views, which are all still in `_monolith.py` at this point) — confirm with:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
grep -nE "from documents\.views import (PassUserMixin|BulkPermissionMixin|PermissionsAwareDocumentCountMixin|DocumentSelectionMixin|DocumentOperationPermissionMixin|serve_file)" src/paperless/urls.py
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: no output. If something does match, re-point it at `documents.views.base` the same way.
|
|
||||||
|
|
||||||
- [ ] **Step 4: Ruff and test**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
ruff check src/documents/views src/paperless_mail/views.py
|
|
||||||
ruff format src/documents/views src/paperless_mail/views.py
|
|
||||||
bash /c/Users/tholmes/Documents/Coding/paperless/vmtest.sh "src/documents/tests src/paperless_mail/tests -v"
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: PASS.
|
|
||||||
|
|
||||||
- [ ] **Step 5: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add src/documents/views src/paperless_mail/views.py
|
|
||||||
git commit -m "refactor: extract documents/views/base.py from the views monolith"
|
|
||||||
```
|
|
||||||
|
|
||||||
## Task 4: Extract the remaining 13 view domain modules and delete the monolith
|
|
||||||
|
|
||||||
**Agent:** django-expert — **Model:** opus (highest blast radius in the plan — `DocumentViewSet` alone is ~1,300 lines and central to the whole API; this task also rewires `paperless/urls.py`'s ~34 import lines that drive URL routing for the entire backend, where a mistake breaks the app at startup, not just in one test)
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
|
|
||||||
- Create: `src/documents/views/index.py`, `src/documents/views/metadata.py`, `src/documents/views/documents.py`, `src/documents/views/upload.py`, `src/documents/views/chat.py`, `src/documents/views/search.py`, `src/documents/views/bulk_edit.py`, `src/documents/views/sharing.py`, `src/documents/views/saved_views.py`, `src/documents/views/tasks.py`, `src/documents/views/workflows.py`, `src/documents/views/system.py`, `src/documents/views/logs.py`
|
|
||||||
- Delete: `src/documents/views/_monolith.py` (once empty)
|
|
||||||
- Modify: `src/paperless/urls.py` (all ~34 `from documents.views import X` lines)
|
|
||||||
- Test: `src/documents/tests/` (full app suite — includes `test_views.py`, `test_api_documents.py`), `src/paperless_mail/tests/`
|
|
||||||
|
|
||||||
**Interfaces:**
|
|
||||||
|
|
||||||
- Consumes: `documents.serialisers.*` (Tasks 1–2) and `documents.views.base` (Task 3).
|
|
||||||
- Produces: the full `documents/views/` package as specified in the Reference map above.
|
|
||||||
|
|
||||||
- [ ] **Step 1: Extract the 13 remaining domain modules in order**
|
|
||||||
|
|
||||||
Following the mechanical extraction recipe, in this exact order:
|
|
||||||
|
|
||||||
1. `index.py` — `IndexView`, `serve_logo`
|
|
||||||
2. `metadata.py` — `CorrespondentViewSet`, `TagViewSet`, `DocumentTypeViewSet`, `StoragePathViewSet`, `CustomFieldViewSet`, `_get_llm_output_language`
|
|
||||||
3. `documents.py` — `EmailDocumentDetailSchema`, `DocumentViewSet`, `UnifiedSearchViewSet`
|
|
||||||
4. `upload.py` — `PostDocumentView`
|
|
||||||
5. `chat.py` — `ChatStreamingSerializer`, `ChatStreamingView`
|
|
||||||
6. `search.py` — `SearchAutoCompleteView`, `GlobalSearchView`, `SelectionDataView`, `StatisticsView`
|
|
||||||
7. `bulk_edit.py` — `BulkEditView`, `RotateDocumentsView`, `MergeDocumentsView`, `DeleteDocumentsView`, `ReprocessDocumentsView`, `EditPdfDocumentsView`, `RemovePasswordDocumentsView`, `BulkEditObjectsView`, `BulkDownloadView`
|
|
||||||
8. `sharing.py` — `ShareLinkViewSet`, `ShareLinkBundleViewSet`, `SharedLinkView`
|
|
||||||
9. `saved_views.py` — `SavedViewViewSet`
|
|
||||||
10. `tasks.py` — `_TasksViewSetSchema`, `TasksViewSet`
|
|
||||||
11. `workflows.py` — `WorkflowTriggerViewSet`, `WorkflowActionViewSet`, `WorkflowViewSet`
|
|
||||||
12. `system.py` — `UiSettingsView`, `RemoteVersionView`, `SystemStatusView`, `TrashView`
|
|
||||||
13. `logs.py` — `LogViewSet`
|
|
||||||
|
|
||||||
After each module, run the ruff fix-up from the recipe before continuing to the next (same rationale as Task 2 Step 1 — attribute F821s to the right module while context is fresh). `documents.py` is the biggest single extraction in this whole plan (`DocumentViewSet` is ~1,300 lines) — expect the most F821 fix-ups here, mostly resolved by adding `from documents.serialisers.documents import ...`, `from documents.serialisers.metadata import ...`, and `from .base import ...` as needed.
|
|
||||||
|
|
||||||
- [ ] **Step 2: Confirm the monolith is empty and delete it**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
grep -n "^class |^def " src/documents/views/_monolith.py
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: no output.
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git rm src/documents/views/_monolith.py
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 3: Re-point every remaining `._monolith` import**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
grep -rn "views\._monolith\|views/_monolith" src/
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: no output. Fix any stragglers per the Reference map.
|
|
||||||
|
|
||||||
- [ ] **Step 4: Update `paperless/urls.py`**
|
|
||||||
|
|
||||||
Replace each of the ~34 `from documents.views import X` lines with `from documents.views.<domain> import X` per the Reference map. For example:
|
|
||||||
|
|
||||||
```python
|
|
||||||
# was:
|
|
||||||
from documents.views import CorrespondentViewSet
|
|
||||||
from documents.views import WorkflowViewSet
|
|
||||||
from documents.views import serve_logo
|
|
||||||
# becomes:
|
|
||||||
from documents.views.metadata import CorrespondentViewSet
|
|
||||||
from documents.views.workflows import WorkflowViewSet
|
|
||||||
from documents.views.index import serve_logo
|
|
||||||
```
|
|
||||||
|
|
||||||
Do this for every import in that block — check off against the full symbol list in the Reference map above so none are missed.
|
|
||||||
|
|
||||||
- [ ] **Step 5: Ruff and test**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
ruff check src/documents/views src/paperless/urls.py
|
|
||||||
ruff format src/documents/views src/paperless/urls.py
|
|
||||||
bash /c/Users/tholmes/Documents/Coding/paperless/vmtest.sh "src/documents/tests src/paperless_mail/tests -v"
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: PASS, including `test_views.py` and `test_api_documents.py` — these exercise URL routing end-to-end, so a broken `urls.py` import shows up here as a collection error.
|
|
||||||
|
|
||||||
- [ ] **Step 6: Commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add src/documents/views src/paperless/urls.py
|
|
||||||
git commit -m "refactor: finish splitting views.py into documents/views/"
|
|
||||||
```
|
|
||||||
|
|
||||||
## Task 5: Repo-wide verification sweep
|
|
||||||
|
|
||||||
**Agent:** general-purpose — **Model:** sonnet (an audit/verification pass: run targeted checks, read the output, fix anything found — moderate judgment, not novel design work)
|
|
||||||
|
|
||||||
**Files:**
|
|
||||||
|
|
||||||
- Modify: any file a grep in this task turns up beyond the ones already handled in Tasks 1–4 (expected: none, per the spec's stated blast radius of exactly `paperless/urls.py`, `paperless_mail/views.py`, `paperless_mail/serialisers.py` — this task exists to confirm that, not to find new work)
|
|
||||||
- Test: full backend suite (all apps, not just `documents`/`paperless_mail`)
|
|
||||||
|
|
||||||
**Interfaces:**
|
|
||||||
|
|
||||||
- Consumes: the finished `documents/views/` and `documents/serialisers/` packages from Tasks 1–4.
|
|
||||||
|
|
||||||
- [ ] **Step 1: Grep the whole repo for any remaining bare-module reference**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
grep -rn "from documents\.views import\|from documents\.serialisers import\|documents\.views\.\_monolith\|documents\.serialisers\.\_monolith\|import documents\.views$\|import documents\.serialisers$" src/
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: no output. `documents/views/__init__.py` and `documents/serialisers/__init__.py` should still be empty (`0` bytes or a single blank line) — confirm with:
|
|
||||||
|
|
||||||
```bash
|
|
||||||
wc -l src/documents/views/__init__.py src/documents/serialisers/__init__.py
|
|
||||||
```
|
|
||||||
|
|
||||||
- [ ] **Step 2: Confirm import direction was never violated**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
grep -rln "from documents\.views" src/documents/serialisers/
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: no output (no file in `serialisers/` imports from `views/`).
|
|
||||||
|
|
||||||
- [ ] **Step 3: Full ruff pass**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
ruff check src/documents/views src/documents/serialisers src/paperless/urls.py src/paperless_mail/views.py src/paperless_mail/serialisers.py
|
|
||||||
ruff format --check src/documents/views src/documents/serialisers src/paperless/urls.py src/paperless_mail/views.py src/paperless_mail/serialisers.py
|
|
||||||
```
|
|
||||||
|
|
||||||
Expected: clean.
|
|
||||||
|
|
||||||
- [ ] **Step 4: Full backend test suite**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
bash /c/Users/tholmes/Documents/Coding/paperless/vmtest.sh "-v"
|
|
||||||
```
|
|
||||||
|
|
||||||
(No path filter — this runs the whole backend suite, confirming nothing outside `documents`/`paperless_mail` was quietly relying on the old module shape, e.g. a management command or a script under `scripts/`.)
|
|
||||||
|
|
||||||
Expected: PASS.
|
|
||||||
|
|
||||||
- [ ] **Step 5: If Steps 1–4 found nothing to fix, commit is a no-op — skip it. If they found strays, fix and commit**
|
|
||||||
|
|
||||||
```bash
|
|
||||||
git add -A
|
|
||||||
git commit -m "refactor: fix stray documents.views/serialisers references found in repo sweep"
|
|
||||||
```
|
|
||||||
@@ -1,158 +0,0 @@
|
|||||||
# Split `documents/views.py` and `documents/serialisers.py` into modules
|
|
||||||
|
|
||||||
## Problem
|
|
||||||
|
|
||||||
`src/documents/views.py` (5,395 lines) and `src/documents/serialisers.py`
|
|
||||||
(3,532 lines) have grown into monolithic files covering every REST resource
|
|
||||||
in the `documents` app: correspondents, tags, document types, storage paths,
|
|
||||||
custom fields, the core document viewset and search, chat, bulk-edit
|
|
||||||
operations, sharing, saved views, tasks, workflows, and system/UI settings.
|
|
||||||
Their size makes them hard to navigate, hard to review incrementally, and
|
|
||||||
increases the chance of unrelated changes colliding in the same file.
|
|
||||||
|
|
||||||
This document specifies splitting both files into packages, one module per
|
|
||||||
domain area, with no behavior change.
|
|
||||||
|
|
||||||
## Non-goals
|
|
||||||
|
|
||||||
- No behavior change. Class names, method bodies, and public API responses
|
|
||||||
are unchanged — this is a pure move/reorganize.
|
|
||||||
- No change to `test_views.py` or `test_api_documents.py`. They exercise the
|
|
||||||
moved classes via imports or via the live API; class names and behavior
|
|
||||||
don't change, so they need no edits. Splitting those test files is a
|
|
||||||
separate, later task if desired.
|
|
||||||
- No change to the frontend, migrations, or any other app beyond the three
|
|
||||||
files that import from `documents.views` / `documents.serialisers`
|
|
||||||
(`paperless/urls.py`, `paperless_mail/views.py`,
|
|
||||||
`paperless_mail/serialisers.py`).
|
|
||||||
- This work happens as its own branch/PR against `dev`, after the in-flight
|
|
||||||
`feature-ai-taxonomy-hints-v2` work merges — not layered on top of it.
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
`documents/views.py` becomes the package `documents/views/`, and
|
|
||||||
`documents/serialisers.py` becomes `documents/serialisers/`. Each gets one
|
|
||||||
module per domain area (table below). Neither package's `__init__.py`
|
|
||||||
re-exports its submodules' contents — it stays empty (or a short docstring
|
|
||||||
only). The three external call sites that currently do
|
|
||||||
`from documents.views import X` / `from documents.serialisers import X` are
|
|
||||||
updated to import from the specific submodule instead
|
|
||||||
(`from documents.views.workflows import WorkflowViewSet`, etc.). This avoids
|
|
||||||
adding an indirection layer that could quietly regrow into a second dumping
|
|
||||||
ground, at the cost of touching those three files.
|
|
||||||
|
|
||||||
### Import direction
|
|
||||||
|
|
||||||
`views/*` modules may import from `serialisers/*` modules; `serialisers/*`
|
|
||||||
modules never import from `views/*`. This keeps the dependency graph acyclic
|
|
||||||
by construction — there is no case in the current code where a serializer
|
|
||||||
needs a view.
|
|
||||||
|
|
||||||
Domain module names are the same across both packages (e.g. `bulk_edit.py`
|
|
||||||
exists in both), which makes the natural import `from documents.serialisers.bulk_edit import BulkEditSerializer`
|
|
||||||
inside `documents/views/bulk_edit.py` easy to find, but a view is free to
|
|
||||||
import a serializer from a different domain module when needed (e.g. a
|
|
||||||
`documents.py` view using a `metadata.py` field serializer) — that's a plain
|
|
||||||
cross-module import, not a cycle risk, since the reverse direction never
|
|
||||||
happens.
|
|
||||||
|
|
||||||
## Module breakdown — `documents/views/`
|
|
||||||
|
|
||||||
| Module | Contents |
|
|
||||||
| ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
||||||
| `base.py` | Shared mixins/helpers: `PassUserMixin`, `BulkPermissionMixin`, `PermissionsAwareDocumentCountMixin`, `DocumentSelectionMixin`, `DocumentOperationPermissionMixin`, `SearchParams`/`SearchResultPage`/`ResolvedRequestDocs`, `_get_tantivy_query_and_mode`, `_get_more_like_id`, `serve_file` |
|
|
||||||
| `index.py` | `IndexView`, `serve_logo` |
|
|
||||||
| `metadata.py` | `CorrespondentViewSet`, `TagViewSet`, `DocumentTypeViewSet`, `StoragePathViewSet`, `CustomFieldViewSet`, `_get_llm_output_language` |
|
|
||||||
| `documents.py` | `EmailDocumentDetailSchema`, `DocumentViewSet`, `UnifiedSearchViewSet` |
|
|
||||||
| `upload.py` | `PostDocumentView` |
|
|
||||||
| `chat.py` | `ChatStreamingSerializer`, `ChatStreamingView` |
|
|
||||||
| `search.py` | `SearchAutoCompleteView`, `GlobalSearchView`, `SelectionDataView`, `StatisticsView` |
|
|
||||||
| `bulk_edit.py` | `BulkEditView`, `RotateDocumentsView`, `MergeDocumentsView`, `DeleteDocumentsView`, `ReprocessDocumentsView`, `EditPdfDocumentsView`, `RemovePasswordDocumentsView`, `BulkEditObjectsView`, `BulkDownloadView` |
|
|
||||||
| `sharing.py` | `ShareLinkViewSet`, `ShareLinkBundleViewSet`, `SharedLinkView` |
|
|
||||||
| `saved_views.py` | `SavedViewViewSet` |
|
|
||||||
| `tasks.py` | `_TasksViewSetSchema`, `TasksViewSet` |
|
|
||||||
| `workflows.py` | `WorkflowTriggerViewSet`, `WorkflowActionViewSet`, `WorkflowViewSet` |
|
|
||||||
| `system.py` | `UiSettingsView`, `RemoteVersionView`, `SystemStatusView`, `TrashView` |
|
|
||||||
| `logs.py` | `LogViewSet` |
|
|
||||||
|
|
||||||
`documents.py` remains the largest module at roughly 1,600 lines
|
|
||||||
(`DocumentViewSet` alone is ~1,300 lines in the current file); every other
|
|
||||||
module is well under 500 lines.
|
|
||||||
|
|
||||||
## Module breakdown — `documents/serialisers/`
|
|
||||||
|
|
||||||
| Module | Contents |
|
|
||||||
| ---------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
||||||
| `base.py` | `DynamicFieldsModelSerializer`, `DocumentUpdateFieldsModelSerializer`, `MatchingModelSerializer`, `SetPermissionsMixin`, `SerializerWithPerms`, `SetPermissionsSerializer`, `OwnedObjectSerializer`, `OwnedObjectListSerializer`, `ReadWriteSerializerMethodField`, `DocumentListSerializer`, `DocumentSelectionSerializer`, `SourceModeValidationMixin`, `BasicUserSerializer`, `NotesSerializer` |
|
|
||||||
| `metadata.py` | `CorrespondentSerializer`, `DocumentTypeSerializer`, `DeprecatedColors`, `ColorField`, `TagSerializer`, `CorrespondentField`, `TagsField`, `DocumentTypeField`, `StoragePathField`, `StoragePathSerializer`, `StoragePathTestSerializer`, `CustomFieldSerializer`, `CustomFieldInstanceSerializer`, `validate_documentlink_targets` |
|
|
||||||
| `documents.py` | `DocumentSerializer`, `SearchResultListSerializer`, `SearchResultSerializer`, `DuplicateDocumentSummarySerializer`, `_DocumentVersionInfo`, `DocumentVersionInfoSerializer`, `DocumentVersionSerializer`, `DocumentVersionLabelSerializer`, `_get_viewable_duplicates` |
|
|
||||||
| `upload.py` | `PostDocumentSerializer` |
|
|
||||||
| `saved_views.py` | `SavedViewFilterRuleSerializer`, `SavedViewSerializer` |
|
|
||||||
| `bulk_edit.py` | `RotateDocumentsSerializer`, `MergeDocumentsSerializer`, `EditPdfDocumentsSerializer`, `RemovePasswordDocumentsSerializer`, `DeleteDocumentsSerializer`, `ReprocessDocumentsSerializer`, `BulkEditSerializer`, `BulkDownloadSerializer`, `BulkEditObjectsSerializer` |
|
|
||||||
| `sharing.py` | `EmailSerializer`, `ShareLinkSerializer`, `ShareLinkBundleSerializer` |
|
|
||||||
| `tasks.py` | `TaskSerializerV10`, `TaskSerializerV9`, `TaskSummarySerializer`, `RunTaskSerializer`, `AcknowledgeTasksViewSerializer` |
|
|
||||||
| `workflows.py` | `WorkflowTriggerSerializer`, `WorkflowActionEmailSerializer`, `WorkflowActionWebhookSerializer`, `WorkflowActionSerializer`, `WorkflowSerializer` |
|
|
||||||
| `system.py` | `UiSettingsViewSerializer`, `TrashSerializer` |
|
|
||||||
|
|
||||||
Note: `ChatStreamingSerializer` is defined in `views.py` today (not
|
|
||||||
`serialisers.py`), directly above `ChatStreamingView`. It moves with
|
|
||||||
`ChatStreamingView` into `documents/views/chat.py` rather than into the
|
|
||||||
serialisers package, preserving its current co-location.
|
|
||||||
|
|
||||||
## External call sites to update
|
|
||||||
|
|
||||||
Only three files import from these two modules today, and all move to
|
|
||||||
importing from the specific new submodule:
|
|
||||||
|
|
||||||
- `src/paperless/urls.py` — ~34 `from documents.views import X` lines, one
|
|
||||||
per viewset/view used in URL routing. Each becomes
|
|
||||||
`from documents.views.<domain> import X`.
|
|
||||||
- `src/paperless_mail/views.py` — `from documents.views import PassUserMixin`
|
|
||||||
becomes `from documents.views.base import PassUserMixin`.
|
|
||||||
- `src/paperless_mail/serialisers.py` — `CorrespondentField`,
|
|
||||||
`DocumentTypeField`, `OwnedObjectSerializer`, `TagsField` move to
|
|
||||||
`from documents.serialisers.metadata import CorrespondentField, DocumentTypeField, TagsField`
|
|
||||||
and `from documents.serialisers.base import OwnedObjectSerializer`.
|
|
||||||
|
|
||||||
## Migration order
|
|
||||||
|
|
||||||
1. Split `serialisers.py` into `documents/serialisers/` first — serializers
|
|
||||||
have no dependency on views, so this half can be verified in isolation.
|
|
||||||
Run the full backend test suite after this step.
|
|
||||||
2. Split `views.py` into `documents/views/`, importing from the new
|
|
||||||
`documents/serialisers/*` modules per the table above. Run the full
|
|
||||||
backend test suite.
|
|
||||||
3. Update the three external call sites (`paperless/urls.py`,
|
|
||||||
`paperless_mail/views.py`, `paperless_mail/serialisers.py`).
|
|
||||||
4. Run `ruff check` / `ruff format` and the full backend test suite once
|
|
||||||
more end to end.
|
|
||||||
|
|
||||||
Splitting serialisers before views (rather than in parallel) means step 2
|
|
||||||
can immediately import finished, correctly-located serializer modules
|
|
||||||
instead of guessing at not-yet-final paths.
|
|
||||||
|
|
||||||
## Risks / error handling
|
|
||||||
|
|
||||||
- **Circular imports**: prevented by construction (serialisers never import
|
|
||||||
from views — see Import direction above). If a genuine cross-domain need
|
|
||||||
is discovered during implementation that seems to require a
|
|
||||||
views→views import cycle (e.g. `UnifiedSearchViewSet` extending
|
|
||||||
`DocumentViewSet` from a different module — both already live in
|
|
||||||
`documents.py` so this doesn't arise), resolve it by moving the shared
|
|
||||||
piece to `base.py` rather than introducing a cycle.
|
|
||||||
- **Missed re-export consumers**: verified via a full-repo grep for
|
|
||||||
`from documents.views import` / `from documents.serialisers import` /
|
|
||||||
`documents.views.` / `documents.serialisers.` before considering the split
|
|
||||||
complete, in case something beyond the three known call sites appears
|
|
||||||
(e.g. in a management command or a rarely-run script).
|
|
||||||
- **Silent behavior drift during move**: since this is a pure reorganization,
|
|
||||||
the full test suite passing after each step (rather than only at the end)
|
|
||||||
is the primary safety net; no new tests are required for this refactor
|
|
||||||
itself.
|
|
||||||
|
|
||||||
## Testing
|
|
||||||
|
|
||||||
No new tests. Existing coverage (`test_views.py`, `test_api_documents.py`,
|
|
||||||
and the rest of the `documents` test suite) is run after each migration step
|
|
||||||
per the ordering above, and must pass unchanged — a failure indicates the
|
|
||||||
move altered behavior, not that new coverage is needed.
|
|
||||||
@@ -99,6 +99,10 @@ 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
|
||||||
|
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ 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
|
||||||
|
|
||||||
|
|||||||
+6
-4
@@ -84,9 +84,9 @@ mariadb = [
|
|||||||
"mysqlclient~=2.2.7",
|
"mysqlclient~=2.2.7",
|
||||||
]
|
]
|
||||||
postgres = [
|
postgres = [
|
||||||
"psycopg[c,pool]==3.3",
|
"psycopg[c,pool]==3.3.4",
|
||||||
# Direct dependency for proper resolution of the pre-built wheels
|
# Direct dependency for proper resolution of the pre-built wheels
|
||||||
"psycopg-c==3.3",
|
"psycopg-c==3.3.4",
|
||||||
"psycopg-pool==3.3.1",
|
"psycopg-pool==3.3.1",
|
||||||
]
|
]
|
||||||
webserver = [
|
webserver = [
|
||||||
@@ -160,8 +160,10 @@ 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.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_x86_64.whl", marker = "sys_platform == 'linux' and platform_machine == 'x86_64' and python_version == '3.12'" },
|
||||||
{ url = "https://github.com/paperless-ngx/builder/releases/download/psycopg-trixie-3.3.0/psycopg_c-3.3.0-cp312-cp312-linux_aarch64.whl", marker = "sys_platform == 'linux' and platform_machine == 'aarch64' and python_version == '3.12'" },
|
{ 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.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" },
|
||||||
|
|||||||
@@ -33,7 +33,7 @@ test('should warn on unsaved changes', async ({ page }) => {
|
|||||||
await page.getByRole('button', { name: 'Close', exact: true }).click()
|
await page.getByRole('button', { name: 'Close', exact: true }).click()
|
||||||
await expect(page.getByRole('dialog')).toHaveText(/unsaved changes/)
|
await expect(page.getByRole('dialog')).toHaveText(/unsaved changes/)
|
||||||
await page.getByRole('button', { name: 'Cancel' }).click()
|
await page.getByRole('button', { name: 'Cancel' }).click()
|
||||||
await page.getByRole('link', { name: 'Close all' }).click()
|
await page.getByRole('button', { name: 'Close all' }).click()
|
||||||
await expect(page.getByRole('dialog')).toHaveText(/unsaved changes/)
|
await expect(page.getByRole('dialog')).toHaveText(/unsaved changes/)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
+767
-286
File diff suppressed because it is too large
Load Diff
+23
-21
@@ -11,20 +11,21 @@
|
|||||||
},
|
},
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@angular/cdk": "^22.0.6",
|
"@angular/cdk": "^22.1.1",
|
||||||
"@angular/common": "~22.1.0",
|
"@angular/common": "~22.1.1",
|
||||||
"@angular/compiler": "~22.1.0",
|
"@angular/compiler": "~22.1.1",
|
||||||
"@angular/core": "~22.1.0",
|
"@angular/core": "~22.1.1",
|
||||||
"@angular/forms": "~22.1.0",
|
"@angular/forms": "~22.1.1",
|
||||||
"@angular/localize": "~22.1.0",
|
"@angular/localize": "~22.1.1",
|
||||||
"@angular/platform-browser": "~22.1.0",
|
"@angular/platform-browser": "~22.1.1",
|
||||||
"@angular/router": "~22.1.0",
|
"@angular/router": "~22.1.1",
|
||||||
"@ng-bootstrap/ng-bootstrap": "^21.0.0",
|
"@ng-bootstrap/ng-bootstrap": "^21.0.0",
|
||||||
"@ng-select/ng-select": "^23.5.0",
|
"@ng-select/ng-select": "~23.6.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",
|
||||||
@@ -40,30 +41,31 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@angular-builders/jest": "^22.0.1",
|
"@angular-builders/jest": "^22.0.1",
|
||||||
"@angular-devkit/core": "^22.1.2",
|
"@angular-devkit/core": "^22.1.3",
|
||||||
"@angular-devkit/schematics": "^22.1.2",
|
"@angular-devkit/schematics": "^22.1.3",
|
||||||
"@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.2",
|
"@angular/build": "22.1.3",
|
||||||
"@angular/cli": "22.1.2",
|
"@angular/cli": "22.1.3",
|
||||||
"@angular/compiler-cli": "~22.1.0",
|
"@angular/compiler-cli": "~22.1.1",
|
||||||
"@playwright/test": "^1.62.0",
|
"@playwright/test": "^1.62.1",
|
||||||
"@types/jest": "^30.0.0",
|
"@types/jest": "^30.0.0",
|
||||||
"@types/node": "^26.1.1",
|
"@types/node": "^26.2.0",
|
||||||
"@typescript-eslint/eslint-plugin": "^8.65.0",
|
"@typescript-eslint/eslint-plugin": "^8.67.0",
|
||||||
"@typescript-eslint/parser": "^8.65.0",
|
"@typescript-eslint/parser": "^8.67.0",
|
||||||
"@typescript-eslint/utils": "^8.65.0",
|
"@typescript-eslint/utils": "^8.67.0",
|
||||||
"eslint": "^10.8.0",
|
"eslint": "^10.8.1",
|
||||||
"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.1",
|
"ts-node": "~10.9.2",
|
||||||
"typescript": "^6.0.3"
|
"typescript": "^6.0.3"
|
||||||
},
|
},
|
||||||
"packageManager": "pnpm@11.15.1"
|
"packageManager": "pnpm@11.15.1"
|
||||||
|
|||||||
Generated
+1015
-5873
File diff suppressed because it is too large
Load Diff
@@ -6,6 +6,7 @@ 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
|
blockExoticSubdeps: true
|
||||||
|
autoInstallPeers: false
|
||||||
allowBuilds:
|
allowBuilds:
|
||||||
"@parcel/watcher": true
|
"@parcel/watcher": true
|
||||||
canvas: true
|
canvas: true
|
||||||
|
|||||||
@@ -89,7 +89,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@if (filterText?.length) {
|
@if (filterText?.length) {
|
||||||
<button class="btn btn-link btn-sm px-2 position-absolute top-0 end-0 z-10" (click)="resetFilter()">
|
<button class="btn btn-link btn-sm px-2 position-absolute top-0 end-0 z-10" (click)="resetFilter()" aria-label="Clear search" i18n-aria-label>
|
||||||
<i-bs width="1em" height="1em" name="x"></i-bs>
|
<i-bs width="1em" height="1em" name="x"></i-bs>
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
@@ -208,7 +208,7 @@
|
|||||||
</td>
|
</td>
|
||||||
}
|
}
|
||||||
<td class="d-lg-none">
|
<td class="d-lg-none">
|
||||||
<button class="btn btn-link" (click)="expandTask(task); $event.stopPropagation();">
|
<button class="btn btn-link" (click)="expandTask(task); $event.stopPropagation();" aria-label="View task details" i18n-aria-label>
|
||||||
<i-bs width="1.2em" height="1.2em" name="info-circle"></i-bs>
|
<i-bs width="1.2em" height="1.2em" name="info-circle"></i-bs>
|
||||||
</button>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -18,9 +18,11 @@
|
|||||||
</button>
|
</button>
|
||||||
</pngx-page-header>
|
</pngx-page-header>
|
||||||
|
|
||||||
<div class="row mb-3">
|
@if (totalDocuments() > 25) {
|
||||||
<ngb-pagination class="col-auto" [pageSize]="25" [collectionSize]="totalDocuments()" [page]="page()" [maxSize]="5" (pageChange)="page.set($event); reload()" size="sm" aria-label="Pagination"></ngb-pagination>
|
<div class="row mb-3">
|
||||||
</div>
|
<ngb-pagination class="col-auto" [pageSize]="25" [collectionSize]="totalDocuments()" [page]="page()" [maxSize]="5" (pageChange)="page.set($event); reload()" size="sm" aria-label="Pagination"></ngb-pagination>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
<div class="card border table-responsive mb-3">
|
<div class="card border table-responsive mb-3">
|
||||||
<table class="table table-striped align-middle shadow-sm mb-0">
|
<table class="table table-striped align-middle shadow-sm mb-0">
|
||||||
@@ -64,7 +66,7 @@
|
|||||||
<td scope="row">
|
<td scope="row">
|
||||||
<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 aria-label="Actions" i18n-aria-label>
|
||||||
<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">
|
||||||
|
|||||||
@@ -2,3 +2,8 @@
|
|||||||
.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;
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,27 +4,34 @@
|
|||||||
(click)="closeMobileSearch(); toggleMenuCollapsed()">
|
(click)="closeMobileSearch(); toggleMenuCollapsed()">
|
||||||
<span class="navbar-toggler-icon"></span>
|
<span class="navbar-toggler-icon"></span>
|
||||||
</button>
|
</button>
|
||||||
<a class="navbar-brand d-flex align-items-center me-0 px-3 py-3 order-sm-0"
|
<a class="navbar-brand d-flex align-items-center me-0 ps-md-3 py-0 order-sm-0"
|
||||||
[ngClass]="{ 'slim': slimSidebarEnabled, 'col-auto col-md-3 col-lg-2 col-xxxl-1' : !slimSidebarEnabled, 'py-3' : !customAppTitle?.length || slimSidebarEnabled, 'py-2': customAppTitle?.length }"
|
[ngClass]="{ 'slim': slimSidebarEnabled, '' : !slimSidebarEnabled }"
|
||||||
routerLink="/dashboard"
|
routerLink="/dashboard"
|
||||||
tourAnchor="tour.intro">
|
tourAnchor="tour.intro">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1000 1000" width="1.5em" height="1.5em" fill="currentColor">
|
@if (!hasCustomBranding) {
|
||||||
<path d="M341,949.1c-6.9-20.3-20.7-61.2-21.9-61-199.6-88.9-182.5-229.8-134.3-347.5,30,137.2,268.8,148.9,146.2,336-.9,2.2,10,27.8,19.5,51.3,22.7-51.9,58.6-115.5,55.8-120.8C178,398.7,724.9,299,807.1,18.5c83,251.5,53.1,659.8-377.4,814.9-2,1.4-63.5,148.6-66.9,150.2-.2-2.1-33.2,2.9-30.1-8.7,1.6-7,4.8-16.2,8.2-25.6h0v-.2h.1ZM323.1,846.2c48.3-71.9-12.7-120.8-56.9-152.2,81.2,107.4,66.4,120.8,56.9,152.2h0Z"/>
|
<pngx-logo extra_classes="navbar-official-logo px-1" height="2.4rem"></pngx-logo>
|
||||||
</svg>
|
<svg class="brand-mark brand-mark-slim d-none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1000 1000" width="1.5em" height="1.5em" fill="currentColor">
|
||||||
<div class="ms-2 ms-md-3 d-inline-block" [class.d-md-none]="slimSidebarEnabled">
|
<path d="M341,949.1c-6.9-20.3-20.7-61.2-21.9-61-199.6-88.9-182.5-229.8-134.3-347.5,30,137.2,268.8,148.9,146.2,336-.9,2.2,10,27.8,19.5,51.3,22.7-51.9,58.6-115.5,55.8-120.8C178,398.7,724.9,299,807.1,18.5c83,251.5,53.1,659.8-377.4,814.9-2,1.4-63.5,148.6-66.9,150.2-.2-2.1-33.2,2.9-30.1-8.7,1.6-7,4.8-16.2,8.2-25.6h0v-.2h.1ZM323.1,846.2c48.3-71.9-12.7-120.8-56.9-152.2,81.2,107.4,66.4,120.8,56.9,152.2h0Z"/>
|
||||||
@if (customAppTitle?.length) {
|
</svg>
|
||||||
<div class="d-flex flex-column align-items-start custom-title">
|
} @else {
|
||||||
<span class="title">{{customAppTitle}}</span>
|
@if (customAppLogo) {
|
||||||
<span class="byline text-uppercase font-monospace" i18n>by Paperless-ngx</span>
|
<img class="brand-logo" [src]="customAppLogo" alt="" />
|
||||||
</div>
|
|
||||||
} @else {
|
} @else {
|
||||||
Paperless-ngx
|
<svg class="brand-mark" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1000 1000" width="1.5em" height="1.5em" fill="currentColor">
|
||||||
|
<path d="M341,949.1c-6.9-20.3-20.7-61.2-21.9-61-199.6-88.9-182.5-229.8-134.3-347.5,30,137.2,268.8,148.9,146.2,336-.9,2.2,10,27.8,19.5,51.3,22.7-51.9,58.6-115.5,55.8-120.8C178,398.7,724.9,299,807.1,18.5c83,251.5,53.1,659.8-377.4,814.9-2,1.4-63.5,148.6-66.9,150.2-.2-2.1-33.2,2.9-30.1-8.7,1.6-7,4.8-16.2,8.2-25.6h0v-.2h.1ZM323.1,846.2c48.3-71.9-12.7-120.8-56.9-152.2,81.2,107.4,66.4,120.8,56.9,152.2h0Z"/>
|
||||||
|
</svg>
|
||||||
}
|
}
|
||||||
</div>
|
<div class="brand-copy ms-2 text-truncate" [class.d-md-none]="slimSidebarEnabled">
|
||||||
|
<span class="brand-title text-truncate">{{ appTitle }}</span>
|
||||||
|
@if (customAppTitle) {
|
||||||
|
<span class="byline text-uppercase font-monospace" i18n>by Paperless-ngx</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
</a>
|
</a>
|
||||||
<div class="search-container flex-grow-1 py-2 pb-3 pb-sm-2 px-3 ps-md-4 me-sm-auto order-3 order-sm-1"
|
<div class="search-container flex-grow-1 py-2 pb-3 pb-sm-2 px-3 ps-md-3 me-sm-auto order-3 order-sm-1"
|
||||||
[class.mobile-hidden]="mobileSearchHidden()">
|
[class.mobile-hidden]="mobileSearchHidden()">
|
||||||
<div class="col-12 col-md-7">
|
<div class="col-12 header-search">
|
||||||
<pngx-global-search></pngx-global-search>
|
<pngx-global-search></pngx-global-search>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -34,7 +41,7 @@
|
|||||||
}
|
}
|
||||||
<pngx-toasts-dropdown></pngx-toasts-dropdown>
|
<pngx-toasts-dropdown></pngx-toasts-dropdown>
|
||||||
<li ngbDropdown class="nav-item dropdown">
|
<li ngbDropdown class="nav-item dropdown">
|
||||||
<button class="btn ps-1 border-0" id="userDropdown" ngbDropdownToggle>
|
<button class="btn navbar-action border-0" id="userDropdown" ngbDropdownToggle aria-label="User menu" i18n-aria-label>
|
||||||
<i-bs width="1.3em" height="1.3em" name="person-circle"></i-bs>
|
<i-bs width="1.3em" height="1.3em" name="person-circle"></i-bs>
|
||||||
<span class="small ms-2 d-none d-sm-inline">
|
<span class="small ms-2 d-none d-sm-inline">
|
||||||
{{this.settingsService.displayName}}
|
{{this.settingsService.displayName}}
|
||||||
@@ -71,7 +78,7 @@
|
|||||||
[ngClass]="slimSidebarEnabled ? 'slim' : 'col-md-3 col-lg-2 col-xxxl-1'" [class.animating]="slimSidebarAnimating()"
|
[ngClass]="slimSidebarEnabled ? 'slim' : 'col-md-3 col-lg-2 col-xxxl-1'" [class.animating]="slimSidebarAnimating()"
|
||||||
[ngbCollapse]="isMenuCollapsed()">
|
[ngbCollapse]="isMenuCollapsed()">
|
||||||
@if (canSaveSettings) {
|
@if (canSaveSettings) {
|
||||||
<button class="btn btn-sm btn-dark sidebar-slim-toggler" (click)="toggleSlimSidebar()">
|
<button class="btn btn-sm btn-dark sidebar-slim-toggler" (click)="toggleSlimSidebar()" [aria-label]="slimSidebarEnabled ? 'Expand sidebar' : 'Collapse sidebar'" i18n-aria-label>
|
||||||
@if (slimSidebarEnabled) {
|
@if (slimSidebarEnabled) {
|
||||||
<i-bs width="0.9em" height="0.9em" name="chevron-double-right"></i-bs>
|
<i-bs width="0.9em" height="0.9em" name="chevron-double-right"></i-bs>
|
||||||
} @else {
|
} @else {
|
||||||
@@ -79,7 +86,7 @@
|
|||||||
}
|
}
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
<div class="sidebar-sticky pt-3 d-flex flex-column justify-space-around">
|
<div class="sidebar-sticky pt-3 pb-1 d-flex flex-column justify-space-around">
|
||||||
<ul class="nav flex-column">
|
<ul class="nav flex-column">
|
||||||
<li class="nav-item app-link">
|
<li class="nav-item app-link">
|
||||||
<a class="nav-link" routerLink="dashboard" routerLinkActive="active" (click)="closeMenu()"
|
<a class="nav-link" routerLink="dashboard" routerLinkActive="active" (click)="closeMenu()"
|
||||||
@@ -89,7 +96,9 @@
|
|||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }">
|
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }">
|
||||||
<a class="nav-link" routerLink="documents" routerLinkActive="active" (click)="closeMenu()"
|
<a class="nav-link" routerLink="documents" routerLinkActive="active"
|
||||||
|
[routerLinkActiveOptions]="{ paths: 'exact', queryParams: 'ignored', matrixParams: 'ignored', fragment: 'ignored' }"
|
||||||
|
(click)="closeMenu()"
|
||||||
ngbPopover="Documents" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
|
ngbPopover="Documents" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
|
||||||
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||||
<i-bs class="me-2" name="files"></i-bs><span><ng-container i18n>Documents</ng-container></span>
|
<i-bs class="me-2" name="files"></i-bs><span><ng-container i18n>Documents</ng-container></span>
|
||||||
@@ -159,11 +168,12 @@
|
|||||||
}
|
}
|
||||||
@if (openDocuments.length >= 1) {
|
@if (openDocuments.length >= 1) {
|
||||||
<li class="nav-item w-100 app-link">
|
<li class="nav-item w-100 app-link">
|
||||||
<a class="nav-link app-link" [class.text-truncate]="!slimSidebarEnabled" [routerLink]="[]" (click)="closeAll()"
|
<button type="button" class="nav-link nav-link-action app-link w-100 text-start"
|
||||||
|
[class.text-truncate]="!slimSidebarEnabled" (click)="closeAll()"
|
||||||
ngbPopover="Close all" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
|
ngbPopover="Close all" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
|
||||||
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||||
<i-bs class="me-2" name="x"></i-bs><span><ng-container i18n>Close all</ng-container></span>
|
<i-bs class="me-2" name="x"></i-bs><span><ng-container i18n>Close all</ng-container></span>
|
||||||
</a>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
}
|
}
|
||||||
</ul>
|
</ul>
|
||||||
@@ -177,10 +187,11 @@
|
|||||||
@if (canManageAttributes) {
|
@if (canManageAttributes) {
|
||||||
<li class="nav-item app-link" tourAnchor="tour.tags">
|
<li class="nav-item app-link" tourAnchor="tour.tags">
|
||||||
<div class="d-flex align-items-center attributes-row">
|
<div class="d-flex align-items-center attributes-row">
|
||||||
<a class="nav-link flex-fill" routerLink="attributes" routerLinkActive="active" (click)="closeMenu()"
|
<a class="nav-link flex-fill" routerLink="attributes" routerLinkActive="active"
|
||||||
|
[routerLinkActiveOptions]="{ exact: !(slimSidebarEnabled || attributesSectionsCollapsed) }" (click)="closeMenu()"
|
||||||
ngbPopover="Attributes" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
|
ngbPopover="Attributes" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
|
||||||
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||||
<i-bs class="me-2" name="stack"></i-bs><span><ng-container i18n>Attributes</ng-container></span>
|
<i-bs name="stack"></i-bs><span class="ms-2"><ng-container i18n>Attributes</ng-container></span>
|
||||||
</a>
|
</a>
|
||||||
@if (!slimSidebarEnabled && canSaveSettings) {
|
@if (!slimSidebarEnabled && canSaveSettings) {
|
||||||
<button
|
<button
|
||||||
@@ -195,32 +206,32 @@
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="attributes-submenu ms-2"
|
class="attributes-submenu ms-3"
|
||||||
[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 py-1" routerLink="attributes/tags" routerLinkActive="active" (click)="closeMenu()">
|
<a class="nav-link" 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 py-1" routerLink="attributes/correspondents" routerLinkActive="active" (click)="closeMenu()">
|
<a class="nav-link" 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 py-1" routerLink="attributes/documenttypes" routerLinkActive="active" (click)="closeMenu()">
|
<a class="nav-link" 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 py-1" routerLink="attributes/storagepaths" routerLinkActive="active" (click)="closeMenu()">
|
<a class="nav-link" 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 py-1" routerLink="attributes/customfields" routerLinkActive="active" (click)="closeMenu()">
|
<a class="nav-link" 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>
|
||||||
@@ -363,7 +374,7 @@
|
|||||||
</a>
|
</a>
|
||||||
}
|
}
|
||||||
} @else {
|
} @else {
|
||||||
<a *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.UISettings }" class="small text-decoration-none" routerLink="/settings" fragment="update-checking"
|
<a *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.UISettings }" class="small text-decoration-none" routerLink="/settings" fragment="update-checking" aria-label="Configure update checking" i18n-aria-label
|
||||||
[ngbPopover]="updateCheckingNotEnabledPopContent" popoverClass="shadow" triggers="mouseenter"
|
[ngbPopover]="updateCheckingNotEnabledPopContent" popoverClass="shadow" triggers="mouseenter"
|
||||||
container="body">
|
container="body">
|
||||||
<i-bs width="1.2em" height="1.2em" name="info-circle"></i-bs>
|
<i-bs width="1.2em" height="1.2em" name="info-circle"></i-bs>
|
||||||
|
|||||||
@@ -7,8 +7,8 @@
|
|||||||
bottom: 0;
|
bottom: 0;
|
||||||
left: 0;
|
left: 0;
|
||||||
z-index: 995; /* Behind the navbar */
|
z-index: 995; /* Behind the navbar */
|
||||||
padding: 50px 0 0; /* Height of navbar */
|
padding: 64px 0 0; /* Height of navbar */
|
||||||
box-shadow: inset -1px 0 0 rgba(0, 0, 0, .1);
|
border-right: 1px solid color-mix(in srgb, var(--bs-border-color) 65%, transparent);
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
--pngx-sidebar-width: 100%;
|
--pngx-sidebar-width: 100%;
|
||||||
max-width: var(--pngx-sidebar-width);
|
max-width: var(--pngx-sidebar-width);
|
||||||
@@ -19,8 +19,13 @@
|
|||||||
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% - 55px)
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav-group:not(:has(.app-link)) .sidebar-heading {
|
.nav-group:not(:has(.app-link)) .sidebar-heading {
|
||||||
@@ -42,7 +47,7 @@
|
|||||||
}
|
}
|
||||||
@media (max-width: 767.98px) {
|
@media (max-width: 767.98px) {
|
||||||
.sidebar {
|
.sidebar {
|
||||||
top: 3.5rem;
|
top: 4rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.search-container {
|
.search-container {
|
||||||
@@ -66,12 +71,14 @@
|
|||||||
|
|
||||||
main {
|
main {
|
||||||
transition: all .2s ease;
|
transition: all .2s ease;
|
||||||
padding-top: 110px;
|
padding-top: 118px;
|
||||||
|
background: var(--pngx-bg-darker);
|
||||||
|
min-height: 100vh;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 768px) {
|
@media (min-width: 768px) {
|
||||||
main {
|
main {
|
||||||
padding-top: 56px;
|
padding-top: 64px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,12 +88,12 @@ main {
|
|||||||
|
|
||||||
.sidebar li.nav-item span,
|
.sidebar li.nav-item span,
|
||||||
.sidebar .sidebar-heading span {
|
.sidebar .sidebar-heading span {
|
||||||
transition: all .1s ease;
|
transition: opacity .1s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media(min-width: 768px) {
|
@media(min-width: 768px) {
|
||||||
.sidebar.slim {
|
.sidebar.slim {
|
||||||
max-width: 50px;
|
max-width: 55px;
|
||||||
|
|
||||||
li.nav-item span.badge {
|
li.nav-item span.badge {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
@@ -145,15 +152,15 @@ main {
|
|||||||
display: block;
|
display: block;
|
||||||
position: fixed;
|
position: fixed;
|
||||||
left: calc(var(--pngx-sidebar-width) - 12px);
|
left: calc(var(--pngx-sidebar-width) - 12px);
|
||||||
top: 60px;
|
top: 72px;
|
||||||
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: all .2s ease;
|
transition: left .2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar.slim .sidebar-slim-toggler {
|
.sidebar.slim .sidebar-slim-toggler {
|
||||||
--pngx-sidebar-width: 50px !important;
|
--pngx-sidebar-width: 56px !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -166,7 +173,7 @@ main {
|
|||||||
position: relative;
|
position: relative;
|
||||||
top: 0;
|
top: 0;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
padding-top: 0.5rem;
|
padding: .75rem .5rem 1rem;
|
||||||
overflow-x: hidden;
|
overflow-x: hidden;
|
||||||
overflow-y: auto; /* Scrollable contents if viewport is shorter than content. */
|
overflow-y: auto; /* Scrollable contents if viewport is shorter than content. */
|
||||||
min-height: min-content;
|
min-height: min-content;
|
||||||
@@ -181,9 +188,14 @@ main {
|
|||||||
.sidebar .nav-link {
|
.sidebar .nav-link {
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
border-radius: .55rem;
|
||||||
|
margin: 1px 0;
|
||||||
|
padding: .55rem .7rem;
|
||||||
|
transition: color .15s ease-in-out, background-color .15s ease-in-out;
|
||||||
|
|
||||||
&:hover, &.active, &:focus {
|
&:hover, &:focus {
|
||||||
color: var(--bs-primary);
|
color: var(--bs-primary);
|
||||||
|
background-color: color-mix(in srgb, var(--bs-primary) 8%, transparent);
|
||||||
}
|
}
|
||||||
|
|
||||||
&:focus-visible {
|
&:focus-visible {
|
||||||
@@ -192,7 +204,21 @@ main {
|
|||||||
}
|
}
|
||||||
|
|
||||||
&.active {
|
&.active {
|
||||||
font-weight: bold;
|
font-weight: 600;
|
||||||
|
color: var(--bs-primary);
|
||||||
|
background-color: color-mix(in srgb, var(--bs-primary) 13%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.nav-link-action {
|
||||||
|
color: var(--bs-secondary-color);
|
||||||
|
font-weight: 400;
|
||||||
|
background-color: transparent;
|
||||||
|
|
||||||
|
&:hover,
|
||||||
|
&:focus {
|
||||||
|
color: var(--bs-primary);
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
i-bs {
|
i-bs {
|
||||||
@@ -201,9 +227,45 @@ main {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.attributes-row {
|
||||||
|
border-radius: .55rem;
|
||||||
|
margin: .1rem 0;
|
||||||
|
transition: color .15s ease-in-out, background-color .15s ease-in-out;
|
||||||
|
|
||||||
|
> .nav-link {
|
||||||
|
margin: 0;
|
||||||
|
|
||||||
|
&:hover,
|
||||||
|
&:focus,
|
||||||
|
&.active {
|
||||||
|
background-color: transparent;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover,
|
||||||
|
&:has(> .nav-link:focus-visible) {
|
||||||
|
background-color: color-mix(in srgb, var(--bs-primary) 8%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:has(> .nav-link.active) {
|
||||||
|
background-color: color-mix(in srgb, var(--bs-primary) 13%, transparent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.attributes-row .attributes-expand-btn {
|
.attributes-row .attributes-expand-btn {
|
||||||
opacity: 0.2;
|
opacity: 0.2;
|
||||||
|
width: 1.75rem;
|
||||||
|
height: 1.75rem;
|
||||||
|
margin-right: .35rem !important;
|
||||||
|
border-radius: 50%;
|
||||||
|
box-shadow: none !important;
|
||||||
transition: opacity 0.15s ease-in-out;
|
transition: opacity 0.15s ease-in-out;
|
||||||
|
|
||||||
|
&:focus-visible {
|
||||||
|
outline: 2px solid color-mix(in srgb, var(--bs-primary) 55%, transparent);
|
||||||
|
outline-offset: 1px;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.attributes-row:hover .attributes-expand-btn {
|
.attributes-row:hover .attributes-expand-btn {
|
||||||
@@ -211,8 +273,10 @@ main {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.sidebar-heading {
|
.sidebar-heading {
|
||||||
font-size: 0.75rem;
|
font-size: 0.68rem;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
|
font-weight: 700;
|
||||||
|
opacity: .75;
|
||||||
}
|
}
|
||||||
|
|
||||||
.nav {
|
.nav {
|
||||||
@@ -269,16 +333,118 @@ main {
|
|||||||
*/
|
*/
|
||||||
|
|
||||||
.navbar-brand {
|
.navbar-brand {
|
||||||
font-size: 1rem;
|
--pngx-navbar-brand-shadow-rgb: 0, 0, 0;
|
||||||
|
font-size: 1.0625rem;
|
||||||
|
min-height: 64px;
|
||||||
|
letter-spacing: -0.015em;
|
||||||
|
|
||||||
.flex-column {
|
&:hover,
|
||||||
padding: 0.15rem 0;
|
&:focus-visible {
|
||||||
|
::ng-deep .navbar-official-logo,
|
||||||
|
.brand-mark,
|
||||||
|
.brand-logo {
|
||||||
|
filter: drop-shadow(0 2px 3px rgba(var(--pngx-navbar-brand-shadow-rgb), .5));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
::ng-deep .navbar-official-logo {
|
||||||
|
filter: drop-shadow(0 1px 2px rgba(var(--pngx-navbar-brand-shadow-rgb), .3));
|
||||||
|
transition: filter .15s ease-in-out;
|
||||||
|
|
||||||
|
@media screen and (max-width: 575.98px) {
|
||||||
|
max-height: 2rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-mark {
|
||||||
|
width: 1.65rem;
|
||||||
|
height: 1.65rem;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
transition: filter .15s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-copy {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
min-width: 0;
|
||||||
|
line-height: 1.1;
|
||||||
|
transition: transform .15s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-title {
|
||||||
|
font-weight: 600;
|
||||||
|
max-width: 100%;
|
||||||
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
.byline {
|
.byline {
|
||||||
font-size: 0.5rem;
|
margin-top: .15rem;
|
||||||
letter-spacing: 0.1rem;
|
font-size: .5rem;
|
||||||
|
font-weight: 500;
|
||||||
|
letter-spacing: .1rem;
|
||||||
|
opacity: .8;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.brand-logo {
|
||||||
|
width: auto;
|
||||||
|
height: 2.75rem;
|
||||||
|
max-width: 5rem;
|
||||||
|
flex: 0 0 auto;
|
||||||
|
object-fit: contain;
|
||||||
|
transition: filter .15s ease-in-out, transform .15s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
:host-context(.primary-light) .navbar-brand {
|
||||||
|
--pngx-navbar-brand-shadow-rgb: 255, 255, 255; // Light app color, use white shadow for dark text
|
||||||
|
}
|
||||||
|
|
||||||
|
:host ::ng-deep .navbar-official-logo {
|
||||||
|
.leaf {
|
||||||
|
fill: color-mix(in srgb, var(--pngx-primary-text-contrast) 70%, var(--bs-primary)) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text {
|
||||||
|
fill: var(--pngx-primary-text-contrast) !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar {
|
||||||
|
min-height: 64px;
|
||||||
|
box-shadow: 0 1px 0 rgba(0, 0, 0, .12), 0 4px 18px rgba(0, 0, 0, .08) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar > ul {
|
||||||
|
align-items: center;
|
||||||
|
gap: .125rem;
|
||||||
|
padding-right: .5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
:host ::ng-deep .navbar-action {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 2.5rem;
|
||||||
|
min-height: 2.5rem;
|
||||||
|
padding: .45rem .55rem;
|
||||||
|
border-radius: .6rem;
|
||||||
|
transition: background-color .15s ease-in-out, opacity .15s ease-in-out;
|
||||||
|
|
||||||
|
&:hover,
|
||||||
|
&:focus-visible {
|
||||||
|
background-color: rgba(0, 0, 0, .14);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#userDropdown {
|
||||||
|
padding-left: .75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-search {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 44rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media screen and (max-width: 575.98px) {
|
@media screen and (max-width: 575.98px) {
|
||||||
@@ -327,18 +493,44 @@ main {
|
|||||||
grid-area: actions;
|
grid-area: actions;
|
||||||
justify-self: end;
|
justify-self: end;
|
||||||
flex-wrap: nowrap;
|
flex-wrap: nowrap;
|
||||||
|
gap: 0;
|
||||||
|
padding-right: .25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
:host ::ng-deep .navbar-action {
|
||||||
|
min-width: 2.25rem;
|
||||||
|
padding-right: .4rem;
|
||||||
|
padding-left: .4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
#userDropdown {
|
||||||
|
padding-right: .35rem;
|
||||||
|
padding-left: .45rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@media screen and (min-width: 768px) {
|
@media screen and (min-width: 768px) {
|
||||||
.navbar-brand.slim {
|
.navbar-brand.slim {
|
||||||
max-width: 50px;
|
max-width: 55px;
|
||||||
|
|
||||||
|
.brand-logo {
|
||||||
|
width: 1.65rem;
|
||||||
|
max-width: 1.65rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.brand-mark-slim {
|
||||||
|
display: block !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
:host ::ng-deep .navbar-brand.slim .navbar-official-logo {
|
||||||
|
display: none;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
:host ::ng-deep .dropdown.show .dropdown-toggle,
|
:host ::ng-deep .dropdown.show .dropdown-toggle,
|
||||||
:host ::ng-deep .dropdown-toggle:hover {
|
:host ::ng-deep .dropdown-toggle:hover {
|
||||||
opacity: 0.7;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dropdown-toggle::after {
|
.dropdown-toggle::after {
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ import { TasksService } from 'src/app/services/tasks.service'
|
|||||||
import { ToastService } from 'src/app/services/toast.service'
|
import { ToastService } from 'src/app/services/toast.service'
|
||||||
import { environment } from 'src/environments/environment'
|
import { environment } from 'src/environments/environment'
|
||||||
import { ChatComponent } from '../chat/chat/chat.component'
|
import { ChatComponent } from '../chat/chat/chat.component'
|
||||||
|
import { LogoComponent } from '../common/logo/logo.component'
|
||||||
import { ProfileEditDialogComponent } from '../common/profile-edit-dialog/profile-edit-dialog.component'
|
import { ProfileEditDialogComponent } from '../common/profile-edit-dialog/profile-edit-dialog.component'
|
||||||
import { DocumentDetailComponent } from '../document-detail/document-detail.component'
|
import { DocumentDetailComponent } from '../document-detail/document-detail.component'
|
||||||
import { ComponentWithPermissions } from '../with-permissions/with-permissions.component'
|
import { ComponentWithPermissions } from '../with-permissions/with-permissions.component'
|
||||||
@@ -59,6 +60,7 @@ const SCROLL_THRESHOLD = 16
|
|||||||
styleUrls: ['./app-frame.component.scss'],
|
styleUrls: ['./app-frame.component.scss'],
|
||||||
imports: [
|
imports: [
|
||||||
GlobalSearchComponent,
|
GlobalSearchComponent,
|
||||||
|
LogoComponent,
|
||||||
DocumentTitlePipe,
|
DocumentTitlePipe,
|
||||||
IfPermissionsDirective,
|
IfPermissionsDirective,
|
||||||
ToastsDropdownComponent,
|
ToastsDropdownComponent,
|
||||||
@@ -190,11 +192,34 @@ export class AppFrameComponent
|
|||||||
return `${environment.appTitle} v${this.settingsService.get(SETTINGS_KEYS.VERSION)}${environment.tag === 'prod' ? '' : ` #${environment.tag}`}`
|
return `${environment.appTitle} v${this.settingsService.get(SETTINGS_KEYS.VERSION)}${environment.tag === 'prod' ? '' : ` #${environment.tag}`}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get appTitle(): string {
|
||||||
|
this.settingsService.trackChanges()
|
||||||
|
return (
|
||||||
|
this.settingsService.get(SETTINGS_KEYS.APP_TITLE) || environment.appTitle
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
get customAppTitle(): string {
|
get customAppTitle(): string {
|
||||||
this.settingsService.trackChanges()
|
this.settingsService.trackChanges()
|
||||||
return this.settingsService.get(SETTINGS_KEYS.APP_TITLE)
|
return this.settingsService.get(SETTINGS_KEYS.APP_TITLE)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get hasCustomBranding(): boolean {
|
||||||
|
this.settingsService.trackChanges()
|
||||||
|
return !!(
|
||||||
|
this.settingsService.get(SETTINGS_KEYS.APP_TITLE)?.length ||
|
||||||
|
this.settingsService.get(SETTINGS_KEYS.APP_LOGO)?.length
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
get customAppLogo(): string {
|
||||||
|
this.settingsService.trackChanges()
|
||||||
|
const logo = this.settingsService.get(SETTINGS_KEYS.APP_LOGO)
|
||||||
|
return logo?.length
|
||||||
|
? environment.apiBaseUrl.replace(/\/api\/$/, logo)
|
||||||
|
: null
|
||||||
|
}
|
||||||
|
|
||||||
get canSaveSettings(): boolean {
|
get canSaveSettings(): boolean {
|
||||||
return (
|
return (
|
||||||
this.permissionsService.currentUserCan(
|
this.permissionsService.currentUserCan(
|
||||||
|
|||||||
@@ -4,12 +4,12 @@ form {
|
|||||||
> i-bs[name="search"] {
|
> i-bs[name="search"] {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 0.6rem;
|
left: 0.6rem;
|
||||||
top: .35rem;
|
top: .25rem;
|
||||||
color: rgba(255, 255, 255, 0.6);
|
color: rgba(255, 255, 255, 0.6);
|
||||||
|
|
||||||
@media screen and (min-width: 768px) {
|
@media screen and (min-width: 768px) {
|
||||||
// adjust for smaller font size on non-mobile
|
// adjust for smaller font size on non-mobile
|
||||||
top: 0.25rem;
|
top: .15rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,8 +37,9 @@ form {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.form-control {
|
.form-control {
|
||||||
color: rgba(255, 255, 255, 0.3);
|
min-height: 2.25rem;
|
||||||
background-color: rgba(0, 0, 0, 0.15);
|
color: rgba(255, 255, 255, 0.55);
|
||||||
|
background-color: rgba(0, 0, 0, 0.16);
|
||||||
padding-left: 1.8rem;
|
padding-left: 1.8rem;
|
||||||
border-color: rgba(255, 255, 255, 0.2);
|
border-color: rgba(255, 255, 255, 0.2);
|
||||||
transition: all .3s ease, padding-left 0s ease, background-color 0s ease; // Safari requires all
|
transition: all .3s ease, padding-left 0s ease, background-color 0s ease; // Safari requires all
|
||||||
@@ -52,7 +53,7 @@ form {
|
|||||||
}
|
}
|
||||||
|
|
||||||
&:focus-within {
|
&:focus-within {
|
||||||
background-color: rgba(0, 0, 0, 0.3);
|
background-color: rgba(0, 0, 0, 0.26);
|
||||||
color: var(--bs-light);
|
color: var(--bs-light);
|
||||||
flex-grow: 1;
|
flex-grow: 1;
|
||||||
padding-left: 0.5rem;
|
padding-left: 0.5rem;
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
|
|
||||||
<li ngbDropdown class="nav-item mx-1" (openChange)="onOpenChange($event)">
|
<li ngbDropdown class="nav-item position-relative" (openChange)="onOpenChange($event)">
|
||||||
@if (toasts().length) {
|
@if (toasts().length) {
|
||||||
<span class="badge rounded-pill z-3 pe-none bg-secondary me-2 position-absolute top-0 left-0">{{ toasts().length }}</span>
|
<span class="notification-count badge rounded-pill z-3 pe-none bg-secondary position-absolute">{{ toasts().length }}</span>
|
||||||
}
|
}
|
||||||
<button class="btn border-0" id="notificationsDropdown" ngbDropdownToggle>
|
<button class="btn navbar-action border-0" id="notificationsDropdown" ngbDropdownToggle aria-label="Notifications" i18n-aria-label>
|
||||||
<i-bs width="1.3em" height="1.3em" name="bell"></i-bs>
|
<i-bs width="1.3em" height="1.3em" name="bell"></i-bs>
|
||||||
</button>
|
</button>
|
||||||
<div ngbDropdownMenu class="dropdown-menu-end shadow p-3" aria-labelledby="notificationsDropdown">
|
<div ngbDropdownMenu class="dropdown-menu-end shadow p-3" aria-labelledby="notificationsDropdown">
|
||||||
|
|||||||
@@ -11,6 +11,16 @@
|
|||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.notification-count {
|
||||||
|
top: -.2rem;
|
||||||
|
right: -.2rem;
|
||||||
|
min-width: 1.15rem;
|
||||||
|
height: 1.15rem;
|
||||||
|
padding: .18rem .32rem;
|
||||||
|
font-size: .68rem;
|
||||||
|
line-height: 1;
|
||||||
|
}
|
||||||
|
|
||||||
.dropdown-item {
|
.dropdown-item {
|
||||||
white-space: initial;
|
white-space: initial;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
|
|
||||||
<li ngbDropdown class="nav-item me-n2" (openChange)="onOpenChange($event)">
|
<li ngbDropdown class="nav-item" (openChange)="onOpenChange($event)">
|
||||||
<button class="btn border-0" id="chatDropdown" ngbDropdownToggle>
|
<button class="btn navbar-action border-0" id="chatDropdown" ngbDropdownToggle aria-label="Chat" i18n-aria-label>
|
||||||
<i-bs width="1.3em" height="1.3em" name="chatSquareDots"></i-bs>
|
<i-bs width="1.3em" height="1.3em" name="chatSquareDots"></i-bs>
|
||||||
</button>
|
</button>
|
||||||
<div ngbDropdownMenu class="dropdown-menu-end shadow p-3" aria-labelledby="chatDropdown">
|
<div ngbDropdownMenu class="dropdown-menu-end shadow p-3" aria-labelledby="chatDropdown">
|
||||||
|
|||||||
+58
@@ -0,0 +1,58 @@
|
|||||||
|
<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
@@ -0,0 +1,3 @@
|
|||||||
|
.list-group-item {
|
||||||
|
cursor: move;
|
||||||
|
}
|
||||||
+70
@@ -0,0 +1,70 @@
|
|||||||
|
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
@@ -0,0 +1,70 @@
|
|||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+1
-1
@@ -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 class="form-select" [ngModel]="metadataDocumentID()" (ngModelChange)="metadataDocumentID.set($event)">
|
<select id="metadataDocumentID" 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>
|
||||||
|
|||||||
+2
-2
@@ -6,7 +6,7 @@
|
|||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-2 d-flex justify-content-end">
|
<div class="col-2 d-flex justify-content-end">
|
||||||
<button class="btn btn-secondary mt-auto" (click)="rotate(false)">
|
<button class="btn btn-secondary mt-auto" (click)="rotate(false)" aria-label="Rotate counterclockwise" i18n-aria-label>
|
||||||
<i-bs name="arrow-counterclockwise"></i-bs>
|
<i-bs name="arrow-counterclockwise"></i-bs>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -16,7 +16,7 @@
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<div class="col-2 d-flex">
|
<div class="col-2 d-flex">
|
||||||
<button class="btn btn-secondary mt-auto" (click)="rotate()">
|
<button class="btn btn-secondary mt-auto" (click)="rotate()" aria-label="Rotate clockwise" i18n-aria-label>
|
||||||
<i-bs name="arrow-clockwise"></i-bs>
|
<i-bs name="arrow-clockwise"></i-bs>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
<div ngbDropdown #fieldDropdown="ngbDropdown" (openChange)="onOpenClose($event)" [popperOptions]="popperOptions">
|
<div ngbDropdown #fieldDropdown="ngbDropdown" (openChange)="onOpenClose($event)" [popperOptions]="popperOptions">
|
||||||
<button type="button" class="btn btn-sm btn-outline-primary" id="customFieldsDropdown" [disabled]="disabled" ngbDropdownToggle>
|
<button type="button" class="btn btn-sm btn-outline-primary" id="customFieldsDropdown" [disabled]="disabled" ngbDropdownToggle aria-label="Custom Fields" i18n-aria-label>
|
||||||
<i-bs name="ui-radios"></i-bs><div class="d-none d-lg-inline ms-1"><ng-container i18n>Custom Fields</ng-container></div>
|
<i-bs name="ui-radios"></i-bs><div class="d-none d-lg-inline ms-1"><ng-container i18n>Custom Fields</ng-container></div>
|
||||||
</button>
|
</button>
|
||||||
<div ngbDropdownMenu aria-labelledby="customFieldsDropdown" class="shadow custom-fields-dropdown">
|
<div ngbDropdownMenu aria-labelledby="customFieldsDropdown" class="shadow custom-fields-dropdown">
|
||||||
|
|||||||
+4
-4
@@ -1,6 +1,6 @@
|
|||||||
@if (useDropdown) {
|
@if (useDropdown) {
|
||||||
<div class="btn-group w-100" role="group" ngbDropdown #dropdown="ngbDropdown" (openChange)="onOpenChange($event)" [popperOptions]="popperOptions">
|
<div class="btn-group w-100" role="group" ngbDropdown #dropdown="ngbDropdown" (openChange)="onOpenChange($event)" [popperOptions]="popperOptions">
|
||||||
<button class="btn btn-sm btn-outline-primary" id="dropdown_toggle" ngbDropdownToggle [disabled]="disabled">
|
<button class="btn btn-sm btn-outline-primary" id="dropdown_toggle" ngbDropdownToggle [disabled]="disabled" [aria-label]="title">
|
||||||
<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 (isActive) {
|
@if (isActive) {
|
||||||
<pngx-clearable-badge [selected]="isActive" (cleared)="reset()"></pngx-clearable-badge>
|
<pngx-clearable-badge [selected]="isActive" (cleared)="reset()"></pngx-clearable-badge>
|
||||||
@@ -38,7 +38,7 @@
|
|||||||
ngbDatepicker
|
ngbDatepicker
|
||||||
#d="ngbDatepicker"
|
#d="ngbDatepicker"
|
||||||
[footerTemplate]="datePickerFooterTemplate" />
|
[footerTemplate]="datePickerFooterTemplate" />
|
||||||
<button class="btn btn-sm btn-outline-secondary rounded-end" (click)="d.toggle()" type="button">
|
<button class="btn btn-sm btn-outline-secondary rounded-end" (click)="d.toggle()" type="button" aria-label="Open date picker" i18n-aria-label>
|
||||||
<i-bs name="calendar-event"></i-bs>
|
<i-bs name="calendar-event"></i-bs>
|
||||||
</button>
|
</button>
|
||||||
<ng-template #datePickerFooterTemplate>
|
<ng-template #datePickerFooterTemplate>
|
||||||
@@ -143,7 +143,7 @@
|
|||||||
<input class="w-25 form-control rounded-end" type="text" [(ngModel)]="atom.value" [disabled]="disabled">
|
<input class="w-25 form-control rounded-end" type="text" [(ngModel)]="atom.value" [disabled]="disabled">
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
<button class="btn btn-link btn-sm text-danger pe-0" type="button" (click)="removeElement(atom)" [disabled]="disabled">
|
<button class="btn btn-link btn-sm text-danger pe-0" type="button" (click)="removeElement(atom)" [disabled]="disabled" aria-label="Remove query" i18n-aria-label>
|
||||||
<i-bs name="x-circle"></i-bs>
|
<i-bs name="x-circle"></i-bs>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -185,7 +185,7 @@
|
|||||||
<i-bs name="braces"></i-bs>
|
<i-bs name="braces"></i-bs>
|
||||||
</button>
|
</button>
|
||||||
@if (expression.depth > 0) {
|
@if (expression.depth > 0) {
|
||||||
<button type="button" class="btn btn-sm btn-outline-secondary text-danger" (click)="removeElement(expression)" [disabled]="disabled">
|
<button type="button" class="btn btn-sm btn-outline-secondary text-danger" (click)="removeElement(expression)" [disabled]="disabled" aria-label="Remove expression" i18n-aria-label>
|
||||||
<i-bs name="x-circle"></i-bs>
|
<i-bs name="x-circle"></i-bs>
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<div class="btn-group w-100" ngbDropdown role="group" [popperOptions]="popperOptions" [placement]="placement">
|
<div class="btn-group w-100" ngbDropdown role="group" [popperOptions]="popperOptions" [placement]="placement">
|
||||||
<button class="btn btn-sm" id="dropdown{{title}}" ngbDropdownToggle [ngClass]="createdDateTo || createdDateFrom ? 'btn-primary' : 'btn-outline-primary'" [disabled]="disabled">
|
<button class="btn btn-sm" id="dropdown{{title}}" ngbDropdownToggle [ngClass]="createdDateTo || createdDateFrom ? 'btn-primary' : 'btn-outline-primary'" [disabled]="disabled" [aria-label]="title">
|
||||||
<i-bs width="1em" height="1em" name="calendar-event-fill"></i-bs><div class="d-none d-sm-inline ms-1">{{title}}</div>
|
<i-bs width="1em" height="1em" name="calendar-event-fill"></i-bs><div class="d-none d-sm-inline ms-1">{{title}}</div>
|
||||||
<pngx-clearable-badge [selected]="isActive" (cleared)="reset()"></pngx-clearable-badge><span class="visually-hidden">selected</span>
|
<pngx-clearable-badge [selected]="isActive" (cleared)="reset()"></pngx-clearable-badge><span class="visually-hidden">selected</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -9,7 +9,7 @@
|
|||||||
<div class="list-group-item d-flex p-2 select-item" role="menuitem">
|
<div class="list-group-item d-flex p-2 select-item" role="menuitem">
|
||||||
<div class="selected-icon">
|
<div class="selected-icon">
|
||||||
@if (createdRelativeDate) {
|
@if (createdRelativeDate) {
|
||||||
<a class="text-light focus-variants" href="javascript:void(0)" (click)="clearCreatedRelativeDate()">
|
<a class="text-light focus-variants" href="javascript:void(0)" (click)="clearCreatedRelativeDate()" aria-label="Clear created relative date" i18n-aria-label>
|
||||||
<i-bs width="1em" height="1em" name="check" class="variant-unfocused text-dark"></i-bs>
|
<i-bs width="1em" height="1em" name="check" class="variant-unfocused text-dark"></i-bs>
|
||||||
<i-bs width="1em" height="1em" name="x" class="variant-focused text-primary"></i-bs>
|
<i-bs width="1em" height="1em" name="x" class="variant-focused text-primary"></i-bs>
|
||||||
</a>
|
</a>
|
||||||
@@ -33,7 +33,7 @@
|
|||||||
<div class="list-group-item d-flex p-2" role="menuitem">
|
<div class="list-group-item d-flex p-2" role="menuitem">
|
||||||
<div class="selected-icon">
|
<div class="selected-icon">
|
||||||
@if (createdDateFrom) {
|
@if (createdDateFrom) {
|
||||||
<a class="text-light focus-variants" href="javascript:void(0)" (click)="clearCreatedFrom()">
|
<a class="text-light focus-variants" href="javascript:void(0)" (click)="clearCreatedFrom()" aria-label="Clear created from date" i18n-aria-label>
|
||||||
<i-bs width="1em" height="1em" name="check" class="variant-unfocused"></i-bs>
|
<i-bs width="1em" height="1em" name="check" class="variant-unfocused"></i-bs>
|
||||||
<i-bs width="1em" height="1em" name="x" class="variant-focused text-primary"></i-bs>
|
<i-bs width="1em" height="1em" name="x" class="variant-focused text-primary"></i-bs>
|
||||||
</a>
|
</a>
|
||||||
@@ -43,7 +43,7 @@
|
|||||||
<span class="input-group-text w-25 small text-muted" i18n>From</span>
|
<span class="input-group-text w-25 small text-muted" i18n>From</span>
|
||||||
<input class="form-control small" [placeholder]="datePlaceHolder" (dateSelect)="onChangeDebounce()" (change)="onChangeDebounce()" (keypress)="onKeyPress($event)"
|
<input class="form-control small" [placeholder]="datePlaceHolder" (dateSelect)="onChangeDebounce()" (change)="onChangeDebounce()" (keypress)="onKeyPress($event)"
|
||||||
maxlength="10" [(ngModel)]="createdDateFrom" ngbDatepicker #createdDateFromPicker="ngbDatepicker" [footerTemplate]="createdFromFooterTemplate">
|
maxlength="10" [(ngModel)]="createdDateFrom" ngbDatepicker #createdDateFromPicker="ngbDatepicker" [footerTemplate]="createdFromFooterTemplate">
|
||||||
<button class="btn btn-outline-secondary" (click)="createdDateFromPicker.toggle()" type="button">
|
<button class="btn btn-outline-secondary" (click)="createdDateFromPicker.toggle()" type="button" aria-label="Open created from date picker" i18n-aria-label>
|
||||||
<i-bs width="1em" height="1em" name="calendar"></i-bs>
|
<i-bs width="1em" height="1em" name="calendar"></i-bs>
|
||||||
</button>
|
</button>
|
||||||
<ng-template #createdFromFooterTemplate>
|
<ng-template #createdFromFooterTemplate>
|
||||||
@@ -57,7 +57,7 @@
|
|||||||
<div class="list-group-item d-flex p-2" role="menuitem">
|
<div class="list-group-item d-flex p-2" role="menuitem">
|
||||||
<div class="selected-icon">
|
<div class="selected-icon">
|
||||||
@if (createdDateTo) {
|
@if (createdDateTo) {
|
||||||
<a class="text-light focus-variants" href="javascript:void(0)" (click)="clearCreatedTo()">
|
<a class="text-light focus-variants" href="javascript:void(0)" (click)="clearCreatedTo()" aria-label="Clear created to date" i18n-aria-label>
|
||||||
<i-bs width="1em" height="1em" name="check" class="variant-unfocused"></i-bs>
|
<i-bs width="1em" height="1em" name="check" class="variant-unfocused"></i-bs>
|
||||||
<i-bs width="1em" height="1em" name="x" class="variant-focused text-primary"></i-bs>
|
<i-bs width="1em" height="1em" name="x" class="variant-focused text-primary"></i-bs>
|
||||||
</a>
|
</a>
|
||||||
@@ -67,7 +67,7 @@
|
|||||||
<span class="input-group-text w-25 small text-muted" i18n>To</span>
|
<span class="input-group-text w-25 small text-muted" i18n>To</span>
|
||||||
<input class="form-control small" [placeholder]="datePlaceHolder" (dateSelect)="onChangeDebounce()" (change)="onChangeDebounce()" (keypress)="onKeyPress($event)"
|
<input class="form-control small" [placeholder]="datePlaceHolder" (dateSelect)="onChangeDebounce()" (change)="onChangeDebounce()" (keypress)="onKeyPress($event)"
|
||||||
maxlength="10" [(ngModel)]="createdDateTo" ngbDatepicker #createdDateToPicker="ngbDatepicker" [footerTemplate]="createdToFooterTemplate">
|
maxlength="10" [(ngModel)]="createdDateTo" ngbDatepicker #createdDateToPicker="ngbDatepicker" [footerTemplate]="createdToFooterTemplate">
|
||||||
<button class="btn btn-outline-secondary" (click)="createdDateToPicker.toggle()" type="button">
|
<button class="btn btn-outline-secondary" (click)="createdDateToPicker.toggle()" type="button" aria-label="Open created to date picker" i18n-aria-label>
|
||||||
<i-bs width="1em" height="1em" name="calendar"></i-bs>
|
<i-bs width="1em" height="1em" name="calendar"></i-bs>
|
||||||
</button>
|
</button>
|
||||||
<ng-template #createdToFooterTemplate>
|
<ng-template #createdToFooterTemplate>
|
||||||
@@ -85,7 +85,7 @@
|
|||||||
<div class="list-group-item d-flex p-2 select-item" role="menuitem">
|
<div class="list-group-item d-flex p-2 select-item" role="menuitem">
|
||||||
<div class="selected-icon">
|
<div class="selected-icon">
|
||||||
@if (addedRelativeDate) {
|
@if (addedRelativeDate) {
|
||||||
<a class="text-light focus-variants" href="javascript:void(0)" (click)="clearAddedRelativeDate()">
|
<a class="text-light focus-variants" href="javascript:void(0)" (click)="clearAddedRelativeDate()" aria-label="Clear added relative date" i18n-aria-label>
|
||||||
<i-bs width="1em" height="1em" name="check" class="variant-unfocused text-dark"></i-bs>
|
<i-bs width="1em" height="1em" name="check" class="variant-unfocused text-dark"></i-bs>
|
||||||
<i-bs width="1em" height="1em" name="x" class="variant-focused text-primary"></i-bs>
|
<i-bs width="1em" height="1em" name="x" class="variant-focused text-primary"></i-bs>
|
||||||
</a>
|
</a>
|
||||||
@@ -109,7 +109,7 @@
|
|||||||
<div class="list-group-item d-flex p-2" role="menuitem">
|
<div class="list-group-item d-flex p-2" role="menuitem">
|
||||||
<div class="selected-icon">
|
<div class="selected-icon">
|
||||||
@if (addedDateFrom) {
|
@if (addedDateFrom) {
|
||||||
<a class="text-light focus-variants" href="javascript:void(0)" (click)="clearAddedFrom()">
|
<a class="text-light focus-variants" href="javascript:void(0)" (click)="clearAddedFrom()" aria-label="Clear added from date" i18n-aria-label>
|
||||||
<i-bs width="1em" height="1em" name="check" class="variant-unfocused"></i-bs>
|
<i-bs width="1em" height="1em" name="check" class="variant-unfocused"></i-bs>
|
||||||
<i-bs width="1em" height="1em" name="x" class="variant-focused text-primary"></i-bs>
|
<i-bs width="1em" height="1em" name="x" class="variant-focused text-primary"></i-bs>
|
||||||
</a>
|
</a>
|
||||||
@@ -119,7 +119,7 @@
|
|||||||
<span class="input-group-text w-25 small text-muted" i18n>From</span>
|
<span class="input-group-text w-25 small text-muted" i18n>From</span>
|
||||||
<input class="form-control small" [placeholder]="datePlaceHolder" (dateSelect)="onChangeDebounce()" (change)="onChangeDebounce()" (keypress)="onKeyPress($event)"
|
<input class="form-control small" [placeholder]="datePlaceHolder" (dateSelect)="onChangeDebounce()" (change)="onChangeDebounce()" (keypress)="onKeyPress($event)"
|
||||||
maxlength="10" [(ngModel)]="addedDateFrom" ngbDatepicker #addedDateFromPicker="ngbDatepicker" [footerTemplate]="addedFromFooterTemplate">
|
maxlength="10" [(ngModel)]="addedDateFrom" ngbDatepicker #addedDateFromPicker="ngbDatepicker" [footerTemplate]="addedFromFooterTemplate">
|
||||||
<button class="btn btn-outline-secondary" (click)="addedDateFromPicker.toggle()" type="button">
|
<button class="btn btn-outline-secondary" (click)="addedDateFromPicker.toggle()" type="button" aria-label="Open added from date picker" i18n-aria-label>
|
||||||
<i-bs width="1em" height="1em" name="calendar"></i-bs>
|
<i-bs width="1em" height="1em" name="calendar"></i-bs>
|
||||||
</button>
|
</button>
|
||||||
<ng-template #addedFromFooterTemplate>
|
<ng-template #addedFromFooterTemplate>
|
||||||
@@ -133,7 +133,7 @@
|
|||||||
<div class="list-group-item d-flex p-2" role="menuitem">
|
<div class="list-group-item d-flex p-2" role="menuitem">
|
||||||
<div class="selected-icon">
|
<div class="selected-icon">
|
||||||
@if (addedDateTo) {
|
@if (addedDateTo) {
|
||||||
<a class="text-light focus-variants" href="javascript:void(0)" (click)="clearAddedTo()">
|
<a class="text-light focus-variants" href="javascript:void(0)" (click)="clearAddedTo()" aria-label="Clear added to date" i18n-aria-label>
|
||||||
<i-bs width="1em" height="1em" name="check" class="variant-unfocused"></i-bs>
|
<i-bs width="1em" height="1em" name="check" class="variant-unfocused"></i-bs>
|
||||||
<i-bs width="1em" height="1em" name="x" class="variant-focused text-primary"></i-bs>
|
<i-bs width="1em" height="1em" name="x" class="variant-focused text-primary"></i-bs>
|
||||||
</a>
|
</a>
|
||||||
@@ -143,7 +143,7 @@
|
|||||||
<span class="input-group-text w-25 small text-muted" i18n>To</span>
|
<span class="input-group-text w-25 small text-muted" i18n>To</span>
|
||||||
<input class="form-control small" [placeholder]="datePlaceHolder" (dateSelect)="onChangeDebounce()" (change)="onChangeDebounce()" (keypress)="onKeyPress($event)"
|
<input class="form-control small" [placeholder]="datePlaceHolder" (dateSelect)="onChangeDebounce()" (change)="onChangeDebounce()" (keypress)="onKeyPress($event)"
|
||||||
maxlength="10" [(ngModel)]="addedDateTo" ngbDatepicker #addedDateToPicker="ngbDatepicker" [footerTemplate]="addedToFooterTemplate">
|
maxlength="10" [(ngModel)]="addedDateTo" ngbDatepicker #addedDateToPicker="ngbDatepicker" [footerTemplate]="addedToFooterTemplate">
|
||||||
<button class="btn btn-outline-secondary" (click)="addedDateToPicker.toggle()" type="button">
|
<button class="btn btn-outline-secondary" (click)="addedDateToPicker.toggle()" type="button" aria-label="Open added to date picker" i18n-aria-label>
|
||||||
<i-bs width="1em" height="1em" name="calendar"></i-bs>
|
<i-bs width="1em" height="1em" name="calendar"></i-bs>
|
||||||
</button>
|
</button>
|
||||||
<ng-template #addedToFooterTemplate>
|
<ng-template #addedToFooterTemplate>
|
||||||
|
|||||||
+4
-4
@@ -1,5 +1,5 @@
|
|||||||
<div class="btn-group w-100" ngbDropdown role="group" (openChange)="dropdownOpenChange($event)" #dropdown="ngbDropdown" (keydown)="listKeyDown($event)" [popperOptions]="popperOptions">
|
<div class="btn-group w-100" ngbDropdown role="group" (openChange)="dropdownOpenChange($event)" #dropdown="ngbDropdown" (keydown)="listKeyDown($event)" [popperOptions]="popperOptions" [autoClose]="!creating()">
|
||||||
<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" [aria-label]="title">
|
||||||
<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) {
|
||||||
<pngx-clearable-badge [number]="selectionModel.totalCount" [selected]="selectionModel.selectionSize() > 0" (cleared)="reset()"></pngx-clearable-badge>
|
<pngx-clearable-badge [number]="selectionModel.totalCount" [selected]="selectionModel.selectionSize() > 0" (cleared)="reset()"></pngx-clearable-badge>
|
||||||
@@ -49,7 +49,7 @@
|
|||||||
</cdk-virtual-scroll-viewport>
|
</cdk-virtual-scroll-viewport>
|
||||||
}
|
}
|
||||||
@if (editing) {
|
@if (editing) {
|
||||||
@if (filteredItems.length === 0 && createRef !== undefined) {
|
@if (filteredItems.length === 0 && createRef !== undefined && filterText?.length > 0) {
|
||||||
<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) {
|
@if (extraButtonTitle && (showExtraButtonIfEmpty || filteredItems?.length > 0)) {
|
||||||
<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>
|
||||||
|
|||||||
+63
-4
@@ -3,6 +3,7 @@ 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,
|
||||||
@@ -48,6 +49,7 @@ 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
|
||||||
@@ -868,7 +870,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
|
|||||||
expect(getRootDocCount(rootWithoutCounts.id)).toEqual(0)
|
expect(getRootDocCount(rootWithoutCounts.id)).toEqual(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should set support create, keep open model and call createRef method', async () => {
|
it('should keep the dropdown open while the create modal is active', async () => {
|
||||||
component.selectionModel.items = items
|
component.selectionModel.items = items
|
||||||
component.icon = 'tag-fill'
|
component.icon = 'tag-fill'
|
||||||
component.selectionModel = selectionModel
|
component.selectionModel = selectionModel
|
||||||
@@ -882,20 +884,44 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
|
|||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
|
|
||||||
component.filterText = 'Test Filter Text'
|
component.filterText = 'Test Filter Text'
|
||||||
component.createRef = jest.fn()
|
const modalClosed = new Subject<void>()
|
||||||
|
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()
|
component.createRef = jest.fn(createModalRef)
|
||||||
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
|
||||||
@@ -911,6 +937,25 @@ 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
|
||||||
@@ -970,4 +1015,18 @@ 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')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
+25
-9
@@ -15,9 +15,13 @@ import {
|
|||||||
signal,
|
signal,
|
||||||
} from '@angular/core'
|
} from '@angular/core'
|
||||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||||
import { NgbDropdown, NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap'
|
import {
|
||||||
|
NgbDropdown,
|
||||||
|
NgbDropdownModule,
|
||||||
|
NgbModalRef,
|
||||||
|
} from '@ng-bootstrap/ng-bootstrap'
|
||||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||||
import { Subject, filter, takeUntil } from 'rxjs'
|
import { Subject, filter, first, merge, 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'
|
||||||
@@ -759,7 +763,7 @@ export class FilterableDropdownComponent
|
|||||||
disabled = false
|
disabled = false
|
||||||
|
|
||||||
@Input()
|
@Input()
|
||||||
createRef: (name) => void
|
createRef: (name: string) => NgbModalRef
|
||||||
|
|
||||||
@Input()
|
@Input()
|
||||||
set documentCounts(counts: SelectionDataItem[]) {
|
set documentCounts(counts: SelectionDataItem[]) {
|
||||||
@@ -774,7 +778,10 @@ export class FilterableDropdownComponent
|
|||||||
@Input()
|
@Input()
|
||||||
extraButtonTitle: string
|
extraButtonTitle: string
|
||||||
|
|
||||||
creating: boolean = false
|
@Input()
|
||||||
|
showExtraButtonIfEmpty: boolean = false
|
||||||
|
|
||||||
|
readonly creating = signal(false)
|
||||||
|
|
||||||
@Output()
|
@Output()
|
||||||
apply = new EventEmitter<ChangedItems>()
|
apply = new EventEmitter<ChangedItems>()
|
||||||
@@ -851,12 +858,18 @@ export class FilterableDropdownComponent
|
|||||||
}
|
}
|
||||||
|
|
||||||
createClicked() {
|
createClicked() {
|
||||||
this.creating = true
|
this.creating.set(true)
|
||||||
this.createRef(this.filterText)
|
const modal = 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()
|
||||||
@@ -869,9 +882,8 @@ 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()) {
|
||||||
@@ -892,7 +904,11 @@ export class FilterableDropdownComponent
|
|||||||
this.dropdown.close()
|
this.dropdown.close()
|
||||||
}
|
}
|
||||||
}, 200)
|
}, 200)
|
||||||
} else if (filtered.length == 0 && this.createRef) {
|
} else if (
|
||||||
|
filtered.length == 0 &&
|
||||||
|
this.createRef &&
|
||||||
|
this.filterText?.length > 0
|
||||||
|
) {
|
||||||
this.createClicked()
|
this.createClicked()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
<div class="input-group" [class.is-invalid]="error">
|
<div class="input-group" [class.is-invalid]="error">
|
||||||
<button type="button" class="input-group-text" [style.background-color]="value" (click)="colorPicker.toggle()"> </button>
|
<button type="button" class="input-group-text" [style.background-color]="value" (click)="colorPicker.toggle()" aria-label="Open color picker" i18n-aria-label> </button>
|
||||||
|
|
||||||
<ng-template #popContent>
|
<ng-template #popContent>
|
||||||
<div style="min-width: 200px;" class="pb-3">
|
<div style="min-width: 200px;" class="pb-3">
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
|
|
||||||
<input #inputField class="form-control" [class.is-invalid]="error" [id]="inputId" [(ngModel)]="value" (change)="onChange(value)" [autoClose]="'outside'" [ngbPopover]="popContent" #colorPicker="ngbPopover" placement="bottom" popoverClass="shadow">
|
<input #inputField class="form-control" [class.is-invalid]="error" [id]="inputId" [(ngModel)]="value" (change)="onChange(value)" [autoClose]="'outside'" [ngbPopover]="popContent" #colorPicker="ngbPopover" placement="bottom" popoverClass="shadow">
|
||||||
|
|
||||||
<button class="btn btn-outline-secondary" type="button" (click)="randomize()">
|
<button class="btn btn-outline-secondary" type="button" (click)="randomize()" aria-label="Choose a random color" i18n-aria-label>
|
||||||
<i-bs name="dice5"></i-bs>
|
<i-bs name="dice5"></i-bs>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -74,7 +74,7 @@
|
|||||||
class="flex-grow-1"></pngx-input-textarea>
|
class="flex-grow-1"></pngx-input-textarea>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
<button type="button" class="btn btn-link text-danger" (click)="removeSelectedField.next(fieldId)">
|
<button type="button" class="btn btn-link text-danger" (click)="removeSelectedField.next(fieldId)" aria-label="Remove custom field" i18n-aria-label>
|
||||||
<i-bs name="trash"></i-bs>
|
<i-bs name="trash"></i-bs>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
<input #inputField class="form-control" [class.is-invalid]="error" [placeholder]="placeholder" [id]="inputId" maxlength="10"
|
<input #inputField class="form-control" [class.is-invalid]="error" [placeholder]="placeholder" [id]="inputId" maxlength="10"
|
||||||
(dateSelect)="onChange(value)" (change)="onChange(value)" (keypress)="onKeyPress($event)" (paste)="onPaste($event)"
|
(dateSelect)="onChange(value)" (change)="onChange(value)" (keypress)="onKeyPress($event)" (paste)="onPaste($event)"
|
||||||
name="dp" [(ngModel)]="value" ngbDatepicker #datePicker="ngbDatepicker" #datePickerContent="ngModel" [disabled]="disabled" [footerTemplate]="datePickerFooterTemplate">
|
name="dp" [(ngModel)]="value" ngbDatepicker #datePicker="ngbDatepicker" #datePickerContent="ngModel" [disabled]="disabled" [footerTemplate]="datePickerFooterTemplate">
|
||||||
<button class="btn btn-outline-secondary calendar" (click)="datePicker.toggle()" type="button" [disabled]="disabled">
|
<button class="btn btn-outline-secondary calendar" (click)="datePicker.toggle()" type="button" [disabled]="disabled" aria-label="Open date picker" i18n-aria-label>
|
||||||
<i-bs width="1.2em" height="1.2em" name="calendar"></i-bs>
|
<i-bs width="1.2em" height="1.2em" name="calendar"></i-bs>
|
||||||
</button>
|
</button>
|
||||||
<ng-template #datePickerFooterTemplate>
|
<ng-template #datePickerFooterTemplate>
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
<div class="input-group mb-3">
|
<div class="input-group mb-3">
|
||||||
<input type="text" class="form-control" [(ngModel)]="entry[0]" (change)="inputChange()" [disabled]="disabled" autocomplete="off">
|
<input type="text" class="form-control" [(ngModel)]="entry[0]" (change)="inputChange()" [disabled]="disabled" autocomplete="off">
|
||||||
<input type="text" class="form-control" [(ngModel)]="entry[1]" (change)="inputChange()" [disabled]="disabled" autocomplete="off">
|
<input type="text" class="form-control" [(ngModel)]="entry[1]" (change)="inputChange()" [disabled]="disabled" autocomplete="off">
|
||||||
<button type="button" class="btn btn-outline-secondary" (click)="removeEntry(i)">
|
<button type="button" class="btn btn-outline-secondary" (click)="removeEntry(i)" aria-label="Remove entry" i18n-aria-label>
|
||||||
<i-bs class="text-danger" name="trash"></i-bs>
|
<i-bs class="text-danger" name="trash"></i-bs>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -50,7 +50,7 @@
|
|||||||
</ng-template>
|
</ng-template>
|
||||||
</ng-select>
|
</ng-select>
|
||||||
@if (allowCreateNew && !hideAddButton) {
|
@if (allowCreateNew && !hideAddButton) {
|
||||||
<button class="btn btn-outline-secondary" type="button" (click)="addItem()" [disabled]="disabled">
|
<button class="btn btn-outline-secondary" type="button" (click)="addItem()" [disabled]="disabled" aria-label="Create new item" i18n-aria-label>
|
||||||
<i-bs width="1.2em" height="1.2em" name="plus"></i-bs>
|
<i-bs width="1.2em" height="1.2em" name="plus"></i-bs>
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,7 +48,7 @@
|
|||||||
</ng-template>
|
</ng-template>
|
||||||
</ng-select>
|
</ng-select>
|
||||||
@if (allowCreate && !hideAddButton) {
|
@if (allowCreate && !hideAddButton) {
|
||||||
<button class="btn btn-outline-secondary" type="button" (click)="createTag(null, true)" [disabled]="disabled">
|
<button class="btn btn-outline-secondary" type="button" (click)="createTag(null, true)" [disabled]="disabled" aria-label="Create new tag" i18n-aria-label>
|
||||||
<i-bs width="1.2em" height="1.2em" name="plus"></i-bs>
|
<i-bs width="1.2em" height="1.2em" name="plus"></i-bs>
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,15 @@ h3 {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
:host {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: .35rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 > .h6 {
|
||||||
|
color: var(--bs-secondary-color);
|
||||||
|
}
|
||||||
|
|
||||||
@media (min-width: 1200px) {
|
@media (min-width: 1200px) {
|
||||||
h3 {
|
h3 {
|
||||||
min-height: 2.8rem;
|
min-height: 2.8rem;
|
||||||
|
|||||||
+4
-4
@@ -1,5 +1,5 @@
|
|||||||
<div class="btn-group w-100" ngbDropdown role="group">
|
<div class="btn-group w-100" ngbDropdown role="group">
|
||||||
<button class="btn btn-sm" id="dropdown{{title}}" ngbDropdownToggle [ngClass]="isActive ? 'btn-primary' : 'btn-outline-primary'" [disabled]="disabled">
|
<button class="btn btn-sm" id="dropdown{{title}}" ngbDropdownToggle [ngClass]="isActive ? 'btn-primary' : 'btn-outline-primary'" [disabled]="disabled" [aria-label]="title">
|
||||||
<i-bs name="person-fill-lock"></i-bs><div class="d-none d-sm-inline ms-1">{{title}}</div>
|
<i-bs name="person-fill-lock"></i-bs><div class="d-none d-sm-inline ms-1">{{title}}</div>
|
||||||
<pngx-clearable-badge [selected]="isActive" (cleared)="reset()"></pngx-clearable-badge><span class="visually-hidden">selected</span>
|
<pngx-clearable-badge [selected]="isActive" (cleared)="reset()"></pngx-clearable-badge><span class="visually-hidden">selected</span>
|
||||||
</button>
|
</button>
|
||||||
@@ -22,7 +22,7 @@
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<div class="me-1">
|
<div class="me-1">
|
||||||
<small i18n>My documents</small>
|
<small>{{ownerFilterLabel}}</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 i18n>Shared with me</small>
|
<small>{{ownerExclusionFilterLabel}}</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 i18n>Shared by me</small>
|
<small>{{sharedByFilterLabel}}</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,6 +94,58 @@ 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,6 +93,55 @@ 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)
|
||||||
|
|
||||||
@@ -164,4 +213,8 @@ export class PermissionsFilterDropdownComponent extends ComponentWithPermissions
|
|||||||
}
|
}
|
||||||
this.onChange()
|
this.onChange()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private getUsername(userID: number): string {
|
||||||
|
return this.users().find((user) => user.id === userID)?.username
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+19
@@ -107,6 +107,25 @@ describe('PermissionsSelectComponent', () => {
|
|||||||
expect(component.form.get('Tag').get('Change').disabled).toBeTruthy()
|
expect(component.form.get('Tag').get('Change').disabled).toBeTruthy()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should update checkboxes when inherited permissions change', () => {
|
||||||
|
component.ngOnInit()
|
||||||
|
component.inheritedPermissions = ['documents.change_document']
|
||||||
|
component.writeValue(['delete_document'])
|
||||||
|
expect(component.form.get('Document').get('Change').value).toBeTruthy()
|
||||||
|
expect(component.form.get('Document').get('Change').disabled).toBeTruthy()
|
||||||
|
|
||||||
|
// swap for a group with a different permission, but the same number of them
|
||||||
|
component.inheritedPermissions = ['documents.view_document']
|
||||||
|
|
||||||
|
// the no-longer-inherited permission is unchecked, the explicit one is kept
|
||||||
|
expect(component.permissions).toEqual(['delete_document'])
|
||||||
|
expect(component.form.get('Document').get('Change').value).toBeFalsy()
|
||||||
|
expect(component.form.get('Document').get('Change').disabled).toBeFalsy()
|
||||||
|
expect(component.form.get('Document').get('Delete').value).toBeTruthy()
|
||||||
|
expect(component.form.get('Document').get('View').value).toBeTruthy()
|
||||||
|
expect(component.form.get('Document').get('View').disabled).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
it('should exclude history permissions if disabled', () => {
|
it('should exclude history permissions if disabled', () => {
|
||||||
settingsService.set(SETTINGS_KEYS.AUDITLOG_ENABLED, false)
|
settingsService.set(SETTINGS_KEYS.AUDITLOG_ENABLED, false)
|
||||||
fixture = TestBed.createComponent(PermissionsSelectComponent)
|
fixture = TestBed.createComponent(PermissionsSelectComponent)
|
||||||
|
|||||||
+61
-35
@@ -74,12 +74,22 @@ export class PermissionsSelectComponent
|
|||||||
? inherited.map((p) => p.replace(/^\w+\./g, ''))
|
? inherited.map((p) => p.replace(/^\w+\./g, ''))
|
||||||
: []
|
: []
|
||||||
|
|
||||||
if (this._inheritedPermissions !== newInheritedPermissions) {
|
const changed =
|
||||||
this._inheritedPermissions = newInheritedPermissions
|
newInheritedPermissions.length !== this._inheritedPermissions.length ||
|
||||||
this.writeValue(this.permissions) // updates visual checks etc.
|
newInheritedPermissions.some(
|
||||||
}
|
(p) => !this._inheritedPermissions.includes(p)
|
||||||
|
)
|
||||||
|
|
||||||
this.updateDisabledStates()
|
if (changed) {
|
||||||
|
// skip inherited permissions, these are the explicitly set ones
|
||||||
|
this.permissions = this.getSelectedPermissions(
|
||||||
|
this.form.getRawValue()
|
||||||
|
).filter((p) => !this._inheritedPermissions.includes(p))
|
||||||
|
this._inheritedPermissions = newInheritedPermissions
|
||||||
|
this.applyCheckedState()
|
||||||
|
} else {
|
||||||
|
this.updateDisabledStates()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
inheritedWarning: string = $localize`Inherited from group`
|
inheritedWarning: string = $localize`Inherited from group`
|
||||||
@@ -106,20 +116,29 @@ export class PermissionsSelectComponent
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.permissions = permissions ?? []
|
this.permissions = permissions ?? []
|
||||||
const allPerms = this._inheritedPermissions.concat(this.permissions)
|
this.applyCheckedState()
|
||||||
|
}
|
||||||
|
|
||||||
allPerms.forEach((permissionStr) => {
|
// sets every checkbox from inherited + own perms
|
||||||
const { actionKey, typeKey } =
|
private applyCheckedState(): void {
|
||||||
this.permissionsService.getPermissionKeys(permissionStr)
|
const allPerms = new Set(
|
||||||
|
this._inheritedPermissions.concat(this.permissions)
|
||||||
|
)
|
||||||
|
|
||||||
if (actionKey && typeKey) {
|
|
||||||
this.form
|
|
||||||
.get(typeKey)
|
|
||||||
?.get(actionKey)
|
|
||||||
?.patchValue(true, { emitEvent: false })
|
|
||||||
}
|
|
||||||
})
|
|
||||||
this.allowedTypes.forEach((type) => {
|
this.allowedTypes.forEach((type) => {
|
||||||
|
const typeGroup = this.form.get(type)
|
||||||
|
for (const action of Object.keys(PermissionAction)) {
|
||||||
|
typeGroup.get(action)?.patchValue(
|
||||||
|
allPerms.has(
|
||||||
|
this.permissionsService.getPermissionCode(
|
||||||
|
PermissionAction[action],
|
||||||
|
PermissionType[type]
|
||||||
|
)
|
||||||
|
),
|
||||||
|
{ emitEvent: false } // don't trigger valueChanges now
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if (this.typeHasAllActionsSelected(type)) {
|
if (this.typeHasAllActionsSelected(type)) {
|
||||||
this.typesWithAllActions.add(type)
|
this.typesWithAllActions.add(type)
|
||||||
} else {
|
} else {
|
||||||
@@ -150,26 +169,9 @@ export class PermissionsSelectComponent
|
|||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
this.form.valueChanges.subscribe((newValue) => {
|
this.form.valueChanges.subscribe((newValue) => {
|
||||||
let permissions = []
|
const permissions = this.getSelectedPermissions(newValue)
|
||||||
Object.entries(newValue).forEach(([typeKey, typeValue]) => {
|
|
||||||
const selectedActions = Object.entries(typeValue).filter(
|
|
||||||
([actionKey, actionValue]) =>
|
|
||||||
actionValue &&
|
|
||||||
this.isActionSupported(
|
|
||||||
PermissionType[typeKey],
|
|
||||||
PermissionAction[actionKey]
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
selectedActions.forEach(([actionKey]) => {
|
|
||||||
permissions.push(
|
|
||||||
(PermissionType[typeKey] as string).replace(
|
|
||||||
'%s',
|
|
||||||
PermissionAction[actionKey]
|
|
||||||
)
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
|
Object.keys(newValue).forEach((typeKey) => {
|
||||||
if (this.typeHasAllActionsSelected(typeKey)) {
|
if (this.typeHasAllActionsSelected(typeKey)) {
|
||||||
this.typesWithAllActions.add(typeKey)
|
this.typesWithAllActions.add(typeKey)
|
||||||
} else {
|
} else {
|
||||||
@@ -269,6 +271,30 @@ export class PermissionsSelectComponent
|
|||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private getSelectedPermissions(formValue: object): string[] {
|
||||||
|
const permissions = []
|
||||||
|
Object.entries(formValue).forEach(([typeKey, typeValue]) => {
|
||||||
|
Object.entries(typeValue)
|
||||||
|
.filter(
|
||||||
|
([actionKey, actionValue]) =>
|
||||||
|
actionValue &&
|
||||||
|
this.isActionSupported(
|
||||||
|
PermissionType[typeKey],
|
||||||
|
PermissionAction[actionKey]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.forEach(([actionKey]) => {
|
||||||
|
permissions.push(
|
||||||
|
this.permissionsService.getPermissionCode(
|
||||||
|
PermissionAction[actionKey],
|
||||||
|
PermissionType[typeKey]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
return permissions
|
||||||
|
}
|
||||||
|
|
||||||
private typeHasAllActionsSelected(typeKey: string): boolean {
|
private typeHasAllActionsSelected(typeKey: string): boolean {
|
||||||
return Object.keys(PermissionAction)
|
return Object.keys(PermissionAction)
|
||||||
.filter((action) =>
|
.filter((action) =>
|
||||||
|
|||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
<div class="btn-group">
|
<div class="btn-group">
|
||||||
<button type="button" class="btn btn-sm btn-outline-primary" (click)="clickSuggest()" [disabled]="disabled() || loading() || (suggestions() && !aiEnabled())">
|
<button type="button" class="btn btn-sm btn-outline-primary" (click)="clickSuggest()" [disabled]="disabled() || loading() || (suggestions() && !aiEnabled())" [aria-label]="noSuggestions ? 'No suggestions' : 'Suggest'" i18n-aria-label>
|
||||||
@if (loading()) {
|
@if (loading()) {
|
||||||
<div class="spinner-border spinner-border-sm" role="status"></div>
|
<div class="spinner-border spinner-border-sm" role="status"></div>
|
||||||
} @else if (noSuggestions) {
|
} @else if (noSuggestions) {
|
||||||
|
|||||||
+1
-1
@@ -23,7 +23,7 @@
|
|||||||
<dd>
|
<dd>
|
||||||
{{status().pngx_version}}
|
{{status().pngx_version}}
|
||||||
@if (versionMismatch()) {
|
@if (versionMismatch()) {
|
||||||
<button class="btn btn-sm d-inline align-items-center btn-dark text-uppercase small" [ngbPopover]="versionPopover" triggers="click mouseenter:mouseleave">
|
<button class="btn btn-sm d-inline align-items-center btn-dark text-uppercase small" [ngbPopover]="versionPopover" triggers="click mouseenter:mouseleave" aria-label="View version mismatch details" i18n-aria-label>
|
||||||
<i-bs name="exclamation-triangle-fill" class="text-danger lh-1"></i-bs>
|
<i-bs name="exclamation-triangle-fill" class="text-danger lh-1"></i-bs>
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,7 @@
|
|||||||
<pngx-page-header title="Dashboard" [subTitle]="subtitle" i18n-title tourAnchor="tour.dashboard">
|
<pngx-page-header title="Dashboard" [subTitle]="subtitle" i18n-title tourAnchor="tour.dashboard"></pngx-page-header>
|
||||||
<pngx-logo extra_classes="d-none d-md-block mt-n2" height="3rem"></pngx-logo>
|
|
||||||
</pngx-page-header>
|
|
||||||
|
|
||||||
<div class="row">
|
<div class="row dashboard-grid g-4 pb-0">
|
||||||
<div class="col-12 col-lg-8 col-xl-9 mb-4">
|
<div class="col-12 col-lg-8 col-xl-9 mb-4 dashboard-main">
|
||||||
<div class="row row-cols-1 g-4"
|
<div class="row row-cols-1 g-4"
|
||||||
cdkDropList
|
cdkDropList
|
||||||
[cdkDropListDisabled]="settingsService.globalDropzoneActive()"
|
[cdkDropListDisabled]="settingsService.globalDropzoneActive()"
|
||||||
@@ -58,7 +56,7 @@
|
|||||||
</ng-container>
|
</ng-container>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-12 col-lg-4 col-xl-3 col-sidebar">
|
<div class="col-12 col-lg-4 col-xl-3 col-sidebar dashboard-aside">
|
||||||
<div class="row row-cols-1 g-4 mb-4 sticky-lg-top z-0">
|
<div class="row row-cols-1 g-4 mb-4 sticky-lg-top z-0">
|
||||||
<pngx-upload-file-widget></pngx-upload-file-widget>
|
<pngx-upload-file-widget></pngx-upload-file-widget>
|
||||||
<pngx-statistics-widget *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.UISettings }"></pngx-statistics-widget>
|
<pngx-statistics-widget *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.UISettings }"></pngx-statistics-widget>
|
||||||
|
|||||||
@@ -1,3 +1,13 @@
|
|||||||
.col-sidebar .row {
|
.col-sidebar .row {
|
||||||
top: 3.5rem;
|
top: 4.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dashboard-grid {
|
||||||
|
padding-bottom: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (min-width: 1200px) {
|
||||||
|
.dashboard-main {
|
||||||
|
padding-right: 1rem;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ import { SavedViewService } from 'src/app/services/rest/saved-view.service'
|
|||||||
import { SettingsService } from 'src/app/services/settings.service'
|
import { SettingsService } from 'src/app/services/settings.service'
|
||||||
import { ToastService } from 'src/app/services/toast.service'
|
import { ToastService } from 'src/app/services/toast.service'
|
||||||
import { environment } from 'src/environments/environment'
|
import { environment } from 'src/environments/environment'
|
||||||
import { LogoComponent } from '../common/logo/logo.component'
|
|
||||||
import { PageHeaderComponent } from '../common/page-header/page-header.component'
|
import { PageHeaderComponent } from '../common/page-header/page-header.component'
|
||||||
import { ComponentWithPermissions } from '../with-permissions/with-permissions.component'
|
import { ComponentWithPermissions } from '../with-permissions/with-permissions.component'
|
||||||
import { SavedViewWidgetComponent } from './widgets/saved-view-widget/saved-view-widget.component'
|
import { SavedViewWidgetComponent } from './widgets/saved-view-widget/saved-view-widget.component'
|
||||||
@@ -28,7 +27,6 @@ import { WelcomeWidgetComponent } from './widgets/welcome-widget/welcome-widget.
|
|||||||
templateUrl: './dashboard.component.html',
|
templateUrl: './dashboard.component.html',
|
||||||
styleUrls: ['./dashboard.component.scss'],
|
styleUrls: ['./dashboard.component.scss'],
|
||||||
imports: [
|
imports: [
|
||||||
LogoComponent,
|
|
||||||
PageHeaderComponent,
|
PageHeaderComponent,
|
||||||
SavedViewWidgetComponent,
|
SavedViewWidgetComponent,
|
||||||
StatisticsWidgetComponent,
|
StatisticsWidgetComponent,
|
||||||
|
|||||||
+11
@@ -8,3 +8,14 @@
|
|||||||
width: 0.6rem;
|
width: 0.6rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.list-group {
|
||||||
|
--bs-list-group-border-color: color-mix(in srgb, var(--bs-border-color) 58%, transparent);
|
||||||
|
--bs-list-group-bg: transparent;
|
||||||
|
border-radius: .6rem;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-group-item {
|
||||||
|
padding: .7rem .8rem;
|
||||||
|
}
|
||||||
|
|||||||
+10
@@ -4,6 +4,16 @@
|
|||||||
|
|
||||||
.btn-outline-dark {
|
.btn-outline-dark {
|
||||||
--bs-btn-border-color: var(--bs-border-color-translucent);
|
--bs-btn-border-color: var(--bs-border-color-translucent);
|
||||||
|
border-style: dashed;
|
||||||
|
border-width: 1px;
|
||||||
|
min-height: 5.25rem;
|
||||||
|
background: color-mix(in srgb, var(--bs-primary) 4%, var(--bs-light)) !important;
|
||||||
|
|
||||||
|
&:hover,
|
||||||
|
&:focus {
|
||||||
|
border-color: var(--bs-primary);
|
||||||
|
background: color-mix(in srgb, var(--bs-primary) 9%, var(--bs-light)) !important;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.smaller {
|
.smaller {
|
||||||
|
|||||||
+1
-1
@@ -1,5 +1,5 @@
|
|||||||
@if (!cardless()) {
|
@if (!cardless()) {
|
||||||
<div class="card shadow-sm bg-light fade" [class.show]="show()" cdkDrag [cdkDragDisabled]="!draggable()" cdkDragPreviewContainer="parent">
|
<div class="card bg-light fade" [class.show]="show()" cdkDrag [cdkDragDisabled]="!draggable()" cdkDragPreviewContainer="parent">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<div class="d-flex justify-content-between align-items-center">
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
<div class="d-flex align-items-center">
|
<div class="d-flex align-items-center">
|
||||||
|
|||||||
@@ -2,6 +2,20 @@ i-bs {
|
|||||||
cursor: move;
|
cursor: move;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
overflow: hidden;
|
||||||
|
border-color: color-mix(in srgb, var(--bs-border-color) 68%, transparent);
|
||||||
|
|
||||||
|
.card-header {
|
||||||
|
padding: .9rem 1rem;
|
||||||
|
border-bottom-color: color-mix(in srgb, var(--bs-border-color) 55%, transparent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-body {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.fade.show {
|
.fade.show {
|
||||||
animation: pngx-entry-fade 160ms ease-out;
|
animation: pngx-entry-fade 160ms ease-out;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -20,7 +20,7 @@
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
<button type="button" class="btn btn-sm btn-outline-danger me-md-4" (click)="delete()" [disabled]="!userIsOwner" *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.Document }">
|
<button type="button" class="btn btn-sm btn-outline-danger me-md-4" (click)="delete()" [disabled]="!userIsOwner" *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.Document }" aria-label="Delete" i18n-aria-label>
|
||||||
<i-bs width="1.2em" height="1.2em" name="trash"></i-bs><span class="d-none d-lg-inline ps-1" i18n>Delete</span>
|
<i-bs width="1.2em" height="1.2em" name="trash"></i-bs><span class="d-none d-lg-inline ps-1" i18n>Delete</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
@@ -35,7 +35,7 @@
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
<div class="btn-group">
|
<div class="btn-group">
|
||||||
<button (click)="download()" class="btn btn-sm btn-outline-primary" [disabled]="downloading()">
|
<button (click)="download()" class="btn btn-sm btn-outline-primary" [disabled]="downloading()" aria-label="Download" i18n-aria-label>
|
||||||
@if (downloading()) {
|
@if (downloading()) {
|
||||||
<div class="spinner-border spinner-border-sm" role="status"></div>
|
<div class="spinner-border spinner-border-sm" role="status"></div>
|
||||||
} @else {
|
} @else {
|
||||||
@@ -45,7 +45,7 @@
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div class="btn-group" ngbDropdown role="group">
|
<div class="btn-group" ngbDropdown role="group">
|
||||||
<button class="btn btn-sm btn-outline-primary dropdown-toggle" [disabled]="downloading()" ngbDropdownToggle></button>
|
<button class="btn btn-sm btn-outline-primary dropdown-toggle" [disabled]="downloading()" ngbDropdownToggle aria-label="Download options" i18n-aria-label></button>
|
||||||
<div class="dropdown-menu shadow" ngbDropdownMenu>
|
<div class="dropdown-menu shadow" ngbDropdownMenu>
|
||||||
@if (metadata()?.has_archive_version) {
|
@if (metadata()?.has_archive_version) {
|
||||||
<button ngbDropdownItem (click)="download(true)" [disabled]="downloading()" i18n>Download original</button>
|
<button ngbDropdownItem (click)="download(true)" [disabled]="downloading()" i18n>Download original</button>
|
||||||
@@ -62,7 +62,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="ms-auto" ngbDropdown>
|
<div class="ms-auto" ngbDropdown>
|
||||||
<button class="btn btn-sm btn-outline-primary" id="actionsDropdown" ngbDropdownToggle>
|
<button class="btn btn-sm btn-outline-primary" id="actionsDropdown" ngbDropdownToggle aria-label="Actions" i18n-aria-label>
|
||||||
<i-bs name="three-dots"></i-bs><div class="d-none d-sm-inline ms-1"><ng-container i18n>Actions</ng-container></div>
|
<i-bs name="three-dots"></i-bs><div class="d-none d-sm-inline ms-1"><ng-container i18n>Actions</ng-container></div>
|
||||||
</button>
|
</button>
|
||||||
<div ngbDropdownMenu aria-labelledby="actionsDropdown" class="shadow">
|
<div ngbDropdownMenu aria-labelledby="actionsDropdown" class="shadow">
|
||||||
@@ -91,7 +91,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="ms-auto" ngbDropdown>
|
<div class="ms-auto" ngbDropdown>
|
||||||
<button class="btn btn-sm btn-outline-primary" id="sendDropdown" ngbDropdownToggle>
|
<button class="btn btn-sm btn-outline-primary" id="sendDropdown" ngbDropdownToggle aria-label="Send" i18n-aria-label>
|
||||||
<i-bs name="send"></i-bs><div class="d-none d-sm-inline ms-1"><ng-container i18n>Send</ng-container></div>
|
<i-bs name="send"></i-bs><div class="d-none d-sm-inline ms-1"><ng-container i18n>Send</ng-container></div>
|
||||||
</button>
|
</button>
|
||||||
<div ngbDropdownMenu aria-labelledby="actionsDropdown" class="shadow">
|
<div ngbDropdownMenu aria-labelledby="actionsDropdown" class="shadow">
|
||||||
|
|||||||
@@ -467,13 +467,6 @@ describe('DocumentDetailComponent', () => {
|
|||||||
const docWithVersions = {
|
const docWithVersions = {
|
||||||
...doc,
|
...doc,
|
||||||
versions: [
|
versions: [
|
||||||
{
|
|
||||||
id: doc.id,
|
|
||||||
added: new Date('2024-01-01T00:00:00Z'),
|
|
||||||
version_label: 'Original',
|
|
||||||
checksum: 'aaaa',
|
|
||||||
is_root: true,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
id: 10,
|
id: 10,
|
||||||
added: new Date('2024-01-02T00:00:00Z'),
|
added: new Date('2024-01-02T00:00:00Z'),
|
||||||
@@ -481,6 +474,13 @@ describe('DocumentDetailComponent', () => {
|
|||||||
checksum: 'bbbb',
|
checksum: 'bbbb',
|
||||||
is_root: false,
|
is_root: false,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: doc.id,
|
||||||
|
added: new Date('2024-01-01T00:00:00Z'),
|
||||||
|
version_label: 'Original',
|
||||||
|
checksum: 'aaaa',
|
||||||
|
is_root: true,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
} as Document
|
} as Document
|
||||||
|
|
||||||
@@ -1232,8 +1232,8 @@ describe('DocumentDetailComponent', () => {
|
|||||||
|
|
||||||
metadataSpy.mockClear()
|
metadataSpy.mockClear()
|
||||||
component.document().versions = [
|
component.document().versions = [
|
||||||
{ id: doc.id, is_root: true },
|
|
||||||
{ id: 10, is_root: false },
|
{ id: 10, is_root: false },
|
||||||
|
{ id: doc.id, is_root: true },
|
||||||
] 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')
|
||||||
@@ -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: doc.id, is_root: true },
|
|
||||||
{ id: 10, is_root: false },
|
{ id: 10, is_root: false },
|
||||||
|
{ id: doc.id, is_root: true },
|
||||||
] 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: doc.id, is_root: true },
|
|
||||||
{ id: 10, is_root: false },
|
{ id: 10, is_root: false },
|
||||||
|
{ id: doc.id, is_root: true },
|
||||||
] as any
|
] as any
|
||||||
|
|
||||||
const getDownloadUrlSpy = jest
|
const getDownloadUrlSpy = jest
|
||||||
@@ -2171,6 +2171,11 @@ describe('DocumentDetailComponent', () => {
|
|||||||
).toBe(10)
|
).toBe(10)
|
||||||
component.openEmailDocument()
|
component.openEmailDocument()
|
||||||
expect(modalSpy).toHaveBeenCalled()
|
expect(modalSpy).toHaveBeenCalled()
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
modalSpy.mock.results[1].value as NgbModalRef
|
||||||
|
).componentInstance.documentIds()
|
||||||
|
).toEqual([10])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should set previewText', () => {
|
it('should set previewText', () => {
|
||||||
|
|||||||
@@ -889,13 +889,9 @@ export class DocumentDetailComponent
|
|||||||
|
|
||||||
updateComponent(doc: Document) {
|
updateComponent(doc: Document) {
|
||||||
this.document.set(doc)
|
this.document.set(doc)
|
||||||
// Default selected version is the newest version
|
// Default selected version is the newest version, which the API returns first
|
||||||
const versions = doc.versions ?? []
|
const versions = doc.versions ?? []
|
||||||
this.selectedVersionId.set(
|
this.selectedVersionId.set(versions.length ? versions[0].id : doc.id)
|
||||||
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()
|
||||||
@@ -1441,7 +1437,8 @@ export class DocumentDetailComponent
|
|||||||
if (!versions.length || !this.selectedVersionId()) {
|
if (!versions.length || !this.selectedVersionId()) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
const latestVersionId = Math.max(...versions.map((version) => version.id))
|
// The API returns versions newest first
|
||||||
|
const latestVersionId = versions[0].id
|
||||||
return this.selectedVersionId() === latestVersionId
|
return this.selectedVersionId() === latestVersionId
|
||||||
? null
|
? null
|
||||||
: this.selectedVersionId()
|
: this.selectedVersionId()
|
||||||
@@ -1976,7 +1973,9 @@ export class DocumentDetailComponent
|
|||||||
const modal = this.modalService.open(EmailDocumentDialogComponent, {
|
const modal = this.modalService.open(EmailDocumentDialogComponent, {
|
||||||
backdrop: 'static',
|
backdrop: 'static',
|
||||||
})
|
})
|
||||||
modal.componentInstance.documentIds.set([this.document().id])
|
modal.componentInstance.documentIds.set([
|
||||||
|
this.selectedVersionId() ?? this.document().id,
|
||||||
|
])
|
||||||
modal.componentInstance.hasArchiveVersion.set(
|
modal.componentInstance.hasArchiveVersion.set(
|
||||||
this.metadata()?.has_archive_version ??
|
this.metadata()?.has_archive_version ??
|
||||||
!!this.document()?.archived_file_name
|
!!this.document()?.archived_file_name
|
||||||
|
|||||||
+18
@@ -0,0 +1,18 @@
|
|||||||
|
<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
@@ -0,0 +1,73 @@
|
|||||||
|
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
@@ -0,0 +1,35 @@
|
|||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
+25
-8
@@ -1,7 +1,10 @@
|
|||||||
<div class="btn-group" ngbDropdown autoClose="outside">
|
<div class="btn-group" ngbDropdown autoClose="outside">
|
||||||
<button class="btn btn-sm btn-outline-secondary dropdown-toggle" ngbDropdownToggle>
|
<button class="btn btn-sm btn-outline-secondary dropdown-toggle" ngbDropdownToggle aria-label="Versions" i18n-aria-label>
|
||||||
<i-bs name="file-earmark-diff"></i-bs>
|
<i-bs name="file-earmark-diff"></i-bs>
|
||||||
<span class="d-none d-lg-inline ps-1" i18n>Versions</span>
|
<span class="d-none d-lg-inline ps-1" i18n>Versions</span>
|
||||||
|
@if (versions.length > 1) {
|
||||||
|
<span class="badge text-bg-secondary ms-1">{{ versions.length }}</span>
|
||||||
|
}
|
||||||
</button>
|
</button>
|
||||||
<div class="dropdown-menu shadow" ngbDropdownMenu>
|
<div class="dropdown-menu shadow" ngbDropdownMenu>
|
||||||
<div class="px-3 py-2 mb-2">
|
<div class="px-3 py-2 mb-2">
|
||||||
@@ -24,13 +27,26 @@
|
|||||||
class="visually-hidden"
|
class="visually-hidden"
|
||||||
(change)="onVersionFileSelected($event)"
|
(change)="onVersionFileSelected($event)"
|
||||||
/>
|
/>
|
||||||
<button
|
<div class="btn-group btn-group-sm w-100">
|
||||||
class="btn btn-sm btn-outline-secondary w-100"
|
<button
|
||||||
(click)="versionFileInput.click()"
|
class="btn btn-sm btn-outline-secondary w-100"
|
||||||
[disabled]="!userIsOwner || !userCanEdit"
|
(click)="versionFileInput.click()"
|
||||||
>
|
[disabled]="!userIsOwner || !userCanEdit"
|
||||||
<i-bs name="file-earmark-plus"></i-bs><span class="ps-1" i18n>Add new version</span>
|
title="Upload a new version"
|
||||||
</button>
|
i18n-title
|
||||||
|
>
|
||||||
|
<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) {
|
||||||
@@ -128,6 +144,7 @@
|
|||||||
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>
|
||||||
|
|||||||
+98
-3
@@ -1,9 +1,16 @@
|
|||||||
import { DatePipe } from '@angular/common'
|
import { DatePipe } from '@angular/common'
|
||||||
import { SimpleChange } from '@angular/core'
|
import { SimpleChange, signal } 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'
|
||||||
@@ -19,12 +26,20 @@ describe('DocumentVersionDropdownComponent', () => {
|
|||||||
let documentService: jest.Mocked<
|
let documentService: jest.Mocked<
|
||||||
Pick<
|
Pick<
|
||||||
DocumentService,
|
DocumentService,
|
||||||
'deleteVersion' | 'getVersions' | 'uploadVersion' | 'updateVersionLabel'
|
| 'deleteVersion'
|
||||||
|
| '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 }>()
|
||||||
@@ -32,13 +47,18 @@ 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: [
|
||||||
@@ -61,6 +81,14 @@ describe('DocumentVersionDropdownComponent', () => {
|
|||||||
provide: ToastService,
|
provide: ToastService,
|
||||||
useValue: toastService,
|
useValue: toastService,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
provide: NgbModal,
|
||||||
|
useValue: modalService,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: PermissionsService,
|
||||||
|
useValue: permissionsService,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
provide: WebsocketStatusService,
|
provide: WebsocketStatusService,
|
||||||
useValue: {
|
useValue: {
|
||||||
@@ -131,6 +159,31 @@ 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
|
||||||
@@ -222,9 +275,10 @@ 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: 3, is_root: true, checksum: 'aaaa' },
|
|
||||||
{ id: 20, is_root: false, checksum: 'cccc' },
|
{ id: 20, is_root: false, checksum: 'cccc' },
|
||||||
|
{ id: 3, is_root: true, checksum: 'aaaa' },
|
||||||
]
|
]
|
||||||
const file = new File(['test'], 'new-version.pdf', {
|
const file = new File(['test'], 'new-version.pdf', {
|
||||||
type: 'application/pdf',
|
type: 'application/pdf',
|
||||||
@@ -323,4 +377,45 @@ 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()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
+62
-6
@@ -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 } from '@ng-bootstrap/ng-bootstrap'
|
import { NgbDropdownModule, NgbModal } 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,6 +25,7 @@ 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'
|
||||||
@@ -33,6 +34,8 @@ 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',
|
||||||
@@ -43,11 +46,15 @@ import { ConfirmButtonComponent } from '../../common/confirm-button/confirm-butt
|
|||||||
NgbDropdownModule,
|
NgbDropdownModule,
|
||||||
NgxBootstrapIconsModule,
|
NgxBootstrapIconsModule,
|
||||||
ConfirmButtonComponent,
|
ConfirmButtonComponent,
|
||||||
|
IfPermissionsDirective,
|
||||||
SlicePipe,
|
SlicePipe,
|
||||||
CustomDatePipe,
|
CustomDatePipe,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class DocumentVersionDropdownComponent implements OnChanges, OnDestroy {
|
export class DocumentVersionDropdownComponent
|
||||||
|
extends ComponentWithPermissions
|
||||||
|
implements OnChanges, OnDestroy
|
||||||
|
{
|
||||||
UploadState = UploadState
|
UploadState = UploadState
|
||||||
|
|
||||||
@Input() documentId: number
|
@Input() documentId: number
|
||||||
@@ -69,6 +76,7 @@ export class DocumentVersionDropdownComponent implements OnChanges, OnDestroy {
|
|||||||
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>()
|
||||||
|
|
||||||
@@ -256,11 +264,10 @@ export class DocumentVersionDropdownComponent implements OnChanges, OnDestroy {
|
|||||||
.subscribe({
|
.subscribe({
|
||||||
next: (doc) => {
|
next: (doc) => {
|
||||||
if (uploadDocumentId !== this.documentId) return
|
if (uploadDocumentId !== this.documentId) return
|
||||||
if (doc?.versions) {
|
if (doc?.versions?.length) {
|
||||||
this.versionsUpdated.emit(doc.versions)
|
this.versionsUpdated.emit(doc.versions)
|
||||||
this.versionSelected.emit(
|
// The API returns versions newest first
|
||||||
Math.max(...doc.versions.map((version) => version.id))
|
this.versionSelected.emit(doc.versions[0].id)
|
||||||
)
|
|
||||||
this.clearVersionUploadStatus()
|
this.clearVersionUploadStatus()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -278,6 +285,55 @@ export class DocumentVersionDropdownComponent implements OnChanges, OnDestroy {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
<h6>
|
<h6>
|
||||||
<button type="button" class="btn btn-outline-secondary btn-sm me-2"
|
<button type="button" class="btn btn-outline-secondary btn-sm me-2"
|
||||||
(click)="expand = !expand">
|
(click)="expand = !expand" aria-label="Toggle document metadata" i18n-aria-label>
|
||||||
@if (!expand) {
|
@if (!expand) {
|
||||||
<i-bs width="1.2em" height="1.2em" name="caret-down"></i-bs>
|
<i-bs width="1.2em" height="1.2em" name="caret-down"></i-bs>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -74,7 +74,7 @@
|
|||||||
</pngx-filterable-dropdown>
|
</pngx-filterable-dropdown>
|
||||||
}
|
}
|
||||||
<div class="btn-group">
|
<div class="btn-group">
|
||||||
<button type="button" class="btn btn-sm btn-outline-primary me-2" (click)="setPermissions()" [disabled]="!userOwnsAll || !userCanEditAll">
|
<button type="button" class="btn btn-sm btn-outline-primary me-2" (click)="setPermissions()" [disabled]="!userOwnsAll || !userCanEditAll" aria-label="Permissions" i18n-aria-label>
|
||||||
<i-bs name="person-fill-lock"></i-bs><div class="d-none d-sm-inline ms-1"><ng-container i18n>Permissions</ng-container></div>
|
<i-bs name="person-fill-lock"></i-bs><div class="d-none d-sm-inline ms-1"><ng-container i18n>Permissions</ng-container></div>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -82,7 +82,7 @@
|
|||||||
<div class="d-flex align-items-center gap-2 ms-auto">
|
<div class="d-flex align-items-center gap-2 ms-auto">
|
||||||
<div class="btn-toolbar">
|
<div class="btn-toolbar">
|
||||||
<div ngbDropdown>
|
<div ngbDropdown>
|
||||||
<button class="btn btn-sm btn-outline-primary" id="dropdownSelect" [disabled]="!userCanEdit && !userCanAdd" ngbDropdownToggle>
|
<button class="btn btn-sm btn-outline-primary" id="dropdownSelect" [disabled]="!userCanEdit && !userCanAdd" ngbDropdownToggle aria-label="Actions" i18n-aria-label>
|
||||||
<i-bs name="three-dots"></i-bs><div class="d-none d-sm-inline ms-1"><ng-container i18n>Actions</ng-container></div>
|
<i-bs name="three-dots"></i-bs><div class="d-none d-sm-inline ms-1"><ng-container i18n>Actions</ng-container></div>
|
||||||
</button>
|
</button>
|
||||||
<div ngbDropdownMenu aria-labelledby="dropdownSelect" class="shadow">
|
<div ngbDropdownMenu aria-labelledby="dropdownSelect" class="shadow">
|
||||||
@@ -95,6 +95,9 @@
|
|||||||
<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>
|
||||||
@@ -104,6 +107,8 @@
|
|||||||
id="dropdownSend"
|
id="dropdownSend"
|
||||||
ngbDropdownToggle
|
ngbDropdownToggle
|
||||||
[disabled]="disabled || !canSendSelection"
|
[disabled]="disabled || !canSendSelection"
|
||||||
|
aria-label="Send"
|
||||||
|
i18n-aria-label
|
||||||
>
|
>
|
||||||
<i-bs name="send"></i-bs><div class="d-none d-sm-inline ms-1"><ng-container i18n>Send</ng-container>
|
<i-bs name="send"></i-bs><div class="d-none d-sm-inline ms-1"><ng-container i18n>Send</ng-container>
|
||||||
</div>
|
</div>
|
||||||
@@ -124,7 +129,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="btn-group btn-group-sm">
|
<div class="btn-group btn-group-sm">
|
||||||
<button class="btn btn-sm btn-outline-primary" [disabled]="awaitingDownload()" (click)="downloadSelected()">
|
<button class="btn btn-sm btn-outline-primary" [disabled]="awaitingDownload()" (click)="downloadSelected()" aria-label="Download" i18n-aria-label>
|
||||||
@if (!awaitingDownload()) {
|
@if (!awaitingDownload()) {
|
||||||
<i-bs name="arrow-down"></i-bs>
|
<i-bs name="arrow-down"></i-bs>
|
||||||
}
|
}
|
||||||
@@ -136,7 +141,7 @@
|
|||||||
<div class="d-none d-sm-inline ms-1"><ng-container i18n>Download</ng-container></div>
|
<div class="d-none d-sm-inline ms-1"><ng-container i18n>Download</ng-container></div>
|
||||||
</button>
|
</button>
|
||||||
<div ngbDropdown class="me-2 d-flex btn-group" role="group">
|
<div ngbDropdown class="me-2 d-flex btn-group" role="group">
|
||||||
<button type="button" class="btn btn-sm btn-outline-primary dropdown-toggle-split rounded-end" ngbDropdownToggle></button>
|
<button type="button" class="btn btn-sm btn-outline-primary dropdown-toggle-split rounded-end" ngbDropdownToggle aria-label="Download options" i18n-aria-label></button>
|
||||||
<div ngbDropdownMenu aria-labelledby="dropdownSelect" class="shadow">
|
<div ngbDropdownMenu aria-labelledby="dropdownSelect" class="shadow">
|
||||||
<form [formGroup]="downloadForm" class="px-3 py-1">
|
<form [formGroup]="downloadForm" class="px-3 py-1">
|
||||||
<p class="mb-1" i18n>Include:</p>
|
<p class="mb-1" i18n>Include:</p>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
.dropdown-toggle-split {
|
.dropdown-toggle-split {
|
||||||
--bs-border-radius: .25rem;
|
--bs-border-radius: .45rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.dropdown-menu{
|
.dropdown-menu{
|
||||||
|
|||||||
@@ -1248,6 +1248,89 @@ 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
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ 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'
|
||||||
@@ -171,6 +172,13 @@ export class BulkEditorComponent
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get userCanDelete(): boolean {
|
||||||
|
return this.permissionService.currentUserCan(
|
||||||
|
PermissionAction.Delete,
|
||||||
|
PermissionType.Document
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
ngOnInit() {
|
ngOnInit() {
|
||||||
if (
|
if (
|
||||||
this.permissionService.currentUserCan(
|
this.permissionService.currentUserCan(
|
||||||
@@ -287,14 +295,17 @@ export class BulkEditorComponent
|
|||||||
private executeDocumentAction(
|
private executeDocumentAction(
|
||||||
modal: NgbModalRef,
|
modal: NgbModalRef,
|
||||||
request: Observable<any>,
|
request: Observable<any>,
|
||||||
options: { deleteOriginals?: boolean } = {}
|
options: { clearSelection?: boolean; successMessage?: string } = {}
|
||||||
) {
|
) {
|
||||||
if (modal) {
|
if (modal) {
|
||||||
modal.componentInstance.buttonsEnabled.set(false)
|
modal.componentInstance.buttonsEnabled.set(false)
|
||||||
}
|
}
|
||||||
request.pipe(first()).subscribe({
|
request.pipe(first()).subscribe({
|
||||||
next: () => {
|
next: () => {
|
||||||
this.handleOperationSuccess(modal, options.deleteOriginals ?? false)
|
this.handleOperationSuccess(modal, options.clearSelection ?? false)
|
||||||
|
if (options.successMessage) {
|
||||||
|
this.toastService.showInfo(options.successMessage)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
error: (error) => this.handleOperationError(modal, error),
|
error: (error) => this.handleOperationError(modal, error),
|
||||||
})
|
})
|
||||||
@@ -762,6 +773,7 @@ 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) {
|
||||||
@@ -785,6 +797,7 @@ 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) {
|
||||||
@@ -806,6 +819,7 @@ 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) {
|
||||||
@@ -827,6 +841,7 @@ 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) {
|
||||||
@@ -848,6 +863,7 @@ 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() {
|
||||||
@@ -985,7 +1001,7 @@ export class BulkEditorComponent
|
|||||||
this.executeDocumentAction(
|
this.executeDocumentAction(
|
||||||
modal,
|
modal,
|
||||||
this.documentService.mergeDocuments(mergeDialog.documentIDs(), args),
|
this.documentService.mergeDocuments(mergeDialog.documentIDs(), args),
|
||||||
{ deleteOriginals: !!args.delete_originals }
|
{ clearSelection: !!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.`
|
||||||
@@ -993,6 +1009,35 @@ 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',
|
||||||
|
|||||||
+1
-1
@@ -61,7 +61,7 @@
|
|||||||
</pngx-input-textarea>
|
</pngx-input-textarea>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
<button type="button" class="btn btn-outline-danger mb-3" (click)="removeField(field.id)">
|
<button type="button" class="btn btn-outline-danger mb-3" (click)="removeField(field.id)" aria-label="Remove custom field" i18n-aria-label>
|
||||||
<i-bs name="x"></i-bs>
|
<i-bs name="x"></i-bs>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+3
-3
@@ -154,9 +154,9 @@
|
|||||||
<i-bs name="download"></i-bs>
|
<i-bs name="download"></i-bs>
|
||||||
</a>
|
</a>
|
||||||
} @else {
|
} @else {
|
||||||
<button class="btn btn-sm btn-outline-secondary placeholder bg-secondary"></button>
|
<span class="btn btn-sm btn-outline-secondary placeholder bg-secondary" aria-hidden="true"></span>
|
||||||
<button class="btn btn-sm btn-outline-secondary placeholder bg-secondary"></button>
|
<span class="btn btn-sm btn-outline-secondary placeholder bg-secondary" aria-hidden="true"></span>
|
||||||
<button class="btn btn-sm btn-outline-secondary placeholder bg-secondary"></button>
|
<span class="btn btn-sm btn-outline-secondary placeholder bg-secondary" aria-hidden="true"></span>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<pngx-page-header [title]="getTitle()">
|
<pngx-page-header [title]="getTitle()">
|
||||||
<div ngbDropdown class="btn-group flex-fill d-sm-none">
|
<div ngbDropdown class="btn-group flex-fill d-sm-none">
|
||||||
<button class="btn btn-sm btn-outline-primary" id="dropdownSelectMobile" ngbDropdownToggle>
|
<button class="btn btn-sm btn-outline-primary" id="dropdownSelectMobile" ngbDropdownToggle aria-label="Select" i18n-aria-label>
|
||||||
<i-bs name="text-indent-left"></i-bs><div class="d-none d-sm-inline ms-1"><ng-container i18n>Select</ng-container></div>
|
<i-bs name="text-indent-left"></i-bs><div class="d-none d-sm-inline ms-1"><ng-container i18n>Select</ng-container></div>
|
||||||
@if (list.hasSelection) {
|
@if (list.hasSelection) {
|
||||||
<pngx-clearable-badge [selected]="list.hasSelection" [number]="list.selectedCount" (cleared)="list.selectNone()"></pngx-clearable-badge><span class="visually-hidden">selected</span>
|
<pngx-clearable-badge [selected]="list.hasSelection" [number]="list.selectedCount" (cleared)="list.selectNone()"></pngx-clearable-badge><span class="visually-hidden">selected</span>
|
||||||
@@ -31,7 +31,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div ngbDropdown class="btn-group flex-fill">
|
<div ngbDropdown class="btn-group flex-fill">
|
||||||
<button class="btn btn-sm btn-outline-primary" id="dropdownDisplayFields" ngbDropdownToggle>
|
<button class="btn btn-sm btn-outline-primary" id="dropdownDisplayFields" ngbDropdownToggle aria-label="Show" i18n-aria-label>
|
||||||
<i-bs name="card-heading"></i-bs><div class="d-none d-sm-inline ms-1"><ng-container i18n>Show</ng-container></div>
|
<i-bs name="card-heading"></i-bs><div class="d-none d-sm-inline ms-1"><ng-container i18n>Show</ng-container></div>
|
||||||
</button>
|
</button>
|
||||||
<div ngbDropdownMenu aria-labelledby="dropdownDisplayFields" class="shadow">
|
<div ngbDropdownMenu aria-labelledby="dropdownDisplayFields" class="shadow">
|
||||||
@@ -61,7 +61,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div ngbDropdown class="btn-group flex-fill">
|
<div ngbDropdown class="btn-group flex-fill">
|
||||||
<button class="btn btn-outline-primary btn-sm" id="dropdownBasic1" ngbDropdownToggle>
|
<button class="btn btn-outline-primary btn-sm" id="dropdownBasic1" ngbDropdownToggle aria-label="Sort" i18n-aria-label>
|
||||||
<i-bs name="arrow-down-up"></i-bs><div class="d-none d-sm-inline ms-1"><ng-container i18n>Sort</ng-container></div>
|
<i-bs name="arrow-down-up"></i-bs><div class="d-none d-sm-inline ms-1"><ng-container i18n>Sort</ng-container></div>
|
||||||
</button>
|
</button>
|
||||||
<div ngbDropdownMenu aria-labelledby="dropdownBasic1" class="shadow dropdown-menu-right">
|
<div ngbDropdownMenu aria-labelledby="dropdownBasic1" class="shadow dropdown-menu-right">
|
||||||
@@ -85,8 +85,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="btn-group flex-fill" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.SavedView }" ngbDropdown role="group">
|
<div class="btn-group flex-fill" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.SavedView }" ngbDropdown #viewsDropdown="ngbDropdown" role="group">
|
||||||
<button class="btn btn-sm btn-outline-primary dropdown-toggle flex-fill" tourAnchor="tour.documents-views" ngbDropdownToggle>
|
<button class="btn btn-sm btn-outline-primary dropdown-toggle flex-fill" tourAnchor="tour.documents-views" ngbDropdownToggle aria-label="Views" i18n-aria-label>
|
||||||
<i-bs name="window-stack"></i-bs><div class="d-none d-sm-inline ms-1"><ng-container i18n>Views</ng-container></div>
|
<i-bs name="window-stack"></i-bs><div class="d-none d-sm-inline ms-1"><ng-container i18n>Views</ng-container></div>
|
||||||
@if (savedViewIsModified) {
|
@if (savedViewIsModified) {
|
||||||
<div class="position-absolute top-0 start-100 p-2 translate-middle badge bg-secondary border border-light rounded-circle">
|
<div class="position-absolute top-0 start-100 p-2 translate-middle badge bg-secondary border border-light rounded-circle">
|
||||||
@@ -95,15 +95,15 @@
|
|||||||
}
|
}
|
||||||
</button>
|
</button>
|
||||||
<div class="dropdown-menu shadow dropdown-menu-right" ngbDropdownMenu>
|
<div class="dropdown-menu shadow dropdown-menu-right" ngbDropdownMenu>
|
||||||
@if (!list.activeSavedViewId) {
|
@if (viewsDropdown.isOpen() && !list.activeSavedViewId && savedViewService.allViews.length > 0) {
|
||||||
@for (view of savedViewService.allViews; track view) {
|
<div class="views-list overflow-y-auto">
|
||||||
<button ngbDropdownItem (click)="loadViewConfig(view.id)">
|
@for (view of savedViewService.allViews; track view.id) {
|
||||||
<i-bs class="me-2" [name]="view.icon || 'funnel'"></i-bs>{{view.name}}
|
<button ngbDropdownItem (click)="loadViewConfig(view.id)">
|
||||||
</button>
|
<i-bs class="me-2" [name]="view.icon || 'funnel'"></i-bs>{{view.name}}
|
||||||
}
|
</button>
|
||||||
@if (savedViewService.allViews.length > 0) {
|
}
|
||||||
<div class="dropdown-divider"></div>
|
</div>
|
||||||
}
|
<div class="dropdown-divider"></div>
|
||||||
}
|
}
|
||||||
|
|
||||||
@if (list.activeSavedViewId && activeSavedViewCanChange) {
|
@if (list.activeSavedViewId && activeSavedViewCanChange) {
|
||||||
@@ -157,7 +157,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
|
|
||||||
<div tourAnchor="tour.documents">
|
<div class="mt-3 mb-n2" tourAnchor="tour.documents">
|
||||||
<ng-container *ngTemplateOutlet="pagination"></ng-container>
|
<ng-container *ngTemplateOutlet="pagination"></ng-container>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -56,17 +56,24 @@ $paperless-card-breakpoints: (
|
|||||||
|
|
||||||
.sticky-top {
|
.sticky-top {
|
||||||
z-index: 990; // below main navbar
|
z-index: 990; // below main navbar
|
||||||
top: calc(7rem - 2px); // height of navbar + search row (mobile)
|
top: calc(7.5rem - 2px); // height of navbar + search row (mobile)
|
||||||
transition: top 0.2s ease;
|
transition: top 0.2s ease;
|
||||||
|
|
||||||
@media (min-width: 580px) {
|
@media (min-width: 580px) {
|
||||||
top: 3.5rem; // height of navbar
|
top: 4.5em; // height of navbar
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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(4rem - 2px); // height of navbar only when search is hidden
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,4 +93,8 @@ a {
|
|||||||
|
|
||||||
pngx-page-header .dropdown-menu {
|
pngx-page-header .dropdown-menu {
|
||||||
--bs-dropdown-min-width: 12em;
|
--bs-dropdown-min-width: 12em;
|
||||||
|
|
||||||
|
.views-list {
|
||||||
|
max-height: min(400px, calc(100vh - 260px)); // leave room for the header above and the actions below
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
</select>
|
</select>
|
||||||
}
|
}
|
||||||
@if (_textFilter) {
|
@if (_textFilter) {
|
||||||
<button class="btn btn-link btn-sm px-2 position-absolute top-0 end-0 z-10" (click)="resetTextField()">
|
<button class="btn btn-link btn-sm px-2 position-absolute top-0 end-0 z-10" (click)="resetTextField()" aria-label="Clear search" i18n-aria-label>
|
||||||
<i-bs width="1em" height="1em" name="x"></i-bs>
|
<i-bs width="1em" height="1em" name="x"></i-bs>
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
|
|||||||
+38
-36
@@ -21,45 +21,47 @@
|
|||||||
<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-group d-block d-sm-none">
|
<div class="btn-toolbar gap-2">
|
||||||
<div ngbDropdown container="body" class="d-inline-block">
|
<div class="btn-group d-block d-sm-none">
|
||||||
<button type="button" class="btn btn-link" id="actionsMenuMobile" (click)="$event.stopPropagation()" ngbDropdownToggle>
|
<div ngbDropdown container="body" class="d-inline-block">
|
||||||
<i-bs name="three-dots-vertical"></i-bs>
|
<button type="button" class="btn btn-link" id="actionsMenuMobile" (click)="$event.stopPropagation()" ngbDropdownToggle aria-label="Actions" i18n-aria-label>
|
||||||
</button>
|
<i-bs name="three-dots-vertical"></i-bs>
|
||||||
<div ngbDropdownMenu aria-labelledby="actionsMenuMobile">
|
</button>
|
||||||
<button (click)="editField(field)" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.CustomField }" ngbDropdownItem i18n>Edit</button>
|
<div ngbDropdownMenu aria-labelledby="actionsMenuMobile">
|
||||||
<button class="text-danger" (click)="deleteField(field)" *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.CustomField }" ngbDropdownItem i18n>Delete</button>
|
<button (click)="editField(field)" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.CustomField }" ngbDropdownItem i18n>Edit</button>
|
||||||
@if (field.document_count > 0) {
|
<button class="text-danger" (click)="deleteField(field)" *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.CustomField }" ngbDropdownItem i18n>Delete</button>
|
||||||
<a
|
@if (field.document_count > 0) {
|
||||||
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }"
|
<a
|
||||||
ngbDropdownItem
|
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }"
|
||||||
[routerLink]="getDocumentFilterUrl(field)"
|
ngbDropdownItem
|
||||||
i18n
|
[routerLink]="getDocumentFilterUrl(field)"
|
||||||
>Filter Documents ({{ field.document_count }})</a
|
i18n
|
||||||
>
|
>Filter Documents ({{ field.document_count }})</a
|
||||||
}
|
>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div class="btn-group d-none d-sm-inline-block">
|
||||||
<div class="btn-group d-none d-sm-inline-block">
|
<button *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.CustomField }" class="btn btn-sm btn-outline-secondary" type="button" (click)="editField(field)">
|
||||||
<button *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.CustomField }" class="btn btn-sm btn-outline-secondary" type="button" (click)="editField(field)">
|
<i-bs width="1em" height="1em" name="pencil" class="me-1"></i-bs><ng-container i18n>Edit</ng-container>
|
||||||
<i-bs width="1em" height="1em" name="pencil" class="me-1"></i-bs><ng-container i18n>Edit</ng-container>
|
</button>
|
||||||
</button>
|
<button *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.CustomField }" class="btn btn-sm btn-outline-danger" type="button" (click)="deleteField(field)">
|
||||||
<button *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.CustomField }" class="btn btn-sm btn-outline-danger" type="button" (click)="deleteField(field)">
|
<i-bs width="1em" height="1em" name="trash" class="me-1"></i-bs><ng-container i18n>Delete</ng-container>
|
||||||
<i-bs width="1em" height="1em" name="trash" class="me-1"></i-bs><ng-container i18n>Delete</ng-container>
|
</button>
|
||||||
</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>
|
||||||
}
|
@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>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
+2
-2
@@ -7,7 +7,7 @@
|
|||||||
>
|
>
|
||||||
@if (activeManagementList) {
|
@if (activeManagementList) {
|
||||||
<div ngbDropdown class="btn-group flex-fill d-sm-none">
|
<div ngbDropdown class="btn-group flex-fill d-sm-none">
|
||||||
<button class="btn btn-sm btn-outline-primary" id="dropdownSelectMobile" ngbDropdownToggle>
|
<button class="btn btn-sm btn-outline-primary" id="dropdownSelectMobile" ngbDropdownToggle aria-label="Select" i18n-aria-label>
|
||||||
<i-bs name="text-indent-left"></i-bs><div class="d-none d-sm-inline ms-1"><ng-container i18n>Select</ng-container></div>
|
<i-bs name="text-indent-left"></i-bs><div class="d-none d-sm-inline ms-1"><ng-container i18n>Select</ng-container></div>
|
||||||
@if (activeManagementList.hasSelection) {
|
@if (activeManagementList.hasSelection) {
|
||||||
<pngx-clearable-badge [selected]="activeManagementList.hasSelection" [number]="activeManagementList.selectedCount" (cleared)="activeManagementList.selectNone()"></pngx-clearable-badge><span class="visually-hidden">selected</span>
|
<pngx-clearable-badge [selected]="activeManagementList.hasSelection" [number]="activeManagementList.selectedCount" (cleared)="activeManagementList.selectNone()"></pngx-clearable-badge><span class="visually-hidden">selected</span>
|
||||||
@@ -69,7 +69,7 @@
|
|||||||
}
|
}
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
<div class="my-3 shadow-sm">
|
<div class="my-3">
|
||||||
<ng-container
|
<ng-container
|
||||||
[ngComponentOutlet]="activeSection?.component"
|
[ngComponentOutlet]="activeSection?.component"
|
||||||
#activeOutlet="ngComponentOutlet"
|
#activeOutlet="ngComponentOutlet"
|
||||||
|
|||||||
+3
-3
@@ -1,7 +1,7 @@
|
|||||||
<div class="row mb-3">
|
<div class="row mb-3">
|
||||||
<div class="col mb-2 mb-xl-0">
|
<div class="col mb-2 mb-xl-0">
|
||||||
<div class="form-inline d-flex align-items-center">
|
<div class="form-inline d-flex align-items-center">
|
||||||
<label class="text-muted me-2 mb-0" for="managementNameFilter" i18n>Filter by:</label>
|
<label class="me-2 mb-0" for="managementNameFilter" i18n>Filter by:</label>
|
||||||
<input id="managementNameFilter" class="form-control form-control-sm flex-fill w-auto" type="text" autofocus [(ngModel)]="nameFilter" (keyup)="onNameFilterKeyUp($event)" placeholder="Name" i18n-placeholder>
|
<input id="managementNameFilter" class="form-control form-control-sm flex-fill w-auto" type="text" autofocus [(ngModel)]="nameFilter" (keyup)="onNameFilterKeyUp($event)" placeholder="Name" i18n-placeholder>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -9,7 +9,7 @@
|
|||||||
<div class="col-auto mb-2 mb-xl-0">
|
<div class="col-auto mb-2 mb-xl-0">
|
||||||
<div class="form-inline d-flex align-items-center">
|
<div class="form-inline d-flex align-items-center">
|
||||||
<div class="input-group input-group-sm w-auto d-none d-md-flex">
|
<div class="input-group input-group-sm w-auto d-none d-md-flex">
|
||||||
<label class="input-group-text border-0" for="managementPageSize" i18n>Show:</label>
|
<label class="input-group-text bg-transparent border-0" for="managementPageSize" i18n>Show:</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="input-group input-group-sm w-auto me-3">
|
<div class="input-group input-group-sm w-auto me-3">
|
||||||
<select id="managementPageSize" class="form-select form-select-sm small" [(ngModel)]="pageSize">
|
<select id="managementPageSize" class="form-select form-select-sm small" [(ngModel)]="pageSize">
|
||||||
@@ -113,7 +113,7 @@
|
|||||||
<div class="btn-toolbar gap-2">
|
<div class="btn-toolbar gap-2">
|
||||||
<div class="btn-group d-block d-sm-none">
|
<div class="btn-group d-block d-sm-none">
|
||||||
<div ngbDropdown container="body" class="d-inline-block">
|
<div ngbDropdown container="body" class="d-inline-block">
|
||||||
<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 aria-label="Actions" i18n-aria-label>
|
||||||
<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">
|
||||||
|
|||||||
@@ -58,7 +58,7 @@
|
|||||||
<div class="col">
|
<div class="col">
|
||||||
<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 aria-label="Actions" i18n-aria-label>
|
||||||
<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">
|
||||||
@@ -146,7 +146,7 @@
|
|||||||
<div class="col-3">
|
<div class="col-3">
|
||||||
<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 aria-label="Actions" i18n-aria-label>
|
||||||
<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">
|
||||||
|
|||||||
@@ -47,7 +47,7 @@
|
|||||||
|
|
||||||
<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 aria-label="Actions" i18n-aria-label>
|
||||||
<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">
|
||||||
|
|||||||
@@ -316,6 +316,34 @@ 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,6 +374,18 @@ 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,
|
||||||
|
|||||||
@@ -438,6 +438,21 @@ describe('ConsumerStatusService', () => {
|
|||||||
expect(updated).toBeTruthy()
|
expect(updated).toBeTruthy()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should ignore keep-alive heartbeat messages from the server', () => {
|
||||||
|
let updated = false
|
||||||
|
let deleted = false
|
||||||
|
websocketStatusService.onDocumentUpdated().subscribe(() => (updated = true))
|
||||||
|
websocketStatusService.onDocumentDeleted().subscribe(() => (deleted = true))
|
||||||
|
|
||||||
|
websocketStatusService.connect()
|
||||||
|
server.send({ type: WebsocketStatusType.HEARTBEAT })
|
||||||
|
|
||||||
|
expect(updated).toBeFalsy()
|
||||||
|
expect(deleted).toBeFalsy()
|
||||||
|
expect(websocketStatusService.getConsumerStatus()).toHaveLength(0)
|
||||||
|
websocketStatusService.disconnect()
|
||||||
|
})
|
||||||
|
|
||||||
it('should ignore document updated events the user cannot view', () => {
|
it('should ignore document updated events the user cannot view', () => {
|
||||||
let updated = false
|
let updated = false
|
||||||
websocketStatusService.onDocumentUpdated().subscribe(() => {
|
websocketStatusService.onDocumentUpdated().subscribe(() => {
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export enum WebsocketStatusType {
|
|||||||
STATUS_UPDATE = 'status_update',
|
STATUS_UPDATE = 'status_update',
|
||||||
DOCUMENTS_DELETED = 'documents_deleted',
|
DOCUMENTS_DELETED = 'documents_deleted',
|
||||||
DOCUMENT_UPDATED = 'document_updated',
|
DOCUMENT_UPDATED = 'document_updated',
|
||||||
|
HEARTBEAT = 'heartbeat',
|
||||||
}
|
}
|
||||||
|
|
||||||
// see ProgressStatusOptions in src/documents/plugins/helpers.py
|
// see ProgressStatusOptions in src/documents/plugins/helpers.py
|
||||||
@@ -207,6 +208,10 @@ export class WebsocketStatusService {
|
|||||||
case WebsocketStatusType.STATUS_UPDATE:
|
case WebsocketStatusType.STATUS_UPDATE:
|
||||||
this.handleProgressUpdate(messageData as WebsocketProgressMessage)
|
this.handleProgressUpdate(messageData as WebsocketProgressMessage)
|
||||||
break
|
break
|
||||||
|
|
||||||
|
case WebsocketStatusType.HEARTBEAT:
|
||||||
|
// keep-alive from the server, see paperless.consumers.StatusConsumer
|
||||||
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -115,6 +115,7 @@ import {
|
|||||||
house,
|
house,
|
||||||
inbox,
|
inbox,
|
||||||
infoCircle,
|
infoCircle,
|
||||||
|
journalBookmarkFill,
|
||||||
journals,
|
journals,
|
||||||
link,
|
link,
|
||||||
listNested,
|
listNested,
|
||||||
@@ -361,6 +362,7 @@ const icons = {
|
|||||||
house,
|
house,
|
||||||
inbox,
|
inbox,
|
||||||
infoCircle,
|
infoCircle,
|
||||||
|
journalBookmarkFill,
|
||||||
journals,
|
journals,
|
||||||
link,
|
link,
|
||||||
listNested,
|
listNested,
|
||||||
|
|||||||
+151
-28
@@ -1,7 +1,7 @@
|
|||||||
@use "sass:meta";
|
@use "sass:meta";
|
||||||
// bs variables
|
// bs variables
|
||||||
$grid-gutter-width: 1.5rem;
|
$grid-gutter-width: 1.5rem;
|
||||||
$border-radius: .375rem;
|
$border-radius: .425rem;
|
||||||
$btn-border-width: var(--bs-border-width);
|
$btn-border-width: var(--bs-border-width);
|
||||||
|
|
||||||
$form-file-button-bg: var(--bs-body-bg);
|
$form-file-button-bg: var(--bs-body-bg);
|
||||||
@@ -68,15 +68,44 @@ body {
|
|||||||
--pngx-body-font-size: 0.875rem;
|
--pngx-body-font-size: 0.875rem;
|
||||||
font-size: var(--pngx-body-font-size);
|
font-size: var(--pngx-body-font-size);
|
||||||
height: 100vh;
|
height: 100vh;
|
||||||
|
letter-spacing: -0.005em;
|
||||||
}
|
}
|
||||||
|
|
||||||
* {
|
* {
|
||||||
transition: background-color 0.3s ease, border-color 0.3s ease;
|
transition: background-color 0.3s ease, border-color 0.3s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.card,
|
||||||
|
.dropdown-menu,
|
||||||
|
.modal-content,
|
||||||
|
.popover {
|
||||||
|
--bs-card-border-color: color-mix(in srgb, var(--bs-border-color) 72%, transparent);
|
||||||
|
border-radius: .55rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
box-shadow: 0 1px 2px rgba(0, 0, 0, .04), 0 8px 24px rgba(0, 0, 0, .035);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
--bs-btn-border-radius: .425rem;
|
||||||
|
--bs-border-radius-sm: .425rem;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-control,
|
||||||
|
.form-select,
|
||||||
|
.input-group-text {
|
||||||
|
border-radius: .425rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pagination, .input-group {
|
||||||
|
--bs-border-radius-sm: .425rem;
|
||||||
|
}
|
||||||
|
|
||||||
@media(min-width: 768px) {
|
@media(min-width: 768px) {
|
||||||
.col-slim {
|
.col-slim {
|
||||||
padding-left: calc(50px + $grid-gutter-width) !important;
|
padding-left: calc(56px + $grid-gutter-width) !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -127,6 +156,27 @@ 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);
|
||||||
@@ -213,7 +263,8 @@ table .btn-link {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.form-switch .form-check-input:focus {
|
.form-switch .form-check-input:focus {
|
||||||
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>"));
|
// neutral knob in place of bootstrap's blue, which clashes with the theme colour
|
||||||
|
--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 {
|
||||||
@@ -269,33 +320,44 @@ 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);
|
||||||
}
|
}
|
||||||
|
|
||||||
.asc {
|
// Sortable table headers
|
||||||
background-color: #f8f9fa!important;
|
th[pngxSortable] {
|
||||||
}
|
cursor: pointer;
|
||||||
|
user-select: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
|
||||||
.asc:after {
|
&::after {
|
||||||
content: '';
|
content: '';
|
||||||
transform: rotate(180deg);
|
display: inline-block;
|
||||||
background: url("data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAABACAYAAACqaXHeAAAAAXNSR0IArs4c6QAAAmxJREFUeAHtmksrRVEUx72fH8CIGQNJkpGUUmakDEiZSJRIZsRQmCkTJRmZmJgQE0kpX0D5DJKJgff7v+ru2u3O3vvc67TOvsdatdrnnP1Y///v7HvvubdbUiIhBISAEBACQkAICAEhIAQ4CXSh2DnyDfmCPEG2Iv9F9MPlM/LHyAecdyMzHYNwR3fdNK/OH9HXl1UCozD24TCvILxizEDWIEzA0FcM8woCgRrJCoS5PIwrANQSMAJX1LEI9bqpQo4JYNFFKRSvIgsxHDVnqZgIkPnNBM0rIGtYk9YOOsqgbgepRCfdbmFtqhFkVEDVPjJp0+Z6e6hRHhqBKgg6ZDCvYBygVmUoEGoh5JTRvIJwhJo1aUOoh4CLPMyvxxi7EWOMgnCGsXXI1GIXlZUYX7ucU+kbR8NW8lh3O7cue0Pk32MKndfUxQFAwxdirk3fHappAnc0oqDPzDfGTBrCfHP04dM4oTV8cxr0SVzH9FF07xD3ib6xCDE+M+aUcVygtWzzbtGX2rPBrEUYfecfQkaFzYi6HjVnGBdtL7epqAlc1+jRdAap74RrnPc4BCijttY2tRcdN0g17w7HqZrXhdJTYAuS3hd8z+vKgK3V1zWPae0mZDMykadBn1hTQBLnZNwVrJpSe/NwEeDsEwCctEOsJTsgxLvCqUl2ACftEGvJDgjxrnBqkh3ASTvEWrIDQrwrnJpkB3DSDrGW7IAQ7wqnJtkBnLRztejXXVu4+mxz/nQ9jR1w5VB86ejLTFcnnDwhzV+F6T+CHZlx6THSjn76eyyBIOPHyDakhBAQAkJACAgBISAEhIAQYCLwC8JxpAmsEGt6AAAAAElFTkSuQmCC") no-repeat;
|
vertical-align: -0.15em;
|
||||||
height: 1rem;
|
width: 0.8rem;
|
||||||
width: 1rem;
|
height: 0.8rem;
|
||||||
display: block;
|
margin-left: 0.25rem;
|
||||||
background-size: 1rem;
|
// chevron, matched to the bootstrap-icons set used elsewhere in the app
|
||||||
float: right;
|
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,
|
||||||
.des {
|
.des {
|
||||||
background-color: #f8f9fa!important;
|
color: var(--bs-primary);
|
||||||
|
--bs-table-color-state: var(--bs-primary); // bootstrap sets cell color at higher specificity
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
opacity: 1 !important;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.des:after {
|
.asc::after {
|
||||||
content: '';
|
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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.paperless-input-select {
|
.paperless-input-select {
|
||||||
@@ -564,10 +626,6 @@ 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 {
|
||||||
@@ -578,10 +636,63 @@ 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);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.alert {
|
||||||
|
--bs-border-radius: .425rem;
|
||||||
|
}
|
||||||
|
|
||||||
.alert-primary {
|
.alert-primary {
|
||||||
--bs-alert-color: var(--bs-primary);
|
--bs-alert-color: var(--bs-primary);
|
||||||
--bs-alert-bg: var(--pngx-primary-faded);
|
--bs-alert-bg: var(--pngx-primary-faded);
|
||||||
@@ -713,6 +824,8 @@ code {
|
|||||||
--bs-accordion-bg: var(--bs-light);
|
--bs-accordion-bg: var(--bs-light);
|
||||||
--bs-accordion-active-color: var(--bs-primary);
|
--bs-accordion-active-color: var(--bs-primary);
|
||||||
--bs-accordion-active-bg: var(--pngx-bg-alt);
|
--bs-accordion-active-bg: var(--pngx-bg-alt);
|
||||||
|
--bs-border-radius: .425rem;
|
||||||
|
--bs-accordion-inner-border-radius: calc(.425rem - 1px);
|
||||||
}
|
}
|
||||||
|
|
||||||
.accordion-button::after {
|
.accordion-button::after {
|
||||||
@@ -776,6 +889,16 @@ 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;
|
||||||
|
|||||||
+23
-15
@@ -20,9 +20,10 @@
|
|||||||
--pngx-primary-darken-27: hsl(var(--pngx-primary), calc(var(--pngx-primary-lightness) - 27%));
|
--pngx-primary-darken-27: hsl(var(--pngx-primary), calc(var(--pngx-primary-lightness) - 27%));
|
||||||
--pngx-success-darken-10: hsl(152, 69%, 11%); // based on success #198754
|
--pngx-success-darken-10: hsl(152, 69%, 11%); // based on success #198754
|
||||||
--pngx-bg-alt: #fff;
|
--pngx-bg-alt: #fff;
|
||||||
--pngx-bg-darker: var(--bs-gray-100);
|
--pngx-bg-darker: #f4f6f5;
|
||||||
--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);
|
||||||
@@ -36,20 +37,22 @@
|
|||||||
$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%);
|
||||||
// Taken from bootstrap
|
// url-encoded $text-color-light-bg
|
||||||
$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>");
|
$text-color-light-bg-esc: "%23212529";
|
||||||
$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>");
|
// Taken from bootstrap, pre-encoded
|
||||||
|
$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] {
|
||||||
background-image: escape-svg($form-check-input-checked-bg-image-dark);
|
--bs-form-check-bg-image: #{$form-check-input-checked-bg-image-dark};
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-check-input:checked[type=radio] {
|
.form-check-input:checked[type=radio] {
|
||||||
background-image: escape-svg($form-check-radio-checked-bg-image-dark);
|
--bs-form-check-bg-image: #{$form-check-radio-checked-bg-image-dark};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -66,6 +69,17 @@ $form-check-radio-checked-bg-image-dark: url("data:image/svg+xml,<svg xmlns='htt
|
|||||||
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%;
|
||||||
@@ -77,8 +91,9 @@ $form-check-radio-checked-bg-image-dark: url("data:image/svg+xml,<svg xmlns='htt
|
|||||||
--pngx-body-color-accent: #{$text-color-dark-bg-accent};
|
--pngx-body-color-accent: #{$text-color-dark-bg-accent};
|
||||||
--pngx-bg-alt: #242529;
|
--pngx-bg-alt: #242529;
|
||||||
--pngx-bg-alt2: #232323;
|
--pngx-bg-alt2: #232323;
|
||||||
--pngx-bg-darker: #101216;
|
--pngx-bg-darker: #121315;
|
||||||
--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);
|
||||||
@@ -92,7 +107,7 @@ $form-check-radio-checked-bg-image-dark: url("data:image/svg+xml,<svg xmlns='htt
|
|||||||
--bs-light-rgb: 28, 28, 31;
|
--bs-light-rgb: 28, 28, 31;
|
||||||
--bs-info: var(--pngx-bg-alt);
|
--bs-info: var(--pngx-bg-alt);
|
||||||
--bs-info-rgb: 36, 36, 39;
|
--bs-info-rgb: 36, 36, 39;
|
||||||
--bs-border-color: #47494f;
|
--bs-border-color: #34373d;
|
||||||
--bs-tertiary-bg: var(--pngx-bg-darker);
|
--bs-tertiary-bg: var(--pngx-bg-darker);
|
||||||
--bs-dark-border-subtle: var(--pngx-bg-darker);
|
--bs-dark-border-subtle: var(--pngx-bg-darker);
|
||||||
--bs-border-color-translucent: rgba(0, 0, 0, .175); // override bs
|
--bs-border-color-translucent: rgba(0, 0, 0, .175); // override bs
|
||||||
@@ -238,13 +253,6 @@ $form-check-radio-checked-bg-image-dark: url("data:image/svg+xml,<svg xmlns='htt
|
|||||||
}
|
}
|
||||||
|
|
||||||
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);
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user