Chorehancement: update to Angular v22, 'zoneless' / 'reactive' (#13114)

This commit is contained in:
shamoon
2026-07-10 00:42:16 -07:00
committed by GitHub
parent f244442c65
commit 106b41a15c
213 changed files with 5363 additions and 5842 deletions
@@ -1,7 +1,7 @@
<nav class="navbar navbar-dark fixed-top bg-primary flex-md-nowrap p-0 shadow-sm">
<button class="navbar-toggler d-md-none collapsed border-0" type="button" data-toggle="collapse"
data-target="#sidebarMenu" aria-controls="sidebarMenu" aria-expanded="false" aria-label="Toggle navigation"
(click)="mobileSearchHidden = false; isMenuCollapsed = !isMenuCollapsed">
(click)="closeMobileSearch(); toggleMenuCollapsed()">
<span class="navbar-toggler-icon"></span>
</button>
<a class="navbar-brand d-flex align-items-center me-0 px-3 py-3 order-sm-0"
@@ -23,7 +23,7 @@
</div>
</a>
<div class="search-container flex-grow-1 py-2 pb-3 pb-sm-2 px-3 ps-md-4 me-sm-auto order-3 order-sm-1"
[class.mobile-hidden]="mobileSearchHidden">
[class.mobile-hidden]="mobileSearchHidden()">
<div class="col-12 col-md-7">
<pngx-global-search></pngx-global-search>
</div>
@@ -68,8 +68,8 @@
<div class="container-fluid">
<div class="row">
<nav id="sidebarMenu" class="d-md-block bg-light sidebar collapse"
[ngClass]="slimSidebarEnabled ? 'slim' : 'col-md-3 col-lg-2 col-xxxl-1'" [class.animating]="slimSidebarAnimating"
[ngbCollapse]="isMenuCollapsed">
[ngClass]="slimSidebarEnabled ? 'slim' : 'col-md-3 col-lg-2 col-xxxl-1'" [class.animating]="slimSidebarAnimating()"
[ngbCollapse]="isMenuCollapsed()">
@if (canSaveSettings) {
<button class="btn btn-sm btn-dark sidebar-slim-toggler" (click)="toggleSlimSidebar()">
@if (slimSidebarEnabled) {
@@ -104,7 +104,7 @@
</h6>
<ul class="nav flex-column mb-2" cdkDropList (cdkDropListDropped)="onDrop($event)">
@for (view of savedViewService.sidebarViews; track view.id) {
<li class="nav-item w-100 app-link" cdkDrag [cdkDragDisabled]="!settingsService.organizingSidebarSavedViews"
<li class="nav-item w-100 app-link" cdkDrag [cdkDragDisabled]="!settingsService.organizingSidebarSavedViews()"
cdkDragPreviewContainer="parent" cdkDragPreviewClass="navItemDrag" (cdkDragStarted)="onDragStart($event)"
(cdkDragEnded)="onDragEnd($event)">
<a class="nav-link" routerLink="view/{{view.id}}"
@@ -120,7 +120,7 @@
<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) {
@if (settingsService.organizingSidebarSavedViews()) {
<div class="position-absolute end-0 top-0 px-3 py-2" [class.me-n3]="slimSidebarEnabled" cdkDragHandle>
<i-bs name="grip-vertical"></i-bs>
</div>
@@ -329,10 +329,10 @@
{{ versionString }}
</a>
</div>
@if (!settingsService.updateCheckingIsSet || appRemoteVersion) {
@if (!settingsService.updateCheckingIsSet || appRemoteVersion()) {
<div class="version-check">
<ng-template #updateAvailablePopContent>
<span class="small">Paperless-ngx {{ appRemoteVersion.version }} <ng-container i18n>is
<span class="small">Paperless-ngx {{ appRemoteVersion().version }} <ng-container i18n>is
available.</ng-container><br /><ng-container i18n>Click to view.</ng-container></span>
</ng-template>
<ng-template #updateCheckingNotEnabledPopContent>
@@ -350,13 +350,13 @@
</p>
</ng-template>
@if (settingsService.updateCheckingIsSet) {
@if (appRemoteVersion.update_available) {
@if (appRemoteVersion().update_available) {
<a class="small text-decoration-none" target="_blank" rel="noopener noreferrer"
href="https://github.com/paperless-ngx/paperless-ngx/releases"
[ngbPopover]="updateAvailablePopContent" popoverClass="shadow" triggers="mouseenter:mouseleave"
container="body">
<i-bs width="1.2em" height="1.2em" name="info-circle" class="me-1"></i-bs>
@if (appRemoteVersion?.update_available) {
@if (appRemoteVersion()?.update_available) {
<ng-container i18n>Update available</ng-container>
}
</a>
@@ -377,7 +377,7 @@
</div>
</nav>
<main role="main" class="ms-sm-auto px-md-4" [class.mobile-search-hidden]="mobileSearchHidden"
<main role="main" class="ms-sm-auto px-md-4" [class.mobile-search-hidden]="mobileSearchHidden()"
[ngClass]="slimSidebarEnabled ? 'col-slim' : 'col-md-9 col-lg-10 col-xxxl-11'">
<router-outlet></router-outlet>
</main>
@@ -4,12 +4,7 @@ import {
HttpTestingController,
provideHttpClientTesting,
} from '@angular/common/http/testing'
import {
ComponentFixture,
TestBed,
fakeAsync,
tick,
} from '@angular/core/testing'
import { ComponentFixture, TestBed } from '@angular/core/testing'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { BrowserModule } from '@angular/platform-browser'
import { ActivatedRoute, Router } from '@angular/router'
@@ -244,11 +239,12 @@ describe('AppFrameComponent', () => {
expect(toastSpy).toHaveBeenCalled()
})
it('should support toggling slim sidebar and saving', fakeAsync(() => {
it('should support toggling slim sidebar and saving', () => {
jest.useFakeTimers()
const saveSettingSpy = jest.spyOn(settingsService, 'set')
settingsService.set(SETTINGS_KEYS.ATTRIBUTES_SECTIONS_COLLAPSED, [])
expect(component.slimSidebarEnabled).toBeFalsy()
expect(component.slimSidebarAnimating).toBeFalsy()
expect(component.slimSidebarAnimating()).toBeFalsy()
component.toggleSlimSidebar()
const requests = httpTestingController.match(
`${environment.apiBaseUrl}ui_settings/`
@@ -259,9 +255,9 @@ describe('AppFrameComponent', () => {
requests[0].request.body.settings.attributes_sections_collapsed
).toEqual(['attributes'])
requests[0].flush({ success: true })
expect(component.slimSidebarAnimating).toBeTruthy()
tick(200)
expect(component.slimSidebarAnimating).toBeFalsy()
expect(component.slimSidebarAnimating()).toBeTruthy()
jest.advanceTimersByTime(200)
expect(component.slimSidebarAnimating()).toBeFalsy()
expect(component.slimSidebarEnabled).toBeTruthy()
expect(saveSettingSpy).toHaveBeenCalledWith(
SETTINGS_KEYS.SLIM_SIDEBAR,
@@ -271,7 +267,8 @@ describe('AppFrameComponent', () => {
SETTINGS_KEYS.ATTRIBUTES_SECTIONS_COLLAPSED,
['attributes']
)
}))
jest.useRealTimers()
})
it('should show error on toggle slim sidebar if store settings fails', () => {
jest.spyOn(console, 'warn').mockImplementation(() => {})
@@ -291,9 +288,9 @@ describe('AppFrameComponent', () => {
fixture.nativeElement as HTMLDivElement
).querySelector('button[data-toggle=collapse]')
button.dispatchEvent(new MouseEvent('click'))
expect(component.isMenuCollapsed).toBeFalsy()
expect(component.isMenuCollapsed()).toBeFalsy()
component.closeMenu()
expect(component.isMenuCollapsed).toBeTruthy()
expect(component.isMenuCollapsed()).toBeTruthy()
})
it('should hide mobile search when scrolling down and show it when scrolling up', () => {
@@ -308,14 +305,14 @@ describe('AppFrameComponent', () => {
value: 40,
})
component.onWindowScroll()
expect(component.mobileSearchHidden).toBe(true)
expect(component.mobileSearchHidden()).toBe(true)
Object.defineProperty(globalThis, 'scrollY', {
configurable: true,
value: 0,
})
component.onWindowScroll()
expect(component.mobileSearchHidden).toBe(false)
expect(component.mobileSearchHidden()).toBe(false)
})
it('should keep mobile search visible on desktop scroll or resize', () => {
@@ -323,13 +320,13 @@ describe('AppFrameComponent', () => {
value: 1024,
})
component.ngOnInit()
component.mobileSearchHidden = true
component.mobileSearchHidden.set(true)
component.onWindowScroll()
expect(component.mobileSearchHidden).toBe(false)
expect(component.mobileSearchHidden()).toBe(false)
component.mobileSearchHidden = true
component.mobileSearchHidden.set(true)
component.onWindowResize()
})
@@ -338,7 +335,7 @@ describe('AppFrameComponent', () => {
value: 767,
})
component.ngOnInit()
component.isMenuCollapsed = false
component.isMenuCollapsed.set(false)
Object.defineProperty(globalThis, 'scrollY', {
configurable: true,
@@ -346,7 +343,7 @@ describe('AppFrameComponent', () => {
})
component.onWindowScroll()
expect(component.mobileSearchHidden).toBe(false)
expect(component.mobileSearchHidden()).toBe(false)
})
it('should support close document & navigate on close current doc', () => {
@@ -379,11 +376,11 @@ describe('AppFrameComponent', () => {
})
it('should disable global dropzone on start drag + drop, re-enable after', () => {
expect(settingsService.globalDropzoneEnabled).toBeTruthy()
expect(settingsService.globalDropzoneEnabled()).toBeTruthy()
component.onDragStart(null)
expect(settingsService.globalDropzoneEnabled).toBeFalsy()
expect(settingsService.globalDropzoneEnabled()).toBeFalsy()
component.onDragEnd(null)
expect(settingsService.globalDropzoneEnabled).toBeTruthy()
expect(settingsService.globalDropzoneEnabled()).toBeTruthy()
})
it('should update saved view sorting on drag + drop, show info', () => {
@@ -6,7 +6,7 @@ import {
moveItemInArray,
} from '@angular/cdk/drag-drop'
import { NgClass } from '@angular/common'
import { Component, HostListener, inject, OnInit } from '@angular/core'
import { Component, HostListener, inject, OnInit, signal } from '@angular/core'
import { ActivatedRoute, Router, RouterModule } from '@angular/router'
import {
NgbCollapseModule,
@@ -90,14 +90,10 @@ export class AppFrameComponent
permissionsService = inject(PermissionsService)
private djangoMessagesService = inject(DjangoMessagesService)
appRemoteVersion: AppRemoteVersion
isMenuCollapsed: boolean = true
slimSidebarAnimating: boolean = false
public mobileSearchHidden: boolean = false
readonly appRemoteVersion = signal<AppRemoteVersion>(null)
readonly isMenuCollapsed = signal(true)
readonly slimSidebarAnimating = signal(false)
readonly mobileSearchHidden = signal(false)
private lastScrollY: number = 0
constructor() {
@@ -147,7 +143,7 @@ export class AppFrameComponent
}
toggleSlimSidebar(): void {
this.slimSidebarAnimating = true
this.slimSidebarAnimating.set(true)
const slimSidebarEnabled = !this.slimSidebarEnabled
this.settingsService.set(SETTINGS_KEYS.SLIM_SIDEBAR, slimSidebarEnabled)
if (slimSidebarEnabled) {
@@ -167,7 +163,7 @@ export class AppFrameComponent
},
})
setTimeout(() => {
this.slimSidebarAnimating = false
this.slimSidebarAnimating.set(false)
}, 200) // slightly longer than css animation for slim sidebar
}
@@ -177,11 +173,25 @@ export class AppFrameComponent
this.attributesSectionsCollapsed = !this.attributesSectionsCollapsed
}
toggleMenuCollapsed(): void {
this.isMenuCollapsed.set(!this.isMenuCollapsed())
}
closeMobileSearch(): void {
this.mobileSearchHidden.set(false)
}
setMobileSearchHidden(hidden: boolean): void {
this.mobileSearchHidden.set(hidden)
}
get versionString(): string {
this.settingsService.trackChanges()
return `${environment.appTitle} v${this.settingsService.get(SETTINGS_KEYS.VERSION)}${environment.tag === 'prod' ? '' : ` #${environment.tag}`}`
}
get customAppTitle(): string {
this.settingsService.trackChanges()
return this.settingsService.get(SETTINGS_KEYS.APP_TITLE)
}
@@ -224,6 +234,7 @@ export class AppFrameComponent
}
get slimSidebarEnabled(): boolean {
this.settingsService.trackChanges()
return this.settingsService.get(SETTINGS_KEYS.SLIM_SIDEBAR)
}
@@ -243,6 +254,7 @@ export class AppFrameComponent
}
get attributesSectionsCollapsed(): boolean {
this.settingsService.trackChanges()
return this.settingsService
.get(SETTINGS_KEYS.ATTRIBUTES_SECTIONS_COLLAPSED)
?.includes(CollapsibleSection.ATTRIBUTES)
@@ -268,13 +280,14 @@ export class AppFrameComponent
}
get aiEnabled(): boolean {
this.settingsService.trackChanges()
return this.settingsService.get(SETTINGS_KEYS.AI_ENABLED)
}
@HostListener('window:resize')
onWindowResize(): void {
if (!this.isMobileViewport()) {
this.mobileSearchHidden = false
this.mobileSearchHidden.set(false)
}
}
@@ -282,8 +295,8 @@ export class AppFrameComponent
onWindowScroll(): void {
const currentScrollY = window.scrollY
if (!this.isMobileViewport() || this.isMenuCollapsed === false) {
this.mobileSearchHidden = false
if (!this.isMobileViewport() || this.isMenuCollapsed() === false) {
this.mobileSearchHidden.set(false)
this.lastScrollY = currentScrollY
return
}
@@ -291,9 +304,9 @@ export class AppFrameComponent
const delta = currentScrollY - this.lastScrollY
if (currentScrollY <= 0 || delta < -SCROLL_THRESHOLD) {
this.mobileSearchHidden = false
this.mobileSearchHidden.set(false)
} else if (currentScrollY > SCROLL_THRESHOLD && delta > SCROLL_THRESHOLD) {
this.mobileSearchHidden = true
this.mobileSearchHidden.set(true)
}
this.lastScrollY = currentScrollY
@@ -304,7 +317,7 @@ export class AppFrameComponent
}
closeMenu() {
this.isMenuCollapsed = true
this.isMenuCollapsed.set(true)
}
editProfile() {
@@ -367,11 +380,11 @@ export class AppFrameComponent
}
onDragStart(event: CdkDragStart) {
this.settingsService.globalDropzoneEnabled = false
this.settingsService.globalDropzoneEnabled.set(false)
}
onDragEnd(event: CdkDragEnd) {
this.settingsService.globalDropzoneEnabled = true
this.settingsService.globalDropzoneEnabled.set(true)
}
onDrop(event: CdkDragDrop<SavedView[]>) {
@@ -392,7 +405,7 @@ export class AppFrameComponent
this.remoteVersionService
.checkForUpdates()
.subscribe((appRemoteVersion: AppRemoteVersion) => {
this.appRemoteVersion = appRemoteVersion
this.appRemoteVersion.set(appRemoteVersion)
})
}
@@ -419,9 +432,10 @@ export class AppFrameComponent
}
get showSidebarCounts(): boolean {
this.settingsService.trackChanges()
return (
this.settingsService.get(SETTINGS_KEYS.SIDEBAR_VIEWS_SHOW_COUNT) &&
!this.settingsService.organizingSidebarSavedViews
!this.settingsService.organizingSidebarSavedViews()
)
}
}
@@ -8,17 +8,17 @@
placeholder="Search" aria-label="Search" i18n-placeholder
autocomplete="off"
spellcheck="false"
[(ngModel)]="query"
(ngModelChange)="this.queryDebounce.next($event)"
[ngModel]="query()"
(ngModelChange)="queryDebounce.next($event)"
(keydown)="searchInputKeyDown($event)"
ngbDropdownAnchor>
<div class="position-absolute top-50 end-0 translate-middle">
@if (loading) {
@if (loading()) {
<div class="spinner-border spinner-border-sm text-muted mt-1"></div>
}
</div>
</div>
@if (query) {
@if (query()) {
<button class="btn btn-sm btn-outline-secondary" type="button" (click)="runFullSearch()">
@if (useAdvancedForFullSearch) {
<ng-container i18n>Advanced search</ng-container>
@@ -77,88 +77,88 @@
<div ngbDropdownMenu class="w-100 mh-75 overflow-y-scroll shadow-lg">
<div (keydown)="dropdownKeyDown($event)">
@if (searchResults?.total === 0) {
@if (searchResults()?.total === 0) {
<h6 class="dropdown-header" i18n="@@searchResults.noResults">No results</h6>
} @else {
@if (searchResults?.documents.length) {
@if (searchResults()?.documents.length) {
<h6 class="dropdown-header" i18n="@@searchResults.documents">Documents</h6>
@for (document of searchResults.documents; track document.id) {
@for (document of searchResults().documents; track document.id) {
<ng-container *ngTemplateOutlet="resultItemTemplate; context: {item: document, nameProp: 'title', type: DataType.Document, icon: 'file-text', date: document.created}"></ng-container>
}
}
@if (searchResults?.saved_views.length) {
@if (searchResults()?.saved_views.length) {
<h6 class="dropdown-header" i18n="@@searchResults.saved_views">Saved Views</h6>
@for (saved_view of searchResults.saved_views; track saved_view.id) {
@for (saved_view of searchResults().saved_views; track saved_view.id) {
<ng-container *ngTemplateOutlet="resultItemTemplate; context: {item: saved_view, nameProp: 'name', type: DataType.SavedView, icon: 'funnel'}"></ng-container>
}
}
@if (searchResults?.tags.length) {
@if (searchResults()?.tags.length) {
<h6 class="dropdown-header" i18n="@@searchResults.tags">Tags</h6>
@for (tag of searchResults.tags; track tag.id) {
@for (tag of searchResults().tags; track tag.id) {
<ng-container *ngTemplateOutlet="resultItemTemplate; context: {item: tag, nameProp: 'name', type: DataType.Tag, icon: 'tag'}"></ng-container>
}
}
@if (searchResults?.correspondents.length) {
@if (searchResults()?.correspondents.length) {
<h6 class="dropdown-header" i18n="@@searchResults.correspondents">Correspondents</h6>
@for (correspondent of searchResults.correspondents; track correspondent.id) {
@for (correspondent of searchResults().correspondents; track correspondent.id) {
<ng-container *ngTemplateOutlet="resultItemTemplate; context: {item: correspondent, nameProp: 'name', type: DataType.Correspondent, icon: 'person'}"></ng-container>
}
}
@if (searchResults?.document_types.length) {
@if (searchResults()?.document_types.length) {
<h6 class="dropdown-header" i18n="@@searchResults.documentTypes">Document types</h6>
@for (documentType of searchResults.document_types; track documentType.id) {
@for (documentType of searchResults().document_types; track documentType.id) {
<ng-container *ngTemplateOutlet="resultItemTemplate; context: {item: documentType, nameProp: 'name', type: DataType.DocumentType, icon: 'file-earmark'}"></ng-container>
}
}
@if (searchResults?.storage_paths.length) {
@if (searchResults()?.storage_paths.length) {
<h6 class="dropdown-header" i18n="@@searchResults.storagePaths">Storage paths</h6>
@for (storagePath of searchResults.storage_paths; track storagePath.id) {
@for (storagePath of searchResults().storage_paths; track storagePath.id) {
<ng-container *ngTemplateOutlet="resultItemTemplate; context: {item: storagePath, nameProp: 'name', type: DataType.StoragePath, icon: 'folder'}"></ng-container>
}
}
@if (searchResults?.users.length) {
@if (searchResults()?.users.length) {
<h6 class="dropdown-header" i18n="@@searchResults.users">Users</h6>
@for (user of searchResults.users; track user.id) {
@for (user of searchResults().users; track user.id) {
<ng-container *ngTemplateOutlet="resultItemTemplate; context: {item: user, nameProp: 'username', type: DataType.User, icon: 'person-square'}"></ng-container>
}
}
@if (searchResults?.groups.length) {
@if (searchResults()?.groups.length) {
<h6 class="dropdown-header" i18n="@@searchResults.groups">Groups</h6>
@for (group of searchResults.groups; track group.id) {
@for (group of searchResults().groups; track group.id) {
<ng-container *ngTemplateOutlet="resultItemTemplate; context: {item: group, nameProp: 'name', type: DataType.Group, icon: 'people'}"></ng-container>
}
}
@if (searchResults?.custom_fields.length) {
@if (searchResults()?.custom_fields.length) {
<h6 class="dropdown-header" i18n="@@searchResults.customFields">Custom fields</h6>
@for (customField of searchResults.custom_fields; track customField.id) {
@for (customField of searchResults().custom_fields; track customField.id) {
<ng-container *ngTemplateOutlet="resultItemTemplate; context: {item: customField, nameProp: 'name', type: DataType.CustomField, icon: 'ui-radios'}"></ng-container>
}
}
@if (searchResults?.mail_accounts.length) {
@if (searchResults()?.mail_accounts.length) {
<h6 class="dropdown-header" i18n="@@searchResults.mailAccounts">Mail accounts</h6>
@for (mailAccount of searchResults.mail_accounts; track mailAccount.id) {
@for (mailAccount of searchResults().mail_accounts; track mailAccount.id) {
<ng-container *ngTemplateOutlet="resultItemTemplate; context: {item: mailAccount, nameProp: 'name', type: DataType.MailAccount, icon: 'envelope-at'}"></ng-container>
}
}
@if (searchResults?.mail_rules.length) {
@if (searchResults()?.mail_rules.length) {
<h6 class="dropdown-header" i18n="@@searchResults.mailRules">Mail rules</h6>
@for (mailRule of searchResults.mail_rules; track mailRule.id) {
@for (mailRule of searchResults().mail_rules; track mailRule.id) {
<ng-container *ngTemplateOutlet="resultItemTemplate; context: {item: mailRule, nameProp: 'name', type: DataType.MailRule, icon: 'envelope'}"></ng-container>
}
}
@if (searchResults?.workflows.length) {
@if (searchResults()?.workflows.length) {
<h6 class="dropdown-header" i18n="@@searchResults.workflows">Workflows</h6>
@for (workflow of searchResults.workflows; track workflow.id) {
@for (workflow of searchResults().workflows; track workflow.id) {
<ng-container *ngTemplateOutlet="resultItemTemplate; context: {item: workflow, nameProp: 'name', type: DataType.Workflow, icon: 'boxes'}"></ng-container>
}
}
@@ -1,12 +1,7 @@
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
import { provideHttpClientTesting } from '@angular/common/http/testing'
import { ElementRef } from '@angular/core'
import {
ComponentFixture,
TestBed,
fakeAsync,
tick,
} from '@angular/core/testing'
import { ComponentFixture, TestBed } from '@angular/core/testing'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { Router } from '@angular/router'
import {
@@ -154,6 +149,7 @@ describe('GlobalSearchComponent', () => {
searchService = TestBed.inject(SearchService)
router = TestBed.inject(Router)
jest.spyOn(router, 'navigate').mockResolvedValue(true)
modalService = TestBed.inject(NgbModal)
documentService = TestBed.inject(DocumentService)
documentListViewService = TestBed.inject(DocumentListViewService)
@@ -170,7 +166,7 @@ describe('GlobalSearchComponent', () => {
document.dispatchEvent(new KeyboardEvent('keydown', { key: '/' }))
expect(focusSpy).toHaveBeenCalled()
component.searchResults = searchResults as any
component.searchResults.set(searchResults as any)
component.resultsDropdown.open()
fixture.detectChanges()
@@ -238,26 +234,26 @@ describe('GlobalSearchComponent', () => {
)
expect(component['currentItemIndex']).toBe(0)
component.searchResults = { total: 1 } as any
component.searchResults.set({ total: 1 } as any)
const primaryActionSpy = jest.spyOn(component, 'primaryAction')
component.searchInputKeyDown(new KeyboardEvent('keydown', { key: 'Enter' }))
expect(primaryActionSpy).toHaveBeenCalled()
component.query = 'test'
component.query.set('test')
const resetSpy = jest.spyOn(GlobalSearchComponent.prototype as any, 'reset')
component.searchInputKeyDown(
new KeyboardEvent('keydown', { key: 'Escape' })
)
expect(resetSpy).toHaveBeenCalled()
component.query = ''
component.query.set('')
const blurSpy = jest.spyOn(component.searchInput.nativeElement, 'blur')
component.searchInputKeyDown(
new KeyboardEvent('keydown', { key: 'Escape' })
)
expect(blurSpy).toHaveBeenCalled()
component.searchResults = { total: 1 } as any
component.searchResults.set({ total: 1 } as any)
component.resultsDropdown.open()
component.searchInputKeyDown(
@@ -268,28 +264,30 @@ describe('GlobalSearchComponent', () => {
component.dropdownKeyDown(new KeyboardEvent('keydown', { key: 'Escape' }))
expect(closeSpy).toHaveBeenCalled()
component.searchResults = searchResults as any
component.searchResults.set(searchResults as any)
component.resultsDropdown.open()
component.query = 'test'
component.query.set('test')
const advancedSearchSpy = jest.spyOn(component, 'runFullSearch')
component.searchInputKeyDown(new KeyboardEvent('keydown', { key: 'Enter' }))
expect(advancedSearchSpy).toHaveBeenCalled()
})
it('should search on query debounce', fakeAsync(() => {
it('should search on query debounce', () => {
jest.useFakeTimers()
const query = 'test'
const searchSpy = jest.spyOn(searchService, 'globalSearch')
searchSpy.mockReturnValue(of({} as any))
const dropdownOpenSpy = jest.spyOn(component.resultsDropdown, 'open')
component.queryDebounce.next(query)
tick(401)
jest.advanceTimersByTime(401)
expect(searchSpy).toHaveBeenCalledWith(query)
expect(dropdownOpenSpy).toHaveBeenCalled()
}))
jest.useRealTimers()
})
it('should support primary action', () => {
const object = { id: 1 }
const routerSpy = jest.spyOn(router, 'navigate')
const routerSpy = jest.mocked(router.navigate)
const modalSpy = jest.spyOn(modalService, 'open')
let modal: NgbModalRef
@@ -465,13 +463,13 @@ describe('GlobalSearchComponent', () => {
const closeSpy = jest.spyOn(component.resultsDropdown, 'close')
component['reset'](true)
expect(debounce).toHaveBeenCalledWith(null)
expect(component.searchResults).toBeNull()
expect(component.searchResults()).toBeNull()
expect(component['currentItemIndex']).toBe(-1)
expect(closeSpy).toHaveBeenCalled()
})
it('should support focus current item', () => {
component.searchResults = searchResults as any
component.searchResults.set(searchResults as any)
fixture.detectChanges()
const focusSpy = jest.spyOn(
component.primaryButtons.get(0).nativeElement,
@@ -489,7 +487,7 @@ describe('GlobalSearchComponent', () => {
})
it('should focus button on dropdown item hover', () => {
component.searchResults = searchResults as any
component.searchResults.set(searchResults as any)
fixture.detectChanges()
const item: ElementRef = component.resultItems.first
const focusSpy = jest.spyOn(
@@ -515,7 +513,7 @@ describe('GlobalSearchComponent', () => {
component.primaryAction(DataType.Document, { id: 2 }, event as any)
expect(openSpy).toHaveBeenCalledWith('/documents/2', '_blank')
component.searchResults = searchResults as any
component.searchResults.set(searchResults as any)
component.resultsDropdown.open()
fixture.detectChanges()
@@ -542,7 +540,7 @@ describe('GlobalSearchComponent', () => {
it('should support title content search and advanced search', () => {
const qfSpy = jest.spyOn(documentListViewService, 'quickFilter')
component.query = 'test'
component.query.set('test')
component.runFullSearch()
expect(qfSpy).toHaveBeenCalledWith([
{ rule_type: FILTER_SIMPLE_TEXT, value: 'test' },
@@ -552,7 +550,7 @@ describe('GlobalSearchComponent', () => {
SETTINGS_KEYS.SEARCH_FULL_TYPE,
GlobalSearchType.ADVANCED
)
component.query = 'test'
component.query.set('test')
component.runFullSearch()
expect(qfSpy).toHaveBeenCalledWith([
{ rule_type: FILTER_FULLTEXT_QUERY, value: 'test' },
@@ -7,6 +7,7 @@ import {
ViewChild,
ViewChildren,
inject,
signal,
} from '@angular/core'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { Router } from '@angular/router'
@@ -82,12 +83,12 @@ export class GlobalSearchComponent implements OnInit {
private locationStrategy = inject(LocationStrategy)
public DataType = DataType
public query: string
readonly query = signal<string>(null)
public queryDebounce: Subject<string>
public searchResults: GlobalSearchResult
readonly searchResults = signal<GlobalSearchResult>(null)
private currentItemIndex: number = -1
private domIndex: number = -1
public loading: boolean = false
readonly loading = signal(false)
@ViewChild('searchInput') searchInput: ElementRef
@ViewChild('resultsDropdown') resultsDropdown: NgbDropdown
@@ -96,6 +97,7 @@ export class GlobalSearchComponent implements OnInit {
@ViewChildren('secondaryButton') secondaryButtons: QueryList<ElementRef>
get useAdvancedForFullSearch(): boolean {
this.settingsService.trackChanges()
return (
this.settingsService.get(SETTINGS_KEYS.SEARCH_FULL_TYPE) ===
GlobalSearchType.ADVANCED
@@ -112,7 +114,7 @@ export class GlobalSearchComponent implements OnInit {
distinctUntilChanged()
)
.subscribe((text) => {
this.query = text
this.query.set(text)
if (text) this.search(text)
})
}
@@ -126,10 +128,10 @@ export class GlobalSearchComponent implements OnInit {
}
private search(query: string) {
this.loading = true
this.loading.set(true)
this.searchService.globalSearch(query.trim()).subscribe((results) => {
this.searchResults = results
this.loading = false
this.searchResults.set(results)
this.loading.set(false)
this.resultsDropdown.open()
})
}
@@ -205,7 +207,7 @@ export class GlobalSearchComponent implements OnInit {
editDialogComponent,
{ size }
)
modalRef.componentInstance.dialogMode = EditDialogMode.EDIT
modalRef.componentInstance.dialogMode.set(EditDialogMode.EDIT)
modalRef.componentInstance.object = object
modalRef.componentInstance.succeeded.subscribe(() => {
this.toastService.showInfo($localize`Successfully updated object.`)
@@ -243,7 +245,7 @@ export class GlobalSearchComponent implements OnInit {
editDialogComponent,
{ size }
)
modalRef.componentInstance.dialogMode = EditDialogMode.EDIT
modalRef.componentInstance.dialogMode.set(EditDialogMode.EDIT)
modalRef.componentInstance.object = object
modalRef.componentInstance.succeeded.subscribe(() => {
this.toastService.showInfo($localize`Successfully updated object.`)
@@ -256,8 +258,8 @@ export class GlobalSearchComponent implements OnInit {
private reset(close: boolean = false) {
this.queryDebounce.next(null)
this.query = null
this.searchResults = null
this.query.set(null)
this.searchResults.set(null)
this.currentItemIndex = -1
if (close) {
this.resultsDropdown.close()
@@ -292,7 +294,7 @@ export class GlobalSearchComponent implements OnInit {
public searchInputKeyDown(event: KeyboardEvent) {
if (
event.key === 'ArrowDown' &&
this.searchResults?.total &&
this.searchResults()?.total &&
this.resultsDropdown.isOpen()
) {
event.preventDefault()
@@ -300,22 +302,22 @@ export class GlobalSearchComponent implements OnInit {
this.setCurrentItem()
} else if (
event.key === 'ArrowUp' &&
this.searchResults?.total &&
this.searchResults()?.total &&
this.resultsDropdown.isOpen()
) {
event.preventDefault()
this.currentItemIndex = this.searchResults.total - 1
this.currentItemIndex = this.searchResults()?.total - 1
this.setCurrentItem()
} else if (event.key === 'Enter') {
if (this.searchResults?.total === 1 && this.resultsDropdown.isOpen()) {
if (this.searchResults()?.total === 1 && this.resultsDropdown.isOpen()) {
this.primaryButtons.first.nativeElement.click()
this.searchInput.nativeElement.blur()
} else if (this.query?.length) {
} else if (this.query()?.length) {
this.runFullSearch()
this.reset(true)
}
} else if (event.key === 'Escape' && !this.resultsDropdown.isOpen()) {
if (this.query?.length) {
if (this.query()?.length) {
this.reset(true)
} else {
this.searchInput.nativeElement.blur()
@@ -325,14 +327,14 @@ export class GlobalSearchComponent implements OnInit {
public dropdownKeyDown(event: KeyboardEvent) {
if (
this.searchResults?.total &&
this.searchResults()?.total &&
this.resultsDropdown.isOpen() &&
document.activeElement !== this.searchInput.nativeElement
) {
if (event.key === 'ArrowDown') {
event.preventDefault()
event.stopImmediatePropagation()
if (this.currentItemIndex < this.searchResults.total - 1) {
if (this.currentItemIndex < this.searchResults()?.total - 1) {
this.currentItemIndex++
this.setCurrentItem()
} else {
@@ -412,10 +414,10 @@ export class GlobalSearchComponent implements OnInit {
? FILTER_FULLTEXT_QUERY
: FILTER_SIMPLE_TEXT
this.documentService.searchQuery = this.useAdvancedForFullSearch
? this.query
? this.query()
: ''
this.documentListViewService.quickFilter([
{ rule_type: ruleType, value: this.query },
{ rule_type: ruleType, value: this.query() },
])
this.reset(true)
}
@@ -1,7 +1,7 @@
<li ngbDropdown class="nav-item mx-1" (openChange)="onOpenChange($event)">
@if (toasts.length) {
<span class="badge rounded-pill z-3 pe-none bg-secondary me-2 position-absolute top-0 left-0">{{ toasts.length }}</span>
@if (toasts().length) {
<span class="badge rounded-pill z-3 pe-none bg-secondary me-2 position-absolute top-0 left-0">{{ toasts().length }}</span>
}
<button class="btn border-0" id="notificationsDropdown" ngbDropdownToggle>
<i-bs width="1.3em" height="1.3em" name="bell"></i-bs>
@@ -12,15 +12,15 @@
<div class="btn-group ms-auto">
<button class="btn btn-sm btn-outline-secondary mb-2 ms-auto"
(click)="toastService.clearToasts()"
[disabled]="toasts.length === 0"
[disabled]="toasts().length === 0"
i18n>Clear All</button>
</div>
</div>
@if (toasts.length === 0) {
@if (toasts().length === 0) {
<p class="text-center mb-0 small text-muted"><em i18n>No notifications</em></p>
}
<div class="scroll-list">
@for (toast of toasts; track toast.id) {
@for (toast of toasts(); track toast.id) {
<pngx-toast [autohide]="false" [toast]="toast" (hidden)="onHidden(toast)" (closed)="toastService.closeToast(toast)"></pngx-toast>
}
</div>
@@ -1,12 +1,6 @@
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
import { provideHttpClientTesting } from '@angular/common/http/testing'
import {
ComponentFixture,
TestBed,
discardPeriodicTasks,
fakeAsync,
flush,
} from '@angular/core/testing'
import { ComponentFixture, TestBed } from '@angular/core/testing'
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
import { Subject } from 'rxjs'
import { Toast, ToastService } from 'src/app/services/toast.service'
@@ -42,7 +36,8 @@ describe('ToastsDropdownComponent', () => {
let component: ToastsDropdownComponent
let fixture: ComponentFixture<ToastsDropdownComponent>
let toastService: ToastService
let toastsSubject: Subject<Toast[]> = new Subject()
let toastsSubject: Subject<Toast[]>
let getToastsSpy: jest.SpyInstance
beforeEach(async () => {
TestBed.configureTestingModule({
@@ -56,57 +51,43 @@ describe('ToastsDropdownComponent', () => {
],
}).compileComponents()
fixture = TestBed.createComponent(ToastsDropdownComponent)
toastService = TestBed.inject(ToastService)
jest.spyOn(toastService, 'getToasts').mockReturnValue(toastsSubject)
toastsSubject = new Subject()
getToastsSpy = jest
.spyOn(toastService, 'getToasts')
.mockReturnValue(toastsSubject)
fixture = TestBed.createComponent(ToastsDropdownComponent)
component = fixture.componentInstance
fixture.detectChanges()
})
it('should call getToasts and return toasts', fakeAsync(() => {
const spy = jest.spyOn(toastService, 'getToasts')
component.ngOnInit()
it('should call getToasts and return toasts', () => {
toastsSubject.next(toasts)
fixture.detectChanges()
expect(spy).toHaveBeenCalled()
expect(component.toasts).toContainEqual({
expect(getToastsSpy).toHaveBeenCalled()
expect(component.toasts()).toContainEqual({
id: 'abc-123',
content: 'foo bar',
delay: 5000,
})
})
component.ngOnDestroy()
flush()
discardPeriodicTasks()
}))
it('should show a toast', fakeAsync(() => {
component.ngOnInit()
it('should show a toast', () => {
toastsSubject.next(toasts)
fixture.detectChanges()
expect(fixture.nativeElement.textContent).toContain('foo bar')
})
component.ngOnDestroy()
flush()
discardPeriodicTasks()
}))
it('should toggle suppressPopupToasts', fakeAsync((finish) => {
component.ngOnInit()
it('should toggle suppressPopupToasts', () => {
fixture.detectChanges()
toastsSubject.next(toasts)
const spy = jest.spyOn(toastService, 'suppressPopupToasts', 'set')
component.onOpenChange(true)
expect(spy).toHaveBeenCalledWith(true)
component.ngOnDestroy()
flush()
discardPeriodicTasks()
}))
})
})
@@ -1,10 +1,11 @@
import { Component, OnDestroy, OnInit, inject } from '@angular/core'
import { Component, inject } from '@angular/core'
import { toSignal } from '@angular/core/rxjs-interop'
import {
NgbDropdownModule,
NgbProgressbarModule,
} from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { Subscription } from 'rxjs'
import { map } from 'rxjs'
import { Toast, ToastService } from 'src/app/services/toast.service'
import { ToastComponent } from '../../common/toast/toast.component'
@@ -19,22 +20,13 @@ import { ToastComponent } from '../../common/toast/toast.component'
NgxBootstrapIconsModule,
],
})
export class ToastsDropdownComponent implements OnInit, OnDestroy {
export class ToastsDropdownComponent {
toastService = inject(ToastService)
private subscription: Subscription
public toasts: Toast[] = []
ngOnDestroy(): void {
this.subscription?.unsubscribe()
}
ngOnInit(): void {
this.subscription = this.toastService.getToasts().subscribe((toasts) => {
this.toasts = [...toasts]
})
}
readonly toasts = toSignal(
this.toastService.getToasts().pipe(map((toasts) => [...toasts])),
{ initialValue: [] as Toast[] }
)
onOpenChange(open: boolean): void {
this.toastService.suppressPopupToasts = open