diff --git a/src/app/clarin-licenses/clarin-license-table/clarin-license-table.component.html b/src/app/clarin-licenses/clarin-license-table/clarin-license-table.component.html index 729b0f781d5..e6b0ddb10e7 100644 --- a/src/app/clarin-licenses/clarin-license-table/clarin-license-table.component.html +++ b/src/app/clarin-licenses/clarin-license-table/clarin-license-table.component.html @@ -83,4 +83,69 @@ + +
+

{{'clarin.license.label.section.title' | translate}}

+ +
+ +
+ + + + + + + + + + + + + + + + + + + + + + +
{{'clarin.license.label.table.header.label' | translate}}{{'clarin.license.label.table.header.title' | translate}}{{'clarin.license.label.table.header.extended' | translate}}{{'clarin.license.label.table.header.icon' | translate}}{{'clarin.license.label.table.header.actions' | translate}}
{{label?.label}}{{label?.title}}{{label?.extended ? ('clarin.license.label.table.boolean.yes' | translate) : ('clarin.license.label.table.boolean.no' | translate)}} + + {{label?.icon?.length > 0 ? ('clarin.license.label.table.icon.available' | translate) : ('clarin.license.label.table.icon.none' | translate)}} + + + + + +
{{'clarin.license.label.table.empty' | translate}}
+
+ + + {{'clarin.license.label.table.loading' | translate}} +
+
+
diff --git a/src/app/clarin-licenses/clarin-license-table/clarin-license-table.component.scss b/src/app/clarin-licenses/clarin-license-table/clarin-license-table.component.scss index b4dab6de9bf..2ea1fa73dbf 100644 --- a/src/app/clarin-licenses/clarin-license-table/clarin-license-table.component.scss +++ b/src/app/clarin-licenses/clarin-license-table/clarin-license-table.component.scss @@ -15,3 +15,7 @@ width: 3.5%; max-width: 3.5%; } + +.labels-actions-column { + width: 11rem; +} diff --git a/src/app/clarin-licenses/clarin-license-table/clarin-license-table.component.spec.ts b/src/app/clarin-licenses/clarin-license-table/clarin-license-table.component.spec.ts index 0cc824bdd8b..32b255a3f04 100644 --- a/src/app/clarin-licenses/clarin-license-table/clarin-license-table.component.spec.ts +++ b/src/app/clarin-licenses/clarin-license-table/clarin-license-table.component.spec.ts @@ -1,9 +1,11 @@ -import { ComponentFixture, fakeAsync, TestBed } from '@angular/core/testing'; +import { ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; import { ClarinLicenseTableComponent } from './clarin-license-table.component'; import { NotificationsServiceStub } from '../../shared/testing/notifications-service.stub'; import { ClarinLicenseDataService } from '../../core/data/clarin/clarin-license-data.service'; import { RequestService } from '../../core/data/request.service'; -import { of as observableOf } from 'rxjs'; +import { EventEmitter } from '@angular/core'; +import { of as observableOf, throwError } from 'rxjs'; import { SharedModule } from '../../shared/shared.module'; import { CommonModule } from '@angular/common'; import { ReactiveFormsModule } from '@angular/forms'; @@ -15,22 +17,32 @@ import { NotificationsService } from '../../shared/notifications/notifications.s import { defaultPagination } from '../clarin-license-table-pagination'; import { ClarinLicenseLabelDataService } from '../../core/data/clarin/clarin-license-label-data.service'; import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; +import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; import { HostWindowService } from '../../shared/host-window.service'; import { HostWindowServiceStub } from '../../shared/testing/host-window-service.stub'; import { createdLicenseLabelRD$, createdLicenseRD$, mockExtendedLicenseLabel, + mockLicenseLabelListRD$, mockLicense, mockLicenseRD$, mockNonExtendedLicenseLabel, successfulResponse } from '../../shared/testing/clarin-license-mock'; import {GroupDataService} from '../../core/eperson/group-data.service'; -import {createSuccessfulRemoteDataObject$} from '../../shared/remote-data.utils'; +import {createSuccessfulRemoteDataObject, createSuccessfulRemoteDataObject$} from '../../shared/remote-data.utils'; +import { createFailedRemoteDataObject$, createNoContentRemoteDataObject$ } from '../../shared/remote-data.utils'; +import { createFailedRemoteDataObject } from '../../shared/remote-data.utils'; import {createPaginatedList} from '../../shared/testing/utils.test'; import {LinkHeadService} from '../../core/services/link-head.service'; import {ConfigurationDataService} from '../../core/data/configuration-data.service'; import {ConfigurationProperty} from '../../core/shared/configuration-property.model'; import {SearchConfigurationService} from '../../core/shared/search/search-configuration.service'; +import { DefineLicenseLabelFormComponent } from './modal/define-license-label-form/define-license-label-form.component'; +import { ConfirmationModalComponent } from '../../shared/confirmation-modal/confirmation-modal.component'; +import { ClarinLicenseLabel } from '../../core/shared/clarin/clarin-license-label.model'; +import { ClarinLicense } from '../../core/shared/clarin/clarin-license.model'; +import { buildPaginatedList } from '../../core/data/paginated-list.model'; +import { PageInfo } from '../../core/shared/page-info.model'; describe('ClarinLicenseTableComponent', () => { let component: ClarinLicenseTableComponent; @@ -40,10 +52,13 @@ describe('ClarinLicenseTableComponent', () => { let clarinLicenseLabelDataService: ClarinLicenseLabelDataService; let requestService: RequestService; let notificationService: NotificationsServiceStub; - let modalStub: NgbActiveModal; + let activeModalStub: NgbActiveModal; + let modalServiceStub: jasmine.SpyObj; let groupsDataService: GroupDataService; let service: ConfigurationDataService; let searchConfigurationServiceStub: SearchConfigurationService; + let labelEditModalRef: any; + let labelDeleteModalRef: any; beforeEach(async () => { notificationService = new NotificationsServiceStub(); @@ -55,14 +70,36 @@ describe('ClarinLicenseTableComponent', () => { getLinkPath: observableOf('') }); clarinLicenseLabelDataService = jasmine.createSpyObj('clarinLicenseLabelService', { - create: createdLicenseLabelRD$ + create: createdLicenseLabelRD$, + findAll: mockLicenseLabelListRD$, + put: createdLicenseLabelRD$, + delete: observableOf({ hasSucceeded: true }) }); requestService = jasmine.createSpyObj('requestService', { send: observableOf('response'), getByUUID: observableOf(successfulResponse), generateRequestId: observableOf('123456'), }); - modalStub = jasmine.createSpyObj('modalService', ['close', 'open']); + activeModalStub = jasmine.createSpyObj('activeModal', ['close', 'open']); + modalServiceStub = jasmine.createSpyObj('modalService', ['open']); + labelEditModalRef = { + componentInstance: {}, + result: Promise.resolve(null) + }; + labelDeleteModalRef = { + componentInstance: { + response: new EventEmitter() + } + }; + modalServiceStub.open.and.callFake((modalComponent) => { + if (modalComponent === DefineLicenseLabelFormComponent) { + return labelEditModalRef; + } + if (modalComponent === ConfirmationModalComponent) { + return labelDeleteModalRef; + } + return { componentInstance: {}, result: Promise.resolve(null) } as any; + }); groupsDataService = jasmine.createSpyObj('groupsDataService', { findListByHref: createSuccessfulRemoteDataObject$(createPaginatedList([])), getGroupRegistryRouterLink: '' @@ -101,7 +138,8 @@ describe('ClarinLicenseTableComponent', () => { { provide: ClarinLicenseLabelDataService, useValue: clarinLicenseLabelDataService }, { provide: PaginationService, useValue: new PaginationServiceStub() }, { provide: NotificationsService, useValue: notificationService }, - { provide: NgbActiveModal, useValue: modalStub }, + { provide: NgbActiveModal, useValue: activeModalStub }, + { provide: NgbModal, useValue: modalServiceStub }, { provide: HostWindowService, useValue: new HostWindowServiceStub(0) }, { provide: GroupDataService, useValue: groupsDataService }, { provide: LinkHeadService, useValue: linkHeadService }, @@ -181,4 +219,231 @@ describe('ClarinLicenseTableComponent', () => { expect((component as any).clarinLicenseService.searchBy).toHaveBeenCalled(); expect((component as ClarinLicenseTableComponent).licensesRD$).not.toBeNull(); }); + + describe('label edit flow', () => { + beforeEach(() => { + notificationService.success.calls.reset(); + notificationService.error.calls.reset(); + (clarinLicenseLabelDataService.put as jasmine.Spy).calls.reset(); + }); + + it('should open edit modal with the selected label when editLabel is called', () => { + component.editLabel(mockExtendedLicenseLabel); + + expect(modalServiceStub.open).toHaveBeenCalledWith(DefineLicenseLabelFormComponent); + expect(labelEditModalRef.componentInstance.clarinLicenseLabel).toBe(mockExtendedLicenseLabel); + }); + + it('should call clarinLicenseLabelService.put with updated label on modal submit', fakeAsync(() => { + const refreshSpy = spyOn(component, 'refreshLabels').and.stub(); + const reloadLicensesSpy = spyOn(component, 'loadAllLicenses').and.stub(); + labelEditModalRef.result = Promise.resolve({ + label: 'EDIT', + title: 'Edited title', + extended: false + }); + + component.editLabel(mockExtendedLicenseLabel); + tick(); + + expect((clarinLicenseLabelDataService.put as jasmine.Spy)).toHaveBeenCalled(); + const putArgument = (clarinLicenseLabelDataService.put as jasmine.Spy).calls.mostRecent().args[0]; + expect(putArgument.id).toBe(mockExtendedLicenseLabel.id); + expect(putArgument._links).toEqual(mockExtendedLicenseLabel._links); + expect(putArgument.label).toBe('EDIT'); + expect(putArgument.title).toBe('Edited title'); + expect(putArgument.extended).toBeFalse(); + expect(notificationService.success).toHaveBeenCalled(); + expect(refreshSpy).toHaveBeenCalled(); + expect(reloadLicensesSpy).toHaveBeenCalled(); + })); + + it('should show error notification on failed edit', fakeAsync(() => { + spyOn(component, 'refreshLabels').and.stub(); + (clarinLicenseLabelDataService.put as jasmine.Spy).and.returnValue(createFailedRemoteDataObject$('put failed', 500)); + + component.editLicenseLabel({ + label: 'ERR', + title: 'Failed title', + extended: true + }, mockExtendedLicenseLabel); + tick(); + + expect(notificationService.error).toHaveBeenCalled(); + })); + }); + + describe('label delete flow', () => { + beforeEach(() => { + notificationService.success.calls.reset(); + notificationService.error.calls.reset(); + (clarinLicenseLabelDataService.delete as jasmine.Spy).calls.reset(); + labelDeleteModalRef.componentInstance.response = new EventEmitter(); + }); + + it('should open confirmation modal when confirmDeleteLabel is called', () => { + component.confirmDeleteLabel(mockNonExtendedLicenseLabel); + + expect(modalServiceStub.open).toHaveBeenCalledWith(ConfirmationModalComponent); + expect(labelDeleteModalRef.componentInstance.headerLabel).toBe('clarin.license.label.delete.confirm.title'); + expect(labelDeleteModalRef.componentInstance.infoLabel).toBe('clarin.license.label.delete.confirm.message'); + expect(labelDeleteModalRef.componentInstance.dso.name).toBe(mockNonExtendedLicenseLabel.label); + }); + + it('should call clarinLicenseLabelService.delete with correct id on confirmation', fakeAsync(() => { + const refreshSpy = spyOn(component, 'refreshLabels').and.stub(); + const reloadLicensesSpy = spyOn(component, 'loadAllLicenses').and.stub(); + (clarinLicenseLabelDataService.delete as jasmine.Spy).and.returnValue(createNoContentRemoteDataObject$()); + + component.confirmDeleteLabel(mockNonExtendedLicenseLabel); + labelDeleteModalRef.componentInstance.response.emit(true); + tick(); + + expect((clarinLicenseLabelDataService.delete as jasmine.Spy)).toHaveBeenCalledWith(String(mockNonExtendedLicenseLabel.id)); + expect(notificationService.success).toHaveBeenCalled(); + expect(refreshSpy).toHaveBeenCalled(); + expect(reloadLicensesSpy).toHaveBeenCalled(); + })); + + it('should show error notification on failed delete', () => { + spyOn(component, 'refreshLabels').and.stub(); + (clarinLicenseLabelDataService.delete as jasmine.Spy).and.returnValue(throwError(() => new Error('delete failed'))); + + component.confirmDeleteLabel(mockNonExtendedLicenseLabel); + labelDeleteModalRef.componentInstance.response.emit(true); + + expect(notificationService.error).toHaveBeenCalled(); + }); + + it('should not call delete service when confirmation is cancelled', () => { + component.confirmDeleteLabel(mockNonExtendedLicenseLabel); + labelDeleteModalRef.componentInstance.response.emit(false); + + expect((clarinLicenseLabelDataService.delete as jasmine.Spy)).not.toHaveBeenCalled(); + }); + }); + + describe('label row actions', () => { + const linkedLabel = Object.assign(new ClarinLicenseLabel(), { + id: 200, + label: 'LNKD', + title: 'Linked', + extended: false, + icon: null, + _links: { + self: { + href: 'url.linked' + } + } + }); + + const unlinkedLabel = Object.assign(new ClarinLicenseLabel(), { + id: 201, + label: 'UNLK', + title: 'Unlinked', + extended: false, + icon: null, + _links: { + self: { + href: 'url.unlinked' + } + } + }); + + const linkedLicense = Object.assign(new ClarinLicense(), { + ...mockLicense, + clarinLicenseLabel: linkedLabel, + extendedClarinLicenseLabels: [] + }); + + beforeEach(() => { + (component as any).labelsRD$.next( + createSuccessfulRemoteDataObject(buildPaginatedList(new PageInfo(), [linkedLabel, unlinkedLabel])) + ); + (component as any).inUseLabelIds = new Set([String(linkedLicense.clarinLicenseLabel.id)]); + fixture.detectChanges(); + }); + + it('should disable delete button and expose tooltip for linked labels', () => { + fixture.detectChanges(); + + const labelRows = fixture.debugElement.queryAll(By.css('.labels-section tbody tr')); + const linkedRowDeleteWrapper = labelRows[0].query(By.css('td:last-child span')); + const linkedRowButtons = labelRows[0].queryAll(By.css('button')); + const linkedDeleteButton = linkedRowButtons[1]; + + expect(linkedRowButtons.length).toBe(2); + expect(linkedDeleteButton.attributes['aria-disabled']).toBe('true'); + expect(linkedDeleteButton.nativeElement.classList.contains('disabled')).toBeTrue(); + expect((linkedRowDeleteWrapper.nativeElement as HTMLElement).getAttribute('tabindex')).toBe('0'); + expect((linkedRowDeleteWrapper.nativeElement as HTMLElement).getAttribute('ng-reflect-ngb-tooltip')).toContain('clarin.license.label.table.del'); + }); + + it('should not open confirmation modal when clicking disabled delete on linked label', () => { + const labelRows = fixture.debugElement.queryAll(By.css('.labels-section tbody tr')); + const linkedDeleteButton = labelRows[0].queryAll(By.css('button'))[1]; + + modalServiceStub.open.calls.reset(); + (clarinLicenseLabelDataService.delete as jasmine.Spy).calls.reset(); + + linkedDeleteButton.nativeElement.click(); + fixture.detectChanges(); + + expect(modalServiceStub.open).not.toHaveBeenCalledWith(ConfirmationModalComponent); + expect((clarinLicenseLabelDataService.delete as jasmine.Spy)).not.toHaveBeenCalled(); + }); + + it('should keep delete button enabled for unlinked labels', () => { + const labelRows = fixture.debugElement.queryAll(By.css('.labels-section tbody tr')); + const unlinkedRowDeleteWrapper = labelRows[1].query(By.css('td:last-child span')); + const unlinkedRowDeleteButton = labelRows[1].queryAll(By.css('button'))[1]; + + expect(unlinkedRowDeleteButton.attributes['aria-disabled']).toBe('false'); + expect(unlinkedRowDeleteButton.nativeElement.classList.contains('disabled')).toBeFalse(); + expect((unlinkedRowDeleteWrapper.nativeElement as HTMLElement).getAttribute('tabindex')).toBeNull(); + }); + }); + + it('should not show labels empty-state row when labels request failed', () => { + (component as any).loading$.next(false); + (component as any).labelsRD$.next(createFailedRemoteDataObject('labels load failed', 500)); + fixture.detectChanges(); + + const emptyStateRow = fixture.debugElement.queryAll(By.css('.labels-section tbody tr')) + .find((row) => row.nativeElement.textContent.includes('clarin.license.label.table.empty')); + + expect(emptyStateRow).toBeUndefined(); + }); + + describe('license usage loading performance', () => { + it('should load full usage dataset only once across repeated table reloads', () => { + (component as any).licenseUsageLoaded = false; + (component as any).licenseUsageLoading = false; + + const usageSpy = spyOn(component, 'loadAllLicensesForUsage').and.callFake(() => { + (component as any).licenseUsageLoading = false; + (component as any).licenseUsageLoaded = true; + }); + + component.loadAllLicenses(); + component.loadAllLicenses(); + + expect(usageSpy).toHaveBeenCalledTimes(1); + }); + + it('should force usage dataset reload when explicitly requested', () => { + (component as any).licenseUsageLoaded = false; + (component as any).licenseUsageLoading = false; + + const usageSpy = spyOn(component, 'loadAllLicensesForUsage').and.callFake(() => { + (component as any).licenseUsageLoading = false; + (component as any).licenseUsageLoaded = true; + }); + + component.loadAllLicenses(); + component.loadAllLicenses(true); + + expect(usageSpy).toHaveBeenCalledTimes(2); + }); + }); }); diff --git a/src/app/clarin-licenses/clarin-license-table/clarin-license-table.component.ts b/src/app/clarin-licenses/clarin-license-table/clarin-license-table.component.ts index 143f991aab1..70459e370c1 100644 --- a/src/app/clarin-licenses/clarin-license-table/clarin-license-table.component.ts +++ b/src/app/clarin-licenses/clarin-license-table/clarin-license-table.component.ts @@ -1,11 +1,11 @@ -import { Component, OnInit } from '@angular/core'; +import { Component, OnDestroy, OnInit } from '@angular/core'; import { PaginationComponentOptions } from '../../shared/pagination/pagination-component-options.model'; -import { BehaviorSubject, combineLatest as observableCombineLatest } from 'rxjs'; +import { BehaviorSubject, combineLatest as observableCombineLatest, Observable, of, Subject } from 'rxjs'; import { RemoteData } from '../../core/data/remote-data'; import { PaginatedList } from '../../core/data/paginated-list.model'; import { ClarinLicense } from '../../core/shared/clarin/clarin-license.model'; import { getFirstCompletedRemoteData, getFirstSucceededRemoteData } from '../../core/shared/operators'; -import { scan, switchMap } from 'rxjs/operators'; +import { scan, switchMap, take, takeUntil } from 'rxjs/operators'; import { PaginationService } from '../../core/pagination/pagination.service'; import { ClarinLicenseDataService } from '../../core/data/clarin/clarin-license-data.service'; import { defaultPagination, defaultSortConfiguration } from '../clarin-license-table-pagination'; @@ -22,6 +22,9 @@ import { ClarinLicenseLabelExtendedSerializer } from '../../core/shared/clarin/c import { ClarinLicenseRequiredInfoSerializer } from '../../core/shared/clarin/clarin-license-required-info-serializer'; import cloneDeep from 'lodash/cloneDeep'; import { RequestParam } from '../../core/cache/models/request-param.model'; +import { SortOptions } from '../../core/cache/models/sort-options.model'; +import { ConfirmationModalComponent } from '../../shared/confirmation-modal/confirmation-modal.component'; +import { DSpaceObject } from '../../core/shared/dspace-object.model'; /** * Component for managing clarin licenses and defining clarin license labels. @@ -31,7 +34,14 @@ import { RequestParam } from '../../core/cache/models/request-param.model'; templateUrl: './clarin-license-table.component.html', styleUrls: ['./clarin-license-table.component.scss'] }) -export class ClarinLicenseTableComponent implements OnInit { +export class ClarinLicenseTableComponent implements OnInit, OnDestroy { + + private readonly defaultListState = { + searchTerm: '', + currentPage: 1, + currentPagination: defaultPagination, + currentSort: defaultSortConfiguration + }; constructor(private paginationService: PaginationService, private clarinLicenseService: ClarinLicenseDataService, @@ -41,6 +51,12 @@ export class ClarinLicenseTableComponent implements OnInit { private notificationService: NotificationsService, private translateService: TranslateService,) { } + /** + * Full licenses dataset used by frontend-only label usage derivation. + */ + allLicensesRD$: BehaviorSubject>> = + new BehaviorSubject>>(null); + /** * The list of ClarinLicense object as BehaviorSubject object */ @@ -67,9 +83,65 @@ export class ClarinLicenseTableComponent implements OnInit { */ searchingLicenseName = ''; + /** + * RemoteData stream for license labels table. + */ + labelsRD$: BehaviorSubject>> = + new BehaviorSubject>>(null); + + /** + * Loading state for labels table. + */ + loading$ = new BehaviorSubject(false); + + /** + * Pagination configuration for labels table. + */ + labelPaginationOptions: PaginationComponentOptions = Object.assign(new PaginationComponentOptions(), { + id: 'cLicenseLabels', + currentPage: 1, + pageSize: 10 + }); + + /** + * Triggers a labels reload without changing pagination state. + */ + private labelsRefresh$ = new BehaviorSubject(undefined); + + /** + * Label ids currently linked from at least one license. + */ + private inUseLabelIds = new Set(); + + /** + * Page size used to retrieve all licenses for usage analysis. + */ + private readonly allLicensesPageSize = 100; + + /** + * Indicates whether the full usage crawl has completed successfully. + */ + private licenseUsageLoaded = false; + + /** + * Indicates whether a full usage crawl is currently in flight. + */ + private licenseUsageLoading = false; + + /** + * Emits when component is destroyed to clean up subscriptions. + */ + private ngUnsubscribe = new Subject(); + ngOnInit(): void { this.initializePaginationOptions(); this.loadAllLicenses(); + this.initializeLabelsPaginationStream(); + } + + ngOnDestroy(): void { + this.ngUnsubscribe.next(); + this.ngUnsubscribe.complete(); } // define license @@ -95,6 +167,7 @@ export class ClarinLicenseTableComponent implements OnInit { const errorMessageContentDef = 'clarin-license.define-license.notification.error-content'; if (isNull(clarinLicense)) { this.notifyOperationStatus(clarinLicense, successfulMessageContentDef, errorMessageContentDef); + return; } // convert string value from the form to the number @@ -109,7 +182,7 @@ export class ClarinLicenseTableComponent implements OnInit { .subscribe((defineLicenseResponse: RemoteData) => { // check payload and show error or successful this.notifyOperationStatus(defineLicenseResponse, successfulMessageContentDef, errorMessageContentDef); - this.loadAllLicenses(); + this.loadAllLicenses(true); }); } @@ -147,6 +220,7 @@ export class ClarinLicenseTableComponent implements OnInit { const errorMessageContentDef = 'clarin-license.edit-license.notification.error-content'; if (isNull(clarinLicense)) { this.notifyOperationStatus(clarinLicense, successfulMessageContentDef, errorMessageContentDef); + return; } const clarinLicenseObj = new ClarinLicense(); @@ -171,7 +245,7 @@ export class ClarinLicenseTableComponent implements OnInit { .subscribe((editResponse: RemoteData) => { // check payload and show error or successful this.notifyOperationStatus(editResponse, successfulMessageContentDef, errorMessageContentDef); - this.loadAllLicenses(); + this.loadAllLicenses(true); }); } @@ -212,10 +286,11 @@ export class ClarinLicenseTableComponent implements OnInit { * @param clarinLicenseLabel object from the License Label modal. */ defineLicenseLabel(clarinLicenseLabel: ClarinLicenseLabel) { - const successfulMessageContentDef = 'clarin-license-label.define-license-label.notification.successful-content'; - const errorMessageContentDef = 'clarin-license-label.define-license-label.notification.error-content'; + const successfulMessageContentDef = 'clarin.license.label.create.success'; + const errorMessageContentDef = 'clarin.license.label.create.error'; if (isNull(clarinLicenseLabel)) { this.notifyOperationStatus(clarinLicenseLabel, successfulMessageContentDef, errorMessageContentDef); + return; } // convert file to the byte array @@ -266,6 +341,7 @@ export class ClarinLicenseTableComponent implements OnInit { // check payload and show error or successful this.notifyOperationStatus(defineLicenseLabelResponse, successfulMessageContentDef, errorMessageContentDef); this.loadAllLicenses(); + this.refreshLabels(); }); } @@ -283,10 +359,140 @@ export class ClarinLicenseTableComponent implements OnInit { const successfulMessageContentDef = 'clarin-license.delete-license.notification.successful-content'; const errorMessageContentDef = 'clarin-license.delete-license.notification.error-content'; this.notifyOperationStatus(deleteLicenseResponse, successfulMessageContentDef, errorMessageContentDef); - this.loadAllLicenses(); + this.loadAllLicenses(true); + }); + } + + /** + * Open the edit modal for the selected license label, pre-filling its current values. + * On confirm, calls the PUT service and refreshes the label list. + */ + editLabel(label: ClarinLicenseLabel) { + if (isNull(label)) { + return; + } + + const editLabelModalRef = this.modalService.open(DefineLicenseLabelFormComponent); + editLabelModalRef.componentInstance.clarinLicenseLabel = label; + + editLabelModalRef.result.then((result) => { + this.editLicenseLabel(result, label); + }).catch(() => { /* dismissed */ }); + } + + /** + * Send a PUT request to update the selected label with the new form values. + * Handles success/error notifications and refreshes the label list. + * @param formValues The updated form values returned from the edit modal. + * @param selectedLabel The selected label row to update. + */ + editLicenseLabel(formValues: any, selectedLabel: ClarinLicenseLabel) { + const successMsg = 'clarin.license.label.edit.success'; + const errorMsg = 'clarin.license.label.edit.error'; + if (isNull(formValues) || isNull(selectedLabel)) { + this.notifyOperationStatus(null, successMsg, errorMsg); + return; + } + + const updatedLabel = new ClarinLicenseLabel(); + updatedLabel.id = selectedLabel.id; + updatedLabel._links = selectedLabel._links; + updatedLabel.type = selectedLabel.type; + updatedLabel.label = formValues.label; + updatedLabel.title = formValues.title; + updatedLabel.extended = !!formValues.extended; + + // file input: convert if a new file was selected, otherwise keep existing icon + const reader = new FileReader(); + try { + reader.readAsArrayBuffer(formValues.icon?.[0]); + reader.onerror = () => { + this.notifyOperationStatus(null, successMsg, errorMsg); + }; + reader.onloadend = (evt) => { + if (evt.target.readyState === FileReader.DONE) { + const buf = evt.target.result; + const bytes: number[] = []; + if (buf instanceof ArrayBuffer) { + const arr = new Uint8Array(buf); + for (const b of arr) { bytes.push(b); } + } + updatedLabel.icon = bytes; + this.doUpdateLabel(updatedLabel, successMsg, errorMsg); + } + }; + } catch { + // no new file selected – keep the existing icon from the stored label + updatedLabel.icon = selectedLabel.icon; + this.doUpdateLabel(updatedLabel, successMsg, errorMsg); + } + } + + /** + * Execute the actual PUT request for a label and handle notifications + dependent list refreshes. + */ + private doUpdateLabel(label: ClarinLicenseLabel, successMsg: string, errorMsg: string) { + this.clarinLicenseLabelService.put(label) + .pipe(getFirstCompletedRemoteData(), takeUntil(this.ngUnsubscribe)) + .subscribe((res: RemoteData) => { + this.notifyOperationStatus(res, successMsg, errorMsg); + if (res?.hasSucceeded) { + this.refreshLabels(); + this.loadAllLicenses(); + } + }); + } + + /** + * Ask for confirmation and delete the selected license label. + */ + confirmDeleteLabel(labelToDelete: ClarinLicenseLabel) { + if (isNull(labelToDelete?.id)) { + return; + } + + const labelDeleteDSO = new DSpaceObject(); + labelDeleteDSO.name = labelToDelete.label; + + const modalRef = this.modalService.open(ConfirmationModalComponent); + modalRef.componentInstance.dso = labelDeleteDSO; + modalRef.componentInstance.headerLabel = 'clarin.license.label.delete.confirm.title'; + modalRef.componentInstance.infoLabel = 'clarin.license.label.delete.confirm.message'; + modalRef.componentInstance.cancelLabel = 'clarin.license.label.delete.cancel.button'; + modalRef.componentInstance.confirmLabel = 'clarin.license.label.delete.confirm.button'; + modalRef.componentInstance.brandColor = 'danger'; + modalRef.componentInstance.confirmIcon = 'fas fa-trash'; + + modalRef.componentInstance.response + .pipe(take(1), takeUntil(this.ngUnsubscribe)) + .subscribe((confirm: boolean) => { + if (!confirm) { + return; + } + + this.clarinLicenseLabelService.delete(String(labelToDelete.id)) + .pipe(getFirstCompletedRemoteData(), takeUntil(this.ngUnsubscribe)) + .subscribe((deleteLabelResponse) => { + if (deleteLabelResponse?.hasSucceeded) { + this.notificationService.success('', this.translateService.get('clarin.license.label.delete.success')); + this.refreshLabels(); + this.loadAllLicenses(); + } else { + this.notificationService.error('', this.translateService.get('clarin.license.label.delete.error')); + } + }, () => { + this.notificationService.error('', this.translateService.get('clarin.license.label.delete.error')); + }); }); } + /** + * Reload labels table using current pagination options. + */ + refreshLabels() { + this.labelsRefresh$.next(undefined); + } + /** * Pop up the notification about the request success. Messages are loaded from the `en.json5`. * @param operationResponse current response @@ -318,10 +524,11 @@ export class ClarinLicenseTableComponent implements OnInit { /** * Fetch all licenses from the API. */ - loadAllLicenses() { + loadAllLicenses(forceUsageReload = false) { this.selectedLicense = null; this.licensesRD$ = new BehaviorSubject>>(null); this.isLoading = true; + this.ensureLicenseUsageLoaded(forceUsageReload); // load the current pagination and sorting options const currentPagination$ = this.getCurrentPagination(); @@ -329,12 +536,16 @@ export class ClarinLicenseTableComponent implements OnInit { const searchTerm$ = new BehaviorSubject(this.searchingLicenseName); observableCombineLatest([currentPagination$, currentSort$, searchTerm$]).pipe( - scan((prevState, [currentPagination, currentSort, searchTerm]) => { + scan((prevState: { + searchTerm: string; + currentPage: number; + currentPagination: PaginationComponentOptions; + currentSort: SortOptions; + }, [currentPagination, currentSort, searchTerm]) => { // If search term has changed, reset to page 1; otherwise, keep current page const currentPage = prevState.searchTerm !== searchTerm ? 1 : currentPagination.currentPage; return { currentPage, currentPagination, currentSort, searchTerm }; - }, { searchTerm: '', currentPage: 1, currentPagination: this.getCurrentPagination(), - currentSort: this.getCurrentSort() }), + }, this.defaultListState), switchMap(({ currentPage, currentPagination, currentSort, searchTerm }) => { return this.clarinLicenseService.searchBy('byNameLike', { @@ -352,6 +563,119 @@ export class ClarinLicenseTableComponent implements OnInit { }); } + /** + * Ensure the expensive full usage crawl runs only when needed. + * @param forceReload When true, invalidate existing usage cache and reload. + */ + private ensureLicenseUsageLoaded(forceReload = false) { + if (forceReload) { + this.licenseUsageLoaded = false; + } + + if (this.licenseUsageLoaded || this.licenseUsageLoading) { + return; + } + + this.licenseUsageLoading = true; + this.loadAllLicensesForUsage(); + } + + /** + * Returns whether a license label is used by at least one license (primary or extended labels). + * @param label License label row object. + */ + isLabelInUse(label: ClarinLicenseLabel): boolean { + if (isNull(label?.id)) { + return false; + } + return this.inUseLabelIds.has(String(label.id)); + } + + /** + * Load all licenses page-by-page and rebuild label usage set. + */ + private loadAllLicensesForUsage() { + this.fetchAllLicensePages(0, []) + .pipe(takeUntil(this.ngUnsubscribe)) + .subscribe(({ response, licenses }) => { + this.licenseUsageLoading = false; + this.allLicensesRD$.next(response); + if (response?.hasSucceeded) { + this.rebuildLabelUsageSet(licenses); + this.licenseUsageLoaded = true; + } else { + this.inUseLabelIds.clear(); + this.licenseUsageLoaded = false; + } + }, () => { + this.licenseUsageLoading = false; + this.licenseUsageLoaded = false; + this.inUseLabelIds.clear(); + }); + } + + /** + * Recursively fetch all pages from the license search endpoint. + * @param currentPage Zero-based page index. + * @param accumulatedLicenses Already collected licenses. + */ + private fetchAllLicensePages( + currentPage: number, + accumulatedLicenses: ClarinLicense[] + ): Observable<{ response: RemoteData>, licenses: ClarinLicense[] }> { + return this.clarinLicenseService.searchBy('byNameLike', { + currentPage, + elementsPerPage: this.allLicensesPageSize, + sort: { field: defaultSortConfiguration.field, direction: defaultSortConfiguration.direction }, + searchParams: [new RequestParam('name', '')] + }, false).pipe( + getFirstCompletedRemoteData(), + switchMap((response: RemoteData>) => { + const pageLicenses = response?.payload?.page ?? []; + const nextAccumulated = [...accumulatedLicenses, ...pageLicenses]; + + if (!response?.hasSucceeded) { + return of({ response, licenses: nextAccumulated }); + } + + const totalPages = response?.payload?.totalPages ?? 1; + const payloadCurrentPage = response?.payload?.currentPage; + const resolvedCurrentPage = isNull(payloadCurrentPage) ? currentPage : payloadCurrentPage; + const nextPage = resolvedCurrentPage + 1; + const hasNextPage = nextPage < totalPages; + + if (!hasNextPage) { + return of({ response, licenses: nextAccumulated }); + } + + return this.fetchAllLicensePages(nextPage, nextAccumulated); + }) + ); + } + + /** + * Build fast lookup of label ids referenced by any loaded license. + * @param licenses Aggregated list of all licenses. + */ + private rebuildLabelUsageSet(licenses: ClarinLicense[]) { + const usageSet = new Set(); + + (licenses || []).forEach((license: ClarinLicense) => { + const mainLabelId = license?.clarinLicenseLabel?.id; + if (!isNull(mainLabelId)) { + usageSet.add(String(mainLabelId)); + } + + (license?.extendedClarinLicenseLabels || []).forEach((extendedLabel: ClarinLicenseLabel) => { + if (!isNull(extendedLabel?.id)) { + usageSet.add(String(extendedLabel.id)); + } + }); + }); + + this.inUseLabelIds = usageSet; + } + /** * Mark the license as selected or unselect if it is already clicked. * @param clarinLicense @@ -388,4 +712,40 @@ export class ClarinLicenseTableComponent implements OnInit { private getCurrentSort() { return this.paginationService.getCurrentSort(this.options.id, defaultSortConfiguration); } + + /** + * Initialize labels data stream so pagination query-param changes trigger fetches reactively. + */ + private initializeLabelsPaginationStream() { + const labelsLoadErrorKey = 'clarin.license.label.load.error'; + const currentLabelPagination$ = this.paginationService + .getCurrentPagination(this.labelPaginationOptions.id, this.labelPaginationOptions); + + observableCombineLatest([currentLabelPagination$, this.labelsRefresh$]) + .pipe( + switchMap(([currentPagination]) => { + this.labelsRD$.next(null); + this.loading$.next(true); + return this.clarinLicenseLabelService.findAll({ + currentPage: currentPagination.currentPage, + elementsPerPage: currentPagination.pageSize + }, false).pipe( + getFirstCompletedRemoteData() + ); + }), + takeUntil(this.ngUnsubscribe) + ) + .subscribe((labelsResponse: RemoteData>) => { + this.labelsRD$.next(labelsResponse); + if (!labelsResponse?.hasSucceeded) { + this.notificationService.error('', this.translateService.get(labelsLoadErrorKey)); + } + this.loading$.next(false); + }, () => { + this.labelsRD$.next(null); + this.notificationService.error('', this.translateService.get(labelsLoadErrorKey)); + this.loading$.next(false); + } + ); + } } diff --git a/src/app/clarin-licenses/clarin-license-table/modal/define-license-label-form/define-license-label-form.component.html b/src/app/clarin-licenses/clarin-license-table/modal/define-license-label-form/define-license-label-form.component.html index a78a285f638..2e9e4c5eb1d 100644 --- a/src/app/clarin-licenses/clarin-license-table/modal/define-license-label-form/define-license-label-form.component.html +++ b/src/app/clarin-licenses/clarin-license-table/modal/define-license-label-form/define-license-label-form.component.html @@ -2,38 +2,43 @@