mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2025-04-02 13:45:10 -05:00
Merge branch 'dev'
This commit is contained in:
commit
5d937cf639
4
.github/workflows/cleanup-tags.yml
vendored
4
.github/workflows/cleanup-tags.yml
vendored
@ -33,7 +33,7 @@ jobs:
|
||||
-
|
||||
name: Clean temporary images
|
||||
if: "${{ env.TOKEN != '' }}"
|
||||
uses: stumpylog/image-cleaner-action/ephemeral@v0.5.0
|
||||
uses: stumpylog/image-cleaner-action/ephemeral@v0.6.0
|
||||
with:
|
||||
token: "${{ env.TOKEN }}"
|
||||
owner: "${{ github.repository_owner }}"
|
||||
@ -61,7 +61,7 @@ jobs:
|
||||
-
|
||||
name: Clean untagged images
|
||||
if: "${{ env.TOKEN != '' }}"
|
||||
uses: stumpylog/image-cleaner-action/untagged@v0.5.0
|
||||
uses: stumpylog/image-cleaner-action/untagged@v0.6.0
|
||||
with:
|
||||
token: "${{ env.TOKEN }}"
|
||||
owner: "${{ github.repository_owner }}"
|
||||
|
@ -5,7 +5,7 @@
|
||||
repos:
|
||||
# General hooks
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: v4.5.0
|
||||
rev: v4.6.0
|
||||
hooks:
|
||||
- id: check-docstring-first
|
||||
- id: check-json
|
||||
@ -47,11 +47,11 @@ repos:
|
||||
exclude: "(^Pipfile\\.lock$)"
|
||||
# Python hooks
|
||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||
rev: 'v0.3.5'
|
||||
rev: 'v0.4.2'
|
||||
hooks:
|
||||
- id: ruff
|
||||
- repo: https://github.com/psf/black-pre-commit-mirror
|
||||
rev: 24.3.0
|
||||
rev: 24.4.2
|
||||
hooks:
|
||||
- id: black
|
||||
# Dockerfile hooks
|
||||
|
@ -52,7 +52,7 @@ ARG TARGETARCH
|
||||
|
||||
# Can be workflow provided, defaults set for manual building
|
||||
ARG JBIG2ENC_VERSION=0.29
|
||||
ARG QPDF_VERSION=11.6.4
|
||||
ARG QPDF_VERSION=11.9.0
|
||||
ARG GS_VERSION=10.02.1
|
||||
|
||||
# Set Python environment variables
|
||||
|
6
Pipfile
6
Pipfile
@ -8,13 +8,13 @@ dateparser = "~=1.2"
|
||||
# WARNING: django does not use semver.
|
||||
# Only patch versions are guaranteed to not introduce breaking changes.
|
||||
django = "~=4.2.11"
|
||||
django-allauth = "*"
|
||||
django-allauth = {extras = ["socialaccount"], version = "*"}
|
||||
django-auditlog = "*"
|
||||
django-celery-results = "*"
|
||||
django-compression-middleware = "*"
|
||||
django-cors-headers = "*"
|
||||
django-extensions = "*"
|
||||
django-filter = "~=24.1"
|
||||
django-filter = "~=24.2"
|
||||
django-guardian = "*"
|
||||
django-multiselectfield = "*"
|
||||
djangorestframework = "==3.14.0"
|
||||
@ -22,7 +22,7 @@ djangorestframework-guardian = "*"
|
||||
drf-writable-nested = "*"
|
||||
bleach = "*"
|
||||
celery = {extras = ["redis"], version = "*"}
|
||||
channels = "~=4.0"
|
||||
channels = "~=4.1"
|
||||
channels-redis = "*"
|
||||
concurrent-log-handler = "*"
|
||||
filelock = "*"
|
||||
|
1979
Pipfile.lock
generated
1979
Pipfile.lock
generated
File diff suppressed because it is too large
Load Diff
@ -63,7 +63,7 @@ If you'd like to jump right in, you can configure a `docker compose` environment
|
||||
bash -c "$(curl -L https://raw.githubusercontent.com/paperless-ngx/paperless-ngx/main/install-paperless-ngx.sh)"
|
||||
```
|
||||
|
||||
Alternatively, you can install the dependencies and setup apache and a database server yourself. The [documentation](https://docs.paperless-ngx.com/setup/#installation) has a step by step guide on how to do it.
|
||||
More details and step-by-step guides for alternative installation methods can be found in [the documentation](https://docs.paperless-ngx.com/setup/#installation).
|
||||
|
||||
Migrating from Paperless-ng is easy, just drop in the new docker image! See the [documentation on migrating](https://docs.paperless-ngx.com/setup/#migrating-to-paperless-ngx) for more details.
|
||||
|
||||
|
@ -80,7 +80,7 @@ django_checks() {
|
||||
|
||||
search_index() {
|
||||
|
||||
local -r index_version=8
|
||||
local -r index_version=9
|
||||
local -r index_version_file=${DATA_DIR}/.index_version
|
||||
|
||||
if [[ (! -f "${index_version_file}") || $(<"${index_version_file}") != "$index_version" ]]; then
|
||||
|
36
docs/api.md
36
docs/api.md
@ -11,7 +11,7 @@ The API provides the following main endpoints:
|
||||
- `/api/correspondents/`: Full CRUD support.
|
||||
- `/api/custom_fields/`: Full CRUD support.
|
||||
- `/api/documents/`: Full CRUD support, except POSTing new documents.
|
||||
See below.
|
||||
See [below](#posting-documents-file-uploads).
|
||||
- `/api/document_types/`: Full CRUD support.
|
||||
- `/api/groups/`: Full CRUD support.
|
||||
- `/api/logs/`: Read-Only.
|
||||
@ -24,6 +24,7 @@ The API provides the following main endpoints:
|
||||
- `/api/tasks/`: Read-only.
|
||||
- `/api/users/`: Full CRUD support.
|
||||
- `/api/workflows/`: Full CRUD support.
|
||||
- `/api/search/` GET, see [below](#global-search).
|
||||
|
||||
All of these endpoints except for the logging endpoint allow you to
|
||||
fetch (and edit and delete where appropriate) individual objects by
|
||||
@ -140,6 +141,7 @@ document. Paperless only reports PDF metadata at this point.
|
||||
|
||||
- `/api/documents/<id>/notes/`: Retrieve notes for a document.
|
||||
- `/api/documents/<id>/share_links/`: Retrieve share links for a document.
|
||||
- `/api/documents/<id>/history/`: Retrieve history of changes for a document.
|
||||
|
||||
## Authorization
|
||||
|
||||
@ -187,6 +189,38 @@ The REST api provides four different forms of authentication.
|
||||
[configuration](configuration.md#PAPERLESS_ENABLE_HTTP_REMOTE_USER_API)),
|
||||
you can authenticate against the API using Remote User auth.
|
||||
|
||||
## Global search
|
||||
|
||||
A global search endpoint is available at `/api/search/` and requires a search term
|
||||
of > 2 characters e.g. `?query=foo`. This endpoint returns a maximum of 3 results
|
||||
across nearly all objects, e.g. documents, tags, saved views, mail rules, etc.
|
||||
Results are only included if the requesting user has the appropriate permissions.
|
||||
|
||||
Results are returned in the following format:
|
||||
|
||||
```json
|
||||
{
|
||||
total: number
|
||||
documents: []
|
||||
saved_views: []
|
||||
correspondents: []
|
||||
document_types: []
|
||||
storage_paths: []
|
||||
tags: []
|
||||
users: []
|
||||
groups: []
|
||||
mail_accounts: []
|
||||
mail_rules: []
|
||||
custom_fields: []
|
||||
workflows: []
|
||||
}
|
||||
```
|
||||
|
||||
Global search first searches objects by name (or title for documents) matching the query.
|
||||
If the optional `db_only` parameter is set, only document titles will be searched. Otherwise,
|
||||
if the amount of documents returned by a simple title string search is < 3, results from the
|
||||
search index will also be included.
|
||||
|
||||
## Searching for documents
|
||||
|
||||
Full text searching is available on the `/api/documents/` endpoint. Two
|
||||
|
@ -6,6 +6,7 @@ You can go multiple routes to setup and run Paperless:
|
||||
- [Pull the image from Docker Hub](#docker_hub)
|
||||
- [Build the Docker image yourself](#docker_build)
|
||||
- [Install Paperless directly on your system manually (bare metal)](#bare_metal)
|
||||
- A user-maintained list of commercial hosting providers can be found [in the wiki](https://github.com/paperless-ngx/paperless-ngx/wiki/Related-Projects)
|
||||
|
||||
The Docker routes are quick & easy. These are the recommended routes.
|
||||
This configures all the stuff from the above automatically so that it
|
||||
|
@ -241,6 +241,11 @@ permissions can be granted to limit access to certain parts of the UI (and corre
|
||||
|
||||
Superusers can access all parts of the front and backend application as well as any and all objects.
|
||||
|
||||
#### Admin Status
|
||||
|
||||
Admin status (Django 'staff status') grants access to viewing the paperless logs and the system status dialog
|
||||
as well as accessing the Django backend.
|
||||
|
||||
#### Detailed Explanation of Global Permissions {#global-permissions}
|
||||
|
||||
Global permissions define what areas of the app and API endpoints the user can access. For example, they
|
||||
@ -249,7 +254,6 @@ still have "object-level" permissions.
|
||||
|
||||
| Type | Details |
|
||||
| ------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| Admin | _View_ or higher permissions grants access to the logs view as well as the system status. |
|
||||
| AppConfig | _Change_ or higher permissions grants access to the "Application Configuration" area. |
|
||||
| Correspondent | Grants global permissions to add, edit, delete or view Correspondents. |
|
||||
| CustomField | Grants global permissions to add, edit, delete or view Custom Fields. |
|
||||
@ -468,6 +472,12 @@ Paperless-ngx supports 3 basic editing operations for PDFs (these operations can
|
||||
|
||||
Note that rotation alters the Paperless-ngx _original_ file, which would, for example, invalidate a digital signature.
|
||||
|
||||
## Document History
|
||||
|
||||
As of version 2.7, Paperless-ngx automatically records all changes to a document and records this in an audit log. The feature requires [`PAPERLESS_AUDIT_LOG_ENABLED`](configuration.md#PAPERLESS_AUDIT_LOG_ENABLED) be enabled, which it is by default as of version 2.7.
|
||||
Changes to documents are visible under the "History" tab. Note that certain changes such as those made by workflows, record the 'actor'
|
||||
as "System".
|
||||
|
||||
## Best practices {#basic-searching}
|
||||
|
||||
Paperless offers a couple tools that help you organize your document
|
||||
@ -540,6 +550,16 @@ collection.
|
||||
|
||||
## Searching {#basic-usage_searching}
|
||||
|
||||
### Global search
|
||||
|
||||
The top search bar in the web UI performs a "global" search of the various
|
||||
objects Paperless-ngx uses, including documents, tags, workflows, etc. Only
|
||||
objects for which the user has appropriate permissions are returned. For
|
||||
documents, if there are < 3 results, "advanced" search results (which use
|
||||
the document index) will also be included. This can be disabled under settings.
|
||||
|
||||
### Document searches
|
||||
|
||||
Paperless offers an extensive searching mechanism that is designed to
|
||||
allow you to quickly find a document you're looking for (for example,
|
||||
that thing that just broke and you bought a couple months ago, that
|
||||
@ -595,6 +615,12 @@ language](https://whoosh.readthedocs.io/en/latest/querylang.html). For
|
||||
details on what date parsing utilities are available, see [Date
|
||||
parsing](https://whoosh.readthedocs.io/en/latest/dates.html#parsing-date-queries).
|
||||
|
||||
## Keyboard shortcuts / hotkeys
|
||||
|
||||
A list of available hotkeys can be shown on any page using <kbd>Shift</kbd> +
|
||||
<kbd>?</kbd>. The help dialog shows only the keys that are currently available
|
||||
based on which area of Paperless-ngx you are using.
|
||||
|
||||
## The recommended workflow {#usage-recommended-workflow}
|
||||
|
||||
Once you have familiarized yourself with paperless and are ready to use
|
||||
|
@ -37,11 +37,11 @@ def worker_int(worker):
|
||||
id2name = {th.ident: th.name for th in threading.enumerate()}
|
||||
code = []
|
||||
for threadId, stack in sys._current_frames().items():
|
||||
code.append("\n# Thread: %s(%d)" % (id2name.get(threadId, ""), threadId))
|
||||
code.append(f"\n# Thread: {id2name.get(threadId, '')}({threadId})")
|
||||
for filename, lineno, name, line in traceback.extract_stack(stack):
|
||||
code.append('File: "%s", line %d, in %s' % (filename, lineno, name))
|
||||
code.append(f'File: "{filename}", line {lineno}, in {name}')
|
||||
if line:
|
||||
code.append(" %s" % (line.strip()))
|
||||
code.append(f" {line.strip()}")
|
||||
worker.log.debug("\n".join(code))
|
||||
|
||||
|
||||
|
@ -71,7 +71,17 @@ if ! docker stats --no-stream &> /dev/null ; then
|
||||
sleep 3
|
||||
fi
|
||||
|
||||
default_time_zone=$(timedatectl show -p Timezone --value)
|
||||
# Added handling for timezone for busybox based linux, not having timedatectl available (i.e. QNAP QTS)
|
||||
# if neither timedatectl nor /etc/TZ is succeeding, defaulting to GMT.
|
||||
if command -v timedatectl &> /dev/null ; then
|
||||
default_time_zone=$(timedatectl show -p Timezone --value)
|
||||
elif [ -f /etc/TZ ] && [ -f /etc/tzlist ] ; then
|
||||
TZ=$(cat /etc/TZ)
|
||||
default_time_zone=$(grep -B 1 -m 1 "$TZ" /etc/tzlist | head -1 | cut -f 2 -d =)
|
||||
else
|
||||
echo "WARN: unable to detect timezone, defaulting to Etc/UTC"
|
||||
default_time_zone="Etc/UTC"
|
||||
fi
|
||||
|
||||
set -e
|
||||
|
||||
|
36
paperless-ngx.code-workspace
Normal file
36
paperless-ngx.code-workspace
Normal file
@ -0,0 +1,36 @@
|
||||
{
|
||||
"folders": [
|
||||
{
|
||||
"path": "."
|
||||
},
|
||||
{
|
||||
"path": "./src",
|
||||
"name": "Backend"
|
||||
},
|
||||
{
|
||||
"path": "./src-ui",
|
||||
"name": "Frontend"
|
||||
},
|
||||
{
|
||||
"path": "./.github",
|
||||
"name": "CI/CD"
|
||||
},
|
||||
{
|
||||
"path": "./docs",
|
||||
"name": "Documentation"
|
||||
}
|
||||
|
||||
],
|
||||
"settings": {
|
||||
"files.exclude": {
|
||||
"**/__pycache__": true,
|
||||
"**/.mypy_cache": true,
|
||||
"**/.ruff_cache": true,
|
||||
"**/.pytest_cache": true,
|
||||
"**/.idea": true,
|
||||
"**/.venv": true,
|
||||
"**/.coverage": true,
|
||||
"**/coverage.json": true
|
||||
}
|
||||
}
|
||||
}
|
@ -124,7 +124,7 @@
|
||||
"content": {
|
||||
"size": -1,
|
||||
"mimeType": "application/json",
|
||||
"text": "{\"count\":6,\"next\":null,\"previous\":null,\"all\":[8,17,7,4,11,15],\"results\":[{\"id\":8,\"name\":\"Correspondent 2\",\"show_on_dashboard\":false,\"show_in_sidebar\":false,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[{\"rule_type\":3,\"value\":\"2\"}],\"owner\":\"2\",\"user_can_change\":true},{\"id\":17,\"name\":\"In the Last Month\",\"show_on_dashboard\":false,\"show_in_sidebar\":false,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[{\"rule_type\":20,\"value\":\"created:[-1 month to now]\"}],\"owner\":\"2\",\"user_can_change\":true},{\"id\":7,\"name\":\"Inbox\",\"show_on_dashboard\":true,\"show_in_sidebar\":true,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[{\"rule_type\":6,\"value\":\"9\"}],\"owner\":\"2\",\"user_can_change\":true},{\"id\":4,\"name\":\"Recently Added\",\"show_on_dashboard\":true,\"show_in_sidebar\":true,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[],\"owner\":\"2\",\"user_can_change\":true},{\"id\":11,\"name\":\"Tag: Another Sample Tag\",\"show_on_dashboard\":false,\"show_in_sidebar\":true,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[{\"rule_type\":6,\"value\":\"4\"}],\"owner\":\"2\",\"user_can_change\":true},{\"id\":15,\"name\":\"View ASN not empty\",\"show_on_dashboard\":false,\"show_in_sidebar\":false,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[{\"rule_type\":18,\"value\":\"false\"}],\"owner\":\"2\",\"user_can_change\":true}]}"
|
||||
"text": "{\"count\":6,\"next\":null,\"previous\":null,\"all\":[8,17,7,4,11,15],\"results\":[{\"id\":8,\"name\":\"Correspondent 2\",\"show_on_dashboard\":false,\"show_in_sidebar\":false,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[{\"rule_type\":3,\"value\":\"2\"}],\"owner\":\"2\",\"user_can_change\":true,\"page_size\":10,\"display_mode\":\"table\",\"display_fields\":[\"created\",\"title\",\"tag\",\"documenttype\"]},{\"id\":17,\"name\":\"In the Last Month\",\"show_on_dashboard\":false,\"show_in_sidebar\":false,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[{\"rule_type\":20,\"value\":\"created:[-1 month to now]\"}],\"owner\":\"2\",\"user_can_change\":true,\"page_size\":10,\"display_mode\":\"table\",\"display_fields\":[\"created\",\"title\",\"tag\",\"documenttype\"]},{\"id\":7,\"name\":\"Inbox\",\"show_on_dashboard\":true,\"show_in_sidebar\":true,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[{\"rule_type\":6,\"value\":\"9\"}],\"owner\":\"2\",\"user_can_change\":true,\"page_size\":10,\"display_mode\":\"table\",\"display_fields\":[\"created\",\"title\",\"tag\",\"documenttype\"]},{\"id\":4,\"name\":\"Recently Added\",\"show_on_dashboard\":true,\"show_in_sidebar\":true,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[],\"owner\":\"2\",\"user_can_change\":true,\"page_size\":10,\"display_mode\":\"table\",\"display_fields\":[\"created\",\"title\",\"tag\",\"documenttype\"]},{\"id\":11,\"name\":\"Tag: Another Sample Tag\",\"show_on_dashboard\":false,\"show_in_sidebar\":true,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[{\"rule_type\":6,\"value\":\"4\"}],\"owner\":\"2\",\"user_can_change\":true,\"page_size\":10,\"display_mode\":\"table\",\"display_fields\":[\"created\",\"title\",\"tag\",\"documenttype\"]},{\"id\":15,\"name\":\"View ASN not empty\",\"show_on_dashboard\":false,\"show_in_sidebar\":false,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[{\"rule_type\":18,\"value\":\"false\"}],\"owner\":\"2\",\"user_can_change\":true,\"page_size\":10,\"display_mode\":\"table\",\"display_fields\":[\"created\",\"title\",\"tag\",\"documenttype\"]}]}"
|
||||
},
|
||||
"headersSize": -1,
|
||||
"bodySize": -1,
|
||||
|
@ -124,7 +124,7 @@
|
||||
"content": {
|
||||
"size": -1,
|
||||
"mimeType": "application/json",
|
||||
"text": "{\"count\":6,\"next\":null,\"previous\":null,\"all\":[8,17,7,4,11,15],\"results\":[{\"id\":8,\"name\":\"Correspondent 2\",\"show_on_dashboard\":false,\"show_in_sidebar\":false,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[{\"rule_type\":3,\"value\":\"2\"}],\"owner\":\"2\",\"user_can_change\":true},{\"id\":17,\"name\":\"In the Last Month\",\"show_on_dashboard\":false,\"show_in_sidebar\":false,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[{\"rule_type\":20,\"value\":\"created:[-1 month to now]\"}],\"owner\":\"2\",\"user_can_change\":true},{\"id\":7,\"name\":\"Inbox\",\"show_on_dashboard\":true,\"show_in_sidebar\":true,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[{\"rule_type\":6,\"value\":\"9\"}],\"owner\":\"2\",\"user_can_change\":true},{\"id\":4,\"name\":\"Recently Added\",\"show_on_dashboard\":true,\"show_in_sidebar\":true,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[],\"owner\":\"2\",\"user_can_change\":true},{\"id\":11,\"name\":\"Tag: Another Sample Tag\",\"show_on_dashboard\":false,\"show_in_sidebar\":true,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[{\"rule_type\":6,\"value\":\"4\"}],\"owner\":\"2\",\"user_can_change\":true},{\"id\":15,\"name\":\"View ASN not empty\",\"show_on_dashboard\":false,\"show_in_sidebar\":false,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[{\"rule_type\":18,\"value\":\"false\"}],\"owner\":\"2\",\"user_can_change\":true}]}"
|
||||
"text": "{\"count\":6,\"next\":null,\"previous\":null,\"all\":[8,17,7,4,11,15],\"results\":[{\"id\":8,\"name\":\"Correspondent 2\",\"show_on_dashboard\":false,\"show_in_sidebar\":false,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[{\"rule_type\":3,\"value\":\"2\"}],\"owner\":\"2\",\"user_can_change\":true,\"page_size\":10,\"display_mode\":\"table\",\"display_fields\":[\"created\",\"title\",\"tag\",\"documenttype\"]},{\"id\":17,\"name\":\"In the Last Month\",\"show_on_dashboard\":false,\"show_in_sidebar\":false,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[{\"rule_type\":20,\"value\":\"created:[-1 month to now]\"}],\"owner\":\"2\",\"user_can_change\":true,\"page_size\":10,\"display_mode\":\"table\",\"display_fields\":[\"created\",\"title\",\"tag\",\"documenttype\"]},{\"id\":7,\"name\":\"Inbox\",\"show_on_dashboard\":true,\"show_in_sidebar\":true,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[{\"rule_type\":6,\"value\":\"9\"}],\"owner\":\"2\",\"user_can_change\":true,\"page_size\":10,\"display_mode\":\"table\",\"display_fields\":[\"created\",\"title\",\"tag\",\"documenttype\"]},{\"id\":4,\"name\":\"Recently Added\",\"show_on_dashboard\":true,\"show_in_sidebar\":true,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[],\"owner\":\"2\",\"user_can_change\":true,\"page_size\":10,\"display_mode\":\"table\",\"display_fields\":[\"created\",\"title\",\"tag\",\"documenttype\"]},{\"id\":11,\"name\":\"Tag: Another Sample Tag\",\"show_on_dashboard\":false,\"show_in_sidebar\":true,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[{\"rule_type\":6,\"value\":\"4\"}],\"owner\":\"2\",\"user_can_change\":true,\"page_size\":10,\"display_mode\":\"table\",\"display_fields\":[\"created\",\"title\",\"tag\",\"documenttype\"]},{\"id\":15,\"name\":\"View ASN not empty\",\"show_on_dashboard\":false,\"show_in_sidebar\":false,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[{\"rule_type\":18,\"value\":\"false\"}],\"owner\":\"2\",\"user_can_change\":true,\"page_size\":10,\"display_mode\":\"table\",\"display_fields\":[\"created\",\"title\",\"tag\",\"documenttype\"]}]}"
|
||||
},
|
||||
"headersSize": -1,
|
||||
"bodySize": -1,
|
||||
|
@ -124,7 +124,7 @@
|
||||
"content": {
|
||||
"size": -1,
|
||||
"mimeType": "application/json",
|
||||
"text": "{\"count\":6,\"next\":null,\"previous\":null,\"all\":[8,17,7,4,11,15],\"results\":[{\"id\":8,\"name\":\"Correspondent 2\",\"show_on_dashboard\":false,\"show_in_sidebar\":false,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[{\"rule_type\":3,\"value\":\"2\"}],\"owner\":\"2\",\"user_can_change\":true},{\"id\":17,\"name\":\"In the Last Month\",\"show_on_dashboard\":false,\"show_in_sidebar\":false,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[{\"rule_type\":20,\"value\":\"created:[-1 month to now]\"}],\"owner\":\"2\",\"user_can_change\":true},{\"id\":7,\"name\":\"Inbox\",\"show_on_dashboard\":true,\"show_in_sidebar\":true,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[{\"rule_type\":6,\"value\":\"9\"}],\"owner\":\"2\",\"user_can_change\":true},{\"id\":4,\"name\":\"Recently Added\",\"show_on_dashboard\":true,\"show_in_sidebar\":true,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[],\"owner\":\"2\",\"user_can_change\":true},{\"id\":11,\"name\":\"Tag: Another Sample Tag\",\"show_on_dashboard\":false,\"show_in_sidebar\":true,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[{\"rule_type\":6,\"value\":\"4\"}],\"owner\":\"2\",\"user_can_change\":true},{\"id\":15,\"name\":\"View ASN not empty\",\"show_on_dashboard\":false,\"show_in_sidebar\":false,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[{\"rule_type\":18,\"value\":\"false\"}],\"owner\":\"2\",\"user_can_change\":true}]}"
|
||||
"text": "{\"count\":6,\"next\":null,\"previous\":null,\"all\":[8,17,7,4,11,15],\"results\":[{\"id\":8,\"name\":\"Correspondent 2\",\"show_on_dashboard\":false,\"show_in_sidebar\":false,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[{\"rule_type\":3,\"value\":\"2\"}],\"owner\":\"2\",\"user_can_change\":true,\"page_size\":10,\"display_mode\":\"table\",\"display_fields\":[\"created\",\"title\",\"tag\",\"documenttype\"]},{\"id\":17,\"name\":\"In the Last Month\",\"show_on_dashboard\":false,\"show_in_sidebar\":false,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[{\"rule_type\":20,\"value\":\"created:[-1 month to now]\"}],\"owner\":\"2\",\"user_can_change\":true,\"page_size\":10,\"display_mode\":\"table\",\"display_fields\":[\"created\",\"title\",\"tag\",\"documenttype\"]},{\"id\":7,\"name\":\"Inbox\",\"show_on_dashboard\":true,\"show_in_sidebar\":true,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[{\"rule_type\":6,\"value\":\"9\"}],\"owner\":\"2\",\"user_can_change\":true,\"page_size\":10,\"display_mode\":\"table\",\"display_fields\":[\"created\",\"title\",\"tag\",\"documenttype\"]},{\"id\":4,\"name\":\"Recently Added\",\"show_on_dashboard\":true,\"show_in_sidebar\":true,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[],\"owner\":\"2\",\"user_can_change\":true,\"page_size\":10,\"display_mode\":\"table\",\"display_fields\":[\"created\",\"title\",\"tag\",\"documenttype\"]},{\"id\":11,\"name\":\"Tag: Another Sample Tag\",\"show_on_dashboard\":false,\"show_in_sidebar\":true,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[{\"rule_type\":6,\"value\":\"4\"}],\"owner\":\"2\",\"user_can_change\":true,\"page_size\":10,\"display_mode\":\"table\",\"display_fields\":[\"created\",\"title\",\"tag\",\"documenttype\"]},{\"id\":15,\"name\":\"View ASN not empty\",\"show_on_dashboard\":false,\"show_in_sidebar\":false,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[{\"rule_type\":18,\"value\":\"false\"}],\"owner\":\"2\",\"user_can_change\":true,\"page_size\":10,\"display_mode\":\"table\",\"display_fields\":[\"created\",\"title\",\"tag\",\"documenttype\"]}]}"
|
||||
},
|
||||
"headersSize": -1,
|
||||
"bodySize": -1,
|
||||
|
@ -124,7 +124,7 @@
|
||||
"content": {
|
||||
"size": -1,
|
||||
"mimeType": "application/json",
|
||||
"text": "{\"count\":6,\"next\":null,\"previous\":null,\"all\":[8,17,7,4,11,15],\"results\":[{\"id\":8,\"name\":\"Correspondent 2\",\"show_on_dashboard\":false,\"show_in_sidebar\":false,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[{\"rule_type\":3,\"value\":\"2\"}],\"owner\":\"2\",\"user_can_change\":true},{\"id\":7,\"name\":\"Inbox\",\"show_on_dashboard\":true,\"show_in_sidebar\":true,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[{\"rule_type\":6,\"value\":\"9\"}],\"owner\":\"2\",\"user_can_change\":true},{\"id\":11,\"name\":\"Tag: Another Sample Tag\",\"show_on_dashboard\":false,\"show_in_sidebar\":true,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[{\"rule_type\":6,\"value\":\"4\"}],\"owner\":\"2\",\"user_can_change\":true}]}"
|
||||
"text": "{\"count\":6,\"next\":null,\"previous\":null,\"all\":[8,17,7,4,11,15],\"results\":[{\"id\":8,\"name\":\"Correspondent 2\",\"show_on_dashboard\":false,\"show_in_sidebar\":false,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[{\"rule_type\":3,\"value\":\"2\"}],\"owner\":\"2\",\"user_can_change\":true,\"page_size\":10,\"display_mode\":\"table\",\"display_fields\":[\"created\",\"title\",\"tag\",\"documenttype\"]},{\"id\":17,\"name\":\"In the Last Month\",\"show_on_dashboard\":false,\"show_in_sidebar\":false,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[{\"rule_type\":20,\"value\":\"created:[-1 month to now]\"}],\"owner\":\"2\",\"user_can_change\":true,\"page_size\":10,\"display_mode\":\"table\",\"display_fields\":[\"created\",\"title\",\"tag\",\"documenttype\"]},{\"id\":7,\"name\":\"Inbox\",\"show_on_dashboard\":true,\"show_in_sidebar\":true,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[{\"rule_type\":6,\"value\":\"9\"}],\"owner\":\"2\",\"user_can_change\":true,\"page_size\":10,\"display_mode\":\"table\",\"display_fields\":[\"created\",\"title\",\"tag\",\"documenttype\"]},{\"id\":4,\"name\":\"Recently Added\",\"show_on_dashboard\":true,\"show_in_sidebar\":true,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[],\"owner\":\"2\",\"user_can_change\":true,\"page_size\":10,\"display_mode\":\"table\",\"display_fields\":[\"created\",\"title\",\"tag\",\"documenttype\"]},{\"id\":11,\"name\":\"Tag: Another Sample Tag\",\"show_on_dashboard\":false,\"show_in_sidebar\":true,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[{\"rule_type\":6,\"value\":\"4\"}],\"owner\":\"2\",\"user_can_change\":true,\"page_size\":10,\"display_mode\":\"table\",\"display_fields\":[\"created\",\"title\",\"tag\",\"documenttype\"]},{\"id\":15,\"name\":\"View ASN not empty\",\"show_on_dashboard\":false,\"show_in_sidebar\":false,\"sort_field\":\"created\",\"sort_reverse\":true,\"filter_rules\":[{\"rule_type\":18,\"value\":\"false\"}],\"owner\":\"2\",\"user_can_change\":true,\"page_size\":10,\"display_mode\":\"table\",\"display_fields\":[\"created\",\"title\",\"tag\",\"documenttype\"]}]}"
|
||||
},
|
||||
"headersSize": -1,
|
||||
"bodySize": -1,
|
||||
|
@ -45,8 +45,8 @@ test('basic filtering', async ({ page }) => {
|
||||
test('text filtering', async ({ page }) => {
|
||||
await page.routeFromHAR(REQUESTS_HAR2, { notFound: 'fallback' })
|
||||
await page.goto('/documents')
|
||||
await page.getByRole('textbox').click()
|
||||
await page.getByRole('textbox').fill('test')
|
||||
await page.getByRole('main').getByRole('combobox').click()
|
||||
await page.getByRole('main').getByRole('combobox').fill('test')
|
||||
await expect(page.locator('pngx-document-list')).toHaveText(/32 documents/)
|
||||
await expect(page).toHaveURL(/title_content=test/)
|
||||
await page.getByRole('button', { name: 'Title & content' }).click()
|
||||
@ -59,12 +59,12 @@ test('text filtering', async ({ page }) => {
|
||||
await expect(page.locator('pngx-document-list')).toHaveText(/26 documents/)
|
||||
await page.getByRole('button', { name: 'Advanced search' }).click()
|
||||
await page.getByRole('button', { name: 'ASN' }).click()
|
||||
await page.getByRole('textbox').fill('1123')
|
||||
await page.getByRole('main').getByRole('combobox').nth(1).fill('1123')
|
||||
await expect(page).toHaveURL(/archive_serial_number=1123/)
|
||||
await expect(page.locator('pngx-document-list')).toHaveText(/one document/i)
|
||||
await page.locator('select').selectOption('greater')
|
||||
await page.getByRole('textbox').click()
|
||||
await page.getByRole('textbox').fill('1123')
|
||||
await page.getByRole('main').getByRole('combobox').nth(1).click()
|
||||
await page.getByRole('main').getByRole('combobox').nth(1).fill('1123')
|
||||
await expect(page).toHaveURL(/archive_serial_number__gt=1123/)
|
||||
await expect(page.locator('pngx-document-list')).toHaveText(/5 documents/)
|
||||
await page.locator('select').selectOption('less')
|
||||
@ -81,14 +81,15 @@ test('text filtering', async ({ page }) => {
|
||||
test('date filtering', async ({ page }) => {
|
||||
await page.routeFromHAR(REQUESTS_HAR3, { notFound: 'fallback' })
|
||||
await page.goto('/documents')
|
||||
await page.getByRole('button', { name: 'Created' }).click()
|
||||
await page.getByRole('menuitem', { name: 'Last 3 months' }).click()
|
||||
await page.getByRole('button', { name: 'Dates' }).click()
|
||||
await page.getByRole('menuitem', { name: 'Last 3 months' }).first().click()
|
||||
await expect(page.locator('pngx-document-list')).toHaveText(/one document/i)
|
||||
await page.getByRole('button', { name: 'Created Clear selected' }).click()
|
||||
await page.getByRole('button', { name: 'Created' }).click()
|
||||
await page.getByRole('button', { name: 'Dates Clear selected' }).click()
|
||||
await page.getByRole('button', { name: 'Dates' }).click()
|
||||
await page
|
||||
.getByRole('menuitem', { name: 'After mm/dd/yyyy' })
|
||||
.getByRole('button')
|
||||
.first()
|
||||
.click()
|
||||
await page.getByRole('combobox', { name: 'Select month' }).selectOption('12')
|
||||
await page.getByRole('combobox', { name: 'Select year' }).selectOption('2022')
|
||||
@ -138,11 +139,11 @@ test('sorting', async ({ page }) => {
|
||||
test('change views', async ({ page }) => {
|
||||
await page.routeFromHAR(REQUESTS_HAR5, { notFound: 'fallback' })
|
||||
await page.goto('/documents')
|
||||
await page.locator('pngx-page-header label').first().click()
|
||||
await page.locator('.btn-group label').first().click()
|
||||
await expect(page.locator('pngx-document-list table')).toBeVisible()
|
||||
await page.locator('pngx-page-header label').nth(1).click()
|
||||
await page.locator('.btn-group label').nth(1).click()
|
||||
await expect(page.locator('pngx-document-card-small').first()).toBeAttached()
|
||||
await page.locator('pngx-page-header label').nth(2).click()
|
||||
await page.locator('.btn-group label').nth(2).click()
|
||||
await expect(page.locator('pngx-document-card-large').first()).toBeAttached()
|
||||
})
|
||||
|
||||
|
2274
src-ui/messages.xlf
2274
src-ui/messages.xlf
File diff suppressed because it is too large
Load Diff
264
src-ui/package-lock.json
generated
264
src-ui/package-lock.json
generated
@ -9,15 +9,15 @@
|
||||
"version": "0.0.0",
|
||||
"hasInstallScript": true,
|
||||
"dependencies": {
|
||||
"@angular/cdk": "^17.3.2",
|
||||
"@angular/common": "~17.3.2",
|
||||
"@angular/compiler": "~17.3.2",
|
||||
"@angular/core": "~17.3.2",
|
||||
"@angular/forms": "~17.3.2",
|
||||
"@angular/localize": "~17.3.2",
|
||||
"@angular/platform-browser": "~17.3.2",
|
||||
"@angular/platform-browser-dynamic": "~17.3.2",
|
||||
"@angular/router": "~17.3.2",
|
||||
"@angular/cdk": "^17.3.6",
|
||||
"@angular/common": "~17.3.7",
|
||||
"@angular/compiler": "~17.3.7",
|
||||
"@angular/core": "~17.3.7",
|
||||
"@angular/forms": "~17.3.7",
|
||||
"@angular/localize": "~17.3.7",
|
||||
"@angular/platform-browser": "~17.3.7",
|
||||
"@angular/platform-browser-dynamic": "~17.3.7",
|
||||
"@angular/router": "~17.3.7",
|
||||
"@ng-bootstrap/ng-bootstrap": "^16.0.0",
|
||||
"@ng-select/ng-select": "^12.0.7",
|
||||
"@ngneat/dirty-check-forms": "^3.0.3",
|
||||
@ -38,14 +38,14 @@
|
||||
"zone.js": "^0.14.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@angular-builders/jest": "17.0.2",
|
||||
"@angular-devkit/build-angular": "~17.3.2",
|
||||
"@angular-builders/jest": "17.0.3",
|
||||
"@angular-devkit/build-angular": "~17.3.6",
|
||||
"@angular-eslint/builder": "17.3.0",
|
||||
"@angular-eslint/eslint-plugin": "17.3.0",
|
||||
"@angular-eslint/eslint-plugin-template": "17.3.0",
|
||||
"@angular-eslint/schematics": "17.3.0",
|
||||
"@angular-eslint/template-parser": "17.3.0",
|
||||
"@angular/cli": "~17.3.2",
|
||||
"@angular/cli": "~17.3.6",
|
||||
"@angular/compiler-cli": "~17.3.2",
|
||||
"@playwright/test": "^1.42.1",
|
||||
"@types/jest": "^29.5.12",
|
||||
@ -86,9 +86,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@angular-builders/common": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@angular-builders/common/-/common-1.0.1.tgz",
|
||||
"integrity": "sha512-qPgTjz3ISdGIY+vOIiUzpZRXwchdL/HEhCRzM2QKdqz/c5AB06X9wKhvXezabtzpYSq4lN9fliPYCntqimefFw==",
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@angular-builders/common/-/common-1.0.2.tgz",
|
||||
"integrity": "sha512-lUusRq6jN1It5LcUTLS6Q+AYAYGTo/EEN8hV0M6Ek9qXzweAouJaSEnwv7p04/pD7yJTl0YOCbN79u+wGm3x4g==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@angular-devkit/core": "^17.1.0",
|
||||
@ -100,12 +100,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@angular-builders/jest": {
|
||||
"version": "17.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@angular-builders/jest/-/jest-17.0.2.tgz",
|
||||
"integrity": "sha512-IoEDqudGTpPBhUv0R7TU0eewASVDMIj+pFZTyHCSb2Y17DkE98mo5wp/GXRBl09LO6VK63bHNBB56oXbKG93fA==",
|
||||
"version": "17.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@angular-builders/jest/-/jest-17.0.3.tgz",
|
||||
"integrity": "sha512-LW4s8t+NLnWR7Aud+EZup8dOBfQF8rfOIncsarDtP/48rz/Ucnzvum7xEt/NYAlZ6y/Dpk7wO6SlqAsaOPf8mA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@angular-builders/common": "1.0.1",
|
||||
"@angular-builders/common": "1.0.2",
|
||||
"@angular-devkit/architect": ">=0.1700.0 < 0.1800.0",
|
||||
"@angular-devkit/core": "^17.0.0",
|
||||
"jest-preset-angular": "14.0.3",
|
||||
@ -123,12 +123,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@angular-devkit/architect": {
|
||||
"version": "0.1703.2",
|
||||
"resolved": "https://registry.npmjs.org/@angular-devkit/architect/-/architect-0.1703.2.tgz",
|
||||
"integrity": "sha512-fT5gSzwDHOyGv8zF97t8rjeoYSGSxXjWWstl3rN1nXdO0qgJ5m6Sv0fupON+HltdXDCBLRH+2khNpqx/Fh0Qww==",
|
||||
"version": "0.1703.6",
|
||||
"resolved": "https://registry.npmjs.org/@angular-devkit/architect/-/architect-0.1703.6.tgz",
|
||||
"integrity": "sha512-Ck501FD/QuOjeKVFs7hU92w8+Ffetv0d5Sq09XY2/uygo5c/thMzp9nkevaIWBxUSeU5RqYZizDrhFVgYzbbOw==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@angular-devkit/core": "17.3.2",
|
||||
"@angular-devkit/core": "17.3.6",
|
||||
"rxjs": "7.8.1"
|
||||
},
|
||||
"engines": {
|
||||
@ -138,15 +138,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@angular-devkit/build-angular": {
|
||||
"version": "17.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@angular-devkit/build-angular/-/build-angular-17.3.2.tgz",
|
||||
"integrity": "sha512-muPCUyL0uHvRkLH4NLWiccER6P2vCm/Q5DDvqyN4XOzzY3tAHHLrKrpvY87sgd2oNJ6Ci8x7GPNcfzR5KELCnw==",
|
||||
"version": "17.3.6",
|
||||
"resolved": "https://registry.npmjs.org/@angular-devkit/build-angular/-/build-angular-17.3.6.tgz",
|
||||
"integrity": "sha512-K4CEZvhQZUUOpmXPVoI1YBM8BARbIlqE6FZRxakmnr+YOtVTYE5s+Dr1wgja8hZIohNz6L7j167G9Aut7oPU/w==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@ampproject/remapping": "2.3.0",
|
||||
"@angular-devkit/architect": "0.1703.2",
|
||||
"@angular-devkit/build-webpack": "0.1703.2",
|
||||
"@angular-devkit/core": "17.3.2",
|
||||
"@angular-devkit/architect": "0.1703.6",
|
||||
"@angular-devkit/build-webpack": "0.1703.6",
|
||||
"@angular-devkit/core": "17.3.6",
|
||||
"@babel/core": "7.24.0",
|
||||
"@babel/generator": "7.23.6",
|
||||
"@babel/helper-annotate-as-pure": "7.22.5",
|
||||
@ -157,7 +157,7 @@
|
||||
"@babel/preset-env": "7.24.0",
|
||||
"@babel/runtime": "7.24.0",
|
||||
"@discoveryjs/json-ext": "0.5.7",
|
||||
"@ngtools/webpack": "17.3.2",
|
||||
"@ngtools/webpack": "17.3.6",
|
||||
"@vitejs/plugin-basic-ssl": "1.1.0",
|
||||
"ansi-colors": "4.1.3",
|
||||
"autoprefixer": "10.4.18",
|
||||
@ -198,8 +198,8 @@
|
||||
"terser": "5.29.1",
|
||||
"tree-kill": "1.2.2",
|
||||
"tslib": "2.6.2",
|
||||
"undici": "6.7.1",
|
||||
"vite": "5.1.5",
|
||||
"undici": "6.11.1",
|
||||
"vite": "5.1.7",
|
||||
"watchpack": "2.4.0",
|
||||
"webpack": "5.90.3",
|
||||
"webpack-dev-middleware": "6.1.2",
|
||||
@ -725,12 +725,12 @@
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@angular-devkit/build-webpack": {
|
||||
"version": "0.1703.2",
|
||||
"resolved": "https://registry.npmjs.org/@angular-devkit/build-webpack/-/build-webpack-0.1703.2.tgz",
|
||||
"integrity": "sha512-w7rVFQcZK4iTCd/MLfQWIkDkwBOfAs++txNQyS9qYID8KvLs1V+oWYd2qDBRelRv1u3YtaCIS1pQx3GFKBC3OA==",
|
||||
"version": "0.1703.6",
|
||||
"resolved": "https://registry.npmjs.org/@angular-devkit/build-webpack/-/build-webpack-0.1703.6.tgz",
|
||||
"integrity": "sha512-pJu0et2SiF0kfXenHSTtAART0omzbWpLgBfeUo4hBh4uwX5IaT+mRpYpr8gCXMq+qsjoQp3HobSU3lPDeBn+bg==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@angular-devkit/architect": "0.1703.2",
|
||||
"@angular-devkit/architect": "0.1703.6",
|
||||
"rxjs": "7.8.1"
|
||||
},
|
||||
"engines": {
|
||||
@ -744,9 +744,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@angular-devkit/core": {
|
||||
"version": "17.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-17.3.2.tgz",
|
||||
"integrity": "sha512-1vxKo9+pdSwTOwqPDSYQh84gZYmCJo6OgR5+AZoGLGMZSeqvi9RG5RiUcOMLQYOnuYv0arlhlWxz0ZjyR8ApKw==",
|
||||
"version": "17.3.6",
|
||||
"resolved": "https://registry.npmjs.org/@angular-devkit/core/-/core-17.3.6.tgz",
|
||||
"integrity": "sha512-FVbkT9dEwHEvjnxr4mvMNSMg2bCFoGoP4X68xXU9dhLEUpC05opLvfbaR3Qh543eCJ5AstosBFVzB/krfIkOvA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"ajv": "8.12.0",
|
||||
@ -777,12 +777,12 @@
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@angular-devkit/schematics": {
|
||||
"version": "17.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-17.3.2.tgz",
|
||||
"integrity": "sha512-AYO6oc6QpFGigc1KiDzEVT1CeLnwvnIedU5Q/U3JDZ/Yqmvgc09D64g9XXER2kg6tV7iEgLxiYnonIAQOHq7eA==",
|
||||
"version": "17.3.6",
|
||||
"resolved": "https://registry.npmjs.org/@angular-devkit/schematics/-/schematics-17.3.6.tgz",
|
||||
"integrity": "sha512-2G1YuPInd8znG7uUgKOS7z72Aku50lTzB/2csWkWPJLAFkh7vKC8QZ40x8S1nC9npVYPhI5CRLX/HVpBh9CyxA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@angular-devkit/core": "17.3.2",
|
||||
"@angular-devkit/core": "17.3.6",
|
||||
"jsonc-parser": "3.2.1",
|
||||
"magic-string": "0.30.8",
|
||||
"ora": "5.4.1",
|
||||
@ -1287,9 +1287,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@angular/cdk": {
|
||||
"version": "17.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@angular/cdk/-/cdk-17.3.2.tgz",
|
||||
"integrity": "sha512-mC2U7aoIf7RSpGgIwVyfQEbaPDDX59plQt88KeTz15wjF8vosLt2DG0rZEoV8Mq14YS47J+jI76q/LJfd6/GCw==",
|
||||
"version": "17.3.6",
|
||||
"resolved": "https://registry.npmjs.org/@angular/cdk/-/cdk-17.3.6.tgz",
|
||||
"integrity": "sha512-7eKrC61/6pmMAxllU/vYKadZRF7x7GxUYpA5G70fNaQsIUUiZvxx/SJN9AuZEoPGAtF6atKlJD8QVmFoDzv/Lw==",
|
||||
"dependencies": {
|
||||
"tslib": "^2.3.0"
|
||||
},
|
||||
@ -1303,15 +1303,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@angular/cli": {
|
||||
"version": "17.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@angular/cli/-/cli-17.3.2.tgz",
|
||||
"integrity": "sha512-g6r4XZyGnh9P6GmWgaFh8RmR4L6UdQ408e3SpG3rjncuPRD57Ur8806GfCLPt6HIA9TARiKmaJ0EJ3RsIjag0g==",
|
||||
"version": "17.3.6",
|
||||
"resolved": "https://registry.npmjs.org/@angular/cli/-/cli-17.3.6.tgz",
|
||||
"integrity": "sha512-poKaRPeI+hFqX+AxIaEriaIggFVcC3XqlT9E1/uBC2rfHirE1n5F9Z7xqEDtMHduKwLbNXhQIPoKIKya8+Hnew==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@angular-devkit/architect": "0.1703.2",
|
||||
"@angular-devkit/core": "17.3.2",
|
||||
"@angular-devkit/schematics": "17.3.2",
|
||||
"@schematics/angular": "17.3.2",
|
||||
"@angular-devkit/architect": "0.1703.6",
|
||||
"@angular-devkit/core": "17.3.6",
|
||||
"@angular-devkit/schematics": "17.3.6",
|
||||
"@schematics/angular": "17.3.6",
|
||||
"@yarnpkg/lockfile": "1.1.0",
|
||||
"ansi-colors": "4.1.3",
|
||||
"ini": "4.1.2",
|
||||
@ -1343,9 +1343,9 @@
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@angular/common": {
|
||||
"version": "17.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@angular/common/-/common-17.3.2.tgz",
|
||||
"integrity": "sha512-7fo+hrQEzo+VX0fJAKK+P4YNeiEnpdMOAkyIdwweyAeUZYeFIs6TKtax3CiJAubnkIkhQ/52uxiusDhK3Wg/WQ==",
|
||||
"version": "17.3.7",
|
||||
"resolved": "https://registry.npmjs.org/@angular/common/-/common-17.3.7.tgz",
|
||||
"integrity": "sha512-A7LRJu1vVCGGgrfZXjU+njz50SiU4weheKCar5PIUprcdIofS1IrHAJDqYh+kwXxkjXbZMOr/ijQY0+AESLEsw==",
|
||||
"dependencies": {
|
||||
"tslib": "^2.3.0"
|
||||
},
|
||||
@ -1353,14 +1353,14 @@
|
||||
"node": "^18.13.0 || >=20.9.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@angular/core": "17.3.2",
|
||||
"@angular/core": "17.3.7",
|
||||
"rxjs": "^6.5.3 || ^7.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@angular/compiler": {
|
||||
"version": "17.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@angular/compiler/-/compiler-17.3.2.tgz",
|
||||
"integrity": "sha512-+/l/FQpVsOPbxZzSKyqEra+yxoI/r8LlTRqshVACv10+DKMWJMHnDkVUrNxvWHutfn4RszpGMtbtHp3yM9rxcA==",
|
||||
"version": "17.3.7",
|
||||
"resolved": "https://registry.npmjs.org/@angular/compiler/-/compiler-17.3.7.tgz",
|
||||
"integrity": "sha512-AlKiqPoxnrpQ0hn13fIaQPSVodaVAIjBW4vpFyuKFqs2LBKg6iolwZ21s8rEI0KR2gXl+8ugj0/UZ6YADiVM5w==",
|
||||
"dependencies": {
|
||||
"tslib": "^2.3.0"
|
||||
},
|
||||
@ -1368,7 +1368,7 @@
|
||||
"node": "^18.13.0 || >=20.9.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@angular/core": "17.3.2"
|
||||
"@angular/core": "17.3.7"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@angular/core": {
|
||||
@ -1377,9 +1377,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@angular/compiler-cli": {
|
||||
"version": "17.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@angular/compiler-cli/-/compiler-cli-17.3.2.tgz",
|
||||
"integrity": "sha512-PG81BrJjeF679tkafjt+t9VEBE1rPq39cdLoBTnPY7Q+E/thVoem5JTRG6hmnLmwEc0xxY6sfYpvx2BB5ywUSA==",
|
||||
"version": "17.3.7",
|
||||
"resolved": "https://registry.npmjs.org/@angular/compiler-cli/-/compiler-cli-17.3.7.tgz",
|
||||
"integrity": "sha512-vSg5IQZ9jGmvYjpbfH8KbH4Sl1IVeE+Mr1ogcxkGEsURSRvKo7EWc0K7LSEI9+gg0VLamMiP9EyCJdPxiJeLJQ==",
|
||||
"dependencies": {
|
||||
"@babel/core": "7.23.9",
|
||||
"@jridgewell/sourcemap-codec": "^1.4.14",
|
||||
@ -1399,14 +1399,14 @@
|
||||
"node": "^18.13.0 || >=20.9.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@angular/compiler": "17.3.2",
|
||||
"@angular/compiler": "17.3.7",
|
||||
"typescript": ">=5.2 <5.5"
|
||||
}
|
||||
},
|
||||
"node_modules/@angular/core": {
|
||||
"version": "17.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@angular/core/-/core-17.3.2.tgz",
|
||||
"integrity": "sha512-eylatBGaN8uihKomEcXkaSHmAea5bEqu1OXifEoVOJiJpJA9Dbt/VcLXkIRFnRGH2NWUT5W79vSoU9GRvPMk5w==",
|
||||
"version": "17.3.7",
|
||||
"resolved": "https://registry.npmjs.org/@angular/core/-/core-17.3.7.tgz",
|
||||
"integrity": "sha512-HWcrbxqnvIMSxFuQdN0KPt08bc87hqr0LKm89yuRTUwx/2sNJlNQUobk6aJj4trswGBttcRDT+GOS4DQP2Nr4g==",
|
||||
"dependencies": {
|
||||
"tslib": "^2.3.0"
|
||||
},
|
||||
@ -1419,9 +1419,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@angular/forms": {
|
||||
"version": "17.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@angular/forms/-/forms-17.3.2.tgz",
|
||||
"integrity": "sha512-sbHYjAEeEWW+02YDEKuuuTEUukm6AayQuHiAu37vACj/2q/2RWQar49IoRcSJfAwP2ckqRSK4mmLoDX4IG/KSg==",
|
||||
"version": "17.3.7",
|
||||
"resolved": "https://registry.npmjs.org/@angular/forms/-/forms-17.3.7.tgz",
|
||||
"integrity": "sha512-FEhXh/VmT++XCoO8i7bBtzxG7Am/cE1zrr9aF+fWW+4jpWvJvVN1IaSiJxgBB+iPsOJ9lTBRwfRW3onlcDkhrw==",
|
||||
"dependencies": {
|
||||
"tslib": "^2.3.0"
|
||||
},
|
||||
@ -1429,16 +1429,16 @@
|
||||
"node": "^18.13.0 || >=20.9.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@angular/common": "17.3.2",
|
||||
"@angular/core": "17.3.2",
|
||||
"@angular/platform-browser": "17.3.2",
|
||||
"@angular/common": "17.3.7",
|
||||
"@angular/core": "17.3.7",
|
||||
"@angular/platform-browser": "17.3.7",
|
||||
"rxjs": "^6.5.3 || ^7.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@angular/localize": {
|
||||
"version": "17.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@angular/localize/-/localize-17.3.2.tgz",
|
||||
"integrity": "sha512-8DMdpWqBZwj367jdT2fSnD406wyNP6WD9wmZr1gzDyViGsM6xUM4udbIJHQ+EABkriSKj3usHqZw6LAzO9kepw==",
|
||||
"version": "17.3.7",
|
||||
"resolved": "https://registry.npmjs.org/@angular/localize/-/localize-17.3.7.tgz",
|
||||
"integrity": "sha512-GidwcxquawJBZXNQs6cJ3GvmyowupW9JFkG5sVsS6KG4yu9SIt4FZC+EbrVtYDhXI3U2wxGkm+9vDKvkSGzG0g==",
|
||||
"dependencies": {
|
||||
"@babel/core": "7.23.9",
|
||||
"@types/babel__core": "7.20.5",
|
||||
@ -1454,14 +1454,14 @@
|
||||
"node": "^18.13.0 || >=20.9.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@angular/compiler": "17.3.2",
|
||||
"@angular/compiler-cli": "17.3.2"
|
||||
"@angular/compiler": "17.3.7",
|
||||
"@angular/compiler-cli": "17.3.7"
|
||||
}
|
||||
},
|
||||
"node_modules/@angular/platform-browser": {
|
||||
"version": "17.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-17.3.2.tgz",
|
||||
"integrity": "sha512-rBVmpJ/uh+CTjYef3Nib1K+31GFbM4mZaw2R2PowKZLgWOT3MWXKy41i44NEyM8qY1dxESmzMzy4NuGfZol42Q==",
|
||||
"version": "17.3.7",
|
||||
"resolved": "https://registry.npmjs.org/@angular/platform-browser/-/platform-browser-17.3.7.tgz",
|
||||
"integrity": "sha512-Nn8ZMaftAvO9dEwribWdNv+QBHhYIBrRkv85G6et80AXfXoYAr/xcfnQECRFtZgPmANqHC5auv/xrmExQG+Yeg==",
|
||||
"dependencies": {
|
||||
"tslib": "^2.3.0"
|
||||
},
|
||||
@ -1469,9 +1469,9 @@
|
||||
"node": "^18.13.0 || >=20.9.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@angular/animations": "17.3.2",
|
||||
"@angular/common": "17.3.2",
|
||||
"@angular/core": "17.3.2"
|
||||
"@angular/animations": "17.3.7",
|
||||
"@angular/common": "17.3.7",
|
||||
"@angular/core": "17.3.7"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@angular/animations": {
|
||||
@ -1480,9 +1480,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@angular/platform-browser-dynamic": {
|
||||
"version": "17.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@angular/platform-browser-dynamic/-/platform-browser-dynamic-17.3.2.tgz",
|
||||
"integrity": "sha512-fcGo9yQ+t9VaG9zPgjQW5HIizbYOKj+9kVk9FPru+uJbYyvJUwEDgpD3aI0DUrQy/OvSf4NMzY/Ucgw1AUknQw==",
|
||||
"version": "17.3.7",
|
||||
"resolved": "https://registry.npmjs.org/@angular/platform-browser-dynamic/-/platform-browser-dynamic-17.3.7.tgz",
|
||||
"integrity": "sha512-9c2I4u0L1p2v1/lW8qy+WaNHisUWbyy6wqsv2v9FfCaSM49Lxymgo9LPFPC4qEG5ei5nE+eIQ2ocRiXXsf5QkQ==",
|
||||
"dependencies": {
|
||||
"tslib": "^2.3.0"
|
||||
},
|
||||
@ -1490,16 +1490,16 @@
|
||||
"node": "^18.13.0 || >=20.9.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@angular/common": "17.3.2",
|
||||
"@angular/compiler": "17.3.2",
|
||||
"@angular/core": "17.3.2",
|
||||
"@angular/platform-browser": "17.3.2"
|
||||
"@angular/common": "17.3.7",
|
||||
"@angular/compiler": "17.3.7",
|
||||
"@angular/core": "17.3.7",
|
||||
"@angular/platform-browser": "17.3.7"
|
||||
}
|
||||
},
|
||||
"node_modules/@angular/router": {
|
||||
"version": "17.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@angular/router/-/router-17.3.2.tgz",
|
||||
"integrity": "sha512-BJiaG7zldhe8FPsg3Xv1o2xsmWNMIuntubRiSt2NlSceAr/NEgHoARpZfAGKTaFSngl6jc407wHOmBBPPALECw==",
|
||||
"version": "17.3.7",
|
||||
"resolved": "https://registry.npmjs.org/@angular/router/-/router-17.3.7.tgz",
|
||||
"integrity": "sha512-lMkuRrc1ZjP5JPDxNHqoAhB0uAnfPQ/q6mJrw1s8IZoVV6VyM+FxR5r13ajNcXWC38xy/YhBjpXPF1vBdxuLXg==",
|
||||
"dependencies": {
|
||||
"tslib": "^2.3.0"
|
||||
},
|
||||
@ -1507,9 +1507,9 @@
|
||||
"node": "^18.13.0 || >=20.9.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@angular/common": "17.3.2",
|
||||
"@angular/core": "17.3.2",
|
||||
"@angular/platform-browser": "17.3.2",
|
||||
"@angular/common": "17.3.7",
|
||||
"@angular/core": "17.3.7",
|
||||
"@angular/platform-browser": "17.3.7",
|
||||
"rxjs": "^6.5.3 || ^7.4.0"
|
||||
}
|
||||
},
|
||||
@ -4844,9 +4844,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@ngtools/webpack": {
|
||||
"version": "17.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@ngtools/webpack/-/webpack-17.3.2.tgz",
|
||||
"integrity": "sha512-E8zejFF4aJ8l2XcF+GgnE/1IqsZepnPT1xzulLB4LXtjVuXLFLoF9xkHQwxs7cJWWZsxd/SlNsCIcn/ezrYBcQ==",
|
||||
"version": "17.3.6",
|
||||
"resolved": "https://registry.npmjs.org/@ngtools/webpack/-/webpack-17.3.6.tgz",
|
||||
"integrity": "sha512-equxbgh2DKzZtiFMoVf1KD4yJcH1q8lpqQ/GSPPQUvONcmHrr+yqdRUdaJ7oZCyCYmXF/nByBxtMKtJr6nKZVg==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": "^18.13.0 || >=20.9.0",
|
||||
@ -5604,13 +5604,13 @@
|
||||
]
|
||||
},
|
||||
"node_modules/@schematics/angular": {
|
||||
"version": "17.3.2",
|
||||
"resolved": "https://registry.npmjs.org/@schematics/angular/-/angular-17.3.2.tgz",
|
||||
"integrity": "sha512-zPINvow0Qo6ionnDl25ZzSSLDyDxBjqRPEJWGHU70expbjXK4A2caQT9P/8ImhapbJAXJCfxg4GF9z1d/sWe4w==",
|
||||
"version": "17.3.6",
|
||||
"resolved": "https://registry.npmjs.org/@schematics/angular/-/angular-17.3.6.tgz",
|
||||
"integrity": "sha512-jCNZdjHSVrI8TrrCnCoXC8GYvQRj7zh+SDdmm91Ve8dbikYNmBOKYLuPaCTsmojWx7ytv962yLlgKzpaa2bbfw==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@angular-devkit/core": "17.3.2",
|
||||
"@angular-devkit/schematics": "17.3.2",
|
||||
"@angular-devkit/core": "17.3.6",
|
||||
"@angular-devkit/schematics": "17.3.6",
|
||||
"jsonc-parser": "3.2.1"
|
||||
},
|
||||
"engines": {
|
||||
@ -5935,9 +5935,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@types/express-serve-static-core": {
|
||||
"version": "4.17.43",
|
||||
"resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.17.43.tgz",
|
||||
"integrity": "sha512-oaYtiBirUOPQGSWNGPWnzyAFJ0BP3cwvN4oWZQY+zUBwpVIGsKUkpBpSztp74drYcjavs7SKFZ4DX1V2QeN8rg==",
|
||||
"version": "4.19.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-4.19.0.tgz",
|
||||
"integrity": "sha512-bGyep3JqPCRry1wq+O5n7oiBgGWmeIJXPjXXCo8EK0u8duZGSYar7cGqd3ML2JUsLGeB7fmc06KYo9fLGWqPvQ==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@types/node": "*",
|
||||
@ -6046,9 +6046,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@types/qs": {
|
||||
"version": "6.9.14",
|
||||
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.14.tgz",
|
||||
"integrity": "sha512-5khscbd3SwWMhFqylJBLQ0zIu7c1K6Vz0uBIt915BI3zV0q1nfjRQD3RqSBcPaO6PHEF4ov/t9y89fSiyThlPA==",
|
||||
"version": "6.9.15",
|
||||
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.9.15.tgz",
|
||||
"integrity": "sha512-uXHQKES6DQKKCLh441Xv/dwxOq1TVS3JPUMlEqoEglvlhR6Mxnlew/Xq/LRVHpLyk7iK3zODe1qYHIMltO7XGg==",
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/@types/range-parser": {
|
||||
@ -6089,14 +6089,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@types/serve-static": {
|
||||
"version": "1.15.5",
|
||||
"resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.5.tgz",
|
||||
"integrity": "sha512-PDRk21MnK70hja/YF8AHfC7yIsiQHn1rcXx7ijCFBX/k+XQJhQT/gw3xekXKJvx+5SXaMMS8oqQy09Mzvz2TuQ==",
|
||||
"version": "1.15.7",
|
||||
"resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-1.15.7.tgz",
|
||||
"integrity": "sha512-W8Ym+h8nhuRwaKPaDw34QUkwsGi6Rc4yYqvKFo5rm2FUEhCFbzVWrxXUxuKK8TASjWsysJY0nsmNCGhCOIsrOw==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"@types/http-errors": "*",
|
||||
"@types/mime": "*",
|
||||
"@types/node": "*"
|
||||
"@types/node": "*",
|
||||
"@types/send": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/sockjs": {
|
||||
@ -9100,9 +9100,9 @@
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/ejs": {
|
||||
"version": "3.1.9",
|
||||
"resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.9.tgz",
|
||||
"integrity": "sha512-rC+QVNMJWv+MtPgkt0y+0rVEIdbtxVADApW9JXrUVlzHetgcyczP/E7DJmWJ4fJCZF2cPcBk0laWO9ZHMG3DmQ==",
|
||||
"version": "3.1.10",
|
||||
"resolved": "https://registry.npmjs.org/ejs/-/ejs-3.1.10.tgz",
|
||||
"integrity": "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"jake": "^10.8.5"
|
||||
@ -11056,9 +11056,9 @@
|
||||
"dev": true
|
||||
},
|
||||
"node_modules/ipaddr.js": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.1.0.tgz",
|
||||
"integrity": "sha512-LlbxQ7xKzfBusov6UMi4MFpEg0m+mAm9xyNGEduwXMEDuf4WfzB/RZwMVYEd7IKGvh4IUkEXYxtAVu9T3OelJQ==",
|
||||
"version": "2.2.0",
|
||||
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-2.2.0.tgz",
|
||||
"integrity": "sha512-Ag3wB2o37wslZS19hZqorUnrnzSkpOVy+IiiDEiTqNubEYpYuHWIf6K4psgN2ZWKExS4xhVCrRVfb/wfW8fWJA==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">= 10"
|
||||
@ -17531,9 +17531,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/tar": {
|
||||
"version": "6.2.0",
|
||||
"resolved": "https://registry.npmjs.org/tar/-/tar-6.2.0.tgz",
|
||||
"integrity": "sha512-/Wo7DcT0u5HUV486xg675HtjNd3BXZ6xDbzsCUZPt5iw8bTQ63bP0Raut3mvro9u+CUyq7YQd8Cx55fsZXxqLQ==",
|
||||
"version": "6.2.1",
|
||||
"resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz",
|
||||
"integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==",
|
||||
"devOptional": true,
|
||||
"dependencies": {
|
||||
"chownr": "^2.0.0",
|
||||
@ -18066,9 +18066,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/undici": {
|
||||
"version": "6.7.1",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-6.7.1.tgz",
|
||||
"integrity": "sha512-+Wtb9bAQw6HYWzCnxrPTMVEV3Q1QjYanI0E4q02ehReMuquQdLTEFEYbfs7hcImVYKcQkWSwT6buEmSVIiDDtQ==",
|
||||
"version": "6.11.1",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-6.11.1.tgz",
|
||||
"integrity": "sha512-KyhzaLJnV1qa3BSHdj4AZ2ndqI0QWPxYzaIOio0WzcEJB9gvuysprJSLtpvc2D9mhR9jPDUk7xlJlZbH2KR5iw==",
|
||||
"dev": true,
|
||||
"engines": {
|
||||
"node": ">=18.0"
|
||||
@ -18295,9 +18295,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "5.1.5",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-5.1.5.tgz",
|
||||
"integrity": "sha512-BdN1xh0Of/oQafhU+FvopafUp6WaYenLU/NFoL5WyJL++GxkNfieKzBhM24H3HVsPQrlAqB7iJYTHabzaRed5Q==",
|
||||
"version": "5.1.7",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-5.1.7.tgz",
|
||||
"integrity": "sha512-sgnEEFTZYMui/sTlH1/XEnVNHMujOahPLGMxn1+5sIT45Xjng1Ec1K78jRP15dSmVgg5WBin9yO81j3o9OxofA==",
|
||||
"dev": true,
|
||||
"dependencies": {
|
||||
"esbuild": "^0.19.3",
|
||||
|
@ -11,15 +11,15 @@
|
||||
},
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@angular/cdk": "^17.3.2",
|
||||
"@angular/common": "~17.3.2",
|
||||
"@angular/compiler": "~17.3.2",
|
||||
"@angular/core": "~17.3.2",
|
||||
"@angular/forms": "~17.3.2",
|
||||
"@angular/localize": "~17.3.2",
|
||||
"@angular/platform-browser": "~17.3.2",
|
||||
"@angular/platform-browser-dynamic": "~17.3.2",
|
||||
"@angular/router": "~17.3.2",
|
||||
"@angular/cdk": "^17.3.6",
|
||||
"@angular/common": "~17.3.7",
|
||||
"@angular/compiler": "~17.3.7",
|
||||
"@angular/core": "~17.3.7",
|
||||
"@angular/forms": "~17.3.7",
|
||||
"@angular/localize": "~17.3.7",
|
||||
"@angular/platform-browser": "~17.3.7",
|
||||
"@angular/platform-browser-dynamic": "~17.3.7",
|
||||
"@angular/router": "~17.3.7",
|
||||
"@ng-bootstrap/ng-bootstrap": "^16.0.0",
|
||||
"@ng-select/ng-select": "^12.0.7",
|
||||
"@ngneat/dirty-check-forms": "^3.0.3",
|
||||
@ -40,14 +40,14 @@
|
||||
"zone.js": "^0.14.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@angular-builders/jest": "17.0.2",
|
||||
"@angular-devkit/build-angular": "~17.3.2",
|
||||
"@angular-builders/jest": "17.0.3",
|
||||
"@angular-devkit/build-angular": "~17.3.6",
|
||||
"@angular-eslint/builder": "17.3.0",
|
||||
"@angular-eslint/eslint-plugin": "17.3.0",
|
||||
"@angular-eslint/eslint-plugin-template": "17.3.0",
|
||||
"@angular-eslint/schematics": "17.3.0",
|
||||
"@angular-eslint/template-parser": "17.3.0",
|
||||
"@angular/cli": "~17.3.2",
|
||||
"@angular/cli": "~17.3.6",
|
||||
"@angular/compiler-cli": "~17.3.2",
|
||||
"@playwright/test": "^1.42.1",
|
||||
"@types/jest": "^29.5.12",
|
||||
|
@ -85,6 +85,7 @@ const mock = () => {
|
||||
}
|
||||
}
|
||||
|
||||
Object.defineProperty(window, 'open', { value: jest.fn() })
|
||||
Object.defineProperty(window, 'localStorage', { value: mock() })
|
||||
Object.defineProperty(window, 'sessionStorage', { value: mock() })
|
||||
Object.defineProperty(window, 'getComputedStyle', {
|
||||
|
@ -141,10 +141,7 @@ export const routes: Routes = [
|
||||
component: LogsComponent,
|
||||
canActivate: [PermissionsGuard],
|
||||
data: {
|
||||
requiredPermission: {
|
||||
action: PermissionAction.View,
|
||||
type: PermissionType.Admin,
|
||||
},
|
||||
requireAdmin: true,
|
||||
},
|
||||
},
|
||||
// redirect old paths
|
||||
|
@ -5,8 +5,7 @@ import {
|
||||
fakeAsync,
|
||||
tick,
|
||||
} from '@angular/core/testing'
|
||||
import { Router } from '@angular/router'
|
||||
import { RouterTestingModule } from '@angular/router/testing'
|
||||
import { Router, RouterModule } from '@angular/router'
|
||||
import { TourService, TourNgBootstrapModule } from 'ngx-ui-tour-ng-bootstrap'
|
||||
import { Subject } from 'rxjs'
|
||||
import { routes } from './app-routing.module'
|
||||
@ -21,6 +20,10 @@ import { ToastService, Toast } from './services/toast.service'
|
||||
import { SettingsService } from './services/settings.service'
|
||||
import { FileDropComponent } from './components/file-drop/file-drop.component'
|
||||
import { NgxFileDropModule } from 'ngx-file-drop'
|
||||
import { NgbModalModule } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { HotKeyService } from './services/hot-key.service'
|
||||
import { PermissionsGuard } from './guards/permissions.guard'
|
||||
import { DirtySavedViewGuard } from './guards/dirty-saved-view.guard'
|
||||
|
||||
describe('AppComponent', () => {
|
||||
let component: AppComponent
|
||||
@ -31,16 +34,18 @@ describe('AppComponent', () => {
|
||||
let toastService: ToastService
|
||||
let router: Router
|
||||
let settingsService: SettingsService
|
||||
let hotKeyService: HotKeyService
|
||||
|
||||
beforeEach(async () => {
|
||||
TestBed.configureTestingModule({
|
||||
declarations: [AppComponent, ToastsComponent, FileDropComponent],
|
||||
providers: [],
|
||||
providers: [PermissionsGuard, DirtySavedViewGuard],
|
||||
imports: [
|
||||
HttpClientTestingModule,
|
||||
TourNgBootstrapModule,
|
||||
RouterTestingModule.withRoutes(routes),
|
||||
RouterModule.forRoot(routes),
|
||||
NgxFileDropModule,
|
||||
NgbModalModule,
|
||||
],
|
||||
}).compileComponents()
|
||||
|
||||
@ -50,6 +55,7 @@ describe('AppComponent', () => {
|
||||
settingsService = TestBed.inject(SettingsService)
|
||||
toastService = TestBed.inject(ToastService)
|
||||
router = TestBed.inject(Router)
|
||||
hotKeyService = TestBed.inject(HotKeyService)
|
||||
fixture = TestBed.createComponent(AppComponent)
|
||||
component = fixture.componentInstance
|
||||
})
|
||||
@ -139,4 +145,20 @@ describe('AppComponent', () => {
|
||||
fileStatusSubject.next(new FileStatus())
|
||||
expect(toastSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should support hotkeys', () => {
|
||||
const addShortcutSpy = jest.spyOn(hotKeyService, 'addShortcut')
|
||||
const routerSpy = jest.spyOn(router, 'navigate')
|
||||
// prevent actual navigation
|
||||
routerSpy.mockReturnValue(new Promise(() => {}))
|
||||
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
|
||||
component.ngOnInit()
|
||||
expect(addShortcutSpy).toHaveBeenCalled()
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'h' }))
|
||||
expect(routerSpy).toHaveBeenCalledWith(['/dashboard'])
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'd' }))
|
||||
expect(routerSpy).toHaveBeenCalledWith(['/documents'])
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', { key: 's' }))
|
||||
expect(routerSpy).toHaveBeenCalledWith(['/settings'])
|
||||
})
|
||||
})
|
||||
|
@ -12,6 +12,7 @@ import {
|
||||
PermissionsService,
|
||||
PermissionType,
|
||||
} from './services/permissions.service'
|
||||
import { HotKeyService } from './services/hot-key.service'
|
||||
|
||||
@Component({
|
||||
selector: 'pngx-root',
|
||||
@ -31,7 +32,8 @@ export class AppComponent implements OnInit, OnDestroy {
|
||||
private tasksService: TasksService,
|
||||
public tourService: TourService,
|
||||
private renderer: Renderer2,
|
||||
private permissionsService: PermissionsService
|
||||
private permissionsService: PermissionsService,
|
||||
private hotKeyService: HotKeyService
|
||||
) {
|
||||
this.settings.updateAppearanceSettings()
|
||||
}
|
||||
@ -123,6 +125,36 @@ export class AppComponent implements OnInit, OnDestroy {
|
||||
}
|
||||
})
|
||||
|
||||
this.hotKeyService
|
||||
.addShortcut({ keys: 'h', description: $localize`Dashboard` })
|
||||
.subscribe(() => {
|
||||
this.router.navigate(['/dashboard'])
|
||||
})
|
||||
if (
|
||||
this.permissionsService.currentUserCan(
|
||||
PermissionAction.View,
|
||||
PermissionType.Document
|
||||
)
|
||||
) {
|
||||
this.hotKeyService
|
||||
.addShortcut({ keys: 'd', description: $localize`Documents` })
|
||||
.subscribe(() => {
|
||||
this.router.navigate(['/documents'])
|
||||
})
|
||||
}
|
||||
if (
|
||||
this.permissionsService.currentUserCan(
|
||||
PermissionAction.Change,
|
||||
PermissionType.UISettings
|
||||
)
|
||||
) {
|
||||
this.hotKeyService
|
||||
.addShortcut({ keys: 's', description: $localize`Settings` })
|
||||
.subscribe(() => {
|
||||
this.router.navigate(['/settings'])
|
||||
})
|
||||
}
|
||||
|
||||
const prevBtnTitle = $localize`Prev`
|
||||
const nextBtnTitle = $localize`Next`
|
||||
const endBtnTitle = $localize`End`
|
||||
|
@ -31,7 +31,7 @@ import { ToastsComponent } from './components/common/toasts/toasts.component'
|
||||
import { FilterEditorComponent } from './components/document-list/filter-editor/filter-editor.component'
|
||||
import { FilterableDropdownComponent } from './components/common/filterable-dropdown/filterable-dropdown.component'
|
||||
import { ToggleableDropdownButtonComponent } from './components/common/filterable-dropdown/toggleable-dropdown-button/toggleable-dropdown-button.component'
|
||||
import { DateDropdownComponent } from './components/common/date-dropdown/date-dropdown.component'
|
||||
import { DatesDropdownComponent } from './components/common/dates-dropdown/dates-dropdown.component'
|
||||
import { DocumentCardLargeComponent } from './components/document-list/document-card-large/document-card-large.component'
|
||||
import { DocumentCardSmallComponent } from './components/document-list/document-card-small/document-card-small.component'
|
||||
import { BulkEditorComponent } from './components/document-list/bulk-editor/bulk-editor.component'
|
||||
@ -119,6 +119,11 @@ import { NgxFilesizeModule } from 'ngx-filesize'
|
||||
import { RotateConfirmDialogComponent } from './components/common/confirm-dialog/rotate-confirm-dialog/rotate-confirm-dialog.component'
|
||||
import { MergeConfirmDialogComponent } from './components/common/confirm-dialog/merge-confirm-dialog/merge-confirm-dialog.component'
|
||||
import { SplitConfirmDialogComponent } from './components/common/confirm-dialog/split-confirm-dialog/split-confirm-dialog.component'
|
||||
import { DocumentHistoryComponent } from './components/document-history/document-history.component'
|
||||
import { DragDropSelectComponent } from './components/common/input/drag-drop-select/drag-drop-select.component'
|
||||
import { CustomFieldDisplayComponent } from './components/common/custom-field-display/custom-field-display.component'
|
||||
import { GlobalSearchComponent } from './components/app-frame/global-search/global-search.component'
|
||||
import { HotkeyDialogComponent } from './components/common/hotkey-dialog/hotkey-dialog.component'
|
||||
import {
|
||||
airplane,
|
||||
archive,
|
||||
@ -137,7 +142,9 @@ import {
|
||||
boxes,
|
||||
calendar,
|
||||
calendarEvent,
|
||||
calendarEventFill,
|
||||
cardChecklist,
|
||||
cardHeading,
|
||||
caretDown,
|
||||
caretUp,
|
||||
chatLeftText,
|
||||
@ -158,6 +165,7 @@ import {
|
||||
doorOpen,
|
||||
download,
|
||||
envelope,
|
||||
envelopeAt,
|
||||
exclamationCircleFill,
|
||||
exclamationTriangle,
|
||||
exclamationTriangleFill,
|
||||
@ -191,6 +199,7 @@ import {
|
||||
personFill,
|
||||
personFillLock,
|
||||
personLock,
|
||||
personSquare,
|
||||
plus,
|
||||
plusCircle,
|
||||
questionCircle,
|
||||
@ -201,6 +210,7 @@ import {
|
||||
sortAlphaDown,
|
||||
sortAlphaUpAlt,
|
||||
tagFill,
|
||||
tag,
|
||||
tags,
|
||||
textIndentLeft,
|
||||
textLeft,
|
||||
@ -231,7 +241,9 @@ const icons = {
|
||||
boxes,
|
||||
calendar,
|
||||
calendarEvent,
|
||||
calendarEventFill,
|
||||
cardChecklist,
|
||||
cardHeading,
|
||||
caretDown,
|
||||
caretUp,
|
||||
chatLeftText,
|
||||
@ -252,6 +264,7 @@ const icons = {
|
||||
doorOpen,
|
||||
download,
|
||||
envelope,
|
||||
envelopeAt,
|
||||
exclamationCircleFill,
|
||||
exclamationTriangle,
|
||||
exclamationTriangleFill,
|
||||
@ -285,6 +298,7 @@ const icons = {
|
||||
personFill,
|
||||
personFillLock,
|
||||
personLock,
|
||||
personSquare,
|
||||
plus,
|
||||
plusCircle,
|
||||
questionCircle,
|
||||
@ -295,6 +309,7 @@ const icons = {
|
||||
sortAlphaDown,
|
||||
sortAlphaUpAlt,
|
||||
tagFill,
|
||||
tag,
|
||||
tags,
|
||||
textIndentLeft,
|
||||
textLeft,
|
||||
@ -402,7 +417,7 @@ function initializeApp(settings: SettingsService) {
|
||||
FilterEditorComponent,
|
||||
FilterableDropdownComponent,
|
||||
ToggleableDropdownButtonComponent,
|
||||
DateDropdownComponent,
|
||||
DatesDropdownComponent,
|
||||
DocumentCardLargeComponent,
|
||||
DocumentCardSmallComponent,
|
||||
BulkEditorComponent,
|
||||
@ -472,6 +487,11 @@ function initializeApp(settings: SettingsService) {
|
||||
RotateConfirmDialogComponent,
|
||||
MergeConfirmDialogComponent,
|
||||
SplitConfirmDialogComponent,
|
||||
DocumentHistoryComponent,
|
||||
DragDropSelectComponent,
|
||||
CustomFieldDisplayComponent,
|
||||
GlobalSearchComponent,
|
||||
HotkeyDialogComponent,
|
||||
],
|
||||
imports: [
|
||||
BrowserModule,
|
||||
|
@ -7,29 +7,30 @@
|
||||
<button class="btn btn-sm btn-outline-primary" (click)="tourService.start()">
|
||||
<i-bs class="me-1" name="airplane"></i-bs> <ng-container i18n>Start tour</ng-container>
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-primary position-relative ms-md-5 me-1" (click)="showSystemStatus()"
|
||||
[disabled]="!systemStatus"
|
||||
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Admin }">
|
||||
@if (!systemStatus) {
|
||||
<div class="spinner-border spinner-border-sm me-1 h-75" role="status"></div>
|
||||
} @else {
|
||||
<i-bs class="me-2" name="card-checklist"></i-bs>
|
||||
@if (systemStatusHasErrors) {
|
||||
<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>
|
||||
</span>
|
||||
@if (permissionsService.isAdmin()) {
|
||||
<button class="btn btn-sm btn-outline-primary position-relative ms-md-5 me-1" (click)="showSystemStatus()"
|
||||
[disabled]="!systemStatus">
|
||||
@if (!systemStatus) {
|
||||
<div class="spinner-border spinner-border-sm me-1 h-75" role="status"></div>
|
||||
} @else {
|
||||
<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>
|
||||
</span>
|
||||
<i-bs class="me-2" name="card-checklist"></i-bs>
|
||||
@if (systemStatusHasErrors) {
|
||||
<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>
|
||||
</span>
|
||||
} @else {
|
||||
<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>
|
||||
</span>
|
||||
}
|
||||
}
|
||||
}
|
||||
<ng-container i18n>System Status</ng-container>
|
||||
</button>
|
||||
<a *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Admin }" class="btn btn-sm btn-primary" href="admin/" target="_blank">
|
||||
<ng-container i18n>Open Django Admin</ng-container>
|
||||
<i-bs name="arrow-up-right"></i-bs>
|
||||
</a>
|
||||
<ng-container i18n>System Status</ng-container>
|
||||
</button>
|
||||
<a class="btn btn-sm btn-primary" href="admin/" target="_blank">
|
||||
<ng-container i18n>Open Django Admin</ng-container>
|
||||
<i-bs name="arrow-up-right"></i-bs>
|
||||
</a>
|
||||
}
|
||||
</pngx-page-header>
|
||||
|
||||
<form [formGroup]="settingsForm" (ngSubmit)="saveSettings()">
|
||||
@ -196,6 +197,14 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h4 class="mt-4" i18n>Global search</h4>
|
||||
|
||||
<div class="row mb-3">
|
||||
<div class="offset-md-3 col">
|
||||
<pngx-input-check i18n-title title="Search database only (do not include advanced search results)" formControlName="searchDbOnly"></pngx-input-check>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<h4 class="mt-4" i18n>Notes</h4>
|
||||
|
||||
<div class="row mb-3">
|
||||
@ -319,52 +328,71 @@
|
||||
</div>
|
||||
|
||||
<h4 i18n>Views</h4>
|
||||
<div formGroupName="savedViews">
|
||||
<ul class="list-group" formGroupName="savedViews">
|
||||
|
||||
@for (view of savedViews; track view) {
|
||||
<li class="list-group-item py-3">
|
||||
<div [formGroupName]="view.id" class="row">
|
||||
<div class="mb-3 col">
|
||||
<label class="form-label" for="name_{{view.id}}" i18n>Name</label>
|
||||
<input type="text" class="form-control" formControlName="name" id="name_{{view.id}}">
|
||||
</div>
|
||||
<div class="mb-2 col">
|
||||
<label class="form-label" for="show_on_dashboard_{{view.id}}" i18n> <span class="visually-hidden">Appears on</span></label>
|
||||
<div class="form-check form-switch">
|
||||
<input type="checkbox" class="form-check-input" id="show_on_dashboard_{{view.id}}" formControlName="show_on_dashboard">
|
||||
<label class="form-check-label" for="show_on_dashboard_{{view.id}}" i18n>Show on dashboard</label>
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<pngx-input-text title="Name" formControlName="name"></pngx-input-text>
|
||||
</div>
|
||||
<div class="form-check form-switch">
|
||||
<input type="checkbox" class="form-check-input" id="show_in_sidebar_{{view.id}}" formControlName="show_in_sidebar">
|
||||
<label class="form-check-label" for="show_in_sidebar_{{view.id}}" i18n>Show in sidebar</label>
|
||||
<div class="col">
|
||||
<div class="form-check form-switch mt-3">
|
||||
<input type="checkbox" class="form-check-input" id="show_on_dashboard_{{view.id}}" formControlName="show_on_dashboard">
|
||||
<label class="form-check-label" for="show_on_dashboard_{{view.id}}" i18n>Show on dashboard</label>
|
||||
</div>
|
||||
<div class="form-check form-switch">
|
||||
<input type="checkbox" class="form-check-input" id="show_in_sidebar_{{view.id}}" formControlName="show_in_sidebar">
|
||||
<label class="form-check-label" for="show_in_sidebar_{{view.id}}" i18n>Show in sidebar</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-auto">
|
||||
<label class="form-label" for="name_{{view.id}}" i18n>Actions</label>
|
||||
<pngx-confirm-button
|
||||
label="Delete"
|
||||
i18n-label
|
||||
(confirm)="deleteSavedView(view)"
|
||||
*pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.SavedView }"
|
||||
buttonClasses="btn-sm btn-outline-danger form-control"
|
||||
iconName="trash">
|
||||
</pngx-confirm-button>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mb-2 col-auto">
|
||||
<label class="form-label" for="name_{{view.id}}" i18n>Actions</label>
|
||||
|
||||
<pngx-confirm-button
|
||||
label="Delete"
|
||||
i18n-label
|
||||
(confirm)="deleteSavedView(view)"
|
||||
*pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.SavedView }"
|
||||
buttonClasses="btn-sm btn-outline-danger form-control"
|
||||
iconName="trash">
|
||||
</pngx-confirm-button>
|
||||
</div>
|
||||
<div class="row">
|
||||
<div class="col">
|
||||
<pngx-input-number i18n-title title="Documents page size" [showAdd]="false" formControlName="page_size"></pngx-input-number>
|
||||
</div>
|
||||
<div class="col">
|
||||
<label class="form-label" for="display_mode_{{view.id}}" i18n>Display as</label>
|
||||
<select class="form-select" formControlName="display_mode">
|
||||
<option [ngValue]="DisplayMode.TABLE" i18n>Table</option>
|
||||
<option [ngValue]="DisplayMode.SMALL_CARDS" i18n>Small Cards</option>
|
||||
<option [ngValue]="DisplayMode.LARGE_CARDS" i18n>Large Cards</option>
|
||||
</select>
|
||||
</div>
|
||||
@if (displayFields) {
|
||||
<pngx-input-drag-drop-select i18n-title title="Show" i18n-emptyText emptyText="Default" [items]="displayFields" formControlName="display_fields"></pngx-input-drag-drop-select>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
}
|
||||
|
||||
@if (savedViews && savedViews.length === 0) {
|
||||
<div i18n>No saved views defined.</div>
|
||||
<li class="list-group-item">
|
||||
<div i18n>No saved views defined.</div>
|
||||
</li>
|
||||
}
|
||||
|
||||
@if (!savedViews) {
|
||||
<div>
|
||||
<li class="list-group-item">
|
||||
<div class="spinner-border spinner-border-sm fw-normal ms-2 me-auto" role="status"></div>
|
||||
<div class="visually-hidden" i18n>Loading...</div>
|
||||
</div>
|
||||
</li>
|
||||
}
|
||||
|
||||
</div>
|
||||
</ul>
|
||||
|
||||
</ng-template>
|
||||
</li>
|
||||
@ -373,4 +401,5 @@
|
||||
<div [ngbNavOutlet]="nav" class="border-start border-end border-bottom p-3 mb-3 shadow-sm"></div>
|
||||
|
||||
<button type="submit" class="btn btn-primary mb-2" [disabled]="(isDirty$ | async) === false" i18n>Save</button>
|
||||
<button type="button" (click)="reset()" class="btn btn-secondary ms-2 mb-2" [disabled]="(isDirty$ | async) === false" i18n>Cancel</button>
|
||||
</form>
|
||||
|
@ -48,6 +48,8 @@ import {
|
||||
InstallType,
|
||||
SystemStatusItemStatus,
|
||||
} from 'src/app/data/system-status'
|
||||
import { DragDropSelectComponent } from '../../common/input/drag-drop-select/drag-drop-select.component'
|
||||
import { DragDropModule } from '@angular/cdk/drag-drop'
|
||||
|
||||
const savedViews = [
|
||||
{ id: 1, name: 'view1', show_in_sidebar: true, show_on_dashboard: true },
|
||||
@ -96,6 +98,7 @@ describe('SettingsComponent', () => {
|
||||
PermissionsGroupComponent,
|
||||
IfOwnerDirective,
|
||||
ConfirmButtonComponent,
|
||||
DragDropSelectComponent,
|
||||
],
|
||||
providers: [CustomDatePipe, DatePipe, PermissionsGuard],
|
||||
imports: [
|
||||
@ -108,6 +111,7 @@ describe('SettingsComponent', () => {
|
||||
NgSelectModule,
|
||||
NgxBootstrapIconsModule.pick(allIcons),
|
||||
NgbModalModule,
|
||||
DragDropModule,
|
||||
],
|
||||
}).compileComponents()
|
||||
|
||||
@ -305,7 +309,7 @@ describe('SettingsComponent', () => {
|
||||
expect(toastErrorSpy).toHaveBeenCalled()
|
||||
expect(storeSpy).toHaveBeenCalled()
|
||||
expect(appearanceSettingsSpy).not.toHaveBeenCalled()
|
||||
expect(setSpy).toHaveBeenCalledTimes(25)
|
||||
expect(setSpy).toHaveBeenCalledTimes(26)
|
||||
|
||||
// succeed
|
||||
storeSpy.mockReturnValueOnce(of(true))
|
||||
@ -418,6 +422,7 @@ describe('SettingsComponent', () => {
|
||||
},
|
||||
}
|
||||
jest.spyOn(systemStatusService, 'get').mockReturnValue(of(status))
|
||||
jest.spyOn(permissionsService, 'isAdmin').mockReturnValue(true)
|
||||
completeSetup()
|
||||
expect(component['systemStatus']).toEqual(status) // private
|
||||
expect(component.systemStatusHasErrors).toBeTruthy()
|
||||
@ -436,4 +441,11 @@ describe('SettingsComponent', () => {
|
||||
size: 'xl',
|
||||
})
|
||||
})
|
||||
|
||||
it('should support reset', () => {
|
||||
completeSetup()
|
||||
component.settingsForm.get('themeColor').setValue('#ff0000')
|
||||
component.reset()
|
||||
expect(component.settingsForm.get('themeColor').value).toEqual('')
|
||||
})
|
||||
})
|
||||
|
@ -50,6 +50,7 @@ import {
|
||||
SystemStatusItemStatus,
|
||||
SystemStatus,
|
||||
} from 'src/app/data/system-status'
|
||||
import { DisplayMode } from 'src/app/data/document'
|
||||
|
||||
enum SettingsNavIDs {
|
||||
General = 1,
|
||||
@ -73,8 +74,8 @@ export class SettingsComponent
|
||||
extends ComponentWithPermissions
|
||||
implements OnInit, AfterViewInit, OnDestroy, DirtyComponent
|
||||
{
|
||||
SettingsNavIDs = SettingsNavIDs
|
||||
activeNavID: number
|
||||
DisplayMode = DisplayMode
|
||||
|
||||
savedViewGroup = new FormGroup({})
|
||||
|
||||
@ -99,6 +100,7 @@ export class SettingsComponent
|
||||
defaultPermsEditUsers: new FormControl(null),
|
||||
defaultPermsEditGroups: new FormControl(null),
|
||||
documentEditingRemoveInboxTags: new FormControl(null),
|
||||
searchDbOnly: new FormControl(null),
|
||||
|
||||
notificationsConsumerNewDocument: new FormControl(null),
|
||||
notificationsConsumerSuccess: new FormControl(null),
|
||||
@ -110,6 +112,10 @@ export class SettingsComponent
|
||||
})
|
||||
|
||||
savedViews: SavedView[]
|
||||
SettingsNavIDs = SettingsNavIDs
|
||||
get displayFields() {
|
||||
return this.settings.allDisplayFields
|
||||
}
|
||||
|
||||
store: BehaviorSubject<any>
|
||||
storeSub: Subscription
|
||||
@ -121,7 +127,7 @@ export class SettingsComponent
|
||||
users: User[]
|
||||
groups: Group[]
|
||||
|
||||
private systemStatus: SystemStatus
|
||||
public systemStatus: SystemStatus
|
||||
|
||||
get systemStatusHasErrors(): boolean {
|
||||
return (
|
||||
@ -299,6 +305,7 @@ export class SettingsComponent
|
||||
documentEditingRemoveInboxTags: this.settings.get(
|
||||
SETTINGS_KEYS.DOCUMENT_EDITING_REMOVE_INBOX_TAGS
|
||||
),
|
||||
searchDbOnly: this.settings.get(SETTINGS_KEYS.SEARCH_DB_ONLY),
|
||||
savedViews: {},
|
||||
}
|
||||
}
|
||||
@ -340,6 +347,9 @@ export class SettingsComponent
|
||||
name: view.name,
|
||||
show_on_dashboard: view.show_on_dashboard,
|
||||
show_in_sidebar: view.show_in_sidebar,
|
||||
page_size: view.page_size,
|
||||
display_mode: view.display_mode,
|
||||
display_fields: view.display_fields,
|
||||
}
|
||||
this.savedViewGroup.addControl(
|
||||
view.id.toString(),
|
||||
@ -348,6 +358,9 @@ export class SettingsComponent
|
||||
name: new FormControl(null),
|
||||
show_on_dashboard: new FormControl(null),
|
||||
show_in_sidebar: new FormControl(null),
|
||||
page_size: new FormControl(null),
|
||||
display_mode: new FormControl(null),
|
||||
display_fields: new FormControl([]),
|
||||
})
|
||||
)
|
||||
}
|
||||
@ -385,12 +398,7 @@ export class SettingsComponent
|
||||
this.settingsForm.patchValue(currentFormValue)
|
||||
}
|
||||
|
||||
if (
|
||||
this.permissionsService.currentUserCan(
|
||||
PermissionAction.View,
|
||||
PermissionType.Admin
|
||||
)
|
||||
) {
|
||||
if (this.permissionsService.isAdmin()) {
|
||||
this.systemStatusService.get().subscribe((status) => {
|
||||
this.systemStatus = status
|
||||
})
|
||||
@ -527,6 +535,10 @@ export class SettingsComponent
|
||||
SETTINGS_KEYS.DOCUMENT_EDITING_REMOVE_INBOX_TAGS,
|
||||
this.settingsForm.value.documentEditingRemoveInboxTags
|
||||
)
|
||||
this.settings.set(
|
||||
SETTINGS_KEYS.SEARCH_DB_ONLY,
|
||||
this.settingsForm.value.searchDbOnly
|
||||
)
|
||||
this.settings.setLanguage(this.settingsForm.value.displayLanguage)
|
||||
this.settings
|
||||
.storeSettings()
|
||||
@ -535,8 +547,8 @@ export class SettingsComponent
|
||||
.subscribe({
|
||||
next: () => {
|
||||
this.store.next(this.settingsForm.value)
|
||||
this.documentListViewService.updatePageSize()
|
||||
this.settings.updateAppearanceSettings()
|
||||
this.settings.initializeDisplayFields()
|
||||
let savedToast: Toast = {
|
||||
content: $localize`Settings were saved successfully.`,
|
||||
delay: 5000,
|
||||
@ -597,6 +609,10 @@ export class SettingsComponent
|
||||
}
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.settingsForm.patchValue(this.store.getValue())
|
||||
}
|
||||
|
||||
clearThemeColor() {
|
||||
this.settingsForm.get('themeColor').patchValue('')
|
||||
}
|
||||
|
@ -52,40 +52,38 @@
|
||||
<i-bs name="plus-circle"></i-bs> <ng-container i18n>Add Group</ng-container>
|
||||
</button>
|
||||
</h4>
|
||||
@if (groups.length > 0) {
|
||||
<ul class="list-group">
|
||||
<ul class="list-group">
|
||||
<li class="list-group-item">
|
||||
<div class="row">
|
||||
<div class="col" i18n>Name</div>
|
||||
<div class="col"></div>
|
||||
<div class="col"></div>
|
||||
<div class="col" i18n>Actions</div>
|
||||
</div>
|
||||
</li>
|
||||
@for (group of groups; track group) {
|
||||
<li class="list-group-item">
|
||||
<div class="row">
|
||||
<div class="col" i18n>Name</div>
|
||||
<div class="col d-flex align-items-center"><button class="btn btn-link p-0 text-start" type="button" (click)="editGroup(group)" [disabled]="!permissionsService.currentUserCan(PermissionAction.Change, PermissionType.Group)">{{group.name}}</button></div>
|
||||
<div class="col"></div>
|
||||
<div class="col"></div>
|
||||
<div class="col" i18n>Actions</div>
|
||||
</div>
|
||||
</li>
|
||||
@for (group of groups; track group) {
|
||||
<li class="list-group-item">
|
||||
<div class="row">
|
||||
<div class="col d-flex align-items-center"><button class="btn btn-link p-0 text-start" type="button" (click)="editGroup(group)" [disabled]="!permissionsService.currentUserCan(PermissionAction.Change, PermissionType.Group)">{{group.name}}</button></div>
|
||||
<div class="col"></div>
|
||||
<div class="col"></div>
|
||||
<div class="col">
|
||||
<div class="btn-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"></i-bs> <ng-container i18n>Edit</ng-container>
|
||||
</button>
|
||||
<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"></i-bs> <ng-container i18n>Delete</ng-container>
|
||||
</button>
|
||||
</div>
|
||||
<div class="col">
|
||||
<div class="btn-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"></i-bs> <ng-container i18n>Edit</ng-container>
|
||||
</button>
|
||||
<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"></i-bs> <ng-container i18n>Delete</ng-container>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
}
|
||||
@if (groups.length === 0) {
|
||||
<li class="list-group-item" i18n>No groups defined</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
</div>
|
||||
</li>
|
||||
}
|
||||
@if (groups.length === 0) {
|
||||
<li class="list-group-item" i18n>No groups defined</li>
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
|
||||
@if (!users || !groups) {
|
||||
|
@ -24,19 +24,10 @@
|
||||
}
|
||||
</div>
|
||||
</a>
|
||||
<div class="search-form-container flex-grow-1 py-2 pb-3 pb-sm-2 px-3 ps-md-4 me-sm-auto order-3 order-sm-1"
|
||||
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }">
|
||||
<form (ngSubmit)="search()" class="form-inline flex-grow-1">
|
||||
<i-bs width="1em" height="1em" name="search"></i-bs>
|
||||
<input class="form-control form-control-sm" type="text" placeholder="Search documents" aria-label="Search"
|
||||
[formControl]="searchField" [ngbTypeahead]="searchAutoComplete" (keyup)="searchFieldKeyup($event)"
|
||||
(selectItem)="itemSelected($event)" i18n-placeholder>
|
||||
@if (!searchFieldEmpty) {
|
||||
<button type="button" class="btn btn-link btn-sm ps-0 pe-1 position-absolute top-0 end-0" (click)="resetSearchField()">
|
||||
<i-bs width="1em" height="1em" name="x"></i-bs>
|
||||
</button>
|
||||
}
|
||||
</form>
|
||||
<div class="search-container flex-grow-1 py-2 pb-3 pb-sm-2 px-3 ps-md-4 me-sm-auto order-3 order-sm-1">
|
||||
<div class="col-12 col-md-7">
|
||||
<pngx-global-search></pngx-global-search>
|
||||
</div>
|
||||
</div>
|
||||
<ul ngbNav class="order-sm-3">
|
||||
<li ngbDropdown class="nav-item dropdown">
|
||||
@ -111,7 +102,7 @@
|
||||
</h6>
|
||||
}
|
||||
<ul class="nav flex-column mb-2" cdkDropList (cdkDropListDropped)="onDrop($event)">
|
||||
@for (view of savedViewService.sidebarViews; track view) {
|
||||
@for (view of savedViewService.sidebarViews; track view.id) {
|
||||
<li class="nav-item w-100 app-link" cdkDrag [cdkDragDisabled]="!settingsService.organizingSidebarSavedViews"
|
||||
cdkDragPreviewContainer="parent" cdkDragPreviewClass="navItemDrag" (cdkDragStarted)="onDragStart($event)"
|
||||
(cdkDragEnded)="onDragEnd($event)">
|
||||
@ -267,13 +258,15 @@
|
||||
}
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Admin }">
|
||||
<a class="nav-link" routerLink="logs" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Logs"
|
||||
i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" container="body"
|
||||
triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||
<i-bs class="me-1" name="text-left"></i-bs><span> <ng-container i18n>Logs</ng-container></span>
|
||||
</a>
|
||||
</li>
|
||||
@if (permissionsService.isAdmin()) {
|
||||
<li class="nav-item app-link">
|
||||
<a class="nav-link" routerLink="logs" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Logs"
|
||||
i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end" container="body"
|
||||
triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||
<i-bs class="me-1" name="text-left"></i-bs><span> <ng-container i18n>Logs</ng-container></span>
|
||||
</a>
|
||||
</li>
|
||||
}
|
||||
<li class="nav-item mt-2" tourAnchor="tour.outro">
|
||||
<a class="px-3 py-2 text-muted small d-flex align-items-center flex-wrap text-decoration-none"
|
||||
target="_blank" rel="noopener noreferrer" href="https://docs.paperless-ngx.com" ngbPopover="Documentation"
|
||||
|
@ -257,59 +257,6 @@ main {
|
||||
}
|
||||
}
|
||||
|
||||
.navbar .search-form-container {
|
||||
max-width: 550px;
|
||||
|
||||
form {
|
||||
position: relative;
|
||||
|
||||
> i-bs {
|
||||
position: absolute;
|
||||
left: 0.6rem;
|
||||
top: .35rem;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
|
||||
@media screen and (min-width: 768px) {
|
||||
// adjust for smaller font size on non-mobile
|
||||
top: 0.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
&:focus-within {
|
||||
form > i-bs {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.form-control::placeholder {
|
||||
color: rgba(255, 255, 255, 0);
|
||||
}
|
||||
}
|
||||
|
||||
.form-control {
|
||||
color: rgba(255, 255, 255, 0.3);
|
||||
background-color: rgba(0, 0, 0, 0.15);
|
||||
padding-left: 1.8rem;
|
||||
border-color: rgba(255, 255, 255, 0.2);
|
||||
transition: all .3s ease, padding-left 0s ease, background-color 0s ease; // Safari requires all
|
||||
max-width: 600px;
|
||||
min-width: 300px; // 1/2 max
|
||||
|
||||
&::placeholder {
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
|
||||
&:focus {
|
||||
background-color: rgba(0, 0, 0, 0.3);
|
||||
color: var(--bs-light);
|
||||
flex-grow: 1;
|
||||
padding-left: 0.5rem;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.version-check {
|
||||
animation: pulse 2s ease-in-out 0s 1;
|
||||
}
|
||||
|
@ -30,14 +30,13 @@ import { OpenDocumentsService } from 'src/app/services/open-documents.service'
|
||||
import { ActivatedRoute, Router } from '@angular/router'
|
||||
import { DocumentDetailComponent } from '../document-detail/document-detail.component'
|
||||
import { SearchService } from 'src/app/services/rest/search.service'
|
||||
import { DocumentListViewService } from 'src/app/services/document-list-view.service'
|
||||
import { FILTER_FULLTEXT_QUERY } from 'src/app/data/filter-rule-type'
|
||||
import { routes } from 'src/app/app-routing.module'
|
||||
import { PermissionsGuard } from 'src/app/guards/permissions.guard'
|
||||
import { CdkDragDrop, DragDropModule } from '@angular/cdk/drag-drop'
|
||||
import { SavedView } from 'src/app/data/saved-view'
|
||||
import { ProfileEditDialogComponent } from '../common/profile-edit-dialog/profile-edit-dialog.component'
|
||||
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
||||
import { GlobalSearchComponent } from './global-search/global-search.component'
|
||||
|
||||
const saved_views = [
|
||||
{
|
||||
@ -89,15 +88,17 @@ describe('AppFrameComponent', () => {
|
||||
let toastService: ToastService
|
||||
let messagesService: DjangoMessagesService
|
||||
let openDocumentsService: OpenDocumentsService
|
||||
let searchService: SearchService
|
||||
let documentListViewService: DocumentListViewService
|
||||
let router: Router
|
||||
let savedViewSpy
|
||||
let modalService: NgbModal
|
||||
|
||||
beforeEach(async () => {
|
||||
TestBed.configureTestingModule({
|
||||
declarations: [AppFrameComponent, IfPermissionsDirective],
|
||||
declarations: [
|
||||
AppFrameComponent,
|
||||
IfPermissionsDirective,
|
||||
GlobalSearchComponent,
|
||||
],
|
||||
imports: [
|
||||
HttpClientTestingModule,
|
||||
BrowserModule,
|
||||
@ -159,8 +160,6 @@ describe('AppFrameComponent', () => {
|
||||
toastService = TestBed.inject(ToastService)
|
||||
messagesService = TestBed.inject(DjangoMessagesService)
|
||||
openDocumentsService = TestBed.inject(OpenDocumentsService)
|
||||
searchService = TestBed.inject(SearchService)
|
||||
documentListViewService = TestBed.inject(DocumentListViewService)
|
||||
modalService = TestBed.inject(NgbModal)
|
||||
router = TestBed.inject(Router)
|
||||
|
||||
@ -296,62 +295,6 @@ describe('AppFrameComponent', () => {
|
||||
expect(component.canDeactivate()).toBeFalsy()
|
||||
})
|
||||
|
||||
it('should call autocomplete endpoint on input', fakeAsync(() => {
|
||||
const autocompleteSpy = jest.spyOn(searchService, 'autocomplete')
|
||||
component.searchAutoComplete(of('hello')).subscribe()
|
||||
tick(250)
|
||||
expect(autocompleteSpy).toHaveBeenCalled()
|
||||
|
||||
component.searchAutoComplete(of('hello world 1')).subscribe()
|
||||
tick(250)
|
||||
expect(autocompleteSpy).toHaveBeenCalled()
|
||||
}))
|
||||
|
||||
it('should handle autocomplete backend failure gracefully', fakeAsync(() => {
|
||||
const serviceAutocompleteSpy = jest.spyOn(searchService, 'autocomplete')
|
||||
serviceAutocompleteSpy.mockReturnValue(
|
||||
throwError(() => new Error('autcomplete failed'))
|
||||
)
|
||||
// serviceAutocompleteSpy.mockReturnValue(of([' world']))
|
||||
let result
|
||||
component.searchAutoComplete(of('hello')).subscribe((res) => {
|
||||
result = res
|
||||
})
|
||||
tick(250)
|
||||
expect(serviceAutocompleteSpy).toHaveBeenCalled()
|
||||
expect(result).toEqual([])
|
||||
}))
|
||||
|
||||
it('should support reset search field', () => {
|
||||
const resetSpy = jest.spyOn(component, 'resetSearchField')
|
||||
const input = (fixture.nativeElement as HTMLDivElement).querySelector(
|
||||
'input'
|
||||
) as HTMLInputElement
|
||||
input.dispatchEvent(new KeyboardEvent('keyup', { key: 'Escape' }))
|
||||
expect(resetSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should support choosing a search item', () => {
|
||||
expect(component.searchField.value).toEqual('')
|
||||
component.itemSelected({ item: 'hello', preventDefault: () => true })
|
||||
expect(component.searchField.value).toEqual('hello ')
|
||||
component.itemSelected({ item: 'world', preventDefault: () => true })
|
||||
expect(component.searchField.value).toEqual('hello world ')
|
||||
})
|
||||
|
||||
it('should navigate via quickFilter on search', () => {
|
||||
const str = 'hello world '
|
||||
component.searchField.patchValue(str)
|
||||
const qfSpy = jest.spyOn(documentListViewService, 'quickFilter')
|
||||
component.search()
|
||||
expect(qfSpy).toHaveBeenCalledWith([
|
||||
{
|
||||
rule_type: FILTER_FULLTEXT_QUERY,
|
||||
value: str.trim(),
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
it('should disable global dropzone on start drag + drop, re-enable after', () => {
|
||||
expect(settingsService.globalDropzoneEnabled).toBeTruthy()
|
||||
component.onDragStart(null)
|
||||
|
@ -1,15 +1,7 @@
|
||||
import { Component, HostListener, OnInit } from '@angular/core'
|
||||
import { FormControl } from '@angular/forms'
|
||||
import { ActivatedRoute, Router } from '@angular/router'
|
||||
import { from, Observable } from 'rxjs'
|
||||
import {
|
||||
debounceTime,
|
||||
distinctUntilChanged,
|
||||
map,
|
||||
switchMap,
|
||||
first,
|
||||
catchError,
|
||||
} from 'rxjs/operators'
|
||||
import { Observable } from 'rxjs'
|
||||
import { first } from 'rxjs/operators'
|
||||
import { Document } from 'src/app/data/document'
|
||||
import { OpenDocumentsService } from 'src/app/services/open-documents.service'
|
||||
import {
|
||||
@ -17,11 +9,8 @@ import {
|
||||
DjangoMessagesService,
|
||||
} from 'src/app/services/django-messages.service'
|
||||
import { SavedViewService } from 'src/app/services/rest/saved-view.service'
|
||||
import { SearchService } from 'src/app/services/rest/search.service'
|
||||
import { environment } from 'src/environments/environment'
|
||||
import { DocumentDetailComponent } from '../document-detail/document-detail.component'
|
||||
import { DocumentListViewService } from 'src/app/services/document-list-view.service'
|
||||
import { FILTER_FULLTEXT_QUERY } from 'src/app/data/filter-rule-type'
|
||||
import {
|
||||
RemoteVersionService,
|
||||
AppRemoteVersion,
|
||||
@ -46,6 +35,7 @@ import {
|
||||
} from '@angular/cdk/drag-drop'
|
||||
import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { ProfileEditDialogComponent } from '../common/profile-edit-dialog/profile-edit-dialog.component'
|
||||
import { ObjectWithId } from 'src/app/data/object-with-id'
|
||||
|
||||
@Component({
|
||||
selector: 'pngx-app-frame',
|
||||
@ -63,16 +53,12 @@ export class AppFrameComponent
|
||||
|
||||
slimSidebarAnimating: boolean = false
|
||||
|
||||
searchField = new FormControl('')
|
||||
|
||||
constructor(
|
||||
public router: Router,
|
||||
private activatedRoute: ActivatedRoute,
|
||||
private openDocumentsService: OpenDocumentsService,
|
||||
private searchService: SearchService,
|
||||
public savedViewService: SavedViewService,
|
||||
private remoteVersionService: RemoteVersionService,
|
||||
private list: DocumentListViewService,
|
||||
public settingsService: SettingsService,
|
||||
public tasksService: TasksService,
|
||||
private readonly toastService: ToastService,
|
||||
@ -164,65 +150,6 @@ export class AppFrameComponent
|
||||
return !this.openDocumentsService.hasDirty()
|
||||
}
|
||||
|
||||
get searchFieldEmpty(): boolean {
|
||||
return this.searchField.value.trim().length == 0
|
||||
}
|
||||
|
||||
resetSearchField() {
|
||||
this.searchField.reset('')
|
||||
}
|
||||
|
||||
searchFieldKeyup(event: KeyboardEvent) {
|
||||
if (event.key == 'Escape') {
|
||||
this.resetSearchField()
|
||||
}
|
||||
}
|
||||
|
||||
searchAutoComplete = (text$: Observable<string>) =>
|
||||
text$.pipe(
|
||||
debounceTime(200),
|
||||
distinctUntilChanged(),
|
||||
map((term) => {
|
||||
if (term.lastIndexOf(' ') != -1) {
|
||||
return term.substring(term.lastIndexOf(' ') + 1)
|
||||
} else {
|
||||
return term
|
||||
}
|
||||
}),
|
||||
switchMap((term) =>
|
||||
term.length < 2
|
||||
? from([[]])
|
||||
: this.searchService.autocomplete(term).pipe(
|
||||
catchError(() => {
|
||||
return from([[]])
|
||||
})
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
itemSelected(event) {
|
||||
event.preventDefault()
|
||||
let currentSearch: string = this.searchField.value
|
||||
let lastSpaceIndex = currentSearch.lastIndexOf(' ')
|
||||
if (lastSpaceIndex != -1) {
|
||||
currentSearch = currentSearch.substring(0, lastSpaceIndex + 1)
|
||||
currentSearch += event.item + ' '
|
||||
} else {
|
||||
currentSearch = event.item + ' '
|
||||
}
|
||||
this.searchField.patchValue(currentSearch)
|
||||
}
|
||||
|
||||
search() {
|
||||
this.closeMenu()
|
||||
this.list.quickFilter([
|
||||
{
|
||||
rule_type: FILTER_FULLTEXT_QUERY,
|
||||
value: (this.searchField.value as string).trim(),
|
||||
},
|
||||
])
|
||||
}
|
||||
|
||||
closeDocument(d: Document) {
|
||||
this.openDocumentsService
|
||||
.closeDocument(d)
|
||||
|
@ -0,0 +1,166 @@
|
||||
|
||||
<div ngbDropdown #resultsDropdown="ngbDropdown" (openChange)="onDropdownOpenChange">
|
||||
<form class="form-inline position-relative">
|
||||
<i-bs width="1em" height="1em" name="search"></i-bs>
|
||||
<div class="input-group">
|
||||
<div class="form-control form-control-sm">
|
||||
<input class="bg-transparent border-0 w-100 h-100" #searchInput type="text" name="query"
|
||||
placeholder="Search" aria-label="Search" i18n-placeholder
|
||||
autocomplete="off" spellcheck="false"
|
||||
[(ngModel)]="query" (ngModelChange)="this.queryDebounce.next($event)" (keydown)="searchInputKeyDown($event)">
|
||||
<div class="position-absolute top-50 end-0 translate-middle">
|
||||
@if (loading) {
|
||||
<div class="spinner-border spinner-border-sm text-muted mt-1"></div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
@if (query && (searchResults?.documents.length === searchService.searchResultObjectLimit || searchService.searchDbOnly)) {
|
||||
<button class="btn btn-sm btn-outline-secondary" type="button" (click)="runAdvanedSearch()">
|
||||
<ng-container i18n>Advanced search</ng-container>
|
||||
<i-bs width="1em" height="1em" name="arrow-right-short"></i-bs>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<ng-template #resultItemTemplate let-item="item" let-nameProp="nameProp" let-type="type" let-icon="icon" let-date="date">
|
||||
<div #resultItem ngbDropdownItem class="py-2 d-flex align-items-center focus-ring border-0 cursor-pointer" tabindex="-1"
|
||||
(click)="primaryAction(type, item)"
|
||||
(mouseenter)="onItemHover($event)">
|
||||
<i-bs width="1.2em" height="1.2em" name="{{icon}}" class="me-2 text-muted"></i-bs>
|
||||
<div class="text-truncate">
|
||||
{{item[nameProp]}}
|
||||
@if (date) {
|
||||
<small class="small text-muted">{{date | customDate}}</small>
|
||||
}
|
||||
</div>
|
||||
<div class="btn-group ms-auto">
|
||||
<button #primaryButton type="button" class="btn btn-sm btn-outline-primary d-flex"
|
||||
(click)="primaryAction(type, item); $event.stopImmediatePropagation()"
|
||||
(keydown)="onButtonKeyDown($event)"
|
||||
[disabled]="disablePrimaryButton(type, item)"
|
||||
(mouseenter)="onButtonHover($event)">
|
||||
@if (type === DataType.Document) {
|
||||
<i-bs width="1em" height="1em" name="pencil"></i-bs>
|
||||
<span> <ng-container i18n>Open</ng-container></span>
|
||||
} @else if (type === DataType.SavedView) {
|
||||
<i-bs width="1em" height="1em" name="eye"></i-bs>
|
||||
<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) {
|
||||
<i-bs width="1em" height="1em" name="pencil"></i-bs>
|
||||
<span> <ng-container i18n>Edit</ng-container></span>
|
||||
} @else {
|
||||
<i-bs width="1em" height="1em" name="filter"></i-bs>
|
||||
<span> <ng-container i18n>Filter documents</ng-container></span>
|
||||
}
|
||||
</button>
|
||||
@if (type !== DataType.SavedView && type !== DataType.Workflow && type !== DataType.CustomField && type !== DataType.Group && type !== DataType.User && type !== DataType.MailAccount && type !== DataType.MailRule) {
|
||||
<button #secondaryButton type="button" class="btn btn-sm btn-outline-primary d-flex"
|
||||
(click)="secondaryAction(type, item); $event.stopImmediatePropagation()"
|
||||
(keydown)="onButtonKeyDown($event)"
|
||||
[disabled]="disableSecondaryButton(type, item)"
|
||||
(mouseenter)="onButtonHover($event)">
|
||||
@if (type === DataType.Document) {
|
||||
<i-bs width="1em" height="1em" name="download"></i-bs>
|
||||
<span> <ng-container i18n>Download</ng-container></span>
|
||||
} @else {
|
||||
<i-bs width="1em" height="1em" name="pencil"></i-bs>
|
||||
<span> <ng-container i18n>Edit</ng-container></span>
|
||||
}
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</ng-template>
|
||||
|
||||
<div ngbDropdownMenu class="w-100 mh-75 overflow-y-scroll shadow-lg">
|
||||
<div (keydown)="dropdownKeyDown($event)">
|
||||
@if (searchResults?.total === 0) {
|
||||
<h6 class="dropdown-header" i18n="@@searchResults.noResults">No results</h6>
|
||||
} @else {
|
||||
@if (searchResults?.documents.length) {
|
||||
<h6 class="dropdown-header" i18n="@@searchResults.documents">Documents</h6>
|
||||
@for (document of searchResults.documents; track document.id) {
|
||||
<ng-container *ngTemplateOutlet="resultItemTemplate; context: {item: document, nameProp: 'title', type: DataType.Document, icon: 'file-text', date: document.added}"></ng-container>
|
||||
}
|
||||
}
|
||||
@if (searchResults?.saved_views.length) {
|
||||
<h6 class="dropdown-header" i18n="@@searchResults.saved_views">Saved Views</h6>
|
||||
@for (saved_view of searchResults.saved_views; track saved_view.id) {
|
||||
<ng-container *ngTemplateOutlet="resultItemTemplate; context: {item: saved_view, nameProp: 'name', type: DataType.SavedView, icon: 'funnel'}"></ng-container>
|
||||
}
|
||||
}
|
||||
|
||||
@if (searchResults?.tags.length) {
|
||||
<h6 class="dropdown-header" i18n="@@searchResults.tags">Tags</h6>
|
||||
@for (tag of searchResults.tags; track tag.id) {
|
||||
<ng-container *ngTemplateOutlet="resultItemTemplate; context: {item: tag, nameProp: 'name', type: DataType.Tag, icon: 'tag'}"></ng-container>
|
||||
}
|
||||
}
|
||||
|
||||
@if (searchResults?.correspondents.length) {
|
||||
<h6 class="dropdown-header" i18n="@@searchResults.correspondents">Correspondents</h6>
|
||||
@for (correspondent of searchResults.correspondents; track correspondent.id) {
|
||||
<ng-container *ngTemplateOutlet="resultItemTemplate; context: {item: correspondent, nameProp: 'name', type: DataType.Correspondent, icon: 'person'}"></ng-container>
|
||||
}
|
||||
}
|
||||
|
||||
@if (searchResults?.document_types.length) {
|
||||
<h6 class="dropdown-header" i18n="@@searchResults.documentTypes">Document types</h6>
|
||||
@for (documentType of searchResults.document_types; track documentType.id) {
|
||||
<ng-container *ngTemplateOutlet="resultItemTemplate; context: {item: documentType, nameProp: 'name', type: DataType.DocumentType, icon: 'file-earmark'}"></ng-container>
|
||||
}
|
||||
}
|
||||
|
||||
@if (searchResults?.storage_paths.length) {
|
||||
<h6 class="dropdown-header" i18n="@@searchResults.storagePaths">Storage paths</h6>
|
||||
@for (storagePath of searchResults.storage_paths; track storagePath.id) {
|
||||
<ng-container *ngTemplateOutlet="resultItemTemplate; context: {item: storagePath, nameProp: 'name', type: DataType.StoragePath, icon: 'folder'}"></ng-container>
|
||||
}
|
||||
}
|
||||
|
||||
@if (searchResults?.users.length) {
|
||||
<h6 class="dropdown-header" i18n="@@searchResults.users">Users</h6>
|
||||
@for (user of searchResults.users; track user.id) {
|
||||
<ng-container *ngTemplateOutlet="resultItemTemplate; context: {item: user, nameProp: 'username', type: DataType.User, icon: 'person-square'}"></ng-container>
|
||||
}
|
||||
}
|
||||
|
||||
@if (searchResults?.groups.length) {
|
||||
<h6 class="dropdown-header" i18n="@@searchResults.groups">Groups</h6>
|
||||
@for (group of searchResults.groups; track group.id) {
|
||||
<ng-container *ngTemplateOutlet="resultItemTemplate; context: {item: group, nameProp: 'name', type: DataType.Group, icon: 'people'}"></ng-container>
|
||||
}
|
||||
}
|
||||
|
||||
@if (searchResults?.custom_fields.length) {
|
||||
<h6 class="dropdown-header" i18n="@@searchResults.customFields">Custom fields</h6>
|
||||
@for (customField of searchResults.custom_fields; track customField.id) {
|
||||
<ng-container *ngTemplateOutlet="resultItemTemplate; context: {item: customField, nameProp: 'name', type: DataType.CustomField, icon: 'ui-radios'}"></ng-container>
|
||||
}
|
||||
}
|
||||
|
||||
@if (searchResults?.mail_accounts.length) {
|
||||
<h6 class="dropdown-header" i18n="@@searchResults.mailAccounts">Mail accounts</h6>
|
||||
@for (mailAccount of searchResults.mail_accounts; track mailAccount.id) {
|
||||
<ng-container *ngTemplateOutlet="resultItemTemplate; context: {item: mailAccount, nameProp: 'name', type: DataType.MailAccount, icon: 'envelope-at'}"></ng-container>
|
||||
}
|
||||
}
|
||||
|
||||
@if (searchResults?.mail_rules.length) {
|
||||
<h6 class="dropdown-header" i18n="@@searchResults.mailRules">Mail rules</h6>
|
||||
@for (mailRule of searchResults.mail_rules; track mailRule.id) {
|
||||
<ng-container *ngTemplateOutlet="resultItemTemplate; context: {item: mailRule, nameProp: 'name', type: DataType.MailRule, icon: 'envelope'}"></ng-container>
|
||||
}
|
||||
}
|
||||
|
||||
@if (searchResults?.workflows.length) {
|
||||
<h6 class="dropdown-header" i18n="@@searchResults.workflows">Workflows</h6>
|
||||
@for (workflow of searchResults.workflows; track workflow.id) {
|
||||
<ng-container *ngTemplateOutlet="resultItemTemplate; context: {item: workflow, nameProp: 'name', type: DataType.Workflow, icon: 'boxes'}"></ng-container>
|
||||
}
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
@ -0,0 +1,97 @@
|
||||
form {
|
||||
position: relative;
|
||||
|
||||
> i-bs[name="search"] {
|
||||
position: absolute;
|
||||
left: 0.6rem;
|
||||
top: .35rem;
|
||||
color: rgba(255, 255, 255, 0.6);
|
||||
|
||||
@media screen and (min-width: 768px) {
|
||||
// adjust for smaller font size on non-mobile
|
||||
top: 0.25rem;
|
||||
}
|
||||
}
|
||||
|
||||
&:focus-within {
|
||||
i-bs[name="search"],
|
||||
.badge {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.form-control::placeholder {
|
||||
color: rgba(255, 255, 255, 0);
|
||||
}
|
||||
}
|
||||
|
||||
.badge {
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
|
||||
.input-group .btn {
|
||||
border-color: rgba(255, 255, 255, 0.2);
|
||||
color: var(--pngx-primary-text-contrast);
|
||||
}
|
||||
|
||||
.form-control {
|
||||
color: rgba(255, 255, 255, 0.3);
|
||||
background-color: rgba(0, 0, 0, 0.15);
|
||||
padding-left: 1.8rem;
|
||||
border-color: rgba(255, 255, 255, 0.2);
|
||||
transition: all .3s ease, padding-left 0s ease, background-color 0s ease; // Safari requires all
|
||||
> input {
|
||||
outline: none;
|
||||
|
||||
&::placeholder {
|
||||
color: rgba(255, 255, 255, 0.4);
|
||||
}
|
||||
}
|
||||
|
||||
&:focus-within {
|
||||
background-color: rgba(0, 0, 0, 0.3);
|
||||
color: var(--bs-light);
|
||||
flex-grow: 1;
|
||||
padding-left: 0.5rem;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
* {
|
||||
--pngx-focus-alpha: 0;
|
||||
}
|
||||
|
||||
.cursor-pointer {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.mh-75 {
|
||||
max-height: 75vh;
|
||||
}
|
||||
|
||||
.dropdown-item {
|
||||
&:has(button:focus) {
|
||||
background-color: var(--pngx-bg-darker);
|
||||
}
|
||||
|
||||
& button {
|
||||
transition: all 0.3s ease, color 0.15s ease;
|
||||
max-width: 2rem;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
& button span {
|
||||
opacity: 0;
|
||||
transition: inherit;
|
||||
}
|
||||
|
||||
&:hover button,
|
||||
&:has(button:focus) button {
|
||||
max-width: 10rem;
|
||||
}
|
||||
|
||||
&:hover button span,
|
||||
&:has(button:focus) span {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
@ -0,0 +1,460 @@
|
||||
import {
|
||||
ComponentFixture,
|
||||
TestBed,
|
||||
fakeAsync,
|
||||
tick,
|
||||
} from '@angular/core/testing'
|
||||
import { GlobalSearchComponent } from './global-search.component'
|
||||
import { of } from 'rxjs'
|
||||
import { SearchService } from 'src/app/services/rest/search.service'
|
||||
import { Router } from '@angular/router'
|
||||
import {
|
||||
NgbDropdownModule,
|
||||
NgbModal,
|
||||
NgbModalModule,
|
||||
NgbModalRef,
|
||||
} from '@ng-bootstrap/ng-bootstrap'
|
||||
import { CorrespondentEditDialogComponent } from '../../common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component'
|
||||
import { UserEditDialogComponent } from '../../common/edit-dialog/user-edit-dialog/user-edit-dialog.component'
|
||||
import { DocumentListViewService } from 'src/app/services/document-list-view.service'
|
||||
import { HttpClientTestingModule } from '@angular/common/http/testing'
|
||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||
import {
|
||||
FILTER_FULLTEXT_QUERY,
|
||||
FILTER_HAS_CORRESPONDENT_ANY,
|
||||
FILTER_HAS_DOCUMENT_TYPE_ANY,
|
||||
FILTER_HAS_STORAGE_PATH_ANY,
|
||||
FILTER_HAS_TAGS_ANY,
|
||||
} from 'src/app/data/filter-rule-type'
|
||||
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
||||
import { DocumentService } from 'src/app/services/rest/document.service'
|
||||
import { MailRuleEditDialogComponent } from '../../common/edit-dialog/mail-rule-edit-dialog/mail-rule-edit-dialog.component'
|
||||
import { MailAccountEditDialogComponent } from '../../common/edit-dialog/mail-account-edit-dialog/mail-account-edit-dialog.component'
|
||||
import { GroupEditDialogComponent } from '../../common/edit-dialog/group-edit-dialog/group-edit-dialog.component'
|
||||
import { CustomFieldEditDialogComponent } from '../../common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component'
|
||||
import { WorkflowEditDialogComponent } from '../../common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component'
|
||||
import { ElementRef } from '@angular/core'
|
||||
import { ToastService } from 'src/app/services/toast.service'
|
||||
import { DataType } from 'src/app/data/datatype'
|
||||
|
||||
const searchResults = {
|
||||
total: 11,
|
||||
documents: [
|
||||
{
|
||||
id: 1,
|
||||
title: 'Test',
|
||||
created_at: new Date(),
|
||||
updated_at: new Date(),
|
||||
document_type: { id: 1, name: 'Test' },
|
||||
storage_path: { id: 1, path: 'Test' },
|
||||
tags: [],
|
||||
correspondents: [],
|
||||
custom_fields: [],
|
||||
},
|
||||
],
|
||||
saved_views: [
|
||||
{
|
||||
id: 1,
|
||||
name: 'TestSavedView',
|
||||
},
|
||||
],
|
||||
correspondents: [
|
||||
{
|
||||
id: 1,
|
||||
name: 'TestCorrespondent',
|
||||
},
|
||||
],
|
||||
document_types: [
|
||||
{
|
||||
id: 1,
|
||||
name: 'TestDocumentType',
|
||||
},
|
||||
],
|
||||
storage_paths: [
|
||||
{
|
||||
id: 1,
|
||||
name: 'TestStoragePath',
|
||||
},
|
||||
],
|
||||
tags: [
|
||||
{
|
||||
id: 1,
|
||||
name: 'TestTag',
|
||||
},
|
||||
],
|
||||
users: [
|
||||
{
|
||||
id: 1,
|
||||
username: 'TestUser',
|
||||
},
|
||||
],
|
||||
groups: [
|
||||
{
|
||||
id: 1,
|
||||
name: 'TestGroup',
|
||||
},
|
||||
],
|
||||
mail_accounts: [
|
||||
{
|
||||
id: 1,
|
||||
name: 'TestMailAccount',
|
||||
},
|
||||
],
|
||||
mail_rules: [
|
||||
{
|
||||
id: 1,
|
||||
name: 'TestMailRule',
|
||||
},
|
||||
],
|
||||
custom_fields: [
|
||||
{
|
||||
id: 1,
|
||||
name: 'TestCustomField',
|
||||
},
|
||||
],
|
||||
workflows: [
|
||||
{
|
||||
id: 1,
|
||||
name: 'TestWorkflow',
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
describe('GlobalSearchComponent', () => {
|
||||
let component: GlobalSearchComponent
|
||||
let fixture: ComponentFixture<GlobalSearchComponent>
|
||||
let searchService: SearchService
|
||||
let router: Router
|
||||
let modalService: NgbModal
|
||||
let documentService: DocumentService
|
||||
let documentListViewService: DocumentListViewService
|
||||
let toastService: ToastService
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
declarations: [GlobalSearchComponent],
|
||||
imports: [
|
||||
HttpClientTestingModule,
|
||||
NgbModalModule,
|
||||
NgbDropdownModule,
|
||||
FormsModule,
|
||||
ReactiveFormsModule,
|
||||
NgxBootstrapIconsModule.pick(allIcons),
|
||||
],
|
||||
}).compileComponents()
|
||||
|
||||
searchService = TestBed.inject(SearchService)
|
||||
router = TestBed.inject(Router)
|
||||
modalService = TestBed.inject(NgbModal)
|
||||
documentService = TestBed.inject(DocumentService)
|
||||
documentListViewService = TestBed.inject(DocumentListViewService)
|
||||
toastService = TestBed.inject(ToastService)
|
||||
|
||||
fixture = TestBed.createComponent(GlobalSearchComponent)
|
||||
component = fixture.componentInstance
|
||||
fixture.detectChanges()
|
||||
})
|
||||
|
||||
it('should handle keyboard nav', () => {
|
||||
const focusSpy = jest.spyOn(component.searchInput.nativeElement, 'focus')
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', { key: '/' }))
|
||||
expect(focusSpy).toHaveBeenCalled()
|
||||
|
||||
component.searchResults = searchResults as any
|
||||
component.resultsDropdown.open()
|
||||
fixture.detectChanges()
|
||||
|
||||
component['currentItemIndex'] = 0
|
||||
component['setCurrentItem']()
|
||||
const firstItemFocusSpy = jest.spyOn(
|
||||
component.primaryButtons.get(1).nativeElement,
|
||||
'focus'
|
||||
)
|
||||
component.dropdownKeyDown(
|
||||
new KeyboardEvent('keydown', { key: 'ArrowDown' })
|
||||
)
|
||||
expect(component['currentItemIndex']).toBe(1)
|
||||
expect(firstItemFocusSpy).toHaveBeenCalled()
|
||||
|
||||
const secondaryItemFocusSpy = jest.spyOn(
|
||||
component.secondaryButtons.get(1).nativeElement,
|
||||
'focus'
|
||||
)
|
||||
component.dropdownKeyDown(
|
||||
new KeyboardEvent('keydown', { key: 'ArrowRight' })
|
||||
)
|
||||
expect(secondaryItemFocusSpy).toHaveBeenCalled()
|
||||
|
||||
component.dropdownKeyDown(
|
||||
new KeyboardEvent('keydown', { key: 'ArrowLeft' })
|
||||
)
|
||||
expect(firstItemFocusSpy).toHaveBeenCalled()
|
||||
|
||||
const zeroItemSpy = jest.spyOn(
|
||||
component.primaryButtons.get(0).nativeElement,
|
||||
'focus'
|
||||
)
|
||||
component.dropdownKeyDown(new KeyboardEvent('keydown', { key: 'ArrowUp' }))
|
||||
expect(component['currentItemIndex']).toBe(0)
|
||||
expect(zeroItemSpy).toHaveBeenCalled()
|
||||
|
||||
const inputFocusSpy = jest.spyOn(
|
||||
component.searchInput.nativeElement,
|
||||
'focus'
|
||||
)
|
||||
component.dropdownKeyDown(new KeyboardEvent('keydown', { key: 'ArrowUp' }))
|
||||
expect(component['currentItemIndex']).toBe(-1)
|
||||
expect(inputFocusSpy).toHaveBeenCalled()
|
||||
|
||||
component.dropdownKeyDown(
|
||||
new KeyboardEvent('keydown', { key: 'ArrowDown' })
|
||||
)
|
||||
component['currentItemIndex'] = searchResults.total - 1
|
||||
component['setCurrentItem']()
|
||||
component.dropdownKeyDown(
|
||||
new KeyboardEvent('keydown', { key: 'ArrowDown' })
|
||||
)
|
||||
expect(component['currentItemIndex']).toBe(-1)
|
||||
|
||||
// Search input
|
||||
|
||||
component.searchInputKeyDown(
|
||||
new KeyboardEvent('keydown', { key: 'ArrowUp' })
|
||||
)
|
||||
expect(component['currentItemIndex']).toBe(searchResults.total - 1)
|
||||
|
||||
component.searchInputKeyDown(
|
||||
new KeyboardEvent('keydown', { key: 'ArrowDown' })
|
||||
)
|
||||
expect(component['currentItemIndex']).toBe(0)
|
||||
|
||||
component.searchResults = { total: 1 } as any
|
||||
const primaryActionSpy = jest.spyOn(component, 'primaryAction')
|
||||
component.searchInputKeyDown(new KeyboardEvent('keydown', { key: 'Enter' }))
|
||||
expect(primaryActionSpy).toHaveBeenCalled()
|
||||
|
||||
component.query = 'test'
|
||||
const resetSpy = jest.spyOn(GlobalSearchComponent.prototype as any, 'reset')
|
||||
component.searchInputKeyDown(
|
||||
new KeyboardEvent('keydown', { key: 'Escape' })
|
||||
)
|
||||
expect(resetSpy).toHaveBeenCalled()
|
||||
|
||||
component.query = ''
|
||||
const blurSpy = jest.spyOn(component.searchInput.nativeElement, 'blur')
|
||||
component.searchInputKeyDown(
|
||||
new KeyboardEvent('keydown', { key: 'Escape' })
|
||||
)
|
||||
expect(blurSpy).toHaveBeenCalled()
|
||||
|
||||
component.searchResults = { total: 1 } as any
|
||||
component.resultsDropdown.close()
|
||||
const openSpy = jest.spyOn(component.resultsDropdown, 'open')
|
||||
component.searchInputKeyDown(new KeyboardEvent('keydown', { key: 'Enter' }))
|
||||
expect(openSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should search on query debounce', fakeAsync(() => {
|
||||
const query = 'test'
|
||||
const searchSpy = jest.spyOn(searchService, 'globalSearch')
|
||||
searchSpy.mockReturnValue(of({} as any))
|
||||
const dropdownOpenSpy = jest.spyOn(component.resultsDropdown, 'open')
|
||||
component.queryDebounce.next(query)
|
||||
tick(401)
|
||||
expect(searchSpy).toHaveBeenCalledWith(query)
|
||||
expect(dropdownOpenSpy).toHaveBeenCalled()
|
||||
}))
|
||||
|
||||
it('should support primary action', () => {
|
||||
const object = { id: 1 }
|
||||
const routerSpy = jest.spyOn(router, 'navigate')
|
||||
const qfSpy = jest.spyOn(documentListViewService, 'quickFilter')
|
||||
const modalSpy = jest.spyOn(modalService, 'open')
|
||||
|
||||
let modal: NgbModalRef
|
||||
modalService.activeInstances.subscribe((m) => (modal = m[m.length - 1]))
|
||||
|
||||
component.primaryAction(DataType.Document, object)
|
||||
expect(routerSpy).toHaveBeenCalledWith(['/documents', object.id])
|
||||
|
||||
component.primaryAction(DataType.SavedView, object)
|
||||
expect(routerSpy).toHaveBeenCalledWith(['/view', object.id])
|
||||
|
||||
component.primaryAction(DataType.Correspondent, object)
|
||||
expect(qfSpy).toHaveBeenCalledWith([
|
||||
{ rule_type: FILTER_HAS_CORRESPONDENT_ANY, value: object.id.toString() },
|
||||
])
|
||||
|
||||
component.primaryAction(DataType.DocumentType, object)
|
||||
expect(qfSpy).toHaveBeenCalledWith([
|
||||
{ rule_type: FILTER_HAS_DOCUMENT_TYPE_ANY, value: object.id.toString() },
|
||||
])
|
||||
|
||||
component.primaryAction(DataType.StoragePath, object)
|
||||
expect(qfSpy).toHaveBeenCalledWith([
|
||||
{ rule_type: FILTER_HAS_STORAGE_PATH_ANY, value: object.id.toString() },
|
||||
])
|
||||
|
||||
component.primaryAction(DataType.Tag, object)
|
||||
expect(qfSpy).toHaveBeenCalledWith([
|
||||
{ rule_type: FILTER_HAS_TAGS_ANY, value: object.id.toString() },
|
||||
])
|
||||
|
||||
component.primaryAction(DataType.User, object)
|
||||
expect(modalSpy).toHaveBeenCalledWith(UserEditDialogComponent, {
|
||||
size: 'lg',
|
||||
})
|
||||
|
||||
component.primaryAction(DataType.Group, object)
|
||||
expect(modalSpy).toHaveBeenCalledWith(GroupEditDialogComponent, {
|
||||
size: 'lg',
|
||||
})
|
||||
|
||||
component.primaryAction(DataType.MailAccount, object)
|
||||
expect(modalSpy).toHaveBeenCalledWith(MailAccountEditDialogComponent, {
|
||||
size: 'xl',
|
||||
})
|
||||
|
||||
component.primaryAction(DataType.MailRule, object)
|
||||
expect(modalSpy).toHaveBeenCalledWith(MailRuleEditDialogComponent, {
|
||||
size: 'xl',
|
||||
})
|
||||
|
||||
component.primaryAction(DataType.CustomField, object)
|
||||
expect(modalSpy).toHaveBeenCalledWith(CustomFieldEditDialogComponent, {
|
||||
size: 'md',
|
||||
})
|
||||
|
||||
component.primaryAction(DataType.Workflow, object)
|
||||
expect(modalSpy).toHaveBeenCalledWith(WorkflowEditDialogComponent, {
|
||||
size: 'xl',
|
||||
})
|
||||
|
||||
const editDialog = modal.componentInstance as CustomFieldEditDialogComponent
|
||||
const toastErrorSpy = jest.spyOn(toastService, 'showError')
|
||||
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
|
||||
|
||||
// fail first
|
||||
editDialog.failed.emit({ error: 'error creating item' })
|
||||
expect(toastErrorSpy).toHaveBeenCalled()
|
||||
|
||||
// succeed
|
||||
editDialog.succeeded.emit(true)
|
||||
expect(toastInfoSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should support secondary action', () => {
|
||||
const doc = searchResults.documents[0]
|
||||
const openSpy = jest.spyOn(window, 'open')
|
||||
component.secondaryAction('document', doc)
|
||||
expect(openSpy).toHaveBeenCalledWith(documentService.getDownloadUrl(doc.id))
|
||||
|
||||
const correspondent = searchResults.correspondents[0]
|
||||
const modalSpy = jest.spyOn(modalService, 'open')
|
||||
|
||||
let modal: NgbModalRef
|
||||
modalService.activeInstances.subscribe((m) => (modal = m[m.length - 1]))
|
||||
|
||||
component.secondaryAction(DataType.Correspondent, correspondent)
|
||||
expect(modalSpy).toHaveBeenCalledWith(CorrespondentEditDialogComponent, {
|
||||
size: 'md',
|
||||
})
|
||||
|
||||
component.secondaryAction(
|
||||
DataType.DocumentType,
|
||||
searchResults.document_types[0]
|
||||
)
|
||||
expect(modalSpy).toHaveBeenCalledWith(CorrespondentEditDialogComponent, {
|
||||
size: 'md',
|
||||
})
|
||||
|
||||
component.secondaryAction(
|
||||
DataType.StoragePath,
|
||||
searchResults.storage_paths[0]
|
||||
)
|
||||
expect(modalSpy).toHaveBeenCalledWith(CorrespondentEditDialogComponent, {
|
||||
size: 'md',
|
||||
})
|
||||
|
||||
component.secondaryAction(DataType.Tag, searchResults.tags[0])
|
||||
expect(modalSpy).toHaveBeenCalledWith(CorrespondentEditDialogComponent, {
|
||||
size: 'md',
|
||||
})
|
||||
|
||||
const editDialog = modal.componentInstance as CustomFieldEditDialogComponent
|
||||
const toastErrorSpy = jest.spyOn(toastService, 'showError')
|
||||
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
|
||||
|
||||
// fail first
|
||||
editDialog.failed.emit({ error: 'error creating item' })
|
||||
expect(toastErrorSpy).toHaveBeenCalled()
|
||||
|
||||
// succeed
|
||||
editDialog.succeeded.emit(true)
|
||||
expect(toastInfoSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should support reset', () => {
|
||||
const debounce = jest.spyOn(component.queryDebounce, 'next')
|
||||
const closeSpy = jest.spyOn(component.resultsDropdown, 'close')
|
||||
component['reset'](true)
|
||||
expect(debounce).toHaveBeenCalledWith(null)
|
||||
expect(component.searchResults).toBeNull()
|
||||
expect(component['currentItemIndex']).toBe(-1)
|
||||
expect(closeSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should support focus current item', () => {
|
||||
component.searchResults = searchResults as any
|
||||
fixture.detectChanges()
|
||||
const focusSpy = jest.spyOn(
|
||||
component.primaryButtons.get(0).nativeElement,
|
||||
'focus'
|
||||
)
|
||||
component['currentItemIndex'] = 0
|
||||
component['setCurrentItem']()
|
||||
expect(focusSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should reset on dropdown close', () => {
|
||||
const resetSpy = jest.spyOn(GlobalSearchComponent.prototype as any, 'reset')
|
||||
component.onDropdownOpenChange(false)
|
||||
expect(resetSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should focus button on dropdown item hover', () => {
|
||||
component.searchResults = searchResults as any
|
||||
fixture.detectChanges()
|
||||
const item: ElementRef = component.resultItems.first
|
||||
const focusSpy = jest.spyOn(
|
||||
component.primaryButtons.first.nativeElement,
|
||||
'focus'
|
||||
)
|
||||
component.onItemHover({ currentTarget: item.nativeElement } as any)
|
||||
expect(component['currentItemIndex']).toBe(0)
|
||||
expect(focusSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should focus on button hover', () => {
|
||||
const event = { currentTarget: { focus: jest.fn() } }
|
||||
const focusSpy = jest.spyOn(event.currentTarget, 'focus')
|
||||
component.onButtonHover(event as any)
|
||||
expect(focusSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should prevent event propagation for keyboard events on buttons that are not arrows', () => {
|
||||
const event = { stopImmediatePropagation: jest.fn(), key: 'Enter' }
|
||||
const stopPropagationSpy = jest.spyOn(event, 'stopImmediatePropagation')
|
||||
component.onButtonKeyDown(event as any)
|
||||
expect(stopPropagationSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should support explicit advanced search', () => {
|
||||
const qfSpy = jest.spyOn(documentListViewService, 'quickFilter')
|
||||
component.query = 'test'
|
||||
component.runAdvanedSearch()
|
||||
expect(qfSpy).toHaveBeenCalledWith([
|
||||
{ rule_type: FILTER_FULLTEXT_QUERY, value: 'test' },
|
||||
])
|
||||
})
|
||||
})
|
@ -0,0 +1,375 @@
|
||||
import {
|
||||
Component,
|
||||
ViewChild,
|
||||
ElementRef,
|
||||
ViewChildren,
|
||||
QueryList,
|
||||
OnInit,
|
||||
} from '@angular/core'
|
||||
import { Router } from '@angular/router'
|
||||
import { NgbDropdown, NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { Subject, debounceTime, distinctUntilChanged, filter, map } from 'rxjs'
|
||||
import {
|
||||
FILTER_FULLTEXT_QUERY,
|
||||
FILTER_HAS_CORRESPONDENT_ANY,
|
||||
FILTER_HAS_DOCUMENT_TYPE_ANY,
|
||||
FILTER_HAS_STORAGE_PATH_ANY,
|
||||
FILTER_HAS_TAGS_ANY,
|
||||
} from 'src/app/data/filter-rule-type'
|
||||
import { DataType } from 'src/app/data/datatype'
|
||||
import { ObjectWithId } from 'src/app/data/object-with-id'
|
||||
import { DocumentListViewService } from 'src/app/services/document-list-view.service'
|
||||
import {
|
||||
PermissionsService,
|
||||
PermissionAction,
|
||||
} from 'src/app/services/permissions.service'
|
||||
import { DocumentService } from 'src/app/services/rest/document.service'
|
||||
import {
|
||||
GlobalSearchResult,
|
||||
SearchService,
|
||||
} from 'src/app/services/rest/search.service'
|
||||
import { ToastService } from 'src/app/services/toast.service'
|
||||
import { CorrespondentEditDialogComponent } from '../../common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component'
|
||||
import { CustomFieldEditDialogComponent } from '../../common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component'
|
||||
import { DocumentTypeEditDialogComponent } from '../../common/edit-dialog/document-type-edit-dialog/document-type-edit-dialog.component'
|
||||
import { EditDialogMode } from '../../common/edit-dialog/edit-dialog.component'
|
||||
import { GroupEditDialogComponent } from '../../common/edit-dialog/group-edit-dialog/group-edit-dialog.component'
|
||||
import { MailAccountEditDialogComponent } from '../../common/edit-dialog/mail-account-edit-dialog/mail-account-edit-dialog.component'
|
||||
import { MailRuleEditDialogComponent } from '../../common/edit-dialog/mail-rule-edit-dialog/mail-rule-edit-dialog.component'
|
||||
import { StoragePathEditDialogComponent } from '../../common/edit-dialog/storage-path-edit-dialog/storage-path-edit-dialog.component'
|
||||
import { TagEditDialogComponent } from '../../common/edit-dialog/tag-edit-dialog/tag-edit-dialog.component'
|
||||
import { UserEditDialogComponent } from '../../common/edit-dialog/user-edit-dialog/user-edit-dialog.component'
|
||||
import { WorkflowEditDialogComponent } from '../../common/edit-dialog/workflow-edit-dialog/workflow-edit-dialog.component'
|
||||
import { HotKeyService } from 'src/app/services/hot-key.service'
|
||||
|
||||
@Component({
|
||||
selector: 'pngx-global-search',
|
||||
templateUrl: './global-search.component.html',
|
||||
styleUrl: './global-search.component.scss',
|
||||
})
|
||||
export class GlobalSearchComponent implements OnInit {
|
||||
public DataType = DataType
|
||||
public query: string
|
||||
public queryDebounce: Subject<string>
|
||||
public searchResults: GlobalSearchResult
|
||||
private currentItemIndex: number = -1
|
||||
private domIndex: number = -1
|
||||
public loading: boolean = false
|
||||
|
||||
@ViewChild('searchInput') searchInput: ElementRef
|
||||
@ViewChild('resultsDropdown') resultsDropdown: NgbDropdown
|
||||
@ViewChildren('resultItem') resultItems: QueryList<ElementRef>
|
||||
@ViewChildren('primaryButton') primaryButtons: QueryList<ElementRef>
|
||||
@ViewChildren('secondaryButton') secondaryButtons: QueryList<ElementRef>
|
||||
|
||||
constructor(
|
||||
public searchService: SearchService,
|
||||
private router: Router,
|
||||
private modalService: NgbModal,
|
||||
private documentService: DocumentService,
|
||||
private documentListViewService: DocumentListViewService,
|
||||
private permissionsService: PermissionsService,
|
||||
private toastService: ToastService,
|
||||
private hotkeyService: HotKeyService
|
||||
) {
|
||||
this.queryDebounce = new Subject<string>()
|
||||
|
||||
this.queryDebounce
|
||||
.pipe(
|
||||
debounceTime(400),
|
||||
map((query) => query?.trim()),
|
||||
filter((query) => !query?.length || query?.length > 2),
|
||||
distinctUntilChanged()
|
||||
)
|
||||
.subscribe((text) => {
|
||||
this.query = text
|
||||
if (text) this.search(text)
|
||||
})
|
||||
}
|
||||
|
||||
ngOnInit() {
|
||||
this.hotkeyService
|
||||
.addShortcut({ keys: '/', description: $localize`Global search` })
|
||||
.subscribe(() => {
|
||||
this.searchInput.nativeElement.focus()
|
||||
})
|
||||
}
|
||||
|
||||
private search(query: string) {
|
||||
this.loading = true
|
||||
this.searchService.globalSearch(query).subscribe((results) => {
|
||||
this.searchResults = results
|
||||
this.loading = false
|
||||
this.resultsDropdown.open()
|
||||
})
|
||||
}
|
||||
|
||||
public primaryAction(type: string, object: ObjectWithId) {
|
||||
this.reset(true)
|
||||
let filterRuleType: number
|
||||
let editDialogComponent: any
|
||||
let size: string = 'md'
|
||||
switch (type) {
|
||||
case DataType.Document:
|
||||
this.router.navigate(['/documents', object.id])
|
||||
return
|
||||
case DataType.SavedView:
|
||||
this.router.navigate(['/view', object.id])
|
||||
return
|
||||
case DataType.Correspondent:
|
||||
filterRuleType = FILTER_HAS_CORRESPONDENT_ANY
|
||||
break
|
||||
case DataType.DocumentType:
|
||||
filterRuleType = FILTER_HAS_DOCUMENT_TYPE_ANY
|
||||
break
|
||||
case DataType.StoragePath:
|
||||
filterRuleType = FILTER_HAS_STORAGE_PATH_ANY
|
||||
break
|
||||
case DataType.Tag:
|
||||
filterRuleType = FILTER_HAS_TAGS_ANY
|
||||
break
|
||||
case DataType.User:
|
||||
editDialogComponent = UserEditDialogComponent
|
||||
size = 'lg'
|
||||
break
|
||||
case DataType.Group:
|
||||
editDialogComponent = GroupEditDialogComponent
|
||||
size = 'lg'
|
||||
break
|
||||
case DataType.MailAccount:
|
||||
editDialogComponent = MailAccountEditDialogComponent
|
||||
size = 'xl'
|
||||
break
|
||||
case DataType.MailRule:
|
||||
editDialogComponent = MailRuleEditDialogComponent
|
||||
size = 'xl'
|
||||
break
|
||||
case DataType.CustomField:
|
||||
editDialogComponent = CustomFieldEditDialogComponent
|
||||
break
|
||||
case DataType.Workflow:
|
||||
editDialogComponent = WorkflowEditDialogComponent
|
||||
size = 'xl'
|
||||
break
|
||||
}
|
||||
|
||||
if (filterRuleType) {
|
||||
this.documentListViewService.quickFilter([
|
||||
{ rule_type: filterRuleType, value: object.id.toString() },
|
||||
])
|
||||
} else if (editDialogComponent) {
|
||||
const modalRef: NgbModalRef = this.modalService.open(
|
||||
editDialogComponent,
|
||||
{ size }
|
||||
)
|
||||
modalRef.componentInstance.dialogMode = EditDialogMode.EDIT
|
||||
modalRef.componentInstance.object = object
|
||||
modalRef.componentInstance.succeeded.subscribe(() => {
|
||||
this.toastService.showInfo($localize`Successfully updated object.`)
|
||||
})
|
||||
modalRef.componentInstance.failed.subscribe((e) => {
|
||||
this.toastService.showError($localize`Error occurred saving object.`, e)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
public secondaryAction(type: string, object: ObjectWithId) {
|
||||
this.reset(true)
|
||||
let editDialogComponent: any
|
||||
let size: string = 'md'
|
||||
switch (type) {
|
||||
case DataType.Document:
|
||||
window.open(this.documentService.getDownloadUrl(object.id))
|
||||
break
|
||||
case DataType.Correspondent:
|
||||
editDialogComponent = CorrespondentEditDialogComponent
|
||||
break
|
||||
case DataType.DocumentType:
|
||||
editDialogComponent = DocumentTypeEditDialogComponent
|
||||
break
|
||||
case DataType.StoragePath:
|
||||
editDialogComponent = StoragePathEditDialogComponent
|
||||
break
|
||||
case DataType.Tag:
|
||||
editDialogComponent = TagEditDialogComponent
|
||||
break
|
||||
}
|
||||
|
||||
if (editDialogComponent) {
|
||||
const modalRef: NgbModalRef = this.modalService.open(
|
||||
editDialogComponent,
|
||||
{ size }
|
||||
)
|
||||
modalRef.componentInstance.dialogMode = EditDialogMode.EDIT
|
||||
modalRef.componentInstance.object = object
|
||||
modalRef.componentInstance.succeeded.subscribe(() => {
|
||||
this.toastService.showInfo($localize`Successfully updated object.`)
|
||||
})
|
||||
modalRef.componentInstance.failed.subscribe((e) => {
|
||||
this.toastService.showError($localize`Error occurred saving object.`, e)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
private reset(close: boolean = false) {
|
||||
this.queryDebounce.next(null)
|
||||
this.searchResults = null
|
||||
this.currentItemIndex = -1
|
||||
if (close) {
|
||||
this.resultsDropdown.close()
|
||||
}
|
||||
}
|
||||
|
||||
private setCurrentItem() {
|
||||
// QueryLists do not always reflect the current DOM order, so we need to find the actual element
|
||||
// Yes, using some vanilla JS
|
||||
const result: HTMLElement = this.resultItems.first.nativeElement.parentNode
|
||||
.querySelectorAll('.dropdown-item')
|
||||
.item(this.currentItemIndex)
|
||||
this.domIndex = this.resultItems
|
||||
.toArray()
|
||||
.indexOf(this.resultItems.find((item) => item.nativeElement === result))
|
||||
const item: ElementRef = this.primaryButtons.get(this.domIndex)
|
||||
item.nativeElement.focus()
|
||||
}
|
||||
|
||||
onItemHover(event: MouseEvent) {
|
||||
const item: ElementRef = this.resultItems
|
||||
.toArray()
|
||||
.find((item) => item.nativeElement === event.currentTarget)
|
||||
this.currentItemIndex = this.resultItems.toArray().indexOf(item)
|
||||
this.setCurrentItem()
|
||||
}
|
||||
|
||||
onButtonHover(event: MouseEvent) {
|
||||
;(event.currentTarget as HTMLElement).focus()
|
||||
}
|
||||
|
||||
public searchInputKeyDown(event: KeyboardEvent) {
|
||||
if (
|
||||
event.key === 'ArrowDown' &&
|
||||
this.searchResults?.total &&
|
||||
this.resultsDropdown.isOpen()
|
||||
) {
|
||||
event.preventDefault()
|
||||
this.currentItemIndex = 0
|
||||
this.setCurrentItem()
|
||||
} else if (
|
||||
event.key === 'ArrowUp' &&
|
||||
this.searchResults?.total &&
|
||||
this.resultsDropdown.isOpen()
|
||||
) {
|
||||
event.preventDefault()
|
||||
this.currentItemIndex = this.searchResults.total - 1
|
||||
this.setCurrentItem()
|
||||
} else if (
|
||||
event.key === 'Enter' &&
|
||||
this.searchResults?.total === 1 &&
|
||||
this.resultsDropdown.isOpen()
|
||||
) {
|
||||
this.primaryButtons.first.nativeElement.click()
|
||||
this.searchInput.nativeElement.blur()
|
||||
} else if (
|
||||
event.key === 'Enter' &&
|
||||
this.searchResults?.total &&
|
||||
!this.resultsDropdown.isOpen()
|
||||
) {
|
||||
this.resultsDropdown.open()
|
||||
} else if (event.key === 'Escape' && !this.resultsDropdown.isOpen()) {
|
||||
if (this.query?.length) {
|
||||
this.reset(true)
|
||||
} else {
|
||||
this.searchInput.nativeElement.blur()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dropdownKeyDown(event: KeyboardEvent) {
|
||||
if (
|
||||
this.searchResults?.total &&
|
||||
this.resultsDropdown.isOpen() &&
|
||||
document.activeElement !== this.searchInput.nativeElement
|
||||
) {
|
||||
if (event.key === 'ArrowDown') {
|
||||
event.preventDefault()
|
||||
event.stopImmediatePropagation()
|
||||
if (this.currentItemIndex < this.searchResults.total - 1) {
|
||||
this.currentItemIndex++
|
||||
this.setCurrentItem()
|
||||
} else {
|
||||
this.searchInput.nativeElement.focus()
|
||||
this.currentItemIndex = -1
|
||||
}
|
||||
} else if (event.key === 'ArrowUp') {
|
||||
event.preventDefault()
|
||||
event.stopImmediatePropagation()
|
||||
if (this.currentItemIndex > 0) {
|
||||
this.currentItemIndex--
|
||||
this.setCurrentItem()
|
||||
} else {
|
||||
this.searchInput.nativeElement.focus()
|
||||
this.currentItemIndex = -1
|
||||
}
|
||||
} else if (event.key === 'ArrowRight') {
|
||||
event.preventDefault()
|
||||
event.stopImmediatePropagation()
|
||||
this.secondaryButtons.get(this.domIndex)?.nativeElement.focus()
|
||||
} else if (event.key === 'ArrowLeft') {
|
||||
event.preventDefault()
|
||||
event.stopImmediatePropagation()
|
||||
this.primaryButtons.get(this.domIndex).nativeElement.focus()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
onButtonKeyDown(event: KeyboardEvent) {
|
||||
// prevents ngBootstrap issue with keydown events
|
||||
if (
|
||||
!['ArrowDown', 'ArrowUp', 'ArrowRight', 'ArrowLeft'].includes(event.key)
|
||||
) {
|
||||
event.stopImmediatePropagation()
|
||||
}
|
||||
}
|
||||
|
||||
public onDropdownOpenChange(open: boolean) {
|
||||
if (!open) {
|
||||
this.reset()
|
||||
}
|
||||
}
|
||||
|
||||
public disablePrimaryButton(type: DataType, object: ObjectWithId): boolean {
|
||||
if (
|
||||
[
|
||||
DataType.Workflow,
|
||||
DataType.CustomField,
|
||||
DataType.Group,
|
||||
DataType.User,
|
||||
].includes(type)
|
||||
) {
|
||||
return !this.permissionsService.currentUserHasObjectPermissions(
|
||||
PermissionAction.Change,
|
||||
object
|
||||
)
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
public disableSecondaryButton(type: DataType, object: ObjectWithId): boolean {
|
||||
if (DataType.Document === type) {
|
||||
return false
|
||||
}
|
||||
|
||||
return !this.permissionsService.currentUserHasObjectPermissions(
|
||||
PermissionAction.Change,
|
||||
object
|
||||
)
|
||||
}
|
||||
|
||||
runAdvanedSearch() {
|
||||
this.documentListViewService.quickFilter([
|
||||
{ rule_type: FILTER_FULLTEXT_QUERY, value: this.query },
|
||||
])
|
||||
this.reset(true)
|
||||
}
|
||||
}
|
@ -36,14 +36,18 @@ describe('MergeConfirmDialogComponent', () => {
|
||||
{ id: 2, name: 'Document 2' },
|
||||
{ id: 3, name: 'Document 3' },
|
||||
]
|
||||
jest.spyOn(documentService, 'getCachedMany').mockReturnValue(of(documents))
|
||||
jest.spyOn(documentService, 'getFew').mockReturnValue(
|
||||
of({
|
||||
all: documents.map((d) => d.id),
|
||||
count: documents.length,
|
||||
results: documents,
|
||||
})
|
||||
)
|
||||
|
||||
component.ngOnInit()
|
||||
|
||||
expect(component.documents).toEqual(documents)
|
||||
expect(documentService.getCachedMany).toHaveBeenCalledWith(
|
||||
component.documentIDs
|
||||
)
|
||||
expect(documentService.getFew).toHaveBeenCalledWith(component.documentIDs)
|
||||
})
|
||||
|
||||
it('should move documentIDs on drop', () => {
|
||||
@ -64,7 +68,13 @@ describe('MergeConfirmDialogComponent', () => {
|
||||
{ id: 2, name: 'Document 2' },
|
||||
{ id: 3, name: 'Document 3' },
|
||||
]
|
||||
jest.spyOn(documentService, 'getCachedMany').mockReturnValue(of(documents))
|
||||
jest.spyOn(documentService, 'getFew').mockReturnValue(
|
||||
of({
|
||||
all: documents.map((d) => d.id),
|
||||
count: documents.length,
|
||||
results: documents,
|
||||
})
|
||||
)
|
||||
|
||||
component.ngOnInit()
|
||||
|
||||
|
@ -34,10 +34,10 @@ export class MergeConfirmDialogComponent
|
||||
|
||||
ngOnInit() {
|
||||
this.documentService
|
||||
.getCachedMany(this.documentIDs)
|
||||
.getFew(this.documentIDs)
|
||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe((documents) => {
|
||||
this._documents = documents
|
||||
.subscribe((r) => {
|
||||
this._documents = r.results
|
||||
})
|
||||
}
|
||||
|
||||
|
@ -12,7 +12,7 @@
|
||||
</div>
|
||||
<div class="col-8 d-flex align-items-center">
|
||||
@if (documentID) {
|
||||
<img class="w-50 m-auto" [ngStyle]="{'transform': 'rotate('+rotation+'deg)'}" [src]="documentService.getThumbUrl(documentID)" />
|
||||
<img class="w-75 m-auto" [ngStyle]="{'transform': 'rotate('+rotation+'deg)'}" [src]="documentService.getThumbUrl(documentID)" />
|
||||
}
|
||||
</div>
|
||||
<div class="col-2 d-flex">
|
||||
|
@ -6,7 +6,7 @@
|
||||
<div class="modal-body">
|
||||
<p>{{message}}</p>
|
||||
<div class="row mb-2">
|
||||
<div class="col-6">
|
||||
<div class="col-8">
|
||||
<div class="input-group input-group-sm">
|
||||
<div class="input-group-text" i18n>Page</div>
|
||||
<input class="form-control" type="number" min="1" [(ngModel)]="page" />
|
||||
@ -21,9 +21,9 @@
|
||||
</pngx-pdf-viewer>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<div class="col-4">
|
||||
<div class="d-grid">
|
||||
<button class="btn btn-sm btn-primary" (click)="addSplit()">
|
||||
<button class="btn btn-sm btn-primary" (click)="addSplit()" [disabled]="page === totalPages">
|
||||
<i-bs name="plus-circle"></i-bs>
|
||||
<span i18n>Add Split</span>
|
||||
</button>
|
||||
@ -31,11 +31,11 @@
|
||||
|
||||
<ul class="list-group mt-3">
|
||||
@for (pageStr of pagesString.split(','); track pageStr; let i = $index) {
|
||||
<li class="list-group-item">
|
||||
<li class="list-group-item d-flex align-items-center">
|
||||
{{pageStr}}
|
||||
@if (pagesString.split(',').length > 1) {
|
||||
|
||||
<button class="btn btn-sm btn-danger" (click)="removeSplit(i)">
|
||||
<button class="btn btn-sm btn-danger ms-auto" (click)="removeSplit(i)">
|
||||
<i-bs name="trash"></i-bs>
|
||||
</button>
|
||||
}
|
||||
|
@ -1,6 +1,6 @@
|
||||
.pdf-viewer-container {
|
||||
background-color: gray;
|
||||
height: 300px;
|
||||
height: 350px;
|
||||
|
||||
pngx-pdf-viewer {
|
||||
width: 100%;
|
||||
|
@ -0,0 +1,25 @@
|
||||
@if (field) {
|
||||
@switch (field.data_type) {
|
||||
@case (CustomFieldDataType.Monetary) {
|
||||
<span>{{value | currency: currency}}</span>
|
||||
}
|
||||
@case (CustomFieldDataType.Date) {
|
||||
<span>{{value | customDate}}</span>
|
||||
}
|
||||
@case (CustomFieldDataType.Url) {
|
||||
<a [href]="value" class="btn-link text-dark text-decoration-none" target="_blank">{{value}}</a>
|
||||
}
|
||||
@case (CustomFieldDataType.DocumentLink) {
|
||||
<div class="d-flex gap-1 flex-wrap">
|
||||
@for (docId of value; track docId) {
|
||||
<a routerLink="/documents/{{docId}}" class="badge bg-dark text-primary" title="View" i18n-title>
|
||||
<i-bs width="0.9em" height="0.9em" name="file-text"></i-bs> <span>{{ getDocumentTitle(docId) }}</span>
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@default {
|
||||
<span>{{value}}</span>
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,89 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||
import { of } from 'rxjs'
|
||||
import { CustomField, CustomFieldDataType } from 'src/app/data/custom-field'
|
||||
import { DocumentService } from 'src/app/services/rest/document.service'
|
||||
import { CustomFieldDisplayComponent } from './custom-field-display.component'
|
||||
import { DisplayField, Document } from 'src/app/data/document'
|
||||
import { HttpClientTestingModule } from '@angular/common/http/testing'
|
||||
import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
|
||||
|
||||
const customFields: CustomField[] = [
|
||||
{ id: 1, name: 'Field 1', data_type: CustomFieldDataType.String },
|
||||
{ id: 2, name: 'Field 2', data_type: CustomFieldDataType.Monetary },
|
||||
{ id: 3, name: 'Field 3', data_type: CustomFieldDataType.DocumentLink },
|
||||
]
|
||||
const document: Document = {
|
||||
id: 1,
|
||||
title: 'Doc 1',
|
||||
custom_fields: [
|
||||
{ field: 1, document: 1, created: null, value: 'Text value' },
|
||||
{ field: 2, document: 1, created: null, value: '100 USD' },
|
||||
{ field: 3, document: 1, created: null, value: '1,2,3' },
|
||||
],
|
||||
}
|
||||
|
||||
describe('CustomFieldDisplayComponent', () => {
|
||||
let component: CustomFieldDisplayComponent
|
||||
let fixture: ComponentFixture<CustomFieldDisplayComponent>
|
||||
let documentService: DocumentService
|
||||
let customFieldService: CustomFieldsService
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
declarations: [CustomFieldDisplayComponent],
|
||||
providers: [DocumentService],
|
||||
imports: [HttpClientTestingModule],
|
||||
}).compileComponents()
|
||||
})
|
||||
|
||||
beforeEach(() => {
|
||||
documentService = TestBed.inject(DocumentService)
|
||||
customFieldService = TestBed.inject(CustomFieldsService)
|
||||
jest
|
||||
.spyOn(customFieldService, 'listAll')
|
||||
.mockReturnValue(of({ results: customFields } as any))
|
||||
fixture = TestBed.createComponent(CustomFieldDisplayComponent)
|
||||
component = fixture.componentInstance
|
||||
fixture.detectChanges()
|
||||
})
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should initialize component', () => {
|
||||
jest
|
||||
.spyOn(documentService, 'getFew')
|
||||
.mockReturnValue(of({ results: [] } as any))
|
||||
|
||||
component.fieldDisplayKey = DisplayField.CUSTOM_FIELD + '2'
|
||||
expect(component.fieldId).toEqual(2)
|
||||
component.document = document
|
||||
expect(component.document.title).toEqual('Doc 1')
|
||||
|
||||
expect(component.field).toEqual(customFields[1])
|
||||
expect(component.value).toEqual(100)
|
||||
expect(component.currency).toEqual('USD')
|
||||
})
|
||||
|
||||
it('should get document titles', () => {
|
||||
const docLinkDocuments: Document[] = [
|
||||
{ id: 1, title: 'Document 1' } as any,
|
||||
{ id: 2, title: 'Document 2' } as any,
|
||||
{ id: 3, title: 'Document 3' } as any,
|
||||
]
|
||||
jest
|
||||
.spyOn(documentService, 'getFew')
|
||||
.mockReturnValue(of({ results: docLinkDocuments } as any))
|
||||
component.fieldId = 3
|
||||
component.document = document
|
||||
|
||||
const title1 = component.getDocumentTitle(1)
|
||||
const title2 = component.getDocumentTitle(2)
|
||||
const title3 = component.getDocumentTitle(3)
|
||||
|
||||
expect(title1).toEqual('Document 1')
|
||||
expect(title2).toEqual('Document 2')
|
||||
expect(title3).toEqual('Document 3')
|
||||
})
|
||||
})
|
@ -0,0 +1,105 @@
|
||||
import { Component, Input, OnDestroy, OnInit } from '@angular/core'
|
||||
import { Subject, takeUntil } from 'rxjs'
|
||||
import { CustomField, CustomFieldDataType } from 'src/app/data/custom-field'
|
||||
import { DisplayField, Document } from 'src/app/data/document'
|
||||
import { Results } from 'src/app/data/results'
|
||||
import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
|
||||
import { DocumentService } from 'src/app/services/rest/document.service'
|
||||
|
||||
@Component({
|
||||
selector: 'pngx-custom-field-display',
|
||||
templateUrl: './custom-field-display.component.html',
|
||||
styleUrl: './custom-field-display.component.scss',
|
||||
})
|
||||
export class CustomFieldDisplayComponent implements OnInit, OnDestroy {
|
||||
CustomFieldDataType = CustomFieldDataType
|
||||
|
||||
private _document: Document
|
||||
@Input()
|
||||
set document(document: Document) {
|
||||
this._document = document
|
||||
this.init()
|
||||
}
|
||||
|
||||
get document(): Document {
|
||||
return this._document
|
||||
}
|
||||
|
||||
private _fieldId: number
|
||||
@Input()
|
||||
set fieldId(id: number) {
|
||||
this._fieldId = id
|
||||
this.init()
|
||||
}
|
||||
|
||||
get fieldId(): number {
|
||||
return this._fieldId
|
||||
}
|
||||
|
||||
@Input()
|
||||
set fieldDisplayKey(key: string) {
|
||||
this.fieldId = parseInt(key.replace(DisplayField.CUSTOM_FIELD, ''), 10)
|
||||
}
|
||||
|
||||
value: any
|
||||
currency: string
|
||||
|
||||
private customFields: CustomField[] = []
|
||||
|
||||
public field: CustomField
|
||||
|
||||
private docLinkDocuments: Document[] = []
|
||||
|
||||
private unsubscribeNotifier: Subject<any> = new Subject()
|
||||
|
||||
constructor(
|
||||
private customFieldService: CustomFieldsService,
|
||||
private documentService: DocumentService
|
||||
) {
|
||||
this.customFieldService.listAll().subscribe((r) => {
|
||||
this.customFields = r.results
|
||||
this.init()
|
||||
})
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.init()
|
||||
}
|
||||
|
||||
private init() {
|
||||
if (this.value || !this._fieldId || !this._document || !this.customFields) {
|
||||
return
|
||||
}
|
||||
this.field = this.customFields.find((f) => f.id === this._fieldId)
|
||||
this.value = this._document.custom_fields.find(
|
||||
(f) => f.field === this._fieldId
|
||||
)?.value
|
||||
if (this.value && this.field.data_type === CustomFieldDataType.Monetary) {
|
||||
this.currency = this.value.match(/([A-Z]{3})/)?.[0]
|
||||
this.value = parseFloat(this.value.replace(this.currency, ''))
|
||||
} else if (
|
||||
this.value?.length &&
|
||||
this.field.data_type === CustomFieldDataType.DocumentLink
|
||||
) {
|
||||
this.getDocuments()
|
||||
}
|
||||
}
|
||||
|
||||
private getDocuments() {
|
||||
this.documentService
|
||||
.getFew(this.value, { fields: 'id,title' })
|
||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe((result: Results<Document>) => {
|
||||
this.docLinkDocuments = result.results
|
||||
})
|
||||
}
|
||||
|
||||
public getDocumentTitle(docId: number): string {
|
||||
return this.docLinkDocuments?.find((d) => d.id === docId)?.title
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.unsubscribeNotifier.next(true)
|
||||
this.unsubscribeNotifier.complete()
|
||||
}
|
||||
}
|
@ -1,30 +1,27 @@
|
||||
<div ngbDropdown #fieldDropdown="ngbDropdown" (openChange)="onOpenClose()">
|
||||
<div ngbDropdown #fieldDropdown="ngbDropdown" (openChange)="onOpenClose($event)">
|
||||
<button class="btn btn-sm btn-outline-primary" id="customFieldsDropdown" [disabled]="disabled" ngbDropdownToggle>
|
||||
<i-bs name="ui-radios"></i-bs>
|
||||
<div class="d-none d-sm-inline"> <ng-container i18n>Custom Fields</ng-container></div>
|
||||
</button>
|
||||
<div ngbDropdownMenu aria-labelledby="customFieldsDropdown" class="shadow custom-fields-dropdown">
|
||||
<ul class="list-group list-group-flush">
|
||||
<li class="list-group-item">
|
||||
<pngx-input-select class="mb-3"
|
||||
[items]="unusedFields"
|
||||
bindLabel="name"
|
||||
[(ngModel)]="field"
|
||||
[placeholder]="placeholderText"
|
||||
[notFoundText]="notFoundText"
|
||||
[disableCreateNew]="!canCreateFields"
|
||||
(createNew)="createField($event)"
|
||||
bindValue="id">
|
||||
</pngx-input-select>
|
||||
<div class="btn-toolbar" role="toolbar">
|
||||
<button class="btn btn-sm btn-outline-secondary me-auto" type="button" (click)="createField()" [disabled]="!canCreateFields">
|
||||
<i-bs width="1em" height="1em" name="asterisk"></i-bs> <ng-container i18n>Create New Field</ng-container>
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-primary me-1" type="button" (click)="addField(); fieldDropdown.close()" [disabled]="field === undefined">
|
||||
<i-bs width="1.2em" height="1.2em" name="plus-circle"></i-bs> <ng-container i18n>Add</ng-container>
|
||||
</button>
|
||||
<div class="list-group list-group-flush" (keydown)="listKeyDown($event)">
|
||||
<div class="list-group-item">
|
||||
<div class="input-group input-group-sm">
|
||||
<input class="form-control" type="text" [(ngModel)]="filterText" placeholder="Search fields" i18n-placeholder (keyup.enter)="listFilterEnter()" #listFilterTextInput>
|
||||
</div>
|
||||
</li>
|
||||
</ul>
|
||||
</div>
|
||||
@for (field of filteredFields; track field.id) {
|
||||
<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>
|
||||
</button>
|
||||
}
|
||||
@if (!filterText?.length || filteredFields.length === 0) {
|
||||
<button class="list-group-item list-group-item-action bg-light" (click)="createField(filterText)" [disabled]="!canCreateFields" #button>
|
||||
<small>
|
||||
<i-bs width=".9em" height=".9em" name="asterisk"></i-bs> <ng-container i18n>Create new field</ng-container>
|
||||
</small>
|
||||
</button>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -1,5 +1,5 @@
|
||||
.custom-fields-dropdown {
|
||||
min-width: 350px;
|
||||
min-width: 300px;
|
||||
|
||||
// correct position on mobile
|
||||
@media (max-width: 575.98px) {
|
||||
@ -8,17 +8,3 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
::ng-deep .ng-select .ng-select-container .ng-value-container .ng-placeholder,
|
||||
::ng-deep .ng-select .ng-option,
|
||||
::ng-deep .ng-select .ng-select-container .ng-value-container .ng-value {
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
::ng-deep .paperless-input-select .ng-select {
|
||||
min-height: calc(1em + 0.75rem + 5px);
|
||||
}
|
||||
|
||||
::ng-deep .paperless-input-select .ng-select .ng-select-container .ng-value-container .ng-input {
|
||||
top: 4px;
|
||||
}
|
||||
|
@ -1,10 +1,11 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||
|
||||
import { CustomFieldsDropdownComponent } from './custom-fields-dropdown.component'
|
||||
import {
|
||||
HttpClientTestingModule,
|
||||
HttpTestingController,
|
||||
} from '@angular/common/http/testing'
|
||||
ComponentFixture,
|
||||
TestBed,
|
||||
fakeAsync,
|
||||
tick,
|
||||
} from '@angular/core/testing'
|
||||
import { CustomFieldsDropdownComponent } from './custom-fields-dropdown.component'
|
||||
import { HttpClientTestingModule } from '@angular/common/http/testing'
|
||||
import { ToastService } from 'src/app/services/toast.service'
|
||||
import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
|
||||
import { of } from 'rxjs'
|
||||
@ -74,28 +75,33 @@ describe('CustomFieldsDropdownComponent', () => {
|
||||
let addedField
|
||||
component.added.subscribe((f) => (addedField = f))
|
||||
component.documentId = 11
|
||||
component.field = fields[0].id
|
||||
component.addField()
|
||||
component.addField({ field: fields[0].id } as any)
|
||||
expect(addedField).not.toBeUndefined()
|
||||
})
|
||||
|
||||
it('should clear field on open / close, updated unused fields', () => {
|
||||
component.field = fields[1].id
|
||||
component.onOpenClose()
|
||||
expect(component.field).toBeUndefined()
|
||||
|
||||
expect(component.unusedFields).toEqual(fields)
|
||||
const updateSpy = jest.spyOn(
|
||||
CustomFieldsDropdownComponent.prototype as any,
|
||||
'updateUnusedFields'
|
||||
)
|
||||
component.existingFields = [{ field: fields[1].id } as any]
|
||||
component.onOpenClose()
|
||||
expect(updateSpy).toHaveBeenCalled()
|
||||
expect(component.unusedFields).toEqual([fields[0]])
|
||||
it('should support filtering fields', () => {
|
||||
const input = fixture.debugElement.query(By.css('input'))
|
||||
input.nativeElement.value = 'Field 1'
|
||||
input.triggerEventHandler('input', { target: input.nativeElement })
|
||||
fixture.detectChanges()
|
||||
expect(component.filteredFields.length).toEqual(1)
|
||||
expect(component.filteredFields[0].name).toEqual('Field 1')
|
||||
})
|
||||
|
||||
it('should support creating field, show error if necessary', () => {
|
||||
it('should support update unused fields', () => {
|
||||
component.existingFields = [{ field: fields[0].id } as any]
|
||||
component['updateUnusedFields']()
|
||||
expect(component['unusedFields'].length).toEqual(1)
|
||||
expect(component['unusedFields'][0].name).toEqual('Field 2')
|
||||
})
|
||||
|
||||
it('should support getting data type label', () => {
|
||||
expect(component.getDataTypeLabel(CustomFieldDataType.Integer)).toEqual(
|
||||
'Integer'
|
||||
)
|
||||
})
|
||||
|
||||
it('should support creating field, show error if necessary, then add', fakeAsync(() => {
|
||||
let modal: NgbModalRef
|
||||
modalService.activeInstances.subscribe((m) => (modal = m[m.length - 1]))
|
||||
const toastErrorSpy = jest.spyOn(toastService, 'showError')
|
||||
@ -104,8 +110,9 @@ describe('CustomFieldsDropdownComponent', () => {
|
||||
CustomFieldsDropdownComponent.prototype as any,
|
||||
'getFields'
|
||||
)
|
||||
const addFieldSpy = jest.spyOn(component, 'addField')
|
||||
|
||||
const createButton = fixture.debugElement.queryAll(By.css('button'))[1]
|
||||
const createButton = fixture.debugElement.queryAll(By.css('button'))[3]
|
||||
createButton.triggerEventHandler('click')
|
||||
|
||||
expect(modal).not.toBeUndefined()
|
||||
@ -118,9 +125,11 @@ describe('CustomFieldsDropdownComponent', () => {
|
||||
|
||||
// succeed
|
||||
editDialog.succeeded.emit(fields[0])
|
||||
tick(100)
|
||||
expect(toastInfoSpy).toHaveBeenCalled()
|
||||
expect(getFieldsSpy).toHaveBeenCalled()
|
||||
})
|
||||
expect(addFieldSpy).toHaveBeenCalled()
|
||||
}))
|
||||
|
||||
it('should support creating field with name', () => {
|
||||
let modal: NgbModalRef
|
||||
@ -131,4 +140,106 @@ describe('CustomFieldsDropdownComponent', () => {
|
||||
const editDialog = modal.componentInstance as CustomFieldEditDialogComponent
|
||||
expect(editDialog.object.name).toEqual('Foo bar')
|
||||
})
|
||||
|
||||
it('should support arrow keyboard navigation', fakeAsync(() => {
|
||||
fixture.nativeElement
|
||||
.querySelector('button')
|
||||
.dispatchEvent(new MouseEvent('click')) // open
|
||||
fixture.detectChanges()
|
||||
tick(100)
|
||||
const filterInputEl: HTMLInputElement =
|
||||
component.listFilterTextInput.nativeElement
|
||||
expect(document.activeElement).toEqual(filterInputEl)
|
||||
const itemButtons = Array.from(
|
||||
(fixture.nativeElement as HTMLDivElement).querySelectorAll(
|
||||
'.custom-fields-dropdown button'
|
||||
)
|
||||
).filter((b) => b.textContent.includes('Field'))
|
||||
filterInputEl.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })
|
||||
)
|
||||
expect(document.activeElement).toEqual(itemButtons[0])
|
||||
itemButtons[0].dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })
|
||||
)
|
||||
expect(document.activeElement).toEqual(itemButtons[1])
|
||||
itemButtons[1].dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true })
|
||||
)
|
||||
expect(document.activeElement).toEqual(itemButtons[0])
|
||||
itemButtons[0].dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true })
|
||||
)
|
||||
expect(document.activeElement).toEqual(filterInputEl)
|
||||
filterInputEl.value = 'foo'
|
||||
component.filterText = 'foo'
|
||||
|
||||
// dont move focus if we're traversing the field
|
||||
filterInputEl.selectionStart = 1
|
||||
expect(document.activeElement).toEqual(filterInputEl)
|
||||
|
||||
// now we're at end, so move focus
|
||||
filterInputEl.selectionStart = 3
|
||||
filterInputEl.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })
|
||||
)
|
||||
expect(document.activeElement).toEqual(itemButtons[0])
|
||||
}))
|
||||
|
||||
it('should support arrow keyboard navigation after tab keyboard navigation', fakeAsync(() => {
|
||||
fixture.nativeElement
|
||||
.querySelector('button')
|
||||
.dispatchEvent(new MouseEvent('click')) // open
|
||||
fixture.detectChanges()
|
||||
tick(100)
|
||||
const filterInputEl: HTMLInputElement =
|
||||
component.listFilterTextInput.nativeElement
|
||||
expect(document.activeElement).toEqual(filterInputEl)
|
||||
const itemButtons = Array.from(
|
||||
(fixture.nativeElement as HTMLDivElement).querySelectorAll(
|
||||
'.custom-fields-dropdown button'
|
||||
)
|
||||
).filter((b) => b.textContent.includes('Field'))
|
||||
filterInputEl.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'Tab', bubbles: true })
|
||||
)
|
||||
itemButtons[0]['focus']() // normally handled by browser
|
||||
itemButtons[0].dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'Tab', bubbles: true })
|
||||
)
|
||||
itemButtons[1]['focus']() // normally handled by browser
|
||||
itemButtons[1].dispatchEvent(
|
||||
new KeyboardEvent('keydown', {
|
||||
key: 'Tab',
|
||||
shiftKey: true,
|
||||
bubbles: true,
|
||||
})
|
||||
)
|
||||
itemButtons[0]['focus']() // normally handled by browser
|
||||
itemButtons[0].dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true })
|
||||
)
|
||||
expect(document.activeElement).toEqual(itemButtons[1])
|
||||
}))
|
||||
|
||||
it('should support enter keyboard navigation', fakeAsync(() => {
|
||||
jest.spyOn(component, 'canCreateFields', 'get').mockReturnValue(true)
|
||||
const addFieldSpy = jest.spyOn(component, 'addField')
|
||||
const createFieldSpy = jest.spyOn(component, 'createField')
|
||||
component.filterText = 'Field 1'
|
||||
component.listFilterEnter()
|
||||
expect(addFieldSpy).toHaveBeenCalled()
|
||||
|
||||
component.filterText = 'Field 3'
|
||||
component.listFilterEnter()
|
||||
expect(createFieldSpy).toHaveBeenCalledWith('Field 3')
|
||||
|
||||
addFieldSpy.mockClear()
|
||||
createFieldSpy.mockClear()
|
||||
|
||||
component.filterText = undefined
|
||||
component.listFilterEnter()
|
||||
expect(createFieldSpy).not.toHaveBeenCalled()
|
||||
expect(addFieldSpy).not.toHaveBeenCalled()
|
||||
}))
|
||||
})
|
||||
|
@ -1,13 +1,17 @@
|
||||
import {
|
||||
Component,
|
||||
ElementRef,
|
||||
EventEmitter,
|
||||
Input,
|
||||
OnDestroy,
|
||||
Output,
|
||||
QueryList,
|
||||
ViewChild,
|
||||
ViewChildren,
|
||||
} from '@angular/core'
|
||||
import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { Subject, first, takeUntil } from 'rxjs'
|
||||
import { CustomField } from 'src/app/data/custom-field'
|
||||
import { CustomField, DATA_TYPE_LABELS } from 'src/app/data/custom-field'
|
||||
import { CustomFieldInstance } from 'src/app/data/custom-field-instance'
|
||||
import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
|
||||
import { ToastService } from 'src/app/services/toast.service'
|
||||
@ -39,23 +43,25 @@ export class CustomFieldsDropdownComponent implements OnDestroy {
|
||||
@Output()
|
||||
created: EventEmitter<CustomField> = new EventEmitter()
|
||||
|
||||
@ViewChild('listFilterTextInput') listFilterTextInput: ElementRef
|
||||
@ViewChildren('button') buttons: QueryList<ElementRef>
|
||||
|
||||
private customFields: CustomField[] = []
|
||||
public unusedFields: CustomField[]
|
||||
private unusedFields: CustomField[] = []
|
||||
private keyboardIndex: number
|
||||
|
||||
public name: string
|
||||
public get filteredFields(): CustomField[] {
|
||||
return this.unusedFields.filter(
|
||||
(f) =>
|
||||
!this.filterText ||
|
||||
f.name.toLowerCase().includes(this.filterText.toLowerCase())
|
||||
)
|
||||
}
|
||||
|
||||
public field: number
|
||||
public filterText: string
|
||||
|
||||
private unsubscribeNotifier: Subject<any> = new Subject()
|
||||
|
||||
get placeholderText(): string {
|
||||
return $localize`Choose field`
|
||||
}
|
||||
|
||||
get notFoundText(): string {
|
||||
return $localize`No unused fields found`
|
||||
}
|
||||
|
||||
get canCreateFields(): boolean {
|
||||
return this.permissionsService.currentUserCan(
|
||||
PermissionAction.Add,
|
||||
@ -87,28 +93,26 @@ export class CustomFieldsDropdownComponent implements OnDestroy {
|
||||
})
|
||||
}
|
||||
|
||||
public getCustomFieldFromInstance(
|
||||
instance: CustomFieldInstance
|
||||
): CustomField {
|
||||
return this.customFields.find((f) => f.id === instance.field)
|
||||
}
|
||||
|
||||
private updateUnusedFields() {
|
||||
this.unusedFields = this.customFields.filter(
|
||||
(f) =>
|
||||
!this.existingFields?.find(
|
||||
(e) => this.getCustomFieldFromInstance(e)?.id === f.id
|
||||
)
|
||||
(f) => !this.existingFields?.find((e) => e.field === f.id)
|
||||
)
|
||||
}
|
||||
|
||||
onOpenClose() {
|
||||
this.field = undefined
|
||||
onOpenClose(open: boolean) {
|
||||
if (open) {
|
||||
setTimeout(() => {
|
||||
this.listFilterTextInput.nativeElement.focus()
|
||||
}, 100)
|
||||
} else {
|
||||
this.filterText = undefined
|
||||
}
|
||||
this.updateUnusedFields()
|
||||
}
|
||||
|
||||
addField() {
|
||||
this.added.emit(this.customFields.find((f) => f.id === this.field))
|
||||
addField(field: CustomField) {
|
||||
this.added.emit(field)
|
||||
this.updateUnusedFields()
|
||||
}
|
||||
|
||||
createField(newName: string = null) {
|
||||
@ -121,6 +125,7 @@ export class CustomFieldsDropdownComponent implements OnDestroy {
|
||||
this.customFieldsService.clearCache()
|
||||
this.getFields()
|
||||
this.created.emit(newField)
|
||||
setTimeout(() => this.addField(newField), 100)
|
||||
})
|
||||
modal.componentInstance.failed
|
||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||
@ -128,4 +133,82 @@ export class CustomFieldsDropdownComponent implements OnDestroy {
|
||||
this.toastService.showError($localize`Error saving field.`, e)
|
||||
})
|
||||
}
|
||||
|
||||
getDataTypeLabel(dataType: string) {
|
||||
return DATA_TYPE_LABELS.find((l) => l.id === dataType)?.name
|
||||
}
|
||||
|
||||
public listFilterEnter() {
|
||||
if (this.filteredFields.length === 1) {
|
||||
this.addField(this.filteredFields[0])
|
||||
} else if (
|
||||
this.filterText &&
|
||||
this.filteredFields.length === 0 &&
|
||||
this.canCreateFields
|
||||
) {
|
||||
this.createField(this.filterText)
|
||||
}
|
||||
}
|
||||
|
||||
private focusNextButtonItem(setFocus: boolean = true) {
|
||||
this.keyboardIndex = Math.min(
|
||||
this.buttons.length - 1,
|
||||
this.keyboardIndex + 1
|
||||
)
|
||||
if (setFocus) this.setButtonItemFocus()
|
||||
}
|
||||
|
||||
focusPreviousButtonItem(setFocus: boolean = true) {
|
||||
this.keyboardIndex = Math.max(0, this.keyboardIndex - 1)
|
||||
if (setFocus) this.setButtonItemFocus()
|
||||
}
|
||||
|
||||
setButtonItemFocus() {
|
||||
this.buttons.get(this.keyboardIndex)?.nativeElement.focus()
|
||||
}
|
||||
|
||||
public listKeyDown(event: KeyboardEvent) {
|
||||
switch (event.key) {
|
||||
case 'ArrowDown':
|
||||
if (event.target instanceof HTMLInputElement) {
|
||||
if (
|
||||
!this.filterText ||
|
||||
event.target.selectionStart === this.filterText.length
|
||||
) {
|
||||
this.keyboardIndex = -1
|
||||
this.focusNextButtonItem()
|
||||
event.preventDefault()
|
||||
}
|
||||
} else if (event.target instanceof HTMLButtonElement) {
|
||||
this.focusNextButtonItem()
|
||||
event.preventDefault()
|
||||
}
|
||||
break
|
||||
case 'ArrowUp':
|
||||
if (event.target instanceof HTMLButtonElement) {
|
||||
if (this.keyboardIndex === 0) {
|
||||
this.listFilterTextInput.nativeElement.focus()
|
||||
} else {
|
||||
this.focusPreviousButtonItem()
|
||||
}
|
||||
event.preventDefault()
|
||||
}
|
||||
break
|
||||
case 'Tab':
|
||||
// just track the index in case user uses arrows
|
||||
if (event.target instanceof HTMLInputElement) {
|
||||
this.keyboardIndex = 0
|
||||
} else if (event.target instanceof HTMLButtonElement) {
|
||||
if (event.shiftKey) {
|
||||
if (this.keyboardIndex > 0) {
|
||||
this.focusPreviousButtonItem(false)
|
||||
}
|
||||
} else {
|
||||
this.focusNextButtonItem(false)
|
||||
}
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -1,71 +0,0 @@
|
||||
<div class="btn-group w-100" ngbDropdown role="group">
|
||||
<button class="btn btn-sm" id="dropdown{{title}}" ngbDropdownToggle [ngClass]="dateBefore || dateAfter ? 'btn-primary' : 'btn-outline-primary'" [disabled]="disabled">
|
||||
{{title}}
|
||||
<pngx-clearable-badge [selected]="isActive" (cleared)="reset()"></pngx-clearable-badge><span class="visually-hidden">selected</span>
|
||||
</button>
|
||||
<div class="dropdown-menu date-dropdown shadow pt-0" ngbDropdownMenu attr.aria-labelledby="dropdown{{title}}">
|
||||
<div class="list-group list-group-flush">
|
||||
@for (rd of relativeDates; track rd) {
|
||||
<button class="list-group-item small list-goup list-group-item-action d-flex p-2" role="menuitem" (click)="setRelativeDate(rd.id)">
|
||||
<div class="selected-icon">
|
||||
@if (relativeDate === rd.id) {
|
||||
<i-bs width="1em" height="1em" name="check"></i-bs>
|
||||
}
|
||||
</div>
|
||||
<div class="d-flex justify-content-between w-100 align-items-center ps-2">
|
||||
<div class="pe-2 pe-lg-4">
|
||||
{{rd.name}}
|
||||
</div>
|
||||
<div class="text-muted small pe-2">
|
||||
<span class="small">
|
||||
{{ rd.date | customDate:'mediumDate' }} – <ng-container i18n>now</ng-container>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
}
|
||||
<div class="list-group-item d-flex flex-column align-items-start" role="menuitem">
|
||||
|
||||
<div class="mb-2 d-flex flex-row w-100 justify-content-between small">
|
||||
<div i18n>After</div>
|
||||
@if (dateAfter) {
|
||||
<a class="btn btn-link p-0 m-0" (click)="clearAfter()">
|
||||
<i-bs width="1em" height="1em" name="x"></i-bs>
|
||||
<small i18n>Clear</small>
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="input-group input-group-sm">
|
||||
<input class="form-control" [placeholder]="datePlaceHolder" (dateSelect)="onChangeDebounce()" (change)="onChangeDebounce()" (keypress)="onKeyPress($event)"
|
||||
maxlength="10" [(ngModel)]="dateAfter" ngbDatepicker #dateAfterPicker="ngbDatepicker">
|
||||
<button class="btn btn-outline-secondary" (click)="dateAfterPicker.toggle()" type="button">
|
||||
<i-bs width="1em" height="1em" name="calendar"></i-bs>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div class="list-group-item d-flex flex-column align-items-start" role="menuitem">
|
||||
|
||||
<div class="mb-2 d-flex flex-row w-100 justify-content-between small">
|
||||
<div i18n>Before</div>
|
||||
@if (dateBefore) {
|
||||
<a class="btn btn-link p-0 m-0" (click)="clearBefore()">
|
||||
<i-bs width="1em" height="1em" name="x"></i-bs>
|
||||
<small i18n>Clear</small>
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="input-group input-group-sm">
|
||||
<input class="form-control" [placeholder]="datePlaceHolder" (dateSelect)="onChangeDebounce()" (change)="onChangeDebounce()" (keypress)="onKeyPress($event)"
|
||||
maxlength="10" [(ngModel)]="dateBefore" ngbDatepicker #dateBeforePicker="ngbDatepicker">
|
||||
<button class="btn btn-outline-secondary" (click)="dateBeforePicker.toggle()" type="button">
|
||||
<i-bs width="1em" height="1em" name="calendar"></i-bs>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
@ -1,164 +0,0 @@
|
||||
import {
|
||||
Component,
|
||||
EventEmitter,
|
||||
Input,
|
||||
Output,
|
||||
OnInit,
|
||||
OnDestroy,
|
||||
} from '@angular/core'
|
||||
import { NgbDateAdapter } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { Subject, Subscription } from 'rxjs'
|
||||
import { debounceTime } from 'rxjs/operators'
|
||||
import { SettingsService } from 'src/app/services/settings.service'
|
||||
import { ISODateAdapter } from 'src/app/utils/ngb-iso-date-adapter'
|
||||
|
||||
export interface DateSelection {
|
||||
before?: string
|
||||
after?: string
|
||||
relativeDateID?: number
|
||||
}
|
||||
|
||||
export enum RelativeDate {
|
||||
LAST_7_DAYS = 0,
|
||||
LAST_MONTH = 1,
|
||||
LAST_3_MONTHS = 2,
|
||||
LAST_YEAR = 3,
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'pngx-date-dropdown',
|
||||
templateUrl: './date-dropdown.component.html',
|
||||
styleUrls: ['./date-dropdown.component.scss'],
|
||||
providers: [{ provide: NgbDateAdapter, useClass: ISODateAdapter }],
|
||||
})
|
||||
export class DateDropdownComponent implements OnInit, OnDestroy {
|
||||
constructor(settings: SettingsService) {
|
||||
this.datePlaceHolder = settings.getLocalizedDateInputFormat()
|
||||
}
|
||||
|
||||
relativeDates = [
|
||||
{
|
||||
id: RelativeDate.LAST_7_DAYS,
|
||||
name: $localize`Last 7 days`,
|
||||
date: new Date().setDate(new Date().getDate() - 7),
|
||||
},
|
||||
{
|
||||
id: RelativeDate.LAST_MONTH,
|
||||
name: $localize`Last month`,
|
||||
date: new Date().setMonth(new Date().getMonth() - 1),
|
||||
},
|
||||
{
|
||||
id: RelativeDate.LAST_3_MONTHS,
|
||||
name: $localize`Last 3 months`,
|
||||
date: new Date().setMonth(new Date().getMonth() - 3),
|
||||
},
|
||||
{
|
||||
id: RelativeDate.LAST_YEAR,
|
||||
name: $localize`Last year`,
|
||||
date: new Date().setFullYear(new Date().getFullYear() - 1),
|
||||
},
|
||||
]
|
||||
|
||||
datePlaceHolder: string
|
||||
|
||||
@Input()
|
||||
dateBefore: string
|
||||
|
||||
@Output()
|
||||
dateBeforeChange = new EventEmitter<string>()
|
||||
|
||||
@Input()
|
||||
dateAfter: string
|
||||
|
||||
@Output()
|
||||
dateAfterChange = new EventEmitter<string>()
|
||||
|
||||
@Input()
|
||||
relativeDate: RelativeDate
|
||||
|
||||
@Output()
|
||||
relativeDateChange = new EventEmitter<number>()
|
||||
|
||||
@Input()
|
||||
title: string
|
||||
|
||||
@Output()
|
||||
datesSet = new EventEmitter<DateSelection>()
|
||||
|
||||
@Input()
|
||||
disabled: boolean = false
|
||||
|
||||
get isActive(): boolean {
|
||||
return (
|
||||
this.relativeDate !== null ||
|
||||
this.dateAfter?.length > 0 ||
|
||||
this.dateBefore?.length > 0
|
||||
)
|
||||
}
|
||||
|
||||
private datesSetDebounce$ = new Subject()
|
||||
|
||||
private sub: Subscription
|
||||
|
||||
ngOnInit() {
|
||||
this.sub = this.datesSetDebounce$.pipe(debounceTime(400)).subscribe(() => {
|
||||
this.onChange()
|
||||
})
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
if (this.sub) {
|
||||
this.sub.unsubscribe()
|
||||
}
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.dateBefore = null
|
||||
this.dateAfter = null
|
||||
this.relativeDate = null
|
||||
this.onChange()
|
||||
}
|
||||
|
||||
setRelativeDate(rd: RelativeDate) {
|
||||
this.dateBefore = null
|
||||
this.dateAfter = null
|
||||
this.relativeDate = this.relativeDate == rd ? null : rd
|
||||
this.onChange()
|
||||
}
|
||||
|
||||
onChange() {
|
||||
this.dateBeforeChange.emit(this.dateBefore)
|
||||
this.dateAfterChange.emit(this.dateAfter)
|
||||
this.relativeDateChange.emit(this.relativeDate)
|
||||
this.datesSet.emit({
|
||||
after: this.dateAfter,
|
||||
before: this.dateBefore,
|
||||
relativeDateID: this.relativeDate,
|
||||
})
|
||||
}
|
||||
|
||||
onChangeDebounce() {
|
||||
this.relativeDate = null
|
||||
this.datesSetDebounce$.next({
|
||||
after: this.dateAfter,
|
||||
before: this.dateBefore,
|
||||
})
|
||||
}
|
||||
|
||||
clearBefore() {
|
||||
this.dateBefore = null
|
||||
this.onChange()
|
||||
}
|
||||
|
||||
clearAfter() {
|
||||
this.dateAfter = null
|
||||
this.onChange()
|
||||
}
|
||||
|
||||
// prevent chars other than numbers and separators
|
||||
onKeyPress(event: KeyboardEvent) {
|
||||
if ('Enter' !== event.key && !/[0-9,\.\/-]+/.test(event.key)) {
|
||||
event.preventDefault()
|
||||
}
|
||||
}
|
||||
}
|
@ -0,0 +1,143 @@
|
||||
<div class="btn-group w-100" ngbDropdown role="group">
|
||||
<button class="btn btn-sm" id="dropdown{{title}}" ngbDropdownToggle [ngClass]="createdDateBefore || createdDateAfter ? 'btn-primary' : 'btn-outline-primary'" [disabled]="disabled">
|
||||
<i-bs width="1em" height="1em" name="calendar-event-fill"></i-bs>
|
||||
<div class="d-none d-sm-inline"> {{title}}</div>
|
||||
<pngx-clearable-badge [selected]="isActive" (cleared)="reset()"></pngx-clearable-badge><span class="visually-hidden">selected</span>
|
||||
</button>
|
||||
<div class="dropdown-menu date-dropdown shadow p-2" ngbDropdownMenu attr.aria-labelledby="dropdown{{title}}">
|
||||
<div class="row d-flex">
|
||||
<div class="col border-end">
|
||||
<div class="list-group list-group-flush">
|
||||
<h6 class="dropdown-header border-bottom" i18n>Created</h6>
|
||||
@for (rd of relativeDates; track rd) {
|
||||
<button class="list-group-item small list-goup list-group-item-action d-flex p-2" role="menuitem" (click)="setCreatedRelativeDate(rd.id)">
|
||||
<div class="selected-icon">
|
||||
@if (createdRelativeDate === rd.id) {
|
||||
<i-bs width="1em" height="1em" name="check"></i-bs>
|
||||
}
|
||||
</div>
|
||||
<div class="d-flex justify-content-between w-100 align-items-center ps-2">
|
||||
<div class="pe-2 pe-lg-4">
|
||||
{{rd.name}}
|
||||
</div>
|
||||
<div class="text-muted small pe-2">
|
||||
<span class="small">
|
||||
{{ rd.date | customDate:'mediumDate' }} – <ng-container i18n>now</ng-container>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
}
|
||||
<div class="list-group-item d-flex flex-column align-items-start" role="menuitem">
|
||||
|
||||
<div class="mb-2 d-flex flex-row w-100 justify-content-between small">
|
||||
<div i18n>After</div>
|
||||
@if (createdDateAfter) {
|
||||
<a class="btn btn-link p-0 m-0" (click)="clearCreatedAfter()">
|
||||
<i-bs width="1em" height="1em" name="x"></i-bs>
|
||||
<small i18n>Clear</small>
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="input-group input-group-sm">
|
||||
<input class="form-control" [placeholder]="datePlaceHolder" (dateSelect)="onChangeDebounce()" (change)="onChangeDebounce()" (keypress)="onKeyPress($event)"
|
||||
maxlength="10" [(ngModel)]="createdDateAfter" ngbDatepicker #createdDateAfterPicker="ngbDatepicker">
|
||||
<button class="btn btn-outline-secondary" (click)="createdDateAfterPicker.toggle()" type="button">
|
||||
<i-bs width="1em" height="1em" name="calendar"></i-bs>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div class="list-group-item d-flex flex-column align-items-start" role="menuitem">
|
||||
|
||||
<div class="mb-2 d-flex flex-row w-100 justify-content-between small">
|
||||
<div i18n>Before</div>
|
||||
@if (createdDateBefore) {
|
||||
<a class="btn btn-link p-0 m-0" (click)="clearCreatedBefore()">
|
||||
<i-bs width="1em" height="1em" name="x"></i-bs>
|
||||
<small i18n>Clear</small>
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="input-group input-group-sm">
|
||||
<input class="form-control" [placeholder]="datePlaceHolder" (dateSelect)="onChangeDebounce()" (change)="onChangeDebounce()" (keypress)="onKeyPress($event)"
|
||||
maxlength="10" [(ngModel)]="createdDateBefore" ngbDatepicker #createdDateBeforePicker="ngbDatepicker">
|
||||
<button class="btn btn-outline-secondary" (click)="createdDateBeforePicker.toggle()" type="button">
|
||||
<i-bs width="1em" height="1em" name="calendar"></i-bs>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col">
|
||||
<h6 class="dropdown-header border-bottom" i18n>Added</h6>
|
||||
<div class="list-group list-group-flush">
|
||||
@for (rd of relativeDates; track rd) {
|
||||
<button class="list-group-item small list-goup list-group-item-action d-flex p-2" role="menuitem" (click)="setAddedRelativeDate(rd.id)">
|
||||
<div class="selected-icon">
|
||||
@if (addedRelativeDate === rd.id) {
|
||||
<i-bs width="1em" height="1em" name="check"></i-bs>
|
||||
}
|
||||
</div>
|
||||
<div class="d-flex justify-content-between w-100 align-items-center ps-2">
|
||||
<div class="pe-2 pe-lg-4">
|
||||
{{rd.name}}
|
||||
</div>
|
||||
<div class="text-muted small pe-2">
|
||||
<span class="small">
|
||||
{{ rd.date | customDate:'mediumDate' }} – <ng-container i18n>now</ng-container>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
}
|
||||
<div class="list-group-item d-flex flex-column align-items-start" role="menuitem">
|
||||
|
||||
<div class="mb-2 d-flex flex-row w-100 justify-content-between small">
|
||||
<div i18n>After</div>
|
||||
@if (addedDateAfter) {
|
||||
<a class="btn btn-link p-0 m-0" (click)="clearAddedAfter()">
|
||||
<i-bs width="1em" height="1em" name="x"></i-bs>
|
||||
<small i18n>Clear</small>
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="input-group input-group-sm">
|
||||
<input class="form-control" [placeholder]="datePlaceHolder" (dateSelect)="onChangeDebounce()" (change)="onChangeDebounce()" (keypress)="onKeyPress($event)"
|
||||
maxlength="10" [(ngModel)]="addedDateAfter" ngbDatepicker #addedDateAfterPicker="ngbDatepicker">
|
||||
<button class="btn btn-outline-secondary" (click)="addedDateAfterPicker.toggle()" type="button">
|
||||
<i-bs width="1em" height="1em" name="calendar"></i-bs>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div class="list-group-item d-flex flex-column align-items-start" role="menuitem">
|
||||
|
||||
<div class="mb-2 d-flex flex-row w-100 justify-content-between small">
|
||||
<div i18n>Before</div>
|
||||
@if (addedDateBefore) {
|
||||
<a class="btn btn-link p-0 m-0" (click)="clearAddedBefore()">
|
||||
<i-bs width="1em" height="1em" name="x"></i-bs>
|
||||
<small i18n>Clear</small>
|
||||
</a>
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="input-group input-group-sm">
|
||||
<input class="form-control" [placeholder]="datePlaceHolder" (dateSelect)="onChangeDebounce()" (change)="onChangeDebounce()" (keypress)="onKeyPress($event)"
|
||||
maxlength="10" [(ngModel)]="addedDateBefore" ngbDatepicker #addedDateBeforePicker="ngbDatepicker">
|
||||
<button class="btn btn-outline-secondary" (click)="addedDateBeforePicker.toggle()" type="button">
|
||||
<i-bs width="1em" height="1em" name="calendar"></i-bs>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
@ -1,6 +1,10 @@
|
||||
.date-dropdown {
|
||||
white-space: nowrap;
|
||||
|
||||
@media(min-width: 768px) {
|
||||
--bs-dropdown-min-width: 40rem;
|
||||
}
|
||||
|
||||
.btn-link {
|
||||
line-height: 1;
|
||||
}
|
@ -4,12 +4,12 @@ import {
|
||||
fakeAsync,
|
||||
tick,
|
||||
} from '@angular/core/testing'
|
||||
let fixture: ComponentFixture<DateDropdownComponent>
|
||||
let fixture: ComponentFixture<DatesDropdownComponent>
|
||||
import {
|
||||
DateDropdownComponent,
|
||||
DatesDropdownComponent,
|
||||
DateSelection,
|
||||
RelativeDate,
|
||||
} from './date-dropdown.component'
|
||||
} from './dates-dropdown.component'
|
||||
import { HttpClientTestingModule } from '@angular/common/http/testing'
|
||||
import { NgbModule } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { SettingsService } from 'src/app/services/settings.service'
|
||||
@ -19,15 +19,15 @@ import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
|
||||
import { DatePipe } from '@angular/common'
|
||||
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
||||
|
||||
describe('DateDropdownComponent', () => {
|
||||
let component: DateDropdownComponent
|
||||
describe('DatesDropdownComponent', () => {
|
||||
let component: DatesDropdownComponent
|
||||
let settingsService: SettingsService
|
||||
let settingsSpy
|
||||
|
||||
beforeEach(async () => {
|
||||
TestBed.configureTestingModule({
|
||||
declarations: [
|
||||
DateDropdownComponent,
|
||||
DatesDropdownComponent,
|
||||
ClearableBadgeComponent,
|
||||
CustomDatePipe,
|
||||
],
|
||||
@ -44,7 +44,7 @@ describe('DateDropdownComponent', () => {
|
||||
settingsService = TestBed.inject(SettingsService)
|
||||
settingsSpy = jest.spyOn(settingsService, 'getLocalizedDateInputFormat')
|
||||
|
||||
fixture = TestBed.createComponent(DateDropdownComponent)
|
||||
fixture = TestBed.createComponent(DatesDropdownComponent)
|
||||
component = fixture.componentInstance
|
||||
|
||||
fixture.detectChanges()
|
||||
@ -57,7 +57,7 @@ describe('DateDropdownComponent', () => {
|
||||
|
||||
it('should support date input, emit change', fakeAsync(() => {
|
||||
let result: string
|
||||
component.dateAfterChange.subscribe((date) => (result = date))
|
||||
component.createdDateAfterChange.subscribe((date) => (result = date))
|
||||
const input: HTMLInputElement = fixture.nativeElement.querySelector('input')
|
||||
input.value = '5/30/2023'
|
||||
input.dispatchEvent(new Event('change'))
|
||||
@ -78,45 +78,69 @@ describe('DateDropdownComponent', () => {
|
||||
it('should support relative dates', fakeAsync(() => {
|
||||
let result: DateSelection
|
||||
component.datesSet.subscribe((date) => (result = date))
|
||||
component.setRelativeDate(null)
|
||||
component.setRelativeDate(RelativeDate.LAST_7_DAYS)
|
||||
component.setCreatedRelativeDate(null)
|
||||
component.setCreatedRelativeDate(RelativeDate.LAST_7_DAYS)
|
||||
component.setAddedRelativeDate(null)
|
||||
component.setAddedRelativeDate(RelativeDate.LAST_7_DAYS)
|
||||
tick(500)
|
||||
expect(result).toEqual({
|
||||
after: null,
|
||||
before: null,
|
||||
relativeDateID: RelativeDate.LAST_7_DAYS,
|
||||
createdAfter: null,
|
||||
createdBefore: null,
|
||||
createdRelativeDateID: RelativeDate.LAST_7_DAYS,
|
||||
addedAfter: null,
|
||||
addedBefore: null,
|
||||
addedRelativeDateID: RelativeDate.LAST_7_DAYS,
|
||||
})
|
||||
}))
|
||||
|
||||
it('should support report if active', () => {
|
||||
component.relativeDate = RelativeDate.LAST_7_DAYS
|
||||
component.createdRelativeDate = RelativeDate.LAST_7_DAYS
|
||||
expect(component.isActive).toBeTruthy()
|
||||
component.relativeDate = null
|
||||
component.dateAfter = '2023-05-30'
|
||||
component.createdRelativeDate = null
|
||||
component.createdDateAfter = '2023-05-30'
|
||||
expect(component.isActive).toBeTruthy()
|
||||
component.dateAfter = null
|
||||
component.dateBefore = '2023-05-30'
|
||||
component.createdDateAfter = null
|
||||
component.createdDateBefore = '2023-05-30'
|
||||
expect(component.isActive).toBeTruthy()
|
||||
component.dateBefore = null
|
||||
component.createdDateBefore = null
|
||||
|
||||
component.addedRelativeDate = RelativeDate.LAST_7_DAYS
|
||||
expect(component.isActive).toBeTruthy()
|
||||
component.addedRelativeDate = null
|
||||
component.addedDateAfter = '2023-05-30'
|
||||
expect(component.isActive).toBeTruthy()
|
||||
component.addedDateAfter = null
|
||||
component.addedDateBefore = '2023-05-30'
|
||||
expect(component.isActive).toBeTruthy()
|
||||
component.addedDateBefore = null
|
||||
|
||||
expect(component.isActive).toBeFalsy()
|
||||
})
|
||||
|
||||
it('should support reset', () => {
|
||||
component.dateAfter = '2023-05-30'
|
||||
component.createdDateAfter = '2023-05-30'
|
||||
component.reset()
|
||||
expect(component.dateAfter).toBeNull()
|
||||
expect(component.createdDateAfter).toBeNull()
|
||||
})
|
||||
|
||||
it('should support clearAfter', () => {
|
||||
component.dateAfter = '2023-05-30'
|
||||
component.clearAfter()
|
||||
expect(component.dateAfter).toBeNull()
|
||||
component.createdDateAfter = '2023-05-30'
|
||||
component.clearCreatedAfter()
|
||||
expect(component.createdDateAfter).toBeNull()
|
||||
|
||||
component.addedDateAfter = '2023-05-30'
|
||||
component.clearAddedAfter()
|
||||
expect(component.addedDateAfter).toBeNull()
|
||||
})
|
||||
|
||||
it('should support clearBefore', () => {
|
||||
component.dateBefore = '2023-05-30'
|
||||
component.clearBefore()
|
||||
expect(component.dateBefore).toBeNull()
|
||||
component.createdDateBefore = '2023-05-30'
|
||||
component.clearCreatedBefore()
|
||||
expect(component.createdDateBefore).toBeNull()
|
||||
|
||||
component.addedDateBefore = '2023-05-30'
|
||||
component.clearAddedBefore()
|
||||
expect(component.addedDateBefore).toBeNull()
|
||||
})
|
||||
|
||||
it('should limit keyboard events', () => {
|
@ -0,0 +1,219 @@
|
||||
import {
|
||||
Component,
|
||||
EventEmitter,
|
||||
Input,
|
||||
Output,
|
||||
OnInit,
|
||||
OnDestroy,
|
||||
} from '@angular/core'
|
||||
import { NgbDateAdapter } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { Subject, Subscription } from 'rxjs'
|
||||
import { debounceTime } from 'rxjs/operators'
|
||||
import { SettingsService } from 'src/app/services/settings.service'
|
||||
import { ISODateAdapter } from 'src/app/utils/ngb-iso-date-adapter'
|
||||
|
||||
export interface DateSelection {
|
||||
createdBefore?: string
|
||||
createdAfter?: string
|
||||
createdRelativeDateID?: number
|
||||
addedBefore?: string
|
||||
addedAfter?: string
|
||||
addedRelativeDateID?: number
|
||||
}
|
||||
|
||||
export enum RelativeDate {
|
||||
LAST_7_DAYS = 0,
|
||||
LAST_MONTH = 1,
|
||||
LAST_3_MONTHS = 2,
|
||||
LAST_YEAR = 3,
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'pngx-dates-dropdown',
|
||||
templateUrl: './dates-dropdown.component.html',
|
||||
styleUrls: ['./dates-dropdown.component.scss'],
|
||||
providers: [{ provide: NgbDateAdapter, useClass: ISODateAdapter }],
|
||||
})
|
||||
export class DatesDropdownComponent implements OnInit, OnDestroy {
|
||||
constructor(settings: SettingsService) {
|
||||
this.datePlaceHolder = settings.getLocalizedDateInputFormat()
|
||||
}
|
||||
|
||||
relativeDates = [
|
||||
{
|
||||
id: RelativeDate.LAST_7_DAYS,
|
||||
name: $localize`Last 7 days`,
|
||||
date: new Date().setDate(new Date().getDate() - 7),
|
||||
},
|
||||
{
|
||||
id: RelativeDate.LAST_MONTH,
|
||||
name: $localize`Last month`,
|
||||
date: new Date().setMonth(new Date().getMonth() - 1),
|
||||
},
|
||||
{
|
||||
id: RelativeDate.LAST_3_MONTHS,
|
||||
name: $localize`Last 3 months`,
|
||||
date: new Date().setMonth(new Date().getMonth() - 3),
|
||||
},
|
||||
{
|
||||
id: RelativeDate.LAST_YEAR,
|
||||
name: $localize`Last year`,
|
||||
date: new Date().setFullYear(new Date().getFullYear() - 1),
|
||||
},
|
||||
]
|
||||
|
||||
datePlaceHolder: string
|
||||
|
||||
// created
|
||||
@Input()
|
||||
createdDateBefore: string
|
||||
|
||||
@Output()
|
||||
createdDateBeforeChange = new EventEmitter<string>()
|
||||
|
||||
@Input()
|
||||
createdDateAfter: string
|
||||
|
||||
@Output()
|
||||
createdDateAfterChange = new EventEmitter<string>()
|
||||
|
||||
@Input()
|
||||
createdRelativeDate: RelativeDate
|
||||
|
||||
@Output()
|
||||
createdRelativeDateChange = new EventEmitter<number>()
|
||||
|
||||
// added
|
||||
@Input()
|
||||
addedDateBefore: string
|
||||
|
||||
@Output()
|
||||
addedDateBeforeChange = new EventEmitter<string>()
|
||||
|
||||
@Input()
|
||||
addedDateAfter: string
|
||||
|
||||
@Output()
|
||||
addedDateAfterChange = new EventEmitter<string>()
|
||||
|
||||
@Input()
|
||||
addedRelativeDate: RelativeDate
|
||||
|
||||
@Output()
|
||||
addedRelativeDateChange = new EventEmitter<number>()
|
||||
|
||||
@Input()
|
||||
title: string
|
||||
|
||||
@Output()
|
||||
datesSet = new EventEmitter<DateSelection>()
|
||||
|
||||
@Input()
|
||||
disabled: boolean = false
|
||||
|
||||
get isActive(): boolean {
|
||||
return (
|
||||
this.createdRelativeDate !== null ||
|
||||
this.createdDateAfter?.length > 0 ||
|
||||
this.createdDateBefore?.length > 0 ||
|
||||
this.addedRelativeDate !== null ||
|
||||
this.addedDateAfter?.length > 0 ||
|
||||
this.addedDateBefore?.length > 0
|
||||
)
|
||||
}
|
||||
|
||||
private datesSetDebounce$ = new Subject()
|
||||
|
||||
private sub: Subscription
|
||||
|
||||
ngOnInit() {
|
||||
this.sub = this.datesSetDebounce$.pipe(debounceTime(400)).subscribe(() => {
|
||||
this.onChange()
|
||||
})
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
if (this.sub) {
|
||||
this.sub.unsubscribe()
|
||||
}
|
||||
}
|
||||
|
||||
reset() {
|
||||
this.createdDateBefore = null
|
||||
this.createdDateAfter = null
|
||||
this.createdRelativeDate = null
|
||||
this.addedDateBefore = null
|
||||
this.addedDateAfter = null
|
||||
this.addedRelativeDate = null
|
||||
this.onChange()
|
||||
}
|
||||
|
||||
setCreatedRelativeDate(rd: RelativeDate) {
|
||||
this.createdDateBefore = null
|
||||
this.createdDateAfter = null
|
||||
this.createdRelativeDate = this.createdRelativeDate == rd ? null : rd
|
||||
this.onChange()
|
||||
}
|
||||
|
||||
setAddedRelativeDate(rd: RelativeDate) {
|
||||
this.addedDateBefore = null
|
||||
this.addedDateAfter = null
|
||||
this.addedRelativeDate = this.addedRelativeDate == rd ? null : rd
|
||||
this.onChange()
|
||||
}
|
||||
|
||||
onChange() {
|
||||
this.createdDateBeforeChange.emit(this.createdDateBefore)
|
||||
this.createdDateAfterChange.emit(this.createdDateAfter)
|
||||
this.createdRelativeDateChange.emit(this.createdRelativeDate)
|
||||
this.addedDateBeforeChange.emit(this.addedDateBefore)
|
||||
this.addedDateAfterChange.emit(this.addedDateAfter)
|
||||
this.addedRelativeDateChange.emit(this.addedRelativeDate)
|
||||
this.datesSet.emit({
|
||||
createdAfter: this.createdDateAfter,
|
||||
createdBefore: this.createdDateBefore,
|
||||
createdRelativeDateID: this.createdRelativeDate,
|
||||
addedAfter: this.addedDateAfter,
|
||||
addedBefore: this.addedDateBefore,
|
||||
addedRelativeDateID: this.addedRelativeDate,
|
||||
})
|
||||
}
|
||||
|
||||
onChangeDebounce() {
|
||||
this.createdRelativeDate = null
|
||||
this.addedRelativeDate = null
|
||||
this.datesSetDebounce$.next({
|
||||
createdAfter: this.createdDateAfter,
|
||||
createdBefore: this.createdDateBefore,
|
||||
addedAfter: this.addedDateAfter,
|
||||
addedBefore: this.addedDateBefore,
|
||||
})
|
||||
}
|
||||
|
||||
clearCreatedBefore() {
|
||||
this.createdDateBefore = null
|
||||
this.onChange()
|
||||
}
|
||||
|
||||
clearCreatedAfter() {
|
||||
this.createdDateAfter = null
|
||||
this.onChange()
|
||||
}
|
||||
|
||||
clearAddedBefore() {
|
||||
this.addedDateBefore = null
|
||||
this.onChange()
|
||||
}
|
||||
|
||||
clearAddedAfter() {
|
||||
this.addedDateAfter = null
|
||||
this.onChange()
|
||||
}
|
||||
|
||||
// prevent chars other than numbers and separators
|
||||
onKeyPress(event: KeyboardEvent) {
|
||||
if ('Enter' !== event.key && !/[0-9,\.\/-]+/.test(event.key)) {
|
||||
event.preventDefault()
|
||||
}
|
||||
}
|
||||
}
|
@ -16,11 +16,15 @@
|
||||
<pngx-input-text i18n-title title="First name" formControlName="first_name" [error]="error?.first_name"></pngx-input-text>
|
||||
<pngx-input-text i18n-title title="Last name" formControlName="last_name" [error]="error?.first_name"></pngx-input-text>
|
||||
|
||||
<div class="mb-2">
|
||||
<div class="mb-2 d-flex flex-column">
|
||||
<div class="form-check form-switch form-check-inline">
|
||||
<input type="checkbox" class="form-check-input" id="is_active" formControlName="is_active">
|
||||
<label class="form-check-label" for="is_active" i18n>Active</label>
|
||||
</div>
|
||||
<div class="form-check form-switch form-check-inline">
|
||||
<input type="checkbox" class="form-check-input" id="is_staff" formControlName="is_staff">
|
||||
<label class="form-check-label" for="is_staff"><ng-container i18n>Admin</ng-container> <small class="form-text text-muted ms-1" i18n>Access logs, Django backend</small></label>
|
||||
</div>
|
||||
<div class="form-check form-switch form-check-inline">
|
||||
<input type="checkbox" class="form-check-input" id="is_superuser" formControlName="is_superuser" (change)="onToggleSuperUser()">
|
||||
<label class="form-check-label" for="is_superuser"><ng-container i18n>Superuser</ng-container> <small class="form-text text-muted ms-1" i18n>(Grants all permissions and can view objects)</small></label>
|
||||
|
@ -56,6 +56,7 @@ export class UserEditDialogComponent
|
||||
first_name: new FormControl(''),
|
||||
last_name: new FormControl(''),
|
||||
is_active: new FormControl(true),
|
||||
is_staff: new FormControl(true),
|
||||
is_superuser: new FormControl(false),
|
||||
groups: new FormControl([]),
|
||||
user_permissions: new FormControl([]),
|
||||
|
@ -26,6 +26,7 @@ import { TagComponent } from '../tag/tag.component'
|
||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||
import { ClearableBadgeComponent } from '../clearable-badge/clearable-badge.component'
|
||||
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
||||
import { HotKeyService } from 'src/app/services/hot-key.service'
|
||||
|
||||
const items: Tag[] = [
|
||||
{
|
||||
@ -53,6 +54,7 @@ let selectionModel: FilterableDropdownSelectionModel
|
||||
describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () => {
|
||||
let component: FilterableDropdownComponent
|
||||
let fixture: ComponentFixture<FilterableDropdownComponent>
|
||||
let hotkeyService: HotKeyService
|
||||
|
||||
beforeEach(async () => {
|
||||
TestBed.configureTestingModule({
|
||||
@ -72,6 +74,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
|
||||
],
|
||||
}).compileComponents()
|
||||
|
||||
hotkeyService = TestBed.inject(HotKeyService)
|
||||
fixture = TestBed.createComponent(FilterableDropdownComponent)
|
||||
component = fixture.componentInstance
|
||||
selectionModel = new FilterableDropdownSelectionModel()
|
||||
@ -577,4 +580,14 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
|
||||
expect(selectionModel.getSelectedItems()).toEqual([items[0]])
|
||||
expect(selectionModel.getExcludedItems()).toEqual([items[1]])
|
||||
})
|
||||
|
||||
it('should support shortcut keys', () => {
|
||||
component.items = items
|
||||
component.icon = 'tag-fill'
|
||||
component.shortcutKey = 't'
|
||||
fixture.detectChanges()
|
||||
const openSpy = jest.spyOn(component.dropdown, 'open')
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', { key: 't' }))
|
||||
expect(openSpy).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
@ -5,14 +5,17 @@ import {
|
||||
Output,
|
||||
ElementRef,
|
||||
ViewChild,
|
||||
OnInit,
|
||||
OnDestroy,
|
||||
} from '@angular/core'
|
||||
import { FilterPipe } from 'src/app/pipes/filter.pipe'
|
||||
import { NgbDropdown } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { ToggleableItemState } from './toggleable-dropdown-button/toggleable-dropdown-button.component'
|
||||
import { MatchingModel } from 'src/app/data/matching-model'
|
||||
import { Subject } from 'rxjs'
|
||||
import { Subject, filter, take, takeUntil } from 'rxjs'
|
||||
import { SelectionDataItem } from 'src/app/services/rest/document.service'
|
||||
import { ObjectWithPermissions } from 'src/app/data/object-with-permissions'
|
||||
import { HotKeyService } from 'src/app/services/hot-key.service'
|
||||
|
||||
export interface ChangedItems {
|
||||
itemsToAdd: MatchingModel[]
|
||||
@ -322,7 +325,7 @@ export class FilterableDropdownSelectionModel {
|
||||
templateUrl: './filterable-dropdown.component.html',
|
||||
styleUrls: ['./filterable-dropdown.component.scss'],
|
||||
})
|
||||
export class FilterableDropdownComponent {
|
||||
export class FilterableDropdownComponent implements OnDestroy, OnInit {
|
||||
@ViewChild('listFilterTextInput') listFilterTextInput: ElementRef
|
||||
@ViewChild('dropdown') dropdown: NgbDropdown
|
||||
@ViewChild('buttonItems') buttonItems: ElementRef
|
||||
@ -419,6 +422,9 @@ export class FilterableDropdownComponent {
|
||||
@Input()
|
||||
documentCounts: SelectionDataItem[]
|
||||
|
||||
@Input()
|
||||
shortcutKey: string
|
||||
|
||||
get name(): string {
|
||||
return this.title ? this.title.replace(/\s/g, '_').toLowerCase() : null
|
||||
}
|
||||
@ -427,12 +433,39 @@ export class FilterableDropdownComponent {
|
||||
|
||||
private keyboardIndex: number
|
||||
|
||||
constructor(private filterPipe: FilterPipe) {
|
||||
private unsubscribeNotifier: Subject<any> = new Subject()
|
||||
|
||||
constructor(
|
||||
private filterPipe: FilterPipe,
|
||||
private hotkeyService: HotKeyService
|
||||
) {
|
||||
this.selectionModelChange.subscribe((updatedModel) => {
|
||||
this.modelIsDirty = updatedModel.isDirty()
|
||||
})
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
if (this.shortcutKey) {
|
||||
this.hotkeyService
|
||||
.addShortcut({
|
||||
keys: this.shortcutKey,
|
||||
description: $localize`Open ${this.title} filter`,
|
||||
})
|
||||
.pipe(
|
||||
takeUntil(this.unsubscribeNotifier),
|
||||
filter(() => !this.disabled)
|
||||
)
|
||||
.subscribe(() => {
|
||||
this.dropdown.open()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
this.unsubscribeNotifier.next(true)
|
||||
this.unsubscribeNotifier.complete()
|
||||
}
|
||||
|
||||
applyClicked() {
|
||||
if (this.selectionModel.isDirty()) {
|
||||
this.dropdown.close()
|
||||
|
@ -0,0 +1,23 @@
|
||||
<div class="modal-header">
|
||||
<h4 class="modal-title" id="modal-basic-title">{{title}}</h4>
|
||||
<button type="button" class="btn-close" aria-label="Close" (click)="close()"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<table class="table">
|
||||
<tbody>
|
||||
@for (key of hotkeys.entries(); track key[0]) {
|
||||
<tr>
|
||||
<td>{{ key[1] }}</td>
|
||||
<td class="d-flex justify-content-end">
|
||||
<kbd [innerHTML]="formatKey(key[0])"></kbd>
|
||||
@if (key[0].includes('control')) {
|
||||
(macOS <kbd [innerHTML]="formatKey(key[0], true)"></kbd>)
|
||||
}
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
</div>
|
@ -0,0 +1,35 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||
|
||||
import { HotkeyDialogComponent } from './hotkey-dialog.component'
|
||||
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
|
||||
|
||||
describe('HotkeyDialogComponent', () => {
|
||||
let component: HotkeyDialogComponent
|
||||
let fixture: ComponentFixture<HotkeyDialogComponent>
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
declarations: [HotkeyDialogComponent],
|
||||
providers: [NgbActiveModal],
|
||||
}).compileComponents()
|
||||
|
||||
fixture = TestBed.createComponent(HotkeyDialogComponent)
|
||||
component = fixture.componentInstance
|
||||
fixture.detectChanges()
|
||||
})
|
||||
|
||||
it('should create', () => {
|
||||
expect(component).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should support close', () => {
|
||||
const closeSpy = jest.spyOn(component.activeModal, 'close')
|
||||
component.close()
|
||||
expect(closeSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should format keys', () => {
|
||||
expect(component.formatKey('control.a')).toEqual('⌃ + a') // ⌃ + a
|
||||
expect(component.formatKey('control.a', true)).toEqual('⌘ + a') // ⌘ + a
|
||||
})
|
||||
})
|
@ -0,0 +1,38 @@
|
||||
import { Component } from '@angular/core'
|
||||
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
|
||||
|
||||
const SYMBOLS = {
|
||||
meta: '⌘', // ⌘
|
||||
control: '⌃', // ⌃
|
||||
shift: '⇧', // ⇧
|
||||
left: '←', // ←
|
||||
right: '→', // →
|
||||
up: '↑', // ↑
|
||||
down: '↓', // ↓
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'pngx-hotkey-dialog',
|
||||
templateUrl: './hotkey-dialog.component.html',
|
||||
styleUrl: './hotkey-dialog.component.scss',
|
||||
})
|
||||
export class HotkeyDialogComponent {
|
||||
public title: string = $localize`Keyboard shortcuts`
|
||||
public hotkeys: Map<string, string> = new Map()
|
||||
|
||||
constructor(public activeModal: NgbActiveModal) {}
|
||||
|
||||
public close(): void {
|
||||
this.activeModal.close()
|
||||
}
|
||||
|
||||
public formatKey(key: string, macOS: boolean = false): string {
|
||||
if (macOS) {
|
||||
key = key.replace('control', 'meta')
|
||||
}
|
||||
return key
|
||||
.split('.')
|
||||
.map((k) => SYMBOLS[k] || k)
|
||||
.join(' + ')
|
||||
}
|
||||
}
|
@ -0,0 +1,26 @@
|
||||
<div class="d-flex flex-row mt-2 align-items-center">
|
||||
<span class="me-2">{{title}}:</span>
|
||||
<div class="d-flex flex-row gap-2 w-100 mh-1" style="min-height: 1em;"
|
||||
cdkDropList #selectedList="cdkDropList"
|
||||
cdkDropListOrientation="horizontal"
|
||||
(cdkDropListDropped)="drop($event)"
|
||||
[cdkDropListConnectedTo]="[unselectedList]">
|
||||
@for (item of selectedItems; track item.id) {
|
||||
<div class="badge bg-primary" cdkDrag>{{item.name}}</div>
|
||||
}
|
||||
@if (selectedItems.length === 0) {
|
||||
<div class="badge bg-light fst-italic" i18n>{{emptyText}}</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
<div class="d-flex flex-row mt-2 align-items-center bg-light p-2">
|
||||
<div class="d-flex flex-row gap-2 w-100 mh-1" style="min-height: 1em;"
|
||||
cdkDropList #unselectedList="cdkDropList"
|
||||
cdkDropListOrientation="horizontal"
|
||||
(cdkDropListDropped)="drop($event)"
|
||||
[cdkDropListConnectedTo]="[selectedList]">
|
||||
@for (item of unselectedItems; track item.id) {
|
||||
<div class="badge bg-secondary opacity-50" cdkDrag>{{item.name}}</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
@ -0,0 +1,7 @@
|
||||
.badge {
|
||||
cursor: move;
|
||||
}
|
||||
|
||||
.d-flex {
|
||||
overflow-x: scroll;
|
||||
}
|
@ -0,0 +1,102 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||
import { DragDropModule } from '@angular/cdk/drag-drop'
|
||||
import { FormsModule, NG_VALUE_ACCESSOR } from '@angular/forms'
|
||||
import { DragDropSelectComponent } from './drag-drop-select.component'
|
||||
|
||||
describe('DragDropSelectComponent', () => {
|
||||
let component: DragDropSelectComponent
|
||||
let fixture: ComponentFixture<DragDropSelectComponent>
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
imports: [DragDropModule, FormsModule],
|
||||
declarations: [DragDropSelectComponent],
|
||||
}).compileComponents()
|
||||
|
||||
fixture = TestBed.createComponent(DragDropSelectComponent)
|
||||
component = fixture.componentInstance
|
||||
fixture.debugElement.injector.get(NG_VALUE_ACCESSOR)
|
||||
fixture.detectChanges()
|
||||
})
|
||||
|
||||
it('should update selectedItems when writeValue is called', () => {
|
||||
const newValue = ['1', '2', '3']
|
||||
component.items = [
|
||||
{ id: '1', name: 'Item 1' },
|
||||
{ id: '2', name: 'Item 2' },
|
||||
{ id: '3', name: 'Item 3' },
|
||||
]
|
||||
component.writeValue(newValue)
|
||||
expect(component.selectedItems).toEqual([
|
||||
{ id: '1', name: 'Item 1' },
|
||||
{ id: '2', name: 'Item 2' },
|
||||
{ id: '3', name: 'Item 3' },
|
||||
])
|
||||
|
||||
component.writeValue(null)
|
||||
expect(component.selectedItems).toEqual([])
|
||||
})
|
||||
|
||||
it('should update selectedItems when an item is dropped within selectedList', () => {
|
||||
component.items = [
|
||||
{ id: '1', name: 'Item 1' },
|
||||
{ id: '2', name: 'Item 2' },
|
||||
{ id: '3', name: 'Item 3' },
|
||||
{ id: '4', name: 'Item 4' },
|
||||
]
|
||||
component.writeValue(['1', '2', '3'])
|
||||
const event = {
|
||||
previousContainer: component.selectedList,
|
||||
container: component.selectedList,
|
||||
previousIndex: 1,
|
||||
currentIndex: 2,
|
||||
}
|
||||
component.drop(event as any)
|
||||
expect(component.selectedItems).toEqual([
|
||||
{ id: '1', name: 'Item 1' },
|
||||
{ id: '3', name: 'Item 3' },
|
||||
{ id: '2', name: 'Item 2' },
|
||||
])
|
||||
})
|
||||
|
||||
it('should update selectedItems when an item is dropped from unselectedList to selectedList', () => {
|
||||
component.items = [
|
||||
{ id: '1', name: 'Item 1' },
|
||||
{ id: '2', name: 'Item 2' },
|
||||
{ id: '3', name: 'Item 3' },
|
||||
]
|
||||
component.writeValue(['1', '2'])
|
||||
const event = {
|
||||
previousContainer: component.unselectedList,
|
||||
container: component.selectedList,
|
||||
previousIndex: 0,
|
||||
currentIndex: 2,
|
||||
}
|
||||
component.drop(event as any)
|
||||
expect(component.selectedItems).toEqual([
|
||||
{ id: '1', name: 'Item 1' },
|
||||
{ id: '2', name: 'Item 2' },
|
||||
{ id: '3', name: 'Item 3' },
|
||||
])
|
||||
})
|
||||
|
||||
it('should update selectedItems when an item is dropped from selectedList to unselectedList', () => {
|
||||
component.items = [
|
||||
{ id: '1', name: 'Item 1' },
|
||||
{ id: '2', name: 'Item 2' },
|
||||
{ id: '3', name: 'Item 3' },
|
||||
]
|
||||
component.writeValue(['1', '2', '3'])
|
||||
const event = {
|
||||
previousContainer: component.selectedList,
|
||||
container: component.unselectedList,
|
||||
previousIndex: 1,
|
||||
currentIndex: 0,
|
||||
}
|
||||
component.drop(event as any)
|
||||
expect(component.selectedItems).toEqual([
|
||||
{ id: '1', name: 'Item 1' },
|
||||
{ id: '3', name: 'Item 3' },
|
||||
])
|
||||
})
|
||||
})
|
@ -0,0 +1,68 @@
|
||||
import { Component, Input, ViewChild, forwardRef } from '@angular/core'
|
||||
import { NG_VALUE_ACCESSOR } from '@angular/forms'
|
||||
import { AbstractInputComponent } from '../abstract-input'
|
||||
import {
|
||||
CdkDragDrop,
|
||||
CdkDropList,
|
||||
moveItemInArray,
|
||||
} from '@angular/cdk/drag-drop'
|
||||
|
||||
@Component({
|
||||
providers: [
|
||||
{
|
||||
provide: NG_VALUE_ACCESSOR,
|
||||
useExisting: forwardRef(() => DragDropSelectComponent),
|
||||
multi: true,
|
||||
},
|
||||
],
|
||||
selector: 'pngx-input-drag-drop-select',
|
||||
templateUrl: './drag-drop-select.component.html',
|
||||
styleUrl: './drag-drop-select.component.scss',
|
||||
})
|
||||
export class DragDropSelectComponent extends AbstractInputComponent<string[]> {
|
||||
@Input() title: string = $localize`Selected items`
|
||||
|
||||
@Input() items: { id: string; name: string }[] = []
|
||||
public selectedItems: { id: string; name: string }[] = []
|
||||
|
||||
@Input()
|
||||
emptyText = $localize`No items selected`
|
||||
|
||||
@ViewChild('selectedList') selectedList: CdkDropList
|
||||
@ViewChild('unselectedList') unselectedList: CdkDropList
|
||||
|
||||
get unselectedItems(): { id: string; name: string }[] {
|
||||
return this.items.filter((i) => !this.selectedItems.includes(i))
|
||||
}
|
||||
|
||||
writeValue(newValue: string[]): void {
|
||||
super.writeValue(newValue)
|
||||
this.selectedItems =
|
||||
newValue?.map((id) => this.items.find((i) => i.id === id)) ?? []
|
||||
}
|
||||
|
||||
public drop(event: CdkDragDrop<string[]>) {
|
||||
if (
|
||||
event.previousContainer === event.container &&
|
||||
event.container === this.selectedList
|
||||
) {
|
||||
moveItemInArray(
|
||||
this.selectedItems,
|
||||
event.previousIndex,
|
||||
event.currentIndex
|
||||
)
|
||||
} else if (event.container === this.selectedList) {
|
||||
this.selectedItems.splice(
|
||||
event.currentIndex,
|
||||
0,
|
||||
this.unselectedItems[event.previousIndex]
|
||||
)
|
||||
} else if (
|
||||
event.container === this.unselectedList &&
|
||||
event.previousContainer === this.selectedList
|
||||
) {
|
||||
this.selectedItems.splice(event.previousIndex, 1)
|
||||
}
|
||||
this.onChange(this.selectedItems.map((i) => i.id))
|
||||
}
|
||||
}
|
@ -12,9 +12,9 @@
|
||||
</div>
|
||||
<div class="position-relative" [class.col-md-9]="horizontal">
|
||||
<div class="input-group" [class.is-invalid]="error">
|
||||
<span class="input-group-text fw-bold bg-light">{{monetaryValue | currency: currencyCode }}</span>
|
||||
<input #currencyField class="form-control text-muted mw-60" tabindex="0" [(ngModel)]="currencyCode" maxlength="3" [class.is-invalid]="error" (change)="onChange(value)" [disabled]="disabled">
|
||||
<input #inputField type="number" tabindex="0" class="form-control text-muted" step=".01" [id]="inputId" [(ngModel)]="monetaryValue" (change)="onChange(value)" [class.is-invalid]="error" [disabled]="disabled">
|
||||
<span class="input-group-text fw-bold bg-light">{{ monetaryValue | currency: currency }}</span>
|
||||
<input #currencyField class="form-control text-muted mw-60" [(ngModel)]="currency" (input)="currencyChange()" maxlength="3" [class.is-invalid]="error" [disabled]="disabled">
|
||||
<input #monetaryValueField type="number" class="form-control text-muted" step=".01" [(ngModel)]="monetaryValue" (input)="monetaryValueChange()" (change)="monetaryValueChange(true)" [class.is-invalid]="error" [disabled]="disabled">
|
||||
</div>
|
||||
<div class="invalid-feedback position-absolute top-100">
|
||||
{{error}}
|
||||
|
@ -11,7 +11,6 @@ import { MonetaryComponent } from './monetary.component'
|
||||
describe('MonetaryComponent', () => {
|
||||
let component: MonetaryComponent
|
||||
let fixture: ComponentFixture<MonetaryComponent>
|
||||
let input: HTMLInputElement
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
@ -24,37 +23,22 @@ describe('MonetaryComponent', () => {
|
||||
fixture.debugElement.injector.get(NG_VALUE_ACCESSOR)
|
||||
component = fixture.componentInstance
|
||||
fixture.detectChanges()
|
||||
input = component.inputField.nativeElement
|
||||
})
|
||||
|
||||
it('should set the currency code correctly', () => {
|
||||
expect(component.currencyCode).toEqual('USD') // default
|
||||
component.currencyCode = 'EUR'
|
||||
expect(component.currencyCode).toEqual('EUR')
|
||||
it('should set the currency code and monetary value correctly', () => {
|
||||
expect(component.currency).toEqual('USD') // default
|
||||
component.writeValue('G123.4')
|
||||
expect(component.currency).toEqual('G')
|
||||
|
||||
component.value = 'G123.4'
|
||||
jest
|
||||
.spyOn(document, 'activeElement', 'get')
|
||||
.mockReturnValue(component.currencyField.nativeElement)
|
||||
expect(component.currencyCode).toEqual('G')
|
||||
component.writeValue('EUR123.4')
|
||||
expect(component.currency).toEqual('EUR')
|
||||
expect(component.monetaryValue).toEqual('123.40')
|
||||
})
|
||||
|
||||
it('should parse monetary value only when out of focus', () => {
|
||||
component.monetaryValue = 10.5
|
||||
jest.spyOn(document, 'activeElement', 'get').mockReturnValue(null)
|
||||
it('should set monetary value to fixed decimals', () => {
|
||||
component.monetaryValue = '10.5'
|
||||
component.monetaryValueChange(true)
|
||||
expect(component.monetaryValue).toEqual('10.50')
|
||||
|
||||
component.value = 'GBP123.4'
|
||||
jest
|
||||
.spyOn(document, 'activeElement', 'get')
|
||||
.mockReturnValue(component.inputField.nativeElement)
|
||||
expect(component.monetaryValue).toEqual('123.4')
|
||||
})
|
||||
|
||||
it('should report value including currency code and monetary value', () => {
|
||||
component.currencyCode = 'EUR'
|
||||
component.monetaryValue = 10.5
|
||||
expect(component.value).toEqual('EUR10.50')
|
||||
})
|
||||
|
||||
it('should set the default currency code based on LOCALE_ID', () => {
|
||||
@ -62,4 +46,32 @@ describe('MonetaryComponent', () => {
|
||||
component = new MonetaryComponent('pt-BR')
|
||||
expect(component.defaultCurrencyCode).toEqual('BRL')
|
||||
})
|
||||
|
||||
it('should parse monetary value correctly', () => {
|
||||
expect(component['parseMonetaryValue']('123.4')).toEqual('123.4')
|
||||
expect(component['parseMonetaryValue']('123.4', true)).toEqual('123.40')
|
||||
expect(component['parseMonetaryValue']('123.4', false)).toEqual('123.4')
|
||||
})
|
||||
|
||||
it('should handle currency change', () => {
|
||||
component.writeValue('USD123.4')
|
||||
component.currency = 'EUR'
|
||||
component.currencyChange()
|
||||
expect(component.currency).toEqual('EUR')
|
||||
expect(component.monetaryValue).toEqual('123.40')
|
||||
})
|
||||
|
||||
it('should handle monetary value change', () => {
|
||||
component.writeValue('USD123.4')
|
||||
component.monetaryValue = '123.4'
|
||||
component.monetaryValueChange()
|
||||
expect(component.monetaryValue).toEqual('123.4')
|
||||
expect(component.value).toEqual('USD123.40')
|
||||
})
|
||||
|
||||
it('should handle null values', () => {
|
||||
component.writeValue(null)
|
||||
expect(component.currency).toEqual('USD')
|
||||
expect(component.monetaryValue).toEqual('')
|
||||
})
|
||||
})
|
||||
|
@ -1,11 +1,4 @@
|
||||
import {
|
||||
Component,
|
||||
ElementRef,
|
||||
forwardRef,
|
||||
Inject,
|
||||
LOCALE_ID,
|
||||
ViewChild,
|
||||
} from '@angular/core'
|
||||
import { Component, forwardRef, Inject, LOCALE_ID } from '@angular/core'
|
||||
import { NG_VALUE_ACCESSOR } from '@angular/forms'
|
||||
import { AbstractInputComponent } from '../abstract-input'
|
||||
import { getLocaleCurrencyCode } from '@angular/common'
|
||||
@ -23,39 +16,50 @@ import { getLocaleCurrencyCode } from '@angular/common'
|
||||
styleUrls: ['./monetary.component.scss'],
|
||||
})
|
||||
export class MonetaryComponent extends AbstractInputComponent<string> {
|
||||
@ViewChild('currencyField')
|
||||
currencyField: ElementRef
|
||||
public currency: string = ''
|
||||
public monetaryValue: string = ''
|
||||
defaultCurrencyCode: string
|
||||
|
||||
constructor(@Inject(LOCALE_ID) currentLocale: string) {
|
||||
super()
|
||||
|
||||
this.defaultCurrencyCode = getLocaleCurrencyCode(currentLocale)
|
||||
this.currency = this.defaultCurrencyCode =
|
||||
getLocaleCurrencyCode(currentLocale)
|
||||
}
|
||||
|
||||
get currencyCode(): string {
|
||||
const focused = document.activeElement === this.currencyField?.nativeElement
|
||||
if (focused && this.value) return this.value.match(/^([A-Z]{0,3})/)?.[0]
|
||||
writeValue(newValue: any): void {
|
||||
this.currency = this.parseCurrencyCode(newValue)
|
||||
this.monetaryValue = this.parseMonetaryValue(newValue, true)
|
||||
|
||||
this.value = this.currency + this.monetaryValue
|
||||
}
|
||||
|
||||
public monetaryValueChange(fixed: boolean = false): void {
|
||||
this.monetaryValue = this.parseMonetaryValue(this.monetaryValue, fixed)
|
||||
this.onChange(this.currency + this.monetaryValue)
|
||||
}
|
||||
|
||||
public currencyChange(): void {
|
||||
if (this.currency.length) {
|
||||
this.currency = this.parseCurrencyCode(this.currency)
|
||||
this.onChange(this.currency + this.monetaryValue?.toString())
|
||||
}
|
||||
}
|
||||
|
||||
private parseCurrencyCode(value: string): string {
|
||||
return (
|
||||
this.value
|
||||
value
|
||||
?.toString()
|
||||
.toUpperCase()
|
||||
.match(/^([A-Z]{1,3})/)?.[0] ?? this.defaultCurrencyCode
|
||||
)
|
||||
}
|
||||
|
||||
set currencyCode(value: string) {
|
||||
this.value = value + this.monetaryValue?.toString()
|
||||
}
|
||||
|
||||
get monetaryValue(): string {
|
||||
if (!this.value) return null
|
||||
const focused = document.activeElement === this.inputField?.nativeElement
|
||||
const val = parseFloat(this.value.toString().replace(/[^0-9.,]+/g, ''))
|
||||
return focused ? val.toString() : val.toFixed(2)
|
||||
}
|
||||
|
||||
set monetaryValue(value: number) {
|
||||
this.value = this.currencyCode + value.toFixed(2)
|
||||
private parseMonetaryValue(value: string, fixed: boolean = false): string {
|
||||
if (!value) {
|
||||
return ''
|
||||
}
|
||||
const val: number = parseFloat(value.toString().replace(/[^0-9.,-]+/g, ''))
|
||||
return fixed ? val.toFixed(2) : val.toString()
|
||||
}
|
||||
}
|
||||
|
@ -36,7 +36,7 @@
|
||||
<span [title]="item[bindLabel]">{{item[bindLabel]}}</span>
|
||||
</ng-template>
|
||||
</ng-select>
|
||||
@if (allowCreateNew) {
|
||||
@if (allowCreateNew && !hideAddButton) {
|
||||
<button class="btn btn-outline-secondary" type="button" (click)="addItem()" [disabled]="disabled">
|
||||
<i-bs width="1.2em" height="1.2em" name="plus"></i-bs>
|
||||
</button>
|
||||
|
@ -94,6 +94,9 @@ export class SelectComponent extends AbstractInputComponent<number> {
|
||||
@Input()
|
||||
disableCreateNew: boolean = false
|
||||
|
||||
@Input()
|
||||
hideAddButton: boolean = false
|
||||
|
||||
@Output()
|
||||
createNew = new EventEmitter<string>()
|
||||
|
||||
|
@ -9,17 +9,17 @@
|
||||
<div class="col" i18n>Delete</div>
|
||||
<div class="col" i18n>View</div>
|
||||
</li>
|
||||
@for (type of PermissionType | keyvalue; track type) {
|
||||
<li class="list-group-item d-flex" [formGroupName]="type.key">
|
||||
<div class="col-3">{{type.key}}:</div>
|
||||
<div class="col form-check form-check-inline form-switch" [ngbPopover]="inheritedWarning" [disablePopover]="!isInherited(type.key)" placement="left" triggers="mouseenter:mouseleave">
|
||||
<input type="checkbox" class="form-check-input" id="{{type.key}}_all" (change)="toggleAll($event, type.key)" [checked]="typesWithAllActions.has(type.key) || isInherited(type.key)" [attr.disabled]="disabled || isInherited(type.key) ? true : null">
|
||||
<label class="form-check-label visually-hidden" for="{{type.key}}_all" i18n>All</label>
|
||||
@for (type of allowedTypes; track type) {
|
||||
<li class="list-group-item d-flex" [formGroupName]="type">
|
||||
<div class="col-3">{{type}}:</div>
|
||||
<div class="col form-check form-check-inline form-switch" [ngbPopover]="inheritedWarning" [disablePopover]="!isInherited(type)" placement="left" triggers="mouseenter:mouseleave">
|
||||
<input type="checkbox" class="form-check-input" id="{{type}}_all" (change)="toggleAll($event, type)" [checked]="typesWithAllActions.has(type) || isInherited(type)" [attr.disabled]="disabled || isInherited(type) ? true : null">
|
||||
<label class="form-check-label visually-hidden" for="{{type}}_all" i18n>All</label>
|
||||
</div>
|
||||
@for (action of PermissionAction | keyvalue; track action) {
|
||||
<div class="col form-check form-check-inline" [ngbPopover]="inheritedWarning" [disablePopover]="!isInherited(type.key, action.key)" placement="left" triggers="mouseenter:mouseleave">
|
||||
<input type="checkbox" class="form-check-input" id="{{type.key}}_{{action.key}}" formControlName="{{action.key}}">
|
||||
<label class="form-check-label visually-hidden" for="{{type.key}}_{{action.key}}" i18n>{{action.key}}</label>
|
||||
<div class="col form-check form-check-inline" [ngbPopover]="inheritedWarning" [disablePopover]="!isInherited(type, action.key)" placement="left" triggers="mouseenter:mouseleave">
|
||||
<input type="checkbox" class="form-check-input" id="{{type}}_{{action.key}}" formControlName="{{action.key}}">
|
||||
<label class="form-check-label visually-hidden" for="{{type}}_{{action.key}}" i18n>{{action.key}}</label>
|
||||
</div>
|
||||
}
|
||||
</li>
|
||||
|
@ -12,6 +12,9 @@ import {
|
||||
} from 'src/app/services/permissions.service'
|
||||
import { By } from '@angular/platform-browser'
|
||||
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
||||
import { SettingsService } from 'src/app/services/settings.service'
|
||||
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
||||
import { HttpClientTestingModule } from '@angular/common/http/testing'
|
||||
|
||||
const permissions = [
|
||||
'add_document',
|
||||
@ -28,6 +31,7 @@ describe('PermissionsSelectComponent', () => {
|
||||
let component: PermissionsSelectComponent
|
||||
let fixture: ComponentFixture<PermissionsSelectComponent>
|
||||
let permissionsChangeResult: Permissions
|
||||
let settingsService: SettingsService
|
||||
|
||||
beforeEach(async () => {
|
||||
TestBed.configureTestingModule({
|
||||
@ -38,9 +42,11 @@ describe('PermissionsSelectComponent', () => {
|
||||
ReactiveFormsModule,
|
||||
NgbModule,
|
||||
NgxBootstrapIconsModule.pick(allIcons),
|
||||
HttpClientTestingModule,
|
||||
],
|
||||
}).compileComponents()
|
||||
|
||||
settingsService = TestBed.inject(SettingsService)
|
||||
fixture = TestBed.createComponent(PermissionsSelectComponent)
|
||||
fixture.debugElement.injector.get(NG_VALUE_ACCESSOR)
|
||||
component = fixture.componentInstance
|
||||
@ -99,4 +105,11 @@ describe('PermissionsSelectComponent', () => {
|
||||
const input2 = fixture.debugElement.query(By.css('input#Tag_Change'))
|
||||
expect(input2.nativeElement.disabled).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should exclude history permissions if disabled', () => {
|
||||
settingsService.set(SETTINGS_KEYS.AUDITLOG_ENABLED, false)
|
||||
fixture = TestBed.createComponent(PermissionsSelectComponent)
|
||||
component = fixture.componentInstance
|
||||
expect(component.allowedTypes).not.toContain('History')
|
||||
})
|
||||
})
|
||||
|
@ -12,6 +12,8 @@ import {
|
||||
PermissionType,
|
||||
} from 'src/app/services/permissions.service'
|
||||
import { ComponentWithPermissions } from '../../with-permissions/with-permissions.component'
|
||||
import { SettingsService } from 'src/app/services/settings.service'
|
||||
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
||||
|
||||
@Component({
|
||||
providers: [
|
||||
@ -60,15 +62,23 @@ export class PermissionsSelectComponent
|
||||
|
||||
inheritedWarning: string = $localize`Inherited from group`
|
||||
|
||||
constructor(private readonly permissionsService: PermissionsService) {
|
||||
public allowedTypes = Object.keys(PermissionType)
|
||||
|
||||
constructor(
|
||||
private readonly permissionsService: PermissionsService,
|
||||
private readonly settingsService: SettingsService
|
||||
) {
|
||||
super()
|
||||
for (const type in PermissionType) {
|
||||
if (!this.settingsService.get(SETTINGS_KEYS.AUDITLOG_ENABLED)) {
|
||||
this.allowedTypes.splice(this.allowedTypes.indexOf('History'), 1)
|
||||
}
|
||||
this.allowedTypes.forEach((type) => {
|
||||
const control = new FormGroup({})
|
||||
for (const action in PermissionAction) {
|
||||
control.addControl(action, new FormControl(null))
|
||||
}
|
||||
this.form.addControl(type, control)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
writeValue(permissions: string[]): void {
|
||||
@ -92,7 +102,7 @@ export class PermissionsSelectComponent
|
||||
}
|
||||
}
|
||||
})
|
||||
Object.keys(PermissionType).forEach((type) => {
|
||||
this.allowedTypes.forEach((type) => {
|
||||
if (
|
||||
Object.values(this.form.get(type).value).every((val) => val == true)
|
||||
) {
|
||||
@ -191,7 +201,7 @@ export class PermissionsSelectComponent
|
||||
}
|
||||
|
||||
updateDisabledStates() {
|
||||
for (const type in PermissionType) {
|
||||
this.allowedTypes.forEach((type) => {
|
||||
const control = this.form.get(type)
|
||||
let actionControl: AbstractControl
|
||||
for (const action in PermissionAction) {
|
||||
@ -200,6 +210,6 @@ export class PermissionsSelectComponent
|
||||
? actionControl.disable()
|
||||
: actionControl.enable()
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
@ -23,7 +23,7 @@
|
||||
}
|
||||
|
||||
<ng-container *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.SavedView }">
|
||||
@for (v of dashboardViews; track v) {
|
||||
@for (v of dashboardViews; track v.id) {
|
||||
<div class="col">
|
||||
<pngx-saved-view-widget
|
||||
[savedView]="v"
|
||||
|
@ -9,58 +9,114 @@
|
||||
<a class="btn-link text-decoration-none" header-buttons [routerLink]="[]" (click)="showAll()" i18n>Show all</a>
|
||||
}
|
||||
|
||||
@if (documents.length) {
|
||||
<table content class="table table-hover mb-0 align-middle">
|
||||
@if (documents.length && displayMode === DisplayMode.TABLE) {
|
||||
<table content class="table table-hover mb-0 mt-n2 align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th scope="col" i18n>Created</th>
|
||||
<th scope="col" i18n>Title</th>
|
||||
@if (permissionsService.currentUserCan(PermissionAction.View, PermissionType.Tag)) {
|
||||
<th scope="col" class="d-none d-md-table-cell" i18n>Tags</th>
|
||||
}
|
||||
@if (permissionsService.currentUserCan(PermissionAction.View, PermissionType.Correspondent)) {
|
||||
<th scope="col" class="d-none d-md-table-cell" i18n>Correspondent</th>
|
||||
} @else {
|
||||
<th scope="col" class="d-none d-md-table-cell"></th>
|
||||
@for (field of displayFields; track field; let i = $index) {
|
||||
@if (displayFields.includes(field)) {
|
||||
<th
|
||||
scope="col"
|
||||
[ngClass]="{
|
||||
'd-none d-md-table-cell': i > 1,
|
||||
'w-25': field === DisplayField.CREATED || field === DisplayField.ADDED
|
||||
}">
|
||||
{{ getColumnTitle(field) }}
|
||||
</th>
|
||||
}
|
||||
}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@for (doc of documents; track doc) {
|
||||
<tr (mouseleave)="maybeClosePopover()">
|
||||
<td class="py-2 py-md-3"><a routerLink="/documents/{{doc.id}}" class="btn-link text-dark text-decoration-none py-2 py-md-3">{{doc.created_date | customDate}}</a></td>
|
||||
<td class="py-2 py-md-3">
|
||||
<a routerLink="/documents/{{doc.id}}" title="Edit" i18n-title class="btn-link text-dark text-decoration-none py-2 py-md-3">{{doc.title | documentTitle}}</a>
|
||||
</td>
|
||||
@if (permissionsService.currentUserCan(PermissionAction.View, PermissionType.Tag)) {
|
||||
<td class="py-2 py-md-3 d-none d-md-table-cell">
|
||||
@for (t of doc.tags$ | async; track t) {
|
||||
<pngx-tag [tag]="t" class="ms-1" (click)="clickTag(t, $event)"></pngx-tag>
|
||||
@for (doc of documents; track doc.id) {
|
||||
<tr>
|
||||
@for (field of displayFields; track field; let i = $index) {
|
||||
<td class="py-2 py-md-3 position-relative" [ngClass]="{ 'd-none d-md-table-cell': i > 1 }">
|
||||
@switch (field) {
|
||||
@case (DisplayField.ADDED) {
|
||||
<a routerLink="/documents/{{doc.id}}" class="btn-link text-dark text-decoration-none py-2 py-md-3">{{doc.added | customDate}}</a>
|
||||
}
|
||||
@case (DisplayField.CREATED) {
|
||||
<a routerLink="/documents/{{doc.id}}" class="btn-link text-dark text-decoration-none py-2 py-md-3">{{doc.created_date | customDate}}</a>
|
||||
}
|
||||
@case (DisplayField.TITLE) {
|
||||
<a routerLink="/documents/{{doc.id}}" title="Edit" i18n-title class="btn-link text-dark text-decoration-none py-2 py-md-3">{{doc.title | documentTitle}}</a>
|
||||
}
|
||||
@case (DisplayField.CORRESPONDENT) {
|
||||
@if (doc.correspondent) {
|
||||
<a class="btn-link text-dark text-decoration-none" type="button" (click)="clickCorrespondent(doc.correspondent, $event)">{{(doc.correspondent$ | async)?.name}}</a>
|
||||
}
|
||||
}
|
||||
@case (DisplayField.TAGS) {
|
||||
@for (t of doc.tags$ | async; track t) {
|
||||
<pngx-tag [tag]="t" class="ms-1" (click)="clickTag(t.id, $event)"></pngx-tag>
|
||||
}
|
||||
}
|
||||
@case (DisplayField.DOCUMENT_TYPE) {
|
||||
@if (doc.document_type) {
|
||||
<a class="btn-link text-dark text-decoration-none" type="button" (click)="clickDocType(doc.document_type, $event)">{{(doc.document_type$ | async)?.name}}</a>
|
||||
}
|
||||
}
|
||||
@case (DisplayField.STORAGE_PATH) {
|
||||
@if (doc.storage_path) {
|
||||
<a class="btn-link text-dark text-decoration-none" type="button" (click)="clickStoragePath(doc.storage_path, $event)">{{(doc.storage_path$ | async)?.name}}</a>
|
||||
}
|
||||
}
|
||||
}
|
||||
@if (field.startsWith(DisplayField.CUSTOM_FIELD)) {
|
||||
<pngx-custom-field-display [document]="doc" [fieldDisplayKey]="field"></pngx-custom-field-display>
|
||||
}
|
||||
@if (i === displayFields.length - 1) {
|
||||
<div class="btn-group position-absolute top-50 end-0 translate-middle-y">
|
||||
<a [href]="getPreviewUrl(doc)" title="View Preview" i18n-title target="_blank" class="btn px-4 btn-dark border-dark-subtle"
|
||||
[ngbPopover]="previewContent" [popoverTitle]="doc.title | documentTitle"
|
||||
autoClose="true" popoverClass="shadow popover-preview" container="body" (mouseenter)="mouseEnterPreviewButton(doc)" (mouseleave)="mouseLeavePreviewButton()" #popover="ngbPopover">
|
||||
<i-bs width="0.8em" height="0.8em" name="eye"></i-bs>
|
||||
</a>
|
||||
<ng-template #previewContent>
|
||||
<pngx-preview-popup [document]="doc" (mouseenter)="mouseEnterPreview()" (mouseleave)="mouseLeavePreview()"></pngx-preview-popup>
|
||||
</ng-template>
|
||||
<a [href]="getDownloadUrl(doc)" class="btn px-4 btn-dark border-dark-subtle" title="Download" i18n-title (click)="$event.stopPropagation()">
|
||||
<i-bs width="0.8em" height="0.8em" name="download"></i-bs>
|
||||
</a>
|
||||
</div>
|
||||
}
|
||||
</td>
|
||||
}
|
||||
<td class="position-relative py-2 py-md-3 d-none d-md-table-cell">
|
||||
@if (permissionsService.currentUserCan(PermissionAction.View, PermissionType.Correspondent) && doc.correspondent !== null) {
|
||||
<a class="btn-link text-dark text-decoration-none py-2 py-md-3" routerLink="/documents" [queryParams]="getCorrespondentQueryParams(doc.correspondent)">{{(doc.correspondent$ | async)?.name}}</a>
|
||||
}
|
||||
<div class="btn-group position-absolute top-50 end-0 translate-middle-y">
|
||||
<a [href]="getPreviewUrl(doc)" title="View Preview" i18n-title target="_blank" class="btn px-4 btn-dark border-dark-subtle"
|
||||
[ngbPopover]="previewContent" [popoverTitle]="doc.title | documentTitle"
|
||||
autoClose="true" popoverClass="shadow popover-preview" container="body" (mouseenter)="mouseEnterPreviewButton(doc)" (mouseleave)="mouseLeavePreviewButton()" #popover="ngbPopover">
|
||||
<i-bs width="0.8em" height="0.8em" name="eye"></i-bs>
|
||||
</a>
|
||||
<ng-template #previewContent>
|
||||
<pngx-preview-popup [document]="doc" (mouseenter)="mouseEnterPreview()" (mouseleave)="mouseLeavePreview()"></pngx-preview-popup>
|
||||
</ng-template>
|
||||
<a [href]="getDownloadUrl(doc)" class="btn px-4 btn-dark border-dark-subtle" title="Download" i18n-title (click)="$event.stopPropagation()">
|
||||
<i-bs width="0.8em" height="0.8em" name="download"></i-bs>
|
||||
</a>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
} @else if (documents.length && displayMode === DisplayMode.SMALL_CARDS) {
|
||||
<div class="row row-cols-paperless-cards my-n2">
|
||||
@for (d of documents; track d.id) {
|
||||
<pngx-document-card-small
|
||||
class="p-0"
|
||||
(dblClickDocument)="openDocumentDetail(d)"
|
||||
[document]="d"
|
||||
[displayFields]="displayFields"
|
||||
(clickTag)="clickTag($event)"
|
||||
(clickCorrespondent)="clickCorrespondent($event)"
|
||||
(clickStoragePath)="clickStoragePath($event)"
|
||||
(clickDocumentType)="clickDocumentType($event)">
|
||||
</pngx-document-card-small>
|
||||
}
|
||||
</div>
|
||||
} @else if (documents.length && displayMode === DisplayMode.LARGE_CARDS) {
|
||||
<div class="row my-n2">
|
||||
@for (d of documents; track d.id) {
|
||||
<pngx-document-card-large
|
||||
(dblClickDocument)="openDocumentDetail(d)"
|
||||
[document]="d"
|
||||
[displayFields]="displayFields"
|
||||
(clickTag)="clickTag($event)"
|
||||
(clickCorrespondent)="clickCorrespondent($event)"
|
||||
(clickStoragePath)="clickStoragePath($event)"
|
||||
(clickDocumentType)="clickDocumentType($event)"
|
||||
(clickMoreLike)="clickMoreLike(d.id)">
|
||||
</pngx-document-card-large>
|
||||
}
|
||||
</div>
|
||||
} @else {
|
||||
<p i18n class="text-center text-muted mb-0 fst-italic">No documents</p>
|
||||
}
|
||||
|
@ -3,10 +3,9 @@ table {
|
||||
table-layout: fixed;
|
||||
}
|
||||
|
||||
th:first-child {
|
||||
width: 25%;
|
||||
@media (min-width: 768px) {
|
||||
width: 15%;
|
||||
@media (min-width: 768px) {
|
||||
th.w-25 {
|
||||
width: 15% !important;
|
||||
}
|
||||
}
|
||||
|
||||
@ -30,3 +29,45 @@ td.py-3 {
|
||||
padding-top: 0.75em !important;
|
||||
padding-bottom: 0.75em !important;
|
||||
}
|
||||
|
||||
$paperless-card-breakpoints: (
|
||||
// 0: 2, // xs is manual for slim-sidebar
|
||||
768px: 2, //md
|
||||
992px: 2, //lg
|
||||
1200px: 3, //xl
|
||||
1600px: 4,
|
||||
1800px: 5,
|
||||
2000px: 6
|
||||
);
|
||||
|
||||
.row-cols-paperless-cards {
|
||||
// xs, we dont want in .col-slim block
|
||||
> * {
|
||||
flex: 0 0 auto;
|
||||
width: calc(100% / 2);
|
||||
}
|
||||
|
||||
@each $width, $n_cols in $paperless-card-breakpoints {
|
||||
@media(min-width: $width) {
|
||||
> * {
|
||||
flex: 0 0 auto;
|
||||
width: calc(100% / $n-cols);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
::ng-deep .col-slim .row-cols-paperless-cards {
|
||||
@each $width, $n_cols in $paperless-card-breakpoints {
|
||||
@media(min-width: $width) {
|
||||
> * {
|
||||
flex: 0 0 auto;
|
||||
width: calc(100% / ($n-cols + 1)) !important;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
::ng-deep .document-card-check {
|
||||
display: none !important; // override for dashboard
|
||||
}
|
||||
|
@ -11,7 +11,13 @@ import { RouterTestingModule } from '@angular/router/testing'
|
||||
import { NgbModule } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { of, Subject } from 'rxjs'
|
||||
import { routes } from 'src/app/app-routing.module'
|
||||
import { FILTER_HAS_TAGS_ALL } from 'src/app/data/filter-rule-type'
|
||||
import {
|
||||
FILTER_CORRESPONDENT,
|
||||
FILTER_DOCUMENT_TYPE,
|
||||
FILTER_FULLTEXT_MORELIKE,
|
||||
FILTER_HAS_TAGS_ALL,
|
||||
FILTER_STORAGE_PATH,
|
||||
} from 'src/app/data/filter-rule-type'
|
||||
import { SavedView } from 'src/app/data/saved-view'
|
||||
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
||||
import { PermissionsGuard } from 'src/app/guards/permissions.guard'
|
||||
@ -31,6 +37,10 @@ import { SafeUrlPipe } from 'src/app/pipes/safeurl.pipe'
|
||||
import { DragDropModule } from '@angular/cdk/drag-drop'
|
||||
import { PreviewPopupComponent } from 'src/app/components/common/preview-popup/preview-popup.component'
|
||||
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
||||
import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
|
||||
import { CustomFieldDataType } from 'src/app/data/custom-field'
|
||||
import { CustomFieldDisplayComponent } from 'src/app/components/common/custom-field-display/custom-field-display.component'
|
||||
import { DisplayMode, DisplayField } from 'src/app/data/document'
|
||||
|
||||
const savedView: SavedView = {
|
||||
id: 1,
|
||||
@ -45,17 +55,53 @@ const savedView: SavedView = {
|
||||
value: '1,2',
|
||||
},
|
||||
],
|
||||
page_size: 20,
|
||||
display_mode: DisplayMode.TABLE,
|
||||
display_fields: [
|
||||
DisplayField.CREATED,
|
||||
DisplayField.TITLE,
|
||||
DisplayField.TAGS,
|
||||
DisplayField.CORRESPONDENT,
|
||||
DisplayField.DOCUMENT_TYPE,
|
||||
DisplayField.STORAGE_PATH,
|
||||
`${DisplayField.CUSTOM_FIELD}11` as any,
|
||||
`${DisplayField.CUSTOM_FIELD}15` as any,
|
||||
],
|
||||
}
|
||||
|
||||
const documentResults = [
|
||||
{
|
||||
id: 2,
|
||||
title: 'doc2',
|
||||
custom_fields: [
|
||||
{ id: 1, field: 11, created: new Date(), value: 'custom', document: 2 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
title: 'doc3',
|
||||
correspondent: 0,
|
||||
custom_fields: [],
|
||||
},
|
||||
{
|
||||
id: 4,
|
||||
title: 'doc4',
|
||||
custom_fields: [
|
||||
{ id: 32, field: 3, created: new Date(), value: 'EUR123', document: 4 },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 5,
|
||||
title: 'doc5',
|
||||
custom_fields: [
|
||||
{
|
||||
id: 22,
|
||||
field: 15,
|
||||
created: new Date(),
|
||||
value: [123, 456, 789],
|
||||
document: 5,
|
||||
},
|
||||
],
|
||||
},
|
||||
]
|
||||
|
||||
@ -77,6 +123,7 @@ describe('SavedViewWidgetComponent', () => {
|
||||
DocumentTitlePipe,
|
||||
SafeUrlPipe,
|
||||
PreviewPopupComponent,
|
||||
CustomFieldDisplayComponent,
|
||||
],
|
||||
providers: [
|
||||
PermissionsGuard,
|
||||
@ -89,6 +136,33 @@ describe('SavedViewWidgetComponent', () => {
|
||||
},
|
||||
CustomDatePipe,
|
||||
DatePipe,
|
||||
{
|
||||
provide: CustomFieldsService,
|
||||
useValue: {
|
||||
listAll: () =>
|
||||
of({
|
||||
all: [3, 11, 15],
|
||||
count: 3,
|
||||
results: [
|
||||
{
|
||||
id: 3,
|
||||
name: 'Custom field 3',
|
||||
data_type: CustomFieldDataType.Monetary,
|
||||
},
|
||||
{
|
||||
id: 11,
|
||||
name: 'Custom Field 11',
|
||||
data_type: CustomFieldDataType.String,
|
||||
},
|
||||
{
|
||||
id: 15,
|
||||
name: 'Custom Field 15',
|
||||
data_type: CustomFieldDataType.DocumentLink,
|
||||
},
|
||||
],
|
||||
}),
|
||||
},
|
||||
},
|
||||
],
|
||||
imports: [
|
||||
HttpClientTestingModule,
|
||||
@ -170,7 +244,7 @@ describe('SavedViewWidgetComponent', () => {
|
||||
component.ngOnInit()
|
||||
expect(listAllSpy).toHaveBeenCalledWith(
|
||||
1,
|
||||
10,
|
||||
20,
|
||||
savedView.sort_field,
|
||||
savedView.sort_reverse,
|
||||
savedView.filter_rules,
|
||||
@ -204,11 +278,78 @@ describe('SavedViewWidgetComponent', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('should navigate to document', () => {
|
||||
const routerSpy = jest.spyOn(router, 'navigate')
|
||||
component.openDocumentDetail(documentResults[0])
|
||||
expect(routerSpy).toHaveBeenCalledWith(['documents', documentResults[0].id])
|
||||
})
|
||||
|
||||
it('should navigate via quickfilter on click tag', () => {
|
||||
const qfSpy = jest.spyOn(documentListViewService, 'quickFilter')
|
||||
component.clickTag({ id: 11, name: 'Tag11' }, new MouseEvent('click'))
|
||||
component.clickTag(11, new MouseEvent('click'))
|
||||
expect(qfSpy).toHaveBeenCalledWith([
|
||||
{ rule_type: FILTER_HAS_TAGS_ALL, value: '11' },
|
||||
])
|
||||
component.clickTag(11) // coverage
|
||||
})
|
||||
|
||||
it('should navigate via quickfilter on click correspondent', () => {
|
||||
const qfSpy = jest.spyOn(documentListViewService, 'quickFilter')
|
||||
component.clickCorrespondent(11, new MouseEvent('click'))
|
||||
expect(qfSpy).toHaveBeenCalledWith([
|
||||
{ rule_type: FILTER_CORRESPONDENT, value: '11' },
|
||||
])
|
||||
component.clickCorrespondent(11) // coverage
|
||||
})
|
||||
|
||||
it('should navigate via quickfilter on click doc type', () => {
|
||||
const qfSpy = jest.spyOn(documentListViewService, 'quickFilter')
|
||||
component.clickDocType(11, new MouseEvent('click'))
|
||||
expect(qfSpy).toHaveBeenCalledWith([
|
||||
{ rule_type: FILTER_DOCUMENT_TYPE, value: '11' },
|
||||
])
|
||||
component.clickDocType(11) // coverage
|
||||
})
|
||||
|
||||
it('should navigate via quickfilter on click storage path', () => {
|
||||
const qfSpy = jest.spyOn(documentListViewService, 'quickFilter')
|
||||
component.clickStoragePath(11, new MouseEvent('click'))
|
||||
expect(qfSpy).toHaveBeenCalledWith([
|
||||
{ rule_type: FILTER_STORAGE_PATH, value: '11' },
|
||||
])
|
||||
component.clickStoragePath(11) // coverage
|
||||
})
|
||||
|
||||
it('should navigate via quickfilter on click more like', () => {
|
||||
const qfSpy = jest.spyOn(documentListViewService, 'quickFilter')
|
||||
component.clickMoreLike(11)
|
||||
expect(qfSpy).toHaveBeenCalledWith([
|
||||
{ rule_type: FILTER_FULLTEXT_MORELIKE, value: '11' },
|
||||
])
|
||||
})
|
||||
|
||||
it('should get correct column title', () => {
|
||||
expect(component.getColumnTitle(DisplayField.TITLE)).toEqual('Title')
|
||||
expect(component.getColumnTitle(DisplayField.CREATED)).toEqual('Created')
|
||||
expect(component.getColumnTitle(DisplayField.ADDED)).toEqual('Added')
|
||||
expect(component.getColumnTitle(DisplayField.TAGS)).toEqual('Tags')
|
||||
expect(component.getColumnTitle(DisplayField.CORRESPONDENT)).toEqual(
|
||||
'Correspondent'
|
||||
)
|
||||
expect(component.getColumnTitle(DisplayField.DOCUMENT_TYPE)).toEqual(
|
||||
'Document type'
|
||||
)
|
||||
expect(component.getColumnTitle(DisplayField.STORAGE_PATH)).toEqual(
|
||||
'Storage path'
|
||||
)
|
||||
})
|
||||
|
||||
it('should get correct column title for custom field', () => {
|
||||
expect(
|
||||
component.getColumnTitle((DisplayField.CUSTOM_FIELD + 11) as any)
|
||||
).toEqual('Custom Field 11')
|
||||
expect(
|
||||
component.getColumnTitle((DisplayField.CUSTOM_FIELD + 15) as any)
|
||||
).toEqual('Custom Field 15')
|
||||
})
|
||||
})
|
||||
|
@ -6,23 +6,38 @@ import {
|
||||
QueryList,
|
||||
ViewChildren,
|
||||
} from '@angular/core'
|
||||
import { Params, Router } from '@angular/router'
|
||||
import { Router } from '@angular/router'
|
||||
import { Subject, takeUntil } from 'rxjs'
|
||||
import { Document } from 'src/app/data/document'
|
||||
import {
|
||||
DEFAULT_DASHBOARD_DISPLAY_FIELDS,
|
||||
DEFAULT_DASHBOARD_VIEW_PAGE_SIZE,
|
||||
DEFAULT_DISPLAY_FIELDS,
|
||||
DisplayField,
|
||||
DisplayMode,
|
||||
Document,
|
||||
} from 'src/app/data/document'
|
||||
import { SavedView } from 'src/app/data/saved-view'
|
||||
import { ConsumerStatusService } from 'src/app/services/consumer-status.service'
|
||||
import { DocumentService } from 'src/app/services/rest/document.service'
|
||||
import { Tag } from 'src/app/data/tag'
|
||||
import {
|
||||
FILTER_CORRESPONDENT,
|
||||
FILTER_DOCUMENT_TYPE,
|
||||
FILTER_FULLTEXT_MORELIKE,
|
||||
FILTER_HAS_TAGS_ALL,
|
||||
FILTER_STORAGE_PATH,
|
||||
} from 'src/app/data/filter-rule-type'
|
||||
import { OpenDocumentsService } from 'src/app/services/open-documents.service'
|
||||
import { DocumentListViewService } from 'src/app/services/document-list-view.service'
|
||||
import { ComponentWithPermissions } from 'src/app/components/with-permissions/with-permissions.component'
|
||||
import { NgbPopover } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { queryParamsFromFilterRules } from 'src/app/utils/query-params'
|
||||
import { PermissionsService } from 'src/app/services/permissions.service'
|
||||
import {
|
||||
PermissionAction,
|
||||
PermissionType,
|
||||
PermissionsService,
|
||||
} from 'src/app/services/permissions.service'
|
||||
import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
|
||||
import { CustomField, CustomFieldDataType } from 'src/app/data/custom-field'
|
||||
import { SettingsService } from 'src/app/services/settings.service'
|
||||
|
||||
@Component({
|
||||
selector: 'pngx-saved-view-widget',
|
||||
@ -33,8 +48,14 @@ export class SavedViewWidgetComponent
|
||||
extends ComponentWithPermissions
|
||||
implements OnInit, OnDestroy
|
||||
{
|
||||
public DisplayMode = DisplayMode
|
||||
public DisplayField = DisplayField
|
||||
public CustomFieldDataType = CustomFieldDataType
|
||||
|
||||
loading: boolean = true
|
||||
|
||||
private customFields: CustomField[] = []
|
||||
|
||||
constructor(
|
||||
private documentService: DocumentService,
|
||||
private router: Router,
|
||||
@ -42,7 +63,9 @@ export class SavedViewWidgetComponent
|
||||
private consumerStatusService: ConsumerStatusService,
|
||||
public openDocumentsService: OpenDocumentsService,
|
||||
public documentListViewService: DocumentListViewService,
|
||||
public permissionsService: PermissionsService
|
||||
public permissionsService: PermissionsService,
|
||||
private settingsService: SettingsService,
|
||||
private customFieldService: CustomFieldsService
|
||||
) {
|
||||
super()
|
||||
}
|
||||
@ -60,14 +83,44 @@ export class SavedViewWidgetComponent
|
||||
mouseOnPreview = false
|
||||
popoverHidden = true
|
||||
|
||||
displayMode: DisplayMode
|
||||
|
||||
displayFields: DisplayField[] = DEFAULT_DASHBOARD_DISPLAY_FIELDS
|
||||
|
||||
ngOnInit(): void {
|
||||
this.reload()
|
||||
this.displayMode = this.savedView.display_mode ?? DisplayMode.TABLE
|
||||
this.consumerStatusService
|
||||
.onDocumentConsumptionFinished()
|
||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe(() => {
|
||||
this.reload()
|
||||
})
|
||||
|
||||
if (
|
||||
this.permissionsService.currentUserCan(
|
||||
PermissionAction.View,
|
||||
PermissionType.CustomField
|
||||
)
|
||||
) {
|
||||
this.customFieldService
|
||||
.listAll()
|
||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe((customFields) => {
|
||||
this.customFields = customFields.results
|
||||
})
|
||||
}
|
||||
|
||||
if (this.savedView.display_fields) {
|
||||
this.displayFields = this.savedView.display_fields
|
||||
}
|
||||
|
||||
// filter by perms etc
|
||||
this.displayFields = this.displayFields.filter(
|
||||
(field) =>
|
||||
this.settingsService.allDisplayFields.find((f) => f.id === field) !==
|
||||
undefined
|
||||
)
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
@ -80,7 +133,7 @@ export class SavedViewWidgetComponent
|
||||
this.documentService
|
||||
.listFiltered(
|
||||
1,
|
||||
10,
|
||||
this.savedView.page_size ?? DEFAULT_DASHBOARD_VIEW_PAGE_SIZE,
|
||||
this.savedView.sort_field,
|
||||
this.savedView.sort_reverse,
|
||||
this.savedView.filter_rules,
|
||||
@ -103,15 +156,52 @@ export class SavedViewWidgetComponent
|
||||
}
|
||||
}
|
||||
|
||||
clickTag(tag: Tag, event: MouseEvent) {
|
||||
event.preventDefault()
|
||||
event.stopImmediatePropagation()
|
||||
clickTag(tagID: number, event: MouseEvent = null) {
|
||||
event?.preventDefault()
|
||||
event?.stopImmediatePropagation()
|
||||
|
||||
this.list.quickFilter([
|
||||
{ rule_type: FILTER_HAS_TAGS_ALL, value: tag.id.toString() },
|
||||
{ rule_type: FILTER_HAS_TAGS_ALL, value: tagID.toString() },
|
||||
])
|
||||
}
|
||||
|
||||
clickCorrespondent(correspondentId: number, event: MouseEvent = null) {
|
||||
event?.preventDefault()
|
||||
event?.stopImmediatePropagation()
|
||||
|
||||
this.list.quickFilter([
|
||||
{ rule_type: FILTER_CORRESPONDENT, value: correspondentId.toString() },
|
||||
])
|
||||
}
|
||||
|
||||
clickDocType(docTypeId: number, event: MouseEvent = null) {
|
||||
event?.preventDefault()
|
||||
event?.stopImmediatePropagation()
|
||||
|
||||
this.list.quickFilter([
|
||||
{ rule_type: FILTER_DOCUMENT_TYPE, value: docTypeId.toString() },
|
||||
])
|
||||
}
|
||||
|
||||
clickStoragePath(storagePathId: number, event: MouseEvent = null) {
|
||||
event?.preventDefault()
|
||||
event?.stopImmediatePropagation()
|
||||
|
||||
this.list.quickFilter([
|
||||
{ rule_type: FILTER_STORAGE_PATH, value: storagePathId.toString() },
|
||||
])
|
||||
}
|
||||
|
||||
clickMoreLike(documentID: number) {
|
||||
this.list.quickFilter([
|
||||
{ rule_type: FILTER_FULLTEXT_MORELIKE, value: documentID.toString() },
|
||||
])
|
||||
}
|
||||
|
||||
openDocumentDetail(document: Document) {
|
||||
this.router.navigate(['documents', document.id])
|
||||
}
|
||||
|
||||
getPreviewUrl(document: Document): string {
|
||||
return this.documentService.getPreviewUrl(document.id)
|
||||
}
|
||||
@ -161,14 +251,11 @@ export class SavedViewWidgetComponent
|
||||
}, 300)
|
||||
}
|
||||
|
||||
getCorrespondentQueryParams(correspondentId: number): Params {
|
||||
return correspondentId !== undefined
|
||||
? queryParamsFromFilterRules([
|
||||
{
|
||||
rule_type: FILTER_CORRESPONDENT,
|
||||
value: correspondentId.toString(),
|
||||
},
|
||||
])
|
||||
: null
|
||||
public getColumnTitle(field: DisplayField): string {
|
||||
if (field.startsWith(DisplayField.CUSTOM_FIELD)) {
|
||||
const id = field.split('_')[2]
|
||||
return this.customFields.find((f) => f.id === parseInt(id))?.name
|
||||
}
|
||||
return DEFAULT_DISPLAY_FIELDS.find((f) => f.id === field)?.name
|
||||
}
|
||||
}
|
||||
|
@ -149,7 +149,7 @@ describe('UploadFileWidgetComponent', () => {
|
||||
expect(dismissSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should allow dismissing all alerts', fakeAsync(() => {
|
||||
it('should allow dismissing completed alerts', fakeAsync(() => {
|
||||
mockConsumerStatuses(consumerStatusService)
|
||||
component.alertsExpanded = true
|
||||
fixture.detectChanges()
|
||||
@ -160,7 +160,7 @@ describe('UploadFileWidgetComponent', () => {
|
||||
component.dismissCompleted()
|
||||
tick(1000)
|
||||
fixture.detectChanges()
|
||||
expect(dismissSpy).toHaveBeenCalledTimes(10)
|
||||
expect(dismissSpy).toHaveBeenCalledTimes(4)
|
||||
}))
|
||||
})
|
||||
|
||||
|
@ -115,12 +115,9 @@ export class UploadFileWidgetComponent extends ComponentWithPermissions {
|
||||
}
|
||||
|
||||
dismissCompleted() {
|
||||
this.alerts.forEach((a) => a.close())
|
||||
if (this.alertsExpanded) {
|
||||
this.getStatusCompleted().forEach((status) =>
|
||||
this.consumerStatusService.dismiss(status)
|
||||
)
|
||||
}
|
||||
this.getStatusCompleted().forEach((status) =>
|
||||
this.consumerStatusService.dismiss(status)
|
||||
)
|
||||
}
|
||||
|
||||
public onFileSelected(event: Event) {
|
||||
|
@ -13,11 +13,11 @@
|
||||
<div class="spinner-border spinner-border-sm fw-normal ms-2 me-auto" role="status"></div>
|
||||
<div class="visually-hidden" i18n>Loading...</div>
|
||||
}
|
||||
<ng-content select ="[header-buttons]"></ng-content>
|
||||
<ng-content select="[header-buttons]"></ng-content>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
<div class="card-body text-dark">
|
||||
<ng-content select ="[content]"></ng-content>
|
||||
<ng-content select="[content]"></ng-content>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -57,7 +57,7 @@
|
||||
<i-bs width="1em" height="1em" name="scissors"></i-bs> <span i18n>Split</span>
|
||||
</button>
|
||||
|
||||
<button ngbDropdownItem (click)="rotateDocument()" [disabled]="!userIsOwner || contentRenderType !== ContentRenderType.PDF">
|
||||
<button ngbDropdownItem (click)="rotateDocument()" [disabled]="!userIsOwner || metadata?.original_mime_type !== 'application/pdf'">
|
||||
<i-bs name="arrow-clockwise"></i-bs> <ng-container i18n>Rotate</ng-container>
|
||||
</button>
|
||||
</div>
|
||||
@ -112,7 +112,7 @@
|
||||
<pngx-input-select [items]="storagePaths" i18n-title title="Storage path" formControlName="storage_path" [allowNull]="true" [showFilter]="true" [horizontal]="true" (filterDocuments)="filterDocuments($event)"
|
||||
(createNew)="createStoragePath($event)" [suggestions]="suggestions?.storage_paths" i18n-placeholder placeholder="Default" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.StoragePath }"></pngx-input-select>
|
||||
<pngx-input-tags formControlName="tags" [suggestions]="suggestions?.tags" [showFilter]="true" [horizontal]="true" (filterDocuments)="filterDocuments($event)" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Tag }"></pngx-input-tags>
|
||||
@for (fieldInstance of document?.custom_fields; track fieldInstance; let i = $index) {
|
||||
@for (fieldInstance of document?.custom_fields; track fieldInstance.field; let i = $index) {
|
||||
<div [formGroup]="customFieldFormFields.controls[i]">
|
||||
@switch (getCustomFieldFromInstance(fieldInstance)?.data_type) {
|
||||
@case (CustomFieldDataType.String) {
|
||||
@ -285,6 +285,17 @@
|
||||
</li>
|
||||
}
|
||||
|
||||
@if (historyEnabled) {
|
||||
<li [ngbNavItem]="DocumentDetailNavIDs.History">
|
||||
<a ngbNavLink i18n>History</a>
|
||||
<ng-template ngbNavContent>
|
||||
<div class="mb-3">
|
||||
<pngx-document-history [documentId]="documentId"></pngx-document-history>
|
||||
</div>
|
||||
</ng-template>
|
||||
</li>
|
||||
}
|
||||
|
||||
@if (showPermissions) {
|
||||
<li [ngbNavItem]="DocumentDetailNavIDs.Permissions">
|
||||
<a ngbNavLink i18n>Permissions</a>
|
||||
|
@ -17,7 +17,7 @@
|
||||
--page-margin: 10px auto;
|
||||
}
|
||||
|
||||
::ng-deep .ng-select-taggable {
|
||||
::ng-deep form .ng-select-taggable {
|
||||
max-width: calc(100% - 90px); // fudge factor for (2x) ng-select button width
|
||||
}
|
||||
|
||||
|
@ -12,8 +12,12 @@ import {
|
||||
} from '@angular/core/testing'
|
||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||
import { By } from '@angular/platform-browser'
|
||||
import { Router, ActivatedRoute, convertToParamMap } from '@angular/router'
|
||||
import { RouterTestingModule } from '@angular/router/testing'
|
||||
import {
|
||||
Router,
|
||||
ActivatedRoute,
|
||||
convertToParamMap,
|
||||
RouterModule,
|
||||
} from '@angular/router'
|
||||
import {
|
||||
NgbModal,
|
||||
NgbModule,
|
||||
@ -253,7 +257,7 @@ describe('DocumentDetailComponent', () => {
|
||||
DatePipe,
|
||||
],
|
||||
imports: [
|
||||
RouterTestingModule.withRoutes(routes),
|
||||
RouterModule.forRoot(routes),
|
||||
HttpClientTestingModule,
|
||||
NgbModule,
|
||||
NgSelectModule,
|
||||
@ -945,9 +949,9 @@ describe('DocumentDetailComponent', () => {
|
||||
fixture.detectChanges()
|
||||
expect(component.document.custom_fields).toHaveLength(initialLength - 1)
|
||||
expect(component.customFieldFormFields).toHaveLength(initialLength - 1)
|
||||
expect(fixture.debugElement.nativeElement.textContent).not.toContain(
|
||||
'Field 1'
|
||||
)
|
||||
expect(
|
||||
fixture.debugElement.query(By.css('form')).nativeElement.textContent
|
||||
).not.toContain('Field 1')
|
||||
const updateSpy = jest.spyOn(documentService, 'update')
|
||||
component.save(true)
|
||||
expect(updateSpy.mock.lastCall[0].custom_fields).toHaveLength(
|
||||
@ -1126,6 +1130,35 @@ describe('DocumentDetailComponent', () => {
|
||||
req.flush(true)
|
||||
})
|
||||
|
||||
it('should support keyboard shortcuts', () => {
|
||||
initNormally()
|
||||
|
||||
jest.spyOn(component, 'hasNext').mockReturnValue(true)
|
||||
const nextSpy = jest.spyOn(component, 'nextDoc')
|
||||
document.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'arrowright', ctrlKey: true })
|
||||
)
|
||||
expect(nextSpy).toHaveBeenCalled()
|
||||
|
||||
jest.spyOn(component, 'hasPrevious').mockReturnValue(true)
|
||||
const prevSpy = jest.spyOn(component, 'previousDoc')
|
||||
document.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 'arrowleft', ctrlKey: true })
|
||||
)
|
||||
expect(prevSpy).toHaveBeenCalled()
|
||||
|
||||
jest.spyOn(openDocumentsService, 'isDirty').mockReturnValue(true)
|
||||
const saveSpy = jest.spyOn(component, 'save')
|
||||
document.dispatchEvent(
|
||||
new KeyboardEvent('keydown', { key: 's', ctrlKey: true })
|
||||
)
|
||||
expect(saveSpy).toHaveBeenCalled()
|
||||
|
||||
const closeSpy = jest.spyOn(component, 'close')
|
||||
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'escape' }))
|
||||
expect(closeSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
function initNormally() {
|
||||
jest
|
||||
.spyOn(activatedRoute, 'paramMap', 'get')
|
||||
|
@ -69,6 +69,7 @@ import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service
|
||||
import { PDFDocumentProxy } from '../common/pdf-viewer/typings'
|
||||
import { SplitConfirmDialogComponent } from '../common/confirm-dialog/split-confirm-dialog/split-confirm-dialog.component'
|
||||
import { RotateConfirmDialogComponent } from '../common/confirm-dialog/rotate-confirm-dialog/rotate-confirm-dialog.component'
|
||||
import { HotKeyService } from 'src/app/services/hot-key.service'
|
||||
|
||||
enum DocumentDetailNavIDs {
|
||||
Details = 1,
|
||||
@ -77,6 +78,7 @@ enum DocumentDetailNavIDs {
|
||||
Preview = 4,
|
||||
Notes = 5,
|
||||
Permissions = 6,
|
||||
History = 7,
|
||||
}
|
||||
|
||||
enum ContentRenderType {
|
||||
@ -200,7 +202,8 @@ export class DocumentDetailComponent
|
||||
private permissionsService: PermissionsService,
|
||||
private userService: UserService,
|
||||
private customFieldsService: CustomFieldsService,
|
||||
private http: HttpClient
|
||||
private http: HttpClient,
|
||||
private hotKeyService: HotKeyService
|
||||
) {
|
||||
super()
|
||||
}
|
||||
@ -454,6 +457,40 @@ export class DocumentDetailComponent
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
this.hotKeyService
|
||||
.addShortcut({
|
||||
keys: 'control.arrowright',
|
||||
description: $localize`Next document`,
|
||||
})
|
||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe(() => {
|
||||
if (this.hasNext()) this.nextDoc()
|
||||
})
|
||||
|
||||
this.hotKeyService
|
||||
.addShortcut({
|
||||
keys: 'control.arrowleft',
|
||||
description: $localize`Previous document`,
|
||||
})
|
||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe(() => {
|
||||
if (this.hasPrevious()) this.previousDoc()
|
||||
})
|
||||
|
||||
this.hotKeyService
|
||||
.addShortcut({ keys: 'escape', description: $localize`Close document` })
|
||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe(() => {
|
||||
this.close()
|
||||
})
|
||||
|
||||
this.hotKeyService
|
||||
.addShortcut({ keys: 'control.s', description: $localize`Save document` })
|
||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe(() => {
|
||||
if (this.openDocumentService.isDirty(this.document)) this.save()
|
||||
})
|
||||
}
|
||||
|
||||
ngOnDestroy(): void {
|
||||
@ -637,13 +674,17 @@ export class DocumentDetailComponent
|
||||
this.documentForm.patchValue(docValues)
|
||||
this.store.next(this.documentForm.value)
|
||||
this.openDocumentService.setDirty(this.document, false)
|
||||
this.openDocumentService.save()
|
||||
this.toastService.showInfo($localize`Document saved successfully.`)
|
||||
this.networkActive = false
|
||||
this.error = null
|
||||
close &&
|
||||
if (close) {
|
||||
this.close(() =>
|
||||
this.openDocumentService.refreshDocument(this.documentId)
|
||||
)
|
||||
} else {
|
||||
this.openDocumentService.refreshDocument(this.documentId)
|
||||
}
|
||||
},
|
||||
error: (error) => {
|
||||
this.networkActive = false
|
||||
@ -902,6 +943,17 @@ export class DocumentDetailComponent
|
||||
)
|
||||
}
|
||||
|
||||
get historyEnabled(): boolean {
|
||||
return (
|
||||
this.settings.get(SETTINGS_KEYS.AUDITLOG_ENABLED) &&
|
||||
this.userIsOwner &&
|
||||
this.permissionsService.currentUserCan(
|
||||
PermissionAction.View,
|
||||
PermissionType.History
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
notesUpdated(notes: DocumentNote[]) {
|
||||
this.document.notes = notes
|
||||
this.openDocumentService.refreshDocument(this.documentId)
|
||||
|
@ -0,0 +1,59 @@
|
||||
@if (loading) {
|
||||
<div class="d-flex">
|
||||
<div class="spinner-border spinner-border-sm fw-normal" role="status"></div>
|
||||
</div>
|
||||
} @else {
|
||||
<ul class="list-group">
|
||||
@if (entries.length === 0) {
|
||||
<li class="list-group-item">
|
||||
<div class="d-flex justify-content-center">
|
||||
<span class="fst-italic" i18n>No entries found.</span>
|
||||
</div>
|
||||
</li>
|
||||
} @else {
|
||||
@for (entry of entries; track entry.id) {
|
||||
<li class="list-group-item">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<ng-template #timestamp>
|
||||
<div class="text-light">
|
||||
{{ entry.timestamp | customDate:'longDate' }} {{ entry.timestamp | date:'shortTime' }}
|
||||
</div>
|
||||
</ng-template>
|
||||
<span class="text-muted" [ngbTooltip]="timestamp">{{ entry.timestamp | customDate:'relative' }}</span>
|
||||
@if (entry.actor) {
|
||||
<span class="ms-3 fst-italic">{{ entry.actor.username }}</span>
|
||||
} @else {
|
||||
<span class="ms-3 fst-italic">System</span>
|
||||
}
|
||||
<span class="badge bg-secondary ms-auto" [class.bg-primary]="entry.action === AuditLogAction.Create">{{ entry.action | titlecase }}</span>
|
||||
</div>
|
||||
@if (entry.action === AuditLogAction.Update) {
|
||||
<ul class="mt-2">
|
||||
@for (change of entry.changes | keyvalue; track change.key) {
|
||||
@if (change.value["type"] === 'm2m') {
|
||||
<li>
|
||||
<span class="fst-italic" i18n>{{ change.value["operation"] | titlecase }}</span>
|
||||
<span class="text-light">{{ change.key | titlecase }}</span>:
|
||||
<code class="text-primary">{{ change.value["objects"].join(', ') }}</code>
|
||||
</li>
|
||||
}
|
||||
@else if (change.value["type"] === 'custom_field') {
|
||||
<li>
|
||||
<span class="text-light">{{ change.value["field"] }}</span>:
|
||||
<code class="text-primary">{{ change.value["value"] }}</code>
|
||||
</li>
|
||||
}
|
||||
@else {
|
||||
<li>
|
||||
<span class="text-light">{{ change.key | titlecase }}</span>:
|
||||
<code class="text-primary">{{ change.value[1] }}</code>
|
||||
</li>
|
||||
}
|
||||
}
|
||||
</ul>
|
||||
}
|
||||
</li>
|
||||
}
|
||||
}
|
||||
</ul>
|
||||
}
|
@ -0,0 +1,58 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||
|
||||
import { DocumentHistoryComponent } from './document-history.component'
|
||||
import { DocumentService } from 'src/app/services/rest/document.service'
|
||||
import { of } from 'rxjs'
|
||||
import { AuditLogAction } from 'src/app/data/auditlog-entry'
|
||||
import { HttpClientTestingModule } from '@angular/common/http/testing'
|
||||
import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
|
||||
import { DatePipe } from '@angular/common'
|
||||
import { NgbCollapseModule, NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
||||
|
||||
describe('DocumentHistoryComponent', () => {
|
||||
let component: DocumentHistoryComponent
|
||||
let fixture: ComponentFixture<DocumentHistoryComponent>
|
||||
let documentService: DocumentService
|
||||
|
||||
beforeEach(async () => {
|
||||
await TestBed.configureTestingModule({
|
||||
declarations: [DocumentHistoryComponent, CustomDatePipe],
|
||||
providers: [DatePipe],
|
||||
imports: [
|
||||
HttpClientTestingModule,
|
||||
NgbCollapseModule,
|
||||
NgxBootstrapIconsModule.pick(allIcons),
|
||||
NgbTooltipModule,
|
||||
],
|
||||
}).compileComponents()
|
||||
|
||||
fixture = TestBed.createComponent(DocumentHistoryComponent)
|
||||
documentService = TestBed.inject(DocumentService)
|
||||
component = fixture.componentInstance
|
||||
})
|
||||
|
||||
it('should get audit log entries on init', () => {
|
||||
const getHistorySpy = jest.spyOn(documentService, 'getHistory')
|
||||
getHistorySpy.mockReturnValue(
|
||||
of([
|
||||
{
|
||||
id: 1,
|
||||
actor: {
|
||||
id: 1,
|
||||
username: 'user1',
|
||||
},
|
||||
action: AuditLogAction.Create,
|
||||
timestamp: '2021-01-01T00:00:00Z',
|
||||
remote_addr: '1.2.3.4',
|
||||
changes: {
|
||||
title: ['old title', 'new title'],
|
||||
},
|
||||
},
|
||||
])
|
||||
)
|
||||
component.documentId = 1
|
||||
fixture.detectChanges()
|
||||
expect(getHistorySpy).toHaveBeenCalledWith(1)
|
||||
})
|
||||
})
|
@ -0,0 +1,36 @@
|
||||
import { Component, Input, OnInit } from '@angular/core'
|
||||
import { AuditLogAction, AuditLogEntry } from 'src/app/data/auditlog-entry'
|
||||
import { DocumentService } from 'src/app/services/rest/document.service'
|
||||
|
||||
@Component({
|
||||
selector: 'pngx-document-history',
|
||||
templateUrl: './document-history.component.html',
|
||||
styleUrl: './document-history.component.scss',
|
||||
})
|
||||
export class DocumentHistoryComponent implements OnInit {
|
||||
public AuditLogAction = AuditLogAction
|
||||
|
||||
private _documentId: number
|
||||
@Input()
|
||||
set documentId(id: number) {
|
||||
this._documentId = id
|
||||
this.ngOnInit()
|
||||
}
|
||||
|
||||
public loading: boolean = true
|
||||
public entries: AuditLogEntry[] = []
|
||||
|
||||
constructor(private documentService: DocumentService) {}
|
||||
|
||||
ngOnInit(): void {
|
||||
if (this._documentId) {
|
||||
this.loading = true
|
||||
this.documentService
|
||||
.getHistory(this._documentId)
|
||||
.subscribe((auditLogEntries) => {
|
||||
this.entries = auditLogEntries
|
||||
this.loading = false
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
@ -21,7 +21,7 @@
|
||||
<pngx-filterable-dropdown title="Tags" icon="tag-fill" i18n-title
|
||||
filterPlaceholder="Filter tags" i18n-filterPlaceholder
|
||||
[items]="tags"
|
||||
[disabled]="!userCanEditAll"
|
||||
[disabled]="!userCanEditAll || disabled"
|
||||
[editing]="true"
|
||||
[manyToOne]="true"
|
||||
[applyOnClose]="applyOnClose"
|
||||
@ -29,49 +29,67 @@
|
||||
(opened)="openTagsDropdown()"
|
||||
[(selectionModel)]="tagSelectionModel"
|
||||
[documentCounts]="tagDocumentCounts"
|
||||
(apply)="setTags($event)">
|
||||
(apply)="setTags($event)"
|
||||
shortcutKey="t">
|
||||
</pngx-filterable-dropdown>
|
||||
}
|
||||
@if (permissionService.currentUserCan(PermissionAction.View, PermissionType.Correspondent)) {
|
||||
<pngx-filterable-dropdown title="Correspondent" icon="person-fill" i18n-title
|
||||
filterPlaceholder="Filter correspondents" i18n-filterPlaceholder
|
||||
[items]="correspondents"
|
||||
[disabled]="!userCanEditAll"
|
||||
[disabled]="!userCanEditAll || disabled"
|
||||
[editing]="true"
|
||||
[applyOnClose]="applyOnClose"
|
||||
[createRef]="createCorrespondent.bind(this)"
|
||||
(opened)="openCorrespondentDropdown()"
|
||||
[(selectionModel)]="correspondentSelectionModel"
|
||||
[documentCounts]="correspondentDocumentCounts"
|
||||
(apply)="setCorrespondents($event)">
|
||||
(apply)="setCorrespondents($event)"
|
||||
shortcutKey="y">
|
||||
</pngx-filterable-dropdown>
|
||||
}
|
||||
@if (permissionService.currentUserCan(PermissionAction.View, PermissionType.DocumentType)) {
|
||||
<pngx-filterable-dropdown title="Document type" icon="file-earmark-fill" i18n-title
|
||||
filterPlaceholder="Filter document types" i18n-filterPlaceholder
|
||||
[items]="documentTypes"
|
||||
[disabled]="!userCanEditAll"
|
||||
[disabled]="!userCanEditAll || disabled"
|
||||
[editing]="true"
|
||||
[applyOnClose]="applyOnClose"
|
||||
[createRef]="createDocumentType.bind(this)"
|
||||
(opened)="openDocumentTypeDropdown()"
|
||||
[(selectionModel)]="documentTypeSelectionModel"
|
||||
[documentCounts]="documentTypeDocumentCounts"
|
||||
(apply)="setDocumentTypes($event)">
|
||||
(apply)="setDocumentTypes($event)"
|
||||
shortcutKey="u">
|
||||
</pngx-filterable-dropdown>
|
||||
}
|
||||
@if (permissionService.currentUserCan(PermissionAction.View, PermissionType.StoragePath)) {
|
||||
<pngx-filterable-dropdown title="Storage path" icon="folder-fill" i18n-title
|
||||
filterPlaceholder="Filter storage paths" i18n-filterPlaceholder
|
||||
[items]="storagePaths"
|
||||
[disabled]="!userCanEditAll"
|
||||
[disabled]="!userCanEditAll || disabled"
|
||||
[editing]="true"
|
||||
[applyOnClose]="applyOnClose"
|
||||
[createRef]="createStoragePath.bind(this)"
|
||||
(opened)="openStoragePathDropdown()"
|
||||
[(selectionModel)]="storagePathsSelectionModel"
|
||||
[documentCounts]="storagePathDocumentCounts"
|
||||
(apply)="setStoragePaths($event)">
|
||||
(apply)="setStoragePaths($event)"
|
||||
shortcutKey="i">
|
||||
</pngx-filterable-dropdown>
|
||||
}
|
||||
@if (permissionService.currentUserCan(PermissionAction.View, PermissionType.CustomField)) {
|
||||
<pngx-filterable-dropdown title="Custom fields" icon="ui-radios" i18n-title
|
||||
filterPlaceholder="Filter custom fields" i18n-filterPlaceholder
|
||||
[items]="customFields"
|
||||
[disabled]="!userCanEditAll"
|
||||
[editing]="true"
|
||||
[applyOnClose]="applyOnClose"
|
||||
[createRef]="createCustomField.bind(this)"
|
||||
(opened)="openCustomFieldsDropdown()"
|
||||
[(selectionModel)]="customFieldsSelectionModel"
|
||||
[documentCounts]="customFieldDocumentCounts"
|
||||
(apply)="setCustomFields($event)">
|
||||
</pngx-filterable-dropdown>
|
||||
}
|
||||
</div>
|
||||
|
@ -55,6 +55,9 @@ import { StoragePathEditDialogComponent } from '../../common/edit-dialog/storage
|
||||
import { IsNumberPipe } from 'src/app/pipes/is-number.pipe'
|
||||
import { RotateConfirmDialogComponent } from '../../common/confirm-dialog/rotate-confirm-dialog/rotate-confirm-dialog.component'
|
||||
import { MergeConfirmDialogComponent } from '../../common/confirm-dialog/merge-confirm-dialog/merge-confirm-dialog.component'
|
||||
import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
|
||||
import { CustomField, CustomFieldDataType } from 'src/app/data/custom-field'
|
||||
import { CustomFieldEditDialogComponent } from '../../common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component'
|
||||
|
||||
const selectionData: SelectionData = {
|
||||
selected_tags: [
|
||||
@ -68,6 +71,10 @@ const selectionData: SelectionData = {
|
||||
{ id: 66, document_count: 3 },
|
||||
{ id: 55, document_count: 0 },
|
||||
],
|
||||
selected_custom_fields: [
|
||||
{ id: 77, document_count: 3 },
|
||||
{ id: 88, document_count: 0 },
|
||||
],
|
||||
}
|
||||
|
||||
describe('BulkEditorComponent', () => {
|
||||
@ -82,6 +89,7 @@ describe('BulkEditorComponent', () => {
|
||||
let correspondentsService: CorrespondentService
|
||||
let documentTypeService: DocumentTypeService
|
||||
let storagePathService: StoragePathService
|
||||
let customFieldsService: CustomFieldsService
|
||||
let httpTestingController: HttpTestingController
|
||||
|
||||
beforeEach(async () => {
|
||||
@ -148,6 +156,18 @@ describe('BulkEditorComponent', () => {
|
||||
}),
|
||||
},
|
||||
},
|
||||
{
|
||||
provide: CustomFieldsService,
|
||||
useValue: {
|
||||
listAll: () =>
|
||||
of({
|
||||
results: [
|
||||
{ id: 77, name: 'customfield1' },
|
||||
{ id: 88, name: 'customfield2' },
|
||||
],
|
||||
}),
|
||||
},
|
||||
},
|
||||
FilterPipe,
|
||||
SettingsService,
|
||||
{
|
||||
@ -189,6 +209,7 @@ describe('BulkEditorComponent', () => {
|
||||
correspondentsService = TestBed.inject(CorrespondentService)
|
||||
documentTypeService = TestBed.inject(DocumentTypeService)
|
||||
storagePathService = TestBed.inject(StoragePathService)
|
||||
customFieldsService = TestBed.inject(CustomFieldsService)
|
||||
httpTestingController = TestBed.inject(HttpTestingController)
|
||||
|
||||
fixture = TestBed.createComponent(BulkEditorComponent)
|
||||
@ -262,6 +283,22 @@ describe('BulkEditorComponent', () => {
|
||||
expect(component.storagePathsSelectionModel.selectionSize()).toEqual(1)
|
||||
})
|
||||
|
||||
it('should apply selection data to custom fields menu', () => {
|
||||
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
|
||||
fixture.detectChanges()
|
||||
expect(
|
||||
component.customFieldsSelectionModel.getSelectedItems()
|
||||
).toHaveLength(0)
|
||||
jest
|
||||
.spyOn(documentListViewService, 'selected', 'get')
|
||||
.mockReturnValue(new Set([3, 5, 7]))
|
||||
jest
|
||||
.spyOn(documentService, 'getSelectionData')
|
||||
.mockReturnValue(of(selectionData))
|
||||
component.openCustomFieldsDropdown()
|
||||
expect(component.customFieldsSelectionModel.selectionSize()).toEqual(1)
|
||||
})
|
||||
|
||||
it('should execute modify tags bulk operation', () => {
|
||||
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
|
||||
jest
|
||||
@ -679,6 +716,122 @@ describe('BulkEditorComponent', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('should execute modify custom fields bulk operation', () => {
|
||||
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
|
||||
jest
|
||||
.spyOn(documentListViewService, 'documents', 'get')
|
||||
.mockReturnValue([{ id: 3 }, { id: 4 }])
|
||||
jest
|
||||
.spyOn(documentListViewService, 'selected', 'get')
|
||||
.mockReturnValue(new Set([3, 4]))
|
||||
jest
|
||||
.spyOn(permissionsService, 'currentUserHasObjectPermissions')
|
||||
.mockReturnValue(true)
|
||||
component.showConfirmationDialogs = false
|
||||
fixture.detectChanges()
|
||||
component.setCustomFields({
|
||||
itemsToAdd: [{ id: 101 }],
|
||||
itemsToRemove: [{ id: 102 }],
|
||||
})
|
||||
let req = httpTestingController.expectOne(
|
||||
`${environment.apiBaseUrl}documents/bulk_edit/`
|
||||
)
|
||||
req.flush(true)
|
||||
expect(req.request.body).toEqual({
|
||||
documents: [3, 4],
|
||||
method: 'modify_custom_fields',
|
||||
parameters: { add_custom_fields: [101], remove_custom_fields: [102] },
|
||||
})
|
||||
httpTestingController.match(
|
||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
|
||||
) // list reload
|
||||
httpTestingController.match(
|
||||
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
||||
) // listAllFilteredIds
|
||||
})
|
||||
|
||||
it('should execute modify custom fields bulk operation with confirmation dialog if enabled', () => {
|
||||
let modal: NgbModalRef
|
||||
modalService.activeInstances.subscribe((m) => (modal = m[0]))
|
||||
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
|
||||
jest
|
||||
.spyOn(documentListViewService, 'documents', 'get')
|
||||
.mockReturnValue([{ id: 3 }, { id: 4 }])
|
||||
jest
|
||||
.spyOn(documentListViewService, 'selected', 'get')
|
||||
.mockReturnValue(new Set([3, 4]))
|
||||
jest
|
||||
.spyOn(permissionsService, 'currentUserHasObjectPermissions')
|
||||
.mockReturnValue(true)
|
||||
component.showConfirmationDialogs = true
|
||||
fixture.detectChanges()
|
||||
component.setCustomFields({
|
||||
itemsToAdd: [{ id: 101 }],
|
||||
itemsToRemove: [{ id: 102 }],
|
||||
})
|
||||
expect(modal).not.toBeUndefined()
|
||||
modal.componentInstance.confirm()
|
||||
httpTestingController
|
||||
.expectOne(`${environment.apiBaseUrl}documents/bulk_edit/`)
|
||||
.flush(true)
|
||||
httpTestingController.match(
|
||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
|
||||
) // list reload
|
||||
httpTestingController.match(
|
||||
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
||||
) // listAllFilteredIds
|
||||
|
||||
// coverage for modal messages
|
||||
component.setCustomFields({
|
||||
itemsToAdd: [{ id: 101 }],
|
||||
itemsToRemove: [],
|
||||
})
|
||||
component.setCustomFields({
|
||||
itemsToAdd: [{ id: 101 }, { id: 102 }],
|
||||
itemsToRemove: [],
|
||||
})
|
||||
component.setCustomFields({
|
||||
itemsToAdd: [],
|
||||
itemsToRemove: [{ id: 101 }, { id: 102 }],
|
||||
})
|
||||
component.setCustomFields({
|
||||
itemsToAdd: [{ id: 100 }],
|
||||
itemsToRemove: [{ id: 101 }, { id: 102 }],
|
||||
})
|
||||
})
|
||||
|
||||
it('should set modal dialog text accordingly for custom fields edit confirmation', () => {
|
||||
let modal: NgbModalRef
|
||||
modalService.activeInstances.subscribe((m) => (modal = m[m.length - 1]))
|
||||
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
|
||||
jest
|
||||
.spyOn(documentListViewService, 'documents', 'get')
|
||||
.mockReturnValue([{ id: 3 }, { id: 4 }])
|
||||
jest
|
||||
.spyOn(documentListViewService, 'selected', 'get')
|
||||
.mockReturnValue(new Set([3, 4]))
|
||||
jest
|
||||
.spyOn(permissionsService, 'currentUserHasObjectPermissions')
|
||||
.mockReturnValue(true)
|
||||
component.showConfirmationDialogs = true
|
||||
fixture.detectChanges()
|
||||
component.setCustomFields({
|
||||
itemsToAdd: [],
|
||||
itemsToRemove: [{ id: 101, name: 'CustomField 101' }],
|
||||
})
|
||||
expect(modal.componentInstance.message).toEqual(
|
||||
'This operation will remove the custom field "CustomField 101" from 2 selected document(s).'
|
||||
)
|
||||
modal.close()
|
||||
component.setCustomFields({
|
||||
itemsToAdd: [{ id: 101, name: 'CustomField 101' }],
|
||||
itemsToRemove: [],
|
||||
})
|
||||
expect(modal.componentInstance.message).toEqual(
|
||||
'This operation will assign the custom field "CustomField 101" to 2 selected document(s).'
|
||||
)
|
||||
})
|
||||
|
||||
it('should only execute bulk operations when changes are detected', () => {
|
||||
component.setTags({
|
||||
itemsToAdd: [],
|
||||
@ -696,6 +849,10 @@ describe('BulkEditorComponent', () => {
|
||||
itemsToAdd: [],
|
||||
itemsToRemove: [],
|
||||
})
|
||||
component.setCustomFields({
|
||||
itemsToAdd: [],
|
||||
itemsToRemove: [],
|
||||
})
|
||||
httpTestingController.expectNone(
|
||||
`${environment.apiBaseUrl}documents/bulk_edit/`
|
||||
)
|
||||
@ -866,9 +1023,13 @@ describe('BulkEditorComponent', () => {
|
||||
jest
|
||||
.spyOn(documentListViewService, 'documents', 'get')
|
||||
.mockReturnValue([{ id: 3 }, { id: 4 }])
|
||||
jest
|
||||
.spyOn(documentService, 'getCachedMany')
|
||||
.mockReturnValue(of([{ id: 3 }, { id: 4 }]))
|
||||
jest.spyOn(documentService, 'getFew').mockReturnValue(
|
||||
of({
|
||||
all: [3, 4],
|
||||
count: 2,
|
||||
results: [{ id: 3 }, { id: 4 }],
|
||||
})
|
||||
)
|
||||
jest
|
||||
.spyOn(documentListViewService, 'selected', 'get')
|
||||
.mockReturnValue(new Set([3, 4]))
|
||||
@ -1175,4 +1336,56 @@ describe('BulkEditorComponent', () => {
|
||||
)
|
||||
expect(component.storagePaths).toEqual(storagePaths.results)
|
||||
})
|
||||
|
||||
it('should support create new custom field', () => {
|
||||
const name = 'New Custom Field'
|
||||
const newCustomField = { id: 101, name: 'New Custom Field' }
|
||||
const customFields: Results<CustomField> = {
|
||||
results: [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Custom Field 1',
|
||||
data_type: CustomFieldDataType.String,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'Custom Field 2',
|
||||
data_type: CustomFieldDataType.String,
|
||||
},
|
||||
],
|
||||
count: 2,
|
||||
all: [1, 2],
|
||||
}
|
||||
|
||||
const modalInstance = {
|
||||
componentInstance: {
|
||||
dialogMode: EditDialogMode.CREATE,
|
||||
object: { name },
|
||||
succeeded: of(newCustomField),
|
||||
},
|
||||
}
|
||||
const customFieldsListAllSpy = jest.spyOn(customFieldsService, 'listAll')
|
||||
customFieldsListAllSpy.mockReturnValue(of(customFields))
|
||||
|
||||
const customFieldsSelectionModelToggleSpy = jest.spyOn(
|
||||
component.customFieldsSelectionModel,
|
||||
'toggle'
|
||||
)
|
||||
|
||||
const modalServiceOpenSpy = jest.spyOn(modalService, 'open')
|
||||
modalServiceOpenSpy.mockReturnValue(modalInstance as any)
|
||||
|
||||
component.createCustomField(name)
|
||||
|
||||
expect(modalServiceOpenSpy).toHaveBeenCalledWith(
|
||||
CustomFieldEditDialogComponent,
|
||||
{ backdrop: 'static' }
|
||||
)
|
||||
expect(customFieldsListAllSpy).toHaveBeenCalled()
|
||||
|
||||
expect(customFieldsSelectionModelToggleSpy).toHaveBeenCalledWith(
|
||||
newCustomField.id
|
||||
)
|
||||
expect(component.customFields).toEqual(customFields.results)
|
||||
})
|
||||
})
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { Component, OnDestroy, OnInit } from '@angular/core'
|
||||
import { Component, Input, OnDestroy, OnInit } from '@angular/core'
|
||||
import { Tag } from 'src/app/data/tag'
|
||||
import { Correspondent } from 'src/app/data/correspondent'
|
||||
import { DocumentType } from 'src/app/data/document-type'
|
||||
@ -41,6 +41,9 @@ import { DocumentTypeEditDialogComponent } from '../../common/edit-dialog/docume
|
||||
import { StoragePathEditDialogComponent } from '../../common/edit-dialog/storage-path-edit-dialog/storage-path-edit-dialog.component'
|
||||
import { RotateConfirmDialogComponent } from '../../common/confirm-dialog/rotate-confirm-dialog/rotate-confirm-dialog.component'
|
||||
import { MergeConfirmDialogComponent } from '../../common/confirm-dialog/merge-confirm-dialog/merge-confirm-dialog.component'
|
||||
import { CustomField } from 'src/app/data/custom-field'
|
||||
import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
|
||||
import { CustomFieldEditDialogComponent } from '../../common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component'
|
||||
|
||||
@Component({
|
||||
selector: 'pngx-bulk-editor',
|
||||
@ -55,15 +58,18 @@ export class BulkEditorComponent
|
||||
correspondents: Correspondent[]
|
||||
documentTypes: DocumentType[]
|
||||
storagePaths: StoragePath[]
|
||||
customFields: CustomField[]
|
||||
|
||||
tagSelectionModel = new FilterableDropdownSelectionModel()
|
||||
correspondentSelectionModel = new FilterableDropdownSelectionModel()
|
||||
documentTypeSelectionModel = new FilterableDropdownSelectionModel()
|
||||
storagePathsSelectionModel = new FilterableDropdownSelectionModel()
|
||||
customFieldsSelectionModel = new FilterableDropdownSelectionModel()
|
||||
tagDocumentCounts: SelectionDataItem[]
|
||||
correspondentDocumentCounts: SelectionDataItem[]
|
||||
documentTypeDocumentCounts: SelectionDataItem[]
|
||||
storagePathDocumentCounts: SelectionDataItem[]
|
||||
customFieldDocumentCounts: SelectionDataItem[]
|
||||
awaitingDownload: boolean
|
||||
|
||||
unsubscribeNotifier: Subject<any> = new Subject()
|
||||
@ -74,6 +80,9 @@ export class BulkEditorComponent
|
||||
downloadUseFormatting: new FormControl(false),
|
||||
})
|
||||
|
||||
@Input()
|
||||
public disabled: boolean = false
|
||||
|
||||
constructor(
|
||||
private documentTypeService: DocumentTypeService,
|
||||
private tagService: TagService,
|
||||
@ -85,6 +94,7 @@ export class BulkEditorComponent
|
||||
private settings: SettingsService,
|
||||
private toastService: ToastService,
|
||||
private storagePathService: StoragePathService,
|
||||
private customFieldService: CustomFieldsService,
|
||||
private permissionService: PermissionsService
|
||||
) {
|
||||
super()
|
||||
@ -166,6 +176,17 @@ export class BulkEditorComponent
|
||||
.pipe(first())
|
||||
.subscribe((result) => (this.storagePaths = result.results))
|
||||
}
|
||||
if (
|
||||
this.permissionService.currentUserCan(
|
||||
PermissionAction.View,
|
||||
PermissionType.CustomField
|
||||
)
|
||||
) {
|
||||
this.customFieldService
|
||||
.listAll()
|
||||
.pipe(first())
|
||||
.subscribe((result) => (this.customFields = result.results))
|
||||
}
|
||||
|
||||
this.downloadForm
|
||||
.get('downloadFileTypeArchive')
|
||||
@ -297,6 +318,19 @@ export class BulkEditorComponent
|
||||
})
|
||||
}
|
||||
|
||||
openCustomFieldsDropdown() {
|
||||
this.documentService
|
||||
.getSelectionData(Array.from(this.list.selected))
|
||||
.pipe(first())
|
||||
.subscribe((s) => {
|
||||
this.customFieldDocumentCounts = s.selected_custom_fields
|
||||
this.applySelectionData(
|
||||
s.selected_custom_fields,
|
||||
this.customFieldsSelectionModel
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
private _localizeList(items: MatchingModel[]) {
|
||||
if (items.length == 0) {
|
||||
return ''
|
||||
@ -495,6 +529,74 @@ export class BulkEditorComponent
|
||||
}
|
||||
}
|
||||
|
||||
setCustomFields(changedCustomFields: ChangedItems) {
|
||||
if (
|
||||
changedCustomFields.itemsToAdd.length == 0 &&
|
||||
changedCustomFields.itemsToRemove.length == 0
|
||||
)
|
||||
return
|
||||
|
||||
if (this.showConfirmationDialogs) {
|
||||
let modal = this.modalService.open(ConfirmDialogComponent, {
|
||||
backdrop: 'static',
|
||||
})
|
||||
modal.componentInstance.title = $localize`Confirm custom field assignment`
|
||||
if (
|
||||
changedCustomFields.itemsToAdd.length == 1 &&
|
||||
changedCustomFields.itemsToRemove.length == 0
|
||||
) {
|
||||
let customField = changedCustomFields.itemsToAdd[0]
|
||||
modal.componentInstance.message = $localize`This operation will assign the custom field "${customField.name}" to ${this.list.selected.size} selected document(s).`
|
||||
} else if (
|
||||
changedCustomFields.itemsToAdd.length > 1 &&
|
||||
changedCustomFields.itemsToRemove.length == 0
|
||||
) {
|
||||
modal.componentInstance.message = $localize`This operation will assign the custom fields ${this._localizeList(
|
||||
changedCustomFields.itemsToAdd
|
||||
)} to ${this.list.selected.size} selected document(s).`
|
||||
} else if (
|
||||
changedCustomFields.itemsToAdd.length == 0 &&
|
||||
changedCustomFields.itemsToRemove.length == 1
|
||||
) {
|
||||
let customField = changedCustomFields.itemsToRemove[0]
|
||||
modal.componentInstance.message = $localize`This operation will remove the custom field "${customField.name}" from ${this.list.selected.size} selected document(s).`
|
||||
} else if (
|
||||
changedCustomFields.itemsToAdd.length == 0 &&
|
||||
changedCustomFields.itemsToRemove.length > 1
|
||||
) {
|
||||
modal.componentInstance.message = $localize`This operation will remove the custom fields ${this._localizeList(
|
||||
changedCustomFields.itemsToRemove
|
||||
)} from ${this.list.selected.size} selected document(s).`
|
||||
} else {
|
||||
modal.componentInstance.message = $localize`This operation will assign the custom fields ${this._localizeList(
|
||||
changedCustomFields.itemsToAdd
|
||||
)} and remove the custom fields ${this._localizeList(
|
||||
changedCustomFields.itemsToRemove
|
||||
)} on ${this.list.selected.size} selected document(s).`
|
||||
}
|
||||
|
||||
modal.componentInstance.btnClass = 'btn-warning'
|
||||
modal.componentInstance.btnCaption = $localize`Confirm`
|
||||
modal.componentInstance.confirmClicked
|
||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe(() => {
|
||||
this.executeBulkOperation(modal, 'modify_custom_fields', {
|
||||
add_custom_fields: changedCustomFields.itemsToAdd.map((f) => f.id),
|
||||
remove_custom_fields: changedCustomFields.itemsToRemove.map(
|
||||
(f) => f.id
|
||||
),
|
||||
})
|
||||
})
|
||||
} else {
|
||||
this.executeBulkOperation(null, 'modify_custom_fields', {
|
||||
add_custom_fields: changedCustomFields.itemsToAdd.map((f) => f.id),
|
||||
remove_custom_fields: changedCustomFields.itemsToRemove.map(
|
||||
(f) => f.id
|
||||
),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
createTag(name: string) {
|
||||
let modal = this.modalService.open(TagEditDialogComponent, {
|
||||
backdrop: 'static',
|
||||
@ -581,6 +683,27 @@ export class BulkEditorComponent
|
||||
})
|
||||
}
|
||||
|
||||
createCustomField(name: string) {
|
||||
let modal = this.modalService.open(CustomFieldEditDialogComponent, {
|
||||
backdrop: 'static',
|
||||
})
|
||||
modal.componentInstance.dialogMode = EditDialogMode.CREATE
|
||||
modal.componentInstance.object = { name }
|
||||
modal.componentInstance.succeeded
|
||||
.pipe(
|
||||
switchMap((newCustomField) => {
|
||||
return this.customFieldService
|
||||
.listAll()
|
||||
.pipe(map((customFields) => ({ newCustomField, customFields })))
|
||||
})
|
||||
)
|
||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe(({ newCustomField, customFields }) => {
|
||||
this.customFields = customFields.results
|
||||
this.customFieldsSelectionModel.toggle(newCustomField.id)
|
||||
})
|
||||
}
|
||||
|
||||
applyDelete() {
|
||||
let modal = this.modalService.open(ConfirmDialogComponent, {
|
||||
backdrop: 'static',
|
||||
|
@ -15,7 +15,7 @@
|
||||
<div class="card-body">
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<h5 class="card-title">
|
||||
@if (document.correspondent) {
|
||||
@if (displayFields.includes(DisplayField.CORRESPONDENT) && document.correspondent) {
|
||||
@if (clickCorrespondent.observers.length ) {
|
||||
<a title="Filter by correspondent" i18n-title (click)="clickCorrespondent.emit(document.correspondent);$event.stopPropagation()" class="fw-bold btn-link">{{(document.correspondent$ | async)?.name}}</a>
|
||||
} @else {
|
||||
@ -23,14 +23,18 @@
|
||||
}
|
||||
:
|
||||
}
|
||||
{{document.title | documentTitle}}
|
||||
@for (t of document.tags$ | async; track t) {
|
||||
<pngx-tag [tag]="t" linkTitle="Filter by tag" i18n-linkTitle class="ms-1" (click)="clickTag.emit(t.id);$event.stopPropagation()" [clickable]="clickTag.observers.length"></pngx-tag>
|
||||
@if (displayFields.includes(DisplayField.TITLE)) {
|
||||
{{document.title | documentTitle}}
|
||||
}
|
||||
@if (displayFields.includes(DisplayField.TAGS)) {
|
||||
@for (t of document.tags$ | async; track t) {
|
||||
<pngx-tag [tag]="t" linkTitle="Filter by tag" i18n-linkTitle class="ms-1" (click)="clickTag.emit(t.id);$event.stopPropagation()" [clickable]="clickTag.observers.length"></pngx-tag>
|
||||
}
|
||||
}
|
||||
</h5>
|
||||
</div>
|
||||
<p class="card-text">
|
||||
@if (document.__search_hit__ && document.__search_hit__.highlights) {
|
||||
@if (document.__search_hit__?.score && document.__search_hit__.highlights) {
|
||||
<span [innerHtml]="document.__search_hit__.highlights"></span>
|
||||
}
|
||||
@for (highlight of searchNoteHighlights; track highlight) {
|
||||
@ -39,7 +43,7 @@
|
||||
<span [innerHtml]="highlight"></span>
|
||||
</span>
|
||||
}
|
||||
@if (!document.__search_hit__) {
|
||||
@if (!document.__search_hit__?.score) {
|
||||
<span class="result-content">{{contentTrimmed}}</span>
|
||||
}
|
||||
</p>
|
||||
@ -66,44 +70,53 @@
|
||||
</div>
|
||||
|
||||
<div class="list-group list-group-horizontal border-0 card-info ms-md-auto mt-2 mt-md-0">
|
||||
@if (notesEnabled && document.notes.length) {
|
||||
@if (displayFields.includes(DisplayField.NOTES) && notesEnabled && document.notes.length) {
|
||||
<button routerLink="/documents/{{document.id}}/notes" class="list-group-item btn btn-sm bg-light text-dark p-1 border-0 me-2 d-flex align-items-center" title="View notes" i18n-title>
|
||||
<i-bs width=".9em" height=".9em" class="me-2 text-muted" name="chat-left-text"></i-bs><small i18n>{{document.notes.length}} Notes</small>
|
||||
</button>
|
||||
}
|
||||
@if (document.document_type) {
|
||||
@if (displayFields.includes(DisplayField.DOCUMENT_TYPE) && document.document_type) {
|
||||
<button type="button" class="list-group-item btn btn-sm bg-light text-dark p-1 border-0 me-2 d-flex align-items-center" title="Filter by document type" i18n-title
|
||||
(click)="clickDocumentType.emit(document.document_type);$event.stopPropagation()">
|
||||
<i-bs width=".9em" height=".9em" class="me-2 text-muted" name="file-earmark"></i-bs><small>{{(document.document_type$ | async)?.name}}</small>
|
||||
</button>
|
||||
}
|
||||
@if (document.storage_path) {
|
||||
@if (displayFields.includes(DisplayField.STORAGE_PATH) && document.storage_path) {
|
||||
<button type="button" class="list-group-item btn btn-sm bg-light text-dark p-1 border-0 me-2 d-flex align-items-center" title="Filter by storage path" i18n-title
|
||||
(click)="clickStoragePath.emit(document.storage_path);$event.stopPropagation()">
|
||||
<i-bs width=".9em" height=".9em" class="me-2 text-muted" name="archive"></i-bs><small>{{(document.storage_path$ | async)?.name}}</small>
|
||||
</button>
|
||||
}
|
||||
@if (document.archive_serial_number | isNumber) {
|
||||
@if (displayFields.includes(DisplayField.ASN) && document.archive_serial_number | isNumber) {
|
||||
<div class="list-group-item me-2 bg-light text-dark p-1 border-0 d-flex align-items-center">
|
||||
<i-bs width=".9em" height=".9em" class="me-2 text-muted" name="upc-scan"></i-bs><small>#{{document.archive_serial_number}}</small>
|
||||
</div>
|
||||
}
|
||||
<ng-template #dateTooltip>
|
||||
<div class="d-flex flex-column text-light">
|
||||
<span i18n>Created: {{ document.created | customDate }}</span>
|
||||
<span i18n>Added: {{ document.added | customDate }}</span>
|
||||
<span i18n>Modified: {{ document.modified | customDate }}</span>
|
||||
</div>
|
||||
</ng-template>
|
||||
<div class="list-group-item bg-light text-dark p-1 border-0 d-flex align-items-center" [ngbTooltip]="dateTooltip">
|
||||
<i-bs width=".9em" height=".9em" class="me-2 text-muted" name="calendar-event"></i-bs><small>{{document.created_date | customDate:'mediumDate'}}</small>
|
||||
</div>
|
||||
@if (document.owner && document.owner !== settingsService.currentUser.id) {
|
||||
@if (displayFields.includes(DisplayField.CREATED) || displayFields.includes(DisplayField.ADDED)) {
|
||||
<ng-template #dateTooltip>
|
||||
<div class="d-flex flex-column text-light">
|
||||
<span i18n>Created: {{ document.created | customDate }}</span>
|
||||
<span i18n>Added: {{ document.added | customDate }}</span>
|
||||
<span i18n>Modified: {{ document.modified | customDate }}</span>
|
||||
</div>
|
||||
</ng-template>
|
||||
@if (displayFields.includes(DisplayField.CREATED)) {
|
||||
<div class="list-group-item bg-light text-dark p-1 border-0 d-flex align-items-center" [ngbTooltip]="dateTooltip">
|
||||
<i-bs width=".9em" height=".9em" class="me-2 text-muted" name="calendar-event"></i-bs><small>{{document.created_date | customDate:'mediumDate'}}</small>
|
||||
</div>
|
||||
}
|
||||
@if (displayFields.includes(DisplayField.ADDED)) {
|
||||
<div class="list-group-item bg-light text-dark p-1 border-0 d-flex align-items-center" [ngbTooltip]="dateTooltip">
|
||||
<i-bs width=".9em" height=".9em" class="me-2 text-muted" name="calendar-event"></i-bs><small>{{document.added | customDate:'mediumDate'}}</small>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
@if (displayFields.includes(DisplayField.OWNER) && document.owner && document.owner !== settingsService.currentUser.id) {
|
||||
<div class="list-group-item bg-light text-dark p-1 border-0 d-flex align-items-center">
|
||||
<i-bs width=".9em" height=".9em" class="me-2 text-muted" name="person-fill-lock"></i-bs><small>{{document.owner | username}}</small>
|
||||
</div>
|
||||
}
|
||||
@if (document.is_shared_by_requester) {
|
||||
@if (displayFields.includes(DisplayField.SHARED) && document.is_shared_by_requester) {
|
||||
<div class="list-group-item bg-light text-dark p-1 border-0 d-flex align-items-center">
|
||||
<i-bs width=".9em" height=".9em" class="me-2 text-muted" name="people-fill"></i-bs><small i18n>Shared</small>
|
||||
</div>
|
||||
@ -114,6 +127,16 @@
|
||||
<ngb-progressbar [type]="searchScoreClass" [value]="document.__search_hit__.score" class="search-score-bar mx-2 mt-1" [max]="1"></ngb-progressbar>
|
||||
</div>
|
||||
}
|
||||
@for (field of document.custom_fields; track field.id) {
|
||||
@if (displayFields.includes(DisplayField.CUSTOM_FIELD + field.field)) {
|
||||
<div class="list-group-item bg-light text-dark p-1 border-0 d-flex align-items-center">
|
||||
<i-bs width=".9em" height=".9em" class="me-2 text-muted" name="ui-radios"></i-bs>
|
||||
<small>
|
||||
<pngx-custom-field-display [document]="document" [fieldId]="field.field"></pngx-custom-field-display>
|
||||
</small>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
@ -21,6 +21,7 @@ import { DocumentCardLargeComponent } from './document-card-large.component'
|
||||
import { IsNumberPipe } from 'src/app/pipes/is-number.pipe'
|
||||
import { PreviewPopupComponent } from '../../common/preview-popup/preview-popup.component'
|
||||
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
||||
import { CustomFieldDisplayComponent } from '../../common/custom-field-display/custom-field-display.component'
|
||||
|
||||
const doc = {
|
||||
id: 10,
|
||||
@ -53,6 +54,7 @@ describe('DocumentCardLargeComponent', () => {
|
||||
SafeUrlPipe,
|
||||
IsNumberPipe,
|
||||
PreviewPopupComponent,
|
||||
CustomFieldDisplayComponent,
|
||||
],
|
||||
providers: [DatePipe],
|
||||
imports: [
|
||||
|
@ -5,7 +5,11 @@ import {
|
||||
Output,
|
||||
ViewChild,
|
||||
} from '@angular/core'
|
||||
import { Document } from 'src/app/data/document'
|
||||
import {
|
||||
DEFAULT_DISPLAY_FIELDS,
|
||||
DisplayField,
|
||||
Document,
|
||||
} from 'src/app/data/document'
|
||||
import { DocumentService } from 'src/app/services/rest/document.service'
|
||||
import { SettingsService } from 'src/app/services/settings.service'
|
||||
import { NgbPopover } from '@ng-bootstrap/ng-bootstrap'
|
||||
@ -18,6 +22,8 @@ import { ComponentWithPermissions } from '../../with-permissions/with-permission
|
||||
styleUrls: ['./document-card-large.component.scss'],
|
||||
})
|
||||
export class DocumentCardLargeComponent extends ComponentWithPermissions {
|
||||
DisplayField = DisplayField
|
||||
|
||||
constructor(
|
||||
private documentService: DocumentService,
|
||||
public settingsService: SettingsService
|
||||
@ -28,6 +34,9 @@ export class DocumentCardLargeComponent extends ComponentWithPermissions {
|
||||
@Input()
|
||||
selected = false
|
||||
|
||||
@Input()
|
||||
displayFields: string[] = DEFAULT_DISPLAY_FIELDS.map((f) => f.id)
|
||||
|
||||
@Output()
|
||||
toggleSelected = new EventEmitter()
|
||||
|
||||
|
@ -10,19 +10,21 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="tags d-flex flex-column text-end position-absolute me-1 fs-6">
|
||||
@for (t of getTagsLimited$() | async; track t) {
|
||||
<pngx-tag [tag]="t" (click)="clickTag.emit(t.id);$event.stopPropagation()" [clickable]="true" linkTitle="Toggle tag filter" i18n-linkTitle></pngx-tag>
|
||||
}
|
||||
@if (moreTags) {
|
||||
<div>
|
||||
<span class="badge text-dark">+ {{moreTags}}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
@if (displayFields?.includes(DisplayField.TAGS)) {
|
||||
<div class="tags d-flex flex-column text-end position-absolute me-1 fs-6">
|
||||
@for (t of getTagsLimited$() | async; track t) {
|
||||
<pngx-tag [tag]="t" (click)="clickTag.emit(t.id);$event.stopPropagation()" [clickable]="true" linkTitle="Toggle tag filter" i18n-linkTitle></pngx-tag>
|
||||
}
|
||||
@if (moreTags) {
|
||||
<div>
|
||||
<span class="badge text-dark">+ {{moreTags}}</span>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
|
||||
@if (notesEnabled && document.notes.length) {
|
||||
@if (displayFields.includes(DisplayField.NOTES) && notesEnabled && document.notes.length) {
|
||||
<a routerLink="/documents/{{document.id}}/notes" class="document-card-notes py-2 px-1">
|
||||
<span class="badge rounded-pill bg-light border text-primary">
|
||||
<i-bs width="1.2em" height="1.2em" class="ms-1 me-1" name="chat-left-text"></i-bs>
|
||||
@ -32,59 +34,86 @@
|
||||
|
||||
<div class="card-body bg-light p-2">
|
||||
<p class="card-text">
|
||||
@if (document.correspondent) {
|
||||
@if (displayFields.includes(DisplayField.CORRESPONDENT) && document.correspondent) {
|
||||
<a title="Toggle correspondent filter" i18n-title (click)="clickCorrespondent.emit(document.correspondent);$event.stopPropagation()" class="fw-bold btn-link">{{(document.correspondent$ | async)?.name ?? privateName}}</a>:
|
||||
}
|
||||
{{document.title | documentTitle}}
|
||||
@if (displayFields.includes(DisplayField.TITLE)) {
|
||||
{{document.title | documentTitle}}
|
||||
}
|
||||
</p>
|
||||
</div>
|
||||
<div class="card-footer pt-0 pb-2 px-2">
|
||||
<div class="list-group list-group-flush border-0 pt-1 pb-2 card-info">
|
||||
@if (document.document_type) {
|
||||
@if (displayFields.includes(DisplayField.DOCUMENT_TYPE) && document.document_type) {
|
||||
<button type="button" class="list-group-item list-group-item-action bg-transparent ps-0 p-1 border-0" title="Toggle document type filter" i18n-title
|
||||
(click)="clickDocumentType.emit(document.document_type);$event.stopPropagation()">
|
||||
<i-bs width="1em" height="1em" class="me-2 text-muted" name="file-earmark"></i-bs>
|
||||
<small>{{(document.document_type$ | async)?.name ?? privateName}}</small>
|
||||
</button>
|
||||
}
|
||||
@if (document.storage_path) {
|
||||
@if (displayFields.includes(DisplayField.STORAGE_PATH) && document.storage_path) {
|
||||
<button type="button" class="list-group-item list-group-item-action bg-transparent ps-0 p-1 border-0" title="Toggle storage path filter" i18n-title
|
||||
(click)="clickStoragePath.emit(document.storage_path);$event.stopPropagation()">
|
||||
<i-bs width="1em" height="1em" class="me-2 text-muted" name="folder"></i-bs>
|
||||
<small>{{(document.storage_path$ | async)?.name ?? privateName}}</small>
|
||||
</button>
|
||||
}
|
||||
<div class="list-group-item bg-transparent p-0 border-0 d-flex flex-wrap-reverse justify-content-between">
|
||||
<ng-template #dateTooltip>
|
||||
<div class="d-flex flex-column text-light">
|
||||
<span i18n>Created: {{ document.created | customDate }}</span>
|
||||
<span i18n>Added: {{ document.added | customDate }}</span>
|
||||
<span i18n>Modified: {{ document.modified | customDate }}</span>
|
||||
@if (displayFields.includes(DisplayField.CREATED)) {
|
||||
<div class="list-group-item bg-transparent p-0 border-0 d-flex flex-wrap-reverse justify-content-between">
|
||||
<ng-template #dateTooltip>
|
||||
<div class="d-flex flex-column text-light">
|
||||
<span i18n>Created: {{ document.created | customDate }}</span>
|
||||
<span i18n>Added: {{ document.added | customDate }}</span>
|
||||
<span i18n>Modified: {{ document.modified | customDate }}</span>
|
||||
</div>
|
||||
</ng-template>
|
||||
<div class="ps-0 p-1" placement="top" [ngbTooltip]="dateTooltip">
|
||||
<i-bs width="1em" height="1em" class="me-2 text-muted" name="calendar-event"></i-bs>
|
||||
<small>{{document.created | customDate:'mediumDate'}}</small>
|
||||
</div>
|
||||
</ng-template>
|
||||
<div class="ps-0 p-1" placement="top" [ngbTooltip]="dateTooltip">
|
||||
<i-bs width="1em" height="1em" class="me-2 text-muted" name="calendar-event"></i-bs>
|
||||
<small>{{document.created_date | customDate:'mediumDate'}}</small>
|
||||
</div>
|
||||
</div>
|
||||
@if (document.archive_serial_number | isNumber) {
|
||||
}
|
||||
@if (displayFields.includes(DisplayField.ADDED)) {
|
||||
<div class="list-group-item bg-transparent p-0 border-0 d-flex flex-wrap-reverse justify-content-between">
|
||||
<ng-template #dateTooltip>
|
||||
<div class="d-flex flex-column text-light">
|
||||
<span i18n>Created: {{ document.created | customDate }}</span>
|
||||
<span i18n>Added: {{ document.added | customDate }}</span>
|
||||
<span i18n>Modified: {{ document.modified | customDate }}</span>
|
||||
</div>
|
||||
</ng-template>
|
||||
<div class="ps-0 p-1" placement="top" [ngbTooltip]="dateTooltip">
|
||||
<i-bs width="1em" height="1em" class="me-2 text-muted" name="calendar-event"></i-bs>
|
||||
<small>{{document.added | customDate:'mediumDate'}}</small>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
@if (displayFields.includes(DisplayField.ASN) && document.archive_serial_number | isNumber) {
|
||||
<div class="ps-0 p-1">
|
||||
<i-bs width="1em" height="1em" class="me-2 text-muted" name="upc-scan"></i-bs>
|
||||
<small>#{{document.archive_serial_number}}</small>
|
||||
</div>
|
||||
}
|
||||
@if (document.owner && document.owner !== settingsService.currentUser.id) {
|
||||
@if (displayFields.includes(DisplayField.OWNER) && document.owner && document.owner !== settingsService.currentUser.id) {
|
||||
<div class="ps-0 p-1">
|
||||
<i-bs width="1em" height="1em" class="me-2 text-muted" name="person-fill-lock"></i-bs>
|
||||
<small>{{document.owner | username}}</small>
|
||||
</div>
|
||||
}
|
||||
@if (document.is_shared_by_requester) {
|
||||
@if (displayFields.includes(DisplayField.SHARED) && document.is_shared_by_requester) {
|
||||
<div class="ps-0 p-1">
|
||||
<i-bs width="1em" height="1em" class="me-2 text-muted" name="people-fill"></i-bs>
|
||||
<small i18n>Shared</small>
|
||||
</div>
|
||||
}
|
||||
@for (field of document.custom_fields; track field.id) {
|
||||
@if (displayFields.includes(DisplayField.CUSTOM_FIELD + field.field)) {
|
||||
<div class="ps-0 p-1 d-flex align-items-center overflow-hidden">
|
||||
<i-bs width="1em" height="1em" class="me-2 text-muted" name="ui-radios"></i-bs>
|
||||
<small><pngx-custom-field-display [document]="document" [fieldId]="field.field"></pngx-custom-field-display></small>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
</div>
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<div class="btn-group w-100">
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user