mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-07-29 15:24:54 +00:00
Compare commits
56
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ff0ce4d123 | ||
|
|
8b3665b32d | ||
|
|
9cce6d1b68 | ||
|
|
bb77e65d52 | ||
|
|
eb5bf53476 | ||
|
|
ff609c2987 | ||
|
|
2b784e709b | ||
|
|
c9716252f0 | ||
|
|
5cf9152a40 | ||
|
|
80210bd3bf | ||
|
|
4e52dd5710 | ||
|
|
d34ef75786 | ||
|
|
b8c424c8e1 | ||
|
|
ec8991c2ec | ||
|
|
4f6d9fa93f | ||
|
|
2180b21c41 | ||
|
|
99bdfdfe7a | ||
|
|
73a0586a75 | ||
|
|
dcba44ad78 | ||
|
|
ed54f1a8b9 | ||
|
|
f8a641b402 | ||
|
|
e85223929c | ||
|
|
f2e3304576 | ||
|
|
6938fb0e01 | ||
|
|
255c214d0b | ||
|
|
a0ab05a9a6 | ||
|
|
df1ddb15cc | ||
|
|
71557d7c64 | ||
|
|
66f5111dc1 | ||
|
|
0addb448f5 | ||
|
|
5428ea5caa | ||
|
|
2de2dfdb81 | ||
|
|
f0d934259c | ||
|
|
735c3b073a | ||
|
|
e2cf3633fe | ||
|
|
c0589a9ce9 | ||
|
|
2135572adb | ||
|
|
697e262b5d | ||
|
|
5a65c4448d | ||
|
|
370a0a29ba | ||
|
|
536007f42b | ||
|
|
71e2dd4b08 | ||
|
|
dabd7826d0 | ||
|
|
054b234739 | ||
|
|
77dd4d1b0c | ||
|
|
56dc3e426e | ||
|
|
be8890cfaf | ||
|
|
e03b09e2ea | ||
|
|
19960cc4fa | ||
|
|
e145fe8cc7 | ||
|
|
fce55a1609 | ||
|
|
1dabd2601d | ||
|
|
88f69841b3 | ||
|
|
5b8fbdcec7 | ||
|
|
beb048b94a | ||
|
|
b33d11778a |
@@ -26,6 +26,7 @@ jobs:
|
|||||||
check-name: 'Merge and Push Manifest'
|
check-name: 'Merge and Push Manifest'
|
||||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
wait-interval: 60
|
wait-interval: 60
|
||||||
|
checks-discovery-timeout: 1800
|
||||||
build-release:
|
build-release:
|
||||||
name: Build Release
|
name: Build Release
|
||||||
needs: wait-for-docker
|
needs: wait-for-docker
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ repos:
|
|||||||
- json
|
- json
|
||||||
# See https://github.com/prettier/prettier/issues/15742 for the fork reason
|
# See https://github.com/prettier/prettier/issues/15742 for the fork reason
|
||||||
- repo: https://github.com/rbubley/mirrors-prettier
|
- repo: https://github.com/rbubley/mirrors-prettier
|
||||||
rev: 'v3.8.4'
|
rev: 'v3.9.4'
|
||||||
hooks:
|
hooks:
|
||||||
- id: prettier
|
- id: prettier
|
||||||
types_or:
|
types_or:
|
||||||
@@ -46,16 +46,16 @@ repos:
|
|||||||
- ts
|
- ts
|
||||||
- markdown
|
- markdown
|
||||||
additional_dependencies:
|
additional_dependencies:
|
||||||
- prettier@3.8.3
|
- prettier@3.9.4
|
||||||
- 'prettier-plugin-organize-imports@4.3.0'
|
- 'prettier-plugin-organize-imports@4.3.0'
|
||||||
# Python hooks
|
# Python hooks
|
||||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||||
rev: v0.15.17
|
rev: v0.15.20
|
||||||
hooks:
|
hooks:
|
||||||
- id: ruff-check
|
- id: ruff-check
|
||||||
- id: ruff-format
|
- id: ruff-format
|
||||||
- repo: https://github.com/tox-dev/pyproject-fmt
|
- repo: https://github.com/tox-dev/pyproject-fmt
|
||||||
rev: "v2.24.1"
|
rev: "v2.25.1"
|
||||||
hooks:
|
hooks:
|
||||||
- id: pyproject-fmt
|
- id: pyproject-fmt
|
||||||
additional_dependencies: [tomli]
|
additional_dependencies: [tomli]
|
||||||
|
|||||||
+5
-1
@@ -30,7 +30,7 @@ RUN set -eux \
|
|||||||
# Purpose: Installs s6-overlay and rootfs
|
# Purpose: Installs s6-overlay and rootfs
|
||||||
# Comments:
|
# Comments:
|
||||||
# - Don't leave anything extra in here either
|
# - Don't leave anything extra in here either
|
||||||
FROM ghcr.io/astral-sh/uv:0.11.19-python3.12-trixie-slim AS s6-overlay-base
|
FROM ghcr.io/astral-sh/uv:0.11.28-python3.12-trixie-slim AS s6-overlay-base
|
||||||
|
|
||||||
WORKDIR /usr/src/s6
|
WORKDIR /usr/src/s6
|
||||||
|
|
||||||
@@ -238,6 +238,10 @@ RUN set -eux \
|
|||||||
&& chown --from root:root --changes --recursive paperless:paperless /usr/src/paperless \
|
&& chown --from root:root --changes --recursive paperless:paperless /usr/src/paperless \
|
||||||
&& echo "Making fontconfig cache writable for arbitrary container UIDs" \
|
&& echo "Making fontconfig cache writable for arbitrary container UIDs" \
|
||||||
&& chmod 1777 /var/cache/fontconfig \
|
&& chmod 1777 /var/cache/fontconfig \
|
||||||
|
&& echo "Making /run world-writable for rootless operation" \
|
||||||
|
&& chmod 1777 /run \
|
||||||
|
&& echo "Removing setuid from s6-overlay-suexec for rootless compat" \
|
||||||
|
&& chmod u-s /command/s6-overlay-suexec \
|
||||||
&& echo "Collecting static files" \
|
&& echo "Collecting static files" \
|
||||||
&& PAPERLESS_SECRET_KEY=build-time-dummy s6-setuidgid paperless python3 manage.py collectstatic --clear --no-input --link \
|
&& PAPERLESS_SECRET_KEY=build-time-dummy s6-setuidgid paperless python3 manage.py collectstatic --clear --no-input --link \
|
||||||
&& PAPERLESS_SECRET_KEY=build-time-dummy s6-setuidgid paperless python3 manage.py compilemessages \
|
&& PAPERLESS_SECRET_KEY=build-time-dummy s6-setuidgid paperless python3 manage.py compilemessages \
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
# correct networking for the tests
|
# correct networking for the tests
|
||||||
services:
|
services:
|
||||||
gotenberg:
|
gotenberg:
|
||||||
image: docker.io/gotenberg/gotenberg:8.33
|
image: docker.io/gotenberg/gotenberg:8.34
|
||||||
hostname: gotenberg
|
hostname: gotenberg
|
||||||
container_name: gotenberg
|
container_name: gotenberg
|
||||||
network_mode: host
|
network_mode: host
|
||||||
@@ -24,7 +24,7 @@ services:
|
|||||||
network_mode: host
|
network_mode: host
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
greenmail:
|
greenmail:
|
||||||
image: docker.io/greenmail/standalone:2.1.8
|
image: docker.io/greenmail/standalone:2.1.9
|
||||||
hostname: greenmail
|
hostname: greenmail
|
||||||
container_name: greenmail
|
container_name: greenmail
|
||||||
environment:
|
environment:
|
||||||
@@ -35,7 +35,7 @@ services:
|
|||||||
- "3143:3143" # IMAP
|
- "3143:3143" # IMAP
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
nginx:
|
nginx:
|
||||||
image: docker.io/nginx:1.31.1-alpine
|
image: docker.io/nginx:1.31.2-alpine
|
||||||
hostname: nginx
|
hostname: nginx
|
||||||
container_name: nginx
|
container_name: nginx
|
||||||
ports:
|
ports:
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ services:
|
|||||||
PAPERLESS_TIKA_GOTENBERG_ENDPOINT: http://gotenberg:3000
|
PAPERLESS_TIKA_GOTENBERG_ENDPOINT: http://gotenberg:3000
|
||||||
PAPERLESS_TIKA_ENDPOINT: http://tika:9998
|
PAPERLESS_TIKA_ENDPOINT: http://tika:9998
|
||||||
gotenberg:
|
gotenberg:
|
||||||
image: docker.io/gotenberg/gotenberg:8.33
|
image: docker.io/gotenberg/gotenberg:8.34
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
# The gotenberg chromium route is used to convert .eml files. We do not
|
# The gotenberg chromium route is used to convert .eml files. We do not
|
||||||
# want to allow external content like tracking pixels or even javascript.
|
# want to allow external content like tracking pixels or even javascript.
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ services:
|
|||||||
PAPERLESS_TIKA_GOTENBERG_ENDPOINT: http://gotenberg:3000
|
PAPERLESS_TIKA_GOTENBERG_ENDPOINT: http://gotenberg:3000
|
||||||
PAPERLESS_TIKA_ENDPOINT: http://tika:9998
|
PAPERLESS_TIKA_ENDPOINT: http://tika:9998
|
||||||
gotenberg:
|
gotenberg:
|
||||||
image: docker.io/gotenberg/gotenberg:8.33
|
image: docker.io/gotenberg/gotenberg:8.34
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
# The gotenberg chromium route is used to convert .eml files. We do not
|
# The gotenberg chromium route is used to convert .eml files. We do not
|
||||||
# want to allow external content like tracking pixels or even javascript.
|
# want to allow external content like tracking pixels or even javascript.
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ services:
|
|||||||
PAPERLESS_TIKA_GOTENBERG_ENDPOINT: http://gotenberg:3000
|
PAPERLESS_TIKA_GOTENBERG_ENDPOINT: http://gotenberg:3000
|
||||||
PAPERLESS_TIKA_ENDPOINT: http://tika:9998
|
PAPERLESS_TIKA_ENDPOINT: http://tika:9998
|
||||||
gotenberg:
|
gotenberg:
|
||||||
image: docker.io/gotenberg/gotenberg:8.33
|
image: docker.io/gotenberg/gotenberg:8.34
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
# The gotenberg chromium route is used to convert .eml files. We do not
|
# The gotenberg chromium route is used to convert .eml files. We do not
|
||||||
# want to allow external content like tracking pixels or even javascript.
|
# want to allow external content like tracking pixels or even javascript.
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -326,3 +326,11 @@ behind a reverse proxy may need to set
|
|||||||
[`PAPERLESS_TRUSTED_PROXIES`](configuration.md#PAPERLESS_TRUSTED_PROXIES),
|
[`PAPERLESS_TRUSTED_PROXIES`](configuration.md#PAPERLESS_TRUSTED_PROXIES),
|
||||||
[`PAPERLESS_ALLAUTH_TRUSTED_CLIENT_IP_HEADER`](configuration.md#PAPERLESS_ALLAUTH_TRUSTED_CLIENT_IP_HEADER),
|
[`PAPERLESS_ALLAUTH_TRUSTED_CLIENT_IP_HEADER`](configuration.md#PAPERLESS_ALLAUTH_TRUSTED_CLIENT_IP_HEADER),
|
||||||
or both, to avoid `403 Forbidden` errors on login.
|
or both, to avoid `403 Forbidden` errors on login.
|
||||||
|
|
||||||
|
## Database Migrations
|
||||||
|
|
||||||
|
Some integer fields have been changed to smaller types to reduce database size. If you have any `MailRule` records with a `maximum_age` greater than 32767, they will be clamped to 32767 during the migration to avoid errors during migration.
|
||||||
|
|
||||||
|
### Action Required
|
||||||
|
|
||||||
|
No user action is required. The migration will automatically clamp any `MailRule.maximum_age` values greater than 32767 to 32767 during the migration process.
|
||||||
|
|||||||
+1
-1
@@ -142,7 +142,7 @@ a [superuser](usage.md#superusers) account.
|
|||||||
|
|
||||||
It is not possible to run the container rootless if additional languages are specified via `PAPERLESS_OCR_LANGUAGES`.
|
It is not possible to run the container rootless if additional languages are specified via `PAPERLESS_OCR_LANGUAGES`.
|
||||||
|
|
||||||
If you want to run Paperless as a rootless container, set `user:` in `docker-compose.yml` to the UID and GID of your host user (use `id -u` and `id -g` to find these values). The container process starts directly as that user with no internal privilege remapping:
|
If you want to run Paperless as a rootless container, set `user:` in `docker-compose.yml` to the UID and GID of your host user (use `id -u` and `id -g` to find these values). The container process starts directly as that user with no internal privilege remapping. Any UID and GID is supported:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
webserver:
|
webserver:
|
||||||
|
|||||||
+4
-4
@@ -16,7 +16,7 @@ classifiers = [
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"azure-ai-documentintelligence>=1.0.2",
|
"azure-ai-documentintelligence>=1.0.2",
|
||||||
"babel>=2.17",
|
"babel>=2.17",
|
||||||
"bleach~=6.3.0",
|
"bleach~=6.4.0",
|
||||||
"celery[redis]~=5.6.2",
|
"celery[redis]~=5.6.2",
|
||||||
"channels~=4.2",
|
"channels~=4.2",
|
||||||
"channels-redis~=4.2",
|
"channels-redis~=4.2",
|
||||||
@@ -56,7 +56,7 @@ dependencies = [
|
|||||||
"llama-index-embeddings-openai-like>=0.2.2",
|
"llama-index-embeddings-openai-like>=0.2.2",
|
||||||
"llama-index-llms-ollama>=0.9.1",
|
"llama-index-llms-ollama>=0.9.1",
|
||||||
"llama-index-llms-openai-like>=0.7.1",
|
"llama-index-llms-openai-like>=0.7.1",
|
||||||
"nltk~=3.9.1",
|
"nltk~=3.10.0",
|
||||||
"ocrmypdf~=17.4.2",
|
"ocrmypdf~=17.4.2",
|
||||||
"openai>=2.32",
|
"openai>=2.32",
|
||||||
"pathvalidate~=3.3.1",
|
"pathvalidate~=3.3.1",
|
||||||
@@ -101,11 +101,11 @@ dev = [
|
|||||||
{ include-group = "testing" },
|
{ include-group = "testing" },
|
||||||
]
|
]
|
||||||
docs = [
|
docs = [
|
||||||
"zensical>=0.0.43",
|
"zensical>=0.0.47",
|
||||||
]
|
]
|
||||||
lint = [
|
lint = [
|
||||||
"prek~=0.3.10",
|
"prek~=0.3.10",
|
||||||
"ruff~=0.15.15",
|
"ruff~=0.15.20",
|
||||||
]
|
]
|
||||||
testing = [
|
testing = [
|
||||||
"daphne",
|
"daphne",
|
||||||
|
|||||||
@@ -79,6 +79,16 @@
|
|||||||
"glob": "{pdf.worker.min.mjs,pdf.min.mjs}",
|
"glob": "{pdf.worker.min.mjs,pdf.min.mjs}",
|
||||||
"input": "node_modules/pdfjs-dist/legacy/build/",
|
"input": "node_modules/pdfjs-dist/legacy/build/",
|
||||||
"output": "/assets/js/"
|
"output": "/assets/js/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"glob": "**/*",
|
||||||
|
"input": "node_modules/pdfjs-dist/wasm/",
|
||||||
|
"output": "/assets/wasm/"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"glob": "**/*",
|
||||||
|
"input": "node_modules/pdfjs-dist/iccs/",
|
||||||
|
"output": "/assets/iccs/"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"styles": [
|
"styles": [
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { expect, test } from '@playwright/test'
|
import { expect, test } from '@playwright/test'
|
||||||
import path from 'node:path'
|
import path from 'node:path'
|
||||||
|
import { mockFilterSelectionData } from '../mock-filter-selection-data'
|
||||||
|
|
||||||
const REQUESTS_HAR = path.join(__dirname, 'requests/api-settings.har')
|
const REQUESTS_HAR = path.join(__dirname, 'requests/api-settings.har')
|
||||||
|
|
||||||
@@ -7,6 +8,7 @@ test('should activate / deactivate save button when settings change', async ({
|
|||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
await page.routeFromHAR(REQUESTS_HAR, { notFound: 'fallback' })
|
await page.routeFromHAR(REQUESTS_HAR, { notFound: 'fallback' })
|
||||||
|
await mockFilterSelectionData(page)
|
||||||
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()
|
||||||
@@ -16,6 +18,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.routeFromHAR(REQUESTS_HAR, { notFound: 'fallback' })
|
||||||
|
await mockFilterSelectionData(page)
|
||||||
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()
|
||||||
@@ -28,6 +31,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.routeFromHAR(REQUESTS_HAR, { notFound: 'fallback' })
|
||||||
|
await mockFilterSelectionData(page)
|
||||||
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,5 +1,6 @@
|
|||||||
import { expect, test } from '@playwright/test'
|
import { expect, test } from '@playwright/test'
|
||||||
import path from 'node:path'
|
import path from 'node:path'
|
||||||
|
import { mockFilterSelectionData } from '../mock-filter-selection-data'
|
||||||
|
|
||||||
const REQUESTS_HAR1 = path.join(__dirname, 'requests/api-dashboard1.har')
|
const REQUESTS_HAR1 = path.join(__dirname, 'requests/api-dashboard1.har')
|
||||||
const REQUESTS_HAR2 = path.join(__dirname, 'requests/api-dashboard2.har')
|
const REQUESTS_HAR2 = path.join(__dirname, 'requests/api-dashboard2.har')
|
||||||
@@ -8,6 +9,7 @@ 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.routeFromHAR(REQUESTS_HAR1, { notFound: 'fallback' })
|
||||||
|
await mockFilterSelectionData(page)
|
||||||
await page.goto('/dashboard')
|
await page.goto('/dashboard')
|
||||||
await page.getByRole('link', { name: 'Documents in inbox' }).click()
|
await page.getByRole('link', { name: 'Documents in inbox' }).click()
|
||||||
await expect(page).toHaveURL(/tags__id__in=9/)
|
await expect(page).toHaveURL(/tags__id__in=9/)
|
||||||
@@ -16,6 +18,7 @@ test('dashboard inbox link', async ({ page }) => {
|
|||||||
|
|
||||||
test('dashboard total documents link', async ({ page }) => {
|
test('dashboard total documents link', async ({ page }) => {
|
||||||
await page.routeFromHAR(REQUESTS_HAR2, { notFound: 'fallback' })
|
await page.routeFromHAR(REQUESTS_HAR2, { notFound: 'fallback' })
|
||||||
|
await mockFilterSelectionData(page)
|
||||||
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/)
|
||||||
@@ -25,6 +28,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.routeFromHAR(REQUESTS_HAR3, { notFound: 'fallback' })
|
||||||
|
await mockFilterSelectionData(page)
|
||||||
await page.goto('/dashboard')
|
await page.goto('/dashboard')
|
||||||
await page
|
await page
|
||||||
.locator('pngx-widget-frame')
|
.locator('pngx-widget-frame')
|
||||||
@@ -38,6 +42,7 @@ test('dashboard saved view show all', async ({ page }) => {
|
|||||||
|
|
||||||
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.routeFromHAR(REQUESTS_HAR4, { notFound: 'fallback' })
|
||||||
|
await mockFilterSelectionData(page)
|
||||||
await page.goto('/dashboard')
|
await page.goto('/dashboard')
|
||||||
await page
|
await page
|
||||||
.locator('pngx-widget-frame')
|
.locator('pngx-widget-frame')
|
||||||
@@ -51,6 +56,7 @@ test('dashboard saved view document links', async ({ page }) => {
|
|||||||
|
|
||||||
test('test slim sidebar', async ({ page }) => {
|
test('test slim sidebar', async ({ page }) => {
|
||||||
await page.routeFromHAR(REQUESTS_HAR1, { notFound: 'fallback' })
|
await page.routeFromHAR(REQUESTS_HAR1, { notFound: 'fallback' })
|
||||||
|
await mockFilterSelectionData(page)
|
||||||
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,5 +1,6 @@
|
|||||||
import { expect, test } from '@playwright/test'
|
import { expect, test } from '@playwright/test'
|
||||||
import path from 'node:path'
|
import path from 'node:path'
|
||||||
|
import { mockFilterSelectionData } from '../mock-filter-selection-data'
|
||||||
|
|
||||||
const REQUESTS_HAR = path.join(__dirname, 'requests/api-document-detail.har')
|
const REQUESTS_HAR = path.join(__dirname, 'requests/api-document-detail.har')
|
||||||
const REQUESTS_HAR2 = path.join(__dirname, 'requests/api-document-detail2.har')
|
const REQUESTS_HAR2 = path.join(__dirname, 'requests/api-document-detail2.har')
|
||||||
@@ -8,6 +9,7 @@ test('should activate / deactivate save button when changes are saved', async ({
|
|||||||
page,
|
page,
|
||||||
}) => {
|
}) => {
|
||||||
await page.routeFromHAR(REQUESTS_HAR, { notFound: 'fallback' })
|
await page.routeFromHAR(REQUESTS_HAR, { notFound: 'fallback' })
|
||||||
|
await mockFilterSelectionData(page)
|
||||||
await page.goto('/documents/175/')
|
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(
|
||||||
@@ -20,6 +22,7 @@ test('should activate / deactivate save button when changes are saved', async ({
|
|||||||
|
|
||||||
test('should warn on unsaved changes', async ({ page }) => {
|
test('should warn on unsaved changes', async ({ page }) => {
|
||||||
await page.routeFromHAR(REQUESTS_HAR, { notFound: 'fallback' })
|
await page.routeFromHAR(REQUESTS_HAR, { notFound: 'fallback' })
|
||||||
|
await mockFilterSelectionData(page)
|
||||||
await page.goto('/documents/175/')
|
await page.goto('/documents/175/')
|
||||||
await expect(page.getByTitle('Correspondent', { exact: true })).toHaveText(
|
await expect(page.getByTitle('Correspondent', { exact: true })).toHaveText(
|
||||||
/\w+/
|
/\w+/
|
||||||
@@ -39,6 +42,7 @@ test('should warn on unsaved changes', async ({ page }) => {
|
|||||||
|
|
||||||
test('should support tab direct navigation', async ({ page }) => {
|
test('should support tab direct navigation', async ({ page }) => {
|
||||||
await page.routeFromHAR(REQUESTS_HAR, { notFound: 'fallback' })
|
await page.routeFromHAR(REQUESTS_HAR, { notFound: 'fallback' })
|
||||||
|
await mockFilterSelectionData(page)
|
||||||
await page.goto('/documents/175/details')
|
await page.goto('/documents/175/details')
|
||||||
await expect(page.getByRole('tab', { name: 'Details' })).toHaveAttribute(
|
await expect(page.getByRole('tab', { name: 'Details' })).toHaveAttribute(
|
||||||
'aria-selected',
|
'aria-selected',
|
||||||
@@ -68,6 +72,7 @@ test('should support tab direct navigation', async ({ page }) => {
|
|||||||
|
|
||||||
test('should show a mobile preview', async ({ page }) => {
|
test('should show a mobile preview', async ({ page }) => {
|
||||||
await page.routeFromHAR(REQUESTS_HAR, { notFound: 'fallback' })
|
await page.routeFromHAR(REQUESTS_HAR, { notFound: 'fallback' })
|
||||||
|
await mockFilterSelectionData(page)
|
||||||
await page.goto('/documents/175/')
|
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()
|
||||||
@@ -77,6 +82,7 @@ test('should show a mobile preview', async ({ page }) => {
|
|||||||
|
|
||||||
test('should show a list of notes', async ({ page }) => {
|
test('should show a list of notes', async ({ page }) => {
|
||||||
await page.routeFromHAR(REQUESTS_HAR, { notFound: 'fallback' })
|
await page.routeFromHAR(REQUESTS_HAR, { notFound: 'fallback' })
|
||||||
|
await mockFilterSelectionData(page)
|
||||||
await page.goto('/documents/175/notes')
|
await page.goto('/documents/175/notes')
|
||||||
await expect(page.locator('pngx-document-notes')).toBeVisible()
|
await expect(page.locator('pngx-document-notes')).toBeVisible()
|
||||||
await expect(
|
await expect(
|
||||||
@@ -89,6 +95,7 @@ test('should show a list of notes', async ({ page }) => {
|
|||||||
|
|
||||||
test('should support quick filters', async ({ page }) => {
|
test('should support quick filters', async ({ page }) => {
|
||||||
await page.routeFromHAR(REQUESTS_HAR2, { notFound: 'fallback' })
|
await page.routeFromHAR(REQUESTS_HAR2, { notFound: 'fallback' })
|
||||||
|
await mockFilterSelectionData(page)
|
||||||
await page.goto('/documents/175/details')
|
await page.goto('/documents/175/details')
|
||||||
await page
|
await page
|
||||||
.getByRole('button', { name: 'Filter documents with these Tags' })
|
.getByRole('button', { name: 'Filter documents with these Tags' })
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { expect, test } from '@playwright/test'
|
import { expect, test } from '@playwright/test'
|
||||||
import path from 'node:path'
|
import path from 'node:path'
|
||||||
|
import { mockFilterSelectionData } from '../mock-filter-selection-data'
|
||||||
|
|
||||||
const REQUESTS_HAR1 = path.join(__dirname, 'requests/api-document-list1.har')
|
const REQUESTS_HAR1 = path.join(__dirname, 'requests/api-document-list1.har')
|
||||||
const REQUESTS_HAR2 = path.join(__dirname, 'requests/api-document-list2.har')
|
const REQUESTS_HAR2 = path.join(__dirname, 'requests/api-document-list2.har')
|
||||||
@@ -10,6 +11,7 @@ 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.routeFromHAR(REQUESTS_HAR1, { notFound: 'fallback' })
|
||||||
|
await mockFilterSelectionData(page)
|
||||||
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()
|
||||||
@@ -45,6 +47,7 @@ test('basic filtering', async ({ page }) => {
|
|||||||
|
|
||||||
test('text filtering', async ({ page }) => {
|
test('text filtering', async ({ page }) => {
|
||||||
await page.routeFromHAR(REQUESTS_HAR2, { notFound: 'fallback' })
|
await page.routeFromHAR(REQUESTS_HAR2, { notFound: 'fallback' })
|
||||||
|
await mockFilterSelectionData(page)
|
||||||
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')
|
||||||
@@ -81,6 +84,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.routeFromHAR(REQUESTS_HAR3, { notFound: 'fallback' })
|
||||||
|
await mockFilterSelectionData(page)
|
||||||
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()
|
||||||
@@ -103,6 +107,7 @@ test('date filtering', async ({ page }) => {
|
|||||||
|
|
||||||
test('sorting', async ({ page }) => {
|
test('sorting', async ({ page }) => {
|
||||||
await page.routeFromHAR(REQUESTS_HAR4, { notFound: 'fallback' })
|
await page.routeFromHAR(REQUESTS_HAR4, { notFound: 'fallback' })
|
||||||
|
await mockFilterSelectionData(page)
|
||||||
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()
|
||||||
@@ -141,6 +146,7 @@ test('sorting', async ({ page }) => {
|
|||||||
|
|
||||||
test('change views', async ({ page }) => {
|
test('change views', async ({ page }) => {
|
||||||
await page.routeFromHAR(REQUESTS_HAR5, { notFound: 'fallback' })
|
await page.routeFromHAR(REQUESTS_HAR5, { notFound: 'fallback' })
|
||||||
|
await mockFilterSelectionData(page)
|
||||||
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()
|
||||||
@@ -152,6 +158,7 @@ test('change views', async ({ page }) => {
|
|||||||
|
|
||||||
test('bulk edit', async ({ page }) => {
|
test('bulk edit', async ({ page }) => {
|
||||||
await page.routeFromHAR(REQUESTS_HAR6, { notFound: 'fallback' })
|
await page.routeFromHAR(REQUESTS_HAR6, { notFound: 'fallback' })
|
||||||
|
await mockFilterSelectionData(page)
|
||||||
await page.goto('/documents')
|
await page.goto('/documents')
|
||||||
|
|
||||||
await page.locator('pngx-document-card-small').nth(0).click()
|
await page.locator('pngx-document-card-small').nth(0).click()
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { Page } from '@playwright/test'
|
||||||
|
|
||||||
|
const EMPTY_SELECTION_DATA = {
|
||||||
|
selected_correspondents: [],
|
||||||
|
selected_tags: [],
|
||||||
|
selected_document_types: [],
|
||||||
|
selected_storage_paths: [],
|
||||||
|
selected_custom_fields: [],
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The document list now fires a GET to filter_selection_data on every
|
||||||
|
* non-search reload(), independent of and concurrent with the main list
|
||||||
|
* request. It's not present in any of the recorded HAR fixtures, so with
|
||||||
|
* `notFound: 'fallback'` it would otherwise fall through to the real
|
||||||
|
* network (nothing listens there in e2e, since only the frontend dev
|
||||||
|
* server is started) and fail every test that reloads the list.
|
||||||
|
*
|
||||||
|
* Playwright checks routes in reverse-registration order, so this must be
|
||||||
|
* registered before a test's own page.routeFromHAR() call for the HAR
|
||||||
|
* route's `notFound: 'fallback'` to defer back to this one.
|
||||||
|
*/
|
||||||
|
export async function mockFilterSelectionData(page: Page) {
|
||||||
|
await page.route('**/api/documents/filter_selection_data/**', (route) =>
|
||||||
|
route.fulfill({
|
||||||
|
json: EMPTY_SELECTION_DATA,
|
||||||
|
// The app calls the (cross-origin, from the e2e app's perspective)
|
||||||
|
// backend at http://localhost:8000 while served from :4200, so a
|
||||||
|
// fulfilled response needs the same CORS header the real backend
|
||||||
|
// sends (and that recorded HAR responses already carry) or the
|
||||||
|
// browser rejects it as a cross-origin failure.
|
||||||
|
headers: { 'Access-Control-Allow-Origin': 'http://localhost:4200' },
|
||||||
|
})
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,10 +1,12 @@
|
|||||||
import { expect, test } from '@playwright/test'
|
import { expect, test } from '@playwright/test'
|
||||||
import path from 'node:path'
|
import path from 'node:path'
|
||||||
|
import { mockFilterSelectionData } from '../mock-filter-selection-data'
|
||||||
|
|
||||||
const REQUESTS_HAR = path.join(__dirname, 'requests/api-global-permissions.har')
|
const REQUESTS_HAR = path.join(__dirname, 'requests/api-global-permissions.har')
|
||||||
|
|
||||||
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.routeFromHAR(REQUESTS_HAR, { notFound: 'fallback' })
|
||||||
|
await mockFilterSelectionData(page)
|
||||||
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')
|
||||||
@@ -15,6 +17,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.routeFromHAR(REQUESTS_HAR, { notFound: 'fallback' })
|
||||||
|
await mockFilterSelectionData(page)
|
||||||
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 +34,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.routeFromHAR(REQUESTS_HAR, { notFound: 'fallback' })
|
||||||
|
await mockFilterSelectionData(page)
|
||||||
await page.goto('/dashboard')
|
await page.goto('/dashboard')
|
||||||
await expect(
|
await expect(
|
||||||
page.getByRole('link', { name: 'Attributes' })
|
page.getByRole('link', { name: 'Attributes' })
|
||||||
@@ -43,6 +47,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.routeFromHAR(REQUESTS_HAR, { notFound: 'fallback' })
|
||||||
|
await mockFilterSelectionData(page)
|
||||||
await page.goto('/dashboard')
|
await page.goto('/dashboard')
|
||||||
await expect(
|
await expect(
|
||||||
page.getByRole('link', { name: 'Attributes' })
|
page.getByRole('link', { name: 'Attributes' })
|
||||||
@@ -55,6 +60,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.routeFromHAR(REQUESTS_HAR, { notFound: 'fallback' })
|
||||||
|
await mockFilterSelectionData(page)
|
||||||
await page.goto('/dashboard')
|
await page.goto('/dashboard')
|
||||||
await expect(
|
await expect(
|
||||||
page.getByRole('link', { name: 'Attributes' })
|
page.getByRole('link', { name: 'Attributes' })
|
||||||
@@ -67,6 +73,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.routeFromHAR(REQUESTS_HAR, { notFound: 'fallback' })
|
||||||
|
await mockFilterSelectionData(page)
|
||||||
await page.goto('/dashboard')
|
await page.goto('/dashboard')
|
||||||
await expect(
|
await expect(
|
||||||
page.getByRole('link', { name: 'Attributes' })
|
page.getByRole('link', { name: 'Attributes' })
|
||||||
@@ -79,6 +86,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.routeFromHAR(REQUESTS_HAR, { notFound: 'fallback' })
|
||||||
|
await mockFilterSelectionData(page)
|
||||||
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')
|
||||||
@@ -89,6 +97,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.routeFromHAR(REQUESTS_HAR, { notFound: 'fallback' })
|
||||||
|
await mockFilterSelectionData(page)
|
||||||
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')
|
||||||
|
|||||||
+291
-258
File diff suppressed because it is too large
Load Diff
@@ -18,7 +18,6 @@
|
|||||||
"@angular/forms": "~22.0.5",
|
"@angular/forms": "~22.0.5",
|
||||||
"@angular/localize": "~22.0.5",
|
"@angular/localize": "~22.0.5",
|
||||||
"@angular/platform-browser": "~22.0.5",
|
"@angular/platform-browser": "~22.0.5",
|
||||||
"@angular/platform-browser-dynamic": "~22.0.5",
|
|
||||||
"@angular/router": "~22.0.5",
|
"@angular/router": "~22.0.5",
|
||||||
"@ng-bootstrap/ng-bootstrap": "^21.0.0",
|
"@ng-bootstrap/ng-bootstrap": "^21.0.0",
|
||||||
"@ng-select/ng-select": "^23.2.0",
|
"@ng-select/ng-select": "^23.2.0",
|
||||||
|
|||||||
Generated
+1295
-1844
File diff suppressed because it is too large
Load Diff
@@ -5,8 +5,9 @@
|
|||||||
i18n-info>
|
i18n-info>
|
||||||
<div class="input-group input-group-sm align-items-center">
|
<div class="input-group input-group-sm align-items-center">
|
||||||
<div class="input-group input-group-sm me-3">
|
<div class="input-group input-group-sm me-3">
|
||||||
<span class="input-group-text text-muted" i18n>Show</span>
|
<label class="input-group-text text-muted" for="logLineLimit" i18n>Show</label>
|
||||||
<input
|
<input
|
||||||
|
id="logLineLimit"
|
||||||
class="form-control"
|
class="form-control"
|
||||||
type="number"
|
type="number"
|
||||||
min="100"
|
min="100"
|
||||||
@@ -17,7 +18,7 @@
|
|||||||
<span class="input-group-text text-muted" i18n>lines</span>
|
<span class="input-group-text text-muted" i18n>lines</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-check form-switch mt-1">
|
<div class="form-check form-switch mt-1">
|
||||||
<input class="form-check-input" type="checkbox" role="switch" [ngModel]="autoRefreshEnabled()" (ngModelChange)="autoRefreshEnabled.set($event)">
|
<input id="autoRefreshSwitch" class="form-check-input" type="checkbox" role="switch" [ngModel]="autoRefreshEnabled()" (ngModelChange)="autoRefreshEnabled.set($event)">
|
||||||
<label class="form-check-label" for="autoRefreshSwitch" i18n>Auto refresh</label>
|
<label class="form-check-label" for="autoRefreshSwitch" i18n>Auto refresh</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -100,9 +100,7 @@ export class LogsComponent
|
|||||||
parsed.some((log, idx) => {
|
parsed.some((log, idx) => {
|
||||||
const current = this.logs()[idx]
|
const current = this.logs()[idx]
|
||||||
return (
|
return (
|
||||||
!current ||
|
current?.message !== log.message || current?.level !== log.level
|
||||||
current.message !== log.message ||
|
|
||||||
current.level !== log.level
|
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
if (hasChanges) {
|
if (hasChanges) {
|
||||||
|
|||||||
@@ -37,7 +37,7 @@
|
|||||||
|
|
||||||
<form [formGroup]="settingsForm" (ngSubmit)="saveSettings()">
|
<form [formGroup]="settingsForm" (ngSubmit)="saveSettings()">
|
||||||
|
|
||||||
<ul ngbNav #nav="ngbNav" (navChange)="onNavChange($event)" [(activeId)]="activeNavID" class="nav-tabs">
|
<ul ngbNav #nav="ngbNav" (navChange)="onNavChange($event)" [activeId]="activeNavID()" (activeIdChange)="activeNavID.set($event)" class="nav-tabs">
|
||||||
<li [ngbNavItem]="SettingsNavIDs.General">
|
<li [ngbNavItem]="SettingsNavIDs.General">
|
||||||
<a ngbNavLink i18n>General</a>
|
<a ngbNavLink i18n>General</a>
|
||||||
<ng-template ngbNavContent>
|
<ng-template ngbNavContent>
|
||||||
@@ -150,10 +150,10 @@
|
|||||||
|
|
||||||
<div class="row mb-3">
|
<div class="row mb-3">
|
||||||
<div class="col-md-3 col-form-label pt-0">
|
<div class="col-md-3 col-form-label pt-0">
|
||||||
<span i18n>Full search links to</span>
|
<label for="searchLink" i18n>Full search links to</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="col mb-3">
|
<div class="col mb-3">
|
||||||
<select class="form-select" formControlName="searchLink">
|
<select id="searchLink" class="form-select" formControlName="searchLink">
|
||||||
<option [ngValue]="GlobalSearchType.TITLE_CONTENT" i18n>Title and content search</option>
|
<option [ngValue]="GlobalSearchType.TITLE_CONTENT" i18n>Title and content search</option>
|
||||||
<option [ngValue]="GlobalSearchType.ADVANCED" i18n>Advanced search</option>
|
<option [ngValue]="GlobalSearchType.ADVANCED" i18n>Advanced search</option>
|
||||||
</select>
|
</select>
|
||||||
@@ -199,10 +199,10 @@
|
|||||||
<h5 i18n>Documents</h5>
|
<h5 i18n>Documents</h5>
|
||||||
<div class="row mb-3">
|
<div class="row mb-3">
|
||||||
<div class="col-md-3 col-form-label pt-0">
|
<div class="col-md-3 col-form-label pt-0">
|
||||||
<span i18n>Items per page</span>
|
<label for="documentListItemPerPage" i18n>Items per page</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="col">
|
<div class="col">
|
||||||
<select class="form-select" formControlName="documentListItemPerPage">
|
<select id="documentListItemPerPage" class="form-select" formControlName="documentListItemPerPage">
|
||||||
<option [ngValue]="10">10</option>
|
<option [ngValue]="10">10</option>
|
||||||
<option [ngValue]="25">25</option>
|
<option [ngValue]="25">25</option>
|
||||||
<option [ngValue]="50">50</option>
|
<option [ngValue]="50">50</option>
|
||||||
@@ -274,10 +274,10 @@
|
|||||||
<h5 class="mt-3" i18n>PDF Editor</h5>
|
<h5 class="mt-3" i18n>PDF Editor</h5>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-md-3 col-form-label pt-0">
|
<div class="col-md-3 col-form-label pt-0">
|
||||||
<span i18n>Default editing mode</span>
|
<label for="pdfEditorDefaultEditMode" i18n>Default editing mode</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="col">
|
<div class="col">
|
||||||
<select class="form-select" formControlName="pdfEditorDefaultEditMode">
|
<select id="pdfEditorDefaultEditMode" class="form-select" formControlName="pdfEditorDefaultEditMode">
|
||||||
<option [ngValue]="PdfEditorEditMode.Create" i18n>Create new document(s)</option>
|
<option [ngValue]="PdfEditorEditMode.Create" i18n>Create new document(s)</option>
|
||||||
<option [ngValue]="PdfEditorEditMode.Update" i18n>Add document version</option>
|
<option [ngValue]="PdfEditorEditMode.Update" i18n>Add document version</option>
|
||||||
</select>
|
</select>
|
||||||
|
|||||||
@@ -242,7 +242,7 @@ describe('SettingsComponent', () => {
|
|||||||
activatedRoute.snapshot.fragment = '#notifications'
|
activatedRoute.snapshot.fragment = '#notifications'
|
||||||
const scrollSpy = jest.spyOn(viewportScroller, 'scrollToAnchor')
|
const scrollSpy = jest.spyOn(viewportScroller, 'scrollToAnchor')
|
||||||
component.ngOnInit()
|
component.ngOnInit()
|
||||||
expect(component.activeNavID).toEqual(4) // Notifications
|
expect(component.activeNavID()).toEqual(4) // Notifications
|
||||||
component.ngAfterViewInit()
|
component.ngAfterViewInit()
|
||||||
expect(scrollSpy).toHaveBeenCalledWith('#notifications')
|
expect(scrollSpy).toHaveBeenCalledWith('#notifications')
|
||||||
})
|
})
|
||||||
@@ -399,17 +399,17 @@ describe('SettingsComponent', () => {
|
|||||||
completeSetup()
|
completeSetup()
|
||||||
const field = 'storage_path'
|
const field = 'storage_path'
|
||||||
expect(
|
expect(
|
||||||
component.settingsForm.get('documentDetailsHiddenFields').value.length
|
component.settingsForm.get('documentDetailsHiddenFields').value
|
||||||
).toEqual(0)
|
).toHaveLength(0)
|
||||||
component.toggleDocumentDetailField(field, false)
|
component.toggleDocumentDetailField(field, false)
|
||||||
expect(
|
expect(
|
||||||
component.settingsForm.get('documentDetailsHiddenFields').value.length
|
component.settingsForm.get('documentDetailsHiddenFields').value
|
||||||
).toEqual(1)
|
).toHaveLength(1)
|
||||||
expect(component.isDocumentDetailFieldShown(field)).toBeFalsy()
|
expect(component.isDocumentDetailFieldShown(field)).toBeFalsy()
|
||||||
component.toggleDocumentDetailField(field, true)
|
component.toggleDocumentDetailField(field, true)
|
||||||
expect(
|
expect(
|
||||||
component.settingsForm.get('documentDetailsHiddenFields').value.length
|
component.settingsForm.get('documentDetailsHiddenFields').value
|
||||||
).toEqual(0)
|
).toHaveLength(0)
|
||||||
expect(component.isDocumentDetailFieldShown(field)).toBeTruthy()
|
expect(component.isDocumentDetailFieldShown(field)).toBeTruthy()
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -142,7 +142,7 @@ export class SettingsComponent
|
|||||||
private systemStatusService = inject(SystemStatusService)
|
private systemStatusService = inject(SystemStatusService)
|
||||||
private savedViewsService = inject(SavedViewService)
|
private savedViewsService = inject(SavedViewService)
|
||||||
|
|
||||||
activeNavID: number
|
readonly activeNavID = signal<number>(undefined)
|
||||||
|
|
||||||
settingsForm = new FormGroup({
|
settingsForm = new FormGroup({
|
||||||
bulkEditConfirmationDialogs: new FormControl(null),
|
bulkEditConfirmationDialogs: new FormControl(null),
|
||||||
@@ -283,7 +283,7 @@ export class SettingsComponent
|
|||||||
(navID) => navID.toLowerCase() == section
|
(navID) => navID.toLowerCase() == section
|
||||||
)
|
)
|
||||||
if (navIDKey) {
|
if (navIDKey) {
|
||||||
this.activeNavID = SettingsNavIDs[navIDKey]
|
this.activeNavID.set(SettingsNavIDs[navIDKey])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -386,7 +386,7 @@ export class SettingsComponent
|
|||||||
.navigate(['settings', foundNavIDkey.toLowerCase()])
|
.navigate(['settings', foundNavIDkey.toLowerCase()])
|
||||||
.then((navigated) => {
|
.then((navigated) => {
|
||||||
if (!navigated && this.isDirty) {
|
if (!navigated && this.isDirty) {
|
||||||
this.activeNavID = navChangeEvent.activeId
|
this.activeNavID.set(navChangeEvent.activeId)
|
||||||
} else if (navigated && this.isDirty) {
|
} else if (navigated && this.isDirty) {
|
||||||
this.initialize()
|
this.initialize()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
<i-bs name="check2-all" class="me-1"></i-bs><ng-container i18n>Dismiss all</ng-container>
|
<i-bs name="check2-all" class="me-1"></i-bs><ng-container i18n>Dismiss all</ng-container>
|
||||||
</button>
|
</button>
|
||||||
<div class="form-check form-switch mb-0 ms-2">
|
<div class="form-check form-switch mb-0 ms-2">
|
||||||
<input class="form-check-input" type="checkbox" role="switch" [(ngModel)]="autoRefreshEnabled">
|
<input id="autoRefreshSwitch" class="form-check-input" type="checkbox" role="switch" [(ngModel)]="autoRefreshEnabled">
|
||||||
<label class="form-check-label" for="autoRefreshSwitch" i18n>Auto refresh</label>
|
<label class="form-check-label" for="autoRefreshSwitch" i18n>Auto refresh</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -93,7 +93,8 @@
|
|||||||
<i-bs width="1em" height="1em" name="x"></i-bs>
|
<i-bs width="1em" height="1em" name="x"></i-bs>
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
<input #filterInput class="form-control form-control-sm" type="text"
|
<label class="visually-hidden" for="taskSearch" i18n>Search tasks</label>
|
||||||
|
<input #filterInput id="taskSearch" class="form-control form-control-sm" type="text"
|
||||||
(keyup)="filterInputKeyup($event)"
|
(keyup)="filterInputKeyup($event)"
|
||||||
[(ngModel)]="filterText">
|
[(ngModel)]="filterText">
|
||||||
</div>
|
</div>
|
||||||
@@ -211,7 +212,7 @@
|
|||||||
<i-bs width="1.2em" height="1.2em" name="info-circle"></i-bs>
|
<i-bs width="1.2em" height="1.2em" name="info-circle"></i-bs>
|
||||||
</button>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
<td scope="row" class="actions-col">
|
<td class="actions-col">
|
||||||
<div class="btn-group" role="group">
|
<div class="btn-group" role="group">
|
||||||
<button class="btn btn-sm btn-outline-secondary" (click)="dismissTask(task); $event.stopPropagation();" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.PaperlessTask }">
|
<button class="btn btn-sm btn-outline-secondary" (click)="dismissTask(task); $event.stopPropagation();" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.PaperlessTask }">
|
||||||
<i-bs name="check" class="me-1"></i-bs><ng-container i18n>Dismiss</ng-container>
|
<i-bs name="check" class="me-1"></i-bs><ng-container i18n>Dismiss</ng-container>
|
||||||
|
|||||||
@@ -150,7 +150,8 @@
|
|||||||
[disablePopover]="!slimSidebarEnabled" placement="end" container="body" triggers="mouseenter:mouseleave"
|
[disablePopover]="!slimSidebarEnabled" placement="end" container="body" triggers="mouseenter:mouseleave"
|
||||||
popoverClass="popover-slim">
|
popoverClass="popover-slim">
|
||||||
<i-bs class="me-2" name="file-text"></i-bs><span>{{d.title | documentTitle}}</span>
|
<i-bs class="me-2" name="file-text"></i-bs><span>{{d.title | documentTitle}}</span>
|
||||||
<span class="close flex-column justify-content-center" (click)="closeDocument(d); $event.preventDefault()">
|
<span class="close flex-column justify-content-center"
|
||||||
|
(click)="$event.preventDefault(); $event.stopPropagation(); closeDocument(d)">
|
||||||
<i-bs name="x"></i-bs>
|
<i-bs name="x"></i-bs>
|
||||||
</span>
|
</span>
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@
|
|||||||
<div ngbDropdownMenu class="dropdown-menu-end shadow p-3" aria-labelledby="chatDropdown">
|
<div ngbDropdownMenu class="dropdown-menu-end shadow p-3" aria-labelledby="chatDropdown">
|
||||||
<div class="chat-container bg-light p-2">
|
<div class="chat-container bg-light p-2">
|
||||||
<div class="chat-messages font-monospace small">
|
<div class="chat-messages font-monospace small">
|
||||||
@for (message of messages; track message) {
|
@for (message of messages(); track message) {
|
||||||
<div class="message d-flex flex-row small" [class.justify-content-end]="message.role === 'user'">
|
<div class="message d-flex flex-row small" [class.justify-content-end]="message.role === 'user'">
|
||||||
<div class="p-2 m-2" [class.bg-body]="message.role === 'user'">
|
<div class="p-2 m-2" [class.bg-body]="message.role === 'user'">
|
||||||
<span>
|
<span>
|
||||||
@@ -29,16 +29,19 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form class="chat-input">
|
<form class="chat-input">
|
||||||
|
<label class="visually-hidden" for="chatInput" i18n>Message</label>
|
||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
<input
|
<input
|
||||||
#chatInput
|
#chatInput
|
||||||
|
id="chatInput"
|
||||||
class="form-control form-control-sm" name="chatInput" type="text"
|
class="form-control form-control-sm" name="chatInput" type="text"
|
||||||
[placeholder]="placeholder"
|
[placeholder]="placeholder"
|
||||||
[disabled]="loading"
|
[disabled]="loading()"
|
||||||
[(ngModel)]="input"
|
[ngModel]="input()"
|
||||||
|
(ngModelChange)="input.set($event)"
|
||||||
(keydown)="searchInputKeyDown($event)"
|
(keydown)="searchInputKeyDown($event)"
|
||||||
/>
|
/>
|
||||||
<button class="btn btn-sm btn-secondary" type="button" (click)="sendMessage()" [disabled]="loading">Send</button>
|
<button class="btn btn-sm btn-secondary" type="button" (click)="sendMessage()" [disabled]="loading()">Send</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -56,44 +56,53 @@ describe('ChatComponent', () => {
|
|||||||
it('should update documentId on initialization', () => {
|
it('should update documentId on initialization', () => {
|
||||||
jest.spyOn(router, 'url', 'get').mockReturnValue('/documents/123')
|
jest.spyOn(router, 'url', 'get').mockReturnValue('/documents/123')
|
||||||
component.ngOnInit()
|
component.ngOnInit()
|
||||||
expect(component.documentId).toBe(123)
|
expect(component.documentId()).toBe(123)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should update documentId on navigation', () => {
|
it('should update documentId on navigation', () => {
|
||||||
component.ngOnInit()
|
component.ngOnInit()
|
||||||
routerEvents$.next(new NavigationEnd(1, '/documents/456', '/documents/456'))
|
routerEvents$.next(new NavigationEnd(1, '/documents/456', '/documents/456'))
|
||||||
expect(component.documentId).toBe(456)
|
expect(component.documentId()).toBe(456)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should return correct placeholder based on documentId', () => {
|
it('should return correct placeholder based on documentId', () => {
|
||||||
component.documentId = 123
|
component.documentId.set(123)
|
||||||
expect(component.placeholder).toBe('Ask a question about this document...')
|
expect(component.placeholder).toBe('Ask a question about this document...')
|
||||||
component.documentId = undefined
|
component.documentId.set(undefined)
|
||||||
expect(component.placeholder).toBe('Ask a question about a document...')
|
expect(component.placeholder).toBe('Ask a question about a document...')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should send a message and handle streaming response', () => {
|
it('should send a message and render the streaming response', async () => {
|
||||||
component.input = 'Hello'
|
component.input.set('Hello')
|
||||||
component.sendMessage()
|
component.sendMessage()
|
||||||
|
|
||||||
expect(component.messages.length).toBe(2)
|
expect(component.messages()).toHaveLength(2)
|
||||||
expect(component.messages[0].content).toBe('Hello')
|
expect(component.messages()[0].content).toBe('Hello')
|
||||||
expect(component.loading).toBe(true)
|
expect(component.loading()).toBe(true)
|
||||||
|
|
||||||
mockStream$.next('Hi')
|
mockStream$.next('Hi')
|
||||||
expect(component.messages[1].content).toBe('H')
|
expect(component.messages()[1].content).toBe('H')
|
||||||
mockStream$.next('Hi there')
|
mockStream$.next('Hi there')
|
||||||
// advance time to process the typewriter effect
|
// advance time to process the typewriter effect
|
||||||
jest.advanceTimersByTime(1000)
|
await jest.runAllTimersAsync()
|
||||||
expect(component.messages[1].content).toBe('Hi there')
|
await fixture.whenStable()
|
||||||
|
expect(component.messages()[1].content).toBe('Hi there')
|
||||||
|
expect(
|
||||||
|
fixture.nativeElement.querySelector('.chat-messages').textContent
|
||||||
|
).toContain('Hi there')
|
||||||
|
|
||||||
mockStream$.complete()
|
mockStream$.complete()
|
||||||
expect(component.loading).toBe(false)
|
await jest.runAllTimersAsync()
|
||||||
expect(component.messages[1].isStreaming).toBe(false)
|
await fixture.whenStable()
|
||||||
|
expect(component.loading()).toBe(false)
|
||||||
|
expect(component.messages()[1].isStreaming).toBe(false)
|
||||||
|
expect(fixture.nativeElement.querySelector('#chatInput').disabled).toBe(
|
||||||
|
false
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should parse references from the metadata trailer without showing it', () => {
|
it('should parse references from the metadata trailer without showing it', () => {
|
||||||
component.input = 'Hello'
|
component.input.set('Hello')
|
||||||
component.sendMessage()
|
component.sendMessage()
|
||||||
|
|
||||||
mockStream$.next(
|
mockStream$.next(
|
||||||
@@ -101,14 +110,14 @@ describe('ChatComponent', () => {
|
|||||||
)
|
)
|
||||||
jest.advanceTimersByTime(1000)
|
jest.advanceTimersByTime(1000)
|
||||||
|
|
||||||
expect(component.messages[1].content).toBe('Hi there')
|
expect(component.messages()[1].content).toBe('Hi there')
|
||||||
expect(component.messages[1].references).toEqual([
|
expect(component.messages()[1].references).toEqual([
|
||||||
{ id: 42, title: 'Bread Recipe' },
|
{ id: 42, title: 'Bread Recipe' },
|
||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should render document reference links under assistant messages', () => {
|
it('should render document reference links under assistant messages', () => {
|
||||||
component.input = 'Hello'
|
component.input.set('Hello')
|
||||||
component.sendMessage()
|
component.sendMessage()
|
||||||
|
|
||||||
mockStream$.next(
|
mockStream$.next(
|
||||||
@@ -123,12 +132,12 @@ describe('ChatComponent', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('should remove delimiter fragments that were already streamed', () => {
|
it('should remove delimiter fragments that were already streamed', () => {
|
||||||
component.input = 'Hello'
|
component.input.set('Hello')
|
||||||
component.sendMessage()
|
component.sendMessage()
|
||||||
|
|
||||||
mockStream$.next(`Hi there${CHAT_METADATA_DELIMITER.slice(0, 8)}`)
|
mockStream$.next(`Hi there${CHAT_METADATA_DELIMITER.slice(0, 8)}`)
|
||||||
jest.advanceTimersByTime(1000)
|
jest.advanceTimersByTime(1000)
|
||||||
expect(component.messages[1].content).toBe(
|
expect(component.messages()[1].content).toBe(
|
||||||
`Hi there${CHAT_METADATA_DELIMITER.slice(0, 8)}`
|
`Hi there${CHAT_METADATA_DELIMITER.slice(0, 8)}`
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -137,28 +146,28 @@ describe('ChatComponent', () => {
|
|||||||
)
|
)
|
||||||
jest.advanceTimersByTime(1000)
|
jest.advanceTimersByTime(1000)
|
||||||
|
|
||||||
expect(component.messages[1].content).toBe('Hi there')
|
expect(component.messages()[1].content).toBe('Hi there')
|
||||||
expect(component.messages[1].references).toEqual([
|
expect(component.messages()[1].references).toEqual([
|
||||||
{ id: 42, title: 'Bread Recipe' },
|
{ id: 42, title: 'Bread Recipe' },
|
||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should handle errors during streaming', () => {
|
it('should handle errors during streaming', () => {
|
||||||
component.input = 'Hello'
|
component.input.set('Hello')
|
||||||
component.sendMessage()
|
component.sendMessage()
|
||||||
|
|
||||||
mockStream$.error('Error')
|
mockStream$.error('Error')
|
||||||
expect(component.messages[1].content).toContain(
|
expect(component.messages()[1].content).toContain(
|
||||||
'⚠️ Error receiving response.'
|
'⚠️ Error receiving response.'
|
||||||
)
|
)
|
||||||
expect(component.loading).toBe(false)
|
expect(component.loading()).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should enqueue typewriter chunks correctly', () => {
|
it('should enqueue typewriter chunks correctly', () => {
|
||||||
const message = { content: '', role: 'assistant', isStreaming: true }
|
const message = { content: '', role: 'assistant', isStreaming: true }
|
||||||
component.enqueueTypewriter(null, message as any) // coverage for null
|
component.enqueueTypewriter(null, message as any) // coverage for null
|
||||||
component.enqueueTypewriter('Hello', message as any)
|
component.enqueueTypewriter('Hello', message as any)
|
||||||
expect(component['typewriterBuffer'].length).toBe(4)
|
expect(component['typewriterBuffer']).toHaveLength(4)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should scroll to bottom after sending a message', () => {
|
it('should scroll to bottom after sending a message', () => {
|
||||||
@@ -166,7 +175,7 @@ describe('ChatComponent', () => {
|
|||||||
ChatComponent.prototype as any,
|
ChatComponent.prototype as any,
|
||||||
'scrollToBottom'
|
'scrollToBottom'
|
||||||
)
|
)
|
||||||
component.input = 'Test'
|
component.input.set('Test')
|
||||||
component.sendMessage()
|
component.sendMessage()
|
||||||
expect(scrollSpy).toHaveBeenCalled()
|
expect(scrollSpy).toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,4 +1,11 @@
|
|||||||
import { Component, ElementRef, inject, OnInit, ViewChild } from '@angular/core'
|
import {
|
||||||
|
Component,
|
||||||
|
ElementRef,
|
||||||
|
inject,
|
||||||
|
OnInit,
|
||||||
|
signal,
|
||||||
|
ViewChild,
|
||||||
|
} from '@angular/core'
|
||||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||||
import { NavigationEnd, Router, RouterModule } from '@angular/router'
|
import { NavigationEnd, Router, RouterModule } from '@angular/router'
|
||||||
import { NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap'
|
import { NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap'
|
||||||
@@ -23,10 +30,10 @@ import {
|
|||||||
styleUrl: './chat.component.scss',
|
styleUrl: './chat.component.scss',
|
||||||
})
|
})
|
||||||
export class ChatComponent implements OnInit {
|
export class ChatComponent implements OnInit {
|
||||||
public messages: ChatMessage[] = []
|
readonly messages = signal<ChatMessage[]>([])
|
||||||
public loading = false
|
readonly loading = signal(false)
|
||||||
public input: string = ''
|
readonly input = signal('')
|
||||||
public documentId!: number
|
readonly documentId = signal<number>(undefined)
|
||||||
|
|
||||||
private chatService: ChatService = inject(ChatService)
|
private chatService: ChatService = inject(ChatService)
|
||||||
private router: Router = inject(Router)
|
private router: Router = inject(Router)
|
||||||
@@ -38,7 +45,7 @@ export class ChatComponent implements OnInit {
|
|||||||
private typewriterActive = false
|
private typewriterActive = false
|
||||||
|
|
||||||
public get placeholder(): string {
|
public get placeholder(): string {
|
||||||
return this.documentId
|
return this.documentId()
|
||||||
? $localize`Ask a question about this document...`
|
? $localize`Ask a question about this document...`
|
||||||
: $localize`Ask a question about a document...`
|
: $localize`Ask a question about a document...`
|
||||||
}
|
}
|
||||||
@@ -57,14 +64,14 @@ export class ChatComponent implements OnInit {
|
|||||||
|
|
||||||
private updateDocumentId(url: string): void {
|
private updateDocumentId(url: string): void {
|
||||||
const docIdRe = url.match(/^\/documents\/(\d+)/)
|
const docIdRe = url.match(/^\/documents\/(\d+)/)
|
||||||
this.documentId = docIdRe ? +docIdRe[1] : undefined
|
this.documentId.set(docIdRe ? +docIdRe[1] : undefined)
|
||||||
}
|
}
|
||||||
|
|
||||||
sendMessage(): void {
|
sendMessage(): void {
|
||||||
if (!this.input.trim()) return
|
if (!this.input().trim()) return
|
||||||
|
|
||||||
const userMessage: ChatMessage = { role: 'user', content: this.input }
|
const userMessage: ChatMessage = { role: 'user', content: this.input() }
|
||||||
this.messages.push(userMessage)
|
this.messages.update((messages) => [...messages, userMessage])
|
||||||
this.scrollToBottom()
|
this.scrollToBottom()
|
||||||
|
|
||||||
const assistantMessage: ChatMessage = {
|
const assistantMessage: ChatMessage = {
|
||||||
@@ -72,12 +79,12 @@ export class ChatComponent implements OnInit {
|
|||||||
content: '',
|
content: '',
|
||||||
isStreaming: true,
|
isStreaming: true,
|
||||||
}
|
}
|
||||||
this.messages.push(assistantMessage)
|
this.messages.update((messages) => [...messages, assistantMessage])
|
||||||
this.loading = true
|
this.loading.set(true)
|
||||||
|
|
||||||
let lastVisibleContent = ''
|
let lastVisibleContent = ''
|
||||||
|
|
||||||
this.chatService.streamChat(this.documentId, this.input).subscribe({
|
this.chatService.streamChat(this.documentId(), this.input()).subscribe({
|
||||||
next: (chunk) => {
|
next: (chunk) => {
|
||||||
const nextResponse = parseChatResponse(chunk)
|
const nextResponse = parseChatResponse(chunk)
|
||||||
|
|
||||||
@@ -93,26 +100,30 @@ export class ChatComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
|
|
||||||
assistantMessage.references = nextResponse.references
|
assistantMessage.references = nextResponse.references
|
||||||
|
this.notifyMessagesChanged()
|
||||||
},
|
},
|
||||||
error: () => {
|
error: () => {
|
||||||
assistantMessage.content += '\n\n⚠️ Error receiving response.'
|
assistantMessage.content += '\n\n⚠️ Error receiving response.'
|
||||||
assistantMessage.isStreaming = false
|
assistantMessage.isStreaming = false
|
||||||
this.loading = false
|
this.notifyMessagesChanged()
|
||||||
|
this.loading.set(false)
|
||||||
},
|
},
|
||||||
complete: () => {
|
complete: () => {
|
||||||
assistantMessage.isStreaming = false
|
assistantMessage.isStreaming = false
|
||||||
this.loading = false
|
this.notifyMessagesChanged()
|
||||||
|
this.loading.set(false)
|
||||||
this.scrollToBottom()
|
this.scrollToBottom()
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
this.input = ''
|
this.input.set('')
|
||||||
}
|
}
|
||||||
|
|
||||||
private resetTypewriter(message: ChatMessage, content: string): void {
|
private resetTypewriter(message: ChatMessage, content: string): void {
|
||||||
this.typewriterBuffer = []
|
this.typewriterBuffer = []
|
||||||
this.typewriterActive = false
|
this.typewriterActive = false
|
||||||
message.content = content
|
message.content = content
|
||||||
|
this.notifyMessagesChanged()
|
||||||
this.scrollToBottom()
|
this.scrollToBottom()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -135,11 +146,16 @@ export class ChatComponent implements OnInit {
|
|||||||
|
|
||||||
const nextChar = this.typewriterBuffer.shift()
|
const nextChar = this.typewriterBuffer.shift()
|
||||||
message.content += nextChar
|
message.content += nextChar
|
||||||
|
this.notifyMessagesChanged()
|
||||||
this.scrollToBottom()
|
this.scrollToBottom()
|
||||||
|
|
||||||
setTimeout(() => this.playTypewriter(message), 10) // 10ms per character
|
setTimeout(() => this.playTypewriter(message), 10) // 10ms per character
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private notifyMessagesChanged(): void {
|
||||||
|
this.messages.update((messages) => [...messages])
|
||||||
|
}
|
||||||
|
|
||||||
private scrollToBottom(): void {
|
private scrollToBottom(): void {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
this.scrollAnchor?.nativeElement?.scrollIntoView({ behavior: 'smooth' })
|
this.scrollAnchor?.nativeElement?.scrollIntoView({ behavior: 'smooth' })
|
||||||
|
|||||||
+11
-1
@@ -1,5 +1,12 @@
|
|||||||
import { CurrencyPipe, getLocaleCurrencyCode, SlicePipe } from '@angular/common'
|
import { CurrencyPipe, getLocaleCurrencyCode, SlicePipe } from '@angular/common'
|
||||||
import { Component, inject, Input, LOCALE_ID, OnInit } from '@angular/core'
|
import {
|
||||||
|
ChangeDetectorRef,
|
||||||
|
Component,
|
||||||
|
inject,
|
||||||
|
Input,
|
||||||
|
LOCALE_ID,
|
||||||
|
OnInit,
|
||||||
|
} from '@angular/core'
|
||||||
import { NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap'
|
import { NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap'
|
||||||
import { takeUntil } from 'rxjs'
|
import { takeUntil } from 'rxjs'
|
||||||
import { CustomField, CustomFieldDataType } from 'src/app/data/custom-field'
|
import { CustomField, CustomFieldDataType } from 'src/app/data/custom-field'
|
||||||
@@ -22,6 +29,7 @@ export class CustomFieldDisplayComponent
|
|||||||
{
|
{
|
||||||
private customFieldService = inject(CustomFieldsService)
|
private customFieldService = inject(CustomFieldsService)
|
||||||
private documentService = inject(DocumentService)
|
private documentService = inject(DocumentService)
|
||||||
|
private changeDetector = inject(ChangeDetectorRef)
|
||||||
|
|
||||||
CustomFieldDataType = CustomFieldDataType
|
CustomFieldDataType = CustomFieldDataType
|
||||||
|
|
||||||
@@ -74,6 +82,7 @@ export class CustomFieldDisplayComponent
|
|||||||
this.customFieldService.listAll().subscribe((r) => {
|
this.customFieldService.listAll().subscribe((r) => {
|
||||||
this.customFields = r.results
|
this.customFields = r.results
|
||||||
this.init()
|
this.init()
|
||||||
|
this.changeDetector.markForCheck()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,6 +120,7 @@ export class CustomFieldDisplayComponent
|
|||||||
this.docLinkDocuments = this.value
|
this.docLinkDocuments = this.value
|
||||||
.map((id) => result.results.find((d) => d.id === id))
|
.map((id) => result.results.find((d) => d.id === id))
|
||||||
.filter((d) => d)
|
.filter((d) => d)
|
||||||
|
this.changeDetector.markForCheck()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -95,8 +95,8 @@ describe('CustomFieldsDropdownComponent', () => {
|
|||||||
it('should support update unused fields', () => {
|
it('should support update unused fields', () => {
|
||||||
component.existingFields = [{ field: fields[0].id } as any]
|
component.existingFields = [{ field: fields[0].id } as any]
|
||||||
component['updateUnusedFields']()
|
component['updateUnusedFields']()
|
||||||
expect(component['unusedFields'].length).toEqual(1)
|
expect(component['unusedFields']().length).toEqual(1)
|
||||||
expect(component['unusedFields'][0].name).toEqual('Field 2')
|
expect(component['unusedFields']()[0].name).toEqual('Field 2')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should support getting data type label', () => {
|
it('should support getting data type label', () => {
|
||||||
|
|||||||
+7
-4
@@ -8,6 +8,7 @@ import {
|
|||||||
ViewChild,
|
ViewChild,
|
||||||
ViewChildren,
|
ViewChildren,
|
||||||
inject,
|
inject,
|
||||||
|
signal,
|
||||||
} from '@angular/core'
|
} from '@angular/core'
|
||||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||||
import { NgbDropdownModule, NgbModal } from '@ng-bootstrap/ng-bootstrap'
|
import { NgbDropdownModule, NgbModal } from '@ng-bootstrap/ng-bootstrap'
|
||||||
@@ -65,11 +66,11 @@ export class CustomFieldsDropdownComponent extends LoadingComponentWithPermissio
|
|||||||
@ViewChildren('button') buttons: QueryList<ElementRef>
|
@ViewChildren('button') buttons: QueryList<ElementRef>
|
||||||
|
|
||||||
private customFields: CustomField[] = []
|
private customFields: CustomField[] = []
|
||||||
private unusedFields: CustomField[] = []
|
private readonly unusedFields = signal<CustomField[]>([])
|
||||||
private keyboardIndex: number
|
private keyboardIndex: number
|
||||||
|
|
||||||
public get filteredFields(): CustomField[] {
|
public get filteredFields(): CustomField[] {
|
||||||
return this.unusedFields.filter(
|
return this.unusedFields().filter(
|
||||||
(f) => !this.filterText || matchesSearchText(f.name, this.filterText)
|
(f) => !this.filterText || matchesSearchText(f.name, this.filterText)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -99,8 +100,10 @@ export class CustomFieldsDropdownComponent extends LoadingComponentWithPermissio
|
|||||||
}
|
}
|
||||||
|
|
||||||
private updateUnusedFields() {
|
private updateUnusedFields() {
|
||||||
this.unusedFields = this.customFields.filter(
|
this.unusedFields.set(
|
||||||
(f) => !this.existingFields?.find((e) => e.field === f.id)
|
this.customFields.filter(
|
||||||
|
(f) => !this.existingFields?.find((e) => e.field === f.id)
|
||||||
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -77,7 +77,7 @@
|
|||||||
<div class="input-group input-group-sm">
|
<div class="input-group input-group-sm">
|
||||||
<ng-select #fieldSelects
|
<ng-select #fieldSelects
|
||||||
class="paperless-input-select"
|
class="paperless-input-select"
|
||||||
[items]="customFields"
|
[items]="customFields()"
|
||||||
[(ngModel)]="atom.field"
|
[(ngModel)]="atom.field"
|
||||||
[disabled]="disabled"
|
[disabled]="disabled"
|
||||||
bindLabel="name"
|
bindLabel="name"
|
||||||
|
|||||||
+3
-3
@@ -79,7 +79,7 @@ describe('CustomFieldsQueryDropdownComponent', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('should initialize custom fields on creation', () => {
|
it('should initialize custom fields on creation', () => {
|
||||||
expect(component.customFields).toEqual(customFields)
|
expect(component.customFields()).toEqual(customFields)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should add an expression when opened if queries are empty', () => {
|
it('should add an expression when opened if queries are empty', () => {
|
||||||
@@ -101,7 +101,7 @@ describe('CustomFieldsQueryDropdownComponent', () => {
|
|||||||
data_type: CustomFieldDataType.String,
|
data_type: CustomFieldDataType.String,
|
||||||
extra_data: {},
|
extra_data: {},
|
||||||
}
|
}
|
||||||
component.customFields = [field]
|
component.customFields.set([field])
|
||||||
const operators = component.getOperatorsForField(1)
|
const operators = component.getOperatorsForField(1)
|
||||||
expect(operators.length).toEqual(
|
expect(operators.length).toEqual(
|
||||||
[
|
[
|
||||||
@@ -138,7 +138,7 @@ describe('CustomFieldsQueryDropdownComponent', () => {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
component.customFields = [field]
|
component.customFields.set([field])
|
||||||
const options = component.getSelectOptionsForField(1)
|
const options = component.getSelectOptionsForField(1)
|
||||||
expect(options).toEqual([
|
expect(options).toEqual([
|
||||||
{ label: 'Option 1', id: 'abc-123' },
|
{ label: 'Option 1', id: 'abc-123' },
|
||||||
|
|||||||
+6
-5
@@ -6,6 +6,7 @@ import {
|
|||||||
Input,
|
Input,
|
||||||
Output,
|
Output,
|
||||||
QueryList,
|
QueryList,
|
||||||
|
signal,
|
||||||
ViewChild,
|
ViewChild,
|
||||||
ViewChildren,
|
ViewChildren,
|
||||||
} from '@angular/core'
|
} from '@angular/core'
|
||||||
@@ -278,7 +279,7 @@ export class CustomFieldsQueryDropdownComponent extends LoadingComponentWithPerm
|
|||||||
@Output()
|
@Output()
|
||||||
selectionModelChange = new EventEmitter<CustomFieldQueriesModel>()
|
selectionModelChange = new EventEmitter<CustomFieldQueriesModel>()
|
||||||
|
|
||||||
customFields: CustomField[] = []
|
readonly customFields = signal<CustomField[]>([])
|
||||||
|
|
||||||
public readonly today: string = new Date().toLocaleDateString('en-CA')
|
public readonly today: string = new Date().toLocaleDateString('en-CA')
|
||||||
|
|
||||||
@@ -325,12 +326,12 @@ export class CustomFieldsQueryDropdownComponent extends LoadingComponentWithPerm
|
|||||||
.listAll()
|
.listAll()
|
||||||
.pipe(first(), takeUntil(this.unsubscribeNotifier))
|
.pipe(first(), takeUntil(this.unsubscribeNotifier))
|
||||||
.subscribe((result) => {
|
.subscribe((result) => {
|
||||||
this.customFields = result.results
|
this.customFields.set(result.results)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
public getCustomFieldByID(id: number): CustomField {
|
public getCustomFieldByID(id: number): CustomField {
|
||||||
return this.customFields.find((field) => field.id === id)
|
return this.customFields().find((field) => field.id === id)
|
||||||
}
|
}
|
||||||
|
|
||||||
public addAtom(expression: CustomFieldQueryExpression) {
|
public addAtom(expression: CustomFieldQueryExpression) {
|
||||||
@@ -353,7 +354,7 @@ export class CustomFieldsQueryDropdownComponent extends LoadingComponentWithPerm
|
|||||||
getOperatorsForField(
|
getOperatorsForField(
|
||||||
fieldID: number
|
fieldID: number
|
||||||
): Array<{ value: string; label: string }> {
|
): Array<{ value: string; label: string }> {
|
||||||
const field = this.customFields.find((field) => field.id === fieldID)
|
const field = this.customFields().find((field) => field.id === fieldID)
|
||||||
const groups: CustomFieldQueryOperatorGroups[] = field
|
const groups: CustomFieldQueryOperatorGroups[] = field
|
||||||
? CUSTOM_FIELD_QUERY_OPERATOR_GROUPS_BY_TYPE[field.data_type]
|
? CUSTOM_FIELD_QUERY_OPERATOR_GROUPS_BY_TYPE[field.data_type]
|
||||||
: [CustomFieldQueryOperatorGroups.Basic]
|
: [CustomFieldQueryOperatorGroups.Basic]
|
||||||
@@ -369,7 +370,7 @@ export class CustomFieldsQueryDropdownComponent extends LoadingComponentWithPerm
|
|||||||
getSelectOptionsForField(
|
getSelectOptionsForField(
|
||||||
fieldID: number
|
fieldID: number
|
||||||
): Array<{ label: string; id: string }> {
|
): Array<{ label: string; id: string }> {
|
||||||
const field = this.customFields.find((field) => field.id === fieldID)
|
const field = this.customFields().find((field) => field.id === fieldID)
|
||||||
if (field) {
|
if (field) {
|
||||||
return field.extra_data['select_options']
|
return field.extra_data['select_options']
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-5
@@ -22,11 +22,11 @@
|
|||||||
<ng-template>
|
<ng-template>
|
||||||
<div class="card mb-2">
|
<div class="card mb-2">
|
||||||
<div class="card-body p-2">
|
<div class="card-body p-2">
|
||||||
@if (testLoading) {
|
@if (testLoading()) {
|
||||||
<ng-container [ngTemplateOutlet]="loadingTemplate"></ng-container>
|
<ng-container [ngTemplateOutlet]="loadingTemplate"></ng-container>
|
||||||
} @else if (testResult) {
|
} @else if (testResult()) {
|
||||||
<code>{{testResult}}</code>
|
<code>{{testResult()}}</code>
|
||||||
} @else if (testFailed) {
|
} @else if (testFailed()) {
|
||||||
<div class="text-danger" i18n>Path test failed</div>
|
<div class="text-danger" i18n>Path test failed</div>
|
||||||
} @else {
|
} @else {
|
||||||
<div class="text-muted small" i18n>No document selected</div>
|
<div class="text-muted small" i18n>No document selected</div>
|
||||||
@@ -42,7 +42,7 @@
|
|||||||
[compareWith]="compareDocuments"
|
[compareWith]="compareDocuments"
|
||||||
[trackByFn]="trackByFn"
|
[trackByFn]="trackByFn"
|
||||||
[minTermLength]="2"
|
[minTermLength]="2"
|
||||||
[loading]="loading"
|
[loading]="loading()"
|
||||||
[typeahead]="documentsInput$"
|
[typeahead]="documentsInput$"
|
||||||
(change)="testPath($event)">
|
(change)="testPath($event)">
|
||||||
<ng-template #loadingTemplate ng-loadingspinner-tmp>
|
<ng-template #loadingTemplate ng-loadingspinner-tmp>
|
||||||
|
|||||||
+5
-5
@@ -58,17 +58,17 @@ describe('StoragePathEditDialogComponent', () => {
|
|||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
component.testPath({ id: 1 })
|
component.testPath({ id: 1 })
|
||||||
expect(testSpy).toHaveBeenCalledWith('test/{{title}}', 1)
|
expect(testSpy).toHaveBeenCalledWith('test/{{title}}', 1)
|
||||||
expect(component.testResult).toBe('test/abc123')
|
expect(component.testResult()).toBe('test/abc123')
|
||||||
expect(component.testFailed).toBeFalsy()
|
expect(component.testFailed()).toBeFalsy()
|
||||||
|
|
||||||
// test failed
|
// test failed
|
||||||
testSpy.mockReturnValueOnce(of(''))
|
testSpy.mockReturnValueOnce(of(''))
|
||||||
component.testPath({ id: 1 })
|
component.testPath({ id: 1 })
|
||||||
expect(component.testResult).toBeNull()
|
expect(component.testResult()).toBeNull()
|
||||||
expect(component.testFailed).toBeTruthy()
|
expect(component.testFailed()).toBeTruthy()
|
||||||
|
|
||||||
component.testPath(null)
|
component.testPath(null)
|
||||||
expect(component.testResult).toBeNull()
|
expect(component.testResult()).toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should compare two documents by id', () => {
|
it('should compare two documents by id', () => {
|
||||||
|
|||||||
+11
-11
@@ -1,5 +1,5 @@
|
|||||||
import { AsyncPipe, NgTemplateOutlet } from '@angular/common'
|
import { AsyncPipe, NgTemplateOutlet } from '@angular/common'
|
||||||
import { Component, OnDestroy, inject } from '@angular/core'
|
import { Component, OnDestroy, inject, signal } from '@angular/core'
|
||||||
import {
|
import {
|
||||||
FormControl,
|
FormControl,
|
||||||
FormGroup,
|
FormGroup,
|
||||||
@@ -65,9 +65,9 @@ export class StoragePathEditDialogComponent
|
|||||||
public documentsInput$ = new Subject<string>()
|
public documentsInput$ = new Subject<string>()
|
||||||
public foundDocuments$: Observable<Document[]>
|
public foundDocuments$: Observable<Document[]>
|
||||||
private testDocument: Document
|
private testDocument: Document
|
||||||
public testResult: string
|
readonly testResult = signal<string>(undefined)
|
||||||
public testFailed: boolean = false
|
readonly testFailed = signal(false)
|
||||||
public testLoading = false
|
readonly testLoading = signal(false)
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super()
|
super()
|
||||||
@@ -99,22 +99,22 @@ export class StoragePathEditDialogComponent
|
|||||||
|
|
||||||
public testPath(document: Document) {
|
public testPath(document: Document) {
|
||||||
if (!document) {
|
if (!document) {
|
||||||
this.testResult = null
|
this.testResult.set(null)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
this.testDocument = document
|
this.testDocument = document
|
||||||
this.testLoading = true
|
this.testLoading.set(true)
|
||||||
;(this.service as StoragePathService)
|
;(this.service as StoragePathService)
|
||||||
.testPath(this.objectForm.get('path').value, document.id)
|
.testPath(this.objectForm.get('path').value, document.id)
|
||||||
.subscribe((result) => {
|
.subscribe((result) => {
|
||||||
if (result?.length) {
|
if (result?.length) {
|
||||||
this.testResult = result
|
this.testResult.set(result)
|
||||||
this.testFailed = false
|
this.testFailed.set(false)
|
||||||
} else {
|
} else {
|
||||||
this.testResult = null
|
this.testResult.set(null)
|
||||||
this.testFailed = true
|
this.testFailed.set(true)
|
||||||
}
|
}
|
||||||
this.testLoading = false
|
this.testLoading.set(false)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -12,7 +12,7 @@
|
|||||||
|
|
||||||
<pngx-input-color i18n-title title="Color" formControlName="color" [error]="error?.color"></pngx-input-color>
|
<pngx-input-color i18n-title title="Color" formControlName="color" [error]="error?.color"></pngx-input-color>
|
||||||
|
|
||||||
<pngx-input-select i18n-title title="Parent" formControlName="parent" [items]="tags" [allowNull]="true" [error]="error?.parent"></pngx-input-select>
|
<pngx-input-select i18n-title title="Parent" formControlName="parent" [items]="tags()" [allowNull]="true" [error]="error?.parent"></pngx-input-select>
|
||||||
|
|
||||||
<pngx-input-check i18n-title title="Inbox tag" formControlName="is_inbox_tag" i18n-hint hint="Inbox tags are automatically assigned to all consumed documents."></pngx-input-check>
|
<pngx-input-check i18n-title title="Inbox tag" formControlName="is_inbox_tag" i18n-hint hint="Inbox tags are automatically assigned to all consumed documents."></pngx-input-check>
|
||||||
<pngx-input-select i18n-title title="Matching algorithm" [items]="getMatchingAlgorithms()" formControlName="matching_algorithm"></pngx-input-select>
|
<pngx-input-select i18n-title title="Matching algorithm" [items]="getMatchingAlgorithms()" formControlName="matching_algorithm"></pngx-input-select>
|
||||||
|
|||||||
+3
-3
@@ -1,4 +1,4 @@
|
|||||||
import { Component, inject } from '@angular/core'
|
import { Component, inject, signal } from '@angular/core'
|
||||||
import {
|
import {
|
||||||
FormControl,
|
FormControl,
|
||||||
FormGroup,
|
FormGroup,
|
||||||
@@ -35,7 +35,7 @@ import { TextComponent } from '../../input/text/text.component'
|
|||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class TagEditDialogComponent extends EditDialogComponent<Tag> {
|
export class TagEditDialogComponent extends EditDialogComponent<Tag> {
|
||||||
tags: Tag[]
|
readonly tags = signal<Tag[]>([])
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super()
|
super()
|
||||||
@@ -43,7 +43,7 @@ export class TagEditDialogComponent extends EditDialogComponent<Tag> {
|
|||||||
this.userService = inject(UserService)
|
this.userService = inject(UserService)
|
||||||
this.settingsService = inject(SettingsService)
|
this.settingsService = inject(SettingsService)
|
||||||
this.service.listAll().subscribe((result) => {
|
this.service.listAll().subscribe((result) => {
|
||||||
this.tags = result.results
|
this.tags.set(result.results)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -180,7 +180,7 @@
|
|||||||
<div class="col">
|
<div class="col">
|
||||||
<div class="trigger-filters mb-3">
|
<div class="trigger-filters mb-3">
|
||||||
<div class="d-flex align-items-center">
|
<div class="d-flex align-items-center">
|
||||||
<label class="form-label mb-0" i18n>Advanced Filters</label>
|
<span class="form-label mb-0" i18n>Advanced Filters</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="btn btn-sm btn-outline-primary ms-auto"
|
class="btn btn-sm btn-outline-primary ms-auto"
|
||||||
|
|||||||
+5
-5
@@ -401,7 +401,7 @@ describe('WorkflowEditDialogComponent', () => {
|
|||||||
component.addFilter(triggerGroup as FormGroup)
|
component.addFilter(triggerGroup as FormGroup)
|
||||||
|
|
||||||
const filters = component.getFiltersFormArray(triggerGroup as FormGroup)
|
const filters = component.getFiltersFormArray(triggerGroup as FormGroup)
|
||||||
expect(filters.length).toBe(3)
|
expect(filters).toHaveLength(3)
|
||||||
|
|
||||||
filters.at(0).get('values').setValue([1])
|
filters.at(0).get('values').setValue([1])
|
||||||
filters.at(1).get('values').setValue([2, 3])
|
filters.at(1).get('values').setValue([2, 3])
|
||||||
@@ -672,7 +672,7 @@ describe('WorkflowEditDialogComponent', () => {
|
|||||||
} as any
|
} as any
|
||||||
|
|
||||||
const filters = component['buildFiltersFormArray'](trigger)
|
const filters = component['buildFiltersFormArray'](trigger)
|
||||||
expect(filters.length).toBe(0)
|
expect(filters).toHaveLength(0)
|
||||||
|
|
||||||
component.filterDefinitions = originalDefinitions
|
component.filterDefinitions = originalDefinitions
|
||||||
})
|
})
|
||||||
@@ -739,7 +739,7 @@ describe('WorkflowEditDialogComponent', () => {
|
|||||||
component.ngOnInit()
|
component.ngOnInit()
|
||||||
const triggerGroup = component.triggerFields.at(0) as FormGroup
|
const triggerGroup = component.triggerFields.at(0) as FormGroup
|
||||||
const filters = component.getFiltersFormArray(triggerGroup)
|
const filters = component.getFiltersFormArray(triggerGroup)
|
||||||
expect(filters.length).toBe(13)
|
expect(filters).toHaveLength(13)
|
||||||
const customFieldFilter = filters.at(12) as FormGroup
|
const customFieldFilter = filters.at(12) as FormGroup
|
||||||
expect(customFieldFilter.get('type').value).toBe(
|
expect(customFieldFilter.get('type').value).toBe(
|
||||||
TriggerFilterType.CustomFieldQuery
|
TriggerFilterType.CustomFieldQuery
|
||||||
@@ -969,12 +969,12 @@ describe('WorkflowEditDialogComponent', () => {
|
|||||||
component.addFilter(triggerGroup)
|
component.addFilter(triggerGroup)
|
||||||
|
|
||||||
component.removeFilter(triggerGroup, 0)
|
component.removeFilter(triggerGroup, 0)
|
||||||
expect(component.getFiltersFormArray(triggerGroup).length).toBe(0)
|
expect(component.getFiltersFormArray(triggerGroup)).toHaveLength(0)
|
||||||
|
|
||||||
component.addFilter(triggerGroup)
|
component.addFilter(triggerGroup)
|
||||||
const filterArrayAfterAdd = component.getFiltersFormArray(triggerGroup)
|
const filterArrayAfterAdd = component.getFiltersFormArray(triggerGroup)
|
||||||
filterArrayAfterAdd.at(0).get('type').setValue(TriggerFilterType.TagsAll)
|
filterArrayAfterAdd.at(0).get('type').setValue(TriggerFilterType.TagsAll)
|
||||||
expect(component.getFiltersFormArray(triggerGroup).length).toBe(1)
|
expect(component.getFiltersFormArray(triggerGroup)).toHaveLength(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should remove selected custom field from the form group', () => {
|
it('should remove selected custom field from the form group', () => {
|
||||||
|
|||||||
+1
-1
@@ -933,7 +933,7 @@ export class WorkflowEditDialogComponent
|
|||||||
|
|
||||||
getFilterSelectItems(type: TriggerFilterType) {
|
getFilterSelectItems(type: TriggerFilterType) {
|
||||||
const definition = this.getFilterDefinition(type)
|
const definition = this.getFilterDefinition(type)
|
||||||
if (!definition || definition.inputType !== 'select') {
|
if (definition?.inputType !== 'select') {
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+2
-2
@@ -58,8 +58,8 @@
|
|||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
@if ((selectionModel.items | filter: filterText:'name').length > 0) {
|
@if ((selectionModel.items | filter: filterText:'name').length > 0) {
|
||||||
<button class="list-group-item list-group-item-action bg-light d-flex align-items-center" (click)="applyClicked()" [disabled]="!modelIsDirty || disabled">
|
<button class="list-group-item list-group-item-action bg-light d-flex align-items-center" (click)="applyClicked()" [disabled]="!modelIsDirty() || disabled">
|
||||||
<small class="ms-2" [ngClass]="{'fw-bold': modelIsDirty}" i18n>Apply</small>
|
<small class="ms-2" [ngClass]="{'fw-bold': modelIsDirty()}" i18n>Apply</small>
|
||||||
<i-bs width="1.5em" height="1em" name="arrow-right"></i-bs>
|
<i-bs width="1.5em" height="1em" name="arrow-right"></i-bs>
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-2
@@ -221,7 +221,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
|
|||||||
.dispatchEvent(new MouseEvent('click')) // open
|
.dispatchEvent(new MouseEvent('click')) // open
|
||||||
selectionModel.toggle(items[0].id)
|
selectionModel.toggle(items[0].id)
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
expect(component.modelIsDirty).toBeTruthy()
|
expect(component.modelIsDirty()).toBeTruthy()
|
||||||
let applyResult: ChangedItems
|
let applyResult: ChangedItems
|
||||||
const closeSpy = jest.spyOn(component.dropdown, 'close')
|
const closeSpy = jest.spyOn(component.dropdown, 'close')
|
||||||
component.apply.subscribe((result) => (applyResult = result))
|
component.apply.subscribe((result) => (applyResult = result))
|
||||||
@@ -244,7 +244,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
|
|||||||
.dispatchEvent(new MouseEvent('click')) // open
|
.dispatchEvent(new MouseEvent('click')) // open
|
||||||
selectionModel.toggle(items[0].id)
|
selectionModel.toggle(items[0].id)
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
expect(component.modelIsDirty).toBeTruthy()
|
expect(component.modelIsDirty()).toBeTruthy()
|
||||||
let applyResult: ChangedItems
|
let applyResult: ChangedItems
|
||||||
component.apply.subscribe((result) => (applyResult = result))
|
component.apply.subscribe((result) => (applyResult = result))
|
||||||
component.dropdown.close()
|
component.dropdown.close()
|
||||||
|
|||||||
+4
-3
@@ -12,6 +12,7 @@ import {
|
|||||||
Output,
|
Output,
|
||||||
ViewChild,
|
ViewChild,
|
||||||
inject,
|
inject,
|
||||||
|
signal,
|
||||||
} from '@angular/core'
|
} from '@angular/core'
|
||||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||||
import { NgbDropdown, NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap'
|
import { NgbDropdown, NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap'
|
||||||
@@ -796,7 +797,7 @@ export class FilterableDropdownComponent
|
|||||||
return this.title ? this.title.replace(/\s/g, '_').toLowerCase() : null
|
return this.title ? this.title.replace(/\s/g, '_').toLowerCase() : null
|
||||||
}
|
}
|
||||||
|
|
||||||
modelIsDirty: boolean = false
|
readonly modelIsDirty = signal(false)
|
||||||
|
|
||||||
private keyboardIndex: number
|
private keyboardIndex: number
|
||||||
|
|
||||||
@@ -811,7 +812,7 @@ export class FilterableDropdownComponent
|
|||||||
constructor() {
|
constructor() {
|
||||||
super()
|
super()
|
||||||
this.selectionModelChange.subscribe((updatedModel) => {
|
this.selectionModelChange.subscribe((updatedModel) => {
|
||||||
this.modelIsDirty = updatedModel.isDirty()
|
this.modelIsDirty.set(updatedModel.isDirty())
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -858,7 +859,7 @@ export class FilterableDropdownComponent
|
|||||||
}, 0)
|
}, 0)
|
||||||
if (this.editing) {
|
if (this.editing) {
|
||||||
this.selectionModel.reset()
|
this.selectionModel.reset()
|
||||||
this.modelIsDirty = false
|
this.modelIsDirty.set(false)
|
||||||
}
|
}
|
||||||
this.selectionModel.singleSelect =
|
this.selectionModel.singleSelect =
|
||||||
this.editing && !this.selectionModel.manyToOne
|
this.editing && !this.selectionModel.manyToOne
|
||||||
|
|||||||
+4
-3
@@ -5,6 +5,7 @@ import {
|
|||||||
inject,
|
inject,
|
||||||
Input,
|
Input,
|
||||||
Output,
|
Output,
|
||||||
|
signal,
|
||||||
} from '@angular/core'
|
} from '@angular/core'
|
||||||
import {
|
import {
|
||||||
FormsModule,
|
FormsModule,
|
||||||
@@ -63,11 +64,11 @@ export class CustomFieldsValuesComponent extends AbstractInputComponent<Object>
|
|||||||
|
|
||||||
super()
|
super()
|
||||||
customFieldsService.listAll().subscribe((items) => {
|
customFieldsService.listAll().subscribe((items) => {
|
||||||
this.fields = items.results
|
this.fields.set(items.results)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
private fields: CustomField[]
|
private readonly fields = signal<CustomField[]>([])
|
||||||
|
|
||||||
private _selectedFields: number[]
|
private _selectedFields: number[]
|
||||||
|
|
||||||
@@ -90,6 +91,6 @@ export class CustomFieldsValuesComponent extends AbstractInputComponent<Object>
|
|||||||
public removeSelectedField: EventEmitter<number> = new EventEmitter<number>()
|
public removeSelectedField: EventEmitter<number> = new EventEmitter<number>()
|
||||||
|
|
||||||
public getCustomField(id: number): CustomField {
|
public getCustomField(id: number): CustomField {
|
||||||
return this.fields.find((field) => field.id === id)
|
return this.fields().find((field) => field.id === id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,7 +33,7 @@
|
|||||||
[compareWith]="compareDocuments"
|
[compareWith]="compareDocuments"
|
||||||
[trackByFn]="trackByFn"
|
[trackByFn]="trackByFn"
|
||||||
[minTermLength]="2"
|
[minTermLength]="2"
|
||||||
[loading]="loading"
|
[loading]="loading()"
|
||||||
[typeahead]="documentsInput$"
|
[typeahead]="documentsInput$"
|
||||||
(mousedown)="$event.stopImmediatePropagation()"
|
(mousedown)="$event.stopImmediatePropagation()"
|
||||||
(change)="onChange(selectedDocumentIDs)">
|
(change)="onChange(selectedDocumentIDs)">
|
||||||
|
|||||||
+26
-2
@@ -2,7 +2,11 @@ import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
|
|||||||
import { provideHttpClientTesting } from '@angular/common/http/testing'
|
import { provideHttpClientTesting } from '@angular/common/http/testing'
|
||||||
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||||
import { NG_VALUE_ACCESSOR } from '@angular/forms'
|
import { NG_VALUE_ACCESSOR } from '@angular/forms'
|
||||||
import { of, throwError } from 'rxjs'
|
import { By } from '@angular/platform-browser'
|
||||||
|
import { provideRouter } from '@angular/router'
|
||||||
|
import { NgSelectComponent } from '@ng-select/ng-select'
|
||||||
|
import { allIcons, NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||||
|
import { of, Subject, throwError } from 'rxjs'
|
||||||
import { FILTER_SIMPLE_TITLE } from 'src/app/data/filter-rule-type'
|
import { FILTER_SIMPLE_TITLE } from 'src/app/data/filter-rule-type'
|
||||||
import { DocumentService } from 'src/app/services/rest/document.service'
|
import { DocumentService } from 'src/app/services/rest/document.service'
|
||||||
import { DocumentLinkComponent } from './document-link.component'
|
import { DocumentLinkComponent } from './document-link.component'
|
||||||
@@ -33,10 +37,11 @@ describe('DocumentLinkComponent', () => {
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
TestBed.configureTestingModule({
|
TestBed.configureTestingModule({
|
||||||
imports: [DocumentLinkComponent],
|
imports: [DocumentLinkComponent, NgxBootstrapIconsModule.pick(allIcons)],
|
||||||
providers: [
|
providers: [
|
||||||
provideHttpClient(withInterceptorsFromDi()),
|
provideHttpClient(withInterceptorsFromDi()),
|
||||||
provideHttpClientTesting(),
|
provideHttpClientTesting(),
|
||||||
|
provideRouter([]),
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
documentService = TestBed.inject(DocumentService)
|
documentService = TestBed.inject(DocumentService)
|
||||||
@@ -60,6 +65,25 @@ describe('DocumentLinkComponent', () => {
|
|||||||
expect(getSpy).toHaveBeenCalled()
|
expect(getSpy).toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should render loading and selected documents after async updates', async () => {
|
||||||
|
const result$ = new Subject<any>()
|
||||||
|
jest.spyOn(documentService, 'getFew').mockReturnValue(result$)
|
||||||
|
|
||||||
|
component.writeValue([1])
|
||||||
|
await fixture.whenStable()
|
||||||
|
|
||||||
|
const select = fixture.debugElement.query(By.directive(NgSelectComponent))
|
||||||
|
.componentInstance as NgSelectComponent
|
||||||
|
expect(select.loading()).toBe(true)
|
||||||
|
|
||||||
|
result$.next({ count: 1, all: [1], results: [documents[0]] })
|
||||||
|
result$.complete()
|
||||||
|
await fixture.whenStable()
|
||||||
|
|
||||||
|
expect(select.loading()).toBe(false)
|
||||||
|
expect(fixture.nativeElement.textContent).toContain(documents[0].title)
|
||||||
|
})
|
||||||
|
|
||||||
it('shoud maintain ordering of selected documents', () => {
|
it('shoud maintain ordering of selected documents', () => {
|
||||||
const getSpy = jest.spyOn(documentService, 'getFew')
|
const getSpy = jest.spyOn(documentService, 'getFew')
|
||||||
getSpy.mockImplementation((ids) => {
|
getSpy.mockImplementation((ids) => {
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import {
|
|||||||
Input,
|
Input,
|
||||||
OnDestroy,
|
OnDestroy,
|
||||||
OnInit,
|
OnInit,
|
||||||
|
signal,
|
||||||
} from '@angular/core'
|
} from '@angular/core'
|
||||||
import {
|
import {
|
||||||
FormsModule,
|
FormsModule,
|
||||||
@@ -63,7 +64,7 @@ export class DocumentLinkComponent
|
|||||||
|
|
||||||
documentsInput$ = new Subject<string>()
|
documentsInput$ = new Subject<string>()
|
||||||
foundDocuments$: Observable<Document[]>
|
foundDocuments$: Observable<Document[]>
|
||||||
loading = false
|
readonly loading = signal(false)
|
||||||
selectedDocuments: Document[] = []
|
selectedDocuments: Document[] = []
|
||||||
|
|
||||||
private unsubscribeNotifier: Subject<any> = new Subject()
|
private unsubscribeNotifier: Subject<any> = new Subject()
|
||||||
@@ -93,12 +94,12 @@ export class DocumentLinkComponent
|
|||||||
this.selectedDocuments = []
|
this.selectedDocuments = []
|
||||||
super.writeValue([])
|
super.writeValue([])
|
||||||
} else {
|
} else {
|
||||||
this.loading = true
|
this.loading.set(true)
|
||||||
this.documentsService
|
this.documentsService
|
||||||
.getFew(documentIDs, { fields: 'id,title' })
|
.getFew(documentIDs, { fields: 'id,title' })
|
||||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||||
.subscribe((documentResults) => {
|
.subscribe((documentResults) => {
|
||||||
this.loading = false
|
this.loading.set(false)
|
||||||
this.selectedDocuments = documentIDs.map(
|
this.selectedDocuments = documentIDs.map(
|
||||||
(id) => documentResults.results.find((d) => d.id === id) ?? {}
|
(id) => documentResults.results.find((d) => d.id === id) ?? {}
|
||||||
)
|
)
|
||||||
@@ -113,7 +114,7 @@ export class DocumentLinkComponent
|
|||||||
this.documentsInput$.pipe(
|
this.documentsInput$.pipe(
|
||||||
distinctUntilChanged(),
|
distinctUntilChanged(),
|
||||||
takeUntil(this.unsubscribeNotifier),
|
takeUntil(this.unsubscribeNotifier),
|
||||||
tap(() => (this.loading = true)),
|
tap(() => this.loading.set(true)),
|
||||||
switchMap((title) =>
|
switchMap((title) =>
|
||||||
this.documentsService
|
this.documentsService
|
||||||
.listFiltered(
|
.listFiltered(
|
||||||
@@ -133,7 +134,7 @@ export class DocumentLinkComponent
|
|||||||
)
|
)
|
||||||
),
|
),
|
||||||
catchError(() => of([])), // empty on error
|
catchError(() => of([])), // empty on error
|
||||||
tap(() => (this.loading = false))
|
tap(() => this.loading.set(false))
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ export class NumberComponent extends AbstractInputComponent<number> {
|
|||||||
this.documentService.getNextAsn().subscribe((nextAsn) => {
|
this.documentService.getNextAsn().subscribe((nextAsn) => {
|
||||||
this.value = nextAsn
|
this.value = nextAsn
|
||||||
this.onChange(this.value)
|
this.onChange(this.value)
|
||||||
|
this.changeDetector.markForCheck()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -157,8 +157,8 @@ export class TagsComponent implements OnInit, ControlValueAccessor {
|
|||||||
|
|
||||||
private removeChildren(tagIDs: number[], tag: Tag) {
|
private removeChildren(tagIDs: number[], tag: Tag) {
|
||||||
if (tag.children?.length) {
|
if (tag.children?.length) {
|
||||||
const childIDs = tag.children.map((child) => child.id)
|
const childIDs = new Set(tag.children.map((child) => child.id))
|
||||||
tagIDs = tagIDs.filter((id) => !childIDs.includes(id))
|
tagIDs = tagIDs.filter((id) => !childIDs.has(id))
|
||||||
for (const child of tag.children) {
|
for (const child of tag.children) {
|
||||||
tagIDs = this.removeChildren(tagIDs, child)
|
tagIDs = this.removeChildren(tagIDs, child)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import { environment } from 'src/environments/environment'
|
|||||||
})
|
})
|
||||||
export class PageHeaderComponent {
|
export class PageHeaderComponent {
|
||||||
private titleService = inject(Title)
|
private titleService = inject(Title)
|
||||||
private clipboard = inject(Clipboard)
|
private readonly clipboard = inject(Clipboard)
|
||||||
|
|
||||||
readonly id = input<number>(undefined)
|
readonly id = input<number>(undefined)
|
||||||
readonly subTitle = input('')
|
readonly subTitle = input('')
|
||||||
|
|||||||
@@ -49,8 +49,7 @@ describe('PngxPdfViewerComponent', () => {
|
|||||||
new URL('assets/js/pdf.worker.min.mjs', document.baseURI).toString()
|
new URL('assets/js/pdf.worker.min.mjs', document.baseURI).toString()
|
||||||
)
|
)
|
||||||
const isVisible = (component as any).findController.onIsPageVisible as
|
const isVisible = (component as any).findController.onIsPageVisible as
|
||||||
| (() => boolean)
|
(() => boolean) | undefined
|
||||||
| undefined
|
|
||||||
expect(isVisible?.()).toBe(true)
|
expect(isVisible?.()).toBe(true)
|
||||||
expect(loadSpy).toHaveBeenCalledWith(
|
expect(loadSpy).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({ numPages: 1 })
|
expect.objectContaining({ numPages: 1 })
|
||||||
@@ -61,6 +60,7 @@ describe('PngxPdfViewerComponent', () => {
|
|||||||
|
|
||||||
it('resolves the worker source relative to the document base URI', async () => {
|
it('resolves the worker source relative to the document base URI', async () => {
|
||||||
setBaseHref('/paperless/')
|
setBaseHref('/paperless/')
|
||||||
|
const getDocumentSpy = jest.spyOn(pdfjs, 'getDocument')
|
||||||
|
|
||||||
await initComponent()
|
await initComponent()
|
||||||
|
|
||||||
@@ -70,6 +70,13 @@ describe('PngxPdfViewerComponent', () => {
|
|||||||
expect(pdfjs.GlobalWorkerOptions.workerSrc).toContain(
|
expect(pdfjs.GlobalWorkerOptions.workerSrc).toContain(
|
||||||
'/paperless/assets/js/pdf.worker.min.mjs'
|
'/paperless/assets/js/pdf.worker.min.mjs'
|
||||||
)
|
)
|
||||||
|
expect(getDocumentSpy).toHaveBeenCalledWith({
|
||||||
|
url: 'test.pdf',
|
||||||
|
password: undefined,
|
||||||
|
withCredentials: true,
|
||||||
|
wasmUrl: expect.stringContaining('/paperless/assets/wasm/'),
|
||||||
|
iccUrl: expect.stringContaining('/paperless/assets/iccs/'),
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('initializes single-page viewer and disables text layer', async () => {
|
it('initializes single-page viewer and disables text layer', async () => {
|
||||||
|
|||||||
@@ -174,10 +174,12 @@ export class PngxPdfViewerComponent
|
|||||||
'assets/js/pdf.worker.min.mjs',
|
'assets/js/pdf.worker.min.mjs',
|
||||||
this.document.baseURI
|
this.document.baseURI
|
||||||
).toString()
|
).toString()
|
||||||
let initOptions = {
|
const initOptions = {
|
||||||
url: this.src,
|
url: this.src,
|
||||||
password: this.password,
|
password: this.password,
|
||||||
withCredentials: true,
|
withCredentials: true,
|
||||||
|
wasmUrl: new URL('assets/wasm/', this.document.baseURI).toString(),
|
||||||
|
iccUrl: new URL('assets/iccs/', this.document.baseURI).toString(),
|
||||||
}
|
}
|
||||||
this.loadingTask = getDocument(initOptions)
|
this.loadingTask = getDocument(initOptions)
|
||||||
try {
|
try {
|
||||||
|
|||||||
+1
-1
@@ -68,7 +68,7 @@
|
|||||||
[(ngModel)]="selectionModel.includeUsers"
|
[(ngModel)]="selectionModel.includeUsers"
|
||||||
[disabled]="disabled"
|
[disabled]="disabled"
|
||||||
[clearable]="false"
|
[clearable]="false"
|
||||||
[items]="users"
|
[items]="users()"
|
||||||
bindLabel="username"
|
bindLabel="username"
|
||||||
multiple="true"
|
multiple="true"
|
||||||
bindValue="id"
|
bindValue="id"
|
||||||
|
|||||||
+10
-3
@@ -1,5 +1,12 @@
|
|||||||
import { NgClass } from '@angular/common'
|
import { NgClass } from '@angular/common'
|
||||||
import { Component, EventEmitter, Input, Output, inject } from '@angular/core'
|
import {
|
||||||
|
Component,
|
||||||
|
EventEmitter,
|
||||||
|
Input,
|
||||||
|
Output,
|
||||||
|
inject,
|
||||||
|
signal,
|
||||||
|
} from '@angular/core'
|
||||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||||
import { NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap'
|
import { NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap'
|
||||||
import { NgSelectComponent } from '@ng-select/ng-select'
|
import { NgSelectComponent } from '@ng-select/ng-select'
|
||||||
@@ -75,7 +82,7 @@ export class PermissionsFilterDropdownComponent extends ComponentWithPermissions
|
|||||||
@Output()
|
@Output()
|
||||||
ownerFilterSet = new EventEmitter<PermissionsSelectionModel>()
|
ownerFilterSet = new EventEmitter<PermissionsSelectionModel>()
|
||||||
|
|
||||||
users: User[]
|
readonly users = signal<User[]>([])
|
||||||
|
|
||||||
hideUnowned: boolean
|
hideUnowned: boolean
|
||||||
|
|
||||||
@@ -102,7 +109,7 @@ export class PermissionsFilterDropdownComponent extends ComponentWithPermissions
|
|||||||
.listAll()
|
.listAll()
|
||||||
.pipe(first())
|
.pipe(first())
|
||||||
.subscribe({
|
.subscribe({
|
||||||
next: (result) => (this.users = result.results),
|
next: (result) => this.users.set(result.results),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+17
-17
@@ -57,15 +57,15 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-12 col-md-6">
|
<div class="col-12 col-md-6">
|
||||||
@if (socialAccounts?.length > 0) {
|
@if (socialAccounts().length > 0) {
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<p i18n>Connected social accounts</p>
|
<p i18n>Connected social accounts</p>
|
||||||
<ul class="list-group">
|
<ul class="list-group">
|
||||||
@for (account of socialAccounts; track account.id) {
|
@for (account of socialAccounts(); track account.id) {
|
||||||
<li class="list-group-item"
|
<li class="list-group-item"
|
||||||
ngbPopover="Set a password before disconnecting social account."
|
ngbPopover="Set a password before disconnecting social account."
|
||||||
i18n-ngbPopover
|
i18n-ngbPopover
|
||||||
[disablePopover]="hasUsablePassword"
|
[disablePopover]="hasUsablePassword()"
|
||||||
triggers="mouseenter:mouseleave">
|
triggers="mouseenter:mouseleave">
|
||||||
{{account.name}} ({{account.provider}})
|
{{account.name}} ({{account.provider}})
|
||||||
<pngx-confirm-button
|
<pngx-confirm-button
|
||||||
@@ -75,7 +75,7 @@
|
|||||||
i18n-title
|
i18n-title
|
||||||
buttonClasses="btn-outline-danger btn-sm ms-2 align-baseline"
|
buttonClasses="btn-outline-danger btn-sm ms-2 align-baseline"
|
||||||
iconName="trash"
|
iconName="trash"
|
||||||
[disabled]="!hasUsablePassword"
|
[disabled]="!hasUsablePassword()"
|
||||||
(confirm)="disconnectSocialAccount(account.id)">
|
(confirm)="disconnectSocialAccount(account.id)">
|
||||||
</pngx-confirm-button>
|
</pngx-confirm-button>
|
||||||
</li>
|
</li>
|
||||||
@@ -84,11 +84,11 @@
|
|||||||
<div class="form-text text-muted text-end fst-italic" i18n>Warning: disconnecting social accounts cannot be undone</div>
|
<div class="form-text text-muted text-end fst-italic" i18n>Warning: disconnecting social accounts cannot be undone</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@if (socialAccountProviders?.length > 0) {
|
@if (socialAccountProviders().length > 0) {
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<p i18n>Connect new social account</p>
|
<p i18n>Connect new social account</p>
|
||||||
<div class="list-group">
|
<div class="list-group">
|
||||||
@for (provider of socialAccountProviders; track provider.name) {
|
@for (provider of socialAccountProviders(); track provider.name) {
|
||||||
<a class="list-group-item list-group-item-action text-primary d-flex align-items-center" href="{{ provider.login_url }}" rel="noopener noreferrer">
|
<a class="list-group-item list-group-item-action text-primary d-flex align-items-center" href="{{ provider.login_url }}" rel="noopener noreferrer">
|
||||||
{{provider.name}}<i-bs class="pb-1 ms-2" name="box-arrow-up-right"></i-bs>
|
{{provider.name}}<i-bs class="pb-1 ms-2" name="box-arrow-up-right"></i-bs>
|
||||||
</a>
|
</a>
|
||||||
@@ -96,7 +96,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@if (!isTotpEnabled) {
|
@if (!isTotpEnabled()) {
|
||||||
<div ngbAccordion>
|
<div ngbAccordion>
|
||||||
<div ngbAccordionItem>
|
<div ngbAccordionItem>
|
||||||
<h2 ngbAccordionHeader>
|
<h2 ngbAccordionHeader>
|
||||||
@@ -105,10 +105,10 @@
|
|||||||
<div ngbAccordionCollapse>
|
<div ngbAccordionCollapse>
|
||||||
<div ngbAccordionBody>
|
<div ngbAccordionBody>
|
||||||
<ng-template>
|
<ng-template>
|
||||||
@if (totpSettingsLoading) {
|
@if (totpSettingsLoading()) {
|
||||||
<div class="spinner-border spinner-border-sm fw-normal ms-2 me-auto" role="status"></div>
|
<div class="spinner-border spinner-border-sm fw-normal ms-2 me-auto" role="status"></div>
|
||||||
<div class="visually-hidden" i18n>Loading...</div>
|
<div class="visually-hidden" i18n>Loading...</div>
|
||||||
} @else if (totpSettings) {
|
} @else if (totpSettings()) {
|
||||||
<figure class="figure">
|
<figure class="figure">
|
||||||
@if (qrSvgDataUrl) {
|
@if (qrSvgDataUrl) {
|
||||||
<img class="bg-white d-inline-block" [src]="qrSvgDataUrl" alt="Authenticator QR code">
|
<img class="bg-white d-inline-block" [src]="qrSvgDataUrl" alt="Authenticator QR code">
|
||||||
@@ -116,14 +116,14 @@
|
|||||||
<figcaption class="figure-caption text-end mt-2" i18n>Scan the QR code with your authenticator app and then enter the code below</figcaption>
|
<figcaption class="figure-caption text-end mt-2" i18n>Scan the QR code with your authenticator app and then enter the code below</figcaption>
|
||||||
</figure>
|
</figure>
|
||||||
<p>
|
<p>
|
||||||
<ng-container i18n>Authenticator secret</ng-container>: <code>{{totpSettings.secret}}</code>.
|
<ng-container i18n>Authenticator secret</ng-container>: <code>{{totpSettings().secret}}</code>.
|
||||||
<ng-container i18n>You can store this secret and use it to reinstall your authenticator app at a later time.</ng-container>
|
<ng-container i18n>You can store this secret and use it to reinstall your authenticator app at a later time.</ng-container>
|
||||||
</p>
|
</p>
|
||||||
<div class="input-group mb-3">
|
<div class="input-group mb-3">
|
||||||
<input type="text" class="form-control" formControlName="totp_code" placeholder="Code" i18n-placeholder>
|
<input type="text" class="form-control" formControlName="totp_code" placeholder="Code" i18n-placeholder>
|
||||||
<button type="button" class="btn btn-primary ml-auto" (click)="activateTotp()" [disabled]="totpLoading">
|
<button type="button" class="btn btn-primary ml-auto" (click)="activateTotp()" [disabled]="totpLoading()">
|
||||||
<ng-container i18n>Enable</ng-container>
|
<ng-container i18n>Enable</ng-container>
|
||||||
@if (totpLoading) {
|
@if (totpLoading()) {
|
||||||
<div class="spinner-border spinner-border-sm fw-normal ms-2" role="status"></div>
|
<div class="spinner-border spinner-border-sm fw-normal ms-2" role="status"></div>
|
||||||
<div class="visually-hidden" i18n>Loading...</div>
|
<div class="visually-hidden" i18n>Loading...</div>
|
||||||
}
|
}
|
||||||
@@ -137,18 +137,18 @@
|
|||||||
</div>
|
</div>
|
||||||
} @else {
|
} @else {
|
||||||
<label class="d-block mb-2" i18n>Two-factor Authentication</label>
|
<label class="d-block mb-2" i18n>Two-factor Authentication</label>
|
||||||
@if (recoveryCodes) {
|
@if (recoveryCodes()) {
|
||||||
<div class="alert alert-warning" role="alert">
|
<div class="alert alert-warning" role="alert">
|
||||||
<i-bs name="exclamation-triangle" class="me-1"></i-bs><ng-container i18n>Recovery codes will not be shown again, make sure to save them.</ng-container>
|
<i-bs name="exclamation-triangle" class="me-1"></i-bs><ng-container i18n>Recovery codes will not be shown again, make sure to save them.</ng-container>
|
||||||
</div>
|
</div>
|
||||||
<div class="d-flex flex-row align-items-start mb-3">
|
<div class="d-flex flex-row align-items-start mb-3">
|
||||||
<ul class="list-group w-50">
|
<ul class="list-group w-50">
|
||||||
@for (code of recoveryCodes; track code; let i = $index) {
|
@for (code of recoveryCodes(); track code; let i = $index) {
|
||||||
@if (i % 2 === 0) {
|
@if (i % 2 === 0) {
|
||||||
<li class="list-group-item d-flex justify-content-around align-items-center">
|
<li class="list-group-item d-flex justify-content-around align-items-center">
|
||||||
<code>{{code}}</code>
|
<code>{{code}}</code>
|
||||||
@if (recoveryCodes[i + 1]) {
|
@if (recoveryCodes()[i + 1]) {
|
||||||
<code>{{recoveryCodes[i + 1]}}</code>
|
<code>{{recoveryCodes()[i + 1]}}</code>
|
||||||
}
|
}
|
||||||
</li>
|
</li>
|
||||||
}
|
}
|
||||||
@@ -171,7 +171,7 @@
|
|||||||
i18n-title
|
i18n-title
|
||||||
buttonClasses="btn-outline-danger btn-sm"
|
buttonClasses="btn-outline-danger btn-sm"
|
||||||
iconName="trash"
|
iconName="trash"
|
||||||
[disabled]="totpLoading"
|
[disabled]="totpLoading()"
|
||||||
(confirm)="deactivateTotp()">
|
(confirm)="deactivateTotp()">
|
||||||
</pngx-confirm-button>
|
</pngx-confirm-button>
|
||||||
}
|
}
|
||||||
|
|||||||
+37
-12
@@ -10,7 +10,7 @@ import {
|
|||||||
NgbPopoverModule,
|
NgbPopoverModule,
|
||||||
} from '@ng-bootstrap/ng-bootstrap'
|
} from '@ng-bootstrap/ng-bootstrap'
|
||||||
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
||||||
import { of, throwError } from 'rxjs'
|
import { NEVER, of, Subject, throwError } from 'rxjs'
|
||||||
import { ProfileService } from 'src/app/services/profile.service'
|
import { ProfileService } from 'src/app/services/profile.service'
|
||||||
import { ToastService } from 'src/app/services/toast.service'
|
import { ToastService } from 'src/app/services/toast.service'
|
||||||
import * as navUtils from 'src/app/utils/navigation'
|
import * as navUtils from 'src/app/utils/navigation'
|
||||||
@@ -60,6 +60,10 @@ describe('ProfileEditDialogComponent', () => {
|
|||||||
providers: [NgbActiveModal, provideHttpClient(withInterceptorsFromDi())],
|
providers: [NgbActiveModal, provideHttpClient(withInterceptorsFromDi())],
|
||||||
})
|
})
|
||||||
profileService = TestBed.inject(ProfileService)
|
profileService = TestBed.inject(ProfileService)
|
||||||
|
jest.spyOn(profileService, 'get').mockReturnValue(NEVER)
|
||||||
|
jest
|
||||||
|
.spyOn(profileService, 'getSocialAccountProviders')
|
||||||
|
.mockReturnValue(of([]))
|
||||||
toastService = TestBed.inject(ToastService)
|
toastService = TestBed.inject(ToastService)
|
||||||
clipboard = TestBed.inject(Clipboard)
|
clipboard = TestBed.inject(Clipboard)
|
||||||
fixture = TestBed.createComponent(ProfileEditDialogComponent)
|
fixture = TestBed.createComponent(ProfileEditDialogComponent)
|
||||||
@@ -155,7 +159,7 @@ describe('ProfileEditDialogComponent', () => {
|
|||||||
'getSocialAccountProviders'
|
'getSocialAccountProviders'
|
||||||
)
|
)
|
||||||
getProvidersSpy.mockReturnValue(of(socialAccountProviders))
|
getProvidersSpy.mockReturnValue(of(socialAccountProviders))
|
||||||
component.hasUsablePassword = true
|
component.hasUsablePassword.set(true)
|
||||||
component.ngOnInit()
|
component.ngOnInit()
|
||||||
component.form.get('password').patchValue('new*pass')
|
component.form.get('password').patchValue('new*pass')
|
||||||
component.onPasswordKeyUp({
|
component.onPasswordKeyUp({
|
||||||
@@ -268,6 +272,27 @@ describe('ProfileEditDialogComponent', () => {
|
|||||||
expect(getProvidersSpy).toHaveBeenCalled()
|
expect(getProvidersSpy).toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should render social account providers after an async update', async () => {
|
||||||
|
const providers$ = new Subject<typeof socialAccountProviders>()
|
||||||
|
jest.spyOn(profileService, 'get').mockReturnValue(of(profile))
|
||||||
|
jest
|
||||||
|
.spyOn(profileService, 'getSocialAccountProviders')
|
||||||
|
.mockReturnValue(providers$)
|
||||||
|
|
||||||
|
component.ngOnInit()
|
||||||
|
await fixture.whenStable()
|
||||||
|
expect(
|
||||||
|
fixture.nativeElement.querySelector('a[href="https://example.com"]')
|
||||||
|
).toBeNull()
|
||||||
|
|
||||||
|
providers$.next(socialAccountProviders)
|
||||||
|
await fixture.whenStable()
|
||||||
|
|
||||||
|
expect(
|
||||||
|
fixture.nativeElement.querySelector('a[href="https://example.com"]')
|
||||||
|
).not.toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
it('should remove disconnected social account from component, show error if needed', () => {
|
it('should remove disconnected social account from component, show error if needed', () => {
|
||||||
const disconnectSpy = jest.spyOn(profileService, 'disconnectSocialAccount')
|
const disconnectSpy = jest.spyOn(profileService, 'disconnectSocialAccount')
|
||||||
const getSpy = jest.spyOn(profileService, 'get')
|
const getSpy = jest.spyOn(profileService, 'get')
|
||||||
@@ -276,7 +301,7 @@ describe('ProfileEditDialogComponent', () => {
|
|||||||
|
|
||||||
const errorSpy = jest.spyOn(toastService, 'showError')
|
const errorSpy = jest.spyOn(toastService, 'showError')
|
||||||
|
|
||||||
expect(component.socialAccounts).toContainEqual(socialAccount)
|
expect(component.socialAccounts()).toContainEqual(socialAccount)
|
||||||
|
|
||||||
// fail first
|
// fail first
|
||||||
disconnectSpy.mockReturnValueOnce(
|
disconnectSpy.mockReturnValueOnce(
|
||||||
@@ -289,7 +314,7 @@ describe('ProfileEditDialogComponent', () => {
|
|||||||
disconnectSpy.mockReturnValue(of(socialAccount.id))
|
disconnectSpy.mockReturnValue(of(socialAccount.id))
|
||||||
component.disconnectSocialAccount(socialAccount.id)
|
component.disconnectSocialAccount(socialAccount.id)
|
||||||
expect(disconnectSpy).toHaveBeenCalled()
|
expect(disconnectSpy).toHaveBeenCalled()
|
||||||
expect(component.socialAccounts).not.toContainEqual(socialAccount)
|
expect(component.socialAccounts()).not.toContainEqual(socialAccount)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should get totp settings', () => {
|
it('should get totp settings', () => {
|
||||||
@@ -310,7 +335,7 @@ describe('ProfileEditDialogComponent', () => {
|
|||||||
getSpy.mockReturnValue(of(settings))
|
getSpy.mockReturnValue(of(settings))
|
||||||
component.gettotpSettings()
|
component.gettotpSettings()
|
||||||
expect(getSpy).toHaveBeenCalled()
|
expect(getSpy).toHaveBeenCalled()
|
||||||
expect(component.totpSettings).toEqual(settings)
|
expect(component.totpSettings()).toEqual(settings)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should activate totp', () => {
|
it('should activate totp', () => {
|
||||||
@@ -319,15 +344,15 @@ describe('ProfileEditDialogComponent', () => {
|
|||||||
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
|
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
|
||||||
const error = new Error('failed to activate totp')
|
const error = new Error('failed to activate totp')
|
||||||
activateSpy.mockReturnValueOnce(throwError(() => error))
|
activateSpy.mockReturnValueOnce(throwError(() => error))
|
||||||
component.totpSettings = {
|
component.totpSettings.set({
|
||||||
url: 'http://localhost/',
|
url: 'http://localhost/',
|
||||||
qr_svg: 'svg',
|
qr_svg: 'svg',
|
||||||
secret: 'secret',
|
secret: 'secret',
|
||||||
}
|
})
|
||||||
component.form.get('totp_code').patchValue('123456')
|
component.form.get('totp_code').patchValue('123456')
|
||||||
component.activateTotp()
|
component.activateTotp()
|
||||||
expect(activateSpy).toHaveBeenCalledWith(
|
expect(activateSpy).toHaveBeenCalledWith(
|
||||||
component.totpSettings.secret,
|
component.totpSettings().secret,
|
||||||
component.form.get('totp_code').value
|
component.form.get('totp_code').value
|
||||||
)
|
)
|
||||||
expect(toastErrorSpy).toHaveBeenCalled()
|
expect(toastErrorSpy).toHaveBeenCalled()
|
||||||
@@ -341,8 +366,8 @@ describe('ProfileEditDialogComponent', () => {
|
|||||||
)
|
)
|
||||||
component.activateTotp()
|
component.activateTotp()
|
||||||
expect(toastInfoSpy).toHaveBeenCalled()
|
expect(toastInfoSpy).toHaveBeenCalled()
|
||||||
expect(component.isTotpEnabled).toBeTruthy()
|
expect(component.isTotpEnabled()).toBeTruthy()
|
||||||
expect(component.recoveryCodes).toEqual(['1', '2', '3'])
|
expect(component.recoveryCodes()).toEqual(['1', '2', '3'])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should deactivate totp', () => {
|
it('should deactivate totp', () => {
|
||||||
@@ -362,13 +387,13 @@ describe('ProfileEditDialogComponent', () => {
|
|||||||
deactivateSpy.mockReturnValueOnce(of(true))
|
deactivateSpy.mockReturnValueOnce(of(true))
|
||||||
component.deactivateTotp()
|
component.deactivateTotp()
|
||||||
expect(toastInfoSpy).toHaveBeenCalled()
|
expect(toastInfoSpy).toHaveBeenCalled()
|
||||||
expect(component.isTotpEnabled).toBeFalsy()
|
expect(component.isTotpEnabled()).toBeFalsy()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should copy recovery codes', () => {
|
it('should copy recovery codes', () => {
|
||||||
jest.useFakeTimers()
|
jest.useFakeTimers()
|
||||||
const copySpy = jest.spyOn(clipboard, 'copy')
|
const copySpy = jest.spyOn(clipboard, 'copy')
|
||||||
component.recoveryCodes = ['1', '2', '3']
|
component.recoveryCodes.set(['1', '2', '3'])
|
||||||
component.copyRecoveryCodes()
|
component.copyRecoveryCodes()
|
||||||
expect(copySpy).toHaveBeenCalledWith('1\n2\n3')
|
expect(copySpy).toHaveBeenCalledWith('1\n2\n3')
|
||||||
jest.advanceTimersByTime(3000)
|
jest.advanceTimersByTime(3000)
|
||||||
|
|||||||
+36
-33
@@ -56,6 +56,14 @@ export class ProfileEditDialogComponent
|
|||||||
readonly showEmailConfirm = signal(false)
|
readonly showEmailConfirm = signal(false)
|
||||||
readonly copied = signal(false)
|
readonly copied = signal(false)
|
||||||
readonly codesCopied = signal(false)
|
readonly codesCopied = signal(false)
|
||||||
|
readonly hasUsablePassword = signal(false)
|
||||||
|
readonly isTotpEnabled = signal(false)
|
||||||
|
readonly totpSettings = signal<TotpSettings>(undefined)
|
||||||
|
readonly totpSettingsLoading = signal(false)
|
||||||
|
readonly totpLoading = signal(false)
|
||||||
|
readonly recoveryCodes = signal<string[]>(undefined)
|
||||||
|
readonly socialAccounts = signal<SocialAccount[]>([])
|
||||||
|
readonly socialAccountProviders = signal<SocialAccountProvider[]>([])
|
||||||
|
|
||||||
public form = new FormGroup({
|
public form = new FormGroup({
|
||||||
email: new FormControl(''),
|
email: new FormControl(''),
|
||||||
@@ -72,25 +80,15 @@ export class ProfileEditDialogComponent
|
|||||||
private newPassword: string
|
private newPassword: string
|
||||||
private passwordConfirm: string
|
private passwordConfirm: string
|
||||||
|
|
||||||
public hasUsablePassword: boolean = false
|
|
||||||
|
|
||||||
private currentEmail: string
|
private currentEmail: string
|
||||||
private newEmail: string
|
private newEmail: string
|
||||||
private emailConfirm: string
|
private emailConfirm: string
|
||||||
|
|
||||||
public isTotpEnabled: boolean = false
|
|
||||||
public totpSettings: TotpSettings
|
|
||||||
public totpSettingsLoading: boolean = false
|
|
||||||
public totpLoading: boolean = false
|
|
||||||
public recoveryCodes: string[]
|
|
||||||
public socialAccounts: SocialAccount[] = []
|
|
||||||
public socialAccountProviders: SocialAccountProvider[] = []
|
|
||||||
|
|
||||||
get qrSvgDataUrl(): string | null {
|
get qrSvgDataUrl(): string | null {
|
||||||
if (!this.totpSettings?.qr_svg) {
|
if (!this.totpSettings()?.qr_svg) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
return `data:image/svg+xml;utf8,${encodeURIComponent(this.totpSettings.qr_svg)}`
|
return `data:image/svg+xml;utf8,${encodeURIComponent(this.totpSettings().qr_svg)}`
|
||||||
}
|
}
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
@@ -107,20 +105,20 @@ export class ProfileEditDialogComponent
|
|||||||
this.onEmailChange()
|
this.onEmailChange()
|
||||||
})
|
})
|
||||||
this.currentPassword = profile.password
|
this.currentPassword = profile.password
|
||||||
this.hasUsablePassword = profile.has_usable_password
|
this.hasUsablePassword.set(profile.has_usable_password)
|
||||||
this.form.get('password').valueChanges.subscribe((newPassword) => {
|
this.form.get('password').valueChanges.subscribe((newPassword) => {
|
||||||
this.newPassword = newPassword
|
this.newPassword = newPassword
|
||||||
this.onPasswordChange()
|
this.onPasswordChange()
|
||||||
})
|
})
|
||||||
this.socialAccounts = profile.social_accounts
|
this.socialAccounts.set(profile.social_accounts ?? [])
|
||||||
this.isTotpEnabled = profile.is_mfa_enabled
|
this.isTotpEnabled.set(profile.is_mfa_enabled)
|
||||||
})
|
})
|
||||||
|
|
||||||
this.profileService
|
this.profileService
|
||||||
.getSocialAccountProviders()
|
.getSocialAccountProviders()
|
||||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||||
.subscribe((providers) => {
|
.subscribe((providers) => {
|
||||||
this.socialAccountProviders = providers
|
this.socialAccountProviders.set(providers ?? [])
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -259,7 +257,9 @@ export class ProfileEditDialogComponent
|
|||||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||||
.subscribe({
|
.subscribe({
|
||||||
next: (id: number) => {
|
next: (id: number) => {
|
||||||
this.socialAccounts = this.socialAccounts.filter((a) => a.id != id)
|
this.socialAccounts.update((accounts) =>
|
||||||
|
accounts.filter((account) => account.id != id)
|
||||||
|
)
|
||||||
},
|
},
|
||||||
error: (error) => {
|
error: (error) => {
|
||||||
this.toastService.showError(
|
this.toastService.showError(
|
||||||
@@ -271,36 +271,39 @@ export class ProfileEditDialogComponent
|
|||||||
}
|
}
|
||||||
|
|
||||||
public gettotpSettings(): void {
|
public gettotpSettings(): void {
|
||||||
this.totpSettingsLoading = true
|
this.totpSettingsLoading.set(true)
|
||||||
this.profileService
|
this.profileService
|
||||||
.getTotpSettings()
|
.getTotpSettings()
|
||||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||||
.subscribe({
|
.subscribe({
|
||||||
next: (totpSettings) => {
|
next: (totpSettings) => {
|
||||||
this.totpSettingsLoading = false
|
this.totpSettingsLoading.set(false)
|
||||||
this.totpSettings = totpSettings
|
this.totpSettings.set(totpSettings)
|
||||||
},
|
},
|
||||||
error: (error) => {
|
error: (error) => {
|
||||||
this.toastService.showError(
|
this.toastService.showError(
|
||||||
$localize`Error fetching TOTP settings`,
|
$localize`Error fetching TOTP settings`,
|
||||||
error
|
error
|
||||||
)
|
)
|
||||||
this.totpSettingsLoading = false
|
this.totpSettingsLoading.set(false)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
public activateTotp(): void {
|
public activateTotp(): void {
|
||||||
this.totpLoading = true
|
this.totpLoading.set(true)
|
||||||
this.form.get('totp_code').disable()
|
this.form.get('totp_code').disable()
|
||||||
this.profileService
|
this.profileService
|
||||||
.activateTotp(this.totpSettings.secret, this.form.get('totp_code').value)
|
.activateTotp(
|
||||||
|
this.totpSettings().secret,
|
||||||
|
this.form.get('totp_code').value
|
||||||
|
)
|
||||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||||
.subscribe({
|
.subscribe({
|
||||||
next: (activationResponse) => {
|
next: (activationResponse) => {
|
||||||
this.totpLoading = false
|
this.totpLoading.set(false)
|
||||||
this.isTotpEnabled = activationResponse.success
|
this.isTotpEnabled.set(activationResponse.success)
|
||||||
this.recoveryCodes = activationResponse.recovery_codes
|
this.recoveryCodes.set(activationResponse.recovery_codes)
|
||||||
this.form.get('totp_code').enable()
|
this.form.get('totp_code').enable()
|
||||||
if (activationResponse.success) {
|
if (activationResponse.success) {
|
||||||
this.toastService.showInfo($localize`TOTP activated successfully`)
|
this.toastService.showInfo($localize`TOTP activated successfully`)
|
||||||
@@ -309,7 +312,7 @@ export class ProfileEditDialogComponent
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
error: (error) => {
|
error: (error) => {
|
||||||
this.totpLoading = false
|
this.totpLoading.set(false)
|
||||||
this.form.get('totp_code').enable()
|
this.form.get('totp_code').enable()
|
||||||
this.toastService.showError($localize`Error activating TOTP`, error)
|
this.toastService.showError($localize`Error activating TOTP`, error)
|
||||||
},
|
},
|
||||||
@@ -317,15 +320,15 @@ export class ProfileEditDialogComponent
|
|||||||
}
|
}
|
||||||
|
|
||||||
public deactivateTotp(): void {
|
public deactivateTotp(): void {
|
||||||
this.totpLoading = true
|
this.totpLoading.set(true)
|
||||||
this.profileService
|
this.profileService
|
||||||
.deactivateTotp()
|
.deactivateTotp()
|
||||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||||
.subscribe({
|
.subscribe({
|
||||||
next: (success) => {
|
next: (success) => {
|
||||||
this.totpLoading = false
|
this.totpLoading.set(false)
|
||||||
this.isTotpEnabled = !success
|
this.isTotpEnabled.set(!success)
|
||||||
this.recoveryCodes = null
|
this.recoveryCodes.set(null)
|
||||||
if (success) {
|
if (success) {
|
||||||
this.toastService.showInfo($localize`TOTP deactivated successfully`)
|
this.toastService.showInfo($localize`TOTP deactivated successfully`)
|
||||||
} else {
|
} else {
|
||||||
@@ -333,14 +336,14 @@ export class ProfileEditDialogComponent
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
error: (error) => {
|
error: (error) => {
|
||||||
this.totpLoading = false
|
this.totpLoading.set(false)
|
||||||
this.toastService.showError($localize`Error deactivating TOTP`, error)
|
this.toastService.showError($localize`Error deactivating TOTP`, error)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
public copyRecoveryCodes(): void {
|
public copyRecoveryCodes(): void {
|
||||||
this.clipboard.copy(this.recoveryCodes.join('\n'))
|
this.clipboard.copy(this.recoveryCodes().join('\n'))
|
||||||
this.codesCopied.set(true)
|
this.codesCopied.set(true)
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
this.codesCopied.set(false)
|
this.codesCopied.set(false)
|
||||||
|
|||||||
+2
-1
@@ -65,8 +65,9 @@
|
|||||||
<dd class="col-sm-8"><code>{{ createdBundle.slug }}</code></dd>
|
<dd class="col-sm-8"><code>{{ createdBundle.slug }}</code></dd>
|
||||||
<dt class="col-sm-4" i18n>Link</dt>
|
<dt class="col-sm-4" i18n>Link</dt>
|
||||||
<dd class="col-sm-8">
|
<dd class="col-sm-8">
|
||||||
|
<label class="visually-hidden" for="shareBundleLink" i18n>Share link</label>
|
||||||
<div class="input-group input-group-sm">
|
<div class="input-group input-group-sm">
|
||||||
<input class="form-control" type="text" [value]="getShareUrl(createdBundle)" readonly>
|
<input id="shareBundleLink" class="form-control" type="text" [value]="getShareUrl(createdBundle)" readonly>
|
||||||
<button
|
<button
|
||||||
class="btn btn-outline-primary"
|
class="btn btn-outline-primary"
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
+3
-1
@@ -14,7 +14,9 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
<div content class="wrapper fade" [class.show]="show()">
|
<div content class="wrapper fade" [class.show]="show()">
|
||||||
@if (displayMode() === DisplayMode.TABLE) {
|
@if (error()) {
|
||||||
|
<div class="alert alert-danger mb-0" role="alert"><ng-container i18n>Error while loading documents</ng-container>: {{error()}}</div>
|
||||||
|
} @else if (displayMode() === DisplayMode.TABLE) {
|
||||||
<table class="table table-hover mb-0 mt-n2 align-middle">
|
<table class="table table-hover mb-0 mt-n2 align-middle">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
|
|||||||
+27
-2
@@ -1,6 +1,10 @@
|
|||||||
import { DragDropModule } from '@angular/cdk/drag-drop'
|
import { DragDropModule } from '@angular/cdk/drag-drop'
|
||||||
import { DatePipe } from '@angular/common'
|
import { DatePipe } from '@angular/common'
|
||||||
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
|
import {
|
||||||
|
HttpErrorResponse,
|
||||||
|
provideHttpClient,
|
||||||
|
withInterceptorsFromDi,
|
||||||
|
} from '@angular/common/http'
|
||||||
import { provideHttpClientTesting } from '@angular/common/http/testing'
|
import { provideHttpClientTesting } from '@angular/common/http/testing'
|
||||||
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||||
import { By } from '@angular/platform-browser'
|
import { By } from '@angular/platform-browser'
|
||||||
@@ -8,7 +12,7 @@ import { Router } from '@angular/router'
|
|||||||
import { RouterTestingModule } from '@angular/router/testing'
|
import { RouterTestingModule } from '@angular/router/testing'
|
||||||
import { NgbModule } from '@ng-bootstrap/ng-bootstrap'
|
import { NgbModule } from '@ng-bootstrap/ng-bootstrap'
|
||||||
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
||||||
import { Subject, of } from 'rxjs'
|
import { Subject, of, throwError } from 'rxjs'
|
||||||
import { routes } from 'src/app/app-routing.module'
|
import { routes } from 'src/app/app-routing.module'
|
||||||
import { CustomFieldDisplayComponent } from 'src/app/components/common/custom-field-display/custom-field-display.component'
|
import { CustomFieldDisplayComponent } from 'src/app/components/common/custom-field-display/custom-field-display.component'
|
||||||
import { PreviewPopupComponent } from 'src/app/components/common/preview-popup/preview-popup.component'
|
import { PreviewPopupComponent } from 'src/app/components/common/preview-popup/preview-popup.component'
|
||||||
@@ -230,6 +234,27 @@ describe('SavedViewWidgetComponent', () => {
|
|||||||
expect(component.documents()).toEqual(documentResults)
|
expect(component.documents()).toEqual(documentResults)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should show an error if documents fail to load', () => {
|
||||||
|
jest.spyOn(documentService, 'listFiltered').mockReturnValue(
|
||||||
|
throwError(
|
||||||
|
() =>
|
||||||
|
new HttpErrorResponse({
|
||||||
|
error: { added__date__lte: ['Enter a valid date.'] },
|
||||||
|
status: 400,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
component.reload()
|
||||||
|
fixture.detectChanges()
|
||||||
|
|
||||||
|
expect(component.loading()).toBe(false)
|
||||||
|
expect(component.error()).toEqual('Added: Enter a valid date.')
|
||||||
|
expect(fixture.debugElement.nativeElement.textContent).toContain(
|
||||||
|
'Error while loading documents: Added: Enter a valid date.'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
it('should reload on document consumption finished', () => {
|
it('should reload on document consumption finished', () => {
|
||||||
const fileStatusSubject = new Subject<FileStatus>()
|
const fileStatusSubject = new Subject<FileStatus>()
|
||||||
jest
|
jest
|
||||||
|
|||||||
+37
-6
@@ -125,6 +125,8 @@ export class SavedViewWidgetComponent
|
|||||||
|
|
||||||
readonly count = signal<number>(null)
|
readonly count = signal<number>(null)
|
||||||
|
|
||||||
|
readonly error = signal<string | null>(null)
|
||||||
|
|
||||||
placeholderRows: number[] = []
|
placeholderRows: number[] = []
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
@@ -180,6 +182,7 @@ export class SavedViewWidgetComponent
|
|||||||
|
|
||||||
reload() {
|
reload() {
|
||||||
this.loading.set(this.documents().length == 0)
|
this.loading.set(this.documents().length == 0)
|
||||||
|
this.error.set(null)
|
||||||
this.show.set(true)
|
this.show.set(true)
|
||||||
this.documentService
|
this.documentService
|
||||||
.listFiltered(
|
.listFiltered(
|
||||||
@@ -191,12 +194,40 @@ export class SavedViewWidgetComponent
|
|||||||
{ truncate_content: true }
|
{ truncate_content: true }
|
||||||
)
|
)
|
||||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||||
.subscribe((result) => {
|
.subscribe({
|
||||||
this.documents.set(result.results)
|
next: (result) => {
|
||||||
this.count.set(result.count)
|
this.documents.set(result.results)
|
||||||
this.savedViewService.setDocumentCount(this.savedView, result.count)
|
this.count.set(result.count)
|
||||||
this.loading.set(false)
|
this.savedViewService.setDocumentCount(this.savedView, result.count)
|
||||||
this.show.set(true)
|
this.loading.set(false)
|
||||||
|
this.show.set(true)
|
||||||
|
},
|
||||||
|
error: (error) => {
|
||||||
|
this.documents.set([])
|
||||||
|
this.count.set(null)
|
||||||
|
let errorMessage
|
||||||
|
if (
|
||||||
|
typeof error.error === 'object' &&
|
||||||
|
Object.keys(error.error).length > 0
|
||||||
|
) {
|
||||||
|
errorMessage = Object.keys(error.error)
|
||||||
|
.map((fieldName) => {
|
||||||
|
const fieldNameBase = fieldName.split('__')[0]
|
||||||
|
const fieldError: Array<string> = error.error[fieldName]
|
||||||
|
return `${
|
||||||
|
this.documentService.sortFields.find(
|
||||||
|
(f) => f.field?.split('__')[0] == fieldNameBase
|
||||||
|
)?.name ?? fieldNameBase
|
||||||
|
}: ${fieldError[0]}`
|
||||||
|
})
|
||||||
|
.join(', ')
|
||||||
|
} else {
|
||||||
|
errorMessage = error.error
|
||||||
|
}
|
||||||
|
this.error.set(errorMessage)
|
||||||
|
this.loading.set(false)
|
||||||
|
this.show.set(true)
|
||||||
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
<p i18n>Searching document with asn {{asn}}</p>
|
<p i18n>Searching document with asn {{asn()}}</p>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Component, OnInit, inject } from '@angular/core'
|
import { Component, OnInit, inject, signal } from '@angular/core'
|
||||||
import { ActivatedRoute, Router } from '@angular/router'
|
import { ActivatedRoute, Router } from '@angular/router'
|
||||||
import { FILTER_ASN } from '../../data/filter-rule-type'
|
import { FILTER_ASN } from '../../data/filter-rule-type'
|
||||||
import { DocumentService } from '../../services/rest/document.service'
|
import { DocumentService } from '../../services/rest/document.service'
|
||||||
@@ -13,13 +13,13 @@ export class DocumentAsnComponent implements OnInit {
|
|||||||
private route = inject(ActivatedRoute)
|
private route = inject(ActivatedRoute)
|
||||||
private router = inject(Router)
|
private router = inject(Router)
|
||||||
|
|
||||||
asn: string
|
readonly asn = signal<string>(undefined)
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
this.route.paramMap.subscribe((paramMap) => {
|
this.route.paramMap.subscribe((paramMap) => {
|
||||||
this.asn = paramMap.get('id')
|
this.asn.set(paramMap.get('id'))
|
||||||
this.documentsService
|
this.documentsService
|
||||||
.listAllFilteredIds([{ rule_type: FILTER_ASN, value: this.asn }])
|
.listAllFilteredIds([{ rule_type: FILTER_ASN, value: this.asn() }])
|
||||||
.subscribe((documentId) => {
|
.subscribe((documentId) => {
|
||||||
if (documentId.length == 1) {
|
if (documentId.length == 1) {
|
||||||
this.router.navigate(['documents', documentId[0]])
|
this.router.navigate(['documents', documentId[0]])
|
||||||
|
|||||||
@@ -315,7 +315,7 @@
|
|||||||
<td>{{metadata()?.original_filename}}</td>
|
<td>{{metadata()?.original_filename}}</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td i18n>Original MD5 checksum</td>
|
<td i18n>Original SHA256 checksum</td>
|
||||||
<td>{{metadata()?.original_checksum}}</td>
|
<td>{{metadata()?.original_checksum}}</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
@@ -328,7 +328,7 @@
|
|||||||
</tr>
|
</tr>
|
||||||
@if (metadata()?.has_archive_version) {
|
@if (metadata()?.has_archive_version) {
|
||||||
<tr>
|
<tr>
|
||||||
<td i18n>Archive MD5 checksum</td>
|
<td i18n>Archive SHA256 checksum</td>
|
||||||
<td>{{metadata()?.archive_checksum}}</td>
|
<td>{{metadata()?.archive_checksum}}</td>
|
||||||
</tr>
|
</tr>
|
||||||
}
|
}
|
||||||
@@ -482,9 +482,7 @@
|
|||||||
</pngx-pdf-viewer>
|
</pngx-pdf-viewer>
|
||||||
</div>
|
</div>
|
||||||
} @else {
|
} @else {
|
||||||
<object [data]="previewUrl() | safeUrl" class="preview-sticky" width="100%">
|
<object [data]="previewUrl() | safeUrl" class="preview-sticky" width="100%"></object>
|
||||||
<span>Preview is unavailable.</span>
|
|
||||||
</object>
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@case (ContentRenderType.Text) {
|
@case (ContentRenderType.Text) {
|
||||||
@@ -505,9 +503,7 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
@case (ContentRenderType.Other) {
|
@case (ContentRenderType.Other) {
|
||||||
<object [data]="previewUrl() | safeUrl" class="preview-sticky" width="100%">
|
<object [data]="previewUrl() | safeUrl" class="preview-sticky" width="100%"></object>
|
||||||
<span>Preview is unavailable.</span>
|
|
||||||
</object>
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@if (requiresPassword) {
|
@if (requiresPassword) {
|
||||||
|
|||||||
@@ -510,7 +510,7 @@ describe('DocumentDetailComponent', () => {
|
|||||||
const deleteButtons = fixture.debugElement.queryAll(
|
const deleteButtons = fixture.debugElement.queryAll(
|
||||||
By.css('pngx-confirm-button')
|
By.css('pngx-confirm-button')
|
||||||
)
|
)
|
||||||
expect(deleteButtons.length).toEqual(1)
|
expect(deleteButtons).toHaveLength(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should fall back to details tab when duplicates tab is active but no duplicates', () => {
|
it('should fall back to details tab when duplicates tab is active but no duplicates', () => {
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
@if (loading()) {
|
@if (loading()) {
|
||||||
<div class="d-flex">
|
<div class="d-flex">
|
||||||
<output class="spinner-border spinner-border-sm fw-normal" role="status"></output>
|
<output class="spinner-border spinner-border-sm fw-normal"></output>
|
||||||
</div>
|
</div>
|
||||||
} @else {
|
} @else {
|
||||||
<ul class="list-group">
|
<ul class="list-group">
|
||||||
|
|||||||
+6
-6
@@ -26,11 +26,11 @@ import { UserService } from 'src/app/services/rest/user.service'
|
|||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class DocumentHistoryComponent implements OnInit {
|
export class DocumentHistoryComponent implements OnInit {
|
||||||
private documentService = inject(DocumentService)
|
private readonly documentService = inject(DocumentService)
|
||||||
private correspondentService = inject(CorrespondentService)
|
private readonly correspondentService = inject(CorrespondentService)
|
||||||
private storagePathService = inject(StoragePathService)
|
private readonly storagePathService = inject(StoragePathService)
|
||||||
private documentTypeService = inject(DocumentTypeService)
|
private readonly documentTypeService = inject(DocumentTypeService)
|
||||||
private userService = inject(UserService)
|
private readonly userService = inject(UserService)
|
||||||
|
|
||||||
public AuditLogAction = AuditLogAction
|
public AuditLogAction = AuditLogAction
|
||||||
|
|
||||||
@@ -70,7 +70,7 @@ export class DocumentHistoryComponent implements OnInit {
|
|||||||
return cached
|
return cached
|
||||||
}
|
}
|
||||||
|
|
||||||
const idInt = parseInt(id, 10)
|
const idInt = Number.parseInt(id, 10)
|
||||||
const fallback$ = of(id)
|
const fallback$ = of(id)
|
||||||
|
|
||||||
let result$: Observable<string>
|
let result$: Observable<string>
|
||||||
|
|||||||
+12
-7
@@ -5,10 +5,11 @@
|
|||||||
</button>
|
</button>
|
||||||
<div class="dropdown-menu shadow" ngbDropdownMenu>
|
<div class="dropdown-menu shadow" ngbDropdownMenu>
|
||||||
<div class="px-3 py-2 mb-2">
|
<div class="px-3 py-2 mb-2">
|
||||||
@if (versionUploadState === UploadState.Idle) {
|
@if (versionUploadState() === UploadState.Idle) {
|
||||||
<div class="input-group input-group-sm mb-2">
|
<div class="input-group input-group-sm mb-2">
|
||||||
<span class="input-group-text" i18n>Label</span>
|
<label class="input-group-text" for="newVersionLabel" i18n>Label</label>
|
||||||
<input
|
<input
|
||||||
|
id="newVersionLabel"
|
||||||
class="form-control"
|
class="form-control"
|
||||||
type="text"
|
type="text"
|
||||||
[(ngModel)]="newVersionLabel"
|
[(ngModel)]="newVersionLabel"
|
||||||
@@ -31,7 +32,7 @@
|
|||||||
<i-bs name="file-earmark-plus"></i-bs><span class="ps-1" i18n>Add new version</span>
|
<i-bs name="file-earmark-plus"></i-bs><span class="ps-1" i18n>Add new version</span>
|
||||||
</button>
|
</button>
|
||||||
} @else {
|
} @else {
|
||||||
@switch (versionUploadState) {
|
@switch (versionUploadState()) {
|
||||||
@case (UploadState.Uploading) {
|
@case (UploadState.Uploading) {
|
||||||
<div class="small text-muted mt-1 d-flex align-items-center">
|
<div class="small text-muted mt-1 d-flex align-items-center">
|
||||||
<output class="spinner-border spinner-border-sm me-2" aria-hidden="true"></output>
|
<output class="spinner-border spinner-border-sm me-2" aria-hidden="true"></output>
|
||||||
@@ -49,8 +50,8 @@
|
|||||||
<span i18n>Version upload failed.</span>
|
<span i18n>Version upload failed.</span>
|
||||||
<button type="button" class="btn btn-link btn-sm p-0 ms-2" (click)="clearVersionUploadStatus()" i18n>Dismiss</button>
|
<button type="button" class="btn btn-link btn-sm p-0 ms-2" (click)="clearVersionUploadStatus()" i18n>Dismiss</button>
|
||||||
</div>
|
</div>
|
||||||
@if (versionUploadError) {
|
@if (versionUploadError()) {
|
||||||
<div class="small text-muted mt-1">{{ versionUploadError }}</div>
|
<div class="small text-muted mt-1">{{ versionUploadError() }}</div>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -70,15 +71,19 @@
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<div class="d-flex flex-column">
|
<div class="d-flex flex-column">
|
||||||
|
@if (isEditingVersion(version.id)) {
|
||||||
|
<label class="visually-hidden" for="versionLabel{{ version.id }}" i18n>Version label</label>
|
||||||
|
}
|
||||||
<div class="input-group input-group-sm mb-1">
|
<div class="input-group input-group-sm mb-1">
|
||||||
@if (isEditingVersion(version.id)) {
|
@if (isEditingVersion(version.id)) {
|
||||||
<input
|
<input
|
||||||
|
id="versionLabel{{ version.id }}"
|
||||||
class="form-control"
|
class="form-control"
|
||||||
type="text"
|
type="text"
|
||||||
[(ngModel)]="versionLabelDraft"
|
[(ngModel)]="versionLabelDraft"
|
||||||
i18n-placeholder
|
i18n-placeholder
|
||||||
placeholder="Version label"
|
placeholder="Version label"
|
||||||
[disabled]="savingVersionLabelId !== null"
|
[disabled]="savingVersionLabelId() !== null"
|
||||||
(keydown.enter)="submitEditedVersionLabel(version, $event)"
|
(keydown.enter)="submitEditedVersionLabel(version, $event)"
|
||||||
(keydown.escape)="cancelEditingVersion($event)"
|
(keydown.escape)="cancelEditingVersion($event)"
|
||||||
(click)="$event.stopPropagation()"
|
(click)="$event.stopPropagation()"
|
||||||
@@ -96,7 +101,7 @@
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="btn btn-outline-secondary"
|
class="btn btn-outline-secondary"
|
||||||
[disabled]="savingVersionLabelId !== null"
|
[disabled]="savingVersionLabelId() !== null"
|
||||||
(click)="isEditingVersion(version.id) ? submitEditedVersionLabel(version, $event) : beginEditingVersion(version, $event)"
|
(click)="isEditingVersion(version.id) ? submitEditedVersionLabel(version, $event) : beginEditingVersion(version, $event)"
|
||||||
>
|
>
|
||||||
@if (isEditingVersion(version.id)) {
|
@if (isEditingVersion(version.id)) {
|
||||||
|
|||||||
+17
-15
@@ -205,7 +205,7 @@ describe('DocumentVersionDropdownComponent', () => {
|
|||||||
{ id: 3, is_root: true, checksum: 'aaaa' },
|
{ id: 3, is_root: true, checksum: 'aaaa' },
|
||||||
{ id: 10, is_root: false, checksum: 'bbbb', version_label: 'Updated' },
|
{ id: 10, is_root: false, checksum: 'bbbb', version_label: 'Updated' },
|
||||||
])
|
])
|
||||||
expect(component.savingVersionLabelId).toBeNull()
|
expect(component.savingVersionLabelId()).toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('saveVersionLabel should show error toast on failure', () => {
|
it('saveVersionLabel should show error toast on failure', () => {
|
||||||
@@ -218,7 +218,7 @@ describe('DocumentVersionDropdownComponent', () => {
|
|||||||
'Error updating version label',
|
'Error updating version label',
|
||||||
error
|
error
|
||||||
)
|
)
|
||||||
expect(component.savingVersionLabelId).toBeNull()
|
expect(component.savingVersionLabelId()).toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('onVersionFileSelected should upload and update versions after websocket success', () => {
|
it('onVersionFileSelected should upload and update versions after websocket success', () => {
|
||||||
@@ -252,11 +252,11 @@ describe('DocumentVersionDropdownComponent', () => {
|
|||||||
expect(versionsEmitSpy).toHaveBeenCalledWith(versions)
|
expect(versionsEmitSpy).toHaveBeenCalledWith(versions)
|
||||||
expect(selectedEmitSpy).toHaveBeenCalledWith(20)
|
expect(selectedEmitSpy).toHaveBeenCalledWith(20)
|
||||||
expect(component.newVersionLabel).toEqual('')
|
expect(component.newVersionLabel).toEqual('')
|
||||||
expect(component.versionUploadState).toEqual(UploadState.Idle)
|
expect(component.versionUploadState()).toEqual(UploadState.Idle)
|
||||||
expect(component.versionUploadError).toBeNull()
|
expect(component.versionUploadError()).toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('onVersionFileSelected should set failed state after websocket failure', () => {
|
it('onVersionFileSelected should render failed state after websocket failure', async () => {
|
||||||
const file = new File(['test'], 'new-version.pdf', {
|
const file = new File(['test'], 'new-version.pdf', {
|
||||||
type: 'application/pdf',
|
type: 'application/pdf',
|
||||||
})
|
})
|
||||||
@@ -266,9 +266,11 @@ describe('DocumentVersionDropdownComponent', () => {
|
|||||||
|
|
||||||
component.onVersionFileSelected({ target: input } as Event)
|
component.onVersionFileSelected({ target: input } as Event)
|
||||||
failed$.next({ taskId: 'task-1', message: 'processing failed' })
|
failed$.next({ taskId: 'task-1', message: 'processing failed' })
|
||||||
|
await fixture.whenStable()
|
||||||
|
|
||||||
expect(component.versionUploadState).toEqual(UploadState.Failed)
|
expect(component.versionUploadState()).toEqual(UploadState.Failed)
|
||||||
expect(component.versionUploadError).toEqual('processing failed')
|
expect(component.versionUploadError()).toEqual('processing failed')
|
||||||
|
expect(fixture.nativeElement.textContent).toContain('processing failed')
|
||||||
expect(documentService.getVersions).not.toHaveBeenCalled()
|
expect(documentService.getVersions).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -282,8 +284,8 @@ describe('DocumentVersionDropdownComponent', () => {
|
|||||||
|
|
||||||
component.onVersionFileSelected({ target: input } as Event)
|
component.onVersionFileSelected({ target: input } as Event)
|
||||||
|
|
||||||
expect(component.versionUploadState).toEqual(UploadState.Failed)
|
expect(component.versionUploadState()).toEqual(UploadState.Failed)
|
||||||
expect(component.versionUploadError).toEqual('Missing task ID.')
|
expect(component.versionUploadError()).toEqual('Missing task ID.')
|
||||||
expect(documentService.getVersions).not.toHaveBeenCalled()
|
expect(documentService.getVersions).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -298,8 +300,8 @@ describe('DocumentVersionDropdownComponent', () => {
|
|||||||
|
|
||||||
component.onVersionFileSelected({ target: input } as Event)
|
component.onVersionFileSelected({ target: input } as Event)
|
||||||
|
|
||||||
expect(component.versionUploadState).toEqual(UploadState.Failed)
|
expect(component.versionUploadState()).toEqual(UploadState.Failed)
|
||||||
expect(component.versionUploadError).toEqual('upload failed')
|
expect(component.versionUploadError()).toEqual('upload failed')
|
||||||
expect(toastService.showError).toHaveBeenCalledWith(
|
expect(toastService.showError).toHaveBeenCalledWith(
|
||||||
'Error uploading new version',
|
'Error uploading new version',
|
||||||
error
|
error
|
||||||
@@ -307,8 +309,8 @@ describe('DocumentVersionDropdownComponent', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('ngOnChanges should clear upload status on document switch', () => {
|
it('ngOnChanges should clear upload status on document switch', () => {
|
||||||
component.versionUploadState = UploadState.Failed
|
component.versionUploadState.set(UploadState.Failed)
|
||||||
component.versionUploadError = 'something failed'
|
component.versionUploadError.set('something failed')
|
||||||
component.editingVersionId = 10
|
component.editingVersionId = 10
|
||||||
component.versionLabelDraft = 'draft'
|
component.versionLabelDraft = 'draft'
|
||||||
|
|
||||||
@@ -316,8 +318,8 @@ describe('DocumentVersionDropdownComponent', () => {
|
|||||||
documentId: new SimpleChange(3, 4, false),
|
documentId: new SimpleChange(3, 4, false),
|
||||||
})
|
})
|
||||||
|
|
||||||
expect(component.versionUploadState).toEqual(UploadState.Idle)
|
expect(component.versionUploadState()).toEqual(UploadState.Idle)
|
||||||
expect(component.versionUploadError).toBeNull()
|
expect(component.versionUploadError()).toBeNull()
|
||||||
expect(component.editingVersionId).toBeNull()
|
expect(component.editingVersionId).toBeNull()
|
||||||
expect(component.versionLabelDraft).toEqual('')
|
expect(component.versionLabelDraft).toEqual('')
|
||||||
})
|
})
|
||||||
|
|||||||
+24
-20
@@ -7,6 +7,7 @@ import {
|
|||||||
OnChanges,
|
OnChanges,
|
||||||
OnDestroy,
|
OnDestroy,
|
||||||
Output,
|
Output,
|
||||||
|
signal,
|
||||||
SimpleChanges,
|
SimpleChanges,
|
||||||
} from '@angular/core'
|
} from '@angular/core'
|
||||||
import { FormsModule } from '@angular/forms'
|
import { FormsModule } from '@angular/forms'
|
||||||
@@ -59,9 +60,9 @@ export class DocumentVersionDropdownComponent implements OnChanges, OnDestroy {
|
|||||||
@Output() versionsUpdated = new EventEmitter<DocumentVersionInfo[]>()
|
@Output() versionsUpdated = new EventEmitter<DocumentVersionInfo[]>()
|
||||||
|
|
||||||
newVersionLabel: string = ''
|
newVersionLabel: string = ''
|
||||||
versionUploadState: UploadState = UploadState.Idle
|
readonly versionUploadState = signal(UploadState.Idle)
|
||||||
versionUploadError: string | null = null
|
readonly versionUploadError = signal<string | null>(null)
|
||||||
savingVersionLabelId: number | null = null
|
readonly savingVersionLabelId = signal<number | null>(null)
|
||||||
editingVersionId: number | null = null
|
editingVersionId: number | null = null
|
||||||
versionLabelDraft: string = ''
|
versionLabelDraft: string = ''
|
||||||
|
|
||||||
@@ -101,7 +102,7 @@ export class DocumentVersionDropdownComponent implements OnChanges, OnDestroy {
|
|||||||
beginEditingVersion(version: DocumentVersionInfo, event?: Event): void {
|
beginEditingVersion(version: DocumentVersionInfo, event?: Event): void {
|
||||||
event?.preventDefault()
|
event?.preventDefault()
|
||||||
event?.stopPropagation()
|
event?.stopPropagation()
|
||||||
if (!this.canEditLabels || this.savingVersionLabelId !== null) return
|
if (!this.canEditLabels || this.savingVersionLabelId() !== null) return
|
||||||
this.editingVersionId = version.id
|
this.editingVersionId = version.id
|
||||||
this.versionLabelDraft = version.version_label ?? ''
|
this.versionLabelDraft = version.version_label ?? ''
|
||||||
}
|
}
|
||||||
@@ -116,7 +117,7 @@ export class DocumentVersionDropdownComponent implements OnChanges, OnDestroy {
|
|||||||
submitEditedVersionLabel(version: DocumentVersionInfo, event?: Event): void {
|
submitEditedVersionLabel(version: DocumentVersionInfo, event?: Event): void {
|
||||||
event?.preventDefault()
|
event?.preventDefault()
|
||||||
event?.stopPropagation()
|
event?.stopPropagation()
|
||||||
if (this.savingVersionLabelId !== null) return
|
if (this.savingVersionLabelId() !== null) return
|
||||||
const nextLabel = this.versionLabelDraft?.trim() || null
|
const nextLabel = this.versionLabelDraft?.trim() || null
|
||||||
const currentLabel = version.version_label?.trim() || null
|
const currentLabel = version.version_label?.trim() || null
|
||||||
if (nextLabel === currentLabel) {
|
if (nextLabel === currentLabel) {
|
||||||
@@ -158,15 +159,15 @@ export class DocumentVersionDropdownComponent implements OnChanges, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
saveVersionLabel(versionId: number, versionLabel: string | null): void {
|
saveVersionLabel(versionId: number, versionLabel: string | null): void {
|
||||||
if (this.savingVersionLabelId !== null) return
|
if (this.savingVersionLabelId() !== null) return
|
||||||
this.savingVersionLabelId = versionId
|
this.savingVersionLabelId.set(versionId)
|
||||||
this.documentsService
|
this.documentsService
|
||||||
.updateVersionLabel(this.documentId, versionId, versionLabel)
|
.updateVersionLabel(this.documentId, versionId, versionLabel)
|
||||||
.pipe(
|
.pipe(
|
||||||
first(),
|
first(),
|
||||||
finalize(() => {
|
finalize(() => {
|
||||||
if (this.savingVersionLabelId === versionId) {
|
if (this.savingVersionLabelId() === versionId) {
|
||||||
this.savingVersionLabelId = null
|
this.savingVersionLabelId.set(null)
|
||||||
}
|
}
|
||||||
}),
|
}),
|
||||||
takeUntil(this.destroy$)
|
takeUntil(this.destroy$)
|
||||||
@@ -199,8 +200,8 @@ export class DocumentVersionDropdownComponent implements OnChanges, OnDestroy {
|
|||||||
const file = input.files[0]
|
const file = input.files[0]
|
||||||
input.value = ''
|
input.value = ''
|
||||||
const label = this.newVersionLabel?.trim()
|
const label = this.newVersionLabel?.trim()
|
||||||
this.versionUploadState = UploadState.Uploading
|
this.versionUploadState.set(UploadState.Uploading)
|
||||||
this.versionUploadError = null
|
this.versionUploadError.set(null)
|
||||||
this.documentsService
|
this.documentsService
|
||||||
.uploadVersion(uploadDocumentId, file, label)
|
.uploadVersion(uploadDocumentId, file, label)
|
||||||
.pipe(
|
.pipe(
|
||||||
@@ -210,7 +211,7 @@ export class DocumentVersionDropdownComponent implements OnChanges, OnDestroy {
|
|||||||
$localize`Uploading new version. Processing will happen in the background.`
|
$localize`Uploading new version. Processing will happen in the background.`
|
||||||
)
|
)
|
||||||
this.newVersionLabel = ''
|
this.newVersionLabel = ''
|
||||||
this.versionUploadState = UploadState.Processing
|
this.versionUploadState.set(UploadState.Processing)
|
||||||
}),
|
}),
|
||||||
map((taskId) =>
|
map((taskId) =>
|
||||||
typeof taskId === 'string'
|
typeof taskId === 'string'
|
||||||
@@ -219,8 +220,8 @@ export class DocumentVersionDropdownComponent implements OnChanges, OnDestroy {
|
|||||||
),
|
),
|
||||||
switchMap((taskId) => {
|
switchMap((taskId) => {
|
||||||
if (!taskId) {
|
if (!taskId) {
|
||||||
this.versionUploadState = UploadState.Failed
|
this.versionUploadState.set(UploadState.Failed)
|
||||||
this.versionUploadError = $localize`Missing task ID.`
|
this.versionUploadError.set($localize`Missing task ID.`)
|
||||||
return of(null)
|
return of(null)
|
||||||
}
|
}
|
||||||
return merge(
|
return merge(
|
||||||
@@ -240,9 +241,10 @@ export class DocumentVersionDropdownComponent implements OnChanges, OnDestroy {
|
|||||||
switchMap((result) => {
|
switchMap((result) => {
|
||||||
if (result?.state !== 'success') {
|
if (result?.state !== 'success') {
|
||||||
if (result?.state === 'failed') {
|
if (result?.state === 'failed') {
|
||||||
this.versionUploadState = UploadState.Failed
|
this.versionUploadState.set(UploadState.Failed)
|
||||||
this.versionUploadError =
|
this.versionUploadError.set(
|
||||||
result.message || $localize`Upload failed.`
|
result.message || $localize`Upload failed.`
|
||||||
|
)
|
||||||
}
|
}
|
||||||
return of(null)
|
return of(null)
|
||||||
}
|
}
|
||||||
@@ -264,8 +266,10 @@ export class DocumentVersionDropdownComponent implements OnChanges, OnDestroy {
|
|||||||
},
|
},
|
||||||
error: (error) => {
|
error: (error) => {
|
||||||
if (uploadDocumentId !== this.documentId) return
|
if (uploadDocumentId !== this.documentId) return
|
||||||
this.versionUploadState = UploadState.Failed
|
this.versionUploadState.set(UploadState.Failed)
|
||||||
this.versionUploadError = error?.message || $localize`Upload failed.`
|
this.versionUploadError.set(
|
||||||
|
error?.message || $localize`Upload failed.`
|
||||||
|
)
|
||||||
this.toastService.showError(
|
this.toastService.showError(
|
||||||
$localize`Error uploading new version`,
|
$localize`Error uploading new version`,
|
||||||
error
|
error
|
||||||
@@ -275,7 +279,7 @@ export class DocumentVersionDropdownComponent implements OnChanges, OnDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
clearVersionUploadStatus(): void {
|
clearVersionUploadStatus(): void {
|
||||||
this.versionUploadState = UploadState.Idle
|
this.versionUploadState.set(UploadState.Idle)
|
||||||
this.versionUploadError = null
|
this.versionUploadError.set(null)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
[createRef]="createTag.bind(this)"
|
[createRef]="createTag.bind(this)"
|
||||||
(opened)="openTagsDropdown()"
|
(opened)="openTagsDropdown()"
|
||||||
[(selectionModel)]="tagSelectionModel"
|
[(selectionModel)]="tagSelectionModel"
|
||||||
[documentCounts]="tagDocumentCounts"
|
[documentCounts]="tagDocumentCounts()"
|
||||||
(apply)="setTags($event)"
|
(apply)="setTags($event)"
|
||||||
shortcutKey="t">
|
shortcutKey="t">
|
||||||
</pngx-filterable-dropdown>
|
</pngx-filterable-dropdown>
|
||||||
@@ -24,7 +24,7 @@
|
|||||||
[createRef]="createCorrespondent.bind(this)"
|
[createRef]="createCorrespondent.bind(this)"
|
||||||
(opened)="openCorrespondentDropdown()"
|
(opened)="openCorrespondentDropdown()"
|
||||||
[(selectionModel)]="correspondentSelectionModel"
|
[(selectionModel)]="correspondentSelectionModel"
|
||||||
[documentCounts]="correspondentDocumentCounts"
|
[documentCounts]="correspondentDocumentCounts()"
|
||||||
(apply)="setCorrespondents($event)"
|
(apply)="setCorrespondents($event)"
|
||||||
shortcutKey="y">
|
shortcutKey="y">
|
||||||
</pngx-filterable-dropdown>
|
</pngx-filterable-dropdown>
|
||||||
@@ -38,7 +38,7 @@
|
|||||||
[createRef]="createDocumentType.bind(this)"
|
[createRef]="createDocumentType.bind(this)"
|
||||||
(opened)="openDocumentTypeDropdown()"
|
(opened)="openDocumentTypeDropdown()"
|
||||||
[(selectionModel)]="documentTypeSelectionModel"
|
[(selectionModel)]="documentTypeSelectionModel"
|
||||||
[documentCounts]="documentTypeDocumentCounts"
|
[documentCounts]="documentTypeDocumentCounts()"
|
||||||
(apply)="setDocumentTypes($event)"
|
(apply)="setDocumentTypes($event)"
|
||||||
shortcutKey="u">
|
shortcutKey="u">
|
||||||
</pngx-filterable-dropdown>
|
</pngx-filterable-dropdown>
|
||||||
@@ -52,7 +52,7 @@
|
|||||||
[createRef]="createStoragePath.bind(this)"
|
[createRef]="createStoragePath.bind(this)"
|
||||||
(opened)="openStoragePathDropdown()"
|
(opened)="openStoragePathDropdown()"
|
||||||
[(selectionModel)]="storagePathsSelectionModel"
|
[(selectionModel)]="storagePathsSelectionModel"
|
||||||
[documentCounts]="storagePathDocumentCounts"
|
[documentCounts]="storagePathDocumentCounts()"
|
||||||
(apply)="setStoragePaths($event)"
|
(apply)="setStoragePaths($event)"
|
||||||
shortcutKey="i">
|
shortcutKey="i">
|
||||||
</pngx-filterable-dropdown>
|
</pngx-filterable-dropdown>
|
||||||
@@ -66,7 +66,7 @@
|
|||||||
[createRef]="createCustomField.bind(this)"
|
[createRef]="createCustomField.bind(this)"
|
||||||
(opened)="openCustomFieldsDropdown()"
|
(opened)="openCustomFieldsDropdown()"
|
||||||
[(selectionModel)]="customFieldsSelectionModel"
|
[(selectionModel)]="customFieldsSelectionModel"
|
||||||
[documentCounts]="customFieldDocumentCounts"
|
[documentCounts]="customFieldDocumentCounts()"
|
||||||
extraButtonTitle="Set values"
|
extraButtonTitle="Set values"
|
||||||
i18n-extraButtonTitle
|
i18n-extraButtonTitle
|
||||||
(extraButton)="setCustomFieldValues($event)"
|
(extraButton)="setCustomFieldValues($event)"
|
||||||
@@ -124,11 +124,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="btn-group btn-group-sm">
|
<div class="btn-group btn-group-sm">
|
||||||
<button class="btn btn-sm btn-outline-primary" [disabled]="awaitingDownload" (click)="downloadSelected()">
|
<button class="btn btn-sm btn-outline-primary" [disabled]="awaitingDownload()" (click)="downloadSelected()">
|
||||||
@if (!awaitingDownload) {
|
@if (!awaitingDownload()) {
|
||||||
<i-bs name="arrow-down"></i-bs>
|
<i-bs name="arrow-down"></i-bs>
|
||||||
}
|
}
|
||||||
@if (awaitingDownload) {
|
@if (awaitingDownload()) {
|
||||||
<div class="spinner-border spinner-border-sm" role="status">
|
<div class="spinner-border spinner-border-sm" role="status">
|
||||||
<span class="visually-hidden">Preparing download...</span>
|
<span class="visually-hidden">Preparing download...</span>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -191,6 +191,14 @@ describe('BulkEditorComponent', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
afterEach(async () => {
|
afterEach(async () => {
|
||||||
|
// A filter_selection_data request now fires concurrently with every
|
||||||
|
// non-search reload(), independent of whether a given test flushes or
|
||||||
|
// even inspects the primary list response. Drain any left unclaimed.
|
||||||
|
httpTestingController.match(
|
||||||
|
(request) =>
|
||||||
|
request.url ===
|
||||||
|
`${environment.apiBaseUrl}documents/filter_selection_data/`
|
||||||
|
)
|
||||||
httpTestingController.verify()
|
httpTestingController.verify()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -303,7 +311,7 @@ describe('BulkEditorComponent', () => {
|
|||||||
component.openDocumentTypeDropdown()
|
component.openDocumentTypeDropdown()
|
||||||
|
|
||||||
expect(getSelectionDataSpy).not.toHaveBeenCalled()
|
expect(getSelectionDataSpy).not.toHaveBeenCalled()
|
||||||
expect(component.documentTypeDocumentCounts).toEqual(
|
expect(component.documentTypeDocumentCounts()).toEqual(
|
||||||
selectionData.selected_document_types
|
selectionData.selected_document_types
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
@@ -320,7 +328,7 @@ describe('BulkEditorComponent', () => {
|
|||||||
component.openCorrespondentDropdown()
|
component.openCorrespondentDropdown()
|
||||||
|
|
||||||
expect(getSelectionDataSpy).not.toHaveBeenCalled()
|
expect(getSelectionDataSpy).not.toHaveBeenCalled()
|
||||||
expect(component.correspondentDocumentCounts).toEqual(
|
expect(component.correspondentDocumentCounts()).toEqual(
|
||||||
selectionData.selected_correspondents
|
selectionData.selected_correspondents
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
@@ -337,7 +345,7 @@ describe('BulkEditorComponent', () => {
|
|||||||
component.openStoragePathDropdown()
|
component.openStoragePathDropdown()
|
||||||
|
|
||||||
expect(getSelectionDataSpy).not.toHaveBeenCalled()
|
expect(getSelectionDataSpy).not.toHaveBeenCalled()
|
||||||
expect(component.storagePathDocumentCounts).toEqual(
|
expect(component.storagePathDocumentCounts()).toEqual(
|
||||||
selectionData.selected_storage_paths
|
selectionData.selected_storage_paths
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
@@ -354,7 +362,7 @@ describe('BulkEditorComponent', () => {
|
|||||||
component.openCustomFieldsDropdown()
|
component.openCustomFieldsDropdown()
|
||||||
|
|
||||||
expect(getSelectionDataSpy).not.toHaveBeenCalled()
|
expect(getSelectionDataSpy).not.toHaveBeenCalled()
|
||||||
expect(component.customFieldDocumentCounts).toEqual(
|
expect(component.customFieldDocumentCounts()).toEqual(
|
||||||
selectionData.selected_custom_fields
|
selectionData.selected_custom_fields
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
@@ -386,7 +394,7 @@ describe('BulkEditorComponent', () => {
|
|||||||
parameters: { add_tags: [101], remove_tags: [] },
|
parameters: { add_tags: [101], remove_tags: [] },
|
||||||
})
|
})
|
||||||
httpTestingController.match(
|
httpTestingController.match(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=false`
|
||||||
) // list reload
|
) // list reload
|
||||||
httpTestingController.match(
|
httpTestingController.match(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
||||||
@@ -432,7 +440,7 @@ describe('BulkEditorComponent', () => {
|
|||||||
parameters: { add_tags: [101], remove_tags: [] },
|
parameters: { add_tags: [101], remove_tags: [] },
|
||||||
})
|
})
|
||||||
httpTestingController.match(
|
httpTestingController.match(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=false`
|
||||||
) // list reload
|
) // list reload
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -461,7 +469,7 @@ describe('BulkEditorComponent', () => {
|
|||||||
.expectOne(`${environment.apiBaseUrl}documents/bulk_edit/`)
|
.expectOne(`${environment.apiBaseUrl}documents/bulk_edit/`)
|
||||||
.flush(true)
|
.flush(true)
|
||||||
httpTestingController.match(
|
httpTestingController.match(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=false`
|
||||||
) // list reload
|
) // list reload
|
||||||
httpTestingController.match(
|
httpTestingController.match(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
||||||
@@ -552,7 +560,7 @@ describe('BulkEditorComponent', () => {
|
|||||||
parameters: { correspondent: 101 },
|
parameters: { correspondent: 101 },
|
||||||
})
|
})
|
||||||
httpTestingController.match(
|
httpTestingController.match(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=false`
|
||||||
) // list reload
|
) // list reload
|
||||||
httpTestingController.match(
|
httpTestingController.match(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
||||||
@@ -584,7 +592,7 @@ describe('BulkEditorComponent', () => {
|
|||||||
.expectOne(`${environment.apiBaseUrl}documents/bulk_edit/`)
|
.expectOne(`${environment.apiBaseUrl}documents/bulk_edit/`)
|
||||||
.flush(true)
|
.flush(true)
|
||||||
httpTestingController.match(
|
httpTestingController.match(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=false`
|
||||||
) // list reload
|
) // list reload
|
||||||
httpTestingController.match(
|
httpTestingController.match(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
||||||
@@ -650,7 +658,7 @@ describe('BulkEditorComponent', () => {
|
|||||||
parameters: { document_type: 101 },
|
parameters: { document_type: 101 },
|
||||||
})
|
})
|
||||||
httpTestingController.match(
|
httpTestingController.match(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=false`
|
||||||
) // list reload
|
) // list reload
|
||||||
httpTestingController.match(
|
httpTestingController.match(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
||||||
@@ -682,7 +690,7 @@ describe('BulkEditorComponent', () => {
|
|||||||
.expectOne(`${environment.apiBaseUrl}documents/bulk_edit/`)
|
.expectOne(`${environment.apiBaseUrl}documents/bulk_edit/`)
|
||||||
.flush(true)
|
.flush(true)
|
||||||
httpTestingController.match(
|
httpTestingController.match(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=false`
|
||||||
) // list reload
|
) // list reload
|
||||||
httpTestingController.match(
|
httpTestingController.match(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
||||||
@@ -748,7 +756,7 @@ describe('BulkEditorComponent', () => {
|
|||||||
parameters: { storage_path: 101 },
|
parameters: { storage_path: 101 },
|
||||||
})
|
})
|
||||||
httpTestingController.match(
|
httpTestingController.match(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=false`
|
||||||
) // list reload
|
) // list reload
|
||||||
httpTestingController.match(
|
httpTestingController.match(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
||||||
@@ -780,7 +788,7 @@ describe('BulkEditorComponent', () => {
|
|||||||
.expectOne(`${environment.apiBaseUrl}documents/bulk_edit/`)
|
.expectOne(`${environment.apiBaseUrl}documents/bulk_edit/`)
|
||||||
.flush(true)
|
.flush(true)
|
||||||
httpTestingController.match(
|
httpTestingController.match(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=false`
|
||||||
) // list reload
|
) // list reload
|
||||||
httpTestingController.match(
|
httpTestingController.match(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
||||||
@@ -846,7 +854,7 @@ describe('BulkEditorComponent', () => {
|
|||||||
parameters: { add_custom_fields: [101], remove_custom_fields: [102] },
|
parameters: { add_custom_fields: [101], remove_custom_fields: [102] },
|
||||||
})
|
})
|
||||||
httpTestingController.match(
|
httpTestingController.match(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=false`
|
||||||
) // list reload
|
) // list reload
|
||||||
httpTestingController.match(
|
httpTestingController.match(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
||||||
@@ -878,7 +886,7 @@ describe('BulkEditorComponent', () => {
|
|||||||
.expectOne(`${environment.apiBaseUrl}documents/bulk_edit/`)
|
.expectOne(`${environment.apiBaseUrl}documents/bulk_edit/`)
|
||||||
.flush(true)
|
.flush(true)
|
||||||
httpTestingController.match(
|
httpTestingController.match(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=false`
|
||||||
) // list reload
|
) // list reload
|
||||||
httpTestingController.match(
|
httpTestingController.match(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
||||||
@@ -987,7 +995,7 @@ describe('BulkEditorComponent', () => {
|
|||||||
documents: [3, 4],
|
documents: [3, 4],
|
||||||
})
|
})
|
||||||
httpTestingController.match(
|
httpTestingController.match(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=false`
|
||||||
) // list reload
|
) // list reload
|
||||||
httpTestingController.match(
|
httpTestingController.match(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
||||||
@@ -1080,7 +1088,7 @@ describe('BulkEditorComponent', () => {
|
|||||||
documents: [3, 4],
|
documents: [3, 4],
|
||||||
})
|
})
|
||||||
httpTestingController.match(
|
httpTestingController.match(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=false`
|
||||||
) // list reload
|
) // list reload
|
||||||
httpTestingController.match(
|
httpTestingController.match(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
||||||
@@ -1115,7 +1123,7 @@ describe('BulkEditorComponent', () => {
|
|||||||
source_mode: 'latest_version',
|
source_mode: 'latest_version',
|
||||||
})
|
})
|
||||||
httpTestingController.match(
|
httpTestingController.match(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=false`
|
||||||
) // list reload
|
) // list reload
|
||||||
httpTestingController.match(
|
httpTestingController.match(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
||||||
@@ -1156,7 +1164,7 @@ describe('BulkEditorComponent', () => {
|
|||||||
metadata_document_id: 3,
|
metadata_document_id: 3,
|
||||||
})
|
})
|
||||||
httpTestingController.match(
|
httpTestingController.match(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=false`
|
||||||
) // list reload
|
) // list reload
|
||||||
httpTestingController.match(
|
httpTestingController.match(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
||||||
@@ -1175,7 +1183,7 @@ describe('BulkEditorComponent', () => {
|
|||||||
delete_originals: true,
|
delete_originals: true,
|
||||||
})
|
})
|
||||||
httpTestingController.match(
|
httpTestingController.match(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=false`
|
||||||
) // list reload
|
) // list reload
|
||||||
httpTestingController.match(
|
httpTestingController.match(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
||||||
@@ -1196,7 +1204,7 @@ describe('BulkEditorComponent', () => {
|
|||||||
archive_fallback: true,
|
archive_fallback: true,
|
||||||
})
|
})
|
||||||
httpTestingController.match(
|
httpTestingController.match(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=false`
|
||||||
) // list reload
|
) // list reload
|
||||||
httpTestingController.match(
|
httpTestingController.match(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
||||||
@@ -1299,7 +1307,7 @@ describe('BulkEditorComponent', () => {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
httpTestingController.match(
|
httpTestingController.match(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=false`
|
||||||
) // list reload
|
) // list reload
|
||||||
httpTestingController.match(
|
httpTestingController.match(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
||||||
@@ -1607,7 +1615,7 @@ describe('BulkEditorComponent', () => {
|
|||||||
expect(toastServiceShowInfoSpy).toHaveBeenCalled()
|
expect(toastServiceShowInfoSpy).toHaveBeenCalled()
|
||||||
expect(listReloadSpy).toHaveBeenCalled()
|
expect(listReloadSpy).toHaveBeenCalled()
|
||||||
httpTestingController.match(
|
httpTestingController.match(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=false`
|
||||||
) // list reload
|
) // list reload
|
||||||
httpTestingController.match(
|
httpTestingController.match(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
||||||
|
|||||||
@@ -1,4 +1,11 @@
|
|||||||
import { Component, inject, Input, OnDestroy, OnInit } from '@angular/core'
|
import {
|
||||||
|
Component,
|
||||||
|
inject,
|
||||||
|
Input,
|
||||||
|
OnDestroy,
|
||||||
|
OnInit,
|
||||||
|
signal,
|
||||||
|
} from '@angular/core'
|
||||||
import {
|
import {
|
||||||
FormControl,
|
FormControl,
|
||||||
FormGroup,
|
FormGroup,
|
||||||
@@ -101,12 +108,12 @@ export class BulkEditorComponent
|
|||||||
documentTypeSelectionModel = new FilterableDropdownSelectionModel()
|
documentTypeSelectionModel = new FilterableDropdownSelectionModel()
|
||||||
storagePathsSelectionModel = new FilterableDropdownSelectionModel()
|
storagePathsSelectionModel = new FilterableDropdownSelectionModel()
|
||||||
customFieldsSelectionModel = new FilterableDropdownSelectionModel(true)
|
customFieldsSelectionModel = new FilterableDropdownSelectionModel(true)
|
||||||
tagDocumentCounts: SelectionDataItem[]
|
readonly tagDocumentCounts = signal<SelectionDataItem[]>(undefined)
|
||||||
correspondentDocumentCounts: SelectionDataItem[]
|
readonly correspondentDocumentCounts = signal<SelectionDataItem[]>(undefined)
|
||||||
documentTypeDocumentCounts: SelectionDataItem[]
|
readonly documentTypeDocumentCounts = signal<SelectionDataItem[]>(undefined)
|
||||||
storagePathDocumentCounts: SelectionDataItem[]
|
readonly storagePathDocumentCounts = signal<SelectionDataItem[]>(undefined)
|
||||||
customFieldDocumentCounts: SelectionDataItem[]
|
readonly customFieldDocumentCounts = signal<SelectionDataItem[]>(undefined)
|
||||||
awaitingDownload: boolean
|
readonly awaitingDownload = signal(false)
|
||||||
|
|
||||||
unsubscribeNotifier: Subject<any> = new Subject()
|
unsubscribeNotifier: Subject<any> = new Subject()
|
||||||
|
|
||||||
@@ -365,8 +372,8 @@ export class BulkEditorComponent
|
|||||||
openTagsDropdown() {
|
openTagsDropdown() {
|
||||||
if (this.list.allSelected) {
|
if (this.list.allSelected) {
|
||||||
const selectionData = this.list.selectionData
|
const selectionData = this.list.selectionData
|
||||||
this.tagDocumentCounts = selectionData?.selected_tags ?? []
|
this.tagDocumentCounts.set(selectionData?.selected_tags ?? [])
|
||||||
this.applySelectionData(this.tagDocumentCounts, this.tagSelectionModel)
|
this.applySelectionData(this.tagDocumentCounts(), this.tagSelectionModel)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -374,7 +381,7 @@ export class BulkEditorComponent
|
|||||||
.getSelectionData(Array.from(this.list.selected))
|
.getSelectionData(Array.from(this.list.selected))
|
||||||
.pipe(first())
|
.pipe(first())
|
||||||
.subscribe((s) => {
|
.subscribe((s) => {
|
||||||
this.tagDocumentCounts = s.selected_tags
|
this.tagDocumentCounts.set(s.selected_tags)
|
||||||
this.applySelectionData(s.selected_tags, this.tagSelectionModel)
|
this.applySelectionData(s.selected_tags, this.tagSelectionModel)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -382,10 +389,11 @@ export class BulkEditorComponent
|
|||||||
openDocumentTypeDropdown() {
|
openDocumentTypeDropdown() {
|
||||||
if (this.list.allSelected) {
|
if (this.list.allSelected) {
|
||||||
const selectionData = this.list.selectionData
|
const selectionData = this.list.selectionData
|
||||||
this.documentTypeDocumentCounts =
|
this.documentTypeDocumentCounts.set(
|
||||||
selectionData?.selected_document_types ?? []
|
selectionData?.selected_document_types ?? []
|
||||||
|
)
|
||||||
this.applySelectionData(
|
this.applySelectionData(
|
||||||
this.documentTypeDocumentCounts,
|
this.documentTypeDocumentCounts(),
|
||||||
this.documentTypeSelectionModel
|
this.documentTypeSelectionModel
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
@@ -395,7 +403,7 @@ export class BulkEditorComponent
|
|||||||
.getSelectionData(Array.from(this.list.selected))
|
.getSelectionData(Array.from(this.list.selected))
|
||||||
.pipe(first())
|
.pipe(first())
|
||||||
.subscribe((s) => {
|
.subscribe((s) => {
|
||||||
this.documentTypeDocumentCounts = s.selected_document_types
|
this.documentTypeDocumentCounts.set(s.selected_document_types)
|
||||||
this.applySelectionData(
|
this.applySelectionData(
|
||||||
s.selected_document_types,
|
s.selected_document_types,
|
||||||
this.documentTypeSelectionModel
|
this.documentTypeSelectionModel
|
||||||
@@ -406,10 +414,11 @@ export class BulkEditorComponent
|
|||||||
openCorrespondentDropdown() {
|
openCorrespondentDropdown() {
|
||||||
if (this.list.allSelected) {
|
if (this.list.allSelected) {
|
||||||
const selectionData = this.list.selectionData
|
const selectionData = this.list.selectionData
|
||||||
this.correspondentDocumentCounts =
|
this.correspondentDocumentCounts.set(
|
||||||
selectionData?.selected_correspondents ?? []
|
selectionData?.selected_correspondents ?? []
|
||||||
|
)
|
||||||
this.applySelectionData(
|
this.applySelectionData(
|
||||||
this.correspondentDocumentCounts,
|
this.correspondentDocumentCounts(),
|
||||||
this.correspondentSelectionModel
|
this.correspondentSelectionModel
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
@@ -419,7 +428,7 @@ export class BulkEditorComponent
|
|||||||
.getSelectionData(Array.from(this.list.selected))
|
.getSelectionData(Array.from(this.list.selected))
|
||||||
.pipe(first())
|
.pipe(first())
|
||||||
.subscribe((s) => {
|
.subscribe((s) => {
|
||||||
this.correspondentDocumentCounts = s.selected_correspondents
|
this.correspondentDocumentCounts.set(s.selected_correspondents)
|
||||||
this.applySelectionData(
|
this.applySelectionData(
|
||||||
s.selected_correspondents,
|
s.selected_correspondents,
|
||||||
this.correspondentSelectionModel
|
this.correspondentSelectionModel
|
||||||
@@ -430,10 +439,11 @@ export class BulkEditorComponent
|
|||||||
openStoragePathDropdown() {
|
openStoragePathDropdown() {
|
||||||
if (this.list.allSelected) {
|
if (this.list.allSelected) {
|
||||||
const selectionData = this.list.selectionData
|
const selectionData = this.list.selectionData
|
||||||
this.storagePathDocumentCounts =
|
this.storagePathDocumentCounts.set(
|
||||||
selectionData?.selected_storage_paths ?? []
|
selectionData?.selected_storage_paths ?? []
|
||||||
|
)
|
||||||
this.applySelectionData(
|
this.applySelectionData(
|
||||||
this.storagePathDocumentCounts,
|
this.storagePathDocumentCounts(),
|
||||||
this.storagePathsSelectionModel
|
this.storagePathsSelectionModel
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
@@ -443,7 +453,7 @@ export class BulkEditorComponent
|
|||||||
.getSelectionData(Array.from(this.list.selected))
|
.getSelectionData(Array.from(this.list.selected))
|
||||||
.pipe(first())
|
.pipe(first())
|
||||||
.subscribe((s) => {
|
.subscribe((s) => {
|
||||||
this.storagePathDocumentCounts = s.selected_storage_paths
|
this.storagePathDocumentCounts.set(s.selected_storage_paths)
|
||||||
this.applySelectionData(
|
this.applySelectionData(
|
||||||
s.selected_storage_paths,
|
s.selected_storage_paths,
|
||||||
this.storagePathsSelectionModel
|
this.storagePathsSelectionModel
|
||||||
@@ -454,10 +464,11 @@ export class BulkEditorComponent
|
|||||||
openCustomFieldsDropdown() {
|
openCustomFieldsDropdown() {
|
||||||
if (this.list.allSelected) {
|
if (this.list.allSelected) {
|
||||||
const selectionData = this.list.selectionData
|
const selectionData = this.list.selectionData
|
||||||
this.customFieldDocumentCounts =
|
this.customFieldDocumentCounts.set(
|
||||||
selectionData?.selected_custom_fields ?? []
|
selectionData?.selected_custom_fields ?? []
|
||||||
|
)
|
||||||
this.applySelectionData(
|
this.applySelectionData(
|
||||||
this.customFieldDocumentCounts,
|
this.customFieldDocumentCounts(),
|
||||||
this.customFieldsSelectionModel
|
this.customFieldsSelectionModel
|
||||||
)
|
)
|
||||||
return
|
return
|
||||||
@@ -467,7 +478,7 @@ export class BulkEditorComponent
|
|||||||
.getSelectionData(Array.from(this.list.selected))
|
.getSelectionData(Array.from(this.list.selected))
|
||||||
.pipe(first())
|
.pipe(first())
|
||||||
.subscribe((s) => {
|
.subscribe((s) => {
|
||||||
this.customFieldDocumentCounts = s.selected_custom_fields
|
this.customFieldDocumentCounts.set(s.selected_custom_fields)
|
||||||
this.applySelectionData(
|
this.applySelectionData(
|
||||||
s.selected_custom_fields,
|
s.selected_custom_fields,
|
||||||
this.customFieldsSelectionModel
|
this.customFieldsSelectionModel
|
||||||
@@ -876,7 +887,7 @@ export class BulkEditorComponent
|
|||||||
}
|
}
|
||||||
|
|
||||||
downloadSelected() {
|
downloadSelected() {
|
||||||
this.awaitingDownload = true
|
this.awaitingDownload.set(true)
|
||||||
let downloadFileType: string =
|
let downloadFileType: string =
|
||||||
this.downloadForm.get('downloadFileTypeArchive').value &&
|
this.downloadForm.get('downloadFileTypeArchive').value &&
|
||||||
this.downloadForm.get('downloadFileTypeOriginals').value
|
this.downloadForm.get('downloadFileTypeOriginals').value
|
||||||
@@ -893,7 +904,7 @@ export class BulkEditorComponent
|
|||||||
.pipe(first())
|
.pipe(first())
|
||||||
.subscribe((result: any) => {
|
.subscribe((result: any) => {
|
||||||
saveAs(result, 'documents.zip')
|
saveAs(result, 'documents.zip')
|
||||||
this.awaitingDownload = false
|
this.awaitingDownload.set(false)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
<div class="row g-0">
|
<div class="row g-0">
|
||||||
<div class="col-md-2 doc-img-container rounded-start" (click)="this.toggleSelected.emit($event)" (dblclick)="dblClickDocument.emit()">
|
<div class="col-md-2 doc-img-container rounded-start" (click)="this.toggleSelected.emit($event)" (dblclick)="dblClickDocument.emit()">
|
||||||
@if (document()) {
|
@if (document()) {
|
||||||
<img [src]="getThumbUrl()" class="card-img doc-img border-end rounded-start" [class.inverted]="getIsThumbInverted()">
|
<img [ngSrc]="getThumbUrl()" fill class="card-img doc-img border-end rounded-start" [class.inverted]="getIsThumbInverted()">
|
||||||
|
|
||||||
<div class="border-end border-bottom bg-light document-card-check">
|
<div class="border-end border-bottom bg-light document-card-check">
|
||||||
<div class="form-check">
|
<div class="form-check">
|
||||||
|
|||||||
+6
@@ -88,6 +88,12 @@ describe('DocumentCardLargeComponent', () => {
|
|||||||
expect(fixture.nativeElement.textContent).toContain('8 pages')
|
expect(fixture.nativeElement.textContent).toContain('8 pages')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should lazy load the thumbnail', () => {
|
||||||
|
const thumbnail: HTMLImageElement =
|
||||||
|
fixture.nativeElement.querySelector('img.doc-img')
|
||||||
|
expect(thumbnail.getAttribute('loading')).toEqual('lazy')
|
||||||
|
})
|
||||||
|
|
||||||
it('should trim content', () => {
|
it('should trim content', () => {
|
||||||
expect(component.contentTrimmed).toHaveLength(503) // includes ...
|
expect(component.contentTrimmed).toHaveLength(503) // includes ...
|
||||||
})
|
})
|
||||||
|
|||||||
+2
-1
@@ -1,4 +1,4 @@
|
|||||||
import { AsyncPipe } from '@angular/common'
|
import { AsyncPipe, NgOptimizedImage } from '@angular/common'
|
||||||
import {
|
import {
|
||||||
AfterViewInit,
|
AfterViewInit,
|
||||||
Component,
|
Component,
|
||||||
@@ -46,6 +46,7 @@ import { LoadingComponentWithPermissions } from '../../loading-component/loading
|
|||||||
TagComponent,
|
TagComponent,
|
||||||
CustomFieldDisplayComponent,
|
CustomFieldDisplayComponent,
|
||||||
AsyncPipe,
|
AsyncPipe,
|
||||||
|
NgOptimizedImage,
|
||||||
UsernamePipe,
|
UsernamePipe,
|
||||||
CorrespondentNamePipe,
|
CorrespondentNamePipe,
|
||||||
DocumentTypeNamePipe,
|
DocumentTypeNamePipe,
|
||||||
|
|||||||
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
<div class="card h-100 shadow-sm document-card" [class.placeholder-glow]="!document()" [class.card-selected]="selected()" (mouseleave)="mouseLeaveCard()">
|
<div class="card h-100 shadow-sm document-card" [class.placeholder-glow]="!document()" [class.card-selected]="selected()" (mouseleave)="mouseLeaveCard()">
|
||||||
<div class="border-bottom doc-img-container rounded-top" (click)="this.toggleSelected.emit($event)" (dblclick)="dblClickDocument.emit(this)">
|
<div class="border-bottom doc-img-container rounded-top" (click)="this.toggleSelected.emit($event)" (dblclick)="dblClickDocument.emit(this)">
|
||||||
@if (document()) {
|
@if (document()) {
|
||||||
<img class="card-img doc-img" [class.inverted]="getIsThumbInverted()" [src]="getThumbUrl()">
|
<img class="card-img doc-img" [class.inverted]="getIsThumbInverted()" [ngSrc]="getThumbUrl()" fill>
|
||||||
|
|
||||||
<div class="border-end border-bottom bg-light py-1 px-2 document-card-check">
|
<div class="border-end border-bottom bg-light py-1 px-2 document-card-check">
|
||||||
<div class="form-check">
|
<div class="form-check">
|
||||||
|
|||||||
+5
-1
@@ -22,10 +22,14 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.doc-img-container {
|
||||||
|
position: relative;
|
||||||
|
height: 180px;
|
||||||
|
}
|
||||||
|
|
||||||
.doc-img {
|
.doc-img {
|
||||||
object-fit: cover;
|
object-fit: cover;
|
||||||
object-position: top left;
|
object-position: top left;
|
||||||
height: 180px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.document-card-check {
|
.document-card-check {
|
||||||
|
|||||||
+6
@@ -61,6 +61,12 @@ describe('DocumentCardSmallComponent', () => {
|
|||||||
expect(fixture.nativeElement.textContent).toContain('12 pages')
|
expect(fixture.nativeElement.textContent).toContain('12 pages')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should lazy load the thumbnail', () => {
|
||||||
|
const thumbnail: HTMLImageElement =
|
||||||
|
fixture.nativeElement.querySelector('img.doc-img')
|
||||||
|
expect(thumbnail.getAttribute('loading')).toEqual('lazy')
|
||||||
|
})
|
||||||
|
|
||||||
it('should display a document, limit tags to 5', () => {
|
it('should display a document, limit tags to 5', () => {
|
||||||
expect(fixture.nativeElement.textContent).toContain('Document 10')
|
expect(fixture.nativeElement.textContent).toContain('Document 10')
|
||||||
expect(
|
expect(
|
||||||
|
|||||||
+2
-1
@@ -1,4 +1,4 @@
|
|||||||
import { AsyncPipe } from '@angular/common'
|
import { AsyncPipe, NgOptimizedImage } from '@angular/common'
|
||||||
import {
|
import {
|
||||||
AfterViewInit,
|
AfterViewInit,
|
||||||
Component,
|
Component,
|
||||||
@@ -46,6 +46,7 @@ import { LoadingComponentWithPermissions } from '../../loading-component/loading
|
|||||||
TagComponent,
|
TagComponent,
|
||||||
CustomFieldDisplayComponent,
|
CustomFieldDisplayComponent,
|
||||||
AsyncPipe,
|
AsyncPipe,
|
||||||
|
NgOptimizedImage,
|
||||||
UsernamePipe,
|
UsernamePipe,
|
||||||
CorrespondentNamePipe,
|
CorrespondentNamePipe,
|
||||||
DocumentTypeNamePipe,
|
DocumentTypeNamePipe,
|
||||||
|
|||||||
@@ -115,7 +115,7 @@
|
|||||||
</pngx-page-header>
|
</pngx-page-header>
|
||||||
|
|
||||||
<div class="row sticky-top py-3 mt-n2 mt-md-n3 bg-body">
|
<div class="row sticky-top py-3 mt-n2 mt-md-n3 bg-body">
|
||||||
<pngx-filter-editor [hidden]="isBulkEditing" [disabled]="isBulkEditing" [filterRules]="list.filterRules" (filterRulesChange)="onFilterRulesChange($event)" (resetFilterRules)="onFilterRulesReset($event)" [unmodifiedFilterRules]="unmodifiedFilterRules" [selectionData]="list.selectionData" #filterEditor></pngx-filter-editor>
|
<pngx-filter-editor [hidden]="isBulkEditing" [disabled]="isBulkEditing" [filterRules]="list.filterRules" (filterRulesChange)="onFilterRulesChange($event)" (resetFilterRules)="onFilterRulesReset($event)" [unmodifiedFilterRules]="unmodifiedFilterRules()" [selectionData]="list.selectionData" #filterEditor></pngx-filter-editor>
|
||||||
<pngx-bulk-editor [hidden]="!isBulkEditing" [disabled]="!isBulkEditing"></pngx-bulk-editor>
|
<pngx-bulk-editor [hidden]="!isBulkEditing" [disabled]="!isBulkEditing"></pngx-bulk-editor>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import {
|
|||||||
OnDestroy,
|
OnDestroy,
|
||||||
OnInit,
|
OnInit,
|
||||||
QueryList,
|
QueryList,
|
||||||
|
signal,
|
||||||
ViewChild,
|
ViewChild,
|
||||||
ViewChildren,
|
ViewChildren,
|
||||||
} from '@angular/core'
|
} from '@angular/core'
|
||||||
@@ -149,7 +150,7 @@ export class DocumentListComponent
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
unmodifiedFilterRules: FilterRule[] = []
|
readonly unmodifiedFilterRules = signal<FilterRule[]>([])
|
||||||
private unmodifiedSavedView: SavedView
|
private unmodifiedSavedView: SavedView
|
||||||
private activeSavedView: SavedView | null = null
|
private activeSavedView: SavedView | null = null
|
||||||
|
|
||||||
@@ -299,7 +300,7 @@ export class DocumentListComponent
|
|||||||
this.savedViewService.setDocumentCount(view, this.list.collectionSize)
|
this.savedViewService.setDocumentCount(view, this.list.collectionSize)
|
||||||
})
|
})
|
||||||
this.updateDisplayCustomFields()
|
this.updateDisplayCustomFields()
|
||||||
this.unmodifiedFilterRules = view.filter_rules
|
this.unmodifiedFilterRules.set(view.filter_rules)
|
||||||
})
|
})
|
||||||
|
|
||||||
this.route.queryParamMap
|
this.route.queryParamMap
|
||||||
@@ -316,7 +317,7 @@ export class DocumentListComponent
|
|||||||
this.activeSavedView = null
|
this.activeSavedView = null
|
||||||
this.list.activateSavedView(null)
|
this.list.activateSavedView(null)
|
||||||
this.list.loadFromQueryParams(queryParams)
|
this.list.loadFromQueryParams(queryParams)
|
||||||
this.unmodifiedFilterRules = []
|
this.unmodifiedFilterRules.set([])
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -415,7 +416,7 @@ export class DocumentListComponent
|
|||||||
this.toastService.showInfo(
|
this.toastService.showInfo(
|
||||||
$localize`View "${this.list.activeSavedViewTitle}" saved successfully.`
|
$localize`View "${this.list.activeSavedViewTitle}" saved successfully.`
|
||||||
)
|
)
|
||||||
this.unmodifiedFilterRules = this.list.filterRules
|
this.unmodifiedFilterRules.set(this.list.filterRules)
|
||||||
},
|
},
|
||||||
error: (err) => {
|
error: (err) => {
|
||||||
this.toastService.showError(
|
this.toastService.showError(
|
||||||
|
|||||||
@@ -80,7 +80,7 @@
|
|||||||
shortcutKey="i"></pngx-filterable-dropdown>
|
shortcutKey="i"></pngx-filterable-dropdown>
|
||||||
}
|
}
|
||||||
|
|
||||||
@if (permissionsService.currentUserCan(PermissionAction.View, PermissionType.CustomField) && customFields.length > 0) {
|
@if (permissionsService.currentUserCan(PermissionAction.View, PermissionType.CustomField) && customFields().length > 0) {
|
||||||
<pngx-custom-fields-query-dropdown class="flex-fill fade" [class.show]="show()" title="Custom fields" icon="ui-radios" i18n-title
|
<pngx-custom-fields-query-dropdown class="flex-fill fade" [class.show]="show()" title="Custom fields" icon="ui-radios" i18n-title
|
||||||
[(selectionModel)]="customFieldQueriesModel"
|
[(selectionModel)]="customFieldQueriesModel"
|
||||||
(selectionModelChange)="updateRules()"
|
(selectionModelChange)="updateRules()"
|
||||||
|
|||||||
+1
-1
@@ -328,7 +328,7 @@ describe('FilterEditorComponent', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('should ingest legacy text filter rules for doc title + content', () => {
|
it('should ingest legacy text filter rules for doc title + content', () => {
|
||||||
expect(component.textFilter).toEqual(null)
|
expect(component.textFilter).toBeNull()
|
||||||
component.filterRules = [
|
component.filterRules = [
|
||||||
{
|
{
|
||||||
rule_type: FILTER_TITLE_CONTENT,
|
rule_type: FILTER_TITLE_CONTENT,
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
AfterViewInit,
|
AfterViewInit,
|
||||||
|
ChangeDetectorRef,
|
||||||
Component,
|
Component,
|
||||||
ElementRef,
|
ElementRef,
|
||||||
EventEmitter,
|
EventEmitter,
|
||||||
@@ -9,6 +10,7 @@ import {
|
|||||||
Output,
|
Output,
|
||||||
ViewChild,
|
ViewChild,
|
||||||
inject,
|
inject,
|
||||||
|
signal,
|
||||||
} from '@angular/core'
|
} from '@angular/core'
|
||||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||||
import {
|
import {
|
||||||
@@ -349,7 +351,7 @@ export class FilterEditorComponent
|
|||||||
@ViewChild('textFilterInput')
|
@ViewChild('textFilterInput')
|
||||||
textFilterInput: ElementRef
|
textFilterInput: ElementRef
|
||||||
|
|
||||||
customFields: CustomField[] = []
|
readonly customFields = signal<CustomField[]>([])
|
||||||
|
|
||||||
tagDocumentCounts: SelectionDataItem[]
|
tagDocumentCounts: SelectionDataItem[]
|
||||||
correspondentDocumentCounts: SelectionDataItem[]
|
correspondentDocumentCounts: SelectionDataItem[]
|
||||||
@@ -514,6 +516,7 @@ export class FilterEditorComponent
|
|||||||
this.documentService.get(this._moreLikeId).subscribe((result) => {
|
this.documentService.get(this._moreLikeId).subscribe((result) => {
|
||||||
this._moreLikeDoc = result
|
this._moreLikeDoc = result
|
||||||
this._textFilter = result.title
|
this._textFilter = result.title
|
||||||
|
this.changeDetector.markForCheck()
|
||||||
})
|
})
|
||||||
break
|
break
|
||||||
case FILTER_CREATED_AFTER:
|
case FILTER_CREATED_AFTER:
|
||||||
@@ -1162,6 +1165,7 @@ export class FilterEditorComponent
|
|||||||
|
|
||||||
private loadingCountTotal: number = 0
|
private loadingCountTotal: number = 0
|
||||||
private loadingCount: number = 0
|
private loadingCount: number = 0
|
||||||
|
private readonly changeDetector = inject(ChangeDetectorRef)
|
||||||
|
|
||||||
private maybeCompleteLoading() {
|
private maybeCompleteLoading() {
|
||||||
this.loadingCount++
|
this.loadingCount++
|
||||||
@@ -1229,7 +1233,7 @@ export class FilterEditorComponent
|
|||||||
) {
|
) {
|
||||||
this.loadingCountTotal++
|
this.loadingCountTotal++
|
||||||
this.customFieldService.listAll().subscribe((result) => {
|
this.customFieldService.listAll().subscribe((result) => {
|
||||||
this.customFields = result.results
|
this.customFields.set(result.results)
|
||||||
this.maybeCompleteLoading()
|
this.maybeCompleteLoading()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-4
@@ -1,18 +1,18 @@
|
|||||||
<div class="row mb-3">
|
<div class="row mb-3">
|
||||||
<div class="col mb-2 mb-xl-0">
|
<div class="col mb-2 mb-xl-0">
|
||||||
<div class="form-inline d-flex align-items-center">
|
<div class="form-inline d-flex align-items-center">
|
||||||
<label class="text-muted me-2 mb-0" i18n>Filter by:</label>
|
<label class="text-muted me-2 mb-0" for="managementNameFilter" i18n>Filter by:</label>
|
||||||
<input class="form-control form-control-sm flex-fill w-auto" type="text" autofocus [(ngModel)]="nameFilter" (keyup)="onNameFilterKeyUp($event)" placeholder="Name" i18n-placeholder>
|
<input id="managementNameFilter" class="form-control form-control-sm flex-fill w-auto" type="text" autofocus [(ngModel)]="nameFilter" (keyup)="onNameFilterKeyUp($event)" placeholder="Name" i18n-placeholder>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="col-auto mb-2 mb-xl-0">
|
<div class="col-auto mb-2 mb-xl-0">
|
||||||
<div class="form-inline d-flex align-items-center">
|
<div class="form-inline d-flex align-items-center">
|
||||||
<div class="input-group input-group-sm w-auto d-none d-md-flex">
|
<div class="input-group input-group-sm w-auto d-none d-md-flex">
|
||||||
<span class="input-group-text border-0" i18n>Show:</span>
|
<label class="input-group-text border-0" for="managementPageSize" i18n>Show:</label>
|
||||||
</div>
|
</div>
|
||||||
<div class="input-group input-group-sm w-auto me-3">
|
<div class="input-group input-group-sm w-auto me-3">
|
||||||
<select class="form-select form-select-sm small" [(ngModel)]="pageSize">
|
<select id="managementPageSize" class="form-select form-select-sm small" [(ngModel)]="pageSize">
|
||||||
<option [ngValue]="25">25</option>
|
<option [ngValue]="25">25</option>
|
||||||
<option [ngValue]="50">50</option>
|
<option [ngValue]="50">50</option>
|
||||||
<option [ngValue]="100">100</option>
|
<option [ngValue]="100">100</option>
|
||||||
|
|||||||
+2
-2
@@ -83,12 +83,12 @@ describe('TagListComponent', () => {
|
|||||||
]
|
]
|
||||||
component['_nameFilter'] = null // Simulate empty name filter
|
component['_nameFilter'] = null // Simulate empty name filter
|
||||||
const filtered = component.filterData(tags as any)
|
const filtered = component.filterData(tags as any)
|
||||||
expect(filtered.length).toBe(2)
|
expect(filtered).toHaveLength(2)
|
||||||
expect(filtered.find((t) => t.id === 2)).toBeUndefined()
|
expect(filtered.find((t) => t.id === 2)).toBeUndefined()
|
||||||
|
|
||||||
component['_nameFilter'] = 'Tag2' // Simulate non-empty name filter
|
component['_nameFilter'] = 'Tag2' // Simulate non-empty name filter
|
||||||
const filteredWithName = component.filterData(tags as any)
|
const filteredWithName = component.filterData(tags as any)
|
||||||
expect(filteredWithName.length).toBe(2)
|
expect(filteredWithName).toHaveLength(2)
|
||||||
expect(filteredWithName.find((t) => t.id === 2)).toBeUndefined()
|
expect(filteredWithName.find((t) => t.id === 2)).toBeUndefined()
|
||||||
expect(
|
expect(
|
||||||
filteredWithName
|
filteredWithName
|
||||||
|
|||||||
+1
-1
@@ -26,7 +26,7 @@
|
|||||||
<th scope="col" style="width: 40px;">
|
<th scope="col" style="width: 40px;">
|
||||||
<div class="form-check m-0 ms-2 me-n2">
|
<div class="form-check m-0 ms-2 me-n2">
|
||||||
<input type="checkbox" class="form-check-input" id="all-objects" [ngModel]="toggleAllEnabled()" (ngModelChange)="toggleAllEnabled.set($event)" [disabled]="processedMails().length === 0" (click)="toggleAll($event); $event.stopPropagation();">
|
<input type="checkbox" class="form-check-input" id="all-objects" [ngModel]="toggleAllEnabled()" (ngModelChange)="toggleAllEnabled.set($event)" [disabled]="processedMails().length === 0" (click)="toggleAll($event); $event.stopPropagation();">
|
||||||
<label class="form-check-label" for="all-objects"></label>
|
<label class="form-check-label" for="all-objects"><span class="visually-hidden" i18n>Select all processed email messages</span></label>
|
||||||
</div>
|
</div>
|
||||||
</th>
|
</th>
|
||||||
<th scope="col" i18n>Subject</th>
|
<th scope="col" i18n>Subject</th>
|
||||||
|
|||||||
+1
-1
@@ -5,6 +5,6 @@
|
|||||||
|
|
||||||
pre {
|
pre {
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
word-break: break-word;
|
overflow-wrap: break-word;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -84,6 +84,28 @@ const view: SavedView = {
|
|||||||
filter_rules: filterRules,
|
filter_rules: filterRules,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const emptySelectionData = {
|
||||||
|
selected_correspondents: [],
|
||||||
|
selected_tags: [],
|
||||||
|
selected_document_types: [],
|
||||||
|
selected_storage_paths: [],
|
||||||
|
selected_custom_fields: [],
|
||||||
|
}
|
||||||
|
|
||||||
|
// A successful (non-search) list response now triggers a separate,
|
||||||
|
// non-blocking request for filter dropdown counts. Tests that flush a
|
||||||
|
// successful list response need to also flush this follow-up request.
|
||||||
|
function flushSelectionDataRequest(
|
||||||
|
httpTestingController: HttpTestingController,
|
||||||
|
querySuffix: string = ''
|
||||||
|
) {
|
||||||
|
const req = httpTestingController.expectOne(
|
||||||
|
`${environment.apiBaseUrl}documents/filter_selection_data/${querySuffix}`
|
||||||
|
)
|
||||||
|
expect(req.request.method).toEqual('GET')
|
||||||
|
req.flush(emptySelectionData)
|
||||||
|
}
|
||||||
|
|
||||||
describe('DocumentListViewService', () => {
|
describe('DocumentListViewService', () => {
|
||||||
let httpTestingController: HttpTestingController
|
let httpTestingController: HttpTestingController
|
||||||
let documentListViewService: DocumentListViewService
|
let documentListViewService: DocumentListViewService
|
||||||
@@ -105,6 +127,7 @@ describe('DocumentListViewService', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
sessionStorage.clear()
|
sessionStorage.clear()
|
||||||
|
localStorage.clear()
|
||||||
httpTestingController = TestBed.inject(HttpTestingController)
|
httpTestingController = TestBed.inject(HttpTestingController)
|
||||||
documentListViewService = TestBed.inject(DocumentListViewService)
|
documentListViewService = TestBed.inject(DocumentListViewService)
|
||||||
settingsService = TestBed.inject(SettingsService)
|
settingsService = TestBed.inject(SettingsService)
|
||||||
@@ -114,8 +137,19 @@ describe('DocumentListViewService', () => {
|
|||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
documentListViewService.cancelPending()
|
documentListViewService.cancelPending()
|
||||||
|
// A filter_selection_data request now fires concurrently with every
|
||||||
|
// non-search reload(), independent of whether the test cares about or
|
||||||
|
// flushes the primary list response. Drain any that a test didn't
|
||||||
|
// explicitly claim via flushSelectionDataRequest, so unrelated tests
|
||||||
|
// don't have to know about this follow-up request to pass verify().
|
||||||
|
httpTestingController.match(
|
||||||
|
(request) =>
|
||||||
|
request.url ===
|
||||||
|
`${environment.apiBaseUrl}documents/filter_selection_data/`
|
||||||
|
)
|
||||||
httpTestingController.verify()
|
httpTestingController.verify()
|
||||||
sessionStorage.clear()
|
sessionStorage.clear()
|
||||||
|
localStorage.clear()
|
||||||
})
|
})
|
||||||
|
|
||||||
afterAll(() => {
|
afterAll(() => {
|
||||||
@@ -128,10 +162,11 @@ describe('DocumentListViewService', () => {
|
|||||||
expect(documentListViewService.currentPage).toEqual(1)
|
expect(documentListViewService.currentPage).toEqual(1)
|
||||||
documentListViewService.reload()
|
documentListViewService.reload()
|
||||||
const req = httpTestingController.expectOne(
|
const req = httpTestingController.expectOne(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=false`
|
||||||
)
|
)
|
||||||
expect(req.request.method).toEqual('GET')
|
expect(req.request.method).toEqual('GET')
|
||||||
req.flush(full_results)
|
req.flush(full_results)
|
||||||
|
flushSelectionDataRequest(httpTestingController)
|
||||||
expect(req.request.method).toEqual('GET')
|
expect(req.request.method).toEqual('GET')
|
||||||
expect(documentListViewService.isReloading).toBeFalsy()
|
expect(documentListViewService.isReloading).toBeFalsy()
|
||||||
expect(documentListViewService.activeSavedViewId).toBeNull()
|
expect(documentListViewService.activeSavedViewId).toBeNull()
|
||||||
@@ -143,12 +178,12 @@ describe('DocumentListViewService', () => {
|
|||||||
it('should handle error on page request out of range', () => {
|
it('should handle error on page request out of range', () => {
|
||||||
documentListViewService.currentPage = 50
|
documentListViewService.currentPage = 50
|
||||||
let req = httpTestingController.expectOne(
|
let req = httpTestingController.expectOne(
|
||||||
`${environment.apiBaseUrl}documents/?page=50&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
`${environment.apiBaseUrl}documents/?page=50&page_size=50&ordering=-created&truncate_content=true&include_selection_data=false`
|
||||||
)
|
)
|
||||||
expect(req.request.method).toEqual('GET')
|
expect(req.request.method).toEqual('GET')
|
||||||
req.flush([], { status: 404, statusText: 'Unexpected error' })
|
req.flush([], { status: 404, statusText: 'Unexpected error' })
|
||||||
req = httpTestingController.expectOne(
|
req = httpTestingController.expectOne(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=false`
|
||||||
)
|
)
|
||||||
expect(req.request.method).toEqual('GET')
|
expect(req.request.method).toEqual('GET')
|
||||||
expect(documentListViewService.currentPage).toEqual(1)
|
expect(documentListViewService.currentPage).toEqual(1)
|
||||||
@@ -165,21 +200,20 @@ describe('DocumentListViewService', () => {
|
|||||||
]
|
]
|
||||||
documentListViewService.setFilterRules(filterRulesAny)
|
documentListViewService.setFilterRules(filterRulesAny)
|
||||||
let req = httpTestingController.expectOne(
|
let req = httpTestingController.expectOne(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true&tags__id__in=${tags__id__in}`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=false&tags__id__in=${tags__id__in}`
|
||||||
)
|
)
|
||||||
expect(req.request.method).toEqual('GET')
|
expect(req.request.method).toEqual('GET')
|
||||||
req.flush(
|
req.flush(
|
||||||
{ archive_serial_number: 'hello' },
|
{ archive_serial_number: 'hello' },
|
||||||
{ status: 404, statusText: 'Unexpected error' }
|
{ status: 404, statusText: 'Unexpected error' }
|
||||||
)
|
)
|
||||||
req = httpTestingController.expectOne(
|
// the error is a plain field error (not a page-out-of-range or deleted
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
// custom-field-sort case), so no automatic retry request is sent here
|
||||||
)
|
expect(documentListViewService.error).toBeTruthy()
|
||||||
expect(req.request.method).toEqual('GET')
|
|
||||||
// reset the list
|
// reset the list
|
||||||
documentListViewService.setFilterRules([])
|
documentListViewService.setFilterRules([])
|
||||||
req = httpTestingController.expectOne(
|
req = httpTestingController.expectOne(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=false`
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -187,7 +221,7 @@ describe('DocumentListViewService', () => {
|
|||||||
documentListViewService.currentPage = 1
|
documentListViewService.currentPage = 1
|
||||||
documentListViewService.sortField = 'custom_field_999'
|
documentListViewService.sortField = 'custom_field_999'
|
||||||
let req = httpTestingController.expectOne(
|
let req = httpTestingController.expectOne(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-custom_field_999&truncate_content=true&include_selection_data=true`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-custom_field_999&truncate_content=true&include_selection_data=false`
|
||||||
)
|
)
|
||||||
expect(req.request.method).toEqual('GET')
|
expect(req.request.method).toEqual('GET')
|
||||||
req.flush(
|
req.flush(
|
||||||
@@ -196,7 +230,7 @@ describe('DocumentListViewService', () => {
|
|||||||
)
|
)
|
||||||
// resets itself
|
// resets itself
|
||||||
req = httpTestingController.expectOne(
|
req = httpTestingController.expectOne(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=false`
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -211,7 +245,7 @@ describe('DocumentListViewService', () => {
|
|||||||
]
|
]
|
||||||
documentListViewService.setFilterRules(filterRulesAny)
|
documentListViewService.setFilterRules(filterRulesAny)
|
||||||
let req = httpTestingController.expectOne(
|
let req = httpTestingController.expectOne(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true&tags__id__in=${tags__id__in}`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=false&tags__id__in=${tags__id__in}`
|
||||||
)
|
)
|
||||||
expect(req.request.method).toEqual('GET')
|
expect(req.request.method).toEqual('GET')
|
||||||
req.flush('Generic error', { status: 404, statusText: 'Unexpected error' })
|
req.flush('Generic error', { status: 404, statusText: 'Unexpected error' })
|
||||||
@@ -219,7 +253,7 @@ describe('DocumentListViewService', () => {
|
|||||||
// reset the list
|
// reset the list
|
||||||
documentListViewService.setFilterRules([])
|
documentListViewService.setFilterRules([])
|
||||||
req = httpTestingController.expectOne(
|
req = httpTestingController.expectOne(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=false`
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -228,7 +262,7 @@ describe('DocumentListViewService', () => {
|
|||||||
expect(documentListViewService.sortReverse).toBeTruthy()
|
expect(documentListViewService.sortReverse).toBeTruthy()
|
||||||
documentListViewService.setSort('added', false)
|
documentListViewService.setSort('added', false)
|
||||||
let req = httpTestingController.expectOne(
|
let req = httpTestingController.expectOne(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=added&truncate_content=true&include_selection_data=true`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=added&truncate_content=true&include_selection_data=false`
|
||||||
)
|
)
|
||||||
expect(req.request.method).toEqual('GET')
|
expect(req.request.method).toEqual('GET')
|
||||||
expect(documentListViewService.sortField).toEqual('added')
|
expect(documentListViewService.sortField).toEqual('added')
|
||||||
@@ -236,12 +270,12 @@ describe('DocumentListViewService', () => {
|
|||||||
|
|
||||||
documentListViewService.sortField = 'created'
|
documentListViewService.sortField = 'created'
|
||||||
req = httpTestingController.expectOne(
|
req = httpTestingController.expectOne(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=created&truncate_content=true&include_selection_data=true`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=created&truncate_content=true&include_selection_data=false`
|
||||||
)
|
)
|
||||||
expect(documentListViewService.sortField).toEqual('created')
|
expect(documentListViewService.sortField).toEqual('created')
|
||||||
documentListViewService.sortReverse = true
|
documentListViewService.sortReverse = true
|
||||||
req = httpTestingController.expectOne(
|
req = httpTestingController.expectOne(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=false`
|
||||||
)
|
)
|
||||||
expect(req.request.method).toEqual('GET')
|
expect(req.request.method).toEqual('GET')
|
||||||
expect(documentListViewService.sortReverse).toBeTruthy()
|
expect(documentListViewService.sortReverse).toBeTruthy()
|
||||||
@@ -284,7 +318,7 @@ describe('DocumentListViewService', () => {
|
|||||||
const req = httpTestingController.expectOne(
|
const req = httpTestingController.expectOne(
|
||||||
`${environment.apiBaseUrl}documents/?page=${page}&page_size=${
|
`${environment.apiBaseUrl}documents/?page=${page}&page_size=${
|
||||||
documentListViewService.pageSize
|
documentListViewService.pageSize
|
||||||
}&ordering=${reverse ? '-' : ''}${sort}&truncate_content=true&include_selection_data=true`
|
}&ordering=${reverse ? '-' : ''}${sort}&truncate_content=true&include_selection_data=false`
|
||||||
)
|
)
|
||||||
expect(req.request.method).toEqual('GET')
|
expect(req.request.method).toEqual('GET')
|
||||||
expect(documentListViewService.currentPage).toEqual(page)
|
expect(documentListViewService.currentPage).toEqual(page)
|
||||||
@@ -301,7 +335,7 @@ describe('DocumentListViewService', () => {
|
|||||||
}
|
}
|
||||||
documentListViewService.loadFromQueryParams(convertToParamMap(params))
|
documentListViewService.loadFromQueryParams(convertToParamMap(params))
|
||||||
let req = httpTestingController.expectOne(
|
let req = httpTestingController.expectOne(
|
||||||
`${environment.apiBaseUrl}documents/?page=${documentListViewService.currentPage}&page_size=${documentListViewService.pageSize}&ordering=-added&truncate_content=true&include_selection_data=true&tags__id__all=${tags__id__all}`
|
`${environment.apiBaseUrl}documents/?page=${documentListViewService.currentPage}&page_size=${documentListViewService.pageSize}&ordering=-added&truncate_content=true&include_selection_data=false&tags__id__all=${tags__id__all}`
|
||||||
)
|
)
|
||||||
expect(req.request.method).toEqual('GET')
|
expect(req.request.method).toEqual('GET')
|
||||||
expect(documentListViewService.filterRules).toEqual([
|
expect(documentListViewService.filterRules).toEqual([
|
||||||
@@ -311,12 +345,16 @@ describe('DocumentListViewService', () => {
|
|||||||
},
|
},
|
||||||
])
|
])
|
||||||
req.flush(full_results)
|
req.flush(full_results)
|
||||||
|
flushSelectionDataRequest(
|
||||||
|
httpTestingController,
|
||||||
|
`?tags__id__all=${tags__id__all}`
|
||||||
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should use filter rules to update query params', () => {
|
it('should use filter rules to update query params', () => {
|
||||||
documentListViewService.setFilterRules(filterRules)
|
documentListViewService.setFilterRules(filterRules)
|
||||||
const req = httpTestingController.expectOne(
|
const req = httpTestingController.expectOne(
|
||||||
`${environment.apiBaseUrl}documents/?page=${documentListViewService.currentPage}&page_size=${documentListViewService.pageSize}&ordering=-created&truncate_content=true&include_selection_data=true&tags__id__all=${tags__id__all}`
|
`${environment.apiBaseUrl}documents/?page=${documentListViewService.currentPage}&page_size=${documentListViewService.pageSize}&ordering=-created&truncate_content=true&include_selection_data=false&tags__id__all=${tags__id__all}`
|
||||||
)
|
)
|
||||||
expect(req.request.method).toEqual('GET')
|
expect(req.request.method).toEqual('GET')
|
||||||
})
|
})
|
||||||
@@ -325,26 +363,31 @@ describe('DocumentListViewService', () => {
|
|||||||
documentListViewService.currentPage = 2
|
documentListViewService.currentPage = 2
|
||||||
let req = httpTestingController.expectOne((request) =>
|
let req = httpTestingController.expectOne((request) =>
|
||||||
request.urlWithParams.startsWith(
|
request.urlWithParams.startsWith(
|
||||||
`${environment.apiBaseUrl}documents/?page=2&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
`${environment.apiBaseUrl}documents/?page=2&page_size=50&ordering=-created&truncate_content=true&include_selection_data=false`
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
expect(req.request.method).toEqual('GET')
|
expect(req.request.method).toEqual('GET')
|
||||||
req.flush(full_results)
|
req.flush(full_results)
|
||||||
|
flushSelectionDataRequest(httpTestingController)
|
||||||
|
|
||||||
documentListViewService.setFilterRules(filterRules, true)
|
documentListViewService.setFilterRules(filterRules, true)
|
||||||
|
|
||||||
const filteredReqs = httpTestingController.match(
|
const filteredReqs = httpTestingController.match(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true&tags__id__all=${tags__id__all}`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=false&tags__id__all=${tags__id__all}`
|
||||||
)
|
)
|
||||||
expect(filteredReqs).toHaveLength(1)
|
expect(filteredReqs).toHaveLength(1)
|
||||||
filteredReqs[0].flush(full_results)
|
filteredReqs[0].flush(full_results)
|
||||||
|
flushSelectionDataRequest(
|
||||||
|
httpTestingController,
|
||||||
|
`?tags__id__all=${tags__id__all}`
|
||||||
|
)
|
||||||
expect(documentListViewService.currentPage).toEqual(1)
|
expect(documentListViewService.currentPage).toEqual(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should support quick filter', () => {
|
it('should support quick filter', () => {
|
||||||
documentListViewService.quickFilter(filterRules)
|
documentListViewService.quickFilter(filterRules)
|
||||||
const req = httpTestingController.expectOne(
|
const req = httpTestingController.expectOne(
|
||||||
`${environment.apiBaseUrl}documents/?page=${documentListViewService.currentPage}&page_size=${documentListViewService.pageSize}&ordering=-created&truncate_content=true&include_selection_data=true&tags__id__all=${tags__id__all}`
|
`${environment.apiBaseUrl}documents/?page=${documentListViewService.currentPage}&page_size=${documentListViewService.pageSize}&ordering=-created&truncate_content=true&include_selection_data=false&tags__id__all=${tags__id__all}`
|
||||||
)
|
)
|
||||||
expect(req.request.method).toEqual('GET')
|
expect(req.request.method).toEqual('GET')
|
||||||
})
|
})
|
||||||
@@ -367,21 +410,21 @@ describe('DocumentListViewService', () => {
|
|||||||
convertToParamMap(params)
|
convertToParamMap(params)
|
||||||
)
|
)
|
||||||
let req = httpTestingController.expectOne(
|
let req = httpTestingController.expectOne(
|
||||||
`${environment.apiBaseUrl}documents/?page=${page}&page_size=${documentListViewService.pageSize}&ordering=-added&truncate_content=true&include_selection_data=true&tags__id__all=${tags__id__all}`
|
`${environment.apiBaseUrl}documents/?page=${page}&page_size=${documentListViewService.pageSize}&ordering=-added&truncate_content=true&include_selection_data=false&tags__id__all=${tags__id__all}`
|
||||||
)
|
)
|
||||||
expect(req.request.method).toEqual('GET')
|
expect(req.request.method).toEqual('GET')
|
||||||
// reset the list
|
// reset the list
|
||||||
documentListViewService.currentPage = 1
|
documentListViewService.currentPage = 1
|
||||||
req = httpTestingController.expectOne(
|
req = httpTestingController.expectOne(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-added&truncate_content=true&include_selection_data=true&tags__id__all=9`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-added&truncate_content=true&include_selection_data=false&tags__id__all=9`
|
||||||
)
|
)
|
||||||
documentListViewService.setFilterRules([])
|
documentListViewService.setFilterRules([])
|
||||||
req = httpTestingController.expectOne(
|
req = httpTestingController.expectOne(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-added&truncate_content=true&include_selection_data=true`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-added&truncate_content=true&include_selection_data=false`
|
||||||
)
|
)
|
||||||
documentListViewService.sortField = 'created'
|
documentListViewService.sortField = 'created'
|
||||||
req = httpTestingController.expectOne(
|
req = httpTestingController.expectOne(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=false`
|
||||||
)
|
)
|
||||||
documentListViewService.activateSavedView(null)
|
documentListViewService.activateSavedView(null)
|
||||||
})
|
})
|
||||||
@@ -389,18 +432,22 @@ describe('DocumentListViewService', () => {
|
|||||||
it('should support navigating next / previous', () => {
|
it('should support navigating next / previous', () => {
|
||||||
documentListViewService.setFilterRules([])
|
documentListViewService.setFilterRules([])
|
||||||
let req = httpTestingController.expectOne(
|
let req = httpTestingController.expectOne(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=false`
|
||||||
)
|
)
|
||||||
expect(documentListViewService.currentPage).toEqual(1)
|
expect(documentListViewService.currentPage).toEqual(1)
|
||||||
documentListViewService.pageSize = 3
|
documentListViewService.pageSize = 3
|
||||||
req = httpTestingController.expectOne(
|
req = httpTestingController.expectOne(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=3&ordering=-created&truncate_content=true&include_selection_data=true`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=3&ordering=-created&truncate_content=true&include_selection_data=false`
|
||||||
)
|
)
|
||||||
expect(req.request.method).toEqual('GET')
|
expect(req.request.method).toEqual('GET')
|
||||||
req.flush({
|
req.flush({
|
||||||
count: 3,
|
count: 3,
|
||||||
results: documents.slice(0, 3),
|
results: documents.slice(0, 3),
|
||||||
})
|
})
|
||||||
|
// two reload()s ran above (setFilterRules, then pageSize), each firing
|
||||||
|
// its own concurrent filter_selection_data request with an identical
|
||||||
|
// (unfiltered) URL; this test doesn't assert on selectionData, so let
|
||||||
|
// afterEach's drain step clean both up rather than disambiguating here.
|
||||||
expect(documentListViewService.hasNext(documents[0].id)).toBeTruthy()
|
expect(documentListViewService.hasNext(documents[0].id)).toBeTruthy()
|
||||||
expect(documentListViewService.hasPrevious(documents[0].id)).toBeFalsy()
|
expect(documentListViewService.hasPrevious(documents[0].id)).toBeFalsy()
|
||||||
documentListViewService.getNext(documents[0].id).subscribe((docId) => {
|
documentListViewService.getNext(documents[0].id).subscribe((docId) => {
|
||||||
@@ -447,7 +494,7 @@ describe('DocumentListViewService', () => {
|
|||||||
expect(documentListViewService.currentPage).toEqual(1)
|
expect(documentListViewService.currentPage).toEqual(1)
|
||||||
documentListViewService.pageSize = 3
|
documentListViewService.pageSize = 3
|
||||||
httpTestingController.match(
|
httpTestingController.match(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=3&ordering=-created&truncate_content=true&include_selection_data=true`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=3&ordering=-created&truncate_content=true&include_selection_data=false`
|
||||||
)
|
)
|
||||||
jest
|
jest
|
||||||
.spyOn(documentListViewService, 'getLastPage')
|
.spyOn(documentListViewService, 'getLastPage')
|
||||||
@@ -462,7 +509,7 @@ describe('DocumentListViewService', () => {
|
|||||||
expect(reloadSpy).toHaveBeenCalled()
|
expect(reloadSpy).toHaveBeenCalled()
|
||||||
expect(documentListViewService.currentPage).toEqual(2)
|
expect(documentListViewService.currentPage).toEqual(2)
|
||||||
const reqs = httpTestingController.match(
|
const reqs = httpTestingController.match(
|
||||||
`${environment.apiBaseUrl}documents/?page=2&page_size=3&ordering=-created&truncate_content=true&include_selection_data=true`
|
`${environment.apiBaseUrl}documents/?page=2&page_size=3&ordering=-created&truncate_content=true&include_selection_data=false`
|
||||||
)
|
)
|
||||||
expect(reqs.length).toBeGreaterThan(0)
|
expect(reqs.length).toBeGreaterThan(0)
|
||||||
})
|
})
|
||||||
@@ -497,11 +544,11 @@ describe('DocumentListViewService', () => {
|
|||||||
.mockReturnValue(documents)
|
.mockReturnValue(documents)
|
||||||
documentListViewService.currentPage = 2
|
documentListViewService.currentPage = 2
|
||||||
httpTestingController.match(
|
httpTestingController.match(
|
||||||
`${environment.apiBaseUrl}documents/?page=2&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
`${environment.apiBaseUrl}documents/?page=2&page_size=50&ordering=-created&truncate_content=true&include_selection_data=false`
|
||||||
)
|
)
|
||||||
documentListViewService.pageSize = 3
|
documentListViewService.pageSize = 3
|
||||||
httpTestingController.match(
|
httpTestingController.match(
|
||||||
`${environment.apiBaseUrl}documents/?page=2&page_size=3&ordering=-created&truncate_content=true&include_selection_data=true`
|
`${environment.apiBaseUrl}documents/?page=2&page_size=3&ordering=-created&truncate_content=true&include_selection_data=false`
|
||||||
)
|
)
|
||||||
const reloadSpy = jest.spyOn(documentListViewService, 'reload')
|
const reloadSpy = jest.spyOn(documentListViewService, 'reload')
|
||||||
documentListViewService.getPrevious(1).subscribe({
|
documentListViewService.getPrevious(1).subscribe({
|
||||||
@@ -511,7 +558,7 @@ describe('DocumentListViewService', () => {
|
|||||||
expect(reloadSpy).toHaveBeenCalled()
|
expect(reloadSpy).toHaveBeenCalled()
|
||||||
expect(documentListViewService.currentPage).toEqual(1)
|
expect(documentListViewService.currentPage).toEqual(1)
|
||||||
const reqs = httpTestingController.match(
|
const reqs = httpTestingController.match(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=3&ordering=-created&truncate_content=true&include_selection_data=true`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=3&ordering=-created&truncate_content=true&include_selection_data=false`
|
||||||
)
|
)
|
||||||
expect(reqs.length).toBeGreaterThan(0)
|
expect(reqs.length).toBeGreaterThan(0)
|
||||||
})
|
})
|
||||||
@@ -524,10 +571,11 @@ describe('DocumentListViewService', () => {
|
|||||||
it('should support select a document', () => {
|
it('should support select a document', () => {
|
||||||
documentListViewService.reload()
|
documentListViewService.reload()
|
||||||
const req = httpTestingController.expectOne(
|
const req = httpTestingController.expectOne(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=false`
|
||||||
)
|
)
|
||||||
expect(req.request.method).toEqual('GET')
|
expect(req.request.method).toEqual('GET')
|
||||||
req.flush(full_results)
|
req.flush(full_results)
|
||||||
|
flushSelectionDataRequest(httpTestingController)
|
||||||
documentListViewService.toggleSelected(documents[0])
|
documentListViewService.toggleSelected(documents[0])
|
||||||
expect(documentListViewService.isSelected(documents[0])).toBeTruthy()
|
expect(documentListViewService.isSelected(documents[0])).toBeTruthy()
|
||||||
documentListViewService.toggleSelected(documents[0])
|
documentListViewService.toggleSelected(documents[0])
|
||||||
@@ -537,10 +585,11 @@ describe('DocumentListViewService', () => {
|
|||||||
it('should support select all', () => {
|
it('should support select all', () => {
|
||||||
documentListViewService.reload()
|
documentListViewService.reload()
|
||||||
const reloadReq = httpTestingController.expectOne(
|
const reloadReq = httpTestingController.expectOne(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=false`
|
||||||
)
|
)
|
||||||
expect(reloadReq.request.method).toEqual('GET')
|
expect(reloadReq.request.method).toEqual('GET')
|
||||||
reloadReq.flush(full_results)
|
reloadReq.flush(full_results)
|
||||||
|
flushSelectionDataRequest(httpTestingController)
|
||||||
|
|
||||||
documentListViewService.selectAll()
|
documentListViewService.selectAll()
|
||||||
expect(documentListViewService.allSelected).toBeTruthy()
|
expect(documentListViewService.allSelected).toBeTruthy()
|
||||||
@@ -553,13 +602,14 @@ describe('DocumentListViewService', () => {
|
|||||||
it('should support select page', () => {
|
it('should support select page', () => {
|
||||||
documentListViewService.pageSize = 3
|
documentListViewService.pageSize = 3
|
||||||
const req = httpTestingController.expectOne(
|
const req = httpTestingController.expectOne(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=3&ordering=-created&truncate_content=true&include_selection_data=true`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=3&ordering=-created&truncate_content=true&include_selection_data=false`
|
||||||
)
|
)
|
||||||
expect(req.request.method).toEqual('GET')
|
expect(req.request.method).toEqual('GET')
|
||||||
req.flush({
|
req.flush({
|
||||||
count: 3,
|
count: 3,
|
||||||
results: documents.slice(0, 3),
|
results: documents.slice(0, 3),
|
||||||
})
|
})
|
||||||
|
flushSelectionDataRequest(httpTestingController)
|
||||||
documentListViewService.selectPage()
|
documentListViewService.selectPage()
|
||||||
expect(documentListViewService.selected.size).toEqual(3)
|
expect(documentListViewService.selected.size).toEqual(3)
|
||||||
expect(documentListViewService.isSelected(documents[5])).toBeFalsy()
|
expect(documentListViewService.isSelected(documents[5])).toBeFalsy()
|
||||||
@@ -568,10 +618,11 @@ describe('DocumentListViewService', () => {
|
|||||||
it('should support select range', () => {
|
it('should support select range', () => {
|
||||||
documentListViewService.reload()
|
documentListViewService.reload()
|
||||||
const req = httpTestingController.expectOne(
|
const req = httpTestingController.expectOne(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=false`
|
||||||
)
|
)
|
||||||
expect(req.request.method).toEqual('GET')
|
expect(req.request.method).toEqual('GET')
|
||||||
req.flush(full_results)
|
req.flush(full_results)
|
||||||
|
flushSelectionDataRequest(httpTestingController)
|
||||||
documentListViewService.toggleSelected(documents[0])
|
documentListViewService.toggleSelected(documents[0])
|
||||||
expect(documentListViewService.isSelected(documents[0])).toBeTruthy()
|
expect(documentListViewService.isSelected(documents[0])).toBeTruthy()
|
||||||
documentListViewService.selectRangeTo(documents[2])
|
documentListViewService.selectRangeTo(documents[2])
|
||||||
@@ -583,9 +634,10 @@ describe('DocumentListViewService', () => {
|
|||||||
it('should clear all-selected mode when toggling a single document', () => {
|
it('should clear all-selected mode when toggling a single document', () => {
|
||||||
documentListViewService.reload()
|
documentListViewService.reload()
|
||||||
const req = httpTestingController.expectOne(
|
const req = httpTestingController.expectOne(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=false`
|
||||||
)
|
)
|
||||||
req.flush(full_results)
|
req.flush(full_results)
|
||||||
|
flushSelectionDataRequest(httpTestingController)
|
||||||
|
|
||||||
documentListViewService.selectAll()
|
documentListViewService.selectAll()
|
||||||
expect(documentListViewService.allSelected).toBeTruthy()
|
expect(documentListViewService.allSelected).toBeTruthy()
|
||||||
@@ -599,9 +651,10 @@ describe('DocumentListViewService', () => {
|
|||||||
it('should clear all-selected mode when selecting a range', () => {
|
it('should clear all-selected mode when selecting a range', () => {
|
||||||
documentListViewService.reload()
|
documentListViewService.reload()
|
||||||
const req = httpTestingController.expectOne(
|
const req = httpTestingController.expectOne(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=false`
|
||||||
)
|
)
|
||||||
req.flush(full_results)
|
req.flush(full_results)
|
||||||
|
flushSelectionDataRequest(httpTestingController)
|
||||||
|
|
||||||
documentListViewService.selectAll()
|
documentListViewService.selectAll()
|
||||||
documentListViewService.toggleSelected(documents[1])
|
documentListViewService.toggleSelected(documents[1])
|
||||||
@@ -619,22 +672,24 @@ describe('DocumentListViewService', () => {
|
|||||||
it('should support selection range reduction', () => {
|
it('should support selection range reduction', () => {
|
||||||
documentListViewService.reload()
|
documentListViewService.reload()
|
||||||
let req = httpTestingController.expectOne(
|
let req = httpTestingController.expectOne(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=false`
|
||||||
)
|
)
|
||||||
expect(req.request.method).toEqual('GET')
|
expect(req.request.method).toEqual('GET')
|
||||||
req.flush(full_results)
|
req.flush(full_results)
|
||||||
|
flushSelectionDataRequest(httpTestingController)
|
||||||
|
|
||||||
documentListViewService.selectAll()
|
documentListViewService.selectAll()
|
||||||
expect(documentListViewService.selected.size).toEqual(6)
|
expect(documentListViewService.selected.size).toEqual(6)
|
||||||
|
|
||||||
documentListViewService.setFilterRules(filterRules)
|
documentListViewService.setFilterRules(filterRules)
|
||||||
req = httpTestingController.expectOne(
|
req = httpTestingController.expectOne(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true&tags__id__all=9`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=false&tags__id__all=9`
|
||||||
)
|
)
|
||||||
req.flush({
|
req.flush({
|
||||||
count: 3,
|
count: 3,
|
||||||
results: documents.slice(0, 3),
|
results: documents.slice(0, 3),
|
||||||
})
|
})
|
||||||
|
flushSelectionDataRequest(httpTestingController, '?tags__id__all=9')
|
||||||
expect(documentListViewService.allSelected).toBeTruthy()
|
expect(documentListViewService.allSelected).toBeTruthy()
|
||||||
expect(documentListViewService.selected.size).toEqual(3)
|
expect(documentListViewService.selected.size).toEqual(3)
|
||||||
})
|
})
|
||||||
@@ -643,7 +698,7 @@ describe('DocumentListViewService', () => {
|
|||||||
const cancelSpy = jest.spyOn(documentListViewService, 'cancelPending')
|
const cancelSpy = jest.spyOn(documentListViewService, 'cancelPending')
|
||||||
documentListViewService.reload()
|
documentListViewService.reload()
|
||||||
httpTestingController.expectOne(
|
httpTestingController.expectOne(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true&tags__id__all=9`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=false`
|
||||||
)
|
)
|
||||||
expect(cancelSpy).toHaveBeenCalled()
|
expect(cancelSpy).toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
@@ -662,7 +717,7 @@ describe('DocumentListViewService', () => {
|
|||||||
documentListViewService.setFilterRules([])
|
documentListViewService.setFilterRules([])
|
||||||
expect(documentListViewService.sortField).toEqual('created')
|
expect(documentListViewService.sortField).toEqual('created')
|
||||||
httpTestingController.expectOne(
|
httpTestingController.expectOne(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=false`
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -689,11 +744,11 @@ describe('DocumentListViewService', () => {
|
|||||||
expect(localStorageSpy).toHaveBeenCalled()
|
expect(localStorageSpy).toHaveBeenCalled()
|
||||||
// reload triggered
|
// reload triggered
|
||||||
httpTestingController.match(
|
httpTestingController.match(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=false`
|
||||||
)
|
)
|
||||||
documentListViewService.displayFields = null
|
documentListViewService.displayFields = null
|
||||||
httpTestingController.match(
|
httpTestingController.match(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=false`
|
||||||
)
|
)
|
||||||
expect(documentListViewService.displayFields).toEqual(
|
expect(documentListViewService.displayFields).toEqual(
|
||||||
DEFAULT_DISPLAY_FIELDS.filter((f) => f.id !== DisplayField.ADDED).map(
|
DEFAULT_DISPLAY_FIELDS.filter((f) => f.id !== DisplayField.ADDED).map(
|
||||||
@@ -738,7 +793,7 @@ describe('DocumentListViewService', () => {
|
|||||||
it('should generate quick filter URL preserving default state', () => {
|
it('should generate quick filter URL preserving default state', () => {
|
||||||
documentListViewService.reload()
|
documentListViewService.reload()
|
||||||
httpTestingController.expectOne(
|
httpTestingController.expectOne(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=false`
|
||||||
)
|
)
|
||||||
const urlTree = documentListViewService.getQuickFilterUrl(filterRules)
|
const urlTree = documentListViewService.getQuickFilterUrl(filterRules)
|
||||||
expect(urlTree).toBeDefined()
|
expect(urlTree).toBeDefined()
|
||||||
|
|||||||
@@ -314,12 +314,39 @@ export class DocumentListViewService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private loadFilterSelectionData(filterRules: FilterRule[]) {
|
||||||
|
this.documentService
|
||||||
|
.getFilterSelectionData(filterRules)
|
||||||
|
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||||
|
.subscribe({
|
||||||
|
next: (selectionData) => {
|
||||||
|
this.selectionData = selectionData
|
||||||
|
this.markChanged()
|
||||||
|
},
|
||||||
|
error: () => {
|
||||||
|
this.selectionData = null
|
||||||
|
this.markChanged()
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
reload(onFinish?, updateQueryParams: boolean = true) {
|
reload(onFinish?, updateQueryParams: boolean = true) {
|
||||||
this.cancelPending()
|
this.cancelPending()
|
||||||
this.isReloading = true
|
this.isReloading = true
|
||||||
this.error = null
|
this.error = null
|
||||||
this.markChanged()
|
this.markChanged()
|
||||||
let activeListViewState = this.activeListViewState
|
let activeListViewState = this.activeListViewState
|
||||||
|
// Full-text search results are already narrowed by the search backend, so
|
||||||
|
// computing selection data inline there is cheap. A plain (unfiltered or
|
||||||
|
// ORM-filtered) browse can span the entire document set, so its selection
|
||||||
|
// data is fetched separately -- concurrently with the list itself, rather
|
||||||
|
// than blocking or waiting on it.
|
||||||
|
const isFullTextSearch = isFullTextFilterRule(
|
||||||
|
activeListViewState.filterRules
|
||||||
|
)
|
||||||
|
if (!isFullTextSearch) {
|
||||||
|
this.loadFilterSelectionData(activeListViewState.filterRules)
|
||||||
|
}
|
||||||
this.documentService
|
this.documentService
|
||||||
.listFiltered(
|
.listFiltered(
|
||||||
activeListViewState.currentPage,
|
activeListViewState.currentPage,
|
||||||
@@ -327,17 +354,22 @@ export class DocumentListViewService {
|
|||||||
activeListViewState.sortField,
|
activeListViewState.sortField,
|
||||||
activeListViewState.sortReverse,
|
activeListViewState.sortReverse,
|
||||||
activeListViewState.filterRules,
|
activeListViewState.filterRules,
|
||||||
{ truncate_content: true, include_selection_data: true }
|
{
|
||||||
|
truncate_content: true,
|
||||||
|
include_selection_data: isFullTextSearch,
|
||||||
|
}
|
||||||
)
|
)
|
||||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||||
.subscribe({
|
.subscribe({
|
||||||
next: (result) => {
|
next: (result) => {
|
||||||
const resultWithSelectionData = result as DocumentResults
|
|
||||||
this.initialized = true
|
this.initialized = true
|
||||||
this.isReloading = false
|
this.isReloading = false
|
||||||
activeListViewState.collectionSize = result.count
|
activeListViewState.collectionSize = result.count
|
||||||
activeListViewState.documents = result.results
|
activeListViewState.documents = result.results
|
||||||
this.selectionData = resultWithSelectionData.selection_data ?? null
|
if (isFullTextSearch) {
|
||||||
|
this.selectionData =
|
||||||
|
(result as DocumentResults).selection_data ?? null
|
||||||
|
}
|
||||||
this.syncSelectedToCurrentPage()
|
this.syncSelectedToCurrentPage()
|
||||||
this.markChanged()
|
this.markChanged()
|
||||||
|
|
||||||
@@ -376,6 +408,9 @@ export class DocumentListViewService {
|
|||||||
// e.g. field was deleted
|
// e.g. field was deleted
|
||||||
this.sortField = 'created'
|
this.sortField = 'created'
|
||||||
} else {
|
} else {
|
||||||
|
// cancel the concurrently-fired selection-data request too, so it
|
||||||
|
// can't resolve afterward and clobber this reset with stale data
|
||||||
|
this.cancelPending()
|
||||||
this.selectionData = null
|
this.selectionData = null
|
||||||
let errorMessage
|
let errorMessage
|
||||||
if (
|
if (
|
||||||
|
|||||||
@@ -41,6 +41,24 @@ export abstract class AbstractPaperlessService<T extends ObjectWithId> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merges a plain params object into an HttpParams instance, skipping
|
||||||
|
* null/undefined values so they aren't serialized as literal "null" /
|
||||||
|
* "undefined" query string entries.
|
||||||
|
*/
|
||||||
|
protected withParams(
|
||||||
|
params,
|
||||||
|
base: HttpParams = new HttpParams()
|
||||||
|
): HttpParams {
|
||||||
|
let httpParams = base
|
||||||
|
for (let key in params) {
|
||||||
|
if (params[key] != null) {
|
||||||
|
httpParams = httpParams.set(key, params[key])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return httpParams
|
||||||
|
}
|
||||||
|
|
||||||
list(
|
list(
|
||||||
page?: number,
|
page?: number,
|
||||||
pageSize?: number,
|
pageSize?: number,
|
||||||
@@ -60,11 +78,7 @@ export abstract class AbstractPaperlessService<T extends ObjectWithId> {
|
|||||||
if (ordering) {
|
if (ordering) {
|
||||||
httpParams = httpParams.set('ordering', ordering)
|
httpParams = httpParams.set('ordering', ordering)
|
||||||
}
|
}
|
||||||
for (let extraParamKey in extraParams) {
|
httpParams = this.withParams(extraParams, httpParams)
|
||||||
if (extraParams[extraParamKey] != null) {
|
|
||||||
httpParams = httpParams.set(extraParamKey, extraParams[extraParamKey])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return this.http
|
return this.http
|
||||||
.get<Results<T>>(this.getResourceUrl(), {
|
.get<Results<T>>(this.getResourceUrl(), {
|
||||||
params: httpParams,
|
params: httpParams,
|
||||||
@@ -113,11 +127,7 @@ export abstract class AbstractPaperlessService<T extends ObjectWithId> {
|
|||||||
httpParams = httpParams.set('id__in', ids.join(','))
|
httpParams = httpParams.set('id__in', ids.join(','))
|
||||||
httpParams = httpParams.set('ordering', '-id')
|
httpParams = httpParams.set('ordering', '-id')
|
||||||
httpParams = httpParams.set('page_size', 1000)
|
httpParams = httpParams.set('page_size', 1000)
|
||||||
for (let extraParamKey in extraParams) {
|
httpParams = this.withParams(extraParams, httpParams)
|
||||||
if (extraParams[extraParamKey] != null) {
|
|
||||||
httpParams = httpParams.set(extraParamKey, extraParams[extraParamKey])
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return this.http
|
return this.http
|
||||||
.get<Results<T>>(this.getResourceUrl(), {
|
.get<Results<T>>(this.getResourceUrl(), {
|
||||||
params: httpParams,
|
params: httpParams,
|
||||||
|
|||||||
@@ -398,6 +398,13 @@ export class DocumentService extends AbstractPaperlessService<Document> {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getFilterSelectionData(filterRules: FilterRule[]): Observable<SelectionData> {
|
||||||
|
return this.http.get<SelectionData>(
|
||||||
|
this.getResourceUrl(null, 'filter_selection_data'),
|
||||||
|
{ params: this.withParams(queryParamsFromFilterRules(filterRules)) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
getSuggestions(id: number): Observable<DocumentSuggestions> {
|
getSuggestions(id: number): Observable<DocumentSuggestions> {
|
||||||
return this.http.get<DocumentSuggestions>(
|
return this.http.get<DocumentSuggestions>(
|
||||||
this.getResourceUrl(id, 'suggestions')
|
this.getResourceUrl(id, 'suggestions')
|
||||||
|
|||||||
@@ -1,12 +1,7 @@
|
|||||||
import { normalizeSync } from 'normalize-diacritics'
|
import { normalizeSync } from 'normalize-diacritics'
|
||||||
|
|
||||||
export type SearchTextValue =
|
export type SearchTextValue =
|
||||||
| string
|
string | number | boolean | bigint | null | undefined
|
||||||
| number
|
|
||||||
| boolean
|
|
||||||
| bigint
|
|
||||||
| null
|
|
||||||
| undefined
|
|
||||||
|
|
||||||
export function normalizeSearchText(value: SearchTextValue): string {
|
export function normalizeSearchText(value: SearchTextValue): string {
|
||||||
return normalizeSync(String(value ?? '')).toLocaleLowerCase()
|
return normalizeSync(String(value ?? '')).toLocaleLowerCase()
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user