From 07fbf4d6abf808d18058a0f4c8e932d37604c783 Mon Sep 17 00:00:00 2001 From: amadulhaxxani Date: Mon, 20 Apr 2026 11:12:08 +0200 Subject: [PATCH 1/8] 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. --- .../clarin-license-label-data.service.ts | 40 ++++++++++++++++++- 1 file changed, 38 insertions(+), 2 deletions(-) diff --git a/src/app/core/data/clarin/clarin-license-label-data.service.ts b/src/app/core/data/clarin/clarin-license-label-data.service.ts index eb4d3f9b454..3901785ac44 100644 --- a/src/app/core/data/clarin/clarin-license-label-data.service.ts +++ b/src/app/core/data/clarin/clarin-license-label-data.service.ts @@ -9,7 +9,6 @@ import { DefaultChangeAnalyzer } from '../default-change-analyzer.service'; import { HttpClient } from '@angular/common/http'; import { NotificationsService } from '../../../shared/notifications/notifications.service'; import { ClarinLicenseLabel } from '../../shared/clarin/clarin-license-label.model'; -import { BaseDataService } from '../base/base-data.service'; import { dataService } from '../base/data-service.decorator'; import {CreateData, CreateDataImpl} from '../base/create-data'; import {RequestParam} from '../../cache/models/request-param.model'; @@ -17,6 +16,10 @@ import {Observable} from 'rxjs'; import {RemoteData} from '../remote-data'; import {CoreState} from '../../core-state.model'; import {FindAllData, FindAllDataImpl} from '../base/find-all-data'; +import {PutData, PutDataImpl} from '../base/put-data'; +import {DeleteData, DeleteDataImpl} from '../base/delete-data'; +import {IdentifiableDataService} from '../base/identifiable-data.service'; +import {NoContent} from '../../shared/NoContent.model'; import { FollowLinkConfig } from 'src/app/shared/utils/follow-link-config.model'; import { FindListOptions } from '../find-list-options.model'; import { PaginatedList } from '../paginated-list.model'; @@ -29,9 +32,11 @@ export const AUTOCOMPLETE = new ResourceType(linkName); */ @Injectable() @dataService(ClarinLicenseLabel.type) -export class ClarinLicenseLabelDataService extends BaseDataService implements CreateData, FindAllData { +export class ClarinLicenseLabelDataService extends IdentifiableDataService implements CreateData, PutData, DeleteData, FindAllData { protected linkPath = linkName; private createData: CreateData; + private putData: PutData; + private deleteData: DeleteData; private findAllData: FindAllData; constructor( @@ -48,6 +53,8 @@ export class ClarinLicenseLabelDataService extends BaseDataService[]): Observable>> { @@ -57,4 +64,33 @@ export class ClarinLicenseLabelDataService extends BaseDataService> { return this.createData.create(object, ...params); } + + /** + * Update an existing Clarin License Label. + * @param object The Clarin License Label object to update. + * @returns Observable containing the updated Clarin License Label remote data response. + */ + put(object: ClarinLicenseLabel): Observable> { + return this.putData.put(object); + } + + /** + * Delete a Clarin License Label by identifier. + * @param objectId The identifier of the Clarin License Label to delete. + * @param copyVirtualMetadata Optional metadata fields to copy prior to delete. + * @returns Observable containing the delete operation remote data response. + */ + delete(objectId: string, copyVirtualMetadata?: string[]): Observable> { + return this.deleteData.delete(objectId, copyVirtualMetadata); + } + + /** + * Delete a Clarin License Label by resource href. + * @param href The href of the Clarin License Label to delete. + * @param copyVirtualMetadata Optional metadata fields to copy prior to delete. + * @returns Observable containing the delete operation remote data response. + */ + deleteByHref(href: string, copyVirtualMetadata?: string[]): Observable> { + return this.deleteData.deleteByHref(href, copyVirtualMetadata); + } } From b1889a5ff1406cfd8c467623ba2f1cd822d826af Mon Sep 17 00:00:00 2001 From: amadulhaxxani Date: Mon, 20 Apr 2026 11:30:46 +0200 Subject: [PATCH 2/8] 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. --- .../clarin-license-table.component.html | 50 +++++++++++++++++++ .../clarin-license-table.component.scss | 4 ++ .../clarin-license-table.component.ts | 28 ++++++++++- 3 files changed, 81 insertions(+), 1 deletion(-) diff --git a/src/app/clarin-licenses/clarin-license-table/clarin-license-table.component.html b/src/app/clarin-licenses/clarin-license-table/clarin-license-table.component.html index 729b0f781d5..ca0fb8d43d2 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,54 @@ + +
+

License Labels

+ + + + + + + + + + + + + + + + + + + + + + + +
LabelTitleExtendedIconActions
{{label?.label}}{{label?.title}}{{label?.extended ? 'Yes' : 'No'}} + + {{label?.icon?.length > 0 ? 'Icon available' : 'No icon'}} + + + +
No license labels available.
+ + +
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.ts b/src/app/clarin-licenses/clarin-license-table/clarin-license-table.component.ts index 143f991aab1..cf67471866c 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,6 @@ import { Component, 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 } 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'; @@ -67,6 +67,16 @@ export class ClarinLicenseTableComponent implements OnInit { */ searchingLicenseName = ''; + /** + * Placeholder list of license labels. + */ + labels$: Observable = of([]); + + /** + * Placeholder loading state for labels table. + */ + loading$: Observable = of(false); + ngOnInit(): void { this.initializePaginationOptions(); this.loadAllLicenses(); @@ -287,6 +297,22 @@ export class ClarinLicenseTableComponent implements OnInit { }); } + /** + * Placeholder edit action for license labels. Wiring will be implemented in a follow-up task. + * @param label Selected license label + */ + editLabel(label: ClarinLicenseLabel) { + console.log('Edit label placeholder action', label); + } + + /** + * Placeholder delete action for license labels. Wiring will be implemented in a follow-up task. + * @param label Selected license label + */ + confirmDeleteLabel(label: ClarinLicenseLabel) { + console.log('Delete label placeholder action', label); + } + /** * Pop up the notification about the request success. Messages are loaded from the `en.json5`. * @param operationResponse current response From 0f6fc9b1c64108c1f9fd3cb3bf6b91501820d816 Mon Sep 17 00:00:00 2001 From: amadulhaxxani Date: Mon, 20 Apr 2026 14:01:02 +0200 Subject: [PATCH 3/8] 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. --- .../clarin-license-table.component.html | 19 +++- .../clarin-license-table.component.spec.ts | 4 +- .../clarin-license-table.component.ts | 107 +++++++++++++++--- 3 files changed, 110 insertions(+), 20 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 ca0fb8d43d2..e069a0da295 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 @@ -90,6 +90,7 @@

License Labels

+ @@ -98,7 +99,15 @@

License Labels

- + + @@ -110,7 +119,8 @@

License Labels

- +
Label Title Extended
+ + {{label?.label}} {{label?.title}} {{label?.extended ? 'Yes' : 'No'}}
No license labels available.No license labels available.
diff --git a/src/app/clarin-licenses/clarin-license-table/clarin-license-table.component.spec.ts b/src/app/clarin-licenses/clarin-license-table/clarin-license-table.component.spec.ts index 0cc824bdd8b..80e9c840a64 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 @@ -21,6 +21,7 @@ import { createdLicenseLabelRD$, createdLicenseRD$, mockExtendedLicenseLabel, + mockLicenseLabelListRD$, mockLicense, mockLicenseRD$, mockNonExtendedLicenseLabel, successfulResponse } from '../../shared/testing/clarin-license-mock'; @@ -55,7 +56,8 @@ describe('ClarinLicenseTableComponent', () => { getLinkPath: observableOf('') }); clarinLicenseLabelDataService = jasmine.createSpyObj('clarinLicenseLabelService', { - create: createdLicenseLabelRD$ + create: createdLicenseLabelRD$, + findAll: mockLicenseLabelListRD$ }); requestService = jasmine.createSpyObj('requestService', { send: observableOf('response'), 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 cf67471866c..c9b3f660a7e 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, Observable, of } from 'rxjs'; +import { BehaviorSubject, combineLatest as observableCombineLatest, Subject } from 'rxjs'; import { RemoteData } from '../../core/data/remote-data'; import { PaginatedList } from '../../core/data/paginated-list.model'; import { ClarinLicense } from '../../core/shared/clarin/clarin-license.model'; import { getFirstCompletedRemoteData, getFirstSucceededRemoteData } from '../../core/shared/operators'; -import { scan, switchMap } from 'rxjs/operators'; +import { scan, switchMap, 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,7 @@ import { ClarinLicenseLabelExtendedSerializer } from '../../core/shared/clarin/c import { ClarinLicenseRequiredInfoSerializer } from '../../core/shared/clarin/clarin-license-required-info-serializer'; import cloneDeep from 'lodash/cloneDeep'; import { RequestParam } from '../../core/cache/models/request-param.model'; +import { SortOptions } from '../../core/cache/models/sort-options.model'; /** * Component for managing clarin licenses and defining clarin license labels. @@ -31,7 +32,14 @@ import { RequestParam } from '../../core/cache/models/request-param.model'; templateUrl: './clarin-license-table.component.html', styleUrls: ['./clarin-license-table.component.scss'] }) -export class ClarinLicenseTableComponent implements OnInit { +export class ClarinLicenseTableComponent implements OnInit, OnDestroy { + + private readonly defaultListState = { + searchTerm: '', + currentPage: 1, + currentPagination: defaultPagination, + currentSort: defaultSortConfiguration + }; constructor(private paginationService: PaginationService, private clarinLicenseService: ClarinLicenseDataService, @@ -68,18 +76,34 @@ export class ClarinLicenseTableComponent implements OnInit { searchingLicenseName = ''; /** - * Placeholder list of license labels. + * List of license labels displayed in the labels table. */ - labels$: Observable = of([]); + labels$ = new BehaviorSubject([]); /** - * Placeholder loading state for labels table. + * Loading state for labels table. */ - loading$: Observable = of(false); + loading$ = new BehaviorSubject(false); + + /** + * Selected label in labels table. + */ + selectedLabel: ClarinLicenseLabel = null; + + /** + * Emits when component is destroyed to clean up subscriptions. + */ + private ngUnsubscribe = new Subject(); ngOnInit(): void { this.initializePaginationOptions(); this.loadAllLicenses(); + this.refreshLabels(); + } + + ngOnDestroy(): void { + this.ngUnsubscribe.next(); + this.ngUnsubscribe.complete(); } // define license @@ -276,6 +300,7 @@ export class ClarinLicenseTableComponent implements OnInit { // check payload and show error or successful this.notifyOperationStatus(defineLicenseLabelResponse, successfulMessageContentDef, errorMessageContentDef); this.loadAllLicenses(); + this.refreshLabels(); }); } @@ -301,16 +326,64 @@ export class ClarinLicenseTableComponent implements OnInit { * Placeholder edit action for license labels. Wiring will be implemented in a follow-up task. * @param label Selected license label */ - editLabel(label: ClarinLicenseLabel) { - console.log('Edit label placeholder action', label); + editLabel() { + if (isNull(this.selectedLabel)) { + return; + } + console.log('Edit label placeholder action', this.selectedLabel); } /** * Placeholder delete action for license labels. Wiring will be implemented in a follow-up task. * @param label Selected license label */ - confirmDeleteLabel(label: ClarinLicenseLabel) { - console.log('Delete label placeholder action', label); + confirmDeleteLabel() { + if (isNull(this.selectedLabel)) { + return; + } + console.log('Delete label placeholder action', this.selectedLabel); + } + + /** + * Load all labels for label management table. + */ + refreshLabels() { + this.selectedLabel = null; + this.loading$.next(true); + + this.clarinLicenseLabelService.findAll({ elementsPerPage: 1000 }, false) + .pipe(getFirstCompletedRemoteData(), takeUntil(this.ngUnsubscribe)) + .subscribe((labelsResponse: RemoteData>) => { + if (labelsResponse?.hasSucceeded) { + this.labels$.next(labelsResponse?.payload?.page ?? []); + } else { + this.labels$.next([]); + this.notificationService.error('', this.translateService.get('clarin-license-label.define-license-label.notification.error-content')); + } + this.loading$.next(false); + }, () => { + this.labels$.next([]); + this.notificationService.error('', this.translateService.get('clarin-license-label.define-license-label.notification.error-content')); + this.loading$.next(false); + } + ); + } + + /** + * Select the label for edit/delete actions. + * @param label The label to select. + */ + selectLabel(label: ClarinLicenseLabel) { + this.selectedLabel = label; + } + + /** + * Determine whether the provided label is selected. + * @param label The label to check. + * @returns True when selected, otherwise false. + */ + isSelected(label: ClarinLicenseLabel): boolean { + return this.selectedLabel?.id === label?.id; } /** @@ -355,12 +428,16 @@ export class ClarinLicenseTableComponent implements OnInit { const searchTerm$ = new BehaviorSubject(this.searchingLicenseName); observableCombineLatest([currentPagination$, currentSort$, searchTerm$]).pipe( - scan((prevState, [currentPagination, currentSort, searchTerm]) => { + scan((prevState: { + searchTerm: string; + currentPage: number; + currentPagination: PaginationComponentOptions; + currentSort: SortOptions; + }, [currentPagination, currentSort, searchTerm]) => { // If search term has changed, reset to page 1; otherwise, keep current page const currentPage = prevState.searchTerm !== searchTerm ? 1 : currentPagination.currentPage; return { currentPage, currentPagination, currentSort, searchTerm }; - }, { searchTerm: '', currentPage: 1, currentPagination: this.getCurrentPagination(), - currentSort: this.getCurrentSort() }), + }, this.defaultListState), switchMap(({ currentPage, currentPagination, currentSort, searchTerm }) => { return this.clarinLicenseService.searchBy('byNameLike', { From 95bf1c9747e04638e5a2c93b568fefa84d0607ce Mon Sep 17 00:00:00 2001 From: amadulhaxxani Date: Wed, 22 Apr 2026 13:10:48 +0200 Subject: [PATCH 4/8] 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. --- .../clarin-license-table.component.html | 113 +++++----- .../clarin-license-table.component.spec.ts | 155 ++++++++++++- .../clarin-license-table.component.ts | 210 ++++++++++++++---- .../define-license-label-form.component.html | 31 +-- ...efine-license-label-form.component.spec.ts | 68 ++++++ .../define-license-label-form.component.ts | 32 ++- ...larin-license-label-extended-serializer.ts | 8 +- src/assets/i18n/cs.json5 | 99 +++++++++ src/assets/i18n/en.json5 | 33 +++ 9 files changed, 619 insertions(+), 130 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 e069a0da295..4a69bb4f61e 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 @@ -85,63 +85,64 @@
-

License Labels

+

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

- - - - - - - - - - - - - - - - - - - - - - - - -
LabelTitleExtendedIconActions
- - {{label?.label}}{{label?.title}}{{label?.extended ? 'Yes' : 'No'}} - - {{label?.icon?.length > 0 ? 'Icon available' : 'No icon'}} - - - -
No license labels available.
+
+ +
+ + + + + + + + + + + + + + + + + + + + + + +
{{'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.spec.ts b/src/app/clarin-licenses/clarin-license-table/clarin-license-table.component.spec.ts index 80e9c840a64..310e5ef958e 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,6 +17,7 @@ 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 { @@ -27,11 +30,14 @@ import { } from '../../shared/testing/clarin-license-mock'; import {GroupDataService} from '../../core/eperson/group-data.service'; import {createSuccessfulRemoteDataObject$} from '../../shared/remote-data.utils'; +import { createFailedRemoteDataObject$, createNoContentRemoteDataObject$ } 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'; describe('ClarinLicenseTableComponent', () => { let component: ClarinLicenseTableComponent; @@ -41,10 +47,13 @@ describe('ClarinLicenseTableComponent', () => { let clarinLicenseLabelDataService: ClarinLicenseLabelDataService; let requestService: RequestService; let notificationService: NotificationsServiceStub; - let modalStub: NgbActiveModal; + let activeModalStub: NgbActiveModal; + let modalServiceStub: jasmine.SpyObj; let groupsDataService: GroupDataService; let service: ConfigurationDataService; let searchConfigurationServiceStub: SearchConfigurationService; + let labelEditModalRef: any; + let labelDeleteModalRef: any; beforeEach(async () => { notificationService = new NotificationsServiceStub(); @@ -57,14 +66,35 @@ describe('ClarinLicenseTableComponent', () => { }); clarinLicenseLabelDataService = jasmine.createSpyObj('clarinLicenseLabelService', { create: createdLicenseLabelRD$, - findAll: mockLicenseLabelListRD$ + 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 +133,8 @@ describe('ClarinLicenseTableComponent', () => { { provide: ClarinLicenseLabelDataService, useValue: clarinLicenseLabelDataService }, { provide: PaginationService, useValue: new PaginationServiceStub() }, { provide: NotificationsService, useValue: notificationService }, - { provide: NgbActiveModal, useValue: modalStub }, + { provide: NgbActiveModal, useValue: activeModalStub }, + { provide: NgbModal, useValue: modalServiceStub }, { provide: HostWindowService, useValue: new HostWindowServiceStub(0) }, { provide: GroupDataService, useValue: groupsDataService }, { provide: LinkHeadService, useValue: linkHeadService }, @@ -183,4 +214,116 @@ describe('ClarinLicenseTableComponent', () => { expect((component as any).clarinLicenseService.searchBy).toHaveBeenCalled(); expect((component as ClarinLicenseTableComponent).licensesRD$).not.toBeNull(); }); + + describe('label edit flow', () => { + beforeEach(() => { + notificationService.success.calls.reset(); + notificationService.error.calls.reset(); + (clarinLicenseLabelDataService.put as jasmine.Spy).calls.reset(); + }); + + it('should open edit modal with the selected label when editLabel is called', () => { + component.editLabel(mockExtendedLicenseLabel); + + expect(modalServiceStub.open).toHaveBeenCalledWith(DefineLicenseLabelFormComponent); + expect(labelEditModalRef.componentInstance.clarinLicenseLabel).toBe(mockExtendedLicenseLabel); + }); + + it('should call clarinLicenseLabelService.put with updated label on modal submit', fakeAsync(() => { + const refreshSpy = spyOn(component, 'refreshLabels').and.stub(); + 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(); + })); + + it('should show error notification on failed edit', fakeAsync(() => { + spyOn(component, 'refreshLabels').and.stub(); + (clarinLicenseLabelDataService.put as jasmine.Spy).and.returnValue(createFailedRemoteDataObject$('put failed', 500)); + + component.editLicenseLabel({ + label: 'ERR', + title: 'Failed title', + extended: true + }, mockExtendedLicenseLabel); + tick(); + + expect(notificationService.error).toHaveBeenCalled(); + })); + }); + + describe('label delete flow', () => { + beforeEach(() => { + notificationService.success.calls.reset(); + notificationService.error.calls.reset(); + (clarinLicenseLabelDataService.delete as jasmine.Spy).calls.reset(); + labelDeleteModalRef.componentInstance.response = new EventEmitter(); + }); + + it('should open confirmation modal when confirmDeleteLabel is called', () => { + component.confirmDeleteLabel(mockNonExtendedLicenseLabel); + + expect(modalServiceStub.open).toHaveBeenCalledWith(ConfirmationModalComponent); + expect(labelDeleteModalRef.componentInstance.headerLabel).toBe('clarin.license.label.delete.confirm.title'); + expect(labelDeleteModalRef.componentInstance.infoLabel).toBe('clarin.license.label.delete.confirm.message'); + expect(labelDeleteModalRef.componentInstance.dso.name).toBe(mockNonExtendedLicenseLabel.label); + }); + + it('should call clarinLicenseLabelService.delete with correct id on confirmation', fakeAsync(() => { + const refreshSpy = spyOn(component, 'refreshLabels').and.stub(); + (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(); + })); + + 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', () => { + it('should render edit and delete buttons for each label row', () => { + fixture.detectChanges(); + + const firstRowButtons = fixture.debugElement.queryAll(By.css('.labels-section tbody tr'))[0] + .queryAll(By.css('button')); + + expect(firstRowButtons.length).toBe(2); + expect((firstRowButtons[0].nativeElement as HTMLButtonElement).disabled).toBeFalse(); + expect((firstRowButtons[1].nativeElement as HTMLButtonElement).disabled).toBeFalse(); + }); + }); }); 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 c9b3f660a7e..f342095136d 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 @@ -5,7 +5,7 @@ import { RemoteData } from '../../core/data/remote-data'; import { PaginatedList } from '../../core/data/paginated-list.model'; import { ClarinLicense } from '../../core/shared/clarin/clarin-license.model'; import { getFirstCompletedRemoteData, getFirstSucceededRemoteData } from '../../core/shared/operators'; -import { scan, switchMap, takeUntil } from 'rxjs/operators'; +import { scan, switchMap, take, takeUntil } from 'rxjs/operators'; import { PaginationService } from '../../core/pagination/pagination.service'; import { ClarinLicenseDataService } from '../../core/data/clarin/clarin-license-data.service'; import { defaultPagination, defaultSortConfiguration } from '../clarin-license-table-pagination'; @@ -23,6 +23,8 @@ import { ClarinLicenseRequiredInfoSerializer } from '../../core/shared/clarin/cl import cloneDeep from 'lodash/cloneDeep'; import { RequestParam } from '../../core/cache/models/request-param.model'; import { SortOptions } from '../../core/cache/models/sort-options.model'; +import { ConfirmationModalComponent } from '../../shared/confirmation-modal/confirmation-modal.component'; +import { DSpaceObject } from '../../core/shared/dspace-object.model'; /** * Component for managing clarin licenses and defining clarin license labels. @@ -76,19 +78,29 @@ export class ClarinLicenseTableComponent implements OnInit, OnDestroy { searchingLicenseName = ''; /** - * List of license labels displayed in the labels table. + * RemoteData stream for license labels table. */ - labels$ = new BehaviorSubject([]); + labelsRD$: BehaviorSubject>> = + new BehaviorSubject>>(null); /** * Loading state for labels table. */ loading$ = new BehaviorSubject(false); - /** - * Selected label in labels table. - */ - selectedLabel: ClarinLicenseLabel = null; + /** + * 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); /** * Emits when component is destroyed to clean up subscriptions. @@ -98,7 +110,7 @@ export class ClarinLicenseTableComponent implements OnInit, OnDestroy { ngOnInit(): void { this.initializePaginationOptions(); this.loadAllLicenses(); - this.refreshLabels(); + this.initializeLabelsPaginationStream(); } ngOnDestroy(): void { @@ -129,6 +141,7 @@ export class ClarinLicenseTableComponent implements OnInit, OnDestroy { 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 @@ -181,6 +194,7 @@ export class ClarinLicenseTableComponent implements OnInit, OnDestroy { const errorMessageContentDef = 'clarin-license.edit-license.notification.error-content'; if (isNull(clarinLicense)) { this.notifyOperationStatus(clarinLicense, successfulMessageContentDef, errorMessageContentDef); + return; } const clarinLicenseObj = new ClarinLicense(); @@ -246,10 +260,11 @@ export class ClarinLicenseTableComponent implements OnInit, OnDestroy { * @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 @@ -323,67 +338,129 @@ export class ClarinLicenseTableComponent implements OnInit, OnDestroy { } /** - * Placeholder edit action for license labels. Wiring will be implemented in a follow-up task. - * @param label Selected license label + * 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() { - if (isNull(this.selectedLabel)) { + editLabel(label: ClarinLicenseLabel) { + if (isNull(label)) { return; } - console.log('Edit label placeholder action', this.selectedLabel); + + const editLabelModalRef = this.modalService.open(DefineLicenseLabelFormComponent); + editLabelModalRef.componentInstance.clarinLicenseLabel = label; + + editLabelModalRef.result.then((result) => { + this.editLicenseLabel(result, label); + }).catch(() => { /* dismissed */ }); } /** - * Placeholder delete action for license labels. Wiring will be implemented in a follow-up task. - * @param label Selected license label + * 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. */ - confirmDeleteLabel() { - if (isNull(this.selectedLabel)) { + 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; } - console.log('Delete label placeholder action', this.selectedLabel); + + const updatedLabel = new ClarinLicenseLabel(); + updatedLabel.id = selectedLabel.id; + updatedLabel._links = selectedLabel._links; + updatedLabel.type = selectedLabel.type; + updatedLabel.label = formValues.label; + updatedLabel.title = formValues.title; + updatedLabel.extended = !!formValues.extended; + + // file input: convert if a new file was selected, otherwise keep existing icon + const reader = new FileReader(); + try { + reader.readAsArrayBuffer(formValues.icon?.[0]); + reader.onerror = () => { + this.notifyOperationStatus(null, successMsg, errorMsg); + }; + reader.onloadend = (evt) => { + if (evt.target.readyState === FileReader.DONE) { + const buf = evt.target.result; + const bytes: number[] = []; + if (buf instanceof ArrayBuffer) { + const arr = new Uint8Array(buf); + for (const b of arr) { bytes.push(b); } + } + updatedLabel.icon = bytes; + this.doUpdateLabel(updatedLabel, successMsg, errorMsg); + } + }; + } catch { + // no new file selected – keep the existing icon from the stored label + updatedLabel.icon = selectedLabel.icon; + this.doUpdateLabel(updatedLabel, successMsg, errorMsg); + } } /** - * Load all labels for label management table. + * Execute the actual PUT request for a label and handle notifications + list refresh. */ - refreshLabels() { - this.selectedLabel = null; - this.loading$.next(true); - - this.clarinLicenseLabelService.findAll({ elementsPerPage: 1000 }, false) + private doUpdateLabel(label: ClarinLicenseLabel, successMsg: string, errorMsg: string) { + this.clarinLicenseLabelService.put(label) .pipe(getFirstCompletedRemoteData(), takeUntil(this.ngUnsubscribe)) - .subscribe((labelsResponse: RemoteData>) => { - if (labelsResponse?.hasSucceeded) { - this.labels$.next(labelsResponse?.payload?.page ?? []); - } else { - this.labels$.next([]); - this.notificationService.error('', this.translateService.get('clarin-license-label.define-license-label.notification.error-content')); - } - this.loading$.next(false); - }, () => { - this.labels$.next([]); - this.notificationService.error('', this.translateService.get('clarin-license-label.define-license-label.notification.error-content')); - this.loading$.next(false); - } - ); + .subscribe((res: RemoteData) => { + this.notifyOperationStatus(res, successMsg, errorMsg); + this.refreshLabels(); + }); } /** - * Select the label for edit/delete actions. - * @param label The label to select. + * Ask for confirmation and delete the selected license label. */ - selectLabel(label: ClarinLicenseLabel) { - this.selectedLabel = label; + confirmDeleteLabel(labelToDelete: ClarinLicenseLabel) { + if (isNull(labelToDelete?.id)) { + return; + } + + const labelDeleteDSO = new DSpaceObject(); + labelDeleteDSO.name = labelToDelete.label; + + const modalRef = this.modalService.open(ConfirmationModalComponent); + modalRef.componentInstance.dso = labelDeleteDSO; + modalRef.componentInstance.headerLabel = 'clarin.license.label.delete.confirm.title'; + modalRef.componentInstance.infoLabel = 'clarin.license.label.delete.confirm.message'; + modalRef.componentInstance.cancelLabel = 'clarin.license.label.delete.cancel.button'; + modalRef.componentInstance.confirmLabel = 'clarin.license.label.delete.confirm.button'; + modalRef.componentInstance.brandColor = 'danger'; + modalRef.componentInstance.confirmIcon = 'fas fa-trash'; + + modalRef.componentInstance.response + .pipe(take(1), takeUntil(this.ngUnsubscribe)) + .subscribe((confirm: boolean) => { + if (!confirm) { + return; + } + + this.clarinLicenseLabelService.delete(String(labelToDelete.id)) + .pipe(getFirstCompletedRemoteData(), takeUntil(this.ngUnsubscribe)) + .subscribe((deleteLabelResponse) => { + if (deleteLabelResponse?.hasSucceeded) { + this.notificationService.success('', this.translateService.get('clarin.license.label.delete.success')); + this.refreshLabels(); + } else { + this.notificationService.error('', this.translateService.get('clarin.license.label.delete.error')); + } + }, () => { + this.notificationService.error('', this.translateService.get('clarin.license.label.delete.error')); + }); + }); } /** - * Determine whether the provided label is selected. - * @param label The label to check. - * @returns True when selected, otherwise false. + * Reload labels table using current pagination options. */ - isSelected(label: ClarinLicenseLabel): boolean { - return this.selectedLabel?.id === label?.id; + refreshLabels() { + this.labelsRefresh$.next(undefined); } /** @@ -491,4 +568,39 @@ export class ClarinLicenseTableComponent implements OnInit, OnDestroy { 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 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('clarin.license.label.create.error')); + } + this.loading$.next(false); + }, () => { + this.labelsRD$.next(null); + this.notificationService.error('', this.translateService.get('clarin.license.label.create.error')); + this.loading$.next(false); + } + ); + } } diff --git a/src/app/clarin-licenses/clarin-license-table/modal/define-license-label-form/define-license-label-form.component.html b/src/app/clarin-licenses/clarin-license-table/modal/define-license-label-form/define-license-label-form.component.html index a78a285f638..2e9e4c5eb1d 100644 --- a/src/app/clarin-licenses/clarin-license-table/modal/define-license-label-form/define-license-label-form.component.html +++ b/src/app/clarin-licenses/clarin-license-table/modal/define-license-label-form/define-license-label-form.component.html @@ -2,38 +2,43 @@