Merge branch 'dev' into feature-ai

This commit is contained in:
shamoon
2025-08-01 23:51:50 -04:00
committed by GitHub
55 changed files with 4049 additions and 2460 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -11,17 +11,17 @@
},
"private": true,
"dependencies": {
"@angular/cdk": "^20.0.4",
"@angular/common": "~20.0.6",
"@angular/compiler": "~20.0.6",
"@angular/core": "~20.0.6",
"@angular/forms": "~20.0.6",
"@angular/localize": "~20.0.6",
"@angular/platform-browser": "~20.0.6",
"@angular/platform-browser-dynamic": "~20.0.6",
"@angular/router": "~20.0.6",
"@angular/cdk": "^20.1.4",
"@angular/common": "~20.1.4",
"@angular/compiler": "~20.1.4",
"@angular/core": "~20.1.4",
"@angular/forms": "~20.1.4",
"@angular/localize": "~20.1.4",
"@angular/platform-browser": "~20.1.4",
"@angular/platform-browser-dynamic": "~20.1.4",
"@angular/router": "~20.1.4",
"@ng-bootstrap/ng-bootstrap": "^19.0.1",
"@ng-select/ng-select": "^15.1.3",
"@ng-select/ng-select": "^20.0.1",
"@ngneat/dirty-check-forms": "^3.0.3",
"@popperjs/core": "^2.11.8",
"bootstrap": "^5.3.7",
@@ -32,7 +32,7 @@
"ngx-color": "^10.0.0",
"ngx-cookie-service": "^20.0.1",
"ngx-device-detector": "^10.0.2",
"ngx-ui-tour-ng-bootstrap": "^17.0.0",
"ngx-ui-tour-ng-bootstrap": "^17.0.1",
"rxjs": "^7.8.2",
"tslib": "^2.8.1",
"utif": "^3.1.0",
@@ -42,33 +42,33 @@
"devDependencies": {
"@angular-builders/custom-webpack": "^20.0.0",
"@angular-builders/jest": "^20.0.0",
"@angular-devkit/core": "^20.0.4",
"@angular-devkit/schematics": "^20.0.4",
"@angular-devkit/core": "^20.1.4",
"@angular-devkit/schematics": "^20.1.4",
"@angular-eslint/builder": "20.1.1",
"@angular-eslint/eslint-plugin": "20.1.1",
"@angular-eslint/eslint-plugin-template": "20.1.1",
"@angular-eslint/schematics": "20.1.1",
"@angular-eslint/template-parser": "20.1.1",
"@angular/build": "^20.0.4",
"@angular/cli": "~20.0.4",
"@angular/compiler-cli": "~20.0.6",
"@angular/build": "^20.1.4",
"@angular/cli": "~20.1.4",
"@angular/compiler-cli": "~20.1.4",
"@codecov/webpack-plugin": "^1.9.1",
"@playwright/test": "^1.53.2",
"@types/jest": "^29.5.14",
"@types/node": "^24.0.10",
"@typescript-eslint/eslint-plugin": "^8.35.1",
"@typescript-eslint/parser": "^8.35.1",
"@typescript-eslint/utils": "^8.35.1",
"eslint": "^9.30.1",
"jest": "29.7.0",
"jest-environment-jsdom": "^29.7.0",
"@playwright/test": "^1.54.2",
"@types/jest": "^30.0.0",
"@types/node": "^24.1.0",
"@typescript-eslint/eslint-plugin": "^8.38.0",
"@typescript-eslint/parser": "^8.38.0",
"@typescript-eslint/utils": "^8.38.0",
"eslint": "^9.32.0",
"jest": "30.0.5",
"jest-environment-jsdom": "^30.0.5",
"jest-junit": "^16.0.0",
"jest-preset-angular": "^14.5.5",
"jest-preset-angular": "^15.0.0",
"jest-websocket-mock": "^2.5.0",
"prettier-plugin-organize-imports": "^4.1.0",
"prettier-plugin-organize-imports": "^4.2.0",
"ts-node": "~10.9.1",
"typescript": "^5.8.3",
"webpack": "^5.99.9"
"webpack": "^5.101.0"
},
"pnpm": {
"onlyBuiltDependencies": [

5202
src-ui/pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,12 +1,16 @@
import '@angular/localize/init'
import { jest } from '@jest/globals'
import { setupZoneTestEnv } from 'jest-preset-angular/setup-env/zone'
import { TextDecoder, TextEncoder } from 'util'
import { TextDecoder, TextEncoder } from 'node:util'
if (process.env.NODE_ENV === 'test') {
setupZoneTestEnv()
}
global.TextEncoder = TextEncoder
global.TextDecoder = TextDecoder
;(globalThis as any).TextEncoder = TextEncoder as unknown as {
new (): TextEncoder
}
;(globalThis as any).TextDecoder = TextDecoder as unknown as {
new (): TextDecoder
}
import { registerLocaleData } from '@angular/common'
import localeAf from '@angular/common/locales/af'
@@ -116,10 +120,6 @@ if (!URL.revokeObjectURL) {
Object.defineProperty(window.URL, 'revokeObjectURL', { value: jest.fn() })
}
Object.defineProperty(window, 'ResizeObserver', { value: mock() })
Object.defineProperty(window, 'location', {
configurable: true,
value: { reload: jest.fn() },
})
HTMLCanvasElement.prototype.getContext = <
typeof HTMLCanvasElement.prototype.getContext

View File

@@ -176,6 +176,7 @@
<div class="row">
<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 document counts in sidebar saved views" formControlName="sidebarViewsShowCount"></pngx-input-check>
</div>
</div>

View File

@@ -31,10 +31,12 @@ import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
import { SafeHtmlPipe } from 'src/app/pipes/safehtml.pipe'
import { PermissionsService } from 'src/app/services/permissions.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 { SettingsService } from 'src/app/services/settings.service'
import { SystemStatusService } from 'src/app/services/system-status.service'
import { Toast, ToastService } from 'src/app/services/toast.service'
import * as navUtils from 'src/app/utils/navigation'
import { ConfirmButtonComponent } from '../../common/confirm-button/confirm-button.component'
import { ConfirmDialogComponent } from '../../common/confirm-dialog/confirm-dialog.component'
import { CheckComponent } from '../../common/input/check/check.component'
@@ -72,6 +74,7 @@ describe('SettingsComponent', () => {
let groupService: GroupService
let modalService: NgbModal
let systemStatusService: SystemStatusService
let savedViewsService: SavedViewService
beforeEach(async () => {
TestBed.configureTestingModule({
@@ -122,6 +125,7 @@ describe('SettingsComponent', () => {
permissionsService = TestBed.inject(PermissionsService)
modalService = TestBed.inject(NgbModal)
systemStatusService = TestBed.inject(SystemStatusService)
savedViewsService = TestBed.inject(SavedViewService)
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
jest
.spyOn(permissionsService, 'currentUserHasObjectPermissions')
@@ -212,7 +216,7 @@ describe('SettingsComponent', () => {
expect(toastErrorSpy).toHaveBeenCalled()
expect(storeSpy).toHaveBeenCalled()
expect(appearanceSettingsSpy).not.toHaveBeenCalled()
expect(setSpy).toHaveBeenCalledTimes(29)
expect(setSpy).toHaveBeenCalledTimes(30)
// succeed
storeSpy.mockReturnValueOnce(of(true))
@@ -222,6 +226,9 @@ describe('SettingsComponent', () => {
})
it('should offer reload if settings changes require', () => {
const reloadSpy = jest
.spyOn(navUtils, 'locationReload')
.mockImplementation(() => {})
completeSetup()
let toast: Toast
toastService.getToasts().subscribe((t) => (toast = t[0]))
@@ -238,6 +245,7 @@ describe('SettingsComponent', () => {
expect(toast.actionName).toEqual('Reload now')
toast.action()
expect(reloadSpy).toHaveBeenCalled()
})
it('should allow setting theme color, visually apply change immediately but not save', () => {
@@ -266,7 +274,7 @@ describe('SettingsComponent', () => {
)
completeSetup(userService)
fixture.detectChanges()
expect(toastErrorSpy).toBeCalled()
expect(toastErrorSpy).toHaveBeenCalled()
})
it('should show errors on load if load groups failure', () => {
@@ -278,7 +286,7 @@ describe('SettingsComponent', () => {
)
completeSetup(groupService)
fixture.detectChanges()
expect(toastErrorSpy).toBeCalled()
expect(toastErrorSpy).toHaveBeenCalled()
})
it('should load system status on initialize, show errors if needed', () => {
@@ -348,4 +356,14 @@ describe('SettingsComponent', () => {
component.reset()
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()
})
})

View File

@@ -49,6 +49,7 @@ import {
PermissionsService,
} from 'src/app/services/permissions.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 {
LanguageOption,
@@ -56,6 +57,7 @@ import {
} from 'src/app/services/settings.service'
import { SystemStatusService } from 'src/app/services/system-status.service'
import { Toast, ToastService } from 'src/app/services/toast.service'
import { locationReload } from 'src/app/utils/navigation'
import { CheckComponent } from '../../common/input/check/check.component'
import { ColorComponent } from '../../common/input/color/color.component'
import { PermissionsGroupComponent } from '../../common/input/permissions/permissions-group/permissions-group.component'
@@ -117,6 +119,7 @@ export class SettingsComponent
permissionsService = inject(PermissionsService)
private modalService = inject(NgbModal)
private systemStatusService = inject(SystemStatusService)
private savedViewsService = inject(SavedViewService)
activeNavID: number
@@ -152,6 +155,7 @@ export class SettingsComponent
notificationsConsumerSuppressOnDashboard: new FormControl(null),
savedViewsWarnOnUnsavedChange: new FormControl(null),
sidebarViewsShowCount: new FormControl(null),
})
SettingsNavIDs = SettingsNavIDs
@@ -197,6 +201,7 @@ export class SettingsComponent
super()
this.settings.settingsSaved.subscribe(() => {
if (!this.savePending) this.initialize()
this.savedViewsService.maybeRefreshDocumentCounts()
})
}
@@ -308,6 +313,9 @@ export class SettingsComponent
savedViewsWarnOnUnsavedChange: this.settings.get(
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),
defaultPermsViewUsers: this.settings.get(
SETTINGS_KEYS.DEFAULT_PERMS_VIEW_USERS
@@ -485,6 +493,10 @@ export class SettingsComponent
SETTINGS_KEYS.SAVED_VIEWS_WARN_ON_UNSAVED_CHANGE,
this.settingsForm.value.savedViewsWarnOnUnsavedChange
)
this.settings.set(
SETTINGS_KEYS.SIDEBAR_VIEWS_SHOW_COUNT,
this.settingsForm.value.sidebarViewsShowCount
)
this.settings.set(
SETTINGS_KEYS.DEFAULT_PERMS_OWNER,
this.settingsForm.value.defaultPermsOwner
@@ -539,7 +551,7 @@ export class SettingsComponent
savedToast.content = $localize`Settings were saved successfully. Reload is required to apply some changes.`
savedToast.actionName = $localize`Reload now`
savedToast.action = () => {
location.reload()
locationReload()
}
}

View File

@@ -19,6 +19,7 @@ import { GroupService } from 'src/app/services/rest/group.service'
import { UserService } from 'src/app/services/rest/user.service'
import { SettingsService } from 'src/app/services/settings.service'
import { ToastService } from 'src/app/services/toast.service'
import * as navUtils from 'src/app/utils/navigation'
import { ConfirmDialogComponent } from '../../common/confirm-dialog/confirm-dialog.component'
import { GroupEditDialogComponent } from '../../common/edit-dialog/group-edit-dialog/group-edit-dialog.component'
import { UserEditDialogComponent } from '../../common/edit-dialog/user-edit-dialog/user-edit-dialog.component'
@@ -107,7 +108,7 @@ describe('UsersAndGroupsComponent', () => {
const toastErrorSpy = jest.spyOn(toastService, 'showError')
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
editDialog.failed.emit()
expect(toastErrorSpy).toBeCalled()
expect(toastErrorSpy).toHaveBeenCalled()
settingsService.currentUser = users[1] // simulate logged in as different user
editDialog.succeeded.emit(users[0])
expect(toastInfoSpy).toHaveBeenCalledWith(
@@ -130,7 +131,7 @@ describe('UsersAndGroupsComponent', () => {
throwError(() => new Error('error deleting user'))
)
deleteDialog.confirm()
expect(toastErrorSpy).toBeCalled()
expect(toastErrorSpy).toHaveBeenCalled()
deleteSpy.mockReturnValueOnce(of(true))
deleteDialog.confirm()
expect(listAllSpy).toHaveBeenCalled()
@@ -142,19 +143,18 @@ describe('UsersAndGroupsComponent', () => {
let modal: NgbModalRef
modalService.activeInstances.subscribe((refs) => (modal = refs[0]))
component.editUser(users[0])
const navSpy = jest
.spyOn(navUtils, 'setLocationHref')
.mockImplementation(() => {})
const editDialog = modal.componentInstance as UserEditDialogComponent
editDialog.passwordIsSet = true
settingsService.currentUser = users[0] // simulate logged in as same user
editDialog.succeeded.emit(users[0])
fixture.detectChanges()
Object.defineProperty(window, 'location', {
value: {
href: 'http://localhost/',
},
writable: true, // possibility to override
})
tick(2600)
expect(window.location.href).toContain('logout')
expect(navSpy).toHaveBeenCalledWith(
`${window.location.origin}/accounts/logout/?next=/accounts/login/?next=/`
)
}))
it('should support edit / create group, show error if needed', () => {
@@ -166,7 +166,7 @@ describe('UsersAndGroupsComponent', () => {
const toastErrorSpy = jest.spyOn(toastService, 'showError')
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
editDialog.failed.emit()
expect(toastErrorSpy).toBeCalled()
expect(toastErrorSpy).toHaveBeenCalled()
editDialog.succeeded.emit(groups[0])
expect(toastInfoSpy).toHaveBeenCalledWith(
`Saved group "${groups[0].name}".`
@@ -188,7 +188,7 @@ describe('UsersAndGroupsComponent', () => {
throwError(() => new Error('error deleting group'))
)
deleteDialog.confirm()
expect(toastErrorSpy).toBeCalled()
expect(toastErrorSpy).toHaveBeenCalled()
deleteSpy.mockReturnValueOnce(of(true))
deleteDialog.confirm()
expect(listAllSpy).toHaveBeenCalled()
@@ -210,7 +210,7 @@ describe('UsersAndGroupsComponent', () => {
)
completeSetup(userService)
fixture.detectChanges()
expect(toastErrorSpy).toBeCalled()
expect(toastErrorSpy).toHaveBeenCalled()
})
it('should show errors on load if load groups failure', () => {
@@ -222,6 +222,6 @@ describe('UsersAndGroupsComponent', () => {
)
completeSetup(groupService)
fixture.detectChanges()
expect(toastErrorSpy).toBeCalled()
expect(toastErrorSpy).toHaveBeenCalled()
})
})

View File

@@ -10,6 +10,7 @@ import { GroupService } from 'src/app/services/rest/group.service'
import { UserService } from 'src/app/services/rest/user.service'
import { SettingsService } from 'src/app/services/settings.service'
import { ToastService } from 'src/app/services/toast.service'
import { setLocationHref } from 'src/app/utils/navigation'
import { ConfirmDialogComponent } from '../../common/confirm-dialog/confirm-dialog.component'
import { EditDialogMode } from '../../common/edit-dialog/edit-dialog.component'
import { GroupEditDialogComponent } from '../../common/edit-dialog/group-edit-dialog/group-edit-dialog.component'
@@ -93,7 +94,9 @@ export class UsersAndGroupsComponent
$localize`Password has been changed, you will be logged out momentarily.`
)
setTimeout(() => {
window.location.href = `${window.location.origin}/accounts/logout/?next=/accounts/login/?next=/`
setLocationHref(
`${window.location.origin}/accounts/logout/?next=/accounts/login/?next=/`
)
}, 2500)
} else {
this.toastService.showInfo(

View File

@@ -115,7 +115,14 @@
routerLinkActive="active" (click)="closeMenu()" [ngbPopover]="view.name"
[disablePopover]="!slimSidebarEnabled" placement="end" container="body" triggers="mouseenter:mouseleave"
popoverClass="popover-slim">
<i-bs class="me-1" name="funnel"></i-bs><span>&nbsp;{{view.name}}</span>
<i-bs class="me-1" name="funnel"></i-bs><span>&nbsp;{{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>
@if (settingsService.organizingSidebarSavedViews) {
<div class="position-absolute end-0 top-0 px-3 py-2" [class.me-n3]="slimSidebarEnabled" cdkDragHandle>

View File

@@ -92,6 +92,7 @@ describe('AppFrameComponent', () => {
let router: Router
let savedViewSpy
let modalService: NgbModal
let maybeRefreshSpy
beforeEach(async () => {
TestBed.configureTestingModule({
@@ -113,7 +114,11 @@ describe('AppFrameComponent', () => {
{
provide: SavedViewService,
useValue: {
reload: () => {},
reload: (fn: any) => {
if (fn) {
fn()
}
},
listAll: () =>
of({
all: [saved_views.map((v) => v.id)],
@@ -121,6 +126,8 @@ describe('AppFrameComponent', () => {
results: saved_views,
}),
sidebarViews: saved_views.filter((v) => v.show_in_sidebar),
getDocumentCount: (view: SavedView) => 5,
maybeRefreshDocumentCounts: () => {},
},
},
PermissionsService,
@@ -169,6 +176,7 @@ describe('AppFrameComponent', () => {
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
savedViewSpy = jest.spyOn(savedViewService, 'reload')
maybeRefreshSpy = jest.spyOn(savedViewService, 'maybeRefreshDocumentCounts')
fixture = TestBed.createComponent(AppFrameComponent)
component = fixture.componentInstance
@@ -359,4 +367,8 @@ describe('AppFrameComponent', () => {
expect(toastErrorSpy).toHaveBeenCalledTimes(2)
expect(toastInfoSpy).toHaveBeenCalledTimes(3)
})
it('should call maybeRefreshDocumentCounts after saved views reload', () => {
expect(maybeRefreshSpy).toHaveBeenCalled()
})
})

View File

@@ -104,7 +104,9 @@ export class AppFrameComponent
PermissionType.SavedView
)
) {
this.savedViewService.reload()
this.savedViewService.reload(() => {
this.savedViewService.maybeRefreshDocumentCounts()
})
}
}
@@ -289,4 +291,8 @@ export class AppFrameComponent
onLogout() {
this.openDocumentsService.closeAll()
}
get showSidebarCounts(): boolean {
return this.settingsService.get(SETTINGS_KEYS.SIDEBAR_VIEWS_SHOW_COUNT)
}
}

View File

@@ -30,7 +30,7 @@
}
<div class="list-group-item">
<div class="input-group input-group-sm">
<input class="form-control" type="text" [(ngModel)]="filterText" [placeholder]="filterPlaceholder" (keyup.enter)="listFilterEnter()" #listFilterTextInput>
<input class="form-control" type="text" spellcheck="false" [(ngModel)]="filterText" [placeholder]="filterPlaceholder" (keyup.enter)="listFilterEnter()" #listFilterTextInput>
</div>
</div>
@if (selectionModel.items) {

View File

@@ -18,6 +18,7 @@ import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
import { of, throwError } from 'rxjs'
import { ProfileService } from 'src/app/services/profile.service'
import { ToastService } from 'src/app/services/toast.service'
import * as navUtils from 'src/app/utils/navigation'
import { ConfirmButtonComponent } from '../confirm-button/confirm-button.component'
import { PasswordComponent } from '../input/password/password.component'
import { TextComponent } from '../input/text/text.component'
@@ -205,16 +206,15 @@ describe('ProfileEditDialogComponent', () => {
const updateSpy = jest.spyOn(profileService, 'update')
updateSpy.mockReturnValue(of(null))
Object.defineProperty(window, 'location', {
value: {
href: 'http://localhost/',
},
writable: true, // possibility to override
})
const navSpy = jest
.spyOn(navUtils, 'setLocationHref')
.mockImplementation(() => {})
component.save()
expect(updateSpy).toHaveBeenCalled()
tick(2600)
expect(window.location.href).toContain('logout')
expect(navSpy).toHaveBeenCalledWith(
`${window.location.origin}/accounts/logout/?next=/accounts/login/?next=/`
)
}))
it('should support auth token copy', fakeAsync(() => {

View File

@@ -21,6 +21,7 @@ import {
import { SafeHtmlPipe } from 'src/app/pipes/safehtml.pipe'
import { ProfileService } from 'src/app/services/profile.service'
import { ToastService } from 'src/app/services/toast.service'
import { setLocationHref } from 'src/app/utils/navigation'
import { LoadingComponentWithPermissions } from '../../loading-component/loading.component'
import { ConfirmButtonComponent } from '../confirm-button/confirm-button.component'
import { PasswordComponent } from '../input/password/password.component'
@@ -194,7 +195,9 @@ export class ProfileEditDialogComponent
$localize`Password has been changed, you will be logged out momentarily.`
)
setTimeout(() => {
window.location.href = `${window.location.origin}/accounts/logout/?next=/accounts/login/?next=/`
setLocationHref(
`${window.location.origin}/accounts/logout/?next=/accounts/login/?next=/`
)
}, 2500)
}
this.activeModal.close()

View File

@@ -1,6 +1,7 @@
<pngx-widget-frame
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }"
[title]="savedView.name"
[badge]="count"
[loading]="loading"
[draggable]="savedView"
>

View File

@@ -118,6 +118,8 @@ export class SavedViewWidgetComponent
displayFields: DisplayField[] = DEFAULT_DASHBOARD_DISPLAY_FIELDS
count: number
ngOnInit(): void {
this.reload()
this.displayMode = this.savedView.display_mode ?? DisplayMode.TABLE
@@ -178,6 +180,7 @@ export class SavedViewWidgetComponent
tap((result) => {
this.show = true
this.documents = result.results
this.count = result.count
}),
delay(500)
)

View File

@@ -2,13 +2,16 @@
<div class="card shadow-sm bg-light fade" [class.show]="show" cdkDrag [cdkDragDisabled]="!draggable" cdkDragPreviewContainer="parent">
<div class="card-header">
<div class="d-flex justify-content-between align-items-center">
<div class="d-flex">
<div class="d-flex align-items-center">
@if (draggable) {
<div class="ms-n2 me-1" cdkDragHandle>
<i-bs name="grip-vertical"></i-bs>
</div>
}
<h6 class="card-title mb-0">{{title}}</h6>
@if (badge) {
<span class="badge bg-info text-dark ms-2">{{badge}}</span>
}
</div>
@if (loading) {
<div class="spinner-border spinner-border-sm fw-normal ms-2 me-auto" role="status"></div>

View File

@@ -30,6 +30,9 @@ export class WidgetFrameComponent
@Input()
cardless: boolean = false
@Input()
badge: string
ngAfterViewInit(): void {
setTimeout(() => {
this.show = true

View File

@@ -1073,6 +1073,22 @@ describe('DocumentDetailComponent', () => {
})
})
it('should restore changed fields and mark as dirty', () => {
jest
.spyOn(activatedRoute, 'paramMap', 'get')
.mockReturnValue(of(convertToParamMap({ id: 3, section: 'details' })))
jest.spyOn(documentService, 'get').mockReturnValueOnce(of(doc))
const docWithChanges = Object.assign({}, doc)
docWithChanges.__changedFields = ['title', 'tags', 'owner']
jest
.spyOn(openDocumentsService, 'getOpenDocument')
.mockReturnValue(docWithChanges)
fixture.detectChanges() // calls ngOnInit
expect(component.documentForm.get('title').dirty).toBeTruthy()
expect(component.documentForm.get('tags').dirty).toBeTruthy()
expect(component.documentForm.get('permissions_form').dirty).toBeTruthy()
})
it('should show custom field errors', () => {
initNormally()
component.error = {

View File

@@ -73,6 +73,7 @@ import { CorrespondentService } from 'src/app/services/rest/correspondent.servic
import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
import { DocumentTypeService } from 'src/app/services/rest/document-type.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 { TagService } from 'src/app/services/rest/tag.service'
import { UserService } from 'src/app/services/rest/user.service'
@@ -200,6 +201,7 @@ export class DocumentDetailComponent
private hotKeyService = inject(HotKeyService)
private componentRouterService = inject(ComponentRouterService)
private deviceDetectorService = inject(DeviceDetectorService)
private savedViewService = inject(SavedViewService)
@ViewChild('inputTitle')
titleInput: TextComponent
@@ -461,6 +463,15 @@ export class DocumentDetailComponent
]
delete openDocument['permissions_form']
}
if (openDocument.__changedFields) {
openDocument.__changedFields.forEach((field) => {
if (field === 'owner' || field === 'set_permissions') {
this.documentForm.get('permissions_form').markAsDirty()
} else {
this.documentForm.get(field)?.markAsDirty()
}
})
}
this.updateComponent(openDocument)
} else {
this.openDocumentService.openDocument(doc)
@@ -524,7 +535,7 @@ export class DocumentDetailComponent
)
.subscribe({
next: ({ doc, dirty }) => {
this.openDocumentService.setDirty(doc, dirty)
this.openDocumentService.setDirty(doc, dirty, this.getChangedFields())
},
error: (error) => {
this.router.navigate(['404'], {
@@ -902,6 +913,7 @@ export class DocumentDetailComponent
} else {
this.openDocumentService.refreshDocument(this.documentId)
}
this.savedViewService.maybeRefreshDocumentCounts()
},
error: (error) => {
this.networkActive = false
@@ -1249,6 +1261,7 @@ export class DocumentDetailComponent
notesUpdated(notes: DocumentNote[]) {
this.document.notes = notes
this.openDocumentService.refreshDocument(this.documentId)
this.savedViewService.maybeRefreshDocumentCounts()
}
get userIsOwner(): boolean {

View File

@@ -32,6 +32,7 @@ import {
DocumentService,
SelectionDataItem,
} 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 { TagService } from 'src/app/services/rest/tag.service'
import { SettingsService } from 'src/app/services/settings.service'
@@ -83,6 +84,7 @@ export class BulkEditorComponent
private storagePathService = inject(StoragePathService)
private customFieldService = inject(CustomFieldsService)
private permissionService = inject(PermissionsService)
private savedViewService = inject(SavedViewService)
tagSelectionModel = new FilterableDropdownSelectionModel(true)
correspondentSelectionModel = new FilterableDropdownSelectionModel()
@@ -270,6 +272,7 @@ export class BulkEditorComponent
this.list.selected.forEach((id) => {
this.openDocumentService.refreshDocument(id)
})
this.savedViewService.maybeRefreshDocumentCounts()
if (modal) {
modal.close()
}

View File

@@ -188,7 +188,7 @@ describe('MailComponent', () => {
const toastErrorSpy = jest.spyOn(toastService, 'showError')
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
editDialog.failed.emit()
expect(toastErrorSpy).toBeCalled()
expect(toastErrorSpy).toHaveBeenCalled()
editDialog.succeeded.emit(mailAccounts[0] as any)
expect(toastInfoSpy).toHaveBeenCalledWith(
`Saved account "${mailAccounts[0].name}".`
@@ -211,7 +211,7 @@ describe('MailComponent', () => {
throwError(() => new Error('error deleting mail account'))
)
deleteDialog.confirm()
expect(toastErrorSpy).toBeCalled()
expect(toastErrorSpy).toHaveBeenCalled()
deleteSpy.mockReturnValueOnce(of(true))
deleteDialog.confirm()
expect(listAllSpy).toHaveBeenCalled()
@@ -246,7 +246,7 @@ describe('MailComponent', () => {
const toastErrorSpy = jest.spyOn(toastService, 'showError')
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
editDialog.failed.emit()
expect(toastErrorSpy).toBeCalled()
expect(toastErrorSpy).toHaveBeenCalled()
editDialog.succeeded.emit(mailRules[0] as any)
expect(toastInfoSpy).toHaveBeenCalledWith(
`Saved rule "${mailRules[0].name}".`
@@ -280,7 +280,7 @@ describe('MailComponent', () => {
throwError(() => new Error('error deleting mail rule "rule1"'))
)
deleteDialog.confirm()
expect(toastErrorSpy).toBeCalled()
expect(toastErrorSpy).toHaveBeenCalled()
deleteSpy.mockReturnValueOnce(of(true))
deleteDialog.confirm()
expect(listAllSpy).toHaveBeenCalled()

View File

@@ -85,7 +85,10 @@ export const CUSTOM_FIELD_QUERY_OPERATOR_GROUPS_BY_TYPE = {
CustomFieldQueryOperatorGroups.Exact,
CustomFieldQueryOperatorGroups.Date,
],
[CustomFieldDataType.Boolean]: [CustomFieldQueryOperatorGroups.Basic],
[CustomFieldDataType.Boolean]: [
CustomFieldQueryOperatorGroups.Basic,
CustomFieldQueryOperatorGroups.Exact,
],
[CustomFieldDataType.Integer]: [
CustomFieldQueryOperatorGroups.Basic,
CustomFieldQueryOperatorGroups.Exact,

View File

@@ -158,4 +158,7 @@ export interface Document extends ObjectWithPermissions {
remove_inbox_tags?: boolean
page_count?: number
// Frontend only
__changedFields?: string[]
}

View File

@@ -58,6 +58,8 @@ export const SETTINGS_KEYS = {
'general-settings:saved-views:dashboard-views-sort-order',
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',
DEFAULT_PERMS_OWNER: 'general-settings:permissions:default-owner',
DEFAULT_PERMS_VIEW_USERS: 'general-settings:permissions:default-view-users',
@@ -228,6 +230,11 @@ export const SETTINGS: UiSetting[] = [
type: 'array',
default: [],
},
{
key: SETTINGS_KEYS.SIDEBAR_VIEWS_SHOW_COUNT,
type: 'boolean',
default: true,
},
{
key: SETTINGS_KEYS.APP_LOGO,
type: 'string',

View File

@@ -241,4 +241,15 @@ describe('OpenDocumentsService', () => {
openDocumentsService.openDocument(doc)
expect(consoleSpy).toHaveBeenCalled()
})
it('should set dirty status with changed fields', () => {
subscriptions.push(
openDocumentsService.openDocument(documents[0]).subscribe()
)
const changedFields = { title: 'foo', content: 'bar' }
openDocumentsService.setDirty(documents[0], true, changedFields)
expect(
openDocumentsService.getOpenDocument(documents[0].id).__changedFields
).toEqual(['title', 'content'])
})
})

View File

@@ -84,10 +84,20 @@ export class OpenDocumentsService {
this.save()
}
setDirty(doc: Document, dirty: boolean) {
if (!this.openDocuments.find((d) => d.id == doc.id)) return
if (dirty) this.dirtyDocuments.add(doc.id)
else this.dirtyDocuments.delete(doc.id)
setDirty(doc: Document, dirty: boolean, changedFields: object = {}) {
const existingDoc = this.getOpenDocument(doc.id)
if (!existingDoc) return
if (dirty) {
this.dirtyDocuments.add(doc.id)
existingDoc.__changedFields = Object.keys(changedFields).filter(
(key) => key !== 'id'
)
} else {
this.dirtyDocuments.delete(doc.id)
existingDoc.__changedFields = []
}
this.save()
}
hasDirty(): boolean {

View File

@@ -17,7 +17,7 @@ const saved_views = [
id: 1,
show_on_dashboard: true,
show_in_sidebar: true,
sort_field: 'name',
sort_field: 'title',
sort_reverse: true,
filter_rules: [],
},
@@ -26,7 +26,7 @@ const saved_views = [
id: 2,
show_on_dashboard: true,
show_in_sidebar: true,
sort_field: 'name',
sort_field: 'created',
sort_reverse: true,
filter_rules: [],
},
@@ -35,7 +35,7 @@ const saved_views = [
id: 3,
show_on_dashboard: true,
show_in_sidebar: true,
sort_field: 'name',
sort_field: 'added',
sort_reverse: true,
filter_rules: [],
},
@@ -44,7 +44,7 @@ const saved_views = [
id: 4,
show_on_dashboard: false,
show_in_sidebar: false,
sort_field: 'name',
sort_field: 'owner',
sort_reverse: true,
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(() => {
// Dont need to setup again

View File

@@ -1,12 +1,13 @@
import { HttpClient } from '@angular/common/http'
import { inject, Injectable } from '@angular/core'
import { combineLatest, Observable } from 'rxjs'
import { tap } from 'rxjs/operators'
import { combineLatest, Observable, Subject } from 'rxjs'
import { takeUntil, tap } from 'rxjs/operators'
import { Results } from 'src/app/data/results'
import { SavedView } from 'src/app/data/saved-view'
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
import { SettingsService } from '../settings.service'
import { AbstractPaperlessService } from './abstract-paperless-service'
import { DocumentService } from './document.service'
@Injectable({
providedIn: 'root',
@@ -14,9 +15,12 @@ import { AbstractPaperlessService } from './abstract-paperless-service'
export class SavedViewService extends AbstractPaperlessService<SavedView> {
protected http: HttpClient
private settingsService = inject(SettingsService)
private documentService = inject(DocumentService)
public loading: boolean = true
private savedViews: SavedView[] = []
private savedViewDocumentCounts: Map<number, number> = new Map()
private unsubscribeNotifier: Subject<void> = new Subject<void>()
constructor() {
super()
@@ -46,8 +50,16 @@ export class SavedViewService extends AbstractPaperlessService<SavedView> {
)
}
public reload() {
this.listAll().subscribe()
public reload(callback: any = null) {
this.listAll()
.pipe(
tap((r) => {
if (callback) {
callback(r)
}
})
)
.subscribe()
}
get allViews() {
@@ -110,4 +122,30 @@ export class SavedViewService extends AbstractPaperlessService<SavedView> {
delete(o: SavedView) {
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)
}
}

View File

@@ -0,0 +1,8 @@
/* istanbul ignore file */
export function setLocationHref(url: string) {
window.location.href = url
}
export function locationReload() {
window.location.reload()
}

View File

@@ -3,7 +3,8 @@
"compilerOptions": {
"outDir": "./out-tsc/spec",
"types": [
"jest"
"jest",
"node",
],
"module": "commonjs",
"emitDecoratorMetadata": true,