Compare commits

...

46 Commits

Author SHA1 Message Date
shamoon
2e593a0022
Fix: fix potential race condition when creating new custom fields on doc details (#9542) 2025-04-02 10:00:25 -07:00
dependabot[bot]
3526a4cf23
Chore(deps): Bump the frontend-angular-dependencies group (#9536)
Bumps the frontend-angular-dependencies group in /src-ui with 20 updates:

| Package | From | To |
| --- | --- | --- |
| [@angular/cdk](https://github.com/angular/components) | `19.2.2` | `19.2.7` |
| [@angular/common](https://github.com/angular/angular/tree/HEAD/packages/common) | `19.2.1` | `19.2.4` |
| [@angular/compiler](https://github.com/angular/angular/tree/HEAD/packages/compiler) | `19.2.1` | `19.2.4` |
| [@angular/core](https://github.com/angular/angular/tree/HEAD/packages/core) | `19.2.1` | `19.2.4` |
| [@angular/forms](https://github.com/angular/angular/tree/HEAD/packages/forms) | `19.2.1` | `19.2.4` |
| [@angular/localize](https://github.com/angular/angular) | `19.2.1` | `19.2.4` |
| [@angular/platform-browser](https://github.com/angular/angular/tree/HEAD/packages/platform-browser) | `19.2.1` | `19.2.4` |
| [@angular/platform-browser-dynamic](https://github.com/angular/angular/tree/HEAD/packages/platform-browser-dynamic) | `19.2.1` | `19.2.4` |
| [@angular/router](https://github.com/angular/angular/tree/HEAD/packages/router) | `19.2.1` | `19.2.4` |
| [@ng-select/ng-select](https://github.com/ng-select/ng-select) | `14.2.3` | `14.2.6` |
| [@angular-devkit/build-angular](https://github.com/angular/angular-cli) | `19.2.1` | `19.2.5` |
| [@angular-devkit/core](https://github.com/angular/angular-cli) | `19.2.1` | `19.2.5` |
| [@angular-devkit/schematics](https://github.com/angular/angular-cli) | `19.2.1` | `19.2.5` |
| [@angular-eslint/builder](https://github.com/angular-eslint/angular-eslint/tree/HEAD/packages/builder) | `19.2.1` | `19.3.0` |
| [@angular-eslint/eslint-plugin](https://github.com/angular-eslint/angular-eslint/tree/HEAD/packages/eslint-plugin) | `19.2.1` | `19.3.0` |
| [@angular-eslint/eslint-plugin-template](https://github.com/angular-eslint/angular-eslint/tree/HEAD/packages/eslint-plugin-template) | `19.2.1` | `19.3.0` |
| [@angular-eslint/schematics](https://github.com/angular-eslint/angular-eslint/tree/HEAD/packages/schematics) | `19.2.1` | `19.3.0` |
| [@angular-eslint/template-parser](https://github.com/angular-eslint/angular-eslint/tree/HEAD/packages/template-parser) | `19.2.1` | `19.3.0` |
| [@angular/cli](https://github.com/angular/angular-cli) | `19.2.1` | `19.2.5` |
| [@angular/compiler-cli](https://github.com/angular/angular/tree/HEAD/packages/compiler-cli) | `19.2.1` | `19.2.4` |


Updates `@angular/cdk` from 19.2.2 to 19.2.7
- [Release notes](https://github.com/angular/components/releases)
- [Changelog](https://github.com/angular/components/blob/main/CHANGELOG.md)
- [Commits](https://github.com/angular/components/compare/19.2.2...19.2.7)

Updates `@angular/common` from 19.2.1 to 19.2.4
- [Release notes](https://github.com/angular/angular/releases)
- [Changelog](https://github.com/angular/angular/blob/main/CHANGELOG.md)
- [Commits](https://github.com/angular/angular/commits/19.2.4/packages/common)

Updates `@angular/compiler` from 19.2.1 to 19.2.4
- [Release notes](https://github.com/angular/angular/releases)
- [Changelog](https://github.com/angular/angular/blob/main/CHANGELOG.md)
- [Commits](https://github.com/angular/angular/commits/19.2.4/packages/compiler)

Updates `@angular/core` from 19.2.1 to 19.2.4
- [Release notes](https://github.com/angular/angular/releases)
- [Changelog](https://github.com/angular/angular/blob/main/CHANGELOG.md)
- [Commits](https://github.com/angular/angular/commits/19.2.4/packages/core)

Updates `@angular/forms` from 19.2.1 to 19.2.4
- [Release notes](https://github.com/angular/angular/releases)
- [Changelog](https://github.com/angular/angular/blob/main/CHANGELOG.md)
- [Commits](https://github.com/angular/angular/commits/19.2.4/packages/forms)

Updates `@angular/localize` from 19.2.1 to 19.2.4
- [Release notes](https://github.com/angular/angular/releases)
- [Changelog](https://github.com/angular/angular/blob/main/CHANGELOG.md)
- [Commits](https://github.com/angular/angular/compare/19.2.1...19.2.4)

Updates `@angular/platform-browser` from 19.2.1 to 19.2.4
- [Release notes](https://github.com/angular/angular/releases)
- [Changelog](https://github.com/angular/angular/blob/main/CHANGELOG.md)
- [Commits](https://github.com/angular/angular/commits/19.2.4/packages/platform-browser)

Updates `@angular/platform-browser-dynamic` from 19.2.1 to 19.2.4
- [Release notes](https://github.com/angular/angular/releases)
- [Changelog](https://github.com/angular/angular/blob/main/CHANGELOG.md)
- [Commits](https://github.com/angular/angular/commits/19.2.4/packages/platform-browser-dynamic)

Updates `@angular/router` from 19.2.1 to 19.2.4
- [Release notes](https://github.com/angular/angular/releases)
- [Changelog](https://github.com/angular/angular/blob/main/CHANGELOG.md)
- [Commits](https://github.com/angular/angular/commits/19.2.4/packages/router)

Updates `@ng-select/ng-select` from 14.2.3 to 14.2.6
- [Release notes](https://github.com/ng-select/ng-select/releases)
- [Changelog](https://github.com/ng-select/ng-select/blob/master/CHANGELOG.md)
- [Commits](https://github.com/ng-select/ng-select/compare/v14.2.3...v14.2.6)

Updates `@angular-devkit/build-angular` from 19.2.1 to 19.2.5
- [Release notes](https://github.com/angular/angular-cli/releases)
- [Changelog](https://github.com/angular/angular-cli/blob/main/CHANGELOG.md)
- [Commits](https://github.com/angular/angular-cli/compare/19.2.1...19.2.5)

Updates `@angular-devkit/core` from 19.2.1 to 19.2.5
- [Release notes](https://github.com/angular/angular-cli/releases)
- [Changelog](https://github.com/angular/angular-cli/blob/main/CHANGELOG.md)
- [Commits](https://github.com/angular/angular-cli/compare/19.2.1...19.2.5)

Updates `@angular-devkit/schematics` from 19.2.1 to 19.2.5
- [Release notes](https://github.com/angular/angular-cli/releases)
- [Changelog](https://github.com/angular/angular-cli/blob/main/CHANGELOG.md)
- [Commits](https://github.com/angular/angular-cli/compare/19.2.1...19.2.5)

Updates `@angular-eslint/builder` from 19.2.1 to 19.3.0
- [Release notes](https://github.com/angular-eslint/angular-eslint/releases)
- [Changelog](https://github.com/angular-eslint/angular-eslint/blob/main/packages/builder/CHANGELOG.md)
- [Commits](https://github.com/angular-eslint/angular-eslint/commits/v19.3.0/packages/builder)

Updates `@angular-eslint/eslint-plugin` from 19.2.1 to 19.3.0
- [Release notes](https://github.com/angular-eslint/angular-eslint/releases)
- [Changelog](https://github.com/angular-eslint/angular-eslint/blob/main/packages/eslint-plugin/CHANGELOG.md)
- [Commits](https://github.com/angular-eslint/angular-eslint/commits/v19.3.0/packages/eslint-plugin)

Updates `@angular-eslint/eslint-plugin-template` from 19.2.1 to 19.3.0
- [Release notes](https://github.com/angular-eslint/angular-eslint/releases)
- [Changelog](https://github.com/angular-eslint/angular-eslint/blob/main/packages/eslint-plugin-template/CHANGELOG.md)
- [Commits](https://github.com/angular-eslint/angular-eslint/commits/v19.3.0/packages/eslint-plugin-template)

Updates `@angular-eslint/schematics` from 19.2.1 to 19.3.0
- [Release notes](https://github.com/angular-eslint/angular-eslint/releases)
- [Changelog](https://github.com/angular-eslint/angular-eslint/blob/main/packages/schematics/CHANGELOG.md)
- [Commits](https://github.com/angular-eslint/angular-eslint/commits/v19.3.0/packages/schematics)

Updates `@angular-eslint/template-parser` from 19.2.1 to 19.3.0
- [Release notes](https://github.com/angular-eslint/angular-eslint/releases)
- [Changelog](https://github.com/angular-eslint/angular-eslint/blob/main/packages/template-parser/CHANGELOG.md)
- [Commits](https://github.com/angular-eslint/angular-eslint/commits/v19.3.0/packages/template-parser)

Updates `@angular/cli` from 19.2.1 to 19.2.5
- [Release notes](https://github.com/angular/angular-cli/releases)
- [Changelog](https://github.com/angular/angular-cli/blob/main/CHANGELOG.md)
- [Commits](https://github.com/angular/angular-cli/compare/19.2.1...19.2.5)

Updates `@angular/compiler-cli` from 19.2.1 to 19.2.4
- [Release notes](https://github.com/angular/angular/releases)
- [Changelog](https://github.com/angular/angular/blob/main/CHANGELOG.md)
- [Commits](https://github.com/angular/angular/commits/19.2.4/packages/compiler-cli)

---
updated-dependencies:
- dependency-name: "@angular/cdk"
  dependency-version: 19.2.7
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: frontend-angular-dependencies
- dependency-name: "@angular/common"
  dependency-version: 19.2.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: frontend-angular-dependencies
- dependency-name: "@angular/compiler"
  dependency-version: 19.2.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: frontend-angular-dependencies
- dependency-name: "@angular/core"
  dependency-version: 19.2.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: frontend-angular-dependencies
- dependency-name: "@angular/forms"
  dependency-version: 19.2.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: frontend-angular-dependencies
- dependency-name: "@angular/localize"
  dependency-version: 19.2.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: frontend-angular-dependencies
- dependency-name: "@angular/platform-browser"
  dependency-version: 19.2.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: frontend-angular-dependencies
- dependency-name: "@angular/platform-browser-dynamic"
  dependency-version: 19.2.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: frontend-angular-dependencies
- dependency-name: "@angular/router"
  dependency-version: 19.2.4
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: frontend-angular-dependencies
- dependency-name: "@ng-select/ng-select"
  dependency-version: 14.2.6
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: frontend-angular-dependencies
- dependency-name: "@angular-devkit/build-angular"
  dependency-version: 19.2.5
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: frontend-angular-dependencies
- dependency-name: "@angular-devkit/core"
  dependency-version: 19.2.5
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: frontend-angular-dependencies
- dependency-name: "@angular-devkit/schematics"
  dependency-version: 19.2.5
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: frontend-angular-dependencies
- dependency-name: "@angular-eslint/builder"
  dependency-version: 19.3.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: frontend-angular-dependencies
- dependency-name: "@angular-eslint/eslint-plugin"
  dependency-version: 19.3.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: frontend-angular-dependencies
- dependency-name: "@angular-eslint/eslint-plugin-template"
  dependency-version: 19.3.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: frontend-angular-dependencies
- dependency-name: "@angular-eslint/schematics"
  dependency-version: 19.3.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: frontend-angular-dependencies
- dependency-name: "@angular-eslint/template-parser"
  dependency-version: 19.3.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: frontend-angular-dependencies
- dependency-name: "@angular/cli"
  dependency-version: 19.2.5
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: frontend-angular-dependencies
- dependency-name: "@angular/compiler-cli"
  dependency-version: 19.2.4
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: frontend-angular-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-04-01 20:29:20 -07:00
dependabot[bot]
f4791cac2d
Chore(deps-dev): Bump the frontend-eslint-dependencies group (#9538)
Bumps the frontend-eslint-dependencies group in /src-ui with 4 updates: [@typescript-eslint/eslint-plugin](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/eslint-plugin), [@typescript-eslint/parser](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/parser), [@typescript-eslint/utils](https://github.com/typescript-eslint/typescript-eslint/tree/HEAD/packages/utils) and [eslint](https://github.com/eslint/eslint).


Updates `@typescript-eslint/eslint-plugin` from 8.26.1 to 8.29.0
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/eslint-plugin/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.29.0/packages/eslint-plugin)

Updates `@typescript-eslint/parser` from 8.26.1 to 8.29.0
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/parser/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.29.0/packages/parser)

Updates `@typescript-eslint/utils` from 8.26.1 to 8.29.0
- [Release notes](https://github.com/typescript-eslint/typescript-eslint/releases)
- [Changelog](https://github.com/typescript-eslint/typescript-eslint/blob/main/packages/utils/CHANGELOG.md)
- [Commits](https://github.com/typescript-eslint/typescript-eslint/commits/v8.29.0/packages/utils)

Updates `eslint` from 9.22.0 to 9.23.0
- [Release notes](https://github.com/eslint/eslint/releases)
- [Changelog](https://github.com/eslint/eslint/blob/main/CHANGELOG.md)
- [Commits](https://github.com/eslint/eslint/compare/v9.22.0...v9.23.0)

---
updated-dependencies:
- dependency-name: "@typescript-eslint/eslint-plugin"
  dependency-version: 8.29.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: frontend-eslint-dependencies
- dependency-name: "@typescript-eslint/parser"
  dependency-version: 8.29.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: frontend-eslint-dependencies
- dependency-name: "@typescript-eslint/utils"
  dependency-version: 8.29.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: frontend-eslint-dependencies
- dependency-name: eslint
  dependency-version: 9.23.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
  dependency-group: frontend-eslint-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-04-01 21:38:33 +00:00
dependabot[bot]
fdafd4eefb
Chore(deps-dev): Bump @types/node from 22.13.9 to 22.13.17 in /src-ui (#9539)
Bumps [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) from 22.13.9 to 22.13.17.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

---
updated-dependencies:
- dependency-name: "@types/node"
  dependency-version: 22.13.17
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-04-01 21:27:52 +00:00
dependabot[bot]
87a8847a8d
Chore(deps-dev): Bump jest-preset-angular (#9537)
Bumps the frontend-jest-dependencies group in /src-ui with 1 update: [jest-preset-angular](https://github.com/thymikee/jest-preset-angular).


Updates `jest-preset-angular` from 14.5.3 to 14.5.4
- [Release notes](https://github.com/thymikee/jest-preset-angular/releases)
- [Changelog](https://github.com/thymikee/jest-preset-angular/blob/main/CHANGELOG.md)
- [Commits](https://github.com/thymikee/jest-preset-angular/compare/v14.5.3...v14.5.4)

---
updated-dependencies:
- dependency-name: jest-preset-angular
  dependency-version: 14.5.4
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: frontend-jest-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-04-01 21:17:31 +00:00
dependabot[bot]
7c31c79bbc
Chore(deps-dev): Bump @playwright/test from 1.50.1 to 1.51.1 in /src-ui (#9540)
Bumps [@playwright/test](https://github.com/microsoft/playwright) from 1.50.1 to 1.51.1.
- [Release notes](https://github.com/microsoft/playwright/releases)
- [Commits](https://github.com/microsoft/playwright/compare/v1.50.1...v1.51.1)

---
updated-dependencies:
- dependency-name: "@playwright/test"
  dependency-version: 1.51.1
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-04-01 14:07:29 -07:00
shamoon
bf2a9b02c6
Fix: correct tag removal button type, prevent dropdown open 2025-04-01 08:05:46 -07:00
shamoon
348858780c
Merge branch 'beta' into dev 2025-03-31 19:35:30 -07:00
shamoon
eb481ac1c0
Fix: fix doc link input (#9533) 2025-03-31 19:35:05 -07:00
shamoon
9a2d7a64ac
Merge branch 'beta' into dev 2025-03-29 10:13:41 -07:00
shamoon
32a7f9cd5a
Enhancement: allow webUI first account signup (#9500) 2025-03-29 17:12:34 +00:00
shamoon
92431b2f4b
Merge branch 'beta' into dev 2025-03-29 10:06:01 -07:00
shamoon
b4b2a92225
Fix: fix unshared items display 2025-03-28 05:47:10 -07:00
shamoon
fd45e81a83
Fix: fix cf dropdown placement on mobile (#9508) 2025-03-27 14:09:51 -07:00
Timon
97d59dce9c
Documentation: correct static url description (#9506) 2025-03-27 13:24:41 -07:00
dependabot[bot]
f3479d982c
Chore(deps): Bump django from 5.1.6 to 5.1.7 in the django group (#9486)
* Chore(deps): Bump django from 5.1.6 to 5.1.7 in the django group

Bumps the django group with 1 update: [django](https://github.com/django/django).

Updates `django` from 5.1.6 to 5.1.7
- [Commits](https://github.com/django/django/compare/5.1.6...5.1.7)

---
updated-dependencies:
- dependency-name: django
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: django
...

Signed-off-by: dependabot[bot] <support@github.com>

* Fixes the missing extras

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Trenton H <797416+stumpylog@users.noreply.github.com>
2025-03-26 21:44:49 +00:00
dependabot[bot]
b3ba673d9a
docker(deps): Bump astral-sh/uv from 0.6.5-python3.12-bookworm-slim to 0.6.9-python3.12-bookworm-slim (#9488)
Bumps [astral-sh/uv](https://github.com/astral-sh/uv) from 0.6.5-python3.12-bookworm-slim to 0.6.9-python3.12-bookworm-slim.
- [Release notes](https://github.com/astral-sh/uv/releases)
- [Changelog](https://github.com/astral-sh/uv/blob/main/CHANGELOG.md)
- [Commits](https://github.com/astral-sh/uv/compare/0.6.5...0.6.9)

---
updated-dependencies:
- dependency-name: astral-sh/uv
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2025-03-26 21:31:45 +00:00
Trenton H
68b7427640 Merge remote-tracking branch 'origin/dev' into beta 2025-03-26 14:19:31 -07:00
Trenton H
9c68100dc0
Fix: Make management commands aware of the container environment (#9499) 2025-03-26 14:17:10 -07:00
Trenton H
6e694ad9ff
Switch to using uvloop and upgrade granian for some ASGI fixes (#9494) 2025-03-25 21:41:29 -07:00
shamoon
5db511afdf
Fix: revert removed x-frame-options header in non-debug 2025-03-24 07:28:27 -07:00
shamoon
a8de26f88a
Fix: only overwrite existing cf values in workflow if set (#9459) 2025-03-23 17:25:15 -07:00
shamoon
7a07f1e81d
Remove unnecessary check 2025-03-21 10:28:20 -07:00
shamoon
92524ae97a
Chore: remove a couple of console logs 2025-03-21 10:24:59 -07:00
shamoon
1c89f6da24
More narrow device tweaks 2025-03-21 10:24:59 -07:00
shamoon
d1a3e3b859
Fix: top nav layout with custom title on very narrow screens 2025-03-21 10:24:58 -07:00
shamoon
79ae594d54
Fix: fix saving docs with notes 2025-03-21 10:24:58 -07:00
shamoon
f753f6dc46
Merge branch 'dev' into beta 2025-03-21 09:52:50 -07:00
github-actions[bot]
97fe5c4176
New Crowdin translations by GitHub Action (#9438)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2025-03-21 09:52:32 -07:00
shamoon
1f5086164b
Chore: remove a couple of console logs 2025-03-21 09:52:06 -07:00
shamoon
e60bd3a132
Fix: fix auto-close when doc update no longer has permissions (#9453) 2025-03-21 09:50:04 -07:00
shamoon
b4047e73bb
More narrow device tweaks 2025-03-21 00:52:28 -07:00
shamoon
4263d2196c
Fix: top nav layout with custom title on very narrow screens 2025-03-21 00:44:37 -07:00
shamoon
ac780134fb
Merge branch 'dev' into beta 2025-03-18 19:18:39 -07:00
github-actions[bot]
5d6cfa7349
New Crowdin translations by GitHub Action (#9430)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2025-03-18 19:17:49 -07:00
shamoon
3105317137
Fix: close email dialog after email send 2025-03-18 19:17:49 -07:00
shamoon
1d9482acc3
Merge branch 'dev' into beta 2025-03-17 23:58:23 -07:00
github-actions[bot]
1456169d7f
New Crowdin translations by GitHub Action (#9408)
Co-authored-by: Crowdin Bot <support+bot@crowdin.com>
2025-03-17 23:36:52 -07:00
shamoon
22a6fe5e10
Enhancement: support more 'not assigned' filtering, refactor (#9429) 2025-03-17 23:35:03 -07:00
shamoon
caa3c13edd
Fix: fix saving docs with notes 2025-03-16 08:31:27 -07:00
shamoon
24e863b298
Coverage 2025-03-14 13:23:42 -07:00
shamoon
0c9d615f56
Merge branch 'main' into beta 2025-03-14 13:07:03 -07:00
shamoon
90561857e8 Documentation: add note about WAL mode with SQLite 2025-02-24 14:08:49 -08:00
shamoon
fc68f55d1a Documentation: correct modify_tags param requirement 2025-02-14 08:20:43 -08:00
shamoon
6a8ec182fa Documentation: clarify encryption docs 2025-02-08 08:07:04 -08:00
Stéphane Brunner
69541546ea
Fix URL to django-rest-framework (#8998) 2025-02-02 08:00:02 -08:00
108 changed files with 8412 additions and 7975 deletions

View File

@ -32,7 +32,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.6.5-python3.12-bookworm-slim AS s6-overlay-base FROM ghcr.io/astral-sh/uv:0.6.9-python3.12-bookworm-slim AS s6-overlay-base
WORKDIR /usr/src/s6 WORKDIR /usr/src/s6
@ -239,6 +239,7 @@ COPY --from=compile-frontend --chown=1000:1000 /src/src/documents/static/fronten
# add users, setup scripts # add users, setup scripts
# Mount the compiled frontend to expected location # Mount the compiled frontend to expected location
RUN set -eux \ RUN set -eux \
&& sed -i '1s|^#!/usr/bin/env python3|#!/command/with-contenv python3|' manage.py \
&& echo "Setting up user/group" \ && echo "Setting up user/group" \
&& addgroup --gid 1000 paperless \ && addgroup --gid 1000 paperless \
&& useradd --uid 1000 --gid paperless --home-dir /usr/src/paperless paperless \ && useradd --uid 1000 --gid paperless --home-dir /usr/src/paperless paperless \

View File

@ -24,8 +24,8 @@
# - Copy this file as 'docker-compose.yml' and the files 'docker-compose.env' # - Copy this file as 'docker-compose.yml' and the files 'docker-compose.env'
# and '.env' into a folder. # and '.env' into a folder.
# - Run 'docker compose pull'. # - Run 'docker compose pull'.
# - Run 'docker compose run --rm webserver createsuperuser' to create a user.
# - Run 'docker compose up -d'. # - Run 'docker compose up -d'.
# #
# For more extensive installation and update instructions, refer to the # For more extensive installation and update instructions, refer to the
# documentation. # documentation.

View File

@ -20,7 +20,6 @@
# - Copy this file as 'docker-compose.yml' and the files 'docker-compose.env' # - Copy this file as 'docker-compose.yml' and the files 'docker-compose.env'
# and '.env' into a folder. # and '.env' into a folder.
# - Run 'docker compose pull'. # - Run 'docker compose pull'.
# - Run 'docker compose run --rm webserver createsuperuser' to create a user.
# - Run 'docker compose up -d'. # - Run 'docker compose up -d'.
# #
# For more extensive installation and update instructions, refer to the # For more extensive installation and update instructions, refer to the

View File

@ -22,10 +22,6 @@
# - Upload 'docker-compose.env' by clicking on 'Load variables from .env file' # - Upload 'docker-compose.env' by clicking on 'Load variables from .env file'
# - Modify the environment variables as needed # - Modify the environment variables as needed
# - Click 'Deploy the stack' and wait for it to be deployed # - Click 'Deploy the stack' and wait for it to be deployed
# - Open the list of containers, select paperless_webserver_1
# - Click 'Console' and then 'Connect' to open the command line inside the container
# - Run 'python3 manage.py createsuperuser' to create a user
# - Exit the console
# #
# For more extensive installation and update instructions, refer to the # For more extensive installation and update instructions, refer to the
# documentation. # documentation.

View File

@ -24,7 +24,6 @@
# - Copy this file as 'docker-compose.yml' and the files 'docker-compose.env' # - Copy this file as 'docker-compose.yml' and the files 'docker-compose.env'
# and '.env' into a folder. # and '.env' into a folder.
# - Run 'docker compose pull'. # - Run 'docker compose pull'.
# - Run 'docker compose run --rm webserver createsuperuser' to create a user.
# - Run 'docker compose up -d'. # - Run 'docker compose up -d'.
# #
# For more extensive installation and update instructions, refer to the # For more extensive installation and update instructions, refer to the

View File

@ -20,7 +20,6 @@
# - Copy this file as 'docker-compose.yml' and the files 'docker-compose.env' # - Copy this file as 'docker-compose.yml' and the files 'docker-compose.env'
# and '.env' into a folder. # and '.env' into a folder.
# - Run 'docker compose pull'. # - Run 'docker compose pull'.
# - Run 'docker compose run --rm webserver createsuperuser' to create a user.
# - Run 'docker compose up -d'. # - Run 'docker compose up -d'.
# #
# For more extensive installation and update instructions, refer to the # For more extensive installation and update instructions, refer to the

View File

@ -24,7 +24,6 @@
# - Copy this file as 'docker-compose.yml' and the files 'docker-compose.env' # - Copy this file as 'docker-compose.yml' and the files 'docker-compose.env'
# and '.env' into a folder. # and '.env' into a folder.
# - Run 'docker compose pull'. # - Run 'docker compose pull'.
# - Run 'docker compose run --rm webserver createsuperuser' to create a user.
# - Run 'docker compose up -d'. # - Run 'docker compose up -d'.
# #
# For more extensive installation and update instructions, refer to the # For more extensive installation and update instructions, refer to the

View File

@ -17,7 +17,6 @@
# - Copy this file as 'docker-compose.yml' and the files 'docker-compose.env' # - Copy this file as 'docker-compose.yml' and the files 'docker-compose.env'
# and '.env' into a folder. # and '.env' into a folder.
# - Run 'docker compose pull'. # - Run 'docker compose pull'.
# - Run 'docker compose run --rm webserver createsuperuser' to create a user.
# - Run 'docker compose up -d'. # - Run 'docker compose up -d'.
# #
# For more extensive installation and update instructions, refer to the # For more extensive installation and update instructions, refer to the

View File

@ -18,9 +18,10 @@ for command in decrypt_documents \
document_fuzzy_match \ document_fuzzy_match \
manage_superuser \ manage_superuser \
convert_mariadb_uuid \ convert_mariadb_uuid \
prune_audit_logs; prune_audit_logs \
createsuperuser;
do do
echo "installing $command..." echo "installing $command..."
sed "s/management_command/$command/g" management_script.sh >"$PWD/rootfs/usr/local/bin/$command" sed "s/management_command/$command/g" management_script.sh >"$PWD/rootfs/usr/local/bin/$command"
chmod +x "$PWD/rootfs/usr/local/bin/$command" chmod u=rwx,g=rwx,o=rx "$PWD/rootfs/usr/local/bin/$command"
done done

View File

@ -14,7 +14,7 @@ if [[ -n "${PAPERLESS_FORCE_SCRIPT_NAME}" ]]; then
fi fi
if [[ -n "${USER_IS_NON_ROOT}" ]]; then if [[ -n "${USER_IS_NON_ROOT}" ]]; then
exec granian --interface asginl --ws "paperless.asgi:application" exec granian --interface asginl --ws --loop uvloop "paperless.asgi:application"
else else
exec s6-setuidgid paperless granian --interface asginl --ws "paperless.asgi:application" exec s6-setuidgid paperless granian --interface asginl --ws --loop uvloop "paperless.asgi:application"
fi fi

View File

@ -0,0 +1,14 @@
#!/command/with-contenv /usr/bin/bash
# shellcheck shell=bash
set -e
cd "${PAPERLESS_SRC_DIR}"
if [[ $(id -u) == 0 ]]; then
s6-setuidgid paperless python3 manage.py createsuperuser "$@"
elif [[ $(id -un) == "paperless" ]]; then
python3 manage.py createsuperuser "$@"
else
echo "Unknown user."
fi

View File

@ -565,19 +565,15 @@ document.
### Managing encryption {#encryption} ### Managing encryption {#encryption}
Documents can be stored in Paperless using GnuPG encryption.
!!! warning !!! warning
Encryption is deprecated since [paperless-ng 0.9](changelog.md#paperless-ng-090) and doesn't really Encryption was removed in [paperless-ng 0.9](changelog.md#paperless-ng-090)
provide any additional security, since you have to store the passphrase because it did not really provide any additional security, the passphrase
in a configuration file on the same system as the encrypted documents was stored in a configuration file on the same system as the documents.
for paperless to work. Furthermore, the entire text content of the Furthermore, the entire text content of the documents is stored plain in
documents is stored plain in the database, even if your documents are the database, even if your documents are encrypted. Filenames are not
encrypted. Filenames are not encrypted as well. encrypted as well. Finally, the web server provides transparent access to
your encrypted documents.
Also, the web server provides transparent access to your encrypted
documents.
Consider running paperless on an encrypted filesystem instead, which Consider running paperless on an encrypted filesystem instead, which
will then at least provide security against physical hardware theft. will then at least provide security against physical hardware theft.
@ -633,3 +629,11 @@ entries created prior to this are not removed. This command allows you to prune
```shell ```shell
prune_audit_logs prune_audit_logs
``` ```
### Create superuser {#create-superuser}
If you need to create a superuser, use the following command:
```shell
createsuperuser
```

View File

@ -270,7 +270,7 @@ The following methods are supported:
- `remove_tag` - `remove_tag`
- Requires `parameters`: `{ "tag": TAG_ID }` - Requires `parameters`: `{ "tag": TAG_ID }`
- `modify_tags` - `modify_tags`
- Requires `parameters`: `{ "add_tags": [LIST_OF_TAG_IDS] }` and / or `{ "remove_tags": [LIST_OF_TAG_IDS] }` - Requires `parameters`: `{ "add_tags": [LIST_OF_TAG_IDS] }` and `{ "remove_tags": [LIST_OF_TAG_IDS] }`
- `delete` - `delete`
- No `parameters` required - No `parameters` required
- `reprocess` - `reprocess`

View File

@ -404,7 +404,7 @@ set this value to /paperless. No trailing slash!
#### [`PAPERLESS_STATIC_URL=<path>`](#PAPERLESS_STATIC_URL) {#PAPERLESS_STATIC_URL} #### [`PAPERLESS_STATIC_URL=<path>`](#PAPERLESS_STATIC_URL) {#PAPERLESS_STATIC_URL}
: Override the STATIC_URL here. Unless you're hosting Paperless off a : Override the STATIC_URL here. Unless you're hosting Paperless off a
subdomain like /paperless/, you probably don't need to change this. specific path like /paperless/, you probably don't need to change this.
If you do change it, be sure to include the trailing slash. If you do change it, be sure to include the trailing slash.
Defaults to "/static/". Defaults to "/static/".

View File

@ -84,7 +84,7 @@ first-time setup.
$ uv run pre-commit install $ uv run pre-commit install
``` ```
6. Apply migrations and create a superuser for your development instance: 6. Apply migrations and create a superuser (also can be done via the web UI) for your development instance:
```bash ```bash
# src/ # src/

View File

@ -131,26 +131,11 @@ account. The script essentially automatically performs the steps described in [D
by default but you can change the image to pull from Docker Hub by changing the `image` by default but you can change the image to pull from Docker Hub by changing the `image`
line to `image: paperlessngx/paperless-ngx:latest`. line to `image: paperlessngx/paperless-ngx:latest`.
6. To be able to login, you will need a "superuser". To create it, 6. Run `docker compose up -d`. This will create and start the necessary containers.
execute the following command:
```shell-session 7. Congratulations! Your Paperless-ngx instance should now be accessible at `http://127.0.0.1:8000`
docker compose run --rm webserver createsuperuser (or similar, depending on your configuration). When you first access the web interface, you will be
``` prompted to create a superuser account.
or using docker exec from within the container:
```shell-session
python3 manage.py createsuperuser
```
This will guide you through the superuser setup.
7. Run `docker compose up -d`. This will create and start the necessary containers.
8. Congratulations! Your Paperless-ngx instance should now be accessible at `http://127.0.0.1:8000`
(or similar, depending on your configuration). Use the superuser credentials you have
created in the previous step to login.
### Build the Docker image yourself {#docker_build} ### Build the Docker image yourself {#docker_build}
@ -386,16 +371,15 @@ are released, dependency support is confirmed, etc.
dependencies for Postgres or Mariadb. You can select those extras with `--extra <EXTRA>` dependencies for Postgres or Mariadb. You can select those extras with `--extra <EXTRA>`
or all with `--all-extras` or all with `--all-extras`
9. Go to `/opt/paperless/src`, and execute the following commands: 9. Go to `/opt/paperless/src`, and execute the following command:
```bash ```bash
# This creates the database schema. # This creates the database schema.
sudo -Hu paperless python3 manage.py migrate sudo -Hu paperless python3 manage.py migrate
# This creates your first paperless user
sudo -Hu paperless python3 manage.py createsuperuser
``` ```
When you first access the web interface you will be prompted to create a superuser account.
10. Optional: Test that paperless is working by executing 10. Optional: Test that paperless is working by executing
```bash ```bash
@ -708,7 +692,8 @@ Paperless runs on Raspberry Pi. However, some things are rather slow on
the Pi and configuring some options in paperless can help improve the Pi and configuring some options in paperless can help improve
performance immensely: performance immensely:
- Stick with SQLite to save some resources. - Stick with SQLite to save some resources. See [troubleshooting](troubleshooting.md#log-reports-creating-paperlesstask-failed)
if you encounter issues with SQLite locking.
- If you do not need the filesystem-based consumer, consider disabling it - If you do not need the filesystem-based consumer, consider disabling it
entirely by setting [`PAPERLESS_CONSUMER_DISABLE`](configuration.md#PAPERLESS_CONSUMER_DISABLE) to `true`. entirely by setting [`PAPERLESS_CONSUMER_DISABLE`](configuration.md#PAPERLESS_CONSUMER_DISABLE) to `true`.
- Consider setting [`PAPERLESS_OCR_PAGES`](configuration.md#PAPERLESS_OCR_PAGES) to 1, so that paperless will - Consider setting [`PAPERLESS_OCR_PAGES`](configuration.md#PAPERLESS_OCR_PAGES) to 1, so that paperless will

View File

@ -292,7 +292,9 @@ many workers attempting to access the database simultaneously.
Consider changing to the PostgreSQL database if you will be processing Consider changing to the PostgreSQL database if you will be processing
many documents at once often. Otherwise, try tweaking the many documents at once often. Otherwise, try tweaking the
[`PAPERLESS_DB_TIMEOUT`](configuration.md#PAPERLESS_DB_TIMEOUT) setting to allow more time for the database to [`PAPERLESS_DB_TIMEOUT`](configuration.md#PAPERLESS_DB_TIMEOUT) setting to allow more time for the database to
unlock. This may have minor performance implications. unlock. Additionally, you can change your SQLite database to use ["Write-Ahead Logging"](https://sqlite.org/wal.html).
These changes may have minor performance implications but can help
prevent database locking issues.
## granian fails to start with "is not a valid port number" ## granian fails to start with "is not a valid port number"

View File

@ -23,7 +23,7 @@ dependencies = [
"dateparser~=1.2", "dateparser~=1.2",
# WARNING: django does not use semver. # WARNING: django does not use semver.
# Only patch versions are guaranteed to not introduce breaking changes. # Only patch versions are guaranteed to not introduce breaking changes.
"django~=5.1.6", "django~=5.1.7",
"django-allauth[socialaccount,mfa]~=65.4.0", "django-allauth[socialaccount,mfa]~=65.4.0",
"django-auditlog~=3.0.0", "django-auditlog~=3.0.0",
"django-celery-results~=2.5.1", "django-celery-results~=2.5.1",
@ -78,7 +78,7 @@ optional-dependencies.postgres = [
"psycopg-c==3.2.5", "psycopg-c==3.2.5",
] ]
optional-dependencies.webserver = [ optional-dependencies.webserver = [
"granian~=2.0.1", "granian[uvloop]~=2.2.0",
] ]
[dependency-groups] [dependency-groups]

File diff suppressed because it is too large Load Diff

View File

@ -12,17 +12,17 @@
}, },
"private": true, "private": true,
"dependencies": { "dependencies": {
"@angular/cdk": "^19.2.2", "@angular/cdk": "^19.2.7",
"@angular/common": "~19.2.1", "@angular/common": "~19.2.4",
"@angular/compiler": "~19.2.1", "@angular/compiler": "~19.2.4",
"@angular/core": "~19.2.1", "@angular/core": "~19.2.4",
"@angular/forms": "~19.2.1", "@angular/forms": "~19.2.4",
"@angular/localize": "~19.2.1", "@angular/localize": "~19.2.4",
"@angular/platform-browser": "~19.2.1", "@angular/platform-browser": "~19.2.4",
"@angular/platform-browser-dynamic": "~19.2.1", "@angular/platform-browser-dynamic": "~19.2.4",
"@angular/router": "~19.2.1", "@angular/router": "~19.2.4",
"@ng-bootstrap/ng-bootstrap": "^18.0.0", "@ng-bootstrap/ng-bootstrap": "^18.0.0",
"@ng-select/ng-select": "^14.2.3", "@ng-select/ng-select": "^14.2.6",
"@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.3", "bootstrap": "^5.3.3",
@ -44,28 +44,28 @@
"devDependencies": { "devDependencies": {
"@angular-builders/custom-webpack": "^19.0.0", "@angular-builders/custom-webpack": "^19.0.0",
"@angular-builders/jest": "^19.0.0", "@angular-builders/jest": "^19.0.0",
"@angular-devkit/build-angular": "^19.2.1", "@angular-devkit/build-angular": "^19.2.5",
"@angular-devkit/core": "^19.2.1", "@angular-devkit/core": "^19.2.5",
"@angular-devkit/schematics": "^19.2.1", "@angular-devkit/schematics": "^19.2.5",
"@angular-eslint/builder": "19.2.1", "@angular-eslint/builder": "19.3.0",
"@angular-eslint/eslint-plugin": "19.2.1", "@angular-eslint/eslint-plugin": "19.3.0",
"@angular-eslint/eslint-plugin-template": "19.2.1", "@angular-eslint/eslint-plugin-template": "19.3.0",
"@angular-eslint/schematics": "19.2.1", "@angular-eslint/schematics": "19.3.0",
"@angular-eslint/template-parser": "19.2.1", "@angular-eslint/template-parser": "19.3.0",
"@angular/cli": "~19.2.1", "@angular/cli": "~19.2.5",
"@angular/compiler-cli": "~19.2.1", "@angular/compiler-cli": "~19.2.4",
"@codecov/webpack-plugin": "^1.9.0", "@codecov/webpack-plugin": "^1.9.0",
"@playwright/test": "^1.50.1", "@playwright/test": "^1.51.1",
"@types/jest": "^29.5.14", "@types/jest": "^29.5.14",
"@types/node": "^22.13.9", "@types/node": "^22.13.17",
"@typescript-eslint/eslint-plugin": "^8.26.1", "@typescript-eslint/eslint-plugin": "^8.29.0",
"@typescript-eslint/parser": "^8.26.1", "@typescript-eslint/parser": "^8.29.0",
"@typescript-eslint/utils": "^8.26.1", "@typescript-eslint/utils": "^8.29.0",
"eslint": "^9.22.0", "eslint": "^9.23.0",
"jest": "29.7.0", "jest": "29.7.0",
"jest-environment-jsdom": "^29.7.0", "jest-environment-jsdom": "^29.7.0",
"jest-junit": "^16.0.0", "jest-junit": "^16.0.0",
"jest-preset-angular": "^14.5.3", "jest-preset-angular": "^14.5.4",
"jest-websocket-mock": "^2.5.0", "jest-websocket-mock": "^2.5.0",
"patch-package": "^8.0.0", "patch-package": "^8.0.0",
"prettier-plugin-organize-imports": "^4.1.0", "prettier-plugin-organize-imports": "^4.1.0",

2387
src-ui/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@ -15,7 +15,7 @@
</svg> </svg>
<div class="ms-2 ms-md-3 d-inline-block" [class.d-md-none]="slimSidebarEnabled"> <div class="ms-2 ms-md-3 d-inline-block" [class.d-md-none]="slimSidebarEnabled">
@if (customAppTitle?.length) { @if (customAppTitle?.length) {
<div class="d-flex flex-column align-items-start"> <div class="d-flex flex-column align-items-start custom-title">
<span class="title">{{customAppTitle}}</span> <span class="title">{{customAppTitle}}</span>
<span class="byline text-uppercase font-monospace" i18n>by Paperless-ngx</span> <span class="byline text-uppercase font-monospace" i18n>by Paperless-ngx</span>
</div> </div>

View File

@ -244,7 +244,7 @@ main {
} }
} }
@media screen and (max-width: 768px) { @media screen and (min-width: 366px) and (max-width: 768px) {
.navbar-toggler { .navbar-toggler {
// compensate for 2 buttons on the right // compensate for 2 buttons on the right
margin-right: 45px; margin-right: 45px;
@ -257,6 +257,13 @@ main {
} }
} }
@media screen and (max-width: 345px) {
.custom-title {
max-width: 110px;
overflow: hidden;
}
}
:host ::ng-deep .dropdown.show .dropdown-toggle, :host ::ng-deep .dropdown.show .dropdown-toggle,
:host ::ng-deep .dropdown-toggle:hover { :host ::ng-deep .dropdown-toggle:hover {
opacity: 0.7; opacity: 0.7;

View File

@ -1,4 +1,4 @@
<div ngbDropdown #fieldDropdown="ngbDropdown" (openChange)="onOpenClose($event)"> <div ngbDropdown #fieldDropdown="ngbDropdown" (openChange)="onOpenClose($event)" [popperOptions]="popperOptions" placement="bottom-end">
<button class="btn btn-sm btn-outline-primary" id="customFieldsDropdown" [disabled]="disabled" ngbDropdownToggle> <button class="btn btn-sm btn-outline-primary" id="customFieldsDropdown" [disabled]="disabled" ngbDropdownToggle>
<i-bs name="ui-radios"></i-bs> <i-bs name="ui-radios"></i-bs>
<div class="d-none d-sm-inline">&nbsp;<ng-container i18n>Custom Fields</ng-container></div> <div class="d-none d-sm-inline">&nbsp;<ng-container i18n>Custom Fields</ng-container></div>

View File

@ -21,6 +21,7 @@ import {
} from 'src/app/services/permissions.service' } from 'src/app/services/permissions.service'
import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service' import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
import { ToastService } from 'src/app/services/toast.service' import { ToastService } from 'src/app/services/toast.service'
import { pngxPopperOptions } from 'src/app/utils/popper-options'
import { LoadingComponentWithPermissions } from '../../loading-component/loading.component' import { LoadingComponentWithPermissions } from '../../loading-component/loading.component'
import { CustomFieldEditDialogComponent } from '../edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component' import { CustomFieldEditDialogComponent } from '../edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component'
@ -36,6 +37,8 @@ import { CustomFieldEditDialogComponent } from '../edit-dialog/custom-field-edit
], ],
}) })
export class CustomFieldsDropdownComponent extends LoadingComponentWithPermissions { export class CustomFieldsDropdownComponent extends LoadingComponentWithPermissions {
public popperOptions = pngxPopperOptions
@Input() @Input()
documentId: number documentId: number

View File

@ -62,6 +62,7 @@ export class EmailDocumentDialogComponent extends LoadingComponentWithPermission
this.emailAddress = '' this.emailAddress = ''
this.emailSubject = '' this.emailSubject = ''
this.emailMessage = '' this.emailMessage = ''
this.close()
this.toastService.showInfo($localize`Email sent`) this.toastService.showInfo($localize`Email sent`)
}, },
error: (e) => { error: (e) => {

View File

@ -7,6 +7,7 @@ import {
tick, tick,
} from '@angular/core/testing' } from '@angular/core/testing'
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons' import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
import { NEGATIVE_NULL_FILTER_VALUE } from 'src/app/data/filter-rule-type'
import { import {
DEFAULT_MATCHING_ALGORITHM, DEFAULT_MATCHING_ALGORITHM,
MATCH_ALL, MATCH_ALL,
@ -44,6 +45,11 @@ const nullItem = {
name: 'Not assigned', name: 'Not assigned',
} }
const negativeNullItem = {
id: NEGATIVE_NULL_FILTER_VALUE,
name: 'Not assigned',
}
let selectionModel: FilterableDropdownSelectionModel let selectionModel: FilterableDropdownSelectionModel
describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () => { describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () => {
@ -64,6 +70,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
hotkeyService = TestBed.inject(HotKeyService) hotkeyService = TestBed.inject(HotKeyService)
fixture = TestBed.createComponent(FilterableDropdownComponent) fixture = TestBed.createComponent(FilterableDropdownComponent)
component = fixture.componentInstance component = fixture.componentInstance
component.selectionModel = new FilterableDropdownSelectionModel()
selectionModel = new FilterableDropdownSelectionModel() selectionModel = new FilterableDropdownSelectionModel()
}) })
@ -74,7 +81,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
}) })
it('should support reset', () => { it('should support reset', () => {
component.items = items component.selectionModel.items = items
component.selectionModel = selectionModel component.selectionModel = selectionModel
selectionModel.set(items[0].id, ToggleableItemState.Selected) selectionModel.set(items[0].id, ToggleableItemState.Selected)
expect(selectionModel.getSelectedItems()).toHaveLength(1) expect(selectionModel.getSelectedItems()).toHaveLength(1)
@ -96,7 +103,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
}) })
it('should emit change when items selected', () => { it('should emit change when items selected', () => {
component.items = items component.selectionModel.items = items
component.selectionModel = selectionModel component.selectionModel = selectionModel
let newModel: FilterableDropdownSelectionModel let newModel: FilterableDropdownSelectionModel
component.selectionModelChange.subscribe((model) => (newModel = model)) component.selectionModelChange.subscribe((model) => (newModel = model))
@ -110,11 +117,11 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
selectionModel.set(items[0].id, ToggleableItemState.NotSelected) selectionModel.set(items[0].id, ToggleableItemState.NotSelected)
expect(newModel.getSelectedItems()).toEqual([]) expect(newModel.getSelectedItems()).toEqual([])
expect(component.items).toEqual([nullItem, ...items]) expect(component.selectionModel.items).toEqual([nullItem, ...items])
}) })
it('should emit change when items excluded', () => { it('should emit change when items excluded', () => {
component.items = items component.selectionModel.items = items
component.selectionModel = selectionModel component.selectionModel = selectionModel
let newModel: FilterableDropdownSelectionModel let newModel: FilterableDropdownSelectionModel
component.selectionModelChange.subscribe((model) => (newModel = model)) component.selectionModelChange.subscribe((model) => (newModel = model))
@ -124,7 +131,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
}) })
it('should emit change when items excluded', () => { it('should emit change when items excluded', () => {
component.items = items component.selectionModel.items = items
component.selectionModel = selectionModel component.selectionModel = selectionModel
let newModel: FilterableDropdownSelectionModel let newModel: FilterableDropdownSelectionModel
component.selectionModelChange.subscribe((model) => (newModel = model)) component.selectionModelChange.subscribe((model) => (newModel = model))
@ -139,8 +146,8 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
}) })
it('should exclude items when excluded and not editing', () => { it('should exclude items when excluded and not editing', () => {
component.items = items component.selectionModel.items = items
component.manyToOne = true component.selectionModel.manyToOne = true
component.selectionModel = selectionModel component.selectionModel = selectionModel
selectionModel.set(items[0].id, ToggleableItemState.Selected) selectionModel.set(items[0].id, ToggleableItemState.Selected)
component.excludeClicked(items[0].id) component.excludeClicked(items[0].id)
@ -149,8 +156,8 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
}) })
it('should toggle when items excluded and editing', () => { it('should toggle when items excluded and editing', () => {
component.items = items component.selectionModel.items = items
component.manyToOne = true component.selectionModel.manyToOne = true
component.editing = true component.editing = true
component.selectionModel = selectionModel component.selectionModel = selectionModel
selectionModel.set(items[0].id, ToggleableItemState.NotSelected) selectionModel.set(items[0].id, ToggleableItemState.NotSelected)
@ -160,8 +167,8 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
}) })
it('should hide count for item if adding will increase size of set', () => { it('should hide count for item if adding will increase size of set', () => {
component.items = items component.selectionModel.items = items
component.manyToOne = true component.selectionModel.manyToOne = true
component.selectionModel = selectionModel component.selectionModel = selectionModel
expect(component.hideCount(items[0])).toBeFalsy() expect(component.hideCount(items[0])).toBeFalsy()
selectionModel.logicalOperator = LogicalOperator.Or selectionModel.logicalOperator = LogicalOperator.Or
@ -170,7 +177,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
it('should enforce single select when editing', () => { it('should enforce single select when editing', () => {
component.editing = true component.editing = true
component.items = items component.selectionModel.items = items
component.selectionModel = selectionModel component.selectionModel = selectionModel
let newModel: FilterableDropdownSelectionModel let newModel: FilterableDropdownSelectionModel
component.selectionModelChange.subscribe((model) => (newModel = model)) component.selectionModelChange.subscribe((model) => (newModel = model))
@ -182,11 +189,11 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
}) })
it('should support manyToOne selecting', () => { it('should support manyToOne selecting', () => {
component.items = items component.selectionModel.items = items
selectionModel.manyToOne = false selectionModel.manyToOne = false
component.selectionModel = selectionModel component.selectionModel = selectionModel
component.manyToOne = true component.selectionModel.manyToOne = true
expect(component.manyToOne).toBeTruthy() expect(component.selectionModel.manyToOne).toBeTruthy()
let newModel: FilterableDropdownSelectionModel let newModel: FilterableDropdownSelectionModel
component.selectionModelChange.subscribe((model) => (newModel = model)) component.selectionModelChange.subscribe((model) => (newModel = model))
@ -197,12 +204,10 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
}) })
it('should dynamically enable / disable modifier toggle', () => { it('should dynamically enable / disable modifier toggle', () => {
component.items = items component.selectionModel.items = items
component.selectionModel = selectionModel component.selectionModel = selectionModel
expect(component.modifierToggleEnabled).toBeTruthy() expect(component.modifierToggleEnabled).toBeTruthy()
selectionModel.toggle(null) component.selectionModel.manyToOne = true
expect(component.modifierToggleEnabled).toBeFalsy()
component.manyToOne = true
expect(component.modifierToggleEnabled).toBeFalsy() expect(component.modifierToggleEnabled).toBeFalsy()
selectionModel.toggle(items[0].id) selectionModel.toggle(items[0].id)
selectionModel.toggle(items[1].id) selectionModel.toggle(items[1].id)
@ -210,7 +215,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
}) })
it('should apply changes and close when apply button clicked', () => { it('should apply changes and close when apply button clicked', () => {
component.items = items component.selectionModel.items = items
component.icon = 'tag-fill' component.icon = 'tag-fill'
component.editing = true component.editing = true
component.selectionModel = selectionModel component.selectionModel = selectionModel
@ -232,7 +237,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
}) })
it('should apply on close if enabled', () => { it('should apply on close if enabled', () => {
component.items = items component.selectionModel.items = items
component.icon = 'tag-fill' component.icon = 'tag-fill'
component.editing = true component.editing = true
component.applyOnClose = true component.applyOnClose = true
@ -250,7 +255,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
}) })
it('should focus text filter on open, support filtering, clear on close', fakeAsync(() => { it('should focus text filter on open, support filtering, clear on close', fakeAsync(() => {
component.items = items component.selectionModel.items = items
component.icon = 'tag-fill' component.icon = 'tag-fill'
fixture.nativeElement fixture.nativeElement
.querySelector('button') .querySelector('button')
@ -277,7 +282,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
})) }))
it('should toggle & close on enter inside filter field if 1 item remains', fakeAsync(() => { it('should toggle & close on enter inside filter field if 1 item remains', fakeAsync(() => {
component.items = items component.selectionModel.items = items
component.icon = 'tag-fill' component.icon = 'tag-fill'
expect(component.selectionModel.getSelectedItems()).toEqual([]) expect(component.selectionModel.getSelectedItems()).toEqual([])
fixture.nativeElement fixture.nativeElement
@ -297,7 +302,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
})) }))
it('should apply & close on enter inside filter field if 1 item remains if editing', fakeAsync(() => { it('should apply & close on enter inside filter field if 1 item remains if editing', fakeAsync(() => {
component.items = items component.selectionModel.items = items
component.icon = 'tag-fill' component.icon = 'tag-fill'
component.editing = true component.editing = true
let applyResult: ChangedItems let applyResult: ChangedItems
@ -319,7 +324,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
})) }))
it('should support arrow keyboard navigation', fakeAsync(() => { it('should support arrow keyboard navigation', fakeAsync(() => {
component.items = items component.selectionModel.items = items
component.icon = 'tag-fill' component.icon = 'tag-fill'
fixture.nativeElement fixture.nativeElement
.querySelector('button') .querySelector('button')
@ -364,7 +369,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
})) }))
it('should support arrow keyboard navigation after tab keyboard navigation', fakeAsync(() => { it('should support arrow keyboard navigation after tab keyboard navigation', fakeAsync(() => {
component.items = items component.selectionModel.items = items
component.icon = 'tag-fill' component.icon = 'tag-fill'
fixture.nativeElement fixture.nativeElement
.querySelector('button') .querySelector('button')
@ -400,7 +405,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
})) }))
it('should support arrow keyboard navigation after click', fakeAsync(() => { it('should support arrow keyboard navigation after click', fakeAsync(() => {
component.items = items component.selectionModel.items = items
component.icon = 'tag-fill' component.icon = 'tag-fill'
fixture.nativeElement fixture.nativeElement
.querySelector('button') .querySelector('button')
@ -425,9 +430,9 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
})) }))
it('should toggle logical operator', fakeAsync(() => { it('should toggle logical operator', fakeAsync(() => {
component.items = items component.selectionModel.items = items
component.icon = 'tag-fill' component.icon = 'tag-fill'
component.manyToOne = true component.selectionModel.manyToOne = true
selectionModel.set(items[0].id, ToggleableItemState.Selected) selectionModel.set(items[0].id, ToggleableItemState.Selected)
selectionModel.set(items[1].id, ToggleableItemState.Selected) selectionModel.set(items[1].id, ToggleableItemState.Selected)
component.selectionModel = selectionModel component.selectionModel = selectionModel
@ -454,7 +459,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
})) }))
it('should toggle intersection include / exclude', fakeAsync(() => { it('should toggle intersection include / exclude', fakeAsync(() => {
component.items = items component.selectionModel.items = items
component.icon = 'tag-fill' component.icon = 'tag-fill'
selectionModel.set(items[0].id, ToggleableItemState.Selected) selectionModel.set(items[0].id, ToggleableItemState.Selected)
selectionModel.set(items[1].id, ToggleableItemState.Selected) selectionModel.set(items[1].id, ToggleableItemState.Selected)
@ -483,22 +488,53 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
expect(changedResult.getExcludedItems()).toEqual(items) expect(changedResult.getExcludedItems()).toEqual(items)
})) }))
it('selection model should sort items by state', () => { it('should update null item selection on toggleIntersection', () => {
component.items = items.concat([{ id: null, name: 'Null B' }]) component.selectionModel.items = items
component.selectionModel = selectionModel component.selectionModel = selectionModel
component.selectionModel.intersection = Intersection.Include
component.selectionModel.set(null, ToggleableItemState.Selected)
component.selectionModel.intersection = Intersection.Exclude
component.selectionModel.toggleIntersection()
expect(component.selectionModel.getExcludedItems()).toEqual([
negativeNullItem,
])
component.selectionModel.intersection = Intersection.Include
component.selectionModel.toggleIntersection()
expect(component.selectionModel.getSelectedItems()).toEqual([nullItem])
})
it('selection model should sort items by state', () => {
component.selectionModel = selectionModel
component.selectionModel.items = items.concat([{ id: 3, name: 'Item3' }])
selectionModel.toggle(items[1].id) selectionModel.toggle(items[1].id)
selectionModel.apply() selectionModel.apply()
expect(selectionModel.items.length).toEqual(4)
expect(selectionModel.items).toEqual([ expect(selectionModel.items).toEqual([
nullItem, nullItem,
{ id: null, name: 'Null B' },
items[1], items[1],
{ id: 3, name: 'Item3' },
items[0], items[0],
]) ])
selectionModel.intersection = Intersection.Exclude
selectionModel.toggleIntersection()
selectionModel.apply()
expect(selectionModel.items).toEqual([
negativeNullItem,
items[1],
{ id: 3, name: 'Item3' },
items[0],
])
// coverage
selectionModel.items = selectionModel.items.reverse()
selectionModel.apply()
}) })
it('selection model should sort items by state and document counts = 0, if set', () => { it('selection model should sort items by state and document counts = 0, if set', () => {
const tagA = { id: 4, name: 'Tag A' } const tagA = { id: 4, name: 'Tag A' }
component.items = items.concat([tagA]) component.selectionModel.items = items.concat([tagA])
component.selectionModel = selectionModel component.selectionModel = selectionModel
component.documentCounts = [ component.documentCounts = [
{ id: 1, document_count: 0 }, // Tag1 { id: 1, document_count: 0 }, // Tag1
@ -529,7 +565,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
}) })
it('should set support create, keep open model and call createRef method', fakeAsync(() => { it('should set support create, keep open model and call createRef method', fakeAsync(() => {
component.items = items component.selectionModel.items = items
component.icon = 'tag-fill' component.icon = 'tag-fill'
component.selectionModel = selectionModel component.selectionModel = selectionModel
fixture.nativeElement fixture.nativeElement
@ -549,7 +585,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
})) }))
it('should call create on enter inside filter field if 0 items remain while editing', fakeAsync(() => { it('should call create on enter inside filter field if 0 items remain while editing', fakeAsync(() => {
component.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()
@ -569,7 +605,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
const id = 1 const id = 1
const state = ToggleableItemState.Selected const state = ToggleableItemState.Selected
component.selectionModel = selectionModel component.selectionModel = selectionModel
component.manyToOne = true component.selectionModel.manyToOne = true
component.selectionModel.singleSelect = true component.selectionModel.singleSelect = true
component.selectionModel.intersection = Intersection.Include component.selectionModel.intersection = Intersection.Include
component.selectionModel['temporarySelectionStates'].set(id, state) component.selectionModel['temporarySelectionStates'].set(id, state)
@ -596,7 +632,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
}) })
it('should support shortcut keys', () => { it('should support shortcut keys', () => {
component.items = items component.selectionModel.items = items
component.icon = 'tag-fill' component.icon = 'tag-fill'
component.shortcutKey = 't' component.shortcutKey = 't'
fixture.detectChanges() fixture.detectChanges()
@ -606,7 +642,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
}) })
it('should support an extra button and not apply changes when clicked', () => { it('should support an extra button and not apply changes when clicked', () => {
component.items = items component.selectionModel.items = items
component.icon = 'tag-fill' component.icon = 'tag-fill'
component.extraButtonTitle = 'Extra' component.extraButtonTitle = 'Extra'
component.selectionModel = selectionModel component.selectionModel = selectionModel

View File

@ -12,6 +12,7 @@ import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { NgbDropdown, NgbDropdownModule } from '@ng-bootstrap/ng-bootstrap' import { NgbDropdown, NgbDropdownModule } 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, takeUntil } from 'rxjs'
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'
import { FilterPipe } from 'src/app/pipes/filter.pipe' import { FilterPipe } from 'src/app/pipes/filter.pipe'
@ -61,15 +62,56 @@ export class FilterableDropdownSelectionModel {
} }
set items(items: MatchingModel[]) { set items(items: MatchingModel[]) {
this._items = items if (items) {
this.sortItems() this._items = Array.from(items)
this.sortItems()
this.setNullItem()
}
}
private setNullItem() {
if (this.manyToOne && this.logicalOperator === LogicalOperator.Or) {
if (this._items[0]?.id === null) {
this._items.shift()
}
return
}
const item = {
name: $localize`:Filter drop down element to filter for documents with no correspondent/type/tag assigned:Not assigned`,
id:
this.manyToOne || this.intersection === Intersection.Include
? null
: NEGATIVE_NULL_FILTER_VALUE,
}
if (
this._items[0]?.id === null ||
this._items[0]?.id === NEGATIVE_NULL_FILTER_VALUE
) {
this._items[0] = item
} else if (this._items) {
this._items.unshift(item)
}
}
constructor(manyToOne: boolean = false) {
this.manyToOne = manyToOne
} }
private sortItems() { private sortItems() {
this._items.sort((a, b) => { this._items.sort((a, b) => {
if (a.id == null && b.id != null) { if (
(a.id == null && b.id != null) ||
(a.id == NEGATIVE_NULL_FILTER_VALUE &&
b.id != NEGATIVE_NULL_FILTER_VALUE)
) {
return -1 return -1
} else if (a.id != null && b.id == null) { } else if (
(a.id != null && b.id == null) ||
(a.id != NEGATIVE_NULL_FILTER_VALUE &&
b.id == NEGATIVE_NULL_FILTER_VALUE)
) {
return 1 return 1
} else if ( } else if (
this.getNonTemporary(a.id) == ToggleableItemState.NotSelected && this.getNonTemporary(a.id) == ToggleableItemState.NotSelected &&
@ -230,6 +272,7 @@ export class FilterableDropdownSelectionModel {
set logicalOperator(operator: LogicalOperator) { set logicalOperator(operator: LogicalOperator) {
this.temporaryLogicalOperator = operator this.temporaryLogicalOperator = operator
this.setNullItem()
} }
toggleOperator() { toggleOperator() {
@ -242,6 +285,7 @@ export class FilterableDropdownSelectionModel {
set intersection(intersection: Intersection) { set intersection(intersection: Intersection) {
this.temporaryIntersection = intersection this.temporaryIntersection = intersection
this.setNullItem()
} }
toggleIntersection() { toggleIntersection() {
@ -250,9 +294,20 @@ export class FilterableDropdownSelectionModel {
this.intersection == Intersection.Include this.intersection == Intersection.Include
? ToggleableItemState.Selected ? ToggleableItemState.Selected
: ToggleableItemState.Excluded : ToggleableItemState.Excluded
this.temporarySelectionStates.forEach((state, key) => { this.temporarySelectionStates.forEach((state, key) => {
this.temporarySelectionStates.set(key, newState) if (key === null && this.intersection === Intersection.Exclude) {
this.temporarySelectionStates.set(NEGATIVE_NULL_FILTER_VALUE, newState)
} else if (
key === NEGATIVE_NULL_FILTER_VALUE &&
this.intersection === Intersection.Include
) {
this.temporarySelectionStates.set(null, newState)
} else {
this.temporarySelectionStates.set(key, newState)
}
}) })
this.changed.next(this) this.changed.next(this)
} }
@ -274,6 +329,7 @@ export class FilterableDropdownSelectionModel {
this.temporarySelectionStates.clear() this.temporarySelectionStates.clear()
this.temporaryLogicalOperator = this._logicalOperator = LogicalOperator.And this.temporaryLogicalOperator = this._logicalOperator = LogicalOperator.And
this.temporaryIntersection = this._intersection = Intersection.Include this.temporaryIntersection = this._intersection = Intersection.Include
this.setNullItem()
if (fireEvent) { if (fireEvent) {
this.changed.next(this) this.changed.next(this)
} }
@ -305,8 +361,10 @@ export class FilterableDropdownSelectionModel {
isNoneSelected() { isNoneSelected() {
return ( return (
this.selectionSize() == 1 && (this.selectionSize() == 1 &&
this.get(null) == ToggleableItemState.Selected this.get(null) == ToggleableItemState.Selected) ||
(this.intersection == Intersection.Exclude &&
this.get(NEGATIVE_NULL_FILTER_VALUE) == ToggleableItemState.Excluded)
) )
} }
@ -384,25 +442,13 @@ export class FilterableDropdownComponent
filterText: string filterText: string
@Input() _selectionModel: FilterableDropdownSelectionModel
set items(items: MatchingModel[]) {
if (items) {
this._selectionModel.items = Array.from(items)
this._selectionModel.items.unshift({
name: $localize`:Filter drop down element to filter for documents with no correspondent/type/tag assigned:Not assigned`,
id: null,
})
}
}
get items(): MatchingModel[] { get items(): MatchingModel[] {
return this._selectionModel.items return this._selectionModel.items
} }
_selectionModel: FilterableDropdownSelectionModel = @Input({ required: true })
new FilterableDropdownSelectionModel()
@Input()
set selectionModel(model: FilterableDropdownSelectionModel) { set selectionModel(model: FilterableDropdownSelectionModel) {
if (this.selectionModel) { if (this.selectionModel) {
this.selectionModel.changed.complete() this.selectionModel.changed.complete()
@ -423,11 +469,6 @@ export class FilterableDropdownComponent
@Output() @Output()
selectionModelChange = new EventEmitter<FilterableDropdownSelectionModel>() selectionModelChange = new EventEmitter<FilterableDropdownSelectionModel>()
@Input()
set manyToOne(manyToOne: boolean) {
this.selectionModel.manyToOne = manyToOne
}
get manyToOne() { get manyToOne() {
return this.selectionModel.manyToOne return this.selectionModel.manyToOne
} }
@ -484,7 +525,7 @@ export class FilterableDropdownComponent
return this.manyToOne return this.manyToOne
? this.selectionModel.selectionSize() > 1 && ? this.selectionModel.selectionSize() > 1 &&
this.selectionModel.getExcludedItems().length == 0 this.selectionModel.getExcludedItems().length == 0
: !this.selectionModel.isNoneSelected() : true
} }
get name(): string { get name(): string {

View File

@ -30,25 +30,24 @@
[placeholder]="placeholder" [placeholder]="placeholder"
[notFoundText]="notFoundText" [notFoundText]="notFoundText"
[multiple]="true" [multiple]="true"
bindValue="id"
[compareWith]="compareDocuments" [compareWith]="compareDocuments"
[trackByFn]="trackByFn" [trackByFn]="trackByFn"
[minTermLength]="2" [minTermLength]="2"
[loading]="loading" [loading]="loading"
[typeahead]="documentsInput$" [typeahead]="documentsInput$"
(mousedown)="$event.stopImmediatePropagation()" (mousedown)="$event.stopImmediatePropagation()"
(change)="onChange(selectedDocuments)"> (change)="onChange(selectedDocumentIDs)">
<ng-template ng-label-tmp let-document="item"> <ng-template ng-label-tmp let-document="item">
<div class="d-flex align-items-center"> <div class="d-flex align-items-center">
@if (!disabled) { @if (!disabled) {
<button class="btn p-0 lh-1" (click)="unselect(document)" title="Remove link" i18n-title><i-bs name="x"></i-bs></button> <button class="btn p-0 lh-1" (click)="unselect(document)" (mousedown)="$event.stopImmediatePropagation()" type="button" title="Remove link" i18n-title><i-bs name="x"></i-bs></button>
} }
@if (document.title) { @if (document.title) {
<a routerLink="/documents/{{document.id}}" class="badge bg-light text-primary" (mousedown)="$event.stopImmediatePropagation();" title="Open link" i18n-title> <a routerLink="/documents/{{document.id}}" class="badge bg-light text-primary" (mousedown)="$event.stopImmediatePropagation();" title="Open link" i18n-title>
<i-bs width="0.9em" height="0.9em" name="file-text"></i-bs>&nbsp;<span>{{document.title}}</span> <i-bs width="0.9em" height="0.9em" name="file-text"></i-bs>&nbsp;<span>{{document.title}}</span>
</a> </a>
} @else { } @else {
<span class="badge bg-light text-muted" (click)="unselect(document)" title="Remove link" i18n-title> <span class="badge bg-light text-muted" (click)="unselect(document)" (mousedown)="$event.stopImmediatePropagation()" type="button" title="Remove link" i18n-title>
<i-bs width="0.9em" height="0.9em" name="exclamation-triangle-fill"></i-bs>&nbsp;<span i18n>Not found</span> <i-bs width="0.9em" height="0.9em" name="exclamation-triangle-fill"></i-bs>&nbsp;<span i18n>Not found</span>
</span> </span>
} }

View File

@ -74,6 +74,11 @@ describe('DocumentLinkComponent', () => {
expect(component.selectedDocuments).toEqual([documents[1], documents[0]]) expect(component.selectedDocuments).toEqual([documents[1], documents[0]])
}) })
it('should retrieve document IDs from selected documents', () => {
component.selectedDocuments = documents
expect(component.selectedDocumentIDs).toEqual([1, 12, 16, 23])
})
it('should search API on select text input', () => { it('should search API on select text input', () => {
const listSpy = jest.spyOn(documentService, 'listFiltered') const listSpy = jest.spyOn(documentService, 'listFiltered')
listSpy.mockImplementation( listSpy.mockImplementation(

View File

@ -71,6 +71,10 @@ export class DocumentLinkComponent
@Input() @Input()
placeholder: string = $localize`Search for documents` placeholder: string = $localize`Search for documents`
get selectedDocumentIDs(): number[] {
return this.selectedDocuments.map((d) => d.id)
}
constructor(private documentsService: DocumentService) { constructor(private documentsService: DocumentService) {
super() super()
} }

View File

@ -17,7 +17,7 @@
(change)="onChange(value)"> (change)="onChange(value)">
<ng-template ng-label-tmp let-item="item"> <ng-template ng-label-tmp let-item="item">
<button class="tag-wrap btn p-0 d-flex align-items-center" (click)="removeTag($event, item.id)" title="Remove tag" i18n-title> <button class="tag-wrap btn p-0 d-flex align-items-center" (click)="removeTag(item.id)" (mousedown)="$event.stopImmediatePropagation()" type="button" title="Remove tag" i18n-title>
<i-bs name="x" style="margin-inline-end: 1px;"></i-bs> <i-bs name="x" style="margin-inline-end: 1px;"></i-bs>
@if (item.id && tags) { @if (item.id && tags) {
<pngx-tag style="background-color: none;" [tag]="getTag(item.id)"></pngx-tag> <pngx-tag style="background-color: none;" [tag]="getTag(item.id)"></pngx-tag>

View File

@ -154,11 +154,11 @@ describe('TagsComponent', () => {
it('support remove tags', () => { it('support remove tags', () => {
component.tags = tags component.tags = tags
component.value = [1, 2] component.value = [1, 2]
component.removeTag(new PointerEvent('point'), 2) component.removeTag(2)
expect(component.value).toEqual([1]) expect(component.value).toEqual([1])
component.disabled = true component.disabled = true
component.removeTag(new PointerEvent('point'), 1) component.removeTag(1)
expect(component.value).toEqual([1]) expect(component.value).toEqual([1])
}) })

View File

@ -118,13 +118,10 @@ export class TagsComponent implements OnInit, ControlValueAccessor {
} }
} }
removeTag(event: PointerEvent, id: number) { removeTag(tagID: number) {
if (this.disabled) return if (this.disabled) return
// prevent opening dropdown let index = this.value.indexOf(tagID)
event.stopImmediatePropagation()
let index = this.value.indexOf(id)
if (index > -1) { if (index > -1) {
let oldValue = this.value let oldValue = this.value
oldValue.splice(index, 1) oldValue.splice(index, 1)

View File

@ -824,11 +824,18 @@ export class DocumentDetailComponent
}, },
error: (error) => { error: (error) => {
this.networkActive = false this.networkActive = false
if (!this.userCanEdit) { const canEdit =
this.permissionsService.currentUserHasObjectPermissions(
PermissionAction.Change,
this.document
)
if (!canEdit) {
// document was 'given away'
this.openDocumentService.setDirty(this.document, false)
this.toastService.showInfo( this.toastService.showInfo(
$localize`Document "${this.document.title}" saved successfully.` $localize`Document "${this.document.title}" saved successfully.`
) )
close && this.close() this.close()
} else { } else {
this.error = error.error this.error = error.error
this.toastService.showError( this.toastService.showError(
@ -1279,9 +1286,7 @@ export class DocumentDetailComponent
this.document.custom_fields?.forEach((fieldInstance) => { this.document.custom_fields?.forEach((fieldInstance) => {
this.customFieldFormFields.push( this.customFieldFormFields.push(
new FormGroup({ new FormGroup({
field: new FormControl( field: new FormControl(fieldInstance.field),
this.getCustomFieldFromInstance(fieldInstance)?.id
),
value: new FormControl(fieldInstance.value), value: new FormControl(fieldInstance.value),
}), }),
{ emitEvent } { emitEvent }

View File

@ -20,10 +20,8 @@
@if (permissionService.currentUserCan(PermissionAction.View, PermissionType.Tag)) { @if (permissionService.currentUserCan(PermissionAction.View, PermissionType.Tag)) {
<pngx-filterable-dropdown title="Tags" icon="tag-fill" i18n-title <pngx-filterable-dropdown title="Tags" icon="tag-fill" i18n-title
filterPlaceholder="Filter tags" i18n-filterPlaceholder filterPlaceholder="Filter tags" i18n-filterPlaceholder
[items]="tags"
[disabled]="!userCanEditAll || disabled" [disabled]="!userCanEditAll || disabled"
[editing]="true" [editing]="true"
[manyToOne]="true"
[applyOnClose]="applyOnClose" [applyOnClose]="applyOnClose"
[createRef]="createTag.bind(this)" [createRef]="createTag.bind(this)"
(opened)="openTagsDropdown()" (opened)="openTagsDropdown()"
@ -36,7 +34,6 @@
@if (permissionService.currentUserCan(PermissionAction.View, PermissionType.Correspondent)) { @if (permissionService.currentUserCan(PermissionAction.View, PermissionType.Correspondent)) {
<pngx-filterable-dropdown title="Correspondent" icon="person-fill" i18n-title <pngx-filterable-dropdown title="Correspondent" icon="person-fill" i18n-title
filterPlaceholder="Filter correspondents" i18n-filterPlaceholder filterPlaceholder="Filter correspondents" i18n-filterPlaceholder
[items]="correspondents"
[disabled]="!userCanEditAll || disabled" [disabled]="!userCanEditAll || disabled"
[editing]="true" [editing]="true"
[applyOnClose]="applyOnClose" [applyOnClose]="applyOnClose"
@ -51,7 +48,6 @@
@if (permissionService.currentUserCan(PermissionAction.View, PermissionType.DocumentType)) { @if (permissionService.currentUserCan(PermissionAction.View, PermissionType.DocumentType)) {
<pngx-filterable-dropdown title="Document type" icon="file-earmark-fill" i18n-title <pngx-filterable-dropdown title="Document type" icon="file-earmark-fill" i18n-title
filterPlaceholder="Filter document types" i18n-filterPlaceholder filterPlaceholder="Filter document types" i18n-filterPlaceholder
[items]="documentTypes"
[disabled]="!userCanEditAll || disabled" [disabled]="!userCanEditAll || disabled"
[editing]="true" [editing]="true"
[applyOnClose]="applyOnClose" [applyOnClose]="applyOnClose"
@ -66,7 +62,6 @@
@if (permissionService.currentUserCan(PermissionAction.View, PermissionType.StoragePath)) { @if (permissionService.currentUserCan(PermissionAction.View, PermissionType.StoragePath)) {
<pngx-filterable-dropdown title="Storage path" icon="folder-fill" i18n-title <pngx-filterable-dropdown title="Storage path" icon="folder-fill" i18n-title
filterPlaceholder="Filter storage paths" i18n-filterPlaceholder filterPlaceholder="Filter storage paths" i18n-filterPlaceholder
[items]="storagePaths"
[disabled]="!userCanEditAll || disabled" [disabled]="!userCanEditAll || disabled"
[editing]="true" [editing]="true"
[applyOnClose]="applyOnClose" [applyOnClose]="applyOnClose"
@ -81,10 +76,8 @@
@if (permissionService.currentUserCan(PermissionAction.View, PermissionType.CustomField)) { @if (permissionService.currentUserCan(PermissionAction.View, PermissionType.CustomField)) {
<pngx-filterable-dropdown title="Custom fields" icon="ui-radios" i18n-title <pngx-filterable-dropdown title="Custom fields" icon="ui-radios" i18n-title
filterPlaceholder="Filter custom fields" i18n-filterPlaceholder filterPlaceholder="Filter custom fields" i18n-filterPlaceholder
[items]="customFields"
[disabled]="!userCanEditAll" [disabled]="!userCanEditAll"
[editing]="true" [editing]="true"
[manyToOne]="true"
[applyOnClose]="applyOnClose" [applyOnClose]="applyOnClose"
[createRef]="createCustomField.bind(this)" [createRef]="createCustomField.bind(this)"
(opened)="openCustomFieldsDropdown()" (opened)="openCustomFieldsDropdown()"

View File

@ -1150,10 +1150,10 @@ describe('BulkEditorComponent', () => {
it('should not attempt to retrieve objects if user does not have permissions', () => { it('should not attempt to retrieve objects if user does not have permissions', () => {
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true) jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
expect(component.tags).toBeUndefined() expect(component.tagSelectionModel.items.length).toEqual(0)
expect(component.correspondents).toBeUndefined() expect(component.correspondentSelectionModel.items.length).toEqual(0)
expect(component.documentTypes).toBeUndefined() expect(component.documentTypeSelectionModel.items.length).toEqual(0)
expect(component.storagePaths).toBeUndefined() expect(component.storagePathsSelectionModel.items.length).toEqual(0)
httpTestingController.expectNone(`${environment.apiBaseUrl}documents/tags/`) httpTestingController.expectNone(`${environment.apiBaseUrl}documents/tags/`)
httpTestingController.expectNone( httpTestingController.expectNone(
`${environment.apiBaseUrl}documents/correspondents/` `${environment.apiBaseUrl}documents/correspondents/`
@ -1204,7 +1204,9 @@ describe('BulkEditorComponent', () => {
expect(tagListAllSpy).toHaveBeenCalled() expect(tagListAllSpy).toHaveBeenCalled()
expect(tagSelectionModelToggleSpy).toHaveBeenCalledWith(newTag.id) expect(tagSelectionModelToggleSpy).toHaveBeenCalledWith(newTag.id)
expect(component.tags).toEqual(tags.results) expect(component.tagSelectionModel.items).toEqual(
[{ id: null, name: 'Not assigned' }].concat(tags.results as any)
)
}) })
it('should support create new correspondent', () => { it('should support create new correspondent', () => {
@ -1251,7 +1253,9 @@ describe('BulkEditorComponent', () => {
expect(correspondentSelectionModelToggleSpy).toHaveBeenCalledWith( expect(correspondentSelectionModelToggleSpy).toHaveBeenCalledWith(
newCorrespondent.id newCorrespondent.id
) )
expect(component.correspondents).toEqual(correspondents.results) expect(component.correspondentSelectionModel.items).toEqual(
[{ id: null, name: 'Not assigned' }].concat(correspondents.results as any)
)
}) })
it('should support create new document type', () => { it('should support create new document type', () => {
@ -1295,7 +1299,9 @@ describe('BulkEditorComponent', () => {
expect(documentTypeSelectionModelToggleSpy).toHaveBeenCalledWith( expect(documentTypeSelectionModelToggleSpy).toHaveBeenCalledWith(
newDocumentType.id newDocumentType.id
) )
expect(component.documentTypes).toEqual(documentTypes.results) expect(component.documentTypeSelectionModel.items).toEqual(
[{ id: null, name: 'Not assigned' }].concat(documentTypes.results as any)
)
}) })
it('should support create new storage path', () => { it('should support create new storage path', () => {
@ -1339,7 +1345,9 @@ describe('BulkEditorComponent', () => {
expect(storagePathsSelectionModelToggleSpy).toHaveBeenCalledWith( expect(storagePathsSelectionModelToggleSpy).toHaveBeenCalledWith(
newStoragePath.id newStoragePath.id
) )
expect(component.storagePaths).toEqual(storagePaths.results) expect(component.storagePathsSelectionModel.items).toEqual(
[{ id: null, name: 'Not assigned' }].concat(storagePaths.results as any)
)
}) })
it('should support create new custom field', () => { it('should support create new custom field', () => {
@ -1391,7 +1399,9 @@ describe('BulkEditorComponent', () => {
expect(customFieldsSelectionModelToggleSpy).toHaveBeenCalledWith( expect(customFieldsSelectionModelToggleSpy).toHaveBeenCalledWith(
newCustomField.id newCustomField.id
) )
expect(component.customFields).toEqual(customFields.results) expect(component.customFieldsSelectionModel.items).toEqual(
[{ id: null, name: 'Not assigned' }].concat(customFields.results as any)
)
}) })
it('should open the bulk edit custom field values dialog with correct parameters', () => { it('should open the bulk edit custom field values dialog with correct parameters', () => {
@ -1416,17 +1426,17 @@ describe('BulkEditorComponent', () => {
const toastServiceShowErrorSpy = jest.spyOn(toastService, 'showError') const toastServiceShowErrorSpy = jest.spyOn(toastService, 'showError')
const listReloadSpy = jest.spyOn(documentListViewService, 'reload') const listReloadSpy = jest.spyOn(documentListViewService, 'reload')
component.customFields = [ component.customFieldsSelectionModel.items = [
{ id: 1, name: 'Custom Field 1', data_type: CustomFieldDataType.String }, { id: 1, name: 'Custom Field 1', data_type: CustomFieldDataType.String },
{ id: 2, name: 'Custom Field 2', data_type: CustomFieldDataType.String }, { id: 2, name: 'Custom Field 2', data_type: CustomFieldDataType.String },
] ] as any
component.setCustomFieldValues({ component.setCustomFieldValues({
itemsToAdd: [{ id: 1 }, { id: 2 }], itemsToAdd: [{ id: 1 }, { id: 2 }],
itemsToRemove: [1], itemsToRemove: [1],
} as any) } as any)
expect(modal.componentInstance.customFields).toEqual(component.customFields) expect(modal.componentInstance.customFields.length).toEqual(2)
expect(modal.componentInstance.fieldsToAddIds).toEqual([1, 2]) expect(modal.componentInstance.fieldsToAddIds).toEqual([1, 2])
expect(modal.componentInstance.documents).toEqual([3, 4]) expect(modal.componentInstance.documents).toEqual([3, 4])

View File

@ -14,12 +14,8 @@ import { saveAs } from 'file-saver'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons' import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { first, map, Subject, switchMap, takeUntil } from 'rxjs' import { first, map, Subject, switchMap, takeUntil } from 'rxjs'
import { ConfirmDialogComponent } from 'src/app/components/common/confirm-dialog/confirm-dialog.component' import { ConfirmDialogComponent } from 'src/app/components/common/confirm-dialog/confirm-dialog.component'
import { Correspondent } from 'src/app/data/correspondent'
import { CustomField } from 'src/app/data/custom-field' import { CustomField } from 'src/app/data/custom-field'
import { DocumentType } from 'src/app/data/document-type'
import { MatchingModel } from 'src/app/data/matching-model' import { MatchingModel } from 'src/app/data/matching-model'
import { StoragePath } from 'src/app/data/storage-path'
import { Tag } from 'src/app/data/tag'
import { SETTINGS_KEYS } from 'src/app/data/ui-settings' import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive' import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
import { DocumentListViewService } from 'src/app/services/document-list-view.service' import { DocumentListViewService } from 'src/app/services/document-list-view.service'
@ -75,17 +71,11 @@ export class BulkEditorComponent
extends ComponentWithPermissions extends ComponentWithPermissions
implements OnInit, OnDestroy implements OnInit, OnDestroy
{ {
tags: Tag[] tagSelectionModel = new FilterableDropdownSelectionModel(true)
correspondents: Correspondent[]
documentTypes: DocumentType[]
storagePaths: StoragePath[]
customFields: CustomField[]
tagSelectionModel = new FilterableDropdownSelectionModel()
correspondentSelectionModel = new FilterableDropdownSelectionModel() correspondentSelectionModel = new FilterableDropdownSelectionModel()
documentTypeSelectionModel = new FilterableDropdownSelectionModel() documentTypeSelectionModel = new FilterableDropdownSelectionModel()
storagePathsSelectionModel = new FilterableDropdownSelectionModel() storagePathsSelectionModel = new FilterableDropdownSelectionModel()
customFieldsSelectionModel = new FilterableDropdownSelectionModel() customFieldsSelectionModel = new FilterableDropdownSelectionModel(true)
tagDocumentCounts: SelectionDataItem[] tagDocumentCounts: SelectionDataItem[]
correspondentDocumentCounts: SelectionDataItem[] correspondentDocumentCounts: SelectionDataItem[]
documentTypeDocumentCounts: SelectionDataItem[] documentTypeDocumentCounts: SelectionDataItem[]
@ -176,7 +166,7 @@ export class BulkEditorComponent
this.tagService this.tagService
.listAll() .listAll()
.pipe(first()) .pipe(first())
.subscribe((result) => (this.tags = result.results)) .subscribe((result) => (this.tagSelectionModel.items = result.results))
} }
if ( if (
this.permissionService.currentUserCan( this.permissionService.currentUserCan(
@ -187,7 +177,9 @@ export class BulkEditorComponent
this.correspondentService this.correspondentService
.listAll() .listAll()
.pipe(first()) .pipe(first())
.subscribe((result) => (this.correspondents = result.results)) .subscribe(
(result) => (this.correspondentSelectionModel.items = result.results)
)
} }
if ( if (
this.permissionService.currentUserCan( this.permissionService.currentUserCan(
@ -198,7 +190,9 @@ export class BulkEditorComponent
this.documentTypeService this.documentTypeService
.listAll() .listAll()
.pipe(first()) .pipe(first())
.subscribe((result) => (this.documentTypes = result.results)) .subscribe(
(result) => (this.documentTypeSelectionModel.items = result.results)
)
} }
if ( if (
this.permissionService.currentUserCan( this.permissionService.currentUserCan(
@ -209,7 +203,9 @@ export class BulkEditorComponent
this.storagePathService this.storagePathService
.listAll() .listAll()
.pipe(first()) .pipe(first())
.subscribe((result) => (this.storagePaths = result.results)) .subscribe(
(result) => (this.storagePathsSelectionModel.items = result.results)
)
} }
if ( if (
this.permissionService.currentUserCan( this.permissionService.currentUserCan(
@ -220,7 +216,9 @@ export class BulkEditorComponent
this.customFieldService this.customFieldService
.listAll() .listAll()
.pipe(first()) .pipe(first())
.subscribe((result) => (this.customFields = result.results)) .subscribe(
(result) => (this.customFieldsSelectionModel.items = result.results)
)
} }
this.downloadForm this.downloadForm
@ -651,7 +649,7 @@ export class BulkEditorComponent
) )
.pipe(takeUntil(this.unsubscribeNotifier)) .pipe(takeUntil(this.unsubscribeNotifier))
.subscribe(({ newTag, tags }) => { .subscribe(({ newTag, tags }) => {
this.tags = tags.results this.tagSelectionModel.items = tags.results
this.tagSelectionModel.toggle(newTag.id) this.tagSelectionModel.toggle(newTag.id)
}) })
} }
@ -674,7 +672,7 @@ export class BulkEditorComponent
) )
.pipe(takeUntil(this.unsubscribeNotifier)) .pipe(takeUntil(this.unsubscribeNotifier))
.subscribe(({ newCorrespondent, correspondents }) => { .subscribe(({ newCorrespondent, correspondents }) => {
this.correspondents = correspondents.results this.correspondentSelectionModel.items = correspondents.results
this.correspondentSelectionModel.toggle(newCorrespondent.id) this.correspondentSelectionModel.toggle(newCorrespondent.id)
}) })
} }
@ -695,7 +693,7 @@ export class BulkEditorComponent
) )
.pipe(takeUntil(this.unsubscribeNotifier)) .pipe(takeUntil(this.unsubscribeNotifier))
.subscribe(({ newDocumentType, documentTypes }) => { .subscribe(({ newDocumentType, documentTypes }) => {
this.documentTypes = documentTypes.results this.documentTypeSelectionModel.items = documentTypes.results
this.documentTypeSelectionModel.toggle(newDocumentType.id) this.documentTypeSelectionModel.toggle(newDocumentType.id)
}) })
} }
@ -716,7 +714,7 @@ export class BulkEditorComponent
) )
.pipe(takeUntil(this.unsubscribeNotifier)) .pipe(takeUntil(this.unsubscribeNotifier))
.subscribe(({ newStoragePath, storagePaths }) => { .subscribe(({ newStoragePath, storagePaths }) => {
this.storagePaths = storagePaths.results this.storagePathsSelectionModel.items = storagePaths.results
this.storagePathsSelectionModel.toggle(newStoragePath.id) this.storagePathsSelectionModel.toggle(newStoragePath.id)
}) })
} }
@ -737,7 +735,7 @@ export class BulkEditorComponent
) )
.pipe(takeUntil(this.unsubscribeNotifier)) .pipe(takeUntil(this.unsubscribeNotifier))
.subscribe(({ newCustomField, customFields }) => { .subscribe(({ newCustomField, customFields }) => {
this.customFields = customFields.results this.customFieldsSelectionModel.items = customFields.results
this.customFieldsSelectionModel.toggle(newCustomField.id) this.customFieldsSelectionModel.toggle(newCustomField.id)
}) })
} }
@ -875,7 +873,9 @@ export class BulkEditorComponent
}) })
const dialog = const dialog =
modal.componentInstance as CustomFieldsBulkEditDialogComponent modal.componentInstance as CustomFieldsBulkEditDialogComponent
dialog.customFields = this.customFields dialog.customFields = (
this.customFieldsSelectionModel.items as CustomField[]
).filter((f) => f.id !== null)
dialog.fieldsToAddIds = changedCustomFields.itemsToAdd.map( dialog.fieldsToAddIds = changedCustomFields.itemsToAdd.map(
(item) => item.id (item) => item.id
) )

View File

@ -35,11 +35,9 @@
<div class="col-auto"> <div class="col-auto">
<div class="d-flex flex-wrap gap-3"> <div class="d-flex flex-wrap gap-3">
<div class="d-flex flex-wrap gap-2"> <div class="d-flex flex-wrap gap-2">
@if (permissionsService.currentUserCan(PermissionAction.View, PermissionType.Tag) && tags.length > 0) { @if (permissionsService.currentUserCan(PermissionAction.View, PermissionType.Tag) && tagSelectionModel.items.length > 0) {
<pngx-filterable-dropdown class="flex-fill fade" [class.show]="show" title="Tags" icon="tag-fill" i18n-title <pngx-filterable-dropdown class="flex-fill fade" [class.show]="show" title="Tags" icon="tag-fill" i18n-title
filterPlaceholder="Filter tags" i18n-filterPlaceholder filterPlaceholder="Filter tags" i18n-filterPlaceholder
[items]="tags"
[manyToOne]="true"
[(selectionModel)]="tagSelectionModel" [(selectionModel)]="tagSelectionModel"
(selectionModelChange)="updateRules()" (selectionModelChange)="updateRules()"
(opened)="onTagsDropdownOpen()" (opened)="onTagsDropdownOpen()"
@ -48,10 +46,9 @@
[disabled]="disabled" [disabled]="disabled"
shortcutKey="t"></pngx-filterable-dropdown> shortcutKey="t"></pngx-filterable-dropdown>
} }
@if (permissionsService.currentUserCan(PermissionAction.View, PermissionType.Correspondent) && correspondents.length > 0) { @if (permissionsService.currentUserCan(PermissionAction.View, PermissionType.Correspondent) && correspondentSelectionModel.items.length > 0) {
<pngx-filterable-dropdown class="flex-fill fade" [class.show]="show" title="Correspondent" icon="person-fill" i18n-title <pngx-filterable-dropdown class="flex-fill fade" [class.show]="show" title="Correspondent" icon="person-fill" i18n-title
filterPlaceholder="Filter correspondents" i18n-filterPlaceholder filterPlaceholder="Filter correspondents" i18n-filterPlaceholder
[items]="correspondents"
[(selectionModel)]="correspondentSelectionModel" [(selectionModel)]="correspondentSelectionModel"
(selectionModelChange)="updateRules()" (selectionModelChange)="updateRules()"
(opened)="onCorrespondentDropdownOpen()" (opened)="onCorrespondentDropdownOpen()"
@ -60,10 +57,9 @@
[disabled]="disabled" [disabled]="disabled"
shortcutKey="y"></pngx-filterable-dropdown> shortcutKey="y"></pngx-filterable-dropdown>
} }
@if (permissionsService.currentUserCan(PermissionAction.View, PermissionType.DocumentType) && documentTypes.length > 0) { @if (permissionsService.currentUserCan(PermissionAction.View, PermissionType.DocumentType) && documentTypeSelectionModel.items.length > 0) {
<pngx-filterable-dropdown class="flex-fill fade" [class.show]="show" title="Document type" icon="file-earmark-fill" i18n-title <pngx-filterable-dropdown class="flex-fill fade" [class.show]="show" title="Document type" icon="file-earmark-fill" i18n-title
filterPlaceholder="Filter document types" i18n-filterPlaceholder filterPlaceholder="Filter document types" i18n-filterPlaceholder
[items]="documentTypes"
[(selectionModel)]="documentTypeSelectionModel" [(selectionModel)]="documentTypeSelectionModel"
(selectionModelChange)="updateRules()" (selectionModelChange)="updateRules()"
(opened)="onDocumentTypeDropdownOpen()" (opened)="onDocumentTypeDropdownOpen()"
@ -72,10 +68,9 @@
[disabled]="disabled" [disabled]="disabled"
shortcutKey="u"></pngx-filterable-dropdown> shortcutKey="u"></pngx-filterable-dropdown>
} }
@if (permissionsService.currentUserCan(PermissionAction.View, PermissionType.StoragePath) && storagePaths.length > 0) { @if (permissionsService.currentUserCan(PermissionAction.View, PermissionType.StoragePath) && storagePathSelectionModel.items.length > 0) {
<pngx-filterable-dropdown class="flex-fill fade" [class.show]="show" title="Storage path" icon="folder-fill" i18n-title <pngx-filterable-dropdown class="flex-fill fade" [class.show]="show" title="Storage path" icon="folder-fill" i18n-title
filterPlaceholder="Filter storage paths" i18n-filterPlaceholder filterPlaceholder="Filter storage paths" i18n-filterPlaceholder
[items]="storagePaths"
[(selectionModel)]="storagePathSelectionModel" [(selectionModel)]="storagePathSelectionModel"
(selectionModelChange)="updateRules()" (selectionModelChange)="updateRules()"
(opened)="onStoragePathDropdownOpen()" (opened)="onStoragePathDropdownOpen()"

View File

@ -69,6 +69,7 @@ import {
FILTER_STORAGE_PATH, FILTER_STORAGE_PATH,
FILTER_TITLE, FILTER_TITLE,
FILTER_TITLE_CONTENT, FILTER_TITLE_CONTENT,
NEGATIVE_NULL_FILTER_VALUE,
} from 'src/app/data/filter-rule-type' } from 'src/app/data/filter-rule-type'
import { StoragePath } from 'src/app/data/storage-path' import { StoragePath } from 'src/app/data/storage-path'
import { Tag } from 'src/app/data/tag' import { Tag } from 'src/app/data/tag'
@ -671,9 +672,6 @@ describe('FilterEditorComponent', () => {
value: '12', value: '12',
}, },
] ]
expect(component.correspondentSelectionModel.logicalOperator).toEqual(
LogicalOperator.Or
)
expect(component.correspondentSelectionModel.intersection).toEqual( expect(component.correspondentSelectionModel.intersection).toEqual(
Intersection.Include Intersection.Include
) )
@ -681,6 +679,19 @@ describe('FilterEditorComponent', () => {
correspondents[0], correspondents[0],
]) ])
component.toggleCorrespondent(12) // coverage component.toggleCorrespondent(12) // coverage
component.filterRules = [
{
rule_type: FILTER_CORRESPONDENT,
value: NEGATIVE_NULL_FILTER_VALUE.toString(),
},
]
expect(component.correspondentSelectionModel.intersection).toEqual(
Intersection.Exclude
)
expect(component.correspondentSelectionModel.getExcludedItems()).toEqual([
{ id: NEGATIVE_NULL_FILTER_VALUE, name: 'Not assigned' },
])
})) }))
it('should ingest filter rules for has any of correspondents', fakeAsync(() => { it('should ingest filter rules for has any of correspondents', fakeAsync(() => {
@ -754,9 +765,6 @@ describe('FilterEditorComponent', () => {
value: '22', value: '22',
}, },
] ]
expect(component.documentTypeSelectionModel.logicalOperator).toEqual(
LogicalOperator.Or
)
expect(component.documentTypeSelectionModel.intersection).toEqual( expect(component.documentTypeSelectionModel.intersection).toEqual(
Intersection.Include Intersection.Include
) )
@ -764,6 +772,19 @@ describe('FilterEditorComponent', () => {
document_types[0], document_types[0],
]) ])
component.toggleDocumentType(22) // coverage component.toggleDocumentType(22) // coverage
component.filterRules = [
{
rule_type: FILTER_DOCUMENT_TYPE,
value: NEGATIVE_NULL_FILTER_VALUE.toString(),
},
]
expect(component.documentTypeSelectionModel.intersection).toEqual(
Intersection.Exclude
)
expect(component.documentTypeSelectionModel.getExcludedItems()).toEqual([
{ id: NEGATIVE_NULL_FILTER_VALUE, name: 'Not assigned' },
])
})) }))
it('should ingest filter rules for has any of document types', fakeAsync(() => { it('should ingest filter rules for has any of document types', fakeAsync(() => {
@ -780,9 +801,6 @@ describe('FilterEditorComponent', () => {
value: '23', value: '23',
}, },
] ]
expect(component.documentTypeSelectionModel.logicalOperator).toEqual(
LogicalOperator.Or
)
expect(component.documentTypeSelectionModel.intersection).toEqual( expect(component.documentTypeSelectionModel.intersection).toEqual(
Intersection.Include Intersection.Include
) )
@ -837,9 +855,6 @@ describe('FilterEditorComponent', () => {
value: '32', value: '32',
}, },
] ]
expect(component.storagePathSelectionModel.logicalOperator).toEqual(
LogicalOperator.Or
)
expect(component.storagePathSelectionModel.intersection).toEqual( expect(component.storagePathSelectionModel.intersection).toEqual(
Intersection.Include Intersection.Include
) )
@ -847,6 +862,19 @@ describe('FilterEditorComponent', () => {
storage_paths[0], storage_paths[0],
]) ])
component.toggleStoragePath(32) // coverage component.toggleStoragePath(32) // coverage
component.filterRules = [
{
rule_type: FILTER_STORAGE_PATH,
value: NEGATIVE_NULL_FILTER_VALUE.toString(),
},
]
expect(component.storagePathSelectionModel.intersection).toEqual(
Intersection.Exclude
)
expect(component.storagePathSelectionModel.getExcludedItems()).toEqual([
{ id: NEGATIVE_NULL_FILTER_VALUE, name: 'Not assigned' },
])
})) }))
it('should ingest filter rules for has any of storage paths', fakeAsync(() => { it('should ingest filter rules for has any of storage paths', fakeAsync(() => {
@ -1398,6 +1426,19 @@ describe('FilterEditorComponent', () => {
value: null, value: null,
}, },
]) ])
const excludeButton = correspondentsFilterableDropdown.queryAll(
By.css('input[value=exclude]')
)[0]
excludeButton.nativeElement.checked = true
excludeButton.triggerEventHandler('change')
fixture.detectChanges()
expect(component.filterRules).toEqual([
{
rule_type: FILTER_CORRESPONDENT,
value: NEGATIVE_NULL_FILTER_VALUE.toString(),
},
])
})) }))
it('should convert user input to correct filter rules on document type selections', fakeAsync(() => { it('should convert user input to correct filter rules on document type selections', fakeAsync(() => {
@ -1455,6 +1496,19 @@ describe('FilterEditorComponent', () => {
value: null, value: null,
}, },
]) ])
const excludeButton = docTypesFilterableDropdown.queryAll(
By.css('input[value=exclude]')
)[0]
excludeButton.nativeElement.checked = true
excludeButton.triggerEventHandler('change')
fixture.detectChanges()
expect(component.filterRules).toEqual([
{
rule_type: FILTER_DOCUMENT_TYPE,
value: NEGATIVE_NULL_FILTER_VALUE.toString(),
},
])
})) }))
it('should convert user input to correct filter rules on storage path selections', fakeAsync(() => { it('should convert user input to correct filter rules on storage path selections', fakeAsync(() => {
@ -1512,6 +1566,19 @@ describe('FilterEditorComponent', () => {
value: null, value: null,
}, },
]) ])
const excludeButton = storagePathsFilterableDropdown.queryAll(
By.css('input[value=exclude]')
)[0]
excludeButton.nativeElement.checked = true
excludeButton.triggerEventHandler('change')
fixture.detectChanges()
expect(component.filterRules).toEqual([
{
rule_type: FILTER_STORAGE_PATH,
value: NEGATIVE_NULL_FILTER_VALUE.toString(),
},
])
})) }))
it('should convert user input to correct filter rules on custom field selections', fakeAsync(() => { it('should convert user input to correct filter rules on custom field selections', fakeAsync(() => {

View File

@ -26,14 +26,12 @@ import {
switchMap, switchMap,
takeUntil, takeUntil,
} from 'rxjs/operators' } from 'rxjs/operators'
import { Correspondent } from 'src/app/data/correspondent'
import { CustomField } from 'src/app/data/custom-field' import { CustomField } from 'src/app/data/custom-field'
import { import {
CustomFieldQueryLogicalOperator, CustomFieldQueryLogicalOperator,
CustomFieldQueryOperator, CustomFieldQueryOperator,
} from 'src/app/data/custom-field-query' } from 'src/app/data/custom-field-query'
import { Document } from 'src/app/data/document' import { Document } from 'src/app/data/document'
import { DocumentType } from 'src/app/data/document-type'
import { FilterRule } from 'src/app/data/filter-rule' import { FilterRule } from 'src/app/data/filter-rule'
import { import {
FILTER_ADDED_AFTER, FILTER_ADDED_AFTER,
@ -75,9 +73,8 @@ import {
FILTER_STORAGE_PATH, FILTER_STORAGE_PATH,
FILTER_TITLE, FILTER_TITLE,
FILTER_TITLE_CONTENT, FILTER_TITLE_CONTENT,
NEGATIVE_NULL_FILTER_VALUE,
} from 'src/app/data/filter-rule-type' } from 'src/app/data/filter-rule-type'
import { StoragePath } from 'src/app/data/storage-path'
import { Tag } from 'src/app/data/tag'
import { import {
PermissionAction, PermissionAction,
PermissionType, PermissionType,
@ -251,7 +248,9 @@ export class FilterEditorComponent
case FILTER_HAS_CORRESPONDENT_ANY: case FILTER_HAS_CORRESPONDENT_ANY:
if (rule.value) { if (rule.value) {
return $localize`Correspondent: ${ return $localize`Correspondent: ${
this.correspondents.find((c) => c.id == +rule.value)?.name this.correspondentSelectionModel.items.find(
(c) => c.id == +rule.value
)?.name
}` }`
} else { } else {
return $localize`Without correspondent` return $localize`Without correspondent`
@ -261,7 +260,9 @@ export class FilterEditorComponent
case FILTER_HAS_DOCUMENT_TYPE_ANY: case FILTER_HAS_DOCUMENT_TYPE_ANY:
if (rule.value) { if (rule.value) {
return $localize`Document type: ${ return $localize`Document type: ${
this.documentTypes.find((dt) => dt.id == +rule.value)?.name this.documentTypeSelectionModel.items.find(
(dt) => dt.id == +rule.value
)?.name
}` }`
} else { } else {
return $localize`Without document type` return $localize`Without document type`
@ -271,7 +272,9 @@ export class FilterEditorComponent
case FILTER_HAS_STORAGE_PATH_ANY: case FILTER_HAS_STORAGE_PATH_ANY:
if (rule.value) { if (rule.value) {
return $localize`Storage path: ${ return $localize`Storage path: ${
this.storagePaths.find((sp) => sp.id == +rule.value)?.name this.storagePathSelectionModel.items.find(
(sp) => sp.id == +rule.value
)?.name
}` }`
} else { } else {
return $localize`Without storage path` return $localize`Without storage path`
@ -279,7 +282,7 @@ export class FilterEditorComponent
case FILTER_HAS_TAGS_ALL: case FILTER_HAS_TAGS_ALL:
return $localize`Tag: ${ return $localize`Tag: ${
this.tags.find((t) => t.id == +rule.value)?.name this.tagSelectionModel.items.find((t) => t.id == +rule.value)?.name
}` }`
case FILTER_HAS_ANY_TAG: case FILTER_HAS_ANY_TAG:
@ -326,10 +329,6 @@ export class FilterEditorComponent
@ViewChild('textFilterInput') @ViewChild('textFilterInput')
textFilterInput: ElementRef textFilterInput: ElementRef
tags: Tag[] = []
correspondents: Correspondent[] = []
documentTypes: DocumentType[] = []
storagePaths: StoragePath[] = []
customFields: CustomField[] = [] customFields: CustomField[] = []
tagDocumentCounts: SelectionDataItem[] tagDocumentCounts: SelectionDataItem[]
@ -370,7 +369,7 @@ export class FilterEditorComponent
) )
} }
tagSelectionModel = new FilterableDropdownSelectionModel() tagSelectionModel = new FilterableDropdownSelectionModel(true)
correspondentSelectionModel = new FilterableDropdownSelectionModel() correspondentSelectionModel = new FilterableDropdownSelectionModel()
documentTypeSelectionModel = new FilterableDropdownSelectionModel() documentTypeSelectionModel = new FilterableDropdownSelectionModel()
storagePathSelectionModel = new FilterableDropdownSelectionModel() storagePathSelectionModel = new FilterableDropdownSelectionModel()
@ -551,6 +550,19 @@ export class FilterEditorComponent
) )
break break
case FILTER_CORRESPONDENT: case FILTER_CORRESPONDENT:
this.correspondentSelectionModel.intersection =
rule.value == NEGATIVE_NULL_FILTER_VALUE.toString()
? Intersection.Exclude
: Intersection.Include
this.correspondentSelectionModel.set(
rule.value ? +rule.value : null,
this.correspondentSelectionModel.intersection ==
Intersection.Include
? ToggleableItemState.Selected
: ToggleableItemState.Excluded,
false
)
break
case FILTER_HAS_CORRESPONDENT_ANY: case FILTER_HAS_CORRESPONDENT_ANY:
this.correspondentSelectionModel.logicalOperator = LogicalOperator.Or this.correspondentSelectionModel.logicalOperator = LogicalOperator.Or
this.correspondentSelectionModel.intersection = Intersection.Include this.correspondentSelectionModel.intersection = Intersection.Include
@ -569,6 +581,18 @@ export class FilterEditorComponent
) )
break break
case FILTER_DOCUMENT_TYPE: case FILTER_DOCUMENT_TYPE:
this.documentTypeSelectionModel.intersection =
rule.value == NEGATIVE_NULL_FILTER_VALUE.toString()
? Intersection.Exclude
: Intersection.Include
this.documentTypeSelectionModel.set(
rule.value ? +rule.value : null,
this.documentTypeSelectionModel.intersection == Intersection.Include
? ToggleableItemState.Selected
: ToggleableItemState.Excluded,
false
)
break
case FILTER_HAS_DOCUMENT_TYPE_ANY: case FILTER_HAS_DOCUMENT_TYPE_ANY:
this.documentTypeSelectionModel.logicalOperator = LogicalOperator.Or this.documentTypeSelectionModel.logicalOperator = LogicalOperator.Or
this.documentTypeSelectionModel.intersection = Intersection.Include this.documentTypeSelectionModel.intersection = Intersection.Include
@ -587,6 +611,18 @@ export class FilterEditorComponent
) )
break break
case FILTER_STORAGE_PATH: case FILTER_STORAGE_PATH:
this.storagePathSelectionModel.intersection =
rule.value == NEGATIVE_NULL_FILTER_VALUE.toString()
? Intersection.Exclude
: Intersection.Include
this.storagePathSelectionModel.set(
rule.value ? +rule.value : null,
this.storagePathSelectionModel.intersection == Intersection.Include
? ToggleableItemState.Selected
: ToggleableItemState.Excluded,
false
)
break
case FILTER_HAS_STORAGE_PATH_ANY: case FILTER_HAS_STORAGE_PATH_ANY:
this.storagePathSelectionModel.logicalOperator = LogicalOperator.Or this.storagePathSelectionModel.logicalOperator = LogicalOperator.Or
this.storagePathSelectionModel.intersection = Intersection.Include this.storagePathSelectionModel.intersection = Intersection.Include
@ -809,9 +845,21 @@ export class FilterEditorComponent
}) })
}) })
} }
if (this.correspondentSelectionModel.isNoneSelected()) { if (
this.correspondentSelectionModel.isNoneSelected() &&
this.correspondentSelectionModel.intersection == Intersection.Include
) {
filterRules.push({ rule_type: FILTER_CORRESPONDENT, value: null }) filterRules.push({ rule_type: FILTER_CORRESPONDENT, value: null })
} else { } else {
if (
this.correspondentSelectionModel.isNoneSelected() &&
this.correspondentSelectionModel.intersection == Intersection.Exclude
) {
filterRules.push({
rule_type: FILTER_CORRESPONDENT,
value: NEGATIVE_NULL_FILTER_VALUE.toString(),
})
}
this.correspondentSelectionModel this.correspondentSelectionModel
.getSelectedItems() .getSelectedItems()
.forEach((correspondent) => { .forEach((correspondent) => {
@ -822,6 +870,7 @@ export class FilterEditorComponent
}) })
this.correspondentSelectionModel this.correspondentSelectionModel
.getExcludedItems() .getExcludedItems()
.filter((correspondent) => correspondent.id > 0)
.forEach((correspondent) => { .forEach((correspondent) => {
filterRules.push({ filterRules.push({
rule_type: FILTER_DOES_NOT_HAVE_CORRESPONDENT, rule_type: FILTER_DOES_NOT_HAVE_CORRESPONDENT,
@ -829,9 +878,21 @@ export class FilterEditorComponent
}) })
}) })
} }
if (this.documentTypeSelectionModel.isNoneSelected()) { if (
this.documentTypeSelectionModel.isNoneSelected() &&
this.documentTypeSelectionModel.intersection === Intersection.Include
) {
filterRules.push({ rule_type: FILTER_DOCUMENT_TYPE, value: null }) filterRules.push({ rule_type: FILTER_DOCUMENT_TYPE, value: null })
} else { } else {
if (
this.documentTypeSelectionModel.isNoneSelected() &&
this.documentTypeSelectionModel.intersection == Intersection.Exclude
) {
filterRules.push({
rule_type: FILTER_DOCUMENT_TYPE,
value: NEGATIVE_NULL_FILTER_VALUE.toString(),
})
}
this.documentTypeSelectionModel this.documentTypeSelectionModel
.getSelectedItems() .getSelectedItems()
.forEach((documentType) => { .forEach((documentType) => {
@ -842,6 +903,7 @@ export class FilterEditorComponent
}) })
this.documentTypeSelectionModel this.documentTypeSelectionModel
.getExcludedItems() .getExcludedItems()
.filter((documentType) => documentType.id > 0)
.forEach((documentType) => { .forEach((documentType) => {
filterRules.push({ filterRules.push({
rule_type: FILTER_DOES_NOT_HAVE_DOCUMENT_TYPE, rule_type: FILTER_DOES_NOT_HAVE_DOCUMENT_TYPE,
@ -849,9 +911,21 @@ export class FilterEditorComponent
}) })
}) })
} }
if (this.storagePathSelectionModel.isNoneSelected()) { if (
this.storagePathSelectionModel.isNoneSelected() &&
this.storagePathSelectionModel.intersection == Intersection.Include
) {
filterRules.push({ rule_type: FILTER_STORAGE_PATH, value: null }) filterRules.push({ rule_type: FILTER_STORAGE_PATH, value: null })
} else { } else {
if (
this.storagePathSelectionModel.isNoneSelected() &&
this.storagePathSelectionModel.intersection == Intersection.Exclude
) {
filterRules.push({
rule_type: FILTER_STORAGE_PATH,
value: NEGATIVE_NULL_FILTER_VALUE.toString(),
})
}
this.storagePathSelectionModel this.storagePathSelectionModel
.getSelectedItems() .getSelectedItems()
.forEach((storagePath) => { .forEach((storagePath) => {
@ -862,6 +936,7 @@ export class FilterEditorComponent
}) })
this.storagePathSelectionModel this.storagePathSelectionModel
.getExcludedItems() .getExcludedItems()
.filter((storagePath) => storagePath.id > 0)
.forEach((storagePath) => { .forEach((storagePath) => {
filterRules.push({ filterRules.push({
rule_type: FILTER_DOES_NOT_HAVE_STORAGE_PATH, rule_type: FILTER_DOES_NOT_HAVE_STORAGE_PATH,
@ -1062,7 +1137,7 @@ export class FilterEditorComponent
) { ) {
this.loadingCountTotal++ this.loadingCountTotal++
this.tagService.listAll().subscribe((result) => { this.tagService.listAll().subscribe((result) => {
this.tags = result.results this.tagSelectionModel.items = result.results
this.maybeCompleteLoading() this.maybeCompleteLoading()
}) })
} }
@ -1074,7 +1149,7 @@ export class FilterEditorComponent
) { ) {
this.loadingCountTotal++ this.loadingCountTotal++
this.correspondentService.listAll().subscribe((result) => { this.correspondentService.listAll().subscribe((result) => {
this.correspondents = result.results this.correspondentSelectionModel.items = result.results
this.maybeCompleteLoading() this.maybeCompleteLoading()
}) })
} }
@ -1086,7 +1161,7 @@ export class FilterEditorComponent
) { ) {
this.loadingCountTotal++ this.loadingCountTotal++
this.documentTypeService.listAll().subscribe((result) => { this.documentTypeService.listAll().subscribe((result) => {
this.documentTypes = result.results this.documentTypeSelectionModel.items = result.results
this.maybeCompleteLoading() this.maybeCompleteLoading()
}) })
} }
@ -1098,7 +1173,7 @@ export class FilterEditorComponent
) { ) {
this.loadingCountTotal++ this.loadingCountTotal++
this.storagePathService.listAll().subscribe((result) => { this.storagePathService.listAll().subscribe((result) => {
this.storagePaths = result.results this.storagePathSelectionModel.items = result.results
this.maybeCompleteLoading() this.maybeCompleteLoading()
}) })
} }

View File

@ -1,5 +1,7 @@
import { DataType } from './datatype' import { DataType } from './datatype'
export const NEGATIVE_NULL_FILTER_VALUE = -1
// These correspond to src/documents/models.py and changes here require a DB migration (and vice versa) // These correspond to src/documents/models.py and changes here require a DB migration (and vice versa)
export const FILTER_TITLE = 0 export const FILTER_TITLE = 0
export const FILTER_CONTENT = 1 export const FILTER_CONTENT = 1

View File

@ -50,7 +50,7 @@ describe('ObjectNamePipe', () => {
}) })
}) })
it('should return empty string if object not found', (done) => { it('should return Private string if object not found', (done) => {
const mockObjects = { const mockObjects = {
results: [{ id: 2, name: 'Object 2' }], results: [{ id: 2, name: 'Object 2' }],
count: 1, count: 1,
@ -60,7 +60,7 @@ describe('ObjectNamePipe', () => {
jest.spyOn(objectService, 'listAll').mockReturnValue(of(mockObjects)) jest.spyOn(objectService, 'listAll').mockReturnValue(of(mockObjects))
pipe.transform(1).subscribe((result) => { pipe.transform(1).subscribe((result) => {
expect(result).toBe('') expect(result).toBe('Private')
done() done()
}) })
}) })

View File

@ -35,7 +35,10 @@ export abstract class ObjectNamePipe implements PipeTransform {
return this.objectService.listAll().pipe( return this.objectService.listAll().pipe(
map((objects) => { map((objects) => {
this.objects = objects.results this.objects = objects.results
return this.objects.find((o) => o.id === obejctId)?.name || '' return (
this.objects.find((o) => o.id === obejctId)?.name ||
$localize`Private`
)
}), }),
catchError(() => of('')) catchError(() => of(''))
) )

View File

@ -156,6 +156,72 @@ describe(`Additional service tests for SavedViewService`, () => {
httpTestingController.verify() // no reload httpTestingController.verify() // no reload
}) })
it('should reload after create, delete, patch and patchMany', () => {
const reloadSpy = jest.spyOn(service, 'reload')
service
.create({
name: 'New Saved View',
show_on_dashboard: true,
show_in_sidebar: true,
sort_field: 'name',
sort_reverse: true,
filter_rules: [],
})
.subscribe()
httpTestingController
.expectOne(`${environment.apiBaseUrl}${endpoint}/`)
.flush({})
expect(reloadSpy).toHaveBeenCalled()
reloadSpy.mockClear()
httpTestingController
.expectOne(
`${environment.apiBaseUrl}${endpoint}/?page=1&page_size=100000`
)
.flush({
results: saved_views,
})
service.delete(saved_views[0]).subscribe()
httpTestingController
.expectOne(`${environment.apiBaseUrl}${endpoint}/1/`)
.flush({})
expect(reloadSpy).toHaveBeenCalled()
reloadSpy.mockClear()
httpTestingController
.expectOne(
`${environment.apiBaseUrl}${endpoint}/?page=1&page_size=100000`
)
.flush({
results: saved_views,
})
service.patch(saved_views[0], true).subscribe()
httpTestingController
.expectOne(`${environment.apiBaseUrl}${endpoint}/1/`)
.flush({})
expect(reloadSpy).toHaveBeenCalled()
httpTestingController
.expectOne(
`${environment.apiBaseUrl}${endpoint}/?page=1&page_size=100000`
)
.flush({
results: saved_views,
})
service.patchMany(saved_views).subscribe()
saved_views.forEach((saved_view) => {
const req = httpTestingController.expectOne(
`${environment.apiBaseUrl}${endpoint}/${saved_view.id}/`
)
req.flush({})
})
expect(reloadSpy).toHaveBeenCalled()
httpTestingController
.expectOne(
`${environment.apiBaseUrl}${endpoint}/?page=1&page_size=100000`
)
.flush({
results: saved_views,
})
})
beforeEach(() => { beforeEach(() => {
// Dont need to setup again // Dont need to setup again

View File

@ -602,7 +602,6 @@ export class SettingsService {
) )
} catch (error) { } catch (error) {
this.toastService.showError(errorMessage) this.toastService.showError(errorMessage)
console.log(error)
} }
this.storeSettings() this.storeSettings()
@ -614,7 +613,6 @@ export class SettingsService {
}, },
error: (e) => { error: (e) => {
this.toastService.showError(errorMessage) this.toastService.showError(errorMessage)
console.log(e)
}, },
}) })
} }
@ -636,7 +634,6 @@ export class SettingsService {
this.toastService.showError( this.toastService.showError(
'Error migrating update checking setting' 'Error migrating update checking setting'
) )
console.log(e)
}, },
}) })
} }

View File

@ -8,6 +8,7 @@ import {
FILTER_HAS_CUSTOM_FIELDS_ALL, FILTER_HAS_CUSTOM_FIELDS_ALL,
FILTER_HAS_CUSTOM_FIELDS_ANY, FILTER_HAS_CUSTOM_FIELDS_ANY,
FILTER_HAS_TAGS_ALL, FILTER_HAS_TAGS_ALL,
NEGATIVE_NULL_FILTER_VALUE,
} from '../data/filter-rule-type' } from '../data/filter-rule-type'
import { import {
filterRulesFromQueryParams, filterRulesFromQueryParams,
@ -97,6 +98,16 @@ describe('QueryParams Utils', () => {
correspondent__isnull: 1, correspondent__isnull: 1,
}) })
params = queryParamsFromFilterRules([
{
rule_type: FILTER_CORRESPONDENT,
value: NEGATIVE_NULL_FILTER_VALUE.toString(),
},
])
expect(params).toEqual({
correspondent__isnull: 0,
})
params = queryParamsFromFilterRules([ params = queryParamsFromFilterRules([
{ {
rule_type: FILTER_HAS_ANY_TAG, rule_type: FILTER_HAS_ANY_TAG,

View File

@ -10,6 +10,7 @@ import {
FILTER_HAS_CUSTOM_FIELDS_ANY, FILTER_HAS_CUSTOM_FIELDS_ANY,
FILTER_RULE_TYPES, FILTER_RULE_TYPES,
FilterRuleType, FilterRuleType,
NEGATIVE_NULL_FILTER_VALUE,
} from '../data/filter-rule-type' } from '../data/filter-rule-type'
import { ListViewState } from '../services/document-list-view.service' import { ListViewState } from '../services/document-list-view.service'
@ -113,6 +114,10 @@ export function filterRulesFromQueryParams(
rt.isnull_filtervar == filterQueryParamName rt.isnull_filtervar == filterQueryParamName
) )
const isNullRuleType = rule_type.isnull_filtervar == filterQueryParamName const isNullRuleType = rule_type.isnull_filtervar == filterQueryParamName
const nullRuleValue =
queryParams.get(filterQueryParamName) == '1'
? null
: NEGATIVE_NULL_FILTER_VALUE.toString()
const valueURIComponent: string = queryParams.get(filterQueryParamName) const valueURIComponent: string = queryParams.get(filterQueryParamName)
const filterQueryParamValues: string[] = rule_type.multi const filterQueryParamValues: string[] = rule_type.multi
? valueURIComponent.split(',') ? valueURIComponent.split(',')
@ -125,7 +130,7 @@ export function filterRulesFromQueryParams(
val = val.replace('1', 'true').replace('0', 'false') val = val.replace('1', 'true').replace('0', 'false')
return { return {
rule_type: rule_type.id, rule_type: rule_type.id,
value: isNullRuleType ? null : val, value: isNullRuleType ? nullRuleValue : val,
} }
}) })
) )
@ -143,6 +148,11 @@ export function queryParamsFromFilterRules(filterRules: FilterRule[]): Params {
let ruleType = FILTER_RULE_TYPES.find((t) => t.id == rule.rule_type) let ruleType = FILTER_RULE_TYPES.find((t) => t.id == rule.rule_type)
if (ruleType.isnull_filtervar && rule.value == null) { if (ruleType.isnull_filtervar && rule.value == null) {
params[ruleType.isnull_filtervar] = 1 params[ruleType.isnull_filtervar] = 1
} else if (
ruleType.isnull_filtervar &&
rule.value == NEGATIVE_NULL_FILTER_VALUE.toString()
) {
params[ruleType.isnull_filtervar] = 0
} else if (ruleType.multi) { } else if (ruleType.multi) {
params[ruleType.filtervar] = params[ruleType.filtervar] params[ruleType.filtervar] = params[ruleType.filtervar]
? params[ruleType.filtervar] + ',' + rule.value ? params[ruleType.filtervar] + ',' + rule.value

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -24,7 +24,7 @@
--pngx-bg-alt2: var(--bs-gray-200); // #e9ecef --pngx-bg-alt2: var(--bs-gray-200); // #e9ecef
--pngx-bg-disabled: #f7f7f7; --pngx-bg-disabled: #f7f7f7;
--pngx-focus-alpha: 0.3; --pngx-focus-alpha: 0.3;
--pngx-toast-max-width: 360px; --pngx-toast-max-width: 340px;
--bs-info: var(--pngx-bg-alt2); --bs-info: var(--pngx-bg-alt2);
--bs-info-rgb: 233, 236, 239; --bs-info-rgb: 233, 236, 239;
@media screen and (min-width: 1024px) { @media screen and (min-width: 1024px) {

View File

@ -1,5 +1,7 @@
from django.conf import settings as django_settings from django.conf import settings as django_settings
from django.contrib.auth.models import User
from documents.models import Document
from paperless.config import GeneralConfig from paperless.config import GeneralConfig
@ -25,4 +27,9 @@ def settings(request):
"domain": getattr(django_settings, "PAPERLESS_URL", request.get_host()), "domain": getattr(django_settings, "PAPERLESS_URL", request.get_host()),
"APP_TITLE": app_title, "APP_TITLE": app_title,
"APP_LOGO": app_logo, "APP_LOGO": app_logo,
"FIRST_INSTALL": User.objects.exclude(
username__in=["consumer", "AnonymousUser"],
).count()
== 0
and Document.global_objects.count() == 0,
} }

View File

@ -870,7 +870,7 @@ class BasicUserSerializer(serializers.ModelSerializer):
class NotesSerializer(serializers.ModelSerializer): class NotesSerializer(serializers.ModelSerializer):
user = BasicUserSerializer() user = BasicUserSerializer(read_only=True)
class Meta: class Meta:
model = Note model = Note
@ -893,7 +893,7 @@ class DocumentSerializer(
created_date = serializers.DateField(required=False) created_date = serializers.DateField(required=False)
page_count = SerializerMethodField() page_count = SerializerMethodField()
notes = NotesSerializer(many=True, required=False) notes = NotesSerializer(many=True, required=False, read_only=True)
custom_fields = CustomFieldInstanceSerializer( custom_fields = CustomFieldInstanceSerializer(
many=True, many=True,

View File

@ -784,10 +784,10 @@ def run_workflows(
field=field, field=field,
document=document, document=document,
).first() ).first()
if instance: if instance and args[value_field_name] is not None:
setattr(instance, value_field_name, args[value_field_name]) setattr(instance, value_field_name, args[value_field_name])
instance.save() instance.save()
else: elif not instance:
CustomFieldInstance.objects.create( CustomFieldInstance.objects.create(
**args, **args,
field=field, field=field,

View File

@ -15,6 +15,12 @@
{% endblock form_top_content %} {% endblock form_top_content %}
{% block form_content %} {% block form_content %}
{% if FIRST_INSTALL %}
<script type="text/javascript">
// forward to the signup page if no users exist
window.location.href = "{{ signup_url }}";
</script>
{% endif %}
{% if not DISABLE_REGULAR_LOGIN %} {% if not DISABLE_REGULAR_LOGIN %}
{% translate "Username" as i18n_username %} {% translate "Username" as i18n_username %}
{% translate "Password" as i18n_password %} {% translate "Password" as i18n_password %}

View File

@ -6,12 +6,19 @@
{% endblock head_title %} {% endblock head_title %}
{% block form_top_content %} {% block form_top_content %}
<p> {% if not FIRST_INSTALL %}
{% blocktrans %}Already have an account? <a href="{{ login_url }}">Sign in</a>{% endblocktrans %} <p>
</p> {% blocktrans %}Already have an account? <a href="{{ login_url }}">Sign in</a>{% endblocktrans %}
</p>
{% endif %}
{% endblock form_top_content %} {% endblock form_top_content %}
{% block form_content %} {% block form_content %}
{% if FIRST_INSTALL %}
<p>
{% blocktrans %}Note: This is the first user account for this installation and will be granted superuser privileges.{% endblocktrans %}
</p>
{% endif %}
{% translate "Username" as i18n_username %} {% translate "Username" as i18n_username %}
{% translate "Email (optional)" as i18n_email %} {% translate "Email (optional)" as i18n_email %}
{% translate "Password" as i18n_password1 %} {% translate "Password" as i18n_password1 %}
@ -42,29 +49,31 @@
{% endblock form_content %} {% endblock form_content %}
{% block after_form_content %} {% block after_form_content %}
{% load allauth socialaccount %} {% if not FIRST_INSTALL %}
{% get_providers as socialaccount_providers %} {% load allauth socialaccount %}
{% if socialaccount_providers %} {% get_providers as socialaccount_providers %}
{% if not DISABLE_REGULAR_LOGIN %} {% if socialaccount_providers %}
<p class="mt-3">{% translate "or sign in via" %}</p> {% if not DISABLE_REGULAR_LOGIN %}
{% endif %} <p class="mt-3">{% translate "or sign in via" %}</p>
<ul class="m-0 p-0"> {% endif %}
{% for provider in socialaccount_providers %} <ul class="m-0 p-0">
{% if provider.id == "openid" %} {% for provider in socialaccount_providers %}
{% for brand in provider.get_brands %} {% if provider.id == "openid" %}
{% provider_login_url provider openid=brand.openid_url process=process as href %} {% for brand in provider.get_brands %}
<li class="d-grid mt-3"><a class="btn btn-secondary" href="{{ href }}">{{ brand.name }}</a></li> {% provider_login_url provider openid=brand.openid_url process=process as href %}
{% endfor %} <li class="d-grid mt-3"><a class="btn btn-secondary" href="{{ href }}">{{ brand.name }}</a></li>
{% else %} {% endfor %}
{% provider_login_url provider process=process scope=scope auth_params=auth_params as href %} {% else %}
<li class="d-grid mt-3"> {% provider_login_url provider process=process scope=scope auth_params=auth_params as href %}
<form class="d-grid" method="POST" action="{{ href }}"> <li class="d-grid mt-3">
{% csrf_token %} <form class="d-grid" method="POST" action="{{ href }}">
<button type="submit" class="btn btn-secondary">{{ provider.name }}</button> {% csrf_token %}
</form> <button type="submit" class="btn btn-secondary">{{ provider.name }}</button>
</li> </form>
{% endif %} </li>
{% endfor %} {% endif %}
</ul> {% endfor %}
{% endif %} </ul>
{% endif %}
{% endif %}
{% endblock after_form_content %} {% endblock after_form_content %}

View File

@ -211,7 +211,7 @@ class TestBulkEditAPI(DirectoriesMixin, APITestCase):
def test_api_modify_tags_not_provided(self, m): def test_api_modify_tags_not_provided(self, m):
""" """
GIVEN: GIVEN:
- API data to modify tags is missing modify_tags field - API data to modify tags is missing remove_tags field
WHEN: WHEN:
- API to edit tags is called - API to edit tags is called
THEN: THEN:

View File

@ -3,7 +3,7 @@ msgstr ""
"Project-Id-Version: paperless-ngx\n" "Project-Id-Version: paperless-ngx\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-03-11 13:33-0700\n" "POT-Creation-Date: 2025-03-11 13:33-0700\n"
"PO-Revision-Date: 2025-03-14 17:27\n" "PO-Revision-Date: 2025-03-20 12:12\n"
"Last-Translator: \n" "Last-Translator: \n"
"Language-Team: Catalan\n" "Language-Team: Catalan\n"
"Language: ca_ES\n" "Language: ca_ES\n"

View File

@ -3,7 +3,7 @@ msgstr ""
"Project-Id-Version: paperless-ngx\n" "Project-Id-Version: paperless-ngx\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-03-11 13:33-0700\n" "POT-Creation-Date: 2025-03-11 13:33-0700\n"
"PO-Revision-Date: 2025-03-11 20:35\n" "PO-Revision-Date: 2025-03-18 12:12\n"
"Last-Translator: \n" "Last-Translator: \n"
"Language-Team: German\n" "Language-Team: German\n"
"Language: de_DE\n" "Language: de_DE\n"

View File

@ -2,7 +2,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: paperless-ngx\n" "Project-Id-Version: paperless-ngx\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-03-11 13:33-0700\n" "POT-Creation-Date: 2025-03-26 21:04-0700\n"
"PO-Revision-Date: 2022-02-17 04:17\n" "PO-Revision-Date: 2022-02-17 04:17\n"
"Last-Translator: \n" "Last-Translator: \n"
"Language-Team: English\n" "Language-Team: English\n"
@ -1235,28 +1235,28 @@ msgstr ""
msgid "Don't have an account yet? <a href=\"%(signup_url)s\">Sign up</a>" msgid "Don't have an account yet? <a href=\"%(signup_url)s\">Sign up</a>"
msgstr "" msgstr ""
#: documents/templates/account/login.html:19 #: documents/templates/account/login.html:25
#: documents/templates/account/signup.html:15 #: documents/templates/account/signup.html:22
#: documents/templates/socialaccount/signup.html:13 #: documents/templates/socialaccount/signup.html:13
msgid "Username" msgid "Username"
msgstr "" msgstr ""
#: documents/templates/account/login.html:20 #: documents/templates/account/login.html:26
#: documents/templates/account/signup.html:17 #: documents/templates/account/signup.html:24
msgid "Password" msgid "Password"
msgstr "" msgstr ""
#: documents/templates/account/login.html:30 #: documents/templates/account/login.html:36
#: documents/templates/mfa/authenticate.html:23 #: documents/templates/mfa/authenticate.html:23
msgid "Sign in" msgid "Sign in"
msgstr "" msgstr ""
#: documents/templates/account/login.html:34 #: documents/templates/account/login.html:40
msgid "Forgot your password?" msgid "Forgot your password?"
msgstr "" msgstr ""
#: documents/templates/account/login.html:45 #: documents/templates/account/login.html:51
#: documents/templates/account/signup.html:49 #: documents/templates/account/signup.html:57
msgid "or sign in via" msgid "or sign in via"
msgstr "" msgstr ""
@ -1335,21 +1335,27 @@ msgstr ""
msgid "Paperless-ngx sign up" msgid "Paperless-ngx sign up"
msgstr "" msgstr ""
#: documents/templates/account/signup.html:10 #: documents/templates/account/signup.html:11
#, python-format #, python-format
msgid "Already have an account? <a href=\"%(login_url)s\">Sign in</a>" msgid "Already have an account? <a href=\"%(login_url)s\">Sign in</a>"
msgstr "" msgstr ""
#: documents/templates/account/signup.html:16 #: documents/templates/account/signup.html:19
msgid ""
"Note: This is the first user account for this installation and will be "
"granted superuser privileges."
msgstr ""
#: documents/templates/account/signup.html:23
#: documents/templates/socialaccount/signup.html:14 #: documents/templates/socialaccount/signup.html:14
msgid "Email (optional)" msgid "Email (optional)"
msgstr "" msgstr ""
#: documents/templates/account/signup.html:18 #: documents/templates/account/signup.html:25
msgid "Password (again)" msgid "Password (again)"
msgstr "" msgstr ""
#: documents/templates/account/signup.html:36 #: documents/templates/account/signup.html:43
#: documents/templates/socialaccount/signup.html:27 #: documents/templates/socialaccount/signup.html:27
msgid "Sign up" msgid "Sign up"
msgstr "" msgstr ""
@ -1578,139 +1584,139 @@ msgstr ""
msgid "paperless application settings" msgid "paperless application settings"
msgstr "" msgstr ""
#: paperless/settings.py:723 #: paperless/settings.py:722
msgid "English (US)" msgid "English (US)"
msgstr "" msgstr ""
#: paperless/settings.py:724 #: paperless/settings.py:723
msgid "Arabic" msgid "Arabic"
msgstr "" msgstr ""
#: paperless/settings.py:725 #: paperless/settings.py:724
msgid "Afrikaans" msgid "Afrikaans"
msgstr "" msgstr ""
#: paperless/settings.py:726 #: paperless/settings.py:725
msgid "Belarusian" msgid "Belarusian"
msgstr "" msgstr ""
#: paperless/settings.py:727 #: paperless/settings.py:726
msgid "Bulgarian" msgid "Bulgarian"
msgstr "" msgstr ""
#: paperless/settings.py:728 #: paperless/settings.py:727
msgid "Catalan" msgid "Catalan"
msgstr "" msgstr ""
#: paperless/settings.py:729 #: paperless/settings.py:728
msgid "Czech" msgid "Czech"
msgstr "" msgstr ""
#: paperless/settings.py:730 #: paperless/settings.py:729
msgid "Danish" msgid "Danish"
msgstr "" msgstr ""
#: paperless/settings.py:731 #: paperless/settings.py:730
msgid "German" msgid "German"
msgstr "" msgstr ""
#: paperless/settings.py:732 #: paperless/settings.py:731
msgid "Greek" msgid "Greek"
msgstr "" msgstr ""
#: paperless/settings.py:733 #: paperless/settings.py:732
msgid "English (GB)" msgid "English (GB)"
msgstr "" msgstr ""
#: paperless/settings.py:734 #: paperless/settings.py:733
msgid "Spanish" msgid "Spanish"
msgstr "" msgstr ""
#: paperless/settings.py:735 #: paperless/settings.py:734
msgid "Finnish" msgid "Finnish"
msgstr "" msgstr ""
#: paperless/settings.py:736 #: paperless/settings.py:735
msgid "French" msgid "French"
msgstr "" msgstr ""
#: paperless/settings.py:737 #: paperless/settings.py:736
msgid "Hungarian" msgid "Hungarian"
msgstr "" msgstr ""
#: paperless/settings.py:738 #: paperless/settings.py:737
msgid "Italian" msgid "Italian"
msgstr "" msgstr ""
#: paperless/settings.py:739 #: paperless/settings.py:738
msgid "Japanese" msgid "Japanese"
msgstr "" msgstr ""
#: paperless/settings.py:740 #: paperless/settings.py:739
msgid "Korean" msgid "Korean"
msgstr "" msgstr ""
#: paperless/settings.py:741 #: paperless/settings.py:740
msgid "Luxembourgish" msgid "Luxembourgish"
msgstr "" msgstr ""
#: paperless/settings.py:742 #: paperless/settings.py:741
msgid "Norwegian" msgid "Norwegian"
msgstr "" msgstr ""
#: paperless/settings.py:743 #: paperless/settings.py:742
msgid "Dutch" msgid "Dutch"
msgstr "" msgstr ""
#: paperless/settings.py:744 #: paperless/settings.py:743
msgid "Polish" msgid "Polish"
msgstr "" msgstr ""
#: paperless/settings.py:745 #: paperless/settings.py:744
msgid "Portuguese (Brazil)" msgid "Portuguese (Brazil)"
msgstr "" msgstr ""
#: paperless/settings.py:746 #: paperless/settings.py:745
msgid "Portuguese" msgid "Portuguese"
msgstr "" msgstr ""
#: paperless/settings.py:747 #: paperless/settings.py:746
msgid "Romanian" msgid "Romanian"
msgstr "" msgstr ""
#: paperless/settings.py:748 #: paperless/settings.py:747
msgid "Russian" msgid "Russian"
msgstr "" msgstr ""
#: paperless/settings.py:749 #: paperless/settings.py:748
msgid "Slovak" msgid "Slovak"
msgstr "" msgstr ""
#: paperless/settings.py:750 #: paperless/settings.py:749
msgid "Slovenian" msgid "Slovenian"
msgstr "" msgstr ""
#: paperless/settings.py:751 #: paperless/settings.py:750
msgid "Serbian" msgid "Serbian"
msgstr "" msgstr ""
#: paperless/settings.py:752 #: paperless/settings.py:751
msgid "Swedish" msgid "Swedish"
msgstr "" msgstr ""
#: paperless/settings.py:753 #: paperless/settings.py:752
msgid "Turkish" msgid "Turkish"
msgstr "" msgstr ""
#: paperless/settings.py:754 #: paperless/settings.py:753
msgid "Ukrainian" msgid "Ukrainian"
msgstr "" msgstr ""
#: paperless/settings.py:755 #: paperless/settings.py:754
msgid "Chinese Simplified" msgid "Chinese Simplified"
msgstr "" msgstr ""
#: paperless/settings.py:756 #: paperless/settings.py:755
msgid "Chinese Traditional" msgid "Chinese Traditional"
msgstr "" msgstr ""

View File

@ -3,7 +3,7 @@ msgstr ""
"Project-Id-Version: paperless-ngx\n" "Project-Id-Version: paperless-ngx\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-03-11 13:33-0700\n" "POT-Creation-Date: 2025-03-11 13:33-0700\n"
"PO-Revision-Date: 2025-03-11 20:35\n" "PO-Revision-Date: 2025-03-18 00:32\n"
"Last-Translator: \n" "Last-Translator: \n"
"Language-Team: Indonesian\n" "Language-Team: Indonesian\n"
"Language: id_ID\n" "Language: id_ID\n"
@ -274,51 +274,51 @@ msgstr "Kartu Besar"
#: documents/models.py:384 #: documents/models.py:384
msgid "Title" msgid "Title"
msgstr "" msgstr "Judul"
#: documents/models.py:385 documents/models.py:942 #: documents/models.py:385 documents/models.py:942
msgid "Created" msgid "Created"
msgstr "" msgstr "Dibuat"
#: documents/models.py:386 documents/models.py:941 #: documents/models.py:386 documents/models.py:941
msgid "Added" msgid "Added"
msgstr "" msgstr "Ditambahkan"
#: documents/models.py:387 #: documents/models.py:387
msgid "Tags" msgid "Tags"
msgstr "" msgstr "Label"
#: documents/models.py:388 #: documents/models.py:388
msgid "Correspondent" msgid "Correspondent"
msgstr "" msgstr "Koresponden"
#: documents/models.py:389 #: documents/models.py:389
msgid "Document Type" msgid "Document Type"
msgstr "" msgstr "Tipe Dokumen"
#: documents/models.py:390 #: documents/models.py:390
msgid "Storage Path" msgid "Storage Path"
msgstr "" msgstr "Lokasi Penyimpanan"
#: documents/models.py:391 #: documents/models.py:391
msgid "Note" msgid "Note"
msgstr "" msgstr "Catatan"
#: documents/models.py:392 #: documents/models.py:392
msgid "Owner" msgid "Owner"
msgstr "" msgstr "Pemilik"
#: documents/models.py:393 #: documents/models.py:393
msgid "Shared" msgid "Shared"
msgstr "" msgstr "Dibagikan"
#: documents/models.py:394 #: documents/models.py:394
msgid "ASN" msgid "ASN"
msgstr "" msgstr "ASN"
#: documents/models.py:395 #: documents/models.py:395
msgid "Pages" msgid "Pages"
msgstr "" msgstr "Halaman"
#: documents/models.py:401 #: documents/models.py:401
msgid "show on dashboard" msgid "show on dashboard"
@ -526,7 +526,7 @@ msgstr "tidak memiliki area khusus"
#: documents/models.py:489 #: documents/models.py:489
msgid "custom fields query" msgid "custom fields query"
msgstr "" msgstr "kueri bidang khusus"
#: documents/models.py:490 #: documents/models.py:490
msgid "created to" msgid "created to"
@ -566,15 +566,15 @@ msgstr "saring aturan"
#: documents/models.py:534 #: documents/models.py:534
msgid "Auto Task" msgid "Auto Task"
msgstr "" msgstr "Tugas Otomatis"
#: documents/models.py:535 #: documents/models.py:535
msgid "Scheduled Task" msgid "Scheduled Task"
msgstr "" msgstr "Tugas Terjadwal"
#: documents/models.py:536 #: documents/models.py:536
msgid "Manual Task" msgid "Manual Task"
msgstr "" msgstr "Tugas Manual"
#: documents/models.py:539 #: documents/models.py:539
msgid "Consume File" msgid "Consume File"
@ -666,7 +666,7 @@ msgstr "Data yang dikembalikan dari tugas"
#: documents/models.py:614 #: documents/models.py:614
msgid "Task Type" msgid "Task Type"
msgstr "" msgstr "Tipe Tugas"
#: documents/models.py:615 #: documents/models.py:615
msgid "The type of task that was run" msgid "The type of task that was run"
@ -746,7 +746,7 @@ msgstr "Tautan Dokumen"
#: documents/models.py:736 #: documents/models.py:736
msgid "Select" msgid "Select"
msgstr "" msgstr "Pilih"
#: documents/models.py:748 #: documents/models.py:748
msgid "data type" msgid "data type"
@ -754,7 +754,7 @@ msgstr "jenis data"
#: documents/models.py:755 #: documents/models.py:755
msgid "extra data" msgid "extra data"
msgstr "" msgstr "data ekstra"
#: documents/models.py:759 #: documents/models.py:759
msgid "Extra data for the custom field, such as select options" msgid "Extra data for the custom field, such as select options"
@ -790,7 +790,7 @@ msgstr "Dokumen diperbarui"
#: documents/models.py:932 #: documents/models.py:932
msgid "Scheduled" msgid "Scheduled"
msgstr "" msgstr "Dijadwalkan"
#: documents/models.py:935 #: documents/models.py:935
msgid "Consume Folder" msgid "Consume Folder"
@ -806,15 +806,15 @@ msgstr "Pengambilan Surat"
#: documents/models.py:938 #: documents/models.py:938
msgid "Web UI" msgid "Web UI"
msgstr "" msgstr "Antarmuka Web"
#: documents/models.py:943 #: documents/models.py:943
msgid "Modified" msgid "Modified"
msgstr "" msgstr "Dimodifikasi"
#: documents/models.py:944 #: documents/models.py:944
msgid "Custom Field" msgid "Custom Field"
msgstr "" msgstr "Kolom Khusus"
#: documents/models.py:947 #: documents/models.py:947
msgid "Workflow Trigger Type" msgid "Workflow Trigger Type"
@ -982,7 +982,7 @@ msgstr "Surel"
#: documents/models.py:1176 #: documents/models.py:1176
msgid "Webhook" msgid "Webhook"
msgstr "" msgstr "Webhook"
#: documents/models.py:1180 #: documents/models.py:1180
msgid "Workflow Action Type" msgid "Workflow Action Type"
@ -1114,11 +1114,11 @@ msgstr "hapus semua kolom khusus"
#: documents/models.py:1395 #: documents/models.py:1395
msgid "email" msgid "email"
msgstr "" msgstr "surel"
#: documents/models.py:1404 #: documents/models.py:1404
msgid "webhook" msgid "webhook"
msgstr "" msgstr "webhook"
#: documents/models.py:1408 #: documents/models.py:1408
msgid "workflow action" msgid "workflow action"
@ -1146,7 +1146,7 @@ msgstr "diaktifkan"
#: documents/models.py:1445 #: documents/models.py:1445
msgid "workflow" msgid "workflow"
msgstr "" msgstr "alur kerja"
#: documents/models.py:1449 #: documents/models.py:1449
msgid "workflow trigger type" msgid "workflow trigger type"
@ -1188,15 +1188,15 @@ msgstr ""
#: documents/templates/account/account_inactive.html:9 #: documents/templates/account/account_inactive.html:9
msgid "Account inactive." msgid "Account inactive."
msgstr "" msgstr "Akun tidak aktif."
#: documents/templates/account/account_inactive.html:14 #: documents/templates/account/account_inactive.html:14
msgid "This account is inactive." msgid "This account is inactive."
msgstr "" msgstr "Akun ini tidak aktif."
#: documents/templates/account/account_inactive.html:16 #: documents/templates/account/account_inactive.html:16
msgid "Return to login" msgid "Return to login"
msgstr "" msgstr "Kembali ke halaman masuk"
#: documents/templates/account/email/base_message.txt:1 #: documents/templates/account/email/base_message.txt:1
#, python-format #, python-format
@ -1358,11 +1358,11 @@ msgstr ""
#: documents/templates/mfa/authenticate.html:17 #: documents/templates/mfa/authenticate.html:17
msgid "Code" msgid "Code"
msgstr "" msgstr "Kode"
#: documents/templates/mfa/authenticate.html:24 #: documents/templates/mfa/authenticate.html:24
msgid "Cancel" msgid "Cancel"
msgstr "" msgstr "Batal"
#: documents/templates/paperless-ngx/base.html:58 #: documents/templates/paperless-ngx/base.html:58
msgid "Share link was not found." msgid "Share link was not found."
@ -1470,7 +1470,7 @@ msgstr ""
#: paperless/models.py:62 #: paperless/models.py:62
msgid "none" msgid "none"
msgstr "" msgstr "tidak ada"
#: paperless/models.py:70 #: paperless/models.py:70
msgid "LeaveColorUnchanged" msgid "LeaveColorUnchanged"
@ -1486,7 +1486,7 @@ msgstr ""
#: paperless/models.py:73 #: paperless/models.py:73
msgid "Gray" msgid "Gray"
msgstr "" msgstr "Abu-abu"
#: paperless/models.py:74 #: paperless/models.py:74
msgid "CMYK" msgid "CMYK"
@ -1566,7 +1566,7 @@ msgstr "Arab"
#: paperless/settings.py:725 #: paperless/settings.py:725
msgid "Afrikaans" msgid "Afrikaans"
msgstr "" msgstr "Bahasa Afrika"
#: paperless/settings.py:726 #: paperless/settings.py:726
msgid "Belarusian" msgid "Belarusian"
@ -1574,7 +1574,7 @@ msgstr "Belarusia"
#: paperless/settings.py:727 #: paperless/settings.py:727
msgid "Bulgarian" msgid "Bulgarian"
msgstr "" msgstr "Bahasa Bulgaria"
#: paperless/settings.py:728 #: paperless/settings.py:728
msgid "Catalan" msgid "Catalan"
@ -1622,11 +1622,11 @@ msgstr "Italia"
#: paperless/settings.py:739 #: paperless/settings.py:739
msgid "Japanese" msgid "Japanese"
msgstr "" msgstr "Bahasa Jepang"
#: paperless/settings.py:740 #: paperless/settings.py:740
msgid "Korean" msgid "Korean"
msgstr "" msgstr "Bahasa Korea"
#: paperless/settings.py:741 #: paperless/settings.py:741
msgid "Luxembourgish" msgid "Luxembourgish"

Some files were not shown because too many files have changed in this diff Show More