mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-11-03 21:39:22 +00:00
Compare commits
110
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
dbb19812f8 | ||
|
|
cc15aa0180 | ||
|
|
e6ea046e31 | ||
|
|
04bae0af62 | ||
|
|
d2f4cfa783 | ||
|
|
7cdf8ec3c2 | ||
|
|
786eac0b22 | ||
|
|
16b79fc023 | ||
|
|
cd58cffd89 | ||
|
|
2c82b06b93 | ||
|
|
5486fa7c40 | ||
|
|
fefe4e5802 | ||
|
|
936f9363a0 | ||
|
|
1b8b861a60 | ||
|
|
c2b25bdd93 | ||
|
|
dddb1f9014 | ||
|
|
6ac609c64e | ||
|
|
4d2c733f21 | ||
|
|
90bddfcb88 | ||
|
|
0de2cb8372 | ||
|
|
37168b927e | ||
|
|
ef3c9f69fe | ||
|
|
3586c5747b | ||
|
|
f8dc15c43d | ||
|
|
bfb8b9076b | ||
|
|
cef246a761 | ||
|
|
5b63c9a0ff | ||
|
|
217c5e681d | ||
|
|
45e99cde5a | ||
|
|
7ea58eaf46 | ||
|
|
a19804a0b7 | ||
|
|
3289be7383 | ||
|
|
d98918dd97 | ||
|
|
34c88613f6 | ||
|
|
13acdabb47 | ||
|
|
b67cfdf3fd | ||
|
|
98d2ddd1c6 | ||
|
|
91b35eada6 | ||
|
|
11248f776d | ||
|
|
331fa13088 | ||
|
|
ebddd5687c | ||
|
|
2925b8de81 | ||
|
|
5e397e1222 | ||
|
|
6f99ad9469 | ||
|
|
a5d3247ca7 | ||
|
|
8a67481a9d | ||
|
|
652f964f5d | ||
|
|
80a1827fa0 | ||
|
|
e240c89f27 | ||
|
|
ced3cf9303 | ||
|
|
08a7d1d4b8 | ||
|
|
47052d6506 | ||
|
|
9bd28d3e57 | ||
|
|
9c4c9f2386 | ||
|
|
cc54e3e5aa | ||
|
|
67c194978e | ||
|
|
c21bdab5c8 | ||
|
|
ea9e5e0732 | ||
|
|
ac955efdbf | ||
|
|
ec52490e4a | ||
|
|
fed9e8d783 | ||
|
|
d4457efe44 | ||
|
|
7d8147c21d | ||
|
|
5f17b1a310 | ||
|
|
63eca7b427 | ||
|
|
c88d02ab42 | ||
|
|
4299e9b12e | ||
|
|
10a1a9eae8 | ||
|
|
e7d70497c2 | ||
|
|
a74256cd2a | ||
|
|
f71a9a6eda | ||
|
|
f319b05e0f | ||
|
|
558cb01a1d | ||
|
|
59e037fed2 | ||
|
|
56ae227de3 | ||
|
|
de61c20197 | ||
|
|
c0dbe1f040 | ||
|
|
35ba0b36a8 | ||
|
|
7bdbcca051 | ||
|
|
9b56dc2f61 | ||
|
|
d872e456ab | ||
|
|
e2b884d353 | ||
|
|
97c98ee44c | ||
|
|
232f9c8dcc | ||
|
|
f25fd5365f | ||
|
|
a3b358bad7 | ||
|
|
376e865989 | ||
|
|
ca5e2df7f0 | ||
|
|
b904881472 | ||
|
|
040ceb8503 | ||
|
|
c089bba32c | ||
|
|
a05afd654c | ||
|
|
f730513e79 | ||
|
|
d4826f50c9 | ||
|
|
8bbca8a269 | ||
|
|
3374debd6a | ||
|
|
f8e93a535a | ||
|
|
98fb7c3b63 | ||
|
|
c657fceea5 | ||
|
|
d76943d57c | ||
|
|
a4003df7e8 | ||
|
|
f6f420a491 | ||
|
|
c105486b6a | ||
|
|
8ea31cb3ce | ||
|
|
4c124caee1 | ||
|
|
9e83864f83 | ||
|
|
7b53a4b316 | ||
|
|
20b7a116fd | ||
|
|
a5c8b82da5 | ||
|
|
8d3902454e |
No files matched your search
@@ -39,7 +39,6 @@ ARG RUNTIME_PACKAGES="\
|
|||||||
ghostscript \
|
ghostscript \
|
||||||
gnupg \
|
gnupg \
|
||||||
icc-profiles-free \
|
icc-profiles-free \
|
||||||
imagemagick \
|
|
||||||
# PostgreSQL
|
# PostgreSQL
|
||||||
postgresql-client \
|
postgresql-client \
|
||||||
# MySQL / MariaDB
|
# MySQL / MariaDB
|
||||||
@@ -93,21 +92,6 @@ RUN set -eux \
|
|||||||
https://github.com/paperless-ngx/builder/releases/download/jbig2enc-trixie-v${JBIG2ENC_VERSION}/jbig2enc_${JBIG2ENC_VERSION}-1_${TARGETARCH}.deb \
|
https://github.com/paperless-ngx/builder/releases/download/jbig2enc-trixie-v${JBIG2ENC_VERSION}/jbig2enc_${JBIG2ENC_VERSION}-1_${TARGETARCH}.deb \
|
||||||
&& dpkg --install ./jbig2enc_${JBIG2ENC_VERSION}-1_${TARGETARCH}.deb
|
&& dpkg --install ./jbig2enc_${JBIG2ENC_VERSION}-1_${TARGETARCH}.deb
|
||||||
|
|
||||||
# setup docker-specific things
|
|
||||||
# These change sometimes, but rarely
|
|
||||||
WORKDIR /usr/src/paperless/src/docker/
|
|
||||||
|
|
||||||
COPY [ \
|
|
||||||
"docker/rootfs/etc/ImageMagick-6/paperless-policy.xml", \
|
|
||||||
"./" \
|
|
||||||
]
|
|
||||||
|
|
||||||
RUN set -eux \
|
|
||||||
&& echo "Configuring ImageMagick" \
|
|
||||||
&& mkdir -p /etc/ImageMagick-6 \
|
|
||||||
&& mv paperless-policy.xml /etc/ImageMagick-6/policy.xml
|
|
||||||
|
|
||||||
|
|
||||||
# Packages needed only for building a few quick Python
|
# Packages needed only for building a few quick Python
|
||||||
# dependencies
|
# dependencies
|
||||||
ARG BUILD_PACKAGES="\
|
ARG BUILD_PACKAGES="\
|
||||||
|
|||||||
@@ -8,10 +8,11 @@ on:
|
|||||||
- 'translations**'
|
- 'translations**'
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
concurrency:
|
concurrency:
|
||||||
group: backend-${{ github.event.pull_request.number || github.ref }}
|
# Let dev/main commits finish, PR and feature-branch runs supersede older ones
|
||||||
|
group: backend-${{ github.event.pull_request.number || ((github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main') && github.run_id) || github.ref }}
|
||||||
cancel-in-progress: true
|
cancel-in-progress: true
|
||||||
env:
|
env:
|
||||||
DEFAULT_UV_VERSION: "0.12.x"
|
DEFAULT_UV_VERSION: "0.13.x"
|
||||||
# Match the Docker image: nltk refuses to read hardlinked data files, such as
|
# Match the Docker image: nltk refuses to read hardlinked data files, such as
|
||||||
# the copy bundled with llama-index when uv links packages from its cache
|
# the copy bundled with llama-index when uv links packages from its cache
|
||||||
UV_LINK_MODE: copy
|
UV_LINK_MODE: copy
|
||||||
@@ -102,7 +103,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
python-version: "${{ matrix.python-version }}"
|
python-version: "${{ matrix.python-version }}"
|
||||||
- name: Install uv
|
- name: Install uv
|
||||||
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
|
uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2
|
||||||
with:
|
with:
|
||||||
version: ${{ env.DEFAULT_UV_VERSION }}
|
version: ${{ env.DEFAULT_UV_VERSION }}
|
||||||
enable-cache: true
|
enable-cache: true
|
||||||
@@ -111,10 +112,7 @@ jobs:
|
|||||||
timeout-minutes: 12
|
timeout-minutes: 12
|
||||||
uses: $/.github/actions/apt-install
|
uses: $/.github/actions/apt-install
|
||||||
with:
|
with:
|
||||||
packages: unpaper tesseract-ocr imagemagick ghostscript poppler-utils
|
packages: unpaper tesseract-ocr ghostscript poppler-utils qpdf
|
||||||
- name: Configure ImageMagick
|
|
||||||
run: |
|
|
||||||
sudo cp docker/rootfs/etc/ImageMagick-6/paperless-policy.xml /etc/ImageMagick-7/policy.xml
|
|
||||||
- name: Install Python dependencies
|
- name: Install Python dependencies
|
||||||
env:
|
env:
|
||||||
PYTHON_VERSION: ${{ steps.setup-python.outputs.python-version }}
|
PYTHON_VERSION: ${{ steps.setup-python.outputs.python-version }}
|
||||||
@@ -175,7 +173,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
python-version: "${{ env.DEFAULT_PYTHON }}"
|
python-version: "${{ env.DEFAULT_PYTHON }}"
|
||||||
- name: Install uv
|
- name: Install uv
|
||||||
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
|
uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2
|
||||||
with:
|
with:
|
||||||
version: ${{ env.DEFAULT_UV_VERSION }}
|
version: ${{ env.DEFAULT_UV_VERSION }}
|
||||||
enable-cache: true
|
enable-cache: true
|
||||||
|
|||||||
@@ -13,7 +13,8 @@ on:
|
|||||||
- main
|
- main
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
concurrency:
|
concurrency:
|
||||||
group: docker-${{ github.event.pull_request.number || github.ref }}
|
# Let dev/main commits finish, PR and feature-branch runs supersede older ones
|
||||||
|
group: docker-${{ github.event.pull_request.number || ((github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main') && github.run_id) || github.ref }}
|
||||||
cancel-in-progress: true
|
cancel-in-progress: true
|
||||||
env:
|
env:
|
||||||
REGISTRY: ghcr.io
|
REGISTRY: ghcr.io
|
||||||
|
|||||||
@@ -6,12 +6,13 @@ on:
|
|||||||
pull_request:
|
pull_request:
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
concurrency:
|
concurrency:
|
||||||
group: docs-${{ github.event.pull_request.number || github.ref }}
|
# Let dev/main commits finish, PR and feature-branch runs supersede older ones
|
||||||
|
group: docs-${{ github.event.pull_request.number || ((github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main') && github.run_id) || github.ref }}
|
||||||
cancel-in-progress: true
|
cancel-in-progress: true
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
env:
|
env:
|
||||||
DEFAULT_UV_VERSION: "0.12.x"
|
DEFAULT_UV_VERSION: "0.13.x"
|
||||||
DEFAULT_PYTHON_VERSION: "3.12"
|
DEFAULT_PYTHON_VERSION: "3.12"
|
||||||
jobs:
|
jobs:
|
||||||
changes:
|
changes:
|
||||||
@@ -78,7 +79,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
|
python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
|
||||||
- name: Install uv
|
- name: Install uv
|
||||||
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
|
uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2
|
||||||
with:
|
with:
|
||||||
version: ${{ env.DEFAULT_UV_VERSION }}
|
version: ${{ env.DEFAULT_UV_VERSION }}
|
||||||
enable-cache: true
|
enable-cache: true
|
||||||
|
|||||||
@@ -8,7 +8,8 @@ on:
|
|||||||
- 'translations**'
|
- 'translations**'
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
concurrency:
|
concurrency:
|
||||||
group: frontend-${{ github.event.pull_request.number || github.ref }}
|
# Let dev/main commits finish, PR and feature-branch runs supersede older ones
|
||||||
|
group: frontend-${{ github.event.pull_request.number || ((github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main') && github.run_id) || github.ref }}
|
||||||
cancel-in-progress: true
|
cancel-in-progress: true
|
||||||
permissions: {}
|
permissions: {}
|
||||||
jobs:
|
jobs:
|
||||||
@@ -191,7 +192,7 @@ jobs:
|
|||||||
runs-on: ubuntu-26.04
|
runs-on: ubuntu-26.04
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
container: mcr.microsoft.com/playwright:v1.62.1-resolute
|
container: mcr.microsoft.com/playwright:v1.64.1-resolute
|
||||||
env:
|
env:
|
||||||
PLAYWRIGHT_BROWSERS_PATH: /ms-playwright
|
PLAYWRIGHT_BROWSERS_PATH: /ms-playwright
|
||||||
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1
|
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1
|
||||||
@@ -216,9 +217,9 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
python-version: '3.12'
|
python-version: '3.12'
|
||||||
- name: Install uv
|
- name: Install uv
|
||||||
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
|
uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2
|
||||||
with:
|
with:
|
||||||
version: '0.12.x'
|
version: '0.13.x'
|
||||||
enable-cache: false
|
enable-cache: false
|
||||||
python-version: ${{ steps.setup-python.outputs.python-version }}
|
python-version: ${{ steps.setup-python.outputs.python-version }}
|
||||||
- name: Cache frontend dependencies
|
- name: Cache frontend dependencies
|
||||||
|
|||||||
@@ -7,7 +7,8 @@ on:
|
|||||||
branches-ignore:
|
branches-ignore:
|
||||||
- 'translations**'
|
- 'translations**'
|
||||||
concurrency:
|
concurrency:
|
||||||
group: lint-${{ github.event.pull_request.number || github.ref }}
|
# Let dev/main commits finish, PR and feature-branch runs supersede older ones
|
||||||
|
group: lint-${{ github.event.pull_request.number || ((github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main') && github.run_id) || github.ref }}
|
||||||
cancel-in-progress: true
|
cancel-in-progress: true
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ concurrency:
|
|||||||
group: release-${{ github.ref }}
|
group: release-${{ github.ref }}
|
||||||
cancel-in-progress: false
|
cancel-in-progress: false
|
||||||
env:
|
env:
|
||||||
DEFAULT_UV_VERSION: "0.12.x"
|
DEFAULT_UV_VERSION: "0.13.x"
|
||||||
DEFAULT_PYTHON_VERSION: "3.12"
|
DEFAULT_PYTHON_VERSION: "3.12"
|
||||||
permissions: {}
|
permissions: {}
|
||||||
jobs:
|
jobs:
|
||||||
@@ -59,7 +59,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
|
python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
|
||||||
- name: Install uv
|
- name: Install uv
|
||||||
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
|
uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2
|
||||||
with:
|
with:
|
||||||
version: ${{ env.DEFAULT_UV_VERSION }}
|
version: ${{ env.DEFAULT_UV_VERSION }}
|
||||||
enable-cache: false
|
enable-cache: false
|
||||||
@@ -213,7 +213,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
|
python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
|
||||||
- name: Install uv
|
- name: Install uv
|
||||||
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
|
uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2
|
||||||
with:
|
with:
|
||||||
version: ${{ env.DEFAULT_UV_VERSION }}
|
version: ${{ env.DEFAULT_UV_VERSION }}
|
||||||
enable-cache: false
|
enable-cache: false
|
||||||
|
|||||||
@@ -8,7 +8,8 @@ on:
|
|||||||
- 'translations**'
|
- 'translations**'
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
concurrency:
|
concurrency:
|
||||||
group: static-analysis-${{ github.event.pull_request.number || github.ref }}
|
# Let dev/main commits finish, PR and feature-branch runs supersede older ones
|
||||||
|
group: static-analysis-${{ github.event.pull_request.number || ((github.ref == 'refs/heads/dev' || github.ref == 'refs/heads/main') && github.run_id) || github.ref }}
|
||||||
cancel-in-progress: true
|
cancel-in-progress: true
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ on:
|
|||||||
branches:
|
branches:
|
||||||
- dev
|
- dev
|
||||||
env:
|
env:
|
||||||
DEFAULT_UV_VERSION: "0.12.x"
|
DEFAULT_UV_VERSION: "0.13.x"
|
||||||
jobs:
|
jobs:
|
||||||
generate-translate-strings:
|
generate-translate-strings:
|
||||||
name: Generate Translation Strings
|
name: Generate Translation Strings
|
||||||
@@ -30,7 +30,7 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
packages: gettext
|
packages: gettext
|
||||||
- name: Install uv
|
- name: Install uv
|
||||||
uses: astral-sh/setup-uv@c18668ad3cf93ea998bef934396af7bb5c839dc7 # v10.2.0
|
uses: astral-sh/setup-uv@07a047e608db84b3d8c06cd9a7c7dcc9c355c18e # v10.3.2
|
||||||
with:
|
with:
|
||||||
version: ${{ env.DEFAULT_UV_VERSION }}
|
version: ${{ env.DEFAULT_UV_VERSION }}
|
||||||
enable-cache: true
|
enable-cache: true
|
||||||
|
|||||||
@@ -38,7 +38,6 @@ src/documents/bulk_edit.py:0: error: Incompatible types in assignment (expressio
|
|||||||
src/documents/bulk_edit.py:0: error: Invalid index type "str" for "dict[FieldDataType, str]"; expected type "FieldDataType" [index]
|
src/documents/bulk_edit.py:0: error: Invalid index type "str" for "dict[FieldDataType, str]"; expected type "FieldDataType" [index]
|
||||||
src/documents/bulk_edit.py:0: error: List comprehension has incompatible type List[tuple[int, Any]]; expected List[int] [misc]
|
src/documents/bulk_edit.py:0: error: List comprehension has incompatible type List[tuple[int, Any]]; expected List[int] [misc]
|
||||||
src/documents/bulk_edit.py:0: error: List comprehension has incompatible type List[tuple[int, None]]; expected List[int] [misc]
|
src/documents/bulk_edit.py:0: error: List comprehension has incompatible type List[tuple[int, None]]; expected List[int] [misc]
|
||||||
src/documents/bulk_edit.py:0: error: Missing named argument "p" for "remove" of "PageList" [call-arg]
|
|
||||||
src/documents/bulk_edit.py:0: error: Missing type arguments for generic type "dict" [type-arg]
|
src/documents/bulk_edit.py:0: error: Missing type arguments for generic type "dict" [type-arg]
|
||||||
src/documents/bulk_edit.py:0: error: Missing type arguments for generic type "dict" [type-arg]
|
src/documents/bulk_edit.py:0: error: Missing type arguments for generic type "dict" [type-arg]
|
||||||
src/documents/bulk_edit.py:0: error: Need type annotation for "to_create" (hint: "to_create: list[<type>] = ...") [var-annotated]
|
src/documents/bulk_edit.py:0: error: Need type annotation for "to_create" (hint: "to_create: list[<type>] = ...") [var-annotated]
|
||||||
|
|||||||
@@ -91,13 +91,6 @@
|
|||||||
"concise_description": "Argument `list[int]` is not assignable to parameter `args` with type `tuple[Any, ...] | None` in function `celery.app.task.Task.apply_async`",
|
"concise_description": "Argument `list[int]` is not assignable to parameter `args` with type `tuple[Any, ...] | None` in function `celery.app.task.Task.apply_async`",
|
||||||
"severity": "error"
|
"severity": "error"
|
||||||
},
|
},
|
||||||
{
|
|
||||||
"column": 33,
|
|
||||||
"path": "src/documents/bulk_edit.py",
|
|
||||||
"name": "missing-argument",
|
|
||||||
"concise_description": "Missing argument `p` in function `pikepdf._core.PageList.remove`",
|
|
||||||
"severity": "error"
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
"column": 25,
|
"column": 25,
|
||||||
"path": "src/documents/caching.py",
|
"path": "src/documents/caching.py",
|
||||||
|
|||||||
+1
-4
@@ -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.12.23-python3.14-trixie-slim AS s6-overlay-base
|
FROM ghcr.io/astral-sh/uv:0.13.5-python3.14-trixie-slim@sha256:d8e732839dfc4225a1ff698803b478b2223c05636c055d3add41ab9dbec0f315 AS s6-overlay-base
|
||||||
|
|
||||||
WORKDIR /usr/src/s6
|
WORKDIR /usr/src/s6
|
||||||
|
|
||||||
@@ -129,7 +129,6 @@ ARG RUNTIME_PACKAGES="\
|
|||||||
ghostscript \
|
ghostscript \
|
||||||
gnupg \
|
gnupg \
|
||||||
icc-profiles-free \
|
icc-profiles-free \
|
||||||
imagemagick \
|
|
||||||
# PostgreSQL
|
# PostgreSQL
|
||||||
postgresql-client \
|
postgresql-client \
|
||||||
# MySQL / MariaDB
|
# MySQL / MariaDB
|
||||||
@@ -167,8 +166,6 @@ RUN set -eux \
|
|||||||
https://github.com/paperless-ngx/builder/releases/download/jbig2enc-trixie-v${JBIG2ENC_VERSION}/jbig2enc_${JBIG2ENC_VERSION}-1_${TARGETARCH}.deb \
|
https://github.com/paperless-ngx/builder/releases/download/jbig2enc-trixie-v${JBIG2ENC_VERSION}/jbig2enc_${JBIG2ENC_VERSION}-1_${TARGETARCH}.deb \
|
||||||
&& echo "Installing jbig2enc" \
|
&& echo "Installing jbig2enc" \
|
||||||
&& dpkg --install ./jbig2enc_${JBIG2ENC_VERSION}-1_${TARGETARCH}.deb \
|
&& dpkg --install ./jbig2enc_${JBIG2ENC_VERSION}-1_${TARGETARCH}.deb \
|
||||||
&& echo "Configuring imagemagick" \
|
|
||||||
&& cp /etc/ImageMagick-6/paperless-policy.xml /etc/ImageMagick-6/policy.xml \
|
|
||||||
&& echo "Cleaning up image layer" \
|
&& echo "Cleaning up image layer" \
|
||||||
&& rm --force --verbose *.deb \
|
&& rm --force --verbose *.deb \
|
||||||
&& rm --recursive --force --verbose /var/lib/apt/lists/* \
|
&& rm --recursive --force --verbose /var/lib/apt/lists/* \
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ The following are not generally considered vulnerabilities unless accompanied by
|
|||||||
- claims based solely on the presence of a library, framework feature or code pattern without a working exploit
|
- claims based solely on the presence of a library, framework feature or code pattern without a working exploit
|
||||||
- pickle deserialization of internal data from trusted components such as the Redis-compatible broker or Paperless-ngx data directory
|
- pickle deserialization of internal data from trusted components such as the Redis-compatible broker or Paperless-ngx data directory
|
||||||
- users with permission to edit users granting themselves additional privileges; this is expected behavior for that trusted permission
|
- users with permission to edit users granting themselves additional privileges; this is expected behavior for that trusted permission
|
||||||
|
- users with change permission on a mail account redirecting stored credentials to another mail server, including through connectivity tests. Mail account editing is a trusted permission, and UI password masking does not imply credential isolation from account editors
|
||||||
- reports that rely on admin-level access, application-configuration access, workflow-editing privileges, shell access, or other high-trust roles unless they demonstrate an unintended privilege boundary bypass
|
- reports that rely on admin-level access, application-configuration access, workflow-editing privileges, shell access, or other high-trust roles unless they demonstrate an unintended privilege boundary bypass
|
||||||
- optional webhook, mail, AI, OCR, or integration behavior described without a product-level vulnerability
|
- optional webhook, mail, AI, OCR, or integration behavior described without a product-level vulnerability
|
||||||
- missing limits or hardening settings presented without concrete impact
|
- missing limits or hardening settings presented without concrete impact
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
# correct networking for the tests
|
# correct networking for the tests
|
||||||
services:
|
services:
|
||||||
gotenberg:
|
gotenberg:
|
||||||
image: docker.io/gotenberg/gotenberg:8.37
|
image: docker.io/gotenberg/gotenberg:8.38
|
||||||
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.14
|
image: docker.io/greenmail/standalone:2.1.15
|
||||||
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.6-alpine
|
image: docker.io/nginx:1.31.7-alpine
|
||||||
hostname: nginx
|
hostname: nginx
|
||||||
container_name: nginx
|
container_name: nginx
|
||||||
ports:
|
ports:
|
||||||
|
|||||||
@@ -71,7 +71,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.37
|
image: docker.io/gotenberg/gotenberg:8.38
|
||||||
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.37
|
image: docker.io/gotenberg/gotenberg:8.38
|
||||||
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.37
|
image: docker.io/gotenberg/gotenberg:8.38
|
||||||
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.
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 30 KiB |
@@ -0,0 +1,57 @@
|
|||||||
|
# Container init flow
|
||||||
|
|
||||||
|
Startup order of the s6-rc oneshot services under `docker/rootfs/etc/s6-overlay/s6-rc.d/`. An arrow `A --> B` means B lists A in its `dependencies.d/`, so B waits for A. Redundant (transitive) edges are omitted.
|
||||||
|
|
||||||
|
Each labelled box is a group of services that can run in parallel: every service in it starts as soon as its own dependencies finish, so services in the same box run in parallel. A service does not wait for the rest of its group, only for the arrows pointing at it. Long-running services such as `init-tesseract-langs` (package installs) overlap with several later steps.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
subgraph w3 ["Run in parallel"]
|
||||||
|
init-tesseract-langs
|
||||||
|
init-modify-user
|
||||||
|
init-wait-for-db
|
||||||
|
init-wait-for-redis
|
||||||
|
end
|
||||||
|
subgraph w4 ["Run in parallel"]
|
||||||
|
init-folders
|
||||||
|
init-compile-bytecode
|
||||||
|
end
|
||||||
|
subgraph w6 ["Run in parallel"]
|
||||||
|
init-search-index
|
||||||
|
init-llmindex-migrate
|
||||||
|
init-superuser
|
||||||
|
end
|
||||||
|
|
||||||
|
base --> init-start --> init-env-file
|
||||||
|
|
||||||
|
init-env-file --> init-tesseract-langs
|
||||||
|
init-env-file --> init-modify-user
|
||||||
|
init-env-file --> init-wait-for-db
|
||||||
|
init-env-file --> init-wait-for-redis
|
||||||
|
init-start --> init-compile-bytecode
|
||||||
|
|
||||||
|
init-modify-user --> init-folders
|
||||||
|
init-modify-user --> init-compile-bytecode
|
||||||
|
|
||||||
|
init-folders --> init-migrations
|
||||||
|
init-compile-bytecode --> init-migrations
|
||||||
|
init-wait-for-db --> init-migrations
|
||||||
|
|
||||||
|
init-migrations --> init-search-index
|
||||||
|
init-migrations --> init-llmindex-migrate
|
||||||
|
init-migrations --> init-superuser
|
||||||
|
|
||||||
|
init-tesseract-langs --> init-search-index
|
||||||
|
init-tesseract-langs --> init-llmindex-migrate
|
||||||
|
init-tesseract-langs --> init-system-checks
|
||||||
|
init-superuser --> init-system-checks
|
||||||
|
|
||||||
|
init-search-index --> init-custom-init
|
||||||
|
init-system-checks --> init-custom-init
|
||||||
|
init-wait-for-redis --> init-custom-init
|
||||||
|
|
||||||
|
init-custom-init --> init-complete
|
||||||
|
init-llmindex-migrate --> init-complete
|
||||||
|
```
|
||||||
|
|
||||||
|
`init-search-index` and `init-llmindex-migrate` run `manage.py`, which runs the Django system checks, including the check that the configured OCR languages are installed, so they wait for `init-tesseract-langs`.
|
||||||
@@ -1,101 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="UTF-8"?>
|
|
||||||
<!DOCTYPE policymap [
|
|
||||||
<!ELEMENT policymap (policy)+>
|
|
||||||
<!ATTLIST policymap xmlns CDATA #FIXED ''>
|
|
||||||
<!ELEMENT policy EMPTY>
|
|
||||||
<!ATTLIST policy xmlns CDATA #FIXED '' domain NMTOKEN #REQUIRED
|
|
||||||
name NMTOKEN #IMPLIED pattern CDATA #IMPLIED rights NMTOKEN #IMPLIED
|
|
||||||
stealth NMTOKEN #IMPLIED value CDATA #IMPLIED>
|
|
||||||
]>
|
|
||||||
<!--
|
|
||||||
Configure ImageMagick policies.
|
|
||||||
|
|
||||||
Domains include system, delegate, coder, filter, path, or resource.
|
|
||||||
|
|
||||||
Rights include none, read, write, execute and all. Use | to combine them,
|
|
||||||
for example: "read | write" to permit read from, or write to, a path.
|
|
||||||
|
|
||||||
Use a glob expression as a pattern.
|
|
||||||
|
|
||||||
Suppose we do not want users to process MPEG video images:
|
|
||||||
|
|
||||||
<policy domain="delegate" rights="none" pattern="mpeg:decode" />
|
|
||||||
|
|
||||||
Here we do not want users reading images from HTTP:
|
|
||||||
|
|
||||||
<policy domain="coder" rights="none" pattern="HTTP" />
|
|
||||||
|
|
||||||
The /repository file system is restricted to read only. We use a glob
|
|
||||||
expression to match all paths that start with /repository:
|
|
||||||
|
|
||||||
<policy domain="path" rights="read" pattern="/repository/*" />
|
|
||||||
|
|
||||||
Lets prevent users from executing any image filters:
|
|
||||||
|
|
||||||
<policy domain="filter" rights="none" pattern="*" />
|
|
||||||
|
|
||||||
Any large image is cached to disk rather than memory:
|
|
||||||
|
|
||||||
<policy domain="resource" name="area" value="1GP"/>
|
|
||||||
|
|
||||||
Define arguments for the memory, map, area, width, height and disk resources
|
|
||||||
with SI prefixes (.e.g 100MB). In addition, resource policies are maximums
|
|
||||||
for each instance of ImageMagick (e.g. policy memory limit 1GB, -limit 2GB
|
|
||||||
exceeds policy maximum so memory limit is 1GB).
|
|
||||||
|
|
||||||
Rules are processed in order. Here we want to restrict ImageMagick to only
|
|
||||||
read or write a small subset of proven web-safe image types:
|
|
||||||
|
|
||||||
<policy domain="delegate" rights="none" pattern="*" />
|
|
||||||
<policy domain="filter" rights="none" pattern="*" />
|
|
||||||
<policy domain="coder" rights="none" pattern="*" />
|
|
||||||
<policy domain="coder" rights="read|write" pattern="{GIF,JPEG,PNG,WEBP}" />
|
|
||||||
-->
|
|
||||||
<policymap>
|
|
||||||
<!-- <policy domain="system" name="shred" value="2"/> -->
|
|
||||||
<!-- <policy domain="system" name="precision" value="6"/> -->
|
|
||||||
<!-- <policy domain="system" name="memory-map" value="anonymous"/> -->
|
|
||||||
<!-- <policy domain="system" name="max-memory-request" value="256MiB"/> -->
|
|
||||||
<!-- <policy domain="resource" name="temporary-path" value="/tmp"/> -->
|
|
||||||
<policy domain="resource" name="memory" value="256MiB"/>
|
|
||||||
<policy domain="resource" name="map" value="512MiB"/>
|
|
||||||
<policy domain="resource" name="width" value="16KP"/>
|
|
||||||
<policy domain="resource" name="height" value="16KP"/>
|
|
||||||
<!-- <policy domain="resource" name="list-length" value="128"/> -->
|
|
||||||
<policy domain="resource" name="area" value="128MB"/>
|
|
||||||
<policy domain="resource" name="disk" value="1GiB"/>
|
|
||||||
<!-- <policy domain="resource" name="file" value="768"/> -->
|
|
||||||
<!-- <policy domain="resource" name="thread" value="4"/> -->
|
|
||||||
<!-- <policy domain="resource" name="throttle" value="0"/> -->
|
|
||||||
<!-- <policy domain="resource" name="time" value="3600"/> -->
|
|
||||||
<!-- Paperless does not process SVG or ImageMagick scripting formats. -->
|
|
||||||
<policy domain="coder" rights="none" pattern="SVG" />
|
|
||||||
<policy domain="coder" rights="none" pattern="SVGZ" />
|
|
||||||
<policy domain="coder" rights="none" pattern="MSVG" />
|
|
||||||
<policy domain="coder" rights="none" pattern="RSVG" />
|
|
||||||
<policy domain="coder" rights="none" pattern="MSL" />
|
|
||||||
<policy domain="coder" rights="none" pattern="MVG" />
|
|
||||||
<policy domain="coder" rights="none" pattern="EPHEMERAL" />
|
|
||||||
<!-- <policy domain="module" rights="none" pattern="{PS,PDF,XPS}" /> -->
|
|
||||||
<!-- <policy domain="delegate" rights="none" pattern="HTTPS" /> -->
|
|
||||||
<!-- <policy domain="path" rights="none" pattern="@*" /> -->
|
|
||||||
<!-- <policy domain="cache" name="memory-map" value="anonymous"/> -->
|
|
||||||
<!-- <policy domain="cache" name="synchronize" value="True"/> -->
|
|
||||||
<!-- <policy domain="cache" name="shared-secret" value="passphrase" stealth="true"/> -->
|
|
||||||
<!-- <policy domain="system" name="pixel-cache-memory" value="anonymous"/> -->
|
|
||||||
<!-- <policy domain="system" name="shred" value="2"/> -->
|
|
||||||
<!-- <policy domain="system" name="precision" value="6"/> -->
|
|
||||||
<!-- use curl -->
|
|
||||||
<policy domain="delegate" rights="none" pattern="URL" />
|
|
||||||
<policy domain="delegate" rights="none" pattern="HTTPS" />
|
|
||||||
<policy domain="delegate" rights="none" pattern="HTTP" />
|
|
||||||
<!-- in order to avoid to get image with password text -->
|
|
||||||
<policy domain="path" rights="none" pattern="@*"/>
|
|
||||||
<!-- disable ghostscript format types -->
|
|
||||||
<policy domain="coder" rights="none" pattern="PS" />
|
|
||||||
<policy domain="coder" rights="none" pattern="PS2" />
|
|
||||||
<policy domain="coder" rights="none" pattern="PS3" />
|
|
||||||
<policy domain="coder" rights="none" pattern="EPS" />
|
|
||||||
<policy domain="coder" rights="read|write" pattern="PDF" />
|
|
||||||
<policy domain="coder" rights="none" pattern="XPS" />
|
|
||||||
</policymap>
|
|
||||||
Whitespace-only changes.
Whitespace-only changes.
@@ -10,9 +10,11 @@ if [[ -n "${PAPERLESS_CONSUMER_DISABLE}" ]]; then
|
|||||||
else
|
else
|
||||||
cd ${PAPERLESS_SRC_DIR}
|
cd ${PAPERLESS_SRC_DIR}
|
||||||
|
|
||||||
|
# Django system checks already ran once in init-system-checks, skip repeating them
|
||||||
|
|
||||||
if [[ -n "${USER_IS_NON_ROOT}" ]]; then
|
if [[ -n "${USER_IS_NON_ROOT}" ]]; then
|
||||||
exec python3 manage.py document_consumer
|
exec python3 manage.py document_consumer --skip-checks
|
||||||
else
|
else
|
||||||
exec s6-setuidgid paperless python3 manage.py document_consumer
|
exec s6-setuidgid paperless python3 manage.py document_consumer --skip-checks
|
||||||
fi
|
fi
|
||||||
fi
|
fi
|
||||||
@@ -3,8 +3,9 @@
|
|||||||
|
|
||||||
cd ${PAPERLESS_SRC_DIR}
|
cd ${PAPERLESS_SRC_DIR}
|
||||||
|
|
||||||
|
# Django system checks already ran once in init-system-checks, skip repeating them
|
||||||
if [[ -n "${USER_IS_NON_ROOT}" ]]; then
|
if [[ -n "${USER_IS_NON_ROOT}" ]]; then
|
||||||
exec /usr/local/bin/celery --app paperless beat --loglevel INFO
|
exec /usr/local/bin/celery --skip-checks --app paperless beat --loglevel INFO
|
||||||
else
|
else
|
||||||
exec s6-setuidgid paperless /usr/local/bin/celery --app paperless beat --loglevel INFO
|
exec s6-setuidgid paperless /usr/local/bin/celery --skip-checks --app paperless beat --loglevel INFO
|
||||||
fi
|
fi
|
||||||
@@ -3,8 +3,9 @@
|
|||||||
|
|
||||||
cd ${PAPERLESS_SRC_DIR}
|
cd ${PAPERLESS_SRC_DIR}
|
||||||
|
|
||||||
|
# Django system checks already ran once in init-system-checks, skip repeating them
|
||||||
if [[ -n "${USER_IS_NON_ROOT}" ]]; then
|
if [[ -n "${USER_IS_NON_ROOT}" ]]; then
|
||||||
exec /usr/local/bin/celery --app paperless worker --loglevel INFO --without-mingle --without-gossip
|
exec /usr/local/bin/celery --skip-checks --app paperless worker --loglevel INFO --without-mingle --without-gossip
|
||||||
else
|
else
|
||||||
exec s6-setuidgid paperless /usr/local/bin/celery --app paperless worker --loglevel INFO --without-mingle --without-gossip
|
exec s6-setuidgid paperless /usr/local/bin/celery --skip-checks --app paperless worker --loglevel INFO --without-mingle --without-gossip
|
||||||
fi
|
fi
|
||||||
+11
@@ -162,6 +162,17 @@ including `gt` (>), `gte` (>=), `lt` (<), `lte` (<=), and `range`.
|
|||||||
Lastly, document link fields support a `contains` operator that behaves
|
Lastly, document link fields support a `contains` operator that behaves
|
||||||
like a "is superset of" check.
|
like a "is superset of" check.
|
||||||
|
|
||||||
|
Date fields also accept relative expressions supported by
|
||||||
|
[dateparser](https://dateparser.readthedocs.io/en/stable/), such as `today`,
|
||||||
|
`in 30 days`, or `1 month ago`. For example, dates within the next 30 days:
|
||||||
|
|
||||||
|
```json
|
||||||
|
["expiry", "range", ["today", "in 30 days"]]
|
||||||
|
```
|
||||||
|
|
||||||
|
Expressions resolve each time the query runs, using `PAPERLESS_TIME_ZONE` and
|
||||||
|
`PAPERLESS_DATE_PARSER_LANGUAGES` (automatic language detection when unset).
|
||||||
|
|
||||||
### `/api/search/autocomplete/`
|
### `/api/search/autocomplete/`
|
||||||
|
|
||||||
Get auto completions for a partial search term.
|
Get auto completions for a partial search term.
|
||||||
|
|||||||
@@ -1,5 +1,155 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## paperless-ngx 3.4.1
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- Fix the id\_\_ schemae in the right place [@shamoon](https://github.com/shamoon) ([#14604](https://github.com/paperless-ngx/paperless-ngx/pull/14604))
|
||||||
|
- Fix: drop redundant change perms checks on cards open button [@shamoon](https://github.com/shamoon) ([#14601](https://github.com/paperless-ngx/paperless-ngx/pull/14601))
|
||||||
|
- Tweak/fix: make new pdf controls mostly hidden [@shamoon](https://github.com/shamoon) ([#14595](https://github.com/paperless-ngx/paperless-ngx/pull/14595))
|
||||||
|
- Tweak/fix: move preview button in doc list [@shamoon](https://github.com/shamoon) ([#14594](https://github.com/paperless-ngx/paperless-ngx/pull/14594))
|
||||||
|
- Fix: checksum light mode display and cf dropdown mobile position [@shamoon](https://github.com/shamoon) ([#14590](https://github.com/paperless-ngx/paperless-ngx/pull/14590))
|
||||||
|
|
||||||
|
### All App Changes
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>6 changes</summary>
|
||||||
|
|
||||||
|
- Fix the id\_\_ schemae in the right place [@shamoon](https://github.com/shamoon) ([#14604](https://github.com/paperless-ngx/paperless-ngx/pull/14604))
|
||||||
|
- Fix: drop redundant change perms checks on cards open button [@shamoon](https://github.com/shamoon) ([#14601](https://github.com/paperless-ngx/paperless-ngx/pull/14601))
|
||||||
|
- Chore: Improve idle memory via more lazy imports [@stumpylog](https://github.com/stumpylog) ([#14599](https://github.com/paperless-ngx/paperless-ngx/pull/14599))
|
||||||
|
- Tweak/fix: make new pdf controls mostly hidden [@shamoon](https://github.com/shamoon) ([#14595](https://github.com/paperless-ngx/paperless-ngx/pull/14595))
|
||||||
|
- Tweak/fix: move preview button in doc list [@shamoon](https://github.com/shamoon) ([#14594](https://github.com/paperless-ngx/paperless-ngx/pull/14594))
|
||||||
|
- Fix: checksum light mode display and cf dropdown mobile position [@shamoon](https://github.com/shamoon) ([#14590](https://github.com/paperless-ngx/paperless-ngx/pull/14590))
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
## paperless-ngx 3.4.0
|
||||||
|
|
||||||
|
### Features / Enhancements
|
||||||
|
|
||||||
|
- Enhancement: support custom fields in workflow title templates [@shamoon](https://github.com/shamoon) ([#14564](https://github.com/paperless-ngx/paperless-ngx/pull/14564))
|
||||||
|
- Enhancement: relative date custom field queries [@shamoon](https://github.com/shamoon) ([#14549](https://github.com/paperless-ngx/paperless-ngx/pull/14549))
|
||||||
|
- Performance: reduce tasks badge load times [@JanKleine](https://github.com/JanKleine) ([#14552](https://github.com/paperless-ngx/paperless-ngx/pull/14552))
|
||||||
|
- Enhancement: skip local 2FA for trusted OIDC AMR [@shamoon](https://github.com/shamoon) ([#14403](https://github.com/paperless-ngx/paperless-ngx/pull/14403))
|
||||||
|
- Feature: Generate PDF thumbnails via poppler utils [@stumpylog](https://github.com/stumpylog) ([#14420](https://github.com/paperless-ngx/paperless-ngx/pull/14420))
|
||||||
|
- Enhancement: add upload count to button, link to tray, reduce notifications [@shamoon](https://github.com/shamoon) ([#14501](https://github.com/paperless-ngx/paperless-ngx/pull/14501))
|
||||||
|
- Enhancement: notifications improvements [@shamoon](https://github.com/shamoon) ([#14500](https://github.com/paperless-ngx/paperless-ngx/pull/14500))
|
||||||
|
- Enhancement: doc details ux improvements [@shamoon](https://github.com/shamoon) ([#14485](https://github.com/paperless-ngx/paperless-ngx/pull/14485))
|
||||||
|
- Enhancement (QoL): allow independently toggling child + parent tags in WF tag fields [@shamoon](https://github.com/shamoon) ([#14400](https://github.com/paperless-ngx/paperless-ngx/pull/14400))
|
||||||
|
- Tweak: drop the user dropdown caret [@shamoon](https://github.com/shamoon) ([#14444](https://github.com/paperless-ngx/paperless-ngx/pull/14444))
|
||||||
|
- Enhancement: navbar upload button [@shamoon](https://github.com/shamoon) ([#14442](https://github.com/paperless-ngx/paperless-ngx/pull/14442))
|
||||||
|
- Enhancement: AI chat UX tweaks [@shamoon](https://github.com/shamoon) ([#14415](https://github.com/paperless-ngx/paperless-ngx/pull/14415))
|
||||||
|
- Enhancement: support markdown in AI chat [@shamoon](https://github.com/shamoon) ([#14409](https://github.com/paperless-ngx/paperless-ngx/pull/14409))
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- Fix: truncate long version labels in dropdown [@shamoon](https://github.com/shamoon) ([#14578](https://github.com/paperless-ngx/paperless-ngx/pull/14578))
|
||||||
|
- Fixhancement: show better result for revoked / expired tasks [@shamoon](https://github.com/shamoon) ([#14566](https://github.com/paperless-ngx/paperless-ngx/pull/14566))
|
||||||
|
- Fix: move re-index after workflow runs on doc edit [@shamoon](https://github.com/shamoon) ([#14558](https://github.com/paperless-ngx/paperless-ngx/pull/14558))
|
||||||
|
- Fix: folder export with --delete fails on a stale manifest.json.tmp [@benpetty](https://github.com/benpetty) ([#14526](https://github.com/paperless-ngx/paperless-ngx/pull/14526))
|
||||||
|
- Fix: fix list type for api schema document object filters [@shamoon](https://github.com/shamoon) ([#14524](https://github.com/paperless-ngx/paperless-ngx/pull/14524))
|
||||||
|
- Fix: standardize some dropdown sizing [@shamoon](https://github.com/shamoon) ([#14510](https://github.com/paperless-ngx/paperless-ngx/pull/14510))
|
||||||
|
- Fix: prevent doc permissions queries on schema view [@shamoon](https://github.com/shamoon) ([#14503](https://github.com/paperless-ngx/paperless-ngx/pull/14503))
|
||||||
|
- Fix: dont defer tag assignment when barcode will not result in a split [@shamoon](https://github.com/shamoon) ([#14497](https://github.com/paperless-ngx/paperless-ngx/pull/14497))
|
||||||
|
|
||||||
|
### Dependencies
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>6 changes</summary>
|
||||||
|
|
||||||
|
- Chore: bump some frontend deps for security fixes [@shamoon](https://github.com/shamoon) ([#14581](https://github.com/paperless-ngx/paperless-ngx/pull/14581))
|
||||||
|
- Chore: bump brotli, djangorestframework, filelock, msgpack, setuptools for security fixes [@stumpylog](https://github.com/stumpylog) ([#14575](https://github.com/paperless-ngx/paperless-ngx/pull/14575))
|
||||||
|
- Chore: bump django-allauth to 65.20 and oauthlib to 4.0 [@stumpylog](https://github.com/stumpylog) ([#14570](https://github.com/paperless-ngx/paperless-ngx/pull/14570))
|
||||||
|
- Chore(deps): Bump the uv group across 1 directory with 2 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#14517](https://github.com/paperless-ngx/paperless-ngx/pull/14517))
|
||||||
|
- Chore(deps): Bump the uv group across 1 directory with 2 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#14493](https://github.com/paperless-ngx/paperless-ngx/pull/14493))
|
||||||
|
- Chore: Drop ImageMagik as a dependency [@stumpylog](https://github.com/stumpylog) ([#14440](https://github.com/paperless-ngx/paperless-ngx/pull/14440))
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
### All App Changes
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>35 changes</summary>
|
||||||
|
|
||||||
|
- Chore: Consolidate test sample files [@stumpylog](https://github.com/stumpylog) ([#14430](https://github.com/paperless-ngx/paperless-ngx/pull/14430))
|
||||||
|
- Chore: bump some frontend deps for security fixes [@shamoon](https://github.com/shamoon) ([#14581](https://github.com/paperless-ngx/paperless-ngx/pull/14581))
|
||||||
|
- Enhancement: support custom fields in workflow title templates [@shamoon](https://github.com/shamoon) ([#14564](https://github.com/paperless-ngx/paperless-ngx/pull/14564))
|
||||||
|
- Enhancement: relative date custom field queries [@shamoon](https://github.com/shamoon) ([#14549](https://github.com/paperless-ngx/paperless-ngx/pull/14549))
|
||||||
|
- Fix: truncate long version labels in dropdown [@shamoon](https://github.com/shamoon) ([#14578](https://github.com/paperless-ngx/paperless-ngx/pull/14578))
|
||||||
|
- Chore: bump brotli, djangorestframework, filelock, msgpack, setuptools for security fixes [@stumpylog](https://github.com/stumpylog) ([#14575](https://github.com/paperless-ngx/paperless-ngx/pull/14575))
|
||||||
|
- Chore: bump django-allauth to 65.20 and oauthlib to 4.0 [@stumpylog](https://github.com/stumpylog) ([#14570](https://github.com/paperless-ngx/paperless-ngx/pull/14570))
|
||||||
|
- Fixhancement: show better result for revoked / expired tasks [@shamoon](https://github.com/shamoon) ([#14566](https://github.com/paperless-ngx/paperless-ngx/pull/14566))
|
||||||
|
- Fix: move re-index after workflow runs on doc edit [@shamoon](https://github.com/shamoon) ([#14558](https://github.com/paperless-ngx/paperless-ngx/pull/14558))
|
||||||
|
- Performance: reduce tasks badge load times [@JanKleine](https://github.com/JanKleine) ([#14552](https://github.com/paperless-ngx/paperless-ngx/pull/14552))
|
||||||
|
- Enhancement: skip local 2FA for trusted OIDC AMR [@shamoon](https://github.com/shamoon) ([#14403](https://github.com/paperless-ngx/paperless-ngx/pull/14403))
|
||||||
|
- Chore: consolidate direct permission grant lookups into one helper [@stumpylog](https://github.com/stumpylog) ([#14456](https://github.com/paperless-ngx/paperless-ngx/pull/14456))
|
||||||
|
- Chore: drop get\_groups\_with\_only\_permission [@stumpylog](https://github.com/stumpylog) ([#14454](https://github.com/paperless-ngx/paperless-ngx/pull/14454))
|
||||||
|
- Chore: Consolidate to a single way to set permission on an object [@stumpylog](https://github.com/stumpylog) ([#14453](https://github.com/paperless-ngx/paperless-ngx/pull/14453))
|
||||||
|
- Chore: Refactor bulk edit to remove PDF knowledge [@stumpylog](https://github.com/stumpylog) ([#14445](https://github.com/paperless-ngx/paperless-ngx/pull/14445))
|
||||||
|
- Fix: folder export with --delete fails on a stale manifest.json.tmp [@benpetty](https://github.com/benpetty) ([#14526](https://github.com/paperless-ngx/paperless-ngx/pull/14526))
|
||||||
|
- Chore(deps): Bump the uv group across 1 directory with 2 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#14517](https://github.com/paperless-ngx/paperless-ngx/pull/14517))
|
||||||
|
- Fix: fix list type for api schema document object filters [@shamoon](https://github.com/shamoon) ([#14524](https://github.com/paperless-ngx/paperless-ngx/pull/14524))
|
||||||
|
- Chore(deps): Bump the uv group across 1 directory with 2 updates @[dependabot[bot]](https://github.com/apps/dependabot) ([#14493](https://github.com/paperless-ngx/paperless-ngx/pull/14493))
|
||||||
|
- Chore: drop get\_objects\_for\_user\_owner\_aware [@stumpylog](https://github.com/stumpylog) ([#14457](https://github.com/paperless-ngx/paperless-ngx/pull/14457))
|
||||||
|
- Chore: update Angular components to 22.2.2 [@shamoon](https://github.com/shamoon) ([#14515](https://github.com/paperless-ngx/paperless-ngx/pull/14515))
|
||||||
|
- Chore: share one base class for the method-mapped permission classes [@stumpylog](https://github.com/stumpylog) ([#14458](https://github.com/paperless-ngx/paperless-ngx/pull/14458))
|
||||||
|
- Fix: standardize some dropdown sizing [@shamoon](https://github.com/shamoon) ([#14510](https://github.com/paperless-ngx/paperless-ngx/pull/14510))
|
||||||
|
- Chore: Drop ImageMagik as a dependency [@stumpylog](https://github.com/stumpylog) ([#14440](https://github.com/paperless-ngx/paperless-ngx/pull/14440))
|
||||||
|
- Feature: Generate PDF thumbnails via poppler utils [@stumpylog](https://github.com/stumpylog) ([#14420](https://github.com/paperless-ngx/paperless-ngx/pull/14420))
|
||||||
|
- Fix: prevent doc permissions queries on schema view [@shamoon](https://github.com/shamoon) ([#14503](https://github.com/paperless-ngx/paperless-ngx/pull/14503))
|
||||||
|
- Enhancement: add upload count to button, link to tray, reduce notifications [@shamoon](https://github.com/shamoon) ([#14501](https://github.com/paperless-ngx/paperless-ngx/pull/14501))
|
||||||
|
- Enhancement: notifications improvements [@shamoon](https://github.com/shamoon) ([#14500](https://github.com/paperless-ngx/paperless-ngx/pull/14500))
|
||||||
|
- Enhancement: doc details ux improvements [@shamoon](https://github.com/shamoon) ([#14485](https://github.com/paperless-ngx/paperless-ngx/pull/14485))
|
||||||
|
- Enhancement (QoL): allow independently toggling child + parent tags in WF tag fields [@shamoon](https://github.com/shamoon) ([#14400](https://github.com/paperless-ngx/paperless-ngx/pull/14400))
|
||||||
|
- Tweak: drop the user dropdown caret [@shamoon](https://github.com/shamoon) ([#14444](https://github.com/paperless-ngx/paperless-ngx/pull/14444))
|
||||||
|
- Enhancement: navbar upload button [@shamoon](https://github.com/shamoon) ([#14442](https://github.com/paperless-ngx/paperless-ngx/pull/14442))
|
||||||
|
- Enhancement: AI chat UX tweaks [@shamoon](https://github.com/shamoon) ([#14415](https://github.com/paperless-ngx/paperless-ngx/pull/14415))
|
||||||
|
- Enhancement: support markdown in AI chat [@shamoon](https://github.com/shamoon) ([#14409](https://github.com/paperless-ngx/paperless-ngx/pull/14409))
|
||||||
|
- Fix: dont defer tag assignment when barcode will not result in a split [@shamoon](https://github.com/shamoon) ([#14497](https://github.com/paperless-ngx/paperless-ngx/pull/14497))
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
|
## paperless-ngx 3.3.4
|
||||||
|
|
||||||
|
### All App Changes
|
||||||
|
|
||||||
|
- Chore: Update certifi for eMudhra CA incident [@shamoon](https://github.com/shamoon) ([#14533](https://github.com/paperless-ngx/paperless-ngx/pull/14533))
|
||||||
|
- Chore: Update uv to 0.13 for the eMudhra CA fixes [@stumpylog](https://github.com/stumpylog) ([#14532](https://github.com/paperless-ngx/paperless-ngx/pull/14532))
|
||||||
|
|
||||||
|
## paperless-ngx 3.3.3
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- Fix: fix global search input layout shifting [@shamoon](https://github.com/shamoon) ([#14491](https://github.com/paperless-ngx/paperless-ngx/pull/14491))
|
||||||
|
- Fix: dont include doc content on global search results [@shamoon](https://github.com/shamoon) ([#14488](https://github.com/paperless-ngx/paperless-ngx/pull/14488))
|
||||||
|
- Fix: remove some redundant pagers [@shamoon](https://github.com/shamoon) ([#14487](https://github.com/paperless-ngx/paperless-ngx/pull/14487))
|
||||||
|
- Fix: prevent weird wrapping of dashboard widget column titles [@shamoon](https://github.com/shamoon) ([#14486](https://github.com/paperless-ngx/paperless-ngx/pull/14486))
|
||||||
|
- Fix: dont paginate 'all' tasks view across sections [@shamoon](https://github.com/shamoon) ([#14484](https://github.com/paperless-ngx/paperless-ngx/pull/14484))
|
||||||
|
- Fix: use float parsing for importer manifest records with a passphrase [@stumpylog](https://github.com/stumpylog) ([#14483](https://github.com/paperless-ngx/paperless-ngx/pull/14483))
|
||||||
|
- Fix: respect subpath in logout, share link and auto-login redirects [@jaimevelaz](https://github.com/jaimevelaz) ([#14476](https://github.com/paperless-ngx/paperless-ngx/pull/14476))
|
||||||
|
- Fix: dont tie suggestions button to ai enabled any more [@shamoon](https://github.com/shamoon) ([#14478](https://github.com/paperless-ngx/paperless-ngx/pull/14478))
|
||||||
|
- Fix: handle single-class classifier in threshold prediction (#<!---->14471) [@stumpylog](https://github.com/stumpylog) ([#14473](https://github.com/paperless-ngx/paperless-ngx/pull/14473))
|
||||||
|
- Fix: keep counter and extension when shortening long public filenames [@stumpylog](https://github.com/stumpylog) ([#14472](https://github.com/paperless-ngx/paperless-ngx/pull/14472))
|
||||||
|
|
||||||
|
### All App Changes
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>10 changes</summary>
|
||||||
|
|
||||||
|
- Fix: fix global search input layout shifting [@shamoon](https://github.com/shamoon) ([#14491](https://github.com/paperless-ngx/paperless-ngx/pull/14491))
|
||||||
|
- Fix: dont include doc content on global search results [@shamoon](https://github.com/shamoon) ([#14488](https://github.com/paperless-ngx/paperless-ngx/pull/14488))
|
||||||
|
- Fix: remove some redundant pagers [@shamoon](https://github.com/shamoon) ([#14487](https://github.com/paperless-ngx/paperless-ngx/pull/14487))
|
||||||
|
- Fix: prevent weird wrapping of dashboard widget column titles [@shamoon](https://github.com/shamoon) ([#14486](https://github.com/paperless-ngx/paperless-ngx/pull/14486))
|
||||||
|
- Fix: dont paginate 'all' tasks view across sections [@shamoon](https://github.com/shamoon) ([#14484](https://github.com/paperless-ngx/paperless-ngx/pull/14484))
|
||||||
|
- Fix: use float parsing for importer manifest records with a passphrase [@stumpylog](https://github.com/stumpylog) ([#14483](https://github.com/paperless-ngx/paperless-ngx/pull/14483))
|
||||||
|
- Fix: respect subpath in logout, share link and auto-login redirects [@jaimevelaz](https://github.com/jaimevelaz) ([#14476](https://github.com/paperless-ngx/paperless-ngx/pull/14476))
|
||||||
|
- Fix: dont tie suggestions button to ai enabled any more [@shamoon](https://github.com/shamoon) ([#14478](https://github.com/paperless-ngx/paperless-ngx/pull/14478))
|
||||||
|
- Fix: handle single-class classifier in threshold prediction (#<!---->14471) [@stumpylog](https://github.com/stumpylog) ([#14473](https://github.com/paperless-ngx/paperless-ngx/pull/14473))
|
||||||
|
- Fix: keep counter and extension when shortening long public filenames [@stumpylog](https://github.com/stumpylog) ([#14472](https://github.com/paperless-ngx/paperless-ngx/pull/14472))
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
## paperless-ngx 3.3.2
|
## paperless-ngx 3.3.2
|
||||||
|
|
||||||
### Bug Fixes
|
### Bug Fixes
|
||||||
|
|||||||
+34
-20
@@ -370,6 +370,15 @@ instead of being removed from the filesystem. Only the original version is kept.
|
|||||||
|
|
||||||
This setting was previously named PAPERLESS_TRASH_DIR.
|
This setting was previously named PAPERLESS_TRASH_DIR.
|
||||||
|
|
||||||
|
#### [`PAPERLESS_SCRATCH_DIR=<path>`](#PAPERLESS_SCRATCH_DIR) {#PAPERLESS_SCRATCH_DIR}
|
||||||
|
|
||||||
|
: This folder is used as a temporary workspace for multiple paperless operations, including
|
||||||
|
consuming documents, editing, and the document exporter.
|
||||||
|
|
||||||
|
This must be writeable by the user running paperless.
|
||||||
|
|
||||||
|
Defaults to "/tmp/paperless".
|
||||||
|
|
||||||
#### [`PAPERLESS_MEDIA_ROOT=<path>`](#PAPERLESS_MEDIA_ROOT) {#PAPERLESS_MEDIA_ROOT}
|
#### [`PAPERLESS_MEDIA_ROOT=<path>`](#PAPERLESS_MEDIA_ROOT) {#PAPERLESS_MEDIA_ROOT}
|
||||||
|
|
||||||
: This is where your documents and thumbnails are stored.
|
: This is where your documents and thumbnails are stored.
|
||||||
@@ -794,6 +803,17 @@ system. See the corresponding
|
|||||||
|
|
||||||
Defaults to None
|
Defaults to None
|
||||||
|
|
||||||
|
#### [`PAPERLESS_SOCIAL_ACCOUNT_MFA_TRUSTED_AMR=<comma-separated-list>`](#PAPERLESS_SOCIAL_ACCOUNT_MFA_TRUSTED_AMR) {#PAPERLESS_SOCIAL_ACCOUNT_MFA_TRUSTED_AMR}
|
||||||
|
|
||||||
|
: A list of authentication method values which, if present in the `amr` claim of the ID token sent by an OpenID Connect provider, cause Paperless-ngx to skip its own two-factor authentication prompt for that login. This avoids users having to enter a second factor twice when the identity provider already performed multi-factor authentication. Logins with a username and password, or via a provider which does not send a matching `amr` value, still require the Paperless-ngx code.
|
||||||
|
|
||||||
|
Which values to trust depends entirely on your identity provider, e.g. Authelia sends `mfa` and Pocket ID sends `phr` for passkey logins (but `otp` for single-factor email codes, which should _not_ be trusted). Some providers, such as Keycloak, do not send an `amr` claim by default and need to be configured to do so. You can see what your provider sends from the Django admin as a superuser by checking `id_token` → `amr` in the "Extra data" of the social account.
|
||||||
|
|
||||||
|
!!! warning
|
||||||
|
Only list values that mean a second factor was actually used for the login.
|
||||||
|
|
||||||
|
Defaults to `mfa`, the standard value for multi-factor authentication ([RFC 8176](https://www.rfc-editor.org/rfc/rfc8176.html)).
|
||||||
|
|
||||||
#### [`PAPERLESS_SOCIAL_ACCOUNT_DEFAULT_GROUPS=<comma-separated-list>`](#PAPERLESS_SOCIAL_ACCOUNT_DEFAULT_GROUPS) {#PAPERLESS_SOCIAL_ACCOUNT_DEFAULT_GROUPS}
|
#### [`PAPERLESS_SOCIAL_ACCOUNT_DEFAULT_GROUPS=<comma-separated-list>`](#PAPERLESS_SOCIAL_ACCOUNT_DEFAULT_GROUPS) {#PAPERLESS_SOCIAL_ACCOUNT_DEFAULT_GROUPS}
|
||||||
|
|
||||||
: A list of group names that users who signup via social accounts will be added to upon signup. Groups listed here must already exist.
|
: A list of group names that users who signup via social accounts will be added to upon signup. Groups listed here must already exist.
|
||||||
@@ -1315,29 +1335,17 @@ valid crontab(5) expression describing when to run.
|
|||||||
|
|
||||||
#### [`PAPERLESS_CONVERT_MEMORY_LIMIT=<num>`](#PAPERLESS_CONVERT_MEMORY_LIMIT) {#PAPERLESS_CONVERT_MEMORY_LIMIT}
|
#### [`PAPERLESS_CONVERT_MEMORY_LIMIT=<num>`](#PAPERLESS_CONVERT_MEMORY_LIMIT) {#PAPERLESS_CONVERT_MEMORY_LIMIT}
|
||||||
|
|
||||||
: On smaller systems, or even in the case of Very Large Documents, the
|
!!! warning
|
||||||
consumer may explode, complaining about how it's "unable to extend
|
|
||||||
pixel cache". In such cases, try setting this to a reasonably low
|
|
||||||
value, like 32. The default is to use whatever is necessary to do
|
|
||||||
everything without writing to disk, and units are in megabytes.
|
|
||||||
|
|
||||||
For more information on how to use this value, you should search the
|
Deprecated and has no effect, since PDF thumbnails no longer use
|
||||||
web for "MAGICK_MEMORY_LIMIT".
|
ImageMagick. It will be removed in a future release.
|
||||||
|
|
||||||
Defaults to 0, which disables the limit.
|
|
||||||
|
|
||||||
#### [`PAPERLESS_CONVERT_TMPDIR=<path>`](#PAPERLESS_CONVERT_TMPDIR) {#PAPERLESS_CONVERT_TMPDIR}
|
#### [`PAPERLESS_CONVERT_TMPDIR=<path>`](#PAPERLESS_CONVERT_TMPDIR) {#PAPERLESS_CONVERT_TMPDIR}
|
||||||
|
|
||||||
: Similar to the memory limit, if you've got a small system and your
|
!!! warning
|
||||||
OS mounts /tmp as tmpfs, you should set this to a path that's on a
|
|
||||||
physical disk, like /home/your_user/tmp or something. ImageMagick
|
|
||||||
will use this as scratch space when crunching through very large
|
|
||||||
documents.
|
|
||||||
|
|
||||||
For more information on how to use this value, you should search the
|
Deprecated and has no effect, since PDF thumbnails no longer use
|
||||||
web for "MAGICK_TMPDIR".
|
ImageMagick. It will be removed in a future release.
|
||||||
|
|
||||||
Default is none, which disables the temporary directory.
|
|
||||||
|
|
||||||
#### [`PAPERLESS_APPS=<string>`](#PAPERLESS_APPS) {#PAPERLESS_APPS}
|
#### [`PAPERLESS_APPS=<string>`](#PAPERLESS_APPS) {#PAPERLESS_APPS}
|
||||||
|
|
||||||
@@ -1885,11 +1893,17 @@ the literal path for that program.
|
|||||||
|
|
||||||
#### [`PAPERLESS_CONVERT_BINARY=<path>`](#PAPERLESS_CONVERT_BINARY) {#PAPERLESS_CONVERT_BINARY}
|
#### [`PAPERLESS_CONVERT_BINARY=<path>`](#PAPERLESS_CONVERT_BINARY) {#PAPERLESS_CONVERT_BINARY}
|
||||||
|
|
||||||
: Defaults to "convert".
|
!!! warning
|
||||||
|
|
||||||
|
Deprecated and has no effect, since Paperless-ngx no longer uses
|
||||||
|
ImageMagick. It will be removed in a future release.
|
||||||
|
|
||||||
#### [`PAPERLESS_GS_BINARY=<path>`](#PAPERLESS_GS_BINARY) {#PAPERLESS_GS_BINARY}
|
#### [`PAPERLESS_GS_BINARY=<path>`](#PAPERLESS_GS_BINARY) {#PAPERLESS_GS_BINARY}
|
||||||
|
|
||||||
: Defaults to "gs".
|
!!! warning
|
||||||
|
|
||||||
|
Deprecated and has no effect, since nothing in Paperless-ngx calls
|
||||||
|
Ghostscript by this path. It will be removed in a future release.
|
||||||
|
|
||||||
## Docker-specific options {#docker}
|
## Docker-specific options {#docker}
|
||||||
|
|
||||||
|
|||||||
+2
-13
@@ -177,17 +177,16 @@ to a positive number to enable polling and disable native filesystem notificatio
|
|||||||
- `pkg-config` for mysqlclient (python dependency)
|
- `pkg-config` for mysqlclient (python dependency)
|
||||||
- `fonts-liberation` for generating thumbnails for plain text
|
- `fonts-liberation` for generating thumbnails for plain text
|
||||||
files
|
files
|
||||||
- `imagemagick` >= 6 for PDF conversion
|
|
||||||
- `gnupg` for decrypting GPG-encrypted email
|
- `gnupg` for decrypting GPG-encrypted email
|
||||||
- `libpq-dev` for PostgreSQL
|
- `libpq-dev` for PostgreSQL
|
||||||
- `libmagic-dev` for mime type detection
|
- `libmagic-dev` for mime type detection
|
||||||
- `mariadb-client` for MariaDB compile time
|
- `mariadb-client` for MariaDB compile time
|
||||||
- `poppler-utils` for barcode detection
|
- `poppler-utils` for thumbnail generation and barcode detection
|
||||||
|
|
||||||
Use this list for your preferred package management:
|
Use this list for your preferred package management:
|
||||||
|
|
||||||
```
|
```
|
||||||
python3 python3-pip python3-dev imagemagick fonts-liberation gnupg libpq-dev default-libmysqlclient-dev pkg-config libmagic-dev poppler-utils
|
python3 python3-pip python3-dev fonts-liberation gnupg libpq-dev default-libmysqlclient-dev pkg-config libmagic-dev poppler-utils
|
||||||
```
|
```
|
||||||
|
|
||||||
These dependencies are required for OCRmyPDF, which is used for text
|
These dependencies are required for OCRmyPDF, which is used for text
|
||||||
@@ -416,16 +415,6 @@ to a positive number to enable polling and disable native filesystem notificatio
|
|||||||
You may need to change the path in the files. Example:
|
You may need to change the path in the files. Example:
|
||||||
`ExecStart=/opt/paperless/.local/bin/celery --app paperless worker --loglevel INFO`
|
`ExecStart=/opt/paperless/.local/bin/celery --app paperless worker --loglevel INFO`
|
||||||
|
|
||||||
12. Configure ImageMagick to allow processing of PDF documents and disable
|
|
||||||
formats that Paperless-ngx does not use. Most distributions disable PDF
|
|
||||||
processing by default, since PDF documents can contain malware. If you
|
|
||||||
don't enable it, Paperless-ngx will fall back to Ghostscript for certain
|
|
||||||
steps such as thumbnail generation.
|
|
||||||
|
|
||||||
Configure the active ImageMagick policy file (commonly
|
|
||||||
`/etc/ImageMagick-6/policy.xml` or `/etc/ImageMagick-7/policy.xml`) and
|
|
||||||
adjust similar to [the docker policy file](https://raw.githubusercontent.com/paperless-ngx/paperless-ngx/refs/heads/main/docker/rootfs/etc/ImageMagick-6/paperless-policy.xml). You should also include restrictions as noted there.
|
|
||||||
|
|
||||||
**Optional: Install the [jbig2enc](https://ocrmypdf.readthedocs.io/en/latest/jbig2.html) encoder.**
|
**Optional: Install the [jbig2enc](https://ocrmypdf.readthedocs.io/en/latest/jbig2.html) encoder.**
|
||||||
This will reduce the size of generated PDF documents. You'll most likely need to compile this yourself, because this
|
This will reduce the size of generated PDF documents. You'll most likely need to compile this yourself, because this
|
||||||
software has been patented until around 2017 and binary packages are not available for most distributions.
|
software has been patented until around 2017 and binary packages are not available for most distributions.
|
||||||
|
|||||||
@@ -472,6 +472,8 @@ Users can enable two-factor authentication (2FA) for their accounts from the 'My
|
|||||||
|
|
||||||
Should a user lose access to their 2FA device and all recovery codes, a superuser can disable 2FA for the user from the 'Users & Groups' management screen.
|
Should a user lose access to their 2FA device and all recovery codes, a superuser can disable 2FA for the user from the 'Users & Groups' management screen.
|
||||||
|
|
||||||
|
If users log in via an OpenID Connect provider which already enforces multi-factor authentication, see [`PAPERLESS_SOCIAL_ACCOUNT_MFA_TRUSTED_AMR`](configuration.md#PAPERLESS_SOCIAL_ACCOUNT_MFA_TRUSTED_AMR) to skip the Paperless-ngx prompt for those logins.
|
||||||
|
|
||||||
## Workflows
|
## Workflows
|
||||||
|
|
||||||
!!! note
|
!!! note
|
||||||
@@ -724,6 +726,7 @@ applied. You can use the following placeholders in the template with any trigger
|
|||||||
- `{{added_time}}`: added time in HH:MM format
|
- `{{added_time}}`: added time in HH:MM format
|
||||||
- `{{original_filename}}`: original file name without extension
|
- `{{original_filename}}`: original file name without extension
|
||||||
- `{{filename}}`: current file name without extension (for "added" workflows this may not be final yet, you can use `{{original_filename}}`)
|
- `{{filename}}`: current file name without extension (for "added" workflows this may not be final yet, you can use `{{original_filename}}`)
|
||||||
|
- `{{custom_fields}}`: Custom field names mapped to their type and value, as in [file name templates](advanced_usage.md#additional-variables). For example, `{{ custom_fields.Invoice.value }}` or `{{ custom_fields | get_cf_value("Invoice", "") }}` for a fallback when the field is missing. In title assignments, fields assigned by the same action are available.
|
||||||
- `{{doc_title}}`: current document title (cannot be used in title assignment)
|
- `{{doc_title}}`: current document title (cannot be used in title assignment)
|
||||||
|
|
||||||
The following placeholders are only available for "added" or "updated" triggers
|
The following placeholders are only available for "added" or "updated" triggers
|
||||||
|
|||||||
@@ -16,6 +16,7 @@
|
|||||||
#PAPERLESS_CONSUMPTION_DIR=../consume
|
#PAPERLESS_CONSUMPTION_DIR=../consume
|
||||||
#PAPERLESS_DATA_DIR=../data
|
#PAPERLESS_DATA_DIR=../data
|
||||||
#PAPERLESS_EMPTY_TRASH_DIR=
|
#PAPERLESS_EMPTY_TRASH_DIR=
|
||||||
|
#PAPERLESS_SCRATCH_DIR=/tmp/paperless
|
||||||
#PAPERLESS_MEDIA_ROOT=../media
|
#PAPERLESS_MEDIA_ROOT=../media
|
||||||
#PAPERLESS_STATICDIR=../static
|
#PAPERLESS_STATICDIR=../static
|
||||||
#PAPERLESS_FILENAME_FORMAT=
|
#PAPERLESS_FILENAME_FORMAT=
|
||||||
@@ -50,8 +51,6 @@ PAPERLESS_SECRET_KEY=change-me
|
|||||||
#PAPERLESS_OCR_ROTATE_PAGES=true
|
#PAPERLESS_OCR_ROTATE_PAGES=true
|
||||||
#PAPERLESS_OCR_ROTATE_PAGES_THRESHOLD=12.0
|
#PAPERLESS_OCR_ROTATE_PAGES_THRESHOLD=12.0
|
||||||
#PAPERLESS_OCR_USER_ARGS={}
|
#PAPERLESS_OCR_USER_ARGS={}
|
||||||
#PAPERLESS_CONVERT_MEMORY_LIMIT=0
|
|
||||||
#PAPERLESS_CONVERT_TMPDIR=/var/tmp/paperless
|
|
||||||
|
|
||||||
# Software tweaks
|
# Software tweaks
|
||||||
|
|
||||||
@@ -87,8 +86,3 @@ PAPERLESS_SECRET_KEY=change-me
|
|||||||
#PAPERLESS_TIKA_ENABLED=false
|
#PAPERLESS_TIKA_ENABLED=false
|
||||||
#PAPERLESS_TIKA_ENDPOINT=http://localhost:9998
|
#PAPERLESS_TIKA_ENDPOINT=http://localhost:9998
|
||||||
#PAPERLESS_TIKA_GOTENBERG_ENDPOINT=http://localhost:3000
|
#PAPERLESS_TIKA_GOTENBERG_ENDPOINT=http://localhost:3000
|
||||||
|
|
||||||
# Binaries
|
|
||||||
|
|
||||||
#PAPERLESS_CONVERT_BINARY=/usr/bin/convert
|
|
||||||
#PAPERLESS_GS_BINARY=/usr/bin/gs
|
|
||||||
+10
-10
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "paperless-ngx"
|
name = "paperless-ngx"
|
||||||
version = "3.3.3"
|
version = "3.4.1"
|
||||||
description = """\
|
description = """\
|
||||||
A community-supported supercharged document management system: scan, index and archive all your physical documents\
|
A community-supported supercharged document management system: scan, index and archive all your physical documents\
|
||||||
"""
|
"""
|
||||||
@@ -26,24 +26,24 @@ dependencies = [
|
|||||||
"dateparser~=1.4",
|
"dateparser~=1.4",
|
||||||
# WARNING: django does not use semver.
|
# WARNING: django does not use semver.
|
||||||
# Only patch versions are guaranteed to not introduce breaking changes.
|
# Only patch versions are guaranteed to not introduce breaking changes.
|
||||||
"django~=5.2.13",
|
"django~=5.2.18",
|
||||||
"django-allauth[mfa,socialaccount]~=65.16.0",
|
"django-allauth[mfa,socialaccount]~=65.20.0",
|
||||||
"django-auditlog~=3.4.1",
|
"django-auditlog~=3.4.1",
|
||||||
"django-cachalot~=2.9.0",
|
"django-cachalot~=2.9.0",
|
||||||
"django-compression-middleware~=0.5.0",
|
"django-compression-middleware~=0.5.0",
|
||||||
"django-cors-headers~=4.9.0",
|
"django-cors-headers~=4.9.0",
|
||||||
"django-extensions~=4.1",
|
"django-extensions~=4.1",
|
||||||
"django-filter>=25.1,<27",
|
"django-filter>=26.2,<27",
|
||||||
"django-guardian>=3.3.3,<3.6",
|
"django-guardian>=3.5.2,<3.6",
|
||||||
"django-multiselectfield~=1.0.1",
|
"django-multiselectfield~=1.0.1",
|
||||||
"django-rich~=2.2.0",
|
"django-rich~=2.2.0",
|
||||||
"django-soft-delete~=1.0.18",
|
"django-soft-delete~=1.0.18",
|
||||||
"django-treenode>=0.24",
|
"django-treenode>=0.24",
|
||||||
"djangorestframework~=3.16",
|
"djangorestframework~=3.16",
|
||||||
"drf-spectacular~=0.30",
|
"drf-spectacular~=0.30",
|
||||||
"drf-spectacular-sidecar>=2026.7.1,<2026.10",
|
"drf-spectacular-sidecar>=2026.10.1,<2026.11",
|
||||||
"drf-writable-nested~=0.7.1",
|
"drf-writable-nested~=0.7.1",
|
||||||
"filelock>=3.32,<4.1",
|
"filelock>=3.32,<4.3",
|
||||||
"flower>=2.0.1,<2.3",
|
"flower>=2.0.1,<2.3",
|
||||||
"gotenberg-client[httpx]~=1.0",
|
"gotenberg-client[httpx]~=1.0",
|
||||||
"httpx-oauth~=0.17",
|
"httpx-oauth~=0.17",
|
||||||
@@ -76,7 +76,7 @@ dependencies = [
|
|||||||
"tantivy~=0.26.0",
|
"tantivy~=0.26.0",
|
||||||
"tika-client[httpx]~=1.0",
|
"tika-client[httpx]~=1.0",
|
||||||
"torch>=2.13,<2.15",
|
"torch>=2.13,<2.15",
|
||||||
"turbohtml~=1.10.0",
|
"turbohtml~=1.15.3",
|
||||||
"watchfiles>=1.2",
|
"watchfiles>=1.2",
|
||||||
"whitenoise~=6.11",
|
"whitenoise~=6.11",
|
||||||
"whoosh-compat[tantivy]==0.3",
|
"whoosh-compat[tantivy]==0.3",
|
||||||
@@ -256,8 +256,8 @@ isort.force-single-line = true
|
|||||||
[tool.codespell]
|
[tool.codespell]
|
||||||
ignore-words-list = "criterias,afterall,valeu,ureue,equest,ure,assertIn,Oktober,commitish,NIN,nin,reprot"
|
ignore-words-list = "criterias,afterall,valeu,ureue,equest,ure,assertIn,Oktober,commitish,NIN,nin,reprot"
|
||||||
skip = """\
|
skip = """\
|
||||||
src-ui/src/locale/*,src-ui/pnpm-lock.yaml,src-ui/e2e/*,src/paperless_mail/tests/samples/*,src/paperless/tests/samples\
|
src-ui/src/locale/*,src-ui/pnpm-lock.yaml,src-ui/e2e/*,src/paperless/tests/samples/mail/*,src/documents/tests/samples\
|
||||||
/mail/*,src/documents/tests/samples/*,*.po,*.json\
|
/*,src/paperless_testing/sample_files/*,*.po,*.json\
|
||||||
"""
|
"""
|
||||||
write-changes = true
|
write-changes = true
|
||||||
|
|
||||||
|
|||||||
@@ -69,6 +69,9 @@
|
|||||||
"src/polyfills.ts"
|
"src/polyfills.ts"
|
||||||
],
|
],
|
||||||
"tsConfig": "tsconfig.app.json",
|
"tsConfig": "tsconfig.app.json",
|
||||||
|
"loader": {
|
||||||
|
".svg": "text"
|
||||||
|
},
|
||||||
"localize": true,
|
"localize": true,
|
||||||
"assets": [
|
"assets": [
|
||||||
"src/apple-touch-icon.png",
|
"src/apple-touch-icon.png",
|
||||||
|
|||||||
+12
-2
@@ -43,6 +43,7 @@ def configure_environment(instance_root: Path) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def seed_database() -> None:
|
def seed_database() -> None:
|
||||||
|
from django.contrib.auth.models import Permission
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
from django.core.management import call_command
|
from django.core.management import call_command
|
||||||
from django.utils import timezone
|
from django.utils import timezone
|
||||||
@@ -63,11 +64,18 @@ def seed_database() -> None:
|
|||||||
username="playwright",
|
username="playwright",
|
||||||
password="playwright", # NOSONAR
|
password="playwright", # NOSONAR
|
||||||
)
|
)
|
||||||
User.objects.create_user(
|
viewer = User.objects.create_user(
|
||||||
username="viewer",
|
username="viewer",
|
||||||
password="viewer", # NOSONAR
|
password="viewer", # NOSONAR
|
||||||
)
|
)
|
||||||
|
|
||||||
|
viewer.user_permissions.add(
|
||||||
|
Permission.objects.get(
|
||||||
|
content_type__app_label="documents",
|
||||||
|
codename="view_uisettings",
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
inbox = Tag.objects.create(name="Inbox", is_inbox_tag=True, owner=admin)
|
inbox = Tag.objects.create(name="Inbox", is_inbox_tag=True, owner=admin)
|
||||||
quick_filter = Tag.objects.create(name="Another Sample Tag", owner=admin)
|
quick_filter = Tag.objects.create(name="Another Sample Tag", owner=admin)
|
||||||
Tag.objects.create(name="TagWithPartial", owner=admin)
|
Tag.objects.create(name="TagWithPartial", owner=admin)
|
||||||
@@ -134,7 +142,9 @@ def seed_database() -> None:
|
|||||||
originals.mkdir(parents=True)
|
originals.mkdir(parents=True)
|
||||||
thumbnails = Path(os.environ["PAPERLESS_MEDIA_ROOT"]) / "documents" / "thumbnails"
|
thumbnails = Path(os.environ["PAPERLESS_MEDIA_ROOT"]) / "documents" / "thumbnails"
|
||||||
thumbnails.mkdir(parents=True)
|
thumbnails.mkdir(parents=True)
|
||||||
sample_pdf = SOURCE_ROOT / "documents" / "tests" / "samples" / "simple.pdf"
|
sample_pdf = (
|
||||||
|
SOURCE_ROOT / "paperless_testing" / "sample_files" / "simple-digital.pdf"
|
||||||
|
)
|
||||||
for document in documents:
|
for document in documents:
|
||||||
shutil.copyfile(sample_pdf, originals / document.filename)
|
shutil.copyfile(sample_pdf, originals / document.filename)
|
||||||
shutil.copyfile(
|
shutil.copyfile(
|
||||||
|
|||||||
@@ -80,12 +80,21 @@ test('should show a list of notes', async ({ page }) => {
|
|||||||
|
|
||||||
test('should support quick filters', async ({ page }) => {
|
test('should support quick filters', async ({ page }) => {
|
||||||
await page.goto('/documents/1/details')
|
await page.goto('/documents/1/details')
|
||||||
|
// filter button is available before tags have loaded
|
||||||
|
const selectedTags = page.locator('pngx-input-tags .ng-value pngx-tag')
|
||||||
|
await expect(selectedTags).toHaveCount(2)
|
||||||
|
await expect(
|
||||||
|
selectedTags.getByText('Another Sample Tag', { exact: true })
|
||||||
|
).toBeVisible()
|
||||||
|
await expect(selectedTags.getByText('Inbox', { exact: true })).toBeVisible()
|
||||||
await page
|
await page
|
||||||
.getByRole('button', { name: 'Filter documents with these Tags' })
|
.getByRole('button', { name: 'Filter documents with these Tags' })
|
||||||
.click()
|
.click()
|
||||||
await expect(page).toHaveURL(
|
await expect(page).toHaveURL((url) => {
|
||||||
/tags__id__all=2,1&sort=created&reverse=1&page=1/
|
const tags = url.searchParams.get('tags__id__all')?.split(',').sort()
|
||||||
)
|
return url.pathname === '/documents' && tags?.join(',') === '1,2'
|
||||||
|
})
|
||||||
|
await expect(page.locator('pngx-document-list')).toHaveText(/one document/i)
|
||||||
})
|
})
|
||||||
|
|
||||||
test('should finish reloading the preview after a remote document update', async ({
|
test('should finish reloading the preview after a remote document update', async ({
|
||||||
|
|||||||
@@ -123,7 +123,7 @@ test('sorting', async ({ page }) => {
|
|||||||
await page.getByRole('button', { name: 'Notes' }).click()
|
await page.getByRole('button', { name: 'Notes' }).click()
|
||||||
await expect(page).toHaveURL(/sort=num_notes/)
|
await expect(page).toHaveURL(/sort=num_notes/)
|
||||||
await page.getByRole('button', { name: 'Sort' }).click()
|
await page.getByRole('button', { name: 'Sort' }).click()
|
||||||
await page.locator('.w-100 > label > i-bs').first().click()
|
await page.locator('.w-100 > label > pngx-bs-icon').first().click()
|
||||||
await expect(page).not.toHaveURL(/reverse=1/)
|
await expect(page).not.toHaveURL(/reverse=1/)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -6,8 +6,15 @@ test.use({
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
test('should not allow user to edit settings', async ({ page }) => {
|
test.beforeEach(async ({ page }) => {
|
||||||
|
const settingsLoaded = page.waitForResponse((response) =>
|
||||||
|
response.url().endsWith('/api/ui_settings/')
|
||||||
|
)
|
||||||
await page.goto('/dashboard')
|
await page.goto('/dashboard')
|
||||||
|
expect((await settingsLoaded).status()).toBe(200)
|
||||||
|
})
|
||||||
|
|
||||||
|
test('should not allow user to edit settings', async ({ page }) => {
|
||||||
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')
|
||||||
await expect(page.locator('body')).toHaveText(
|
await expect(page.locator('body')).toHaveText(
|
||||||
@@ -16,7 +23,6 @@ test('should not allow user to edit settings', async ({ page }) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test('should not allow user to view documents', async ({ page }) => {
|
test('should not allow user to view documents', async ({ page }) => {
|
||||||
await page.goto('/dashboard')
|
|
||||||
await expect(
|
await expect(
|
||||||
page.locator('nav').getByRole('link', { name: 'Documents' })
|
page.locator('nav').getByRole('link', { name: 'Documents' })
|
||||||
).not.toBeAttached()
|
).not.toBeAttached()
|
||||||
@@ -31,7 +37,6 @@ test('should not allow user to view documents', async ({ page }) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test('should not allow user to view correspondents', async ({ page }) => {
|
test('should not allow user to view correspondents', async ({ page }) => {
|
||||||
await page.goto('/dashboard')
|
|
||||||
await expect(
|
await expect(
|
||||||
page.getByRole('link', { name: 'Attributes' })
|
page.getByRole('link', { name: 'Attributes' })
|
||||||
).not.toBeAttached()
|
).not.toBeAttached()
|
||||||
@@ -42,7 +47,6 @@ test('should not allow user to view correspondents', async ({ page }) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test('should not allow user to view tags', async ({ page }) => {
|
test('should not allow user to view tags', async ({ page }) => {
|
||||||
await page.goto('/dashboard')
|
|
||||||
await expect(
|
await expect(
|
||||||
page.getByRole('link', { name: 'Attributes' })
|
page.getByRole('link', { name: 'Attributes' })
|
||||||
).not.toBeAttached()
|
).not.toBeAttached()
|
||||||
@@ -53,7 +57,6 @@ test('should not allow user to view tags', async ({ page }) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test('should not allow user to view document types', async ({ page }) => {
|
test('should not allow user to view document types', async ({ page }) => {
|
||||||
await page.goto('/dashboard')
|
|
||||||
await expect(
|
await expect(
|
||||||
page.getByRole('link', { name: 'Attributes' })
|
page.getByRole('link', { name: 'Attributes' })
|
||||||
).not.toBeAttached()
|
).not.toBeAttached()
|
||||||
@@ -64,7 +67,6 @@ test('should not allow user to view document types', async ({ page }) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test('should not allow user to view storage paths', async ({ page }) => {
|
test('should not allow user to view storage paths', async ({ page }) => {
|
||||||
await page.goto('/dashboard')
|
|
||||||
await expect(
|
await expect(
|
||||||
page.getByRole('link', { name: 'Attributes' })
|
page.getByRole('link', { name: 'Attributes' })
|
||||||
).not.toBeAttached()
|
).not.toBeAttached()
|
||||||
@@ -75,7 +77,6 @@ test('should not allow user to view storage paths', async ({ page }) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test('should not allow user to view logs', async ({ page }) => {
|
test('should not allow user to view logs', async ({ page }) => {
|
||||||
await page.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')
|
||||||
await expect(page.locator('body')).toHaveText(
|
await expect(page.locator('body')).toHaveText(
|
||||||
@@ -84,7 +85,6 @@ test('should not allow user to view logs', async ({ page }) => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
test('should not allow user to view tasks', async ({ page }) => {
|
test('should not allow user to view tasks', async ({ page }) => {
|
||||||
await page.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')
|
||||||
await expect(page.locator('body')).toHaveText(
|
await expect(page.locator('body')).toHaveText(
|
||||||
|
|||||||
@@ -28,7 +28,7 @@ module.exports = {
|
|||||||
'abstract-paperless-service',
|
'abstract-paperless-service',
|
||||||
],
|
],
|
||||||
transformIgnorePatterns: [
|
transformIgnorePatterns: [
|
||||||
'node_modules/(?!.*(\\.mjs$|tslib|lodash-es|normalize-diacritics|@angular/common/locales/.*\\.js$))',
|
'node_modules/(?!.*(\\.mjs$|tslib|lodash-es|normalize-diacritics|marked|@angular/common/locales/.*\\.js$|bootstrap-icons/icons/.*\\.svg$))',
|
||||||
],
|
],
|
||||||
moduleNameMapper: {
|
moduleNameMapper: {
|
||||||
...esmPreset.moduleNameMapper,
|
...esmPreset.moduleNameMapper,
|
||||||
|
|||||||
+913
-665
File diff suppressed because it is too large.
Load diff
+32
-31
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "paperless-ngx-ui",
|
"name": "paperless-ngx-ui",
|
||||||
"version": "3.3.3",
|
"version": "3.4.1",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"preinstall": "npx only-allow pnpm",
|
"preinstall": "npx only-allow pnpm",
|
||||||
"ng": "ng",
|
"ng": "ng",
|
||||||
@@ -15,59 +15,60 @@
|
|||||||
},
|
},
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@angular/cdk": "^22.2.0",
|
"@angular/cdk": "^22.2.4",
|
||||||
"@angular/common": "~22.2.0",
|
"@angular/common": "~22.2.3",
|
||||||
"@angular/compiler": "~22.2.0",
|
"@angular/compiler": "~22.2.3",
|
||||||
"@angular/core": "~22.2.0",
|
"@angular/core": "~22.2.3",
|
||||||
"@angular/forms": "~22.2.0",
|
"@angular/forms": "~22.2.3",
|
||||||
"@angular/localize": "~22.2.0",
|
"@angular/localize": "~22.2.3",
|
||||||
"@angular/platform-browser": "~22.2.0",
|
"@angular/platform-browser": "~22.2.3",
|
||||||
"@angular/router": "~22.2.0",
|
"@angular/router": "~22.2.3",
|
||||||
"@ng-bootstrap/ng-bootstrap": "^21.0.0",
|
"@ng-bootstrap/ng-bootstrap": "^21.0.0",
|
||||||
"@ng-select/ng-select": "~24.1.2",
|
"@ng-select/ng-select": "~24.2.1",
|
||||||
"@ngneat/dirty-check-forms": "^3.0.3",
|
"@ngneat/dirty-check-forms": "^3.0.3",
|
||||||
"@popperjs/core": "^2.11.8",
|
"@popperjs/core": "^2.11.8",
|
||||||
"bootstrap": "^5.3.8",
|
"bootstrap": "^5.3.8",
|
||||||
|
"bootstrap-icons": "^1.13.2",
|
||||||
"file-saver": "^2.0.5",
|
"file-saver": "^2.0.5",
|
||||||
"lodash-es": "^4.18.1",
|
"lodash-es": "^4.18.1",
|
||||||
|
"marked": "~18.1.1",
|
||||||
"mime-names": "^1.0.0",
|
"mime-names": "^1.0.0",
|
||||||
"ngx-bootstrap-icons": "^1.9.3",
|
|
||||||
"ngx-color": "^10.1.0",
|
"ngx-color": "^10.1.0",
|
||||||
"ngx-cookie-service": "^22.0.0",
|
"ngx-cookie-service": "^22.0.0",
|
||||||
"ngx-device-detector": "^12.0.0",
|
"ngx-device-detector": "^12.0.0",
|
||||||
"ngx-ui-tour-ng-bootstrap": "^19.0.0",
|
"ngx-ui-tour-ng-bootstrap": "^19.0.0",
|
||||||
"normalize-diacritics": "^5.0.0",
|
"normalize-diacritics": "^5.0.0",
|
||||||
"pdfjs-dist": "^6.3.289",
|
"pdfjs-dist": "^6.4.299",
|
||||||
"rxjs": "^7.8.2",
|
"rxjs": "^7.8.2",
|
||||||
"tslib": "^2.8.1",
|
"tslib": "^2.8.1",
|
||||||
"utif": "^3.1.0",
|
"utif": "^3.1.0",
|
||||||
"uuid": "^14.0.2"
|
"uuid": "^14.0.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@angular-builders/jest": "^22.0.1",
|
"@angular-builders/jest": "^22.0.1",
|
||||||
"@angular-devkit/core": "^22.2.0",
|
"@angular-devkit/core": "^22.2.4",
|
||||||
"@angular-devkit/schematics": "^22.2.0",
|
"@angular-devkit/schematics": "^22.2.4",
|
||||||
"@angular-eslint/builder": "22.5.0",
|
"@angular-eslint/builder": "22.6.0",
|
||||||
"@angular-eslint/eslint-plugin": "22.5.0",
|
"@angular-eslint/eslint-plugin": "22.6.0",
|
||||||
"@angular-eslint/eslint-plugin-template": "22.5.0",
|
"@angular-eslint/eslint-plugin-template": "22.6.0",
|
||||||
"@angular-eslint/schematics": "22.5.0",
|
"@angular-eslint/schematics": "22.6.0",
|
||||||
"@angular-eslint/template-parser": "22.5.0",
|
"@angular-eslint/template-parser": "22.6.0",
|
||||||
"@angular/build": "22.2.0",
|
"@angular/build": "22.2.4",
|
||||||
"@angular/cli": "22.2.0",
|
"@angular/cli": "22.2.4",
|
||||||
"@angular/compiler-cli": "~22.2.0",
|
"@angular/compiler-cli": "~22.2.3",
|
||||||
"@playwright/test": "^1.62.1",
|
"@playwright/test": "^1.64.1",
|
||||||
"@types/jest": "^30.0.0",
|
"@types/jest": "^30.0.0",
|
||||||
"@types/node": "^26.6.2",
|
"@types/node": "^26.6.5",
|
||||||
"@typescript-eslint/eslint-plugin": "^8.70.1",
|
"@typescript-eslint/eslint-plugin": "^8.72.0",
|
||||||
"@typescript-eslint/parser": "^8.70.1",
|
"@typescript-eslint/parser": "^8.72.0",
|
||||||
"@typescript-eslint/utils": "^8.70.1",
|
"@typescript-eslint/utils": "^8.72.0",
|
||||||
"eslint": "^10.11.0",
|
"eslint": "^10.13.0",
|
||||||
"jest": "30.5.2",
|
"jest": "30.5.2",
|
||||||
"jest-environment-jsdom": "^30.5.2",
|
"jest-environment-jsdom": "^30.5.2",
|
||||||
"jest-junit": "^17.0.0",
|
"jest-junit": "^17.0.0",
|
||||||
"jest-preset-angular": "^17.0.0",
|
"jest-preset-angular": "^17.0.3",
|
||||||
"jest-websocket-mock": "^2.5.0",
|
"jest-websocket-mock": "^2.5.0",
|
||||||
"prettier": "^3.9.9",
|
"prettier": "^3.9.10",
|
||||||
"prettier-plugin-organize-imports": "^4.3.0",
|
"prettier-plugin-organize-imports": "^4.3.0",
|
||||||
"ts-node": "~10.9.2",
|
"ts-node": "~10.9.2",
|
||||||
"typescript": "^6.0.3"
|
"typescript": "^6.0.3"
|
||||||
|
|||||||
Generated
+1002
-803
File diff suppressed because it is too large.
Load diff
@@ -24,3 +24,6 @@ onlyBuiltDependencies:
|
|||||||
ignoredBuiltDependencies:
|
ignoredBuiltDependencies:
|
||||||
- normalize-diacritics
|
- normalize-diacritics
|
||||||
- unrs-resolver
|
- unrs-resolver
|
||||||
|
overrides:
|
||||||
|
"vite@>=8.3.0 <8.3.3": "^8.3.3"
|
||||||
|
"zod@<4.6.6": "^4.6.6"
|
||||||
@@ -3,7 +3,6 @@ import { provideHttpClientTesting } from '@angular/common/http/testing'
|
|||||||
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||||
import { Router, RouterModule } from '@angular/router'
|
import { Router, RouterModule } from '@angular/router'
|
||||||
import { NgbModalModule } from '@ng-bootstrap/ng-bootstrap'
|
import { NgbModalModule } from '@ng-bootstrap/ng-bootstrap'
|
||||||
import { allIcons, NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
|
||||||
import {
|
import {
|
||||||
provideUiTour,
|
provideUiTour,
|
||||||
TourNgBootstrap,
|
TourNgBootstrap,
|
||||||
@@ -19,6 +18,7 @@ import { PermissionsGuard } from './guards/permissions.guard'
|
|||||||
import { HotKeyService } from './services/hot-key.service'
|
import { HotKeyService } from './services/hot-key.service'
|
||||||
import { PermissionsService } from './services/permissions.service'
|
import { PermissionsService } from './services/permissions.service'
|
||||||
import { SettingsService } from './services/settings.service'
|
import { SettingsService } from './services/settings.service'
|
||||||
|
import { TasksService } from './services/tasks.service'
|
||||||
import { Toast, ToastService } from './services/toast.service'
|
import { Toast, ToastService } from './services/toast.service'
|
||||||
import {
|
import {
|
||||||
FileStatus,
|
FileStatus,
|
||||||
@@ -34,6 +34,7 @@ describe('AppComponent', () => {
|
|||||||
let toastService: ToastService
|
let toastService: ToastService
|
||||||
let router: Router
|
let router: Router
|
||||||
let settingsService: SettingsService
|
let settingsService: SettingsService
|
||||||
|
let tasksService: TasksService
|
||||||
let hotKeyService: HotKeyService
|
let hotKeyService: HotKeyService
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
@@ -45,7 +46,6 @@ describe('AppComponent', () => {
|
|||||||
ToastsComponent,
|
ToastsComponent,
|
||||||
FileDropComponent,
|
FileDropComponent,
|
||||||
TourNgBootstrap,
|
TourNgBootstrap,
|
||||||
NgxBootstrapIconsModule.pick(allIcons),
|
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
PermissionsGuard,
|
PermissionsGuard,
|
||||||
@@ -60,6 +60,7 @@ describe('AppComponent', () => {
|
|||||||
websocketStatusService = TestBed.inject(WebsocketStatusService)
|
websocketStatusService = TestBed.inject(WebsocketStatusService)
|
||||||
permissionsService = TestBed.inject(PermissionsService)
|
permissionsService = TestBed.inject(PermissionsService)
|
||||||
settingsService = TestBed.inject(SettingsService)
|
settingsService = TestBed.inject(SettingsService)
|
||||||
|
tasksService = TestBed.inject(TasksService)
|
||||||
toastService = TestBed.inject(ToastService)
|
toastService = TestBed.inject(ToastService)
|
||||||
router = TestBed.inject(Router)
|
router = TestBed.inject(Router)
|
||||||
hotKeyService = TestBed.inject(HotKeyService)
|
hotKeyService = TestBed.inject(HotKeyService)
|
||||||
@@ -117,30 +118,33 @@ describe('AppComponent', () => {
|
|||||||
expect(toast.action).toBeUndefined()
|
expect(toast.action).toBeUndefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should display toast on document added', () => {
|
it('should only reload tasks when a document is detected', () => {
|
||||||
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
|
|
||||||
const toastSpy = jest.spyOn(toastService, 'show')
|
const toastSpy = jest.spyOn(toastService, 'show')
|
||||||
|
const reloadSpy = jest.spyOn(tasksService, 'reload')
|
||||||
const fileStatusSubject = new Subject<FileStatus>()
|
const fileStatusSubject = new Subject<FileStatus>()
|
||||||
jest
|
jest
|
||||||
.spyOn(websocketStatusService, 'onDocumentDetected')
|
.spyOn(websocketStatusService, 'onDocumentDetected')
|
||||||
.mockReturnValue(fileStatusSubject)
|
.mockReturnValue(fileStatusSubject)
|
||||||
component.ngOnInit()
|
component.ngOnInit()
|
||||||
fileStatusSubject.next(new FileStatus())
|
fileStatusSubject.next(new FileStatus())
|
||||||
expect(toastSpy).toHaveBeenCalled()
|
expect(reloadSpy).toHaveBeenCalled()
|
||||||
|
expect(toastSpy).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should suppress dashboard notifications if set', () => {
|
it('should add consumed documents to the bell without a popup', () => {
|
||||||
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
|
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
|
||||||
jest.spyOn(settingsService, 'get').mockReturnValue(true)
|
const popupSpy = jest.fn()
|
||||||
jest.spyOn(router, 'url', 'get').mockReturnValue('/dashboard')
|
toastService.showToast.subscribe(popupSpy)
|
||||||
const toastSpy = jest.spyOn(toastService, 'show')
|
let toasts: Toast[] = []
|
||||||
|
toastService.getToasts().subscribe((t) => (toasts = [...t]))
|
||||||
const fileStatusSubject = new Subject<FileStatus>()
|
const fileStatusSubject = new Subject<FileStatus>()
|
||||||
jest
|
jest
|
||||||
.spyOn(websocketStatusService, 'onDocumentDetected')
|
.spyOn(websocketStatusService, 'onDocumentConsumptionFinished')
|
||||||
.mockReturnValue(fileStatusSubject)
|
.mockReturnValue(fileStatusSubject)
|
||||||
component.ngOnInit()
|
component.ngOnInit()
|
||||||
fileStatusSubject.next(new FileStatus())
|
fileStatusSubject.next(new FileStatus())
|
||||||
expect(toastSpy).not.toHaveBeenCalled()
|
expect(toasts).toHaveLength(1)
|
||||||
|
expect(popupSpy).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should display toast on document failed', () => {
|
it('should display toast on document failed', () => {
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import {
|
|||||||
} from './services/permissions.service'
|
} from './services/permissions.service'
|
||||||
import { SettingsService } from './services/settings.service'
|
import { SettingsService } from './services/settings.service'
|
||||||
import { TasksService } from './services/tasks.service'
|
import { TasksService } from './services/tasks.service'
|
||||||
import { ToastService } from './services/toast.service'
|
import { ToastLevel, ToastService } from './services/toast.service'
|
||||||
import { WebsocketStatusService } from './services/websocket-status.service'
|
import { WebsocketStatusService } from './services/websocket-status.service'
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
@@ -61,18 +61,6 @@ export class AppComponent implements OnInit, OnDestroy {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private showNotification(key) {
|
|
||||||
if (
|
|
||||||
this.router.url == '/dashboard' &&
|
|
||||||
this.settings.get(
|
|
||||||
SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_SUPPRESS_ON_DASHBOARD
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
return this.settings.get(key)
|
|
||||||
}
|
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
this.websocketStatusService.connect()
|
this.websocketStatusService.connect()
|
||||||
|
|
||||||
@@ -80,29 +68,22 @@ export class AppComponent implements OnInit, OnDestroy {
|
|||||||
.onDocumentConsumptionFinished()
|
.onDocumentConsumptionFinished()
|
||||||
.subscribe((status) => {
|
.subscribe((status) => {
|
||||||
this.tasksService.reload()
|
this.tasksService.reload()
|
||||||
if (
|
if (this.settings.get(SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_SUCCESS)) {
|
||||||
this.showNotification(SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_SUCCESS)
|
const canView = this.permissionsService.currentUserCan(
|
||||||
) {
|
|
||||||
if (
|
|
||||||
this.permissionsService.currentUserCan(
|
|
||||||
PermissionAction.View,
|
PermissionAction.View,
|
||||||
PermissionType.Document
|
PermissionType.Document
|
||||||
)
|
)
|
||||||
) {
|
// the upload status tray already shows this, so only keep a record
|
||||||
this.toastService.show({
|
this.toastService.show({
|
||||||
content: $localize`Document ${status.filename} was added to Paperless-ngx.`,
|
content: $localize`Document ${status.filename} was added to Paperless-ngx.`,
|
||||||
delay: 10000,
|
delay: 10000,
|
||||||
actionName: $localize`Open document`,
|
level: ToastLevel.Success,
|
||||||
action: () => {
|
silent: true,
|
||||||
this.router.navigate(['documents', status.documentId])
|
actionName: canView ? $localize`Open document` : undefined,
|
||||||
},
|
action: canView
|
||||||
|
? () => this.router.navigate(['documents', status.documentId])
|
||||||
|
: undefined,
|
||||||
})
|
})
|
||||||
} else {
|
|
||||||
this.toastService.show({
|
|
||||||
content: $localize`Document ${status.filename} was added to Paperless-ngx.`,
|
|
||||||
delay: 10000,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -110,9 +91,7 @@ export class AppComponent implements OnInit, OnDestroy {
|
|||||||
.onDocumentConsumptionFailed()
|
.onDocumentConsumptionFailed()
|
||||||
.subscribe((status) => {
|
.subscribe((status) => {
|
||||||
this.tasksService.reload()
|
this.tasksService.reload()
|
||||||
if (
|
if (this.settings.get(SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_FAILED)) {
|
||||||
this.showNotification(SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_FAILED)
|
|
||||||
) {
|
|
||||||
this.toastService.showError(
|
this.toastService.showError(
|
||||||
$localize`Could not add ${status.filename}\: ${status.message}`
|
$localize`Could not add ${status.filename}\: ${status.message}`
|
||||||
)
|
)
|
||||||
@@ -121,18 +100,8 @@ export class AppComponent implements OnInit, OnDestroy {
|
|||||||
|
|
||||||
this.newDocumentSubscription = this.websocketStatusService
|
this.newDocumentSubscription = this.websocketStatusService
|
||||||
.onDocumentDetected()
|
.onDocumentDetected()
|
||||||
.subscribe((status) => {
|
.subscribe(() => {
|
||||||
this.tasksService.reload()
|
this.tasksService.reload()
|
||||||
if (
|
|
||||||
this.showNotification(
|
|
||||||
SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_NEW_DOCUMENT
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
this.toastService.show({
|
|
||||||
content: $localize`Document ${status.filename} is being processed by Paperless-ngx.`,
|
|
||||||
delay: 5000,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
})
|
})
|
||||||
|
|
||||||
this.hotKeyService
|
this.hotKeyService
|
||||||
|
|||||||
@@ -28,7 +28,7 @@
|
|||||||
{{option.title}}
|
{{option.title}}
|
||||||
</h6>
|
</h6>
|
||||||
<a class="btn btn-sm btn-link" title="Read the documentation about this setting" i18n-title [href]="getDocsUrl(option.config_key)" target="_blank" referrerpolicy="no-referrer">
|
<a class="btn btn-sm btn-link" title="Read the documentation about this setting" i18n-title [href]="getDocsUrl(option.config_key)" target="_blank" referrerpolicy="no-referrer">
|
||||||
<i-bs name="info-circle"></i-bs>
|
<pngx-bs-icon name="info-circle"></pngx-bs-icon>
|
||||||
</a>
|
</a>
|
||||||
@if (isExternallyConfigured(option.config_key)) {
|
@if (isExternallyConfigured(option.config_key)) {
|
||||||
@if (isSet(option.key)) {
|
@if (isSet(option.key)) {
|
||||||
@@ -40,11 +40,11 @@
|
|||||||
@if (isSet(option.key)) {
|
@if (isSet(option.key)) {
|
||||||
@if (isExternallyConfigured(option.config_key)) {
|
@if (isExternallyConfigured(option.config_key)) {
|
||||||
<button type="button" class="btn btn-sm btn-link text-danger ms-auto pe-0" title="Use the externally configured value" i18n-title (click)="resetOption(option.key)">
|
<button type="button" class="btn btn-sm btn-link text-danger ms-auto pe-0" title="Use the externally configured value" i18n-title (click)="resetOption(option.key)">
|
||||||
<i-bs class="me-1" name="x"></i-bs><ng-container i18n>Reset to external</ng-container>
|
<pngx-bs-icon class="me-1" name="x"></pngx-bs-icon><ng-container i18n>Reset to external</ng-container>
|
||||||
</button>
|
</button>
|
||||||
} @else {
|
} @else {
|
||||||
<button type="button" class="btn btn-sm btn-link text-danger ms-auto pe-0" title="Reset" i18n-title (click)="resetOption(option.key)">
|
<button type="button" class="btn btn-sm btn-link text-danger ms-auto pe-0" title="Reset" i18n-title (click)="resetOption(option.key)">
|
||||||
<i-bs class="me-1" name="x"></i-bs><ng-container i18n>Reset</ng-container>
|
<pngx-bs-icon class="me-1" name="x"></pngx-bs-icon><ng-container i18n>Reset</ng-container>
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,7 +6,6 @@ import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
|||||||
import { BrowserModule } from '@angular/platform-browser'
|
import { BrowserModule } from '@angular/platform-browser'
|
||||||
import { NgbModule } from '@ng-bootstrap/ng-bootstrap'
|
import { NgbModule } from '@ng-bootstrap/ng-bootstrap'
|
||||||
import { NgSelectModule } from '@ng-select/ng-select'
|
import { NgSelectModule } from '@ng-select/ng-select'
|
||||||
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
|
||||||
import { of, throwError } from 'rxjs'
|
import { of, throwError } from 'rxjs'
|
||||||
import {
|
import {
|
||||||
ConfigCategory,
|
ConfigCategory,
|
||||||
@@ -39,7 +38,6 @@ describe('ConfigComponent', () => {
|
|||||||
NgSelectModule,
|
NgSelectModule,
|
||||||
FormsModule,
|
FormsModule,
|
||||||
ReactiveFormsModule,
|
ReactiveFormsModule,
|
||||||
NgxBootstrapIconsModule.pick(allIcons),
|
|
||||||
ConfigComponent,
|
ConfigComponent,
|
||||||
TextComponent,
|
TextComponent,
|
||||||
SelectComponent,
|
SelectComponent,
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import {
|
|||||||
} from '@angular/forms'
|
} from '@angular/forms'
|
||||||
import { NgbNavModule } from '@ng-bootstrap/ng-bootstrap'
|
import { NgbNavModule } from '@ng-bootstrap/ng-bootstrap'
|
||||||
import { DirtyComponent, dirtyCheck } from '@ngneat/dirty-check-forms'
|
import { DirtyComponent, dirtyCheck } from '@ngneat/dirty-check-forms'
|
||||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
|
||||||
import {
|
import {
|
||||||
BehaviorSubject,
|
BehaviorSubject,
|
||||||
Observable,
|
Observable,
|
||||||
@@ -17,6 +16,7 @@ import {
|
|||||||
first,
|
first,
|
||||||
takeUntil,
|
takeUntil,
|
||||||
} from 'rxjs'
|
} from 'rxjs'
|
||||||
|
import { BootstrapIconComponent } from 'src/app/components/common/bootstrap-icon/bootstrap-icon.component'
|
||||||
import {
|
import {
|
||||||
ConfigCategory,
|
ConfigCategory,
|
||||||
ConfigOption,
|
ConfigOption,
|
||||||
@@ -52,7 +52,7 @@ import { LoadingComponentWithPermissions } from '../../loading-component/loading
|
|||||||
NgbNavModule,
|
NgbNavModule,
|
||||||
FormsModule,
|
FormsModule,
|
||||||
ReactiveFormsModule,
|
ReactiveFormsModule,
|
||||||
NgxBootstrapIconsModule,
|
BootstrapIconComponent,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class ConfigComponent
|
export class ConfigComponent
|
||||||
@@ -190,7 +190,7 @@ export class ConfigComponent
|
|||||||
this.initialize(config)
|
this.initialize(config)
|
||||||
this.store.next(config)
|
this.store.next(config)
|
||||||
this.settingsService.initializeSettings().subscribe()
|
this.settingsService.initializeSettings().subscribe()
|
||||||
this.toastService.showInfo($localize`Configuration updated`)
|
this.toastService.showSuccess($localize`Configuration updated`)
|
||||||
},
|
},
|
||||||
error: (e) => {
|
error: (e) => {
|
||||||
this.loading.set(false)
|
this.loading.set(false)
|
||||||
@@ -217,7 +217,7 @@ export class ConfigComponent
|
|||||||
this.initialize(config)
|
this.initialize(config)
|
||||||
this.store.next(config)
|
this.store.next(config)
|
||||||
this.settingsService.initializeSettings().subscribe()
|
this.settingsService.initializeSettings().subscribe()
|
||||||
this.toastService.showInfo($localize`File successfully updated`)
|
this.toastService.showSuccess($localize`File successfully updated`)
|
||||||
},
|
},
|
||||||
error: (e) => {
|
error: (e) => {
|
||||||
this.loading.set(false)
|
this.loading.set(false)
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import { provideHttpClientTesting } from '@angular/common/http/testing'
|
|||||||
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||||
import { BrowserModule, By } from '@angular/platform-browser'
|
import { BrowserModule, By } from '@angular/platform-browser'
|
||||||
import { NgbModule, NgbNavLink } from '@ng-bootstrap/ng-bootstrap'
|
import { NgbModule, NgbNavLink } from '@ng-bootstrap/ng-bootstrap'
|
||||||
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
|
||||||
import { of, throwError } from 'rxjs'
|
import { of, throwError } from 'rxjs'
|
||||||
import { LogService } from 'src/app/services/rest/log.service'
|
import { LogService } from 'src/app/services/rest/log.service'
|
||||||
import { PageHeaderComponent } from '../../common/page-header/page-header.component'
|
import { PageHeaderComponent } from '../../common/page-header/page-header.component'
|
||||||
@@ -40,7 +39,6 @@ describe('LogsComponent', () => {
|
|||||||
imports: [
|
imports: [
|
||||||
BrowserModule,
|
BrowserModule,
|
||||||
NgbModule,
|
NgbModule,
|
||||||
NgxBootstrapIconsModule.pick(allIcons),
|
|
||||||
LogsComponent,
|
LogsComponent,
|
||||||
PageHeaderComponent,
|
PageHeaderComponent,
|
||||||
CommonModule,
|
CommonModule,
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
i18n-info
|
i18n-info
|
||||||
>
|
>
|
||||||
<button class="btn btn-sm btn-outline-primary" (click)="tourService.start()">
|
<button class="btn btn-sm btn-outline-primary" (click)="tourService.start()">
|
||||||
<i-bs class="me-2" name="airplane"></i-bs><ng-container i18n>Start tour</ng-container>
|
<pngx-bs-icon class="me-2" name="airplane"></pngx-bs-icon><ng-container i18n>Start tour</ng-container>
|
||||||
</button>
|
</button>
|
||||||
@if (canViewSystemStatus) {
|
@if (canViewSystemStatus) {
|
||||||
<button class="btn btn-sm btn-outline-primary position-relative ms-md-5 me-1" (click)="showSystemStatus()"
|
<button class="btn btn-sm btn-outline-primary position-relative ms-md-5 me-1" (click)="showSystemStatus()"
|
||||||
@@ -13,14 +13,14 @@
|
|||||||
@if (!systemStatus()) {
|
@if (!systemStatus()) {
|
||||||
<div class="spinner-border spinner-border-sm me-2 h-75" role="status"></div>
|
<div class="spinner-border spinner-border-sm me-2 h-75" role="status"></div>
|
||||||
} @else {
|
} @else {
|
||||||
<i-bs class="me-2" name="card-checklist"></i-bs>
|
<pngx-bs-icon class="me-2" name="card-checklist"></pngx-bs-icon>
|
||||||
@if (systemStatusHasErrors) {
|
@if (systemStatusHasErrors) {
|
||||||
<span class="badge bg-body position-absolute top-0 start-100 translate-middle rounded-pill p-0">
|
<span class="badge bg-body position-absolute top-0 start-100 translate-middle rounded-pill p-0">
|
||||||
<i-bs name="exclamation-circle-fill" class="text-danger" width="1.75em" height="1.75em"></i-bs>
|
<pngx-bs-icon name="exclamation-circle-fill" class="text-danger" width="1.75em" height="1.75em"></pngx-bs-icon>
|
||||||
</span>
|
</span>
|
||||||
} @else {
|
} @else {
|
||||||
<span class="badge bg-body position-absolute top-0 start-100 translate-middle rounded-pill p-0">
|
<span class="badge bg-body position-absolute top-0 start-100 translate-middle rounded-pill p-0">
|
||||||
<i-bs name="check-circle-fill" class="text-primary" width="1.75em" height="1.75em"></i-bs>
|
<pngx-bs-icon name="check-circle-fill" class="text-primary" width="1.75em" height="1.75em"></pngx-bs-icon>
|
||||||
</span>
|
</span>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -30,7 +30,7 @@
|
|||||||
@if (permissionsService.isAdmin()) {
|
@if (permissionsService.isAdmin()) {
|
||||||
<a class="btn btn-sm btn-primary" href="admin/" target="_blank">
|
<a class="btn btn-sm btn-primary" href="admin/" target="_blank">
|
||||||
<ng-container i18n>Open Django Admin</ng-container>
|
<ng-container i18n>Open Django Admin</ng-container>
|
||||||
<i-bs class="ms-2" name="arrow-up-right"></i-bs>
|
<pngx-bs-icon class="ms-2" name="arrow-up-right"></pngx-bs-icon>
|
||||||
</a>
|
</a>
|
||||||
}
|
}
|
||||||
</pngx-page-header>
|
</pngx-page-header>
|
||||||
@@ -151,7 +151,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="col-2">
|
<div class="col-2">
|
||||||
<button class="btn btn-link btn-sm pt-2 ps-0" [disabled]="!this.settingsForm.get('themeColor').value" (click)="clearThemeColor()">
|
<button class="btn btn-link btn-sm pt-2 ps-0" [disabled]="!this.settingsForm.get('themeColor').value" (click)="clearThemeColor()">
|
||||||
<i-bs width="1em" height="1em" name="x"></i-bs><ng-container i18n>Reset</ng-container>
|
<pngx-bs-icon width="1em" height="1em" name="x"></pngx-bs-icon><ng-container i18n>Reset</ng-container>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -181,7 +181,7 @@
|
|||||||
<div class="col d-flex flex-row align-items-start">
|
<div class="col d-flex flex-row align-items-start">
|
||||||
<pngx-input-check i18n-title title="Enable update checking" formControlName="updateCheckingEnabled"></pngx-input-check>
|
<pngx-input-check i18n-title title="Enable update checking" formControlName="updateCheckingEnabled"></pngx-input-check>
|
||||||
<button class="btn btn-sm btn-link text-muted me-auto p-0 ms-2" title="What's this?" i18n-title type="button" triggers="mouseenter:mouseleave" [ngbPopover]="updatesPopover" [autoClose]="true">
|
<button class="btn btn-sm btn-link text-muted me-auto p-0 ms-2" title="What's this?" i18n-title type="button" triggers="mouseenter:mouseleave" [ngbPopover]="updatesPopover" [autoClose]="true">
|
||||||
<i-bs name="question-circle"></i-bs>
|
<pngx-bs-icon name="question-circle"></pngx-bs-icon>
|
||||||
</button>
|
</button>
|
||||||
<ng-template #updatesPopover>
|
<ng-template #updatesPopover>
|
||||||
<p i18n>
|
<p i18n>
|
||||||
@@ -263,9 +263,9 @@
|
|||||||
<input type="radio" class="btn-check" id="suggestionSourceBoth" [value]="SuggestionSource.Both" formControlName="documentEditingSuggestionSource">
|
<input type="radio" class="btn-check" id="suggestionSourceBoth" [value]="SuggestionSource.Both" formControlName="documentEditingSuggestionSource">
|
||||||
<label class="btn btn-outline-primary" for="suggestionSourceBoth"><ng-container i18n>Both</ng-container></label>
|
<label class="btn btn-outline-primary" for="suggestionSourceBoth"><ng-container i18n>Both</ng-container></label>
|
||||||
<input type="radio" class="btn-check" id="suggestionSourceML" [value]="SuggestionSource.ML" formControlName="documentEditingSuggestionSource">
|
<input type="radio" class="btn-check" id="suggestionSourceML" [value]="SuggestionSource.ML" formControlName="documentEditingSuggestionSource">
|
||||||
<label class="btn btn-outline-primary" for="suggestionSourceML"><i-bs class="me-1" name="cpu"></i-bs><ng-container i18n>ML only</ng-container></label>
|
<label class="btn btn-outline-primary" for="suggestionSourceML"><pngx-bs-icon class="me-1" name="cpu"></pngx-bs-icon><ng-container i18n>ML only</ng-container></label>
|
||||||
<input type="radio" class="btn-check" id="suggestionSourceAI" [value]="SuggestionSource.AI" formControlName="documentEditingSuggestionSource">
|
<input type="radio" class="btn-check" id="suggestionSourceAI" [value]="SuggestionSource.AI" formControlName="documentEditingSuggestionSource">
|
||||||
<label class="btn btn-outline-primary" for="suggestionSourceAI"><i-bs class="me-1" name="stars"></i-bs><ng-container i18n>AI only</ng-container></label>
|
<label class="btn btn-outline-primary" for="suggestionSourceAI"><pngx-bs-icon class="me-1" name="stars"></pngx-bs-icon><ng-container i18n>AI only</ng-container></label>
|
||||||
</fieldset>
|
</fieldset>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -425,10 +425,8 @@
|
|||||||
|
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col">
|
<div class="col">
|
||||||
<pngx-input-check i18n-title title="Show notifications when new documents are detected" formControlName="notificationsConsumerNewDocument"></pngx-input-check>
|
<pngx-input-check i18n-title title="Add notifications when document processing completes successfully" formControlName="notificationsConsumerSuccess" i18n-hint hint="These are added to the notifications menu without a popup, processing progress is shown at the bottom of the screen."></pngx-input-check>
|
||||||
<pngx-input-check i18n-title title="Show notifications when document processing completes successfully" formControlName="notificationsConsumerSuccess"></pngx-input-check>
|
|
||||||
<pngx-input-check i18n-title title="Show notifications when document processing fails" formControlName="notificationsConsumerFailed"></pngx-input-check>
|
<pngx-input-check i18n-title title="Show notifications when document processing fails" formControlName="notificationsConsumerFailed"></pngx-input-check>
|
||||||
<pngx-input-check i18n-title title="Suppress notifications on dashboard" formControlName="notificationsConsumerSuppressOnDashboard" i18n-hint hint="This will suppress all messages about document processing status on the dashboard."></pngx-input-check>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ import {
|
|||||||
NgbNavLink,
|
NgbNavLink,
|
||||||
} from '@ng-bootstrap/ng-bootstrap'
|
} from '@ng-bootstrap/ng-bootstrap'
|
||||||
import { NgSelectModule } from '@ng-select/ng-select'
|
import { NgSelectModule } from '@ng-select/ng-select'
|
||||||
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
|
||||||
import { provideUiTour } from 'ngx-ui-tour-ng-bootstrap'
|
import { provideUiTour } from 'ngx-ui-tour-ng-bootstrap'
|
||||||
import { of, throwError } from 'rxjs'
|
import { of, throwError } from 'rxjs'
|
||||||
import { routes } from 'src/app/app-routing.module'
|
import { routes } from 'src/app/app-routing.module'
|
||||||
@@ -133,7 +132,6 @@ describe('SettingsComponent', () => {
|
|||||||
ReactiveFormsModule,
|
ReactiveFormsModule,
|
||||||
NgbAlertModule,
|
NgbAlertModule,
|
||||||
NgSelectModule,
|
NgSelectModule,
|
||||||
NgxBootstrapIconsModule.pick(allIcons),
|
|
||||||
NgbModalModule,
|
NgbModalModule,
|
||||||
DragDropModule,
|
DragDropModule,
|
||||||
SettingsComponent,
|
SettingsComponent,
|
||||||
@@ -307,7 +305,7 @@ describe('SettingsComponent', () => {
|
|||||||
expect(toastErrorSpy).toHaveBeenCalled()
|
expect(toastErrorSpy).toHaveBeenCalled()
|
||||||
expect(storeSpy).toHaveBeenCalled()
|
expect(storeSpy).toHaveBeenCalled()
|
||||||
expect(appearanceSettingsSpy).not.toHaveBeenCalled()
|
expect(appearanceSettingsSpy).not.toHaveBeenCalled()
|
||||||
expect(setSpy).toHaveBeenCalledTimes(35)
|
expect(setSpy).toHaveBeenCalledTimes(33)
|
||||||
expect(setSpy).toHaveBeenCalledWith(SETTINGS_KEYS.SIDEBAR_HIDDEN_ITEMS, [
|
expect(setSpy).toHaveBeenCalledWith(SETTINGS_KEYS.SIDEBAR_HIDDEN_ITEMS, [
|
||||||
HideableSidebarItemID.Workflows,
|
HideableSidebarItemID.Workflows,
|
||||||
])
|
])
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ import {
|
|||||||
NgbPopoverModule,
|
NgbPopoverModule,
|
||||||
} from '@ng-bootstrap/ng-bootstrap'
|
} from '@ng-bootstrap/ng-bootstrap'
|
||||||
import { DirtyComponent, dirtyCheck } from '@ngneat/dirty-check-forms'
|
import { DirtyComponent, dirtyCheck } from '@ngneat/dirty-check-forms'
|
||||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
|
||||||
import { TourService } from 'ngx-ui-tour-ng-bootstrap'
|
import { TourService } from 'ngx-ui-tour-ng-bootstrap'
|
||||||
import {
|
import {
|
||||||
BehaviorSubject,
|
BehaviorSubject,
|
||||||
@@ -34,6 +33,7 @@ import {
|
|||||||
takeUntil,
|
takeUntil,
|
||||||
tap,
|
tap,
|
||||||
} from 'rxjs'
|
} from 'rxjs'
|
||||||
|
import { BootstrapIconComponent } from 'src/app/components/common/bootstrap-icon/bootstrap-icon.component'
|
||||||
import { Group } from 'src/app/data/group'
|
import { Group } from 'src/app/data/group'
|
||||||
import {
|
import {
|
||||||
SystemStatus,
|
SystemStatus,
|
||||||
@@ -63,7 +63,7 @@ import {
|
|||||||
SettingsService,
|
SettingsService,
|
||||||
} from 'src/app/services/settings.service'
|
} from 'src/app/services/settings.service'
|
||||||
import { SystemStatusService } from 'src/app/services/system-status.service'
|
import { SystemStatusService } from 'src/app/services/system-status.service'
|
||||||
import { Toast, ToastService } from 'src/app/services/toast.service'
|
import { Toast, ToastLevel, ToastService } from 'src/app/services/toast.service'
|
||||||
import { locationReload } from 'src/app/utils/navigation'
|
import { locationReload } from 'src/app/utils/navigation'
|
||||||
import { CheckComponent } from '../../common/input/check/check.component'
|
import { CheckComponent } from '../../common/input/check/check.component'
|
||||||
import { ColorComponent } from '../../common/input/color/color.component'
|
import { ColorComponent } from '../../common/input/color/color.component'
|
||||||
@@ -135,7 +135,7 @@ const sidebarItemLabels: Record<HideableSidebarItemID, string> = {
|
|||||||
ReactiveFormsModule,
|
ReactiveFormsModule,
|
||||||
NgbNavModule,
|
NgbNavModule,
|
||||||
NgbPopoverModule,
|
NgbPopoverModule,
|
||||||
NgxBootstrapIconsModule,
|
BootstrapIconComponent,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class SettingsComponent
|
export class SettingsComponent
|
||||||
@@ -190,10 +190,8 @@ export class SettingsComponent
|
|||||||
searchDbOnly: new FormControl(null),
|
searchDbOnly: new FormControl(null),
|
||||||
searchLink: new FormControl(null),
|
searchLink: new FormControl(null),
|
||||||
|
|
||||||
notificationsConsumerNewDocument: new FormControl(null),
|
|
||||||
notificationsConsumerSuccess: new FormControl(null),
|
notificationsConsumerSuccess: new FormControl(null),
|
||||||
notificationsConsumerFailed: new FormControl(null),
|
notificationsConsumerFailed: new FormControl(null),
|
||||||
notificationsConsumerSuppressOnDashboard: new FormControl(null),
|
|
||||||
|
|
||||||
savedViewsWarnOnUnsavedChange: new FormControl(null),
|
savedViewsWarnOnUnsavedChange: new FormControl(null),
|
||||||
sidebarViewsShowCount: new FormControl(null),
|
sidebarViewsShowCount: new FormControl(null),
|
||||||
@@ -371,18 +369,12 @@ export class SettingsComponent
|
|||||||
updateCheckingEnabled: this.settings.get(
|
updateCheckingEnabled: this.settings.get(
|
||||||
SETTINGS_KEYS.UPDATE_CHECKING_ENABLED
|
SETTINGS_KEYS.UPDATE_CHECKING_ENABLED
|
||||||
),
|
),
|
||||||
notificationsConsumerNewDocument: this.settings.get(
|
|
||||||
SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_NEW_DOCUMENT
|
|
||||||
),
|
|
||||||
notificationsConsumerSuccess: this.settings.get(
|
notificationsConsumerSuccess: this.settings.get(
|
||||||
SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_SUCCESS
|
SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_SUCCESS
|
||||||
),
|
),
|
||||||
notificationsConsumerFailed: this.settings.get(
|
notificationsConsumerFailed: this.settings.get(
|
||||||
SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_FAILED
|
SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_FAILED
|
||||||
),
|
),
|
||||||
notificationsConsumerSuppressOnDashboard: this.settings.get(
|
|
||||||
SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_SUPPRESS_ON_DASHBOARD
|
|
||||||
),
|
|
||||||
savedViewsWarnOnUnsavedChange: this.settings.get(
|
savedViewsWarnOnUnsavedChange: this.settings.get(
|
||||||
SETTINGS_KEYS.SAVED_VIEWS_WARN_ON_UNSAVED_CHANGE
|
SETTINGS_KEYS.SAVED_VIEWS_WARN_ON_UNSAVED_CHANGE
|
||||||
),
|
),
|
||||||
@@ -571,10 +563,6 @@ export class SettingsComponent
|
|||||||
SETTINGS_KEYS.DATE_FORMAT,
|
SETTINGS_KEYS.DATE_FORMAT,
|
||||||
this.settingsForm.value.dateFormat
|
this.settingsForm.value.dateFormat
|
||||||
)
|
)
|
||||||
this.settings.set(
|
|
||||||
SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_NEW_DOCUMENT,
|
|
||||||
this.settingsForm.value.notificationsConsumerNewDocument
|
|
||||||
)
|
|
||||||
this.settings.set(
|
this.settings.set(
|
||||||
SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_SUCCESS,
|
SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_SUCCESS,
|
||||||
this.settingsForm.value.notificationsConsumerSuccess
|
this.settingsForm.value.notificationsConsumerSuccess
|
||||||
@@ -583,10 +571,6 @@ export class SettingsComponent
|
|||||||
SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_FAILED,
|
SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_FAILED,
|
||||||
this.settingsForm.value.notificationsConsumerFailed
|
this.settingsForm.value.notificationsConsumerFailed
|
||||||
)
|
)
|
||||||
this.settings.set(
|
|
||||||
SETTINGS_KEYS.NOTIFICATIONS_CONSUMER_SUPPRESS_ON_DASHBOARD,
|
|
||||||
this.settingsForm.value.notificationsConsumerSuppressOnDashboard
|
|
||||||
)
|
|
||||||
this.settings.set(
|
this.settings.set(
|
||||||
SETTINGS_KEYS.NOTES_ENABLED,
|
SETTINGS_KEYS.NOTES_ENABLED,
|
||||||
this.settingsForm.value.notesEnabled
|
this.settingsForm.value.notesEnabled
|
||||||
@@ -664,8 +648,11 @@ export class SettingsComponent
|
|||||||
let savedToast: Toast = {
|
let savedToast: Toast = {
|
||||||
content: $localize`Settings were saved successfully.`,
|
content: $localize`Settings were saved successfully.`,
|
||||||
delay: 5000,
|
delay: 5000,
|
||||||
|
level: ToastLevel.Success,
|
||||||
|
persistent: false,
|
||||||
}
|
}
|
||||||
if (reloadRequired) {
|
if (reloadRequired) {
|
||||||
|
savedToast.persistent = true
|
||||||
savedToast.content = $localize`Settings were saved successfully. Reload is required to apply some changes.`
|
savedToast.content = $localize`Settings were saved successfully. Reload is required to apply some changes.`
|
||||||
savedToast.actionName = $localize`Reload now`
|
savedToast.actionName = $localize`Reload now`
|
||||||
savedToast.action = () => {
|
savedToast.action = () => {
|
||||||
|
|||||||
@@ -7,13 +7,13 @@
|
|||||||
>
|
>
|
||||||
<div class="btn-toolbar col col-md-auto align-items-center gap-2">
|
<div class="btn-toolbar col col-md-auto align-items-center gap-2">
|
||||||
<button class="btn btn-sm btn-outline-secondary me-2" (click)="clearSelection()" [hidden]="selectedTasks.size === 0">
|
<button class="btn btn-sm btn-outline-secondary me-2" (click)="clearSelection()" [hidden]="selectedTasks.size === 0">
|
||||||
<i-bs name="x" class="me-1"></i-bs><ng-container i18n>Clear selection</ng-container>
|
<pngx-bs-icon name="x" class="me-1"></pngx-bs-icon><ng-container i18n>Clear selection</ng-container>
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-sm btn-outline-primary me-2" (click)="dismissTasks()" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.PaperlessTask }" [disabled]="visibleTasks.length === 0">
|
<button class="btn btn-sm btn-outline-primary me-2" (click)="dismissTasks()" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.PaperlessTask }" [disabled]="visibleTasks.length === 0">
|
||||||
<i-bs name="check2-all" class="me-1"></i-bs>{{dismissButtonText}}
|
<pngx-bs-icon name="check2-all" class="me-1"></pngx-bs-icon>{{dismissButtonText}}
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-sm btn-outline-primary me-2" (click)="dismissAllTasks()" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.PaperlessTask }" [disabled]="totalTasks() === 0">
|
<button class="btn btn-sm btn-outline-primary me-2" (click)="dismissAllTasks()" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.PaperlessTask }" [disabled]="totalTasks() === 0">
|
||||||
<i-bs name="check2-all" class="me-1"></i-bs><ng-container i18n>Dismiss all</ng-container>
|
<pngx-bs-icon name="check2-all" class="me-1"></pngx-bs-icon><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 id="autoRefreshSwitch" class="form-check-input" type="checkbox" role="switch" [(ngModel)]="autoRefreshEnabled">
|
<input id="autoRefreshSwitch" class="form-check-input" type="checkbox" role="switch" [(ngModel)]="autoRefreshEnabled">
|
||||||
@@ -86,7 +86,7 @@
|
|||||||
</div>
|
</div>
|
||||||
@if (filterText?.length) {
|
@if (filterText?.length) {
|
||||||
<button class="btn btn-link btn-sm px-2 position-absolute top-0 end-0 z-10" (click)="resetFilter()" aria-label="Clear search" i18n-aria-label>
|
<button class="btn btn-link btn-sm px-2 position-absolute top-0 end-0 z-10" (click)="resetFilter()" aria-label="Clear search" i18n-aria-label>
|
||||||
<i-bs width="1em" height="1em" name="x"></i-bs>
|
<pngx-bs-icon width="1em" height="1em" name="x"></pngx-bs-icon>
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
<label class="visually-hidden" for="taskSearch" i18n>Search tasks</label>
|
<label class="visually-hidden" for="taskSearch" i18n>Search tasks</label>
|
||||||
@@ -98,7 +98,7 @@
|
|||||||
|
|
||||||
@if (isFiltered) {
|
@if (isFiltered) {
|
||||||
<button class="btn btn-link py-0 ms-md-auto" (click)="resetFilters()">
|
<button class="btn btn-link py-0 ms-md-auto" (click)="resetFilters()">
|
||||||
<i-bs width="1em" height="1em" name="x"></i-bs><small i18n>Reset filters</small>
|
<pngx-bs-icon width="1em" height="1em" name="x"></pngx-bs-icon><small i18n>Reset filters</small>
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -125,7 +125,7 @@
|
|||||||
@if (hasMore) {
|
@if (hasMore) {
|
||||||
<ng-container i18n>{{tasks.length}} of {{count}} tasks</ng-container>
|
<ng-container i18n>{{tasks.length}} of {{count}} tasks</ng-container>
|
||||||
<button class="btn btn-link show-all p-0 ms-2 align-baseline" type="button" (click)="setSection(section)">
|
<button class="btn btn-link show-all p-0 ms-2 align-baseline" type="button" (click)="setSection(section)">
|
||||||
<ng-container i18n>Show all</ng-container><i-bs class="ms-1" width="1em" height="1em" name="arrow-right"></i-bs>
|
<ng-container i18n>Show all</ng-container><pngx-bs-icon class="ms-1" width="1em" height="1em" name="arrow-right"></pngx-bs-icon>
|
||||||
</button>
|
</button>
|
||||||
} @else {
|
} @else {
|
||||||
<ng-container i18n>{count, plural, =1 {1 task} other {{{count}} tasks}}</ng-container>
|
<ng-container i18n>{count, plural, =1 {1 task} other {{{count}} tasks}}</ng-container>
|
||||||
@@ -180,7 +180,7 @@
|
|||||||
@if (taskShowsSeparateTypeLabel(task)) {
|
@if (taskShowsSeparateTypeLabel(task)) {
|
||||||
<span class="me-2">{{ task.task_type_display }}</span>
|
<span class="me-2">{{ task.task_type_display }}</span>
|
||||||
}
|
}
|
||||||
<span class="text-nowrap"><i-bs class="me-1" width="1em" height="1em" name="box-arrow-in-right"></i-bs>{{ task.trigger_source_display }}</span>
|
<span class="text-nowrap"><pngx-bs-icon class="me-1" width="1em" height="1em" name="box-arrow-in-right"></pngx-bs-icon>{{ task.trigger_source_display }}</span>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
<td class="d-none d-lg-table-cell created-col">{{ task.date_created | customDate:'short' }}</td>
|
<td class="d-none d-lg-table-cell created-col">{{ task.date_created | customDate:'short' }}</td>
|
||||||
@@ -197,7 +197,7 @@
|
|||||||
}
|
}
|
||||||
@if (duplicateDocumentId(task)) {
|
@if (duplicateDocumentId(task)) {
|
||||||
<div class="small text-warning-emphasis d-flex align-items-center gap-1 mt-1">
|
<div class="small text-warning-emphasis d-flex align-items-center gap-1 mt-1">
|
||||||
<i-bs class="lh-1" width="1em" height="1em" name="exclamation-triangle"></i-bs>
|
<pngx-bs-icon class="lh-1" width="1em" height="1em" name="exclamation-triangle"></pngx-bs-icon>
|
||||||
<span>{{ duplicateTaskLabel(task) }}</span>
|
<span>{{ duplicateTaskLabel(task) }}</span>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -213,18 +213,18 @@
|
|||||||
}
|
}
|
||||||
<td class="d-lg-none">
|
<td class="d-lg-none">
|
||||||
<button class="btn btn-link" (click)="expandTask(task); $event.stopPropagation();" aria-label="View task details" i18n-aria-label>
|
<button class="btn btn-link" (click)="expandTask(task); $event.stopPropagation();" aria-label="View task details" i18n-aria-label>
|
||||||
<i-bs width="1.2em" height="1.2em" name="info-circle"></i-bs>
|
<pngx-bs-icon width="1.2em" height="1.2em" name="info-circle"></pngx-bs-icon>
|
||||||
</button>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
<td 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>
|
<pngx-bs-icon name="check" class="me-1"></pngx-bs-icon><ng-container i18n>Dismiss</ng-container>
|
||||||
</button>
|
</button>
|
||||||
<ng-container *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }">
|
<ng-container *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }">
|
||||||
@if (task.related_document_ids?.[0]) {
|
@if (task.related_document_ids?.[0]) {
|
||||||
<a class="btn btn-sm btn-outline-primary" [routerLink]="['/documents', task.related_document_ids[0]]" (click)="dismissTask(task)">
|
<a class="btn btn-sm btn-outline-primary" [routerLink]="['/documents', task.related_document_ids[0]]" (click)="dismissTask(task)">
|
||||||
<i-bs name="file-text" class="me-1"></i-bs><ng-container i18n>Open Document</ng-container>
|
<pngx-bs-icon name="file-text" class="me-1"></pngx-bs-icon><ng-container i18n>Open Document</ng-container>
|
||||||
</a>
|
</a>
|
||||||
}
|
}
|
||||||
</ng-container>
|
</ng-container>
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import { By } from '@angular/platform-browser'
|
|||||||
import { Router } from '@angular/router'
|
import { Router } from '@angular/router'
|
||||||
import { RouterTestingModule } from '@angular/router/testing'
|
import { RouterTestingModule } from '@angular/router/testing'
|
||||||
import { NgbModal, NgbModalRef, NgbModule } from '@ng-bootstrap/ng-bootstrap'
|
import { NgbModal, NgbModalRef, NgbModule } from '@ng-bootstrap/ng-bootstrap'
|
||||||
import { allIcons, NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
|
||||||
import { of, throwError } from 'rxjs'
|
import { of, throwError } from 'rxjs'
|
||||||
import { routes } from 'src/app/app-routing.module'
|
import { routes } from 'src/app/app-routing.module'
|
||||||
import {
|
import {
|
||||||
@@ -19,7 +18,6 @@ import {
|
|||||||
PaperlessTaskTriggerSource,
|
PaperlessTaskTriggerSource,
|
||||||
PaperlessTaskType,
|
PaperlessTaskType,
|
||||||
} from 'src/app/data/paperless-task'
|
} from 'src/app/data/paperless-task'
|
||||||
import { Results } from 'src/app/data/results'
|
|
||||||
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
||||||
import { PermissionsGuard } from 'src/app/guards/permissions.guard'
|
import { PermissionsGuard } from 'src/app/guards/permissions.guard'
|
||||||
import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
|
import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
|
||||||
@@ -153,11 +151,6 @@ const tasks: PaperlessTask[] = [
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
const paginatedTasks: Results<PaperlessTask> = {
|
|
||||||
count: tasks.length,
|
|
||||||
results: tasks,
|
|
||||||
}
|
|
||||||
|
|
||||||
const sectionCountResponse = {
|
const sectionCountResponse = {
|
||||||
all: 7,
|
all: 7,
|
||||||
needs_attention: 2,
|
needs_attention: 2,
|
||||||
@@ -180,7 +173,6 @@ describe('TasksComponent', () => {
|
|||||||
imports: [
|
imports: [
|
||||||
NgbModule,
|
NgbModule,
|
||||||
RouterTestingModule.withRoutes(routes),
|
RouterTestingModule.withRoutes(routes),
|
||||||
NgxBootstrapIconsModule.pick(allIcons),
|
|
||||||
FormsModule,
|
FormsModule,
|
||||||
TasksComponent,
|
TasksComponent,
|
||||||
PageHeaderComponent,
|
PageHeaderComponent,
|
||||||
@@ -204,7 +196,7 @@ describe('TasksComponent', () => {
|
|||||||
}).compileComponents()
|
}).compileComponents()
|
||||||
|
|
||||||
tasksService = TestBed.inject(TasksService)
|
tasksService = TestBed.inject(TasksService)
|
||||||
reloadSpy = jest.spyOn(tasksService, 'reload')
|
reloadSpy = jest.spyOn(tasksService, 'reload').mockImplementation(() => {})
|
||||||
httpTestingController = TestBed.inject(HttpTestingController)
|
httpTestingController = TestBed.inject(HttpTestingController)
|
||||||
modalService = TestBed.inject(NgbModal)
|
modalService = TestBed.inject(NgbModal)
|
||||||
router = TestBed.inject(Router)
|
router = TestBed.inject(Router)
|
||||||
@@ -214,15 +206,6 @@ describe('TasksComponent', () => {
|
|||||||
jest.useFakeTimers()
|
jest.useFakeTimers()
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
|
|
||||||
httpTestingController
|
|
||||||
.expectOne(
|
|
||||||
(req) =>
|
|
||||||
req.url === `${environment.apiBaseUrl}tasks/` &&
|
|
||||||
req.params.get('acknowledged') === 'false' &&
|
|
||||||
req.params.get('page_size') === '1000'
|
|
||||||
)
|
|
||||||
.flush(paginatedTasks)
|
|
||||||
|
|
||||||
httpTestingController
|
httpTestingController
|
||||||
.expectOne(
|
.expectOne(
|
||||||
(req) =>
|
(req) =>
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import {
|
|||||||
NgbPaginationModule,
|
NgbPaginationModule,
|
||||||
NgbPopoverModule,
|
NgbPopoverModule,
|
||||||
} from '@ng-bootstrap/ng-bootstrap'
|
} from '@ng-bootstrap/ng-bootstrap'
|
||||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
|
||||||
import {
|
import {
|
||||||
debounceTime,
|
debounceTime,
|
||||||
distinctUntilChanged,
|
distinctUntilChanged,
|
||||||
@@ -22,6 +21,7 @@ import {
|
|||||||
takeUntil,
|
takeUntil,
|
||||||
timer,
|
timer,
|
||||||
} from 'rxjs'
|
} from 'rxjs'
|
||||||
|
import { BootstrapIconComponent } from 'src/app/components/common/bootstrap-icon/bootstrap-icon.component'
|
||||||
import {
|
import {
|
||||||
PaperlessTask,
|
PaperlessTask,
|
||||||
PaperlessTaskStatus,
|
PaperlessTaskStatus,
|
||||||
@@ -151,7 +151,7 @@ const TRIGGER_SOURCE_OPTIONS: Array<{
|
|||||||
NgbDropdownModule,
|
NgbDropdownModule,
|
||||||
NgbPaginationModule,
|
NgbPaginationModule,
|
||||||
NgbPopoverModule,
|
NgbPopoverModule,
|
||||||
NgxBootstrapIconsModule,
|
BootstrapIconComponent,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class TasksComponent
|
export class TasksComponent
|
||||||
|
|||||||
@@ -5,16 +5,16 @@
|
|||||||
i18n-info
|
i18n-info
|
||||||
infoLink="usage/#document-trash">
|
infoLink="usage/#document-trash">
|
||||||
<button class="btn btn-sm btn-outline-secondary" (click)="clearSelection()" [hidden]="selectedDocuments().size === 0">
|
<button class="btn btn-sm btn-outline-secondary" (click)="clearSelection()" [hidden]="selectedDocuments().size === 0">
|
||||||
<i-bs name="x" class="me-1"></i-bs><ng-container i18n>Clear selection</ng-container>
|
<pngx-bs-icon name="x" class="me-1"></pngx-bs-icon><ng-container i18n>Clear selection</ng-container>
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="btn btn-sm btn-outline-primary" (click)="restoreAll(selectedDocuments())" [disabled]="selectedDocuments().size === 0">
|
<button type="button" class="btn btn-sm btn-outline-primary" (click)="restoreAll(selectedDocuments())" [disabled]="selectedDocuments().size === 0">
|
||||||
<i-bs name="arrow-counterclockwise" class="me-1"></i-bs><ng-container i18n>Restore selected</ng-container>
|
<pngx-bs-icon name="arrow-counterclockwise" class="me-1"></pngx-bs-icon><ng-container i18n>Restore selected</ng-container>
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="btn btn-sm btn-outline-danger" (click)="emptyTrash(selectedDocuments())" [disabled]="selectedDocuments().size === 0">
|
<button type="button" class="btn btn-sm btn-outline-danger" (click)="emptyTrash(selectedDocuments())" [disabled]="selectedDocuments().size === 0">
|
||||||
<i-bs name="trash" class="me-1"></i-bs><ng-container i18n>Delete selected</ng-container>
|
<pngx-bs-icon name="trash" class="me-1"></pngx-bs-icon><ng-container i18n>Delete selected</ng-container>
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="btn btn-sm btn-outline-danger" (click)="emptyTrash()" [disabled]="documentsInTrash().length === 0">
|
<button type="button" class="btn btn-sm btn-outline-danger" (click)="emptyTrash()" [disabled]="documentsInTrash().length === 0">
|
||||||
<i-bs name="trash" class="me-1"></i-bs><ng-container i18n>Empty trash</ng-container>
|
<pngx-bs-icon name="trash" class="me-1"></pngx-bs-icon><ng-container i18n>Empty trash</ng-container>
|
||||||
</button>
|
</button>
|
||||||
</pngx-page-header>
|
</pngx-page-header>
|
||||||
|
|
||||||
@@ -59,7 +59,7 @@
|
|||||||
<td scope="row">
|
<td scope="row">
|
||||||
{{ document.title }}
|
{{ document.title }}
|
||||||
<pngx-preview-popup [document]="document" linkClasses="btn btn-sm btn-link" #popupPreview>
|
<pngx-preview-popup [document]="document" linkClasses="btn btn-sm btn-link" #popupPreview>
|
||||||
<i-bs name="eye"></i-bs>
|
<pngx-bs-icon name="eye"></pngx-bs-icon>
|
||||||
</pngx-preview-popup>
|
</pngx-preview-popup>
|
||||||
</td>
|
</td>
|
||||||
<td scope="row" class="d-none d-sm-table-cell" i18n>{{ getDaysRemaining(document) }} days</td>
|
<td scope="row" class="d-none d-sm-table-cell" i18n>{{ getDaysRemaining(document) }} days</td>
|
||||||
@@ -67,7 +67,7 @@
|
|||||||
<div class="btn-group d-block d-sm-none">
|
<div class="btn-group d-block d-sm-none">
|
||||||
<div ngbDropdown container="body" class="d-inline-block">
|
<div ngbDropdown container="body" class="d-inline-block">
|
||||||
<button type="button" class="btn btn-link" id="actionsMenuMobile" (click)="$event.stopPropagation()" ngbDropdownToggle aria-label="Actions" i18n-aria-label>
|
<button type="button" class="btn btn-link" id="actionsMenuMobile" (click)="$event.stopPropagation()" ngbDropdownToggle aria-label="Actions" i18n-aria-label>
|
||||||
<i-bs name="three-dots-vertical"></i-bs>
|
<pngx-bs-icon name="three-dots-vertical"></pngx-bs-icon>
|
||||||
</button>
|
</button>
|
||||||
<div ngbDropdownMenu aria-labelledby="actionsMenuMobile">
|
<div ngbDropdownMenu aria-labelledby="actionsMenuMobile">
|
||||||
<button (click)="restore(document)" ngbDropdownItem i18n>Restore</button>
|
<button (click)="restore(document)" ngbDropdownItem i18n>Restore</button>
|
||||||
@@ -77,10 +77,10 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="btn-group d-none d-sm-block">
|
<div class="btn-group d-none d-sm-block">
|
||||||
<button class="btn btn-sm btn-outline-secondary" (click)="restore(document); $event.stopPropagation();">
|
<button class="btn btn-sm btn-outline-secondary" (click)="restore(document); $event.stopPropagation();">
|
||||||
<i-bs width="1em" height="1em" name="arrow-counterclockwise" class="me-1"></i-bs><ng-container i18n>Restore</ng-container>
|
<pngx-bs-icon width="1em" height="1em" name="arrow-counterclockwise" class="me-1"></pngx-bs-icon><ng-container i18n>Restore</ng-container>
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-sm btn-outline-danger" (click)="delete(document); $event.stopPropagation();">
|
<button class="btn btn-sm btn-outline-danger" (click)="delete(document); $event.stopPropagation();">
|
||||||
<i-bs width="1em" height="1em" name="trash" class="me-1"></i-bs><ng-container i18n>Delete</ng-container>
|
<pngx-bs-icon width="1em" height="1em" name="trash" class="me-1"></pngx-bs-icon><ng-container i18n>Delete</ng-container>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ import {
|
|||||||
NgbPaginationModule,
|
NgbPaginationModule,
|
||||||
NgbPopoverModule,
|
NgbPopoverModule,
|
||||||
} from '@ng-bootstrap/ng-bootstrap'
|
} from '@ng-bootstrap/ng-bootstrap'
|
||||||
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
|
||||||
import { of, throwError } from 'rxjs'
|
import { of, throwError } from 'rxjs'
|
||||||
import { ToastService } from 'src/app/services/toast.service'
|
import { ToastService } from 'src/app/services/toast.service'
|
||||||
import { TrashService } from 'src/app/services/trash.service'
|
import { TrashService } from 'src/app/services/trash.service'
|
||||||
@@ -48,7 +47,6 @@ describe('TrashComponent', () => {
|
|||||||
ReactiveFormsModule,
|
ReactiveFormsModule,
|
||||||
NgbPopoverModule,
|
NgbPopoverModule,
|
||||||
NgbPaginationModule,
|
NgbPaginationModule,
|
||||||
NgxBootstrapIconsModule.pick(allIcons),
|
|
||||||
TrashComponent,
|
TrashComponent,
|
||||||
PageHeaderComponent,
|
PageHeaderComponent,
|
||||||
ConfirmDialogComponent,
|
ConfirmDialogComponent,
|
||||||
@@ -99,11 +97,16 @@ describe('TrashComponent', () => {
|
|||||||
modal.componentInstance.confirmClicked.next()
|
modal.componentInstance.confirmClicked.next()
|
||||||
expect(toastErrorSpy).toHaveBeenCalled()
|
expect(toastErrorSpy).toHaveBeenCalled()
|
||||||
|
|
||||||
|
let toasts
|
||||||
|
toastService.getToasts().subscribe((allToasts) => {
|
||||||
|
toasts = [...allToasts]
|
||||||
|
})
|
||||||
trashSpy.mockReturnValue(of('OK'))
|
trashSpy.mockReturnValue(of('OK'))
|
||||||
component.delete(documentsInTrash[0])
|
component.delete(documentsInTrash[0])
|
||||||
expect(modal).toBeDefined()
|
expect(modal).toBeDefined()
|
||||||
modal.componentInstance.confirmClicked.next()
|
modal.componentInstance.confirmClicked.next()
|
||||||
expect(trashSpy).toHaveBeenCalled()
|
expect(trashSpy).toHaveBeenCalled()
|
||||||
|
expect(toasts[0].content).toContain('deleted')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should support empty trash, show error if needed', () => {
|
it('should support empty trash, show error if needed', () => {
|
||||||
|
|||||||
@@ -6,12 +6,12 @@ import {
|
|||||||
NgbModal,
|
NgbModal,
|
||||||
NgbPaginationModule,
|
NgbPaginationModule,
|
||||||
} from '@ng-bootstrap/ng-bootstrap'
|
} from '@ng-bootstrap/ng-bootstrap'
|
||||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
|
||||||
import { takeUntil, tap } from 'rxjs'
|
import { takeUntil, tap } from 'rxjs'
|
||||||
|
import { BootstrapIconComponent } from 'src/app/components/common/bootstrap-icon/bootstrap-icon.component'
|
||||||
import { Document } from 'src/app/data/document'
|
import { Document } from 'src/app/data/document'
|
||||||
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
||||||
import { SettingsService } from 'src/app/services/settings.service'
|
import { SettingsService } from 'src/app/services/settings.service'
|
||||||
import { ToastService } from 'src/app/services/toast.service'
|
import { ToastLevel, ToastService } from 'src/app/services/toast.service'
|
||||||
import { TrashService } from 'src/app/services/trash.service'
|
import { TrashService } from 'src/app/services/trash.service'
|
||||||
import { ConfirmDialogComponent } from '../../common/confirm-dialog/confirm-dialog.component'
|
import { ConfirmDialogComponent } from '../../common/confirm-dialog/confirm-dialog.component'
|
||||||
import { PageHeaderComponent } from '../../common/page-header/page-header.component'
|
import { PageHeaderComponent } from '../../common/page-header/page-header.component'
|
||||||
@@ -29,7 +29,7 @@ import { LoadingComponentWithPermissions } from '../../loading-component/loading
|
|||||||
ReactiveFormsModule,
|
ReactiveFormsModule,
|
||||||
NgbDropdownModule,
|
NgbDropdownModule,
|
||||||
NgbPaginationModule,
|
NgbPaginationModule,
|
||||||
NgxBootstrapIconsModule,
|
BootstrapIconComponent,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class TrashComponent
|
export class TrashComponent
|
||||||
@@ -87,9 +87,11 @@ export class TrashComponent
|
|||||||
modal.componentInstance.buttonsEnabled.set(false)
|
modal.componentInstance.buttonsEnabled.set(false)
|
||||||
this.trashService.emptyTrash([document.id]).subscribe({
|
this.trashService.emptyTrash([document.id]).subscribe({
|
||||||
next: () => {
|
next: () => {
|
||||||
this.toastService.showInfo(
|
this.toastService.show({
|
||||||
$localize`Document "${document.title}" deleted`
|
content: $localize`Document "${document.title}" deleted`,
|
||||||
)
|
delay: 5000,
|
||||||
|
level: ToastLevel.Success,
|
||||||
|
})
|
||||||
modal.close()
|
modal.close()
|
||||||
this.reload()
|
this.reload()
|
||||||
},
|
},
|
||||||
@@ -122,7 +124,11 @@ export class TrashComponent
|
|||||||
.emptyTrash(documents ? Array.from(documents) : null)
|
.emptyTrash(documents ? Array.from(documents) : null)
|
||||||
.subscribe({
|
.subscribe({
|
||||||
next: () => {
|
next: () => {
|
||||||
this.toastService.showInfo($localize`Document(s) deleted`)
|
this.toastService.show({
|
||||||
|
content: $localize`Document(s) deleted`,
|
||||||
|
delay: 5000,
|
||||||
|
level: ToastLevel.Success,
|
||||||
|
})
|
||||||
this.allToggled.set(false)
|
this.allToggled.set(false)
|
||||||
modal.close()
|
modal.close()
|
||||||
this.reload()
|
this.reload()
|
||||||
@@ -144,6 +150,7 @@ export class TrashComponent
|
|||||||
this.toastService.show({
|
this.toastService.show({
|
||||||
content: $localize`Document "${document.title}" restored`,
|
content: $localize`Document "${document.title}" restored`,
|
||||||
delay: 5000,
|
delay: 5000,
|
||||||
|
level: ToastLevel.Success,
|
||||||
actionName: $localize`Open document`,
|
actionName: $localize`Open document`,
|
||||||
action: () => {
|
action: () => {
|
||||||
this.router.navigate(['documents', document.id])
|
this.router.navigate(['documents', document.id])
|
||||||
@@ -165,7 +172,11 @@ export class TrashComponent
|
|||||||
.restoreDocuments(documents ? Array.from(documents) : null)
|
.restoreDocuments(documents ? Array.from(documents) : null)
|
||||||
.subscribe({
|
.subscribe({
|
||||||
next: () => {
|
next: () => {
|
||||||
this.toastService.showInfo($localize`Document(s) restored`)
|
this.toastService.show({
|
||||||
|
content: $localize`Document(s) restored`,
|
||||||
|
delay: 5000,
|
||||||
|
level: ToastLevel.Success,
|
||||||
|
})
|
||||||
this.allToggled.set(false)
|
this.allToggled.set(false)
|
||||||
this.reload()
|
this.reload()
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
<h4 class="d-flex">
|
<h4 class="d-flex">
|
||||||
<ng-container i18n>Users</ng-container>
|
<ng-container i18n>Users</ng-container>
|
||||||
<button type="button" class="btn btn-sm btn-outline-primary ms-4" (click)="editUser()" *pngxIfPermissions="{ action: PermissionAction.Add, type: PermissionType.User }">
|
<button type="button" class="btn btn-sm btn-outline-primary ms-4" (click)="editUser()" *pngxIfPermissions="{ action: PermissionAction.Add, type: PermissionType.User }">
|
||||||
<i-bs name="plus-circle" class="me-1"></i-bs><ng-container i18n>Add User</ng-container>
|
<pngx-bs-icon name="plus-circle" class="me-1"></pngx-bs-icon><ng-container i18n>Add User</ng-container>
|
||||||
</button>
|
</button>
|
||||||
</h4>
|
</h4>
|
||||||
<ul class="list-group">
|
<ul class="list-group">
|
||||||
@@ -32,10 +32,10 @@
|
|||||||
<div class="col">
|
<div class="col">
|
||||||
<div class="btn-group">
|
<div class="btn-group">
|
||||||
<button class="btn btn-sm btn-outline-secondary" type="button" (click)="editUser(user)" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.User }">
|
<button class="btn btn-sm btn-outline-secondary" type="button" (click)="editUser(user)" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.User }">
|
||||||
<i-bs width="1em" height="1em" name="pencil" class="me-1"></i-bs><ng-container i18n>Edit</ng-container>
|
<pngx-bs-icon width="1em" height="1em" name="pencil" class="me-1"></pngx-bs-icon><ng-container i18n>Edit</ng-container>
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-sm btn-outline-danger" type="button" (click)="deleteUser(user)" *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.User }">
|
<button class="btn btn-sm btn-outline-danger" type="button" (click)="deleteUser(user)" *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.User }">
|
||||||
<i-bs width="1em" height="1em" name="trash" class="me-1"></i-bs><ng-container i18n>Delete</ng-container>
|
<pngx-bs-icon width="1em" height="1em" name="trash" class="me-1"></pngx-bs-icon><ng-container i18n>Delete</ng-container>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -49,7 +49,7 @@
|
|||||||
<h4 class="mt-4 d-flex">
|
<h4 class="mt-4 d-flex">
|
||||||
<ng-container i18n>Groups</ng-container>
|
<ng-container i18n>Groups</ng-container>
|
||||||
<button type="button" class="btn btn-sm btn-outline-primary ms-4" (click)="editGroup()" *pngxIfPermissions="{ action: PermissionAction.Add, type: PermissionType.Group }">
|
<button type="button" class="btn btn-sm btn-outline-primary ms-4" (click)="editGroup()" *pngxIfPermissions="{ action: PermissionAction.Add, type: PermissionType.Group }">
|
||||||
<i-bs name="plus-circle" class="me-1"></i-bs><ng-container i18n>Add Group</ng-container>
|
<pngx-bs-icon name="plus-circle" class="me-1"></pngx-bs-icon><ng-container i18n>Add Group</ng-container>
|
||||||
</button>
|
</button>
|
||||||
</h4>
|
</h4>
|
||||||
<ul class="list-group">
|
<ul class="list-group">
|
||||||
@@ -70,10 +70,10 @@
|
|||||||
<div class="col">
|
<div class="col">
|
||||||
<div class="btn-group">
|
<div class="btn-group">
|
||||||
<button class="btn btn-sm btn-outline-secondary" type="button" (click)="editGroup(group)" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.Group }">
|
<button class="btn btn-sm btn-outline-secondary" type="button" (click)="editGroup(group)" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.Group }">
|
||||||
<i-bs width="1em" height="1em" name="pencil" class="me-1"></i-bs><ng-container i18n>Edit</ng-container>
|
<pngx-bs-icon width="1em" height="1em" name="pencil" class="me-1"></pngx-bs-icon><ng-container i18n>Edit</ng-container>
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-sm btn-outline-danger" type="button" (click)="deleteGroup(group)" *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.Group }">
|
<button class="btn btn-sm btn-outline-danger" type="button" (click)="deleteGroup(group)" *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.Group }">
|
||||||
<i-bs width="1em" height="1em" name="trash" class="me-1"></i-bs><ng-container i18n>Delete</ng-container>
|
<pngx-bs-icon width="1em" height="1em" name="trash" class="me-1"></pngx-bs-icon><ng-container i18n>Delete</ng-container>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ 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 { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap'
|
import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap'
|
||||||
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
|
||||||
import { of, throwError } from 'rxjs'
|
import { of, throwError } from 'rxjs'
|
||||||
import { Group } from 'src/app/data/group'
|
import { Group } from 'src/app/data/group'
|
||||||
import { User } from 'src/app/data/user'
|
import { User } from 'src/app/data/user'
|
||||||
@@ -41,7 +40,6 @@ describe('UsersAndGroupsComponent', () => {
|
|||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
TestBed.configureTestingModule({
|
TestBed.configureTestingModule({
|
||||||
imports: [NgxBootstrapIconsModule.pick(allIcons)],
|
|
||||||
providers: [
|
providers: [
|
||||||
CustomDatePipe,
|
CustomDatePipe,
|
||||||
DatePipe,
|
DatePipe,
|
||||||
@@ -101,7 +99,7 @@ describe('UsersAndGroupsComponent', () => {
|
|||||||
component.editUser(users[0])
|
component.editUser(users[0])
|
||||||
const editDialog = modal.componentInstance as UserEditDialogComponent
|
const editDialog = modal.componentInstance as UserEditDialogComponent
|
||||||
const toastErrorSpy = jest.spyOn(toastService, 'showError')
|
const toastErrorSpy = jest.spyOn(toastService, 'showError')
|
||||||
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
|
const toastInfoSpy = jest.spyOn(toastService, 'showSuccess')
|
||||||
editDialog.failed.emit()
|
editDialog.failed.emit()
|
||||||
expect(toastErrorSpy).toHaveBeenCalled()
|
expect(toastErrorSpy).toHaveBeenCalled()
|
||||||
settingsService.currentUser.set(users[1]) // simulate logged in as different user
|
settingsService.currentUser.set(users[1]) // simulate logged in as different user
|
||||||
@@ -120,7 +118,7 @@ describe('UsersAndGroupsComponent', () => {
|
|||||||
const deleteDialog = modal.componentInstance as ConfirmDialogComponent
|
const deleteDialog = modal.componentInstance as ConfirmDialogComponent
|
||||||
const deleteSpy = jest.spyOn(userService, 'delete')
|
const deleteSpy = jest.spyOn(userService, 'delete')
|
||||||
const toastErrorSpy = jest.spyOn(toastService, 'showError')
|
const toastErrorSpy = jest.spyOn(toastService, 'showError')
|
||||||
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
|
const toastInfoSpy = jest.spyOn(toastService, 'showSuccess')
|
||||||
const listAllSpy = jest.spyOn(userService, 'listAll')
|
const listAllSpy = jest.spyOn(userService, 'listAll')
|
||||||
deleteSpy.mockReturnValueOnce(
|
deleteSpy.mockReturnValueOnce(
|
||||||
throwError(() => new Error('error deleting user'))
|
throwError(() => new Error('error deleting user'))
|
||||||
@@ -186,7 +184,7 @@ describe('UsersAndGroupsComponent', () => {
|
|||||||
component.editGroup(groups[0])
|
component.editGroup(groups[0])
|
||||||
const editDialog = modal.componentInstance as GroupEditDialogComponent
|
const editDialog = modal.componentInstance as GroupEditDialogComponent
|
||||||
const toastErrorSpy = jest.spyOn(toastService, 'showError')
|
const toastErrorSpy = jest.spyOn(toastService, 'showError')
|
||||||
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
|
const toastInfoSpy = jest.spyOn(toastService, 'showSuccess')
|
||||||
editDialog.failed.emit()
|
editDialog.failed.emit()
|
||||||
expect(toastErrorSpy).toHaveBeenCalled()
|
expect(toastErrorSpy).toHaveBeenCalled()
|
||||||
editDialog.succeeded.emit(groups[0])
|
editDialog.succeeded.emit(groups[0])
|
||||||
@@ -204,7 +202,7 @@ describe('UsersAndGroupsComponent', () => {
|
|||||||
const deleteDialog = modal.componentInstance as ConfirmDialogComponent
|
const deleteDialog = modal.componentInstance as ConfirmDialogComponent
|
||||||
const deleteSpy = jest.spyOn(groupService, 'delete')
|
const deleteSpy = jest.spyOn(groupService, 'delete')
|
||||||
const toastErrorSpy = jest.spyOn(toastService, 'showError')
|
const toastErrorSpy = jest.spyOn(toastService, 'showError')
|
||||||
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
|
const toastInfoSpy = jest.spyOn(toastService, 'showSuccess')
|
||||||
const listAllSpy = jest.spyOn(groupService, 'listAll')
|
const listAllSpy = jest.spyOn(groupService, 'listAll')
|
||||||
deleteSpy.mockReturnValueOnce(
|
deleteSpy.mockReturnValueOnce(
|
||||||
throwError(() => new Error('error deleting group'))
|
throwError(() => new Error('error deleting group'))
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { Component, OnDestroy, OnInit, inject, signal } from '@angular/core'
|
import { Component, OnDestroy, OnInit, inject, signal } from '@angular/core'
|
||||||
import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
|
import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
|
||||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
|
||||||
import { Subject, first, takeUntil } from 'rxjs'
|
import { Subject, first, takeUntil } from 'rxjs'
|
||||||
|
import { BootstrapIconComponent } from 'src/app/components/common/bootstrap-icon/bootstrap-icon.component'
|
||||||
import { Group } from 'src/app/data/group'
|
import { Group } from 'src/app/data/group'
|
||||||
import { User } from 'src/app/data/user'
|
import { User } from 'src/app/data/user'
|
||||||
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
||||||
@@ -29,7 +29,7 @@ import { ComponentWithPermissions } from '../../with-permissions/with-permission
|
|||||||
imports: [
|
imports: [
|
||||||
PageHeaderComponent,
|
PageHeaderComponent,
|
||||||
IfPermissionsDirective,
|
IfPermissionsDirective,
|
||||||
NgxBootstrapIconsModule,
|
BootstrapIconComponent,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class UsersAndGroupsComponent
|
export class UsersAndGroupsComponent
|
||||||
@@ -122,7 +122,7 @@ export class UsersAndGroupsComponent
|
|||||||
)
|
)
|
||||||
}, 2500)
|
}, 2500)
|
||||||
} else {
|
} else {
|
||||||
this.toastService.showInfo(
|
this.toastService.showSuccess(
|
||||||
$localize`Saved user "${newUser.username}".`
|
$localize`Saved user "${newUser.username}".`
|
||||||
)
|
)
|
||||||
this.usersService.listAll().subscribe((r) => {
|
this.usersService.listAll().subscribe((r) => {
|
||||||
@@ -151,7 +151,9 @@ export class UsersAndGroupsComponent
|
|||||||
this.usersService.delete(user).subscribe({
|
this.usersService.delete(user).subscribe({
|
||||||
next: () => {
|
next: () => {
|
||||||
modal.close()
|
modal.close()
|
||||||
this.toastService.showInfo($localize`Deleted user "${user.username}"`)
|
this.toastService.showSuccess(
|
||||||
|
$localize`Deleted user "${user.username}"`
|
||||||
|
)
|
||||||
this.usersService.listAll().subscribe((r) => {
|
this.usersService.listAll().subscribe((r) => {
|
||||||
this.users.set(r.results)
|
this.users.set(r.results)
|
||||||
})
|
})
|
||||||
@@ -178,7 +180,9 @@ export class UsersAndGroupsComponent
|
|||||||
modal.componentInstance.succeeded
|
modal.componentInstance.succeeded
|
||||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||||
.subscribe((newGroup) => {
|
.subscribe((newGroup) => {
|
||||||
this.toastService.showInfo($localize`Saved group "${newGroup.name}".`)
|
this.toastService.showSuccess(
|
||||||
|
$localize`Saved group "${newGroup.name}".`
|
||||||
|
)
|
||||||
this.groupsService.listAll().subscribe((r) => {
|
this.groupsService.listAll().subscribe((r) => {
|
||||||
this.groups.set(r.results)
|
this.groups.set(r.results)
|
||||||
})
|
})
|
||||||
@@ -204,7 +208,9 @@ export class UsersAndGroupsComponent
|
|||||||
this.groupsService.delete(group).subscribe({
|
this.groupsService.delete(group).subscribe({
|
||||||
next: () => {
|
next: () => {
|
||||||
modal.close()
|
modal.close()
|
||||||
this.toastService.showInfo($localize`Deleted group "${group.name}"`)
|
this.toastService.showSuccess(
|
||||||
|
$localize`Deleted group "${group.name}"`
|
||||||
|
)
|
||||||
this.groupsService.listAll().subscribe((r) => {
|
this.groupsService.listAll().subscribe((r) => {
|
||||||
this.groups.set(r.results)
|
this.groups.set(r.results)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
<button class="navbar-toggler d-md-none collapsed border-0" type="button" data-toggle="collapse"
|
<button class="navbar-toggler d-md-none collapsed border-0" type="button" data-toggle="collapse"
|
||||||
data-target="#sidebarMenu" aria-controls="sidebarMenu" aria-expanded="false" aria-label="Toggle navigation"
|
data-target="#sidebarMenu" aria-controls="sidebarMenu" aria-expanded="false" aria-label="Toggle navigation"
|
||||||
(click)="closeMobileSearch(); toggleMenuCollapsed()">
|
(click)="closeMobileSearch(); toggleMenuCollapsed()">
|
||||||
<i-bs width="1.5em" height="1.5em" name="list"></i-bs>
|
<pngx-bs-icon width="1.5em" height="1.5em" name="list"></pngx-bs-icon>
|
||||||
</button>
|
</button>
|
||||||
<div class="navbar-brand-container order-sm-0">
|
<div class="navbar-brand-container order-sm-0">
|
||||||
<a class="navbar-brand d-flex align-items-center me-0 ps-md-3 py-0"
|
<a class="navbar-brand d-flex align-items-center me-0 ps-md-3 py-0"
|
||||||
@@ -29,8 +29,20 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="search-container flex-grow-1 py-2 pb-3 pb-sm-2 me-sm-auto order-3 order-sm-1"
|
<div class="search-container flex-grow-1 py-2 pb-3 pb-sm-2 me-sm-auto order-3 order-sm-1"
|
||||||
[class.mobile-hidden]="mobileSearchHidden()">
|
[class.mobile-hidden]="mobileSearchHidden()">
|
||||||
<div class="col-12 header-search mx-auto">
|
<div class="col-12 header-search mx-auto d-flex align-items-stretch gap-3">
|
||||||
<pngx-global-search></pngx-global-search>
|
<pngx-global-search class="flex-grow-1"></pngx-global-search>
|
||||||
|
<div *pngxIfPermissions="{ action: PermissionAction.Add, type: PermissionType.Document }" class="btn-group header-upload-group flex-shrink-0">
|
||||||
|
<button class="btn btn-sm header-upload border-0 d-flex align-items-center justify-content-center py-0 px-2" type="button"
|
||||||
|
(click)="fileUpload.click()" aria-label="Upload documents" i18n-aria-label>
|
||||||
|
<pngx-bs-icon class="d-flex" width="1.1em" height="1.1em" name="file-earmark-arrow-up"></pngx-bs-icon><span class="ms-2 d-sm-none d-md-inline" i18n>Upload</span>
|
||||||
|
</button>
|
||||||
|
@if (uploadsInProgress > 0) {
|
||||||
|
<button class="btn btn-sm header-upload border-0 d-flex align-items-center py-0 px-2" type="button"
|
||||||
|
(click)="showUploadStatus()" aria-label="Show document processing status" i18n-aria-label>
|
||||||
|
<span class="spinner-border spinner-border-sm" aria-hidden="true"></span><span class="ms-2 small">{{ uploadsInProgress }}</span>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<ul ngbNav class="order-sm-3">
|
<ul ngbNav class="order-sm-3">
|
||||||
@@ -40,7 +52,7 @@
|
|||||||
<pngx-toasts-dropdown></pngx-toasts-dropdown>
|
<pngx-toasts-dropdown></pngx-toasts-dropdown>
|
||||||
<li ngbDropdown class="nav-item dropdown">
|
<li ngbDropdown class="nav-item dropdown">
|
||||||
<button class="btn navbar-action border-0 me-1 me-md-2" id="userDropdown" ngbDropdownToggle aria-label="User menu" i18n-aria-label>
|
<button class="btn navbar-action border-0 me-1 me-md-2" id="userDropdown" ngbDropdownToggle aria-label="User menu" i18n-aria-label>
|
||||||
<i-bs width="1.3em" height="1.3em" name="person-circle"></i-bs>
|
<pngx-bs-icon width="1.3em" height="1.3em" name="person-circle"></pngx-bs-icon>
|
||||||
<span class="small ms-2 d-none d-sm-inline">
|
<span class="small ms-2 d-none d-sm-inline">
|
||||||
{{this.settingsService.displayName}}
|
{{this.settingsService.displayName}}
|
||||||
</span>
|
</span>
|
||||||
@@ -51,25 +63,28 @@
|
|||||||
<div class="dropdown-divider"></div>
|
<div class="dropdown-divider"></div>
|
||||||
</div>
|
</div>
|
||||||
<button ngbDropdownItem class="nav-link" (click)="editProfile()">
|
<button ngbDropdownItem class="nav-link" (click)="editProfile()">
|
||||||
<i-bs class="me-2" name="person"></i-bs><ng-container i18n>My Profile</ng-container>
|
<pngx-bs-icon class="me-2" name="person"></pngx-bs-icon><ng-container i18n>My Profile</ng-container>
|
||||||
</button>
|
</button>
|
||||||
<a ngbDropdownItem class="nav-link" routerLink="settings" (click)="closeMenu()"
|
<a ngbDropdownItem class="nav-link" routerLink="settings" (click)="closeMenu()"
|
||||||
*pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.UISettings }">
|
*pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.UISettings }">
|
||||||
<i-bs class="me-2" name="gear"></i-bs><ng-container i18n>Settings</ng-container>
|
<pngx-bs-icon class="me-2" name="gear"></pngx-bs-icon><ng-container i18n>Settings</ng-container>
|
||||||
</a>
|
</a>
|
||||||
<a ngbDropdownItem class="nav-link d-flex" href="accounts/logout/" (click)="onLogout()">
|
<a ngbDropdownItem class="nav-link d-flex" href="accounts/logout/" (click)="onLogout()">
|
||||||
<i-bs class="me-2" name="door-open"></i-bs><ng-container i18n>Logout</ng-container>
|
<pngx-bs-icon class="me-2" name="door-open"></pngx-bs-icon><ng-container i18n>Logout</ng-container>
|
||||||
</a>
|
</a>
|
||||||
<div class="dropdown-divider"></div>
|
<div class="dropdown-divider"></div>
|
||||||
<a ngbDropdownItem class="nav-link" target="_blank" rel="noopener noreferrer"
|
<a ngbDropdownItem class="nav-link" target="_blank" rel="noopener noreferrer"
|
||||||
href="https://docs.paperless-ngx.com">
|
href="https://docs.paperless-ngx.com">
|
||||||
<i-bs class="me-2" name="question-circle"></i-bs><ng-container i18n>Documentation</ng-container>
|
<pngx-bs-icon class="me-2" name="question-circle"></pngx-bs-icon><ng-container i18n>Documentation</ng-container>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
|
<input type="file" class="visually-hidden" id="navbar-file-upload" (change)="onFileSelected($event)" multiple #fileUpload tabindex="-1" aria-hidden="true">
|
||||||
|
<label for="navbar-file-upload" class="visually-hidden" i18n>Upload Files</label>
|
||||||
|
|
||||||
<div class="container-fluid">
|
<div class="container-fluid">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<nav id="sidebarMenu" class="d-md-block bg-light sidebar collapse"
|
<nav id="sidebarMenu" class="d-md-block bg-light sidebar collapse"
|
||||||
@@ -78,9 +93,9 @@
|
|||||||
@if (canSaveSettings) {
|
@if (canSaveSettings) {
|
||||||
<button class="btn btn-sm btn-dark sidebar-slim-toggler" (click)="toggleSlimSidebar()" [aria-label]="slimSidebarEnabled ? 'Expand sidebar' : 'Collapse sidebar'" i18n-aria-label>
|
<button class="btn btn-sm btn-dark sidebar-slim-toggler" (click)="toggleSlimSidebar()" [aria-label]="slimSidebarEnabled ? 'Expand sidebar' : 'Collapse sidebar'" i18n-aria-label>
|
||||||
@if (slimSidebarEnabled) {
|
@if (slimSidebarEnabled) {
|
||||||
<i-bs width="0.9em" height="0.9em" name="chevron-double-right"></i-bs>
|
<pngx-bs-icon width="0.9em" height="0.9em" name="chevron-double-right"></pngx-bs-icon>
|
||||||
} @else {
|
} @else {
|
||||||
<i-bs width="0.9em" height="0.9em" name="chevron-double-left"></i-bs>
|
<pngx-bs-icon width="0.9em" height="0.9em" name="chevron-double-left"></pngx-bs-icon>
|
||||||
}
|
}
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
@@ -90,7 +105,7 @@
|
|||||||
<a class="nav-link" [class.opacity-50]="settingsService.sidebarItemIsHidden(HideableSidebarItemID.Dashboard)" [class.pe-5]="settingsService.organizingSidebarItems() && !slimSidebarEnabled && !slimSidebarAnimating()" routerLink="dashboard" routerLinkActive="active" (click)="closeMenu()"
|
<a class="nav-link" [class.opacity-50]="settingsService.sidebarItemIsHidden(HideableSidebarItemID.Dashboard)" [class.pe-5]="settingsService.organizingSidebarItems() && !slimSidebarEnabled && !slimSidebarAnimating()" routerLink="dashboard" routerLinkActive="active" (click)="closeMenu()"
|
||||||
ngbPopover="Dashboard" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end"
|
ngbPopover="Dashboard" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end"
|
||||||
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||||
<i-bs class="me-2" name="house"></i-bs><span class="nav-link-label"><ng-container i18n>Dashboard</ng-container></span>
|
<pngx-bs-icon class="me-2" name="house"></pngx-bs-icon><span class="nav-link-label"><ng-container i18n>Dashboard</ng-container></span>
|
||||||
</a>
|
</a>
|
||||||
@if (settingsService.organizingSidebarItems()) {
|
@if (settingsService.organizingSidebarItems()) {
|
||||||
<pngx-input-switch class="position-absolute top-50 end-0 translate-middle-y me-1" [class.d-none]="slimSidebarEnabled || slimSidebarAnimating()" [compact]="true" title="Dashboard" i18n-title [ngModel]="!settingsService.sidebarItemIsHidden(HideableSidebarItemID.Dashboard)" (ngModelChange)="toggleSidebarItem(HideableSidebarItemID.Dashboard, $event)"></pngx-input-switch>
|
<pngx-input-switch class="position-absolute top-50 end-0 translate-middle-y me-1" [class.d-none]="slimSidebarEnabled || slimSidebarAnimating()" [compact]="true" title="Dashboard" i18n-title [ngModel]="!settingsService.sidebarItemIsHidden(HideableSidebarItemID.Dashboard)" (ngModelChange)="toggleSidebarItem(HideableSidebarItemID.Dashboard, $event)"></pngx-input-switch>
|
||||||
@@ -102,7 +117,7 @@
|
|||||||
(click)="closeMenu()"
|
(click)="closeMenu()"
|
||||||
ngbPopover="Documents" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end"
|
ngbPopover="Documents" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end"
|
||||||
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||||
<i-bs class="me-2" name="files"></i-bs><span class="nav-link-label"><ng-container i18n>Documents</ng-container></span>
|
<pngx-bs-icon class="me-2" name="files"></pngx-bs-icon><span class="nav-link-label"><ng-container i18n>Documents</ng-container></span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
@@ -121,7 +136,7 @@
|
|||||||
routerLinkActive="active" (click)="closeMenu()" [ngbPopover]="view.name"
|
routerLinkActive="active" (click)="closeMenu()" [ngbPopover]="view.name"
|
||||||
[disablePopover]="!slimSidebarPopoversEnabled" placement="end" container="body" triggers="mouseenter:mouseleave"
|
[disablePopover]="!slimSidebarPopoversEnabled" placement="end" container="body" triggers="mouseenter:mouseleave"
|
||||||
popoverClass="popover-slim">
|
popoverClass="popover-slim">
|
||||||
<i-bs class="me-2" [name]="view.icon || 'funnel'"></i-bs><span><div class="d-inline-flex view-name"><span class="overflow-hidden" [class.text-wrap]="!slimSidebarEnabled">{{view.name}}</span></div>
|
<pngx-bs-icon class="me-2" [name]="view.icon || 'funnel'"></pngx-bs-icon><span><div class="d-inline-flex view-name"><span class="overflow-hidden" [class.text-wrap]="!slimSidebarEnabled">{{view.name}}</span></div>
|
||||||
@if (showSidebarCounts && !slimSidebarEnabled) {
|
@if (showSidebarCounts && !slimSidebarEnabled) {
|
||||||
<span class="badge bg-info text-dark ms-2 d-inline">{{ savedViewService.getDocumentCount(view) }}</span>
|
<span class="badge bg-info text-dark ms-2 d-inline">{{ savedViewService.getDocumentCount(view) }}</span>
|
||||||
}
|
}
|
||||||
@@ -132,7 +147,7 @@
|
|||||||
</a>
|
</a>
|
||||||
@if (settingsService.organizingSidebarSavedViews() && canSaveSettings) {
|
@if (settingsService.organizingSidebarSavedViews() && canSaveSettings) {
|
||||||
<div class="position-absolute end-0 top-0 px-1 py-2" [class.me-n2]="slimSidebarEnabled" cdkDragHandle>
|
<div class="position-absolute end-0 top-0 px-1 py-2" [class.me-n2]="slimSidebarEnabled" cdkDragHandle>
|
||||||
<i-bs name="grip-vertical"></i-bs>
|
<pngx-bs-icon name="grip-vertical"></pngx-bs-icon>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</li>
|
</li>
|
||||||
@@ -159,10 +174,10 @@
|
|||||||
routerLinkActive="active" (click)="closeMenu()" [ngbPopover]="d.title | documentTitle"
|
routerLinkActive="active" (click)="closeMenu()" [ngbPopover]="d.title | documentTitle"
|
||||||
[disablePopover]="!slimSidebarPopoversEnabled" placement="end" container="body" triggers="mouseenter:mouseleave"
|
[disablePopover]="!slimSidebarPopoversEnabled" 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>
|
<pngx-bs-icon class="me-2" name="file-text"></pngx-bs-icon><span>{{d.title | documentTitle}}</span>
|
||||||
<span class="close flex-column justify-content-center"
|
<span class="close flex-column justify-content-center"
|
||||||
(click)="$event.preventDefault(); $event.stopPropagation(); closeDocument(d)">
|
(click)="$event.preventDefault(); $event.stopPropagation(); closeDocument(d)">
|
||||||
<i-bs name="x"></i-bs>
|
<pngx-bs-icon name="x"></pngx-bs-icon>
|
||||||
</span>
|
</span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
@@ -173,7 +188,7 @@
|
|||||||
[class.text-truncate]="!slimSidebarEnabled" (click)="closeAll()"
|
[class.text-truncate]="!slimSidebarEnabled" (click)="closeAll()"
|
||||||
ngbPopover="Close all" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end"
|
ngbPopover="Close all" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end"
|
||||||
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||||
<i-bs class="me-2" name="x"></i-bs><span class="nav-link-label"><ng-container i18n>Close all</ng-container></span>
|
<pngx-bs-icon class="me-2" name="x"></pngx-bs-icon><span class="nav-link-label"><ng-container i18n>Close all</ng-container></span>
|
||||||
</button>
|
</button>
|
||||||
</li>
|
</li>
|
||||||
}
|
}
|
||||||
@@ -192,7 +207,7 @@
|
|||||||
[routerLinkActiveOptions]="{ exact: !(slimSidebarEnabled || attributesSectionsCollapsed) }" (click)="closeMenu()"
|
[routerLinkActiveOptions]="{ exact: !(slimSidebarEnabled || attributesSectionsCollapsed) }" (click)="closeMenu()"
|
||||||
ngbPopover="Attributes" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end"
|
ngbPopover="Attributes" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end"
|
||||||
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||||
<i-bs name="stack"></i-bs><span class="nav-link-label ms-2"><ng-container i18n>Attributes</ng-container></span>
|
<pngx-bs-icon name="stack"></pngx-bs-icon><span class="nav-link-label ms-2"><ng-container i18n>Attributes</ng-container></span>
|
||||||
</a>
|
</a>
|
||||||
@if (!slimSidebarEnabled && canSaveSettings) {
|
@if (!slimSidebarEnabled && canSaveSettings) {
|
||||||
<button
|
<button
|
||||||
@@ -202,7 +217,7 @@
|
|||||||
[attr.aria-label]="attributesSectionsCollapsed ? 'Expand attributes sections' : 'Collapse attributes sections'"
|
[attr.aria-label]="attributesSectionsCollapsed ? 'Expand attributes sections' : 'Collapse attributes sections'"
|
||||||
i18n-aria-label
|
i18n-aria-label
|
||||||
>
|
>
|
||||||
<i-bs [name]="attributesSectionsCollapsed ? 'plus-circle' : 'dash-circle'"></i-bs>
|
<pngx-bs-icon [name]="attributesSectionsCollapsed ? 'plus-circle' : 'dash-circle'"></pngx-bs-icon>
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
@@ -213,27 +228,27 @@
|
|||||||
<ul class="nav flex-column">
|
<ul class="nav flex-column">
|
||||||
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Tag }">
|
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Tag }">
|
||||||
<a class="nav-link" routerLink="attributes/tags" routerLinkActive="active" (click)="closeMenu()">
|
<a class="nav-link" routerLink="attributes/tags" routerLinkActive="active" (click)="closeMenu()">
|
||||||
<i-bs class="me-2" name="tags"></i-bs><span class="nav-link-label"><ng-container i18n>Tags</ng-container></span>
|
<pngx-bs-icon class="me-2" name="tags"></pngx-bs-icon><span class="nav-link-label"><ng-container i18n>Tags</ng-container></span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Correspondent }">
|
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Correspondent }">
|
||||||
<a class="nav-link" routerLink="attributes/correspondents" routerLinkActive="active" (click)="closeMenu()">
|
<a class="nav-link" routerLink="attributes/correspondents" routerLinkActive="active" (click)="closeMenu()">
|
||||||
<i-bs class="me-2" name="person"></i-bs><span class="nav-link-label"><ng-container i18n>Correspondents</ng-container></span>
|
<pngx-bs-icon class="me-2" name="person"></pngx-bs-icon><span class="nav-link-label"><ng-container i18n>Correspondents</ng-container></span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.DocumentType }">
|
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.DocumentType }">
|
||||||
<a class="nav-link" routerLink="attributes/documenttypes" routerLinkActive="active" (click)="closeMenu()">
|
<a class="nav-link" routerLink="attributes/documenttypes" routerLinkActive="active" (click)="closeMenu()">
|
||||||
<i-bs class="me-2" name="hash"></i-bs><span class="nav-link-label"><ng-container i18n>Document types</ng-container></span>
|
<pngx-bs-icon class="me-2" name="hash"></pngx-bs-icon><span class="nav-link-label"><ng-container i18n>Document types</ng-container></span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.StoragePath }">
|
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.StoragePath }">
|
||||||
<a class="nav-link" routerLink="attributes/storagepaths" routerLinkActive="active" (click)="closeMenu()">
|
<a class="nav-link" routerLink="attributes/storagepaths" routerLinkActive="active" (click)="closeMenu()">
|
||||||
<i-bs class="me-2" name="folder"></i-bs><span class="nav-link-label"><ng-container i18n>Storage paths</ng-container></span>
|
<pngx-bs-icon class="me-2" name="folder"></pngx-bs-icon><span class="nav-link-label"><ng-container i18n>Storage paths</ng-container></span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.CustomField }">
|
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.CustomField }">
|
||||||
<a class="nav-link" routerLink="attributes/customfields" routerLinkActive="active" (click)="closeMenu()">
|
<a class="nav-link" routerLink="attributes/customfields" routerLinkActive="active" (click)="closeMenu()">
|
||||||
<i-bs class="me-2" name="ui-radios"></i-bs><span class="nav-link-label"><ng-container i18n>Custom fields</ng-container></span>
|
<pngx-bs-icon class="me-2" name="ui-radios"></pngx-bs-icon><span class="nav-link-label"><ng-container i18n>Custom fields</ng-container></span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
@@ -244,7 +259,7 @@
|
|||||||
<a class="nav-link" [class.opacity-50]="settingsService.sidebarItemIsHidden(HideableSidebarItemID.SavedViews)" [class.pe-5]="settingsService.organizingSidebarItems() && !slimSidebarEnabled && !slimSidebarAnimating()" routerLink="savedviews" routerLinkActive="active" (click)="closeMenu()"
|
<a class="nav-link" [class.opacity-50]="settingsService.sidebarItemIsHidden(HideableSidebarItemID.SavedViews)" [class.pe-5]="settingsService.organizingSidebarItems() && !slimSidebarEnabled && !slimSidebarAnimating()" routerLink="savedviews" routerLinkActive="active" (click)="closeMenu()"
|
||||||
ngbPopover="Saved Views" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end"
|
ngbPopover="Saved Views" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end"
|
||||||
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||||
<i-bs class="me-2" name="window-stack"></i-bs><span class="nav-link-label"><ng-container i18n>Saved Views</ng-container></span>
|
<pngx-bs-icon class="me-2" name="window-stack"></pngx-bs-icon><span class="nav-link-label"><ng-container i18n>Saved Views</ng-container></span>
|
||||||
</a>
|
</a>
|
||||||
@if (settingsService.organizingSidebarItems()) {
|
@if (settingsService.organizingSidebarItems()) {
|
||||||
<pngx-input-switch class="position-absolute top-50 end-0 translate-middle-y me-1" [class.d-none]="slimSidebarEnabled || slimSidebarAnimating()" [compact]="true" title="Saved Views" i18n-title [ngModel]="!settingsService.sidebarItemIsHidden(HideableSidebarItemID.SavedViews)" (ngModelChange)="toggleSidebarItem(HideableSidebarItemID.SavedViews, $event)"></pngx-input-switch>
|
<pngx-input-switch class="position-absolute top-50 end-0 translate-middle-y me-1" [class.d-none]="slimSidebarEnabled || slimSidebarAnimating()" [compact]="true" title="Saved Views" i18n-title [ngModel]="!settingsService.sidebarItemIsHidden(HideableSidebarItemID.SavedViews)" (ngModelChange)="toggleSidebarItem(HideableSidebarItemID.SavedViews, $event)"></pngx-input-switch>
|
||||||
@@ -255,7 +270,7 @@
|
|||||||
<a class="nav-link" [class.opacity-50]="settingsService.sidebarItemIsHidden(HideableSidebarItemID.ShareLinks)" [class.pe-5]="settingsService.organizingSidebarItems() && !slimSidebarEnabled && !slimSidebarAnimating()" routerLink="share-links" routerLinkActive="active" (click)="closeMenu()"
|
<a class="nav-link" [class.opacity-50]="settingsService.sidebarItemIsHidden(HideableSidebarItemID.ShareLinks)" [class.pe-5]="settingsService.organizingSidebarItems() && !slimSidebarEnabled && !slimSidebarAnimating()" routerLink="share-links" routerLinkActive="active" (click)="closeMenu()"
|
||||||
ngbPopover="Share links" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end"
|
ngbPopover="Share links" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end"
|
||||||
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||||
<i-bs class="me-2" name="link"></i-bs><span class="nav-link-label"><ng-container i18n>Share links</ng-container></span>
|
<pngx-bs-icon class="me-2" name="link"></pngx-bs-icon><span class="nav-link-label"><ng-container i18n>Share links</ng-container></span>
|
||||||
</a>
|
</a>
|
||||||
@if (settingsService.organizingSidebarItems()) {
|
@if (settingsService.organizingSidebarItems()) {
|
||||||
<pngx-input-switch class="position-absolute top-50 end-0 translate-middle-y me-1" [class.d-none]="slimSidebarEnabled || slimSidebarAnimating()" [compact]="true" title="Share Links" i18n-title [ngModel]="!settingsService.sidebarItemIsHidden(HideableSidebarItemID.ShareLinks)" (ngModelChange)="toggleSidebarItem(HideableSidebarItemID.ShareLinks, $event)"></pngx-input-switch>
|
<pngx-input-switch class="position-absolute top-50 end-0 translate-middle-y me-1" [class.d-none]="slimSidebarEnabled || slimSidebarAnimating()" [compact]="true" title="Share Links" i18n-title [ngModel]="!settingsService.sidebarItemIsHidden(HideableSidebarItemID.ShareLinks)" (ngModelChange)="toggleSidebarItem(HideableSidebarItemID.ShareLinks, $event)"></pngx-input-switch>
|
||||||
@@ -268,7 +283,7 @@
|
|||||||
<a class="nav-link" [class.opacity-50]="settingsService.sidebarItemIsHidden(HideableSidebarItemID.Workflows)" [class.pe-5]="settingsService.organizingSidebarItems() && !slimSidebarEnabled && !slimSidebarAnimating()" routerLink="workflows" routerLinkActive="active" (click)="closeMenu()"
|
<a class="nav-link" [class.opacity-50]="settingsService.sidebarItemIsHidden(HideableSidebarItemID.Workflows)" [class.pe-5]="settingsService.organizingSidebarItems() && !slimSidebarEnabled && !slimSidebarAnimating()" routerLink="workflows" routerLinkActive="active" (click)="closeMenu()"
|
||||||
ngbPopover="Workflows" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end"
|
ngbPopover="Workflows" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end"
|
||||||
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||||
<i-bs class="me-2" name="boxes"></i-bs><span class="nav-link-label"><ng-container i18n>Workflows</ng-container></span>
|
<pngx-bs-icon class="me-2" name="boxes"></pngx-bs-icon><span class="nav-link-label"><ng-container i18n>Workflows</ng-container></span>
|
||||||
</a>
|
</a>
|
||||||
@if (settingsService.organizingSidebarItems()) {
|
@if (settingsService.organizingSidebarItems()) {
|
||||||
<pngx-input-switch class="position-absolute top-50 end-0 translate-middle-y me-1" [class.d-none]="slimSidebarEnabled || slimSidebarAnimating()" [compact]="true" title="Workflows" i18n-title [ngModel]="!settingsService.sidebarItemIsHidden(HideableSidebarItemID.Workflows)" (ngModelChange)="toggleSidebarItem(HideableSidebarItemID.Workflows, $event)"></pngx-input-switch>
|
<pngx-input-switch class="position-absolute top-50 end-0 translate-middle-y me-1" [class.d-none]="slimSidebarEnabled || slimSidebarAnimating()" [compact]="true" title="Workflows" i18n-title [ngModel]="!settingsService.sidebarItemIsHidden(HideableSidebarItemID.Workflows)" (ngModelChange)="toggleSidebarItem(HideableSidebarItemID.Workflows, $event)"></pngx-input-switch>
|
||||||
@@ -279,7 +294,7 @@
|
|||||||
<a class="nav-link" [class.opacity-50]="settingsService.sidebarItemIsHidden(HideableSidebarItemID.Mail)" [class.pe-5]="settingsService.organizingSidebarItems() && !slimSidebarEnabled && !slimSidebarAnimating()" routerLink="mail" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Mail"
|
<a class="nav-link" [class.opacity-50]="settingsService.sidebarItemIsHidden(HideableSidebarItemID.Mail)" [class.pe-5]="settingsService.organizingSidebarItems() && !slimSidebarEnabled && !slimSidebarAnimating()" routerLink="mail" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Mail"
|
||||||
i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end" container="body"
|
i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end" container="body"
|
||||||
triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||||
<i-bs class="me-2" name="envelope"></i-bs><span class="nav-link-label"><ng-container i18n>Mail</ng-container></span>
|
<pngx-bs-icon class="me-2" name="envelope"></pngx-bs-icon><span class="nav-link-label"><ng-container i18n>Mail</ng-container></span>
|
||||||
</a>
|
</a>
|
||||||
@if (settingsService.organizingSidebarItems()) {
|
@if (settingsService.organizingSidebarItems()) {
|
||||||
<pngx-input-switch class="position-absolute top-50 end-0 translate-middle-y me-1" [class.d-none]="slimSidebarEnabled || slimSidebarAnimating()" [compact]="true" title="Mail" i18n-title [ngModel]="!settingsService.sidebarItemIsHidden(HideableSidebarItemID.Mail)" (ngModelChange)="toggleSidebarItem(HideableSidebarItemID.Mail, $event)"></pngx-input-switch>
|
<pngx-input-switch class="position-absolute top-50 end-0 translate-middle-y me-1" [class.d-none]="slimSidebarEnabled || slimSidebarAnimating()" [compact]="true" title="Mail" i18n-title [ngModel]="!settingsService.sidebarItemIsHidden(HideableSidebarItemID.Mail)" (ngModelChange)="toggleSidebarItem(HideableSidebarItemID.Mail, $event)"></pngx-input-switch>
|
||||||
@@ -289,7 +304,7 @@
|
|||||||
<a class="nav-link" routerLink="trash" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Trash"
|
<a class="nav-link" routerLink="trash" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Trash"
|
||||||
i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end" container="body"
|
i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end" container="body"
|
||||||
triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||||
<i-bs class="me-2" name="trash"></i-bs><span class="nav-link-label"><ng-container i18n>Trash</ng-container></span>
|
<pngx-bs-icon class="me-2" name="trash"></pngx-bs-icon><span class="nav-link-label"><ng-container i18n>Trash</ng-container></span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
@@ -305,21 +320,21 @@
|
|||||||
<a class="nav-link" routerLink="settings" routerLinkActive="active" (click)="closeMenu()"
|
<a class="nav-link" routerLink="settings" routerLinkActive="active" (click)="closeMenu()"
|
||||||
ngbPopover="Settings" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end"
|
ngbPopover="Settings" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end"
|
||||||
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||||
<i-bs class="me-2" name="gear"></i-bs><span class="nav-link-label"><ng-container i18n>Settings</ng-container></span>
|
<pngx-bs-icon class="me-2" name="gear"></pngx-bs-icon><span class="nav-link-label"><ng-container i18n>Settings</ng-container></span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.AppConfig }">
|
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.AppConfig }">
|
||||||
<a class="nav-link" routerLink="config" routerLinkActive="active" (click)="closeMenu()"
|
<a class="nav-link" routerLink="config" routerLinkActive="active" (click)="closeMenu()"
|
||||||
ngbPopover="Configuration" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end"
|
ngbPopover="Configuration" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end"
|
||||||
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||||
<i-bs class="me-2" name="sliders2-vertical"></i-bs><span class="nav-link-label"><ng-container i18n>Configuration</ng-container></span>
|
<pngx-bs-icon class="me-2" name="sliders2-vertical"></pngx-bs-icon><span class="nav-link-label"><ng-container i18n>Configuration</ng-container></span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.User }">
|
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.User }">
|
||||||
<a class="nav-link" routerLink="usersgroups" routerLinkActive="active" (click)="closeMenu()"
|
<a class="nav-link" routerLink="usersgroups" routerLinkActive="active" (click)="closeMenu()"
|
||||||
ngbPopover="Users & Groups" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end"
|
ngbPopover="Users & Groups" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end"
|
||||||
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||||
<i-bs class="me-2" name="people"></i-bs><span class="nav-link-label"><ng-container i18n>Users & Groups</ng-container></span>
|
<pngx-bs-icon class="me-2" name="people"></pngx-bs-icon><span class="nav-link-label"><ng-container i18n>Users & Groups</ng-container></span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item app-link"
|
<li class="nav-item app-link"
|
||||||
@@ -328,12 +343,12 @@
|
|||||||
<a class="nav-link" routerLink="tasks" routerLinkActive="active" (click)="closeMenu()"
|
<a class="nav-link" routerLink="tasks" routerLinkActive="active" (click)="closeMenu()"
|
||||||
ngbPopover="Tasks" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end"
|
ngbPopover="Tasks" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end"
|
||||||
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||||
<i-bs class="me-2" name="list-task"></i-bs><span class="nav-link-label"><ng-container i18n>Tasks</ng-container></span>
|
<pngx-bs-icon class="me-2" name="list-task"></pngx-bs-icon><span class="nav-link-label"><ng-container i18n>Tasks</ng-container></span>
|
||||||
@if (tasksService.needsAttentionTasks.length > 0 && !slimSidebarEnabled) {
|
@if (tasksService.needsAttentionTasksCount > 0 && !slimSidebarEnabled) {
|
||||||
<span class="badge bg-danger ms-2 d-inline flex-shrink-0">{{tasksService.needsAttentionTasks.length}}</span>
|
<span class="badge bg-danger ms-2 d-inline flex-shrink-0">{{tasksService.needsAttentionTasksCount}}</span>
|
||||||
}
|
}
|
||||||
@if (tasksService.needsAttentionTasks.length > 0 && slimSidebarEnabled) {
|
@if (tasksService.needsAttentionTasksCount > 0 && slimSidebarEnabled) {
|
||||||
<span class="badge bg-danger position-absolute top-0 end-0 d-none d-md-block">{{tasksService.needsAttentionTasks.length}}</span>
|
<span class="badge bg-danger position-absolute top-0 end-0 d-none d-md-block">{{tasksService.needsAttentionTasksCount}}</span>
|
||||||
}
|
}
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
@@ -342,7 +357,7 @@
|
|||||||
<a class="nav-link" routerLink="logs" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Logs"
|
<a class="nav-link" routerLink="logs" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Logs"
|
||||||
i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end" container="body"
|
i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end" container="body"
|
||||||
triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||||
<i-bs class="me-2" name="text-left"></i-bs><span class="nav-link-label"><ng-container i18n>Logs</ng-container></span>
|
<pngx-bs-icon class="me-2" name="text-left"></pngx-bs-icon><span class="nav-link-label"><ng-container i18n>Logs</ng-container></span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
}
|
}
|
||||||
@@ -351,7 +366,7 @@
|
|||||||
target="_blank" rel="noopener noreferrer" href="https://docs.paperless-ngx.com" ngbPopover="Documentation"
|
target="_blank" rel="noopener noreferrer" href="https://docs.paperless-ngx.com" ngbPopover="Documentation"
|
||||||
i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end" container="body"
|
i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end" container="body"
|
||||||
triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||||
<i-bs class="d-flex me-2" name="question-circle"></i-bs><span><ng-container i18n>Documentation</ng-container></span>
|
<pngx-bs-icon class="d-flex me-2" name="question-circle"></pngx-bs-icon><span><ng-container i18n>Documentation</ng-container></span>
|
||||||
</a>
|
</a>
|
||||||
@if (settingsService.organizingSidebarItems()) {
|
@if (settingsService.organizingSidebarItems()) {
|
||||||
<pngx-input-switch class="position-absolute top-50 end-0 translate-middle-y me-1" [class.d-none]="slimSidebarEnabled || slimSidebarAnimating()" [compact]="true" title="Documentation" i18n-title [ngModel]="!settingsService.sidebarItemIsHidden(HideableSidebarItemID.Documentation)" (ngModelChange)="toggleSidebarItem(HideableSidebarItemID.Documentation, $event)"></pngx-input-switch>
|
<pngx-input-switch class="position-absolute top-50 end-0 translate-middle-y me-1" [class.d-none]="slimSidebarEnabled || slimSidebarAnimating()" [compact]="true" title="Documentation" i18n-title [ngModel]="!settingsService.sidebarItemIsHidden(HideableSidebarItemID.Documentation)" (ngModelChange)="toggleSidebarItem(HideableSidebarItemID.Documentation, $event)"></pngx-input-switch>
|
||||||
@@ -393,7 +408,7 @@
|
|||||||
href="https://github.com/paperless-ngx/paperless-ngx/releases"
|
href="https://github.com/paperless-ngx/paperless-ngx/releases"
|
||||||
[ngbPopover]="updateAvailablePopContent" popoverClass="shadow" triggers="mouseenter:mouseleave"
|
[ngbPopover]="updateAvailablePopContent" popoverClass="shadow" triggers="mouseenter:mouseleave"
|
||||||
container="body">
|
container="body">
|
||||||
<i-bs width="1.2em" height="1.2em" name="info-circle" class="me-1"></i-bs>
|
<pngx-bs-icon width="1.2em" height="1.2em" name="info-circle" class="me-1"></pngx-bs-icon>
|
||||||
@if (appRemoteVersion()?.update_available) {
|
@if (appRemoteVersion()?.update_available) {
|
||||||
<ng-container i18n>Update available</ng-container>
|
<ng-container i18n>Update available</ng-container>
|
||||||
}
|
}
|
||||||
@@ -403,7 +418,7 @@
|
|||||||
<a *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.UISettings }" class="small text-decoration-none" routerLink="/settings" fragment="update-checking" aria-label="Configure update checking" i18n-aria-label
|
<a *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.UISettings }" class="small text-decoration-none" routerLink="/settings" fragment="update-checking" aria-label="Configure update checking" i18n-aria-label
|
||||||
[ngbPopover]="updateCheckingNotEnabledPopContent" popoverClass="shadow" triggers="mouseenter"
|
[ngbPopover]="updateCheckingNotEnabledPopContent" popoverClass="shadow" triggers="mouseenter"
|
||||||
container="body">
|
container="body">
|
||||||
<i-bs width="1.2em" height="1.2em" name="info-circle"></i-bs>
|
<pngx-bs-icon width="1.2em" height="1.2em" name="info-circle"></pngx-bs-icon>
|
||||||
</a>
|
</a>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
@@ -419,5 +434,6 @@
|
|||||||
[ngClass]="slimSidebarEnabled ? 'col-slim' : 'col-sidebar-expanded'">
|
[ngClass]="slimSidebarEnabled ? 'col-slim' : 'col-sidebar-expanded'">
|
||||||
<router-outlet></router-outlet>
|
<router-outlet></router-outlet>
|
||||||
</main>
|
</main>
|
||||||
|
<pngx-upload-status-tray></pngx-upload-status-tray>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -142,7 +142,7 @@ main {
|
|||||||
padding-left: 0;
|
padding-left: 0;
|
||||||
padding-right: 0;
|
padding-right: 0;
|
||||||
|
|
||||||
i-bs {
|
pngx-bs-icon {
|
||||||
margin-right: 0 !important;
|
margin-right: 0 !important;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -268,7 +268,7 @@ main {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
i-bs {
|
pngx-bs-icon {
|
||||||
position: relative;
|
position: relative;
|
||||||
top: -1px;
|
top: -1px;
|
||||||
}
|
}
|
||||||
@@ -279,7 +279,7 @@ main {
|
|||||||
align-items: center;
|
align-items: center;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
|
||||||
> i-bs {
|
> pngx-bs-icon {
|
||||||
flex: 0 0 auto;
|
flex: 0 0 auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -373,11 +373,11 @@ main {
|
|||||||
width: 1.8rem;
|
width: 1.8rem;
|
||||||
height: 100%;
|
height: 100%;
|
||||||
|
|
||||||
i-bs {
|
pngx-bs-icon {
|
||||||
opacity: 0.5;
|
opacity: 0.5;
|
||||||
}
|
}
|
||||||
|
|
||||||
&:hover i-bs {
|
&:hover pngx-bs-icon {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -392,7 +392,7 @@ main {
|
|||||||
text-decoration: underline;
|
text-decoration: underline;
|
||||||
}
|
}
|
||||||
|
|
||||||
i-bs {
|
pngx-bs-icon {
|
||||||
margin-bottom: 2px;
|
margin-bottom: 2px;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -538,7 +538,36 @@ main {
|
|||||||
|
|
||||||
.header-search {
|
.header-search {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
max-width: 55rem;
|
max-width: 50rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-search pngx-global-search {
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
// light w shadow to stand out against search
|
||||||
|
.header-upload {
|
||||||
|
--bs-btn-bg: rgba(255, 255, 255, .15);
|
||||||
|
--bs-btn-hover-bg: rgba(255, 255, 255, .22);
|
||||||
|
--bs-btn-active-bg: rgba(255, 255, 255, .3);
|
||||||
|
color: var(--pngx-primary-text-contrast) !important; // .navbar .dropdown > .btn in styles.scss
|
||||||
|
|
||||||
|
& + & {
|
||||||
|
border-left: 1px solid rgba(255, 255, 255, .2) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
box-shadow: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus-visible {
|
||||||
|
box-shadow: inset 0 0 0 2px rgba(255, 255, 255, .5);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.header-upload-group {
|
||||||
|
border-radius: var(--bs-border-radius-sm);
|
||||||
|
box-shadow: 0 1px 3px rgba(0, 0, 0, .35), 0 1px 1px rgba(0, 0, 0, .2);
|
||||||
}
|
}
|
||||||
|
|
||||||
@media screen and (max-width: 575.98px) {
|
@media screen and (max-width: 575.98px) {
|
||||||
@@ -630,14 +659,13 @@ main {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.dropdown-toggle::after {
|
.dropdown-toggle::after {
|
||||||
margin-left: 0.4em;
|
display: none;
|
||||||
vertical-align: 0.155em;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.navbar .dropdown-menu {
|
.navbar .dropdown-menu {
|
||||||
font-size: 0.875rem; // body size
|
font-size: 0.875rem; // body size
|
||||||
|
|
||||||
a i-bs, button i-bs {
|
a pngx-bs-icon, button pngx-bs-icon {
|
||||||
opacity: 0.6;
|
opacity: 0.6;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -665,6 +693,10 @@ main {
|
|||||||
cursor: move;
|
cursor: move;
|
||||||
}
|
}
|
||||||
|
|
||||||
::ng-deep .navItemDrag .position-absolute i-bs {
|
::ng-deep .navItemDrag .position-absolute pngx-bs-icon {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.smaller {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
@@ -6,11 +6,10 @@ import {
|
|||||||
} from '@angular/common/http/testing'
|
} from '@angular/common/http/testing'
|
||||||
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||||
import { BrowserModule } from '@angular/platform-browser'
|
import { BrowserModule, By } from '@angular/platform-browser'
|
||||||
import { ActivatedRoute, Router } from '@angular/router'
|
import { ActivatedRoute, Router } from '@angular/router'
|
||||||
import { RouterTestingModule } from '@angular/router/testing'
|
import { RouterTestingModule } from '@angular/router/testing'
|
||||||
import { NgbModal, NgbModalModule, NgbModule } from '@ng-bootstrap/ng-bootstrap'
|
import { NgbModal, NgbModalModule, NgbModule } from '@ng-bootstrap/ng-bootstrap'
|
||||||
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
|
||||||
import { provideUiTour } from 'ngx-ui-tour-ng-bootstrap'
|
import { provideUiTour } from 'ngx-ui-tour-ng-bootstrap'
|
||||||
import { of, throwError } from 'rxjs'
|
import { of, throwError } from 'rxjs'
|
||||||
import { routes } from 'src/app/app-routing.module'
|
import { routes } from 'src/app/app-routing.module'
|
||||||
@@ -33,8 +32,11 @@ import { SearchService } from 'src/app/services/rest/search.service'
|
|||||||
import { SettingsService } from 'src/app/services/settings.service'
|
import { SettingsService } from 'src/app/services/settings.service'
|
||||||
import { TasksService } from 'src/app/services/tasks.service'
|
import { TasksService } from 'src/app/services/tasks.service'
|
||||||
import { ToastService } from 'src/app/services/toast.service'
|
import { ToastService } from 'src/app/services/toast.service'
|
||||||
|
import { UploadDocumentsService } from 'src/app/services/upload-documents.service'
|
||||||
|
import { WebsocketStatusService } from 'src/app/services/websocket-status.service'
|
||||||
import { environment } from 'src/environments/environment'
|
import { environment } from 'src/environments/environment'
|
||||||
import { ProfileEditDialogComponent } from '../common/profile-edit-dialog/profile-edit-dialog.component'
|
import { ProfileEditDialogComponent } from '../common/profile-edit-dialog/profile-edit-dialog.component'
|
||||||
|
import { UploadStatusTrayComponent } from '../common/upload-status-tray/upload-status-tray.component'
|
||||||
import { DocumentDetailComponent } from '../document-detail/document-detail.component'
|
import { DocumentDetailComponent } from '../document-detail/document-detail.component'
|
||||||
import { AppFrameComponent } from './app-frame.component'
|
import { AppFrameComponent } from './app-frame.component'
|
||||||
import { GlobalSearchComponent } from './global-search/global-search.component'
|
import { GlobalSearchComponent } from './global-search/global-search.component'
|
||||||
@@ -105,7 +107,6 @@ describe('AppFrameComponent', () => {
|
|||||||
ReactiveFormsModule,
|
ReactiveFormsModule,
|
||||||
DragDropModule,
|
DragDropModule,
|
||||||
NgbModalModule,
|
NgbModalModule,
|
||||||
NgxBootstrapIconsModule.pick(allIcons),
|
|
||||||
AppFrameComponent,
|
AppFrameComponent,
|
||||||
IfPermissionsDirective,
|
IfPermissionsDirective,
|
||||||
GlobalSearchComponent,
|
GlobalSearchComponent,
|
||||||
@@ -462,6 +463,45 @@ describe('AppFrameComponent', () => {
|
|||||||
expect(routerSpy).toHaveBeenCalled()
|
expect(routerSpy).toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should upload files selected from the navbar upload button', () => {
|
||||||
|
const uploadDocumentsService = TestBed.inject(UploadDocumentsService)
|
||||||
|
const uploadSpy = jest
|
||||||
|
.spyOn(uploadDocumentsService, 'uploadFiles')
|
||||||
|
.mockImplementation(() => {})
|
||||||
|
const toastSpy = jest.spyOn(toastService, 'showInfo')
|
||||||
|
const file = new File(['testing'], 'file.pdf')
|
||||||
|
const input = window.document.createElement('input')
|
||||||
|
jest.spyOn(input, 'files', 'get').mockReturnValue([file] as any)
|
||||||
|
|
||||||
|
component.onFileSelected({ target: input } as unknown as Event)
|
||||||
|
expect(toastSpy).not.toHaveBeenCalled()
|
||||||
|
expect(uploadSpy).toHaveBeenCalledWith([file])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should show in-progress uploads and expand the status tray', () => {
|
||||||
|
const websocketStatusService = TestBed.inject(WebsocketStatusService)
|
||||||
|
expect(component.uploadsInProgress).toEqual(0)
|
||||||
|
websocketStatusService.newFileUpload('file.pdf')
|
||||||
|
expect(component.uploadsInProgress).toEqual(1)
|
||||||
|
|
||||||
|
fixture.detectChanges()
|
||||||
|
const tray = fixture.debugElement.query(
|
||||||
|
By.directive(UploadStatusTrayComponent)
|
||||||
|
).componentInstance as UploadStatusTrayComponent
|
||||||
|
tray.toggleCollapsed()
|
||||||
|
expect(tray.collapsed()).toBe(true)
|
||||||
|
component.showUploadStatus()
|
||||||
|
expect(tray.collapsed()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not upload when no files are selected from the navbar', () => {
|
||||||
|
const uploadDocumentsService = TestBed.inject(UploadDocumentsService)
|
||||||
|
const uploadSpy = jest.spyOn(uploadDocumentsService, 'uploadFiles')
|
||||||
|
const input = window.document.createElement('input')
|
||||||
|
component.onFileSelected({ target: input } as unknown as Event)
|
||||||
|
expect(uploadSpy).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
it('should close all documents on logout', () => {
|
it('should close all documents on logout', () => {
|
||||||
const closeAllSpy = jest.spyOn(openDocumentsService, 'closeAll')
|
const closeAllSpy = jest.spyOn(openDocumentsService, 'closeAll')
|
||||||
component.onLogout()
|
component.onLogout()
|
||||||
@@ -483,7 +523,7 @@ describe('AppFrameComponent', () => {
|
|||||||
|
|
||||||
it('should update saved view sorting on drag + drop, show info', () => {
|
it('should update saved view sorting on drag + drop, show info', () => {
|
||||||
const settingsSpy = jest.spyOn(settingsService, 'updateSidebarViewsSort')
|
const settingsSpy = jest.spyOn(settingsService, 'updateSidebarViewsSort')
|
||||||
const toastSpy = jest.spyOn(toastService, 'showInfo')
|
const toastSpy = jest.spyOn(toastService, 'showSuccess')
|
||||||
jest.spyOn(settingsService, 'storeSettings').mockReturnValue(of(true))
|
jest.spyOn(settingsService, 'storeSettings').mockReturnValue(of(true))
|
||||||
component.onDrop({ previousIndex: 0, currentIndex: 1 } as CdkDragDrop<
|
component.onDrop({ previousIndex: 0, currentIndex: 1 } as CdkDragDrop<
|
||||||
SavedView[]
|
SavedView[]
|
||||||
@@ -525,6 +565,7 @@ describe('AppFrameComponent', () => {
|
|||||||
|
|
||||||
it('should show toasts for django messages', () => {
|
it('should show toasts for django messages', () => {
|
||||||
const toastErrorSpy = jest.spyOn(toastService, 'showError')
|
const toastErrorSpy = jest.spyOn(toastService, 'showError')
|
||||||
|
const toastSuccessSpy = jest.spyOn(toastService, 'showSuccess')
|
||||||
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
|
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
|
||||||
jest.spyOn(messagesService, 'get').mockReturnValue([
|
jest.spyOn(messagesService, 'get').mockReturnValue([
|
||||||
{ level: DjangoMessageLevel.WARNING, message: 'Test warning' },
|
{ level: DjangoMessageLevel.WARNING, message: 'Test warning' },
|
||||||
@@ -535,7 +576,8 @@ describe('AppFrameComponent', () => {
|
|||||||
])
|
])
|
||||||
component.ngOnInit()
|
component.ngOnInit()
|
||||||
expect(toastErrorSpy).toHaveBeenCalledTimes(2)
|
expect(toastErrorSpy).toHaveBeenCalledTimes(2)
|
||||||
expect(toastInfoSpy).toHaveBeenCalledTimes(3)
|
expect(toastSuccessSpy).toHaveBeenCalledTimes(1)
|
||||||
|
expect(toastInfoSpy).toHaveBeenCalledTimes(2)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should call maybeRefreshDocumentCounts after saved views reload', () => {
|
it('should call maybeRefreshDocumentCounts after saved views reload', () => {
|
||||||
@@ -544,8 +586,8 @@ describe('AppFrameComponent', () => {
|
|||||||
|
|
||||||
it('should show tasks badge for needs-attention tasks', () => {
|
it('should show tasks badge for needs-attention tasks', () => {
|
||||||
jest
|
jest
|
||||||
.spyOn(tasksService, 'needsAttentionTasks', 'get')
|
.spyOn(tasksService, 'needsAttentionTasksCount', 'get')
|
||||||
.mockReturnValue([{} as any, {} as any])
|
.mockReturnValue(2)
|
||||||
|
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,14 @@ import {
|
|||||||
moveItemInArray,
|
moveItemInArray,
|
||||||
} from '@angular/cdk/drag-drop'
|
} from '@angular/cdk/drag-drop'
|
||||||
import { NgClass } from '@angular/common'
|
import { NgClass } from '@angular/common'
|
||||||
import { Component, HostListener, inject, OnInit, signal } from '@angular/core'
|
import {
|
||||||
|
Component,
|
||||||
|
HostListener,
|
||||||
|
inject,
|
||||||
|
OnInit,
|
||||||
|
signal,
|
||||||
|
viewChild,
|
||||||
|
} from '@angular/core'
|
||||||
import { FormsModule } from '@angular/forms'
|
import { FormsModule } from '@angular/forms'
|
||||||
import { ActivatedRoute, Router, RouterModule } from '@angular/router'
|
import { ActivatedRoute, Router, RouterModule } from '@angular/router'
|
||||||
import {
|
import {
|
||||||
@@ -16,10 +23,10 @@ import {
|
|||||||
NgbNavModule,
|
NgbNavModule,
|
||||||
NgbPopoverModule,
|
NgbPopoverModule,
|
||||||
} from '@ng-bootstrap/ng-bootstrap'
|
} from '@ng-bootstrap/ng-bootstrap'
|
||||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
|
||||||
import { TourNgBootstrap } from 'ngx-ui-tour-ng-bootstrap'
|
import { TourNgBootstrap } from 'ngx-ui-tour-ng-bootstrap'
|
||||||
import { Observable } from 'rxjs'
|
import { Observable } from 'rxjs'
|
||||||
import { first } from 'rxjs/operators'
|
import { first } from 'rxjs/operators'
|
||||||
|
import { BootstrapIconComponent } from 'src/app/components/common/bootstrap-icon/bootstrap-icon.component'
|
||||||
import { Document } from 'src/app/data/document'
|
import { Document } from 'src/app/data/document'
|
||||||
import { SavedView } from 'src/app/data/saved-view'
|
import { SavedView } from 'src/app/data/saved-view'
|
||||||
import {
|
import {
|
||||||
@@ -48,12 +55,15 @@ import { SavedViewService } from 'src/app/services/rest/saved-view.service'
|
|||||||
import { SettingsService } from 'src/app/services/settings.service'
|
import { SettingsService } from 'src/app/services/settings.service'
|
||||||
import { TasksService } from 'src/app/services/tasks.service'
|
import { TasksService } from 'src/app/services/tasks.service'
|
||||||
import { ToastService } from 'src/app/services/toast.service'
|
import { ToastService } from 'src/app/services/toast.service'
|
||||||
|
import { UploadDocumentsService } from 'src/app/services/upload-documents.service'
|
||||||
|
import { WebsocketStatusService } from 'src/app/services/websocket-status.service'
|
||||||
import { environment } from 'src/environments/environment'
|
import { environment } from 'src/environments/environment'
|
||||||
import { ChatComponent } from '../chat/chat/chat.component'
|
import { ChatComponent } from '../chat/chat/chat.component'
|
||||||
|
import { SwitchComponent } from '../common/input/switch/switch.component'
|
||||||
import { BrandMarkComponent } from '../common/logo/brand-mark/brand-mark.component'
|
import { BrandMarkComponent } from '../common/logo/brand-mark/brand-mark.component'
|
||||||
import { LogoComponent } from '../common/logo/logo.component'
|
import { LogoComponent } from '../common/logo/logo.component'
|
||||||
import { ProfileEditDialogComponent } from '../common/profile-edit-dialog/profile-edit-dialog.component'
|
import { ProfileEditDialogComponent } from '../common/profile-edit-dialog/profile-edit-dialog.component'
|
||||||
import { SwitchComponent } from '../common/input/switch/switch.component'
|
import { UploadStatusTrayComponent } from '../common/upload-status-tray/upload-status-tray.component'
|
||||||
import { DocumentDetailComponent } from '../document-detail/document-detail.component'
|
import { DocumentDetailComponent } from '../document-detail/document-detail.component'
|
||||||
import { ComponentWithPermissions } from '../with-permissions/with-permissions.component'
|
import { ComponentWithPermissions } from '../with-permissions/with-permissions.component'
|
||||||
import { GlobalSearchComponent } from './global-search/global-search.component'
|
import { GlobalSearchComponent } from './global-search/global-search.component'
|
||||||
@@ -79,11 +89,12 @@ const SCROLL_THRESHOLD = 16
|
|||||||
NgbPopoverModule,
|
NgbPopoverModule,
|
||||||
NgbCollapseModule,
|
NgbCollapseModule,
|
||||||
NgbNavModule,
|
NgbNavModule,
|
||||||
NgxBootstrapIconsModule,
|
BootstrapIconComponent,
|
||||||
DragDropModule,
|
DragDropModule,
|
||||||
TourNgBootstrap,
|
TourNgBootstrap,
|
||||||
FormsModule,
|
FormsModule,
|
||||||
SwitchComponent,
|
SwitchComponent,
|
||||||
|
UploadStatusTrayComponent,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class AppFrameComponent
|
export class AppFrameComponent
|
||||||
@@ -101,6 +112,10 @@ export class AppFrameComponent
|
|||||||
private modalService = inject(NgbModal)
|
private modalService = inject(NgbModal)
|
||||||
permissionsService = inject(PermissionsService)
|
permissionsService = inject(PermissionsService)
|
||||||
private djangoMessagesService = inject(DjangoMessagesService)
|
private djangoMessagesService = inject(DjangoMessagesService)
|
||||||
|
private uploadDocumentsService = inject(UploadDocumentsService)
|
||||||
|
private websocketStatusService = inject(WebsocketStatusService)
|
||||||
|
|
||||||
|
private readonly uploadStatusTray = viewChild(UploadStatusTrayComponent)
|
||||||
|
|
||||||
readonly appRemoteVersion = signal<AppRemoteVersion>(null)
|
readonly appRemoteVersion = signal<AppRemoteVersion>(null)
|
||||||
readonly isMenuCollapsed = signal(true)
|
readonly isMenuCollapsed = signal(true)
|
||||||
@@ -171,6 +186,8 @@ export class AppFrameComponent
|
|||||||
this.toastService.showError(message.message)
|
this.toastService.showError(message.message)
|
||||||
break
|
break
|
||||||
case DjangoMessageLevel.SUCCESS:
|
case DjangoMessageLevel.SUCCESS:
|
||||||
|
this.toastService.showSuccess(message.message)
|
||||||
|
break
|
||||||
case DjangoMessageLevel.INFO:
|
case DjangoMessageLevel.INFO:
|
||||||
case DjangoMessageLevel.DEBUG:
|
case DjangoMessageLevel.DEBUG:
|
||||||
this.toastService.showInfo(message.message)
|
this.toastService.showInfo(message.message)
|
||||||
@@ -477,7 +494,7 @@ export class AppFrameComponent
|
|||||||
|
|
||||||
this.settingsService.updateSidebarViewsSort(sidebarViews).subscribe({
|
this.settingsService.updateSidebarViewsSort(sidebarViews).subscribe({
|
||||||
next: () => {
|
next: () => {
|
||||||
this.toastService.showInfo($localize`Sidebar views updated`)
|
this.toastService.showSuccess($localize`Sidebar views updated`)
|
||||||
},
|
},
|
||||||
error: (e) => {
|
error: (e) => {
|
||||||
this.toastService.showError($localize`Error updating sidebar views`, e)
|
this.toastService.showError($localize`Error updating sidebar views`, e)
|
||||||
@@ -511,6 +528,22 @@ export class AppFrameComponent
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
onFileSelected(event: Event) {
|
||||||
|
const input = event.target as HTMLInputElement
|
||||||
|
const files = Array.from(input.files ?? [])
|
||||||
|
input.value = ''
|
||||||
|
if (!files.length) return
|
||||||
|
this.uploadDocumentsService.uploadFiles(files)
|
||||||
|
}
|
||||||
|
|
||||||
|
get uploadsInProgress(): number {
|
||||||
|
return this.websocketStatusService.getConsumerStatusNotCompleted().length
|
||||||
|
}
|
||||||
|
|
||||||
|
showUploadStatus() {
|
||||||
|
this.uploadStatusTray()?.expand()
|
||||||
|
}
|
||||||
|
|
||||||
onLogout() {
|
onLogout() {
|
||||||
this.openDocumentsService.closeAll()
|
this.openDocumentsService.closeAll()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
|
|
||||||
<div ngbDropdown #resultsDropdown="ngbDropdown" (openChange)="onDropdownOpenChange">
|
<div ngbDropdown #resultsDropdown="ngbDropdown" (openChange)="onDropdownOpenChange">
|
||||||
<form class="form-inline position-relative">
|
<form class="form-inline position-relative">
|
||||||
<i-bs width="1em" height="1em" name="search"></i-bs>
|
<pngx-bs-icon width="1em" height="1em" name="search"></pngx-bs-icon>
|
||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
<div class="form-control form-control-sm">
|
<div class="form-control form-control-sm">
|
||||||
<input class="bg-transparent border-0 w-100 h-100" #searchInput type="text" name="query"
|
<input class="bg-transparent border-0 w-100 h-100" #searchInput type="text" name="query"
|
||||||
@@ -25,7 +25,7 @@
|
|||||||
} @else {
|
} @else {
|
||||||
<ng-container i18n>Search</ng-container>
|
<ng-container i18n>Search</ng-container>
|
||||||
}
|
}
|
||||||
<i-bs width="1em" height="1em" name="arrow-right-short"></i-bs>
|
<pngx-bs-icon width="1em" height="1em" name="arrow-right-short"></pngx-bs-icon>
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
@@ -35,7 +35,7 @@
|
|||||||
<div #resultItem ngbDropdownItem class="py-2 d-flex align-items-center focus-ring border-0 cursor-pointer" tabindex="-1"
|
<div #resultItem ngbDropdownItem class="py-2 d-flex align-items-center focus-ring border-0 cursor-pointer" tabindex="-1"
|
||||||
(click)="primaryAction(type, item, $event)"
|
(click)="primaryAction(type, item, $event)"
|
||||||
(mouseenter)="onItemHover($event)">
|
(mouseenter)="onItemHover($event)">
|
||||||
<i-bs width="1.2em" height="1.2em" name="{{icon}}" class="me-2 text-muted"></i-bs>
|
<pngx-bs-icon width="1.2em" height="1.2em" name="{{icon}}" class="me-2 text-muted"></pngx-bs-icon>
|
||||||
<div class="text-truncate">
|
<div class="text-truncate">
|
||||||
{{item[nameProp]}}
|
{{item[nameProp]}}
|
||||||
@if (date) {
|
@if (date) {
|
||||||
@@ -49,13 +49,13 @@
|
|||||||
[disabled]="disablePrimaryButton(type, item)"
|
[disabled]="disablePrimaryButton(type, item)"
|
||||||
(mouseenter)="onButtonHover($event)">
|
(mouseenter)="onButtonHover($event)">
|
||||||
@if (type === DataType.Document) {
|
@if (type === DataType.Document) {
|
||||||
<i-bs width="1em" height="1em" name="file-earmark-richtext" class="me-1"></i-bs><span><ng-container i18n>Open</ng-container></span>
|
<pngx-bs-icon width="1em" height="1em" name="file-earmark-richtext" class="me-1"></pngx-bs-icon><span><ng-container i18n>Open</ng-container></span>
|
||||||
} @else if (type === DataType.SavedView) {
|
} @else if (type === DataType.SavedView) {
|
||||||
<i-bs width="1em" height="1em" name="eye" class="me-1"></i-bs><span><ng-container i18n>Open</ng-container></span>
|
<pngx-bs-icon width="1em" height="1em" name="eye" class="me-1"></pngx-bs-icon><span><ng-container i18n>Open</ng-container></span>
|
||||||
} @else if (type === DataType.Workflow || type === DataType.CustomField || type === DataType.Group || type === DataType.User || type === DataType.MailAccount || type === DataType.MailRule) {
|
} @else if (type === DataType.Workflow || type === DataType.CustomField || type === DataType.Group || type === DataType.User || type === DataType.MailAccount || type === DataType.MailRule) {
|
||||||
<i-bs width="1em" height="1em" name="pencil" class="me-1"></i-bs><span><ng-container i18n>Open</ng-container></span>
|
<pngx-bs-icon width="1em" height="1em" name="pencil" class="me-1"></pngx-bs-icon><span><ng-container i18n>Open</ng-container></span>
|
||||||
} @else {
|
} @else {
|
||||||
<i-bs width="1em" height="1em" name="filter" class="me-1"></i-bs><span><ng-container i18n>Filter documents</ng-container></span>
|
<pngx-bs-icon width="1em" height="1em" name="filter" class="me-1"></pngx-bs-icon><span><ng-container i18n>Filter documents</ng-container></span>
|
||||||
}
|
}
|
||||||
</button>
|
</button>
|
||||||
@if (type !== DataType.SavedView && type !== DataType.Workflow && type !== DataType.CustomField && type !== DataType.Group && type !== DataType.User && type !== DataType.MailAccount && type !== DataType.MailRule) {
|
@if (type !== DataType.SavedView && type !== DataType.Workflow && type !== DataType.CustomField && type !== DataType.Group && type !== DataType.User && type !== DataType.MailAccount && type !== DataType.MailRule) {
|
||||||
@@ -65,9 +65,9 @@
|
|||||||
[disabled]="disableSecondaryButton(type, item)"
|
[disabled]="disableSecondaryButton(type, item)"
|
||||||
(mouseenter)="onButtonHover($event)">
|
(mouseenter)="onButtonHover($event)">
|
||||||
@if (type === DataType.Document) {
|
@if (type === DataType.Document) {
|
||||||
<i-bs width="1em" height="1em" name="download" class="me-1"></i-bs><span><ng-container i18n>Download</ng-container></span>
|
<pngx-bs-icon width="1em" height="1em" name="download" class="me-1"></pngx-bs-icon><span><ng-container i18n>Download</ng-container></span>
|
||||||
} @else {
|
} @else {
|
||||||
<i-bs width="1em" height="1em" name="file-earmark-richtext" class="me-1"></i-bs><span><ng-container i18n>Open</ng-container></span>
|
<pngx-bs-icon width="1em" height="1em" name="file-earmark-richtext" class="me-1"></pngx-bs-icon><span><ng-container i18n>Open</ng-container></span>
|
||||||
}
|
}
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
form {
|
form {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
|
||||||
> i-bs[name="search"] {
|
> pngx-bs-icon[name="search"] {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 0.6rem;
|
left: 0.6rem;
|
||||||
top: .25rem;
|
top: .25rem;
|
||||||
@@ -14,7 +14,7 @@ form {
|
|||||||
}
|
}
|
||||||
|
|
||||||
&:focus-within {
|
&:focus-within {
|
||||||
i-bs[name="search"],
|
pngx-bs-icon[name="search"],
|
||||||
.badge {
|
.badge {
|
||||||
display: none !important;
|
display: none !important;
|
||||||
}
|
}
|
||||||
@@ -72,7 +72,7 @@ form {
|
|||||||
|
|
||||||
.dropdown.show {
|
.dropdown.show {
|
||||||
// Hide the search icon when the dropdown is shown
|
// Hide the search icon when the dropdown is shown
|
||||||
i-bs[name="search"] {
|
pngx-bs-icon[name="search"] {
|
||||||
display: none !important;
|
display: none !important;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ import {
|
|||||||
NgbModalModule,
|
NgbModalModule,
|
||||||
NgbModalRef,
|
NgbModalRef,
|
||||||
} from '@ng-bootstrap/ng-bootstrap'
|
} from '@ng-bootstrap/ng-bootstrap'
|
||||||
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
|
||||||
import { of } from 'rxjs'
|
import { of } from 'rxjs'
|
||||||
import { DataType } from 'src/app/data/datatype'
|
import { DataType } from 'src/app/data/datatype'
|
||||||
import {
|
import {
|
||||||
@@ -138,7 +137,6 @@ describe('GlobalSearchComponent', () => {
|
|||||||
NgbDropdownModule,
|
NgbDropdownModule,
|
||||||
FormsModule,
|
FormsModule,
|
||||||
ReactiveFormsModule,
|
ReactiveFormsModule,
|
||||||
NgxBootstrapIconsModule.pick(allIcons),
|
|
||||||
GlobalSearchComponent,
|
GlobalSearchComponent,
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
@@ -409,7 +407,7 @@ describe('GlobalSearchComponent', () => {
|
|||||||
|
|
||||||
const editDialog = modal.componentInstance as CustomFieldEditDialogComponent
|
const editDialog = modal.componentInstance as CustomFieldEditDialogComponent
|
||||||
const toastErrorSpy = jest.spyOn(toastService, 'showError')
|
const toastErrorSpy = jest.spyOn(toastService, 'showError')
|
||||||
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
|
const toastInfoSpy = jest.spyOn(toastService, 'showSuccess')
|
||||||
|
|
||||||
// fail first
|
// fail first
|
||||||
editDialog.failed.emit({ error: 'error creating item' })
|
editDialog.failed.emit({ error: 'error creating item' })
|
||||||
@@ -460,7 +458,7 @@ describe('GlobalSearchComponent', () => {
|
|||||||
|
|
||||||
const editDialog = modal.componentInstance as CustomFieldEditDialogComponent
|
const editDialog = modal.componentInstance as CustomFieldEditDialogComponent
|
||||||
const toastErrorSpy = jest.spyOn(toastService, 'showError')
|
const toastErrorSpy = jest.spyOn(toastService, 'showError')
|
||||||
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
|
const toastInfoSpy = jest.spyOn(toastService, 'showSuccess')
|
||||||
|
|
||||||
// fail first
|
// fail first
|
||||||
editDialog.failed.emit({ error: 'error creating item' })
|
editDialog.failed.emit({ error: 'error creating item' })
|
||||||
|
|||||||
@@ -17,8 +17,8 @@ import {
|
|||||||
NgbModal,
|
NgbModal,
|
||||||
NgbModalRef,
|
NgbModalRef,
|
||||||
} from '@ng-bootstrap/ng-bootstrap'
|
} from '@ng-bootstrap/ng-bootstrap'
|
||||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
|
||||||
import { Subject, debounceTime, distinctUntilChanged, filter } from 'rxjs'
|
import { Subject, debounceTime, distinctUntilChanged, filter } from 'rxjs'
|
||||||
|
import { BootstrapIconComponent } from 'src/app/components/common/bootstrap-icon/bootstrap-icon.component'
|
||||||
import { DataType } from 'src/app/data/datatype'
|
import { DataType } from 'src/app/data/datatype'
|
||||||
import {
|
import {
|
||||||
FILTER_FULLTEXT_QUERY,
|
FILTER_FULLTEXT_QUERY,
|
||||||
@@ -65,7 +65,7 @@ import { WorkflowEditDialogComponent } from '../../common/edit-dialog/workflow-e
|
|||||||
CustomDatePipe,
|
CustomDatePipe,
|
||||||
FormsModule,
|
FormsModule,
|
||||||
ReactiveFormsModule,
|
ReactiveFormsModule,
|
||||||
NgxBootstrapIconsModule,
|
BootstrapIconComponent,
|
||||||
NgbDropdownModule,
|
NgbDropdownModule,
|
||||||
NgTemplateOutlet,
|
NgTemplateOutlet,
|
||||||
],
|
],
|
||||||
@@ -216,7 +216,7 @@ export class GlobalSearchComponent implements OnInit {
|
|||||||
modalRef.componentInstance.dialogMode.set(EditDialogMode.EDIT)
|
modalRef.componentInstance.dialogMode.set(EditDialogMode.EDIT)
|
||||||
modalRef.componentInstance.object = object
|
modalRef.componentInstance.object = object
|
||||||
modalRef.componentInstance.succeeded.subscribe(() => {
|
modalRef.componentInstance.succeeded.subscribe(() => {
|
||||||
this.toastService.showInfo($localize`Successfully updated object.`)
|
this.toastService.showSuccess($localize`Successfully updated object.`)
|
||||||
})
|
})
|
||||||
modalRef.componentInstance.failed.subscribe((e) => {
|
modalRef.componentInstance.failed.subscribe((e) => {
|
||||||
this.toastService.showError($localize`Error occurred saving object.`, e)
|
this.toastService.showError($localize`Error occurred saving object.`, e)
|
||||||
@@ -254,7 +254,7 @@ export class GlobalSearchComponent implements OnInit {
|
|||||||
modalRef.componentInstance.dialogMode.set(EditDialogMode.EDIT)
|
modalRef.componentInstance.dialogMode.set(EditDialogMode.EDIT)
|
||||||
modalRef.componentInstance.object = object
|
modalRef.componentInstance.object = object
|
||||||
modalRef.componentInstance.succeeded.subscribe(() => {
|
modalRef.componentInstance.succeeded.subscribe(() => {
|
||||||
this.toastService.showInfo($localize`Successfully updated object.`)
|
this.toastService.showSuccess($localize`Successfully updated object.`)
|
||||||
})
|
})
|
||||||
modalRef.componentInstance.failed.subscribe((e) => {
|
modalRef.componentInstance.failed.subscribe((e) => {
|
||||||
this.toastService.showError($localize`Error occurred saving object.`, e)
|
this.toastService.showError($localize`Error occurred saving object.`, e)
|
||||||
|
|||||||
+18
-15
@@ -1,27 +1,30 @@
|
|||||||
|
|
||||||
<li ngbDropdown class="nav-item position-relative" (openChange)="onOpenChange($event)">
|
<li ngbDropdown class="nav-item position-relative" (openChange)="onOpenChange($event)">
|
||||||
@if (toasts().length) {
|
@if (unread().length) {
|
||||||
<span class="notification-count badge rounded-pill z-3 pe-none bg-secondary position-absolute">{{ toasts().length }}</span>
|
<span class="notification-count badge rounded-pill z-3 pe-none position-absolute" [class.bg-danger]="hasUnreadError()" [class.bg-secondary]="!hasUnreadError()">{{ unread().length }}</span>
|
||||||
}
|
}
|
||||||
<button class="btn navbar-action border-0" id="notificationsDropdown" ngbDropdownToggle aria-label="Notifications" i18n-aria-label>
|
<button class="btn navbar-action border-0" id="notificationsDropdown" ngbDropdownToggle aria-label="Notifications" i18n-aria-label>
|
||||||
<i-bs width="1.3em" height="1.3em" name="bell"></i-bs>
|
<pngx-bs-icon width="1.3em" height="1.3em" name="bell"></pngx-bs-icon>
|
||||||
</button>
|
</button>
|
||||||
<div ngbDropdownMenu class="dropdown-menu-end shadow p-3" aria-labelledby="notificationsDropdown">
|
<div ngbDropdownMenu class="dropdown-menu-end shadow p-2" aria-labelledby="notificationsDropdown">
|
||||||
<div class="btn-toolbar align-items-center" role="toolbar">
|
<div class="d-flex align-items-center px-1 mb-2">
|
||||||
<h6 i18n>Notifications</h6>
|
<h6 class="mb-0" i18n>Notifications</h6>
|
||||||
<div class="btn-group ms-auto">
|
@if (toasts().length) {
|
||||||
<button class="btn btn-sm btn-outline-secondary mb-2 ms-auto"
|
<button class="btn btn-link btn-sm p-0 ms-auto text-body-secondary text-decoration-none clear-all"
|
||||||
(click)="toastService.clearToasts()"
|
(click)="toastService.clearToasts()">
|
||||||
[disabled]="toasts().length === 0"
|
<pngx-bs-icon width="1em" height="1em" name="check2-all" class="me-1"></pngx-bs-icon><ng-container i18n>Clear All</ng-container>
|
||||||
i18n>Clear All</button>
|
</button>
|
||||||
</div>
|
}
|
||||||
</div>
|
</div>
|
||||||
@if (toasts().length === 0) {
|
@if (toasts().length === 0) {
|
||||||
<p class="text-center mb-0 small text-muted"><em i18n>No notifications</em></p>
|
<div class="text-center text-body-secondary small py-4">
|
||||||
|
<pngx-bs-icon width="1.5em" height="1.5em" name="bell" class="d-block mx-auto mb-2 opacity-50"></pngx-bs-icon>
|
||||||
|
<ng-container i18n>No notifications</ng-container>
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
<div class="scroll-list">
|
<div class="scroll-list d-flex flex-column gap-2">
|
||||||
@for (toast of toasts(); track toast.id) {
|
@for (toast of toasts(); track toast.id) {
|
||||||
<pngx-toast [autohide]="false" [toast]="toast" (hidden)="onHidden(toast)" (closed)="toastService.closeToast(toast)"></pngx-toast>
|
<pngx-toast [autohide]="false" [toast]="toast" [now]="now()" [isNew]="newIds().has(toast.id)" (closed)="toastService.closeToast(toast)" (actioned)="onActioned(toast)"></pngx-toast>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -30,3 +30,7 @@
|
|||||||
right: -3rem;
|
right: -3rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.clear-all:hover {
|
||||||
|
color: var(--bs-body-color) !important;
|
||||||
|
}
|
||||||
+71
-6
@@ -1,9 +1,8 @@
|
|||||||
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
|
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
|
||||||
import { provideHttpClientTesting } from '@angular/common/http/testing'
|
import { provideHttpClientTesting } from '@angular/common/http/testing'
|
||||||
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||||
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
|
||||||
import { Subject } from 'rxjs'
|
import { Subject } from 'rxjs'
|
||||||
import { Toast, ToastService } from 'src/app/services/toast.service'
|
import { Toast, ToastLevel, ToastService } from 'src/app/services/toast.service'
|
||||||
import { ToastsDropdownComponent } from './toasts-dropdown.component'
|
import { ToastsDropdownComponent } from './toasts-dropdown.component'
|
||||||
|
|
||||||
const toasts = [
|
const toasts = [
|
||||||
@@ -41,10 +40,7 @@ describe('ToastsDropdownComponent', () => {
|
|||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
TestBed.configureTestingModule({
|
TestBed.configureTestingModule({
|
||||||
imports: [
|
imports: [ToastsDropdownComponent],
|
||||||
ToastsDropdownComponent,
|
|
||||||
NgxBootstrapIconsModule.pick(allIcons),
|
|
||||||
],
|
|
||||||
providers: [
|
providers: [
|
||||||
provideHttpClient(withInterceptorsFromDi()),
|
provideHttpClient(withInterceptorsFromDi()),
|
||||||
provideHttpClientTesting(),
|
provideHttpClientTesting(),
|
||||||
@@ -90,4 +86,73 @@ describe('ToastsDropdownComponent', () => {
|
|||||||
component.onOpenChange(true)
|
component.onOpenChange(true)
|
||||||
expect(spy).toHaveBeenCalledWith(true)
|
expect(spy).toHaveBeenCalledWith(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should badge unread toasts, red when one is an error', () => {
|
||||||
|
toastsSubject.next([
|
||||||
|
{ id: 'a', content: 'Read', delay: 5000, read: true },
|
||||||
|
{ id: 'b', content: 'Unread', delay: 5000, read: false },
|
||||||
|
])
|
||||||
|
fixture.detectChanges()
|
||||||
|
let badge: HTMLElement = fixture.nativeElement.querySelector(
|
||||||
|
'.notification-count'
|
||||||
|
)
|
||||||
|
expect(badge.textContent.trim()).toEqual('1')
|
||||||
|
expect(badge.classList).toContain('bg-secondary')
|
||||||
|
|
||||||
|
toastsSubject.next([
|
||||||
|
{ id: 'c', content: 'Failed', delay: 5000, level: ToastLevel.Error },
|
||||||
|
])
|
||||||
|
fixture.detectChanges()
|
||||||
|
badge = fixture.nativeElement.querySelector('.notification-count')
|
||||||
|
expect(badge.classList).toContain('bg-danger')
|
||||||
|
|
||||||
|
toastsSubject.next([{ id: 'd', content: 'Seen', delay: 5000, read: true }])
|
||||||
|
fixture.detectChanges()
|
||||||
|
expect(
|
||||||
|
fixture.nativeElement.querySelector('.notification-count')
|
||||||
|
).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should mark all toasts read when opened', () => {
|
||||||
|
const markSpy = jest.spyOn(toastService, 'markAllRead')
|
||||||
|
component.onOpenChange(false)
|
||||||
|
expect(markSpy).not.toHaveBeenCalled()
|
||||||
|
component.onOpenChange(true)
|
||||||
|
expect(markSpy).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should tick the clock only while open', () => {
|
||||||
|
jest.useFakeTimers()
|
||||||
|
component.onOpenChange(true)
|
||||||
|
const opened = component.now()
|
||||||
|
jest.advanceTimersByTime(30000)
|
||||||
|
const ticked = component.now()
|
||||||
|
expect(ticked).not.toBe(opened)
|
||||||
|
|
||||||
|
component.onOpenChange(false)
|
||||||
|
jest.advanceTimersByTime(60000)
|
||||||
|
expect(component.now()).toBe(ticked)
|
||||||
|
jest.useRealTimers()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should mark toasts that were unread when opened as new', () => {
|
||||||
|
toastsSubject.next([
|
||||||
|
{ id: 'a', content: 'Seen', delay: 5000, read: true },
|
||||||
|
{ id: 'b', content: 'Unseen', delay: 5000, read: false },
|
||||||
|
])
|
||||||
|
fixture.detectChanges()
|
||||||
|
component.onOpenChange(true)
|
||||||
|
expect([...component.newIds()]).toEqual(['b'])
|
||||||
|
component.onOpenChange(false)
|
||||||
|
expect(component.newIds().size).toEqual(0)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should clear the toast and close the menu when an action is used', () => {
|
||||||
|
const toast = { id: 'a', content: 'Moved', delay: 5000 }
|
||||||
|
const closeToastSpy = jest.spyOn(toastService, 'closeToast')
|
||||||
|
const closeSpy = jest.spyOn(component.dropdown, 'close')
|
||||||
|
component.onActioned(toast)
|
||||||
|
expect(closeToastSpy).toHaveBeenCalledWith(toast)
|
||||||
|
expect(closeSpy).toHaveBeenCalled()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
@@ -1,12 +1,20 @@
|
|||||||
import { Component, inject } from '@angular/core'
|
|
||||||
import { toSignal } from '@angular/core/rxjs-interop'
|
|
||||||
import {
|
import {
|
||||||
|
Component,
|
||||||
|
DestroyRef,
|
||||||
|
ViewChild,
|
||||||
|
computed,
|
||||||
|
inject,
|
||||||
|
signal,
|
||||||
|
} from '@angular/core'
|
||||||
|
import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop'
|
||||||
|
import {
|
||||||
|
NgbDropdown,
|
||||||
NgbDropdownModule,
|
NgbDropdownModule,
|
||||||
NgbProgressbarModule,
|
NgbProgressbarModule,
|
||||||
} from '@ng-bootstrap/ng-bootstrap'
|
} from '@ng-bootstrap/ng-bootstrap'
|
||||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
import { Subscription, interval, map } from 'rxjs'
|
||||||
import { map } from 'rxjs'
|
import { BootstrapIconComponent } from 'src/app/components/common/bootstrap-icon/bootstrap-icon.component'
|
||||||
import { Toast, ToastService } from 'src/app/services/toast.service'
|
import { Toast, ToastLevel, ToastService } from 'src/app/services/toast.service'
|
||||||
import { ToastComponent } from '../../common/toast/toast.component'
|
import { ToastComponent } from '../../common/toast/toast.component'
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
@@ -17,18 +25,49 @@ import { ToastComponent } from '../../common/toast/toast.component'
|
|||||||
ToastComponent,
|
ToastComponent,
|
||||||
NgbDropdownModule,
|
NgbDropdownModule,
|
||||||
NgbProgressbarModule,
|
NgbProgressbarModule,
|
||||||
NgxBootstrapIconsModule,
|
BootstrapIconComponent,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class ToastsDropdownComponent {
|
export class ToastsDropdownComponent {
|
||||||
toastService = inject(ToastService)
|
toastService = inject(ToastService)
|
||||||
|
private destroyRef = inject(DestroyRef)
|
||||||
|
private clock: Subscription
|
||||||
|
|
||||||
|
@ViewChild(NgbDropdown) dropdown: NgbDropdown
|
||||||
|
|
||||||
|
/** Drives the relative timestamps, ticks only while the dropdown is open */
|
||||||
|
readonly now = signal(new Date())
|
||||||
|
|
||||||
|
/** Notifications that were unread when the dropdown was opened */
|
||||||
|
readonly newIds = signal(new Set<string>())
|
||||||
|
|
||||||
readonly toasts = toSignal(
|
readonly toasts = toSignal(
|
||||||
this.toastService.getToasts().pipe(map((toasts) => [...toasts])),
|
this.toastService.getToasts().pipe(map((toasts) => [...toasts])),
|
||||||
{ initialValue: [] as Toast[] }
|
{ initialValue: [] as Toast[] }
|
||||||
)
|
)
|
||||||
|
|
||||||
|
readonly unread = computed(() => this.toasts().filter((t) => !t.read))
|
||||||
|
|
||||||
|
readonly hasUnreadError = computed(() =>
|
||||||
|
this.unread().some((t) => t.level === ToastLevel.Error)
|
||||||
|
)
|
||||||
|
|
||||||
onOpenChange(open: boolean): void {
|
onOpenChange(open: boolean): void {
|
||||||
this.toastService.suppressPopupToasts = open
|
this.toastService.suppressPopupToasts = open
|
||||||
|
this.clock?.unsubscribe()
|
||||||
|
this.newIds.set(new Set(open ? this.unread().map((t) => t.id) : []))
|
||||||
|
if (open) {
|
||||||
|
this.toastService.markAllRead()
|
||||||
|
this.now.set(new Date())
|
||||||
|
this.clock = interval(30000)
|
||||||
|
.pipe(takeUntilDestroyed(this.destroyRef))
|
||||||
|
.subscribe(() => this.now.set(new Date()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Using an action (Undo, Open document...) clears it and closes the menu */
|
||||||
|
onActioned(toast: Toast): void {
|
||||||
|
this.toastService.closeToast(toast)
|
||||||
|
this.dropdown?.close()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,23 +1,52 @@
|
|||||||
|
|
||||||
<li ngbDropdown class="nav-item" (openChange)="onOpenChange($event)">
|
<li ngbDropdown class="nav-item" display="dynamic" placement="bottom-end" [popperOptions]="popperOptions" (openChange)="onOpenChange($event)">
|
||||||
<button class="btn navbar-action border-0" id="chatDropdown" ngbDropdownToggle aria-label="Chat" i18n-aria-label>
|
<button class="btn navbar-action border-0" id="chatDropdown" ngbDropdownToggle aria-label="Chat" i18n-aria-label>
|
||||||
<i-bs width="1.3em" height="1.3em" name="chatSquareDots"></i-bs>
|
<pngx-bs-icon width="1.3em" height="1.3em" name="chatSquareDots"></pngx-bs-icon>
|
||||||
</button>
|
</button>
|
||||||
<div ngbDropdownMenu class="dropdown-menu-end shadow p-3" aria-labelledby="chatDropdown">
|
<div ngbDropdownMenu class="dropdown-menu-end shadow p-0 overflow-hidden" aria-labelledby="chatDropdown">
|
||||||
<div class="chat-container bg-light p-2">
|
<div class="d-flex align-items-center border-bottom px-3 py-2">
|
||||||
<div class="chat-messages font-monospace small">
|
<pngx-bs-icon width="1em" height="1em" name="stars" class="text-primary me-2"></pngx-bs-icon>
|
||||||
@for (message of messages(); track message) {
|
<span class="fw-semibold">
|
||||||
<div class="message d-flex flex-row small" [class.justify-content-end]="message.role === 'user'">
|
@if (documentId()) {
|
||||||
<div class="p-2 m-2" [class.bg-body]="message.role === 'user'">
|
<ng-container i18n>Ask about this document</ng-container>
|
||||||
<span class="text-break">
|
} @else {
|
||||||
{{ message.content }}
|
<ng-container i18n>Ask about your documents</ng-container>
|
||||||
@if (message.isStreaming) { <span class="blinking-cursor">|</span> }
|
}
|
||||||
</span>
|
</span>
|
||||||
@if (message.role === 'assistant' && message.references?.length) {
|
@if (messages().length) {
|
||||||
|
<button class="btn btn-sm btn-link text-muted ms-auto p-0" type="button" (click)="clearChat()" title="Clear chat" i18n-title>
|
||||||
|
<pngx-bs-icon width="1em" height="1em" name="arrow-counterclockwise"></pngx-bs-icon>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="chat-messages px-2">
|
||||||
|
@if (!messages().length) {
|
||||||
|
<p class="text-muted text-center py-4 px-3 mb-0" i18n>Answers come from your documents, with links to their sources.</p>
|
||||||
|
}
|
||||||
|
@for (message of messages(); track message) {
|
||||||
|
<div class="message d-flex flex-row" [class.justify-content-end]="message.role === 'user'" [class.mt-3]="message.role === 'user' && !$first">
|
||||||
|
<div class="p-2 m-2" [class.bg-body-secondary]="message.role === 'user'" [class.rounded-3]="message.role === 'user'" [class.flex-grow-1]="message.role === 'assistant'">
|
||||||
|
@if (message.isStreaming && !message.content) {
|
||||||
|
<div class="d-flex align-items-center text-muted">
|
||||||
|
<div class="spinner-border spinner-border-sm me-2" role="status"></div>
|
||||||
|
@if (slowResponse()) {
|
||||||
|
<ng-container i18n>Still thinking...</ng-container>
|
||||||
|
} @else {
|
||||||
|
<ng-container i18n>Thinking...</ng-container>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
} @else if (message.role === 'assistant') {
|
||||||
|
<div class="chat-markdown text-break text-wrap" [class.streaming]="message.isStreaming" [innerHTML]="message.content | markdown"></div>
|
||||||
|
} @else {
|
||||||
|
<span class="text-break">{{ message.content }}</span>
|
||||||
|
}
|
||||||
|
@if (message.role === 'assistant' && !message.isStreaming && message.references?.length) {
|
||||||
<div class="chat-references list-group mt-3">
|
<div class="chat-references list-group mt-3">
|
||||||
|
<div class="list-group-item text-muted small py-1" i18n>Sources</div>
|
||||||
@for (reference of message.references; track reference.id) {
|
@for (reference of message.references; track reference.id) {
|
||||||
<a class="list-group-item list-group-item-action d-flex text-primary text-break" [routerLink]="['/documents', reference.id]">
|
<a class="list-group-item list-group-item-action d-flex align-items-center text-primary" [routerLink]="['/documents', reference.id]" [title]="reference.title">
|
||||||
<i-bs width="0.9em" height="0.9em" name="file-text" class="me-1"></i-bs><span>{{ reference.title }}</span>
|
<pngx-bs-icon width="0.9em" height="0.9em" name="file-text" class="me-2 flex-shrink-0"></pngx-bs-icon><span class="text-truncate">{{ reference.title }}</span>
|
||||||
</a>
|
</a>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
@@ -28,22 +57,29 @@
|
|||||||
<div #scrollAnchor></div>
|
<div #scrollAnchor></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form class="chat-input">
|
<form class="chat-input border-top p-2">
|
||||||
<label class="visually-hidden" for="chatInput" i18n>Message</label>
|
<label class="visually-hidden" for="chatInput" i18n>Message</label>
|
||||||
<div class="input-group">
|
<div class="input-group">
|
||||||
<input
|
<input
|
||||||
#chatInput
|
#chatInput
|
||||||
id="chatInput"
|
id="chatInput"
|
||||||
class="form-control form-control-sm" name="chatInput" type="text"
|
class="form-control" name="chatInput" type="text"
|
||||||
[placeholder]="placeholder"
|
[placeholder]="placeholder"
|
||||||
[disabled]="loading()"
|
[disabled]="loading()"
|
||||||
[ngModel]="input()"
|
[ngModel]="input()"
|
||||||
(ngModelChange)="input.set($event)"
|
(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>
|
@if (loading()) {
|
||||||
|
<button class="btn btn-outline-secondary" type="button" (click)="stopStreaming()" title="Stop" i18n-title aria-label="Stop" i18n-aria-label>
|
||||||
|
<pngx-bs-icon width="1em" height="1em" name="x-lg"></pngx-bs-icon>
|
||||||
|
</button>
|
||||||
|
} @else {
|
||||||
|
<button class="btn btn-primary" type="button" (click)="sendMessage()" title="Send" i18n-title aria-label="Send" i18n-aria-label>
|
||||||
|
<pngx-bs-icon width="1em" height="1em" name="arrow-return-left"></pngx-bs-icon>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</li>
|
</li>
|
||||||
@@ -1,17 +1,13 @@
|
|||||||
.dropdown-menu {
|
.dropdown-menu {
|
||||||
width: var(--pngx-toast-max-width);
|
width: min(36rem, calc(100vw - 20px));
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-messages {
|
.chat-messages {
|
||||||
max-height: 350px;
|
max-height: min(60dvh, 36rem);
|
||||||
overflow-y: auto;
|
overflow-y: auto;
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-references {
|
|
||||||
font-family: var(--bs-font-sans-serif);
|
|
||||||
}
|
|
||||||
|
|
||||||
.dropdown-toggle::after {
|
.dropdown-toggle::after {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
@@ -20,23 +16,44 @@
|
|||||||
white-space: initial;
|
white-space: initial;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media screen and (max-width: 400px) {
|
@media screen and (max-width: 575.98px) {
|
||||||
:host ::ng-deep .dropdown-menu-end {
|
.chat-messages {
|
||||||
right: -3rem;
|
max-height: calc(100dvh - 12rem);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.blinking-cursor {
|
.chat-markdown.streaming ::ng-deep {
|
||||||
font-weight: bold;
|
> :last-child:not(ul, ol, table)::after,
|
||||||
font-size: 1.2em;
|
> :is(ul, ol):last-child > li:last-child::after {
|
||||||
|
content: '';
|
||||||
|
display: inline-block;
|
||||||
|
width: 2px;
|
||||||
|
height: 1.05em;
|
||||||
|
margin-left: 2px;
|
||||||
|
background-color: currentColor;
|
||||||
|
vertical-align: -0.15em;
|
||||||
animation: blink 1s step-end infinite;
|
animation: blink 1s step-end infinite;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes blink {
|
@keyframes blink {
|
||||||
from, to {
|
50% {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
50% {
|
}
|
||||||
opacity: 1;
|
|
||||||
|
.chat-markdown ::ng-deep {
|
||||||
|
h1, h2, h3, h4, h5, h6 {
|
||||||
|
font-size: 1em;
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
|
||||||
|
th, td {
|
||||||
|
padding: 0.15rem 0.4rem;
|
||||||
|
border: 1px solid var(--bs-border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
> :last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -4,7 +4,6 @@ import { ElementRef } from '@angular/core'
|
|||||||
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||||
import { NavigationEnd, Router } from '@angular/router'
|
import { NavigationEnd, Router } from '@angular/router'
|
||||||
import { RouterTestingModule } from '@angular/router/testing'
|
import { RouterTestingModule } from '@angular/router/testing'
|
||||||
import { allIcons, NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
|
||||||
import { Subject } from 'rxjs'
|
import { Subject } from 'rxjs'
|
||||||
import {
|
import {
|
||||||
CHAT_METADATA_DELIMITER,
|
CHAT_METADATA_DELIMITER,
|
||||||
@@ -22,11 +21,7 @@ describe('ChatComponent', () => {
|
|||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
TestBed.configureTestingModule({
|
TestBed.configureTestingModule({
|
||||||
imports: [
|
imports: [RouterTestingModule, ChatComponent],
|
||||||
NgxBootstrapIconsModule.pick(allIcons),
|
|
||||||
RouterTestingModule,
|
|
||||||
ChatComponent,
|
|
||||||
],
|
|
||||||
providers: [
|
providers: [
|
||||||
provideHttpClient(withInterceptorsFromDi()),
|
provideHttpClient(withInterceptorsFromDi()),
|
||||||
provideHttpClientTesting(),
|
provideHttpClientTesting(),
|
||||||
@@ -80,8 +75,16 @@ describe('ChatComponent', () => {
|
|||||||
expect(component.messages()[0].content).toBe('Hello')
|
expect(component.messages()[0].content).toBe('Hello')
|
||||||
expect(component.loading()).toBe(true)
|
expect(component.loading()).toBe(true)
|
||||||
|
|
||||||
|
fixture.detectChanges()
|
||||||
|
const messagesEl = fixture.nativeElement.querySelector('.chat-messages')
|
||||||
|
expect(messagesEl.querySelector('.spinner-border')).not.toBeNull()
|
||||||
|
expect(messagesEl.textContent).toContain('Thinking...')
|
||||||
|
|
||||||
mockStream$.next('Hi')
|
mockStream$.next('Hi')
|
||||||
expect(component.messages()[1].content).toBe('H')
|
expect(component.messages()[1].content).toBe('H')
|
||||||
|
fixture.detectChanges()
|
||||||
|
expect(messagesEl.querySelector('.spinner-border')).toBeNull()
|
||||||
|
expect(messagesEl.querySelector('.chat-markdown.streaming')).not.toBeNull()
|
||||||
mockStream$.next('Hi there')
|
mockStream$.next('Hi there')
|
||||||
// advance time to process the typewriter effect
|
// advance time to process the typewriter effect
|
||||||
await jest.runAllTimersAsync()
|
await jest.runAllTimersAsync()
|
||||||
@@ -96,6 +99,8 @@ describe('ChatComponent', () => {
|
|||||||
await fixture.whenStable()
|
await fixture.whenStable()
|
||||||
expect(component.loading()).toBe(false)
|
expect(component.loading()).toBe(false)
|
||||||
expect(component.messages()[1].isStreaming).toBe(false)
|
expect(component.messages()[1].isStreaming).toBe(false)
|
||||||
|
fixture.detectChanges()
|
||||||
|
expect(messagesEl.querySelector('.chat-markdown.streaming')).toBeNull()
|
||||||
expect(fixture.nativeElement.querySelector('#chatInput').disabled).toBe(
|
expect(fixture.nativeElement.querySelector('#chatInput').disabled).toBe(
|
||||||
false
|
false
|
||||||
)
|
)
|
||||||
@@ -123,6 +128,10 @@ describe('ChatComponent', () => {
|
|||||||
mockStream$.next(
|
mockStream$.next(
|
||||||
`Hi there${CHAT_METADATA_DELIMITER}{"references":[{"id":42,"title":"Bread Recipe"}]}`
|
`Hi there${CHAT_METADATA_DELIMITER}{"references":[{"id":42,"title":"Bread Recipe"}]}`
|
||||||
)
|
)
|
||||||
|
fixture.detectChanges()
|
||||||
|
expect(fixture.nativeElement.querySelector('.chat-references')).toBeNull()
|
||||||
|
|
||||||
|
mockStream$.complete()
|
||||||
jest.advanceTimersByTime(1000)
|
jest.advanceTimersByTime(1000)
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
|
|
||||||
@@ -156,13 +165,96 @@ describe('ChatComponent', () => {
|
|||||||
component.input.set('Hello')
|
component.input.set('Hello')
|
||||||
component.sendMessage()
|
component.sendMessage()
|
||||||
|
|
||||||
|
mockStream$.next('Partial')
|
||||||
mockStream$.error('Error')
|
mockStream$.error('Error')
|
||||||
expect(component.messages()[1].content).toContain(
|
expect(component.messages()[1].content).toBe(
|
||||||
'⚠️ Error receiving response.'
|
'Partial\n\n⚠️ Error receiving response.'
|
||||||
)
|
)
|
||||||
expect(component.loading()).toBe(false)
|
expect(component.loading()).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should keep streaming until the typewriter catches up', () => {
|
||||||
|
component.input.set('Hello')
|
||||||
|
component.sendMessage()
|
||||||
|
|
||||||
|
mockStream$.next('Hi there')
|
||||||
|
mockStream$.complete()
|
||||||
|
expect(component.messages()[1].isStreaming).toBe(true)
|
||||||
|
expect(component.loading()).toBe(true)
|
||||||
|
|
||||||
|
jest.advanceTimersByTime(1000)
|
||||||
|
expect(component.messages()[1].content).toBe('Hi there')
|
||||||
|
expect(component.messages()[1].isStreaming).toBe(false)
|
||||||
|
expect(component.loading()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should show a slow response message while waiting', () => {
|
||||||
|
component.input.set('Hello')
|
||||||
|
component.sendMessage()
|
||||||
|
fixture.detectChanges()
|
||||||
|
const messagesEl = fixture.nativeElement.querySelector('.chat-messages')
|
||||||
|
expect(messagesEl.textContent).not.toContain('Still thinking...')
|
||||||
|
|
||||||
|
jest.advanceTimersByTime(8000)
|
||||||
|
fixture.detectChanges()
|
||||||
|
expect(messagesEl.textContent).toContain('Still thinking...')
|
||||||
|
|
||||||
|
mockStream$.next('Hi')
|
||||||
|
expect(component.slowResponse()).toBe(false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should stop streaming and keep the partial response', () => {
|
||||||
|
component.input.set('Hello')
|
||||||
|
component.sendMessage()
|
||||||
|
fixture.detectChanges()
|
||||||
|
const stopButton = fixture.nativeElement.querySelector('.chat-input button')
|
||||||
|
expect(stopButton.getAttribute('aria-label')).toBe('Stop')
|
||||||
|
|
||||||
|
mockStream$.next('Hi there')
|
||||||
|
jest.advanceTimersByTime(20)
|
||||||
|
stopButton.click()
|
||||||
|
|
||||||
|
expect(mockStream$.observed).toBe(false)
|
||||||
|
expect(component.messages()[1].content).toBe('Hi there')
|
||||||
|
expect(component.messages()[1].isStreaming).toBe(false)
|
||||||
|
expect(component.loading()).toBe(false)
|
||||||
|
|
||||||
|
jest.advanceTimersByTime(1000)
|
||||||
|
expect(component.messages()[1].content).toBe('Hi there')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should drop the empty response when stopped before any content', () => {
|
||||||
|
component.input.set('Hello')
|
||||||
|
component.sendMessage()
|
||||||
|
component.stopStreaming()
|
||||||
|
|
||||||
|
expect(component.messages()).toHaveLength(1)
|
||||||
|
expect(component.loading()).toBe(false)
|
||||||
|
component.stopStreaming() // no-op once stopped
|
||||||
|
expect(component.messages()).toHaveLength(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should clear the conversation', () => {
|
||||||
|
component.input.set('Hello')
|
||||||
|
component.sendMessage()
|
||||||
|
mockStream$.next('Hi there')
|
||||||
|
fixture.detectChanges()
|
||||||
|
|
||||||
|
fixture.nativeElement.querySelector('button[title="Clear chat"]').click()
|
||||||
|
expect(component.messages()).toHaveLength(0)
|
||||||
|
expect(component.loading()).toBe(false)
|
||||||
|
expect(mockStream$.observed).toBe(false)
|
||||||
|
|
||||||
|
fixture.detectChanges()
|
||||||
|
jest.advanceTimersByTime(10)
|
||||||
|
expect(document.activeElement).toBe(
|
||||||
|
fixture.nativeElement.querySelector('#chatInput')
|
||||||
|
)
|
||||||
|
expect(fixture.nativeElement.textContent).toContain(
|
||||||
|
'Answers come from your documents'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
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
|
||||||
@@ -207,4 +299,33 @@ describe('ChatComponent', () => {
|
|||||||
component.searchInputKeyDown(event)
|
component.searchInputKeyDown(event)
|
||||||
expect(component.sendMessage).not.toHaveBeenCalled()
|
expect(component.sendMessage).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should render markdown for assistant messages only', () => {
|
||||||
|
component.messages.set([
|
||||||
|
{ role: 'user', content: '**user**' },
|
||||||
|
{ role: 'assistant', content: '**assistant** <script>x</script>' },
|
||||||
|
])
|
||||||
|
fixture.detectChanges()
|
||||||
|
const el: HTMLElement = fixture.nativeElement
|
||||||
|
expect(el.querySelector('.chat-markdown strong')?.textContent).toBe(
|
||||||
|
'assistant'
|
||||||
|
)
|
||||||
|
expect(el.querySelector('.chat-markdown script')).toBeNull()
|
||||||
|
expect(el.textContent).toContain('**user**')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should keep the destination hostname visible after sanitizing chat links', () => {
|
||||||
|
component.messages.set([
|
||||||
|
{
|
||||||
|
role: 'assistant',
|
||||||
|
content:
|
||||||
|
'[View original document](https://otherdomain.example/?data=secret)',
|
||||||
|
},
|
||||||
|
])
|
||||||
|
fixture.detectChanges()
|
||||||
|
const el: HTMLElement = fixture.nativeElement
|
||||||
|
const link = el.querySelector('.chat-markdown a')
|
||||||
|
expect(link?.textContent).toBe('View original document')
|
||||||
|
expect(link?.nextElementSibling?.textContent).toBe('(otherdomain.example)')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
@@ -9,13 +9,17 @@ import {
|
|||||||
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'
|
||||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
import { filter, map, Subscription } from 'rxjs'
|
||||||
import { filter, map } from 'rxjs'
|
import { BootstrapIconComponent } from 'src/app/components/common/bootstrap-icon/bootstrap-icon.component'
|
||||||
|
import { MarkdownPipe } from 'src/app/pipes/markdown.pipe'
|
||||||
import {
|
import {
|
||||||
ChatMessage,
|
ChatMessage,
|
||||||
ChatService,
|
ChatService,
|
||||||
parseChatResponse,
|
parseChatResponse,
|
||||||
} from 'src/app/services/chat.service'
|
} from 'src/app/services/chat.service'
|
||||||
|
import { pngxPopperOptions } from 'src/app/utils/popper-options'
|
||||||
|
|
||||||
|
const SLOW_RESPONSE_DELAY_MS = 8000
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'pngx-chat',
|
selector: 'pngx-chat',
|
||||||
@@ -23,8 +27,9 @@ import {
|
|||||||
FormsModule,
|
FormsModule,
|
||||||
ReactiveFormsModule,
|
ReactiveFormsModule,
|
||||||
RouterModule,
|
RouterModule,
|
||||||
NgxBootstrapIconsModule,
|
BootstrapIconComponent,
|
||||||
NgbDropdownModule,
|
NgbDropdownModule,
|
||||||
|
MarkdownPipe,
|
||||||
],
|
],
|
||||||
templateUrl: './chat.component.html',
|
templateUrl: './chat.component.html',
|
||||||
styleUrl: './chat.component.scss',
|
styleUrl: './chat.component.scss',
|
||||||
@@ -34,6 +39,8 @@ export class ChatComponent implements OnInit {
|
|||||||
readonly loading = signal(false)
|
readonly loading = signal(false)
|
||||||
readonly input = signal('')
|
readonly input = signal('')
|
||||||
readonly documentId = signal<number>(undefined)
|
readonly documentId = signal<number>(undefined)
|
||||||
|
readonly slowResponse = signal(false)
|
||||||
|
public popperOptions = pngxPopperOptions
|
||||||
|
|
||||||
private chatService: ChatService = inject(ChatService)
|
private chatService: ChatService = inject(ChatService)
|
||||||
private router: Router = inject(Router)
|
private router: Router = inject(Router)
|
||||||
@@ -43,6 +50,11 @@ export class ChatComponent implements OnInit {
|
|||||||
|
|
||||||
private typewriterBuffer: string[] = []
|
private typewriterBuffer: string[] = []
|
||||||
private typewriterActive = false
|
private typewriterActive = false
|
||||||
|
private typewriterTimeout: ReturnType<typeof setTimeout>
|
||||||
|
private streamSubscription: Subscription
|
||||||
|
private streamingMessage: ChatMessage
|
||||||
|
private streamFinished = false
|
||||||
|
private slowResponseTimeout: ReturnType<typeof setTimeout>
|
||||||
|
|
||||||
public get placeholder(): string {
|
public get placeholder(): string {
|
||||||
return this.documentId()
|
return this.documentId()
|
||||||
@@ -81,11 +93,20 @@ export class ChatComponent implements OnInit {
|
|||||||
}
|
}
|
||||||
this.messages.update((messages) => [...messages, assistantMessage])
|
this.messages.update((messages) => [...messages, assistantMessage])
|
||||||
this.loading.set(true)
|
this.loading.set(true)
|
||||||
|
this.streamingMessage = assistantMessage
|
||||||
|
this.streamFinished = false
|
||||||
|
this.slowResponseTimeout = setTimeout(
|
||||||
|
() => this.slowResponse.set(true),
|
||||||
|
SLOW_RESPONSE_DELAY_MS
|
||||||
|
)
|
||||||
|
|
||||||
let lastVisibleContent = ''
|
let lastVisibleContent = ''
|
||||||
|
|
||||||
this.chatService.streamChat(this.documentId(), this.input()).subscribe({
|
this.streamSubscription = this.chatService
|
||||||
|
.streamChat(this.documentId(), this.input())
|
||||||
|
.subscribe({
|
||||||
next: (chunk) => {
|
next: (chunk) => {
|
||||||
|
this.clearSlowResponse()
|
||||||
const nextResponse = parseChatResponse(chunk)
|
const nextResponse = parseChatResponse(chunk)
|
||||||
|
|
||||||
if (nextResponse.content.length < lastVisibleContent.length) {
|
if (nextResponse.content.length < lastVisibleContent.length) {
|
||||||
@@ -103,25 +124,65 @@ export class ChatComponent implements OnInit {
|
|||||||
this.notifyMessagesChanged()
|
this.notifyMessagesChanged()
|
||||||
},
|
},
|
||||||
error: () => {
|
error: () => {
|
||||||
|
this.flushTypewriter(assistantMessage)
|
||||||
assistantMessage.content += '\n\n⚠️ Error receiving response.'
|
assistantMessage.content += '\n\n⚠️ Error receiving response.'
|
||||||
assistantMessage.isStreaming = false
|
this.finishStreaming(assistantMessage)
|
||||||
this.notifyMessagesChanged()
|
|
||||||
this.loading.set(false)
|
|
||||||
},
|
},
|
||||||
complete: () => {
|
complete: () => {
|
||||||
assistantMessage.isStreaming = false
|
// let the typewriter catch up before hiding the cursor
|
||||||
this.notifyMessagesChanged()
|
this.streamFinished = true
|
||||||
this.loading.set(false)
|
if (!this.typewriterActive) this.finishStreaming(assistantMessage)
|
||||||
this.scrollToBottom()
|
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
this.input.set('')
|
this.input.set('')
|
||||||
}
|
}
|
||||||
|
|
||||||
private resetTypewriter(message: ChatMessage, content: string): void {
|
stopStreaming(): void {
|
||||||
|
const message = this.streamingMessage
|
||||||
|
if (!message?.isStreaming) return
|
||||||
|
|
||||||
|
this.streamSubscription?.unsubscribe()
|
||||||
|
this.flushTypewriter(message)
|
||||||
|
if (!message.content) {
|
||||||
|
this.messages.update((messages) => messages.filter((m) => m !== message))
|
||||||
|
}
|
||||||
|
this.finishStreaming(message)
|
||||||
|
}
|
||||||
|
|
||||||
|
clearChat(): void {
|
||||||
|
this.stopStreaming()
|
||||||
|
this.messages.set([])
|
||||||
|
setTimeout(() => this.chatInput?.nativeElement.focus())
|
||||||
|
}
|
||||||
|
|
||||||
|
private finishStreaming(message: ChatMessage): void {
|
||||||
|
this.clearSlowResponse()
|
||||||
|
this.streamFinished = false
|
||||||
|
message.isStreaming = false
|
||||||
|
this.notifyMessagesChanged()
|
||||||
|
this.loading.set(false)
|
||||||
|
this.scrollToBottom()
|
||||||
|
}
|
||||||
|
|
||||||
|
private clearSlowResponse(): void {
|
||||||
|
clearTimeout(this.slowResponseTimeout)
|
||||||
|
this.slowResponse.set(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
private flushTypewriter(message: ChatMessage): void {
|
||||||
|
message.content += this.typewriterBuffer.join('')
|
||||||
|
this.cancelTypewriter()
|
||||||
|
}
|
||||||
|
|
||||||
|
private cancelTypewriter(): void {
|
||||||
|
clearTimeout(this.typewriterTimeout)
|
||||||
this.typewriterBuffer = []
|
this.typewriterBuffer = []
|
||||||
this.typewriterActive = false
|
this.typewriterActive = false
|
||||||
|
}
|
||||||
|
|
||||||
|
private resetTypewriter(message: ChatMessage, content: string): void {
|
||||||
|
this.cancelTypewriter()
|
||||||
message.content = content
|
message.content = content
|
||||||
this.notifyMessagesChanged()
|
this.notifyMessagesChanged()
|
||||||
this.scrollToBottom()
|
this.scrollToBottom()
|
||||||
@@ -141,6 +202,7 @@ export class ChatComponent implements OnInit {
|
|||||||
playTypewriter(message: ChatMessage): void {
|
playTypewriter(message: ChatMessage): void {
|
||||||
if (this.typewriterBuffer.length === 0) {
|
if (this.typewriterBuffer.length === 0) {
|
||||||
this.typewriterActive = false
|
this.typewriterActive = false
|
||||||
|
if (this.streamFinished) this.finishStreaming(message)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -149,7 +211,8 @@ export class ChatComponent implements OnInit {
|
|||||||
this.notifyMessagesChanged()
|
this.notifyMessagesChanged()
|
||||||
this.scrollToBottom()
|
this.scrollToBottom()
|
||||||
|
|
||||||
setTimeout(() => this.playTypewriter(message), 10) // 10ms per character
|
// 10ms per character
|
||||||
|
this.typewriterTimeout = setTimeout(() => this.playTypewriter(message), 10)
|
||||||
}
|
}
|
||||||
|
|
||||||
private notifyMessagesChanged(): void {
|
private notifyMessagesChanged(): void {
|
||||||
|
|||||||
@@ -0,0 +1,61 @@
|
|||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||||
|
import { BootstrapIconComponent } from './bootstrap-icon.component'
|
||||||
|
|
||||||
|
describe('BootstrapIconComponent', () => {
|
||||||
|
let fixture: ComponentFixture<BootstrapIconComponent>
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
imports: [BootstrapIconComponent],
|
||||||
|
})
|
||||||
|
fixture = TestBed.createComponent(BootstrapIconComponent)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should render the named icon as inline svg', () => {
|
||||||
|
fixture.componentRef.setInput('name', 'arrow-down-up')
|
||||||
|
fixture.detectChanges()
|
||||||
|
const svg: SVGElement = fixture.nativeElement.querySelector('svg')
|
||||||
|
expect(svg.classList).toContain('bi-arrow-down-up')
|
||||||
|
expect(svg.getAttribute('width')).toEqual('16')
|
||||||
|
expect(svg.getAttribute('height')).toEqual('16')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should apply width and height', () => {
|
||||||
|
fixture.componentRef.setInput('name', 'x-lg')
|
||||||
|
fixture.componentRef.setInput('width', '1.2em')
|
||||||
|
fixture.componentRef.setInput('height', 2)
|
||||||
|
fixture.detectChanges()
|
||||||
|
const svg: SVGElement = fixture.nativeElement.querySelector('svg')
|
||||||
|
expect(svg.getAttribute('width')).toEqual('1.2em')
|
||||||
|
expect(svg.getAttribute('height')).toEqual('2')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not allow width or height to inject attributes', () => {
|
||||||
|
fixture.componentRef.setInput('name', 'x-lg')
|
||||||
|
fixture.componentRef.setInput('width', '1" onload="alert(1)')
|
||||||
|
fixture.componentRef.setInput('height', '1" onclick="alert(1)')
|
||||||
|
fixture.detectChanges()
|
||||||
|
const svg: SVGElement = fixture.nativeElement.querySelector('svg')
|
||||||
|
expect(svg.hasAttribute('onload')).toBe(false)
|
||||||
|
expect(svg.hasAttribute('onclick')).toBe(false)
|
||||||
|
expect(svg.getAttribute('width')).toEqual('1" onload="alert(1)')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should update when the name changes', () => {
|
||||||
|
fixture.componentRef.setInput('name', 'x-lg')
|
||||||
|
fixture.detectChanges()
|
||||||
|
fixture.componentRef.setInput('name', 'sliders2-vertical')
|
||||||
|
fixture.detectChanges()
|
||||||
|
expect(fixture.nativeElement.querySelector('svg').classList).toContain(
|
||||||
|
'bi-sliders2-vertical'
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should render nothing and warn for an unregistered icon', () => {
|
||||||
|
const warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {})
|
||||||
|
fixture.componentRef.setInput('name', 'not-an-icon')
|
||||||
|
fixture.detectChanges()
|
||||||
|
expect(fixture.nativeElement.innerHTML).toEqual('')
|
||||||
|
expect(warnSpy).toHaveBeenCalledWith('Icon not found: not-an-icon')
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import {
|
||||||
|
ChangeDetectionStrategy,
|
||||||
|
Component,
|
||||||
|
effect,
|
||||||
|
ElementRef,
|
||||||
|
inject,
|
||||||
|
input,
|
||||||
|
} from '@angular/core'
|
||||||
|
import { icons } from './icons'
|
||||||
|
|
||||||
|
/** Converts icon name e.g. `arrow-down-up` to its registry key. */
|
||||||
|
const toKey = (name: string) =>
|
||||||
|
name.trim().replace(/-(.)/g, (_, char: string) => char.toUpperCase())
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders an icon from the official bootstrap-icons package as inline SVG.
|
||||||
|
* Icons must be registered in `./icons.ts`.
|
||||||
|
*/
|
||||||
|
@Component({
|
||||||
|
selector: 'pngx-bs-icon',
|
||||||
|
template: '',
|
||||||
|
changeDetection: ChangeDetectionStrategy.OnPush,
|
||||||
|
})
|
||||||
|
export class BootstrapIconComponent {
|
||||||
|
private readonly elementRef = inject(ElementRef<HTMLElement>)
|
||||||
|
|
||||||
|
readonly name = input<string>()
|
||||||
|
readonly width = input<string | number>()
|
||||||
|
readonly height = input<string | number>()
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
effect(() => {
|
||||||
|
const element = this.elementRef.nativeElement
|
||||||
|
element.innerHTML = this.svg()
|
||||||
|
const svg = element.firstElementChild
|
||||||
|
if (!svg) return
|
||||||
|
const width = this.width()
|
||||||
|
const height = this.height()
|
||||||
|
if (width) svg.setAttribute('width', String(width))
|
||||||
|
if (height) svg.setAttribute('height', String(height))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private svg(): string {
|
||||||
|
const name = this.name()
|
||||||
|
if (!name) return ''
|
||||||
|
const svg = icons[toKey(name)]
|
||||||
|
if (!svg) {
|
||||||
|
console.warn(`Icon not found: ${name}`)
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
return svg
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,317 @@
|
|||||||
|
import airplane from 'bootstrap-icons/icons/airplane.svg'
|
||||||
|
import archive from 'bootstrap-icons/icons/archive.svg'
|
||||||
|
import arrowClockwise from 'bootstrap-icons/icons/arrow-clockwise.svg'
|
||||||
|
import arrowCounterclockwise from 'bootstrap-icons/icons/arrow-counterclockwise.svg'
|
||||||
|
import arrowDownUp from 'bootstrap-icons/icons/arrow-down-up.svg'
|
||||||
|
import arrowDown from 'bootstrap-icons/icons/arrow-down.svg'
|
||||||
|
import arrowLeft from 'bootstrap-icons/icons/arrow-left.svg'
|
||||||
|
import arrowRepeat from 'bootstrap-icons/icons/arrow-repeat.svg'
|
||||||
|
import arrowReturnLeft from 'bootstrap-icons/icons/arrow-return-left.svg'
|
||||||
|
import arrowRightShort from 'bootstrap-icons/icons/arrow-right-short.svg'
|
||||||
|
import arrowRight from 'bootstrap-icons/icons/arrow-right.svg'
|
||||||
|
import arrowUpRight from 'bootstrap-icons/icons/arrow-up-right.svg'
|
||||||
|
import asterisk from 'bootstrap-icons/icons/asterisk.svg'
|
||||||
|
import bank from 'bootstrap-icons/icons/bank.svg'
|
||||||
|
import basket from 'bootstrap-icons/icons/basket.svg'
|
||||||
|
import bell from 'bootstrap-icons/icons/bell.svg'
|
||||||
|
import bodyText from 'bootstrap-icons/icons/body-text.svg'
|
||||||
|
import bookmark from 'bootstrap-icons/icons/bookmark.svg'
|
||||||
|
import boxArrowInRight from 'bootstrap-icons/icons/box-arrow-in-right.svg'
|
||||||
|
import boxArrowUpRight from 'bootstrap-icons/icons/box-arrow-up-right.svg'
|
||||||
|
import boxArrowUp from 'bootstrap-icons/icons/box-arrow-up.svg'
|
||||||
|
import boxes from 'bootstrap-icons/icons/boxes.svg'
|
||||||
|
import braces from 'bootstrap-icons/icons/braces.svg'
|
||||||
|
import briefcase from 'bootstrap-icons/icons/briefcase.svg'
|
||||||
|
import building from 'bootstrap-icons/icons/building.svg'
|
||||||
|
import calculator from 'bootstrap-icons/icons/calculator.svg'
|
||||||
|
import calendarEventFill from 'bootstrap-icons/icons/calendar-event-fill.svg'
|
||||||
|
import calendarEvent from 'bootstrap-icons/icons/calendar-event.svg'
|
||||||
|
import calendar from 'bootstrap-icons/icons/calendar.svg'
|
||||||
|
import camera from 'bootstrap-icons/icons/camera.svg'
|
||||||
|
import cardChecklist from 'bootstrap-icons/icons/card-checklist.svg'
|
||||||
|
import cardHeading from 'bootstrap-icons/icons/card-heading.svg'
|
||||||
|
import caretDown from 'bootstrap-icons/icons/caret-down.svg'
|
||||||
|
import caretUp from 'bootstrap-icons/icons/caret-up.svg'
|
||||||
|
import cash from 'bootstrap-icons/icons/cash.svg'
|
||||||
|
import chatLeftText from 'bootstrap-icons/icons/chat-left-text.svg'
|
||||||
|
import chatSquareDots from 'bootstrap-icons/icons/chat-square-dots.svg'
|
||||||
|
import checkAll from 'bootstrap-icons/icons/check-all.svg'
|
||||||
|
import checkCircleFill from 'bootstrap-icons/icons/check-circle-fill.svg'
|
||||||
|
import checkCircle from 'bootstrap-icons/icons/check-circle.svg'
|
||||||
|
import checkLg from 'bootstrap-icons/icons/check-lg.svg'
|
||||||
|
import check from 'bootstrap-icons/icons/check.svg'
|
||||||
|
import check2All from 'bootstrap-icons/icons/check2-all.svg'
|
||||||
|
import chevronDoubleLeft from 'bootstrap-icons/icons/chevron-double-left.svg'
|
||||||
|
import chevronDoubleRight from 'bootstrap-icons/icons/chevron-double-right.svg'
|
||||||
|
import chevronDown from 'bootstrap-icons/icons/chevron-down.svg'
|
||||||
|
import chevronRight from 'bootstrap-icons/icons/chevron-right.svg'
|
||||||
|
import chevronUp from 'bootstrap-icons/icons/chevron-up.svg'
|
||||||
|
import circle from 'bootstrap-icons/icons/circle.svg'
|
||||||
|
import clipboardCheckFill from 'bootstrap-icons/icons/clipboard-check-fill.svg'
|
||||||
|
import clipboardCheck from 'bootstrap-icons/icons/clipboard-check.svg'
|
||||||
|
import clipboardFill from 'bootstrap-icons/icons/clipboard-fill.svg'
|
||||||
|
import clipboard from 'bootstrap-icons/icons/clipboard.svg'
|
||||||
|
import clockHistory from 'bootstrap-icons/icons/clock-history.svg'
|
||||||
|
import cpu from 'bootstrap-icons/icons/cpu.svg'
|
||||||
|
import creditCard from 'bootstrap-icons/icons/credit-card.svg'
|
||||||
|
import dashCircle from 'bootstrap-icons/icons/dash-circle.svg'
|
||||||
|
import dash from 'bootstrap-icons/icons/dash.svg'
|
||||||
|
import diagram3 from 'bootstrap-icons/icons/diagram-3.svg'
|
||||||
|
import dice5 from 'bootstrap-icons/icons/dice-5.svg'
|
||||||
|
import doorOpen from 'bootstrap-icons/icons/door-open.svg'
|
||||||
|
import download from 'bootstrap-icons/icons/download.svg'
|
||||||
|
import envelopeAtFill from 'bootstrap-icons/icons/envelope-at-fill.svg'
|
||||||
|
import envelopeAt from 'bootstrap-icons/icons/envelope-at.svg'
|
||||||
|
import envelope from 'bootstrap-icons/icons/envelope.svg'
|
||||||
|
import exclamationCircleFill from 'bootstrap-icons/icons/exclamation-circle-fill.svg'
|
||||||
|
import exclamationTriangleFill from 'bootstrap-icons/icons/exclamation-triangle-fill.svg'
|
||||||
|
import exclamationTriangle from 'bootstrap-icons/icons/exclamation-triangle.svg'
|
||||||
|
import eye from 'bootstrap-icons/icons/eye.svg'
|
||||||
|
import fileEarmarkArrowUp from 'bootstrap-icons/icons/file-earmark-arrow-up.svg'
|
||||||
|
import fileEarmarkCheck from 'bootstrap-icons/icons/file-earmark-check.svg'
|
||||||
|
import fileEarmarkDiff from 'bootstrap-icons/icons/file-earmark-diff.svg'
|
||||||
|
import fileEarmarkFill from 'bootstrap-icons/icons/file-earmark-fill.svg'
|
||||||
|
import fileEarmarkLock from 'bootstrap-icons/icons/file-earmark-lock.svg'
|
||||||
|
import fileEarmarkMedical from 'bootstrap-icons/icons/file-earmark-medical.svg'
|
||||||
|
import fileEarmarkMinus from 'bootstrap-icons/icons/file-earmark-minus.svg'
|
||||||
|
import fileEarmarkPerson from 'bootstrap-icons/icons/file-earmark-person.svg'
|
||||||
|
import fileEarmarkPlus from 'bootstrap-icons/icons/file-earmark-plus.svg'
|
||||||
|
import fileEarmarkRichtext from 'bootstrap-icons/icons/file-earmark-richtext.svg'
|
||||||
|
import fileEarmarkSpreadsheet from 'bootstrap-icons/icons/file-earmark-spreadsheet.svg'
|
||||||
|
import fileEarmark from 'bootstrap-icons/icons/file-earmark.svg'
|
||||||
|
import fileText from 'bootstrap-icons/icons/file-text.svg'
|
||||||
|
import files from 'bootstrap-icons/icons/files.svg'
|
||||||
|
import filter from 'bootstrap-icons/icons/filter.svg'
|
||||||
|
import floppy from 'bootstrap-icons/icons/floppy.svg'
|
||||||
|
import folderFill from 'bootstrap-icons/icons/folder-fill.svg'
|
||||||
|
import folder from 'bootstrap-icons/icons/folder.svg'
|
||||||
|
import funnel from 'bootstrap-icons/icons/funnel.svg'
|
||||||
|
import gear from 'bootstrap-icons/icons/gear.svg'
|
||||||
|
import globe2 from 'bootstrap-icons/icons/globe2.svg'
|
||||||
|
import google from 'bootstrap-icons/icons/google.svg'
|
||||||
|
import grid from 'bootstrap-icons/icons/grid.svg'
|
||||||
|
import gripVertical from 'bootstrap-icons/icons/grip-vertical.svg'
|
||||||
|
import hash from 'bootstrap-icons/icons/hash.svg'
|
||||||
|
import hddStack from 'bootstrap-icons/icons/hdd-stack.svg'
|
||||||
|
import heart from 'bootstrap-icons/icons/heart.svg'
|
||||||
|
import house from 'bootstrap-icons/icons/house.svg'
|
||||||
|
import inbox from 'bootstrap-icons/icons/inbox.svg'
|
||||||
|
import infoCircle from 'bootstrap-icons/icons/info-circle.svg'
|
||||||
|
import journalBookmarkFill from 'bootstrap-icons/icons/journal-bookmark-fill.svg'
|
||||||
|
import journals from 'bootstrap-icons/icons/journals.svg'
|
||||||
|
import lightbulb from 'bootstrap-icons/icons/lightbulb.svg'
|
||||||
|
import link from 'bootstrap-icons/icons/link.svg'
|
||||||
|
import listNested from 'bootstrap-icons/icons/list-nested.svg'
|
||||||
|
import listTask from 'bootstrap-icons/icons/list-task.svg'
|
||||||
|
import listUl from 'bootstrap-icons/icons/list-ul.svg'
|
||||||
|
import list from 'bootstrap-icons/icons/list.svg'
|
||||||
|
import microsoft from 'bootstrap-icons/icons/microsoft.svg'
|
||||||
|
import newspaper from 'bootstrap-icons/icons/newspaper.svg'
|
||||||
|
import nodePlus from 'bootstrap-icons/icons/node-plus.svg'
|
||||||
|
import paperclip from 'bootstrap-icons/icons/paperclip.svg'
|
||||||
|
import pencil from 'bootstrap-icons/icons/pencil.svg'
|
||||||
|
import peopleFill from 'bootstrap-icons/icons/people-fill.svg'
|
||||||
|
import people from 'bootstrap-icons/icons/people.svg'
|
||||||
|
import personCircle from 'bootstrap-icons/icons/person-circle.svg'
|
||||||
|
import personFillLock from 'bootstrap-icons/icons/person-fill-lock.svg'
|
||||||
|
import personFill from 'bootstrap-icons/icons/person-fill.svg'
|
||||||
|
import personLock from 'bootstrap-icons/icons/person-lock.svg'
|
||||||
|
import personSquare from 'bootstrap-icons/icons/person-square.svg'
|
||||||
|
import person from 'bootstrap-icons/icons/person.svg'
|
||||||
|
import playFill from 'bootstrap-icons/icons/play-fill.svg'
|
||||||
|
import plusCircle from 'bootstrap-icons/icons/plus-circle.svg'
|
||||||
|
import plus from 'bootstrap-icons/icons/plus.svg'
|
||||||
|
import printer from 'bootstrap-icons/icons/printer.svg'
|
||||||
|
import questionCircle from 'bootstrap-icons/icons/question-circle.svg'
|
||||||
|
import receipt from 'bootstrap-icons/icons/receipt.svg'
|
||||||
|
import safe from 'bootstrap-icons/icons/safe.svg'
|
||||||
|
import scissors from 'bootstrap-icons/icons/scissors.svg'
|
||||||
|
import search from 'bootstrap-icons/icons/search.svg'
|
||||||
|
import send from 'bootstrap-icons/icons/send.svg'
|
||||||
|
import shop from 'bootstrap-icons/icons/shop.svg'
|
||||||
|
import slashCircle from 'bootstrap-icons/icons/slash-circle.svg'
|
||||||
|
import sliders2Vertical from 'bootstrap-icons/icons/sliders2-vertical.svg'
|
||||||
|
import sortAlphaDown from 'bootstrap-icons/icons/sort-alpha-down.svg'
|
||||||
|
import sortAlphaUpAlt from 'bootstrap-icons/icons/sort-alpha-up-alt.svg'
|
||||||
|
import stack from 'bootstrap-icons/icons/stack.svg'
|
||||||
|
import stars from 'bootstrap-icons/icons/stars.svg'
|
||||||
|
import tagFill from 'bootstrap-icons/icons/tag-fill.svg'
|
||||||
|
import tag from 'bootstrap-icons/icons/tag.svg'
|
||||||
|
import tags from 'bootstrap-icons/icons/tags.svg'
|
||||||
|
import telephone from 'bootstrap-icons/icons/telephone.svg'
|
||||||
|
import textIndentLeft from 'bootstrap-icons/icons/text-indent-left.svg'
|
||||||
|
import textLeft from 'bootstrap-icons/icons/text-left.svg'
|
||||||
|
import threeDotsVertical from 'bootstrap-icons/icons/three-dots-vertical.svg'
|
||||||
|
import threeDots from 'bootstrap-icons/icons/three-dots.svg'
|
||||||
|
import trash from 'bootstrap-icons/icons/trash.svg'
|
||||||
|
import truck from 'bootstrap-icons/icons/truck.svg'
|
||||||
|
import uiRadios from 'bootstrap-icons/icons/ui-radios.svg'
|
||||||
|
import unlock from 'bootstrap-icons/icons/unlock.svg'
|
||||||
|
import upcScan from 'bootstrap-icons/icons/upc-scan.svg'
|
||||||
|
import wallet2 from 'bootstrap-icons/icons/wallet2.svg'
|
||||||
|
import windowStack from 'bootstrap-icons/icons/window-stack.svg'
|
||||||
|
import xCircle from 'bootstrap-icons/icons/x-circle.svg'
|
||||||
|
import xLg from 'bootstrap-icons/icons/x-lg.svg'
|
||||||
|
import x from 'bootstrap-icons/icons/x.svg'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* `<pngx-bs-icon>`. Only icons registered here are bundled but can add
|
||||||
|
* new ones (any icon from the bootstrap-icons package) as needed.
|
||||||
|
*/
|
||||||
|
export const icons: Record<string, string> = {
|
||||||
|
airplane,
|
||||||
|
archive,
|
||||||
|
arrowClockwise,
|
||||||
|
arrowCounterclockwise,
|
||||||
|
arrowDown,
|
||||||
|
arrowDownUp,
|
||||||
|
arrowLeft,
|
||||||
|
arrowRepeat,
|
||||||
|
arrowReturnLeft,
|
||||||
|
arrowRight,
|
||||||
|
arrowRightShort,
|
||||||
|
arrowUpRight,
|
||||||
|
asterisk,
|
||||||
|
bank,
|
||||||
|
basket,
|
||||||
|
bell,
|
||||||
|
braces,
|
||||||
|
bodyText,
|
||||||
|
bookmark,
|
||||||
|
boxArrowInRight,
|
||||||
|
boxArrowUp,
|
||||||
|
boxArrowUpRight,
|
||||||
|
boxes,
|
||||||
|
briefcase,
|
||||||
|
building,
|
||||||
|
calculator,
|
||||||
|
calendar,
|
||||||
|
calendarEvent,
|
||||||
|
calendarEventFill,
|
||||||
|
camera,
|
||||||
|
cardChecklist,
|
||||||
|
cardHeading,
|
||||||
|
caretDown,
|
||||||
|
caretUp,
|
||||||
|
chatLeftText,
|
||||||
|
chatSquareDots,
|
||||||
|
check,
|
||||||
|
check2All,
|
||||||
|
checkAll,
|
||||||
|
checkCircle,
|
||||||
|
checkCircleFill,
|
||||||
|
checkLg,
|
||||||
|
chevronDoubleLeft,
|
||||||
|
chevronDoubleRight,
|
||||||
|
chevronDown,
|
||||||
|
chevronRight,
|
||||||
|
chevronUp,
|
||||||
|
circle,
|
||||||
|
clipboard,
|
||||||
|
clipboardCheck,
|
||||||
|
clipboardCheckFill,
|
||||||
|
clipboardFill,
|
||||||
|
clockHistory,
|
||||||
|
cpu,
|
||||||
|
cash,
|
||||||
|
creditCard,
|
||||||
|
dash,
|
||||||
|
dashCircle,
|
||||||
|
diagram3,
|
||||||
|
dice5,
|
||||||
|
doorOpen,
|
||||||
|
download,
|
||||||
|
envelope,
|
||||||
|
envelopeAt,
|
||||||
|
envelopeAtFill,
|
||||||
|
exclamationCircleFill,
|
||||||
|
exclamationTriangle,
|
||||||
|
exclamationTriangleFill,
|
||||||
|
eye,
|
||||||
|
fileEarmark,
|
||||||
|
fileEarmarkArrowUp,
|
||||||
|
fileEarmarkCheck,
|
||||||
|
fileEarmarkDiff,
|
||||||
|
fileEarmarkFill,
|
||||||
|
fileEarmarkLock,
|
||||||
|
fileEarmarkMedical,
|
||||||
|
fileEarmarkMinus,
|
||||||
|
fileEarmarkPerson,
|
||||||
|
fileEarmarkPlus,
|
||||||
|
fileEarmarkRichtext,
|
||||||
|
fileEarmarkSpreadsheet,
|
||||||
|
files,
|
||||||
|
fileText,
|
||||||
|
filter,
|
||||||
|
floppy,
|
||||||
|
folder,
|
||||||
|
folderFill,
|
||||||
|
funnel,
|
||||||
|
gear,
|
||||||
|
globe2,
|
||||||
|
google,
|
||||||
|
grid,
|
||||||
|
gripVertical,
|
||||||
|
hash,
|
||||||
|
hddStack,
|
||||||
|
heart,
|
||||||
|
house,
|
||||||
|
inbox,
|
||||||
|
infoCircle,
|
||||||
|
journalBookmarkFill,
|
||||||
|
journals,
|
||||||
|
lightbulb,
|
||||||
|
link,
|
||||||
|
list,
|
||||||
|
listNested,
|
||||||
|
listTask,
|
||||||
|
listUl,
|
||||||
|
microsoft,
|
||||||
|
newspaper,
|
||||||
|
nodePlus,
|
||||||
|
pencil,
|
||||||
|
paperclip,
|
||||||
|
people,
|
||||||
|
peopleFill,
|
||||||
|
person,
|
||||||
|
personCircle,
|
||||||
|
personFill,
|
||||||
|
personFillLock,
|
||||||
|
personLock,
|
||||||
|
personSquare,
|
||||||
|
playFill,
|
||||||
|
plus,
|
||||||
|
plusCircle,
|
||||||
|
printer,
|
||||||
|
questionCircle,
|
||||||
|
receipt,
|
||||||
|
safe,
|
||||||
|
scissors,
|
||||||
|
search,
|
||||||
|
send,
|
||||||
|
slashCircle,
|
||||||
|
shop,
|
||||||
|
sliders2Vertical,
|
||||||
|
sortAlphaDown,
|
||||||
|
sortAlphaUpAlt,
|
||||||
|
stack,
|
||||||
|
stars,
|
||||||
|
tagFill,
|
||||||
|
tag,
|
||||||
|
tags,
|
||||||
|
textIndentLeft,
|
||||||
|
textLeft,
|
||||||
|
telephone,
|
||||||
|
threeDots,
|
||||||
|
threeDotsVertical,
|
||||||
|
trash,
|
||||||
|
truck,
|
||||||
|
uiRadios,
|
||||||
|
unlock,
|
||||||
|
upcScan,
|
||||||
|
wallet2,
|
||||||
|
windowStack,
|
||||||
|
x,
|
||||||
|
xCircle,
|
||||||
|
xLg,
|
||||||
|
}
|
||||||
@@ -1,11 +1,11 @@
|
|||||||
@if (active()) {
|
@if (active()) {
|
||||||
<button class="position-absolute top-0 start-100 translate-middle badge bg-secondary border border-light rounded-pill p-1" title="Clear" i18n-title (click)="onClick($event)">
|
<button class="position-absolute top-0 start-100 translate-middle badge bg-secondary border border-light rounded-pill p-1" title="Clear" i18n-title (click)="onClick($event)">
|
||||||
@if (!isNumbered() && selected()) {
|
@if (!isNumbered() && selected()) {
|
||||||
<i-bs class="check" width="1em" height="1em" name="check-lg"></i-bs>
|
<pngx-bs-icon class="check" width="1em" height="1em" name="check-lg"></pngx-bs-icon>
|
||||||
}
|
}
|
||||||
@if (isNumbered()) {
|
@if (isNumbered()) {
|
||||||
<div class="number">{{number()}}<span class="visually-hidden">selected</span></div>
|
<div class="number">{{number()}}<span class="visually-hidden">selected</span></div>
|
||||||
}
|
}
|
||||||
<i-bs class="x" width=".9em" height="1em" name="x-lg"></i-bs>
|
<pngx-bs-icon class="x" width=".9em" height="1em" name="x-lg"></pngx-bs-icon>
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||||
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
|
||||||
import { ClearableBadgeComponent } from './clearable-badge.component'
|
import { ClearableBadgeComponent } from './clearable-badge.component'
|
||||||
|
|
||||||
describe('ClearableBadgeComponent', () => {
|
describe('ClearableBadgeComponent', () => {
|
||||||
@@ -8,10 +7,7 @@ describe('ClearableBadgeComponent', () => {
|
|||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
TestBed.configureTestingModule({
|
TestBed.configureTestingModule({
|
||||||
imports: [
|
imports: [ClearableBadgeComponent],
|
||||||
NgxBootstrapIconsModule.pick(allIcons),
|
|
||||||
ClearableBadgeComponent,
|
|
||||||
],
|
|
||||||
}).compileComponents()
|
}).compileComponents()
|
||||||
|
|
||||||
fixture = TestBed.createComponent(ClearableBadgeComponent)
|
fixture = TestBed.createComponent(ClearableBadgeComponent)
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import { Component, computed, EventEmitter, input, Output } from '@angular/core'
|
import { Component, computed, EventEmitter, input, Output } from '@angular/core'
|
||||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
import { BootstrapIconComponent } from 'src/app/components/common/bootstrap-icon/bootstrap-icon.component'
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'pngx-clearable-badge',
|
selector: 'pngx-clearable-badge',
|
||||||
templateUrl: './clearable-badge.component.html',
|
templateUrl: './clearable-badge.component.html',
|
||||||
styleUrls: ['./clearable-badge.component.scss'],
|
styleUrls: ['./clearable-badge.component.scss'],
|
||||||
imports: [NgxBootstrapIconsModule],
|
imports: [BootstrapIconComponent],
|
||||||
})
|
})
|
||||||
export class ClearableBadgeComponent {
|
export class ClearableBadgeComponent {
|
||||||
readonly number = input<number>(undefined)
|
readonly number = input<number>(undefined)
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
popoverClass="popover-slim"
|
popoverClass="popover-slim"
|
||||||
>
|
>
|
||||||
@if (iconName) {
|
@if (iconName) {
|
||||||
<i-bs [class.me-1]="label" name="{{iconName}}"></i-bs>
|
<pngx-bs-icon [class.me-1]="label" name="{{iconName}}"></pngx-bs-icon>
|
||||||
}
|
}
|
||||||
<ng-container>{{label}}</ng-container>
|
<ng-container>{{label}}</ng-container>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||||
|
|
||||||
import { NgbPopoverModule } from '@ng-bootstrap/ng-bootstrap'
|
import { NgbPopoverModule } from '@ng-bootstrap/ng-bootstrap'
|
||||||
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
|
||||||
import { ConfirmButtonComponent } from './confirm-button.component'
|
import { ConfirmButtonComponent } from './confirm-button.component'
|
||||||
|
|
||||||
describe('ConfirmButtonComponent', () => {
|
describe('ConfirmButtonComponent', () => {
|
||||||
@@ -10,11 +9,7 @@ describe('ConfirmButtonComponent', () => {
|
|||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
await TestBed.configureTestingModule({
|
await TestBed.configureTestingModule({
|
||||||
imports: [
|
imports: [NgbPopoverModule, ConfirmButtonComponent],
|
||||||
NgbPopoverModule,
|
|
||||||
NgxBootstrapIconsModule.pick(allIcons),
|
|
||||||
ConfirmButtonComponent,
|
|
||||||
],
|
|
||||||
}).compileComponents()
|
}).compileComponents()
|
||||||
|
|
||||||
fixture = TestBed.createComponent(ConfirmButtonComponent)
|
fixture = TestBed.createComponent(ConfirmButtonComponent)
|
||||||
|
|||||||
@@ -6,13 +6,13 @@ import {
|
|||||||
ViewChild,
|
ViewChild,
|
||||||
} from '@angular/core'
|
} from '@angular/core'
|
||||||
import { NgbPopover, NgbPopoverModule } from '@ng-bootstrap/ng-bootstrap'
|
import { NgbPopover, NgbPopoverModule } from '@ng-bootstrap/ng-bootstrap'
|
||||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
import { BootstrapIconComponent } from 'src/app/components/common/bootstrap-icon/bootstrap-icon.component'
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'pngx-confirm-button',
|
selector: 'pngx-confirm-button',
|
||||||
templateUrl: './confirm-button.component.html',
|
templateUrl: './confirm-button.component.html',
|
||||||
styleUrl: './confirm-button.component.scss',
|
styleUrl: './confirm-button.component.scss',
|
||||||
imports: [NgbPopoverModule, NgxBootstrapIconsModule],
|
imports: [NgbPopoverModule, BootstrapIconComponent],
|
||||||
})
|
})
|
||||||
export class ConfirmButtonComponent {
|
export class ConfirmButtonComponent {
|
||||||
@Input()
|
@Input()
|
||||||
|
|||||||
+1
-1
@@ -22,7 +22,7 @@
|
|||||||
@let document = getDocument(documentID);
|
@let document = getDocument(documentID);
|
||||||
@if (document) {
|
@if (document) {
|
||||||
<li class="list-group-item d-flex align-items-center" cdkDrag>
|
<li class="list-group-item d-flex align-items-center" cdkDrag>
|
||||||
<i-bs name="grip-vertical" class="me-2"></i-bs>
|
<pngx-bs-icon name="grip-vertical" class="me-2"></pngx-bs-icon>
|
||||||
<div class="d-flex flex-column">
|
<div class="d-flex flex-column">
|
||||||
<div>
|
<div>
|
||||||
@if (document.correspondent) {
|
@if (document.correspondent) {
|
||||||
|
|||||||
+1
-5
@@ -2,7 +2,6 @@ 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 { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
|
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
|
||||||
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
|
||||||
import { of } from 'rxjs'
|
import { of } from 'rxjs'
|
||||||
import { DocumentService } from 'src/app/services/rest/document.service'
|
import { DocumentService } from 'src/app/services/rest/document.service'
|
||||||
import { MergeAsVersionsConfirmDialogComponent } from './merge-as-versions-confirm-dialog.component'
|
import { MergeAsVersionsConfirmDialogComponent } from './merge-as-versions-confirm-dialog.component'
|
||||||
@@ -14,10 +13,7 @@ describe('MergeAsVersionsConfirmDialogComponent', () => {
|
|||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
await TestBed.configureTestingModule({
|
await TestBed.configureTestingModule({
|
||||||
imports: [
|
imports: [MergeAsVersionsConfirmDialogComponent],
|
||||||
NgxBootstrapIconsModule.pick(allIcons),
|
|
||||||
MergeAsVersionsConfirmDialogComponent,
|
|
||||||
],
|
|
||||||
providers: [
|
providers: [
|
||||||
NgbActiveModal,
|
NgbActiveModal,
|
||||||
provideHttpClient(withInterceptorsFromDi()),
|
provideHttpClient(withInterceptorsFromDi()),
|
||||||
|
|||||||
+2
-2
@@ -6,8 +6,8 @@ import {
|
|||||||
import { AsyncPipe } from '@angular/common'
|
import { AsyncPipe } from '@angular/common'
|
||||||
import { Component, OnInit, computed, inject, signal } from '@angular/core'
|
import { Component, OnInit, computed, inject, signal } from '@angular/core'
|
||||||
import { FormsModule } from '@angular/forms'
|
import { FormsModule } from '@angular/forms'
|
||||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
|
||||||
import { takeUntil } from 'rxjs'
|
import { takeUntil } from 'rxjs'
|
||||||
|
import { BootstrapIconComponent } from 'src/app/components/common/bootstrap-icon/bootstrap-icon.component'
|
||||||
import { Document } from 'src/app/data/document'
|
import { Document } from 'src/app/data/document'
|
||||||
import { CorrespondentNamePipe } from 'src/app/pipes/correspondent-name.pipe'
|
import { CorrespondentNamePipe } from 'src/app/pipes/correspondent-name.pipe'
|
||||||
import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
|
import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
|
||||||
@@ -24,7 +24,7 @@ import { ConfirmDialogComponent } from '../confirm-dialog.component'
|
|||||||
CustomDatePipe,
|
CustomDatePipe,
|
||||||
DragDropModule,
|
DragDropModule,
|
||||||
FormsModule,
|
FormsModule,
|
||||||
NgxBootstrapIconsModule,
|
BootstrapIconComponent,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class MergeAsVersionsConfirmDialogComponent
|
export class MergeAsVersionsConfirmDialogComponent
|
||||||
|
|||||||
+1
-1
@@ -15,7 +15,7 @@
|
|||||||
@let document = getDocument(documentID);
|
@let document = getDocument(documentID);
|
||||||
@if (document) {
|
@if (document) {
|
||||||
<li class="list-group-item d-flex align-items-center" cdkDrag>
|
<li class="list-group-item d-flex align-items-center" cdkDrag>
|
||||||
<i-bs name="grip-vertical" class="me-2"></i-bs>
|
<pngx-bs-icon name="grip-vertical" class="me-2"></pngx-bs-icon>
|
||||||
<div class="d-flex flex-column">
|
<div class="d-flex flex-column">
|
||||||
<div>
|
<div>
|
||||||
@if (document.correspondent) {
|
@if (document.correspondent) {
|
||||||
|
|||||||
+1
-7
@@ -3,7 +3,6 @@ import { provideHttpClientTesting } from '@angular/common/http/testing'
|
|||||||
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||||
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
|
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
|
||||||
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
|
||||||
import { of } from 'rxjs'
|
import { of } from 'rxjs'
|
||||||
import { DocumentService } from 'src/app/services/rest/document.service'
|
import { DocumentService } from 'src/app/services/rest/document.service'
|
||||||
import { MergeConfirmDialogComponent } from './merge-confirm-dialog.component'
|
import { MergeConfirmDialogComponent } from './merge-confirm-dialog.component'
|
||||||
@@ -15,12 +14,7 @@ describe('MergeConfirmDialogComponent', () => {
|
|||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
await TestBed.configureTestingModule({
|
await TestBed.configureTestingModule({
|
||||||
imports: [
|
imports: [ReactiveFormsModule, FormsModule, MergeConfirmDialogComponent],
|
||||||
NgxBootstrapIconsModule.pick(allIcons),
|
|
||||||
ReactiveFormsModule,
|
|
||||||
FormsModule,
|
|
||||||
MergeConfirmDialogComponent,
|
|
||||||
],
|
|
||||||
providers: [
|
providers: [
|
||||||
NgbActiveModal,
|
NgbActiveModal,
|
||||||
provideHttpClient(withInterceptorsFromDi()),
|
provideHttpClient(withInterceptorsFromDi()),
|
||||||
|
|||||||
+2
-2
@@ -6,8 +6,8 @@ import {
|
|||||||
import { AsyncPipe } from '@angular/common'
|
import { AsyncPipe } from '@angular/common'
|
||||||
import { Component, OnInit, inject, signal } from '@angular/core'
|
import { Component, OnInit, inject, signal } from '@angular/core'
|
||||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
|
||||||
import { takeUntil } from 'rxjs'
|
import { takeUntil } from 'rxjs'
|
||||||
|
import { BootstrapIconComponent } from 'src/app/components/common/bootstrap-icon/bootstrap-icon.component'
|
||||||
import { Document } from 'src/app/data/document'
|
import { Document } from 'src/app/data/document'
|
||||||
import { CorrespondentNamePipe } from 'src/app/pipes/correspondent-name.pipe'
|
import { CorrespondentNamePipe } from 'src/app/pipes/correspondent-name.pipe'
|
||||||
import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
|
import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
|
||||||
@@ -26,7 +26,7 @@ import { ConfirmDialogComponent } from '../confirm-dialog.component'
|
|||||||
DragDropModule,
|
DragDropModule,
|
||||||
FormsModule,
|
FormsModule,
|
||||||
ReactiveFormsModule,
|
ReactiveFormsModule,
|
||||||
NgxBootstrapIconsModule,
|
BootstrapIconComponent,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class MergeConfirmDialogComponent
|
export class MergeConfirmDialogComponent
|
||||||
|
|||||||
+2
-2
@@ -18,7 +18,7 @@
|
|||||||
(ngModelChange)="onUpdateDocumentChange($event)"
|
(ngModelChange)="onUpdateDocumentChange($event)"
|
||||||
/>
|
/>
|
||||||
<label class="btn btn-outline-primary btn-sm" for="removeReplace">
|
<label class="btn btn-outline-primary btn-sm" for="removeReplace">
|
||||||
<i-bs name="pencil"></i-bs>
|
<pngx-bs-icon name="pencil"></pngx-bs-icon>
|
||||||
<span class="ms-2" i18n>Replace current document</span>
|
<span class="ms-2" i18n>Replace current document</span>
|
||||||
</label>
|
</label>
|
||||||
<input
|
<input
|
||||||
@@ -31,7 +31,7 @@
|
|||||||
(ngModelChange)="onUpdateDocumentChange($event)"
|
(ngModelChange)="onUpdateDocumentChange($event)"
|
||||||
/>
|
/>
|
||||||
<label class="btn btn-outline-primary btn-sm" for="removeCreate">
|
<label class="btn btn-outline-primary btn-sm" for="removeCreate">
|
||||||
<i-bs name="plus"></i-bs>
|
<pngx-bs-icon name="plus"></pngx-bs-icon>
|
||||||
<span class="ms-2" i18n>Create new document</span>
|
<span class="ms-2" i18n>Create new document</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+1
-5
@@ -1,7 +1,6 @@
|
|||||||
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'
|
||||||
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
|
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
|
||||||
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
|
||||||
import { PasswordRemovalConfirmDialogComponent } from './password-removal-confirm-dialog.component'
|
import { PasswordRemovalConfirmDialogComponent } from './password-removal-confirm-dialog.component'
|
||||||
|
|
||||||
describe('PasswordRemovalConfirmDialogComponent', () => {
|
describe('PasswordRemovalConfirmDialogComponent', () => {
|
||||||
@@ -11,10 +10,7 @@ describe('PasswordRemovalConfirmDialogComponent', () => {
|
|||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
await TestBed.configureTestingModule({
|
await TestBed.configureTestingModule({
|
||||||
providers: [NgbActiveModal],
|
providers: [NgbActiveModal],
|
||||||
imports: [
|
imports: [PasswordRemovalConfirmDialogComponent],
|
||||||
NgxBootstrapIconsModule.pick(allIcons),
|
|
||||||
PasswordRemovalConfirmDialogComponent,
|
|
||||||
],
|
|
||||||
}).compileComponents()
|
}).compileComponents()
|
||||||
|
|
||||||
fixture = TestBed.createComponent(PasswordRemovalConfirmDialogComponent)
|
fixture = TestBed.createComponent(PasswordRemovalConfirmDialogComponent)
|
||||||
|
|||||||
+2
-2
@@ -1,13 +1,13 @@
|
|||||||
import { Component } from '@angular/core'
|
import { Component } from '@angular/core'
|
||||||
import { FormsModule } from '@angular/forms'
|
import { FormsModule } from '@angular/forms'
|
||||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
import { BootstrapIconComponent } from 'src/app/components/common/bootstrap-icon/bootstrap-icon.component'
|
||||||
import { ConfirmDialogComponent } from '../confirm-dialog.component'
|
import { ConfirmDialogComponent } from '../confirm-dialog.component'
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'pngx-password-removal-confirm-dialog',
|
selector: 'pngx-password-removal-confirm-dialog',
|
||||||
templateUrl: './password-removal-confirm-dialog.component.html',
|
templateUrl: './password-removal-confirm-dialog.component.html',
|
||||||
styleUrls: ['./password-removal-confirm-dialog.component.scss'],
|
styleUrls: ['./password-removal-confirm-dialog.component.scss'],
|
||||||
imports: [FormsModule, NgxBootstrapIconsModule],
|
imports: [FormsModule, BootstrapIconComponent],
|
||||||
})
|
})
|
||||||
export class PasswordRemovalConfirmDialogComponent extends ConfirmDialogComponent {
|
export class PasswordRemovalConfirmDialogComponent extends ConfirmDialogComponent {
|
||||||
updateDocument: boolean = true
|
updateDocument: boolean = true
|
||||||
|
|||||||
+2
-2
@@ -7,7 +7,7 @@
|
|||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-2 d-flex justify-content-end">
|
<div class="col-2 d-flex justify-content-end">
|
||||||
<button class="btn btn-secondary mt-auto" (click)="rotate(false)" aria-label="Rotate counterclockwise" i18n-aria-label>
|
<button class="btn btn-secondary mt-auto" (click)="rotate(false)" aria-label="Rotate counterclockwise" i18n-aria-label>
|
||||||
<i-bs name="arrow-counterclockwise"></i-bs>
|
<pngx-bs-icon name="arrow-counterclockwise"></pngx-bs-icon>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-8 d-flex align-items-center">
|
<div class="col-8 d-flex align-items-center">
|
||||||
@@ -17,7 +17,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="col-2 d-flex">
|
<div class="col-2 d-flex">
|
||||||
<button class="btn btn-secondary mt-auto" (click)="rotate()" aria-label="Rotate clockwise" i18n-aria-label>
|
<button class="btn btn-secondary mt-auto" (click)="rotate()" aria-label="Rotate clockwise" i18n-aria-label>
|
||||||
<i-bs name="arrow-clockwise"></i-bs>
|
<pngx-bs-icon name="arrow-clockwise"></pngx-bs-icon>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+1
-5
@@ -2,7 +2,6 @@ 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 { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
|
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
|
||||||
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
|
||||||
import { RotateConfirmDialogComponent } from './rotate-confirm-dialog.component'
|
import { RotateConfirmDialogComponent } from './rotate-confirm-dialog.component'
|
||||||
|
|
||||||
describe('RotateConfirmDialogComponent', () => {
|
describe('RotateConfirmDialogComponent', () => {
|
||||||
@@ -11,10 +10,7 @@ describe('RotateConfirmDialogComponent', () => {
|
|||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
await TestBed.configureTestingModule({
|
await TestBed.configureTestingModule({
|
||||||
imports: [
|
imports: [RotateConfirmDialogComponent],
|
||||||
NgxBootstrapIconsModule.pick(allIcons),
|
|
||||||
RotateConfirmDialogComponent,
|
|
||||||
],
|
|
||||||
providers: [
|
providers: [
|
||||||
NgbActiveModal,
|
NgbActiveModal,
|
||||||
provideHttpClient(withInterceptorsFromDi()),
|
provideHttpClient(withInterceptorsFromDi()),
|
||||||
|
|||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
import { NgStyle } from '@angular/common'
|
import { NgStyle } from '@angular/common'
|
||||||
import { Component, inject, signal } from '@angular/core'
|
import { Component, inject, signal } from '@angular/core'
|
||||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
import { BootstrapIconComponent } from 'src/app/components/common/bootstrap-icon/bootstrap-icon.component'
|
||||||
import { DocumentService } from 'src/app/services/rest/document.service'
|
import { DocumentService } from 'src/app/services/rest/document.service'
|
||||||
import { ConfirmDialogComponent } from '../confirm-dialog.component'
|
import { ConfirmDialogComponent } from '../confirm-dialog.component'
|
||||||
|
|
||||||
@@ -8,7 +8,7 @@ import { ConfirmDialogComponent } from '../confirm-dialog.component'
|
|||||||
selector: 'pngx-rotate-confirm-dialog',
|
selector: 'pngx-rotate-confirm-dialog',
|
||||||
templateUrl: './rotate-confirm-dialog.component.html',
|
templateUrl: './rotate-confirm-dialog.component.html',
|
||||||
styleUrl: './rotate-confirm-dialog.component.scss',
|
styleUrl: './rotate-confirm-dialog.component.scss',
|
||||||
imports: [NgStyle, NgxBootstrapIconsModule],
|
imports: [NgStyle, BootstrapIconComponent],
|
||||||
})
|
})
|
||||||
export class RotateConfirmDialogComponent extends ConfirmDialogComponent {
|
export class RotateConfirmDialogComponent extends ConfirmDialogComponent {
|
||||||
documentService = inject(DocumentService)
|
documentService = inject(DocumentService)
|
||||||
|
|||||||
+1
-1
@@ -20,7 +20,7 @@
|
|||||||
@for (docId of value; track docId) {
|
@for (docId of value; track docId) {
|
||||||
@if (getDocumentTitle(docId)) {
|
@if (getDocumentTitle(docId)) {
|
||||||
<a routerLink="/documents/{{docId}}" class="badge bg-body text-primary" title="View" i18n-title>
|
<a routerLink="/documents/{{docId}}" class="badge bg-body text-primary" title="View" i18n-title>
|
||||||
<i-bs width="0.9em" height="0.9em" name="file-text" class="me-1"></i-bs><span>{{ getDocumentTitle(docId) }}</span>
|
<pngx-bs-icon width="0.9em" height="0.9em" name="file-text" class="me-1"></pngx-bs-icon><span>{{ getDocumentTitle(docId) }}</span>
|
||||||
</a>
|
</a>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-5
@@ -1,6 +1,6 @@
|
|||||||
<div ngbDropdown #fieldDropdown="ngbDropdown" (openChange)="onOpenClose($event)" [popperOptions]="popperOptions">
|
<div ngbDropdown #fieldDropdown="ngbDropdown" (openChange)="onOpenClose($event)" [popperOptions]="popperOptions">
|
||||||
<button type="button" class="btn btn-sm btn-outline-primary" id="customFieldsDropdown" [disabled]="disabled" ngbDropdownToggle aria-label="Custom Fields" i18n-aria-label>
|
<button type="button" class="btn btn-sm btn-outline-primary" id="customFieldsDropdown" [disabled]="disabled" ngbDropdownToggle aria-label="Custom Fields" i18n-aria-label>
|
||||||
<i-bs name="ui-radios"></i-bs><div class="d-none d-lg-inline ms-1"><ng-container i18n>Custom Fields</ng-container></div>
|
<pngx-bs-icon name="ui-radios"></pngx-bs-icon><div class="d-none d-lg-inline ms-1"><ng-container i18n>Custom Fields</ng-container></div>
|
||||||
</button>
|
</button>
|
||||||
<div ngbDropdownMenu aria-labelledby="customFieldsDropdown" class="shadow custom-fields-dropdown">
|
<div ngbDropdownMenu aria-labelledby="customFieldsDropdown" class="shadow custom-fields-dropdown">
|
||||||
<div class="list-group list-group-flush" (keydown)="listKeyDown($event)">
|
<div class="list-group list-group-flush" (keydown)="listKeyDown($event)">
|
||||||
@@ -11,14 +11,12 @@
|
|||||||
</div>
|
</div>
|
||||||
@for (field of filteredFields; track field.id) {
|
@for (field of filteredFields; track field.id) {
|
||||||
<button type="button" class="list-group-item list-group-item-action bg-light" (click)="addField(field)" #button>
|
<button type="button" class="list-group-item list-group-item-action bg-light" (click)="addField(field)" #button>
|
||||||
<small class="d-flex">{{field.name}} <small class="ms-auto text-muted">{{getDataTypeLabel(field.data_type)}}</small></small>
|
<div class="d-flex">{{field.name}} <small class="ms-auto text-muted">{{getDataTypeLabel(field.data_type)}}</small></div>
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
@if (!filterText?.length || filteredFields.length === 0) {
|
@if (!filterText?.length || filteredFields.length === 0) {
|
||||||
<button type="button" class="list-group-item list-group-item-action bg-light" (click)="createField(filterText)" [disabled]="!canCreateFields" #button>
|
<button type="button" class="list-group-item list-group-item-action bg-light" (click)="createField(filterText)" [disabled]="!canCreateFields" #button>
|
||||||
<small>
|
<pngx-bs-icon width=".9em" height=".9em" name="asterisk" class="me-1"></pngx-bs-icon><ng-container i18n>Create new field</ng-container>
|
||||||
<i-bs width=".9em" height=".9em" name="asterisk" class="me-1"></i-bs><ng-container i18n>Create new field</ng-container>
|
|
||||||
</small>
|
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
-7
@@ -1,10 +1,3 @@
|
|||||||
.custom-fields-dropdown {
|
.custom-fields-dropdown {
|
||||||
min-width: 300px;
|
min-width: 300px;
|
||||||
|
|
||||||
// correct position on mobile
|
|
||||||
@media (max-width: 575.98px) {
|
|
||||||
&.show {
|
|
||||||
margin-left: -245px !important;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Loaded 100 of 519 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user