Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
74abdf8ff8 | ||
|
|
ce5d5c33b0 | ||
|
|
d9d44520bd | ||
|
|
500ff563cf | ||
|
|
7f314b5149 | ||
|
|
132918821d | ||
|
|
512a3fe196 | ||
|
|
b01e0368b7 | ||
|
|
1c82bd15c5 | ||
|
|
cbac71c165 | ||
|
|
8780bcd5c7 | ||
|
|
9d2416c435 | ||
|
|
a98d0669e4 | ||
|
|
bda506968b | ||
|
|
4ccb34a70b | ||
|
|
0a466c9fcf | ||
|
|
c9cc4f427d |
@@ -185,16 +185,22 @@ 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
|
name: "E2E Tests (${{ matrix.shard-index }}/${{ matrix.shard-count }})"
|
||||||
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.1-noble
|
container: mcr.microsoft.com/playwright:v1.62.0-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
|
||||||
@@ -210,17 +216,6 @@ 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:
|
||||||
@@ -230,17 +225,8 @@ 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
|
run: cd src-ui && pnpm exec playwright test --shard ${{ matrix.shard-index }}/${{ matrix.shard-count }}
|
||||||
frontend-build:
|
frontend-build:
|
||||||
name: Frontend Build
|
name: Frontend Build
|
||||||
needs: [changes, unit-tests, e2e-tests]
|
needs: [changes, unit-tests, e2e-tests]
|
||||||
|
|||||||
@@ -59,13 +59,10 @@ 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
|
||||||
- pickle deserialization of internal data from trusted components such as the Redis-compatible broker or Paperless-ngx data directory
|
- 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
|
||||||
- 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
|
||||||
|
|||||||
@@ -138,9 +138,7 @@ for suggested generation and embedding models.
|
|||||||
With AI enabled, Paperless-ngx can suggest a title, tags, correspondent, document type,
|
With AI enabled, Paperless-ngx can suggest a title, tags, correspondent, document type,
|
||||||
storage path and dates by sending the document to the LLM. This is **opt-in per request**
|
storage path and dates by sending the document to the LLM. This is **opt-in per request**
|
||||||
and surfaces through the "Suggest" control on the document detail page, alongside the
|
and surfaces through the "Suggest" control on the document detail page, alongside the
|
||||||
classic classifier-based suggestions — it does not disable them. Suggestions are requested
|
classic classifier-based suggestions — it does not disable them. Suggestion output
|
||||||
automatically when you open a document that carries an inbox tag unless "Automatically request
|
|
||||||
suggestions for inbox documents" under Settings > Documents is disabled. Suggestion output
|
|
||||||
language can be steered with
|
language can be steered with
|
||||||
[`PAPERLESS_AI_LLM_OUTPUT_LANGUAGE`](configuration.md#PAPERLESS_AI_LLM_OUTPUT_LANGUAGE)
|
[`PAPERLESS_AI_LLM_OUTPUT_LANGUAGE`](configuration.md#PAPERLESS_AI_LLM_OUTPUT_LANGUAGE)
|
||||||
(otherwise it follows the user's UI language).
|
(otherwise it follows the user's UI language).
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 1.4 MiB After Width: | Height: | Size: 1.8 MiB |
|
Before Width: | Height: | Size: 487 KiB After Width: | Height: | Size: 501 KiB |
|
Before Width: | Height: | Size: 34 KiB After Width: | Height: | Size: 21 KiB |
|
Before Width: | Height: | Size: 558 KiB After Width: | Height: | Size: 2.2 MiB |
|
Before Width: | Height: | Size: 1.1 MiB After Width: | Height: | Size: 644 KiB |
|
Before Width: | Height: | Size: 1.4 MiB After Width: | Height: | Size: 667 KiB |
|
Before Width: | Height: | Size: 1.2 MiB After Width: | Height: | Size: 1003 KiB |
|
Before Width: | Height: | Size: 1.4 MiB After Width: | Height: | Size: 1.7 MiB |
|
Before Width: | Height: | Size: 1.5 MiB After Width: | Height: | Size: 2.1 MiB |
|
Before Width: | Height: | Size: 1.4 MiB After Width: | Height: | Size: 1.8 MiB |
|
Before Width: | Height: | Size: 972 KiB After Width: | Height: | Size: 925 KiB |
|
Before Width: | Height: | Size: 1.4 MiB After Width: | Height: | Size: 1.7 MiB |
|
Before Width: | Height: | Size: 1.4 MiB After Width: | Height: | Size: 1.8 MiB |
|
Before Width: | Height: | Size: 558 KiB After Width: | Height: | Size: 2.3 MiB |
|
Before Width: | Height: | Size: 1.3 MiB After Width: | Height: | Size: 726 KiB |
|
Before Width: | Height: | Size: 294 KiB After Width: | Height: | Size: 169 KiB |
|
Before Width: | Height: | Size: 298 KiB After Width: | Height: | Size: 432 KiB |
|
Before Width: | Height: | Size: 322 KiB After Width: | Height: | Size: 280 KiB |
|
Before Width: | Height: | Size: 205 KiB After Width: | Height: | Size: 246 KiB |
|
Before Width: | Height: | Size: 43 KiB After Width: | Height: | Size: 27 KiB |
|
Before Width: | Height: | Size: 44 KiB After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 57 KiB After Width: | Height: | Size: 48 KiB |
|
Before Width: | Height: | Size: 65 KiB After Width: | Height: | Size: 45 KiB |
|
Before Width: | Height: | Size: 516 KiB After Width: | Height: | Size: 559 KiB |
|
Before Width: | Height: | Size: 230 KiB After Width: | Height: | Size: 116 KiB |
|
Before Width: | Height: | Size: 333 KiB After Width: | Height: | Size: 87 KiB |
|
Before Width: | Height: | Size: 1.2 MiB After Width: | Height: | Size: 792 KiB |
|
Before Width: | Height: | Size: 291 KiB After Width: | Height: | Size: 137 KiB |
@@ -1,277 +1,5 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
## paperless-ngx 3.1.2
|
|
||||||
|
|
||||||
### Bug Fixes
|
|
||||||
|
|
||||||
- Fix: fix dark mode select disabled color, ensure disabled cursor on display mode dropdown [@shamoon](https://github.com/shamoon) ([#13881](https://github.com/paperless-ngx/paperless-ngx/pull/13881))
|
|
||||||
- Fix: add disable to the drag-drop list component [@shamoon](https://github.com/shamoon) ([#13880](https://github.com/paperless-ngx/paperless-ngx/pull/13880))
|
|
||||||
|
|
||||||
### Documentation
|
|
||||||
|
|
||||||
- Chore: update screenshots for v3+ [@shamoon](https://github.com/shamoon) ([#13883](https://github.com/paperless-ngx/paperless-ngx/pull/13883))
|
|
||||||
|
|
||||||
### All App Changes
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary>2 changes</summary>
|
|
||||||
|
|
||||||
- Fix: fix dark mode select disabled color, ensure disabled cursor on display mode dropdown [@shamoon](https://github.com/shamoon) ([#13881](https://github.com/paperless-ngx/paperless-ngx/pull/13881))
|
|
||||||
- Fix: add disable to the drag-drop list component [@shamoon](https://github.com/shamoon) ([#13880](https://github.com/paperless-ngx/paperless-ngx/pull/13880))
|
|
||||||
|
|
||||||
</details>
|
|
||||||
|
|
||||||
## paperless-ngx 3.1.1
|
|
||||||
|
|
||||||
### Bug Fixes
|
|
||||||
|
|
||||||
- Fix: 3.1.0 llm suggestions remove existing metadata from prompt, dont drop name suggestions [@shamoon](https://github.com/shamoon) ([#13866](https://github.com/paperless-ngx/paperless-ngx/pull/13866))
|
|
||||||
- Fix: set global search earlier to avoid awaiting debounce [@shamoon](https://github.com/shamoon) ([#13865](https://github.com/paperless-ngx/paperless-ngx/pull/13865))
|
|
||||||
- Fix: responsive sidebar, centralize and make sizes saner [@shamoon](https://github.com/shamoon) ([#13863](https://github.com/paperless-ngx/paperless-ngx/pull/13863))
|
|
||||||
- Tweak/fix: show existing count for ai suggestions [@shamoon](https://github.com/shamoon) ([#13861](https://github.com/paperless-ngx/paperless-ngx/pull/13861))
|
|
||||||
- Fix: 3.1.0 llm suggestions simplify schema, fix docstrings [@shamoon](https://github.com/shamoon) ([#13850](https://github.com/paperless-ngx/paperless-ngx/pull/13850))
|
|
||||||
- Fix: ensure ui reset of suggestionsLoading when changing docs [@shamoon](https://github.com/shamoon) ([#13840](https://github.com/paperless-ngx/paperless-ngx/pull/13840))
|
|
||||||
- Fix: always pass a non-empty api key for OpenAI-like servers [@shamoon](https://github.com/shamoon) ([#13838](https://github.com/paperless-ngx/paperless-ngx/pull/13838))
|
|
||||||
- Fix: hide slim sidebar scrollbar in browsers with stupid scrollbars [@shamoon](https://github.com/shamoon) ([#13837](https://github.com/paperless-ngx/paperless-ngx/pull/13837))
|
|
||||||
- Fix: correct sharelink bundle + document link permissions display bugs [@shamoon](https://github.com/shamoon) ([#13827](https://github.com/paperless-ngx/paperless-ngx/pull/13827))
|
|
||||||
- Fix: immediately re-add doc to index after trash restore [@shamoon](https://github.com/shamoon) ([#13818](https://github.com/paperless-ngx/paperless-ngx/pull/13818))
|
|
||||||
- Fix: navbar brand anchor size + Safari position jitter [@shamoon](https://github.com/shamoon) ([#13810](https://github.com/paperless-ngx/paperless-ngx/pull/13810))
|
|
||||||
|
|
||||||
### All App Changes
|
|
||||||
|
|
||||||
<details>
|
|
||||||
<summary>12 changes</summary>
|
|
||||||
|
|
||||||
- Fix: 3.1.0 llm suggestions remove existing metadata from prompt, dont drop name suggestions [@shamoon](https://github.com/shamoon) ([#13866](https://github.com/paperless-ngx/paperless-ngx/pull/13866))
|
|
||||||
- Fix: set global search earlier to avoid awaiting debounce [@shamoon](https://github.com/shamoon) ([#13865](https://github.com/paperless-ngx/paperless-ngx/pull/13865))
|
|
||||||
- Fix: responsive sidebar, centralize and make sizes saner [@shamoon](https://github.com/shamoon) ([#13863](https://github.com/paperless-ngx/paperless-ngx/pull/13863))
|
|
||||||
- Tweak/fix: show existing count for ai suggestions [@shamoon](https://github.com/shamoon) ([#13861](https://github.com/paperless-ngx/paperless-ngx/pull/13861))
|
|
||||||
- Fix: 3.1.0 llm suggestions simplify schema, fix docstrings [@shamoon](https://github.com/shamoon) ([#13850](https://github.com/paperless-ngx/paperless-ngx/pull/13850))
|
|
||||||
- Fixhancement: make imap port required, better error display [@shamoon](https://github.com/shamoon) ([#13845](https://github.com/paperless-ngx/paperless-ngx/pull/13845))
|
|
||||||
- Fix: ensure ui reset of suggestionsLoading when changing docs [@shamoon](https://github.com/shamoon) ([#13840](https://github.com/paperless-ngx/paperless-ngx/pull/13840))
|
|
||||||
- Fix: always pass a non-empty api key for OpenAI-like servers [@shamoon](https://github.com/shamoon) ([#13838](https://github.com/paperless-ngx/paperless-ngx/pull/13838))
|
|
||||||
- Fix: hide slim sidebar scrollbar in browsers with stupid scrollbars [@shamoon](https://github.com/shamoon) ([#13837](https://github.com/paperless-ngx/paperless-ngx/pull/13837))
|
|
||||||
- Fix: correct sharelink bundle + document link permissions display bugs [@shamoon](https://github.com/shamoon) ([#13827](https://github.com/paperless-ngx/paperless-ngx/pull/13827))
|
|
||||||
- Fix: immediately re-add doc to index after trash restore [@shamoon](https://github.com/shamoon) ([#13818](https://github.com/paperless-ngx/paperless-ngx/pull/13818))
|
|
||||||
- Fix: navbar brand anchor size + Safari position jitter [@shamoon](https://github.com/shamoon) ([#13810](https://github.com/paperless-ngx/paperless-ngx/pull/13810))
|
|
||||||
|
|
||||||
</details>
|
|
||||||
|
|
||||||
## paperless-ngx 3.1.0
|
|
||||||
|
|
||||||
### Features / Enhancements
|
|
||||||
|
|
||||||
- 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,12 +8,6 @@ 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`.
|
||||||
@@ -1131,10 +1125,6 @@ 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:
|
||||||
|
|
||||||
@@ -2088,12 +2078,6 @@ password. All of these options come from their similarly-named [Django settings]
|
|||||||
|
|
||||||
Defaults to "always".
|
Defaults to "always".
|
||||||
|
|
||||||
#### [`PAPERLESS_REMOTE_OCR_ALLOW_INTERNAL_ENDPOINTS=<bool>`](#PAPERLESS_REMOTE_OCR_ALLOW_INTERNAL_ENDPOINTS) {#PAPERLESS_REMOTE_OCR_ALLOW_INTERNAL_ENDPOINTS}
|
|
||||||
|
|
||||||
: If set to false, Paperless blocks remote OCR endpoint URLs that resolve to non-public addresses (e.g., localhost, etc).
|
|
||||||
|
|
||||||
Defaults to True.
|
|
||||||
|
|
||||||
## AI {#ai}
|
## AI {#ai}
|
||||||
|
|
||||||
#### [`PAPERLESS_AI_ENABLED=<bool>`](#PAPERLESS_AI_ENABLED) {#PAPERLESS_AI_ENABLED}
|
#### [`PAPERLESS_AI_ENABLED=<bool>`](#PAPERLESS_AI_ENABLED) {#PAPERLESS_AI_ENABLED}
|
||||||
|
|||||||
@@ -224,19 +224,13 @@ respectively, can be run non-interactively with:
|
|||||||
|
|
||||||
```bash
|
```bash
|
||||||
pnpm ng test
|
pnpm ng test
|
||||||
pnpm e2e
|
pnpm playwright test
|
||||||
```
|
```
|
||||||
|
|
||||||
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 e2e:ui
|
pnpm playwright test --ui
|
||||||
```
|
```
|
||||||
|
|
||||||
### Building the frontend
|
### Building the frontend
|
||||||
|
|||||||
@@ -317,8 +317,6 @@ a "document already exists" message.
|
|||||||
|
|
||||||
Paperless-ngx can suggest tags, correspondents, document types and storage paths for documents based on the content of the document. This is done using a (non-LLM) machine learning model that is trained on the documents in your database. The suggestions are shown in the document detail page and can be accepted or rejected by the user.
|
Paperless-ngx can suggest tags, correspondents, document types and storage paths for documents based on the content of the document. This is done using a (non-LLM) machine learning model that is trained on the documents in your database. The suggestions are shown in the document detail page and can be accepted or rejected by the user.
|
||||||
|
|
||||||
Suggestions are requested automatically when you open a document that still has an inbox tag. To only request them by pressing the "Suggest" button instead, turn off "Automatically request suggestions for inbox documents" under Settings > Documents.
|
|
||||||
|
|
||||||
## AI Features
|
## AI Features
|
||||||
|
|
||||||
Paperless-ngx includes several features that use AI to enhance the document management experience. These features are optional and can be enabled or disabled in the settings. If you are using the AI features, you may want to also enable the "LLM index" feature, which supports Retrieval-Augmented Generation (RAG) designed to improve the quality of AI responses. The LLM index feature is not enabled by default and requires additional configuration.
|
Paperless-ngx includes several features that use AI to enhance the document management experience. These features are optional and can be enabled or disabled in the settings. If you are using the AI features, you may want to also enable the "LLM index" feature, which supports Retrieval-Augmented Generation (RAG) designed to improve the quality of AI responses. The LLM index feature is not enabled by default and requires additional configuration.
|
||||||
@@ -429,27 +427,27 @@ Global permissions define what areas of the app and API endpoints users can acce
|
|||||||
determine if a user can create, edit, delete or view _any_ documents, but individual documents themselves
|
determine if a user can create, edit, delete or view _any_ documents, but individual documents themselves
|
||||||
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.<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. |
|
| AppConfig | _Change_ or higher permissions grants access to the "Application Configuration" area. |
|
||||||
| 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. |
|
||||||
| DocumentType | Add, edit, delete or view Document Types. |
|
| DocumentType | Add, edit, delete or view Document Types. |
|
||||||
| Group | Add, edit, delete or view Groups. |
|
| Group | Add, edit, delete or view Groups. |
|
||||||
| GlobalStatistics | View aggregate object counts and statistics. This does not grant access to view individual documents. |
|
| GlobalStatistics | View aggregate object counts and statistics. This does not grant access to view individual documents. |
|
||||||
| MailAccount | Add, edit, delete or view Mail Accounts. |
|
| MailAccount | Add, edit, delete or view Mail Accounts. |
|
||||||
| MailRule | Add, edit, delete or view Mail Rules. |
|
| MailRule | Add, edit, delete or view Mail Rules. |
|
||||||
| Note | Add, edit, delete or view Notes. |
|
| Note | Add, edit, delete or view Notes. |
|
||||||
| PaperlessTask | View or dismiss (_Change_) File Tasks. |
|
| PaperlessTask | View or dismiss (_Change_) File Tasks. |
|
||||||
| SavedView | Add, edit, delete or view Saved Views. |
|
| SavedView | Add, edit, delete or view Saved Views. |
|
||||||
| ShareLink | Add, delete or view Share Links. |
|
| ShareLink | Add, delete or view Share Links. |
|
||||||
| StoragePath | Add, edit, delete or view Storage Paths. |
|
| StoragePath | Add, edit, delete or view Storage Paths. |
|
||||||
| SystemMonitoring | View the system status dialog, tasks summary and their API endpoints. Admin users also retain system status access. |
|
| SystemMonitoring | View the system status dialog, tasks summary and their API endpoints. Admin users also retain system status access. |
|
||||||
| Tag | Add, edit, delete or view Tags. |
|
| Tag | Add, edit, delete or view Tags. |
|
||||||
| UISettings | Add, edit, delete or view the UI settings that are used by the web app.<br/>:warning: **Users that will access the web UI must be granted at least _View_ permissions.** |
|
| UISettings | Add, edit, delete or view the UI settings that are used by the web app.<br/>:warning: **Users that will access the web UI must be granted at least _View_ permissions.** |
|
||||||
| User | Add, edit, delete or view other user accounts via Settings > Users & Groups and `/api/users/`. These permissions are not needed for users to edit their own profile via "My Profile" or `/api/profile/`. |
|
| User | Add, edit, delete or view other user accounts via Settings > Users & Groups and `/api/users/`. These permissions are not needed for users to edit their own profile via "My Profile" or `/api/profile/`. |
|
||||||
| Workflow | Add, edit, delete or view Workflows.<br/>Note that Workflows are global; all users who can access workflows see the same set. Workflows have other permission implications — see [Workflow permissions](#workflow-permissions). |
|
| Workflow | Add, edit, delete or view Workflows.<br/>Note that Workflows are global; all users who can access workflows see the same set. Workflows have other permission implications — see [Workflow permissions](#workflow-permissions). |
|
||||||
|
|
||||||
#### Detailed Explanation of Object Permissions {#object-permissions}
|
#### Detailed Explanation of Object Permissions {#object-permissions}
|
||||||
|
|
||||||
@@ -669,35 +667,6 @@ 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.1.2"
|
version = "3.0.5"
|
||||||
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"
|
||||||
@@ -40,14 +40,13 @@ dependencies = [
|
|||||||
"djangorestframework~=3.16",
|
"djangorestframework~=3.16",
|
||||||
"drf-spectacular~=0.30",
|
"drf-spectacular~=0.30",
|
||||||
"drf-spectacular-sidecar~=2026.7.1",
|
"drf-spectacular-sidecar~=2026.7.1",
|
||||||
"drf-writable-nested~=0.7.1",
|
|
||||||
"filelock~=3.32.0",
|
"filelock~=3.32.0",
|
||||||
"flower~=2.0.1",
|
"flower~=2.0.1",
|
||||||
"gotenberg-client~=0.14.0",
|
"gotenberg-client~=0.14.0",
|
||||||
"httpx-oauth~=0.17",
|
"httpx-oauth~=0.17",
|
||||||
"ijson>=3.5.1",
|
"ijson>=3.5.1",
|
||||||
"imap-tools~=1.14.0",
|
"imap-tools~=1.14.0",
|
||||||
"jinja2~=3.1.6",
|
"jinja2~=3.1.5",
|
||||||
"langdetect~=1.0.9",
|
"langdetect~=1.0.9",
|
||||||
"llama-index-core>=0.14.23",
|
"llama-index-core>=0.14.23",
|
||||||
"llama-index-embeddings-huggingface>=0.6.1",
|
"llama-index-embeddings-huggingface>=0.6.1",
|
||||||
@@ -247,7 +246,7 @@ per-file-ignores."src/documents/models.py" = [
|
|||||||
isort.force-single-line = true
|
isort.force-single-line = true
|
||||||
|
|
||||||
[tool.codespell]
|
[tool.codespell]
|
||||||
ignore-words-list = "criterias,afterall,valeu,ureue,equest,ure,assertIn,Oktober,commitish,NIN,nin"
|
ignore-words-list = "criterias,afterall,valeu,ureue,equest,ure,assertIn,Oktober,commitish"
|
||||||
skip = """\
|
skip = """\
|
||||||
src-ui/src/locale/*,src-ui/pnpm-lock.yaml,src-ui/e2e/*,src/paperless_mail/tests/samples/*,src/paperless/tests/samples\
|
src-ui/src/locale/*,src-ui/pnpm-lock.yaml,src-ui/e2e/*,src/paperless_mail/tests/samples/*,src/paperless/tests/samples\
|
||||||
/mail/*,src/documents/tests/samples/*,*.po,*.json\
|
/mail/*,src/documents/tests/samples/*,*.po,*.json\
|
||||||
|
|||||||
@@ -139,17 +139,6 @@
|
|||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
"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"
|
||||||
@@ -166,9 +155,6 @@
|
|||||||
"configurations": {
|
"configurations": {
|
||||||
"production": {
|
"production": {
|
||||||
"buildTarget": "paperless-ui:build:production"
|
"buildTarget": "paperless-ui:build:production"
|
||||||
},
|
|
||||||
"e2e": {
|
|
||||||
"buildTarget": "paperless-ui:build:e2e"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,194 @@
|
|||||||
|
{
|
||||||
|
"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,25 +1,12 @@
|
|||||||
import { expect, test } from '@playwright/test'
|
import { expect, test } from '@playwright/test'
|
||||||
|
import path from 'node:path'
|
||||||
|
|
||||||
test.beforeEach(async ({ page }) => {
|
const REQUESTS_HAR = path.join(__dirname, 'requests/api-settings.har')
|
||||||
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()
|
||||||
@@ -28,6 +15,7 @@ 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()
|
||||||
@@ -39,6 +27,7 @@ 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()
|
||||||
|
|||||||
@@ -1,237 +0,0 @@
|
|||||||
"""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,13 +1,21 @@
|
|||||||
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=1/)
|
await expect(page).toHaveURL(/tags__id__in=9/)
|
||||||
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/)
|
||||||
@@ -16,6 +24,7 @@ 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')
|
||||||
@@ -23,11 +32,12 @@ 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\/1/)
|
await expect(page).toHaveURL(/view\/7/)
|
||||||
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')
|
||||||
@@ -36,10 +46,11 @@ 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\/1\/details/)
|
await expect(page).toHaveURL(/documents\/310\/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,9 +1,14 @@
|
|||||||
import { expect, test } from '@playwright/test'
|
import { expect, test, type WebSocketRoute } from '@playwright/test'
|
||||||
|
import path from 'node:path'
|
||||||
|
|
||||||
|
const REQUESTS_HAR = path.join(__dirname, 'requests/api-document-detail.har')
|
||||||
|
const REQUESTS_HAR2 = path.join(__dirname, 'requests/api-document-detail2.har')
|
||||||
|
|
||||||
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.goto('/documents/1/')
|
await page.routeFromHAR(REQUESTS_HAR, { notFound: 'fallback' })
|
||||||
|
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+/
|
||||||
@@ -14,7 +19,8 @@ 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.goto('/documents/1/')
|
await page.routeFromHAR(REQUESTS_HAR, { notFound: 'fallback' })
|
||||||
|
await page.goto('/documents/175/')
|
||||||
await expect(page.getByTitle('Correspondent', { exact: true })).toHaveText(
|
await expect(page.getByTitle('Correspondent', { exact: true })).toHaveText(
|
||||||
/\w+/
|
/\w+/
|
||||||
)
|
)
|
||||||
@@ -32,27 +38,28 @@ 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.goto('/documents/1/details')
|
await page.routeFromHAR(REQUESTS_HAR, { notFound: 'fallback' })
|
||||||
|
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/1/content')
|
await page.goto('/documents/175/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/1/metadata')
|
await page.goto('/documents/175/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/1/notes')
|
await page.goto('/documents/175/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/1/permissions')
|
await page.goto('/documents/175/permissions')
|
||||||
await expect(page.getByRole('tab', { name: 'Permissions' })).toHaveAttribute(
|
await expect(page.getByRole('tab', { name: 'Permissions' })).toHaveAttribute(
|
||||||
'aria-selected',
|
'aria-selected',
|
||||||
'true'
|
'true'
|
||||||
@@ -60,7 +67,8 @@ 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.goto('/documents/1/')
|
await page.routeFromHAR(REQUESTS_HAR, { notFound: 'fallback' })
|
||||||
|
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()
|
||||||
@@ -68,7 +76,8 @@ 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.goto('/documents/1/notes')
|
await page.routeFromHAR(REQUESTS_HAR, { notFound: 'fallback' })
|
||||||
|
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', {
|
||||||
@@ -79,25 +88,32 @@ 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.goto('/documents/1/details')
|
await page.routeFromHAR(REQUESTS_HAR2, { notFound: 'fallback' })
|
||||||
|
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(
|
await expect(page).toHaveURL(/tags__id__all=4&sort=created&reverse=1&page=1/)
|
||||||
/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/1/preview/')) {
|
if (request.url().includes('/api/documents/175/preview/')) {
|
||||||
previewRequestCount++
|
previewRequestCount++
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
await page.goto('/documents/1/details')
|
await page.goto('/documents/175/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(
|
||||||
@@ -112,37 +128,27 @@ 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
|
||||||
|
|
||||||
await expect
|
const statusSocket = await statusSocketReady
|
||||||
.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/1/?full_perms=true') &&
|
response.url().includes('/api/documents/175/?full_perms=true') &&
|
||||||
response.request().method() === 'GET'
|
response.request().method() === 'GET'
|
||||||
)
|
)
|
||||||
await page.evaluate(() => {
|
statusSocket.send(
|
||||||
const detail = document.querySelector('pngx-document-detail')
|
JSON.stringify({
|
||||||
const component = (window as any).ng.getComponent(detail)
|
type: 'document_updated',
|
||||||
component.handleIncomingDocumentUpdated({
|
data: {
|
||||||
document_id: 1,
|
document_id: 175,
|
||||||
modified: '2099-07-26T20:00:00Z',
|
modified: '2026-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)
|
.toBeGreaterThan(previewRequestsBeforeReload + 1)
|
||||||
await expect
|
|
||||||
.poll(() =>
|
|
||||||
page.evaluate(() => {
|
|
||||||
const detail = document.querySelector('pngx-document-detail')
|
|
||||||
return (window as any).ng.getComponent(detail).previewLoaded()
|
|
||||||
})
|
|
||||||
)
|
|
||||||
.toBe(true)
|
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,10 +1,19 @@
|
|||||||
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=1/)
|
await expect(page).toHaveURL(/tags__id__all=9/)
|
||||||
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()
|
||||||
@@ -14,27 +23,28 @@ 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=(?:1,2|2,1)/)
|
await expect(page).toHaveURL(/correspondent__id__in=12,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=(?:1,2|2,1)/)
|
await expect(page).toHaveURL(/correspondent__id__none=12,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=1/)
|
await expect(page).toHaveURL(/storage_path__id__in=5/)
|
||||||
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')
|
||||||
@@ -47,7 +57,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(/32 documents/)
|
await expect(page.locator('pngx-document-list')).toHaveText(/26 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')
|
||||||
@@ -70,6 +80,7 @@ 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()
|
||||||
@@ -83,14 +94,15 @@ 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()
|
||||||
const createdFrom = page.getByRole('textbox', { name: 'mm/dd/yyyy' }).first()
|
await page.getByRole('combobox', { name: 'Select month' }).selectOption('12')
|
||||||
await createdFrom.fill('12/11/2022')
|
await page.getByRole('combobox', { name: 'Select year' }).selectOption('2022')
|
||||||
await createdFrom.press('Enter')
|
await page.getByText('11', { exact: true }).click()
|
||||||
await page.getByRole('button', { name: 'Title & content' }).click()
|
await page.getByRole('button', { name: 'Title & content' }).click()
|
||||||
await expect(page.locator('pngx-document-list')).toHaveText(/3 documents/)
|
await expect(page.locator('pngx-document-list')).toHaveText(/2 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()
|
||||||
@@ -128,6 +140,7 @@ 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()
|
||||||
@@ -138,18 +151,12 @@ test('change views', async ({ page }) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test('bulk edit', async ({ page }) => {
|
test('bulk edit', async ({ page }) => {
|
||||||
const uiSettingsLoaded = page.waitForResponse(
|
await page.routeFromHAR(REQUESTS_HAR6, { notFound: 'fallback' })
|
||||||
(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 .doc-img-container')
|
.locator('pngx-document-card-small')
|
||||||
.nth(0)
|
|
||||||
.click()
|
|
||||||
await page
|
|
||||||
.locator('pngx-document-card-small .doc-img-container')
|
|
||||||
.nth(3)
|
.nth(3)
|
||||||
.click({
|
.click({
|
||||||
modifiers: ['Shift'],
|
modifiers: ['Shift'],
|
||||||
@@ -169,14 +176,8 @@ test('bulk edit', async ({ page }) => {
|
|||||||
)
|
)
|
||||||
await page.getByRole('button', { name: 'None' }).click()
|
await page.getByRole('button', { name: 'None' }).click()
|
||||||
|
|
||||||
await page
|
await page.locator('pngx-document-card-small').nth(1).click()
|
||||||
.locator('pngx-document-card-small .doc-img-container')
|
await page.locator('pngx-document-card-small').nth(2).click()
|
||||||
.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
|
||||||
@@ -184,26 +185,15 @@ 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 = isValid && postData['parameters']['add_tags'].includes(5)
|
||||||
isValid &&
|
return request.url().toString().includes('bulk_edit') && 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,12 +1,10 @@
|
|||||||
import { expect, test } from '@playwright/test'
|
import { expect, test } from '@playwright/test'
|
||||||
|
import path from 'node:path'
|
||||||
|
|
||||||
test.use({
|
const REQUESTS_HAR = path.join(__dirname, 'requests/api-global-permissions.har')
|
||||||
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')
|
||||||
@@ -16,6 +14,7 @@ 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' })
|
||||||
@@ -31,6 +30,7 @@ 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,6 +42,7 @@ 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' })
|
||||||
@@ -53,6 +54,7 @@ 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' })
|
||||||
@@ -64,6 +66,7 @@ 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' })
|
||||||
@@ -75,6 +78,7 @@ 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')
|
||||||
@@ -84,6 +88,7 @@ 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')
|
||||||
|
|||||||
@@ -0,0 +1,353 @@
|
|||||||
|
{
|
||||||
|
"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,15 +1,11 @@
|
|||||||
{
|
{
|
||||||
"name": "paperless-ngx-ui",
|
"name": "paperless-ngx-ui",
|
||||||
"version": "3.1.2",
|
"version": "3.0.5",
|
||||||
"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,25 +14,17 @@ 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,
|
||||||
/* Keep parallelism modest for the shared SQLite backend. */
|
/* Opt out of parallel tests on CI. */
|
||||||
workers: 2,
|
workers: process.env.CI ? 1 : undefined,
|
||||||
/* 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 the disposable backend and local UI before starting the tests. */
|
/* Run your local dev server before starting the tests */
|
||||||
webServer: [
|
webServer: {
|
||||||
{
|
port,
|
||||||
url: 'http://localhost:8001/accounts/login/',
|
command: 'pnpm run start',
|
||||||
command: 'npm run e2e:backend',
|
reuseExistingServer: !process.env.CI,
|
||||||
reuseExistingServer: false,
|
timeout: 2 * 60 * 1000,
|
||||||
timeout: 2 * 60 * 1000,
|
},
|
||||||
},
|
|
||||||
{
|
|
||||||
port,
|
|
||||||
command: 'npm run start:e2e',
|
|
||||||
reuseExistingServer: !process.env.CI,
|
|
||||||
timeout: 2 * 60 * 1000,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
|
/* 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" autocomplete="off">
|
<form [formGroup]="configForm" (ngSubmit)="saveConfig()" class="pb-4">
|
||||||
|
|
||||||
<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]" autocomplete="new-password"></pngx-input-password> }
|
@case (ConfigOptionType.Password) { <pngx-input-password [formControlName]="option.key" [error]="errors[option.key]"></pngx-input-password> }
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
@if (option.note) {
|
@if (option.note) {
|
||||||
|
|||||||
@@ -237,12 +237,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="row">
|
|
||||||
<div class="col">
|
|
||||||
<pngx-input-check i18n-title title="Automatically request suggestions for inbox documents" i18n-hint hint="If un-checked, suggestions must be requested via the Suggest button." formControlName="documentEditingAutoSuggest"></pngx-input-check>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col">
|
<div class="col">
|
||||||
<pngx-input-check i18n-title title="Show document thumbnail during loading" formControlName="documentEditingOverlayThumbnail"></pngx-input-check>
|
<pngx-input-check i18n-title title="Show document thumbnail during loading" formControlName="documentEditingOverlayThumbnail"></pngx-input-check>
|
||||||
|
|||||||
@@ -267,7 +267,7 @@ describe('SettingsComponent', () => {
|
|||||||
expect(toastErrorSpy).toHaveBeenCalled()
|
expect(toastErrorSpy).toHaveBeenCalled()
|
||||||
expect(storeSpy).toHaveBeenCalled()
|
expect(storeSpy).toHaveBeenCalled()
|
||||||
expect(appearanceSettingsSpy).not.toHaveBeenCalled()
|
expect(appearanceSettingsSpy).not.toHaveBeenCalled()
|
||||||
expect(setSpy).toHaveBeenCalledTimes(33)
|
expect(setSpy).toHaveBeenCalledTimes(32)
|
||||||
|
|
||||||
// succeed
|
// succeed
|
||||||
storeSpy.mockReturnValueOnce(of(true))
|
storeSpy.mockReturnValueOnce(of(true))
|
||||||
|
|||||||
@@ -168,7 +168,6 @@ export class SettingsComponent
|
|||||||
pdfEditorDefaultEditMode: new FormControl(null),
|
pdfEditorDefaultEditMode: new FormControl(null),
|
||||||
documentEditingRemoveInboxTags: new FormControl(null),
|
documentEditingRemoveInboxTags: new FormControl(null),
|
||||||
documentEditingOverlayThumbnail: new FormControl(null),
|
documentEditingOverlayThumbnail: new FormControl(null),
|
||||||
documentEditingAutoSuggest: new FormControl(null),
|
|
||||||
documentDetailsHiddenFields: new FormControl([]),
|
documentDetailsHiddenFields: new FormControl([]),
|
||||||
searchDbOnly: new FormControl(null),
|
searchDbOnly: new FormControl(null),
|
||||||
searchLink: new FormControl(null),
|
searchLink: new FormControl(null),
|
||||||
@@ -369,9 +368,6 @@ export class SettingsComponent
|
|||||||
documentEditingOverlayThumbnail: this.settings.get(
|
documentEditingOverlayThumbnail: this.settings.get(
|
||||||
SETTINGS_KEYS.DOCUMENT_EDITING_OVERLAY_THUMBNAIL
|
SETTINGS_KEYS.DOCUMENT_EDITING_OVERLAY_THUMBNAIL
|
||||||
),
|
),
|
||||||
documentEditingAutoSuggest: this.settings.get(
|
|
||||||
SETTINGS_KEYS.DOCUMENT_EDITING_AUTO_SUGGEST
|
|
||||||
),
|
|
||||||
documentDetailsHiddenFields: this.settings.get(
|
documentDetailsHiddenFields: this.settings.get(
|
||||||
SETTINGS_KEYS.DOCUMENT_DETAILS_HIDDEN_FIELDS
|
SETTINGS_KEYS.DOCUMENT_DETAILS_HIDDEN_FIELDS
|
||||||
),
|
),
|
||||||
@@ -569,10 +565,6 @@ export class SettingsComponent
|
|||||||
SETTINGS_KEYS.DOCUMENT_EDITING_OVERLAY_THUMBNAIL,
|
SETTINGS_KEYS.DOCUMENT_EDITING_OVERLAY_THUMBNAIL,
|
||||||
this.settingsForm.value.documentEditingOverlayThumbnail
|
this.settingsForm.value.documentEditingOverlayThumbnail
|
||||||
)
|
)
|
||||||
this.settings.set(
|
|
||||||
SETTINGS_KEYS.DOCUMENT_EDITING_AUTO_SUGGEST,
|
|
||||||
this.settingsForm.value.documentEditingAutoSuggest
|
|
||||||
)
|
|
||||||
this.settings.set(
|
this.settings.set(
|
||||||
SETTINGS_KEYS.DOCUMENT_DETAILS_HIDDEN_FIELDS,
|
SETTINGS_KEYS.DOCUMENT_DETAILS_HIDDEN_FIELDS,
|
||||||
this.settingsForm.value.documentDetailsHiddenFields
|
this.settingsForm.value.documentDetailsHiddenFields
|
||||||
|
|||||||
@@ -41,8 +41,6 @@ export class TrashComponent
|
|||||||
private modalService = inject(NgbModal)
|
private modalService = inject(NgbModal)
|
||||||
private settingsService = inject(SettingsService)
|
private settingsService = inject(SettingsService)
|
||||||
private router = inject(Router)
|
private router = inject(Router)
|
||||||
private readonly emptyTrashDelaySetting =
|
|
||||||
this.settingsService.getSignal<number>(SETTINGS_KEYS.EMPTY_TRASH_DELAY)
|
|
||||||
|
|
||||||
readonly documentsInTrash = signal<Document[]>([])
|
readonly documentsInTrash = signal<Document[]>([])
|
||||||
readonly selectedDocuments = signal<Set<number>>(new Set())
|
readonly selectedDocuments = signal<Set<number>>(new Set())
|
||||||
@@ -202,7 +200,8 @@ export class TrashComponent
|
|||||||
}
|
}
|
||||||
|
|
||||||
getDaysRemaining(document: Document): number {
|
getDaysRemaining(document: Document): number {
|
||||||
const delay = this.emptyTrashDelaySetting()
|
this.settingsService.trackChanges()
|
||||||
|
const delay = this.settingsService.get(SETTINGS_KEYS.EMPTY_TRASH_DELAY)
|
||||||
const diff = new Date().getTime() - new Date(document.deleted_at).getTime()
|
const diff = new Date().getTime() - new Date(document.deleted_at).getTime()
|
||||||
const days = Math.ceil(diff / (1000 * 3600 * 24))
|
const days = Math.ceil(diff / (1000 * 3600 * 24))
|
||||||
return delay - days
|
return delay - days
|
||||||
|
|||||||
@@ -4,29 +4,31 @@
|
|||||||
(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>
|
||||||
<div class="navbar-brand-container order-sm-0">
|
<a class="navbar-brand d-flex align-items-center me-0 ps-md-3 py-0 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>
|
||||||
<pngx-brand-mark class="brand-mark brand-mark-slim d-none"></pngx-brand-mark>
|
<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">
|
||||||
|
<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 {
|
||||||
|
@if (customAppLogo) {
|
||||||
|
<img class="brand-logo" [src]="customAppLogo" alt="" />
|
||||||
} @else {
|
} @else {
|
||||||
@if (customAppLogo) {
|
<svg class="brand-mark" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1000 1000" width="1.5em" height="1.5em" fill="currentColor">
|
||||||
<img class="brand-logo" [src]="customAppLogo" alt="" />
|
<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"/>
|
||||||
} @else {
|
</svg>
|
||||||
<pngx-brand-mark class="brand-mark"></pngx-brand-mark>
|
|
||||||
}
|
|
||||||
<div class="brand-copy ms-2 text-truncate" [class.d-md-none]="slimSidebarEnabled">
|
|
||||||
<span class="brand-title text-truncate">{{ appTitle }}</span>
|
|
||||||
@if (customAppTitle) {
|
|
||||||
<span class="byline text-uppercase font-monospace" i18n>by Paperless-ngx</span>
|
|
||||||
}
|
|
||||||
</div>
|
|
||||||
}
|
}
|
||||||
</a>
|
<div class="brand-copy ms-2 text-truncate" [class.d-md-none]="slimSidebarEnabled">
|
||||||
</div>
|
<span class="brand-title text-truncate">{{ appTitle }}</span>
|
||||||
|
@if (customAppTitle) {
|
||||||
|
<span class="byline text-uppercase font-monospace" i18n>by Paperless-ngx</span>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</a>
|
||||||
<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">
|
||||||
@@ -39,7 +41,7 @@
|
|||||||
}
|
}
|
||||||
<pngx-toasts-dropdown></pngx-toasts-dropdown>
|
<pngx-toasts-dropdown></pngx-toasts-dropdown>
|
||||||
<li ngbDropdown class="nav-item dropdown">
|
<li ngbDropdown class="nav-item dropdown">
|
||||||
<button class="btn navbar-action border-0 me-1 me-md-2" id="userDropdown" ngbDropdownToggle aria-label="User menu" i18n-aria-label>
|
<button class="btn navbar-action border-0" id="userDropdown" ngbDropdownToggle aria-label="User menu" i18n-aria-label>
|
||||||
<i-bs width="1.3em" height="1.3em" name="person-circle"></i-bs>
|
<i-bs width="1.3em" height="1.3em" name="person-circle"></i-bs>
|
||||||
<span class="small ms-2 d-none d-sm-inline">
|
<span class="small ms-2 d-none d-sm-inline">
|
||||||
{{this.settingsService.displayName}}
|
{{this.settingsService.displayName}}
|
||||||
@@ -73,7 +75,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' : 'expanded'" [class.animating]="slimSidebarAnimating()"
|
[ngClass]="slimSidebarEnabled ? 'slim' : 'col-md-3 col-lg-2 col-xxxl-1'" [class.animating]="slimSidebarAnimating()"
|
||||||
[ngbCollapse]="isMenuCollapsed()">
|
[ngbCollapse]="isMenuCollapsed()">
|
||||||
@if (canSaveSettings) {
|
@if (canSaveSettings) {
|
||||||
<button class="btn btn-sm btn-dark sidebar-slim-toggler" (click)="toggleSlimSidebar()" [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>
|
||||||
@@ -90,7 +92,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 class="nav-link-label"><ng-container i18n>Dashboard</ng-container></span>
|
<i-bs class="me-2" name="house"></i-bs><span><ng-container i18n>Dashboard</ng-container></span>
|
||||||
</a>
|
</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 }">
|
||||||
@@ -99,7 +101,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 class="nav-link-label"><ng-container i18n>Documents</ng-container></span>
|
<i-bs class="me-2" name="files"></i-bs><span><ng-container i18n>Documents</ng-container></span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
@@ -111,7 +113,7 @@
|
|||||||
</h6>
|
</h6>
|
||||||
<ul class="nav flex-column mb-2" cdkDropList (cdkDropListDropped)="onDrop($event)">
|
<ul class="nav flex-column mb-2" cdkDropList (cdkDropListDropped)="onDrop($event)">
|
||||||
@for (view of savedViewService.sidebarViews; track view.id) {
|
@for (view of savedViewService.sidebarViews; track view.id) {
|
||||||
<li class="nav-item app-link" cdkDrag [cdkDragDisabled]="!settingsService.organizingSidebarSavedViews() || !canSaveSettings"
|
<li class="nav-item w-100 app-link" cdkDrag [cdkDragDisabled]="!settingsService.organizingSidebarSavedViews() || !canSaveSettings"
|
||||||
cdkDragPreviewContainer="parent" cdkDragPreviewClass="navItemDrag" (cdkDragStarted)="onDragStart($event)"
|
cdkDragPreviewContainer="parent" cdkDragPreviewClass="navItemDrag" (cdkDragStarted)="onDragStart($event)"
|
||||||
(cdkDragEnded)="onDragEnd($event)">
|
(cdkDragEnded)="onDragEnd($event)">
|
||||||
<a class="nav-link" routerLink="view/{{view.id}}"
|
<a class="nav-link" routerLink="view/{{view.id}}"
|
||||||
@@ -128,7 +130,7 @@
|
|||||||
}
|
}
|
||||||
</a>
|
</a>
|
||||||
@if (settingsService.organizingSidebarSavedViews() && canSaveSettings) {
|
@if (settingsService.organizingSidebarSavedViews() && canSaveSettings) {
|
||||||
<div class="position-absolute end-0 top-0 px-1 py-2" [class.me-n2]="slimSidebarEnabled" cdkDragHandle>
|
<div class="position-absolute end-0 top-0 px-3 py-2" [class.me-n3]="slimSidebarEnabled" cdkDragHandle>
|
||||||
<i-bs name="grip-vertical"></i-bs>
|
<i-bs name="grip-vertical"></i-bs>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -170,7 +172,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 class="nav-link-label"><ng-container i18n>Close all</ng-container></span>
|
<i-bs class="me-2" name="x"></i-bs><span><ng-container i18n>Close all</ng-container></span>
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
}
|
}
|
||||||
@@ -189,7 +191,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="nav-link-label ms-2"><ng-container i18n>Attributes</ng-container></span>
|
<i-bs name="stack"></i-bs><span class="ms-2"><ng-container i18n>Attributes</ng-container></span>
|
||||||
</a>
|
</a>
|
||||||
@if (!slimSidebarEnabled && canSaveSettings) {
|
@if (!slimSidebarEnabled && canSaveSettings) {
|
||||||
<button
|
<button
|
||||||
@@ -210,27 +212,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 class="nav-link-label"><ng-container i18n>Tags</ng-container></span>
|
<i-bs class="me-2" name="tags"></i-bs><span><ng-container i18n>Tags</ng-container></span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Correspondent }">
|
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Correspondent }">
|
||||||
<a class="nav-link" routerLink="attributes/correspondents" routerLinkActive="active" (click)="closeMenu()">
|
<a class="nav-link" routerLink="attributes/correspondents" routerLinkActive="active" (click)="closeMenu()">
|
||||||
<i-bs class="me-2" name="person"></i-bs><span class="nav-link-label"><ng-container i18n>Correspondents</ng-container></span>
|
<i-bs class="me-2" name="person"></i-bs><span><ng-container i18n>Correspondents</ng-container></span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.DocumentType }">
|
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.DocumentType }">
|
||||||
<a class="nav-link" routerLink="attributes/documenttypes" routerLinkActive="active" (click)="closeMenu()">
|
<a class="nav-link" routerLink="attributes/documenttypes" routerLinkActive="active" (click)="closeMenu()">
|
||||||
<i-bs class="me-2" name="hash"></i-bs><span class="nav-link-label"><ng-container i18n>Document types</ng-container></span>
|
<i-bs class="me-2" name="hash"></i-bs><span><ng-container i18n>Document types</ng-container></span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.StoragePath }">
|
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.StoragePath }">
|
||||||
<a class="nav-link" routerLink="attributes/storagepaths" routerLinkActive="active" (click)="closeMenu()">
|
<a class="nav-link" routerLink="attributes/storagepaths" routerLinkActive="active" (click)="closeMenu()">
|
||||||
<i-bs class="me-2" name="folder"></i-bs><span class="nav-link-label"><ng-container i18n>Storage paths</ng-container></span>
|
<i-bs class="me-2" name="folder"></i-bs><span><ng-container i18n>Storage paths</ng-container></span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.CustomField }">
|
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.CustomField }">
|
||||||
<a class="nav-link" routerLink="attributes/customfields" routerLinkActive="active" (click)="closeMenu()">
|
<a class="nav-link" routerLink="attributes/customfields" routerLinkActive="active" (click)="closeMenu()">
|
||||||
<i-bs class="me-2" name="ui-radios"></i-bs><span class="nav-link-label"><ng-container i18n>Custom fields</ng-container></span>
|
<i-bs class="me-2" name="ui-radios"></i-bs><span><ng-container i18n>Custom fields</ng-container></span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
@@ -241,7 +243,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 class="nav-link-label"><ng-container i18n>Saved Views</ng-container></span>
|
<i-bs class="me-2" name="window-stack"></i-bs><span><ng-container i18n>Saved Views</ng-container></span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item app-link"
|
<li class="nav-item app-link"
|
||||||
@@ -250,7 +252,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 class="nav-link-label"><ng-container i18n>Workflows</ng-container></span>
|
<i-bs class="me-2" name="boxes"></i-bs><span><ng-container i18n>Workflows</ng-container></span>
|
||||||
</a>
|
</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 }"
|
||||||
@@ -258,14 +260,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 class="nav-link-label"><ng-container i18n>Mail</ng-container></span>
|
<i-bs class="me-2" name="envelope"></i-bs><span><ng-container i18n>Mail</ng-container></span>
|
||||||
</a>
|
</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 class="nav-link-label"><ng-container i18n>Trash</ng-container></span>
|
<i-bs class="me-2" name="trash"></i-bs><span><ng-container i18n>Trash</ng-container></span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
@@ -281,21 +283,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 class="nav-link-label"><ng-container i18n>Settings</ng-container></span>
|
<i-bs class="me-2" name="gear"></i-bs><span><ng-container i18n>Settings</ng-container></span>
|
||||||
</a>
|
</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 class="nav-link-label"><ng-container i18n>Configuration</ng-container></span>
|
<i-bs class="me-2" name="sliders2-vertical"></i-bs><span><ng-container i18n>Configuration</ng-container></span>
|
||||||
</a>
|
</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 class="nav-link-label"><ng-container i18n>Users & Groups</ng-container></span>
|
<i-bs class="me-2" name="people"></i-bs><span><ng-container i18n>Users & Groups</ng-container></span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item app-link"
|
<li class="nav-item app-link"
|
||||||
@@ -304,10 +306,9 @@
|
|||||||
<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 class="nav-link-label"><ng-container i18n>Tasks</ng-container></span>
|
<i-bs class="me-2" name="list-task"></i-bs><span><ng-container i18n>Tasks</ng-container>@if (tasksService.needsAttentionTasks.length > 0) {
|
||||||
@if (tasksService.needsAttentionTasks.length > 0 && !slimSidebarEnabled) {
|
<span><span class="badge bg-danger ms-2 d-inline">{{tasksService.needsAttentionTasks.length}}</span></span>
|
||||||
<span class="badge bg-danger ms-2 d-inline flex-shrink-0">{{tasksService.needsAttentionTasks.length}}</span>
|
}</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>
|
||||||
}
|
}
|
||||||
@@ -318,7 +319,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 class="nav-link-label"><ng-container i18n>Logs</ng-container></span>
|
<i-bs class="me-2" name="text-left"></i-bs><span><ng-container i18n>Logs</ng-container></span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
}
|
}
|
||||||
@@ -332,7 +333,7 @@
|
|||||||
</li>
|
</li>
|
||||||
<li class="nav-item" [class.visually-hidden]="slimSidebarEnabled">
|
<li class="nav-item" [class.visually-hidden]="slimSidebarEnabled">
|
||||||
<div class="text-muted small d-flex align-items-center flex-wrap nav-label">
|
<div class="text-muted small d-flex align-items-center flex-wrap nav-label">
|
||||||
<div class="me-2">
|
<div class="me-3">
|
||||||
<a class="text-muted text-decoration-none" target="_blank" rel="noopener noreferrer"
|
<a class="text-muted text-decoration-none" target="_blank" rel="noopener noreferrer"
|
||||||
href="https://github.com/paperless-ngx/paperless-ngx" ngbPopover="GitHub" i18n-ngbPopover
|
href="https://github.com/paperless-ngx/paperless-ngx" ngbPopover="GitHub" i18n-ngbPopover
|
||||||
[disablePopover]="!slimSidebarPopoversEnabled" placement="end" container="body"
|
[disablePopover]="!slimSidebarPopoversEnabled" placement="end" container="body"
|
||||||
@@ -341,7 +342,7 @@
|
|||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
@if (!settingsService.updateCheckingIsSet || appRemoteVersion()) {
|
@if (!settingsService.updateCheckingIsSet || appRemoteVersion()) {
|
||||||
<div class="version-check d-flex align-items-center">
|
<div class="version-check">
|
||||||
<ng-template #updateAvailablePopContent>
|
<ng-template #updateAvailablePopContent>
|
||||||
<span class="small">Paperless-ngx {{ appRemoteVersion().version }} <ng-container i18n>is
|
<span class="small">Paperless-ngx {{ appRemoteVersion().version }} <ng-container i18n>is
|
||||||
available.</ng-container><br /><ng-container i18n>Click to view.</ng-container></span>
|
available.</ng-container><br /><ng-container i18n>Click to view.</ng-container></span>
|
||||||
@@ -389,7 +390,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-sidebar-expanded'">
|
[ngClass]="slimSidebarEnabled ? 'col-slim' : 'col-md-9 col-lg-10 col-xxxl-11'">
|
||||||
<router-outlet></router-outlet>
|
<router-outlet></router-outlet>
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -11,7 +11,6 @@
|
|||||||
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;
|
||||||
|
|
||||||
@@ -33,10 +32,17 @@
|
|||||||
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) {
|
||||||
&.expanded {
|
--pngx-sidebar-width: 25%;
|
||||||
--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) {
|
||||||
@@ -103,16 +109,6 @@ 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;
|
||||||
|
|
||||||
@@ -129,19 +125,6 @@ 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 {
|
||||||
@@ -254,26 +237,6 @@ 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;
|
||||||
}
|
}
|
||||||
@@ -399,7 +362,6 @@ 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;
|
||||||
|
|
||||||
@@ -412,8 +374,6 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -446,8 +406,6 @@ 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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -459,7 +417,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) 85%, var(--bs-primary)) !important;
|
fill: color-mix(in srgb, var(--pngx-primary-text-contrast) 70%, var(--bs-primary)) !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
.text {
|
.text {
|
||||||
@@ -478,19 +436,6 @@ 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;
|
||||||
@@ -531,7 +476,6 @@ 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;
|
||||||
|
|||||||
@@ -193,23 +193,6 @@ describe('AppFrameComponent', () => {
|
|||||||
expect(savedViewSpy).toHaveBeenCalled()
|
expect(savedViewSpy).toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should update reinitialized signal-backed settings without manual change detection', async () => {
|
|
||||||
settingsService.initializeSettings().subscribe()
|
|
||||||
httpTestingController
|
|
||||||
.expectOne(`${environment.apiBaseUrl}ui_settings/`)
|
|
||||||
.flush({
|
|
||||||
settings: { app_title: 'Reactive title' },
|
|
||||||
user: {},
|
|
||||||
permissions: [],
|
|
||||||
})
|
|
||||||
|
|
||||||
await fixture.whenStable()
|
|
||||||
|
|
||||||
expect(
|
|
||||||
fixture.nativeElement.querySelector('.brand-title').textContent
|
|
||||||
).toBe('Reactive title')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should check for update if enabled', () => {
|
it('should check for update if enabled', () => {
|
||||||
const updateCheckSpy = jest.spyOn(remoteVersionService, 'checkForUpdates')
|
const updateCheckSpy = jest.spyOn(remoteVersionService, 'checkForUpdates')
|
||||||
updateCheckSpy.mockImplementation(() => {
|
updateCheckSpy.mockImplementation(() => {
|
||||||
@@ -560,27 +543,6 @@ 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,7 +45,6 @@ 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'
|
||||||
@@ -62,7 +61,6 @@ const SCROLL_THRESHOLD = 16
|
|||||||
imports: [
|
imports: [
|
||||||
GlobalSearchComponent,
|
GlobalSearchComponent,
|
||||||
LogoComponent,
|
LogoComponent,
|
||||||
BrandMarkComponent,
|
|
||||||
DocumentTitlePipe,
|
DocumentTitlePipe,
|
||||||
IfPermissionsDirective,
|
IfPermissionsDirective,
|
||||||
ToastsDropdownComponent,
|
ToastsDropdownComponent,
|
||||||
@@ -98,29 +96,6 @@ export class AppFrameComponent
|
|||||||
readonly isMenuCollapsed = signal(true)
|
readonly isMenuCollapsed = signal(true)
|
||||||
readonly slimSidebarAnimating = signal(false)
|
readonly slimSidebarAnimating = signal(false)
|
||||||
readonly mobileSearchHidden = signal(false)
|
readonly mobileSearchHidden = signal(false)
|
||||||
private readonly versionSetting = this.settingsService.getSignal<string>(
|
|
||||||
SETTINGS_KEYS.VERSION
|
|
||||||
)
|
|
||||||
private readonly appTitleSetting = this.settingsService.getSignal<string>(
|
|
||||||
SETTINGS_KEYS.APP_TITLE
|
|
||||||
)
|
|
||||||
private readonly appLogoSetting = this.settingsService.getSignal<string>(
|
|
||||||
SETTINGS_KEYS.APP_LOGO
|
|
||||||
)
|
|
||||||
private readonly slimSidebarSetting = this.settingsService.getSignal<boolean>(
|
|
||||||
SETTINGS_KEYS.SLIM_SIDEBAR
|
|
||||||
)
|
|
||||||
private readonly attributesSectionsCollapsedSetting =
|
|
||||||
this.settingsService.getSignal<CollapsibleSection[]>(
|
|
||||||
SETTINGS_KEYS.ATTRIBUTES_SECTIONS_COLLAPSED
|
|
||||||
)
|
|
||||||
private readonly aiEnabledSetting = this.settingsService.getSignal<boolean>(
|
|
||||||
SETTINGS_KEYS.AI_ENABLED
|
|
||||||
)
|
|
||||||
private readonly sidebarViewsShowCountSetting =
|
|
||||||
this.settingsService.getSignal<boolean>(
|
|
||||||
SETTINGS_KEYS.SIDEBAR_VIEWS_SHOW_COUNT
|
|
||||||
)
|
|
||||||
private lastScrollY: number = 0
|
private lastScrollY: number = 0
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -141,7 +116,6 @@ 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()
|
||||||
@@ -214,23 +188,33 @@ export class AppFrameComponent
|
|||||||
}
|
}
|
||||||
|
|
||||||
get versionString(): string {
|
get versionString(): string {
|
||||||
return `${environment.appTitle} v${this.versionSetting()}${environment.tag === 'prod' ? '' : ` #${environment.tag}`}`
|
this.settingsService.trackChanges()
|
||||||
|
return `${environment.appTitle} v${this.settingsService.get(SETTINGS_KEYS.VERSION)}${environment.tag === 'prod' ? '' : ` #${environment.tag}`}`
|
||||||
}
|
}
|
||||||
|
|
||||||
get appTitle(): string {
|
get appTitle(): string {
|
||||||
return this.appTitleSetting() || environment.appTitle
|
this.settingsService.trackChanges()
|
||||||
|
return (
|
||||||
|
this.settingsService.get(SETTINGS_KEYS.APP_TITLE) || environment.appTitle
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
get customAppTitle(): string {
|
get customAppTitle(): string {
|
||||||
return this.appTitleSetting()
|
this.settingsService.trackChanges()
|
||||||
|
return this.settingsService.get(SETTINGS_KEYS.APP_TITLE)
|
||||||
}
|
}
|
||||||
|
|
||||||
get hasCustomBranding(): boolean {
|
get hasCustomBranding(): boolean {
|
||||||
return !!(this.appTitleSetting()?.length || this.appLogoSetting()?.length)
|
this.settingsService.trackChanges()
|
||||||
|
return !!(
|
||||||
|
this.settingsService.get(SETTINGS_KEYS.APP_TITLE)?.length ||
|
||||||
|
this.settingsService.get(SETTINGS_KEYS.APP_LOGO)?.length
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
get customAppLogo(): string {
|
get customAppLogo(): string {
|
||||||
const logo = this.appLogoSetting()
|
this.settingsService.trackChanges()
|
||||||
|
const logo = this.settingsService.get(SETTINGS_KEYS.APP_LOGO)
|
||||||
return logo?.length
|
return logo?.length
|
||||||
? environment.apiBaseUrl.replace(/\/api\/$/, logo)
|
? environment.apiBaseUrl.replace(/\/api\/$/, logo)
|
||||||
: null
|
: null
|
||||||
@@ -275,7 +259,8 @@ export class AppFrameComponent
|
|||||||
}
|
}
|
||||||
|
|
||||||
get slimSidebarEnabled(): boolean {
|
get slimSidebarEnabled(): boolean {
|
||||||
return this.slimSidebarSetting()
|
this.settingsService.trackChanges()
|
||||||
|
return this.settingsService.get(SETTINGS_KEYS.SLIM_SIDEBAR)
|
||||||
}
|
}
|
||||||
|
|
||||||
set slimSidebarEnabled(enabled: boolean) {
|
set slimSidebarEnabled(enabled: boolean) {
|
||||||
@@ -298,9 +283,10 @@ export class AppFrameComponent
|
|||||||
}
|
}
|
||||||
|
|
||||||
get attributesSectionsCollapsed(): boolean {
|
get attributesSectionsCollapsed(): boolean {
|
||||||
return this.attributesSectionsCollapsedSetting()?.includes(
|
this.settingsService.trackChanges()
|
||||||
CollapsibleSection.ATTRIBUTES
|
return this.settingsService
|
||||||
)
|
.get(SETTINGS_KEYS.ATTRIBUTES_SECTIONS_COLLAPSED)
|
||||||
|
?.includes(CollapsibleSection.ATTRIBUTES)
|
||||||
}
|
}
|
||||||
|
|
||||||
set attributesSectionsCollapsed(collapsed: boolean) {
|
set attributesSectionsCollapsed(collapsed: boolean) {
|
||||||
@@ -323,7 +309,8 @@ export class AppFrameComponent
|
|||||||
}
|
}
|
||||||
|
|
||||||
get aiEnabled(): boolean {
|
get aiEnabled(): boolean {
|
||||||
return this.aiEnabledSetting()
|
this.settingsService.trackChanges()
|
||||||
|
return this.settingsService.get(SETTINGS_KEYS.AI_ENABLED)
|
||||||
}
|
}
|
||||||
|
|
||||||
@HostListener('window:resize')
|
@HostListener('window:resize')
|
||||||
@@ -354,22 +341,6 @@ 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
|
||||||
}
|
}
|
||||||
@@ -490,8 +461,9 @@ export class AppFrameComponent
|
|||||||
}
|
}
|
||||||
|
|
||||||
get showSidebarCounts(): boolean {
|
get showSidebarCounts(): boolean {
|
||||||
|
this.settingsService.trackChanges()
|
||||||
return (
|
return (
|
||||||
this.sidebarViewsShowCountSetting() &&
|
this.settingsService.get(SETTINGS_KEYS.SIDEBAR_VIEWS_SHOW_COUNT) &&
|
||||||
!this.settingsService.organizingSidebarSavedViews()
|
!this.settingsService.organizingSidebarSavedViews()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@
|
|||||||
autocomplete="off"
|
autocomplete="off"
|
||||||
spellcheck="false"
|
spellcheck="false"
|
||||||
[ngModel]="query()"
|
[ngModel]="query()"
|
||||||
(ngModelChange)="onQueryChange($event)"
|
(ngModelChange)="queryDebounce.next($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,19 +272,6 @@ 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'
|
||||||
|
|||||||
@@ -81,10 +81,6 @@ export class GlobalSearchComponent implements OnInit {
|
|||||||
private hotkeyService = inject(HotKeyService)
|
private hotkeyService = inject(HotKeyService)
|
||||||
private settingsService = inject(SettingsService)
|
private settingsService = inject(SettingsService)
|
||||||
private locationStrategy = inject(LocationStrategy)
|
private locationStrategy = inject(LocationStrategy)
|
||||||
private readonly searchFullTypeSetting =
|
|
||||||
this.settingsService.getSignal<GlobalSearchType>(
|
|
||||||
SETTINGS_KEYS.SEARCH_FULL_TYPE
|
|
||||||
)
|
|
||||||
|
|
||||||
public DataType = DataType
|
public DataType = DataType
|
||||||
readonly query = signal<string>(null)
|
readonly query = signal<string>(null)
|
||||||
@@ -101,7 +97,11 @@ export class GlobalSearchComponent implements OnInit {
|
|||||||
@ViewChildren('secondaryButton') secondaryButtons: QueryList<ElementRef>
|
@ViewChildren('secondaryButton') secondaryButtons: QueryList<ElementRef>
|
||||||
|
|
||||||
get useAdvancedForFullSearch(): boolean {
|
get useAdvancedForFullSearch(): boolean {
|
||||||
return this.searchFullTypeSetting() === GlobalSearchType.ADVANCED
|
this.settingsService.trackChanges()
|
||||||
|
return (
|
||||||
|
this.settingsService.get(SETTINGS_KEYS.SEARCH_FULL_TYPE) ===
|
||||||
|
GlobalSearchType.ADVANCED
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -119,12 +119,6 @@ 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` })
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
@if (useDropdown) {
|
@if (useDropdown) {
|
||||||
<div class="btn-group w-100" role="group" ngbDropdown #dropdown="ngbDropdown" (openChange)="onOpenChange($event)" [popperOptions]="popperOptions">
|
<div class="btn-group w-100" role="group" ngbDropdown #dropdown="ngbDropdown" (openChange)="onOpenChange($event)" [popperOptions]="popperOptions">
|
||||||
<button class="btn btn-sm" [ngClass]="!editing && isActive ? 'btn-primary' : 'btn-outline-primary'" id="dropdown_toggle" ngbDropdownToggle [disabled]="disabled" [aria-label]="title">
|
<button class="btn btn-sm btn-outline-primary" id="dropdown_toggle" ngbDropdownToggle [disabled]="disabled" [aria-label]="title">
|
||||||
<i-bs name="{{icon}}"></i-bs><div class="d-none d-sm-inline ms-1">{{title}}</div>
|
<i-bs name="{{icon}}"></i-bs><div class="d-none d-sm-inline ms-1">{{title}}</div>
|
||||||
@if (isActive) {
|
@if (isActive) {
|
||||||
<pngx-clearable-badge [selected]="isActive" (cleared)="reset()"></pngx-clearable-badge>
|
<pngx-clearable-badge [selected]="isActive" (cleared)="reset()"></pngx-clearable-badge>
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
getLocaleNumberSymbol,
|
getLocaleNumberSymbol,
|
||||||
NgClass,
|
|
||||||
NgTemplateOutlet,
|
NgTemplateOutlet,
|
||||||
NumberSymbol,
|
NumberSymbol,
|
||||||
} from '@angular/common'
|
} from '@angular/common'
|
||||||
@@ -49,26 +48,25 @@ import { ClearableBadgeComponent } from '../clearable-badge/clearable-badge.comp
|
|||||||
import { DocumentLinkComponent } from '../input/document-link/document-link.component'
|
import { DocumentLinkComponent } from '../input/document-link/document-link.component'
|
||||||
|
|
||||||
export class CustomFieldQueriesModel {
|
export class CustomFieldQueriesModel {
|
||||||
private readonly _queries = signal<CustomFieldQueryElement[]>([])
|
private _queries: CustomFieldQueryElement[] = []
|
||||||
private rootSubscriptions: Subscription[] = []
|
private rootSubscriptions: Subscription[] = []
|
||||||
|
|
||||||
public readonly changed = new Subject<CustomFieldQueriesModel>()
|
public readonly changed = new Subject<CustomFieldQueriesModel>()
|
||||||
|
|
||||||
public get queries(): CustomFieldQueryElement[] {
|
public get queries(): CustomFieldQueryElement[] {
|
||||||
return this._queries()
|
return this._queries
|
||||||
}
|
}
|
||||||
|
|
||||||
public set queries(value: CustomFieldQueryElement[]) {
|
public set queries(value: CustomFieldQueryElement[]) {
|
||||||
this.teardownRootSubscriptions()
|
this.teardownRootSubscriptions()
|
||||||
const queries = value ?? []
|
this._queries = value ?? []
|
||||||
for (const element of queries) {
|
for (const element of this._queries) {
|
||||||
this.rootSubscriptions.push(
|
this.rootSubscriptions.push(
|
||||||
element.changed.subscribe(() => {
|
element.changed.subscribe(() => {
|
||||||
this.changed.next(this)
|
this.changed.next(this)
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
this._queries.set(queries)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public clear(fireEvent = true) {
|
public clear(fireEvent = true) {
|
||||||
@@ -211,7 +209,6 @@ export class CustomFieldQueriesModel {
|
|||||||
DocumentLinkComponent,
|
DocumentLinkComponent,
|
||||||
ReactiveFormsModule,
|
ReactiveFormsModule,
|
||||||
NgbDatepickerModule,
|
NgbDatepickerModule,
|
||||||
NgClass,
|
|
||||||
NgTemplateOutlet,
|
NgTemplateOutlet,
|
||||||
NgSelectModule,
|
NgSelectModule,
|
||||||
NgxBootstrapIconsModule,
|
NgxBootstrapIconsModule,
|
||||||
|
|||||||
@@ -94,7 +94,6 @@ 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,45 +462,6 @@
|
|||||||
</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,7 +22,6 @@ 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'
|
||||||
@@ -50,7 +49,6 @@ 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,
|
||||||
@@ -196,16 +194,6 @@ describe('WorkflowEditDialogComponent', () => {
|
|||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
})
|
})
|
||||||
|
|
||||||
function setActionSettings({
|
|
||||||
email = true,
|
|
||||||
remoteOcr = true,
|
|
||||||
ai = true,
|
|
||||||
} = {}) {
|
|
||||||
settingsService.set(SETTINGS_KEYS.EMAIL_ENABLED, email)
|
|
||||||
settingsService.set(SETTINGS_KEYS.REMOTE_OCR_CONFIGURED, remoteOcr)
|
|
||||||
settingsService.set(SETTINGS_KEYS.AI_ENABLED, ai)
|
|
||||||
}
|
|
||||||
|
|
||||||
it('should support create and edit modes, support adding triggers and actions on new workflow', () => {
|
it('should support create and edit modes, support adding triggers and actions on new workflow', () => {
|
||||||
component.dialogMode.set(EditDialogMode.CREATE)
|
component.dialogMode.set(EditDialogMode.CREATE)
|
||||||
const createTitleSpy = jest.spyOn(component, 'getCreateTitle')
|
const createTitleSpy = jest.spyOn(component, 'getCreateTitle')
|
||||||
@@ -228,7 +216,7 @@ describe('WorkflowEditDialogComponent', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('should return source options, type options, type name, schedule date field options', () => {
|
it('should return source options, type options, type name, schedule date field options', () => {
|
||||||
setActionSettings()
|
jest.spyOn(settingsService, 'get').mockReturnValue(true)
|
||||||
component.ngOnInit()
|
component.ngOnInit()
|
||||||
expect(component.sourceOptions).toEqual(DOCUMENT_SOURCE_OPTIONS)
|
expect(component.sourceOptions).toEqual(DOCUMENT_SOURCE_OPTIONS)
|
||||||
expect(component.triggerTypeOptions).toEqual(WORKFLOW_TYPE_OPTIONS)
|
expect(component.triggerTypeOptions).toEqual(WORKFLOW_TYPE_OPTIONS)
|
||||||
@@ -251,21 +239,20 @@ describe('WorkflowEditDialogComponent', () => {
|
|||||||
SCHEDULE_DATE_FIELD_OPTIONS
|
SCHEDULE_DATE_FIELD_OPTIONS
|
||||||
)
|
)
|
||||||
|
|
||||||
// Email, remote OCR and AI all disabled
|
// Email disabled
|
||||||
setActionSettings({ email: false, remoteOcr: false, ai: 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
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should offer remote OCR only for consumption workflows', () => {
|
it('should offer remote OCR only for consumption workflows', () => {
|
||||||
setActionSettings()
|
jest.spyOn(settingsService, 'get').mockReturnValue(true)
|
||||||
|
|
||||||
// A consumption trigger makes the action reachable
|
// A consumption trigger makes the action reachable
|
||||||
component.object = {
|
component.object = {
|
||||||
@@ -295,7 +282,7 @@ describe('WorkflowEditDialogComponent', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('should offer remote OCR on a trigger added to a new workflow', () => {
|
it('should offer remote OCR on a trigger added to a new workflow', () => {
|
||||||
setActionSettings()
|
jest.spyOn(settingsService, 'get').mockReturnValue(true)
|
||||||
component.ngOnInit()
|
component.ngOnInit()
|
||||||
|
|
||||||
// Nothing for the action to apply to yet
|
// Nothing for the action to apply to yet
|
||||||
@@ -321,7 +308,7 @@ describe('WorkflowEditDialogComponent', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('should keep remote OCR listed when an action already uses it', () => {
|
it('should keep remote OCR listed when an action already uses it', () => {
|
||||||
setActionSettings()
|
jest.spyOn(settingsService, 'get').mockReturnValue(true)
|
||||||
|
|
||||||
// Otherwise changing the trigger would silently blank the selection
|
// Otherwise changing the trigger would silently blank the selection
|
||||||
component.object = {
|
component.object = {
|
||||||
@@ -339,7 +326,9 @@ describe('WorkflowEditDialogComponent', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('should not offer remote OCR when no engine is configured', () => {
|
it('should not offer remote OCR when no engine is configured', () => {
|
||||||
setActionSettings({ remoteOcr: false })
|
jest
|
||||||
|
.spyOn(settingsService, 'get')
|
||||||
|
.mockImplementation((key) => key !== SETTINGS_KEYS.REMOTE_OCR_CONFIGURED)
|
||||||
|
|
||||||
component.object = {
|
component.object = {
|
||||||
name: 'Workflow 1',
|
name: 'Workflow 1',
|
||||||
@@ -355,123 +344,6 @@ describe('WorkflowEditDialogComponent', () => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should offer apply AI suggestions unless every trigger is consumption', () => {
|
|
||||||
setActionSettings()
|
|
||||||
|
|
||||||
// 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', () => {
|
|
||||||
setActionSettings()
|
|
||||||
|
|
||||||
// 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', () => {
|
|
||||||
setActionSettings({ ai: false })
|
|
||||||
|
|
||||||
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,7 +30,6 @@ 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'
|
||||||
@@ -153,37 +152,6 @@ 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 {
|
||||||
@@ -537,13 +505,6 @@ export class WorkflowEditDialogComponent
|
|||||||
readonly dateCustomFields = computed(() =>
|
readonly dateCustomFields = computed(() =>
|
||||||
this.customFields()?.filter((f) => f.data_type === CustomFieldDataType.Date)
|
this.customFields()?.filter((f) => f.data_type === CustomFieldDataType.Date)
|
||||||
)
|
)
|
||||||
private readonly emailEnabledSetting =
|
|
||||||
this.settingsService.getSignal<boolean>(SETTINGS_KEYS.EMAIL_ENABLED)
|
|
||||||
private readonly remoteOcrConfiguredSetting =
|
|
||||||
this.settingsService.getSignal<boolean>(SETTINGS_KEYS.REMOTE_OCR_CONFIGURED)
|
|
||||||
private readonly aiEnabledSetting = this.settingsService.getSignal<boolean>(
|
|
||||||
SETTINGS_KEYS.AI_ENABLED
|
|
||||||
)
|
|
||||||
|
|
||||||
expandedItem: number = null
|
expandedItem: number = null
|
||||||
|
|
||||||
@@ -596,7 +557,7 @@ export class WorkflowEditDialogComponent
|
|||||||
private getAllowedActionTypes() {
|
private getAllowedActionTypes() {
|
||||||
let allowed = WORKFLOW_ACTION_OPTIONS
|
let allowed = WORKFLOW_ACTION_OPTIONS
|
||||||
|
|
||||||
if (!this.emailEnabledSetting()) {
|
if (!this.settingsService.get(SETTINGS_KEYS.EMAIL_ENABLED)) {
|
||||||
allowed = allowed.filter((a) => a.id !== WorkflowActionType.Email)
|
allowed = allowed.filter((a) => a.id !== WorkflowActionType.Email)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -604,7 +565,7 @@ export class WorkflowEditDialogComponent
|
|||||||
// offered for workflows that run at consumption.
|
// offered for workflows that run at consumption.
|
||||||
const formWorkflow: Workflow = this.objectForm?.value
|
const formWorkflow: Workflow = this.objectForm?.value
|
||||||
const remoteOcrUsable =
|
const remoteOcrUsable =
|
||||||
this.remoteOcrConfiguredSetting() &&
|
this.settingsService.get(SETTINGS_KEYS.REMOTE_OCR_CONFIGURED) &&
|
||||||
(formWorkflow?.triggers?.some(
|
(formWorkflow?.triggers?.some(
|
||||||
(trigger) => trigger.type === WorkflowTriggerType.Consumption
|
(trigger) => trigger.type === WorkflowTriggerType.Consumption
|
||||||
) ||
|
) ||
|
||||||
@@ -615,24 +576,6 @@ 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.aiEnabledSetting() &&
|
|
||||||
(!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)
|
||||||
@@ -1284,11 +1227,6 @@ 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 }
|
||||||
)
|
)
|
||||||
@@ -1369,6 +1307,7 @@ export class WorkflowEditDialogComponent
|
|||||||
}
|
}
|
||||||
|
|
||||||
get actionTypeOptions() {
|
get actionTypeOptions() {
|
||||||
|
this.settingsService.trackChanges()
|
||||||
// Computed on read rather than cached
|
// Computed on read rather than cached
|
||||||
return this.getAllowedActionTypes()
|
return this.getAllowedActionTypes()
|
||||||
}
|
}
|
||||||
@@ -1377,10 +1316,6 @@ 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)
|
||||||
@@ -1434,9 +1369,6 @@ 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)
|
||||||
|
|||||||
@@ -839,9 +839,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
|
|||||||
selectionModel.items = [memoRoot]
|
selectionModel.items = [memoRoot]
|
||||||
selectionModel.documentCounts = [{ id: memoRoot.id, document_count: 9 }]
|
selectionModel.documentCounts = [{ id: memoRoot.id, document_count: 9 }]
|
||||||
|
|
||||||
const getRootDocCount = (selectionModel as any).createRootDocCounter(
|
const getRootDocCount = (selectionModel as any).createRootDocCounter()
|
||||||
selectionModel.items
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(getRootDocCount(memoRoot.id)).toEqual(9)
|
expect(getRootDocCount(memoRoot.id)).toEqual(9)
|
||||||
selectionModel.documentCounts = []
|
selectionModel.documentCounts = []
|
||||||
@@ -857,9 +855,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
|
|||||||
selectionModel.items = [rootWithoutSelection]
|
selectionModel.items = [rootWithoutSelection]
|
||||||
selectionModel.documentCounts = []
|
selectionModel.documentCounts = []
|
||||||
|
|
||||||
const getRootDocCount = (selectionModel as any).createRootDocCounter(
|
const getRootDocCount = (selectionModel as any).createRootDocCounter()
|
||||||
selectionModel.items
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(getRootDocCount(rootWithoutSelection.id)).toEqual(4)
|
expect(getRootDocCount(rootWithoutSelection.id)).toEqual(4)
|
||||||
})
|
})
|
||||||
@@ -869,9 +865,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
|
|||||||
selectionModel.items = [rootWithoutCounts]
|
selectionModel.items = [rootWithoutCounts]
|
||||||
selectionModel.documentCounts = []
|
selectionModel.documentCounts = []
|
||||||
|
|
||||||
const getRootDocCount = (selectionModel as any).createRootDocCounter(
|
const getRootDocCount = (selectionModel as any).createRootDocCounter()
|
||||||
selectionModel.items
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(getRootDocCount(rootWithoutCounts.id)).toEqual(0)
|
expect(getRootDocCount(rootWithoutCounts.id)).toEqual(0)
|
||||||
})
|
})
|
||||||
@@ -972,7 +966,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
|
|||||||
component.selectionModel['temporarySelectionStates'].set(id, state)
|
component.selectionModel['temporarySelectionStates'].set(id, state)
|
||||||
const changedSpy = jest.spyOn(component.selectionModel.changed, 'next')
|
const changedSpy = jest.spyOn(component.selectionModel.changed, 'next')
|
||||||
component.selectionModel.exclude(id)
|
component.selectionModel.exclude(id)
|
||||||
expect(component.selectionModel.temporaryLogicalOperator()).toBe(
|
expect(component.selectionModel.temporaryLogicalOperator).toBe(
|
||||||
LogicalOperator.And
|
LogicalOperator.And
|
||||||
)
|
)
|
||||||
expect(component.selectionModel['temporarySelectionStates'].get(id)).toBe(
|
expect(component.selectionModel['temporarySelectionStates'].get(id)).toBe(
|
||||||
|
|||||||
@@ -64,56 +64,43 @@ export class FilterableDropdownSelectionModel {
|
|||||||
|
|
||||||
manyToOne = false
|
manyToOne = false
|
||||||
singleSelect = false
|
singleSelect = false
|
||||||
|
private _logicalOperator: LogicalOperator = LogicalOperator.And
|
||||||
|
temporaryLogicalOperator: LogicalOperator = this._logicalOperator
|
||||||
|
private _intersection: Intersection = Intersection.Include
|
||||||
|
temporaryIntersection: Intersection = this._intersection
|
||||||
|
|
||||||
private readonly _logicalOperator = signal(LogicalOperator.And)
|
private _documentCounts: SelectionDataItem[] = []
|
||||||
readonly temporaryLogicalOperator = signal(LogicalOperator.And)
|
|
||||||
private readonly _intersection = signal(Intersection.Include)
|
|
||||||
readonly temporaryIntersection = signal(Intersection.Include)
|
|
||||||
private readonly _documentCounts = signal<SelectionDataItem[]>([])
|
|
||||||
private readonly _items = signal<MatchingModel[]>([])
|
|
||||||
private readonly _selectionStates = signal(
|
|
||||||
new Map<number, ToggleableItemState>()
|
|
||||||
)
|
|
||||||
private readonly _temporarySelectionStates = signal(
|
|
||||||
new Map<number, ToggleableItemState>()
|
|
||||||
)
|
|
||||||
|
|
||||||
public documentCountSortingEnabled = false
|
public documentCountSortingEnabled = false
|
||||||
|
|
||||||
private get selectionStates(): ReadonlyMap<number, ToggleableItemState> {
|
|
||||||
return this._selectionStates()
|
|
||||||
}
|
|
||||||
|
|
||||||
private get temporarySelectionStates(): ReadonlyMap<
|
|
||||||
number,
|
|
||||||
ToggleableItemState
|
|
||||||
> {
|
|
||||||
return this._temporarySelectionStates()
|
|
||||||
}
|
|
||||||
|
|
||||||
public set documentCounts(counts: SelectionDataItem[]) {
|
public set documentCounts(counts: SelectionDataItem[]) {
|
||||||
this._documentCounts.set(counts)
|
this._documentCounts = counts
|
||||||
if (this.documentCountSortingEnabled) {
|
if (this.documentCountSortingEnabled) {
|
||||||
this._items.set(this.sortItems(this.items))
|
this.sortItems()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private _items: MatchingModel[] = []
|
||||||
get items(): MatchingModel[] {
|
get items(): MatchingModel[] {
|
||||||
return this._items()
|
return this._items
|
||||||
}
|
}
|
||||||
|
|
||||||
set items(items: MatchingModel[]) {
|
set items(items: MatchingModel[]) {
|
||||||
if (items) {
|
if (items) {
|
||||||
this._items.set(this.withNullItem(this.sortItems(Array.from(items))))
|
this._items = Array.from(items)
|
||||||
|
this.sortItems()
|
||||||
|
this.setNullItem()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private withNullItem(items: MatchingModel[]): MatchingModel[] {
|
private setNullItem() {
|
||||||
if (this.manyToOne && this.logicalOperator === LogicalOperator.Or) {
|
if (this.manyToOne && this.logicalOperator === LogicalOperator.Or) {
|
||||||
return items[0]?.id === null ? items.slice(1) : items
|
if (this._items[0]?.id === null) {
|
||||||
|
this._items.shift()
|
||||||
|
}
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
const nullItem = {
|
const item = {
|
||||||
name: $localize`:Filter drop down element to filter for documents with no correspondent/type/tag assigned:Not assigned`,
|
name: $localize`:Filter drop down element to filter for documents with no correspondent/type/tag assigned:Not assigned`,
|
||||||
id:
|
id:
|
||||||
this.manyToOne || this.intersection === Intersection.Include
|
this.manyToOne || this.intersection === Intersection.Include
|
||||||
@@ -121,17 +108,22 @@ export class FilterableDropdownSelectionModel {
|
|||||||
: NEGATIVE_NULL_FILTER_VALUE,
|
: NEGATIVE_NULL_FILTER_VALUE,
|
||||||
}
|
}
|
||||||
|
|
||||||
return items[0]?.id === null || items[0]?.id === NEGATIVE_NULL_FILTER_VALUE
|
if (
|
||||||
? [nullItem, ...items.slice(1)]
|
this._items[0]?.id === null ||
|
||||||
: [nullItem, ...items]
|
this._items[0]?.id === NEGATIVE_NULL_FILTER_VALUE
|
||||||
|
) {
|
||||||
|
this._items[0] = item
|
||||||
|
} else if (this._items) {
|
||||||
|
this._items.unshift(item)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
constructor(manyToOne: boolean = false) {
|
constructor(manyToOne: boolean = false) {
|
||||||
this.manyToOne = manyToOne
|
this.manyToOne = manyToOne
|
||||||
}
|
}
|
||||||
|
|
||||||
private sortItems(items: MatchingModel[]): MatchingModel[] {
|
private sortItems() {
|
||||||
const sorted = [...items].sort((a, b) => {
|
this._items.sort((a, b) => {
|
||||||
if (
|
if (
|
||||||
(a.id == null && b.id != null) ||
|
(a.id == null && b.id != null) ||
|
||||||
(a.id == NEGATIVE_NULL_FILTER_VALUE &&
|
(a.id == NEGATIVE_NULL_FILTER_VALUE &&
|
||||||
@@ -162,13 +154,13 @@ export class FilterableDropdownSelectionModel {
|
|||||||
) {
|
) {
|
||||||
return -1
|
return -1
|
||||||
} else if (
|
} else if (
|
||||||
this._documentCounts().length &&
|
this._documentCounts.length &&
|
||||||
this.getDocumentCount(b.id) === 0 &&
|
this.getDocumentCount(b.id) === 0 &&
|
||||||
this.getDocumentCount(a.id) > this.getDocumentCount(b.id)
|
this.getDocumentCount(a.id) > this.getDocumentCount(b.id)
|
||||||
) {
|
) {
|
||||||
return -1
|
return -1
|
||||||
} else if (
|
} else if (
|
||||||
this._documentCounts().length &&
|
this._documentCounts.length &&
|
||||||
this.getDocumentCount(a.id) === 0 &&
|
this.getDocumentCount(a.id) === 0 &&
|
||||||
this.getDocumentCount(a.id) < this.getDocumentCount(b.id)
|
this.getDocumentCount(a.id) < this.getDocumentCount(b.id)
|
||||||
) {
|
) {
|
||||||
@@ -178,11 +170,15 @@ export class FilterableDropdownSelectionModel {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
return this._documentCounts().length
|
if (this._documentCounts.length) {
|
||||||
? this.promoteBranchesWithDocumentCounts(sorted)
|
this.promoteBranchesWithDocumentCounts()
|
||||||
: sorted
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private selectionStates = new Map<number, ToggleableItemState>()
|
||||||
|
|
||||||
|
private temporarySelectionStates = new Map<number, ToggleableItemState>()
|
||||||
|
|
||||||
getSelectedItems() {
|
getSelectedItems() {
|
||||||
return this.items.filter(
|
return this.items.filter(
|
||||||
(i) =>
|
(i) =>
|
||||||
@@ -198,33 +194,30 @@ export class FilterableDropdownSelectionModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
set(id: number, state: ToggleableItemState, fireEvent = true) {
|
set(id: number, state: ToggleableItemState, fireEvent = true) {
|
||||||
const states = new Map(this.temporarySelectionStates)
|
|
||||||
if (state == ToggleableItemState.NotSelected) {
|
if (state == ToggleableItemState.NotSelected) {
|
||||||
states.delete(id)
|
this.temporarySelectionStates.delete(id)
|
||||||
} else {
|
} else {
|
||||||
states.set(id, state)
|
this.temporarySelectionStates.set(id, state)
|
||||||
}
|
}
|
||||||
this._temporarySelectionStates.set(states)
|
|
||||||
if (fireEvent) {
|
if (fireEvent) {
|
||||||
this.changed.next(this)
|
this.changed.next(this)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
toggle(id: number, fireEvent = true) {
|
toggle(id: number, fireEvent = true) {
|
||||||
const states = new Map(this.temporarySelectionStates)
|
let state = this.temporarySelectionStates.get(id)
|
||||||
let state = states.get(id)
|
|
||||||
if (
|
if (
|
||||||
state == undefined ||
|
state == undefined ||
|
||||||
(state != ToggleableItemState.Selected &&
|
(state != ToggleableItemState.Selected &&
|
||||||
state != ToggleableItemState.Excluded)
|
state != ToggleableItemState.Excluded)
|
||||||
) {
|
) {
|
||||||
if (this.manyToOne || this.singleSelect) {
|
if (this.manyToOne || this.singleSelect) {
|
||||||
states.set(id, ToggleableItemState.Selected)
|
this.temporarySelectionStates.set(id, ToggleableItemState.Selected)
|
||||||
|
|
||||||
if (this.singleSelect) {
|
if (this.singleSelect) {
|
||||||
for (let key of states.keys()) {
|
for (let key of this.temporarySelectionStates.keys()) {
|
||||||
if (key != id) {
|
if (key != id) {
|
||||||
states.delete(key)
|
this.temporarySelectionStates.delete(key)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -240,26 +233,25 @@ export class FilterableDropdownSelectionModel {
|
|||||||
) {
|
) {
|
||||||
newState = ToggleableItemState.NotSelected
|
newState = ToggleableItemState.NotSelected
|
||||||
}
|
}
|
||||||
states.set(id, newState)
|
this.temporarySelectionStates.set(id, newState)
|
||||||
}
|
}
|
||||||
} else if (
|
} else if (
|
||||||
state == ToggleableItemState.Selected ||
|
state == ToggleableItemState.Selected ||
|
||||||
state == ToggleableItemState.Excluded
|
state == ToggleableItemState.Excluded
|
||||||
) {
|
) {
|
||||||
states.delete(id)
|
this.temporarySelectionStates.delete(id)
|
||||||
this.clearDescendantSelections(states, id)
|
this.clearDescendantSelections(id)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!id) {
|
if (!id) {
|
||||||
for (let key of states.keys()) {
|
for (let key of this.temporarySelectionStates.keys()) {
|
||||||
if (key) {
|
if (key) {
|
||||||
states.delete(key)
|
this.temporarySelectionStates.delete(key)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
states.delete(null)
|
this.temporarySelectionStates.delete(null)
|
||||||
}
|
}
|
||||||
this._temporarySelectionStates.set(states)
|
|
||||||
|
|
||||||
if (fireEvent) {
|
if (fireEvent) {
|
||||||
this.changed.next(this)
|
this.changed.next(this)
|
||||||
@@ -267,21 +259,20 @@ export class FilterableDropdownSelectionModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
exclude(id: number, fireEvent: boolean = true) {
|
exclude(id: number, fireEvent: boolean = true) {
|
||||||
const states = new Map(this.temporarySelectionStates)
|
let state = this.temporarySelectionStates.get(id)
|
||||||
let state = states.get(id)
|
|
||||||
if (id && (state == null || state != ToggleableItemState.Excluded)) {
|
if (id && (state == null || state != ToggleableItemState.Excluded)) {
|
||||||
const operator = this.manyToOne ? LogicalOperator.And : LogicalOperator.Or
|
this.temporaryLogicalOperator = this._logicalOperator = this.manyToOne
|
||||||
this.temporaryLogicalOperator.set(operator)
|
? LogicalOperator.And
|
||||||
this._logicalOperator.set(operator)
|
: LogicalOperator.Or
|
||||||
|
|
||||||
if (this.manyToOne || this.singleSelect) {
|
if (this.manyToOne || this.singleSelect) {
|
||||||
states.set(id, ToggleableItemState.Excluded)
|
this.temporarySelectionStates.set(id, ToggleableItemState.Excluded)
|
||||||
this.clearDescendantSelections(states, id)
|
this.clearDescendantSelections(id)
|
||||||
|
|
||||||
if (this.singleSelect) {
|
if (this.singleSelect) {
|
||||||
for (let key of states.keys()) {
|
for (let key of this.temporarySelectionStates.keys()) {
|
||||||
if (key != id) {
|
if (key != id) {
|
||||||
states.delete(key)
|
this.temporarySelectionStates.delete(key)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -296,18 +287,17 @@ export class FilterableDropdownSelectionModel {
|
|||||||
) {
|
) {
|
||||||
newState = ToggleableItemState.NotSelected
|
newState = ToggleableItemState.NotSelected
|
||||||
}
|
}
|
||||||
states.set(id, newState)
|
this.temporarySelectionStates.set(id, newState)
|
||||||
if (newState == ToggleableItemState.Excluded) {
|
if (newState == ToggleableItemState.Excluded) {
|
||||||
this.clearDescendantSelections(states, id)
|
this.clearDescendantSelections(id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (!id || state == ToggleableItemState.Excluded) {
|
} else if (!id || state == ToggleableItemState.Excluded) {
|
||||||
states.delete(id)
|
this.temporarySelectionStates.delete(id)
|
||||||
if (id) {
|
if (id) {
|
||||||
this.clearDescendantSelections(states, id)
|
this.clearDescendantSelections(id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
this._temporarySelectionStates.set(states)
|
|
||||||
|
|
||||||
if (fireEvent) {
|
if (fireEvent) {
|
||||||
this.changed.next(this)
|
this.changed.next(this)
|
||||||
@@ -318,12 +308,9 @@ export class FilterableDropdownSelectionModel {
|
|||||||
return this.selectionStates.get(id) || ToggleableItemState.NotSelected
|
return this.selectionStates.get(id) || ToggleableItemState.NotSelected
|
||||||
}
|
}
|
||||||
|
|
||||||
private clearDescendantSelections(
|
private clearDescendantSelections(id: number) {
|
||||||
states: Map<number, ToggleableItemState>,
|
|
||||||
id: number
|
|
||||||
) {
|
|
||||||
for (const descendantID of this.getDescendantIDs(id)) {
|
for (const descendantID of this.getDescendantIDs(id)) {
|
||||||
states.delete(descendantID)
|
this.temporarySelectionStates.delete(descendantID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -333,7 +320,7 @@ export class FilterableDropdownSelectionModel {
|
|||||||
|
|
||||||
while (queue.length) {
|
while (queue.length) {
|
||||||
const parentID = queue.shift()
|
const parentID = queue.shift()
|
||||||
for (const item of this.items) {
|
for (const item of this._items) {
|
||||||
if (
|
if (
|
||||||
typeof item?.id === 'number' &&
|
typeof item?.id === 'number' &&
|
||||||
typeof (item as any)['parent'] === 'number' &&
|
typeof (item as any)['parent'] === 'number' &&
|
||||||
@@ -349,12 +336,12 @@ export class FilterableDropdownSelectionModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
get logicalOperator(): LogicalOperator {
|
get logicalOperator(): LogicalOperator {
|
||||||
return this.temporaryLogicalOperator()
|
return this.temporaryLogicalOperator
|
||||||
}
|
}
|
||||||
|
|
||||||
set logicalOperator(operator: LogicalOperator) {
|
set logicalOperator(operator: LogicalOperator) {
|
||||||
this.temporaryLogicalOperator.set(operator)
|
this.temporaryLogicalOperator = operator
|
||||||
this._items.set(this.withNullItem(this.items))
|
this.setNullItem()
|
||||||
}
|
}
|
||||||
|
|
||||||
toggleOperator() {
|
toggleOperator() {
|
||||||
@@ -362,12 +349,12 @@ export class FilterableDropdownSelectionModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
get intersection(): Intersection {
|
get intersection(): Intersection {
|
||||||
return this.temporaryIntersection()
|
return this.temporaryIntersection
|
||||||
}
|
}
|
||||||
|
|
||||||
set intersection(intersection: Intersection) {
|
set intersection(intersection: Intersection) {
|
||||||
this.temporaryIntersection.set(intersection)
|
this.temporaryIntersection = intersection
|
||||||
this._items.set(this.withNullItem(this.items))
|
this.setNullItem()
|
||||||
}
|
}
|
||||||
|
|
||||||
toggleIntersection() {
|
toggleIntersection() {
|
||||||
@@ -377,20 +364,18 @@ export class FilterableDropdownSelectionModel {
|
|||||||
? ToggleableItemState.Selected
|
? ToggleableItemState.Selected
|
||||||
: ToggleableItemState.Excluded
|
: ToggleableItemState.Excluded
|
||||||
|
|
||||||
const states = new Map(this.temporarySelectionStates)
|
this.temporarySelectionStates.forEach((state, key) => {
|
||||||
states.forEach((state, key) => {
|
|
||||||
if (key === null && this.intersection === Intersection.Exclude) {
|
if (key === null && this.intersection === Intersection.Exclude) {
|
||||||
states.set(NEGATIVE_NULL_FILTER_VALUE, newState)
|
this.temporarySelectionStates.set(NEGATIVE_NULL_FILTER_VALUE, newState)
|
||||||
} else if (
|
} else if (
|
||||||
key === NEGATIVE_NULL_FILTER_VALUE &&
|
key === NEGATIVE_NULL_FILTER_VALUE &&
|
||||||
this.intersection === Intersection.Include
|
this.intersection === Intersection.Include
|
||||||
) {
|
) {
|
||||||
states.set(null, newState)
|
this.temporarySelectionStates.set(null, newState)
|
||||||
} else {
|
} else {
|
||||||
states.set(key, newState)
|
this.temporarySelectionStates.set(key, newState)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
this._temporarySelectionStates.set(states)
|
|
||||||
|
|
||||||
this.changed.next(this)
|
this.changed.next(this)
|
||||||
}
|
}
|
||||||
@@ -410,12 +395,10 @@ export class FilterableDropdownSelectionModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
clear(fireEvent = true) {
|
clear(fireEvent = true) {
|
||||||
this._temporarySelectionStates.set(new Map())
|
this.temporarySelectionStates.clear()
|
||||||
this.temporaryLogicalOperator.set(LogicalOperator.And)
|
this.temporaryLogicalOperator = this._logicalOperator = LogicalOperator.And
|
||||||
this._logicalOperator.set(LogicalOperator.And)
|
this.temporaryIntersection = this._intersection = Intersection.Include
|
||||||
this.temporaryIntersection.set(Intersection.Include)
|
this.setNullItem()
|
||||||
this._intersection.set(Intersection.Include)
|
|
||||||
this._items.set(this.withNullItem(this.items))
|
|
||||||
if (fireEvent) {
|
if (fireEvent) {
|
||||||
this.changed.next(this)
|
this.changed.next(this)
|
||||||
}
|
}
|
||||||
@@ -436,9 +419,9 @@ export class FilterableDropdownSelectionModel {
|
|||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
return true
|
return true
|
||||||
} else if (this.temporaryLogicalOperator() !== this._logicalOperator()) {
|
} else if (this.temporaryLogicalOperator !== this._logicalOperator) {
|
||||||
return true
|
return true
|
||||||
} else if (this.temporaryIntersection() !== this._intersection()) {
|
} else if (this.temporaryIntersection !== this._intersection) {
|
||||||
return true
|
return true
|
||||||
} else {
|
} else {
|
||||||
return false
|
return false
|
||||||
@@ -455,29 +438,23 @@ export class FilterableDropdownSelectionModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
getDocumentCount(id: number) {
|
getDocumentCount(id: number) {
|
||||||
return this._documentCounts().find((c) => c.id === id)?.document_count
|
return this._documentCounts.find((c) => c.id === id)?.document_count
|
||||||
}
|
}
|
||||||
|
|
||||||
private promoteBranchesWithDocumentCounts(
|
private promoteBranchesWithDocumentCounts() {
|
||||||
items: MatchingModel[]
|
const parentById = this.buildParentById()
|
||||||
): MatchingModel[] {
|
|
||||||
const parentById = this.buildParentById(items)
|
|
||||||
const findRootId = this.createRootFinder(parentById)
|
const findRootId = this.createRootFinder(parentById)
|
||||||
const getRootDocCount = this.createRootDocCounter(items)
|
const getRootDocCount = this.createRootDocCounter()
|
||||||
const summaries = this.buildBranchSummaries(
|
const summaries = this.buildBranchSummaries(findRootId, getRootDocCount)
|
||||||
items,
|
|
||||||
findRootId,
|
|
||||||
getRootDocCount
|
|
||||||
)
|
|
||||||
const orderedBranches = this.orderBranchesByPriority(summaries)
|
const orderedBranches = this.orderBranchesByPriority(summaries)
|
||||||
|
|
||||||
return orderedBranches.flatMap((summary) => summary.items)
|
this._items = orderedBranches.flatMap((summary) => summary.items)
|
||||||
}
|
}
|
||||||
|
|
||||||
private buildParentById(items: MatchingModel[]): Map<number, number | null> {
|
private buildParentById(): Map<number, number | null> {
|
||||||
const parentById = new Map<number, number | null>()
|
const parentById = new Map<number, number | null>()
|
||||||
|
|
||||||
for (const item of items) {
|
for (const item of this._items) {
|
||||||
if (typeof item?.id === 'number') {
|
if (typeof item?.id === 'number') {
|
||||||
const parentValue = (item as any)['parent']
|
const parentValue = (item as any)['parent']
|
||||||
parentById.set(
|
parentById.set(
|
||||||
@@ -515,9 +492,7 @@ export class FilterableDropdownSelectionModel {
|
|||||||
return findRootId
|
return findRootId
|
||||||
}
|
}
|
||||||
|
|
||||||
private createRootDocCounter(
|
private createRootDocCounter(): (rootId: number) => number {
|
||||||
items: MatchingModel[]
|
|
||||||
): (rootId: number) => number {
|
|
||||||
const docCountMemo = new Map<number, number>()
|
const docCountMemo = new Map<number, number>()
|
||||||
|
|
||||||
return (rootId: number): number => {
|
return (rootId: number): number => {
|
||||||
@@ -532,7 +507,7 @@ export class FilterableDropdownSelectionModel {
|
|||||||
return explicit
|
return explicit
|
||||||
}
|
}
|
||||||
|
|
||||||
const rootItem = items.find((i) => i.id === rootId)
|
const rootItem = this._items.find((i) => i.id === rootId)
|
||||||
const fallback =
|
const fallback =
|
||||||
typeof (rootItem as any)?.['document_count'] === 'number'
|
typeof (rootItem as any)?.['document_count'] === 'number'
|
||||||
? (rootItem as any)['document_count']
|
? (rootItem as any)['document_count']
|
||||||
@@ -544,13 +519,12 @@ export class FilterableDropdownSelectionModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private buildBranchSummaries(
|
private buildBranchSummaries(
|
||||||
items: MatchingModel[],
|
|
||||||
findRootId: (id: number) => number,
|
findRootId: (id: number) => number,
|
||||||
getRootDocCount: (rootId: number) => number
|
getRootDocCount: (rootId: number) => number
|
||||||
): Map<string, BranchSummary> {
|
): Map<string, BranchSummary> {
|
||||||
const summaries = new Map<string, BranchSummary>()
|
const summaries = new Map<string, BranchSummary>()
|
||||||
|
|
||||||
for (const [index, item] of items.entries()) {
|
for (const [index, item] of this._items.entries()) {
|
||||||
const { key, special, rootId } = this.describeBranchItem(
|
const { key, special, rootId } = this.describeBranchItem(
|
||||||
item,
|
item,
|
||||||
index,
|
index,
|
||||||
@@ -642,23 +616,28 @@ export class FilterableDropdownSelectionModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
init(map: Map<number, ToggleableItemState>) {
|
init(map: Map<number, ToggleableItemState>) {
|
||||||
this._temporarySelectionStates.set(new Map(map))
|
this.temporarySelectionStates = map
|
||||||
this.apply()
|
this.apply()
|
||||||
}
|
}
|
||||||
|
|
||||||
apply() {
|
apply() {
|
||||||
this._selectionStates.set(new Map(this.temporarySelectionStates))
|
this.selectionStates.clear()
|
||||||
this._logicalOperator.set(this.temporaryLogicalOperator())
|
this.temporarySelectionStates.forEach((value, key) => {
|
||||||
this._intersection.set(this.temporaryIntersection())
|
this.selectionStates.set(key, value)
|
||||||
this._items.set(this.sortItems(this.items))
|
})
|
||||||
|
this._logicalOperator = this.temporaryLogicalOperator
|
||||||
|
this._intersection = this.temporaryIntersection
|
||||||
|
this.sortItems()
|
||||||
}
|
}
|
||||||
|
|
||||||
reset(complete: boolean = false) {
|
reset(complete: boolean = false) {
|
||||||
|
this.temporarySelectionStates.clear()
|
||||||
if (complete) {
|
if (complete) {
|
||||||
this._selectionStates.set(new Map())
|
this.selectionStates.clear()
|
||||||
this._temporarySelectionStates.set(new Map())
|
|
||||||
} else {
|
} else {
|
||||||
this._temporarySelectionStates.set(new Map(this.selectionStates))
|
this.selectionStates.forEach((value, key) => {
|
||||||
|
this.temporarySelectionStates.set(key, value)
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,8 +7,6 @@
|
|||||||
padding-left: calc(calc(var(--depth) - 2) * 1rem);
|
padding-left: calc(calc(var(--depth) - 2) * 1rem);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
min-width: 0;
|
|
||||||
overflow-wrap: anywhere;
|
|
||||||
|
|
||||||
.indicator {
|
.indicator {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
@@ -20,7 +18,3 @@
|
|||||||
margin-left: .5rem;
|
margin-left: .5rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.badge {
|
|
||||||
flex-shrink: 0;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -47,8 +47,8 @@
|
|||||||
<i-bs width="0.9em" height="0.9em" name="file-text" class="me-1"></i-bs><span>{{document.title}}</span>
|
<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">
|
<span class="badge bg-light text-muted" (click)="unselect(document)" (mousedown)="$event.stopImmediatePropagation()" type="button" title="Remove link" i18n-title>
|
||||||
<i-bs width="0.9em" height="0.9em" name="exclamation-triangle-fill" class="me-1"></i-bs><span i18n>Unavailable</span>
|
<i-bs width="0.9em" height="0.9em" name="exclamation-triangle-fill" class="me-1"></i-bs><span i18n>Not found</span>
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -151,23 +151,6 @@ 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) => {
|
||||||
@@ -184,15 +167,6 @@ 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 }
|
(id) => documentResults.results.find((d) => d.id === id) ?? {}
|
||||||
)
|
)
|
||||||
super.writeValue(documentIDs)
|
super.writeValue(documentIDs)
|
||||||
})
|
})
|
||||||
@@ -142,8 +142,6 @@ 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,7 +3,6 @@
|
|||||||
<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) {
|
||||||
@@ -18,7 +17,6 @@
|
|||||||
<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,11 +1,3 @@
|
|||||||
.badge {
|
.badge {
|
||||||
cursor: move;
|
cursor: move;
|
||||||
}
|
}
|
||||||
|
|
||||||
.cdk-drop-list-disabled {
|
|
||||||
cursor: not-allowed !important;
|
|
||||||
|
|
||||||
* {
|
|
||||||
pointer-events: none !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -98,29 +98,4 @@ 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,8 +46,6 @@ 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,10 +40,6 @@ 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 = 'off'
|
autocomplete: string
|
||||||
|
|
||||||
public textVisible: boolean = false
|
public textVisible: boolean = false
|
||||||
|
|
||||||
|
|||||||
@@ -36,16 +36,11 @@
|
|||||||
(focus)="clearLastSearchTerm()"
|
(focus)="clearLastSearchTerm()"
|
||||||
(clear)="clearLastSearchTerm()"
|
(clear)="clearLastSearchTerm()"
|
||||||
(blur)="onBlur()">
|
(blur)="onBlur()">
|
||||||
<ng-template ng-label-tmp let-item="item" let-clear="clear">
|
<ng-template ng-label-tmp let-item="item">
|
||||||
@if (multiple && !disabled) {
|
@if (iconField && item[iconField]) {
|
||||||
<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>
|
<i-bs class="me-2" [name]="item[iconField]"></i-bs>
|
||||||
}
|
}
|
||||||
<span class="ng-value-label" [title]="item[bindLabel]">
|
<span [title]="item[bindLabel]">{{item[bindLabel]}}</span>
|
||||||
@if (iconField && item[iconField]) {
|
|
||||||
<i-bs class="me-2" [name]="item[iconField]"></i-bs>
|
|
||||||
}
|
|
||||||
{{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,12 +24,6 @@ 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 = 'off'
|
autocomplete: string
|
||||||
|
|
||||||
@Input()
|
@Input()
|
||||||
placeholder: string = ''
|
placeholder: string = ''
|
||||||
|
|||||||
@@ -1,3 +0,0 @@
|
|||||||
<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>
|
|
||||||
|
Before Width: | Height: | Size: 545 B |
@@ -1,14 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
@@ -7,7 +7,7 @@
|
|||||||
<div class="list-group list-group-flush">
|
<div class="list-group list-group-flush">
|
||||||
<button class="list-group-item list-group-item-action d-flex align-items-center p-2 border-top-0 border-start-0 border-end-0 border-bottom" role="menuitem" (click)="setFilter(OwnerFilterType.NONE)" [disabled]="disabled">
|
<button class="list-group-item list-group-item-action d-flex align-items-center p-2 border-top-0 border-start-0 border-end-0 border-bottom" role="menuitem" (click)="setFilter(OwnerFilterType.NONE)" [disabled]="disabled">
|
||||||
<div class="selected-icon me-1">
|
<div class="selected-icon me-1">
|
||||||
@if (selectionModel.ownerFilter() === OwnerFilterType.NONE) {
|
@if (selectionModel.ownerFilter === OwnerFilterType.NONE) {
|
||||||
<i-bs width="1em" height="1em" name="check"></i-bs>
|
<i-bs width="1em" height="1em" name="check"></i-bs>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
@@ -17,7 +17,7 @@
|
|||||||
</button>
|
</button>
|
||||||
<button class="list-group-item list-group-item-action d-flex align-items-center p-2 border-top-0 border-start-0 border-end-0 border-bottom" role="menuitem" (click)="setFilter(OwnerFilterType.SELF)" [disabled]="disabled">
|
<button class="list-group-item list-group-item-action d-flex align-items-center p-2 border-top-0 border-start-0 border-end-0 border-bottom" role="menuitem" (click)="setFilter(OwnerFilterType.SELF)" [disabled]="disabled">
|
||||||
<div class="selected-icon me-1">
|
<div class="selected-icon me-1">
|
||||||
@if (selectionModel.ownerFilter() === OwnerFilterType.SELF) {
|
@if (selectionModel.ownerFilter === OwnerFilterType.SELF) {
|
||||||
<i-bs width="1em" height="1em" name="check"></i-bs>
|
<i-bs width="1em" height="1em" name="check"></i-bs>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
@@ -27,7 +27,7 @@
|
|||||||
</button>
|
</button>
|
||||||
<button class="list-group-item list-group-item-action d-flex align-items-center p-2 border-top-0 border-start-0 border-end-0 border-bottom" role="menuitem" (click)="setFilter(OwnerFilterType.NOT_SELF)" [disabled]="disabled">
|
<button class="list-group-item list-group-item-action d-flex align-items-center p-2 border-top-0 border-start-0 border-end-0 border-bottom" role="menuitem" (click)="setFilter(OwnerFilterType.NOT_SELF)" [disabled]="disabled">
|
||||||
<div class="selected-icon me-1">
|
<div class="selected-icon me-1">
|
||||||
@if (selectionModel.ownerFilter() === OwnerFilterType.NOT_SELF) {
|
@if (selectionModel.ownerFilter === OwnerFilterType.NOT_SELF) {
|
||||||
<i-bs width="1em" height="1em" name="check"></i-bs>
|
<i-bs width="1em" height="1em" name="check"></i-bs>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
@@ -37,7 +37,7 @@
|
|||||||
</button>
|
</button>
|
||||||
<button class="list-group-item list-group-item-action d-flex align-items-center p-2 border-top-0 border-start-0 border-end-0 border-bottom" role="menuitem" (click)="setFilter(OwnerFilterType.SHARED_BY_ME)" [disabled]="disabled">
|
<button class="list-group-item list-group-item-action d-flex align-items-center p-2 border-top-0 border-start-0 border-end-0 border-bottom" role="menuitem" (click)="setFilter(OwnerFilterType.SHARED_BY_ME)" [disabled]="disabled">
|
||||||
<div class="selected-icon me-1">
|
<div class="selected-icon me-1">
|
||||||
@if (selectionModel.ownerFilter() === OwnerFilterType.SHARED_BY_ME) {
|
@if (selectionModel.ownerFilter === OwnerFilterType.SHARED_BY_ME) {
|
||||||
<i-bs width="1em" height="1em" name="check"></i-bs>
|
<i-bs width="1em" height="1em" name="check"></i-bs>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
@@ -47,7 +47,7 @@
|
|||||||
</button>
|
</button>
|
||||||
<button class="list-group-item list-group-item-action d-flex align-items-center p-2 border-top-0 border-start-0 border-end-0 border-bottom" role="menuitem" (click)="setFilter(OwnerFilterType.UNOWNED)" [disabled]="disabled">
|
<button class="list-group-item list-group-item-action d-flex align-items-center p-2 border-top-0 border-start-0 border-end-0 border-bottom" role="menuitem" (click)="setFilter(OwnerFilterType.UNOWNED)" [disabled]="disabled">
|
||||||
<div class="selected-icon me-1">
|
<div class="selected-icon me-1">
|
||||||
@if (selectionModel.ownerFilter() === OwnerFilterType.UNOWNED) {
|
@if (selectionModel.ownerFilter === OwnerFilterType.UNOWNED) {
|
||||||
<i-bs width="1em" height="1em" name="check"></i-bs>
|
<i-bs width="1em" height="1em" name="check"></i-bs>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
@@ -57,7 +57,7 @@
|
|||||||
</button>
|
</button>
|
||||||
<button *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.User }" class="list-group-item list-group-item-action d-flex align-items-center p-2 border-top-0 border-start-0 border-end-0 border-bottom" role="menuitem" [disabled]="disabled">
|
<button *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.User }" class="list-group-item list-group-item-action d-flex align-items-center p-2 border-top-0 border-start-0 border-end-0 border-bottom" role="menuitem" [disabled]="disabled">
|
||||||
<div class="selected-icon me-1">
|
<div class="selected-icon me-1">
|
||||||
@if (selectionModel.ownerFilter() === OwnerFilterType.OTHERS) {
|
@if (selectionModel.ownerFilter === OwnerFilterType.OTHERS) {
|
||||||
<i-bs width="1em" height="1em" name="check"></i-bs>
|
<i-bs width="1em" height="1em" name="check"></i-bs>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
@@ -65,8 +65,7 @@
|
|||||||
<ng-select
|
<ng-select
|
||||||
name="user"
|
name="user"
|
||||||
class="user-select small"
|
class="user-select small"
|
||||||
[ngModel]="selectionModel.includeUsers()"
|
[(ngModel)]="selectionModel.includeUsers"
|
||||||
(ngModelChange)="selectionModel.includeUsers.set($event)"
|
|
||||||
[disabled]="disabled"
|
[disabled]="disabled"
|
||||||
[clearable]="false"
|
[clearable]="false"
|
||||||
[items]="users()"
|
[items]="users()"
|
||||||
@@ -79,10 +78,10 @@
|
|||||||
</ng-select>
|
</ng-select>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
@if (selectionModel.ownerFilter() === OwnerFilterType.NONE || selectionModel.ownerFilter() === OwnerFilterType.NOT_SELF) {
|
@if (selectionModel.ownerFilter === OwnerFilterType.NONE || selectionModel.ownerFilter === OwnerFilterType.NOT_SELF) {
|
||||||
<div class="list-group-item list-group-item-action d-flex align-items-center p-2 ps-3 border-bottom-0 border-start-0 border-end-0">
|
<div class="list-group-item list-group-item-action d-flex align-items-center p-2 ps-3 border-bottom-0 border-start-0 border-end-0">
|
||||||
<div class="form-check form-switch w-100">
|
<div class="form-check form-switch w-100">
|
||||||
<input type="checkbox" class="form-check-input" id="hideUnowned" [ngModel]="selectionModel.hideUnowned()" (ngModelChange)="selectionModel.hideUnowned.set($event)" (change)="onChange()" [disabled]="disabled">
|
<input type="checkbox" class="form-check-input" id="hideUnowned" [(ngModel)]="this.selectionModel.hideUnowned" (change)="onChange()" [disabled]="disabled">
|
||||||
<label class="form-check-label w-100" for="hideUnowned"><small i18n>Hide unowned</small></label>
|
<label class="form-check-label w-100" for="hideUnowned"><small i18n>Hide unowned</small></label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -90,56 +90,56 @@ describe('PermissionsFilterDropdownComponent', () => {
|
|||||||
component.setFilter(OwnerFilterType.OTHERS)
|
component.setFilter(OwnerFilterType.OTHERS)
|
||||||
expect(component.isActive).toBeTruthy()
|
expect(component.isActive).toBeTruthy()
|
||||||
component.setFilter(OwnerFilterType.NONE)
|
component.setFilter(OwnerFilterType.NONE)
|
||||||
component.selectionModel.hideUnowned.set(true)
|
component.selectionModel.hideUnowned = true
|
||||||
expect(component.isActive).toBeTruthy()
|
expect(component.isActive).toBeTruthy()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should describe concrete user filters honestly', () => {
|
it('should describe concrete user filters honestly', () => {
|
||||||
component.selectionModel.ownerFilter.set(OwnerFilterType.SELF)
|
component.selectionModel.ownerFilter = OwnerFilterType.SELF
|
||||||
component.selectionModel.userID.set(1)
|
component.selectionModel.userID = 1
|
||||||
expect(component.ownerFilterLabel).toEqual('Owned by user1')
|
expect(component.ownerFilterLabel).toEqual('Owned by user1')
|
||||||
|
|
||||||
component.selectionModel.ownerFilter.set(OwnerFilterType.NOT_SELF)
|
component.selectionModel.ownerFilter = OwnerFilterType.NOT_SELF
|
||||||
component.selectionModel.excludeUsers.set([1])
|
component.selectionModel.excludeUsers = [1]
|
||||||
expect(component.ownerExclusionFilterLabel).toEqual('Not owned by user1')
|
expect(component.ownerExclusionFilterLabel).toEqual('Not owned by user1')
|
||||||
|
|
||||||
component.selectionModel.ownerFilter.set(OwnerFilterType.SHARED_BY_ME)
|
component.selectionModel.ownerFilter = OwnerFilterType.SHARED_BY_ME
|
||||||
component.selectionModel.userID.set(1)
|
component.selectionModel.userID = 1
|
||||||
expect(component.sharedByFilterLabel).toEqual('Shared by user1')
|
expect(component.sharedByFilterLabel).toEqual('Shared by user1')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should describe concrete filters when usernames are unavailable', () => {
|
it('should describe concrete filters when usernames are unavailable', () => {
|
||||||
component.selectionModel.ownerFilter.set(OwnerFilterType.SELF)
|
component.selectionModel.ownerFilter = OwnerFilterType.SELF
|
||||||
component.selectionModel.userID.set(99)
|
component.selectionModel.userID = 99
|
||||||
expect(component.ownerFilterLabel).toEqual('Owned by another user')
|
expect(component.ownerFilterLabel).toEqual('Owned by another user')
|
||||||
|
|
||||||
component.selectionModel.ownerFilter.set(OwnerFilterType.NOT_SELF)
|
component.selectionModel.ownerFilter = OwnerFilterType.NOT_SELF
|
||||||
component.selectionModel.excludeUsers.set([99])
|
component.selectionModel.excludeUsers = [99]
|
||||||
expect(component.ownerExclusionFilterLabel).toEqual(
|
expect(component.ownerExclusionFilterLabel).toEqual(
|
||||||
'Not owned by another user'
|
'Not owned by another user'
|
||||||
)
|
)
|
||||||
|
|
||||||
component.selectionModel.excludeUsers.set([98, 99])
|
component.selectionModel.excludeUsers = [98, 99]
|
||||||
expect(component.ownerExclusionFilterLabel).toEqual(
|
expect(component.ownerExclusionFilterLabel).toEqual(
|
||||||
'Not owned by selected users'
|
'Not owned by selected users'
|
||||||
)
|
)
|
||||||
|
|
||||||
component.selectionModel.ownerFilter.set(OwnerFilterType.SHARED_BY_ME)
|
component.selectionModel.ownerFilter = OwnerFilterType.SHARED_BY_ME
|
||||||
component.selectionModel.userID.set(99)
|
component.selectionModel.userID = 99
|
||||||
expect(component.sharedByFilterLabel).toEqual('Shared by another user')
|
expect(component.sharedByFilterLabel).toEqual('Shared by another user')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should retain relative labels for filters bound to the current user', () => {
|
it('should retain relative labels for filters bound to the current user', () => {
|
||||||
component.selectionModel.userID.set(currentUserID)
|
component.selectionModel.userID = currentUserID
|
||||||
expect(component.ownerFilterLabel).toEqual('My documents')
|
expect(component.ownerFilterLabel).toEqual('My documents')
|
||||||
expect(component.sharedByFilterLabel).toEqual('Shared by me')
|
expect(component.sharedByFilterLabel).toEqual('Shared by me')
|
||||||
|
|
||||||
component.selectionModel.excludeUsers.set([currentUserID])
|
component.selectionModel.excludeUsers = [currentUserID]
|
||||||
expect(component.ownerExclusionFilterLabel).toEqual('Shared with me')
|
expect(component.ownerExclusionFilterLabel).toEqual('Shared with me')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should retain relative labels for inactive filter choices', () => {
|
it('should retain relative labels for inactive filter choices', () => {
|
||||||
component.selectionModel.ownerFilter.set(OwnerFilterType.NONE)
|
component.selectionModel.ownerFilter = OwnerFilterType.NONE
|
||||||
|
|
||||||
expect(component.ownerFilterLabel).toEqual('My documents')
|
expect(component.ownerFilterLabel).toEqual('My documents')
|
||||||
expect(component.ownerExclusionFilterLabel).toEqual('Shared with me')
|
expect(component.ownerExclusionFilterLabel).toEqual('Shared with me')
|
||||||
@@ -148,41 +148,32 @@ describe('PermissionsFilterDropdownComponent', () => {
|
|||||||
|
|
||||||
it('should support reset', () => {
|
it('should support reset', () => {
|
||||||
component.setFilter(OwnerFilterType.OTHERS)
|
component.setFilter(OwnerFilterType.OTHERS)
|
||||||
expect(component.selectionModel.ownerFilter()).not.toEqual(
|
expect(component.selectionModel.ownerFilter).not.toEqual(
|
||||||
OwnerFilterType.NONE
|
OwnerFilterType.NONE
|
||||||
)
|
)
|
||||||
component.reset()
|
component.reset()
|
||||||
expect(component.selectionModel.ownerFilter()).toEqual(OwnerFilterType.NONE)
|
expect(component.selectionModel.ownerFilter).toEqual(OwnerFilterType.NONE)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should toggle owner filter type when users selected', () => {
|
it('should toggle owner filter type when users selected', () => {
|
||||||
component.selectionModel.ownerFilter.set(OwnerFilterType.NONE)
|
component.selectionModel.ownerFilter = OwnerFilterType.NONE
|
||||||
|
|
||||||
// this would normally be done by select component
|
// this would normally be done by select component
|
||||||
component.selectionModel.includeUsers.set([12])
|
component.selectionModel.includeUsers = [12]
|
||||||
component.onUserSelect()
|
component.onUserSelect()
|
||||||
expect(component.selectionModel.ownerFilter()).toEqual(
|
expect(component.selectionModel.ownerFilter).toEqual(OwnerFilterType.OTHERS)
|
||||||
OwnerFilterType.OTHERS
|
|
||||||
)
|
|
||||||
|
|
||||||
// this would normally be done by select component
|
// this would normally be done by select component
|
||||||
component.selectionModel.includeUsers.set(null)
|
component.selectionModel.includeUsers = null
|
||||||
component.onUserSelect()
|
component.onUserSelect()
|
||||||
|
|
||||||
expect(component.selectionModel.ownerFilter()).toEqual(OwnerFilterType.NONE)
|
expect(component.selectionModel.ownerFilter).toEqual(OwnerFilterType.NONE)
|
||||||
})
|
})
|
||||||
it('should emit a selection model depending on the type of owner filter set', () => {
|
it('should emit a selection model depending on the type of owner filter set', () => {
|
||||||
const emitted = () => ({
|
component.selectionModel.ownerFilter = OwnerFilterType.NONE
|
||||||
excludeUsers: ownerFilterSetResult.excludeUsers(),
|
|
||||||
hideUnowned: ownerFilterSetResult.hideUnowned(),
|
|
||||||
includeUsers: ownerFilterSetResult.includeUsers(),
|
|
||||||
ownerFilter: ownerFilterSetResult.ownerFilter(),
|
|
||||||
userID: ownerFilterSetResult.userID(),
|
|
||||||
})
|
|
||||||
component.selectionModel.ownerFilter.set(OwnerFilterType.NONE)
|
|
||||||
|
|
||||||
component.setFilter(OwnerFilterType.SELF)
|
component.setFilter(OwnerFilterType.SELF)
|
||||||
expect(emitted()).toEqual({
|
expect(ownerFilterSetResult).toEqual({
|
||||||
excludeUsers: [],
|
excludeUsers: [],
|
||||||
hideUnowned: false,
|
hideUnowned: false,
|
||||||
includeUsers: [],
|
includeUsers: [],
|
||||||
@@ -191,7 +182,7 @@ describe('PermissionsFilterDropdownComponent', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
component.setFilter(OwnerFilterType.NOT_SELF)
|
component.setFilter(OwnerFilterType.NOT_SELF)
|
||||||
expect(emitted()).toEqual({
|
expect(ownerFilterSetResult).toEqual({
|
||||||
excludeUsers: [currentUserID],
|
excludeUsers: [currentUserID],
|
||||||
hideUnowned: false,
|
hideUnowned: false,
|
||||||
includeUsers: [],
|
includeUsers: [],
|
||||||
@@ -200,7 +191,7 @@ describe('PermissionsFilterDropdownComponent', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
component.setFilter(OwnerFilterType.NONE)
|
component.setFilter(OwnerFilterType.NONE)
|
||||||
expect(emitted()).toEqual({
|
expect(ownerFilterSetResult).toEqual({
|
||||||
excludeUsers: [],
|
excludeUsers: [],
|
||||||
hideUnowned: false,
|
hideUnowned: false,
|
||||||
includeUsers: [],
|
includeUsers: [],
|
||||||
@@ -209,7 +200,7 @@ describe('PermissionsFilterDropdownComponent', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
component.setFilter(OwnerFilterType.SHARED_BY_ME)
|
component.setFilter(OwnerFilterType.SHARED_BY_ME)
|
||||||
expect(emitted()).toEqual({
|
expect(ownerFilterSetResult).toEqual({
|
||||||
excludeUsers: [],
|
excludeUsers: [],
|
||||||
hideUnowned: false,
|
hideUnowned: false,
|
||||||
includeUsers: [],
|
includeUsers: [],
|
||||||
@@ -218,7 +209,7 @@ describe('PermissionsFilterDropdownComponent', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
component.setFilter(OwnerFilterType.UNOWNED)
|
component.setFilter(OwnerFilterType.UNOWNED)
|
||||||
expect(emitted()).toEqual({
|
expect(ownerFilterSetResult).toEqual({
|
||||||
excludeUsers: [],
|
excludeUsers: [],
|
||||||
hideUnowned: false,
|
hideUnowned: false,
|
||||||
includeUsers: [],
|
includeUsers: [],
|
||||||
|
|||||||
@@ -25,18 +25,18 @@ import { ComponentWithPermissions } from '../../with-permissions/with-permission
|
|||||||
import { ClearableBadgeComponent } from '../clearable-badge/clearable-badge.component'
|
import { ClearableBadgeComponent } from '../clearable-badge/clearable-badge.component'
|
||||||
|
|
||||||
export class PermissionsSelectionModel {
|
export class PermissionsSelectionModel {
|
||||||
readonly ownerFilter = signal(OwnerFilterType.NONE)
|
ownerFilter: OwnerFilterType
|
||||||
readonly hideUnowned = signal(false)
|
hideUnowned: boolean
|
||||||
readonly userID = signal<number>(null)
|
userID: number
|
||||||
readonly includeUsers = signal<number[]>([])
|
includeUsers: number[]
|
||||||
readonly excludeUsers = signal<number[]>([])
|
excludeUsers: number[]
|
||||||
|
|
||||||
clear() {
|
clear() {
|
||||||
this.ownerFilter.set(OwnerFilterType.NONE)
|
this.ownerFilter = OwnerFilterType.NONE
|
||||||
this.userID.set(null)
|
this.userID = null
|
||||||
this.hideUnowned.set(false)
|
this.hideUnowned = false
|
||||||
this.includeUsers.set([])
|
this.includeUsers = []
|
||||||
this.excludeUsers.set([])
|
this.excludeUsers = []
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,31 +84,33 @@ export class PermissionsFilterDropdownComponent extends ComponentWithPermissions
|
|||||||
|
|
||||||
readonly users = signal<User[]>([])
|
readonly users = signal<User[]>([])
|
||||||
|
|
||||||
|
hideUnowned: boolean
|
||||||
|
|
||||||
get isActive(): boolean {
|
get isActive(): boolean {
|
||||||
return (
|
return (
|
||||||
this.selectionModel.ownerFilter() !== OwnerFilterType.NONE ||
|
this.selectionModel.ownerFilter !== OwnerFilterType.NONE ||
|
||||||
this.selectionModel.hideUnowned()
|
this.selectionModel.hideUnowned
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
get ownerFilterLabel(): string {
|
get ownerFilterLabel(): string {
|
||||||
if (
|
if (
|
||||||
this.selectionModel?.ownerFilter() !== OwnerFilterType.SELF ||
|
this.selectionModel?.ownerFilter !== OwnerFilterType.SELF ||
|
||||||
this.selectionModel?.userID() === this.settingsService.currentUser()?.id
|
this.selectionModel?.userID === this.settingsService.currentUser()?.id
|
||||||
) {
|
) {
|
||||||
return $localize`My documents`
|
return $localize`My documents`
|
||||||
}
|
}
|
||||||
|
|
||||||
const username = this.getUsername(this.selectionModel?.userID())
|
const username = this.getUsername(this.selectionModel?.userID)
|
||||||
return username
|
return username
|
||||||
? $localize`Owned by ${username}`
|
? $localize`Owned by ${username}`
|
||||||
: $localize`Owned by another user`
|
: $localize`Owned by another user`
|
||||||
}
|
}
|
||||||
|
|
||||||
get ownerExclusionFilterLabel(): string {
|
get ownerExclusionFilterLabel(): string {
|
||||||
const excludedUsers = this.selectionModel?.excludeUsers() ?? []
|
const excludedUsers = this.selectionModel?.excludeUsers ?? []
|
||||||
if (
|
if (
|
||||||
this.selectionModel?.ownerFilter() !== OwnerFilterType.NOT_SELF ||
|
this.selectionModel?.ownerFilter !== OwnerFilterType.NOT_SELF ||
|
||||||
(excludedUsers.length === 1 &&
|
(excludedUsers.length === 1 &&
|
||||||
excludedUsers[0] === this.settingsService.currentUser()?.id)
|
excludedUsers[0] === this.settingsService.currentUser()?.id)
|
||||||
) {
|
) {
|
||||||
@@ -128,13 +130,13 @@ export class PermissionsFilterDropdownComponent extends ComponentWithPermissions
|
|||||||
|
|
||||||
get sharedByFilterLabel(): string {
|
get sharedByFilterLabel(): string {
|
||||||
if (
|
if (
|
||||||
this.selectionModel?.ownerFilter() !== OwnerFilterType.SHARED_BY_ME ||
|
this.selectionModel?.ownerFilter !== OwnerFilterType.SHARED_BY_ME ||
|
||||||
this.selectionModel?.userID() === this.settingsService.currentUser()?.id
|
this.selectionModel?.userID === this.settingsService.currentUser()?.id
|
||||||
) {
|
) {
|
||||||
return $localize`Shared by me`
|
return $localize`Shared by me`
|
||||||
}
|
}
|
||||||
|
|
||||||
const username = this.getUsername(this.selectionModel?.userID())
|
const username = this.getUsername(this.selectionModel?.userID)
|
||||||
return username
|
return username
|
||||||
? $localize`Shared by ${username}`
|
? $localize`Shared by ${username}`
|
||||||
: $localize`Shared by another user`
|
: $localize`Shared by another user`
|
||||||
@@ -167,36 +169,34 @@ export class PermissionsFilterDropdownComponent extends ComponentWithPermissions
|
|||||||
}
|
}
|
||||||
|
|
||||||
setFilter(type: OwnerFilterType) {
|
setFilter(type: OwnerFilterType) {
|
||||||
this.selectionModel.ownerFilter.set(type)
|
this.selectionModel.ownerFilter = type
|
||||||
if (this.selectionModel.ownerFilter() === OwnerFilterType.SELF) {
|
if (this.selectionModel.ownerFilter === OwnerFilterType.SELF) {
|
||||||
this.selectionModel.includeUsers.set([])
|
this.selectionModel.includeUsers = []
|
||||||
this.selectionModel.excludeUsers.set([])
|
this.selectionModel.excludeUsers = []
|
||||||
this.selectionModel.userID.set(this.settingsService.currentUser().id)
|
this.selectionModel.userID = this.settingsService.currentUser().id
|
||||||
this.selectionModel.hideUnowned.set(false)
|
this.selectionModel.hideUnowned = false
|
||||||
} else if (this.selectionModel.ownerFilter() === OwnerFilterType.NOT_SELF) {
|
} else if (this.selectionModel.ownerFilter === OwnerFilterType.NOT_SELF) {
|
||||||
this.selectionModel.userID.set(null)
|
this.selectionModel.userID = null
|
||||||
this.selectionModel.includeUsers.set([])
|
this.selectionModel.includeUsers = []
|
||||||
this.selectionModel.excludeUsers.set([
|
this.selectionModel.excludeUsers = [this.settingsService.currentUser().id]
|
||||||
this.settingsService.currentUser().id,
|
this.selectionModel.hideUnowned = false
|
||||||
])
|
} else if (this.selectionModel.ownerFilter === OwnerFilterType.NONE) {
|
||||||
this.selectionModel.hideUnowned.set(false)
|
this.selectionModel.userID = null
|
||||||
} else if (this.selectionModel.ownerFilter() === OwnerFilterType.NONE) {
|
this.selectionModel.includeUsers = []
|
||||||
this.selectionModel.userID.set(null)
|
this.selectionModel.excludeUsers = []
|
||||||
this.selectionModel.includeUsers.set([])
|
this.selectionModel.hideUnowned = false
|
||||||
this.selectionModel.excludeUsers.set([])
|
|
||||||
this.selectionModel.hideUnowned.set(false)
|
|
||||||
} else if (
|
} else if (
|
||||||
this.selectionModel.ownerFilter() === OwnerFilterType.SHARED_BY_ME
|
this.selectionModel.ownerFilter === OwnerFilterType.SHARED_BY_ME
|
||||||
) {
|
) {
|
||||||
this.selectionModel.userID.set(this.settingsService.currentUser()?.id)
|
this.selectionModel.userID = this.settingsService.currentUser()?.id
|
||||||
this.selectionModel.includeUsers.set([])
|
this.selectionModel.includeUsers = []
|
||||||
this.selectionModel.excludeUsers.set([])
|
this.selectionModel.excludeUsers = []
|
||||||
this.selectionModel.hideUnowned.set(false)
|
this.selectionModel.hideUnowned = false
|
||||||
} else if (this.selectionModel.ownerFilter() === OwnerFilterType.UNOWNED) {
|
} else if (this.selectionModel.ownerFilter === OwnerFilterType.UNOWNED) {
|
||||||
this.selectionModel.userID.set(null)
|
this.selectionModel.userID = null
|
||||||
this.selectionModel.includeUsers.set([])
|
this.selectionModel.includeUsers = []
|
||||||
this.selectionModel.excludeUsers.set([])
|
this.selectionModel.excludeUsers = []
|
||||||
this.selectionModel.hideUnowned.set(false)
|
this.selectionModel.hideUnowned = false
|
||||||
}
|
}
|
||||||
this.onChange()
|
this.onChange()
|
||||||
}
|
}
|
||||||
@@ -206,11 +206,11 @@ export class PermissionsFilterDropdownComponent extends ComponentWithPermissions
|
|||||||
}
|
}
|
||||||
|
|
||||||
onUserSelect() {
|
onUserSelect() {
|
||||||
this.selectionModel.ownerFilter.set(
|
if (this.selectionModel.includeUsers?.length) {
|
||||||
this.selectionModel.includeUsers()?.length
|
this.selectionModel.ownerFilter = OwnerFilterType.OTHERS
|
||||||
? OwnerFilterType.OTHERS
|
} else {
|
||||||
: OwnerFilterType.NONE
|
this.selectionModel.ownerFilter = OwnerFilterType.NONE
|
||||||
)
|
}
|
||||||
this.onChange()
|
this.onChange()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -25,34 +25,29 @@
|
|||||||
|
|
||||||
<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 (novelSuggestions === 0 && reusableSuggestions === 0) {
|
@if (totalSuggestions === 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,115 +21,15 @@ describe('SuggestionsDropdownComponent', () => {
|
|||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should exclude suggested storage path names from totalSuggestions', () => {
|
it('should calculate 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: [],
|
||||||
|
|||||||