More reorganizing

This commit is contained in:
shamoon
2026-02-06 21:15:55 -08:00
parent c634bb4a02
commit 2caf2ec5be
17 changed files with 18 additions and 18 deletions

View File

@@ -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>&nbsp;<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>&nbsp;<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>&nbsp;<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>

View File

@@ -0,0 +1,4 @@
// hide caret on mobile dropdown
.d-block.d-sm-none .dropdown-toggle::after {
display: none;
}

View File

@@ -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]],
]),
},
])
})
})

View File

@@ -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]],
]),
},
])
}
}

View File

@@ -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,

View File

@@ -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()
})
})

View File

@@ -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}"?`
}
}

View File

@@ -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"?')
})
})

View File

@@ -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}"?`
}
}

View File

@@ -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) {
&nbsp;({{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>&nbsp;<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>&nbsp;<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>&nbsp;<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>

View File

@@ -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
}

View File

@@ -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()
}))
})

View File

@@ -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
)
},
})
})
}
}

View File

@@ -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)}...`
)
})
})

View File

@@ -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}"?`
}
}

View File

@@ -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)
})
})

View File

@@ -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
}
}