Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8124fc835f | ||
|
|
f92c861e2d | ||
|
|
1ba1f2b9c2 | ||
|
|
07f1a356f8 | ||
|
|
8d41d31bd7 | ||
|
|
351892bbab | ||
|
|
5d6ea11828 | ||
|
|
c5765a50a1 | ||
|
|
c2a9532b8f | ||
|
|
713c857a08 | ||
|
|
912c6eb52e | ||
|
|
73ef14f37a | ||
|
|
d78754bff1 | ||
|
|
5c5b1ee6b5 | ||
|
|
08f2f4bfe2 | ||
|
|
f993462973 | ||
|
|
ae70b8d60f | ||
|
|
38db6b51db | ||
|
|
31e9f4272c | ||
|
|
b8659c1af3 | ||
|
|
741115b36b | ||
|
|
1211db5cbb | ||
|
|
ca98dffbd2 | ||
|
|
4db1451e41 | ||
|
|
624b7911e5 | ||
|
|
5a1a5333ad | ||
|
|
bfe8213b78 | ||
|
|
85e192f935 | ||
|
|
4fcd4961bb | ||
|
|
5d9401ac4a | ||
|
|
6935defe7c | ||
|
|
440049978b | ||
|
|
06e9c1c02b | ||
|
|
40d09ef309 | ||
|
|
a4499dc9c1 | ||
|
|
ba017ce5b8 | ||
|
|
fd543f2bff | ||
|
|
e0e060f089 | ||
|
|
a7ff3a8272 | ||
|
|
b20d707f2a | ||
|
|
fa6e481224 | ||
|
|
aba32fbd57 | ||
|
|
d371085699 | ||
|
|
14b6a41b88 | ||
|
|
8af3e69084 | ||
|
|
1d23a9550c | ||
|
|
b89fb0f978 | ||
|
|
535975e2fd | ||
|
|
9c475e0b27 | ||
|
|
7ddc1c9801 | ||
|
|
6f3945f11f | ||
|
|
a784a642ee | ||
|
|
8ded82ea23 | ||
|
|
3a5312ba6f | ||
|
|
00d9bf474a | ||
|
|
8079690f33 | ||
|
|
c6252a0234 | ||
|
|
4a69c47bdd | ||
|
|
d30ee1d620 | ||
|
|
e1aa9997d7 | ||
|
|
05917a04aa |
@@ -185,22 +185,16 @@ jobs:
|
||||
flags: frontend-node-${{ matrix.node-version }}
|
||||
directory: src-ui/coverage/
|
||||
e2e-tests:
|
||||
name: "E2E Tests (${{ matrix.shard-index }}/${{ matrix.shard-count }})"
|
||||
name: E2E Tests
|
||||
needs: [changes, install-dependencies]
|
||||
if: needs.changes.outputs.frontend_changed == 'true'
|
||||
runs-on: ubuntu-24.04
|
||||
permissions:
|
||||
contents: read
|
||||
container: mcr.microsoft.com/playwright:v1.62.0-noble
|
||||
container: mcr.microsoft.com/playwright:v1.62.1-noble
|
||||
env:
|
||||
PLAYWRIGHT_BROWSERS_PATH: /ms-playwright
|
||||
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
node-version: [24.x]
|
||||
shard-index: [1, 2]
|
||||
shard-count: [2]
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
@@ -216,6 +210,17 @@ jobs:
|
||||
node-version: 24.x
|
||||
cache: 'pnpm'
|
||||
cache-dependency-path: 'src-ui/pnpm-lock.yaml'
|
||||
- name: Set up Python
|
||||
id: setup-python
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
with:
|
||||
python-version: '3.12'
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
with:
|
||||
version: '0.12.x'
|
||||
enable-cache: false
|
||||
python-version: ${{ steps.setup-python.outputs.python-version }}
|
||||
- name: Cache frontend dependencies
|
||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||
with:
|
||||
@@ -225,8 +230,17 @@ jobs:
|
||||
key: ${{ runner.os }}-frontend-${{ hashFiles('src-ui/pnpm-lock.yaml') }}
|
||||
- name: Install dependencies
|
||||
run: cd src-ui && pnpm install --frozen-lockfile
|
||||
- name: Install backend system dependencies
|
||||
run: |
|
||||
apt-get update
|
||||
apt-get install --yes --quiet --no-install-recommends libmagic1
|
||||
- name: Install backend dependencies
|
||||
env:
|
||||
PYTHON_VERSION: ${{ steps.setup-python.outputs.python-version }}
|
||||
# The frozen repository lockfile and its build hooks are trusted.
|
||||
run: uv sync --python "${PYTHON_VERSION}" --no-dev --frozen # NOSONAR
|
||||
- name: Run Playwright E2E tests
|
||||
run: cd src-ui && pnpm exec playwright test --shard ${{ matrix.shard-index }}/${{ matrix.shard-count }}
|
||||
run: cd src-ui && pnpm exec playwright test
|
||||
frontend-build:
|
||||
name: Frontend Build
|
||||
needs: [changes, unit-tests, e2e-tests]
|
||||
|
||||
@@ -138,7 +138,9 @@ for suggested generation and embedding models.
|
||||
With AI enabled, Paperless-ngx can suggest a title, tags, correspondent, document type,
|
||||
storage path and dates by sending the document to the LLM. This is **opt-in per request**
|
||||
and surfaces through the "Suggest" control on the document detail page, alongside the
|
||||
classic classifier-based suggestions — it does not disable them. Suggestion output
|
||||
classic classifier-based suggestions — it does not disable them. Suggestions are requested
|
||||
automatically when you open a document that carries an inbox tag unless "Automatically request
|
||||
suggestions for inbox documents" under Settings > Documents is disabled. Suggestion output
|
||||
language can be steered with
|
||||
[`PAPERLESS_AI_LLM_OUTPUT_LANGUAGE`](configuration.md#PAPERLESS_AI_LLM_OUTPUT_LANGUAGE)
|
||||
(otherwise it follows the user's UI language).
|
||||
|
||||
|
Before Width: | Height: | Size: 1.8 MiB After Width: | Height: | Size: 1.4 MiB |
|
Before Width: | Height: | Size: 501 KiB After Width: | Height: | Size: 487 KiB |
|
Before Width: | Height: | Size: 21 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 2.2 MiB After Width: | Height: | Size: 558 KiB |
|
Before Width: | Height: | Size: 644 KiB After Width: | Height: | Size: 1.1 MiB |
|
Before Width: | Height: | Size: 667 KiB After Width: | Height: | Size: 1.4 MiB |
|
Before Width: | Height: | Size: 1003 KiB After Width: | Height: | Size: 1.2 MiB |
|
Before Width: | Height: | Size: 1.7 MiB After Width: | Height: | Size: 1.4 MiB |
|
Before Width: | Height: | Size: 2.1 MiB After Width: | Height: | Size: 1.5 MiB |
|
Before Width: | Height: | Size: 1.8 MiB After Width: | Height: | Size: 1.4 MiB |
|
Before Width: | Height: | Size: 925 KiB After Width: | Height: | Size: 972 KiB |
|
Before Width: | Height: | Size: 1.7 MiB After Width: | Height: | Size: 1.4 MiB |
|
Before Width: | Height: | Size: 1.8 MiB After Width: | Height: | Size: 1.4 MiB |
|
Before Width: | Height: | Size: 2.3 MiB After Width: | Height: | Size: 558 KiB |
|
Before Width: | Height: | Size: 726 KiB After Width: | Height: | Size: 1.3 MiB |
|
Before Width: | Height: | Size: 169 KiB After Width: | Height: | Size: 294 KiB |
|
Before Width: | Height: | Size: 432 KiB After Width: | Height: | Size: 298 KiB |
|
Before Width: | Height: | Size: 280 KiB After Width: | Height: | Size: 322 KiB |
|
Before Width: | Height: | Size: 246 KiB After Width: | Height: | Size: 205 KiB |
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 43 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 44 KiB |
|
Before Width: | Height: | Size: 48 KiB After Width: | Height: | Size: 57 KiB |
|
Before Width: | Height: | Size: 45 KiB After Width: | Height: | Size: 65 KiB |
|
Before Width: | Height: | Size: 559 KiB After Width: | Height: | Size: 516 KiB |
|
Before Width: | Height: | Size: 116 KiB After Width: | Height: | Size: 230 KiB |
|
Before Width: | Height: | Size: 87 KiB After Width: | Height: | Size: 333 KiB |
|
Before Width: | Height: | Size: 792 KiB After Width: | Height: | Size: 1.2 MiB |
|
Before Width: | Height: | Size: 137 KiB After Width: | Height: | Size: 291 KiB |
@@ -1,5 +1,62 @@
|
||||
# Changelog
|
||||
|
||||
## paperless-ngx 3.1.2
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Fix: fix dark mode select disabled color, ensure disabled cursor on display mode dropdown [@shamoon](https://github.com/shamoon) ([#13881](https://github.com/paperless-ngx/paperless-ngx/pull/13881))
|
||||
- Fix: add disable to the drag-drop list component [@shamoon](https://github.com/shamoon) ([#13880](https://github.com/paperless-ngx/paperless-ngx/pull/13880))
|
||||
|
||||
### Documentation
|
||||
|
||||
- Chore: update screenshots for v3+ [@shamoon](https://github.com/shamoon) ([#13883](https://github.com/paperless-ngx/paperless-ngx/pull/13883))
|
||||
|
||||
### All App Changes
|
||||
|
||||
<details>
|
||||
<summary>2 changes</summary>
|
||||
|
||||
- Fix: fix dark mode select disabled color, ensure disabled cursor on display mode dropdown [@shamoon](https://github.com/shamoon) ([#13881](https://github.com/paperless-ngx/paperless-ngx/pull/13881))
|
||||
- Fix: add disable to the drag-drop list component [@shamoon](https://github.com/shamoon) ([#13880](https://github.com/paperless-ngx/paperless-ngx/pull/13880))
|
||||
|
||||
</details>
|
||||
|
||||
## paperless-ngx 3.1.1
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Fix: 3.1.0 llm suggestions remove existing metadata from prompt, dont drop name suggestions [@shamoon](https://github.com/shamoon) ([#13866](https://github.com/paperless-ngx/paperless-ngx/pull/13866))
|
||||
- Fix: set global search earlier to avoid awaiting debounce [@shamoon](https://github.com/shamoon) ([#13865](https://github.com/paperless-ngx/paperless-ngx/pull/13865))
|
||||
- Fix: responsive sidebar, centralize and make sizes saner [@shamoon](https://github.com/shamoon) ([#13863](https://github.com/paperless-ngx/paperless-ngx/pull/13863))
|
||||
- Tweak/fix: show existing count for ai suggestions [@shamoon](https://github.com/shamoon) ([#13861](https://github.com/paperless-ngx/paperless-ngx/pull/13861))
|
||||
- Fix: 3.1.0 llm suggestions simplify schema, fix docstrings [@shamoon](https://github.com/shamoon) ([#13850](https://github.com/paperless-ngx/paperless-ngx/pull/13850))
|
||||
- Fix: ensure ui reset of suggestionsLoading when changing docs [@shamoon](https://github.com/shamoon) ([#13840](https://github.com/paperless-ngx/paperless-ngx/pull/13840))
|
||||
- Fix: always pass a non-empty api key for OpenAI-like servers [@shamoon](https://github.com/shamoon) ([#13838](https://github.com/paperless-ngx/paperless-ngx/pull/13838))
|
||||
- Fix: hide slim sidebar scrollbar in browsers with stupid scrollbars [@shamoon](https://github.com/shamoon) ([#13837](https://github.com/paperless-ngx/paperless-ngx/pull/13837))
|
||||
- Fix: correct sharelink bundle + document link permissions display bugs [@shamoon](https://github.com/shamoon) ([#13827](https://github.com/paperless-ngx/paperless-ngx/pull/13827))
|
||||
- Fix: immediately re-add doc to index after trash restore [@shamoon](https://github.com/shamoon) ([#13818](https://github.com/paperless-ngx/paperless-ngx/pull/13818))
|
||||
- Fix: navbar brand anchor size + Safari position jitter [@shamoon](https://github.com/shamoon) ([#13810](https://github.com/paperless-ngx/paperless-ngx/pull/13810))
|
||||
|
||||
### All App Changes
|
||||
|
||||
<details>
|
||||
<summary>12 changes</summary>
|
||||
|
||||
- Fix: 3.1.0 llm suggestions remove existing metadata from prompt, dont drop name suggestions [@shamoon](https://github.com/shamoon) ([#13866](https://github.com/paperless-ngx/paperless-ngx/pull/13866))
|
||||
- Fix: set global search earlier to avoid awaiting debounce [@shamoon](https://github.com/shamoon) ([#13865](https://github.com/paperless-ngx/paperless-ngx/pull/13865))
|
||||
- Fix: responsive sidebar, centralize and make sizes saner [@shamoon](https://github.com/shamoon) ([#13863](https://github.com/paperless-ngx/paperless-ngx/pull/13863))
|
||||
- Tweak/fix: show existing count for ai suggestions [@shamoon](https://github.com/shamoon) ([#13861](https://github.com/paperless-ngx/paperless-ngx/pull/13861))
|
||||
- Fix: 3.1.0 llm suggestions simplify schema, fix docstrings [@shamoon](https://github.com/shamoon) ([#13850](https://github.com/paperless-ngx/paperless-ngx/pull/13850))
|
||||
- Fixhancement: make imap port required, better error display [@shamoon](https://github.com/shamoon) ([#13845](https://github.com/paperless-ngx/paperless-ngx/pull/13845))
|
||||
- Fix: ensure ui reset of suggestionsLoading when changing docs [@shamoon](https://github.com/shamoon) ([#13840](https://github.com/paperless-ngx/paperless-ngx/pull/13840))
|
||||
- Fix: always pass a non-empty api key for OpenAI-like servers [@shamoon](https://github.com/shamoon) ([#13838](https://github.com/paperless-ngx/paperless-ngx/pull/13838))
|
||||
- Fix: hide slim sidebar scrollbar in browsers with stupid scrollbars [@shamoon](https://github.com/shamoon) ([#13837](https://github.com/paperless-ngx/paperless-ngx/pull/13837))
|
||||
- Fix: correct sharelink bundle + document link permissions display bugs [@shamoon](https://github.com/shamoon) ([#13827](https://github.com/paperless-ngx/paperless-ngx/pull/13827))
|
||||
- Fix: immediately re-add doc to index after trash restore [@shamoon](https://github.com/shamoon) ([#13818](https://github.com/paperless-ngx/paperless-ngx/pull/13818))
|
||||
- Fix: navbar brand anchor size + Safari position jitter [@shamoon](https://github.com/shamoon) ([#13810](https://github.com/paperless-ngx/paperless-ngx/pull/13810))
|
||||
|
||||
</details>
|
||||
|
||||
## paperless-ngx 3.1.0
|
||||
|
||||
### Features / Enhancements
|
||||
|
||||
@@ -2088,6 +2088,12 @@ password. All of these options come from their similarly-named [Django settings]
|
||||
|
||||
Defaults to "always".
|
||||
|
||||
#### [`PAPERLESS_REMOTE_OCR_ALLOW_INTERNAL_ENDPOINTS=<bool>`](#PAPERLESS_REMOTE_OCR_ALLOW_INTERNAL_ENDPOINTS) {#PAPERLESS_REMOTE_OCR_ALLOW_INTERNAL_ENDPOINTS}
|
||||
|
||||
: If set to false, Paperless blocks remote OCR endpoint URLs that resolve to non-public addresses (e.g., localhost, etc).
|
||||
|
||||
Defaults to True.
|
||||
|
||||
## AI {#ai}
|
||||
|
||||
#### [`PAPERLESS_AI_ENABLED=<bool>`](#PAPERLESS_AI_ENABLED) {#PAPERLESS_AI_ENABLED}
|
||||
|
||||
@@ -224,13 +224,19 @@ respectively, can be run non-interactively with:
|
||||
|
||||
```bash
|
||||
pnpm ng test
|
||||
pnpm playwright test
|
||||
pnpm e2e
|
||||
```
|
||||
|
||||
The Playwright suite starts both the Angular development server and a disposable
|
||||
Paperless instance on port 8001. The instance uses SQLite, temporary data and
|
||||
media directories, and deterministic sample documents; it is removed when the
|
||||
test run finishes. This requires the back-end Python dependencies from the
|
||||
regular development setup to be installed with `uv sync`.
|
||||
|
||||
Playwright also includes a UI which can be run with:
|
||||
|
||||
```bash
|
||||
pnpm playwright test --ui
|
||||
pnpm e2e:ui
|
||||
```
|
||||
|
||||
### Building the frontend
|
||||
|
||||
@@ -317,6 +317,8 @@ a "document already exists" message.
|
||||
|
||||
Paperless-ngx can suggest tags, correspondents, document types and storage paths for documents based on the content of the document. This is done using a (non-LLM) machine learning model that is trained on the documents in your database. The suggestions are shown in the document detail page and can be accepted or rejected by the user.
|
||||
|
||||
Suggestions are requested automatically when you open a document that still has an inbox tag. To only request them by pressing the "Suggest" button instead, turn off "Automatically request suggestions for inbox documents" under Settings > Documents.
|
||||
|
||||
## AI Features
|
||||
|
||||
Paperless-ngx includes several features that use AI to enhance the document management experience. These features are optional and can be enabled or disabled in the settings. If you are using the AI features, you may want to also enable the "LLM index" feature, which supports Retrieval-Augmented Generation (RAG) designed to improve the quality of AI responses. The LLM index feature is not enabled by default and requires additional configuration.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "paperless-ngx"
|
||||
version = "3.1.0"
|
||||
version = "3.1.2"
|
||||
description = "A community-supported supercharged document management system: scan, index and archive all your physical documents"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
@@ -47,7 +47,7 @@ dependencies = [
|
||||
"httpx-oauth~=0.17",
|
||||
"ijson>=3.5.1",
|
||||
"imap-tools~=1.14.0",
|
||||
"jinja2~=3.1.5",
|
||||
"jinja2~=3.1.6",
|
||||
"langdetect~=1.0.9",
|
||||
"llama-index-core>=0.14.23",
|
||||
"llama-index-embeddings-huggingface>=0.6.1",
|
||||
@@ -247,7 +247,7 @@ per-file-ignores."src/documents/models.py" = [
|
||||
isort.force-single-line = true
|
||||
|
||||
[tool.codespell]
|
||||
ignore-words-list = "criterias,afterall,valeu,ureue,equest,ure,assertIn,Oktober,commitish"
|
||||
ignore-words-list = "criterias,afterall,valeu,ureue,equest,ure,assertIn,Oktober,commitish,NIN,nin"
|
||||
skip = """\
|
||||
src-ui/src/locale/*,src-ui/pnpm-lock.yaml,src-ui/e2e/*,src/paperless_mail/tests/samples/*,src/paperless/tests/samples\
|
||||
/mail/*,src/documents/tests/samples/*,*.po,*.json\
|
||||
|
||||
@@ -139,6 +139,17 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
"e2e": {
|
||||
"fileReplacements": [
|
||||
{
|
||||
"replace": "src/environments/environment.ts",
|
||||
"with": "src/environments/environment.e2e.ts"
|
||||
}
|
||||
],
|
||||
"localize": [
|
||||
"en-US"
|
||||
]
|
||||
},
|
||||
"en-US": {
|
||||
"localize": [
|
||||
"en-US"
|
||||
@@ -155,6 +166,9 @@
|
||||
"configurations": {
|
||||
"production": {
|
||||
"buildTarget": "paperless-ui:build:production"
|
||||
},
|
||||
"e2e": {
|
||||
"buildTarget": "paperless-ui:build:e2e"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -1,194 +0,0 @@
|
||||
{
|
||||
"log": {
|
||||
"version": "1.2",
|
||||
"creator": {
|
||||
"name": "Playwright",
|
||||
"version": "1.33.0"
|
||||
},
|
||||
"browser": {
|
||||
"name": "chromium",
|
||||
"version": "113.0.5672.53"
|
||||
},
|
||||
"entries": [
|
||||
{
|
||||
"startedDateTime": "2023-05-14T07:18:59.856Z",
|
||||
"time": 6.025,
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"url": "http://localhost:8000/api/ui_settings/",
|
||||
"httpVersion": "HTTP/1.1",
|
||||
"cookies": [],
|
||||
"headers": [
|
||||
{ "name": "Accept", "value": "application/json; version=3" },
|
||||
{ "name": "Accept-Encoding", "value": "gzip, deflate, br" },
|
||||
{ "name": "Accept-Language", "value": "en-US" },
|
||||
{ "name": "Connection", "value": "keep-alive" },
|
||||
{ "name": "Host", "value": "localhost:8000" },
|
||||
{ "name": "Origin", "value": "http://localhost:4200" },
|
||||
{ "name": "Referer", "value": "http://localhost:4200/" },
|
||||
{ "name": "Sec-Fetch-Dest", "value": "empty" },
|
||||
{ "name": "Sec-Fetch-Mode", "value": "cors" },
|
||||
{ "name": "Sec-Fetch-Site", "value": "same-site" },
|
||||
{ "name": "User-Agent", "value": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/113.0.5672.53 Safari/537.36" }
|
||||
],
|
||||
"queryString": [],
|
||||
"headersSize": -1,
|
||||
"bodySize": -1
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"statusText": "OK",
|
||||
"httpVersion": "HTTP/1.1",
|
||||
"cookies": [],
|
||||
"headers": [
|
||||
{ "name": "Access-Control-Allow-Origin", "value": "http://localhost:4200" },
|
||||
{ "name": "Allow", "value": "GET, POST, HEAD, OPTIONS" },
|
||||
{ "name": "Content-Encoding", "value": "br" },
|
||||
{ "name": "Content-Language", "value": "en-us" },
|
||||
{ "name": "Content-Length", "value": "953" },
|
||||
{ "name": "Content-Type", "value": "application/json" },
|
||||
{ "name": "Cross-Origin-Opener-Policy", "value": "same-origin" },
|
||||
{ "name": "Referrer-Policy", "value": "same-origin" },
|
||||
{ "name": "Vary", "value": "Accept, Accept-Language, Origin, Cookie, Accept-Encoding" },
|
||||
{ "name": "X-Api-Version", "value": "3" },
|
||||
{ "name": "X-Content-Type-Options", "value": "nosniff" },
|
||||
{ "name": "X-Frame-Options", "value": "ANY" },
|
||||
{ "name": "X-Version", "value": "1.14.4" }
|
||||
],
|
||||
"content": {
|
||||
"size": -1,
|
||||
"mimeType": "application/json",
|
||||
"text": "{\"user\":{\"id\":2,\"username\":\"testuser\",\"is_superuser\":false,\"groups\":[]},\"settings\":{\"language\":\"\",\"bulk_edit\":{\"confirmation_dialogs\":true,\"apply_on_close\":false},\"documentListSize\":50,\"dark_mode\":{\"use_system\":true,\"enabled\":\"false\",\"thumb_inverted\":\"true\"},\"theme\":{\"color\":\"#9fbf2f\"},\"document_details\":{\"native_pdf_viewer\":false},\"date_display\":{\"date_locale\":\"\",\"date_format\":\"mediumDate\"},\"notifications\":{\"consumer_new_documents\":true,\"consumer_success\":true,\"consumer_failed\":true,\"consumer_suppress_on_dashboard\":true},\"comments_enabled\":true,\"slim_sidebar\":false,\"update_checking\":{\"enabled\":false,\"backend_setting\":\"default\"},\"saved_views\":{\"warn_on_unsaved_change\":true},\"notes_enabled\":true,\"tour_complete\":true},\"permissions\":[\"change_savedview\",\"change_schedule\",\"change_failure\",\"delete_token\",\"add_mailrule\",\"view_failure\",\"view_groupresult\",\"add_note\",\"change_taskresult\",\"view_tag\",\"view_user\",\"add_tag\",\"change_processedmail\",\"change_session\",\"view_taskattributes\",\"delete_groupresult\",\"delete_correspondent\",\"delete_schedule\",\"delete_contenttype\",\"view_chordcounter\",\"view_success\",\"delete_documenttype\",\"add_tokenproxy\",\"delete_paperlesstask\",\"add_log\",\"view_mailaccount\",\"add_uisettings\",\"view_savedview\",\"view_uisettings\",\"delete_storagepath\",\"delete_frontendsettings\",\"change_paperlesstask\",\"view_taskresult\",\"delete_processedmail\",\"view_processedmail\",\"view_session\",\"delete_chordcounter\",\"view_note\",\"delete_session\",\"view_document\",\"change_mailaccount\",\"delete_taskattributes\",\"add_groupobjectpermission\",\"view_mailrule\",\"change_savedviewfilterrule\",\"change_log\",\"change_comment\",\"add_mailaccount\",\"add_frontendsettings\",\"add_userobjectpermission\",\"delete_note\",\"view_token\",\"add_failure\",\"delete_user\",\"add_success\",\"view_ormq\",\"view_tokenproxy\",\"delete_uisettings\",\"change_groupobjectpermission\",\"add_logentry\",\"add_ormq\",\"view_frontendsettings\",\"view_schedule\",\"change_taskattributes\",\"view_documenttype\",\"view_logentry\",\"change_correspondent\",\"add_groupresult\",\"delete_groupobjectpermission\",\"change_mailrule\",\"change_permission\",\"delete_log\",\"view_userobjectpermission\",\"view_correspondent\",\"delete_document\",\"change_uisettings\",\"change_storagepath\",\"change_document\",\"delete_tokenproxy\",\"change_note\",\"delete_permission\",\"change_contenttype\",\"add_token\",\"change_success\",\"delete_logentry\",\"view_savedviewfilterrule\",\"delete_task\",\"add_savedview\",\"add_paperlesstask\",\"add_task\",\"change_documenttype\",\"add_documenttype\",\"change_token\",\"view_task\",\"view_permission\",\"change_task\",\"delete_userobjectpermission\",\"change_group\",\"add_group\",\"change_tag\",\"change_chordcounter\",\"add_storagepath\",\"delete_group\",\"add_taskattributes\",\"delete_mailaccount\",\"delete_tag\",\"add_schedule\",\"delete_failure\",\"delete_mailrule\",\"add_savedviewfilterrule\",\"change_ormq\",\"change_logentry\",\"add_taskresult\",\"view_group\",\"delete_comment\",\"add_contenttype\",\"add_document\",\"change_tokenproxy\",\"delete_success\",\"add_comment\",\"delete_ormq\",\"add_processedmail\",\"view_paperlesstask\",\"delete_savedview\",\"change_user\",\"add_session\",\"view_groupobjectpermission\",\"add_user\",\"add_correspondent\",\"delete_taskresult\",\"view_contenttype\",\"view_storagepath\",\"add_permission\",\"change_userobjectpermission\",\"delete_savedviewfilterrule\",\"change_groupresult\",\"add_chordcounter\",\"view_log\",\"view_comment\",\"change_frontendsettings\"]}"
|
||||
},
|
||||
"headersSize": -1,
|
||||
"bodySize": -1,
|
||||
"redirectURL": ""
|
||||
},
|
||||
"cache": {},
|
||||
"timings": { "send": -1, "wait": -1, "receive": 6.025 }
|
||||
},
|
||||
{
|
||||
"startedDateTime": "2023-05-14T07:18:59.990Z",
|
||||
"time": 1.082,
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"url": "http://localhost:8000/api/saved_views/?page=1&page_size=100000",
|
||||
"httpVersion": "HTTP/1.1",
|
||||
"cookies": [],
|
||||
"headers": [
|
||||
{ "name": "Accept", "value": "application/json; version=3" },
|
||||
{ "name": "Accept-Encoding", "value": "gzip, deflate, br" },
|
||||
{ "name": "Accept-Language", "value": "en-US" },
|
||||
{ "name": "Connection", "value": "keep-alive" },
|
||||
{ "name": "Host", "value": "localhost:8000" },
|
||||
{ "name": "Origin", "value": "http://localhost:4200" },
|
||||
{ "name": "Referer", "value": "http://localhost:4200/" },
|
||||
{ "name": "Sec-Fetch-Dest", "value": "empty" },
|
||||
{ "name": "Sec-Fetch-Mode", "value": "cors" },
|
||||
{ "name": "Sec-Fetch-Site", "value": "same-site" },
|
||||
{ "name": "User-Agent", "value": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/113.0.5672.53 Safari/537.36" }
|
||||
],
|
||||
"queryString": [
|
||||
{
|
||||
"name": "page",
|
||||
"value": "1"
|
||||
},
|
||||
{
|
||||
"name": "page_size",
|
||||
"value": "100000"
|
||||
}
|
||||
],
|
||||
"headersSize": -1,
|
||||
"bodySize": -1
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"statusText": "OK",
|
||||
"httpVersion": "HTTP/1.1",
|
||||
"cookies": [],
|
||||
"headers": [
|
||||
{ "name": "Access-Control-Allow-Origin", "value": "http://localhost:4200" },
|
||||
{ "name": "Allow", "value": "GET, POST, HEAD, OPTIONS" },
|
||||
{ "name": "Content-Encoding", "value": "br" },
|
||||
{ "name": "Content-Language", "value": "en-us" },
|
||||
{ "name": "Content-Length", "value": "851" },
|
||||
{ "name": "Content-Type", "value": "application/json" },
|
||||
{ "name": "Cross-Origin-Opener-Policy", "value": "same-origin" },
|
||||
{ "name": "Referrer-Policy", "value": "same-origin" },
|
||||
{ "name": "Vary", "value": "Accept, Accept-Language, Origin, Cookie, Accept-Encoding" },
|
||||
{ "name": "X-Api-Version", "value": "3" },
|
||||
{ "name": "X-Content-Type-Options", "value": "nosniff" },
|
||||
{ "name": "X-Frame-Options", "value": "ANY" },
|
||||
{ "name": "X-Version", "value": "1.14.4" }
|
||||
],
|
||||
"content": {
|
||||
"size": -1,
|
||||
"mimeType": "application/json",
|
||||
"text": "{\"count\":6,\"next\":null,\"previous\":null,\"all\":[8,17,7,4,11,15],\"results\":[{\"id\":8,\"name\":\"Correspondent 2\",\"show_on_dashboard\":false,\"show_in_sidebar\":false,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[{\"rule_type\":3,\"value\":\"2\"}],\"owner\":\"2\",\"user_can_change\":true},{\"id\":17,\"name\":\"In the Last Month\",\"show_on_dashboard\":false,\"show_in_sidebar\":false,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[{\"rule_type\":20,\"value\":\"created:[-1 month to now]\"}],\"owner\":\"2\",\"user_can_change\":true},{\"id\":7,\"name\":\"Inbox\",\"show_on_dashboard\":true,\"show_in_sidebar\":true,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[{\"rule_type\":6,\"value\":\"9\"}],\"owner\":\"2\",\"user_can_change\":true},{\"id\":4,\"name\":\"Recently Added\",\"show_on_dashboard\":true,\"show_in_sidebar\":true,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[],\"owner\":\"2\",\"user_can_change\":true},{\"id\":11,\"name\":\"Tag: Another Sample Tag\",\"show_on_dashboard\":false,\"show_in_sidebar\":true,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[{\"rule_type\":6,\"value\":\"4\"}],\"owner\":\"2\",\"user_can_change\":true},{\"id\":15,\"name\":\"View ASN not empty\",\"show_on_dashboard\":false,\"show_in_sidebar\":false,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[{\"rule_type\":18,\"value\":\"false\"}],\"owner\":\"2\",\"user_can_change\":true}]}"
|
||||
},
|
||||
"headersSize": -1,
|
||||
"bodySize": -1,
|
||||
"redirectURL": ""
|
||||
},
|
||||
"cache": {},
|
||||
"timings": { "send": -1, "wait": -1, "receive": 1.082 }
|
||||
},
|
||||
{
|
||||
"startedDateTime": "2023-05-14T07:18:59.990Z",
|
||||
"time": 0.647,
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"url": "http://localhost:8000/api/tasks/",
|
||||
"httpVersion": "HTTP/1.1",
|
||||
"cookies": [],
|
||||
"headers": [
|
||||
{ "name": "Accept", "value": "application/json; version=3" },
|
||||
{ "name": "Accept-Encoding", "value": "gzip, deflate, br" },
|
||||
{ "name": "Accept-Language", "value": "en-US" },
|
||||
{ "name": "Connection", "value": "keep-alive" },
|
||||
{ "name": "Host", "value": "localhost:8000" },
|
||||
{ "name": "Origin", "value": "http://localhost:4200" },
|
||||
{ "name": "Referer", "value": "http://localhost:4200/" },
|
||||
{ "name": "Sec-Fetch-Dest", "value": "empty" },
|
||||
{ "name": "Sec-Fetch-Mode", "value": "cors" },
|
||||
{ "name": "Sec-Fetch-Site", "value": "same-site" },
|
||||
{ "name": "User-Agent", "value": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/113.0.5672.53 Safari/537.36" }
|
||||
],
|
||||
"queryString": [],
|
||||
"headersSize": -1,
|
||||
"bodySize": -1
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"statusText": "OK",
|
||||
"httpVersion": "HTTP/1.1",
|
||||
"cookies": [],
|
||||
"headers": [
|
||||
{ "name": "Access-Control-Allow-Origin", "value": "http://localhost:4200" },
|
||||
{ "name": "Allow", "value": "GET, HEAD, OPTIONS" },
|
||||
{ "name": "Content-Language", "value": "en-us" },
|
||||
{ "name": "Content-Length", "value": "2" },
|
||||
{ "name": "Content-Type", "value": "application/json" },
|
||||
{ "name": "Cross-Origin-Opener-Policy", "value": "same-origin" },
|
||||
{ "name": "Referrer-Policy", "value": "same-origin" },
|
||||
{ "name": "Vary", "value": "Accept, Accept-Language, Origin, Cookie" },
|
||||
{ "name": "X-Api-Version", "value": "3" },
|
||||
{ "name": "X-Content-Type-Options", "value": "nosniff" },
|
||||
{ "name": "X-Frame-Options", "value": "ANY" },
|
||||
{ "name": "X-Version", "value": "1.14.4" }
|
||||
],
|
||||
"content": {
|
||||
"size": -1,
|
||||
"mimeType": "application/json",
|
||||
"text": "[]"
|
||||
},
|
||||
"headersSize": -1,
|
||||
"bodySize": -1,
|
||||
"redirectURL": ""
|
||||
},
|
||||
"cache": {},
|
||||
"timings": { "send": -1, "wait": -1, "receive": 0.647 }
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,25 @@
|
||||
import { expect, test } from '@playwright/test'
|
||||
import path from 'node:path'
|
||||
|
||||
const REQUESTS_HAR = path.join(__dirname, 'requests/api-settings.har')
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.route('**/api/status/', (route) =>
|
||||
route.fulfill({
|
||||
json: {
|
||||
database: { status: 'OK' },
|
||||
tasks: {
|
||||
redis_status: 'DISABLED',
|
||||
celery_status: 'DISABLED',
|
||||
index_status: 'OK',
|
||||
classifier_status: 'OK',
|
||||
sanity_check_status: 'OK',
|
||||
},
|
||||
},
|
||||
})
|
||||
)
|
||||
})
|
||||
|
||||
test('should activate / deactivate save button when settings change', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.routeFromHAR(REQUESTS_HAR, { notFound: 'fallback' })
|
||||
await page.goto('/settings')
|
||||
await expect(page.getByRole('button', { name: 'Save' })).toBeDisabled()
|
||||
await page.getByLabel('Use system setting').click()
|
||||
@@ -15,7 +28,6 @@ test('should activate / deactivate save button when settings change', async ({
|
||||
})
|
||||
|
||||
test('should warn on unsaved changes', async ({ page }) => {
|
||||
await page.routeFromHAR(REQUESTS_HAR, { notFound: 'fallback' })
|
||||
await page.goto('/settings')
|
||||
await page.getByLabel('Use system setting').click()
|
||||
await page.getByRole('link', { name: 'Dashboard' }).click()
|
||||
@@ -27,7 +39,6 @@ test('should warn on unsaved changes', async ({ page }) => {
|
||||
})
|
||||
|
||||
test('should apply appearance changes when set', async ({ page }) => {
|
||||
await page.routeFromHAR(REQUESTS_HAR, { notFound: 'fallback' })
|
||||
await page.goto('/settings')
|
||||
await expect(page.locator('html')).toHaveAttribute('data-bs-theme', /auto/)
|
||||
await page.getByLabel('Use system setting').click()
|
||||
|
||||
@@ -0,0 +1,237 @@
|
||||
"""Start a disposable Paperless instance for the Playwright test suite."""
|
||||
|
||||
# ruff: noqa: INP001, T201
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import logging
|
||||
import logging.config
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
REPOSITORY_ROOT = Path(__file__).resolve().parents[2]
|
||||
SOURCE_ROOT = REPOSITORY_ROOT / "src"
|
||||
|
||||
|
||||
def configure_environment(instance_root: Path) -> None:
|
||||
paths = {
|
||||
"PAPERLESS_CONSUMPTION_DIR": instance_root / "consume",
|
||||
"PAPERLESS_DATA_DIR": instance_root / "data",
|
||||
"PAPERLESS_MEDIA_ROOT": instance_root / "media",
|
||||
"PAPERLESS_SCRATCH_DIR": instance_root / "scratch",
|
||||
}
|
||||
for name, path in paths.items():
|
||||
path.mkdir(parents=True)
|
||||
os.environ[name] = str(path)
|
||||
(paths["PAPERLESS_DATA_DIR"] / "index").mkdir()
|
||||
|
||||
os.environ.update(
|
||||
{
|
||||
"DJANGO_SETTINGS_MODULE": "paperless.settings",
|
||||
"PAPERLESS_AI_ENABLED": "false",
|
||||
"PAPERLESS_CHANNELS_BACKEND": "channels.layers.InMemoryChannelLayer",
|
||||
"PAPERLESS_DEBUG": "true",
|
||||
"PAPERLESS_SECRET_KEY": "playwright-only-not-a-real-secret",
|
||||
},
|
||||
)
|
||||
sys.path.insert(0, str(SOURCE_ROOT))
|
||||
|
||||
|
||||
def seed_database() -> None:
|
||||
from django.contrib.auth.models import User
|
||||
from django.core.management import call_command
|
||||
from django.utils import timezone
|
||||
|
||||
from documents.models import Correspondent
|
||||
from documents.models import Document
|
||||
from documents.models import DocumentType
|
||||
from documents.models import Note
|
||||
from documents.models import SavedView
|
||||
from documents.models import SavedViewFilterRule
|
||||
from documents.models import StoragePath
|
||||
from documents.models import Tag
|
||||
from documents.models import UiSettings
|
||||
|
||||
# Fixed credentials are safe within this disposable, localhost-only instance.
|
||||
admin = User.objects.create_superuser(
|
||||
username="playwright",
|
||||
password="playwright", # NOSONAR
|
||||
)
|
||||
User.objects.create_user(
|
||||
username="viewer",
|
||||
password="viewer", # NOSONAR
|
||||
)
|
||||
|
||||
inbox = Tag.objects.create(name="Inbox", is_inbox_tag=True, owner=admin)
|
||||
quick_filter = Tag.objects.create(name="Another Sample Tag", owner=admin)
|
||||
Tag.objects.create(name="TagWithPartial", owner=admin)
|
||||
invoice = DocumentType.objects.create(name="Invoice Test", owner=admin)
|
||||
correspondent_1 = Correspondent.objects.create(
|
||||
name="Test Correspondent 1",
|
||||
owner=admin,
|
||||
)
|
||||
correspondent_2 = Correspondent.objects.create(name="Correspondent 9", owner=admin)
|
||||
storage_path = StoragePath.objects.create(
|
||||
name="Testing 12",
|
||||
path="e2e/{created_year}/{title}",
|
||||
owner=admin,
|
||||
)
|
||||
|
||||
today = timezone.localdate()
|
||||
documents = []
|
||||
for number in range(1, 62):
|
||||
title = f"test document {number}" if number <= 9 else f"document {number}"
|
||||
content = (
|
||||
f"Playwright test content for document {number}"
|
||||
if number <= 32
|
||||
else f"Seeded content for document {number}"
|
||||
)
|
||||
created = today if number == 1 else datetime.date(2021, 1, 1)
|
||||
if number in (2, 3):
|
||||
created = datetime.date(2022, 12, 11)
|
||||
|
||||
documents.append(
|
||||
Document(
|
||||
title=title,
|
||||
content=content,
|
||||
checksum=f"{number:064x}",
|
||||
mime_type="application/pdf",
|
||||
filename=f"{number:07}.pdf",
|
||||
original_filename=f"document-{number}.pdf",
|
||||
archive_serial_number=1122 + number if number <= 6 else None,
|
||||
created=created,
|
||||
owner=admin,
|
||||
document_type=invoice if number <= 3 else None,
|
||||
correspondent=(
|
||||
correspondent_1
|
||||
if number <= 4
|
||||
else correspondent_2
|
||||
if number <= 7
|
||||
else None
|
||||
),
|
||||
storage_path=storage_path if number <= 8 else None,
|
||||
),
|
||||
)
|
||||
|
||||
Document.objects.bulk_create(documents)
|
||||
originals = Path(os.environ["PAPERLESS_MEDIA_ROOT"]) / "documents" / "originals"
|
||||
originals.mkdir(parents=True)
|
||||
thumbnails = Path(os.environ["PAPERLESS_MEDIA_ROOT"]) / "documents" / "thumbnails"
|
||||
thumbnails.mkdir(parents=True)
|
||||
sample_pdf = SOURCE_ROOT / "documents" / "tests" / "samples" / "simple.pdf"
|
||||
for document in documents:
|
||||
shutil.copyfile(sample_pdf, originals / document.filename)
|
||||
shutil.copyfile(
|
||||
SOURCE_ROOT / "documents" / "resources" / "document.webp",
|
||||
thumbnails / f"{document.pk:07}.webp",
|
||||
)
|
||||
|
||||
for document in documents[:8]:
|
||||
document.tags.add(inbox)
|
||||
documents[0].tags.add(quick_filter)
|
||||
|
||||
for number in range(1, 5):
|
||||
Note.objects.create(
|
||||
note=f"Playwright note {number}",
|
||||
document=documents[0],
|
||||
user=admin,
|
||||
)
|
||||
|
||||
inbox_view = SavedView.objects.create(
|
||||
name="Inbox",
|
||||
owner=admin,
|
||||
sort_field="created",
|
||||
sort_reverse=True,
|
||||
page_size=10,
|
||||
display_mode=SavedView.DisplayMode.TABLE,
|
||||
display_fields=["created", "title", "tag", "documenttype"],
|
||||
)
|
||||
SavedViewFilterRule.objects.create(
|
||||
saved_view=inbox_view,
|
||||
rule_type=6,
|
||||
value=str(inbox.pk),
|
||||
)
|
||||
|
||||
UiSettings.objects.create(
|
||||
user=admin,
|
||||
settings={
|
||||
"language": "",
|
||||
"bulk_edit": {"confirmation_dialogs": True, "apply_on_close": False},
|
||||
"documentListSize": 50,
|
||||
"dark_mode": {
|
||||
"use_system": True,
|
||||
"enabled": False,
|
||||
"thumb_inverted": True,
|
||||
},
|
||||
"theme": {"color": "#9fbf2f"},
|
||||
"document_details": {"native_pdf_viewer": False},
|
||||
"date_display": {"date_locale": "", "date_format": "mediumDate"},
|
||||
"comments_enabled": True,
|
||||
"slim_sidebar": False,
|
||||
"update_checking": {"enabled": False},
|
||||
"saved_views": {
|
||||
"warn_on_unsaved_change": True,
|
||||
"dashboard_views_visible_ids": [inbox_view.pk],
|
||||
"sidebar_views_visible_ids": [inbox_view.pk],
|
||||
},
|
||||
"notes_enabled": True,
|
||||
"tour_complete": True,
|
||||
},
|
||||
)
|
||||
|
||||
call_command(
|
||||
"document_index",
|
||||
"reindex",
|
||||
recreate=True,
|
||||
heap_size_mb=16,
|
||||
verbosity=0,
|
||||
)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
started = time.monotonic()
|
||||
with tempfile.TemporaryDirectory(prefix="paperless-playwright-") as instance:
|
||||
configure_environment(Path(instance))
|
||||
|
||||
os.chdir(SOURCE_ROOT)
|
||||
print("Loading the Paperless backend...", flush=True)
|
||||
import django
|
||||
|
||||
django.setup()
|
||||
|
||||
from django.conf import settings
|
||||
from django.core.management import call_command
|
||||
|
||||
settings.CELERY_TASK_ALWAYS_EAGER = True
|
||||
settings.CELERY_TASK_EAGER_PROPAGATES = True
|
||||
|
||||
print("Migrating the disposable database...", flush=True)
|
||||
call_command("migrate", interactive=False, verbosity=0)
|
||||
print("Seeding Playwright data...", flush=True)
|
||||
seed_database()
|
||||
elapsed = time.monotonic() - started
|
||||
print(f"Playwright backend ready in {elapsed:.1f}s", flush=True)
|
||||
settings.LOGGING["handlers"]["console"]["level"] = "WARNING"
|
||||
settings.LOGGING["handlers"]["playwright_null"] = {
|
||||
"class": "logging.NullHandler",
|
||||
}
|
||||
settings.LOGGING["loggers"]["django.server"] = {
|
||||
"handlers": ["playwright_null"],
|
||||
"propagate": False,
|
||||
}
|
||||
logging.config.dictConfig(settings.LOGGING)
|
||||
call_command(
|
||||
"runserver",
|
||||
"localhost:8001",
|
||||
use_reloader=False,
|
||||
verbosity=1,
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,21 +1,13 @@
|
||||
import { expect, test } from '@playwright/test'
|
||||
import path from 'node:path'
|
||||
|
||||
const REQUESTS_HAR1 = path.join(__dirname, 'requests/api-dashboard1.har')
|
||||
const REQUESTS_HAR2 = path.join(__dirname, 'requests/api-dashboard2.har')
|
||||
const REQUESTS_HAR3 = path.join(__dirname, 'requests/api-dashboard3.har')
|
||||
const REQUESTS_HAR4 = path.join(__dirname, 'requests/api-dashboard4.har')
|
||||
|
||||
test('dashboard inbox link', async ({ page }) => {
|
||||
await page.routeFromHAR(REQUESTS_HAR1, { notFound: 'fallback' })
|
||||
await page.goto('/dashboard')
|
||||
await page.getByRole('link', { name: 'Documents in inbox' }).click()
|
||||
await expect(page).toHaveURL(/tags__id__in=9/)
|
||||
await expect(page).toHaveURL(/tags__id__in=1/)
|
||||
await expect(page.locator('pngx-document-list')).toHaveText(/8 documents/)
|
||||
})
|
||||
|
||||
test('dashboard total documents link', async ({ page }) => {
|
||||
await page.routeFromHAR(REQUESTS_HAR2, { notFound: 'fallback' })
|
||||
await page.goto('/dashboard')
|
||||
await page.getByRole('link').filter({ hasText: 'Total documents' }).click()
|
||||
await expect(page).toHaveURL(/documents/)
|
||||
@@ -24,7 +16,6 @@ test('dashboard total documents link', async ({ page }) => {
|
||||
})
|
||||
|
||||
test('dashboard saved view show all', async ({ page }) => {
|
||||
await page.routeFromHAR(REQUESTS_HAR3, { notFound: 'fallback' })
|
||||
await page.goto('/dashboard')
|
||||
await page
|
||||
.locator('pngx-widget-frame')
|
||||
@@ -32,12 +23,11 @@ test('dashboard saved view show all', async ({ page }) => {
|
||||
.getByRole('link', { name: 'Show all' })
|
||||
.first()
|
||||
.click()
|
||||
await expect(page).toHaveURL(/view\/7/)
|
||||
await expect(page).toHaveURL(/view\/1/)
|
||||
await expect(page.locator('pngx-document-list')).toHaveText(/8 documents/)
|
||||
})
|
||||
|
||||
test('dashboard saved view document links', async ({ page }) => {
|
||||
await page.routeFromHAR(REQUESTS_HAR4, { notFound: 'fallback' })
|
||||
await page.goto('/dashboard')
|
||||
await page
|
||||
.locator('pngx-widget-frame')
|
||||
@@ -46,11 +36,10 @@ test('dashboard saved view document links', async ({ page }) => {
|
||||
.getByRole('link', { name: /test/ })
|
||||
.first()
|
||||
.click({ position: { x: 0, y: 0 } })
|
||||
await expect(page).toHaveURL(/documents\/310\/details/)
|
||||
await expect(page).toHaveURL(/documents\/1\/details/)
|
||||
})
|
||||
|
||||
test('test slim sidebar', async ({ page }) => {
|
||||
await page.routeFromHAR(REQUESTS_HAR1, { notFound: 'fallback' })
|
||||
await page.goto('/dashboard')
|
||||
await page.locator('.sidebar-slim-toggler').click()
|
||||
await expect(
|
||||
|
||||
@@ -1,14 +1,9 @@
|
||||
import { expect, test, type WebSocketRoute } from '@playwright/test'
|
||||
import path from 'node:path'
|
||||
|
||||
const REQUESTS_HAR = path.join(__dirname, 'requests/api-document-detail.har')
|
||||
const REQUESTS_HAR2 = path.join(__dirname, 'requests/api-document-detail2.har')
|
||||
import { expect, test } from '@playwright/test'
|
||||
|
||||
test('should activate / deactivate save button when changes are saved', async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.routeFromHAR(REQUESTS_HAR, { notFound: 'fallback' })
|
||||
await page.goto('/documents/175/')
|
||||
await page.goto('/documents/1/')
|
||||
await page.waitForSelector('pngx-document-detail pngx-input-text:first-child')
|
||||
await expect(page.getByTitle('Storage path', { exact: true })).toHaveText(
|
||||
/\w+/
|
||||
@@ -19,8 +14,7 @@ test('should activate / deactivate save button when changes are saved', async ({
|
||||
})
|
||||
|
||||
test('should warn on unsaved changes', async ({ page }) => {
|
||||
await page.routeFromHAR(REQUESTS_HAR, { notFound: 'fallback' })
|
||||
await page.goto('/documents/175/')
|
||||
await page.goto('/documents/1/')
|
||||
await expect(page.getByTitle('Correspondent', { exact: true })).toHaveText(
|
||||
/\w+/
|
||||
)
|
||||
@@ -38,28 +32,27 @@ test('should warn on unsaved changes', async ({ page }) => {
|
||||
})
|
||||
|
||||
test('should support tab direct navigation', async ({ page }) => {
|
||||
await page.routeFromHAR(REQUESTS_HAR, { notFound: 'fallback' })
|
||||
await page.goto('/documents/175/details')
|
||||
await page.goto('/documents/1/details')
|
||||
await expect(page.getByRole('tab', { name: 'Details' })).toHaveAttribute(
|
||||
'aria-selected',
|
||||
'true'
|
||||
)
|
||||
await page.goto('/documents/175/content')
|
||||
await page.goto('/documents/1/content')
|
||||
await expect(page.getByRole('tab', { name: 'Content' })).toHaveAttribute(
|
||||
'aria-selected',
|
||||
'true'
|
||||
)
|
||||
await page.goto('/documents/175/metadata')
|
||||
await page.goto('/documents/1/metadata')
|
||||
await expect(page.getByRole('tab', { name: 'Metadata' })).toHaveAttribute(
|
||||
'aria-selected',
|
||||
'true'
|
||||
)
|
||||
await page.goto('/documents/175/notes')
|
||||
await page.goto('/documents/1/notes')
|
||||
await expect(page.getByRole('tab', { name: 'Notes' })).toHaveAttribute(
|
||||
'aria-selected',
|
||||
'true'
|
||||
)
|
||||
await page.goto('/documents/175/permissions')
|
||||
await page.goto('/documents/1/permissions')
|
||||
await expect(page.getByRole('tab', { name: 'Permissions' })).toHaveAttribute(
|
||||
'aria-selected',
|
||||
'true'
|
||||
@@ -67,8 +60,7 @@ test('should support tab direct navigation', async ({ page }) => {
|
||||
})
|
||||
|
||||
test('should show a mobile preview', async ({ page }) => {
|
||||
await page.routeFromHAR(REQUESTS_HAR, { notFound: 'fallback' })
|
||||
await page.goto('/documents/175/')
|
||||
await page.goto('/documents/1/')
|
||||
await page.setViewportSize({ width: 400, height: 1000 })
|
||||
await expect(page.getByRole('tab', { name: 'Preview' })).toBeVisible()
|
||||
await page.getByRole('tab', { name: 'Preview' }).click()
|
||||
@@ -76,8 +68,7 @@ test('should show a mobile preview', async ({ page }) => {
|
||||
})
|
||||
|
||||
test('should show a list of notes', async ({ page }) => {
|
||||
await page.routeFromHAR(REQUESTS_HAR, { notFound: 'fallback' })
|
||||
await page.goto('/documents/175/notes')
|
||||
await page.goto('/documents/1/notes')
|
||||
await expect(page.locator('pngx-document-notes')).toBeVisible()
|
||||
await expect(
|
||||
await page.getByRole('button', {
|
||||
@@ -88,32 +79,25 @@ test('should show a list of notes', async ({ page }) => {
|
||||
})
|
||||
|
||||
test('should support quick filters', async ({ page }) => {
|
||||
await page.routeFromHAR(REQUESTS_HAR2, { notFound: 'fallback' })
|
||||
await page.goto('/documents/175/details')
|
||||
await page.goto('/documents/1/details')
|
||||
await page
|
||||
.getByRole('button', { name: 'Filter documents with these Tags' })
|
||||
.click()
|
||||
await expect(page).toHaveURL(/tags__id__all=4&sort=created&reverse=1&page=1/)
|
||||
await expect(page).toHaveURL(
|
||||
/tags__id__all=2,1&sort=created&reverse=1&page=1/
|
||||
)
|
||||
})
|
||||
|
||||
test('should finish reloading the preview after a remote document update', async ({
|
||||
page,
|
||||
}) => {
|
||||
let resolveStatusSocket: (socket: WebSocketRoute) => void
|
||||
const statusSocketReady = new Promise<WebSocketRoute>((resolve) => {
|
||||
resolveStatusSocket = resolve
|
||||
})
|
||||
await page.routeWebSocket(/\/ws\/status\/$/, (socket) => {
|
||||
resolveStatusSocket(socket)
|
||||
})
|
||||
await page.routeFromHAR(REQUESTS_HAR, { notFound: 'fallback' })
|
||||
let previewRequestCount = 0
|
||||
page.on('request', (request) => {
|
||||
if (request.url().includes('/api/documents/175/preview/')) {
|
||||
if (request.url().includes('/api/documents/1/preview/')) {
|
||||
previewRequestCount++
|
||||
}
|
||||
})
|
||||
await page.goto('/documents/175/details')
|
||||
await page.goto('/documents/1/details')
|
||||
|
||||
await page.locator('pngx-document-detail').waitFor()
|
||||
await expect(page.getByTitle('Storage path', { exact: true })).toHaveText(
|
||||
@@ -128,27 +112,37 @@ test('should finish reloading the preview after a remote document update', async
|
||||
expect(previewWasLoaded).toBe(true)
|
||||
const previewRequestsBeforeReload = previewRequestCount
|
||||
|
||||
const statusSocket = await statusSocketReady
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(() => {
|
||||
const detail = document.querySelector('pngx-document-detail')
|
||||
return (window as any).ng.getComponent(detail).networkActive()
|
||||
})
|
||||
)
|
||||
.toBe(false)
|
||||
const documentReloaded = page.waitForResponse(
|
||||
(response) =>
|
||||
response.url().includes('/api/documents/175/?full_perms=true') &&
|
||||
response.url().includes('/api/documents/1/?full_perms=true') &&
|
||||
response.request().method() === 'GET'
|
||||
)
|
||||
statusSocket.send(
|
||||
JSON.stringify({
|
||||
type: 'document_updated',
|
||||
data: {
|
||||
document_id: 175,
|
||||
modified: '2026-07-26T20:00:00Z',
|
||||
},
|
||||
await page.evaluate(() => {
|
||||
const detail = document.querySelector('pngx-document-detail')
|
||||
const component = (window as any).ng.getComponent(detail)
|
||||
component.handleIncomingDocumentUpdated({
|
||||
document_id: 1,
|
||||
modified: '2099-07-26T20:00:00Z',
|
||||
})
|
||||
)
|
||||
})
|
||||
await documentReloaded
|
||||
|
||||
await expect(
|
||||
page.getByText('Document reloaded with latest changes.').first()
|
||||
).toBeVisible()
|
||||
await expect
|
||||
.poll(() => previewRequestCount)
|
||||
.toBeGreaterThan(previewRequestsBeforeReload + 1)
|
||||
.toBeGreaterThan(previewRequestsBeforeReload)
|
||||
await expect
|
||||
.poll(() =>
|
||||
page.evaluate(() => {
|
||||
const detail = document.querySelector('pngx-document-detail')
|
||||
return (window as any).ng.getComponent(detail).previewLoaded()
|
||||
})
|
||||
)
|
||||
.toBe(true)
|
||||
})
|
||||
|
||||
@@ -1,19 +1,10 @@
|
||||
import { expect, test } from '@playwright/test'
|
||||
import path from 'node:path'
|
||||
|
||||
const REQUESTS_HAR1 = path.join(__dirname, 'requests/api-document-list1.har')
|
||||
const REQUESTS_HAR2 = path.join(__dirname, 'requests/api-document-list2.har')
|
||||
const REQUESTS_HAR3 = path.join(__dirname, 'requests/api-document-list3.har')
|
||||
const REQUESTS_HAR4 = path.join(__dirname, 'requests/api-document-list4.har')
|
||||
const REQUESTS_HAR5 = path.join(__dirname, 'requests/api-document-list5.har')
|
||||
const REQUESTS_HAR6 = path.join(__dirname, 'requests/api-document-list6.har')
|
||||
|
||||
test('basic filtering', async ({ page }) => {
|
||||
await page.routeFromHAR(REQUESTS_HAR1, { notFound: 'fallback' })
|
||||
await page.goto('/documents')
|
||||
await page.getByRole('button', { name: 'Tags' }).click()
|
||||
await page.getByRole('menuitem', { name: 'Inbox' }).click()
|
||||
await expect(page).toHaveURL(/tags__id__all=9/)
|
||||
await expect(page).toHaveURL(/tags__id__all=1/)
|
||||
await expect(page.locator('pngx-document-list')).toHaveText(/8 documents/)
|
||||
await page.getByRole('button', { name: 'Document type' }).click()
|
||||
await page.getByRole('menuitem', { name: /^Invoice Test/ }).click()
|
||||
@@ -23,28 +14,27 @@ test('basic filtering', async ({ page }) => {
|
||||
await page.getByRole('button', { name: 'Correspondent' }).click()
|
||||
await page.getByRole('menuitem', { name: 'Test Correspondent 1' }).click()
|
||||
await page.getByRole('menuitem', { name: 'Correspondent 9' }).click()
|
||||
await expect(page).toHaveURL(/correspondent__id__in=12,1/)
|
||||
await expect(page).toHaveURL(/correspondent__id__in=(?:1,2|2,1)/)
|
||||
await expect(page.locator('pngx-document-list')).toHaveText(/7 documents/)
|
||||
await page
|
||||
.locator('pngx-filter-editor')
|
||||
.getByTitle('Correspondent')
|
||||
.getByText('Exclude')
|
||||
.click()
|
||||
await expect(page).toHaveURL(/correspondent__id__none=12,1/)
|
||||
await expect(page).toHaveURL(/correspondent__id__none=(?:1,2|2,1)/)
|
||||
await expect(page.locator('pngx-document-list')).toHaveText(/54 documents/)
|
||||
// clear button
|
||||
await page.getByRole('button', { name: '2 selected', exact: true }).click()
|
||||
await expect(page.locator('pngx-document-list')).toHaveText(/61 documents/)
|
||||
await page.getByRole('button', { name: 'Storage path' }).click()
|
||||
await page.getByRole('menuitem', { name: 'Testing 12' }).click()
|
||||
await expect(page).toHaveURL(/storage_path__id__in=5/)
|
||||
await expect(page).toHaveURL(/storage_path__id__in=1/)
|
||||
await expect(page.locator('pngx-document-list')).toHaveText(/8 documents/)
|
||||
await page.getByRole('button', { name: 'Reset filters' }).first().click()
|
||||
await expect(page.locator('pngx-document-list')).toHaveText(/61 documents/)
|
||||
})
|
||||
|
||||
test('text filtering', async ({ page }) => {
|
||||
await page.routeFromHAR(REQUESTS_HAR2, { notFound: 'fallback' })
|
||||
await page.goto('/documents')
|
||||
await page.getByRole('main').getByRole('combobox').click()
|
||||
await page.getByRole('main').getByRole('combobox').fill('test')
|
||||
@@ -57,7 +47,7 @@ test('text filtering', async ({ page }) => {
|
||||
await page.getByRole('button', { name: 'Title', exact: true }).click()
|
||||
await page.getByRole('button', { name: 'Advanced search' }).click()
|
||||
await expect(page).toHaveURL(/query=test/)
|
||||
await expect(page.locator('pngx-document-list')).toHaveText(/26 documents/)
|
||||
await expect(page.locator('pngx-document-list')).toHaveText(/32 documents/)
|
||||
await page.getByRole('button', { name: 'Advanced search' }).click()
|
||||
await page.getByRole('button', { name: 'ASN' }).click()
|
||||
await page.getByRole('main').getByRole('combobox').nth(1).fill('1123')
|
||||
@@ -80,7 +70,6 @@ test('text filtering', async ({ page }) => {
|
||||
})
|
||||
|
||||
test('date filtering', async ({ page }) => {
|
||||
await page.routeFromHAR(REQUESTS_HAR3, { notFound: 'fallback' })
|
||||
await page.goto('/documents')
|
||||
await page.getByRole('button', { name: 'Dates' }).click()
|
||||
await page.locator('.ng-arrow-wrapper').first().click()
|
||||
@@ -94,15 +83,14 @@ test('date filtering', async ({ page }) => {
|
||||
await page.getByRole('option', { name: 'Within 3 months' }).click()
|
||||
await page.getByLabel('Dates selected').locator('button').first().click()
|
||||
await page.getByLabel('Dates selected').locator('button').first().click()
|
||||
await page.getByRole('combobox', { name: 'Select month' }).selectOption('12')
|
||||
await page.getByRole('combobox', { name: 'Select year' }).selectOption('2022')
|
||||
await page.getByText('11', { exact: true }).click()
|
||||
const createdFrom = page.getByRole('textbox', { name: 'mm/dd/yyyy' }).first()
|
||||
await createdFrom.fill('12/11/2022')
|
||||
await createdFrom.press('Enter')
|
||||
await page.getByRole('button', { name: 'Title & content' }).click()
|
||||
await expect(page.locator('pngx-document-list')).toHaveText(/2 documents/)
|
||||
await expect(page.locator('pngx-document-list')).toHaveText(/3 documents/)
|
||||
})
|
||||
|
||||
test('sorting', async ({ page }) => {
|
||||
await page.routeFromHAR(REQUESTS_HAR4, { notFound: 'fallback' })
|
||||
await page.goto('/documents')
|
||||
await page.getByRole('button', { name: 'Sort' }).click()
|
||||
await page.getByRole('button', { name: 'ASN' }).click()
|
||||
@@ -140,7 +128,6 @@ test('sorting', async ({ page }) => {
|
||||
})
|
||||
|
||||
test('change views', async ({ page }) => {
|
||||
await page.routeFromHAR(REQUESTS_HAR5, { notFound: 'fallback' })
|
||||
await page.goto('/documents')
|
||||
await page.locator('.btn-group > label').first().click()
|
||||
await expect(page.locator('pngx-document-list table')).toBeVisible()
|
||||
@@ -151,12 +138,18 @@ test('change views', async ({ page }) => {
|
||||
})
|
||||
|
||||
test('bulk edit', async ({ page }) => {
|
||||
await page.routeFromHAR(REQUESTS_HAR6, { notFound: 'fallback' })
|
||||
const uiSettingsLoaded = page.waitForResponse(
|
||||
(response) => response.url().includes('/api/ui_settings/') && response.ok()
|
||||
)
|
||||
await page.goto('/documents')
|
||||
await uiSettingsLoaded
|
||||
|
||||
await page.locator('pngx-document-card-small').nth(0).click()
|
||||
await page
|
||||
.locator('pngx-document-card-small')
|
||||
.locator('pngx-document-card-small .doc-img-container')
|
||||
.nth(0)
|
||||
.click()
|
||||
await page
|
||||
.locator('pngx-document-card-small .doc-img-container')
|
||||
.nth(3)
|
||||
.click({
|
||||
modifiers: ['Shift'],
|
||||
@@ -176,8 +169,14 @@ test('bulk edit', async ({ page }) => {
|
||||
)
|
||||
await page.getByRole('button', { name: 'None' }).click()
|
||||
|
||||
await page.locator('pngx-document-card-small').nth(1).click()
|
||||
await page.locator('pngx-document-card-small').nth(2).click()
|
||||
await page
|
||||
.locator('pngx-document-card-small .doc-img-container')
|
||||
.nth(1)
|
||||
.click()
|
||||
await page
|
||||
.locator('pngx-document-card-small .doc-img-container')
|
||||
.nth(2)
|
||||
.click()
|
||||
|
||||
await page.getByRole('button', { name: 'Tags' }).click()
|
||||
await page
|
||||
@@ -185,15 +184,26 @@ test('bulk edit', async ({ page }) => {
|
||||
.fill('TagWithPartial')
|
||||
await page.getByRole('menuitem', { name: 'TagWithPartial' }).click()
|
||||
|
||||
await page.getByRole('button', { name: 'Apply' }).click()
|
||||
|
||||
const bulkEditPromise = page.waitForRequest((request) => {
|
||||
if (
|
||||
!request.url().includes('/api/documents/bulk_edit/') ||
|
||||
request.method() !== 'POST'
|
||||
) {
|
||||
return false
|
||||
}
|
||||
const postData = request.postDataJSON()
|
||||
let isValid = postData['method'] == 'modify_tags'
|
||||
isValid = isValid && postData['parameters']['add_tags'].includes(5)
|
||||
return request.url().toString().includes('bulk_edit') && isValid
|
||||
isValid =
|
||||
isValid &&
|
||||
[
|
||||
...postData['parameters']['add_tags'],
|
||||
...postData['parameters']['remove_tags'],
|
||||
].includes(3)
|
||||
return isValid
|
||||
})
|
||||
|
||||
await page.getByRole('button', { name: 'Apply' }).click()
|
||||
await expect(page.getByRole('button', { name: 'Confirm' })).toBeVisible()
|
||||
await page.getByRole('button', { name: 'Confirm' }).click()
|
||||
await bulkEditPromise
|
||||
})
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { expect, test } from '@playwright/test'
|
||||
import path from 'node:path'
|
||||
|
||||
const REQUESTS_HAR = path.join(__dirname, 'requests/api-global-permissions.har')
|
||||
test.use({
|
||||
extraHTTPHeaders: {
|
||||
Authorization: `Basic ${Buffer.from('viewer:viewer').toString('base64')}`,
|
||||
},
|
||||
})
|
||||
|
||||
test('should not allow user to edit settings', async ({ page }) => {
|
||||
await page.routeFromHAR(REQUESTS_HAR, { notFound: 'fallback' })
|
||||
await page.goto('/dashboard')
|
||||
await expect(page.getByRole('link', { name: 'Settings' })).not.toBeAttached()
|
||||
await page.goto('/settings')
|
||||
@@ -14,7 +16,6 @@ test('should not allow user to edit settings', async ({ page }) => {
|
||||
})
|
||||
|
||||
test('should not allow user to view documents', async ({ page }) => {
|
||||
await page.routeFromHAR(REQUESTS_HAR, { notFound: 'fallback' })
|
||||
await page.goto('/dashboard')
|
||||
await expect(
|
||||
page.locator('nav').getByRole('link', { name: 'Documents' })
|
||||
@@ -30,7 +31,6 @@ test('should not allow user to view documents', async ({ page }) => {
|
||||
})
|
||||
|
||||
test('should not allow user to view correspondents', async ({ page }) => {
|
||||
await page.routeFromHAR(REQUESTS_HAR, { notFound: 'fallback' })
|
||||
await page.goto('/dashboard')
|
||||
await expect(
|
||||
page.getByRole('link', { name: 'Attributes' })
|
||||
@@ -42,7 +42,6 @@ test('should not allow user to view correspondents', async ({ page }) => {
|
||||
})
|
||||
|
||||
test('should not allow user to view tags', async ({ page }) => {
|
||||
await page.routeFromHAR(REQUESTS_HAR, { notFound: 'fallback' })
|
||||
await page.goto('/dashboard')
|
||||
await expect(
|
||||
page.getByRole('link', { name: 'Attributes' })
|
||||
@@ -54,7 +53,6 @@ test('should not allow user to view tags', async ({ page }) => {
|
||||
})
|
||||
|
||||
test('should not allow user to view document types', async ({ page }) => {
|
||||
await page.routeFromHAR(REQUESTS_HAR, { notFound: 'fallback' })
|
||||
await page.goto('/dashboard')
|
||||
await expect(
|
||||
page.getByRole('link', { name: 'Attributes' })
|
||||
@@ -66,7 +64,6 @@ test('should not allow user to view document types', async ({ page }) => {
|
||||
})
|
||||
|
||||
test('should not allow user to view storage paths', async ({ page }) => {
|
||||
await page.routeFromHAR(REQUESTS_HAR, { notFound: 'fallback' })
|
||||
await page.goto('/dashboard')
|
||||
await expect(
|
||||
page.getByRole('link', { name: 'Attributes' })
|
||||
@@ -78,7 +75,6 @@ test('should not allow user to view storage paths', async ({ page }) => {
|
||||
})
|
||||
|
||||
test('should not allow user to view logs', async ({ page }) => {
|
||||
await page.routeFromHAR(REQUESTS_HAR, { notFound: 'fallback' })
|
||||
await page.goto('/dashboard')
|
||||
await expect(page.getByRole('link', { name: 'Logs' })).not.toBeAttached()
|
||||
await page.goto('/logs')
|
||||
@@ -88,7 +84,6 @@ test('should not allow user to view logs', async ({ page }) => {
|
||||
})
|
||||
|
||||
test('should not allow user to view tasks', async ({ page }) => {
|
||||
await page.routeFromHAR(REQUESTS_HAR, { notFound: 'fallback' })
|
||||
await page.goto('/dashboard')
|
||||
await expect(page.getByRole('link', { name: 'Tasks' })).not.toBeAttached()
|
||||
await page.goto('/tasks')
|
||||
|
||||
@@ -1,353 +0,0 @@
|
||||
{
|
||||
"log": {
|
||||
"version": "1.2",
|
||||
"creator": {
|
||||
"name": "Playwright",
|
||||
"version": "1.33.0"
|
||||
},
|
||||
"browser": {
|
||||
"name": "chromium",
|
||||
"version": "113.0.5672.53"
|
||||
},
|
||||
"entries": [
|
||||
{
|
||||
"startedDateTime": "2023-05-14T07:16:51.455Z",
|
||||
"time": 5.787,
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"url": "http://localhost:8000/api/ui_settings/",
|
||||
"httpVersion": "HTTP/1.1",
|
||||
"cookies": [],
|
||||
"headers": [
|
||||
{ "name": "Accept", "value": "application/json; version=3" },
|
||||
{ "name": "Accept-Encoding", "value": "gzip, deflate, br" },
|
||||
{ "name": "Accept-Language", "value": "en-US" },
|
||||
{ "name": "Connection", "value": "keep-alive" },
|
||||
{ "name": "Host", "value": "localhost:8000" },
|
||||
{ "name": "Origin", "value": "http://localhost:4200" },
|
||||
{ "name": "Referer", "value": "http://localhost:4200/" },
|
||||
{ "name": "Sec-Fetch-Dest", "value": "empty" },
|
||||
{ "name": "Sec-Fetch-Mode", "value": "cors" },
|
||||
{ "name": "Sec-Fetch-Site", "value": "same-site" },
|
||||
{ "name": "User-Agent", "value": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/113.0.5672.53 Safari/537.36" }
|
||||
],
|
||||
"queryString": [],
|
||||
"headersSize": -1,
|
||||
"bodySize": -1
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"statusText": "OK",
|
||||
"httpVersion": "HTTP/1.1",
|
||||
"cookies": [],
|
||||
"headers": [
|
||||
{ "name": "Access-Control-Allow-Origin", "value": "http://localhost:4200" },
|
||||
{ "name": "Allow", "value": "GET, POST, HEAD, OPTIONS" },
|
||||
{ "name": "Content-Encoding", "value": "br" },
|
||||
{ "name": "Content-Language", "value": "en-us" },
|
||||
{ "name": "Content-Length", "value": "385" },
|
||||
{ "name": "Content-Type", "value": "application/json" },
|
||||
{ "name": "Cross-Origin-Opener-Policy", "value": "same-origin" },
|
||||
{ "name": "Referrer-Policy", "value": "same-origin" },
|
||||
{ "name": "Vary", "value": "Accept, Accept-Language, Origin, Cookie, Accept-Encoding" },
|
||||
{ "name": "X-Api-Version", "value": "3" },
|
||||
{ "name": "X-Content-Type-Options", "value": "nosniff" },
|
||||
{ "name": "X-Frame-Options", "value": "ANY" },
|
||||
{ "name": "X-Version", "value": "1.14.4" }
|
||||
],
|
||||
"content": {
|
||||
"size": -1,
|
||||
"mimeType": "application/json",
|
||||
"text": "{\"user\":{\"id\":2,\"username\":\"testuser\",\"is_superuser\":false,\"groups\":[]},\"settings\":{\"language\":\"\",\"bulk_edit\":{\"confirmation_dialogs\":true,\"apply_on_close\":false},\"documentListSize\":50,\"dark_mode\":{\"use_system\":false,\"enabled\":\"false\",\"thumb_inverted\":\"true\"},\"theme\":{\"color\":\"#9fbf2f\"},\"document_details\":{\"native_pdf_viewer\":false},\"date_display\":{\"date_locale\":\"\",\"date_format\":\"mediumDate\"},\"notifications\":{\"consumer_new_documents\":true,\"consumer_success\":true,\"consumer_failed\":true,\"consumer_suppress_on_dashboard\":true},\"comments_enabled\":true,\"slim_sidebar\":false,\"update_checking\":{\"enabled\":false,\"backend_setting\":\"default\"},\"saved_views\":{\"warn_on_unsaved_change\":true},\"notes_enabled\":true,\"tour_complete\":true},\"permissions\":[]}"
|
||||
},
|
||||
"headersSize": -1,
|
||||
"bodySize": -1,
|
||||
"redirectURL": ""
|
||||
},
|
||||
"cache": {},
|
||||
"timings": { "send": -1, "wait": -1, "receive": 5.787 }
|
||||
},
|
||||
{
|
||||
"startedDateTime": "2023-05-14T07:16:51.578Z",
|
||||
"time": 0.566,
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"url": "http://localhost:8000/api/tasks/",
|
||||
"httpVersion": "HTTP/1.1",
|
||||
"cookies": [],
|
||||
"headers": [
|
||||
{ "name": "Accept", "value": "application/json; version=3" },
|
||||
{ "name": "Accept-Encoding", "value": "gzip, deflate, br" },
|
||||
{ "name": "Accept-Language", "value": "en-US" },
|
||||
{ "name": "Connection", "value": "keep-alive" },
|
||||
{ "name": "Host", "value": "localhost:8000" },
|
||||
{ "name": "Origin", "value": "http://localhost:4200" },
|
||||
{ "name": "Referer", "value": "http://localhost:4200/" },
|
||||
{ "name": "Sec-Fetch-Dest", "value": "empty" },
|
||||
{ "name": "Sec-Fetch-Mode", "value": "cors" },
|
||||
{ "name": "Sec-Fetch-Site", "value": "same-site" },
|
||||
{ "name": "User-Agent", "value": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/113.0.5672.53 Safari/537.36" }
|
||||
],
|
||||
"queryString": [],
|
||||
"headersSize": -1,
|
||||
"bodySize": -1
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"statusText": "OK",
|
||||
"httpVersion": "HTTP/1.1",
|
||||
"cookies": [],
|
||||
"headers": [
|
||||
{ "name": "Access-Control-Allow-Origin", "value": "http://localhost:4200" },
|
||||
{ "name": "Allow", "value": "GET, HEAD, OPTIONS" },
|
||||
{ "name": "Content-Language", "value": "en-us" },
|
||||
{ "name": "Content-Length", "value": "2" },
|
||||
{ "name": "Content-Type", "value": "application/json" },
|
||||
{ "name": "Cross-Origin-Opener-Policy", "value": "same-origin" },
|
||||
{ "name": "Referrer-Policy", "value": "same-origin" },
|
||||
{ "name": "Vary", "value": "Accept, Accept-Language, Origin, Cookie" },
|
||||
{ "name": "X-Api-Version", "value": "3" },
|
||||
{ "name": "X-Content-Type-Options", "value": "nosniff" },
|
||||
{ "name": "X-Frame-Options", "value": "ANY" },
|
||||
{ "name": "X-Version", "value": "1.14.4" }
|
||||
],
|
||||
"content": {
|
||||
"size": -1,
|
||||
"mimeType": "application/json",
|
||||
"text": "[]"
|
||||
},
|
||||
"headersSize": -1,
|
||||
"bodySize": -1,
|
||||
"redirectURL": ""
|
||||
},
|
||||
"cache": {},
|
||||
"timings": { "send": -1, "wait": -1, "receive": 0.566 }
|
||||
},
|
||||
{
|
||||
"startedDateTime": "2023-05-14T07:16:51.578Z",
|
||||
"time": 0.452,
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"url": "http://localhost:8000/api/statistics/",
|
||||
"httpVersion": "HTTP/1.1",
|
||||
"cookies": [],
|
||||
"headers": [
|
||||
{ "name": "Accept", "value": "application/json; version=3" },
|
||||
{ "name": "Accept-Encoding", "value": "gzip, deflate, br" },
|
||||
{ "name": "Accept-Language", "value": "en-US" },
|
||||
{ "name": "Connection", "value": "keep-alive" },
|
||||
{ "name": "Host", "value": "localhost:8000" },
|
||||
{ "name": "Origin", "value": "http://localhost:4200" },
|
||||
{ "name": "Referer", "value": "http://localhost:4200/" },
|
||||
{ "name": "Sec-Fetch-Dest", "value": "empty" },
|
||||
{ "name": "Sec-Fetch-Mode", "value": "cors" },
|
||||
{ "name": "Sec-Fetch-Site", "value": "same-site" },
|
||||
{ "name": "User-Agent", "value": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/113.0.5672.53 Safari/537.36" }
|
||||
],
|
||||
"queryString": [],
|
||||
"headersSize": -1,
|
||||
"bodySize": -1
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"statusText": "OK",
|
||||
"httpVersion": "HTTP/1.1",
|
||||
"cookies": [],
|
||||
"headers": [
|
||||
{ "name": "Access-Control-Allow-Origin", "value": "http://localhost:4200" },
|
||||
{ "name": "Allow", "value": "GET, HEAD, OPTIONS" },
|
||||
{ "name": "Content-Language", "value": "en-us" },
|
||||
{ "name": "Content-Length", "value": "257" },
|
||||
{ "name": "Content-Type", "value": "application/json" },
|
||||
{ "name": "Cross-Origin-Opener-Policy", "value": "same-origin" },
|
||||
{ "name": "Referrer-Policy", "value": "same-origin" },
|
||||
{ "name": "Vary", "value": "Accept, Accept-Language, Origin, Cookie" },
|
||||
{ "name": "X-Api-Version", "value": "3" },
|
||||
{ "name": "X-Content-Type-Options", "value": "nosniff" },
|
||||
{ "name": "X-Frame-Options", "value": "ANY" },
|
||||
{ "name": "X-Version", "value": "1.14.4" }
|
||||
],
|
||||
"content": {
|
||||
"size": -1,
|
||||
"mimeType": "application/json",
|
||||
"text": "{\"documents_total\":61,\"documents_inbox\":8,\"inbox_tag\":9,\"document_file_type_counts\":[{\"mime_type\":\"application/pdf\",\"mime_type_count\":57},{\"mime_type\":\"text/plain\",\"mime_type_count\":3},{\"mime_type\":\"text/csv\",\"mime_type_count\":1}],\"character_count\":2407053}"
|
||||
},
|
||||
"headersSize": -1,
|
||||
"bodySize": -1,
|
||||
"redirectURL": ""
|
||||
},
|
||||
"cache": {},
|
||||
"timings": { "send": -1, "wait": -1, "receive": 0.452 }
|
||||
},
|
||||
{
|
||||
"startedDateTime": "2023-05-14T07:16:51.691Z",
|
||||
"time": 0.891,
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"url": "http://localhost:8000/api/ui_settings/",
|
||||
"httpVersion": "HTTP/1.1",
|
||||
"cookies": [],
|
||||
"headers": [
|
||||
{ "name": "Accept", "value": "application/json; version=3" },
|
||||
{ "name": "Accept-Encoding", "value": "gzip, deflate, br" },
|
||||
{ "name": "Accept-Language", "value": "en-US" },
|
||||
{ "name": "Connection", "value": "keep-alive" },
|
||||
{ "name": "Host", "value": "localhost:8000" },
|
||||
{ "name": "Origin", "value": "http://localhost:4200" },
|
||||
{ "name": "Referer", "value": "http://localhost:4200/" },
|
||||
{ "name": "Sec-Fetch-Dest", "value": "empty" },
|
||||
{ "name": "Sec-Fetch-Mode", "value": "cors" },
|
||||
{ "name": "Sec-Fetch-Site", "value": "same-site" },
|
||||
{ "name": "User-Agent", "value": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/113.0.5672.53 Safari/537.36" }
|
||||
],
|
||||
"queryString": [],
|
||||
"headersSize": -1,
|
||||
"bodySize": -1
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"statusText": "OK",
|
||||
"httpVersion": "HTTP/1.1",
|
||||
"cookies": [],
|
||||
"headers": [
|
||||
{ "name": "Access-Control-Allow-Origin", "value": "http://localhost:4200" },
|
||||
{ "name": "Allow", "value": "GET, POST, HEAD, OPTIONS" },
|
||||
{ "name": "Content-Encoding", "value": "br" },
|
||||
{ "name": "Content-Language", "value": "en-us" },
|
||||
{ "name": "Content-Length", "value": "385" },
|
||||
{ "name": "Content-Type", "value": "application/json" },
|
||||
{ "name": "Cross-Origin-Opener-Policy", "value": "same-origin" },
|
||||
{ "name": "Referrer-Policy", "value": "same-origin" },
|
||||
{ "name": "Vary", "value": "Accept, Accept-Language, Origin, Cookie, Accept-Encoding" },
|
||||
{ "name": "X-Api-Version", "value": "3" },
|
||||
{ "name": "X-Content-Type-Options", "value": "nosniff" },
|
||||
{ "name": "X-Frame-Options", "value": "ANY" },
|
||||
{ "name": "X-Version", "value": "1.14.4" }
|
||||
],
|
||||
"content": {
|
||||
"size": -1,
|
||||
"mimeType": "application/json",
|
||||
"text": "{\"user\":{\"id\":2,\"username\":\"testuser\",\"is_superuser\":false,\"groups\":[]},\"settings\":{\"language\":\"\",\"bulk_edit\":{\"confirmation_dialogs\":true,\"apply_on_close\":false},\"documentListSize\":50,\"dark_mode\":{\"use_system\":false,\"enabled\":\"false\",\"thumb_inverted\":\"true\"},\"theme\":{\"color\":\"#9fbf2f\"},\"document_details\":{\"native_pdf_viewer\":false},\"date_display\":{\"date_locale\":\"\",\"date_format\":\"mediumDate\"},\"notifications\":{\"consumer_new_documents\":true,\"consumer_success\":true,\"consumer_failed\":true,\"consumer_suppress_on_dashboard\":true},\"comments_enabled\":true,\"slim_sidebar\":false,\"update_checking\":{\"enabled\":false,\"backend_setting\":\"default\"},\"saved_views\":{\"warn_on_unsaved_change\":true},\"notes_enabled\":true,\"tour_complete\":true},\"permissions\":[]}"
|
||||
},
|
||||
"headersSize": -1,
|
||||
"bodySize": -1,
|
||||
"redirectURL": ""
|
||||
},
|
||||
"cache": {},
|
||||
"timings": { "send": -1, "wait": -1, "receive": 0.891 }
|
||||
},
|
||||
{
|
||||
"startedDateTime": "2023-05-14T07:16:51.739Z",
|
||||
"time": 0.405,
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"url": "http://localhost:8000/api/tasks/",
|
||||
"httpVersion": "HTTP/1.1",
|
||||
"cookies": [],
|
||||
"headers": [
|
||||
{ "name": "Accept", "value": "application/json; version=3" },
|
||||
{ "name": "Accept-Encoding", "value": "gzip, deflate, br" },
|
||||
{ "name": "Accept-Language", "value": "en-US" },
|
||||
{ "name": "Connection", "value": "keep-alive" },
|
||||
{ "name": "Host", "value": "localhost:8000" },
|
||||
{ "name": "Origin", "value": "http://localhost:4200" },
|
||||
{ "name": "Referer", "value": "http://localhost:4200/" },
|
||||
{ "name": "Sec-Fetch-Dest", "value": "empty" },
|
||||
{ "name": "Sec-Fetch-Mode", "value": "cors" },
|
||||
{ "name": "Sec-Fetch-Site", "value": "same-site" },
|
||||
{ "name": "User-Agent", "value": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/113.0.5672.53 Safari/537.36" }
|
||||
],
|
||||
"queryString": [],
|
||||
"headersSize": -1,
|
||||
"bodySize": -1
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"statusText": "OK",
|
||||
"httpVersion": "HTTP/1.1",
|
||||
"cookies": [],
|
||||
"headers": [
|
||||
{ "name": "Access-Control-Allow-Origin", "value": "http://localhost:4200" },
|
||||
{ "name": "Allow", "value": "GET, HEAD, OPTIONS" },
|
||||
{ "name": "Content-Language", "value": "en-us" },
|
||||
{ "name": "Content-Length", "value": "2" },
|
||||
{ "name": "Content-Type", "value": "application/json" },
|
||||
{ "name": "Cross-Origin-Opener-Policy", "value": "same-origin" },
|
||||
{ "name": "Referrer-Policy", "value": "same-origin" },
|
||||
{ "name": "Vary", "value": "Accept, Accept-Language, Origin, Cookie" },
|
||||
{ "name": "X-Api-Version", "value": "3" },
|
||||
{ "name": "X-Content-Type-Options", "value": "nosniff" },
|
||||
{ "name": "X-Frame-Options", "value": "ANY" },
|
||||
{ "name": "X-Version", "value": "1.14.4" }
|
||||
],
|
||||
"content": {
|
||||
"size": -1,
|
||||
"mimeType": "application/json",
|
||||
"text": "[]"
|
||||
},
|
||||
"headersSize": -1,
|
||||
"bodySize": -1,
|
||||
"redirectURL": ""
|
||||
},
|
||||
"cache": {},
|
||||
"timings": { "send": -1, "wait": -1, "receive": 0.405 }
|
||||
},
|
||||
{
|
||||
"startedDateTime": "2023-05-14T07:16:51.739Z",
|
||||
"time": 0.665,
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"url": "http://localhost:8000/api/statistics/",
|
||||
"httpVersion": "HTTP/1.1",
|
||||
"cookies": [],
|
||||
"headers": [
|
||||
{ "name": "Accept", "value": "application/json; version=3" },
|
||||
{ "name": "Accept-Encoding", "value": "gzip, deflate, br" },
|
||||
{ "name": "Accept-Language", "value": "en-US" },
|
||||
{ "name": "Connection", "value": "keep-alive" },
|
||||
{ "name": "Host", "value": "localhost:8000" },
|
||||
{ "name": "Origin", "value": "http://localhost:4200" },
|
||||
{ "name": "Referer", "value": "http://localhost:4200/" },
|
||||
{ "name": "Sec-Fetch-Dest", "value": "empty" },
|
||||
{ "name": "Sec-Fetch-Mode", "value": "cors" },
|
||||
{ "name": "Sec-Fetch-Site", "value": "same-site" },
|
||||
{ "name": "User-Agent", "value": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/113.0.5672.53 Safari/537.36" }
|
||||
],
|
||||
"queryString": [],
|
||||
"headersSize": -1,
|
||||
"bodySize": -1
|
||||
},
|
||||
"response": {
|
||||
"status": 200,
|
||||
"statusText": "OK",
|
||||
"httpVersion": "HTTP/1.1",
|
||||
"cookies": [],
|
||||
"headers": [
|
||||
{ "name": "Access-Control-Allow-Origin", "value": "http://localhost:4200" },
|
||||
{ "name": "Allow", "value": "GET, HEAD, OPTIONS" },
|
||||
{ "name": "Content-Language", "value": "en-us" },
|
||||
{ "name": "Content-Length", "value": "257" },
|
||||
{ "name": "Content-Type", "value": "application/json" },
|
||||
{ "name": "Cross-Origin-Opener-Policy", "value": "same-origin" },
|
||||
{ "name": "Referrer-Policy", "value": "same-origin" },
|
||||
{ "name": "Vary", "value": "Accept, Accept-Language, Origin, Cookie" },
|
||||
{ "name": "X-Api-Version", "value": "3" },
|
||||
{ "name": "X-Content-Type-Options", "value": "nosniff" },
|
||||
{ "name": "X-Frame-Options", "value": "ANY" },
|
||||
{ "name": "X-Version", "value": "1.14.4" }
|
||||
],
|
||||
"content": {
|
||||
"size": -1,
|
||||
"mimeType": "application/json",
|
||||
"text": "{\"documents_total\":61,\"documents_inbox\":8,\"inbox_tag\":9,\"document_file_type_counts\":[{\"mime_type\":\"application/pdf\",\"mime_type_count\":57},{\"mime_type\":\"text/plain\",\"mime_type_count\":3},{\"mime_type\":\"text/csv\",\"mime_type_count\":1}],\"character_count\":2407053}"
|
||||
},
|
||||
"headersSize": -1,
|
||||
"bodySize": -1,
|
||||
"redirectURL": ""
|
||||
},
|
||||
"cache": {},
|
||||
"timings": { "send": -1, "wait": -1, "receive": 0.665 }
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,15 @@
|
||||
{
|
||||
"name": "paperless-ngx-ui",
|
||||
"version": "3.1.0",
|
||||
"version": "3.1.2",
|
||||
"scripts": {
|
||||
"preinstall": "npx only-allow pnpm",
|
||||
"ng": "ng",
|
||||
"start": "ng serve",
|
||||
"build": "ng build",
|
||||
"e2e": "playwright test",
|
||||
"e2e:backend": "uv run --project .. --no-sync python e2e/backend.py",
|
||||
"e2e:ui": "playwright test --ui",
|
||||
"start:e2e": "ng serve --configuration=e2e",
|
||||
"test": "ng test",
|
||||
"lint": "ng lint"
|
||||
},
|
||||
|
||||
@@ -14,17 +14,25 @@ export default defineConfig({
|
||||
forbidOnly: !!process.env.CI,
|
||||
/* Retry on CI only */
|
||||
retries: process.env.CI ? 3 : 0,
|
||||
/* Opt out of parallel tests on CI. */
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
/* Keep parallelism modest for the shared SQLite backend. */
|
||||
workers: 2,
|
||||
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
||||
reporter: 'html',
|
||||
/* Run your local dev server before starting the tests */
|
||||
webServer: {
|
||||
port,
|
||||
command: 'pnpm run start',
|
||||
reuseExistingServer: !process.env.CI,
|
||||
timeout: 2 * 60 * 1000,
|
||||
},
|
||||
/* Run the disposable backend and local UI before starting the tests. */
|
||||
webServer: [
|
||||
{
|
||||
url: 'http://localhost:8001/accounts/login/',
|
||||
command: 'npm run e2e:backend',
|
||||
reuseExistingServer: false,
|
||||
timeout: 2 * 60 * 1000,
|
||||
},
|
||||
{
|
||||
port,
|
||||
command: 'npm run start:e2e',
|
||||
reuseExistingServer: !process.env.CI,
|
||||
timeout: 2 * 60 * 1000,
|
||||
},
|
||||
],
|
||||
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
||||
use: {
|
||||
/* Base URL to use in actions like `await page.goto('/')`. */
|
||||
|
||||
@@ -237,6 +237,12 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<pngx-input-check i18n-title title="Automatically request suggestions for inbox documents" i18n-hint hint="If un-checked, suggestions must be requested via the Suggest button." formControlName="documentEditingAutoSuggest"></pngx-input-check>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<pngx-input-check i18n-title title="Show document thumbnail during loading" formControlName="documentEditingOverlayThumbnail"></pngx-input-check>
|
||||
|
||||
@@ -267,7 +267,7 @@ describe('SettingsComponent', () => {
|
||||
expect(toastErrorSpy).toHaveBeenCalled()
|
||||
expect(storeSpy).toHaveBeenCalled()
|
||||
expect(appearanceSettingsSpy).not.toHaveBeenCalled()
|
||||
expect(setSpy).toHaveBeenCalledTimes(32)
|
||||
expect(setSpy).toHaveBeenCalledTimes(33)
|
||||
|
||||
// succeed
|
||||
storeSpy.mockReturnValueOnce(of(true))
|
||||
|
||||
@@ -168,6 +168,7 @@ export class SettingsComponent
|
||||
pdfEditorDefaultEditMode: new FormControl(null),
|
||||
documentEditingRemoveInboxTags: new FormControl(null),
|
||||
documentEditingOverlayThumbnail: new FormControl(null),
|
||||
documentEditingAutoSuggest: new FormControl(null),
|
||||
documentDetailsHiddenFields: new FormControl([]),
|
||||
searchDbOnly: new FormControl(null),
|
||||
searchLink: new FormControl(null),
|
||||
@@ -368,6 +369,9 @@ export class SettingsComponent
|
||||
documentEditingOverlayThumbnail: this.settings.get(
|
||||
SETTINGS_KEYS.DOCUMENT_EDITING_OVERLAY_THUMBNAIL
|
||||
),
|
||||
documentEditingAutoSuggest: this.settings.get(
|
||||
SETTINGS_KEYS.DOCUMENT_EDITING_AUTO_SUGGEST
|
||||
),
|
||||
documentDetailsHiddenFields: this.settings.get(
|
||||
SETTINGS_KEYS.DOCUMENT_DETAILS_HIDDEN_FIELDS
|
||||
),
|
||||
@@ -565,6 +569,10 @@ export class SettingsComponent
|
||||
SETTINGS_KEYS.DOCUMENT_EDITING_OVERLAY_THUMBNAIL,
|
||||
this.settingsForm.value.documentEditingOverlayThumbnail
|
||||
)
|
||||
this.settings.set(
|
||||
SETTINGS_KEYS.DOCUMENT_EDITING_AUTO_SUGGEST,
|
||||
this.settingsForm.value.documentEditingAutoSuggest
|
||||
)
|
||||
this.settings.set(
|
||||
SETTINGS_KEYS.DOCUMENT_DETAILS_HIDDEN_FIELDS,
|
||||
this.settingsForm.value.documentDetailsHiddenFields
|
||||
|
||||
@@ -41,6 +41,8 @@ export class TrashComponent
|
||||
private modalService = inject(NgbModal)
|
||||
private settingsService = inject(SettingsService)
|
||||
private router = inject(Router)
|
||||
private readonly emptyTrashDelaySetting =
|
||||
this.settingsService.getSignal<number>(SETTINGS_KEYS.EMPTY_TRASH_DELAY)
|
||||
|
||||
readonly documentsInTrash = signal<Document[]>([])
|
||||
readonly selectedDocuments = signal<Set<number>>(new Set())
|
||||
@@ -200,8 +202,7 @@ export class TrashComponent
|
||||
}
|
||||
|
||||
getDaysRemaining(document: Document): number {
|
||||
this.settingsService.trackChanges()
|
||||
const delay = this.settingsService.get(SETTINGS_KEYS.EMPTY_TRASH_DELAY)
|
||||
const delay = this.emptyTrashDelaySetting()
|
||||
const diff = new Date().getTime() - new Date(document.deleted_at).getTime()
|
||||
const days = Math.ceil(diff / (1000 * 3600 * 24))
|
||||
return delay - days
|
||||
|
||||
@@ -73,7 +73,7 @@
|
||||
<div class="container-fluid">
|
||||
<div class="row">
|
||||
<nav id="sidebarMenu" class="d-md-block bg-light sidebar collapse"
|
||||
[ngClass]="slimSidebarEnabled ? 'slim' : 'col-md-3 col-lg-2 col-xxxl-1'" [class.animating]="slimSidebarAnimating()"
|
||||
[ngClass]="slimSidebarEnabled ? 'slim' : 'expanded'" [class.animating]="slimSidebarAnimating()"
|
||||
[ngbCollapse]="isMenuCollapsed()">
|
||||
@if (canSaveSettings) {
|
||||
<button class="btn btn-sm btn-dark sidebar-slim-toggler" (click)="toggleSlimSidebar()" [aria-label]="slimSidebarEnabled ? 'Expand sidebar' : 'Collapse sidebar'" i18n-aria-label>
|
||||
@@ -90,7 +90,7 @@
|
||||
<a class="nav-link" routerLink="dashboard" routerLinkActive="active" (click)="closeMenu()"
|
||||
ngbPopover="Dashboard" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end"
|
||||
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||
<i-bs class="me-2" name="house"></i-bs><span><ng-container i18n>Dashboard</ng-container></span>
|
||||
<i-bs class="me-2" name="house"></i-bs><span class="nav-link-label"><ng-container i18n>Dashboard</ng-container></span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }">
|
||||
@@ -99,7 +99,7 @@
|
||||
(click)="closeMenu()"
|
||||
ngbPopover="Documents" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end"
|
||||
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 class="nav-link-label"><ng-container i18n>Documents</ng-container></span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
@@ -111,7 +111,7 @@
|
||||
</h6>
|
||||
<ul class="nav flex-column mb-2" cdkDropList (cdkDropListDropped)="onDrop($event)">
|
||||
@for (view of savedViewService.sidebarViews; track view.id) {
|
||||
<li class="nav-item w-100 app-link" cdkDrag [cdkDragDisabled]="!settingsService.organizingSidebarSavedViews() || !canSaveSettings"
|
||||
<li class="nav-item app-link" cdkDrag [cdkDragDisabled]="!settingsService.organizingSidebarSavedViews() || !canSaveSettings"
|
||||
cdkDragPreviewContainer="parent" cdkDragPreviewClass="navItemDrag" (cdkDragStarted)="onDragStart($event)"
|
||||
(cdkDragEnded)="onDragEnd($event)">
|
||||
<a class="nav-link" routerLink="view/{{view.id}}"
|
||||
@@ -128,7 +128,7 @@
|
||||
}
|
||||
</a>
|
||||
@if (settingsService.organizingSidebarSavedViews() && canSaveSettings) {
|
||||
<div class="position-absolute end-0 top-0 px-3 py-2" [class.me-n3]="slimSidebarEnabled" cdkDragHandle>
|
||||
<div class="position-absolute end-0 top-0 px-1 py-2" [class.me-n2]="slimSidebarEnabled" cdkDragHandle>
|
||||
<i-bs name="grip-vertical"></i-bs>
|
||||
</div>
|
||||
}
|
||||
@@ -170,7 +170,7 @@
|
||||
[class.text-truncate]="!slimSidebarEnabled" (click)="closeAll()"
|
||||
ngbPopover="Close all" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end"
|
||||
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 class="nav-link-label"><ng-container i18n>Close all</ng-container></span>
|
||||
</button>
|
||||
</li>
|
||||
}
|
||||
@@ -189,7 +189,7 @@
|
||||
[routerLinkActiveOptions]="{ exact: !(slimSidebarEnabled || attributesSectionsCollapsed) }" (click)="closeMenu()"
|
||||
ngbPopover="Attributes" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end"
|
||||
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||
<i-bs name="stack"></i-bs><span class="ms-2"><ng-container i18n>Attributes</ng-container></span>
|
||||
<i-bs name="stack"></i-bs><span class="nav-link-label ms-2"><ng-container i18n>Attributes</ng-container></span>
|
||||
</a>
|
||||
@if (!slimSidebarEnabled && canSaveSettings) {
|
||||
<button
|
||||
@@ -210,27 +210,27 @@
|
||||
<ul class="nav flex-column">
|
||||
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Tag }">
|
||||
<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 class="nav-link-label"><ng-container i18n>Tags</ng-container></span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Correspondent }">
|
||||
<a class="nav-link" 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 class="nav-link-label"><ng-container i18n>Correspondents</ng-container></span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.DocumentType }">
|
||||
<a class="nav-link" 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 class="nav-link-label"><ng-container i18n>Document types</ng-container></span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.StoragePath }">
|
||||
<a class="nav-link" routerLink="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 class="nav-link-label"><ng-container i18n>Storage paths</ng-container></span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.CustomField }">
|
||||
<a class="nav-link" routerLink="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 class="nav-link-label"><ng-container i18n>Custom fields</ng-container></span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
@@ -241,7 +241,7 @@
|
||||
<a class="nav-link" routerLink="savedviews" routerLinkActive="active" (click)="closeMenu()"
|
||||
ngbPopover="Saved Views" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end"
|
||||
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||
<i-bs class="me-2" name="window-stack"></i-bs><span><ng-container i18n>Saved Views</ng-container></span>
|
||||
<i-bs class="me-2" name="window-stack"></i-bs><span class="nav-link-label"><ng-container i18n>Saved Views</ng-container></span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item app-link"
|
||||
@@ -250,7 +250,7 @@
|
||||
<a class="nav-link" routerLink="workflows" routerLinkActive="active" (click)="closeMenu()"
|
||||
ngbPopover="Workflows" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end"
|
||||
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||
<i-bs class="me-2" name="boxes"></i-bs><span><ng-container i18n>Workflows</ng-container></span>
|
||||
<i-bs class="me-2" name="boxes"></i-bs><span class="nav-link-label"><ng-container i18n>Workflows</ng-container></span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.MailAccount }"
|
||||
@@ -258,14 +258,14 @@
|
||||
<a class="nav-link" routerLink="mail" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Mail"
|
||||
i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end" container="body"
|
||||
triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||
<i-bs class="me-2" name="envelope"></i-bs><span><ng-container i18n>Mail</ng-container></span>
|
||||
<i-bs class="me-2" name="envelope"></i-bs><span class="nav-link-label"><ng-container i18n>Mail</ng-container></span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.Document }">
|
||||
<a class="nav-link" routerLink="trash" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Trash"
|
||||
i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end" container="body"
|
||||
triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||
<i-bs class="me-2" name="trash"></i-bs><span><ng-container i18n>Trash</ng-container></span>
|
||||
<i-bs class="me-2" name="trash"></i-bs><span class="nav-link-label"><ng-container i18n>Trash</ng-container></span>
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
@@ -281,21 +281,21 @@
|
||||
<a class="nav-link" routerLink="settings" routerLinkActive="active" (click)="closeMenu()"
|
||||
ngbPopover="Settings" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end"
|
||||
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||
<i-bs class="me-2" name="gear"></i-bs><span><ng-container i18n>Settings</ng-container></span>
|
||||
<i-bs class="me-2" name="gear"></i-bs><span class="nav-link-label"><ng-container i18n>Settings</ng-container></span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.AppConfig }">
|
||||
<a class="nav-link" routerLink="config" routerLinkActive="active" (click)="closeMenu()"
|
||||
ngbPopover="Configuration" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end"
|
||||
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||
<i-bs class="me-2" name="sliders2-vertical"></i-bs><span><ng-container i18n>Configuration</ng-container></span>
|
||||
<i-bs class="me-2" name="sliders2-vertical"></i-bs><span class="nav-link-label"><ng-container i18n>Configuration</ng-container></span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.User }">
|
||||
<a class="nav-link" routerLink="usersgroups" routerLinkActive="active" (click)="closeMenu()"
|
||||
ngbPopover="Users & Groups" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end"
|
||||
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||
<i-bs class="me-2" name="people"></i-bs><span><ng-container i18n>Users & Groups</ng-container></span>
|
||||
<i-bs class="me-2" name="people"></i-bs><span class="nav-link-label"><ng-container i18n>Users & Groups</ng-container></span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item app-link"
|
||||
@@ -304,9 +304,10 @@
|
||||
<a class="nav-link" routerLink="tasks" routerLinkActive="active" (click)="closeMenu()"
|
||||
ngbPopover="Tasks" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end"
|
||||
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||
<i-bs class="me-2" name="list-task"></i-bs><span><ng-container i18n>Tasks</ng-container>@if (tasksService.needsAttentionTasks.length > 0) {
|
||||
<span><span class="badge bg-danger ms-2 d-inline">{{tasksService.needsAttentionTasks.length}}</span></span>
|
||||
}</span>
|
||||
<i-bs class="me-2" name="list-task"></i-bs><span class="nav-link-label"><ng-container i18n>Tasks</ng-container></span>
|
||||
@if (tasksService.needsAttentionTasks.length > 0 && !slimSidebarEnabled) {
|
||||
<span class="badge bg-danger ms-2 d-inline flex-shrink-0">{{tasksService.needsAttentionTasks.length}}</span>
|
||||
}
|
||||
@if (tasksService.needsAttentionTasks.length > 0 && slimSidebarEnabled) {
|
||||
<span class="badge bg-danger position-absolute top-0 end-0 d-none d-md-block">{{tasksService.needsAttentionTasks.length}}</span>
|
||||
}
|
||||
@@ -317,7 +318,7 @@
|
||||
<a class="nav-link" routerLink="logs" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Logs"
|
||||
i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end" container="body"
|
||||
triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||
<i-bs class="me-2" name="text-left"></i-bs><span><ng-container i18n>Logs</ng-container></span>
|
||||
<i-bs class="me-2" name="text-left"></i-bs><span class="nav-link-label"><ng-container i18n>Logs</ng-container></span>
|
||||
</a>
|
||||
</li>
|
||||
}
|
||||
@@ -331,7 +332,7 @@
|
||||
</li>
|
||||
<li class="nav-item" [class.visually-hidden]="slimSidebarEnabled">
|
||||
<div class="text-muted small d-flex align-items-center flex-wrap nav-label">
|
||||
<div class="me-3">
|
||||
<div class="me-2">
|
||||
<a class="text-muted text-decoration-none" target="_blank" rel="noopener noreferrer"
|
||||
href="https://github.com/paperless-ngx/paperless-ngx" ngbPopover="GitHub" i18n-ngbPopover
|
||||
[disablePopover]="!slimSidebarPopoversEnabled" placement="end" container="body"
|
||||
@@ -340,7 +341,7 @@
|
||||
</a>
|
||||
</div>
|
||||
@if (!settingsService.updateCheckingIsSet || appRemoteVersion()) {
|
||||
<div class="version-check">
|
||||
<div class="version-check d-flex align-items-center">
|
||||
<ng-template #updateAvailablePopContent>
|
||||
<span class="small">Paperless-ngx {{ appRemoteVersion().version }} <ng-container i18n>is
|
||||
available.</ng-container><br /><ng-container i18n>Click to view.</ng-container></span>
|
||||
@@ -388,7 +389,7 @@
|
||||
</nav>
|
||||
|
||||
<main role="main" class="ms-sm-auto px-md-4" [class.mobile-search-hidden]="mobileSearchHidden()"
|
||||
[ngClass]="slimSidebarEnabled ? 'col-slim' : 'col-md-9 col-lg-10 col-xxxl-11'">
|
||||
[ngClass]="slimSidebarEnabled ? 'col-slim' : 'col-sidebar-expanded'">
|
||||
<router-outlet></router-outlet>
|
||||
</main>
|
||||
</div>
|
||||
|
||||
@@ -11,6 +11,7 @@
|
||||
border-right: 1px solid color-mix(in srgb, var(--bs-border-color) 65%, transparent);
|
||||
overflow-y: auto;
|
||||
--pngx-sidebar-width: 100%;
|
||||
width: var(--pngx-sidebar-width);
|
||||
max-width: var(--pngx-sidebar-width);
|
||||
transition: all .2s ease;
|
||||
|
||||
@@ -32,17 +33,10 @@
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
// These come from the col-* classes for non-slim sidebar, needed for animation
|
||||
@media (min-width: 768px) {
|
||||
--pngx-sidebar-width: 25%;
|
||||
}
|
||||
|
||||
@media (min-width: 992px) {
|
||||
--pngx-sidebar-width: 16.66666667%;
|
||||
}
|
||||
|
||||
@media (min-width: 2400px) {
|
||||
--pngx-sidebar-width: 8.33333333%;
|
||||
&.expanded {
|
||||
--pngx-sidebar-width: var(--pngx-sidebar-expanded-width);
|
||||
}
|
||||
}
|
||||
}
|
||||
@media (max-width: 767.98px) {
|
||||
@@ -109,6 +103,16 @@ main {
|
||||
}
|
||||
|
||||
@media(min-width: 768px) {
|
||||
// hide scrollbars on browsers that take up layout width
|
||||
// :host-context since <html> is outside the component
|
||||
:host-context(.pngx-classic-scrollbars) .sidebar.slim {
|
||||
scrollbar-width: none;
|
||||
|
||||
&::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar.slim {
|
||||
max-width: 55px;
|
||||
|
||||
@@ -125,6 +129,19 @@ main {
|
||||
.sidebar-heading span {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.nav-link,
|
||||
.nav-anchor {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding-left: 0;
|
||||
padding-right: 0;
|
||||
|
||||
i-bs {
|
||||
margin-right: 0 !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar.slim:not(.animating) ~ main.col-slim {
|
||||
@@ -237,6 +254,26 @@ main {
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar .nav-link:has(> .nav-link-label) {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
|
||||
> i-bs {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
> .nav-link-label {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
|
||||
.sidebar:not(.slim):not(.animating) .nav-link > .nav-link-label {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sidebar .nav-anchor, .sidebar .nav-label {
|
||||
padding: .25rem .7rem;
|
||||
}
|
||||
@@ -362,7 +399,7 @@ main {
|
||||
}
|
||||
|
||||
::ng-deep .navbar-official-logo {
|
||||
will-change: filter; // Safari repaints the whole navbar on filter change without this
|
||||
will-change: filter; // prevent resizing the filter region on hover and nudging the logo a pixel
|
||||
filter: drop-shadow(0 1px 2px rgba(var(--pngx-navbar-brand-shadow-rgb), .3));
|
||||
transition: filter .15s ease-in-out;
|
||||
|
||||
@@ -375,6 +412,8 @@ main {
|
||||
width: 1.65rem;
|
||||
height: 1.65rem;
|
||||
flex: 0 0 auto;
|
||||
will-change: filter; // prevent resizing the filter region on hover and nudging the logo a pixel
|
||||
filter: drop-shadow(0 2px 3px rgba(var(--pngx-navbar-brand-shadow-rgb), 0));
|
||||
transition: filter .15s ease-in-out;
|
||||
}
|
||||
|
||||
@@ -407,6 +446,8 @@ main {
|
||||
max-width: 5rem;
|
||||
flex: 0 0 auto;
|
||||
object-fit: contain;
|
||||
will-change: filter; // prevent resizing the filter region on hover and nudging the logo a pixel
|
||||
filter: drop-shadow(0 2px 3px rgba(var(--pngx-navbar-brand-shadow-rgb), 0));
|
||||
transition: filter .15s ease-in-out, transform .15s ease-in-out;
|
||||
}
|
||||
|
||||
|
||||
@@ -193,6 +193,23 @@ describe('AppFrameComponent', () => {
|
||||
expect(savedViewSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should update reinitialized signal-backed settings without manual change detection', async () => {
|
||||
settingsService.initializeSettings().subscribe()
|
||||
httpTestingController
|
||||
.expectOne(`${environment.apiBaseUrl}ui_settings/`)
|
||||
.flush({
|
||||
settings: { app_title: 'Reactive title' },
|
||||
user: {},
|
||||
permissions: [],
|
||||
})
|
||||
|
||||
await fixture.whenStable()
|
||||
|
||||
expect(
|
||||
fixture.nativeElement.querySelector('.brand-title').textContent
|
||||
).toBe('Reactive title')
|
||||
})
|
||||
|
||||
it('should check for update if enabled', () => {
|
||||
const updateCheckSpy = jest.spyOn(remoteVersionService, 'checkForUpdates')
|
||||
updateCheckSpy.mockImplementation(() => {
|
||||
@@ -543,6 +560,27 @@ describe('AppFrameComponent', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('should only flag scrollbars that take up layout width', () => {
|
||||
const offsetWidth = jest.spyOn(HTMLElement.prototype, 'offsetWidth', 'get')
|
||||
jest.spyOn(HTMLElement.prototype, 'clientWidth', 'get').mockReturnValue(100)
|
||||
|
||||
offsetWidth.mockReturnValue(115)
|
||||
component['detectClassicScrollbars']()
|
||||
expect(
|
||||
window.document.documentElement.classList.contains(
|
||||
'pngx-classic-scrollbars'
|
||||
)
|
||||
).toBeTruthy()
|
||||
|
||||
offsetWidth.mockReturnValue(100)
|
||||
component['detectClassicScrollbars']()
|
||||
expect(
|
||||
window.document.documentElement.classList.contains(
|
||||
'pngx-classic-scrollbars'
|
||||
)
|
||||
).toBeFalsy()
|
||||
})
|
||||
|
||||
it('should collapse attributes sections when enabling slim sidebar', () => {
|
||||
jest.spyOn(settingsService, 'storeSettings').mockReturnValue(of(true))
|
||||
settingsService.set(SETTINGS_KEYS.ATTRIBUTES_SECTIONS_COLLAPSED, [])
|
||||
|
||||
@@ -98,6 +98,29 @@ export class AppFrameComponent
|
||||
readonly isMenuCollapsed = signal(true)
|
||||
readonly slimSidebarAnimating = signal(false)
|
||||
readonly mobileSearchHidden = signal(false)
|
||||
private readonly versionSetting = this.settingsService.getSignal<string>(
|
||||
SETTINGS_KEYS.VERSION
|
||||
)
|
||||
private readonly appTitleSetting = this.settingsService.getSignal<string>(
|
||||
SETTINGS_KEYS.APP_TITLE
|
||||
)
|
||||
private readonly appLogoSetting = this.settingsService.getSignal<string>(
|
||||
SETTINGS_KEYS.APP_LOGO
|
||||
)
|
||||
private readonly slimSidebarSetting = this.settingsService.getSignal<boolean>(
|
||||
SETTINGS_KEYS.SLIM_SIDEBAR
|
||||
)
|
||||
private readonly attributesSectionsCollapsedSetting =
|
||||
this.settingsService.getSignal<CollapsibleSection[]>(
|
||||
SETTINGS_KEYS.ATTRIBUTES_SECTIONS_COLLAPSED
|
||||
)
|
||||
private readonly aiEnabledSetting = this.settingsService.getSignal<boolean>(
|
||||
SETTINGS_KEYS.AI_ENABLED
|
||||
)
|
||||
private readonly sidebarViewsShowCountSetting =
|
||||
this.settingsService.getSignal<boolean>(
|
||||
SETTINGS_KEYS.SIDEBAR_VIEWS_SHOW_COUNT
|
||||
)
|
||||
private lastScrollY: number = 0
|
||||
|
||||
constructor() {
|
||||
@@ -118,6 +141,7 @@ export class AppFrameComponent
|
||||
|
||||
ngOnInit(): void {
|
||||
this.lastScrollY = window.scrollY
|
||||
this.detectClassicScrollbars()
|
||||
|
||||
if (this.settingsService.get(SETTINGS_KEYS.UPDATE_CHECKING_ENABLED)) {
|
||||
this.checkForUpdates()
|
||||
@@ -190,33 +214,23 @@ export class AppFrameComponent
|
||||
}
|
||||
|
||||
get versionString(): string {
|
||||
this.settingsService.trackChanges()
|
||||
return `${environment.appTitle} v${this.settingsService.get(SETTINGS_KEYS.VERSION)}${environment.tag === 'prod' ? '' : ` #${environment.tag}`}`
|
||||
return `${environment.appTitle} v${this.versionSetting()}${environment.tag === 'prod' ? '' : ` #${environment.tag}`}`
|
||||
}
|
||||
|
||||
get appTitle(): string {
|
||||
this.settingsService.trackChanges()
|
||||
return (
|
||||
this.settingsService.get(SETTINGS_KEYS.APP_TITLE) || environment.appTitle
|
||||
)
|
||||
return this.appTitleSetting() || environment.appTitle
|
||||
}
|
||||
|
||||
get customAppTitle(): string {
|
||||
this.settingsService.trackChanges()
|
||||
return this.settingsService.get(SETTINGS_KEYS.APP_TITLE)
|
||||
return this.appTitleSetting()
|
||||
}
|
||||
|
||||
get hasCustomBranding(): boolean {
|
||||
this.settingsService.trackChanges()
|
||||
return !!(
|
||||
this.settingsService.get(SETTINGS_KEYS.APP_TITLE)?.length ||
|
||||
this.settingsService.get(SETTINGS_KEYS.APP_LOGO)?.length
|
||||
)
|
||||
return !!(this.appTitleSetting()?.length || this.appLogoSetting()?.length)
|
||||
}
|
||||
|
||||
get customAppLogo(): string {
|
||||
this.settingsService.trackChanges()
|
||||
const logo = this.settingsService.get(SETTINGS_KEYS.APP_LOGO)
|
||||
const logo = this.appLogoSetting()
|
||||
return logo?.length
|
||||
? environment.apiBaseUrl.replace(/\/api\/$/, logo)
|
||||
: null
|
||||
@@ -261,8 +275,7 @@ export class AppFrameComponent
|
||||
}
|
||||
|
||||
get slimSidebarEnabled(): boolean {
|
||||
this.settingsService.trackChanges()
|
||||
return this.settingsService.get(SETTINGS_KEYS.SLIM_SIDEBAR)
|
||||
return this.slimSidebarSetting()
|
||||
}
|
||||
|
||||
set slimSidebarEnabled(enabled: boolean) {
|
||||
@@ -285,10 +298,9 @@ export class AppFrameComponent
|
||||
}
|
||||
|
||||
get attributesSectionsCollapsed(): boolean {
|
||||
this.settingsService.trackChanges()
|
||||
return this.settingsService
|
||||
.get(SETTINGS_KEYS.ATTRIBUTES_SECTIONS_COLLAPSED)
|
||||
?.includes(CollapsibleSection.ATTRIBUTES)
|
||||
return this.attributesSectionsCollapsedSetting()?.includes(
|
||||
CollapsibleSection.ATTRIBUTES
|
||||
)
|
||||
}
|
||||
|
||||
set attributesSectionsCollapsed(collapsed: boolean) {
|
||||
@@ -311,8 +323,7 @@ export class AppFrameComponent
|
||||
}
|
||||
|
||||
get aiEnabled(): boolean {
|
||||
this.settingsService.trackChanges()
|
||||
return this.settingsService.get(SETTINGS_KEYS.AI_ENABLED)
|
||||
return this.aiEnabledSetting()
|
||||
}
|
||||
|
||||
@HostListener('window:resize')
|
||||
@@ -343,6 +354,22 @@ export class AppFrameComponent
|
||||
this.lastScrollY = currentScrollY
|
||||
}
|
||||
|
||||
/**
|
||||
* Flag for browsers whose scrollbars take up layout width. Remove me
|
||||
* some day, I hope.
|
||||
*/
|
||||
private detectClassicScrollbars(): void {
|
||||
const probe = document.createElement('div')
|
||||
probe.style.cssText =
|
||||
'position:absolute;top:-9999px;width:100px;height:100px;overflow:scroll'
|
||||
document.body.appendChild(probe)
|
||||
document.documentElement.classList.toggle(
|
||||
'pngx-classic-scrollbars',
|
||||
probe.offsetWidth > probe.clientWidth
|
||||
)
|
||||
probe.remove()
|
||||
}
|
||||
|
||||
private isMobileViewport(): boolean {
|
||||
return window.innerWidth < 768
|
||||
}
|
||||
@@ -463,9 +490,8 @@ export class AppFrameComponent
|
||||
}
|
||||
|
||||
get showSidebarCounts(): boolean {
|
||||
this.settingsService.trackChanges()
|
||||
return (
|
||||
this.settingsService.get(SETTINGS_KEYS.SIDEBAR_VIEWS_SHOW_COUNT) &&
|
||||
this.sidebarViewsShowCountSetting() &&
|
||||
!this.settingsService.organizingSidebarSavedViews()
|
||||
)
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
autocomplete="off"
|
||||
spellcheck="false"
|
||||
[ngModel]="query()"
|
||||
(ngModelChange)="queryDebounce.next($event)"
|
||||
(ngModelChange)="onQueryChange($event)"
|
||||
(keydown)="searchInputKeyDown($event)"
|
||||
ngbDropdownAnchor>
|
||||
<div class="position-absolute top-50 end-0 translate-middle">
|
||||
|
||||
@@ -272,6 +272,19 @@ describe('GlobalSearchComponent', () => {
|
||||
expect(advancedSearchSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should set query immediately and run full search on enter without waiting for debounce', () => {
|
||||
jest.useFakeTimers()
|
||||
const searchSpy = jest.spyOn(searchService, 'globalSearch')
|
||||
searchSpy.mockReturnValue(of({} as any))
|
||||
const fullSearchSpy = jest.spyOn(component, 'runFullSearch')
|
||||
component.onQueryChange('test')
|
||||
expect(component.query()).toBe('test')
|
||||
component.searchInputKeyDown(new KeyboardEvent('keydown', { key: 'Enter' }))
|
||||
expect(fullSearchSpy).toHaveBeenCalled()
|
||||
expect(searchSpy).not.toHaveBeenCalled()
|
||||
jest.useRealTimers()
|
||||
})
|
||||
|
||||
it('should search on query debounce', () => {
|
||||
jest.useFakeTimers()
|
||||
const query = 'test'
|
||||
|
||||
@@ -81,6 +81,10 @@ export class GlobalSearchComponent implements OnInit {
|
||||
private hotkeyService = inject(HotKeyService)
|
||||
private settingsService = inject(SettingsService)
|
||||
private locationStrategy = inject(LocationStrategy)
|
||||
private readonly searchFullTypeSetting =
|
||||
this.settingsService.getSignal<GlobalSearchType>(
|
||||
SETTINGS_KEYS.SEARCH_FULL_TYPE
|
||||
)
|
||||
|
||||
public DataType = DataType
|
||||
readonly query = signal<string>(null)
|
||||
@@ -97,11 +101,7 @@ export class GlobalSearchComponent implements OnInit {
|
||||
@ViewChildren('secondaryButton') secondaryButtons: QueryList<ElementRef>
|
||||
|
||||
get useAdvancedForFullSearch(): boolean {
|
||||
this.settingsService.trackChanges()
|
||||
return (
|
||||
this.settingsService.get(SETTINGS_KEYS.SEARCH_FULL_TYPE) ===
|
||||
GlobalSearchType.ADVANCED
|
||||
)
|
||||
return this.searchFullTypeSetting() === GlobalSearchType.ADVANCED
|
||||
}
|
||||
|
||||
constructor() {
|
||||
@@ -119,6 +119,12 @@ export class GlobalSearchComponent implements OnInit {
|
||||
})
|
||||
}
|
||||
|
||||
public onQueryChange(text: string) {
|
||||
// set immediately so Enter / the full search button work without waiting for the debounce
|
||||
this.query.set(text)
|
||||
this.queryDebounce.next(text)
|
||||
}
|
||||
|
||||
public ngOnInit() {
|
||||
this.hotkeyService
|
||||
.addShortcut({ keys: '/', description: $localize`Global search` })
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
@if (useDropdown) {
|
||||
<div class="btn-group w-100" role="group" ngbDropdown #dropdown="ngbDropdown" (openChange)="onOpenChange($event)" [popperOptions]="popperOptions">
|
||||
<button class="btn btn-sm btn-outline-primary" id="dropdown_toggle" ngbDropdownToggle [disabled]="disabled" [aria-label]="title">
|
||||
<button class="btn btn-sm" [ngClass]="!editing && isActive ? 'btn-primary' : '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>
|
||||
@if (isActive) {
|
||||
<pngx-clearable-badge [selected]="isActive" (cleared)="reset()"></pngx-clearable-badge>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
getLocaleNumberSymbol,
|
||||
NgClass,
|
||||
NgTemplateOutlet,
|
||||
NumberSymbol,
|
||||
} from '@angular/common'
|
||||
@@ -48,25 +49,26 @@ import { ClearableBadgeComponent } from '../clearable-badge/clearable-badge.comp
|
||||
import { DocumentLinkComponent } from '../input/document-link/document-link.component'
|
||||
|
||||
export class CustomFieldQueriesModel {
|
||||
private _queries: CustomFieldQueryElement[] = []
|
||||
private readonly _queries = signal<CustomFieldQueryElement[]>([])
|
||||
private rootSubscriptions: Subscription[] = []
|
||||
|
||||
public readonly changed = new Subject<CustomFieldQueriesModel>()
|
||||
|
||||
public get queries(): CustomFieldQueryElement[] {
|
||||
return this._queries
|
||||
return this._queries()
|
||||
}
|
||||
|
||||
public set queries(value: CustomFieldQueryElement[]) {
|
||||
this.teardownRootSubscriptions()
|
||||
this._queries = value ?? []
|
||||
for (const element of this._queries) {
|
||||
const queries = value ?? []
|
||||
for (const element of queries) {
|
||||
this.rootSubscriptions.push(
|
||||
element.changed.subscribe(() => {
|
||||
this.changed.next(this)
|
||||
})
|
||||
)
|
||||
}
|
||||
this._queries.set(queries)
|
||||
}
|
||||
|
||||
public clear(fireEvent = true) {
|
||||
@@ -209,6 +211,7 @@ export class CustomFieldQueriesModel {
|
||||
DocumentLinkComponent,
|
||||
ReactiveFormsModule,
|
||||
NgbDatepickerModule,
|
||||
NgClass,
|
||||
NgTemplateOutlet,
|
||||
NgSelectModule,
|
||||
NgxBootstrapIconsModule,
|
||||
|
||||
@@ -94,6 +94,7 @@ export class MailAccountEditDialogComponent extends EditDialogComponent<MailAcco
|
||||
this.testActive = false
|
||||
this.testResult.set('danger')
|
||||
this.alertTimeout = setTimeout(() => this.testResultAlert.close(), 5000)
|
||||
this.error = e.error
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
@@ -196,6 +196,16 @@ describe('WorkflowEditDialogComponent', () => {
|
||||
fixture.detectChanges()
|
||||
})
|
||||
|
||||
function setActionSettings({
|
||||
email = true,
|
||||
remoteOcr = true,
|
||||
ai = true,
|
||||
} = {}) {
|
||||
settingsService.set(SETTINGS_KEYS.EMAIL_ENABLED, email)
|
||||
settingsService.set(SETTINGS_KEYS.REMOTE_OCR_CONFIGURED, remoteOcr)
|
||||
settingsService.set(SETTINGS_KEYS.AI_ENABLED, ai)
|
||||
}
|
||||
|
||||
it('should support create and edit modes, support adding triggers and actions on new workflow', () => {
|
||||
component.dialogMode.set(EditDialogMode.CREATE)
|
||||
const createTitleSpy = jest.spyOn(component, 'getCreateTitle')
|
||||
@@ -218,7 +228,7 @@ describe('WorkflowEditDialogComponent', () => {
|
||||
})
|
||||
|
||||
it('should return source options, type options, type name, schedule date field options', () => {
|
||||
jest.spyOn(settingsService, 'get').mockReturnValue(true)
|
||||
setActionSettings()
|
||||
component.ngOnInit()
|
||||
expect(component.sourceOptions).toEqual(DOCUMENT_SOURCE_OPTIONS)
|
||||
expect(component.triggerTypeOptions).toEqual(WORKFLOW_TYPE_OPTIONS)
|
||||
@@ -242,7 +252,7 @@ describe('WorkflowEditDialogComponent', () => {
|
||||
)
|
||||
|
||||
// Email, remote OCR and AI all disabled
|
||||
jest.spyOn(settingsService, 'get').mockReturnValue(false)
|
||||
setActionSettings({ email: false, remoteOcr: false, ai: false })
|
||||
component.ngOnInit()
|
||||
expect(component.actionTypeOptions).toEqual(
|
||||
WORKFLOW_ACTION_OPTIONS.filter(
|
||||
@@ -255,7 +265,7 @@ describe('WorkflowEditDialogComponent', () => {
|
||||
})
|
||||
|
||||
it('should offer remote OCR only for consumption workflows', () => {
|
||||
jest.spyOn(settingsService, 'get').mockReturnValue(true)
|
||||
setActionSettings()
|
||||
|
||||
// A consumption trigger makes the action reachable
|
||||
component.object = {
|
||||
@@ -285,7 +295,7 @@ describe('WorkflowEditDialogComponent', () => {
|
||||
})
|
||||
|
||||
it('should offer remote OCR on a trigger added to a new workflow', () => {
|
||||
jest.spyOn(settingsService, 'get').mockReturnValue(true)
|
||||
setActionSettings()
|
||||
component.ngOnInit()
|
||||
|
||||
// Nothing for the action to apply to yet
|
||||
@@ -311,7 +321,7 @@ describe('WorkflowEditDialogComponent', () => {
|
||||
})
|
||||
|
||||
it('should keep remote OCR listed when an action already uses it', () => {
|
||||
jest.spyOn(settingsService, 'get').mockReturnValue(true)
|
||||
setActionSettings()
|
||||
|
||||
// Otherwise changing the trigger would silently blank the selection
|
||||
component.object = {
|
||||
@@ -329,9 +339,7 @@ describe('WorkflowEditDialogComponent', () => {
|
||||
})
|
||||
|
||||
it('should not offer remote OCR when no engine is configured', () => {
|
||||
jest
|
||||
.spyOn(settingsService, 'get')
|
||||
.mockImplementation((key) => key !== SETTINGS_KEYS.REMOTE_OCR_CONFIGURED)
|
||||
setActionSettings({ remoteOcr: false })
|
||||
|
||||
component.object = {
|
||||
name: 'Workflow 1',
|
||||
@@ -348,7 +356,7 @@ describe('WorkflowEditDialogComponent', () => {
|
||||
})
|
||||
|
||||
it('should offer apply AI suggestions unless every trigger is consumption', () => {
|
||||
jest.spyOn(settingsService, 'get').mockReturnValue(true)
|
||||
setActionSettings()
|
||||
|
||||
// Consumption runs before the document has been parsed, so there would be
|
||||
// no content to make suggestions from
|
||||
@@ -382,7 +390,7 @@ describe('WorkflowEditDialogComponent', () => {
|
||||
})
|
||||
|
||||
it('should keep apply AI suggestions listed when an action already uses it', () => {
|
||||
jest.spyOn(settingsService, 'get').mockReturnValue(true)
|
||||
setActionSettings()
|
||||
|
||||
// Otherwise changing the trigger would silently blank the selection
|
||||
component.object = {
|
||||
@@ -400,9 +408,7 @@ describe('WorkflowEditDialogComponent', () => {
|
||||
})
|
||||
|
||||
it('should not offer apply AI suggestions when AI is disabled', () => {
|
||||
jest
|
||||
.spyOn(settingsService, 'get')
|
||||
.mockImplementation((key) => key !== SETTINGS_KEYS.AI_ENABLED)
|
||||
setActionSettings({ ai: false })
|
||||
|
||||
component.object = {
|
||||
name: 'Workflow 1',
|
||||
|
||||
@@ -537,6 +537,13 @@ export class WorkflowEditDialogComponent
|
||||
readonly dateCustomFields = computed(() =>
|
||||
this.customFields()?.filter((f) => f.data_type === CustomFieldDataType.Date)
|
||||
)
|
||||
private readonly emailEnabledSetting =
|
||||
this.settingsService.getSignal<boolean>(SETTINGS_KEYS.EMAIL_ENABLED)
|
||||
private readonly remoteOcrConfiguredSetting =
|
||||
this.settingsService.getSignal<boolean>(SETTINGS_KEYS.REMOTE_OCR_CONFIGURED)
|
||||
private readonly aiEnabledSetting = this.settingsService.getSignal<boolean>(
|
||||
SETTINGS_KEYS.AI_ENABLED
|
||||
)
|
||||
|
||||
expandedItem: number = null
|
||||
|
||||
@@ -589,7 +596,7 @@ export class WorkflowEditDialogComponent
|
||||
private getAllowedActionTypes() {
|
||||
let allowed = WORKFLOW_ACTION_OPTIONS
|
||||
|
||||
if (!this.settingsService.get(SETTINGS_KEYS.EMAIL_ENABLED)) {
|
||||
if (!this.emailEnabledSetting()) {
|
||||
allowed = allowed.filter((a) => a.id !== WorkflowActionType.Email)
|
||||
}
|
||||
|
||||
@@ -597,7 +604,7 @@ export class WorkflowEditDialogComponent
|
||||
// offered for workflows that run at consumption.
|
||||
const formWorkflow: Workflow = this.objectForm?.value
|
||||
const remoteOcrUsable =
|
||||
this.settingsService.get(SETTINGS_KEYS.REMOTE_OCR_CONFIGURED) &&
|
||||
this.remoteOcrConfiguredSetting() &&
|
||||
(formWorkflow?.triggers?.some(
|
||||
(trigger) => trigger.type === WorkflowTriggerType.Consumption
|
||||
) ||
|
||||
@@ -612,7 +619,7 @@ export class WorkflowEditDialogComponent
|
||||
// once every trigger is consumption, so it stays offered on a workflow
|
||||
// that has no triggers yet.
|
||||
const aiSuggestionsUsable =
|
||||
this.settingsService.get(SETTINGS_KEYS.AI_ENABLED) &&
|
||||
this.aiEnabledSetting() &&
|
||||
(!formWorkflow?.triggers?.length ||
|
||||
formWorkflow.triggers.some(
|
||||
(trigger) => trigger.type !== WorkflowTriggerType.Consumption
|
||||
@@ -1362,7 +1369,6 @@ export class WorkflowEditDialogComponent
|
||||
}
|
||||
|
||||
get actionTypeOptions() {
|
||||
this.settingsService.trackChanges()
|
||||
// Computed on read rather than cached
|
||||
return this.getAllowedActionTypes()
|
||||
}
|
||||
|
||||
@@ -839,7 +839,9 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
|
||||
selectionModel.items = [memoRoot]
|
||||
selectionModel.documentCounts = [{ id: memoRoot.id, document_count: 9 }]
|
||||
|
||||
const getRootDocCount = (selectionModel as any).createRootDocCounter()
|
||||
const getRootDocCount = (selectionModel as any).createRootDocCounter(
|
||||
selectionModel.items
|
||||
)
|
||||
|
||||
expect(getRootDocCount(memoRoot.id)).toEqual(9)
|
||||
selectionModel.documentCounts = []
|
||||
@@ -855,7 +857,9 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
|
||||
selectionModel.items = [rootWithoutSelection]
|
||||
selectionModel.documentCounts = []
|
||||
|
||||
const getRootDocCount = (selectionModel as any).createRootDocCounter()
|
||||
const getRootDocCount = (selectionModel as any).createRootDocCounter(
|
||||
selectionModel.items
|
||||
)
|
||||
|
||||
expect(getRootDocCount(rootWithoutSelection.id)).toEqual(4)
|
||||
})
|
||||
@@ -865,7 +869,9 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
|
||||
selectionModel.items = [rootWithoutCounts]
|
||||
selectionModel.documentCounts = []
|
||||
|
||||
const getRootDocCount = (selectionModel as any).createRootDocCounter()
|
||||
const getRootDocCount = (selectionModel as any).createRootDocCounter(
|
||||
selectionModel.items
|
||||
)
|
||||
|
||||
expect(getRootDocCount(rootWithoutCounts.id)).toEqual(0)
|
||||
})
|
||||
@@ -966,7 +972,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
|
||||
component.selectionModel['temporarySelectionStates'].set(id, state)
|
||||
const changedSpy = jest.spyOn(component.selectionModel.changed, 'next')
|
||||
component.selectionModel.exclude(id)
|
||||
expect(component.selectionModel.temporaryLogicalOperator).toBe(
|
||||
expect(component.selectionModel.temporaryLogicalOperator()).toBe(
|
||||
LogicalOperator.And
|
||||
)
|
||||
expect(component.selectionModel['temporarySelectionStates'].get(id)).toBe(
|
||||
|
||||
@@ -64,43 +64,56 @@ export class FilterableDropdownSelectionModel {
|
||||
|
||||
manyToOne = false
|
||||
singleSelect = false
|
||||
private _logicalOperator: LogicalOperator = LogicalOperator.And
|
||||
temporaryLogicalOperator: LogicalOperator = this._logicalOperator
|
||||
private _intersection: Intersection = Intersection.Include
|
||||
temporaryIntersection: Intersection = this._intersection
|
||||
|
||||
private _documentCounts: SelectionDataItem[] = []
|
||||
private readonly _logicalOperator = signal(LogicalOperator.And)
|
||||
readonly temporaryLogicalOperator = signal(LogicalOperator.And)
|
||||
private readonly _intersection = signal(Intersection.Include)
|
||||
readonly temporaryIntersection = signal(Intersection.Include)
|
||||
private readonly _documentCounts = signal<SelectionDataItem[]>([])
|
||||
private readonly _items = signal<MatchingModel[]>([])
|
||||
private readonly _selectionStates = signal(
|
||||
new Map<number, ToggleableItemState>()
|
||||
)
|
||||
private readonly _temporarySelectionStates = signal(
|
||||
new Map<number, ToggleableItemState>()
|
||||
)
|
||||
|
||||
public documentCountSortingEnabled = false
|
||||
|
||||
private get selectionStates(): ReadonlyMap<number, ToggleableItemState> {
|
||||
return this._selectionStates()
|
||||
}
|
||||
|
||||
private get temporarySelectionStates(): ReadonlyMap<
|
||||
number,
|
||||
ToggleableItemState
|
||||
> {
|
||||
return this._temporarySelectionStates()
|
||||
}
|
||||
|
||||
public set documentCounts(counts: SelectionDataItem[]) {
|
||||
this._documentCounts = counts
|
||||
this._documentCounts.set(counts)
|
||||
if (this.documentCountSortingEnabled) {
|
||||
this.sortItems()
|
||||
this._items.set(this.sortItems(this.items))
|
||||
}
|
||||
}
|
||||
|
||||
private _items: MatchingModel[] = []
|
||||
get items(): MatchingModel[] {
|
||||
return this._items
|
||||
return this._items()
|
||||
}
|
||||
|
||||
set items(items: MatchingModel[]) {
|
||||
if (items) {
|
||||
this._items = Array.from(items)
|
||||
this.sortItems()
|
||||
this.setNullItem()
|
||||
this._items.set(this.withNullItem(this.sortItems(Array.from(items))))
|
||||
}
|
||||
}
|
||||
|
||||
private setNullItem() {
|
||||
private withNullItem(items: MatchingModel[]): MatchingModel[] {
|
||||
if (this.manyToOne && this.logicalOperator === LogicalOperator.Or) {
|
||||
if (this._items[0]?.id === null) {
|
||||
this._items.shift()
|
||||
}
|
||||
return
|
||||
return items[0]?.id === null ? items.slice(1) : items
|
||||
}
|
||||
|
||||
const item = {
|
||||
const nullItem = {
|
||||
name: $localize`:Filter drop down element to filter for documents with no correspondent/type/tag assigned:Not assigned`,
|
||||
id:
|
||||
this.manyToOne || this.intersection === Intersection.Include
|
||||
@@ -108,22 +121,17 @@ export class FilterableDropdownSelectionModel {
|
||||
: NEGATIVE_NULL_FILTER_VALUE,
|
||||
}
|
||||
|
||||
if (
|
||||
this._items[0]?.id === null ||
|
||||
this._items[0]?.id === NEGATIVE_NULL_FILTER_VALUE
|
||||
) {
|
||||
this._items[0] = item
|
||||
} else if (this._items) {
|
||||
this._items.unshift(item)
|
||||
}
|
||||
return items[0]?.id === null || items[0]?.id === NEGATIVE_NULL_FILTER_VALUE
|
||||
? [nullItem, ...items.slice(1)]
|
||||
: [nullItem, ...items]
|
||||
}
|
||||
|
||||
constructor(manyToOne: boolean = false) {
|
||||
this.manyToOne = manyToOne
|
||||
}
|
||||
|
||||
private sortItems() {
|
||||
this._items.sort((a, b) => {
|
||||
private sortItems(items: MatchingModel[]): MatchingModel[] {
|
||||
const sorted = [...items].sort((a, b) => {
|
||||
if (
|
||||
(a.id == null && b.id != null) ||
|
||||
(a.id == NEGATIVE_NULL_FILTER_VALUE &&
|
||||
@@ -154,13 +162,13 @@ export class FilterableDropdownSelectionModel {
|
||||
) {
|
||||
return -1
|
||||
} else if (
|
||||
this._documentCounts.length &&
|
||||
this._documentCounts().length &&
|
||||
this.getDocumentCount(b.id) === 0 &&
|
||||
this.getDocumentCount(a.id) > this.getDocumentCount(b.id)
|
||||
) {
|
||||
return -1
|
||||
} else if (
|
||||
this._documentCounts.length &&
|
||||
this._documentCounts().length &&
|
||||
this.getDocumentCount(a.id) === 0 &&
|
||||
this.getDocumentCount(a.id) < this.getDocumentCount(b.id)
|
||||
) {
|
||||
@@ -170,15 +178,11 @@ export class FilterableDropdownSelectionModel {
|
||||
}
|
||||
})
|
||||
|
||||
if (this._documentCounts.length) {
|
||||
this.promoteBranchesWithDocumentCounts()
|
||||
}
|
||||
return this._documentCounts().length
|
||||
? this.promoteBranchesWithDocumentCounts(sorted)
|
||||
: sorted
|
||||
}
|
||||
|
||||
private selectionStates = new Map<number, ToggleableItemState>()
|
||||
|
||||
private temporarySelectionStates = new Map<number, ToggleableItemState>()
|
||||
|
||||
getSelectedItems() {
|
||||
return this.items.filter(
|
||||
(i) =>
|
||||
@@ -194,30 +198,33 @@ export class FilterableDropdownSelectionModel {
|
||||
}
|
||||
|
||||
set(id: number, state: ToggleableItemState, fireEvent = true) {
|
||||
const states = new Map(this.temporarySelectionStates)
|
||||
if (state == ToggleableItemState.NotSelected) {
|
||||
this.temporarySelectionStates.delete(id)
|
||||
states.delete(id)
|
||||
} else {
|
||||
this.temporarySelectionStates.set(id, state)
|
||||
states.set(id, state)
|
||||
}
|
||||
this._temporarySelectionStates.set(states)
|
||||
if (fireEvent) {
|
||||
this.changed.next(this)
|
||||
}
|
||||
}
|
||||
|
||||
toggle(id: number, fireEvent = true) {
|
||||
let state = this.temporarySelectionStates.get(id)
|
||||
const states = new Map(this.temporarySelectionStates)
|
||||
let state = states.get(id)
|
||||
if (
|
||||
state == undefined ||
|
||||
(state != ToggleableItemState.Selected &&
|
||||
state != ToggleableItemState.Excluded)
|
||||
) {
|
||||
if (this.manyToOne || this.singleSelect) {
|
||||
this.temporarySelectionStates.set(id, ToggleableItemState.Selected)
|
||||
states.set(id, ToggleableItemState.Selected)
|
||||
|
||||
if (this.singleSelect) {
|
||||
for (let key of this.temporarySelectionStates.keys()) {
|
||||
for (let key of states.keys()) {
|
||||
if (key != id) {
|
||||
this.temporarySelectionStates.delete(key)
|
||||
states.delete(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -233,25 +240,26 @@ export class FilterableDropdownSelectionModel {
|
||||
) {
|
||||
newState = ToggleableItemState.NotSelected
|
||||
}
|
||||
this.temporarySelectionStates.set(id, newState)
|
||||
states.set(id, newState)
|
||||
}
|
||||
} else if (
|
||||
state == ToggleableItemState.Selected ||
|
||||
state == ToggleableItemState.Excluded
|
||||
) {
|
||||
this.temporarySelectionStates.delete(id)
|
||||
this.clearDescendantSelections(id)
|
||||
states.delete(id)
|
||||
this.clearDescendantSelections(states, id)
|
||||
}
|
||||
|
||||
if (!id) {
|
||||
for (let key of this.temporarySelectionStates.keys()) {
|
||||
for (let key of states.keys()) {
|
||||
if (key) {
|
||||
this.temporarySelectionStates.delete(key)
|
||||
states.delete(key)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
this.temporarySelectionStates.delete(null)
|
||||
states.delete(null)
|
||||
}
|
||||
this._temporarySelectionStates.set(states)
|
||||
|
||||
if (fireEvent) {
|
||||
this.changed.next(this)
|
||||
@@ -259,20 +267,21 @@ export class FilterableDropdownSelectionModel {
|
||||
}
|
||||
|
||||
exclude(id: number, fireEvent: boolean = true) {
|
||||
let state = this.temporarySelectionStates.get(id)
|
||||
const states = new Map(this.temporarySelectionStates)
|
||||
let state = states.get(id)
|
||||
if (id && (state == null || state != ToggleableItemState.Excluded)) {
|
||||
this.temporaryLogicalOperator = this._logicalOperator = this.manyToOne
|
||||
? LogicalOperator.And
|
||||
: LogicalOperator.Or
|
||||
const operator = this.manyToOne ? LogicalOperator.And : LogicalOperator.Or
|
||||
this.temporaryLogicalOperator.set(operator)
|
||||
this._logicalOperator.set(operator)
|
||||
|
||||
if (this.manyToOne || this.singleSelect) {
|
||||
this.temporarySelectionStates.set(id, ToggleableItemState.Excluded)
|
||||
this.clearDescendantSelections(id)
|
||||
states.set(id, ToggleableItemState.Excluded)
|
||||
this.clearDescendantSelections(states, id)
|
||||
|
||||
if (this.singleSelect) {
|
||||
for (let key of this.temporarySelectionStates.keys()) {
|
||||
for (let key of states.keys()) {
|
||||
if (key != id) {
|
||||
this.temporarySelectionStates.delete(key)
|
||||
states.delete(key)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -287,17 +296,18 @@ export class FilterableDropdownSelectionModel {
|
||||
) {
|
||||
newState = ToggleableItemState.NotSelected
|
||||
}
|
||||
this.temporarySelectionStates.set(id, newState)
|
||||
states.set(id, newState)
|
||||
if (newState == ToggleableItemState.Excluded) {
|
||||
this.clearDescendantSelections(id)
|
||||
this.clearDescendantSelections(states, id)
|
||||
}
|
||||
}
|
||||
} else if (!id || state == ToggleableItemState.Excluded) {
|
||||
this.temporarySelectionStates.delete(id)
|
||||
states.delete(id)
|
||||
if (id) {
|
||||
this.clearDescendantSelections(id)
|
||||
this.clearDescendantSelections(states, id)
|
||||
}
|
||||
}
|
||||
this._temporarySelectionStates.set(states)
|
||||
|
||||
if (fireEvent) {
|
||||
this.changed.next(this)
|
||||
@@ -308,9 +318,12 @@ export class FilterableDropdownSelectionModel {
|
||||
return this.selectionStates.get(id) || ToggleableItemState.NotSelected
|
||||
}
|
||||
|
||||
private clearDescendantSelections(id: number) {
|
||||
private clearDescendantSelections(
|
||||
states: Map<number, ToggleableItemState>,
|
||||
id: number
|
||||
) {
|
||||
for (const descendantID of this.getDescendantIDs(id)) {
|
||||
this.temporarySelectionStates.delete(descendantID)
|
||||
states.delete(descendantID)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -320,7 +333,7 @@ export class FilterableDropdownSelectionModel {
|
||||
|
||||
while (queue.length) {
|
||||
const parentID = queue.shift()
|
||||
for (const item of this._items) {
|
||||
for (const item of this.items) {
|
||||
if (
|
||||
typeof item?.id === 'number' &&
|
||||
typeof (item as any)['parent'] === 'number' &&
|
||||
@@ -336,12 +349,12 @@ export class FilterableDropdownSelectionModel {
|
||||
}
|
||||
|
||||
get logicalOperator(): LogicalOperator {
|
||||
return this.temporaryLogicalOperator
|
||||
return this.temporaryLogicalOperator()
|
||||
}
|
||||
|
||||
set logicalOperator(operator: LogicalOperator) {
|
||||
this.temporaryLogicalOperator = operator
|
||||
this.setNullItem()
|
||||
this.temporaryLogicalOperator.set(operator)
|
||||
this._items.set(this.withNullItem(this.items))
|
||||
}
|
||||
|
||||
toggleOperator() {
|
||||
@@ -349,12 +362,12 @@ export class FilterableDropdownSelectionModel {
|
||||
}
|
||||
|
||||
get intersection(): Intersection {
|
||||
return this.temporaryIntersection
|
||||
return this.temporaryIntersection()
|
||||
}
|
||||
|
||||
set intersection(intersection: Intersection) {
|
||||
this.temporaryIntersection = intersection
|
||||
this.setNullItem()
|
||||
this.temporaryIntersection.set(intersection)
|
||||
this._items.set(this.withNullItem(this.items))
|
||||
}
|
||||
|
||||
toggleIntersection() {
|
||||
@@ -364,18 +377,20 @@ export class FilterableDropdownSelectionModel {
|
||||
? ToggleableItemState.Selected
|
||||
: ToggleableItemState.Excluded
|
||||
|
||||
this.temporarySelectionStates.forEach((state, key) => {
|
||||
const states = new Map(this.temporarySelectionStates)
|
||||
states.forEach((state, key) => {
|
||||
if (key === null && this.intersection === Intersection.Exclude) {
|
||||
this.temporarySelectionStates.set(NEGATIVE_NULL_FILTER_VALUE, newState)
|
||||
states.set(NEGATIVE_NULL_FILTER_VALUE, newState)
|
||||
} else if (
|
||||
key === NEGATIVE_NULL_FILTER_VALUE &&
|
||||
this.intersection === Intersection.Include
|
||||
) {
|
||||
this.temporarySelectionStates.set(null, newState)
|
||||
states.set(null, newState)
|
||||
} else {
|
||||
this.temporarySelectionStates.set(key, newState)
|
||||
states.set(key, newState)
|
||||
}
|
||||
})
|
||||
this._temporarySelectionStates.set(states)
|
||||
|
||||
this.changed.next(this)
|
||||
}
|
||||
@@ -395,10 +410,12 @@ export class FilterableDropdownSelectionModel {
|
||||
}
|
||||
|
||||
clear(fireEvent = true) {
|
||||
this.temporarySelectionStates.clear()
|
||||
this.temporaryLogicalOperator = this._logicalOperator = LogicalOperator.And
|
||||
this.temporaryIntersection = this._intersection = Intersection.Include
|
||||
this.setNullItem()
|
||||
this._temporarySelectionStates.set(new Map())
|
||||
this.temporaryLogicalOperator.set(LogicalOperator.And)
|
||||
this._logicalOperator.set(LogicalOperator.And)
|
||||
this.temporaryIntersection.set(Intersection.Include)
|
||||
this._intersection.set(Intersection.Include)
|
||||
this._items.set(this.withNullItem(this.items))
|
||||
if (fireEvent) {
|
||||
this.changed.next(this)
|
||||
}
|
||||
@@ -419,9 +436,9 @@ export class FilterableDropdownSelectionModel {
|
||||
)
|
||||
) {
|
||||
return true
|
||||
} else if (this.temporaryLogicalOperator !== this._logicalOperator) {
|
||||
} else if (this.temporaryLogicalOperator() !== this._logicalOperator()) {
|
||||
return true
|
||||
} else if (this.temporaryIntersection !== this._intersection) {
|
||||
} else if (this.temporaryIntersection() !== this._intersection()) {
|
||||
return true
|
||||
} else {
|
||||
return false
|
||||
@@ -438,23 +455,29 @@ export class FilterableDropdownSelectionModel {
|
||||
}
|
||||
|
||||
getDocumentCount(id: number) {
|
||||
return this._documentCounts.find((c) => c.id === id)?.document_count
|
||||
return this._documentCounts().find((c) => c.id === id)?.document_count
|
||||
}
|
||||
|
||||
private promoteBranchesWithDocumentCounts() {
|
||||
const parentById = this.buildParentById()
|
||||
private promoteBranchesWithDocumentCounts(
|
||||
items: MatchingModel[]
|
||||
): MatchingModel[] {
|
||||
const parentById = this.buildParentById(items)
|
||||
const findRootId = this.createRootFinder(parentById)
|
||||
const getRootDocCount = this.createRootDocCounter()
|
||||
const summaries = this.buildBranchSummaries(findRootId, getRootDocCount)
|
||||
const getRootDocCount = this.createRootDocCounter(items)
|
||||
const summaries = this.buildBranchSummaries(
|
||||
items,
|
||||
findRootId,
|
||||
getRootDocCount
|
||||
)
|
||||
const orderedBranches = this.orderBranchesByPriority(summaries)
|
||||
|
||||
this._items = orderedBranches.flatMap((summary) => summary.items)
|
||||
return orderedBranches.flatMap((summary) => summary.items)
|
||||
}
|
||||
|
||||
private buildParentById(): Map<number, number | null> {
|
||||
private buildParentById(items: MatchingModel[]): Map<number, number | null> {
|
||||
const parentById = new Map<number, number | null>()
|
||||
|
||||
for (const item of this._items) {
|
||||
for (const item of items) {
|
||||
if (typeof item?.id === 'number') {
|
||||
const parentValue = (item as any)['parent']
|
||||
parentById.set(
|
||||
@@ -492,7 +515,9 @@ export class FilterableDropdownSelectionModel {
|
||||
return findRootId
|
||||
}
|
||||
|
||||
private createRootDocCounter(): (rootId: number) => number {
|
||||
private createRootDocCounter(
|
||||
items: MatchingModel[]
|
||||
): (rootId: number) => number {
|
||||
const docCountMemo = new Map<number, number>()
|
||||
|
||||
return (rootId: number): number => {
|
||||
@@ -507,7 +532,7 @@ export class FilterableDropdownSelectionModel {
|
||||
return explicit
|
||||
}
|
||||
|
||||
const rootItem = this._items.find((i) => i.id === rootId)
|
||||
const rootItem = items.find((i) => i.id === rootId)
|
||||
const fallback =
|
||||
typeof (rootItem as any)?.['document_count'] === 'number'
|
||||
? (rootItem as any)['document_count']
|
||||
@@ -519,12 +544,13 @@ export class FilterableDropdownSelectionModel {
|
||||
}
|
||||
|
||||
private buildBranchSummaries(
|
||||
items: MatchingModel[],
|
||||
findRootId: (id: number) => number,
|
||||
getRootDocCount: (rootId: number) => number
|
||||
): Map<string, BranchSummary> {
|
||||
const summaries = new Map<string, BranchSummary>()
|
||||
|
||||
for (const [index, item] of this._items.entries()) {
|
||||
for (const [index, item] of items.entries()) {
|
||||
const { key, special, rootId } = this.describeBranchItem(
|
||||
item,
|
||||
index,
|
||||
@@ -616,28 +642,23 @@ export class FilterableDropdownSelectionModel {
|
||||
}
|
||||
|
||||
init(map: Map<number, ToggleableItemState>) {
|
||||
this.temporarySelectionStates = map
|
||||
this._temporarySelectionStates.set(new Map(map))
|
||||
this.apply()
|
||||
}
|
||||
|
||||
apply() {
|
||||
this.selectionStates.clear()
|
||||
this.temporarySelectionStates.forEach((value, key) => {
|
||||
this.selectionStates.set(key, value)
|
||||
})
|
||||
this._logicalOperator = this.temporaryLogicalOperator
|
||||
this._intersection = this.temporaryIntersection
|
||||
this.sortItems()
|
||||
this._selectionStates.set(new Map(this.temporarySelectionStates))
|
||||
this._logicalOperator.set(this.temporaryLogicalOperator())
|
||||
this._intersection.set(this.temporaryIntersection())
|
||||
this._items.set(this.sortItems(this.items))
|
||||
}
|
||||
|
||||
reset(complete: boolean = false) {
|
||||
this.temporarySelectionStates.clear()
|
||||
if (complete) {
|
||||
this.selectionStates.clear()
|
||||
this._selectionStates.set(new Map())
|
||||
this._temporarySelectionStates.set(new Map())
|
||||
} else {
|
||||
this.selectionStates.forEach((value, key) => {
|
||||
this.temporarySelectionStates.set(key, value)
|
||||
})
|
||||
this._temporarySelectionStates.set(new Map(this.selectionStates))
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,8 @@
|
||||
padding-left: calc(calc(var(--depth) - 2) * 1rem);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
|
||||
.indicator {
|
||||
display: inline-block;
|
||||
@@ -18,3 +20,7 @@
|
||||
margin-left: .5rem;
|
||||
}
|
||||
}
|
||||
|
||||
.badge {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
@@ -47,8 +47,8 @@
|
||||
<i-bs width="0.9em" height="0.9em" name="file-text" class="me-1"></i-bs><span>{{document.title}}</span>
|
||||
</a>
|
||||
} @else {
|
||||
<span class="badge bg-light text-muted" (click)="unselect(document)" (mousedown)="$event.stopImmediatePropagation()" type="button" title="Remove link" i18n-title>
|
||||
<i-bs width="0.9em" height="0.9em" name="exclamation-triangle-fill" class="me-1"></i-bs><span i18n>Not found</span>
|
||||
<span class="badge bg-light text-muted">
|
||||
<i-bs width="0.9em" height="0.9em" name="exclamation-triangle-fill" class="me-1"></i-bs><span i18n>Unavailable</span>
|
||||
</span>
|
||||
}
|
||||
</div>
|
||||
|
||||
@@ -151,6 +151,23 @@ describe('DocumentLinkComponent', () => {
|
||||
expect(component.selectedDocuments).toEqual([])
|
||||
})
|
||||
|
||||
it('should preserve and neutrally label unavailable document IDs', async () => {
|
||||
jest.spyOn(documentService, 'getFew').mockReturnValue(
|
||||
of({
|
||||
count: 0,
|
||||
all: [],
|
||||
results: [],
|
||||
})
|
||||
)
|
||||
|
||||
component.writeValue([99])
|
||||
await fixture.whenStable()
|
||||
|
||||
expect(component.selectedDocuments).toEqual([{ id: 99 }])
|
||||
expect(fixture.nativeElement.textContent).toContain('Unavailable')
|
||||
expect(fixture.nativeElement.textContent).not.toContain('Not found')
|
||||
})
|
||||
|
||||
it('should support unselect', () => {
|
||||
const getSpy = jest.spyOn(documentService, 'getFew')
|
||||
getSpy.mockImplementation((ids) => {
|
||||
@@ -167,6 +184,15 @@ describe('DocumentLinkComponent', () => {
|
||||
expect(component.selectedDocuments).toEqual([documents[1]])
|
||||
})
|
||||
|
||||
it('should not unselect documents when disabled', () => {
|
||||
component.disabled = true
|
||||
component.selectedDocuments = [documents[0]]
|
||||
|
||||
component.unselect(documents[0])
|
||||
|
||||
expect(component.selectedDocuments).toEqual([documents[0]])
|
||||
})
|
||||
|
||||
it('should use correct compare, trackBy functions', () => {
|
||||
expect(component.compareDocuments(documents[0], { id: 1 })).toBeTruthy()
|
||||
expect(component.compareDocuments(documents[0], { id: 2 })).toBeFalsy()
|
||||
|
||||
@@ -101,7 +101,7 @@ export class DocumentLinkComponent
|
||||
.subscribe((documentResults) => {
|
||||
this.loading.set(false)
|
||||
this.selectedDocuments = documentIDs.map(
|
||||
(id) => documentResults.results.find((d) => d.id === id) ?? {}
|
||||
(id) => documentResults.results.find((d) => d.id === id) ?? { id }
|
||||
)
|
||||
super.writeValue(documentIDs)
|
||||
})
|
||||
@@ -142,6 +142,8 @@ export class DocumentLinkComponent
|
||||
}
|
||||
|
||||
unselect(document: Document): void {
|
||||
if (this.disabled) return
|
||||
|
||||
this.selectedDocuments = this.selectedDocuments.filter(
|
||||
(d) => d && d.id !== document.id
|
||||
)
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
<div class="d-flex flex-wrap flex-row gap-2 w-100 mh-1" style="min-height: 1em;"
|
||||
cdkDropList #selectedList="cdkDropList"
|
||||
cdkDropListOrientation="mixed"
|
||||
[cdkDropListDisabled]="disabled"
|
||||
(cdkDropListDropped)="drop($event)"
|
||||
[cdkDropListConnectedTo]="[unselectedList]">
|
||||
@for (item of selectedItems; track item.id) {
|
||||
@@ -17,6 +18,7 @@
|
||||
<div class="d-flex flex-wrap flex-row gap-2 w-100" style="min-height: 1em;"
|
||||
cdkDropList #unselectedList="cdkDropList"
|
||||
cdkDropListOrientation="mixed"
|
||||
[cdkDropListDisabled]="disabled"
|
||||
(cdkDropListDropped)="drop($event)"
|
||||
[cdkDropListConnectedTo]="[selectedList]">
|
||||
@for (item of unselectedItems; track item.id) {
|
||||
|
||||
@@ -1,3 +1,11 @@
|
||||
.badge {
|
||||
cursor: move;
|
||||
}
|
||||
|
||||
.cdk-drop-list-disabled {
|
||||
cursor: not-allowed !important;
|
||||
|
||||
* {
|
||||
pointer-events: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -98,4 +98,29 @@ describe('DragDropSelectComponent', () => {
|
||||
{ id: '3', name: 'Item 3' },
|
||||
])
|
||||
})
|
||||
|
||||
it('should disable drag and drop when the control is disabled', () => {
|
||||
component.items = [
|
||||
{ id: '1', name: 'Item 1' },
|
||||
{ id: '2', name: 'Item 2' },
|
||||
]
|
||||
component.writeValue(['1', '2'])
|
||||
component.setDisabledState(true)
|
||||
fixture.detectChanges()
|
||||
|
||||
expect(component.selectedList.disabled).toBe(true)
|
||||
expect(component.unselectedList.disabled).toBe(true)
|
||||
|
||||
component.drop({
|
||||
previousContainer: component.selectedList,
|
||||
container: component.selectedList,
|
||||
previousIndex: 0,
|
||||
currentIndex: 1,
|
||||
} as any)
|
||||
|
||||
expect(component.selectedItems).toEqual([
|
||||
{ id: '1', name: 'Item 1' },
|
||||
{ id: '2', name: 'Item 2' },
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -46,6 +46,8 @@ export class DragDropSelectComponent extends AbstractInputComponent<string[]> {
|
||||
}
|
||||
|
||||
public drop(event: CdkDragDrop<string[]>) {
|
||||
if (this.disabled) return
|
||||
|
||||
if (
|
||||
event.previousContainer === event.container &&
|
||||
event.container === this.selectedList
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
<div class="list-group list-group-flush">
|
||||
<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.NONE)" [disabled]="disabled">
|
||||
<div class="selected-icon me-1">
|
||||
@if (selectionModel.ownerFilter === OwnerFilterType.NONE) {
|
||||
@if (selectionModel.ownerFilter() === OwnerFilterType.NONE) {
|
||||
<i-bs width="1em" height="1em" name="check"></i-bs>
|
||||
}
|
||||
</div>
|
||||
@@ -17,7 +17,7 @@
|
||||
</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.SELF)" [disabled]="disabled">
|
||||
<div class="selected-icon me-1">
|
||||
@if (selectionModel.ownerFilter === OwnerFilterType.SELF) {
|
||||
@if (selectionModel.ownerFilter() === OwnerFilterType.SELF) {
|
||||
<i-bs width="1em" height="1em" name="check"></i-bs>
|
||||
}
|
||||
</div>
|
||||
@@ -27,7 +27,7 @@
|
||||
</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">
|
||||
<div class="selected-icon me-1">
|
||||
@if (selectionModel.ownerFilter === OwnerFilterType.NOT_SELF) {
|
||||
@if (selectionModel.ownerFilter() === OwnerFilterType.NOT_SELF) {
|
||||
<i-bs width="1em" height="1em" name="check"></i-bs>
|
||||
}
|
||||
</div>
|
||||
@@ -37,7 +37,7 @@
|
||||
</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">
|
||||
<div class="selected-icon me-1">
|
||||
@if (selectionModel.ownerFilter === OwnerFilterType.SHARED_BY_ME) {
|
||||
@if (selectionModel.ownerFilter() === OwnerFilterType.SHARED_BY_ME) {
|
||||
<i-bs width="1em" height="1em" name="check"></i-bs>
|
||||
}
|
||||
</div>
|
||||
@@ -47,7 +47,7 @@
|
||||
</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">
|
||||
<div class="selected-icon me-1">
|
||||
@if (selectionModel.ownerFilter === OwnerFilterType.UNOWNED) {
|
||||
@if (selectionModel.ownerFilter() === OwnerFilterType.UNOWNED) {
|
||||
<i-bs width="1em" height="1em" name="check"></i-bs>
|
||||
}
|
||||
</div>
|
||||
@@ -57,7 +57,7 @@
|
||||
</button>
|
||||
<button *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.User }" 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" [disabled]="disabled">
|
||||
<div class="selected-icon me-1">
|
||||
@if (selectionModel.ownerFilter === OwnerFilterType.OTHERS) {
|
||||
@if (selectionModel.ownerFilter() === OwnerFilterType.OTHERS) {
|
||||
<i-bs width="1em" height="1em" name="check"></i-bs>
|
||||
}
|
||||
</div>
|
||||
@@ -65,7 +65,8 @@
|
||||
<ng-select
|
||||
name="user"
|
||||
class="user-select small"
|
||||
[(ngModel)]="selectionModel.includeUsers"
|
||||
[ngModel]="selectionModel.includeUsers()"
|
||||
(ngModelChange)="selectionModel.includeUsers.set($event)"
|
||||
[disabled]="disabled"
|
||||
[clearable]="false"
|
||||
[items]="users()"
|
||||
@@ -78,10 +79,10 @@
|
||||
</ng-select>
|
||||
</div>
|
||||
</button>
|
||||
@if (selectionModel.ownerFilter === OwnerFilterType.NONE || selectionModel.ownerFilter === OwnerFilterType.NOT_SELF) {
|
||||
@if (selectionModel.ownerFilter() === OwnerFilterType.NONE || selectionModel.ownerFilter() === OwnerFilterType.NOT_SELF) {
|
||||
<div class="list-group-item list-group-item-action d-flex align-items-center p-2 ps-3 border-bottom-0 border-start-0 border-end-0">
|
||||
<div class="form-check form-switch w-100">
|
||||
<input type="checkbox" class="form-check-input" id="hideUnowned" [(ngModel)]="this.selectionModel.hideUnowned" (change)="onChange()" [disabled]="disabled">
|
||||
<input type="checkbox" class="form-check-input" id="hideUnowned" [ngModel]="selectionModel.hideUnowned()" (ngModelChange)="selectionModel.hideUnowned.set($event)" (change)="onChange()" [disabled]="disabled">
|
||||
<label class="form-check-label w-100" for="hideUnowned"><small i18n>Hide unowned</small></label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -90,56 +90,56 @@ describe('PermissionsFilterDropdownComponent', () => {
|
||||
component.setFilter(OwnerFilterType.OTHERS)
|
||||
expect(component.isActive).toBeTruthy()
|
||||
component.setFilter(OwnerFilterType.NONE)
|
||||
component.selectionModel.hideUnowned = true
|
||||
component.selectionModel.hideUnowned.set(true)
|
||||
expect(component.isActive).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should describe concrete user filters honestly', () => {
|
||||
component.selectionModel.ownerFilter = OwnerFilterType.SELF
|
||||
component.selectionModel.userID = 1
|
||||
component.selectionModel.ownerFilter.set(OwnerFilterType.SELF)
|
||||
component.selectionModel.userID.set(1)
|
||||
expect(component.ownerFilterLabel).toEqual('Owned by user1')
|
||||
|
||||
component.selectionModel.ownerFilter = OwnerFilterType.NOT_SELF
|
||||
component.selectionModel.excludeUsers = [1]
|
||||
component.selectionModel.ownerFilter.set(OwnerFilterType.NOT_SELF)
|
||||
component.selectionModel.excludeUsers.set([1])
|
||||
expect(component.ownerExclusionFilterLabel).toEqual('Not owned by user1')
|
||||
|
||||
component.selectionModel.ownerFilter = OwnerFilterType.SHARED_BY_ME
|
||||
component.selectionModel.userID = 1
|
||||
component.selectionModel.ownerFilter.set(OwnerFilterType.SHARED_BY_ME)
|
||||
component.selectionModel.userID.set(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
|
||||
component.selectionModel.ownerFilter.set(OwnerFilterType.SELF)
|
||||
component.selectionModel.userID.set(99)
|
||||
expect(component.ownerFilterLabel).toEqual('Owned by another user')
|
||||
|
||||
component.selectionModel.ownerFilter = OwnerFilterType.NOT_SELF
|
||||
component.selectionModel.excludeUsers = [99]
|
||||
component.selectionModel.ownerFilter.set(OwnerFilterType.NOT_SELF)
|
||||
component.selectionModel.excludeUsers.set([99])
|
||||
expect(component.ownerExclusionFilterLabel).toEqual(
|
||||
'Not owned by another user'
|
||||
)
|
||||
|
||||
component.selectionModel.excludeUsers = [98, 99]
|
||||
component.selectionModel.excludeUsers.set([98, 99])
|
||||
expect(component.ownerExclusionFilterLabel).toEqual(
|
||||
'Not owned by selected users'
|
||||
)
|
||||
|
||||
component.selectionModel.ownerFilter = OwnerFilterType.SHARED_BY_ME
|
||||
component.selectionModel.userID = 99
|
||||
component.selectionModel.ownerFilter.set(OwnerFilterType.SHARED_BY_ME)
|
||||
component.selectionModel.userID.set(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
|
||||
component.selectionModel.userID.set(currentUserID)
|
||||
expect(component.ownerFilterLabel).toEqual('My documents')
|
||||
expect(component.sharedByFilterLabel).toEqual('Shared by me')
|
||||
|
||||
component.selectionModel.excludeUsers = [currentUserID]
|
||||
component.selectionModel.excludeUsers.set([currentUserID])
|
||||
expect(component.ownerExclusionFilterLabel).toEqual('Shared with me')
|
||||
})
|
||||
|
||||
it('should retain relative labels for inactive filter choices', () => {
|
||||
component.selectionModel.ownerFilter = OwnerFilterType.NONE
|
||||
component.selectionModel.ownerFilter.set(OwnerFilterType.NONE)
|
||||
|
||||
expect(component.ownerFilterLabel).toEqual('My documents')
|
||||
expect(component.ownerExclusionFilterLabel).toEqual('Shared with me')
|
||||
@@ -148,32 +148,41 @@ describe('PermissionsFilterDropdownComponent', () => {
|
||||
|
||||
it('should support reset', () => {
|
||||
component.setFilter(OwnerFilterType.OTHERS)
|
||||
expect(component.selectionModel.ownerFilter).not.toEqual(
|
||||
expect(component.selectionModel.ownerFilter()).not.toEqual(
|
||||
OwnerFilterType.NONE
|
||||
)
|
||||
component.reset()
|
||||
expect(component.selectionModel.ownerFilter).toEqual(OwnerFilterType.NONE)
|
||||
expect(component.selectionModel.ownerFilter()).toEqual(OwnerFilterType.NONE)
|
||||
})
|
||||
|
||||
it('should toggle owner filter type when users selected', () => {
|
||||
component.selectionModel.ownerFilter = OwnerFilterType.NONE
|
||||
component.selectionModel.ownerFilter.set(OwnerFilterType.NONE)
|
||||
|
||||
// this would normally be done by select component
|
||||
component.selectionModel.includeUsers = [12]
|
||||
component.selectionModel.includeUsers.set([12])
|
||||
component.onUserSelect()
|
||||
expect(component.selectionModel.ownerFilter).toEqual(OwnerFilterType.OTHERS)
|
||||
expect(component.selectionModel.ownerFilter()).toEqual(
|
||||
OwnerFilterType.OTHERS
|
||||
)
|
||||
|
||||
// this would normally be done by select component
|
||||
component.selectionModel.includeUsers = null
|
||||
component.selectionModel.includeUsers.set(null)
|
||||
component.onUserSelect()
|
||||
|
||||
expect(component.selectionModel.ownerFilter).toEqual(OwnerFilterType.NONE)
|
||||
expect(component.selectionModel.ownerFilter()).toEqual(OwnerFilterType.NONE)
|
||||
})
|
||||
it('should emit a selection model depending on the type of owner filter set', () => {
|
||||
component.selectionModel.ownerFilter = OwnerFilterType.NONE
|
||||
const emitted = () => ({
|
||||
excludeUsers: ownerFilterSetResult.excludeUsers(),
|
||||
hideUnowned: ownerFilterSetResult.hideUnowned(),
|
||||
includeUsers: ownerFilterSetResult.includeUsers(),
|
||||
ownerFilter: ownerFilterSetResult.ownerFilter(),
|
||||
userID: ownerFilterSetResult.userID(),
|
||||
})
|
||||
component.selectionModel.ownerFilter.set(OwnerFilterType.NONE)
|
||||
|
||||
component.setFilter(OwnerFilterType.SELF)
|
||||
expect(ownerFilterSetResult).toEqual({
|
||||
expect(emitted()).toEqual({
|
||||
excludeUsers: [],
|
||||
hideUnowned: false,
|
||||
includeUsers: [],
|
||||
@@ -182,7 +191,7 @@ describe('PermissionsFilterDropdownComponent', () => {
|
||||
})
|
||||
|
||||
component.setFilter(OwnerFilterType.NOT_SELF)
|
||||
expect(ownerFilterSetResult).toEqual({
|
||||
expect(emitted()).toEqual({
|
||||
excludeUsers: [currentUserID],
|
||||
hideUnowned: false,
|
||||
includeUsers: [],
|
||||
@@ -191,7 +200,7 @@ describe('PermissionsFilterDropdownComponent', () => {
|
||||
})
|
||||
|
||||
component.setFilter(OwnerFilterType.NONE)
|
||||
expect(ownerFilterSetResult).toEqual({
|
||||
expect(emitted()).toEqual({
|
||||
excludeUsers: [],
|
||||
hideUnowned: false,
|
||||
includeUsers: [],
|
||||
@@ -200,7 +209,7 @@ describe('PermissionsFilterDropdownComponent', () => {
|
||||
})
|
||||
|
||||
component.setFilter(OwnerFilterType.SHARED_BY_ME)
|
||||
expect(ownerFilterSetResult).toEqual({
|
||||
expect(emitted()).toEqual({
|
||||
excludeUsers: [],
|
||||
hideUnowned: false,
|
||||
includeUsers: [],
|
||||
@@ -209,7 +218,7 @@ describe('PermissionsFilterDropdownComponent', () => {
|
||||
})
|
||||
|
||||
component.setFilter(OwnerFilterType.UNOWNED)
|
||||
expect(ownerFilterSetResult).toEqual({
|
||||
expect(emitted()).toEqual({
|
||||
excludeUsers: [],
|
||||
hideUnowned: false,
|
||||
includeUsers: [],
|
||||
|
||||
@@ -25,18 +25,18 @@ import { ComponentWithPermissions } from '../../with-permissions/with-permission
|
||||
import { ClearableBadgeComponent } from '../clearable-badge/clearable-badge.component'
|
||||
|
||||
export class PermissionsSelectionModel {
|
||||
ownerFilter: OwnerFilterType
|
||||
hideUnowned: boolean
|
||||
userID: number
|
||||
includeUsers: number[]
|
||||
excludeUsers: number[]
|
||||
readonly ownerFilter = signal(OwnerFilterType.NONE)
|
||||
readonly hideUnowned = signal(false)
|
||||
readonly userID = signal<number>(null)
|
||||
readonly includeUsers = signal<number[]>([])
|
||||
readonly excludeUsers = signal<number[]>([])
|
||||
|
||||
clear() {
|
||||
this.ownerFilter = OwnerFilterType.NONE
|
||||
this.userID = null
|
||||
this.hideUnowned = false
|
||||
this.includeUsers = []
|
||||
this.excludeUsers = []
|
||||
this.ownerFilter.set(OwnerFilterType.NONE)
|
||||
this.userID.set(null)
|
||||
this.hideUnowned.set(false)
|
||||
this.includeUsers.set([])
|
||||
this.excludeUsers.set([])
|
||||
}
|
||||
}
|
||||
|
||||
@@ -84,33 +84,31 @@ export class PermissionsFilterDropdownComponent extends ComponentWithPermissions
|
||||
|
||||
readonly users = signal<User[]>([])
|
||||
|
||||
hideUnowned: boolean
|
||||
|
||||
get isActive(): boolean {
|
||||
return (
|
||||
this.selectionModel.ownerFilter !== OwnerFilterType.NONE ||
|
||||
this.selectionModel.hideUnowned
|
||||
this.selectionModel.ownerFilter() !== OwnerFilterType.NONE ||
|
||||
this.selectionModel.hideUnowned()
|
||||
)
|
||||
}
|
||||
|
||||
get ownerFilterLabel(): string {
|
||||
if (
|
||||
this.selectionModel?.ownerFilter !== OwnerFilterType.SELF ||
|
||||
this.selectionModel?.userID === this.settingsService.currentUser()?.id
|
||||
this.selectionModel?.ownerFilter() !== OwnerFilterType.SELF ||
|
||||
this.selectionModel?.userID() === this.settingsService.currentUser()?.id
|
||||
) {
|
||||
return $localize`My documents`
|
||||
}
|
||||
|
||||
const username = this.getUsername(this.selectionModel?.userID)
|
||||
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 ?? []
|
||||
const excludedUsers = this.selectionModel?.excludeUsers() ?? []
|
||||
if (
|
||||
this.selectionModel?.ownerFilter !== OwnerFilterType.NOT_SELF ||
|
||||
this.selectionModel?.ownerFilter() !== OwnerFilterType.NOT_SELF ||
|
||||
(excludedUsers.length === 1 &&
|
||||
excludedUsers[0] === this.settingsService.currentUser()?.id)
|
||||
) {
|
||||
@@ -130,13 +128,13 @@ export class PermissionsFilterDropdownComponent extends ComponentWithPermissions
|
||||
|
||||
get sharedByFilterLabel(): string {
|
||||
if (
|
||||
this.selectionModel?.ownerFilter !== OwnerFilterType.SHARED_BY_ME ||
|
||||
this.selectionModel?.userID === this.settingsService.currentUser()?.id
|
||||
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)
|
||||
const username = this.getUsername(this.selectionModel?.userID())
|
||||
return username
|
||||
? $localize`Shared by ${username}`
|
||||
: $localize`Shared by another user`
|
||||
@@ -169,34 +167,36 @@ export class PermissionsFilterDropdownComponent extends ComponentWithPermissions
|
||||
}
|
||||
|
||||
setFilter(type: OwnerFilterType) {
|
||||
this.selectionModel.ownerFilter = type
|
||||
if (this.selectionModel.ownerFilter === OwnerFilterType.SELF) {
|
||||
this.selectionModel.includeUsers = []
|
||||
this.selectionModel.excludeUsers = []
|
||||
this.selectionModel.userID = this.settingsService.currentUser().id
|
||||
this.selectionModel.hideUnowned = false
|
||||
} else if (this.selectionModel.ownerFilter === OwnerFilterType.NOT_SELF) {
|
||||
this.selectionModel.userID = null
|
||||
this.selectionModel.includeUsers = []
|
||||
this.selectionModel.excludeUsers = [this.settingsService.currentUser().id]
|
||||
this.selectionModel.hideUnowned = false
|
||||
} else if (this.selectionModel.ownerFilter === OwnerFilterType.NONE) {
|
||||
this.selectionModel.userID = null
|
||||
this.selectionModel.includeUsers = []
|
||||
this.selectionModel.excludeUsers = []
|
||||
this.selectionModel.hideUnowned = false
|
||||
this.selectionModel.ownerFilter.set(type)
|
||||
if (this.selectionModel.ownerFilter() === OwnerFilterType.SELF) {
|
||||
this.selectionModel.includeUsers.set([])
|
||||
this.selectionModel.excludeUsers.set([])
|
||||
this.selectionModel.userID.set(this.settingsService.currentUser().id)
|
||||
this.selectionModel.hideUnowned.set(false)
|
||||
} else if (this.selectionModel.ownerFilter() === OwnerFilterType.NOT_SELF) {
|
||||
this.selectionModel.userID.set(null)
|
||||
this.selectionModel.includeUsers.set([])
|
||||
this.selectionModel.excludeUsers.set([
|
||||
this.settingsService.currentUser().id,
|
||||
])
|
||||
this.selectionModel.hideUnowned.set(false)
|
||||
} else if (this.selectionModel.ownerFilter() === OwnerFilterType.NONE) {
|
||||
this.selectionModel.userID.set(null)
|
||||
this.selectionModel.includeUsers.set([])
|
||||
this.selectionModel.excludeUsers.set([])
|
||||
this.selectionModel.hideUnowned.set(false)
|
||||
} else if (
|
||||
this.selectionModel.ownerFilter === OwnerFilterType.SHARED_BY_ME
|
||||
this.selectionModel.ownerFilter() === OwnerFilterType.SHARED_BY_ME
|
||||
) {
|
||||
this.selectionModel.userID = this.settingsService.currentUser()?.id
|
||||
this.selectionModel.includeUsers = []
|
||||
this.selectionModel.excludeUsers = []
|
||||
this.selectionModel.hideUnowned = false
|
||||
} else if (this.selectionModel.ownerFilter === OwnerFilterType.UNOWNED) {
|
||||
this.selectionModel.userID = null
|
||||
this.selectionModel.includeUsers = []
|
||||
this.selectionModel.excludeUsers = []
|
||||
this.selectionModel.hideUnowned = false
|
||||
this.selectionModel.userID.set(this.settingsService.currentUser()?.id)
|
||||
this.selectionModel.includeUsers.set([])
|
||||
this.selectionModel.excludeUsers.set([])
|
||||
this.selectionModel.hideUnowned.set(false)
|
||||
} else if (this.selectionModel.ownerFilter() === OwnerFilterType.UNOWNED) {
|
||||
this.selectionModel.userID.set(null)
|
||||
this.selectionModel.includeUsers.set([])
|
||||
this.selectionModel.excludeUsers.set([])
|
||||
this.selectionModel.hideUnowned.set(false)
|
||||
}
|
||||
this.onChange()
|
||||
}
|
||||
@@ -206,11 +206,11 @@ export class PermissionsFilterDropdownComponent extends ComponentWithPermissions
|
||||
}
|
||||
|
||||
onUserSelect() {
|
||||
if (this.selectionModel.includeUsers?.length) {
|
||||
this.selectionModel.ownerFilter = OwnerFilterType.OTHERS
|
||||
} else {
|
||||
this.selectionModel.ownerFilter = OwnerFilterType.NONE
|
||||
}
|
||||
this.selectionModel.ownerFilter.set(
|
||||
this.selectionModel.includeUsers()?.length
|
||||
? OwnerFilterType.OTHERS
|
||||
: OwnerFilterType.NONE
|
||||
)
|
||||
this.onChange()
|
||||
}
|
||||
|
||||
|
||||
@@ -25,29 +25,34 @@
|
||||
|
||||
<div ngbDropdownMenu aria-labelledby="suggestionsDropdown" class="shadow suggestions-dropdown">
|
||||
<div class="list-group list-group-flush small pb-0">
|
||||
@if (totalSuggestions === 0) {
|
||||
@if (novelSuggestions === 0 && reusableSuggestions === 0) {
|
||||
<div class="list-group-item text-muted fst-italic">
|
||||
<small class="text-muted small fst-italic" i18n>No novel suggestions</small>
|
||||
</div>
|
||||
}
|
||||
@if (suggestions()?.suggested_tags.length > 0) {
|
||||
@if (suggestions()?.suggested_tags?.length > 0) {
|
||||
<small class="list-group-item text-uppercase text-muted small"><i-bs class="me-2" name="tags"></i-bs><ng-container i18n>Tags</ng-container></small>
|
||||
@for (tag of suggestions().suggested_tags; track tag) {
|
||||
<button type="button" class="list-group-item list-group-item-action bg-light" (click)="addTag.emit(tag)">{{ tag }}</button>
|
||||
}
|
||||
}
|
||||
@if (suggestions()?.suggested_document_types.length > 0) {
|
||||
@if (suggestions()?.suggested_document_types?.length > 0) {
|
||||
<div class="list-group-item text-uppercase text-muted small"><i-bs class="me-2" name="hash"></i-bs><ng-container i18n>Document Types</ng-container></div>
|
||||
@for (type of suggestions().suggested_document_types; track type) {
|
||||
<button type="button" class="list-group-item list-group-item-action bg-light" (click)="addDocumentType.emit(type)">{{ type }}</button>
|
||||
}
|
||||
}
|
||||
@if (suggestions()?.suggested_correspondents.length > 0) {
|
||||
@if (suggestions()?.suggested_correspondents?.length > 0) {
|
||||
<div class="list-group-item text-uppercase text-muted small"><i-bs class="me-2" name="person"></i-bs><ng-container i18n>Correspondents</ng-container></div>
|
||||
@for (correspondent of suggestions().suggested_correspondents; track correspondent) {
|
||||
<button type="button" class="list-group-item list-group-item-action bg-light" (click)="addCorrespondent.emit(correspondent)">{{ correspondent }}</button>
|
||||
}
|
||||
}
|
||||
@if (reusableSuggestions > 0) {
|
||||
<div class="list-group-item text-muted fst-italic">
|
||||
<small class="text-muted small fst-italic" i18n>{reusableSuggestions, plural, =1 {1 existing value suggested below} other {{{reusableSuggestions}} existing values suggested below}}</small>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -21,15 +21,115 @@ describe('SuggestionsDropdownComponent', () => {
|
||||
fixture.detectChanges()
|
||||
})
|
||||
|
||||
it('should calculate totalSuggestions', () => {
|
||||
it('should exclude suggested storage path names from totalSuggestions', () => {
|
||||
fixture.componentRef.setInput('suggestions', {
|
||||
suggested_correspondents: ['John Doe'],
|
||||
suggested_tags: ['Tag1', 'Tag2'],
|
||||
suggested_document_types: ['Type1'],
|
||||
suggested_storage_paths: ['Finance/Invoices'],
|
||||
})
|
||||
expect(component.totalSuggestions).toBe(4)
|
||||
})
|
||||
|
||||
it('should count suggestions when a category is absent from the response', () => {
|
||||
fixture.componentRef.setInput('suggestions', {
|
||||
suggested_tags: ['Tag1'],
|
||||
})
|
||||
expect(component.totalSuggestions).toBe(1)
|
||||
})
|
||||
|
||||
it('should count reused values the document does not have yet', () => {
|
||||
fixture.componentRef.setInput('suggestions', {
|
||||
tags: [1, 2, 3],
|
||||
correspondents: [10],
|
||||
document_types: [20],
|
||||
suggested_tags: ['NewTag'],
|
||||
})
|
||||
fixture.componentRef.setInput('appliedTags', [2])
|
||||
fixture.componentRef.setInput('appliedDocumentType', 20)
|
||||
|
||||
// tags 1 and 3 are not applied yet, correspondent 10 is not set, tag 2 and
|
||||
// document type 20 already are.
|
||||
expect(component.reusableSuggestions).toBe(3)
|
||||
expect(component.novelSuggestions).toBe(1)
|
||||
expect(component.totalSuggestions).toBe(4)
|
||||
})
|
||||
|
||||
it('should not count reused values that are already applied', () => {
|
||||
fixture.componentRef.setInput('suggestions', {
|
||||
tags: [1],
|
||||
correspondents: [10],
|
||||
document_types: [20],
|
||||
})
|
||||
fixture.componentRef.setInput('appliedTags', [1])
|
||||
fixture.componentRef.setInput('appliedCorrespondent', 10)
|
||||
fixture.componentRef.setInput('appliedDocumentType', 20)
|
||||
|
||||
expect(component.totalSuggestions).toBe(0)
|
||||
})
|
||||
|
||||
it('should point at the fields when suggestions are all reused', () => {
|
||||
// The dropdown lists only values to create, so a response made entirely of
|
||||
// reused existing objects used to render as "No novel suggestions".
|
||||
fixture.componentRef.setInput('aiEnabled', true)
|
||||
fixture.componentRef.setInput('suggestions', {
|
||||
tags: [1, 2],
|
||||
suggested_tags: [],
|
||||
suggested_correspondents: [],
|
||||
suggested_document_types: [],
|
||||
})
|
||||
fixture.detectChanges()
|
||||
component.clickSuggest()
|
||||
fixture.detectChanges()
|
||||
|
||||
expect(fixture.nativeElement.textContent).toContain(
|
||||
'2 existing values suggested below'
|
||||
)
|
||||
expect(fixture.nativeElement.textContent).not.toContain(
|
||||
'No novel suggestions'
|
||||
)
|
||||
})
|
||||
|
||||
it('should account for reused values alongside values to create', () => {
|
||||
// The badge counts both, but only the novel names are listed here, so the
|
||||
// dropdown has to say where the rest of the count came from.
|
||||
fixture.componentRef.setInput('aiEnabled', true)
|
||||
fixture.componentRef.setInput('suggestions', {
|
||||
tags: [6, 3],
|
||||
suggested_tags: ['Arbitration', 'New York'],
|
||||
suggested_correspondents: [],
|
||||
suggested_document_types: [],
|
||||
})
|
||||
fixture.detectChanges()
|
||||
component.clickSuggest()
|
||||
fixture.detectChanges()
|
||||
|
||||
expect(component.totalSuggestions).toBe(4)
|
||||
expect(fixture.nativeElement.textContent).toContain('Arbitration')
|
||||
expect(fixture.nativeElement.textContent).toContain(
|
||||
'2 existing values suggested below'
|
||||
)
|
||||
})
|
||||
|
||||
it('should count classic (non-AI) suggestions, which are ids only', () => {
|
||||
// /api/documents/{id}/suggestions/ returns only id arrays and no
|
||||
// suggested_* keys at all, so every one of its suggestions is a reused
|
||||
// existing object - including storage paths.
|
||||
fixture.componentRef.setInput('suggestions', {
|
||||
correspondents: [4],
|
||||
tags: [6, 3],
|
||||
document_types: [2],
|
||||
storage_paths: [7],
|
||||
dates: ['2005-01-01'],
|
||||
})
|
||||
|
||||
expect(component.novelSuggestions).toBe(0)
|
||||
expect(component.totalSuggestions).toBe(5)
|
||||
|
||||
fixture.componentRef.setInput('appliedStoragePath', 7)
|
||||
expect(component.totalSuggestions).toBe(4)
|
||||
})
|
||||
|
||||
it('should show when a completed request returned no suggestions', () => {
|
||||
fixture.componentRef.setInput('suggestions', {
|
||||
correspondents: [],
|
||||
|
||||
@@ -25,6 +25,11 @@ export class SuggestionsDropdownComponent {
|
||||
readonly loading = input(false)
|
||||
readonly disabled = input(false)
|
||||
|
||||
readonly appliedTags = input<number[]>([])
|
||||
readonly appliedCorrespondent = input<number>(null)
|
||||
readonly appliedDocumentType = input<number>(null)
|
||||
readonly appliedStoragePath = input<number>(null)
|
||||
|
||||
@Output()
|
||||
getSuggestions: EventEmitter<SuggestionsDropdownComponent> =
|
||||
new EventEmitter()
|
||||
@@ -54,14 +59,46 @@ export class SuggestionsDropdownComponent {
|
||||
}
|
||||
}
|
||||
|
||||
get totalSuggestions(): number {
|
||||
get novelSuggestions(): number {
|
||||
return (
|
||||
this.suggestions()?.suggested_correspondents?.length +
|
||||
this.suggestions()?.suggested_tags?.length +
|
||||
this.suggestions()?.suggested_document_types?.length || 0
|
||||
(this.suggestions()?.suggested_correspondents?.length ?? 0) +
|
||||
(this.suggestions()?.suggested_tags?.length ?? 0) +
|
||||
(this.suggestions()?.suggested_document_types?.length ?? 0)
|
||||
)
|
||||
}
|
||||
|
||||
get reusableSuggestions(): number {
|
||||
const correspondent = this.appliedCorrespondent()
|
||||
const documentType = this.appliedDocumentType()
|
||||
const storagePath = this.appliedStoragePath()
|
||||
// Storage paths count here but not in novelSuggestions: an existing one
|
||||
// can be applied from the field, a suggested name cannot create one.
|
||||
return (
|
||||
this.countUnapplied(this.suggestions()?.tags, this.appliedTags()) +
|
||||
this.countUnapplied(
|
||||
this.suggestions()?.correspondents,
|
||||
correspondent ? [correspondent] : []
|
||||
) +
|
||||
this.countUnapplied(
|
||||
this.suggestions()?.document_types,
|
||||
documentType ? [documentType] : []
|
||||
) +
|
||||
this.countUnapplied(
|
||||
this.suggestions()?.storage_paths,
|
||||
storagePath ? [storagePath] : []
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
get totalSuggestions(): number {
|
||||
return this.novelSuggestions + this.reusableSuggestions
|
||||
}
|
||||
|
||||
private countUnapplied(suggested: number[], applied: number[]): number {
|
||||
return (suggested ?? []).filter((id) => !(applied ?? []).includes(id))
|
||||
.length
|
||||
}
|
||||
|
||||
get noSuggestions(): boolean {
|
||||
const suggestions = this.suggestions()
|
||||
return (
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
<input type="file" class="visually-hidden" (change)="onFileSelected($event)" multiple #fileUpload>
|
||||
</form>
|
||||
@if (getStatus().length > 0) {
|
||||
<div class="fixed-bottom p-2 p-md-4 d-flex justify-content-end pe-none consumer-status-list" [ngClass]="slimSidebarEnabled ? 'col-slim' : 'offset-md-3 offset-lg-2'">
|
||||
<div class="fixed-bottom p-2 p-md-4 d-flex justify-content-end pe-none consumer-status-list" [ngClass]="slimSidebarEnabled ? 'col-slim' : 'offset-sidebar-expanded'">
|
||||
<div class="col col-lg-4 col-xl-3 ps-0 pe-0 ps-lg-3 pe-lg-0 pe-auto overflow-y-scroll">
|
||||
<div class="card shadow-sm consumer-status-card">
|
||||
<div class="card-body">
|
||||
|
||||
@@ -134,6 +134,10 @@
|
||||
[loading]="suggestionsLoading()"
|
||||
[suggestions]="suggestions()"
|
||||
[aiEnabled]="aiEnabled"
|
||||
[appliedTags]="documentForm.value.tags"
|
||||
[appliedCorrespondent]="documentForm.value.correspondent"
|
||||
[appliedDocumentType]="documentForm.value.document_type"
|
||||
[appliedStoragePath]="documentForm.value.storage_path"
|
||||
(getSuggestions)="getSuggestions()"
|
||||
(addTag)="createTag($event)"
|
||||
(addDocumentType)="createDocumentType($event)"
|
||||
|
||||
@@ -24,7 +24,7 @@ import {
|
||||
} from '@ng-bootstrap/ng-bootstrap'
|
||||
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
||||
import { DeviceDetectorService } from 'ngx-device-detector'
|
||||
import { of, throwError } from 'rxjs'
|
||||
import { Subject, of, throwError } from 'rxjs'
|
||||
import { routes } from 'src/app/app-routing.module'
|
||||
import { Correspondent } from 'src/app/data/correspondent'
|
||||
import { CustomFieldDataType } from 'src/app/data/custom-field'
|
||||
@@ -1209,24 +1209,53 @@ describe('DocumentDetailComponent', () => {
|
||||
expect(fixture.debugElement.queryAll(By.css('textarea.rtl'))).not.toBeNull()
|
||||
})
|
||||
|
||||
it('should display built-in pdf viewer if not disabled', () => {
|
||||
it('should display built-in pdf viewer if not disabled', async () => {
|
||||
initNormally()
|
||||
component.document().archived_file_name = 'file.pdf'
|
||||
component.document.update((document) => ({
|
||||
...document,
|
||||
archived_file_name: 'file.pdf',
|
||||
}))
|
||||
settingsService.set(SETTINGS_KEYS.USE_NATIVE_PDF_VIEWER, false)
|
||||
expect(component.useNativePdfViewer).toBeFalsy()
|
||||
fixture.detectChanges()
|
||||
await fixture.whenStable()
|
||||
expect(fixture.debugElement.query(By.css('pngx-pdf-viewer'))).not.toBeNull()
|
||||
})
|
||||
|
||||
it('should display native pdf viewer if enabled', () => {
|
||||
initNormally()
|
||||
component.document().archived_file_name = 'file.pdf'
|
||||
component.document.update((document) => ({
|
||||
...document,
|
||||
archived_file_name: 'file.pdf',
|
||||
}))
|
||||
settingsService.set(SETTINGS_KEYS.USE_NATIVE_PDF_VIEWER, true)
|
||||
expect(component.useNativePdfViewer).toBeTruthy()
|
||||
fixture.detectChanges()
|
||||
expect(fixture.debugElement.query(By.css('object'))).not.toBeNull()
|
||||
})
|
||||
|
||||
it('should reflect signal-backed document detail display settings', () => {
|
||||
settingsService.set(SETTINGS_KEYS.DOCUMENT_EDITING_OVERLAY_THUMBNAIL, false)
|
||||
settingsService.set(SETTINGS_KEYS.DOCUMENT_DETAILS_HIDDEN_FIELDS, [
|
||||
component.DocumentDetailFieldID.Correspondent,
|
||||
])
|
||||
|
||||
expect(component.showThumbnailOverlay).toBeFalsy()
|
||||
expect(
|
||||
component.isFieldHidden(component.DocumentDetailFieldID.Correspondent)
|
||||
).toBeTruthy()
|
||||
expect(
|
||||
component.isFieldHidden(component.DocumentDetailFieldID.DocumentType)
|
||||
).toBeFalsy()
|
||||
|
||||
settingsService.set(SETTINGS_KEYS.DOCUMENT_EDITING_OVERLAY_THUMBNAIL, true)
|
||||
settingsService.set(SETTINGS_KEYS.DOCUMENT_DETAILS_HIDDEN_FIELDS, [])
|
||||
|
||||
expect(component.showThumbnailOverlay).toBeTruthy()
|
||||
expect(
|
||||
component.isFieldHidden(component.DocumentDetailFieldID.Correspondent)
|
||||
).toBeFalsy()
|
||||
})
|
||||
|
||||
it('should attempt to retrieve metadata', () => {
|
||||
const metadataSpy = jest.spyOn(documentService, 'getMetadata')
|
||||
metadataSpy.mockReturnValue(of({ has_archive_version: true }))
|
||||
@@ -1444,6 +1473,55 @@ describe('DocumentDetailComponent', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('should not automatically get suggestions if auto-suggest is disabled', () => {
|
||||
settingsService.set(SETTINGS_KEYS.DOCUMENT_EDITING_AUTO_SUGGEST, false)
|
||||
const suggestionsSpy = jest.spyOn(documentService, 'getSuggestions')
|
||||
suggestionsSpy.mockReturnValue(of({ tags: [42] }))
|
||||
initNormally()
|
||||
expect(suggestionsSpy).not.toHaveBeenCalled()
|
||||
|
||||
// still available on demand
|
||||
component.getSuggestions()
|
||||
expect(suggestionsSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should not automatically get AI suggestions if auto-suggest is disabled', () => {
|
||||
settingsService.set(SETTINGS_KEYS.DOCUMENT_EDITING_AUTO_SUGGEST, false)
|
||||
const getSetting = settingsService.get.bind(settingsService)
|
||||
jest
|
||||
.spyOn(settingsService, 'get')
|
||||
.mockImplementation((key) =>
|
||||
key === SETTINGS_KEYS.AI_ENABLED ? true : getSetting(key)
|
||||
)
|
||||
const aiSuggestionsSpy = jest.spyOn(documentService, 'getAiSuggestions')
|
||||
aiSuggestionsSpy.mockReturnValue(of({ tags: [42] }))
|
||||
initNormally()
|
||||
expect(aiSuggestionsSpy).not.toHaveBeenCalled()
|
||||
|
||||
component.getSuggestions()
|
||||
expect(aiSuggestionsSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should reset the suggestions loading state if the document changes mid-request', () => {
|
||||
const getSetting = settingsService.get.bind(settingsService)
|
||||
jest
|
||||
.spyOn(settingsService, 'get')
|
||||
.mockImplementation((key) =>
|
||||
key === SETTINGS_KEYS.AI_ENABLED ? true : getSetting(key)
|
||||
)
|
||||
const pending = new Subject<any>()
|
||||
jest
|
||||
.spyOn(documentService, 'getAiSuggestions')
|
||||
.mockReturnValue(pending.asObservable())
|
||||
initNormally()
|
||||
expect(component.suggestionsLoading()).toBeTruthy()
|
||||
|
||||
// the in-flight request is cancelled, e.g. by a websocket-driven reload
|
||||
component.docChangeNotifier.next(component.documentId())
|
||||
|
||||
expect(component.suggestionsLoading()).toBeFalsy()
|
||||
})
|
||||
|
||||
it('should show error if needed for get suggestions', () => {
|
||||
const suggestionsSpy = jest.spyOn(documentService, 'getSuggestions')
|
||||
const errorSpy = jest.spyOn(toastService, 'showError')
|
||||
@@ -1665,7 +1743,10 @@ describe('DocumentDetailComponent', () => {
|
||||
|
||||
it('should change preview element by render type', () => {
|
||||
initNormally()
|
||||
component.document().archived_file_name = 'file.pdf'
|
||||
component.document.update((document) => ({
|
||||
...document,
|
||||
archived_file_name: 'file.pdf',
|
||||
}))
|
||||
fixture.detectChanges()
|
||||
expect(component.archiveContentRenderType).toEqual(
|
||||
component.ContentRenderType.PDF
|
||||
@@ -1674,8 +1755,11 @@ describe('DocumentDetailComponent', () => {
|
||||
fixture.debugElement.query(By.css('pdf-viewer-container'))
|
||||
).not.toBeUndefined()
|
||||
|
||||
component.document().archived_file_name = undefined
|
||||
component.document().mime_type = 'text/plain'
|
||||
component.document.update((document) => ({
|
||||
...document,
|
||||
archived_file_name: undefined,
|
||||
mime_type: 'text/plain',
|
||||
}))
|
||||
fixture.detectChanges()
|
||||
expect(component.archiveContentRenderType).toEqual(
|
||||
component.ContentRenderType.Text
|
||||
@@ -1684,7 +1768,10 @@ describe('DocumentDetailComponent', () => {
|
||||
fixture.debugElement.query(By.css('div.preview-sticky'))
|
||||
).not.toBeUndefined()
|
||||
|
||||
component.document().mime_type = 'image/jpeg'
|
||||
component.document.update((document) => ({
|
||||
...document,
|
||||
mime_type: 'image/jpeg',
|
||||
}))
|
||||
fixture.detectChanges()
|
||||
expect(component.archiveContentRenderType).toEqual(
|
||||
component.ContentRenderType.Image
|
||||
@@ -1692,9 +1779,12 @@ describe('DocumentDetailComponent', () => {
|
||||
expect(
|
||||
fixture.debugElement.query(By.css('.preview-sticky img'))
|
||||
).not.toBeUndefined()
|
||||
;((component.document().mime_type =
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document'),
|
||||
fixture.detectChanges())
|
||||
component.document.update((document) => ({
|
||||
...document,
|
||||
mime_type:
|
||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||
}))
|
||||
fixture.detectChanges()
|
||||
expect(component.archiveContentRenderType).toEqual(
|
||||
component.ContentRenderType.Other
|
||||
)
|
||||
|
||||
@@ -34,6 +34,7 @@ import {
|
||||
debounceTime,
|
||||
distinctUntilChanged,
|
||||
filter,
|
||||
finalize,
|
||||
first,
|
||||
map,
|
||||
switchMap,
|
||||
@@ -226,6 +227,22 @@ export class DocumentDetailComponent
|
||||
private deviceDetectorService = inject(DeviceDetectorService)
|
||||
private savedViewService = inject(SavedViewService)
|
||||
private readonly websocketStatusService = inject(WebsocketStatusService)
|
||||
private readonly useNativePdfViewerSetting = this.settings.getSignal<boolean>(
|
||||
SETTINGS_KEYS.USE_NATIVE_PDF_VIEWER
|
||||
)
|
||||
private readonly aiEnabledSetting = this.settings.getSignal<boolean>(
|
||||
SETTINGS_KEYS.AI_ENABLED
|
||||
)
|
||||
private readonly showThumbnailOverlaySetting =
|
||||
this.settings.getSignal<boolean>(
|
||||
SETTINGS_KEYS.DOCUMENT_EDITING_OVERLAY_THUMBNAIL
|
||||
)
|
||||
private readonly autoSuggestSetting = this.settings.getSignal<boolean>(
|
||||
SETTINGS_KEYS.DOCUMENT_EDITING_AUTO_SUGGEST
|
||||
)
|
||||
private readonly hiddenFieldsSetting = this.settings.getSignal<
|
||||
DocumentDetailFieldID[]
|
||||
>(SETTINGS_KEYS.DOCUMENT_DETAILS_HIDDEN_FIELDS)
|
||||
|
||||
@ViewChild('inputTitle')
|
||||
titleInput: TextComponent
|
||||
@@ -332,8 +349,7 @@ export class DocumentDetailComponent
|
||||
}
|
||||
|
||||
get useNativePdfViewer(): boolean {
|
||||
this.settings.trackChanges()
|
||||
return this.settings.get(SETTINGS_KEYS.USE_NATIVE_PDF_VIEWER)
|
||||
return this.useNativePdfViewerSetting()
|
||||
}
|
||||
|
||||
get isMobile(): boolean {
|
||||
@@ -341,12 +357,14 @@ export class DocumentDetailComponent
|
||||
}
|
||||
|
||||
get aiEnabled(): boolean {
|
||||
this.settings.trackChanges()
|
||||
return this.settings.get(SETTINGS_KEYS.AI_ENABLED)
|
||||
return this.aiEnabledSetting()
|
||||
}
|
||||
|
||||
get autoSuggest(): boolean {
|
||||
return this.autoSuggestSetting()
|
||||
}
|
||||
|
||||
get archiveContentRenderType(): ContentRenderType {
|
||||
this.settings.trackChanges()
|
||||
const hasArchiveVersion =
|
||||
this.metadata()?.has_archive_version ??
|
||||
!!this.document()?.archived_file_name
|
||||
@@ -358,22 +376,17 @@ export class DocumentDetailComponent
|
||||
}
|
||||
|
||||
get originalContentRenderType(): ContentRenderType {
|
||||
this.settings.trackChanges()
|
||||
return this.getRenderType(
|
||||
this.metadata()?.original_mime_type || this.document()?.mime_type
|
||||
)
|
||||
}
|
||||
|
||||
get showThumbnailOverlay(): boolean {
|
||||
this.settings.trackChanges()
|
||||
return this.settings.get(SETTINGS_KEYS.DOCUMENT_EDITING_OVERLAY_THUMBNAIL)
|
||||
return this.showThumbnailOverlaySetting()
|
||||
}
|
||||
|
||||
isFieldHidden(fieldId: DocumentDetailFieldID): boolean {
|
||||
this.settings.trackChanges()
|
||||
return this.settings
|
||||
.get(SETTINGS_KEYS.DOCUMENT_DETAILS_HIDDEN_FIELDS)
|
||||
.includes(fieldId)
|
||||
return this.hiddenFieldsSetting().includes(fieldId)
|
||||
}
|
||||
|
||||
private getRenderType(mimeType: string): ContentRenderType {
|
||||
@@ -898,6 +911,7 @@ export class DocumentDetailComponent
|
||||
this.updateFormForCustomFields()
|
||||
this.loadMetadataForSelectedVersion()
|
||||
if (
|
||||
this.autoSuggest &&
|
||||
this.permissionsService.currentUserHasObjectPermissions(
|
||||
PermissionAction.Change,
|
||||
doc
|
||||
@@ -1016,16 +1030,15 @@ export class DocumentDetailComponent
|
||||
.pipe(
|
||||
first(),
|
||||
takeUntil(this.unsubscribeNotifier),
|
||||
takeUntil(this.docChangeNotifier)
|
||||
takeUntil(this.docChangeNotifier),
|
||||
finalize(() => this.suggestionsLoading.set(false))
|
||||
)
|
||||
.subscribe({
|
||||
next: (result) => {
|
||||
this.suggestions.set(result)
|
||||
this.suggestionsLoading.set(false)
|
||||
},
|
||||
error: (error) => {
|
||||
this.suggestions.set(null)
|
||||
this.suggestionsLoading.set(false)
|
||||
this.toastService.showError(
|
||||
$localize`Error retrieving suggestions.`,
|
||||
error
|
||||
|
||||
@@ -114,13 +114,23 @@
|
||||
</div>
|
||||
</button>
|
||||
<div ngbDropdownMenu aria-labelledby="dropdownSend" class="shadow">
|
||||
<button ngbDropdownItem (click)="createShareLinkBundle()" [disabled]="!canSendSelection">
|
||||
<i-bs name="link" class="me-1"></i-bs><ng-container i18n>Create a share link bundle</ng-container>
|
||||
</button>
|
||||
<button ngbDropdownItem (click)="manageShareLinkBundles()">
|
||||
<i-bs name="list-ul" class="me-1"></i-bs><ng-container i18n>Manage share link bundles</ng-container>
|
||||
</button>
|
||||
<div class="dropdown-divider"></div>
|
||||
@if (permissionService.currentUserCan(PermissionAction.Add, PermissionType.ShareLinkBundle)) {
|
||||
<button ngbDropdownItem (click)="createShareLinkBundle()" [disabled]="!canSendSelection">
|
||||
<i-bs name="link" class="me-1"></i-bs><ng-container i18n>Create a share link bundle</ng-container>
|
||||
</button>
|
||||
}
|
||||
@if (permissionService.currentUserCan(PermissionAction.View, PermissionType.ShareLinkBundle)) {
|
||||
<button ngbDropdownItem (click)="manageShareLinkBundles()">
|
||||
<i-bs name="list-ul" class="me-1"></i-bs><ng-container i18n>Manage share link bundles</ng-container>
|
||||
</button>
|
||||
}
|
||||
@if (
|
||||
emailEnabled &&
|
||||
(permissionService.currentUserCan(PermissionAction.Add, PermissionType.ShareLinkBundle) ||
|
||||
permissionService.currentUserCan(PermissionAction.View, PermissionType.ShareLinkBundle))
|
||||
) {
|
||||
<div class="dropdown-divider"></div>
|
||||
}
|
||||
@if (emailEnabled) {
|
||||
<button ngbDropdownItem (click)="emailSelected()" [disabled]="!canSendSelection">
|
||||
<i-bs name="envelope" class="me-1"></i-bs><ng-container i18n>Email</ng-container>
|
||||
|
||||
@@ -19,7 +19,11 @@ import { StoragePath } from 'src/app/data/storage-path'
|
||||
import { Tag } from 'src/app/data/tag'
|
||||
import { FilterPipe } from 'src/app/pipes/filter.pipe'
|
||||
import { DocumentListViewService } from 'src/app/services/document-list-view.service'
|
||||
import { PermissionsService } from 'src/app/services/permissions.service'
|
||||
import {
|
||||
PermissionAction,
|
||||
PermissionsService,
|
||||
PermissionType,
|
||||
} from 'src/app/services/permissions.service'
|
||||
import { CorrespondentService } from 'src/app/services/rest/correspondent.service'
|
||||
import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
|
||||
import { DocumentTypeService } from 'src/app/services/rest/document-type.service'
|
||||
@@ -252,6 +256,54 @@ describe('BulkEditorComponent', () => {
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('should only show permitted share link bundle actions', () => {
|
||||
permissionsService.initialize(
|
||||
[
|
||||
permissionsService.getPermissionCode(
|
||||
PermissionAction.Add,
|
||||
PermissionType.ShareLinkBundle
|
||||
),
|
||||
],
|
||||
{ is_superuser: false } as any
|
||||
)
|
||||
fixture.detectChanges()
|
||||
|
||||
expect(fixture.nativeElement.textContent).toContain(
|
||||
'Create a share link bundle'
|
||||
)
|
||||
expect(fixture.nativeElement.textContent).not.toContain(
|
||||
'Manage share link bundles'
|
||||
)
|
||||
|
||||
permissionsService.initialize(
|
||||
[
|
||||
permissionsService.getPermissionCode(
|
||||
PermissionAction.View,
|
||||
PermissionType.ShareLinkBundle
|
||||
),
|
||||
],
|
||||
{ is_superuser: false } as any
|
||||
)
|
||||
fixture.detectChanges()
|
||||
|
||||
expect(fixture.nativeElement.textContent).not.toContain(
|
||||
'Create a share link bundle'
|
||||
)
|
||||
expect(fixture.nativeElement.textContent).toContain(
|
||||
'Manage share link bundles'
|
||||
)
|
||||
|
||||
permissionsService.initialize([], { is_superuser: false } as any)
|
||||
fixture.detectChanges()
|
||||
|
||||
expect(fixture.nativeElement.textContent).not.toContain(
|
||||
'Create a share link bundle'
|
||||
)
|
||||
expect(fixture.nativeElement.textContent).not.toContain(
|
||||
'Manage share link bundles'
|
||||
)
|
||||
})
|
||||
|
||||
it('should apply selection data to correspondents menu', () => {
|
||||
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
|
||||
fixture.detectChanges()
|
||||
|
||||
@@ -101,7 +101,7 @@ export class BulkEditorComponent
|
||||
private toastService = inject(ToastService)
|
||||
private storagePathService = inject(StoragePathService)
|
||||
private customFieldService = inject(CustomFieldsService)
|
||||
private permissionService = inject(PermissionsService)
|
||||
public readonly permissionService = inject(PermissionsService)
|
||||
private savedViewService = inject(SavedViewService)
|
||||
private readonly shareLinkBundleService = inject(ShareLinkBundleService)
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
</div>
|
||||
<div class="d-none d-sm-flex flex-fill me-3">
|
||||
<div class="input-group input-group-sm">
|
||||
<span class="input-group-text border-0" i18n>Select:</span>
|
||||
<span class="input-group-text bg-transparent border-0" i18n>Select:</span>
|
||||
</div>
|
||||
<div class="btn-group btn-group-sm flex-nowrap">
|
||||
@if (list.hasSelection) {
|
||||
@@ -116,7 +116,7 @@
|
||||
|
||||
</pngx-page-header>
|
||||
|
||||
<div class="row sticky-top py-3 mt-n2 mt-md-n3 bg-body">
|
||||
<div class="row sticky-top py-3 mt-n2 mt-md-n3 bg-body rounded shadow-sm">
|
||||
<pngx-filter-editor [hidden]="isBulkEditing" [disabled]="isBulkEditing" [filterRules]="list.filterRules" (filterRulesChange)="onFilterRulesChange($event)" (resetFilterRules)="onFilterRulesReset($event)" [unmodifiedFilterRules]="unmodifiedFilterRules()" [selectionData]="list.selectionData" #filterEditor></pngx-filter-editor>
|
||||
<pngx-bulk-editor [hidden]="!isBulkEditing" [disabled]="!isBulkEditing"></pngx-bulk-editor>
|
||||
</div>
|
||||
|
||||
@@ -121,6 +121,8 @@ export class DocumentListComponent
|
||||
settingsService = inject(SettingsService)
|
||||
private hotKeyService = inject(HotKeyService)
|
||||
permissionService = inject(PermissionsService)
|
||||
private readonly notesEnabledSetting =
|
||||
this.settingsService.getSignal<boolean>(SETTINGS_KEYS.NOTES_ENABLED)
|
||||
|
||||
DisplayField = DisplayField
|
||||
DisplayMode = DisplayMode
|
||||
@@ -574,8 +576,7 @@ export class DocumentListComponent
|
||||
}
|
||||
|
||||
get notesEnabled(): boolean {
|
||||
this.settingsService.trackChanges()
|
||||
return this.settingsService.get(SETTINGS_KEYS.NOTES_ENABLED)
|
||||
return this.notesEnabledSetting()
|
||||
}
|
||||
|
||||
resetFilters() {
|
||||
|
||||