mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2025-09-10 21:33:48 -05:00
Compare commits
4 Commits
5410074062
...
fded55dc70
Author | SHA1 | Date | |
---|---|---|---|
![]() |
fded55dc70 | ||
![]() |
20da51278e | ||
![]() |
293c84d871 | ||
![]() |
1fe8599266 |
File diff suppressed because it is too large
Load Diff
@@ -176,6 +176,7 @@
|
|||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col">
|
<div class="col">
|
||||||
<pngx-input-check i18n-title title="Show warning when closing saved views with unsaved changes" formControlName="savedViewsWarnOnUnsavedChange"></pngx-input-check>
|
<pngx-input-check i18n-title title="Show warning when closing saved views with unsaved changes" formControlName="savedViewsWarnOnUnsavedChange"></pngx-input-check>
|
||||||
|
<pngx-input-check i18n-title title="Show document counts in sidebar saved views" formControlName="sidebarViewsShowCount"></pngx-input-check>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
@@ -31,6 +31,7 @@ import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
|
|||||||
import { SafeHtmlPipe } from 'src/app/pipes/safehtml.pipe'
|
import { SafeHtmlPipe } from 'src/app/pipes/safehtml.pipe'
|
||||||
import { PermissionsService } from 'src/app/services/permissions.service'
|
import { PermissionsService } from 'src/app/services/permissions.service'
|
||||||
import { GroupService } from 'src/app/services/rest/group.service'
|
import { GroupService } from 'src/app/services/rest/group.service'
|
||||||
|
import { SavedViewService } from 'src/app/services/rest/saved-view.service'
|
||||||
import { UserService } from 'src/app/services/rest/user.service'
|
import { UserService } from 'src/app/services/rest/user.service'
|
||||||
import { SettingsService } from 'src/app/services/settings.service'
|
import { SettingsService } from 'src/app/services/settings.service'
|
||||||
import { SystemStatusService } from 'src/app/services/system-status.service'
|
import { SystemStatusService } from 'src/app/services/system-status.service'
|
||||||
@@ -72,6 +73,7 @@ describe('SettingsComponent', () => {
|
|||||||
let groupService: GroupService
|
let groupService: GroupService
|
||||||
let modalService: NgbModal
|
let modalService: NgbModal
|
||||||
let systemStatusService: SystemStatusService
|
let systemStatusService: SystemStatusService
|
||||||
|
let savedViewsService: SavedViewService
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
TestBed.configureTestingModule({
|
TestBed.configureTestingModule({
|
||||||
@@ -122,6 +124,7 @@ describe('SettingsComponent', () => {
|
|||||||
permissionsService = TestBed.inject(PermissionsService)
|
permissionsService = TestBed.inject(PermissionsService)
|
||||||
modalService = TestBed.inject(NgbModal)
|
modalService = TestBed.inject(NgbModal)
|
||||||
systemStatusService = TestBed.inject(SystemStatusService)
|
systemStatusService = TestBed.inject(SystemStatusService)
|
||||||
|
savedViewsService = TestBed.inject(SavedViewService)
|
||||||
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
|
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
|
||||||
jest
|
jest
|
||||||
.spyOn(permissionsService, 'currentUserHasObjectPermissions')
|
.spyOn(permissionsService, 'currentUserHasObjectPermissions')
|
||||||
@@ -212,7 +215,7 @@ describe('SettingsComponent', () => {
|
|||||||
expect(toastErrorSpy).toHaveBeenCalled()
|
expect(toastErrorSpy).toHaveBeenCalled()
|
||||||
expect(storeSpy).toHaveBeenCalled()
|
expect(storeSpy).toHaveBeenCalled()
|
||||||
expect(appearanceSettingsSpy).not.toHaveBeenCalled()
|
expect(appearanceSettingsSpy).not.toHaveBeenCalled()
|
||||||
expect(setSpy).toHaveBeenCalledTimes(29)
|
expect(setSpy).toHaveBeenCalledTimes(30)
|
||||||
|
|
||||||
// succeed
|
// succeed
|
||||||
storeSpy.mockReturnValueOnce(of(true))
|
storeSpy.mockReturnValueOnce(of(true))
|
||||||
@@ -345,4 +348,14 @@ describe('SettingsComponent', () => {
|
|||||||
component.reset()
|
component.reset()
|
||||||
expect(component.settingsForm.get('themeColor').value).toEqual('')
|
expect(component.settingsForm.get('themeColor').value).toEqual('')
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should trigger maybeRefreshDocumentCounts on settings save', () => {
|
||||||
|
completeSetup()
|
||||||
|
const maybeRefreshSpy = jest.spyOn(
|
||||||
|
savedViewsService,
|
||||||
|
'maybeRefreshDocumentCounts'
|
||||||
|
)
|
||||||
|
settingsService.settingsSaved.emit(true)
|
||||||
|
expect(maybeRefreshSpy).toHaveBeenCalled()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
@@ -49,6 +49,7 @@ import {
|
|||||||
PermissionsService,
|
PermissionsService,
|
||||||
} from 'src/app/services/permissions.service'
|
} from 'src/app/services/permissions.service'
|
||||||
import { GroupService } from 'src/app/services/rest/group.service'
|
import { GroupService } from 'src/app/services/rest/group.service'
|
||||||
|
import { SavedViewService } from 'src/app/services/rest/saved-view.service'
|
||||||
import { UserService } from 'src/app/services/rest/user.service'
|
import { UserService } from 'src/app/services/rest/user.service'
|
||||||
import {
|
import {
|
||||||
LanguageOption,
|
LanguageOption,
|
||||||
@@ -117,6 +118,7 @@ export class SettingsComponent
|
|||||||
permissionsService = inject(PermissionsService)
|
permissionsService = inject(PermissionsService)
|
||||||
private modalService = inject(NgbModal)
|
private modalService = inject(NgbModal)
|
||||||
private systemStatusService = inject(SystemStatusService)
|
private systemStatusService = inject(SystemStatusService)
|
||||||
|
private savedViewsService = inject(SavedViewService)
|
||||||
|
|
||||||
activeNavID: number
|
activeNavID: number
|
||||||
|
|
||||||
@@ -152,6 +154,7 @@ export class SettingsComponent
|
|||||||
notificationsConsumerSuppressOnDashboard: new FormControl(null),
|
notificationsConsumerSuppressOnDashboard: new FormControl(null),
|
||||||
|
|
||||||
savedViewsWarnOnUnsavedChange: new FormControl(null),
|
savedViewsWarnOnUnsavedChange: new FormControl(null),
|
||||||
|
sidebarViewsShowCount: new FormControl(null),
|
||||||
})
|
})
|
||||||
|
|
||||||
SettingsNavIDs = SettingsNavIDs
|
SettingsNavIDs = SettingsNavIDs
|
||||||
@@ -197,6 +200,7 @@ export class SettingsComponent
|
|||||||
super()
|
super()
|
||||||
this.settings.settingsSaved.subscribe(() => {
|
this.settings.settingsSaved.subscribe(() => {
|
||||||
if (!this.savePending) this.initialize()
|
if (!this.savePending) this.initialize()
|
||||||
|
this.savedViewsService.maybeRefreshDocumentCounts()
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -308,6 +312,9 @@ export class SettingsComponent
|
|||||||
savedViewsWarnOnUnsavedChange: this.settings.get(
|
savedViewsWarnOnUnsavedChange: this.settings.get(
|
||||||
SETTINGS_KEYS.SAVED_VIEWS_WARN_ON_UNSAVED_CHANGE
|
SETTINGS_KEYS.SAVED_VIEWS_WARN_ON_UNSAVED_CHANGE
|
||||||
),
|
),
|
||||||
|
sidebarViewsShowCount: this.settings.get(
|
||||||
|
SETTINGS_KEYS.SIDEBAR_VIEWS_SHOW_COUNT
|
||||||
|
),
|
||||||
defaultPermsOwner: this.settings.get(SETTINGS_KEYS.DEFAULT_PERMS_OWNER),
|
defaultPermsOwner: this.settings.get(SETTINGS_KEYS.DEFAULT_PERMS_OWNER),
|
||||||
defaultPermsViewUsers: this.settings.get(
|
defaultPermsViewUsers: this.settings.get(
|
||||||
SETTINGS_KEYS.DEFAULT_PERMS_VIEW_USERS
|
SETTINGS_KEYS.DEFAULT_PERMS_VIEW_USERS
|
||||||
@@ -485,6 +492,10 @@ export class SettingsComponent
|
|||||||
SETTINGS_KEYS.SAVED_VIEWS_WARN_ON_UNSAVED_CHANGE,
|
SETTINGS_KEYS.SAVED_VIEWS_WARN_ON_UNSAVED_CHANGE,
|
||||||
this.settingsForm.value.savedViewsWarnOnUnsavedChange
|
this.settingsForm.value.savedViewsWarnOnUnsavedChange
|
||||||
)
|
)
|
||||||
|
this.settings.set(
|
||||||
|
SETTINGS_KEYS.SIDEBAR_VIEWS_SHOW_COUNT,
|
||||||
|
this.settingsForm.value.sidebarViewsShowCount
|
||||||
|
)
|
||||||
this.settings.set(
|
this.settings.set(
|
||||||
SETTINGS_KEYS.DEFAULT_PERMS_OWNER,
|
SETTINGS_KEYS.DEFAULT_PERMS_OWNER,
|
||||||
this.settingsForm.value.defaultPermsOwner
|
this.settingsForm.value.defaultPermsOwner
|
||||||
|
@@ -112,7 +112,14 @@
|
|||||||
routerLinkActive="active" (click)="closeMenu()" [ngbPopover]="view.name"
|
routerLinkActive="active" (click)="closeMenu()" [ngbPopover]="view.name"
|
||||||
[disablePopover]="!slimSidebarEnabled" placement="end" container="body" triggers="mouseenter:mouseleave"
|
[disablePopover]="!slimSidebarEnabled" placement="end" container="body" triggers="mouseenter:mouseleave"
|
||||||
popoverClass="popover-slim">
|
popoverClass="popover-slim">
|
||||||
<i-bs class="me-1" name="funnel"></i-bs><span> {{view.name}}</span>
|
<i-bs class="me-1" name="funnel"></i-bs><span> {{view.name}}
|
||||||
|
@if (showSidebarCounts && !slimSidebarEnabled) {
|
||||||
|
<span><span class="badge bg-info text-dark ms-2 d-inline">{{ savedViewService.getDocumentCount(view) }}</span></span>
|
||||||
|
}
|
||||||
|
</span>
|
||||||
|
@if (showSidebarCounts && slimSidebarEnabled) {
|
||||||
|
<span class="badge bg-info text-dark position-absolute top-0 end-0 d-none d-md-block">{{ savedViewService.getDocumentCount(view) }}</span>
|
||||||
|
}
|
||||||
</a>
|
</a>
|
||||||
@if (settingsService.organizingSidebarSavedViews) {
|
@if (settingsService.organizingSidebarSavedViews) {
|
||||||
<div class="position-absolute end-0 top-0 px-3 py-2" [class.me-n3]="slimSidebarEnabled" cdkDragHandle>
|
<div class="position-absolute end-0 top-0 px-3 py-2" [class.me-n3]="slimSidebarEnabled" cdkDragHandle>
|
||||||
|
@@ -92,6 +92,7 @@ describe('AppFrameComponent', () => {
|
|||||||
let router: Router
|
let router: Router
|
||||||
let savedViewSpy
|
let savedViewSpy
|
||||||
let modalService: NgbModal
|
let modalService: NgbModal
|
||||||
|
let maybeRefreshSpy
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(async () => {
|
||||||
TestBed.configureTestingModule({
|
TestBed.configureTestingModule({
|
||||||
@@ -113,7 +114,11 @@ describe('AppFrameComponent', () => {
|
|||||||
{
|
{
|
||||||
provide: SavedViewService,
|
provide: SavedViewService,
|
||||||
useValue: {
|
useValue: {
|
||||||
reload: () => {},
|
reload: (fn: any) => {
|
||||||
|
if (fn) {
|
||||||
|
fn()
|
||||||
|
}
|
||||||
|
},
|
||||||
listAll: () =>
|
listAll: () =>
|
||||||
of({
|
of({
|
||||||
all: [saved_views.map((v) => v.id)],
|
all: [saved_views.map((v) => v.id)],
|
||||||
@@ -121,6 +126,8 @@ describe('AppFrameComponent', () => {
|
|||||||
results: saved_views,
|
results: saved_views,
|
||||||
}),
|
}),
|
||||||
sidebarViews: saved_views.filter((v) => v.show_in_sidebar),
|
sidebarViews: saved_views.filter((v) => v.show_in_sidebar),
|
||||||
|
getDocumentCount: (view: SavedView) => 5,
|
||||||
|
maybeRefreshDocumentCounts: () => {},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
PermissionsService,
|
PermissionsService,
|
||||||
@@ -169,6 +176,7 @@ describe('AppFrameComponent', () => {
|
|||||||
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
|
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
|
||||||
|
|
||||||
savedViewSpy = jest.spyOn(savedViewService, 'reload')
|
savedViewSpy = jest.spyOn(savedViewService, 'reload')
|
||||||
|
maybeRefreshSpy = jest.spyOn(savedViewService, 'maybeRefreshDocumentCounts')
|
||||||
|
|
||||||
fixture = TestBed.createComponent(AppFrameComponent)
|
fixture = TestBed.createComponent(AppFrameComponent)
|
||||||
component = fixture.componentInstance
|
component = fixture.componentInstance
|
||||||
@@ -359,4 +367,8 @@ describe('AppFrameComponent', () => {
|
|||||||
expect(toastErrorSpy).toHaveBeenCalledTimes(2)
|
expect(toastErrorSpy).toHaveBeenCalledTimes(2)
|
||||||
expect(toastInfoSpy).toHaveBeenCalledTimes(3)
|
expect(toastInfoSpy).toHaveBeenCalledTimes(3)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should call maybeRefreshDocumentCounts after saved views reload', () => {
|
||||||
|
expect(maybeRefreshSpy).toHaveBeenCalled()
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
@@ -102,7 +102,9 @@ export class AppFrameComponent
|
|||||||
PermissionType.SavedView
|
PermissionType.SavedView
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
this.savedViewService.reload()
|
this.savedViewService.reload(() => {
|
||||||
|
this.savedViewService.maybeRefreshDocumentCounts()
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -283,4 +285,8 @@ export class AppFrameComponent
|
|||||||
onLogout() {
|
onLogout() {
|
||||||
this.openDocumentsService.closeAll()
|
this.openDocumentsService.closeAll()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
get showSidebarCounts(): boolean {
|
||||||
|
return this.settingsService.get(SETTINGS_KEYS.SIDEBAR_VIEWS_SHOW_COUNT)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@@ -1,6 +1,7 @@
|
|||||||
<pngx-widget-frame
|
<pngx-widget-frame
|
||||||
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }"
|
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }"
|
||||||
[title]="savedView.name"
|
[title]="savedView.name"
|
||||||
|
[badge]="count"
|
||||||
[loading]="loading"
|
[loading]="loading"
|
||||||
[draggable]="savedView"
|
[draggable]="savedView"
|
||||||
>
|
>
|
||||||
|
@@ -118,6 +118,8 @@ export class SavedViewWidgetComponent
|
|||||||
|
|
||||||
displayFields: DisplayField[] = DEFAULT_DASHBOARD_DISPLAY_FIELDS
|
displayFields: DisplayField[] = DEFAULT_DASHBOARD_DISPLAY_FIELDS
|
||||||
|
|
||||||
|
count: number
|
||||||
|
|
||||||
ngOnInit(): void {
|
ngOnInit(): void {
|
||||||
this.reload()
|
this.reload()
|
||||||
this.displayMode = this.savedView.display_mode ?? DisplayMode.TABLE
|
this.displayMode = this.savedView.display_mode ?? DisplayMode.TABLE
|
||||||
@@ -178,6 +180,7 @@ export class SavedViewWidgetComponent
|
|||||||
tap((result) => {
|
tap((result) => {
|
||||||
this.show = true
|
this.show = true
|
||||||
this.documents = result.results
|
this.documents = result.results
|
||||||
|
this.count = result.count
|
||||||
}),
|
}),
|
||||||
delay(500)
|
delay(500)
|
||||||
)
|
)
|
||||||
|
@@ -2,13 +2,16 @@
|
|||||||
<div class="card shadow-sm bg-light fade" [class.show]="show" cdkDrag [cdkDragDisabled]="!draggable" cdkDragPreviewContainer="parent">
|
<div class="card shadow-sm bg-light fade" [class.show]="show" cdkDrag [cdkDragDisabled]="!draggable" cdkDragPreviewContainer="parent">
|
||||||
<div class="card-header">
|
<div class="card-header">
|
||||||
<div class="d-flex justify-content-between align-items-center">
|
<div class="d-flex justify-content-between align-items-center">
|
||||||
<div class="d-flex">
|
<div class="d-flex align-items-center">
|
||||||
@if (draggable) {
|
@if (draggable) {
|
||||||
<div class="ms-n2 me-1" cdkDragHandle>
|
<div class="ms-n2 me-1" cdkDragHandle>
|
||||||
<i-bs name="grip-vertical"></i-bs>
|
<i-bs name="grip-vertical"></i-bs>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
<h6 class="card-title mb-0">{{title}}</h6>
|
<h6 class="card-title mb-0">{{title}}</h6>
|
||||||
|
@if (badge) {
|
||||||
|
<span class="badge bg-info text-dark ms-2">{{badge}}</span>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
@if (loading) {
|
@if (loading) {
|
||||||
<div class="spinner-border spinner-border-sm fw-normal ms-2 me-auto" role="status"></div>
|
<div class="spinner-border spinner-border-sm fw-normal ms-2 me-auto" role="status"></div>
|
||||||
|
@@ -30,6 +30,9 @@ export class WidgetFrameComponent
|
|||||||
@Input()
|
@Input()
|
||||||
cardless: boolean = false
|
cardless: boolean = false
|
||||||
|
|
||||||
|
@Input()
|
||||||
|
badge: string
|
||||||
|
|
||||||
ngAfterViewInit(): void {
|
ngAfterViewInit(): void {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
this.show = true
|
this.show = true
|
||||||
|
@@ -73,6 +73,7 @@ import { CorrespondentService } from 'src/app/services/rest/correspondent.servic
|
|||||||
import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
|
import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
|
||||||
import { DocumentTypeService } from 'src/app/services/rest/document-type.service'
|
import { DocumentTypeService } from 'src/app/services/rest/document-type.service'
|
||||||
import { DocumentService } from 'src/app/services/rest/document.service'
|
import { DocumentService } from 'src/app/services/rest/document.service'
|
||||||
|
import { SavedViewService } from 'src/app/services/rest/saved-view.service'
|
||||||
import { StoragePathService } from 'src/app/services/rest/storage-path.service'
|
import { StoragePathService } from 'src/app/services/rest/storage-path.service'
|
||||||
import { UserService } from 'src/app/services/rest/user.service'
|
import { UserService } from 'src/app/services/rest/user.service'
|
||||||
import { SettingsService } from 'src/app/services/settings.service'
|
import { SettingsService } from 'src/app/services/settings.service'
|
||||||
@@ -195,6 +196,7 @@ export class DocumentDetailComponent
|
|||||||
private hotKeyService = inject(HotKeyService)
|
private hotKeyService = inject(HotKeyService)
|
||||||
private componentRouterService = inject(ComponentRouterService)
|
private componentRouterService = inject(ComponentRouterService)
|
||||||
private deviceDetectorService = inject(DeviceDetectorService)
|
private deviceDetectorService = inject(DeviceDetectorService)
|
||||||
|
private savedViewService = inject(SavedViewService)
|
||||||
|
|
||||||
@ViewChild('inputTitle')
|
@ViewChild('inputTitle')
|
||||||
titleInput: TextComponent
|
titleInput: TextComponent
|
||||||
@@ -841,6 +843,7 @@ export class DocumentDetailComponent
|
|||||||
} else {
|
} else {
|
||||||
this.openDocumentService.refreshDocument(this.documentId)
|
this.openDocumentService.refreshDocument(this.documentId)
|
||||||
}
|
}
|
||||||
|
this.savedViewService.maybeRefreshDocumentCounts()
|
||||||
},
|
},
|
||||||
error: (error) => {
|
error: (error) => {
|
||||||
this.networkActive = false
|
this.networkActive = false
|
||||||
@@ -1188,6 +1191,7 @@ export class DocumentDetailComponent
|
|||||||
notesUpdated(notes: DocumentNote[]) {
|
notesUpdated(notes: DocumentNote[]) {
|
||||||
this.document.notes = notes
|
this.document.notes = notes
|
||||||
this.openDocumentService.refreshDocument(this.documentId)
|
this.openDocumentService.refreshDocument(this.documentId)
|
||||||
|
this.savedViewService.maybeRefreshDocumentCounts()
|
||||||
}
|
}
|
||||||
|
|
||||||
get userIsOwner(): boolean {
|
get userIsOwner(): boolean {
|
||||||
|
@@ -32,6 +32,7 @@ import {
|
|||||||
DocumentService,
|
DocumentService,
|
||||||
SelectionDataItem,
|
SelectionDataItem,
|
||||||
} from 'src/app/services/rest/document.service'
|
} from 'src/app/services/rest/document.service'
|
||||||
|
import { SavedViewService } from 'src/app/services/rest/saved-view.service'
|
||||||
import { StoragePathService } from 'src/app/services/rest/storage-path.service'
|
import { StoragePathService } from 'src/app/services/rest/storage-path.service'
|
||||||
import { TagService } from 'src/app/services/rest/tag.service'
|
import { TagService } from 'src/app/services/rest/tag.service'
|
||||||
import { SettingsService } from 'src/app/services/settings.service'
|
import { SettingsService } from 'src/app/services/settings.service'
|
||||||
@@ -83,6 +84,7 @@ export class BulkEditorComponent
|
|||||||
private storagePathService = inject(StoragePathService)
|
private storagePathService = inject(StoragePathService)
|
||||||
private customFieldService = inject(CustomFieldsService)
|
private customFieldService = inject(CustomFieldsService)
|
||||||
private permissionService = inject(PermissionsService)
|
private permissionService = inject(PermissionsService)
|
||||||
|
private savedViewService = inject(SavedViewService)
|
||||||
|
|
||||||
tagSelectionModel = new FilterableDropdownSelectionModel(true)
|
tagSelectionModel = new FilterableDropdownSelectionModel(true)
|
||||||
correspondentSelectionModel = new FilterableDropdownSelectionModel()
|
correspondentSelectionModel = new FilterableDropdownSelectionModel()
|
||||||
@@ -270,6 +272,7 @@ export class BulkEditorComponent
|
|||||||
this.list.selected.forEach((id) => {
|
this.list.selected.forEach((id) => {
|
||||||
this.openDocumentService.refreshDocument(id)
|
this.openDocumentService.refreshDocument(id)
|
||||||
})
|
})
|
||||||
|
this.savedViewService.maybeRefreshDocumentCounts()
|
||||||
if (modal) {
|
if (modal) {
|
||||||
modal.close()
|
modal.close()
|
||||||
}
|
}
|
||||||
|
@@ -58,6 +58,8 @@ export const SETTINGS_KEYS = {
|
|||||||
'general-settings:saved-views:dashboard-views-sort-order',
|
'general-settings:saved-views:dashboard-views-sort-order',
|
||||||
SIDEBAR_VIEWS_SORT_ORDER:
|
SIDEBAR_VIEWS_SORT_ORDER:
|
||||||
'general-settings:saved-views:sidebar-views-sort-order',
|
'general-settings:saved-views:sidebar-views-sort-order',
|
||||||
|
SIDEBAR_VIEWS_SHOW_COUNT:
|
||||||
|
'general-settings:saved-views:sidebar-views-show-count',
|
||||||
TOUR_COMPLETE: 'general-settings:tour-complete',
|
TOUR_COMPLETE: 'general-settings:tour-complete',
|
||||||
DEFAULT_PERMS_OWNER: 'general-settings:permissions:default-owner',
|
DEFAULT_PERMS_OWNER: 'general-settings:permissions:default-owner',
|
||||||
DEFAULT_PERMS_VIEW_USERS: 'general-settings:permissions:default-view-users',
|
DEFAULT_PERMS_VIEW_USERS: 'general-settings:permissions:default-view-users',
|
||||||
@@ -227,6 +229,11 @@ export const SETTINGS: UiSetting[] = [
|
|||||||
type: 'array',
|
type: 'array',
|
||||||
default: [],
|
default: [],
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
key: SETTINGS_KEYS.SIDEBAR_VIEWS_SHOW_COUNT,
|
||||||
|
type: 'boolean',
|
||||||
|
default: true,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
key: SETTINGS_KEYS.APP_LOGO,
|
key: SETTINGS_KEYS.APP_LOGO,
|
||||||
type: 'string',
|
type: 'string',
|
||||||
|
@@ -17,7 +17,7 @@ const saved_views = [
|
|||||||
id: 1,
|
id: 1,
|
||||||
show_on_dashboard: true,
|
show_on_dashboard: true,
|
||||||
show_in_sidebar: true,
|
show_in_sidebar: true,
|
||||||
sort_field: 'name',
|
sort_field: 'title',
|
||||||
sort_reverse: true,
|
sort_reverse: true,
|
||||||
filter_rules: [],
|
filter_rules: [],
|
||||||
},
|
},
|
||||||
@@ -26,7 +26,7 @@ const saved_views = [
|
|||||||
id: 2,
|
id: 2,
|
||||||
show_on_dashboard: true,
|
show_on_dashboard: true,
|
||||||
show_in_sidebar: true,
|
show_in_sidebar: true,
|
||||||
sort_field: 'name',
|
sort_field: 'created',
|
||||||
sort_reverse: true,
|
sort_reverse: true,
|
||||||
filter_rules: [],
|
filter_rules: [],
|
||||||
},
|
},
|
||||||
@@ -35,7 +35,7 @@ const saved_views = [
|
|||||||
id: 3,
|
id: 3,
|
||||||
show_on_dashboard: true,
|
show_on_dashboard: true,
|
||||||
show_in_sidebar: true,
|
show_in_sidebar: true,
|
||||||
sort_field: 'name',
|
sort_field: 'added',
|
||||||
sort_reverse: true,
|
sort_reverse: true,
|
||||||
filter_rules: [],
|
filter_rules: [],
|
||||||
},
|
},
|
||||||
@@ -44,7 +44,7 @@ const saved_views = [
|
|||||||
id: 4,
|
id: 4,
|
||||||
show_on_dashboard: false,
|
show_on_dashboard: false,
|
||||||
show_in_sidebar: false,
|
show_in_sidebar: false,
|
||||||
sort_field: 'name',
|
sort_field: 'owner',
|
||||||
sort_reverse: true,
|
sort_reverse: true,
|
||||||
filter_rules: [],
|
filter_rules: [],
|
||||||
},
|
},
|
||||||
@@ -222,6 +222,43 @@ describe(`Additional service tests for SavedViewService`, () => {
|
|||||||
})
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
|
it('should accept a callback for reload', () => {
|
||||||
|
const reloadSpy = jest.fn()
|
||||||
|
service.reload(reloadSpy)
|
||||||
|
const req = httpTestingController.expectOne(
|
||||||
|
`${environment.apiBaseUrl}${endpoint}/?page=1&page_size=100000`
|
||||||
|
)
|
||||||
|
req.flush({
|
||||||
|
results: saved_views,
|
||||||
|
})
|
||||||
|
expect(reloadSpy).toHaveBeenCalled()
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should support getting document counts for views', () => {
|
||||||
|
service.maybeRefreshDocumentCounts(saved_views)
|
||||||
|
saved_views.forEach((saved_view) => {
|
||||||
|
const req = httpTestingController.expectOne(
|
||||||
|
`${environment.apiBaseUrl}documents/?page=1&page_size=1&ordering=-${saved_view.sort_field}&fields=id&truncate_content=true`
|
||||||
|
)
|
||||||
|
req.flush({
|
||||||
|
all: [],
|
||||||
|
count: 1,
|
||||||
|
results: [{ id: 1 }],
|
||||||
|
})
|
||||||
|
})
|
||||||
|
expect(service.getDocumentCount(saved_views[0])).toEqual(1)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('should not refresh document counts if setting is disabled', () => {
|
||||||
|
jest.spyOn(settingsService, 'get').mockImplementation((key) => {
|
||||||
|
if (key === SETTINGS_KEYS.SIDEBAR_VIEWS_SHOW_COUNT) return false
|
||||||
|
})
|
||||||
|
service.maybeRefreshDocumentCounts(saved_views)
|
||||||
|
httpTestingController.expectNone(
|
||||||
|
`${environment.apiBaseUrl}documents/?page=1&page_size=1&ordering=-${saved_views[0].sort_field}&fields=id&truncate_content=true`
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
// Dont need to setup again
|
// Dont need to setup again
|
||||||
|
|
||||||
|
@@ -1,12 +1,13 @@
|
|||||||
import { HttpClient } from '@angular/common/http'
|
import { HttpClient } from '@angular/common/http'
|
||||||
import { inject, Injectable } from '@angular/core'
|
import { inject, Injectable } from '@angular/core'
|
||||||
import { combineLatest, Observable } from 'rxjs'
|
import { combineLatest, Observable, Subject } from 'rxjs'
|
||||||
import { tap } from 'rxjs/operators'
|
import { takeUntil, tap } from 'rxjs/operators'
|
||||||
import { Results } from 'src/app/data/results'
|
import { Results } from 'src/app/data/results'
|
||||||
import { SavedView } from 'src/app/data/saved-view'
|
import { SavedView } from 'src/app/data/saved-view'
|
||||||
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
||||||
import { SettingsService } from '../settings.service'
|
import { SettingsService } from '../settings.service'
|
||||||
import { AbstractPaperlessService } from './abstract-paperless-service'
|
import { AbstractPaperlessService } from './abstract-paperless-service'
|
||||||
|
import { DocumentService } from './document.service'
|
||||||
|
|
||||||
@Injectable({
|
@Injectable({
|
||||||
providedIn: 'root',
|
providedIn: 'root',
|
||||||
@@ -14,9 +15,12 @@ import { AbstractPaperlessService } from './abstract-paperless-service'
|
|||||||
export class SavedViewService extends AbstractPaperlessService<SavedView> {
|
export class SavedViewService extends AbstractPaperlessService<SavedView> {
|
||||||
protected http: HttpClient
|
protected http: HttpClient
|
||||||
private settingsService = inject(SettingsService)
|
private settingsService = inject(SettingsService)
|
||||||
|
private documentService = inject(DocumentService)
|
||||||
|
|
||||||
public loading: boolean = true
|
public loading: boolean = true
|
||||||
private savedViews: SavedView[] = []
|
private savedViews: SavedView[] = []
|
||||||
|
private savedViewDocumentCounts: Map<number, number> = new Map()
|
||||||
|
private unsubscribeNotifier: Subject<void> = new Subject<void>()
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
super()
|
super()
|
||||||
@@ -46,8 +50,16 @@ export class SavedViewService extends AbstractPaperlessService<SavedView> {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
public reload() {
|
public reload(callback: any = null) {
|
||||||
this.listAll().subscribe()
|
this.listAll()
|
||||||
|
.pipe(
|
||||||
|
tap((r) => {
|
||||||
|
if (callback) {
|
||||||
|
callback(r)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.subscribe()
|
||||||
}
|
}
|
||||||
|
|
||||||
get allViews() {
|
get allViews() {
|
||||||
@@ -110,4 +122,30 @@ export class SavedViewService extends AbstractPaperlessService<SavedView> {
|
|||||||
delete(o: SavedView) {
|
delete(o: SavedView) {
|
||||||
return super.delete(o).pipe(tap(() => this.reload()))
|
return super.delete(o).pipe(tap(() => this.reload()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public maybeRefreshDocumentCounts(views: SavedView[] = this.sidebarViews) {
|
||||||
|
if (!this.settingsService.get(SETTINGS_KEYS.SIDEBAR_VIEWS_SHOW_COUNT)) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.unsubscribeNotifier.next() // clear previous subscriptions
|
||||||
|
views.forEach((view) => {
|
||||||
|
this.documentService
|
||||||
|
.listFiltered(
|
||||||
|
1,
|
||||||
|
1,
|
||||||
|
view.sort_field,
|
||||||
|
view.sort_reverse,
|
||||||
|
view.filter_rules,
|
||||||
|
{ fields: 'id', truncate_content: true }
|
||||||
|
)
|
||||||
|
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||||
|
.subscribe((results: Results<Document>) => {
|
||||||
|
this.savedViewDocumentCounts.set(view.id, results.count)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
public getDocumentCount(view: SavedView): number {
|
||||||
|
return this.savedViewDocumentCounts.get(view.id)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@@ -2,10 +2,12 @@ from __future__ import annotations
|
|||||||
|
|
||||||
import logging
|
import logging
|
||||||
import math
|
import math
|
||||||
|
import re
|
||||||
from collections import Counter
|
from collections import Counter
|
||||||
from contextlib import contextmanager
|
from contextlib import contextmanager
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from datetime import time
|
from datetime import time
|
||||||
|
from datetime import timedelta
|
||||||
from datetime import timezone
|
from datetime import timezone
|
||||||
from shutil import rmtree
|
from shutil import rmtree
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
@@ -13,6 +15,8 @@ from typing import Literal
|
|||||||
|
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django.utils import timezone as django_timezone
|
from django.utils import timezone as django_timezone
|
||||||
|
from django.utils.timezone import get_current_timezone
|
||||||
|
from django.utils.timezone import now
|
||||||
from guardian.shortcuts import get_users_with_perms
|
from guardian.shortcuts import get_users_with_perms
|
||||||
from whoosh import classify
|
from whoosh import classify
|
||||||
from whoosh import highlight
|
from whoosh import highlight
|
||||||
@@ -344,6 +348,7 @@ class LocalDateParser(English):
|
|||||||
class DelayedFullTextQuery(DelayedQuery):
|
class DelayedFullTextQuery(DelayedQuery):
|
||||||
def _get_query(self) -> tuple:
|
def _get_query(self) -> tuple:
|
||||||
q_str = self.query_params["query"]
|
q_str = self.query_params["query"]
|
||||||
|
q_str = rewrite_natural_date_keywords(q_str)
|
||||||
qp = MultifieldParser(
|
qp = MultifieldParser(
|
||||||
[
|
[
|
||||||
"content",
|
"content",
|
||||||
@@ -450,3 +455,37 @@ def get_permissions_criterias(user: User | None = None) -> list:
|
|||||||
query.Term("viewer_id", str(user.id)),
|
query.Term("viewer_id", str(user.id)),
|
||||||
)
|
)
|
||||||
return user_criterias
|
return user_criterias
|
||||||
|
|
||||||
|
|
||||||
|
def rewrite_natural_date_keywords(query_string: str) -> str:
|
||||||
|
"""
|
||||||
|
Rewrites natural date keywords (e.g. added:today or added:"yesterday") to UTC range syntax for Whoosh.
|
||||||
|
"""
|
||||||
|
|
||||||
|
tz = get_current_timezone()
|
||||||
|
local_now = now().astimezone(tz)
|
||||||
|
|
||||||
|
today = local_now.date()
|
||||||
|
yesterday = today - timedelta(days=1)
|
||||||
|
|
||||||
|
ranges = {
|
||||||
|
"today": (
|
||||||
|
datetime.combine(today, time.min, tzinfo=tz),
|
||||||
|
datetime.combine(today, time.max, tzinfo=tz),
|
||||||
|
),
|
||||||
|
"yesterday": (
|
||||||
|
datetime.combine(yesterday, time.min, tzinfo=tz),
|
||||||
|
datetime.combine(yesterday, time.max, tzinfo=tz),
|
||||||
|
),
|
||||||
|
}
|
||||||
|
|
||||||
|
pattern = r"(\b(?:added|created))\s*:\s*[\"']?(today|yesterday)[\"']?"
|
||||||
|
|
||||||
|
def repl(m):
|
||||||
|
field, keyword = m.group(1), m.group(2)
|
||||||
|
start, end = ranges[keyword]
|
||||||
|
start_str = start.astimezone(timezone.utc).strftime("%Y%m%d%H%M%S")
|
||||||
|
end_str = end.astimezone(timezone.utc).strftime("%Y%m%d%H%M%S")
|
||||||
|
return f"{field}:[{start_str} TO {end_str}]"
|
||||||
|
|
||||||
|
return re.sub(pattern, repl, query_string)
|
||||||
|
@@ -1,6 +1,11 @@
|
|||||||
|
from datetime import datetime
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
|
|
||||||
|
from django.contrib.auth.models import User
|
||||||
from django.test import TestCase
|
from django.test import TestCase
|
||||||
|
from django.test import override_settings
|
||||||
|
from django.utils.timezone import get_current_timezone
|
||||||
|
from django.utils.timezone import timezone
|
||||||
|
|
||||||
from documents import index
|
from documents import index
|
||||||
from documents.models import Document
|
from documents.models import Document
|
||||||
@@ -90,3 +95,35 @@ class TestAutoComplete(DirectoriesMixin, TestCase):
|
|||||||
_, kwargs = mocked_update_doc.call_args
|
_, kwargs = mocked_update_doc.call_args
|
||||||
|
|
||||||
self.assertIsNone(kwargs["asn"])
|
self.assertIsNone(kwargs["asn"])
|
||||||
|
|
||||||
|
@override_settings(TIME_ZONE="Pacific/Auckland")
|
||||||
|
def test_added_today_respects_local_timezone_boundary(self):
|
||||||
|
tz = get_current_timezone()
|
||||||
|
fixed_now = datetime(2025, 7, 20, 15, 0, 0, tzinfo=tz)
|
||||||
|
|
||||||
|
# Fake a time near the local boundary (1 AM NZT = 13:00 UTC on previous UTC day)
|
||||||
|
local_dt = datetime(2025, 7, 20, 1, 0, 0).replace(tzinfo=tz)
|
||||||
|
utc_dt = local_dt.astimezone(timezone.utc)
|
||||||
|
|
||||||
|
doc = Document.objects.create(
|
||||||
|
title="Time zone",
|
||||||
|
content="Testing added:today",
|
||||||
|
checksum="edgecase123",
|
||||||
|
added=utc_dt,
|
||||||
|
)
|
||||||
|
|
||||||
|
with index.open_index_writer() as writer:
|
||||||
|
index.update_document(writer, doc)
|
||||||
|
|
||||||
|
superuser = User.objects.create_superuser(username="testuser")
|
||||||
|
self.client.force_login(superuser)
|
||||||
|
|
||||||
|
with mock.patch("documents.index.now", return_value=fixed_now):
|
||||||
|
response = self.client.get("/api/documents/?query=added:today")
|
||||||
|
results = response.json()["results"]
|
||||||
|
self.assertEqual(len(results), 1)
|
||||||
|
self.assertEqual(results[0]["id"], doc.id)
|
||||||
|
|
||||||
|
response = self.client.get("/api/documents/?query=added:yesterday")
|
||||||
|
results = response.json()["results"]
|
||||||
|
self.assertEqual(len(results), 0)
|
||||||
|
@@ -1095,7 +1095,14 @@ class DocumentViewSet(
|
|||||||
|
|
||||||
@extend_schema_view(
|
@extend_schema_view(
|
||||||
list=extend_schema(
|
list=extend_schema(
|
||||||
|
description="Document views including search",
|
||||||
parameters=[
|
parameters=[
|
||||||
|
OpenApiParameter(
|
||||||
|
name="query",
|
||||||
|
type=OpenApiTypes.STR,
|
||||||
|
location=OpenApiParameter.QUERY,
|
||||||
|
description="Advanced search query string",
|
||||||
|
),
|
||||||
OpenApiParameter(
|
OpenApiParameter(
|
||||||
name="full_perms",
|
name="full_perms",
|
||||||
type=OpenApiTypes.BOOL,
|
type=OpenApiTypes.BOOL,
|
||||||
|
Reference in New Issue
Block a user