Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2d25400a23 | ||
|
|
279e075f58 | ||
|
|
2654dc46cf | ||
|
|
1f6a4ce402 | ||
|
|
8152c50288 | ||
|
|
ef8b87571f | ||
|
|
ce1dffbbd5 | ||
|
|
26b288f61a | ||
|
|
abd08e8000 | ||
|
|
649923d639 | ||
|
|
c869deed0e | ||
|
|
007316edf7 | ||
|
|
ade66f4228 | ||
|
|
4feeaea460 | ||
|
|
e13fb69913 | ||
|
|
80be118e05 | ||
|
|
5f4ac876e4 |
@@ -72,9 +72,11 @@ jobs:
|
|||||||
'You are welcome to open a new issue that describes the problem you observed in your own words.'
|
'You are welcome to open a new issue that describes the problem you observed in your own words.'
|
||||||
: 'This issue was automatically closed because it was not opened using our bug report form. ' +
|
: 'This issue was automatically closed because it was not opened using our bug report form. ' +
|
||||||
'Issues have to be created through the form so that the details we need to investigate are included.\n\n' +
|
'Issues have to be created through the form so that the details we need to investigate are included.\n\n' +
|
||||||
`If the problem is still there, please [open a new issue](${newIssue}) using the form. No other action is needed here.\n\n` +
|
`If the problem is still there, please [open a new issue](${newIssue}) using the form — that is all it takes ` +
|
||||||
|
'to get it looked at, and no other action is needed here.\n\n' +
|
||||||
'If any part of your report was written by an AI tool or agent, you must say so: undisclosed AI-generated ' +
|
'If any part of your report was written by an AI tool or agent, you must say so: undisclosed AI-generated ' +
|
||||||
`contributions are a violation of our [Code of Conduct](${codeOfConduct}).`;
|
`contributions are a violation of our [Code of Conduct](${codeOfConduct}), and such reports must describe the ` +
|
||||||
|
`behavior you observed only, without code analysis or suggested fixes. See our [contributing guidelines](${contributing}).`;
|
||||||
|
|
||||||
await github.rest.issues.createComment({ ...common, body });
|
await github.rest.issues.createComment({ ...common, body });
|
||||||
await github.rest.issues.addLabels({ ...common, labels: ['ai'] });
|
await github.rest.issues.addLabels({ ...common, labels: ['ai'] });
|
||||||
|
|||||||
@@ -25,10 +25,6 @@ jobs:
|
|||||||
pr-bot:
|
pr-bot:
|
||||||
name: Automated PR Bot
|
name: Automated PR Bot
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
# Runs after Anti-slop so the welcome comment can see whether the PR was closed
|
|
||||||
# instead of racing it. Still runs if that job fails, so labeling is not lost.
|
|
||||||
needs: Anti-slop
|
|
||||||
if: ${{ !cancelled() }}
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
pull-requests: write
|
pull-requests: write
|
||||||
@@ -103,25 +99,8 @@ jobs:
|
|||||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||||
with:
|
with:
|
||||||
script: |
|
script: |
|
||||||
const user = context.payload.pull_request.user.login;
|
const pr = context.payload.pull_request;
|
||||||
|
const user = pr.user.login;
|
||||||
// Re-read the PR: Anti-slop may have closed and labeled it after the webhook
|
|
||||||
const { data: pr } = await github.rest.pulls.get({
|
|
||||||
owner: context.repo.owner,
|
|
||||||
repo: context.repo.repo,
|
|
||||||
pull_number: context.payload.pull_request.number,
|
|
||||||
});
|
|
||||||
|
|
||||||
if (pr.state === 'closed') {
|
|
||||||
core.info('Skipping comment: PR is already closed');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const labels = pr.labels.map((label) => (typeof label === 'string' ? label : label.name));
|
|
||||||
if (labels.includes('ai')) {
|
|
||||||
core.info('Skipping comment: PR is labeled ai');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const { data: members } = await github.rest.orgs.listMembers({
|
const { data: members } = await github.rest.orgs.listMembers({
|
||||||
org: 'paperless-ngx',
|
org: 'paperless-ngx',
|
||||||
|
|||||||
@@ -1,58 +0,0 @@
|
|||||||
#!/command/with-contenv /usr/bin/bash
|
|
||||||
# shellcheck shell=bash
|
|
||||||
declare -r log_prefix="[init-compile-bytecode]"
|
|
||||||
|
|
||||||
# PYTHONDONTWRITEBYTECODE=1 is set for the whole container. This unit compiles a
|
|
||||||
# scoped set of libraries anyway, to speed up startup without bloating image size.
|
|
||||||
|
|
||||||
# Handle the people using a read only file system
|
|
||||||
if [[ "${S6_READ_ONLY_ROOT}" == "1" ]]; then
|
|
||||||
echo "${log_prefix} S6_READ_ONLY_ROOT=1, skipping (nothing to write bytecode to)"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
# When running as a non-root user, site-packages is still root-owned and unwritable,
|
|
||||||
# so this step would just fail loudly on every container start. Skip it.
|
|
||||||
if [[ -n "${USER_IS_NON_ROOT}" ]]; then
|
|
||||||
echo "${log_prefix} USER_IS_NON_ROOT is set, skipping (site-packages is not writable)"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
declare -r site_packages="$(python3 -c 'import site; print(site.getsitepackages()[0])')"
|
|
||||||
|
|
||||||
# Deliberately scoped to packages that paperless.settings/paperless/__init__.py import
|
|
||||||
# unconditionally on every manage.py invocation (Django itself, the always-loaded
|
|
||||||
# INSTALLED_APPS, and celery). This is NOT "compile everything" - the optional AI stack
|
|
||||||
# (torch, llama-index, sentence-transformers, ...) is intentionally excluded since it is
|
|
||||||
# lazy-imported and large.
|
|
||||||
declare -a scope=(
|
|
||||||
"${PAPERLESS_SRC_DIR}"
|
|
||||||
"${site_packages}/django"
|
|
||||||
"${site_packages}/celery"
|
|
||||||
"${site_packages}/kombu"
|
|
||||||
"${site_packages}/rest_framework"
|
|
||||||
"${site_packages}/django_filters"
|
|
||||||
"${site_packages}/whitenoise"
|
|
||||||
"${site_packages}/corsheaders"
|
|
||||||
"${site_packages}/django_extensions"
|
|
||||||
"${site_packages}/guardian"
|
|
||||||
"${site_packages}/allauth"
|
|
||||||
"${site_packages}/drf_spectacular"
|
|
||||||
"${site_packages}/drf_spectacular_sidecar"
|
|
||||||
"${site_packages}/treenode"
|
|
||||||
"${site_packages}/compression_middleware"
|
|
||||||
)
|
|
||||||
|
|
||||||
declare -a existing_scope=()
|
|
||||||
for path in "${scope[@]}"; do
|
|
||||||
[[ -d "${path}" ]] && existing_scope+=("${path}")
|
|
||||||
done
|
|
||||||
|
|
||||||
echo "${log_prefix} Compiling bytecode for: ${existing_scope[*]}"
|
|
||||||
declare -r start_seconds=${SECONDS}
|
|
||||||
|
|
||||||
if ! PYTHONDONTWRITEBYTECODE= python3 -m compileall -q "${existing_scope[@]}"; then
|
|
||||||
echo "${log_prefix} WARNING: compileall reported errors (read-only filesystem or unwritable site-packages?); continuing without a bytecode cache"
|
|
||||||
fi
|
|
||||||
|
|
||||||
echo "${log_prefix} Done in $((SECONDS - start_seconds))s"
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
oneshot
|
|
||||||
@@ -1 +0,0 @@
|
|||||||
/etc/s6-overlay/s6-rc.d/init-compile-bytecode/run
|
|
||||||
@@ -138,9 +138,7 @@ for suggested generation and embedding models.
|
|||||||
With AI enabled, Paperless-ngx can suggest a title, tags, correspondent, document type,
|
With AI enabled, Paperless-ngx can suggest a title, tags, correspondent, document type,
|
||||||
storage path and dates by sending the document to the LLM. This is **opt-in per request**
|
storage path and dates by sending the document to the LLM. This is **opt-in per request**
|
||||||
and surfaces through the "Suggest" control on the document detail page, alongside the
|
and surfaces through the "Suggest" control on the document detail page, alongside the
|
||||||
classic classifier-based suggestions — it does not disable them. Suggestions are requested
|
classic classifier-based suggestions — it does not disable them. Suggestion output
|
||||||
automatically when you open a document that carries an inbox tag unless "Automatically request
|
|
||||||
suggestions for inbox documents" under Settings > Documents is disabled. Suggestion output
|
|
||||||
language can be steered with
|
language can be steered with
|
||||||
[`PAPERLESS_AI_LLM_OUTPUT_LANGUAGE`](configuration.md#PAPERLESS_AI_LLM_OUTPUT_LANGUAGE)
|
[`PAPERLESS_AI_LLM_OUTPUT_LANGUAGE`](configuration.md#PAPERLESS_AI_LLM_OUTPUT_LANGUAGE)
|
||||||
(otherwise it follows the user's UI language).
|
(otherwise it follows the user's UI language).
|
||||||
|
|||||||
@@ -1200,15 +1200,6 @@ still perform some basic text pre-processing before matching.
|
|||||||
|
|
||||||
Defaults to true, enabling the feature.
|
Defaults to true, enabling the feature.
|
||||||
|
|
||||||
#### [`PAPERLESS_CLASSIFIER_MATCH_THRESHOLD=<float>`](#PAPERLESS_CLASSIFIER_MATCH_THRESHOLD) {#PAPERLESS_CLASSIFIER_MATCH_THRESHOLD}
|
|
||||||
|
|
||||||
: Sets the minimum confidence score (0.0-1.0) required for the automatic
|
|
||||||
classifier to assign a correspondent, document type, or storage path to a
|
|
||||||
document. Predictions below this threshold are discarded and the field is
|
|
||||||
left unassigned, preventing low-confidence guesses from being applied.
|
|
||||||
|
|
||||||
Defaults to 0.6.
|
|
||||||
|
|
||||||
#### [`PAPERLESS_DATE_PARSER_LANGUAGES=<lang>`](#PAPERLESS_DATE_PARSER_LANGUAGES) {#PAPERLESS_DATE_PARSER_LANGUAGES}
|
#### [`PAPERLESS_DATE_PARSER_LANGUAGES=<lang>`](#PAPERLESS_DATE_PARSER_LANGUAGES) {#PAPERLESS_DATE_PARSER_LANGUAGES}
|
||||||
|
|
||||||
: Specifies which language Paperless should use when parsing dates from documents.
|
: Specifies which language Paperless should use when parsing dates from documents.
|
||||||
|
|||||||
@@ -317,8 +317,6 @@ a "document already exists" message.
|
|||||||
|
|
||||||
Paperless-ngx can suggest tags, correspondents, document types and storage paths for documents based on the content of the document. This is done using a (non-LLM) machine learning model that is trained on the documents in your database. The suggestions are shown in the document detail page and can be accepted or rejected by the user.
|
Paperless-ngx can suggest tags, correspondents, document types and storage paths for documents based on the content of the document. This is done using a (non-LLM) machine learning model that is trained on the documents in your database. The suggestions are shown in the document detail page and can be accepted or rejected by the user.
|
||||||
|
|
||||||
Suggestions are requested automatically when you open a document that still has an inbox tag. To only request them by pressing the "Suggest" button instead, turn off "Automatically request suggestions for inbox documents" under Settings > Documents.
|
|
||||||
|
|
||||||
## AI Features
|
## AI Features
|
||||||
|
|
||||||
Paperless-ngx includes several features that use AI to enhance the document management experience. These features are optional and can be enabled or disabled in the settings. If you are using the AI features, you may want to also enable the "LLM index" feature, which supports Retrieval-Augmented Generation (RAG) designed to improve the quality of AI responses. The LLM index feature is not enabled by default and requires additional configuration.
|
Paperless-ngx includes several features that use AI to enhance the document management experience. These features are optional and can be enabled or disabled in the settings. If you are using the AI features, you may want to also enable the "LLM index" feature, which supports Retrieval-Augmented Generation (RAG) designed to improve the quality of AI responses. The LLM index feature is not enabled by default and requires additional configuration.
|
||||||
@@ -686,8 +684,7 @@ It requires [AI features](configuration.md#ai) to be enabled. You can specify:
|
|||||||
never replace the document's existing tags.
|
never replace the document's existing tags.
|
||||||
|
|
||||||
The action works with every trigger **except Consumption Started**, because suggestions are made from
|
The action works with every trigger **except Consumption Started**, because suggestions are made from
|
||||||
the document's text, which does not exist until after the document has been processed. Documents whose
|
the document's text, which does not exist until after the document has been processed.
|
||||||
processed text is empty or contains only whitespace are skipped.
|
|
||||||
|
|
||||||
Because the query to the AI service is slow, the action is queued and runs in the background rather
|
Because the query to the AI service is slow, the action is queued and runs in the background rather
|
||||||
than as part of the workflow run itself. The document is updated once the suggestions come back.
|
than as part of the workflow run itself. The document is updated once the suggestions come back.
|
||||||
|
|||||||
@@ -32,21 +32,21 @@ dependencies = [
|
|||||||
"django-cors-headers~=4.9.0",
|
"django-cors-headers~=4.9.0",
|
||||||
"django-extensions~=4.1",
|
"django-extensions~=4.1",
|
||||||
"django-filter~=25.1",
|
"django-filter~=25.1",
|
||||||
"django-guardian>=3.3.3,<3.5",
|
"django-guardian~=3.3.3",
|
||||||
"django-multiselectfield~=1.0.1",
|
"django-multiselectfield~=1.0.1",
|
||||||
"django-rich~=2.2.0",
|
"django-rich~=2.2.0",
|
||||||
"django-soft-delete~=1.0.18",
|
"django-soft-delete~=1.0.18",
|
||||||
"django-treenode>=0.24",
|
"django-treenode>=0.24",
|
||||||
"djangorestframework~=3.16",
|
"djangorestframework~=3.16",
|
||||||
"drf-spectacular~=0.30",
|
"drf-spectacular~=0.30",
|
||||||
"drf-spectacular-sidecar>=2026.7.1,<2026.9",
|
"drf-spectacular-sidecar~=2026.7.1",
|
||||||
"drf-writable-nested~=0.7.1",
|
"drf-writable-nested~=0.7.1",
|
||||||
"filelock~=3.32.0",
|
"filelock~=3.32.0",
|
||||||
"flower>=2.0.1,<2.2",
|
"flower>=2.0.1,<2.2",
|
||||||
"gotenberg-client[httpx]~=1.0",
|
"gotenberg-client[httpx]~=1.0",
|
||||||
"httpx-oauth~=0.17",
|
"httpx-oauth~=0.17",
|
||||||
"ijson>=3.5.1",
|
"ijson>=3.5.1",
|
||||||
"imap-tools>=1.14,<1.16",
|
"imap-tools~=1.14.0",
|
||||||
"jinja2~=3.1.6",
|
"jinja2~=3.1.6",
|
||||||
"langdetect~=1.0.9",
|
"langdetect~=1.0.9",
|
||||||
"llama-index-core>=0.14.23",
|
"llama-index-core>=0.14.23",
|
||||||
@@ -56,7 +56,7 @@ dependencies = [
|
|||||||
"llama-index-llms-ollama>=0.9.1",
|
"llama-index-llms-ollama>=0.9.1",
|
||||||
"llama-index-llms-openai-like>=0.7.1",
|
"llama-index-llms-openai-like>=0.7.1",
|
||||||
"nltk~=3.10.0",
|
"nltk~=3.10.0",
|
||||||
"ocrmypdf>=17.7,<17.12",
|
"ocrmypdf>=17.7,<17.11",
|
||||||
"openai>=2.48",
|
"openai>=2.48",
|
||||||
"pathvalidate~=3.3.1",
|
"pathvalidate~=3.3.1",
|
||||||
"pdf2image~=1.17.0",
|
"pdf2image~=1.17.0",
|
||||||
@@ -103,17 +103,17 @@ docs = [
|
|||||||
"zensical>=0.0.51",
|
"zensical>=0.0.51",
|
||||||
]
|
]
|
||||||
lint = [
|
lint = [
|
||||||
"prek>=0.4.11,<0.6",
|
"prek~=0.4.11",
|
||||||
"ruff~=0.16.1",
|
"ruff~=0.16.1",
|
||||||
]
|
]
|
||||||
testing = [
|
testing = [
|
||||||
"daphne",
|
"daphne",
|
||||||
"factory-boy~=3.3.1",
|
"factory-boy~=3.3.1",
|
||||||
"faker>=40.36,<40.38",
|
"faker~=40.36.0",
|
||||||
"imagehash",
|
"imagehash",
|
||||||
"pytest~=9.1.1",
|
"pytest~=9.1.1",
|
||||||
"pytest-cov~=7.1.0",
|
"pytest-cov~=7.1.0",
|
||||||
"pytest-django>=4.12,<4.15",
|
"pytest-django~=4.12.0",
|
||||||
"pytest-env~=1.7.0",
|
"pytest-env~=1.7.0",
|
||||||
"pytest-httpx",
|
"pytest-httpx",
|
||||||
"pytest-mock~=3.15.1",
|
"pytest-mock~=3.15.1",
|
||||||
|
|||||||
@@ -71,10 +71,8 @@
|
|||||||
"tsConfig": "tsconfig.app.json",
|
"tsConfig": "tsconfig.app.json",
|
||||||
"localize": true,
|
"localize": true,
|
||||||
"assets": [
|
"assets": [
|
||||||
|
"src/favicon.ico",
|
||||||
"src/apple-touch-icon.png",
|
"src/apple-touch-icon.png",
|
||||||
"src/icon-192.png",
|
|
||||||
"src/icon-512.png",
|
|
||||||
"src/icon-512-maskable.png",
|
|
||||||
"src/assets",
|
"src/assets",
|
||||||
"src/manifest.webmanifest",
|
"src/manifest.webmanifest",
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { DocumentListComponent } from './components/document-list/document-list.
|
|||||||
import { DocumentAttributesComponent } from './components/manage/document-attributes/document-attributes.component'
|
import { DocumentAttributesComponent } from './components/manage/document-attributes/document-attributes.component'
|
||||||
import { MailComponent } from './components/manage/mail/mail.component'
|
import { MailComponent } from './components/manage/mail/mail.component'
|
||||||
import { SavedViewsComponent } from './components/manage/saved-views/saved-views.component'
|
import { SavedViewsComponent } from './components/manage/saved-views/saved-views.component'
|
||||||
|
import { ShareLinksComponent } from './components/manage/share-links/share-links.component'
|
||||||
import { WorkflowsComponent } from './components/manage/workflows/workflows.component'
|
import { WorkflowsComponent } from './components/manage/workflows/workflows.component'
|
||||||
import { NotFoundComponent } from './components/not-found/not-found.component'
|
import { NotFoundComponent } from './components/not-found/not-found.component'
|
||||||
import { DirtyDocGuard } from './guards/dirty-doc.guard'
|
import { DirtyDocGuard } from './guards/dirty-doc.guard'
|
||||||
@@ -310,6 +311,24 @@ export const routes: Routes = [
|
|||||||
componentName: 'SavedViewsComponent',
|
componentName: 'SavedViewsComponent',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'share-links',
|
||||||
|
component: ShareLinksComponent,
|
||||||
|
canActivate: [PermissionsGuard],
|
||||||
|
data: {
|
||||||
|
requiredPermissionAny: [
|
||||||
|
{
|
||||||
|
action: PermissionAction.View,
|
||||||
|
type: PermissionType.ShareLink,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
action: PermissionAction.View,
|
||||||
|
type: PermissionType.ShareLinkBundle,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
componentName: 'ShareLinksComponent',
|
||||||
|
},
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|||||||
@@ -23,30 +23,17 @@
|
|||||||
<div class="col">
|
<div class="col">
|
||||||
<div class="card bg-light">
|
<div class="card bg-light">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="card-title d-flex align-items-center flex-wrap">
|
<div class="card-title d-flex align-items-center">
|
||||||
<h6 class="mb-0">
|
<h6 class="mb-0">
|
||||||
{{option.title}}
|
{{option.title}}
|
||||||
</h6>
|
</h6>
|
||||||
<a class="btn btn-sm btn-link" title="Read the documentation about this setting" i18n-title [href]="getDocsUrl(option.config_key)" target="_blank" referrerpolicy="no-referrer">
|
<a class="btn btn-sm btn-link" title="Read the documentation about this setting" i18n-title [href]="getDocsUrl(option.config_key)" target="_blank" referrerpolicy="no-referrer">
|
||||||
<i-bs name="info-circle"></i-bs>
|
<i-bs name="info-circle"></i-bs>
|
||||||
</a>
|
</a>
|
||||||
@if (isExternallyConfigured(option.config_key)) {
|
|
||||||
@if (isSet(option.key)) {
|
|
||||||
<span class="badge rounded-pill bg-body-secondary text-dark fw-normal" title="This value overrides {{option.config_key}}, which is set outside Paperless." i18n-title>Overrides external</span>
|
|
||||||
} @else {
|
|
||||||
<span class="badge rounded-pill bg-body-secondary text-dark fw-normal" title="{{option.config_key}} is set outside Paperless. Enter a value here to override it." i18n-title>Set externally</span>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@if (isSet(option.key)) {
|
@if (isSet(option.key)) {
|
||||||
@if (isExternallyConfigured(option.config_key)) {
|
<button type="button" class="btn btn-sm btn-link text-danger ms-auto pe-0" title="Reset" i18n-title (click)="resetOption(option.key)">
|
||||||
<button type="button" class="btn btn-sm btn-link text-danger ms-auto pe-0" title="Use the externally configured value" i18n-title (click)="resetOption(option.key)">
|
<i-bs class="me-1" name="x"></i-bs><ng-container i18n>Reset</ng-container>
|
||||||
<i-bs class="me-1" name="x"></i-bs><ng-container i18n>Reset to external</ng-container>
|
</button>
|
||||||
</button>
|
|
||||||
} @else {
|
|
||||||
<button type="button" class="btn btn-sm btn-link text-danger ms-auto pe-0" title="Reset" i18n-title (click)="resetOption(option.key)">
|
|
||||||
<i-bs class="me-1" name="x"></i-bs><ng-container i18n>Reset</ng-container>
|
|
||||||
</button>
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<div class="mb-n3">
|
<div class="mb-n3">
|
||||||
|
|||||||
@@ -163,19 +163,6 @@ describe('ConfigComponent', () => {
|
|||||||
expect(component.configForm.get('barcodes_enabled').value).toBeNull()
|
expect(component.configForm.get('barcodes_enabled').value).toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should identify externally configured options', () => {
|
|
||||||
component.externallyConfiguredVariables = new Set([
|
|
||||||
'PAPERLESS_OCR_LANGUAGE',
|
|
||||||
])
|
|
||||||
|
|
||||||
expect(
|
|
||||||
component.isExternallyConfigured('PAPERLESS_OCR_LANGUAGE')
|
|
||||||
).toBeTruthy()
|
|
||||||
expect(
|
|
||||||
component.isExternallyConfigured('PAPERLESS_OCR_OUTPUT_TYPE')
|
|
||||||
).toBeFalsy()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should group options into sections within a category, or not', () => {
|
it('should group options into sections within a category, or not', () => {
|
||||||
const sections = component.getCategorySections(ConfigCategory.OCR)
|
const sections = component.getCategorySections(ConfigCategory.OCR)
|
||||||
expect(sections).toEqual([null, ConfigSection.RemoteOCR])
|
expect(sections).toEqual([null, ConfigSection.RemoteOCR])
|
||||||
|
|||||||
@@ -69,7 +69,6 @@ export class ConfigComponent
|
|||||||
public configForm = new FormGroup({})
|
public configForm = new FormGroup({})
|
||||||
|
|
||||||
public errors = {}
|
public errors = {}
|
||||||
public externallyConfiguredVariables = new Set<string>()
|
|
||||||
|
|
||||||
get optionCategories(): string[] {
|
get optionCategories(): string[] {
|
||||||
return Object.values(ConfigCategory)
|
return Object.values(ConfigCategory)
|
||||||
@@ -153,9 +152,6 @@ export class ConfigComponent
|
|||||||
}
|
}
|
||||||
|
|
||||||
private initialize(config: PaperlessConfig) {
|
private initialize(config: PaperlessConfig) {
|
||||||
this.externallyConfiguredVariables = new Set(
|
|
||||||
config.externally_configured_variables ?? []
|
|
||||||
)
|
|
||||||
if (!this.store) {
|
if (!this.store) {
|
||||||
this.store = new BehaviorSubject(config)
|
this.store = new BehaviorSubject(config)
|
||||||
|
|
||||||
@@ -166,9 +162,7 @@ export class ConfigComponent
|
|||||||
this.configForm.patchValue(state, { emitEvent: false })
|
this.configForm.patchValue(state, { emitEvent: false })
|
||||||
})
|
})
|
||||||
|
|
||||||
this.isDirty$ = dirtyCheck(this.configForm, this.store.asObservable(), {
|
this.isDirty$ = dirtyCheck(this.configForm, this.store.asObservable())
|
||||||
excludeKeys: ['externally_configured_variables'],
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
this.configForm.patchValue(config)
|
this.configForm.patchValue(config)
|
||||||
|
|
||||||
@@ -233,10 +227,6 @@ export class ConfigComponent
|
|||||||
return this.configForm.get(key).value != null
|
return this.configForm.get(key).value != null
|
||||||
}
|
}
|
||||||
|
|
||||||
public isExternallyConfigured(configKey: string): boolean {
|
|
||||||
return this.externallyConfiguredVariables.has(configKey)
|
|
||||||
}
|
|
||||||
|
|
||||||
public resetOption(key: string) {
|
public resetOption(key: string) {
|
||||||
this.configForm.get(key).setValue(null)
|
this.configForm.get(key).setValue(null)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -112,22 +112,6 @@
|
|||||||
|
|
||||||
<pngx-input-check i18n-title title="Use 'slim' sidebar (icons only)" formControlName="slimSidebarEnabled"></pngx-input-check>
|
<pngx-input-check i18n-title title="Use 'slim' sidebar (icons only)" formControlName="slimSidebarEnabled"></pngx-input-check>
|
||||||
|
|
||||||
<p class="mb-2 mt-3" i18n>Sidebar items to show:</p>
|
|
||||||
@for (option of sidebarItemOptions; track option.id) {
|
|
||||||
<div class="form-check">
|
|
||||||
<input
|
|
||||||
class="form-check-input"
|
|
||||||
type="checkbox"
|
|
||||||
[id]="'sidebar-item-setting-' + option.id"
|
|
||||||
[checked]="isSidebarItemShown(option.id)"
|
|
||||||
(change)="toggleSidebarItem(option.id, $event.target.checked)"
|
|
||||||
/>
|
|
||||||
<label class="form-check-label" [for]="'sidebar-item-setting-' + option.id">
|
|
||||||
{{ option.label }}
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -253,12 +237,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="row">
|
|
||||||
<div class="col">
|
|
||||||
<pngx-input-check i18n-title title="Automatically request suggestions for inbox documents" i18n-hint hint="If un-checked, suggestions must be requested via the Suggest button." formControlName="documentEditingAutoSuggest"></pngx-input-check>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col">
|
<div class="col">
|
||||||
<pngx-input-check i18n-title title="Show document thumbnail during loading" formControlName="documentEditingOverlayThumbnail"></pngx-input-check>
|
<pngx-input-check i18n-title title="Show document thumbnail during loading" formControlName="documentEditingOverlayThumbnail"></pngx-input-check>
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ import {
|
|||||||
SystemStatus,
|
SystemStatus,
|
||||||
SystemStatusItemStatus,
|
SystemStatusItemStatus,
|
||||||
} from 'src/app/data/system-status'
|
} from 'src/app/data/system-status'
|
||||||
import { HideableSidebarItemID, SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
||||||
import { IfOwnerDirective } from 'src/app/directives/if-owner.directive'
|
import { IfOwnerDirective } from 'src/app/directives/if-owner.directive'
|
||||||
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
||||||
import { PermissionsGuard } from 'src/app/guards/permissions.guard'
|
import { PermissionsGuard } from 'src/app/guards/permissions.guard'
|
||||||
@@ -209,45 +209,6 @@ describe('SettingsComponent', () => {
|
|||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
}
|
}
|
||||||
|
|
||||||
it('supports configuring sidebar items and canceling changes', () => {
|
|
||||||
completeSetup()
|
|
||||||
|
|
||||||
component.toggleSidebarItem(HideableSidebarItemID.Workflows, false)
|
|
||||||
fixture.detectChanges()
|
|
||||||
|
|
||||||
expect(component.settingsForm.value.sidebarHiddenItems).toContain(
|
|
||||||
HideableSidebarItemID.Workflows
|
|
||||||
)
|
|
||||||
|
|
||||||
settingsService.updateSidebarItemVisibility(
|
|
||||||
HideableSidebarItemID.Mail,
|
|
||||||
false
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(component.settingsForm.value.sidebarHiddenItems).toContain(
|
|
||||||
HideableSidebarItemID.Mail
|
|
||||||
)
|
|
||||||
|
|
||||||
component.reset()
|
|
||||||
|
|
||||||
expect(component.settingsForm.value.sidebarHiddenItems).not.toContain(
|
|
||||||
HideableSidebarItemID.Workflows
|
|
||||||
)
|
|
||||||
expect(component.settingsForm.value.sidebarHiddenItems).not.toContain(
|
|
||||||
HideableSidebarItemID.Mail
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('enables sidebar item controls on general settings until destroyed', () => {
|
|
||||||
completeSetup()
|
|
||||||
|
|
||||||
expect(settingsService.organizingSidebarItems()).toBe(true)
|
|
||||||
|
|
||||||
component.ngOnDestroy()
|
|
||||||
|
|
||||||
expect(settingsService.organizingSidebarItems()).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should support tabbed settings & change URL, prevent navigation if dirty confirmation rejected', async () => {
|
it('should support tabbed settings & change URL, prevent navigation if dirty confirmation rejected', async () => {
|
||||||
completeSetup()
|
completeSetup()
|
||||||
const navigateSpy = jest.spyOn(router, 'navigate')
|
const navigateSpy = jest.spyOn(router, 'navigate')
|
||||||
@@ -288,7 +249,6 @@ describe('SettingsComponent', () => {
|
|||||||
|
|
||||||
it('should support save local settings updating appearance settings and calling API, show error', () => {
|
it('should support save local settings updating appearance settings and calling API, show error', () => {
|
||||||
completeSetup()
|
completeSetup()
|
||||||
component.toggleSidebarItem(HideableSidebarItemID.Workflows, false)
|
|
||||||
const toastErrorSpy = jest.spyOn(toastService, 'showError')
|
const toastErrorSpy = jest.spyOn(toastService, 'showError')
|
||||||
const toastSpy = jest.spyOn(toastService, 'show')
|
const toastSpy = jest.spyOn(toastService, 'show')
|
||||||
const storeSpy = jest.spyOn(settingsService, 'storeSettings')
|
const storeSpy = jest.spyOn(settingsService, 'storeSettings')
|
||||||
@@ -307,10 +267,7 @@ describe('SettingsComponent', () => {
|
|||||||
expect(toastErrorSpy).toHaveBeenCalled()
|
expect(toastErrorSpy).toHaveBeenCalled()
|
||||||
expect(storeSpy).toHaveBeenCalled()
|
expect(storeSpy).toHaveBeenCalled()
|
||||||
expect(appearanceSettingsSpy).not.toHaveBeenCalled()
|
expect(appearanceSettingsSpy).not.toHaveBeenCalled()
|
||||||
expect(setSpy).toHaveBeenCalledTimes(34)
|
expect(setSpy).toHaveBeenCalledTimes(32)
|
||||||
expect(setSpy).toHaveBeenCalledWith(SETTINGS_KEYS.SIDEBAR_HIDDEN_ITEMS, [
|
|
||||||
HideableSidebarItemID.Workflows,
|
|
||||||
])
|
|
||||||
|
|
||||||
// succeed
|
// succeed
|
||||||
storeSpy.mockReturnValueOnce(of(true))
|
storeSpy.mockReturnValueOnce(of(true))
|
||||||
|
|||||||
@@ -39,12 +39,7 @@ import {
|
|||||||
SystemStatus,
|
SystemStatus,
|
||||||
SystemStatusItemStatus,
|
SystemStatusItemStatus,
|
||||||
} from 'src/app/data/system-status'
|
} from 'src/app/data/system-status'
|
||||||
import {
|
import { GlobalSearchType, SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
||||||
GlobalSearchType,
|
|
||||||
HIDEABLE_SIDEBAR_ITEM_IDS,
|
|
||||||
HideableSidebarItemID,
|
|
||||||
SETTINGS_KEYS,
|
|
||||||
} from 'src/app/data/ui-settings'
|
|
||||||
import { User } from 'src/app/data/user'
|
import { User } from 'src/app/data/user'
|
||||||
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
||||||
import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
|
import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
|
||||||
@@ -107,14 +102,6 @@ const documentDetailFieldOptions = [
|
|||||||
{ id: DocumentDetailFieldID.Tags, label: $localize`Tags` },
|
{ id: DocumentDetailFieldID.Tags, label: $localize`Tags` },
|
||||||
]
|
]
|
||||||
|
|
||||||
const sidebarItemLabels: Record<HideableSidebarItemID, string> = {
|
|
||||||
[HideableSidebarItemID.Dashboard]: $localize`Dashboard`,
|
|
||||||
[HideableSidebarItemID.SavedViews]: $localize`Saved Views`,
|
|
||||||
[HideableSidebarItemID.Workflows]: $localize`Workflows`,
|
|
||||||
[HideableSidebarItemID.Mail]: $localize`Mail`,
|
|
||||||
[HideableSidebarItemID.Documentation]: $localize`Documentation`,
|
|
||||||
}
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'pngx-settings',
|
selector: 'pngx-settings',
|
||||||
templateUrl: './settings.component.html',
|
templateUrl: './settings.component.html',
|
||||||
@@ -162,7 +149,6 @@ export class SettingsComponent
|
|||||||
bulkEditApplyOnClose: new FormControl(null),
|
bulkEditApplyOnClose: new FormControl(null),
|
||||||
documentListItemPerPage: new FormControl(null),
|
documentListItemPerPage: new FormControl(null),
|
||||||
slimSidebarEnabled: new FormControl(null),
|
slimSidebarEnabled: new FormControl(null),
|
||||||
sidebarHiddenItems: new FormControl<HideableSidebarItemID[]>([]),
|
|
||||||
darkModeUseSystem: new FormControl(null),
|
darkModeUseSystem: new FormControl(null),
|
||||||
darkModeEnabled: new FormControl(null),
|
darkModeEnabled: new FormControl(null),
|
||||||
darkModeInvertThumbs: new FormControl(null),
|
darkModeInvertThumbs: new FormControl(null),
|
||||||
@@ -182,7 +168,6 @@ export class SettingsComponent
|
|||||||
pdfEditorDefaultEditMode: new FormControl(null),
|
pdfEditorDefaultEditMode: new FormControl(null),
|
||||||
documentEditingRemoveInboxTags: new FormControl(null),
|
documentEditingRemoveInboxTags: new FormControl(null),
|
||||||
documentEditingOverlayThumbnail: new FormControl(null),
|
documentEditingOverlayThumbnail: new FormControl(null),
|
||||||
documentEditingAutoSuggest: new FormControl(null),
|
|
||||||
documentDetailsHiddenFields: new FormControl([]),
|
documentDetailsHiddenFields: new FormControl([]),
|
||||||
searchDbOnly: new FormControl(null),
|
searchDbOnly: new FormControl(null),
|
||||||
searchLink: new FormControl(null),
|
searchLink: new FormControl(null),
|
||||||
@@ -200,7 +185,6 @@ export class SettingsComponent
|
|||||||
|
|
||||||
store: BehaviorSubject<any>
|
store: BehaviorSubject<any>
|
||||||
storeSub: Subscription
|
storeSub: Subscription
|
||||||
sidebarItemsSub: Subscription
|
|
||||||
isDirty$: Observable<boolean>
|
isDirty$: Observable<boolean>
|
||||||
isDirty: boolean = false
|
isDirty: boolean = false
|
||||||
unsubscribeNotifier: Subject<any> = new Subject()
|
unsubscribeNotifier: Subject<any> = new Subject()
|
||||||
@@ -218,10 +202,6 @@ export class SettingsComponent
|
|||||||
public readonly PdfEditorEditMode = PdfEditorEditMode
|
public readonly PdfEditorEditMode = PdfEditorEditMode
|
||||||
|
|
||||||
public readonly documentDetailFieldOptions = documentDetailFieldOptions
|
public readonly documentDetailFieldOptions = documentDetailFieldOptions
|
||||||
public readonly sidebarItemOptions = HIDEABLE_SIDEBAR_ITEM_IDS.map((id) => ({
|
|
||||||
id,
|
|
||||||
label: sidebarItemLabels[id],
|
|
||||||
}))
|
|
||||||
|
|
||||||
get systemStatusHasErrors(): boolean {
|
get systemStatusHasErrors(): boolean {
|
||||||
const status = this.systemStatus()
|
const status = this.systemStatus()
|
||||||
@@ -249,10 +229,6 @@ export class SettingsComponent
|
|||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super()
|
super()
|
||||||
this.sidebarItemsSub =
|
|
||||||
this.settings.sidebarHiddenItemsEditingChanged.subscribe((hiddenItems) =>
|
|
||||||
this.settingsForm.controls.sidebarHiddenItems.setValue(hiddenItems)
|
|
||||||
)
|
|
||||||
this.settings.settingsSaved.subscribe(() => {
|
this.settings.settingsSaved.subscribe(() => {
|
||||||
if (!this.savePending) this.initialize()
|
if (!this.savePending) this.initialize()
|
||||||
this.savedViewsService.maybeRefreshDocumentCounts()
|
this.savedViewsService.maybeRefreshDocumentCounts()
|
||||||
@@ -302,21 +278,14 @@ export class SettingsComponent
|
|||||||
|
|
||||||
this.activatedRoute.paramMap.subscribe((paramMap) => {
|
this.activatedRoute.paramMap.subscribe((paramMap) => {
|
||||||
const section = paramMap.get('section')
|
const section = paramMap.get('section')
|
||||||
let navID = SettingsNavIDs.General
|
|
||||||
if (section) {
|
if (section) {
|
||||||
const navIDKey: string = Object.keys(SettingsNavIDs).find(
|
const navIDKey: string = Object.keys(SettingsNavIDs).find(
|
||||||
(navID) => navID.toLowerCase() == section
|
(navID) => navID.toLowerCase() == section
|
||||||
)
|
)
|
||||||
if (navIDKey) {
|
if (navIDKey) {
|
||||||
navID = SettingsNavIDs[navIDKey]
|
this.activeNavID.set(SettingsNavIDs[navIDKey])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
this.activeNavID.set(navID)
|
|
||||||
this.settings.sidebarHiddenItemsEditing.set(
|
|
||||||
navID === SettingsNavIDs.General
|
|
||||||
? [...this.settingsForm.controls.sidebarHiddenItems.value]
|
|
||||||
: null
|
|
||||||
)
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -340,7 +309,6 @@ export class SettingsComponent
|
|||||||
SETTINGS_KEYS.DOCUMENT_LIST_SIZE
|
SETTINGS_KEYS.DOCUMENT_LIST_SIZE
|
||||||
),
|
),
|
||||||
slimSidebarEnabled: this.settings.get(SETTINGS_KEYS.SLIM_SIDEBAR),
|
slimSidebarEnabled: this.settings.get(SETTINGS_KEYS.SLIM_SIDEBAR),
|
||||||
sidebarHiddenItems: this.settings.get(SETTINGS_KEYS.SIDEBAR_HIDDEN_ITEMS),
|
|
||||||
darkModeUseSystem: this.settings.get(SETTINGS_KEYS.DARK_MODE_USE_SYSTEM),
|
darkModeUseSystem: this.settings.get(SETTINGS_KEYS.DARK_MODE_USE_SYSTEM),
|
||||||
darkModeEnabled: this.settings.get(SETTINGS_KEYS.DARK_MODE_ENABLED),
|
darkModeEnabled: this.settings.get(SETTINGS_KEYS.DARK_MODE_ENABLED),
|
||||||
darkModeInvertThumbs: this.settings.get(
|
darkModeInvertThumbs: this.settings.get(
|
||||||
@@ -400,9 +368,6 @@ export class SettingsComponent
|
|||||||
documentEditingOverlayThumbnail: this.settings.get(
|
documentEditingOverlayThumbnail: this.settings.get(
|
||||||
SETTINGS_KEYS.DOCUMENT_EDITING_OVERLAY_THUMBNAIL
|
SETTINGS_KEYS.DOCUMENT_EDITING_OVERLAY_THUMBNAIL
|
||||||
),
|
),
|
||||||
documentEditingAutoSuggest: this.settings.get(
|
|
||||||
SETTINGS_KEYS.DOCUMENT_EDITING_AUTO_SUGGEST
|
|
||||||
),
|
|
||||||
documentDetailsHiddenFields: this.settings.get(
|
documentDetailsHiddenFields: this.settings.get(
|
||||||
SETTINGS_KEYS.DOCUMENT_DETAILS_HIDDEN_FIELDS
|
SETTINGS_KEYS.DOCUMENT_DETAILS_HIDDEN_FIELDS
|
||||||
),
|
),
|
||||||
@@ -467,12 +432,6 @@ export class SettingsComponent
|
|||||||
this.settingsForm.patchValue(currentFormValue)
|
this.settingsForm.patchValue(currentFormValue)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.settings.organizingSidebarItems()) {
|
|
||||||
this.settings.sidebarHiddenItemsEditing.set([
|
|
||||||
...this.settingsForm.controls.sidebarHiddenItems.value,
|
|
||||||
])
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this.canViewSystemStatus) {
|
if (this.canViewSystemStatus) {
|
||||||
this.systemStatusService.get().subscribe((status) => {
|
this.systemStatusService.get().subscribe((status) => {
|
||||||
this.systemStatus.set(status)
|
this.systemStatus.set(status)
|
||||||
@@ -481,18 +440,8 @@ export class SettingsComponent
|
|||||||
}
|
}
|
||||||
|
|
||||||
ngOnDestroy() {
|
ngOnDestroy() {
|
||||||
this.settings.sidebarHiddenItemsEditing.set(null)
|
|
||||||
if (this.isDirty) this.settings.updateAppearanceSettings() // in case user changed appearance but didn't save
|
if (this.isDirty) this.settings.updateAppearanceSettings() // in case user changed appearance but didn't save
|
||||||
this.storeSub && this.storeSub.unsubscribe()
|
this.storeSub && this.storeSub.unsubscribe()
|
||||||
this.sidebarItemsSub.unsubscribe()
|
|
||||||
}
|
|
||||||
|
|
||||||
isSidebarItemShown(item: HideableSidebarItemID): boolean {
|
|
||||||
return !(this.settingsForm.value.sidebarHiddenItems || []).includes(item)
|
|
||||||
}
|
|
||||||
|
|
||||||
toggleSidebarItem(item: HideableSidebarItemID, checked: boolean): void {
|
|
||||||
this.settings.updateSidebarItemVisibility(item, checked)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public saveSettings() {
|
public saveSettings() {
|
||||||
@@ -520,10 +469,6 @@ export class SettingsComponent
|
|||||||
SETTINGS_KEYS.SLIM_SIDEBAR,
|
SETTINGS_KEYS.SLIM_SIDEBAR,
|
||||||
this.settingsForm.value.slimSidebarEnabled
|
this.settingsForm.value.slimSidebarEnabled
|
||||||
)
|
)
|
||||||
this.settings.set(
|
|
||||||
SETTINGS_KEYS.SIDEBAR_HIDDEN_ITEMS,
|
|
||||||
this.settingsForm.value.sidebarHiddenItems
|
|
||||||
)
|
|
||||||
this.settings.set(
|
this.settings.set(
|
||||||
SETTINGS_KEYS.DARK_MODE_USE_SYSTEM,
|
SETTINGS_KEYS.DARK_MODE_USE_SYSTEM,
|
||||||
this.settingsForm.value.darkModeUseSystem
|
this.settingsForm.value.darkModeUseSystem
|
||||||
@@ -620,10 +565,6 @@ export class SettingsComponent
|
|||||||
SETTINGS_KEYS.DOCUMENT_EDITING_OVERLAY_THUMBNAIL,
|
SETTINGS_KEYS.DOCUMENT_EDITING_OVERLAY_THUMBNAIL,
|
||||||
this.settingsForm.value.documentEditingOverlayThumbnail
|
this.settingsForm.value.documentEditingOverlayThumbnail
|
||||||
)
|
)
|
||||||
this.settings.set(
|
|
||||||
SETTINGS_KEYS.DOCUMENT_EDITING_AUTO_SUGGEST,
|
|
||||||
this.settingsForm.value.documentEditingAutoSuggest
|
|
||||||
)
|
|
||||||
this.settings.set(
|
this.settings.set(
|
||||||
SETTINGS_KEYS.DOCUMENT_DETAILS_HIDDEN_FIELDS,
|
SETTINGS_KEYS.DOCUMENT_DETAILS_HIDDEN_FIELDS,
|
||||||
this.settingsForm.value.documentDetailsHiddenFields
|
this.settingsForm.value.documentDetailsHiddenFields
|
||||||
@@ -683,11 +624,6 @@ export class SettingsComponent
|
|||||||
|
|
||||||
reset() {
|
reset() {
|
||||||
this.settingsForm.patchValue(this.store.getValue())
|
this.settingsForm.patchValue(this.store.getValue())
|
||||||
if (this.settings.organizingSidebarItems()) {
|
|
||||||
this.settings.sidebarHiddenItemsEditing.set([
|
|
||||||
...this.settingsForm.controls.sidebarHiddenItems.value,
|
|
||||||
])
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
clearThemeColor() {
|
clearThemeColor() {
|
||||||
|
|||||||
@@ -86,15 +86,12 @@
|
|||||||
}
|
}
|
||||||
<div class="sidebar-sticky pt-3 pb-1 d-flex flex-column justify-space-around">
|
<div class="sidebar-sticky pt-3 pb-1 d-flex flex-column justify-space-around">
|
||||||
<ul class="nav flex-column">
|
<ul class="nav flex-column">
|
||||||
<li class="nav-item app-link position-relative" [class.d-none]="settingsService.sidebarItemIsHidden(HideableSidebarItemID.Dashboard) && !settingsService.organizingSidebarItems()">
|
<li class="nav-item app-link">
|
||||||
<a class="nav-link" [class.opacity-50]="settingsService.sidebarItemIsHidden(HideableSidebarItemID.Dashboard)" [class.pe-5]="settingsService.organizingSidebarItems() && !slimSidebarEnabled && !slimSidebarAnimating()" routerLink="dashboard" routerLinkActive="active" (click)="closeMenu()"
|
<a class="nav-link" routerLink="dashboard" routerLinkActive="active" (click)="closeMenu()"
|
||||||
ngbPopover="Dashboard" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end"
|
ngbPopover="Dashboard" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end"
|
||||||
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||||
<i-bs class="me-2" name="house"></i-bs><span class="nav-link-label"><ng-container i18n>Dashboard</ng-container></span>
|
<i-bs class="me-2" name="house"></i-bs><span class="nav-link-label"><ng-container i18n>Dashboard</ng-container></span>
|
||||||
</a>
|
</a>
|
||||||
@if (settingsService.organizingSidebarItems()) {
|
|
||||||
<pngx-input-switch class="position-absolute top-50 end-0 translate-middle-y me-1" [class.d-none]="slimSidebarEnabled || slimSidebarAnimating()" [compact]="true" title="Dashboard" i18n-title [ngModel]="!settingsService.sidebarItemIsHidden(HideableSidebarItemID.Dashboard)" (ngModelChange)="toggleSidebarItem(HideableSidebarItemID.Dashboard, $event)"></pngx-input-switch>
|
|
||||||
}
|
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }">
|
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }">
|
||||||
<a class="nav-link" routerLink="documents" routerLinkActive="active"
|
<a class="nav-link" routerLink="documents" routerLinkActive="active"
|
||||||
@@ -240,38 +237,38 @@
|
|||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
}
|
}
|
||||||
<li class="nav-item app-link position-relative" [class.d-none]="settingsService.sidebarItemIsHidden(HideableSidebarItemID.SavedViews) && !settingsService.organizingSidebarItems()" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.SavedView }">
|
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.SavedView }">
|
||||||
<a class="nav-link" [class.opacity-50]="settingsService.sidebarItemIsHidden(HideableSidebarItemID.SavedViews)" [class.pe-5]="settingsService.organizingSidebarItems() && !slimSidebarEnabled && !slimSidebarAnimating()" routerLink="savedviews" routerLinkActive="active" (click)="closeMenu()"
|
<a class="nav-link" routerLink="savedviews" routerLinkActive="active" (click)="closeMenu()"
|
||||||
ngbPopover="Saved Views" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end"
|
ngbPopover="Saved Views" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end"
|
||||||
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||||
<i-bs class="me-2" name="window-stack"></i-bs><span class="nav-link-label"><ng-container i18n>Saved Views</ng-container></span>
|
<i-bs class="me-2" name="window-stack"></i-bs><span class="nav-link-label"><ng-container i18n>Saved Views</ng-container></span>
|
||||||
</a>
|
</a>
|
||||||
@if (settingsService.organizingSidebarItems()) {
|
|
||||||
<pngx-input-switch class="position-absolute top-50 end-0 translate-middle-y me-1" [class.d-none]="slimSidebarEnabled || slimSidebarAnimating()" [compact]="true" title="Saved Views" i18n-title [ngModel]="!settingsService.sidebarItemIsHidden(HideableSidebarItemID.SavedViews)" (ngModelChange)="toggleSidebarItem(HideableSidebarItemID.SavedViews, $event)"></pngx-input-switch>
|
|
||||||
}
|
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item app-link position-relative" [class.d-none]="settingsService.sidebarItemIsHidden(HideableSidebarItemID.Workflows) && !settingsService.organizingSidebarItems()"
|
@if (canManageShareLinks) {
|
||||||
|
<li class="nav-item app-link">
|
||||||
|
<a class="nav-link" routerLink="share-links" routerLinkActive="active" (click)="closeMenu()"
|
||||||
|
ngbPopover="Share links" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end"
|
||||||
|
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||||
|
<i-bs class="me-2" name="link"></i-bs><span class="nav-link-label"><ng-container i18n>Share links</ng-container></span>
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
<li class="nav-item app-link"
|
||||||
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Workflow }"
|
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Workflow }"
|
||||||
tourAnchor="tour.workflows">
|
tourAnchor="tour.workflows">
|
||||||
<a class="nav-link" [class.opacity-50]="settingsService.sidebarItemIsHidden(HideableSidebarItemID.Workflows)" [class.pe-5]="settingsService.organizingSidebarItems() && !slimSidebarEnabled && !slimSidebarAnimating()" routerLink="workflows" routerLinkActive="active" (click)="closeMenu()"
|
<a class="nav-link" routerLink="workflows" routerLinkActive="active" (click)="closeMenu()"
|
||||||
ngbPopover="Workflows" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end"
|
ngbPopover="Workflows" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end"
|
||||||
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||||
<i-bs class="me-2" name="boxes"></i-bs><span class="nav-link-label"><ng-container i18n>Workflows</ng-container></span>
|
<i-bs class="me-2" name="boxes"></i-bs><span class="nav-link-label"><ng-container i18n>Workflows</ng-container></span>
|
||||||
</a>
|
</a>
|
||||||
@if (settingsService.organizingSidebarItems()) {
|
|
||||||
<pngx-input-switch class="position-absolute top-50 end-0 translate-middle-y me-1" [class.d-none]="slimSidebarEnabled || slimSidebarAnimating()" [compact]="true" title="Workflows" i18n-title [ngModel]="!settingsService.sidebarItemIsHidden(HideableSidebarItemID.Workflows)" (ngModelChange)="toggleSidebarItem(HideableSidebarItemID.Workflows, $event)"></pngx-input-switch>
|
|
||||||
}
|
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item app-link position-relative" [class.d-none]="settingsService.sidebarItemIsHidden(HideableSidebarItemID.Mail) && !settingsService.organizingSidebarItems()" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.MailAccount }"
|
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.MailAccount }"
|
||||||
tourAnchor="tour.mail">
|
tourAnchor="tour.mail">
|
||||||
<a class="nav-link" [class.opacity-50]="settingsService.sidebarItemIsHidden(HideableSidebarItemID.Mail)" [class.pe-5]="settingsService.organizingSidebarItems() && !slimSidebarEnabled && !slimSidebarAnimating()" routerLink="mail" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Mail"
|
<a class="nav-link" routerLink="mail" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Mail"
|
||||||
i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end" container="body"
|
i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end" container="body"
|
||||||
triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||||
<i-bs class="me-2" name="envelope"></i-bs><span class="nav-link-label"><ng-container i18n>Mail</ng-container></span>
|
<i-bs class="me-2" name="envelope"></i-bs><span class="nav-link-label"><ng-container i18n>Mail</ng-container></span>
|
||||||
</a>
|
</a>
|
||||||
@if (settingsService.organizingSidebarItems()) {
|
|
||||||
<pngx-input-switch class="position-absolute top-50 end-0 translate-middle-y me-1" [class.d-none]="slimSidebarEnabled || slimSidebarAnimating()" [compact]="true" title="Mail" i18n-title [ngModel]="!settingsService.sidebarItemIsHidden(HideableSidebarItemID.Mail)" (ngModelChange)="toggleSidebarItem(HideableSidebarItemID.Mail, $event)"></pngx-input-switch>
|
|
||||||
}
|
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.Document }">
|
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.Document }">
|
||||||
<a class="nav-link" routerLink="trash" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Trash"
|
<a class="nav-link" routerLink="trash" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Trash"
|
||||||
@@ -334,16 +331,13 @@
|
|||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
}
|
}
|
||||||
<li class="nav-item mt-2 position-relative" [class.d-none]="settingsService.sidebarItemIsHidden(HideableSidebarItemID.Documentation) && !settingsService.organizingSidebarItems()" tourAnchor="tour.outro">
|
<li class="nav-item mt-2" tourAnchor="tour.outro">
|
||||||
<a class="text-muted small d-flex align-items-center flex-wrap text-decoration-none nav-anchor" [class.opacity-50]="settingsService.sidebarItemIsHidden(HideableSidebarItemID.Documentation)" [class.pe-5]="settingsService.organizingSidebarItems() && !slimSidebarEnabled && !slimSidebarAnimating()"
|
<a class="text-muted small d-flex align-items-center flex-wrap text-decoration-none nav-anchor"
|
||||||
target="_blank" rel="noopener noreferrer" href="https://docs.paperless-ngx.com" ngbPopover="Documentation"
|
target="_blank" rel="noopener noreferrer" href="https://docs.paperless-ngx.com" ngbPopover="Documentation"
|
||||||
i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end" container="body"
|
i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end" container="body"
|
||||||
triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||||
<i-bs class="d-flex me-2" name="question-circle"></i-bs><span><ng-container i18n>Documentation</ng-container></span>
|
<i-bs class="d-flex me-2" name="question-circle"></i-bs><span><ng-container i18n>Documentation</ng-container></span>
|
||||||
</a>
|
</a>
|
||||||
@if (settingsService.organizingSidebarItems()) {
|
|
||||||
<pngx-input-switch class="position-absolute top-50 end-0 translate-middle-y me-1" [class.d-none]="slimSidebarEnabled || slimSidebarAnimating()" [compact]="true" title="Documentation" i18n-title [ngModel]="!settingsService.sidebarItemIsHidden(HideableSidebarItemID.Documentation)" (ngModelChange)="toggleSidebarItem(HideableSidebarItemID.Documentation, $event)"></pngx-input-switch>
|
|
||||||
}
|
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item" [class.visually-hidden]="slimSidebarEnabled">
|
<li class="nav-item" [class.visually-hidden]="slimSidebarEnabled">
|
||||||
<div class="text-muted small d-flex align-items-center flex-wrap nav-label">
|
<div class="text-muted small d-flex align-items-center flex-wrap nav-label">
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import { provideUiTour } from 'ngx-ui-tour-ng-bootstrap'
|
|||||||
import { of, throwError } from 'rxjs'
|
import { of, throwError } from 'rxjs'
|
||||||
import { routes } from 'src/app/app-routing.module'
|
import { routes } from 'src/app/app-routing.module'
|
||||||
import { SavedView } from 'src/app/data/saved-view'
|
import { SavedView } from 'src/app/data/saved-view'
|
||||||
import { HideableSidebarItemID, SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
||||||
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
||||||
import { PermissionsGuard } from 'src/app/guards/permissions.guard'
|
import { PermissionsGuard } from 'src/app/guards/permissions.guard'
|
||||||
import {
|
import {
|
||||||
@@ -287,82 +287,6 @@ describe('AppFrameComponent', () => {
|
|||||||
jest.useRealTimers()
|
jest.useRealTimers()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should hide configured sidebar items', () => {
|
|
||||||
settingsService.set(SETTINGS_KEYS.SIDEBAR_HIDDEN_ITEMS, [
|
|
||||||
HideableSidebarItemID.Dashboard,
|
|
||||||
HideableSidebarItemID.Workflows,
|
|
||||||
])
|
|
||||||
fixture.detectChanges()
|
|
||||||
|
|
||||||
expect(
|
|
||||||
fixture.nativeElement.querySelector('[routerLink="dashboard"]')
|
|
||||||
.parentElement.classList
|
|
||||||
).toContain('d-none')
|
|
||||||
expect(
|
|
||||||
fixture.nativeElement.querySelector('[routerLink="workflows"]')
|
|
||||||
.parentElement.classList
|
|
||||||
).toContain('d-none')
|
|
||||||
expect(
|
|
||||||
fixture.nativeElement.querySelector('[routerLink="mail"]').parentElement
|
|
||||||
.classList
|
|
||||||
).not.toContain('d-none')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should show hidden items and visibility switches while customizing', () => {
|
|
||||||
settingsService.set(SETTINGS_KEYS.SIDEBAR_HIDDEN_ITEMS, [
|
|
||||||
HideableSidebarItemID.Dashboard,
|
|
||||||
])
|
|
||||||
settingsService.sidebarHiddenItemsEditing.set([
|
|
||||||
HideableSidebarItemID.Dashboard,
|
|
||||||
])
|
|
||||||
fixture.detectChanges()
|
|
||||||
|
|
||||||
expect(
|
|
||||||
fixture.nativeElement.querySelectorAll('pngx-input-switch').length
|
|
||||||
).toBe(5)
|
|
||||||
expect(
|
|
||||||
fixture.nativeElement.querySelector('[routerLink="dashboard"]')
|
|
||||||
.parentElement.classList
|
|
||||||
).not.toContain('d-none')
|
|
||||||
expect(
|
|
||||||
fixture.nativeElement.querySelector('[routerLink="dashboard"]').classList
|
|
||||||
).toContain('opacity-50')
|
|
||||||
|
|
||||||
settingsService.set(SETTINGS_KEYS.SLIM_SIDEBAR, true)
|
|
||||||
fixture.detectChanges()
|
|
||||||
|
|
||||||
expect(
|
|
||||||
Array.from(
|
|
||||||
fixture.nativeElement.querySelectorAll('pngx-input-switch')
|
|
||||||
).every((toggle: HTMLElement) => toggle.classList.contains('d-none'))
|
|
||||||
).toBe(true)
|
|
||||||
expect(
|
|
||||||
fixture.nativeElement.querySelector('[routerLink="dashboard"]').classList
|
|
||||||
).not.toContain('pe-5')
|
|
||||||
|
|
||||||
settingsService.set(SETTINGS_KEYS.SLIM_SIDEBAR, false)
|
|
||||||
component.slimSidebarAnimating.set(true)
|
|
||||||
fixture.detectChanges()
|
|
||||||
|
|
||||||
expect(
|
|
||||||
Array.from(
|
|
||||||
fixture.nativeElement.querySelectorAll('pngx-input-switch')
|
|
||||||
).every((toggle: HTMLElement) => toggle.classList.contains('d-none'))
|
|
||||||
).toBe(true)
|
|
||||||
|
|
||||||
component.slimSidebarAnimating.set(false)
|
|
||||||
fixture.detectChanges()
|
|
||||||
|
|
||||||
expect(
|
|
||||||
Array.from(
|
|
||||||
fixture.nativeElement.querySelectorAll('pngx-input-switch')
|
|
||||||
).every((toggle: HTMLElement) => !toggle.classList.contains('d-none'))
|
|
||||||
).toBe(true)
|
|
||||||
expect(
|
|
||||||
fixture.nativeElement.querySelector('[routerLink="dashboard"]').classList
|
|
||||||
).toContain('pe-5')
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should show error on toggle slim sidebar if store settings fails', () => {
|
it('should show error on toggle slim sidebar if store settings fails', () => {
|
||||||
jest.spyOn(console, 'warn').mockImplementation(() => {})
|
jest.spyOn(console, 'warn').mockImplementation(() => {})
|
||||||
const toastSpy = jest.spyOn(toastService, 'showError')
|
const toastSpy = jest.spyOn(toastService, 'showError')
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import {
|
|||||||
} from '@angular/cdk/drag-drop'
|
} from '@angular/cdk/drag-drop'
|
||||||
import { NgClass } from '@angular/common'
|
import { NgClass } from '@angular/common'
|
||||||
import { Component, HostListener, inject, OnInit, signal } from '@angular/core'
|
import { Component, HostListener, inject, OnInit, signal } from '@angular/core'
|
||||||
import { FormsModule } from '@angular/forms'
|
|
||||||
import { ActivatedRoute, Router, RouterModule } from '@angular/router'
|
import { ActivatedRoute, Router, RouterModule } from '@angular/router'
|
||||||
import {
|
import {
|
||||||
NgbCollapseModule,
|
NgbCollapseModule,
|
||||||
@@ -22,11 +21,7 @@ import { Observable } from 'rxjs'
|
|||||||
import { first } from 'rxjs/operators'
|
import { first } from 'rxjs/operators'
|
||||||
import { Document } from 'src/app/data/document'
|
import { Document } from 'src/app/data/document'
|
||||||
import { SavedView } from 'src/app/data/saved-view'
|
import { SavedView } from 'src/app/data/saved-view'
|
||||||
import {
|
import { CollapsibleSection, SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
||||||
CollapsibleSection,
|
|
||||||
HideableSidebarItemID,
|
|
||||||
SETTINGS_KEYS,
|
|
||||||
} from 'src/app/data/ui-settings'
|
|
||||||
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
||||||
import { ComponentCanDeactivate } from 'src/app/guards/dirty-doc.guard'
|
import { ComponentCanDeactivate } from 'src/app/guards/dirty-doc.guard'
|
||||||
import { DocumentTitlePipe } from 'src/app/pipes/document-title.pipe'
|
import { DocumentTitlePipe } from 'src/app/pipes/document-title.pipe'
|
||||||
@@ -53,7 +48,6 @@ import { ChatComponent } from '../chat/chat/chat.component'
|
|||||||
import { BrandMarkComponent } from '../common/logo/brand-mark/brand-mark.component'
|
import { BrandMarkComponent } from '../common/logo/brand-mark/brand-mark.component'
|
||||||
import { LogoComponent } from '../common/logo/logo.component'
|
import { LogoComponent } from '../common/logo/logo.component'
|
||||||
import { ProfileEditDialogComponent } from '../common/profile-edit-dialog/profile-edit-dialog.component'
|
import { ProfileEditDialogComponent } from '../common/profile-edit-dialog/profile-edit-dialog.component'
|
||||||
import { SwitchComponent } from '../common/input/switch/switch.component'
|
|
||||||
import { DocumentDetailComponent } from '../document-detail/document-detail.component'
|
import { DocumentDetailComponent } from '../document-detail/document-detail.component'
|
||||||
import { ComponentWithPermissions } from '../with-permissions/with-permissions.component'
|
import { ComponentWithPermissions } from '../with-permissions/with-permissions.component'
|
||||||
import { GlobalSearchComponent } from './global-search/global-search.component'
|
import { GlobalSearchComponent } from './global-search/global-search.component'
|
||||||
@@ -82,8 +76,6 @@ const SCROLL_THRESHOLD = 16
|
|||||||
NgxBootstrapIconsModule,
|
NgxBootstrapIconsModule,
|
||||||
DragDropModule,
|
DragDropModule,
|
||||||
TourNgBootstrap,
|
TourNgBootstrap,
|
||||||
FormsModule,
|
|
||||||
SwitchComponent,
|
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class AppFrameComponent
|
export class AppFrameComponent
|
||||||
@@ -106,7 +98,6 @@ export class AppFrameComponent
|
|||||||
readonly isMenuCollapsed = signal(true)
|
readonly isMenuCollapsed = signal(true)
|
||||||
readonly slimSidebarAnimating = signal(false)
|
readonly slimSidebarAnimating = signal(false)
|
||||||
readonly mobileSearchHidden = signal(false)
|
readonly mobileSearchHidden = signal(false)
|
||||||
readonly HideableSidebarItemID = HideableSidebarItemID
|
|
||||||
private readonly versionSetting = this.settingsService.getSignal<string>(
|
private readonly versionSetting = this.settingsService.getSignal<string>(
|
||||||
SETTINGS_KEYS.VERSION
|
SETTINGS_KEYS.VERSION
|
||||||
)
|
)
|
||||||
@@ -204,10 +195,6 @@ export class AppFrameComponent
|
|||||||
}, 200) // slightly longer than css animation for slim sidebar
|
}, 200) // slightly longer than css animation for slim sidebar
|
||||||
}
|
}
|
||||||
|
|
||||||
toggleSidebarItem(item: HideableSidebarItemID, visible: boolean): void {
|
|
||||||
this.settingsService.updateSidebarItemVisibility(item, visible)
|
|
||||||
}
|
|
||||||
|
|
||||||
toggleAttributesSections(event?: Event): void {
|
toggleAttributesSections(event?: Event): void {
|
||||||
event?.preventDefault()
|
event?.preventDefault()
|
||||||
event?.stopPropagation()
|
event?.stopPropagation()
|
||||||
@@ -234,6 +221,19 @@ export class AppFrameComponent
|
|||||||
return this.appTitleSetting() || environment.appTitle
|
return this.appTitleSetting() || environment.appTitle
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get canManageShareLinks(): boolean {
|
||||||
|
return (
|
||||||
|
this.permissionsService.currentUserCan(
|
||||||
|
PermissionAction.View,
|
||||||
|
PermissionType.ShareLink
|
||||||
|
) ||
|
||||||
|
this.permissionsService.currentUserCan(
|
||||||
|
PermissionAction.View,
|
||||||
|
PermissionType.ShareLinkBundle
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
get customAppTitle(): string {
|
get customAppTitle(): string {
|
||||||
return this.appTitleSetting()
|
return this.appTitleSetting()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'
|
|||||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||||
import { NgbActiveModal, NgbModule } from '@ng-bootstrap/ng-bootstrap'
|
import { NgbActiveModal, NgbModule } from '@ng-bootstrap/ng-bootstrap'
|
||||||
import { NgSelectModule } from '@ng-select/ng-select'
|
import { NgSelectModule } from '@ng-select/ng-select'
|
||||||
import { of, throwError } from 'rxjs'
|
import { of } from 'rxjs'
|
||||||
import {
|
import {
|
||||||
MailAction,
|
MailAction,
|
||||||
MailMetadataCorrespondentOption,
|
MailMetadataCorrespondentOption,
|
||||||
@@ -15,7 +15,6 @@ import { CorrespondentService } from 'src/app/services/rest/correspondent.servic
|
|||||||
import { DocumentTypeService } from 'src/app/services/rest/document-type.service'
|
import { DocumentTypeService } from 'src/app/services/rest/document-type.service'
|
||||||
import { MailAccountService } from 'src/app/services/rest/mail-account.service'
|
import { MailAccountService } from 'src/app/services/rest/mail-account.service'
|
||||||
import { SettingsService } from 'src/app/services/settings.service'
|
import { SettingsService } from 'src/app/services/settings.service'
|
||||||
import { ToastService } from 'src/app/services/toast.service'
|
|
||||||
import { CheckComponent } from '../../input/check/check.component'
|
import { CheckComponent } from '../../input/check/check.component'
|
||||||
import { NumberComponent } from '../../input/number/number.component'
|
import { NumberComponent } from '../../input/number/number.component'
|
||||||
import { PermissionsFormComponent } from '../../input/permissions/permissions-form/permissions-form.component'
|
import { PermissionsFormComponent } from '../../input/permissions/permissions-form/permissions-form.component'
|
||||||
@@ -82,41 +81,6 @@ describe('MailRuleEditDialogComponent', () => {
|
|||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should use empty related object lists when retrieval fails', () => {
|
|
||||||
const failed = () => throwError(() => new Error('Forbidden'))
|
|
||||||
const toastSpy = jest.spyOn(TestBed.inject(ToastService), 'showError')
|
|
||||||
jest
|
|
||||||
.spyOn(TestBed.inject(MailAccountService), 'listAll')
|
|
||||||
.mockReturnValue(failed())
|
|
||||||
jest
|
|
||||||
.spyOn(TestBed.inject(CorrespondentService), 'listAll')
|
|
||||||
.mockReturnValue(failed())
|
|
||||||
jest
|
|
||||||
.spyOn(TestBed.inject(DocumentTypeService), 'listAll')
|
|
||||||
.mockReturnValue(failed())
|
|
||||||
|
|
||||||
const failedFixture = TestBed.createComponent(MailRuleEditDialogComponent)
|
|
||||||
const failedComponent = failedFixture.componentInstance
|
|
||||||
|
|
||||||
expect(failedComponent.accounts()).toEqual([])
|
|
||||||
expect(failedComponent.correspondents()).toEqual([])
|
|
||||||
expect(failedComponent.documentTypes()).toEqual([])
|
|
||||||
expect(() => failedFixture.detectChanges()).not.toThrow()
|
|
||||||
expect(toastSpy).toHaveBeenCalledTimes(3)
|
|
||||||
expect(toastSpy).toHaveBeenCalledWith(
|
|
||||||
'Error retrieving mail accounts',
|
|
||||||
expect.any(Error)
|
|
||||||
)
|
|
||||||
expect(toastSpy).toHaveBeenCalledWith(
|
|
||||||
'Error retrieving correspondents',
|
|
||||||
expect.any(Error)
|
|
||||||
)
|
|
||||||
expect(toastSpy).toHaveBeenCalledWith(
|
|
||||||
'Error retrieving document types',
|
|
||||||
expect.any(Error)
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should support create and edit modes', () => {
|
it('should support create and edit modes', () => {
|
||||||
component.dialogMode.set(EditDialogMode.CREATE)
|
component.dialogMode.set(EditDialogMode.CREATE)
|
||||||
const createTitleSpy = jest.spyOn(component, 'getCreateTitle')
|
const createTitleSpy = jest.spyOn(component, 'getCreateTitle')
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
FormsModule,
|
FormsModule,
|
||||||
ReactiveFormsModule,
|
ReactiveFormsModule,
|
||||||
} from '@angular/forms'
|
} from '@angular/forms'
|
||||||
import { catchError, map, of } from 'rxjs'
|
import { map } from 'rxjs'
|
||||||
import { EditDialogComponent } from 'src/app/components/common/edit-dialog/edit-dialog.component'
|
import { EditDialogComponent } from 'src/app/components/common/edit-dialog/edit-dialog.component'
|
||||||
import { Correspondent } from 'src/app/data/correspondent'
|
import { Correspondent } from 'src/app/data/correspondent'
|
||||||
import { DocumentType } from 'src/app/data/document-type'
|
import { DocumentType } from 'src/app/data/document-type'
|
||||||
@@ -26,7 +26,6 @@ import { MailAccountService } from 'src/app/services/rest/mail-account.service'
|
|||||||
import { MailRuleService } from 'src/app/services/rest/mail-rule.service'
|
import { MailRuleService } from 'src/app/services/rest/mail-rule.service'
|
||||||
import { UserService } from 'src/app/services/rest/user.service'
|
import { UserService } from 'src/app/services/rest/user.service'
|
||||||
import { SettingsService } from 'src/app/services/settings.service'
|
import { SettingsService } from 'src/app/services/settings.service'
|
||||||
import { ToastService } from 'src/app/services/toast.service'
|
|
||||||
import { CheckComponent } from '../../input/check/check.component'
|
import { CheckComponent } from '../../input/check/check.component'
|
||||||
import { NumberComponent } from '../../input/number/number.component'
|
import { NumberComponent } from '../../input/number/number.component'
|
||||||
import { SelectComponent } from '../../input/select/select.component'
|
import { SelectComponent } from '../../input/select/select.component'
|
||||||
@@ -159,45 +158,17 @@ export class MailRuleEditDialogComponent extends EditDialogComponent<MailRule> {
|
|||||||
private readonly accountService = inject(MailAccountService)
|
private readonly accountService = inject(MailAccountService)
|
||||||
private readonly correspondentService = inject(CorrespondentService)
|
private readonly correspondentService = inject(CorrespondentService)
|
||||||
private readonly documentTypeService = inject(DocumentTypeService)
|
private readonly documentTypeService = inject(DocumentTypeService)
|
||||||
private readonly toastService = inject(ToastService)
|
|
||||||
|
|
||||||
readonly accounts = toSignal(
|
readonly accounts = toSignal(
|
||||||
this.accountService.listAll().pipe(
|
this.accountService.listAll().pipe(map((result) => result.results)),
|
||||||
map((result) => result.results),
|
|
||||||
catchError((error) => {
|
|
||||||
this.toastService.showError(
|
|
||||||
$localize`Error retrieving mail accounts`,
|
|
||||||
error
|
|
||||||
)
|
|
||||||
return of([])
|
|
||||||
})
|
|
||||||
),
|
|
||||||
{ initialValue: undefined as MailAccount[] }
|
{ initialValue: undefined as MailAccount[] }
|
||||||
)
|
)
|
||||||
readonly correspondents = toSignal(
|
readonly correspondents = toSignal(
|
||||||
this.correspondentService.listAll().pipe(
|
this.correspondentService.listAll().pipe(map((result) => result.results)),
|
||||||
map((result) => result.results),
|
|
||||||
catchError((error) => {
|
|
||||||
this.toastService.showError(
|
|
||||||
$localize`Error retrieving correspondents`,
|
|
||||||
error
|
|
||||||
)
|
|
||||||
return of([])
|
|
||||||
})
|
|
||||||
),
|
|
||||||
{ initialValue: undefined as Correspondent[] }
|
{ initialValue: undefined as Correspondent[] }
|
||||||
)
|
)
|
||||||
readonly documentTypes = toSignal(
|
readonly documentTypes = toSignal(
|
||||||
this.documentTypeService.listAll().pipe(
|
this.documentTypeService.listAll().pipe(map((result) => result.results)),
|
||||||
map((result) => result.results),
|
|
||||||
catchError((error) => {
|
|
||||||
this.toastService.showError(
|
|
||||||
$localize`Error retrieving document types`,
|
|
||||||
error
|
|
||||||
)
|
|
||||||
return of([])
|
|
||||||
})
|
|
||||||
),
|
|
||||||
{ initialValue: undefined as DocumentType[] }
|
{ initialValue: undefined as DocumentType[] }
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -81,23 +81,6 @@ describe('UserEditDialogComponent', () => {
|
|||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should use an empty group list when retrieval fails', () => {
|
|
||||||
const toastSpy = jest.spyOn(toastService, 'showError')
|
|
||||||
jest
|
|
||||||
.spyOn(TestBed.inject(GroupService), 'listAll')
|
|
||||||
.mockReturnValue(throwError(() => new Error('Forbidden')))
|
|
||||||
|
|
||||||
const failedFixture = TestBed.createComponent(UserEditDialogComponent)
|
|
||||||
const failedComponent = failedFixture.componentInstance
|
|
||||||
|
|
||||||
expect(failedComponent.groups()).toEqual([])
|
|
||||||
expect(() => failedFixture.detectChanges()).not.toThrow()
|
|
||||||
expect(toastSpy).toHaveBeenCalledWith(
|
|
||||||
'Error retrieving groups',
|
|
||||||
expect.any(Error)
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should support create and edit modes', () => {
|
it('should support create and edit modes', () => {
|
||||||
component.dialogMode.set(EditDialogMode.CREATE)
|
component.dialogMode.set(EditDialogMode.CREATE)
|
||||||
const createTitleSpy = jest.spyOn(component, 'getCreateTitle')
|
const createTitleSpy = jest.spyOn(component, 'getCreateTitle')
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
FormsModule,
|
FormsModule,
|
||||||
ReactiveFormsModule,
|
ReactiveFormsModule,
|
||||||
} from '@angular/forms'
|
} from '@angular/forms'
|
||||||
import { catchError, first, map, of } from 'rxjs'
|
import { first, map } from 'rxjs'
|
||||||
import { EditDialogComponent } from 'src/app/components/common/edit-dialog/edit-dialog.component'
|
import { EditDialogComponent } from 'src/app/components/common/edit-dialog/edit-dialog.component'
|
||||||
import { Group } from 'src/app/data/group'
|
import { Group } from 'src/app/data/group'
|
||||||
import { User } from 'src/app/data/user'
|
import { User } from 'src/app/data/user'
|
||||||
@@ -42,13 +42,7 @@ export class UserEditDialogComponent
|
|||||||
private readonly groupsService = inject(GroupService)
|
private readonly groupsService = inject(GroupService)
|
||||||
|
|
||||||
readonly groups = toSignal(
|
readonly groups = toSignal(
|
||||||
this.groupsService.listAll().pipe(
|
this.groupsService.listAll().pipe(map((result) => result.results)),
|
||||||
map((result) => result.results),
|
|
||||||
catchError((error) => {
|
|
||||||
this.toastService.showError($localize`Error retrieving groups`, error)
|
|
||||||
return of([])
|
|
||||||
})
|
|
||||||
),
|
|
||||||
{ initialValue: undefined as Group[] }
|
{ initialValue: undefined as Group[] }
|
||||||
)
|
)
|
||||||
readonly passwordIsSet = signal(false)
|
readonly passwordIsSet = signal(false)
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import {
|
|||||||
} from '@angular/forms'
|
} from '@angular/forms'
|
||||||
import { NgbActiveModal, NgbModule } from '@ng-bootstrap/ng-bootstrap'
|
import { NgbActiveModal, NgbModule } from '@ng-bootstrap/ng-bootstrap'
|
||||||
import { NgSelectModule } from '@ng-select/ng-select'
|
import { NgSelectModule } from '@ng-select/ng-select'
|
||||||
import { of, throwError } from 'rxjs'
|
import { of } from 'rxjs'
|
||||||
import { CustomFieldQueriesModel } from 'src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component'
|
import { CustomFieldQueriesModel } from 'src/app/components/common/custom-fields-query-dropdown/custom-fields-query-dropdown.component'
|
||||||
import { CustomFieldDataType } from 'src/app/data/custom-field'
|
import { CustomFieldDataType } from 'src/app/data/custom-field'
|
||||||
import { CustomFieldQueryLogicalOperator } from 'src/app/data/custom-field-query'
|
import { CustomFieldQueryLogicalOperator } from 'src/app/data/custom-field-query'
|
||||||
@@ -39,7 +39,6 @@ import { DocumentTypeService } from 'src/app/services/rest/document-type.service
|
|||||||
import { MailRuleService } from 'src/app/services/rest/mail-rule.service'
|
import { MailRuleService } from 'src/app/services/rest/mail-rule.service'
|
||||||
import { StoragePathService } from 'src/app/services/rest/storage-path.service'
|
import { StoragePathService } from 'src/app/services/rest/storage-path.service'
|
||||||
import { SettingsService } from 'src/app/services/settings.service'
|
import { SettingsService } from 'src/app/services/settings.service'
|
||||||
import { ToastService } from 'src/app/services/toast.service'
|
|
||||||
import { CustomFieldQueryExpression } from 'src/app/utils/custom-field-query-element'
|
import { CustomFieldQueryExpression } from 'src/app/utils/custom-field-query-element'
|
||||||
import { ConfirmButtonComponent } from '../../confirm-button/confirm-button.component'
|
import { ConfirmButtonComponent } from '../../confirm-button/confirm-button.component'
|
||||||
import { NumberComponent } from '../../input/number/number.component'
|
import { NumberComponent } from '../../input/number/number.component'
|
||||||
@@ -207,44 +206,6 @@ describe('WorkflowEditDialogComponent', () => {
|
|||||||
settingsService.set(SETTINGS_KEYS.AI_ENABLED, ai)
|
settingsService.set(SETTINGS_KEYS.AI_ENABLED, ai)
|
||||||
}
|
}
|
||||||
|
|
||||||
it('should use empty related object lists when access is forbidden', () => {
|
|
||||||
const forbidden = () => throwError(() => new Error('Forbidden'))
|
|
||||||
const toastSpy = jest.spyOn(TestBed.inject(ToastService), 'showError')
|
|
||||||
jest
|
|
||||||
.spyOn(TestBed.inject(CorrespondentService), 'listAll')
|
|
||||||
.mockReturnValue(forbidden())
|
|
||||||
jest
|
|
||||||
.spyOn(TestBed.inject(DocumentTypeService), 'listAll')
|
|
||||||
.mockReturnValue(forbidden())
|
|
||||||
jest
|
|
||||||
.spyOn(TestBed.inject(StoragePathService), 'listAll')
|
|
||||||
.mockReturnValue(forbidden())
|
|
||||||
jest
|
|
||||||
.spyOn(TestBed.inject(MailRuleService), 'listAll')
|
|
||||||
.mockReturnValue(forbidden())
|
|
||||||
jest
|
|
||||||
.spyOn(TestBed.inject(CustomFieldsService), 'listAll')
|
|
||||||
.mockReturnValue(forbidden())
|
|
||||||
|
|
||||||
const forbiddenFixture = TestBed.createComponent(
|
|
||||||
WorkflowEditDialogComponent
|
|
||||||
)
|
|
||||||
const forbiddenComponent = forbiddenFixture.componentInstance
|
|
||||||
|
|
||||||
expect(forbiddenComponent.correspondents()).toEqual([])
|
|
||||||
expect(forbiddenComponent.documentTypes()).toEqual([])
|
|
||||||
expect(forbiddenComponent.storagePaths()).toEqual([])
|
|
||||||
expect(forbiddenComponent.mailRules()).toEqual([])
|
|
||||||
expect(forbiddenComponent.customFields()).toEqual([])
|
|
||||||
expect(forbiddenComponent.dateCustomFields()).toEqual([])
|
|
||||||
expect(() => forbiddenFixture.detectChanges()).not.toThrow()
|
|
||||||
expect(toastSpy).toHaveBeenCalledTimes(1)
|
|
||||||
expect(toastSpy).toHaveBeenCalledWith(
|
|
||||||
'Some workflow options could not be loaded.',
|
|
||||||
expect.any(Error)
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should support create and edit modes, support adding triggers and actions on new workflow', () => {
|
it('should support create and edit modes, support adding triggers and actions on new workflow', () => {
|
||||||
component.dialogMode.set(EditDialogMode.CREATE)
|
component.dialogMode.set(EditDialogMode.CREATE)
|
||||||
const createTitleSpy = jest.spyOn(component, 'getCreateTitle')
|
const createTitleSpy = jest.spyOn(component, 'getCreateTitle')
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import {
|
|||||||
} from '@angular/forms'
|
} from '@angular/forms'
|
||||||
import { NgbAccordionModule } from '@ng-bootstrap/ng-bootstrap'
|
import { NgbAccordionModule } from '@ng-bootstrap/ng-bootstrap'
|
||||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||||
import { Subscription, catchError, map, of, takeUntil } from 'rxjs'
|
import { Subscription, map, takeUntil } from 'rxjs'
|
||||||
import { Correspondent } from 'src/app/data/correspondent'
|
import { Correspondent } from 'src/app/data/correspondent'
|
||||||
import { CustomField, CustomFieldDataType } from 'src/app/data/custom-field'
|
import { CustomField, CustomFieldDataType } from 'src/app/data/custom-field'
|
||||||
import { DocumentType } from 'src/app/data/document-type'
|
import { DocumentType } from 'src/app/data/document-type'
|
||||||
@@ -48,7 +48,6 @@ import { StoragePathService } from 'src/app/services/rest/storage-path.service'
|
|||||||
import { UserService } from 'src/app/services/rest/user.service'
|
import { UserService } from 'src/app/services/rest/user.service'
|
||||||
import { WorkflowService } from 'src/app/services/rest/workflow.service'
|
import { WorkflowService } from 'src/app/services/rest/workflow.service'
|
||||||
import { SettingsService } from 'src/app/services/settings.service'
|
import { SettingsService } from 'src/app/services/settings.service'
|
||||||
import { ToastService } from 'src/app/services/toast.service'
|
|
||||||
import { CustomFieldQueryExpression } from 'src/app/utils/custom-field-query-element'
|
import { CustomFieldQueryExpression } from 'src/app/utils/custom-field-query-element'
|
||||||
import { ConfirmButtonComponent } from '../../confirm-button/confirm-button.component'
|
import { ConfirmButtonComponent } from '../../confirm-button/confirm-button.component'
|
||||||
import {
|
import {
|
||||||
@@ -513,43 +512,26 @@ export class WorkflowEditDialogComponent
|
|||||||
private readonly storagePathService = inject(StoragePathService)
|
private readonly storagePathService = inject(StoragePathService)
|
||||||
private readonly mailRuleService = inject(MailRuleService)
|
private readonly mailRuleService = inject(MailRuleService)
|
||||||
private readonly customFieldsService = inject(CustomFieldsService)
|
private readonly customFieldsService = inject(CustomFieldsService)
|
||||||
private readonly toastService = inject(ToastService)
|
|
||||||
private relatedObjectLoadErrorShown = false
|
|
||||||
|
|
||||||
readonly templates = signal<Workflow[]>(undefined)
|
readonly templates = signal<Workflow[]>(undefined)
|
||||||
readonly correspondents = toSignal(
|
readonly correspondents = toSignal(
|
||||||
this.correspondentService.listAll().pipe(
|
this.correspondentService.listAll().pipe(map((result) => result.results)),
|
||||||
map((result) => result.results),
|
|
||||||
catchError((error) => this.handleRelatedObjectLoadError(error))
|
|
||||||
),
|
|
||||||
{ initialValue: undefined as Correspondent[] }
|
{ initialValue: undefined as Correspondent[] }
|
||||||
)
|
)
|
||||||
readonly documentTypes = toSignal(
|
readonly documentTypes = toSignal(
|
||||||
this.documentTypeService.listAll().pipe(
|
this.documentTypeService.listAll().pipe(map((result) => result.results)),
|
||||||
map((result) => result.results),
|
|
||||||
catchError((error) => this.handleRelatedObjectLoadError(error))
|
|
||||||
),
|
|
||||||
{ initialValue: undefined as DocumentType[] }
|
{ initialValue: undefined as DocumentType[] }
|
||||||
)
|
)
|
||||||
readonly storagePaths = toSignal(
|
readonly storagePaths = toSignal(
|
||||||
this.storagePathService.listAll().pipe(
|
this.storagePathService.listAll().pipe(map((result) => result.results)),
|
||||||
map((result) => result.results),
|
|
||||||
catchError((error) => this.handleRelatedObjectLoadError(error))
|
|
||||||
),
|
|
||||||
{ initialValue: undefined as StoragePath[] }
|
{ initialValue: undefined as StoragePath[] }
|
||||||
)
|
)
|
||||||
readonly mailRules = toSignal(
|
readonly mailRules = toSignal(
|
||||||
this.mailRuleService.listAll().pipe(
|
this.mailRuleService.listAll().pipe(map((result) => result.results)),
|
||||||
map((result) => result.results),
|
|
||||||
catchError((error) => this.handleRelatedObjectLoadError(error))
|
|
||||||
),
|
|
||||||
{ initialValue: undefined as MailRule[] }
|
{ initialValue: undefined as MailRule[] }
|
||||||
)
|
)
|
||||||
readonly customFields = toSignal(
|
readonly customFields = toSignal(
|
||||||
this.customFieldsService.listAll().pipe(
|
this.customFieldsService.listAll().pipe(map((result) => result.results)),
|
||||||
map((result) => result.results),
|
|
||||||
catchError((error) => this.handleRelatedObjectLoadError(error))
|
|
||||||
),
|
|
||||||
{ initialValue: undefined as CustomField[] }
|
{ initialValue: undefined as CustomField[] }
|
||||||
)
|
)
|
||||||
readonly dateCustomFields = computed(() =>
|
readonly dateCustomFields = computed(() =>
|
||||||
@@ -563,17 +545,6 @@ export class WorkflowEditDialogComponent
|
|||||||
SETTINGS_KEYS.AI_ENABLED
|
SETTINGS_KEYS.AI_ENABLED
|
||||||
)
|
)
|
||||||
|
|
||||||
private handleRelatedObjectLoadError(error) {
|
|
||||||
if (!this.relatedObjectLoadErrorShown) {
|
|
||||||
this.relatedObjectLoadErrorShown = true
|
|
||||||
this.toastService.showError(
|
|
||||||
$localize`Some workflow options could not be loaded.`,
|
|
||||||
error
|
|
||||||
)
|
|
||||||
}
|
|
||||||
return of([])
|
|
||||||
}
|
|
||||||
|
|
||||||
expandedItem: number = null
|
expandedItem: number = null
|
||||||
|
|
||||||
private readonly triggerFilterOptionsMap = new WeakMap<
|
private readonly triggerFilterOptionsMap = new WeakMap<
|
||||||
|
|||||||
@@ -7,9 +7,8 @@ import {
|
|||||||
ReactiveFormsModule,
|
ReactiveFormsModule,
|
||||||
} from '@angular/forms'
|
} from '@angular/forms'
|
||||||
import { NgSelectModule } from '@ng-select/ng-select'
|
import { NgSelectModule } from '@ng-select/ng-select'
|
||||||
import { of, throwError } from 'rxjs'
|
import { of } from 'rxjs'
|
||||||
import { GroupService } from 'src/app/services/rest/group.service'
|
import { GroupService } from 'src/app/services/rest/group.service'
|
||||||
import { ToastService } from 'src/app/services/toast.service'
|
|
||||||
import { PermissionsGroupComponent } from './permissions-group.component'
|
import { PermissionsGroupComponent } from './permissions-group.component'
|
||||||
|
|
||||||
describe('PermissionsGroupComponent', () => {
|
describe('PermissionsGroupComponent', () => {
|
||||||
@@ -61,19 +60,4 @@ describe('PermissionsGroupComponent', () => {
|
|||||||
expect(component.value).toEqual({ id: 2, name: 'Group 2' })
|
expect(component.value).toEqual({ id: 2, name: 'Group 2' })
|
||||||
expect(groupServiceSpy).toHaveBeenCalled()
|
expect(groupServiceSpy).toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should use an empty group list when retrieval fails', () => {
|
|
||||||
const toastSpy = jest.spyOn(TestBed.inject(ToastService), 'showError')
|
|
||||||
groupServiceSpy.mockReturnValue(throwError(() => new Error('Forbidden')))
|
|
||||||
|
|
||||||
const failedFixture = TestBed.createComponent(PermissionsGroupComponent)
|
|
||||||
const failedComponent = failedFixture.componentInstance
|
|
||||||
|
|
||||||
expect(failedComponent.groups()).toEqual([])
|
|
||||||
expect(() => failedFixture.detectChanges()).not.toThrow()
|
|
||||||
expect(toastSpy).toHaveBeenCalledWith(
|
|
||||||
'Error retrieving groups',
|
|
||||||
expect.any(Error)
|
|
||||||
)
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -6,10 +6,9 @@ import {
|
|||||||
ReactiveFormsModule,
|
ReactiveFormsModule,
|
||||||
} from '@angular/forms'
|
} from '@angular/forms'
|
||||||
import { NgSelectComponent } from '@ng-select/ng-select'
|
import { NgSelectComponent } from '@ng-select/ng-select'
|
||||||
import { catchError, map, of } from 'rxjs'
|
import { map } from 'rxjs/operators'
|
||||||
import { Group } from 'src/app/data/group'
|
import { Group } from 'src/app/data/group'
|
||||||
import { GroupService } from 'src/app/services/rest/group.service'
|
import { GroupService } from 'src/app/services/rest/group.service'
|
||||||
import { ToastService } from 'src/app/services/toast.service'
|
|
||||||
import { AbstractInputComponent } from '../../abstract-input'
|
import { AbstractInputComponent } from '../../abstract-input'
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
@@ -27,15 +26,8 @@ import { AbstractInputComponent } from '../../abstract-input'
|
|||||||
})
|
})
|
||||||
export class PermissionsGroupComponent extends AbstractInputComponent<Group> {
|
export class PermissionsGroupComponent extends AbstractInputComponent<Group> {
|
||||||
private readonly groupService = inject(GroupService)
|
private readonly groupService = inject(GroupService)
|
||||||
private readonly toastService = inject(ToastService)
|
|
||||||
readonly groups = toSignal(
|
readonly groups = toSignal(
|
||||||
this.groupService.listAll().pipe(
|
this.groupService.listAll().pipe(map((result) => result.results)),
|
||||||
map((result) => result.results),
|
|
||||||
catchError((error) => {
|
|
||||||
this.toastService.showError($localize`Error retrieving groups`, error)
|
|
||||||
return of([])
|
|
||||||
})
|
|
||||||
),
|
|
||||||
{ initialValue: undefined as Group[] }
|
{ initialValue: undefined as Group[] }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,9 +7,8 @@ import {
|
|||||||
ReactiveFormsModule,
|
ReactiveFormsModule,
|
||||||
} from '@angular/forms'
|
} from '@angular/forms'
|
||||||
import { NgSelectModule } from '@ng-select/ng-select'
|
import { NgSelectModule } from '@ng-select/ng-select'
|
||||||
import { of, throwError } from 'rxjs'
|
import { of } from 'rxjs'
|
||||||
import { UserService } from 'src/app/services/rest/user.service'
|
import { UserService } from 'src/app/services/rest/user.service'
|
||||||
import { ToastService } from 'src/app/services/toast.service'
|
|
||||||
import { PermissionsUserComponent } from './permissions-user.component'
|
import { PermissionsUserComponent } from './permissions-user.component'
|
||||||
|
|
||||||
describe('PermissionsUserComponent', () => {
|
describe('PermissionsUserComponent', () => {
|
||||||
@@ -61,19 +60,4 @@ describe('PermissionsUserComponent', () => {
|
|||||||
expect(component.value).toEqual({ id: 2, name: 'User 2' })
|
expect(component.value).toEqual({ id: 2, name: 'User 2' })
|
||||||
expect(userServiceSpy).toHaveBeenCalled()
|
expect(userServiceSpy).toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should use an empty user list when retrieval fails', () => {
|
|
||||||
const toastSpy = jest.spyOn(TestBed.inject(ToastService), 'showError')
|
|
||||||
userServiceSpy.mockReturnValue(throwError(() => new Error('Forbidden')))
|
|
||||||
|
|
||||||
const failedFixture = TestBed.createComponent(PermissionsUserComponent)
|
|
||||||
const failedComponent = failedFixture.componentInstance
|
|
||||||
|
|
||||||
expect(failedComponent.users()).toEqual([])
|
|
||||||
expect(() => failedFixture.detectChanges()).not.toThrow()
|
|
||||||
expect(toastSpy).toHaveBeenCalledWith(
|
|
||||||
'Error retrieving users',
|
|
||||||
expect.any(Error)
|
|
||||||
)
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -6,10 +6,9 @@ import {
|
|||||||
ReactiveFormsModule,
|
ReactiveFormsModule,
|
||||||
} from '@angular/forms'
|
} from '@angular/forms'
|
||||||
import { NgSelectComponent } from '@ng-select/ng-select'
|
import { NgSelectComponent } from '@ng-select/ng-select'
|
||||||
import { catchError, map, of } from 'rxjs'
|
import { map } from 'rxjs/operators'
|
||||||
import { User } from 'src/app/data/user'
|
import { User } from 'src/app/data/user'
|
||||||
import { UserService } from 'src/app/services/rest/user.service'
|
import { UserService } from 'src/app/services/rest/user.service'
|
||||||
import { ToastService } from 'src/app/services/toast.service'
|
|
||||||
import { AbstractInputComponent } from '../../abstract-input'
|
import { AbstractInputComponent } from '../../abstract-input'
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
@@ -27,15 +26,8 @@ import { AbstractInputComponent } from '../../abstract-input'
|
|||||||
})
|
})
|
||||||
export class PermissionsUserComponent extends AbstractInputComponent<User[]> {
|
export class PermissionsUserComponent extends AbstractInputComponent<User[]> {
|
||||||
private readonly userService = inject(UserService)
|
private readonly userService = inject(UserService)
|
||||||
private readonly toastService = inject(ToastService)
|
|
||||||
readonly users = toSignal(
|
readonly users = toSignal(
|
||||||
this.userService.listAll().pipe(
|
this.userService.listAll().pipe(map((result) => result.results)),
|
||||||
map((result) => result.results),
|
|
||||||
catchError((error) => {
|
|
||||||
this.toastService.showError($localize`Error retrieving users`, error)
|
|
||||||
return of([])
|
|
||||||
})
|
|
||||||
),
|
|
||||||
{ initialValue: undefined as User[] }
|
{ initialValue: undefined as User[] }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
<div [class.mb-3]="!compact">
|
<div class="mb-3">
|
||||||
<div [class.row]="!compact">
|
<div class="row">
|
||||||
@if (!horizontal && !compact) {
|
@if (!horizontal) {
|
||||||
<div class="d-flex align-items-center position-relative hidden-button-container col-md-3">
|
<div class="d-flex align-items-center position-relative hidden-button-container col-md-3">
|
||||||
<label class="form-label" [for]="inputId" [ngbTooltip]="showUnsetNote && isUnset ? tipContent: null" placement="end">
|
<label class="form-label" [for]="inputId" [ngbTooltip]="showUnsetNote && isUnset ? tipContent: null" placement="end">
|
||||||
{{title}}
|
{{title}}
|
||||||
@@ -17,8 +17,8 @@
|
|||||||
}
|
}
|
||||||
<div [ngClass]="{'align-items-center': horizontal, 'd-flex': horizontal}">
|
<div [ngClass]="{'align-items-center': horizontal, 'd-flex': horizontal}">
|
||||||
<div class="form-check form-switch">
|
<div class="form-check form-switch">
|
||||||
<input #inputField type="checkbox" class="form-check-input" [id]="inputId" [(ngModel)]="value" [ngModelOptions]="{standalone: true}" (change)="onChange(value)" (blur)="onTouched()" [disabled]="disabled" [attr.aria-label]="compact ? title : null">
|
<input #inputField type="checkbox" class="form-check-input" [id]="inputId" [(ngModel)]="value" [ngModelOptions]="{standalone: true}" (change)="onChange(value)" (blur)="onTouched()" [disabled]="disabled">
|
||||||
@if (horizontal && !compact) {
|
@if (horizontal) {
|
||||||
<label class="form-check-label" [class.text-muted]="showUnsetNote && isUnset" [for]="inputId" [ngbTooltip]="showUnsetNote && isUnset ? tipContent: null" placement="end">
|
<label class="form-check-label" [class.text-muted]="showUnsetNote && isUnset" [for]="inputId" [ngbTooltip]="showUnsetNote && isUnset ? tipContent: null" placement="end">
|
||||||
{{title}}
|
{{title}}
|
||||||
@if (showUnsetNote && isUnset) {
|
@if (showUnsetNote && isUnset) {
|
||||||
|
|||||||
@@ -48,14 +48,4 @@ describe('SwitchComponent', () => {
|
|||||||
component.value = undefined
|
component.value = undefined
|
||||||
expect(component.isUnset).toBeTruthy()
|
expect(component.isUnset).toBeTruthy()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should support a compact layout', () => {
|
|
||||||
component.compact = true
|
|
||||||
component.title = 'Test switch'
|
|
||||||
fixture.detectChanges()
|
|
||||||
|
|
||||||
expect(fixture.nativeElement.querySelector('.mb-3')).toBeNull()
|
|
||||||
expect(fixture.nativeElement.querySelector('.row')).toBeNull()
|
|
||||||
expect(input.getAttribute('aria-label')).toEqual('Test switch')
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -25,9 +25,6 @@ export class SwitchComponent extends AbstractInputComponent<boolean> {
|
|||||||
@Input()
|
@Input()
|
||||||
showUnsetNote: boolean = false
|
showUnsetNote: boolean = false
|
||||||
|
|
||||||
@Input()
|
|
||||||
compact: boolean = false
|
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super()
|
super()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,9 +4,8 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'
|
|||||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||||
import { NgbActiveModal, NgbModule } from '@ng-bootstrap/ng-bootstrap'
|
import { NgbActiveModal, NgbModule } from '@ng-bootstrap/ng-bootstrap'
|
||||||
import { NgSelectModule } from '@ng-select/ng-select'
|
import { NgSelectModule } from '@ng-select/ng-select'
|
||||||
import { of, throwError } from 'rxjs'
|
import { of } from 'rxjs'
|
||||||
import { UserService } from 'src/app/services/rest/user.service'
|
import { UserService } from 'src/app/services/rest/user.service'
|
||||||
import { ToastService } from 'src/app/services/toast.service'
|
|
||||||
import { PermissionsFormComponent } from '../input/permissions/permissions-form/permissions-form.component'
|
import { PermissionsFormComponent } from '../input/permissions/permissions-form/permissions-form.component'
|
||||||
import { PermissionsGroupComponent } from '../input/permissions/permissions-group/permissions-group.component'
|
import { PermissionsGroupComponent } from '../input/permissions/permissions-group/permissions-group.component'
|
||||||
import { PermissionsUserComponent } from '../input/permissions/permissions-user/permissions-user.component'
|
import { PermissionsUserComponent } from '../input/permissions/permissions-user/permissions-user.component'
|
||||||
@@ -78,23 +77,6 @@ describe('PermissionsDialogComponent', () => {
|
|||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should use an empty user list when retrieval fails', () => {
|
|
||||||
const toastSpy = jest.spyOn(TestBed.inject(ToastService), 'showError')
|
|
||||||
jest
|
|
||||||
.spyOn(TestBed.inject(UserService), 'listAll')
|
|
||||||
.mockReturnValue(throwError(() => new Error('Forbidden')))
|
|
||||||
|
|
||||||
const failedFixture = TestBed.createComponent(PermissionsDialogComponent)
|
|
||||||
const failedComponent = failedFixture.componentInstance
|
|
||||||
|
|
||||||
expect(failedComponent.users()).toEqual([])
|
|
||||||
expect(() => failedFixture.detectChanges()).not.toThrow()
|
|
||||||
expect(toastSpy).toHaveBeenCalledWith(
|
|
||||||
'Error retrieving users',
|
|
||||||
expect.any(Error)
|
|
||||||
)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should return permissions', () => {
|
it('should return permissions', () => {
|
||||||
expect(component.permissions).toEqual({
|
expect(component.permissions).toEqual({
|
||||||
owner: null,
|
owner: null,
|
||||||
|
|||||||
@@ -14,11 +14,10 @@ import {
|
|||||||
ReactiveFormsModule,
|
ReactiveFormsModule,
|
||||||
} from '@angular/forms'
|
} from '@angular/forms'
|
||||||
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
|
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
|
||||||
import { catchError, map, of } from 'rxjs'
|
import { map } from 'rxjs'
|
||||||
import { ObjectWithPermissions } from 'src/app/data/object-with-permissions'
|
import { ObjectWithPermissions } from 'src/app/data/object-with-permissions'
|
||||||
import { User } from 'src/app/data/user'
|
import { User } from 'src/app/data/user'
|
||||||
import { UserService } from 'src/app/services/rest/user.service'
|
import { UserService } from 'src/app/services/rest/user.service'
|
||||||
import { ToastService } from 'src/app/services/toast.service'
|
|
||||||
import { PermissionsFormComponent } from '../input/permissions/permissions-form/permissions-form.component'
|
import { PermissionsFormComponent } from '../input/permissions/permissions-form/permissions-form.component'
|
||||||
import { SwitchComponent } from '../input/switch/switch.component'
|
import { SwitchComponent } from '../input/switch/switch.component'
|
||||||
|
|
||||||
@@ -36,16 +35,9 @@ import { SwitchComponent } from '../input/switch/switch.component'
|
|||||||
export class PermissionsDialogComponent {
|
export class PermissionsDialogComponent {
|
||||||
activeModal = inject(NgbActiveModal)
|
activeModal = inject(NgbActiveModal)
|
||||||
private userService = inject(UserService)
|
private userService = inject(UserService)
|
||||||
private toastService = inject(ToastService)
|
|
||||||
|
|
||||||
readonly users = toSignal(
|
readonly users = toSignal(
|
||||||
this.userService.listAll().pipe(
|
this.userService.listAll().pipe(map((r) => r.results)),
|
||||||
map((r) => r.results),
|
|
||||||
catchError((error) => {
|
|
||||||
this.toastService.showError($localize`Error retrieving users`, error)
|
|
||||||
return of([])
|
|
||||||
})
|
|
||||||
),
|
|
||||||
{ initialValue: undefined as User[] }
|
{ initialValue: undefined as User[] }
|
||||||
)
|
)
|
||||||
readonly title = signal($localize`Set permissions`)
|
readonly title = signal($localize`Set permissions`)
|
||||||
|
|||||||
@@ -1473,35 +1473,6 @@ describe('DocumentDetailComponent', () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should not automatically get suggestions if auto-suggest is disabled', () => {
|
|
||||||
settingsService.set(SETTINGS_KEYS.DOCUMENT_EDITING_AUTO_SUGGEST, false)
|
|
||||||
const suggestionsSpy = jest.spyOn(documentService, 'getSuggestions')
|
|
||||||
suggestionsSpy.mockReturnValue(of({ tags: [42] }))
|
|
||||||
initNormally()
|
|
||||||
expect(suggestionsSpy).not.toHaveBeenCalled()
|
|
||||||
|
|
||||||
// still available on demand
|
|
||||||
component.getSuggestions()
|
|
||||||
expect(suggestionsSpy).toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should not automatically get AI suggestions if auto-suggest is disabled', () => {
|
|
||||||
settingsService.set(SETTINGS_KEYS.DOCUMENT_EDITING_AUTO_SUGGEST, false)
|
|
||||||
const getSetting = settingsService.get.bind(settingsService)
|
|
||||||
jest
|
|
||||||
.spyOn(settingsService, 'get')
|
|
||||||
.mockImplementation((key) =>
|
|
||||||
key === SETTINGS_KEYS.AI_ENABLED ? true : getSetting(key)
|
|
||||||
)
|
|
||||||
const aiSuggestionsSpy = jest.spyOn(documentService, 'getAiSuggestions')
|
|
||||||
aiSuggestionsSpy.mockReturnValue(of({ tags: [42] }))
|
|
||||||
initNormally()
|
|
||||||
expect(aiSuggestionsSpy).not.toHaveBeenCalled()
|
|
||||||
|
|
||||||
component.getSuggestions()
|
|
||||||
expect(aiSuggestionsSpy).toHaveBeenCalled()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should reset the suggestions loading state if the document changes mid-request', () => {
|
it('should reset the suggestions loading state if the document changes mid-request', () => {
|
||||||
const getSetting = settingsService.get.bind(settingsService)
|
const getSetting = settingsService.get.bind(settingsService)
|
||||||
jest
|
jest
|
||||||
|
|||||||
@@ -237,9 +237,6 @@ export class DocumentDetailComponent
|
|||||||
this.settings.getSignal<boolean>(
|
this.settings.getSignal<boolean>(
|
||||||
SETTINGS_KEYS.DOCUMENT_EDITING_OVERLAY_THUMBNAIL
|
SETTINGS_KEYS.DOCUMENT_EDITING_OVERLAY_THUMBNAIL
|
||||||
)
|
)
|
||||||
private readonly autoSuggestSetting = this.settings.getSignal<boolean>(
|
|
||||||
SETTINGS_KEYS.DOCUMENT_EDITING_AUTO_SUGGEST
|
|
||||||
)
|
|
||||||
private readonly hiddenFieldsSetting = this.settings.getSignal<
|
private readonly hiddenFieldsSetting = this.settings.getSignal<
|
||||||
DocumentDetailFieldID[]
|
DocumentDetailFieldID[]
|
||||||
>(SETTINGS_KEYS.DOCUMENT_DETAILS_HIDDEN_FIELDS)
|
>(SETTINGS_KEYS.DOCUMENT_DETAILS_HIDDEN_FIELDS)
|
||||||
@@ -360,10 +357,6 @@ export class DocumentDetailComponent
|
|||||||
return this.aiEnabledSetting()
|
return this.aiEnabledSetting()
|
||||||
}
|
}
|
||||||
|
|
||||||
get autoSuggest(): boolean {
|
|
||||||
return this.autoSuggestSetting()
|
|
||||||
}
|
|
||||||
|
|
||||||
get archiveContentRenderType(): ContentRenderType {
|
get archiveContentRenderType(): ContentRenderType {
|
||||||
const hasArchiveVersion =
|
const hasArchiveVersion =
|
||||||
this.metadata()?.has_archive_version ??
|
this.metadata()?.has_archive_version ??
|
||||||
@@ -911,7 +904,6 @@ export class DocumentDetailComponent
|
|||||||
this.updateFormForCustomFields()
|
this.updateFormForCustomFields()
|
||||||
this.loadMetadataForSelectedVersion()
|
this.loadMetadataForSelectedVersion()
|
||||||
if (
|
if (
|
||||||
this.autoSuggest &&
|
|
||||||
this.permissionsService.currentUserHasObjectPermissions(
|
this.permissionsService.currentUserHasObjectPermissions(
|
||||||
PermissionAction.Change,
|
PermissionAction.Change,
|
||||||
doc
|
doc
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
import { EventEmitter, signal } from '@angular/core'
|
import { EventEmitter, signal } from '@angular/core'
|
||||||
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||||
import { By } from '@angular/platform-browser'
|
import { By } from '@angular/platform-browser'
|
||||||
|
import { Router } from '@angular/router'
|
||||||
import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap'
|
import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap'
|
||||||
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
||||||
import { of, throwError } from 'rxjs'
|
import { of, throwError } from 'rxjs'
|
||||||
@@ -46,7 +47,6 @@ import { StoragePathEditDialogComponent } from '../../common/edit-dialog/storage
|
|||||||
import { TagEditDialogComponent } from '../../common/edit-dialog/tag-edit-dialog/tag-edit-dialog.component'
|
import { TagEditDialogComponent } from '../../common/edit-dialog/tag-edit-dialog/tag-edit-dialog.component'
|
||||||
import { FilterableDropdownComponent } from '../../common/filterable-dropdown/filterable-dropdown.component'
|
import { FilterableDropdownComponent } from '../../common/filterable-dropdown/filterable-dropdown.component'
|
||||||
import { ShareLinkBundleDialogComponent } from '../../common/share-link-bundle-dialog/share-link-bundle-dialog.component'
|
import { ShareLinkBundleDialogComponent } from '../../common/share-link-bundle-dialog/share-link-bundle-dialog.component'
|
||||||
import { ShareLinkBundleManageDialogComponent } from '../../common/share-link-bundle-manage-dialog/share-link-bundle-manage-dialog.component'
|
|
||||||
import { BulkEditorComponent } from './bulk-editor.component'
|
import { BulkEditorComponent } from './bulk-editor.component'
|
||||||
|
|
||||||
const selectionData: SelectionData = {
|
const selectionData: SelectionData = {
|
||||||
@@ -82,6 +82,7 @@ describe('BulkEditorComponent', () => {
|
|||||||
let customFieldsService: CustomFieldsService
|
let customFieldsService: CustomFieldsService
|
||||||
let httpTestingController: HttpTestingController
|
let httpTestingController: HttpTestingController
|
||||||
let shareLinkBundleService: ShareLinkBundleService
|
let shareLinkBundleService: ShareLinkBundleService
|
||||||
|
let router: Router
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
TestBed.configureTestingModule({
|
TestBed.configureTestingModule({
|
||||||
@@ -167,11 +168,14 @@ describe('BulkEditorComponent', () => {
|
|||||||
provide: ShareLinkBundleService,
|
provide: ShareLinkBundleService,
|
||||||
useValue: {
|
useValue: {
|
||||||
createBundle: jest.fn(),
|
createBundle: jest.fn(),
|
||||||
listAllBundles: jest.fn(),
|
|
||||||
rebuildBundle: jest.fn(),
|
rebuildBundle: jest.fn(),
|
||||||
delete: jest.fn(),
|
delete: jest.fn(),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
provide: Router,
|
||||||
|
useValue: { navigate: jest.fn().mockResolvedValue(true) },
|
||||||
|
},
|
||||||
provideHttpClient(withInterceptorsFromDi()),
|
provideHttpClient(withInterceptorsFromDi()),
|
||||||
provideHttpClientTesting(),
|
provideHttpClientTesting(),
|
||||||
],
|
],
|
||||||
@@ -189,6 +193,7 @@ describe('BulkEditorComponent', () => {
|
|||||||
customFieldsService = TestBed.inject(CustomFieldsService)
|
customFieldsService = TestBed.inject(CustomFieldsService)
|
||||||
httpTestingController = TestBed.inject(HttpTestingController)
|
httpTestingController = TestBed.inject(HttpTestingController)
|
||||||
shareLinkBundleService = TestBed.inject(ShareLinkBundleService)
|
shareLinkBundleService = TestBed.inject(ShareLinkBundleService)
|
||||||
|
router = TestBed.inject(Router)
|
||||||
|
|
||||||
fixture = TestBed.createComponent(BulkEditorComponent)
|
fixture = TestBed.createComponent(BulkEditorComponent)
|
||||||
component = fixture.componentInstance
|
component = fixture.componentInstance
|
||||||
@@ -1824,9 +1829,9 @@ describe('BulkEditorComponent', () => {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
const openSpy = jest.spyOn(modalService, 'open')
|
const openSpy = jest
|
||||||
openSpy.mockReturnValueOnce(modalRef as NgbModalRef)
|
.spyOn(modalService, 'open')
|
||||||
openSpy.mockReturnValueOnce({} as NgbModalRef)
|
.mockReturnValueOnce(modalRef as NgbModalRef)
|
||||||
;(shareLinkBundleService.createBundle as jest.Mock).mockReturnValueOnce(
|
;(shareLinkBundleService.createBundle as jest.Mock).mockReturnValueOnce(
|
||||||
of({ id: 42 })
|
of({ id: 42 })
|
||||||
)
|
)
|
||||||
@@ -1860,11 +1865,9 @@ describe('BulkEditorComponent', () => {
|
|||||||
|
|
||||||
dialogInstance.onOpenManage()
|
dialogInstance.onOpenManage()
|
||||||
expect(modalRef.close).toHaveBeenCalled()
|
expect(modalRef.close).toHaveBeenCalled()
|
||||||
expect(openSpy).toHaveBeenNthCalledWith(
|
expect(router.navigate).toHaveBeenCalledWith(['/share-links'], {
|
||||||
2,
|
queryParams: { type: 'bundles' },
|
||||||
ShareLinkBundleManageDialogComponent,
|
})
|
||||||
expect.objectContaining({ backdrop: 'static', size: 'lg' })
|
|
||||||
)
|
|
||||||
openSpy.mockRestore()
|
openSpy.mockRestore()
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -1917,13 +1920,10 @@ describe('BulkEditorComponent', () => {
|
|||||||
openSpy.mockRestore()
|
openSpy.mockRestore()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should open share link bundle management dialog', () => {
|
it('should navigate to share link bundle management', () => {
|
||||||
const openSpy = jest.spyOn(modalService, 'open')
|
|
||||||
component.manageShareLinkBundles()
|
component.manageShareLinkBundles()
|
||||||
expect(openSpy).toHaveBeenCalledWith(
|
expect(router.navigate).toHaveBeenCalledWith(['/share-links'], {
|
||||||
ShareLinkBundleManageDialogComponent,
|
queryParams: { type: 'bundles' },
|
||||||
expect.objectContaining({ backdrop: 'static', size: 'lg' })
|
})
|
||||||
)
|
|
||||||
openSpy.mockRestore()
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import {
|
|||||||
FormsModule,
|
FormsModule,
|
||||||
ReactiveFormsModule,
|
ReactiveFormsModule,
|
||||||
} from '@angular/forms'
|
} from '@angular/forms'
|
||||||
|
import { Router } from '@angular/router'
|
||||||
import {
|
import {
|
||||||
NgbDropdownModule,
|
NgbDropdownModule,
|
||||||
NgbModal,
|
NgbModal,
|
||||||
@@ -69,7 +70,6 @@ import {
|
|||||||
import { ToggleableItemState } from '../../common/filterable-dropdown/toggleable-dropdown-button/toggleable-dropdown-button.component'
|
import { ToggleableItemState } from '../../common/filterable-dropdown/toggleable-dropdown-button/toggleable-dropdown-button.component'
|
||||||
import { PermissionsDialogComponent } from '../../common/permissions-dialog/permissions-dialog.component'
|
import { PermissionsDialogComponent } from '../../common/permissions-dialog/permissions-dialog.component'
|
||||||
import { ShareLinkBundleDialogComponent } from '../../common/share-link-bundle-dialog/share-link-bundle-dialog.component'
|
import { ShareLinkBundleDialogComponent } from '../../common/share-link-bundle-dialog/share-link-bundle-dialog.component'
|
||||||
import { ShareLinkBundleManageDialogComponent } from '../../common/share-link-bundle-manage-dialog/share-link-bundle-manage-dialog.component'
|
|
||||||
import { ComponentWithPermissions } from '../../with-permissions/with-permissions.component'
|
import { ComponentWithPermissions } from '../../with-permissions/with-permissions.component'
|
||||||
import { CustomFieldsBulkEditDialogComponent } from './custom-fields-bulk-edit-dialog/custom-fields-bulk-edit-dialog.component'
|
import { CustomFieldsBulkEditDialogComponent } from './custom-fields-bulk-edit-dialog/custom-fields-bulk-edit-dialog.component'
|
||||||
|
|
||||||
@@ -104,6 +104,7 @@ export class BulkEditorComponent
|
|||||||
public readonly permissionService = inject(PermissionsService)
|
public readonly permissionService = inject(PermissionsService)
|
||||||
private savedViewService = inject(SavedViewService)
|
private savedViewService = inject(SavedViewService)
|
||||||
private readonly shareLinkBundleService = inject(ShareLinkBundleService)
|
private readonly shareLinkBundleService = inject(ShareLinkBundleService)
|
||||||
|
private readonly router = inject(Router)
|
||||||
|
|
||||||
tagSelectionModel = new FilterableDropdownSelectionModel(true)
|
tagSelectionModel = new FilterableDropdownSelectionModel(true)
|
||||||
correspondentSelectionModel = new FilterableDropdownSelectionModel()
|
correspondentSelectionModel = new FilterableDropdownSelectionModel()
|
||||||
@@ -1135,9 +1136,8 @@ export class BulkEditorComponent
|
|||||||
}
|
}
|
||||||
|
|
||||||
manageShareLinkBundles() {
|
manageShareLinkBundles() {
|
||||||
this.modalService.open(ShareLinkBundleManageDialogComponent, {
|
void this.router.navigate(['/share-links'], {
|
||||||
backdrop: 'static',
|
queryParams: { type: 'bundles' },
|
||||||
size: 'lg',
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -10,7 +10,7 @@
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@if (textFilterTarget === 'asn' || textFilterTarget === 'duplicates') {
|
@if (textFilterTarget === 'asn') {
|
||||||
<select class="form-select flex-grow-0 w-auto" [(ngModel)]="textFilterModifier" (change)="textFilterModifierChange()">
|
<select class="form-select flex-grow-0 w-auto" [(ngModel)]="textFilterModifier" (change)="textFilterModifierChange()">
|
||||||
@for (m of textFilterModifiers; track m) {
|
@for (m of textFilterModifiers; track m) {
|
||||||
<option ngbDropdownItem [value]="m.id">{{m.label}}</option>
|
<option ngbDropdownItem [value]="m.id">{{m.label}}</option>
|
||||||
@@ -23,7 +23,7 @@
|
|||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
<input #textFilterInput class="form-control form-control-sm" type="text"
|
<input #textFilterInput class="form-control form-control-sm" type="text"
|
||||||
[disabled]="textFilterInputDisabled"
|
[disabled]="textFilterModifierIsNull"
|
||||||
[(ngModel)]="textFilter"
|
[(ngModel)]="textFilter"
|
||||||
(keydown)="textFilterKeydown($event)"
|
(keydown)="textFilterKeydown($event)"
|
||||||
[ngbTypeahead]="searchAutoComplete"
|
[ngbTypeahead]="searchAutoComplete"
|
||||||
|
|||||||
@@ -53,7 +53,6 @@ import {
|
|||||||
FILTER_HAS_CUSTOM_FIELDS_ALL,
|
FILTER_HAS_CUSTOM_FIELDS_ALL,
|
||||||
FILTER_HAS_CUSTOM_FIELDS_ANY,
|
FILTER_HAS_CUSTOM_FIELDS_ANY,
|
||||||
FILTER_HAS_DOCUMENT_TYPE_ANY,
|
FILTER_HAS_DOCUMENT_TYPE_ANY,
|
||||||
FILTER_HAS_DUPLICATES,
|
|
||||||
FILTER_HAS_STORAGE_PATH_ANY,
|
FILTER_HAS_STORAGE_PATH_ANY,
|
||||||
FILTER_HAS_TAGS_ALL,
|
FILTER_HAS_TAGS_ALL,
|
||||||
FILTER_HAS_TAGS_ANY,
|
FILTER_HAS_TAGS_ANY,
|
||||||
@@ -428,38 +427,6 @@ describe('FilterEditorComponent', () => {
|
|||||||
expect(component.textFilterTarget).toEqual('mime-type') // TEXT_FILTER_TARGET_MIME_TYPE
|
expect(component.textFilterTarget).toEqual('mime-type') // TEXT_FILTER_TARGET_MIME_TYPE
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should ingest filter rules for documents with duplicates', () => {
|
|
||||||
component.filterRules = [
|
|
||||||
{
|
|
||||||
rule_type: FILTER_HAS_DUPLICATES,
|
|
||||||
value: 'true',
|
|
||||||
},
|
|
||||||
]
|
|
||||||
fixture.detectChanges()
|
|
||||||
|
|
||||||
expect(component.textFilterTarget).toEqual('duplicates')
|
|
||||||
expect(component.textFilterModifier).toEqual('has-duplicates')
|
|
||||||
expect(component.textFilterInputDisabled).toBeTruthy()
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should ingest filter rules for documents without duplicates', () => {
|
|
||||||
component.filterRules = [
|
|
||||||
{
|
|
||||||
rule_type: FILTER_HAS_DUPLICATES,
|
|
||||||
value: 'false',
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
expect(component.textFilterTarget).toEqual('duplicates')
|
|
||||||
expect(component.textFilterModifier).toEqual('does-not-have-duplicates')
|
|
||||||
expect(component.filterRules).toEqual([
|
|
||||||
{
|
|
||||||
rule_type: FILTER_HAS_DUPLICATES,
|
|
||||||
value: 'false',
|
|
||||||
},
|
|
||||||
])
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should ingest text filter rules for fulltext query', () => {
|
it('should ingest text filter rules for fulltext query', () => {
|
||||||
expect(component.textFilter).toEqual(null)
|
expect(component.textFilter).toEqual(null)
|
||||||
component.filterRules = [
|
component.filterRules = [
|
||||||
@@ -1423,33 +1390,6 @@ describe('FilterEditorComponent', () => {
|
|||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should convert duplicate target input to the correct filter rule', () => {
|
|
||||||
const textFieldTargetDropdown = fixture.debugElement.queryAll(
|
|
||||||
By.directive(NgbDropdownItem)
|
|
||||||
)[5]
|
|
||||||
textFieldTargetDropdown.triggerEventHandler('click')
|
|
||||||
fixture.detectChanges()
|
|
||||||
|
|
||||||
expect(component.textFilterTarget).toEqual('duplicates')
|
|
||||||
expect(component.filterRules).toEqual([
|
|
||||||
{
|
|
||||||
rule_type: FILTER_HAS_DUPLICATES,
|
|
||||||
value: 'true',
|
|
||||||
},
|
|
||||||
])
|
|
||||||
|
|
||||||
const textFieldModifierSelect = fixture.debugElement.query(By.css('select'))
|
|
||||||
textFieldModifierSelect.nativeElement.value = 'does-not-have-duplicates'
|
|
||||||
textFieldModifierSelect.nativeElement.dispatchEvent(new Event('change'))
|
|
||||||
fixture.detectChanges()
|
|
||||||
expect(component.filterRules).toEqual([
|
|
||||||
{
|
|
||||||
rule_type: FILTER_HAS_DUPLICATES,
|
|
||||||
value: 'false',
|
|
||||||
},
|
|
||||||
])
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should convert user input to correct filter rules on full text query', () => {
|
it('should convert user input to correct filter rules on full text query', () => {
|
||||||
component.textFilterInput.nativeElement.value = 'foo'
|
component.textFilterInput.nativeElement.value = 'foo'
|
||||||
component.textFilterInput.nativeElement.dispatchEvent(new Event('input'))
|
component.textFilterInput.nativeElement.dispatchEvent(new Event('input'))
|
||||||
@@ -2238,22 +2178,6 @@ describe('FilterEditorComponent', () => {
|
|||||||
]
|
]
|
||||||
expect(component.generateFilterName()).toEqual('Without any tag')
|
expect(component.generateFilterName()).toEqual('Without any tag')
|
||||||
|
|
||||||
component.filterRules = [
|
|
||||||
{
|
|
||||||
rule_type: FILTER_HAS_DUPLICATES,
|
|
||||||
value: 'true',
|
|
||||||
},
|
|
||||||
]
|
|
||||||
expect(component.generateFilterName()).toEqual('With duplicates')
|
|
||||||
|
|
||||||
component.filterRules = [
|
|
||||||
{
|
|
||||||
rule_type: FILTER_HAS_DUPLICATES,
|
|
||||||
value: 'false',
|
|
||||||
},
|
|
||||||
]
|
|
||||||
expect(component.generateFilterName()).toEqual('Without duplicates')
|
|
||||||
|
|
||||||
component.filterRules = [
|
component.filterRules = [
|
||||||
{
|
{
|
||||||
rule_type: FILTER_CUSTOM_FIELDS_QUERY,
|
rule_type: FILTER_CUSTOM_FIELDS_QUERY,
|
||||||
|
|||||||
@@ -65,7 +65,6 @@ import {
|
|||||||
FILTER_HAS_CUSTOM_FIELDS_ALL,
|
FILTER_HAS_CUSTOM_FIELDS_ALL,
|
||||||
FILTER_HAS_CUSTOM_FIELDS_ANY,
|
FILTER_HAS_CUSTOM_FIELDS_ANY,
|
||||||
FILTER_HAS_DOCUMENT_TYPE_ANY,
|
FILTER_HAS_DOCUMENT_TYPE_ANY,
|
||||||
FILTER_HAS_DUPLICATES,
|
|
||||||
FILTER_HAS_STORAGE_PATH_ANY,
|
FILTER_HAS_STORAGE_PATH_ANY,
|
||||||
FILTER_HAS_TAGS_ALL,
|
FILTER_HAS_TAGS_ALL,
|
||||||
FILTER_HAS_TAGS_ANY,
|
FILTER_HAS_TAGS_ANY,
|
||||||
@@ -130,15 +129,12 @@ const TEXT_FILTER_TARGET_FULLTEXT_QUERY = 'fulltext-query'
|
|||||||
const TEXT_FILTER_TARGET_FULLTEXT_MORELIKE = 'fulltext-morelike'
|
const TEXT_FILTER_TARGET_FULLTEXT_MORELIKE = 'fulltext-morelike'
|
||||||
const TEXT_FILTER_TARGET_CUSTOM_FIELDS = 'custom-fields'
|
const TEXT_FILTER_TARGET_CUSTOM_FIELDS = 'custom-fields'
|
||||||
const TEXT_FILTER_TARGET_MIME_TYPE = 'mime-type'
|
const TEXT_FILTER_TARGET_MIME_TYPE = 'mime-type'
|
||||||
const TEXT_FILTER_TARGET_DUPLICATES = 'duplicates'
|
|
||||||
|
|
||||||
const TEXT_FILTER_MODIFIER_EQUALS = 'equals'
|
const TEXT_FILTER_MODIFIER_EQUALS = 'equals'
|
||||||
const TEXT_FILTER_MODIFIER_NULL = 'is null'
|
const TEXT_FILTER_MODIFIER_NULL = 'is null'
|
||||||
const TEXT_FILTER_MODIFIER_NOTNULL = 'not null'
|
const TEXT_FILTER_MODIFIER_NOTNULL = 'not null'
|
||||||
const TEXT_FILTER_MODIFIER_GT = 'greater'
|
const TEXT_FILTER_MODIFIER_GT = 'greater'
|
||||||
const TEXT_FILTER_MODIFIER_LT = 'less'
|
const TEXT_FILTER_MODIFIER_LT = 'less'
|
||||||
const TEXT_FILTER_MODIFIER_HAS_DUPLICATES = 'has-duplicates'
|
|
||||||
const TEXT_FILTER_MODIFIER_DOES_NOT_HAVE_DUPLICATES = 'does-not-have-duplicates'
|
|
||||||
|
|
||||||
const RELATIVE_DATE_QUERY_REGEXP_CREATED = /created:[\["]([^\]]+)[\]"]/g
|
const RELATIVE_DATE_QUERY_REGEXP_CREATED = /created:[\["]([^\]]+)[\]"]/g
|
||||||
const RELATIVE_DATE_QUERY_REGEXP_ADDED = /added:[\["]([^\]]+)[\]"]/g
|
const RELATIVE_DATE_QUERY_REGEXP_ADDED = /added:[\["]([^\]]+)[\]"]/g
|
||||||
@@ -209,7 +205,6 @@ const DEFAULT_TEXT_FILTER_TARGET_OPTIONS = [
|
|||||||
id: TEXT_FILTER_TARGET_FULLTEXT_QUERY,
|
id: TEXT_FILTER_TARGET_FULLTEXT_QUERY,
|
||||||
name: $localize`Advanced search`,
|
name: $localize`Advanced search`,
|
||||||
},
|
},
|
||||||
{ id: TEXT_FILTER_TARGET_DUPLICATES, name: $localize`Duplicates` },
|
|
||||||
]
|
]
|
||||||
|
|
||||||
const DEPRECATED_CUSTOM_FIELDS_TEXT_FILTER_TARGET_OPTION = {
|
const DEPRECATED_CUSTOM_FIELDS_TEXT_FILTER_TARGET_OPTION = {
|
||||||
@@ -246,17 +241,6 @@ const DEFAULT_TEXT_FILTER_MODIFIER_OPTIONS = [
|
|||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
const DUPLICATES_FILTER_MODIFIER_OPTIONS = [
|
|
||||||
{
|
|
||||||
id: TEXT_FILTER_MODIFIER_HAS_DUPLICATES,
|
|
||||||
label: $localize`exist`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: TEXT_FILTER_MODIFIER_DOES_NOT_HAVE_DUPLICATES,
|
|
||||||
label: $localize`do not exist`,
|
|
||||||
},
|
|
||||||
]
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'pngx-filter-editor',
|
selector: 'pngx-filter-editor',
|
||||||
templateUrl: './filter-editor.component.html',
|
templateUrl: './filter-editor.component.html',
|
||||||
@@ -336,12 +320,6 @@ export class FilterEditorComponent
|
|||||||
if (rule.value == 'false') {
|
if (rule.value == 'false') {
|
||||||
return $localize`Without any tag`
|
return $localize`Without any tag`
|
||||||
}
|
}
|
||||||
break
|
|
||||||
|
|
||||||
case FILTER_HAS_DUPLICATES:
|
|
||||||
return rule.value == 'false'
|
|
||||||
? $localize`Without duplicates`
|
|
||||||
: $localize`With duplicates`
|
|
||||||
|
|
||||||
case FILTER_CUSTOM_FIELDS_QUERY:
|
case FILTER_CUSTOM_FIELDS_QUERY:
|
||||||
return $localize`Custom fields query`
|
return $localize`Custom fields query`
|
||||||
@@ -412,9 +390,7 @@ export class FilterEditorComponent
|
|||||||
public textFilterModifier: string
|
public textFilterModifier: string
|
||||||
|
|
||||||
get textFilterModifiers() {
|
get textFilterModifiers() {
|
||||||
return this.textFilterTarget === TEXT_FILTER_TARGET_DUPLICATES
|
return DEFAULT_TEXT_FILTER_MODIFIER_OPTIONS
|
||||||
? DUPLICATES_FILTER_MODIFIER_OPTIONS
|
|
||||||
: DEFAULT_TEXT_FILTER_MODIFIER_OPTIONS
|
|
||||||
}
|
}
|
||||||
|
|
||||||
get textFilterModifierIsNull(): boolean {
|
get textFilterModifierIsNull(): boolean {
|
||||||
@@ -423,13 +399,6 @@ export class FilterEditorComponent
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
get textFilterInputDisabled(): boolean {
|
|
||||||
return (
|
|
||||||
this.textFilterModifierIsNull ||
|
|
||||||
this.textFilterTarget === TEXT_FILTER_TARGET_DUPLICATES
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
tagSelectionModel = new FilterableDropdownSelectionModel(true)
|
tagSelectionModel = new FilterableDropdownSelectionModel(true)
|
||||||
correspondentSelectionModel = new FilterableDropdownSelectionModel()
|
correspondentSelectionModel = new FilterableDropdownSelectionModel()
|
||||||
documentTypeSelectionModel = new FilterableDropdownSelectionModel()
|
documentTypeSelectionModel = new FilterableDropdownSelectionModel()
|
||||||
@@ -475,7 +444,6 @@ export class FilterEditorComponent
|
|||||||
this.customFieldQueriesModel.clear(false)
|
this.customFieldQueriesModel.clear(false)
|
||||||
this._textFilter = null
|
this._textFilter = null
|
||||||
this._moreLikeId = null
|
this._moreLikeId = null
|
||||||
this.textFilterTarget = TEXT_FILTER_TARGET_TITLE_CONTENT
|
|
||||||
this.dateAddedTo = null
|
this.dateAddedTo = null
|
||||||
this.dateAddedFrom = null
|
this.dateAddedFrom = null
|
||||||
this.dateCreatedTo = null
|
this.dateCreatedTo = null
|
||||||
@@ -509,13 +477,6 @@ export class FilterEditorComponent
|
|||||||
this.textFilterTarget = TEXT_FILTER_TARGET_MIME_TYPE
|
this.textFilterTarget = TEXT_FILTER_TARGET_MIME_TYPE
|
||||||
this._textFilter = rule.value
|
this._textFilter = rule.value
|
||||||
break
|
break
|
||||||
case FILTER_HAS_DUPLICATES:
|
|
||||||
this.textFilterTarget = TEXT_FILTER_TARGET_DUPLICATES
|
|
||||||
this.textFilterModifier =
|
|
||||||
rule.value == 'false' || rule.value == '0'
|
|
||||||
? TEXT_FILTER_MODIFIER_DOES_NOT_HAVE_DUPLICATES
|
|
||||||
: TEXT_FILTER_MODIFIER_HAS_DUPLICATES
|
|
||||||
break
|
|
||||||
case FILTER_FULLTEXT_QUERY:
|
case FILTER_FULLTEXT_QUERY:
|
||||||
let allQueryArgs = rule.value.split(',')
|
let allQueryArgs = rule.value.split(',')
|
||||||
let textQueryArgs = []
|
let textQueryArgs = []
|
||||||
@@ -839,14 +800,6 @@ export class FilterEditorComponent
|
|||||||
value: this._textFilter.trim(),
|
value: this._textFilter.trim(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if (this.textFilterTarget == TEXT_FILTER_TARGET_DUPLICATES) {
|
|
||||||
filterRules.push({
|
|
||||||
rule_type: FILTER_HAS_DUPLICATES,
|
|
||||||
value: (
|
|
||||||
this.textFilterModifier == TEXT_FILTER_MODIFIER_HAS_DUPLICATES
|
|
||||||
).toString(),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
if (this._textFilter && this.textFilterTarget == TEXT_FILTER_TARGET_TITLE) {
|
if (this._textFilter && this.textFilterTarget == TEXT_FILTER_TARGET_TITLE) {
|
||||||
filterRules.push({
|
filterRules.push({
|
||||||
rule_type: FILTER_SIMPLE_TITLE,
|
rule_type: FILTER_SIMPLE_TITLE,
|
||||||
@@ -1210,7 +1163,7 @@ export class FilterEditorComponent
|
|||||||
}
|
}
|
||||||
|
|
||||||
get textFilter() {
|
get textFilter() {
|
||||||
return this.textFilterInputDisabled ? '' : this._textFilter
|
return this.textFilterModifierIsNull ? '' : this._textFilter
|
||||||
}
|
}
|
||||||
|
|
||||||
set textFilter(value) {
|
set textFilter(value) {
|
||||||
@@ -1410,24 +1363,12 @@ export class FilterEditorComponent
|
|||||||
this._textFilter = ''
|
this._textFilter = ''
|
||||||
}
|
}
|
||||||
this.textFilterTarget = target
|
this.textFilterTarget = target
|
||||||
if (target == TEXT_FILTER_TARGET_DUPLICATES) {
|
|
||||||
this._textFilter = ''
|
|
||||||
this.textFilterModifier = TEXT_FILTER_MODIFIER_HAS_DUPLICATES
|
|
||||||
} else if (
|
|
||||||
[
|
|
||||||
TEXT_FILTER_MODIFIER_HAS_DUPLICATES,
|
|
||||||
TEXT_FILTER_MODIFIER_DOES_NOT_HAVE_DUPLICATES,
|
|
||||||
].includes(this.textFilterModifier)
|
|
||||||
) {
|
|
||||||
this.textFilterModifier = TEXT_FILTER_MODIFIER_EQUALS
|
|
||||||
}
|
|
||||||
this.textFilterInput.nativeElement.focus()
|
this.textFilterInput.nativeElement.focus()
|
||||||
this.updateRules()
|
this.updateRules()
|
||||||
}
|
}
|
||||||
|
|
||||||
textFilterModifierChange() {
|
textFilterModifierChange() {
|
||||||
if (
|
if (
|
||||||
this.textFilterTarget == TEXT_FILTER_TARGET_DUPLICATES ||
|
|
||||||
this.textFilterModifierIsNull ||
|
this.textFilterModifierIsNull ||
|
||||||
([
|
([
|
||||||
TEXT_FILTER_MODIFIER_EQUALS,
|
TEXT_FILTER_MODIFIER_EQUALS,
|
||||||
|
|||||||
@@ -1,38 +1,22 @@
|
|||||||
<div class="modal-header">
|
<div class="border border-top-0 rounded-bottom p-3">
|
||||||
<h4 class="modal-title">{{ title }}</h4>
|
|
||||||
<button type="button" class="btn-close" aria-label="Close" (click)="close()"></button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="modal-body">
|
|
||||||
@if (loading()) {
|
|
||||||
<div class="d-flex align-items-center gap-2">
|
|
||||||
<div class="spinner-border spinner-border-sm" role="status"></div>
|
|
||||||
<span i18n>Loading share link bundles…</span>
|
|
||||||
</div>
|
|
||||||
}
|
|
||||||
@if (!loading() && error()) {
|
@if (!loading() && error()) {
|
||||||
<div class="alert alert-danger mb-0" role="alert">
|
<div class="alert alert-danger mb-0" role="alert">
|
||||||
{{ error() }}
|
{{ error() }}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@if (!loading() && !error()) {
|
@if (!loading() && !error()) {
|
||||||
<div class="d-flex justify-content-between align-items-center mb-2">
|
|
||||||
<p class="mb-0 text-muted small">
|
|
||||||
<ng-container i18n>Status updates every few seconds while bundles are being prepared.</ng-container>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
@if (bundles().length === 0) {
|
@if (bundles().length === 0) {
|
||||||
<p class="mb-0 text-muted fst-italic" i18n>No share link bundles currently exist.</p>
|
<p class="mb-0 text-muted fst-italic" i18n>No share link bundles currently exist.</p>
|
||||||
}
|
}
|
||||||
@if (bundles().length > 0) {
|
@if (bundles().length > 0) {
|
||||||
<div class="table-responsive">
|
<div class="table-responsive">
|
||||||
<table class="table table-sm align-middle mb-0">
|
<table class="table table-sm align-middle mb-0 bg-body">
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th scope="col" i18n>Created</th>
|
<th scope="col" class="fw-normal" pngxSortable="created" [currentSortField]="sortField()" [currentSortReverse]="sortReverse()" (sort)="onSort($event)" i18n>Created</th>
|
||||||
<th scope="col" i18n>Status</th>
|
<th scope="col" class="fw-normal" pngxSortable="status" [currentSortField]="sortField()" [currentSortReverse]="sortReverse()" (sort)="onSort($event)" i18n>Status</th>
|
||||||
<th scope="col" i18n>Size</th>
|
<th scope="col" i18n>Size</th>
|
||||||
<th scope="col" i18n>Expires</th>
|
<th scope="col" class="fw-normal" pngxSortable="expiration" [currentSortField]="sortField()" [currentSortReverse]="sortReverse()" (sort)="onSort($event)" i18n>Expires</th>
|
||||||
<th scope="col" i18n>Documents</th>
|
<th scope="col" i18n>Documents</th>
|
||||||
<th scope="col" i18n>File version</th>
|
<th scope="col" i18n>File version</th>
|
||||||
<th scope="col" class="text-end" i18n>Actions</th>
|
<th scope="col" class="text-end" i18n>Actions</th>
|
||||||
@@ -96,6 +80,9 @@
|
|||||||
<td>
|
<td>
|
||||||
@if (bundle.expiration) {
|
@if (bundle.expiration) {
|
||||||
{{ bundle.expiration | date: 'short' }}
|
{{ bundle.expiration | date: 'short' }}
|
||||||
|
@if (isExpired(bundle.expiration)) {
|
||||||
|
<span class="badge text-bg-danger ms-2" i18n>Expired</span>
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@if (!bundle.expiration) {
|
@if (!bundle.expiration) {
|
||||||
<span i18n>Never</span>
|
<span i18n>Never</span>
|
||||||
@@ -104,42 +91,49 @@
|
|||||||
<td>{{ bundle.document_count }}</td>
|
<td>{{ bundle.document_count }}</td>
|
||||||
<td>{{ fileVersionLabel(bundle.file_version) }}</td>
|
<td>{{ fileVersionLabel(bundle.file_version) }}</td>
|
||||||
<td class="text-end">
|
<td class="text-end">
|
||||||
<div class="btn-group btn-group-sm">
|
<div class="d-inline-block position-relative">
|
||||||
<button
|
<span
|
||||||
type="button"
|
class="badge bg-primary small fade position-absolute top-50 end-100 translate-middle-y me-2 pe-none z-3 text-nowrap"
|
||||||
class="btn btn-outline-primary"
|
[class.show]="copiedSlug() === bundle.slug"
|
||||||
[disabled]="bundle.status !== statuses.Ready"
|
i18n
|
||||||
(click)="copy(bundle)"
|
>Copied!</span>
|
||||||
title="Copy share link"
|
<div class="btn-group btn-group-sm">
|
||||||
i18n-title
|
|
||||||
>
|
|
||||||
@if (copiedSlug() === bundle.slug) {
|
|
||||||
<i-bs name="clipboard-check"></i-bs>
|
|
||||||
}
|
|
||||||
@if (copiedSlug() !== bundle.slug) {
|
|
||||||
<i-bs name="clipboard"></i-bs>
|
|
||||||
}
|
|
||||||
<span class="visually-hidden" i18n>Copy share link</span>
|
|
||||||
</button>
|
|
||||||
@if (bundle.status === statuses.Failed) {
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="btn btn-outline-warning"
|
class="btn btn-outline-primary"
|
||||||
[disabled]="loading()"
|
[disabled]="bundle.status !== statuses.Ready"
|
||||||
(click)="retry(bundle)"
|
(click)="copy(bundle)"
|
||||||
|
title="Copy share link"
|
||||||
|
i18n-title
|
||||||
>
|
>
|
||||||
<i-bs name="arrow-clockwise"></i-bs>
|
@if (copiedSlug() === bundle.slug) {
|
||||||
<span class="visually-hidden" i18n>Retry</span>
|
<i-bs name="clipboard-check"></i-bs>
|
||||||
|
}
|
||||||
|
@if (copiedSlug() !== bundle.slug) {
|
||||||
|
<i-bs name="clipboard"></i-bs>
|
||||||
|
}
|
||||||
|
<span class="visually-hidden" i18n>Copy share link</span>
|
||||||
</button>
|
</button>
|
||||||
}
|
@if (bundle.status === statuses.Failed) {
|
||||||
<pngx-confirm-button
|
<button
|
||||||
buttonClasses="btn btn-sm btn-outline-danger"
|
type="button"
|
||||||
[disabled]="loading()"
|
class="btn btn-outline-warning"
|
||||||
(confirm)="delete(bundle)"
|
[disabled]="loading()"
|
||||||
iconName="trash"
|
(click)="retry(bundle)"
|
||||||
>
|
>
|
||||||
<span class="visually-hidden" i18n>Delete share link bundle</span>
|
<i-bs name="arrow-clockwise"></i-bs>
|
||||||
</pngx-confirm-button>
|
<span class="visually-hidden" i18n>Retry</span>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
<pngx-confirm-button
|
||||||
|
buttonClasses="btn btn-sm btn-outline-danger"
|
||||||
|
[disabled]="loading()"
|
||||||
|
(confirm)="delete(bundle)"
|
||||||
|
iconName="trash"
|
||||||
|
>
|
||||||
|
<span class="visually-hidden" i18n>Delete share link bundle</span>
|
||||||
|
</pngx-confirm-button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -147,10 +141,32 @@
|
|||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="d-flex flex-wrap justify-content-end align-items-center gap-3 mt-3 ms-auto">
|
||||||
|
<div class="d-flex flex-wrap justify-content-end align-items-center gap-3">
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
|
<label class="small text-muted me-2" for="shareLinkBundlePageSize" i18n>Show:</label>
|
||||||
|
<select id="shareLinkBundlePageSize" class="form-select form-select-sm w-auto" [(ngModel)]="pageSize">
|
||||||
|
<option [ngValue]="25">25</option>
|
||||||
|
<option [ngValue]="50">50</option>
|
||||||
|
<option [ngValue]="100">100</option>
|
||||||
|
</select>
|
||||||
|
<span class="small text-muted ms-2 d-none d-md-inline" i18n>per page</span>
|
||||||
|
</div>
|
||||||
|
@if (total() > pageSize) {
|
||||||
|
<ngb-pagination
|
||||||
|
class="mb-0"
|
||||||
|
[pageSize]="pageSize"
|
||||||
|
[collectionSize]="total()"
|
||||||
|
[page]="page()"
|
||||||
|
[maxSize]="5"
|
||||||
|
(pageChange)="setPage($event)"
|
||||||
|
size="sm"
|
||||||
|
aria-label="Share link bundles pagination"
|
||||||
|
i18n-aria-label
|
||||||
|
></ngb-pagination>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="modal-footer">
|
|
||||||
<button type="button" class="btn btn-outline-secondary btn-sm" (click)="close()" i18n>Close</button>
|
|
||||||
</div>
|
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
import { Clipboard } from '@angular/cdk/clipboard'
|
import { Clipboard } from '@angular/cdk/clipboard'
|
||||||
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||||
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
|
|
||||||
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
||||||
import { of, throwError } from 'rxjs'
|
import { of, throwError } from 'rxjs'
|
||||||
import { FileVersion } from 'src/app/data/share-link'
|
import { FileVersion } from 'src/app/data/share-link'
|
||||||
@@ -8,13 +7,15 @@ import {
|
|||||||
ShareLinkBundleStatus,
|
ShareLinkBundleStatus,
|
||||||
ShareLinkBundleSummary,
|
ShareLinkBundleSummary,
|
||||||
} from 'src/app/data/share-link-bundle'
|
} from 'src/app/data/share-link-bundle'
|
||||||
|
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
||||||
import { ShareLinkBundleService } from 'src/app/services/rest/share-link-bundle.service'
|
import { ShareLinkBundleService } from 'src/app/services/rest/share-link-bundle.service'
|
||||||
|
import { SettingsService } from 'src/app/services/settings.service'
|
||||||
import { ToastService } from 'src/app/services/toast.service'
|
import { ToastService } from 'src/app/services/toast.service'
|
||||||
import { environment } from 'src/environments/environment'
|
import { environment } from 'src/environments/environment'
|
||||||
import { ShareLinkBundleManageDialogComponent } from './share-link-bundle-manage-dialog.component'
|
import { ShareLinkBundleListComponent } from './share-link-bundle-list.component'
|
||||||
|
|
||||||
class MockShareLinkBundleService {
|
class MockShareLinkBundleService {
|
||||||
listAllBundles = jest.fn()
|
list = jest.fn()
|
||||||
delete = jest.fn()
|
delete = jest.fn()
|
||||||
rebuildBundle = jest.fn()
|
rebuildBundle = jest.fn()
|
||||||
}
|
}
|
||||||
@@ -24,13 +25,12 @@ class MockToastService {
|
|||||||
showError = jest.fn()
|
showError = jest.fn()
|
||||||
}
|
}
|
||||||
|
|
||||||
describe('ShareLinkBundleManageDialogComponent', () => {
|
describe('ShareLinkBundleListComponent', () => {
|
||||||
let component: ShareLinkBundleManageDialogComponent
|
let component: ShareLinkBundleListComponent
|
||||||
let fixture: ComponentFixture<ShareLinkBundleManageDialogComponent>
|
let fixture: ComponentFixture<ShareLinkBundleListComponent>
|
||||||
let service: MockShareLinkBundleService
|
let service: MockShareLinkBundleService
|
||||||
let toastService: MockToastService
|
let toastService: MockToastService
|
||||||
let clipboard: Clipboard
|
let clipboard: Clipboard
|
||||||
let activeModal: NgbActiveModal
|
|
||||||
let originalApiBaseUrl: string
|
let originalApiBaseUrl: string
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -38,26 +38,24 @@ describe('ShareLinkBundleManageDialogComponent', () => {
|
|||||||
toastService = new MockToastService()
|
toastService = new MockToastService()
|
||||||
originalApiBaseUrl = environment.apiBaseUrl
|
originalApiBaseUrl = environment.apiBaseUrl
|
||||||
|
|
||||||
service.listAllBundles.mockReturnValue(of([]))
|
service.list.mockReturnValue(of({ count: 0, results: [] }))
|
||||||
service.delete.mockReturnValue(of(true))
|
service.delete.mockReturnValue(of(true))
|
||||||
service.rebuildBundle.mockReturnValue(of(sampleBundle()))
|
service.rebuildBundle.mockReturnValue(of(sampleBundle()))
|
||||||
|
|
||||||
TestBed.configureTestingModule({
|
TestBed.configureTestingModule({
|
||||||
imports: [
|
imports: [
|
||||||
ShareLinkBundleManageDialogComponent,
|
ShareLinkBundleListComponent,
|
||||||
NgxBootstrapIconsModule.pick(allIcons),
|
NgxBootstrapIconsModule.pick(allIcons),
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
NgbActiveModal,
|
|
||||||
{ provide: ShareLinkBundleService, useValue: service },
|
{ provide: ShareLinkBundleService, useValue: service },
|
||||||
{ provide: ToastService, useValue: toastService },
|
{ provide: ToastService, useValue: toastService },
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|
||||||
fixture = TestBed.createComponent(ShareLinkBundleManageDialogComponent)
|
fixture = TestBed.createComponent(ShareLinkBundleListComponent)
|
||||||
component = fixture.componentInstance
|
component = fixture.componentInstance
|
||||||
clipboard = TestBed.inject(Clipboard)
|
clipboard = TestBed.inject(Clipboard)
|
||||||
activeModal = TestBed.inject(NgbActiveModal)
|
|
||||||
})
|
})
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
@@ -84,28 +82,28 @@ describe('ShareLinkBundleManageDialogComponent', () => {
|
|||||||
it('loads bundles on init and polls periodically', () => {
|
it('loads bundles on init and polls periodically', () => {
|
||||||
jest.useFakeTimers()
|
jest.useFakeTimers()
|
||||||
const bundles = [sampleBundle({ status: ShareLinkBundleStatus.Ready })]
|
const bundles = [sampleBundle({ status: ShareLinkBundleStatus.Ready })]
|
||||||
service.listAllBundles.mockReset()
|
service.list.mockReset()
|
||||||
service.listAllBundles
|
service.list
|
||||||
.mockReturnValueOnce(of(bundles))
|
.mockReturnValueOnce(of({ count: bundles.length, results: bundles }))
|
||||||
.mockReturnValue(of(bundles))
|
.mockReturnValue(of({ count: bundles.length, results: bundles }))
|
||||||
|
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
|
|
||||||
expect(service.listAllBundles).toHaveBeenCalledTimes(1)
|
expect(service.list).toHaveBeenCalledWith(1, 25, 'created', true)
|
||||||
expect(component.bundles()).toEqual(bundles)
|
expect(component.bundles()).toEqual(bundles)
|
||||||
expect(component.loading()).toBe(false)
|
expect(component.loading()).toBe(false)
|
||||||
expect(component.error()).toBeNull()
|
expect(component.error()).toBeNull()
|
||||||
|
|
||||||
jest.advanceTimersByTime(5000)
|
jest.advanceTimersByTime(5000)
|
||||||
expect(service.listAllBundles).toHaveBeenCalledTimes(2)
|
expect(service.list).toHaveBeenCalledTimes(2)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('handles errors when loading bundles', () => {
|
it('handles errors when loading bundles', () => {
|
||||||
jest.useFakeTimers()
|
jest.useFakeTimers()
|
||||||
service.listAllBundles.mockReset()
|
service.list.mockReset()
|
||||||
service.listAllBundles
|
service.list
|
||||||
.mockReturnValueOnce(throwError(() => new Error('load fail')))
|
.mockReturnValueOnce(throwError(() => new Error('load fail')))
|
||||||
.mockReturnValue(of([]))
|
.mockReturnValue(of({ count: 0, results: [] }))
|
||||||
|
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
|
|
||||||
@@ -114,7 +112,57 @@ describe('ShareLinkBundleManageDialogComponent', () => {
|
|||||||
expect(component.loading()).toBe(false)
|
expect(component.loading()).toBe(false)
|
||||||
|
|
||||||
jest.advanceTimersByTime(5000)
|
jest.advanceTimersByTime(5000)
|
||||||
expect(service.listAllBundles).toHaveBeenCalledTimes(2)
|
expect(service.list).toHaveBeenCalledTimes(2)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('loads another page', () => {
|
||||||
|
fixture.detectChanges()
|
||||||
|
|
||||||
|
component.setPage(2)
|
||||||
|
|
||||||
|
expect(service.list).toHaveBeenLastCalledWith(2, 25, 'created', true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sorts bundles and returns to the first page', () => {
|
||||||
|
fixture.detectChanges()
|
||||||
|
component.page.set(2)
|
||||||
|
|
||||||
|
component.onSort({ column: 'status', reverse: false })
|
||||||
|
|
||||||
|
expect(component.page()).toBe(1)
|
||||||
|
expect(service.list).toHaveBeenLastCalledWith(1, 25, 'status', false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('marks expired share link bundles', () => {
|
||||||
|
service.list.mockReturnValue(
|
||||||
|
of({
|
||||||
|
count: 1,
|
||||||
|
results: [sampleBundle({ expiration: '2000-01-01T00:00:00.000Z' })],
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
fixture.detectChanges()
|
||||||
|
|
||||||
|
expect(fixture.nativeElement.textContent).toContain('Expired')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('stores a changed page size and reloads from the first page', () => {
|
||||||
|
fixture.detectChanges()
|
||||||
|
const settingsService = TestBed.inject(SettingsService)
|
||||||
|
jest
|
||||||
|
.spyOn(settingsService, 'get')
|
||||||
|
.mockReturnValueOnce({ share_link_bundles: 25 })
|
||||||
|
const setSpy = jest.spyOn(settingsService, 'set')
|
||||||
|
jest.spyOn(settingsService, 'storeSettings').mockReturnValue(of({}))
|
||||||
|
component.page.set(2)
|
||||||
|
|
||||||
|
component.pageSize = 100
|
||||||
|
|
||||||
|
expect(setSpy).toHaveBeenCalledWith(SETTINGS_KEYS.OBJECT_LIST_SIZES, {
|
||||||
|
share_link_bundles: 100,
|
||||||
|
})
|
||||||
|
expect(component.page()).toBe(1)
|
||||||
|
expect(service.list).toHaveBeenLastCalledWith(1, 100, 'created', true)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('copies bundle links when ready', () => {
|
it('copies bundle links when ready', () => {
|
||||||
@@ -126,16 +174,24 @@ describe('ShareLinkBundleManageDialogComponent', () => {
|
|||||||
slug: 'ready-slug',
|
slug: 'ready-slug',
|
||||||
status: ShareLinkBundleStatus.Ready,
|
status: ShareLinkBundleStatus.Ready,
|
||||||
})
|
})
|
||||||
|
component.bundles.set([readyBundle])
|
||||||
|
fixture.detectChanges()
|
||||||
component.copy(readyBundle)
|
component.copy(readyBundle)
|
||||||
|
|
||||||
expect(clipboard.copy).toHaveBeenCalledWith(
|
expect(clipboard.copy).toHaveBeenCalledWith(
|
||||||
component.getShareUrl(readyBundle)
|
component.getShareUrl(readyBundle)
|
||||||
)
|
)
|
||||||
expect(component.copiedSlug()).toBe('ready-slug')
|
expect(component.copiedSlug()).toBe('ready-slug')
|
||||||
expect(toastService.showInfo).toHaveBeenCalled()
|
expect(toastService.showInfo).not.toHaveBeenCalled()
|
||||||
|
fixture.detectChanges()
|
||||||
|
expect(
|
||||||
|
fixture.nativeElement.querySelector('.badge.show').textContent
|
||||||
|
).toContain('Copied!')
|
||||||
|
|
||||||
jest.advanceTimersByTime(3000)
|
jest.advanceTimersByTime(3000)
|
||||||
expect(component.copiedSlug()).toBeNull()
|
expect(component.copiedSlug()).toBeNull()
|
||||||
|
fixture.detectChanges()
|
||||||
|
expect(fixture.nativeElement.querySelector('.badge.show')).toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('ignores copy requests for non-ready bundles', () => {
|
it('ignores copy requests for non-ready bundles', () => {
|
||||||
@@ -146,7 +202,7 @@ describe('ShareLinkBundleManageDialogComponent', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('deletes bundles and refreshes list', () => {
|
it('deletes bundles and refreshes list', () => {
|
||||||
service.listAllBundles.mockReturnValue(of([]))
|
service.list.mockReturnValue(of({ count: 0, results: [] }))
|
||||||
service.delete.mockReturnValue(of(true))
|
service.delete.mockReturnValue(of(true))
|
||||||
|
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
@@ -157,12 +213,12 @@ describe('ShareLinkBundleManageDialogComponent', () => {
|
|||||||
expect(toastService.showInfo).toHaveBeenCalledWith(
|
expect(toastService.showInfo).toHaveBeenCalledWith(
|
||||||
expect.stringContaining('deleted.')
|
expect.stringContaining('deleted.')
|
||||||
)
|
)
|
||||||
expect(service.listAllBundles).toHaveBeenCalledTimes(2)
|
expect(service.list).toHaveBeenCalledTimes(2)
|
||||||
expect(component.loading()).toBe(false)
|
expect(component.loading()).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('handles delete errors gracefully', () => {
|
it('handles delete errors gracefully', () => {
|
||||||
service.listAllBundles.mockReturnValue(of([]))
|
service.list.mockReturnValue(of({ count: 0, results: [] }))
|
||||||
service.delete.mockReturnValue(throwError(() => new Error('delete fail')))
|
service.delete.mockReturnValue(throwError(() => new Error('delete fail')))
|
||||||
|
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
@@ -174,7 +230,7 @@ describe('ShareLinkBundleManageDialogComponent', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('retries bundle build and replaces existing entry', () => {
|
it('retries bundle build and replaces existing entry', () => {
|
||||||
service.listAllBundles.mockReturnValue(of([]))
|
service.list.mockReturnValue(of({ count: 0, results: [] }))
|
||||||
const updated = sampleBundle({ status: ShareLinkBundleStatus.Ready })
|
const updated = sampleBundle({ status: ShareLinkBundleStatus.Ready })
|
||||||
service.rebuildBundle.mockReturnValue(of(updated))
|
service.rebuildBundle.mockReturnValue(of(updated))
|
||||||
|
|
||||||
@@ -189,7 +245,7 @@ describe('ShareLinkBundleManageDialogComponent', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('adds new bundle when retry returns unknown entry', () => {
|
it('adds new bundle when retry returns unknown entry', () => {
|
||||||
service.listAllBundles.mockReturnValue(of([]))
|
service.list.mockReturnValue(of({ count: 0, results: [] }))
|
||||||
service.rebuildBundle.mockReturnValue(
|
service.rebuildBundle.mockReturnValue(
|
||||||
of(sampleBundle({ id: 99, slug: 'new-slug' }))
|
of(sampleBundle({ id: 99, slug: 'new-slug' }))
|
||||||
)
|
)
|
||||||
@@ -203,7 +259,7 @@ describe('ShareLinkBundleManageDialogComponent', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('handles retry errors', () => {
|
it('handles retry errors', () => {
|
||||||
service.listAllBundles.mockReturnValue(of([]))
|
service.list.mockReturnValue(of({ count: 0, results: [] }))
|
||||||
service.rebuildBundle.mockReturnValue(throwError(() => new Error('fail')))
|
service.rebuildBundle.mockReturnValue(throwError(() => new Error('fail')))
|
||||||
|
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
@@ -213,8 +269,8 @@ describe('ShareLinkBundleManageDialogComponent', () => {
|
|||||||
expect(toastService.showError).toHaveBeenCalled()
|
expect(toastService.showError).toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('maps helpers and closes dialog', () => {
|
it('maps status and file version helpers', () => {
|
||||||
service.listAllBundles.mockReturnValue(of([]))
|
service.list.mockReturnValue(of({ count: 0, results: [] }))
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
|
|
||||||
expect(component.statusLabel(ShareLinkBundleStatus.Processing)).toContain(
|
expect(component.statusLabel(ShareLinkBundleStatus.Processing)).toContain(
|
||||||
@@ -227,9 +283,5 @@ describe('ShareLinkBundleManageDialogComponent', () => {
|
|||||||
environment.apiBaseUrl = 'https://example.com/api/'
|
environment.apiBaseUrl = 'https://example.com/api/'
|
||||||
const url = component.getShareUrl(sampleBundle({ slug: 'sluggy' }))
|
const url = component.getShareUrl(sampleBundle({ slug: 'sluggy' }))
|
||||||
expect(url).toBe('https://example.com/share/sluggy')
|
expect(url).toBe('https://example.com/share/sluggy')
|
||||||
|
|
||||||
const closeSpy = jest.spyOn(activeModal, 'close')
|
|
||||||
component.close()
|
|
||||||
expect(closeSpy).toHaveBeenCalled()
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
@@ -1,7 +1,11 @@
|
|||||||
import { Clipboard } from '@angular/cdk/clipboard'
|
import { Clipboard } from '@angular/cdk/clipboard'
|
||||||
import { CommonModule } from '@angular/common'
|
import { CommonModule } from '@angular/common'
|
||||||
import { Component, OnDestroy, OnInit, inject, signal } from '@angular/core'
|
import { Component, OnDestroy, OnInit, inject, signal } from '@angular/core'
|
||||||
import { NgbActiveModal, NgbPopoverModule } from '@ng-bootstrap/ng-bootstrap'
|
import { FormsModule } from '@angular/forms'
|
||||||
|
import {
|
||||||
|
NgbPaginationModule,
|
||||||
|
NgbPopoverModule,
|
||||||
|
} from '@ng-bootstrap/ng-bootstrap'
|
||||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||||
import { Subject, catchError, of, switchMap, takeUntil, timer } from 'rxjs'
|
import { Subject, catchError, of, switchMap, takeUntil, timer } from 'rxjs'
|
||||||
import { FileVersion } from 'src/app/data/share-link'
|
import { FileVersion } from 'src/app/data/share-link'
|
||||||
@@ -11,42 +15,77 @@ import {
|
|||||||
ShareLinkBundleStatus,
|
ShareLinkBundleStatus,
|
||||||
ShareLinkBundleSummary,
|
ShareLinkBundleSummary,
|
||||||
} from 'src/app/data/share-link-bundle'
|
} from 'src/app/data/share-link-bundle'
|
||||||
|
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
||||||
|
import {
|
||||||
|
SortEvent,
|
||||||
|
SortableDirective,
|
||||||
|
} from 'src/app/directives/sortable.directive'
|
||||||
import { FileSizePipe } from 'src/app/pipes/file-size.pipe'
|
import { FileSizePipe } from 'src/app/pipes/file-size.pipe'
|
||||||
import { ShareLinkBundleService } from 'src/app/services/rest/share-link-bundle.service'
|
import { ShareLinkBundleService } from 'src/app/services/rest/share-link-bundle.service'
|
||||||
|
import { SettingsService } from 'src/app/services/settings.service'
|
||||||
import { ToastService } from 'src/app/services/toast.service'
|
import { ToastService } from 'src/app/services/toast.service'
|
||||||
import { environment } from 'src/environments/environment'
|
import { environment } from 'src/environments/environment'
|
||||||
import { LoadingComponentWithPermissions } from '../../loading-component/loading.component'
|
import { ConfirmButtonComponent } from 'src/app/components/common/confirm-button/confirm-button.component'
|
||||||
import { ConfirmButtonComponent } from '../confirm-button/confirm-button.component'
|
import { LoadingComponentWithPermissions } from 'src/app/components/loading-component/loading.component'
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'pngx-share-link-bundle-manage-dialog',
|
selector: 'pngx-share-link-bundle-list',
|
||||||
templateUrl: './share-link-bundle-manage-dialog.component.html',
|
templateUrl: './share-link-bundle-list.component.html',
|
||||||
styleUrls: ['./share-link-bundle-manage-dialog.component.scss'],
|
styleUrls: ['./share-link-bundle-list.component.scss'],
|
||||||
imports: [
|
imports: [
|
||||||
ConfirmButtonComponent,
|
ConfirmButtonComponent,
|
||||||
CommonModule,
|
CommonModule,
|
||||||
|
FormsModule,
|
||||||
|
NgbPaginationModule,
|
||||||
NgbPopoverModule,
|
NgbPopoverModule,
|
||||||
NgxBootstrapIconsModule,
|
NgxBootstrapIconsModule,
|
||||||
|
SortableDirective,
|
||||||
FileSizePipe,
|
FileSizePipe,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class ShareLinkBundleManageDialogComponent
|
export class ShareLinkBundleListComponent
|
||||||
extends LoadingComponentWithPermissions
|
extends LoadingComponentWithPermissions
|
||||||
implements OnInit, OnDestroy
|
implements OnInit, OnDestroy
|
||||||
{
|
{
|
||||||
private readonly activeModal = inject(NgbActiveModal)
|
|
||||||
private readonly shareLinkBundleService = inject(ShareLinkBundleService)
|
private readonly shareLinkBundleService = inject(ShareLinkBundleService)
|
||||||
|
private readonly settingsService = inject(SettingsService)
|
||||||
private readonly toastService = inject(ToastService)
|
private readonly toastService = inject(ToastService)
|
||||||
private readonly clipboard = inject(Clipboard)
|
private readonly clipboard = inject(Clipboard)
|
||||||
|
|
||||||
title = $localize`Share link bundles`
|
|
||||||
readonly bundles = signal<ShareLinkBundleSummary[]>([])
|
readonly bundles = signal<ShareLinkBundleSummary[]>([])
|
||||||
readonly error = signal<string | null>(null)
|
readonly error = signal<string | null>(null)
|
||||||
readonly copiedSlug = signal<string | null>(null)
|
readonly copiedSlug = signal<string | null>(null)
|
||||||
|
readonly total = signal(0)
|
||||||
|
readonly page = signal(1)
|
||||||
|
readonly sortField = signal('created')
|
||||||
|
readonly sortReverse = signal(true)
|
||||||
|
|
||||||
readonly statuses = ShareLinkBundleStatus
|
readonly statuses = ShareLinkBundleStatus
|
||||||
readonly fileVersions = FileVersion
|
readonly fileVersions = FileVersion
|
||||||
|
|
||||||
|
get pageSize(): number {
|
||||||
|
return (
|
||||||
|
this.settingsService.get(SETTINGS_KEYS.OBJECT_LIST_SIZES)
|
||||||
|
?.share_link_bundles || 25
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
set pageSize(pageSize: number) {
|
||||||
|
this.settingsService.set(SETTINGS_KEYS.OBJECT_LIST_SIZES, {
|
||||||
|
...this.settingsService.get(SETTINGS_KEYS.OBJECT_LIST_SIZES),
|
||||||
|
share_link_bundles: pageSize,
|
||||||
|
})
|
||||||
|
this.settingsService.storeSettings().subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.page.set(1)
|
||||||
|
this.triggerRefresh(false)
|
||||||
|
},
|
||||||
|
error: (error) => {
|
||||||
|
this.toastService.showError($localize`Error saving settings`, error)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
private readonly refresh$ = new Subject<boolean>()
|
private readonly refresh$ = new Subject<boolean>()
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
@@ -57,25 +96,33 @@ export class ShareLinkBundleManageDialogComponent
|
|||||||
this.loading.set(true)
|
this.loading.set(true)
|
||||||
}
|
}
|
||||||
this.error.set(null)
|
this.error.set(null)
|
||||||
return this.shareLinkBundleService.listAllBundles().pipe(
|
return this.shareLinkBundleService
|
||||||
catchError((error) => {
|
.list(
|
||||||
if (!silent) {
|
this.page(),
|
||||||
this.loading.set(false)
|
this.pageSize,
|
||||||
}
|
this.sortField(),
|
||||||
this.error.set($localize`Failed to load share link bundles.`)
|
this.sortReverse()
|
||||||
this.toastService.showError(
|
)
|
||||||
$localize`Error retrieving share link bundles.`,
|
.pipe(
|
||||||
error
|
catchError((error) => {
|
||||||
)
|
if (!silent) {
|
||||||
return of(null)
|
this.loading.set(false)
|
||||||
})
|
}
|
||||||
)
|
this.error.set($localize`Failed to load share link bundles.`)
|
||||||
|
this.toastService.showError(
|
||||||
|
$localize`Error retrieving share link bundles.`,
|
||||||
|
error
|
||||||
|
)
|
||||||
|
return of(null)
|
||||||
|
})
|
||||||
|
)
|
||||||
}),
|
}),
|
||||||
takeUntil(this.unsubscribeNotifier)
|
takeUntil(this.unsubscribeNotifier)
|
||||||
)
|
)
|
||||||
.subscribe((results) => {
|
.subscribe((results) => {
|
||||||
if (results) {
|
if (results) {
|
||||||
this.bundles.set(results)
|
this.bundles.set(results.results)
|
||||||
|
this.total.set(results.count)
|
||||||
this.copiedSlug.set(null)
|
this.copiedSlug.set(null)
|
||||||
}
|
}
|
||||||
this.loading.set(false)
|
this.loading.set(false)
|
||||||
@@ -98,6 +145,18 @@ export class ShareLinkBundleManageDialogComponent
|
|||||||
}`
|
}`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setPage(page: number): void {
|
||||||
|
this.page.set(page)
|
||||||
|
this.triggerRefresh(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
onSort(event: SortEvent): void {
|
||||||
|
this.sortField.set(event.column || 'created')
|
||||||
|
this.sortReverse.set(event.column ? event.reverse : true)
|
||||||
|
this.page.set(1)
|
||||||
|
this.triggerRefresh(false)
|
||||||
|
}
|
||||||
|
|
||||||
copy(bundle: ShareLinkBundleSummary): void {
|
copy(bundle: ShareLinkBundleSummary): void {
|
||||||
if (bundle.status !== ShareLinkBundleStatus.Ready) {
|
if (bundle.status !== ShareLinkBundleStatus.Ready) {
|
||||||
return
|
return
|
||||||
@@ -108,7 +167,6 @@ export class ShareLinkBundleManageDialogComponent
|
|||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
this.copiedSlug.set(null)
|
this.copiedSlug.set(null)
|
||||||
}, 3000)
|
}, 3000)
|
||||||
this.toastService.showInfo($localize`Share link copied to clipboard.`)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -117,6 +175,9 @@ export class ShareLinkBundleManageDialogComponent
|
|||||||
this.loading.set(true)
|
this.loading.set(true)
|
||||||
this.shareLinkBundleService.delete(bundle).subscribe({
|
this.shareLinkBundleService.delete(bundle).subscribe({
|
||||||
next: () => {
|
next: () => {
|
||||||
|
if (this.bundles().length === 1 && this.page() > 1) {
|
||||||
|
this.page.update((page) => page - 1)
|
||||||
|
}
|
||||||
this.toastService.showInfo($localize`Share link bundle deleted.`)
|
this.toastService.showInfo($localize`Share link bundle deleted.`)
|
||||||
this.triggerRefresh(false)
|
this.triggerRefresh(false)
|
||||||
},
|
},
|
||||||
@@ -153,8 +214,8 @@ export class ShareLinkBundleManageDialogComponent
|
|||||||
return SHARE_LINK_BUNDLE_FILE_VERSION_LABELS[version] ?? version
|
return SHARE_LINK_BUNDLE_FILE_VERSION_LABELS[version] ?? version
|
||||||
}
|
}
|
||||||
|
|
||||||
close(): void {
|
isExpired(expiration?: string): boolean {
|
||||||
this.activeModal.close()
|
return !!expiration && Date.parse(expiration) <= Date.now()
|
||||||
}
|
}
|
||||||
|
|
||||||
private replaceBundle(updated: ShareLinkBundleSummary): void {
|
private replaceBundle(updated: ShareLinkBundleSummary): void {
|
||||||
@@ -0,0 +1,110 @@
|
|||||||
|
<div class="border border-top-0 rounded-bottom p-3">
|
||||||
|
@if (!loading() && error()) {
|
||||||
|
<div class="alert alert-danger mb-0" role="alert">{{ error() }}</div>
|
||||||
|
}
|
||||||
|
@if (!loading() && !error() && links().length === 0) {
|
||||||
|
<p class="mb-0 text-muted fst-italic" i18n>
|
||||||
|
No document share links currently exist.
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
@if (!loading() && !error() && links().length > 0) {
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="table table-sm align-middle mb-0 bg-body">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th scope="col" class="fw-normal" pngxSortable="document__title" [currentSortField]="sortField()" [currentSortReverse]="sortReverse()" (sort)="onSort($event)" i18n>Document</th>
|
||||||
|
<th scope="col" class="fw-normal" pngxSortable="created" [currentSortField]="sortField()" [currentSortReverse]="sortReverse()" (sort)="onSort($event)" i18n>Created</th>
|
||||||
|
<th scope="col" class="fw-normal" pngxSortable="expiration" [currentSortField]="sortField()" [currentSortReverse]="sortReverse()" (sort)="onSort($event)" i18n>Expires</th>
|
||||||
|
<th scope="col" i18n>File version</th>
|
||||||
|
<th scope="col" class="text-end" i18n>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
@for (link of links(); track link.id) {
|
||||||
|
<tr>
|
||||||
|
<td>
|
||||||
|
<a routerLink="/documents/{{ link.document }}">{{ link.document_title | documentTitle }}</a>
|
||||||
|
<span class="badge bg-primary text-primary-text-contrast ms-3 small fs-normal cursor-pointer" (click)="copyDocumentID(link.document)">
|
||||||
|
@if (copiedDocumentID() === link.document) {
|
||||||
|
<i-bs width="1em" height="1em" name="clipboard-check" class="me-1"></i-bs><ng-container i18n>Copied!</ng-container>
|
||||||
|
} @else {
|
||||||
|
ID: {{link.document}}
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td>{{ link.created | date: 'short' }}</td>
|
||||||
|
<td>
|
||||||
|
@if (link.expiration) {
|
||||||
|
{{ link.expiration | date: 'short' }}
|
||||||
|
@if (isExpired(link.expiration)) {
|
||||||
|
<span class="badge text-bg-danger ms-2" i18n>Expired</span>
|
||||||
|
}
|
||||||
|
} @else {
|
||||||
|
<span i18n>Never</span>
|
||||||
|
}
|
||||||
|
</td>
|
||||||
|
<td>{{ fileVersionLabel(link.file_version) }}</td>
|
||||||
|
<td class="text-end">
|
||||||
|
<div class="d-inline-block position-relative">
|
||||||
|
<span
|
||||||
|
class="badge bg-primary small fade position-absolute top-50 end-100 translate-middle-y me-2 pe-none z-3 text-nowrap"
|
||||||
|
[class.show]="copiedID() === link.id"
|
||||||
|
i18n
|
||||||
|
>Copied!</span>
|
||||||
|
<div class="btn-group btn-group-sm">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-outline-primary"
|
||||||
|
(click)="copy(link)"
|
||||||
|
title="Copy share link"
|
||||||
|
i18n-title
|
||||||
|
>
|
||||||
|
@if (copiedID() === link.id) {
|
||||||
|
<i-bs name="clipboard-check"></i-bs>
|
||||||
|
} @else {
|
||||||
|
<i-bs name="clipboard"></i-bs>
|
||||||
|
}
|
||||||
|
<span class="visually-hidden" i18n>Copy share link</span>
|
||||||
|
</button>
|
||||||
|
<pngx-confirm-button
|
||||||
|
*pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.ShareLink }"
|
||||||
|
buttonClasses="btn btn-sm btn-outline-danger"
|
||||||
|
(confirm)="delete(link)"
|
||||||
|
iconName="trash"
|
||||||
|
>
|
||||||
|
<span class="visually-hidden" i18n>Delete share link</span>
|
||||||
|
</pngx-confirm-button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div class="d-flex flex-wrap justify-content-end align-items-center gap-3 mt-3 ms-auto">
|
||||||
|
<div class="d-flex align-items-center">
|
||||||
|
<label class="small text-muted me-2" for="shareLinkPageSize" i18n>Show:</label>
|
||||||
|
<select id="shareLinkPageSize" class="form-select form-select-sm w-auto" [(ngModel)]="pageSize">
|
||||||
|
<option [ngValue]="25">25</option>
|
||||||
|
<option [ngValue]="50">50</option>
|
||||||
|
<option [ngValue]="100">100</option>
|
||||||
|
</select>
|
||||||
|
<span class="small text-muted ms-2 d-none d-md-inline" i18n>per page</span>
|
||||||
|
</div>
|
||||||
|
@if (total() > pageSize) {
|
||||||
|
<ngb-pagination
|
||||||
|
class="mb-0"
|
||||||
|
[pageSize]="pageSize"
|
||||||
|
[collectionSize]="total()"
|
||||||
|
[page]="page()"
|
||||||
|
[maxSize]="5"
|
||||||
|
(pageChange)="setPage($event)"
|
||||||
|
size="sm"
|
||||||
|
aria-label="Share links pagination"
|
||||||
|
i18n-aria-label
|
||||||
|
></ngb-pagination>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,155 @@
|
|||||||
|
import { Clipboard } from '@angular/cdk/clipboard'
|
||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||||
|
import { RouterTestingModule } from '@angular/router/testing'
|
||||||
|
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
||||||
|
import { of, throwError } from 'rxjs'
|
||||||
|
import { FileVersion, ShareLink } from 'src/app/data/share-link'
|
||||||
|
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
||||||
|
import { ShareLinkService } from 'src/app/services/rest/share-link.service'
|
||||||
|
import { SettingsService } from 'src/app/services/settings.service'
|
||||||
|
import { ToastService } from 'src/app/services/toast.service'
|
||||||
|
import { ShareLinkListComponent } from './share-link-list.component'
|
||||||
|
|
||||||
|
describe('ShareLinkListComponent', () => {
|
||||||
|
let component: ShareLinkListComponent
|
||||||
|
let fixture: ComponentFixture<ShareLinkListComponent>
|
||||||
|
let service: jest.Mocked<Pick<ShareLinkService, 'list' | 'delete'>>
|
||||||
|
let clipboard: Clipboard
|
||||||
|
let toastService: jest.Mocked<Pick<ToastService, 'showInfo' | 'showError'>>
|
||||||
|
|
||||||
|
const link = {
|
||||||
|
id: 1,
|
||||||
|
document: 42,
|
||||||
|
document_title: 'Test document',
|
||||||
|
slug: 'share-slug',
|
||||||
|
created: new Date().toISOString(),
|
||||||
|
expiration: null,
|
||||||
|
file_version: FileVersion.Archive,
|
||||||
|
} as ShareLink
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
service = {
|
||||||
|
list: jest.fn().mockReturnValue(of({ count: 1, results: [link] })),
|
||||||
|
delete: jest.fn().mockReturnValue(of(true)),
|
||||||
|
}
|
||||||
|
toastService = {
|
||||||
|
showInfo: jest.fn(),
|
||||||
|
showError: jest.fn(),
|
||||||
|
}
|
||||||
|
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
imports: [
|
||||||
|
ShareLinkListComponent,
|
||||||
|
NgxBootstrapIconsModule.pick(allIcons),
|
||||||
|
RouterTestingModule,
|
||||||
|
],
|
||||||
|
providers: [
|
||||||
|
{ provide: ShareLinkService, useValue: service },
|
||||||
|
{ provide: ToastService, useValue: toastService },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(ShareLinkListComponent)
|
||||||
|
component = fixture.componentInstance
|
||||||
|
clipboard = TestBed.inject(Clipboard)
|
||||||
|
})
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
jest.clearAllTimers()
|
||||||
|
jest.useRealTimers()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('loads and renders document share links', () => {
|
||||||
|
fixture.detectChanges()
|
||||||
|
|
||||||
|
expect(service.list).toHaveBeenCalledWith(1, 25, 'created', true)
|
||||||
|
expect(component.links()).toEqual([link])
|
||||||
|
expect(fixture.nativeElement.textContent).toContain('Test document')
|
||||||
|
expect(fixture.nativeElement.textContent).toContain('ID: 42')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('loads another page', () => {
|
||||||
|
fixture.detectChanges()
|
||||||
|
component.setPage(2)
|
||||||
|
|
||||||
|
expect(service.list).toHaveBeenLastCalledWith(2, 25, 'created', true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('sorts links and returns to the first page', () => {
|
||||||
|
fixture.detectChanges()
|
||||||
|
component.page.set(2)
|
||||||
|
|
||||||
|
component.onSort({ column: 'expiration', reverse: false })
|
||||||
|
|
||||||
|
expect(component.page()).toBe(1)
|
||||||
|
expect(service.list).toHaveBeenLastCalledWith(1, 25, 'expiration', false)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('marks expired share links', () => {
|
||||||
|
service.list.mockReturnValue(
|
||||||
|
of({
|
||||||
|
count: 1,
|
||||||
|
results: [
|
||||||
|
{
|
||||||
|
...link,
|
||||||
|
expiration: '2000-01-01T00:00:00.000Z',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
})
|
||||||
|
)
|
||||||
|
|
||||||
|
fixture.detectChanges()
|
||||||
|
|
||||||
|
expect(fixture.nativeElement.textContent).toContain('Expired')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('stores a changed page size and reloads from the first page', () => {
|
||||||
|
const settingsService = TestBed.inject(SettingsService)
|
||||||
|
jest.spyOn(settingsService, 'get').mockReturnValueOnce({ share_links: 25 })
|
||||||
|
const setSpy = jest.spyOn(settingsService, 'set')
|
||||||
|
jest.spyOn(settingsService, 'storeSettings').mockReturnValue(of({}))
|
||||||
|
const reloadSpy = jest.spyOn(component, 'reload')
|
||||||
|
component.page.set(2)
|
||||||
|
|
||||||
|
component.pageSize = 50
|
||||||
|
|
||||||
|
expect(setSpy).toHaveBeenCalledWith(SETTINGS_KEYS.OBJECT_LIST_SIZES, {
|
||||||
|
share_links: 50,
|
||||||
|
})
|
||||||
|
expect(component.page()).toBe(1)
|
||||||
|
expect(reloadSpy).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows local copy feedback without a toast', () => {
|
||||||
|
jest.useFakeTimers()
|
||||||
|
jest.spyOn(clipboard, 'copy').mockReturnValue(true)
|
||||||
|
fixture.detectChanges()
|
||||||
|
|
||||||
|
component.copy(link)
|
||||||
|
fixture.detectChanges()
|
||||||
|
|
||||||
|
expect(component.copiedID()).toBe(link.id)
|
||||||
|
expect(fixture.nativeElement.querySelector('.badge.show')).not.toBeNull()
|
||||||
|
expect(toastService.showInfo).not.toHaveBeenCalled()
|
||||||
|
|
||||||
|
jest.advanceTimersByTime(3000)
|
||||||
|
expect(component.copiedID()).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('deletes a link and reloads the list', () => {
|
||||||
|
fixture.detectChanges()
|
||||||
|
component.delete(link)
|
||||||
|
|
||||||
|
expect(service.delete).toHaveBeenCalledWith(link)
|
||||||
|
expect(service.list).toHaveBeenCalledTimes(2)
|
||||||
|
expect(toastService.showInfo).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('shows an error when loading fails', () => {
|
||||||
|
service.list.mockReturnValue(throwError(() => new Error('load failed')))
|
||||||
|
fixture.detectChanges()
|
||||||
|
|
||||||
|
expect(component.error()).toContain('Failed to load share links.')
|
||||||
|
expect(toastService.showError).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,172 @@
|
|||||||
|
import { Clipboard } from '@angular/cdk/clipboard'
|
||||||
|
import { CommonModule } from '@angular/common'
|
||||||
|
import { Component, OnInit, inject, signal } from '@angular/core'
|
||||||
|
import { FormsModule } from '@angular/forms'
|
||||||
|
import { RouterModule } from '@angular/router'
|
||||||
|
import { NgbPaginationModule } from '@ng-bootstrap/ng-bootstrap'
|
||||||
|
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||||
|
import { takeUntil } from 'rxjs'
|
||||||
|
import { ConfirmButtonComponent } from 'src/app/components/common/confirm-button/confirm-button.component'
|
||||||
|
import { LoadingComponentWithPermissions } from 'src/app/components/loading-component/loading.component'
|
||||||
|
import { FileVersion, ShareLink } from 'src/app/data/share-link'
|
||||||
|
import { SHARE_LINK_BUNDLE_FILE_VERSION_LABELS } from 'src/app/data/share-link-bundle'
|
||||||
|
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
||||||
|
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
||||||
|
import {
|
||||||
|
SortEvent,
|
||||||
|
SortableDirective,
|
||||||
|
} from 'src/app/directives/sortable.directive'
|
||||||
|
import { DocumentTitlePipe } from 'src/app/pipes/document-title.pipe'
|
||||||
|
import {
|
||||||
|
PermissionAction,
|
||||||
|
PermissionType,
|
||||||
|
} from 'src/app/services/permissions.service'
|
||||||
|
import { ShareLinkService } from 'src/app/services/rest/share-link.service'
|
||||||
|
import { SettingsService } from 'src/app/services/settings.service'
|
||||||
|
import { ToastService } from 'src/app/services/toast.service'
|
||||||
|
import { environment } from 'src/environments/environment'
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'pngx-share-link-list',
|
||||||
|
templateUrl: './share-link-list.component.html',
|
||||||
|
imports: [
|
||||||
|
CommonModule,
|
||||||
|
ConfirmButtonComponent,
|
||||||
|
DocumentTitlePipe,
|
||||||
|
FormsModule,
|
||||||
|
IfPermissionsDirective,
|
||||||
|
NgbPaginationModule,
|
||||||
|
NgxBootstrapIconsModule,
|
||||||
|
RouterModule,
|
||||||
|
SortableDirective,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class ShareLinkListComponent
|
||||||
|
extends LoadingComponentWithPermissions
|
||||||
|
implements OnInit
|
||||||
|
{
|
||||||
|
private readonly clipboard = inject(Clipboard)
|
||||||
|
private readonly shareLinkService = inject(ShareLinkService)
|
||||||
|
private readonly settingsService = inject(SettingsService)
|
||||||
|
private readonly toastService = inject(ToastService)
|
||||||
|
|
||||||
|
readonly links = signal<ShareLink[]>([])
|
||||||
|
readonly total = signal(0)
|
||||||
|
readonly page = signal(1)
|
||||||
|
readonly sortField = signal('created')
|
||||||
|
readonly sortReverse = signal(true)
|
||||||
|
readonly copiedID = signal<number | null>(null)
|
||||||
|
readonly copiedDocumentID = signal<number | null>(null)
|
||||||
|
readonly error = signal<string | null>(null)
|
||||||
|
readonly PermissionAction = PermissionAction
|
||||||
|
readonly PermissionType = PermissionType
|
||||||
|
|
||||||
|
get pageSize(): number {
|
||||||
|
return (
|
||||||
|
this.settingsService.get(SETTINGS_KEYS.OBJECT_LIST_SIZES)?.share_links ||
|
||||||
|
25
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
set pageSize(pageSize: number) {
|
||||||
|
this.settingsService.set(SETTINGS_KEYS.OBJECT_LIST_SIZES, {
|
||||||
|
...this.settingsService.get(SETTINGS_KEYS.OBJECT_LIST_SIZES),
|
||||||
|
share_links: pageSize,
|
||||||
|
})
|
||||||
|
this.settingsService.storeSettings().subscribe({
|
||||||
|
next: () => {
|
||||||
|
this.page.set(1)
|
||||||
|
this.reload()
|
||||||
|
},
|
||||||
|
error: (error) => {
|
||||||
|
this.toastService.showError($localize`Error saving settings`, error)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
ngOnInit(): void {
|
||||||
|
this.reload()
|
||||||
|
}
|
||||||
|
|
||||||
|
reload(): void {
|
||||||
|
this.loading.set(true)
|
||||||
|
this.error.set(null)
|
||||||
|
this.shareLinkService
|
||||||
|
.list(this.page(), this.pageSize, this.sortField(), this.sortReverse())
|
||||||
|
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||||
|
.subscribe({
|
||||||
|
next: (results) => {
|
||||||
|
this.links.set(results.results)
|
||||||
|
this.total.set(results.count)
|
||||||
|
this.loading.set(false)
|
||||||
|
},
|
||||||
|
error: (error) => {
|
||||||
|
this.loading.set(false)
|
||||||
|
this.error.set($localize`Failed to load share links.`)
|
||||||
|
this.toastService.showError(
|
||||||
|
$localize`Error retrieving share links.`,
|
||||||
|
error
|
||||||
|
)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
setPage(page: number): void {
|
||||||
|
this.page.set(page)
|
||||||
|
this.reload()
|
||||||
|
}
|
||||||
|
|
||||||
|
onSort(event: SortEvent): void {
|
||||||
|
this.sortField.set(event.column || 'created')
|
||||||
|
this.sortReverse.set(event.column ? event.reverse : true)
|
||||||
|
this.page.set(1)
|
||||||
|
this.reload()
|
||||||
|
}
|
||||||
|
|
||||||
|
getShareUrl(link: ShareLink): string {
|
||||||
|
const apiURL = new URL(environment.apiBaseUrl)
|
||||||
|
return `${apiURL.origin}${apiURL.pathname.replace(/\/api\/$/, '/share/')}${
|
||||||
|
link.slug
|
||||||
|
}`
|
||||||
|
}
|
||||||
|
|
||||||
|
fileVersionLabel(version: FileVersion): string {
|
||||||
|
return SHARE_LINK_BUNDLE_FILE_VERSION_LABELS[version] ?? version
|
||||||
|
}
|
||||||
|
|
||||||
|
isExpired(expiration?: string): boolean {
|
||||||
|
return !!expiration && Date.parse(expiration) <= Date.now()
|
||||||
|
}
|
||||||
|
|
||||||
|
copy(link: ShareLink): void {
|
||||||
|
if (this.clipboard.copy(this.getShareUrl(link))) {
|
||||||
|
this.copiedID.set(link.id)
|
||||||
|
setTimeout(() => this.copiedID.set(null), 3000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
delete(link: ShareLink): void {
|
||||||
|
this.shareLinkService.delete(link).subscribe({
|
||||||
|
next: () => {
|
||||||
|
if (this.links().length === 1 && this.page() > 1) {
|
||||||
|
this.page.update((page) => page - 1)
|
||||||
|
}
|
||||||
|
this.toastService.showInfo($localize`Share link deleted.`)
|
||||||
|
this.reload()
|
||||||
|
},
|
||||||
|
error: (error) => {
|
||||||
|
this.toastService.showError(
|
||||||
|
$localize`Error deleting share link.`,
|
||||||
|
error
|
||||||
|
)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
copyDocumentID(documentID: number): void {
|
||||||
|
if (this.clipboard.copy(documentID.toString())) {
|
||||||
|
this.copiedDocumentID.set(documentID)
|
||||||
|
setTimeout(() => this.copiedDocumentID.set(null), 3000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
<pngx-page-header
|
||||||
|
title="Share links"
|
||||||
|
i18n-title
|
||||||
|
info="Manage public links to individual documents and document bundles."
|
||||||
|
i18n-info
|
||||||
|
[loading]="loading()"
|
||||||
|
></pngx-page-header>
|
||||||
|
|
||||||
|
<ul
|
||||||
|
ngbNav
|
||||||
|
#nav="ngbNav"
|
||||||
|
class="nav-tabs"
|
||||||
|
[activeId]="activeNavID()"
|
||||||
|
(activeIdChange)="selectTab($event)"
|
||||||
|
>
|
||||||
|
@if (canViewDocumentLinks) {
|
||||||
|
<li [ngbNavItem]="ShareLinksNavIDs.DocumentLinks">
|
||||||
|
<button ngbNavLink i18n>Document links</button>
|
||||||
|
<ng-template ngbNavContent>
|
||||||
|
<pngx-share-link-list></pngx-share-link-list>
|
||||||
|
</ng-template>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
@if (canViewBundles) {
|
||||||
|
<li [ngbNavItem]="ShareLinksNavIDs.Bundles">
|
||||||
|
<button ngbNavLink i18n>Bundles</button>
|
||||||
|
<ng-template ngbNavContent>
|
||||||
|
<pngx-share-link-bundle-list></pngx-share-link-bundle-list>
|
||||||
|
</ng-template>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div class="bg-body" [ngbNavOutlet]="nav"></div>
|
||||||
@@ -0,0 +1,102 @@
|
|||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||||
|
import { ActivatedRoute, convertToParamMap, Router } from '@angular/router'
|
||||||
|
import { NgbNavModule } from '@ng-bootstrap/ng-bootstrap'
|
||||||
|
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
||||||
|
import { of } from 'rxjs'
|
||||||
|
import {
|
||||||
|
PermissionAction,
|
||||||
|
PermissionsService,
|
||||||
|
PermissionType,
|
||||||
|
} from 'src/app/services/permissions.service'
|
||||||
|
import { ShareLinkBundleService } from 'src/app/services/rest/share-link-bundle.service'
|
||||||
|
import { ShareLinkService } from 'src/app/services/rest/share-link.service'
|
||||||
|
import { ToastService } from 'src/app/services/toast.service'
|
||||||
|
import { PageHeaderComponent } from '../../common/page-header/page-header.component'
|
||||||
|
import { ShareLinksComponent, ShareLinksNavIDs } from './share-links.component'
|
||||||
|
|
||||||
|
describe('ShareLinksComponent', () => {
|
||||||
|
let fixture: ComponentFixture<ShareLinksComponent>
|
||||||
|
let permissionsService: PermissionsService
|
||||||
|
let router: Router
|
||||||
|
|
||||||
|
const configure = async (type: string = null) => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [
|
||||||
|
ShareLinksComponent,
|
||||||
|
NgbNavModule,
|
||||||
|
NgxBootstrapIconsModule.pick(allIcons),
|
||||||
|
PageHeaderComponent,
|
||||||
|
],
|
||||||
|
providers: [
|
||||||
|
PermissionsService,
|
||||||
|
{
|
||||||
|
provide: ActivatedRoute,
|
||||||
|
useValue: {
|
||||||
|
snapshot: { queryParamMap: convertToParamMap({ type }) },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: Router,
|
||||||
|
useValue: { navigate: jest.fn().mockResolvedValue(true) },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: ShareLinkBundleService,
|
||||||
|
useValue: {
|
||||||
|
list: jest.fn().mockReturnValue(of({ count: 0, results: [] })),
|
||||||
|
rebuildBundle: jest.fn(),
|
||||||
|
delete: jest.fn(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: ShareLinkService,
|
||||||
|
useValue: {
|
||||||
|
list: jest.fn().mockReturnValue(of({ count: 0, results: [] })),
|
||||||
|
delete: jest.fn(),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: ToastService,
|
||||||
|
useValue: { showInfo: jest.fn(), showError: jest.fn() },
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}).compileComponents()
|
||||||
|
|
||||||
|
permissionsService = TestBed.inject(PermissionsService)
|
||||||
|
router = TestBed.inject(Router)
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => TestBed.resetTestingModule())
|
||||||
|
|
||||||
|
it('uses the requested bundles tab when permitted', async () => {
|
||||||
|
await configure(ShareLinksNavIDs.Bundles)
|
||||||
|
jest
|
||||||
|
.spyOn(permissionsService, 'currentUserCan')
|
||||||
|
.mockImplementation(
|
||||||
|
(action, type) =>
|
||||||
|
action === PermissionAction.View &&
|
||||||
|
type === PermissionType.ShareLinkBundle
|
||||||
|
)
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(ShareLinksComponent)
|
||||||
|
fixture.detectChanges()
|
||||||
|
|
||||||
|
expect(fixture.componentInstance.activeNavID()).toBe(
|
||||||
|
ShareLinksNavIDs.Bundles
|
||||||
|
)
|
||||||
|
expect(fixture.nativeElement.textContent).not.toContain('Document links')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('updates the URL when a tab is selected', async () => {
|
||||||
|
await configure()
|
||||||
|
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(ShareLinksComponent)
|
||||||
|
fixture.componentInstance.selectTab(ShareLinksNavIDs.Bundles)
|
||||||
|
|
||||||
|
expect(router.navigate).toHaveBeenCalledWith([], {
|
||||||
|
relativeTo: TestBed.inject(ActivatedRoute),
|
||||||
|
queryParams: { type: ShareLinksNavIDs.Bundles },
|
||||||
|
queryParamsHandling: 'merge',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
import { Component, computed, inject, signal, viewChild } from '@angular/core'
|
||||||
|
import { ActivatedRoute, Router } from '@angular/router'
|
||||||
|
import { NgbNavModule } from '@ng-bootstrap/ng-bootstrap'
|
||||||
|
import {
|
||||||
|
PermissionAction,
|
||||||
|
PermissionsService,
|
||||||
|
PermissionType,
|
||||||
|
} from 'src/app/services/permissions.service'
|
||||||
|
import { PageHeaderComponent } from '../../common/page-header/page-header.component'
|
||||||
|
import { ShareLinkBundleListComponent } from './share-link-bundle-list/share-link-bundle-list.component'
|
||||||
|
import { ShareLinkListComponent } from './share-link-list/share-link-list.component'
|
||||||
|
|
||||||
|
export enum ShareLinksNavIDs {
|
||||||
|
DocumentLinks = 'documents',
|
||||||
|
Bundles = 'bundles',
|
||||||
|
}
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'pngx-share-links',
|
||||||
|
templateUrl: './share-links.component.html',
|
||||||
|
imports: [
|
||||||
|
NgbNavModule,
|
||||||
|
PageHeaderComponent,
|
||||||
|
ShareLinkBundleListComponent,
|
||||||
|
ShareLinkListComponent,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class ShareLinksComponent {
|
||||||
|
private readonly route = inject(ActivatedRoute)
|
||||||
|
private readonly router = inject(Router)
|
||||||
|
private readonly permissionsService = inject(PermissionsService)
|
||||||
|
|
||||||
|
readonly ShareLinksNavIDs = ShareLinksNavIDs
|
||||||
|
readonly activeNavID = signal(this.getInitialNavID())
|
||||||
|
private readonly documentLinks = viewChild(ShareLinkListComponent)
|
||||||
|
private readonly bundles = viewChild(ShareLinkBundleListComponent)
|
||||||
|
readonly loading = computed(() => {
|
||||||
|
const activeList =
|
||||||
|
this.activeNavID() === ShareLinksNavIDs.DocumentLinks
|
||||||
|
? this.documentLinks()
|
||||||
|
: this.bundles()
|
||||||
|
return activeList?.loading() ?? true
|
||||||
|
})
|
||||||
|
|
||||||
|
get canViewDocumentLinks(): boolean {
|
||||||
|
return this.permissionsService.currentUserCan(
|
||||||
|
PermissionAction.View,
|
||||||
|
PermissionType.ShareLink
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
get canViewBundles(): boolean {
|
||||||
|
return this.permissionsService.currentUserCan(
|
||||||
|
PermissionAction.View,
|
||||||
|
PermissionType.ShareLinkBundle
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
selectTab(tab: ShareLinksNavIDs): void {
|
||||||
|
this.activeNavID.set(tab)
|
||||||
|
void this.router.navigate([], {
|
||||||
|
relativeTo: this.route,
|
||||||
|
queryParams: { type: tab },
|
||||||
|
queryParamsHandling: 'merge',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private getInitialNavID(): ShareLinksNavIDs {
|
||||||
|
const requestedTab = this.route.snapshot.queryParamMap.get('type')
|
||||||
|
if (requestedTab === ShareLinksNavIDs.Bundles && this.canViewBundles) {
|
||||||
|
return ShareLinksNavIDs.Bundles
|
||||||
|
}
|
||||||
|
if (this.canViewDocumentLinks) {
|
||||||
|
return ShareLinksNavIDs.DocumentLinks
|
||||||
|
}
|
||||||
|
return ShareLinksNavIDs.Bundles
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -49,7 +49,6 @@ export const FILTER_MODIFIED_AFTER = 16
|
|||||||
export const FILTER_TITLE_CONTENT = 19 // Deprecated in favor of Tantivy-backed `text` filtervar. Keep for now for existing saved views
|
export const FILTER_TITLE_CONTENT = 19 // Deprecated in favor of Tantivy-backed `text` filtervar. Keep for now for existing saved views
|
||||||
export const FILTER_SIMPLE_TITLE = 48
|
export const FILTER_SIMPLE_TITLE = 48
|
||||||
export const FILTER_SIMPLE_TEXT = 49
|
export const FILTER_SIMPLE_TEXT = 49
|
||||||
export const FILTER_HAS_DUPLICATES = 50
|
|
||||||
export const FILTER_FULLTEXT_QUERY = 20
|
export const FILTER_FULLTEXT_QUERY = 20
|
||||||
export const FILTER_FULLTEXT_MORELIKE = 21
|
export const FILTER_FULLTEXT_MORELIKE = 21
|
||||||
|
|
||||||
@@ -383,13 +382,6 @@ export const FILTER_RULE_TYPES: FilterRuleType[] = [
|
|||||||
datatype: 'string',
|
datatype: 'string',
|
||||||
multi: false,
|
multi: false,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
id: FILTER_HAS_DUPLICATES,
|
|
||||||
filtervar: 'has_duplicates',
|
|
||||||
datatype: 'boolean',
|
|
||||||
multi: false,
|
|
||||||
default: true,
|
|
||||||
},
|
|
||||||
]
|
]
|
||||||
|
|
||||||
export interface FilterRuleType {
|
export interface FilterRuleType {
|
||||||
|
|||||||
@@ -422,7 +422,6 @@ export const PaperlessConfigOptions: ConfigOption[] = [
|
|||||||
]
|
]
|
||||||
|
|
||||||
export interface PaperlessConfig extends ObjectWithId {
|
export interface PaperlessConfig extends ObjectWithId {
|
||||||
externally_configured_variables: string[]
|
|
||||||
output_type: OutputTypeConfig
|
output_type: OutputTypeConfig
|
||||||
pages: number
|
pages: number
|
||||||
language: string
|
language: string
|
||||||
|
|||||||
@@ -26,5 +26,7 @@ export interface ShareLink extends ObjectWithPermissions {
|
|||||||
|
|
||||||
document: number // Document
|
document: number // Document
|
||||||
|
|
||||||
|
document_title?: string
|
||||||
|
|
||||||
file_version: string
|
file_version: string
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,16 +24,6 @@ export enum CollapsibleSection {
|
|||||||
ATTRIBUTES = 'attributes',
|
ATTRIBUTES = 'attributes',
|
||||||
}
|
}
|
||||||
|
|
||||||
export enum HideableSidebarItemID {
|
|
||||||
Dashboard = 'dashboard',
|
|
||||||
SavedViews = 'saved_views',
|
|
||||||
Workflows = 'workflows',
|
|
||||||
Mail = 'mail',
|
|
||||||
Documentation = 'documentation',
|
|
||||||
}
|
|
||||||
|
|
||||||
export const HIDEABLE_SIDEBAR_ITEM_IDS = Object.values(HideableSidebarItemID)
|
|
||||||
|
|
||||||
export const PAPERLESS_GREEN_HEX = '#17541f'
|
export const PAPERLESS_GREEN_HEX = '#17541f'
|
||||||
|
|
||||||
export const SETTINGS_KEYS = {
|
export const SETTINGS_KEYS = {
|
||||||
@@ -66,7 +56,6 @@ export const SETTINGS_KEYS = {
|
|||||||
NOTES_ENABLED: 'general-settings:notes-enabled',
|
NOTES_ENABLED: 'general-settings:notes-enabled',
|
||||||
AUDITLOG_ENABLED: 'general-settings:auditlog-enabled',
|
AUDITLOG_ENABLED: 'general-settings:auditlog-enabled',
|
||||||
SLIM_SIDEBAR: 'general-settings:slim-sidebar',
|
SLIM_SIDEBAR: 'general-settings:slim-sidebar',
|
||||||
SIDEBAR_HIDDEN_ITEMS: 'general-settings:sidebar:hidden-items',
|
|
||||||
ATTRIBUTES_SECTIONS_COLLAPSED:
|
ATTRIBUTES_SECTIONS_COLLAPSED:
|
||||||
'general-settings:attributes-sections-collapsed',
|
'general-settings:attributes-sections-collapsed',
|
||||||
UPDATE_CHECKING_ENABLED: 'general-settings:update-checking:enabled',
|
UPDATE_CHECKING_ENABLED: 'general-settings:update-checking:enabled',
|
||||||
@@ -95,8 +84,6 @@ export const SETTINGS_KEYS = {
|
|||||||
'general-settings:document-editing:remove-inbox-tags',
|
'general-settings:document-editing:remove-inbox-tags',
|
||||||
DOCUMENT_EDITING_OVERLAY_THUMBNAIL:
|
DOCUMENT_EDITING_OVERLAY_THUMBNAIL:
|
||||||
'general-settings:document-editing:overlay-thumbnail',
|
'general-settings:document-editing:overlay-thumbnail',
|
||||||
DOCUMENT_EDITING_AUTO_SUGGEST:
|
|
||||||
'general-settings:document-editing:auto-suggest',
|
|
||||||
DOCUMENT_DETAILS_HIDDEN_FIELDS:
|
DOCUMENT_DETAILS_HIDDEN_FIELDS:
|
||||||
'general-settings:document-details:hidden-fields',
|
'general-settings:document-details:hidden-fields',
|
||||||
SEARCH_DB_ONLY: 'general-settings:search:db-only',
|
SEARCH_DB_ONLY: 'general-settings:search:db-only',
|
||||||
@@ -138,11 +125,6 @@ export const SETTINGS: UiSetting[] = [
|
|||||||
type: 'boolean',
|
type: 'boolean',
|
||||||
default: false,
|
default: false,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
key: SETTINGS_KEYS.SIDEBAR_HIDDEN_ITEMS,
|
|
||||||
type: 'array',
|
|
||||||
default: [],
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
key: SETTINGS_KEYS.ATTRIBUTES_SECTIONS_COLLAPSED,
|
key: SETTINGS_KEYS.ATTRIBUTES_SECTIONS_COLLAPSED,
|
||||||
type: 'array',
|
type: 'array',
|
||||||
@@ -246,6 +228,8 @@ export const SETTINGS: UiSetting[] = [
|
|||||||
document_types: 25,
|
document_types: 25,
|
||||||
tags: 25,
|
tags: 25,
|
||||||
storage_paths: 25,
|
storage_paths: 25,
|
||||||
|
share_links: 25,
|
||||||
|
share_link_bundles: 25,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -318,11 +302,6 @@ export const SETTINGS: UiSetting[] = [
|
|||||||
type: 'boolean',
|
type: 'boolean',
|
||||||
default: true,
|
default: true,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
key: SETTINGS_KEYS.DOCUMENT_EDITING_AUTO_SUGGEST,
|
|
||||||
type: 'boolean',
|
|
||||||
default: true,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
key: SETTINGS_KEYS.DOCUMENT_DETAILS_HIDDEN_FIELDS,
|
key: SETTINGS_KEYS.DOCUMENT_DETAILS_HIDDEN_FIELDS,
|
||||||
type: 'array',
|
type: 'array',
|
||||||
|
|||||||
@@ -221,25 +221,6 @@ describe('OpenDocumentsService', () => {
|
|||||||
expect(openDocumentsService.getOpenDocuments()).toHaveLength(1)
|
expect(openDocumentsService.getOpenDocuments()).toHaveLength(1)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should refresh documents in place and keep unsaved edits', () => {
|
|
||||||
const openDoc = { ...documents[0] }
|
|
||||||
subscriptions.push(openDocumentsService.openDocument(openDoc).subscribe())
|
|
||||||
openDoc.title = 'Unsaved title'
|
|
||||||
openDocumentsService.setDirty(openDoc, true, { title: openDoc.title })
|
|
||||||
|
|
||||||
openDocumentsService.refreshDocument(openDoc.id)
|
|
||||||
httpTestingController
|
|
||||||
.expectOne(
|
|
||||||
`${environment.apiBaseUrl}documents/${openDoc.id}/?full_perms=true`
|
|
||||||
)
|
|
||||||
.flush({ ...documents[0], tags: [4] })
|
|
||||||
|
|
||||||
const refreshed = openDocumentsService.getOpenDocument(openDoc.id)
|
|
||||||
expect(refreshed).toBe(openDoc)
|
|
||||||
expect(refreshed.title).toEqual('Unsaved title')
|
|
||||||
expect(refreshed.tags).toEqual([4])
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should handle error on refresh documents', () => {
|
it('should handle error on refresh documents', () => {
|
||||||
subscriptions.push(
|
subscriptions.push(
|
||||||
openDocumentsService.openDocument(documents[1]).subscribe()
|
openDocumentsService.openDocument(documents[1]).subscribe()
|
||||||
|
|||||||
@@ -50,15 +50,7 @@ export class OpenDocumentsService {
|
|||||||
if (index > -1) {
|
if (index > -1) {
|
||||||
this.documentService.get(id).subscribe({
|
this.documentService.get(id).subscribe({
|
||||||
next: (doc) => {
|
next: (doc) => {
|
||||||
const openDoc = this.openDocuments.find((d) => d.id == id)
|
this.openDocuments[index] = doc
|
||||||
if (!openDoc) return
|
|
||||||
const unsavedEdits = Object.fromEntries(
|
|
||||||
(openDoc.__changedFields ?? []).map((field) => [
|
|
||||||
field,
|
|
||||||
openDoc[field],
|
|
||||||
])
|
|
||||||
)
|
|
||||||
Object.assign(openDoc, doc, unsavedEdits)
|
|
||||||
this.save()
|
this.save()
|
||||||
},
|
},
|
||||||
error: () => {
|
error: () => {
|
||||||
|
|||||||
@@ -48,13 +48,4 @@ describe('ShareLinkBundleService', () => {
|
|||||||
expect(req.request.body).toEqual({})
|
expect(req.request.body).toEqual({})
|
||||||
req.flush({})
|
req.flush({})
|
||||||
})
|
})
|
||||||
|
|
||||||
it('lists bundles with expected parameters', () => {
|
|
||||||
subscription = service.listAllBundles().subscribe()
|
|
||||||
const req = httpTestingController.expectOne(
|
|
||||||
`${environment.apiBaseUrl}${endpoint}/?page=1&page_size=1000&ordering=-created`
|
|
||||||
)
|
|
||||||
expect(req.request.method).toBe('GET')
|
|
||||||
req.flush({ results: [] })
|
|
||||||
})
|
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { Injectable } from '@angular/core'
|
import { Injectable } from '@angular/core'
|
||||||
import { Observable } from 'rxjs'
|
import { Observable } from 'rxjs'
|
||||||
import { map } from 'rxjs/operators'
|
|
||||||
import {
|
import {
|
||||||
ShareLinkBundleCreatePayload,
|
ShareLinkBundleCreatePayload,
|
||||||
ShareLinkBundleSummary,
|
ShareLinkBundleSummary,
|
||||||
@@ -32,10 +31,4 @@ export class ShareLinkBundleService extends AbstractNameFilterService<ShareLinkB
|
|||||||
{}
|
{}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
listAllBundles(): Observable<ShareLinkBundleSummary[]> {
|
|
||||||
return this.list(1, 1000, 'created', true).pipe(
|
|
||||||
map((response) => response.results)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,11 +14,7 @@ import { CustomFieldDataType } from '../data/custom-field'
|
|||||||
import { DEFAULT_DISPLAY_FIELDS, DisplayField } from '../data/document'
|
import { DEFAULT_DISPLAY_FIELDS, DisplayField } from '../data/document'
|
||||||
import { SavedView } from '../data/saved-view'
|
import { SavedView } from '../data/saved-view'
|
||||||
import { RemoteOCRModeConfig } from '../data/paperless-config'
|
import { RemoteOCRModeConfig } from '../data/paperless-config'
|
||||||
import {
|
import { SETTINGS_KEYS, UiSettings } from '../data/ui-settings'
|
||||||
HideableSidebarItemID,
|
|
||||||
SETTINGS_KEYS,
|
|
||||||
UiSettings,
|
|
||||||
} from '../data/ui-settings'
|
|
||||||
import { PermissionsService } from './permissions.service'
|
import { PermissionsService } from './permissions.service'
|
||||||
import { CustomFieldsService } from './rest/custom-fields.service'
|
import { CustomFieldsService } from './rest/custom-fields.service'
|
||||||
import { SettingsService } from './settings.service'
|
import { SettingsService } from './settings.service'
|
||||||
@@ -234,35 +230,6 @@ describe('SettingsService', () => {
|
|||||||
expect(notesEnabled()).toBeFalsy()
|
expect(notesEnabled()).toBeFalsy()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('updates sidebar item visibility', () => {
|
|
||||||
httpTestingController
|
|
||||||
.expectOne(`${environment.apiBaseUrl}ui_settings/`)
|
|
||||||
.flush(ui_settings)
|
|
||||||
|
|
||||||
expect(
|
|
||||||
settingsService.sidebarItemIsHidden(HideableSidebarItemID.Workflows)
|
|
||||||
).toBe(false)
|
|
||||||
|
|
||||||
settingsService.updateSidebarItemVisibility(
|
|
||||||
HideableSidebarItemID.Workflows,
|
|
||||||
false
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(
|
|
||||||
settingsService.sidebarItemIsHidden(HideableSidebarItemID.Workflows)
|
|
||||||
).toBe(true)
|
|
||||||
expect(settingsService.get(SETTINGS_KEYS.SIDEBAR_HIDDEN_ITEMS)).toEqual([])
|
|
||||||
|
|
||||||
settingsService.updateSidebarItemVisibility(
|
|
||||||
HideableSidebarItemID.Workflows,
|
|
||||||
true
|
|
||||||
)
|
|
||||||
|
|
||||||
expect(
|
|
||||||
settingsService.sidebarItemIsHidden(HideableSidebarItemID.Workflows)
|
|
||||||
).toBe(false)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('updates setting signals when settings are reinitialized', () => {
|
it('updates setting signals when settings are reinitialized', () => {
|
||||||
let req = httpTestingController.expectOne(
|
let req = httpTestingController.expectOne(
|
||||||
`${environment.apiBaseUrl}ui_settings/`
|
`${environment.apiBaseUrl}ui_settings/`
|
||||||
|
|||||||
@@ -24,7 +24,6 @@ import { DEFAULT_DISPLAY_FIELDS, DisplayField } from '../data/document'
|
|||||||
import { RemoteOCRModeConfig } from '../data/paperless-config'
|
import { RemoteOCRModeConfig } from '../data/paperless-config'
|
||||||
import { SavedView } from '../data/saved-view'
|
import { SavedView } from '../data/saved-view'
|
||||||
import {
|
import {
|
||||||
HideableSidebarItemID,
|
|
||||||
PAPERLESS_GREEN_HEX,
|
PAPERLESS_GREEN_HEX,
|
||||||
SETTINGS,
|
SETTINGS,
|
||||||
SETTINGS_KEYS,
|
SETTINGS_KEYS,
|
||||||
@@ -314,18 +313,6 @@ export class SettingsService {
|
|||||||
readonly globalDropzoneEnabled = signal(true)
|
readonly globalDropzoneEnabled = signal(true)
|
||||||
readonly globalDropzoneActive = signal(false)
|
readonly globalDropzoneActive = signal(false)
|
||||||
readonly organizingSidebarSavedViews = signal(false)
|
readonly organizingSidebarSavedViews = signal(false)
|
||||||
readonly sidebarHiddenItemsEditing = signal<HideableSidebarItemID[] | null>(
|
|
||||||
null
|
|
||||||
)
|
|
||||||
readonly organizingSidebarItems = computed(
|
|
||||||
() => this.sidebarHiddenItemsEditing() !== null
|
|
||||||
)
|
|
||||||
readonly sidebarHiddenItemsEditingChanged = new EventEmitter<
|
|
||||||
HideableSidebarItemID[]
|
|
||||||
>()
|
|
||||||
readonly hiddenSidebarItems = this.getSignal<HideableSidebarItemID[]>(
|
|
||||||
SETTINGS_KEYS.SIDEBAR_HIDDEN_ITEMS
|
|
||||||
)
|
|
||||||
|
|
||||||
readonly allDisplayFields = signal<Array<{ id: DisplayField; name: string }>>(
|
readonly allDisplayFields = signal<Array<{ id: DisplayField; name: string }>>(
|
||||||
DEFAULT_DISPLAY_FIELDS
|
DEFAULT_DISPLAY_FIELDS
|
||||||
@@ -762,29 +749,6 @@ export class SettingsService {
|
|||||||
return this.storeSettings()
|
return this.storeSettings()
|
||||||
}
|
}
|
||||||
|
|
||||||
sidebarItemIsHidden(item: HideableSidebarItemID): boolean {
|
|
||||||
return (
|
|
||||||
this.sidebarHiddenItemsEditing() ?? this.hiddenSidebarItems()
|
|
||||||
).includes(item)
|
|
||||||
}
|
|
||||||
|
|
||||||
updateSidebarItemVisibility(
|
|
||||||
item: HideableSidebarItemID,
|
|
||||||
visible: boolean
|
|
||||||
): void {
|
|
||||||
const hiddenItems = new Set(
|
|
||||||
this.sidebarHiddenItemsEditing() ?? this.hiddenSidebarItems()
|
|
||||||
)
|
|
||||||
if (visible) {
|
|
||||||
hiddenItems.delete(item)
|
|
||||||
} else {
|
|
||||||
hiddenItems.add(item)
|
|
||||||
}
|
|
||||||
const updatedHiddenItems = [...hiddenItems]
|
|
||||||
this.sidebarHiddenItemsEditing.set(updatedHiddenItems)
|
|
||||||
this.sidebarHiddenItemsEditingChanged.emit(updatedHiddenItems)
|
|
||||||
}
|
|
||||||
|
|
||||||
updateSavedViewsVisibility(
|
updateSavedViewsVisibility(
|
||||||
dashboardVisibleViewIds: number[],
|
dashboardVisibleViewIds: number[],
|
||||||
sidebarVisibleViewIds: number[]
|
sidebarVisibleViewIds: number[]
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import {
|
|||||||
FILTER_HAS_ANY_TAG,
|
FILTER_HAS_ANY_TAG,
|
||||||
FILTER_HAS_CUSTOM_FIELDS_ALL,
|
FILTER_HAS_CUSTOM_FIELDS_ALL,
|
||||||
FILTER_HAS_CUSTOM_FIELDS_ANY,
|
FILTER_HAS_CUSTOM_FIELDS_ANY,
|
||||||
FILTER_HAS_DUPLICATES,
|
|
||||||
FILTER_HAS_TAGS_ALL,
|
FILTER_HAS_TAGS_ALL,
|
||||||
FILTER_SIMPLE_TEXT,
|
FILTER_SIMPLE_TEXT,
|
||||||
FILTER_SIMPLE_TITLE,
|
FILTER_SIMPLE_TITLE,
|
||||||
@@ -133,16 +132,6 @@ describe('QueryParams Utils', () => {
|
|||||||
is_tagged: 0,
|
is_tagged: 0,
|
||||||
})
|
})
|
||||||
|
|
||||||
params = queryParamsFromFilterRules([
|
|
||||||
{
|
|
||||||
rule_type: FILTER_HAS_DUPLICATES,
|
|
||||||
value: 'false',
|
|
||||||
},
|
|
||||||
])
|
|
||||||
expect(params).toEqual({
|
|
||||||
has_duplicates: 0,
|
|
||||||
})
|
|
||||||
|
|
||||||
params = queryParamsFromFilterRules([
|
params = queryParamsFromFilterRules([
|
||||||
{
|
{
|
||||||
rule_type: FILTER_TITLE_CONTENT,
|
rule_type: FILTER_TITLE_CONTENT,
|
||||||
@@ -258,18 +247,6 @@ describe('QueryParams Utils', () => {
|
|||||||
},
|
},
|
||||||
])
|
])
|
||||||
|
|
||||||
rules = filterRulesFromQueryParams(
|
|
||||||
convertToParamMap({
|
|
||||||
has_duplicates: 'true',
|
|
||||||
})
|
|
||||||
)
|
|
||||||
expect(rules).toEqual([
|
|
||||||
{
|
|
||||||
rule_type: FILTER_HAS_DUPLICATES,
|
|
||||||
value: 'true',
|
|
||||||
},
|
|
||||||
])
|
|
||||||
|
|
||||||
rules = filterRulesFromQueryParams(
|
rules = filterRulesFromQueryParams(
|
||||||
convertToParamMap({
|
convertToParamMap({
|
||||||
correspondent__isnull: '1',
|
correspondent__isnull: '1',
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 7.6 KiB After Width: | Height: | Size: 6.1 KiB |
@@ -0,0 +1,3 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 198.4 238.9" style="enable-background:new 0 0 198.4 238.9" xml:space="preserve">
|
||||||
|
<path d="M194.7 0C164.211 70.943 17.64 79.733 64.55 194.06c.59 1.468-10.848 17-18.47 29.897-1.758-6.453-3.816-13.486-3.516-14.075 38.109-45.141-27.26-70.643-30.776-107.583-16.423 29.318-22.286 80.623 27.25 110.23.29 0 2.637 11.138 3.816 16.712-1.169 2.348-2.348 4.695-2.927 6.454-1.168 2.926 7.622 2.637 7.622 3.226.879-.29 21.697-36.94 22.276-37.23C187.667 174.711 208.485 68.596 194.699 0zm-60.096 74.749c-55.11 49.246-64.49 85.897-62.732 103.777-18.47-43.682 35.772-91.76 62.732-103.777zM28.2 145.102c10.548 9.67 28.14 39.278 13.196 56.58 3.506-7.912 4.684-25.793-13.196-56.58z"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 727 B |
@@ -0,0 +1,4 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 2897.4 896.6" style="enable-background:new 0 0 2897.4 896.6" xml:space="preserve">
|
||||||
|
<path d="M1022.3 428.7c-17.8-19.9-42.7-29.8-74.7-29.8-22.3 0-42.4 5.7-60.5 17.3-18.1 11.6-32.3 27.5-42.5 47.8s-15.3 42.9-15.3 67.8 5.1 47.5 15.3 67.8c10.3 20.3 24.4 36.2 42.5 47.8 18.1 11.5 38.3 17.3 60.5 17.3 32 0 56.9-9.9 74.7-29.8V655.5h84.5V408.3h-84.5v20.4zM1010.5 575c-10.2 11.7-23.6 17.6-40.2 17.6s-29.9-5.9-40-17.6-15.1-26.1-15.1-43.3c0-17.1 5-31.6 15.1-43.3s23.4-17.6 40-17.6 30 5.9 40.2 17.6 15.3 26.1 15.3 43.3-5.1 31.6-15.3 43.3zM1381 416.1c-18.1-11.5-38.3-17.3-60.5-17.4-32 0-56.9 9.9-74.7 29.8v-20.4h-84.5v390.7h84.5v-164c17.8 19.9 42.7 29.8 74.7 29.8 22.3 0 42.4-5.7 60.5-17.3s32.3-27.5 42.5-47.8c10.2-20.3 15.3-42.9 15.3-67.8s-5.1-47.5-15.3-67.8c-10.3-20.3-24.4-36.2-42.5-47.8zM1337.9 575c-10.1 11.7-23.4 17.6-40 17.6s-29.9-5.9-40-17.6-15.1-26.1-15.1-43.3c0-17.1 5-31.6 15.1-43.3s23.4-17.6 40-17.6 29.9 5.9 40 17.6 15.1 26.1 15.1 43.3-5.1 31.6-15.1 43.3zM1672.2 416.8c-20.5-12-43-18-67.6-18-24.9 0-47.6 5.9-68 17.6-20.4 11.7-36.5 27.7-48.2 48s-17.6 42.7-17.6 67.3c.3 25.2 6.2 47.8 17.8 68 11.5 20.2 28 36 49.3 47.6 21.3 11.5 45.9 17.3 73.8 17.3 48.6 0 86.8-14.7 114.7-44l-52.5-48.9c-8.6 8.3-17.6 14.6-26.7 19-9.3 4.3-21.1 6.4-35.3 6.4-11.6 0-22.5-3.6-32.7-10.9-10.3-7.3-17.1-16.5-20.7-27.8h180l.4-11.6c0-29.6-6-55.7-18-78.2s-28.3-39.8-48.7-51.8zm-113.9 86.4c2.1-12.1 7.5-21.8 16.2-29.1s18.7-10.9 30-10.9 21.2 3.6 29.8 10.9c8.6 7.2 13.9 16.9 16 29.1h-92zM1895.3 411.7c-11 5.6-20.3 13.7-28 24.4h-.1v-28h-84.5v247.3h84.5V536.3c0-22.6 4.7-38.1 14.2-46.5 9.5-8.5 22.7-12.7 39.6-12.7 6.2 0 13.5 1 21.8 3.1l10.7-72c-5.9-3.3-14.5-4.9-25.8-4.9-10.6 0-21.4 2.8-32.4 8.4zM1985 277.4h84.5v377.8H1985zM2313.2 416.8c-20.5-12-43-18-67.6-18-24.9 0-47.6 5.9-68 17.6s-36.5 27.7-48.2 48c-11.7 20.3-17.6 42.7-17.6 67.3.3 25.2 6.2 47.8 17.8 68 11.5 20.2 28 36 49.3 47.6 21.3 11.5 45.9 17.3 73.8 17.3 48.6 0 86.8-14.7 114.7-44l-52.5-48.9c-8.6 8.3-17.6 14.6-26.7 19-9.3 4.3-21.1 6.4-35.3 6.4-11.6 0-22.5-3.6-32.7-10.9-10.3-7.3-17.1-16.5-20.7-27.8h180l.4-11.6c0-29.6-6-55.7-18-78.2s-28.3-39.8-48.7-51.8zm-113.9 86.4c2.1-12.1 7.5-21.8 16.2-29.1s18.7-10.9 30-10.9 21.2 3.6 29.8 10.9c8.6 7.2 13.9 16.9 16 29.1h-92zM2583.6 507.7c-13.8-4.4-30.6-8.1-50.5-11.1-15.1-2.7-26.1-5.2-32.9-7.6-6.8-2.4-10.2-6.1-10.2-11.1s2.3-8.7 6.7-10.9c4.4-2.2 11.5-3.3 21.3-3.3 11.6 0 24.3 2.4 38.1 7.2 13.9 4.8 26.2 11 36.9 18.4l32.4-58.2c-11.3-7.4-26.2-14.7-44.9-21.8-18.7-7.1-39.6-10.7-62.7-10.7-33.7 0-60.2 7.6-79.3 22.7-19.1 15.1-28.7 36.1-28.7 63.1 0 19 4.8 33.9 14.4 44.7 9.6 10.8 21 18.5 34 22.9 13.1 4.5 28.9 8.3 47.6 11.6 14.6 2.7 25.1 5.3 31.6 7.8s9.8 6.5 9.8 11.8c0 10.4-9.7 15.6-29.3 15.6-13.7 0-28.5-2.3-44.7-6.9-16.1-4.6-29.2-11.3-39.3-20.2l-33.3 60c9.2 7.4 24.6 14.7 46.2 22 21.7 7.3 45.2 10.9 70.7 10.9 34.7 0 62.9-7.4 84.5-22.4 21.7-15 32.5-37.3 32.5-66.9 0-19.3-5-34.2-15.1-44.9s-22-18.3-35.8-22.7zM2883.4 575.3c0-19.3-5-34.2-15.1-44.9s-22-18.3-35.8-22.7c-13.8-4.4-30.6-8.1-50.5-11.1-15.1-2.7-26.1-5.2-32.9-7.6-6.8-2.4-10.2-6.1-10.2-11.1s2.3-8.7 6.7-10.9c4.4-2.2 11.5-3.3 21.3-3.3 11.6 0 24.3 2.4 38.1 7.2 13.9 4.8 26.2 11 36.9 18.4l32.4-58.2c-11.3-7.4-26.2-14.7-44.9-21.8-18.7-7.1-39.6-10.7-62.7-10.7-33.7 0-60.2 7.6-79.3 22.7-19.1 15.1-28.7 36.1-28.7 63.1 0 19 4.8 33.9 14.4 44.7 9.6 10.8 21 18.5 34 22.9 13.1 4.5 28.9 8.3 47.6 11.6 14.6 2.7 25.1 5.3 31.6 7.8s9.8 6.5 9.8 11.8c0 10.4-9.7 15.6-29.3 15.6-13.7 0-28.5-2.3-44.7-6.9-16.1-4.6-29.2-11.3-39.3-20.2l-33.3 60c9.2 7.4 24.6 14.7 46.2 22 21.7 7.3 45.2 10.9 70.7 10.9 34.7 0 62.9-7.4 84.5-22.4 21.7-15 32.5-37.3 32.5-66.9zM2460.7 738.7h59.6v17.2h-59.6zM2596.5 706.4c-5.7 0-11 1-15.8 3s-9 5-12.5 8.9v-9.4h-19.4v93.6h19.4v-52c0-8.6 2.1-15.3 6.3-20 4.2-4.7 9.5-7.1 15.9-7.1 7.8 0 13.4 2.3 16.8 6.7 3.4 4.5 5.1 11.3 5.1 20.5v52h19.4v-56.8c0-12.8-3.2-22.6-9.5-29.3-6.4-6.7-14.9-10.1-25.7-10.1zM2733.8 717.7c-3.6-3.4-7.9-6.1-13.1-8.2s-10.6-3.1-16.2-3.1c-8.7 0-16.5 2.1-23.5 6.3s-12.5 10-16.5 17.3c-4 7.3-6 15.4-6 24.4 0 8.9 2 17.1 6 24.3 4 7.3 9.5 13 16.5 17.2s14.9 6.3 23.5 6.3c5.6 0 11-1 16.2-3.1 5.1-2.1 9.5-4.8 13.1-8.2v24.4c0 8.5-2.5 14.8-7.6 18.7-5 3.9-11 5.9-18 5.9-6.7 0-12.4-1.6-17.3-4.7-4.8-3.1-7.6-7.7-8.3-13.8h-19.4c.6 7.7 2.9 14.2 7.1 19.5s9.6 9.3 16.2 12c6.6 2.7 13.8 4 21.7 4 12.8 0 23.5-3.4 32-10.1 8.6-6.7 12.8-17.1 12.8-31.1V708.9h-19.2v8.8zm-1.6 52.4c-2.5 4.7-6 8.3-10.4 11.2-4.4 2.7-9.4 4-14.9 4-5.7 0-10.8-1.4-15.2-4.3s-7.8-6.7-10.2-11.4c-2.3-4.8-3.5-9.8-3.5-15.2 0-5.5 1.1-10.6 3.5-15.3s5.8-8.5 10.2-11.3 9.5-4.2 15.2-4.2c5.5 0 10.5 1.4 14.9 4s7.9 6.3 10.4 11 3.8 10 3.8 15.8-1.3 11-3.8 15.7zM2867.9 708.9h-21.4l-25.6 33-25.4-33h-22.4l36 46.1-37.6 47.5h21.4l27.2-34.6 27.1 34.7h22.4l-37.6-48.2zM757.6 293.7c-20-10.8-42.6-16.2-67.8-16.2H600c-8.5 39.2-21.1 76.4-37.6 111.3-9.9 20.8-21.1 40.6-33.6 59.4v207.2h88.9V521.5h72c25.2 0 47.8-5.4 67.8-16.2s35.7-25.6 47.1-44.2c11.4-18.7 17.1-39.1 17.1-61.3.1-22.7-5.6-43.3-17-61.9-11.4-18.7-27.1-33.4-47.1-44.2zm-41 140.6c-9.3 8.9-21.6 13.3-36.7 13.3l-62.2.4v-92.5l62.2-.4c15.1 0 27.3 4.4 36.7 13.3 9.4 8.9 14 19.9 14 32.9 0 13.2-4.6 24.1-14 33z"/>
|
||||||
|
<path d="M140 713.7c-3.4-16.4-10.3-49.1-11.2-49.1C-16.9 577.5.4 426.6 48.6 340.4 59 449 251.2 524 139.1 656.8c-.9 1.7 5.2 22.4 10.3 41.4 22.4-37.9 56-83.6 54.3-87.9C65.9 273.9 496.9 248.1 586.6 39.4c40.5 201.8-20.7 513.9-367.2 593.2-1.7.9-62.9 108.6-65.5 109.5 0-1.7-25.9-.9-22.4-9.5 1.6-5.2 5.1-12 8.5-18.9zm-4.3-81.1c44-50.9-7.8-137.9-38.8-166.4 52.6 90.5 49.1 143.1 38.8 166.4z" style="fill:#17541f"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 5.4 KiB |
@@ -0,0 +1,3 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="264.567" height="318.552" viewBox="0 0 70 84.284">
|
||||||
|
<path style="fill:#17541f;stroke-width:1.10017" d="M752.438 82.365C638.02 348.605 87.938 381.61 263.964 810.674c2.2 5.5-40.706 63.81-69.31 112.217-6.602-24.204-14.304-50.607-13.204-52.807C324.473 700.658 79.136 604.944 65.934 466.322 4.324 576.34-17.678 768.868 168.25 879.984c1.1 0 9.902 41.808 14.303 62.711-4.4 8.802-8.802 17.602-11.002 24.203-4.4 11.002 28.603 9.902 28.603 12.102 3.3-1.1 81.413-138.62 83.614-139.72 442.267-101.216 520.377-499.476 468.67-756.915ZM526.904 362.906c-206.831 184.828-242.036 322.35-235.435 389.46-69.31-163.926 134.22-344.353 235.435-389.46ZM127.543 626.947c39.606 36.306 105.616 147.422 49.508 212.332 13.202-29.704 17.602-96.814-49.508-212.332z" transform="matrix(.094 0 0 .094 -2.042 -7.742)" fill="#17541F"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 855 B |
@@ -0,0 +1,3 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="264.567" height="318.552" viewBox="0 0 70 84.284">
|
||||||
|
<path style="fill:#fff;stroke-width:1.10017" d="M752.438 82.365C638.02 348.605 87.938 381.61 263.964 810.674c2.2 5.5-40.706 63.81-69.31 112.217-6.602-24.204-14.304-50.607-13.204-52.807C324.473 700.658 79.136 604.944 65.934 466.322 4.324 576.34-17.678 768.868 168.25 879.984c1.1 0 9.902 41.808 14.303 62.711-4.4 8.802-8.802 17.602-11.002 24.203-4.4 11.002 28.603 9.902 28.603 12.102 3.3-1.1 81.413-138.62 83.614-139.72 442.267-101.216 520.377-499.476 468.67-756.915ZM526.904 362.906c-206.831 184.828-242.036 322.35-235.435 389.46-69.31-163.926 134.22-344.353 235.435-389.46ZM127.543 626.947c39.606 36.306 105.616 147.422 49.508 212.332 13.202-29.704 17.602-96.814-49.508-212.332z" transform="matrix(.094 0 0 .094 -2.042 -7.742)" fill="#fff"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 849 B |
@@ -0,0 +1,4 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 2897.4 896.6" style="enable-background:new 0 0 2897.4 896.6" xml:space="preserve">
|
||||||
|
<path d="M1022.3 428.7c-17.8-19.9-42.7-29.8-74.7-29.8-22.3 0-42.4 5.7-60.5 17.3-18.1 11.6-32.3 27.5-42.5 47.8s-15.3 42.9-15.3 67.8 5.1 47.5 15.3 67.8c10.3 20.3 24.4 36.2 42.5 47.8 18.1 11.5 38.3 17.3 60.5 17.3 32 0 56.9-9.9 74.7-29.8V655.5h84.5V408.3h-84.5v20.4zM1010.5 575c-10.2 11.7-23.6 17.6-40.2 17.6s-29.9-5.9-40-17.6-15.1-26.1-15.1-43.3c0-17.1 5-31.6 15.1-43.3s23.4-17.6 40-17.6 30 5.9 40.2 17.6 15.3 26.1 15.3 43.3-5.1 31.6-15.3 43.3zM1381 416.1c-18.1-11.5-38.3-17.3-60.5-17.4-32 0-56.9 9.9-74.7 29.8v-20.4h-84.5v390.7h84.5v-164c17.8 19.9 42.7 29.8 74.7 29.8 22.3 0 42.4-5.7 60.5-17.3s32.3-27.5 42.5-47.8c10.2-20.3 15.3-42.9 15.3-67.8s-5.1-47.5-15.3-67.8c-10.3-20.3-24.4-36.2-42.5-47.8zM1337.9 575c-10.1 11.7-23.4 17.6-40 17.6s-29.9-5.9-40-17.6-15.1-26.1-15.1-43.3c0-17.1 5-31.6 15.1-43.3s23.4-17.6 40-17.6 29.9 5.9 40 17.6 15.1 26.1 15.1 43.3-5.1 31.6-15.1 43.3zM1672.2 416.8c-20.5-12-43-18-67.6-18-24.9 0-47.6 5.9-68 17.6-20.4 11.7-36.5 27.7-48.2 48s-17.6 42.7-17.6 67.3c.3 25.2 6.2 47.8 17.8 68 11.5 20.2 28 36 49.3 47.6 21.3 11.5 45.9 17.3 73.8 17.3 48.6 0 86.8-14.7 114.7-44l-52.5-48.9c-8.6 8.3-17.6 14.6-26.7 19-9.3 4.3-21.1 6.4-35.3 6.4-11.6 0-22.5-3.6-32.7-10.9-10.3-7.3-17.1-16.5-20.7-27.8h180l.4-11.6c0-29.6-6-55.7-18-78.2s-28.3-39.8-48.7-51.8zm-113.9 86.4c2.1-12.1 7.5-21.8 16.2-29.1s18.7-10.9 30-10.9 21.2 3.6 29.8 10.9c8.6 7.2 13.9 16.9 16 29.1h-92zM1895.3 411.7c-11 5.6-20.3 13.7-28 24.4h-.1v-28h-84.5v247.3h84.5V536.3c0-22.6 4.7-38.1 14.2-46.5 9.5-8.5 22.7-12.7 39.6-12.7 6.2 0 13.5 1 21.8 3.1l10.7-72c-5.9-3.3-14.5-4.9-25.8-4.9-10.6 0-21.4 2.8-32.4 8.4zM1985 277.4h84.5v377.8H1985zM2313.2 416.8c-20.5-12-43-18-67.6-18-24.9 0-47.6 5.9-68 17.6s-36.5 27.7-48.2 48c-11.7 20.3-17.6 42.7-17.6 67.3.3 25.2 6.2 47.8 17.8 68 11.5 20.2 28 36 49.3 47.6 21.3 11.5 45.9 17.3 73.8 17.3 48.6 0 86.8-14.7 114.7-44l-52.5-48.9c-8.6 8.3-17.6 14.6-26.7 19-9.3 4.3-21.1 6.4-35.3 6.4-11.6 0-22.5-3.6-32.7-10.9-10.3-7.3-17.1-16.5-20.7-27.8h180l.4-11.6c0-29.6-6-55.7-18-78.2s-28.3-39.8-48.7-51.8zm-113.9 86.4c2.1-12.1 7.5-21.8 16.2-29.1s18.7-10.9 30-10.9 21.2 3.6 29.8 10.9c8.6 7.2 13.9 16.9 16 29.1h-92zM2583.6 507.7c-13.8-4.4-30.6-8.1-50.5-11.1-15.1-2.7-26.1-5.2-32.9-7.6-6.8-2.4-10.2-6.1-10.2-11.1s2.3-8.7 6.7-10.9c4.4-2.2 11.5-3.3 21.3-3.3 11.6 0 24.3 2.4 38.1 7.2 13.9 4.8 26.2 11 36.9 18.4l32.4-58.2c-11.3-7.4-26.2-14.7-44.9-21.8-18.7-7.1-39.6-10.7-62.7-10.7-33.7 0-60.2 7.6-79.3 22.7-19.1 15.1-28.7 36.1-28.7 63.1 0 19 4.8 33.9 14.4 44.7 9.6 10.8 21 18.5 34 22.9 13.1 4.5 28.9 8.3 47.6 11.6 14.6 2.7 25.1 5.3 31.6 7.8s9.8 6.5 9.8 11.8c0 10.4-9.7 15.6-29.3 15.6-13.7 0-28.5-2.3-44.7-6.9-16.1-4.6-29.2-11.3-39.3-20.2l-33.3 60c9.2 7.4 24.6 14.7 46.2 22 21.7 7.3 45.2 10.9 70.7 10.9 34.7 0 62.9-7.4 84.5-22.4 21.7-15 32.5-37.3 32.5-66.9 0-19.3-5-34.2-15.1-44.9s-22-18.3-35.8-22.7zM2883.4 575.3c0-19.3-5-34.2-15.1-44.9s-22-18.3-35.8-22.7c-13.8-4.4-30.6-8.1-50.5-11.1-15.1-2.7-26.1-5.2-32.9-7.6-6.8-2.4-10.2-6.1-10.2-11.1s2.3-8.7 6.7-10.9c4.4-2.2 11.5-3.3 21.3-3.3 11.6 0 24.3 2.4 38.1 7.2 13.9 4.8 26.2 11 36.9 18.4l32.4-58.2c-11.3-7.4-26.2-14.7-44.9-21.8-18.7-7.1-39.6-10.7-62.7-10.7-33.7 0-60.2 7.6-79.3 22.7-19.1 15.1-28.7 36.1-28.7 63.1 0 19 4.8 33.9 14.4 44.7 9.6 10.8 21 18.5 34 22.9 13.1 4.5 28.9 8.3 47.6 11.6 14.6 2.7 25.1 5.3 31.6 7.8s9.8 6.5 9.8 11.8c0 10.4-9.7 15.6-29.3 15.6-13.7 0-28.5-2.3-44.7-6.9-16.1-4.6-29.2-11.3-39.3-20.2l-33.3 60c9.2 7.4 24.6 14.7 46.2 22 21.7 7.3 45.2 10.9 70.7 10.9 34.7 0 62.9-7.4 84.5-22.4 21.7-15 32.5-37.3 32.5-66.9zM2460.7 738.7h59.6v17.2h-59.6zM2596.5 706.4c-5.7 0-11 1-15.8 3s-9 5-12.5 8.9v-9.4h-19.4v93.6h19.4v-52c0-8.6 2.1-15.3 6.3-20 4.2-4.7 9.5-7.1 15.9-7.1 7.8 0 13.4 2.3 16.8 6.7 3.4 4.5 5.1 11.3 5.1 20.5v52h19.4v-56.8c0-12.8-3.2-22.6-9.5-29.3-6.4-6.7-14.9-10.1-25.7-10.1zM2733.8 717.7c-3.6-3.4-7.9-6.1-13.1-8.2s-10.6-3.1-16.2-3.1c-8.7 0-16.5 2.1-23.5 6.3s-12.5 10-16.5 17.3c-4 7.3-6 15.4-6 24.4 0 8.9 2 17.1 6 24.3 4 7.3 9.5 13 16.5 17.2s14.9 6.3 23.5 6.3c5.6 0 11-1 16.2-3.1 5.1-2.1 9.5-4.8 13.1-8.2v24.4c0 8.5-2.5 14.8-7.6 18.7-5 3.9-11 5.9-18 5.9-6.7 0-12.4-1.6-17.3-4.7-4.8-3.1-7.6-7.7-8.3-13.8h-19.4c.6 7.7 2.9 14.2 7.1 19.5s9.6 9.3 16.2 12c6.6 2.7 13.8 4 21.7 4 12.8 0 23.5-3.4 32-10.1 8.6-6.7 12.8-17.1 12.8-31.1V708.9h-19.2v8.8zm-1.6 52.4c-2.5 4.7-6 8.3-10.4 11.2-4.4 2.7-9.4 4-14.9 4-5.7 0-10.8-1.4-15.2-4.3s-7.8-6.7-10.2-11.4c-2.3-4.8-3.5-9.8-3.5-15.2 0-5.5 1.1-10.6 3.5-15.3s5.8-8.5 10.2-11.3 9.5-4.2 15.2-4.2c5.5 0 10.5 1.4 14.9 4s7.9 6.3 10.4 11 3.8 10 3.8 15.8-1.3 11-3.8 15.7zM2867.9 708.9h-21.4l-25.6 33-25.4-33h-22.4l36 46.1-37.6 47.5h21.4l27.2-34.6 27.1 34.7h22.4l-37.6-48.2zM757.6 293.7c-20-10.8-42.6-16.2-67.8-16.2H600c-8.5 39.2-21.1 76.4-37.6 111.3-9.9 20.8-21.1 40.6-33.6 59.4v207.2h88.9V521.5h72c25.2 0 47.8-5.4 67.8-16.2s35.7-25.6 47.1-44.2c11.4-18.7 17.1-39.1 17.1-61.3.1-22.7-5.6-43.3-17-61.9-11.4-18.7-27.1-33.4-47.1-44.2zm-41 140.6c-9.3 8.9-21.6 13.3-36.7 13.3l-62.2.4v-92.5l62.2-.4c15.1 0 27.3 4.4 36.7 13.3 9.4 8.9 14 19.9 14 32.9 0 13.2-4.6 24.1-14 33z"/>
|
||||||
|
<path d="M140 713.7c-3.4-16.4-10.3-49.1-11.2-49.1C-16.9 577.5.4 426.6 48.6 340.4 59 449 251.2 524 139.1 656.8c-.9 1.7 5.2 22.4 10.3 41.4 22.4-37.9 56-83.6 54.3-87.9C65.9 273.9 496.9 248.1 586.6 39.4c40.5 201.8-20.7 513.9-367.2 593.2-1.7.9-62.9 108.6-65.5 109.5 0-1.7-25.9-.9-22.4-9.5 1.6-5.2 5.1-12 8.5-18.9zm-4.3-81.1c44-50.9-7.8-137.9-38.8-166.4 52.6 90.5 49.1 143.1 38.8 166.4z" style="fill:#17541f"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 5.4 KiB |
|
After Width: | Height: | Size: 108 KiB |
|
Before Width: | Height: | Size: 7.6 KiB |
|
Before Width: | Height: | Size: 22 KiB |
|
Before Width: | Height: | Size: 8.2 KiB |
@@ -8,6 +8,7 @@
|
|||||||
<meta name="color-scheme" content="dark light">
|
<meta name="color-scheme" content="dark light">
|
||||||
<meta name="theme-color" content="#17541f" />
|
<meta name="theme-color" content="#17541f" />
|
||||||
<link rel="manifest" href="manifest.webmanifest">
|
<link rel="manifest" href="manifest.webmanifest">
|
||||||
|
<link rel="icon" type="image/x-icon" href="favicon.ico">
|
||||||
<link rel="apple-touch-icon" href="apple-touch-icon.png">
|
<link rel="apple-touch-icon" href="apple-touch-icon.png">
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
|
|||||||