mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-08-20 09:43:29 +00:00
Compare commits
149
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
01a0880e6f | ||
|
|
ac5a39a3d4 | ||
|
|
a424dace43 | ||
|
|
751299895e | ||
|
|
5f9bc5de88 | ||
|
|
f1c8a72f26 | ||
|
|
fd3c525f03 | ||
|
|
e389298aab | ||
|
|
c5c5cc0b1d | ||
|
|
b17a512539 | ||
|
|
4cf027de40 | ||
|
|
41953c7846 | ||
|
|
e5abe5cf32 | ||
|
|
643d0205bc | ||
|
|
2865a095b0 | ||
|
|
f0eb77c405 | ||
|
|
e5cc7c6c40 | ||
|
|
f2806179a2 | ||
|
|
36c736a9f9 | ||
|
|
be35079d54 | ||
|
|
a75465b734 | ||
|
|
0af1e41753 | ||
|
|
d88b281eb2 | ||
|
|
8446036777 | ||
|
|
8ad5e8cca3 | ||
|
|
1606a46b53 | ||
|
|
f647f304da | ||
|
|
31746371f4 | ||
|
|
0e5fbc973a | ||
|
|
3322c92837 | ||
|
|
db15c82804 | ||
|
|
fe5d09a123 | ||
|
|
a0feb827c9 | ||
|
|
b599b13f72 | ||
|
|
01c12d9ea4 | ||
|
|
f5c0d118f7 | ||
|
|
ff13847d0a | ||
|
|
634f803872 | ||
|
|
639d566a7c | ||
|
|
0a94f8f0d4 | ||
|
|
2a8579f610 | ||
|
|
4789fe9a52 | ||
|
|
e150c8c7c0 | ||
|
|
879cd4a30a | ||
|
|
6a02b87dde | ||
|
|
59a2651804 | ||
|
|
a99f63e059 | ||
|
|
939cb52f6e | ||
|
|
855669ddf9 | ||
|
|
62089df2d8 | ||
|
|
5e5f6a88a3 | ||
|
|
02e6c49c62 | ||
|
|
3be64da4cb | ||
|
|
c28c532bef | ||
|
|
1d61f7fc62 | ||
|
|
aa67fd3aef | ||
|
|
17dc482872 | ||
|
|
b0e0e8a353 | ||
|
|
fc242bb570 | ||
|
|
b192a419fd | ||
|
|
3986150f95 | ||
|
|
ee5588ade3 | ||
|
|
2635a12281 | ||
|
|
1f396e51f3 | ||
|
|
3eb784b34d | ||
|
|
cb03a0b33e | ||
|
|
a96d0b15b8 | ||
|
|
376b61938f | ||
|
|
d1f5eb0335 | ||
|
|
d283205f57 | ||
|
|
42908ad2b9 | ||
|
|
db6842e710 | ||
|
|
d5f8cd59fb | ||
|
|
91d6741b4f | ||
|
|
972758fc72 | ||
|
|
15d9829b6d | ||
|
|
5ad34dfe03 | ||
|
|
52a0484f74 | ||
|
|
71e2f86f70 | ||
|
|
1824e5fafd | ||
|
|
7b9e56ef22 | ||
|
|
2b9bed749d | ||
|
|
ef49414162 | ||
|
|
a488ba6f90 | ||
|
|
47727de116 | ||
|
|
e86adb6c36 | ||
|
|
74aa10f31f | ||
|
|
2ea4a792fe | ||
|
|
3f5c770af4 | ||
|
|
765313926f | ||
|
|
1f1725ba56 | ||
|
|
c6cecd3c4e | ||
|
|
5e54259db9 | ||
|
|
3a484a3ff2 | ||
|
|
92a1b24583 | ||
|
|
8709dd807b | ||
|
|
5a5a0ffd21 | ||
|
|
d7aa0cb862 | ||
|
|
eafb494066 | ||
|
|
4ca1863dee | ||
|
|
d5d22c2c0f | ||
|
|
991d99bb7c | ||
|
|
30f11814ff | ||
|
|
0ca5c66ca7 | ||
|
|
dd99c2289d | ||
|
|
23c11f49d0 | ||
|
|
cfa1d3b058 | ||
|
|
68bd8f8f63 | ||
|
|
41d5d86637 | ||
|
|
2a50425902 | ||
|
|
b67ac5a7d7 | ||
|
|
b48c434266 | ||
|
|
92baa89638 | ||
|
|
4c7b3dd307 | ||
|
|
fe48752a6f | ||
|
|
4692de5595 | ||
|
|
141d74464b | ||
|
|
c05d069e97 | ||
|
|
f5e47a86c3 | ||
|
|
fe196eaf0e | ||
|
|
7fda0e877b | ||
|
|
5b8f4db1ca | ||
|
|
08ab98f3fc | ||
|
|
8fb73b2709 | ||
|
|
b8ab8327b0 | ||
|
|
3a0ca03544 | ||
|
|
882dfa78d2 | ||
|
|
a5645392a5 | ||
|
|
bbdf2cbd06 | ||
|
|
0bbe180f63 | ||
|
|
e35452beeb | ||
|
|
68a769b40c | ||
|
|
e5b27df99c | ||
|
|
95a48dd685 | ||
|
|
aa938de747 | ||
|
|
486b4babac | ||
|
|
adf1dd4e05 | ||
|
|
3d57f4396f | ||
|
|
752c949009 | ||
|
|
e37ef5aa71 | ||
|
|
919ffdd794 | ||
|
|
30fe172847 | ||
|
|
e79f0d4106 | ||
|
|
c29c9178f0 | ||
|
|
baae99eb49 | ||
|
|
f50a021ef6 | ||
|
|
c8e00dca04 | ||
|
|
0a56018d6b | ||
|
|
350684cd6b |
@@ -1,187 +0,0 @@
|
|||||||
---
|
|
||||||
name: paperless-benchmarking
|
|
||||||
description: Use when profiling paperless-ngx performance, running `manage.py benchmark`, investigating a slow query or endpoint, or deciding whether a profiling finding should become a permanent registered scenario. Covers command reference, the fork/merge-back branch workflow for perf investigations, and how to read query-plan output.
|
|
||||||
---
|
|
||||||
|
|
||||||
# Paperless-ngx Benchmarking
|
|
||||||
|
|
||||||
This repo has a built-in benchmarking/profiling tool: `manage.py benchmark`, in
|
|
||||||
the `paperless_benchmark` Django app. It replaces ad hoc standalone scripts —
|
|
||||||
use it instead of writing new one-off seed/timing scripts.
|
|
||||||
|
|
||||||
## Command reference
|
|
||||||
|
|
||||||
```
|
|
||||||
manage.py benchmark seed --tier {home,medium,large} [--reset --yes-i-know-this-wipes-the-database] [--seed N]
|
|
||||||
manage.py benchmark run --repeat 5 [--label baseline]
|
|
||||||
manage.py benchmark profile <scenario_name> [--repeat 5] [--explain]
|
|
||||||
manage.py benchmark list-scenarios
|
|
||||||
```
|
|
||||||
|
|
||||||
- **`seed`** builds a realistic dataset at one of three scales: `home` (500
|
|
||||||
documents — fast, use this for iteration), `medium` (20,000 — the default
|
|
||||||
when `--tier` is omitted; a multi-minute seed), `large` (360,000 — matches
|
|
||||||
the scale reported in real large-install bug reports; slow, only use it
|
|
||||||
when a finding needs confirming at real scale). `--reset` wipes any
|
|
||||||
previously-seeded benchmark data first — but it is destructive and
|
|
||||||
irreversible: it deletes **all** users, **all** groups, and **all**
|
|
||||||
documents/tags/correspondents/document types/storage paths in the target
|
|
||||||
database, not just benchmark-created rows. Only run it against a disposable
|
|
||||||
benchmark database, never a real install. Because of that, `--reset` also
|
|
||||||
requires passing `--yes-i-know-this-wipes-the-database` in the same
|
|
||||||
invocation, or the command raises an error and does nothing. Omit both
|
|
||||||
flags if you want to layer more data onto an existing seed instead. `seed`
|
|
||||||
creates two named users, `perf_target` (mixed owned/shared documents,
|
|
||||||
realistic guardian permission grants) and `perf_admin` (superuser), plus a
|
|
||||||
general user/group pool with realistic permission-row ratios.
|
|
||||||
- **`run`** times the 3 built-in API endpoint benchmarks
|
|
||||||
(`/api/documents/`, `/api/documents/?page_size=50`, `/api/tags/?page_size=100000`) for both
|
|
||||||
`perf_target` and `perf_admin`, reporting min/median/max wall-clock and SQL
|
|
||||||
query count. Requires `seed` to have already run — it reuses that data, it
|
|
||||||
does not seed its own.
|
|
||||||
- **`profile`** times one named scenario from the registry (see
|
|
||||||
`list-scenarios`) via best-of-N repeat timing and SQL query count, against
|
|
||||||
`perf_target`. `--explain` additionally captures and prints the query plan:
|
|
||||||
real `EXPLAIN ANALYZE` execution stats on PostgreSQL/MariaDB, or
|
|
||||||
`EXPLAIN QUERY PLAN` (plan only, no real timing/row counts — clearly labeled
|
|
||||||
as such) on SQLite. Like `run`, `profile` requires `seed` to have already
|
|
||||||
run — it does not seed its own data either.
|
|
||||||
- Every `run`/`profile` invocation appends a JSON line to
|
|
||||||
`benchmark_results/history.jsonl` at the repo root (local-only, gitignored —
|
|
||||||
never commit this file). Use it to compare a `before`/`after` pair across
|
|
||||||
two invocations without hand-copying numbers.
|
|
||||||
- Full chain example: `seed --reset` once, then `run` and `profile` as many
|
|
||||||
times as you want against that same seeded data — no need to reseed between
|
|
||||||
them.
|
|
||||||
|
|
||||||
## Adding a new scenario
|
|
||||||
|
|
||||||
A "scenario" is a named, registered query/operation that `profile` can time
|
|
||||||
and explain. To add one, edit `src/paperless_benchmark/scenarios.py`: write a
|
|
||||||
`_<name>_run(user)` function (returns whatever `run_profile` should time) and
|
|
||||||
optionally a `_<name>_queryset(user)` function (returns the `QuerySet` for
|
|
||||||
`--explain` to analyze), then `register(Scenario(name=..., describe=...,
|
|
||||||
run=..., queryset_for_explain=...))` at module level. Both functions receive
|
|
||||||
the already-seeded `perf_target` user — they should not seed their own data.
|
|
||||||
|
|
||||||
## Branch workflow
|
|
||||||
|
|
||||||
`tools/benchmark-management-commands` is a **long-lived tooling branch**, not
|
|
||||||
a feature branch that gets merged and closed:
|
|
||||||
|
|
||||||
1. It is periodically brought up to date with `dev` (merge `dev` into it) so
|
|
||||||
the tooling doesn't drift from the schema/codebase it profiles. Do this
|
|
||||||
before starting a new investigation if it's been a while since the last
|
|
||||||
sync.
|
|
||||||
2. **Every performance investigation forks its own branch from
|
|
||||||
`tools/benchmark-management-commands`** (not from `dev`). Do the
|
|
||||||
investigation there: write throwaway profiling code, try fixes, capture
|
|
||||||
before/after numbers.
|
|
||||||
3. **That investigation branch never merges into `dev` or production.** Its
|
|
||||||
only job is to produce evidence and, optionally, a reusable scenario.
|
|
||||||
4. If the investigation turns up a scenario worth keeping permanently (see
|
|
||||||
"When to graduate a scenario" below), open a PR that adds **just that
|
|
||||||
scenario** back into `tools/benchmark-management-commands` — not the rest
|
|
||||||
of the investigation branch's throwaway code.
|
|
||||||
5. Any actual production fix the investigation motivates (e.g. an ORM query
|
|
||||||
change) goes into its own normal feature branch off `dev`, following the
|
|
||||||
project's regular contribution process — profiling evidence informs that
|
|
||||||
PR's description, but the profiling code itself does not travel with it.
|
|
||||||
|
|
||||||
## Reading query-plan output
|
|
||||||
|
|
||||||
- **PostgreSQL** `EXPLAIN ANALYZE`: look for `Seq Scan` on a large table
|
|
||||||
(missing index), a large gap between `rows=N` (planner's estimate) and the
|
|
||||||
actual row count in parentheses (stale statistics or a bad cardinality
|
|
||||||
estimate), and nested-loop joins driven by an outer relation with many
|
|
||||||
rows (usually the N+1 pattern this tool exists to catch).
|
|
||||||
- **MariaDB**: verified against a real MariaDB 12.3 container that MariaDB
|
|
||||||
does NOT accept MySQL 8.0.18+'s `EXPLAIN ANALYZE` syntax (it's a 1064
|
|
||||||
syntax error) -- `capture_explain()` instead runs MariaDB's own
|
|
||||||
`ANALYZE <statement>` form (no `EXPLAIN` keyword), which returns a
|
|
||||||
tabular plan with real per-row execution columns: `rows` (estimate) vs.
|
|
||||||
`r_rows` (actual), and `filtered` vs. `r_filtered`. A large gap between
|
|
||||||
`rows` and `r_rows`, or `type: ALL` (full table scan) on a large table,
|
|
||||||
are the signals to look for -- the same underlying concerns as Postgres's
|
|
||||||
`Seq Scan`/estimate-vs-actual gap, just in MariaDB's column-based output
|
|
||||||
instead of Postgres's nested-tree text format.
|
|
||||||
- **SQLite** `EXPLAIN QUERY PLAN`: no real timing/row-count data, only the
|
|
||||||
chosen access path (`SCAN` vs `SEARCH`, which index if any). Useful for
|
|
||||||
confirming an index is even being considered, not for judging real-world
|
|
||||||
cost — corroborate any SQLite finding against Postgres/MariaDB before
|
|
||||||
trusting it, since planner behavior differs meaningfully between them.
|
|
||||||
- Compare query **count**, not just timing, between before/after: a fix that
|
|
||||||
keeps the same wall-clock time but drops query count from O(n) to O(1) is
|
|
||||||
still a real, durable improvement — timing alone is noisy and
|
|
||||||
environment-dependent, query count is not.
|
|
||||||
|
|
||||||
## Cleaning up after an interrupted run
|
|
||||||
|
|
||||||
If a `seed`/`run`/`profile` invocation gets killed mid-run (Ctrl-C, `kill -9`,
|
|
||||||
a timed-out SSH session, etc.), check whether it left anything behind before
|
|
||||||
trusting the next benchmark's numbers. This was verified for real: a
|
|
||||||
`benchmark seed --tier large --reset ...` was started against both a fresh
|
|
||||||
PostgreSQL 18 container and a fresh MariaDB 12.3 container and `kill -9`'d a
|
|
||||||
few seconds into document seeding. In both cases, the database-side
|
|
||||||
connection disappeared immediately -- no stuck backend, no lingering query,
|
|
||||||
no held lock was observed in either backend once the killed process's PID
|
|
||||||
was confirmed gone. That said, this was one interruption point (mid
|
|
||||||
bulk-seed, between chunks); a run killed mid-query, or a driver/network
|
|
||||||
hiccup that doesn't cleanly close the socket, could behave differently, so
|
|
||||||
still check before trusting a number if any run in the session was
|
|
||||||
interrupted:
|
|
||||||
|
|
||||||
- **PostgreSQL**: look for leftover connections against the benchmark
|
|
||||||
database:
|
|
||||||
|
|
||||||
```sql
|
|
||||||
SELECT pid, state, query, query_start
|
|
||||||
FROM pg_stat_activity
|
|
||||||
WHERE datname = current_database() AND pid <> pg_backend_pid();
|
|
||||||
```
|
|
||||||
|
|
||||||
If a stuck backend shows up, clear it with:
|
|
||||||
|
|
||||||
```sql
|
|
||||||
SELECT pg_terminate_backend(pid)
|
|
||||||
FROM pg_stat_activity
|
|
||||||
WHERE datname = current_database() AND pid <> pg_backend_pid();
|
|
||||||
```
|
|
||||||
|
|
||||||
- **MariaDB**: look for leftover connections/queries:
|
|
||||||
|
|
||||||
```sql
|
|
||||||
SHOW FULL PROCESSLIST;
|
|
||||||
```
|
|
||||||
|
|
||||||
If a stuck connection shows up (anything other than your current admin
|
|
||||||
session), clear it with:
|
|
||||||
|
|
||||||
```sql
|
|
||||||
KILL <id>;
|
|
||||||
```
|
|
||||||
|
|
||||||
A stray connection left running concurrently with a subsequent benchmark run
|
|
||||||
would add real, contaminating load (extra queries competing for the same
|
|
||||||
rows, possibly held locks slowing the next run's timings) -- cheap to rule
|
|
||||||
out, expensive to silently trust a number that was actually measured
|
|
||||||
alongside a zombie connection.
|
|
||||||
|
|
||||||
## When to graduate a one-off finding into a permanent scenario
|
|
||||||
|
|
||||||
Register a scenario (rather than leaving it as throwaway code on the
|
|
||||||
investigation branch) when **both** are true:
|
|
||||||
|
|
||||||
- The query pattern is one this codebase is likely to regress on again (e.g.
|
|
||||||
it involves a permission-check join, a bulk operation, or anything else
|
|
||||||
with an easy-to-reintroduce N+1) — not a one-time fluke specific to this
|
|
||||||
investigation.
|
|
||||||
- Re-running it later, against a fresh seed, would still produce a
|
|
||||||
meaningful signal (it doesn't depend on investigation-specific throwaway
|
|
||||||
data or a fix that's already permanently landed and can't regress the same
|
|
||||||
way).
|
|
||||||
|
|
||||||
If a finding doesn't meet both bars, keep it as disposable code on the
|
|
||||||
investigation branch and let the branch's evidence (captured in the PR
|
|
||||||
description of whatever production fix it motivates) be the permanent
|
|
||||||
record instead.
|
|
||||||
+37
-35
@@ -1,49 +1,51 @@
|
|||||||
categories:
|
categories:
|
||||||
|
- type: 'pre-include'
|
||||||
|
when:
|
||||||
|
- label: 'enhancement'
|
||||||
|
- label: 'bug'
|
||||||
|
- label: 'chore'
|
||||||
|
- label: 'deployment'
|
||||||
|
- label: 'translation'
|
||||||
|
- label: 'dependencies'
|
||||||
|
- label: 'documentation'
|
||||||
|
- label: 'frontend'
|
||||||
|
- label: 'backend'
|
||||||
|
- label: 'ci-cd'
|
||||||
|
- label: 'breaking-change'
|
||||||
|
- label: 'notable'
|
||||||
|
- type: 'pre-exclude'
|
||||||
|
when:
|
||||||
|
- label: 'skip-changelog'
|
||||||
- title: 'Breaking Changes'
|
- title: 'Breaking Changes'
|
||||||
labels:
|
when:
|
||||||
- 'breaking-change'
|
- label: 'breaking-change'
|
||||||
- title: 'Notable Changes'
|
- title: 'Notable Changes'
|
||||||
labels:
|
when:
|
||||||
- 'notable'
|
- label: 'notable'
|
||||||
- title: 'Features / Enhancements'
|
- title: 'Features / Enhancements'
|
||||||
labels:
|
when:
|
||||||
- 'enhancement'
|
- label: 'enhancement'
|
||||||
- title: 'Bug Fixes'
|
- title: 'Bug Fixes'
|
||||||
labels:
|
when:
|
||||||
- 'bug'
|
- label: 'bug'
|
||||||
- title: 'Documentation'
|
- title: 'Documentation'
|
||||||
labels:
|
when:
|
||||||
- 'documentation'
|
- label: 'documentation'
|
||||||
- title: 'Maintenance'
|
- title: 'Maintenance'
|
||||||
labels:
|
when:
|
||||||
- 'chore'
|
- label: 'chore'
|
||||||
- 'deployment'
|
- label: 'deployment'
|
||||||
- 'translation'
|
- label: 'translation'
|
||||||
- 'ci-cd'
|
- label: 'ci-cd'
|
||||||
- title: 'Dependencies'
|
- title: 'Dependencies'
|
||||||
collapse-after: 3
|
collapse-after: 3
|
||||||
labels:
|
when:
|
||||||
- 'dependencies'
|
- label: 'dependencies'
|
||||||
- title: 'All App Changes'
|
- title: 'All App Changes'
|
||||||
labels:
|
|
||||||
- 'frontend'
|
|
||||||
- 'backend'
|
|
||||||
collapse-after: 1
|
collapse-after: 1
|
||||||
include-labels:
|
when:
|
||||||
- 'enhancement'
|
- label: 'frontend'
|
||||||
- 'bug'
|
- label: 'backend'
|
||||||
- 'chore'
|
|
||||||
- 'deployment'
|
|
||||||
- 'translation'
|
|
||||||
- 'dependencies'
|
|
||||||
- 'documentation'
|
|
||||||
- 'frontend'
|
|
||||||
- 'backend'
|
|
||||||
- 'ci-cd'
|
|
||||||
- 'breaking-change'
|
|
||||||
- 'notable'
|
|
||||||
exclude-labels:
|
|
||||||
- 'skip-changelog'
|
|
||||||
filter-by-commitish: true
|
filter-by-commitish: true
|
||||||
category-template: '### $TITLE'
|
category-template: '### $TITLE'
|
||||||
change-template: '- $TITLE @$AUTHOR ([#$NUMBER]($URL))'
|
change-template: '- $TITLE @$AUTHOR ([#$NUMBER]($URL))'
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ concurrency:
|
|||||||
group: backend-${{ github.event.pull_request.number || github.ref }}
|
group: backend-${{ github.event.pull_request.number || github.ref }}
|
||||||
cancel-in-progress: true
|
cancel-in-progress: true
|
||||||
env:
|
env:
|
||||||
DEFAULT_UV_VERSION: "0.11.x"
|
DEFAULT_UV_VERSION: "0.12.x"
|
||||||
NLTK_DATA: "/usr/share/nltk_data"
|
NLTK_DATA: "/usr/share/nltk_data"
|
||||||
permissions: {}
|
permissions: {}
|
||||||
jobs:
|
jobs:
|
||||||
@@ -24,7 +24,7 @@ jobs:
|
|||||||
backend_changed: ${{ steps.force.outputs.run_all == 'true' || steps.filter.outputs.backend == 'true' }}
|
backend_changed: ${{ steps.force.outputs.run_all == 'true' || steps.filter.outputs.backend == 'true' }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
@@ -63,7 +63,7 @@ jobs:
|
|||||||
- name: Detect changes
|
- name: Detect changes
|
||||||
id: filter
|
id: filter
|
||||||
if: steps.force.outputs.run_all != 'true'
|
if: steps.force.outputs.run_all != 'true'
|
||||||
uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
|
uses: dorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706 # v4.0.2
|
||||||
with:
|
with:
|
||||||
base: ${{ steps.range.outputs.base }}
|
base: ${{ steps.range.outputs.base }}
|
||||||
ref: ${{ steps.range.outputs.ref }}
|
ref: ${{ steps.range.outputs.ref }}
|
||||||
@@ -87,7 +87,7 @@ jobs:
|
|||||||
fail-fast: false
|
fail-fast: false
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
- name: Start containers
|
- name: Start containers
|
||||||
@@ -96,11 +96,11 @@ jobs:
|
|||||||
docker compose --file docker/compose/docker-compose.ci-test.yml up --detach
|
docker compose --file docker/compose/docker-compose.ci-test.yml up --detach
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
id: setup-python
|
id: setup-python
|
||||||
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
|
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||||
with:
|
with:
|
||||||
python-version: "${{ matrix.python-version }}"
|
python-version: "${{ matrix.python-version }}"
|
||||||
- name: Install uv
|
- name: Install uv
|
||||||
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
|
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||||
with:
|
with:
|
||||||
version: ${{ env.DEFAULT_UV_VERSION }}
|
version: ${{ env.DEFAULT_UV_VERSION }}
|
||||||
enable-cache: true
|
enable-cache: true
|
||||||
@@ -169,16 +169,16 @@ jobs:
|
|||||||
PAPERLESS_SECRET_KEY: "ci-typing-not-a-real-secret"
|
PAPERLESS_SECRET_KEY: "ci-typing-not-a-real-secret"
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
id: setup-python
|
id: setup-python
|
||||||
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
|
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||||
with:
|
with:
|
||||||
python-version: "${{ env.DEFAULT_PYTHON }}"
|
python-version: "${{ env.DEFAULT_PYTHON }}"
|
||||||
- name: Install uv
|
- name: Install uv
|
||||||
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
|
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||||
with:
|
with:
|
||||||
version: ${{ env.DEFAULT_UV_VERSION }}
|
version: ${{ env.DEFAULT_UV_VERSION }}
|
||||||
enable-cache: true
|
enable-cache: true
|
||||||
|
|||||||
@@ -41,7 +41,7 @@ jobs:
|
|||||||
ref-name: ${{ steps.ref.outputs.name }}
|
ref-name: ${{ steps.ref.outputs.name }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
- name: Determine ref name
|
- name: Determine ref name
|
||||||
@@ -106,9 +106,9 @@ jobs:
|
|||||||
echo "repository=${repo_name}"
|
echo "repository=${repo_name}"
|
||||||
echo "name=${repo_name}" >> $GITHUB_OUTPUT
|
echo "name=${repo_name}" >> $GITHUB_OUTPUT
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
|
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
|
||||||
- name: Login to GitHub Container Registry
|
- name: Login to GitHub Container Registry
|
||||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
|
||||||
with:
|
with:
|
||||||
registry: ${{ env.REGISTRY }}
|
registry: ${{ env.REGISTRY }}
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
@@ -121,7 +121,7 @@ jobs:
|
|||||||
sudo rm -rf "$AGENT_TOOLSDIRECTORY"
|
sudo rm -rf "$AGENT_TOOLSDIRECTORY"
|
||||||
- name: Docker metadata
|
- name: Docker metadata
|
||||||
id: docker-meta
|
id: docker-meta
|
||||||
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
|
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
|
||||||
with:
|
with:
|
||||||
images: |
|
images: |
|
||||||
${{ env.REGISTRY }}/${{ steps.repo.outputs.name }}
|
${{ env.REGISTRY }}/${{ steps.repo.outputs.name }}
|
||||||
@@ -132,7 +132,7 @@ jobs:
|
|||||||
type=semver,pattern={{major}}.{{minor}}
|
type=semver,pattern={{major}}.{{minor}}
|
||||||
- name: Build and push by digest
|
- name: Build and push by digest
|
||||||
id: build
|
id: build
|
||||||
uses: docker/build-push-action@f9f3042f7e2789586610d6e8b85c8f03e5195baf # v7.2.0
|
uses: docker/build-push-action@53b7df96c91f9c12dcc8a07bcb9ccacbed38856a # v7.3.0
|
||||||
with:
|
with:
|
||||||
context: .
|
context: .
|
||||||
file: ./Dockerfile
|
file: ./Dockerfile
|
||||||
@@ -182,29 +182,29 @@ jobs:
|
|||||||
echo "Downloaded digests:"
|
echo "Downloaded digests:"
|
||||||
ls -la /tmp/digests/
|
ls -la /tmp/digests/
|
||||||
- name: Set up Docker Buildx
|
- name: Set up Docker Buildx
|
||||||
uses: docker/setup-buildx-action@d7f5e7f509e45cec5c76c4d5afdd7de93d0b3df5 # v4.1.0
|
uses: docker/setup-buildx-action@bb05f3f5519dd87d3ba754cc423b652a5edd6d2c # v4.2.0
|
||||||
- name: Login to GitHub Container Registry
|
- name: Login to GitHub Container Registry
|
||||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
|
||||||
with:
|
with:
|
||||||
registry: ${{ env.REGISTRY }}
|
registry: ${{ env.REGISTRY }}
|
||||||
username: ${{ github.actor }}
|
username: ${{ github.actor }}
|
||||||
password: ${{ secrets.GITHUB_TOKEN }}
|
password: ${{ secrets.GITHUB_TOKEN }}
|
||||||
- name: Login to Docker Hub
|
- name: Login to Docker Hub
|
||||||
if: needs.build-arch.outputs.push-external == 'true'
|
if: needs.build-arch.outputs.push-external == 'true'
|
||||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
|
||||||
with:
|
with:
|
||||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||||
- name: Login to Quay.io
|
- name: Login to Quay.io
|
||||||
if: needs.build-arch.outputs.push-external == 'true'
|
if: needs.build-arch.outputs.push-external == 'true'
|
||||||
uses: docker/login-action@650006c6eb7dba73a995cc03b0b2d7f5ca915bee # v4.2.0
|
uses: docker/login-action@abd2ef45e78c5afb21d64d4ca52ee8550d9572c7 # v4.5.1
|
||||||
with:
|
with:
|
||||||
registry: quay.io
|
registry: quay.io
|
||||||
username: ${{ secrets.QUAY_USERNAME }}
|
username: ${{ secrets.QUAY_USERNAME }}
|
||||||
password: ${{ secrets.QUAY_ROBOT_TOKEN }}
|
password: ${{ secrets.QUAY_ROBOT_TOKEN }}
|
||||||
- name: Docker metadata
|
- name: Docker metadata
|
||||||
id: docker-meta
|
id: docker-meta
|
||||||
uses: docker/metadata-action@80c7e94dd9b9319bd5eb7a0e0fe9291e23a2a2e9 # v6.1.0
|
uses: docker/metadata-action@dc802804100637a589fabce1cb79ff13a1411302 # v6.2.0
|
||||||
with:
|
with:
|
||||||
images: |
|
images: |
|
||||||
${{ env.REGISTRY }}/${{ needs.build-arch.outputs.repository }}
|
${{ env.REGISTRY }}/${{ needs.build-arch.outputs.repository }}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ concurrency:
|
|||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
env:
|
env:
|
||||||
DEFAULT_UV_VERSION: "0.11.x"
|
DEFAULT_UV_VERSION: "0.12.x"
|
||||||
DEFAULT_PYTHON_VERSION: "3.12"
|
DEFAULT_PYTHON_VERSION: "3.12"
|
||||||
jobs:
|
jobs:
|
||||||
changes:
|
changes:
|
||||||
@@ -21,7 +21,7 @@ jobs:
|
|||||||
docs_changed: ${{ steps.force.outputs.run_all == 'true' || steps.filter.outputs.docs == 'true' }}
|
docs_changed: ${{ steps.force.outputs.run_all == 'true' || steps.filter.outputs.docs == 'true' }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
@@ -50,7 +50,7 @@ jobs:
|
|||||||
- name: Detect changes
|
- name: Detect changes
|
||||||
id: filter
|
id: filter
|
||||||
if: steps.force.outputs.run_all != 'true'
|
if: steps.force.outputs.run_all != 'true'
|
||||||
uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
|
uses: dorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706 # v4.0.2
|
||||||
with:
|
with:
|
||||||
base: ${{ steps.range.outputs.base }}
|
base: ${{ steps.range.outputs.base }}
|
||||||
ref: ${{ steps.range.outputs.ref }}
|
ref: ${{ steps.range.outputs.ref }}
|
||||||
@@ -69,16 +69,16 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0
|
- uses: actions/configure-pages@45bfe0192ca1faeb007ade9deae92b16b8254a0d # v6.0.0
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
id: setup-python
|
id: setup-python
|
||||||
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
|
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||||
with:
|
with:
|
||||||
python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
|
python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
|
||||||
- name: Install uv
|
- name: Install uv
|
||||||
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
|
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||||
with:
|
with:
|
||||||
version: ${{ env.DEFAULT_UV_VERSION }}
|
version: ${{ env.DEFAULT_UV_VERSION }}
|
||||||
enable-cache: true
|
enable-cache: true
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ jobs:
|
|||||||
frontend_changed: ${{ steps.force.outputs.run_all == 'true' || steps.filter.outputs.frontend == 'true' }}
|
frontend_changed: ${{ steps.force.outputs.run_all == 'true' || steps.filter.outputs.frontend == 'true' }}
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
@@ -60,7 +60,7 @@ jobs:
|
|||||||
- name: Detect changes
|
- name: Detect changes
|
||||||
id: filter
|
id: filter
|
||||||
if: steps.force.outputs.run_all != 'true'
|
if: steps.force.outputs.run_all != 'true'
|
||||||
uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
|
uses: dorny/paths-filter@7b450fff21473bca461d4b92ce414b9d0420d706 # v4.0.2
|
||||||
with:
|
with:
|
||||||
base: ${{ steps.range.outputs.base }}
|
base: ${{ steps.range.outputs.base }}
|
||||||
ref: ${{ steps.range.outputs.ref }}
|
ref: ${{ steps.range.outputs.ref }}
|
||||||
@@ -77,15 +77,15 @@ jobs:
|
|||||||
contents: read
|
contents: read
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
- name: Install pnpm
|
- name: Install pnpm
|
||||||
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
||||||
with:
|
with:
|
||||||
version: 10
|
package_json_file: src-ui/package.json
|
||||||
- name: Use Node.js 24
|
- name: Use Node.js 24
|
||||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||||
with:
|
with:
|
||||||
node-version: 24.x
|
node-version: 24.x
|
||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
@@ -109,15 +109,15 @@ jobs:
|
|||||||
contents: read
|
contents: read
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
- name: Install pnpm
|
- name: Install pnpm
|
||||||
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
||||||
with:
|
with:
|
||||||
version: 10
|
package_json_file: src-ui/package.json
|
||||||
- name: Use Node.js 24
|
- name: Use Node.js 24
|
||||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||||
with:
|
with:
|
||||||
node-version: 24.x
|
node-version: 24.x
|
||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
@@ -129,8 +129,8 @@ jobs:
|
|||||||
~/.pnpm-store
|
~/.pnpm-store
|
||||||
~/.cache
|
~/.cache
|
||||||
key: ${{ runner.os }}-frontend-${{ hashFiles('src-ui/pnpm-lock.yaml') }}
|
key: ${{ runner.os }}-frontend-${{ hashFiles('src-ui/pnpm-lock.yaml') }}
|
||||||
- name: Re-link Angular CLI
|
- name: Install dependencies
|
||||||
run: cd src-ui && pnpm link @angular/cli
|
run: cd src-ui && pnpm install --frozen-lockfile
|
||||||
- name: Run lint
|
- name: Run lint
|
||||||
run: cd src-ui && pnpm run lint
|
run: cd src-ui && pnpm run lint
|
||||||
unit-tests:
|
unit-tests:
|
||||||
@@ -148,15 +148,15 @@ jobs:
|
|||||||
shard-count: [4]
|
shard-count: [4]
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
- name: Install pnpm
|
- name: Install pnpm
|
||||||
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
||||||
with:
|
with:
|
||||||
version: 10
|
package_json_file: src-ui/package.json
|
||||||
- name: Use Node.js 24
|
- name: Use Node.js 24
|
||||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||||
with:
|
with:
|
||||||
node-version: 24.x
|
node-version: 24.x
|
||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
@@ -168,8 +168,8 @@ jobs:
|
|||||||
~/.pnpm-store
|
~/.pnpm-store
|
||||||
~/.cache
|
~/.cache
|
||||||
key: ${{ runner.os }}-frontend-${{ hashFiles('src-ui/pnpm-lock.yaml') }}
|
key: ${{ runner.os }}-frontend-${{ hashFiles('src-ui/pnpm-lock.yaml') }}
|
||||||
- name: Re-link Angular CLI
|
- name: Install dependencies
|
||||||
run: cd src-ui && pnpm link @angular/cli
|
run: cd src-ui && pnpm install --frozen-lockfile
|
||||||
- name: Run Jest unit tests
|
- name: Run Jest unit tests
|
||||||
run: cd src-ui && pnpm run test --max-workers=2 --shard=${{ matrix.shard-index }}/${{ matrix.shard-count }}
|
run: cd src-ui && pnpm run test --max-workers=2 --shard=${{ matrix.shard-index }}/${{ matrix.shard-count }}
|
||||||
- name: Upload test results to Codecov
|
- name: Upload test results to Codecov
|
||||||
@@ -191,7 +191,7 @@ jobs:
|
|||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
container: mcr.microsoft.com/playwright:v1.61.1-noble
|
container: mcr.microsoft.com/playwright:v1.62.0-noble
|
||||||
env:
|
env:
|
||||||
PLAYWRIGHT_BROWSERS_PATH: /ms-playwright
|
PLAYWRIGHT_BROWSERS_PATH: /ms-playwright
|
||||||
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1
|
PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: 1
|
||||||
@@ -203,15 +203,15 @@ jobs:
|
|||||||
shard-count: [2]
|
shard-count: [2]
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
- name: Install pnpm
|
- name: Install pnpm
|
||||||
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
||||||
with:
|
with:
|
||||||
version: 10
|
package_json_file: src-ui/package.json
|
||||||
- name: Use Node.js 24
|
- name: Use Node.js 24
|
||||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||||
with:
|
with:
|
||||||
node-version: 24.x
|
node-version: 24.x
|
||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
@@ -223,32 +223,29 @@ jobs:
|
|||||||
~/.pnpm-store
|
~/.pnpm-store
|
||||||
~/.cache
|
~/.cache
|
||||||
key: ${{ runner.os }}-frontend-${{ hashFiles('src-ui/pnpm-lock.yaml') }}
|
key: ${{ runner.os }}-frontend-${{ hashFiles('src-ui/pnpm-lock.yaml') }}
|
||||||
- name: Re-link Angular CLI
|
|
||||||
run: cd src-ui && pnpm link @angular/cli
|
|
||||||
- name: Install dependencies
|
- name: Install dependencies
|
||||||
run: cd src-ui && pnpm install --no-frozen-lockfile
|
run: cd src-ui && pnpm install --frozen-lockfile
|
||||||
- name: Run Playwright E2E tests
|
- name: Run Playwright E2E tests
|
||||||
run: cd src-ui && pnpm exec playwright test --shard ${{ matrix.shard-index }}/${{ matrix.shard-count }}
|
run: cd src-ui && pnpm exec playwright test --shard ${{ matrix.shard-index }}/${{ matrix.shard-count }}
|
||||||
bundle-analysis:
|
frontend-build:
|
||||||
name: Bundle Analysis
|
name: Frontend Build
|
||||||
needs: [changes, unit-tests, e2e-tests]
|
needs: [changes, unit-tests, e2e-tests]
|
||||||
if: needs.changes.outputs.frontend_changed == 'true'
|
if: needs.changes.outputs.frontend_changed == 'true'
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
environment: bundle-analysis
|
|
||||||
permissions:
|
permissions:
|
||||||
contents: read
|
contents: read
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
with:
|
with:
|
||||||
fetch-depth: 2
|
fetch-depth: 2
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
- name: Install pnpm
|
- name: Install pnpm
|
||||||
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
||||||
with:
|
with:
|
||||||
version: 10
|
package_json_file: src-ui/package.json
|
||||||
- name: Use Node.js 24
|
- name: Use Node.js 24
|
||||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||||
with:
|
with:
|
||||||
node-version: 24.x
|
node-version: 24.x
|
||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
@@ -260,21 +257,19 @@ jobs:
|
|||||||
~/.pnpm-store
|
~/.pnpm-store
|
||||||
~/.cache
|
~/.cache
|
||||||
key: ${{ runner.os }}-frontend-${{ hashFiles('src-ui/pnpm-lock.yaml') }}
|
key: ${{ runner.os }}-frontend-${{ hashFiles('src-ui/pnpm-lock.yaml') }}
|
||||||
- name: Re-link Angular CLI
|
- name: Install dependencies
|
||||||
run: cd src-ui && pnpm link @angular/cli
|
run: cd src-ui && pnpm install --frozen-lockfile
|
||||||
- name: Build and analyze
|
- name: Build
|
||||||
env:
|
|
||||||
CODECOV_TOKEN: ${{ secrets.CODECOV_TOKEN }}
|
|
||||||
run: cd src-ui && pnpm run build --configuration=production
|
run: cd src-ui && pnpm run build --configuration=production
|
||||||
gate:
|
gate:
|
||||||
name: Frontend CI Gate
|
name: Frontend CI Gate
|
||||||
needs: [changes, install-dependencies, lint, unit-tests, e2e-tests, bundle-analysis]
|
needs: [changes, install-dependencies, lint, unit-tests, e2e-tests, frontend-build]
|
||||||
if: always()
|
if: always()
|
||||||
runs-on: ubuntu-slim
|
runs-on: ubuntu-slim
|
||||||
steps:
|
steps:
|
||||||
- name: Check gate
|
- name: Check gate
|
||||||
env:
|
env:
|
||||||
BUNDLE_ANALYSIS_RESULT: ${{ needs['bundle-analysis'].result }}
|
BUILD_RESULT: ${{ needs['frontend-build'].result }}
|
||||||
E2E_RESULT: ${{ needs['e2e-tests'].result }}
|
E2E_RESULT: ${{ needs['e2e-tests'].result }}
|
||||||
FRONTEND_CHANGED: ${{ needs.changes.outputs.frontend_changed }}
|
FRONTEND_CHANGED: ${{ needs.changes.outputs.frontend_changed }}
|
||||||
INSTALL_RESULT: ${{ needs['install-dependencies'].result }}
|
INSTALL_RESULT: ${{ needs['install-dependencies'].result }}
|
||||||
@@ -306,8 +301,8 @@ jobs:
|
|||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
if [[ "${BUNDLE_ANALYSIS_RESULT}" != "success" ]]; then
|
if [[ "${BUILD_RESULT}" != "success" ]]; then
|
||||||
echo "::error::Frontend bundle-analysis job result: ${BUNDLE_ANALYSIS_RESULT}"
|
echo "::error::Frontend build job result: ${BUILD_RESULT}"
|
||||||
exit 1
|
exit 1
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
|||||||
@@ -17,12 +17,12 @@ jobs:
|
|||||||
runs-on: ubuntu-slim
|
runs-on: ubuntu-slim
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
- name: Install Python
|
- name: Install Python
|
||||||
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
|
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||||
with:
|
with:
|
||||||
python-version: "3.14"
|
python-version: "3.14"
|
||||||
- name: Run prek
|
- name: Run prek
|
||||||
uses: j178/prek-action@bdca6f102f98e2b4c7029491a53dfd366469e33d # v2.0.4
|
uses: j178/prek-action@5337cb91e0fa35a7ff31b9ca345126d8bbbcdf16 # v2.0.6
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ concurrency:
|
|||||||
group: release-${{ github.ref }}
|
group: release-${{ github.ref }}
|
||||||
cancel-in-progress: false
|
cancel-in-progress: false
|
||||||
env:
|
env:
|
||||||
DEFAULT_UV_VERSION: "0.11.x"
|
DEFAULT_UV_VERSION: "0.12.x"
|
||||||
DEFAULT_PYTHON_VERSION: "3.12"
|
DEFAULT_PYTHON_VERSION: "3.12"
|
||||||
permissions: {}
|
permissions: {}
|
||||||
jobs:
|
jobs:
|
||||||
@@ -20,7 +20,7 @@ jobs:
|
|||||||
statuses: read
|
statuses: read
|
||||||
steps:
|
steps:
|
||||||
- name: Wait for Docker build
|
- name: Wait for Docker build
|
||||||
uses: lewagon/wait-on-check-action@96d9100b431964d10e0136aff8b9ccb92470505e # v1.8.0
|
uses: lewagon/wait-on-check-action@2271c86c146b96545b4e871b855e10ffa6f50773 # v1.9.0
|
||||||
with:
|
with:
|
||||||
ref: ${{ github.sha }}
|
ref: ${{ github.sha }}
|
||||||
check-name: 'Merge and Push Manifest'
|
check-name: 'Merge and Push Manifest'
|
||||||
@@ -35,16 +35,16 @@ jobs:
|
|||||||
contents: read
|
contents: read
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
# ---- Frontend Build ----
|
# ---- Frontend Build ----
|
||||||
- name: Install pnpm
|
- name: Install pnpm
|
||||||
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
||||||
with:
|
with:
|
||||||
version: 10
|
package_json_file: src-ui/package.json
|
||||||
- name: Use Node.js 24
|
- name: Use Node.js 24
|
||||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||||
with:
|
with:
|
||||||
node-version: 24.x
|
node-version: 24.x
|
||||||
package-manager-cache: false
|
package-manager-cache: false
|
||||||
@@ -55,11 +55,11 @@ jobs:
|
|||||||
# ---- Backend Setup ----
|
# ---- Backend Setup ----
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
id: setup-python
|
id: setup-python
|
||||||
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
|
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||||
with:
|
with:
|
||||||
python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
|
python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
|
||||||
- name: Install uv
|
- name: Install uv
|
||||||
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
|
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||||
with:
|
with:
|
||||||
version: ${{ env.DEFAULT_UV_VERSION }}
|
version: ${{ env.DEFAULT_UV_VERSION }}
|
||||||
enable-cache: false
|
enable-cache: false
|
||||||
@@ -171,7 +171,7 @@ jobs:
|
|||||||
fi
|
fi
|
||||||
- name: Create release and changelog
|
- name: Create release and changelog
|
||||||
id: create-release
|
id: create-release
|
||||||
uses: release-drafter/release-drafter@4d75298e00d9e34c483e5ff8c68d0ea1c1940c1e # v7.5.1
|
uses: release-drafter/release-drafter@eada3c96a64734dd381cfbda23511034e328ddb0 # v7.6.0
|
||||||
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 }}
|
||||||
@@ -202,17 +202,17 @@ jobs:
|
|||||||
pull-requests: write
|
pull-requests: write
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
with:
|
with:
|
||||||
ref: main
|
ref: main
|
||||||
persist-credentials: true # for pushing changelog branch
|
persist-credentials: true # for pushing changelog branch
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
id: setup-python
|
id: setup-python
|
||||||
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
|
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||||
with:
|
with:
|
||||||
python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
|
python-version: ${{ env.DEFAULT_PYTHON_VERSION }}
|
||||||
- name: Install uv
|
- name: Install uv
|
||||||
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
|
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||||
with:
|
with:
|
||||||
version: ${{ env.DEFAULT_UV_VERSION }}
|
version: ${{ env.DEFAULT_UV_VERSION }}
|
||||||
enable-cache: false
|
enable-cache: false
|
||||||
|
|||||||
@@ -22,11 +22,11 @@ jobs:
|
|||||||
security-events: write
|
security-events: write
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
- name: Run zizmor
|
- name: Run zizmor
|
||||||
uses: zizmorcore/zizmor-action@192e21d79ab29983730a13d1382995c2307fbcaa # v0.5.7
|
uses: zizmorcore/zizmor-action@6fc4b006235f201fdab3722e17240ab420d580e5 # v0.6.1
|
||||||
semgrep:
|
semgrep:
|
||||||
name: Semgrep CE
|
name: Semgrep CE
|
||||||
runs-on: ubuntu-24.04
|
runs-on: ubuntu-24.04
|
||||||
@@ -38,13 +38,13 @@ jobs:
|
|||||||
security-events: write
|
security-events: write
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
- name: Run Semgrep
|
- name: Run Semgrep
|
||||||
run: semgrep scan --config auto --sarif-output results.sarif
|
run: semgrep scan --config auto --sarif-output results.sarif
|
||||||
- name: Upload results to GitHub code scanning
|
- name: Upload results to GitHub code scanning
|
||||||
uses: github/codeql-action/upload-sarif@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
|
uses: github/codeql-action/upload-sarif@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3
|
||||||
if: always()
|
if: always()
|
||||||
with:
|
with:
|
||||||
sarif_file: results.sarif
|
sarif_file: results.sarif
|
||||||
|
|||||||
@@ -34,12 +34,12 @@ jobs:
|
|||||||
# Learn more about CodeQL language support at https://git.io/codeql-language-support
|
# Learn more about CodeQL language support at https://git.io/codeql-language-support
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout repository
|
- name: Checkout repository
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
with:
|
with:
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
# Initializes the CodeQL tools for scanning.
|
# Initializes the CodeQL tools for scanning.
|
||||||
- name: Initialize CodeQL
|
- name: Initialize CodeQL
|
||||||
uses: github/codeql-action/init@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
|
uses: github/codeql-action/init@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3
|
||||||
with:
|
with:
|
||||||
languages: ${{ matrix.language }}
|
languages: ${{ matrix.language }}
|
||||||
# If you wish to specify custom queries, you can do so here or in a config file.
|
# If you wish to specify custom queries, you can do so here or in a config file.
|
||||||
@@ -47,4 +47,4 @@ jobs:
|
|||||||
# Prefix the list here with "+" to use these queries and those in the config file.
|
# Prefix the list here with "+" to use these queries and those in the config file.
|
||||||
# queries: ./path/to/local/query, your-org/your-repo/queries@main
|
# queries: ./path/to/local/query, your-org/your-repo/queries@main
|
||||||
- name: Perform CodeQL Analysis
|
- name: Perform CodeQL Analysis
|
||||||
uses: github/codeql-action/analyze@8aad20d150bbac5944a9f9d289da16a4b0d87c1e # v4.36.2
|
uses: github/codeql-action/analyze@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4.37.3
|
||||||
|
|||||||
@@ -17,12 +17,12 @@ jobs:
|
|||||||
environment: translation-sync
|
environment: translation-sync
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
with:
|
with:
|
||||||
token: ${{ secrets.PNGX_BOT_PAT }}
|
token: ${{ secrets.PNGX_BOT_PAT }}
|
||||||
persist-credentials: false
|
persist-credentials: false
|
||||||
- name: crowdin action
|
- name: crowdin action
|
||||||
uses: crowdin/github-action@52aa776766211d83d975df51f3b9c53c2f8ba35f # v2.16.3
|
uses: crowdin/github-action@c7af9bc98b01694653031fef2a0dc6c7888ce9bc # v2.17.0
|
||||||
with:
|
with:
|
||||||
upload_translations: false
|
upload_translations: false
|
||||||
download_translations: true
|
download_translations: true
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ jobs:
|
|||||||
steps:
|
steps:
|
||||||
- name: Label PR by file path or branch name
|
- name: Label PR by file path or branch name
|
||||||
# see .github/labeler.yml for the labeler config
|
# see .github/labeler.yml for the labeler config
|
||||||
uses: actions/labeler@f27b608878404679385c85cfa523b85ccb86e213 # v6.1.0
|
uses: actions/labeler@bf12e9b00b37c5c0ca2b87b79b2daf7891dbda13 # v7.0.0
|
||||||
with:
|
with:
|
||||||
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
repo-token: ${{ secrets.GITHUB_TOKEN }}
|
||||||
- name: Label by size
|
- name: Label by size
|
||||||
|
|||||||
@@ -19,6 +19,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@4d75298e00d9e34c483e5ff8c68d0ea1c1940c1e # v7.5.1
|
uses: release-drafter/release-drafter@eada3c96a64734dd381cfbda23511034e328ddb0 # v7.6.0
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ jobs:
|
|||||||
issues: write
|
issues: write
|
||||||
pull-requests: write
|
pull-requests: write
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0
|
- uses: actions/stale@1e223db275d687790206a7acac4d1a11bd6fe629 # v10.4.0
|
||||||
with:
|
with:
|
||||||
days-before-stale: 7
|
days-before-stale: 7
|
||||||
days-before-close: 14
|
days-before-close: 14
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ on:
|
|||||||
branches:
|
branches:
|
||||||
- dev
|
- dev
|
||||||
env:
|
env:
|
||||||
DEFAULT_UV_VERSION: "0.11.x"
|
DEFAULT_UV_VERSION: "0.12.x"
|
||||||
jobs:
|
jobs:
|
||||||
generate-translate-strings:
|
generate-translate-strings:
|
||||||
name: Generate Translation Strings
|
name: Generate Translation Strings
|
||||||
@@ -14,7 +14,7 @@ jobs:
|
|||||||
contents: write
|
contents: write
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout code
|
- name: Checkout code
|
||||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||||
env:
|
env:
|
||||||
GH_REF: ${{ github.ref }} # sonar rule:githubactions:S7630 - avoid injection
|
GH_REF: ${{ github.ref }} # sonar rule:githubactions:S7630 - avoid injection
|
||||||
with:
|
with:
|
||||||
@@ -23,13 +23,13 @@ jobs:
|
|||||||
persist-credentials: true # for pushing translation branch
|
persist-credentials: true # for pushing translation branch
|
||||||
- name: Set up Python
|
- name: Set up Python
|
||||||
id: setup-python
|
id: setup-python
|
||||||
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6.3.0
|
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||||
- name: Install system dependencies
|
- name: Install system dependencies
|
||||||
run: |
|
run: |
|
||||||
sudo apt-get update -qq
|
sudo apt-get update -qq
|
||||||
sudo apt-get install -qq --no-install-recommends gettext
|
sudo apt-get install -qq --no-install-recommends gettext
|
||||||
- name: Install uv
|
- name: Install uv
|
||||||
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # v8.2.0
|
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||||
with:
|
with:
|
||||||
version: ${{ env.DEFAULT_UV_VERSION }}
|
version: ${{ env.DEFAULT_UV_VERSION }}
|
||||||
enable-cache: true
|
enable-cache: true
|
||||||
@@ -45,9 +45,9 @@ jobs:
|
|||||||
- name: Install pnpm
|
- name: Install pnpm
|
||||||
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
uses: pnpm/action-setup@0ebf47130e4866e96fce0953f49152a61190b271 # v6.0.9
|
||||||
with:
|
with:
|
||||||
version: 10
|
package_json_file: src-ui/package.json
|
||||||
- name: Use Node.js 24
|
- name: Use Node.js 24
|
||||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||||
with:
|
with:
|
||||||
node-version: 24.x
|
node-version: 24.x
|
||||||
cache: 'pnpm'
|
cache: 'pnpm'
|
||||||
@@ -61,16 +61,13 @@ jobs:
|
|||||||
~/.cache
|
~/.cache
|
||||||
key: ${{ runner.os }}-frontenddeps-${{ hashFiles('src-ui/pnpm-lock.yaml') }}
|
key: ${{ runner.os }}-frontenddeps-${{ hashFiles('src-ui/pnpm-lock.yaml') }}
|
||||||
- name: Install frontend dependencies
|
- name: Install frontend dependencies
|
||||||
if: steps.cache-frontend-deps.outputs.cache-hit != 'true'
|
run: cd src-ui && pnpm install --frozen-lockfile
|
||||||
run: cd src-ui && pnpm install
|
|
||||||
- name: Re-link Angular cli
|
|
||||||
run: cd src-ui && pnpm link @angular/cli
|
|
||||||
- name: Generate frontend translation strings
|
- name: Generate frontend translation strings
|
||||||
run: |
|
run: |
|
||||||
cd src-ui
|
cd src-ui
|
||||||
pnpm run ng extract-i18n
|
pnpm run ng extract-i18n
|
||||||
- name: Commit changes
|
- name: Commit changes
|
||||||
uses: stefanzweifel/git-auto-commit-action@04702edda442b2e678b25b537cec683a1493fcb9 # v7.1.0
|
uses: stefanzweifel/git-auto-commit-action@4a55954c782fc1ea30b9056cd3e7a2b40ca8887d # v7.2.0
|
||||||
with:
|
with:
|
||||||
file_pattern: 'src-ui/messages.xlf src/locale/en_US/LC_MESSAGES/django.po'
|
file_pattern: 'src-ui/messages.xlf src/locale/en_US/LC_MESSAGES/django.po'
|
||||||
commit_message: "Auto translate strings"
|
commit_message: "Auto translate strings"
|
||||||
|
|||||||
@@ -115,6 +115,3 @@ celerybeat-schedule*
|
|||||||
|
|
||||||
# Git worktree local folder
|
# Git worktree local folder
|
||||||
.worktrees
|
.worktrees
|
||||||
|
|
||||||
# Benchmark tooling output (local only, never committed)
|
|
||||||
/benchmark_results/
|
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ repos:
|
|||||||
- id: check-case-conflict
|
- id: check-case-conflict
|
||||||
- id: detect-private-key
|
- id: detect-private-key
|
||||||
- repo: https://github.com/codespell-project/codespell
|
- repo: https://github.com/codespell-project/codespell
|
||||||
rev: v2.4.2
|
rev: v2.4.3
|
||||||
hooks:
|
hooks:
|
||||||
- id: codespell
|
- id: codespell
|
||||||
additional_dependencies: [tomli]
|
additional_dependencies: [tomli]
|
||||||
@@ -38,7 +38,7 @@ repos:
|
|||||||
- json
|
- json
|
||||||
# See https://github.com/prettier/prettier/issues/15742 for the fork reason
|
# See https://github.com/prettier/prettier/issues/15742 for the fork reason
|
||||||
- repo: https://github.com/rbubley/mirrors-prettier
|
- repo: https://github.com/rbubley/mirrors-prettier
|
||||||
rev: 'v3.9.4'
|
rev: 'v3.9.6'
|
||||||
hooks:
|
hooks:
|
||||||
- id: prettier
|
- id: prettier
|
||||||
types_or:
|
types_or:
|
||||||
@@ -46,16 +46,16 @@ repos:
|
|||||||
- ts
|
- ts
|
||||||
- markdown
|
- markdown
|
||||||
additional_dependencies:
|
additional_dependencies:
|
||||||
- prettier@3.9.4
|
- prettier@3.9.6
|
||||||
- 'prettier-plugin-organize-imports@4.3.0'
|
- 'prettier-plugin-organize-imports@4.3.0'
|
||||||
# Python hooks
|
# Python hooks
|
||||||
- repo: https://github.com/astral-sh/ruff-pre-commit
|
- repo: https://github.com/astral-sh/ruff-pre-commit
|
||||||
rev: v0.15.20
|
rev: v0.16.1
|
||||||
hooks:
|
hooks:
|
||||||
- id: ruff-check
|
- id: ruff-check
|
||||||
- id: ruff-format
|
- id: ruff-format
|
||||||
- repo: https://github.com/tox-dev/pyproject-fmt
|
- repo: https://github.com/tox-dev/pyproject-fmt
|
||||||
rev: "v2.25.1"
|
rev: "v2.26.0"
|
||||||
hooks:
|
hooks:
|
||||||
- id: pyproject-fmt
|
- id: pyproject-fmt
|
||||||
additional_dependencies: [tomli]
|
additional_dependencies: [tomli]
|
||||||
|
|||||||
+1
-1
@@ -30,7 +30,7 @@ RUN set -eux \
|
|||||||
# Purpose: Installs s6-overlay and rootfs
|
# Purpose: Installs s6-overlay and rootfs
|
||||||
# Comments:
|
# Comments:
|
||||||
# - Don't leave anything extra in here either
|
# - Don't leave anything extra in here either
|
||||||
FROM ghcr.io/astral-sh/uv:0.11.28-python3.12-trixie-slim AS s6-overlay-base
|
FROM ghcr.io/astral-sh/uv:0.12.5-python3.14-trixie-slim AS s6-overlay-base
|
||||||
|
|
||||||
WORKDIR /usr/src/s6
|
WORKDIR /usr/src/s6
|
||||||
|
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ services:
|
|||||||
network_mode: host
|
network_mode: host
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
greenmail:
|
greenmail:
|
||||||
image: docker.io/greenmail/standalone:2.1.9
|
image: docker.io/greenmail/standalone:2.1.11
|
||||||
hostname: greenmail
|
hostname: greenmail
|
||||||
container_name: greenmail
|
container_name: greenmail
|
||||||
environment:
|
environment:
|
||||||
@@ -35,7 +35,7 @@ services:
|
|||||||
- "3143:3143" # IMAP
|
- "3143:3143" # IMAP
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
nginx:
|
nginx:
|
||||||
image: docker.io/nginx:1.31.2-alpine
|
image: docker.io/nginx:1.31.3-alpine
|
||||||
hostname: nginx
|
hostname: nginx
|
||||||
container_name: nginx
|
container_name: nginx
|
||||||
ports:
|
ports:
|
||||||
|
|||||||
@@ -68,7 +68,14 @@
|
|||||||
<!-- <policy domain="resource" name="thread" value="4"/> -->
|
<!-- <policy domain="resource" name="thread" value="4"/> -->
|
||||||
<!-- <policy domain="resource" name="throttle" value="0"/> -->
|
<!-- <policy domain="resource" name="throttle" value="0"/> -->
|
||||||
<!-- <policy domain="resource" name="time" value="3600"/> -->
|
<!-- <policy domain="resource" name="time" value="3600"/> -->
|
||||||
<!-- <policy domain="coder" rights="none" pattern="MVG" /> -->
|
<!-- Paperless does not process SVG or ImageMagick scripting formats. -->
|
||||||
|
<policy domain="coder" rights="none" pattern="SVG" />
|
||||||
|
<policy domain="coder" rights="none" pattern="SVGZ" />
|
||||||
|
<policy domain="coder" rights="none" pattern="MSVG" />
|
||||||
|
<policy domain="coder" rights="none" pattern="RSVG" />
|
||||||
|
<policy domain="coder" rights="none" pattern="MSL" />
|
||||||
|
<policy domain="coder" rights="none" pattern="MVG" />
|
||||||
|
<policy domain="coder" rights="none" pattern="EPHEMERAL" />
|
||||||
<!-- <policy domain="module" rights="none" pattern="{PS,PDF,XPS}" /> -->
|
<!-- <policy domain="module" rights="none" pattern="{PS,PDF,XPS}" /> -->
|
||||||
<!-- <policy domain="delegate" rights="none" pattern="HTTPS" /> -->
|
<!-- <policy domain="delegate" rights="none" pattern="HTTPS" /> -->
|
||||||
<!-- <policy domain="path" rights="none" pattern="@*" /> -->
|
<!-- <policy domain="path" rights="none" pattern="@*" /> -->
|
||||||
@@ -78,8 +85,6 @@
|
|||||||
<!-- <policy domain="system" name="pixel-cache-memory" value="anonymous"/> -->
|
<!-- <policy domain="system" name="pixel-cache-memory" value="anonymous"/> -->
|
||||||
<!-- <policy domain="system" name="shred" value="2"/> -->
|
<!-- <policy domain="system" name="shred" value="2"/> -->
|
||||||
<!-- <policy domain="system" name="precision" value="6"/> -->
|
<!-- <policy domain="system" name="precision" value="6"/> -->
|
||||||
<!-- not needed due to the need to use explicitly by mvg: -->
|
|
||||||
<!-- <policy domain="delegate" rights="none" pattern="MVG" /> -->
|
|
||||||
<!-- use curl -->
|
<!-- use curl -->
|
||||||
<policy domain="delegate" rights="none" pattern="URL" />
|
<policy domain="delegate" rights="none" pattern="URL" />
|
||||||
<policy domain="delegate" rights="none" pattern="HTTPS" />
|
<policy domain="delegate" rights="none" pattern="HTTPS" />
|
||||||
|
|||||||
@@ -0,0 +1,12 @@
|
|||||||
|
#!/command/with-contenv /usr/bin/bash
|
||||||
|
# shellcheck shell=bash
|
||||||
|
|
||||||
|
declare -r log_prefix="[init-llmindex-migrate]"
|
||||||
|
|
||||||
|
echo "${log_prefix} Checking for pending LLM index migrations..."
|
||||||
|
cd "${PAPERLESS_SRC_DIR}"
|
||||||
|
if [[ -n "${USER_IS_NON_ROOT}" ]]; then
|
||||||
|
python3 manage.py document_llmindex migrate
|
||||||
|
else
|
||||||
|
s6-setuidgid paperless python3 manage.py document_llmindex migrate
|
||||||
|
fi
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
oneshot
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
/etc/s6-overlay/s6-rc.d/init-llmindex-migrate/run
|
||||||
+27
-1
@@ -212,6 +212,16 @@ following:
|
|||||||
This is a no-op if the index is already up to date, so it is safe to
|
This is a no-op if the index is already up to date, so it is safe to
|
||||||
run on every upgrade.
|
run on every upgrade.
|
||||||
|
|
||||||
|
5. Migrate the LLM index if needed.
|
||||||
|
|
||||||
|
```shell-session
|
||||||
|
cd src
|
||||||
|
python3 manage.py document_llmindex migrate
|
||||||
|
```
|
||||||
|
|
||||||
|
This is a no-op if the index schema is already current, so it is safe
|
||||||
|
to run on every upgrade.
|
||||||
|
|
||||||
### Database Upgrades
|
### Database Upgrades
|
||||||
|
|
||||||
Paperless-ngx is compatible with Django-supported versions of PostgreSQL and MariaDB and it is generally
|
Paperless-ngx is compatible with Django-supported versions of PostgreSQL and MariaDB and it is generally
|
||||||
@@ -289,6 +299,8 @@ optional arguments:
|
|||||||
-sm, --split-manifest
|
-sm, --split-manifest
|
||||||
-z, --zip
|
-z, --zip
|
||||||
-zn, --zip-name
|
-zn, --zip-name
|
||||||
|
--zip-compression
|
||||||
|
--zip-compression-level
|
||||||
--data-only
|
--data-only
|
||||||
--no-progress-bar
|
--no-progress-bar
|
||||||
--passphrase
|
--passphrase
|
||||||
@@ -351,6 +363,19 @@ If `-z` or `--zip` is provided, the export will be a zip file
|
|||||||
in the target directory, named according to the current local date or the
|
in the target directory, named according to the current local date or the
|
||||||
value set in `-zn` or `--zip-name`.
|
value set in `-zn` or `--zip-name`.
|
||||||
|
|
||||||
|
The compression method for the zip can be set with `--zip-compression`
|
||||||
|
(`stored`, `deflated` (default), `bzip2`, `lzma`, or `zstd`) and tuned with
|
||||||
|
`--zip-compression-level` (deflated: 0–9, bzip2: 1–9, zstd: -22–22; ignored
|
||||||
|
for `stored` and `lzma`). Both options require `--zip`.
|
||||||
|
|
||||||
|
!!! warning
|
||||||
|
|
||||||
|
`zstd` compression requires Python 3.14 or newer on **both** the machine
|
||||||
|
creating the export and any machine importing it. An archive compressed with
|
||||||
|
`zstd` (or `lzma`/`bzip2` where those modules are unavailable) cannot be
|
||||||
|
imported on a runtime that lacks the codec; the importer will refuse it with
|
||||||
|
a clear error. The default `deflated` is universally readable.
|
||||||
|
|
||||||
If `--data-only` is provided, only the database will be exported. This option is intended
|
If `--data-only` is provided, only the database will be exported. This option is intended
|
||||||
to facilitate database upgrades without needing to clean documents and thumbnails from the media directory.
|
to facilitate database upgrades without needing to clean documents and thumbnails from the media directory.
|
||||||
|
|
||||||
@@ -532,7 +557,7 @@ index is updated automatically on the schedule set by
|
|||||||
can manage it manually:
|
can manage it manually:
|
||||||
|
|
||||||
```
|
```
|
||||||
document_llmindex {rebuild,update,compact}
|
document_llmindex {rebuild,update,compact,migrate}
|
||||||
```
|
```
|
||||||
|
|
||||||
Specify `rebuild` to build the index from scratch from all documents in the database. Use
|
Specify `rebuild` to build the index from scratch from all documents in the database. Use
|
||||||
@@ -689,6 +714,7 @@ document_fuzzy_match [--ratio] [--processes N]
|
|||||||
| --ratio | No | 85.0 | a number between 0 and 100, setting how similar a document must be for it to be reported. Higher numbers mean more similarity. |
|
| --ratio | No | 85.0 | a number between 0 and 100, setting how similar a document must be for it to be reported. Higher numbers mean more similarity. |
|
||||||
| --processes | No | 1/4 of system cores | Number of processes to use for matching. Setting 1 disables multiple processes |
|
| --processes | No | 1/4 of system cores | Number of processes to use for matching. Setting 1 disables multiple processes |
|
||||||
| --delete | No | False | If provided, one document of a matched pair above the ratio will be deleted. |
|
| --delete | No | False | If provided, one document of a matched pair above the ratio will be deleted. |
|
||||||
|
| --url | No | blank | If an instance URL is provided, the output table will show URLs to each documents instead of the document ID and name. |
|
||||||
|
|
||||||
!!! warning
|
!!! warning
|
||||||
|
|
||||||
|
|||||||
@@ -227,6 +227,7 @@ Version-aware endpoints:
|
|||||||
- `PATCH /api/documents/{id}/`: content updates target the selected version (`?version={version_id}`) or latest version by default; non-content metadata updates target the root document.
|
- `PATCH /api/documents/{id}/`: content updates target the selected version (`?version={version_id}`) or latest version by default; non-content metadata updates target the root document.
|
||||||
- `GET /api/documents/{id}/download/`, `GET /api/documents/{id}/preview/`, `GET /api/documents/{id}/thumb/`, `GET /api/documents/{id}/metadata/`: accept `?version={version_id}`.
|
- `GET /api/documents/{id}/download/`, `GET /api/documents/{id}/preview/`, `GET /api/documents/{id}/thumb/`, `GET /api/documents/{id}/metadata/`: accept `?version={version_id}`.
|
||||||
- `POST /api/documents/{id}/update_version/`: uploads a new version using multipart form field `document` and optional `version_label`.
|
- `POST /api/documents/{id}/update_version/`: uploads a new version using multipart form field `document` and optional `version_label`.
|
||||||
|
- `POST /api/documents/merge_as_versions/`: merges existing top-level documents as versions of a selected root. The JSON body must contain `documents` (at least two document IDs) and `root_document_id` (one of those IDs). When merging one source document, an optional `version_label` may be provided.
|
||||||
- `PATCH /api/documents/{id}/versions/{version_id}/`: updates the `version_label` of a specific version.
|
- `PATCH /api/documents/{id}/versions/{version_id}/`: updates the `version_label` of a specific version.
|
||||||
- `DELETE /api/documents/{root_id}/versions/{version_id}/`: deletes a non-root version.
|
- `DELETE /api/documents/{root_id}/versions/{version_id}/`: deletes a non-root version.
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,65 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## paperless-ngx 3.0.5
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- Fix: accept Whoosh-era abbreviated relative-date units (yrs, mos, wks, etc) in search queries [@stumpylog](https://github.com/stumpylog) ([#13486](https://github.com/paperless-ngx/paperless-ngx/pull/13486))
|
||||||
|
- Fix: fix edit dialog error change detection [@shamoon](https://github.com/shamoon) ([#13483](https://github.com/paperless-ngx/paperless-ngx/pull/13483))
|
||||||
|
- Fix: key the AI suggestion cache by model and endpoint [@lunetics](https://github.com/lunetics) ([#13449](https://github.com/paperless-ngx/paperless-ngx/pull/13449))
|
||||||
|
- Fix: validate custom field values in bulk operations [@shamoon](https://github.com/shamoon) ([#13457](https://github.com/paperless-ngx/paperless-ngx/pull/13457))
|
||||||
|
- Fixhancement: better handle empty fields from AI suggestions [@shamoon](https://github.com/shamoon) ([#13454](https://github.com/paperless-ngx/paperless-ngx/pull/13454))
|
||||||
|
- Fix: normalize monetary decimal symbol by locale [@shamoon](https://github.com/shamoon) ([#13427](https://github.com/paperless-ngx/paperless-ngx/pull/13427))
|
||||||
|
- Fix: fold overflowing path text in sanity checker [@stumpylog](https://github.com/stumpylog) ([#13426](https://github.com/paperless-ngx/paperless-ngx/pull/13426))
|
||||||
|
- Fix: correct delayed add field button for custom fields [@shamoon](https://github.com/shamoon) ([#13424](https://github.com/paperless-ngx/paperless-ngx/pull/13424))
|
||||||
|
- Fix: hide attributes collapse / show button without UI settings [@shamoon](https://github.com/shamoon) ([#13425](https://github.com/paperless-ngx/paperless-ngx/pull/13425))
|
||||||
|
- Fix: consolidate born-digital PDF detection between archive decision and OCR [@stumpylog](https://github.com/stumpylog) ([#13409](https://github.com/paperless-ngx/paperless-ngx/pull/13409))
|
||||||
|
- Fix: prevent scale vs page loop in pngx PDF viewer [@shamoon](https://github.com/shamoon) ([#13406](https://github.com/paperless-ngx/paperless-ngx/pull/13406))
|
||||||
|
- Fix: handle hidden line breaks in email subjects when sending email [@shamoon](https://github.com/shamoon) ([#13402](https://github.com/paperless-ngx/paperless-ngx/pull/13402))
|
||||||
|
- Fix: avoid NotSupportedError from document\_importer on MariaDB [@stumpylog](https://github.com/stumpylog) ([#13400](https://github.com/paperless-ngx/paperless-ngx/pull/13400))
|
||||||
|
- Fix: exclude next-period start from relative date-range filters [@stumpylog](https://github.com/stumpylog) ([#13381](https://github.com/paperless-ngx/paperless-ngx/pull/13381))
|
||||||
|
- Fix: close non-atomic db connections in before\_task\_publish [@shamoon](https://github.com/shamoon) ([#13366](https://github.com/paperless-ngx/paperless-ngx/pull/13366))
|
||||||
|
- Chore/fix: refactor frontend task service [@shamoon](https://github.com/shamoon) ([#13365](https://github.com/paperless-ngx/paperless-ngx/pull/13365))
|
||||||
|
- Fix: fix Enter selection in search autocomplete [@shamoon](https://github.com/shamoon) ([#13361](https://github.com/paperless-ngx/paperless-ngx/pull/13361))
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
- Documentation: PAPERLESS\_CONSUMER\_IGNORE\_PATTERNS clarifications [@shamoon](https://github.com/shamoon) ([#13489](https://github.com/paperless-ngx/paperless-ngx/pull/13489))
|
||||||
|
- Documentation: Add Password Removal workflow action documentation [@stumpylog](https://github.com/stumpylog) ([#13377](https://github.com/paperless-ngx/paperless-ngx/pull/13377))
|
||||||
|
|
||||||
|
### Dependencies
|
||||||
|
|
||||||
|
- Chore(deps): Bump pymdown-extensions from 10.21.3 to 11.0 in the uv group across 1 directory @[dependabot[bot]](https://github.com/apps/dependabot) ([#13378](https://github.com/paperless-ngx/paperless-ngx/pull/13378))
|
||||||
|
|
||||||
|
### All App Changes
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>21 changes</summary>
|
||||||
|
|
||||||
|
- Fix: accept Whoosh-era abbreviated relative-date units (yrs, mos, wks, etc) in search queries [@stumpylog](https://github.com/stumpylog) ([#13486](https://github.com/paperless-ngx/paperless-ngx/pull/13486))
|
||||||
|
- Fix: fix edit dialog error change detection [@shamoon](https://github.com/shamoon) ([#13483](https://github.com/paperless-ngx/paperless-ngx/pull/13483))
|
||||||
|
- Performance: sqlite-vec point-delete for document chunks [@stumpylog](https://github.com/stumpylog) ([#13438](https://github.com/paperless-ngx/paperless-ngx/pull/13438))
|
||||||
|
- Fix: key the AI suggestion cache by model and endpoint [@lunetics](https://github.com/lunetics) ([#13449](https://github.com/paperless-ngx/paperless-ngx/pull/13449))
|
||||||
|
- Fix: validate custom field values in bulk operations [@shamoon](https://github.com/shamoon) ([#13457](https://github.com/paperless-ngx/paperless-ngx/pull/13457))
|
||||||
|
- Fixhancement: better handle empty fields from AI suggestions [@shamoon](https://github.com/shamoon) ([#13454](https://github.com/paperless-ngx/paperless-ngx/pull/13454))
|
||||||
|
- Performance: Use server side iterators during LLM index updating [@stumpylog](https://github.com/stumpylog) ([#13430](https://github.com/paperless-ngx/paperless-ngx/pull/13430))
|
||||||
|
- Fix: normalize monetary decimal symbol by locale [@shamoon](https://github.com/shamoon) ([#13427](https://github.com/paperless-ngx/paperless-ngx/pull/13427))
|
||||||
|
- Fix: fold overflowing path text in sanity checker [@stumpylog](https://github.com/stumpylog) ([#13426](https://github.com/paperless-ngx/paperless-ngx/pull/13426))
|
||||||
|
- Fix: correct delayed add field button for custom fields [@shamoon](https://github.com/shamoon) ([#13424](https://github.com/paperless-ngx/paperless-ngx/pull/13424))
|
||||||
|
- Fix: hide attributes collapse / show button without UI settings [@shamoon](https://github.com/shamoon) ([#13425](https://github.com/paperless-ngx/paperless-ngx/pull/13425))
|
||||||
|
- Fix: consolidate born-digital PDF detection between archive decision and OCR [@stumpylog](https://github.com/stumpylog) ([#13409](https://github.com/paperless-ngx/paperless-ngx/pull/13409))
|
||||||
|
- Fix: prevent scale vs page loop in pngx PDF viewer [@shamoon](https://github.com/shamoon) ([#13406](https://github.com/paperless-ngx/paperless-ngx/pull/13406))
|
||||||
|
- Fix: handle hidden line breaks in email subjects when sending email [@shamoon](https://github.com/shamoon) ([#13402](https://github.com/paperless-ngx/paperless-ngx/pull/13402))
|
||||||
|
- Fix: avoid NotSupportedError from document\_importer on MariaDB [@stumpylog](https://github.com/stumpylog) ([#13400](https://github.com/paperless-ngx/paperless-ngx/pull/13400))
|
||||||
|
- Tweak: adjust doc details button toolbar flow [@shamoon](https://github.com/shamoon) ([#13382](https://github.com/paperless-ngx/paperless-ngx/pull/13382))
|
||||||
|
- Fix: exclude next-period start from relative date-range filters [@stumpylog](https://github.com/stumpylog) ([#13381](https://github.com/paperless-ngx/paperless-ngx/pull/13381))
|
||||||
|
- Chore(deps): Bump pymdown-extensions from 10.21.3 to 11.0 in the uv group across 1 directory @[dependabot[bot]](https://github.com/apps/dependabot) ([#13378](https://github.com/paperless-ngx/paperless-ngx/pull/13378))
|
||||||
|
- Fix: close non-atomic db connections in before\_task\_publish [@shamoon](https://github.com/shamoon) ([#13366](https://github.com/paperless-ngx/paperless-ngx/pull/13366))
|
||||||
|
- Chore/fix: refactor frontend task service [@shamoon](https://github.com/shamoon) ([#13365](https://github.com/paperless-ngx/paperless-ngx/pull/13365))
|
||||||
|
- Fix: fix Enter selection in search autocomplete [@shamoon](https://github.com/shamoon) ([#13361](https://github.com/paperless-ngx/paperless-ngx/pull/13361))
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
## paperless-ngx 3.0.4
|
## paperless-ngx 3.0.4
|
||||||
|
|
||||||
### Bug Fixes
|
### Bug Fixes
|
||||||
|
|||||||
+32
-6
@@ -776,6 +776,24 @@ system. See the corresponding
|
|||||||
|
|
||||||
Defaults to "groups"
|
Defaults to "groups"
|
||||||
|
|
||||||
|
#### [`PAPERLESS_SOCIAL_ACCOUNT_SYNC_SUPERUSER_GROUP=<str>`](#PAPERLESS_SOCIAL_ACCOUNT_SYNC_SUPERUSER_GROUP) {#PAPERLESS_SOCIAL_ACCOUNT_SYNC_SUPERUSER_GROUP}
|
||||||
|
|
||||||
|
: Allows you to define a group name that, if present in the third-party authentication system's groups claim, will grant the user superuser (admin) and staff status in Paperless-ngx. If the group is not present in the claim, superuser status will be revoked upon next login.
|
||||||
|
|
||||||
|
!!! warning
|
||||||
|
This is a direct reflection of the claim on every login, including the connecting user, with no exemption for the last remaining admin. If the group is missing or misconfigured on the identity provider side, the logged-in user will immediately lose their own superuser access. Fix the group membership or claim mapping on the identity provider to restore it. If the identity provider itself is unreachable or misconfigured and you are locked out, you can recover admin access locally with `manage.py createsuperuser`.
|
||||||
|
|
||||||
|
Defaults to None
|
||||||
|
|
||||||
|
#### [`PAPERLESS_SOCIAL_ACCOUNT_SYNC_STAFF_GROUP=<str>`](#PAPERLESS_SOCIAL_ACCOUNT_SYNC_STAFF_GROUP) {#PAPERLESS_SOCIAL_ACCOUNT_SYNC_STAFF_GROUP}
|
||||||
|
|
||||||
|
: Allows you to define a group name that, if present in the third-party authentication system's groups claim, will grant the user staff status in Paperless-ngx. If the group is not present in the claim and the user is not a superuser, staff status will be revoked upon next login.
|
||||||
|
|
||||||
|
!!! warning
|
||||||
|
As with [`PAPERLESS_SOCIAL_ACCOUNT_SYNC_SUPERUSER_GROUP`](#PAPERLESS_SOCIAL_ACCOUNT_SYNC_SUPERUSER_GROUP), this is applied on every login unconditionally, including for the connecting user themselves.
|
||||||
|
|
||||||
|
Defaults to None
|
||||||
|
|
||||||
#### [`PAPERLESS_SOCIAL_ACCOUNT_DEFAULT_GROUPS=<comma-separated-list>`](#PAPERLESS_SOCIAL_ACCOUNT_DEFAULT_GROUPS) {#PAPERLESS_SOCIAL_ACCOUNT_DEFAULT_GROUPS}
|
#### [`PAPERLESS_SOCIAL_ACCOUNT_DEFAULT_GROUPS=<comma-separated-list>`](#PAPERLESS_SOCIAL_ACCOUNT_DEFAULT_GROUPS) {#PAPERLESS_SOCIAL_ACCOUNT_DEFAULT_GROUPS}
|
||||||
|
|
||||||
: A list of group names that users who signup via social accounts will be added to upon signup. Groups listed here must already exist.
|
: A list of group names that users who signup via social accounts will be added to upon signup. Groups listed here must already exist.
|
||||||
@@ -948,10 +966,11 @@ for display in the web interface.
|
|||||||
|
|
||||||
!!! note
|
!!! note
|
||||||
|
|
||||||
The **remote OCR parser** (Azure AI) always produces a searchable
|
The **remote OCR parser** (Azure AI) also honors this setting: when
|
||||||
PDF and stores it as the archive copy, regardless of this setting.
|
no archive is requested (`never`, or `auto` with a born-digital PDF),
|
||||||
`ARCHIVE_FILE_GENERATION=never` has no effect when the remote
|
the remote engine is skipped entirely and locally-extracted text is
|
||||||
parser handles a document.
|
used instead, avoiding an unnecessary API call and a duplicate text
|
||||||
|
layer.
|
||||||
|
|
||||||
#### [`PAPERLESS_OCR_CLEAN=<mode>`](#PAPERLESS_OCR_CLEAN) {#PAPERLESS_OCR_CLEAN}
|
#### [`PAPERLESS_OCR_CLEAN=<mode>`](#PAPERLESS_OCR_CLEAN) {#PAPERLESS_OCR_CLEAN}
|
||||||
|
|
||||||
@@ -1360,13 +1379,16 @@ don't exist yet.
|
|||||||
#### [`PAPERLESS_CONSUMER_IGNORE_PATTERNS=<json>`](#PAPERLESS_CONSUMER_IGNORE_PATTERNS) {#PAPERLESS_CONSUMER_IGNORE_PATTERNS}
|
#### [`PAPERLESS_CONSUMER_IGNORE_PATTERNS=<json>`](#PAPERLESS_CONSUMER_IGNORE_PATTERNS) {#PAPERLESS_CONSUMER_IGNORE_PATTERNS}
|
||||||
|
|
||||||
: Additional regex patterns for files to ignore in the consumption directory. Patterns are matched against filenames only (not full paths)
|
: Additional regex patterns for files to ignore in the consumption directory. Patterns are matched against filenames only (not full paths)
|
||||||
using Python's `re.match()`, which anchors at the start of the filename.
|
using Python's `re.search()`. Use `^` to anchor a pattern to the start of the filename and `$` to anchor it to the end.
|
||||||
|
|
||||||
See the [watchfiles documentation](https://watchfiles.helpmanual.io/api/filters/#watchfiles.BaseFilter.ignore_entity_patterns)
|
See the [watchfiles documentation](https://watchfiles.helpmanual.io/api/filters/#watchfiles.BaseFilter.ignore_entity_patterns)
|
||||||
|
|
||||||
This setting is for additional patterns beyond the built-in defaults. Common system files and directories are already ignored automatically.
|
This setting is for additional patterns beyond the built-in defaults. Common system files and directories are already ignored automatically.
|
||||||
The patterns will be compiled via Python's standard `re` module.
|
The patterns will be compiled via Python's standard `re` module.
|
||||||
|
|
||||||
|
These are regular expressions, not glob patterns. For example, the glob pattern `._*` does not mean "starts with `._`" when used as a
|
||||||
|
regular expression; it matches nearly any non-empty filename. Use `^\._.*` for that behavior instead.
|
||||||
|
|
||||||
Example custom patterns:
|
Example custom patterns:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
@@ -1381,7 +1403,11 @@ using Python's `re.match()`, which anchors at the start of the filename.
|
|||||||
|
|
||||||
Defaults to `[]` (empty list, uses only built-in defaults).
|
Defaults to `[]` (empty list, uses only built-in defaults).
|
||||||
|
|
||||||
The default ignores are `[.DS_Store, .DS_STORE, ._*, desktop.ini, Thumbs.db]` and cannot be overridden.
|
The built-in file patterns are equivalent to the following regular expressions and cannot be overridden:
|
||||||
|
|
||||||
|
```json
|
||||||
|
["^\\.DS_Store$", "^\\.DS_STORE$", "^\\._.*", "^desktop\\.ini$", "^Thumbs\\.db$"]
|
||||||
|
```
|
||||||
|
|
||||||
#### [`PAPERLESS_CONSUMER_IGNORE_DIRS=<json>`](#PAPERLESS_CONSUMER_IGNORE_DIRS) {#PAPERLESS_CONSUMER_IGNORE_DIRS}
|
#### [`PAPERLESS_CONSUMER_IGNORE_DIRS=<json>`](#PAPERLESS_CONSUMER_IGNORE_DIRS) {#PAPERLESS_CONSUMER_IGNORE_DIRS}
|
||||||
|
|
||||||
|
|||||||
@@ -187,10 +187,11 @@ PAPERLESS_ARCHIVE_FILE_GENERATION=auto
|
|||||||
|
|
||||||
### Remote OCR parser
|
### Remote OCR parser
|
||||||
|
|
||||||
If you use the **remote OCR parser** (Azure AI), note that it always produces a
|
If you use the **remote OCR parser** (Azure AI), `ARCHIVE_FILE_GENERATION` is
|
||||||
searchable PDF and stores it as the archive copy. `ARCHIVE_FILE_GENERATION=never`
|
honored the same way as for the local engine: when no archive is requested
|
||||||
has no effect for documents handled by the remote parser - the archive is produced
|
(`never`, or `auto` with a born-digital PDF), the remote engine is skipped
|
||||||
unconditionally by the remote engine.
|
entirely and locally-extracted text is used instead, avoiding an unnecessary
|
||||||
|
API call and a duplicate text layer.
|
||||||
|
|
||||||
## Search Index (Whoosh -> Tantivy)
|
## Search Index (Whoosh -> Tantivy)
|
||||||
|
|
||||||
|
|||||||
+8
-15
@@ -416,22 +416,15 @@ to a positive number to enable polling and disable native filesystem notificatio
|
|||||||
You may need to change the path in the files. Example:
|
You may need to change the path in the files. Example:
|
||||||
`ExecStart=/opt/paperless/.local/bin/celery --app paperless worker --loglevel INFO`
|
`ExecStart=/opt/paperless/.local/bin/celery --app paperless worker --loglevel INFO`
|
||||||
|
|
||||||
12. Configure ImageMagick to allow processing of PDF documents. Most
|
12. Configure ImageMagick to allow processing of PDF documents and disable
|
||||||
distributions have this disabled by default, since PDF documents can
|
formats that Paperless-ngx does not use. Most distributions disable PDF
|
||||||
contain malware. If you don't do this, Paperless-ngx will fall back to
|
processing by default, since PDF documents can contain malware. If you
|
||||||
Ghostscript for certain steps such as thumbnail generation.
|
don't enable it, Paperless-ngx will fall back to Ghostscript for certain
|
||||||
|
steps such as thumbnail generation.
|
||||||
|
|
||||||
Edit `/etc/ImageMagick-6/policy.xml` and adjust
|
Configure the active ImageMagick policy file (commonly
|
||||||
|
`/etc/ImageMagick-6/policy.xml` or `/etc/ImageMagick-7/policy.xml`) and
|
||||||
```
|
adjust similar to [the docker policy file](https://raw.githubusercontent.com/paperless-ngx/paperless-ngx/refs/heads/main/docker/rootfs/etc/ImageMagick-6/paperless-policy.xml). You should also include restrictions as noted there.
|
||||||
<policy domain="coder" rights="none" pattern="PDF" />
|
|
||||||
```
|
|
||||||
|
|
||||||
to
|
|
||||||
|
|
||||||
```
|
|
||||||
<policy domain="coder" rights="read|write" pattern="PDF" />
|
|
||||||
```
|
|
||||||
|
|
||||||
**Optional: Install the [jbig2enc](https://ocrmypdf.readthedocs.io/en/latest/jbig2.html) encoder.**
|
**Optional: Install the [jbig2enc](https://ocrmypdf.readthedocs.io/en/latest/jbig2.html) encoder.**
|
||||||
This will reduce the size of generated PDF documents. You'll most likely need to compile this yourself, because this
|
This will reduce the size of generated PDF documents. You'll most likely need to compile this yourself, because this
|
||||||
|
|||||||
+7
-1
@@ -99,6 +99,10 @@ Think of versions as **file history** for a document.
|
|||||||
- By default, search and document content use the latest version.
|
- By default, search and document content use the latest version.
|
||||||
- In document detail, selecting a version switches the preview, file metadata and content (and download etc buttons) to that version.
|
- In document detail, selecting a version switches the preview, file metadata and content (and download etc buttons) to that version.
|
||||||
- Deleting a non-root version keeps metadata and falls back to the latest remaining version.
|
- Deleting a non-root version keeps metadata and falls back to the latest remaining version.
|
||||||
|
- From the document list, select two or more documents and choose **Merge as versions** to combine them under one entry. Select the root document whose metadata and permissions should be retained; the other selected documents become file versions. The root may already have versions, but documents being added as versions must not have version histories of their own.
|
||||||
|
- From a document's **Versions** menu, choose **Existing** to search for another document and add it as a version of the current document.
|
||||||
|
- Documents merged as versions give up their archive serial number. If the root has no ASN of its own it takes the first one, otherwise the ASNs are released and the removal is logged.
|
||||||
|
- Merging as versions cannot be undone from the UI, and deleting the root document moves its versions to the trash as well.
|
||||||
|
|
||||||
### Management Lists
|
### Management Lists
|
||||||
|
|
||||||
@@ -576,7 +580,9 @@ The following workflow action types are available:
|
|||||||
- Tags, correspondent, document type and storage path
|
- 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, optionally with a value. If no value is set, the field is only added to the
|
||||||
|
document and any value it may already have is left untouched. If a value is set, it will
|
||||||
|
overwrite an existing value of that field on the document.
|
||||||
|
|
||||||
##### Removal {#workflow-action-removal}
|
##### Removal {#workflow-action-removal}
|
||||||
|
|
||||||
|
|||||||
@@ -34,6 +34,8 @@ PAPERLESS_SECRET_KEY=change-me
|
|||||||
#PAPERLESS_AUTO_LOGIN_USERNAME=
|
#PAPERLESS_AUTO_LOGIN_USERNAME=
|
||||||
#PAPERLESS_COOKIE_PREFIX=
|
#PAPERLESS_COOKIE_PREFIX=
|
||||||
#PAPERLESS_ENABLE_HTTP_REMOTE_USER=false
|
#PAPERLESS_ENABLE_HTTP_REMOTE_USER=false
|
||||||
|
#PAPERLESS_SOCIAL_ACCOUNT_SYNC_SUPERUSER_GROUP=
|
||||||
|
#PAPERLESS_SOCIAL_ACCOUNT_SYNC_STAFF_GROUP=
|
||||||
|
|
||||||
# OCR settings
|
# OCR settings
|
||||||
|
|
||||||
|
|||||||
+31
-29
@@ -1,6 +1,6 @@
|
|||||||
[project]
|
[project]
|
||||||
name = "paperless-ngx"
|
name = "paperless-ngx"
|
||||||
version = "3.0.4"
|
version = "3.0.5"
|
||||||
description = "A community-supported supercharged document management system: scan, index and archive all your physical documents"
|
description = "A community-supported supercharged document management system: scan, index and archive all your physical documents"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
requires-python = ">=3.11"
|
requires-python = ">=3.11"
|
||||||
@@ -21,7 +21,7 @@ dependencies = [
|
|||||||
"channels~=4.2",
|
"channels~=4.2",
|
||||||
"channels-redis~=4.2",
|
"channels-redis~=4.2",
|
||||||
"concurrent-log-handler~=0.9.25",
|
"concurrent-log-handler~=0.9.25",
|
||||||
"dateparser~=1.2",
|
"dateparser~=1.4",
|
||||||
# WARNING: django does not use semver.
|
# WARNING: django does not use semver.
|
||||||
# Only patch versions are guaranteed to not introduce breaking changes.
|
# Only patch versions are guaranteed to not introduce breaking changes.
|
||||||
"django~=5.2.13",
|
"django~=5.2.13",
|
||||||
@@ -32,33 +32,32 @@ dependencies = [
|
|||||||
"django-cors-headers~=4.9.0",
|
"django-cors-headers~=4.9.0",
|
||||||
"django-extensions~=4.1",
|
"django-extensions~=4.1",
|
||||||
"django-filter~=25.1",
|
"django-filter~=25.1",
|
||||||
"django-guardian~=3.3.0",
|
"django-guardian~=3.3.3",
|
||||||
"django-multiselectfield~=1.0.1",
|
"django-multiselectfield~=1.0.1",
|
||||||
"django-rich~=2.2.0",
|
"django-rich~=2.2.0",
|
||||||
"django-soft-delete~=1.0.18",
|
"django-soft-delete~=1.0.18",
|
||||||
"django-treenode>=0.24",
|
"django-treenode>=0.24",
|
||||||
"djangorestframework~=3.16",
|
"djangorestframework~=3.16",
|
||||||
"djangorestframework-guardian~=0.4.0",
|
"drf-spectacular~=0.30",
|
||||||
"drf-spectacular~=0.28",
|
"drf-spectacular-sidecar~=2026.7.1",
|
||||||
"drf-spectacular-sidecar~=2026.5.1",
|
|
||||||
"drf-writable-nested~=0.7.1",
|
"drf-writable-nested~=0.7.1",
|
||||||
"filelock~=3.29.0",
|
"filelock~=3.32.0",
|
||||||
"flower~=2.0.1",
|
"flower~=2.0.1",
|
||||||
"gotenberg-client~=0.14.0",
|
"gotenberg-client~=0.14.0",
|
||||||
"httpx-oauth~=0.16",
|
"httpx-oauth~=0.17",
|
||||||
"ijson>=3.2",
|
"ijson>=3.5.1",
|
||||||
"imap-tools~=1.13.0",
|
"imap-tools~=1.14.0",
|
||||||
"jinja2~=3.1.5",
|
"jinja2~=3.1.5",
|
||||||
"langdetect~=1.0.9",
|
"langdetect~=1.0.9",
|
||||||
"llama-index-core>=0.14.22",
|
"llama-index-core>=0.14.23",
|
||||||
"llama-index-embeddings-huggingface>=0.6.1",
|
"llama-index-embeddings-huggingface>=0.6.1",
|
||||||
"llama-index-embeddings-ollama>=0.9",
|
"llama-index-embeddings-ollama>=0.9",
|
||||||
"llama-index-embeddings-openai-like>=0.2.2",
|
"llama-index-embeddings-openai-like>=0.2.2",
|
||||||
"llama-index-llms-ollama>=0.9.1",
|
"llama-index-llms-ollama>=0.9.1",
|
||||||
"llama-index-llms-openai-like>=0.7.1",
|
"llama-index-llms-openai-like>=0.7.1",
|
||||||
"nltk~=3.10.0",
|
"nltk~=3.10.0",
|
||||||
"ocrmypdf~=17.4.2",
|
"ocrmypdf~=17.7.0",
|
||||||
"openai>=2.32",
|
"openai>=2.48",
|
||||||
"pathvalidate~=3.3.1",
|
"pathvalidate~=3.3.1",
|
||||||
"pdf2image~=1.17.0",
|
"pdf2image~=1.17.0",
|
||||||
"python-dateutil~=2.9.0",
|
"python-dateutil~=2.9.0",
|
||||||
@@ -68,26 +67,26 @@ dependencies = [
|
|||||||
"python-magic~=0.4.27",
|
"python-magic~=0.4.27",
|
||||||
"rapidfuzz~=3.14.5",
|
"rapidfuzz~=3.14.5",
|
||||||
"redis[hiredis]~=5.2.1",
|
"redis[hiredis]~=5.2.1",
|
||||||
"regex>=2026.4.4",
|
"regex>=2026.7.19",
|
||||||
"scikit-learn~=1.8.0",
|
"scikit-learn~=1.9.0",
|
||||||
"sentence-transformers>=5.4.1",
|
"sentence-transformers>=5.6.1",
|
||||||
"setproctitle~=1.3.4",
|
"setproctitle~=1.3.4",
|
||||||
"sqlite-vec==0.1.9",
|
"sqlite-vec==0.1.9",
|
||||||
"tantivy~=0.26.0",
|
"tantivy~=0.26.0",
|
||||||
"tika-client~=0.11.0",
|
"tika-client~=0.11.0",
|
||||||
"torch~=2.13.0",
|
"torch~=2.13.0",
|
||||||
"watchfiles>=1.1.1",
|
"watchfiles>=1.2",
|
||||||
"whitenoise~=6.11",
|
"whitenoise~=6.11",
|
||||||
"zxing-cpp~=3.0.0",
|
"zxing-cpp~=3.1.0",
|
||||||
]
|
]
|
||||||
[project.optional-dependencies]
|
[project.optional-dependencies]
|
||||||
mariadb = [
|
mariadb = [
|
||||||
"mysqlclient~=2.2.7",
|
"mysqlclient~=2.2.7",
|
||||||
]
|
]
|
||||||
postgres = [
|
postgres = [
|
||||||
"psycopg[c,pool]==3.3",
|
"psycopg[c,pool]==3.3.4",
|
||||||
# Direct dependency for proper resolution of the pre-built wheels
|
# Direct dependency for proper resolution of the pre-built wheels
|
||||||
"psycopg-c==3.3",
|
"psycopg-c==3.3.4",
|
||||||
"psycopg-pool==3.3.1",
|
"psycopg-pool==3.3.1",
|
||||||
]
|
]
|
||||||
webserver = [
|
webserver = [
|
||||||
@@ -101,25 +100,25 @@ dev = [
|
|||||||
{ include-group = "testing" },
|
{ include-group = "testing" },
|
||||||
]
|
]
|
||||||
docs = [
|
docs = [
|
||||||
"zensical>=0.0.47",
|
"zensical>=0.0.51",
|
||||||
]
|
]
|
||||||
lint = [
|
lint = [
|
||||||
"prek~=0.3.10",
|
"prek~=0.4.11",
|
||||||
"ruff~=0.15.20",
|
"ruff~=0.16.1",
|
||||||
]
|
]
|
||||||
testing = [
|
testing = [
|
||||||
"daphne",
|
"daphne",
|
||||||
"factory-boy~=3.3.1",
|
"factory-boy~=3.3.1",
|
||||||
"faker~=40.15.0",
|
"faker~=40.36.0",
|
||||||
"imagehash",
|
"imagehash",
|
||||||
"pytest~=9.0.3",
|
"pytest~=9.1.1",
|
||||||
"pytest-cov~=7.1.0",
|
"pytest-cov~=7.1.0",
|
||||||
"pytest-django~=4.12.0",
|
"pytest-django~=4.12.0",
|
||||||
"pytest-env~=1.6.0",
|
"pytest-env~=1.7.0",
|
||||||
"pytest-httpx",
|
"pytest-httpx",
|
||||||
"pytest-mock~=3.15.1",
|
"pytest-mock~=3.15.1",
|
||||||
# "pytest-randomly~=4.0.1",
|
# "pytest-randomly~=4.0.1",
|
||||||
"pytest-rerunfailures~=16.1",
|
"pytest-rerunfailures~=16.4",
|
||||||
"pytest-sugar",
|
"pytest-sugar",
|
||||||
"pytest-xdist~=3.8.0",
|
"pytest-xdist~=3.8.0",
|
||||||
"time-machine>=2.13",
|
"time-machine>=2.13",
|
||||||
@@ -161,8 +160,10 @@ explicit = true
|
|||||||
[tool.uv.sources]
|
[tool.uv.sources]
|
||||||
# Markers are chosen to select these almost exclusively when building the Docker image
|
# Markers are chosen to select these almost exclusively when building the Docker image
|
||||||
psycopg-c = [
|
psycopg-c = [
|
||||||
{ url = "https://github.com/paperless-ngx/builder/releases/download/psycopg-trixie-3.3.0/psycopg_c-3.3.0-cp312-cp312-linux_x86_64.whl", marker = "sys_platform == 'linux' and platform_machine == 'x86_64' and python_version == '3.12'" },
|
{ url = "https://github.com/paperless-ngx/builder/releases/download/psycopg-trixie-3.3.4/psycopg_c-3.3.4-cp312-cp312-linux_x86_64.whl", marker = "sys_platform == 'linux' and platform_machine == 'x86_64' and python_version == '3.12'" },
|
||||||
{ url = "https://github.com/paperless-ngx/builder/releases/download/psycopg-trixie-3.3.0/psycopg_c-3.3.0-cp312-cp312-linux_aarch64.whl", marker = "sys_platform == 'linux' and platform_machine == 'aarch64' and python_version == '3.12'" },
|
{ url = "https://github.com/paperless-ngx/builder/releases/download/psycopg-trixie-3.3.4/psycopg_c-3.3.4-cp312-cp312-linux_aarch64.whl", marker = "sys_platform == 'linux' and platform_machine == 'aarch64' and python_version == '3.12'" },
|
||||||
|
{ url = "https://github.com/paperless-ngx/builder/releases/download/psycopg-trixie-3.3.4/psycopg_c-3.3.4-cp314-cp314-linux_x86_64.whl", marker = "sys_platform == 'linux' and platform_machine == 'x86_64' and python_version == '3.14'" },
|
||||||
|
{ url = "https://github.com/paperless-ngx/builder/releases/download/psycopg-trixie-3.3.4/psycopg_c-3.3.4-cp314-cp314-linux_aarch64.whl", marker = "sys_platform == 'linux' and platform_machine == 'aarch64' and python_version == '3.14'" },
|
||||||
]
|
]
|
||||||
torch = [
|
torch = [
|
||||||
{ index = "pytorch-cpu" },
|
{ index = "pytorch-cpu" },
|
||||||
@@ -183,6 +184,7 @@ output-format = "grouped"
|
|||||||
line-ending = "lf"
|
line-ending = "lf"
|
||||||
[tool.ruff.lint]
|
[tool.ruff.lint]
|
||||||
# https://docs.astral.sh/ruff/rules/
|
# https://docs.astral.sh/ruff/rules/
|
||||||
|
select = [ "E4", "E7", "E9", "F" ]
|
||||||
extend-select = [
|
extend-select = [
|
||||||
"COM", # https://docs.astral.sh/ruff/rules/#flake8-commas-com
|
"COM", # https://docs.astral.sh/ruff/rules/#flake8-commas-com
|
||||||
"DJ", # https://docs.astral.sh/ruff/rules/#flake8-django-dj
|
"DJ", # https://docs.astral.sh/ruff/rules/#flake8-django-dj
|
||||||
|
|||||||
+12
-9
@@ -56,13 +56,13 @@
|
|||||||
},
|
},
|
||||||
"architect": {
|
"architect": {
|
||||||
"build": {
|
"build": {
|
||||||
"builder": "@angular-builders/custom-webpack:browser",
|
"builder": "@angular/build:application",
|
||||||
"options": {
|
"options": {
|
||||||
"customWebpackConfig": {
|
"outputPath": {
|
||||||
"path": "./extra-webpack.config.ts"
|
"base": "dist/paperless-ui",
|
||||||
|
"browser": ""
|
||||||
},
|
},
|
||||||
"outputPath": "dist/paperless-ui",
|
"browser": "src/main.ts",
|
||||||
"main": "src/main.ts",
|
|
||||||
"outputHashing": "none",
|
"outputHashing": "none",
|
||||||
"index": "src/index.html",
|
"index": "src/index.html",
|
||||||
"polyfills": [
|
"polyfills": [
|
||||||
@@ -97,6 +97,7 @@
|
|||||||
"scripts": [],
|
"scripts": [],
|
||||||
"allowedCommonJsDependencies": [
|
"allowedCommonJsDependencies": [
|
||||||
"file-saver",
|
"file-saver",
|
||||||
|
"mime-names",
|
||||||
"utif"
|
"utif"
|
||||||
],
|
],
|
||||||
"extractLicenses": false,
|
"extractLicenses": false,
|
||||||
@@ -117,11 +118,13 @@
|
|||||||
"with": "src/environments/environment.prod.ts"
|
"with": "src/environments/environment.prod.ts"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"outputPath": "../src/documents/static/frontend/",
|
"outputPath": {
|
||||||
|
"base": "../src/documents/static/frontend/",
|
||||||
|
"browser": ""
|
||||||
|
},
|
||||||
"optimization": true,
|
"optimization": true,
|
||||||
"outputHashing": "none",
|
"outputHashing": "none",
|
||||||
"sourceMap": false,
|
"sourceMap": false,
|
||||||
"namedChunks": false,
|
|
||||||
"extractLicenses": true,
|
"extractLicenses": true,
|
||||||
"budgets": [
|
"budgets": [
|
||||||
{
|
{
|
||||||
@@ -145,7 +148,7 @@
|
|||||||
"defaultConfiguration": ""
|
"defaultConfiguration": ""
|
||||||
},
|
},
|
||||||
"serve": {
|
"serve": {
|
||||||
"builder": "@angular-builders/custom-webpack:dev-server",
|
"builder": "@angular/build:dev-server",
|
||||||
"options": {
|
"options": {
|
||||||
"buildTarget": "paperless-ui:build:en-US"
|
"buildTarget": "paperless-ui:build:en-US"
|
||||||
},
|
},
|
||||||
@@ -156,7 +159,7 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"extract-i18n": {
|
"extract-i18n": {
|
||||||
"builder": "@angular-builders/custom-webpack:extract-i18n",
|
"builder": "@angular/build:extract-i18n",
|
||||||
"options": {
|
"options": {
|
||||||
"buildTarget": "paperless-ui:build"
|
"buildTarget": "paperless-ui:build"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,24 +0,0 @@
|
|||||||
import {
|
|
||||||
CustomWebpackBrowserSchema,
|
|
||||||
TargetOptions,
|
|
||||||
} from '@angular-builders/custom-webpack'
|
|
||||||
import * as webpack from 'webpack'
|
|
||||||
const { codecovWebpackPlugin } = require('@codecov/webpack-plugin')
|
|
||||||
|
|
||||||
export default (
|
|
||||||
config: webpack.Configuration,
|
|
||||||
options: CustomWebpackBrowserSchema,
|
|
||||||
targetOptions: TargetOptions
|
|
||||||
) => {
|
|
||||||
if (config.plugins) {
|
|
||||||
config.plugins.push(
|
|
||||||
codecovWebpackPlugin({
|
|
||||||
enableBundleAnalysis: process.env.CODECOV_TOKEN !== undefined,
|
|
||||||
bundleName: 'paperless-ngx',
|
|
||||||
uploadToken: process.env.CODECOV_TOKEN,
|
|
||||||
})
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return config
|
|
||||||
}
|
|
||||||
+1482
-913
File diff suppressed because it is too large
Load Diff
+32
-33
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "paperless-ngx-ui",
|
"name": "paperless-ngx-ui",
|
||||||
"version": "3.0.4",
|
"version": "3.0.5",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"preinstall": "npx only-allow pnpm",
|
"preinstall": "npx only-allow pnpm",
|
||||||
"ng": "ng",
|
"ng": "ng",
|
||||||
@@ -11,20 +11,21 @@
|
|||||||
},
|
},
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@angular/cdk": "^22.0.3",
|
"@angular/cdk": "^22.1.1",
|
||||||
"@angular/common": "~22.0.5",
|
"@angular/common": "~22.1.1",
|
||||||
"@angular/compiler": "~22.0.5",
|
"@angular/compiler": "~22.1.1",
|
||||||
"@angular/core": "~22.0.5",
|
"@angular/core": "~22.1.1",
|
||||||
"@angular/forms": "~22.0.5",
|
"@angular/forms": "~22.1.1",
|
||||||
"@angular/localize": "~22.0.5",
|
"@angular/localize": "~22.1.1",
|
||||||
"@angular/platform-browser": "~22.0.5",
|
"@angular/platform-browser": "~22.1.1",
|
||||||
"@angular/router": "~22.0.5",
|
"@angular/router": "~22.1.1",
|
||||||
"@ng-bootstrap/ng-bootstrap": "^21.0.0",
|
"@ng-bootstrap/ng-bootstrap": "^21.0.0",
|
||||||
"@ng-select/ng-select": "^23.2.0",
|
"@ng-select/ng-select": "~23.6.0",
|
||||||
"@ngneat/dirty-check-forms": "^3.0.3",
|
"@ngneat/dirty-check-forms": "^3.0.3",
|
||||||
"@popperjs/core": "^2.11.8",
|
"@popperjs/core": "^2.11.8",
|
||||||
"bootstrap": "^5.3.8",
|
"bootstrap": "^5.3.8",
|
||||||
"file-saver": "^2.0.5",
|
"file-saver": "^2.0.5",
|
||||||
|
"lodash-es": "^4.18.1",
|
||||||
"mime-names": "^1.0.0",
|
"mime-names": "^1.0.0",
|
||||||
"ngx-bootstrap-icons": "^1.9.3",
|
"ngx-bootstrap-icons": "^1.9.3",
|
||||||
"ngx-color": "^10.1.0",
|
"ngx-color": "^10.1.0",
|
||||||
@@ -32,42 +33,40 @@
|
|||||||
"ngx-device-detector": "^12.0.0",
|
"ngx-device-detector": "^12.0.0",
|
||||||
"ngx-ui-tour-ng-bootstrap": "^19.0.0",
|
"ngx-ui-tour-ng-bootstrap": "^19.0.0",
|
||||||
"normalize-diacritics": "^5.0.0",
|
"normalize-diacritics": "^5.0.0",
|
||||||
"pdfjs-dist": "^6.0.227",
|
"pdfjs-dist": "^6.2.108",
|
||||||
"rxjs": "^7.8.2",
|
"rxjs": "^7.8.2",
|
||||||
"tslib": "^2.8.1",
|
"tslib": "^2.8.1",
|
||||||
"utif": "^3.1.0",
|
"utif": "^3.1.0",
|
||||||
"uuid": "^14.0.1"
|
"uuid": "^14.0.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@angular-builders/custom-webpack": "^22.0.1",
|
|
||||||
"@angular-builders/jest": "^22.0.1",
|
"@angular-builders/jest": "^22.0.1",
|
||||||
"@angular-devkit/core": "^22.0.5",
|
"@angular-devkit/core": "^22.1.3",
|
||||||
"@angular-devkit/schematics": "^22.0.5",
|
"@angular-devkit/schematics": "^22.1.3",
|
||||||
"@angular-eslint/builder": "22.0.0",
|
"@angular-eslint/builder": "22.1.0",
|
||||||
"@angular-eslint/eslint-plugin": "22.0.0",
|
"@angular-eslint/eslint-plugin": "22.1.0",
|
||||||
"@angular-eslint/eslint-plugin-template": "22.0.0",
|
"@angular-eslint/eslint-plugin-template": "22.1.0",
|
||||||
"@angular-eslint/schematics": "22.0.0",
|
"@angular-eslint/schematics": "22.1.0",
|
||||||
"@angular-eslint/template-parser": "22.0.0",
|
"@angular-eslint/template-parser": "22.1.0",
|
||||||
"@angular/build": "^22.0.5",
|
"@angular/build": "22.1.3",
|
||||||
"@angular/cli": "~22.0.5",
|
"@angular/cli": "22.1.3",
|
||||||
"@angular/compiler-cli": "~22.0.5",
|
"@angular/compiler-cli": "~22.1.1",
|
||||||
"@codecov/webpack-plugin": "^2.0.1",
|
"@playwright/test": "^1.62.1",
|
||||||
"@playwright/test": "^1.61.1",
|
|
||||||
"@types/jest": "^30.0.0",
|
"@types/jest": "^30.0.0",
|
||||||
"@types/node": "^26.0.0",
|
"@types/node": "^26.2.0",
|
||||||
"@typescript-eslint/eslint-plugin": "^8.62.0",
|
"@typescript-eslint/eslint-plugin": "^8.67.0",
|
||||||
"@typescript-eslint/parser": "^8.62.0",
|
"@typescript-eslint/parser": "^8.67.0",
|
||||||
"@typescript-eslint/utils": "^8.62.0",
|
"@typescript-eslint/utils": "^8.67.0",
|
||||||
"eslint": "^10.5.0",
|
"eslint": "^10.8.1",
|
||||||
"jest": "30.4.2",
|
"jest": "30.4.2",
|
||||||
"jest-environment-jsdom": "^30.4.1",
|
"jest-environment-jsdom": "^30.4.1",
|
||||||
"jest-junit": "^17.0.0",
|
"jest-junit": "^17.0.0",
|
||||||
"jest-preset-angular": "^17.0.0",
|
"jest-preset-angular": "^17.0.0",
|
||||||
"jest-websocket-mock": "^2.5.0",
|
"jest-websocket-mock": "^2.5.0",
|
||||||
|
"prettier": "^3.9.6",
|
||||||
"prettier-plugin-organize-imports": "^4.3.0",
|
"prettier-plugin-organize-imports": "^4.3.0",
|
||||||
"ts-node": "~10.9.1",
|
"ts-node": "~10.9.2",
|
||||||
"typescript": "^6.0.3",
|
"typescript": "^6.0.3"
|
||||||
"webpack": "^5.107.2"
|
|
||||||
},
|
},
|
||||||
"packageManager": "pnpm@10.26.0"
|
"packageManager": "pnpm@11.15.1"
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+2004
-6666
File diff suppressed because it is too large
Load Diff
@@ -5,6 +5,8 @@ trustPolicy: no-downgrade
|
|||||||
trustPolicyExclude:
|
trustPolicyExclude:
|
||||||
- "chokidar@4.0.3"
|
- "chokidar@4.0.3"
|
||||||
- "semver@6.3.1 || 5.7.2"
|
- "semver@6.3.1 || 5.7.2"
|
||||||
|
blockExoticSubdeps: true
|
||||||
|
autoInstallPeers: false
|
||||||
allowBuilds:
|
allowBuilds:
|
||||||
"@parcel/watcher": true
|
"@parcel/watcher": true
|
||||||
canvas: true
|
canvas: true
|
||||||
|
|||||||
@@ -576,7 +576,7 @@ describe('TasksComponent', () => {
|
|||||||
|
|
||||||
expect(dismissSpy).toHaveBeenCalledWith(new Set([tasks[0].id, tasks[1].id]))
|
expect(dismissSpy).toHaveBeenCalledWith(new Set([tasks[0].id, tasks[1].id]))
|
||||||
expect(toastSpy).toHaveBeenCalledWith('Error dismissing tasks', error)
|
expect(toastSpy).toHaveBeenCalledWith('Error dismissing tasks', error)
|
||||||
expect(modal.componentInstance.buttonsEnabled).toBe(true)
|
expect(modal.componentInstance.buttonsEnabled()).toBe(true)
|
||||||
expect(component.selectedTasks.size).toBe(0)
|
expect(component.selectedTasks.size).toBe(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -642,7 +642,7 @@ describe('TasksComponent', () => {
|
|||||||
|
|
||||||
expect(dismissSpy).toHaveBeenCalled()
|
expect(dismissSpy).toHaveBeenCalled()
|
||||||
expect(toastSpy).toHaveBeenCalledWith('Error dismissing tasks', error)
|
expect(toastSpy).toHaveBeenCalledWith('Error dismissing tasks', error)
|
||||||
expect(modal.componentInstance.buttonsEnabled).toBe(true)
|
expect(modal.componentInstance.buttonsEnabled()).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should dismiss the currently visible scoped and filtered tasks', () => {
|
it('should dismiss the currently visible scoped and filtered tasks', () => {
|
||||||
|
|||||||
@@ -316,7 +316,7 @@ export class TasksComponent
|
|||||||
modal.componentInstance.btnClass = 'btn-warning'
|
modal.componentInstance.btnClass = 'btn-warning'
|
||||||
modal.componentInstance.btnCaption = $localize`Dismiss`
|
modal.componentInstance.btnCaption = $localize`Dismiss`
|
||||||
modal.componentInstance.confirmClicked.pipe(first()).subscribe(() => {
|
modal.componentInstance.confirmClicked.pipe(first()).subscribe(() => {
|
||||||
modal.componentInstance.buttonsEnabled = false
|
modal.componentInstance.buttonsEnabled.set(false)
|
||||||
modal.close()
|
modal.close()
|
||||||
this.tasksService.dismissTasks(tasks).subscribe({
|
this.tasksService.dismissTasks(tasks).subscribe({
|
||||||
next: () => {
|
next: () => {
|
||||||
@@ -324,7 +324,7 @@ export class TasksComponent
|
|||||||
},
|
},
|
||||||
error: (e) => {
|
error: (e) => {
|
||||||
this.toastService.showError($localize`Error dismissing tasks`, e)
|
this.toastService.showError($localize`Error dismissing tasks`, e)
|
||||||
modal.componentInstance.buttonsEnabled = true
|
modal.componentInstance.buttonsEnabled.set(true)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
this.clearSelection()
|
this.clearSelection()
|
||||||
@@ -350,7 +350,7 @@ export class TasksComponent
|
|||||||
modal.componentInstance.btnClass = 'btn-warning'
|
modal.componentInstance.btnClass = 'btn-warning'
|
||||||
modal.componentInstance.btnCaption = $localize`Dismiss`
|
modal.componentInstance.btnCaption = $localize`Dismiss`
|
||||||
modal.componentInstance.confirmClicked.pipe(first()).subscribe(() => {
|
modal.componentInstance.confirmClicked.pipe(first()).subscribe(() => {
|
||||||
modal.componentInstance.buttonsEnabled = false
|
modal.componentInstance.buttonsEnabled.set(false)
|
||||||
modal.close()
|
modal.close()
|
||||||
this.tasksService.dismissAllTasks().subscribe({
|
this.tasksService.dismissAllTasks().subscribe({
|
||||||
next: () => {
|
next: () => {
|
||||||
@@ -358,7 +358,7 @@ export class TasksComponent
|
|||||||
},
|
},
|
||||||
error: (e) => {
|
error: (e) => {
|
||||||
this.toastService.showError($localize`Error dismissing tasks`, e)
|
this.toastService.showError($localize`Error dismissing tasks`, e)
|
||||||
modal.componentInstance.buttonsEnabled = true
|
modal.componentInstance.buttonsEnabled.set(true)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
this.clearSelection()
|
this.clearSelection()
|
||||||
|
|||||||
@@ -2,3 +2,8 @@
|
|||||||
.d-block.d-sm-none .dropdown-toggle::after {
|
.d-block.d-sm-none .dropdown-toggle::after {
|
||||||
display: none;
|
display: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
tbody tr:last-child td,
|
||||||
|
table:not(:has(tbody tr)) thead th {
|
||||||
|
border-bottom: none;
|
||||||
|
}
|
||||||
|
|||||||
@@ -82,7 +82,7 @@ export class TrashComponent
|
|||||||
modal.componentInstance.confirmClicked
|
modal.componentInstance.confirmClicked
|
||||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||||
.subscribe(() => {
|
.subscribe(() => {
|
||||||
modal.componentInstance.buttonsEnabled = false
|
modal.componentInstance.buttonsEnabled.set(false)
|
||||||
this.trashService.emptyTrash([document.id]).subscribe({
|
this.trashService.emptyTrash([document.id]).subscribe({
|
||||||
next: () => {
|
next: () => {
|
||||||
this.toastService.showInfo(
|
this.toastService.showInfo(
|
||||||
|
|||||||
@@ -146,7 +146,7 @@ export class UsersAndGroupsComponent
|
|||||||
modal.componentInstance.btnClass = 'btn-danger'
|
modal.componentInstance.btnClass = 'btn-danger'
|
||||||
modal.componentInstance.btnCaption = $localize`Proceed`
|
modal.componentInstance.btnCaption = $localize`Proceed`
|
||||||
modal.componentInstance.confirmClicked.subscribe(() => {
|
modal.componentInstance.confirmClicked.subscribe(() => {
|
||||||
modal.componentInstance.buttonsEnabled = false
|
modal.componentInstance.buttonsEnabled.set(false)
|
||||||
this.usersService.delete(user).subscribe({
|
this.usersService.delete(user).subscribe({
|
||||||
next: () => {
|
next: () => {
|
||||||
modal.close()
|
modal.close()
|
||||||
@@ -199,7 +199,7 @@ export class UsersAndGroupsComponent
|
|||||||
modal.componentInstance.btnClass = 'btn-danger'
|
modal.componentInstance.btnClass = 'btn-danger'
|
||||||
modal.componentInstance.btnCaption = $localize`Proceed`
|
modal.componentInstance.btnCaption = $localize`Proceed`
|
||||||
modal.componentInstance.confirmClicked.subscribe(() => {
|
modal.componentInstance.confirmClicked.subscribe(() => {
|
||||||
modal.componentInstance.buttonsEnabled = false
|
modal.componentInstance.buttonsEnabled.set(false)
|
||||||
this.groupsService.delete(group).subscribe({
|
this.groupsService.delete(group).subscribe({
|
||||||
next: () => {
|
next: () => {
|
||||||
modal.close()
|
modal.close()
|
||||||
|
|||||||
@@ -104,14 +104,14 @@
|
|||||||
</h6>
|
</h6>
|
||||||
<ul class="nav flex-column mb-2" cdkDropList (cdkDropListDropped)="onDrop($event)">
|
<ul class="nav flex-column mb-2" cdkDropList (cdkDropListDropped)="onDrop($event)">
|
||||||
@for (view of savedViewService.sidebarViews; track view.id) {
|
@for (view of savedViewService.sidebarViews; track view.id) {
|
||||||
<li class="nav-item w-100 app-link" cdkDrag [cdkDragDisabled]="!settingsService.organizingSidebarSavedViews()"
|
<li class="nav-item w-100 app-link" cdkDrag [cdkDragDisabled]="!settingsService.organizingSidebarSavedViews() || !canSaveSettings"
|
||||||
cdkDragPreviewContainer="parent" cdkDragPreviewClass="navItemDrag" (cdkDragStarted)="onDragStart($event)"
|
cdkDragPreviewContainer="parent" cdkDragPreviewClass="navItemDrag" (cdkDragStarted)="onDragStart($event)"
|
||||||
(cdkDragEnded)="onDragEnd($event)">
|
(cdkDragEnded)="onDragEnd($event)">
|
||||||
<a class="nav-link" routerLink="view/{{view.id}}"
|
<a class="nav-link" routerLink="view/{{view.id}}"
|
||||||
routerLinkActive="active" (click)="closeMenu()" [ngbPopover]="view.name"
|
routerLinkActive="active" (click)="closeMenu()" [ngbPopover]="view.name"
|
||||||
[disablePopover]="!slimSidebarEnabled" placement="end" container="body" triggers="mouseenter:mouseleave"
|
[disablePopover]="!slimSidebarEnabled" placement="end" container="body" triggers="mouseenter:mouseleave"
|
||||||
popoverClass="popover-slim">
|
popoverClass="popover-slim">
|
||||||
<i-bs class="me-2" name="funnel"></i-bs><span><div class="d-inline-flex view-name"><span class="overflow-hidden" [class.text-wrap]="!slimSidebarEnabled">{{view.name}}</span></div>
|
<i-bs class="me-2" [name]="view.icon || 'funnel'"></i-bs><span><div class="d-inline-flex view-name"><span class="overflow-hidden" [class.text-wrap]="!slimSidebarEnabled">{{view.name}}</span></div>
|
||||||
@if (showSidebarCounts && !slimSidebarEnabled) {
|
@if (showSidebarCounts && !slimSidebarEnabled) {
|
||||||
<span class="badge bg-info text-dark ms-2 d-inline">{{ savedViewService.getDocumentCount(view) }}</span>
|
<span class="badge bg-info text-dark ms-2 d-inline">{{ savedViewService.getDocumentCount(view) }}</span>
|
||||||
}
|
}
|
||||||
@@ -120,7 +120,7 @@
|
|||||||
<span class="badge bg-info text-dark position-absolute top-0 end-0 d-none d-md-block">{{ savedViewService.getDocumentCount(view) }}</span>
|
<span class="badge bg-info text-dark position-absolute top-0 end-0 d-none d-md-block">{{ savedViewService.getDocumentCount(view) }}</span>
|
||||||
}
|
}
|
||||||
</a>
|
</a>
|
||||||
@if (settingsService.organizingSidebarSavedViews()) {
|
@if (settingsService.organizingSidebarSavedViews() && canSaveSettings) {
|
||||||
<div class="position-absolute end-0 top-0 px-3 py-2" [class.me-n3]="slimSidebarEnabled" cdkDragHandle>
|
<div class="position-absolute end-0 top-0 px-3 py-2" [class.me-n3]="slimSidebarEnabled" cdkDragHandle>
|
||||||
<i-bs name="grip-vertical"></i-bs>
|
<i-bs name="grip-vertical"></i-bs>
|
||||||
</div>
|
</div>
|
||||||
@@ -182,7 +182,7 @@
|
|||||||
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||||
<i-bs class="me-2" name="stack"></i-bs><span><ng-container i18n>Attributes</ng-container></span>
|
<i-bs class="me-2" name="stack"></i-bs><span><ng-container i18n>Attributes</ng-container></span>
|
||||||
</a>
|
</a>
|
||||||
@if (!slimSidebarEnabled) {
|
@if (!slimSidebarEnabled && canSaveSettings) {
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="btn btn-link btn-sm text-muted p-0 me-3 attributes-expand-btn"
|
class="btn btn-link btn-sm text-muted p-0 me-3 attributes-expand-btn"
|
||||||
@@ -195,32 +195,32 @@
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="attributes-submenu ms-2"
|
class="attributes-submenu ms-3"
|
||||||
[ngbCollapse]="slimSidebarEnabled || attributesSectionsCollapsed"
|
[ngbCollapse]="slimSidebarEnabled || attributesSectionsCollapsed"
|
||||||
>
|
>
|
||||||
<ul class="nav flex-column">
|
<ul class="nav flex-column">
|
||||||
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Tag }">
|
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Tag }">
|
||||||
<a class="nav-link py-1" routerLink="attributes/tags" routerLinkActive="active" (click)="closeMenu()">
|
<a class="nav-link" routerLink="attributes/tags" routerLinkActive="active" (click)="closeMenu()">
|
||||||
<i-bs class="me-2" name="tags"></i-bs><span><ng-container i18n>Tags</ng-container></span>
|
<i-bs class="me-2" name="tags"></i-bs><span><ng-container i18n>Tags</ng-container></span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Correspondent }">
|
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Correspondent }">
|
||||||
<a class="nav-link py-1" routerLink="attributes/correspondents" routerLinkActive="active" (click)="closeMenu()">
|
<a class="nav-link" routerLink="attributes/correspondents" routerLinkActive="active" (click)="closeMenu()">
|
||||||
<i-bs class="me-2" name="person"></i-bs><span><ng-container i18n>Correspondents</ng-container></span>
|
<i-bs class="me-2" name="person"></i-bs><span><ng-container i18n>Correspondents</ng-container></span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.DocumentType }">
|
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.DocumentType }">
|
||||||
<a class="nav-link py-1" routerLink="attributes/documenttypes" routerLinkActive="active" (click)="closeMenu()">
|
<a class="nav-link" routerLink="attributes/documenttypes" routerLinkActive="active" (click)="closeMenu()">
|
||||||
<i-bs class="me-2" name="hash"></i-bs><span><ng-container i18n>Document types</ng-container></span>
|
<i-bs class="me-2" name="hash"></i-bs><span><ng-container i18n>Document types</ng-container></span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.StoragePath }">
|
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.StoragePath }">
|
||||||
<a class="nav-link py-1" routerLink="attributes/storagepaths" routerLinkActive="active" (click)="closeMenu()">
|
<a class="nav-link" routerLink="attributes/storagepaths" routerLinkActive="active" (click)="closeMenu()">
|
||||||
<i-bs class="me-2" name="folder"></i-bs><span><ng-container i18n>Storage paths</ng-container></span>
|
<i-bs class="me-2" name="folder"></i-bs><span><ng-container i18n>Storage paths</ng-container></span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.CustomField }">
|
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.CustomField }">
|
||||||
<a class="nav-link py-1" routerLink="attributes/customfields" routerLinkActive="active" (click)="closeMenu()">
|
<a class="nav-link" routerLink="attributes/customfields" routerLinkActive="active" (click)="closeMenu()">
|
||||||
<i-bs class="me-2" name="ui-radios"></i-bs><span><ng-container i18n>Custom fields</ng-container></span>
|
<i-bs class="me-2" name="ui-radios"></i-bs><span><ng-container i18n>Custom fields</ng-container></span>
|
||||||
</a>
|
</a>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
@@ -19,6 +19,11 @@
|
|||||||
height: 0.8em;
|
height: 0.8em;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.sidebar-heading,
|
||||||
|
.text-uppercase {
|
||||||
|
letter-spacing: 0.06em;
|
||||||
|
}
|
||||||
|
|
||||||
.view-name {
|
.view-name {
|
||||||
max-width: calc(100% - 50px)
|
max-width: calc(100% - 50px)
|
||||||
}
|
}
|
||||||
@@ -47,11 +52,12 @@
|
|||||||
|
|
||||||
.search-container {
|
.search-container {
|
||||||
max-height: 4.5rem;
|
max-height: 4.5rem;
|
||||||
overflow: hidden;
|
overflow: visible;
|
||||||
transition: max-height .2s ease, opacity .2s ease, padding-top .2s ease, padding-bottom .2s ease;
|
transition: max-height .2s ease, opacity .2s ease, padding-top .2s ease, padding-bottom .2s ease;
|
||||||
|
|
||||||
&.mobile-hidden {
|
&.mobile-hidden {
|
||||||
max-height: 0;
|
max-height: 0;
|
||||||
|
overflow: hidden;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
padding-top: 0 !important;
|
padding-top: 0 !important;
|
||||||
padding-bottom: 0 !important;
|
padding-bottom: 0 !important;
|
||||||
@@ -80,7 +86,7 @@ main {
|
|||||||
|
|
||||||
.sidebar li.nav-item span,
|
.sidebar li.nav-item span,
|
||||||
.sidebar .sidebar-heading span {
|
.sidebar .sidebar-heading span {
|
||||||
transition: all .1s ease;
|
transition: opacity .1s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media(min-width: 768px) {
|
@media(min-width: 768px) {
|
||||||
@@ -148,7 +154,7 @@ main {
|
|||||||
z-index: 996;
|
z-index: 996;
|
||||||
--bs-btn-padding-x: 0.35rem;
|
--bs-btn-padding-x: 0.35rem;
|
||||||
--bs-btn-padding-y: 0.125rem;
|
--bs-btn-padding-y: 0.125rem;
|
||||||
transition: all .2s ease;
|
transition: left .2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar.slim .sidebar-slim-toggler {
|
.sidebar.slim .sidebar-slim-toggler {
|
||||||
@@ -180,6 +186,8 @@ main {
|
|||||||
.sidebar .nav-link {
|
.sidebar .nav-link {
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
border-left: 2px solid transparent;
|
||||||
|
transition: color .15s ease-in-out;
|
||||||
|
|
||||||
&:hover, &.active, &:focus {
|
&:hover, &.active, &:focus {
|
||||||
color: var(--bs-primary);
|
color: var(--bs-primary);
|
||||||
@@ -192,6 +200,7 @@ main {
|
|||||||
|
|
||||||
&.active {
|
&.active {
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
|
border-left-color: var(--bs-primary);
|
||||||
}
|
}
|
||||||
|
|
||||||
i-bs {
|
i-bs {
|
||||||
@@ -200,6 +209,17 @@ main {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// sub-page gets marker only
|
||||||
|
.nav-item:has(.attributes-submenu.show .nav-link.active) > .attributes-row > .nav-link.active {
|
||||||
|
border-left-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
// bring sub-menu markers back out to L edge
|
||||||
|
.attributes-submenu .nav-link {
|
||||||
|
margin-left: -1rem;
|
||||||
|
padding-left: calc(var(--bs-nav-link-padding-x) + 1rem);
|
||||||
|
}
|
||||||
|
|
||||||
.attributes-row .attributes-expand-btn {
|
.attributes-row .attributes-expand-btn {
|
||||||
opacity: 0.2;
|
opacity: 0.2;
|
||||||
transition: opacity 0.15s ease-in-out;
|
transition: opacity 0.15s ease-in-out;
|
||||||
|
|||||||
@@ -12,10 +12,10 @@
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<button type="button" class="btn" [class]="cancelBtnClass" (click)="cancel()" [disabled]="!buttonsEnabled">
|
<button type="button" class="btn" [class]="cancelBtnClass" (click)="cancel()" [disabled]="!buttonsEnabled()">
|
||||||
<span class="d-inline-block" style="padding-bottom: 1px;">{{cancelBtnCaption}}</span>
|
<span class="d-inline-block" style="padding-bottom: 1px;">{{cancelBtnCaption}}</span>
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="btn" [class]="btnClass" (click)="confirm()" [disabled]="!confirmButtonEnabled || !buttonsEnabled">
|
<button type="button" class="btn" [class]="btnClass" (click)="confirm()" [disabled]="!confirmButtonEnabled || !buttonsEnabled()">
|
||||||
<span>
|
<span>
|
||||||
{{btnCaption}}
|
{{btnCaption}}
|
||||||
<span class="visually-hidden">{{ seconds | number: '1.0-0' }} seconds</span>
|
<span class="visually-hidden">{{ seconds | number: '1.0-0' }} seconds</span>
|
||||||
@@ -25,7 +25,7 @@
|
|||||||
}
|
}
|
||||||
</button>
|
</button>
|
||||||
@if (alternativeBtnCaption) {
|
@if (alternativeBtnCaption) {
|
||||||
<button type="button" class="btn" [class]="alternativeBtnClass" (click)="alternative()" [disabled]="!alternativeButtonEnabled || !buttonsEnabled">
|
<button type="button" class="btn" [class]="alternativeBtnClass" (click)="alternative()" [disabled]="!alternativeButtonEnabled || !buttonsEnabled()">
|
||||||
{{alternativeBtnCaption}}
|
{{alternativeBtnCaption}}
|
||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,6 +64,22 @@ describe('ConfirmDialogComponent', () => {
|
|||||||
expect(confirmSubjectResult).toBeTruthy()
|
expect(confirmSubjectResult).toBeTruthy()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should re-render the buttons when they are toggled from outside', async () => {
|
||||||
|
const confirmButton: HTMLButtonElement =
|
||||||
|
fixture.nativeElement.querySelectorAll('.modal-footer button')[1]
|
||||||
|
expect(confirmButton.disabled).toBeFalsy()
|
||||||
|
|
||||||
|
// Deliberately no detectChanges: a request callback toggling this is all
|
||||||
|
// that happens, and nothing else schedules a render for the modal
|
||||||
|
component.buttonsEnabled.set(false)
|
||||||
|
await fixture.whenStable()
|
||||||
|
expect(confirmButton.disabled).toBeTruthy()
|
||||||
|
|
||||||
|
component.buttonsEnabled.set(true)
|
||||||
|
await fixture.whenStable()
|
||||||
|
expect(confirmButton.disabled).toBeFalsy()
|
||||||
|
})
|
||||||
|
|
||||||
it('should support cancel & close modal', () => {
|
it('should support cancel & close modal', () => {
|
||||||
let confirmSubjectResult
|
let confirmSubjectResult
|
||||||
const closeModalSpy = jest.spyOn(modal, 'close')
|
const closeModalSpy = jest.spyOn(modal, 'close')
|
||||||
|
|||||||
@@ -1,5 +1,12 @@
|
|||||||
import { DecimalPipe } from '@angular/common'
|
import { DecimalPipe } from '@angular/common'
|
||||||
import { Component, EventEmitter, Input, Output, inject } from '@angular/core'
|
import {
|
||||||
|
Component,
|
||||||
|
EventEmitter,
|
||||||
|
Input,
|
||||||
|
Output,
|
||||||
|
inject,
|
||||||
|
signal,
|
||||||
|
} from '@angular/core'
|
||||||
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
|
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
|
||||||
import { Subject } from 'rxjs'
|
import { Subject } from 'rxjs'
|
||||||
import { LoadingComponentWithPermissions } from '../../loading-component/loading.component'
|
import { LoadingComponentWithPermissions } from '../../loading-component/loading.component'
|
||||||
@@ -46,8 +53,7 @@ export class ConfirmDialogComponent extends LoadingComponentWithPermissions {
|
|||||||
@Input()
|
@Input()
|
||||||
cancelBtnCaption = $localize`Cancel`
|
cancelBtnCaption = $localize`Cancel`
|
||||||
|
|
||||||
@Input()
|
readonly buttonsEnabled = signal(true)
|
||||||
buttonsEnabled = true
|
|
||||||
|
|
||||||
confirmButtonEnabled = true
|
confirmButtonEnabled = true
|
||||||
alternativeButtonEnabled = true
|
alternativeButtonEnabled = true
|
||||||
|
|||||||
+58
@@ -0,0 +1,58 @@
|
|||||||
|
<div class="modal-header">
|
||||||
|
<h4 class="modal-title" id="modal-basic-title">{{title}}</h4>
|
||||||
|
<button type="button" class="btn-close" aria-label="Close" (click)="cancel()"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<p>{{message}}</p>
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label" for="rootDocumentID" i18n>Root document:</label>
|
||||||
|
<select id="rootDocumentID" class="form-select" [ngModel]="rootDocumentID()" (ngModelChange)="rootDocumentID.set($event)">
|
||||||
|
@for (document of documents(); track document.id) {
|
||||||
|
<option [ngValue]="document.id">{{document.title}}</option>
|
||||||
|
}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="form-group mt-4">
|
||||||
|
<span class="form-label d-inline-block" i18n>Versions (oldest first):</span>
|
||||||
|
<ul class="list-group"
|
||||||
|
cdkDropList
|
||||||
|
[cdkDropListData]="versionDocumentIDs()"
|
||||||
|
(cdkDropListDropped)="onDrop($event)">
|
||||||
|
@for (documentID of versionDocumentIDs(); track documentID) {
|
||||||
|
@let document = getDocument(documentID);
|
||||||
|
@if (document) {
|
||||||
|
<li class="list-group-item d-flex align-items-center" cdkDrag>
|
||||||
|
<i-bs name="grip-vertical" class="me-2"></i-bs>
|
||||||
|
<div class="d-flex flex-column">
|
||||||
|
<div>
|
||||||
|
@if (document.correspondent) {
|
||||||
|
<b>{{document.correspondent | correspondentName | async}}: </b>
|
||||||
|
}{{document.title}}
|
||||||
|
</div>
|
||||||
|
<small class="text-muted">
|
||||||
|
{{document.created | customDate:'mediumDate'}}
|
||||||
|
@if (document.page_count) {
|
||||||
|
| {document.page_count, plural, =1 {One page} other {{{document.page_count}} pages}}
|
||||||
|
}
|
||||||
|
</small>
|
||||||
|
</div>
|
||||||
|
@if ($last) {
|
||||||
|
<span class="badge bg-primary ms-auto" i18n>Current version</span>
|
||||||
|
}
|
||||||
|
</li>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</ul>
|
||||||
|
@if (versionDocumentIDs().length > 1) {
|
||||||
|
<div class="form-text" i18n>Drag to reorder.</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn" [class]="cancelBtnClass" (click)="cancel()" [disabled]="!buttonsEnabled()">
|
||||||
|
<span class="d-inline-block" style="padding-bottom: 1px;">{{cancelBtnCaption}}</span>
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn" [class]="btnClass" (click)="confirm()" [disabled]="!confirmButtonEnabled || !buttonsEnabled()">
|
||||||
|
{{btnCaption}}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
+3
@@ -0,0 +1,3 @@
|
|||||||
|
.list-group-item {
|
||||||
|
cursor: move;
|
||||||
|
}
|
||||||
+70
@@ -0,0 +1,70 @@
|
|||||||
|
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
|
||||||
|
import { provideHttpClientTesting } from '@angular/common/http/testing'
|
||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||||
|
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
|
||||||
|
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
||||||
|
import { of } from 'rxjs'
|
||||||
|
import { DocumentService } from 'src/app/services/rest/document.service'
|
||||||
|
import { MergeAsVersionsConfirmDialogComponent } from './merge-as-versions-confirm-dialog.component'
|
||||||
|
|
||||||
|
describe('MergeAsVersionsConfirmDialogComponent', () => {
|
||||||
|
let component: MergeAsVersionsConfirmDialogComponent
|
||||||
|
let fixture: ComponentFixture<MergeAsVersionsConfirmDialogComponent>
|
||||||
|
let documentService: DocumentService
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [
|
||||||
|
NgxBootstrapIconsModule.pick(allIcons),
|
||||||
|
MergeAsVersionsConfirmDialogComponent,
|
||||||
|
],
|
||||||
|
providers: [
|
||||||
|
NgbActiveModal,
|
||||||
|
provideHttpClient(withInterceptorsFromDi()),
|
||||||
|
provideHttpClientTesting(),
|
||||||
|
],
|
||||||
|
}).compileComponents()
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(MergeAsVersionsConfirmDialogComponent)
|
||||||
|
documentService = TestBed.inject(DocumentService)
|
||||||
|
component = fixture.componentInstance
|
||||||
|
fixture.detectChanges()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should fetch selected documents', () => {
|
||||||
|
const documents = [
|
||||||
|
{ id: 1, title: 'Document 1' },
|
||||||
|
{ id: 2, title: 'Document 2' },
|
||||||
|
]
|
||||||
|
jest.spyOn(documentService, 'getFew').mockReturnValue(
|
||||||
|
of({
|
||||||
|
all: [1, 2],
|
||||||
|
count: 2,
|
||||||
|
results: documents,
|
||||||
|
})
|
||||||
|
)
|
||||||
|
component.documentIDs.set([1, 2])
|
||||||
|
|
||||||
|
component.ngOnInit()
|
||||||
|
|
||||||
|
expect(component.documents()).toEqual(documents)
|
||||||
|
expect(documentService.getFew).toHaveBeenCalledWith([1, 2])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should exclude the root from the draggable documents', () => {
|
||||||
|
component.documentIDs.set([1, 2, 3])
|
||||||
|
component.rootDocumentID.set(2)
|
||||||
|
|
||||||
|
expect(component.versionDocumentIDs()).toEqual([1, 3])
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should move draggable documents while keeping the root fixed', () => {
|
||||||
|
component.documentIDs.set([1, 2, 3])
|
||||||
|
component.rootDocumentID.set(1)
|
||||||
|
|
||||||
|
component.onDrop({ previousIndex: 1, currentIndex: 0 } as any)
|
||||||
|
|
||||||
|
expect(component.documentIDs()).toEqual([1, 3, 2])
|
||||||
|
expect(component.versionDocumentIDs()).toEqual([3, 2])
|
||||||
|
})
|
||||||
|
})
|
||||||
+70
@@ -0,0 +1,70 @@
|
|||||||
|
import {
|
||||||
|
CdkDragDrop,
|
||||||
|
DragDropModule,
|
||||||
|
moveItemInArray,
|
||||||
|
} from '@angular/cdk/drag-drop'
|
||||||
|
import { AsyncPipe } from '@angular/common'
|
||||||
|
import { Component, OnInit, computed, inject, signal } from '@angular/core'
|
||||||
|
import { FormsModule } from '@angular/forms'
|
||||||
|
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||||
|
import { takeUntil } from 'rxjs'
|
||||||
|
import { Document } from 'src/app/data/document'
|
||||||
|
import { CorrespondentNamePipe } from 'src/app/pipes/correspondent-name.pipe'
|
||||||
|
import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
|
||||||
|
import { DocumentService } from 'src/app/services/rest/document.service'
|
||||||
|
import { ConfirmDialogComponent } from '../confirm-dialog.component'
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'pngx-merge-as-versions-confirm-dialog',
|
||||||
|
templateUrl: './merge-as-versions-confirm-dialog.component.html',
|
||||||
|
styleUrl: './merge-as-versions-confirm-dialog.component.scss',
|
||||||
|
imports: [
|
||||||
|
AsyncPipe,
|
||||||
|
CorrespondentNamePipe,
|
||||||
|
CustomDatePipe,
|
||||||
|
DragDropModule,
|
||||||
|
FormsModule,
|
||||||
|
NgxBootstrapIconsModule,
|
||||||
|
],
|
||||||
|
})
|
||||||
|
export class MergeAsVersionsConfirmDialogComponent
|
||||||
|
extends ConfirmDialogComponent
|
||||||
|
implements OnInit
|
||||||
|
{
|
||||||
|
private readonly documentService = inject(DocumentService)
|
||||||
|
|
||||||
|
readonly documentIDs = signal<number[]>([])
|
||||||
|
readonly documents = signal<Document[]>([])
|
||||||
|
readonly rootDocumentID = signal(-1)
|
||||||
|
readonly versionDocumentIDs = computed(() =>
|
||||||
|
this.documentIDs().filter(
|
||||||
|
(documentID) => documentID !== this.rootDocumentID()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
ngOnInit() {
|
||||||
|
this.documentService
|
||||||
|
.getFew(this.documentIDs())
|
||||||
|
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||||
|
.subscribe((response) => this.documents.set(response.results))
|
||||||
|
}
|
||||||
|
|
||||||
|
onDrop(event: CdkDragDrop<number[]>) {
|
||||||
|
const versionDocumentIDs = this.versionDocumentIDs().concat()
|
||||||
|
moveItemInArray(versionDocumentIDs, event.previousIndex, event.currentIndex)
|
||||||
|
|
||||||
|
// The root keeps its place in the list, only the versions move around it
|
||||||
|
let versionIndex = 0
|
||||||
|
this.documentIDs.update((documentIDs) =>
|
||||||
|
documentIDs.map((documentID) =>
|
||||||
|
documentID === this.rootDocumentID()
|
||||||
|
? documentID
|
||||||
|
: versionDocumentIDs[versionIndex++]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
getDocument(documentID: number): Document | undefined {
|
||||||
|
return this.documents().find((document) => document.id === documentID)
|
||||||
|
}
|
||||||
|
}
|
||||||
+3
-3
@@ -36,7 +36,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div class="form-group mt-4">
|
<div class="form-group mt-4">
|
||||||
<label class="form-label" for="metadataDocumentID" i18n>Use metadata from:</label>
|
<label class="form-label" for="metadataDocumentID" i18n>Use metadata from:</label>
|
||||||
<select class="form-select" [ngModel]="metadataDocumentID()" (ngModelChange)="metadataDocumentID.set($event)">
|
<select id="metadataDocumentID" class="form-select" [ngModel]="metadataDocumentID()" (ngModelChange)="metadataDocumentID.set($event)">
|
||||||
<option [ngValue]="-1" i18n>Regenerate all metadata</option>
|
<option [ngValue]="-1" i18n>Regenerate all metadata</option>
|
||||||
@for (document of documents(); track document.id) {
|
@for (document of documents(); track document.id) {
|
||||||
<option [ngValue]="document.id">{{document.title}}</option>
|
<option [ngValue]="document.id">{{document.title}}</option>
|
||||||
@@ -56,10 +56,10 @@
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
<button type="button" class="btn" [class]="cancelBtnClass" (click)="cancel()" [disabled]="!buttonsEnabled">
|
<button type="button" class="btn" [class]="cancelBtnClass" (click)="cancel()" [disabled]="!buttonsEnabled()">
|
||||||
<span class="d-inline-block" style="padding-bottom: 1px;">{{cancelBtnCaption}}</span>
|
<span class="d-inline-block" style="padding-bottom: 1px;">{{cancelBtnCaption}}</span>
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="btn" [class]="btnClass" (click)="confirm()" [disabled]="!confirmButtonEnabled || !buttonsEnabled">
|
<button type="button" class="btn" [class]="btnClass" (click)="confirm()" [disabled]="!confirmButtonEnabled || !buttonsEnabled()">
|
||||||
{{btnCaption}}
|
{{btnCaption}}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+2
-2
@@ -57,7 +57,7 @@
|
|||||||
class="btn"
|
class="btn"
|
||||||
[class]="cancelBtnClass"
|
[class]="cancelBtnClass"
|
||||||
(click)="cancel()"
|
(click)="cancel()"
|
||||||
[disabled]="!buttonsEnabled"
|
[disabled]="!buttonsEnabled()"
|
||||||
>
|
>
|
||||||
<span class="d-inline-block" style="padding-bottom: 1px;">
|
<span class="d-inline-block" style="padding-bottom: 1px;">
|
||||||
{{cancelBtnCaption}}
|
{{cancelBtnCaption}}
|
||||||
@@ -68,7 +68,7 @@
|
|||||||
class="btn"
|
class="btn"
|
||||||
[class]="btnClass"
|
[class]="btnClass"
|
||||||
(click)="confirm()"
|
(click)="confirm()"
|
||||||
[disabled]="!confirmButtonEnabled || !buttonsEnabled"
|
[disabled]="!confirmButtonEnabled || !buttonsEnabled()"
|
||||||
>
|
>
|
||||||
{{btnCaption}}
|
{{btnCaption}}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
+2
-2
@@ -34,10 +34,10 @@
|
|||||||
<p class="mb-0 small"><b>{{messageBold}}</b></p>
|
<p class="mb-0 small"><b>{{messageBold}}</b></p>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<button type="button" class="btn" [class]="cancelBtnClass" (click)="cancel()" [disabled]="!buttonsEnabled">
|
<button type="button" class="btn" [class]="cancelBtnClass" (click)="cancel()" [disabled]="!buttonsEnabled()">
|
||||||
<span class="d-inline-block" style="padding-bottom: 1px;">{{cancelBtnCaption}}</span>
|
<span class="d-inline-block" style="padding-bottom: 1px;">{{cancelBtnCaption}}</span>
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="btn" [class]="btnClass" (click)="confirm()" [disabled]="!confirmButtonEnabled || !buttonsEnabled || degrees === 0">
|
<button type="button" class="btn" [class]="btnClass" (click)="confirm()" [disabled]="!confirmButtonEnabled || !buttonsEnabled() || degrees === 0">
|
||||||
{{btnCaption}}
|
{{btnCaption}}
|
||||||
@if (!confirmButtonEnabled) {
|
@if (!confirmButtonEnabled) {
|
||||||
<ngb-progressbar style="height: 1px;" type="dark" [max]="secondsTotal" [value]="seconds"></ngb-progressbar>
|
<ngb-progressbar style="height: 1px;" type="dark" [max]="secondsTotal" [value]="seconds"></ngb-progressbar>
|
||||||
|
|||||||
+5
@@ -68,6 +68,11 @@
|
|||||||
></ng-select>
|
></ng-select>
|
||||||
} @else if (getCustomFieldByID(atom.field)?.data_type === CustomFieldDataType.DocumentLink) {
|
} @else if (getCustomFieldByID(atom.field)?.data_type === CustomFieldDataType.DocumentLink) {
|
||||||
<pngx-input-document-link [(ngModel)]="atom.value" class="w-25 form-select doc-link-select p-0" placeholder="Search docs..." i18n-placeholder [minimal]="true"></pngx-input-document-link>
|
<pngx-input-document-link [(ngModel)]="atom.value" class="w-25 form-select doc-link-select p-0" placeholder="Search docs..." i18n-placeholder [minimal]="true"></pngx-input-document-link>
|
||||||
|
} @else if (getCustomFieldByID(atom.field)?.data_type === CustomFieldDataType.Monetary) {
|
||||||
|
<input class="w-25 form-control rounded-end" type="text" inputmode="decimal"
|
||||||
|
[ngModel]="atom.value"
|
||||||
|
(ngModelChange)="setMonetaryValue(atom, $event)"
|
||||||
|
[disabled]="disabled">
|
||||||
} @else {
|
} @else {
|
||||||
<input class="w-25 form-control rounded-end" type="text" [(ngModel)]="atom.value" [disabled]="disabled">
|
<input class="w-25 form-control rounded-end" type="text" [(ngModel)]="atom.value" [disabled]="disabled">
|
||||||
}
|
}
|
||||||
|
|||||||
+24
@@ -1,5 +1,6 @@
|
|||||||
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
|
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
|
||||||
import { provideHttpClientTesting } from '@angular/common/http/testing'
|
import { provideHttpClientTesting } from '@angular/common/http/testing'
|
||||||
|
import { LOCALE_ID } from '@angular/core'
|
||||||
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||||
import { NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap'
|
import { NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap'
|
||||||
@@ -41,6 +42,12 @@ const customFields = [
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 3,
|
||||||
|
name: 'Test Monetary Field',
|
||||||
|
data_type: CustomFieldDataType.Monetary,
|
||||||
|
extra_data: { default_currency: 'EUR' },
|
||||||
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
describe('CustomFieldsQueryDropdownComponent', () => {
|
describe('CustomFieldsQueryDropdownComponent', () => {
|
||||||
@@ -61,6 +68,7 @@ describe('CustomFieldsQueryDropdownComponent', () => {
|
|||||||
providers: [
|
providers: [
|
||||||
provideHttpClient(withInterceptorsFromDi()),
|
provideHttpClient(withInterceptorsFromDi()),
|
||||||
provideHttpClientTesting(),
|
provideHttpClientTesting(),
|
||||||
|
{ provide: LOCALE_ID, useValue: 'de' },
|
||||||
],
|
],
|
||||||
}).compileComponents()
|
}).compileComponents()
|
||||||
|
|
||||||
@@ -150,6 +158,22 @@ describe('CustomFieldsQueryDropdownComponent', () => {
|
|||||||
expect(options2).toEqual([])
|
expect(options2).toEqual([])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should normalize localized monetary comparison values', () => {
|
||||||
|
const atom = new CustomFieldQueryAtom([3, 'exact', null])
|
||||||
|
|
||||||
|
component.setMonetaryValue(atom, '1.234,56')
|
||||||
|
|
||||||
|
expect(atom.value).toEqual('1234.56')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should preserve API-formatted monetary comparison values', () => {
|
||||||
|
const atom = new CustomFieldQueryAtom([3, 'exact', null])
|
||||||
|
|
||||||
|
component.setMonetaryValue(atom, '1234.56')
|
||||||
|
|
||||||
|
expect(atom.value).toEqual('1234.56')
|
||||||
|
})
|
||||||
|
|
||||||
it('should remove an element from the selection model', () => {
|
it('should remove an element from the selection model', () => {
|
||||||
const expression = new CustomFieldQueryExpression()
|
const expression = new CustomFieldQueryExpression()
|
||||||
const atom = new CustomFieldQueryAtom()
|
const atom = new CustomFieldQueryAtom()
|
||||||
|
|||||||
+21
-1
@@ -1,9 +1,14 @@
|
|||||||
import { NgTemplateOutlet } from '@angular/common'
|
import {
|
||||||
|
getLocaleNumberSymbol,
|
||||||
|
NgTemplateOutlet,
|
||||||
|
NumberSymbol,
|
||||||
|
} from '@angular/common'
|
||||||
import {
|
import {
|
||||||
Component,
|
Component,
|
||||||
EventEmitter,
|
EventEmitter,
|
||||||
inject,
|
inject,
|
||||||
Input,
|
Input,
|
||||||
|
LOCALE_ID,
|
||||||
Output,
|
Output,
|
||||||
QueryList,
|
QueryList,
|
||||||
signal,
|
signal,
|
||||||
@@ -212,6 +217,7 @@ export class CustomFieldQueriesModel {
|
|||||||
})
|
})
|
||||||
export class CustomFieldsQueryDropdownComponent extends LoadingComponentWithPermissions {
|
export class CustomFieldsQueryDropdownComponent extends LoadingComponentWithPermissions {
|
||||||
protected customFieldsService = inject(CustomFieldsService)
|
protected customFieldsService = inject(CustomFieldsService)
|
||||||
|
private readonly locale = inject(LOCALE_ID)
|
||||||
|
|
||||||
public CustomFieldQueryComponentType = CustomFieldQueryElementType
|
public CustomFieldQueryComponentType = CustomFieldQueryElementType
|
||||||
public CustomFieldQueryOperator = CustomFieldQueryOperator
|
public CustomFieldQueryOperator = CustomFieldQueryOperator
|
||||||
@@ -376,4 +382,18 @@ export class CustomFieldsQueryDropdownComponent extends LoadingComponentWithPerm
|
|||||||
}
|
}
|
||||||
return []
|
return []
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setMonetaryValue(atom: CustomFieldQueryAtom, value: string) {
|
||||||
|
// Normalize the decimal symbol e.g. . vs , by locale
|
||||||
|
const decimalSymbol = getLocaleNumberSymbol(
|
||||||
|
this.locale,
|
||||||
|
NumberSymbol.Decimal
|
||||||
|
)
|
||||||
|
if (decimalSymbol !== '.' && value.includes(decimalSymbol)) {
|
||||||
|
const groupSymbol = getLocaleNumberSymbol(this.locale, NumberSymbol.Group)
|
||||||
|
value = value.split(groupSymbol).join('').split(decimalSymbol).join('.')
|
||||||
|
}
|
||||||
|
|
||||||
|
atom.value = value
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ import { EditDialogComponent, EditDialogMode } from './edit-dialog.component'
|
|||||||
template: `
|
template: `
|
||||||
<div>
|
<div>
|
||||||
<h4 class="modal-title" id="modal-basic-title">{{ getTitle() }}</h4>
|
<h4 class="modal-title" id="modal-basic-title">{{ getTitle() }}</h4>
|
||||||
|
<span class="error">{{ error?.name }}</span>
|
||||||
|
<button [disabled]="networkActive" (click)="save()">Save</button>
|
||||||
</div>
|
</div>
|
||||||
`,
|
`,
|
||||||
imports: [FormsModule, ReactiveFormsModule],
|
imports: [FormsModule, ReactiveFormsModule],
|
||||||
@@ -276,4 +278,22 @@ describe('EditDialogComponent', () => {
|
|||||||
expect(failedSpy).toHaveBeenCalled()
|
expect(failedSpy).toHaveBeenCalled()
|
||||||
expect(component.error).toEqual('error')
|
expect(component.error).toEqual('error')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should update the view after a failed save', async () => {
|
||||||
|
const button: HTMLButtonElement =
|
||||||
|
fixture.nativeElement.querySelector('button')
|
||||||
|
button.click()
|
||||||
|
await fixture.whenStable()
|
||||||
|
expect(button.disabled).toBe(true)
|
||||||
|
|
||||||
|
httpTestingController
|
||||||
|
.expectOne(`${environment.apiBaseUrl}tags/`)
|
||||||
|
.flush({ name: ['Name is required.'] }, { status: 400, statusText: '' })
|
||||||
|
await fixture.whenStable()
|
||||||
|
|
||||||
|
expect(button.disabled).toBe(false)
|
||||||
|
expect(fixture.nativeElement.querySelector('.error').textContent).toContain(
|
||||||
|
'Name is required.'
|
||||||
|
)
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
|
ChangeDetectorRef,
|
||||||
Directive,
|
Directive,
|
||||||
EventEmitter,
|
EventEmitter,
|
||||||
Input,
|
Input,
|
||||||
@@ -45,6 +46,7 @@ export abstract class EditDialogComponent<
|
|||||||
protected userService = inject(UserService)
|
protected userService = inject(UserService)
|
||||||
protected settingsService = inject(SettingsService)
|
protected settingsService = inject(SettingsService)
|
||||||
protected permissionsService = inject(PermissionsService)
|
protected permissionsService = inject(PermissionsService)
|
||||||
|
protected changeDetector = inject(ChangeDetectorRef)
|
||||||
|
|
||||||
dialogMode = model(EditDialogMode.CREATE)
|
dialogMode = model(EditDialogMode.CREATE)
|
||||||
|
|
||||||
@@ -108,10 +110,12 @@ export abstract class EditDialogComponent<
|
|||||||
// wait to enable close button so it doesn't steal focus from input since its the first clickable element in the DOM
|
// wait to enable close button so it doesn't steal focus from input since its the first clickable element in the DOM
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
this.closeEnabled = true
|
this.closeEnabled = true
|
||||||
|
this.changeDetector.markForCheck()
|
||||||
})
|
})
|
||||||
|
|
||||||
this.userService.listAll().subscribe((r) => {
|
this.userService.listAll().subscribe((r) => {
|
||||||
this.users = r.results
|
this.users = r.results
|
||||||
|
this.changeDetector.markForCheck()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -191,6 +195,7 @@ export abstract class EditDialogComponent<
|
|||||||
this.error = error.error
|
this.error = error.error
|
||||||
this.networkActive = false
|
this.networkActive = false
|
||||||
this.failed.next(error)
|
this.failed.next(error)
|
||||||
|
this.changeDetector.markForCheck()
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
+24
@@ -1019,4 +1019,28 @@ describe('WorkflowEditDialogComponent', () => {
|
|||||||
'pass3',
|
'pass3',
|
||||||
])
|
])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should parse passwords again when retrying a failed save', () => {
|
||||||
|
component.object = {
|
||||||
|
name: 'Workflow with blank Passwords',
|
||||||
|
id: 1,
|
||||||
|
order: null,
|
||||||
|
enabled: true,
|
||||||
|
triggers: [],
|
||||||
|
actions: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
type: WorkflowActionType.PasswordRemoval,
|
||||||
|
passwords: [],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}
|
||||||
|
component.ngOnInit()
|
||||||
|
|
||||||
|
component.save()
|
||||||
|
component.objectForm.get('order').setValue(1)
|
||||||
|
|
||||||
|
expect(() => component.save()).not.toThrow()
|
||||||
|
expect(component.objectForm.get('actions').value[0].passwords).toEqual([])
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
+2
-3
@@ -1207,9 +1207,8 @@ export class WorkflowEditDialogComponent
|
|||||||
return passwords.join('\n')
|
return passwords.join('\n')
|
||||||
}
|
}
|
||||||
|
|
||||||
private parsePasswords(value: string = ''): string[] {
|
private parsePasswords(value: string | string[] = ''): string[] {
|
||||||
return value
|
return (Array.isArray(value) ? value : value.split(/[\n,]+/))
|
||||||
.split(/[\n,]+/)
|
|
||||||
.map((entry) => entry.trim())
|
.map((entry) => entry.trim())
|
||||||
.filter((entry) => entry.length > 0)
|
.filter((entry) => entry.length > 0)
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-3
@@ -1,4 +1,4 @@
|
|||||||
<div class="btn-group w-100" ngbDropdown role="group" (openChange)="dropdownOpenChange($event)" #dropdown="ngbDropdown" (keydown)="listKeyDown($event)" [popperOptions]="popperOptions">
|
<div class="btn-group w-100" ngbDropdown role="group" (openChange)="dropdownOpenChange($event)" #dropdown="ngbDropdown" (keydown)="listKeyDown($event)" [popperOptions]="popperOptions" [autoClose]="!creating()">
|
||||||
<button class="btn btn-sm" id="dropdown_{{name}}" ngbDropdownToggle [ngClass]="!editing && selectionModel.selectionSize() > 0 ? 'btn-primary' : 'btn-outline-primary'" [disabled]="disabled">
|
<button class="btn btn-sm" id="dropdown_{{name}}" ngbDropdownToggle [ngClass]="!editing && selectionModel.selectionSize() > 0 ? 'btn-primary' : 'btn-outline-primary'" [disabled]="disabled">
|
||||||
<i-bs name="{{icon}}"></i-bs><div class="d-none d-sm-inline ms-1">{{title}}</div>
|
<i-bs name="{{icon}}"></i-bs><div class="d-none d-sm-inline ms-1">{{title}}</div>
|
||||||
@if (!editing && selectionModel.totalCount > 0) {
|
@if (!editing && selectionModel.totalCount > 0) {
|
||||||
@@ -49,7 +49,7 @@
|
|||||||
</cdk-virtual-scroll-viewport>
|
</cdk-virtual-scroll-viewport>
|
||||||
}
|
}
|
||||||
@if (editing) {
|
@if (editing) {
|
||||||
@if (filteredItems.length === 0 && createRef !== undefined) {
|
@if (filteredItems.length === 0 && createRef !== undefined && filterText?.length > 0) {
|
||||||
<button class="list-group-item list-group-item-action bg-light" (click)="createClicked()" [disabled]="disabled">
|
<button class="list-group-item list-group-item-action bg-light" (click)="createClicked()" [disabled]="disabled">
|
||||||
<small class="ms-2"><ng-container i18n>Create</ng-container> "{{filterText}}"</small>
|
<small class="ms-2"><ng-container i18n>Create</ng-container> "{{filterText}}"</small>
|
||||||
<i-bs width="1.5em" height="1em" name="plus"></i-bs>
|
<i-bs width="1.5em" height="1em" name="plus"></i-bs>
|
||||||
@@ -62,7 +62,7 @@
|
|||||||
</button>
|
</button>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@if (extraButtonTitle) {
|
@if (extraButtonTitle && (showExtraButtonIfEmpty || filteredItems?.length > 0)) {
|
||||||
<button class="list-group-item list-group-item-action bg-light d-flex align-items-center" (click)="extraButtonClicked($event)" [disabled]="disabled">
|
<button class="list-group-item list-group-item-action bg-light d-flex align-items-center" (click)="extraButtonClicked($event)" [disabled]="disabled">
|
||||||
<small class="ms-2 fw-bold">{{extraButtonTitle}}</small>
|
<small class="ms-2 fw-bold">{{extraButtonTitle}}</small>
|
||||||
<i-bs width="1.5em" height="1em" name="arrow-right"></i-bs>
|
<i-bs width="1.5em" height="1em" name="arrow-right"></i-bs>
|
||||||
|
|||||||
+63
-4
@@ -3,6 +3,7 @@ import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
|
|||||||
import { provideHttpClientTesting } from '@angular/common/http/testing'
|
import { provideHttpClientTesting } from '@angular/common/http/testing'
|
||||||
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||||
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
||||||
|
import { NEVER, Subject } from 'rxjs'
|
||||||
import { NEGATIVE_NULL_FILTER_VALUE } from 'src/app/data/filter-rule-type'
|
import { NEGATIVE_NULL_FILTER_VALUE } from 'src/app/data/filter-rule-type'
|
||||||
import {
|
import {
|
||||||
DEFAULT_MATCHING_ALGORITHM,
|
DEFAULT_MATCHING_ALGORITHM,
|
||||||
@@ -48,6 +49,7 @@ const negativeNullItem = {
|
|||||||
|
|
||||||
let selectionModel: FilterableDropdownSelectionModel
|
let selectionModel: FilterableDropdownSelectionModel
|
||||||
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
|
const wait = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
|
||||||
|
const createModalRef = () => ({ closed: NEVER, dismissed: NEVER }) as any
|
||||||
|
|
||||||
describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () => {
|
describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () => {
|
||||||
let component: FilterableDropdownComponent
|
let component: FilterableDropdownComponent
|
||||||
@@ -868,7 +870,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
|
|||||||
expect(getRootDocCount(rootWithoutCounts.id)).toEqual(0)
|
expect(getRootDocCount(rootWithoutCounts.id)).toEqual(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should set support create, keep open model and call createRef method', async () => {
|
it('should keep the dropdown open while the create modal is active', async () => {
|
||||||
component.selectionModel.items = items
|
component.selectionModel.items = items
|
||||||
component.icon = 'tag-fill'
|
component.icon = 'tag-fill'
|
||||||
component.selectionModel = selectionModel
|
component.selectionModel = selectionModel
|
||||||
@@ -882,20 +884,44 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
|
|||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
|
|
||||||
component.filterText = 'Test Filter Text'
|
component.filterText = 'Test Filter Text'
|
||||||
component.createRef = jest.fn()
|
const modalClosed = new Subject<void>()
|
||||||
|
component.createRef = jest.fn(
|
||||||
|
() =>
|
||||||
|
({
|
||||||
|
closed: modalClosed,
|
||||||
|
dismissed: NEVER,
|
||||||
|
}) as any
|
||||||
|
)
|
||||||
component.createClicked()
|
component.createClicked()
|
||||||
expect(component.creating).toBeTruthy()
|
expect(component.creating()).toBeTruthy()
|
||||||
expect(component.createRef).toHaveBeenCalledWith('Test Filter Text')
|
expect(component.createRef).toHaveBeenCalledWith('Test Filter Text')
|
||||||
|
fixture.detectChanges()
|
||||||
|
expect(component.dropdown.autoClose).toBeFalsy()
|
||||||
|
|
||||||
|
document.body.dispatchEvent(new MouseEvent('mousedown', { bubbles: true }))
|
||||||
|
document.body.dispatchEvent(new MouseEvent('mouseup', { bubbles: true }))
|
||||||
|
await wait(10)
|
||||||
|
expect(component.dropdown.isOpen()).toBeTruthy()
|
||||||
|
|
||||||
|
// Also cover a close that was already scheduled before autoClose changed.
|
||||||
const openSpy = jest.spyOn(component.dropdown, 'open')
|
const openSpy = jest.spyOn(component.dropdown, 'open')
|
||||||
component.dropdownOpenChange(false)
|
component.dropdownOpenChange(false)
|
||||||
expect(openSpy).toHaveBeenCalled() // should keep open
|
expect(openSpy).toHaveBeenCalled() // should keep open
|
||||||
|
component.dropdownOpenChange(false)
|
||||||
|
expect(openSpy).toHaveBeenCalledTimes(2) // modal interactions keep it open
|
||||||
|
|
||||||
|
modalClosed.next()
|
||||||
|
fixture.detectChanges()
|
||||||
|
expect(component.creating()).toBeFalsy()
|
||||||
|
expect(component.dropdown.autoClose).toBeTruthy()
|
||||||
|
expect(component.dropdown.isOpen()).toBeTruthy()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should call create on enter inside filter field if 0 items remain while editing', async () => {
|
it('should call create on enter inside filter field if 0 items remain while editing', async () => {
|
||||||
component.selectionModel.items = items
|
component.selectionModel.items = items
|
||||||
component.icon = 'tag-fill'
|
component.icon = 'tag-fill'
|
||||||
component.editing = true
|
component.editing = true
|
||||||
component.createRef = jest.fn()
|
component.createRef = jest.fn(createModalRef)
|
||||||
const createSpy = jest.spyOn(component, 'createClicked')
|
const createSpy = jest.spyOn(component, 'createClicked')
|
||||||
expect(component.selectionModel.getSelectedItems()).toEqual([])
|
expect(component.selectionModel.getSelectedItems()).toEqual([])
|
||||||
fixture.nativeElement
|
fixture.nativeElement
|
||||||
@@ -911,6 +937,25 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
|
|||||||
expect(createSpy).toHaveBeenCalled()
|
expect(createSpy).toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should only show create when a non-empty filter has no matches', () => {
|
||||||
|
component.selectionModel.items = []
|
||||||
|
component.icon = 'tag-fill'
|
||||||
|
component.editing = true
|
||||||
|
component.createRef = jest.fn(createModalRef)
|
||||||
|
|
||||||
|
fixture.detectChanges()
|
||||||
|
expect(fixture.nativeElement.textContent).not.toContain('Create')
|
||||||
|
component.listFilterEnter()
|
||||||
|
expect(component.createRef).not.toHaveBeenCalled()
|
||||||
|
|
||||||
|
const filterInput: HTMLInputElement =
|
||||||
|
fixture.nativeElement.querySelector('input[type="text"]')
|
||||||
|
filterInput.value = 'FooBar'
|
||||||
|
filterInput.dispatchEvent(new Event('input'))
|
||||||
|
fixture.detectChanges()
|
||||||
|
expect(fixture.nativeElement.textContent).toContain('Create "FooBar"')
|
||||||
|
})
|
||||||
|
|
||||||
it('should exclude item and trigger change event', () => {
|
it('should exclude item and trigger change event', () => {
|
||||||
const id = 1
|
const id = 1
|
||||||
const state = ToggleableItemState.Selected
|
const state = ToggleableItemState.Selected
|
||||||
@@ -970,4 +1015,18 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
|
|||||||
expect(extraButtonClicked).toBeTruthy()
|
expect(extraButtonClicked).toBeTruthy()
|
||||||
expect(applied).toBeFalsy()
|
expect(applied).toBeFalsy()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should only show the extra button for an empty result when enabled', () => {
|
||||||
|
component.selectionModel.items = items
|
||||||
|
component.icon = 'tag-fill'
|
||||||
|
component.extraButtonTitle = 'Extra'
|
||||||
|
component.filterText = 'FooBar'
|
||||||
|
|
||||||
|
fixture.detectChanges()
|
||||||
|
expect(fixture.nativeElement.textContent).not.toContain('Extra')
|
||||||
|
|
||||||
|
fixture.componentRef.setInput('showExtraButtonIfEmpty', true)
|
||||||
|
fixture.detectChanges()
|
||||||
|
expect(fixture.nativeElement.textContent).toContain('Extra')
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
+25
-9
@@ -15,9 +15,13 @@ import {
|
|||||||
signal,
|
signal,
|
||||||
} from '@angular/core'
|
} from '@angular/core'
|
||||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||||
import { NgbDropdown, NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap'
|
import {
|
||||||
|
NgbDropdown,
|
||||||
|
NgbDropdownModule,
|
||||||
|
NgbModalRef,
|
||||||
|
} from '@ng-bootstrap/ng-bootstrap'
|
||||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||||
import { Subject, filter, takeUntil } from 'rxjs'
|
import { Subject, filter, first, merge, takeUntil } from 'rxjs'
|
||||||
import { NEGATIVE_NULL_FILTER_VALUE } from 'src/app/data/filter-rule-type'
|
import { NEGATIVE_NULL_FILTER_VALUE } from 'src/app/data/filter-rule-type'
|
||||||
import { MatchingModel } from 'src/app/data/matching-model'
|
import { MatchingModel } from 'src/app/data/matching-model'
|
||||||
import { ObjectWithPermissions } from 'src/app/data/object-with-permissions'
|
import { ObjectWithPermissions } from 'src/app/data/object-with-permissions'
|
||||||
@@ -759,7 +763,7 @@ export class FilterableDropdownComponent
|
|||||||
disabled = false
|
disabled = false
|
||||||
|
|
||||||
@Input()
|
@Input()
|
||||||
createRef: (name) => void
|
createRef: (name: string) => NgbModalRef
|
||||||
|
|
||||||
@Input()
|
@Input()
|
||||||
set documentCounts(counts: SelectionDataItem[]) {
|
set documentCounts(counts: SelectionDataItem[]) {
|
||||||
@@ -774,7 +778,10 @@ export class FilterableDropdownComponent
|
|||||||
@Input()
|
@Input()
|
||||||
extraButtonTitle: string
|
extraButtonTitle: string
|
||||||
|
|
||||||
creating: boolean = false
|
@Input()
|
||||||
|
showExtraButtonIfEmpty: boolean = false
|
||||||
|
|
||||||
|
readonly creating = signal(false)
|
||||||
|
|
||||||
@Output()
|
@Output()
|
||||||
apply = new EventEmitter<ChangedItems>()
|
apply = new EventEmitter<ChangedItems>()
|
||||||
@@ -851,12 +858,18 @@ export class FilterableDropdownComponent
|
|||||||
}
|
}
|
||||||
|
|
||||||
createClicked() {
|
createClicked() {
|
||||||
this.creating = true
|
this.creating.set(true)
|
||||||
this.createRef(this.filterText)
|
const modal = this.createRef(this.filterText)
|
||||||
|
merge(modal.closed, modal.dismissed)
|
||||||
|
.pipe(first(), takeUntil(this.unsubscribeNotifier))
|
||||||
|
.subscribe(() => this.creating.set(false))
|
||||||
}
|
}
|
||||||
|
|
||||||
dropdownOpenChange(open: boolean): void {
|
dropdownOpenChange(open: boolean): void {
|
||||||
if (open) {
|
if (open) {
|
||||||
|
// Dont let a create modal close this
|
||||||
|
if (this.creating()) return
|
||||||
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
this.listFilterTextInput?.nativeElement.focus()
|
this.listFilterTextInput?.nativeElement.focus()
|
||||||
this.buttonsViewport?.checkViewportSize()
|
this.buttonsViewport?.checkViewportSize()
|
||||||
@@ -869,9 +882,8 @@ export class FilterableDropdownComponent
|
|||||||
this.editing && !this.selectionModel.manyToOne
|
this.editing && !this.selectionModel.manyToOne
|
||||||
this.opened.next(this)
|
this.opened.next(this)
|
||||||
} else {
|
} else {
|
||||||
if (this.creating) {
|
if (this.creating()) {
|
||||||
this.dropdown?.open()
|
this.dropdown?.open()
|
||||||
this.creating = false
|
|
||||||
} else {
|
} else {
|
||||||
this.filterText = ''
|
this.filterText = ''
|
||||||
if (this.applyOnClose && this.selectionModel.isDirty()) {
|
if (this.applyOnClose && this.selectionModel.isDirty()) {
|
||||||
@@ -892,7 +904,11 @@ export class FilterableDropdownComponent
|
|||||||
this.dropdown.close()
|
this.dropdown.close()
|
||||||
}
|
}
|
||||||
}, 200)
|
}, 200)
|
||||||
} else if (filtered.length == 0 && this.createRef) {
|
} else if (
|
||||||
|
filtered.length == 0 &&
|
||||||
|
this.createRef &&
|
||||||
|
this.filterText?.length > 0
|
||||||
|
) {
|
||||||
this.createClicked()
|
this.createClicked()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-4
@@ -52,10 +52,10 @@ describe('CustomFieldsValuesComponent', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('should set selectedFields and map values correctly', () => {
|
it('should set selectedFields and map values correctly', () => {
|
||||||
component.value = { 1: 'value1' }
|
component.value = { 1: 'value1', 3: 0, 4: false }
|
||||||
component.selectedFields = [1, 2]
|
component.selectedFields = [1, 2, 3, 4]
|
||||||
expect(component.selectedFields).toEqual([1, 2])
|
expect(component.selectedFields).toEqual([1, 2, 3, 4])
|
||||||
expect(component.value).toEqual({ 1: 'value1', 2: null })
|
expect(component.value).toEqual({ 1: 'value1', 2: null, 3: 0, 4: false })
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should return the correct custom field by id', () => {
|
it('should return the correct custom field by id', () => {
|
||||||
|
|||||||
+1
-1
@@ -77,7 +77,7 @@ export class CustomFieldsValuesComponent extends AbstractInputComponent<Object>
|
|||||||
this._selectedFields = newFields
|
this._selectedFields = newFields
|
||||||
// map the selected fields to an object with field_id as key and value as value
|
// map the selected fields to an object with field_id as key and value as value
|
||||||
this.value = newFields.reduce((acc, fieldId) => {
|
this.value = newFields.reduce((acc, fieldId) => {
|
||||||
acc[fieldId] = this.value?.[fieldId] || null
|
acc[fieldId] = this.value?.[fieldId] ?? null
|
||||||
return acc
|
return acc
|
||||||
}, {})
|
}, {})
|
||||||
this.onChange(this.value)
|
this.onChange(this.value)
|
||||||
|
|||||||
@@ -36,7 +36,16 @@
|
|||||||
(focus)="clearLastSearchTerm()"
|
(focus)="clearLastSearchTerm()"
|
||||||
(clear)="clearLastSearchTerm()"
|
(clear)="clearLastSearchTerm()"
|
||||||
(blur)="onBlur()">
|
(blur)="onBlur()">
|
||||||
|
<ng-template ng-label-tmp let-item="item">
|
||||||
|
@if (iconField && item[iconField]) {
|
||||||
|
<i-bs class="me-2" [name]="item[iconField]"></i-bs>
|
||||||
|
}
|
||||||
|
<span [title]="item[bindLabel]">{{item[bindLabel]}}</span>
|
||||||
|
</ng-template>
|
||||||
<ng-template ng-option-tmp let-item="item">
|
<ng-template ng-option-tmp let-item="item">
|
||||||
|
@if (iconField && item[iconField]) {
|
||||||
|
<i-bs class="me-2" [name]="item[iconField]"></i-bs>
|
||||||
|
}
|
||||||
<span [title]="item[bindLabel]">{{item[bindLabel]}}</span>
|
<span [title]="item[bindLabel]">{{item[bindLabel]}}</span>
|
||||||
</ng-template>
|
</ng-template>
|
||||||
</ng-select>
|
</ng-select>
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ import { AbstractInputComponent } from '../abstract-input'
|
|||||||
NgxBootstrapIconsModule,
|
NgxBootstrapIconsModule,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class SelectComponent extends AbstractInputComponent<number> {
|
export class SelectComponent extends AbstractInputComponent<number | string> {
|
||||||
constructor() {
|
constructor() {
|
||||||
super()
|
super()
|
||||||
this.addItemRef = this.addItem.bind(this)
|
this.addItemRef = this.addItem.bind(this)
|
||||||
@@ -100,6 +100,9 @@ export class SelectComponent extends AbstractInputComponent<number> {
|
|||||||
@Input()
|
@Input()
|
||||||
bindLabel: string = 'name'
|
bindLabel: string = 'name'
|
||||||
|
|
||||||
|
@Input()
|
||||||
|
iconField: string
|
||||||
|
|
||||||
public searchFn = (term: string, item: any): boolean =>
|
public searchFn = (term: string, item: any): boolean =>
|
||||||
matchesSearchText(item?.[this.bindLabel], term)
|
matchesSearchText(item?.[this.bindLabel], term)
|
||||||
|
|
||||||
|
|||||||
@@ -100,7 +100,7 @@
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
<div class="form-group ms-md-auto">
|
<div class="form-group ms-md-auto">
|
||||||
<button type="button" class="btn me-2" [class]="cancelBtnClass" (click)="cancel()" [disabled]="!buttonsEnabled">{{ cancelBtnCaption }}</button>
|
<button type="button" class="btn me-2" [class]="cancelBtnClass" (click)="cancel()" [disabled]="!buttonsEnabled()">{{ cancelBtnCaption }}</button>
|
||||||
<button type="button" class="btn" [class]="btnClass" (click)="confirm()" [disabled]="pages.length === 0">{{ btnCaption }}</button>
|
<button type="button" class="btn" [class]="btnClass" (click)="confirm()" [disabled]="pages.length === 0">{{ btnCaption }}</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -151,6 +151,13 @@
|
|||||||
inset: 0;
|
inset: 0;
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
|
|
||||||
|
& section {
|
||||||
|
position: absolute;
|
||||||
|
text-align: initial;
|
||||||
|
box-sizing: border-box;
|
||||||
|
transform-origin: 0 0;
|
||||||
|
}
|
||||||
|
|
||||||
& .annotationTextContent {
|
& .annotationTextContent {
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -90,6 +90,7 @@ describe('PngxPdfViewerComponent', () => {
|
|||||||
}
|
}
|
||||||
expect(viewer).toBeInstanceOf(PDFSinglePageViewer)
|
expect(viewer).toBeInstanceOf(PDFSinglePageViewer)
|
||||||
expect(viewer.options.textLayerMode).toBe(0)
|
expect(viewer.options.textLayerMode).toBe(0)
|
||||||
|
expect(viewer.options.enableSelectionRendering).toBe(false)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('applies zoom, rotation, and page changes', async () => {
|
it('applies zoom, rotation, and page changes', async () => {
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
ViewChild,
|
ViewChild,
|
||||||
} from '@angular/core'
|
} from '@angular/core'
|
||||||
import {
|
import {
|
||||||
|
AnnotationMode,
|
||||||
getDocument,
|
getDocument,
|
||||||
GlobalWorkerOptions,
|
GlobalWorkerOptions,
|
||||||
PDFDocumentLoadingTask,
|
PDFDocumentLoadingTask,
|
||||||
@@ -221,6 +222,8 @@ export class PngxPdfViewerComponent
|
|||||||
linkService: this.linkService,
|
linkService: this.linkService,
|
||||||
findController: this.findController,
|
findController: this.findController,
|
||||||
textLayerMode,
|
textLayerMode,
|
||||||
|
annotationMode: AnnotationMode.ENABLE,
|
||||||
|
enableSelectionRendering: false,
|
||||||
removePageBorders: true,
|
removePageBorders: true,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+3
-3
@@ -22,7 +22,7 @@
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<div class="me-1">
|
<div class="me-1">
|
||||||
<small i18n>My documents</small>
|
<small>{{ownerFilterLabel}}</small>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
<button class="list-group-item list-group-item-action d-flex align-items-center p-2 border-top-0 border-start-0 border-end-0 border-bottom" role="menuitem" (click)="setFilter(OwnerFilterType.NOT_SELF)" [disabled]="disabled">
|
<button class="list-group-item list-group-item-action d-flex align-items-center p-2 border-top-0 border-start-0 border-end-0 border-bottom" role="menuitem" (click)="setFilter(OwnerFilterType.NOT_SELF)" [disabled]="disabled">
|
||||||
@@ -32,7 +32,7 @@
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<div class="me-1">
|
<div class="me-1">
|
||||||
<small i18n>Shared with me</small>
|
<small>{{ownerExclusionFilterLabel}}</small>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
<button class="list-group-item list-group-item-action d-flex align-items-center p-2 border-top-0 border-start-0 border-end-0 border-bottom" role="menuitem" (click)="setFilter(OwnerFilterType.SHARED_BY_ME)" [disabled]="disabled">
|
<button class="list-group-item list-group-item-action d-flex align-items-center p-2 border-top-0 border-start-0 border-end-0 border-bottom" role="menuitem" (click)="setFilter(OwnerFilterType.SHARED_BY_ME)" [disabled]="disabled">
|
||||||
@@ -42,7 +42,7 @@
|
|||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
<div class="me-1">
|
<div class="me-1">
|
||||||
<small i18n>Shared by me</small>
|
<small>{{sharedByFilterLabel}}</small>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
<button class="list-group-item list-group-item-action d-flex align-items-center p-2 border-top-0 border-start-0 border-end-0 border-bottom" role="menuitem" (click)="setFilter(OwnerFilterType.UNOWNED)" [disabled]="disabled">
|
<button class="list-group-item list-group-item-action d-flex align-items-center p-2 border-top-0 border-start-0 border-end-0 border-bottom" role="menuitem" (click)="setFilter(OwnerFilterType.UNOWNED)" [disabled]="disabled">
|
||||||
|
|||||||
+52
@@ -94,6 +94,58 @@ describe('PermissionsFilterDropdownComponent', () => {
|
|||||||
expect(component.isActive).toBeTruthy()
|
expect(component.isActive).toBeTruthy()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should describe concrete user filters honestly', () => {
|
||||||
|
component.selectionModel.ownerFilter = OwnerFilterType.SELF
|
||||||
|
component.selectionModel.userID = 1
|
||||||
|
expect(component.ownerFilterLabel).toEqual('Owned by user1')
|
||||||
|
|
||||||
|
component.selectionModel.ownerFilter = OwnerFilterType.NOT_SELF
|
||||||
|
component.selectionModel.excludeUsers = [1]
|
||||||
|
expect(component.ownerExclusionFilterLabel).toEqual('Not owned by user1')
|
||||||
|
|
||||||
|
component.selectionModel.ownerFilter = OwnerFilterType.SHARED_BY_ME
|
||||||
|
component.selectionModel.userID = 1
|
||||||
|
expect(component.sharedByFilterLabel).toEqual('Shared by user1')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should describe concrete filters when usernames are unavailable', () => {
|
||||||
|
component.selectionModel.ownerFilter = OwnerFilterType.SELF
|
||||||
|
component.selectionModel.userID = 99
|
||||||
|
expect(component.ownerFilterLabel).toEqual('Owned by another user')
|
||||||
|
|
||||||
|
component.selectionModel.ownerFilter = OwnerFilterType.NOT_SELF
|
||||||
|
component.selectionModel.excludeUsers = [99]
|
||||||
|
expect(component.ownerExclusionFilterLabel).toEqual(
|
||||||
|
'Not owned by another user'
|
||||||
|
)
|
||||||
|
|
||||||
|
component.selectionModel.excludeUsers = [98, 99]
|
||||||
|
expect(component.ownerExclusionFilterLabel).toEqual(
|
||||||
|
'Not owned by selected users'
|
||||||
|
)
|
||||||
|
|
||||||
|
component.selectionModel.ownerFilter = OwnerFilterType.SHARED_BY_ME
|
||||||
|
component.selectionModel.userID = 99
|
||||||
|
expect(component.sharedByFilterLabel).toEqual('Shared by another user')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should retain relative labels for filters bound to the current user', () => {
|
||||||
|
component.selectionModel.userID = currentUserID
|
||||||
|
expect(component.ownerFilterLabel).toEqual('My documents')
|
||||||
|
expect(component.sharedByFilterLabel).toEqual('Shared by me')
|
||||||
|
|
||||||
|
component.selectionModel.excludeUsers = [currentUserID]
|
||||||
|
expect(component.ownerExclusionFilterLabel).toEqual('Shared with me')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should retain relative labels for inactive filter choices', () => {
|
||||||
|
component.selectionModel.ownerFilter = OwnerFilterType.NONE
|
||||||
|
|
||||||
|
expect(component.ownerFilterLabel).toEqual('My documents')
|
||||||
|
expect(component.ownerExclusionFilterLabel).toEqual('Shared with me')
|
||||||
|
expect(component.sharedByFilterLabel).toEqual('Shared by me')
|
||||||
|
})
|
||||||
|
|
||||||
it('should support reset', () => {
|
it('should support reset', () => {
|
||||||
component.setFilter(OwnerFilterType.OTHERS)
|
component.setFilter(OwnerFilterType.OTHERS)
|
||||||
expect(component.selectionModel.ownerFilter).not.toEqual(
|
expect(component.selectionModel.ownerFilter).not.toEqual(
|
||||||
|
|||||||
+53
@@ -93,6 +93,55 @@ export class PermissionsFilterDropdownComponent extends ComponentWithPermissions
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get ownerFilterLabel(): string {
|
||||||
|
if (
|
||||||
|
this.selectionModel?.ownerFilter !== OwnerFilterType.SELF ||
|
||||||
|
this.selectionModel?.userID === this.settingsService.currentUser()?.id
|
||||||
|
) {
|
||||||
|
return $localize`My documents`
|
||||||
|
}
|
||||||
|
|
||||||
|
const username = this.getUsername(this.selectionModel?.userID)
|
||||||
|
return username
|
||||||
|
? $localize`Owned by ${username}`
|
||||||
|
: $localize`Owned by another user`
|
||||||
|
}
|
||||||
|
|
||||||
|
get ownerExclusionFilterLabel(): string {
|
||||||
|
const excludedUsers = this.selectionModel?.excludeUsers ?? []
|
||||||
|
if (
|
||||||
|
this.selectionModel?.ownerFilter !== OwnerFilterType.NOT_SELF ||
|
||||||
|
(excludedUsers.length === 1 &&
|
||||||
|
excludedUsers[0] === this.settingsService.currentUser()?.id)
|
||||||
|
) {
|
||||||
|
return $localize`Shared with me`
|
||||||
|
}
|
||||||
|
|
||||||
|
const usernames = excludedUsers
|
||||||
|
.map((id) => this.getUsername(id))
|
||||||
|
.filter(Boolean)
|
||||||
|
if (usernames.length === excludedUsers.length && usernames.length > 0) {
|
||||||
|
return $localize`Not owned by ${usernames.join(', ')}`
|
||||||
|
}
|
||||||
|
return excludedUsers.length === 1
|
||||||
|
? $localize`Not owned by another user`
|
||||||
|
: $localize`Not owned by selected users`
|
||||||
|
}
|
||||||
|
|
||||||
|
get sharedByFilterLabel(): string {
|
||||||
|
if (
|
||||||
|
this.selectionModel?.ownerFilter !== OwnerFilterType.SHARED_BY_ME ||
|
||||||
|
this.selectionModel?.userID === this.settingsService.currentUser()?.id
|
||||||
|
) {
|
||||||
|
return $localize`Shared by me`
|
||||||
|
}
|
||||||
|
|
||||||
|
const username = this.getUsername(this.selectionModel?.userID)
|
||||||
|
return username
|
||||||
|
? $localize`Shared by ${username}`
|
||||||
|
: $localize`Shared by another user`
|
||||||
|
}
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
const userService = inject(UserService)
|
const userService = inject(UserService)
|
||||||
|
|
||||||
@@ -164,4 +213,8 @@ export class PermissionsFilterDropdownComponent extends ComponentWithPermissions
|
|||||||
}
|
}
|
||||||
this.onChange()
|
this.onChange()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private getUsername(userID: number): string {
|
||||||
|
return this.users().find((user) => user.id === userID)?.username
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+9
@@ -17,6 +17,10 @@ const permissions = [
|
|||||||
'view_document',
|
'view_document',
|
||||||
'change_document',
|
'change_document',
|
||||||
'delete_document',
|
'delete_document',
|
||||||
|
'add_sharelinkbundle',
|
||||||
|
'view_sharelinkbundle',
|
||||||
|
'change_sharelinkbundle',
|
||||||
|
'delete_sharelinkbundle',
|
||||||
'change_tag',
|
'change_tag',
|
||||||
'view_documenttype',
|
'view_documenttype',
|
||||||
]
|
]
|
||||||
@@ -75,6 +79,7 @@ describe('PermissionsSelectComponent', () => {
|
|||||||
component.ngOnInit()
|
component.ngOnInit()
|
||||||
component.writeValue(permissions)
|
component.writeValue(permissions)
|
||||||
expect(component.typesWithAllActions).toContain('Document')
|
expect(component.typesWithAllActions).toContain('Document')
|
||||||
|
expect(component.typesWithAllActions).toContain('ShareLinkBundle')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should update checkboxes on permissions set', () => {
|
it('should update checkboxes on permissions set', () => {
|
||||||
@@ -85,6 +90,10 @@ describe('PermissionsSelectComponent', () => {
|
|||||||
expect(input1.nativeElement.checked).toBeTruthy()
|
expect(input1.nativeElement.checked).toBeTruthy()
|
||||||
const input2 = fixture.debugElement.query(By.css('input#Tag_Change'))
|
const input2 = fixture.debugElement.query(By.css('input#Tag_Change'))
|
||||||
expect(input2.nativeElement.checked).toBeTruthy()
|
expect(input2.nativeElement.checked).toBeTruthy()
|
||||||
|
const bundleInput = fixture.debugElement.query(
|
||||||
|
By.css('input#ShareLinkBundle_Add')
|
||||||
|
)
|
||||||
|
expect(bundleInput.nativeElement.checked).toBeTruthy()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('disable checkboxes when permissions are inherited', () => {
|
it('disable checkboxes when permissions are inherited', () => {
|
||||||
|
|||||||
+1
-1
@@ -119,7 +119,7 @@
|
|||||||
type="button"
|
type="button"
|
||||||
class="btn btn-primary btn-sm d-inline-flex align-items-center gap-2 text-nowrap"
|
class="btn btn-primary btn-sm d-inline-flex align-items-center gap-2 text-nowrap"
|
||||||
(click)="submit()"
|
(click)="submit()"
|
||||||
[disabled]="loading() || !buttonsEnabled">
|
[disabled]="loading() || !buttonsEnabled()">
|
||||||
@if (loading()) {
|
@if (loading()) {
|
||||||
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
|
<span class="spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -69,7 +69,7 @@ describe('ShareLinkBundleDialogComponent', () => {
|
|||||||
file_version: FileVersion.Original,
|
file_version: FileVersion.Original,
|
||||||
expiration_days: 3,
|
expiration_days: 3,
|
||||||
})
|
})
|
||||||
expect(component.buttonsEnabled).toBe(false)
|
expect(component.buttonsEnabled()).toBe(false)
|
||||||
expect(confirmSpy).toHaveBeenCalled()
|
expect(confirmSpy).toHaveBeenCalled()
|
||||||
|
|
||||||
component.form.setValue({
|
component.form.setValue({
|
||||||
|
|||||||
+1
-1
@@ -78,7 +78,7 @@ export class ShareLinkBundleDialogComponent extends ConfirmDialogComponent {
|
|||||||
: FileVersion.Original,
|
: FileVersion.Original,
|
||||||
expiration_days: this.form.value.expirationDays,
|
expiration_days: this.form.value.expirationDays,
|
||||||
}
|
}
|
||||||
this.buttonsEnabled = false
|
this.buttonsEnabled.set(false)
|
||||||
super.confirm()
|
super.confirm()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+8
-2
@@ -2,10 +2,16 @@
|
|||||||
<button type="button" class="btn btn-sm btn-outline-primary" (click)="clickSuggest()" [disabled]="disabled() || loading() || (suggestions() && !aiEnabled())">
|
<button type="button" class="btn btn-sm btn-outline-primary" (click)="clickSuggest()" [disabled]="disabled() || loading() || (suggestions() && !aiEnabled())">
|
||||||
@if (loading()) {
|
@if (loading()) {
|
||||||
<div class="spinner-border spinner-border-sm" role="status"></div>
|
<div class="spinner-border spinner-border-sm" role="status"></div>
|
||||||
|
} @else if (noSuggestions) {
|
||||||
|
<i-bs width="1.2em" height="1.2em" name="check-circle"></i-bs>
|
||||||
} @else {
|
} @else {
|
||||||
<i-bs width="1.2em" height="1.2em" name="stars"></i-bs>
|
<i-bs width="1.2em" height="1.2em" name="stars"></i-bs>
|
||||||
}
|
}
|
||||||
<span class="d-none d-lg-inline ps-1" i18n>Suggest</span>
|
@if (noSuggestions) {
|
||||||
|
<span class="d-none d-lg-inline ps-1" i18n>No suggestions</span>
|
||||||
|
} @else {
|
||||||
|
<span class="d-none d-lg-inline ps-1" i18n>Suggest</span>
|
||||||
|
}
|
||||||
@if (totalSuggestions > 0) {
|
@if (totalSuggestions > 0) {
|
||||||
<span class="badge bg-primary ms-2">{{ totalSuggestions }}</span>
|
<span class="badge bg-primary ms-2">{{ totalSuggestions }}</span>
|
||||||
}
|
}
|
||||||
@@ -19,7 +25,7 @@
|
|||||||
|
|
||||||
<div ngbDropdownMenu aria-labelledby="suggestionsDropdown" class="shadow suggestions-dropdown">
|
<div ngbDropdownMenu aria-labelledby="suggestionsDropdown" class="shadow suggestions-dropdown">
|
||||||
<div class="list-group list-group-flush small pb-0">
|
<div class="list-group list-group-flush small pb-0">
|
||||||
@if (!suggestions()?.suggested_tags && !suggestions()?.suggested_document_types && !suggestions()?.suggested_correspondents) {
|
@if (totalSuggestions === 0) {
|
||||||
<div class="list-group-item text-muted fst-italic">
|
<div class="list-group-item text-muted fst-italic">
|
||||||
<small class="text-muted small fst-italic" i18n>No novel suggestions</small>
|
<small class="text-muted small fst-italic" i18n>No novel suggestions</small>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+29
@@ -30,6 +30,34 @@ describe('SuggestionsDropdownComponent', () => {
|
|||||||
expect(component.totalSuggestions).toBe(4)
|
expect(component.totalSuggestions).toBe(4)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should show when a completed request returned no suggestions', () => {
|
||||||
|
fixture.componentRef.setInput('suggestions', {
|
||||||
|
correspondents: [],
|
||||||
|
tags: [],
|
||||||
|
document_types: [],
|
||||||
|
storage_paths: [],
|
||||||
|
dates: [],
|
||||||
|
})
|
||||||
|
fixture.detectChanges()
|
||||||
|
|
||||||
|
expect(component.noSuggestions).toBeTruthy()
|
||||||
|
expect(fixture.nativeElement.textContent).toContain('No suggestions')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not show the empty state before a request or with suggestions', () => {
|
||||||
|
expect(component.noSuggestions).toBeFalsy()
|
||||||
|
|
||||||
|
fixture.componentRef.setInput('suggestions', {
|
||||||
|
correspondents: [],
|
||||||
|
tags: [42],
|
||||||
|
document_types: [],
|
||||||
|
storage_paths: [],
|
||||||
|
dates: [],
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(component.noSuggestions).toBeFalsy()
|
||||||
|
})
|
||||||
|
|
||||||
it('should emit getSuggestions when clickSuggest is called and suggestions are null', () => {
|
it('should emit getSuggestions when clickSuggest is called and suggestions are null', () => {
|
||||||
jest.spyOn(component.getSuggestions, 'emit')
|
jest.spyOn(component.getSuggestions, 'emit')
|
||||||
fixture.componentRef.setInput('suggestions', null)
|
fixture.componentRef.setInput('suggestions', null)
|
||||||
@@ -59,5 +87,6 @@ describe('SuggestionsDropdownComponent', () => {
|
|||||||
})
|
})
|
||||||
component.clickSuggest()
|
component.clickSuggest()
|
||||||
expect(component.dropdown.open).toBeTruthy()
|
expect(component.dropdown.open).toBeTruthy()
|
||||||
|
expect(fixture.nativeElement.textContent).toContain('No novel suggestions')
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
+17
@@ -61,4 +61,21 @@ export class SuggestionsDropdownComponent {
|
|||||||
this.suggestions()?.suggested_document_types?.length || 0
|
this.suggestions()?.suggested_document_types?.length || 0
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get noSuggestions(): boolean {
|
||||||
|
const suggestions = this.suggestions()
|
||||||
|
return (
|
||||||
|
suggestions != null &&
|
||||||
|
!suggestions.title &&
|
||||||
|
!suggestions.tags?.length &&
|
||||||
|
!suggestions.suggested_tags?.length &&
|
||||||
|
!suggestions.correspondents?.length &&
|
||||||
|
!suggestions.suggested_correspondents?.length &&
|
||||||
|
!suggestions.document_types?.length &&
|
||||||
|
!suggestions.suggested_document_types?.length &&
|
||||||
|
!suggestions.storage_paths?.length &&
|
||||||
|
!suggestions.suggested_storage_paths?.length &&
|
||||||
|
!suggestions.dates?.length
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
@@ -1,6 +1,7 @@
|
|||||||
<pngx-widget-frame
|
<pngx-widget-frame
|
||||||
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }"
|
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }"
|
||||||
[title]="savedView.name"
|
[title]="savedView.name"
|
||||||
|
[titleIcon]="savedView.icon || 'funnel'"
|
||||||
[loading]="false"
|
[loading]="false"
|
||||||
[draggable]="savedView"
|
[draggable]="savedView"
|
||||||
>
|
>
|
||||||
|
|||||||
+6
-1
@@ -8,7 +8,12 @@
|
|||||||
<i-bs name="grip-vertical"></i-bs>
|
<i-bs name="grip-vertical"></i-bs>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
<h6 class="card-title mb-0">{{title()}}</h6>
|
<h6 class="card-title mb-0">
|
||||||
|
@if (titleIcon()) {
|
||||||
|
<i-bs class="me-2" [name]="titleIcon()"></i-bs>
|
||||||
|
}
|
||||||
|
{{title()}}
|
||||||
|
</h6>
|
||||||
<ng-content select="[title-badge]"></ng-content>
|
<ng-content select="[title-badge]"></ng-content>
|
||||||
@if (badge() !== null && badge() !== undefined) {
|
@if (badge() !== null && badge() !== undefined) {
|
||||||
<span class="badge bg-info text-dark ms-2">{{badge()}}</span>
|
<span class="badge bg-info text-dark ms-2">{{badge()}}</span>
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ export class WidgetFrameComponent implements AfterViewInit {
|
|||||||
|
|
||||||
title = input<string>()
|
title = input<string>()
|
||||||
|
|
||||||
|
titleIcon = input<string>()
|
||||||
|
|
||||||
draggable = input<any>()
|
draggable = input<any>()
|
||||||
|
|
||||||
cardless = input(false)
|
cardless = input(false)
|
||||||
|
|||||||
@@ -467,13 +467,6 @@ describe('DocumentDetailComponent', () => {
|
|||||||
const docWithVersions = {
|
const docWithVersions = {
|
||||||
...doc,
|
...doc,
|
||||||
versions: [
|
versions: [
|
||||||
{
|
|
||||||
id: doc.id,
|
|
||||||
added: new Date('2024-01-01T00:00:00Z'),
|
|
||||||
version_label: 'Original',
|
|
||||||
checksum: 'aaaa',
|
|
||||||
is_root: true,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
id: 10,
|
id: 10,
|
||||||
added: new Date('2024-01-02T00:00:00Z'),
|
added: new Date('2024-01-02T00:00:00Z'),
|
||||||
@@ -481,6 +474,13 @@ describe('DocumentDetailComponent', () => {
|
|||||||
checksum: 'bbbb',
|
checksum: 'bbbb',
|
||||||
is_root: false,
|
is_root: false,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: doc.id,
|
||||||
|
added: new Date('2024-01-01T00:00:00Z'),
|
||||||
|
version_label: 'Original',
|
||||||
|
checksum: 'aaaa',
|
||||||
|
is_root: true,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
} as Document
|
} as Document
|
||||||
|
|
||||||
@@ -1232,8 +1232,8 @@ describe('DocumentDetailComponent', () => {
|
|||||||
|
|
||||||
metadataSpy.mockClear()
|
metadataSpy.mockClear()
|
||||||
component.document().versions = [
|
component.document().versions = [
|
||||||
{ id: doc.id, is_root: true },
|
|
||||||
{ id: 10, is_root: false },
|
{ id: 10, is_root: false },
|
||||||
|
{ id: doc.id, is_root: true },
|
||||||
] as any
|
] as any
|
||||||
jest.spyOn(documentService, 'getPreviewUrl').mockReturnValue('preview-root')
|
jest.spyOn(documentService, 'getPreviewUrl').mockReturnValue('preview-root')
|
||||||
jest.spyOn(documentService, 'getThumbUrl').mockReturnValue('thumb-root')
|
jest.spyOn(documentService, 'getThumbUrl').mockReturnValue('thumb-root')
|
||||||
@@ -1564,7 +1564,7 @@ describe('DocumentDetailComponent', () => {
|
|||||||
dialog.confirmClicked.next()
|
dialog.confirmClicked.next()
|
||||||
await openModal.result
|
await openModal.result
|
||||||
|
|
||||||
expect(dialog.buttonsEnabled).toBe(false)
|
expect(dialog.buttonsEnabled()).toBe(false)
|
||||||
expect(reloadSpy).toHaveBeenCalled()
|
expect(reloadSpy).toHaveBeenCalled()
|
||||||
expect((component as any).incomingUpdateModal).toBeNull()
|
expect((component as any).incomingUpdateModal).toBeNull()
|
||||||
})
|
})
|
||||||
@@ -1789,7 +1789,7 @@ describe('DocumentDetailComponent', () => {
|
|||||||
|
|
||||||
expect(errorSpy).toHaveBeenCalled()
|
expect(errorSpy).toHaveBeenCalled()
|
||||||
expect(component.networkActive()).toBe(false)
|
expect(component.networkActive()).toBe(false)
|
||||||
expect(dialog.buttonsEnabled).toBe(true)
|
expect(dialog.buttonsEnabled()).toBe(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should refresh the document when removing password in update mode', () => {
|
it('should refresh the document when removing password in update mode', () => {
|
||||||
@@ -1929,8 +1929,8 @@ describe('DocumentDetailComponent', () => {
|
|||||||
component.documentId.set(doc.id)
|
component.documentId.set(doc.id)
|
||||||
component.document.set({ ...doc, versions: [] } as Document)
|
component.document.set({ ...doc, versions: [] } as Document)
|
||||||
const updatedVersions = [
|
const updatedVersions = [
|
||||||
{ id: doc.id, is_root: true },
|
|
||||||
{ id: 10, is_root: false },
|
{ id: 10, is_root: false },
|
||||||
|
{ id: doc.id, is_root: true },
|
||||||
] as any
|
] as any
|
||||||
const openDoc = { ...doc, versions: [] } as Document
|
const openDoc = { ...doc, versions: [] } as Document
|
||||||
jest.spyOn(openDocumentsService, 'getOpenDocument').mockReturnValue(openDoc)
|
jest.spyOn(openDocumentsService, 'getOpenDocument').mockReturnValue(openDoc)
|
||||||
@@ -2046,8 +2046,8 @@ describe('DocumentDetailComponent', () => {
|
|||||||
it('should include version in download and print only for non-latest selected version', () => {
|
it('should include version in download and print only for non-latest selected version', () => {
|
||||||
initNormally()
|
initNormally()
|
||||||
component.document().versions = [
|
component.document().versions = [
|
||||||
{ id: doc.id, is_root: true },
|
|
||||||
{ id: 10, is_root: false },
|
{ id: 10, is_root: false },
|
||||||
|
{ id: doc.id, is_root: true },
|
||||||
] as any
|
] as any
|
||||||
|
|
||||||
const getDownloadUrlSpy = jest
|
const getDownloadUrlSpy = jest
|
||||||
@@ -2161,8 +2161,14 @@ describe('DocumentDetailComponent', () => {
|
|||||||
it('should support open share links and email modals', () => {
|
it('should support open share links and email modals', () => {
|
||||||
const modalSpy = jest.spyOn(modalService, 'open')
|
const modalSpy = jest.spyOn(modalService, 'open')
|
||||||
initNormally()
|
initNormally()
|
||||||
|
component.selectedVersionId.set(10)
|
||||||
component.openShareLinks()
|
component.openShareLinks()
|
||||||
expect(modalSpy).toHaveBeenCalled()
|
expect(modalSpy).toHaveBeenCalled()
|
||||||
|
expect(
|
||||||
|
(
|
||||||
|
modalSpy.mock.results[0].value as NgbModalRef
|
||||||
|
).componentInstance.documentId()
|
||||||
|
).toBe(10)
|
||||||
component.openEmailDocument()
|
component.openEmailDocument()
|
||||||
expect(modalSpy).toHaveBeenCalled()
|
expect(modalSpy).toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
@@ -2191,9 +2197,6 @@ describe('DocumentDetailComponent', () => {
|
|||||||
const appendChildSpy = jest
|
const appendChildSpy = jest
|
||||||
.spyOn(document.body, 'appendChild')
|
.spyOn(document.body, 'appendChild')
|
||||||
.mockImplementation((node: Node) => node)
|
.mockImplementation((node: Node) => node)
|
||||||
const removeChildSpy = jest
|
|
||||||
.spyOn(document.body, 'removeChild')
|
|
||||||
.mockImplementation((node: Node) => node)
|
|
||||||
const createObjectURLSpy = jest
|
const createObjectURLSpy = jest
|
||||||
.spyOn(URL, 'createObjectURL')
|
.spyOn(URL, 'createObjectURL')
|
||||||
.mockReturnValue('blob:mock-url')
|
.mockReturnValue('blob:mock-url')
|
||||||
@@ -2212,6 +2215,7 @@ describe('DocumentDetailComponent', () => {
|
|||||||
src: '',
|
src: '',
|
||||||
onload: null,
|
onload: null,
|
||||||
contentWindow: mockContentWindow,
|
contentWindow: mockContentWindow,
|
||||||
|
remove: jest.fn(),
|
||||||
}
|
}
|
||||||
|
|
||||||
const createElementSpy = jest
|
const createElementSpy = jest
|
||||||
@@ -2255,12 +2259,11 @@ describe('DocumentDetailComponent', () => {
|
|||||||
mockContentWindow.onafterprint(new Event('afterprint'))
|
mockContentWindow.onafterprint(new Event('afterprint'))
|
||||||
}
|
}
|
||||||
|
|
||||||
expect(removeChildSpy).toHaveBeenCalledWith(mockIframe)
|
expect(mockIframe.remove).toHaveBeenCalled()
|
||||||
expect(revokeObjectURLSpy).toHaveBeenCalledWith('blob:mock-url')
|
expect(revokeObjectURLSpy).toHaveBeenCalledWith('blob:mock-url')
|
||||||
|
|
||||||
createElementSpy.mockRestore()
|
createElementSpy.mockRestore()
|
||||||
appendChildSpy.mockRestore()
|
appendChildSpy.mockRestore()
|
||||||
removeChildSpy.mockRestore()
|
|
||||||
createObjectURLSpy.mockRestore()
|
createObjectURLSpy.mockRestore()
|
||||||
revokeObjectURLSpy.mockRestore()
|
revokeObjectURLSpy.mockRestore()
|
||||||
})
|
})
|
||||||
@@ -2307,9 +2310,6 @@ describe('DocumentDetailComponent', () => {
|
|||||||
const appendChildSpy = jest
|
const appendChildSpy = jest
|
||||||
.spyOn(document.body, 'appendChild')
|
.spyOn(document.body, 'appendChild')
|
||||||
.mockImplementation((node: Node) => node)
|
.mockImplementation((node: Node) => node)
|
||||||
const removeChildSpy = jest
|
|
||||||
.spyOn(document.body, 'removeChild')
|
|
||||||
.mockImplementation((node: Node) => node)
|
|
||||||
const createObjectURLSpy = jest
|
const createObjectURLSpy = jest
|
||||||
.spyOn(URL, 'createObjectURL')
|
.spyOn(URL, 'createObjectURL')
|
||||||
.mockReturnValue('blob:mock-url')
|
.mockReturnValue('blob:mock-url')
|
||||||
@@ -2332,6 +2332,7 @@ describe('DocumentDetailComponent', () => {
|
|||||||
src: '',
|
src: '',
|
||||||
onload: null,
|
onload: null,
|
||||||
contentWindow: mockContentWindow,
|
contentWindow: mockContentWindow,
|
||||||
|
remove: jest.fn(),
|
||||||
}
|
}
|
||||||
|
|
||||||
const createElementSpy = jest
|
const createElementSpy = jest
|
||||||
@@ -2354,15 +2355,21 @@ describe('DocumentDetailComponent', () => {
|
|||||||
|
|
||||||
if (expectToast) {
|
if (expectToast) {
|
||||||
expect(toastSpy).toHaveBeenCalled()
|
expect(toastSpy).toHaveBeenCalled()
|
||||||
|
expect(mockIframe.remove).toHaveBeenCalled()
|
||||||
|
expect(revokeObjectURLSpy).toHaveBeenCalledWith('blob:mock-url')
|
||||||
} else {
|
} else {
|
||||||
expect(toastSpy).not.toHaveBeenCalled()
|
expect(toastSpy).not.toHaveBeenCalled()
|
||||||
|
expect(mockIframe.remove).not.toHaveBeenCalled()
|
||||||
|
expect(revokeObjectURLSpy).not.toHaveBeenCalled()
|
||||||
|
|
||||||
|
component.ngOnDestroy()
|
||||||
|
|
||||||
|
expect(mockIframe.remove).toHaveBeenCalled()
|
||||||
|
expect(revokeObjectURLSpy).toHaveBeenCalledWith('blob:mock-url')
|
||||||
}
|
}
|
||||||
expect(removeChildSpy).toHaveBeenCalledWith(mockIframe)
|
|
||||||
expect(revokeObjectURLSpy).toHaveBeenCalledWith('blob:mock-url')
|
|
||||||
|
|
||||||
createElementSpy.mockRestore()
|
createElementSpy.mockRestore()
|
||||||
appendChildSpy.mockRestore()
|
appendChildSpy.mockRestore()
|
||||||
removeChildSpy.mockRestore()
|
|
||||||
createObjectURLSpy.mockRestore()
|
createObjectURLSpy.mockRestore()
|
||||||
revokeObjectURLSpy.mockRestore()
|
revokeObjectURLSpy.mockRestore()
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -289,6 +289,8 @@ export class DocumentDetailComponent
|
|||||||
private incomingUpdateModal: NgbModalRef
|
private incomingUpdateModal: NgbModalRef
|
||||||
private pendingIncomingUpdate: IncomingDocumentUpdate
|
private pendingIncomingUpdate: IncomingDocumentUpdate
|
||||||
private lastLocalSaveModified: string | null = null
|
private lastLocalSaveModified: string | null = null
|
||||||
|
private printIframe: HTMLIFrameElement | null = null
|
||||||
|
private printBlobUrl: string | null = null
|
||||||
|
|
||||||
requiresPassword: boolean = false
|
requiresPassword: boolean = false
|
||||||
password: string
|
password: string
|
||||||
@@ -657,7 +659,7 @@ export class DocumentDetailComponent
|
|||||||
modal.componentInstance.cancelBtnCaption = $localize`Dismiss`
|
modal.componentInstance.cancelBtnCaption = $localize`Dismiss`
|
||||||
|
|
||||||
modal.componentInstance.confirmClicked.pipe(first()).subscribe(() => {
|
modal.componentInstance.confirmClicked.pipe(first()).subscribe(() => {
|
||||||
modal.componentInstance.buttonsEnabled = false
|
modal.componentInstance.buttonsEnabled.set(false)
|
||||||
modal.close()
|
modal.close()
|
||||||
this.reloadRemoteVersion()
|
this.reloadRemoteVersion()
|
||||||
})
|
})
|
||||||
@@ -868,6 +870,7 @@ export class DocumentDetailComponent
|
|||||||
}
|
}
|
||||||
|
|
||||||
ngOnDestroy(): void {
|
ngOnDestroy(): void {
|
||||||
|
this.cleanupPrintDocument()
|
||||||
this.unsubscribeNotifier.next(this)
|
this.unsubscribeNotifier.next(this)
|
||||||
this.unsubscribeNotifier.complete()
|
this.unsubscribeNotifier.complete()
|
||||||
}
|
}
|
||||||
@@ -886,13 +889,9 @@ export class DocumentDetailComponent
|
|||||||
|
|
||||||
updateComponent(doc: Document) {
|
updateComponent(doc: Document) {
|
||||||
this.document.set(doc)
|
this.document.set(doc)
|
||||||
// Default selected version is the newest version
|
// Default selected version is the newest version, which the API returns first
|
||||||
const versions = doc.versions ?? []
|
const versions = doc.versions ?? []
|
||||||
this.selectedVersionId.set(
|
this.selectedVersionId.set(versions.length ? versions[0].id : doc.id)
|
||||||
versions.length
|
|
||||||
? Math.max(...versions.map((version) => version.id))
|
|
||||||
: doc.id
|
|
||||||
)
|
|
||||||
this.previewLoaded.set(false)
|
this.previewLoaded.set(false)
|
||||||
this.requiresPassword = false
|
this.requiresPassword = false
|
||||||
this.updateFormForCustomFields()
|
this.updateFormForCustomFields()
|
||||||
@@ -1371,7 +1370,7 @@ export class DocumentDetailComponent
|
|||||||
modal.componentInstance.confirmClicked
|
modal.componentInstance.confirmClicked
|
||||||
.pipe(
|
.pipe(
|
||||||
switchMap(() => {
|
switchMap(() => {
|
||||||
modal.componentInstance.buttonsEnabled = false
|
modal.componentInstance.buttonsEnabled.set(false)
|
||||||
return this.documentsService.delete(this.document())
|
return this.documentsService.delete(this.document())
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
@@ -1383,7 +1382,7 @@ export class DocumentDetailComponent
|
|||||||
},
|
},
|
||||||
error: (error) => {
|
error: (error) => {
|
||||||
this.toastService.showError($localize`Error deleting document`, error)
|
this.toastService.showError($localize`Error deleting document`, error)
|
||||||
modal.componentInstance.buttonsEnabled = true
|
modal.componentInstance.buttonsEnabled.set(true)
|
||||||
this.subscribeModalDelete(modal)
|
this.subscribeModalDelete(modal)
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -1408,7 +1407,7 @@ export class DocumentDetailComponent
|
|||||||
modal.componentInstance.btnClass = 'btn-danger'
|
modal.componentInstance.btnClass = 'btn-danger'
|
||||||
modal.componentInstance.btnCaption = $localize`Proceed`
|
modal.componentInstance.btnCaption = $localize`Proceed`
|
||||||
modal.componentInstance.confirmClicked.subscribe(() => {
|
modal.componentInstance.confirmClicked.subscribe(() => {
|
||||||
modal.componentInstance.buttonsEnabled = false
|
modal.componentInstance.buttonsEnabled.set(false)
|
||||||
this.documentsService
|
this.documentsService
|
||||||
.reprocessDocuments({ documents: [this.document().id] })
|
.reprocessDocuments({ documents: [this.document().id] })
|
||||||
.subscribe({
|
.subscribe({
|
||||||
@@ -1422,7 +1421,7 @@ export class DocumentDetailComponent
|
|||||||
},
|
},
|
||||||
error: (error) => {
|
error: (error) => {
|
||||||
if (modal) {
|
if (modal) {
|
||||||
modal.componentInstance.buttonsEnabled = true
|
modal.componentInstance.buttonsEnabled.set(true)
|
||||||
}
|
}
|
||||||
this.toastService.showError(
|
this.toastService.showError(
|
||||||
$localize`Error executing operation`,
|
$localize`Error executing operation`,
|
||||||
@@ -1438,7 +1437,8 @@ export class DocumentDetailComponent
|
|||||||
if (!versions.length || !this.selectedVersionId()) {
|
if (!versions.length || !this.selectedVersionId()) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
const latestVersionId = Math.max(...versions.map((version) => version.id))
|
// The API returns versions newest first
|
||||||
|
const latestVersionId = versions[0].id
|
||||||
return this.selectedVersionId() === latestVersionId
|
return this.selectedVersionId() === latestVersionId
|
||||||
? null
|
? null
|
||||||
: this.selectedVersionId()
|
: this.selectedVersionId()
|
||||||
@@ -1795,7 +1795,7 @@ export class DocumentDetailComponent
|
|||||||
modal.componentInstance.confirmClicked
|
modal.componentInstance.confirmClicked
|
||||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||||
.subscribe(() => {
|
.subscribe(() => {
|
||||||
modal.componentInstance.buttonsEnabled = false
|
modal.componentInstance.buttonsEnabled.set(false)
|
||||||
this.documentsService
|
this.documentsService
|
||||||
.editPdfDocuments([sourceDocumentId], {
|
.editPdfDocuments([sourceDocumentId], {
|
||||||
operations: modal.componentInstance.getOperations(),
|
operations: modal.componentInstance.getOperations(),
|
||||||
@@ -1818,7 +1818,7 @@ export class DocumentDetailComponent
|
|||||||
},
|
},
|
||||||
error: (error) => {
|
error: (error) => {
|
||||||
if (modal) {
|
if (modal) {
|
||||||
modal.componentInstance.buttonsEnabled = true
|
modal.componentInstance.buttonsEnabled.set(true)
|
||||||
}
|
}
|
||||||
this.toastService.showError(
|
this.toastService.showError(
|
||||||
$localize`Error executing PDF edit operation`,
|
$localize`Error executing PDF edit operation`,
|
||||||
@@ -1852,7 +1852,7 @@ export class DocumentDetailComponent
|
|||||||
const sourceDocumentId = this.selectedVersionId() ?? this.document().id
|
const sourceDocumentId = this.selectedVersionId() ?? this.document().id
|
||||||
const dialog =
|
const dialog =
|
||||||
modal.componentInstance as PasswordRemovalConfirmDialogComponent
|
modal.componentInstance as PasswordRemovalConfirmDialogComponent
|
||||||
dialog.buttonsEnabled = false
|
dialog.buttonsEnabled.set(false)
|
||||||
this.networkActive.set(true)
|
this.networkActive.set(true)
|
||||||
this.documentsService
|
this.documentsService
|
||||||
.removePasswordDocuments([sourceDocumentId], {
|
.removePasswordDocuments([sourceDocumentId], {
|
||||||
@@ -1877,7 +1877,7 @@ export class DocumentDetailComponent
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
error: (error) => {
|
error: (error) => {
|
||||||
dialog.buttonsEnabled = true
|
dialog.buttonsEnabled.set(true)
|
||||||
this.networkActive.set(false)
|
this.networkActive.set(false)
|
||||||
this.toastService.showError(
|
this.toastService.showError(
|
||||||
$localize`Error executing password removal operation`,
|
$localize`Error executing password removal operation`,
|
||||||
@@ -1889,6 +1889,7 @@ export class DocumentDetailComponent
|
|||||||
}
|
}
|
||||||
|
|
||||||
printDocument() {
|
printDocument() {
|
||||||
|
this.cleanupPrintDocument()
|
||||||
const selectedVersionId = this.getSelectedNonLatestVersionId()
|
const selectedVersionId = this.getSelectedNonLatestVersionId()
|
||||||
const printUrl = this.documentsService.getDownloadUrl(
|
const printUrl = this.documentsService.getDownloadUrl(
|
||||||
this.document().id,
|
this.document().id,
|
||||||
@@ -1902,6 +1903,8 @@ export class DocumentDetailComponent
|
|||||||
next: (blob) => {
|
next: (blob) => {
|
||||||
const blobUrl = URL.createObjectURL(blob)
|
const blobUrl = URL.createObjectURL(blob)
|
||||||
const iframe = document.createElement('iframe')
|
const iframe = document.createElement('iframe')
|
||||||
|
this.printIframe = iframe
|
||||||
|
this.printBlobUrl = blobUrl
|
||||||
iframe.style.position = 'fixed'
|
iframe.style.position = 'fixed'
|
||||||
iframe.style.right = '0'
|
iframe.style.right = '0'
|
||||||
iframe.style.bottom = '0'
|
iframe.style.bottom = '0'
|
||||||
@@ -1917,22 +1920,19 @@ export class DocumentDetailComponent
|
|||||||
iframe.contentWindow.focus()
|
iframe.contentWindow.focus()
|
||||||
iframe.contentWindow.print()
|
iframe.contentWindow.print()
|
||||||
iframe.contentWindow.onafterprint = () => {
|
iframe.contentWindow.onafterprint = () => {
|
||||||
document.body.removeChild(iframe)
|
this.cleanupPrintDocument()
|
||||||
URL.revokeObjectURL(blobUrl)
|
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// FF throws cross-origin error on onafterprint
|
// FF throws cross-origin error on onafterprint
|
||||||
const isCrossOriginAfterPrintError =
|
const isCrossOriginAfterPrintError =
|
||||||
err instanceof DOMException &&
|
err instanceof DOMException &&
|
||||||
err.message.includes('onafterprint')
|
err.message.includes('onafterprint')
|
||||||
|
// FF throws here while print preview is still reading the iframe
|
||||||
|
// so keep it alive until the next print or teardown
|
||||||
if (!isCrossOriginAfterPrintError) {
|
if (!isCrossOriginAfterPrintError) {
|
||||||
this.toastService.showError($localize`Print failed.`, err)
|
this.toastService.showError($localize`Print failed.`, err)
|
||||||
|
timer(100).subscribe(() => this.cleanupPrintDocument())
|
||||||
}
|
}
|
||||||
timer(100).subscribe(() => {
|
|
||||||
// delay to avoid FF print failure
|
|
||||||
document.body.removeChild(iframe)
|
|
||||||
URL.revokeObjectURL(blobUrl)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -1945,9 +1945,20 @@ export class DocumentDetailComponent
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private cleanupPrintDocument() {
|
||||||
|
if (this.printIframe) this.printIframe.remove()
|
||||||
|
this.printIframe = null
|
||||||
|
if (this.printBlobUrl) {
|
||||||
|
URL.revokeObjectURL(this.printBlobUrl)
|
||||||
|
this.printBlobUrl = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public openShareLinks() {
|
public openShareLinks() {
|
||||||
const modal = this.modalService.open(ShareLinksDialogComponent)
|
const modal = this.modalService.open(ShareLinksDialogComponent)
|
||||||
modal.componentInstance.documentId.set(this.document().id)
|
modal.componentInstance.documentId.set(
|
||||||
|
this.selectedVersionId() ?? this.document().id
|
||||||
|
)
|
||||||
modal.componentInstance.hasArchiveVersion.set(
|
modal.componentInstance.hasArchiveVersion.set(
|
||||||
this.metadata()?.has_archive_version ??
|
this.metadata()?.has_archive_version ??
|
||||||
!!this.document()?.archived_file_name
|
!!this.document()?.archived_file_name
|
||||||
|
|||||||
+18
@@ -0,0 +1,18 @@
|
|||||||
|
<div class="modal-header">
|
||||||
|
<h4 class="modal-title" i18n>Add existing document as version</h4>
|
||||||
|
<button type="button" class="btn-close" aria-label="Close" (click)="cancel()"></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<pngx-input-document-link
|
||||||
|
[(ngModel)]="selectedDocumentIDs"
|
||||||
|
[parentDocumentID]="rootDocumentID"
|
||||||
|
[minimal]="true"
|
||||||
|
placeholder="Search for a document"
|
||||||
|
i18n-placeholder
|
||||||
|
></pngx-input-document-link>
|
||||||
|
<div class="form-text mt-2" i18n>Select one document to add as a version.</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-outline-secondary" (click)="cancel()" [disabled]="!buttonsEnabled()" i18n>Cancel</button>
|
||||||
|
<button type="button" class="btn btn-primary" (click)="confirm()" [disabled]="!buttonsEnabled() || selectedDocumentIDs.length !== 1" i18n>Add version</button>
|
||||||
|
</div>
|
||||||
+73
@@ -0,0 +1,73 @@
|
|||||||
|
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||||
|
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
|
||||||
|
import { DocumentService } from 'src/app/services/rest/document.service'
|
||||||
|
import { AddExistingDocumentVersionDialogComponent } from './add-existing-document-version-dialog.component'
|
||||||
|
|
||||||
|
describe('AddExistingDocumentVersionDialogComponent', () => {
|
||||||
|
let component: AddExistingDocumentVersionDialogComponent
|
||||||
|
let fixture: ComponentFixture<AddExistingDocumentVersionDialogComponent>
|
||||||
|
let activeModal: jest.Mocked<Pick<NgbActiveModal, 'dismiss'>>
|
||||||
|
|
||||||
|
beforeEach(async () => {
|
||||||
|
activeModal = { dismiss: jest.fn() }
|
||||||
|
await TestBed.configureTestingModule({
|
||||||
|
imports: [AddExistingDocumentVersionDialogComponent],
|
||||||
|
providers: [
|
||||||
|
{
|
||||||
|
provide: NgbActiveModal,
|
||||||
|
useValue: activeModal,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: DocumentService,
|
||||||
|
useValue: {},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
}).compileComponents()
|
||||||
|
|
||||||
|
fixture = TestBed.createComponent(AddExistingDocumentVersionDialogComponent)
|
||||||
|
component = fixture.componentInstance
|
||||||
|
component.rootDocumentID = 3
|
||||||
|
fixture.detectChanges()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should emit the single selected document', () => {
|
||||||
|
const emitSpy = jest.spyOn(component.confirmClicked, 'emit')
|
||||||
|
component.selectedDocumentIDs = [20]
|
||||||
|
|
||||||
|
component.confirm()
|
||||||
|
|
||||||
|
expect(emitSpy).toHaveBeenCalledWith(20)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should require exactly one selected document', () => {
|
||||||
|
const emitSpy = jest.spyOn(component.confirmClicked, 'emit')
|
||||||
|
component.selectedDocumentIDs = [20, 21]
|
||||||
|
|
||||||
|
component.confirm()
|
||||||
|
|
||||||
|
expect(emitSpy).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should dismiss on cancel', () => {
|
||||||
|
component.cancel()
|
||||||
|
|
||||||
|
expect(activeModal.dismiss).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should re-render the buttons when they are toggled from outside', async () => {
|
||||||
|
const cancelButton: HTMLButtonElement = fixture.nativeElement.querySelector(
|
||||||
|
'.modal-footer button'
|
||||||
|
)
|
||||||
|
expect(cancelButton.disabled).toBeFalsy()
|
||||||
|
|
||||||
|
// No detectChanges: the dropdown toggling this from a request callback is
|
||||||
|
// all that happens, and nothing else schedules a render for the modal
|
||||||
|
component.buttonsEnabled.set(false)
|
||||||
|
await fixture.whenStable()
|
||||||
|
expect(cancelButton.disabled).toBeTruthy()
|
||||||
|
|
||||||
|
component.buttonsEnabled.set(true)
|
||||||
|
await fixture.whenStable()
|
||||||
|
expect(cancelButton.disabled).toBeFalsy()
|
||||||
|
})
|
||||||
|
})
|
||||||
+35
@@ -0,0 +1,35 @@
|
|||||||
|
import {
|
||||||
|
Component,
|
||||||
|
EventEmitter,
|
||||||
|
Input,
|
||||||
|
Output,
|
||||||
|
inject,
|
||||||
|
signal,
|
||||||
|
} from '@angular/core'
|
||||||
|
import { FormsModule } from '@angular/forms'
|
||||||
|
import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'
|
||||||
|
import { DocumentLinkComponent } from 'src/app/components/common/input/document-link/document-link.component'
|
||||||
|
|
||||||
|
@Component({
|
||||||
|
selector: 'pngx-add-existing-document-version-dialog',
|
||||||
|
templateUrl: './add-existing-document-version-dialog.component.html',
|
||||||
|
imports: [DocumentLinkComponent, FormsModule],
|
||||||
|
})
|
||||||
|
export class AddExistingDocumentVersionDialogComponent {
|
||||||
|
private readonly activeModal = inject(NgbActiveModal)
|
||||||
|
|
||||||
|
@Input() rootDocumentID: number
|
||||||
|
@Output() confirmClicked = new EventEmitter<number>()
|
||||||
|
|
||||||
|
selectedDocumentIDs: number[] = []
|
||||||
|
readonly buttonsEnabled = signal(true)
|
||||||
|
|
||||||
|
confirm(): void {
|
||||||
|
if (this.selectedDocumentIDs.length !== 1) return
|
||||||
|
this.confirmClicked.emit(this.selectedDocumentIDs[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
cancel(): void {
|
||||||
|
this.activeModal.dismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
+21
-7
@@ -24,13 +24,26 @@
|
|||||||
class="visually-hidden"
|
class="visually-hidden"
|
||||||
(change)="onVersionFileSelected($event)"
|
(change)="onVersionFileSelected($event)"
|
||||||
/>
|
/>
|
||||||
<button
|
<div class="btn-group btn-group-sm w-100">
|
||||||
class="btn btn-sm btn-outline-secondary w-100"
|
<button
|
||||||
(click)="versionFileInput.click()"
|
class="btn btn-sm btn-outline-secondary w-100"
|
||||||
[disabled]="!userIsOwner || !userCanEdit"
|
(click)="versionFileInput.click()"
|
||||||
>
|
[disabled]="!userIsOwner || !userCanEdit"
|
||||||
<i-bs name="file-earmark-plus"></i-bs><span class="ps-1" i18n>Add new version</span>
|
title="Upload a new version"
|
||||||
</button>
|
i18n-title
|
||||||
|
>
|
||||||
|
<i-bs name="file-earmark-plus"></i-bs><span class="ps-1" i18n>Upload</span>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="btn btn-sm btn-outline-secondary w-100"
|
||||||
|
(click)="addExistingDocumentAsVersion()"
|
||||||
|
[disabled]="!userIsOwner || !userCanEdit"
|
||||||
|
title="Use an existing document"
|
||||||
|
i18n-title
|
||||||
|
>
|
||||||
|
<i-bs name="file-earmark"></i-bs><span class="ps-1" i18n>Existing</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
} @else {
|
} @else {
|
||||||
@switch (versionUploadState()) {
|
@switch (versionUploadState()) {
|
||||||
@case (UploadState.Uploading) {
|
@case (UploadState.Uploading) {
|
||||||
@@ -128,6 +141,7 @@
|
|||||||
i18n-confirmMessage
|
i18n-confirmMessage
|
||||||
[disabled]="!userIsOwner || !userCanEdit"
|
[disabled]="!userIsOwner || !userCanEdit"
|
||||||
(confirm)="deleteVersion(version.id)"
|
(confirm)="deleteVersion(version.id)"
|
||||||
|
*pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.Document }"
|
||||||
>
|
>
|
||||||
<span class="visually-hidden" i18n>Delete version</span>
|
<span class="visually-hidden" i18n>Delete version</span>
|
||||||
</pngx-confirm-button>
|
</pngx-confirm-button>
|
||||||
|
|||||||
+98
-3
@@ -1,9 +1,16 @@
|
|||||||
import { DatePipe } from '@angular/common'
|
import { DatePipe } from '@angular/common'
|
||||||
import { SimpleChange } from '@angular/core'
|
import { SimpleChange, signal } from '@angular/core'
|
||||||
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||||
|
import { By } from '@angular/platform-browser'
|
||||||
|
import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
|
||||||
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
||||||
import { Subject, of, throwError } from 'rxjs'
|
import { Subject, of, throwError } from 'rxjs'
|
||||||
import { DocumentVersionInfo } from 'src/app/data/document'
|
import { DocumentVersionInfo } from 'src/app/data/document'
|
||||||
|
import {
|
||||||
|
PermissionAction,
|
||||||
|
PermissionsService,
|
||||||
|
PermissionType,
|
||||||
|
} from 'src/app/services/permissions.service'
|
||||||
import { DocumentService } from 'src/app/services/rest/document.service'
|
import { DocumentService } from 'src/app/services/rest/document.service'
|
||||||
import { SettingsService } from 'src/app/services/settings.service'
|
import { SettingsService } from 'src/app/services/settings.service'
|
||||||
import { ToastService } from 'src/app/services/toast.service'
|
import { ToastService } from 'src/app/services/toast.service'
|
||||||
@@ -19,12 +26,20 @@ describe('DocumentVersionDropdownComponent', () => {
|
|||||||
let documentService: jest.Mocked<
|
let documentService: jest.Mocked<
|
||||||
Pick<
|
Pick<
|
||||||
DocumentService,
|
DocumentService,
|
||||||
'deleteVersion' | 'getVersions' | 'uploadVersion' | 'updateVersionLabel'
|
| 'deleteVersion'
|
||||||
|
| 'getVersions'
|
||||||
|
| 'mergeDocumentsAsVersions'
|
||||||
|
| 'uploadVersion'
|
||||||
|
| 'updateVersionLabel'
|
||||||
>
|
>
|
||||||
>
|
>
|
||||||
let toastService: jest.Mocked<Pick<ToastService, 'showError' | 'showInfo'>>
|
let toastService: jest.Mocked<Pick<ToastService, 'showError' | 'showInfo'>>
|
||||||
let finished$: Subject<{ taskId: string }>
|
let finished$: Subject<{ taskId: string }>
|
||||||
let failed$: Subject<{ taskId: string; message?: string }>
|
let failed$: Subject<{ taskId: string; message?: string }>
|
||||||
|
let modalService: jest.Mocked<Pick<NgbModal, 'open'>>
|
||||||
|
let permissionsService: jest.Mocked<
|
||||||
|
Pick<PermissionsService, 'currentUserCan'>
|
||||||
|
>
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
finished$ = new Subject<{ taskId: string }>()
|
finished$ = new Subject<{ taskId: string }>()
|
||||||
@@ -32,13 +47,18 @@ describe('DocumentVersionDropdownComponent', () => {
|
|||||||
documentService = {
|
documentService = {
|
||||||
deleteVersion: jest.fn(),
|
deleteVersion: jest.fn(),
|
||||||
getVersions: jest.fn(),
|
getVersions: jest.fn(),
|
||||||
|
mergeDocumentsAsVersions: jest.fn(),
|
||||||
uploadVersion: jest.fn(),
|
uploadVersion: jest.fn(),
|
||||||
updateVersionLabel: jest.fn(),
|
updateVersionLabel: jest.fn(),
|
||||||
}
|
}
|
||||||
|
modalService = { open: jest.fn() }
|
||||||
toastService = {
|
toastService = {
|
||||||
showError: jest.fn(),
|
showError: jest.fn(),
|
||||||
showInfo: jest.fn(),
|
showInfo: jest.fn(),
|
||||||
}
|
}
|
||||||
|
permissionsService = {
|
||||||
|
currentUserCan: jest.fn().mockReturnValue(true),
|
||||||
|
}
|
||||||
|
|
||||||
await TestBed.configureTestingModule({
|
await TestBed.configureTestingModule({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -61,6 +81,14 @@ describe('DocumentVersionDropdownComponent', () => {
|
|||||||
provide: ToastService,
|
provide: ToastService,
|
||||||
useValue: toastService,
|
useValue: toastService,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
provide: NgbModal,
|
||||||
|
useValue: modalService,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
provide: PermissionsService,
|
||||||
|
useValue: permissionsService,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
provide: WebsocketStatusService,
|
provide: WebsocketStatusService,
|
||||||
useValue: {
|
useValue: {
|
||||||
@@ -131,6 +159,31 @@ describe('DocumentVersionDropdownComponent', () => {
|
|||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should not show version delete buttons without document delete permission', () => {
|
||||||
|
fixture.destroy()
|
||||||
|
permissionsService.currentUserCan.mockReturnValue(false)
|
||||||
|
fixture = TestBed.createComponent(DocumentVersionDropdownComponent)
|
||||||
|
component = fixture.componentInstance
|
||||||
|
component.documentId = 3
|
||||||
|
component.selectedVersionId = 3
|
||||||
|
component.userIsOwner = true
|
||||||
|
component.userCanEdit = true
|
||||||
|
component.versions = [
|
||||||
|
{ id: 3, is_root: true, checksum: 'aaaa' },
|
||||||
|
{ id: 10, is_root: false, checksum: 'bbbb' },
|
||||||
|
]
|
||||||
|
|
||||||
|
fixture.detectChanges()
|
||||||
|
|
||||||
|
expect(permissionsService.currentUserCan).toHaveBeenCalledWith(
|
||||||
|
PermissionAction.Delete,
|
||||||
|
PermissionType.Document
|
||||||
|
)
|
||||||
|
expect(
|
||||||
|
fixture.debugElement.queryAll(By.css('pngx-confirm-button'))
|
||||||
|
).toHaveLength(0)
|
||||||
|
})
|
||||||
|
|
||||||
it('beginEditingVersion should set active row and draft label', () => {
|
it('beginEditingVersion should set active row and draft label', () => {
|
||||||
component.userCanEdit = true
|
component.userCanEdit = true
|
||||||
component.userIsOwner = true
|
component.userIsOwner = true
|
||||||
@@ -222,9 +275,10 @@ describe('DocumentVersionDropdownComponent', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('onVersionFileSelected should upload and update versions after websocket success', () => {
|
it('onVersionFileSelected should upload and update versions after websocket success', () => {
|
||||||
|
// Newest first, as the API returns them
|
||||||
const versions: DocumentVersionInfo[] = [
|
const versions: DocumentVersionInfo[] = [
|
||||||
{ id: 3, is_root: true, checksum: 'aaaa' },
|
|
||||||
{ id: 20, is_root: false, checksum: 'cccc' },
|
{ id: 20, is_root: false, checksum: 'cccc' },
|
||||||
|
{ id: 3, is_root: true, checksum: 'aaaa' },
|
||||||
]
|
]
|
||||||
const file = new File(['test'], 'new-version.pdf', {
|
const file = new File(['test'], 'new-version.pdf', {
|
||||||
type: 'application/pdf',
|
type: 'application/pdf',
|
||||||
@@ -323,4 +377,45 @@ describe('DocumentVersionDropdownComponent', () => {
|
|||||||
expect(component.editingVersionId).toBeNull()
|
expect(component.editingVersionId).toBeNull()
|
||||||
expect(component.versionLabelDraft).toEqual('')
|
expect(component.versionLabelDraft).toEqual('')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('addExistingDocumentAsVersion should merge with a label and refresh versions', () => {
|
||||||
|
const confirmClicked = new Subject<number>()
|
||||||
|
const modal = {
|
||||||
|
componentInstance: {
|
||||||
|
rootDocumentID: null,
|
||||||
|
buttonsEnabled: signal(true),
|
||||||
|
confirmClicked,
|
||||||
|
},
|
||||||
|
close: jest.fn(),
|
||||||
|
}
|
||||||
|
modalService.open.mockReturnValue(modal as any)
|
||||||
|
documentService.mergeDocumentsAsVersions.mockReturnValue(of({} as any))
|
||||||
|
// Newest first, as the API returns them. The merged document has a lower id
|
||||||
|
// than the root, which is the whole point of merging an existing document.
|
||||||
|
const versions: DocumentVersionInfo[] = [
|
||||||
|
{ id: 2, is_root: false, checksum: 'cccc' },
|
||||||
|
{ id: 3, is_root: true, checksum: 'aaaa' },
|
||||||
|
]
|
||||||
|
documentService.getVersions.mockReturnValue(of({ id: 3, versions } as any))
|
||||||
|
component.newVersionLabel = ' Imported '
|
||||||
|
const versionsEmitSpy = jest.spyOn(component.versionsUpdated, 'emit')
|
||||||
|
const selectedEmitSpy = jest.spyOn(component.versionSelected, 'emit')
|
||||||
|
|
||||||
|
component.addExistingDocumentAsVersion()
|
||||||
|
expect(modal.componentInstance.rootDocumentID).toEqual(3)
|
||||||
|
confirmClicked.next(2)
|
||||||
|
|
||||||
|
expect(documentService.mergeDocumentsAsVersions).toHaveBeenCalledWith(
|
||||||
|
[3, 2],
|
||||||
|
3,
|
||||||
|
'Imported'
|
||||||
|
)
|
||||||
|
expect(documentService.updateVersionLabel).not.toHaveBeenCalled()
|
||||||
|
expect(documentService.getVersions).toHaveBeenCalledWith(3)
|
||||||
|
expect(versionsEmitSpy).toHaveBeenCalledWith(versions)
|
||||||
|
expect(selectedEmitSpy).toHaveBeenCalledWith(2)
|
||||||
|
expect(component.newVersionLabel).toEqual('')
|
||||||
|
expect(modal.close).toHaveBeenCalled()
|
||||||
|
expect(toastService.showInfo).toHaveBeenCalled()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
+62
-6
@@ -11,7 +11,7 @@ import {
|
|||||||
SimpleChanges,
|
SimpleChanges,
|
||||||
} from '@angular/core'
|
} from '@angular/core'
|
||||||
import { FormsModule } from '@angular/forms'
|
import { FormsModule } from '@angular/forms'
|
||||||
import { NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap'
|
import { NgbDropdownModule, NgbModal } from '@ng-bootstrap/ng-bootstrap'
|
||||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||||
import { merge, of, Subject } from 'rxjs'
|
import { merge, of, Subject } from 'rxjs'
|
||||||
import {
|
import {
|
||||||
@@ -25,6 +25,7 @@ import {
|
|||||||
tap,
|
tap,
|
||||||
} from 'rxjs/operators'
|
} from 'rxjs/operators'
|
||||||
import { DocumentVersionInfo } from 'src/app/data/document'
|
import { DocumentVersionInfo } from 'src/app/data/document'
|
||||||
|
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
||||||
import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
|
import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
|
||||||
import { DocumentService } from 'src/app/services/rest/document.service'
|
import { DocumentService } from 'src/app/services/rest/document.service'
|
||||||
import { ToastService } from 'src/app/services/toast.service'
|
import { ToastService } from 'src/app/services/toast.service'
|
||||||
@@ -33,6 +34,8 @@ import {
|
|||||||
WebsocketStatusService,
|
WebsocketStatusService,
|
||||||
} from 'src/app/services/websocket-status.service'
|
} from 'src/app/services/websocket-status.service'
|
||||||
import { ConfirmButtonComponent } from '../../common/confirm-button/confirm-button.component'
|
import { ConfirmButtonComponent } from '../../common/confirm-button/confirm-button.component'
|
||||||
|
import { ComponentWithPermissions } from '../../with-permissions/with-permissions.component'
|
||||||
|
import { AddExistingDocumentVersionDialogComponent } from './add-existing-document-version-dialog/add-existing-document-version-dialog.component'
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'pngx-document-version-dropdown',
|
selector: 'pngx-document-version-dropdown',
|
||||||
@@ -43,11 +46,15 @@ import { ConfirmButtonComponent } from '../../common/confirm-button/confirm-butt
|
|||||||
NgbDropdownModule,
|
NgbDropdownModule,
|
||||||
NgxBootstrapIconsModule,
|
NgxBootstrapIconsModule,
|
||||||
ConfirmButtonComponent,
|
ConfirmButtonComponent,
|
||||||
|
IfPermissionsDirective,
|
||||||
SlicePipe,
|
SlicePipe,
|
||||||
CustomDatePipe,
|
CustomDatePipe,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class DocumentVersionDropdownComponent implements OnChanges, OnDestroy {
|
export class DocumentVersionDropdownComponent
|
||||||
|
extends ComponentWithPermissions
|
||||||
|
implements OnChanges, OnDestroy
|
||||||
|
{
|
||||||
UploadState = UploadState
|
UploadState = UploadState
|
||||||
|
|
||||||
@Input() documentId: number
|
@Input() documentId: number
|
||||||
@@ -69,6 +76,7 @@ export class DocumentVersionDropdownComponent implements OnChanges, OnDestroy {
|
|||||||
private readonly documentsService = inject(DocumentService)
|
private readonly documentsService = inject(DocumentService)
|
||||||
private readonly toastService = inject(ToastService)
|
private readonly toastService = inject(ToastService)
|
||||||
private readonly websocketStatusService = inject(WebsocketStatusService)
|
private readonly websocketStatusService = inject(WebsocketStatusService)
|
||||||
|
private readonly modalService = inject(NgbModal)
|
||||||
private readonly destroy$ = new Subject<void>()
|
private readonly destroy$ = new Subject<void>()
|
||||||
private readonly documentChange$ = new Subject<void>()
|
private readonly documentChange$ = new Subject<void>()
|
||||||
|
|
||||||
@@ -256,11 +264,10 @@ export class DocumentVersionDropdownComponent implements OnChanges, OnDestroy {
|
|||||||
.subscribe({
|
.subscribe({
|
||||||
next: (doc) => {
|
next: (doc) => {
|
||||||
if (uploadDocumentId !== this.documentId) return
|
if (uploadDocumentId !== this.documentId) return
|
||||||
if (doc?.versions) {
|
if (doc?.versions?.length) {
|
||||||
this.versionsUpdated.emit(doc.versions)
|
this.versionsUpdated.emit(doc.versions)
|
||||||
this.versionSelected.emit(
|
// The API returns versions newest first
|
||||||
Math.max(...doc.versions.map((version) => version.id))
|
this.versionSelected.emit(doc.versions[0].id)
|
||||||
)
|
|
||||||
this.clearVersionUploadStatus()
|
this.clearVersionUploadStatus()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -278,6 +285,55 @@ export class DocumentVersionDropdownComponent implements OnChanges, OnDestroy {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
addExistingDocumentAsVersion(): void {
|
||||||
|
const modal = this.modalService.open(
|
||||||
|
AddExistingDocumentVersionDialogComponent,
|
||||||
|
{ backdrop: 'static' }
|
||||||
|
)
|
||||||
|
const dialog =
|
||||||
|
modal.componentInstance as AddExistingDocumentVersionDialogComponent
|
||||||
|
dialog.rootDocumentID = this.documentId
|
||||||
|
dialog.confirmClicked
|
||||||
|
.pipe(takeUntil(this.destroy$), takeUntil(this.documentChange$))
|
||||||
|
.subscribe((existingDocumentID) => {
|
||||||
|
dialog.buttonsEnabled.set(false)
|
||||||
|
const versionLabel = this.newVersionLabel?.trim()
|
||||||
|
this.documentsService
|
||||||
|
.mergeDocumentsAsVersions(
|
||||||
|
[this.documentId, existingDocumentID],
|
||||||
|
this.documentId,
|
||||||
|
versionLabel
|
||||||
|
)
|
||||||
|
.pipe(
|
||||||
|
switchMap(() => this.documentsService.getVersions(this.documentId)),
|
||||||
|
first(),
|
||||||
|
finalize(() => dialog.buttonsEnabled.set(true)),
|
||||||
|
takeUntil(this.destroy$),
|
||||||
|
takeUntil(this.documentChange$)
|
||||||
|
)
|
||||||
|
.subscribe({
|
||||||
|
next: (document) => {
|
||||||
|
if (document?.versions?.length) {
|
||||||
|
this.versionsUpdated.emit(document.versions)
|
||||||
|
// The API returns versions newest first
|
||||||
|
this.versionSelected.emit(document.versions[0].id)
|
||||||
|
}
|
||||||
|
this.newVersionLabel = ''
|
||||||
|
modal.close()
|
||||||
|
this.toastService.showInfo(
|
||||||
|
$localize`Existing document added as a version.`
|
||||||
|
)
|
||||||
|
},
|
||||||
|
error: (error) => {
|
||||||
|
this.toastService.showError(
|
||||||
|
$localize`Error adding existing document as a version`,
|
||||||
|
error
|
||||||
|
)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
clearVersionUploadStatus(): void {
|
clearVersionUploadStatus(): void {
|
||||||
this.versionUploadState.set(UploadState.Idle)
|
this.versionUploadState.set(UploadState.Idle)
|
||||||
this.versionUploadError.set(null)
|
this.versionUploadError.set(null)
|
||||||
|
|||||||
@@ -95,6 +95,9 @@
|
|||||||
<button ngbDropdownItem (click)="mergeSelected()" [disabled]="!userCanAdd || list.allSelected || list.selectedCount < 2">
|
<button ngbDropdownItem (click)="mergeSelected()" [disabled]="!userCanAdd || list.allSelected || list.selectedCount < 2">
|
||||||
<i-bs name="journals" class="me-1"></i-bs><ng-container i18n>Merge</ng-container>
|
<i-bs name="journals" class="me-1"></i-bs><ng-container i18n>Merge</ng-container>
|
||||||
</button>
|
</button>
|
||||||
|
<button ngbDropdownItem (click)="mergeSelectedAsVersions()" [disabled]="!userOwnsAll || !userCanEditAll || !userCanDelete || list.allSelected || list.selectedCount < 2">
|
||||||
|
<i-bs name="journal-bookmark-fill" class="me-1"></i-bs><ng-container i18n>Merge as versions</ng-container>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1248,6 +1248,89 @@ describe('BulkEditorComponent', () => {
|
|||||||
expect(documentListViewService.selected.size).toEqual(0)
|
expect(documentListViewService.selected.size).toEqual(0)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should support merging documents as versions', () => {
|
||||||
|
let modal: NgbModalRef
|
||||||
|
modalService.activeInstances.subscribe((m) => (modal = m[0]))
|
||||||
|
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
|
||||||
|
jest
|
||||||
|
.spyOn(documentListViewService, 'documents', 'get')
|
||||||
|
.mockReturnValue([{ id: 3 }, { id: 4 }])
|
||||||
|
jest.spyOn(documentService, 'getFew').mockReturnValue(
|
||||||
|
of({
|
||||||
|
all: [3, 4],
|
||||||
|
count: 2,
|
||||||
|
results: [
|
||||||
|
{ id: 3, title: 'Document 3' },
|
||||||
|
{ id: 4, title: 'Document 4' },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
)
|
||||||
|
jest
|
||||||
|
.spyOn(documentListViewService, 'selected', 'get')
|
||||||
|
.mockReturnValue(new Set([3, 4]))
|
||||||
|
jest
|
||||||
|
.spyOn(permissionsService, 'currentUserHasObjectPermissions')
|
||||||
|
.mockReturnValue(true)
|
||||||
|
jest
|
||||||
|
.spyOn(permissionsService, 'currentUserOwnsObject')
|
||||||
|
.mockReturnValue(true)
|
||||||
|
const mergeAsVersionsSpy = jest
|
||||||
|
.spyOn(documentService, 'mergeDocumentsAsVersions')
|
||||||
|
.mockReturnValue(of(true))
|
||||||
|
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
|
||||||
|
fixture.detectChanges()
|
||||||
|
|
||||||
|
component.mergeSelectedAsVersions()
|
||||||
|
expect(modal).not.toBeUndefined()
|
||||||
|
modal.componentInstance.rootDocumentID.set(4)
|
||||||
|
modal.componentInstance.confirm()
|
||||||
|
|
||||||
|
expect(mergeAsVersionsSpy).toHaveBeenCalledWith([3, 4], 4)
|
||||||
|
httpTestingController.match(
|
||||||
|
`${environment.apiBaseUrl}documents/?page=1&page_size=50&ordering=-created&truncate_content=true&include_selection_data=true`
|
||||||
|
)
|
||||||
|
httpTestingController.match(
|
||||||
|
`${environment.apiBaseUrl}documents/?page=1&page_size=100000&fields=id`
|
||||||
|
)
|
||||||
|
expect(documentListViewService.selected.size).toEqual(0)
|
||||||
|
expect(toastInfoSpy).toHaveBeenCalledWith('Documents merged as versions.')
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not report success when merging documents as versions fails', () => {
|
||||||
|
let modal: NgbModalRef
|
||||||
|
modalService.activeInstances.subscribe((m) => (modal = m[0]))
|
||||||
|
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
|
||||||
|
jest
|
||||||
|
.spyOn(documentListViewService, 'documents', 'get')
|
||||||
|
.mockReturnValue([{ id: 3 }, { id: 4 }])
|
||||||
|
jest.spyOn(documentService, 'getFew').mockReturnValue(
|
||||||
|
of({
|
||||||
|
all: [3, 4],
|
||||||
|
count: 2,
|
||||||
|
results: [
|
||||||
|
{ id: 3, title: 'Document 3' },
|
||||||
|
{ id: 4, title: 'Document 4' },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
)
|
||||||
|
jest
|
||||||
|
.spyOn(documentListViewService, 'selected', 'get')
|
||||||
|
.mockReturnValue(new Set([3, 4]))
|
||||||
|
jest
|
||||||
|
.spyOn(documentService, 'mergeDocumentsAsVersions')
|
||||||
|
.mockReturnValue(throwError(() => new Error('failed')))
|
||||||
|
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
|
||||||
|
const toastErrorSpy = jest.spyOn(toastService, 'showError')
|
||||||
|
fixture.detectChanges()
|
||||||
|
|
||||||
|
component.mergeSelectedAsVersions()
|
||||||
|
modal.componentInstance.rootDocumentID.set(4)
|
||||||
|
modal.componentInstance.confirm()
|
||||||
|
|
||||||
|
expect(toastErrorSpy).toHaveBeenCalled()
|
||||||
|
expect(toastInfoSpy).not.toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
it('should support bulk download with archive, originals or both and file formatting', () => {
|
it('should support bulk download with archive, originals or both and file formatting', () => {
|
||||||
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
|
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
|
||||||
jest
|
jest
|
||||||
@@ -1683,7 +1766,7 @@ describe('BulkEditorComponent', () => {
|
|||||||
expiration_days: 7,
|
expiration_days: 7,
|
||||||
},
|
},
|
||||||
loading: signal(false),
|
loading: signal(false),
|
||||||
buttonsEnabled: true,
|
buttonsEnabled: signal(true),
|
||||||
copied: signal(false),
|
copied: signal(false),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@@ -1715,7 +1798,7 @@ describe('BulkEditorComponent', () => {
|
|||||||
expiration_days: 7,
|
expiration_days: 7,
|
||||||
})
|
})
|
||||||
expect(dialogInstance.loading()).toBe(false)
|
expect(dialogInstance.loading()).toBe(false)
|
||||||
expect(dialogInstance.buttonsEnabled).toBe(false)
|
expect(dialogInstance.buttonsEnabled()).toBe(false)
|
||||||
expect(dialogInstance.createdBundle).toEqual({ id: 42 })
|
expect(dialogInstance.createdBundle).toEqual({ id: 42 })
|
||||||
expect(typeof dialogInstance.onOpenManage).toBe('function')
|
expect(typeof dialogInstance.onOpenManage).toBe('function')
|
||||||
expect(toastInfoSpy).toHaveBeenCalledWith(
|
expect(toastInfoSpy).toHaveBeenCalledWith(
|
||||||
@@ -1755,7 +1838,7 @@ describe('BulkEditorComponent', () => {
|
|||||||
expiration_days: null,
|
expiration_days: null,
|
||||||
},
|
},
|
||||||
loading: signal(false),
|
loading: signal(false),
|
||||||
buttonsEnabled: true,
|
buttonsEnabled: signal(true),
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1777,7 +1860,7 @@ describe('BulkEditorComponent', () => {
|
|||||||
expect.any(Error)
|
expect.any(Error)
|
||||||
)
|
)
|
||||||
expect(dialogInstance.loading()).toBe(false)
|
expect(dialogInstance.loading()).toBe(false)
|
||||||
expect(dialogInstance.buttonsEnabled).toBe(true)
|
expect(dialogInstance.buttonsEnabled()).toBe(true)
|
||||||
openSpy.mockRestore()
|
openSpy.mockRestore()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -50,6 +50,7 @@ import { SettingsService } from 'src/app/services/settings.service'
|
|||||||
import { ToastService } from 'src/app/services/toast.service'
|
import { ToastService } from 'src/app/services/toast.service'
|
||||||
import { flattenTags } from 'src/app/utils/flatten-tags'
|
import { flattenTags } from 'src/app/utils/flatten-tags'
|
||||||
import { queryParamsFromFilterRules } from 'src/app/utils/query-params'
|
import { queryParamsFromFilterRules } from 'src/app/utils/query-params'
|
||||||
|
import { MergeAsVersionsConfirmDialogComponent } from '../../common/confirm-dialog/merge-as-versions-confirm-dialog/merge-as-versions-confirm-dialog.component'
|
||||||
import { MergeConfirmDialogComponent } from '../../common/confirm-dialog/merge-confirm-dialog/merge-confirm-dialog.component'
|
import { MergeConfirmDialogComponent } from '../../common/confirm-dialog/merge-confirm-dialog/merge-confirm-dialog.component'
|
||||||
import { RotateConfirmDialogComponent } from '../../common/confirm-dialog/rotate-confirm-dialog/rotate-confirm-dialog.component'
|
import { RotateConfirmDialogComponent } from '../../common/confirm-dialog/rotate-confirm-dialog/rotate-confirm-dialog.component'
|
||||||
import { CorrespondentEditDialogComponent } from '../../common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component'
|
import { CorrespondentEditDialogComponent } from '../../common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component'
|
||||||
@@ -171,6 +172,13 @@ export class BulkEditorComponent
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get userCanDelete(): boolean {
|
||||||
|
return this.permissionService.currentUserCan(
|
||||||
|
PermissionAction.Delete,
|
||||||
|
PermissionType.Document
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
ngOnInit() {
|
ngOnInit() {
|
||||||
if (
|
if (
|
||||||
this.permissionService.currentUserCan(
|
this.permissionService.currentUserCan(
|
||||||
@@ -273,7 +281,7 @@ export class BulkEditorComponent
|
|||||||
overrideSelection?: DocumentSelectionQuery
|
overrideSelection?: DocumentSelectionQuery
|
||||||
) {
|
) {
|
||||||
if (modal) {
|
if (modal) {
|
||||||
this.setModalButtonsEnabled(modal, false)
|
modal.componentInstance.buttonsEnabled.set(false)
|
||||||
}
|
}
|
||||||
this.documentService
|
this.documentService
|
||||||
.bulkEdit(overrideSelection ?? this.getSelectionQuery(), method, args)
|
.bulkEdit(overrideSelection ?? this.getSelectionQuery(), method, args)
|
||||||
@@ -287,14 +295,17 @@ export class BulkEditorComponent
|
|||||||
private executeDocumentAction(
|
private executeDocumentAction(
|
||||||
modal: NgbModalRef,
|
modal: NgbModalRef,
|
||||||
request: Observable<any>,
|
request: Observable<any>,
|
||||||
options: { deleteOriginals?: boolean } = {}
|
options: { clearSelection?: boolean; successMessage?: string } = {}
|
||||||
) {
|
) {
|
||||||
if (modal) {
|
if (modal) {
|
||||||
this.setModalButtonsEnabled(modal, false)
|
modal.componentInstance.buttonsEnabled.set(false)
|
||||||
}
|
}
|
||||||
request.pipe(first()).subscribe({
|
request.pipe(first()).subscribe({
|
||||||
next: () => {
|
next: () => {
|
||||||
this.handleOperationSuccess(modal, options.deleteOriginals ?? false)
|
this.handleOperationSuccess(modal, options.clearSelection ?? false)
|
||||||
|
if (options.successMessage) {
|
||||||
|
this.toastService.showInfo(options.successMessage)
|
||||||
|
}
|
||||||
},
|
},
|
||||||
error: (error) => this.handleOperationError(modal, error),
|
error: (error) => this.handleOperationError(modal, error),
|
||||||
})
|
})
|
||||||
@@ -320,7 +331,7 @@ export class BulkEditorComponent
|
|||||||
|
|
||||||
private handleOperationError(modal: NgbModalRef, error: any) {
|
private handleOperationError(modal: NgbModalRef, error: any) {
|
||||||
if (modal) {
|
if (modal) {
|
||||||
this.setModalButtonsEnabled(modal, true)
|
modal.componentInstance.buttonsEnabled.set(true)
|
||||||
}
|
}
|
||||||
this.toastService.showError(
|
this.toastService.showError(
|
||||||
$localize`Error executing bulk operation`,
|
$localize`Error executing bulk operation`,
|
||||||
@@ -328,15 +339,6 @@ export class BulkEditorComponent
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private setModalButtonsEnabled(modal: NgbModalRef, enabled: boolean) {
|
|
||||||
const buttonsEnabled = modal.componentInstance.buttonsEnabled
|
|
||||||
if (typeof buttonsEnabled?.set === 'function') {
|
|
||||||
buttonsEnabled.set(enabled)
|
|
||||||
} else {
|
|
||||||
modal.componentInstance.buttonsEnabled = enabled
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private applySelectionData(
|
private applySelectionData(
|
||||||
items: SelectionDataItem[],
|
items: SelectionDataItem[],
|
||||||
selectionModel: FilterableDropdownSelectionModel
|
selectionModel: FilterableDropdownSelectionModel
|
||||||
@@ -771,6 +773,7 @@ export class BulkEditorComponent
|
|||||||
this.tagSelectionModel.items = flattenTags(tags.results)
|
this.tagSelectionModel.items = flattenTags(tags.results)
|
||||||
this.tagSelectionModel.toggle(newTag.id)
|
this.tagSelectionModel.toggle(newTag.id)
|
||||||
})
|
})
|
||||||
|
return modal
|
||||||
}
|
}
|
||||||
|
|
||||||
createCorrespondent(name: string) {
|
createCorrespondent(name: string) {
|
||||||
@@ -794,6 +797,7 @@ export class BulkEditorComponent
|
|||||||
this.correspondentSelectionModel.items = correspondents.results
|
this.correspondentSelectionModel.items = correspondents.results
|
||||||
this.correspondentSelectionModel.toggle(newCorrespondent.id)
|
this.correspondentSelectionModel.toggle(newCorrespondent.id)
|
||||||
})
|
})
|
||||||
|
return modal
|
||||||
}
|
}
|
||||||
|
|
||||||
createDocumentType(name: string) {
|
createDocumentType(name: string) {
|
||||||
@@ -815,6 +819,7 @@ export class BulkEditorComponent
|
|||||||
this.documentTypeSelectionModel.items = documentTypes.results
|
this.documentTypeSelectionModel.items = documentTypes.results
|
||||||
this.documentTypeSelectionModel.toggle(newDocumentType.id)
|
this.documentTypeSelectionModel.toggle(newDocumentType.id)
|
||||||
})
|
})
|
||||||
|
return modal
|
||||||
}
|
}
|
||||||
|
|
||||||
createStoragePath(name: string) {
|
createStoragePath(name: string) {
|
||||||
@@ -836,6 +841,7 @@ export class BulkEditorComponent
|
|||||||
this.storagePathsSelectionModel.items = storagePaths.results
|
this.storagePathsSelectionModel.items = storagePaths.results
|
||||||
this.storagePathsSelectionModel.toggle(newStoragePath.id)
|
this.storagePathsSelectionModel.toggle(newStoragePath.id)
|
||||||
})
|
})
|
||||||
|
return modal
|
||||||
}
|
}
|
||||||
|
|
||||||
createCustomField(name: string) {
|
createCustomField(name: string) {
|
||||||
@@ -857,6 +863,7 @@ export class BulkEditorComponent
|
|||||||
this.customFieldsSelectionModel.items = customFields.results
|
this.customFieldsSelectionModel.items = customFields.results
|
||||||
this.customFieldsSelectionModel.toggle(newCustomField.id)
|
this.customFieldsSelectionModel.toggle(newCustomField.id)
|
||||||
})
|
})
|
||||||
|
return modal
|
||||||
}
|
}
|
||||||
|
|
||||||
applyDelete() {
|
applyDelete() {
|
||||||
@@ -872,7 +879,7 @@ export class BulkEditorComponent
|
|||||||
modal.componentInstance.confirmClicked
|
modal.componentInstance.confirmClicked
|
||||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||||
.subscribe(() => {
|
.subscribe(() => {
|
||||||
modal.componentInstance.buttonsEnabled = false
|
modal.componentInstance.buttonsEnabled.set(false)
|
||||||
this.executeDocumentAction(
|
this.executeDocumentAction(
|
||||||
modal,
|
modal,
|
||||||
this.documentService.deleteDocuments(this.getSelectionQuery())
|
this.documentService.deleteDocuments(this.getSelectionQuery())
|
||||||
@@ -920,7 +927,7 @@ export class BulkEditorComponent
|
|||||||
modal.componentInstance.confirmClicked
|
modal.componentInstance.confirmClicked
|
||||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||||
.subscribe(() => {
|
.subscribe(() => {
|
||||||
modal.componentInstance.buttonsEnabled = false
|
modal.componentInstance.buttonsEnabled.set(false)
|
||||||
this.executeDocumentAction(
|
this.executeDocumentAction(
|
||||||
modal,
|
modal,
|
||||||
this.documentService.reprocessDocuments(this.getSelectionQuery())
|
this.documentService.reprocessDocuments(this.getSelectionQuery())
|
||||||
@@ -957,7 +964,7 @@ export class BulkEditorComponent
|
|||||||
rotateDialog.confirmClicked
|
rotateDialog.confirmClicked
|
||||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||||
.subscribe(() => {
|
.subscribe(() => {
|
||||||
rotateDialog.buttonsEnabled = false
|
rotateDialog.buttonsEnabled.set(false)
|
||||||
this.executeDocumentAction(
|
this.executeDocumentAction(
|
||||||
modal,
|
modal,
|
||||||
this.documentService.rotateDocuments(
|
this.documentService.rotateDocuments(
|
||||||
@@ -990,11 +997,11 @@ export class BulkEditorComponent
|
|||||||
if (mergeDialog.archiveFallback()) {
|
if (mergeDialog.archiveFallback()) {
|
||||||
args.archive_fallback = true
|
args.archive_fallback = true
|
||||||
}
|
}
|
||||||
mergeDialog.buttonsEnabled = false
|
mergeDialog.buttonsEnabled.set(false)
|
||||||
this.executeDocumentAction(
|
this.executeDocumentAction(
|
||||||
modal,
|
modal,
|
||||||
this.documentService.mergeDocuments(mergeDialog.documentIDs(), args),
|
this.documentService.mergeDocuments(mergeDialog.documentIDs(), args),
|
||||||
{ deleteOriginals: !!args.delete_originals }
|
{ clearSelection: !!args.delete_originals }
|
||||||
)
|
)
|
||||||
this.toastService.showInfo(
|
this.toastService.showInfo(
|
||||||
$localize`Merged document will be queued for consumption.`
|
$localize`Merged document will be queued for consumption.`
|
||||||
@@ -1002,6 +1009,35 @@ export class BulkEditorComponent
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
mergeSelectedAsVersions() {
|
||||||
|
let modal = this.modalService.open(MergeAsVersionsConfirmDialogComponent, {
|
||||||
|
backdrop: 'static',
|
||||||
|
})
|
||||||
|
const mergeDialog =
|
||||||
|
modal.componentInstance as MergeAsVersionsConfirmDialogComponent
|
||||||
|
const documentIDs = Array.from(this.list.selected)
|
||||||
|
mergeDialog.title = $localize`Merge as versions`
|
||||||
|
mergeDialog.message = $localize`The selected documents will become versions of the root document.`
|
||||||
|
mergeDialog.btnCaption = $localize`Proceed`
|
||||||
|
mergeDialog.documentIDs.set(documentIDs)
|
||||||
|
mergeDialog.rootDocumentID.set(documentIDs[0])
|
||||||
|
mergeDialog.confirmClicked
|
||||||
|
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||||
|
.subscribe(() => {
|
||||||
|
this.executeDocumentAction(
|
||||||
|
modal,
|
||||||
|
this.documentService.mergeDocumentsAsVersions(
|
||||||
|
mergeDialog.documentIDs(),
|
||||||
|
mergeDialog.rootDocumentID()
|
||||||
|
),
|
||||||
|
{
|
||||||
|
clearSelection: true,
|
||||||
|
successMessage: $localize`Documents merged as versions.`,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
public setCustomFieldValues(changedCustomFields: ChangedItems) {
|
public setCustomFieldValues(changedCustomFields: ChangedItems) {
|
||||||
const modal = this.modalService.open(CustomFieldsBulkEditDialogComponent, {
|
const modal = this.modalService.open(CustomFieldsBulkEditDialogComponent, {
|
||||||
backdrop: 'static',
|
backdrop: 'static',
|
||||||
@@ -1063,14 +1099,14 @@ export class BulkEditorComponent
|
|||||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||||
.subscribe(() => {
|
.subscribe(() => {
|
||||||
dialog.loading.set(true)
|
dialog.loading.set(true)
|
||||||
dialog.buttonsEnabled = false
|
dialog.buttonsEnabled.set(false)
|
||||||
this.shareLinkBundleService
|
this.shareLinkBundleService
|
||||||
.createBundle(dialog.payload)
|
.createBundle(dialog.payload)
|
||||||
.pipe(first())
|
.pipe(first())
|
||||||
.subscribe({
|
.subscribe({
|
||||||
next: (result) => {
|
next: (result) => {
|
||||||
dialog.loading.set(false)
|
dialog.loading.set(false)
|
||||||
dialog.buttonsEnabled = false
|
dialog.buttonsEnabled.set(false)
|
||||||
dialog.createdBundle = result
|
dialog.createdBundle = result
|
||||||
dialog.copied.set(false)
|
dialog.copied.set(false)
|
||||||
dialog.payload = null
|
dialog.payload = null
|
||||||
@@ -1084,7 +1120,7 @@ export class BulkEditorComponent
|
|||||||
},
|
},
|
||||||
error: (error) => {
|
error: (error) => {
|
||||||
dialog.loading.set(false)
|
dialog.loading.set(false)
|
||||||
dialog.buttonsEnabled = true
|
dialog.buttonsEnabled.set(true)
|
||||||
this.toastService.showError(
|
this.toastService.showError(
|
||||||
$localize`Share link bundle creation is not available yet.`,
|
$localize`Share link bundle creation is not available yet.`,
|
||||||
error
|
error
|
||||||
|
|||||||
@@ -97,7 +97,9 @@
|
|||||||
<div class="dropdown-menu shadow dropdown-menu-right" ngbDropdownMenu>
|
<div class="dropdown-menu shadow dropdown-menu-right" ngbDropdownMenu>
|
||||||
@if (!list.activeSavedViewId) {
|
@if (!list.activeSavedViewId) {
|
||||||
@for (view of savedViewService.allViews; track view) {
|
@for (view of savedViewService.allViews; track view) {
|
||||||
<button ngbDropdownItem (click)="loadViewConfig(view.id)">{{view.name}}</button>
|
<button ngbDropdownItem (click)="loadViewConfig(view.id)">
|
||||||
|
<i-bs class="me-2" [name]="view.icon || 'funnel'"></i-bs>{{view.name}}
|
||||||
|
</button>
|
||||||
}
|
}
|
||||||
@if (savedViewService.allViews.length > 0) {
|
@if (savedViewService.allViews.length > 0) {
|
||||||
<div class="dropdown-divider"></div>
|
<div class="dropdown-divider"></div>
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user