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 31b971d1f37..cb2ab8e7b99 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
@@ -8,83 +8,172 @@
-
+
+
+
+
+
+
+
+ @if (isLoading) {
+
+ }
+
+
+
+
+
+
+
+
+
+
+
+
+
-
-
-
-
+
+
+
+
{{'clarin.license.label.section.title' | translate}}
+
+
+
+
- @if (isLoading) {
-
- }
-
-
-
-
-
-
-
-
-
-
-
+
+ @if (loading$ | async) {
+
+ }
+ @if (loading$ | async) {
+ {{'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
new file mode 100644
index 00000000000..368616659a5
--- /dev/null
+++ b/src/app/clarin-licenses/clarin-license-table/clarin-license-table.component.spec.ts
@@ -0,0 +1,657 @@
+import { CommonModule } from '@angular/common';
+import { EventEmitter } from '@angular/core';
+import {
+ ComponentFixture,
+ fakeAsync,
+ TestBed,
+ tick,
+} from '@angular/core/testing';
+import { ReactiveFormsModule } from '@angular/forms';
+import { By } from '@angular/platform-browser';
+import { RouterTestingModule } from '@angular/router/testing';
+import {
+ NgbActiveModal,
+ NgbModal,
+ NgbTooltip,
+} from '@ng-bootstrap/ng-bootstrap';
+import { TranslateModule } from '@ngx-translate/core';
+import {
+ of,
+ throwError,
+} from 'rxjs';
+
+import { ClarinLicenseDataService } from '../../core/data/clarin/clarin-license-data.service';
+import { ClarinLicenseLabelDataService } from '../../core/data/clarin/clarin-license-label-data.service';
+import { ConfigurationDataService } from '../../core/data/configuration-data.service';
+import { buildPaginatedList } from '../../core/data/paginated-list.model';
+import { RequestService } from '../../core/data/request.service';
+import { GroupDataService } from '../../core/eperson/group-data.service';
+import { PaginationService } from '../../core/pagination/pagination.service';
+import { LinkHeadService } from '../../core/services/link-head.service';
+import { ClarinLicense } from '../../core/shared/clarin/clarin-license.model';
+import { ClarinLicenseLabel } from '../../core/shared/clarin/clarin-license-label.model';
+import { ConfigurationProperty } from '../../core/shared/configuration-property.model';
+import { PageInfo } from '../../core/shared/page-info.model';
+import { SearchConfigurationService } from '../../core/shared/search/search-configuration.service';
+import { ConfirmationModalComponent } from '../../shared/confirmation-modal/confirmation-modal.component';
+import { HostWindowService } from '../../shared/host-window.service';
+import { NotificationsService } from '../../shared/notifications/notifications.service';
+import {
+ createFailedRemoteDataObject,
+ createFailedRemoteDataObject$,
+ createNoContentRemoteDataObject$,
+ createSuccessfulRemoteDataObject,
+ createSuccessfulRemoteDataObject$,
+} from '../../shared/remote-data.utils';
+import {
+ createdLicenseLabelRD$,
+ createdLicenseRD$,
+ mockExtendedLicenseLabel,
+ mockLicense,
+ mockLicenseLabelListRD$,
+ mockLicenseRD$,
+ mockNonExtendedLicenseLabel,
+ successfulResponse,
+} from '../../shared/testing/clarin-license-mock';
+import { HostWindowServiceStub } from '../../shared/testing/host-window-service.stub';
+import { NotificationsServiceStub } from '../../shared/testing/notifications-service.stub';
+import { PaginationServiceStub } from '../../shared/testing/pagination-service.stub';
+import { createPaginatedList } from '../../shared/testing/utils.test';
+import { defaultPagination } from '../clarin-license-table-pagination';
+import { ClarinLicenseTableComponent } from './clarin-license-table.component';
+import { DefineLicenseLabelFormComponent } from './modal/define-license-label-form/define-license-label-form.component';
+
+describe('ClarinLicenseTableComponent', () => {
+ let component: ClarinLicenseTableComponent;
+ let fixture: ComponentFixture
;
+
+ let clarinLicenseDataService: ClarinLicenseDataService;
+ let clarinLicenseLabelDataService: ClarinLicenseLabelDataService;
+ let requestService: RequestService;
+ let notificationService: NotificationsServiceStub;
+ let activeModalStub: NgbActiveModal;
+ let modalServiceStub: jasmine.SpyObj;
+ let groupsDataService: GroupDataService;
+ let searchConfigurationServiceStub: SearchConfigurationService;
+ let labelEditModalRef: any;
+ let labelDeleteModalRef: any;
+ let paginationServiceStub: PaginationServiceStub;
+
+ beforeEach(async () => {
+ notificationService = new NotificationsServiceStub();
+ clarinLicenseDataService = jasmine.createSpyObj('clarinLicenseService', {
+ findAll: mockLicenseRD$,
+ create: createdLicenseRD$,
+ put: createdLicenseRD$,
+ delete: createNoContentRemoteDataObject$(),
+ searchBy: mockLicenseRD$,
+ getLinkPath: of(''),
+ });
+ clarinLicenseLabelDataService = jasmine.createSpyObj('clarinLicenseLabelService', {
+ create: createdLicenseLabelRD$,
+ findAll: mockLicenseLabelListRD$,
+ put: createdLicenseLabelRD$,
+ delete: of({ hasSucceeded: true }),
+ });
+ requestService = jasmine.createSpyObj('requestService', {
+ send: of('response'),
+ getByUUID: of(successfulResponse),
+ generateRequestId: of('123456'),
+ });
+ 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: '',
+ });
+ const linkHeadService = jasmine.createSpyObj('linkHeadService', {
+ addTag: {},
+ removeTag: {},
+ });
+ const configurationDataService = jasmine.createSpyObj('configurationDataService', {
+ findByPropertyName: createSuccessfulRemoteDataObject$(Object.assign(new ConfigurationProperty(), {
+ name: 'test',
+ values: [
+ 'org.dspace.ctask.general.ProfileFormats = test',
+ ],
+ })),
+ });
+ searchConfigurationServiceStub = jasmine.createSpyObj('SearchConfigurationService', {
+ getCurrentConfiguration: of('default'),
+ getCurrentScope: of('test-id'),
+ updateFixedFilter: jasmine.createSpy('updateFixedFilter'),
+ setPaginationId: jasmine.createSpy('setPaginationId'),
+ });
+ paginationServiceStub = new PaginationServiceStub();
+
+ await TestBed.configureTestingModule({
+ imports: [
+ CommonModule,
+ ReactiveFormsModule,
+ TranslateModule.forRoot(),
+ RouterTestingModule.withRoutes([]),
+ ClarinLicenseTableComponent,
+ ],
+ providers: [
+ { provide: RequestService, useValue: requestService },
+ { provide: ClarinLicenseDataService, useValue: clarinLicenseDataService },
+ { provide: ClarinLicenseLabelDataService, useValue: clarinLicenseLabelDataService },
+ { provide: PaginationService, useValue: paginationServiceStub },
+ { provide: NotificationsService, useValue: notificationService },
+ { provide: NgbActiveModal, useValue: activeModalStub },
+ { provide: NgbModal, useValue: modalServiceStub },
+ { provide: HostWindowService, useValue: new HostWindowServiceStub(0) },
+ { provide: GroupDataService, useValue: groupsDataService },
+ { provide: LinkHeadService, useValue: linkHeadService },
+ { provide: ConfigurationDataService, useValue: configurationDataService },
+ { provide: SearchConfigurationService, useValue: searchConfigurationServiceStub },
+ ],
+ })
+ .compileComponents();
+ });
+
+ beforeEach(() => {
+ fixture = TestBed.createComponent(ClarinLicenseTableComponent);
+ component = fixture.componentInstance;
+ fixture.detectChanges();
+ });
+
+ afterEach(() => {
+ component = null;
+ clarinLicenseLabelDataService = null;
+ fixture.destroy();
+ });
+
+ it('should create', () => {
+ expect(component).toBeTruthy();
+ });
+
+ it('should initialize paginationOptions', () => {
+ (component as ClarinLicenseTableComponent).ngOnInit();
+ expect((component as ClarinLicenseTableComponent).options).toEqual(defaultPagination);
+ });
+
+ it('should onInit should initialize clarin license table data', () => {
+ (component as ClarinLicenseTableComponent).ngOnInit();
+ expect((component as any).clarinLicenseService.searchBy).toHaveBeenCalled();
+ expect((component as ClarinLicenseTableComponent).licensesRD$).not.toBeNull();
+ });
+
+ it('should create new clarin license and reload the licenses table', () => {
+ (component as ClarinLicenseTableComponent).defineNewLicense(mockLicense);
+ expect((component as any).clarinLicenseService.create).toHaveBeenCalled();
+ // notificate successful response
+ expect((component as any).notificationService.success).toHaveBeenCalled();
+ // load table data
+ expect((component as any).clarinLicenseService.searchBy).toHaveBeenCalled();
+ expect((component as ClarinLicenseTableComponent).licensesRD$).not.toBeNull();
+ });
+
+ it('should create new clarin license label when icon image is null', () => {
+ // non extended ll has no icon
+ (component as ClarinLicenseTableComponent).defineLicenseLabel(mockNonExtendedLicenseLabel);
+ expect((component as any).notificationService.success).toHaveBeenCalled();
+ });
+
+ // NOTE: the v7 original wrapped this in fakeAsync() and never tick()ed, so the whenStable()
+ // callback - and every expectation inside it - never ran: the spec was vacuous. 9-base karma
+ // runs jasmine with failSpecWithNoExpectations:true, which exposes that. Reading a Blob with
+ // FileReader is a real browser task that neither fakeAsync nor whenStable() flushes, so the
+ // spec waits for the create call itself and then asserts for real.
+ it('should create new clarin license label and load table data', (done) => {
+ const createSpy = clarinLicenseLabelDataService.create as jasmine.Spy;
+ createSpy.calls.reset();
+
+ // extended ll has icon
+ (component as ClarinLicenseTableComponent).defineLicenseLabel(mockExtendedLicenseLabel);
+ fixture.detectChanges();
+
+ const assertWhenCreated = () => {
+ if (!createSpy.calls.any()) {
+ setTimeout(assertWhenCreated, 10);
+ return;
+ }
+ expect(createSpy).toHaveBeenCalled();
+ // notificate successful response
+ expect(notificationService.success).toHaveBeenCalled();
+ // load table data
+ expect((component as any).clarinLicenseService.searchBy).toHaveBeenCalled();
+ expect((component as ClarinLicenseTableComponent).licensesRD$).not.toBeNull();
+ done();
+ };
+ assertWhenCreated();
+ });
+
+ it('should successful edit clarin license', () => {
+ // some license must be selected
+ (component as ClarinLicenseTableComponent).selectedLicense = mockLicense;
+ // non extended ll has no icon
+ (component as ClarinLicenseTableComponent).editLicense(mockLicense);
+ expect((component as any).clarinLicenseService.put).toHaveBeenCalled();
+ // notificate successful response
+ expect((component as any).notificationService.success).toHaveBeenCalled();
+ // load table data
+ expect((component as any).clarinLicenseService.searchBy).toHaveBeenCalled();
+ expect((component as ClarinLicenseTableComponent).licensesRD$).not.toBeNull();
+ });
+
+ describe('license delete button', () => {
+ const getDeleteControls = () => {
+ const actionsRow = fixture.debugElement.query(By.css('.mt-2'));
+ const deleteWrapper = actionsRow.query(By.css('.btn-group.pe-1:last-child span'));
+ const deleteButton = deleteWrapper.query(By.css('button.btn-danger'));
+ return { deleteWrapper, deleteButton };
+ };
+
+ beforeEach(() => {
+ (clarinLicenseDataService.delete as jasmine.Spy).calls.reset();
+ });
+
+ it('should disable delete button and expose tooltip when selected license has bitstreams', () => {
+ component.selectedLicense = Object.assign({}, mockLicense, { bitstreams: 2 });
+ fixture.detectChanges();
+
+ const { deleteWrapper, deleteButton } = getDeleteControls();
+ const deleteTooltip = deleteWrapper.injector.get(NgbTooltip);
+
+ expect(deleteButton.attributes['aria-disabled']).toBe('true');
+ expect(deleteButton.nativeElement.classList.contains('disabled')).toBeTrue();
+ expect((deleteWrapper.nativeElement as HTMLElement).getAttribute('tabindex')).toBe('0');
+ expect(deleteTooltip.ngbTooltip as string).toContain('clarin-license.button.delete-l');
+ });
+
+ it('should not call delete when clicking disabled delete button', () => {
+ component.selectedLicense = Object.assign({}, mockLicense, { bitstreams: 1 });
+ fixture.detectChanges();
+
+ const { deleteButton } = getDeleteControls();
+ deleteButton.nativeElement.click();
+
+ expect((clarinLicenseDataService.delete as jasmine.Spy)).not.toHaveBeenCalled();
+ });
+
+ it('should enable delete button and call delete when selected license has no bitstreams', () => {
+ component.selectedLicense = Object.assign({}, mockLicense, { bitstreams: 0 });
+ fixture.detectChanges();
+
+ const { deleteWrapper, deleteButton } = getDeleteControls();
+ deleteButton.nativeElement.click();
+
+ expect(deleteButton.attributes['aria-disabled']).toBe('false');
+ expect(deleteButton.nativeElement.classList.contains('disabled')).toBeFalse();
+ expect((deleteWrapper.nativeElement as HTMLElement).getAttribute('tabindex')).toBeNull();
+ expect((clarinLicenseDataService.delete as jasmine.Spy)).toHaveBeenCalledWith(String(mockLicense.id));
+ });
+ });
+
+ 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, { centered: true });
+ 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 clear the icon when clearIcon is set and no new file is selected', fakeAsync(() => {
+ spyOn(component, 'refreshLabels').and.stub();
+ spyOn(component, 'loadAllLicenses').and.stub();
+ const labelWithIcon = Object.assign(new ClarinLicenseLabel(), {
+ ...mockExtendedLicenseLabel,
+ icon: [1, 2, 3],
+ });
+
+ component.editLicenseLabel({
+ label: 'CLR',
+ title: 'Cleared icon',
+ extended: false,
+ clearIcon: true,
+ }, labelWithIcon);
+ tick();
+
+ const putArgument = (clarinLicenseLabelDataService.put as jasmine.Spy).calls.mostRecent().args[0];
+ expect(putArgument.icon).toEqual([]);
+ expect(notificationService.success).toHaveBeenCalled();
+ }));
+
+ it('should keep the existing icon when clearIcon is not set and no new file is selected', fakeAsync(() => {
+ spyOn(component, 'refreshLabels').and.stub();
+ spyOn(component, 'loadAllLicenses').and.stub();
+ const labelWithIcon = Object.assign(new ClarinLicenseLabel(), {
+ ...mockExtendedLicenseLabel,
+ icon: [1, 2, 3],
+ });
+
+ component.editLicenseLabel({
+ label: 'KEP',
+ title: 'Kept icon',
+ extended: false,
+ clearIcon: false,
+ }, labelWithIcon);
+ tick();
+
+ const putArgument = (clarinLicenseLabelDataService.put as jasmine.Spy).calls.mostRecent().args[0];
+ expect(putArgument.icon).toBeUndefined();
+ }));
+
+ 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 create pagination', () => {
+ it('should jump to the last labels page after a successful create so the new label is visible', () => {
+ paginationServiceStub.updateRoute.calls.reset();
+ const refreshSpy = spyOn(component, 'refreshLabels').and.stub();
+ // 25 existing labels, page size 10 -> after adding one (26) the new label is on page 3.
+ (component as any).labelsRD$.next(
+ createSuccessfulRemoteDataObject(buildPaginatedList(
+ Object.assign(new PageInfo(), { totalElements: 25, elementsPerPage: 10 }), [])),
+ );
+
+ component.createClarinLicenseLabel(mockNonExtendedLicenseLabel, [], 'ok', 'err');
+
+ expect(paginationServiceStub.updateRoute).toHaveBeenCalledWith(
+ (component as any).labelPaginationOptions.id, { page: 3 });
+ expect(refreshSpy).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, { centered: true });
+ expect(labelDeleteModalRef.componentInstance.headerLabel).toBe('clarin.license.label.delete.confirm.title');
+ expect(labelDeleteModalRef.componentInstance.infoLabel).toBe('clarin.license.label.delete.confirm.message');
+ // v9 ConfirmationModalComponent takes a `name` string, not a `dso`
+ expect(labelDeleteModalRef.componentInstance.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)]);
+ (component as any).labelUsageReady$.next(true);
+ fixture.detectChanges();
+ });
+
+ it('should disable delete on all rows until the usage crawl has finished', () => {
+ (component as any).labelUsageReady$.next(false);
+ fixture.detectChanges();
+
+ const labelRows = fixture.debugElement.queryAll(By.css('.labels-section tbody tr'));
+ const deleteButtons = labelRows.map((row) => row.queryAll(By.css('button'))[1]);
+
+ deleteButtons.forEach((deleteButton) => {
+ expect(deleteButton.attributes['aria-disabled']).toBe('true');
+ expect(deleteButton.nativeElement.classList.contains('disabled')).toBeTrue();
+ });
+ });
+
+ 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];
+ // Angular 20 no longer emits `ng-reflect-*` attributes, so read the bound value off the
+ // NgbTooltip directive instance instead of off the DOM attribute (v7 spec did the latter).
+ const linkedRowTooltip = linkedRowDeleteWrapper.injector.get(NgbTooltip);
+
+ 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(linkedRowTooltip.ngbTooltip as string).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();
+
+ // the v7 original asserted not.toHaveBeenCalledWith(ConfirmationModalComponent) with a
+ // single argument; every real call passes two, so that assertion could never fail. Match
+ // the real call signature so the guard is actually exercised.
+ expect(modalServiceStub.open).not.toHaveBeenCalledWith(ConfirmationModalComponent, { centered: true });
+ 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).labelUsageReady$.next(false);
+ (component as any).licenseUsageLoading = false;
+
+ const usageSpy = spyOn(component, 'loadAllLicensesForUsage').and.callFake(() => {
+ (component as any).licenseUsageLoading = false;
+ (component as any).labelUsageReady$.next(true);
+ });
+
+ component.loadAllLicenses();
+ component.loadAllLicenses();
+
+ expect(usageSpy).toHaveBeenCalledTimes(1);
+ });
+
+ it('should force usage dataset reload when explicitly requested', () => {
+ (component as any).labelUsageReady$.next(false);
+ (component as any).licenseUsageLoading = false;
+
+ const usageSpy = spyOn(component, 'loadAllLicensesForUsage').and.callFake(() => {
+ (component as any).licenseUsageLoading = false;
+ (component as any).labelUsageReady$.next(true);
+ });
+
+ component.loadAllLicenses();
+ component.loadAllLicenses({ forceUsageReload: true });
+
+ expect(usageSpy).toHaveBeenCalledTimes(2);
+ });
+ });
+
+ it('should reset pagination to page 1 when the search term changes', () => {
+ paginationServiceStub.pagination.id = defaultPagination.id;
+ paginationServiceStub.pagination.currentPage = 2;
+ paginationServiceStub.pagination.pageSize = 10;
+ paginationServiceStub.pagination.pageSizeOptions = defaultPagination.pageSizeOptions;
+ (component as any).clarinLicenseService.searchBy.calls.reset();
+ paginationServiceStub.resetPage.calls.reset();
+
+ component.searchingLicenseName = 'Universal';
+
+ component.searchLicenses();
+
+ expect(paginationServiceStub.resetPage).toHaveBeenCalledWith(defaultPagination.id);
+ expect((component as any).clarinLicenseService.searchBy).toHaveBeenCalledWith(
+ 'byNameLike',
+ jasmine.objectContaining({
+ currentPage: 1,
+ elementsPerPage: 10,
+ }),
+ false,
+ );
+ });
+
+ it('should not reset pagination when searching with the same term', () => {
+ paginationServiceStub.pagination.id = defaultPagination.id;
+ paginationServiceStub.pagination.currentPage = 2;
+ paginationServiceStub.pagination.pageSize = 10;
+ paginationServiceStub.pagination.pageSizeOptions = defaultPagination.pageSizeOptions;
+ (component as any).clarinLicenseService.searchBy.calls.reset();
+ paginationServiceStub.resetPage.calls.reset();
+ (component as any).previousSearchTerm = 'Universal';
+ component.searchingLicenseName = 'Universal';
+
+ component.searchLicenses();
+
+ expect(paginationServiceStub.resetPage).not.toHaveBeenCalled();
+ expect((component as any).clarinLicenseService.searchBy).toHaveBeenCalledWith(
+ 'byNameLike',
+ jasmine.objectContaining({
+ currentPage: 2,
+ elementsPerPage: 10,
+ }),
+ false,
+ );
+ });
+});
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 ce02b6e9ef3..44ea56ef85b 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,6 +1,7 @@
import { CommonModule } from '@angular/common';
import {
Component,
+ OnDestroy,
OnInit,
} from '@angular/core';
import { FormsModule } from '@angular/forms';
@@ -8,6 +9,7 @@ import {
NgbActiveModal,
NgbDropdownModule,
NgbModal,
+ NgbTooltipModule,
} from '@ng-bootstrap/ng-bootstrap';
import {
TranslateModule,
@@ -17,8 +19,15 @@ import cloneDeep from 'lodash/cloneDeep';
import {
BehaviorSubject,
combineLatest as observableCombineLatest,
+ Observable,
+ of,
+ Subject,
} from 'rxjs';
-import { switchMap } from 'rxjs/operators';
+import {
+ switchMap,
+ take,
+ takeUntil,
+} from 'rxjs/operators';
import { RequestParam } from '../../core/cache/models/request-param.model';
import { ClarinLicenseDataService } from '../../core/data/clarin/clarin-license-data.service';
@@ -36,7 +45,11 @@ import {
getFirstSucceededRemoteData,
} from '../../core/shared/operators';
import { BtnDisabledDirective } from '../../shared/btn-disabled.directive';
-import { isNull } from '../../shared/empty.util';
+import { ConfirmationModalComponent } from '../../shared/confirmation-modal/confirmation-modal.component';
+import {
+ hasNoValue,
+ isNull,
+} from '../../shared/empty.util';
import { ThemedLoadingComponent } from '../../shared/loading/themed-loading.component';
import { NotificationsService } from '../../shared/notifications/notifications.service';
import { PaginationComponent } from '../../shared/pagination/pagination.component';
@@ -56,21 +69,25 @@ import { DefineLicenseLabelFormComponent } from './modal/define-license-label-fo
*/
@Component({
- imports: [BtnDisabledDirective,
+ imports: [
+ BtnDisabledDirective,
ClarinExtendedLicensePipe,
ClarinLicenseRequiredInfoPipe,
CommonModule,
FormsModule,
NgbDropdownModule,
+ NgbTooltipModule,
PaginationComponent,
ThemedLoadingComponent,
- TranslateModule, VarDirective],
+ TranslateModule,
+ VarDirective,
+ ],
providers: [NgbActiveModal],
selector: 'ds-clarin-license-table',
templateUrl: './clarin-license-table.component.html',
styleUrls: ['./clarin-license-table.component.scss'],
})
-export class ClarinLicenseTableComponent implements OnInit {
+export class ClarinLicenseTableComponent implements OnInit, OnDestroy {
constructor(private paginationService: PaginationService,
private clarinLicenseService: ClarinLicenseDataService,
@@ -80,6 +97,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
*/
@@ -106,14 +129,73 @@ 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);
+
+ /**
+ * Single source of truth for whether the full license usage crawl has finished building the
+ * in-use set. Emits true once the crawl completes successfully; until then the label Delete
+ * buttons stay disabled so an in-use label is never deletable during the crawl window.
+ * Read synchronously via `.value` as the re-crawl guard, and bound reactively in the template.
+ */
+ labelUsageReady$ = 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 a full usage crawl is currently in flight.
+ */
+ private licenseUsageLoading = false;
+
/**
* Stores the previous search term to detect when a new search should reset pagination.
*/
private previousSearchTerm = '';
+ /**
+ * 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
@@ -121,7 +203,7 @@ export class ClarinLicenseTableComponent implements OnInit {
* Pop up the License modal where the user fill in the License data.
*/
openDefineLicenseForm() {
- const defineLicenseModalRef = this.modalService.open(DefineLicenseFormComponent);
+ const defineLicenseModalRef = this.modalService.open(DefineLicenseFormComponent, { centered: true });
defineLicenseModalRef.result.then((result: ClarinLicense) => {
this.defineNewLicense(result);
@@ -139,6 +221,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
@@ -153,7 +236,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({ forceUsageReload: true });
});
}
@@ -167,7 +250,7 @@ export class ClarinLicenseTableComponent implements OnInit {
}
// pass the actual clarin license values to the define-clarin-license modal
- const editLicenseModalRef = this.modalService.open(DefineLicenseFormComponent);
+ const editLicenseModalRef = this.modalService.open(DefineLicenseFormComponent, { centered: true });
editLicenseModalRef.componentInstance.name = this.selectedLicense.name;
editLicenseModalRef.componentInstance.definition = this.selectedLicense.definition;
editLicenseModalRef.componentInstance.confirmation = this.selectedLicense.confirmation;
@@ -191,6 +274,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();
@@ -215,7 +299,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({ forceUsageReload: true });
});
}
@@ -242,7 +326,7 @@ export class ClarinLicenseTableComponent implements OnInit {
* Pop up License Label modal where the user fill in the License Label data.
*/
openDefineLicenseLabelForm() {
- const defineLicenseLabelModalRef = this.modalService.open(DefineLicenseLabelFormComponent);
+ const defineLicenseLabelModalRef = this.modalService.open(DefineLicenseLabelFormComponent, { centered: true });
defineLicenseLabelModalRef.result.then((result: ClarinLicenseLabel) => {
this.defineLicenseLabel(result);
@@ -256,10 +340,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
@@ -310,6 +395,13 @@ export class ClarinLicenseTableComponent implements OnInit {
// check payload and show error or successful
this.notifyOperationStatus(defineLicenseLabelResponse, successfulMessageContentDef, errorMessageContentDef);
this.loadAllLicenses();
+ if (defineLicenseLabelResponse?.hasSucceeded) {
+ // The backend returns labels in ascending insertion order (it ignores the sort param),
+ // so a new label lands on the last page. Jump there so the admin sees it without paging.
+ this.goToLastLabelsPage();
+ } else {
+ this.refreshLabels();
+ }
});
}
@@ -318,7 +410,7 @@ export class ClarinLicenseTableComponent implements OnInit {
* Delete selected license. If none license is selected do nothing.
*/
deleteLicense() {
- if (isNull(this.selectedLicense?.id)) {
+ if (hasNoValue(this.selectedLicense?.id) || this.isSelectedLicenseInUse()) {
return;
}
this.clarinLicenseService.delete(String(this.selectedLicense.id))
@@ -327,10 +419,158 @@ 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({ forceUsageReload: true });
+ });
+ }
+
+ /**
+ * Returns whether selected license has attached bitstreams.
+ */
+ isSelectedLicenseInUse(): boolean {
+ return this.selectedLicense?.bitstreams > 0;
+ }
+
+ /**
+ * 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, { centered: true });
+ 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 – clear the icon when requested, otherwise keep the existing one
+ updatedLabel.icon = formValues.clearIcon ? [] : undefined;
+ 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 modalRef = this.modalService.open(ConfirmationModalComponent, { centered: true });
+ modalRef.componentInstance.name = labelToDelete.label;
+ 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);
+ }
+
+ /**
+ * Navigate the labels table to the page that contains the most recently created label.
+ * Exactly one label was just added, so the new total is the current total plus one; the new
+ * label is on the last page because the backend lists labels in ascending insertion order.
+ */
+ private goToLastLabelsPage() {
+ const pageSize = this.labelPaginationOptions.pageSize;
+ const currentTotal = this.labelsRD$.value?.payload?.totalElements ?? 0;
+ const lastPage = Math.max(1, Math.ceil((currentTotal + 1) / pageSize));
+ this.paginationService.updateRoute(this.labelPaginationOptions.id, { page: lastPage });
+ // Force a reload as well so the table refreshes even when already on the target page.
+ this.refreshLabels();
+ }
+
/**
* Pop up the notification about the request success. Messages are loaded from the `en.json5`.
* @param operationResponse current response
@@ -369,17 +609,19 @@ export class ClarinLicenseTableComponent implements OnInit {
this.paginationService.resetPage(this.options.id);
}
- this.loadAllLicenses(hasSearchTermChanged ? 1 : undefined);
+ this.loadAllLicenses({ pageOverride: hasSearchTermChanged ? 1 : undefined });
this.previousSearchTerm = this.searchingLicenseName;
}
/**
* Fetch all licenses from the API.
*/
- loadAllLicenses(pageOverride?: number) {
+ loadAllLicenses(options: { pageOverride?: number; forceUsageReload?: boolean } = {}) {
+ const { pageOverride, forceUsageReload = false } = options;
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();
@@ -402,6 +644,120 @@ 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.labelUsageReady$.next(false);
+ }
+
+ if (this.labelUsageReady$.value || this.licenseUsageLoading) {
+ return;
+ }
+
+ this.licenseUsageLoading = true;
+ this.labelUsageReady$.next(false);
+ 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.labelUsageReady$.next(true);
+ } else {
+ this.inUseLabelIds.clear();
+ this.labelUsageReady$.next(false);
+ }
+ }, () => {
+ this.licenseUsageLoading = false;
+ this.inUseLabelIds.clear();
+ this.labelUsageReady$.next(false);
+ });
+ }
+
+ /**
+ * 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: currentPage + 1,
+ 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
@@ -438,4 +794,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-form/define-license-form.component.html b/src/app/clarin-licenses/clarin-license-table/modal/define-license-form/define-license-form.component.html
index 77eeb374090..2ce8e21d30e 100644
--- a/src/app/clarin-licenses/clarin-license-table/modal/define-license-form/define-license-form.component.html
+++ b/src/app/clarin-licenses/clarin-license-table/modal/define-license-form/define-license-form.component.html
@@ -1,71 +1,65 @@
-