mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-09-03 00:17:16 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e51c7b0957 | ||
|
|
1e5afe0968 | ||
|
|
78ea120e0e | ||
|
|
6b2a5e1a2e | ||
|
|
bb88453a5f | ||
|
|
135f9f6251 | ||
|
|
1c0fbeb6f3 | ||
|
|
1ba1f2b9c2 | ||
|
|
07f1a356f8 | ||
|
|
8d41d31bd7 | ||
|
|
351892bbab | ||
|
|
5d6ea11828 | ||
|
|
c5765a50a1 | ||
|
|
c2a9532b8f | ||
|
|
713c857a08 | ||
|
|
912c6eb52e | ||
|
|
73ef14f37a | ||
|
|
d78754bff1 | ||
|
|
5c5b1ee6b5 | ||
|
|
08f2f4bfe2 | ||
|
|
f993462973 | ||
|
|
ae70b8d60f | ||
|
|
38db6b51db | ||
|
|
31e9f4272c | ||
|
|
b8659c1af3 | ||
|
|
741115b36b | ||
|
|
1211db5cbb |
@@ -1,5 +1,26 @@
|
|||||||
# Changelog
|
# Changelog
|
||||||
|
|
||||||
|
## paperless-ngx 3.1.2
|
||||||
|
|
||||||
|
### Bug Fixes
|
||||||
|
|
||||||
|
- Fix: fix dark mode select disabled color, ensure disabled cursor on display mode dropdown [@shamoon](https://github.com/shamoon) ([#13881](https://github.com/paperless-ngx/paperless-ngx/pull/13881))
|
||||||
|
- Fix: add disable to the drag-drop list component [@shamoon](https://github.com/shamoon) ([#13880](https://github.com/paperless-ngx/paperless-ngx/pull/13880))
|
||||||
|
|
||||||
|
### Documentation
|
||||||
|
|
||||||
|
- Chore: update screenshots for v3+ [@shamoon](https://github.com/shamoon) ([#13883](https://github.com/paperless-ngx/paperless-ngx/pull/13883))
|
||||||
|
|
||||||
|
### All App Changes
|
||||||
|
|
||||||
|
<details>
|
||||||
|
<summary>2 changes</summary>
|
||||||
|
|
||||||
|
- Fix: fix dark mode select disabled color, ensure disabled cursor on display mode dropdown [@shamoon](https://github.com/shamoon) ([#13881](https://github.com/paperless-ngx/paperless-ngx/pull/13881))
|
||||||
|
- Fix: add disable to the drag-drop list component [@shamoon](https://github.com/shamoon) ([#13880](https://github.com/paperless-ngx/paperless-ngx/pull/13880))
|
||||||
|
|
||||||
|
</details>
|
||||||
|
|
||||||
## paperless-ngx 3.1.1
|
## paperless-ngx 3.1.1
|
||||||
|
|
||||||
### Bug Fixes
|
### Bug Fixes
|
||||||
|
|||||||
@@ -2088,6 +2088,12 @@ password. All of these options come from their similarly-named [Django settings]
|
|||||||
|
|
||||||
Defaults to "always".
|
Defaults to "always".
|
||||||
|
|
||||||
|
#### [`PAPERLESS_REMOTE_OCR_ALLOW_INTERNAL_ENDPOINTS=<bool>`](#PAPERLESS_REMOTE_OCR_ALLOW_INTERNAL_ENDPOINTS) {#PAPERLESS_REMOTE_OCR_ALLOW_INTERNAL_ENDPOINTS}
|
||||||
|
|
||||||
|
: If set to false, Paperless blocks remote OCR endpoint URLs that resolve to non-public addresses (e.g., localhost, etc).
|
||||||
|
|
||||||
|
Defaults to True.
|
||||||
|
|
||||||
## AI {#ai}
|
## AI {#ai}
|
||||||
|
|
||||||
#### [`PAPERLESS_AI_ENABLED=<bool>`](#PAPERLESS_AI_ENABLED) {#PAPERLESS_AI_ENABLED}
|
#### [`PAPERLESS_AI_ENABLED=<bool>`](#PAPERLESS_AI_ENABLED) {#PAPERLESS_AI_ENABLED}
|
||||||
|
|||||||
+2
-2
@@ -47,7 +47,7 @@ dependencies = [
|
|||||||
"httpx-oauth~=0.17",
|
"httpx-oauth~=0.17",
|
||||||
"ijson>=3.5.1",
|
"ijson>=3.5.1",
|
||||||
"imap-tools~=1.14.0",
|
"imap-tools~=1.14.0",
|
||||||
"jinja2~=3.1.5",
|
"jinja2~=3.1.6",
|
||||||
"langdetect~=1.0.9",
|
"langdetect~=1.0.9",
|
||||||
"llama-index-core>=0.14.23",
|
"llama-index-core>=0.14.23",
|
||||||
"llama-index-embeddings-huggingface>=0.6.1",
|
"llama-index-embeddings-huggingface>=0.6.1",
|
||||||
@@ -247,7 +247,7 @@ per-file-ignores."src/documents/models.py" = [
|
|||||||
isort.force-single-line = true
|
isort.force-single-line = true
|
||||||
|
|
||||||
[tool.codespell]
|
[tool.codespell]
|
||||||
ignore-words-list = "criterias,afterall,valeu,ureue,equest,ure,assertIn,Oktober,commitish"
|
ignore-words-list = "criterias,afterall,valeu,ureue,equest,ure,assertIn,Oktober,commitish,NIN,nin"
|
||||||
skip = """\
|
skip = """\
|
||||||
src-ui/src/locale/*,src-ui/pnpm-lock.yaml,src-ui/e2e/*,src/paperless_mail/tests/samples/*,src/paperless/tests/samples\
|
src-ui/src/locale/*,src-ui/pnpm-lock.yaml,src-ui/e2e/*,src/paperless_mail/tests/samples/*,src/paperless/tests/samples\
|
||||||
/mail/*,src/documents/tests/samples/*,*.po,*.json\
|
/mail/*,src/documents/tests/samples/*,*.po,*.json\
|
||||||
|
|||||||
+201
-205
File diff suppressed because it is too large
Load Diff
+13
-13
@@ -15,14 +15,14 @@
|
|||||||
},
|
},
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@angular/cdk": "^22.1.1",
|
"@angular/cdk": "^22.1.4",
|
||||||
"@angular/common": "~22.1.1",
|
"@angular/common": "~22.1.3",
|
||||||
"@angular/compiler": "~22.1.1",
|
"@angular/compiler": "~22.1.3",
|
||||||
"@angular/core": "~22.1.1",
|
"@angular/core": "~22.1.3",
|
||||||
"@angular/forms": "~22.1.1",
|
"@angular/forms": "~22.1.3",
|
||||||
"@angular/localize": "~22.1.1",
|
"@angular/localize": "~22.1.3",
|
||||||
"@angular/platform-browser": "~22.1.1",
|
"@angular/platform-browser": "~22.1.3",
|
||||||
"@angular/router": "~22.1.1",
|
"@angular/router": "~22.1.3",
|
||||||
"@ng-bootstrap/ng-bootstrap": "^21.0.0",
|
"@ng-bootstrap/ng-bootstrap": "^21.0.0",
|
||||||
"@ng-select/ng-select": "~23.6.0",
|
"@ng-select/ng-select": "~23.6.0",
|
||||||
"@ngneat/dirty-check-forms": "^3.0.3",
|
"@ngneat/dirty-check-forms": "^3.0.3",
|
||||||
@@ -45,16 +45,16 @@
|
|||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@angular-builders/jest": "^22.0.1",
|
"@angular-builders/jest": "^22.0.1",
|
||||||
"@angular-devkit/core": "^22.1.3",
|
"@angular-devkit/core": "^22.1.6",
|
||||||
"@angular-devkit/schematics": "^22.1.3",
|
"@angular-devkit/schematics": "^22.1.6",
|
||||||
"@angular-eslint/builder": "22.1.0",
|
"@angular-eslint/builder": "22.1.0",
|
||||||
"@angular-eslint/eslint-plugin": "22.1.0",
|
"@angular-eslint/eslint-plugin": "22.1.0",
|
||||||
"@angular-eslint/eslint-plugin-template": "22.1.0",
|
"@angular-eslint/eslint-plugin-template": "22.1.0",
|
||||||
"@angular-eslint/schematics": "22.1.0",
|
"@angular-eslint/schematics": "22.1.0",
|
||||||
"@angular-eslint/template-parser": "22.1.0",
|
"@angular-eslint/template-parser": "22.1.0",
|
||||||
"@angular/build": "22.1.3",
|
"@angular/build": "22.1.6",
|
||||||
"@angular/cli": "22.1.3",
|
"@angular/cli": "22.1.6",
|
||||||
"@angular/compiler-cli": "~22.1.1",
|
"@angular/compiler-cli": "~22.1.3",
|
||||||
"@playwright/test": "^1.62.1",
|
"@playwright/test": "^1.62.1",
|
||||||
"@types/jest": "^30.0.0",
|
"@types/jest": "^30.0.0",
|
||||||
"@types/node": "^26.2.0",
|
"@types/node": "^26.2.0",
|
||||||
|
|||||||
Generated
+437
-629
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,7 @@
|
|||||||
i18n-title
|
i18n-title
|
||||||
info="Tasks shows detailed information about document consumption and system tasks."
|
info="Tasks shows detailed information about document consumption and system tasks."
|
||||||
i18n-info
|
i18n-info
|
||||||
|
[loading]="loading() && pagedTasks().length === 0"
|
||||||
>
|
>
|
||||||
<div class="btn-toolbar col col-md-auto align-items-center gap-2">
|
<div class="btn-toolbar col col-md-auto align-items-center gap-2">
|
||||||
<button class="btn btn-sm btn-outline-secondary me-2" (click)="clearSelection()" [hidden]="selectedTasks.size === 0">
|
<button class="btn btn-sm btn-outline-secondary me-2" (click)="clearSelection()" [hidden]="selectedTasks.size === 0">
|
||||||
@@ -21,11 +22,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</pngx-page-header>
|
</pngx-page-header>
|
||||||
|
|
||||||
@if (loading() && pagedTasks().length === 0) {
|
|
||||||
<div class="spinner-border spinner-border-sm fw-normal ms-2 me-auto" role="status"></div>
|
|
||||||
<div class="visually-hidden" i18n>Loading...</div>
|
|
||||||
}
|
|
||||||
|
|
||||||
<div class="task-controls mb-3 gap-3 btn-toolbar align-items-center" role="toolbar">
|
<div class="task-controls mb-3 gap-3 btn-toolbar align-items-center" role="toolbar">
|
||||||
<div class="task-view-scope btn-group btn-group-sm" role="group">
|
<div class="task-view-scope btn-group btn-group-sm" role="group">
|
||||||
<input
|
<input
|
||||||
@@ -285,6 +281,6 @@
|
|||||||
<ng-container [ngTemplateOutlet]="tasksTemplate" [ngTemplateOutletContext]="{tasks: tasksForSection(section), section: section}"></ng-container>
|
<ng-container [ngTemplateOutlet]="tasksTemplate" [ngTemplateOutletContext]="{tasks: tasksForSection(section), section: section}"></ng-container>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
} @else {
|
} @else if (!loading() || pagedTasks().length > 0) {
|
||||||
<div class="alert alert-secondary fst-italic" i18n>No tasks match the current filters.</div>
|
<div class="alert alert-secondary fst-italic" i18n>No tasks match the current filters.</div>
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,6 +41,8 @@ export class TrashComponent
|
|||||||
private modalService = inject(NgbModal)
|
private modalService = inject(NgbModal)
|
||||||
private settingsService = inject(SettingsService)
|
private settingsService = inject(SettingsService)
|
||||||
private router = inject(Router)
|
private router = inject(Router)
|
||||||
|
private readonly emptyTrashDelaySetting =
|
||||||
|
this.settingsService.getSignal<number>(SETTINGS_KEYS.EMPTY_TRASH_DELAY)
|
||||||
|
|
||||||
readonly documentsInTrash = signal<Document[]>([])
|
readonly documentsInTrash = signal<Document[]>([])
|
||||||
readonly selectedDocuments = signal<Set<number>>(new Set())
|
readonly selectedDocuments = signal<Set<number>>(new Set())
|
||||||
@@ -200,8 +202,7 @@ export class TrashComponent
|
|||||||
}
|
}
|
||||||
|
|
||||||
getDaysRemaining(document: Document): number {
|
getDaysRemaining(document: Document): number {
|
||||||
this.settingsService.trackChanges()
|
const delay = this.emptyTrashDelaySetting()
|
||||||
const delay = this.settingsService.get(SETTINGS_KEYS.EMPTY_TRASH_DELAY)
|
|
||||||
const diff = new Date().getTime() - new Date(document.deleted_at).getTime()
|
const diff = new Date().getTime() - new Date(document.deleted_at).getTime()
|
||||||
const days = Math.ceil(diff / (1000 * 3600 * 24))
|
const days = Math.ceil(diff / (1000 * 3600 * 24))
|
||||||
return delay - days
|
return delay - days
|
||||||
|
|||||||
@@ -111,7 +111,7 @@
|
|||||||
</h6>
|
</h6>
|
||||||
<ul class="nav flex-column mb-2" cdkDropList (cdkDropListDropped)="onDrop($event)">
|
<ul class="nav flex-column mb-2" cdkDropList (cdkDropListDropped)="onDrop($event)">
|
||||||
@for (view of savedViewService.sidebarViews; track view.id) {
|
@for (view of savedViewService.sidebarViews; track view.id) {
|
||||||
<li class="nav-item w-100 app-link" cdkDrag [cdkDragDisabled]="!settingsService.organizingSidebarSavedViews() || !canSaveSettings"
|
<li class="nav-item app-link" cdkDrag [cdkDragDisabled]="!settingsService.organizingSidebarSavedViews() || !canSaveSettings"
|
||||||
cdkDragPreviewContainer="parent" cdkDragPreviewClass="navItemDrag" (cdkDragStarted)="onDragStart($event)"
|
cdkDragPreviewContainer="parent" cdkDragPreviewClass="navItemDrag" (cdkDragStarted)="onDragStart($event)"
|
||||||
(cdkDragEnded)="onDragEnd($event)">
|
(cdkDragEnded)="onDragEnd($event)">
|
||||||
<a class="nav-link" routerLink="view/{{view.id}}"
|
<a class="nav-link" routerLink="view/{{view.id}}"
|
||||||
@@ -128,7 +128,7 @@
|
|||||||
}
|
}
|
||||||
</a>
|
</a>
|
||||||
@if (settingsService.organizingSidebarSavedViews() && canSaveSettings) {
|
@if (settingsService.organizingSidebarSavedViews() && canSaveSettings) {
|
||||||
<div class="position-absolute end-0 top-0 px-3 py-2" [class.me-n3]="slimSidebarEnabled" cdkDragHandle>
|
<div class="position-absolute end-0 top-0 px-1 py-2" [class.me-n2]="slimSidebarEnabled" cdkDragHandle>
|
||||||
<i-bs name="grip-vertical"></i-bs>
|
<i-bs name="grip-vertical"></i-bs>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@@ -332,7 +332,7 @@
|
|||||||
</li>
|
</li>
|
||||||
<li class="nav-item" [class.visually-hidden]="slimSidebarEnabled">
|
<li class="nav-item" [class.visually-hidden]="slimSidebarEnabled">
|
||||||
<div class="text-muted small d-flex align-items-center flex-wrap nav-label">
|
<div class="text-muted small d-flex align-items-center flex-wrap nav-label">
|
||||||
<div class="me-3">
|
<div class="me-2">
|
||||||
<a class="text-muted text-decoration-none" target="_blank" rel="noopener noreferrer"
|
<a class="text-muted text-decoration-none" target="_blank" rel="noopener noreferrer"
|
||||||
href="https://github.com/paperless-ngx/paperless-ngx" ngbPopover="GitHub" i18n-ngbPopover
|
href="https://github.com/paperless-ngx/paperless-ngx" ngbPopover="GitHub" i18n-ngbPopover
|
||||||
[disablePopover]="!slimSidebarPopoversEnabled" placement="end" container="body"
|
[disablePopover]="!slimSidebarPopoversEnabled" placement="end" container="body"
|
||||||
@@ -341,7 +341,7 @@
|
|||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
@if (!settingsService.updateCheckingIsSet || appRemoteVersion()) {
|
@if (!settingsService.updateCheckingIsSet || appRemoteVersion()) {
|
||||||
<div class="version-check">
|
<div class="version-check d-flex align-items-center">
|
||||||
<ng-template #updateAvailablePopContent>
|
<ng-template #updateAvailablePopContent>
|
||||||
<span class="small">Paperless-ngx {{ appRemoteVersion().version }} <ng-container i18n>is
|
<span class="small">Paperless-ngx {{ appRemoteVersion().version }} <ng-container i18n>is
|
||||||
available.</ng-container><br /><ng-container i18n>Click to view.</ng-container></span>
|
available.</ng-container><br /><ng-container i18n>Click to view.</ng-container></span>
|
||||||
|
|||||||
@@ -34,9 +34,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
@media (min-width: 768px) {
|
@media (min-width: 768px) {
|
||||||
&.expanded {
|
--pngx-sidebar-width: var(--pngx-sidebar-expanded-width);
|
||||||
--pngx-sidebar-width: var(--pngx-sidebar-expanded-width);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@media (max-width: 767.98px) {
|
@media (max-width: 767.98px) {
|
||||||
@@ -113,6 +111,13 @@ main {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// only animate when the user toggles slim mode
|
||||||
|
.sidebar:not(.animating),
|
||||||
|
.sidebar:not(.animating) ~ main,
|
||||||
|
.sidebar:not(.animating) .sidebar-slim-toggler {
|
||||||
|
transition: none;
|
||||||
|
}
|
||||||
|
|
||||||
.sidebar.slim {
|
.sidebar.slim {
|
||||||
max-width: 55px;
|
max-width: 55px;
|
||||||
|
|
||||||
@@ -123,8 +128,6 @@ main {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.sidebar.slim:not(.animating) {
|
.sidebar.slim:not(.animating) {
|
||||||
transition: none;
|
|
||||||
|
|
||||||
li.nav-item span,
|
li.nav-item span,
|
||||||
.sidebar-heading span {
|
.sidebar-heading span {
|
||||||
display: none;
|
display: none;
|
||||||
@@ -144,10 +147,6 @@ main {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.sidebar.slim:not(.animating) ~ main.col-slim {
|
|
||||||
transition: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.sidebar.animating {
|
.sidebar.animating {
|
||||||
li.nav-item span,
|
li.nav-item span,
|
||||||
.sidebar-heading span {
|
.sidebar-heading span {
|
||||||
@@ -196,6 +195,26 @@ main {
|
|||||||
--bs-popover-body-padding-y: .5rem;
|
--bs-popover-body-padding-y: .5rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@media (prefers-reduced-motion: no-preference) {
|
||||||
|
.sidebar-sticky > ul,
|
||||||
|
.sidebar-sticky > .nav-group {
|
||||||
|
animation: sidebar-nav-in .3s cubic-bezier(.22, .61, .36, 1) backwards;
|
||||||
|
}
|
||||||
|
|
||||||
|
@for $i from 2 through 5 {
|
||||||
|
.sidebar-sticky > :nth-child(#{$i}) {
|
||||||
|
animation-delay: #{($i - 1) * 0.04}s;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes sidebar-nav-in {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(6px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
.sidebar-sticky {
|
.sidebar-sticky {
|
||||||
position: relative;
|
position: relative;
|
||||||
top: 0;
|
top: 0;
|
||||||
|
|||||||
@@ -193,6 +193,23 @@ describe('AppFrameComponent', () => {
|
|||||||
expect(savedViewSpy).toHaveBeenCalled()
|
expect(savedViewSpy).toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should update reinitialized signal-backed settings without manual change detection', async () => {
|
||||||
|
settingsService.initializeSettings().subscribe()
|
||||||
|
httpTestingController
|
||||||
|
.expectOne(`${environment.apiBaseUrl}ui_settings/`)
|
||||||
|
.flush({
|
||||||
|
settings: { app_title: 'Reactive title' },
|
||||||
|
user: {},
|
||||||
|
permissions: [],
|
||||||
|
})
|
||||||
|
|
||||||
|
await fixture.whenStable()
|
||||||
|
|
||||||
|
expect(
|
||||||
|
fixture.nativeElement.querySelector('.brand-title').textContent
|
||||||
|
).toBe('Reactive title')
|
||||||
|
})
|
||||||
|
|
||||||
it('should check for update if enabled', () => {
|
it('should check for update if enabled', () => {
|
||||||
const updateCheckSpy = jest.spyOn(remoteVersionService, 'checkForUpdates')
|
const updateCheckSpy = jest.spyOn(remoteVersionService, 'checkForUpdates')
|
||||||
updateCheckSpy.mockImplementation(() => {
|
updateCheckSpy.mockImplementation(() => {
|
||||||
|
|||||||
@@ -98,6 +98,29 @@ export class AppFrameComponent
|
|||||||
readonly isMenuCollapsed = signal(true)
|
readonly isMenuCollapsed = signal(true)
|
||||||
readonly slimSidebarAnimating = signal(false)
|
readonly slimSidebarAnimating = signal(false)
|
||||||
readonly mobileSearchHidden = signal(false)
|
readonly mobileSearchHidden = signal(false)
|
||||||
|
private readonly versionSetting = this.settingsService.getSignal<string>(
|
||||||
|
SETTINGS_KEYS.VERSION
|
||||||
|
)
|
||||||
|
private readonly appTitleSetting = this.settingsService.getSignal<string>(
|
||||||
|
SETTINGS_KEYS.APP_TITLE
|
||||||
|
)
|
||||||
|
private readonly appLogoSetting = this.settingsService.getSignal<string>(
|
||||||
|
SETTINGS_KEYS.APP_LOGO
|
||||||
|
)
|
||||||
|
private readonly slimSidebarSetting = this.settingsService.getSignal<boolean>(
|
||||||
|
SETTINGS_KEYS.SLIM_SIDEBAR
|
||||||
|
)
|
||||||
|
private readonly attributesSectionsCollapsedSetting =
|
||||||
|
this.settingsService.getSignal<CollapsibleSection[]>(
|
||||||
|
SETTINGS_KEYS.ATTRIBUTES_SECTIONS_COLLAPSED
|
||||||
|
)
|
||||||
|
private readonly aiEnabledSetting = this.settingsService.getSignal<boolean>(
|
||||||
|
SETTINGS_KEYS.AI_ENABLED
|
||||||
|
)
|
||||||
|
private readonly sidebarViewsShowCountSetting =
|
||||||
|
this.settingsService.getSignal<boolean>(
|
||||||
|
SETTINGS_KEYS.SIDEBAR_VIEWS_SHOW_COUNT
|
||||||
|
)
|
||||||
private lastScrollY: number = 0
|
private lastScrollY: number = 0
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
@@ -191,33 +214,23 @@ export class AppFrameComponent
|
|||||||
}
|
}
|
||||||
|
|
||||||
get versionString(): string {
|
get versionString(): string {
|
||||||
this.settingsService.trackChanges()
|
return `${environment.appTitle} v${this.versionSetting()}${environment.tag === 'prod' ? '' : ` #${environment.tag}`}`
|
||||||
return `${environment.appTitle} v${this.settingsService.get(SETTINGS_KEYS.VERSION)}${environment.tag === 'prod' ? '' : ` #${environment.tag}`}`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
get appTitle(): string {
|
get appTitle(): string {
|
||||||
this.settingsService.trackChanges()
|
return this.appTitleSetting() || environment.appTitle
|
||||||
return (
|
|
||||||
this.settingsService.get(SETTINGS_KEYS.APP_TITLE) || environment.appTitle
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
get customAppTitle(): string {
|
get customAppTitle(): string {
|
||||||
this.settingsService.trackChanges()
|
return this.appTitleSetting()
|
||||||
return this.settingsService.get(SETTINGS_KEYS.APP_TITLE)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
get hasCustomBranding(): boolean {
|
get hasCustomBranding(): boolean {
|
||||||
this.settingsService.trackChanges()
|
return !!(this.appTitleSetting()?.length || this.appLogoSetting()?.length)
|
||||||
return !!(
|
|
||||||
this.settingsService.get(SETTINGS_KEYS.APP_TITLE)?.length ||
|
|
||||||
this.settingsService.get(SETTINGS_KEYS.APP_LOGO)?.length
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
get customAppLogo(): string {
|
get customAppLogo(): string {
|
||||||
this.settingsService.trackChanges()
|
const logo = this.appLogoSetting()
|
||||||
const logo = this.settingsService.get(SETTINGS_KEYS.APP_LOGO)
|
|
||||||
return logo?.length
|
return logo?.length
|
||||||
? environment.apiBaseUrl.replace(/\/api\/$/, logo)
|
? environment.apiBaseUrl.replace(/\/api\/$/, logo)
|
||||||
: null
|
: null
|
||||||
@@ -262,8 +275,7 @@ export class AppFrameComponent
|
|||||||
}
|
}
|
||||||
|
|
||||||
get slimSidebarEnabled(): boolean {
|
get slimSidebarEnabled(): boolean {
|
||||||
this.settingsService.trackChanges()
|
return this.slimSidebarSetting()
|
||||||
return this.settingsService.get(SETTINGS_KEYS.SLIM_SIDEBAR)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
set slimSidebarEnabled(enabled: boolean) {
|
set slimSidebarEnabled(enabled: boolean) {
|
||||||
@@ -286,10 +298,9 @@ export class AppFrameComponent
|
|||||||
}
|
}
|
||||||
|
|
||||||
get attributesSectionsCollapsed(): boolean {
|
get attributesSectionsCollapsed(): boolean {
|
||||||
this.settingsService.trackChanges()
|
return this.attributesSectionsCollapsedSetting()?.includes(
|
||||||
return this.settingsService
|
CollapsibleSection.ATTRIBUTES
|
||||||
.get(SETTINGS_KEYS.ATTRIBUTES_SECTIONS_COLLAPSED)
|
)
|
||||||
?.includes(CollapsibleSection.ATTRIBUTES)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
set attributesSectionsCollapsed(collapsed: boolean) {
|
set attributesSectionsCollapsed(collapsed: boolean) {
|
||||||
@@ -312,8 +323,7 @@ export class AppFrameComponent
|
|||||||
}
|
}
|
||||||
|
|
||||||
get aiEnabled(): boolean {
|
get aiEnabled(): boolean {
|
||||||
this.settingsService.trackChanges()
|
return this.aiEnabledSetting()
|
||||||
return this.settingsService.get(SETTINGS_KEYS.AI_ENABLED)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@HostListener('window:resize')
|
@HostListener('window:resize')
|
||||||
@@ -480,9 +490,8 @@ export class AppFrameComponent
|
|||||||
}
|
}
|
||||||
|
|
||||||
get showSidebarCounts(): boolean {
|
get showSidebarCounts(): boolean {
|
||||||
this.settingsService.trackChanges()
|
|
||||||
return (
|
return (
|
||||||
this.settingsService.get(SETTINGS_KEYS.SIDEBAR_VIEWS_SHOW_COUNT) &&
|
this.sidebarViewsShowCountSetting() &&
|
||||||
!this.settingsService.organizingSidebarSavedViews()
|
!this.settingsService.organizingSidebarSavedViews()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -81,6 +81,10 @@ export class GlobalSearchComponent implements OnInit {
|
|||||||
private hotkeyService = inject(HotKeyService)
|
private hotkeyService = inject(HotKeyService)
|
||||||
private settingsService = inject(SettingsService)
|
private settingsService = inject(SettingsService)
|
||||||
private locationStrategy = inject(LocationStrategy)
|
private locationStrategy = inject(LocationStrategy)
|
||||||
|
private readonly searchFullTypeSetting =
|
||||||
|
this.settingsService.getSignal<GlobalSearchType>(
|
||||||
|
SETTINGS_KEYS.SEARCH_FULL_TYPE
|
||||||
|
)
|
||||||
|
|
||||||
public DataType = DataType
|
public DataType = DataType
|
||||||
readonly query = signal<string>(null)
|
readonly query = signal<string>(null)
|
||||||
@@ -97,11 +101,7 @@ export class GlobalSearchComponent implements OnInit {
|
|||||||
@ViewChildren('secondaryButton') secondaryButtons: QueryList<ElementRef>
|
@ViewChildren('secondaryButton') secondaryButtons: QueryList<ElementRef>
|
||||||
|
|
||||||
get useAdvancedForFullSearch(): boolean {
|
get useAdvancedForFullSearch(): boolean {
|
||||||
this.settingsService.trackChanges()
|
return this.searchFullTypeSetting() === GlobalSearchType.ADVANCED
|
||||||
return (
|
|
||||||
this.settingsService.get(SETTINGS_KEYS.SEARCH_FULL_TYPE) ===
|
|
||||||
GlobalSearchType.ADVANCED
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
|
|||||||
+1
-1
@@ -1,6 +1,6 @@
|
|||||||
@if (useDropdown) {
|
@if (useDropdown) {
|
||||||
<div class="btn-group w-100" role="group" ngbDropdown #dropdown="ngbDropdown" (openChange)="onOpenChange($event)" [popperOptions]="popperOptions">
|
<div class="btn-group w-100" role="group" ngbDropdown #dropdown="ngbDropdown" (openChange)="onOpenChange($event)" [popperOptions]="popperOptions">
|
||||||
<button class="btn btn-sm btn-outline-primary" id="dropdown_toggle" ngbDropdownToggle [disabled]="disabled" [aria-label]="title">
|
<button class="btn btn-sm" [ngClass]="!editing && isActive ? 'btn-primary' : 'btn-outline-primary'" id="dropdown_toggle" ngbDropdownToggle [disabled]="disabled" [aria-label]="title">
|
||||||
<i-bs name="{{icon}}"></i-bs><div class="d-none d-sm-inline ms-1">{{title}}</div>
|
<i-bs name="{{icon}}"></i-bs><div class="d-none d-sm-inline ms-1">{{title}}</div>
|
||||||
@if (isActive) {
|
@if (isActive) {
|
||||||
<pngx-clearable-badge [selected]="isActive" (cleared)="reset()"></pngx-clearable-badge>
|
<pngx-clearable-badge [selected]="isActive" (cleared)="reset()"></pngx-clearable-badge>
|
||||||
|
|||||||
+7
-4
@@ -1,5 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
getLocaleNumberSymbol,
|
getLocaleNumberSymbol,
|
||||||
|
NgClass,
|
||||||
NgTemplateOutlet,
|
NgTemplateOutlet,
|
||||||
NumberSymbol,
|
NumberSymbol,
|
||||||
} from '@angular/common'
|
} from '@angular/common'
|
||||||
@@ -48,25 +49,26 @@ import { ClearableBadgeComponent } from '../clearable-badge/clearable-badge.comp
|
|||||||
import { DocumentLinkComponent } from '../input/document-link/document-link.component'
|
import { DocumentLinkComponent } from '../input/document-link/document-link.component'
|
||||||
|
|
||||||
export class CustomFieldQueriesModel {
|
export class CustomFieldQueriesModel {
|
||||||
private _queries: CustomFieldQueryElement[] = []
|
private readonly _queries = signal<CustomFieldQueryElement[]>([])
|
||||||
private rootSubscriptions: Subscription[] = []
|
private rootSubscriptions: Subscription[] = []
|
||||||
|
|
||||||
public readonly changed = new Subject<CustomFieldQueriesModel>()
|
public readonly changed = new Subject<CustomFieldQueriesModel>()
|
||||||
|
|
||||||
public get queries(): CustomFieldQueryElement[] {
|
public get queries(): CustomFieldQueryElement[] {
|
||||||
return this._queries
|
return this._queries()
|
||||||
}
|
}
|
||||||
|
|
||||||
public set queries(value: CustomFieldQueryElement[]) {
|
public set queries(value: CustomFieldQueryElement[]) {
|
||||||
this.teardownRootSubscriptions()
|
this.teardownRootSubscriptions()
|
||||||
this._queries = value ?? []
|
const queries = value ?? []
|
||||||
for (const element of this._queries) {
|
for (const element of queries) {
|
||||||
this.rootSubscriptions.push(
|
this.rootSubscriptions.push(
|
||||||
element.changed.subscribe(() => {
|
element.changed.subscribe(() => {
|
||||||
this.changed.next(this)
|
this.changed.next(this)
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
this._queries.set(queries)
|
||||||
}
|
}
|
||||||
|
|
||||||
public clear(fireEvent = true) {
|
public clear(fireEvent = true) {
|
||||||
@@ -209,6 +211,7 @@ export class CustomFieldQueriesModel {
|
|||||||
DocumentLinkComponent,
|
DocumentLinkComponent,
|
||||||
ReactiveFormsModule,
|
ReactiveFormsModule,
|
||||||
NgbDatepickerModule,
|
NgbDatepickerModule,
|
||||||
|
NgClass,
|
||||||
NgTemplateOutlet,
|
NgTemplateOutlet,
|
||||||
NgSelectModule,
|
NgSelectModule,
|
||||||
NgxBootstrapIconsModule,
|
NgxBootstrapIconsModule,
|
||||||
|
|||||||
+19
-13
@@ -196,6 +196,16 @@ describe('WorkflowEditDialogComponent', () => {
|
|||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
function setActionSettings({
|
||||||
|
email = true,
|
||||||
|
remoteOcr = true,
|
||||||
|
ai = true,
|
||||||
|
} = {}) {
|
||||||
|
settingsService.set(SETTINGS_KEYS.EMAIL_ENABLED, email)
|
||||||
|
settingsService.set(SETTINGS_KEYS.REMOTE_OCR_CONFIGURED, remoteOcr)
|
||||||
|
settingsService.set(SETTINGS_KEYS.AI_ENABLED, ai)
|
||||||
|
}
|
||||||
|
|
||||||
it('should support create and edit modes, support adding triggers and actions on new workflow', () => {
|
it('should support create and edit modes, support adding triggers and actions on new workflow', () => {
|
||||||
component.dialogMode.set(EditDialogMode.CREATE)
|
component.dialogMode.set(EditDialogMode.CREATE)
|
||||||
const createTitleSpy = jest.spyOn(component, 'getCreateTitle')
|
const createTitleSpy = jest.spyOn(component, 'getCreateTitle')
|
||||||
@@ -218,7 +228,7 @@ describe('WorkflowEditDialogComponent', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('should return source options, type options, type name, schedule date field options', () => {
|
it('should return source options, type options, type name, schedule date field options', () => {
|
||||||
jest.spyOn(settingsService, 'get').mockReturnValue(true)
|
setActionSettings()
|
||||||
component.ngOnInit()
|
component.ngOnInit()
|
||||||
expect(component.sourceOptions).toEqual(DOCUMENT_SOURCE_OPTIONS)
|
expect(component.sourceOptions).toEqual(DOCUMENT_SOURCE_OPTIONS)
|
||||||
expect(component.triggerTypeOptions).toEqual(WORKFLOW_TYPE_OPTIONS)
|
expect(component.triggerTypeOptions).toEqual(WORKFLOW_TYPE_OPTIONS)
|
||||||
@@ -242,7 +252,7 @@ describe('WorkflowEditDialogComponent', () => {
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Email, remote OCR and AI all disabled
|
// Email, remote OCR and AI all disabled
|
||||||
jest.spyOn(settingsService, 'get').mockReturnValue(false)
|
setActionSettings({ email: false, remoteOcr: false, ai: false })
|
||||||
component.ngOnInit()
|
component.ngOnInit()
|
||||||
expect(component.actionTypeOptions).toEqual(
|
expect(component.actionTypeOptions).toEqual(
|
||||||
WORKFLOW_ACTION_OPTIONS.filter(
|
WORKFLOW_ACTION_OPTIONS.filter(
|
||||||
@@ -255,7 +265,7 @@ describe('WorkflowEditDialogComponent', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('should offer remote OCR only for consumption workflows', () => {
|
it('should offer remote OCR only for consumption workflows', () => {
|
||||||
jest.spyOn(settingsService, 'get').mockReturnValue(true)
|
setActionSettings()
|
||||||
|
|
||||||
// A consumption trigger makes the action reachable
|
// A consumption trigger makes the action reachable
|
||||||
component.object = {
|
component.object = {
|
||||||
@@ -285,7 +295,7 @@ describe('WorkflowEditDialogComponent', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('should offer remote OCR on a trigger added to a new workflow', () => {
|
it('should offer remote OCR on a trigger added to a new workflow', () => {
|
||||||
jest.spyOn(settingsService, 'get').mockReturnValue(true)
|
setActionSettings()
|
||||||
component.ngOnInit()
|
component.ngOnInit()
|
||||||
|
|
||||||
// Nothing for the action to apply to yet
|
// Nothing for the action to apply to yet
|
||||||
@@ -311,7 +321,7 @@ describe('WorkflowEditDialogComponent', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('should keep remote OCR listed when an action already uses it', () => {
|
it('should keep remote OCR listed when an action already uses it', () => {
|
||||||
jest.spyOn(settingsService, 'get').mockReturnValue(true)
|
setActionSettings()
|
||||||
|
|
||||||
// Otherwise changing the trigger would silently blank the selection
|
// Otherwise changing the trigger would silently blank the selection
|
||||||
component.object = {
|
component.object = {
|
||||||
@@ -329,9 +339,7 @@ describe('WorkflowEditDialogComponent', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('should not offer remote OCR when no engine is configured', () => {
|
it('should not offer remote OCR when no engine is configured', () => {
|
||||||
jest
|
setActionSettings({ remoteOcr: false })
|
||||||
.spyOn(settingsService, 'get')
|
|
||||||
.mockImplementation((key) => key !== SETTINGS_KEYS.REMOTE_OCR_CONFIGURED)
|
|
||||||
|
|
||||||
component.object = {
|
component.object = {
|
||||||
name: 'Workflow 1',
|
name: 'Workflow 1',
|
||||||
@@ -348,7 +356,7 @@ describe('WorkflowEditDialogComponent', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('should offer apply AI suggestions unless every trigger is consumption', () => {
|
it('should offer apply AI suggestions unless every trigger is consumption', () => {
|
||||||
jest.spyOn(settingsService, 'get').mockReturnValue(true)
|
setActionSettings()
|
||||||
|
|
||||||
// Consumption runs before the document has been parsed, so there would be
|
// Consumption runs before the document has been parsed, so there would be
|
||||||
// no content to make suggestions from
|
// no content to make suggestions from
|
||||||
@@ -382,7 +390,7 @@ describe('WorkflowEditDialogComponent', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('should keep apply AI suggestions listed when an action already uses it', () => {
|
it('should keep apply AI suggestions listed when an action already uses it', () => {
|
||||||
jest.spyOn(settingsService, 'get').mockReturnValue(true)
|
setActionSettings()
|
||||||
|
|
||||||
// Otherwise changing the trigger would silently blank the selection
|
// Otherwise changing the trigger would silently blank the selection
|
||||||
component.object = {
|
component.object = {
|
||||||
@@ -400,9 +408,7 @@ describe('WorkflowEditDialogComponent', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
it('should not offer apply AI suggestions when AI is disabled', () => {
|
it('should not offer apply AI suggestions when AI is disabled', () => {
|
||||||
jest
|
setActionSettings({ ai: false })
|
||||||
.spyOn(settingsService, 'get')
|
|
||||||
.mockImplementation((key) => key !== SETTINGS_KEYS.AI_ENABLED)
|
|
||||||
|
|
||||||
component.object = {
|
component.object = {
|
||||||
name: 'Workflow 1',
|
name: 'Workflow 1',
|
||||||
|
|||||||
+10
-4
@@ -537,6 +537,13 @@ export class WorkflowEditDialogComponent
|
|||||||
readonly dateCustomFields = computed(() =>
|
readonly dateCustomFields = computed(() =>
|
||||||
this.customFields()?.filter((f) => f.data_type === CustomFieldDataType.Date)
|
this.customFields()?.filter((f) => f.data_type === CustomFieldDataType.Date)
|
||||||
)
|
)
|
||||||
|
private readonly emailEnabledSetting =
|
||||||
|
this.settingsService.getSignal<boolean>(SETTINGS_KEYS.EMAIL_ENABLED)
|
||||||
|
private readonly remoteOcrConfiguredSetting =
|
||||||
|
this.settingsService.getSignal<boolean>(SETTINGS_KEYS.REMOTE_OCR_CONFIGURED)
|
||||||
|
private readonly aiEnabledSetting = this.settingsService.getSignal<boolean>(
|
||||||
|
SETTINGS_KEYS.AI_ENABLED
|
||||||
|
)
|
||||||
|
|
||||||
expandedItem: number = null
|
expandedItem: number = null
|
||||||
|
|
||||||
@@ -589,7 +596,7 @@ export class WorkflowEditDialogComponent
|
|||||||
private getAllowedActionTypes() {
|
private getAllowedActionTypes() {
|
||||||
let allowed = WORKFLOW_ACTION_OPTIONS
|
let allowed = WORKFLOW_ACTION_OPTIONS
|
||||||
|
|
||||||
if (!this.settingsService.get(SETTINGS_KEYS.EMAIL_ENABLED)) {
|
if (!this.emailEnabledSetting()) {
|
||||||
allowed = allowed.filter((a) => a.id !== WorkflowActionType.Email)
|
allowed = allowed.filter((a) => a.id !== WorkflowActionType.Email)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -597,7 +604,7 @@ export class WorkflowEditDialogComponent
|
|||||||
// offered for workflows that run at consumption.
|
// offered for workflows that run at consumption.
|
||||||
const formWorkflow: Workflow = this.objectForm?.value
|
const formWorkflow: Workflow = this.objectForm?.value
|
||||||
const remoteOcrUsable =
|
const remoteOcrUsable =
|
||||||
this.settingsService.get(SETTINGS_KEYS.REMOTE_OCR_CONFIGURED) &&
|
this.remoteOcrConfiguredSetting() &&
|
||||||
(formWorkflow?.triggers?.some(
|
(formWorkflow?.triggers?.some(
|
||||||
(trigger) => trigger.type === WorkflowTriggerType.Consumption
|
(trigger) => trigger.type === WorkflowTriggerType.Consumption
|
||||||
) ||
|
) ||
|
||||||
@@ -612,7 +619,7 @@ export class WorkflowEditDialogComponent
|
|||||||
// once every trigger is consumption, so it stays offered on a workflow
|
// once every trigger is consumption, so it stays offered on a workflow
|
||||||
// that has no triggers yet.
|
// that has no triggers yet.
|
||||||
const aiSuggestionsUsable =
|
const aiSuggestionsUsable =
|
||||||
this.settingsService.get(SETTINGS_KEYS.AI_ENABLED) &&
|
this.aiEnabledSetting() &&
|
||||||
(!formWorkflow?.triggers?.length ||
|
(!formWorkflow?.triggers?.length ||
|
||||||
formWorkflow.triggers.some(
|
formWorkflow.triggers.some(
|
||||||
(trigger) => trigger.type !== WorkflowTriggerType.Consumption
|
(trigger) => trigger.type !== WorkflowTriggerType.Consumption
|
||||||
@@ -1362,7 +1369,6 @@ export class WorkflowEditDialogComponent
|
|||||||
}
|
}
|
||||||
|
|
||||||
get actionTypeOptions() {
|
get actionTypeOptions() {
|
||||||
this.settingsService.trackChanges()
|
|
||||||
// Computed on read rather than cached
|
// Computed on read rather than cached
|
||||||
return this.getAllowedActionTypes()
|
return this.getAllowedActionTypes()
|
||||||
}
|
}
|
||||||
|
|||||||
+10
-4
@@ -839,7 +839,9 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
|
|||||||
selectionModel.items = [memoRoot]
|
selectionModel.items = [memoRoot]
|
||||||
selectionModel.documentCounts = [{ id: memoRoot.id, document_count: 9 }]
|
selectionModel.documentCounts = [{ id: memoRoot.id, document_count: 9 }]
|
||||||
|
|
||||||
const getRootDocCount = (selectionModel as any).createRootDocCounter()
|
const getRootDocCount = (selectionModel as any).createRootDocCounter(
|
||||||
|
selectionModel.items
|
||||||
|
)
|
||||||
|
|
||||||
expect(getRootDocCount(memoRoot.id)).toEqual(9)
|
expect(getRootDocCount(memoRoot.id)).toEqual(9)
|
||||||
selectionModel.documentCounts = []
|
selectionModel.documentCounts = []
|
||||||
@@ -855,7 +857,9 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
|
|||||||
selectionModel.items = [rootWithoutSelection]
|
selectionModel.items = [rootWithoutSelection]
|
||||||
selectionModel.documentCounts = []
|
selectionModel.documentCounts = []
|
||||||
|
|
||||||
const getRootDocCount = (selectionModel as any).createRootDocCounter()
|
const getRootDocCount = (selectionModel as any).createRootDocCounter(
|
||||||
|
selectionModel.items
|
||||||
|
)
|
||||||
|
|
||||||
expect(getRootDocCount(rootWithoutSelection.id)).toEqual(4)
|
expect(getRootDocCount(rootWithoutSelection.id)).toEqual(4)
|
||||||
})
|
})
|
||||||
@@ -865,7 +869,9 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
|
|||||||
selectionModel.items = [rootWithoutCounts]
|
selectionModel.items = [rootWithoutCounts]
|
||||||
selectionModel.documentCounts = []
|
selectionModel.documentCounts = []
|
||||||
|
|
||||||
const getRootDocCount = (selectionModel as any).createRootDocCounter()
|
const getRootDocCount = (selectionModel as any).createRootDocCounter(
|
||||||
|
selectionModel.items
|
||||||
|
)
|
||||||
|
|
||||||
expect(getRootDocCount(rootWithoutCounts.id)).toEqual(0)
|
expect(getRootDocCount(rootWithoutCounts.id)).toEqual(0)
|
||||||
})
|
})
|
||||||
@@ -966,7 +972,7 @@ describe('FilterableDropdownComponent & FilterableDropdownSelectionModel', () =>
|
|||||||
component.selectionModel['temporarySelectionStates'].set(id, state)
|
component.selectionModel['temporarySelectionStates'].set(id, state)
|
||||||
const changedSpy = jest.spyOn(component.selectionModel.changed, 'next')
|
const changedSpy = jest.spyOn(component.selectionModel.changed, 'next')
|
||||||
component.selectionModel.exclude(id)
|
component.selectionModel.exclude(id)
|
||||||
expect(component.selectionModel.temporaryLogicalOperator).toBe(
|
expect(component.selectionModel.temporaryLogicalOperator()).toBe(
|
||||||
LogicalOperator.And
|
LogicalOperator.And
|
||||||
)
|
)
|
||||||
expect(component.selectionModel['temporarySelectionStates'].get(id)).toBe(
|
expect(component.selectionModel['temporarySelectionStates'].get(id)).toBe(
|
||||||
|
|||||||
+125
-104
@@ -64,43 +64,56 @@ export class FilterableDropdownSelectionModel {
|
|||||||
|
|
||||||
manyToOne = false
|
manyToOne = false
|
||||||
singleSelect = false
|
singleSelect = false
|
||||||
private _logicalOperator: LogicalOperator = LogicalOperator.And
|
|
||||||
temporaryLogicalOperator: LogicalOperator = this._logicalOperator
|
|
||||||
private _intersection: Intersection = Intersection.Include
|
|
||||||
temporaryIntersection: Intersection = this._intersection
|
|
||||||
|
|
||||||
private _documentCounts: SelectionDataItem[] = []
|
private readonly _logicalOperator = signal(LogicalOperator.And)
|
||||||
|
readonly temporaryLogicalOperator = signal(LogicalOperator.And)
|
||||||
|
private readonly _intersection = signal(Intersection.Include)
|
||||||
|
readonly temporaryIntersection = signal(Intersection.Include)
|
||||||
|
private readonly _documentCounts = signal<SelectionDataItem[]>([])
|
||||||
|
private readonly _items = signal<MatchingModel[]>([])
|
||||||
|
private readonly _selectionStates = signal(
|
||||||
|
new Map<number, ToggleableItemState>()
|
||||||
|
)
|
||||||
|
private readonly _temporarySelectionStates = signal(
|
||||||
|
new Map<number, ToggleableItemState>()
|
||||||
|
)
|
||||||
|
|
||||||
public documentCountSortingEnabled = false
|
public documentCountSortingEnabled = false
|
||||||
|
|
||||||
|
private get selectionStates(): ReadonlyMap<number, ToggleableItemState> {
|
||||||
|
return this._selectionStates()
|
||||||
|
}
|
||||||
|
|
||||||
|
private get temporarySelectionStates(): ReadonlyMap<
|
||||||
|
number,
|
||||||
|
ToggleableItemState
|
||||||
|
> {
|
||||||
|
return this._temporarySelectionStates()
|
||||||
|
}
|
||||||
|
|
||||||
public set documentCounts(counts: SelectionDataItem[]) {
|
public set documentCounts(counts: SelectionDataItem[]) {
|
||||||
this._documentCounts = counts
|
this._documentCounts.set(counts)
|
||||||
if (this.documentCountSortingEnabled) {
|
if (this.documentCountSortingEnabled) {
|
||||||
this.sortItems()
|
this._items.set(this.sortItems(this.items))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private _items: MatchingModel[] = []
|
|
||||||
get items(): MatchingModel[] {
|
get items(): MatchingModel[] {
|
||||||
return this._items
|
return this._items()
|
||||||
}
|
}
|
||||||
|
|
||||||
set items(items: MatchingModel[]) {
|
set items(items: MatchingModel[]) {
|
||||||
if (items) {
|
if (items) {
|
||||||
this._items = Array.from(items)
|
this._items.set(this.withNullItem(this.sortItems(Array.from(items))))
|
||||||
this.sortItems()
|
|
||||||
this.setNullItem()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private setNullItem() {
|
private withNullItem(items: MatchingModel[]): MatchingModel[] {
|
||||||
if (this.manyToOne && this.logicalOperator === LogicalOperator.Or) {
|
if (this.manyToOne && this.logicalOperator === LogicalOperator.Or) {
|
||||||
if (this._items[0]?.id === null) {
|
return items[0]?.id === null ? items.slice(1) : items
|
||||||
this._items.shift()
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const item = {
|
const nullItem = {
|
||||||
name: $localize`:Filter drop down element to filter for documents with no correspondent/type/tag assigned:Not assigned`,
|
name: $localize`:Filter drop down element to filter for documents with no correspondent/type/tag assigned:Not assigned`,
|
||||||
id:
|
id:
|
||||||
this.manyToOne || this.intersection === Intersection.Include
|
this.manyToOne || this.intersection === Intersection.Include
|
||||||
@@ -108,22 +121,17 @@ export class FilterableDropdownSelectionModel {
|
|||||||
: NEGATIVE_NULL_FILTER_VALUE,
|
: NEGATIVE_NULL_FILTER_VALUE,
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
return items[0]?.id === null || items[0]?.id === NEGATIVE_NULL_FILTER_VALUE
|
||||||
this._items[0]?.id === null ||
|
? [nullItem, ...items.slice(1)]
|
||||||
this._items[0]?.id === NEGATIVE_NULL_FILTER_VALUE
|
: [nullItem, ...items]
|
||||||
) {
|
|
||||||
this._items[0] = item
|
|
||||||
} else if (this._items) {
|
|
||||||
this._items.unshift(item)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
constructor(manyToOne: boolean = false) {
|
constructor(manyToOne: boolean = false) {
|
||||||
this.manyToOne = manyToOne
|
this.manyToOne = manyToOne
|
||||||
}
|
}
|
||||||
|
|
||||||
private sortItems() {
|
private sortItems(items: MatchingModel[]): MatchingModel[] {
|
||||||
this._items.sort((a, b) => {
|
const sorted = [...items].sort((a, b) => {
|
||||||
if (
|
if (
|
||||||
(a.id == null && b.id != null) ||
|
(a.id == null && b.id != null) ||
|
||||||
(a.id == NEGATIVE_NULL_FILTER_VALUE &&
|
(a.id == NEGATIVE_NULL_FILTER_VALUE &&
|
||||||
@@ -154,13 +162,13 @@ export class FilterableDropdownSelectionModel {
|
|||||||
) {
|
) {
|
||||||
return -1
|
return -1
|
||||||
} else if (
|
} else if (
|
||||||
this._documentCounts.length &&
|
this._documentCounts().length &&
|
||||||
this.getDocumentCount(b.id) === 0 &&
|
this.getDocumentCount(b.id) === 0 &&
|
||||||
this.getDocumentCount(a.id) > this.getDocumentCount(b.id)
|
this.getDocumentCount(a.id) > this.getDocumentCount(b.id)
|
||||||
) {
|
) {
|
||||||
return -1
|
return -1
|
||||||
} else if (
|
} else if (
|
||||||
this._documentCounts.length &&
|
this._documentCounts().length &&
|
||||||
this.getDocumentCount(a.id) === 0 &&
|
this.getDocumentCount(a.id) === 0 &&
|
||||||
this.getDocumentCount(a.id) < this.getDocumentCount(b.id)
|
this.getDocumentCount(a.id) < this.getDocumentCount(b.id)
|
||||||
) {
|
) {
|
||||||
@@ -170,15 +178,11 @@ export class FilterableDropdownSelectionModel {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
if (this._documentCounts.length) {
|
return this._documentCounts().length
|
||||||
this.promoteBranchesWithDocumentCounts()
|
? this.promoteBranchesWithDocumentCounts(sorted)
|
||||||
}
|
: sorted
|
||||||
}
|
}
|
||||||
|
|
||||||
private selectionStates = new Map<number, ToggleableItemState>()
|
|
||||||
|
|
||||||
private temporarySelectionStates = new Map<number, ToggleableItemState>()
|
|
||||||
|
|
||||||
getSelectedItems() {
|
getSelectedItems() {
|
||||||
return this.items.filter(
|
return this.items.filter(
|
||||||
(i) =>
|
(i) =>
|
||||||
@@ -194,30 +198,33 @@ export class FilterableDropdownSelectionModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
set(id: number, state: ToggleableItemState, fireEvent = true) {
|
set(id: number, state: ToggleableItemState, fireEvent = true) {
|
||||||
|
const states = new Map(this.temporarySelectionStates)
|
||||||
if (state == ToggleableItemState.NotSelected) {
|
if (state == ToggleableItemState.NotSelected) {
|
||||||
this.temporarySelectionStates.delete(id)
|
states.delete(id)
|
||||||
} else {
|
} else {
|
||||||
this.temporarySelectionStates.set(id, state)
|
states.set(id, state)
|
||||||
}
|
}
|
||||||
|
this._temporarySelectionStates.set(states)
|
||||||
if (fireEvent) {
|
if (fireEvent) {
|
||||||
this.changed.next(this)
|
this.changed.next(this)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
toggle(id: number, fireEvent = true) {
|
toggle(id: number, fireEvent = true) {
|
||||||
let state = this.temporarySelectionStates.get(id)
|
const states = new Map(this.temporarySelectionStates)
|
||||||
|
let state = states.get(id)
|
||||||
if (
|
if (
|
||||||
state == undefined ||
|
state == undefined ||
|
||||||
(state != ToggleableItemState.Selected &&
|
(state != ToggleableItemState.Selected &&
|
||||||
state != ToggleableItemState.Excluded)
|
state != ToggleableItemState.Excluded)
|
||||||
) {
|
) {
|
||||||
if (this.manyToOne || this.singleSelect) {
|
if (this.manyToOne || this.singleSelect) {
|
||||||
this.temporarySelectionStates.set(id, ToggleableItemState.Selected)
|
states.set(id, ToggleableItemState.Selected)
|
||||||
|
|
||||||
if (this.singleSelect) {
|
if (this.singleSelect) {
|
||||||
for (let key of this.temporarySelectionStates.keys()) {
|
for (let key of states.keys()) {
|
||||||
if (key != id) {
|
if (key != id) {
|
||||||
this.temporarySelectionStates.delete(key)
|
states.delete(key)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -233,25 +240,26 @@ export class FilterableDropdownSelectionModel {
|
|||||||
) {
|
) {
|
||||||
newState = ToggleableItemState.NotSelected
|
newState = ToggleableItemState.NotSelected
|
||||||
}
|
}
|
||||||
this.temporarySelectionStates.set(id, newState)
|
states.set(id, newState)
|
||||||
}
|
}
|
||||||
} else if (
|
} else if (
|
||||||
state == ToggleableItemState.Selected ||
|
state == ToggleableItemState.Selected ||
|
||||||
state == ToggleableItemState.Excluded
|
state == ToggleableItemState.Excluded
|
||||||
) {
|
) {
|
||||||
this.temporarySelectionStates.delete(id)
|
states.delete(id)
|
||||||
this.clearDescendantSelections(id)
|
this.clearDescendantSelections(states, id)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!id) {
|
if (!id) {
|
||||||
for (let key of this.temporarySelectionStates.keys()) {
|
for (let key of states.keys()) {
|
||||||
if (key) {
|
if (key) {
|
||||||
this.temporarySelectionStates.delete(key)
|
states.delete(key)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
this.temporarySelectionStates.delete(null)
|
states.delete(null)
|
||||||
}
|
}
|
||||||
|
this._temporarySelectionStates.set(states)
|
||||||
|
|
||||||
if (fireEvent) {
|
if (fireEvent) {
|
||||||
this.changed.next(this)
|
this.changed.next(this)
|
||||||
@@ -259,20 +267,21 @@ export class FilterableDropdownSelectionModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
exclude(id: number, fireEvent: boolean = true) {
|
exclude(id: number, fireEvent: boolean = true) {
|
||||||
let state = this.temporarySelectionStates.get(id)
|
const states = new Map(this.temporarySelectionStates)
|
||||||
|
let state = states.get(id)
|
||||||
if (id && (state == null || state != ToggleableItemState.Excluded)) {
|
if (id && (state == null || state != ToggleableItemState.Excluded)) {
|
||||||
this.temporaryLogicalOperator = this._logicalOperator = this.manyToOne
|
const operator = this.manyToOne ? LogicalOperator.And : LogicalOperator.Or
|
||||||
? LogicalOperator.And
|
this.temporaryLogicalOperator.set(operator)
|
||||||
: LogicalOperator.Or
|
this._logicalOperator.set(operator)
|
||||||
|
|
||||||
if (this.manyToOne || this.singleSelect) {
|
if (this.manyToOne || this.singleSelect) {
|
||||||
this.temporarySelectionStates.set(id, ToggleableItemState.Excluded)
|
states.set(id, ToggleableItemState.Excluded)
|
||||||
this.clearDescendantSelections(id)
|
this.clearDescendantSelections(states, id)
|
||||||
|
|
||||||
if (this.singleSelect) {
|
if (this.singleSelect) {
|
||||||
for (let key of this.temporarySelectionStates.keys()) {
|
for (let key of states.keys()) {
|
||||||
if (key != id) {
|
if (key != id) {
|
||||||
this.temporarySelectionStates.delete(key)
|
states.delete(key)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -287,17 +296,18 @@ export class FilterableDropdownSelectionModel {
|
|||||||
) {
|
) {
|
||||||
newState = ToggleableItemState.NotSelected
|
newState = ToggleableItemState.NotSelected
|
||||||
}
|
}
|
||||||
this.temporarySelectionStates.set(id, newState)
|
states.set(id, newState)
|
||||||
if (newState == ToggleableItemState.Excluded) {
|
if (newState == ToggleableItemState.Excluded) {
|
||||||
this.clearDescendantSelections(id)
|
this.clearDescendantSelections(states, id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else if (!id || state == ToggleableItemState.Excluded) {
|
} else if (!id || state == ToggleableItemState.Excluded) {
|
||||||
this.temporarySelectionStates.delete(id)
|
states.delete(id)
|
||||||
if (id) {
|
if (id) {
|
||||||
this.clearDescendantSelections(id)
|
this.clearDescendantSelections(states, id)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
this._temporarySelectionStates.set(states)
|
||||||
|
|
||||||
if (fireEvent) {
|
if (fireEvent) {
|
||||||
this.changed.next(this)
|
this.changed.next(this)
|
||||||
@@ -308,9 +318,12 @@ export class FilterableDropdownSelectionModel {
|
|||||||
return this.selectionStates.get(id) || ToggleableItemState.NotSelected
|
return this.selectionStates.get(id) || ToggleableItemState.NotSelected
|
||||||
}
|
}
|
||||||
|
|
||||||
private clearDescendantSelections(id: number) {
|
private clearDescendantSelections(
|
||||||
|
states: Map<number, ToggleableItemState>,
|
||||||
|
id: number
|
||||||
|
) {
|
||||||
for (const descendantID of this.getDescendantIDs(id)) {
|
for (const descendantID of this.getDescendantIDs(id)) {
|
||||||
this.temporarySelectionStates.delete(descendantID)
|
states.delete(descendantID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -320,7 +333,7 @@ export class FilterableDropdownSelectionModel {
|
|||||||
|
|
||||||
while (queue.length) {
|
while (queue.length) {
|
||||||
const parentID = queue.shift()
|
const parentID = queue.shift()
|
||||||
for (const item of this._items) {
|
for (const item of this.items) {
|
||||||
if (
|
if (
|
||||||
typeof item?.id === 'number' &&
|
typeof item?.id === 'number' &&
|
||||||
typeof (item as any)['parent'] === 'number' &&
|
typeof (item as any)['parent'] === 'number' &&
|
||||||
@@ -336,12 +349,12 @@ export class FilterableDropdownSelectionModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
get logicalOperator(): LogicalOperator {
|
get logicalOperator(): LogicalOperator {
|
||||||
return this.temporaryLogicalOperator
|
return this.temporaryLogicalOperator()
|
||||||
}
|
}
|
||||||
|
|
||||||
set logicalOperator(operator: LogicalOperator) {
|
set logicalOperator(operator: LogicalOperator) {
|
||||||
this.temporaryLogicalOperator = operator
|
this.temporaryLogicalOperator.set(operator)
|
||||||
this.setNullItem()
|
this._items.set(this.withNullItem(this.items))
|
||||||
}
|
}
|
||||||
|
|
||||||
toggleOperator() {
|
toggleOperator() {
|
||||||
@@ -349,12 +362,12 @@ export class FilterableDropdownSelectionModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
get intersection(): Intersection {
|
get intersection(): Intersection {
|
||||||
return this.temporaryIntersection
|
return this.temporaryIntersection()
|
||||||
}
|
}
|
||||||
|
|
||||||
set intersection(intersection: Intersection) {
|
set intersection(intersection: Intersection) {
|
||||||
this.temporaryIntersection = intersection
|
this.temporaryIntersection.set(intersection)
|
||||||
this.setNullItem()
|
this._items.set(this.withNullItem(this.items))
|
||||||
}
|
}
|
||||||
|
|
||||||
toggleIntersection() {
|
toggleIntersection() {
|
||||||
@@ -364,18 +377,20 @@ export class FilterableDropdownSelectionModel {
|
|||||||
? ToggleableItemState.Selected
|
? ToggleableItemState.Selected
|
||||||
: ToggleableItemState.Excluded
|
: ToggleableItemState.Excluded
|
||||||
|
|
||||||
this.temporarySelectionStates.forEach((state, key) => {
|
const states = new Map(this.temporarySelectionStates)
|
||||||
|
states.forEach((state, key) => {
|
||||||
if (key === null && this.intersection === Intersection.Exclude) {
|
if (key === null && this.intersection === Intersection.Exclude) {
|
||||||
this.temporarySelectionStates.set(NEGATIVE_NULL_FILTER_VALUE, newState)
|
states.set(NEGATIVE_NULL_FILTER_VALUE, newState)
|
||||||
} else if (
|
} else if (
|
||||||
key === NEGATIVE_NULL_FILTER_VALUE &&
|
key === NEGATIVE_NULL_FILTER_VALUE &&
|
||||||
this.intersection === Intersection.Include
|
this.intersection === Intersection.Include
|
||||||
) {
|
) {
|
||||||
this.temporarySelectionStates.set(null, newState)
|
states.set(null, newState)
|
||||||
} else {
|
} else {
|
||||||
this.temporarySelectionStates.set(key, newState)
|
states.set(key, newState)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
this._temporarySelectionStates.set(states)
|
||||||
|
|
||||||
this.changed.next(this)
|
this.changed.next(this)
|
||||||
}
|
}
|
||||||
@@ -395,10 +410,12 @@ export class FilterableDropdownSelectionModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
clear(fireEvent = true) {
|
clear(fireEvent = true) {
|
||||||
this.temporarySelectionStates.clear()
|
this._temporarySelectionStates.set(new Map())
|
||||||
this.temporaryLogicalOperator = this._logicalOperator = LogicalOperator.And
|
this.temporaryLogicalOperator.set(LogicalOperator.And)
|
||||||
this.temporaryIntersection = this._intersection = Intersection.Include
|
this._logicalOperator.set(LogicalOperator.And)
|
||||||
this.setNullItem()
|
this.temporaryIntersection.set(Intersection.Include)
|
||||||
|
this._intersection.set(Intersection.Include)
|
||||||
|
this._items.set(this.withNullItem(this.items))
|
||||||
if (fireEvent) {
|
if (fireEvent) {
|
||||||
this.changed.next(this)
|
this.changed.next(this)
|
||||||
}
|
}
|
||||||
@@ -419,9 +436,9 @@ export class FilterableDropdownSelectionModel {
|
|||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
return true
|
return true
|
||||||
} else if (this.temporaryLogicalOperator !== this._logicalOperator) {
|
} else if (this.temporaryLogicalOperator() !== this._logicalOperator()) {
|
||||||
return true
|
return true
|
||||||
} else if (this.temporaryIntersection !== this._intersection) {
|
} else if (this.temporaryIntersection() !== this._intersection()) {
|
||||||
return true
|
return true
|
||||||
} else {
|
} else {
|
||||||
return false
|
return false
|
||||||
@@ -438,23 +455,29 @@ export class FilterableDropdownSelectionModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
getDocumentCount(id: number) {
|
getDocumentCount(id: number) {
|
||||||
return this._documentCounts.find((c) => c.id === id)?.document_count
|
return this._documentCounts().find((c) => c.id === id)?.document_count
|
||||||
}
|
}
|
||||||
|
|
||||||
private promoteBranchesWithDocumentCounts() {
|
private promoteBranchesWithDocumentCounts(
|
||||||
const parentById = this.buildParentById()
|
items: MatchingModel[]
|
||||||
|
): MatchingModel[] {
|
||||||
|
const parentById = this.buildParentById(items)
|
||||||
const findRootId = this.createRootFinder(parentById)
|
const findRootId = this.createRootFinder(parentById)
|
||||||
const getRootDocCount = this.createRootDocCounter()
|
const getRootDocCount = this.createRootDocCounter(items)
|
||||||
const summaries = this.buildBranchSummaries(findRootId, getRootDocCount)
|
const summaries = this.buildBranchSummaries(
|
||||||
|
items,
|
||||||
|
findRootId,
|
||||||
|
getRootDocCount
|
||||||
|
)
|
||||||
const orderedBranches = this.orderBranchesByPriority(summaries)
|
const orderedBranches = this.orderBranchesByPriority(summaries)
|
||||||
|
|
||||||
this._items = orderedBranches.flatMap((summary) => summary.items)
|
return orderedBranches.flatMap((summary) => summary.items)
|
||||||
}
|
}
|
||||||
|
|
||||||
private buildParentById(): Map<number, number | null> {
|
private buildParentById(items: MatchingModel[]): Map<number, number | null> {
|
||||||
const parentById = new Map<number, number | null>()
|
const parentById = new Map<number, number | null>()
|
||||||
|
|
||||||
for (const item of this._items) {
|
for (const item of items) {
|
||||||
if (typeof item?.id === 'number') {
|
if (typeof item?.id === 'number') {
|
||||||
const parentValue = (item as any)['parent']
|
const parentValue = (item as any)['parent']
|
||||||
parentById.set(
|
parentById.set(
|
||||||
@@ -492,7 +515,9 @@ export class FilterableDropdownSelectionModel {
|
|||||||
return findRootId
|
return findRootId
|
||||||
}
|
}
|
||||||
|
|
||||||
private createRootDocCounter(): (rootId: number) => number {
|
private createRootDocCounter(
|
||||||
|
items: MatchingModel[]
|
||||||
|
): (rootId: number) => number {
|
||||||
const docCountMemo = new Map<number, number>()
|
const docCountMemo = new Map<number, number>()
|
||||||
|
|
||||||
return (rootId: number): number => {
|
return (rootId: number): number => {
|
||||||
@@ -507,7 +532,7 @@ export class FilterableDropdownSelectionModel {
|
|||||||
return explicit
|
return explicit
|
||||||
}
|
}
|
||||||
|
|
||||||
const rootItem = this._items.find((i) => i.id === rootId)
|
const rootItem = items.find((i) => i.id === rootId)
|
||||||
const fallback =
|
const fallback =
|
||||||
typeof (rootItem as any)?.['document_count'] === 'number'
|
typeof (rootItem as any)?.['document_count'] === 'number'
|
||||||
? (rootItem as any)['document_count']
|
? (rootItem as any)['document_count']
|
||||||
@@ -519,12 +544,13 @@ export class FilterableDropdownSelectionModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private buildBranchSummaries(
|
private buildBranchSummaries(
|
||||||
|
items: MatchingModel[],
|
||||||
findRootId: (id: number) => number,
|
findRootId: (id: number) => number,
|
||||||
getRootDocCount: (rootId: number) => number
|
getRootDocCount: (rootId: number) => number
|
||||||
): Map<string, BranchSummary> {
|
): Map<string, BranchSummary> {
|
||||||
const summaries = new Map<string, BranchSummary>()
|
const summaries = new Map<string, BranchSummary>()
|
||||||
|
|
||||||
for (const [index, item] of this._items.entries()) {
|
for (const [index, item] of items.entries()) {
|
||||||
const { key, special, rootId } = this.describeBranchItem(
|
const { key, special, rootId } = this.describeBranchItem(
|
||||||
item,
|
item,
|
||||||
index,
|
index,
|
||||||
@@ -616,28 +642,23 @@ export class FilterableDropdownSelectionModel {
|
|||||||
}
|
}
|
||||||
|
|
||||||
init(map: Map<number, ToggleableItemState>) {
|
init(map: Map<number, ToggleableItemState>) {
|
||||||
this.temporarySelectionStates = map
|
this._temporarySelectionStates.set(new Map(map))
|
||||||
this.apply()
|
this.apply()
|
||||||
}
|
}
|
||||||
|
|
||||||
apply() {
|
apply() {
|
||||||
this.selectionStates.clear()
|
this._selectionStates.set(new Map(this.temporarySelectionStates))
|
||||||
this.temporarySelectionStates.forEach((value, key) => {
|
this._logicalOperator.set(this.temporaryLogicalOperator())
|
||||||
this.selectionStates.set(key, value)
|
this._intersection.set(this.temporaryIntersection())
|
||||||
})
|
this._items.set(this.sortItems(this.items))
|
||||||
this._logicalOperator = this.temporaryLogicalOperator
|
|
||||||
this._intersection = this.temporaryIntersection
|
|
||||||
this.sortItems()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
reset(complete: boolean = false) {
|
reset(complete: boolean = false) {
|
||||||
this.temporarySelectionStates.clear()
|
|
||||||
if (complete) {
|
if (complete) {
|
||||||
this.selectionStates.clear()
|
this._selectionStates.set(new Map())
|
||||||
|
this._temporarySelectionStates.set(new Map())
|
||||||
} else {
|
} else {
|
||||||
this.selectionStates.forEach((value, key) => {
|
this._temporarySelectionStates.set(new Map(this.selectionStates))
|
||||||
this.temporarySelectionStates.set(key, value)
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+6
@@ -7,6 +7,8 @@
|
|||||||
padding-left: calc(calc(var(--depth) - 2) * 1rem);
|
padding-left: calc(calc(var(--depth) - 2) * 1rem);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
min-width: 0;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
|
||||||
.indicator {
|
.indicator {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
@@ -18,3 +20,7 @@
|
|||||||
margin-left: .5rem;
|
margin-left: .5rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|||||||
+10
-9
@@ -7,7 +7,7 @@
|
|||||||
<div class="list-group list-group-flush">
|
<div class="list-group list-group-flush">
|
||||||
<button class="list-group-item list-group-item-action d-flex align-items-center p-2 border-top-0 border-start-0 border-end-0 border-bottom" role="menuitem" (click)="setFilter(OwnerFilterType.NONE)" [disabled]="disabled">
|
<button class="list-group-item list-group-item-action d-flex align-items-center p-2 border-top-0 border-start-0 border-end-0 border-bottom" role="menuitem" (click)="setFilter(OwnerFilterType.NONE)" [disabled]="disabled">
|
||||||
<div class="selected-icon me-1">
|
<div class="selected-icon me-1">
|
||||||
@if (selectionModel.ownerFilter === OwnerFilterType.NONE) {
|
@if (selectionModel.ownerFilter() === OwnerFilterType.NONE) {
|
||||||
<i-bs width="1em" height="1em" name="check"></i-bs>
|
<i-bs width="1em" height="1em" name="check"></i-bs>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
@@ -17,7 +17,7 @@
|
|||||||
</button>
|
</button>
|
||||||
<button class="list-group-item list-group-item-action d-flex align-items-center p-2 border-top-0 border-start-0 border-end-0 border-bottom" role="menuitem" (click)="setFilter(OwnerFilterType.SELF)" [disabled]="disabled">
|
<button class="list-group-item list-group-item-action d-flex align-items-center p-2 border-top-0 border-start-0 border-end-0 border-bottom" role="menuitem" (click)="setFilter(OwnerFilterType.SELF)" [disabled]="disabled">
|
||||||
<div class="selected-icon me-1">
|
<div class="selected-icon me-1">
|
||||||
@if (selectionModel.ownerFilter === OwnerFilterType.SELF) {
|
@if (selectionModel.ownerFilter() === OwnerFilterType.SELF) {
|
||||||
<i-bs width="1em" height="1em" name="check"></i-bs>
|
<i-bs width="1em" height="1em" name="check"></i-bs>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
@@ -27,7 +27,7 @@
|
|||||||
</button>
|
</button>
|
||||||
<button class="list-group-item list-group-item-action d-flex align-items-center p-2 border-top-0 border-start-0 border-end-0 border-bottom" role="menuitem" (click)="setFilter(OwnerFilterType.NOT_SELF)" [disabled]="disabled">
|
<button class="list-group-item list-group-item-action d-flex align-items-center p-2 border-top-0 border-start-0 border-end-0 border-bottom" role="menuitem" (click)="setFilter(OwnerFilterType.NOT_SELF)" [disabled]="disabled">
|
||||||
<div class="selected-icon me-1">
|
<div class="selected-icon me-1">
|
||||||
@if (selectionModel.ownerFilter === OwnerFilterType.NOT_SELF) {
|
@if (selectionModel.ownerFilter() === OwnerFilterType.NOT_SELF) {
|
||||||
<i-bs width="1em" height="1em" name="check"></i-bs>
|
<i-bs width="1em" height="1em" name="check"></i-bs>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
@@ -37,7 +37,7 @@
|
|||||||
</button>
|
</button>
|
||||||
<button class="list-group-item list-group-item-action d-flex align-items-center p-2 border-top-0 border-start-0 border-end-0 border-bottom" role="menuitem" (click)="setFilter(OwnerFilterType.SHARED_BY_ME)" [disabled]="disabled">
|
<button class="list-group-item list-group-item-action d-flex align-items-center p-2 border-top-0 border-start-0 border-end-0 border-bottom" role="menuitem" (click)="setFilter(OwnerFilterType.SHARED_BY_ME)" [disabled]="disabled">
|
||||||
<div class="selected-icon me-1">
|
<div class="selected-icon me-1">
|
||||||
@if (selectionModel.ownerFilter === OwnerFilterType.SHARED_BY_ME) {
|
@if (selectionModel.ownerFilter() === OwnerFilterType.SHARED_BY_ME) {
|
||||||
<i-bs width="1em" height="1em" name="check"></i-bs>
|
<i-bs width="1em" height="1em" name="check"></i-bs>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
@@ -47,7 +47,7 @@
|
|||||||
</button>
|
</button>
|
||||||
<button class="list-group-item list-group-item-action d-flex align-items-center p-2 border-top-0 border-start-0 border-end-0 border-bottom" role="menuitem" (click)="setFilter(OwnerFilterType.UNOWNED)" [disabled]="disabled">
|
<button class="list-group-item list-group-item-action d-flex align-items-center p-2 border-top-0 border-start-0 border-end-0 border-bottom" role="menuitem" (click)="setFilter(OwnerFilterType.UNOWNED)" [disabled]="disabled">
|
||||||
<div class="selected-icon me-1">
|
<div class="selected-icon me-1">
|
||||||
@if (selectionModel.ownerFilter === OwnerFilterType.UNOWNED) {
|
@if (selectionModel.ownerFilter() === OwnerFilterType.UNOWNED) {
|
||||||
<i-bs width="1em" height="1em" name="check"></i-bs>
|
<i-bs width="1em" height="1em" name="check"></i-bs>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
@@ -57,7 +57,7 @@
|
|||||||
</button>
|
</button>
|
||||||
<button *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.User }" class="list-group-item list-group-item-action d-flex align-items-center p-2 border-top-0 border-start-0 border-end-0 border-bottom" role="menuitem" [disabled]="disabled">
|
<button *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.User }" class="list-group-item list-group-item-action d-flex align-items-center p-2 border-top-0 border-start-0 border-end-0 border-bottom" role="menuitem" [disabled]="disabled">
|
||||||
<div class="selected-icon me-1">
|
<div class="selected-icon me-1">
|
||||||
@if (selectionModel.ownerFilter === OwnerFilterType.OTHERS) {
|
@if (selectionModel.ownerFilter() === OwnerFilterType.OTHERS) {
|
||||||
<i-bs width="1em" height="1em" name="check"></i-bs>
|
<i-bs width="1em" height="1em" name="check"></i-bs>
|
||||||
}
|
}
|
||||||
</div>
|
</div>
|
||||||
@@ -65,7 +65,8 @@
|
|||||||
<ng-select
|
<ng-select
|
||||||
name="user"
|
name="user"
|
||||||
class="user-select small"
|
class="user-select small"
|
||||||
[(ngModel)]="selectionModel.includeUsers"
|
[ngModel]="selectionModel.includeUsers()"
|
||||||
|
(ngModelChange)="selectionModel.includeUsers.set($event)"
|
||||||
[disabled]="disabled"
|
[disabled]="disabled"
|
||||||
[clearable]="false"
|
[clearable]="false"
|
||||||
[items]="users()"
|
[items]="users()"
|
||||||
@@ -78,10 +79,10 @@
|
|||||||
</ng-select>
|
</ng-select>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
@if (selectionModel.ownerFilter === OwnerFilterType.NONE || selectionModel.ownerFilter === OwnerFilterType.NOT_SELF) {
|
@if (selectionModel.ownerFilter() === OwnerFilterType.NONE || selectionModel.ownerFilter() === OwnerFilterType.NOT_SELF) {
|
||||||
<div class="list-group-item list-group-item-action d-flex align-items-center p-2 ps-3 border-bottom-0 border-start-0 border-end-0">
|
<div class="list-group-item list-group-item-action d-flex align-items-center p-2 ps-3 border-bottom-0 border-start-0 border-end-0">
|
||||||
<div class="form-check form-switch w-100">
|
<div class="form-check form-switch w-100">
|
||||||
<input type="checkbox" class="form-check-input" id="hideUnowned" [(ngModel)]="this.selectionModel.hideUnowned" (change)="onChange()" [disabled]="disabled">
|
<input type="checkbox" class="form-check-input" id="hideUnowned" [ngModel]="selectionModel.hideUnowned()" (ngModelChange)="selectionModel.hideUnowned.set($event)" (change)="onChange()" [disabled]="disabled">
|
||||||
<label class="form-check-label w-100" for="hideUnowned"><small i18n>Hide unowned</small></label>
|
<label class="form-check-label w-100" for="hideUnowned"><small i18n>Hide unowned</small></label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+39
-30
@@ -90,56 +90,56 @@ describe('PermissionsFilterDropdownComponent', () => {
|
|||||||
component.setFilter(OwnerFilterType.OTHERS)
|
component.setFilter(OwnerFilterType.OTHERS)
|
||||||
expect(component.isActive).toBeTruthy()
|
expect(component.isActive).toBeTruthy()
|
||||||
component.setFilter(OwnerFilterType.NONE)
|
component.setFilter(OwnerFilterType.NONE)
|
||||||
component.selectionModel.hideUnowned = true
|
component.selectionModel.hideUnowned.set(true)
|
||||||
expect(component.isActive).toBeTruthy()
|
expect(component.isActive).toBeTruthy()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should describe concrete user filters honestly', () => {
|
it('should describe concrete user filters honestly', () => {
|
||||||
component.selectionModel.ownerFilter = OwnerFilterType.SELF
|
component.selectionModel.ownerFilter.set(OwnerFilterType.SELF)
|
||||||
component.selectionModel.userID = 1
|
component.selectionModel.userID.set(1)
|
||||||
expect(component.ownerFilterLabel).toEqual('Owned by user1')
|
expect(component.ownerFilterLabel).toEqual('Owned by user1')
|
||||||
|
|
||||||
component.selectionModel.ownerFilter = OwnerFilterType.NOT_SELF
|
component.selectionModel.ownerFilter.set(OwnerFilterType.NOT_SELF)
|
||||||
component.selectionModel.excludeUsers = [1]
|
component.selectionModel.excludeUsers.set([1])
|
||||||
expect(component.ownerExclusionFilterLabel).toEqual('Not owned by user1')
|
expect(component.ownerExclusionFilterLabel).toEqual('Not owned by user1')
|
||||||
|
|
||||||
component.selectionModel.ownerFilter = OwnerFilterType.SHARED_BY_ME
|
component.selectionModel.ownerFilter.set(OwnerFilterType.SHARED_BY_ME)
|
||||||
component.selectionModel.userID = 1
|
component.selectionModel.userID.set(1)
|
||||||
expect(component.sharedByFilterLabel).toEqual('Shared by user1')
|
expect(component.sharedByFilterLabel).toEqual('Shared by user1')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should describe concrete filters when usernames are unavailable', () => {
|
it('should describe concrete filters when usernames are unavailable', () => {
|
||||||
component.selectionModel.ownerFilter = OwnerFilterType.SELF
|
component.selectionModel.ownerFilter.set(OwnerFilterType.SELF)
|
||||||
component.selectionModel.userID = 99
|
component.selectionModel.userID.set(99)
|
||||||
expect(component.ownerFilterLabel).toEqual('Owned by another user')
|
expect(component.ownerFilterLabel).toEqual('Owned by another user')
|
||||||
|
|
||||||
component.selectionModel.ownerFilter = OwnerFilterType.NOT_SELF
|
component.selectionModel.ownerFilter.set(OwnerFilterType.NOT_SELF)
|
||||||
component.selectionModel.excludeUsers = [99]
|
component.selectionModel.excludeUsers.set([99])
|
||||||
expect(component.ownerExclusionFilterLabel).toEqual(
|
expect(component.ownerExclusionFilterLabel).toEqual(
|
||||||
'Not owned by another user'
|
'Not owned by another user'
|
||||||
)
|
)
|
||||||
|
|
||||||
component.selectionModel.excludeUsers = [98, 99]
|
component.selectionModel.excludeUsers.set([98, 99])
|
||||||
expect(component.ownerExclusionFilterLabel).toEqual(
|
expect(component.ownerExclusionFilterLabel).toEqual(
|
||||||
'Not owned by selected users'
|
'Not owned by selected users'
|
||||||
)
|
)
|
||||||
|
|
||||||
component.selectionModel.ownerFilter = OwnerFilterType.SHARED_BY_ME
|
component.selectionModel.ownerFilter.set(OwnerFilterType.SHARED_BY_ME)
|
||||||
component.selectionModel.userID = 99
|
component.selectionModel.userID.set(99)
|
||||||
expect(component.sharedByFilterLabel).toEqual('Shared by another user')
|
expect(component.sharedByFilterLabel).toEqual('Shared by another user')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should retain relative labels for filters bound to the current user', () => {
|
it('should retain relative labels for filters bound to the current user', () => {
|
||||||
component.selectionModel.userID = currentUserID
|
component.selectionModel.userID.set(currentUserID)
|
||||||
expect(component.ownerFilterLabel).toEqual('My documents')
|
expect(component.ownerFilterLabel).toEqual('My documents')
|
||||||
expect(component.sharedByFilterLabel).toEqual('Shared by me')
|
expect(component.sharedByFilterLabel).toEqual('Shared by me')
|
||||||
|
|
||||||
component.selectionModel.excludeUsers = [currentUserID]
|
component.selectionModel.excludeUsers.set([currentUserID])
|
||||||
expect(component.ownerExclusionFilterLabel).toEqual('Shared with me')
|
expect(component.ownerExclusionFilterLabel).toEqual('Shared with me')
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should retain relative labels for inactive filter choices', () => {
|
it('should retain relative labels for inactive filter choices', () => {
|
||||||
component.selectionModel.ownerFilter = OwnerFilterType.NONE
|
component.selectionModel.ownerFilter.set(OwnerFilterType.NONE)
|
||||||
|
|
||||||
expect(component.ownerFilterLabel).toEqual('My documents')
|
expect(component.ownerFilterLabel).toEqual('My documents')
|
||||||
expect(component.ownerExclusionFilterLabel).toEqual('Shared with me')
|
expect(component.ownerExclusionFilterLabel).toEqual('Shared with me')
|
||||||
@@ -148,32 +148,41 @@ describe('PermissionsFilterDropdownComponent', () => {
|
|||||||
|
|
||||||
it('should support reset', () => {
|
it('should support reset', () => {
|
||||||
component.setFilter(OwnerFilterType.OTHERS)
|
component.setFilter(OwnerFilterType.OTHERS)
|
||||||
expect(component.selectionModel.ownerFilter).not.toEqual(
|
expect(component.selectionModel.ownerFilter()).not.toEqual(
|
||||||
OwnerFilterType.NONE
|
OwnerFilterType.NONE
|
||||||
)
|
)
|
||||||
component.reset()
|
component.reset()
|
||||||
expect(component.selectionModel.ownerFilter).toEqual(OwnerFilterType.NONE)
|
expect(component.selectionModel.ownerFilter()).toEqual(OwnerFilterType.NONE)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should toggle owner filter type when users selected', () => {
|
it('should toggle owner filter type when users selected', () => {
|
||||||
component.selectionModel.ownerFilter = OwnerFilterType.NONE
|
component.selectionModel.ownerFilter.set(OwnerFilterType.NONE)
|
||||||
|
|
||||||
// this would normally be done by select component
|
// this would normally be done by select component
|
||||||
component.selectionModel.includeUsers = [12]
|
component.selectionModel.includeUsers.set([12])
|
||||||
component.onUserSelect()
|
component.onUserSelect()
|
||||||
expect(component.selectionModel.ownerFilter).toEqual(OwnerFilterType.OTHERS)
|
expect(component.selectionModel.ownerFilter()).toEqual(
|
||||||
|
OwnerFilterType.OTHERS
|
||||||
|
)
|
||||||
|
|
||||||
// this would normally be done by select component
|
// this would normally be done by select component
|
||||||
component.selectionModel.includeUsers = null
|
component.selectionModel.includeUsers.set(null)
|
||||||
component.onUserSelect()
|
component.onUserSelect()
|
||||||
|
|
||||||
expect(component.selectionModel.ownerFilter).toEqual(OwnerFilterType.NONE)
|
expect(component.selectionModel.ownerFilter()).toEqual(OwnerFilterType.NONE)
|
||||||
})
|
})
|
||||||
it('should emit a selection model depending on the type of owner filter set', () => {
|
it('should emit a selection model depending on the type of owner filter set', () => {
|
||||||
component.selectionModel.ownerFilter = OwnerFilterType.NONE
|
const emitted = () => ({
|
||||||
|
excludeUsers: ownerFilterSetResult.excludeUsers(),
|
||||||
|
hideUnowned: ownerFilterSetResult.hideUnowned(),
|
||||||
|
includeUsers: ownerFilterSetResult.includeUsers(),
|
||||||
|
ownerFilter: ownerFilterSetResult.ownerFilter(),
|
||||||
|
userID: ownerFilterSetResult.userID(),
|
||||||
|
})
|
||||||
|
component.selectionModel.ownerFilter.set(OwnerFilterType.NONE)
|
||||||
|
|
||||||
component.setFilter(OwnerFilterType.SELF)
|
component.setFilter(OwnerFilterType.SELF)
|
||||||
expect(ownerFilterSetResult).toEqual({
|
expect(emitted()).toEqual({
|
||||||
excludeUsers: [],
|
excludeUsers: [],
|
||||||
hideUnowned: false,
|
hideUnowned: false,
|
||||||
includeUsers: [],
|
includeUsers: [],
|
||||||
@@ -182,7 +191,7 @@ describe('PermissionsFilterDropdownComponent', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
component.setFilter(OwnerFilterType.NOT_SELF)
|
component.setFilter(OwnerFilterType.NOT_SELF)
|
||||||
expect(ownerFilterSetResult).toEqual({
|
expect(emitted()).toEqual({
|
||||||
excludeUsers: [currentUserID],
|
excludeUsers: [currentUserID],
|
||||||
hideUnowned: false,
|
hideUnowned: false,
|
||||||
includeUsers: [],
|
includeUsers: [],
|
||||||
@@ -191,7 +200,7 @@ describe('PermissionsFilterDropdownComponent', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
component.setFilter(OwnerFilterType.NONE)
|
component.setFilter(OwnerFilterType.NONE)
|
||||||
expect(ownerFilterSetResult).toEqual({
|
expect(emitted()).toEqual({
|
||||||
excludeUsers: [],
|
excludeUsers: [],
|
||||||
hideUnowned: false,
|
hideUnowned: false,
|
||||||
includeUsers: [],
|
includeUsers: [],
|
||||||
@@ -200,7 +209,7 @@ describe('PermissionsFilterDropdownComponent', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
component.setFilter(OwnerFilterType.SHARED_BY_ME)
|
component.setFilter(OwnerFilterType.SHARED_BY_ME)
|
||||||
expect(ownerFilterSetResult).toEqual({
|
expect(emitted()).toEqual({
|
||||||
excludeUsers: [],
|
excludeUsers: [],
|
||||||
hideUnowned: false,
|
hideUnowned: false,
|
||||||
includeUsers: [],
|
includeUsers: [],
|
||||||
@@ -209,7 +218,7 @@ describe('PermissionsFilterDropdownComponent', () => {
|
|||||||
})
|
})
|
||||||
|
|
||||||
component.setFilter(OwnerFilterType.UNOWNED)
|
component.setFilter(OwnerFilterType.UNOWNED)
|
||||||
expect(ownerFilterSetResult).toEqual({
|
expect(emitted()).toEqual({
|
||||||
excludeUsers: [],
|
excludeUsers: [],
|
||||||
hideUnowned: false,
|
hideUnowned: false,
|
||||||
includeUsers: [],
|
includeUsers: [],
|
||||||
|
|||||||
+53
-53
@@ -25,18 +25,18 @@ import { ComponentWithPermissions } from '../../with-permissions/with-permission
|
|||||||
import { ClearableBadgeComponent } from '../clearable-badge/clearable-badge.component'
|
import { ClearableBadgeComponent } from '../clearable-badge/clearable-badge.component'
|
||||||
|
|
||||||
export class PermissionsSelectionModel {
|
export class PermissionsSelectionModel {
|
||||||
ownerFilter: OwnerFilterType
|
readonly ownerFilter = signal(OwnerFilterType.NONE)
|
||||||
hideUnowned: boolean
|
readonly hideUnowned = signal(false)
|
||||||
userID: number
|
readonly userID = signal<number>(null)
|
||||||
includeUsers: number[]
|
readonly includeUsers = signal<number[]>([])
|
||||||
excludeUsers: number[]
|
readonly excludeUsers = signal<number[]>([])
|
||||||
|
|
||||||
clear() {
|
clear() {
|
||||||
this.ownerFilter = OwnerFilterType.NONE
|
this.ownerFilter.set(OwnerFilterType.NONE)
|
||||||
this.userID = null
|
this.userID.set(null)
|
||||||
this.hideUnowned = false
|
this.hideUnowned.set(false)
|
||||||
this.includeUsers = []
|
this.includeUsers.set([])
|
||||||
this.excludeUsers = []
|
this.excludeUsers.set([])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -84,33 +84,31 @@ export class PermissionsFilterDropdownComponent extends ComponentWithPermissions
|
|||||||
|
|
||||||
readonly users = signal<User[]>([])
|
readonly users = signal<User[]>([])
|
||||||
|
|
||||||
hideUnowned: boolean
|
|
||||||
|
|
||||||
get isActive(): boolean {
|
get isActive(): boolean {
|
||||||
return (
|
return (
|
||||||
this.selectionModel.ownerFilter !== OwnerFilterType.NONE ||
|
this.selectionModel.ownerFilter() !== OwnerFilterType.NONE ||
|
||||||
this.selectionModel.hideUnowned
|
this.selectionModel.hideUnowned()
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
get ownerFilterLabel(): string {
|
get ownerFilterLabel(): string {
|
||||||
if (
|
if (
|
||||||
this.selectionModel?.ownerFilter !== OwnerFilterType.SELF ||
|
this.selectionModel?.ownerFilter() !== OwnerFilterType.SELF ||
|
||||||
this.selectionModel?.userID === this.settingsService.currentUser()?.id
|
this.selectionModel?.userID() === this.settingsService.currentUser()?.id
|
||||||
) {
|
) {
|
||||||
return $localize`My documents`
|
return $localize`My documents`
|
||||||
}
|
}
|
||||||
|
|
||||||
const username = this.getUsername(this.selectionModel?.userID)
|
const username = this.getUsername(this.selectionModel?.userID())
|
||||||
return username
|
return username
|
||||||
? $localize`Owned by ${username}`
|
? $localize`Owned by ${username}`
|
||||||
: $localize`Owned by another user`
|
: $localize`Owned by another user`
|
||||||
}
|
}
|
||||||
|
|
||||||
get ownerExclusionFilterLabel(): string {
|
get ownerExclusionFilterLabel(): string {
|
||||||
const excludedUsers = this.selectionModel?.excludeUsers ?? []
|
const excludedUsers = this.selectionModel?.excludeUsers() ?? []
|
||||||
if (
|
if (
|
||||||
this.selectionModel?.ownerFilter !== OwnerFilterType.NOT_SELF ||
|
this.selectionModel?.ownerFilter() !== OwnerFilterType.NOT_SELF ||
|
||||||
(excludedUsers.length === 1 &&
|
(excludedUsers.length === 1 &&
|
||||||
excludedUsers[0] === this.settingsService.currentUser()?.id)
|
excludedUsers[0] === this.settingsService.currentUser()?.id)
|
||||||
) {
|
) {
|
||||||
@@ -130,13 +128,13 @@ export class PermissionsFilterDropdownComponent extends ComponentWithPermissions
|
|||||||
|
|
||||||
get sharedByFilterLabel(): string {
|
get sharedByFilterLabel(): string {
|
||||||
if (
|
if (
|
||||||
this.selectionModel?.ownerFilter !== OwnerFilterType.SHARED_BY_ME ||
|
this.selectionModel?.ownerFilter() !== OwnerFilterType.SHARED_BY_ME ||
|
||||||
this.selectionModel?.userID === this.settingsService.currentUser()?.id
|
this.selectionModel?.userID() === this.settingsService.currentUser()?.id
|
||||||
) {
|
) {
|
||||||
return $localize`Shared by me`
|
return $localize`Shared by me`
|
||||||
}
|
}
|
||||||
|
|
||||||
const username = this.getUsername(this.selectionModel?.userID)
|
const username = this.getUsername(this.selectionModel?.userID())
|
||||||
return username
|
return username
|
||||||
? $localize`Shared by ${username}`
|
? $localize`Shared by ${username}`
|
||||||
: $localize`Shared by another user`
|
: $localize`Shared by another user`
|
||||||
@@ -169,34 +167,36 @@ export class PermissionsFilterDropdownComponent extends ComponentWithPermissions
|
|||||||
}
|
}
|
||||||
|
|
||||||
setFilter(type: OwnerFilterType) {
|
setFilter(type: OwnerFilterType) {
|
||||||
this.selectionModel.ownerFilter = type
|
this.selectionModel.ownerFilter.set(type)
|
||||||
if (this.selectionModel.ownerFilter === OwnerFilterType.SELF) {
|
if (this.selectionModel.ownerFilter() === OwnerFilterType.SELF) {
|
||||||
this.selectionModel.includeUsers = []
|
this.selectionModel.includeUsers.set([])
|
||||||
this.selectionModel.excludeUsers = []
|
this.selectionModel.excludeUsers.set([])
|
||||||
this.selectionModel.userID = this.settingsService.currentUser().id
|
this.selectionModel.userID.set(this.settingsService.currentUser().id)
|
||||||
this.selectionModel.hideUnowned = false
|
this.selectionModel.hideUnowned.set(false)
|
||||||
} else if (this.selectionModel.ownerFilter === OwnerFilterType.NOT_SELF) {
|
} else if (this.selectionModel.ownerFilter() === OwnerFilterType.NOT_SELF) {
|
||||||
this.selectionModel.userID = null
|
this.selectionModel.userID.set(null)
|
||||||
this.selectionModel.includeUsers = []
|
this.selectionModel.includeUsers.set([])
|
||||||
this.selectionModel.excludeUsers = [this.settingsService.currentUser().id]
|
this.selectionModel.excludeUsers.set([
|
||||||
this.selectionModel.hideUnowned = false
|
this.settingsService.currentUser().id,
|
||||||
} else if (this.selectionModel.ownerFilter === OwnerFilterType.NONE) {
|
])
|
||||||
this.selectionModel.userID = null
|
this.selectionModel.hideUnowned.set(false)
|
||||||
this.selectionModel.includeUsers = []
|
} else if (this.selectionModel.ownerFilter() === OwnerFilterType.NONE) {
|
||||||
this.selectionModel.excludeUsers = []
|
this.selectionModel.userID.set(null)
|
||||||
this.selectionModel.hideUnowned = false
|
this.selectionModel.includeUsers.set([])
|
||||||
|
this.selectionModel.excludeUsers.set([])
|
||||||
|
this.selectionModel.hideUnowned.set(false)
|
||||||
} else if (
|
} else if (
|
||||||
this.selectionModel.ownerFilter === OwnerFilterType.SHARED_BY_ME
|
this.selectionModel.ownerFilter() === OwnerFilterType.SHARED_BY_ME
|
||||||
) {
|
) {
|
||||||
this.selectionModel.userID = this.settingsService.currentUser()?.id
|
this.selectionModel.userID.set(this.settingsService.currentUser()?.id)
|
||||||
this.selectionModel.includeUsers = []
|
this.selectionModel.includeUsers.set([])
|
||||||
this.selectionModel.excludeUsers = []
|
this.selectionModel.excludeUsers.set([])
|
||||||
this.selectionModel.hideUnowned = false
|
this.selectionModel.hideUnowned.set(false)
|
||||||
} else if (this.selectionModel.ownerFilter === OwnerFilterType.UNOWNED) {
|
} else if (this.selectionModel.ownerFilter() === OwnerFilterType.UNOWNED) {
|
||||||
this.selectionModel.userID = null
|
this.selectionModel.userID.set(null)
|
||||||
this.selectionModel.includeUsers = []
|
this.selectionModel.includeUsers.set([])
|
||||||
this.selectionModel.excludeUsers = []
|
this.selectionModel.excludeUsers.set([])
|
||||||
this.selectionModel.hideUnowned = false
|
this.selectionModel.hideUnowned.set(false)
|
||||||
}
|
}
|
||||||
this.onChange()
|
this.onChange()
|
||||||
}
|
}
|
||||||
@@ -206,11 +206,11 @@ export class PermissionsFilterDropdownComponent extends ComponentWithPermissions
|
|||||||
}
|
}
|
||||||
|
|
||||||
onUserSelect() {
|
onUserSelect() {
|
||||||
if (this.selectionModel.includeUsers?.length) {
|
this.selectionModel.ownerFilter.set(
|
||||||
this.selectionModel.ownerFilter = OwnerFilterType.OTHERS
|
this.selectionModel.includeUsers()?.length
|
||||||
} else {
|
? OwnerFilterType.OTHERS
|
||||||
this.selectionModel.ownerFilter = OwnerFilterType.NONE
|
: OwnerFilterType.NONE
|
||||||
}
|
)
|
||||||
this.onChange()
|
this.onChange()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1209,24 +1209,53 @@ describe('DocumentDetailComponent', () => {
|
|||||||
expect(fixture.debugElement.queryAll(By.css('textarea.rtl'))).not.toBeNull()
|
expect(fixture.debugElement.queryAll(By.css('textarea.rtl'))).not.toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should display built-in pdf viewer if not disabled', () => {
|
it('should display built-in pdf viewer if not disabled', async () => {
|
||||||
initNormally()
|
initNormally()
|
||||||
component.document().archived_file_name = 'file.pdf'
|
component.document.update((document) => ({
|
||||||
|
...document,
|
||||||
|
archived_file_name: 'file.pdf',
|
||||||
|
}))
|
||||||
settingsService.set(SETTINGS_KEYS.USE_NATIVE_PDF_VIEWER, false)
|
settingsService.set(SETTINGS_KEYS.USE_NATIVE_PDF_VIEWER, false)
|
||||||
expect(component.useNativePdfViewer).toBeFalsy()
|
expect(component.useNativePdfViewer).toBeFalsy()
|
||||||
fixture.detectChanges()
|
await fixture.whenStable()
|
||||||
expect(fixture.debugElement.query(By.css('pngx-pdf-viewer'))).not.toBeNull()
|
expect(fixture.debugElement.query(By.css('pngx-pdf-viewer'))).not.toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should display native pdf viewer if enabled', () => {
|
it('should display native pdf viewer if enabled', () => {
|
||||||
initNormally()
|
initNormally()
|
||||||
component.document().archived_file_name = 'file.pdf'
|
component.document.update((document) => ({
|
||||||
|
...document,
|
||||||
|
archived_file_name: 'file.pdf',
|
||||||
|
}))
|
||||||
settingsService.set(SETTINGS_KEYS.USE_NATIVE_PDF_VIEWER, true)
|
settingsService.set(SETTINGS_KEYS.USE_NATIVE_PDF_VIEWER, true)
|
||||||
expect(component.useNativePdfViewer).toBeTruthy()
|
expect(component.useNativePdfViewer).toBeTruthy()
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
expect(fixture.debugElement.query(By.css('object'))).not.toBeNull()
|
expect(fixture.debugElement.query(By.css('object'))).not.toBeNull()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should reflect signal-backed document detail display settings', () => {
|
||||||
|
settingsService.set(SETTINGS_KEYS.DOCUMENT_EDITING_OVERLAY_THUMBNAIL, false)
|
||||||
|
settingsService.set(SETTINGS_KEYS.DOCUMENT_DETAILS_HIDDEN_FIELDS, [
|
||||||
|
component.DocumentDetailFieldID.Correspondent,
|
||||||
|
])
|
||||||
|
|
||||||
|
expect(component.showThumbnailOverlay).toBeFalsy()
|
||||||
|
expect(
|
||||||
|
component.isFieldHidden(component.DocumentDetailFieldID.Correspondent)
|
||||||
|
).toBeTruthy()
|
||||||
|
expect(
|
||||||
|
component.isFieldHidden(component.DocumentDetailFieldID.DocumentType)
|
||||||
|
).toBeFalsy()
|
||||||
|
|
||||||
|
settingsService.set(SETTINGS_KEYS.DOCUMENT_EDITING_OVERLAY_THUMBNAIL, true)
|
||||||
|
settingsService.set(SETTINGS_KEYS.DOCUMENT_DETAILS_HIDDEN_FIELDS, [])
|
||||||
|
|
||||||
|
expect(component.showThumbnailOverlay).toBeTruthy()
|
||||||
|
expect(
|
||||||
|
component.isFieldHidden(component.DocumentDetailFieldID.Correspondent)
|
||||||
|
).toBeFalsy()
|
||||||
|
})
|
||||||
|
|
||||||
it('should attempt to retrieve metadata', () => {
|
it('should attempt to retrieve metadata', () => {
|
||||||
const metadataSpy = jest.spyOn(documentService, 'getMetadata')
|
const metadataSpy = jest.spyOn(documentService, 'getMetadata')
|
||||||
metadataSpy.mockReturnValue(of({ has_archive_version: true }))
|
metadataSpy.mockReturnValue(of({ has_archive_version: true }))
|
||||||
@@ -1685,7 +1714,10 @@ describe('DocumentDetailComponent', () => {
|
|||||||
|
|
||||||
it('should change preview element by render type', () => {
|
it('should change preview element by render type', () => {
|
||||||
initNormally()
|
initNormally()
|
||||||
component.document().archived_file_name = 'file.pdf'
|
component.document.update((document) => ({
|
||||||
|
...document,
|
||||||
|
archived_file_name: 'file.pdf',
|
||||||
|
}))
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
expect(component.archiveContentRenderType).toEqual(
|
expect(component.archiveContentRenderType).toEqual(
|
||||||
component.ContentRenderType.PDF
|
component.ContentRenderType.PDF
|
||||||
@@ -1694,8 +1726,11 @@ describe('DocumentDetailComponent', () => {
|
|||||||
fixture.debugElement.query(By.css('pdf-viewer-container'))
|
fixture.debugElement.query(By.css('pdf-viewer-container'))
|
||||||
).not.toBeUndefined()
|
).not.toBeUndefined()
|
||||||
|
|
||||||
component.document().archived_file_name = undefined
|
component.document.update((document) => ({
|
||||||
component.document().mime_type = 'text/plain'
|
...document,
|
||||||
|
archived_file_name: undefined,
|
||||||
|
mime_type: 'text/plain',
|
||||||
|
}))
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
expect(component.archiveContentRenderType).toEqual(
|
expect(component.archiveContentRenderType).toEqual(
|
||||||
component.ContentRenderType.Text
|
component.ContentRenderType.Text
|
||||||
@@ -1704,7 +1739,10 @@ describe('DocumentDetailComponent', () => {
|
|||||||
fixture.debugElement.query(By.css('div.preview-sticky'))
|
fixture.debugElement.query(By.css('div.preview-sticky'))
|
||||||
).not.toBeUndefined()
|
).not.toBeUndefined()
|
||||||
|
|
||||||
component.document().mime_type = 'image/jpeg'
|
component.document.update((document) => ({
|
||||||
|
...document,
|
||||||
|
mime_type: 'image/jpeg',
|
||||||
|
}))
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
expect(component.archiveContentRenderType).toEqual(
|
expect(component.archiveContentRenderType).toEqual(
|
||||||
component.ContentRenderType.Image
|
component.ContentRenderType.Image
|
||||||
@@ -1712,9 +1750,12 @@ describe('DocumentDetailComponent', () => {
|
|||||||
expect(
|
expect(
|
||||||
fixture.debugElement.query(By.css('.preview-sticky img'))
|
fixture.debugElement.query(By.css('.preview-sticky img'))
|
||||||
).not.toBeUndefined()
|
).not.toBeUndefined()
|
||||||
;((component.document().mime_type =
|
component.document.update((document) => ({
|
||||||
'application/vnd.openxmlformats-officedocument.wordprocessingml.document'),
|
...document,
|
||||||
fixture.detectChanges())
|
mime_type:
|
||||||
|
'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
|
||||||
|
}))
|
||||||
|
fixture.detectChanges()
|
||||||
expect(component.archiveContentRenderType).toEqual(
|
expect(component.archiveContentRenderType).toEqual(
|
||||||
component.ContentRenderType.Other
|
component.ContentRenderType.Other
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -227,6 +227,19 @@ export class DocumentDetailComponent
|
|||||||
private deviceDetectorService = inject(DeviceDetectorService)
|
private deviceDetectorService = inject(DeviceDetectorService)
|
||||||
private savedViewService = inject(SavedViewService)
|
private savedViewService = inject(SavedViewService)
|
||||||
private readonly websocketStatusService = inject(WebsocketStatusService)
|
private readonly websocketStatusService = inject(WebsocketStatusService)
|
||||||
|
private readonly useNativePdfViewerSetting = this.settings.getSignal<boolean>(
|
||||||
|
SETTINGS_KEYS.USE_NATIVE_PDF_VIEWER
|
||||||
|
)
|
||||||
|
private readonly aiEnabledSetting = this.settings.getSignal<boolean>(
|
||||||
|
SETTINGS_KEYS.AI_ENABLED
|
||||||
|
)
|
||||||
|
private readonly showThumbnailOverlaySetting =
|
||||||
|
this.settings.getSignal<boolean>(
|
||||||
|
SETTINGS_KEYS.DOCUMENT_EDITING_OVERLAY_THUMBNAIL
|
||||||
|
)
|
||||||
|
private readonly hiddenFieldsSetting = this.settings.getSignal<
|
||||||
|
DocumentDetailFieldID[]
|
||||||
|
>(SETTINGS_KEYS.DOCUMENT_DETAILS_HIDDEN_FIELDS)
|
||||||
|
|
||||||
@ViewChild('inputTitle')
|
@ViewChild('inputTitle')
|
||||||
titleInput: TextComponent
|
titleInput: TextComponent
|
||||||
@@ -333,8 +346,7 @@ export class DocumentDetailComponent
|
|||||||
}
|
}
|
||||||
|
|
||||||
get useNativePdfViewer(): boolean {
|
get useNativePdfViewer(): boolean {
|
||||||
this.settings.trackChanges()
|
return this.useNativePdfViewerSetting()
|
||||||
return this.settings.get(SETTINGS_KEYS.USE_NATIVE_PDF_VIEWER)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
get isMobile(): boolean {
|
get isMobile(): boolean {
|
||||||
@@ -342,12 +354,10 @@ export class DocumentDetailComponent
|
|||||||
}
|
}
|
||||||
|
|
||||||
get aiEnabled(): boolean {
|
get aiEnabled(): boolean {
|
||||||
this.settings.trackChanges()
|
return this.aiEnabledSetting()
|
||||||
return this.settings.get(SETTINGS_KEYS.AI_ENABLED)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
get archiveContentRenderType(): ContentRenderType {
|
get archiveContentRenderType(): ContentRenderType {
|
||||||
this.settings.trackChanges()
|
|
||||||
const hasArchiveVersion =
|
const hasArchiveVersion =
|
||||||
this.metadata()?.has_archive_version ??
|
this.metadata()?.has_archive_version ??
|
||||||
!!this.document()?.archived_file_name
|
!!this.document()?.archived_file_name
|
||||||
@@ -359,22 +369,17 @@ export class DocumentDetailComponent
|
|||||||
}
|
}
|
||||||
|
|
||||||
get originalContentRenderType(): ContentRenderType {
|
get originalContentRenderType(): ContentRenderType {
|
||||||
this.settings.trackChanges()
|
|
||||||
return this.getRenderType(
|
return this.getRenderType(
|
||||||
this.metadata()?.original_mime_type || this.document()?.mime_type
|
this.metadata()?.original_mime_type || this.document()?.mime_type
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
get showThumbnailOverlay(): boolean {
|
get showThumbnailOverlay(): boolean {
|
||||||
this.settings.trackChanges()
|
return this.showThumbnailOverlaySetting()
|
||||||
return this.settings.get(SETTINGS_KEYS.DOCUMENT_EDITING_OVERLAY_THUMBNAIL)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
isFieldHidden(fieldId: DocumentDetailFieldID): boolean {
|
isFieldHidden(fieldId: DocumentDetailFieldID): boolean {
|
||||||
this.settings.trackChanges()
|
return this.hiddenFieldsSetting().includes(fieldId)
|
||||||
return this.settings
|
|
||||||
.get(SETTINGS_KEYS.DOCUMENT_DETAILS_HIDDEN_FIELDS)
|
|
||||||
.includes(fieldId)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private getRenderType(mimeType: string): ContentRenderType {
|
private getRenderType(mimeType: string): ContentRenderType {
|
||||||
|
|||||||
@@ -116,7 +116,7 @@
|
|||||||
|
|
||||||
</pngx-page-header>
|
</pngx-page-header>
|
||||||
|
|
||||||
<div class="row sticky-top py-3 mt-n2 mt-md-n3 bg-body">
|
<div class="row sticky-top py-3 mt-n2 mt-md-n3 bg-body rounded shadow-sm">
|
||||||
<pngx-filter-editor [hidden]="isBulkEditing" [disabled]="isBulkEditing" [filterRules]="list.filterRules" (filterRulesChange)="onFilterRulesChange($event)" (resetFilterRules)="onFilterRulesReset($event)" [unmodifiedFilterRules]="unmodifiedFilterRules()" [selectionData]="list.selectionData" #filterEditor></pngx-filter-editor>
|
<pngx-filter-editor [hidden]="isBulkEditing" [disabled]="isBulkEditing" [filterRules]="list.filterRules" (filterRulesChange)="onFilterRulesChange($event)" (resetFilterRules)="onFilterRulesReset($event)" [unmodifiedFilterRules]="unmodifiedFilterRules()" [selectionData]="list.selectionData" #filterEditor></pngx-filter-editor>
|
||||||
<pngx-bulk-editor [hidden]="!isBulkEditing" [disabled]="!isBulkEditing"></pngx-bulk-editor>
|
<pngx-bulk-editor [hidden]="!isBulkEditing" [disabled]="!isBulkEditing"></pngx-bulk-editor>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -121,6 +121,8 @@ export class DocumentListComponent
|
|||||||
settingsService = inject(SettingsService)
|
settingsService = inject(SettingsService)
|
||||||
private hotKeyService = inject(HotKeyService)
|
private hotKeyService = inject(HotKeyService)
|
||||||
permissionService = inject(PermissionsService)
|
permissionService = inject(PermissionsService)
|
||||||
|
private readonly notesEnabledSetting =
|
||||||
|
this.settingsService.getSignal<boolean>(SETTINGS_KEYS.NOTES_ENABLED)
|
||||||
|
|
||||||
DisplayField = DisplayField
|
DisplayField = DisplayField
|
||||||
DisplayMode = DisplayMode
|
DisplayMode = DisplayMode
|
||||||
@@ -574,8 +576,7 @@ export class DocumentListComponent
|
|||||||
}
|
}
|
||||||
|
|
||||||
get notesEnabled(): boolean {
|
get notesEnabled(): boolean {
|
||||||
this.settingsService.trackChanges()
|
return this.notesEnabledSetting()
|
||||||
return this.settingsService.get(SETTINGS_KEYS.NOTES_ENABLED)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
resetFilters() {
|
resetFilters() {
|
||||||
|
|||||||
+129
-20
@@ -621,6 +621,43 @@ describe('FilterEditorComponent', () => {
|
|||||||
component.toggleTag(2) // coverage
|
component.toggleTag(2) // coverage
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should reflect ingested tag filter rules in the dropdown toggle', () => {
|
||||||
|
const dropdown = fixture.debugElement.query(
|
||||||
|
By.css('pngx-filterable-dropdown')
|
||||||
|
)
|
||||||
|
const toggle = dropdown.nativeElement.querySelector('#dropdown_tags')
|
||||||
|
expect(toggle.classList.contains('btn-primary')).toBeFalsy()
|
||||||
|
expect(
|
||||||
|
dropdown.nativeElement.querySelector('pngx-clearable-badge')
|
||||||
|
).toBeNull()
|
||||||
|
|
||||||
|
// switching to a view with a tag filter
|
||||||
|
component.filterRules = [
|
||||||
|
{
|
||||||
|
rule_type: FILTER_HAS_TAGS_ALL,
|
||||||
|
value: '2',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
fixture.detectChanges()
|
||||||
|
expect(toggle.classList.contains('btn-primary')).toBeTruthy()
|
||||||
|
expect(
|
||||||
|
dropdown.nativeElement.querySelector('pngx-clearable-badge')
|
||||||
|
).not.toBeNull()
|
||||||
|
|
||||||
|
// and back to a view without one
|
||||||
|
component.filterRules = [
|
||||||
|
{
|
||||||
|
rule_type: FILTER_HAS_CORRESPONDENT_ANY,
|
||||||
|
value: '12',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
fixture.detectChanges()
|
||||||
|
expect(toggle.classList.contains('btn-primary')).toBeFalsy()
|
||||||
|
expect(
|
||||||
|
dropdown.nativeElement.querySelector('pngx-clearable-badge')
|
||||||
|
).toBeNull()
|
||||||
|
})
|
||||||
|
|
||||||
it('should ingest filter rules for has any tags', () => {
|
it('should ingest filter rules for has any tags', () => {
|
||||||
expect(component.tagSelectionModel.getSelectedItems()).toHaveLength(0)
|
expect(component.tagSelectionModel.getSelectedItems()).toHaveLength(0)
|
||||||
component.filterRules = [
|
component.filterRules = [
|
||||||
@@ -1034,8 +1071,51 @@ describe('FilterEditorComponent', () => {
|
|||||||
).toEqual([42, CustomFieldQueryOperator.Exists, 'true'])
|
).toEqual([42, CustomFieldQueryOperator.Exists, 'true'])
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should reflect ingested custom field query rules in the dropdown toggle', () => {
|
||||||
|
const dropdown = fixture.debugElement.query(
|
||||||
|
By.css('pngx-custom-fields-query-dropdown')
|
||||||
|
)
|
||||||
|
expect(
|
||||||
|
dropdown.nativeElement.querySelector('pngx-clearable-badge')
|
||||||
|
).toBeNull()
|
||||||
|
|
||||||
|
// switching to a view with a custom field query
|
||||||
|
component.filterRules = [
|
||||||
|
{
|
||||||
|
rule_type: FILTER_CUSTOM_FIELDS_QUERY,
|
||||||
|
value: '["OR",[[42,"exists","true"]]]',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
fixture.detectChanges()
|
||||||
|
expect(
|
||||||
|
dropdown.nativeElement.querySelector('pngx-clearable-badge')
|
||||||
|
).not.toBeNull()
|
||||||
|
expect(
|
||||||
|
dropdown.nativeElement
|
||||||
|
.querySelector('#dropdown_toggle')
|
||||||
|
.classList.contains('btn-primary')
|
||||||
|
).toBeTruthy()
|
||||||
|
|
||||||
|
// and back to a view without one
|
||||||
|
component.filterRules = [
|
||||||
|
{
|
||||||
|
rule_type: FILTER_HAS_TAGS_ALL,
|
||||||
|
value: '19',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
fixture.detectChanges()
|
||||||
|
expect(
|
||||||
|
dropdown.nativeElement.querySelector('pngx-clearable-badge')
|
||||||
|
).toBeNull()
|
||||||
|
expect(
|
||||||
|
dropdown.nativeElement
|
||||||
|
.querySelector('#dropdown_toggle')
|
||||||
|
.classList.contains('btn-primary')
|
||||||
|
).toBeFalsy()
|
||||||
|
})
|
||||||
|
|
||||||
it('should ingest filter rules for owner', () => {
|
it('should ingest filter rules for owner', () => {
|
||||||
expect(component.permissionsSelectionModel.ownerFilter).toEqual(
|
expect(component.permissionsSelectionModel.ownerFilter()).toEqual(
|
||||||
OwnerFilterType.NONE
|
OwnerFilterType.NONE
|
||||||
)
|
)
|
||||||
component.filterRules = [
|
component.filterRules = [
|
||||||
@@ -1044,15 +1124,38 @@ describe('FilterEditorComponent', () => {
|
|||||||
value: '100',
|
value: '100',
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
expect(component.permissionsSelectionModel.ownerFilter).toEqual(
|
expect(component.permissionsSelectionModel.ownerFilter()).toEqual(
|
||||||
OwnerFilterType.SELF
|
OwnerFilterType.SELF
|
||||||
)
|
)
|
||||||
expect(component.permissionsSelectionModel.hideUnowned).toBeFalsy()
|
expect(component.permissionsSelectionModel.hideUnowned()).toBeFalsy()
|
||||||
expect(component.permissionsSelectionModel.userID).toEqual(100)
|
expect(component.permissionsSelectionModel.userID()).toEqual(100)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should reflect ingested owner filter rules in the dropdown toggle', () => {
|
||||||
|
const dropdown = fixture.debugElement.query(
|
||||||
|
By.css('pngx-permissions-filter-dropdown')
|
||||||
|
)
|
||||||
|
const toggle = dropdown.nativeElement.querySelector('button')
|
||||||
|
expect(toggle.classList.contains('btn-primary')).toBeFalsy()
|
||||||
|
|
||||||
|
// switching to a view with an owner filter
|
||||||
|
component.filterRules = [
|
||||||
|
{
|
||||||
|
rule_type: FILTER_OWNER,
|
||||||
|
value: '100',
|
||||||
|
},
|
||||||
|
]
|
||||||
|
fixture.detectChanges()
|
||||||
|
expect(toggle.classList.contains('btn-primary')).toBeTruthy()
|
||||||
|
|
||||||
|
// and back to a view without one
|
||||||
|
component.filterRules = []
|
||||||
|
fixture.detectChanges()
|
||||||
|
expect(toggle.classList.contains('btn-primary')).toBeFalsy()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should ingest filter rules for owner is others', () => {
|
it('should ingest filter rules for owner is others', () => {
|
||||||
expect(component.permissionsSelectionModel.ownerFilter).toEqual(
|
expect(component.permissionsSelectionModel.ownerFilter()).toEqual(
|
||||||
OwnerFilterType.NONE
|
OwnerFilterType.NONE
|
||||||
)
|
)
|
||||||
component.filterRules = [
|
component.filterRules = [
|
||||||
@@ -1061,14 +1164,14 @@ describe('FilterEditorComponent', () => {
|
|||||||
value: '50',
|
value: '50',
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
expect(component.permissionsSelectionModel.ownerFilter).toEqual(
|
expect(component.permissionsSelectionModel.ownerFilter()).toEqual(
|
||||||
OwnerFilterType.OTHERS
|
OwnerFilterType.OTHERS
|
||||||
)
|
)
|
||||||
expect(component.permissionsSelectionModel.includeUsers).toContain(50)
|
expect(component.permissionsSelectionModel.includeUsers()).toContain(50)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should ingest filter rules for owner does not include others', () => {
|
it('should ingest filter rules for owner does not include others', () => {
|
||||||
expect(component.permissionsSelectionModel.ownerFilter).toEqual(
|
expect(component.permissionsSelectionModel.ownerFilter()).toEqual(
|
||||||
OwnerFilterType.NONE
|
OwnerFilterType.NONE
|
||||||
)
|
)
|
||||||
component.filterRules = [
|
component.filterRules = [
|
||||||
@@ -1077,14 +1180,14 @@ describe('FilterEditorComponent', () => {
|
|||||||
value: '50',
|
value: '50',
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
expect(component.permissionsSelectionModel.ownerFilter).toEqual(
|
expect(component.permissionsSelectionModel.ownerFilter()).toEqual(
|
||||||
OwnerFilterType.NOT_SELF
|
OwnerFilterType.NOT_SELF
|
||||||
)
|
)
|
||||||
expect(component.permissionsSelectionModel.excludeUsers).toContain(50)
|
expect(component.permissionsSelectionModel.excludeUsers()).toContain(50)
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should ingest filter rules for owner is null', () => {
|
it('should ingest filter rules for owner is null', () => {
|
||||||
expect(component.permissionsSelectionModel.ownerFilter).toEqual(
|
expect(component.permissionsSelectionModel.ownerFilter()).toEqual(
|
||||||
OwnerFilterType.NONE
|
OwnerFilterType.NONE
|
||||||
)
|
)
|
||||||
component.filterRules = [
|
component.filterRules = [
|
||||||
@@ -1093,10 +1196,10 @@ describe('FilterEditorComponent', () => {
|
|||||||
value: 'true',
|
value: 'true',
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
expect(component.permissionsSelectionModel.ownerFilter).toEqual(
|
expect(component.permissionsSelectionModel.ownerFilter()).toEqual(
|
||||||
OwnerFilterType.UNOWNED
|
OwnerFilterType.UNOWNED
|
||||||
)
|
)
|
||||||
expect(component.permissionsSelectionModel.hideUnowned).toBeFalsy()
|
expect(component.permissionsSelectionModel.hideUnowned()).toBeFalsy()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should ingest filter rules for owner is not null', () => {
|
it('should ingest filter rules for owner is not null', () => {
|
||||||
@@ -1106,14 +1209,14 @@ describe('FilterEditorComponent', () => {
|
|||||||
value: 'false',
|
value: 'false',
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
expect(component.permissionsSelectionModel.hideUnowned).toBeTruthy()
|
expect(component.permissionsSelectionModel.hideUnowned()).toBeTruthy()
|
||||||
component.filterRules = [
|
component.filterRules = [
|
||||||
{
|
{
|
||||||
rule_type: FILTER_OWNER_ISNULL,
|
rule_type: FILTER_OWNER_ISNULL,
|
||||||
value: '0',
|
value: '0',
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
expect(component.permissionsSelectionModel.hideUnowned).toBeTruthy()
|
expect(component.permissionsSelectionModel.hideUnowned()).toBeTruthy()
|
||||||
})
|
})
|
||||||
|
|
||||||
it('should ingest filter rules for shared by me', () => {
|
it('should ingest filter rules for shared by me', () => {
|
||||||
@@ -1123,7 +1226,7 @@ describe('FilterEditorComponent', () => {
|
|||||||
value: '2',
|
value: '2',
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
expect(component.permissionsSelectionModel.userID).toEqual(2)
|
expect(component.permissionsSelectionModel.userID()).toEqual(2)
|
||||||
})
|
})
|
||||||
|
|
||||||
// GET filterRules
|
// GET filterRules
|
||||||
@@ -1889,7 +1992,10 @@ describe('FilterEditorComponent', () => {
|
|||||||
value: '1',
|
value: '1',
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
component.permissionsSelectionModel.excludeUsers.push(2)
|
component.permissionsSelectionModel.excludeUsers.update((users) => [
|
||||||
|
...users,
|
||||||
|
2,
|
||||||
|
])
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
expect(component.filterRules).toEqual([
|
expect(component.filterRules).toEqual([
|
||||||
{
|
{
|
||||||
@@ -1939,8 +2045,11 @@ describe('FilterEditorComponent', () => {
|
|||||||
// TODO: mock input in code
|
// TODO: mock input in code
|
||||||
// userSelect.query(By.css('input')).nativeElement.value = '3'
|
// userSelect.query(By.css('input')).nativeElement.value = '3'
|
||||||
// userSelect.triggerEventHandler('change')
|
// userSelect.triggerEventHandler('change')
|
||||||
component.permissionsSelectionModel.ownerFilter = OwnerFilterType.OTHERS
|
component.permissionsSelectionModel.ownerFilter.set(OwnerFilterType.OTHERS)
|
||||||
component.permissionsSelectionModel.includeUsers.push(3)
|
component.permissionsSelectionModel.includeUsers.update((users) => [
|
||||||
|
...users,
|
||||||
|
3,
|
||||||
|
])
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
expect(component.filterRules).toEqual([
|
expect(component.filterRules).toEqual([
|
||||||
{
|
{
|
||||||
@@ -1960,7 +2069,7 @@ describe('FilterEditorComponent', () => {
|
|||||||
ownerToggle.nativeElement.checked = true
|
ownerToggle.nativeElement.checked = true
|
||||||
// ownerToggle.triggerEventHandler('change')
|
// ownerToggle.triggerEventHandler('change')
|
||||||
// TODO: ngModel isn't doing this here
|
// TODO: ngModel isn't doing this here
|
||||||
component.permissionsSelectionModel.hideUnowned = true
|
component.permissionsSelectionModel.hideUnowned.set(true)
|
||||||
fixture.detectChanges()
|
fixture.detectChanges()
|
||||||
expect(component.filterRules).toEqual([
|
expect(component.filterRules).toEqual([
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -735,38 +735,50 @@ export class FilterEditorComponent
|
|||||||
this._textFilter = rule.value
|
this._textFilter = rule.value
|
||||||
break
|
break
|
||||||
case FILTER_OWNER:
|
case FILTER_OWNER:
|
||||||
this.permissionsSelectionModel.ownerFilter = OwnerFilterType.SELF
|
this.permissionsSelectionModel.ownerFilter.set(OwnerFilterType.SELF)
|
||||||
this.permissionsSelectionModel.hideUnowned = false
|
this.permissionsSelectionModel.hideUnowned.set(false)
|
||||||
if (rule.value)
|
if (rule.value)
|
||||||
this.permissionsSelectionModel.userID = parseInt(rule.value, 10)
|
this.permissionsSelectionModel.userID.set(
|
||||||
|
Number.parseInt(rule.value, 10)
|
||||||
|
)
|
||||||
break
|
break
|
||||||
case FILTER_OWNER_ANY:
|
case FILTER_OWNER_ANY:
|
||||||
this.permissionsSelectionModel.ownerFilter = OwnerFilterType.OTHERS
|
this.permissionsSelectionModel.ownerFilter.set(OwnerFilterType.OTHERS)
|
||||||
if (rule.value)
|
if (rule.value)
|
||||||
this.permissionsSelectionModel.includeUsers.push(
|
this.permissionsSelectionModel.includeUsers.update((users) => [
|
||||||
parseInt(rule.value, 10)
|
...users,
|
||||||
)
|
Number.parseInt(rule.value, 10),
|
||||||
|
])
|
||||||
break
|
break
|
||||||
case FILTER_OWNER_DOES_NOT_INCLUDE:
|
case FILTER_OWNER_DOES_NOT_INCLUDE:
|
||||||
this.permissionsSelectionModel.ownerFilter = OwnerFilterType.NOT_SELF
|
this.permissionsSelectionModel.ownerFilter.set(
|
||||||
|
OwnerFilterType.NOT_SELF
|
||||||
|
)
|
||||||
if (rule.value)
|
if (rule.value)
|
||||||
this.permissionsSelectionModel.excludeUsers.push(
|
this.permissionsSelectionModel.excludeUsers.update((users) => [
|
||||||
parseInt(rule.value, 10)
|
...users,
|
||||||
)
|
Number.parseInt(rule.value, 10),
|
||||||
|
])
|
||||||
break
|
break
|
||||||
case FILTER_SHARED_BY_USER:
|
case FILTER_SHARED_BY_USER:
|
||||||
this.permissionsSelectionModel.ownerFilter =
|
this.permissionsSelectionModel.ownerFilter.set(
|
||||||
OwnerFilterType.SHARED_BY_ME
|
OwnerFilterType.SHARED_BY_ME
|
||||||
|
)
|
||||||
if (rule.value)
|
if (rule.value)
|
||||||
this.permissionsSelectionModel.userID = parseInt(rule.value, 10)
|
this.permissionsSelectionModel.userID.set(
|
||||||
|
Number.parseInt(rule.value, 10)
|
||||||
|
)
|
||||||
break
|
break
|
||||||
case FILTER_OWNER_ISNULL:
|
case FILTER_OWNER_ISNULL:
|
||||||
if (rule.value === 'true' || rule.value === '1') {
|
if (rule.value === 'true' || rule.value === '1') {
|
||||||
this.permissionsSelectionModel.hideUnowned = false
|
this.permissionsSelectionModel.hideUnowned.set(false)
|
||||||
this.permissionsSelectionModel.ownerFilter = OwnerFilterType.UNOWNED
|
this.permissionsSelectionModel.ownerFilter.set(
|
||||||
|
OwnerFilterType.UNOWNED
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
this.permissionsSelectionModel.hideUnowned =
|
this.permissionsSelectionModel.hideUnowned.set(
|
||||||
rule.value === 'false' || rule.value === '0'
|
rule.value === 'false' || rule.value === '0'
|
||||||
|
)
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1074,34 +1086,35 @@ export class FilterEditorComponent
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (this.permissionsSelectionModel.ownerFilter == OwnerFilterType.SELF) {
|
if (this.permissionsSelectionModel.ownerFilter() == OwnerFilterType.SELF) {
|
||||||
filterRules.push({
|
filterRules.push({
|
||||||
rule_type: FILTER_OWNER,
|
rule_type: FILTER_OWNER,
|
||||||
value: this.permissionsSelectionModel.userID.toString(),
|
value: this.permissionsSelectionModel.userID().toString(),
|
||||||
})
|
})
|
||||||
} else if (
|
} else if (
|
||||||
this.permissionsSelectionModel.ownerFilter == OwnerFilterType.NOT_SELF
|
this.permissionsSelectionModel.ownerFilter() == OwnerFilterType.NOT_SELF
|
||||||
) {
|
) {
|
||||||
filterRules.push({
|
filterRules.push({
|
||||||
rule_type: FILTER_OWNER_DOES_NOT_INCLUDE,
|
rule_type: FILTER_OWNER_DOES_NOT_INCLUDE,
|
||||||
value: this.permissionsSelectionModel.excludeUsers?.join(','),
|
value: this.permissionsSelectionModel.excludeUsers()?.join(','),
|
||||||
})
|
})
|
||||||
} else if (
|
} else if (
|
||||||
this.permissionsSelectionModel.ownerFilter == OwnerFilterType.OTHERS
|
this.permissionsSelectionModel.ownerFilter() == OwnerFilterType.OTHERS
|
||||||
) {
|
) {
|
||||||
filterRules.push({
|
filterRules.push({
|
||||||
rule_type: FILTER_OWNER_ANY,
|
rule_type: FILTER_OWNER_ANY,
|
||||||
value: this.permissionsSelectionModel.includeUsers?.join(','),
|
value: this.permissionsSelectionModel.includeUsers()?.join(','),
|
||||||
})
|
})
|
||||||
} else if (
|
} else if (
|
||||||
this.permissionsSelectionModel.ownerFilter == OwnerFilterType.SHARED_BY_ME
|
this.permissionsSelectionModel.ownerFilter() ==
|
||||||
|
OwnerFilterType.SHARED_BY_ME
|
||||||
) {
|
) {
|
||||||
filterRules.push({
|
filterRules.push({
|
||||||
rule_type: FILTER_SHARED_BY_USER,
|
rule_type: FILTER_SHARED_BY_USER,
|
||||||
value: this.permissionsSelectionModel.userID.toString(),
|
value: this.permissionsSelectionModel.userID().toString(),
|
||||||
})
|
})
|
||||||
} else if (
|
} else if (
|
||||||
this.permissionsSelectionModel.ownerFilter == OwnerFilterType.UNOWNED
|
this.permissionsSelectionModel.ownerFilter() == OwnerFilterType.UNOWNED
|
||||||
) {
|
) {
|
||||||
filterRules.push({
|
filterRules.push({
|
||||||
rule_type: FILTER_OWNER_ISNULL,
|
rule_type: FILTER_OWNER_ISNULL,
|
||||||
@@ -1109,7 +1122,7 @@ export class FilterEditorComponent
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.permissionsSelectionModel.hideUnowned) {
|
if (this.permissionsSelectionModel.hideUnowned()) {
|
||||||
filterRules.push({
|
filterRules.push({
|
||||||
rule_type: FILTER_OWNER_ISNULL,
|
rule_type: FILTER_OWNER_ISNULL,
|
||||||
value: 'false',
|
value: 'false',
|
||||||
|
|||||||
@@ -210,6 +210,48 @@ describe('SettingsService', () => {
|
|||||||
expect(settingsService.get(SETTINGS_KEYS.THEME_COLOR)).toEqual('#000000')
|
expect(settingsService.get(SETTINGS_KEYS.THEME_COLOR)).toEqual('#000000')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('provides stable signals that update when settings change', () => {
|
||||||
|
const req = httpTestingController.expectOne(
|
||||||
|
`${environment.apiBaseUrl}ui_settings/`
|
||||||
|
)
|
||||||
|
req.flush(ui_settings)
|
||||||
|
|
||||||
|
const notesEnabled = settingsService.getSignal<boolean>(
|
||||||
|
SETTINGS_KEYS.NOTES_ENABLED
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(notesEnabled()).toBeTruthy()
|
||||||
|
expect(
|
||||||
|
settingsService.getSignal<boolean>(SETTINGS_KEYS.NOTES_ENABLED)
|
||||||
|
).toBe(notesEnabled)
|
||||||
|
|
||||||
|
settingsService.set(SETTINGS_KEYS.NOTES_ENABLED, false)
|
||||||
|
|
||||||
|
expect(notesEnabled()).toBeFalsy()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('updates setting signals when settings are reinitialized', () => {
|
||||||
|
let req = httpTestingController.expectOne(
|
||||||
|
`${environment.apiBaseUrl}ui_settings/`
|
||||||
|
)
|
||||||
|
req.flush(ui_settings)
|
||||||
|
const appTitle = settingsService.getSignal<string>(SETTINGS_KEYS.APP_TITLE)
|
||||||
|
|
||||||
|
settingsService.initializeSettings().subscribe()
|
||||||
|
req = httpTestingController.expectOne(
|
||||||
|
`${environment.apiBaseUrl}ui_settings/`
|
||||||
|
)
|
||||||
|
req.flush({
|
||||||
|
...ui_settings,
|
||||||
|
settings: {
|
||||||
|
...ui_settings.settings,
|
||||||
|
app_title: 'Updated title',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
expect(appTitle()).toBe('Updated title')
|
||||||
|
})
|
||||||
|
|
||||||
it('sets django cookie for languages', () => {
|
it('sets django cookie for languages', () => {
|
||||||
httpTestingController
|
httpTestingController
|
||||||
.expectOne(`${environment.apiBaseUrl}ui_settings/`)
|
.expectOne(`${environment.apiBaseUrl}ui_settings/`)
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { HttpClient } from '@angular/common/http'
|
|||||||
import {
|
import {
|
||||||
DOCUMENT,
|
DOCUMENT,
|
||||||
EventEmitter,
|
EventEmitter,
|
||||||
|
Signal,
|
||||||
|
computed,
|
||||||
inject,
|
inject,
|
||||||
Injectable,
|
Injectable,
|
||||||
LOCALE_ID,
|
LOCALE_ID,
|
||||||
@@ -297,6 +299,7 @@ export class SettingsService {
|
|||||||
|
|
||||||
private settings: Record<string, any> = {}
|
private settings: Record<string, any> = {}
|
||||||
private readonly settingsVersion = signal(0)
|
private readonly settingsVersion = signal(0)
|
||||||
|
private readonly settingSignals = new Map<string, Signal<unknown>>()
|
||||||
readonly currentUser = signal<User>(undefined)
|
readonly currentUser = signal<User>(undefined)
|
||||||
|
|
||||||
public settingsSaved: EventEmitter<any> = new EventEmitter()
|
public settingsSaved: EventEmitter<any> = new EventEmitter()
|
||||||
@@ -326,10 +329,6 @@ export class SettingsService {
|
|||||||
return !UNSAFE_OBJECT_KEYS.has(key)
|
return !UNSAFE_OBJECT_KEYS.has(key)
|
||||||
}
|
}
|
||||||
|
|
||||||
public trackChanges(): void {
|
|
||||||
this.settingsVersion()
|
|
||||||
}
|
|
||||||
|
|
||||||
private assignSafeSettings(source: Record<string, any>) {
|
private assignSafeSettings(source: Record<string, any>) {
|
||||||
if (!source || typeof source !== 'object' || Array.isArray(source)) {
|
if (!source || typeof source !== 'object' || Array.isArray(source)) {
|
||||||
return
|
return
|
||||||
@@ -339,6 +338,7 @@ export class SettingsService {
|
|||||||
if (!this.isSafeObjectKey(key)) continue
|
if (!this.isSafeObjectKey(key)) continue
|
||||||
this.settings[key] = source[key]
|
this.settings[key] = source[key]
|
||||||
}
|
}
|
||||||
|
this.settingsVersion.update((version) => version + 1)
|
||||||
}
|
}
|
||||||
|
|
||||||
// this is called by the app initializer in app.module
|
// this is called by the app initializer in app.module
|
||||||
@@ -594,6 +594,18 @@ export class SettingsService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
getSignal<T = any>(key: string): Signal<T> {
|
||||||
|
let settingSignal = this.settingSignals.get(key)
|
||||||
|
if (!settingSignal) {
|
||||||
|
settingSignal = computed(() => {
|
||||||
|
this.settingsVersion()
|
||||||
|
return this.get(key)
|
||||||
|
})
|
||||||
|
this.settingSignals.set(key, settingSignal)
|
||||||
|
}
|
||||||
|
return settingSignal as Signal<T>
|
||||||
|
}
|
||||||
|
|
||||||
set(key: string, value: any) {
|
set(key: string, value: any) {
|
||||||
// parse key:key:key into nested object
|
// parse key:key:key into nested object
|
||||||
let settingObj = this.settings
|
let settingObj = this.settings
|
||||||
|
|||||||
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
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
+5
-16
@@ -47,6 +47,8 @@ $grid-breakpoints: (
|
|||||||
);
|
);
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
|
--bs-border-radius: #{$border-radius};
|
||||||
|
|
||||||
@each $name, $value in $grid-breakpoints {
|
@each $name, $value in $grid-breakpoints {
|
||||||
--bs-breakpoint-#{$name}: #{$value};
|
--bs-breakpoint-#{$name}: #{$value};
|
||||||
}
|
}
|
||||||
@@ -78,19 +80,12 @@ body {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.btn {
|
.btn {
|
||||||
--bs-btn-border-radius: .425rem;
|
--bs-border-radius-sm: #{$border-radius};
|
||||||
--bs-border-radius-sm: .425rem;
|
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-control,
|
|
||||||
.form-select,
|
|
||||||
.input-group-text {
|
|
||||||
border-radius: .425rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.pagination, .input-group {
|
.pagination, .input-group {
|
||||||
--bs-border-radius-sm: .425rem;
|
--bs-border-radius-sm: #{$border-radius};
|
||||||
}
|
}
|
||||||
|
|
||||||
@media(min-width: 768px) {
|
@media(min-width: 768px) {
|
||||||
@@ -689,10 +684,6 @@ table.table {
|
|||||||
--bs-toast-max-width: var(--pngx-toast-max-width);
|
--bs-toast-max-width: var(--pngx-toast-max-width);
|
||||||
}
|
}
|
||||||
|
|
||||||
.alert {
|
|
||||||
--bs-border-radius: .425rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.alert-primary {
|
.alert-primary {
|
||||||
--bs-alert-color: var(--bs-primary);
|
--bs-alert-color: var(--bs-primary);
|
||||||
--bs-alert-bg: var(--pngx-primary-faded);
|
--bs-alert-bg: var(--pngx-primary-faded);
|
||||||
@@ -824,8 +815,6 @@ code {
|
|||||||
--bs-accordion-bg: var(--bs-light);
|
--bs-accordion-bg: var(--bs-light);
|
||||||
--bs-accordion-active-color: var(--bs-primary);
|
--bs-accordion-active-color: var(--bs-primary);
|
||||||
--bs-accordion-active-bg: var(--pngx-bg-alt);
|
--bs-accordion-active-bg: var(--pngx-bg-alt);
|
||||||
--bs-border-radius: .425rem;
|
|
||||||
--bs-accordion-inner-border-radius: calc(.425rem - 1px);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.accordion-button::after {
|
.accordion-button::after {
|
||||||
@@ -849,7 +838,7 @@ code {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* Animate items as they're being sorted. */
|
/* Animate items as they're being sorted. */
|
||||||
.cdk-drop-list-dragging .cdk-drag {
|
.cdk-drop-list-dragging .cdk-drag:not(.cdk-drag-preview) {
|
||||||
transition: transform 250ms cubic-bezier(0, 0, 0.2, 1);
|
transition: transform 250ms cubic-bezier(0, 0, 0.2, 1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -113,6 +113,7 @@ $form-check-radio-checked-bg-image-dark: url("data:image/svg+xml,%3csvg xmlns='h
|
|||||||
--bs-tertiary-bg: var(--pngx-bg-darker);
|
--bs-tertiary-bg: var(--pngx-bg-darker);
|
||||||
--bs-dark-border-subtle: var(--pngx-bg-darker);
|
--bs-dark-border-subtle: var(--pngx-bg-darker);
|
||||||
--bs-border-color-translucent: rgba(0, 0, 0, .175); // override bs
|
--bs-border-color-translucent: rgba(0, 0, 0, .175); // override bs
|
||||||
|
--bs-box-shadow-sm: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.15); // slightly darker than bs default
|
||||||
|
|
||||||
.text-dark, .text-light {
|
.text-dark, .text-light {
|
||||||
color: var(--bs-body-color) !important;
|
color: var(--bs-body-color) !important;
|
||||||
|
|||||||
@@ -16,6 +16,9 @@ from django.core.cache import cache
|
|||||||
from django.core.cache import caches
|
from django.core.cache import caches
|
||||||
|
|
||||||
from documents.models import Document
|
from documents.models import Document
|
||||||
|
from paperless.signed_pickle import SignedPickleError
|
||||||
|
from paperless.signed_pickle import signed_pickle_dumps
|
||||||
|
from paperless.signed_pickle import signed_pickle_loads
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from django.core.cache.backends.base import BaseCache
|
from django.core.cache.backends.base import BaseCache
|
||||||
@@ -118,9 +121,11 @@ class StoredLRUCache(LRUCache):
|
|||||||
serialized_data = self._backend.get(self._backend_key)
|
serialized_data = self._backend.get(self._backend_key)
|
||||||
try:
|
try:
|
||||||
self._data = (
|
self._data = (
|
||||||
pickle.loads(serialized_data) if serialized_data else OrderedDict()
|
signed_pickle_loads(serialized_data)
|
||||||
|
if serialized_data
|
||||||
|
else OrderedDict()
|
||||||
)
|
)
|
||||||
except pickle.PickleError:
|
except (SignedPickleError, pickle.PickleError):
|
||||||
logger.warning(
|
logger.warning(
|
||||||
"Cache exists in backend but could not be read (possibly invalid format)",
|
"Cache exists in backend but could not be read (possibly invalid format)",
|
||||||
)
|
)
|
||||||
@@ -132,7 +137,7 @@ class StoredLRUCache(LRUCache):
|
|||||||
"""
|
"""
|
||||||
self._backend.set(
|
self._backend.set(
|
||||||
self._backend_key,
|
self._backend_key,
|
||||||
pickle.dumps(self._data),
|
signed_pickle_dumps(self._data),
|
||||||
self.backend_ttl,
|
self.backend_ttl,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -28,6 +28,9 @@ from documents.caching import CLASSIFIER_VERSION_KEY
|
|||||||
from documents.caching import StoredLRUCache
|
from documents.caching import StoredLRUCache
|
||||||
from documents.models import Document
|
from documents.models import Document
|
||||||
from documents.models import MatchingModel
|
from documents.models import MatchingModel
|
||||||
|
from paperless.signed_pickle import SignedPickleError
|
||||||
|
from paperless.signed_pickle import signed_pickle_dumps
|
||||||
|
from paperless.signed_pickle import signed_pickle_loads
|
||||||
|
|
||||||
logger = logging.getLogger("paperless.classifier")
|
logger = logging.getLogger("paperless.classifier")
|
||||||
|
|
||||||
@@ -527,10 +530,17 @@ class DocumentClassifier:
|
|||||||
serialized_result = read_cache.get(key)
|
serialized_result = read_cache.get(key)
|
||||||
if serialized_result is None:
|
if serialized_result is None:
|
||||||
result = self.data_vectorizer.transform([self.preprocess_content(content)])
|
result = self.data_vectorizer.transform([self.preprocess_content(content)])
|
||||||
read_cache.set(key, pickle.dumps(result), CACHE_5_MINUTES)
|
read_cache.set(key, signed_pickle_dumps(result), CACHE_5_MINUTES)
|
||||||
else:
|
else:
|
||||||
read_cache.touch(key, CACHE_5_MINUTES)
|
try:
|
||||||
result = pickle.loads(serialized_result)
|
result = signed_pickle_loads(serialized_result)
|
||||||
|
except SignedPickleError:
|
||||||
|
result = self.data_vectorizer.transform(
|
||||||
|
[self.preprocess_content(content)],
|
||||||
|
)
|
||||||
|
read_cache.set(key, signed_pickle_dumps(result), CACHE_5_MINUTES)
|
||||||
|
else:
|
||||||
|
read_cache.touch(key, CACHE_5_MINUTES)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
def predict_correspondent(self, content: str) -> int | None:
|
def predict_correspondent(self, content: str) -> int | None:
|
||||||
|
|||||||
@@ -314,7 +314,7 @@ def _consume_file(
|
|||||||
consumption_dir: Path,
|
consumption_dir: Path,
|
||||||
*,
|
*,
|
||||||
subdirs_as_tags: bool,
|
subdirs_as_tags: bool,
|
||||||
) -> None:
|
) -> bool:
|
||||||
"""
|
"""
|
||||||
Queue a file for consumption.
|
Queue a file for consumption.
|
||||||
|
|
||||||
@@ -322,15 +322,20 @@ def _consume_file(
|
|||||||
filepath: Path to the file to consume.
|
filepath: Path to the file to consume.
|
||||||
consumption_dir: Base consumption directory.
|
consumption_dir: Base consumption directory.
|
||||||
subdirs_as_tags: Whether to create tags from subdirectory names.
|
subdirs_as_tags: Whether to create tags from subdirectory names.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if the file was successfully handed to Celery, False otherwise.
|
||||||
|
Callers must not record the file as queued on failure, or the rescan
|
||||||
|
will never retry it.
|
||||||
"""
|
"""
|
||||||
# Verify file still exists and is accessible
|
# Verify file still exists and is accessible
|
||||||
try:
|
try:
|
||||||
if not filepath.is_file():
|
if not filepath.is_file():
|
||||||
logger.debug(f"Not consuming {filepath}: not a file or doesn't exist")
|
logger.debug(f"Not consuming {filepath}: not a file or doesn't exist")
|
||||||
return
|
return False
|
||||||
except OSError as e:
|
except OSError as e:
|
||||||
logger.warning(f"Not consuming {filepath}: {e}")
|
logger.warning(f"Not consuming {filepath}: {e}")
|
||||||
return
|
return False
|
||||||
|
|
||||||
# Get tags from path if configured
|
# Get tags from path if configured
|
||||||
tag_ids: list[int] | None = None
|
tag_ids: list[int] | None = None
|
||||||
@@ -355,6 +360,9 @@ def _consume_file(
|
|||||||
)
|
)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception(f"Error while queuing document {filepath}")
|
logger.exception(f"Error while queuing document {filepath}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
return True
|
||||||
|
|
||||||
|
|
||||||
class Command(BaseCommand):
|
class Command(BaseCommand):
|
||||||
@@ -492,12 +500,12 @@ class Command(BaseCommand):
|
|||||||
if not consumer_filter(Change.added, str(filepath)):
|
if not consumer_filter(Change.added, str(filepath)):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
_consume_file(
|
if _consume_file(
|
||||||
filepath=filepath,
|
filepath=filepath,
|
||||||
consumption_dir=directory,
|
consumption_dir=directory,
|
||||||
subdirs_as_tags=subdirs_as_tags,
|
subdirs_as_tags=subdirs_as_tags,
|
||||||
)
|
):
|
||||||
queued.add(filepath.resolve())
|
queued.add(filepath.resolve())
|
||||||
|
|
||||||
return queued
|
return queued
|
||||||
|
|
||||||
@@ -651,14 +659,16 @@ class Command(BaseCommand):
|
|||||||
|
|
||||||
# Check for stable files
|
# Check for stable files
|
||||||
for stable_path in tracker.get_stable_files():
|
for stable_path in tracker.get_stable_files():
|
||||||
_consume_file(
|
# Only remember files that were actually queued, so the
|
||||||
|
# rescan does not re-queue them while the consume task
|
||||||
|
# has yet to remove them from disk, but does retry a
|
||||||
|
# failed publish instead of stranding it
|
||||||
|
if _consume_file(
|
||||||
filepath=stable_path,
|
filepath=stable_path,
|
||||||
consumption_dir=directory,
|
consumption_dir=directory,
|
||||||
subdirs_as_tags=subdirs_as_tags,
|
subdirs_as_tags=subdirs_as_tags,
|
||||||
)
|
):
|
||||||
# Remember it so the rescan does not re-queue it while
|
queued.add(stable_path)
|
||||||
# the consume task has yet to remove it from disk
|
|
||||||
queued.add(stable_path)
|
|
||||||
|
|
||||||
# Exit watch loop to reconfigure timeout
|
# Exit watch loop to reconfigure timeout
|
||||||
break
|
break
|
||||||
|
|||||||
@@ -462,7 +462,11 @@ class Document(SoftDeleteModel, ModelWithOwner): # type: ignore[django-manager-
|
|||||||
"""
|
"""
|
||||||
Returns a sanitized filename for the document, not including any paths.
|
Returns a sanitized filename for the document, not including any paths.
|
||||||
"""
|
"""
|
||||||
result = str(self)
|
# Root owns metadata for all versions
|
||||||
|
context_document = (
|
||||||
|
self.root_document if self.root_document_id is not None else self
|
||||||
|
)
|
||||||
|
result = str(context_document)
|
||||||
|
|
||||||
if counter:
|
if counter:
|
||||||
result += f"_{counter:02}"
|
result += f"_{counter:02}"
|
||||||
|
|||||||
@@ -1003,7 +1003,7 @@ def run_workflows(
|
|||||||
|
|
||||||
# kwargs so the PaperlessTask record can note the
|
# kwargs so the PaperlessTask record can note the
|
||||||
# document, see _extract_input_data
|
# document, see _extract_input_data
|
||||||
apply_ai_suggestions.delay(
|
apply_ai_suggestions.delay_on_commit(
|
||||||
action_id=action.pk,
|
action_id=action.pk,
|
||||||
document_id=document.pk,
|
document_id=document.pk,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1063,3 +1063,79 @@ class TestApiAppConfig(DirectoriesMixin, APITestCase):
|
|||||||
)
|
)
|
||||||
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||||
self.assertIn("non-public address", str(response.data).lower())
|
self.assertIn("non-public address", str(response.data).lower())
|
||||||
|
|
||||||
|
@override_settings(REMOTE_OCR_ALLOW_INTERNAL_ENDPOINTS=False)
|
||||||
|
def test_update_remote_ocr_endpoint_blocks_internal_endpoint_when_disallowed(
|
||||||
|
self,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- Internal remote OCR endpoints are disallowed
|
||||||
|
WHEN:
|
||||||
|
- The config is updated with a remote OCR endpoint resolving internally
|
||||||
|
THEN:
|
||||||
|
- The request is rejected
|
||||||
|
"""
|
||||||
|
response = self.client.patch(
|
||||||
|
f"{self.ENDPOINT}1/",
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"remote_ocr_endpoint": "http://127.0.0.1:5000",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST)
|
||||||
|
self.assertIn("non-public address", str(response.data).lower())
|
||||||
|
|
||||||
|
@override_settings(REMOTE_OCR_ALLOW_INTERNAL_ENDPOINTS=True)
|
||||||
|
def test_update_remote_ocr_endpoint_allows_internal_endpoint_by_default(
|
||||||
|
self,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- Internal remote OCR endpoints are allowed (the default)
|
||||||
|
WHEN:
|
||||||
|
- The config is updated with a remote OCR endpoint resolving internally
|
||||||
|
THEN:
|
||||||
|
- The request is accepted, preserving existing self-hosted deployments
|
||||||
|
"""
|
||||||
|
response = self.client.patch(
|
||||||
|
f"{self.ENDPOINT}1/",
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"remote_ocr_endpoint": "http://127.0.0.1:5000",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
self.assertEqual(
|
||||||
|
response.data["remote_ocr_endpoint"],
|
||||||
|
"http://127.0.0.1:5000",
|
||||||
|
)
|
||||||
|
|
||||||
|
@override_settings(REMOTE_OCR_ALLOW_INTERNAL_ENDPOINTS=False)
|
||||||
|
def test_update_remote_ocr_endpoint_empty_value_skips_validation(
|
||||||
|
self,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- Internal remote OCR endpoints are disallowed
|
||||||
|
WHEN:
|
||||||
|
- The config is updated with an empty remote OCR endpoint
|
||||||
|
THEN:
|
||||||
|
- The request is accepted; clearing the field never needs
|
||||||
|
outbound URL validation
|
||||||
|
"""
|
||||||
|
response = self.client.patch(
|
||||||
|
f"{self.ENDPOINT}1/",
|
||||||
|
json.dumps(
|
||||||
|
{
|
||||||
|
"remote_ocr_endpoint": "",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
content_type="application/json",
|
||||||
|
)
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
self.assertEqual(response.data["remote_ocr_endpoint"], "")
|
||||||
|
|||||||
@@ -1,11 +1,18 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from django.contrib.auth.models import Permission
|
||||||
from django.contrib.auth.models import User
|
from django.contrib.auth.models import User
|
||||||
from rest_framework import status
|
from rest_framework import status
|
||||||
|
from rest_framework.test import APIClient
|
||||||
from rest_framework.test import APITestCase
|
from rest_framework.test import APITestCase
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from pytest_mock import MockerFixture
|
||||||
|
|
||||||
|
|
||||||
class TestChatStreamingViewInputValidation(APITestCase):
|
class TestChatStreamingViewInputValidation(APITestCase):
|
||||||
def setUp(self) -> None:
|
def setUp(self) -> None:
|
||||||
@@ -42,3 +49,73 @@ class TestChatStreamingViewInputValidation(APITestCase):
|
|||||||
format="json",
|
format="json",
|
||||||
)
|
)
|
||||||
assert resp.status_code == status.HTTP_400_BAD_REQUEST
|
assert resp.status_code == status.HTTP_400_BAD_REQUEST
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.django_db
|
||||||
|
class TestChatStreamingViewUnrestrictedFlag:
|
||||||
|
"""The document id filter may only be skipped (``unrestricted=True``) for
|
||||||
|
an active superuser, never for a regular user -- regardless of what
|
||||||
|
permissions that user holds.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def mocked_stream_chat(self, mocker: MockerFixture) -> mock.MagicMock:
|
||||||
|
"""AI enabled, with stream_chat_with_documents patched so the view
|
||||||
|
never touches the real vector store; returns the patched callable so
|
||||||
|
tests can inspect how it was called.
|
||||||
|
"""
|
||||||
|
mocker.patch("documents.views.AIConfig").return_value.ai_enabled = True
|
||||||
|
return mocker.patch(
|
||||||
|
"documents.views.stream_chat_with_documents",
|
||||||
|
return_value=iter(()),
|
||||||
|
)
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def viewer_client(self, user_client: APIClient, regular_user: User) -> APIClient:
|
||||||
|
"""The conftest regular-user client, granted the global
|
||||||
|
view_document permission -- the minimum ViewDocumentsPermissions
|
||||||
|
needs to reach the view at all. Model-level only: says nothing
|
||||||
|
about which documents (if any) this user can actually see.
|
||||||
|
"""
|
||||||
|
regular_user.user_permissions.add(
|
||||||
|
*Permission.objects.filter(codename="view_document"),
|
||||||
|
)
|
||||||
|
return user_client
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("client_fixture", "expected_unrestricted"),
|
||||||
|
[
|
||||||
|
pytest.param("admin_client", True, id="superuser_is_unrestricted"),
|
||||||
|
pytest.param("viewer_client", False, id="regular_user_is_restricted"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_unrestricted_only_for_superuser(
|
||||||
|
self,
|
||||||
|
request: pytest.FixtureRequest,
|
||||||
|
mocked_stream_chat: mock.MagicMock,
|
||||||
|
client_fixture: str,
|
||||||
|
*,
|
||||||
|
expected_unrestricted: bool,
|
||||||
|
) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- A superuser, or a regular user holding the global
|
||||||
|
view_document permission (but no object-level document access)
|
||||||
|
WHEN:
|
||||||
|
- They post a chat question with no document_id
|
||||||
|
THEN:
|
||||||
|
- stream_chat_with_documents is called with unrestricted=True
|
||||||
|
only for the superuser; the regular user is always
|
||||||
|
unrestricted=False, regardless of their permissions
|
||||||
|
"""
|
||||||
|
client: APIClient = request.getfixturevalue(client_fixture)
|
||||||
|
|
||||||
|
client.post(
|
||||||
|
"/api/documents/chat/",
|
||||||
|
data={"q": "What's in these documents?"},
|
||||||
|
format="json",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert (
|
||||||
|
mocked_stream_chat.call_args.kwargs["unrestricted"] is expected_unrestricted
|
||||||
|
)
|
||||||
|
|||||||
@@ -102,6 +102,7 @@ class TestApiObjects(DirectoriesMixin, APITestCase):
|
|||||||
- API is called
|
- API is called
|
||||||
THEN:
|
THEN:
|
||||||
- Last correspondence date is returned only if requested for list, and for detail
|
- Last correspondence date is returned only if requested for list, and for detail
|
||||||
|
- The date is scoped to documents the requesting user may view
|
||||||
"""
|
"""
|
||||||
|
|
||||||
Document.objects.create(
|
Document.objects.create(
|
||||||
@@ -145,6 +146,32 @@ class TestApiObjects(DirectoriesMixin, APITestCase):
|
|||||||
response.data["last_correspondence"],
|
response.data["last_correspondence"],
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# A newer document owned by another user must not leak through the
|
||||||
|
# aggregate for a non-superuser who cannot view it
|
||||||
|
other = User.objects.create_user(username="other")
|
||||||
|
Document.objects.create(
|
||||||
|
mime_type="application/pdf",
|
||||||
|
correspondent=self.c1,
|
||||||
|
created=datetime.date(2023, 6, 1),
|
||||||
|
checksum="hidden",
|
||||||
|
owner=other,
|
||||||
|
)
|
||||||
|
|
||||||
|
user = User.objects.create_user(username="regular")
|
||||||
|
user.user_permissions.add(
|
||||||
|
Permission.objects.get(codename="view_correspondent"),
|
||||||
|
)
|
||||||
|
self.client.force_authenticate(user=user)
|
||||||
|
|
||||||
|
response = self.client.get("/api/correspondents/?last_correspondence=true")
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
result = next(r for r in response.data["results"] if r["id"] == self.c1.id)
|
||||||
|
self.assertIn("2022-01-02", result["last_correspondence"])
|
||||||
|
|
||||||
|
response = self.client.get(f"/api/correspondents/{self.c1.id}/")
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_200_OK)
|
||||||
|
self.assertIn("2022-01-02", response.data["last_correspondence"])
|
||||||
|
|
||||||
def test_paginated_objects_include_all_only_for_legacy_version(self) -> None:
|
def test_paginated_objects_include_all_only_for_legacy_version(self) -> None:
|
||||||
response_v10 = self.client.get("/api/correspondents/")
|
response_v10 = self.client.get("/api/correspondents/")
|
||||||
self.assertEqual(response_v10.status_code, status.HTTP_200_OK)
|
self.assertEqual(response_v10.status_code, status.HTTP_200_OK)
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import pickle
|
|
||||||
|
|
||||||
from documents.caching import StoredLRUCache
|
from documents.caching import StoredLRUCache
|
||||||
|
from paperless.signed_pickle import HMAC_SIZE
|
||||||
|
from paperless.signed_pickle import signed_pickle_dumps
|
||||||
|
from paperless.signed_pickle import signed_pickle_loads
|
||||||
|
|
||||||
|
|
||||||
def test_lru_cache_entries() -> None:
|
def test_lru_cache_entries() -> None:
|
||||||
@@ -42,4 +43,16 @@ def test_stored_lru_cache_key_ttl(mocker) -> None:
|
|||||||
key, data, timeout = mock_backend.set.call_args[0]
|
key, data, timeout = mock_backend.set.call_args[0]
|
||||||
assert key == "test_key"
|
assert key == "test_key"
|
||||||
assert timeout == 321
|
assert timeout == 321
|
||||||
assert pickle.loads(data) == {"x": "X", "y": "Y"}
|
assert signed_pickle_loads(data) == {"x": "X", "y": "Y"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_stored_lru_cache_rejects_tampered_data(mocker) -> None:
|
||||||
|
serialized_data = bytearray(signed_pickle_dumps({"x": "X"}))
|
||||||
|
serialized_data[HMAC_SIZE] ^= 0xFF
|
||||||
|
mock_backend = mocker.Mock()
|
||||||
|
mock_backend.get.return_value = bytes(serialized_data)
|
||||||
|
cache = StoredLRUCache("test_key", backend=mock_backend)
|
||||||
|
|
||||||
|
cache.load()
|
||||||
|
|
||||||
|
assert cache.get("x") is None
|
||||||
|
|||||||
@@ -19,6 +19,8 @@ from documents.models import MatchingModel
|
|||||||
from documents.models import StoragePath
|
from documents.models import StoragePath
|
||||||
from documents.models import Tag
|
from documents.models import Tag
|
||||||
from documents.tests.utils import DirectoriesMixin
|
from documents.tests.utils import DirectoriesMixin
|
||||||
|
from paperless.signed_pickle import HMAC_SIZE
|
||||||
|
from paperless.signed_pickle import signed_pickle_dumps
|
||||||
|
|
||||||
|
|
||||||
def dummy_preprocess(content: str, **kwargs):
|
def dummy_preprocess(content: str, **kwargs):
|
||||||
@@ -265,6 +267,27 @@ class TestClassifier(DirectoriesMixin, TestCase):
|
|||||||
self.assertEqual(mock_preprocess_content.call_count, 2)
|
self.assertEqual(mock_preprocess_content.call_count, 2)
|
||||||
self.assertEqual(mock_transform.call_count, 2)
|
self.assertEqual(mock_transform.call_count, 2)
|
||||||
|
|
||||||
|
def test_vectorize_recomputes_tampered_cache_entry(self) -> None:
|
||||||
|
cached = bytearray(signed_pickle_dumps(["cached vector"]))
|
||||||
|
cached[HMAC_SIZE] ^= 0xFF
|
||||||
|
self.classifier.data_vectorizer = mock.Mock()
|
||||||
|
self.classifier.data_vectorizer.transform.return_value = ["fresh vector"]
|
||||||
|
|
||||||
|
with (
|
||||||
|
mock.patch(
|
||||||
|
"documents.classifier.read_cache.get",
|
||||||
|
return_value=bytes(cached),
|
||||||
|
),
|
||||||
|
mock.patch("documents.classifier.read_cache.set") as cache_set,
|
||||||
|
mock.patch("documents.classifier.read_cache.touch") as cache_touch,
|
||||||
|
):
|
||||||
|
result = self.classifier._vectorize("content")
|
||||||
|
|
||||||
|
self.assertEqual(result, ["fresh vector"])
|
||||||
|
self.classifier.data_vectorizer.transform.assert_called_once()
|
||||||
|
cache_set.assert_called_once()
|
||||||
|
cache_touch.assert_not_called()
|
||||||
|
|
||||||
def test_no_retrain_if_no_change(self) -> None:
|
def test_no_retrain_if_no_change(self) -> None:
|
||||||
"""
|
"""
|
||||||
GIVEN:
|
GIVEN:
|
||||||
|
|||||||
@@ -156,6 +156,40 @@ class TestDocument(TestCase):
|
|||||||
)
|
)
|
||||||
self.assertEqual(doc.get_public_filename(), "2020-12-25 test")
|
self.assertEqual(doc.get_public_filename(), "2020-12-25 test")
|
||||||
|
|
||||||
|
def test_version_file_name_uses_root_document_metadata(self) -> None:
|
||||||
|
root_correspondent = Correspondent.objects.create(name="Root correspondent")
|
||||||
|
version_correspondent = Correspondent.objects.create(
|
||||||
|
name="Version correspondent",
|
||||||
|
)
|
||||||
|
root = Document.objects.create(
|
||||||
|
mime_type="application/pdf",
|
||||||
|
title="Root title",
|
||||||
|
created=date(2020, 12, 25),
|
||||||
|
correspondent=root_correspondent,
|
||||||
|
)
|
||||||
|
version = Document.objects.create(
|
||||||
|
mime_type="application/pdf",
|
||||||
|
title="Version title",
|
||||||
|
created=date(1990, 1, 1),
|
||||||
|
correspondent=version_correspondent,
|
||||||
|
root_document=root,
|
||||||
|
version_index=1,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
version.get_public_filename(),
|
||||||
|
"2020-12-25 Root correspondent Root title.pdf",
|
||||||
|
)
|
||||||
|
|
||||||
|
root.title = "Updated root title"
|
||||||
|
root.save(update_fields=("title",))
|
||||||
|
version.refresh_from_db()
|
||||||
|
|
||||||
|
self.assertEqual(
|
||||||
|
version.get_public_filename(),
|
||||||
|
"2020-12-25 Root correspondent Updated root title.pdf",
|
||||||
|
)
|
||||||
|
|
||||||
def test_suggestion_content_uses_latest_version_content_for_root_documents(
|
def test_suggestion_content_uses_latest_version_content_for_root_documents(
|
||||||
self,
|
self,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|||||||
@@ -445,12 +445,13 @@ class TestConsumeFile:
|
|||||||
target = consumption_dir / "document.pdf"
|
target = consumption_dir / "document.pdf"
|
||||||
shutil.copy(sample_pdf, target)
|
shutil.copy(sample_pdf, target)
|
||||||
|
|
||||||
_consume_file(
|
result = _consume_file(
|
||||||
filepath=target,
|
filepath=target,
|
||||||
consumption_dir=consumption_dir,
|
consumption_dir=consumption_dir,
|
||||||
subdirs_as_tags=False,
|
subdirs_as_tags=False,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
assert result is True
|
||||||
mock_consume_file_delay.apply_async.assert_called_once()
|
mock_consume_file_delay.apply_async.assert_called_once()
|
||||||
call_args = mock_consume_file_delay.apply_async.call_args
|
call_args = mock_consume_file_delay.apply_async.call_args
|
||||||
consumable_doc = call_args.kwargs["kwargs"]["input_doc"]
|
consumable_doc = call_args.kwargs["kwargs"]["input_doc"]
|
||||||
@@ -464,11 +465,12 @@ class TestConsumeFile:
|
|||||||
mock_consume_file_delay: MagicMock,
|
mock_consume_file_delay: MagicMock,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Test _consume_file handles nonexistent files gracefully."""
|
"""Test _consume_file handles nonexistent files gracefully."""
|
||||||
_consume_file(
|
result = _consume_file(
|
||||||
filepath=consumption_dir / "nonexistent.pdf",
|
filepath=consumption_dir / "nonexistent.pdf",
|
||||||
consumption_dir=consumption_dir,
|
consumption_dir=consumption_dir,
|
||||||
subdirs_as_tags=False,
|
subdirs_as_tags=False,
|
||||||
)
|
)
|
||||||
|
assert result is False
|
||||||
mock_consume_file_delay.apply_async.assert_not_called()
|
mock_consume_file_delay.apply_async.assert_not_called()
|
||||||
|
|
||||||
def test_consume_directory(
|
def test_consume_directory(
|
||||||
@@ -480,11 +482,12 @@ class TestConsumeFile:
|
|||||||
subdir = consumption_dir / "subdir"
|
subdir = consumption_dir / "subdir"
|
||||||
subdir.mkdir()
|
subdir.mkdir()
|
||||||
|
|
||||||
_consume_file(
|
result = _consume_file(
|
||||||
filepath=subdir,
|
filepath=subdir,
|
||||||
consumption_dir=consumption_dir,
|
consumption_dir=consumption_dir,
|
||||||
subdirs_as_tags=False,
|
subdirs_as_tags=False,
|
||||||
)
|
)
|
||||||
|
assert result is False
|
||||||
mock_consume_file_delay.apply_async.assert_not_called()
|
mock_consume_file_delay.apply_async.assert_not_called()
|
||||||
|
|
||||||
def test_consume_with_permission_error(
|
def test_consume_with_permission_error(
|
||||||
@@ -499,13 +502,33 @@ class TestConsumeFile:
|
|||||||
shutil.copy(sample_pdf, target)
|
shutil.copy(sample_pdf, target)
|
||||||
|
|
||||||
mocker.patch.object(Path, "is_file", side_effect=PermissionError("denied"))
|
mocker.patch.object(Path, "is_file", side_effect=PermissionError("denied"))
|
||||||
_consume_file(
|
result = _consume_file(
|
||||||
filepath=target,
|
filepath=target,
|
||||||
consumption_dir=consumption_dir,
|
consumption_dir=consumption_dir,
|
||||||
subdirs_as_tags=False,
|
subdirs_as_tags=False,
|
||||||
)
|
)
|
||||||
|
assert result is False
|
||||||
mock_consume_file_delay.apply_async.assert_not_called()
|
mock_consume_file_delay.apply_async.assert_not_called()
|
||||||
|
|
||||||
|
def test_consume_with_apply_async_failure(
|
||||||
|
self,
|
||||||
|
consumption_dir: Path,
|
||||||
|
sample_pdf: Path,
|
||||||
|
mock_consume_file_delay: MagicMock,
|
||||||
|
) -> None:
|
||||||
|
"""Test _consume_file reports failure when apply_async raises."""
|
||||||
|
target = consumption_dir / "document.pdf"
|
||||||
|
shutil.copy(sample_pdf, target)
|
||||||
|
|
||||||
|
mock_consume_file_delay.apply_async.side_effect = Exception("broker down")
|
||||||
|
|
||||||
|
result = _consume_file(
|
||||||
|
filepath=target,
|
||||||
|
consumption_dir=consumption_dir,
|
||||||
|
subdirs_as_tags=False,
|
||||||
|
)
|
||||||
|
assert result is False
|
||||||
|
|
||||||
def test_consume_with_tags_error(
|
def test_consume_with_tags_error(
|
||||||
self,
|
self,
|
||||||
consumption_dir: Path,
|
consumption_dir: Path,
|
||||||
@@ -522,11 +545,12 @@ class TestConsumeFile:
|
|||||||
side_effect=DatabaseError("Something happened"),
|
side_effect=DatabaseError("Something happened"),
|
||||||
)
|
)
|
||||||
|
|
||||||
_consume_file(
|
result = _consume_file(
|
||||||
filepath=target,
|
filepath=target,
|
||||||
consumption_dir=consumption_dir,
|
consumption_dir=consumption_dir,
|
||||||
subdirs_as_tags=True,
|
subdirs_as_tags=True,
|
||||||
)
|
)
|
||||||
|
assert result is True
|
||||||
mock_consume_file_delay.apply_async.assert_called_once()
|
mock_consume_file_delay.apply_async.assert_called_once()
|
||||||
call_args = mock_consume_file_delay.apply_async.call_args
|
call_args = mock_consume_file_delay.apply_async.call_args
|
||||||
overrides = call_args.kwargs["kwargs"]["overrides"]
|
overrides = call_args.kwargs["kwargs"]["overrides"]
|
||||||
@@ -1249,6 +1273,52 @@ class TestProcessExistingFilesQueued:
|
|||||||
assert target.resolve() in queued
|
assert target.resolve() in queued
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.management
|
||||||
|
@pytest.mark.django_db
|
||||||
|
class TestCommandRetryAfterQueueFailure:
|
||||||
|
"""
|
||||||
|
Regression test for GH #13923.
|
||||||
|
|
||||||
|
A file whose ``apply_async`` publish fails (e.g. broker briefly down)
|
||||||
|
must not be marked as queued, so the periodic rescan retries it once
|
||||||
|
the broker recovers, instead of stranding it until the consumer
|
||||||
|
process is restarted.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def test_watch_loop_retries_failed_publish_on_rescan(
|
||||||
|
self,
|
||||||
|
consumption_dir: Path,
|
||||||
|
sample_pdf: Path,
|
||||||
|
mock_consume_file_delay: MagicMock,
|
||||||
|
start_consumer: Callable[..., ConsumerThread],
|
||||||
|
) -> None:
|
||||||
|
"""A publish failure from the watch loop is retried by the rescan."""
|
||||||
|
apply_async = mock_consume_file_delay.apply_async
|
||||||
|
|
||||||
|
def fail_first_call(*args: object, **kwargs: object) -> None:
|
||||||
|
if apply_async.call_count == 1:
|
||||||
|
raise Exception("broker down")
|
||||||
|
|
||||||
|
apply_async.side_effect = fail_first_call
|
||||||
|
|
||||||
|
thread = start_consumer(stability_delay=0.1, rescan_interval=0.3)
|
||||||
|
|
||||||
|
target = consumption_dir / "document.pdf"
|
||||||
|
shutil.copy(sample_pdf, target)
|
||||||
|
|
||||||
|
deadline = monotonic() + 5.0
|
||||||
|
while apply_async.call_count < 2 and monotonic() < deadline:
|
||||||
|
sleep(0.1)
|
||||||
|
|
||||||
|
if thread.exception:
|
||||||
|
raise thread.exception
|
||||||
|
|
||||||
|
assert apply_async.call_count >= 2, (
|
||||||
|
"Expected the failed publish to be retried by the rescan, "
|
||||||
|
f"but apply_async was only called {apply_async.call_count} time(s)"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.management
|
@pytest.mark.management
|
||||||
@pytest.mark.django_db
|
@pytest.mark.django_db
|
||||||
class TestCommandRescanRecovery:
|
class TestCommandRescanRecovery:
|
||||||
|
|||||||
@@ -192,6 +192,50 @@ class ShareLinkBundleAPITests(DirectoriesMixin, APITestCase):
|
|||||||
self.assertEqual(response.status_code, status.HTTP_302_FOUND)
|
self.assertEqual(response.status_code, status.HTTP_302_FOUND)
|
||||||
self.assertIn("sharelink_notfound=1", response["Location"])
|
self.assertIn("sharelink_notfound=1", response["Location"])
|
||||||
|
|
||||||
|
def test_share_link_missing_file_redirects(self) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- A share link whose document file is missing from disk
|
||||||
|
WHEN:
|
||||||
|
- The public share link is requested anonymously
|
||||||
|
THEN:
|
||||||
|
- The user is redirected to login instead of a 500 error
|
||||||
|
"""
|
||||||
|
doc = DocumentFactory.create(filename="missing-original.pdf")
|
||||||
|
share_link = ShareLink.objects.create(
|
||||||
|
slug="missingfilelink",
|
||||||
|
document=doc,
|
||||||
|
file_version=ShareLink.FileVersion.ORIGINAL,
|
||||||
|
)
|
||||||
|
|
||||||
|
self.client.logout()
|
||||||
|
response = self.client.get(f"/share/{share_link.slug}/")
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_302_FOUND)
|
||||||
|
self.assertIn("sharelink_notfound=1", response["Location"])
|
||||||
|
|
||||||
|
def test_download_ready_bundle_missing_file_returns_503(self) -> None:
|
||||||
|
"""
|
||||||
|
GIVEN:
|
||||||
|
- A READY bundle whose zip file is missing from disk
|
||||||
|
WHEN:
|
||||||
|
- The public share link is requested anonymously
|
||||||
|
THEN:
|
||||||
|
- A 503 is returned instead of a 500 error
|
||||||
|
"""
|
||||||
|
bundle = ShareLinkBundle.objects.create(
|
||||||
|
slug="missingbundlefile",
|
||||||
|
file_version=ShareLink.FileVersion.ARCHIVE,
|
||||||
|
status=ShareLinkBundle.Status.READY,
|
||||||
|
file_path="bundles/gone.zip",
|
||||||
|
)
|
||||||
|
bundle.documents.set([self.document])
|
||||||
|
|
||||||
|
self.client.logout()
|
||||||
|
response = self.client.get(f"/share/{bundle.slug}/")
|
||||||
|
|
||||||
|
self.assertEqual(response.status_code, status.HTTP_503_SERVICE_UNAVAILABLE)
|
||||||
|
|
||||||
|
|
||||||
class ShareLinkBundleTaskTests(DirectoriesMixin, APITestCase):
|
class ShareLinkBundleTaskTests(DirectoriesMixin, APITestCase):
|
||||||
def setUp(self) -> None:
|
def setUp(self) -> None:
|
||||||
|
|||||||
@@ -5621,11 +5621,15 @@ class TestApplyAISuggestionsWorkflowAction(
|
|||||||
action = self.make_action()
|
action = self.make_action()
|
||||||
self.make_workflow(action, WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED)
|
self.make_workflow(action, WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED)
|
||||||
|
|
||||||
with mock.patch("documents.tasks.apply_ai_suggestions.delay") as delay:
|
with (
|
||||||
|
mock.patch("documents.tasks.apply_ai_suggestions.delay") as delay,
|
||||||
|
self.captureOnCommitCallbacks(execute=True),
|
||||||
|
):
|
||||||
run_workflows(
|
run_workflows(
|
||||||
WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED,
|
WorkflowTrigger.WorkflowTriggerType.DOCUMENT_ADDED,
|
||||||
self.doc,
|
self.doc,
|
||||||
)
|
)
|
||||||
|
delay.assert_not_called()
|
||||||
|
|
||||||
delay.assert_called_once_with(action_id=action.pk, document_id=self.doc.pk)
|
delay.assert_called_once_with(action_id=action.pk, document_id=self.doc.pk)
|
||||||
|
|
||||||
|
|||||||
+30
-9
@@ -180,6 +180,7 @@ from documents.permissions import has_system_status_permission
|
|||||||
from documents.permissions import permitted_document_ids
|
from documents.permissions import permitted_document_ids
|
||||||
from documents.permissions import permitted_object_ids
|
from documents.permissions import permitted_object_ids
|
||||||
from documents.permissions import set_permissions_for_object
|
from documents.permissions import set_permissions_for_object
|
||||||
|
from documents.permissions import user_is_unrestricted
|
||||||
from documents.plugins.date_parsing import get_date_parser
|
from documents.plugins.date_parsing import get_date_parser
|
||||||
from documents.schema import generate_object_with_permissions_schema
|
from documents.schema import generate_object_with_permissions_schema
|
||||||
from documents.search import SearchHit
|
from documents.search import SearchHit
|
||||||
@@ -577,13 +578,19 @@ class CorrespondentViewSet(
|
|||||||
def list(self, request, *args, **kwargs):
|
def list(self, request, *args, **kwargs):
|
||||||
if request.query_params.get("last_correspondence", None):
|
if request.query_params.get("last_correspondence", None):
|
||||||
self.queryset = self.queryset.annotate(
|
self.queryset = self.queryset.annotate(
|
||||||
last_correspondence=Max("documents__created"),
|
last_correspondence=Max(
|
||||||
|
"documents__created",
|
||||||
|
filter=self.get_document_count_filter(),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
return super().list(request, *args, **kwargs)
|
return super().list(request, *args, **kwargs)
|
||||||
|
|
||||||
def retrieve(self, request, *args, **kwargs):
|
def retrieve(self, request, *args, **kwargs):
|
||||||
self.queryset = self.queryset.annotate(
|
self.queryset = self.queryset.annotate(
|
||||||
last_correspondence=Max("documents__created"),
|
last_correspondence=Max(
|
||||||
|
"documents__created",
|
||||||
|
filter=self.get_document_count_filter(),
|
||||||
|
),
|
||||||
)
|
)
|
||||||
return super().retrieve(request, *args, **kwargs)
|
return super().retrieve(request, *args, **kwargs)
|
||||||
|
|
||||||
@@ -2323,10 +2330,12 @@ class ChatStreamingView(GenericAPIView[Any]):
|
|||||||
return HttpResponseForbidden("Insufficient permissions")
|
return HttpResponseForbidden("Insufficient permissions")
|
||||||
|
|
||||||
documents = Document.objects.filter(pk=document.pk)
|
documents = Document.objects.filter(pk=document.pk)
|
||||||
|
unrestricted = False
|
||||||
else:
|
else:
|
||||||
documents = Document.objects.filter(
|
documents = Document.objects.filter(
|
||||||
id__in=permitted_document_ids(request.user),
|
id__in=permitted_document_ids(request.user),
|
||||||
)
|
)
|
||||||
|
unrestricted = user_is_unrestricted(request.user)
|
||||||
|
|
||||||
output_language = get_llm_output_language(
|
output_language = get_llm_output_language(
|
||||||
ai_config=ai_config,
|
ai_config=ai_config,
|
||||||
@@ -2337,6 +2346,7 @@ class ChatStreamingView(GenericAPIView[Any]):
|
|||||||
stream_chat_with_documents(
|
stream_chat_with_documents(
|
||||||
query_str=question,
|
query_str=question,
|
||||||
documents=documents,
|
documents=documents,
|
||||||
|
unrestricted=unrestricted,
|
||||||
output_language=output_language,
|
output_language=output_language,
|
||||||
),
|
),
|
||||||
content_type="text/event-stream",
|
content_type="text/event-stream",
|
||||||
@@ -4573,6 +4583,10 @@ class ShareLinkViewSet(
|
|||||||
class ShareLinkBundleViewSet(PassUserMixin, ModelViewSet[ShareLinkBundle]):
|
class ShareLinkBundleViewSet(PassUserMixin, ModelViewSet[ShareLinkBundle]):
|
||||||
model = ShareLinkBundle
|
model = ShareLinkBundle
|
||||||
|
|
||||||
|
# Bundles are immutable once created; rebuild via the dedicated action
|
||||||
|
# rather than PUT/PATCH.
|
||||||
|
http_method_names = ["get", "post", "delete", "head", "options"]
|
||||||
|
|
||||||
queryset = ShareLinkBundle.objects.all()
|
queryset = ShareLinkBundle.objects.all()
|
||||||
|
|
||||||
serializer_class = ShareLinkBundleSerializer
|
serializer_class = ShareLinkBundleSerializer
|
||||||
@@ -4707,12 +4721,15 @@ class SharedLinkView(View):
|
|||||||
and share_link.expiration < timezone.now()
|
and share_link.expiration < timezone.now()
|
||||||
):
|
):
|
||||||
return HttpResponseRedirect("/accounts/login/?sharelink_expired=1")
|
return HttpResponseRedirect("/accounts/login/?sharelink_expired=1")
|
||||||
return serve_file(
|
try:
|
||||||
doc=share_link.document,
|
return serve_file(
|
||||||
use_archive=share_link.file_version == ShareLink.FileVersion.ARCHIVE
|
doc=share_link.document,
|
||||||
and share_link.document.has_archive_version,
|
use_archive=share_link.file_version == ShareLink.FileVersion.ARCHIVE
|
||||||
disposition="inline",
|
and share_link.document.has_archive_version,
|
||||||
)
|
disposition="inline",
|
||||||
|
)
|
||||||
|
except FileNotFoundError:
|
||||||
|
return HttpResponseRedirect("/accounts/login/?sharelink_notfound=1")
|
||||||
|
|
||||||
bundle = ShareLinkBundle.objects.filter(slug=slug).first()
|
bundle = ShareLinkBundle.objects.filter(slug=slug).first()
|
||||||
if bundle is None:
|
if bundle is None:
|
||||||
@@ -4734,7 +4751,11 @@ class SharedLinkView(View):
|
|||||||
|
|
||||||
file_path = bundle.absolute_file_path
|
file_path = bundle.absolute_file_path
|
||||||
|
|
||||||
if bundle.status == ShareLinkBundle.Status.FAILED or file_path is None:
|
if (
|
||||||
|
bundle.status == ShareLinkBundle.Status.FAILED
|
||||||
|
or file_path is None
|
||||||
|
or not file_path.exists()
|
||||||
|
):
|
||||||
return HttpResponse(
|
return HttpResponse(
|
||||||
_(
|
_(
|
||||||
"The share link bundle is unavailable.",
|
"The share link bundle is unavailable.",
|
||||||
|
|||||||
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
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user