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 @@ - + + + +
+
+ + + + + + + + + + + + + + + + @for (cLicense of cLicenses?.page; track cLicense) { + + + + + + + + + + + } + +
{{"clarin-license.table.name" | translate}}{{"clarin-license.table.definition" | translate}}{{"clarin-license.table.confirmation" | translate}}{{"clarin-license.table.required-user-info" | translate}}{{"clarin-license.table.label" | translate}}{{"clarin-license.table.extended-labels" | translate}}{{"clarin-license.table.bitstreams" | translate}}
+ + {{cLicense?.name}}{{cLicense?.definition}}{{cLicense?.confirmation}}{{cLicense?.requiredInfo | dsCLicenseRequiredInfo}}{{cLicense?.clarinLicenseLabel?.label}}{{cLicense?.extendedClarinLicenseLabels | dsExtendedCLicense}}{{cLicense?.bitstreams}}
+ @if (isLoading) { + + } +
+ +
+
+
+ +
+ + +
+
+
+
+ +
+
+ + + +
+
- -
-
- +
+ +
+

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

+ +
+ +
- - - - - - - - + + + + + - @for (cLicense of cLicenses?.page; track cLicense) { - - + + + + + - - - - - - - + + } + @if ((loading$ | async) === false && (labelsRD$ | async)?.hasSucceeded && !(cLicenseLabels?.page?.length > 0)) { + + }
{{"clarin-license.table.name" | translate}}{{"clarin-license.table.definition" | translate}}{{"clarin-license.table.confirmation" | translate}}{{"clarin-license.table.required-user-info" | translate}}{{"clarin-license.table.label" | translate}}{{"clarin-license.table.extended-labels" | translate}}{{"clarin-license.table.bitstreams" | 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}}
- + @for (label of cLicenseLabels?.page; track label) { +
{{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)}} + + + + + + + {{cLicense?.name}}{{cLicense?.definition}}{{cLicense?.confirmation}}{{cLicense?.requiredInfo | dsCLicenseRequiredInfo}}{{cLicense?.clarinLicenseLabel?.label}}{{cLicense?.extendedClarinLicenseLabels | dsExtendedCLicense}}{{cLicense?.bitstreams}}
{{'clarin.license.label.table.empty' | 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 @@ -