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
@@ -59,10 +59,10 @@
<div [ngbNavOutlet]="nav" class="border-start border-end border-bottom p-3 mb-3 shadow-sm"></div>
<div class="btn-toolbar" role="toolbar">
<div class="btn-group me-2">
<button type="button" (click)="discardChanges()" class="btn btn-outline-secondary" [disabled]="loading || (isDirty$ | async) === false" i18n>Cancel</button>
<button type="button" (click)="discardChanges()" class="btn btn-outline-secondary" [disabled]="loading() || (isDirty$ | async) === false" i18n>Cancel</button>
</div>
<div class="btn-group">
<button type="submit" class="btn btn-primary" [disabled]="loading || !configForm.valid || (isDirty$ | async) === false" i18n>Save</button>
<button type="submit" class="btn btn-primary" [disabled]="loading() || !configForm.valid || (isDirty$ | async) === false" i18n>Save</button>
</div>
</div>
</form>
@@ -97,11 +97,11 @@ export class ConfigComponent
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe({
next: (config) => {
this.loading = false
this.loading.set(false)
this.initialize(config)
},
error: (e) => {
this.loading = false
this.loading.set(false)
this.toastService.showError($localize`Error retrieving config`, e)
},
})
@@ -162,20 +162,20 @@ export class ConfigComponent
}
public saveConfig() {
this.loading = true
this.loading.set(true)
this.configService
.saveConfig(this.configForm.value as PaperlessConfig)
.pipe(takeUntil(this.unsubscribeNotifier), first())
.subscribe({
next: (config) => {
this.loading = false
this.loading.set(false)
this.initialize(config)
this.store.next(config)
this.settingsService.initializeSettings().subscribe()
this.toastService.showInfo($localize`Configuration updated`)
},
error: (e) => {
this.loading = false
this.loading.set(false)
this.toastService.showError(
$localize`An error occurred updating configuration`,
e
@@ -189,20 +189,20 @@ export class ConfigComponent
}
public uploadFile(file: File, key: string) {
this.loading = true
this.loading.set(true)
this.configService
.uploadFile(file, this.configForm.value['id'], key)
.pipe(takeUntil(this.unsubscribeNotifier), first())
.subscribe({
next: (config) => {
this.loading = false
this.loading.set(false)
this.initialize(config)
this.store.next(config)
this.settingsService.initializeSettings().subscribe()
this.toastService.showInfo($localize`File successfully updated`)
},
error: (e) => {
this.loading = false
this.loading.set(false)
this.toastService.showError(
$localize`An error occurred uploading file`,
e
@@ -11,30 +11,30 @@
type="number"
min="100"
step="100"
[(ngModel)]="limit"
(ngModelChange)="onLimitChange($event)"
[ngModel]="limit()"
(ngModelChange)="limit.set($event); onLimitChange($event)"
style="width: 100px;">
<span class="input-group-text text-muted" i18n>lines</span>
</div>
<div class="form-check form-switch mt-1">
<input class="form-check-input" type="checkbox" role="switch" [(ngModel)]="autoRefreshEnabled">
<input class="form-check-input" type="checkbox" role="switch" [ngModel]="autoRefreshEnabled()" (ngModelChange)="autoRefreshEnabled.set($event)">
<label class="form-check-label" for="autoRefreshSwitch" i18n>Auto refresh</label>
</div>
</div>
</pngx-page-header>
<ul ngbNav #nav="ngbNav" [(activeId)]="activeLog" (activeIdChange)="reloadLogs()" class="nav-tabs">
@for (logFile of logFiles; track logFile) {
<ul ngbNav #nav="ngbNav" [activeId]="activeLog()" (activeIdChange)="activeLog.set($event); reloadLogs()" class="nav-tabs">
@for (logFile of logFiles(); track logFile) {
<li [ngbNavItem]="logFile">
<a ngbNavLink>
{{logFile}}.log
</a>
</li>
}
@if (loading || !logFiles.length) {
@if (loading() || !logFiles().length) {
<div class="ps-2 d-flex align-items-center">
<div class="spinner-border spinner-border-sm me-2" role="status"></div>
@if (!logFiles.length) {
@if (!logFiles().length) {
<ng-container i18n>Loading...</ng-container>
}
</div>
@@ -42,13 +42,13 @@
</ul>
<div #logContainer class="bg-dark text-light font-monospace log-container p-3" (scroll)="onScroll()">
@if (loading && !logFiles.length) {
@if (loading() && !logFiles().length) {
<div>
<div class="spinner-border spinner-border-sm me-2" role="status"></div>
<ng-container i18n>Loading...</ng-container>
</div>
} @else {
@for (log of logs; track log) {
@for (log of logs(); track log) {
<p class="m-0 p-0" [ngClass]="'log-entry-' + log.level">{{log.message}}</p>
}
}
@@ -56,7 +56,7 @@
<button
type="button"
class="btn btn-sm btn-secondary jump-to-bottom position-fixed bottom-0 end-0 m-5"
[class.visible]="showJumpToBottom"
[class.visible]="showJumpToBottom()"
(click)="scrollToBottom()"
>
<span i18n>Jump to bottom</span>
@@ -86,7 +86,7 @@ describe('LogsComponent', () => {
throwError(() => new Error('error getting logs'))
)
component.reloadLogs()
expect(component.logs).toHaveLength(0)
expect(component.logs()).toHaveLength(0)
})
it('should auto refresh, allow toggle', () => {
@@ -97,7 +97,7 @@ describe('LogsComponent', () => {
jest.advanceTimersByTime(6000)
expect(reloadSpy).toHaveBeenCalledTimes(2)
component.autoRefreshEnabled = false
component.autoRefreshEnabled.set(false)
jest.advanceTimersByTime(6000)
expect(reloadSpy).toHaveBeenCalledTimes(2)
})
@@ -112,9 +112,9 @@ describe('LogsComponent', () => {
})
it('should update jump to bottom visibility on scroll', () => {
component.showJumpToBottom = false
component.showJumpToBottom.set(false)
jest.spyOn(component as any, 'isNearBottom').mockReturnValue(false)
component.onScroll()
expect(component.showJumpToBottom).toBe(true)
expect(component.showJumpToBottom()).toBe(true)
})
})
@@ -7,6 +7,7 @@ import {
OnInit,
ViewChild,
inject,
signal,
} from '@angular/core'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { NgbNavModule } from '@ng-bootstrap/ng-bootstrap'
@@ -34,17 +35,17 @@ export class LogsComponent
private logService = inject(LogService)
private changedetectorRef = inject(ChangeDetectorRef)
public logs: Array<{ message: string; level: number }> = []
readonly logs = signal<Array<{ message: string; level: number }>>([])
public logFiles: string[] = []
readonly logFiles = signal<string[]>([])
public activeLog: string
readonly activeLog = signal<string>(undefined)
public autoRefreshEnabled: boolean = true
readonly autoRefreshEnabled = signal<boolean>(true)
public limit: number = 5000
readonly limit = signal<number>(5000)
public showJumpToBottom = false
readonly showJumpToBottom = signal<boolean>(false)
private readonly limitChange$ = new Subject<number>()
@@ -59,15 +60,15 @@ export class LogsComponent
.list()
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe((result) => {
this.logFiles = result
this.loading = false
if (this.logFiles.length > 0) {
this.activeLog = this.logFiles[0]
this.logFiles.set(result)
this.loading.set(false)
if (this.logFiles().length > 0) {
this.activeLog.set(this.logFiles()[0])
this.reloadLogs()
}
timer(5000, 5000)
.pipe(
filter(() => this.autoRefreshEnabled),
filter(() => this.autoRefreshEnabled()),
takeUntil(this.unsubscribeNotifier)
)
.subscribe(() => {
@@ -85,19 +86,19 @@ export class LogsComponent
}
reloadLogs() {
this.loading = true
this.loading.set(true)
const shouldStickToBottom = this.isNearBottom()
this.logService
.get(this.activeLog, this.limit)
.get(this.activeLog(), this.limit())
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe({
next: (result) => {
this.loading = false
this.loading.set(false)
const parsed = this.parseLogsWithLevel(result)
const hasChanges =
parsed.length !== this.logs.length ||
parsed.length !== this.logs().length ||
parsed.some((log, idx) => {
const current = this.logs[idx]
const current = this.logs()[idx]
return (
!current ||
current.message !== log.message ||
@@ -105,16 +106,16 @@ export class LogsComponent
)
})
if (hasChanges) {
this.logs = parsed
this.logs.set(parsed)
if (shouldStickToBottom) {
this.scrollToBottom()
}
this.showJumpToBottom = !shouldStickToBottom
this.showJumpToBottom.set(!shouldStickToBottom)
}
},
error: () => {
this.logs = []
this.loading = false
this.logs.set([])
this.loading.set(false)
},
})
}
@@ -149,7 +150,7 @@ export class LogsComponent
}
this.changedetectorRef.detectChanges()
viewport.scrollTop = viewport.scrollHeight
this.showJumpToBottom = false
this.showJumpToBottom.set(false)
}
private isNearBottom(): boolean {
@@ -162,6 +163,6 @@ export class LogsComponent
}
onScroll(): void {
this.showJumpToBottom = !this.isNearBottom()
this.showJumpToBottom.set(!this.isNearBottom())
}
}
@@ -9,8 +9,8 @@
</button>
@if (canViewSystemStatus) {
<button class="btn btn-sm btn-outline-primary position-relative ms-md-5 me-1" (click)="showSystemStatus()"
[disabled]="!systemStatus">
@if (!systemStatus) {
[disabled]="!systemStatus()">
@if (!systemStatus()) {
<div class="spinner-border spinner-border-sm me-2 h-75" role="status"></div>
} @else {
<i-bs class="me-2" name="card-checklist"></i-bs>
@@ -313,7 +313,7 @@
<span i18n>Default Owner</span>
</div>
<div class="col-md-5">
<pngx-input-select [items]="users" bindLabel="username" formControlName="defaultPermsOwner" [allowNull]="true"></pngx-input-select>
<pngx-input-select [items]="users()" bindLabel="username" formControlName="defaultPermsOwner" [allowNull]="true"></pngx-input-select>
<small class="form-text text-muted text-end d-block mt-n2" i18n>Objects without an owner can be viewed and edited by all users</small>
</div>
</div>
@@ -99,6 +99,13 @@ const status: SystemStatus = {
llmindex_status: SystemStatusItemStatus.DISABLED,
llmindex_last_modified: new Date().toISOString(),
llmindex_error: null,
summary: {
days: 30,
total_count: 12,
pending_count: 1,
success_count: 10,
failure_count: 1,
},
},
}
@@ -161,7 +168,7 @@ describe('SettingsComponent', () => {
viewportScroller = TestBed.inject(ViewportScroller)
toastService = TestBed.inject(ToastService)
settingsService = TestBed.inject(SettingsService)
settingsService.currentUser = users[0]
settingsService.currentUser.set(users[0])
userService = TestBed.inject(UserService)
permissionsService = TestBed.inject(PermissionsService)
modalService = TestBed.inject(NgbModal)
@@ -202,7 +209,7 @@ describe('SettingsComponent', () => {
fixture.detectChanges()
}
it('should support tabbed settings & change URL, prevent navigation if dirty confirmation rejected', () => {
it('should support tabbed settings & change URL, prevent navigation if dirty confirmation rejected', async () => {
completeSetup()
const navigateSpy = jest.spyOn(router, 'navigate')
const tabButtons = fixture.debugElement.queryAll(By.directive(NgbNavLink))
@@ -210,16 +217,19 @@ describe('SettingsComponent', () => {
expect(navigateSpy).toHaveBeenCalledWith(['settings', 'documents'])
tabButtons[2].nativeElement.dispatchEvent(new MouseEvent('click'))
expect(navigateSpy).toHaveBeenCalledWith(['settings', 'permissions'])
await fixture.whenStable()
const initSpy = jest.spyOn(component, 'initialize')
component.isDirty = true // mock dirty
navigateSpy.mockResolvedValueOnce(false) // nav rejected cause dirty
tabButtons[0].nativeElement.dispatchEvent(new MouseEvent('click'))
await fixture.whenStable()
expect(navigateSpy).toHaveBeenCalledWith(['settings', 'general'])
expect(initSpy).not.toHaveBeenCalled()
navigateSpy.mockResolvedValueOnce(true) // nav accepted even though dirty
tabButtons[2].nativeElement.dispatchEvent(new MouseEvent('click'))
await fixture.whenStable()
expect(navigateSpy).toHaveBeenCalledWith(['settings', 'permissions'])
expect(initSpy).toHaveBeenCalled()
})
@@ -340,13 +350,13 @@ describe('SettingsComponent', () => {
type === PermissionType.SystemMonitoring
)
completeSetup()
expect(component['systemStatus']).toEqual(status) // private
expect(component.systemStatus()).toEqual(status)
expect(component.systemStatusHasErrors).toBeTruthy()
// coverage
component['systemStatus'].database.status = SystemStatusItemStatus.OK
component['systemStatus'].tasks.redis_status = SystemStatusItemStatus.OK
component['systemStatus'].tasks.celery_status = SystemStatusItemStatus.OK
component['systemStatus'].tasks.sanity_check_status =
component.systemStatus().database.status = SystemStatusItemStatus.OK
component.systemStatus().tasks.redis_status = SystemStatusItemStatus.OK
component.systemStatus().tasks.celery_status = SystemStatusItemStatus.OK
component.systemStatus().tasks.sanity_check_status =
SystemStatusItemStatus.OK
expect(component.systemStatusHasErrors).toBeFalsy()
})
@@ -6,6 +6,7 @@ import {
OnDestroy,
OnInit,
inject,
signal,
} from '@angular/core'
import {
FormControl,
@@ -189,10 +190,10 @@ export class SettingsComponent
unsubscribeNotifier: Subject<any> = new Subject()
savePending: boolean = false
users: User[]
groups: Group[]
readonly users = signal<User[]>(undefined)
readonly groups = signal<Group[]>(undefined)
public systemStatus: SystemStatus
public readonly systemStatus = signal<SystemStatus>(undefined)
public readonly GlobalSearchType = GlobalSearchType
@@ -203,16 +204,18 @@ export class SettingsComponent
public readonly documentDetailFieldOptions = documentDetailFieldOptions
get systemStatusHasErrors(): boolean {
const status = this.systemStatus()
if (!status) {
return false
}
return (
this.systemStatus.database.status === SystemStatusItemStatus.ERROR ||
this.systemStatus.tasks.redis_status === SystemStatusItemStatus.ERROR ||
this.systemStatus.tasks.celery_status === SystemStatusItemStatus.ERROR ||
this.systemStatus.tasks.index_status === SystemStatusItemStatus.ERROR ||
this.systemStatus.tasks.classifier_status ===
SystemStatusItemStatus.ERROR ||
this.systemStatus.tasks.sanity_check_status ===
SystemStatusItemStatus.ERROR ||
this.systemStatus.websocket_connected === SystemStatusItemStatus.ERROR
status.database.status === SystemStatusItemStatus.ERROR ||
status.tasks.redis_status === SystemStatusItemStatus.ERROR ||
status.tasks.celery_status === SystemStatusItemStatus.ERROR ||
status.tasks.index_status === SystemStatusItemStatus.ERROR ||
status.tasks.classifier_status === SystemStatusItemStatus.ERROR ||
status.tasks.sanity_check_status === SystemStatusItemStatus.ERROR ||
status.websocket_connected === SystemStatusItemStatus.ERROR
)
}
@@ -246,7 +249,7 @@ export class SettingsComponent
.pipe(first())
.subscribe({
next: (r) => {
this.users = r.results
this.users.set(r.results)
},
error: (e) => {
this.toastService.showError($localize`Error retrieving users`, e)
@@ -265,7 +268,7 @@ export class SettingsComponent
.pipe(first())
.subscribe({
next: (r) => {
this.groups = r.results
this.groups.set(r.results)
},
error: (e) => {
this.toastService.showError($localize`Error retrieving groups`, e)
@@ -431,7 +434,7 @@ export class SettingsComponent
if (this.canViewSystemStatus) {
this.systemStatusService.get().subscribe((status) => {
this.systemStatus = status
this.systemStatus.set(status)
})
}
}
@@ -664,6 +667,6 @@ export class SettingsComponent
size: 'xl',
}
)
modal.componentInstance.status = this.systemStatus
modal.componentInstance.status.set(this.systemStatus())
}
}
@@ -11,7 +11,7 @@
<button class="btn btn-sm btn-outline-primary me-2" (click)="dismissTasks()" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.PaperlessTask }" [disabled]="visibleTasks.length === 0">
<i-bs name="check2-all" class="me-1"></i-bs>{{dismissButtonText}}
</button>
<button class="btn btn-sm btn-outline-primary me-2" (click)="dismissAllTasks()" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.PaperlessTask }" [disabled]="totalTasks === 0">
<button class="btn btn-sm btn-outline-primary me-2" (click)="dismissAllTasks()" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.PaperlessTask }" [disabled]="totalTasks() === 0">
<i-bs name="check2-all" class="me-1"></i-bs><ng-container i18n>Dismiss all</ng-container>
</button>
<div class="form-check form-switch mb-0 ms-2">
@@ -107,7 +107,7 @@
<ngb-pagination
[pageSize]="pageSize"
[collectionSize]="totalTasks"
[collectionSize]="totalTasks()"
[page]="page"
[maxSize]="5"
[rotate]="true"
@@ -362,7 +362,7 @@ describe('TasksComponent', () => {
)
req.flush({ count: 2, results: [tasks[0], tasks[1]] })
expect(component.totalTasks).toBe(2)
expect(component.totalTasks()).toBe(2)
})
it('should apply task type and trigger source filters to the server-side task query', () => {
@@ -435,8 +435,8 @@ describe('TasksComponent', () => {
.flush(pageTwoTasks)
expect(component.page).toBe(2)
expect(component.totalTasks).toBe(30)
expect(component.pagedTasks).toEqual([tasks[0]])
expect(component.totalTasks()).toBe(30)
expect(component.pagedTasks()).toEqual([tasks[0]])
})
it('should not replace section counts with current-page counts', () => {
@@ -1,5 +1,5 @@
import { JsonPipe, NgTemplateOutlet } from '@angular/common'
import { Component, inject, OnDestroy, OnInit } from '@angular/core'
import { Component, inject, OnDestroy, OnInit, signal } from '@angular/core'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { Router, RouterLink } from '@angular/router'
import {
@@ -166,14 +166,14 @@ export class TasksComponent
public autoRefreshEnabled: boolean = true
public readonly pageSize = 25
public page: number = 1
public totalTasks: number = 0
public sectionCounts: Record<TaskSection, number> = {
readonly totalTasks = signal(0)
readonly sectionCounts = signal<Record<TaskSection, number>>({
[TaskSection.All]: 0,
[TaskSection.NeedsAttention]: 0,
[TaskSection.InProgress]: 0,
[TaskSection.Completed]: 0,
}
public pagedTasks: PaperlessTask[] = []
})
readonly pagedTasks = signal<PaperlessTask[]>([])
public selectedSection: TaskSection = TaskSection.All
public selectedTaskType: PaperlessTaskType | null = null
public selectedTriggerSource: PaperlessTaskTriggerSource | null = null
@@ -346,7 +346,7 @@ export class TasksComponent
backdrop: 'static',
})
modal.componentInstance.title = $localize`Confirm Dismiss All`
modal.componentInstance.messageBold = $localize`Dismiss all ${this.totalTasks} tasks?`
modal.componentInstance.messageBold = $localize`Dismiss all ${this.totalTasks()} tasks?`
modal.componentInstance.btnClass = 'btn-warning'
modal.componentInstance.btnCaption = $localize`Dismiss`
modal.componentInstance.confirmClicked.pipe(first()).subscribe(() => {
@@ -465,7 +465,7 @@ export class TasksComponent
}
tasksForSection(section: TaskSection): PaperlessTask[] {
let tasks = this.pagedTasks.filter((task) =>
let tasks = this.pagedTasks().filter((task) =>
this.taskBelongsToSection(task, section)
)
@@ -477,7 +477,14 @@ export class TasksComponent
}
sectionCount(section: TaskSection): number {
return this.sectionCounts[section]
return this.sectionCounts()[section]
}
private setSectionCount(section: TaskSection, count: number) {
this.sectionCounts.update((counts) => ({
...counts,
[section]: count,
}))
}
sectionShowsResults(section: TaskSection): boolean {
@@ -652,7 +659,7 @@ export class TasksComponent
? this.sections
: [this.selectedSection]
return this.pagedTasks.filter(
return this.pagedTasks().filter(
(task) =>
sections.some((section) => this.taskBelongsToSection(task, section)) &&
this.taskMatchesFilters(task, { taskType, triggerSource })
@@ -664,10 +671,12 @@ export class TasksComponent
.statusCounts(this.getParamsForSection(TaskSection.All))
.pipe(first(), takeUntil(this.unsubscribeNotifier))
.subscribe((counts) => {
this.sectionCounts[TaskSection.All] = counts.all
this.sectionCounts[TaskSection.NeedsAttention] = counts.needs_attention
this.sectionCounts[TaskSection.InProgress] = counts.in_progress
this.sectionCounts[TaskSection.Completed] = counts.completed
this.sectionCounts.set({
[TaskSection.All]: counts.all,
[TaskSection.NeedsAttention]: counts.needs_attention,
[TaskSection.InProgress]: counts.in_progress,
[TaskSection.Completed]: counts.completed,
})
})
}
@@ -723,7 +732,7 @@ export class TasksComponent
this.reloadSectionCounts()
this.loading = true
this.loading.set(true)
this.tasksService
.list(
this.page,
@@ -733,24 +742,24 @@ export class TasksComponent
.pipe(first(), takeUntil(this.unsubscribeNotifier))
.subscribe({
next: (result) => {
this.pagedTasks = result.results
this.totalTasks = result.count
this.sectionCounts[TaskSection.All] = result.count
this.pagedTasks.set(result.results)
this.totalTasks.set(result.count)
this.setSectionCount(TaskSection.All, result.count)
if (this.selectedSection !== TaskSection.All) {
this.sectionCounts[this.selectedSection] = result.count
this.setSectionCount(this.selectedSection, result.count)
}
this.loading = false
this.loading.set(false)
if (
this.page > 1 &&
this.pagedTasks.length === 0 &&
this.totalTasks > 0
this.pagedTasks().length === 0 &&
this.totalTasks() > 0
) {
this.page -= 1
this.reloadPage()
}
},
error: () => {
this.loading = false
this.loading.set(false)
},
})
}
@@ -4,22 +4,22 @@
info="Manage trashed documents that are pending deletion."
i18n-info
infoLink="usage/#document-trash">
<button class="btn btn-sm btn-outline-secondary" (click)="clearSelection()" [hidden]="selectedDocuments.size === 0">
<button class="btn btn-sm btn-outline-secondary" (click)="clearSelection()" [hidden]="selectedDocuments().size === 0">
<i-bs name="x" class="me-1"></i-bs><ng-container i18n>Clear selection</ng-container>
</button>
<button type="button" class="btn btn-sm btn-outline-primary" (click)="restoreAll(selectedDocuments)" [disabled]="selectedDocuments.size === 0">
<button type="button" class="btn btn-sm btn-outline-primary" (click)="restoreAll(selectedDocuments())" [disabled]="selectedDocuments().size === 0">
<i-bs name="arrow-counterclockwise" class="me-1"></i-bs><ng-container i18n>Restore selected</ng-container>
</button>
<button type="button" class="btn btn-sm btn-outline-danger" (click)="emptyTrash(selectedDocuments)" [disabled]="selectedDocuments.size === 0">
<button type="button" class="btn btn-sm btn-outline-danger" (click)="emptyTrash(selectedDocuments())" [disabled]="selectedDocuments().size === 0">
<i-bs name="trash" class="me-1"></i-bs><ng-container i18n>Delete selected</ng-container>
</button>
<button type="button" class="btn btn-sm btn-outline-danger" (click)="emptyTrash()" [disabled]="documentsInTrash.length === 0">
<button type="button" class="btn btn-sm btn-outline-danger" (click)="emptyTrash()" [disabled]="documentsInTrash().length === 0">
<i-bs name="trash" class="me-1"></i-bs><ng-container i18n>Empty trash</ng-container>
</button>
</pngx-page-header>
<div class="row mb-3">
<ngb-pagination class="col-auto" [pageSize]="25" [collectionSize]="totalDocuments" [(page)]="page" [maxSize]="5" (pageChange)="reload()" size="sm" aria-label="Pagination"></ngb-pagination>
<ngb-pagination class="col-auto" [pageSize]="25" [collectionSize]="totalDocuments()" [page]="page()" [maxSize]="5" (pageChange)="page.set($event); reload()" size="sm" aria-label="Pagination"></ngb-pagination>
</div>
<div class="card border table-responsive mb-3">
@@ -28,7 +28,7 @@
<tr>
<th scope="col">
<div class="form-check m-0 ms-2 me-n2">
<input type="checkbox" class="form-check-input" id="all-objects" [(ngModel)]="allToggled" [disabled]="documentsInTrash.length === 0" (click)="toggleAll($event); $event.stopPropagation();">
<input type="checkbox" class="form-check-input" id="all-objects" [ngModel]="allToggled()" (ngModelChange)="allToggled.set($event)" [disabled]="documentsInTrash().length === 0" (click)="toggleAll($event); $event.stopPropagation();">
<label class="form-check-label" for="all-objects"></label>
</div>
</th>
@@ -38,7 +38,7 @@
</tr>
</thead>
<tbody>
@if (loading) {
@if (loading()) {
<tr>
<td colspan="5">
<div class="spinner-border spinner-border-sm me-2" role="status"></div>
@@ -46,11 +46,11 @@
</td>
</tr>
}
@for (document of documentsInTrash; track document.id) {
<tr (click)="toggleSelected(document); $event.stopPropagation();" (mouseleave)="popupPreview.close()" class="data-row fade" [class.show]="show">
@for (document of documentsInTrash(); track document.id) {
<tr (click)="toggleSelected(document); $event.stopPropagation();" (mouseleave)="popupPreview.close()" class="data-row fade" [class.show]="show()">
<td>
<div class="form-check m-0 ms-2 me-n2">
<input type="checkbox" class="form-check-input" id="{{document.id}}" [checked]="selectedDocuments.has(document.id)" (click)="toggleSelected(document); $event.stopPropagation();">
<input type="checkbox" class="form-check-input" id="{{document.id}}" [checked]="selectedDocuments().has(document.id)" (click)="toggleSelected(document); $event.stopPropagation();">
<label class="form-check-label" for="{{document.id}}"></label>
</div>
</td>
@@ -88,16 +88,16 @@
</table>
</div>
@if (!loading) {
@if (!loading()) {
<div class="d-flex mb-2">
<div>
<ng-container i18n>{totalDocuments, plural, =1 {One document in trash} other {{{totalDocuments || 0}} total documents in trash}}</ng-container>
@if (selectedDocuments.size > 0) {
&nbsp;({{selectedDocuments.size}} selected)
<ng-container i18n>{totalDocuments(), plural, =1 {One document in trash} other {{{totalDocuments() || 0}} total documents in trash}}</ng-container>
@if (selectedDocuments().size > 0) {
&nbsp;({{selectedDocuments().size}} selected)
}
</div>
@if (documentsInTrash.length > 20) {
<ngb-pagination class="ms-auto" [pageSize]="25" [collectionSize]="totalDocuments" [(page)]="page" [maxSize]="5" (pageChange)="reload()" size="sm" aria-label="Pagination"></ngb-pagination>
@if (documentsInTrash().length > 20) {
<ngb-pagination class="ms-auto" [pageSize]="25" [collectionSize]="totalDocuments()" [page]="page()" [maxSize]="5" (pageChange)="page.set($event); reload()" size="sm" aria-label="Pagination"></ngb-pagination>
}
</div>
}
@@ -60,10 +60,15 @@ describe('TrashComponent', () => {
modalService = TestBed.inject(NgbModal)
toastService = TestBed.inject(ToastService)
router = TestBed.inject(Router)
jest.spyOn(router, 'navigate').mockResolvedValue(true)
component = fixture.componentInstance
fixture.detectChanges()
})
afterEach(() => {
jest.useRealTimers()
})
it('should call correct service method on reload', () => {
jest.useFakeTimers()
const trashSpy = jest.spyOn(trashService, 'getTrash')
@@ -77,7 +82,7 @@ describe('TrashComponent', () => {
component.reload()
jest.advanceTimersByTime(100)
expect(trashSpy).toHaveBeenCalled()
expect(component.documentsInTrash).toEqual(documentsInTrash)
expect(component.documentsInTrash()).toEqual(documentsInTrash)
})
it('should support delete document, show error if needed', () => {
@@ -179,8 +184,8 @@ describe('TrashComponent', () => {
})
it('should support toggle all items in view', () => {
component.documentsInTrash = documentsInTrash
expect(component.selectedDocuments.size).toEqual(0)
component.documentsInTrash.set(documentsInTrash)
expect(component.selectedDocuments().size).toEqual(0)
const toggleAllSpy = jest.spyOn(component, 'toggleAll')
const checkButton = fixture.debugElement.queryAll(
By.css('input.form-check-input')
@@ -189,21 +194,21 @@ describe('TrashComponent', () => {
checkButton.nativeElement.checked = true
checkButton.nativeElement.dispatchEvent(new Event('click'))
expect(toggleAllSpy).toHaveBeenCalled()
expect(component.selectedDocuments.size).toEqual(documentsInTrash.length)
expect(component.selectedDocuments().size).toEqual(documentsInTrash.length)
})
it('should support toggle item', () => {
component.selectedDocuments = new Set([1])
component.selectedDocuments.set(new Set([1]))
component.toggleSelected(documentsInTrash[0])
expect(component.selectedDocuments.size).toEqual(0)
expect(component.selectedDocuments().size).toEqual(0)
component.toggleSelected(documentsInTrash[0])
expect(component.selectedDocuments.size).toEqual(1)
expect(component.selectedDocuments().size).toEqual(1)
})
it('should support clear selection', () => {
component.selectedDocuments = new Set([1])
component.selectedDocuments.set(new Set([1]))
component.clearSelection()
expect(component.selectedDocuments.size).toEqual(0)
expect(component.selectedDocuments().size).toEqual(0)
})
it('should correctly display days remaining', () => {
@@ -1,4 +1,4 @@
import { Component, OnDestroy, inject } from '@angular/core'
import { Component, OnDestroy, inject, signal } from '@angular/core'
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
import { Router } from '@angular/router'
import {
@@ -7,7 +7,7 @@ import {
NgbPaginationModule,
} from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { delay, takeUntil, tap } from 'rxjs'
import { takeUntil, tap } from 'rxjs'
import { Document } from 'src/app/data/document'
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
import { SettingsService } from 'src/app/services/settings.service'
@@ -42,11 +42,11 @@ export class TrashComponent
private settingsService = inject(SettingsService)
private router = inject(Router)
public documentsInTrash: Document[] = []
public selectedDocuments: Set<number> = new Set()
public allToggled: boolean = false
public page: number = 1
public totalDocuments: number
readonly documentsInTrash = signal<Document[]>([])
readonly selectedDocuments = signal<Set<number>>(new Set())
readonly allToggled = signal(false)
readonly page = signal(1)
readonly totalDocuments = signal<number>(undefined)
constructor() {
super()
@@ -54,20 +54,19 @@ export class TrashComponent
}
reload() {
this.loading = true
this.loading.set(true)
this.trashService
.getTrash(this.page)
.getTrash(this.page())
.pipe(
tap((r) => {
this.documentsInTrash = r.results
this.totalDocuments = r.count
this.selectedDocuments.clear()
this.loading = false
}),
delay(100)
this.documentsInTrash.set(r.results)
this.totalDocuments.set(r.count)
this.selectedDocuments.set(new Set())
this.loading.set(false)
})
)
.subscribe(() => {
this.show = true
this.show.set(true)
})
}
@@ -122,7 +121,7 @@ export class TrashComponent
.subscribe({
next: () => {
this.toastService.showInfo($localize`Document(s) deleted`)
this.allToggled = false
this.allToggled.set(false)
modal.close()
this.reload()
},
@@ -165,7 +164,7 @@ export class TrashComponent
.subscribe({
next: () => {
this.toastService.showInfo($localize`Document(s) restored`)
this.allToggled = false
this.allToggled.set(false)
this.reload()
},
error: (err) => {
@@ -179,24 +178,29 @@ export class TrashComponent
toggleAll(event: PointerEvent) {
if ((event.target as HTMLInputElement).checked) {
this.selectedDocuments = new Set(this.documentsInTrash.map((t) => t.id))
this.selectedDocuments.set(
new Set(this.documentsInTrash().map((t) => t.id))
)
} else {
this.clearSelection()
}
}
toggleSelected(object: Document) {
this.selectedDocuments.has(object.id)
? this.selectedDocuments.delete(object.id)
: this.selectedDocuments.add(object.id)
const selectedDocuments = new Set(this.selectedDocuments())
selectedDocuments.has(object.id)
? selectedDocuments.delete(object.id)
: selectedDocuments.add(object.id)
this.selectedDocuments.set(selectedDocuments)
}
clearSelection() {
this.allToggled = false
this.selectedDocuments.clear()
this.allToggled.set(false)
this.selectedDocuments.set(new Set())
}
getDaysRemaining(document: Document): number {
this.settingsService.trackChanges()
const delay = this.settingsService.get(SETTINGS_KEYS.EMPTY_TRASH_DELAY)
const diff = new Date().getTime() - new Date(document.deleted_at).getTime()
const days = Math.ceil(diff / (1000 * 3600 * 24))
@@ -7,7 +7,7 @@
>
</pngx-page-header>
@if (canViewUsers && users) {
@if (canViewUsers && users()) {
<h4 class="d-flex">
<ng-container i18n>Users</ng-container>
<button type="button" class="btn btn-sm btn-outline-primary ms-4" (click)="editUser()" *pngxIfPermissions="{ action: PermissionAction.Add, type: PermissionType.User }">
@@ -23,7 +23,7 @@
<div class="col" i18n>Actions</div>
</div>
</li>
@for (user of users; track user) {
@for (user of users(); track user) {
<li class="list-group-item">
<div class="row">
<div class="col d-flex align-items-center" [class.opacity-50]="!user.is_active"><button class="btn btn-link p-0 text-start" type="button" (click)="editUser(user)" [disabled]="!permissionsService.currentUserCan(PermissionAction.Change, PermissionType.User)">{{user.username}}</button></div>
@@ -45,7 +45,7 @@
</ul>
}
@if (canViewGroups && groups) {
@if (canViewGroups && groups()) {
<h4 class="mt-4 d-flex">
<ng-container i18n>Groups</ng-container>
<button type="button" class="btn btn-sm btn-outline-primary ms-4" (click)="editGroup()" *pngxIfPermissions="{ action: PermissionAction.Add, type: PermissionType.Group }">
@@ -61,7 +61,7 @@
<div class="col" i18n>Actions</div>
</div>
</li>
@for (group of groups; track group) {
@for (group of groups(); track group) {
<li class="list-group-item">
<div class="row">
<div class="col d-flex align-items-center"><button class="btn btn-link p-0 text-start" type="button" (click)="editGroup(group)" [disabled]="!permissionsService.currentUserCan(PermissionAction.Change, PermissionType.Group)">{{group.name}}</button></div>
@@ -80,13 +80,13 @@
</div>
</li>
}
@if (groups.length === 0) {
@if (groups().length === 0) {
<li class="list-group-item" i18n>No groups defined</li>
}
</ul>
}
@if ((canViewUsers && !users) || (canViewGroups && !groups)) {
@if ((canViewUsers && !users()) || (canViewGroups && !groups())) {
<div>
<div class="spinner-border spinner-border-sm fw-normal ms-2 me-auto" role="status"></div>
<div class="visually-hidden" i18n>Loading...</div>
@@ -1,12 +1,7 @@
import { DatePipe } from '@angular/common'
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
import { provideHttpClientTesting } from '@angular/common/http/testing'
import {
ComponentFixture,
TestBed,
fakeAsync,
tick,
} from '@angular/core/testing'
import { ComponentFixture, TestBed } from '@angular/core/testing'
import { NgbModal, NgbModalRef } from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
import { of, throwError } from 'rxjs'
@@ -57,7 +52,7 @@ describe('UsersAndGroupsComponent', () => {
}).compileComponents()
fixture = TestBed.createComponent(UsersAndGroupsComponent)
settingsService = TestBed.inject(SettingsService)
settingsService.currentUser = users[0]
settingsService.currentUser.set(users[0])
userService = TestBed.inject(UserService)
modalService = TestBed.inject(NgbModal)
toastService = TestBed.inject(ToastService)
@@ -109,7 +104,7 @@ describe('UsersAndGroupsComponent', () => {
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
editDialog.failed.emit()
expect(toastErrorSpy).toHaveBeenCalled()
settingsService.currentUser = users[1] // simulate logged in as different user
settingsService.currentUser.set(users[1]) // simulate logged in as different user
editDialog.succeeded.emit(users[0])
expect(toastInfoSpy).toHaveBeenCalledWith(
`Saved user "${users[0].username}".`
@@ -138,7 +133,8 @@ describe('UsersAndGroupsComponent', () => {
expect(toastInfoSpy).toHaveBeenCalledWith('Deleted user "user1"')
})
it('should logout current user if password changed, after delay', fakeAsync(() => {
it('should logout current user if password changed, after delay', () => {
jest.useFakeTimers()
completeSetup()
let modal: NgbModalRef
modalService.activeInstances.subscribe((refs) => (modal = refs[0]))
@@ -148,14 +144,15 @@ describe('UsersAndGroupsComponent', () => {
.mockImplementation(() => {})
const editDialog = modal.componentInstance as UserEditDialogComponent
editDialog.passwordIsSet = true
settingsService.currentUser = users[0] // simulate logged in as same user
settingsService.currentUser.set(users[0]) // simulate logged in as same user
editDialog.succeeded.emit(users[0])
fixture.detectChanges()
tick(2600)
jest.advanceTimersByTime(2600)
expect(navSpy).toHaveBeenCalledWith(
`${window.location.origin}/accounts/logout/?next=/accounts/login/?next=/`
)
}))
jest.useRealTimers()
})
it('should support edit / create group, show error if needed', () => {
completeSetup()
@@ -1,4 +1,4 @@
import { Component, OnDestroy, OnInit, inject } from '@angular/core'
import { Component, OnDestroy, OnInit, inject, signal } from '@angular/core'
import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
import { Subject, first, takeUntil } from 'rxjs'
@@ -43,8 +43,8 @@ export class UsersAndGroupsComponent
permissionsService = inject(PermissionsService)
private settings = inject(SettingsService)
users: User[]
groups: Group[]
readonly users = signal<User[]>(null)
readonly groups = signal<Group[]>(null)
unsubscribeNotifier: Subject<any> = new Subject()
@@ -69,7 +69,7 @@ export class UsersAndGroupsComponent
.pipe(first(), takeUntil(this.unsubscribeNotifier))
.subscribe({
next: (r) => {
this.users = r.results
this.users.set(r.results)
},
error: (e) => {
this.toastService.showError($localize`Error retrieving users`, e)
@@ -83,7 +83,7 @@ export class UsersAndGroupsComponent
.pipe(first(), takeUntil(this.unsubscribeNotifier))
.subscribe({
next: (r) => {
this.groups = r.results
this.groups.set(r.results)
},
error: (e) => {
this.toastService.showError($localize`Error retrieving groups`, e)
@@ -101,15 +101,15 @@ export class UsersAndGroupsComponent
backdrop: 'static',
size: 'xl',
})
modal.componentInstance.dialogMode = user
? EditDialogMode.EDIT
: EditDialogMode.CREATE
modal.componentInstance.dialogMode.set(
user ? EditDialogMode.EDIT : EditDialogMode.CREATE
)
modal.componentInstance.object = user
modal.componentInstance.succeeded
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe((newUser: User) => {
if (
newUser.id === this.settings.currentUser.id &&
newUser.id === this.settings.currentUser().id &&
(modal.componentInstance as UserEditDialogComponent).passwordIsSet
) {
this.toastService.showInfo(
@@ -125,7 +125,7 @@ export class UsersAndGroupsComponent
$localize`Saved user "${newUser.username}".`
)
this.usersService.listAll().subscribe((r) => {
this.users = r.results
this.users.set(r.results)
})
}
})
@@ -152,7 +152,7 @@ export class UsersAndGroupsComponent
modal.close()
this.toastService.showInfo($localize`Deleted user "${user.username}"`)
this.usersService.listAll().subscribe((r) => {
this.users = r.results
this.users.set(r.results)
})
},
error: (e) => {
@@ -170,16 +170,16 @@ export class UsersAndGroupsComponent
backdrop: 'static',
size: 'lg',
})
modal.componentInstance.dialogMode = group
? EditDialogMode.EDIT
: EditDialogMode.CREATE
modal.componentInstance.dialogMode.set(
group ? EditDialogMode.EDIT : EditDialogMode.CREATE
)
modal.componentInstance.object = group
modal.componentInstance.succeeded
.pipe(takeUntil(this.unsubscribeNotifier))
.subscribe((newGroup) => {
this.toastService.showInfo($localize`Saved group "${newGroup.name}".`)
this.groupsService.listAll().subscribe((r) => {
this.groups = r.results
this.groups.set(r.results)
})
})
modal.componentInstance.failed
@@ -205,7 +205,7 @@ export class UsersAndGroupsComponent
modal.close()
this.toastService.showInfo($localize`Deleted group "${group.name}"`)
this.groupsService.listAll().subscribe((r) => {
this.groups = r.results
this.groups.set(r.results)
})
},
error: (e) => {
@@ -219,6 +219,6 @@ export class UsersAndGroupsComponent
}
getGroupName(id: number): string {
return this.groups?.find((g) => g.id === id)?.name ?? ''
return this.groups()?.find((g) => g.id === id)?.name ?? ''
}
}