Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 | ||
|
|
02b2f55248 | ||
|
|
a2ea3b9e76 | ||
|
|
a298568962 | ||
|
|
f6f37898e8 | ||
|
|
0cfe33720a | ||
|
|
14b17ddf8d | ||
|
|
9009fbb4e9 | ||
|
|
94651d817d | ||
|
|
1630e78aac | ||
|
|
4e79553489 | ||
|
|
f24d58a383 | ||
|
|
d486dd1205 | ||
|
|
406bc1a233 | ||
|
|
808746c28c | ||
|
|
ff6d73ce17 | ||
|
|
b4068d38bc | ||
|
|
139739c2a2 | ||
|
|
cafed919a1 | ||
|
|
549afde1fd | ||
|
|
a8576d827e | ||
|
|
7330c4d9cb | ||
|
|
aad75b4981 | ||
|
|
2e7cd14964 | ||
|
|
5090ffe39e | ||
|
|
35d40b51cb | ||
|
|
78025df405 | ||
|
|
e24db7023a | ||
|
|
f5ddc14588 |
@@ -185,22 +185,16 @@ jobs:
|
|||||||
flags: frontend-node-${{ matrix.node-version }}
|
flags: frontend-node-${{ matrix.node-version }}
|
||||||
directory: src-ui/coverage/
|
directory: src-ui/coverage/
|
||||||
e2e-tests:
|
e2e-tests:
|
||||||
name: "E2E Tests (${{ matrix.shard-index }}/${{ matrix.shard-count }})"
|
name: E2E Tests
|
||||||
needs: [changes, install-dependencies]
|
needs: [changes, install-dependencies]
|
||||||
if: needs.changes.outputs.frontend_changed == 'true'
|
if: needs.changes.outputs.frontend_changed == 'true'
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
container: mcr.microsoft.com/playwright:v1.62.0-noble
|
container: mcr.microsoft.com/playwright:v1.62.1-noble
|
||||||
env:
|
env:
|
||||||
PLAYWRIGHT_BROWSERS_PATH: /ms-playwright
|
PLAYWRIGHT_BROWSERS_PATH: /ms-playwright
|
||||||
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1
|
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1
|
||||||
strategy:
|
|
||||||
fail-fast: false
|
|
||||||
matrix:
|
|
||||||
node-version: [24.x]
|
|
||||||
shard-index: [1, 2]
|
|
||||||
shard-count: [2]
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
@@ -216,6 +210,17 @@ jobs:
|
|||||||
node-version: 24.x
|
node-version: 24.x
|
||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
cache-dependency-path: 'src-ui/pnpm-lock.yaml'
|
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
|
- name: Cache frontend dependencies
|
||||||
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
uses: actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0
|
||||||
with:
|
with:
|
||||||
@@ -225,8 +230,17 @@ jobs:
|
|||||||
key: ${{ runner.os }}-frontend-${{ hashFiles('src-ui/pnpm-lock.yaml') }}
|
key: ${{ runner.os }}-frontend-${{ hashFiles('src-ui/pnpm-lock.yaml') }}
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: cd src-ui && pnpm install --frozen-lockfile
|
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
|
- 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:
|
frontend-build:
|
||||||
name: Frontend Build
|
name: Frontend Build
|
||||||
needs: [changes, unit-tests, e2e-tests]
|
needs: [changes, unit-tests, e2e-tests]
|
||||||
|
|||||||
@@ -59,10 +59,13 @@ The following are not generally considered vulnerabilities unless accompanied by
|
|||||||
- large uploads or resource usage that do not bypass documented limits or privileges
|
- large uploads or resource usage that do not bypass documented limits or privileges
|
||||||
- IDOR / access control claims regarding the ability to attach an un-viewable object to a document. This is expected behavior.
|
- IDOR / access control claims regarding the ability to attach an un-viewable object to a document. This is expected behavior.
|
||||||
- claims based solely on the presence of a library, framework feature or code pattern without a working exploit
|
- claims based solely on the presence of a library, framework feature or code pattern without a working exploit
|
||||||
- reports that rely on admin-level access, workflow-editing privileges, shell access, or other high-trust roles unless they demonstrate an unintended privilege boundary bypass
|
- pickle deserialization of internal data from trusted components such as the Redis-compatible broker or Paperless-ngx data directory
|
||||||
|
- users with permission to edit users granting themselves additional privileges; this is expected behavior for that trusted permission
|
||||||
|
- reports that rely on admin-level access, application-configuration access, workflow-editing privileges, shell access, or other high-trust roles unless they demonstrate an unintended privilege boundary bypass
|
||||||
- optional webhook, mail, AI, OCR, or integration behavior described without a product-level vulnerability
|
- optional webhook, mail, AI, OCR, or integration behavior described without a product-level vulnerability
|
||||||
- missing limits or hardening settings presented without concrete impact
|
- missing limits or hardening settings presented without concrete impact
|
||||||
- generic AI or static-analysis output that is not confirmed against the current codebase and a real deployment scenario
|
- generic AI or static-analysis output that is not confirmed against the current codebase and a real deployment scenario
|
||||||
|
- metadata names visible in a document's custom storage path, even when the user cannot access the underlying metadata object; this is expected behavior
|
||||||
- the ability to attach objects that a user cannot access to a document by ID is an intentional design choice, and not considered a vulnerability
|
- the ability to attach objects that a user cannot access to a document by ID is an intentional design choice, and not considered a vulnerability
|
||||||
|
|
||||||
## Transparency
|
## Transparency
|
||||||
|
|||||||
|
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,256 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## 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
|
||||||
|
|
||||||
|
- Enhancement: Apply AI suggestions workflow action [@shamoon](https://github.com/shamoon) ([#13639](https://github.com/paperless-ngx/paperless-ngx/pull/13639))
|
||||||
|
- Enhancement: welcome widget visual tweaks [@shamoon](https://github.com/shamoon) ([#13794](https://github.com/paperless-ngx/paperless-ngx/pull/13794))
|
||||||
|
- Enhancement: support using remote OCR engines selectively [@shamoon](https://github.com/shamoon) ([#13633](https://github.com/paperless-ngx/paperless-ngx/pull/13633))
|
||||||
|
- Enhancement: websocket heartbeat [@oktupol](https://github.com/oktupol) ([#13739](https://github.com/paperless-ngx/paperless-ngx/pull/13739))
|
||||||
|
- Enhancement: more v3 ui tweaks [@shamoon](https://github.com/shamoon) ([#13774](https://github.com/paperless-ngx/paperless-ngx/pull/13774))
|
||||||
|
- Tweak: better support long list of views in documents list [@shamoon](https://github.com/shamoon) ([#13769](https://github.com/paperless-ngx/paperless-ngx/pull/13769))
|
||||||
|
- QoL: add count badge to versions dropdown [@shamoon](https://github.com/shamoon) ([#13753](https://github.com/paperless-ngx/paperless-ngx/pull/13753))
|
||||||
|
- Tweakhancement: add jitter to IMAP polling schedule [@shamoon](https://github.com/shamoon) ([#13734](https://github.com/paperless-ngx/paperless-ngx/pull/13734))
|
||||||
|
- Enhancement: sync OIDC groups to superuser and staff roles [@BeSovereign](https://github.com/BeSovereign) ([#13060](https://github.com/paperless-ngx/paperless-ngx/pull/13060))
|
||||||
|
- Enhancement: merge documents as versions [@shamoon](https://github.com/shamoon) ([#13515](https://github.com/paperless-ngx/paperless-ngx/pull/13515))
|
||||||
|
- Tweak: adjust modal proportions for small screens [@shamoon](https://github.com/shamoon) ([#13728](https://github.com/paperless-ngx/paperless-ngx/pull/13728))
|
||||||
|
- Refactor: render paperless\_ai prompts via Jinja2 templates instead of f-strings [@stumpylog](https://github.com/stumpylog) ([#13698](https://github.com/paperless-ngx/paperless-ngx/pull/13698))
|
||||||
|
- Tweak: small visual tweaks / improvements \& fixes [@shamoon](https://github.com/shamoon) ([#13700](https://github.com/paperless-ngx/paperless-ngx/pull/13700))
|
||||||
|
- Enhancement: prefer existing tags, types, correspondents, and storage paths in AI suggestions [@stumpylog](https://github.com/stumpylog) ([#13676](https://github.com/paperless-ngx/paperless-ngx/pull/13676))
|
||||||
|
- Tweak: tweak permissions menu labels for shared user-dependent views [@shamoon](https://github.com/shamoon) ([#13685](https://github.com/paperless-ngx/paperless-ngx/pull/13685))
|
||||||
|
- Feature: Allow selection of compression type and and level during export [@stumpylog](https://github.com/stumpylog) ([#13661](https://github.com/paperless-ngx/paperless-ngx/pull/13661))
|
||||||
|
- Enhancement: customizable icons for saved views [@shamoon](https://github.com/shamoon) ([#13388](https://github.com/paperless-ngx/paperless-ngx/pull/13388))
|
||||||
|
- Enhancement: Add --url argument to document\_fuzzy\_match to improve output [@lukyjay](https://github.com/lukyjay) ([#13123](https://github.com/paperless-ngx/paperless-ngx/pull/13123))
|
||||||
|
- Feature: Updates remote OCR parser to respect the OCR mode setting [@stumpylog](https://github.com/stumpylog) ([#13408](https://github.com/paperless-ngx/paperless-ngx/pull/13408))
|
||||||
|
- Tweak: improve no ML suggestions UX [@shamoon](https://github.com/shamoon) ([#13621](https://github.com/paperless-ngx/paperless-ngx/pull/13621))
|
||||||
|
- Performance: reduce memory and I/O overhead of the document exporter during zip exports [@stumpylog](https://github.com/stumpylog) ([#13490](https://github.com/paperless-ngx/paperless-ngx/pull/13490))
|
||||||
|
- QoL: make name button text on attribute pages selectable [@shamoon](https://github.com/shamoon) ([#13592](https://github.com/paperless-ngx/paperless-ngx/pull/13592))
|
||||||
|
- Performance: More efficient mail fetching [@stumpylog](https://github.com/stumpylog) ([#13432](https://github.com/paperless-ngx/paperless-ngx/pull/13432))
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- Fix: prevent config autocomplete craziness [@shamoon](https://github.com/shamoon) ([#13808](https://github.com/paperless-ngx/paperless-ngx/pull/13808))
|
||||||
|
- Fix/performance: prevent token reuse in dropdown filtering, also a perf thing [@shamoon](https://github.com/shamoon) ([#13804](https://github.com/paperless-ngx/paperless-ngx/pull/13804))
|
||||||
|
- Fix: exclude version documents from bulk edit "all" [@shamoon](https://github.com/shamoon) ([#13791](https://github.com/paperless-ngx/paperless-ngx/pull/13791))
|
||||||
|
- Fix: fix bottom mobile nav buttons on Android [@shamoon](https://github.com/shamoon) ([#13780](https://github.com/paperless-ngx/paperless-ngx/pull/13780))
|
||||||
|
- Fix: lazy import guardian modules to fix search language setting [@shamoon](https://github.com/shamoon) ([#13768](https://github.com/paperless-ngx/paperless-ngx/pull/13768))
|
||||||
|
- Fix: version indexing fixes [@shamoon](https://github.com/shamoon) ([#13737](https://github.com/paperless-ngx/paperless-ngx/pull/13737))
|
||||||
|
- Fix: append charset to file response for text files [@shamoon](https://github.com/shamoon) ([#13759](https://github.com/paperless-ngx/paperless-ngx/pull/13759))
|
||||||
|
- Chore: pin Apache Tika images to 3.3.1 [@shamoon](https://github.com/shamoon) ([#13758](https://github.com/paperless-ngx/paperless-ngx/pull/13758))
|
||||||
|
- Fix: align bulk edit object perms with document model [@shamoon](https://github.com/shamoon) ([#13757](https://github.com/paperless-ngx/paperless-ngx/pull/13757))
|
||||||
|
- Fix: use selected version for doc detail emailing [@shamoon](https://github.com/shamoon) ([#13738](https://github.com/paperless-ngx/paperless-ngx/pull/13738))
|
||||||
|
- Fix: hide version delete button without global perms [@shamoon](https://github.com/shamoon) ([#13735](https://github.com/paperless-ngx/paperless-ngx/pull/13735))
|
||||||
|
- Fix: dont re-render path template when checking collisions [@shamoon](https://github.com/shamoon) ([#13718](https://github.com/paperless-ngx/paperless-ngx/pull/13718))
|
||||||
|
- Fix: DocumentClassifierSchema bounds [@shamoon](https://github.com/shamoon) ([#13707](https://github.com/paperless-ngx/paperless-ngx/pull/13707))
|
||||||
|
- Fix: remove shadow around attribute pages [@shamoon](https://github.com/shamoon) ([#13696](https://github.com/paperless-ngx/paperless-ngx/pull/13696))
|
||||||
|
- Zen: correct dropdown corner radius visual defect [@shamoon](https://github.com/shamoon) ([#13695](https://github.com/paperless-ngx/paperless-ngx/pull/13695))
|
||||||
|
- Fix: handle Android keyboard popper overlay [@shamoon](https://github.com/shamoon) ([#13694](https://github.com/paperless-ngx/paperless-ngx/pull/13694))
|
||||||
|
- Fix: only show create when there is text, hide set values if no fields in cf bulk edit dropdown [@shamoon](https://github.com/shamoon) ([#13688](https://github.com/paperless-ngx/paperless-ngx/pull/13688))
|
||||||
|
- Fix: reopen a fresh Tantivy index per write to prevent orphaned segment files [@stumpylog](https://github.com/stumpylog) ([#13682](https://github.com/paperless-ngx/paperless-ngx/pull/13682))
|
||||||
|
- Fix: fix validation of workflow title assignment [@maxtruxa](https://github.com/maxtruxa) ([#13659](https://github.com/paperless-ngx/paperless-ngx/pull/13659))
|
||||||
|
- Fix: dont clip search dropdown on mobile [@shamoon](https://github.com/shamoon) ([#13675](https://github.com/paperless-ngx/paperless-ngx/pull/13675))
|
||||||
|
- Fix: include sharelink bundle perms in WebUI [@shamoon](https://github.com/shamoon) ([#13664](https://github.com/paperless-ngx/paperless-ngx/pull/13664))
|
||||||
|
- Fix: add pagination to saved views management page [@shamoon](https://github.com/shamoon) ([#13646](https://github.com/paperless-ngx/paperless-ngx/pull/13646))
|
||||||
|
- Fix: fixes for workflow assign custom field values [@shamoon](https://github.com/shamoon) ([#13630](https://github.com/paperless-ngx/paperless-ngx/pull/13630))
|
||||||
|
- Fix: deny deactivated users in permission filtering and auto-login [@stumpylog](https://github.com/stumpylog) ([#13623](https://github.com/paperless-ngx/paperless-ngx/pull/13623))
|
||||||
|
- Fix: check bulk mail delete permissions for the whole batch up front [@stumpylog](https://github.com/stumpylog) ([#13620](https://github.com/paperless-ngx/paperless-ngx/pull/13620))
|
||||||
|
- Fix: Allow DRF to validate the maximum API key length [@stumpylog](https://github.com/stumpylog) ([#13614](https://github.com/paperless-ngx/paperless-ngx/pull/13614))
|
||||||
|
- Fix: render PDF form values in annotation layer [@shamoon](https://github.com/shamoon) ([#13607](https://github.com/paperless-ngx/paperless-ngx/pull/13607))
|
||||||
|
- QoL: disable name button without perms [@shamoon](https://github.com/shamoon) ([#13606](https://github.com/paperless-ngx/paperless-ngx/pull/13606))
|
||||||
|
- Fix: prevent debounce overwrites in advanced search field, also improve Esc behavior [@shamoon](https://github.com/shamoon) ([#13602](https://github.com/paperless-ngx/paperless-ngx/pull/13602))
|
||||||
|
- Fix: raise ParseError on remote OCR failure instead of silently continuing [@stumpylog](https://github.com/stumpylog) ([#13574](https://github.com/paperless-ngx/paperless-ngx/pull/13574))
|
||||||
|
- Fix: use selected version when creating share links [@shamoon](https://github.com/shamoon) ([#13571](https://github.com/paperless-ngx/paperless-ngx/pull/13571))
|
||||||
|
- Fix: reject bulk edit permissions requests without the correct key [@shamoon](https://github.com/shamoon) ([#13563](https://github.com/paperless-ngx/paperless-ngx/pull/13563))
|
||||||
|
- Fix: correctly serve app logo specified in env [@shamoon](https://github.com/shamoon) ([#13561](https://github.com/paperless-ngx/paperless-ngx/pull/13561))
|
||||||
|
- Fix: prevent workflow passwords field type error [@shamoon](https://github.com/shamoon) ([#13552](https://github.com/paperless-ngx/paperless-ngx/pull/13552))
|
||||||
|
- Fix: correct Firefox print regression [@shamoon](https://github.com/shamoon) ([#13543](https://github.com/paperless-ngx/paperless-ngx/pull/13543))
|
||||||
|
- Fix: hide some saved view operations on management page without permissions [@shamoon](https://github.com/shamoon) ([#13542](https://github.com/paperless-ngx/paperless-ngx/pull/13542))
|
||||||
|
- Fix: disable pdfjs selection rendering [@shamoon](https://github.com/shamoon) ([#13538](https://github.com/paperless-ngx/paperless-ngx/pull/13538))
|
||||||
|
- Fix: hide sidebar drag grips with insufficient permissions [@shamoon](https://github.com/shamoon) ([#13536](https://github.com/paperless-ngx/paperless-ngx/pull/13536))
|
||||||
|
- Fix: don't re-queue a consume-folder file that is already queued and awaiting consumption [@stumpylog](https://github.com/stumpylog) ([#13526](https://github.com/paperless-ngx/paperless-ngx/pull/13526))
|
||||||
|
- Fix: correct multi-search non-adjacent queries [@shamoon](https://github.com/shamoon) ([#13504](https://github.com/paperless-ngx/paperless-ngx/pull/13504))
|
||||||
|
- Fix: Content-Disposition filename normalization [@shamoon](https://github.com/shamoon) ([#13514](https://github.com/paperless-ngx/paperless-ngx/pull/13514))
|
||||||
|
- Fix: prevent duplicated text query with multiple date queries [@shamoon](https://github.com/shamoon) ([#13522](https://github.com/paperless-ngx/paperless-ngx/pull/13522))
|
||||||
|
- Fix: crash filtering document link custom fields with an unset or unrelated field present [@ggouzi](https://github.com/ggouzi) ([#13518](https://github.com/paperless-ngx/paperless-ngx/pull/13518))
|
||||||
|
- Fix: parse unpadded yyyy-mm-dd date input regardless of locale [@Se1foo](https://github.com/Se1foo) ([#13501](https://github.com/paperless-ngx/paperless-ngx/pull/13501))
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
- Documentation: clarify default OCR mode changes in v3 [@shamoon](https://github.com/shamoon) ([#13666](https://github.com/paperless-ngx/paperless-ngx/pull/13666))
|
||||||
|
- Fix: fixes for workflow assign custom field values [@shamoon](https://github.com/shamoon) ([#13630](https://github.com/paperless-ngx/paperless-ngx/pull/13630))
|
||||||
|
- Documentation: add wiki links for AI stuff and parser plugins [@shamoon](https://github.com/shamoon) ([#13626](https://github.com/paperless-ngx/paperless-ngx/pull/13626))
|
||||||
|
|
||||||
|
### Maintenance
|
||||||
|
|
||||||
|
- Chore(deps): Bump the actions group across 1 directory with 20 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#13481](https://github.com/paperless-ngx/paperless-ngx/pull/13481))
|
||||||
|
|
||||||
|
### Dependencies
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>23 changes</summary>
|
||||||
|
|
||||||
|
- Chore: Upgrade Docker image to Python 3.14 [@stumpylog](https://github.com/stumpylog) ([#13721](https://github.com/paperless-ngx/paperless-ngx/pull/13721))
|
||||||
|
- Chore(deps): Bump the uv group across 1 directory with 2 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#13709](https://github.com/paperless-ngx/paperless-ngx/pull/13709))
|
||||||
|
- Chore: update, reorg some npm deps [@shamoon](https://github.com/shamoon) ([#13716](https://github.com/paperless-ngx/paperless-ngx/pull/13716))
|
||||||
|
- Chore: update fpdf2 to 2.8.8 [@shamoon](https://github.com/shamoon) ([#13629](https://github.com/paperless-ngx/paperless-ngx/pull/13629))
|
||||||
|
- Chore: update pnpm, add blockExoticSubdeps [@shamoon](https://github.com/shamoon) ([#13628](https://github.com/paperless-ngx/paperless-ngx/pull/13628))
|
||||||
|
- Chore(deps): Bump h2 from 4.3.0 to 4.4.1 in the uv group across 1 directory @[dependabot[bot]](https://github.com/apps/dependabot) ([#13593](https://github.com/paperless-ngx/paperless-ngx/pull/13593))
|
||||||
|
- Chore(deps): Bump pdfjs-dist from 6.1.200 to 6.2.108 in /src-ui in the npm\_and\_yarn group across 1 directory @[dependabot[bot]](https://github.com/apps/dependabot) ([#13594](https://github.com/paperless-ngx/paperless-ngx/pull/13594))
|
||||||
|
- Chore(deps): Bump cryptography from 48.0.1 to 50.0.0 in the uv group across 1 directory @[dependabot[bot]](https://github.com/apps/dependabot) ([#13588](https://github.com/paperless-ngx/paperless-ngx/pull/13588))
|
||||||
|
- Chore(deps): Bump the utilities-patch group across 1 directory with 6 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#13539](https://github.com/paperless-ngx/paperless-ngx/pull/13539))
|
||||||
|
- Chore(deps): Bump the utilities-minor group across 1 directory with 20 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#13535](https://github.com/paperless-ngx/paperless-ngx/pull/13535))
|
||||||
|
- Chore(deps): Bump aiohttp from 3.14.1 to 3.14.3 in the uv group across 1 directory @[dependabot[bot]](https://github.com/apps/dependabot) ([#13537](https://github.com/paperless-ngx/paperless-ngx/pull/13537))
|
||||||
|
- Chore(deps-dev): Bump zensical from 0.0.47 to 0.0.51 in the development group across 1 directory @[dependabot[bot]](https://github.com/apps/dependabot) ([#13533](https://github.com/paperless-ngx/paperless-ngx/pull/13533))
|
||||||
|
- Chore(deps-dev): Bump postcss from 8.5.22 to 8.5.25 in /src/paperless\_mail/templates in the npm\_and\_yarn group across 1 directory @[dependabot[bot]](https://github.com/apps/dependabot) ([#13534](https://github.com/paperless-ngx/paperless-ngx/pull/13534))
|
||||||
|
- Chore(deps): Bump the pre-commit-dependencies group across 1 directory with 4 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#13532](https://github.com/paperless-ngx/paperless-ngx/pull/13532))
|
||||||
|
- Chore: ruff 0.16 upgrade [@stumpylog](https://github.com/stumpylog) ([#13531](https://github.com/paperless-ngx/paperless-ngx/pull/13531))
|
||||||
|
- Chore(deps): Bump the actions group across 1 directory with 20 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#13481](https://github.com/paperless-ngx/paperless-ngx/pull/13481))
|
||||||
|
- docker(deps): bump astral-sh/uv from 0.11.28-python3.12-trixie-slim to 0.11.32-python3.12-trixie-slim @[dependabot[bot]](https://github.com/apps/dependabot) ([#13472](https://github.com/paperless-ngx/paperless-ngx/pull/13472))
|
||||||
|
- docker-compose(deps): bump nginx from 1.31.2-alpine to 1.31.3-alpine in /docker/compose @[dependabot[bot]](https://github.com/apps/dependabot) ([#13470](https://github.com/paperless-ngx/paperless-ngx/pull/13470))
|
||||||
|
- docker-compose(deps): Bump greenmail/standalone from 2.1.9 to 2.1.11 in /docker/compose @[dependabot[bot]](https://github.com/apps/dependabot) ([#13469](https://github.com/paperless-ngx/paperless-ngx/pull/13469))
|
||||||
|
- Chore(deps): Bump the frontend-angular-dependencies group across 1 directory with 18 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#13476](https://github.com/paperless-ngx/paperless-ngx/pull/13476))
|
||||||
|
- Chore(deps-dev): Bump @playwright/test from 1.61.1 to 1.62.0 in /src-ui @[dependabot[bot]](https://github.com/apps/dependabot) ([#13480](https://github.com/paperless-ngx/paperless-ngx/pull/13480))
|
||||||
|
- Chore(deps-dev): Bump the frontend-eslint-dependencies group across 1 directory with 4 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#13477](https://github.com/paperless-ngx/paperless-ngx/pull/13477))
|
||||||
|
- Chore(deps-dev): Bump @types/node from 26.1.0 to 26.1.1 in /src-ui @[dependabot[bot]](https://github.com/apps/dependabot) ([#13479](https://github.com/paperless-ngx/paperless-ngx/pull/13479))
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
### All App Changes
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>91 changes</summary>
|
||||||
|
|
||||||
|
- Fix: prevent config autocomplete craziness [@shamoon](https://github.com/shamoon) ([#13808](https://github.com/paperless-ngx/paperless-ngx/pull/13808))
|
||||||
|
- Fix/performance: prevent token reuse in dropdown filtering, also a perf thing [@shamoon](https://github.com/shamoon) ([#13804](https://github.com/paperless-ngx/paperless-ngx/pull/13804))
|
||||||
|
- Enhancement: Apply AI suggestions workflow action [@shamoon](https://github.com/shamoon) ([#13639](https://github.com/paperless-ngx/paperless-ngx/pull/13639))
|
||||||
|
- Fix: exclude version documents from bulk edit "all" [@shamoon](https://github.com/shamoon) ([#13791](https://github.com/paperless-ngx/paperless-ngx/pull/13791))
|
||||||
|
- Enhancement: welcome widget visual tweaks [@shamoon](https://github.com/shamoon) ([#13794](https://github.com/paperless-ngx/paperless-ngx/pull/13794))
|
||||||
|
- Performance: fetch note authors with prefetch instead of one query each [@shamoon](https://github.com/shamoon) ([#13790](https://github.com/paperless-ngx/paperless-ngx/pull/13790))
|
||||||
|
- Tweak: more misc UI tweaks [@shamoon](https://github.com/shamoon) ([#13783](https://github.com/paperless-ngx/paperless-ngx/pull/13783))
|
||||||
|
- Enhancement: support using remote OCR engines selectively [@shamoon](https://github.com/shamoon) ([#13633](https://github.com/paperless-ngx/paperless-ngx/pull/13633))
|
||||||
|
- Fix: fix bottom mobile nav buttons on Android [@shamoon](https://github.com/shamoon) ([#13780](https://github.com/paperless-ngx/paperless-ngx/pull/13780))
|
||||||
|
- Enhancement: websocket heartbeat [@oktupol](https://github.com/oktupol) ([#13739](https://github.com/paperless-ngx/paperless-ngx/pull/13739))
|
||||||
|
- Fix: lazy import guardian modules to fix search language setting [@shamoon](https://github.com/shamoon) ([#13768](https://github.com/paperless-ngx/paperless-ngx/pull/13768))
|
||||||
|
- Enhancement: more v3 ui tweaks [@shamoon](https://github.com/shamoon) ([#13774](https://github.com/paperless-ngx/paperless-ngx/pull/13774))
|
||||||
|
- Chore: add some missing UI accessibility labels [@shamoon](https://github.com/shamoon) ([#13772](https://github.com/paperless-ngx/paperless-ngx/pull/13772))
|
||||||
|
- Chore: refactor permission checkbox live changes [@shamoon](https://github.com/shamoon) ([#13771](https://github.com/paperless-ngx/paperless-ngx/pull/13771))
|
||||||
|
- Tweak: better support long list of views in documents list [@shamoon](https://github.com/shamoon) ([#13769](https://github.com/paperless-ngx/paperless-ngx/pull/13769))
|
||||||
|
- Fix: version indexing fixes [@shamoon](https://github.com/shamoon) ([#13737](https://github.com/paperless-ngx/paperless-ngx/pull/13737))
|
||||||
|
- Fix: append charset to file response for text files [@shamoon](https://github.com/shamoon) ([#13759](https://github.com/paperless-ngx/paperless-ngx/pull/13759))
|
||||||
|
- Fix: align bulk edit object perms with document model [@shamoon](https://github.com/shamoon) ([#13757](https://github.com/paperless-ngx/paperless-ngx/pull/13757))
|
||||||
|
- QoL: add count badge to versions dropdown [@shamoon](https://github.com/shamoon) ([#13753](https://github.com/paperless-ngx/paperless-ngx/pull/13753))
|
||||||
|
- Tweakhancement: add jitter to IMAP polling schedule [@shamoon](https://github.com/shamoon) ([#13734](https://github.com/paperless-ngx/paperless-ngx/pull/13734))
|
||||||
|
- Fix: use selected version for doc detail emailing [@shamoon](https://github.com/shamoon) ([#13738](https://github.com/paperless-ngx/paperless-ngx/pull/13738))
|
||||||
|
- Fix: hide version delete button without global perms [@shamoon](https://github.com/shamoon) ([#13735](https://github.com/paperless-ngx/paperless-ngx/pull/13735))
|
||||||
|
- Enhancement: sync OIDC groups to superuser and staff roles [@BeSovereign](https://github.com/BeSovereign) ([#13060](https://github.com/paperless-ngx/paperless-ngx/pull/13060))
|
||||||
|
- Enhancement: merge documents as versions [@shamoon](https://github.com/shamoon) ([#13515](https://github.com/paperless-ngx/paperless-ngx/pull/13515))
|
||||||
|
- Tweak: adjust modal proportions for small screens [@shamoon](https://github.com/shamoon) ([#13728](https://github.com/paperless-ngx/paperless-ngx/pull/13728))
|
||||||
|
- Refactor: render paperless\_ai prompts via Jinja2 templates instead of f-strings [@stumpylog](https://github.com/stumpylog) ([#13698](https://github.com/paperless-ngx/paperless-ngx/pull/13698))
|
||||||
|
- Chore(deps): Bump the uv group across 1 directory with 2 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#13709](https://github.com/paperless-ngx/paperless-ngx/pull/13709))
|
||||||
|
- Fix: dont re-render path template when checking collisions [@shamoon](https://github.com/shamoon) ([#13718](https://github.com/paperless-ngx/paperless-ngx/pull/13718))
|
||||||
|
- Chore: update, reorg some npm deps [@shamoon](https://github.com/shamoon) ([#13716](https://github.com/paperless-ngx/paperless-ngx/pull/13716))
|
||||||
|
- Fix: DocumentClassifierSchema bounds [@shamoon](https://github.com/shamoon) ([#13707](https://github.com/paperless-ngx/paperless-ngx/pull/13707))
|
||||||
|
- Tweak: small visual tweaks / improvements \& fixes [@shamoon](https://github.com/shamoon) ([#13700](https://github.com/paperless-ngx/paperless-ngx/pull/13700))
|
||||||
|
- Fix: remove shadow around attribute pages [@shamoon](https://github.com/shamoon) ([#13696](https://github.com/paperless-ngx/paperless-ngx/pull/13696))
|
||||||
|
- Zen: correct dropdown corner radius visual defect [@shamoon](https://github.com/shamoon) ([#13695](https://github.com/paperless-ngx/paperless-ngx/pull/13695))
|
||||||
|
- Fix: handle Android keyboard popper overlay [@shamoon](https://github.com/shamoon) ([#13694](https://github.com/paperless-ngx/paperless-ngx/pull/13694))
|
||||||
|
- Enhancement: prefer existing tags, types, correspondents, and storage paths in AI suggestions [@stumpylog](https://github.com/stumpylog) ([#13676](https://github.com/paperless-ngx/paperless-ngx/pull/13676))
|
||||||
|
- Fix: only show create when there is text, hide set values if no fields in cf bulk edit dropdown [@shamoon](https://github.com/shamoon) ([#13688](https://github.com/paperless-ngx/paperless-ngx/pull/13688))
|
||||||
|
- Fix: reopen a fresh Tantivy index per write to prevent orphaned segment files [@stumpylog](https://github.com/stumpylog) ([#13682](https://github.com/paperless-ngx/paperless-ngx/pull/13682))
|
||||||
|
- Tweak: tweak permissions menu labels for shared user-dependent views [@shamoon](https://github.com/shamoon) ([#13685](https://github.com/paperless-ngx/paperless-ngx/pull/13685))
|
||||||
|
- Fix: fix validation of workflow title assignment [@maxtruxa](https://github.com/maxtruxa) ([#13659](https://github.com/paperless-ngx/paperless-ngx/pull/13659))
|
||||||
|
- Feature: Allow selection of compression type and and level during export [@stumpylog](https://github.com/stumpylog) ([#13661](https://github.com/paperless-ngx/paperless-ngx/pull/13661))
|
||||||
|
- Fix: dont clip search dropdown on mobile [@shamoon](https://github.com/shamoon) ([#13675](https://github.com/paperless-ngx/paperless-ngx/pull/13675))
|
||||||
|
- Fix: include sharelink bundle perms in WebUI [@shamoon](https://github.com/shamoon) ([#13664](https://github.com/paperless-ngx/paperless-ngx/pull/13664))
|
||||||
|
- Enhancement: customizable icons for saved views [@shamoon](https://github.com/shamoon) ([#13388](https://github.com/paperless-ngx/paperless-ngx/pull/13388))
|
||||||
|
- Enhancement: Add --url argument to document\_fuzzy\_match to improve output [@lukyjay](https://github.com/lukyjay) ([#13123](https://github.com/paperless-ngx/paperless-ngx/pull/13123))
|
||||||
|
- Feature: Updates remote OCR parser to respect the OCR mode setting [@stumpylog](https://github.com/stumpylog) ([#13408](https://github.com/paperless-ngx/paperless-ngx/pull/13408))
|
||||||
|
- Performance: pass document chat queries as a QuerySet instead of a materialized.list [@stumpylog](https://github.com/stumpylog) ([#13638](https://github.com/paperless-ngx/paperless-ngx/pull/13638))
|
||||||
|
- Fix: add pagination to saved views management page [@shamoon](https://github.com/shamoon) ([#13646](https://github.com/paperless-ngx/paperless-ngx/pull/13646))
|
||||||
|
- Fix: fixes for workflow assign custom field values [@shamoon](https://github.com/shamoon) ([#13630](https://github.com/paperless-ngx/paperless-ngx/pull/13630))
|
||||||
|
- Fix: deny deactivated users in permission filtering and auto-login [@stumpylog](https://github.com/stumpylog) ([#13623](https://github.com/paperless-ngx/paperless-ngx/pull/13623))
|
||||||
|
- Chore: update fpdf2 to 2.8.8 [@shamoon](https://github.com/shamoon) ([#13629](https://github.com/paperless-ngx/paperless-ngx/pull/13629))
|
||||||
|
- Chore: update pnpm, add blockExoticSubdeps [@shamoon](https://github.com/shamoon) ([#13628](https://github.com/paperless-ngx/paperless-ngx/pull/13628))
|
||||||
|
- Fix: check bulk mail delete permissions for the whole batch up front [@stumpylog](https://github.com/stumpylog) ([#13620](https://github.com/paperless-ngx/paperless-ngx/pull/13620))
|
||||||
|
- Tweak: improve no ML suggestions UX [@shamoon](https://github.com/shamoon) ([#13621](https://github.com/paperless-ngx/paperless-ngx/pull/13621))
|
||||||
|
- Fix: Allow DRF to validate the maximum API key length [@stumpylog](https://github.com/stumpylog) ([#13614](https://github.com/paperless-ngx/paperless-ngx/pull/13614))
|
||||||
|
- Performance: unify permission-filtering backends, fixes Correspondent/Tag list slowness [@stumpylog](https://github.com/stumpylog) ([#13601](https://github.com/paperless-ngx/paperless-ngx/pull/13601))
|
||||||
|
- Performance: generalize permitted\_document\_ids into permitted\_object\_ids for any model [@stumpylog](https://github.com/stumpylog) ([#13578](https://github.com/paperless-ngx/paperless-ngx/pull/13578))
|
||||||
|
- Fix: render PDF form values in annotation layer [@shamoon](https://github.com/shamoon) ([#13607](https://github.com/paperless-ngx/paperless-ngx/pull/13607))
|
||||||
|
- QoL: disable name button without perms [@shamoon](https://github.com/shamoon) ([#13606](https://github.com/paperless-ngx/paperless-ngx/pull/13606))
|
||||||
|
- Fix: prevent debounce overwrites in advanced search field, also improve Esc behavior [@shamoon](https://github.com/shamoon) ([#13602](https://github.com/paperless-ngx/paperless-ngx/pull/13602))
|
||||||
|
- Performance: reduce memory and I/O overhead of the document exporter during zip exports [@stumpylog](https://github.com/stumpylog) ([#13490](https://github.com/paperless-ngx/paperless-ngx/pull/13490))
|
||||||
|
- Chore(deps): Bump h2 from 4.3.0 to 4.4.1 in the uv group across 1 directory @[dependabot[bot]](https://github.com/apps/dependabot) ([#13593](https://github.com/paperless-ngx/paperless-ngx/pull/13593))
|
||||||
|
- Chore(deps): Bump pdfjs-dist from 6.1.200 to 6.2.108 in /src-ui in the npm\_and\_yarn group across 1 directory @[dependabot[bot]](https://github.com/apps/dependabot) ([#13594](https://github.com/paperless-ngx/paperless-ngx/pull/13594))
|
||||||
|
- QoL: make name button text on attribute pages selectable [@shamoon](https://github.com/shamoon) ([#13592](https://github.com/paperless-ngx/paperless-ngx/pull/13592))
|
||||||
|
- Fix: raise ParseError on remote OCR failure instead of silently continuing [@stumpylog](https://github.com/stumpylog) ([#13574](https://github.com/paperless-ngx/paperless-ngx/pull/13574))
|
||||||
|
- Fix: use selected version when creating share links [@shamoon](https://github.com/shamoon) ([#13571](https://github.com/paperless-ngx/paperless-ngx/pull/13571))
|
||||||
|
- Chore: specify AI chat refine template [@shamoon](https://github.com/shamoon) ([#13564](https://github.com/paperless-ngx/paperless-ngx/pull/13564))
|
||||||
|
- Fix: reject bulk edit permissions requests without the correct key [@shamoon](https://github.com/shamoon) ([#13563](https://github.com/paperless-ngx/paperless-ngx/pull/13563))
|
||||||
|
- Fix: correctly serve app logo specified in env [@shamoon](https://github.com/shamoon) ([#13561](https://github.com/paperless-ngx/paperless-ngx/pull/13561))
|
||||||
|
- Fix: prevent workflow passwords field type error [@shamoon](https://github.com/shamoon) ([#13552](https://github.com/paperless-ngx/paperless-ngx/pull/13552))
|
||||||
|
- Chore(deps): Bump the utilities-patch group across 1 directory with 6 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#13539](https://github.com/paperless-ngx/paperless-ngx/pull/13539))
|
||||||
|
- Chore(deps): Bump the utilities-minor group across 1 directory with 20 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#13535](https://github.com/paperless-ngx/paperless-ngx/pull/13535))
|
||||||
|
- Performance: More efficient mail fetching [@stumpylog](https://github.com/stumpylog) ([#13432](https://github.com/paperless-ngx/paperless-ngx/pull/13432))
|
||||||
|
- Performance: eliminate per-document guardian permission-check causing high CPU on document lists [@stumpylog](https://github.com/stumpylog) ([#13505](https://github.com/paperless-ngx/paperless-ngx/pull/13505))
|
||||||
|
- Fix: correct Firefox print regression [@shamoon](https://github.com/shamoon) ([#13543](https://github.com/paperless-ngx/paperless-ngx/pull/13543))
|
||||||
|
- Fix: hide some saved view operations on management page without permissions [@shamoon](https://github.com/shamoon) ([#13542](https://github.com/paperless-ngx/paperless-ngx/pull/13542))
|
||||||
|
- Chore(deps): Bump aiohttp from 3.14.1 to 3.14.3 in the uv group across 1 directory @[dependabot[bot]](https://github.com/apps/dependabot) ([#13537](https://github.com/paperless-ngx/paperless-ngx/pull/13537))
|
||||||
|
- Chore(deps-dev): Bump zensical from 0.0.47 to 0.0.51 in the development group across 1 directory @[dependabot[bot]](https://github.com/apps/dependabot) ([#13533](https://github.com/paperless-ngx/paperless-ngx/pull/13533))
|
||||||
|
- Fix: disable pdfjs selection rendering [@shamoon](https://github.com/shamoon) ([#13538](https://github.com/paperless-ngx/paperless-ngx/pull/13538))
|
||||||
|
- Fix: hide sidebar drag grips with insufficient permissions [@shamoon](https://github.com/shamoon) ([#13536](https://github.com/paperless-ngx/paperless-ngx/pull/13536))
|
||||||
|
- Chore(deps-dev): Bump postcss from 8.5.22 to 8.5.25 in /src/paperless\_mail/templates in the npm\_and\_yarn group across 1 directory @[dependabot[bot]](https://github.com/apps/dependabot) ([#13534](https://github.com/paperless-ngx/paperless-ngx/pull/13534))
|
||||||
|
- Chore: ruff 0.16 upgrade [@stumpylog](https://github.com/stumpylog) ([#13531](https://github.com/paperless-ngx/paperless-ngx/pull/13531))
|
||||||
|
- Fix: don't re-queue a consume-folder file that is already queued and awaiting consumption [@stumpylog](https://github.com/stumpylog) ([#13526](https://github.com/paperless-ngx/paperless-ngx/pull/13526))
|
||||||
|
- Fix: correct multi-search non-adjacent queries [@shamoon](https://github.com/shamoon) ([#13504](https://github.com/paperless-ngx/paperless-ngx/pull/13504))
|
||||||
|
- Fix: Content-Disposition filename normalization [@shamoon](https://github.com/shamoon) ([#13514](https://github.com/paperless-ngx/paperless-ngx/pull/13514))
|
||||||
|
- Fix: prevent duplicated text query with multiple date queries [@shamoon](https://github.com/shamoon) ([#13522](https://github.com/paperless-ngx/paperless-ngx/pull/13522))
|
||||||
|
- Fix: crash filtering document link custom fields with an unset or unrelated field present [@ggouzi](https://github.com/ggouzi) ([#13518](https://github.com/paperless-ngx/paperless-ngx/pull/13518))
|
||||||
|
- Fix: parse unpadded yyyy-mm-dd date input regardless of locale [@Se1foo](https://github.com/Se1foo) ([#13501](https://github.com/paperless-ngx/paperless-ngx/pull/13501))
|
||||||
|
- Chore(deps): Bump the frontend-angular-dependencies group across 1 directory with 18 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#13476](https://github.com/paperless-ngx/paperless-ngx/pull/13476))
|
||||||
|
- Chore(deps-dev): Bump @playwright/test from 1.61.1 to 1.62.0 in /src-ui @[dependabot[bot]](https://github.com/apps/dependabot) ([#13480](https://github.com/paperless-ngx/paperless-ngx/pull/13480))
|
||||||
|
- Chore(deps-dev): Bump the frontend-eslint-dependencies group across 1 directory with 4 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#13477](https://github.com/paperless-ngx/paperless-ngx/pull/13477))
|
||||||
|
- Chore(deps-dev): Bump @types/node from 26.1.0 to 26.1.1 in /src-ui @[dependabot[bot]](https://github.com/apps/dependabot) ([#13479](https://github.com/paperless-ngx/paperless-ngx/pull/13479))
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
## paperless-ngx 3.0.5
|
## paperless-ngx 3.0.5
|
||||||
|
|
||||||
### Bug Fixes
|
### Bug Fixes
|
||||||
|
|||||||
@@ -8,6 +8,12 @@ common [OCR](#ocr) related settings and some frontend settings. If set, these wi
|
|||||||
preference over the settings via environment variables. If not set, the environment setting
|
preference over the settings via environment variables. If not set, the environment setting
|
||||||
or applicable default will be utilized instead.
|
or applicable default will be utilized instead.
|
||||||
|
|
||||||
|
!!! warning
|
||||||
|
|
||||||
|
Changing configuration from the UI requires the `AppConfig` permission, which applies
|
||||||
|
instance-wide and should be treated as an admin-level permission. See
|
||||||
|
[global permissions](usage.md#global-permissions).
|
||||||
|
|
||||||
- If you run paperless on docker, `paperless.conf` is not used.
|
- If you run paperless on docker, `paperless.conf` is not used.
|
||||||
Rather, configure paperless by copying necessary options to
|
Rather, configure paperless by copying necessary options to
|
||||||
`docker-compose.env`.
|
`docker-compose.env`.
|
||||||
@@ -1125,6 +1131,10 @@ they use underscores instead of dashes.
|
|||||||
so specifying invalid options may prevent paperless from consuming
|
so specifying invalid options may prevent paperless from consuming
|
||||||
any documents. Use with caution!
|
any documents. Use with caution!
|
||||||
|
|
||||||
|
These arguments are passed directly to OCRmyPDF, so this setting should only
|
||||||
|
be changed by trusted users. This applies to the `AppConfig` permission as well,
|
||||||
|
which allows setting these arguments from the UI.
|
||||||
|
|
||||||
Specify arguments as a JSON dictionary. Keep note of lower case
|
Specify arguments as a JSON dictionary. Keep note of lower case
|
||||||
booleans and double quoted parameter names and strings. Examples:
|
booleans and double quoted parameter names and strings. Examples:
|
||||||
|
|
||||||
|
|||||||
@@ -224,13 +224,19 @@ respectively, can be run non-interactively with:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm ng test
|
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:
|
Playwright also includes a UI which can be run with:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm playwright test --ui
|
pnpm e2e:ui
|
||||||
```
|
```
|
||||||
|
|
||||||
### Building the frontend
|
### Building the frontend
|
||||||
|
|||||||
@@ -428,8 +428,8 @@ determine if a user can create, edit, delete or view _any_ documents, but indivi
|
|||||||
still have "object-level" permissions.
|
still have "object-level" permissions.
|
||||||
|
|
||||||
| Type | Details |
|
| Type | Details |
|
||||||
| ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
| ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||||
| AppConfig | _Change_ or higher permissions grants access to the "Application Configuration" area. |
|
| AppConfig | _Change_ or higher permissions grants access to the "Application Configuration" area.<br/>:warning: **This is a trusted, admin-level permission.** Application configuration applies instance-wide and some settings, such as OCR arguments, are passed directly to the underlying processing tools. |
|
||||||
| Correspondent | Add, edit, delete or view Correspondents. |
|
| Correspondent | Add, edit, delete or view Correspondents. |
|
||||||
| CustomField | Add, edit, delete or view Custom Fields. |
|
| CustomField | Add, edit, delete or view Custom Fields. |
|
||||||
| Document | Add, edit, delete or view Documents. |
|
| Document | Add, edit, delete or view Documents. |
|
||||||
@@ -667,6 +667,35 @@ The action takes no options, its presence is what enables remote OCR for a match
|
|||||||
If the remote engine is not configured, or does not support the document's file type, the document is
|
If the remote engine is not configured, or does not support the document's file type, the document is
|
||||||
processed locally instead and a warning is written to the log.
|
processed locally instead and a warning is written to the log.
|
||||||
|
|
||||||
|
##### Apply AI Suggestions {#workflow-action-apply-ai-suggestions}
|
||||||
|
|
||||||
|
"Apply AI Suggestions" actions ask the configured AI service for title and metadata suggestions,
|
||||||
|
the same as the AI suggestions shown on the document detail page, except applied automatically and in bulk.
|
||||||
|
It requires [AI features](configuration.md#ai) to be enabled. You can specify:
|
||||||
|
|
||||||
|
- Which suggestions to apply: title, tags, correspondent, document type, storage path and / or created
|
||||||
|
date. Suggestions for fields you did not select are discarded.
|
||||||
|
- Whether to create missing items. By default only tags, correspondents and document types that
|
||||||
|
already exist are assigned and any other suggestion is dropped. With this enabled, suggested items
|
||||||
|
that do not exist are created. Storage paths are never created.
|
||||||
|
- Whether to overwrite existing values. By default a field is only filled in if it is currently empty.
|
||||||
|
Note that documents almost always already have a title and created date, so if you select those you
|
||||||
|
will usually want to enable this too. Tags are an exception: suggested tags are always added and
|
||||||
|
never replace the document's existing tags.
|
||||||
|
|
||||||
|
The action works with every trigger **except Consumption Started**, because suggestions are made from
|
||||||
|
the document's text, which does not exist until after the document has been processed.
|
||||||
|
|
||||||
|
Because the query to the AI service is slow, the action is queued and runs in the background rather
|
||||||
|
than as part of the workflow run itself. The document is updated once the suggestions come back.
|
||||||
|
|
||||||
|
!!! warning
|
||||||
|
|
||||||
|
Every matching document results in a query to the AI service, which may incur costs and have privacy
|
||||||
|
implications. Queries can be slow, so a workflow matching a large number of documents can occupy the
|
||||||
|
task queue, and delay consumption of new documents, etc. Consider narrowing the trigger filters,
|
||||||
|
running in small batches and / or increasing workers.
|
||||||
|
|
||||||
#### Workflow placeholders
|
#### Workflow placeholders
|
||||||
|
|
||||||
Titles and webhook payloads can be generated by workflows using [Jinja templates](https://jinja.palletsprojects.com/en/3.1.x/templates/).
|
Titles and webhook payloads can be generated by workflows using [Jinja templates](https://jinja.palletsprojects.com/en/3.1.x/templates/).
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "paperless-ngx"
|
name = "paperless-ngx"
|
||||||
version = "3.0.5"
|
version = "3.1.2"
|
||||||
description = "A community-supported supercharged document management system: scan, index and archive all your physical documents"
|
description = "A community-supported supercharged document management system: scan, index and archive all your physical documents"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
|
|||||||
@@ -139,6 +139,17 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"e2e": {
|
||||||
|
"fileReplacements": [
|
||||||
|
{
|
||||||
|
"replace": "src/environments/environment.ts",
|
||||||
|
"with": "src/environments/environment.e2e.ts"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"localize": [
|
||||||
|
"en-US"
|
||||||
|
]
|
||||||
|
},
|
||||||
"en-US": {
|
"en-US": {
|
||||||
"localize": [
|
"localize": [
|
||||||
"en-US"
|
"en-US"
|
||||||
@@ -155,6 +166,9 @@
|
|||||||
"configurations": {
|
"configurations": {
|
||||||
"production": {
|
"production": {
|
||||||
"buildTarget": "paperless-ui:build: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 { 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 ({
|
test('should activate / deactivate save button when settings change', async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
await page.routeFromHAR(REQUESTS_HAR, { notFound: 'fallback' })
|
|
||||||
await page.goto('/settings')
|
await page.goto('/settings')
|
||||||
await expect(page.getByRole('button', { name: 'Save' })).toBeDisabled()
|
await expect(page.getByRole('button', { name: 'Save' })).toBeDisabled()
|
||||||
await page.getByLabel('Use system setting').click()
|
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 }) => {
|
test('should warn on unsaved changes', async ({ page }) => {
|
||||||
await page.routeFromHAR(REQUESTS_HAR, { notFound: 'fallback' })
|
|
||||||
await page.goto('/settings')
|
await page.goto('/settings')
|
||||||
await page.getByLabel('Use system setting').click()
|
await page.getByLabel('Use system setting').click()
|
||||||
await page.getByRole('link', { name: 'Dashboard' }).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 }) => {
|
test('should apply appearance changes when set', async ({ page }) => {
|
||||||
await page.routeFromHAR(REQUESTS_HAR, { notFound: 'fallback' })
|
|
||||||
await page.goto('/settings')
|
await page.goto('/settings')
|
||||||
await expect(page.locator('html')).toHaveAttribute('data-bs-theme', /auto/)
|
await expect(page.locator('html')).toHaveAttribute('data-bs-theme', /auto/)
|
||||||
await page.getByLabel('Use system setting').click()
|
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 { 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 }) => {
|
test('dashboard inbox link', async ({ page }) => {
|
||||||
await page.routeFromHAR(REQUESTS_HAR1, { notFound: 'fallback' })
|
|
||||||
await page.goto('/dashboard')
|
await page.goto('/dashboard')
|
||||||
await page.getByRole('link', { name: 'Documents in inbox' }).click()
|
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/)
|
await expect(page.locator('pngx-document-list')).toHaveText(/8 documents/)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('dashboard total documents link', async ({ page }) => {
|
test('dashboard total documents link', async ({ page }) => {
|
||||||
await page.routeFromHAR(REQUESTS_HAR2, { notFound: 'fallback' })
|
|
||||||
await page.goto('/dashboard')
|
await page.goto('/dashboard')
|
||||||
await page.getByRole('link').filter({ hasText: 'Total documents' }).click()
|
await page.getByRole('link').filter({ hasText: 'Total documents' }).click()
|
||||||
await expect(page).toHaveURL(/documents/)
|
await expect(page).toHaveURL(/documents/)
|
||||||
@@ -24,7 +16,6 @@ test('dashboard total documents link', async ({ page }) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test('dashboard saved view show all', async ({ page }) => {
|
test('dashboard saved view show all', async ({ page }) => {
|
||||||
await page.routeFromHAR(REQUESTS_HAR3, { notFound: 'fallback' })
|
|
||||||
await page.goto('/dashboard')
|
await page.goto('/dashboard')
|
||||||
await page
|
await page
|
||||||
.locator('pngx-widget-frame')
|
.locator('pngx-widget-frame')
|
||||||
@@ -32,12 +23,11 @@ test('dashboard saved view show all', async ({ page }) => {
|
|||||||
.getByRole('link', { name: 'Show all' })
|
.getByRole('link', { name: 'Show all' })
|
||||||
.first()
|
.first()
|
||||||
.click()
|
.click()
|
||||||
await expect(page).toHaveURL(/view\/7/)
|
await expect(page).toHaveURL(/view\/1/)
|
||||||
await expect(page.locator('pngx-document-list')).toHaveText(/8 documents/)
|
await expect(page.locator('pngx-document-list')).toHaveText(/8 documents/)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('dashboard saved view document links', async ({ page }) => {
|
test('dashboard saved view document links', async ({ page }) => {
|
||||||
await page.routeFromHAR(REQUESTS_HAR4, { notFound: 'fallback' })
|
|
||||||
await page.goto('/dashboard')
|
await page.goto('/dashboard')
|
||||||
await page
|
await page
|
||||||
.locator('pngx-widget-frame')
|
.locator('pngx-widget-frame')
|
||||||
@@ -46,11 +36,10 @@ test('dashboard saved view document links', async ({ page }) => {
|
|||||||
.getByRole('link', { name: /test/ })
|
.getByRole('link', { name: /test/ })
|
||||||
.first()
|
.first()
|
||||||
.click({ position: { x: 0, y: 0 } })
|
.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 }) => {
|
test('test slim sidebar', async ({ page }) => {
|
||||||
await page.routeFromHAR(REQUESTS_HAR1, { notFound: 'fallback' })
|
|
||||||
await page.goto('/dashboard')
|
await page.goto('/dashboard')
|
||||||
await page.locator('.sidebar-slim-toggler').click()
|
await page.locator('.sidebar-slim-toggler').click()
|
||||||
await expect(
|
await expect(
|
||||||
|
|||||||
@@ -1,14 +1,9 @@
|
|||||||
import { expect, test, type WebSocketRoute } from '@playwright/test'
|
import { expect, test } 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')
|
|
||||||
|
|
||||||
test('should activate / deactivate save button when changes are saved', async ({
|
test('should activate / deactivate save button when changes are saved', async ({
|
||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
await page.routeFromHAR(REQUESTS_HAR, { notFound: 'fallback' })
|
await page.goto('/documents/1/')
|
||||||
await page.goto('/documents/175/')
|
|
||||||
await page.waitForSelector('pngx-document-detail pngx-input-text:first-child')
|
await page.waitForSelector('pngx-document-detail pngx-input-text:first-child')
|
||||||
await expect(page.getByTitle('Storage path', { exact: true })).toHaveText(
|
await expect(page.getByTitle('Storage path', { exact: true })).toHaveText(
|
||||||
/\w+/
|
/\w+/
|
||||||
@@ -19,8 +14,7 @@ test('should activate / deactivate save button when changes are saved', async ({
|
|||||||
})
|
})
|
||||||
|
|
||||||
test('should warn on unsaved changes', async ({ page }) => {
|
test('should warn on unsaved changes', async ({ page }) => {
|
||||||
await page.routeFromHAR(REQUESTS_HAR, { notFound: 'fallback' })
|
await page.goto('/documents/1/')
|
||||||
await page.goto('/documents/175/')
|
|
||||||
await expect(page.getByTitle('Correspondent', { exact: true })).toHaveText(
|
await expect(page.getByTitle('Correspondent', { exact: true })).toHaveText(
|
||||||
/\w+/
|
/\w+/
|
||||||
)
|
)
|
||||||
@@ -38,28 +32,27 @@ test('should warn on unsaved changes', async ({ page }) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test('should support tab direct navigation', async ({ page }) => {
|
test('should support tab direct navigation', async ({ page }) => {
|
||||||
await page.routeFromHAR(REQUESTS_HAR, { notFound: 'fallback' })
|
await page.goto('/documents/1/details')
|
||||||
await page.goto('/documents/175/details')
|
|
||||||
await expect(page.getByRole('tab', { name: 'Details' })).toHaveAttribute(
|
await expect(page.getByRole('tab', { name: 'Details' })).toHaveAttribute(
|
||||||
'aria-selected',
|
'aria-selected',
|
||||||
'true'
|
'true'
|
||||||
)
|
)
|
||||||
await page.goto('/documents/175/content')
|
await page.goto('/documents/1/content')
|
||||||
await expect(page.getByRole('tab', { name: 'Content' })).toHaveAttribute(
|
await expect(page.getByRole('tab', { name: 'Content' })).toHaveAttribute(
|
||||||
'aria-selected',
|
'aria-selected',
|
||||||
'true'
|
'true'
|
||||||
)
|
)
|
||||||
await page.goto('/documents/175/metadata')
|
await page.goto('/documents/1/metadata')
|
||||||
await expect(page.getByRole('tab', { name: 'Metadata' })).toHaveAttribute(
|
await expect(page.getByRole('tab', { name: 'Metadata' })).toHaveAttribute(
|
||||||
'aria-selected',
|
'aria-selected',
|
||||||
'true'
|
'true'
|
||||||
)
|
)
|
||||||
await page.goto('/documents/175/notes')
|
await page.goto('/documents/1/notes')
|
||||||
await expect(page.getByRole('tab', { name: 'Notes' })).toHaveAttribute(
|
await expect(page.getByRole('tab', { name: 'Notes' })).toHaveAttribute(
|
||||||
'aria-selected',
|
'aria-selected',
|
||||||
'true'
|
'true'
|
||||||
)
|
)
|
||||||
await page.goto('/documents/175/permissions')
|
await page.goto('/documents/1/permissions')
|
||||||
await expect(page.getByRole('tab', { name: 'Permissions' })).toHaveAttribute(
|
await expect(page.getByRole('tab', { name: 'Permissions' })).toHaveAttribute(
|
||||||
'aria-selected',
|
'aria-selected',
|
||||||
'true'
|
'true'
|
||||||
@@ -67,8 +60,7 @@ test('should support tab direct navigation', async ({ page }) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test('should show a mobile preview', async ({ page }) => {
|
test('should show a mobile preview', async ({ page }) => {
|
||||||
await page.routeFromHAR(REQUESTS_HAR, { notFound: 'fallback' })
|
await page.goto('/documents/1/')
|
||||||
await page.goto('/documents/175/')
|
|
||||||
await page.setViewportSize({ width: 400, height: 1000 })
|
await page.setViewportSize({ width: 400, height: 1000 })
|
||||||
await expect(page.getByRole('tab', { name: 'Preview' })).toBeVisible()
|
await expect(page.getByRole('tab', { name: 'Preview' })).toBeVisible()
|
||||||
await page.getByRole('tab', { name: 'Preview' }).click()
|
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 }) => {
|
test('should show a list of notes', async ({ page }) => {
|
||||||
await page.routeFromHAR(REQUESTS_HAR, { notFound: 'fallback' })
|
await page.goto('/documents/1/notes')
|
||||||
await page.goto('/documents/175/notes')
|
|
||||||
await expect(page.locator('pngx-document-notes')).toBeVisible()
|
await expect(page.locator('pngx-document-notes')).toBeVisible()
|
||||||
await expect(
|
await expect(
|
||||||
await page.getByRole('button', {
|
await page.getByRole('button', {
|
||||||
@@ -88,32 +79,25 @@ test('should show a list of notes', async ({ page }) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test('should support quick filters', async ({ page }) => {
|
test('should support quick filters', async ({ page }) => {
|
||||||
await page.routeFromHAR(REQUESTS_HAR2, { notFound: 'fallback' })
|
await page.goto('/documents/1/details')
|
||||||
await page.goto('/documents/175/details')
|
|
||||||
await page
|
await page
|
||||||
.getByRole('button', { name: 'Filter documents with these Tags' })
|
.getByRole('button', { name: 'Filter documents with these Tags' })
|
||||||
.click()
|
.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 ({
|
test('should finish reloading the preview after a remote document update', async ({
|
||||||
page,
|
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
|
let previewRequestCount = 0
|
||||||
page.on('request', (request) => {
|
page.on('request', (request) => {
|
||||||
if (request.url().includes('/api/documents/175/preview/')) {
|
if (request.url().includes('/api/documents/1/preview/')) {
|
||||||
previewRequestCount++
|
previewRequestCount++
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
await page.goto('/documents/175/details')
|
await page.goto('/documents/1/details')
|
||||||
|
|
||||||
await page.locator('pngx-document-detail').waitFor()
|
await page.locator('pngx-document-detail').waitFor()
|
||||||
await expect(page.getByTitle('Storage path', { exact: true })).toHaveText(
|
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)
|
expect(previewWasLoaded).toBe(true)
|
||||||
const previewRequestsBeforeReload = previewRequestCount
|
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(
|
const documentReloaded = page.waitForResponse(
|
||||||
(response) =>
|
(response) =>
|
||||||
response.url().includes('/api/documents/175/?full_perms=true') &&
|
response.url().includes('/api/documents/1/?full_perms=true') &&
|
||||||
response.request().method() === 'GET'
|
response.request().method() === 'GET'
|
||||||
)
|
)
|
||||||
statusSocket.send(
|
await page.evaluate(() => {
|
||||||
JSON.stringify({
|
const detail = document.querySelector('pngx-document-detail')
|
||||||
type: 'document_updated',
|
const component = (window as any).ng.getComponent(detail)
|
||||||
data: {
|
component.handleIncomingDocumentUpdated({
|
||||||
document_id: 175,
|
document_id: 1,
|
||||||
modified: '2026-07-26T20:00:00Z',
|
modified: '2099-07-26T20:00:00Z',
|
||||||
},
|
})
|
||||||
})
|
})
|
||||||
)
|
|
||||||
await documentReloaded
|
await documentReloaded
|
||||||
|
|
||||||
await expect(
|
|
||||||
page.getByText('Document reloaded with latest changes.').first()
|
|
||||||
).toBeVisible()
|
|
||||||
await expect
|
await expect
|
||||||
.poll(() => previewRequestCount)
|
.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 { 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 }) => {
|
test('basic filtering', async ({ page }) => {
|
||||||
await page.routeFromHAR(REQUESTS_HAR1, { notFound: 'fallback' })
|
|
||||||
await page.goto('/documents')
|
await page.goto('/documents')
|
||||||
await page.getByRole('button', { name: 'Tags' }).click()
|
await page.getByRole('button', { name: 'Tags' }).click()
|
||||||
await page.getByRole('menuitem', { name: 'Inbox' }).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 expect(page.locator('pngx-document-list')).toHaveText(/8 documents/)
|
||||||
await page.getByRole('button', { name: 'Document type' }).click()
|
await page.getByRole('button', { name: 'Document type' }).click()
|
||||||
await page.getByRole('menuitem', { name: /^Invoice Test/ }).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('button', { name: 'Correspondent' }).click()
|
||||||
await page.getByRole('menuitem', { name: 'Test Correspondent 1' }).click()
|
await page.getByRole('menuitem', { name: 'Test Correspondent 1' }).click()
|
||||||
await page.getByRole('menuitem', { name: 'Correspondent 9' }).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 expect(page.locator('pngx-document-list')).toHaveText(/7 documents/)
|
||||||
await page
|
await page
|
||||||
.locator('pngx-filter-editor')
|
.locator('pngx-filter-editor')
|
||||||
.getByTitle('Correspondent')
|
.getByTitle('Correspondent')
|
||||||
.getByText('Exclude')
|
.getByText('Exclude')
|
||||||
.click()
|
.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/)
|
await expect(page.locator('pngx-document-list')).toHaveText(/54 documents/)
|
||||||
// clear button
|
// clear button
|
||||||
await page.getByRole('button', { name: '2 selected', exact: true }).click()
|
await page.getByRole('button', { name: '2 selected', exact: true }).click()
|
||||||
await expect(page.locator('pngx-document-list')).toHaveText(/61 documents/)
|
await expect(page.locator('pngx-document-list')).toHaveText(/61 documents/)
|
||||||
await page.getByRole('button', { name: 'Storage path' }).click()
|
await page.getByRole('button', { name: 'Storage path' }).click()
|
||||||
await page.getByRole('menuitem', { name: 'Testing 12' }).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 expect(page.locator('pngx-document-list')).toHaveText(/8 documents/)
|
||||||
await page.getByRole('button', { name: 'Reset filters' }).first().click()
|
await page.getByRole('button', { name: 'Reset filters' }).first().click()
|
||||||
await expect(page.locator('pngx-document-list')).toHaveText(/61 documents/)
|
await expect(page.locator('pngx-document-list')).toHaveText(/61 documents/)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('text filtering', async ({ page }) => {
|
test('text filtering', async ({ page }) => {
|
||||||
await page.routeFromHAR(REQUESTS_HAR2, { notFound: 'fallback' })
|
|
||||||
await page.goto('/documents')
|
await page.goto('/documents')
|
||||||
await page.getByRole('main').getByRole('combobox').click()
|
await page.getByRole('main').getByRole('combobox').click()
|
||||||
await page.getByRole('main').getByRole('combobox').fill('test')
|
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: 'Title', exact: true }).click()
|
||||||
await page.getByRole('button', { name: 'Advanced search' }).click()
|
await page.getByRole('button', { name: 'Advanced search' }).click()
|
||||||
await expect(page).toHaveURL(/query=test/)
|
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: 'Advanced search' }).click()
|
||||||
await page.getByRole('button', { name: 'ASN' }).click()
|
await page.getByRole('button', { name: 'ASN' }).click()
|
||||||
await page.getByRole('main').getByRole('combobox').nth(1).fill('1123')
|
await page.getByRole('main').getByRole('combobox').nth(1).fill('1123')
|
||||||
@@ -80,7 +70,6 @@ test('text filtering', async ({ page }) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test('date filtering', async ({ page }) => {
|
test('date filtering', async ({ page }) => {
|
||||||
await page.routeFromHAR(REQUESTS_HAR3, { notFound: 'fallback' })
|
|
||||||
await page.goto('/documents')
|
await page.goto('/documents')
|
||||||
await page.getByRole('button', { name: 'Dates' }).click()
|
await page.getByRole('button', { name: 'Dates' }).click()
|
||||||
await page.locator('.ng-arrow-wrapper').first().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.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.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')
|
const createdFrom = page.getByRole('textbox', { name: 'mm/dd/yyyy' }).first()
|
||||||
await page.getByRole('combobox', { name: 'Select year' }).selectOption('2022')
|
await createdFrom.fill('12/11/2022')
|
||||||
await page.getByText('11', { exact: true }).click()
|
await createdFrom.press('Enter')
|
||||||
await page.getByRole('button', { name: 'Title & content' }).click()
|
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 }) => {
|
test('sorting', async ({ page }) => {
|
||||||
await page.routeFromHAR(REQUESTS_HAR4, { notFound: 'fallback' })
|
|
||||||
await page.goto('/documents')
|
await page.goto('/documents')
|
||||||
await page.getByRole('button', { name: 'Sort' }).click()
|
await page.getByRole('button', { name: 'Sort' }).click()
|
||||||
await page.getByRole('button', { name: 'ASN' }).click()
|
await page.getByRole('button', { name: 'ASN' }).click()
|
||||||
@@ -140,7 +128,6 @@ test('sorting', async ({ page }) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test('change views', async ({ page }) => {
|
test('change views', async ({ page }) => {
|
||||||
await page.routeFromHAR(REQUESTS_HAR5, { notFound: 'fallback' })
|
|
||||||
await page.goto('/documents')
|
await page.goto('/documents')
|
||||||
await page.locator('.btn-group > label').first().click()
|
await page.locator('.btn-group > label').first().click()
|
||||||
await expect(page.locator('pngx-document-list table')).toBeVisible()
|
await expect(page.locator('pngx-document-list table')).toBeVisible()
|
||||||
@@ -151,12 +138,18 @@ test('change views', async ({ page }) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test('bulk edit', 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 page.goto('/documents')
|
||||||
|
await uiSettingsLoaded
|
||||||
|
|
||||||
await page.locator('pngx-document-card-small').nth(0).click()
|
|
||||||
await page
|
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)
|
.nth(3)
|
||||||
.click({
|
.click({
|
||||||
modifiers: ['Shift'],
|
modifiers: ['Shift'],
|
||||||
@@ -176,8 +169,14 @@ test('bulk edit', async ({ page }) => {
|
|||||||
)
|
)
|
||||||
await page.getByRole('button', { name: 'None' }).click()
|
await page.getByRole('button', { name: 'None' }).click()
|
||||||
|
|
||||||
await page.locator('pngx-document-card-small').nth(1).click()
|
await page
|
||||||
await page.locator('pngx-document-card-small').nth(2).click()
|
.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.getByRole('button', { name: 'Tags' }).click()
|
||||||
await page
|
await page
|
||||||
@@ -185,15 +184,26 @@ test('bulk edit', async ({ page }) => {
|
|||||||
.fill('TagWithPartial')
|
.fill('TagWithPartial')
|
||||||
await page.getByRole('menuitem', { name: 'TagWithPartial' }).click()
|
await page.getByRole('menuitem', { name: 'TagWithPartial' }).click()
|
||||||
|
|
||||||
await page.getByRole('button', { name: 'Apply' }).click()
|
|
||||||
|
|
||||||
const bulkEditPromise = page.waitForRequest((request) => {
|
const bulkEditPromise = page.waitForRequest((request) => {
|
||||||
|
if (
|
||||||
|
!request.url().includes('/api/documents/bulk_edit/') ||
|
||||||
|
request.method() !== 'POST'
|
||||||
|
) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
const postData = request.postDataJSON()
|
const postData = request.postDataJSON()
|
||||||
let isValid = postData['method'] == 'modify_tags'
|
let isValid = postData['method'] == 'modify_tags'
|
||||||
isValid = isValid && postData['parameters']['add_tags'].includes(5)
|
isValid =
|
||||||
return request.url().toString().includes('bulk_edit') && 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 page.getByRole('button', { name: 'Confirm' }).click()
|
||||||
await bulkEditPromise
|
await bulkEditPromise
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import { expect, test } from '@playwright/test'
|
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 }) => {
|
test('should not allow user to edit settings', async ({ page }) => {
|
||||||
await page.routeFromHAR(REQUESTS_HAR, { notFound: 'fallback' })
|
|
||||||
await page.goto('/dashboard')
|
await page.goto('/dashboard')
|
||||||
await expect(page.getByRole('link', { name: 'Settings' })).not.toBeAttached()
|
await expect(page.getByRole('link', { name: 'Settings' })).not.toBeAttached()
|
||||||
await page.goto('/settings')
|
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 }) => {
|
test('should not allow user to view documents', async ({ page }) => {
|
||||||
await page.routeFromHAR(REQUESTS_HAR, { notFound: 'fallback' })
|
|
||||||
await page.goto('/dashboard')
|
await page.goto('/dashboard')
|
||||||
await expect(
|
await expect(
|
||||||
page.locator('nav').getByRole('link', { name: 'Documents' })
|
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 }) => {
|
test('should not allow user to view correspondents', async ({ page }) => {
|
||||||
await page.routeFromHAR(REQUESTS_HAR, { notFound: 'fallback' })
|
|
||||||
await page.goto('/dashboard')
|
await page.goto('/dashboard')
|
||||||
await expect(
|
await expect(
|
||||||
page.getByRole('link', { name: 'Attributes' })
|
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 }) => {
|
test('should not allow user to view tags', async ({ page }) => {
|
||||||
await page.routeFromHAR(REQUESTS_HAR, { notFound: 'fallback' })
|
|
||||||
await page.goto('/dashboard')
|
await page.goto('/dashboard')
|
||||||
await expect(
|
await expect(
|
||||||
page.getByRole('link', { name: 'Attributes' })
|
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 }) => {
|
test('should not allow user to view document types', async ({ page }) => {
|
||||||
await page.routeFromHAR(REQUESTS_HAR, { notFound: 'fallback' })
|
|
||||||
await page.goto('/dashboard')
|
await page.goto('/dashboard')
|
||||||
await expect(
|
await expect(
|
||||||
page.getByRole('link', { name: 'Attributes' })
|
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 }) => {
|
test('should not allow user to view storage paths', async ({ page }) => {
|
||||||
await page.routeFromHAR(REQUESTS_HAR, { notFound: 'fallback' })
|
|
||||||
await page.goto('/dashboard')
|
await page.goto('/dashboard')
|
||||||
await expect(
|
await expect(
|
||||||
page.getByRole('link', { name: 'Attributes' })
|
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 }) => {
|
test('should not allow user to view logs', async ({ page }) => {
|
||||||
await page.routeFromHAR(REQUESTS_HAR, { notFound: 'fallback' })
|
|
||||||
await page.goto('/dashboard')
|
await page.goto('/dashboard')
|
||||||
await expect(page.getByRole('link', { name: 'Logs' })).not.toBeAttached()
|
await expect(page.getByRole('link', { name: 'Logs' })).not.toBeAttached()
|
||||||
await page.goto('/logs')
|
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 }) => {
|
test('should not allow user to view tasks', async ({ page }) => {
|
||||||
await page.routeFromHAR(REQUESTS_HAR, { notFound: 'fallback' })
|
|
||||||
await page.goto('/dashboard')
|
await page.goto('/dashboard')
|
||||||
await expect(page.getByRole('link', { name: 'Tasks' })).not.toBeAttached()
|
await expect(page.getByRole('link', { name: 'Tasks' })).not.toBeAttached()
|
||||||
await page.goto('/tasks')
|
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",
|
"name": "paperless-ngx-ui",
|
||||||
"version": "3.0.5",
|
"version": "3.1.2",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"preinstall": "npx only-allow pnpm",
|
"preinstall": "npx only-allow pnpm",
|
||||||
"ng": "ng",
|
"ng": "ng",
|
||||||
"start": "ng serve",
|
"start": "ng serve",
|
||||||
"build": "ng build",
|
"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",
|
"test": "ng test",
|
||||||
"lint": "ng lint"
|
"lint": "ng lint"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -14,17 +14,25 @@ export default defineConfig({
|
|||||||
forbidOnly: !!process.env.CI,
|
forbidOnly: !!process.env.CI,
|
||||||
/* Retry on CI only */
|
/* Retry on CI only */
|
||||||
retries: process.env.CI ? 3 : 0,
|
retries: process.env.CI ? 3 : 0,
|
||||||
/* Opt out of parallel tests on CI. */
|
/* Keep parallelism modest for the shared SQLite backend. */
|
||||||
workers: process.env.CI ? 1 : undefined,
|
workers: 2,
|
||||||
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
/* Reporter to use. See https://playwright.dev/docs/test-reporters */
|
||||||
reporter: 'html',
|
reporter: 'html',
|
||||||
/* Run your local dev server before starting the tests */
|
/* Run the disposable backend and local UI before starting the tests. */
|
||||||
webServer: {
|
webServer: [
|
||||||
|
{
|
||||||
|
url: 'http://localhost:8001/accounts/login/',
|
||||||
|
command: 'npm run e2e:backend',
|
||||||
|
reuseExistingServer: false,
|
||||||
|
timeout: 2 * 60 * 1000,
|
||||||
|
},
|
||||||
|
{
|
||||||
port,
|
port,
|
||||||
command: 'pnpm run start',
|
command: 'npm run start:e2e',
|
||||||
reuseExistingServer: !process.env.CI,
|
reuseExistingServer: !process.env.CI,
|
||||||
timeout: 2 * 60 * 1000,
|
timeout: 2 * 60 * 1000,
|
||||||
},
|
},
|
||||||
|
],
|
||||||
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
||||||
use: {
|
use: {
|
||||||
/* Base URL to use in actions like `await page.goto('/')`. */
|
/* Base URL to use in actions like `await page.goto('/')`. */
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
infoLink="configuration">
|
infoLink="configuration">
|
||||||
</pngx-page-header>
|
</pngx-page-header>
|
||||||
|
|
||||||
<form [formGroup]="configForm" (ngSubmit)="saveConfig()" class="pb-4">
|
<form [formGroup]="configForm" (ngSubmit)="saveConfig()" class="pb-4" autocomplete="off">
|
||||||
|
|
||||||
<ul ngbNav #nav="ngbNav" class="nav-tabs">
|
<ul ngbNav #nav="ngbNav" class="nav-tabs">
|
||||||
@for (category of optionCategories; track category) {
|
@for (category of optionCategories; track category) {
|
||||||
@@ -44,7 +44,7 @@
|
|||||||
@case (ConfigOptionType.String) { <pngx-input-text [formControlName]="option.key" [error]="errors[option.key]"></pngx-input-text> }
|
@case (ConfigOptionType.String) { <pngx-input-text [formControlName]="option.key" [error]="errors[option.key]"></pngx-input-text> }
|
||||||
@case (ConfigOptionType.JSON) { <pngx-input-text [formControlName]="option.key" [error]="errors[option.key]"></pngx-input-text> }
|
@case (ConfigOptionType.JSON) { <pngx-input-text [formControlName]="option.key" [error]="errors[option.key]"></pngx-input-text> }
|
||||||
@case (ConfigOptionType.File) { <pngx-input-file [formControlName]="option.key" (upload)="uploadFile($event, option.key)" [error]="errors[option.key]"></pngx-input-file> }
|
@case (ConfigOptionType.File) { <pngx-input-file [formControlName]="option.key" (upload)="uploadFile($event, option.key)" [error]="errors[option.key]"></pngx-input-file> }
|
||||||
@case (ConfigOptionType.Password) { <pngx-input-password [formControlName]="option.key" [error]="errors[option.key]"></pngx-input-password> }
|
@case (ConfigOptionType.Password) { <pngx-input-password [formControlName]="option.key" [error]="errors[option.key]" autocomplete="new-password"></pngx-input-password> }
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
@if (option.note) {
|
@if (option.note) {
|
||||||
|
|||||||
@@ -4,22 +4,19 @@
|
|||||||
(click)="closeMobileSearch(); toggleMenuCollapsed()">
|
(click)="closeMobileSearch(); toggleMenuCollapsed()">
|
||||||
<i-bs width="1.5em" height="1.5em" name="list"></i-bs>
|
<i-bs width="1.5em" height="1.5em" name="list"></i-bs>
|
||||||
</button>
|
</button>
|
||||||
<a class="navbar-brand d-flex align-items-center me-0 ps-md-3 py-0 order-sm-0"
|
<div class="navbar-brand-container order-sm-0">
|
||||||
|
<a class="navbar-brand d-flex align-items-center me-0 ps-md-3 py-0"
|
||||||
[ngClass]="{ 'slim': slimSidebarEnabled, '' : !slimSidebarEnabled }"
|
[ngClass]="{ 'slim': slimSidebarEnabled, '' : !slimSidebarEnabled }"
|
||||||
routerLink="/dashboard"
|
routerLink="/dashboard"
|
||||||
tourAnchor="tour.intro">
|
tourAnchor="tour.intro">
|
||||||
@if (!hasCustomBranding) {
|
@if (!hasCustomBranding) {
|
||||||
<pngx-logo extra_classes="navbar-official-logo px-1" height="2.4rem"></pngx-logo>
|
<pngx-logo extra_classes="navbar-official-logo px-1" height="2.4rem"></pngx-logo>
|
||||||
<svg class="brand-mark brand-mark-slim d-none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1000 1000" width="1.5em" height="1.5em" fill="currentColor">
|
<pngx-brand-mark class="brand-mark brand-mark-slim d-none"></pngx-brand-mark>
|
||||||
<path d="M341,949.1c-6.9-20.3-20.7-61.2-21.9-61-199.6-88.9-182.5-229.8-134.3-347.5,30,137.2,268.8,148.9,146.2,336-.9,2.2,10,27.8,19.5,51.3,22.7-51.9,58.6-115.5,55.8-120.8C178,398.7,724.9,299,807.1,18.5c83,251.5,53.1,659.8-377.4,814.9-2,1.4-63.5,148.6-66.9,150.2-.2-2.1-33.2,2.9-30.1-8.7,1.6-7,4.8-16.2,8.2-25.6h0v-.2h.1ZM323.1,846.2c48.3-71.9-12.7-120.8-56.9-152.2,81.2,107.4,66.4,120.8,56.9,152.2h0Z"/>
|
|
||||||
</svg>
|
|
||||||
} @else {
|
} @else {
|
||||||
@if (customAppLogo) {
|
@if (customAppLogo) {
|
||||||
<img class="brand-logo" [src]="customAppLogo" alt="" />
|
<img class="brand-logo" [src]="customAppLogo" alt="" />
|
||||||
} @else {
|
} @else {
|
||||||
<svg class="brand-mark" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1000 1000" width="1.5em" height="1.5em" fill="currentColor">
|
<pngx-brand-mark class="brand-mark"></pngx-brand-mark>
|
||||||
<path d="M341,949.1c-6.9-20.3-20.7-61.2-21.9-61-199.6-88.9-182.5-229.8-134.3-347.5,30,137.2,268.8,148.9,146.2,336-.9,2.2,10,27.8,19.5,51.3,22.7-51.9,58.6-115.5,55.8-120.8C178,398.7,724.9,299,807.1,18.5c83,251.5,53.1,659.8-377.4,814.9-2,1.4-63.5,148.6-66.9,150.2-.2-2.1-33.2,2.9-30.1-8.7,1.6-7,4.8-16.2,8.2-25.6h0v-.2h.1ZM323.1,846.2c48.3-71.9-12.7-120.8-56.9-152.2,81.2,107.4,66.4,120.8,56.9,152.2h0Z"/>
|
|
||||||
</svg>
|
|
||||||
}
|
}
|
||||||
<div class="brand-copy ms-2 text-truncate" [class.d-md-none]="slimSidebarEnabled">
|
<div class="brand-copy ms-2 text-truncate" [class.d-md-none]="slimSidebarEnabled">
|
||||||
<span class="brand-title text-truncate">{{ appTitle }}</span>
|
<span class="brand-title text-truncate">{{ appTitle }}</span>
|
||||||
@@ -29,6 +26,7 @@
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</a>
|
</a>
|
||||||
|
</div>
|
||||||
<div class="search-container flex-grow-1 py-2 pb-3 pb-sm-2 me-sm-auto order-3 order-sm-1"
|
<div class="search-container flex-grow-1 py-2 pb-3 pb-sm-2 me-sm-auto order-3 order-sm-1"
|
||||||
[class.mobile-hidden]="mobileSearchHidden()">
|
[class.mobile-hidden]="mobileSearchHidden()">
|
||||||
<div class="col-12 header-search mx-auto">
|
<div class="col-12 header-search mx-auto">
|
||||||
@@ -41,7 +39,7 @@
|
|||||||
}
|
}
|
||||||
<pngx-toasts-dropdown></pngx-toasts-dropdown>
|
<pngx-toasts-dropdown></pngx-toasts-dropdown>
|
||||||
<li ngbDropdown class="nav-item dropdown">
|
<li ngbDropdown class="nav-item dropdown">
|
||||||
<button class="btn navbar-action border-0" id="userDropdown" ngbDropdownToggle aria-label="User menu" i18n-aria-label>
|
<button class="btn navbar-action border-0 me-1 me-md-2" id="userDropdown" ngbDropdownToggle aria-label="User menu" i18n-aria-label>
|
||||||
<i-bs width="1.3em" height="1.3em" name="person-circle"></i-bs>
|
<i-bs width="1.3em" height="1.3em" name="person-circle"></i-bs>
|
||||||
<span class="small ms-2 d-none d-sm-inline">
|
<span class="small ms-2 d-none d-sm-inline">
|
||||||
{{this.settingsService.displayName}}
|
{{this.settingsService.displayName}}
|
||||||
@@ -75,7 +73,7 @@
|
|||||||
<div class="container-fluid">
|
<div class="container-fluid">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<nav id="sidebarMenu" class="d-md-block bg-light sidebar collapse"
|
<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()">
|
[ngbCollapse]="isMenuCollapsed()">
|
||||||
@if (canSaveSettings) {
|
@if (canSaveSettings) {
|
||||||
<button class="btn btn-sm btn-dark sidebar-slim-toggler" (click)="toggleSlimSidebar()" [aria-label]="slimSidebarEnabled ? 'Expand sidebar' : 'Collapse sidebar'" i18n-aria-label>
|
<button class="btn btn-sm btn-dark sidebar-slim-toggler" (click)="toggleSlimSidebar()" [aria-label]="slimSidebarEnabled ? 'Expand sidebar' : 'Collapse sidebar'" i18n-aria-label>
|
||||||
@@ -92,7 +90,7 @@
|
|||||||
<a class="nav-link" routerLink="dashboard" routerLinkActive="active" (click)="closeMenu()"
|
<a class="nav-link" routerLink="dashboard" routerLinkActive="active" (click)="closeMenu()"
|
||||||
ngbPopover="Dashboard" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end"
|
ngbPopover="Dashboard" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end"
|
||||||
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
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>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }">
|
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }">
|
||||||
@@ -101,7 +99,7 @@
|
|||||||
(click)="closeMenu()"
|
(click)="closeMenu()"
|
||||||
ngbPopover="Documents" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end"
|
ngbPopover="Documents" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end"
|
||||||
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||||
<i-bs class="me-2" name="files"></i-bs><span><ng-container i18n>Documents</ng-container></span>
|
<i-bs class="me-2" name="files"></i-bs><span class="nav-link-label"><ng-container i18n>Documents</ng-container></span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
@@ -172,7 +170,7 @@
|
|||||||
[class.text-truncate]="!slimSidebarEnabled" (click)="closeAll()"
|
[class.text-truncate]="!slimSidebarEnabled" (click)="closeAll()"
|
||||||
ngbPopover="Close all" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end"
|
ngbPopover="Close all" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end"
|
||||||
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||||
<i-bs class="me-2" name="x"></i-bs><span><ng-container i18n>Close all</ng-container></span>
|
<i-bs class="me-2" name="x"></i-bs><span class="nav-link-label"><ng-container i18n>Close all</ng-container></span>
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
}
|
}
|
||||||
@@ -191,7 +189,7 @@
|
|||||||
[routerLinkActiveOptions]="{ exact: !(slimSidebarEnabled || attributesSectionsCollapsed) }" (click)="closeMenu()"
|
[routerLinkActiveOptions]="{ exact: !(slimSidebarEnabled || attributesSectionsCollapsed) }" (click)="closeMenu()"
|
||||||
ngbPopover="Attributes" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end"
|
ngbPopover="Attributes" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end"
|
||||||
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
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>
|
</a>
|
||||||
@if (!slimSidebarEnabled && canSaveSettings) {
|
@if (!slimSidebarEnabled && canSaveSettings) {
|
||||||
<button
|
<button
|
||||||
@@ -212,27 +210,27 @@
|
|||||||
<ul class="nav flex-column">
|
<ul class="nav flex-column">
|
||||||
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Tag }">
|
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Tag }">
|
||||||
<a class="nav-link" routerLink="attributes/tags" routerLinkActive="active" (click)="closeMenu()">
|
<a class="nav-link" 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>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Correspondent }">
|
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Correspondent }">
|
||||||
<a class="nav-link" routerLink="attributes/correspondents" routerLinkActive="active" (click)="closeMenu()">
|
<a class="nav-link" 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>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.DocumentType }">
|
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.DocumentType }">
|
||||||
<a class="nav-link" routerLink="attributes/documenttypes" routerLinkActive="active" (click)="closeMenu()">
|
<a class="nav-link" 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>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.StoragePath }">
|
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.StoragePath }">
|
||||||
<a class="nav-link" routerLink="attributes/storagepaths" routerLinkActive="active" (click)="closeMenu()">
|
<a class="nav-link" 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>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.CustomField }">
|
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.CustomField }">
|
||||||
<a class="nav-link" routerLink="attributes/customfields" routerLinkActive="active" (click)="closeMenu()">
|
<a class="nav-link" 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>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
@@ -243,7 +241,7 @@
|
|||||||
<a class="nav-link" routerLink="savedviews" routerLinkActive="active" (click)="closeMenu()"
|
<a class="nav-link" routerLink="savedviews" routerLinkActive="active" (click)="closeMenu()"
|
||||||
ngbPopover="Saved Views" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end"
|
ngbPopover="Saved Views" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end"
|
||||||
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
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>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item app-link"
|
<li class="nav-item app-link"
|
||||||
@@ -252,7 +250,7 @@
|
|||||||
<a class="nav-link" routerLink="workflows" routerLinkActive="active" (click)="closeMenu()"
|
<a class="nav-link" routerLink="workflows" routerLinkActive="active" (click)="closeMenu()"
|
||||||
ngbPopover="Workflows" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end"
|
ngbPopover="Workflows" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end"
|
||||||
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
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>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.MailAccount }"
|
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.MailAccount }"
|
||||||
@@ -260,14 +258,14 @@
|
|||||||
<a class="nav-link" routerLink="mail" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Mail"
|
<a class="nav-link" routerLink="mail" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Mail"
|
||||||
i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end" container="body"
|
i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end" container="body"
|
||||||
triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
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>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.Document }">
|
<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"
|
<a class="nav-link" routerLink="trash" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Trash"
|
||||||
i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end" container="body"
|
i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end" container="body"
|
||||||
triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
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>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
@@ -283,21 +281,21 @@
|
|||||||
<a class="nav-link" routerLink="settings" routerLinkActive="active" (click)="closeMenu()"
|
<a class="nav-link" routerLink="settings" routerLinkActive="active" (click)="closeMenu()"
|
||||||
ngbPopover="Settings" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end"
|
ngbPopover="Settings" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end"
|
||||||
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
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>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.AppConfig }">
|
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.AppConfig }">
|
||||||
<a class="nav-link" routerLink="config" routerLinkActive="active" (click)="closeMenu()"
|
<a class="nav-link" routerLink="config" routerLinkActive="active" (click)="closeMenu()"
|
||||||
ngbPopover="Configuration" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end"
|
ngbPopover="Configuration" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end"
|
||||||
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
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>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.User }">
|
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.User }">
|
||||||
<a class="nav-link" routerLink="usersgroups" routerLinkActive="active" (click)="closeMenu()"
|
<a class="nav-link" routerLink="usersgroups" routerLinkActive="active" (click)="closeMenu()"
|
||||||
ngbPopover="Users & Groups" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end"
|
ngbPopover="Users & Groups" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end"
|
||||||
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
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>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item app-link"
|
<li class="nav-item app-link"
|
||||||
@@ -306,9 +304,10 @@
|
|||||||
<a class="nav-link" routerLink="tasks" routerLinkActive="active" (click)="closeMenu()"
|
<a class="nav-link" routerLink="tasks" routerLinkActive="active" (click)="closeMenu()"
|
||||||
ngbPopover="Tasks" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end"
|
ngbPopover="Tasks" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end"
|
||||||
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
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) {
|
<i-bs class="me-2" name="list-task"></i-bs><span class="nav-link-label"><ng-container i18n>Tasks</ng-container></span>
|
||||||
<span><span class="badge bg-danger ms-2 d-inline">{{tasksService.needsAttentionTasks.length}}</span></span>
|
@if (tasksService.needsAttentionTasks.length > 0 && !slimSidebarEnabled) {
|
||||||
}</span>
|
<span class="badge bg-danger ms-2 d-inline flex-shrink-0">{{tasksService.needsAttentionTasks.length}}</span>
|
||||||
|
}
|
||||||
@if (tasksService.needsAttentionTasks.length > 0 && slimSidebarEnabled) {
|
@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>
|
<span class="badge bg-danger position-absolute top-0 end-0 d-none d-md-block">{{tasksService.needsAttentionTasks.length}}</span>
|
||||||
}
|
}
|
||||||
@@ -319,7 +318,7 @@
|
|||||||
<a class="nav-link" routerLink="logs" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Logs"
|
<a class="nav-link" routerLink="logs" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Logs"
|
||||||
i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end" container="body"
|
i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end" container="body"
|
||||||
triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
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>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
}
|
}
|
||||||
@@ -390,7 +389,7 @@
|
|||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<main role="main" class="ms-sm-auto px-md-4" [class.mobile-search-hidden]="mobileSearchHidden()"
|
<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>
|
<router-outlet></router-outlet>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@
|
|||||||
border-right: 1px solid color-mix(in srgb, var(--bs-border-color) 65%, transparent);
|
border-right: 1px solid color-mix(in srgb, var(--bs-border-color) 65%, transparent);
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
--pngx-sidebar-width: 100%;
|
--pngx-sidebar-width: 100%;
|
||||||
|
width: var(--pngx-sidebar-width);
|
||||||
max-width: var(--pngx-sidebar-width);
|
max-width: var(--pngx-sidebar-width);
|
||||||
transition: all .2s ease;
|
transition: all .2s ease;
|
||||||
|
|
||||||
@@ -32,17 +33,10 @@
|
|||||||
display: none !important;
|
display: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
// These come from the col-* classes for non-slim sidebar, needed for animation
|
|
||||||
@media (min-width: 768px) {
|
@media (min-width: 768px) {
|
||||||
--pngx-sidebar-width: 25%;
|
&.expanded {
|
||||||
|
--pngx-sidebar-width: var(--pngx-sidebar-expanded-width);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 992px) {
|
|
||||||
--pngx-sidebar-width: 16.66666667%;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (min-width: 2400px) {
|
|
||||||
--pngx-sidebar-width: 8.33333333%;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@media (max-width: 767.98px) {
|
@media (max-width: 767.98px) {
|
||||||
@@ -109,6 +103,16 @@ main {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media(min-width: 768px) {
|
@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 {
|
.sidebar.slim {
|
||||||
max-width: 55px;
|
max-width: 55px;
|
||||||
|
|
||||||
@@ -125,6 +129,19 @@ main {
|
|||||||
.sidebar-heading span {
|
.sidebar-heading span {
|
||||||
display: none;
|
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 {
|
.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 {
|
.sidebar .nav-anchor, .sidebar .nav-label {
|
||||||
padding: .25rem .7rem;
|
padding: .25rem .7rem;
|
||||||
}
|
}
|
||||||
@@ -362,6 +399,7 @@ main {
|
|||||||
}
|
}
|
||||||
|
|
||||||
::ng-deep .navbar-official-logo {
|
::ng-deep .navbar-official-logo {
|
||||||
|
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));
|
filter: drop-shadow(0 1px 2px rgba(var(--pngx-navbar-brand-shadow-rgb), .3));
|
||||||
transition: filter .15s ease-in-out;
|
transition: filter .15s ease-in-out;
|
||||||
|
|
||||||
@@ -374,6 +412,8 @@ main {
|
|||||||
width: 1.65rem;
|
width: 1.65rem;
|
||||||
height: 1.65rem;
|
height: 1.65rem;
|
||||||
flex: 0 0 auto;
|
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;
|
transition: filter .15s ease-in-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -406,6 +446,8 @@ main {
|
|||||||
max-width: 5rem;
|
max-width: 5rem;
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
object-fit: contain;
|
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;
|
transition: filter .15s ease-in-out, transform .15s ease-in-out;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -417,7 +459,7 @@ main {
|
|||||||
|
|
||||||
:host ::ng-deep .navbar-official-logo {
|
:host ::ng-deep .navbar-official-logo {
|
||||||
.leaf {
|
.leaf {
|
||||||
fill: color-mix(in srgb, var(--pngx-primary-text-contrast) 70%, var(--bs-primary)) !important;
|
fill: color-mix(in srgb, var(--pngx-primary-text-contrast) 85%, var(--bs-primary)) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.text {
|
.text {
|
||||||
@@ -436,6 +478,19 @@ main {
|
|||||||
padding-right: .5rem;
|
padding-right: .5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// true center the search with equal flex widths
|
||||||
|
@media (min-width: 768px) {
|
||||||
|
.navbar-brand-container,
|
||||||
|
.navbar > ul {
|
||||||
|
display: flex; // so the brand link stays as wide as its contents
|
||||||
|
flex: 1 1 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar > ul {
|
||||||
|
justify-content: flex-end;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
:host ::ng-deep .navbar-action {
|
:host ::ng-deep .navbar-action {
|
||||||
display: inline-flex;
|
display: inline-flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
@@ -476,6 +531,7 @@ main {
|
|||||||
text-align: left;
|
text-align: left;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.navbar-brand-container,
|
||||||
.navbar-brand {
|
.navbar-brand {
|
||||||
grid-area: brand;
|
grid-area: brand;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
|||||||
@@ -543,6 +543,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', () => {
|
it('should collapse attributes sections when enabling slim sidebar', () => {
|
||||||
jest.spyOn(settingsService, 'storeSettings').mockReturnValue(of(true))
|
jest.spyOn(settingsService, 'storeSettings').mockReturnValue(of(true))
|
||||||
settingsService.set(SETTINGS_KEYS.ATTRIBUTES_SECTIONS_COLLAPSED, [])
|
settingsService.set(SETTINGS_KEYS.ATTRIBUTES_SECTIONS_COLLAPSED, [])
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ import { TasksService } from 'src/app/services/tasks.service'
|
|||||||
import { ToastService } from 'src/app/services/toast.service'
|
import { ToastService } from 'src/app/services/toast.service'
|
||||||
import { environment } from 'src/environments/environment'
|
import { environment } from 'src/environments/environment'
|
||||||
import { ChatComponent } from '../chat/chat/chat.component'
|
import { ChatComponent } from '../chat/chat/chat.component'
|
||||||
|
import { BrandMarkComponent } from '../common/logo/brand-mark/brand-mark.component'
|
||||||
import { LogoComponent } from '../common/logo/logo.component'
|
import { LogoComponent } from '../common/logo/logo.component'
|
||||||
import { ProfileEditDialogComponent } from '../common/profile-edit-dialog/profile-edit-dialog.component'
|
import { ProfileEditDialogComponent } from '../common/profile-edit-dialog/profile-edit-dialog.component'
|
||||||
import { DocumentDetailComponent } from '../document-detail/document-detail.component'
|
import { DocumentDetailComponent } from '../document-detail/document-detail.component'
|
||||||
@@ -61,6 +62,7 @@ const SCROLL_THRESHOLD = 16
|
|||||||
imports: [
|
imports: [
|
||||||
GlobalSearchComponent,
|
GlobalSearchComponent,
|
||||||
LogoComponent,
|
LogoComponent,
|
||||||
|
BrandMarkComponent,
|
||||||
DocumentTitlePipe,
|
DocumentTitlePipe,
|
||||||
IfPermissionsDirective,
|
IfPermissionsDirective,
|
||||||
ToastsDropdownComponent,
|
ToastsDropdownComponent,
|
||||||
@@ -116,6 +118,7 @@ export class AppFrameComponent
|
|||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
this.lastScrollY = window.scrollY
|
this.lastScrollY = window.scrollY
|
||||||
|
this.detectClassicScrollbars()
|
||||||
|
|
||||||
if (this.settingsService.get(SETTINGS_KEYS.UPDATE_CHECKING_ENABLED)) {
|
if (this.settingsService.get(SETTINGS_KEYS.UPDATE_CHECKING_ENABLED)) {
|
||||||
this.checkForUpdates()
|
this.checkForUpdates()
|
||||||
@@ -341,6 +344,22 @@ export class AppFrameComponent
|
|||||||
this.lastScrollY = currentScrollY
|
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 {
|
private isMobileViewport(): boolean {
|
||||||
return window.innerWidth < 768
|
return window.innerWidth < 768
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
spellcheck="false"
|
spellcheck="false"
|
||||||
[ngModel]="query()"
|
[ngModel]="query()"
|
||||||
(ngModelChange)="queryDebounce.next($event)"
|
(ngModelChange)="onQueryChange($event)"
|
||||||
(keydown)="searchInputKeyDown($event)"
|
(keydown)="searchInputKeyDown($event)"
|
||||||
ngbDropdownAnchor>
|
ngbDropdownAnchor>
|
||||||
<div class="position-absolute top-50 end-0 translate-middle">
|
<div class="position-absolute top-50 end-0 translate-middle">
|
||||||
|
|||||||
@@ -272,6 +272,19 @@ describe('GlobalSearchComponent', () => {
|
|||||||
expect(advancedSearchSpy).toHaveBeenCalled()
|
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', () => {
|
it('should search on query debounce', () => {
|
||||||
jest.useFakeTimers()
|
jest.useFakeTimers()
|
||||||
const query = 'test'
|
const query = 'test'
|
||||||
|
|||||||
@@ -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() {
|
public ngOnInit() {
|
||||||
this.hotkeyService
|
this.hotkeyService
|
||||||
.addShortcut({ keys: '/', description: $localize`Global search` })
|
.addShortcut({ keys: '/', description: $localize`Global search` })
|
||||||
|
|||||||
@@ -94,6 +94,7 @@ export class MailAccountEditDialogComponent extends EditDialogComponent<MailAcco
|
|||||||
this.testActive = false
|
this.testActive = false
|
||||||
this.testResult.set('danger')
|
this.testResult.set('danger')
|
||||||
this.alertTimeout = setTimeout(() => this.testResultAlert.close(), 5000)
|
this.alertTimeout = setTimeout(() => this.testResultAlert.close(), 5000)
|
||||||
|
this.error = e.error
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -462,6 +462,45 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
@case (WorkflowActionType.ApplyAiSuggestions) {
|
||||||
|
<div class="row">
|
||||||
|
<div class="col">
|
||||||
|
<p class="text-muted small" i18n>The document will be sent to the configured AI service for suggestions. Consider costs and privacy.</p>
|
||||||
|
<pngx-input-select
|
||||||
|
i18n-title
|
||||||
|
title="Apply suggestions for"
|
||||||
|
[items]="aiSuggestionFieldOptions"
|
||||||
|
[multiple]="true"
|
||||||
|
formControlName="ai_suggestion_fields"
|
||||||
|
[error]="error?.actions?.[i]?.ai_suggestion_fields"
|
||||||
|
hint="Suggestions for fields that are not selected are discarded."
|
||||||
|
i18n-hint
|
||||||
|
></pngx-input-select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<pngx-input-switch
|
||||||
|
[horizontal]="true"
|
||||||
|
i18n-title
|
||||||
|
title="Create missing items"
|
||||||
|
formControlName="ai_create_missing"
|
||||||
|
hint="Create suggested tags, correspondents and document types that do not exist yet."
|
||||||
|
i18n-hint
|
||||||
|
></pngx-input-switch>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<pngx-input-switch
|
||||||
|
[horizontal]="true"
|
||||||
|
i18n-title
|
||||||
|
title="Overwrite existing values"
|
||||||
|
formControlName="ai_overwrite_existing"
|
||||||
|
hint="Apply suggestions even if the document already has a value. Tags are always added, never replaced."
|
||||||
|
i18n-hint
|
||||||
|
></pngx-input-switch>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
} from 'src/app/data/matching-model'
|
} from 'src/app/data/matching-model'
|
||||||
import { Workflow } from 'src/app/data/workflow'
|
import { Workflow } from 'src/app/data/workflow'
|
||||||
import {
|
import {
|
||||||
|
AISuggestionField,
|
||||||
WorkflowAction,
|
WorkflowAction,
|
||||||
WorkflowActionType,
|
WorkflowActionType,
|
||||||
} from 'src/app/data/workflow-action'
|
} from 'src/app/data/workflow-action'
|
||||||
@@ -49,6 +50,7 @@ import { TagsComponent } from '../../input/tags/tags.component'
|
|||||||
import { TextComponent } from '../../input/text/text.component'
|
import { TextComponent } from '../../input/text/text.component'
|
||||||
import { EditDialogMode } from '../edit-dialog.component'
|
import { EditDialogMode } from '../edit-dialog.component'
|
||||||
import {
|
import {
|
||||||
|
AI_SUGGESTION_FIELD_OPTIONS,
|
||||||
DOCUMENT_SOURCE_OPTIONS,
|
DOCUMENT_SOURCE_OPTIONS,
|
||||||
SCHEDULE_DATE_FIELD_OPTIONS,
|
SCHEDULE_DATE_FIELD_OPTIONS,
|
||||||
TriggerFilterType,
|
TriggerFilterType,
|
||||||
@@ -239,14 +241,15 @@ describe('WorkflowEditDialogComponent', () => {
|
|||||||
SCHEDULE_DATE_FIELD_OPTIONS
|
SCHEDULE_DATE_FIELD_OPTIONS
|
||||||
)
|
)
|
||||||
|
|
||||||
// Email disabled
|
// Email, remote OCR and AI all disabled
|
||||||
jest.spyOn(settingsService, 'get').mockReturnValue(false)
|
jest.spyOn(settingsService, 'get').mockReturnValue(false)
|
||||||
component.ngOnInit()
|
component.ngOnInit()
|
||||||
expect(component.actionTypeOptions).toEqual(
|
expect(component.actionTypeOptions).toEqual(
|
||||||
WORKFLOW_ACTION_OPTIONS.filter(
|
WORKFLOW_ACTION_OPTIONS.filter(
|
||||||
(a) =>
|
(a) =>
|
||||||
a.id !== WorkflowActionType.Email &&
|
a.id !== WorkflowActionType.Email &&
|
||||||
a.id !== WorkflowActionType.RemoteOcr
|
a.id !== WorkflowActionType.RemoteOcr &&
|
||||||
|
a.id !== WorkflowActionType.ApplyAiSuggestions
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
@@ -344,6 +347,125 @@ describe('WorkflowEditDialogComponent', () => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should offer apply AI suggestions unless every trigger is consumption', () => {
|
||||||
|
jest.spyOn(settingsService, 'get').mockReturnValue(true)
|
||||||
|
|
||||||
|
// Consumption runs before the document has been parsed, so there would be
|
||||||
|
// no content to make suggestions from
|
||||||
|
component.object = {
|
||||||
|
name: 'Workflow 1',
|
||||||
|
order: 0,
|
||||||
|
enabled: true,
|
||||||
|
triggers: [{ type: WorkflowTriggerType.Consumption }],
|
||||||
|
actions: [],
|
||||||
|
} as Workflow
|
||||||
|
component.ngOnInit()
|
||||||
|
expect(component.actionTypeOptions.map((a) => a.id)).not.toContain(
|
||||||
|
WorkflowActionType.ApplyAiSuggestions
|
||||||
|
)
|
||||||
|
|
||||||
|
// A second, usable trigger is enough
|
||||||
|
component.object = {
|
||||||
|
name: 'Workflow 2',
|
||||||
|
order: 0,
|
||||||
|
enabled: true,
|
||||||
|
triggers: [
|
||||||
|
{ type: WorkflowTriggerType.Consumption },
|
||||||
|
{ type: WorkflowTriggerType.DocumentAdded },
|
||||||
|
],
|
||||||
|
actions: [],
|
||||||
|
} as Workflow
|
||||||
|
component.ngOnInit()
|
||||||
|
expect(component.actionTypeOptions.map((a) => a.id)).toContain(
|
||||||
|
WorkflowActionType.ApplyAiSuggestions
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should keep apply AI suggestions listed when an action already uses it', () => {
|
||||||
|
jest.spyOn(settingsService, 'get').mockReturnValue(true)
|
||||||
|
|
||||||
|
// Otherwise changing the trigger would silently blank the selection
|
||||||
|
component.object = {
|
||||||
|
name: 'Workflow 1',
|
||||||
|
order: 0,
|
||||||
|
enabled: true,
|
||||||
|
triggers: [{ type: WorkflowTriggerType.Consumption }],
|
||||||
|
actions: [{ type: WorkflowActionType.ApplyAiSuggestions }],
|
||||||
|
} as Workflow
|
||||||
|
component.ngOnInit()
|
||||||
|
|
||||||
|
expect(component.actionTypeOptions.map((a) => a.id)).toContain(
|
||||||
|
WorkflowActionType.ApplyAiSuggestions
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not offer apply AI suggestions when AI is disabled', () => {
|
||||||
|
jest
|
||||||
|
.spyOn(settingsService, 'get')
|
||||||
|
.mockImplementation((key) => key !== SETTINGS_KEYS.AI_ENABLED)
|
||||||
|
|
||||||
|
component.object = {
|
||||||
|
name: 'Workflow 1',
|
||||||
|
order: 0,
|
||||||
|
enabled: true,
|
||||||
|
triggers: [{ type: WorkflowTriggerType.DocumentAdded }],
|
||||||
|
actions: [],
|
||||||
|
} as Workflow
|
||||||
|
component.ngOnInit()
|
||||||
|
|
||||||
|
expect(component.actionTypeOptions.map((a) => a.id)).not.toContain(
|
||||||
|
WorkflowActionType.ApplyAiSuggestions
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should create form fields for apply AI suggestions options', () => {
|
||||||
|
component.object = {
|
||||||
|
name: 'Workflow 1',
|
||||||
|
order: 0,
|
||||||
|
enabled: true,
|
||||||
|
triggers: [{ type: WorkflowTriggerType.DocumentAdded }],
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
type: WorkflowActionType.ApplyAiSuggestions,
|
||||||
|
ai_suggestion_fields: [
|
||||||
|
AISuggestionField.Title,
|
||||||
|
AISuggestionField.Tags,
|
||||||
|
],
|
||||||
|
ai_create_missing: true,
|
||||||
|
ai_overwrite_existing: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
} as Workflow
|
||||||
|
component.ngOnInit()
|
||||||
|
|
||||||
|
const action = component.actionFields.at(0)
|
||||||
|
expect(action.get('ai_suggestion_fields').value).toEqual([
|
||||||
|
AISuggestionField.Title,
|
||||||
|
AISuggestionField.Tags,
|
||||||
|
])
|
||||||
|
expect(action.get('ai_create_missing').value).toBeTruthy()
|
||||||
|
expect(action.get('ai_overwrite_existing').value).toBeTruthy()
|
||||||
|
expect(component.aiSuggestionFieldOptions).toEqual(
|
||||||
|
AI_SUGGESTION_FIELD_OPTIONS
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should default apply AI suggestions options on a new action', () => {
|
||||||
|
component.object = {
|
||||||
|
name: 'Workflow 1',
|
||||||
|
order: 0,
|
||||||
|
enabled: true,
|
||||||
|
triggers: [{ type: WorkflowTriggerType.DocumentAdded }],
|
||||||
|
actions: [],
|
||||||
|
} as Workflow
|
||||||
|
component.addAction()
|
||||||
|
|
||||||
|
const action = component.actionFields.at(component.actionFields.length - 1)
|
||||||
|
expect(action.get('ai_suggestion_fields').value).toEqual([])
|
||||||
|
expect(action.get('ai_create_missing').value).toBeFalsy()
|
||||||
|
expect(action.get('ai_overwrite_existing').value).toBeFalsy()
|
||||||
|
})
|
||||||
|
|
||||||
it('should support add and remove triggers and actions', () => {
|
it('should support add and remove triggers and actions', () => {
|
||||||
component.object = workflow
|
component.object = workflow
|
||||||
component.addTrigger()
|
component.addTrigger()
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import { StoragePath } from 'src/app/data/storage-path'
|
|||||||
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
||||||
import { Workflow } from 'src/app/data/workflow'
|
import { Workflow } from 'src/app/data/workflow'
|
||||||
import {
|
import {
|
||||||
|
AISuggestionField,
|
||||||
WorkflowAction,
|
WorkflowAction,
|
||||||
WorkflowActionType,
|
WorkflowActionType,
|
||||||
} from 'src/app/data/workflow-action'
|
} from 'src/app/data/workflow-action'
|
||||||
@@ -152,6 +153,37 @@ export const WORKFLOW_ACTION_OPTIONS = [
|
|||||||
id: WorkflowActionType.RemoteOcr,
|
id: WorkflowActionType.RemoteOcr,
|
||||||
name: $localize`Remote OCR`,
|
name: $localize`Remote OCR`,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: WorkflowActionType.ApplyAiSuggestions,
|
||||||
|
name: $localize`Apply AI suggestions`,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
|
||||||
|
export const AI_SUGGESTION_FIELD_OPTIONS = [
|
||||||
|
{
|
||||||
|
id: AISuggestionField.Title,
|
||||||
|
name: $localize`Title`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: AISuggestionField.Tags,
|
||||||
|
name: $localize`Tags`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: AISuggestionField.Correspondent,
|
||||||
|
name: $localize`Correspondent`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: AISuggestionField.DocumentType,
|
||||||
|
name: $localize`Document type`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: AISuggestionField.StoragePath,
|
||||||
|
name: $localize`Storage path`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: AISuggestionField.Created,
|
||||||
|
name: $localize`Created date`,
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
export enum TriggerFilterType {
|
export enum TriggerFilterType {
|
||||||
@@ -576,6 +608,24 @@ export class WorkflowEditDialogComponent
|
|||||||
allowed = allowed.filter((a) => a.id !== WorkflowActionType.RemoteOcr)
|
allowed = allowed.filter((a) => a.id !== WorkflowActionType.RemoteOcr)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Only available after consumption. Unlike remote OCR this is hidden only
|
||||||
|
// 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) &&
|
||||||
|
(!formWorkflow?.triggers?.length ||
|
||||||
|
formWorkflow.triggers.some(
|
||||||
|
(trigger) => trigger.type !== WorkflowTriggerType.Consumption
|
||||||
|
) ||
|
||||||
|
formWorkflow.actions?.some(
|
||||||
|
(action) => action.type === WorkflowActionType.ApplyAiSuggestions
|
||||||
|
))
|
||||||
|
if (!aiSuggestionsUsable) {
|
||||||
|
allowed = allowed.filter(
|
||||||
|
(a) => a.id !== WorkflowActionType.ApplyAiSuggestions
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if (
|
if (
|
||||||
this.allowedActionTypes?.length === allowed.length &&
|
this.allowedActionTypes?.length === allowed.length &&
|
||||||
this.allowedActionTypes.every((a, i) => a.id === allowed[i].id)
|
this.allowedActionTypes.every((a, i) => a.id === allowed[i].id)
|
||||||
@@ -1227,6 +1277,11 @@ export class WorkflowEditDialogComponent
|
|||||||
passwords: new FormControl(
|
passwords: new FormControl(
|
||||||
this.formatPasswords(action.passwords ?? [])
|
this.formatPasswords(action.passwords ?? [])
|
||||||
),
|
),
|
||||||
|
ai_suggestion_fields: new FormControl(
|
||||||
|
action.ai_suggestion_fields ?? []
|
||||||
|
),
|
||||||
|
ai_create_missing: new FormControl(!!action.ai_create_missing),
|
||||||
|
ai_overwrite_existing: new FormControl(!!action.ai_overwrite_existing),
|
||||||
}),
|
}),
|
||||||
{ emitEvent }
|
{ emitEvent }
|
||||||
)
|
)
|
||||||
@@ -1316,6 +1371,10 @@ export class WorkflowEditDialogComponent
|
|||||||
return this.actionTypeOptions.find((t) => t.id === type)?.name ?? ''
|
return this.actionTypeOptions.find((t) => t.id === type)?.name ?? ''
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get aiSuggestionFieldOptions() {
|
||||||
|
return AI_SUGGESTION_FIELD_OPTIONS
|
||||||
|
}
|
||||||
|
|
||||||
addAction() {
|
addAction() {
|
||||||
if (!this.object) {
|
if (!this.object) {
|
||||||
this.object = Object.assign({}, this.objectForm.value)
|
this.object = Object.assign({}, this.objectForm.value)
|
||||||
@@ -1369,6 +1428,9 @@ export class WorkflowEditDialogComponent
|
|||||||
include_document: false,
|
include_document: false,
|
||||||
},
|
},
|
||||||
passwords: [],
|
passwords: [],
|
||||||
|
ai_suggestion_fields: [],
|
||||||
|
ai_create_missing: false,
|
||||||
|
ai_overwrite_existing: false,
|
||||||
}
|
}
|
||||||
this.object.actions.push(action)
|
this.object.actions.push(action)
|
||||||
this.createActionField(action)
|
this.createActionField(action)
|
||||||
|
|||||||
@@ -47,8 +47,8 @@
|
|||||||
<i-bs width="0.9em" height="0.9em" name="file-text" class="me-1"></i-bs><span>{{document.title}}</span>
|
<i-bs width="0.9em" height="0.9em" name="file-text" class="me-1"></i-bs><span>{{document.title}}</span>
|
||||||
</a>
|
</a>
|
||||||
} @else {
|
} @else {
|
||||||
<span class="badge bg-light text-muted" (click)="unselect(document)" (mousedown)="$event.stopImmediatePropagation()" type="button" title="Remove link" i18n-title>
|
<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>Not found</span>
|
<i-bs width="0.9em" height="0.9em" name="exclamation-triangle-fill" class="me-1"></i-bs><span i18n>Unavailable</span>
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -151,6 +151,23 @@ describe('DocumentLinkComponent', () => {
|
|||||||
expect(component.selectedDocuments).toEqual([])
|
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', () => {
|
it('should support unselect', () => {
|
||||||
const getSpy = jest.spyOn(documentService, 'getFew')
|
const getSpy = jest.spyOn(documentService, 'getFew')
|
||||||
getSpy.mockImplementation((ids) => {
|
getSpy.mockImplementation((ids) => {
|
||||||
@@ -167,6 +184,15 @@ describe('DocumentLinkComponent', () => {
|
|||||||
expect(component.selectedDocuments).toEqual([documents[1]])
|
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', () => {
|
it('should use correct compare, trackBy functions', () => {
|
||||||
expect(component.compareDocuments(documents[0], { id: 1 })).toBeTruthy()
|
expect(component.compareDocuments(documents[0], { id: 1 })).toBeTruthy()
|
||||||
expect(component.compareDocuments(documents[0], { id: 2 })).toBeFalsy()
|
expect(component.compareDocuments(documents[0], { id: 2 })).toBeFalsy()
|
||||||
|
|||||||
@@ -101,7 +101,7 @@ export class DocumentLinkComponent
|
|||||||
.subscribe((documentResults) => {
|
.subscribe((documentResults) => {
|
||||||
this.loading.set(false)
|
this.loading.set(false)
|
||||||
this.selectedDocuments = documentIDs.map(
|
this.selectedDocuments = documentIDs.map(
|
||||||
(id) => documentResults.results.find((d) => d.id === id) ?? {}
|
(id) => documentResults.results.find((d) => d.id === id) ?? { id }
|
||||||
)
|
)
|
||||||
super.writeValue(documentIDs)
|
super.writeValue(documentIDs)
|
||||||
})
|
})
|
||||||
@@ -142,6 +142,8 @@ export class DocumentLinkComponent
|
|||||||
}
|
}
|
||||||
|
|
||||||
unselect(document: Document): void {
|
unselect(document: Document): void {
|
||||||
|
if (this.disabled) return
|
||||||
|
|
||||||
this.selectedDocuments = this.selectedDocuments.filter(
|
this.selectedDocuments = this.selectedDocuments.filter(
|
||||||
(d) => d && d.id !== document.id
|
(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;"
|
<div class="d-flex flex-wrap flex-row gap-2 w-100 mh-1" style="min-height: 1em;"
|
||||||
cdkDropList #selectedList="cdkDropList"
|
cdkDropList #selectedList="cdkDropList"
|
||||||
cdkDropListOrientation="mixed"
|
cdkDropListOrientation="mixed"
|
||||||
|
[cdkDropListDisabled]="disabled"
|
||||||
(cdkDropListDropped)="drop($event)"
|
(cdkDropListDropped)="drop($event)"
|
||||||
[cdkDropListConnectedTo]="[unselectedList]">
|
[cdkDropListConnectedTo]="[unselectedList]">
|
||||||
@for (item of selectedItems; track item.id) {
|
@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;"
|
<div class="d-flex flex-wrap flex-row gap-2 w-100" style="min-height: 1em;"
|
||||||
cdkDropList #unselectedList="cdkDropList"
|
cdkDropList #unselectedList="cdkDropList"
|
||||||
cdkDropListOrientation="mixed"
|
cdkDropListOrientation="mixed"
|
||||||
|
[cdkDropListDisabled]="disabled"
|
||||||
(cdkDropListDropped)="drop($event)"
|
(cdkDropListDropped)="drop($event)"
|
||||||
[cdkDropListConnectedTo]="[selectedList]">
|
[cdkDropListConnectedTo]="[selectedList]">
|
||||||
@for (item of unselectedItems; track item.id) {
|
@for (item of unselectedItems; track item.id) {
|
||||||
|
|||||||
@@ -1,3 +1,11 @@
|
|||||||
.badge {
|
.badge {
|
||||||
cursor: move;
|
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' },
|
{ 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[]>) {
|
public drop(event: CdkDragDrop<string[]>) {
|
||||||
|
if (this.disabled) return
|
||||||
|
|
||||||
if (
|
if (
|
||||||
event.previousContainer === event.container &&
|
event.previousContainer === event.container &&
|
||||||
event.container === this.selectedList
|
event.container === this.selectedList
|
||||||
|
|||||||
@@ -40,6 +40,10 @@ describe('PasswordComponent', () => {
|
|||||||
// expect(component.value).toEqual('foo')
|
// expect(component.value).toEqual('foo')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should not offer itself to browser autofill by default', () => {
|
||||||
|
expect(input.getAttribute('autocomplete')).toEqual('off')
|
||||||
|
})
|
||||||
|
|
||||||
it('should support toggling field visibility', () => {
|
it('should support toggling field visibility', () => {
|
||||||
expect(input.type).toEqual('password')
|
expect(input.type).toEqual('password')
|
||||||
component.showReveal = true
|
component.showReveal = true
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export class PasswordComponent extends AbstractInputComponent<string> {
|
|||||||
showReveal: boolean = false
|
showReveal: boolean = false
|
||||||
|
|
||||||
@Input()
|
@Input()
|
||||||
autocomplete: string
|
autocomplete: string = 'off'
|
||||||
|
|
||||||
public textVisible: boolean = false
|
public textVisible: boolean = false
|
||||||
|
|
||||||
|
|||||||
@@ -36,11 +36,16 @@
|
|||||||
(focus)="clearLastSearchTerm()"
|
(focus)="clearLastSearchTerm()"
|
||||||
(clear)="clearLastSearchTerm()"
|
(clear)="clearLastSearchTerm()"
|
||||||
(blur)="onBlur()">
|
(blur)="onBlur()">
|
||||||
<ng-template ng-label-tmp let-item="item">
|
<ng-template ng-label-tmp let-item="item" let-clear="clear">
|
||||||
|
@if (multiple && !disabled) {
|
||||||
|
<span class="ng-value-icon left" role="button" tabindex="0" (click)="clear(item)" (keydown.enter)="clear(item)" aria-label="Remove item" i18n-aria-label>×</span>
|
||||||
|
}
|
||||||
|
<span class="ng-value-label" [title]="item[bindLabel]">
|
||||||
@if (iconField && item[iconField]) {
|
@if (iconField && item[iconField]) {
|
||||||
<i-bs class="me-2" [name]="item[iconField]"></i-bs>
|
<i-bs class="me-2" [name]="item[iconField]"></i-bs>
|
||||||
}
|
}
|
||||||
<span [title]="item[bindLabel]">{{item[bindLabel]}}</span>
|
{{item[bindLabel]}}
|
||||||
|
</span>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
<ng-template ng-option-tmp let-item="item">
|
<ng-template ng-option-tmp let-item="item">
|
||||||
@if (iconField && item[iconField]) {
|
@if (iconField && item[iconField]) {
|
||||||
|
|||||||
@@ -24,6 +24,12 @@ describe('TextComponent', () => {
|
|||||||
input = component.inputField.nativeElement
|
input = component.inputField.nativeElement
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should not offer itself to browser autofill by default', () => {
|
||||||
|
expect(
|
||||||
|
component.inputField.nativeElement.getAttribute('autocomplete')
|
||||||
|
).toEqual('off')
|
||||||
|
})
|
||||||
|
|
||||||
it('should support use of input field', () => {
|
it('should support use of input field', () => {
|
||||||
expect(component.value).toBeUndefined()
|
expect(component.value).toBeUndefined()
|
||||||
input.value = 'foo'
|
input.value = 'foo'
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ import { AbstractInputComponent } from '../abstract-input'
|
|||||||
})
|
})
|
||||||
export class TextComponent extends AbstractInputComponent<string> {
|
export class TextComponent extends AbstractInputComponent<string> {
|
||||||
@Input()
|
@Input()
|
||||||
autocomplete: string
|
autocomplete: string = 'off'
|
||||||
|
|
||||||
@Input()
|
@Input()
|
||||||
placeholder: string = ''
|
placeholder: string = ''
|
||||||
|
|||||||
@@ -0,0 +1,3 @@
|
|||||||
|
<svg class="w-100 h-100 d-block" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1000 1000" fill="currentColor" aria-hidden="true">
|
||||||
|
<path d="M341,949.1c-6.9-20.3-20.7-61.2-21.9-61-199.6-88.9-182.5-229.8-134.3-347.5,30,137.2,268.8,148.9,146.2,336-.9,2.2,10,27.8,19.5,51.3,22.7-51.9,58.6-115.5,55.8-120.8C178,398.7,724.9,299,807.1,18.5c83,251.5,53.1,659.8-377.4,814.9-2,1.4-63.5,148.6-66.9,150.2-.2-2.1-33.2,2.9-30.1-8.7,1.6-7,4.8-16.2,8.2-25.6h0v-.2h.1ZM323.1,846.2c48.3-71.9-12.7-120.8-56.9-152.2,81.2,107.4,66.4,120.8,56.9,152.2h0Z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 545 B |
@@ -0,0 +1,14 @@
|
|||||||
|
import { Component, input } from '@angular/core'
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'pngx-brand-mark',
|
||||||
|
templateUrl: './brand-mark.component.html',
|
||||||
|
host: {
|
||||||
|
'[style.width]': 'width()',
|
||||||
|
'[style.height]': 'height()',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
export class BrandMarkComponent {
|
||||||
|
readonly width = input<string>(null)
|
||||||
|
readonly height = input<string>(null)
|
||||||
|
}
|
||||||
@@ -25,29 +25,34 @@
|
|||||||
|
|
||||||
<div ngbDropdownMenu aria-labelledby="suggestionsDropdown" class="shadow suggestions-dropdown">
|
<div ngbDropdownMenu aria-labelledby="suggestionsDropdown" class="shadow suggestions-dropdown">
|
||||||
<div class="list-group list-group-flush small pb-0">
|
<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">
|
<div class="list-group-item text-muted fst-italic">
|
||||||
<small class="text-muted small fst-italic" i18n>No novel suggestions</small>
|
<small class="text-muted small fst-italic" i18n>No novel suggestions</small>
|
||||||
</div>
|
</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>
|
<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) {
|
@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>
|
<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>
|
<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) {
|
@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>
|
<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>
|
<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) {
|
@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>
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -21,15 +21,115 @@ describe('SuggestionsDropdownComponent', () => {
|
|||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should calculate totalSuggestions', () => {
|
it('should exclude suggested storage path names from totalSuggestions', () => {
|
||||||
fixture.componentRef.setInput('suggestions', {
|
fixture.componentRef.setInput('suggestions', {
|
||||||
suggested_correspondents: ['John Doe'],
|
suggested_correspondents: ['John Doe'],
|
||||||
suggested_tags: ['Tag1', 'Tag2'],
|
suggested_tags: ['Tag1', 'Tag2'],
|
||||||
suggested_document_types: ['Type1'],
|
suggested_document_types: ['Type1'],
|
||||||
|
suggested_storage_paths: ['Finance/Invoices'],
|
||||||
})
|
})
|
||||||
expect(component.totalSuggestions).toBe(4)
|
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', () => {
|
it('should show when a completed request returned no suggestions', () => {
|
||||||
fixture.componentRef.setInput('suggestions', {
|
fixture.componentRef.setInput('suggestions', {
|
||||||
correspondents: [],
|
correspondents: [],
|
||||||
|
|||||||
@@ -25,6 +25,11 @@ export class SuggestionsDropdownComponent {
|
|||||||
readonly loading = input(false)
|
readonly loading = input(false)
|
||||||
readonly disabled = 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()
|
@Output()
|
||||||
getSuggestions: EventEmitter<SuggestionsDropdownComponent> =
|
getSuggestions: EventEmitter<SuggestionsDropdownComponent> =
|
||||||
new EventEmitter()
|
new EventEmitter()
|
||||||
@@ -54,14 +59,46 @@ export class SuggestionsDropdownComponent {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
get totalSuggestions(): number {
|
get novelSuggestions(): number {
|
||||||
return (
|
return (
|
||||||
this.suggestions()?.suggested_correspondents?.length +
|
(this.suggestions()?.suggested_correspondents?.length ?? 0) +
|
||||||
this.suggestions()?.suggested_tags?.length +
|
(this.suggestions()?.suggested_tags?.length ?? 0) +
|
||||||
this.suggestions()?.suggested_document_types?.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 {
|
get noSuggestions(): boolean {
|
||||||
const suggestions = this.suggestions()
|
const suggestions = this.suggestions()
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
<input type="file" class="visually-hidden" (change)="onFileSelected($event)" multiple #fileUpload>
|
<input type="file" class="visually-hidden" (change)="onFileSelected($event)" multiple #fileUpload>
|
||||||
</form>
|
</form>
|
||||||
@if (getStatus().length > 0) {
|
@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="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 shadow-sm consumer-status-card">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
|
|||||||
@@ -1,10 +1,18 @@
|
|||||||
<ngb-alert class="pe-3" type="primary" [dismissible]="true" (closed)="dismiss.emit(true)">
|
<ngb-alert class="welcome-widget bg-light shadow-sm pe-3 position-relative overflow-hidden" type="primary" [dismissible]="true" (closed)="dismiss.emit(true)">
|
||||||
<h4 class="alert-heading"><ng-container i18n>Paperless-ngx is running!</ng-container> 🎉</h4>
|
<pngx-brand-mark class="welcome-watermark d-none d-md-block position-absolute top-50 translate-middle-y text-primary pe-none" width="26rem" height="26rem"></pngx-brand-mark>
|
||||||
|
<div class="d-flex align-items-start gap-3 gap-md-4 position-relative">
|
||||||
|
<pngx-brand-mark class="d-none d-sm-block flex-shrink-0 text-primary" width="3rem" height="3rem"></pngx-brand-mark>
|
||||||
|
<div class="flex-grow-1">
|
||||||
|
<h4 class="alert-heading text-primary fw-semibold"><ng-container i18n>Paperless-ngx is running!</ng-container> 🎉</h4>
|
||||||
|
<div class="welcome-copy text-dark">
|
||||||
<p i18n>You're ready to start uploading documents! Explore the various features of this web app on your own, or start a quick tour using the button below.</p>
|
<p i18n>You're ready to start uploading documents! Explore the various features of this web app on your own, or start a quick tour using the button below.</p>
|
||||||
<p i18n>More detail on how to use and configure Paperless-ngx is always available in the <a href="https://docs.paperless-ngx.com" target="_blank">documentation</a>.</p>
|
<p class="mb-0" i18n>More detail on how to use and configure Paperless-ngx is always available in the <a href="https://docs.paperless-ngx.com" target="_blank">documentation</a>.</p>
|
||||||
<hr>
|
</div>
|
||||||
<div class="d-flex align-items-end">
|
<hr class="opacity-100">
|
||||||
<p class="lead fs-6 m-0"><em i18n>Thanks for being a part of the Paperless-ngx community!</em></p>
|
<div class="d-flex flex-column flex-sm-row align-items-sm-center gap-3">
|
||||||
<button class="btn btn-primary ms-auto flex-shrink-0" (click)="tourService.start()"><ng-container i18n>Start the tour</ng-container> →</button>
|
<p class="m-0 text-secondary"><em i18n>Thanks for being a part of the Paperless-ngx community!</em></p>
|
||||||
|
<button class="btn btn-primary ms-sm-auto flex-shrink-0 align-self-start align-self-sm-auto" (click)="tourService.start()"><ng-container i18n>Start the tour</ng-container> →</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</ngb-alert>
|
</ngb-alert>
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
.welcome-widget {
|
||||||
|
border-color: color-mix(in srgb, var(--bs-primary) 50%, transparent);
|
||||||
|
|
||||||
|
hr {
|
||||||
|
border-top-color: color-mix(in srgb, var(--bs-primary) 25%, transparent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.welcome-copy {
|
||||||
|
max-width: 68ch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.welcome-watermark {
|
||||||
|
right: -6rem;
|
||||||
|
opacity: .07;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,12 +1,13 @@
|
|||||||
import { Component, EventEmitter, Output, inject } from '@angular/core'
|
import { Component, EventEmitter, Output, inject } from '@angular/core'
|
||||||
import { NgbAlertModule } from '@ng-bootstrap/ng-bootstrap'
|
import { NgbAlertModule } from '@ng-bootstrap/ng-bootstrap'
|
||||||
import { TourService } from 'ngx-ui-tour-ng-bootstrap'
|
import { TourService } from 'ngx-ui-tour-ng-bootstrap'
|
||||||
|
import { BrandMarkComponent } from '../../../common/logo/brand-mark/brand-mark.component'
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'pngx-welcome-widget',
|
selector: 'pngx-welcome-widget',
|
||||||
templateUrl: './welcome-widget.component.html',
|
templateUrl: './welcome-widget.component.html',
|
||||||
styleUrls: ['./welcome-widget.component.scss'],
|
styleUrls: ['./welcome-widget.component.scss'],
|
||||||
imports: [NgbAlertModule],
|
imports: [NgbAlertModule, BrandMarkComponent],
|
||||||
})
|
})
|
||||||
export class WelcomeWidgetComponent {
|
export class WelcomeWidgetComponent {
|
||||||
readonly tourService = inject(TourService)
|
readonly tourService = inject(TourService)
|
||||||
|
|||||||
@@ -134,6 +134,10 @@
|
|||||||
[loading]="suggestionsLoading()"
|
[loading]="suggestionsLoading()"
|
||||||
[suggestions]="suggestions()"
|
[suggestions]="suggestions()"
|
||||||
[aiEnabled]="aiEnabled"
|
[aiEnabled]="aiEnabled"
|
||||||
|
[appliedTags]="documentForm.value.tags"
|
||||||
|
[appliedCorrespondent]="documentForm.value.correspondent"
|
||||||
|
[appliedDocumentType]="documentForm.value.document_type"
|
||||||
|
[appliedStoragePath]="documentForm.value.storage_path"
|
||||||
(getSuggestions)="getSuggestions()"
|
(getSuggestions)="getSuggestions()"
|
||||||
(addTag)="createTag($event)"
|
(addTag)="createTag($event)"
|
||||||
(addDocumentType)="createDocumentType($event)"
|
(addDocumentType)="createDocumentType($event)"
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ import {
|
|||||||
} from '@ng-bootstrap/ng-bootstrap'
|
} from '@ng-bootstrap/ng-bootstrap'
|
||||||
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
||||||
import { DeviceDetectorService } from 'ngx-device-detector'
|
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 { routes } from 'src/app/app-routing.module'
|
||||||
import { Correspondent } from 'src/app/data/correspondent'
|
import { Correspondent } from 'src/app/data/correspondent'
|
||||||
import { CustomFieldDataType } from 'src/app/data/custom-field'
|
import { CustomFieldDataType } from 'src/app/data/custom-field'
|
||||||
@@ -1444,6 +1444,26 @@ describe('DocumentDetailComponent', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
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', () => {
|
it('should show error if needed for get suggestions', () => {
|
||||||
const suggestionsSpy = jest.spyOn(documentService, 'getSuggestions')
|
const suggestionsSpy = jest.spyOn(documentService, 'getSuggestions')
|
||||||
const errorSpy = jest.spyOn(toastService, 'showError')
|
const errorSpy = jest.spyOn(toastService, 'showError')
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ import {
|
|||||||
debounceTime,
|
debounceTime,
|
||||||
distinctUntilChanged,
|
distinctUntilChanged,
|
||||||
filter,
|
filter,
|
||||||
|
finalize,
|
||||||
first,
|
first,
|
||||||
map,
|
map,
|
||||||
switchMap,
|
switchMap,
|
||||||
@@ -1016,16 +1017,15 @@ export class DocumentDetailComponent
|
|||||||
.pipe(
|
.pipe(
|
||||||
first(),
|
first(),
|
||||||
takeUntil(this.unsubscribeNotifier),
|
takeUntil(this.unsubscribeNotifier),
|
||||||
takeUntil(this.docChangeNotifier)
|
takeUntil(this.docChangeNotifier),
|
||||||
|
finalize(() => this.suggestionsLoading.set(false))
|
||||||
)
|
)
|
||||||
.subscribe({
|
.subscribe({
|
||||||
next: (result) => {
|
next: (result) => {
|
||||||
this.suggestions.set(result)
|
this.suggestions.set(result)
|
||||||
this.suggestionsLoading.set(false)
|
|
||||||
},
|
},
|
||||||
error: (error) => {
|
error: (error) => {
|
||||||
this.suggestions.set(null)
|
this.suggestions.set(null)
|
||||||
this.suggestionsLoading.set(false)
|
|
||||||
this.toastService.showError(
|
this.toastService.showError(
|
||||||
$localize`Error retrieving suggestions.`,
|
$localize`Error retrieving suggestions.`,
|
||||||
error
|
error
|
||||||
|
|||||||
@@ -114,13 +114,23 @@
|
|||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
<div ngbDropdownMenu aria-labelledby="dropdownSend" class="shadow">
|
<div ngbDropdownMenu aria-labelledby="dropdownSend" class="shadow">
|
||||||
|
@if (permissionService.currentUserCan(PermissionAction.Add, PermissionType.ShareLinkBundle)) {
|
||||||
<button ngbDropdownItem (click)="createShareLinkBundle()" [disabled]="!canSendSelection">
|
<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>
|
<i-bs name="link" class="me-1"></i-bs><ng-container i18n>Create a share link bundle</ng-container>
|
||||||
</button>
|
</button>
|
||||||
|
}
|
||||||
|
@if (permissionService.currentUserCan(PermissionAction.View, PermissionType.ShareLinkBundle)) {
|
||||||
<button ngbDropdownItem (click)="manageShareLinkBundles()">
|
<button ngbDropdownItem (click)="manageShareLinkBundles()">
|
||||||
<i-bs name="list-ul" class="me-1"></i-bs><ng-container i18n>Manage share link bundles</ng-container>
|
<i-bs name="list-ul" class="me-1"></i-bs><ng-container i18n>Manage share link bundles</ng-container>
|
||||||
</button>
|
</button>
|
||||||
|
}
|
||||||
|
@if (
|
||||||
|
emailEnabled &&
|
||||||
|
(permissionService.currentUserCan(PermissionAction.Add, PermissionType.ShareLinkBundle) ||
|
||||||
|
permissionService.currentUserCan(PermissionAction.View, PermissionType.ShareLinkBundle))
|
||||||
|
) {
|
||||||
<div class="dropdown-divider"></div>
|
<div class="dropdown-divider"></div>
|
||||||
|
}
|
||||||
@if (emailEnabled) {
|
@if (emailEnabled) {
|
||||||
<button ngbDropdownItem (click)="emailSelected()" [disabled]="!canSendSelection">
|
<button ngbDropdownItem (click)="emailSelected()" [disabled]="!canSendSelection">
|
||||||
<i-bs name="envelope" class="me-1"></i-bs><ng-container i18n>Email</ng-container>
|
<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 { Tag } from 'src/app/data/tag'
|
||||||
import { FilterPipe } from 'src/app/pipes/filter.pipe'
|
import { FilterPipe } from 'src/app/pipes/filter.pipe'
|
||||||
import { DocumentListViewService } from 'src/app/services/document-list-view.service'
|
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 { CorrespondentService } from 'src/app/services/rest/correspondent.service'
|
||||||
import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
|
import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
|
||||||
import { DocumentTypeService } from 'src/app/services/rest/document-type.service'
|
import { DocumentTypeService } from 'src/app/services/rest/document-type.service'
|
||||||
@@ -252,6 +256,54 @@ describe('BulkEditorComponent', () => {
|
|||||||
).toBe(true)
|
).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', () => {
|
it('should apply selection data to correspondents menu', () => {
|
||||||
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
|
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
|
|||||||
@@ -101,7 +101,7 @@ export class BulkEditorComponent
|
|||||||
private toastService = inject(ToastService)
|
private toastService = inject(ToastService)
|
||||||
private storagePathService = inject(StoragePathService)
|
private storagePathService = inject(StoragePathService)
|
||||||
private customFieldService = inject(CustomFieldsService)
|
private customFieldService = inject(CustomFieldsService)
|
||||||
private permissionService = inject(PermissionsService)
|
public readonly permissionService = inject(PermissionsService)
|
||||||
private savedViewService = inject(SavedViewService)
|
private savedViewService = inject(SavedViewService)
|
||||||
private readonly shareLinkBundleService = inject(ShareLinkBundleService)
|
private readonly shareLinkBundleService = inject(ShareLinkBundleService)
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="d-none d-sm-flex flex-fill me-3">
|
<div class="d-none d-sm-flex flex-fill me-3">
|
||||||
<div class="input-group input-group-sm">
|
<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>
|
||||||
<div class="btn-group btn-group-sm flex-nowrap">
|
<div class="btn-group btn-group-sm flex-nowrap">
|
||||||
@if (list.hasSelection) {
|
@if (list.hasSelection) {
|
||||||
|
|||||||
@@ -61,7 +61,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="col">
|
<div class="col">
|
||||||
<label class="form-label" for="display_mode_{{view.id}}" i18n>Display as</label>
|
<label class="form-label" for="display_mode_{{view.id}}" i18n>Display as</label>
|
||||||
<select class="form-select" formControlName="display_mode">
|
<select class="form-select form-control" formControlName="display_mode">
|
||||||
<option [ngValue]="DisplayMode.TABLE" i18n>Table</option>
|
<option [ngValue]="DisplayMode.TABLE" i18n>Table</option>
|
||||||
<option [ngValue]="DisplayMode.SMALL_CARDS" i18n>Small Cards</option>
|
<option [ngValue]="DisplayMode.SMALL_CARDS" i18n>Small Cards</option>
|
||||||
<option [ngValue]="DisplayMode.LARGE_CARDS" i18n>Large Cards</option>
|
<option [ngValue]="DisplayMode.LARGE_CARDS" i18n>Large Cards</option>
|
||||||
|
|||||||