mirror of
https://github.com/paperless-ngx/paperless-ngx.git
synced 2026-02-09 23:49:29 -06:00
More reorganizing
This commit is contained in:
@@ -0,0 +1,70 @@
|
||||
<ul class="list-group">
|
||||
|
||||
<li class="list-group-item">
|
||||
<div class="row">
|
||||
<div class="col" i18n>Name</div>
|
||||
<div class="col" i18n>Data Type</div>
|
||||
<div class="col" i18n>Actions</div>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
@if (loading) {
|
||||
<li class="list-group-item">
|
||||
<div class="spinner-border spinner-border-sm me-2" role="status"></div>
|
||||
<ng-container i18n>Loading...</ng-container>
|
||||
</li>
|
||||
}
|
||||
|
||||
@for (field of fields; track field) {
|
||||
<li class="list-group-item">
|
||||
<div class="row fade" [class.show]="show">
|
||||
<div class="col d-flex align-items-center"><button class="btn btn-link p-0 text-start" type="button" (click)="editField(field)" [disabled]="!permissionsService.currentUserCan(PermissionAction.Change, PermissionType.CustomField)">{{field.name}}</button></div>
|
||||
<div class="col d-flex align-items-center">{{getDataType(field)}}</div>
|
||||
<div class="col">
|
||||
<div class="btn-group d-block d-sm-none">
|
||||
<div ngbDropdown container="body" class="d-inline-block">
|
||||
<button type="button" class="btn btn-link" id="actionsMenuMobile" (click)="$event.stopPropagation()" ngbDropdownToggle>
|
||||
<i-bs name="three-dots-vertical"></i-bs>
|
||||
</button>
|
||||
<div ngbDropdownMenu aria-labelledby="actionsMenuMobile">
|
||||
<button (click)="editField(field)" *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.CustomField }" ngbDropdownItem i18n>Edit</button>
|
||||
<button class="text-danger" (click)="deleteField(field)" *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.CustomField }" ngbDropdownItem i18n>Delete</button>
|
||||
@if (field.document_count > 0) {
|
||||
<a
|
||||
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }"
|
||||
ngbDropdownItem
|
||||
[routerLink]="getDocumentFilterUrl(field)"
|
||||
i18n
|
||||
>Filter Documents ({{ field.document_count }})</a
|
||||
>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="btn-group d-none d-sm-inline-block">
|
||||
<button *pngxIfPermissions="{ action: PermissionAction.Change, type: PermissionType.CustomField }" class="btn btn-sm btn-outline-secondary" type="button" (click)="editField(field)">
|
||||
<i-bs width="1em" height="1em" name="pencil"></i-bs> <ng-container i18n>Edit</ng-container>
|
||||
</button>
|
||||
<button *pngxIfPermissions="{ action: PermissionAction.Delete, type: PermissionType.CustomField }" class="btn btn-sm btn-outline-danger" type="button" (click)="deleteField(field)">
|
||||
<i-bs width="1em" height="1em" name="trash"></i-bs> <ng-container i18n>Delete</ng-container>
|
||||
</button>
|
||||
</div>
|
||||
@if (field.document_count > 0) {
|
||||
<div class="btn-group d-none d-sm-inline-block ms-2">
|
||||
<a
|
||||
class="btn btn-sm btn-outline-secondary"
|
||||
[routerLink]="getDocumentFilterUrl(field)"
|
||||
>
|
||||
<i-bs width="1em" height="1em" name="filter"></i-bs> <ng-container i18n>Documents</ng-container
|
||||
><span class="badge bg-light text-secondary ms-2">{{ field.document_count }}</span>
|
||||
</a>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
}
|
||||
@if (!loading && fields.length === 0) {
|
||||
<li class="list-group-item" i18n>No fields defined.</li>
|
||||
}
|
||||
</ul>
|
||||
@@ -0,0 +1,4 @@
|
||||
// hide caret on mobile dropdown
|
||||
.d-block.d-sm-none .dropdown-toggle::after {
|
||||
display: none;
|
||||
}
|
||||
@@ -0,0 +1,199 @@
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||
|
||||
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
|
||||
import { provideHttpClientTesting } from '@angular/common/http/testing'
|
||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||
import { By } from '@angular/platform-browser'
|
||||
import { RouterTestingModule } from '@angular/router/testing'
|
||||
import {
|
||||
NgbModal,
|
||||
NgbModalModule,
|
||||
NgbModalRef,
|
||||
NgbPaginationModule,
|
||||
NgbPopoverModule,
|
||||
} from '@ng-bootstrap/ng-bootstrap'
|
||||
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
||||
import { of, throwError } from 'rxjs'
|
||||
import { CustomField, CustomFieldDataType } from 'src/app/data/custom-field'
|
||||
import {
|
||||
CustomFieldQueryLogicalOperator,
|
||||
CustomFieldQueryOperator,
|
||||
} from 'src/app/data/custom-field-query'
|
||||
import { FILTER_CUSTOM_FIELDS_QUERY } from 'src/app/data/filter-rule-type'
|
||||
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
||||
import { DocumentListViewService } from 'src/app/services/document-list-view.service'
|
||||
import { PermissionsService } from 'src/app/services/permissions.service'
|
||||
import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
|
||||
import { SettingsService } from 'src/app/services/settings.service'
|
||||
import { ToastService } from 'src/app/services/toast.service'
|
||||
import { ConfirmDialogComponent } from '../../common/confirm-dialog/confirm-dialog.component'
|
||||
import { CustomFieldEditDialogComponent } from '../../common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component'
|
||||
import { PageHeaderComponent } from '../../common/page-header/page-header.component'
|
||||
import { CustomFieldsComponent } from './custom-fields.component'
|
||||
|
||||
const fields: CustomField[] = [
|
||||
{
|
||||
id: 0,
|
||||
name: 'Field 1',
|
||||
data_type: CustomFieldDataType.String,
|
||||
},
|
||||
{
|
||||
id: 1,
|
||||
name: 'Field 2',
|
||||
data_type: CustomFieldDataType.Integer,
|
||||
},
|
||||
]
|
||||
|
||||
describe('CustomFieldsComponent', () => {
|
||||
let component: CustomFieldsComponent
|
||||
let fixture: ComponentFixture<CustomFieldsComponent>
|
||||
let customFieldsService: CustomFieldsService
|
||||
let modalService: NgbModal
|
||||
let toastService: ToastService
|
||||
let listViewService: DocumentListViewService
|
||||
let settingsService: SettingsService
|
||||
|
||||
beforeEach(() => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [
|
||||
NgbPaginationModule,
|
||||
FormsModule,
|
||||
ReactiveFormsModule,
|
||||
NgbModalModule,
|
||||
NgbPopoverModule,
|
||||
NgxBootstrapIconsModule.pick(allIcons),
|
||||
RouterTestingModule,
|
||||
CustomFieldsComponent,
|
||||
IfPermissionsDirective,
|
||||
PageHeaderComponent,
|
||||
ConfirmDialogComponent,
|
||||
],
|
||||
providers: [
|
||||
{
|
||||
provide: PermissionsService,
|
||||
useValue: {
|
||||
currentUserCan: () => true,
|
||||
currentUserHasObjectPermissions: () => true,
|
||||
currentUserOwnsObject: () => true,
|
||||
},
|
||||
},
|
||||
provideHttpClient(withInterceptorsFromDi()),
|
||||
provideHttpClientTesting(),
|
||||
],
|
||||
})
|
||||
|
||||
customFieldsService = TestBed.inject(CustomFieldsService)
|
||||
jest.spyOn(customFieldsService, 'listAll').mockReturnValue(
|
||||
of({
|
||||
count: fields.length,
|
||||
all: fields.map((o) => o.id),
|
||||
results: fields,
|
||||
})
|
||||
)
|
||||
modalService = TestBed.inject(NgbModal)
|
||||
toastService = TestBed.inject(ToastService)
|
||||
listViewService = TestBed.inject(DocumentListViewService)
|
||||
settingsService = TestBed.inject(SettingsService)
|
||||
settingsService.currentUser = { id: 0, username: 'test' }
|
||||
|
||||
fixture = TestBed.createComponent(CustomFieldsComponent)
|
||||
component = fixture.componentInstance
|
||||
fixture.detectChanges()
|
||||
jest.useFakeTimers()
|
||||
jest.advanceTimersByTime(100)
|
||||
})
|
||||
|
||||
it('should support create, show notification on error / success', () => {
|
||||
let modal: NgbModalRef
|
||||
modalService.activeInstances.subscribe((m) => (modal = m[m.length - 1]))
|
||||
const toastErrorSpy = jest.spyOn(toastService, 'showError')
|
||||
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
|
||||
const reloadSpy = jest.spyOn(component, 'reload')
|
||||
|
||||
component.editField()
|
||||
|
||||
expect(modal).not.toBeUndefined()
|
||||
const editDialog = modal.componentInstance as CustomFieldEditDialogComponent
|
||||
|
||||
// fail first
|
||||
editDialog.failed.emit({ error: 'error creating item' })
|
||||
expect(toastErrorSpy).toHaveBeenCalled()
|
||||
expect(reloadSpy).not.toHaveBeenCalled()
|
||||
|
||||
// succeed
|
||||
editDialog.succeeded.emit(fields[0])
|
||||
expect(toastInfoSpy).toHaveBeenCalled()
|
||||
expect(reloadSpy).toHaveBeenCalled()
|
||||
jest.advanceTimersByTime(100)
|
||||
})
|
||||
|
||||
it('should support edit, show notification on error / success', () => {
|
||||
let modal: NgbModalRef
|
||||
modalService.activeInstances.subscribe((m) => (modal = m[m.length - 1]))
|
||||
const toastErrorSpy = jest.spyOn(toastService, 'showError')
|
||||
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
|
||||
const reloadSpy = jest.spyOn(component, 'reload')
|
||||
|
||||
const editButton = fixture.debugElement
|
||||
.queryAll(By.css('button'))
|
||||
.find((btn) =>
|
||||
btn.nativeElement.textContent.trim().includes(fields[0].name)
|
||||
)
|
||||
editButton.triggerEventHandler('click')
|
||||
|
||||
expect(modal).not.toBeUndefined()
|
||||
const editDialog = modal.componentInstance as CustomFieldEditDialogComponent
|
||||
expect(editDialog.object).toEqual(fields[0])
|
||||
|
||||
// fail first
|
||||
editDialog.failed.emit({ error: 'error editing item' })
|
||||
expect(toastErrorSpy).toHaveBeenCalled()
|
||||
expect(reloadSpy).not.toHaveBeenCalled()
|
||||
|
||||
// succeed
|
||||
editDialog.succeeded.emit(fields[0])
|
||||
expect(toastInfoSpy).toHaveBeenCalled()
|
||||
expect(reloadSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should support delete, show notification on error / success', () => {
|
||||
let modal: NgbModalRef
|
||||
modalService.activeInstances.subscribe((m) => (modal = m[m.length - 1]))
|
||||
const toastErrorSpy = jest.spyOn(toastService, 'showError')
|
||||
const deleteSpy = jest.spyOn(customFieldsService, 'delete')
|
||||
const reloadSpy = jest.spyOn(component, 'reload')
|
||||
|
||||
const deleteButton = fixture.debugElement
|
||||
.queryAll(By.css('button'))
|
||||
.find((btn) => btn.nativeElement.textContent.trim().includes('Delete'))
|
||||
deleteButton.triggerEventHandler('click')
|
||||
|
||||
expect(modal).not.toBeUndefined()
|
||||
const editDialog = modal.componentInstance as ConfirmDialogComponent
|
||||
|
||||
// fail first
|
||||
deleteSpy.mockReturnValueOnce(throwError(() => new Error('error deleting')))
|
||||
editDialog.confirmClicked.emit()
|
||||
expect(toastErrorSpy).toHaveBeenCalled()
|
||||
expect(reloadSpy).not.toHaveBeenCalled()
|
||||
|
||||
// succeed
|
||||
deleteSpy.mockReturnValueOnce(of(true))
|
||||
editDialog.confirmClicked.emit()
|
||||
expect(reloadSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should provide document filter url', () => {
|
||||
const urlSpy = jest.spyOn(listViewService, 'getQuickFilterUrl')
|
||||
component.getDocumentFilterUrl(fields[0])
|
||||
expect(urlSpy).toHaveBeenCalledWith([
|
||||
{
|
||||
rule_type: FILTER_CUSTOM_FIELDS_QUERY,
|
||||
value: JSON.stringify([
|
||||
CustomFieldQueryLogicalOperator.Or,
|
||||
[[fields[0].id, CustomFieldQueryOperator.Exists, true]],
|
||||
]),
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,144 @@
|
||||
import { Component, OnInit, inject } from '@angular/core'
|
||||
import { RouterModule } from '@angular/router'
|
||||
import {
|
||||
NgbDropdownModule,
|
||||
NgbModal,
|
||||
NgbPaginationModule,
|
||||
} from '@ng-bootstrap/ng-bootstrap'
|
||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||
import { delay, takeUntil, tap } from 'rxjs'
|
||||
import { ConfirmDialogComponent } from 'src/app/components/common/confirm-dialog/confirm-dialog.component'
|
||||
import { CustomFieldEditDialogComponent } from 'src/app/components/common/edit-dialog/custom-field-edit-dialog/custom-field-edit-dialog.component'
|
||||
import { EditDialogMode } from 'src/app/components/common/edit-dialog/edit-dialog.component'
|
||||
import { LoadingComponentWithPermissions } from 'src/app/components/loading-component/loading.component'
|
||||
import { CustomField, DATA_TYPE_LABELS } from 'src/app/data/custom-field'
|
||||
import {
|
||||
CustomFieldQueryLogicalOperator,
|
||||
CustomFieldQueryOperator,
|
||||
} from 'src/app/data/custom-field-query'
|
||||
import { FILTER_CUSTOM_FIELDS_QUERY } from 'src/app/data/filter-rule-type'
|
||||
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
||||
import { DocumentListViewService } from 'src/app/services/document-list-view.service'
|
||||
import { PermissionsService } from 'src/app/services/permissions.service'
|
||||
import { CustomFieldsService } from 'src/app/services/rest/custom-fields.service'
|
||||
import { DocumentService } from 'src/app/services/rest/document.service'
|
||||
import { SavedViewService } from 'src/app/services/rest/saved-view.service'
|
||||
import { SettingsService } from 'src/app/services/settings.service'
|
||||
import { ToastService } from 'src/app/services/toast.service'
|
||||
|
||||
@Component({
|
||||
selector: 'pngx-custom-fields',
|
||||
templateUrl: './custom-fields.component.html',
|
||||
styleUrls: ['./custom-fields.component.scss'],
|
||||
imports: [
|
||||
IfPermissionsDirective,
|
||||
NgbDropdownModule,
|
||||
NgbPaginationModule,
|
||||
NgxBootstrapIconsModule,
|
||||
RouterModule,
|
||||
],
|
||||
})
|
||||
export class CustomFieldsComponent
|
||||
extends LoadingComponentWithPermissions
|
||||
implements OnInit
|
||||
{
|
||||
private customFieldsService = inject(CustomFieldsService)
|
||||
permissionsService = inject(PermissionsService)
|
||||
private modalService = inject(NgbModal)
|
||||
private toastService = inject(ToastService)
|
||||
private documentListViewService = inject(DocumentListViewService)
|
||||
private settingsService = inject(SettingsService)
|
||||
private documentService = inject(DocumentService)
|
||||
private savedViewService = inject(SavedViewService)
|
||||
|
||||
public fields: CustomField[] = []
|
||||
|
||||
ngOnInit() {
|
||||
this.reload()
|
||||
}
|
||||
|
||||
reload() {
|
||||
this.customFieldsService
|
||||
.listAll()
|
||||
.pipe(
|
||||
takeUntil(this.unsubscribeNotifier),
|
||||
tap((r) => {
|
||||
this.fields = r.results
|
||||
}),
|
||||
delay(100)
|
||||
)
|
||||
.subscribe(() => {
|
||||
this.show = true
|
||||
this.loading = false
|
||||
})
|
||||
}
|
||||
|
||||
editField(field: CustomField) {
|
||||
const modal = this.modalService.open(CustomFieldEditDialogComponent)
|
||||
modal.componentInstance.dialogMode = field
|
||||
? EditDialogMode.EDIT
|
||||
: EditDialogMode.CREATE
|
||||
modal.componentInstance.object = field
|
||||
modal.componentInstance.succeeded
|
||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe((newField) => {
|
||||
this.toastService.showInfo($localize`Saved field "${newField.name}".`)
|
||||
this.customFieldsService.clearCache()
|
||||
this.settingsService.initializeDisplayFields()
|
||||
this.documentService.reload()
|
||||
this.reload()
|
||||
})
|
||||
modal.componentInstance.failed
|
||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe((e) => {
|
||||
this.toastService.showError($localize`Error saving field.`, e)
|
||||
})
|
||||
}
|
||||
|
||||
deleteField(field: CustomField) {
|
||||
const modal = this.modalService.open(ConfirmDialogComponent, {
|
||||
backdrop: 'static',
|
||||
})
|
||||
modal.componentInstance.title = $localize`Confirm delete field`
|
||||
modal.componentInstance.messageBold = $localize`This operation will permanently delete this field.`
|
||||
modal.componentInstance.message = $localize`This operation cannot be undone.`
|
||||
modal.componentInstance.btnClass = 'btn-danger'
|
||||
modal.componentInstance.btnCaption = $localize`Proceed`
|
||||
modal.componentInstance.confirmClicked.subscribe(() => {
|
||||
modal.componentInstance.buttonsEnabled = false
|
||||
this.customFieldsService.delete(field).subscribe({
|
||||
next: () => {
|
||||
modal.close()
|
||||
this.toastService.showInfo($localize`Deleted field "${field.name}"`)
|
||||
this.customFieldsService.clearCache()
|
||||
this.settingsService.initializeDisplayFields()
|
||||
this.documentService.reload()
|
||||
this.savedViewService.reload()
|
||||
this.reload()
|
||||
},
|
||||
error: (e) => {
|
||||
this.toastService.showError(
|
||||
$localize`Error deleting field "${field.name}".`,
|
||||
e
|
||||
)
|
||||
},
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
getDataType(field: CustomField): string {
|
||||
return DATA_TYPE_LABELS.find((l) => l.id === field.data_type).name
|
||||
}
|
||||
|
||||
getDocumentFilterUrl(field: CustomField) {
|
||||
return this.documentListViewService.getQuickFilterUrl([
|
||||
{
|
||||
rule_type: FILTER_CUSTOM_FIELDS_QUERY,
|
||||
value: JSON.stringify([
|
||||
CustomFieldQueryLogicalOperator.Or,
|
||||
[[field.id, CustomFieldQueryOperator.Exists, true]],
|
||||
]),
|
||||
},
|
||||
])
|
||||
}
|
||||
}
|
||||
@@ -23,12 +23,12 @@ import {
|
||||
} from 'src/app/services/permissions.service'
|
||||
import { ClearableBadgeComponent } from '../../common/clearable-badge/clearable-badge.component'
|
||||
import { PageHeaderComponent } from '../../common/page-header/page-header.component'
|
||||
import { CustomFieldsComponent } from '../custom-fields/custom-fields.component'
|
||||
import { CorrespondentListComponent } from '../management-list/correspondent-list/correspondent-list.component'
|
||||
import { DocumentTypeListComponent } from '../management-list/document-type-list/document-type-list.component'
|
||||
import { ManagementListComponent } from '../management-list/management-list.component'
|
||||
import { StoragePathListComponent } from '../management-list/storage-path-list/storage-path-list.component'
|
||||
import { TagListComponent } from '../management-list/tag-list/tag-list.component'
|
||||
import { CustomFieldsComponent } from './custom-fields/custom-fields.component'
|
||||
import { CorrespondentListComponent } from './management-list/correspondent-list/correspondent-list.component'
|
||||
import { DocumentTypeListComponent } from './management-list/document-type-list/document-type-list.component'
|
||||
import { ManagementListComponent } from './management-list/management-list.component'
|
||||
import { StoragePathListComponent } from './management-list/storage-path-list/storage-path-list.component'
|
||||
import { TagListComponent } from './management-list/tag-list/tag-list.component'
|
||||
|
||||
enum DocumentAttributesNavIDs {
|
||||
Tags = 1,
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
import { DatePipe } from '@angular/common'
|
||||
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
|
||||
import { provideHttpClientTesting } from '@angular/common/http/testing'
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||
import { NgbPaginationModule } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
||||
import { of } from 'rxjs'
|
||||
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
||||
import { SortableDirective } from 'src/app/directives/sortable.directive'
|
||||
import { CorrespondentService } from 'src/app/services/rest/correspondent.service'
|
||||
import { PageHeaderComponent } from '../../../common/page-header/page-header.component'
|
||||
import { CorrespondentListComponent } from './correspondent-list.component'
|
||||
|
||||
describe('CorrespondentListComponent', () => {
|
||||
let component: CorrespondentListComponent
|
||||
let fixture: ComponentFixture<CorrespondentListComponent>
|
||||
let correspondentsService: CorrespondentService
|
||||
|
||||
beforeEach(async () => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [
|
||||
NgbPaginationModule,
|
||||
FormsModule,
|
||||
ReactiveFormsModule,
|
||||
NgxBootstrapIconsModule.pick(allIcons),
|
||||
CorrespondentListComponent,
|
||||
SortableDirective,
|
||||
PageHeaderComponent,
|
||||
IfPermissionsDirective,
|
||||
],
|
||||
providers: [
|
||||
DatePipe,
|
||||
provideHttpClient(withInterceptorsFromDi()),
|
||||
provideHttpClientTesting(),
|
||||
],
|
||||
}).compileComponents()
|
||||
correspondentsService = TestBed.inject(CorrespondentService)
|
||||
})
|
||||
|
||||
// Tests are included in management-list.component.spec.ts
|
||||
|
||||
it('should use correct delete message', () => {
|
||||
jest.spyOn(correspondentsService, 'listFiltered').mockReturnValue(
|
||||
of({
|
||||
count: 3,
|
||||
all: [1, 2, 3],
|
||||
results: [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Correspondent1',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'Correspondent2',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: 'Correspondent3',
|
||||
},
|
||||
],
|
||||
})
|
||||
)
|
||||
fixture = TestBed.createComponent(CorrespondentListComponent)
|
||||
component = fixture.componentInstance
|
||||
fixture.detectChanges()
|
||||
|
||||
expect(
|
||||
component.getDeleteMessage({ id: 1, name: 'Correspondent1' })
|
||||
).toEqual(
|
||||
'Do you really want to delete the correspondent "Correspondent1"?'
|
||||
)
|
||||
})
|
||||
|
||||
it('should support very old date strings', () => {
|
||||
jest.spyOn(correspondentsService, 'listFiltered').mockReturnValue(
|
||||
of({
|
||||
count: 2,
|
||||
all: [1, 2],
|
||||
results: [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Correspondent1',
|
||||
last_correspondence: '1832-12-31T15:32:54-07:52:58',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'Correspondent2',
|
||||
last_correspondence: '1901-07-01T00:00:00+00:09:21',
|
||||
},
|
||||
],
|
||||
})
|
||||
)
|
||||
fixture = TestBed.createComponent(CorrespondentListComponent)
|
||||
component = fixture.componentInstance
|
||||
fixture.detectChanges()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,79 @@
|
||||
import { NgClass, NgTemplateOutlet } from '@angular/common'
|
||||
import { Component, inject } from '@angular/core'
|
||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||
import { RouterModule } from '@angular/router'
|
||||
import {
|
||||
NgbDropdownModule,
|
||||
NgbPaginationModule,
|
||||
} from '@ng-bootstrap/ng-bootstrap'
|
||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||
import { CorrespondentEditDialogComponent } from 'src/app/components/common/edit-dialog/correspondent-edit-dialog/correspondent-edit-dialog.component'
|
||||
import { Correspondent } from 'src/app/data/correspondent'
|
||||
import { FILTER_HAS_CORRESPONDENT_ANY } from 'src/app/data/filter-rule-type'
|
||||
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
||||
import { SortableDirective } from 'src/app/directives/sortable.directive'
|
||||
import { CustomDatePipe } from 'src/app/pipes/custom-date.pipe'
|
||||
import { PermissionType } from 'src/app/services/permissions.service'
|
||||
import { CorrespondentService } from 'src/app/services/rest/correspondent.service'
|
||||
import { ManagementListComponent } from '../management-list.component'
|
||||
|
||||
@Component({
|
||||
selector: 'pngx-correspondent-list',
|
||||
templateUrl: './../management-list.component.html',
|
||||
styleUrls: ['./../management-list.component.scss'],
|
||||
providers: [{ provide: CustomDatePipe }],
|
||||
imports: [
|
||||
SortableDirective,
|
||||
IfPermissionsDirective,
|
||||
FormsModule,
|
||||
ReactiveFormsModule,
|
||||
RouterModule,
|
||||
NgClass,
|
||||
NgTemplateOutlet,
|
||||
NgbDropdownModule,
|
||||
NgbPaginationModule,
|
||||
NgxBootstrapIconsModule,
|
||||
],
|
||||
})
|
||||
export class CorrespondentListComponent extends ManagementListComponent<Correspondent> {
|
||||
private datePipe = inject(CustomDatePipe)
|
||||
|
||||
constructor() {
|
||||
super()
|
||||
this.service = inject(CorrespondentService)
|
||||
this.editDialogComponent = CorrespondentEditDialogComponent
|
||||
this.filterRuleType = FILTER_HAS_CORRESPONDENT_ANY
|
||||
this.typeName = $localize`correspondent`
|
||||
this.typeNamePlural = $localize`correspondents`
|
||||
this.permissionType = PermissionType.Correspondent
|
||||
this.extraColumns = [
|
||||
{
|
||||
key: 'last_correspondence',
|
||||
name: $localize`Last used`,
|
||||
valueFn: (c: Correspondent) => {
|
||||
if (c.last_correspondence) {
|
||||
let date = new Date(c.last_correspondence)
|
||||
if (date.toString() == 'Invalid Date') {
|
||||
// very old date strings are unable to be parsed
|
||||
date = new Date(
|
||||
c.last_correspondence
|
||||
?.toString()
|
||||
.replace(/([-+])(\d\d):\d\d:\d\d/gm, `$1$2:00`)
|
||||
)
|
||||
}
|
||||
return this.datePipe.transform(date)
|
||||
}
|
||||
return ''
|
||||
},
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
public reloadData(): void {
|
||||
super.reloadData({ last_correspondence: true })
|
||||
}
|
||||
|
||||
getDeleteMessage(object: Correspondent) {
|
||||
return $localize`Do you really want to delete the correspondent "${object.name}"?`
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { DatePipe } from '@angular/common'
|
||||
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
|
||||
import { provideHttpClientTesting } from '@angular/common/http/testing'
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||
import { NgbPaginationModule } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
||||
import { of } from 'rxjs'
|
||||
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
||||
import { SortableDirective } from 'src/app/directives/sortable.directive'
|
||||
import { DocumentTypeService } from 'src/app/services/rest/document-type.service'
|
||||
import { PageHeaderComponent } from '../../../common/page-header/page-header.component'
|
||||
import { DocumentTypeListComponent } from './document-type-list.component'
|
||||
|
||||
describe('DocumentTypeListComponent', () => {
|
||||
let component: DocumentTypeListComponent
|
||||
let fixture: ComponentFixture<DocumentTypeListComponent>
|
||||
let documentTypeService: DocumentTypeService
|
||||
|
||||
beforeEach(async () => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [
|
||||
NgbPaginationModule,
|
||||
FormsModule,
|
||||
ReactiveFormsModule,
|
||||
NgxBootstrapIconsModule.pick(allIcons),
|
||||
DocumentTypeListComponent,
|
||||
SortableDirective,
|
||||
PageHeaderComponent,
|
||||
IfPermissionsDirective,
|
||||
],
|
||||
providers: [
|
||||
DatePipe,
|
||||
provideHttpClient(withInterceptorsFromDi()),
|
||||
provideHttpClientTesting(),
|
||||
],
|
||||
}).compileComponents()
|
||||
|
||||
documentTypeService = TestBed.inject(DocumentTypeService)
|
||||
jest.spyOn(documentTypeService, 'listFiltered').mockReturnValue(
|
||||
of({
|
||||
count: 3,
|
||||
all: [1, 2, 3],
|
||||
results: [
|
||||
{
|
||||
id: 1,
|
||||
name: 'DocumentType1',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'DocumentType2',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: 'DocumentType3',
|
||||
},
|
||||
],
|
||||
})
|
||||
)
|
||||
fixture = TestBed.createComponent(DocumentTypeListComponent)
|
||||
component = fixture.componentInstance
|
||||
fixture.detectChanges()
|
||||
})
|
||||
|
||||
// Tests are included in management-list.component.spec.ts
|
||||
|
||||
it('should use correct delete message', () => {
|
||||
expect(
|
||||
component.getDeleteMessage({ id: 1, name: 'DocumentType1' })
|
||||
).toEqual('Do you really want to delete the document type "DocumentType1"?')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,50 @@
|
||||
import { NgClass, NgTemplateOutlet } from '@angular/common'
|
||||
import { Component, inject } from '@angular/core'
|
||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||
import { RouterModule } from '@angular/router'
|
||||
import {
|
||||
NgbDropdownModule,
|
||||
NgbPaginationModule,
|
||||
} from '@ng-bootstrap/ng-bootstrap'
|
||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||
import { DocumentTypeEditDialogComponent } from 'src/app/components/common/edit-dialog/document-type-edit-dialog/document-type-edit-dialog.component'
|
||||
import { DocumentType } from 'src/app/data/document-type'
|
||||
import { FILTER_HAS_DOCUMENT_TYPE_ANY } from 'src/app/data/filter-rule-type'
|
||||
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
||||
import { SortableDirective } from 'src/app/directives/sortable.directive'
|
||||
import { PermissionType } from 'src/app/services/permissions.service'
|
||||
import { DocumentTypeService } from 'src/app/services/rest/document-type.service'
|
||||
import { ManagementListComponent } from '../management-list.component'
|
||||
|
||||
@Component({
|
||||
selector: 'pngx-document-type-list',
|
||||
templateUrl: './../management-list.component.html',
|
||||
styleUrls: ['./../management-list.component.scss'],
|
||||
imports: [
|
||||
SortableDirective,
|
||||
IfPermissionsDirective,
|
||||
FormsModule,
|
||||
ReactiveFormsModule,
|
||||
RouterModule,
|
||||
NgClass,
|
||||
NgTemplateOutlet,
|
||||
NgbDropdownModule,
|
||||
NgbPaginationModule,
|
||||
NgxBootstrapIconsModule,
|
||||
],
|
||||
})
|
||||
export class DocumentTypeListComponent extends ManagementListComponent<DocumentType> {
|
||||
constructor() {
|
||||
super()
|
||||
this.service = inject(DocumentTypeService)
|
||||
this.editDialogComponent = DocumentTypeEditDialogComponent
|
||||
this.filterRuleType = FILTER_HAS_DOCUMENT_TYPE_ANY
|
||||
this.typeName = $localize`document type`
|
||||
this.typeNamePlural = $localize`document types`
|
||||
this.permissionType = PermissionType.DocumentType
|
||||
}
|
||||
|
||||
getDeleteMessage(object: DocumentType) {
|
||||
return $localize`Do you really want to delete the document type "${object.name}"?`
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
<div class="row mb-3">
|
||||
<div class="col mb-2 mb-xl-0">
|
||||
<div class="form-inline d-flex align-items-center">
|
||||
<label class="text-muted me-2 mb-0" i18n>Filter by:</label>
|
||||
<input class="form-control form-control-sm flex-fill w-auto" type="text" autofocus [(ngModel)]="nameFilter" (keyup)="onNameFilterKeyUp($event)" placeholder="Name" i18n-placeholder>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-auto mb-2 mb-xl-0">
|
||||
<div class="form-inline d-flex align-items-center">
|
||||
<div class="input-group input-group-sm w-auto d-none d-md-flex">
|
||||
<span class="input-group-text border-0" i18n>Show:</span>
|
||||
</div>
|
||||
<div class="input-group input-group-sm w-auto me-3">
|
||||
<select class="form-select form-select-sm small" [(ngModel)]="pageSize">
|
||||
<option [ngValue]="25">25</option>
|
||||
<option [ngValue]="50">50</option>
|
||||
<option [ngValue]="100">100</option>
|
||||
</select>
|
||||
<span class="input-group-text text-muted d-none d-md-flex" i18n>per page</span>
|
||||
</div>
|
||||
<ngb-pagination [pageSize]="pageSize" [collectionSize]="collectionSize" [(page)]="page" [maxSize]="5" (pageChange)="reloadData()" size="sm" aria-label="Pagination"></ngb-pagination>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div class="card border table-responsive mb-3">
|
||||
<table class="table table-striped align-middle shadow-sm mb-0">
|
||||
<thead>
|
||||
<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)]="togggleAll" [disabled]="data.length === 0" (change)="selectPage($event.target.checked); $event.stopPropagation();">
|
||||
<label class="form-check-label" for="all-objects"></label>
|
||||
</div>
|
||||
</th>
|
||||
<th scope="col" class="fw-normal" pngxSortable="name" [currentSortField]="sortField" [currentSortReverse]="sortReverse" (sort)="onSort($event)" i18n>Name</th>
|
||||
<th scope="col" class="fw-normal d-none d-sm-table-cell" pngxSortable="matching_algorithm" [currentSortField]="sortField" [currentSortReverse]="sortReverse" (sort)="onSort($event)" i18n>Matching</th>
|
||||
<th scope="col" class="fw-normal" pngxSortable="document_count" [currentSortField]="sortField" [currentSortReverse]="sortReverse" (sort)="onSort($event)" i18n>Document count</th>
|
||||
@for (column of extraColumns; track column) {
|
||||
<th scope="col" class="fw-normal" [ngClass]="{ 'd-none d-sm-table-cell' : column.hideOnMobile }" pngxSortable="{{column.key}}" [currentSortField]="sortField" [currentSortReverse]="sortReverse" (sort)="onSort($event)">{{column.name}}</th>
|
||||
}
|
||||
<th scope="col" class="fw-normal" i18n>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@if (loading && data.length === 0) {
|
||||
<tr>
|
||||
<td colspan="5">
|
||||
<div class="spinner-border spinner-border-sm me-2" role="status"></div>
|
||||
<ng-container i18n>Loading...</ng-container>
|
||||
</td>
|
||||
</tr>
|
||||
}
|
||||
@for (object of data; track object) {
|
||||
<ng-container [ngTemplateOutlet]="objectRow" [ngTemplateOutletContext]="{ object: object, depth: 0 }"></ng-container>
|
||||
}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
@if (!loading || data.length > 0) {
|
||||
<div class="d-flex mb-2">
|
||||
@if (collectionSize > 0) {
|
||||
<div>
|
||||
<ng-container i18n>{collectionSize, plural, =1 {One {{typeName}}} other {{{collectionSize || 0}} total {{typeNamePlural}}}}</ng-container>
|
||||
@if (selectedObjects.size > 0) {
|
||||
({{selectedObjects.size}} selected)
|
||||
}
|
||||
</div>
|
||||
}
|
||||
@if (collectionSize > 20) {
|
||||
<ngb-pagination class="ms-auto" [pageSize]="pageSize" [collectionSize]="collectionSize" [(page)]="page" [maxSize]="5" (pageChange)="reloadData()" size="sm" aria-label="Pagination"></ngb-pagination>
|
||||
}
|
||||
</div>
|
||||
}
|
||||
|
||||
<ng-template #objectRow let-object="object" let-depth="depth">
|
||||
<tr (click)="toggleSelected(object); $event.stopPropagation();" 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="{{typeName}}{{object.id}}" [checked]="selectedObjects.has(object.id)" (click)="toggleSelected(object); $event.stopPropagation();">
|
||||
<label class="form-check-label" for="{{typeName}}{{object.id}}"></label>
|
||||
</div>
|
||||
</td>
|
||||
<td scope="row" class="name-cell" style="--depth: {{depth}}">
|
||||
@if (depth > 0) {
|
||||
<div class="indicator"></div>
|
||||
}
|
||||
<button class="btn btn-link ms-0 ps-0 text-start" (click)="userCanEdit(object) ? openEditDialog(object) : null; $event.stopPropagation()">{{ object.name }}</button>
|
||||
</td>
|
||||
<td scope="row" class="d-none d-sm-table-cell">{{ getMatching(object) }}</td>
|
||||
<td scope="row">{{ getDocumentCount(object) }}</td>
|
||||
@for (column of extraColumns; track column) {
|
||||
<td scope="row" [ngClass]="{ 'd-none d-sm-table-cell' : column.hideOnMobile }">
|
||||
@if (column.badgeFn) {
|
||||
<span
|
||||
class="badge"
|
||||
[style.color]="column.badgeFn.call(null, object)?.textColor"
|
||||
[style.backgroundColor]="column.badgeFn.call(null, object)?.backgroundColor"
|
||||
>
|
||||
{{ column.badgeFn.call(null, object)?.text }}
|
||||
</span>
|
||||
} @else if (column.monospace) {
|
||||
<span class="font-monospace">{{ column.valueFn.call(null, object) }}</span>
|
||||
} @else {
|
||||
{{ column.valueFn.call(null, object) }}
|
||||
}
|
||||
</td>
|
||||
}
|
||||
<td scope="row">
|
||||
<div class="btn-toolbar gap-2">
|
||||
<div class="btn-group d-block d-sm-none">
|
||||
<div ngbDropdown container="body" class="d-inline-block">
|
||||
<button type="button" class="btn btn-link" id="actionsMenuMobile" (click)="$event.stopPropagation()" ngbDropdownToggle>
|
||||
<i-bs name="three-dots-vertical"></i-bs>
|
||||
</button>
|
||||
<div ngbDropdownMenu aria-labelledby="actionsMenuMobile">
|
||||
<button (click)="openEditDialog(object)" *pngxIfPermissions="{ action: PermissionAction.Change, type: permissionType }" ngbDropdownItem i18n>Edit</button>
|
||||
<button class="text-danger" (click)="openDeleteDialog(object)" *pngxIfPermissions="{ action: PermissionAction.Delete, type: permissionType }" ngbDropdownItem i18n>Delete</button>
|
||||
@if (getDocumentCount(object) > 0) {
|
||||
<a
|
||||
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }"
|
||||
ngbDropdownItem
|
||||
[routerLink]="getDocumentFilterUrl(object)"
|
||||
(click)="$event?.stopPropagation()"
|
||||
i18n
|
||||
>Filter Documents ({{ getDocumentCount(object) }})</a
|
||||
>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="btn-group d-none d-sm-inline-block">
|
||||
<button class="btn btn-sm btn-outline-secondary" (click)="openEditDialog(object); $event.stopPropagation();" *pngxIfPermissions="{ action: PermissionAction.Change, type: permissionType }" [disabled]="!userCanEdit(object)">
|
||||
<i-bs width="1em" height="1em" name="pencil"></i-bs> <ng-container i18n>Edit</ng-container>
|
||||
</button>
|
||||
<button class="btn btn-sm btn-outline-danger" (click)="openDeleteDialog(object); $event.stopPropagation();" *pngxIfPermissions="{ action: PermissionAction.Delete, type: permissionType }" [disabled]="!userCanDelete(object)">
|
||||
<i-bs width="1em" height="1em" name="trash"></i-bs> <ng-container i18n>Delete</ng-container>
|
||||
</button>
|
||||
</div>
|
||||
@if (getDocumentCount(object) > 0) {
|
||||
<div class="btn-group d-none d-sm-inline-block">
|
||||
<a
|
||||
class="btn btn-sm btn-outline-secondary"
|
||||
*pngxIfPermissions="{ action: PermissionAction.View, type: PermissionType.Document }"
|
||||
[routerLink]="getDocumentFilterUrl(object)"
|
||||
(click)="$event?.stopPropagation()"
|
||||
>
|
||||
<i-bs width="1em" height="1em" name="filter"></i-bs> <ng-container i18n>Documents</ng-container
|
||||
><span class="badge bg-light text-secondary ms-2">{{ getDocumentCount(object) }}</span>
|
||||
</a>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
@if (object.children && object.children.length > 0) {
|
||||
@for (child of object.children; track child) {
|
||||
<ng-container [ngTemplateOutlet]="objectRow" [ngTemplateOutletContext]="{ object: getOriginalObject(child), depth: depth + 1 }"></ng-container>
|
||||
}
|
||||
}
|
||||
</ng-template>
|
||||
@@ -0,0 +1,30 @@
|
||||
// hide caret on mobile dropdown
|
||||
.d-block.d-sm-none .dropdown-toggle::after {
|
||||
display: none;
|
||||
}
|
||||
|
||||
tbody tr:last-child td {
|
||||
border-bottom: none !important;
|
||||
}
|
||||
|
||||
.form-check {
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
td.name-cell {
|
||||
padding-left: calc(calc(var(--depth) - 1) * 1.1rem);
|
||||
|
||||
.indicator {
|
||||
display: inline-block;
|
||||
width: .8rem;
|
||||
height: .8rem;
|
||||
border-left: 1px solid var(--bs-secondary);
|
||||
border-bottom: 1px solid var(--bs-secondary);
|
||||
margin-right: .25rem;
|
||||
margin-left: .5rem;
|
||||
}
|
||||
}
|
||||
|
||||
select.small {
|
||||
font-size: 0.875rem !important; // 14px
|
||||
}
|
||||
@@ -0,0 +1,516 @@
|
||||
import { DatePipe } from '@angular/common'
|
||||
import {
|
||||
HttpErrorResponse,
|
||||
provideHttpClient,
|
||||
withInterceptorsFromDi,
|
||||
} from '@angular/common/http'
|
||||
import { provideHttpClientTesting } from '@angular/common/http/testing'
|
||||
import {
|
||||
ComponentFixture,
|
||||
TestBed,
|
||||
fakeAsync,
|
||||
tick,
|
||||
} from '@angular/core/testing'
|
||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||
import { By } from '@angular/platform-browser'
|
||||
import { RouterLinkWithHref } from '@angular/router'
|
||||
import { RouterTestingModule } from '@angular/router/testing'
|
||||
import {
|
||||
NgbModal,
|
||||
NgbModalModule,
|
||||
NgbModalRef,
|
||||
NgbPaginationModule,
|
||||
} from '@ng-bootstrap/ng-bootstrap'
|
||||
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
||||
import { of, throwError } from 'rxjs'
|
||||
import { routes } from 'src/app/app-routing.module'
|
||||
import { FILTER_HAS_TAGS_ALL } from 'src/app/data/filter-rule-type'
|
||||
import {
|
||||
MATCH_AUTO,
|
||||
MATCH_LITERAL,
|
||||
MATCH_NONE,
|
||||
} from 'src/app/data/matching-model'
|
||||
import { Tag } from 'src/app/data/tag'
|
||||
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
||||
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
||||
import { SortableDirective } from 'src/app/directives/sortable.directive'
|
||||
import { PermissionsGuard } from 'src/app/guards/permissions.guard'
|
||||
import { DocumentListViewService } from 'src/app/services/document-list-view.service'
|
||||
import {
|
||||
PermissionAction,
|
||||
PermissionsService,
|
||||
} from 'src/app/services/permissions.service'
|
||||
import { BulkEditObjectOperation } from 'src/app/services/rest/abstract-name-filter-service'
|
||||
import { TagService } from 'src/app/services/rest/tag.service'
|
||||
import { SettingsService } from 'src/app/services/settings.service'
|
||||
import { ToastService } from 'src/app/services/toast.service'
|
||||
import { ConfirmDialogComponent } from '../../common/confirm-dialog/confirm-dialog.component'
|
||||
import { EditDialogComponent } from '../../common/edit-dialog/edit-dialog.component'
|
||||
import { PageHeaderComponent } from '../../common/page-header/page-header.component'
|
||||
import { PermissionsDialogComponent } from '../../common/permissions-dialog/permissions-dialog.component'
|
||||
import { ManagementListComponent } from './management-list.component'
|
||||
import { TagListComponent } from './tag-list/tag-list.component'
|
||||
|
||||
const tags: Tag[] = [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Tag1 Foo',
|
||||
matching_algorithm: MATCH_LITERAL,
|
||||
match: 'foo',
|
||||
document_count: 35,
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'Tag2',
|
||||
matching_algorithm: MATCH_NONE,
|
||||
document_count: 0,
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: 'Tag3',
|
||||
matching_algorithm: MATCH_AUTO,
|
||||
document_count: 5,
|
||||
},
|
||||
]
|
||||
|
||||
describe('ManagementListComponent', () => {
|
||||
let component: ManagementListComponent<Tag>
|
||||
let fixture: ComponentFixture<ManagementListComponent<Tag>>
|
||||
let tagService: TagService
|
||||
let modalService: NgbModal
|
||||
let toastService: ToastService
|
||||
let documentListViewService: DocumentListViewService
|
||||
let permissionsService: PermissionsService
|
||||
let settingsService: SettingsService
|
||||
|
||||
beforeEach(async () => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [
|
||||
NgbPaginationModule,
|
||||
FormsModule,
|
||||
ReactiveFormsModule,
|
||||
NgbModalModule,
|
||||
RouterTestingModule.withRoutes(routes),
|
||||
NgxBootstrapIconsModule.pick(allIcons),
|
||||
TagListComponent,
|
||||
SortableDirective,
|
||||
PageHeaderComponent,
|
||||
IfPermissionsDirective,
|
||||
ConfirmDialogComponent,
|
||||
PermissionsDialogComponent,
|
||||
],
|
||||
providers: [
|
||||
DatePipe,
|
||||
PermissionsGuard,
|
||||
provideHttpClient(withInterceptorsFromDi()),
|
||||
provideHttpClientTesting(),
|
||||
],
|
||||
}).compileComponents()
|
||||
|
||||
tagService = TestBed.inject(TagService)
|
||||
jest
|
||||
.spyOn(tagService, 'listFiltered')
|
||||
.mockImplementation(
|
||||
(page, pageSize, sortField, sortReverse, nameFilter, fullPerms) => {
|
||||
const results = nameFilter
|
||||
? tags.filter((t) => t.name.toLowerCase().includes(nameFilter))
|
||||
: tags
|
||||
return of({
|
||||
count: results.length,
|
||||
all: results.map((o) => o.id),
|
||||
results,
|
||||
})
|
||||
}
|
||||
)
|
||||
permissionsService = TestBed.inject(PermissionsService)
|
||||
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(true)
|
||||
jest
|
||||
.spyOn(permissionsService, 'currentUserHasObjectPermissions')
|
||||
.mockReturnValue(true)
|
||||
jest
|
||||
.spyOn(permissionsService, 'currentUserOwnsObject')
|
||||
.mockReturnValue(true)
|
||||
modalService = TestBed.inject(NgbModal)
|
||||
toastService = TestBed.inject(ToastService)
|
||||
documentListViewService = TestBed.inject(DocumentListViewService)
|
||||
settingsService = TestBed.inject(SettingsService)
|
||||
fixture = TestBed.createComponent(TagListComponent)
|
||||
component = fixture.componentInstance
|
||||
fixture.detectChanges()
|
||||
})
|
||||
|
||||
// These tests are shared among all management list components
|
||||
|
||||
it('should support filtering, clear on Esc key', fakeAsync(() => {
|
||||
const nameFilterInput = fixture.debugElement.query(By.css('input'))
|
||||
nameFilterInput.nativeElement.value = 'foo'
|
||||
// nameFilterInput.nativeElement.dispatchEvent(new Event('input'))
|
||||
component.nameFilter = 'foo' // subject normally triggered by ngModel
|
||||
tick(400) // debounce
|
||||
fixture.detectChanges()
|
||||
expect(component.data).toEqual([tags[0]])
|
||||
|
||||
nameFilterInput.nativeElement.dispatchEvent(
|
||||
new KeyboardEvent('keyup', { code: 'Escape' })
|
||||
)
|
||||
tick(400) // debounce
|
||||
fixture.detectChanges()
|
||||
expect(component.nameFilter).toBeNull()
|
||||
expect(component.data).toEqual(tags)
|
||||
tick(100) // load
|
||||
}))
|
||||
|
||||
it('should support create, show notification on error / success', () => {
|
||||
let modal: NgbModalRef
|
||||
modalService.activeInstances.subscribe((m) => (modal = m[m.length - 1]))
|
||||
const toastErrorSpy = jest.spyOn(toastService, 'showError')
|
||||
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
|
||||
const reloadSpy = jest.spyOn(component, 'reloadData')
|
||||
|
||||
component.openCreateDialog()
|
||||
|
||||
expect(modal).not.toBeUndefined()
|
||||
const editDialog = modal.componentInstance as EditDialogComponent<Tag>
|
||||
|
||||
// fail first
|
||||
editDialog.failed.emit({ error: 'error creating item' })
|
||||
expect(toastErrorSpy).toHaveBeenCalled()
|
||||
expect(reloadSpy).not.toHaveBeenCalled()
|
||||
|
||||
// succeed
|
||||
editDialog.succeeded.emit()
|
||||
expect(toastInfoSpy).toHaveBeenCalled()
|
||||
expect(reloadSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should support edit, show notification on error / success', () => {
|
||||
let modal: NgbModalRef
|
||||
modalService.activeInstances.subscribe((m) => (modal = m[m.length - 1]))
|
||||
const toastErrorSpy = jest.spyOn(toastService, 'showError')
|
||||
const toastInfoSpy = jest.spyOn(toastService, 'showInfo')
|
||||
const reloadSpy = jest.spyOn(component, 'reloadData')
|
||||
|
||||
component.openEditDialog(tags[0])
|
||||
|
||||
expect(modal).not.toBeUndefined()
|
||||
const editDialog = modal.componentInstance as EditDialogComponent<Tag>
|
||||
expect(editDialog.object).toEqual(tags[0])
|
||||
|
||||
// fail first
|
||||
editDialog.failed.emit({ error: 'error editing item' })
|
||||
expect(toastErrorSpy).toHaveBeenCalled()
|
||||
expect(reloadSpy).not.toHaveBeenCalled()
|
||||
|
||||
// succeed
|
||||
editDialog.succeeded.emit()
|
||||
expect(toastInfoSpy).toHaveBeenCalled()
|
||||
expect(reloadSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should support delete, show notification on error / success', () => {
|
||||
let modal: NgbModalRef
|
||||
modalService.activeInstances.subscribe((m) => (modal = m[m.length - 1]))
|
||||
const toastErrorSpy = jest.spyOn(toastService, 'showError')
|
||||
const deleteSpy = jest.spyOn(tagService, 'delete')
|
||||
const reloadSpy = jest.spyOn(component, 'reloadData')
|
||||
|
||||
component.openDeleteDialog(tags[0])
|
||||
|
||||
expect(modal).not.toBeUndefined()
|
||||
const editDialog = modal.componentInstance as ConfirmDialogComponent
|
||||
|
||||
// fail first
|
||||
deleteSpy.mockReturnValueOnce(throwError(() => new Error('error deleting')))
|
||||
editDialog.confirmClicked.emit()
|
||||
expect(toastErrorSpy).toHaveBeenCalled()
|
||||
expect(reloadSpy).not.toHaveBeenCalled()
|
||||
|
||||
// succeed
|
||||
deleteSpy.mockReturnValueOnce(of(true))
|
||||
editDialog.confirmClicked.emit()
|
||||
expect(reloadSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should use the all list length for collection size when provided', fakeAsync(() => {
|
||||
jest.spyOn(tagService, 'listFiltered').mockReturnValueOnce(
|
||||
of({
|
||||
count: 1,
|
||||
all: [1, 2, 3],
|
||||
results: tags.slice(0, 1),
|
||||
})
|
||||
)
|
||||
|
||||
component.reloadData()
|
||||
tick(100)
|
||||
|
||||
expect(component.collectionSize).toBe(3)
|
||||
}))
|
||||
|
||||
it('should support quick filter for objects', () => {
|
||||
const expectedUrl = documentListViewService.getQuickFilterUrl([
|
||||
{ rule_type: FILTER_HAS_TAGS_ALL, value: tags[0].id.toString() },
|
||||
])
|
||||
const filterLink = fixture.debugElement.query(
|
||||
By.css('a.btn-outline-secondary')
|
||||
)
|
||||
expect(filterLink).toBeTruthy()
|
||||
const routerLink = filterLink.injector.get(RouterLinkWithHref)
|
||||
expect(routerLink.urlTree).toEqual(expectedUrl)
|
||||
})
|
||||
|
||||
it('should reload on sort', () => {
|
||||
const reloadSpy = jest.spyOn(component, 'reloadData')
|
||||
const sortable = fixture.debugElement.query(By.directive(SortableDirective))
|
||||
sortable.triggerEventHandler('click')
|
||||
expect(reloadSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should fall back to first page if error is page is out of range', () => {
|
||||
jest.spyOn(tagService, 'listFiltered').mockReturnValueOnce(
|
||||
throwError(
|
||||
() =>
|
||||
new HttpErrorResponse({
|
||||
status: 404,
|
||||
error: { detail: 'Invalid page' },
|
||||
})
|
||||
)
|
||||
)
|
||||
component.page = 2
|
||||
component.reloadData()
|
||||
expect(component.page).toEqual(1)
|
||||
})
|
||||
|
||||
it('should support toggle select page in vew', () => {
|
||||
expect(component.selectedObjects.size).toEqual(0)
|
||||
const selectPageSpy = jest.spyOn(component, 'selectPage')
|
||||
const checkButton = fixture.debugElement.queryAll(
|
||||
By.css('input.form-check-input')
|
||||
)[0]
|
||||
checkButton.nativeElement.dispatchEvent(new Event('change'))
|
||||
checkButton.nativeElement.checked = true
|
||||
checkButton.nativeElement.dispatchEvent(new Event('change'))
|
||||
expect(selectPageSpy).toHaveBeenCalled()
|
||||
expect(component.selectedObjects.size).toEqual(tags.length)
|
||||
})
|
||||
|
||||
it('selectNone should clear selection and reset toggle flag', () => {
|
||||
component.selectedObjects = new Set([tags[0].id, tags[1].id])
|
||||
component.togggleAll = true
|
||||
|
||||
component.selectNone()
|
||||
|
||||
expect(component.selectedObjects.size).toBe(0)
|
||||
expect(component.togggleAll).toBe(false)
|
||||
})
|
||||
|
||||
it('selectPage should select current page items or clear selection', () => {
|
||||
component.selectPage(true)
|
||||
expect(component.selectedObjects).toEqual(new Set(tags.map((t) => t.id)))
|
||||
expect(component.togggleAll).toBe(true)
|
||||
|
||||
component.togggleAll = true
|
||||
component.selectPage(false)
|
||||
expect(component.selectedObjects.size).toBe(0)
|
||||
expect(component.togggleAll).toBe(false)
|
||||
})
|
||||
|
||||
it('selectAll should use all IDs when collection size exists', () => {
|
||||
;(component as any).allIDs = [1, 2, 3, 4]
|
||||
component.collectionSize = 4
|
||||
|
||||
component.selectAll()
|
||||
|
||||
expect(component.selectedObjects).toEqual(new Set([1, 2, 3, 4]))
|
||||
expect(component.togggleAll).toBe(true)
|
||||
})
|
||||
|
||||
it('selectAll should clear selection when collection size is zero', () => {
|
||||
component.selectedObjects = new Set([1])
|
||||
component.collectionSize = 0
|
||||
component.togggleAll = true
|
||||
|
||||
component.selectAll()
|
||||
|
||||
expect(component.selectedObjects.size).toBe(0)
|
||||
expect(component.togggleAll).toBe(false)
|
||||
})
|
||||
|
||||
it('toggleSelected should toggle object selection and update toggle state', () => {
|
||||
component.toggleSelected(tags[0])
|
||||
expect(component.selectedObjects.has(tags[0].id)).toBe(true)
|
||||
expect(component.togggleAll).toBe(false)
|
||||
|
||||
component.toggleSelected(tags[1])
|
||||
component.toggleSelected(tags[2])
|
||||
expect(component.togggleAll).toBe(true)
|
||||
|
||||
component.toggleSelected(tags[1])
|
||||
expect(component.selectedObjects.has(tags[1].id)).toBe(false)
|
||||
expect(component.togggleAll).toBe(false)
|
||||
})
|
||||
|
||||
it('areAllPageItemsSelected should return false when page has no selectable items', () => {
|
||||
component.data = []
|
||||
component.selectedObjects.clear()
|
||||
|
||||
expect((component as any).areAllPageItemsSelected()).toBe(false)
|
||||
|
||||
component.data = tags
|
||||
})
|
||||
|
||||
it('should support bulk edit permissions', () => {
|
||||
const bulkEditPermsSpy = jest.spyOn(tagService, 'bulk_edit_objects')
|
||||
component.toggleSelected(tags[0])
|
||||
component.toggleSelected(tags[1])
|
||||
component.toggleSelected(tags[2])
|
||||
component.toggleSelected(tags[2]) // uncheck, for coverage
|
||||
const selected = new Set([tags[0].id, tags[1].id])
|
||||
expect(component.selectedObjects).toEqual(selected)
|
||||
let modal: NgbModalRef
|
||||
modalService.activeInstances.subscribe((m) => (modal = m[m.length - 1]))
|
||||
fixture.detectChanges()
|
||||
component.setPermissions()
|
||||
expect(modal).not.toBeUndefined()
|
||||
|
||||
// fail first
|
||||
bulkEditPermsSpy.mockReturnValueOnce(
|
||||
throwError(() => new Error('error setting permissions'))
|
||||
)
|
||||
const errorToastSpy = jest.spyOn(toastService, 'showError')
|
||||
modal.componentInstance.confirmClicked.emit({
|
||||
permissions: {},
|
||||
merge: true,
|
||||
})
|
||||
expect(bulkEditPermsSpy).toHaveBeenCalled()
|
||||
expect(errorToastSpy).toHaveBeenCalled()
|
||||
|
||||
const successToastSpy = jest.spyOn(toastService, 'showInfo')
|
||||
bulkEditPermsSpy.mockReturnValueOnce(of('OK'))
|
||||
modal.componentInstance.confirmClicked.emit({
|
||||
permissions: {},
|
||||
merge: true,
|
||||
})
|
||||
expect(bulkEditPermsSpy).toHaveBeenCalled()
|
||||
expect(successToastSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should support bulk delete objects', () => {
|
||||
const bulkEditSpy = jest.spyOn(tagService, 'bulk_edit_objects')
|
||||
component.toggleSelected(tags[0])
|
||||
component.toggleSelected(tags[1])
|
||||
const selected = new Set([tags[0].id, tags[1].id])
|
||||
expect(component.selectedObjects).toEqual(selected)
|
||||
let modal: NgbModalRef
|
||||
modalService.activeInstances.subscribe((m) => (modal = m[m.length - 1]))
|
||||
fixture.detectChanges()
|
||||
component.delete()
|
||||
expect(modal).not.toBeUndefined()
|
||||
|
||||
// fail first
|
||||
bulkEditSpy.mockReturnValueOnce(
|
||||
throwError(() => new Error('error setting permissions'))
|
||||
)
|
||||
const errorToastSpy = jest.spyOn(toastService, 'showError')
|
||||
modal.componentInstance.confirmClicked.emit(null)
|
||||
expect(bulkEditSpy).toHaveBeenCalledWith(
|
||||
Array.from(selected),
|
||||
BulkEditObjectOperation.Delete
|
||||
)
|
||||
expect(errorToastSpy).toHaveBeenCalled()
|
||||
|
||||
const successToastSpy = jest.spyOn(toastService, 'showInfo')
|
||||
bulkEditSpy.mockReturnValueOnce(of('OK'))
|
||||
modal.componentInstance.confirmClicked.emit(null)
|
||||
expect(bulkEditSpy).toHaveBeenCalled()
|
||||
expect(successToastSpy).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('should disallow bulk permissions or delete objects if no global perms', () => {
|
||||
jest.spyOn(permissionsService, 'currentUserCan').mockReturnValue(false)
|
||||
expect(component.userCanBulkEdit(PermissionAction.Delete)).toBeFalsy()
|
||||
expect(component.userCanBulkEdit(PermissionAction.Change)).toBeFalsy()
|
||||
})
|
||||
|
||||
it('should return an original object from filtered child object', () => {
|
||||
const childTag: Tag = {
|
||||
id: 4,
|
||||
name: 'Child Tag',
|
||||
matching_algorithm: MATCH_LITERAL,
|
||||
match: 'child',
|
||||
document_count: 10,
|
||||
parent: 1,
|
||||
}
|
||||
component['unfilteredData'].push(childTag)
|
||||
const original = component.getOriginalObject({ id: 4 } as Tag)
|
||||
expect(original).toEqual(childTag)
|
||||
})
|
||||
|
||||
it('getSelectableIDs should return flat ids when not overridden', () => {
|
||||
const ids = (
|
||||
ManagementListComponent.prototype as any
|
||||
).getSelectableIDs.call({}, [{ id: 1 }, { id: 5 }] as any)
|
||||
expect(ids).toEqual([1, 5])
|
||||
})
|
||||
|
||||
it('pageSize getter should return stored page size or default to 25', () => {
|
||||
jest.spyOn(settingsService, 'get').mockReturnValue({ tags: 50 })
|
||||
component.typeNamePlural = 'tags'
|
||||
|
||||
expect(component.pageSize).toBe(50)
|
||||
})
|
||||
|
||||
it('pageSize getter should return 25 when no size is stored', () => {
|
||||
const settingsService = TestBed.inject(SettingsService)
|
||||
jest.spyOn(settingsService, 'get').mockReturnValue({})
|
||||
component.typeNamePlural = 'tags'
|
||||
|
||||
expect(component.pageSize).toBe(25)
|
||||
})
|
||||
|
||||
it('pageSize setter should update settings, reset page and reload data on success', fakeAsync(() => {
|
||||
const reloadSpy = jest.spyOn(component, 'reloadData')
|
||||
const toastErrorSpy = jest.spyOn(toastService, 'showError')
|
||||
|
||||
jest.spyOn(settingsService, 'get').mockReturnValue({ tags: 25 })
|
||||
jest.spyOn(settingsService, 'set').mockImplementation(() => {})
|
||||
jest
|
||||
.spyOn(settingsService, 'storeSettings')
|
||||
.mockReturnValue(of({ success: true }))
|
||||
|
||||
component.typeNamePlural = 'tags'
|
||||
component.page = 2
|
||||
component.pageSize = 100
|
||||
|
||||
tick()
|
||||
|
||||
expect(settingsService.set).toHaveBeenCalledWith(
|
||||
SETTINGS_KEYS.OBJECT_LIST_SIZES,
|
||||
{ tags: 100 }
|
||||
)
|
||||
expect(component.page).toBe(1)
|
||||
expect(reloadSpy).toHaveBeenCalled()
|
||||
expect(toastErrorSpy).not.toHaveBeenCalled()
|
||||
}))
|
||||
|
||||
it('pageSize setter should show error toast on settings store failure', fakeAsync(() => {
|
||||
const reloadSpy = jest.spyOn(component, 'reloadData')
|
||||
const toastErrorSpy = jest.spyOn(toastService, 'showError')
|
||||
|
||||
jest.spyOn(settingsService, 'get').mockReturnValue({ tags: 25 })
|
||||
jest.spyOn(settingsService, 'set').mockImplementation(() => {})
|
||||
jest
|
||||
.spyOn(settingsService, 'storeSettings')
|
||||
.mockReturnValue(throwError(() => new Error('error storing settings')))
|
||||
|
||||
component.typeNamePlural = 'tags'
|
||||
component.pageSize = 50
|
||||
|
||||
tick()
|
||||
|
||||
expect(toastErrorSpy).toHaveBeenCalledWith(
|
||||
'Error saving settings',
|
||||
expect.any(Error)
|
||||
)
|
||||
expect(reloadSpy).not.toHaveBeenCalled()
|
||||
}))
|
||||
})
|
||||
@@ -0,0 +1,442 @@
|
||||
import { HttpErrorResponse } from '@angular/common/http'
|
||||
import {
|
||||
Directive,
|
||||
inject,
|
||||
OnDestroy,
|
||||
OnInit,
|
||||
QueryList,
|
||||
ViewChildren,
|
||||
} from '@angular/core'
|
||||
import { NgbModal } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { Subject } from 'rxjs'
|
||||
import {
|
||||
debounceTime,
|
||||
delay,
|
||||
distinctUntilChanged,
|
||||
takeUntil,
|
||||
tap,
|
||||
} from 'rxjs/operators'
|
||||
import { ConfirmDialogComponent } from 'src/app/components/common/confirm-dialog/confirm-dialog.component'
|
||||
import { EditDialogMode } from 'src/app/components/common/edit-dialog/edit-dialog.component'
|
||||
import { PermissionsDialogComponent } from 'src/app/components/common/permissions-dialog/permissions-dialog.component'
|
||||
import { LoadingComponentWithPermissions } from 'src/app/components/loading-component/loading.component'
|
||||
import {
|
||||
MATCH_AUTO,
|
||||
MATCH_NONE,
|
||||
MATCHING_ALGORITHMS,
|
||||
MatchingModel,
|
||||
} from 'src/app/data/matching-model'
|
||||
import { ObjectWithPermissions } from 'src/app/data/object-with-permissions'
|
||||
import { SETTINGS_KEYS } from 'src/app/data/ui-settings'
|
||||
import {
|
||||
SortableDirective,
|
||||
SortEvent,
|
||||
} from 'src/app/directives/sortable.directive'
|
||||
import { DocumentListViewService } from 'src/app/services/document-list-view.service'
|
||||
import {
|
||||
PermissionAction,
|
||||
PermissionsService,
|
||||
PermissionType,
|
||||
} from 'src/app/services/permissions.service'
|
||||
import {
|
||||
AbstractNameFilterService,
|
||||
BulkEditObjectOperation,
|
||||
} from 'src/app/services/rest/abstract-name-filter-service'
|
||||
import { SettingsService } from 'src/app/services/settings.service'
|
||||
import { ToastService } from 'src/app/services/toast.service'
|
||||
|
||||
export interface ManagementListColumn {
|
||||
key: string
|
||||
|
||||
name: string
|
||||
|
||||
valueFn?: any
|
||||
|
||||
badgeFn?: (object: any) => {
|
||||
text: string
|
||||
textColor?: string
|
||||
backgroundColor?: string
|
||||
}
|
||||
|
||||
hideOnMobile?: boolean
|
||||
|
||||
monospace?: boolean
|
||||
}
|
||||
|
||||
@Directive()
|
||||
export abstract class ManagementListComponent<T extends MatchingModel>
|
||||
extends LoadingComponentWithPermissions
|
||||
implements OnInit, OnDestroy
|
||||
{
|
||||
protected service: AbstractNameFilterService<T>
|
||||
private modalService: NgbModal = inject(NgbModal)
|
||||
protected editDialogComponent: any
|
||||
private toastService: ToastService = inject(ToastService)
|
||||
private documentListViewService: DocumentListViewService = inject(
|
||||
DocumentListViewService
|
||||
)
|
||||
private permissionsService: PermissionsService = inject(PermissionsService)
|
||||
protected filterRuleType: number
|
||||
public typeName: string
|
||||
public typeNamePlural: string
|
||||
public permissionType: PermissionType
|
||||
public extraColumns: ManagementListColumn[]
|
||||
|
||||
private readonly settingsService = inject(SettingsService)
|
||||
|
||||
@ViewChildren(SortableDirective) headers: QueryList<SortableDirective>
|
||||
|
||||
public data: T[] = []
|
||||
private unfilteredData: T[] = []
|
||||
private allIDs: number[] = []
|
||||
|
||||
public page = 1
|
||||
|
||||
public collectionSize = 0
|
||||
|
||||
public sortField: string
|
||||
public sortReverse: boolean
|
||||
|
||||
private nameFilterDebounce: Subject<string>
|
||||
protected unsubscribeNotifier: Subject<any> = new Subject()
|
||||
protected _nameFilter: string
|
||||
|
||||
public selectedObjects: Set<number> = new Set()
|
||||
public togggleAll: boolean = false
|
||||
|
||||
ngOnInit(): void {
|
||||
this.reloadData()
|
||||
|
||||
this.nameFilterDebounce = new Subject<string>()
|
||||
|
||||
this.nameFilterDebounce
|
||||
.pipe(
|
||||
takeUntil(this.unsubscribeNotifier),
|
||||
debounceTime(400),
|
||||
distinctUntilChanged()
|
||||
)
|
||||
.subscribe((title) => {
|
||||
this._nameFilter = title
|
||||
this.page = 1
|
||||
this.reloadData()
|
||||
})
|
||||
}
|
||||
|
||||
getMatching(o: MatchingModel) {
|
||||
if (o.matching_algorithm == MATCH_AUTO) {
|
||||
return $localize`Automatic`
|
||||
} else if (o.matching_algorithm == MATCH_NONE) {
|
||||
return $localize`None`
|
||||
} else if (o.match && o.match.length > 0) {
|
||||
return `${
|
||||
MATCHING_ALGORITHMS.find((a) => a.id == o.matching_algorithm).shortName
|
||||
}: ${o.match}`
|
||||
} else {
|
||||
return '-'
|
||||
}
|
||||
}
|
||||
|
||||
onSort(event: SortEvent) {
|
||||
this.sortField = event.column
|
||||
this.sortReverse = event.reverse
|
||||
this.reloadData()
|
||||
}
|
||||
|
||||
protected filterData(data: T[]): T[] {
|
||||
return data
|
||||
}
|
||||
|
||||
getDocumentCount(object: MatchingModel): number {
|
||||
return (
|
||||
object.document_count ??
|
||||
this.unfilteredData.find((d) => d.id == object.id)?.document_count ??
|
||||
0
|
||||
)
|
||||
}
|
||||
|
||||
public getOriginalObject(object: T): T {
|
||||
return this.unfilteredData.find((d) => d?.id == object?.id) || object
|
||||
}
|
||||
|
||||
reloadData(extraParams: { [key: string]: any } = null) {
|
||||
this.loading = true
|
||||
this.clearSelection()
|
||||
this.service
|
||||
.listFiltered(
|
||||
this.page,
|
||||
this.pageSize,
|
||||
this.sortField,
|
||||
this.sortReverse,
|
||||
this._nameFilter,
|
||||
true,
|
||||
extraParams
|
||||
)
|
||||
.pipe(
|
||||
takeUntil(this.unsubscribeNotifier),
|
||||
tap((c) => {
|
||||
this.unfilteredData = c.results
|
||||
this.data = this.filterData(c.results)
|
||||
this.collectionSize = c.all?.length ?? c.count
|
||||
this.allIDs = c.all
|
||||
}),
|
||||
delay(100)
|
||||
)
|
||||
.subscribe({
|
||||
error: (error: HttpErrorResponse) => {
|
||||
if (error.error?.detail?.includes('Invalid page')) {
|
||||
this.page = 1
|
||||
this.reloadData()
|
||||
}
|
||||
},
|
||||
next: () => {
|
||||
this.show = true
|
||||
this.loading = false
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
openCreateDialog() {
|
||||
var activeModal = this.modalService.open(this.editDialogComponent, {
|
||||
backdrop: 'static',
|
||||
})
|
||||
activeModal.componentInstance.dialogMode = EditDialogMode.CREATE
|
||||
activeModal.componentInstance.succeeded.subscribe(() => {
|
||||
this.reloadData()
|
||||
this.toastService.showInfo(
|
||||
$localize`Successfully created ${this.typeName}.`
|
||||
)
|
||||
})
|
||||
activeModal.componentInstance.failed.subscribe((e) => {
|
||||
this.toastService.showError(
|
||||
$localize`Error occurred while creating ${this.typeName}.`,
|
||||
e
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
openEditDialog(object: T) {
|
||||
var activeModal = this.modalService.open(this.editDialogComponent, {
|
||||
backdrop: 'static',
|
||||
})
|
||||
activeModal.componentInstance.object = object
|
||||
activeModal.componentInstance.dialogMode = EditDialogMode.EDIT
|
||||
activeModal.componentInstance.succeeded.subscribe(() => {
|
||||
this.reloadData()
|
||||
this.toastService.showInfo(
|
||||
$localize`Successfully updated ${this.typeName} "${object.name}".`
|
||||
)
|
||||
})
|
||||
activeModal.componentInstance.failed.subscribe((e) => {
|
||||
this.toastService.showError(
|
||||
$localize`Error occurred while saving ${this.typeName}.`,
|
||||
e
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
abstract getDeleteMessage(object: T)
|
||||
|
||||
getDocumentFilterUrl(object: MatchingModel) {
|
||||
return this.documentListViewService.getQuickFilterUrl([
|
||||
{ rule_type: this.filterRuleType, value: object.id.toString() },
|
||||
])
|
||||
}
|
||||
|
||||
openDeleteDialog(object: T) {
|
||||
var activeModal = this.modalService.open(ConfirmDialogComponent, {
|
||||
backdrop: 'static',
|
||||
})
|
||||
activeModal.componentInstance.title = $localize`Confirm delete`
|
||||
activeModal.componentInstance.messageBold = this.getDeleteMessage(object)
|
||||
activeModal.componentInstance.message = $localize`Associated documents will not be deleted.`
|
||||
activeModal.componentInstance.btnClass = 'btn-danger'
|
||||
activeModal.componentInstance.btnCaption = $localize`Delete`
|
||||
activeModal.componentInstance.confirmClicked.subscribe(() => {
|
||||
activeModal.componentInstance.buttonsEnabled = false
|
||||
this.service
|
||||
.delete(object)
|
||||
.pipe(takeUntil(this.unsubscribeNotifier))
|
||||
.subscribe({
|
||||
next: () => {
|
||||
activeModal.close()
|
||||
this.reloadData()
|
||||
},
|
||||
error: (error) => {
|
||||
activeModal.componentInstance.buttonsEnabled = true
|
||||
this.toastService.showError(
|
||||
$localize`Error while deleting element`,
|
||||
error
|
||||
)
|
||||
},
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
get nameFilter() {
|
||||
return this._nameFilter
|
||||
}
|
||||
|
||||
set nameFilter(nameFilter: string) {
|
||||
this.nameFilterDebounce.next(nameFilter)
|
||||
}
|
||||
|
||||
onNameFilterKeyUp(event: KeyboardEvent) {
|
||||
if (event.code == 'Escape') this.nameFilterDebounce.next(null)
|
||||
}
|
||||
|
||||
public get pageSize(): number {
|
||||
return (
|
||||
this.settingsService.get(SETTINGS_KEYS.OBJECT_LIST_SIZES)[
|
||||
this.typeNamePlural
|
||||
] || 25
|
||||
)
|
||||
}
|
||||
|
||||
public set pageSize(newPageSize: number) {
|
||||
this.settingsService.set(SETTINGS_KEYS.OBJECT_LIST_SIZES, {
|
||||
...this.settingsService.get(SETTINGS_KEYS.OBJECT_LIST_SIZES),
|
||||
[this.typeNamePlural]: newPageSize,
|
||||
})
|
||||
this.settingsService.storeSettings().subscribe({
|
||||
next: () => {
|
||||
this.page = 1
|
||||
this.reloadData()
|
||||
},
|
||||
error: (error) => {
|
||||
this.toastService.showError($localize`Error saving settings`, error)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
userCanDelete(object: ObjectWithPermissions): boolean {
|
||||
return this.permissionsService.currentUserOwnsObject(object)
|
||||
}
|
||||
|
||||
userCanEdit(object: ObjectWithPermissions): boolean {
|
||||
return this.permissionsService.currentUserHasObjectPermissions(
|
||||
this.PermissionAction.Change,
|
||||
object
|
||||
)
|
||||
}
|
||||
|
||||
userCanBulkEdit(action: PermissionAction): boolean {
|
||||
if (!this.permissionsService.currentUserCan(action, this.permissionType))
|
||||
return false
|
||||
let ownsAll: boolean = true
|
||||
const objects = this.data.filter((o) => this.selectedObjects.has(o.id))
|
||||
ownsAll = objects.every((o) =>
|
||||
this.permissionsService.currentUserOwnsObject(o)
|
||||
)
|
||||
return ownsAll
|
||||
}
|
||||
|
||||
protected getSelectableIDs(objects: T[]): number[] {
|
||||
return objects.map((o) => o.id)
|
||||
}
|
||||
|
||||
clearSelection() {
|
||||
this.togggleAll = false
|
||||
this.selectedObjects.clear()
|
||||
}
|
||||
|
||||
selectNone() {
|
||||
this.clearSelection()
|
||||
}
|
||||
|
||||
selectPage(select: boolean) {
|
||||
if (select) {
|
||||
this.selectedObjects = new Set(this.getSelectableIDs(this.data))
|
||||
this.togggleAll = this.areAllPageItemsSelected()
|
||||
} else {
|
||||
this.clearSelection()
|
||||
}
|
||||
}
|
||||
|
||||
selectAll() {
|
||||
if (!this.collectionSize) {
|
||||
this.clearSelection()
|
||||
return
|
||||
}
|
||||
this.selectedObjects = new Set(this.allIDs)
|
||||
this.togggleAll = this.areAllPageItemsSelected()
|
||||
}
|
||||
|
||||
toggleSelected(object) {
|
||||
this.selectedObjects.has(object.id)
|
||||
? this.selectedObjects.delete(object.id)
|
||||
: this.selectedObjects.add(object.id)
|
||||
this.togggleAll = this.areAllPageItemsSelected()
|
||||
}
|
||||
|
||||
protected areAllPageItemsSelected(): boolean {
|
||||
const ids = this.getSelectableIDs(this.data)
|
||||
return ids.length > 0 && ids.every((id) => this.selectedObjects.has(id))
|
||||
}
|
||||
|
||||
setPermissions() {
|
||||
let modal = this.modalService.open(PermissionsDialogComponent, {
|
||||
backdrop: 'static',
|
||||
})
|
||||
modal.componentInstance.confirmClicked.subscribe(
|
||||
({ permissions, merge }) => {
|
||||
modal.componentInstance.buttonsEnabled = false
|
||||
this.service
|
||||
.bulk_edit_objects(
|
||||
Array.from(this.selectedObjects),
|
||||
BulkEditObjectOperation.SetPermissions,
|
||||
permissions,
|
||||
merge
|
||||
)
|
||||
.subscribe({
|
||||
next: () => {
|
||||
modal.close()
|
||||
this.toastService.showInfo(
|
||||
$localize`Permissions updated successfully`
|
||||
)
|
||||
this.reloadData()
|
||||
},
|
||||
error: (error) => {
|
||||
modal.componentInstance.buttonsEnabled = true
|
||||
this.toastService.showError(
|
||||
$localize`Error updating permissions`,
|
||||
error
|
||||
)
|
||||
},
|
||||
})
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
delete() {
|
||||
let modal = this.modalService.open(ConfirmDialogComponent, {
|
||||
backdrop: 'static',
|
||||
})
|
||||
modal.componentInstance.title = $localize`Confirm delete`
|
||||
modal.componentInstance.messageBold = $localize`This operation will permanently delete the selected ${this.typeNamePlural}.`
|
||||
modal.componentInstance.message = $localize`This operation cannot be undone.`
|
||||
modal.componentInstance.btnClass = 'btn-danger'
|
||||
modal.componentInstance.btnCaption = $localize`Proceed`
|
||||
modal.componentInstance.confirmClicked.subscribe(() => {
|
||||
modal.componentInstance.buttonsEnabled = false
|
||||
this.service
|
||||
.bulk_edit_objects(
|
||||
Array.from(this.selectedObjects),
|
||||
BulkEditObjectOperation.Delete
|
||||
)
|
||||
.subscribe({
|
||||
next: () => {
|
||||
modal.close()
|
||||
this.toastService.showInfo($localize`Objects deleted successfully`)
|
||||
this.reloadData()
|
||||
},
|
||||
error: (error) => {
|
||||
modal.componentInstance.buttonsEnabled = true
|
||||
this.toastService.showError(
|
||||
$localize`Error deleting objects`,
|
||||
error
|
||||
)
|
||||
},
|
||||
})
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,84 @@
|
||||
import { DatePipe } from '@angular/common'
|
||||
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
|
||||
import { provideHttpClientTesting } from '@angular/common/http/testing'
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||
import { NgbPaginationModule } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
||||
import { of } from 'rxjs'
|
||||
import { StoragePath } from 'src/app/data/storage-path'
|
||||
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
||||
import { SortableDirective } from 'src/app/directives/sortable.directive'
|
||||
import { StoragePathService } from 'src/app/services/rest/storage-path.service'
|
||||
import { PageHeaderComponent } from '../../../common/page-header/page-header.component'
|
||||
import { StoragePathListComponent } from './storage-path-list.component'
|
||||
|
||||
describe('StoragePathListComponent', () => {
|
||||
let component: StoragePathListComponent
|
||||
let fixture: ComponentFixture<StoragePathListComponent>
|
||||
let storagePathService: StoragePathService
|
||||
|
||||
beforeEach(async () => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [
|
||||
NgbPaginationModule,
|
||||
FormsModule,
|
||||
ReactiveFormsModule,
|
||||
NgxBootstrapIconsModule.pick(allIcons),
|
||||
StoragePathListComponent,
|
||||
SortableDirective,
|
||||
PageHeaderComponent,
|
||||
IfPermissionsDirective,
|
||||
],
|
||||
providers: [
|
||||
DatePipe,
|
||||
provideHttpClient(withInterceptorsFromDi()),
|
||||
provideHttpClientTesting(),
|
||||
],
|
||||
}).compileComponents()
|
||||
|
||||
storagePathService = TestBed.inject(StoragePathService)
|
||||
jest.spyOn(storagePathService, 'listFiltered').mockReturnValue(
|
||||
of({
|
||||
count: 3,
|
||||
all: [1, 2, 3],
|
||||
results: [
|
||||
{
|
||||
id: 1,
|
||||
name: 'StoragePath1',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'StoragePath2',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: 'StoragePath3',
|
||||
},
|
||||
],
|
||||
})
|
||||
)
|
||||
fixture = TestBed.createComponent(StoragePathListComponent)
|
||||
component = fixture.componentInstance
|
||||
fixture.detectChanges()
|
||||
})
|
||||
|
||||
// Tests are included in management-list.component.spec.ts
|
||||
|
||||
it('should use correct delete message', () => {
|
||||
expect(component.getDeleteMessage({ id: 1, name: 'StoragePath1' })).toEqual(
|
||||
'Do you really want to delete the storage path "StoragePath1"?'
|
||||
)
|
||||
})
|
||||
|
||||
it('should truncate path if necessary', () => {
|
||||
const path: StoragePath = {
|
||||
id: 1,
|
||||
name: 'StoragePath1',
|
||||
path: 'a'.repeat(100),
|
||||
}
|
||||
expect(component.extraColumns[0].valueFn(path)).toEqual(
|
||||
`${'a'.repeat(49)}...`
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,61 @@
|
||||
import { NgClass, NgTemplateOutlet } from '@angular/common'
|
||||
import { Component, inject } from '@angular/core'
|
||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||
import { RouterModule } from '@angular/router'
|
||||
import {
|
||||
NgbDropdownModule,
|
||||
NgbPaginationModule,
|
||||
} from '@ng-bootstrap/ng-bootstrap'
|
||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||
import { StoragePathEditDialogComponent } from 'src/app/components/common/edit-dialog/storage-path-edit-dialog/storage-path-edit-dialog.component'
|
||||
import { FILTER_HAS_STORAGE_PATH_ANY } from 'src/app/data/filter-rule-type'
|
||||
import { StoragePath } from 'src/app/data/storage-path'
|
||||
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
||||
import { SortableDirective } from 'src/app/directives/sortable.directive'
|
||||
import { PermissionType } from 'src/app/services/permissions.service'
|
||||
import { StoragePathService } from 'src/app/services/rest/storage-path.service'
|
||||
import { ManagementListComponent } from '../management-list.component'
|
||||
|
||||
@Component({
|
||||
selector: 'pngx-storage-path-list',
|
||||
templateUrl: './../management-list.component.html',
|
||||
styleUrls: ['./../management-list.component.scss'],
|
||||
imports: [
|
||||
SortableDirective,
|
||||
IfPermissionsDirective,
|
||||
FormsModule,
|
||||
ReactiveFormsModule,
|
||||
RouterModule,
|
||||
NgClass,
|
||||
NgTemplateOutlet,
|
||||
NgbDropdownModule,
|
||||
NgbPaginationModule,
|
||||
NgxBootstrapIconsModule,
|
||||
],
|
||||
})
|
||||
export class StoragePathListComponent extends ManagementListComponent<StoragePath> {
|
||||
constructor() {
|
||||
super()
|
||||
this.service = inject(StoragePathService)
|
||||
this.editDialogComponent = StoragePathEditDialogComponent
|
||||
this.filterRuleType = FILTER_HAS_STORAGE_PATH_ANY
|
||||
this.typeName = $localize`storage path`
|
||||
this.typeNamePlural = $localize`storage paths`
|
||||
this.permissionType = PermissionType.StoragePath
|
||||
this.extraColumns = [
|
||||
{
|
||||
key: 'path',
|
||||
name: $localize`Path`,
|
||||
hideOnMobile: true,
|
||||
monospace: true,
|
||||
valueFn: (c: StoragePath) => {
|
||||
return `${c.path?.slice(0, 49)}${c.path?.length > 50 ? '...' : ''}`
|
||||
},
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
getDeleteMessage(object: StoragePath) {
|
||||
return $localize`Do you really want to delete the storage path "${object.name}"?`
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import { DatePipe } from '@angular/common'
|
||||
import { provideHttpClient, withInterceptorsFromDi } from '@angular/common/http'
|
||||
import { provideHttpClientTesting } from '@angular/common/http/testing'
|
||||
import { ComponentFixture, TestBed } from '@angular/core/testing'
|
||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||
import { NgbPaginationModule } from '@ng-bootstrap/ng-bootstrap'
|
||||
import { NgxBootstrapIconsModule, allIcons } from 'ngx-bootstrap-icons'
|
||||
import { of } from 'rxjs'
|
||||
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
||||
import { SortableDirective } from 'src/app/directives/sortable.directive'
|
||||
import { TagService } from 'src/app/services/rest/tag.service'
|
||||
import { PageHeaderComponent } from '../../../common/page-header/page-header.component'
|
||||
import { TagListComponent } from './tag-list.component'
|
||||
|
||||
describe('TagListComponent', () => {
|
||||
let component: TagListComponent
|
||||
let fixture: ComponentFixture<TagListComponent>
|
||||
let tagService: TagService
|
||||
let listFilteredSpy: jest.SpyInstance
|
||||
|
||||
beforeEach(async () => {
|
||||
TestBed.configureTestingModule({
|
||||
imports: [
|
||||
NgbPaginationModule,
|
||||
FormsModule,
|
||||
ReactiveFormsModule,
|
||||
NgxBootstrapIconsModule.pick(allIcons),
|
||||
TagListComponent,
|
||||
SortableDirective,
|
||||
PageHeaderComponent,
|
||||
IfPermissionsDirective,
|
||||
],
|
||||
providers: [
|
||||
DatePipe,
|
||||
provideHttpClient(withInterceptorsFromDi()),
|
||||
provideHttpClientTesting(),
|
||||
],
|
||||
}).compileComponents()
|
||||
|
||||
tagService = TestBed.inject(TagService)
|
||||
listFilteredSpy = jest.spyOn(tagService, 'listFiltered').mockReturnValue(
|
||||
of({
|
||||
count: 3,
|
||||
all: [1, 2, 3],
|
||||
results: [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Tag1',
|
||||
},
|
||||
{
|
||||
id: 2,
|
||||
name: 'Tag2',
|
||||
},
|
||||
{
|
||||
id: 3,
|
||||
name: 'Tag3',
|
||||
},
|
||||
],
|
||||
})
|
||||
)
|
||||
fixture = TestBed.createComponent(TagListComponent)
|
||||
component = fixture.componentInstance
|
||||
fixture.detectChanges()
|
||||
})
|
||||
|
||||
// Tests are included in management-list.component.spec.ts
|
||||
|
||||
it('should use correct delete message', () => {
|
||||
expect(component.getDeleteMessage({ id: 1, name: 'Tag1' })).toEqual(
|
||||
'Do you really want to delete the tag "Tag1"?'
|
||||
)
|
||||
})
|
||||
|
||||
it('should omit matching children from top level when their parent is present', () => {
|
||||
const tags = [
|
||||
{
|
||||
id: 1,
|
||||
name: 'Tag1',
|
||||
parent: null,
|
||||
children: [{ id: 2, name: 'Tag2', parent: 1 }],
|
||||
},
|
||||
{ id: 2, name: 'Tag2', parent: 1 },
|
||||
{ id: 3, name: 'Tag3', parent: null },
|
||||
]
|
||||
component['_nameFilter'] = null // Simulate empty name filter
|
||||
const filtered = component.filterData(tags as any)
|
||||
expect(filtered.length).toBe(2)
|
||||
expect(filtered.find((t) => t.id === 2)).toBeUndefined()
|
||||
|
||||
component['_nameFilter'] = 'Tag2' // Simulate non-empty name filter
|
||||
const filteredWithName = component.filterData(tags as any)
|
||||
expect(filteredWithName.length).toBe(2)
|
||||
expect(filteredWithName.find((t) => t.id === 2)).toBeUndefined()
|
||||
expect(
|
||||
filteredWithName
|
||||
.find((t) => t.id === 1)
|
||||
?.children?.some((c) => c.id === 2)
|
||||
).toBe(true)
|
||||
})
|
||||
|
||||
it('should request only parent tags when no name filter is applied', () => {
|
||||
expect(tagService.listFiltered).toHaveBeenCalledWith(
|
||||
1,
|
||||
25,
|
||||
undefined,
|
||||
undefined,
|
||||
undefined,
|
||||
true,
|
||||
{ is_root: true }
|
||||
)
|
||||
})
|
||||
|
||||
it('should include child tags when a name filter is applied', () => {
|
||||
listFilteredSpy.mockClear()
|
||||
component['_nameFilter'] = 'Tag'
|
||||
component.reloadData()
|
||||
expect(tagService.listFiltered).toHaveBeenCalledWith(
|
||||
1,
|
||||
25,
|
||||
undefined,
|
||||
undefined,
|
||||
'Tag',
|
||||
true,
|
||||
null
|
||||
)
|
||||
})
|
||||
|
||||
it('should include child tags when selecting all', () => {
|
||||
const parent = {
|
||||
id: 10,
|
||||
name: 'Parent',
|
||||
children: [
|
||||
{
|
||||
id: 11,
|
||||
name: 'Child',
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
component.data = [parent as any]
|
||||
component.selectPage(true)
|
||||
|
||||
expect(component.selectedObjects.has(10)).toBe(true)
|
||||
expect(component.selectedObjects.has(11)).toBe(true)
|
||||
|
||||
component.selectPage(false)
|
||||
expect(component.selectedObjects.size).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,91 @@
|
||||
import { NgClass, NgTemplateOutlet } from '@angular/common'
|
||||
import { Component, inject } from '@angular/core'
|
||||
import { FormsModule, ReactiveFormsModule } from '@angular/forms'
|
||||
import { RouterModule } from '@angular/router'
|
||||
import {
|
||||
NgbDropdownModule,
|
||||
NgbPaginationModule,
|
||||
} from '@ng-bootstrap/ng-bootstrap'
|
||||
import { NgxBootstrapIconsModule } from 'ngx-bootstrap-icons'
|
||||
import { TagEditDialogComponent } from 'src/app/components/common/edit-dialog/tag-edit-dialog/tag-edit-dialog.component'
|
||||
import { FILTER_HAS_TAGS_ALL } from 'src/app/data/filter-rule-type'
|
||||
import { Tag } from 'src/app/data/tag'
|
||||
import { IfPermissionsDirective } from 'src/app/directives/if-permissions.directive'
|
||||
import { SortableDirective } from 'src/app/directives/sortable.directive'
|
||||
import { PermissionType } from 'src/app/services/permissions.service'
|
||||
import { TagService } from 'src/app/services/rest/tag.service'
|
||||
import { ManagementListComponent } from '../management-list.component'
|
||||
|
||||
@Component({
|
||||
selector: 'pngx-tag-list',
|
||||
templateUrl: './../management-list.component.html',
|
||||
styleUrls: ['./../management-list.component.scss'],
|
||||
imports: [
|
||||
SortableDirective,
|
||||
IfPermissionsDirective,
|
||||
FormsModule,
|
||||
ReactiveFormsModule,
|
||||
RouterModule,
|
||||
NgClass,
|
||||
NgTemplateOutlet,
|
||||
NgbDropdownModule,
|
||||
NgbPaginationModule,
|
||||
NgxBootstrapIconsModule,
|
||||
],
|
||||
})
|
||||
export class TagListComponent extends ManagementListComponent<Tag> {
|
||||
constructor() {
|
||||
super()
|
||||
this.service = inject(TagService)
|
||||
this.editDialogComponent = TagEditDialogComponent
|
||||
this.filterRuleType = FILTER_HAS_TAGS_ALL
|
||||
this.typeName = $localize`tag`
|
||||
this.typeNamePlural = $localize`tags`
|
||||
this.permissionType = PermissionType.Tag
|
||||
this.extraColumns = [
|
||||
{
|
||||
key: 'color',
|
||||
name: $localize`Color`,
|
||||
badgeFn: (t: Tag) => ({
|
||||
text: t.color,
|
||||
textColor: t.text_color,
|
||||
backgroundColor: t.color,
|
||||
}),
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
getDeleteMessage(object: Tag) {
|
||||
return $localize`Do you really want to delete the tag "${object.name}"?`
|
||||
}
|
||||
|
||||
override reloadData(extraParams: { [key: string]: any } = null) {
|
||||
const params = this.nameFilter?.length
|
||||
? extraParams
|
||||
: { ...extraParams, is_root: true }
|
||||
super.reloadData(params)
|
||||
}
|
||||
|
||||
filterData(data: Tag[]) {
|
||||
if (!this.nameFilter?.length) {
|
||||
return data.filter((tag) => !tag.parent)
|
||||
}
|
||||
|
||||
// When filtering by name, exclude children if their parent is also present
|
||||
const availableIds = new Set(data.map((tag) => tag.id))
|
||||
return data.filter((tag) => !tag.parent || !availableIds.has(tag.parent))
|
||||
}
|
||||
|
||||
protected override getSelectableIDs(tags: Tag[]): number[] {
|
||||
const ids: number[] = []
|
||||
for (const tag of tags.filter(Boolean)) {
|
||||
if (tag.id != null) {
|
||||
ids.push(tag.id)
|
||||
}
|
||||
if (Array.isArray(tag.children) && tag.children.length) {
|
||||
ids.push(...this.getSelectableIDs(tag.children))
|
||||
}
|
||||
}
|
||||
return ids
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user