mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-09-09 03:07:59 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f4f1de4c0e | ||
|
|
ca1f78336f | ||
|
|
aadbbddcef | ||
|
|
5ecf06463d | ||
|
|
d66c681aae | ||
|
|
4fb6182b41 | ||
|
|
b1cb41438d | ||
|
|
d60ec3cd28 |
@@ -112,6 +112,22 @@
|
||||
|
||||
<pngx-input-check i18n-title title="Use 'slim' sidebar (icons only)" formControlName="slimSidebarEnabled"></pngx-input-check>
|
||||
|
||||
<p class="mb-2 mt-3" i18n>Sidebar items to show:</p>
|
||||
@for (option of sidebarItemOptions; track option.id) {
|
||||
<div class="form-check">
|
||||
<input
|
||||
class="form-check-input"
|
||||
type="checkbox"
|
||||
[id]="'sidebar-item-setting-' + option.id"
|
||||
[checked]="isSidebarItemShown(option.id)"
|
||||
(change)="toggleSidebarItem(option.id, $event.target.checked)"
|
||||
/>
|
||||
<label class="form-check-label" [for]="'sidebar-item-setting-' + option.id">
|
||||
{{ option.label }}
|
||||
</label>
|
||||
</div>
|
||||
}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -24,7 +24,7 @@ import {
|
||||
SystemStatus,
|
||||
SystemStatusItemStatus,
|
||||
} from 'src/app/data/system-status'
|
||||
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
||||
import { HideableSidebarItemID, SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
||||
import { IfOwnerDirective } from 'src/app/directives/if-owner.directive'
|
||||
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
||||
import { PermissionsGuard } from 'src/app/guards/permissions.guard'
|
||||
@@ -209,6 +209,45 @@ describe('SettingsComponent', () => {
|
||||
fixture.detectChanges()
|
||||
}
|
||||
|
||||
it('supports configuring sidebar items and canceling changes', () => {
|
||||
completeSetup()
|
||||
|
||||
component.toggleSidebarItem(HideableSidebarItemID.Workflows, false)
|
||||
fixture.detectChanges()
|
||||
|
||||
expect(component.settingsForm.value.sidebarHiddenItems).toContain(
|
||||
HideableSidebarItemID.Workflows
|
||||
)
|
||||
|
||||
settingsService.updateSidebarItemVisibility(
|
||||
HideableSidebarItemID.Mail,
|
||||
false
|
||||
)
|
||||
|
||||
expect(component.settingsForm.value.sidebarHiddenItems).toContain(
|
||||
HideableSidebarItemID.Mail
|
||||
)
|
||||
|
||||
component.reset()
|
||||
|
||||
expect(component.settingsForm.value.sidebarHiddenItems).not.toContain(
|
||||
HideableSidebarItemID.Workflows
|
||||
)
|
||||
expect(component.settingsForm.value.sidebarHiddenItems).not.toContain(
|
||||
HideableSidebarItemID.Mail
|
||||
)
|
||||
})
|
||||
|
||||
it('enables sidebar item controls on general settings until destroyed', () => {
|
||||
completeSetup()
|
||||
|
||||
expect(settingsService.organizingSidebarItems()).toBe(true)
|
||||
|
||||
component.ngOnDestroy()
|
||||
|
||||
expect(settingsService.organizingSidebarItems()).toBe(false)
|
||||
})
|
||||
|
||||
it('should support tabbed settings & change URL, prevent navigation if dirty confirmation rejected', async () => {
|
||||
completeSetup()
|
||||
const navigateSpy = jest.spyOn(router, 'navigate')
|
||||
@@ -249,6 +288,7 @@ describe('SettingsComponent', () => {
|
||||
|
||||
it('should support save local settings updating appearance settings and calling API, show error', () => {
|
||||
completeSetup()
|
||||
component.toggleSidebarItem(HideableSidebarItemID.Workflows, false)
|
||||
const toastErrorSpy = jest.spyOn(toastService, 'showError')
|
||||
const toastSpy = jest.spyOn(toastService, 'show')
|
||||
const storeSpy = jest.spyOn(settingsService, 'storeSettings')
|
||||
@@ -267,7 +307,10 @@ describe('SettingsComponent', () => {
|
||||
expect(toastErrorSpy).toHaveBeenCalled()
|
||||
expect(storeSpy).toHaveBeenCalled()
|
||||
expect(appearanceSettingsSpy).not.toHaveBeenCalled()
|
||||
expect(setSpy).toHaveBeenCalledTimes(33)
|
||||
expect(setSpy).toHaveBeenCalledTimes(34)
|
||||
expect(setSpy).toHaveBeenCalledWith(SETTINGS_KEYS.SIDEBAR_HIDDEN_ITEMS, [
|
||||
HideableSidebarItemID.Workflows,
|
||||
])
|
||||
|
||||
// succeed
|
||||
storeSpy.mockReturnValueOnce(of(true))
|
||||
|
||||
@@ -39,7 +39,12 @@ import {
|
||||
SystemStatus,
|
||||
SystemStatusItemStatus,
|
||||
} from 'src/app/data/system-status'
|
||||
import { GlobalSearchType, SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
||||
import {
|
||||
GlobalSearchType,
|
||||
HIDEABLE_SIDEBAR_ITEM_IDS,
|
||||
HideableSidebarItemID,
|
||||
SETTINGS_KEYS,
|
||||
} from 'src/app/data/ui-settings'
|
||||
import { User } from 'src/app/data/user'
|
||||
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
||||
import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
|
||||
@@ -102,6 +107,14 @@ const documentDetailFieldOptions = [
|
||||
{ id: DocumentDetailFieldID.Tags, label: $localize`Tags` },
|
||||
]
|
||||
|
||||
const sidebarItemLabels: Record<HideableSidebarItemID, string> = {
|
||||
[HideableSidebarItemID.Dashboard]: $localize`Dashboard`,
|
||||
[HideableSidebarItemID.SavedViews]: $localize`Saved Views`,
|
||||
[HideableSidebarItemID.Workflows]: $localize`Workflows`,
|
||||
[HideableSidebarItemID.Mail]: $localize`Mail`,
|
||||
[HideableSidebarItemID.Documentation]: $localize`Documentation`,
|
||||
}
|
||||
|
||||
@Component({
|
||||
selector: 'pngx-settings',
|
||||
templateUrl: './settings.component.html',
|
||||
@@ -149,6 +162,7 @@ export class SettingsComponent
|
||||
bulkEditApplyOnClose: new FormControl(null),
|
||||
documentListItemPerPage: new FormControl(null),
|
||||
slimSidebarEnabled: new FormControl(null),
|
||||
sidebarHiddenItems: new FormControl<HideableSidebarItemID[]>([]),
|
||||
darkModeUseSystem: new FormControl(null),
|
||||
darkModeEnabled: new FormControl(null),
|
||||
darkModeInvertThumbs: new FormControl(null),
|
||||
@@ -186,6 +200,7 @@ export class SettingsComponent
|
||||
|
||||
store: BehaviorSubject<any>
|
||||
storeSub: Subscription
|
||||
sidebarItemsSub: Subscription
|
||||
isDirty$: Observable<boolean>
|
||||
isDirty: boolean = false
|
||||
unsubscribeNotifier: Subject<any> = new Subject()
|
||||
@@ -203,6 +218,10 @@ export class SettingsComponent
|
||||
public readonly PdfEditorEditMode = PdfEditorEditMode
|
||||
|
||||
public readonly documentDetailFieldOptions = documentDetailFieldOptions
|
||||
public readonly sidebarItemOptions = HIDEABLE_SIDEBAR_ITEM_IDS.map((id) => ({
|
||||
id,
|
||||
label: sidebarItemLabels[id],
|
||||
}))
|
||||
|
||||
get systemStatusHasErrors(): boolean {
|
||||
const status = this.systemStatus()
|
||||
@@ -230,6 +249,10 @@ export class SettingsComponent
|
||||
|
||||
constructor() {
|
||||
super()
|
||||
this.sidebarItemsSub =
|
||||
this.settings.sidebarHiddenItemsEditingChanged.subscribe((hiddenItems) =>
|
||||
this.settingsForm.controls.sidebarHiddenItems.setValue(hiddenItems)
|
||||
)
|
||||
this.settings.settingsSaved.subscribe(() => {
|
||||
if (!this.savePending) this.initialize()
|
||||
this.savedViewsService.maybeRefreshDocumentCounts()
|
||||
@@ -279,14 +302,21 @@ export class SettingsComponent
|
||||
|
||||
this.activatedRoute.paramMap.subscribe((paramMap) => {
|
||||
const section = paramMap.get('section')
|
||||
let navID = SettingsNavIDs.General
|
||||
if (section) {
|
||||
const navIDKey: string = Object.keys(SettingsNavIDs).find(
|
||||
(navID) => navID.toLowerCase() == section
|
||||
)
|
||||
if (navIDKey) {
|
||||
this.activeNavID.set(SettingsNavIDs[navIDKey])
|
||||
navID = SettingsNavIDs[navIDKey]
|
||||
}
|
||||
}
|
||||
this.activeNavID.set(navID)
|
||||
this.settings.sidebarHiddenItemsEditing.set(
|
||||
navID === SettingsNavIDs.General
|
||||
? [...this.settingsForm.controls.sidebarHiddenItems.value]
|
||||
: null
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -310,6 +340,7 @@ export class SettingsComponent
|
||||
SETTINGS_KEYS.DOCUMENT_LIST_SIZE
|
||||
),
|
||||
slimSidebarEnabled: this.settings.get(SETTINGS_KEYS.SLIM_SIDEBAR),
|
||||
sidebarHiddenItems: this.settings.get(SETTINGS_KEYS.SIDEBAR_HIDDEN_ITEMS),
|
||||
darkModeUseSystem: this.settings.get(SETTINGS_KEYS.DARK_MODE_USE_SYSTEM),
|
||||
darkModeEnabled: this.settings.get(SETTINGS_KEYS.DARK_MODE_ENABLED),
|
||||
darkModeInvertThumbs: this.settings.get(
|
||||
@@ -436,6 +467,12 @@ export class SettingsComponent
|
||||
this.settingsForm.patchValue(currentFormValue)
|
||||
}
|
||||
|
||||
if (this.settings.organizingSidebarItems()) {
|
||||
this.settings.sidebarHiddenItemsEditing.set([
|
||||
...this.settingsForm.controls.sidebarHiddenItems.value,
|
||||
])
|
||||
}
|
||||
|
||||
if (this.canViewSystemStatus) {
|
||||
this.systemStatusService.get().subscribe((status) => {
|
||||
this.systemStatus.set(status)
|
||||
@@ -444,8 +481,18 @@ export class SettingsComponent
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
this.settings.sidebarHiddenItemsEditing.set(null)
|
||||
if (this.isDirty) this.settings.updateAppearanceSettings() // in case user changed appearance but didn't save
|
||||
this.storeSub && this.storeSub.unsubscribe()
|
||||
this.sidebarItemsSub.unsubscribe()
|
||||
}
|
||||
|
||||
isSidebarItemShown(item: HideableSidebarItemID): boolean {
|
||||
return !(this.settingsForm.value.sidebarHiddenItems || []).includes(item)
|
||||
}
|
||||
|
||||
toggleSidebarItem(item: HideableSidebarItemID, checked: boolean): void {
|
||||
this.settings.updateSidebarItemVisibility(item, checked)
|
||||
}
|
||||
|
||||
public saveSettings() {
|
||||
@@ -473,6 +520,10 @@ export class SettingsComponent
|
||||
SETTINGS_KEYS.SLIM_SIDEBAR,
|
||||
this.settingsForm.value.slimSidebarEnabled
|
||||
)
|
||||
this.settings.set(
|
||||
SETTINGS_KEYS.SIDEBAR_HIDDEN_ITEMS,
|
||||
this.settingsForm.value.sidebarHiddenItems
|
||||
)
|
||||
this.settings.set(
|
||||
SETTINGS_KEYS.DARK_MODE_USE_SYSTEM,
|
||||
this.settingsForm.value.darkModeUseSystem
|
||||
@@ -632,6 +683,11 @@ export class SettingsComponent
|
||||
|
||||
reset() {
|
||||
this.settingsForm.patchValue(this.store.getValue())
|
||||
if (this.settings.organizingSidebarItems()) {
|
||||
this.settings.sidebarHiddenItemsEditing.set([
|
||||
...this.settingsForm.controls.sidebarHiddenItems.value,
|
||||
])
|
||||
}
|
||||
}
|
||||
|
||||
clearThemeColor() {
|
||||
|
||||
@@ -86,12 +86,15 @@
|
||||
}
|
||||
<div class="sidebar-sticky pt-3 pb-1 d-flex flex-column justify-space-around">
|
||||
<ul class="nav flex-column">
|
||||
<li class="nav-item app-link">
|
||||
<a class="nav-link" routerLink="dashboard" routerLinkActive="active" (click)="closeMenu()"
|
||||
<li class="nav-item app-link position-relative" [class.d-none]="settingsService.sidebarItemIsHidden(HideableSidebarItemID.Dashboard) && !settingsService.organizingSidebarItems()">
|
||||
<a class="nav-link" [class.opacity-50]="settingsService.sidebarItemIsHidden(HideableSidebarItemID.Dashboard)" [class.pe-5]="settingsService.organizingSidebarItems() && !slimSidebarEnabled && !slimSidebarAnimating()" routerLink="dashboard" routerLinkActive="active" (click)="closeMenu()"
|
||||
ngbPopover="Dashboard" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end"
|
||||
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||
<i-bs class="me-2" name="house"></i-bs><span class="nav-link-label"><ng-container i18n>Dashboard</ng-container></span>
|
||||
</a>
|
||||
@if (settingsService.organizingSidebarItems()) {
|
||||
<pngx-input-switch class="position-absolute top-50 end-0 translate-middle-y me-1" [class.d-none]="slimSidebarEnabled || slimSidebarAnimating()" [compact]="true" title="Dashboard" i18n-title [ngModel]="!settingsService.sidebarItemIsHidden(HideableSidebarItemID.Dashboard)" (ngModelChange)="toggleSidebarItem(HideableSidebarItemID.Dashboard, $event)"></pngx-input-switch>
|
||||
}
|
||||
</li>
|
||||
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }">
|
||||
<a class="nav-link" routerLink="documents" routerLinkActive="active"
|
||||
@@ -237,29 +240,38 @@
|
||||
</div>
|
||||
</li>
|
||||
}
|
||||
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.SavedView }">
|
||||
<a class="nav-link" routerLink="savedviews" routerLinkActive="active" (click)="closeMenu()"
|
||||
<li class="nav-item app-link position-relative" [class.d-none]="settingsService.sidebarItemIsHidden(HideableSidebarItemID.SavedViews) && !settingsService.organizingSidebarItems()" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.SavedView }">
|
||||
<a class="nav-link" [class.opacity-50]="settingsService.sidebarItemIsHidden(HideableSidebarItemID.SavedViews)" [class.pe-5]="settingsService.organizingSidebarItems() && !slimSidebarEnabled && !slimSidebarAnimating()" routerLink="savedviews" routerLinkActive="active" (click)="closeMenu()"
|
||||
ngbPopover="Saved Views" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end"
|
||||
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||
<i-bs class="me-2" name="window-stack"></i-bs><span class="nav-link-label"><ng-container i18n>Saved Views</ng-container></span>
|
||||
</a>
|
||||
@if (settingsService.organizingSidebarItems()) {
|
||||
<pngx-input-switch class="position-absolute top-50 end-0 translate-middle-y me-1" [class.d-none]="slimSidebarEnabled || slimSidebarAnimating()" [compact]="true" title="Saved Views" i18n-title [ngModel]="!settingsService.sidebarItemIsHidden(HideableSidebarItemID.SavedViews)" (ngModelChange)="toggleSidebarItem(HideableSidebarItemID.SavedViews, $event)"></pngx-input-switch>
|
||||
}
|
||||
</li>
|
||||
<li class="nav-item app-link"
|
||||
<li class="nav-item app-link position-relative" [class.d-none]="settingsService.sidebarItemIsHidden(HideableSidebarItemID.Workflows) && !settingsService.organizingSidebarItems()"
|
||||
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Workflow }"
|
||||
tourAnchor="tour.workflows">
|
||||
<a class="nav-link" routerLink="workflows" routerLinkActive="active" (click)="closeMenu()"
|
||||
<a class="nav-link" [class.opacity-50]="settingsService.sidebarItemIsHidden(HideableSidebarItemID.Workflows)" [class.pe-5]="settingsService.organizingSidebarItems() && !slimSidebarEnabled && !slimSidebarAnimating()" routerLink="workflows" routerLinkActive="active" (click)="closeMenu()"
|
||||
ngbPopover="Workflows" i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end"
|
||||
container="body" triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||
<i-bs class="me-2" name="boxes"></i-bs><span class="nav-link-label"><ng-container i18n>Workflows</ng-container></span>
|
||||
</a>
|
||||
@if (settingsService.organizingSidebarItems()) {
|
||||
<pngx-input-switch class="position-absolute top-50 end-0 translate-middle-y me-1" [class.d-none]="slimSidebarEnabled || slimSidebarAnimating()" [compact]="true" title="Workflows" i18n-title [ngModel]="!settingsService.sidebarItemIsHidden(HideableSidebarItemID.Workflows)" (ngModelChange)="toggleSidebarItem(HideableSidebarItemID.Workflows, $event)"></pngx-input-switch>
|
||||
}
|
||||
</li>
|
||||
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.MailAccount }"
|
||||
<li class="nav-item app-link position-relative" [class.d-none]="settingsService.sidebarItemIsHidden(HideableSidebarItemID.Mail) && !settingsService.organizingSidebarItems()" *pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.MailAccount }"
|
||||
tourAnchor="tour.mail">
|
||||
<a class="nav-link" routerLink="mail" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Mail"
|
||||
<a class="nav-link" [class.opacity-50]="settingsService.sidebarItemIsHidden(HideableSidebarItemID.Mail)" [class.pe-5]="settingsService.organizingSidebarItems() && !slimSidebarEnabled && !slimSidebarAnimating()" routerLink="mail" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Mail"
|
||||
i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end" container="body"
|
||||
triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||
<i-bs class="me-2" name="envelope"></i-bs><span class="nav-link-label"><ng-container i18n>Mail</ng-container></span>
|
||||
</a>
|
||||
@if (settingsService.organizingSidebarItems()) {
|
||||
<pngx-input-switch class="position-absolute top-50 end-0 translate-middle-y me-1" [class.d-none]="slimSidebarEnabled || slimSidebarAnimating()" [compact]="true" title="Mail" i18n-title [ngModel]="!settingsService.sidebarItemIsHidden(HideableSidebarItemID.Mail)" (ngModelChange)="toggleSidebarItem(HideableSidebarItemID.Mail, $event)"></pngx-input-switch>
|
||||
}
|
||||
</li>
|
||||
<li class="nav-item app-link" *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.Document }">
|
||||
<a class="nav-link" routerLink="trash" routerLinkActive="active" (click)="closeMenu()" ngbPopover="Trash"
|
||||
@@ -322,13 +334,16 @@
|
||||
</a>
|
||||
</li>
|
||||
}
|
||||
<li class="nav-item mt-2" tourAnchor="tour.outro">
|
||||
<a class="text-muted small d-flex align-items-center flex-wrap text-decoration-none nav-anchor"
|
||||
<li class="nav-item mt-2 position-relative" [class.d-none]="settingsService.sidebarItemIsHidden(HideableSidebarItemID.Documentation) && !settingsService.organizingSidebarItems()" tourAnchor="tour.outro">
|
||||
<a class="text-muted small d-flex align-items-center flex-wrap text-decoration-none nav-anchor" [class.opacity-50]="settingsService.sidebarItemIsHidden(HideableSidebarItemID.Documentation)" [class.pe-5]="settingsService.organizingSidebarItems() && !slimSidebarEnabled && !slimSidebarAnimating()"
|
||||
target="_blank" rel="noopener noreferrer" href="https://docs.paperless-ngx.com" ngbPopover="Documentation"
|
||||
i18n-ngbPopover [disablePopover]="!slimSidebarPopoversEnabled" placement="end" container="body"
|
||||
triggers="mouseenter:mouseleave" popoverClass="popover-slim">
|
||||
<i-bs class="d-flex me-2" name="question-circle"></i-bs><span><ng-container i18n>Documentation</ng-container></span>
|
||||
</a>
|
||||
@if (settingsService.organizingSidebarItems()) {
|
||||
<pngx-input-switch class="position-absolute top-50 end-0 translate-middle-y me-1" [class.d-none]="slimSidebarEnabled || slimSidebarAnimating()" [compact]="true" title="Documentation" i18n-title [ngModel]="!settingsService.sidebarItemIsHidden(HideableSidebarItemID.Documentation)" (ngModelChange)="toggleSidebarItem(HideableSidebarItemID.Documentation, $event)"></pngx-input-switch>
|
||||
}
|
||||
</li>
|
||||
<li class="nav-item" [class.visually-hidden]="slimSidebarEnabled">
|
||||
<div class="text-muted small d-flex align-items-center flex-wrap nav-label">
|
||||
|
||||
@@ -15,7 +15,7 @@ import { provideUiTour } from 'ngx-ui-tour-ng-bootstrap'
|
||||
import { of, throwError } from 'rxjs'
|
||||
import { routes } from 'src/app/app-routing.module'
|
||||
import { SavedView } from 'src/app/data/saved-view'
|
||||
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
||||
import { HideableSidebarItemID, SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
||||
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
||||
import { PermissionsGuard } from 'src/app/guards/permissions.guard'
|
||||
import {
|
||||
@@ -287,6 +287,82 @@ describe('AppFrameComponent', () => {
|
||||
jest.useRealTimers()
|
||||
})
|
||||
|
||||
it('should hide configured sidebar items', () => {
|
||||
settingsService.set(SETTINGS_KEYS.SIDEBAR_HIDDEN_ITEMS, [
|
||||
HideableSidebarItemID.Dashboard,
|
||||
HideableSidebarItemID.Workflows,
|
||||
])
|
||||
fixture.detectChanges()
|
||||
|
||||
expect(
|
||||
fixture.nativeElement.querySelector('[routerLink="dashboard"]')
|
||||
.parentElement.classList
|
||||
).toContain('d-none')
|
||||
expect(
|
||||
fixture.nativeElement.querySelector('[routerLink="workflows"]')
|
||||
.parentElement.classList
|
||||
).toContain('d-none')
|
||||
expect(
|
||||
fixture.nativeElement.querySelector('[routerLink="mail"]').parentElement
|
||||
.classList
|
||||
).not.toContain('d-none')
|
||||
})
|
||||
|
||||
it('should show hidden items and visibility switches while customizing', () => {
|
||||
settingsService.set(SETTINGS_KEYS.SIDEBAR_HIDDEN_ITEMS, [
|
||||
HideableSidebarItemID.Dashboard,
|
||||
])
|
||||
settingsService.sidebarHiddenItemsEditing.set([
|
||||
HideableSidebarItemID.Dashboard,
|
||||
])
|
||||
fixture.detectChanges()
|
||||
|
||||
expect(
|
||||
fixture.nativeElement.querySelectorAll('pngx-input-switch').length
|
||||
).toBe(5)
|
||||
expect(
|
||||
fixture.nativeElement.querySelector('[routerLink="dashboard"]')
|
||||
.parentElement.classList
|
||||
).not.toContain('d-none')
|
||||
expect(
|
||||
fixture.nativeElement.querySelector('[routerLink="dashboard"]').classList
|
||||
).toContain('opacity-50')
|
||||
|
||||
settingsService.set(SETTINGS_KEYS.SLIM_SIDEBAR, true)
|
||||
fixture.detectChanges()
|
||||
|
||||
expect(
|
||||
Array.from(
|
||||
fixture.nativeElement.querySelectorAll('pngx-input-switch')
|
||||
).every((toggle: HTMLElement) => toggle.classList.contains('d-none'))
|
||||
).toBe(true)
|
||||
expect(
|
||||
fixture.nativeElement.querySelector('[routerLink="dashboard"]').classList
|
||||
).not.toContain('pe-5')
|
||||
|
||||
settingsService.set(SETTINGS_KEYS.SLIM_SIDEBAR, false)
|
||||
component.slimSidebarAnimating.set(true)
|
||||
fixture.detectChanges()
|
||||
|
||||
expect(
|
||||
Array.from(
|
||||
fixture.nativeElement.querySelectorAll('pngx-input-switch')
|
||||
).every((toggle: HTMLElement) => toggle.classList.contains('d-none'))
|
||||
).toBe(true)
|
||||
|
||||
component.slimSidebarAnimating.set(false)
|
||||
fixture.detectChanges()
|
||||
|
||||
expect(
|
||||
Array.from(
|
||||
fixture.nativeElement.querySelectorAll('pngx-input-switch')
|
||||
).every((toggle: HTMLElement) => !toggle.classList.contains('d-none'))
|
||||
).toBe(true)
|
||||
expect(
|
||||
fixture.nativeElement.querySelector('[routerLink="dashboard"]').classList
|
||||
).toContain('pe-5')
|
||||
})
|
||||
|
||||
it('should show error on toggle slim sidebar if store settings fails', () => {
|
||||
jest.spyOn(console, 'warn').mockImplementation(() => {})
|
||||
const toastSpy = jest.spyOn(toastService, 'showError')
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
} from '@angular/cdk/drag-drop'
|
||||
import { NgClass } from '@angular/common'
|
||||
import { Component, HostListener, inject, OnInit, signal } from '@angular/core'
|
||||
import { FormsModule } from '@angular/forms'
|
||||
import { ActivatedRoute, Router, RouterModule } from '@angular/router'
|
||||
import {
|
||||
NgbCollapseModule,
|
||||
@@ -21,7 +22,11 @@ import { Observable } from 'rxjs'
|
||||
import { first } from 'rxjs/operators'
|
||||
import { Document } from 'src/app/data/document'
|
||||
import { SavedView } from 'src/app/data/saved-view'
|
||||
import { CollapsibleSection, SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
||||
import {
|
||||
CollapsibleSection,
|
||||
HideableSidebarItemID,
|
||||
SETTINGS_KEYS,
|
||||
} from 'src/app/data/ui-settings'
|
||||
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
||||
import { ComponentCanDeactivate } from 'src/app/guards/dirty-doc.guard'
|
||||
import { DocumentTitlePipe } from 'src/app/pipes/document-title.pipe'
|
||||
@@ -48,6 +53,7 @@ import { ChatComponent } from '../chat/chat/chat.component'
|
||||
import { BrandMarkComponent } from '../common/logo/brand-mark/brand-mark.component'
|
||||
import { LogoComponent } from '../common/logo/logo.component'
|
||||
import { ProfileEditDialogComponent } from '../common/profile-edit-dialog/profile-edit-dialog.component'
|
||||
import { SwitchComponent } from '../common/input/switch/switch.component'
|
||||
import { DocumentDetailComponent } from '../document-detail/document-detail.component'
|
||||
import { ComponentWithPermissions } from '../with-permissions/with-permissions.component'
|
||||
import { GlobalSearchComponent } from './global-search/global-search.component'
|
||||
@@ -76,6 +82,8 @@ const SCROLL_THRESHOLD = 16
|
||||
NgxBootstrapIconsModule,
|
||||
DragDropModule,
|
||||
TourNgBootstrap,
|
||||
FormsModule,
|
||||
SwitchComponent,
|
||||
],
|
||||
})
|
||||
export class AppFrameComponent
|
||||
@@ -98,6 +106,7 @@ export class AppFrameComponent
|
||||
readonly isMenuCollapsed = signal(true)
|
||||
readonly slimSidebarAnimating = signal(false)
|
||||
readonly mobileSearchHidden = signal(false)
|
||||
readonly HideableSidebarItemID = HideableSidebarItemID
|
||||
private readonly versionSetting = this.settingsService.getSignal<string>(
|
||||
SETTINGS_KEYS.VERSION
|
||||
)
|
||||
@@ -195,6 +204,10 @@ export class AppFrameComponent
|
||||
}, 200) // slightly longer than css animation for slim sidebar
|
||||
}
|
||||
|
||||
toggleSidebarItem(item: HideableSidebarItemID, visible: boolean): void {
|
||||
this.settingsService.updateSidebarItemVisibility(item, visible)
|
||||
}
|
||||
|
||||
toggleAttributesSections(event?: Event): void {
|
||||
event?.preventDefault()
|
||||
event?.stopPropagation()
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<div class="mb-3">
|
||||
<div class="row">
|
||||
@if (!horizontal) {
|
||||
<div [class.mb-3]="!compact">
|
||||
<div [class.row]="!compact">
|
||||
@if (!horizontal && !compact) {
|
||||
<div class="d-flex align-items-center position-relative hidden-button-container col-md-3">
|
||||
<label class="form-label" [for]="inputId" [ngbTooltip]="showUnsetNote && isUnset ? tipContent: null" placement="end">
|
||||
{{title}}
|
||||
@@ -17,8 +17,8 @@
|
||||
}
|
||||
<div [ngClass]="{'align-items-center': horizontal, 'd-flex': horizontal}">
|
||||
<div class="form-check form-switch">
|
||||
<input #inputField type="checkbox" class="form-check-input" [id]="inputId" [(ngModel)]="value" [ngModelOptions]="{standalone: true}" (change)="onChange(value)" (blur)="onTouched()" [disabled]="disabled">
|
||||
@if (horizontal) {
|
||||
<input #inputField type="checkbox" class="form-check-input" [id]="inputId" [(ngModel)]="value" [ngModelOptions]="{standalone: true}" (change)="onChange(value)" (blur)="onTouched()" [disabled]="disabled" [attr.aria-label]="compact ? title : null">
|
||||
@if (horizontal && !compact) {
|
||||
<label class="form-check-label" [class.text-muted]="showUnsetNote && isUnset" [for]="inputId" [ngbTooltip]="showUnsetNote && isUnset ? tipContent: null" placement="end">
|
||||
{{title}}
|
||||
@if (showUnsetNote && isUnset) {
|
||||
|
||||
@@ -48,4 +48,14 @@ describe('SwitchComponent', () => {
|
||||
component.value = undefined
|
||||
expect(component.isUnset).toBeTruthy()
|
||||
})
|
||||
|
||||
it('should support a compact layout', () => {
|
||||
component.compact = true
|
||||
component.title = 'Test switch'
|
||||
fixture.detectChanges()
|
||||
|
||||
expect(fixture.nativeElement.querySelector('.mb-3')).toBeNull()
|
||||
expect(fixture.nativeElement.querySelector('.row')).toBeNull()
|
||||
expect(input.getAttribute('aria-label')).toEqual('Test switch')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -25,6 +25,9 @@ export class SwitchComponent extends AbstractInputComponent<boolean> {
|
||||
@Input()
|
||||
showUnsetNote: boolean = false
|
||||
|
||||
@Input()
|
||||
compact: boolean = false
|
||||
|
||||
constructor() {
|
||||
super()
|
||||
}
|
||||
|
||||
@@ -24,6 +24,16 @@ export enum CollapsibleSection {
|
||||
ATTRIBUTES = 'attributes',
|
||||
}
|
||||
|
||||
export enum HideableSidebarItemID {
|
||||
Dashboard = 'dashboard',
|
||||
SavedViews = 'saved_views',
|
||||
Workflows = 'workflows',
|
||||
Mail = 'mail',
|
||||
Documentation = 'documentation',
|
||||
}
|
||||
|
||||
export const HIDEABLE_SIDEBAR_ITEM_IDS = Object.values(HideableSidebarItemID)
|
||||
|
||||
export const PAPERLESS_GREEN_HEX = '#17541f'
|
||||
|
||||
export const SETTINGS_KEYS = {
|
||||
@@ -56,6 +66,7 @@ export const SETTINGS_KEYS = {
|
||||
NOTES_ENABLED: 'general-settings:notes-enabled',
|
||||
AUDITLOG_ENABLED: 'general-settings:auditlog-enabled',
|
||||
SLIM_SIDEBAR: 'general-settings:slim-sidebar',
|
||||
SIDEBAR_HIDDEN_ITEMS: 'general-settings:sidebar:hidden-items',
|
||||
ATTRIBUTES_SECTIONS_COLLAPSED:
|
||||
'general-settings:attributes-sections-collapsed',
|
||||
UPDATE_CHECKING_ENABLED: 'general-settings:update-checking:enabled',
|
||||
@@ -127,6 +138,11 @@ export const SETTINGS: UiSetting[] = [
|
||||
type: 'boolean',
|
||||
default: false,
|
||||
},
|
||||
{
|
||||
key: SETTINGS_KEYS.SIDEBAR_HIDDEN_ITEMS,
|
||||
type: 'array',
|
||||
default: [],
|
||||
},
|
||||
{
|
||||
key: SETTINGS_KEYS.ATTRIBUTES_SECTIONS_COLLAPSED,
|
||||
type: 'array',
|
||||
|
||||
@@ -14,7 +14,11 @@ import { CustomFieldDataType } from '../data/custom-field'
|
||||
import { DEFAULT_DISPLAY_FIELDS, DisplayField } from '../data/document'
|
||||
import { SavedView } from '../data/saved-view'
|
||||
import { RemoteOCRModeConfig } from '../data/paperless-config'
|
||||
import { SETTINGS_KEYS, UiSettings } from '../data/ui-settings'
|
||||
import {
|
||||
HideableSidebarItemID,
|
||||
SETTINGS_KEYS,
|
||||
UiSettings,
|
||||
} from '../data/ui-settings'
|
||||
import { PermissionsService } from './permissions.service'
|
||||
import { CustomFieldsService } from './rest/custom-fields.service'
|
||||
import { SettingsService } from './settings.service'
|
||||
@@ -230,6 +234,35 @@ describe('SettingsService', () => {
|
||||
expect(notesEnabled()).toBeFalsy()
|
||||
})
|
||||
|
||||
it('updates sidebar item visibility', () => {
|
||||
httpTestingController
|
||||
.expectOne(`${environment.apiBaseUrl}ui_settings/`)
|
||||
.flush(ui_settings)
|
||||
|
||||
expect(
|
||||
settingsService.sidebarItemIsHidden(HideableSidebarItemID.Workflows)
|
||||
).toBe(false)
|
||||
|
||||
settingsService.updateSidebarItemVisibility(
|
||||
HideableSidebarItemID.Workflows,
|
||||
false
|
||||
)
|
||||
|
||||
expect(
|
||||
settingsService.sidebarItemIsHidden(HideableSidebarItemID.Workflows)
|
||||
).toBe(true)
|
||||
expect(settingsService.get(SETTINGS_KEYS.SIDEBAR_HIDDEN_ITEMS)).toEqual([])
|
||||
|
||||
settingsService.updateSidebarItemVisibility(
|
||||
HideableSidebarItemID.Workflows,
|
||||
true
|
||||
)
|
||||
|
||||
expect(
|
||||
settingsService.sidebarItemIsHidden(HideableSidebarItemID.Workflows)
|
||||
).toBe(false)
|
||||
})
|
||||
|
||||
it('updates setting signals when settings are reinitialized', () => {
|
||||
let req = httpTestingController.expectOne(
|
||||
`${environment.apiBaseUrl}ui_settings/`
|
||||
|
||||
@@ -24,6 +24,7 @@ import { DEFAULT_DISPLAY_FIELDS, DisplayField } from '../data/document'
|
||||
import { RemoteOCRModeConfig } from '../data/paperless-config'
|
||||
import { SavedView } from '../data/saved-view'
|
||||
import {
|
||||
HideableSidebarItemID,
|
||||
PAPERLESS_GREEN_HEX,
|
||||
SETTINGS,
|
||||
SETTINGS_KEYS,
|
||||
@@ -313,6 +314,18 @@ export class SettingsService {
|
||||
readonly globalDropzoneEnabled = signal(true)
|
||||
readonly globalDropzoneActive = signal(false)
|
||||
readonly organizingSidebarSavedViews = signal(false)
|
||||
readonly sidebarHiddenItemsEditing = signal<HideableSidebarItemID[] | null>(
|
||||
null
|
||||
)
|
||||
readonly organizingSidebarItems = computed(
|
||||
() => this.sidebarHiddenItemsEditing() !== null
|
||||
)
|
||||
readonly sidebarHiddenItemsEditingChanged = new EventEmitter<
|
||||
HideableSidebarItemID[]
|
||||
>()
|
||||
readonly hiddenSidebarItems = this.getSignal<HideableSidebarItemID[]>(
|
||||
SETTINGS_KEYS.SIDEBAR_HIDDEN_ITEMS
|
||||
)
|
||||
|
||||
readonly allDisplayFields = signal<Array<{ id: DisplayField; name: string }>>(
|
||||
DEFAULT_DISPLAY_FIELDS
|
||||
@@ -749,6 +762,29 @@ export class SettingsService {
|
||||
return this.storeSettings()
|
||||
}
|
||||
|
||||
sidebarItemIsHidden(item: HideableSidebarItemID): boolean {
|
||||
return (
|
||||
this.sidebarHiddenItemsEditing() ?? this.hiddenSidebarItems()
|
||||
).includes(item)
|
||||
}
|
||||
|
||||
updateSidebarItemVisibility(
|
||||
item: HideableSidebarItemID,
|
||||
visible: boolean
|
||||
): void {
|
||||
const hiddenItems = new Set(
|
||||
this.sidebarHiddenItemsEditing() ?? this.hiddenSidebarItems()
|
||||
)
|
||||
if (visible) {
|
||||
hiddenItems.delete(item)
|
||||
} else {
|
||||
hiddenItems.add(item)
|
||||
}
|
||||
const updatedHiddenItems = [...hiddenItems]
|
||||
this.sidebarHiddenItemsEditing.set(updatedHiddenItems)
|
||||
this.sidebarHiddenItemsEditingChanged.emit(updatedHiddenItems)
|
||||
}
|
||||
|
||||
updateSavedViewsVisibility(
|
||||
dashboardVisibleViewIds: number[],
|
||||
sidebarVisibleViewIds: number[]
|
||||
|
||||
@@ -374,7 +374,6 @@ class Document(SoftDeleteModel, ModelWithOwner): # type: ignore[django-manager-
|
||||
If the queryset already annotated ``effective_content``, that value is used.
|
||||
"""
|
||||
# Here to avoid circular import
|
||||
from documents.versioning import LATEST_VERSION_CONTENT_PREFETCH_ATTR
|
||||
from documents.versioning import sort_versions_newest_first
|
||||
from documents.versioning import versions_newest_first
|
||||
|
||||
@@ -384,19 +383,6 @@ class Document(SoftDeleteModel, ModelWithOwner): # type: ignore[django-manager-
|
||||
if self.root_document_id is not None or self.pk is None:
|
||||
return self.content
|
||||
|
||||
latest_version_prefetch = getattr(
|
||||
self,
|
||||
LATEST_VERSION_CONTENT_PREFETCH_ATTR,
|
||||
None,
|
||||
)
|
||||
if latest_version_prefetch is not None:
|
||||
# Empty list means prefetch ran and found no versions — use own content.
|
||||
return (
|
||||
latest_version_prefetch[0].content
|
||||
if latest_version_prefetch
|
||||
else self.content
|
||||
)
|
||||
|
||||
prefetched_cache = getattr(self, "_prefetched_objects_cache", None)
|
||||
prefetched_versions = (
|
||||
prefetched_cache.get("versions")
|
||||
|
||||
@@ -89,7 +89,6 @@ from documents.templating.utils import convert_format_str_to_template_format
|
||||
from documents.templating.workflows import validate_workflow_template
|
||||
from documents.validators import uri_validator
|
||||
from documents.validators import url_validator
|
||||
from documents.versioning import has_prefetched_effective_content
|
||||
from documents.versioning import sort_versions_newest_first
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -1153,14 +1152,8 @@ class DocumentSerializer(
|
||||
|
||||
def to_representation(self, instance):
|
||||
doc = super().to_representation(instance)
|
||||
if "content" in self.fields and has_prefetched_effective_content(instance):
|
||||
# Only resolve version-aware content when it's cheap: an SQL
|
||||
# annotation or a versions prefetch is already on the instance.
|
||||
# A caller that set up neither (e.g. TrashView, GlobalSearchView,
|
||||
# which build their own querysets) gets the document's own,
|
||||
# unresolved content instead of paying for an extra per-instance
|
||||
# query -- same as before effective_content resolution existed.
|
||||
doc["content"] = instance.get_effective_content() or ""
|
||||
if "content" in self.fields and hasattr(instance, "effective_content"):
|
||||
doc["content"] = getattr(instance, "effective_content") or ""
|
||||
if self.truncate_content and "content" in self.fields:
|
||||
doc["content"] = doc.get("content")[0:550]
|
||||
return doc
|
||||
|
||||
@@ -1,240 +0,0 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import pytest
|
||||
from django.db import connection
|
||||
from django.test.utils import CaptureQueriesContext
|
||||
from rest_framework import status
|
||||
|
||||
from documents.models import Document
|
||||
from documents.tests.factories import DocumentFactory
|
||||
from documents.versioning import LATEST_VERSION_CONTENT_PREFETCH_ATTR
|
||||
from documents.versioning import has_prefetched_effective_content
|
||||
from documents.versioning import latest_version_content_prefetch
|
||||
from documents.views import DocumentViewSet
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from rest_framework.test import APIClient
|
||||
|
||||
|
||||
class TestNeedsEffectiveContentAnnotation:
|
||||
"""
|
||||
DocumentViewSet._needs_effective_content_annotation() decides whether
|
||||
the effective_content correlated subquery is worth attaching to the
|
||||
queryset at all -- see TestDocumentListEffectiveContentAnnotation below
|
||||
for why. This only checks that decision's own logic (a plain query-param
|
||||
membership test), not that Django/DRF's filtering machinery works.
|
||||
"""
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("params", "expected"),
|
||||
[
|
||||
({}, False),
|
||||
({"ordering": "-added"}, False),
|
||||
({"tags__id__in": "1,2"}, False),
|
||||
({"search": ""}, False),
|
||||
({"search": " "}, False),
|
||||
({"content__icontains": ""}, False),
|
||||
({"search": "foo"}, True),
|
||||
({"title_content": "foo"}, True),
|
||||
({"content__istartswith": "foo"}, True),
|
||||
({"content__iendswith": "foo"}, True),
|
||||
({"content__icontains": "foo"}, True),
|
||||
({"content__iexact": "foo"}, True),
|
||||
],
|
||||
)
|
||||
def test_detects_content_filter_params(
|
||||
self,
|
||||
params: dict[str, str],
|
||||
expected: bool, # noqa: FBT001
|
||||
) -> None:
|
||||
# GIVEN a view bound to a request carrying the given query params
|
||||
view = DocumentViewSet()
|
||||
view.request = SimpleNamespace(query_params=params)
|
||||
|
||||
# WHEN checking whether the effective_content annotation is needed
|
||||
# THEN it's needed only for requests that actually filter on it
|
||||
assert view._needs_effective_content_annotation() is expected
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestDocumentListEffectiveContentAnnotation:
|
||||
"""
|
||||
DocumentViewSet.get_queryset() only attaches the effective_content
|
||||
correlated subquery when a request actually filters on it. Attaching it
|
||||
unconditionally re-executes it once per candidate row before the page's
|
||||
LIMIT is applied -- fine on SQLite/Postgres, but pathological on
|
||||
MariaDB's default cardinality estimation for the root_document_id
|
||||
self-join once candidate counts get large (see the root_document_id /
|
||||
effective_content perf investigation).
|
||||
"""
|
||||
|
||||
def test_list_without_content_filter_skips_annotation_but_returns_latest_content(
|
||||
self,
|
||||
admin_client: APIClient,
|
||||
) -> None:
|
||||
# GIVEN a root document whose latest version has different content
|
||||
root = DocumentFactory(content="old-root-content")
|
||||
DocumentFactory(
|
||||
root_document=root,
|
||||
version_index=1,
|
||||
content="new-version-content",
|
||||
)
|
||||
|
||||
# WHEN listing documents with no search/content-filter param
|
||||
with CaptureQueriesContext(connection) as ctx:
|
||||
response = admin_client.get("/api/documents/?fields=id,content")
|
||||
|
||||
# THEN the response still reflects the latest version's content...
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
assert response.data["results"] == [
|
||||
{"id": root.id, "content": "new-version-content"},
|
||||
]
|
||||
# ...without the database ever evaluating effective_content per row
|
||||
assert not any(
|
||||
"effective_content" in query["sql"] for query in ctx.captured_queries
|
||||
)
|
||||
|
||||
def test_latest_version_content_prefetch_carries_only_the_newest_version(
|
||||
self,
|
||||
) -> None:
|
||||
# GIVEN a root document with two versions
|
||||
root = DocumentFactory(content="root-content")
|
||||
DocumentFactory(
|
||||
root_document=root,
|
||||
version_index=1,
|
||||
content="older-version-content",
|
||||
)
|
||||
DocumentFactory(
|
||||
root_document=root,
|
||||
version_index=2,
|
||||
content="newest-version-content",
|
||||
)
|
||||
|
||||
# WHEN fetching the root through latest_version_content_prefetch()
|
||||
fetched_root = (
|
||||
Document.objects.filter(pk=root.pk)
|
||||
.prefetch_related(
|
||||
latest_version_content_prefetch(),
|
||||
)
|
||||
.get()
|
||||
)
|
||||
|
||||
# THEN the prefetch carries only the single newest version, not
|
||||
# every historical version's content (the whole point of not
|
||||
# reusing the metadata-only "versions" prefetch for this)
|
||||
latest = getattr(fetched_root, LATEST_VERSION_CONTENT_PREFETCH_ATTR)
|
||||
assert [v.content for v in latest] == ["newest-version-content"]
|
||||
|
||||
|
||||
class TestHasPrefetchedEffectiveContent:
|
||||
"""
|
||||
DocumentSerializer.to_representation() only calls get_effective_content()
|
||||
when has_prefetched_effective_content() says it's cheap -- otherwise a
|
||||
caller that never set up an annotation or prefetch (TrashView,
|
||||
GlobalSearchView, which build their own querysets and don't display
|
||||
content at all) would pay for a per-instance query nobody asked for.
|
||||
"""
|
||||
|
||||
def test_false_with_no_annotation_or_prefetch(self) -> None:
|
||||
document = Document()
|
||||
assert has_prefetched_effective_content(document) is False
|
||||
|
||||
def test_true_with_effective_content_annotation(self) -> None:
|
||||
document = Document()
|
||||
document.effective_content = "resolved"
|
||||
assert has_prefetched_effective_content(document) is True
|
||||
|
||||
def test_true_with_lean_prefetch_attr_even_when_empty(self) -> None:
|
||||
document = Document()
|
||||
setattr(document, LATEST_VERSION_CONTENT_PREFETCH_ATTR, [])
|
||||
assert has_prefetched_effective_content(document) is True
|
||||
|
||||
def test_true_with_metadata_versions_prefetch_cache(self) -> None:
|
||||
document = Document()
|
||||
document._prefetched_objects_cache = {"versions": []}
|
||||
assert has_prefetched_effective_content(document) is True
|
||||
|
||||
|
||||
def _get_effective_content_fallback_queries(
|
||||
ctx: CaptureQueriesContext,
|
||||
) -> list[dict[str, str]]:
|
||||
"""
|
||||
Document.get_effective_content()'s per-instance fallback (no annotation,
|
||||
no prefetch) is a `.values_list("content", flat=True).first()` query --
|
||||
a SELECT of just the content column. Distinct from get_versions()'s own,
|
||||
unrelated per-instance metadata query (id/checksum/added/etc, no
|
||||
content) run to build the "versions" response field, which isn't part
|
||||
of what this test file covers.
|
||||
"""
|
||||
return [
|
||||
q
|
||||
for q in ctx.captured_queries
|
||||
if q["sql"].startswith('SELECT "documents_document"."content" FROM')
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
class TestTrashAndGlobalSearchEffectiveContentIsNeverPerInstance:
|
||||
"""
|
||||
TrashView and GlobalSearchView serialize Document instances with
|
||||
DocumentSerializer too, but build their querysets independently of
|
||||
DocumentViewSet.get_queryset(). TrashView doesn't display content at all,
|
||||
so it keeps the document's own unresolved content; GlobalSearchView
|
||||
annotates effective_content itself, so it shows the latest version's.
|
||||
Neither should ever fall back to a per-instance query.
|
||||
"""
|
||||
|
||||
def test_trash_list_shows_unresolved_content_with_no_extra_query(
|
||||
self,
|
||||
admin_client: APIClient,
|
||||
) -> None:
|
||||
# GIVEN a trashed root document whose own content differs from what
|
||||
# a (also trashed, since deletion cascades) version would have had
|
||||
root = DocumentFactory(content="own-content")
|
||||
DocumentFactory(
|
||||
root_document=root,
|
||||
version_index=1,
|
||||
content="version-content",
|
||||
)
|
||||
root.delete()
|
||||
|
||||
# WHEN listing trash
|
||||
with CaptureQueriesContext(connection) as ctx:
|
||||
response = admin_client.get("/api/trash/")
|
||||
|
||||
# THEN the response shows the document's own content...
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
[result] = [r for r in response.data["results"] if r["id"] == root.id]
|
||||
assert result["content"] == "own-content"
|
||||
# ...without ever querying for versions to resolve it
|
||||
assert _get_effective_content_fallback_queries(ctx) == []
|
||||
|
||||
def test_global_search_db_only_shows_latest_version_content_with_no_extra_query(
|
||||
self,
|
||||
admin_client: APIClient,
|
||||
) -> None:
|
||||
# GIVEN a root document, findable by title, whose own content
|
||||
# differs from its latest version's
|
||||
root = DocumentFactory(title="findme", content="own-content")
|
||||
DocumentFactory(
|
||||
root_document=root,
|
||||
version_index=1,
|
||||
content="version-content",
|
||||
)
|
||||
|
||||
# WHEN using the global search endpoint's db_only mode
|
||||
with CaptureQueriesContext(connection) as ctx:
|
||||
response = admin_client.get(
|
||||
"/api/search/?query=findme&db_only=true",
|
||||
)
|
||||
|
||||
# THEN the response shows the latest version's content, resolved by
|
||||
# GlobalSearchView's own effective_content annotation...
|
||||
assert response.status_code == status.HTTP_200_OK
|
||||
[result] = [d for d in response.data["documents"] if d["id"] == root.id]
|
||||
assert result["content"] == "version-content"
|
||||
# ...with no per-instance fallback query
|
||||
assert _get_effective_content_fallback_queries(ctx) == []
|
||||
@@ -7,12 +7,9 @@ from typing import Any
|
||||
|
||||
from django.db.models import F
|
||||
from django.db.models import OuterRef
|
||||
from django.db.models import Prefetch
|
||||
from django.db.models import QuerySet
|
||||
from django.db.models import Subquery
|
||||
from django.db.models import Window
|
||||
from django.db.models.functions import Coalesce
|
||||
from django.db.models.functions import RowNumber
|
||||
|
||||
from documents.models import Document
|
||||
|
||||
@@ -49,68 +46,6 @@ def annotate_effective_content(documents: QuerySet[Document]) -> QuerySet[Docume
|
||||
)
|
||||
|
||||
|
||||
LATEST_VERSION_CONTENT_PREFETCH_ATTR = "_latest_version_content_prefetch"
|
||||
|
||||
|
||||
def latest_version_content_prefetch() -> Prefetch:
|
||||
"""
|
||||
A Prefetch for Document.versions scoped to just the newest version's
|
||||
content, for get_effective_content()'s fallback when no SQL annotation
|
||||
is present.
|
||||
|
||||
Deliberately not merged into a metadata-only "versions" prefetch (the one
|
||||
used for the serialized versions list): that one fetches every historical
|
||||
version of every document, and pulling full OCR content for versions
|
||||
nobody will read wastes DB transfer/memory at scale. This one is windowed
|
||||
down to a single row per root, then bounded by Prefetch's own IN-list to
|
||||
whatever page/result set it's attached to -- one cheap bulk query total,
|
||||
not one per document and not one per version.
|
||||
"""
|
||||
return Prefetch(
|
||||
"versions",
|
||||
queryset=(
|
||||
Document.objects.filter(
|
||||
root_document_id__isnull=False,
|
||||
deleted_at__isnull=True,
|
||||
)
|
||||
.annotate(
|
||||
rn=Window(
|
||||
RowNumber(),
|
||||
partition_by=F("root_document_id"),
|
||||
order_by=[
|
||||
F("version_index").desc(nulls_last=True),
|
||||
F("id").desc(),
|
||||
],
|
||||
),
|
||||
)
|
||||
.filter(rn=1)
|
||||
.only("id", "root_document_id", "content")
|
||||
),
|
||||
to_attr=LATEST_VERSION_CONTENT_PREFETCH_ATTR,
|
||||
)
|
||||
|
||||
|
||||
def has_prefetched_effective_content(document: Document) -> bool:
|
||||
"""
|
||||
True if document.get_effective_content() can answer without an extra
|
||||
per-instance query -- an SQL ``effective_content`` annotation, the lean
|
||||
latest_version_content_prefetch(), or the metadata-only "versions"
|
||||
prefetch is already present on the instance.
|
||||
|
||||
Callers that haven't set any of those up (e.g. views that build their
|
||||
own querysets independently of DocumentViewSet.get_queryset(), like
|
||||
TrashView or GlobalSearchView) intentionally don't pay for version-aware
|
||||
content resolution -- see DocumentSerializer.to_representation(), which
|
||||
uses this to decide whether to call get_effective_content() at all.
|
||||
"""
|
||||
if hasattr(document, "effective_content"):
|
||||
return True
|
||||
if getattr(document, LATEST_VERSION_CONTENT_PREFETCH_ATTR, None) is not None:
|
||||
return True
|
||||
prefetched_cache = getattr(document, "_prefetched_objects_cache", None)
|
||||
return isinstance(prefetched_cache, dict) and "versions" in prefetched_cache
|
||||
|
||||
|
||||
def sort_versions_newest_first(documents: list[Document]) -> list[Document]:
|
||||
"""
|
||||
Same sorting as versions_newest_first()
|
||||
|
||||
+28
-72
@@ -36,6 +36,7 @@ from django.db.migrations.recorder import MigrationRecorder
|
||||
from django.db.models import Avg
|
||||
from django.db.models import Case
|
||||
from django.db.models import Count
|
||||
from django.db.models import F
|
||||
from django.db.models import IntegerField
|
||||
from django.db.models import Max
|
||||
from django.db.models import Model
|
||||
@@ -136,14 +137,12 @@ from documents.filters import CustomFieldFilterSet
|
||||
from documents.filters import DocumentFilterSet
|
||||
from documents.filters import DocumentsOrderingFilter
|
||||
from documents.filters import DocumentTypeFilterSet
|
||||
from documents.filters import EffectiveContentFilter
|
||||
from documents.filters import PaperlessTaskFilterSet
|
||||
from documents.filters import PermittedObjectsFilter
|
||||
from documents.filters import ShareLinkBundleFilterSet
|
||||
from documents.filters import ShareLinkFilterSet
|
||||
from documents.filters import StoragePathFilterSet
|
||||
from documents.filters import TagFilterSet
|
||||
from documents.filters import TitleContentFilter
|
||||
from documents.mail import EmailAttachment
|
||||
from documents.mail import send_email
|
||||
from documents.matching import match_correspondents
|
||||
@@ -237,7 +236,6 @@ from documents.versioning import annotate_effective_content
|
||||
from documents.versioning import get_latest_version_for_root
|
||||
from documents.versioning import get_request_version_param
|
||||
from documents.versioning import get_root_document
|
||||
from documents.versioning import latest_version_content_prefetch
|
||||
from documents.versioning import resolve_requested_version_for_root
|
||||
from documents.versioning import versions_newest_first
|
||||
from paperless import version
|
||||
@@ -1085,49 +1083,12 @@ class DocumentViewSet(
|
||||
],
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def _content_filter_params(cls) -> tuple[str, ...]:
|
||||
"""
|
||||
Query params whose filtering needs effective_content evaluated in SQL
|
||||
against every candidate row -- see
|
||||
_needs_effective_content_annotation(). Derived rather than
|
||||
hand-maintained so a new content-filtering param counts automatically.
|
||||
"""
|
||||
params = [
|
||||
name
|
||||
for name, f in DocumentFilterSet.declared_filters.items()
|
||||
if isinstance(f, (TitleContentFilter, EffectiveContentFilter))
|
||||
]
|
||||
if "effective_content" in cls.search_fields:
|
||||
params.append(SearchFilter().search_param)
|
||||
return tuple(params)
|
||||
|
||||
def _needs_effective_content_annotation(self) -> bool:
|
||||
# effective_content is a per-row correlated subquery resolving each
|
||||
# document's latest version. Filtering *on* it forces the database to
|
||||
# evaluate it for every candidate row before reaching the LIMIT, which
|
||||
# the root_document_id self-join makes pathological on MariaDB
|
||||
# specifically once real candidate counts get large; otherwise the
|
||||
# "versions" prefetch + Document.get_effective_content() resolves only
|
||||
# the page that survives pagination. Every param here is deprecated in
|
||||
# favor of the Tantivy-backed search endpoint (see filters.py's
|
||||
# TitleContentFilter/EffectiveContentFilter docs), so pay that cost
|
||||
# only when one is actually used. Blank values don't count, matching
|
||||
# how those filters themselves no-op on them -- an empty `?search=`
|
||||
# applies no predicate.
|
||||
params = self.request.query_params
|
||||
return any(
|
||||
params.get(param, "").strip() for param in self._content_filter_params()
|
||||
)
|
||||
|
||||
def _needs_effective_content_prefetch(self) -> bool:
|
||||
# The prefetch spares get_effective_content() a per-instance fallback
|
||||
# query, but only earns itself when content can reach the response.
|
||||
# Mirror get_serializer() below: no `fields` param keeps every field.
|
||||
fields_param = self.request.query_params.get("fields", None)
|
||||
return fields_param is None or "content" in fields_param.split(",")
|
||||
|
||||
def get_queryset(self):
|
||||
latest_version_content = Subquery(
|
||||
versions_newest_first(
|
||||
Document.objects.filter(root_document=OuterRef("pk")),
|
||||
).values("content")[:1],
|
||||
)
|
||||
# A correlated subquery avoids the LEFT JOIN + Count() this used to
|
||||
# be, which forced a GROUP BY aggregate over every matching document
|
||||
# before the query could even be sorted or limited.
|
||||
@@ -1147,38 +1108,33 @@ class DocumentViewSet(
|
||||
# ObjectFilter.filter(). A blanket .distinct() here forces the
|
||||
# database to fully sort and dedupe every visible document before
|
||||
# it can apply LIMIT, which is disastrous at scale.
|
||||
prefetches = [
|
||||
Prefetch(
|
||||
"versions",
|
||||
queryset=Document.objects.only(
|
||||
"id",
|
||||
"added",
|
||||
"checksum",
|
||||
"version_label",
|
||||
"root_document_id",
|
||||
"version_index",
|
||||
),
|
||||
),
|
||||
"tags",
|
||||
Prefetch(
|
||||
"custom_fields",
|
||||
queryset=CustomFieldInstance.objects.select_related("field"),
|
||||
),
|
||||
# NotesSerializer nests the author, this avoids query per note
|
||||
Prefetch("notes", queryset=Note.objects.select_related("user")),
|
||||
]
|
||||
if self._needs_effective_content_prefetch():
|
||||
prefetches.append(latest_version_content_prefetch())
|
||||
queryset = (
|
||||
return (
|
||||
Document.objects.filter(root_document__isnull=True)
|
||||
.order_by("-created", "-id")
|
||||
.annotate(effective_content=Coalesce(latest_version_content, F("content")))
|
||||
.annotate(num_notes=Coalesce(note_count, 0))
|
||||
.select_related("correspondent", "storage_path", "document_type", "owner")
|
||||
.prefetch_related(*prefetches)
|
||||
.prefetch_related(
|
||||
Prefetch(
|
||||
"versions",
|
||||
queryset=Document.objects.only(
|
||||
"id",
|
||||
"added",
|
||||
"checksum",
|
||||
"version_label",
|
||||
"root_document_id",
|
||||
"version_index",
|
||||
),
|
||||
),
|
||||
"tags",
|
||||
Prefetch(
|
||||
"custom_fields",
|
||||
queryset=CustomFieldInstance.objects.select_related("field"),
|
||||
),
|
||||
# NotesSerializer nests the author, this avoids query per note
|
||||
Prefetch("notes", queryset=Note.objects.select_related("user")),
|
||||
)
|
||||
)
|
||||
if self._needs_effective_content_annotation():
|
||||
queryset = annotate_effective_content(queryset)
|
||||
return queryset
|
||||
|
||||
def get_serializer(self, *args, **kwargs):
|
||||
fields_param = self.request.query_params.get("fields", None)
|
||||
|
||||
Reference in New Issue
Block a user