Reorganize

This commit is contained in:
shamoon
2026-04-20 16:35:42 -07:00
parent d8e2ab9e71
commit 6c2d7adf76
3 changed files with 144 additions and 73 deletions
@@ -5,56 +5,12 @@
i18n-info
>
<div class="btn-toolbar col col-md-auto align-items-center gap-2">
@if (isFiltered) {
<button class="btn btn-link py-0" (click)="resetFilters()">
<i-bs width="1em" height="1em" name="x"></i-bs><small i18n>Reset filters</small>
</button>
}
<button class="btn btn-sm btn-outline-secondary me-2" (click)="clearSelection()" [hidden]="selectedTasks.size === 0">
<i-bs name="x" class="me-1"></i-bs><ng-container i18n>Clear selection</ng-container>
</button>
<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>
<div class="form-inline d-flex align-items-center">
<div class="input-group input-group-sm flex-fill w-auto flex-nowrap">
<span class="input-group-text text-muted" i18n>Filter by</span>
<div ngbDropdown>
<button class="btn btn-sm btn-outline-primary" ngbDropdownToggle>{{filterTargetName}}</button>
<div class="dropdown-menu shadow" ngbDropdownMenu>
@for (t of filterTargets; track t.id) {
<button ngbDropdownItem [class.active]="filterTargetID === t.id" (click)="filterTargetID = t.id">{{t.name}}</button>
}
</div>
</div>
@if (filterText?.length) {
<button class="btn btn-link btn-sm px-2 position-absolute top-0 end-0 z-10" (click)="resetFilter()">
<i-bs width="1em" height="1em" name="x"></i-bs>
</button>
}
<input #filterInput class="form-control form-control-sm" type="text"
(keyup)="filterInputKeyup($event)"
[(ngModel)]="filterText">
</div>
</div>
<div ngbDropdown>
<button class="btn btn-sm btn-outline-primary" ngbDropdownToggle>{{selectedTaskTypeLabel}}</button>
<div class="dropdown-menu shadow" ngbDropdownMenu>
<button ngbDropdownItem [class.active]="selectedTaskType === allFilterValue" (click)="setTaskType(allFilterValue)" i18n>All types</button>
@for (option of taskTypeOptions; track option.value) {
<button ngbDropdownItem [class.active]="selectedTaskType === option.value" [disabled]="isTaskTypeOptionDisabled(option.value)" (click)="setTaskType(option.value)">{{option.label}}</button>
}
</div>
</div>
<div ngbDropdown>
<button class="btn btn-sm btn-outline-primary" ngbDropdownToggle>{{selectedTriggerSourceLabel}}</button>
<div class="dropdown-menu shadow" ngbDropdownMenu>
<button ngbDropdownItem [class.active]="selectedTriggerSource === allFilterValue" (click)="setTriggerSource(allFilterValue)" i18n>All sources</button>
@for (option of triggerSourceOptions; track option.value) {
<button ngbDropdownItem [class.active]="selectedTriggerSource === option.value" [disabled]="isTriggerSourceOptionDisabled(option.value)" (click)="setTriggerSource(option.value)">{{option.label}}</button>
}
</div>
</div>
<div class="form-check form-switch mb-0 ms-2">
<input class="form-check-input" type="checkbox" role="switch" [(ngModel)]="autoRefreshEnabled">
<label class="form-check-label" for="autoRefreshSwitch" i18n>Auto refresh</label>
@@ -67,24 +23,80 @@
<div class="visually-hidden" i18n>Loading...</div>
}
<div class="d-flex flex-wrap gap-2 mb-3">
<button
class="btn btn-sm"
[class.btn-primary]="selectedSection === allTaskSections"
[class.btn-outline-primary]="selectedSection !== allTaskSections"
(click)="setSection(allTaskSections)">
<ng-container i18n>All</ng-container>
</button>
@for (section of sections; track section) {
<button
class="btn btn-sm"
[class.btn-primary]="selectedSection === section"
[class.btn-outline-primary]="selectedSection !== section"
(click)="setSection(section)">
{{ sectionLabel(section) }}
@if (sectionCount(section) > 0) {
<span class="badge ms-2" [class.bg-danger]="section === TaskSection.NeedsAttention" [class.bg-secondary]="section !== TaskSection.NeedsAttention">{{sectionCount(section)}}</span>
<div class="task-controls mb-3 btn-toolbar" [class.task-controls-scoped]="hasScopedSectionView">
<div class="task-view-scope btn-group btn-group-sm me-3" role="group">
<input type="radio" class="btn-check"
[checked]="selectedSection === allTaskSections"
id="section-all"
/>
<label
class="btn btn-outline-primary"
for="section-all"
(click)="setSection(allTaskSections)">
<ng-container i18n>All</ng-container>
</label>
@for (section of sections; track section) {
<input
type="radio" class="btn-check"
[checked]="selectedSection === section"
id="section-{{section}}"
/>
<label
class="btn btn-outline-primary"
[for]="'section-' + section"
(click)="setSection(section)">
{{ sectionLabel(section) }}
@if (sectionCount(section) > 0) {
<span class="badge ms-2" [class.bg-danger]="section === TaskSection.NeedsAttention" [class.bg-secondary]="section !== TaskSection.NeedsAttention">{{sectionCount(section)}}</span>
}
</label>
}
</div>
<div ngbDropdown>
<button class="btn btn-sm btn-outline-primary me-3" ngbDropdownToggle>{{selectedTaskTypeLabel}}</button>
<div class="dropdown-menu shadow" ngbDropdownMenu>
<button ngbDropdownItem [class.active]="selectedTaskType === allFilterValue" (click)="setTaskType(allFilterValue)" i18n>All types</button>
@for (option of taskTypeOptions; track option.value) {
<button ngbDropdownItem [class.active]="selectedTaskType === option.value" [disabled]="isTaskTypeOptionDisabled(option.value)" (click)="setTaskType(option.value)">{{option.label}}</button>
}
</div>
</div>
<div ngbDropdown>
<button class="btn btn-sm btn-outline-primary me-3" ngbDropdownToggle>{{selectedTriggerSourceLabel}}</button>
<div class="dropdown-menu shadow" ngbDropdownMenu>
<button ngbDropdownItem [class.active]="selectedTriggerSource === allFilterValue" (click)="setTriggerSource(allFilterValue)" i18n>All sources</button>
@for (option of triggerSourceOptions; track option.value) {
<button ngbDropdownItem [class.active]="selectedTriggerSource === option.value" [disabled]="isTriggerSourceOptionDisabled(option.value)" (click)="setTriggerSource(option.value)">{{option.label}}</button>
}
</div>
</div>
<div class="form-inline d-flex align-items-center flex-grow-1 task-search">
<div class="input-group input-group-sm flex-fill w-auto flex-nowrap">
<span class="input-group-text text-muted" i18n>Filter by</span>
<div ngbDropdown>
<button class="btn btn-sm btn-outline-primary" ngbDropdownToggle>{{filterTargetName}}</button>
<div class="dropdown-menu shadow" ngbDropdownMenu>
@for (t of filterTargets; track t.id) {
<button ngbDropdownItem [class.active]="filterTargetID === t.id" (click)="filterTargetID = t.id">{{t.name}}</button>
}
</div>
</div>
@if (filterText?.length) {
<button class="btn btn-link btn-sm px-2 position-absolute top-0 end-0 z-10" (click)="resetFilter()">
<i-bs width="1em" height="1em" name="x"></i-bs>
</button>
}
<input #filterInput class="form-control form-control-sm" type="text"
(keyup)="filterInputKeyup($event)"
[(ngModel)]="filterText">
</div>
</div>
@if (isFiltered) {
<button class="btn btn-link py-0 ms-md-auto" (click)="resetFilters()">
<i-bs width="1em" height="1em" name="x"></i-bs><small i18n>Reset filters</small>
</button>
}
</div>
@@ -211,13 +211,15 @@ describe('TasksComponent', () => {
expect(component.selectedTaskType).toBe(ALL_FILTER_VALUE)
expect(component.selectedTriggerSource).toBe(ALL_FILTER_VALUE)
const buttons = fixture.debugElement.queryAll(By.css('.btn.btn-sm'))
const text = buttons.map((button) => button.nativeElement.textContent)
fixture.detectChanges()
expect(text.join(' ')).toContain('All')
expect(text.join(' ')).toContain('Needs attention2')
expect(text.join(' ')).toContain('In progress2')
expect(text.join(' ')).toContain('Recent completed2')
const viewScope = fixture.debugElement.query(By.css('.task-view-scope'))
const text = viewScope.nativeElement.textContent
expect(text).toContain('All')
expect(text).toContain('Needs attention2')
expect(text).toContain('In progress2')
expect(text).toContain('Recent completed2')
})
it('should filter visible sections by selected status', () => {
@@ -260,13 +262,40 @@ describe('TasksComponent', () => {
component.resetFilters()
expect(component.selectedSection).toBe(ALL_TASK_SECTIONS)
expect(component.selectedSection).toBe(TaskSection.InProgress)
expect(component.selectedTaskType).toBe(ALL_FILTER_VALUE)
expect(component.selectedTriggerSource).toBe(ALL_FILTER_VALUE)
expect(component.filterText).toBe('')
expect(component.isFiltered).toBe(false)
})
it('should keep header controls focused on actions and auto refresh', () => {
fixture.detectChanges()
const header = fixture.debugElement.query(By.css('pngx-page-header'))
const headerText = header.nativeElement.textContent
expect(headerText).toContain('Dismiss visible')
expect(headerText).toContain('Auto refresh')
expect(headerText).not.toContain('All types')
expect(headerText).not.toContain('All sources')
expect(headerText).not.toContain('Reset filters')
})
it('should render the view scope row above the filter bar', () => {
fixture.detectChanges()
const controls = fixture.debugElement.query(By.css('.task-controls'))
const controlChildren = controls.children
expect(controlChildren[0].nativeElement.className).toContain(
'task-view-scope'
)
expect(controlChildren[1].nativeElement.className).toContain(
'task-filter-bar'
)
})
it('should expose stable task type options and disable empty ones', () => {
expect(component.taskTypeOptions.map((option) => option.value)).toContain(
PaperlessTaskType.TrainClassifier
@@ -387,6 +416,22 @@ describe('TasksComponent', () => {
expect(dismissSpy).toHaveBeenCalledWith(new Set([467, 466]))
})
it('should dismiss the currently visible scoped and filtered tasks', () => {
component.setSection(TaskSection.InProgress)
component.setTaskType(PaperlessTaskType.SanityCheck)
component.setTriggerSource(PaperlessTaskTriggerSource.System)
let modal: NgbModalRef
modalService.activeInstances.subscribe((m) => (modal = m[m.length - 1]))
const dismissSpy = jest.spyOn(tasksService, 'dismissTasks')
component.dismissTasks()
expect(modal).not.toBeUndefined()
modal.componentInstance.confirmClicked.emit()
expect(dismissSpy).toHaveBeenCalledWith(new Set([461]))
})
it('should support toggling a full section', () => {
const toggleCheck = fixture.debugElement.query(
By.css('#all-tasks-needs_attention')
@@ -416,7 +461,7 @@ describe('TasksComponent', () => {
it('should filter tasks by file name', () => {
const input = fixture.debugElement.query(
By.css('pngx-page-header input[type=text]')
By.css('.task-filter-bar input[type=text]')
)
input.nativeElement.value = '191092'
input.nativeElement.dispatchEvent(new Event('input'))
@@ -458,7 +503,7 @@ describe('TasksComponent', () => {
component.setSection(TaskSection.NeedsAttention)
component.filterTargetID = 1
const input = fixture.debugElement.query(
By.css('pngx-page-header input[type=text]')
By.css('.task-filter-bar input[type=text]')
)
input.nativeElement.value = 'duplicate'
input.nativeElement.dispatchEvent(new Event('input'))
@@ -472,7 +517,7 @@ describe('TasksComponent', () => {
it('should support keyboard events for filtering', () => {
const input = fixture.debugElement.query(
By.css('pngx-page-header input[type=text]')
By.css('.task-filter-bar input[type=text]')
)
input.nativeElement.value = '191092'
input.nativeElement.dispatchEvent(
@@ -484,4 +529,16 @@ describe('TasksComponent', () => {
)
expect(component.filterText).toEqual('')
})
it('should keep clearing selection independent from resetting filters', () => {
component.setTaskType(PaperlessTaskType.ConsumeFile)
component.toggleSelected(tasks[0])
expect(component.selectedTasks.size).toBe(1)
component.clearSelection()
expect(component.selectedTasks.size).toBe(0)
expect(component.selectedTaskType).toBe(PaperlessTaskType.ConsumeFile)
expect(component.isFiltered).toBe(true)
})
})
@@ -217,13 +217,16 @@ export class TasksComponent
get isFiltered(): boolean {
return (
this.selectedSection !== ALL_TASK_SECTIONS ||
this.selectedTaskType !== ALL_FILTER_VALUE ||
this.selectedTriggerSource !== ALL_FILTER_VALUE ||
this._filterText.length > 0
)
}
get hasScopedSectionView(): boolean {
return this.selectedSection !== ALL_TASK_SECTIONS
}
ngOnInit() {
this.tasksService.reload()
timer(5000, 5000)
@@ -441,7 +444,6 @@ export class TasksComponent
}
public resetFilters() {
this.selectedSection = ALL_TASK_SECTIONS
this.selectedTaskType = ALL_FILTER_VALUE
this.selectedTriggerSource = ALL_FILTER_VALUE
this.resetFilter()