From 6a279c9bb403443ab4626677ee3489419f9bd289 Mon Sep 17 00:00:00 2001 From: Amad Ul Hassan Date: Wed, 3 Jun 2026 20:41:52 +0200 Subject: [PATCH 1/2] 84 license labels management (ufal/dspace-angular#134) * Add PUT and DELETE to ClarinLicenseLabel service Replace BaseDataService with IdentifiableDataService and add PutData/DeleteData support for ClarinLicenseLabel. Wire PutDataImpl and DeleteDataImpl in the constructor, expose put(), delete() and deleteByHref() methods, and update imports (remove BaseDataService, add PutData, DeleteData, IdentifiableDataService and NoContent). Enables updating and deleting Clarin License Labels via the data service. * Add license labels table and placeholders Introduce a new License Labels section in the Clarin license table UI with a table, empty-state row, aria labels, and a ds-loading indicator (src/...component.html). Add CSS rule to size the actions column (src/...component.scss). In the component (src/...component.ts) add placeholder Observables (labels$, loading$) and stub methods editLabel and confirmDeleteLabel that log actions; data loading and wiring will be implemented in a follow-up task. * Add label selection and refresh logic Add selectable labels UI and wiring for label management: - Template: add a radio column with aria labels, mark selected row, disable Edit/Delete unless a label is selected, and adjust colspan for empty state. Edit/Delete buttons now call no-arg handlers. - Component: introduce selectedLabel, labels$ and loading$ BehaviorSubjects, refreshLabels() to fetch all labels via clarinLicenseLabelService.findAll (with loading/error handling), selectLabel() and isSelected() helpers, and call refreshLabels() on init and after label creation. Add ngUnsubscribe and ngOnDestroy to clean up subscriptions (takeUntil). Update scan initial state to use a defaultListState and import SortOptions. - Tests: update spec to provide mockLicenseLabelListRD$ and spy findAll. These changes enable selecting a license label for future edit/delete actions and keep the label list in sync with the backend. * Add license labels management and i18n Replace the static license-labels table with a paginated, RemoteData-driven table and translated headers; add loading state and accessible SR-only labels. Wire up edit and delete flows: open DefineLicenseLabelFormComponent for edits (convert file input, call PUT, notify and refresh) and ConfirmationModalComponent for deletes (call DELETE, notify and refresh). Introduce labelsRD$ stream, pagination options, labelsRefresh$ trigger and initializeLabelsPaginationStream to reactively fetch pages. Enhance DefineLicenseLabelFormComponent to support edit mode (prefill form, boolean extended options, aria/id fixes) and update serializer to accept booleans and legacy string values. Update and extend unit tests to cover edit/delete flows and template changes. Add new English and Czech i18n keys for the labels UI and actions. * Use dedicated i18n key for label load errors Introduce a specific i18n key for license label load failures and use it in the labels pagination stream. Added a labelsLoadErrorKey constant in ClarinLicenseTableComponent and replaced usages of the create-error key when handling load failures. Also added the new "clarin.license.label.load.error" translations to en.json5 and cs.json5. * Refresh licenses after label update Call loadAllLicenses() when a label PUT succeeds so dependent license lists are refreshed. The component now only calls refreshLabels() and loadAllLicenses() if the RemoteData indicates success. Updated unit test to spy on loadAllLicenses and assert it is called on modal submit; adjusted method comment accordingly. * Reload licenses after label deletion When a license label is successfully deleted, also reload the full license list to ensure UI reflects the change. Call loadAllLicenses() alongside refreshLabels() in the component, and update the spec to assert loadAllLicenses() is invoked (adding a spy and expectation). * Hide empty table row while loading Prevent the "no licenses" message from appearing while data is being fetched by adding a loading$ | async check to the empty-row *ngIf in clarin-license-table.component.html. The empty message now only shows when not loading and cLicenseLabels.page is empty, avoiding a flash of the empty state during load. * Disable delete for labels in use with tooltip Prevent deleting license labels that are referenced by any license: template changes wrap the delete button in a span that conditionally shows an ngbTooltip and uses dsBtnDisabled with a conditional click handler. Component logic now loads all licenses (paged) on init, builds a Set of in-use label IDs (inUseLabelIds) and exposes isLabelInUse to drive the UI; helper methods fetchAllLicensePages, rebuildLabelUsageSet and loadAllLicensesForUsage were added along with an allLicensesRD$ stream. Unit tests were extended to cover linked vs unlinked label behavior (disabled state, tooltip, and preventing the confirmation modal), and i18n entries for the disabled-tooltip were added for English and Czech. * Show labels empty row only on successful load Update clarin-license-table template to only render the labels empty-state row when the labels request has succeeded ((labelsRD$ | async)?.hasSucceeded). This prevents the empty message from appearing during failed or pending label requests. Add a unit test that simulates a failed labels request to verify the empty-state row is not shown, and import the needed createFailedRemoteDataObject helper for the test. * Optimize license usage loading and tests Add caching and locking around the expensive full license-usage crawl to avoid redundant requests. Introduce licenseUsageLoaded and licenseUsageLoading flags, a new ensureLicenseUsageLoaded(forceReload) helper, and a forceUsageReload parameter to loadAllLicenses so callers can explicitly invalidate the cache. Update create/edit/delete flows to force a usage reload, and set/clear the loading flags in the usage fetch success/error handlers. Add unit tests to verify that the full usage dataset is loaded only once across repeated reloads and that forcing a reload triggers a new fetch. * F1: disable label Delete until usage crawl completes The in-use set (inUseLabelIds) is empty until the async license usage crawl finishes, so during that window an in-use label's Delete button was briefly enabled. Add a usageReady$ stream that only emits true once the crawl succeeds and gate every row's Delete button on it: all Delete buttons stay disabled (with a "checking usage" tooltip) until usage resolves, after which in-use labels stay disabled and unused ones enable. On failure the buttons stay disabled so there is no unsafe window. Frontend-only mitigation; the usage crawl itself is unchanged. * F2: de-wrap double-nested modal in both license forms Both define-license-form and define-license-label-form nested their own .modal > .modal-dialog > .modal-content inside ng-bootstrap's NgbModalWindow, which already provides those wrappers. The duplicate chrome collapsed ngb's .modal-content and pinned the inner position:fixed .modal to the top of the viewport instead of centering. Remove the redundant inner wrappers so ngb owns the modal chrome, delete the now-dead `.modal { display: ... }` scss workarounds in both forms, fix the `modal-boy` -> `modal-body` typo in define-license-form, and open all four form modals with { centered: true } so the dialog is centered. * F3: surface newly created label without manual paging The labels endpoint lists labels in ascending insertion order and ignores the sort param, so a freshly created label received the highest id and landed on the last pagination page, invisible to the admin after Save. After a successful create, jump the labels table to the last page (derived from the current total plus the one just added) so the new row is shown immediately. * F4: allow clearing a label icon on edit and preview the current icon The label edit form could keep or replace an icon but never remove one, and gave no indication of the current icon. Add a "Remove current icon" checkbox and a small preview (reusing secureImageData) shown in edit mode when the label has an icon. When clear is requested and no new file is chosen, the table sends an empty icon array through the existing PUT path to remove it; selecting a new file still takes precedence. * F5: center the delete-confirmation modal and complete cs.json5 label parity Pass { centered: true } when opening ConfirmationModalComponent from confirmDeleteLabel() so the delete-confirmation dialog is vertically centered like the four license/label form dialogs, removing the top-aligned visual inconsistency. Also add the three Czech translations that were missing from cs.json5 (table.delete.checking-tooltip, edit.icon.preview, edit.icon.clear) so the en/cs label key sets are back in parity. * test: expect centered option in delete-confirmation modal spec The F5 change passes { centered: true } to modalService.open; update the assertion to match so the label delete flow spec passes. --------- Co-authored-by: kosarko (cherry picked from commit 87cb7a7f007a0190894f4e4a772489a92a7c976b) --- .../clarin-license-table.component.html | 69 +++ .../clarin-license-table.component.scss | 4 + .../clarin-license-table.component.spec.ts | 352 ++++++++++++++- .../clarin-license-table.component.ts | 401 +++++++++++++++++- .../define-license-form.component.html | 110 +++-- .../define-license-form.component.scss | 3 - .../define-license-label-form.component.html | 82 ++-- .../define-license-label-form.component.scss | 3 - ...efine-license-label-form.component.spec.ts | 68 +++ .../define-license-label-form.component.ts | 53 ++- .../clarin-license-label-data.service.ts | 40 +- ...larin-license-label-extended-serializer.ts | 8 +- src/assets/i18n/cs.json5 | 114 +++++ src/assets/i18n/en.json5 | 38 ++ 14 files changed, 1214 insertions(+), 131 deletions(-) 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 c723c1db5a8..b7a2aac91c4 100644 --- a/src/app/clarin-licenses/clarin-license-table/clarin-license-table.component.html +++ b/src/app/clarin-licenses/clarin-license-table/clarin-license-table.component.html @@ -83,4 +83,73 @@ + +
+

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

+ +
+ +
+ + + + + + + + + + + + + + + + + + + + + + +
{{'clarin.license.label.table.header.label' | translate}}{{'clarin.license.label.table.header.title' | translate}}{{'clarin.license.label.table.header.extended' | translate}}{{'clarin.license.label.table.header.icon' | translate}}{{'clarin.license.label.table.header.actions' | translate}}
{{label?.label}}{{label?.title}}{{label?.extended ? ('clarin.license.label.table.boolean.yes' | translate) : ('clarin.license.label.table.boolean.no' | translate)}} + + {{label?.icon?.length > 0 ? ('clarin.license.label.table.icon.available' | translate) : ('clarin.license.label.table.icon.none' | translate)}} + + + + + + + +
{{'clarin.license.label.table.empty' | translate}}
+
+ + + {{'clarin.license.label.table.loading' | translate}} +
+
+
diff --git a/src/app/clarin-licenses/clarin-license-table/clarin-license-table.component.scss b/src/app/clarin-licenses/clarin-license-table/clarin-license-table.component.scss index b4dab6de9bf..2ea1fa73dbf 100644 --- a/src/app/clarin-licenses/clarin-license-table/clarin-license-table.component.scss +++ b/src/app/clarin-licenses/clarin-license-table/clarin-license-table.component.scss @@ -15,3 +15,7 @@ width: 3.5%; max-width: 3.5%; } + +.labels-actions-column { + width: 11rem; +} diff --git a/src/app/clarin-licenses/clarin-license-table/clarin-license-table.component.spec.ts b/src/app/clarin-licenses/clarin-license-table/clarin-license-table.component.spec.ts index 32fb6a50c24..093c15e4251 100644 --- a/src/app/clarin-licenses/clarin-license-table/clarin-license-table.component.spec.ts +++ b/src/app/clarin-licenses/clarin-license-table/clarin-license-table.component.spec.ts @@ -1,9 +1,11 @@ -import { ComponentFixture, fakeAsync, TestBed } from '@angular/core/testing'; +import { ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing'; +import { By } from '@angular/platform-browser'; import { ClarinLicenseTableComponent } from './clarin-license-table.component'; import { NotificationsServiceStub } from '../../shared/testing/notifications-service.stub'; import { ClarinLicenseDataService } from '../../core/data/clarin/clarin-license-data.service'; import { RequestService } from '../../core/data/request.service'; -import { of as observableOf } from 'rxjs'; +import { EventEmitter } from '@angular/core'; +import { of as observableOf, throwError } from 'rxjs'; import { SharedModule } from '../../shared/shared.module'; import { CommonModule } from '@angular/common'; import { ReactiveFormsModule } from '@angular/forms'; @@ -15,22 +17,32 @@ import { NotificationsService } from '../../shared/notifications/notifications.s import { defaultPagination } from '../clarin-license-table-pagination'; import { ClarinLicenseLabelDataService } from '../../core/data/clarin/clarin-license-label-data.service'; import { NgbActiveModal } from '@ng-bootstrap/ng-bootstrap'; +import { NgbModal } from '@ng-bootstrap/ng-bootstrap'; import { HostWindowService } from '../../shared/host-window.service'; import { HostWindowServiceStub } from '../../shared/testing/host-window-service.stub'; import { createdLicenseLabelRD$, createdLicenseRD$, mockExtendedLicenseLabel, + mockLicenseLabelListRD$, mockLicense, mockLicenseRD$, mockNonExtendedLicenseLabel, successfulResponse } from '../../shared/testing/clarin-license-mock'; import {GroupDataService} from '../../core/eperson/group-data.service'; -import {createSuccessfulRemoteDataObject$} from '../../shared/remote-data.utils'; +import {createSuccessfulRemoteDataObject, createSuccessfulRemoteDataObject$} from '../../shared/remote-data.utils'; +import { createFailedRemoteDataObject$, createNoContentRemoteDataObject$ } from '../../shared/remote-data.utils'; +import { createFailedRemoteDataObject } from '../../shared/remote-data.utils'; import {createPaginatedList} from '../../shared/testing/utils.test'; import {LinkHeadService} from '../../core/services/link-head.service'; import {ConfigurationDataService} from '../../core/data/configuration-data.service'; import {ConfigurationProperty} from '../../core/shared/configuration-property.model'; import {SearchConfigurationService} from '../../core/shared/search/search-configuration.service'; +import { DefineLicenseLabelFormComponent } from './modal/define-license-label-form/define-license-label-form.component'; +import { ConfirmationModalComponent } from '../../shared/confirmation-modal/confirmation-modal.component'; +import { ClarinLicenseLabel } from '../../core/shared/clarin/clarin-license-label.model'; +import { ClarinLicense } from '../../core/shared/clarin/clarin-license.model'; +import { buildPaginatedList } from '../../core/data/paginated-list.model'; +import { PageInfo } from '../../core/shared/page-info.model'; describe('ClarinLicenseTableComponent', () => { let component: ClarinLicenseTableComponent; @@ -40,10 +52,13 @@ describe('ClarinLicenseTableComponent', () => { let clarinLicenseLabelDataService: ClarinLicenseLabelDataService; let requestService: RequestService; let notificationService: NotificationsServiceStub; - let modalStub: NgbActiveModal; + let activeModalStub: NgbActiveModal; + let modalServiceStub: jasmine.SpyObj; let groupsDataService: GroupDataService; let service: ConfigurationDataService; let searchConfigurationServiceStub: SearchConfigurationService; + let labelEditModalRef: any; + let labelDeleteModalRef: any; let paginationServiceStub: PaginationServiceStub; beforeEach(async () => { @@ -56,14 +71,36 @@ describe('ClarinLicenseTableComponent', () => { getLinkPath: observableOf('') }); clarinLicenseLabelDataService = jasmine.createSpyObj('clarinLicenseLabelService', { - create: createdLicenseLabelRD$ + create: createdLicenseLabelRD$, + findAll: mockLicenseLabelListRD$, + put: createdLicenseLabelRD$, + delete: observableOf({ hasSucceeded: true }) }); requestService = jasmine.createSpyObj('requestService', { send: observableOf('response'), getByUUID: observableOf(successfulResponse), generateRequestId: observableOf('123456'), }); - modalStub = jasmine.createSpyObj('modalService', ['close', 'open']); + activeModalStub = jasmine.createSpyObj('activeModal', ['close', 'open']); + modalServiceStub = jasmine.createSpyObj('modalService', ['open']); + labelEditModalRef = { + componentInstance: {}, + result: Promise.resolve(null) + }; + labelDeleteModalRef = { + componentInstance: { + response: new EventEmitter() + } + }; + modalServiceStub.open.and.callFake((modalComponent) => { + if (modalComponent === DefineLicenseLabelFormComponent) { + return labelEditModalRef; + } + if (modalComponent === ConfirmationModalComponent) { + return labelDeleteModalRef; + } + return { componentInstance: {}, result: Promise.resolve(null) } as any; + }); groupsDataService = jasmine.createSpyObj('groupsDataService', { findListByHref: createSuccessfulRemoteDataObject$(createPaginatedList([])), getGroupRegistryRouterLink: '' @@ -103,7 +140,8 @@ describe('ClarinLicenseTableComponent', () => { { provide: ClarinLicenseLabelDataService, useValue: clarinLicenseLabelDataService }, { provide: PaginationService, useValue: paginationServiceStub }, { provide: NotificationsService, useValue: notificationService }, - { provide: NgbActiveModal, useValue: modalStub }, + { provide: NgbActiveModal, useValue: activeModalStub }, + { provide: NgbModal, useValue: modalServiceStub }, { provide: HostWindowService, useValue: new HostWindowServiceStub(0) }, { provide: GroupDataService, useValue: groupsDataService }, { provide: LinkHeadService, useValue: linkHeadService }, @@ -184,6 +222,306 @@ describe('ClarinLicenseTableComponent', () => { expect((component as ClarinLicenseTableComponent).licensesRD$).not.toBeNull(); }); + describe('label edit flow', () => { + beforeEach(() => { + notificationService.success.calls.reset(); + notificationService.error.calls.reset(); + (clarinLicenseLabelDataService.put as jasmine.Spy).calls.reset(); + }); + + it('should open edit modal with the selected label when editLabel is called', () => { + component.editLabel(mockExtendedLicenseLabel); + + expect(modalServiceStub.open).toHaveBeenCalledWith(DefineLicenseLabelFormComponent, { 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).toEqual([1, 2, 3]); + })); + + 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'); + expect(labelDeleteModalRef.componentInstance.dso.name).toBe(mockNonExtendedLicenseLabel.label); + }); + + it('should call clarinLicenseLabelService.delete with correct id on confirmation', fakeAsync(() => { + const refreshSpy = spyOn(component, 'refreshLabels').and.stub(); + const reloadLicensesSpy = spyOn(component, 'loadAllLicenses').and.stub(); + (clarinLicenseLabelDataService.delete as jasmine.Spy).and.returnValue(createNoContentRemoteDataObject$()); + + component.confirmDeleteLabel(mockNonExtendedLicenseLabel); + labelDeleteModalRef.componentInstance.response.emit(true); + tick(); + + expect((clarinLicenseLabelDataService.delete as jasmine.Spy)).toHaveBeenCalledWith(String(mockNonExtendedLicenseLabel.id)); + expect(notificationService.success).toHaveBeenCalled(); + expect(refreshSpy).toHaveBeenCalled(); + expect(reloadLicensesSpy).toHaveBeenCalled(); + })); + + it('should show error notification on failed delete', () => { + spyOn(component, 'refreshLabels').and.stub(); + (clarinLicenseLabelDataService.delete as jasmine.Spy).and.returnValue(throwError(() => new Error('delete failed'))); + + component.confirmDeleteLabel(mockNonExtendedLicenseLabel); + labelDeleteModalRef.componentInstance.response.emit(true); + + expect(notificationService.error).toHaveBeenCalled(); + }); + + it('should not call delete service when confirmation is cancelled', () => { + component.confirmDeleteLabel(mockNonExtendedLicenseLabel); + labelDeleteModalRef.componentInstance.response.emit(false); + + expect((clarinLicenseLabelDataService.delete as jasmine.Spy)).not.toHaveBeenCalled(); + }); + }); + + describe('label row actions', () => { + const linkedLabel = Object.assign(new ClarinLicenseLabel(), { + id: 200, + label: 'LNKD', + title: 'Linked', + extended: false, + icon: null, + _links: { + self: { + href: 'url.linked' + } + } + }); + + const unlinkedLabel = Object.assign(new ClarinLicenseLabel(), { + id: 201, + label: 'UNLK', + title: 'Unlinked', + extended: false, + icon: null, + _links: { + self: { + href: 'url.unlinked' + } + } + }); + + const linkedLicense = Object.assign(new ClarinLicense(), { + ...mockLicense, + clarinLicenseLabel: linkedLabel, + extendedClarinLicenseLabels: [] + }); + + beforeEach(() => { + (component as any).labelsRD$.next( + createSuccessfulRemoteDataObject(buildPaginatedList(new PageInfo(), [linkedLabel, unlinkedLabel])) + ); + (component as any).inUseLabelIds = new Set([String(linkedLicense.clarinLicenseLabel.id)]); + (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]; + + expect(linkedRowButtons.length).toBe(2); + expect(linkedDeleteButton.attributes['aria-disabled']).toBe('true'); + expect(linkedDeleteButton.nativeElement.classList.contains('disabled')).toBeTrue(); + expect((linkedRowDeleteWrapper.nativeElement as HTMLElement).getAttribute('tabindex')).toBe('0'); + expect((linkedRowDeleteWrapper.nativeElement as HTMLElement).getAttribute('ng-reflect-ngb-tooltip')).toContain('clarin.license.label.table.del'); + }); + + it('should not open confirmation modal when clicking disabled delete on linked label', () => { + const labelRows = fixture.debugElement.queryAll(By.css('.labels-section tbody tr')); + const linkedDeleteButton = labelRows[0].queryAll(By.css('button'))[1]; + + modalServiceStub.open.calls.reset(); + (clarinLicenseLabelDataService.delete as jasmine.Spy).calls.reset(); + + linkedDeleteButton.nativeElement.click(); + fixture.detectChanges(); + + expect(modalServiceStub.open).not.toHaveBeenCalledWith(ConfirmationModalComponent); + expect((clarinLicenseLabelDataService.delete as jasmine.Spy)).not.toHaveBeenCalled(); + }); + + it('should keep delete button enabled for unlinked labels', () => { + const labelRows = fixture.debugElement.queryAll(By.css('.labels-section tbody tr')); + const unlinkedRowDeleteWrapper = labelRows[1].query(By.css('td:last-child span')); + const unlinkedRowDeleteButton = labelRows[1].queryAll(By.css('button'))[1]; + + expect(unlinkedRowDeleteButton.attributes['aria-disabled']).toBe('false'); + expect(unlinkedRowDeleteButton.nativeElement.classList.contains('disabled')).toBeFalse(); + expect((unlinkedRowDeleteWrapper.nativeElement as HTMLElement).getAttribute('tabindex')).toBeNull(); + }); + }); + + it('should not show labels empty-state row when labels request failed', () => { + (component as any).loading$.next(false); + (component as any).labelsRD$.next(createFailedRemoteDataObject('labels load failed', 500)); + fixture.detectChanges(); + + const emptyStateRow = fixture.debugElement.queryAll(By.css('.labels-section tbody tr')) + .find((row) => row.nativeElement.textContent.includes('clarin.license.label.table.empty')); + + expect(emptyStateRow).toBeUndefined(); + }); + + describe('license usage loading performance', () => { + it('should load full usage dataset only once across repeated table reloads', () => { + (component as any).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; 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 f83513d13df..effe2774ec7 100644 --- a/src/app/clarin-licenses/clarin-license-table/clarin-license-table.component.ts +++ b/src/app/clarin-licenses/clarin-license-table/clarin-license-table.component.ts @@ -1,11 +1,11 @@ -import { Component, OnInit } from '@angular/core'; +import { Component, OnDestroy, OnInit } from '@angular/core'; import { PaginationComponentOptions } from '../../shared/pagination/pagination-component-options.model'; -import { BehaviorSubject, combineLatest as observableCombineLatest } from 'rxjs'; +import { BehaviorSubject, combineLatest as observableCombineLatest, Observable, of, Subject } from 'rxjs'; import { RemoteData } from '../../core/data/remote-data'; import { PaginatedList } from '../../core/data/paginated-list.model'; import { ClarinLicense } from '../../core/shared/clarin/clarin-license.model'; import { getFirstCompletedRemoteData, getFirstSucceededRemoteData } from '../../core/shared/operators'; -import { switchMap } from 'rxjs/operators'; +import { switchMap, take, takeUntil } from 'rxjs/operators'; import { PaginationService } from '../../core/pagination/pagination.service'; import { ClarinLicenseDataService } from '../../core/data/clarin/clarin-license-data.service'; import { defaultPagination, defaultSortConfiguration } from '../clarin-license-table-pagination'; @@ -22,6 +22,8 @@ import { ClarinLicenseLabelExtendedSerializer } from '../../core/shared/clarin/c import { ClarinLicenseRequiredInfoSerializer } from '../../core/shared/clarin/clarin-license-required-info-serializer'; import cloneDeep from 'lodash/cloneDeep'; import { RequestParam } from '../../core/cache/models/request-param.model'; +import { ConfirmationModalComponent } from '../../shared/confirmation-modal/confirmation-modal.component'; +import { DSpaceObject } from '../../core/shared/dspace-object.model'; /** * Component for managing clarin licenses and defining clarin license labels. @@ -31,7 +33,7 @@ import { RequestParam } from '../../core/cache/models/request-param.model'; templateUrl: './clarin-license-table.component.html', styleUrls: ['./clarin-license-table.component.scss'] }) -export class ClarinLicenseTableComponent implements OnInit { +export class ClarinLicenseTableComponent implements OnInit, OnDestroy { constructor(private paginationService: PaginationService, private clarinLicenseService: ClarinLicenseDataService, @@ -41,6 +43,12 @@ export class ClarinLicenseTableComponent implements OnInit { private notificationService: NotificationsService, private translateService: TranslateService,) { } + /** + * Full licenses dataset used by frontend-only label usage derivation. + */ + allLicensesRD$: BehaviorSubject>> = + new BehaviorSubject>>(null); + /** * The list of ClarinLicense object as BehaviorSubject object */ @@ -67,14 +75,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 @@ -82,7 +149,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); @@ -100,6 +167,7 @@ export class ClarinLicenseTableComponent implements OnInit { const errorMessageContentDef = 'clarin-license.define-license.notification.error-content'; if (isNull(clarinLicense)) { this.notifyOperationStatus(clarinLicense, successfulMessageContentDef, errorMessageContentDef); + return; } // convert string value from the form to the number @@ -114,7 +182,7 @@ export class ClarinLicenseTableComponent implements OnInit { .subscribe((defineLicenseResponse: RemoteData) => { // check payload and show error or successful this.notifyOperationStatus(defineLicenseResponse, successfulMessageContentDef, errorMessageContentDef); - this.loadAllLicenses(); + this.loadAllLicenses({ forceUsageReload: true }); }); } @@ -128,7 +196,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; @@ -152,6 +220,7 @@ export class ClarinLicenseTableComponent implements OnInit { const errorMessageContentDef = 'clarin-license.edit-license.notification.error-content'; if (isNull(clarinLicense)) { this.notifyOperationStatus(clarinLicense, successfulMessageContentDef, errorMessageContentDef); + return; } const clarinLicenseObj = new ClarinLicense(); @@ -176,7 +245,7 @@ export class ClarinLicenseTableComponent implements OnInit { .subscribe((editResponse: RemoteData) => { // check payload and show error or successful this.notifyOperationStatus(editResponse, successfulMessageContentDef, errorMessageContentDef); - this.loadAllLicenses(); + this.loadAllLicenses({ forceUsageReload: true }); }); } @@ -203,7 +272,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); @@ -217,10 +286,11 @@ export class ClarinLicenseTableComponent implements OnInit { * @param clarinLicenseLabel object from the License Label modal. */ defineLicenseLabel(clarinLicenseLabel: ClarinLicenseLabel) { - const successfulMessageContentDef = 'clarin-license-label.define-license-label.notification.successful-content'; - const errorMessageContentDef = 'clarin-license-label.define-license-label.notification.error-content'; + const successfulMessageContentDef = 'clarin.license.label.create.success'; + const errorMessageContentDef = 'clarin.license.label.create.error'; if (isNull(clarinLicenseLabel)) { this.notifyOperationStatus(clarinLicenseLabel, successfulMessageContentDef, errorMessageContentDef); + return; } // convert file to the byte array @@ -271,6 +341,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(); + } }); } @@ -288,10 +365,154 @@ 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 }); + }); + } + + /** + * 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 ? [] : selectedLabel.icon; + this.doUpdateLabel(updatedLabel, successMsg, errorMsg); + } + } + + /** + * Execute the actual PUT request for a label and handle notifications + dependent list refreshes. + */ + private doUpdateLabel(label: ClarinLicenseLabel, successMsg: string, errorMsg: string) { + this.clarinLicenseLabelService.put(label) + .pipe(getFirstCompletedRemoteData(), takeUntil(this.ngUnsubscribe)) + .subscribe((res: RemoteData) => { + this.notifyOperationStatus(res, successMsg, errorMsg); + if (res?.hasSucceeded) { + this.refreshLabels(); + this.loadAllLicenses(); + } }); } + /** + * Ask for confirmation and delete the selected license label. + */ + confirmDeleteLabel(labelToDelete: ClarinLicenseLabel) { + if (isNull(labelToDelete?.id)) { + return; + } + + const labelDeleteDSO = new DSpaceObject(); + labelDeleteDSO.name = labelToDelete.label; + + const modalRef = this.modalService.open(ConfirmationModalComponent, { centered: true }); + modalRef.componentInstance.dso = labelDeleteDSO; + modalRef.componentInstance.headerLabel = 'clarin.license.label.delete.confirm.title'; + modalRef.componentInstance.infoLabel = 'clarin.license.label.delete.confirm.message'; + modalRef.componentInstance.cancelLabel = 'clarin.license.label.delete.cancel.button'; + modalRef.componentInstance.confirmLabel = 'clarin.license.label.delete.confirm.button'; + modalRef.componentInstance.brandColor = 'danger'; + modalRef.componentInstance.confirmIcon = 'fas fa-trash'; + + modalRef.componentInstance.response + .pipe(take(1), takeUntil(this.ngUnsubscribe)) + .subscribe((confirm: boolean) => { + if (!confirm) { + return; + } + + this.clarinLicenseLabelService.delete(String(labelToDelete.id)) + .pipe(getFirstCompletedRemoteData(), takeUntil(this.ngUnsubscribe)) + .subscribe((deleteLabelResponse) => { + if (deleteLabelResponse?.hasSucceeded) { + this.notificationService.success('', this.translateService.get('clarin.license.label.delete.success')); + this.refreshLabels(); + this.loadAllLicenses(); + } else { + this.notificationService.error('', this.translateService.get('clarin.license.label.delete.error')); + } + }, () => { + this.notificationService.error('', this.translateService.get('clarin.license.label.delete.error')); + }); + }); + } + + /** + * Reload labels table using current pagination options. + */ + refreshLabels() { + this.labelsRefresh$.next(undefined); + } + + /** + * 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 @@ -330,17 +551,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(); @@ -363,6 +586,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, + 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 @@ -399,4 +736,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 2212f994e1d..3a48bd12f8a 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,63 +1,57 @@ -