mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2025-08-05 18:58:34 -05:00
Compare commits
182 Commits
Author | SHA1 | Date | |
---|---|---|---|
![]() |
428f9cd761 | ||
![]() |
d828c1a2ff | ||
![]() |
25b49db7c0 | ||
![]() |
55a40708a6 | ||
![]() |
dae5bca883 | ||
![]() |
fc74da9b82 | ||
![]() |
2b006907d5 | ||
![]() |
1ba1afdce5 | ||
![]() |
a98317c52a | ||
![]() |
ffddd0f323 | ||
![]() |
4cb2f0acef | ||
![]() |
98663e902f | ||
![]() |
0f5e935214 | ||
![]() |
b9636a3def | ||
![]() |
35574f3b86 | ||
![]() |
00a8f0cd6e | ||
![]() |
6779042242 | ||
![]() |
6379e7b54f | ||
![]() |
2fa742c94b | ||
![]() |
aa0da2f516 | ||
![]() |
f07441a408 | ||
![]() |
f6084acfc8 | ||
![]() |
23ceb2a5ec | ||
![]() |
a698791059 | ||
![]() |
41c1f38ab2 | ||
![]() |
83c85dc10e | ||
![]() |
a020d807d4 | ||
![]() |
464ee51de8 | ||
![]() |
c57c1d5389 | ||
![]() |
af16bb3934 | ||
![]() |
fba416e8e1 | ||
![]() |
3d8de50b5a | ||
![]() |
86263a52ea | ||
![]() |
754627681c | ||
![]() |
bf11dc8d1b | ||
![]() |
84721b001f | ||
![]() |
f48a20c75f | ||
![]() |
16f4552e0e | ||
![]() |
86811d0733 | ||
![]() |
d2f9b5d5e5 | ||
![]() |
f5e1675107 | ||
![]() |
ae016cae4b | ||
![]() |
c7f6d03508 | ||
![]() |
955f2d0db9 | ||
![]() |
ba32684df6 | ||
![]() |
2f7adf40ac | ||
![]() |
a52031161b | ||
![]() |
3e2c541b7b | ||
![]() |
967fc98090 | ||
![]() |
22e95f45bd | ||
![]() |
38c777ec0f | ||
![]() |
ccbc97399a | ||
![]() |
f43013a746 | ||
![]() |
1335ab5f1b | ||
![]() |
90b4691f16 | ||
![]() |
f053ee3191 | ||
![]() |
86748c1e96 | ||
![]() |
22ded7d4c3 | ||
![]() |
ff5063849a | ||
![]() |
ec49284274 | ||
![]() |
6bd5c34b54 | ||
![]() |
db0a2eb1a3 | ||
![]() |
4948438378 | ||
![]() |
76064178f5 | ||
![]() |
c772bd94b0 | ||
![]() |
7f8f7fbb15 | ||
![]() |
388d821f45 | ||
![]() |
9c15623a89 | ||
![]() |
966eb00de0 | ||
![]() |
1c699278a3 | ||
![]() |
4c6c976f63 | ||
![]() |
ebc9ce17b5 | ||
![]() |
8039ce3c2b | ||
![]() |
385d48f644 | ||
![]() |
f682fe25fc | ||
![]() |
7924bf8611 | ||
![]() |
d75b909d28 | ||
![]() |
47dfe85a7c | ||
![]() |
4d0e8a338f | ||
![]() |
cfc64d37bb | ||
![]() |
2db66280cc | ||
![]() |
9f045f4494 | ||
![]() |
4fdb28c8d6 | ||
![]() |
f1049cf889 | ||
![]() |
8d664fad56 | ||
![]() |
f6ddcfa839 | ||
![]() |
ce59f2ad5e | ||
![]() |
0de00a4ac1 | ||
![]() |
bec72dffeb | ||
![]() |
463e95367c | ||
![]() |
1739de2694 | ||
![]() |
fd8db27a88 | ||
![]() |
9ddf14bebe | ||
![]() |
1cf8ea3aba | ||
![]() |
134993fce6 | ||
![]() |
ff1955e014 | ||
![]() |
d83bbdc50b | ||
![]() |
907b6d1294 | ||
![]() |
4e7bb1c8da | ||
![]() |
09ab694d05 | ||
![]() |
03ced65d5f | ||
![]() |
01d919cf31 | ||
![]() |
8d5f331e63 | ||
![]() |
ed556ead6f | ||
![]() |
e10a904f33 | ||
![]() |
f2a05b61da | ||
![]() |
21f96f0679 | ||
![]() |
2ffabd54e5 | ||
![]() |
1197437750 | ||
![]() |
d1339374d0 | ||
![]() |
5ba4b9d6b2 | ||
![]() |
b386ea9426 | ||
![]() |
197174f400 | ||
![]() |
97dceba783 | ||
![]() |
2ed4400827 | ||
![]() |
58cbcbd6ef | ||
![]() |
4aeb2e1a74 | ||
![]() |
45c5f81b34 | ||
![]() |
13201dbfff | ||
![]() |
0b1523f4e5 | ||
![]() |
cd3b1a221e | ||
![]() |
4855f4b8b1 | ||
![]() |
6587470033 | ||
![]() |
6487dab132 | ||
![]() |
b643a68fa3 | ||
![]() |
b60e16fe33 | ||
![]() |
c508be6ecd | ||
![]() |
3b2d4fe876 | ||
![]() |
b47f301831 | ||
![]() |
a79b9de1a2 | ||
![]() |
e98da2e72c | ||
![]() |
718171a125 | ||
![]() |
aaa130e20d | ||
![]() |
4606caeaa8 | ||
![]() |
4813a7bc70 | ||
![]() |
fb82aa0ee1 | ||
![]() |
c7e0c32226 | ||
![]() |
607adf44f3 | ||
![]() |
625780899d | ||
![]() |
25542c56b9 | ||
![]() |
45e2b7f814 | ||
![]() |
6b34f592df | ||
![]() |
6cf732e6ec | ||
![]() |
dfd959839f | ||
![]() |
d165e89ac3 | ||
![]() |
421a87c94b | ||
![]() |
b55529b913 | ||
![]() |
c62d892969 | ||
![]() |
9e6aa55230 | ||
![]() |
6090305b77 | ||
![]() |
d1b516a089 | ||
![]() |
89aff63e52 | ||
![]() |
11e9c4d8cc | ||
![]() |
9d84e95771 | ||
![]() |
2aced1c305 | ||
![]() |
454098630b | ||
![]() |
8f3ab2791b | ||
![]() |
61209b1057 | ||
![]() |
38a817e887 | ||
![]() |
5e3d1b26e7 | ||
![]() |
4996b7e5f7 | ||
![]() |
2c8fddb554 | ||
![]() |
ae05011062 | ||
![]() |
50a6b7e154 | ||
![]() |
d0ce4113e0 | ||
![]() |
d55900b877 | ||
![]() |
2a73ab4693 | ||
![]() |
2aea220c6d | ||
![]() |
b0c305e852 | ||
![]() |
73a77d2a45 | ||
![]() |
3a011e7c04 | ||
![]() |
d48b75d862 | ||
![]() |
f6e26d5953 | ||
![]() |
7863780883 | ||
![]() |
c2ac9a26a2 | ||
![]() |
58e8f796d1 | ||
![]() |
ba0f4718e5 | ||
![]() |
6fb4daf03e | ||
![]() |
f6c34494a7 | ||
![]() |
1e4d284b30 | ||
![]() |
1f3406fd77 | ||
![]() |
398faf36fc |
2
.github/dependabot.yml
vendored
2
.github/dependabot.yml
vendored
@@ -47,6 +47,8 @@ updates:
|
|||||||
# Add reviewers
|
# Add reviewers
|
||||||
reviewers:
|
reviewers:
|
||||||
- "paperless-ngx/backend"
|
- "paperless-ngx/backend"
|
||||||
|
ignore:
|
||||||
|
- dependency-name: "uvicorn"
|
||||||
groups:
|
groups:
|
||||||
development:
|
development:
|
||||||
patterns:
|
patterns:
|
||||||
|
16
.github/workflows/ci.yml
vendored
16
.github/workflows/ci.yml
vendored
@@ -16,7 +16,7 @@ on:
|
|||||||
env:
|
env:
|
||||||
# This is the version of pipenv all the steps will use
|
# This is the version of pipenv all the steps will use
|
||||||
# If changing this, change Dockerfile
|
# If changing this, change Dockerfile
|
||||||
DEFAULT_PIP_ENV_VERSION: "2023.11.15"
|
DEFAULT_PIP_ENV_VERSION: "2023.12.1"
|
||||||
# This is the default version of Python to use in most steps which aren't specific
|
# This is the default version of Python to use in most steps which aren't specific
|
||||||
DEFAULT_PYTHON_VERSION: "3.10"
|
DEFAULT_PYTHON_VERSION: "3.10"
|
||||||
|
|
||||||
@@ -42,7 +42,7 @@ jobs:
|
|||||||
python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
|
python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
|
||||||
-
|
-
|
||||||
name: Check files
|
name: Check files
|
||||||
uses: pre-commit/action@v3.0.0
|
uses: pre-commit/action@v3.0.1
|
||||||
|
|
||||||
documentation:
|
documentation:
|
||||||
name: "Build & Deploy Documentation"
|
name: "Build & Deploy Documentation"
|
||||||
@@ -184,7 +184,7 @@ jobs:
|
|||||||
cache-dependency-path: 'src-ui/package-lock.json'
|
cache-dependency-path: 'src-ui/package-lock.json'
|
||||||
- name: Cache frontend dependencies
|
- name: Cache frontend dependencies
|
||||||
id: cache-frontend-deps
|
id: cache-frontend-deps
|
||||||
uses: actions/cache@v3
|
uses: actions/cache@v4
|
||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
~/.npm
|
~/.npm
|
||||||
@@ -221,7 +221,7 @@ jobs:
|
|||||||
cache-dependency-path: 'src-ui/package-lock.json'
|
cache-dependency-path: 'src-ui/package-lock.json'
|
||||||
- name: Cache frontend dependencies
|
- name: Cache frontend dependencies
|
||||||
id: cache-frontend-deps
|
id: cache-frontend-deps
|
||||||
uses: actions/cache@v3
|
uses: actions/cache@v4
|
||||||
with:
|
with:
|
||||||
path: |
|
path: |
|
||||||
~/.npm
|
~/.npm
|
||||||
@@ -283,7 +283,7 @@ jobs:
|
|||||||
merge-multiple: true
|
merge-multiple: true
|
||||||
-
|
-
|
||||||
name: Upload frontend coverage to Codecov
|
name: Upload frontend coverage to Codecov
|
||||||
uses: codecov/codecov-action@v3
|
uses: codecov/codecov-action@v4
|
||||||
with:
|
with:
|
||||||
# not required for public repos, but intermittently fails otherwise
|
# not required for public repos, but intermittently fails otherwise
|
||||||
token: ${{ secrets.CODECOV_TOKEN }}
|
token: ${{ secrets.CODECOV_TOKEN }}
|
||||||
@@ -299,7 +299,7 @@ jobs:
|
|||||||
path: src/
|
path: src/
|
||||||
-
|
-
|
||||||
name: Upload coverage to Codecov
|
name: Upload coverage to Codecov
|
||||||
uses: codecov/codecov-action@v3
|
uses: codecov/codecov-action@v4
|
||||||
with:
|
with:
|
||||||
# not required for public repos, but intermittently fails otherwise
|
# not required for public repos, but intermittently fails otherwise
|
||||||
token: ${{ secrets.CODECOV_TOKEN }}
|
token: ${{ secrets.CODECOV_TOKEN }}
|
||||||
@@ -577,7 +577,7 @@ jobs:
|
|||||||
-
|
-
|
||||||
name: Create Release and Changelog
|
name: Create Release and Changelog
|
||||||
id: create-release
|
id: create-release
|
||||||
uses: release-drafter/release-drafter@v5
|
uses: release-drafter/release-drafter@v6
|
||||||
with:
|
with:
|
||||||
name: Paperless-ngx ${{ steps.get_version.outputs.version }}
|
name: Paperless-ngx ${{ steps.get_version.outputs.version }}
|
||||||
tag: ${{ steps.get_version.outputs.version }}
|
tag: ${{ steps.get_version.outputs.version }}
|
||||||
@@ -645,7 +645,7 @@ jobs:
|
|||||||
script: |
|
script: |
|
||||||
const { repo, owner } = context.repo;
|
const { repo, owner } = context.repo;
|
||||||
const result = await github.rest.pulls.create({
|
const result = await github.rest.pulls.create({
|
||||||
title: '[Documentation] Add ${{ needs.publish-release.outputs.version }} changelog',
|
title: 'Documentation: Add ${{ needs.publish-release.outputs.version }} changelog',
|
||||||
owner,
|
owner,
|
||||||
repo,
|
repo,
|
||||||
head: '${{ needs.publish-release.outputs.version }}-changelog',
|
head: '${{ needs.publish-release.outputs.version }}-changelog',
|
||||||
|
4
.github/workflows/cleanup-tags.yml
vendored
4
.github/workflows/cleanup-tags.yml
vendored
@@ -33,7 +33,7 @@ jobs:
|
|||||||
-
|
-
|
||||||
name: Clean temporary images
|
name: Clean temporary images
|
||||||
if: "${{ env.TOKEN != '' }}"
|
if: "${{ env.TOKEN != '' }}"
|
||||||
uses: stumpylog/image-cleaner-action/ephemeral@v0.4.0
|
uses: stumpylog/image-cleaner-action/ephemeral@v0.5.0
|
||||||
with:
|
with:
|
||||||
token: "${{ env.TOKEN }}"
|
token: "${{ env.TOKEN }}"
|
||||||
owner: "${{ github.repository_owner }}"
|
owner: "${{ github.repository_owner }}"
|
||||||
@@ -61,7 +61,7 @@ jobs:
|
|||||||
-
|
-
|
||||||
name: Clean untagged images
|
name: Clean untagged images
|
||||||
if: "${{ env.TOKEN != '' }}"
|
if: "${{ env.TOKEN != '' }}"
|
||||||
uses: stumpylog/image-cleaner-action/untagged@v0.4.0
|
uses: stumpylog/image-cleaner-action/untagged@v0.5.0
|
||||||
with:
|
with:
|
||||||
token: "${{ env.TOKEN }}"
|
token: "${{ env.TOKEN }}"
|
||||||
owner: "${{ github.repository_owner }}"
|
owner: "${{ github.repository_owner }}"
|
||||||
|
2
.github/workflows/project-actions.yml
vendored
2
.github/workflows/project-actions.yml
vendored
@@ -22,6 +22,6 @@ jobs:
|
|||||||
if: github.event_name == 'pull_request_target' && (github.event.action == 'opened' || github.event.action == 'reopened') && github.event.pull_request.user.login != 'dependabot'
|
if: github.event_name == 'pull_request_target' && (github.event.action == 'opened' || github.event.action == 'reopened') && github.event.pull_request.user.login != 'dependabot'
|
||||||
steps:
|
steps:
|
||||||
- name: Label PR with release-drafter
|
- name: Label PR with release-drafter
|
||||||
uses: release-drafter/release-drafter@v5
|
uses: release-drafter/release-drafter@v6
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
87
.github/workflows/repo-maintenance.yml
vendored
87
.github/workflows/repo-maintenance.yml
vendored
@@ -28,7 +28,7 @@ jobs:
|
|||||||
stale-issue-message: >
|
stale-issue-message: >
|
||||||
This issue has been automatically marked as stale because it has not had
|
This issue has been automatically marked as stale because it has not had
|
||||||
recent activity. It will be closed if no further activity occurs. Thank you
|
recent activity. It will be closed if no further activity occurs. Thank you
|
||||||
for your contributions.
|
for your contributions. See our [contributing guidelines](https://github.com/paperless-ngx/paperless-ngx/blob/dev/CONTRIBUTING.md#automatic-repository-maintenance) for more details.
|
||||||
lock-threads:
|
lock-threads:
|
||||||
name: 'Lock Old Threads'
|
name: 'Lock Old Threads'
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -43,14 +43,17 @@ jobs:
|
|||||||
This issue has been automatically locked since there
|
This issue has been automatically locked since there
|
||||||
has not been any recent activity after it was closed.
|
has not been any recent activity after it was closed.
|
||||||
Please open a new discussion or issue for related concerns.
|
Please open a new discussion or issue for related concerns.
|
||||||
|
See our [contributing guidelines](https://github.com/paperless-ngx/paperless-ngx/blob/dev/CONTRIBUTING.md#automatic-repository-maintenance) for more details.
|
||||||
pr-comment: >
|
pr-comment: >
|
||||||
This pull request has been automatically locked since there
|
This pull request has been automatically locked since there
|
||||||
has not been any recent activity after it was closed.
|
has not been any recent activity after it was closed.
|
||||||
Please open a new discussion or issue for related concerns.
|
Please open a new discussion or issue for related concerns.
|
||||||
|
See our [contributing guidelines](https://github.com/paperless-ngx/paperless-ngx/blob/dev/CONTRIBUTING.md#automatic-repository-maintenance) for more details.
|
||||||
discussion-comment: >
|
discussion-comment: >
|
||||||
This discussion has been automatically locked since there
|
This discussion has been automatically locked since there
|
||||||
has not been any recent activity after it was closed.
|
has not been any recent activity after it was closed.
|
||||||
Please open a new discussion for related concerns.
|
Please open a new discussion for related concerns.
|
||||||
|
See our [contributing guidelines](https://github.com/paperless-ngx/paperless-ngx/blob/dev/CONTRIBUTING.md#automatic-repository-maintenance) for more details.
|
||||||
close-answered-discussions:
|
close-answered-discussions:
|
||||||
name: 'Close Answered Discussions'
|
name: 'Close Answered Discussions'
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
@@ -90,7 +93,7 @@ jobs:
|
|||||||
}`;
|
}`;
|
||||||
const commentVariables = {
|
const commentVariables = {
|
||||||
discussion: discussion.id,
|
discussion: discussion.id,
|
||||||
body: 'This discussion has been automatically closed because it was marked as answered.',
|
body: 'This discussion has been automatically closed because it was marked as answered. Please see our [contributing guidelines](https://github.com/paperless-ngx/paperless-ngx/blob/dev/CONTRIBUTING.md#automatic-repository-maintenance) for more details.',
|
||||||
}
|
}
|
||||||
await github.graphql(addCommentMutation, commentVariables)
|
await github.graphql(addCommentMutation, commentVariables)
|
||||||
|
|
||||||
@@ -180,7 +183,85 @@ jobs:
|
|||||||
}`;
|
}`;
|
||||||
const commentVariables = {
|
const commentVariables = {
|
||||||
discussion: discussion.id,
|
discussion: discussion.id,
|
||||||
body: 'This discussion has been automatically closed due to inactivity.',
|
body: 'This discussion has been automatically closed due to inactivity. Please see our [contributing guidelines](https://github.com/paperless-ngx/paperless-ngx/blob/dev/CONTRIBUTING.md#automatic-repository-maintenance) for more details.',
|
||||||
|
}
|
||||||
|
await github.graphql(addCommentMutation, commentVariables);
|
||||||
|
|
||||||
|
const closeDiscussionMutation = `mutation($discussion:ID!, $reason:DiscussionCloseReason!) {
|
||||||
|
closeDiscussion(input:{discussionId:$discussion, reason:$reason}) {
|
||||||
|
clientMutationId
|
||||||
|
}
|
||||||
|
}`;
|
||||||
|
const closeVariables = {
|
||||||
|
discussion: discussion.id,
|
||||||
|
reason: "OUTDATED",
|
||||||
|
}
|
||||||
|
await github.graphql(closeDiscussionMutation, closeVariables);
|
||||||
|
|
||||||
|
await sleep(1000);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
close-unsupported-feature-requests:
|
||||||
|
name: 'Close Unsupported Feature Requests'
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- uses: actions/github-script@v7
|
||||||
|
with:
|
||||||
|
script: |
|
||||||
|
function sleep(ms) {
|
||||||
|
return new Promise(resolve => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
const CUTOFF_1_DAYS = 180;
|
||||||
|
const CUTOFF_1_COUNT = 5;
|
||||||
|
const CUTOFF_2_DAYS = 365;
|
||||||
|
const CUTOFF_2_COUNT = 10;
|
||||||
|
|
||||||
|
const cutoff1Date = new Date();
|
||||||
|
cutoff1Date.setDate(cutoff1Date.getDate() - CUTOFF_1_DAYS);
|
||||||
|
const cutoff2Date = new Date();
|
||||||
|
cutoff2Date.setDate(cutoff2Date.getDate() - CUTOFF_2_DAYS);
|
||||||
|
|
||||||
|
const query = `query(
|
||||||
|
$owner:String!,
|
||||||
|
$name:String!,
|
||||||
|
$featureRequestsCategory:ID!,
|
||||||
|
) {
|
||||||
|
repository(owner:$owner, name:$name){
|
||||||
|
discussions(
|
||||||
|
categoryId:$featureRequestsCategory,
|
||||||
|
last:100,
|
||||||
|
states:[OPEN],
|
||||||
|
) {
|
||||||
|
nodes {
|
||||||
|
id,
|
||||||
|
number,
|
||||||
|
updatedAt,
|
||||||
|
upvoteCount,
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}`;
|
||||||
|
const variables = {
|
||||||
|
owner: context.repo.owner,
|
||||||
|
name: context.repo.repo,
|
||||||
|
featureRequestsCategory: "DIC_kwDOG1Zs184CBNr4"
|
||||||
|
}
|
||||||
|
const result = await github.graphql(query, variables);
|
||||||
|
|
||||||
|
for (const discussion of result.repository.discussions.nodes) {
|
||||||
|
const discussionDate = new Date(discussion.updatedAt);
|
||||||
|
if ((discussionDate < cutoff1Date && discussion.upvoteCount < CUTOFF_1_COUNT) ||
|
||||||
|
(discussionDate < cutoff2Date && discussion.upvoteCount < CUTOFF_2_COUNT)) {
|
||||||
|
console.log(`Closing discussion #${discussion.number} (${discussion.id}), last updated at ${discussion.updatedAt} with votes ${discussion.upvoteCount}`);
|
||||||
|
const addCommentMutation = `mutation($discussion:ID!, $body:String!) {
|
||||||
|
addDiscussionComment(input:{discussionId:$discussion, body:$body}) {
|
||||||
|
clientMutationId
|
||||||
|
}
|
||||||
|
}`;
|
||||||
|
const commentVariables = {
|
||||||
|
discussion: discussion.id,
|
||||||
|
body: 'This discussion has been automatically closed due to lack of community support. Please see our [contributing guidelines](https://github.com/paperless-ngx/paperless-ngx/blob/dev/CONTRIBUTING.md#automatic-repository-maintenance) for more details.',
|
||||||
}
|
}
|
||||||
await github.graphql(addCommentMutation, commentVariables);
|
await github.graphql(addCommentMutation, commentVariables);
|
||||||
|
|
||||||
|
@@ -47,11 +47,11 @@ repos:
|
|||||||
exclude: "(^Pipfile\\.lock$)"
|
exclude: "(^Pipfile\\.lock$)"
|
||||||
# Python hooks
|
# Python hooks
|
||||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||||
rev: 'v0.1.11'
|
rev: 'v0.3.0'
|
||||||
hooks:
|
hooks:
|
||||||
- id: ruff
|
- id: ruff
|
||||||
- repo: https://github.com/psf/black-pre-commit-mirror
|
- repo: https://github.com/psf/black-pre-commit-mirror
|
||||||
rev: 23.12.1
|
rev: 24.2.0
|
||||||
hooks:
|
hooks:
|
||||||
- id: black
|
- id: black
|
||||||
# Dockerfile hooks
|
# Dockerfile hooks
|
||||||
|
42
.ruff.toml
42
.ruff.toml
@@ -1,8 +1,3 @@
|
|||||||
# https://beta.ruff.rs/docs/settings/
|
|
||||||
# https://beta.ruff.rs/docs/rules/
|
|
||||||
extend-select = ["I", "W", "UP", "COM", "DJ", "EXE", "ISC", "ICN", "G201", "INP", "PIE", "RSE", "SIM", "TID", "PLC", "PLE", "RUF"]
|
|
||||||
# TODO PTH
|
|
||||||
ignore = ["DJ001", "SIM105", "RUF012"]
|
|
||||||
fix = true
|
fix = true
|
||||||
line-length = 88
|
line-length = 88
|
||||||
respect-gitignore = true
|
respect-gitignore = true
|
||||||
@@ -11,13 +6,42 @@ target-version = "py39"
|
|||||||
output-format = "grouped"
|
output-format = "grouped"
|
||||||
show-fixes = true
|
show-fixes = true
|
||||||
|
|
||||||
[per-file-ignores]
|
# https://docs.astral.sh/ruff/settings/
|
||||||
|
# https://docs.astral.sh/ruff/rules/
|
||||||
|
[lint]
|
||||||
|
extend-select = [
|
||||||
|
"W", # https://docs.astral.sh/ruff/rules/#pycodestyle-e-w
|
||||||
|
"I", # https://docs.astral.sh/ruff/rules/#isort-i
|
||||||
|
"UP", # https://docs.astral.sh/ruff/rules/#pyupgrade-up
|
||||||
|
"COM", # https://docs.astral.sh/ruff/rules/#flake8-commas-com
|
||||||
|
"DJ", # https://docs.astral.sh/ruff/rules/#flake8-django-dj
|
||||||
|
"EXE", # https://docs.astral.sh/ruff/rules/#flake8-executable-exe
|
||||||
|
"ISC", # https://docs.astral.sh/ruff/rules/#flake8-implicit-str-concat-isc
|
||||||
|
"ICN", # https://docs.astral.sh/ruff/rules/#flake8-import-conventions-icn
|
||||||
|
"G201", # https://docs.astral.sh/ruff/rules/#flake8-logging-format-g
|
||||||
|
"INP", # https://docs.astral.sh/ruff/rules/#flake8-no-pep420-inp
|
||||||
|
"PIE", # https://docs.astral.sh/ruff/rules/#flake8-pie-pie
|
||||||
|
"Q", # https://docs.astral.sh/ruff/rules/#flake8-quotes-q
|
||||||
|
"RSE", # https://docs.astral.sh/ruff/rules/#flake8-raise-rse
|
||||||
|
"T20", # https://docs.astral.sh/ruff/rules/#flake8-print-t20
|
||||||
|
"SIM", # https://docs.astral.sh/ruff/rules/#flake8-simplify-sim
|
||||||
|
"TID", # https://docs.astral.sh/ruff/rules/#flake8-tidy-imports-tid
|
||||||
|
"TCH", # https://docs.astral.sh/ruff/rules/#flake8-type-checking-tch
|
||||||
|
"PLC", # https://docs.astral.sh/ruff/rules/#pylint-pl
|
||||||
|
"PLE", # https://docs.astral.sh/ruff/rules/#pylint-pl
|
||||||
|
"RUF", # https://docs.astral.sh/ruff/rules/#ruff-specific-rules-ruf
|
||||||
|
"FLY", # https://docs.astral.sh/ruff/rules/#flynt-fly
|
||||||
|
]
|
||||||
|
# TODO PTH https://docs.astral.sh/ruff/rules/#flake8-use-pathlib-pth
|
||||||
|
ignore = ["DJ001", "SIM105", "RUF012"]
|
||||||
|
|
||||||
|
[lint.per-file-ignores]
|
||||||
".github/scripts/*.py" = ["E501", "INP001", "SIM117"]
|
".github/scripts/*.py" = ["E501", "INP001", "SIM117"]
|
||||||
"docker/wait-for-redis.py" = ["INP001"]
|
"docker/wait-for-redis.py" = ["INP001", "T201"]
|
||||||
"*/tests/*.py" = ["E501", "SIM117"]
|
"*/tests/*.py" = ["E501", "SIM117"]
|
||||||
"*/migrations/*.py" = ["E501", "SIM"]
|
"*/migrations/*.py" = ["E501", "SIM", "T201"]
|
||||||
"src/paperless_tesseract/tests/test_parser.py" = ["RUF001"]
|
"src/paperless_tesseract/tests/test_parser.py" = ["RUF001"]
|
||||||
"src/documents/models.py" = ["SIM115"]
|
"src/documents/models.py" = ["SIM115"]
|
||||||
|
|
||||||
[isort]
|
[lint.isort]
|
||||||
force-single-line = true
|
force-single-line = true
|
||||||
|
@@ -137,3 +137,19 @@ All team members are notified when mentioned or assigned to a relevant issue or
|
|||||||
We are not overly strict with inviting people to the organization. If you have read the [team permissions](#permissions) and think having additional access would enhance your contributions, please reach out to an [admin](#structure) of the team.
|
We are not overly strict with inviting people to the organization. If you have read the [team permissions](#permissions) and think having additional access would enhance your contributions, please reach out to an [admin](#structure) of the team.
|
||||||
|
|
||||||
The admins occasionally invite contributors directly if we believe having them on a team will accelerate their work.
|
The admins occasionally invite contributors directly if we believe having them on a team will accelerate their work.
|
||||||
|
|
||||||
|
# Automatic Repository Maintenance
|
||||||
|
|
||||||
|
The Paperless-ngx team appreciates all effort and interest from the community in filing bug reports, creating feature requests, sharing ideas and helping other
|
||||||
|
community members. That said, in an effort to keep the repository organized and managebale the project uses automatic handling of certain areas:
|
||||||
|
|
||||||
|
- Issues that cannot be reproduced will be marked 'stale' after 7 days of inactivity and closed after 14 further days of inactivity.
|
||||||
|
- Issues, pull requests and discussions that are closed will be locked after 30 days of inactivity.
|
||||||
|
- Discussions with a marked answer will be automatically closed.
|
||||||
|
- Discussions in the 'General' or 'Support' categories will be closed after 180 days of inactivity.
|
||||||
|
- Feature requests that do not meet the following thresholds will be closed: 5 "up-votes" after 180 days of inactivity or 10 "up-votes" after 365 days.
|
||||||
|
|
||||||
|
In all cases, threads can be re-opened by project maintainers and, of course, users can always create a new discussion for related concerns.
|
||||||
|
Finally, remember that all information remains searchable and 'closed' feature requests can still serve as inspiration for new features.
|
||||||
|
|
||||||
|
Thank you all for your contributions.
|
||||||
|
11
Dockerfile
11
Dockerfile
@@ -29,7 +29,7 @@ COPY Pipfile* ./
|
|||||||
|
|
||||||
RUN set -eux \
|
RUN set -eux \
|
||||||
&& echo "Installing pipenv" \
|
&& echo "Installing pipenv" \
|
||||||
&& python3 -m pip install --no-cache-dir --upgrade pipenv==2023.11.15 \
|
&& python3 -m pip install --no-cache-dir --upgrade pipenv==2023.12.1 \
|
||||||
&& echo "Generating requirement.txt" \
|
&& echo "Generating requirement.txt" \
|
||||||
&& pipenv requirements > requirements.txt
|
&& pipenv requirements > requirements.txt
|
||||||
|
|
||||||
@@ -39,8 +39,6 @@ RUN set -eux \
|
|||||||
# - Don't leave anything extra in here
|
# - Don't leave anything extra in here
|
||||||
FROM docker.io/python:3.11-slim-bookworm as main-app
|
FROM docker.io/python:3.11-slim-bookworm as main-app
|
||||||
|
|
||||||
ENV PYTHONWARNINGS="ignore:::django.http.response:517"
|
|
||||||
|
|
||||||
LABEL org.opencontainers.image.authors="paperless-ngx team <hello@paperless-ngx.com>"
|
LABEL org.opencontainers.image.authors="paperless-ngx team <hello@paperless-ngx.com>"
|
||||||
LABEL org.opencontainers.image.documentation="https://docs.paperless-ngx.com/"
|
LABEL org.opencontainers.image.documentation="https://docs.paperless-ngx.com/"
|
||||||
LABEL org.opencontainers.image.source="https://github.com/paperless-ngx/paperless-ngx"
|
LABEL org.opencontainers.image.source="https://github.com/paperless-ngx/paperless-ngx"
|
||||||
@@ -57,6 +55,13 @@ ARG JBIG2ENC_VERSION=0.29
|
|||||||
ARG QPDF_VERSION=11.6.4
|
ARG QPDF_VERSION=11.6.4
|
||||||
ARG GS_VERSION=10.02.1
|
ARG GS_VERSION=10.02.1
|
||||||
|
|
||||||
|
# Set Python environment variables
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
|
PYTHONUNBUFFERED=1 \
|
||||||
|
# Ignore warning from Whitenoise
|
||||||
|
PYTHONWARNINGS="ignore:::django.http.response:517" \
|
||||||
|
PNGX_CONTAINERIZED=1
|
||||||
|
|
||||||
#
|
#
|
||||||
# Begin installation and configuration
|
# Begin installation and configuration
|
||||||
# Order the steps below from least often changed to most
|
# Order the steps below from least often changed to most
|
||||||
|
9
Pipfile
9
Pipfile
@@ -7,7 +7,8 @@ name = "pypi"
|
|||||||
dateparser = "~=1.2"
|
dateparser = "~=1.2"
|
||||||
# WARNING: django does not use semver.
|
# WARNING: django does not use semver.
|
||||||
# Only patch versions are guaranteed to not introduce breaking changes.
|
# Only patch versions are guaranteed to not introduce breaking changes.
|
||||||
django = "~=4.2.9"
|
django = "~=4.2.11"
|
||||||
|
django-allauth = "*"
|
||||||
django-auditlog = "*"
|
django-auditlog = "*"
|
||||||
django-celery-results = "*"
|
django-celery-results = "*"
|
||||||
django-compression-middleware = "*"
|
django-compression-middleware = "*"
|
||||||
@@ -45,12 +46,12 @@ python-magic = "*"
|
|||||||
pyzbar = "*"
|
pyzbar = "*"
|
||||||
rapidfuzz = "*"
|
rapidfuzz = "*"
|
||||||
redis = {extras = ["hiredis"], version = "*"}
|
redis = {extras = ["hiredis"], version = "*"}
|
||||||
scikit-learn = "~=1.3"
|
scikit-learn = "~=1.4"
|
||||||
setproctitle = "*"
|
setproctitle = "*"
|
||||||
tika-client = "*"
|
tika-client = "*"
|
||||||
tqdm = "*"
|
tqdm = "*"
|
||||||
uvicorn = {extras = ["standard"], version = "*"}
|
uvicorn = {extras = ["standard"], version = "==0.25.0"}
|
||||||
watchdog = "~=3.0"
|
watchdog = "~=4.0"
|
||||||
whitenoise = "~=6.6"
|
whitenoise = "~=6.6"
|
||||||
whoosh="~=2.7"
|
whoosh="~=2.7"
|
||||||
zxing-cpp = {version = "*", platform_machine = "== 'x86_64'"}
|
zxing-cpp = {version = "*", platform_machine = "== 'x86_64'"}
|
||||||
|
1680
Pipfile.lock
generated
1680
Pipfile.lock
generated
File diff suppressed because it is too large
Load Diff
12
README.md
12
README.md
@@ -21,7 +21,7 @@ Paperless-ngx is a document management system that transforms your physical docu
|
|||||||
|
|
||||||
Paperless-ngx is the official successor to the original [Paperless](https://github.com/the-paperless-project/paperless) & [Paperless-ng](https://github.com/jonaswinkler/paperless-ng) projects and is designed to distribute the responsibility of advancing and supporting the project among a team of people. [Consider joining us!](#community-support)
|
Paperless-ngx is the official successor to the original [Paperless](https://github.com/the-paperless-project/paperless) & [Paperless-ng](https://github.com/jonaswinkler/paperless-ng) projects and is designed to distribute the responsibility of advancing and supporting the project among a team of people. [Consider joining us!](#community-support)
|
||||||
|
|
||||||
A demo is available at [demo.paperless-ngx.com](https://demo.paperless-ngx.com) using login `demo` / `demo`. _Note: demo content is reset frequently and confidential information should not be uploaded._
|
Thanks to the generous folks at [DigitalOcean](https://m.do.co/c/8d70b916d462), a demo is available at [demo.paperless-ngx.com](https://demo.paperless-ngx.com) using login `demo` / `demo`. _Note: demo content is reset frequently and confidential information should not be uploaded._
|
||||||
|
|
||||||
- [Features](#features)
|
- [Features](#features)
|
||||||
- [Getting started](#getting-started)
|
- [Getting started](#getting-started)
|
||||||
@@ -33,6 +33,16 @@ A demo is available at [demo.paperless-ngx.com](https://demo.paperless-ngx.com)
|
|||||||
- [Affiliated Projects](#affiliated-projects)
|
- [Affiliated Projects](#affiliated-projects)
|
||||||
- [Important Note](#important-note)
|
- [Important Note](#important-note)
|
||||||
|
|
||||||
|
<p align="right">This project is supported by:<br/>
|
||||||
|
<a href="https://m.do.co/c/8d70b916d462" style="padding-top: 4px; display: block;">
|
||||||
|
<picture>
|
||||||
|
<source media="(prefers-color-scheme: dark)" srcset="https://opensource.nyc3.cdn.digitaloceanspaces.com/attribution/assets/SVG/DO_Logo_horizontal_white.svg" width="140px">
|
||||||
|
<source media="(prefers-color-scheme: light)" srcset="https://opensource.nyc3.cdn.digitaloceanspaces.com/attribution/assets/SVG/DO_Logo_horizontal_black_.svg" width="140px">
|
||||||
|
<img src="https://opensource.nyc3.cdn.digitaloceanspaces.com/attribution/assets/SVG/DO_Logo_horizontal_black_.svg" width="140px">
|
||||||
|
</picture>
|
||||||
|
</a>
|
||||||
|
</p>
|
||||||
|
|
||||||
# Features
|
# Features
|
||||||
|
|
||||||
<picture>
|
<picture>
|
||||||
|
9
SECURITY.md
Normal file
9
SECURITY.md
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
# Security Policy
|
||||||
|
|
||||||
|
## Reporting a Vulnerability
|
||||||
|
|
||||||
|
The Paperless-ngx team and community take security bugs seriously. We appreciate your efforts to responsibly disclose your findings, and will make every effort to acknowledge your contributions.
|
||||||
|
|
||||||
|
To report a security issue, please use the GitHub Security Advisory ["Report a Vulnerability"](https://github.com/paperless-ngx/paperless-ngx/security/advisories/new) tab.
|
||||||
|
|
||||||
|
The team will send a response indicating the next steps in handling your report. After the initial reply to your report, the security team will keep you informed of the progress towards a fix and full announcement, and may ask for additional information or guidance.
|
@@ -90,13 +90,13 @@ initialize() {
|
|||||||
fi
|
fi
|
||||||
done
|
done
|
||||||
|
|
||||||
local -r tmp_dir="/tmp/paperless"
|
local -r tmp_dir="${PAPERLESS_SCRATCH_DIR:=/tmp/paperless}"
|
||||||
echo "Creating directory ${tmp_dir}"
|
echo "Creating directory scratch directory ${tmp_dir}"
|
||||||
mkdir --parents "${tmp_dir}"
|
mkdir --parents "${tmp_dir}"
|
||||||
|
|
||||||
set +e
|
set +e
|
||||||
echo "Adjusting permissions of paperless files. This may take a while."
|
echo "Adjusting permissions of paperless files. This may take a while."
|
||||||
chown -R paperless:paperless ${tmp_dir}
|
chown -R paperless:paperless "${tmp_dir}"
|
||||||
for dir in \
|
for dir in \
|
||||||
"${export_dir}" \
|
"${export_dir}" \
|
||||||
"${DATA_DIR}" \
|
"${DATA_DIR}" \
|
||||||
|
@@ -1,14 +1,15 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
|
|
||||||
|
SUPERVISORD_WORKING_DIR="${PAPERLESS_SUPERVISORD_WORKING_DIR:-$PWD}"
|
||||||
rootless_args=()
|
rootless_args=()
|
||||||
if [ "$(id -u)" == "$(id -u paperless)" ]; then
|
if [ "$(id -u)" == "$(id -u paperless)" ]; then
|
||||||
rootless_args=(
|
rootless_args=(
|
||||||
--user
|
--user
|
||||||
paperless
|
paperless
|
||||||
--logfile
|
--logfile
|
||||||
supervisord.log
|
"${SUPERVISORD_WORKING_DIR}/supervisord.log"
|
||||||
--pidfile
|
--pidfile
|
||||||
supervisord.pid
|
"${SUPERVISORD_WORKING_DIR}/supervisord.pid"
|
||||||
)
|
)
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
@@ -67,15 +67,15 @@ you installed paperless-ngx in the first place. The releases are
|
|||||||
available at the [release
|
available at the [release
|
||||||
page](https://github.com/paperless-ngx/paperless-ngx/releases).
|
page](https://github.com/paperless-ngx/paperless-ngx/releases).
|
||||||
|
|
||||||
First of all, ensure that paperless is stopped.
|
First of all, make sure no active processes (like consumption) are running, then [make a backup](#backup).
|
||||||
|
|
||||||
|
After that, ensure that paperless is stopped:
|
||||||
|
|
||||||
```shell-session
|
```shell-session
|
||||||
$ cd /path/to/paperless
|
$ cd /path/to/paperless
|
||||||
$ docker compose down
|
$ docker compose down
|
||||||
```
|
```
|
||||||
|
|
||||||
After that, [make a backup](#backup).
|
|
||||||
|
|
||||||
1. If you pull the image from the docker hub, all you need to do is:
|
1. If you pull the image from the docker hub, all you need to do is:
|
||||||
|
|
||||||
```shell-session
|
```shell-session
|
||||||
|
@@ -437,7 +437,7 @@ with Prometheus, as it exports metrics. For details on its capabilities,
|
|||||||
refer to the [Flower](https://flower.readthedocs.io/en/latest/index.html)
|
refer to the [Flower](https://flower.readthedocs.io/en/latest/index.html)
|
||||||
documentation.
|
documentation.
|
||||||
|
|
||||||
Flower can be enabled with the setting [PAPERLESS_ENABLE_FLOWER](configuration/#PAPERLESS_ENABLE_FLOWER).
|
Flower can be enabled with the setting [PAPERLESS_ENABLE_FLOWER](configuration.md#PAPERLESS_ENABLE_FLOWER).
|
||||||
To configure Flower further, create a `flowerconfig.py` and
|
To configure Flower further, create a `flowerconfig.py` and
|
||||||
place it into the `src/paperless` directory. For a Docker
|
place it into the `src/paperless` directory. For a Docker
|
||||||
installation, you can use volumes to accomplish this:
|
installation, you can use volumes to accomplish this:
|
||||||
@@ -517,6 +517,18 @@ existing tables) with:
|
|||||||
an older system may fix issues that can arise while setting up Paperless-ngx but
|
an older system may fix issues that can arise while setting up Paperless-ngx but
|
||||||
`utf8mb3` can cause issues with consumption (where `utf8mb4` does not).
|
`utf8mb3` can cause issues with consumption (where `utf8mb4` does not).
|
||||||
|
|
||||||
|
### Missing timezones
|
||||||
|
|
||||||
|
MySQL as well as MariaDB do not have any timezone information by default (though some
|
||||||
|
docker images such as the official MariaDB image take care of this for you) which will
|
||||||
|
cause unexpected behavior with date-based queries.
|
||||||
|
|
||||||
|
To fix this, execute one of the following commands:
|
||||||
|
|
||||||
|
MySQL: `mysql_tzinfo_to_sql /usr/share/zoneinfo | mysql -u root mysql -p`
|
||||||
|
|
||||||
|
MariaDB: `mariadb-tzinfo-to-sql /usr/share/zoneinfo | mariadb -u root mysql -p`
|
||||||
|
|
||||||
## Barcodes {#barcodes}
|
## Barcodes {#barcodes}
|
||||||
|
|
||||||
Paperless is able to utilize barcodes for automatically performing some tasks.
|
Paperless is able to utilize barcodes for automatically performing some tasks.
|
||||||
@@ -557,6 +569,14 @@ barcode is located. However, differing from the splitting, the page with the
|
|||||||
barcode _will_ be retained. This allows application of a barcode to any page, including
|
barcode _will_ be retained. This allows application of a barcode to any page, including
|
||||||
one which holds data to keep in the document.
|
one which holds data to keep in the document.
|
||||||
|
|
||||||
|
### Tag Assignment
|
||||||
|
|
||||||
|
When enabled, Paperless will parse barcodes and attempt to interpret and assign tags.
|
||||||
|
|
||||||
|
See the relevant settings [`PAPERLESS_CONSUMER_ENABLE_TAG_BARCODE`](configuration.md#PAPERLESS_CONSUMER_ENABLE_TAG_BARCODE)
|
||||||
|
and [`PAPERLESS_CONSUMER_TAG_BARCODE_MAPPING`](configuration.md#PAPERLESS_CONSUMER_TAG_BARCODE_MAPPING)
|
||||||
|
for more information.
|
||||||
|
|
||||||
## Automatic collation of double-sided documents {#collate}
|
## Automatic collation of double-sided documents {#collate}
|
||||||
|
|
||||||
!!! note
|
!!! note
|
||||||
@@ -628,3 +648,51 @@ single-sided split marker page, the split document(s) will have an empty page at
|
|||||||
whatever else was on the backside of the split marker page.) You can work around that by having
|
whatever else was on the backside of the split marker page.) You can work around that by having
|
||||||
a split marker page that has the split barcode on _both_ sides. This way, the extra page will
|
a split marker page that has the split barcode on _both_ sides. This way, the extra page will
|
||||||
get automatically removed.
|
get automatically removed.
|
||||||
|
|
||||||
|
## SSO and third party authentication with Paperless-ngx
|
||||||
|
|
||||||
|
Paperless-ngx has a built-in authentication system from Django but you can easily integrate an
|
||||||
|
external authentication solution using one of the following methods:
|
||||||
|
|
||||||
|
### Remote User authentication
|
||||||
|
|
||||||
|
This is a simple option that uses remote user authentication made available by certain SSO
|
||||||
|
applications. See the relevant configuration options for more information:
|
||||||
|
[PAPERLESS_ENABLE_HTTP_REMOTE_USER](configuration.md#PAPERLESS_ENABLE_HTTP_REMOTE_USER) and
|
||||||
|
[PAPERLESS_HTTP_REMOTE_USER_HEADER_NAME](configuration.md#PAPERLESS_HTTP_REMOTE_USER_HEADER_NAME)
|
||||||
|
|
||||||
|
### OpenID Connect and social authentication
|
||||||
|
|
||||||
|
Version 2.5.0 of Paperless-ngx added support for integrating other authentication systems via
|
||||||
|
the [django-allauth](https://github.com/pennersr/django-allauth) package. Once set up, users
|
||||||
|
can either log in or (optionally) sign up using any third party systems you integrate. See the
|
||||||
|
relevant [configuration settings](configuration.md#PAPERLESS_SOCIALACCOUNT_PROVIDERS) and
|
||||||
|
[django-allauth docs](https://docs.allauth.org/en/latest/socialaccount/configuration.html)
|
||||||
|
for more information.
|
||||||
|
|
||||||
|
To associate an existing Paperless-ngx account with a social account, first login with your
|
||||||
|
regular credentials and then choose "My Profile" from the user dropdown in the app and you
|
||||||
|
will see options to connect social account(s). If enabled, signup options will be available
|
||||||
|
on the login page.
|
||||||
|
|
||||||
|
As an example, to set up login via Github, the following environment variables would need to be
|
||||||
|
set:
|
||||||
|
|
||||||
|
```conf
|
||||||
|
PAPERLESS_APPS="allauth.socialaccount.providers.github"
|
||||||
|
PAPERLESS_SOCIALACCOUNT_PROVIDERS='{"github": {"APPS": [{"provider_id": "github","name": "Github","client_id": "<CLIENT_ID>","secret": "<CLIENT_SECRET>"}]}}'
|
||||||
|
```
|
||||||
|
|
||||||
|
Or, to use OpenID Connect ("OIDC"), via Keycloak in this example:
|
||||||
|
|
||||||
|
```conf
|
||||||
|
PAPERLESS_APPS="allauth.socialaccount.providers.openid_connect"
|
||||||
|
PAPERLESS_SOCIALACCOUNT_PROVIDERS='
|
||||||
|
{"openid_connect": {"APPS": [{"provider_id": "keycloak","name": "Keycloak","client_id": "paperless","secret": "<CLIENT_SECRET>","settings": { "server_url": "https://<KEYCLOAK_SERVER>/realms/<REALM>/.well-known/openid-configuration"}}]}}'
|
||||||
|
```
|
||||||
|
|
||||||
|
More details about configuration option for various providers can be found in the [allauth documentation](https://docs.allauth.org/en/latest/socialaccount/providers/index.html#provider-specifics).
|
||||||
|
|
||||||
|
### Disabling Regular Login
|
||||||
|
|
||||||
|
Once external auth is set up, 'regular' login can be disabled with the [PAPERLESS_DISABLE_REGULAR_LOGIN](configuration.md#PAPERLESS_DISABLE_REGULAR_LOGIN) setting.
|
||||||
|
83
docs/api.md
83
docs/api.md
@@ -58,6 +58,10 @@ fields:
|
|||||||
- `custom_fields`: Array of custom fields & values, specified as
|
- `custom_fields`: Array of custom fields & values, specified as
|
||||||
`{ field: CUSTOM_FIELD_ID, value: VALUE }`
|
`{ field: CUSTOM_FIELD_ID, value: VALUE }`
|
||||||
|
|
||||||
|
!!! note
|
||||||
|
|
||||||
|
Note that all endpoint URLs must end with a `/`slash.
|
||||||
|
|
||||||
## Downloading documents
|
## Downloading documents
|
||||||
|
|
||||||
In addition to that, the document endpoint offers these additional
|
In addition to that, the document endpoint offers these additional
|
||||||
@@ -139,7 +143,7 @@ document. Paperless only reports PDF metadata at this point.
|
|||||||
|
|
||||||
## Authorization
|
## Authorization
|
||||||
|
|
||||||
The REST api provides three different forms of authentication.
|
The REST api provides four different forms of authentication.
|
||||||
|
|
||||||
1. Basic authentication
|
1. Basic authentication
|
||||||
|
|
||||||
@@ -177,6 +181,12 @@ The REST api provides three different forms of authentication.
|
|||||||
|
|
||||||
Tokens can also be managed in the Django admin.
|
Tokens can also be managed in the Django admin.
|
||||||
|
|
||||||
|
4. Remote User authentication
|
||||||
|
|
||||||
|
If enabled (see
|
||||||
|
[configuration](configuration.md#PAPERLESS_ENABLE_HTTP_REMOTE_USER_API)),
|
||||||
|
you can authenticate against the API using Remote User auth.
|
||||||
|
|
||||||
## Searching for documents
|
## Searching for documents
|
||||||
|
|
||||||
Full text searching is available on the `/api/documents/` endpoint. Two
|
Full text searching is available on the `/api/documents/` endpoint. Two
|
||||||
@@ -185,7 +195,7 @@ results:
|
|||||||
|
|
||||||
- `/api/documents/?query=your%20search%20query`: Search for a document
|
- `/api/documents/?query=your%20search%20query`: Search for a document
|
||||||
using a full text query. For details on the syntax, see [Basic Usage - Searching](usage.md#basic-usage_searching).
|
using a full text query. For details on the syntax, see [Basic Usage - Searching](usage.md#basic-usage_searching).
|
||||||
- `/api/documents/?more_like=1234`: Search for documents similar to
|
- `/api/documents/?more_like_id=1234`: Search for documents similar to
|
||||||
the document with id 1234.
|
the document with id 1234.
|
||||||
|
|
||||||
Pagination works exactly the same as it does for normal requests on this
|
Pagination works exactly the same as it does for normal requests on this
|
||||||
@@ -324,6 +334,65 @@ granted). You can pass the parameter `full_perms=true` to API calls to view the
|
|||||||
full permissions of objects in a format that mirrors the `set_permissions`
|
full permissions of objects in a format that mirrors the `set_permissions`
|
||||||
parameter above.
|
parameter above.
|
||||||
|
|
||||||
|
## Bulk Editing
|
||||||
|
|
||||||
|
The API supports various bulk-editing operations which are executed asynchronously.
|
||||||
|
|
||||||
|
### Documents
|
||||||
|
|
||||||
|
For bulk operations on documents, use the endpoint `/api/bulk_edit/` which accepts
|
||||||
|
a json payload of the format:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"documents": [LIST_OF_DOCUMENT_IDS],
|
||||||
|
"method": METHOD, // see below
|
||||||
|
"parameters": args // see below
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
The following methods are supported:
|
||||||
|
|
||||||
|
- `set_correspondent`
|
||||||
|
- Requires `parameters`: `{ "correspondent": CORRESPONDENT_ID }`
|
||||||
|
- `set_document_type`
|
||||||
|
- Requires `parameters`: `{ "document_type": DOCUMENT_TYPE_ID }`
|
||||||
|
- `set_storage_path`
|
||||||
|
- Requires `parameters`: `{ "storage_path": STORAGE_PATH_ID }`
|
||||||
|
- `add_tag`
|
||||||
|
- Requires `parameters`: `{ "tag": TAG_ID }`
|
||||||
|
- `remove_tag`
|
||||||
|
- Requires `parameters`: `{ "tag": TAG_ID }`
|
||||||
|
- `modify_tags`
|
||||||
|
- Requires `parameters`: `{ "add_tags": [LIST_OF_TAG_IDS] }` and / or `{ "remove_tags": [LIST_OF_TAG_IDS] }`
|
||||||
|
- `delete`
|
||||||
|
- No `parameters` required
|
||||||
|
- `redo_ocr`
|
||||||
|
- No `parameters` required
|
||||||
|
- `set_permissions`
|
||||||
|
- Requires `parameters`:
|
||||||
|
- `"permissions": PERMISSIONS_OBJ` (see format [above](#permissions)) and / or
|
||||||
|
- `"owner": OWNER_ID or null`
|
||||||
|
- `"merge": true or false` (defaults to false)
|
||||||
|
- The `merge` flag determines if the supplied permissions will overwrite all existing permissions (including
|
||||||
|
removing them) or be merged with existing permissions.
|
||||||
|
|
||||||
|
### Objects
|
||||||
|
|
||||||
|
Bulk editing for objects (tags, document types etc.) currently supports set permissions or delete
|
||||||
|
operations, using the endpoint: `/api/bulk_edit_objects/`, which requires a json payload of the format:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"objects": [LIST_OF_OBJECT_IDS],
|
||||||
|
"object_type": "tags", "correspondents", "document_types" or "storage_paths",
|
||||||
|
"operation": "set_permissions" or "delete",
|
||||||
|
"owner": OWNER_ID, // optional
|
||||||
|
"permissions": { "view": { "users": [] ... }, "change": { ... } }, // (see 'set_permissions' format above)
|
||||||
|
"merge": true / false // defaults to false, see above
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
## API Versioning
|
## API Versioning
|
||||||
|
|
||||||
The REST API is versioned since Paperless-ngx 1.3.0.
|
The REST API is versioned since Paperless-ngx 1.3.0.
|
||||||
@@ -380,3 +449,13 @@ Initial API version.
|
|||||||
color to use for a specific tag, which is either black or white
|
color to use for a specific tag, which is either black or white
|
||||||
depending on the brightness of `Tag.color`.
|
depending on the brightness of `Tag.color`.
|
||||||
- Removed field `Tag.colour`.
|
- Removed field `Tag.colour`.
|
||||||
|
|
||||||
|
#### Version 3
|
||||||
|
|
||||||
|
- Permissions endpoints have been added.
|
||||||
|
- The format of the `/api/ui_settings/` has changed.
|
||||||
|
|
||||||
|
#### Version 4
|
||||||
|
|
||||||
|
- Consumption templates were refactored to workflows and API endpoints
|
||||||
|
changed as such.
|
||||||
|
@@ -1,5 +1,350 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## paperless-ngx 2.6.0
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- Feature: Allow user to control PIL image pixel limit [@stumpylog](https://github.com/stumpylog) ([#5997](https://github.com/paperless-ngx/paperless-ngx/pull/5997))
|
||||||
|
- Feature: Allow a user to disable the pixel limit for OCR entirely [@stumpylog](https://github.com/stumpylog) ([#5996](https://github.com/paperless-ngx/paperless-ngx/pull/5996))
|
||||||
|
- Feature: workflow removal action [@shamoon](https://github.com/shamoon) ([#5928](https://github.com/paperless-ngx/paperless-ngx/pull/5928))
|
||||||
|
- Feature: system status [@shamoon](https://github.com/shamoon) ([#5743](https://github.com/paperless-ngx/paperless-ngx/pull/5743))
|
||||||
|
- Enhancement: better monetary field with currency code [@shamoon](https://github.com/shamoon) ([#5858](https://github.com/paperless-ngx/paperless-ngx/pull/5858))
|
||||||
|
- Enhancement: support disabling regular login [@shamoon](https://github.com/shamoon) ([#5816](https://github.com/paperless-ngx/paperless-ngx/pull/5816))
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- Fix: refactor base path settings, correct logout redirect [@shamoon](https://github.com/shamoon) ([#5976](https://github.com/paperless-ngx/paperless-ngx/pull/5976))
|
||||||
|
- Fix: always pass from UI, dont require in API [@shamoon](https://github.com/shamoon) ([#5962](https://github.com/paperless-ngx/paperless-ngx/pull/5962))
|
||||||
|
- Fix: Clear metadata cache when the filename(s) change [@stumpylog](https://github.com/stumpylog) ([#5957](https://github.com/paperless-ngx/paperless-ngx/pull/5957))
|
||||||
|
- Fix: include monetary, float and doc link values in search filters [@shamoon](https://github.com/shamoon) ([#5951](https://github.com/paperless-ngx/paperless-ngx/pull/5951))
|
||||||
|
- Fix: Better handling of a corrupted index [@stumpylog](https://github.com/stumpylog) ([#5950](https://github.com/paperless-ngx/paperless-ngx/pull/5950))
|
||||||
|
- Fix: Don't assume the location of scratch directory in Docker [@stumpylog](https://github.com/stumpylog) ([#5948](https://github.com/paperless-ngx/paperless-ngx/pull/5948))
|
||||||
|
- Fix: ensure document title always limited to 128 chars [@shamoon](https://github.com/shamoon) ([#5934](https://github.com/paperless-ngx/paperless-ngx/pull/5934))
|
||||||
|
- Fix: use for password reset emails, if set [@shamoon](https://github.com/shamoon) ([#5902](https://github.com/paperless-ngx/paperless-ngx/pull/5902))
|
||||||
|
- Fix: Correct docker compose check in install script [@ShanSanear](https://github.com/ShanSanear) ([#5917](https://github.com/paperless-ngx/paperless-ngx/pull/5917))
|
||||||
|
- Fix: respect global permissions for UI settings [@shamoon](https://github.com/shamoon) ([#5919](https://github.com/paperless-ngx/paperless-ngx/pull/5919))
|
||||||
|
- Fix: allow disable email verification during signup [@shamoon](https://github.com/shamoon) ([#5895](https://github.com/paperless-ngx/paperless-ngx/pull/5895))
|
||||||
|
- Fix: refactor accounts templates and create signup template [@shamoon](https://github.com/shamoon) ([#5899](https://github.com/paperless-ngx/paperless-ngx/pull/5899))
|
||||||
|
|
||||||
|
### Maintenance
|
||||||
|
|
||||||
|
- Chore(deps): Bump the actions group with 3 updates [@dependabot](https://github.com/dependabot) ([#5907](https://github.com/paperless-ngx/paperless-ngx/pull/5907))
|
||||||
|
- Chore: Ignores uvicorn updates in dependabot [@stumpylog](https://github.com/stumpylog) ([#5906](https://github.com/paperless-ngx/paperless-ngx/pull/5906))
|
||||||
|
|
||||||
|
### Dependencies
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>15 changes</summary>
|
||||||
|
|
||||||
|
- Chore(deps): Bump the small-changes group with 3 updates [@dependabot](https://github.com/dependabot) ([#6001](https://github.com/paperless-ngx/paperless-ngx/pull/6001))
|
||||||
|
- Chore(deps-dev): Bump the development group with 2 updates [@dependabot](https://github.com/dependabot) ([#5998](https://github.com/paperless-ngx/paperless-ngx/pull/5998))
|
||||||
|
- Chore(deps): Bump the django group with 1 update [@dependabot](https://github.com/dependabot) ([#6000](https://github.com/paperless-ngx/paperless-ngx/pull/6000))
|
||||||
|
- Chore(deps-dev): Bump [@<!---->playwright/test from 1.41.2 to 1.42.0 in /src-ui @dependabot](https://github.com/<!---->playwright/test from 1.41.2 to 1.42.0 in /src-ui @dependabot) ([#5964](https://github.com/paperless-ngx/paperless-ngx/pull/5964))
|
||||||
|
- Chore(deps-dev): Bump [@<!---->types/node from 20.11.20 to 20.11.24 in /src-ui @dependabot](https://github.com/<!---->types/node from 20.11.20 to 20.11.24 in /src-ui @dependabot) ([#5965](https://github.com/paperless-ngx/paperless-ngx/pull/5965))
|
||||||
|
- Chore(deps): Bump the frontend-angular-dependencies group in /src-ui with 11 updates [@dependabot](https://github.com/dependabot) ([#5963](https://github.com/paperless-ngx/paperless-ngx/pull/5963))
|
||||||
|
- Chore(deps-dev): Bump the frontend-eslint-dependencies group in /src-ui with 3 updates [@dependabot](https://github.com/dependabot) ([#5918](https://github.com/paperless-ngx/paperless-ngx/pull/5918))
|
||||||
|
- Chore(deps-dev): Bump [@<!---->types/node from 20.11.16 to 20.11.20 in /src-ui @dependabot](https://github.com/<!---->types/node from 20.11.16 to 20.11.20 in /src-ui @dependabot) ([#5912](https://github.com/paperless-ngx/paperless-ngx/pull/5912))
|
||||||
|
- Chore(deps): Bump zone.js from 0.14.3 to 0.14.4 in /src-ui [@dependabot](https://github.com/dependabot) ([#5913](https://github.com/paperless-ngx/paperless-ngx/pull/5913))
|
||||||
|
- Chore(deps): Bump bootstrap from 5.3.2 to 5.3.3 in /src-ui [@dependabot](https://github.com/dependabot) ([#5911](https://github.com/paperless-ngx/paperless-ngx/pull/5911))
|
||||||
|
- Chore(deps-dev): Bump typescript from 5.2.2 to 5.3.3 in /src-ui [@dependabot](https://github.com/dependabot) ([#5915](https://github.com/paperless-ngx/paperless-ngx/pull/5915))
|
||||||
|
- Chore(deps): Bump the frontend-angular-dependencies group in /src-ui with 15 updates [@dependabot](https://github.com/dependabot) ([#5908](https://github.com/paperless-ngx/paperless-ngx/pull/5908))
|
||||||
|
- Chore(deps): Bump the small-changes group with 4 updates [@dependabot](https://github.com/dependabot) ([#5916](https://github.com/paperless-ngx/paperless-ngx/pull/5916))
|
||||||
|
- Chore(deps-dev): Bump the development group with 4 updates [@dependabot](https://github.com/dependabot) ([#5914](https://github.com/paperless-ngx/paperless-ngx/pull/5914))
|
||||||
|
- Chore(deps): Bump the actions group with 3 updates [@dependabot](https://github.com/dependabot) ([#5907](https://github.com/paperless-ngx/paperless-ngx/pull/5907))
|
||||||
|
</details>
|
||||||
|
|
||||||
|
### All App Changes
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>33 changes</summary>
|
||||||
|
|
||||||
|
- Feature: Allow user to control PIL image pixel limit [@stumpylog](https://github.com/stumpylog) ([#5997](https://github.com/paperless-ngx/paperless-ngx/pull/5997))
|
||||||
|
- Enhancement: show ID when editing objects [@shamoon](https://github.com/shamoon) ([#6003](https://github.com/paperless-ngx/paperless-ngx/pull/6003))
|
||||||
|
- Feature: Allow a user to disable the pixel limit for OCR entirely [@stumpylog](https://github.com/stumpylog) ([#5996](https://github.com/paperless-ngx/paperless-ngx/pull/5996))
|
||||||
|
- Chore(deps): Bump the small-changes group with 3 updates [@dependabot](https://github.com/dependabot) ([#6001](https://github.com/paperless-ngx/paperless-ngx/pull/6001))
|
||||||
|
- Chore(deps-dev): Bump the development group with 2 updates [@dependabot](https://github.com/dependabot) ([#5998](https://github.com/paperless-ngx/paperless-ngx/pull/5998))
|
||||||
|
- Chore(deps): Bump the django group with 1 update [@dependabot](https://github.com/dependabot) ([#6000](https://github.com/paperless-ngx/paperless-ngx/pull/6000))
|
||||||
|
- Feature: workflow removal action [@shamoon](https://github.com/shamoon) ([#5928](https://github.com/paperless-ngx/paperless-ngx/pull/5928))
|
||||||
|
- Feature: system status [@shamoon](https://github.com/shamoon) ([#5743](https://github.com/paperless-ngx/paperless-ngx/pull/5743))
|
||||||
|
- Fix: refactor base path settings, correct logout redirect [@shamoon](https://github.com/shamoon) ([#5976](https://github.com/paperless-ngx/paperless-ngx/pull/5976))
|
||||||
|
- Chore(deps-dev): Bump [@<!---->playwright/test from 1.41.2 to 1.42.0 in /src-ui @dependabot](https://github.com/<!---->playwright/test from 1.41.2 to 1.42.0 in /src-ui @dependabot) ([#5964](https://github.com/paperless-ngx/paperless-ngx/pull/5964))
|
||||||
|
- Chore(deps-dev): Bump [@<!---->types/node from 20.11.20 to 20.11.24 in /src-ui @dependabot](https://github.com/<!---->types/node from 20.11.20 to 20.11.24 in /src-ui @dependabot) ([#5965](https://github.com/paperless-ngx/paperless-ngx/pull/5965))
|
||||||
|
- Chore(deps): Bump the frontend-angular-dependencies group in /src-ui with 11 updates [@dependabot](https://github.com/dependabot) ([#5963](https://github.com/paperless-ngx/paperless-ngx/pull/5963))
|
||||||
|
- Fix: always pass from UI, dont require in API [@shamoon](https://github.com/shamoon) ([#5962](https://github.com/paperless-ngx/paperless-ngx/pull/5962))
|
||||||
|
- Fix: Clear metadata cache when the filename(s) change [@stumpylog](https://github.com/stumpylog) ([#5957](https://github.com/paperless-ngx/paperless-ngx/pull/5957))
|
||||||
|
- Fix: include monetary, float and doc link values in search filters [@shamoon](https://github.com/shamoon) ([#5951](https://github.com/paperless-ngx/paperless-ngx/pull/5951))
|
||||||
|
- Fix: Better handling of a corrupted index [@stumpylog](https://github.com/stumpylog) ([#5950](https://github.com/paperless-ngx/paperless-ngx/pull/5950))
|
||||||
|
- Chore: Includes OCRMyPdf logging into the log file [@stumpylog](https://github.com/stumpylog) ([#5947](https://github.com/paperless-ngx/paperless-ngx/pull/5947))
|
||||||
|
- Fix: ensure document title always limited to 128 chars [@shamoon](https://github.com/shamoon) ([#5934](https://github.com/paperless-ngx/paperless-ngx/pull/5934))
|
||||||
|
- Enhancement: better monetary field with currency code [@shamoon](https://github.com/shamoon) ([#5858](https://github.com/paperless-ngx/paperless-ngx/pull/5858))
|
||||||
|
- Change: add Thumbs.db to default ignores [@DennisGaida](https://github.com/DennisGaida) ([#5924](https://github.com/paperless-ngx/paperless-ngx/pull/5924))
|
||||||
|
- Fix: use for password reset emails, if set [@shamoon](https://github.com/shamoon) ([#5902](https://github.com/paperless-ngx/paperless-ngx/pull/5902))
|
||||||
|
- Fix: respect global permissions for UI settings [@shamoon](https://github.com/shamoon) ([#5919](https://github.com/paperless-ngx/paperless-ngx/pull/5919))
|
||||||
|
- Chore(deps-dev): Bump the frontend-eslint-dependencies group in /src-ui with 3 updates [@dependabot](https://github.com/dependabot) ([#5918](https://github.com/paperless-ngx/paperless-ngx/pull/5918))
|
||||||
|
- Chore(deps-dev): Bump [@<!---->types/node from 20.11.16 to 20.11.20 in /src-ui @dependabot](https://github.com/<!---->types/node from 20.11.16 to 20.11.20 in /src-ui @dependabot) ([#5912](https://github.com/paperless-ngx/paperless-ngx/pull/5912))
|
||||||
|
- Chore(deps): Bump zone.js from 0.14.3 to 0.14.4 in /src-ui [@dependabot](https://github.com/dependabot) ([#5913](https://github.com/paperless-ngx/paperless-ngx/pull/5913))
|
||||||
|
- Chore(deps): Bump bootstrap from 5.3.2 to 5.3.3 in /src-ui [@dependabot](https://github.com/dependabot) ([#5911](https://github.com/paperless-ngx/paperless-ngx/pull/5911))
|
||||||
|
- Chore(deps-dev): Bump typescript from 5.2.2 to 5.3.3 in /src-ui [@dependabot](https://github.com/dependabot) ([#5915](https://github.com/paperless-ngx/paperless-ngx/pull/5915))
|
||||||
|
- Chore(deps): Bump the frontend-angular-dependencies group in /src-ui with 15 updates [@dependabot](https://github.com/dependabot) ([#5908](https://github.com/paperless-ngx/paperless-ngx/pull/5908))
|
||||||
|
- Fix: allow disable email verification during signup [@shamoon](https://github.com/shamoon) ([#5895](https://github.com/paperless-ngx/paperless-ngx/pull/5895))
|
||||||
|
- Fix: refactor accounts templates and create signup template [@shamoon](https://github.com/shamoon) ([#5899](https://github.com/paperless-ngx/paperless-ngx/pull/5899))
|
||||||
|
- Chore(deps): Bump the small-changes group with 4 updates [@dependabot](https://github.com/dependabot) ([#5916](https://github.com/paperless-ngx/paperless-ngx/pull/5916))
|
||||||
|
- Chore(deps-dev): Bump the development group with 4 updates [@dependabot](https://github.com/dependabot) ([#5914](https://github.com/paperless-ngx/paperless-ngx/pull/5914))
|
||||||
|
- Enhancement: support disabling regular login [@shamoon](https://github.com/shamoon) ([#5816](https://github.com/paperless-ngx/paperless-ngx/pull/5816))
|
||||||
|
</details>
|
||||||
|
|
||||||
|
## paperless-ngx 2.5.4
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- Fix: handle title placeholder for docs without original_filename [@shamoon](https://github.com/shamoon) ([#5828](https://github.com/paperless-ngx/paperless-ngx/pull/5828))
|
||||||
|
- Fix: bulk edit objects does not respect global permissions [@shamoon](https://github.com/shamoon) ([#5888](https://github.com/paperless-ngx/paperless-ngx/pull/5888))
|
||||||
|
- Fix: intermittent save \& close warnings [@shamoon](https://github.com/shamoon) ([#5838](https://github.com/paperless-ngx/paperless-ngx/pull/5838))
|
||||||
|
- Fix: inotify read timeout not in ms [@grembo](https://github.com/grembo) ([#5876](https://github.com/paperless-ngx/paperless-ngx/pull/5876))
|
||||||
|
- Fix: allow relative date queries not in quick list [@shamoon](https://github.com/shamoon) ([#5801](https://github.com/paperless-ngx/paperless-ngx/pull/5801))
|
||||||
|
- Fix: pass rule id to consumed .eml files [@shamoon](https://github.com/shamoon) ([#5800](https://github.com/paperless-ngx/paperless-ngx/pull/5800))
|
||||||
|
|
||||||
|
### Dependencies
|
||||||
|
|
||||||
|
- Chore(deps): Bump cryptography from 42.0.2 to 42.0.4 [@dependabot](https://github.com/dependabot) ([#5851](https://github.com/paperless-ngx/paperless-ngx/pull/5851))
|
||||||
|
- Chore(deps-dev): Bump ip from 2.0.0 to 2.0.1 in /src-ui [@dependabot](https://github.com/dependabot) ([#5835](https://github.com/paperless-ngx/paperless-ngx/pull/5835))
|
||||||
|
- Chore(deps): Bump undici and [@<!---->angular-devkit/build-angular in /src-ui @dependabot](https://github.com/<!---->angular-devkit/build-angular in /src-ui @dependabot) ([#5796](https://github.com/paperless-ngx/paperless-ngx/pull/5796))
|
||||||
|
|
||||||
|
### All App Changes
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>8 changes</summary>
|
||||||
|
|
||||||
|
- Fix: handle title placeholder for docs without original_filename [@shamoon](https://github.com/shamoon) ([#5828](https://github.com/paperless-ngx/paperless-ngx/pull/5828))
|
||||||
|
- Fix: bulk edit objects does not respect global permissions [@shamoon](https://github.com/shamoon) ([#5888](https://github.com/paperless-ngx/paperless-ngx/pull/5888))
|
||||||
|
- Fix: intermittent save \& close warnings [@shamoon](https://github.com/shamoon) ([#5838](https://github.com/paperless-ngx/paperless-ngx/pull/5838))
|
||||||
|
- Fix: inotify read timeout not in ms [@grembo](https://github.com/grembo) ([#5876](https://github.com/paperless-ngx/paperless-ngx/pull/5876))
|
||||||
|
- Chore(deps-dev): Bump ip from 2.0.0 to 2.0.1 in /src-ui [@dependabot](https://github.com/dependabot) ([#5835](https://github.com/paperless-ngx/paperless-ngx/pull/5835))
|
||||||
|
- Chore(deps): Bump undici and [@<!---->angular-devkit/build-angular in /src-ui @dependabot](https://github.com/<!---->angular-devkit/build-angular in /src-ui @dependabot) ([#5796](https://github.com/paperless-ngx/paperless-ngx/pull/5796))
|
||||||
|
- Fix: allow relative date queries not in quick list [@shamoon](https://github.com/shamoon) ([#5801](https://github.com/paperless-ngx/paperless-ngx/pull/5801))
|
||||||
|
- Fix: pass rule id to consumed .eml files [@shamoon](https://github.com/shamoon) ([#5800](https://github.com/paperless-ngx/paperless-ngx/pull/5800))
|
||||||
|
</details>
|
||||||
|
|
||||||
|
## paperless-ngx 2.5.3
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- Fix: dont allow allauth redirects to any host [@shamoon](https://github.com/shamoon) ([#5783](https://github.com/paperless-ngx/paperless-ngx/pull/5783))
|
||||||
|
- Fix: Interaction when both splitting and ASN are enabled [@stumpylog](https://github.com/stumpylog) ([#5779](https://github.com/paperless-ngx/paperless-ngx/pull/5779))
|
||||||
|
- Fix: moved ssl_mode parameter for mysql backend engine [@MaciejSzczurek](https://github.com/MaciejSzczurek) ([#5771](https://github.com/paperless-ngx/paperless-ngx/pull/5771))
|
||||||
|
|
||||||
|
### All App Changes
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>3 changes</summary>
|
||||||
|
|
||||||
|
- Fix: dont allow allauth redirects to any host [@shamoon](https://github.com/shamoon) ([#5783](https://github.com/paperless-ngx/paperless-ngx/pull/5783))
|
||||||
|
- Fix: Interaction when both splitting and ASN are enabled [@stumpylog](https://github.com/stumpylog) ([#5779](https://github.com/paperless-ngx/paperless-ngx/pull/5779))
|
||||||
|
- Fix: moved ssl_mode parameter for mysql backend engine [@MaciejSzczurek](https://github.com/MaciejSzczurek) ([#5771](https://github.com/paperless-ngx/paperless-ngx/pull/5771))
|
||||||
|
</details>
|
||||||
|
|
||||||
|
## paperless-ngx 2.5.2
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- Fix: Generated secret key may include single or double quotes [@schmidtnz](https://github.com/schmidtnz) ([#5767](https://github.com/paperless-ngx/paperless-ngx/pull/5767))
|
||||||
|
- Fix: consumer status alerts container blocks elements [@shamoon](https://github.com/shamoon) ([#5762](https://github.com/paperless-ngx/paperless-ngx/pull/5762))
|
||||||
|
- Fix: handle document notes user format api change [@shamoon](https://github.com/shamoon) ([#5751](https://github.com/paperless-ngx/paperless-ngx/pull/5751))
|
||||||
|
- Fix: Assign ASN from barcode only after any splitting [@stumpylog](https://github.com/stumpylog) ([#5745](https://github.com/paperless-ngx/paperless-ngx/pull/5745))
|
||||||
|
|
||||||
|
### Dependencies
|
||||||
|
|
||||||
|
- Chore(deps): Bump the major-versions group with 1 update [@dependabot](https://github.com/dependabot) ([#5741](https://github.com/paperless-ngx/paperless-ngx/pull/5741))
|
||||||
|
|
||||||
|
### All App Changes
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>4 changes</summary>
|
||||||
|
|
||||||
|
- Fix: consumer status alerts container blocks elements [@shamoon](https://github.com/shamoon) ([#5762](https://github.com/paperless-ngx/paperless-ngx/pull/5762))
|
||||||
|
- Fix: handle document notes user format api change [@shamoon](https://github.com/shamoon) ([#5751](https://github.com/paperless-ngx/paperless-ngx/pull/5751))
|
||||||
|
- Fix: Assign ASN from barcode only after any splitting [@stumpylog](https://github.com/stumpylog) ([#5745](https://github.com/paperless-ngx/paperless-ngx/pull/5745))
|
||||||
|
- Chore(deps): Bump the major-versions group with 1 update [@dependabot](https://github.com/dependabot) ([#5741](https://github.com/paperless-ngx/paperless-ngx/pull/5741))
|
||||||
|
</details>
|
||||||
|
|
||||||
|
## paperless-ngx 2.5.1
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- Fix: Splitting on ASN barcodes even if not enabled [@stumpylog](https://github.com/stumpylog) ([#5740](https://github.com/paperless-ngx/paperless-ngx/pull/5740))
|
||||||
|
|
||||||
|
### Dependencies
|
||||||
|
|
||||||
|
- Chore(deps-dev): Bump the development group with 2 updates [@dependabot](https://github.com/dependabot) ([#5737](https://github.com/paperless-ngx/paperless-ngx/pull/5737))
|
||||||
|
- Chore(deps): Bump the django group with 1 update [@dependabot](https://github.com/dependabot) ([#5739](https://github.com/paperless-ngx/paperless-ngx/pull/5739))
|
||||||
|
|
||||||
|
### All App Changes
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>3 changes</summary>
|
||||||
|
|
||||||
|
- Chore(deps-dev): Bump the development group with 2 updates [@dependabot](https://github.com/dependabot) ([#5737](https://github.com/paperless-ngx/paperless-ngx/pull/5737))
|
||||||
|
- Chore(deps): Bump the django group with 1 update [@dependabot](https://github.com/dependabot) ([#5739](https://github.com/paperless-ngx/paperless-ngx/pull/5739))
|
||||||
|
- Fix: Splitting on ASN barcodes even if not enabled [@stumpylog](https://github.com/stumpylog) ([#5740](https://github.com/paperless-ngx/paperless-ngx/pull/5740))
|
||||||
|
</details>
|
||||||
|
|
||||||
|
## paperless-ngx 2.5.0
|
||||||
|
|
||||||
|
### Breaking Changes
|
||||||
|
|
||||||
|
- Enhancement: bulk delete objects [@shamoon](https://github.com/shamoon) ([#5688](https://github.com/paperless-ngx/paperless-ngx/pull/5688))
|
||||||
|
|
||||||
|
### Notable Changes
|
||||||
|
|
||||||
|
- Feature: OIDC \& social authentication [@mpflanzer](https://github.com/mpflanzer) ([#5190](https://github.com/paperless-ngx/paperless-ngx/pull/5190))
|
||||||
|
|
||||||
|
### Features
|
||||||
|
|
||||||
|
- Enhancement: confirm buttons [@shamoon](https://github.com/shamoon) ([#5680](https://github.com/paperless-ngx/paperless-ngx/pull/5680))
|
||||||
|
- Enhancement: bulk delete objects [@shamoon](https://github.com/shamoon) ([#5688](https://github.com/paperless-ngx/paperless-ngx/pull/5688))
|
||||||
|
- Feature: allow create objects from bulk edit [@shamoon](https://github.com/shamoon) ([#5667](https://github.com/paperless-ngx/paperless-ngx/pull/5667))
|
||||||
|
- Feature: Allow tagging by putting barcodes on documents [@pkrahmer](https://github.com/pkrahmer) ([#5580](https://github.com/paperless-ngx/paperless-ngx/pull/5580))
|
||||||
|
- Feature: Cache metadata and suggestions in Redis [@stumpylog](https://github.com/stumpylog) ([#5638](https://github.com/paperless-ngx/paperless-ngx/pull/5638))
|
||||||
|
- Feature: Japanese translation [@shamoon](https://github.com/shamoon) ([#5641](https://github.com/paperless-ngx/paperless-ngx/pull/5641))
|
||||||
|
- Feature: option for auto-remove inbox tags on save [@shamoon](https://github.com/shamoon) ([#5562](https://github.com/paperless-ngx/paperless-ngx/pull/5562))
|
||||||
|
- Enhancement: allow paperless to run in read-only filesystem [@hegerdes](https://github.com/hegerdes) ([#5596](https://github.com/paperless-ngx/paperless-ngx/pull/5596))
|
||||||
|
- Enhancement: mergeable bulk edit permissions [@shamoon](https://github.com/shamoon) ([#5508](https://github.com/paperless-ngx/paperless-ngx/pull/5508))
|
||||||
|
- Enhancement: re-implement remote user auth for unsafe API requests as opt-in [@shamoon](https://github.com/shamoon) ([#5561](https://github.com/paperless-ngx/paperless-ngx/pull/5561))
|
||||||
|
- Enhancement: Respect PDF cropbox for thumbnail generation [@henningBunk](https://github.com/henningBunk) ([#5531](https://github.com/paperless-ngx/paperless-ngx/pull/5531))
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- Fix: Test metadata items for Unicode issues [@stumpylog](https://github.com/stumpylog) ([#5707](https://github.com/paperless-ngx/paperless-ngx/pull/5707))
|
||||||
|
- Change: try to show preview even if metadata fails [@shamoon](https://github.com/shamoon) ([#5706](https://github.com/paperless-ngx/paperless-ngx/pull/5706))
|
||||||
|
- Fix: only check workflow trigger source if not empty [@shamoon](https://github.com/shamoon) ([#5701](https://github.com/paperless-ngx/paperless-ngx/pull/5701))
|
||||||
|
- Fix: frontend validation of number fields fails upon save [@shamoon](https://github.com/shamoon) ([#5646](https://github.com/paperless-ngx/paperless-ngx/pull/5646))
|
||||||
|
- Fix: Explicit validation of custom field name unique constraint [@shamoon](https://github.com/shamoon) ([#5647](https://github.com/paperless-ngx/paperless-ngx/pull/5647))
|
||||||
|
- Fix: Don't attempt to retrieve object types user doesn't have permissions to [@shamoon](https://github.com/shamoon) ([#5612](https://github.com/paperless-ngx/paperless-ngx/pull/5612))
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
- Documentation: add detail about consumer polling behavior [@silmaril42](https://github.com/silmaril42) ([#5674](https://github.com/paperless-ngx/paperless-ngx/pull/5674))
|
||||||
|
- Paperless-ngx Demo: new and improved [@shamoon](https://github.com/shamoon) ([#5639](https://github.com/paperless-ngx/paperless-ngx/pull/5639))
|
||||||
|
- Documentation: Add docs about missing timezones in MySQL/MariaDB [@Programie](https://github.com/Programie) ([#5583](https://github.com/paperless-ngx/paperless-ngx/pull/5583))
|
||||||
|
|
||||||
|
### Maintenance
|
||||||
|
|
||||||
|
- Chore(deps): Bump the actions group with 1 update [@dependabot](https://github.com/dependabot) ([#5629](https://github.com/paperless-ngx/paperless-ngx/pull/5629))
|
||||||
|
- Chore(deps): Bump the actions group with 1 update [@dependabot](https://github.com/dependabot) ([#5597](https://github.com/paperless-ngx/paperless-ngx/pull/5597))
|
||||||
|
|
||||||
|
### Dependencies
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>9 changes</summary>
|
||||||
|
|
||||||
|
- Chore: Backend dependencies update [@stumpylog](https://github.com/stumpylog) ([#5676](https://github.com/paperless-ngx/paperless-ngx/pull/5676))
|
||||||
|
- Chore(deps-dev): Bump [@<!---->playwright/test from 1.40.1 to 1.41.2 in /src-ui @dependabot](https://github.com/<!---->playwright/test from 1.40.1 to 1.41.2 in /src-ui @dependabot) ([#5634](https://github.com/paperless-ngx/paperless-ngx/pull/5634))
|
||||||
|
- Chore(deps): Bump the frontend-angular-dependencies group in /src-ui with 19 updates [@dependabot](https://github.com/dependabot) ([#5630](https://github.com/paperless-ngx/paperless-ngx/pull/5630))
|
||||||
|
- Chore(deps-dev): Bump the frontend-jest-dependencies group in /src-ui with 2 updates [@dependabot](https://github.com/dependabot) ([#5631](https://github.com/paperless-ngx/paperless-ngx/pull/5631))
|
||||||
|
- Chore(deps-dev): Bump the frontend-eslint-dependencies group in /src-ui with 2 updates [@dependabot](https://github.com/dependabot) ([#5632](https://github.com/paperless-ngx/paperless-ngx/pull/5632))
|
||||||
|
- Chore(deps): Bump zone.js from 0.14.2 to 0.14.3 in /src-ui [@dependabot](https://github.com/dependabot) ([#5633](https://github.com/paperless-ngx/paperless-ngx/pull/5633))
|
||||||
|
- Chore(deps-dev): Bump [@<!---->types/node from 20.10.6 to 20.11.16 in /src-ui @dependabot](https://github.com/<!---->types/node from 20.10.6 to 20.11.16 in /src-ui @dependabot) ([#5635](https://github.com/paperless-ngx/paperless-ngx/pull/5635))
|
||||||
|
- Chore(deps): Bump the actions group with 1 update [@dependabot](https://github.com/dependabot) ([#5629](https://github.com/paperless-ngx/paperless-ngx/pull/5629))
|
||||||
|
- Chore(deps): Bump the actions group with 1 update [@dependabot](https://github.com/dependabot) ([#5597](https://github.com/paperless-ngx/paperless-ngx/pull/5597))
|
||||||
|
</details>
|
||||||
|
|
||||||
|
### All App Changes
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>28 changes</summary>
|
||||||
|
|
||||||
|
- Chore: Ensure all creations of directories create the parents too [@stumpylog](https://github.com/stumpylog) ([#5711](https://github.com/paperless-ngx/paperless-ngx/pull/5711))
|
||||||
|
- Fix: Test metadata items for Unicode issues [@stumpylog](https://github.com/stumpylog) ([#5707](https://github.com/paperless-ngx/paperless-ngx/pull/5707))
|
||||||
|
- Change: try to show preview even if metadata fails [@shamoon](https://github.com/shamoon) ([#5706](https://github.com/paperless-ngx/paperless-ngx/pull/5706))
|
||||||
|
- Fix: only check workflow trigger source if not empty [@shamoon](https://github.com/shamoon) ([#5701](https://github.com/paperless-ngx/paperless-ngx/pull/5701))
|
||||||
|
- Enhancement: confirm buttons [@shamoon](https://github.com/shamoon) ([#5680](https://github.com/paperless-ngx/paperless-ngx/pull/5680))
|
||||||
|
- Enhancement: bulk delete objects [@shamoon](https://github.com/shamoon) ([#5688](https://github.com/paperless-ngx/paperless-ngx/pull/5688))
|
||||||
|
- Chore: Backend dependencies update [@stumpylog](https://github.com/stumpylog) ([#5676](https://github.com/paperless-ngx/paperless-ngx/pull/5676))
|
||||||
|
- Feature: OIDC \& social authentication [@mpflanzer](https://github.com/mpflanzer) ([#5190](https://github.com/paperless-ngx/paperless-ngx/pull/5190))
|
||||||
|
- Chore: Don't write Python bytecode in the Docker image [@stumpylog](https://github.com/stumpylog) ([#5677](https://github.com/paperless-ngx/paperless-ngx/pull/5677))
|
||||||
|
- Feature: allow create objects from bulk edit [@shamoon](https://github.com/shamoon) ([#5667](https://github.com/paperless-ngx/paperless-ngx/pull/5667))
|
||||||
|
- Chore: Use memory cache backend in debug mode [@shamoon](https://github.com/shamoon) ([#5666](https://github.com/paperless-ngx/paperless-ngx/pull/5666))
|
||||||
|
- Chore: Adds additional rules for Ruff linter [@stumpylog](https://github.com/stumpylog) ([#5660](https://github.com/paperless-ngx/paperless-ngx/pull/5660))
|
||||||
|
- Feature: Allow tagging by putting barcodes on documents [@pkrahmer](https://github.com/pkrahmer) ([#5580](https://github.com/paperless-ngx/paperless-ngx/pull/5580))
|
||||||
|
- Feature: Cache metadata and suggestions in Redis [@stumpylog](https://github.com/stumpylog) ([#5638](https://github.com/paperless-ngx/paperless-ngx/pull/5638))
|
||||||
|
- Fix: frontend validation of number fields fails upon save [@shamoon](https://github.com/shamoon) ([#5646](https://github.com/paperless-ngx/paperless-ngx/pull/5646))
|
||||||
|
- Fix: Explicit validation of custom field name unique constraint [@shamoon](https://github.com/shamoon) ([#5647](https://github.com/paperless-ngx/paperless-ngx/pull/5647))
|
||||||
|
- Feature: Japanese translation [@shamoon](https://github.com/shamoon) ([#5641](https://github.com/paperless-ngx/paperless-ngx/pull/5641))
|
||||||
|
- Chore(deps-dev): Bump [@<!---->playwright/test from 1.40.1 to 1.41.2 in /src-ui @dependabot](https://github.com/<!---->playwright/test from 1.40.1 to 1.41.2 in /src-ui @dependabot) ([#5634](https://github.com/paperless-ngx/paperless-ngx/pull/5634))
|
||||||
|
- Feature: option for auto-remove inbox tags on save [@shamoon](https://github.com/shamoon) ([#5562](https://github.com/paperless-ngx/paperless-ngx/pull/5562))
|
||||||
|
- Chore(deps): Bump the frontend-angular-dependencies group in /src-ui with 19 updates [@dependabot](https://github.com/dependabot) ([#5630](https://github.com/paperless-ngx/paperless-ngx/pull/5630))
|
||||||
|
- Chore(deps-dev): Bump the frontend-jest-dependencies group in /src-ui with 2 updates [@dependabot](https://github.com/dependabot) ([#5631](https://github.com/paperless-ngx/paperless-ngx/pull/5631))
|
||||||
|
- Chore(deps-dev): Bump the frontend-eslint-dependencies group in /src-ui with 2 updates [@dependabot](https://github.com/dependabot) ([#5632](https://github.com/paperless-ngx/paperless-ngx/pull/5632))
|
||||||
|
- Chore(deps): Bump zone.js from 0.14.2 to 0.14.3 in /src-ui [@dependabot](https://github.com/dependabot) ([#5633](https://github.com/paperless-ngx/paperless-ngx/pull/5633))
|
||||||
|
- Chore(deps-dev): Bump [@<!---->types/node from 20.10.6 to 20.11.16 in /src-ui @dependabot](https://github.com/<!---->types/node from 20.10.6 to 20.11.16 in /src-ui @dependabot) ([#5635](https://github.com/paperless-ngx/paperless-ngx/pull/5635))
|
||||||
|
- Enhancement: mergeable bulk edit permissions [@shamoon](https://github.com/shamoon) ([#5508](https://github.com/paperless-ngx/paperless-ngx/pull/5508))
|
||||||
|
- Enhancement: re-implement remote user auth for unsafe API requests as opt-in [@shamoon](https://github.com/shamoon) ([#5561](https://github.com/paperless-ngx/paperless-ngx/pull/5561))
|
||||||
|
- Enhancement: Respect PDF cropbox for thumbnail generation [@henningBunk](https://github.com/henningBunk) ([#5531](https://github.com/paperless-ngx/paperless-ngx/pull/5531))
|
||||||
|
- Fix: Don't attempt to retrieve object types user doesn't have permissions to [@shamoon](https://github.com/shamoon) ([#5612](https://github.com/paperless-ngx/paperless-ngx/pull/5612))
|
||||||
|
</details>
|
||||||
|
|
||||||
|
## paperless-ngx 2.4.3
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- Fix: Ensure the scratch directory exists before consuming via the folder [@stumpylog](https://github.com/stumpylog) ([#5579](https://github.com/paperless-ngx/paperless-ngx/pull/5579))
|
||||||
|
|
||||||
|
### All App Changes
|
||||||
|
|
||||||
|
- Fix: Ensure the scratch directory exists before consuming via the folder [@stumpylog](https://github.com/stumpylog) ([#5579](https://github.com/paperless-ngx/paperless-ngx/pull/5579))
|
||||||
|
|
||||||
|
## paperless-ngx 2.4.2
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- Fix: improve one of the date matching regexes [@shamoon](https://github.com/shamoon) ([#5540](https://github.com/paperless-ngx/paperless-ngx/pull/5540))
|
||||||
|
- Fix: tweak doc detail component behavior while awaiting metadata [@shamoon](https://github.com/shamoon) ([#5546](https://github.com/paperless-ngx/paperless-ngx/pull/5546))
|
||||||
|
|
||||||
|
### All App Changes
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>2 changes</summary>
|
||||||
|
|
||||||
|
- Fix: improve one of the date matching regexes [@shamoon](https://github.com/shamoon) ([#5540](https://github.com/paperless-ngx/paperless-ngx/pull/5540))
|
||||||
|
- Fix: tweak doc detail component behavior while awaiting metadata [@shamoon](https://github.com/shamoon) ([#5546](https://github.com/paperless-ngx/paperless-ngx/pull/5546))
|
||||||
|
</details>
|
||||||
|
|
||||||
|
## paperless-ngx 2.4.1
|
||||||
|
|
||||||
|
### Breaking Changes
|
||||||
|
|
||||||
|
- Change: merge workflow permissions assignments instead of overwrite [@shamoon](https://github.com/shamoon) ([#5496](https://github.com/paperless-ngx/paperless-ngx/pull/5496))
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- Fix: Minor frontend things in 2.4.0 [@shamoon](https://github.com/shamoon) ([#5514](https://github.com/paperless-ngx/paperless-ngx/pull/5514))
|
||||||
|
- Fix: install script fails on alpine linux [@shamoon](https://github.com/shamoon) ([#5520](https://github.com/paperless-ngx/paperless-ngx/pull/5520))
|
||||||
|
- Fix: enforce permissions for app config [@shamoon](https://github.com/shamoon) ([#5516](https://github.com/paperless-ngx/paperless-ngx/pull/5516))
|
||||||
|
- Fix: render images not converted to pdf, refactor doc detail rendering [@shamoon](https://github.com/shamoon) ([#5475](https://github.com/paperless-ngx/paperless-ngx/pull/5475))
|
||||||
|
- Fix: Dont parse numbers with exponent as integer [@shamoon](https://github.com/shamoon) ([#5457](https://github.com/paperless-ngx/paperless-ngx/pull/5457))
|
||||||
|
|
||||||
|
### Maintenance
|
||||||
|
|
||||||
|
- Chore: Build fix- branches [@shamoon](https://github.com/shamoon) ([#5501](https://github.com/paperless-ngx/paperless-ngx/pull/5501))
|
||||||
|
|
||||||
|
### Dependencies
|
||||||
|
|
||||||
|
- Chore(deps-dev): Bump the development group with 1 update [@dependabot](https://github.com/dependabot) ([#5503](https://github.com/paperless-ngx/paperless-ngx/pull/5503))
|
||||||
|
|
||||||
|
### All App Changes
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>7 changes</summary>
|
||||||
|
|
||||||
|
- Revert "Enhancement: support remote user auth directly against API (DRF)" @shamoon ([#5534](https://github.com/paperless-ngx/paperless-ngx/pull/5534))
|
||||||
|
- Fix: Minor frontend things in 2.4.0 [@shamoon](https://github.com/shamoon) ([#5514](https://github.com/paperless-ngx/paperless-ngx/pull/5514))
|
||||||
|
- Fix: enforce permissions for app config [@shamoon](https://github.com/shamoon) ([#5516](https://github.com/paperless-ngx/paperless-ngx/pull/5516))
|
||||||
|
- Change: merge workflow permissions assignments instead of overwrite [@shamoon](https://github.com/shamoon) ([#5496](https://github.com/paperless-ngx/paperless-ngx/pull/5496))
|
||||||
|
- Chore(deps-dev): Bump the development group with 1 update [@dependabot](https://github.com/dependabot) ([#5503](https://github.com/paperless-ngx/paperless-ngx/pull/5503))
|
||||||
|
- Fix: render images not converted to pdf, refactor doc detail rendering [@shamoon](https://github.com/shamoon) ([#5475](https://github.com/paperless-ngx/paperless-ngx/pull/5475))
|
||||||
|
- Fix: Dont parse numbers with exponent as integer [@shamoon](https://github.com/shamoon) ([#5457](https://github.com/paperless-ngx/paperless-ngx/pull/5457))
|
||||||
|
</details>
|
||||||
|
|
||||||
## paperless-ngx 2.4.0
|
## paperless-ngx 2.4.0
|
||||||
|
|
||||||
### Features
|
### Features
|
||||||
|
@@ -34,6 +34,8 @@ matcher.
|
|||||||
`redis://<username>:<password>@<host>:<port>`
|
`redis://<username>:<password>@<host>:<port>`
|
||||||
- With the requirepass option PAPERLESS_REDIS =
|
- With the requirepass option PAPERLESS_REDIS =
|
||||||
`redis://:<password>@<host>:<port>`
|
`redis://:<password>@<host>:<port>`
|
||||||
|
- To include the redis database index PAPERLESS_REDIS =
|
||||||
|
`redis://<username>:<password>@<host>:<port>/<DBIndex>`
|
||||||
|
|
||||||
[More information on securing your Redis
|
[More information on securing your Redis
|
||||||
Instance](https://redis.io/docs/getting-started/#securing-redis).
|
Instance](https://redis.io/docs/getting-started/#securing-redis).
|
||||||
@@ -452,19 +454,32 @@ applications.
|
|||||||
|
|
||||||
This will allow authentication by simply adding a
|
This will allow authentication by simply adding a
|
||||||
`Remote-User: <username>` header to a request. Use with care! You
|
`Remote-User: <username>` header to a request. Use with care! You
|
||||||
especially *must: ensure that any such header is not passed from
|
especially *must* ensure that any such header is not passed from
|
||||||
your proxy server to paperless.
|
external requests to your reverse-proxy to paperless (that would
|
||||||
|
effectively bypass all authentication).
|
||||||
|
|
||||||
If you're exposing paperless to the internet directly, do not use
|
If you're exposing paperless to the internet directly (i.e.
|
||||||
this.
|
without a reverse proxy), do not use this.
|
||||||
|
|
||||||
Also see the warning [in the official documentation](https://docs.djangoproject.com/en/4.1/howto/auth-remote-user/#configuration).
|
Also see the warning [in the official documentation](https://docs.djangoproject.com/en/4.1/howto/auth-remote-user/#configuration).
|
||||||
|
|
||||||
Defaults to "false" which disables this feature.
|
Defaults to "false" which disables this feature.
|
||||||
|
|
||||||
|
#### [`PAPERLESS_ENABLE_HTTP_REMOTE_USER_API=<bool>`](#PAPERLESS_ENABLE_HTTP_REMOTE_USER_API) {#PAPERLESS_ENABLE_HTTP_REMOTE_USER_API}
|
||||||
|
|
||||||
|
: Allows authentication via HTTP_REMOTE_USER directly against the API
|
||||||
|
|
||||||
|
!!! warning
|
||||||
|
|
||||||
|
See the warning above about securing your installation when using remote user header authentication. This setting is separate from
|
||||||
|
`PAPERLESS_ENABLE_HTTP_REMOTE_USER` to avoid introducing a security vulnerability to existing reverse proxy setups. As above,
|
||||||
|
ensure that your reverse proxy does not simply pass the `Remote-User` header from the internet to paperless.
|
||||||
|
|
||||||
|
Defaults to "false" which disables this feature.
|
||||||
|
|
||||||
#### [`PAPERLESS_HTTP_REMOTE_USER_HEADER_NAME=<str>`](#PAPERLESS_HTTP_REMOTE_USER_HEADER_NAME) {#PAPERLESS_HTTP_REMOTE_USER_HEADER_NAME}
|
#### [`PAPERLESS_HTTP_REMOTE_USER_HEADER_NAME=<str>`](#PAPERLESS_HTTP_REMOTE_USER_HEADER_NAME) {#PAPERLESS_HTTP_REMOTE_USER_HEADER_NAME}
|
||||||
|
|
||||||
: If "PAPERLESS_ENABLE_HTTP_REMOTE_USER" is enabled, this
|
: If "PAPERLESS_ENABLE_HTTP_REMOTE_USER" or `PAPERLESS_ENABLE_HTTP_REMOTE_USER_API` are enabled, this
|
||||||
property allows to customize the name of the HTTP header from which
|
property allows to customize the name of the HTTP header from which
|
||||||
the authenticated username is extracted. Values are in terms of
|
the authenticated username is extracted. Values are in terms of
|
||||||
[HttpRequest.META](https://docs.djangoproject.com/en/4.1/ref/request-response/#django.http.HttpRequest.META).
|
[HttpRequest.META](https://docs.djangoproject.com/en/4.1/ref/request-response/#django.http.HttpRequest.META).
|
||||||
@@ -521,6 +536,59 @@ This is for use with self-signed certificates against local IMAP servers.
|
|||||||
Settings this value has security implications for the security of your email.
|
Settings this value has security implications for the security of your email.
|
||||||
Understand what it does and be sure you need to before setting.
|
Understand what it does and be sure you need to before setting.
|
||||||
|
|
||||||
|
#### [`PAPERLESS_SOCIALACCOUNT_PROVIDERS=<json>`](#PAPERLESS_SOCIALACCOUNT_PROVIDERS) {#PAPERLESS_SOCIALACCOUNT_PROVIDERS}
|
||||||
|
|
||||||
|
: This variable is used to setup login and signup via social account providers which are compatible with django-allauth.
|
||||||
|
See the corresponding [django-allauth documentation](https://docs.allauth.org/en/latest/socialaccount/providers/index.html)
|
||||||
|
for a list of provider configurations. You will also need to include the relevant Django 'application' inside the
|
||||||
|
[PAPERLESS_APPS](#PAPERLESS_APPS) setting to activate that specific authentication provider (e.g. `allauth.socialaccount.providers.openid_connect` for the [OIDC Connect provider](https://docs.allauth.org/en/latest/socialaccount/providers/openid_connect.html)).
|
||||||
|
|
||||||
|
Defaults to None, which does not enable any third party authentication systems.
|
||||||
|
|
||||||
|
#### [`PAPERLESS_SOCIAL_AUTO_SIGNUP=<bool>`](#PAPERLESS_SOCIAL_AUTO_SIGNUP) {#PAPERLESS_SOCIAL_AUTO_SIGNUP}
|
||||||
|
|
||||||
|
: Attempt to signup the user using retrieved email, username etc from the third party authentication
|
||||||
|
system. See the corresponding
|
||||||
|
[django-allauth documentation](https://docs.allauth.org/en/latest/socialaccount/configuration.html)
|
||||||
|
|
||||||
|
Defaults to False
|
||||||
|
|
||||||
|
#### [`PAPERLESS_SOCIALACCOUNT_ALLOW_SIGNUPS=<bool>`](#PAPERLESS_SOCIALACCOUNT_ALLOW_SIGNUPS) {#PAPERLESS_SOCIALACCOUNT_ALLOW_SIGNUPS}
|
||||||
|
|
||||||
|
: Allow users to signup for a new Paperless-ngx account using any setup third party authentication systems.
|
||||||
|
|
||||||
|
Defaults to True
|
||||||
|
|
||||||
|
#### [`PAPERLESS_ACCOUNT_ALLOW_SIGNUPS=<bool>`](#PAPERLESS_ACCOUNT_ALLOW_SIGNUPS) {#PAPERLESS_ACCOUNT_ALLOW_SIGNUPS}
|
||||||
|
|
||||||
|
: Allow users to signup for a new Paperless-ngx account.
|
||||||
|
|
||||||
|
Defaults to False
|
||||||
|
|
||||||
|
#### [`PAPERLESS_ACCOUNT_DEFAULT_HTTP_PROTOCOL=<string>`](#PAPERLESS_ACCOUNT_DEFAULT_HTTP_PROTOCOL) {#PAPERLESS_ACCOUNT_DEFAULT_HTTP_PROTOCOL}
|
||||||
|
|
||||||
|
: The protocol used when generating URLs, e.g. login callback URLs. See the corresponding
|
||||||
|
[django-allauth documentation](https://docs.allauth.org/en/latest/account/configuration.html)
|
||||||
|
|
||||||
|
Defaults to 'https'
|
||||||
|
|
||||||
|
#### [`PAPERLESS_ACCOUNT_EMAIL_VERIFICATION=<string>`](#PAPERLESS_ACCOUNT_EMAIL_VERIFICATION) {#PAPERLESS_ACCOUNT_EMAIL_VERIFICATION}
|
||||||
|
|
||||||
|
: Determines whether email addresses are verified during signup (as performed by Django allauth). See the relevant
|
||||||
|
[paperless settings](#PAPERLESS_EMAIL_HOST) and [the allauth docs](https://docs.allauth.org/en/latest/account/configuration.html)
|
||||||
|
|
||||||
|
Defaults to 'optional'
|
||||||
|
|
||||||
|
!!! note
|
||||||
|
|
||||||
|
If you do not have a working email server set up you should set this to 'none'.
|
||||||
|
|
||||||
|
#### [`PAPERLESS_DISABLE_REGULAR_LOGIN=<bool>`](#PAPERLESS_DISABLE_REGULAR_LOGIN) {#PAPERLESS_DISABLE_REGULAR_LOGIN}
|
||||||
|
|
||||||
|
: Disables the regular frontend username / password login, i.e. once you have setup SSO. Note that the Django admin login cannot be disabled.
|
||||||
|
|
||||||
|
Defaults to False
|
||||||
|
|
||||||
## OCR settings {#ocr}
|
## OCR settings {#ocr}
|
||||||
|
|
||||||
Paperless uses [OCRmyPDF](https://ocrmypdf.readthedocs.io/en/latest/)
|
Paperless uses [OCRmyPDF](https://ocrmypdf.readthedocs.io/en/latest/)
|
||||||
@@ -698,6 +766,8 @@ but could result in missing text content.
|
|||||||
If unset, will default to the value determined by
|
If unset, will default to the value determined by
|
||||||
[Pillow](https://pillow.readthedocs.io/en/stable/reference/Image.html#PIL.Image.MAX_IMAGE_PIXELS).
|
[Pillow](https://pillow.readthedocs.io/en/stable/reference/Image.html#PIL.Image.MAX_IMAGE_PIXELS).
|
||||||
|
|
||||||
|
Setting this value to 0 will entirely disable the limit. See the below warning.
|
||||||
|
|
||||||
!!! note
|
!!! note
|
||||||
|
|
||||||
Increasing this limit could cause Paperless to consume additional
|
Increasing this limit could cause Paperless to consume additional
|
||||||
@@ -707,7 +777,7 @@ but could result in missing text content.
|
|||||||
!!! warning
|
!!! warning
|
||||||
|
|
||||||
The limit is intended to prevent malicious files from consuming
|
The limit is intended to prevent malicious files from consuming
|
||||||
system resources and causing crashes and other errors. Only increase
|
system resources and causing crashes and other errors. Only change
|
||||||
this value if you are certain your documents are not malicious and
|
this value if you are certain your documents are not malicious and
|
||||||
you need the text which was not OCRed
|
you need the text which was not OCRed
|
||||||
|
|
||||||
@@ -891,6 +961,28 @@ documents.
|
|||||||
|
|
||||||
Default is none, which disables the temporary directory.
|
Default is none, which disables the temporary directory.
|
||||||
|
|
||||||
|
#### [`PAPERLESS_APPS=<string>`](#PAPERLESS_APPS) {#PAPERLESS_APPS}
|
||||||
|
|
||||||
|
: A comma-separated list of Django apps to be included in Django's
|
||||||
|
[`INSTALLED_APPS`](https://docs.djangoproject.com/en/5.0/ref/applications/). This setting should
|
||||||
|
be used with caution!
|
||||||
|
|
||||||
|
Defaults to None, which does not add any additional apps.
|
||||||
|
|
||||||
|
#### [`PAPERLESS_MAX_IMAGE_PIXELS=<number>`](#PAPERLESS_MAX_IMAGE_PIXELS) {#PAPERLESS_MAX_IMAGE_PIXELS}
|
||||||
|
|
||||||
|
: Configures the maximum size of an image PIL will allow to load without warning or error.
|
||||||
|
|
||||||
|
: If unset, will default to the value determined by
|
||||||
|
[Pillow](https://pillow.readthedocs.io/en/stable/reference/Image.html#PIL.Image.MAX_IMAGE_PIXELS).
|
||||||
|
|
||||||
|
Defaults to None, which does change the limit
|
||||||
|
|
||||||
|
!!! warning
|
||||||
|
|
||||||
|
This limit is designed to prevent denial of service from malicious files.
|
||||||
|
It should only be raised or disabled in certain circumstances and with great care.
|
||||||
|
|
||||||
## Document Consumption {#consume_config}
|
## Document Consumption {#consume_config}
|
||||||
|
|
||||||
#### [`PAPERLESS_CONSUMER_DELETE_DUPLICATES=<bool>`](#PAPERLESS_CONSUMER_DELETE_DUPLICATES) {#PAPERLESS_CONSUMER_DELETE_DUPLICATES}
|
#### [`PAPERLESS_CONSUMER_DELETE_DUPLICATES=<bool>`](#PAPERLESS_CONSUMER_DELETE_DUPLICATES) {#PAPERLESS_CONSUMER_DELETE_DUPLICATES}
|
||||||
@@ -938,7 +1030,7 @@ or hidden folders some tools use to store data.
|
|||||||
`._foo.pdf` and `._bar/foo.pdf`
|
`._foo.pdf` and `._bar/foo.pdf`
|
||||||
|
|
||||||
Defaults to
|
Defaults to
|
||||||
`[".DS_STORE/*", "._*", ".stfolder/*", ".stversions/*", ".localized/*", "desktop.ini", "@eaDir/*"]`.
|
`[".DS_Store", ".DS_STORE", "._*", ".stfolder/*", ".stversions/*", ".localized/*", "desktop.ini", "@eaDir/*", "Thumbs.db"]`.
|
||||||
|
|
||||||
#### [`PAPERLESS_CONSUMER_BARCODE_SCANNER=<string>`](#PAPERLESS_CONSUMER_BARCODE_SCANNER) {#PAPERLESS_CONSUMER_BARCODE_SCANNER}
|
#### [`PAPERLESS_CONSUMER_BARCODE_SCANNER=<string>`](#PAPERLESS_CONSUMER_BARCODE_SCANNER) {#PAPERLESS_CONSUMER_BARCODE_SCANNER}
|
||||||
|
|
||||||
@@ -1049,8 +1141,10 @@ system changes with `inotify`.
|
|||||||
|
|
||||||
#### [`PAPERLESS_CONSUMER_POLLING_RETRY_COUNT=<num>`](#PAPERLESS_CONSUMER_POLLING_RETRY_COUNT) {#PAPERLESS_CONSUMER_POLLING_RETRY_COUNT}
|
#### [`PAPERLESS_CONSUMER_POLLING_RETRY_COUNT=<num>`](#PAPERLESS_CONSUMER_POLLING_RETRY_COUNT) {#PAPERLESS_CONSUMER_POLLING_RETRY_COUNT}
|
||||||
|
|
||||||
: If consumer polling is enabled, sets the number of times paperless
|
: If consumer polling is enabled, sets the maximum number of times
|
||||||
will check for a file to remain unmodified.
|
paperless will check for a file to remain unmodified. If a file's
|
||||||
|
modification time and size are identical for two consecutive checks, it
|
||||||
|
will be consumed.
|
||||||
|
|
||||||
Defaults to 5.
|
Defaults to 5.
|
||||||
|
|
||||||
@@ -1159,6 +1253,55 @@ combination with PAPERLESS_CONSUMER_BARCODE_UPSCALE bigger than 1.0.
|
|||||||
|
|
||||||
Defaults to "300"
|
Defaults to "300"
|
||||||
|
|
||||||
|
#### [`PAPERLESS_CONSUMER_ENABLE_TAG_BARCODE=<bool>`](#PAPERLESS_CONSUMER_ENABLE_TAG_BARCODE) {#PAPERLESS_CONSUMER_ENABLE_TAG_BARCODE}
|
||||||
|
|
||||||
|
: Enables the detection of barcodes in the scanned document and
|
||||||
|
assigns or creates tags if a properly formatted barcode is detected.
|
||||||
|
|
||||||
|
The barcode must match one of the (configurable) regular expressions.
|
||||||
|
If the barcode text contains ',' (comma), it is split into multiple
|
||||||
|
barcodes which are individually processed for tagging.
|
||||||
|
|
||||||
|
Matching is case insensitive.
|
||||||
|
|
||||||
|
Defaults to false.
|
||||||
|
|
||||||
|
#### [`PAPERLESS_CONSUMER_TAG_BARCODE_MAPPING=<json dict>`](#PAPERLESS_CONSUMER_TAG_BARCODE_MAPPING) {#PAPERLESS_CONSUMER_TAG_BARCODE_MAPPING}
|
||||||
|
|
||||||
|
: Defines a dictionary of filter regex and substitute expressions.
|
||||||
|
|
||||||
|
Syntax: {"<regex>": "<substitute>" [,...]]}
|
||||||
|
|
||||||
|
A barcode is considered for tagging if the barcode text matches
|
||||||
|
at least one of the provided <regex> pattern.
|
||||||
|
|
||||||
|
If a match is found, the <substitute> rule is applied. This allows very
|
||||||
|
versatile reformatting and mapping of barcode pattern to tag values.
|
||||||
|
|
||||||
|
If a tag is not found it will be created.
|
||||||
|
|
||||||
|
Defaults to:
|
||||||
|
|
||||||
|
{"TAG:(.*)": "\\g<1>"} which defines
|
||||||
|
- a regex TAG:(.*) which includes barcodes beginning with TAG:
|
||||||
|
followed by any text that gets stored into match group #1 and
|
||||||
|
- a substitute \\g<1> that replaces the original barcode text
|
||||||
|
by the content in match group #1.
|
||||||
|
Consequently, the tag is the barcode text without its TAG: prefix.
|
||||||
|
|
||||||
|
More examples:
|
||||||
|
|
||||||
|
{"ASN12.*": "JOHN", "ASN13.*": "SMITH"} for example maps
|
||||||
|
- ASN12nnnn barcodes to the tag JOHN and
|
||||||
|
- ASN13nnnn barcodes to the tag SMITH.
|
||||||
|
|
||||||
|
{"T-J": "JOHN", "T-S": "SMITH", "T-D": "DOE"} directly maps
|
||||||
|
- T-J barcodes to the tag JOHN,
|
||||||
|
- T-S barcodes to the tag SMITH and
|
||||||
|
- T-D barcodes to the tag DOE.
|
||||||
|
|
||||||
|
Please refer to the Python regex documentation for more information.
|
||||||
|
|
||||||
## Audit Trail
|
## Audit Trail
|
||||||
|
|
||||||
#### [`PAPERLESS_AUDIT_LOG_ENABLED=<bool>`](#PAPERLESS_AUDIT_LOG_ENABLED) {#PAPERLESS_AUDIT_LOG_ENABLED}
|
#### [`PAPERLESS_AUDIT_LOG_ENABLED=<bool>`](#PAPERLESS_AUDIT_LOG_ENABLED) {#PAPERLESS_AUDIT_LOG_ENABLED}
|
||||||
@@ -1329,6 +1472,12 @@ started by the container.
|
|||||||
|
|
||||||
You can read more about this in the [advanced documentation](advanced_usage.md#celery-monitoring).
|
You can read more about this in the [advanced documentation](advanced_usage.md#celery-monitoring).
|
||||||
|
|
||||||
|
#### [`PAPERLESS_SUPERVISORD_WORKING_DIR=<defined>`](#PAPERLESS_SUPERVISORD_WORKING_DIR) {#PAPERLESS_SUPERVISORD_WORKING_DIR}
|
||||||
|
|
||||||
|
: If this environment variable is defined, the `supervisord.log` and `supervisord.pid` file will be created under the specified path in `PAPERLESS_SUPERVISORD_WORKING_DIR`. Setting `PAPERLESS_SUPERVISORD_WORKING_DIR=/tmp` and `PYTHONPYCACHEPREFIX=/tmp/pycache` would allow paperless to work on a read-only filesystem.
|
||||||
|
|
||||||
|
Please take note that the `PAPERLESS_DATA_DIR` and `PAPERLESS_MEDIA_ROOT` paths still have to be writable, just like the `PAPERLESS_SUPERVISORD_WORKING_DIR`. The can be archived by using bind or volume mounts. Only works in the container is run as user *paperless*
|
||||||
|
|
||||||
## Frontend Settings
|
## Frontend Settings
|
||||||
|
|
||||||
#### [`PAPERLESS_APP_TITLE=<bool>`](#PAPERLESS_APP_TITLE) {#PAPERLESS_APP_TITLE}
|
#### [`PAPERLESS_APP_TITLE=<bool>`](#PAPERLESS_APP_TITLE) {#PAPERLESS_APP_TITLE}
|
||||||
|
@@ -8,6 +8,13 @@ physical documents into a searchable online archive so you can keep, well, _less
|
|||||||
[Get started](setup.md){ .md-button .md-button--primary .index-callout }
|
[Get started](setup.md){ .md-button .md-button--primary .index-callout }
|
||||||
[Demo](https://demo.paperless-ngx.com){ .md-button .md-button--secondary target=\_blank }
|
[Demo](https://demo.paperless-ngx.com){ .md-button .md-button--secondary target=\_blank }
|
||||||
|
|
||||||
|
<div style="display: flex; justify-content: end; margin-top: -1.5rem;">
|
||||||
|
<a href="https://m.do.co/c/8d70b916d462" target="_blank">
|
||||||
|
<img src="https://opensource.nyc3.cdn.digitaloceanspaces.com/attribution/assets/PoweredByDO/DO_Powered_by_Badge_white.svg#only-dark" class="no-lightbox" width="150px">
|
||||||
|
<img src="https://opensource.nyc3.cdn.digitaloceanspaces.com/attribution/assets/PoweredByDO/DO_Powered_by_Badge_black.svg#only-light" class="no-lightbox" width="150px">
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
<div class="grid-right" markdown>
|
<div class="grid-right" markdown>
|
||||||
{.index-screenshot}
|
{.index-screenshot}
|
||||||
|
@@ -253,7 +253,8 @@ permissions can be granted to limit access to certain parts of the UI (and corre
|
|||||||
### Password reset
|
### Password reset
|
||||||
|
|
||||||
In order to enable the password reset feature you will need to setup an SMTP backend, see
|
In order to enable the password reset feature you will need to setup an SMTP backend, see
|
||||||
[`PAPERLESS_EMAIL_HOST`](configuration.md#PAPERLESS_EMAIL_HOST)
|
[`PAPERLESS_EMAIL_HOST`](configuration.md#PAPERLESS_EMAIL_HOST). If your installation does not have
|
||||||
|
[`PAPERLESS_URL`](configuration.md#PAPERLESS_URL) set, the reset link included in emails will use the server host.
|
||||||
|
|
||||||
## Workflows
|
## Workflows
|
||||||
|
|
||||||
@@ -328,14 +329,21 @@ Workflows allow you to filter by:
|
|||||||
|
|
||||||
### Workflow Actions
|
### Workflow Actions
|
||||||
|
|
||||||
There is currently one type of workflow action, "Assignment", which can assign:
|
There are currently two types of workflow actions, "Assignment", which can assign:
|
||||||
|
|
||||||
- Title, see [title placeholders](usage.md#title-placeholders) below
|
- Title, see [title placeholders](usage.md#title-placeholders) below
|
||||||
- Tags, correspondent, document types
|
- Tags, correspondent, document type and storage path
|
||||||
- Document owner
|
- Document owner
|
||||||
- View and / or edit permissions to users or groups
|
- View and / or edit permissions to users or groups
|
||||||
- Custom fields. Note that no value for the field will be set
|
- Custom fields. Note that no value for the field will be set
|
||||||
|
|
||||||
|
and "Removal" actions, which can remove either all of or specific sets of the following:
|
||||||
|
|
||||||
|
- Tags, correspondents, document types or storage paths
|
||||||
|
- Document owner
|
||||||
|
- View and / or edit permissions
|
||||||
|
- Custom fields
|
||||||
|
|
||||||
#### Title placeholders
|
#### Title placeholders
|
||||||
|
|
||||||
Workflow titles can include placeholders but the available options differ depending on the type of
|
Workflow titles can include placeholders but the available options differ depending on the type of
|
||||||
@@ -406,7 +414,7 @@ The following custom field types are supported:
|
|||||||
- `URL`: a valid url
|
- `URL`: a valid url
|
||||||
- `Integer`: integer number e.g. 12
|
- `Integer`: integer number e.g. 12
|
||||||
- `Number`: float number e.g. 12.3456
|
- `Number`: float number e.g. 12.3456
|
||||||
- `Monetary`: float number with exactly two decimals, e.g. 12.30
|
- `Monetary`: [ISO 4217 currency code](https://en.wikipedia.org/wiki/ISO_4217#List_of_ISO_4217_currency_codes) and a number with exactly two decimals, e.g. USD12.30
|
||||||
- `Document Link`: reference(s) to other document(s) displayed as links, automatically creates a symmetrical link in reverse
|
- `Document Link`: reference(s) to other document(s) displayed as links, automatically creates a symmetrical link in reverse
|
||||||
|
|
||||||
## Share Links
|
## Share Links
|
||||||
|
@@ -56,8 +56,8 @@ if ! command -v docker &> /dev/null ; then
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if ! command -v docker compose &> /dev/null ; then
|
if ! docker compose &> /dev/null ; then
|
||||||
echo "docker compose executable not found. Is docker compose installed?"
|
echo "docker compose plugin not found. Is docker compose installed?"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -315,7 +315,7 @@ fi
|
|||||||
wget "https://raw.githubusercontent.com/paperless-ngx/paperless-ngx/main/docker/compose/docker-compose.$DOCKER_COMPOSE_VERSION.yml" -O docker-compose.yml
|
wget "https://raw.githubusercontent.com/paperless-ngx/paperless-ngx/main/docker/compose/docker-compose.$DOCKER_COMPOSE_VERSION.yml" -O docker-compose.yml
|
||||||
wget "https://raw.githubusercontent.com/paperless-ngx/paperless-ngx/main/docker/compose/.env" -O .env
|
wget "https://raw.githubusercontent.com/paperless-ngx/paperless-ngx/main/docker/compose/.env" -O .env
|
||||||
|
|
||||||
SECRET_KEY=$(LC_ALL=C tr -dc 'a-zA-Z0-9!"#$%&'\''()*+,-./:;<=>?@[\]^_`{|}~' < /dev/urandom | dd bs=1 count=64 2>/dev/null)
|
SECRET_KEY=$(LC_ALL=C tr -dc 'a-zA-Z0-9!#$%&()*+,-./:;<=>?@[\]^_`{|}~' < /dev/urandom | dd bs=1 count=64 2>/dev/null)
|
||||||
|
|
||||||
|
|
||||||
DEFAULT_LANGUAGES=("deu eng fra ita spa")
|
DEFAULT_LANGUAGES=("deu eng fra ita spa")
|
||||||
|
@@ -73,4 +73,6 @@ extra:
|
|||||||
link: https://matrix.to/#/#paperless:matrix.org
|
link: https://matrix.to/#/#paperless:matrix.org
|
||||||
plugins:
|
plugins:
|
||||||
- search
|
- search
|
||||||
- glightbox
|
- glightbox:
|
||||||
|
skip_classes:
|
||||||
|
- no-lightbox
|
||||||
|
@@ -68,6 +68,8 @@
|
|||||||
#PAPERLESS_CONSUMER_BARCODE_STRING=PATCHT
|
#PAPERLESS_CONSUMER_BARCODE_STRING=PATCHT
|
||||||
#PAPERLESS_CONSUMER_BARCODE_UPSCALE=0.0
|
#PAPERLESS_CONSUMER_BARCODE_UPSCALE=0.0
|
||||||
#PAPERLESS_CONSUMER_BARCODE_DPI=300
|
#PAPERLESS_CONSUMER_BARCODE_DPI=300
|
||||||
|
#PAPERLESS_CONSUMER_ENABLE_TAG_BARCODE=false
|
||||||
|
#PAPERLESS_CONSUMER_TAG_BARCODE_MAPPING={"TAG:(.*)": "\\g<1>"}
|
||||||
#PAPERLESS_CONSUMER_ENABLE_COLLATE_DOUBLE_SIDED=false
|
#PAPERLESS_CONSUMER_ENABLE_COLLATE_DOUBLE_SIDED=false
|
||||||
#PAPERLESS_CONSUMER_COLLATE_DOUBLE_SIDED_SUBDIR_NAME=double-sided
|
#PAPERLESS_CONSUMER_COLLATE_DOUBLE_SIDED_SUBDIR_NAME=double-sided
|
||||||
#PAPERLESS_CONSUMER_COLLATE_DOUBLE_SIDED_TIFF_SUPPORT=false
|
#PAPERLESS_CONSUMER_COLLATE_DOUBLE_SIDED_TIFF_SUPPORT=false
|
||||||
|
@@ -31,6 +31,7 @@
|
|||||||
"fr-FR": "src/locale/messages.fr_FR.xlf",
|
"fr-FR": "src/locale/messages.fr_FR.xlf",
|
||||||
"hu-HU": "src/locale/messages.hu_HU.xlf",
|
"hu-HU": "src/locale/messages.hu_HU.xlf",
|
||||||
"it-IT": "src/locale/messages.it_IT.xlf",
|
"it-IT": "src/locale/messages.it_IT.xlf",
|
||||||
|
"ja-JP": "src/locale/messages.ja_JP.xlf",
|
||||||
"lb-LU": "src/locale/messages.lb_LU.xlf",
|
"lb-LU": "src/locale/messages.lb_LU.xlf",
|
||||||
"nl-NL": "src/locale/messages.nl_NL.xlf",
|
"nl-NL": "src/locale/messages.nl_NL.xlf",
|
||||||
"no-NO": "src/locale/messages.no_NO.xlf",
|
"no-NO": "src/locale/messages.no_NO.xlf",
|
||||||
@@ -76,7 +77,9 @@
|
|||||||
"scripts": [],
|
"scripts": [],
|
||||||
"allowedCommonJsDependencies": [
|
"allowedCommonJsDependencies": [
|
||||||
"pdfjs-dist",
|
"pdfjs-dist",
|
||||||
"pdfjs-dist/web/pdf_viewer"
|
"pdfjs-dist/web/pdf_viewer",
|
||||||
|
"filesize",
|
||||||
|
"file-saver"
|
||||||
],
|
],
|
||||||
"vendorChunk": true,
|
"vendorChunk": true,
|
||||||
"extractLicenses": false,
|
"extractLicenses": false,
|
||||||
|
1680
src-ui/messages.xlf
1680
src-ui/messages.xlf
File diff suppressed because it is too large
Load Diff
5012
src-ui/package-lock.json
generated
5012
src-ui/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -11,57 +11,58 @@
|
|||||||
},
|
},
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@angular/cdk": "^17.0.4",
|
"@angular/cdk": "^17.2.1",
|
||||||
"@angular/common": "~17.0.8",
|
"@angular/common": "~17.2.3",
|
||||||
"@angular/compiler": "~17.0.8",
|
"@angular/compiler": "~17.2.3",
|
||||||
"@angular/core": "~17.0.8",
|
"@angular/core": "~17.2.3",
|
||||||
"@angular/forms": "~17.0.8",
|
"@angular/forms": "~17.2.3",
|
||||||
"@angular/localize": "~17.0.8",
|
"@angular/localize": "~17.2.3",
|
||||||
"@angular/platform-browser": "~17.0.8",
|
"@angular/platform-browser": "~17.2.3",
|
||||||
"@angular/platform-browser-dynamic": "~17.0.8",
|
"@angular/platform-browser-dynamic": "~17.2.3",
|
||||||
"@angular/router": "~17.0.8",
|
"@angular/router": "~17.2.3",
|
||||||
"@ng-bootstrap/ng-bootstrap": "^16.0.0",
|
"@ng-bootstrap/ng-bootstrap": "^16.0.0",
|
||||||
"@ng-select/ng-select": "^12.0.4",
|
"@ng-select/ng-select": "^12.0.7",
|
||||||
"@ngneat/dirty-check-forms": "^3.0.3",
|
"@ngneat/dirty-check-forms": "^3.0.3",
|
||||||
"@popperjs/core": "^2.11.8",
|
"@popperjs/core": "^2.11.8",
|
||||||
"bootstrap": "^5.3.2",
|
"bootstrap": "^5.3.3",
|
||||||
"file-saver": "^2.0.5",
|
"file-saver": "^2.0.5",
|
||||||
"mime-names": "^1.0.0",
|
"mime-names": "^1.0.0",
|
||||||
"ngx-bootstrap-icons": "^1.9.3",
|
"ngx-bootstrap-icons": "^1.9.3",
|
||||||
"ngx-color": "^9.0.0",
|
"ngx-color": "^9.0.0",
|
||||||
"ngx-cookie-service": "^17.0.1",
|
"ngx-cookie-service": "^17.1.0",
|
||||||
"ngx-file-drop": "^16.0.0",
|
"ngx-file-drop": "^16.0.0",
|
||||||
"ngx-ui-tour-ng-bootstrap": "^14.0.1",
|
"ngx-filesize": "^3.0.3",
|
||||||
|
"ngx-ui-tour-ng-bootstrap": "^14.0.2",
|
||||||
"pdfjs-dist": "^3.11.174",
|
"pdfjs-dist": "^3.11.174",
|
||||||
"rxjs": "^7.8.1",
|
"rxjs": "^7.8.1",
|
||||||
"tslib": "^2.6.2",
|
"tslib": "^2.6.2",
|
||||||
"uuid": "^9.0.1",
|
"uuid": "^9.0.1",
|
||||||
"zone.js": "^0.14.2"
|
"zone.js": "^0.14.4"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@angular-builders/jest": "17.0.0",
|
"@angular-builders/jest": "17.0.2",
|
||||||
"@angular-devkit/build-angular": "~17.0.8",
|
"@angular-devkit/build-angular": "~17.2.2",
|
||||||
"@angular-eslint/builder": "17.1.1",
|
"@angular-eslint/builder": "17.2.1",
|
||||||
"@angular-eslint/eslint-plugin": "17.1.1",
|
"@angular-eslint/eslint-plugin": "17.2.1",
|
||||||
"@angular-eslint/eslint-plugin-template": "17.1.1",
|
"@angular-eslint/eslint-plugin-template": "17.2.1",
|
||||||
"@angular-eslint/schematics": "17.1.1",
|
"@angular-eslint/schematics": "17.2.1",
|
||||||
"@angular-eslint/template-parser": "17.1.1",
|
"@angular-eslint/template-parser": "17.2.1",
|
||||||
"@angular/cli": "~17.0.8",
|
"@angular/cli": "~17.2.2",
|
||||||
"@angular/compiler-cli": "~17.0.7",
|
"@angular/compiler-cli": "~17.2.2",
|
||||||
"@playwright/test": "^1.40.1",
|
"@playwright/test": "^1.42.0",
|
||||||
"@types/jest": "^29.5.10",
|
"@types/jest": "^29.5.12",
|
||||||
"@types/node": "^20.10.6",
|
"@types/node": "^20.11.24",
|
||||||
"@typescript-eslint/eslint-plugin": "^6.17.0",
|
"@typescript-eslint/eslint-plugin": "^7.1.0",
|
||||||
"@typescript-eslint/parser": "^6.17.0",
|
"@typescript-eslint/parser": "^7.1.0",
|
||||||
"concurrently": "^8.2.2",
|
"concurrently": "^8.2.2",
|
||||||
"eslint": "^8.56.0",
|
"eslint": "^8.57.0",
|
||||||
"jest": "29.7.0",
|
"jest": "29.7.0",
|
||||||
"jest-environment-jsdom": "^29.7.0",
|
"jest-environment-jsdom": "^29.7.0",
|
||||||
"jest-preset-angular": "^13.1.4",
|
"jest-preset-angular": "^14.0.0",
|
||||||
"jest-websocket-mock": "^2.5.0",
|
"jest-websocket-mock": "^2.5.0",
|
||||||
"patch-package": "^8.0.0",
|
"patch-package": "^8.0.0",
|
||||||
"ts-node": "~10.9.1",
|
"ts-node": "~10.9.1",
|
||||||
"typescript": "^5.2.2",
|
"typescript": "^5.3.3",
|
||||||
"wait-on": "^7.2.0"
|
"wait-on": "^7.2.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -23,6 +23,7 @@ import localeFi from '@angular/common/locales/fi'
|
|||||||
import localeFr from '@angular/common/locales/fr'
|
import localeFr from '@angular/common/locales/fr'
|
||||||
import localeHu from '@angular/common/locales/hu'
|
import localeHu from '@angular/common/locales/hu'
|
||||||
import localeIt from '@angular/common/locales/it'
|
import localeIt from '@angular/common/locales/it'
|
||||||
|
import localeJa from '@angular/common/locales/ja'
|
||||||
import localeLb from '@angular/common/locales/lb'
|
import localeLb from '@angular/common/locales/lb'
|
||||||
import localeNl from '@angular/common/locales/nl'
|
import localeNl from '@angular/common/locales/nl'
|
||||||
import localeNo from '@angular/common/locales/no'
|
import localeNo from '@angular/common/locales/no'
|
||||||
@@ -53,6 +54,7 @@ registerLocaleData(localeFi)
|
|||||||
registerLocaleData(localeFr)
|
registerLocaleData(localeFr)
|
||||||
registerLocaleData(localeHu)
|
registerLocaleData(localeHu)
|
||||||
registerLocaleData(localeIt)
|
registerLocaleData(localeIt)
|
||||||
|
registerLocaleData(localeJa)
|
||||||
registerLocaleData(localeLb)
|
registerLocaleData(localeLb)
|
||||||
registerLocaleData(localeNl)
|
registerLocaleData(localeNl)
|
||||||
registerLocaleData(localeNo)
|
registerLocaleData(localeNo)
|
||||||
@@ -92,6 +94,10 @@ Object.defineProperty(navigator, 'clipboard', {
|
|||||||
})
|
})
|
||||||
Object.defineProperty(navigator, 'canShare', { value: () => true })
|
Object.defineProperty(navigator, 'canShare', { value: () => true })
|
||||||
Object.defineProperty(window, 'ResizeObserver', { value: mock() })
|
Object.defineProperty(window, 'ResizeObserver', { value: mock() })
|
||||||
|
Object.defineProperty(window, 'location', {
|
||||||
|
configurable: true,
|
||||||
|
value: { reload: jest.fn() },
|
||||||
|
})
|
||||||
|
|
||||||
HTMLCanvasElement.prototype.getContext = <
|
HTMLCanvasElement.prototype.getContext = <
|
||||||
typeof HTMLCanvasElement.prototype.getContext
|
typeof HTMLCanvasElement.prototype.getContext
|
||||||
|
@@ -163,7 +163,7 @@ export const routes: Routes = [
|
|||||||
canActivate: [PermissionsGuard],
|
canActivate: [PermissionsGuard],
|
||||||
data: {
|
data: {
|
||||||
requiredPermission: {
|
requiredPermission: {
|
||||||
action: PermissionAction.View,
|
action: PermissionAction.Change,
|
||||||
type: PermissionType.UISettings,
|
type: PermissionType.UISettings,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
@@ -112,7 +112,12 @@ import { SwitchComponent } from './components/common/input/switch/switch.compone
|
|||||||
import { ConfigComponent } from './components/admin/config/config.component'
|
import { ConfigComponent } from './components/admin/config/config.component'
|
||||||
import { FileComponent } from './components/common/input/file/file.component'
|
import { FileComponent } from './components/common/input/file/file.component'
|
||||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||||
|
import { ConfirmButtonComponent } from './components/common/confirm-button/confirm-button.component'
|
||||||
|
import { MonetaryComponent } from './components/common/input/monetary/monetary.component'
|
||||||
|
import { SystemStatusDialogComponent } from './components/common/system-status-dialog/system-status-dialog.component'
|
||||||
|
import { NgxFilesizeModule } from 'ngx-filesize'
|
||||||
import {
|
import {
|
||||||
|
airplane,
|
||||||
archive,
|
archive,
|
||||||
arrowCounterclockwise,
|
arrowCounterclockwise,
|
||||||
arrowDown,
|
arrowDown,
|
||||||
@@ -127,16 +132,19 @@ import {
|
|||||||
boxes,
|
boxes,
|
||||||
calendar,
|
calendar,
|
||||||
calendarEvent,
|
calendarEvent,
|
||||||
|
cardChecklist,
|
||||||
caretDown,
|
caretDown,
|
||||||
caretUp,
|
caretUp,
|
||||||
chatLeftText,
|
chatLeftText,
|
||||||
check,
|
check,
|
||||||
check2All,
|
check2All,
|
||||||
checkAll,
|
checkAll,
|
||||||
|
checkCircleFill,
|
||||||
checkLg,
|
checkLg,
|
||||||
chevronDoubleLeft,
|
chevronDoubleLeft,
|
||||||
chevronDoubleRight,
|
chevronDoubleRight,
|
||||||
clipboard,
|
clipboard,
|
||||||
|
clipboardCheck,
|
||||||
clipboardCheckFill,
|
clipboardCheckFill,
|
||||||
clipboardFill,
|
clipboardFill,
|
||||||
dash,
|
dash,
|
||||||
@@ -145,7 +153,9 @@ import {
|
|||||||
doorOpen,
|
doorOpen,
|
||||||
download,
|
download,
|
||||||
envelope,
|
envelope,
|
||||||
|
exclamationCircleFill,
|
||||||
exclamationTriangle,
|
exclamationTriangle,
|
||||||
|
exclamationTriangleFill,
|
||||||
eye,
|
eye,
|
||||||
fileEarmark,
|
fileEarmark,
|
||||||
fileEarmarkCheck,
|
fileEarmarkCheck,
|
||||||
@@ -197,6 +207,7 @@ import {
|
|||||||
} from 'ngx-bootstrap-icons'
|
} from 'ngx-bootstrap-icons'
|
||||||
|
|
||||||
const icons = {
|
const icons = {
|
||||||
|
airplane,
|
||||||
archive,
|
archive,
|
||||||
arrowCounterclockwise,
|
arrowCounterclockwise,
|
||||||
arrowDown,
|
arrowDown,
|
||||||
@@ -211,16 +222,19 @@ const icons = {
|
|||||||
boxes,
|
boxes,
|
||||||
calendar,
|
calendar,
|
||||||
calendarEvent,
|
calendarEvent,
|
||||||
|
cardChecklist,
|
||||||
caretDown,
|
caretDown,
|
||||||
caretUp,
|
caretUp,
|
||||||
chatLeftText,
|
chatLeftText,
|
||||||
check,
|
check,
|
||||||
check2All,
|
check2All,
|
||||||
checkAll,
|
checkAll,
|
||||||
|
checkCircleFill,
|
||||||
checkLg,
|
checkLg,
|
||||||
chevronDoubleLeft,
|
chevronDoubleLeft,
|
||||||
chevronDoubleRight,
|
chevronDoubleRight,
|
||||||
clipboard,
|
clipboard,
|
||||||
|
clipboardCheck,
|
||||||
clipboardCheckFill,
|
clipboardCheckFill,
|
||||||
clipboardFill,
|
clipboardFill,
|
||||||
dash,
|
dash,
|
||||||
@@ -229,7 +243,9 @@ const icons = {
|
|||||||
doorOpen,
|
doorOpen,
|
||||||
download,
|
download,
|
||||||
envelope,
|
envelope,
|
||||||
|
exclamationCircleFill,
|
||||||
exclamationTriangle,
|
exclamationTriangle,
|
||||||
|
exclamationTriangleFill,
|
||||||
eye,
|
eye,
|
||||||
fileEarmark,
|
fileEarmark,
|
||||||
fileEarmarkCheck,
|
fileEarmarkCheck,
|
||||||
@@ -295,6 +311,7 @@ import localeFi from '@angular/common/locales/fi'
|
|||||||
import localeFr from '@angular/common/locales/fr'
|
import localeFr from '@angular/common/locales/fr'
|
||||||
import localeHu from '@angular/common/locales/hu'
|
import localeHu from '@angular/common/locales/hu'
|
||||||
import localeIt from '@angular/common/locales/it'
|
import localeIt from '@angular/common/locales/it'
|
||||||
|
import localeJa from '@angular/common/locales/ja'
|
||||||
import localeLb from '@angular/common/locales/lb'
|
import localeLb from '@angular/common/locales/lb'
|
||||||
import localeNl from '@angular/common/locales/nl'
|
import localeNl from '@angular/common/locales/nl'
|
||||||
import localeNo from '@angular/common/locales/no'
|
import localeNo from '@angular/common/locales/no'
|
||||||
@@ -325,6 +342,7 @@ registerLocaleData(localeFi)
|
|||||||
registerLocaleData(localeFr)
|
registerLocaleData(localeFr)
|
||||||
registerLocaleData(localeHu)
|
registerLocaleData(localeHu)
|
||||||
registerLocaleData(localeIt)
|
registerLocaleData(localeIt)
|
||||||
|
registerLocaleData(localeJa)
|
||||||
registerLocaleData(localeLb)
|
registerLocaleData(localeLb)
|
||||||
registerLocaleData(localeNl)
|
registerLocaleData(localeNl)
|
||||||
registerLocaleData(localeNo)
|
registerLocaleData(localeNo)
|
||||||
@@ -437,6 +455,9 @@ function initializeApp(settings: SettingsService) {
|
|||||||
SwitchComponent,
|
SwitchComponent,
|
||||||
ConfigComponent,
|
ConfigComponent,
|
||||||
FileComponent,
|
FileComponent,
|
||||||
|
ConfirmButtonComponent,
|
||||||
|
MonetaryComponent,
|
||||||
|
SystemStatusDialogComponent,
|
||||||
],
|
],
|
||||||
imports: [
|
imports: [
|
||||||
BrowserModule,
|
BrowserModule,
|
||||||
@@ -451,6 +472,7 @@ function initializeApp(settings: SettingsService) {
|
|||||||
TourNgBootstrapModule,
|
TourNgBootstrapModule,
|
||||||
DragDropModule,
|
DragDropModule,
|
||||||
NgxBootstrapIconsModule.pick(icons),
|
NgxBootstrapIconsModule.pick(icons),
|
||||||
|
NgxFilesizeModule,
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
{
|
{
|
||||||
|
@@ -4,10 +4,31 @@
|
|||||||
info="Options to customize appearance, notifications, saved views and more. Settings apply to the <strong>current user only</strong>."
|
info="Options to customize appearance, notifications, saved views and more. Settings apply to the <strong>current user only</strong>."
|
||||||
i18n-info
|
i18n-info
|
||||||
>
|
>
|
||||||
<button class="btn btn-sm btn-outline-primary" (click)="tourService.start()"><ng-container i18n>Start tour</ng-container></button>
|
<button class="btn btn-sm btn-outline-primary" (click)="tourService.start()">
|
||||||
<a *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Admin }" class="btn btn-sm btn-primary ms-3" href="admin/" target="_blank">
|
<i-bs class="me-1" name="airplane"></i-bs> <ng-container i18n>Start tour</ng-container>
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-sm btn-outline-primary position-relative ms-md-5 me-1" (click)="showSystemStatus()"
|
||||||
|
[disabled]="!systemStatus"
|
||||||
|
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Admin }">
|
||||||
|
@if (!systemStatus) {
|
||||||
|
<div class="spinner-border spinner-border-sm me-1 h-75" role="status"></div>
|
||||||
|
} @else {
|
||||||
|
<i-bs class="me-2" name="card-checklist"></i-bs>
|
||||||
|
@if (systemStatusHasErrors) {
|
||||||
|
<span class="badge bg-body position-absolute top-0 start-100 translate-middle rounded-pill p-0">
|
||||||
|
<i-bs name="exclamation-circle-fill" class="text-danger" width="1.75em" height="1.75em"></i-bs>
|
||||||
|
</span>
|
||||||
|
} @else {
|
||||||
|
<span class="badge bg-body position-absolute top-0 start-100 translate-middle rounded-pill p-0">
|
||||||
|
<i-bs name="check-circle-fill" class="text-primary" width="1.75em" height="1.75em"></i-bs>
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
<ng-container i18n>System Status</ng-container>
|
||||||
|
</button>
|
||||||
|
<a *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Admin }" class="btn btn-sm btn-primary" href="admin/" target="_blank">
|
||||||
<ng-container i18n>Open Django Admin</ng-container>
|
<ng-container i18n>Open Django Admin</ng-container>
|
||||||
<i-bs name="arrow-up-right"></i-bs>
|
<i-bs name="arrow-up-right"></i-bs>
|
||||||
</a>
|
</a>
|
||||||
</pngx-page-header>
|
</pngx-page-header>
|
||||||
|
|
||||||
@@ -158,6 +179,14 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<h4 class="mt-4" i18n>Document editing</h4>
|
||||||
|
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="offset-md-3 col">
|
||||||
|
<pngx-input-check i18n-title title="Automatically remove inbox tag(s) on save" formControlName="documentEditingRemoveInboxTags"></pngx-input-check>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<h4 class="mt-4" i18n>Bulk editing</h4>
|
<h4 class="mt-4" i18n>Bulk editing</h4>
|
||||||
|
|
||||||
<div class="row mb-3">
|
<div class="row mb-3">
|
||||||
@@ -311,7 +340,15 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="mb-2 col-auto">
|
<div class="mb-2 col-auto">
|
||||||
<label class="form-label" for="name_{{view.id}}" i18n>Actions</label>
|
<label class="form-label" for="name_{{view.id}}" i18n>Actions</label>
|
||||||
<button type="button" class="btn btn-sm btn-outline-danger form-control" (click)="deleteSavedView(view)" *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.SavedView }" i18n>Delete</button>
|
|
||||||
|
<pngx-confirm-button
|
||||||
|
label="Delete"
|
||||||
|
i18n-label
|
||||||
|
(confirm)="deleteSavedView(view)"
|
||||||
|
*pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.SavedView }"
|
||||||
|
buttonClasses="btn-sm btn-outline-danger form-control"
|
||||||
|
iconName="trash">
|
||||||
|
</pngx-confirm-button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -335,5 +372,5 @@
|
|||||||
|
|
||||||
<div [ngbNavOutlet]="nav" class="border-start border-end border-bottom p-3 mb-3 shadow-sm"></div>
|
<div [ngbNavOutlet]="nav" class="border-start border-end border-bottom p-3 mb-3 shadow-sm"></div>
|
||||||
|
|
||||||
<button type="submit" class="btn btn-primary mb-2" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.UISettings }" [disabled]="(isDirty$ | async) === false" i18n>Save</button>
|
<button type="submit" class="btn btn-primary mb-2" [disabled]="(isDirty$ | async) === false" i18n>Save</button>
|
||||||
</form>
|
</form>
|
||||||
|
@@ -9,6 +9,8 @@ import {
|
|||||||
NgbModule,
|
NgbModule,
|
||||||
NgbAlertModule,
|
NgbAlertModule,
|
||||||
NgbNavLink,
|
NgbNavLink,
|
||||||
|
NgbModal,
|
||||||
|
NgbModalModule,
|
||||||
} from '@ng-bootstrap/ng-bootstrap'
|
} from '@ng-bootstrap/ng-bootstrap'
|
||||||
import { NgSelectModule } from '@ng-select/ng-select'
|
import { NgSelectModule } from '@ng-select/ng-select'
|
||||||
import { of, throwError } from 'rxjs'
|
import { of, throwError } from 'rxjs'
|
||||||
@@ -38,6 +40,14 @@ import { PageHeaderComponent } from '../../common/page-header/page-header.compon
|
|||||||
import { SettingsComponent } from './settings.component'
|
import { SettingsComponent } from './settings.component'
|
||||||
import { IfOwnerDirective } from 'src/app/directives/if-owner.directive'
|
import { IfOwnerDirective } from 'src/app/directives/if-owner.directive'
|
||||||
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
||||||
|
import { ConfirmButtonComponent } from '../../common/confirm-button/confirm-button.component'
|
||||||
|
import { SystemStatusDialogComponent } from '../../common/system-status-dialog/system-status-dialog.component'
|
||||||
|
import { SystemStatusService } from 'src/app/services/system-status.service'
|
||||||
|
import {
|
||||||
|
SystemStatus,
|
||||||
|
InstallType,
|
||||||
|
SystemStatusItemStatus,
|
||||||
|
} from 'src/app/data/system-status'
|
||||||
|
|
||||||
const savedViews = [
|
const savedViews = [
|
||||||
{ id: 1, name: 'view1', show_in_sidebar: true, show_on_dashboard: true },
|
{ id: 1, name: 'view1', show_in_sidebar: true, show_on_dashboard: true },
|
||||||
@@ -64,6 +74,8 @@ describe('SettingsComponent', () => {
|
|||||||
let userService: UserService
|
let userService: UserService
|
||||||
let permissionsService: PermissionsService
|
let permissionsService: PermissionsService
|
||||||
let groupService: GroupService
|
let groupService: GroupService
|
||||||
|
let modalService: NgbModal
|
||||||
|
let systemStatusService: SystemStatusService
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
TestBed.configureTestingModule({
|
TestBed.configureTestingModule({
|
||||||
@@ -83,6 +95,7 @@ describe('SettingsComponent', () => {
|
|||||||
PermissionsUserComponent,
|
PermissionsUserComponent,
|
||||||
PermissionsGroupComponent,
|
PermissionsGroupComponent,
|
||||||
IfOwnerDirective,
|
IfOwnerDirective,
|
||||||
|
ConfirmButtonComponent,
|
||||||
],
|
],
|
||||||
providers: [CustomDatePipe, DatePipe, PermissionsGuard],
|
providers: [CustomDatePipe, DatePipe, PermissionsGuard],
|
||||||
imports: [
|
imports: [
|
||||||
@@ -94,6 +107,7 @@ describe('SettingsComponent', () => {
|
|||||||
NgbAlertModule,
|
NgbAlertModule,
|
||||||
NgSelectModule,
|
NgSelectModule,
|
||||||
NgxBootstrapIconsModule.pick(allIcons),
|
NgxBootstrapIconsModule.pick(allIcons),
|
||||||
|
NgbModalModule,
|
||||||
],
|
],
|
||||||
}).compileComponents()
|
}).compileComponents()
|
||||||
|
|
||||||
@@ -105,6 +119,8 @@ describe('SettingsComponent', () => {
|
|||||||
settingsService.currentUser = users[0]
|
settingsService.currentUser = users[0]
|
||||||
userService = TestBed.inject(UserService)
|
userService = TestBed.inject(UserService)
|
||||||
permissionsService = TestBed.inject(PermissionsService)
|
permissionsService = TestBed.inject(PermissionsService)
|
||||||
|
modalService = TestBed.inject(NgbModal)
|
||||||
|
systemStatusService = TestBed.inject(SystemStatusService)
|
||||||
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
|
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
|
||||||
jest
|
jest
|
||||||
.spyOn(permissionsService, 'currentUserHasObjectPermissions')
|
.spyOn(permissionsService, 'currentUserHasObjectPermissions')
|
||||||
@@ -289,7 +305,7 @@ describe('SettingsComponent', () => {
|
|||||||
expect(toastErrorSpy).toHaveBeenCalled()
|
expect(toastErrorSpy).toHaveBeenCalled()
|
||||||
expect(storeSpy).toHaveBeenCalled()
|
expect(storeSpy).toHaveBeenCalled()
|
||||||
expect(appearanceSettingsSpy).not.toHaveBeenCalled()
|
expect(appearanceSettingsSpy).not.toHaveBeenCalled()
|
||||||
expect(setSpy).toHaveBeenCalledTimes(24)
|
expect(setSpy).toHaveBeenCalledTimes(25)
|
||||||
|
|
||||||
// succeed
|
// succeed
|
||||||
storeSpy.mockReturnValueOnce(of(true))
|
storeSpy.mockReturnValueOnce(of(true))
|
||||||
@@ -307,10 +323,15 @@ describe('SettingsComponent', () => {
|
|||||||
component.store.getValue()['displayLanguage'] = 'en-US'
|
component.store.getValue()['displayLanguage'] = 'en-US'
|
||||||
component.store.getValue()['updateCheckingEnabled'] = false
|
component.store.getValue()['updateCheckingEnabled'] = false
|
||||||
component.settingsForm.value.displayLanguage = 'en-GB'
|
component.settingsForm.value.displayLanguage = 'en-GB'
|
||||||
component.settingsForm.value.updateCheckingEnabled = true
|
jest.spyOn(settingsService, 'storeSettings').mockReturnValue(of(true))
|
||||||
jest.spyOn(settingsService, 'storeSettings').mockReturnValueOnce(of(true))
|
|
||||||
component.saveSettings()
|
component.saveSettings()
|
||||||
expect(toast.actionName).toEqual('Reload now')
|
expect(toast.actionName).toEqual('Reload now')
|
||||||
|
|
||||||
|
component.settingsForm.value.updateCheckingEnabled = true
|
||||||
|
component.saveSettings()
|
||||||
|
|
||||||
|
expect(toast.actionName).toEqual('Reload now')
|
||||||
|
toast.action()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should allow setting theme color, visually apply change immediately but not save', () => {
|
it('should allow setting theme color, visually apply change immediately but not save', () => {
|
||||||
@@ -365,4 +386,54 @@ describe('SettingsComponent', () => {
|
|||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
expect(toastErrorSpy).toBeCalled()
|
expect(toastErrorSpy).toBeCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should load system status on initialize, show errors if needed', () => {
|
||||||
|
const status: SystemStatus = {
|
||||||
|
pngx_version: '2.4.3',
|
||||||
|
server_os: 'macOS-14.1.1-arm64-arm-64bit',
|
||||||
|
install_type: InstallType.BareMetal,
|
||||||
|
storage: { total: 494384795648, available: 13573525504 },
|
||||||
|
database: {
|
||||||
|
type: 'sqlite',
|
||||||
|
url: '/paperless-ngx/data/db.sqlite3',
|
||||||
|
status: SystemStatusItemStatus.ERROR,
|
||||||
|
error: null,
|
||||||
|
migration_status: {
|
||||||
|
latest_migration: 'socialaccount.0006_alter_socialaccount_extra_data',
|
||||||
|
unapplied_migrations: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
tasks: {
|
||||||
|
redis_url: 'redis://localhost:6379',
|
||||||
|
redis_status: SystemStatusItemStatus.ERROR,
|
||||||
|
redis_error:
|
||||||
|
'Error 61 connecting to localhost:6379. Connection refused.',
|
||||||
|
celery_status: SystemStatusItemStatus.ERROR,
|
||||||
|
index_status: SystemStatusItemStatus.OK,
|
||||||
|
index_last_modified: new Date().toISOString(),
|
||||||
|
index_error: null,
|
||||||
|
classifier_status: SystemStatusItemStatus.OK,
|
||||||
|
classifier_last_trained: new Date().toISOString(),
|
||||||
|
classifier_error: null,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
jest.spyOn(systemStatusService, 'get').mockReturnValue(of(status))
|
||||||
|
completeSetup()
|
||||||
|
expect(component['systemStatus']).toEqual(status) // private
|
||||||
|
expect(component.systemStatusHasErrors).toBeTruthy()
|
||||||
|
// coverage
|
||||||
|
component['systemStatus'].database.status = SystemStatusItemStatus.OK
|
||||||
|
component['systemStatus'].tasks.redis_status = SystemStatusItemStatus.OK
|
||||||
|
component['systemStatus'].tasks.celery_status = SystemStatusItemStatus.OK
|
||||||
|
expect(component.systemStatusHasErrors).toBeFalsy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should open system status dialog', () => {
|
||||||
|
const modalOpenSpy = jest.spyOn(modalService, 'open')
|
||||||
|
completeSetup()
|
||||||
|
component.showSystemStatus()
|
||||||
|
expect(modalOpenSpy).toHaveBeenCalledWith(SystemStatusDialogComponent, {
|
||||||
|
size: 'xl',
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
@@ -9,7 +9,11 @@ import {
|
|||||||
} from '@angular/core'
|
} from '@angular/core'
|
||||||
import { FormGroup, FormControl } from '@angular/forms'
|
import { FormGroup, FormControl } from '@angular/forms'
|
||||||
import { ActivatedRoute, Router } from '@angular/router'
|
import { ActivatedRoute, Router } from '@angular/router'
|
||||||
import { NgbNavChangeEvent } from '@ng-bootstrap/ng-bootstrap'
|
import {
|
||||||
|
NgbModal,
|
||||||
|
NgbModalRef,
|
||||||
|
NgbNavChangeEvent,
|
||||||
|
} from '@ng-bootstrap/ng-bootstrap'
|
||||||
import { DirtyComponent, dirtyCheck } from '@ngneat/dirty-check-forms'
|
import { DirtyComponent, dirtyCheck } from '@ngneat/dirty-check-forms'
|
||||||
import { TourService } from 'ngx-ui-tour-ng-bootstrap'
|
import { TourService } from 'ngx-ui-tour-ng-bootstrap'
|
||||||
import {
|
import {
|
||||||
@@ -40,6 +44,12 @@ import {
|
|||||||
} from 'src/app/services/settings.service'
|
} from 'src/app/services/settings.service'
|
||||||
import { ToastService, Toast } from 'src/app/services/toast.service'
|
import { ToastService, Toast } from 'src/app/services/toast.service'
|
||||||
import { ComponentWithPermissions } from '../../with-permissions/with-permissions.component'
|
import { ComponentWithPermissions } from '../../with-permissions/with-permissions.component'
|
||||||
|
import { SystemStatusDialogComponent } from '../../common/system-status-dialog/system-status-dialog.component'
|
||||||
|
import { SystemStatusService } from 'src/app/services/system-status.service'
|
||||||
|
import {
|
||||||
|
SystemStatusItemStatus,
|
||||||
|
SystemStatus,
|
||||||
|
} from 'src/app/data/system-status'
|
||||||
|
|
||||||
enum SettingsNavIDs {
|
enum SettingsNavIDs {
|
||||||
General = 1,
|
General = 1,
|
||||||
@@ -88,6 +98,7 @@ export class SettingsComponent
|
|||||||
defaultPermsViewGroups: new FormControl(null),
|
defaultPermsViewGroups: new FormControl(null),
|
||||||
defaultPermsEditUsers: new FormControl(null),
|
defaultPermsEditUsers: new FormControl(null),
|
||||||
defaultPermsEditGroups: new FormControl(null),
|
defaultPermsEditGroups: new FormControl(null),
|
||||||
|
documentEditingRemoveInboxTags: new FormControl(null),
|
||||||
|
|
||||||
notificationsConsumerNewDocument: new FormControl(null),
|
notificationsConsumerNewDocument: new FormControl(null),
|
||||||
notificationsConsumerSuccess: new FormControl(null),
|
notificationsConsumerSuccess: new FormControl(null),
|
||||||
@@ -110,6 +121,18 @@ export class SettingsComponent
|
|||||||
users: User[]
|
users: User[]
|
||||||
groups: Group[]
|
groups: Group[]
|
||||||
|
|
||||||
|
private systemStatus: SystemStatus
|
||||||
|
|
||||||
|
get systemStatusHasErrors(): boolean {
|
||||||
|
return (
|
||||||
|
this.systemStatus.database.status === SystemStatusItemStatus.ERROR ||
|
||||||
|
this.systemStatus.tasks.redis_status === SystemStatusItemStatus.ERROR ||
|
||||||
|
this.systemStatus.tasks.celery_status === SystemStatusItemStatus.ERROR ||
|
||||||
|
this.systemStatus.tasks.index_status === SystemStatusItemStatus.ERROR ||
|
||||||
|
this.systemStatus.tasks.classifier_status === SystemStatusItemStatus.ERROR
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
get computedDateLocale(): string {
|
get computedDateLocale(): string {
|
||||||
return (
|
return (
|
||||||
this.settingsForm.value.dateLocale ||
|
this.settingsForm.value.dateLocale ||
|
||||||
@@ -130,7 +153,9 @@ export class SettingsComponent
|
|||||||
private usersService: UserService,
|
private usersService: UserService,
|
||||||
private groupsService: GroupService,
|
private groupsService: GroupService,
|
||||||
private router: Router,
|
private router: Router,
|
||||||
public permissionsService: PermissionsService
|
public permissionsService: PermissionsService,
|
||||||
|
private modalService: NgbModal,
|
||||||
|
private systemStatusService: SystemStatusService
|
||||||
) {
|
) {
|
||||||
super()
|
super()
|
||||||
this.settings.settingsSaved.subscribe(() => {
|
this.settings.settingsSaved.subscribe(() => {
|
||||||
@@ -271,6 +296,9 @@ export class SettingsComponent
|
|||||||
defaultPermsEditGroups: this.settings.get(
|
defaultPermsEditGroups: this.settings.get(
|
||||||
SETTINGS_KEYS.DEFAULT_PERMS_EDIT_GROUPS
|
SETTINGS_KEYS.DEFAULT_PERMS_EDIT_GROUPS
|
||||||
),
|
),
|
||||||
|
documentEditingRemoveInboxTags: this.settings.get(
|
||||||
|
SETTINGS_KEYS.DOCUMENT_EDITING_REMOVE_INBOX_TAGS
|
||||||
|
),
|
||||||
savedViews: {},
|
savedViews: {},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -356,6 +384,17 @@ export class SettingsComponent
|
|||||||
// prevents loss of unsaved changes
|
// prevents loss of unsaved changes
|
||||||
this.settingsForm.patchValue(currentFormValue)
|
this.settingsForm.patchValue(currentFormValue)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
this.permissionsService.currentUserCan(
|
||||||
|
PermissionAction.View,
|
||||||
|
PermissionType.Admin
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
this.systemStatusService.get().subscribe((status) => {
|
||||||
|
this.systemStatus = status
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private emptyGroup(group: FormGroup) {
|
private emptyGroup(group: FormGroup) {
|
||||||
@@ -484,6 +523,10 @@ export class SettingsComponent
|
|||||||
SETTINGS_KEYS.DEFAULT_PERMS_EDIT_GROUPS,
|
SETTINGS_KEYS.DEFAULT_PERMS_EDIT_GROUPS,
|
||||||
this.settingsForm.value.defaultPermsEditGroups
|
this.settingsForm.value.defaultPermsEditGroups
|
||||||
)
|
)
|
||||||
|
this.settings.set(
|
||||||
|
SETTINGS_KEYS.DOCUMENT_EDITING_REMOVE_INBOX_TAGS,
|
||||||
|
this.settingsForm.value.documentEditingRemoveInboxTags
|
||||||
|
)
|
||||||
this.settings.setLanguage(this.settingsForm.value.displayLanguage)
|
this.settings.setLanguage(this.settingsForm.value.displayLanguage)
|
||||||
this.settings
|
this.settings
|
||||||
.storeSettings()
|
.storeSettings()
|
||||||
@@ -557,4 +600,14 @@ export class SettingsComponent
|
|||||||
clearThemeColor() {
|
clearThemeColor() {
|
||||||
this.settingsForm.get('themeColor').patchValue('')
|
this.settingsForm.get('themeColor').patchValue('')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
showSystemStatus() {
|
||||||
|
const modal: NgbModalRef = this.modalService.open(
|
||||||
|
SystemStatusDialogComponent,
|
||||||
|
{
|
||||||
|
size: 'xl',
|
||||||
|
}
|
||||||
|
)
|
||||||
|
modal.componentInstance.status = this.systemStatus
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@@ -33,64 +33,64 @@
|
|||||||
<div class="btn-group">
|
<div class="btn-group">
|
||||||
<button class="btn btn-sm btn-outline-secondary" type="button" (click)="editUser(user)" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.User }">
|
<button class="btn btn-sm btn-outline-secondary" type="button" (click)="editUser(user)" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.User }">
|
||||||
<i-bs width="1em" height="1em" name="pencil"></i-bs> <ng-container i18n>Edit</ng-container>
|
<i-bs width="1em" height="1em" name="pencil"></i-bs> <ng-container i18n>Edit</ng-container>
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-sm btn-outline-danger" type="button" (click)="deleteUser(user)" *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.User }">
|
||||||
|
<i-bs width="1em" height="1em" name="trash"></i-bs> <ng-container i18n>Delete</ng-container>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (groups) {
|
||||||
|
<h4 class="mt-4 d-flex">
|
||||||
|
<ng-container i18n>Groups</ng-container>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-primary ms-4" (click)="editGroup()" *pngxIfPermissions="{ action: PermissionAction.Add, type: PermissionType.Group }">
|
||||||
|
<i-bs name="plus-circle"></i-bs> <ng-container i18n>Add Group</ng-container>
|
||||||
|
</button>
|
||||||
|
</h4>
|
||||||
|
@if (groups.length > 0) {
|
||||||
|
<ul class="list-group">
|
||||||
|
<li class="list-group-item">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col" i18n>Name</div>
|
||||||
|
<div class="col"></div>
|
||||||
|
<div class="col"></div>
|
||||||
|
<div class="col" i18n>Actions</div>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
@for (group of groups; track group) {
|
||||||
|
<li class="list-group-item">
|
||||||
|
<div class="row">
|
||||||
|
<div class="col d-flex align-items-center"><button class="btn btn-link p-0" type="button" (click)="editGroup(group)" [disabled]="!permissionsService.currentUserCan(PermissionAction.Change, PermissionType.Group)">{{group.name}}</button></div>
|
||||||
|
<div class="col"></div>
|
||||||
|
<div class="col"></div>
|
||||||
|
<div class="col">
|
||||||
|
<div class="btn-group">
|
||||||
|
<button class="btn btn-sm btn-outline-secondary" type="button" (click)="editGroup(group)" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.Group }">
|
||||||
|
<i-bs width="1em" height="1em" name="pencil"></i-bs> <ng-container i18n>Edit</ng-container>
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-sm btn-outline-danger" type="button" (click)="deleteUser(user)" *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.User }">
|
<button class="btn btn-sm btn-outline-danger" type="button" (click)="deleteGroup(group)" *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.Group }">
|
||||||
<i-bs width="1em" height="1em" name="trash"></i-bs> <ng-container i18n>Delete</ng-container>
|
<i-bs width="1em" height="1em" name="trash"></i-bs> <ng-container i18n>Delete</ng-container>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
|
||||||
}
|
|
||||||
</ul>
|
|
||||||
}
|
|
||||||
|
|
||||||
@if (groups) {
|
|
||||||
<h4 class="mt-4 d-flex">
|
|
||||||
<ng-container i18n>Groups</ng-container>
|
|
||||||
<button type="button" class="btn btn-sm btn-outline-primary ms-4" (click)="editGroup()" *pngxIfPermissions="{ action: PermissionAction.Add, type: PermissionType.Group }">
|
|
||||||
<i-bs name="plus-circle"></i-bs> <ng-container i18n>Add Group</ng-container>
|
|
||||||
</button>
|
|
||||||
</h4>
|
|
||||||
@if (groups.length > 0) {
|
|
||||||
<ul class="list-group">
|
|
||||||
<li class="list-group-item">
|
|
||||||
<div class="row">
|
|
||||||
<div class="col" i18n>Name</div>
|
|
||||||
<div class="col"></div>
|
|
||||||
<div class="col"></div>
|
|
||||||
<div class="col" i18n>Actions</div>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
@for (group of groups; track group) {
|
|
||||||
<li class="list-group-item">
|
|
||||||
<div class="row">
|
|
||||||
<div class="col d-flex align-items-center"><button class="btn btn-link p-0" type="button" (click)="editGroup(group)" [disabled]="!permissionsService.currentUserCan(PermissionAction.Change, PermissionType.Group)">{{group.name}}</button></div>
|
|
||||||
<div class="col"></div>
|
|
||||||
<div class="col"></div>
|
|
||||||
<div class="col">
|
|
||||||
<div class="btn-group">
|
|
||||||
<button class="btn btn-sm btn-outline-secondary" type="button" (click)="editGroup(group)" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.Group }">
|
|
||||||
<i-bs width="1em" height="1em" name="pencil"></i-bs> <ng-container i18n>Edit</ng-container>
|
|
||||||
</button>
|
|
||||||
<button class="btn btn-sm btn-outline-danger" type="button" (click)="deleteGroup(group)" *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.Group }">
|
|
||||||
<i-bs width="1em" height="1em" name="trash"></i-bs> <ng-container i18n>Delete</ng-container>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</li>
|
|
||||||
}
|
|
||||||
@if (groups.length === 0) {
|
|
||||||
<li class="list-group-item" i18n>No groups defined</li>
|
|
||||||
}
|
|
||||||
</ul>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@if (!users || !groups) {
|
|
||||||
<div>
|
|
||||||
<div class="spinner-border spinner-border-sm fw-normal ms-2 me-auto" role="status"></div>
|
|
||||||
<div class="visually-hidden" i18n>Loading...</div>
|
|
||||||
</div>
|
</div>
|
||||||
}
|
</li>
|
||||||
|
}
|
||||||
|
@if (groups.length === 0) {
|
||||||
|
<li class="list-group-item" i18n>No groups defined</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@if (!users || !groups) {
|
||||||
|
<div>
|
||||||
|
<div class="spinner-border spinner-border-sm fw-normal ms-2 me-auto" role="status"></div>
|
||||||
|
<div class="visually-hidden" i18n>Loading...</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
@@ -4,16 +4,16 @@
|
|||||||
(click)="isMenuCollapsed = !isMenuCollapsed">
|
(click)="isMenuCollapsed = !isMenuCollapsed">
|
||||||
<span class="navbar-toggler-icon"></span>
|
<span class="navbar-toggler-icon"></span>
|
||||||
</button>
|
</button>
|
||||||
<a class="navbar-brand d-flex col-auto col-md-3 col-lg-2 me-0 px-3 py-3 order-sm-0"
|
<a class="navbar-brand d-flex align-items-center me-0 px-3 py-3 order-sm-0"
|
||||||
[ngClass]="{ 'slim': slimSidebarEnabled, 'd-flex col-auto col-md-3 col-lg-2' : !slimSidebarEnabled, 'py-3' : !customAppTitle?.length || slimSidebarEnabled, 'py-2': customAppTitle?.length }"
|
[ngClass]="{ 'slim': slimSidebarEnabled, 'col-auto col-md-3 col-lg-2' : !slimSidebarEnabled, 'py-3' : !customAppTitle?.length || slimSidebarEnabled, 'py-2': customAppTitle?.length }"
|
||||||
routerLink="/dashboard"
|
routerLink="/dashboard"
|
||||||
tourAnchor="tour.intro">
|
tourAnchor="tour.intro">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 198.43 238.91" width="1em" class="me-2" fill="currentColor">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 198.43 238.91" width="1em" height="1.5em" fill="currentColor">
|
||||||
<path
|
<path
|
||||||
d="M194.7,0C164.22,70.94,17.64,79.74,64.55,194.06c.58,1.47-10.85,17-18.47,29.9-1.76-6.45-3.81-13.48-3.52-14.07,38.11-45.14-27.26-70.65-30.78-107.58C-4.64,131.62-10.5,182.92,39,212.53c.3,0,2.64,11.14,3.81,16.71a58.55,58.55,0,0,0-2.93,6.45c-1.17,2.93,7.62,2.64,7.62,3.22.88-.29,21.7-36.93,22.28-37.23C187.67,174.72,208.48,68.6,194.7,0ZM134.61,74.75C79.5,124,70.12,160.64,71.88,178.53,53.41,134.85,107.64,86.77,134.61,74.75ZM28.2,145.11c10.55,9.67,28.14,39.28,13.19,56.57C44.91,193.77,46.08,175.89,28.2,145.11Z"
|
d="M194.7,0C164.22,70.94,17.64,79.74,64.55,194.06c.58,1.47-10.85,17-18.47,29.9-1.76-6.45-3.81-13.48-3.52-14.07,38.11-45.14-27.26-70.65-30.78-107.58C-4.64,131.62-10.5,182.92,39,212.53c.3,0,2.64,11.14,3.81,16.71a58.55,58.55,0,0,0-2.93,6.45c-1.17,2.93,7.62,2.64,7.62,3.22.88-.29,21.7-36.93,22.28-37.23C187.67,174.72,208.48,68.6,194.7,0ZM134.61,74.75C79.5,124,70.12,160.64,71.88,178.53,53.41,134.85,107.64,86.77,134.61,74.75ZM28.2,145.11c10.55,9.67,28.14,39.28,13.19,56.57C44.91,193.77,46.08,175.89,28.2,145.11Z"
|
||||||
transform="translate(0 0)" />
|
transform="translate(0 0)" />
|
||||||
</svg>
|
</svg>
|
||||||
<div class="ms-2 d-inline-block" [class.visually-hidden]="slimSidebarEnabled">
|
<div class="ms-2 ms-md-3 d-inline-block" [class.d-md-none]="slimSidebarEnabled">
|
||||||
@if (customAppTitle?.length) {
|
@if (customAppTitle?.length) {
|
||||||
<div class="d-flex flex-column align-items-start">
|
<div class="d-flex flex-column align-items-start">
|
||||||
<span class="title">{{customAppTitle}}</span>
|
<span class="title">{{customAppTitle}}</span>
|
||||||
@@ -27,12 +27,12 @@
|
|||||||
<div class="search-form-container flex-grow-1 py-2 pb-3 pb-sm-2 px-3 ps-md-4 me-sm-auto order-3 order-sm-1"
|
<div class="search-form-container flex-grow-1 py-2 pb-3 pb-sm-2 px-3 ps-md-4 me-sm-auto order-3 order-sm-1"
|
||||||
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }">
|
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }">
|
||||||
<form (ngSubmit)="search()" class="form-inline flex-grow-1">
|
<form (ngSubmit)="search()" class="form-inline flex-grow-1">
|
||||||
<i-bs style="top: .25em;" width="1em" height="1em" name="search"></i-bs>
|
<i-bs width="1em" height="1em" name="search"></i-bs>
|
||||||
<input class="form-control form-control-sm" type="text" placeholder="Search documents" aria-label="Search"
|
<input class="form-control form-control-sm" type="text" placeholder="Search documents" aria-label="Search"
|
||||||
[formControl]="searchField" [ngbTypeahead]="searchAutoComplete" (keyup)="searchFieldKeyup($event)"
|
[formControl]="searchField" [ngbTypeahead]="searchAutoComplete" (keyup)="searchFieldKeyup($event)"
|
||||||
(selectItem)="itemSelected($event)" i18n-placeholder>
|
(selectItem)="itemSelected($event)" i18n-placeholder>
|
||||||
@if (!searchFieldEmpty) {
|
@if (!searchFieldEmpty) {
|
||||||
<button type="button" class="btn btn-link btn-sm px-0 position-absolute top-0 end-0" (click)="resetSearchField()">
|
<button type="button" class="btn btn-link btn-sm ps-0 pe-1 position-absolute top-0 end-0" (click)="resetSearchField()">
|
||||||
<i-bs width="1em" height="1em" name="x"></i-bs>
|
<i-bs width="1em" height="1em" name="x"></i-bs>
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
@@ -55,7 +55,7 @@
|
|||||||
<i-bs class="me-2" name="person"></i-bs> <ng-container i18n>My Profile</ng-container>
|
<i-bs class="me-2" name="person"></i-bs> <ng-container i18n>My Profile</ng-container>
|
||||||
</button>
|
</button>
|
||||||
<a ngbDropdownItem class="nav-link" routerLink="settings" (click)="closeMenu()"
|
<a ngbDropdownItem class="nav-link" routerLink="settings" (click)="closeMenu()"
|
||||||
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.UISettings }">
|
*pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.UISettings }">
|
||||||
<i-bs class="me-2" name="gear"></i-bs><ng-container i18n>Settings</ng-container>
|
<i-bs class="me-2" name="gear"></i-bs><ng-container i18n>Settings</ng-container>
|
||||||
</a>
|
</a>
|
||||||
<a ngbDropdownItem class="nav-link d-flex" href="accounts/logout/" (click)="onLogout()">
|
<a ngbDropdownItem class="nav-link d-flex" href="accounts/logout/" (click)="onLogout()">
|
||||||
@@ -227,7 +227,7 @@
|
|||||||
<span i18n>Administration</span>
|
<span i18n>Administration</span>
|
||||||
</h6>
|
</h6>
|
||||||
<ul class="nav flex-column mb-2">
|
<ul class="nav flex-column mb-2">
|
||||||
<li class="nav-item" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.UISettings }"
|
<li class="nav-item" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.UISettings }"
|
||||||
tourAnchor="tour.settings">
|
tourAnchor="tour.settings">
|
||||||
<a class="nav-link" routerLink="settings" routerLinkActive="active" (click)="closeMenu()"
|
<a class="nav-link" routerLink="settings" routerLinkActive="active" (click)="closeMenu()"
|
||||||
ngbPopover="Settings" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
|
ngbPopover="Settings" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
|
||||||
@@ -256,10 +256,10 @@
|
|||||||
ngbPopover="File Tasks" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
|
ngbPopover="File Tasks" i18n-ngbPopover [disablePopover]="!slimSidebarEnabled" placement="end"
|
||||||
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||||
<i-bs class="me-1" name="list-task"></i-bs><span> <ng-container i18n>File Tasks</ng-container>@if (tasksService.failedFileTasks.length > 0) {
|
<i-bs class="me-1" name="list-task"></i-bs><span> <ng-container i18n>File Tasks</ng-container>@if (tasksService.failedFileTasks.length > 0) {
|
||||||
<span><span class="badge bg-danger ms-2">{{tasksService.failedFileTasks.length}}</span></span>
|
<span><span class="badge bg-danger ms-2 d-inline">{{tasksService.failedFileTasks.length}}</span></span>
|
||||||
}</span>
|
}</span>
|
||||||
@if (tasksService.failedFileTasks.length > 0 && slimSidebarEnabled) {
|
@if (tasksService.failedFileTasks.length > 0 && slimSidebarEnabled) {
|
||||||
<span class="badge bg-danger position-absolute top-0 end-0">{{tasksService.failedFileTasks.length}}</span>
|
<span class="badge bg-danger position-absolute top-0 end-0 d-none d-md-block">{{tasksService.failedFileTasks.length}}</span>
|
||||||
}
|
}
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
@@ -262,9 +262,15 @@ main {
|
|||||||
> i-bs {
|
> i-bs {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
left: 0.6rem;
|
left: 0.6rem;
|
||||||
top: 0.5rem;
|
top: .35rem;
|
||||||
color: rgba(255, 255, 255, 0.6);
|
color: rgba(255, 255, 255, 0.6);
|
||||||
|
|
||||||
|
@media screen and (min-width: 768px) {
|
||||||
|
// adjust for smaller font size on non-mobile
|
||||||
|
top: 0.25rem;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@@ -21,6 +21,10 @@ import { IfPermissionsDirective } from 'src/app/directives/if-permissions.direct
|
|||||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||||
import { of, throwError } from 'rxjs'
|
import { of, throwError } from 'rxjs'
|
||||||
import { ToastService } from 'src/app/services/toast.service'
|
import { ToastService } from 'src/app/services/toast.service'
|
||||||
|
import {
|
||||||
|
DjangoMessageLevel,
|
||||||
|
DjangoMessagesService,
|
||||||
|
} from 'src/app/services/django-messages.service'
|
||||||
import { environment } from 'src/environments/environment'
|
import { environment } from 'src/environments/environment'
|
||||||
import { OpenDocumentsService } from 'src/app/services/open-documents.service'
|
import { OpenDocumentsService } from 'src/app/services/open-documents.service'
|
||||||
import { ActivatedRoute, Router } from '@angular/router'
|
import { ActivatedRoute, Router } from '@angular/router'
|
||||||
@@ -83,6 +87,7 @@ describe('AppFrameComponent', () => {
|
|||||||
let permissionsService: PermissionsService
|
let permissionsService: PermissionsService
|
||||||
let remoteVersionService: RemoteVersionService
|
let remoteVersionService: RemoteVersionService
|
||||||
let toastService: ToastService
|
let toastService: ToastService
|
||||||
|
let messagesService: DjangoMessagesService
|
||||||
let openDocumentsService: OpenDocumentsService
|
let openDocumentsService: OpenDocumentsService
|
||||||
let searchService: SearchService
|
let searchService: SearchService
|
||||||
let documentListViewService: DocumentListViewService
|
let documentListViewService: DocumentListViewService
|
||||||
@@ -123,6 +128,7 @@ describe('AppFrameComponent', () => {
|
|||||||
RemoteVersionService,
|
RemoteVersionService,
|
||||||
IfPermissionsDirective,
|
IfPermissionsDirective,
|
||||||
ToastService,
|
ToastService,
|
||||||
|
DjangoMessagesService,
|
||||||
OpenDocumentsService,
|
OpenDocumentsService,
|
||||||
SearchService,
|
SearchService,
|
||||||
NgbModal,
|
NgbModal,
|
||||||
@@ -151,6 +157,7 @@ describe('AppFrameComponent', () => {
|
|||||||
permissionsService = TestBed.inject(PermissionsService)
|
permissionsService = TestBed.inject(PermissionsService)
|
||||||
remoteVersionService = TestBed.inject(RemoteVersionService)
|
remoteVersionService = TestBed.inject(RemoteVersionService)
|
||||||
toastService = TestBed.inject(ToastService)
|
toastService = TestBed.inject(ToastService)
|
||||||
|
messagesService = TestBed.inject(DjangoMessagesService)
|
||||||
openDocumentsService = TestBed.inject(OpenDocumentsService)
|
openDocumentsService = TestBed.inject(OpenDocumentsService)
|
||||||
searchService = TestBed.inject(SearchService)
|
searchService = TestBed.inject(SearchService)
|
||||||
documentListViewService = TestBed.inject(DocumentListViewService)
|
documentListViewService = TestBed.inject(DocumentListViewService)
|
||||||
@@ -393,4 +400,19 @@ describe('AppFrameComponent', () => {
|
|||||||
backdrop: 'static',
|
backdrop: 'static',
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should show toasts for django messages', () => {
|
||||||
|
const toastErrorSpy = jest.spyOn(toastService, 'showError')
|
||||||
|
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
|
||||||
|
jest.spyOn(messagesService, 'get').mockReturnValue([
|
||||||
|
{ level: DjangoMessageLevel.WARNING, message: 'Test warning' },
|
||||||
|
{ level: DjangoMessageLevel.ERROR, message: 'Test error' },
|
||||||
|
{ level: DjangoMessageLevel.SUCCESS, message: 'Test success' },
|
||||||
|
{ level: DjangoMessageLevel.INFO, message: 'Test info' },
|
||||||
|
{ level: DjangoMessageLevel.DEBUG, message: 'Test debug' },
|
||||||
|
])
|
||||||
|
component.ngOnInit()
|
||||||
|
expect(toastErrorSpy).toHaveBeenCalledTimes(2)
|
||||||
|
expect(toastInfoSpy).toHaveBeenCalledTimes(3)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
@@ -12,6 +12,10 @@ import {
|
|||||||
} from 'rxjs/operators'
|
} from 'rxjs/operators'
|
||||||
import { Document } from 'src/app/data/document'
|
import { Document } from 'src/app/data/document'
|
||||||
import { OpenDocumentsService } from 'src/app/services/open-documents.service'
|
import { OpenDocumentsService } from 'src/app/services/open-documents.service'
|
||||||
|
import {
|
||||||
|
DjangoMessageLevel,
|
||||||
|
DjangoMessagesService,
|
||||||
|
} from 'src/app/services/django-messages.service'
|
||||||
import { SavedViewService } from 'src/app/services/rest/saved-view.service'
|
import { SavedViewService } from 'src/app/services/rest/saved-view.service'
|
||||||
import { SearchService } from 'src/app/services/rest/search.service'
|
import { SearchService } from 'src/app/services/rest/search.service'
|
||||||
import { environment } from 'src/environments/environment'
|
import { environment } from 'src/environments/environment'
|
||||||
@@ -73,7 +77,8 @@ export class AppFrameComponent
|
|||||||
public tasksService: TasksService,
|
public tasksService: TasksService,
|
||||||
private readonly toastService: ToastService,
|
private readonly toastService: ToastService,
|
||||||
private modalService: NgbModal,
|
private modalService: NgbModal,
|
||||||
permissionsService: PermissionsService
|
public permissionsService: PermissionsService,
|
||||||
|
private djangoMessagesService: DjangoMessagesService
|
||||||
) {
|
) {
|
||||||
super()
|
super()
|
||||||
|
|
||||||
@@ -92,6 +97,20 @@ export class AppFrameComponent
|
|||||||
this.checkForUpdates()
|
this.checkForUpdates()
|
||||||
}
|
}
|
||||||
this.tasksService.reload()
|
this.tasksService.reload()
|
||||||
|
|
||||||
|
this.djangoMessagesService.get().forEach((message) => {
|
||||||
|
switch (message.level) {
|
||||||
|
case DjangoMessageLevel.ERROR:
|
||||||
|
case DjangoMessageLevel.WARNING:
|
||||||
|
this.toastService.showError(message.message)
|
||||||
|
break
|
||||||
|
case DjangoMessageLevel.SUCCESS:
|
||||||
|
case DjangoMessageLevel.INFO:
|
||||||
|
case DjangoMessageLevel.DEBUG:
|
||||||
|
this.toastService.showInfo(message.message)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
toggleSlimSidebar(): void {
|
toggleSlimSidebar(): void {
|
||||||
|
@@ -0,0 +1,22 @@
|
|||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn {{buttonClasses}}"
|
||||||
|
(click)="onClick($event)"
|
||||||
|
[disabled]="disabled"
|
||||||
|
[ngbPopover]="popoverContent"
|
||||||
|
[autoClose]="true"
|
||||||
|
(hidden)="confirming = false"
|
||||||
|
#popover="ngbPopover"
|
||||||
|
popoverClass="popover-slim"
|
||||||
|
>
|
||||||
|
@if (iconName) {
|
||||||
|
<i-bs [class.me-1]="label" name="{{iconName}}"></i-bs>
|
||||||
|
}
|
||||||
|
<ng-container>{{label}}</ng-container>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<ng-template #popoverContent>
|
||||||
|
<div>
|
||||||
|
{{confirmMessage}} <button class="btn btn-link btn-sm text-danger p-0 m-0 lh-1" type="button" (click)="onConfirm($event)">Yes</button>
|
||||||
|
</div>
|
||||||
|
</ng-template>
|
@@ -0,0 +1,12 @@
|
|||||||
|
// Taken from bootstrap rules, obv
|
||||||
|
::ng-deep .input-group > pngx-confirm-button:not(:first-child):not(.dropdown-menu):not(.valid-tooltip):not(.valid-feedback):not(.invalid-tooltip):not(.invalid-feedback) > button,
|
||||||
|
::ng-deep .btn-group > pngx-confirm-button:not(:first-child):not(.dropdown-menu):not(.valid-tooltip):not(.valid-feedback):not(.invalid-tooltip):not(.invalid-feedback) > button {
|
||||||
|
border-top-left-radius: 0;
|
||||||
|
border-bottom-left-radius: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
::ng-deep .input-group:not(.has-validation) > pngx-confirm-button:not(:last-child):not(.dropdown-toggle):not(.dropdown-menu):not(.form-floating) > button,
|
||||||
|
::ng-deep .btn-group:not(.has-validation) > pngx-confirm-button:not(:last-child):not(.dropdown-toggle):not(.dropdown-menu):not(.form-floating) > button {
|
||||||
|
border-top-right-radius: 0;
|
||||||
|
border-bottom-right-radius: 0;
|
||||||
|
}
|
@@ -0,0 +1,37 @@
|
|||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||||
|
|
||||||
|
import { ConfirmButtonComponent } from './confirm-button.component'
|
||||||
|
import { NgbPopoverModule } from '@ng-bootstrap/ng-bootstrap'
|
||||||
|
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
||||||
|
|
||||||
|
describe('ConfirmButtonComponent', () => {
|
||||||
|
let component: ConfirmButtonComponent
|
||||||
|
let fixture: ComponentFixture<ConfirmButtonComponent>
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
declarations: [ConfirmButtonComponent],
|
||||||
|
imports: [NgbPopoverModule, NgxBootstrapIconsModule.pick(allIcons)],
|
||||||
|
}).compileComponents()
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(ConfirmButtonComponent)
|
||||||
|
component = fixture.componentInstance
|
||||||
|
fixture.detectChanges()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should show confirm on click', () => {
|
||||||
|
expect(component.popover.isOpen()).toBeFalsy()
|
||||||
|
expect(component.confirming).toBeFalsy()
|
||||||
|
component.onClick(new MouseEvent('click'))
|
||||||
|
expect(component.popover.isOpen()).toBeTruthy()
|
||||||
|
expect(component.confirming).toBeTruthy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should emit confirm on confirm', () => {
|
||||||
|
const confirmSpy = jest.spyOn(component.confirm, 'emit')
|
||||||
|
component.onConfirm(new MouseEvent('click'))
|
||||||
|
expect(confirmSpy).toHaveBeenCalled()
|
||||||
|
expect(component.popover.isOpen()).toBeFalsy()
|
||||||
|
expect(component.confirming).toBeFalsy()
|
||||||
|
})
|
||||||
|
})
|
@@ -0,0 +1,55 @@
|
|||||||
|
import {
|
||||||
|
Component,
|
||||||
|
EventEmitter,
|
||||||
|
Input,
|
||||||
|
Output,
|
||||||
|
ViewChild,
|
||||||
|
} from '@angular/core'
|
||||||
|
import { NgbPopover } from '@ng-bootstrap/ng-bootstrap'
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'pngx-confirm-button',
|
||||||
|
templateUrl: './confirm-button.component.html',
|
||||||
|
styleUrl: './confirm-button.component.scss',
|
||||||
|
})
|
||||||
|
export class ConfirmButtonComponent {
|
||||||
|
@Input()
|
||||||
|
label: string
|
||||||
|
|
||||||
|
@Input()
|
||||||
|
confirmMessage: string = $localize`Are you sure?`
|
||||||
|
|
||||||
|
@Input()
|
||||||
|
buttonClasses: string = 'btn-primary'
|
||||||
|
|
||||||
|
@Input()
|
||||||
|
iconName: string
|
||||||
|
|
||||||
|
@Input()
|
||||||
|
disabled: boolean = false
|
||||||
|
|
||||||
|
@Output()
|
||||||
|
confirm: EventEmitter<void> = new EventEmitter<void>()
|
||||||
|
|
||||||
|
@ViewChild('popover') popover: NgbPopover
|
||||||
|
|
||||||
|
public confirming: boolean = false
|
||||||
|
|
||||||
|
public onClick(event: MouseEvent) {
|
||||||
|
if (!this.confirming) {
|
||||||
|
this.confirming = true
|
||||||
|
this.popover.open()
|
||||||
|
}
|
||||||
|
|
||||||
|
event.preventDefault()
|
||||||
|
event.stopImmediatePropagation()
|
||||||
|
}
|
||||||
|
|
||||||
|
public onConfirm(event: MouseEvent) {
|
||||||
|
this.confirm.emit()
|
||||||
|
this.confirming = false
|
||||||
|
|
||||||
|
event.preventDefault()
|
||||||
|
event.stopImmediatePropagation()
|
||||||
|
}
|
||||||
|
}
|
@@ -1,6 +1,9 @@
|
|||||||
<form [formGroup]="objectForm" (ngSubmit)="save()" autocomplete="off">
|
<form [formGroup]="objectForm" (ngSubmit)="save()" autocomplete="off">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<h4 class="modal-title" id="modal-basic-title">{{getTitle()}}</h4>
|
<h4 class="modal-title" id="modal-basic-title">{{getTitle()}}</h4>
|
||||||
|
@if (object?.id) {
|
||||||
|
<span class="badge bg-primary text-primary-text-contrast ms-2">ID: {{object.id}}</span>
|
||||||
|
}
|
||||||
<button type="button" [disabled]="!closeEnabled" class="btn-close" aria-label="Close" (click)="cancel()">
|
<button type="button" [disabled]="!closeEnabled" class="btn-close" aria-label="Close" (click)="cancel()">
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
@@ -1,6 +1,9 @@
|
|||||||
<form [formGroup]="objectForm" (ngSubmit)="save()" autocomplete="off">
|
<form [formGroup]="objectForm" (ngSubmit)="save()" autocomplete="off">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<h4 class="modal-title" id="modal-basic-title">{{getTitle()}}</h4>
|
<h4 class="modal-title" id="modal-basic-title">{{getTitle()}}</h4>
|
||||||
|
@if (object?.id) {
|
||||||
|
<span class="badge bg-primary text-primary-text-contrast ms-2">ID: {{object.id}}</span>
|
||||||
|
}
|
||||||
<button type="button" [disabled]="!closeEnabled" class="btn-close" aria-label="Close" (click)="cancel()">
|
<button type="button" [disabled]="!closeEnabled" class="btn-close" aria-label="Close" (click)="cancel()">
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
@@ -1,6 +1,9 @@
|
|||||||
<form [formGroup]="objectForm" (ngSubmit)="save()" autocomplete="off">
|
<form [formGroup]="objectForm" (ngSubmit)="save()" autocomplete="off">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<h4 class="modal-title" id="modal-basic-title">{{getTitle()}}</h4>
|
<h4 class="modal-title" id="modal-basic-title">{{getTitle()}}</h4>
|
||||||
|
@if (object?.id) {
|
||||||
|
<span class="badge bg-primary text-primary-text-contrast ms-2">ID: {{object.id}}</span>
|
||||||
|
}
|
||||||
<button type="button" [disabled]="!closeEnabled" class="btn-close" aria-label="Close" (click)="cancel()">
|
<button type="button" [disabled]="!closeEnabled" class="btn-close" aria-label="Close" (click)="cancel()">
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
@@ -1,6 +1,9 @@
|
|||||||
<form [formGroup]="objectForm" (ngSubmit)="save()" autocomplete="off">
|
<form [formGroup]="objectForm" (ngSubmit)="save()" autocomplete="off">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<h4 class="modal-title" id="modal-basic-title">{{getTitle()}}</h4>
|
<h4 class="modal-title" id="modal-basic-title">{{getTitle()}}</h4>
|
||||||
|
@if (object?.id) {
|
||||||
|
<span class="badge bg-primary text-primary-text-contrast ms-2">ID: {{object.id}}</span>
|
||||||
|
}
|
||||||
<button type="button" [disabled]="!closeEnabled" class="btn-close" aria-label="Close" (click)="cancel()">
|
<button type="button" [disabled]="!closeEnabled" class="btn-close" aria-label="Close" (click)="cancel()">
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
@@ -1,6 +1,9 @@
|
|||||||
<form [formGroup]="objectForm" (ngSubmit)="save()" autocomplete="off">
|
<form [formGroup]="objectForm" (ngSubmit)="save()" autocomplete="off">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<h4 class="modal-title" id="modal-basic-title">{{getTitle()}}</h4>
|
<h4 class="modal-title" id="modal-basic-title">{{getTitle()}}</h4>
|
||||||
|
@if (object?.id) {
|
||||||
|
<span class="badge bg-primary text-primary-text-contrast ms-2">ID: {{object.id}}</span>
|
||||||
|
}
|
||||||
<button type="button" [disabled]="!closeEnabled" class="btn-close" aria-label="Close" (click)="cancel()">
|
<button type="button" [disabled]="!closeEnabled" class="btn-close" aria-label="Close" (click)="cancel()">
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
@@ -1,6 +1,9 @@
|
|||||||
<form [formGroup]="objectForm" (ngSubmit)="save()" autocomplete="off">
|
<form [formGroup]="objectForm" (ngSubmit)="save()" autocomplete="off">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<h4 class="modal-title" id="modal-basic-title">{{getTitle()}}</h4>
|
<h4 class="modal-title" id="modal-basic-title">{{getTitle()}}</h4>
|
||||||
|
@if (object?.id) {
|
||||||
|
<span class="badge bg-primary text-primary-text-contrast ms-2">ID: {{object.id}}</span>
|
||||||
|
}
|
||||||
<button type="button" [disabled]="!closeEnabled" class="btn-close" aria-label="Close" (click)="cancel()">
|
<button type="button" [disabled]="!closeEnabled" class="btn-close" aria-label="Close" (click)="cancel()">
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
@@ -1,6 +1,9 @@
|
|||||||
<form [formGroup]="objectForm" (ngSubmit)="save()" autocomplete="off">
|
<form [formGroup]="objectForm" (ngSubmit)="save()" autocomplete="off">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<h4 class="modal-title" id="modal-basic-title">{{getTitle()}}</h4>
|
<h4 class="modal-title" id="modal-basic-title">{{getTitle()}}</h4>
|
||||||
|
@if (object?.id) {
|
||||||
|
<span class="badge bg-primary text-primary-text-contrast ms-2">ID: {{object.id}}</span>
|
||||||
|
}
|
||||||
<button type="button" [disabled]="!closeEnabled" class="btn-close" aria-label="Close" (click)="cancel()">
|
<button type="button" [disabled]="!closeEnabled" class="btn-close" aria-label="Close" (click)="cancel()">
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
@@ -1,6 +1,9 @@
|
|||||||
<form [formGroup]="objectForm" (ngSubmit)="save()" autocomplete="off">
|
<form [formGroup]="objectForm" (ngSubmit)="save()" autocomplete="off">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<h4 class="modal-title" id="modal-basic-title">{{getTitle()}}</h4>
|
<h4 class="modal-title" id="modal-basic-title">{{getTitle()}}</h4>
|
||||||
|
@if (object?.id) {
|
||||||
|
<span class="badge bg-primary text-primary-text-contrast ms-2">ID: {{object.id}}</span>
|
||||||
|
}
|
||||||
<button type="button" [disabled]="!closeEnabled" class="btn-close" aria-label="Close" (click)="cancel()">
|
<button type="button" [disabled]="!closeEnabled" class="btn-close" aria-label="Close" (click)="cancel()">
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
@@ -1,6 +1,9 @@
|
|||||||
<form [formGroup]="objectForm" (ngSubmit)="save()" autocomplete="off">
|
<form [formGroup]="objectForm" (ngSubmit)="save()" autocomplete="off">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<h4 class="modal-title" id="modal-basic-title">{{getTitle()}}</h4>
|
<h4 class="modal-title" id="modal-basic-title">{{getTitle()}}</h4>
|
||||||
|
@if (object?.id) {
|
||||||
|
<span class="badge bg-primary text-primary-text-contrast ms-2">ID: {{object.id}}</span>
|
||||||
|
}
|
||||||
<button type="button" [disabled]="!closeEnabled" class="btn-close" aria-label="Close" (click)="cancel()">
|
<button type="button" [disabled]="!closeEnabled" class="btn-close" aria-label="Close" (click)="cancel()">
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
@@ -1,6 +1,9 @@
|
|||||||
<form [formGroup]="objectForm" (ngSubmit)="save()" autocomplete="off">
|
<form [formGroup]="objectForm" (ngSubmit)="save()" autocomplete="off">
|
||||||
<div class="modal-header">
|
<div class="modal-header">
|
||||||
<h4 class="modal-title" id="modal-basic-title">{{getTitle()}}</h4>
|
<h4 class="modal-title" id="modal-basic-title">{{getTitle()}}</h4>
|
||||||
|
@if (object?.id) {
|
||||||
|
<span class="badge bg-primary text-primary-text-contrast ms-2">ID: {{object.id}}</span>
|
||||||
|
}
|
||||||
<button type="button" [disabled]="!closeEnabled" class="btn-close" aria-label="Close" (click)="cancel()">
|
<button type="button" [disabled]="!closeEnabled" class="btn-close" aria-label="Close" (click)="cancel()">
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -35,12 +38,16 @@
|
|||||||
<div ngbAccordionItem>
|
<div ngbAccordionItem>
|
||||||
<div ngbAccordionHeader>
|
<div ngbAccordionHeader>
|
||||||
<button ngbAccordionButton>{{i + 1}}. {{getTriggerTypeOptionName(triggerFields.controls[i].value.type)}}
|
<button ngbAccordionButton>{{i + 1}}. {{getTriggerTypeOptionName(triggerFields.controls[i].value.type)}}
|
||||||
@if(trigger.id > -1) {
|
@if(trigger.id) {
|
||||||
<span class="badge bg-primary text-primary-text-contrast ms-2">ID: {{trigger.id}}</span>
|
<span class="badge bg-primary text-primary-text-contrast ms-2">ID: {{trigger.id}}</span>
|
||||||
}
|
}
|
||||||
<button type="button" class="btn btn-link text-danger ms-2" (click)="removeTrigger(i)">
|
<pngx-confirm-button
|
||||||
<i-bs name="trash"></i-bs> <ng-container i18n>Delete</ng-container>
|
label="Delete"
|
||||||
</button>
|
i18n-label
|
||||||
|
(confirm)="removeTrigger(i)"
|
||||||
|
buttonClasses="btn-link text-danger ms-2"
|
||||||
|
iconName="trash">
|
||||||
|
</pngx-confirm-button>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div ngbAccordionCollapse>
|
<div ngbAccordionCollapse>
|
||||||
@@ -73,73 +80,21 @@
|
|||||||
<div ngbAccordionItem cdkDrag [formGroup]="actionFields.controls[i]">
|
<div ngbAccordionItem cdkDrag [formGroup]="actionFields.controls[i]">
|
||||||
<div ngbAccordionHeader>
|
<div ngbAccordionHeader>
|
||||||
<button ngbAccordionButton>{{i + 1}}. {{getActionTypeOptionName(actionFields.controls[i].value.type)}}
|
<button ngbAccordionButton>{{i + 1}}. {{getActionTypeOptionName(actionFields.controls[i].value.type)}}
|
||||||
@if(action.id > -1) {
|
@if(action.id) {
|
||||||
<span class="badge bg-primary text-primary-text-contrast ms-2">ID: {{action.id}}</span>
|
<span class="badge bg-primary text-primary-text-contrast ms-2">ID: {{action.id}}</span>
|
||||||
}
|
}
|
||||||
<button type="button" class="btn btn-link text-danger ms-2" (click)="removeAction(i)">
|
<pngx-confirm-button
|
||||||
<i-bs name="trash"></i-bs> <ng-container i18n>Delete</ng-container>
|
label="Delete"
|
||||||
</button>
|
i18n-label
|
||||||
|
(confirm)="removeAction(i)"
|
||||||
|
buttonClasses="btn-link text-danger ms-2"
|
||||||
|
iconName="trash">
|
||||||
|
</pngx-confirm-button>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
<div ngbAccordionCollapse>
|
<div ngbAccordionCollapse>
|
||||||
<div ngbAccordionBody>
|
<div ngbAccordionBody>
|
||||||
<pngx-input-select i18n-title title="Action type" [horizontal]="true" [items]="actionTypeOptions" formControlName="type"></pngx-input-select>
|
<ng-template [ngTemplateOutlet]="actionForm" [ngTemplateOutletContext]="{ formGroup: actionFields.controls[i], action: action }"></ng-template>
|
||||||
<input type="hidden" formControlName="id" />
|
|
||||||
<div class="row">
|
|
||||||
<div class="col">
|
|
||||||
<pngx-input-text i18n-title title="Assign title" formControlName="assign_title" i18n-hint hint="Can include some placeholders, see <a target='_blank' href='https://docs.paperless-ngx.com/usage/#workflows'>documentation</a>." [error]="error?.actions?.[i]?.assign_title"></pngx-input-text>
|
|
||||||
<pngx-input-tags [allowCreate]="false" i18n-title title="Assign tags" formControlName="assign_tags"></pngx-input-tags>
|
|
||||||
<pngx-input-select i18n-title title="Assign document type" [items]="documentTypes" [allowNull]="true" formControlName="assign_document_type"></pngx-input-select>
|
|
||||||
<pngx-input-select i18n-title title="Assign correspondent" [items]="correspondents" [allowNull]="true" formControlName="assign_correspondent"></pngx-input-select>
|
|
||||||
<pngx-input-select i18n-title title="Assign storage path" [items]="storagePaths" [allowNull]="true" formControlName="assign_storage_path"></pngx-input-select>
|
|
||||||
<pngx-input-select i18n-title title="Assign custom fields" multiple="true" [items]="customFields" [allowNull]="true" formControlName="assign_custom_fields"></pngx-input-select>
|
|
||||||
</div>
|
|
||||||
<div class="col">
|
|
||||||
<pngx-input-select i18n-title title="Assign owner" [items]="users" bindLabel="username" formControlName="assign_owner" [allowNull]="true"></pngx-input-select>
|
|
||||||
<div>
|
|
||||||
<label class="form-label" i18n>Assign view permissions</label>
|
|
||||||
<div class="mb-2">
|
|
||||||
<div class="row mb-1">
|
|
||||||
<div class="col-lg-3">
|
|
||||||
<label class="form-label d-block my-2 text-nowrap" i18n>Users:</label>
|
|
||||||
</div>
|
|
||||||
<div class="col-lg-9">
|
|
||||||
<pngx-permissions-user type="view" formControlName="assign_view_users"></pngx-permissions-user>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-lg-3">
|
|
||||||
<label class="form-label d-block my-2 text-nowrap" i18n>Groups:</label>
|
|
||||||
</div>
|
|
||||||
<div class="col-lg-9">
|
|
||||||
<pngx-permissions-group type="view" formControlName="assign_view_groups"></pngx-permissions-group>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<label class="form-label" i18n>Assign edit permissions</label>
|
|
||||||
<div>
|
|
||||||
<div class="row mb-1">
|
|
||||||
<div class="col-lg-3">
|
|
||||||
<label class="form-label d-block my-2 text-nowrap" i18n>Users:</label>
|
|
||||||
</div>
|
|
||||||
<div class="col-lg-9">
|
|
||||||
<pngx-permissions-user type="change" formControlName="assign_change_users"></pngx-permissions-user>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="row">
|
|
||||||
<div class="col-lg-3">
|
|
||||||
<label class="form-label d-block my-2 text-nowrap" i18n>Groups:</label>
|
|
||||||
</div>
|
|
||||||
<div class="col-lg-9">
|
|
||||||
<pngx-permissions-group type="change" formControlName="assign_change_groups"></pngx-permissions-group>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<small class="form-text text-muted text-end d-block" i18n>Edit permissions also grant viewing permissions</small>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -193,3 +148,154 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
|
|
||||||
|
<ng-template #actionForm let-formGroup="formGroup" let action="action">
|
||||||
|
<div [formGroup]="formGroup">
|
||||||
|
<input type="hidden" formControlName="id" />
|
||||||
|
<pngx-input-select i18n-title title="Action type" [horizontal]="true" [items]="actionTypeOptions" formControlName="type"></pngx-input-select>
|
||||||
|
@switch(formGroup.get('type').value) {
|
||||||
|
@case ( WorkflowActionType.Assignment) {
|
||||||
|
<div class="row">
|
||||||
|
<div class="col">
|
||||||
|
<pngx-input-text i18n-title title="Assign title" formControlName="assign_title" i18n-hint hint="Can include some placeholders, see <a target='_blank' href='https://docs.paperless-ngx.com/usage/#workflows'>documentation</a>." [error]="error?.actions?.[i]?.assign_title"></pngx-input-text>
|
||||||
|
<pngx-input-tags [allowCreate]="false" i18n-title title="Assign tags" formControlName="assign_tags"></pngx-input-tags>
|
||||||
|
<pngx-input-select i18n-title title="Assign document type" [items]="documentTypes" [allowNull]="true" formControlName="assign_document_type"></pngx-input-select>
|
||||||
|
<pngx-input-select i18n-title title="Assign correspondent" [items]="correspondents" [allowNull]="true" formControlName="assign_correspondent"></pngx-input-select>
|
||||||
|
<pngx-input-select i18n-title title="Assign storage path" [items]="storagePaths" [allowNull]="true" formControlName="assign_storage_path"></pngx-input-select>
|
||||||
|
<pngx-input-select i18n-title title="Assign custom fields" multiple="true" [items]="customFields" [allowNull]="true" formControlName="assign_custom_fields"></pngx-input-select>
|
||||||
|
</div>
|
||||||
|
<div class="col">
|
||||||
|
<pngx-input-select i18n-title title="Assign owner" [items]="users" bindLabel="username" formControlName="assign_owner" [allowNull]="true"></pngx-input-select>
|
||||||
|
<div>
|
||||||
|
<label class="form-label" i18n>Assign view permissions</label>
|
||||||
|
<div class="mb-2">
|
||||||
|
<div class="row mb-1">
|
||||||
|
<div class="col-lg-3">
|
||||||
|
<label class="form-label d-block my-2 text-nowrap" i18n>Users:</label>
|
||||||
|
</div>
|
||||||
|
<div class="col-lg-9">
|
||||||
|
<pngx-permissions-user type="view" formControlName="assign_view_users"></pngx-permissions-user>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-lg-3">
|
||||||
|
<label class="form-label d-block my-2 text-nowrap" i18n>Groups:</label>
|
||||||
|
</div>
|
||||||
|
<div class="col-lg-9">
|
||||||
|
<pngx-permissions-group type="view" formControlName="assign_view_groups"></pngx-permissions-group>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<label class="form-label" i18n>Assign edit permissions</label>
|
||||||
|
<div>
|
||||||
|
<div class="row mb-1">
|
||||||
|
<div class="col-lg-3">
|
||||||
|
<label class="form-label d-block my-2 text-nowrap" i18n>Users:</label>
|
||||||
|
</div>
|
||||||
|
<div class="col-lg-9">
|
||||||
|
<pngx-permissions-user type="change" formControlName="assign_change_users"></pngx-permissions-user>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-lg-3">
|
||||||
|
<label class="form-label d-block my-2 text-nowrap" i18n>Groups:</label>
|
||||||
|
</div>
|
||||||
|
<div class="col-lg-9">
|
||||||
|
<pngx-permissions-group type="change" formControlName="assign_change_groups"></pngx-permissions-group>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<small class="form-text text-muted text-end d-block" i18n>Edit permissions also grant viewing permissions</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
@case (WorkflowActionType.Removal) {
|
||||||
|
<div class="row">
|
||||||
|
<div class="col">
|
||||||
|
<h6 class="form-label" i18n>Remove tags</h6>
|
||||||
|
<pngx-input-switch i18n-title title="Remove all" [horizontal]="true" formControlName="remove_all_tags"></pngx-input-switch>
|
||||||
|
<div class="mt-n3">
|
||||||
|
<pngx-input-tags [allowCreate]="false" title="" formControlName="remove_tags"></pngx-input-tags>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h6 class="form-label" i18n>Remove correspondents</h6>
|
||||||
|
<pngx-input-switch i18n-title title="Remove all" [horizontal]="true" formControlName="remove_all_correspondents"></pngx-input-switch>
|
||||||
|
<div class="mt-n3">
|
||||||
|
<pngx-input-select i18n-title title="" multiple="true" [items]="correspondents" formControlName="remove_correspondents"></pngx-input-select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h6 class="form-label" i18n>Remove document types</h6>
|
||||||
|
<pngx-input-switch i18n-title title="Remove all" [horizontal]="true" formControlName="remove_all_document_types"></pngx-input-switch>
|
||||||
|
<div class="mt-n3">
|
||||||
|
<pngx-input-select i18n-title title="" multiple="true" [items]="documentTypes" formControlName="remove_document_types"></pngx-input-select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h6 class="form-label" i18n>Remove storage paths</h6>
|
||||||
|
<pngx-input-switch i18n-title title="Remove all" [horizontal]="true" formControlName="remove_all_storage_paths"></pngx-input-switch>
|
||||||
|
<div class="mt-n3">
|
||||||
|
<pngx-input-select i18n-title title="" multiple="true" [items]="storagePaths" formControlName="remove_storage_paths"></pngx-input-select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h6 class="form-label" i18n>Remove custom fields</h6>
|
||||||
|
<pngx-input-switch i18n-title title="Remove all" [horizontal]="true" formControlName="remove_all_custom_fields"></pngx-input-switch>
|
||||||
|
<div class="mt-n3">
|
||||||
|
<pngx-input-select i18n-title title="" multiple="true" [items]="customFields" formControlName="remove_custom_fields"></pngx-input-select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col">
|
||||||
|
<h6 class="form-label" i18n>Remove owners</h6>
|
||||||
|
<pngx-input-switch i18n-title title="Remove all" [horizontal]="true" formControlName="remove_all_owners"></pngx-input-switch>
|
||||||
|
<div class="mt-n3">
|
||||||
|
<pngx-input-select i18n-title title="" multiple="true" [items]="users" bindLabel="username" formControlName="remove_owners"></pngx-input-select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h6 class="form-label" i18n>Remove permissions</h6>
|
||||||
|
<pngx-input-switch i18n-title title="Remove all" [horizontal]="true" formControlName="remove_all_permissions"></pngx-input-switch>
|
||||||
|
<div>
|
||||||
|
<label class="form-label" i18n>View permissions</label>
|
||||||
|
<div class="mb-2">
|
||||||
|
<div class="row mb-1">
|
||||||
|
<div class="col-lg-3">
|
||||||
|
<label class="form-label d-block my-2 text-nowrap" i18n>Users:</label>
|
||||||
|
</div>
|
||||||
|
<div class="col-lg-9">
|
||||||
|
<pngx-permissions-user type="view" formControlName="remove_view_users"></pngx-permissions-user>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-lg-3">
|
||||||
|
<label class="form-label d-block my-2 text-nowrap" i18n>Groups:</label>
|
||||||
|
</div>
|
||||||
|
<div class="col-lg-9">
|
||||||
|
<pngx-permissions-group type="view" formControlName="remove_view_groups"></pngx-permissions-group>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<label class="form-label" i18n>Edit permissions</label>
|
||||||
|
<div>
|
||||||
|
<div class="row mb-1">
|
||||||
|
<div class="col-lg-3">
|
||||||
|
<label class="form-label d-block my-2 text-nowrap" i18n>Users:</label>
|
||||||
|
</div>
|
||||||
|
<div class="col-lg-9">
|
||||||
|
<pngx-permissions-user type="change" formControlName="remove_change_users"></pngx-permissions-user>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="row">
|
||||||
|
<div class="col-lg-3">
|
||||||
|
<label class="form-label d-block my-2 text-nowrap" i18n>Groups:</label>
|
||||||
|
</div>
|
||||||
|
<div class="col-lg-9">
|
||||||
|
<pngx-permissions-group type="change" formControlName="remove_change_groups"></pngx-permissions-group>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<small class="form-text text-muted text-end d-block" i18n>Edit permissions also grant viewing permissions</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</ng-template>
|
||||||
|
@@ -38,6 +38,7 @@ import {
|
|||||||
WorkflowActionType,
|
WorkflowActionType,
|
||||||
} from 'src/app/data/workflow-action'
|
} from 'src/app/data/workflow-action'
|
||||||
import { MATCHING_ALGORITHMS, MATCH_AUTO } from 'src/app/data/matching-model'
|
import { MATCHING_ALGORITHMS, MATCH_AUTO } from 'src/app/data/matching-model'
|
||||||
|
import { ConfirmButtonComponent } from '../../confirm-button/confirm-button.component'
|
||||||
|
|
||||||
const workflow: Workflow = {
|
const workflow: Workflow = {
|
||||||
name: 'Workflow 1',
|
name: 'Workflow 1',
|
||||||
@@ -85,6 +86,7 @@ describe('WorkflowEditDialogComponent', () => {
|
|||||||
PermissionsUserComponent,
|
PermissionsUserComponent,
|
||||||
PermissionsGroupComponent,
|
PermissionsGroupComponent,
|
||||||
SafeHtmlPipe,
|
SafeHtmlPipe,
|
||||||
|
ConfirmButtonComponent,
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
NgbActiveModal,
|
NgbActiveModal,
|
||||||
@@ -233,4 +235,103 @@ describe('WorkflowEditDialogComponent', () => {
|
|||||||
MATCHING_ALGORITHMS.find((a) => a.id === MATCH_AUTO)
|
MATCHING_ALGORITHMS.find((a) => a.id === MATCH_AUTO)
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should disable or enable action fields based on removal action type', () => {
|
||||||
|
const workflow: Workflow = {
|
||||||
|
name: 'Workflow 1',
|
||||||
|
id: 1,
|
||||||
|
order: 1,
|
||||||
|
enabled: true,
|
||||||
|
triggers: [],
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
type: WorkflowActionType.Removal,
|
||||||
|
remove_all_tags: true,
|
||||||
|
remove_all_document_types: true,
|
||||||
|
remove_all_correspondents: true,
|
||||||
|
remove_all_storage_paths: true,
|
||||||
|
remove_all_custom_fields: true,
|
||||||
|
remove_all_owners: true,
|
||||||
|
remove_all_permissions: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
component.object = workflow
|
||||||
|
component.ngOnInit()
|
||||||
|
|
||||||
|
component['checkRemovalActionFields'](workflow)
|
||||||
|
|
||||||
|
// Assert that the action fields are disabled or enabled correctly
|
||||||
|
expect(
|
||||||
|
component.actionFields.at(0).get('remove_tags').disabled
|
||||||
|
).toBeTruthy()
|
||||||
|
expect(
|
||||||
|
component.actionFields.at(0).get('remove_document_types').disabled
|
||||||
|
).toBeTruthy()
|
||||||
|
expect(
|
||||||
|
component.actionFields.at(0).get('remove_correspondents').disabled
|
||||||
|
).toBeTruthy()
|
||||||
|
expect(
|
||||||
|
component.actionFields.at(0).get('remove_storage_paths').disabled
|
||||||
|
).toBeTruthy()
|
||||||
|
expect(
|
||||||
|
component.actionFields.at(0).get('remove_custom_fields').disabled
|
||||||
|
).toBeTruthy()
|
||||||
|
expect(
|
||||||
|
component.actionFields.at(0).get('remove_owners').disabled
|
||||||
|
).toBeTruthy()
|
||||||
|
expect(
|
||||||
|
component.actionFields.at(0).get('remove_view_users').disabled
|
||||||
|
).toBeTruthy()
|
||||||
|
expect(
|
||||||
|
component.actionFields.at(0).get('remove_view_groups').disabled
|
||||||
|
).toBeTruthy()
|
||||||
|
expect(
|
||||||
|
component.actionFields.at(0).get('remove_change_users').disabled
|
||||||
|
).toBeTruthy()
|
||||||
|
expect(
|
||||||
|
component.actionFields.at(0).get('remove_change_groups').disabled
|
||||||
|
).toBeTruthy()
|
||||||
|
|
||||||
|
workflow.actions[0].remove_all_tags = false
|
||||||
|
workflow.actions[0].remove_all_document_types = false
|
||||||
|
workflow.actions[0].remove_all_correspondents = false
|
||||||
|
workflow.actions[0].remove_all_storage_paths = false
|
||||||
|
workflow.actions[0].remove_all_custom_fields = false
|
||||||
|
workflow.actions[0].remove_all_owners = false
|
||||||
|
workflow.actions[0].remove_all_permissions = false
|
||||||
|
|
||||||
|
component['checkRemovalActionFields'](workflow)
|
||||||
|
|
||||||
|
// Assert that the action fields are disabled or enabled correctly
|
||||||
|
expect(component.actionFields.at(0).get('remove_tags').disabled).toBeFalsy()
|
||||||
|
expect(
|
||||||
|
component.actionFields.at(0).get('remove_document_types').disabled
|
||||||
|
).toBeFalsy()
|
||||||
|
expect(
|
||||||
|
component.actionFields.at(0).get('remove_correspondents').disabled
|
||||||
|
).toBeFalsy()
|
||||||
|
expect(
|
||||||
|
component.actionFields.at(0).get('remove_storage_paths').disabled
|
||||||
|
).toBeFalsy()
|
||||||
|
expect(
|
||||||
|
component.actionFields.at(0).get('remove_custom_fields').disabled
|
||||||
|
).toBeFalsy()
|
||||||
|
expect(
|
||||||
|
component.actionFields.at(0).get('remove_owners').disabled
|
||||||
|
).toBeFalsy()
|
||||||
|
expect(
|
||||||
|
component.actionFields.at(0).get('remove_view_users').disabled
|
||||||
|
).toBeFalsy()
|
||||||
|
expect(
|
||||||
|
component.actionFields.at(0).get('remove_view_groups').disabled
|
||||||
|
).toBeFalsy()
|
||||||
|
expect(
|
||||||
|
component.actionFields.at(0).get('remove_change_users').disabled
|
||||||
|
).toBeFalsy()
|
||||||
|
expect(
|
||||||
|
component.actionFields.at(0).get('remove_change_groups').disabled
|
||||||
|
).toBeFalsy()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
@@ -68,6 +68,10 @@ export const WORKFLOW_ACTION_OPTIONS = [
|
|||||||
id: WorkflowActionType.Assignment,
|
id: WorkflowActionType.Assignment,
|
||||||
name: $localize`Assignment`,
|
name: $localize`Assignment`,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: WorkflowActionType.Removal,
|
||||||
|
name: $localize`Removal`,
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
const TRIGGER_MATCHING_ALGORITHMS = MATCHING_ALGORITHMS.filter(
|
const TRIGGER_MATCHING_ALGORITHMS = MATCHING_ALGORITHMS.filter(
|
||||||
@@ -84,6 +88,7 @@ export class WorkflowEditDialogComponent
|
|||||||
implements OnInit
|
implements OnInit
|
||||||
{
|
{
|
||||||
public WorkflowTriggerType = WorkflowTriggerType
|
public WorkflowTriggerType = WorkflowTriggerType
|
||||||
|
public WorkflowActionType = WorkflowActionType
|
||||||
|
|
||||||
templates: Workflow[]
|
templates: Workflow[]
|
||||||
correspondents: Correspondent[]
|
correspondents: Correspondent[]
|
||||||
@@ -159,6 +164,124 @@ export class WorkflowEditDialogComponent
|
|||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
super.ngOnInit()
|
super.ngOnInit()
|
||||||
this.updateAllTriggerActionFields()
|
this.updateAllTriggerActionFields()
|
||||||
|
this.objectForm.valueChanges.subscribe(
|
||||||
|
this.checkRemovalActionFields.bind(this)
|
||||||
|
)
|
||||||
|
this.checkRemovalActionFields(this.objectForm.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
private checkRemovalActionFields(formWorkflow: Workflow) {
|
||||||
|
formWorkflow.actions
|
||||||
|
.filter((action) => action.type === WorkflowActionType.Removal)
|
||||||
|
.forEach((action, i) => {
|
||||||
|
if (action.remove_all_tags) {
|
||||||
|
this.actionFields
|
||||||
|
.at(i)
|
||||||
|
.get('remove_tags')
|
||||||
|
.disable({ emitEvent: false })
|
||||||
|
} else {
|
||||||
|
this.actionFields
|
||||||
|
.at(i)
|
||||||
|
.get('remove_tags')
|
||||||
|
.enable({ emitEvent: false })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action.remove_all_document_types) {
|
||||||
|
this.actionFields
|
||||||
|
.at(i)
|
||||||
|
.get('remove_document_types')
|
||||||
|
.disable({ emitEvent: false })
|
||||||
|
} else {
|
||||||
|
this.actionFields
|
||||||
|
.at(i)
|
||||||
|
.get('remove_document_types')
|
||||||
|
.enable({ emitEvent: false })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action.remove_all_correspondents) {
|
||||||
|
this.actionFields
|
||||||
|
.at(i)
|
||||||
|
.get('remove_correspondents')
|
||||||
|
.disable({ emitEvent: false })
|
||||||
|
} else {
|
||||||
|
this.actionFields
|
||||||
|
.at(i)
|
||||||
|
.get('remove_correspondents')
|
||||||
|
.enable({ emitEvent: false })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action.remove_all_storage_paths) {
|
||||||
|
this.actionFields
|
||||||
|
.at(i)
|
||||||
|
.get('remove_storage_paths')
|
||||||
|
.disable({ emitEvent: false })
|
||||||
|
} else {
|
||||||
|
this.actionFields
|
||||||
|
.at(i)
|
||||||
|
.get('remove_storage_paths')
|
||||||
|
.enable({ emitEvent: false })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action.remove_all_custom_fields) {
|
||||||
|
this.actionFields
|
||||||
|
.at(i)
|
||||||
|
.get('remove_custom_fields')
|
||||||
|
.disable({ emitEvent: false })
|
||||||
|
} else {
|
||||||
|
this.actionFields
|
||||||
|
.at(i)
|
||||||
|
.get('remove_custom_fields')
|
||||||
|
.enable({ emitEvent: false })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action.remove_all_owners) {
|
||||||
|
this.actionFields
|
||||||
|
.at(i)
|
||||||
|
.get('remove_owners')
|
||||||
|
.disable({ emitEvent: false })
|
||||||
|
} else {
|
||||||
|
this.actionFields
|
||||||
|
.at(i)
|
||||||
|
.get('remove_owners')
|
||||||
|
.enable({ emitEvent: false })
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action.remove_all_permissions) {
|
||||||
|
this.actionFields
|
||||||
|
.at(i)
|
||||||
|
.get('remove_view_users')
|
||||||
|
.disable({ emitEvent: false })
|
||||||
|
this.actionFields
|
||||||
|
.at(i)
|
||||||
|
.get('remove_view_groups')
|
||||||
|
.disable({ emitEvent: false })
|
||||||
|
this.actionFields
|
||||||
|
.at(i)
|
||||||
|
.get('remove_change_users')
|
||||||
|
.disable({ emitEvent: false })
|
||||||
|
this.actionFields
|
||||||
|
.at(i)
|
||||||
|
.get('remove_change_groups')
|
||||||
|
.disable({ emitEvent: false })
|
||||||
|
} else {
|
||||||
|
this.actionFields
|
||||||
|
.at(i)
|
||||||
|
.get('remove_view_users')
|
||||||
|
.enable({ emitEvent: false })
|
||||||
|
this.actionFields
|
||||||
|
.at(i)
|
||||||
|
.get('remove_view_groups')
|
||||||
|
.enable({ emitEvent: false })
|
||||||
|
this.actionFields
|
||||||
|
.at(i)
|
||||||
|
.get('remove_change_users')
|
||||||
|
.enable({ emitEvent: false })
|
||||||
|
this.actionFields
|
||||||
|
.at(i)
|
||||||
|
.get('remove_change_groups')
|
||||||
|
.enable({ emitEvent: false })
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
get triggerFields(): FormArray {
|
get triggerFields(): FormArray {
|
||||||
@@ -215,6 +338,31 @@ export class WorkflowEditDialogComponent
|
|||||||
assign_change_users: new FormControl(action.assign_change_users),
|
assign_change_users: new FormControl(action.assign_change_users),
|
||||||
assign_change_groups: new FormControl(action.assign_change_groups),
|
assign_change_groups: new FormControl(action.assign_change_groups),
|
||||||
assign_custom_fields: new FormControl(action.assign_custom_fields),
|
assign_custom_fields: new FormControl(action.assign_custom_fields),
|
||||||
|
remove_tags: new FormControl(action.remove_tags),
|
||||||
|
remove_all_tags: new FormControl(action.remove_all_tags),
|
||||||
|
remove_document_types: new FormControl(action.remove_document_types),
|
||||||
|
remove_all_document_types: new FormControl(
|
||||||
|
action.remove_all_document_types
|
||||||
|
),
|
||||||
|
remove_correspondents: new FormControl(action.remove_correspondents),
|
||||||
|
remove_all_correspondents: new FormControl(
|
||||||
|
action.remove_all_correspondents
|
||||||
|
),
|
||||||
|
remove_storage_paths: new FormControl(action.remove_storage_paths),
|
||||||
|
remove_all_storage_paths: new FormControl(
|
||||||
|
action.remove_all_storage_paths
|
||||||
|
),
|
||||||
|
remove_owners: new FormControl(action.remove_owners),
|
||||||
|
remove_all_owners: new FormControl(action.remove_all_owners),
|
||||||
|
remove_view_users: new FormControl(action.remove_view_users),
|
||||||
|
remove_view_groups: new FormControl(action.remove_view_groups),
|
||||||
|
remove_change_users: new FormControl(action.remove_change_users),
|
||||||
|
remove_change_groups: new FormControl(action.remove_change_groups),
|
||||||
|
remove_all_permissions: new FormControl(action.remove_all_permissions),
|
||||||
|
remove_custom_fields: new FormControl(action.remove_custom_fields),
|
||||||
|
remove_all_custom_fields: new FormControl(
|
||||||
|
action.remove_all_custom_fields
|
||||||
|
),
|
||||||
}),
|
}),
|
||||||
{ emitEvent }
|
{ emitEvent }
|
||||||
)
|
)
|
||||||
@@ -290,6 +438,23 @@ export class WorkflowEditDialogComponent
|
|||||||
assign_change_users: [],
|
assign_change_users: [],
|
||||||
assign_change_groups: [],
|
assign_change_groups: [],
|
||||||
assign_custom_fields: [],
|
assign_custom_fields: [],
|
||||||
|
remove_tags: [],
|
||||||
|
remove_all_tags: false,
|
||||||
|
remove_document_types: [],
|
||||||
|
remove_all_document_types: false,
|
||||||
|
remove_correspondents: [],
|
||||||
|
remove_all_correspondents: false,
|
||||||
|
remove_storage_paths: [],
|
||||||
|
remove_all_storage_paths: false,
|
||||||
|
remove_owners: [],
|
||||||
|
remove_all_owners: false,
|
||||||
|
remove_view_users: [],
|
||||||
|
remove_view_groups: [],
|
||||||
|
remove_change_users: [],
|
||||||
|
remove_change_groups: [],
|
||||||
|
remove_all_permissions: false,
|
||||||
|
remove_custom_fields: [],
|
||||||
|
remove_all_custom_fields: false,
|
||||||
}
|
}
|
||||||
this.object.actions.push(action)
|
this.object.actions.push(action)
|
||||||
this.createActionField(action)
|
this.createActionField(action)
|
||||||
|
@@ -45,10 +45,18 @@
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@if (editing) {
|
@if (editing) {
|
||||||
<button class="list-group-item list-group-item-action bg-light" (click)="applyClicked()" [disabled]="!modelIsDirty || disabled">
|
@if ((selectionModel.itemsSorted | filter: filterText).length === 0 && createRef !== undefined) {
|
||||||
<small class="ms-2" [ngClass]="{'fw-bold': modelIsDirty}" i18n>Apply</small>
|
<button class="list-group-item list-group-item-action bg-light" (click)="createClicked()" [disabled]="disabled">
|
||||||
<i-bs width="1.5em" height="1em" name="arrow-right"></i-bs>
|
<small class="ms-2"><ng-container i18n>Create</ng-container> "{{filterText}}"</small>
|
||||||
</button>
|
<i-bs width="1.5em" height="1em" name="plus"></i-bs>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
@if ((selectionModel.itemsSorted | filter: filterText).length > 0) {
|
||||||
|
<button class="list-group-item list-group-item-action bg-light" (click)="applyClicked()" [disabled]="!modelIsDirty || disabled">
|
||||||
|
<small class="ms-2" [ngClass]="{'fw-bold': modelIsDirty}" i18n>Apply</small>
|
||||||
|
<i-bs width="1.5em" height="1em" name="arrow-right"></i-bs>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@if (!editing && manyToOne) {
|
@if (!editing && manyToOne) {
|
||||||
<div class="list-group-item list-group-item-note pt-1 pb-2">
|
<div class="list-group-item list-group-item-note pt-1 pb-2">
|
||||||
|
@@ -493,11 +493,88 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
|
|||||||
expect(changedResult.getExcludedItems()).toEqual(items)
|
expect(changedResult.getExcludedItems()).toEqual(items)
|
||||||
}))
|
}))
|
||||||
|
|
||||||
it('FilterableDropdownSelectionModel should sort items by state', () => {
|
it('selection model should sort items by state', () => {
|
||||||
component.items = items
|
component.items = items.concat([{ id: null, name: 'Null B' }])
|
||||||
component.selectionModel = selectionModel
|
component.selectionModel = selectionModel
|
||||||
selectionModel.toggle(items[1].id)
|
selectionModel.toggle(items[1].id)
|
||||||
selectionModel.apply()
|
selectionModel.apply()
|
||||||
expect(selectionModel.itemsSorted).toEqual([nullItem, items[1], items[0]])
|
expect(selectionModel.itemsSorted).toEqual([
|
||||||
|
nullItem,
|
||||||
|
{ id: null, name: 'Null B' },
|
||||||
|
items[1],
|
||||||
|
items[0],
|
||||||
|
])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should set support create, keep open model and call createRef method', fakeAsync(() => {
|
||||||
|
component.items = items
|
||||||
|
component.icon = 'tag-fill'
|
||||||
|
component.selectionModel = selectionModel
|
||||||
|
fixture.nativeElement
|
||||||
|
.querySelector('button')
|
||||||
|
.dispatchEvent(new MouseEvent('click')) // open
|
||||||
|
fixture.detectChanges()
|
||||||
|
tick(100)
|
||||||
|
|
||||||
|
component.filterText = 'Test Filter Text'
|
||||||
|
component.createRef = jest.fn()
|
||||||
|
component.createClicked()
|
||||||
|
expect(component.creating).toBeTruthy()
|
||||||
|
expect(component.createRef).toHaveBeenCalledWith('Test Filter Text')
|
||||||
|
const openSpy = jest.spyOn(component.dropdown, 'open')
|
||||||
|
component.dropdownOpenChange(false)
|
||||||
|
expect(openSpy).toHaveBeenCalled() // should keep open
|
||||||
|
}))
|
||||||
|
|
||||||
|
it('should call create on enter inside filter field if 0 items remain while editing', fakeAsync(() => {
|
||||||
|
component.items = items
|
||||||
|
component.icon = 'tag-fill'
|
||||||
|
component.editing = true
|
||||||
|
component.createRef = jest.fn()
|
||||||
|
const createSpy = jest.spyOn(component, 'createClicked')
|
||||||
|
expect(component.selectionModel.getSelectedItems()).toEqual([])
|
||||||
|
fixture.nativeElement
|
||||||
|
.querySelector('button')
|
||||||
|
.dispatchEvent(new MouseEvent('click')) // open
|
||||||
|
fixture.detectChanges()
|
||||||
|
tick(100)
|
||||||
|
component.filterText = 'FooBar'
|
||||||
|
fixture.detectChanges()
|
||||||
|
component.listFilterTextInput.nativeElement.dispatchEvent(
|
||||||
|
new KeyboardEvent('keyup', { key: 'Enter' })
|
||||||
|
)
|
||||||
|
expect(component.selectionModel.getSelectedItems()).toEqual([])
|
||||||
|
tick(300)
|
||||||
|
expect(createSpy).toHaveBeenCalled()
|
||||||
|
}))
|
||||||
|
|
||||||
|
it('should exclude item and trigger change event', () => {
|
||||||
|
const id = 1
|
||||||
|
const state = ToggleableItemState.Selected
|
||||||
|
component.selectionModel = selectionModel
|
||||||
|
component.manyToOne = true
|
||||||
|
component.selectionModel.singleSelect = true
|
||||||
|
component.selectionModel.intersection = Intersection.Include
|
||||||
|
component.selectionModel['temporarySelectionStates'].set(id, state)
|
||||||
|
const changedSpy = jest.spyOn(component.selectionModel.changed, 'next')
|
||||||
|
component.selectionModel.exclude(id)
|
||||||
|
expect(component.selectionModel.temporaryLogicalOperator).toBe(
|
||||||
|
LogicalOperator.And
|
||||||
|
)
|
||||||
|
expect(component.selectionModel['temporarySelectionStates'].get(id)).toBe(
|
||||||
|
ToggleableItemState.Excluded
|
||||||
|
)
|
||||||
|
expect(component.selectionModel['temporarySelectionStates'].size).toBe(1)
|
||||||
|
expect(changedSpy).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should initialize selection states and apply changes', () => {
|
||||||
|
selectionModel.items = items
|
||||||
|
const map = new Map<number, ToggleableItemState>()
|
||||||
|
map.set(1, ToggleableItemState.Selected)
|
||||||
|
map.set(2, ToggleableItemState.Excluded)
|
||||||
|
selectionModel.init(map)
|
||||||
|
expect(selectionModel.getSelectedItems()).toEqual([items[0]])
|
||||||
|
expect(selectionModel.getExcludedItems()).toEqual([items[1]])
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
@@ -275,7 +275,7 @@ export class FilterableDropdownSelectionModel {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
init(map) {
|
init(map: Map<number, ToggleableItemState>) {
|
||||||
this.temporarySelectionStates = map
|
this.temporarySelectionStates = map
|
||||||
this.apply()
|
this.apply()
|
||||||
}
|
}
|
||||||
@@ -398,6 +398,11 @@ export class FilterableDropdownComponent {
|
|||||||
@Input()
|
@Input()
|
||||||
disabled = false
|
disabled = false
|
||||||
|
|
||||||
|
@Input()
|
||||||
|
createRef: (name) => void
|
||||||
|
|
||||||
|
creating: boolean = false
|
||||||
|
|
||||||
@Output()
|
@Output()
|
||||||
apply = new EventEmitter<ChangedItems>()
|
apply = new EventEmitter<ChangedItems>()
|
||||||
|
|
||||||
@@ -437,6 +442,11 @@ export class FilterableDropdownComponent {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
createClicked() {
|
||||||
|
this.creating = true
|
||||||
|
this.createRef(this.filterText)
|
||||||
|
}
|
||||||
|
|
||||||
dropdownOpenChange(open: boolean): void {
|
dropdownOpenChange(open: boolean): void {
|
||||||
if (open) {
|
if (open) {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@@ -448,9 +458,14 @@ export class FilterableDropdownComponent {
|
|||||||
}
|
}
|
||||||
this.opened.next(this)
|
this.opened.next(this)
|
||||||
} else {
|
} else {
|
||||||
this.filterText = ''
|
if (this.creating) {
|
||||||
if (this.applyOnClose && this.selectionModel.isDirty()) {
|
this.dropdown.open()
|
||||||
this.apply.emit(this.selectionModel.diff())
|
this.creating = false
|
||||||
|
} else {
|
||||||
|
this.filterText = ''
|
||||||
|
if (this.applyOnClose && this.selectionModel.isDirty()) {
|
||||||
|
this.apply.emit(this.selectionModel.diff())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -466,6 +481,8 @@ export class FilterableDropdownComponent {
|
|||||||
this.dropdown.close()
|
this.dropdown.close()
|
||||||
}
|
}
|
||||||
}, 200)
|
}, 200)
|
||||||
|
} else if (filtered.length == 0 && this.createRef) {
|
||||||
|
this.createClicked()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -0,0 +1,27 @@
|
|||||||
|
<div class="mb-3" [class.pb-3]="error">
|
||||||
|
<div class="row">
|
||||||
|
<div class="d-flex align-items-center position-relative hidden-button-container" [class.col-md-3]="horizontal">
|
||||||
|
@if (title) {
|
||||||
|
<label class="form-label" [class.mb-md-0]="horizontal" [for]="inputId">{{title}}</label>
|
||||||
|
}
|
||||||
|
@if (removable) {
|
||||||
|
<button type="button" class="btn btn-sm btn-danger position-absolute left-0" (click)="removed.emit(this)">
|
||||||
|
<i-bs name="x"></i-bs> <ng-container i18n>Remove</ng-container>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="position-relative" [class.col-md-9]="horizontal">
|
||||||
|
<div class="input-group" [class.is-invalid]="error">
|
||||||
|
<span class="input-group-text fw-bold bg-light">{{monetaryValue | currency: currencyCode }}</span>
|
||||||
|
<input #currencyField class="form-control text-muted mw-60" tabindex="0" [(ngModel)]="currencyCode" maxlength="3" [class.is-invalid]="error" (change)="onChange(value)" [disabled]="disabled">
|
||||||
|
<input #inputField type="number" tabindex="0" class="form-control text-muted" step=".01" [id]="inputId" [(ngModel)]="monetaryValue" (change)="onChange(value)" [class.is-invalid]="error" [disabled]="disabled">
|
||||||
|
</div>
|
||||||
|
<div class="invalid-feedback position-absolute top-100">
|
||||||
|
{{error}}
|
||||||
|
</div>
|
||||||
|
@if (hint) {
|
||||||
|
<small class="form-text text-muted">{{hint}}</small>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
@@ -0,0 +1,11 @@
|
|||||||
|
.input-group-text {
|
||||||
|
font-size: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.text-muted:focus-within {
|
||||||
|
color: var(--bs-body-color) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mw-60 {
|
||||||
|
max-width: 60px;
|
||||||
|
}
|
@@ -0,0 +1,59 @@
|
|||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||||
|
import {
|
||||||
|
FormsModule,
|
||||||
|
NG_VALUE_ACCESSOR,
|
||||||
|
ReactiveFormsModule,
|
||||||
|
} from '@angular/forms'
|
||||||
|
import { HttpClientTestingModule } from '@angular/common/http/testing'
|
||||||
|
import { CurrencyPipe } from '@angular/common'
|
||||||
|
import { MonetaryComponent } from './monetary.component'
|
||||||
|
|
||||||
|
describe('MonetaryComponent', () => {
|
||||||
|
let component: MonetaryComponent
|
||||||
|
let fixture: ComponentFixture<MonetaryComponent>
|
||||||
|
let input: HTMLInputElement
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
declarations: [MonetaryComponent],
|
||||||
|
providers: [CurrencyPipe],
|
||||||
|
imports: [FormsModule, ReactiveFormsModule, HttpClientTestingModule],
|
||||||
|
}).compileComponents()
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(MonetaryComponent)
|
||||||
|
fixture.debugElement.injector.get(NG_VALUE_ACCESSOR)
|
||||||
|
component = fixture.componentInstance
|
||||||
|
fixture.detectChanges()
|
||||||
|
input = component.inputField.nativeElement
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should set the currency code correctly', () => {
|
||||||
|
expect(component.currencyCode).toEqual('USD') // default
|
||||||
|
component.currencyCode = 'EUR'
|
||||||
|
expect(component.currencyCode).toEqual('EUR')
|
||||||
|
|
||||||
|
component.value = 'G123.4'
|
||||||
|
jest
|
||||||
|
.spyOn(document, 'activeElement', 'get')
|
||||||
|
.mockReturnValue(component.currencyField.nativeElement)
|
||||||
|
expect(component.currencyCode).toEqual('G')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should parse monetary value only when out of focus', () => {
|
||||||
|
component.monetaryValue = 10.5
|
||||||
|
jest.spyOn(document, 'activeElement', 'get').mockReturnValue(null)
|
||||||
|
expect(component.monetaryValue).toEqual('10.50')
|
||||||
|
|
||||||
|
component.value = 'GBP123.4'
|
||||||
|
jest
|
||||||
|
.spyOn(document, 'activeElement', 'get')
|
||||||
|
.mockReturnValue(component.inputField.nativeElement)
|
||||||
|
expect(component.monetaryValue).toEqual('123.4')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should report value including currency code and monetary value', () => {
|
||||||
|
component.currencyCode = 'EUR'
|
||||||
|
component.monetaryValue = 10.5
|
||||||
|
expect(component.value).toEqual('EUR10.50')
|
||||||
|
})
|
||||||
|
})
|
@@ -0,0 +1,59 @@
|
|||||||
|
import {
|
||||||
|
Component,
|
||||||
|
DEFAULT_CURRENCY_CODE,
|
||||||
|
ElementRef,
|
||||||
|
forwardRef,
|
||||||
|
Inject,
|
||||||
|
ViewChild,
|
||||||
|
} from '@angular/core'
|
||||||
|
import { NG_VALUE_ACCESSOR } from '@angular/forms'
|
||||||
|
import { AbstractInputComponent } from '../abstract-input'
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
provide: NG_VALUE_ACCESSOR,
|
||||||
|
useExisting: forwardRef(() => MonetaryComponent),
|
||||||
|
multi: true,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
selector: 'pngx-input-monetary',
|
||||||
|
templateUrl: './monetary.component.html',
|
||||||
|
styleUrls: ['./monetary.component.scss'],
|
||||||
|
})
|
||||||
|
export class MonetaryComponent extends AbstractInputComponent<string> {
|
||||||
|
@ViewChild('currencyField')
|
||||||
|
currencyField: ElementRef
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
@Inject(DEFAULT_CURRENCY_CODE) public defaultCurrencyCode: string
|
||||||
|
) {
|
||||||
|
super()
|
||||||
|
}
|
||||||
|
|
||||||
|
get currencyCode(): string {
|
||||||
|
const focused = document.activeElement === this.currencyField?.nativeElement
|
||||||
|
if (focused && this.value) return this.value.match(/^([A-Z]{0,3})/)?.[0]
|
||||||
|
return (
|
||||||
|
this.value
|
||||||
|
?.toString()
|
||||||
|
.toUpperCase()
|
||||||
|
.match(/^([A-Z]{1,3})/)?.[0] ?? this.defaultCurrencyCode
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
set currencyCode(value: string) {
|
||||||
|
this.value = value + this.monetaryValue?.toString()
|
||||||
|
}
|
||||||
|
|
||||||
|
get monetaryValue(): string {
|
||||||
|
if (!this.value) return null
|
||||||
|
const focused = document.activeElement === this.inputField?.nativeElement
|
||||||
|
const val = parseFloat(this.value.toString().replace(/[^0-9.,]+/g, ''))
|
||||||
|
return focused ? val.toString() : val.toFixed(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
set monetaryValue(value: number) {
|
||||||
|
this.value = this.currencyCode + value.toFixed(2)
|
||||||
|
}
|
||||||
|
}
|
@@ -47,22 +47,25 @@ describe('NumberComponent', () => {
|
|||||||
expect(component.value).toEqual(1002)
|
expect(component.value).toEqual(1002)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should support float & monetary values', () => {
|
it('should support float, monetary values & scientific notation', () => {
|
||||||
component.writeValue(11.13)
|
const mockFn = jest.fn()
|
||||||
expect(component.value).toEqual(11)
|
component.registerOnChange(mockFn)
|
||||||
|
|
||||||
|
component.step = 1
|
||||||
|
component.onChange(11.13)
|
||||||
|
expect(mockFn).toHaveBeenCalledWith(11)
|
||||||
|
|
||||||
|
component.onChange(1.23456789e8)
|
||||||
|
expect(mockFn).toHaveBeenCalledWith(123456789)
|
||||||
|
|
||||||
|
component.step = 0.01
|
||||||
|
component.onChange(11.1)
|
||||||
|
expect(mockFn).toHaveBeenCalledWith('11.10')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should display monetary values fixed to 2 decimals', () => {
|
||||||
component.step = 0.01
|
component.step = 0.01
|
||||||
component.writeValue(11.1)
|
component.writeValue(11.1)
|
||||||
expect(component.value).toEqual('11.10')
|
expect(component.value).toEqual('11.10')
|
||||||
component.step = 0.1
|
|
||||||
component.writeValue(12.3456)
|
|
||||||
expect(component.value).toEqual(12.3456)
|
|
||||||
// float (step = .1) doesn't force 2 decimals
|
|
||||||
component.writeValue(11.1)
|
|
||||||
expect(component.value).toEqual(11.1)
|
|
||||||
})
|
|
||||||
|
|
||||||
it('should support scientific notation', () => {
|
|
||||||
component.writeValue(1.23456789e8)
|
|
||||||
expect(component.value).toEqual(123456789)
|
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
@@ -36,9 +36,18 @@ export class NumberComponent extends AbstractInputComponent<number> {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
registerOnChange(fn: any): void {
|
||||||
|
this.onChange = (newValue: any) => {
|
||||||
|
// number validation
|
||||||
|
if (this.step === 1 && newValue?.toString().indexOf('e') === -1)
|
||||||
|
newValue = parseInt(newValue, 10)
|
||||||
|
if (this.step === 0.01) newValue = parseFloat(newValue).toFixed(2)
|
||||||
|
fn(newValue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
writeValue(newValue: any): void {
|
writeValue(newValue: any): void {
|
||||||
if (this.step === 1 && newValue?.toString().indexOf('e') === -1)
|
// Allow monetary values to be displayed with 2 decimals
|
||||||
newValue = parseInt(newValue, 10)
|
|
||||||
if (this.step === 0.01) newValue = parseFloat(newValue).toFixed(2)
|
if (this.step === 0.01) newValue = parseFloat(newValue).toFixed(2)
|
||||||
super.writeValue(newValue)
|
super.writeValue(newValue)
|
||||||
}
|
}
|
||||||
|
@@ -1,4 +1,4 @@
|
|||||||
<div class="paperless-input-select">
|
<div class="paperless-input-select" [class.disabled]="disabled">
|
||||||
<div>
|
<div>
|
||||||
<ng-select name="inputId" [(ngModel)]="value"
|
<ng-select name="inputId" [(ngModel)]="value"
|
||||||
[disabled]="disabled"
|
[disabled]="disabled"
|
||||||
|
@@ -0,0 +1,11 @@
|
|||||||
|
.paperless-input-select.disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
|
||||||
|
::ng-deep ng-select {
|
||||||
|
pointer-events: none;
|
||||||
|
|
||||||
|
.ng-select-container {
|
||||||
|
background-color: var(--pngx-bg-alt) !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@@ -1,4 +1,4 @@
|
|||||||
<div class="paperless-input-select">
|
<div class="paperless-input-select" [class.disabled]="disabled">
|
||||||
<div>
|
<div>
|
||||||
<ng-select name="inputId" [(ngModel)]="value"
|
<ng-select name="inputId" [(ngModel)]="value"
|
||||||
[disabled]="disabled"
|
[disabled]="disabled"
|
||||||
|
@@ -0,0 +1,11 @@
|
|||||||
|
.paperless-input-select.disabled {
|
||||||
|
cursor: not-allowed;
|
||||||
|
|
||||||
|
::ng-deep ng-select {
|
||||||
|
pointer-events: none;
|
||||||
|
|
||||||
|
.ng-select-container {
|
||||||
|
background-color: var(--pngx-bg-alt) !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@@ -1,6 +1,7 @@
|
|||||||
// styles for ng-select child are in styles.scss
|
// styles for ng-select child are in styles.scss
|
||||||
.paperless-input-select.disabled {
|
.paperless-input-select.disabled {
|
||||||
.input-group {
|
.input-group,
|
||||||
|
div > div {
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@@ -118,4 +118,18 @@ describe('SelectComponent', () => {
|
|||||||
tick(3000)
|
tick(3000)
|
||||||
expect(clearSpy).toHaveBeenCalled()
|
expect(clearSpy).toHaveBeenCalled()
|
||||||
}))
|
}))
|
||||||
|
|
||||||
|
it('should emit filtered documents', () => {
|
||||||
|
component.value = 10
|
||||||
|
component.items = items
|
||||||
|
const emitSpy = jest.spyOn(component.filterDocuments, 'emit')
|
||||||
|
component.onFilterDocuments()
|
||||||
|
expect(emitSpy).toHaveBeenCalledWith([items[2]])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should return the correct filter button title', () => {
|
||||||
|
component.title = 'Tag'
|
||||||
|
const expectedTitle = `Filter documents with this ${component.title}`
|
||||||
|
expect(component.filterButtonTitle).toEqual(expectedTitle)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
@@ -15,7 +15,7 @@
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
<div [ngClass]="{'col-md-9': horizontal, '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" (change)="onChange(value)" (blur)="onTouched()" [disabled]="disabled">
|
<input #inputField type="checkbox" class="form-check-input" [id]="inputId" [(ngModel)]="value" (change)="onChange(value)" (blur)="onTouched()" [disabled]="disabled">
|
||||||
@if (horizontal) {
|
@if (horizontal) {
|
||||||
|
@@ -169,4 +169,12 @@ describe('TagsComponent', () => {
|
|||||||
expect(component.getTag(2)).toEqual(tags[1])
|
expect(component.getTag(2)).toEqual(tags[1])
|
||||||
expect(component.getTag(4)).toBeUndefined()
|
expect(component.getTag(4)).toBeUndefined()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should emit filtered documents', () => {
|
||||||
|
component.value = [10]
|
||||||
|
component.tags = tags
|
||||||
|
const emitSpy = jest.spyOn(component.filterDocuments, 'emit')
|
||||||
|
component.onFilterDocuments()
|
||||||
|
expect(emitSpy).toHaveBeenCalledWith([tags[2]])
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
@@ -19,7 +19,7 @@
|
|||||||
}
|
}
|
||||||
</h3>
|
</h3>
|
||||||
</div>
|
</div>
|
||||||
<div class="btn-toolbar col col-md-auto">
|
<div class="btn-toolbar col col-md-auto gap-2">
|
||||||
<ng-content></ng-content>
|
<ng-content></ng-content>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
@@ -5,12 +5,15 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="modal-body">
|
<div class="modal-body">
|
||||||
|
|
||||||
@if (!object && message) {
|
|
||||||
<p class="mb-3" [innerHTML]="message | safeHtml"></p>
|
|
||||||
}
|
|
||||||
|
|
||||||
<form [formGroup]="form">
|
<form [formGroup]="form">
|
||||||
<pngx-permissions-form [users]="users" formControlName="permissions_form"></pngx-permissions-form>
|
<div class="form-group">
|
||||||
|
<pngx-permissions-form [users]="users" formControlName="permissions_form"></pngx-permissions-form>
|
||||||
|
</div>
|
||||||
|
<div class="form-group mt-4">
|
||||||
|
<div class="offset-lg-3 row">
|
||||||
|
<pngx-input-switch i18n-title title="Merge with existing permissions" [horizontal]="true" [hint]="hint" formControlName="merge"></pngx-input-switch>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
@@ -20,5 +23,5 @@
|
|||||||
<span class="visually-hidden" i18n>Loading...</span>
|
<span class="visually-hidden" i18n>Loading...</span>
|
||||||
}
|
}
|
||||||
<button type="button" class="btn btn-outline-primary" (click)="cancelClicked()" [disabled]="!buttonsEnabled" i18n>Cancel</button>
|
<button type="button" class="btn btn-outline-primary" (click)="cancelClicked()" [disabled]="!buttonsEnabled" i18n>Cancel</button>
|
||||||
<button type="button" class="btn btn-primary" (click)="confirmClicked.emit(permissions)" [disabled]="!buttonsEnabled" i18n>Confirm</button>
|
<button type="button" class="btn btn-primary" (click)="confirm()" [disabled]="!buttonsEnabled" i18n>Confirm</button>
|
||||||
</div>
|
</div>
|
||||||
|
@@ -11,6 +11,7 @@ import { NgSelectModule } from '@ng-select/ng-select'
|
|||||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||||
import { PermissionsUserComponent } from '../input/permissions/permissions-user/permissions-user.component'
|
import { PermissionsUserComponent } from '../input/permissions/permissions-user/permissions-user.component'
|
||||||
import { PermissionsGroupComponent } from '../input/permissions/permissions-group/permissions-group.component'
|
import { PermissionsGroupComponent } from '../input/permissions/permissions-group/permissions-group.component'
|
||||||
|
import { SwitchComponent } from '../input/switch/switch.component'
|
||||||
|
|
||||||
const set_permissions = {
|
const set_permissions = {
|
||||||
owner: 10,
|
owner: 10,
|
||||||
@@ -37,6 +38,7 @@ describe('PermissionsDialogComponent', () => {
|
|||||||
PermissionsDialogComponent,
|
PermissionsDialogComponent,
|
||||||
SafeHtmlPipe,
|
SafeHtmlPipe,
|
||||||
SelectComponent,
|
SelectComponent,
|
||||||
|
SwitchComponent,
|
||||||
PermissionsFormComponent,
|
PermissionsFormComponent,
|
||||||
PermissionsUserComponent,
|
PermissionsUserComponent,
|
||||||
PermissionsGroupComponent,
|
PermissionsGroupComponent,
|
||||||
@@ -112,4 +114,23 @@ describe('PermissionsDialogComponent', () => {
|
|||||||
expect(component.title).toEqual(`Edit permissions for ${obj.name}`)
|
expect(component.title).toEqual(`Edit permissions for ${obj.name}`)
|
||||||
expect(component.permissions).toEqual(set_permissions)
|
expect(component.permissions).toEqual(set_permissions)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should toggle hint based on object existence (if editing) or merge flag', () => {
|
||||||
|
component.form.get('merge').setValue(true)
|
||||||
|
expect(component.hint.includes('Existing')).toBeTruthy()
|
||||||
|
component.form.get('merge').setValue(false)
|
||||||
|
expect(component.hint.includes('will be replaced')).toBeTruthy()
|
||||||
|
component.object = {}
|
||||||
|
expect(component.hint).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should emit permissions and merge flag on confirm', () => {
|
||||||
|
const confirmSpy = jest.spyOn(component.confirmClicked, 'emit')
|
||||||
|
component.form.get('permissions_form').setValue(set_permissions)
|
||||||
|
component.confirm()
|
||||||
|
expect(confirmSpy).toHaveBeenCalledWith({
|
||||||
|
permissions: set_permissions,
|
||||||
|
merge: true,
|
||||||
|
})
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
@@ -32,6 +32,7 @@ export class PermissionsDialogComponent {
|
|||||||
this.o = o
|
this.o = o
|
||||||
this.title = $localize`Edit permissions for ` + o['name']
|
this.title = $localize`Edit permissions for ` + o['name']
|
||||||
this.form.patchValue({
|
this.form.patchValue({
|
||||||
|
merge: true,
|
||||||
permissions_form: {
|
permissions_form: {
|
||||||
owner: o.owner,
|
owner: o.owner,
|
||||||
set_permissions: o.permissions,
|
set_permissions: o.permissions,
|
||||||
@@ -43,8 +44,9 @@ export class PermissionsDialogComponent {
|
|||||||
return this.o
|
return this.o
|
||||||
}
|
}
|
||||||
|
|
||||||
form = new FormGroup({
|
public form = new FormGroup({
|
||||||
permissions_form: new FormControl(),
|
permissions_form: new FormControl(),
|
||||||
|
merge: new FormControl(true),
|
||||||
})
|
})
|
||||||
|
|
||||||
buttonsEnabled: boolean = true
|
buttonsEnabled: boolean = true
|
||||||
@@ -66,11 +68,21 @@ export class PermissionsDialogComponent {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Input()
|
get hint(): string {
|
||||||
message =
|
if (this.object) return null
|
||||||
$localize`Note that permissions set here will override any existing permissions`
|
return this.form.get('merge').value
|
||||||
|
? $localize`Existing owner, user and group permissions will be merged with these settings.`
|
||||||
|
: $localize`Any and all existing owner, user and group permissions will be replaced.`
|
||||||
|
}
|
||||||
|
|
||||||
cancelClicked() {
|
cancelClicked() {
|
||||||
this.activeModal.close()
|
this.activeModal.close()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
confirm() {
|
||||||
|
this.confirmClicked.emit({
|
||||||
|
permissions: this.permissions,
|
||||||
|
merge: this.form.get('merge').value,
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@@ -62,22 +62,24 @@
|
|||||||
<i-bs width="1em" height="1em" name="check"></i-bs>
|
<i-bs width="1em" height="1em" name="check"></i-bs>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<div class="me-1 w-100">
|
@if (permissionsService.currentUserCan(PermissionAction.View, PermissionType.User)) {
|
||||||
<ng-select
|
<div class="me-1 w-100">
|
||||||
name="user"
|
<ng-select
|
||||||
class="user-select small"
|
name="user"
|
||||||
[(ngModel)]="selectionModel.includeUsers"
|
class="user-select small"
|
||||||
[disabled]="disabled"
|
[(ngModel)]="selectionModel.includeUsers"
|
||||||
[clearable]="false"
|
[disabled]="disabled"
|
||||||
[items]="users"
|
[clearable]="false"
|
||||||
bindLabel="username"
|
[items]="users"
|
||||||
multiple="true"
|
bindLabel="username"
|
||||||
bindValue="id"
|
multiple="true"
|
||||||
placeholder="Users"
|
bindValue="id"
|
||||||
i18n-placeholder
|
placeholder="Users"
|
||||||
(change)="onUserSelect()">
|
i18n-placeholder
|
||||||
</ng-select>
|
(change)="onUserSelect()">
|
||||||
</div>
|
</ng-select>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
</button>
|
</button>
|
||||||
@if (selectionModel.ownerFilter === OwnerFilterType.NONE || selectionModel.ownerFilter === OwnerFilterType.NOT_SELF) {
|
@if (selectionModel.ownerFilter === OwnerFilterType.NONE || selectionModel.ownerFilter === OwnerFilterType.NOT_SELF) {
|
||||||
<div class="list-group-item list-group-item-action d-flex align-items-center p-2 ps-3 border-bottom-0 border-start-0 border-end-0">
|
<div class="list-group-item list-group-item-action d-flex align-items-center p-2 ps-3 border-bottom-0 border-start-0 border-end-0">
|
||||||
|
@@ -67,7 +67,7 @@ export class PermissionsFilterDropdownComponent extends ComponentWithPermissions
|
|||||||
}
|
}
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
permissionsService: PermissionsService,
|
public permissionsService: PermissionsService,
|
||||||
userService: UserService,
|
userService: UserService,
|
||||||
private settingsService: SettingsService
|
private settingsService: SettingsService
|
||||||
) {
|
) {
|
||||||
|
@@ -41,14 +41,58 @@
|
|||||||
}
|
}
|
||||||
<span class="visually-hidden" i18n>Copy</span>
|
<span class="visually-hidden" i18n>Copy</span>
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="btn btn-outline-secondary" (click)="generateAuthToken()" i18n-title title="Regenerate auth token">
|
<pngx-confirm-button
|
||||||
<i-bs width="1.2em" height="1.2em" name="arrow-repeat"></i-bs>
|
title="Regenerate auth token"
|
||||||
</button>
|
i18n-title
|
||||||
|
buttonClasses=" btn-outline-secondary"
|
||||||
|
iconName="arrow-repeat"
|
||||||
|
[disabled]="!hasUsablePassword"
|
||||||
|
(confirm)="generateAuthToken()">
|
||||||
|
</pngx-confirm-button>
|
||||||
</div>
|
</div>
|
||||||
<span class="badge copied-badge bg-primary small fade ms-4 position-absolute top-50 translate-middle-y pe-none z-3" [class.show]="copied" i18n>Copied!</span>
|
<span class="badge copied-badge bg-primary small fade ms-4 position-absolute top-50 translate-middle-y pe-none z-3" [class.show]="copied" i18n>Copied!</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-text text-muted text-end fst-italic" i18n>Warning: changing the token cannot be undone</div>
|
<div class="form-text text-muted text-end fst-italic" i18n>Warning: changing the token cannot be undone</div>
|
||||||
</div>
|
</div>
|
||||||
|
@if (socialAccounts?.length > 0) {
|
||||||
|
<div class="mb-3">
|
||||||
|
<p i18n>Connected social accounts</p>
|
||||||
|
<ul class="list-group">
|
||||||
|
@for (account of socialAccounts; track account.id) {
|
||||||
|
<li class="list-group-item"
|
||||||
|
ngbPopover="Set a password before disconnecting social account."
|
||||||
|
i18n-ngbPopover
|
||||||
|
[disablePopover]="hasUsablePassword"
|
||||||
|
triggers="mouseenter:mouseleave">
|
||||||
|
{{account.name}} ({{account.provider}})
|
||||||
|
<pngx-confirm-button
|
||||||
|
label="Disconnect"
|
||||||
|
i18n-label
|
||||||
|
title="Disconnect {{ account.name }} social account"
|
||||||
|
i18n-title
|
||||||
|
buttonClasses="btn-outline-danger btn-sm ms-2 align-baseline"
|
||||||
|
iconName="trash"
|
||||||
|
[disabled]="!hasUsablePassword"
|
||||||
|
(confirm)="disconnectSocialAccount(account.id)">
|
||||||
|
</pngx-confirm-button>
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
<div class="form-text text-muted text-end fst-italic" i18n>Warning: disconnecting social accounts cannot be undone</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
@if (socialAccountProviders?.length > 0) {
|
||||||
|
<div class="mb-3">
|
||||||
|
<p i18n>Connect new social account</p>
|
||||||
|
<div class="list-group">
|
||||||
|
@for (provider of socialAccountProviders; track provider.name) {
|
||||||
|
<a class="list-group-item list-group-item-action text-primary d-flex align-items-center" href="{{ provider.login_url }}" rel="noopener noreferrer">
|
||||||
|
{{provider.name}} <i-bs class="pb-1 ps-1" name="box-arrow-up-right"></i-bs>
|
||||||
|
</a>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<button type="button" class="btn btn-outline-secondary" (click)="cancel()" i18n [disabled]="networkActive">Cancel</button>
|
<button type="button" class="btn btn-outline-secondary" (click)="cancel()" i18n [disabled]="networkActive">Cancel</button>
|
||||||
|
@@ -12,6 +12,7 @@ import {
|
|||||||
NgbAccordionModule,
|
NgbAccordionModule,
|
||||||
NgbActiveModal,
|
NgbActiveModal,
|
||||||
NgbModalModule,
|
NgbModalModule,
|
||||||
|
NgbPopoverModule,
|
||||||
} from '@ng-bootstrap/ng-bootstrap'
|
} from '@ng-bootstrap/ng-bootstrap'
|
||||||
import { HttpClientModule } from '@angular/common/http'
|
import { HttpClientModule } from '@angular/common/http'
|
||||||
import { TextComponent } from '../input/text/text.component'
|
import { TextComponent } from '../input/text/text.component'
|
||||||
@@ -20,14 +21,24 @@ import { of, throwError } from 'rxjs'
|
|||||||
import { ToastService } from 'src/app/services/toast.service'
|
import { ToastService } from 'src/app/services/toast.service'
|
||||||
import { Clipboard } from '@angular/cdk/clipboard'
|
import { Clipboard } from '@angular/cdk/clipboard'
|
||||||
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
||||||
|
import { ConfirmButtonComponent } from '../confirm-button/confirm-button.component'
|
||||||
|
|
||||||
|
const socialAccount = {
|
||||||
|
id: 1,
|
||||||
|
provider: 'test_provider',
|
||||||
|
name: 'Test Provider',
|
||||||
|
}
|
||||||
const profile = {
|
const profile = {
|
||||||
email: 'foo@bar.com',
|
email: 'foo@bar.com',
|
||||||
password: '*********',
|
password: '*********',
|
||||||
first_name: 'foo',
|
first_name: 'foo',
|
||||||
last_name: 'bar',
|
last_name: 'bar',
|
||||||
auth_token: '123456789abcdef',
|
auth_token: '123456789abcdef',
|
||||||
|
social_accounts: [socialAccount],
|
||||||
}
|
}
|
||||||
|
const socialAccountProviders = [
|
||||||
|
{ name: 'Test Provider', login_url: 'https://example.com' },
|
||||||
|
]
|
||||||
|
|
||||||
describe('ProfileEditDialogComponent', () => {
|
describe('ProfileEditDialogComponent', () => {
|
||||||
let component: ProfileEditDialogComponent
|
let component: ProfileEditDialogComponent
|
||||||
@@ -42,6 +53,7 @@ describe('ProfileEditDialogComponent', () => {
|
|||||||
ProfileEditDialogComponent,
|
ProfileEditDialogComponent,
|
||||||
TextComponent,
|
TextComponent,
|
||||||
PasswordComponent,
|
PasswordComponent,
|
||||||
|
ConfirmButtonComponent,
|
||||||
],
|
],
|
||||||
providers: [NgbActiveModal],
|
providers: [NgbActiveModal],
|
||||||
imports: [
|
imports: [
|
||||||
@@ -51,6 +63,7 @@ describe('ProfileEditDialogComponent', () => {
|
|||||||
NgbModalModule,
|
NgbModalModule,
|
||||||
NgbAccordionModule,
|
NgbAccordionModule,
|
||||||
NgxBootstrapIconsModule.pick(allIcons),
|
NgxBootstrapIconsModule.pick(allIcons),
|
||||||
|
NgbPopoverModule,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
profileService = TestBed.inject(ProfileService)
|
profileService = TestBed.inject(ProfileService)
|
||||||
@@ -64,6 +77,11 @@ describe('ProfileEditDialogComponent', () => {
|
|||||||
it('should get profile on init, display in form', () => {
|
it('should get profile on init, display in form', () => {
|
||||||
const getSpy = jest.spyOn(profileService, 'get')
|
const getSpy = jest.spyOn(profileService, 'get')
|
||||||
getSpy.mockReturnValue(of(profile))
|
getSpy.mockReturnValue(of(profile))
|
||||||
|
const getProvidersSpy = jest.spyOn(
|
||||||
|
profileService,
|
||||||
|
'getSocialAccountProviders'
|
||||||
|
)
|
||||||
|
getProvidersSpy.mockReturnValue(of(socialAccountProviders))
|
||||||
component.ngOnInit()
|
component.ngOnInit()
|
||||||
expect(getSpy).toHaveBeenCalled()
|
expect(getSpy).toHaveBeenCalled()
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
@@ -103,6 +121,11 @@ describe('ProfileEditDialogComponent', () => {
|
|||||||
expect(component.form.get('email_confirm').enabled).toBeFalsy()
|
expect(component.form.get('email_confirm').enabled).toBeFalsy()
|
||||||
const getSpy = jest.spyOn(profileService, 'get')
|
const getSpy = jest.spyOn(profileService, 'get')
|
||||||
getSpy.mockReturnValue(of(profile))
|
getSpy.mockReturnValue(of(profile))
|
||||||
|
const getProvidersSpy = jest.spyOn(
|
||||||
|
profileService,
|
||||||
|
'getSocialAccountProviders'
|
||||||
|
)
|
||||||
|
getProvidersSpy.mockReturnValue(of(socialAccountProviders))
|
||||||
component.ngOnInit()
|
component.ngOnInit()
|
||||||
component.form.get('email').patchValue('foo@bar2.com')
|
component.form.get('email').patchValue('foo@bar2.com')
|
||||||
component.onEmailKeyUp({ target: { value: 'foo@bar2.com' } } as any)
|
component.onEmailKeyUp({ target: { value: 'foo@bar2.com' } } as any)
|
||||||
@@ -134,6 +157,12 @@ describe('ProfileEditDialogComponent', () => {
|
|||||||
expect(component.form.get('password_confirm').enabled).toBeFalsy()
|
expect(component.form.get('password_confirm').enabled).toBeFalsy()
|
||||||
const getSpy = jest.spyOn(profileService, 'get')
|
const getSpy = jest.spyOn(profileService, 'get')
|
||||||
getSpy.mockReturnValue(of(profile))
|
getSpy.mockReturnValue(of(profile))
|
||||||
|
const getProvidersSpy = jest.spyOn(
|
||||||
|
profileService,
|
||||||
|
'getSocialAccountProviders'
|
||||||
|
)
|
||||||
|
getProvidersSpy.mockReturnValue(of(socialAccountProviders))
|
||||||
|
component.hasUsablePassword = true
|
||||||
component.ngOnInit()
|
component.ngOnInit()
|
||||||
component.form.get('password').patchValue('new*pass')
|
component.form.get('password').patchValue('new*pass')
|
||||||
component.onPasswordKeyUp({
|
component.onPasswordKeyUp({
|
||||||
@@ -167,6 +196,11 @@ describe('ProfileEditDialogComponent', () => {
|
|||||||
it('should logout on save if password changed', fakeAsync(() => {
|
it('should logout on save if password changed', fakeAsync(() => {
|
||||||
const getSpy = jest.spyOn(profileService, 'get')
|
const getSpy = jest.spyOn(profileService, 'get')
|
||||||
getSpy.mockReturnValue(of(profile))
|
getSpy.mockReturnValue(of(profile))
|
||||||
|
const getProvidersSpy = jest.spyOn(
|
||||||
|
profileService,
|
||||||
|
'getSocialAccountProviders'
|
||||||
|
)
|
||||||
|
getProvidersSpy.mockReturnValue(of(socialAccountProviders))
|
||||||
component.ngOnInit()
|
component.ngOnInit()
|
||||||
component['newPassword'] = 'new*pass'
|
component['newPassword'] = 'new*pass'
|
||||||
component.form.get('password').patchValue('new*pass')
|
component.form.get('password').patchValue('new*pass')
|
||||||
@@ -189,6 +223,11 @@ describe('ProfileEditDialogComponent', () => {
|
|||||||
it('should support auth token copy', fakeAsync(() => {
|
it('should support auth token copy', fakeAsync(() => {
|
||||||
const getSpy = jest.spyOn(profileService, 'get')
|
const getSpy = jest.spyOn(profileService, 'get')
|
||||||
getSpy.mockReturnValue(of(profile))
|
getSpy.mockReturnValue(of(profile))
|
||||||
|
const getProvidersSpy = jest.spyOn(
|
||||||
|
profileService,
|
||||||
|
'getSocialAccountProviders'
|
||||||
|
)
|
||||||
|
getProvidersSpy.mockReturnValue(of(socialAccountProviders))
|
||||||
component.ngOnInit()
|
component.ngOnInit()
|
||||||
const copySpy = jest.spyOn(clipboard, 'copy')
|
const copySpy = jest.spyOn(clipboard, 'copy')
|
||||||
component.copyAuthToken()
|
component.copyAuthToken()
|
||||||
@@ -220,4 +259,40 @@ describe('ProfileEditDialogComponent', () => {
|
|||||||
)
|
)
|
||||||
expect(component.form.get('auth_token').value).toEqual(newToken)
|
expect(component.form.get('auth_token').value).toEqual(newToken)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should get social account providers on init', () => {
|
||||||
|
const getSpy = jest.spyOn(profileService, 'get')
|
||||||
|
getSpy.mockReturnValue(of(profile))
|
||||||
|
const getProvidersSpy = jest.spyOn(
|
||||||
|
profileService,
|
||||||
|
'getSocialAccountProviders'
|
||||||
|
)
|
||||||
|
getProvidersSpy.mockReturnValue(of(socialAccountProviders))
|
||||||
|
component.ngOnInit()
|
||||||
|
expect(getProvidersSpy).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should remove disconnected social account from component, show error if needed', () => {
|
||||||
|
const disconnectSpy = jest.spyOn(profileService, 'disconnectSocialAccount')
|
||||||
|
const getSpy = jest.spyOn(profileService, 'get')
|
||||||
|
getSpy.mockImplementation(() => of(profile))
|
||||||
|
component.ngOnInit()
|
||||||
|
|
||||||
|
const errorSpy = jest.spyOn(toastService, 'showError')
|
||||||
|
|
||||||
|
expect(component.socialAccounts).toContainEqual(socialAccount)
|
||||||
|
|
||||||
|
// fail first
|
||||||
|
disconnectSpy.mockReturnValueOnce(
|
||||||
|
throwError(() => new Error('unable to disconnect'))
|
||||||
|
)
|
||||||
|
component.disconnectSocialAccount(socialAccount.id)
|
||||||
|
expect(errorSpy).toHaveBeenCalled()
|
||||||
|
|
||||||
|
// succeed
|
||||||
|
disconnectSpy.mockReturnValue(of(socialAccount.id))
|
||||||
|
component.disconnectSocialAccount(socialAccount.id)
|
||||||
|
expect(disconnectSpy).toHaveBeenCalled()
|
||||||
|
expect(component.socialAccounts).not.toContainEqual(socialAccount)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
@@ -2,6 +2,7 @@ import { Component, OnDestroy, OnInit } from '@angular/core'
|
|||||||
import { FormControl, FormGroup } from '@angular/forms'
|
import { FormControl, FormGroup } from '@angular/forms'
|
||||||
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
|
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
|
||||||
import { ProfileService } from 'src/app/services/profile.service'
|
import { ProfileService } from 'src/app/services/profile.service'
|
||||||
|
import { SocialAccount, SocialAccountProvider } from 'src/app/data/user-profile'
|
||||||
import { ToastService } from 'src/app/services/toast.service'
|
import { ToastService } from 'src/app/services/toast.service'
|
||||||
import { Subject, takeUntil } from 'rxjs'
|
import { Subject, takeUntil } from 'rxjs'
|
||||||
import { Clipboard } from '@angular/cdk/clipboard'
|
import { Clipboard } from '@angular/cdk/clipboard'
|
||||||
@@ -30,6 +31,7 @@ export class ProfileEditDialogComponent implements OnInit, OnDestroy {
|
|||||||
private newPassword: string
|
private newPassword: string
|
||||||
private passwordConfirm: string
|
private passwordConfirm: string
|
||||||
public showPasswordConfirm: boolean = false
|
public showPasswordConfirm: boolean = false
|
||||||
|
public hasUsablePassword: boolean = false
|
||||||
|
|
||||||
private currentEmail: string
|
private currentEmail: string
|
||||||
private newEmail: string
|
private newEmail: string
|
||||||
@@ -38,6 +40,9 @@ export class ProfileEditDialogComponent implements OnInit, OnDestroy {
|
|||||||
|
|
||||||
public copied: boolean = false
|
public copied: boolean = false
|
||||||
|
|
||||||
|
public socialAccounts: SocialAccount[] = []
|
||||||
|
public socialAccountProviders: SocialAccountProvider[] = []
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private profileService: ProfileService,
|
private profileService: ProfileService,
|
||||||
public activeModal: NgbActiveModal,
|
public activeModal: NgbActiveModal,
|
||||||
@@ -59,10 +64,19 @@ export class ProfileEditDialogComponent implements OnInit, OnDestroy {
|
|||||||
this.onEmailChange()
|
this.onEmailChange()
|
||||||
})
|
})
|
||||||
this.currentPassword = profile.password
|
this.currentPassword = profile.password
|
||||||
|
this.hasUsablePassword = profile.has_usable_password
|
||||||
this.form.get('password').valueChanges.subscribe((newPassword) => {
|
this.form.get('password').valueChanges.subscribe((newPassword) => {
|
||||||
this.newPassword = newPassword
|
this.newPassword = newPassword
|
||||||
this.onPasswordChange()
|
this.onPasswordChange()
|
||||||
})
|
})
|
||||||
|
this.socialAccounts = profile.social_accounts
|
||||||
|
})
|
||||||
|
|
||||||
|
this.profileService
|
||||||
|
.getSocialAccountProviders()
|
||||||
|
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||||
|
.subscribe((providers) => {
|
||||||
|
this.socialAccountProviders = providers
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -182,4 +196,21 @@ export class ProfileEditDialogComponent implements OnInit, OnDestroy {
|
|||||||
this.copied = false
|
this.copied = false
|
||||||
}, 3000)
|
}, 3000)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
disconnectSocialAccount(id: number): void {
|
||||||
|
this.profileService
|
||||||
|
.disconnectSocialAccount(id)
|
||||||
|
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||||
|
.subscribe({
|
||||||
|
next: (id: number) => {
|
||||||
|
this.socialAccounts = this.socialAccounts.filter((a) => a.id != id)
|
||||||
|
},
|
||||||
|
error: (error) => {
|
||||||
|
this.toastService.showError(
|
||||||
|
$localize`Error disconnecting social account`,
|
||||||
|
error
|
||||||
|
)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@@ -0,0 +1,158 @@
|
|||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title" id="modal-basic-title" i18n>System Status</h5>
|
||||||
|
<button type="button" class="btn-close" aria-label="Close" (click)="close()"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
@if (!status) {
|
||||||
|
<div class="w-100 h-100 d-flex align-items-center justify-content-center">
|
||||||
|
<div>
|
||||||
|
<div class="spinner-border spinner-border-sm me-2" role="status"></div>
|
||||||
|
<ng-container i18n>Loading...</ng-container>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<div class="row row-cols-1 row-cols-md-3 g-3">
|
||||||
|
<div class="col">
|
||||||
|
<div class="card bg-light h-100">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="card-title mb-0" i18n>Environment</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<dl class="card-text">
|
||||||
|
<dt i18n>Paperless-ngx Version</dt>
|
||||||
|
<dd>{{status.pngx_version}}</dd>
|
||||||
|
<dt i18n>Install Type</dt>
|
||||||
|
<dd>{{status.install_type}}</dd>
|
||||||
|
<dt i18n>Server OS</dt>
|
||||||
|
<dd>{{status.server_os}}</dd>
|
||||||
|
<dt i18n>Media Storage</dt>
|
||||||
|
<dd>
|
||||||
|
<ngb-progressbar style="height: 4px;" class="mt-2 mb-1" type="primary" [max]="status.storage.total" [value]="status.storage.total - status.storage.available"></ngb-progressbar>
|
||||||
|
<span class="small">{{status.storage.available | filesize}} <ng-container i18n>available</ng-container> ({{status.storage.total | filesize}} <ng-container i18n>total</ng-container>)</span>
|
||||||
|
</dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col">
|
||||||
|
<div class="card bg-light h-100">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="card-title mb-0" i18n>Database</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<dl class="card-text">
|
||||||
|
<dt i18n>Type</dt>
|
||||||
|
<dd>{{status.database.type}}</dd>
|
||||||
|
<dt i18n>Status</dt>
|
||||||
|
<dd class="d-flex align-items-center">
|
||||||
|
{{status.database.status}}
|
||||||
|
@if (status.database.status === 'OK') {
|
||||||
|
<i-bs name="check-circle-fill" class="text-primary ms-2 lh-1" ngbPopover="{{status.database.url}}" triggers="mouseenter:mouseleave"></i-bs>
|
||||||
|
} @else {
|
||||||
|
<i-bs name="exclamation-triangle-fill" class="text-danger ms-2 lh-1" ngbPopover="{{status.database.url}}: {{status.database.error}}" triggers="mouseenter:mouseleave"></i-bs>
|
||||||
|
}
|
||||||
|
</dd>
|
||||||
|
<dt i18n>Migration Status</dt>
|
||||||
|
<dd class="d-flex align-items-center">
|
||||||
|
@if (status.database.migration_status.unapplied_migrations.length === 0) {
|
||||||
|
<ng-container>Up to date</ng-container><i-bs name="check-circle-fill" class="text-primary ms-2 lh-1" [ngbPopover]="migrationStatus" triggers="mouseenter:mouseleave"></i-bs>
|
||||||
|
} @else {
|
||||||
|
<ng-container>{{status.database.migration_status.unapplied_migrations.length}} Pending</ng-container><i-bs name="exclamation-triangle-fill" class="text-warning ms-2 lh-1" [ngbPopover]="migrationStatus" triggers="mouseenter:mouseleave"></i-bs>
|
||||||
|
}
|
||||||
|
<ng-template #migrationStatus>
|
||||||
|
<h6><ng-container i18n>Latest Migration</ng-container>:</h6> <span class="font-monospace small">{{status.database.migration_status.latest_migration}}</span>
|
||||||
|
@if (status.database.migration_status.unapplied_migrations.length > 0) {
|
||||||
|
<h6 class="mt-3"><ng-container i18n>Pending Migrations</ng-container>:</h6>
|
||||||
|
<ul>
|
||||||
|
@for (migration of status.database.migration_status.unapplied_migrations; track migration) {
|
||||||
|
<li class="font-monospace small">{{migration}}</li>
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
}
|
||||||
|
</ng-template>
|
||||||
|
</dd>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="col">
|
||||||
|
<div class="card bg-light h-100">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5 class="card-title mb-0" i18n>Tasks</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<dl class="card-text">
|
||||||
|
<dt i18n>Redis Status</dt>
|
||||||
|
<dd class="d-flex align-items-center">
|
||||||
|
{{status.tasks.redis_status}}
|
||||||
|
@if (status.tasks.redis_status === 'OK') {
|
||||||
|
<i-bs name="check-circle-fill" class="text-primary ms-2 lh-1" ngbPopover="{{status.tasks.redis_url}}" triggers="mouseenter:mouseleave"></i-bs>
|
||||||
|
} @else {
|
||||||
|
<i-bs name="exclamation-triangle-fill" class="text-danger ms-2 lh-1" ngbPopover="{{status.tasks.redis_url}}: {{status.tasks.redis_error}}" triggers="mouseenter:mouseleave"></i-bs>
|
||||||
|
}
|
||||||
|
</dd>
|
||||||
|
<dt i18n>Celery Status</dt>
|
||||||
|
<dd class="d-flex align-items-center">
|
||||||
|
{{status.tasks.celery_status}}
|
||||||
|
@if (status.tasks.celery_status === 'OK') {
|
||||||
|
<i-bs name="check-circle-fill" class="text-primary ms-2 lh-1"></i-bs>
|
||||||
|
} @else {
|
||||||
|
<i-bs name="exclamation-triangle-fill" class="text-danger ms-2 lh-1"></i-bs>
|
||||||
|
}
|
||||||
|
</dd>
|
||||||
|
<dt i18n>Search Index</dt>
|
||||||
|
<dd class="d-flex align-items-center">
|
||||||
|
{{status.tasks.index_status}}
|
||||||
|
@if (status.tasks.index_status === 'OK') {
|
||||||
|
@if (isStale(status.tasks.index_last_modified)) {
|
||||||
|
<i-bs name="exclamation-triangle-fill" class="text-warning ms-2 lh-1" [ngbPopover]="indexStatus" triggers="mouseenter:mouseleave"></i-bs>
|
||||||
|
} @else {
|
||||||
|
<i-bs name="check-circle-fill" class="text-primary ms-2 lh-1" [ngbPopover]="indexStatus" triggers="mouseenter:mouseleave"></i-bs>
|
||||||
|
}
|
||||||
|
} @else {
|
||||||
|
<i-bs name="exclamation-triangle-fill" class="text-danger ms-2 lh-1" ngbPopover="{{status.tasks.index_error}}" triggers="mouseenter:mouseleave"></i-bs>
|
||||||
|
}
|
||||||
|
</dd>
|
||||||
|
<ng-template #indexStatus>
|
||||||
|
<h6><ng-container i18n>Last Updated</ng-container>:</h6> <span class="font-monospace small">{{status.tasks.index_last_modified | customDate:'medium'}}</span>
|
||||||
|
</ng-template>
|
||||||
|
<dt i18n>Classifier</dt>
|
||||||
|
<dd class="d-flex align-items-center">
|
||||||
|
{{status.tasks.classifier_status}}
|
||||||
|
@if (status.tasks.classifier_status === 'OK') {
|
||||||
|
@if (isStale(status.tasks.classifier_last_trained)) {
|
||||||
|
<i-bs name="exclamation-triangle-fill" class="text-warning ms-2 lh-1" [ngbPopover]="classifierStatus" triggers="mouseenter:mouseleave"></i-bs>
|
||||||
|
} @else {
|
||||||
|
<i-bs name="check-circle-fill" class="text-primary ms-2 lh-1" [ngbPopover]="classifierStatus" triggers="mouseenter:mouseleave"></i-bs>
|
||||||
|
}
|
||||||
|
} @else {
|
||||||
|
<i-bs name="exclamation-triangle-fill" class="ms-2 lh-1"
|
||||||
|
[class.text-danger]="status.tasks.classifier_status === SystemStatusItemStatus.ERROR"
|
||||||
|
[class.text-warning]="status.tasks.classifier_status === SystemStatusItemStatus.WARNING"
|
||||||
|
ngbPopover="{{status.tasks.classifier_error}}"
|
||||||
|
triggers="mouseenter:mouseleave"></i-bs>
|
||||||
|
}
|
||||||
|
</dd>
|
||||||
|
<ng-template #classifierStatus>
|
||||||
|
<h6><ng-container i18n>Last Trained</ng-container>:</h6> <span class="font-monospace small">{{status.tasks.classifier_last_trained | customDate:'medium'}}</span>
|
||||||
|
</ng-template>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button class="btn btn-sm btn-outline-secondary" (click)="copy()">
|
||||||
|
@if (!copied) {
|
||||||
|
<i-bs name="clipboard-fill"></i-bs>
|
||||||
|
}
|
||||||
|
@if (copied) {
|
||||||
|
<i-bs name="clipboard-check-fill"></i-bs>
|
||||||
|
}
|
||||||
|
<ng-container i18n>Copy</ng-container>
|
||||||
|
</button>
|
||||||
|
</div>
|
@@ -0,0 +1,103 @@
|
|||||||
|
import {
|
||||||
|
ComponentFixture,
|
||||||
|
TestBed,
|
||||||
|
fakeAsync,
|
||||||
|
tick,
|
||||||
|
} from '@angular/core/testing'
|
||||||
|
import {
|
||||||
|
NgbActiveModal,
|
||||||
|
NgbModalModule,
|
||||||
|
NgbPopoverModule,
|
||||||
|
NgbProgressbarModule,
|
||||||
|
} from '@ng-bootstrap/ng-bootstrap'
|
||||||
|
import { Clipboard, ClipboardModule } from '@angular/cdk/clipboard'
|
||||||
|
import { SystemStatusDialogComponent } from './system-status-dialog.component'
|
||||||
|
import {
|
||||||
|
SystemStatusItemStatus,
|
||||||
|
InstallType,
|
||||||
|
SystemStatus,
|
||||||
|
} from 'src/app/data/system-status'
|
||||||
|
import { HttpClientTestingModule } from '@angular/common/http/testing'
|
||||||
|
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
||||||
|
import { NgxFilesizeModule } from 'ngx-filesize'
|
||||||
|
|
||||||
|
const status: SystemStatus = {
|
||||||
|
pngx_version: '2.4.3',
|
||||||
|
server_os: 'macOS-14.1.1-arm64-arm-64bit',
|
||||||
|
install_type: InstallType.BareMetal,
|
||||||
|
storage: { total: 494384795648, available: 13573525504 },
|
||||||
|
database: {
|
||||||
|
type: 'sqlite',
|
||||||
|
url: '/paperless-ngx/data/db.sqlite3',
|
||||||
|
status: SystemStatusItemStatus.ERROR,
|
||||||
|
error: null,
|
||||||
|
migration_status: {
|
||||||
|
latest_migration: 'socialaccount.0006_alter_socialaccount_extra_data',
|
||||||
|
unapplied_migrations: [],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
tasks: {
|
||||||
|
redis_url: 'redis://localhost:6379',
|
||||||
|
redis_status: SystemStatusItemStatus.ERROR,
|
||||||
|
redis_error: 'Error 61 connecting to localhost:6379. Connection refused.',
|
||||||
|
celery_status: SystemStatusItemStatus.ERROR,
|
||||||
|
index_status: SystemStatusItemStatus.OK,
|
||||||
|
index_last_modified: new Date().toISOString(),
|
||||||
|
index_error: null,
|
||||||
|
classifier_status: SystemStatusItemStatus.OK,
|
||||||
|
classifier_last_trained: new Date().toISOString(),
|
||||||
|
classifier_error: null,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('SystemStatusDialogComponent', () => {
|
||||||
|
let component: SystemStatusDialogComponent
|
||||||
|
let fixture: ComponentFixture<SystemStatusDialogComponent>
|
||||||
|
let clipboard: Clipboard
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
declarations: [SystemStatusDialogComponent],
|
||||||
|
providers: [NgbActiveModal],
|
||||||
|
imports: [
|
||||||
|
NgbModalModule,
|
||||||
|
ClipboardModule,
|
||||||
|
HttpClientTestingModule,
|
||||||
|
NgxBootstrapIconsModule.pick(allIcons),
|
||||||
|
NgxFilesizeModule,
|
||||||
|
NgbPopoverModule,
|
||||||
|
NgbProgressbarModule,
|
||||||
|
],
|
||||||
|
}).compileComponents()
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(SystemStatusDialogComponent)
|
||||||
|
component = fixture.componentInstance
|
||||||
|
component.status = status
|
||||||
|
clipboard = TestBed.inject(Clipboard)
|
||||||
|
fixture.detectChanges()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should close the active modal', () => {
|
||||||
|
const closeSpy = jest.spyOn(component.activeModal, 'close')
|
||||||
|
component.close()
|
||||||
|
expect(closeSpy).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should copy the system status to clipboard', fakeAsync(() => {
|
||||||
|
jest.spyOn(clipboard, 'copy')
|
||||||
|
component.copy()
|
||||||
|
expect(clipboard.copy).toHaveBeenCalledWith(
|
||||||
|
JSON.stringify(component.status)
|
||||||
|
)
|
||||||
|
expect(component.copied).toBeTruthy()
|
||||||
|
tick(3000)
|
||||||
|
expect(component.copied).toBeFalsy()
|
||||||
|
}))
|
||||||
|
|
||||||
|
it('should calculate if date is stale', () => {
|
||||||
|
const date = new Date()
|
||||||
|
date.setHours(date.getHours() - 25)
|
||||||
|
expect(component.isStale(date.toISOString())).toBeTruthy()
|
||||||
|
expect(component.isStale(date.toISOString(), 26)).toBeFalsy()
|
||||||
|
})
|
||||||
|
})
|
@@ -0,0 +1,43 @@
|
|||||||
|
import { Component, Input } from '@angular/core'
|
||||||
|
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
|
||||||
|
import {
|
||||||
|
SystemStatus,
|
||||||
|
SystemStatusItemStatus,
|
||||||
|
} from 'src/app/data/system-status'
|
||||||
|
import { SystemStatusService } from 'src/app/services/system-status.service'
|
||||||
|
import { Clipboard } from '@angular/cdk/clipboard'
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'pngx-system-status-dialog',
|
||||||
|
templateUrl: './system-status-dialog.component.html',
|
||||||
|
styleUrl: './system-status-dialog.component.scss',
|
||||||
|
})
|
||||||
|
export class SystemStatusDialogComponent {
|
||||||
|
public SystemStatusItemStatus = SystemStatusItemStatus
|
||||||
|
public status: SystemStatus
|
||||||
|
|
||||||
|
public copied: boolean = false
|
||||||
|
|
||||||
|
constructor(
|
||||||
|
public activeModal: NgbActiveModal,
|
||||||
|
private clipboard: Clipboard
|
||||||
|
) {}
|
||||||
|
|
||||||
|
public close() {
|
||||||
|
this.activeModal.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
public copy() {
|
||||||
|
this.clipboard.copy(JSON.stringify(this.status))
|
||||||
|
this.copied = true
|
||||||
|
setTimeout(() => {
|
||||||
|
this.copied = false
|
||||||
|
}, 3000)
|
||||||
|
}
|
||||||
|
|
||||||
|
public isStale(dateStr: string, hours: number = 24): boolean {
|
||||||
|
const date = new Date(dateStr)
|
||||||
|
const now = new Date()
|
||||||
|
return now.getTime() - date.getTime() > hours * 60 * 60 * 1000
|
||||||
|
}
|
||||||
|
}
|
@@ -15,8 +15,14 @@
|
|||||||
<tr>
|
<tr>
|
||||||
<th scope="col" i18n>Created</th>
|
<th scope="col" i18n>Created</th>
|
||||||
<th scope="col" i18n>Title</th>
|
<th scope="col" i18n>Title</th>
|
||||||
<th scope="col" class="d-none d-md-table-cell" i18n>Tags</th>
|
@if (permissionsService.currentUserCan(PermissionAction.View, PermissionType.Tag)) {
|
||||||
<th scope="col" class="d-none d-md-table-cell" i18n>Correspondent</th>
|
<th scope="col" class="d-none d-md-table-cell" i18n>Tags</th>
|
||||||
|
}
|
||||||
|
@if (permissionsService.currentUserCan(PermissionAction.View, PermissionType.Correspondent)) {
|
||||||
|
<th scope="col" class="d-none d-md-table-cell" i18n>Correspondent</th>
|
||||||
|
} @else {
|
||||||
|
<th scope="col" class="d-none d-md-table-cell"></th>
|
||||||
|
}
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
@@ -26,13 +32,15 @@
|
|||||||
<td class="py-2 py-md-3">
|
<td class="py-2 py-md-3">
|
||||||
<a routerLink="/documents/{{doc.id}}" title="Edit" i18n-title class="btn-link text-dark text-decoration-none py-2 py-md-3">{{doc.title | documentTitle}}</a>
|
<a routerLink="/documents/{{doc.id}}" title="Edit" i18n-title class="btn-link text-dark text-decoration-none py-2 py-md-3">{{doc.title | documentTitle}}</a>
|
||||||
</td>
|
</td>
|
||||||
<td class="py-2 py-md-3 d-none d-md-table-cell">
|
@if (permissionsService.currentUserCan(PermissionAction.View, PermissionType.Tag)) {
|
||||||
@for (t of doc.tags$ | async; track t) {
|
<td class="py-2 py-md-3 d-none d-md-table-cell">
|
||||||
<pngx-tag [tag]="t" class="ms-1" (click)="clickTag(t, $event)"></pngx-tag>
|
@for (t of doc.tags$ | async; track t) {
|
||||||
}
|
<pngx-tag [tag]="t" class="ms-1" (click)="clickTag(t, $event)"></pngx-tag>
|
||||||
</td>
|
}
|
||||||
|
</td>
|
||||||
|
}
|
||||||
<td class="position-relative py-2 py-md-3 d-none d-md-table-cell">
|
<td class="position-relative py-2 py-md-3 d-none d-md-table-cell">
|
||||||
@if (doc.correspondent !== null) {
|
@if (permissionsService.currentUserCan(PermissionAction.View, PermissionType.Correspondent) && doc.correspondent !== null) {
|
||||||
<a class="btn-link text-dark text-decoration-none py-2 py-md-3" routerLink="/documents" [queryParams]="getCorrespondentQueryParams(doc.correspondent)">{{(doc.correspondent$ | async)?.name}}</a>
|
<a class="btn-link text-dark text-decoration-none py-2 py-md-3" routerLink="/documents" [queryParams]="getCorrespondentQueryParams(doc.correspondent)">{{(doc.correspondent$ | async)?.name}}</a>
|
||||||
}
|
}
|
||||||
<div class="btn-group position-absolute top-50 end-0 translate-middle-y">
|
<div class="btn-group position-absolute top-50 end-0 translate-middle-y">
|
||||||
|
@@ -22,6 +22,7 @@ import { DocumentListViewService } from 'src/app/services/document-list-view.ser
|
|||||||
import { ComponentWithPermissions } from 'src/app/components/with-permissions/with-permissions.component'
|
import { ComponentWithPermissions } from 'src/app/components/with-permissions/with-permissions.component'
|
||||||
import { NgbPopover } from '@ng-bootstrap/ng-bootstrap'
|
import { NgbPopover } from '@ng-bootstrap/ng-bootstrap'
|
||||||
import { queryParamsFromFilterRules } from 'src/app/utils/query-params'
|
import { queryParamsFromFilterRules } from 'src/app/utils/query-params'
|
||||||
|
import { PermissionsService } from 'src/app/services/permissions.service'
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'pngx-saved-view-widget',
|
selector: 'pngx-saved-view-widget',
|
||||||
@@ -40,7 +41,8 @@ export class SavedViewWidgetComponent
|
|||||||
private list: DocumentListViewService,
|
private list: DocumentListViewService,
|
||||||
private consumerStatusService: ConsumerStatusService,
|
private consumerStatusService: ConsumerStatusService,
|
||||||
public openDocumentsService: OpenDocumentsService,
|
public openDocumentsService: OpenDocumentsService,
|
||||||
public documentListViewService: DocumentListViewService
|
public documentListViewService: DocumentListViewService,
|
||||||
|
public permissionsService: PermissionsService
|
||||||
) {
|
) {
|
||||||
super()
|
super()
|
||||||
}
|
}
|
||||||
|
@@ -6,8 +6,8 @@
|
|||||||
<input type="file" class="visually-hidden" (change)="onFileSelected($event)" multiple #fileUpload>
|
<input type="file" class="visually-hidden" (change)="onFileSelected($event)" multiple #fileUpload>
|
||||||
</form>
|
</form>
|
||||||
@if (getStatus().length > 0) {
|
@if (getStatus().length > 0) {
|
||||||
<div class="fixed-bottom p-2 p-md-4 d-flex justify-content-end" [ngClass]="slimSidebarEnabled ? 'col-slim' : 'offset-md-3 offset-lg-2'">
|
<div class="fixed-bottom p-2 p-md-4 d-flex justify-content-end pe-none" [ngClass]="slimSidebarEnabled ? 'col-slim' : 'offset-md-3 offset-lg-2'">
|
||||||
<div class="col col-lg-4 col-xl-3 ps-0 pe-0 ps-lg-3 pe-lg-0">
|
<div class="col col-lg-4 col-xl-3 ps-0 pe-0 ps-lg-3 pe-lg-0 pe-auto">
|
||||||
<div class="card shadow-sm consumer-status-card">
|
<div class="card shadow-sm consumer-status-card">
|
||||||
<div class="card-body">
|
<div class="card-body">
|
||||||
<div class="row">
|
<div class="row">
|
||||||
|
@@ -119,6 +119,8 @@ describe('UploadFileWidgetComponent', () => {
|
|||||||
const processingStatus = new FileStatus()
|
const processingStatus = new FileStatus()
|
||||||
processingStatus.phase = FileStatusPhase.WORKING
|
processingStatus.phase = FileStatusPhase.WORKING
|
||||||
expect(component.getStatusColor(processingStatus)).toEqual('primary')
|
expect(component.getStatusColor(processingStatus)).toEqual('primary')
|
||||||
|
processingStatus.phase = FileStatusPhase.UPLOADING
|
||||||
|
expect(component.getStatusColor(processingStatus)).toEqual('primary')
|
||||||
const failedStatus = new FileStatus()
|
const failedStatus = new FileStatus()
|
||||||
failedStatus.phase = FileStatusPhase.FAILED
|
failedStatus.phase = FileStatusPhase.FAILED
|
||||||
expect(component.getStatusColor(failedStatus)).toEqual('danger')
|
expect(component.getStatusColor(failedStatus)).toEqual('danger')
|
||||||
|
@@ -1,11 +1,11 @@
|
|||||||
<pngx-page-header [(title)]="title">
|
<pngx-page-header [(title)]="title">
|
||||||
@if (contentRenderType === ContentRenderType.PDF && !useNativePdfViewer) {
|
@if (contentRenderType === ContentRenderType.PDF && !useNativePdfViewer) {
|
||||||
<div class="input-group input-group-sm me-2 d-none d-md-flex">
|
<div class="input-group input-group-sm d-none d-md-flex">
|
||||||
<div class="input-group-text" i18n>Page</div>
|
<div class="input-group-text" i18n>Page</div>
|
||||||
<input class="form-control flex-grow-0 w-auto" type="number" min="1" [max]="previewNumPages" [(ngModel)]="previewCurrentPage" />
|
<input class="form-control flex-grow-0 w-auto" type="number" min="1" [max]="previewNumPages" [(ngModel)]="previewCurrentPage" />
|
||||||
<div class="input-group-text" i18n>of {{previewNumPages}}</div>
|
<div class="input-group-text" i18n>of {{previewNumPages}}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="input-group input-group-sm me-5 d-none d-md-flex">
|
<div class="input-group input-group-sm me-md-5 d-none d-md-flex">
|
||||||
<button class="btn btn-outline-secondary" (click)="decreaseZoom()" i18n>-</button>
|
<button class="btn btn-outline-secondary" (click)="decreaseZoom()" i18n>-</button>
|
||||||
<select class="form-select" (change)="onZoomSelect($event)">
|
<select class="form-select" (change)="onZoomSelect($event)">
|
||||||
@for (setting of zoomSettings; track setting) {
|
@for (setting of zoomSettings; track setting) {
|
||||||
@@ -18,11 +18,11 @@
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
|
||||||
<button type="button" class="btn btn-sm btn-outline-danger me-4" (click)="delete()" [disabled]="!userIsOwner" *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.Document }">
|
<button type="button" class="btn btn-sm btn-outline-danger me-md-4" (click)="delete()" [disabled]="!userIsOwner" *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.Document }">
|
||||||
<i-bs width="1.2em" height="1.2em" name="trash"></i-bs><span class="d-none d-lg-inline ps-1" i18n>Delete</span>
|
<i-bs width="1.2em" height="1.2em" name="trash"></i-bs><span class="d-none d-lg-inline ps-1" i18n>Delete</span>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<div class="btn-group me-2">
|
<div class="btn-group">
|
||||||
<a [href]="downloadUrl" class="btn btn-sm btn-outline-primary">
|
<a [href]="downloadUrl" class="btn btn-sm btn-outline-primary">
|
||||||
<i-bs width="1.2em" height="1.2em" name="download"></i-bs><span class="d-none d-lg-inline ps-1" i18n>Download</span>
|
<i-bs width="1.2em" height="1.2em" name="download"></i-bs><span class="d-none d-lg-inline ps-1" i18n>Download</span>
|
||||||
</a>
|
</a>
|
||||||
@@ -38,7 +38,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="ms-auto" ngbDropdown>
|
<div class="ms-auto" ngbDropdown>
|
||||||
<button class="btn btn-sm btn-outline-primary me-2" id="actionsDropdown" ngbDropdownToggle>
|
<button class="btn btn-sm btn-outline-primary" id="actionsDropdown" ngbDropdownToggle>
|
||||||
<i-bs name="three-dots"></i-bs>
|
<i-bs name="three-dots"></i-bs>
|
||||||
<div class="d-none d-sm-inline"> <ng-container i18n>Actions</ng-container></div>
|
<div class="d-none d-sm-inline"> <ng-container i18n>Actions</ng-container></div>
|
||||||
</button>
|
</button>
|
||||||
@@ -55,7 +55,6 @@
|
|||||||
|
|
||||||
<pngx-custom-fields-dropdown
|
<pngx-custom-fields-dropdown
|
||||||
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.CustomField }"
|
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.CustomField }"
|
||||||
class="me-2"
|
|
||||||
[documentId]="documentId"
|
[documentId]="documentId"
|
||||||
[disabled]="!userIsOwner"
|
[disabled]="!userIsOwner"
|
||||||
[existingFields]="document?.custom_fields"
|
[existingFields]="document?.custom_fields"
|
||||||
@@ -142,14 +141,12 @@
|
|||||||
[error]="getCustomFieldError(i)"></pngx-input-number>
|
[error]="getCustomFieldError(i)"></pngx-input-number>
|
||||||
}
|
}
|
||||||
@case (CustomFieldDataType.Monetary) {
|
@case (CustomFieldDataType.Monetary) {
|
||||||
<pngx-input-number formControlName="value"
|
<pngx-input-monetary formControlName="value"
|
||||||
[title]="getCustomFieldFromInstance(fieldInstance)?.name"
|
[title]="getCustomFieldFromInstance(fieldInstance)?.name"
|
||||||
[removable]="userIsOwner"
|
[removable]="userIsOwner"
|
||||||
(removed)="removeField(fieldInstance)"
|
(removed)="removeField(fieldInstance)"
|
||||||
[horizontal]="true"
|
[horizontal]="true"
|
||||||
[showAdd]="false"
|
[error]="getCustomFieldError(i)"></pngx-input-monetary>
|
||||||
[step]=".01"
|
|
||||||
[error]="getCustomFieldError(i)"></pngx-input-number>
|
|
||||||
}
|
}
|
||||||
@case (CustomFieldDataType.Boolean) {
|
@case (CustomFieldDataType.Boolean) {
|
||||||
<pngx-input-check formControlName="value"
|
<pngx-input-check formControlName="value"
|
||||||
@@ -324,44 +321,45 @@
|
|||||||
<ng-container i18n>Loading...</ng-container>
|
<ng-container i18n>Loading...</ng-container>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
} @else {
|
||||||
@switch (contentRenderType) {
|
@switch (contentRenderType) {
|
||||||
@case (ContentRenderType.PDF) {
|
@case (ContentRenderType.PDF) {
|
||||||
@if (!useNativePdfViewer) {
|
@if (!useNativePdfViewer) {
|
||||||
<div class="preview-sticky pdf-viewer-container">
|
<div class="preview-sticky pdf-viewer-container">
|
||||||
<pngx-pdf-viewer
|
<pngx-pdf-viewer
|
||||||
[src]="{ url: previewUrl, password: password }"
|
[src]="{ url: previewUrl, password: password }"
|
||||||
[original-size]="false"
|
[original-size]="false"
|
||||||
[show-borders]="true"
|
[show-borders]="true"
|
||||||
[show-all]="true"
|
[show-all]="true"
|
||||||
[(page)]="previewCurrentPage"
|
[(page)]="previewCurrentPage"
|
||||||
[zoom-scale]="previewZoomScale"
|
[zoom-scale]="previewZoomScale"
|
||||||
[zoom]="previewZoomSetting"
|
[zoom]="previewZoomSetting"
|
||||||
(error)="onError($event)"
|
(error)="onError($event)"
|
||||||
(after-load-complete)="pdfPreviewLoaded($event)">
|
(after-load-complete)="pdfPreviewLoaded($event)">
|
||||||
</pngx-pdf-viewer>
|
</pngx-pdf-viewer>
|
||||||
|
</div>
|
||||||
|
} @else {
|
||||||
|
<object [data]="previewUrl | safeUrl" class="preview-sticky" width="100%"></object>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@case (ContentRenderType.Text) {
|
||||||
|
<div class="preview-sticky bg-light p-3 overflow-auto" width="100%">{{previewText}}</div>
|
||||||
|
}
|
||||||
|
@case (ContentRenderType.Image) {
|
||||||
|
<div class="preview-sticky">
|
||||||
|
<img [src]="previewUrl | safeUrl" width="100%" height="100%" alt="{{title}}" />
|
||||||
</div>
|
</div>
|
||||||
} @else {
|
}
|
||||||
|
@case (ContentRenderType.Other) {
|
||||||
<object [data]="previewUrl | safeUrl" class="preview-sticky" width="100%"></object>
|
<object [data]="previewUrl | safeUrl" class="preview-sticky" width="100%"></object>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@case (ContentRenderType.Text) {
|
@if (requiresPassword) {
|
||||||
<div class="preview-sticky bg-light p-3 overflow-auto" width="100%">{{previewText}}</div>
|
<div class="password-prompt">
|
||||||
}
|
<form>
|
||||||
@case (ContentRenderType.Image) {
|
<input autocomplete="" autofocus="true" class="form-control" i18n-placeholder placeholder="Enter Password" type="password" (keyup)="onPasswordKeyUp($event)" />
|
||||||
<div class="preview-sticky">
|
</form>
|
||||||
<img [src]="previewUrl | safeUrl" width="100%" height="100%" alt="{{title}}" />
|
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@case (ContentRenderType.Other) {
|
|
||||||
<object [data]="previewUrl | safeUrl" class="preview-sticky" width="100%"></object>
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@if (requiresPassword) {
|
|
||||||
<div class="password-prompt">
|
|
||||||
<form>
|
|
||||||
<input autocomplete="" autofocus="true" class="form-control" i18n-placeholder placeholder="Enter Password" type="password" (keyup)="onPasswordKeyUp($event)" />
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
}
|
}
|
||||||
</ng-template>
|
</ng-template>
|
||||||
|
@@ -1,5 +1,8 @@
|
|||||||
import { DatePipe } from '@angular/common'
|
import { DatePipe } from '@angular/common'
|
||||||
import { HttpClientTestingModule } from '@angular/common/http/testing'
|
import {
|
||||||
|
HttpClientTestingModule,
|
||||||
|
HttpTestingController,
|
||||||
|
} from '@angular/common/http/testing'
|
||||||
import {
|
import {
|
||||||
ComponentFixture,
|
ComponentFixture,
|
||||||
TestBed,
|
TestBed,
|
||||||
@@ -71,6 +74,7 @@ import { CustomFieldDataType } from 'src/app/data/custom-field'
|
|||||||
import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
|
import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
|
||||||
import { PdfViewerComponent } from '../common/pdf-viewer/pdf-viewer.component'
|
import { PdfViewerComponent } from '../common/pdf-viewer/pdf-viewer.component'
|
||||||
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
||||||
|
import { environment } from 'src/environments/environment'
|
||||||
|
|
||||||
const doc: Document = {
|
const doc: Document = {
|
||||||
id: 3,
|
id: 3,
|
||||||
@@ -91,12 +95,12 @@ const doc: Document = {
|
|||||||
{
|
{
|
||||||
created: new Date(),
|
created: new Date(),
|
||||||
note: 'note 1',
|
note: 'note 1',
|
||||||
user: 1,
|
user: { id: 1, username: 'user1' },
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
created: new Date(),
|
created: new Date(),
|
||||||
note: 'note 2',
|
note: 'note 2',
|
||||||
user: 2,
|
user: { id: 2, username: 'user2' },
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
custom_fields: [
|
custom_fields: [
|
||||||
@@ -136,6 +140,7 @@ describe('DocumentDetailComponent', () => {
|
|||||||
let documentListViewService: DocumentListViewService
|
let documentListViewService: DocumentListViewService
|
||||||
let settingsService: SettingsService
|
let settingsService: SettingsService
|
||||||
let customFieldsService: CustomFieldsService
|
let customFieldsService: CustomFieldsService
|
||||||
|
let httpTestingController: HttpTestingController
|
||||||
|
|
||||||
let currentUserCan = true
|
let currentUserCan = true
|
||||||
let currentUserHasObjectPermissions = true
|
let currentUserHasObjectPermissions = true
|
||||||
@@ -266,6 +271,7 @@ describe('DocumentDetailComponent', () => {
|
|||||||
settingsService.currentUser = { id: 1 }
|
settingsService.currentUser = { id: 1 }
|
||||||
customFieldsService = TestBed.inject(CustomFieldsService)
|
customFieldsService = TestBed.inject(CustomFieldsService)
|
||||||
fixture = TestBed.createComponent(DocumentDetailComponent)
|
fixture = TestBed.createComponent(DocumentDetailComponent)
|
||||||
|
httpTestingController = TestBed.inject(HttpTestingController)
|
||||||
component = fixture.componentInstance
|
component = fixture.componentInstance
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -350,6 +356,26 @@ describe('DocumentDetailComponent', () => {
|
|||||||
expect(component.documentForm.disabled).toBeTruthy()
|
expect(component.documentForm.disabled).toBeTruthy()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should not attempt to retrieve objects if user does not have permissions', () => {
|
||||||
|
currentUserCan = false
|
||||||
|
initNormally()
|
||||||
|
expect(component.correspondents).toBeUndefined()
|
||||||
|
expect(component.documentTypes).toBeUndefined()
|
||||||
|
expect(component.storagePaths).toBeUndefined()
|
||||||
|
expect(component.users).toBeUndefined()
|
||||||
|
httpTestingController.expectNone(`${environment.apiBaseUrl}documents/tags/`)
|
||||||
|
httpTestingController.expectNone(
|
||||||
|
`${environment.apiBaseUrl}documents/correspondents/`
|
||||||
|
)
|
||||||
|
httpTestingController.expectNone(
|
||||||
|
`${environment.apiBaseUrl}documents/document_types/`
|
||||||
|
)
|
||||||
|
httpTestingController.expectNone(
|
||||||
|
`${environment.apiBaseUrl}documents/storage_paths/`
|
||||||
|
)
|
||||||
|
currentUserCan = true
|
||||||
|
})
|
||||||
|
|
||||||
it('should support creating document type', () => {
|
it('should support creating document type', () => {
|
||||||
initNormally()
|
initNormally()
|
||||||
let openModal: NgbModalRef
|
let openModal: NgbModalRef
|
||||||
@@ -681,6 +707,7 @@ describe('DocumentDetailComponent', () => {
|
|||||||
|
|
||||||
it('should support Enter key in password field', () => {
|
it('should support Enter key in password field', () => {
|
||||||
initNormally()
|
initNormally()
|
||||||
|
component.metadata = { has_archive_version: true }
|
||||||
component.onError({ name: 'PasswordException' }) // normally dispatched by pdf viewer
|
component.onError({ name: 'PasswordException' }) // normally dispatched by pdf viewer
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
expect(component.password).toBeUndefined()
|
expect(component.password).toBeUndefined()
|
||||||
|
@@ -82,6 +82,7 @@ enum ContentRenderType {
|
|||||||
Image = 'image',
|
Image = 'image',
|
||||||
Text = 'text',
|
Text = 'text',
|
||||||
Other = 'other',
|
Other = 'other',
|
||||||
|
Unknown = 'unknown',
|
||||||
}
|
}
|
||||||
|
|
||||||
enum ZoomSetting {
|
enum ZoomSetting {
|
||||||
@@ -211,6 +212,7 @@ export class DocumentDetailComponent
|
|||||||
}
|
}
|
||||||
|
|
||||||
get contentRenderType(): ContentRenderType {
|
get contentRenderType(): ContentRenderType {
|
||||||
|
if (!this.metadata) return ContentRenderType.Unknown
|
||||||
const contentType = this.metadata?.has_archive_version
|
const contentType = this.metadata?.has_archive_version
|
||||||
? 'application/pdf'
|
? 'application/pdf'
|
||||||
: this.metadata?.original_mime_type
|
: this.metadata?.original_mime_type
|
||||||
@@ -248,25 +250,50 @@ export class DocumentDetailComponent
|
|||||||
Object.assign(this.document, docValues)
|
Object.assign(this.document, docValues)
|
||||||
})
|
})
|
||||||
|
|
||||||
this.correspondentService
|
if (
|
||||||
.listAll()
|
this.permissionsService.currentUserCan(
|
||||||
.pipe(first(), takeUntil(this.unsubscribeNotifier))
|
PermissionAction.View,
|
||||||
.subscribe((result) => (this.correspondents = result.results))
|
PermissionType.Correspondent
|
||||||
|
)
|
||||||
this.documentTypeService
|
) {
|
||||||
.listAll()
|
this.correspondentService
|
||||||
.pipe(first(), takeUntil(this.unsubscribeNotifier))
|
.listAll()
|
||||||
.subscribe((result) => (this.documentTypes = result.results))
|
.pipe(first(), takeUntil(this.unsubscribeNotifier))
|
||||||
|
.subscribe((result) => (this.correspondents = result.results))
|
||||||
this.storagePathService
|
}
|
||||||
.listAll()
|
if (
|
||||||
.pipe(first(), takeUntil(this.unsubscribeNotifier))
|
this.permissionsService.currentUserCan(
|
||||||
.subscribe((result) => (this.storagePaths = result.results))
|
PermissionAction.View,
|
||||||
|
PermissionType.DocumentType
|
||||||
this.userService
|
)
|
||||||
.listAll()
|
) {
|
||||||
.pipe(first(), takeUntil(this.unsubscribeNotifier))
|
this.documentTypeService
|
||||||
.subscribe((result) => (this.users = result.results))
|
.listAll()
|
||||||
|
.pipe(first(), takeUntil(this.unsubscribeNotifier))
|
||||||
|
.subscribe((result) => (this.documentTypes = result.results))
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
this.permissionsService.currentUserCan(
|
||||||
|
PermissionAction.View,
|
||||||
|
PermissionType.StoragePath
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
this.storagePathService
|
||||||
|
.listAll()
|
||||||
|
.pipe(first(), takeUntil(this.unsubscribeNotifier))
|
||||||
|
.subscribe((result) => (this.storagePaths = result.results))
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
this.permissionsService.currentUserCan(
|
||||||
|
PermissionAction.View,
|
||||||
|
PermissionType.User
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
this.userService
|
||||||
|
.listAll()
|
||||||
|
.pipe(first(), takeUntil(this.unsubscribeNotifier))
|
||||||
|
.subscribe((result) => (this.users = result.results))
|
||||||
|
}
|
||||||
|
|
||||||
this.getCustomFields()
|
this.getCustomFields()
|
||||||
|
|
||||||
@@ -460,7 +487,7 @@ export class DocumentDetailComponent
|
|||||||
this.metadata = result
|
this.metadata = result
|
||||||
},
|
},
|
||||||
error: (error) => {
|
error: (error) => {
|
||||||
this.metadata = null
|
this.metadata = {} // allow display to fallback to <object> tag
|
||||||
this.toastService.showError(
|
this.toastService.showError(
|
||||||
$localize`Error retrieving metadata`,
|
$localize`Error retrieving metadata`,
|
||||||
error
|
error
|
||||||
@@ -603,13 +630,18 @@ export class DocumentDetailComponent
|
|||||||
.update(this.document)
|
.update(this.document)
|
||||||
.pipe(first())
|
.pipe(first())
|
||||||
.subscribe({
|
.subscribe({
|
||||||
next: () => {
|
next: (docValues) => {
|
||||||
|
// in case data changed while saving eg removing inbox_tags
|
||||||
|
this.documentForm.patchValue(docValues)
|
||||||
this.store.next(this.documentForm.value)
|
this.store.next(this.documentForm.value)
|
||||||
|
this.openDocumentService.setDirty(this.document, false)
|
||||||
this.toastService.showInfo($localize`Document saved successfully.`)
|
this.toastService.showInfo($localize`Document saved successfully.`)
|
||||||
close && this.close()
|
|
||||||
this.networkActive = false
|
this.networkActive = false
|
||||||
this.error = null
|
this.error = null
|
||||||
this.openDocumentService.refreshDocument(this.documentId)
|
close &&
|
||||||
|
this.close(() =>
|
||||||
|
this.openDocumentService.refreshDocument(this.documentId)
|
||||||
|
)
|
||||||
},
|
},
|
||||||
error: (error) => {
|
error: (error) => {
|
||||||
this.networkActive = false
|
this.networkActive = false
|
||||||
@@ -664,12 +696,13 @@ export class DocumentDetailComponent
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
close() {
|
close(closedCallback: () => void = null) {
|
||||||
this.openDocumentService
|
this.openDocumentService
|
||||||
.closeDocument(this.document)
|
.closeDocument(this.document)
|
||||||
.pipe(first())
|
.pipe(first())
|
||||||
.subscribe((closed) => {
|
.subscribe((closed) => {
|
||||||
if (!closed) return
|
if (!closed) return
|
||||||
|
if (closedCallback) closedCallback()
|
||||||
if (this.documentListViewService.activeSavedViewId) {
|
if (this.documentListViewService.activeSavedViewId) {
|
||||||
this.router.navigate([
|
this.router.navigate([
|
||||||
'view',
|
'view',
|
||||||
|
@@ -17,51 +17,63 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="d-flex align-items-center gap-2" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.Document }">
|
<div class="d-flex align-items-center gap-2" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.Document }">
|
||||||
<label class="me-2" i18n>Edit:</label>
|
<label class="me-2" i18n>Edit:</label>
|
||||||
<pngx-filterable-dropdown title="Tags" icon="tag-fill" i18n-title
|
@if (permissionService.currentUserCan(PermissionAction.View, PermissionType.Tag)) {
|
||||||
filterPlaceholder="Filter tags" i18n-filterPlaceholder
|
<pngx-filterable-dropdown title="Tags" icon="tag-fill" i18n-title
|
||||||
[items]="tags"
|
filterPlaceholder="Filter tags" i18n-filterPlaceholder
|
||||||
[disabled]="!userCanEditAll"
|
[items]="tags"
|
||||||
[editing]="true"
|
[disabled]="!userCanEditAll"
|
||||||
[manyToOne]="true"
|
[editing]="true"
|
||||||
[applyOnClose]="applyOnClose"
|
[manyToOne]="true"
|
||||||
(opened)="openTagsDropdown()"
|
[applyOnClose]="applyOnClose"
|
||||||
[(selectionModel)]="tagSelectionModel"
|
[createRef]="createTag.bind(this)"
|
||||||
[documentCounts]="tagDocumentCounts"
|
(opened)="openTagsDropdown()"
|
||||||
(apply)="setTags($event)">
|
[(selectionModel)]="tagSelectionModel"
|
||||||
</pngx-filterable-dropdown>
|
[documentCounts]="tagDocumentCounts"
|
||||||
<pngx-filterable-dropdown title="Correspondent" icon="person-fill" i18n-title
|
(apply)="setTags($event)">
|
||||||
filterPlaceholder="Filter correspondents" i18n-filterPlaceholder
|
</pngx-filterable-dropdown>
|
||||||
[items]="correspondents"
|
}
|
||||||
[disabled]="!userCanEditAll"
|
@if (permissionService.currentUserCan(PermissionAction.View, PermissionType.Correspondent)) {
|
||||||
[editing]="true"
|
<pngx-filterable-dropdown title="Correspondent" icon="person-fill" i18n-title
|
||||||
[applyOnClose]="applyOnClose"
|
filterPlaceholder="Filter correspondents" i18n-filterPlaceholder
|
||||||
(opened)="openCorrespondentDropdown()"
|
[items]="correspondents"
|
||||||
[(selectionModel)]="correspondentSelectionModel"
|
[disabled]="!userCanEditAll"
|
||||||
[documentCounts]="correspondentDocumentCounts"
|
[editing]="true"
|
||||||
(apply)="setCorrespondents($event)">
|
[applyOnClose]="applyOnClose"
|
||||||
</pngx-filterable-dropdown>
|
[createRef]="createCorrespondent.bind(this)"
|
||||||
<pngx-filterable-dropdown title="Document type" icon="file-earmark-fill" i18n-title
|
(opened)="openCorrespondentDropdown()"
|
||||||
filterPlaceholder="Filter document types" i18n-filterPlaceholder
|
[(selectionModel)]="correspondentSelectionModel"
|
||||||
[items]="documentTypes"
|
[documentCounts]="correspondentDocumentCounts"
|
||||||
[disabled]="!userCanEditAll"
|
(apply)="setCorrespondents($event)">
|
||||||
[editing]="true"
|
</pngx-filterable-dropdown>
|
||||||
[applyOnClose]="applyOnClose"
|
}
|
||||||
(opened)="openDocumentTypeDropdown()"
|
@if (permissionService.currentUserCan(PermissionAction.View, PermissionType.DocumentType)) {
|
||||||
[(selectionModel)]="documentTypeSelectionModel"
|
<pngx-filterable-dropdown title="Document type" icon="file-earmark-fill" i18n-title
|
||||||
[documentCounts]="documentTypeDocumentCounts"
|
filterPlaceholder="Filter document types" i18n-filterPlaceholder
|
||||||
(apply)="setDocumentTypes($event)">
|
[items]="documentTypes"
|
||||||
</pngx-filterable-dropdown>
|
[disabled]="!userCanEditAll"
|
||||||
<pngx-filterable-dropdown title="Storage path" icon="folder-fill" i18n-title
|
[editing]="true"
|
||||||
filterPlaceholder="Filter storage paths" i18n-filterPlaceholder
|
[applyOnClose]="applyOnClose"
|
||||||
[items]="storagePaths"
|
[createRef]="createDocumentType.bind(this)"
|
||||||
[disabled]="!userCanEditAll"
|
(opened)="openDocumentTypeDropdown()"
|
||||||
[editing]="true"
|
[(selectionModel)]="documentTypeSelectionModel"
|
||||||
[applyOnClose]="applyOnClose"
|
[documentCounts]="documentTypeDocumentCounts"
|
||||||
(opened)="openStoragePathDropdown()"
|
(apply)="setDocumentTypes($event)">
|
||||||
[(selectionModel)]="storagePathsSelectionModel"
|
</pngx-filterable-dropdown>
|
||||||
[documentCounts]="storagePathDocumentCounts"
|
}
|
||||||
(apply)="setStoragePaths($event)">
|
@if (permissionService.currentUserCan(PermissionAction.View, PermissionType.StoragePath)) {
|
||||||
</pngx-filterable-dropdown>
|
<pngx-filterable-dropdown title="Storage path" icon="folder-fill" i18n-title
|
||||||
|
filterPlaceholder="Filter storage paths" i18n-filterPlaceholder
|
||||||
|
[items]="storagePaths"
|
||||||
|
[disabled]="!userCanEditAll"
|
||||||
|
[editing]="true"
|
||||||
|
[applyOnClose]="applyOnClose"
|
||||||
|
[createRef]="createStoragePath.bind(this)"
|
||||||
|
(opened)="openStoragePathDropdown()"
|
||||||
|
[(selectionModel)]="storagePathsSelectionModel"
|
||||||
|
[documentCounts]="storagePathDocumentCounts"
|
||||||
|
(apply)="setStoragePaths($event)">
|
||||||
|
</pngx-filterable-dropdown>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
<div class="d-flex align-items-center gap-2 ms-auto">
|
<div class="d-flex align-items-center gap-2 ms-auto">
|
||||||
<div class="btn-toolbar">
|
<div class="btn-toolbar">
|
||||||
|
@@ -41,6 +41,17 @@ import { PermissionsUserComponent } from '../../common/input/permissions/permiss
|
|||||||
import { NgSelectModule } from '@ng-select/ng-select'
|
import { NgSelectModule } from '@ng-select/ng-select'
|
||||||
import { GroupService } from 'src/app/services/rest/group.service'
|
import { GroupService } from 'src/app/services/rest/group.service'
|
||||||
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
||||||
|
import { SwitchComponent } from '../../common/input/switch/switch.component'
|
||||||
|
import { EditDialogMode } from '../../common/edit-dialog/edit-dialog.component'
|
||||||
|
import { TagEditDialogComponent } from '../../common/edit-dialog/tag-edit-dialog/tag-edit-dialog.component'
|
||||||
|
import { Results } from 'src/app/data/results'
|
||||||
|
import { Tag } from 'src/app/data/tag'
|
||||||
|
import { Correspondent } from 'src/app/data/correspondent'
|
||||||
|
import { DocumentType } from 'src/app/data/document-type'
|
||||||
|
import { StoragePath } from 'src/app/data/storage-path'
|
||||||
|
import { CorrespondentEditDialogComponent } from '../../common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component'
|
||||||
|
import { DocumentTypeEditDialogComponent } from '../../common/edit-dialog/document-type-edit-dialog/document-type-edit-dialog.component'
|
||||||
|
import { StoragePathEditDialogComponent } from '../../common/edit-dialog/storage-path-edit-dialog/storage-path-edit-dialog.component'
|
||||||
|
|
||||||
const selectionData: SelectionData = {
|
const selectionData: SelectionData = {
|
||||||
selected_tags: [
|
selected_tags: [
|
||||||
@@ -64,6 +75,10 @@ describe('BulkEditorComponent', () => {
|
|||||||
let documentService: DocumentService
|
let documentService: DocumentService
|
||||||
let toastService: ToastService
|
let toastService: ToastService
|
||||||
let modalService: NgbModal
|
let modalService: NgbModal
|
||||||
|
let tagService: TagService
|
||||||
|
let correspondentsService: CorrespondentService
|
||||||
|
let documentTypeService: DocumentTypeService
|
||||||
|
let storagePathService: StoragePathService
|
||||||
let httpTestingController: HttpTestingController
|
let httpTestingController: HttpTestingController
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
@@ -81,6 +96,7 @@ describe('BulkEditorComponent', () => {
|
|||||||
SelectComponent,
|
SelectComponent,
|
||||||
PermissionsGroupComponent,
|
PermissionsGroupComponent,
|
||||||
PermissionsUserComponent,
|
PermissionsUserComponent,
|
||||||
|
SwitchComponent,
|
||||||
],
|
],
|
||||||
providers: [
|
providers: [
|
||||||
PermissionsService,
|
PermissionsService,
|
||||||
@@ -163,6 +179,10 @@ describe('BulkEditorComponent', () => {
|
|||||||
documentService = TestBed.inject(DocumentService)
|
documentService = TestBed.inject(DocumentService)
|
||||||
toastService = TestBed.inject(ToastService)
|
toastService = TestBed.inject(ToastService)
|
||||||
modalService = TestBed.inject(NgbModal)
|
modalService = TestBed.inject(NgbModal)
|
||||||
|
tagService = TestBed.inject(TagService)
|
||||||
|
correspondentsService = TestBed.inject(CorrespondentService)
|
||||||
|
documentTypeService = TestBed.inject(DocumentTypeService)
|
||||||
|
storagePathService = TestBed.inject(StoragePathService)
|
||||||
httpTestingController = TestBed.inject(HttpTestingController)
|
httpTestingController = TestBed.inject(HttpTestingController)
|
||||||
|
|
||||||
fixture = TestBed.createComponent(BulkEditorComponent)
|
fixture = TestBed.createComponent(BulkEditorComponent)
|
||||||
@@ -851,7 +871,18 @@ describe('BulkEditorComponent', () => {
|
|||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
component.setPermissions()
|
component.setPermissions()
|
||||||
expect(modal).not.toBeUndefined()
|
expect(modal).not.toBeUndefined()
|
||||||
modal.componentInstance.confirmClicked.next()
|
const perms = {
|
||||||
|
permissions: {
|
||||||
|
view_users: [],
|
||||||
|
change_users: [],
|
||||||
|
view_groups: [],
|
||||||
|
change_groups: [],
|
||||||
|
},
|
||||||
|
}
|
||||||
|
modal.componentInstance.confirmClicked.emit({
|
||||||
|
permissions: perms,
|
||||||
|
merge: true,
|
||||||
|
})
|
||||||
let req = httpTestingController.expectOne(
|
let req = httpTestingController.expectOne(
|
||||||
`${environment.apiBaseUrl}documents/bulk_edit/`
|
`${environment.apiBaseUrl}documents/bulk_edit/`
|
||||||
)
|
)
|
||||||
@@ -859,7 +890,10 @@ describe('BulkEditorComponent', () => {
|
|||||||
expect(req.request.body).toEqual({
|
expect(req.request.body).toEqual({
|
||||||
documents: [3, 4],
|
documents: [3, 4],
|
||||||
method: 'set_permissions',
|
method: 'set_permissions',
|
||||||
parameters: undefined,
|
parameters: {
|
||||||
|
permissions: perms.permissions,
|
||||||
|
merge: true,
|
||||||
|
},
|
||||||
})
|
})
|
||||||
httpTestingController.match(
|
httpTestingController.match(
|
||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true`
|
||||||
@@ -868,4 +902,198 @@ describe('BulkEditorComponent', () => {
|
|||||||
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
||||||
) // listAllFilteredIds
|
) // listAllFilteredIds
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should not attempt to retrieve objects if user does not have permissions', () => {
|
||||||
|
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
|
||||||
|
expect(component.tags).toBeUndefined()
|
||||||
|
expect(component.correspondents).toBeUndefined()
|
||||||
|
expect(component.documentTypes).toBeUndefined()
|
||||||
|
expect(component.storagePaths).toBeUndefined()
|
||||||
|
httpTestingController.expectNone(`${environment.apiBaseUrl}documents/tags/`)
|
||||||
|
httpTestingController.expectNone(
|
||||||
|
`${environment.apiBaseUrl}documents/correspondents/`
|
||||||
|
)
|
||||||
|
httpTestingController.expectNone(
|
||||||
|
`${environment.apiBaseUrl}documents/document_types/`
|
||||||
|
)
|
||||||
|
httpTestingController.expectNone(
|
||||||
|
`${environment.apiBaseUrl}documents/storage_paths/`
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should support create new tag', () => {
|
||||||
|
const name = 'New Tag'
|
||||||
|
const newTag = { id: 101, name: 'New Tag' }
|
||||||
|
const tags: Results<Tag> = {
|
||||||
|
results: [
|
||||||
|
{ id: 1, name: 'Tag 1' },
|
||||||
|
{ id: 2, name: 'Tag 2' },
|
||||||
|
],
|
||||||
|
count: 2,
|
||||||
|
all: [1, 2],
|
||||||
|
}
|
||||||
|
|
||||||
|
const modalInstance = {
|
||||||
|
componentInstance: {
|
||||||
|
dialogMode: EditDialogMode.CREATE,
|
||||||
|
object: { name },
|
||||||
|
succeeded: of(newTag),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
const tagListAllSpy = jest.spyOn(tagService, 'listAll')
|
||||||
|
tagListAllSpy.mockReturnValue(of(tags))
|
||||||
|
|
||||||
|
const tagSelectionModelToggleSpy = jest.spyOn(
|
||||||
|
component.tagSelectionModel,
|
||||||
|
'toggle'
|
||||||
|
)
|
||||||
|
|
||||||
|
const modalServiceOpenSpy = jest.spyOn(modalService, 'open')
|
||||||
|
modalServiceOpenSpy.mockReturnValue(modalInstance as any)
|
||||||
|
|
||||||
|
component.createTag(name)
|
||||||
|
|
||||||
|
expect(modalServiceOpenSpy).toHaveBeenCalledWith(TagEditDialogComponent, {
|
||||||
|
backdrop: 'static',
|
||||||
|
})
|
||||||
|
expect(tagListAllSpy).toHaveBeenCalled()
|
||||||
|
|
||||||
|
expect(tagSelectionModelToggleSpy).toHaveBeenCalledWith(newTag.id)
|
||||||
|
expect(component.tags).toEqual(tags.results)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should support create new correspondent', () => {
|
||||||
|
const name = 'New Correspondent'
|
||||||
|
const newCorrespondent = { id: 101, name: 'New Correspondent' }
|
||||||
|
const correspondents: Results<Correspondent> = {
|
||||||
|
results: [
|
||||||
|
{ id: 1, name: 'Correspondent 1' },
|
||||||
|
{ id: 2, name: 'Correspondent 2' },
|
||||||
|
],
|
||||||
|
count: 2,
|
||||||
|
all: [1, 2],
|
||||||
|
}
|
||||||
|
|
||||||
|
const modalInstance = {
|
||||||
|
componentInstance: {
|
||||||
|
dialogMode: EditDialogMode.CREATE,
|
||||||
|
object: { name },
|
||||||
|
succeeded: of(newCorrespondent),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
const correspondentsListAllSpy = jest.spyOn(
|
||||||
|
correspondentsService,
|
||||||
|
'listAll'
|
||||||
|
)
|
||||||
|
correspondentsListAllSpy.mockReturnValue(of(correspondents))
|
||||||
|
|
||||||
|
const correspondentSelectionModelToggleSpy = jest.spyOn(
|
||||||
|
component.correspondentSelectionModel,
|
||||||
|
'toggle'
|
||||||
|
)
|
||||||
|
|
||||||
|
const modalServiceOpenSpy = jest.spyOn(modalService, 'open')
|
||||||
|
modalServiceOpenSpy.mockReturnValue(modalInstance as any)
|
||||||
|
|
||||||
|
component.createCorrespondent(name)
|
||||||
|
|
||||||
|
expect(modalServiceOpenSpy).toHaveBeenCalledWith(
|
||||||
|
CorrespondentEditDialogComponent,
|
||||||
|
{ backdrop: 'static' }
|
||||||
|
)
|
||||||
|
expect(correspondentsListAllSpy).toHaveBeenCalled()
|
||||||
|
|
||||||
|
expect(correspondentSelectionModelToggleSpy).toHaveBeenCalledWith(
|
||||||
|
newCorrespondent.id
|
||||||
|
)
|
||||||
|
expect(component.correspondents).toEqual(correspondents.results)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should support create new document type', () => {
|
||||||
|
const name = 'New Document Type'
|
||||||
|
const newDocumentType = { id: 101, name: 'New Document Type' }
|
||||||
|
const documentTypes: Results<DocumentType> = {
|
||||||
|
results: [
|
||||||
|
{ id: 1, name: 'Document Type 1' },
|
||||||
|
{ id: 2, name: 'Document Type 2' },
|
||||||
|
],
|
||||||
|
count: 2,
|
||||||
|
all: [1, 2],
|
||||||
|
}
|
||||||
|
|
||||||
|
const modalInstance = {
|
||||||
|
componentInstance: {
|
||||||
|
dialogMode: EditDialogMode.CREATE,
|
||||||
|
object: { name },
|
||||||
|
succeeded: of(newDocumentType),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
const documentTypesListAllSpy = jest.spyOn(documentTypeService, 'listAll')
|
||||||
|
documentTypesListAllSpy.mockReturnValue(of(documentTypes))
|
||||||
|
|
||||||
|
const documentTypeSelectionModelToggleSpy = jest.spyOn(
|
||||||
|
component.documentTypeSelectionModel,
|
||||||
|
'toggle'
|
||||||
|
)
|
||||||
|
|
||||||
|
const modalServiceOpenSpy = jest.spyOn(modalService, 'open')
|
||||||
|
modalServiceOpenSpy.mockReturnValue(modalInstance as any)
|
||||||
|
|
||||||
|
component.createDocumentType(name)
|
||||||
|
|
||||||
|
expect(modalServiceOpenSpy).toHaveBeenCalledWith(
|
||||||
|
DocumentTypeEditDialogComponent,
|
||||||
|
{ backdrop: 'static' }
|
||||||
|
)
|
||||||
|
expect(documentTypesListAllSpy).toHaveBeenCalled()
|
||||||
|
|
||||||
|
expect(documentTypeSelectionModelToggleSpy).toHaveBeenCalledWith(
|
||||||
|
newDocumentType.id
|
||||||
|
)
|
||||||
|
expect(component.documentTypes).toEqual(documentTypes.results)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should support create new storage path', () => {
|
||||||
|
const name = 'New Storage Path'
|
||||||
|
const newStoragePath = { id: 101, name: 'New Storage Path' }
|
||||||
|
const storagePaths: Results<StoragePath> = {
|
||||||
|
results: [
|
||||||
|
{ id: 1, name: 'Storage Path 1' },
|
||||||
|
{ id: 2, name: 'Storage Path 2' },
|
||||||
|
],
|
||||||
|
count: 2,
|
||||||
|
all: [1, 2],
|
||||||
|
}
|
||||||
|
|
||||||
|
const modalInstance = {
|
||||||
|
componentInstance: {
|
||||||
|
dialogMode: EditDialogMode.CREATE,
|
||||||
|
object: { name },
|
||||||
|
succeeded: of(newStoragePath),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
const storagePathsListAllSpy = jest.spyOn(storagePathService, 'listAll')
|
||||||
|
storagePathsListAllSpy.mockReturnValue(of(storagePaths))
|
||||||
|
|
||||||
|
const storagePathsSelectionModelToggleSpy = jest.spyOn(
|
||||||
|
component.storagePathsSelectionModel,
|
||||||
|
'toggle'
|
||||||
|
)
|
||||||
|
|
||||||
|
const modalServiceOpenSpy = jest.spyOn(modalService, 'open')
|
||||||
|
modalServiceOpenSpy.mockReturnValue(modalInstance as any)
|
||||||
|
|
||||||
|
component.createStoragePath(name)
|
||||||
|
|
||||||
|
expect(modalServiceOpenSpy).toHaveBeenCalledWith(
|
||||||
|
StoragePathEditDialogComponent,
|
||||||
|
{ backdrop: 'static' }
|
||||||
|
)
|
||||||
|
expect(storagePathsListAllSpy).toHaveBeenCalled()
|
||||||
|
|
||||||
|
expect(storagePathsSelectionModelToggleSpy).toHaveBeenCalledWith(
|
||||||
|
newStoragePath.id
|
||||||
|
)
|
||||||
|
expect(component.storagePaths).toEqual(storagePaths.results)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
@@ -33,7 +33,12 @@ import {
|
|||||||
PermissionType,
|
PermissionType,
|
||||||
} from 'src/app/services/permissions.service'
|
} from 'src/app/services/permissions.service'
|
||||||
import { FormControl, FormGroup } from '@angular/forms'
|
import { FormControl, FormGroup } from '@angular/forms'
|
||||||
import { first, Subject, takeUntil } from 'rxjs'
|
import { first, map, Subject, switchMap, takeUntil } from 'rxjs'
|
||||||
|
import { CorrespondentEditDialogComponent } from '../../common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component'
|
||||||
|
import { EditDialogMode } from '../../common/edit-dialog/edit-dialog.component'
|
||||||
|
import { TagEditDialogComponent } from '../../common/edit-dialog/tag-edit-dialog/tag-edit-dialog.component'
|
||||||
|
import { DocumentTypeEditDialogComponent } from '../../common/edit-dialog/document-type-edit-dialog/document-type-edit-dialog.component'
|
||||||
|
import { StoragePathEditDialogComponent } from '../../common/edit-dialog/storage-path-edit-dialog/storage-path-edit-dialog.component'
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'pngx-bulk-editor',
|
selector: 'pngx-bulk-editor',
|
||||||
@@ -115,22 +120,50 @@ export class BulkEditorComponent
|
|||||||
}
|
}
|
||||||
|
|
||||||
ngOnInit() {
|
ngOnInit() {
|
||||||
this.tagService
|
if (
|
||||||
.listAll()
|
this.permissionService.currentUserCan(
|
||||||
.pipe(first())
|
PermissionAction.View,
|
||||||
.subscribe((result) => (this.tags = result.results))
|
PermissionType.Tag
|
||||||
this.correspondentService
|
)
|
||||||
.listAll()
|
) {
|
||||||
.pipe(first())
|
this.tagService
|
||||||
.subscribe((result) => (this.correspondents = result.results))
|
.listAll()
|
||||||
this.documentTypeService
|
.pipe(first())
|
||||||
.listAll()
|
.subscribe((result) => (this.tags = result.results))
|
||||||
.pipe(first())
|
}
|
||||||
.subscribe((result) => (this.documentTypes = result.results))
|
if (
|
||||||
this.storagePathService
|
this.permissionService.currentUserCan(
|
||||||
.listAll()
|
PermissionAction.View,
|
||||||
.pipe(first())
|
PermissionType.Correspondent
|
||||||
.subscribe((result) => (this.storagePaths = result.results))
|
)
|
||||||
|
) {
|
||||||
|
this.correspondentService
|
||||||
|
.listAll()
|
||||||
|
.pipe(first())
|
||||||
|
.subscribe((result) => (this.correspondents = result.results))
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
this.permissionService.currentUserCan(
|
||||||
|
PermissionAction.View,
|
||||||
|
PermissionType.DocumentType
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
this.documentTypeService
|
||||||
|
.listAll()
|
||||||
|
.pipe(first())
|
||||||
|
.subscribe((result) => (this.documentTypes = result.results))
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
this.permissionService.currentUserCan(
|
||||||
|
PermissionAction.View,
|
||||||
|
PermissionType.StoragePath
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
this.storagePathService
|
||||||
|
.listAll()
|
||||||
|
.pipe(first())
|
||||||
|
.subscribe((result) => (this.storagePaths = result.results))
|
||||||
|
}
|
||||||
|
|
||||||
this.downloadForm
|
this.downloadForm
|
||||||
.get('downloadFileTypeArchive')
|
.get('downloadFileTypeArchive')
|
||||||
@@ -451,6 +484,92 @@ export class BulkEditorComponent
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
createTag(name: string) {
|
||||||
|
let modal = this.modalService.open(TagEditDialogComponent, {
|
||||||
|
backdrop: 'static',
|
||||||
|
})
|
||||||
|
modal.componentInstance.dialogMode = EditDialogMode.CREATE
|
||||||
|
modal.componentInstance.object = { name }
|
||||||
|
modal.componentInstance.succeeded
|
||||||
|
.pipe(
|
||||||
|
switchMap((newTag) => {
|
||||||
|
return this.tagService
|
||||||
|
.listAll()
|
||||||
|
.pipe(map((tags) => ({ newTag, tags })))
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||||
|
.subscribe(({ newTag, tags }) => {
|
||||||
|
this.tags = tags.results
|
||||||
|
this.tagSelectionModel.toggle(newTag.id)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
createCorrespondent(name: string) {
|
||||||
|
let modal = this.modalService.open(CorrespondentEditDialogComponent, {
|
||||||
|
backdrop: 'static',
|
||||||
|
})
|
||||||
|
modal.componentInstance.dialogMode = EditDialogMode.CREATE
|
||||||
|
modal.componentInstance.object = { name }
|
||||||
|
modal.componentInstance.succeeded
|
||||||
|
.pipe(
|
||||||
|
switchMap((newCorrespondent) => {
|
||||||
|
return this.correspondentService
|
||||||
|
.listAll()
|
||||||
|
.pipe(
|
||||||
|
map((correspondents) => ({ newCorrespondent, correspondents }))
|
||||||
|
)
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||||
|
.subscribe(({ newCorrespondent, correspondents }) => {
|
||||||
|
this.correspondents = correspondents.results
|
||||||
|
this.correspondentSelectionModel.toggle(newCorrespondent.id)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
createDocumentType(name: string) {
|
||||||
|
let modal = this.modalService.open(DocumentTypeEditDialogComponent, {
|
||||||
|
backdrop: 'static',
|
||||||
|
})
|
||||||
|
modal.componentInstance.dialogMode = EditDialogMode.CREATE
|
||||||
|
modal.componentInstance.object = { name }
|
||||||
|
modal.componentInstance.succeeded
|
||||||
|
.pipe(
|
||||||
|
switchMap((newDocumentType) => {
|
||||||
|
return this.documentTypeService
|
||||||
|
.listAll()
|
||||||
|
.pipe(map((documentTypes) => ({ newDocumentType, documentTypes })))
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||||
|
.subscribe(({ newDocumentType, documentTypes }) => {
|
||||||
|
this.documentTypes = documentTypes.results
|
||||||
|
this.documentTypeSelectionModel.toggle(newDocumentType.id)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
createStoragePath(name: string) {
|
||||||
|
let modal = this.modalService.open(StoragePathEditDialogComponent, {
|
||||||
|
backdrop: 'static',
|
||||||
|
})
|
||||||
|
modal.componentInstance.dialogMode = EditDialogMode.CREATE
|
||||||
|
modal.componentInstance.object = { name }
|
||||||
|
modal.componentInstance.succeeded
|
||||||
|
.pipe(
|
||||||
|
switchMap((newStoragePath) => {
|
||||||
|
return this.storagePathService
|
||||||
|
.listAll()
|
||||||
|
.pipe(map((storagePaths) => ({ newStoragePath, storagePaths })))
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||||
|
.subscribe(({ newStoragePath, storagePaths }) => {
|
||||||
|
this.storagePaths = storagePaths.results
|
||||||
|
this.storagePathsSelectionModel.toggle(newStoragePath.id)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
applyDelete() {
|
applyDelete() {
|
||||||
let modal = this.modalService.open(ConfirmDialogComponent, {
|
let modal = this.modalService.open(ConfirmDialogComponent, {
|
||||||
backdrop: 'static',
|
backdrop: 'static',
|
||||||
@@ -512,9 +631,14 @@ export class BulkEditorComponent
|
|||||||
let modal = this.modalService.open(PermissionsDialogComponent, {
|
let modal = this.modalService.open(PermissionsDialogComponent, {
|
||||||
backdrop: 'static',
|
backdrop: 'static',
|
||||||
})
|
})
|
||||||
modal.componentInstance.confirmClicked.subscribe((permissions) => {
|
modal.componentInstance.confirmClicked.subscribe(
|
||||||
modal.componentInstance.buttonsEnabled = false
|
({ permissions, merge }) => {
|
||||||
this.executeBulkOperation(modal, 'set_permissions', permissions)
|
modal.componentInstance.buttonsEnabled = false
|
||||||
})
|
this.executeBulkOperation(modal, 'set_permissions', {
|
||||||
|
...permissions,
|
||||||
|
merge,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@@ -25,7 +25,7 @@
|
|||||||
@if (notesEnabled && document.notes.length) {
|
@if (notesEnabled && document.notes.length) {
|
||||||
<a routerLink="/documents/{{document.id}}/notes" class="document-card-notes py-2 px-1">
|
<a routerLink="/documents/{{document.id}}/notes" class="document-card-notes py-2 px-1">
|
||||||
<span class="badge rounded-pill bg-light border text-primary">
|
<span class="badge rounded-pill bg-light border text-primary">
|
||||||
<i-bs width="0.9rem" height="0.9rem" class="ms-1 me-1" name="chat-left-text"></i-bs>
|
<i-bs width="1.2em" height="1.2em" class="ms-1 me-1" name="chat-left-text"></i-bs>
|
||||||
{{document.notes.length}}</span>
|
{{document.notes.length}}</span>
|
||||||
</a>
|
</a>
|
||||||
}
|
}
|
||||||
@@ -43,14 +43,14 @@
|
|||||||
@if (document.document_type) {
|
@if (document.document_type) {
|
||||||
<button type="button" class="list-group-item list-group-item-action bg-transparent ps-0 p-1 border-0" title="Toggle document type filter" i18n-title
|
<button type="button" class="list-group-item list-group-item-action bg-transparent ps-0 p-1 border-0" title="Toggle document type filter" i18n-title
|
||||||
(click)="clickDocumentType.emit(document.document_type);$event.stopPropagation()">
|
(click)="clickDocumentType.emit(document.document_type);$event.stopPropagation()">
|
||||||
<i-bs width="0.9rem" height="0.9rem" class="me-2 text-muted" name="file-earmark"></i-bs>
|
<i-bs width="1em" height="1em" class="me-2 text-muted" name="file-earmark"></i-bs>
|
||||||
<small>{{(document.document_type$ | async)?.name ?? privateName}}</small>
|
<small>{{(document.document_type$ | async)?.name ?? privateName}}</small>
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
@if (document.storage_path) {
|
@if (document.storage_path) {
|
||||||
<button type="button" class="list-group-item list-group-item-action bg-transparent ps-0 p-1 border-0" title="Toggle storage path filter" i18n-title
|
<button type="button" class="list-group-item list-group-item-action bg-transparent ps-0 p-1 border-0" title="Toggle storage path filter" i18n-title
|
||||||
(click)="clickStoragePath.emit(document.storage_path);$event.stopPropagation()">
|
(click)="clickStoragePath.emit(document.storage_path);$event.stopPropagation()">
|
||||||
<i-bs width="0.9rem" height="0.9rem" class="me-2 text-muted" name="folder"></i-bs>
|
<i-bs width="1em" height="1em" class="me-2 text-muted" name="folder"></i-bs>
|
||||||
<small>{{(document.storage_path$ | async)?.name ?? privateName}}</small>
|
<small>{{(document.storage_path$ | async)?.name ?? privateName}}</small>
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
@@ -63,25 +63,25 @@
|
|||||||
</div>
|
</div>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
<div class="ps-0 p-1" placement="top" [ngbTooltip]="dateTooltip">
|
<div class="ps-0 p-1" placement="top" [ngbTooltip]="dateTooltip">
|
||||||
<i-bs width="0.9rem" height="0.9rem" class="me-2 text-muted" name="calendar-event"></i-bs>
|
<i-bs width="1em" height="1em" class="me-2 text-muted" name="calendar-event"></i-bs>
|
||||||
<small>{{document.created_date | customDate:'mediumDate'}}</small>
|
<small>{{document.created_date | customDate:'mediumDate'}}</small>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@if (document.archive_serial_number | isNumber) {
|
@if (document.archive_serial_number | isNumber) {
|
||||||
<div class="ps-0 p-1">
|
<div class="ps-0 p-1">
|
||||||
<i-bs width="0.9rem" height="0.9rem" class="me-2 text-muted" name="upc-scan"></i-bs>
|
<i-bs width="1em" height="1em" class="me-2 text-muted" name="upc-scan"></i-bs>
|
||||||
<small>#{{document.archive_serial_number}}</small>
|
<small>#{{document.archive_serial_number}}</small>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@if (document.owner && document.owner !== settingsService.currentUser.id) {
|
@if (document.owner && document.owner !== settingsService.currentUser.id) {
|
||||||
<div class="ps-0 p-1">
|
<div class="ps-0 p-1">
|
||||||
<i-bs width="0.9rem" height="0.9rem" class="me-2 text-muted" name="person-fill-lock"></i-bs>
|
<i-bs width="1em" height="1em" class="me-2 text-muted" name="person-fill-lock"></i-bs>
|
||||||
<small>{{document.owner | username}}</small>
|
<small>{{document.owner | username}}</small>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@if (document.is_shared_by_requester) {
|
@if (document.is_shared_by_requester) {
|
||||||
<div class="ps-0 p-1">
|
<div class="ps-0 p-1">
|
||||||
<i-bs width="0.9rem" height="0.9rem" class="me-2 text-muted" name="people-fill"></i-bs>
|
<i-bs width="1em" height="1em" class="me-2 text-muted" name="people-fill"></i-bs>
|
||||||
<small i18n>Shared</small>
|
<small i18n>Shared</small>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
|
@@ -79,7 +79,7 @@ export class DocumentCardSmallComponent extends ComponentWithPermissions {
|
|||||||
|
|
||||||
getTagsLimited$() {
|
getTagsLimited$() {
|
||||||
const limit = this.document.notes.length > 0 ? 6 : 7
|
const limit = this.document.notes.length > 0 ? 6 : 7
|
||||||
return this.document.tags$.pipe(
|
return this.document.tags$?.pipe(
|
||||||
map((tags) => {
|
map((tags) => {
|
||||||
if (tags.length > limit) {
|
if (tags.length > limit) {
|
||||||
this.moreTags = tags.length - (limit - 1)
|
this.moreTags = tags.length - (limit - 1)
|
||||||
|
@@ -1,6 +1,6 @@
|
|||||||
<pngx-page-header [title]="getTitle()">
|
<pngx-page-header [title]="getTitle()">
|
||||||
|
|
||||||
<div ngbDropdown class="me-2 d-flex">
|
<div ngbDropdown class="d-flex">
|
||||||
<button class="btn btn-sm btn-outline-primary" id="dropdownSelect" ngbDropdownToggle>
|
<button class="btn btn-sm btn-outline-primary" id="dropdownSelect" ngbDropdownToggle>
|
||||||
<i-bs name="text-indent-left"></i-bs>
|
<i-bs name="text-indent-left"></i-bs>
|
||||||
<div class="d-none d-sm-inline"> <ng-container i18n>Select</ng-container></div>
|
<div class="d-none d-sm-inline"> <ng-container i18n>Select</ng-container></div>
|
||||||
@@ -26,7 +26,7 @@
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div ngbDropdown class="btn-group ms-2 flex-fill">
|
<div ngbDropdown class="btn-group flex-fill">
|
||||||
<button class="btn btn-outline-primary btn-sm" id="dropdownBasic1" ngbDropdownToggle i18n>Sort</button>
|
<button class="btn btn-outline-primary btn-sm" id="dropdownBasic1" ngbDropdownToggle i18n>Sort</button>
|
||||||
<div ngbDropdownMenu aria-labelledby="dropdownBasic1" class="shadow dropdown-menu-right">
|
<div ngbDropdownMenu aria-labelledby="dropdownBasic1" class="shadow dropdown-menu-right">
|
||||||
<div class="w-100 d-flex pb-2 mb-1 border-bottom">
|
<div class="w-100 d-flex pb-2 mb-1 border-bottom">
|
||||||
@@ -49,7 +49,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="btn-group ms-2 flex-fill" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.SavedView }" ngbDropdown role="group">
|
<div class="btn-group flex-fill" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.SavedView }" ngbDropdown role="group">
|
||||||
<button class="btn btn-sm btn-outline-primary dropdown-toggle flex-fill" tourAnchor="tour.documents-views" ngbDropdownToggle>
|
<button class="btn btn-sm btn-outline-primary dropdown-toggle flex-fill" tourAnchor="tour.documents-views" ngbDropdownToggle>
|
||||||
<ng-container i18n>Views</ng-container>
|
<ng-container i18n>Views</ng-container>
|
||||||
@if (savedViewIsModified) {
|
@if (savedViewIsModified) {
|
||||||
@@ -232,7 +232,7 @@
|
|||||||
@if (d.notes.length) {
|
@if (d.notes.length) {
|
||||||
<a routerLink="/documents/{{d.id}}/notes" class="btn btn-sm p-0">
|
<a routerLink="/documents/{{d.id}}/notes" class="btn btn-sm p-0">
|
||||||
<span class="badge rounded-pill bg-light border text-primary">
|
<span class="badge rounded-pill bg-light border text-primary">
|
||||||
<i-bs width="0.9rem" height="0.9rem" class="ms-1 me-1" name="chat-left-text"></i-bs>
|
<i-bs width="1.2em" height="1.2em" class="ms-1 me-1" name="chat-left-text"></i-bs>
|
||||||
{{d.notes.length}}</span>
|
{{d.notes.length}}</span>
|
||||||
</a>
|
</a>
|
||||||
}
|
}
|
||||||
|
@@ -18,7 +18,7 @@
|
|||||||
</select>
|
</select>
|
||||||
}
|
}
|
||||||
@if (_textFilter) {
|
@if (_textFilter) {
|
||||||
<button class="btn btn-link btn-sm px-0 position-absolute top-0 end-0 z-10" (click)="resetTextField()">
|
<button class="btn btn-link btn-sm px-2 position-absolute top-0 end-0 z-10" (click)="resetTextField()">
|
||||||
<i-bs width="1em" height="1em" name="x"></i-bs>
|
<i-bs width="1em" height="1em" name="x"></i-bs>
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
@@ -29,7 +29,8 @@
|
|||||||
<div class="col-auto">
|
<div class="col-auto">
|
||||||
<div class="d-flex flex-wrap gap-3">
|
<div class="d-flex flex-wrap gap-3">
|
||||||
<div class="d-flex flex-wrap gap-2">
|
<div class="d-flex flex-wrap gap-2">
|
||||||
<pngx-filterable-dropdown class="flex-fill" title="Tags" icon="tag-fill" i18n-title
|
@if (permissionsService.currentUserCan(PermissionAction.View, PermissionType.Tag)) {
|
||||||
|
<pngx-filterable-dropdown class="flex-fill" title="Tags" icon="tag-fill" i18n-title
|
||||||
filterPlaceholder="Filter tags" i18n-filterPlaceholder
|
filterPlaceholder="Filter tags" i18n-filterPlaceholder
|
||||||
[items]="tags"
|
[items]="tags"
|
||||||
[manyToOne]="true"
|
[manyToOne]="true"
|
||||||
@@ -37,31 +38,38 @@
|
|||||||
(selectionModelChange)="updateRules()"
|
(selectionModelChange)="updateRules()"
|
||||||
(opened)="onTagsDropdownOpen()"
|
(opened)="onTagsDropdownOpen()"
|
||||||
[documentCounts]="tagDocumentCounts"
|
[documentCounts]="tagDocumentCounts"
|
||||||
[allowSelectNone]="true"></pngx-filterable-dropdown>
|
[allowSelectNone]="true"></pngx-filterable-dropdown>
|
||||||
<pngx-filterable-dropdown class="flex-fill" title="Correspondent" icon="person-fill" i18n-title
|
}
|
||||||
|
@if (permissionsService.currentUserCan(PermissionAction.View, PermissionType.Correspondent)) {
|
||||||
|
<pngx-filterable-dropdown class="flex-fill" title="Correspondent" icon="person-fill" i18n-title
|
||||||
filterPlaceholder="Filter correspondents" i18n-filterPlaceholder
|
filterPlaceholder="Filter correspondents" i18n-filterPlaceholder
|
||||||
[items]="correspondents"
|
[items]="correspondents"
|
||||||
[(selectionModel)]="correspondentSelectionModel"
|
[(selectionModel)]="correspondentSelectionModel"
|
||||||
(selectionModelChange)="updateRules()"
|
(selectionModelChange)="updateRules()"
|
||||||
(opened)="onCorrespondentDropdownOpen()"
|
(opened)="onCorrespondentDropdownOpen()"
|
||||||
[documentCounts]="correspondentDocumentCounts"
|
[documentCounts]="correspondentDocumentCounts"
|
||||||
[allowSelectNone]="true"></pngx-filterable-dropdown>
|
[allowSelectNone]="true"></pngx-filterable-dropdown>
|
||||||
<pngx-filterable-dropdown class="flex-fill" title="Document type" icon="file-earmark-fill" i18n-title
|
}
|
||||||
filterPlaceholder="Filter document types" i18n-filterPlaceholder
|
@if (permissionsService.currentUserCan(PermissionAction.View, PermissionType.DocumentType)) {
|
||||||
[items]="documentTypes"
|
<pngx-filterable-dropdown class="flex-fill" title="Document type" icon="file-earmark-fill" i18n-title
|
||||||
[(selectionModel)]="documentTypeSelectionModel"
|
filterPlaceholder="Filter document types" i18n-filterPlaceholder
|
||||||
(selectionModelChange)="updateRules()"
|
[items]="documentTypes"
|
||||||
(opened)="onDocumentTypeDropdownOpen()"
|
[(selectionModel)]="documentTypeSelectionModel"
|
||||||
[documentCounts]="documentTypeDocumentCounts"
|
(selectionModelChange)="updateRules()"
|
||||||
[allowSelectNone]="true"></pngx-filterable-dropdown>
|
(opened)="onDocumentTypeDropdownOpen()"
|
||||||
<pngx-filterable-dropdown class="flex-fill" title="Storage path" icon="folder-fill" i18n-title
|
[documentCounts]="documentTypeDocumentCounts"
|
||||||
|
[allowSelectNone]="true"></pngx-filterable-dropdown>
|
||||||
|
}
|
||||||
|
@if (permissionsService.currentUserCan(PermissionAction.View, PermissionType.StoragePath)) {
|
||||||
|
<pngx-filterable-dropdown class="flex-fill" title="Storage path" icon="folder-fill" i18n-title
|
||||||
filterPlaceholder="Filter storage paths" i18n-filterPlaceholder
|
filterPlaceholder="Filter storage paths" i18n-filterPlaceholder
|
||||||
[items]="storagePaths"
|
[items]="storagePaths"
|
||||||
[(selectionModel)]="storagePathSelectionModel"
|
[(selectionModel)]="storagePathSelectionModel"
|
||||||
(selectionModelChange)="updateRules()"
|
(selectionModelChange)="updateRules()"
|
||||||
(opened)="onStoragePathDropdownOpen()"
|
(opened)="onStoragePathDropdownOpen()"
|
||||||
[documentCounts]="storagePathDocumentCounts"
|
[documentCounts]="storagePathDocumentCounts"
|
||||||
[allowSelectNone]="true"></pngx-filterable-dropdown>
|
[allowSelectNone]="true"></pngx-filterable-dropdown>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
<div class="d-flex flex-wrap gap-2">
|
<div class="d-flex flex-wrap gap-2">
|
||||||
<pngx-date-dropdown
|
<pngx-date-dropdown
|
||||||
|
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user