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..8cc2aaee145 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
@@ -76,11 +76,92 @@
-
+
+
+
+
+
+
{{'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)}} |
+
+ 0 ? 'fa-check text-success' : 'fa-times text-muted'" aria-hidden="true">
+ {{label?.icon?.length > 0 ? ('clarin.license.label.table.icon.available' | translate) : ('clarin.license.label.table.icon.none' | translate)}}
+ |
+
+
+
+
+
+
+
+ |
+
+ 0)">
+ | {{'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..d8b39dd7888 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';
@@ -14,23 +16,32 @@ import { PaginationServiceStub } from '../../shared/testing/pagination-service.s
import { NotificationsService } from '../../shared/notifications/notifications.service';
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 { NgbActiveModal, NgbModal, NgbTooltip } 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 +51,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 () => {
@@ -52,18 +66,41 @@ describe('ClarinLicenseTableComponent', () => {
findAll: mockLicenseRD$,
create: createdLicenseRD$,
put: createdLicenseRD$,
+ delete: createNoContentRemoteDataObject$(),
searchBy: mockLicenseRD$,
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,355 @@ describe('ClarinLicenseTableComponent', () => {
expect((component as ClarinLicenseTableComponent).licensesRD$).not.toBeNull();
});
+ describe('license delete button', () => {
+ const getDeleteControls = () => {
+ const actionsRow = fixture.debugElement.query(By.css('.mt-2'));
+ const deleteWrapper = actionsRow.query(By.css('.btn-group.pr-1:last-child span'));
+ const deleteButton = deleteWrapper.query(By.css('button.btn-danger'));
+ return { deleteWrapper, deleteButton };
+ };
+
+ beforeEach(() => {
+ (clarinLicenseDataService.delete as jasmine.Spy).calls.reset();
+ });
+
+ it('should disable delete button and expose tooltip when selected license has bitstreams', () => {
+ component.selectedLicense = Object.assign({}, mockLicense, { bitstreams: 2 });
+ fixture.detectChanges();
+
+ const { deleteWrapper, deleteButton } = getDeleteControls();
+ const deleteTooltip = deleteWrapper.injector.get(NgbTooltip);
+
+ expect(deleteButton.attributes['aria-disabled']).toBe('true');
+ expect(deleteButton.nativeElement.classList.contains('disabled')).toBeTrue();
+ expect((deleteWrapper.nativeElement as HTMLElement).getAttribute('tabindex')).toBe('0');
+ expect(deleteTooltip.ngbTooltip as string).toContain('clarin-license.button.delete-l');
+ });
+
+ it('should not call delete when clicking disabled delete button', () => {
+ component.selectedLicense = Object.assign({}, mockLicense, { bitstreams: 1 });
+ fixture.detectChanges();
+
+ const { deleteButton } = getDeleteControls();
+ deleteButton.nativeElement.click();
+
+ expect((clarinLicenseDataService.delete as jasmine.Spy)).not.toHaveBeenCalled();
+ });
+
+ it('should enable delete button and call delete when selected license has no bitstreams', () => {
+ component.selectedLicense = Object.assign({}, mockLicense, { bitstreams: 0 });
+ fixture.detectChanges();
+
+ const { deleteWrapper, deleteButton } = getDeleteControls();
+ deleteButton.nativeElement.click();
+
+ expect(deleteButton.attributes['aria-disabled']).toBe('false');
+ expect(deleteButton.nativeElement.classList.contains('disabled')).toBeFalse();
+ expect((deleteWrapper.nativeElement as HTMLElement).getAttribute('tabindex')).toBeNull();
+ expect((clarinLicenseDataService.delete as jasmine.Spy)).toHaveBeenCalledWith(String(mockLicense.id));
+ });
+ });
+
+ describe('label edit flow', () => {
+ beforeEach(() => {
+ notificationService.success.calls.reset();
+ notificationService.error.calls.reset();
+ (clarinLicenseLabelDataService.put as jasmine.Spy).calls.reset();
+ });
+
+ it('should open edit modal with the selected label when editLabel is called', () => {
+ component.editLabel(mockExtendedLicenseLabel);
+
+ expect(modalServiceStub.open).toHaveBeenCalledWith(DefineLicenseLabelFormComponent, { centered: true });
+ expect(labelEditModalRef.componentInstance.clarinLicenseLabel).toBe(mockExtendedLicenseLabel);
+ });
+
+ it('should call clarinLicenseLabelService.put with updated label on modal submit', fakeAsync(() => {
+ const refreshSpy = spyOn(component, 'refreshLabels').and.stub();
+ const reloadLicensesSpy = spyOn(component, 'loadAllLicenses').and.stub();
+ labelEditModalRef.result = Promise.resolve({
+ label: 'EDIT',
+ title: 'Edited title',
+ extended: false
+ });
+
+ component.editLabel(mockExtendedLicenseLabel);
+ tick();
+
+ expect((clarinLicenseLabelDataService.put as jasmine.Spy)).toHaveBeenCalled();
+ const putArgument = (clarinLicenseLabelDataService.put as jasmine.Spy).calls.mostRecent().args[0];
+ expect(putArgument.id).toBe(mockExtendedLicenseLabel.id);
+ expect(putArgument._links).toEqual(mockExtendedLicenseLabel._links);
+ expect(putArgument.label).toBe('EDIT');
+ expect(putArgument.title).toBe('Edited title');
+ expect(putArgument.extended).toBeFalse();
+ expect(notificationService.success).toHaveBeenCalled();
+ expect(refreshSpy).toHaveBeenCalled();
+ expect(reloadLicensesSpy).toHaveBeenCalled();
+ }));
+
+ it('should clear the icon when clearIcon is set and no new file is selected', fakeAsync(() => {
+ spyOn(component, 'refreshLabels').and.stub();
+ spyOn(component, 'loadAllLicenses').and.stub();
+ const labelWithIcon = Object.assign(new ClarinLicenseLabel(), {
+ ...mockExtendedLicenseLabel,
+ icon: [1, 2, 3]
+ });
+
+ component.editLicenseLabel({
+ label: 'CLR',
+ title: 'Cleared icon',
+ extended: false,
+ clearIcon: true
+ }, labelWithIcon);
+ tick();
+
+ const putArgument = (clarinLicenseLabelDataService.put as jasmine.Spy).calls.mostRecent().args[0];
+ expect(putArgument.icon).toEqual([]);
+ expect(notificationService.success).toHaveBeenCalled();
+ }));
+
+ it('should keep the existing icon when clearIcon is not set and no new file is selected', fakeAsync(() => {
+ spyOn(component, 'refreshLabels').and.stub();
+ spyOn(component, 'loadAllLicenses').and.stub();
+ const labelWithIcon = Object.assign(new ClarinLicenseLabel(), {
+ ...mockExtendedLicenseLabel,
+ icon: [1, 2, 3]
+ });
+
+ component.editLicenseLabel({
+ label: 'KEP',
+ title: 'Kept icon',
+ extended: false,
+ clearIcon: false
+ }, labelWithIcon);
+ tick();
+
+ const putArgument = (clarinLicenseLabelDataService.put as jasmine.Spy).calls.mostRecent().args[0];
+ expect(putArgument.icon).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..91e0457f18e 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';
@@ -15,13 +15,15 @@ import { DefineLicenseLabelFormComponent } from './modal/define-license-label-fo
import { ClarinLicenseConfirmationSerializer } from '../../core/shared/clarin/clarin-license-confirmation-serializer';
import { NotificationsService } from '../../shared/notifications/notifications.service';
import { TranslateService } from '@ngx-translate/core';
-import { isNull } from '../../shared/empty.util';
+import { hasNoValue, isNull } from '../../shared/empty.util';
import { ClarinLicenseLabel } from '../../core/shared/clarin/clarin-license-label.model';
import { ClarinLicenseLabelDataService } from '../../core/data/clarin/clarin-license-label-data.service';
import { ClarinLicenseLabelExtendedSerializer } from '../../core/shared/clarin/clarin-license-label-extended-serializer';
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();
+ }
});
}
@@ -279,7 +356,7 @@ export class ClarinLicenseTableComponent implements OnInit {
* Delete selected license. If none license is selected do nothing.
*/
deleteLicense() {
- if (isNull(this.selectedLicense?.id)) {
+ if (hasNoValue(this.selectedLicense?.id) || this.isSelectedLicenseInUse()) {
return;
}
this.clarinLicenseService.delete(String(this.selectedLicense.id))
@@ -288,10 +365,161 @@ export class ClarinLicenseTableComponent implements OnInit {
const successfulMessageContentDef = 'clarin-license.delete-license.notification.successful-content';
const errorMessageContentDef = 'clarin-license.delete-license.notification.error-content';
this.notifyOperationStatus(deleteLicenseResponse, successfulMessageContentDef, errorMessageContentDef);
- this.loadAllLicenses();
+ this.loadAllLicenses({ forceUsageReload: true });
+ });
+ }
+
+ /**
+ * Returns whether selected license has attached bitstreams.
+ */
+ isSelectedLicenseInUse(): boolean {
+ return this.selectedLicense?.bitstreams > 0;
+ }
+
+ /**
+ * Open the edit modal for the selected license label, pre-filling its current values.
+ * On confirm, calls the PUT service and refreshes the label list.
+ */
+ editLabel(label: ClarinLicenseLabel) {
+ if (isNull(label)) {
+ return;
+ }
+
+ const editLabelModalRef = this.modalService.open(DefineLicenseLabelFormComponent, { centered: true });
+ editLabelModalRef.componentInstance.clarinLicenseLabel = label;
+
+ editLabelModalRef.result.then((result) => {
+ this.editLicenseLabel(result, label);
+ }).catch(() => { /* dismissed */ });
+ }
+
+ /**
+ * Send a PUT request to update the selected label with the new form values.
+ * Handles success/error notifications and refreshes the label list.
+ * @param formValues The updated form values returned from the edit modal.
+ * @param selectedLabel The selected label row to update.
+ */
+ editLicenseLabel(formValues: any, selectedLabel: ClarinLicenseLabel) {
+ const successMsg = 'clarin.license.label.edit.success';
+ const errorMsg = 'clarin.license.label.edit.error';
+ if (isNull(formValues) || isNull(selectedLabel)) {
+ this.notifyOperationStatus(null, successMsg, errorMsg);
+ return;
+ }
+
+ const updatedLabel = new ClarinLicenseLabel();
+ updatedLabel.id = selectedLabel.id;
+ updatedLabel._links = selectedLabel._links;
+ updatedLabel.type = selectedLabel.type;
+ updatedLabel.label = formValues.label;
+ updatedLabel.title = formValues.title;
+ updatedLabel.extended = !!formValues.extended;
+
+ // file input: convert if a new file was selected, otherwise keep existing icon
+ const reader = new FileReader();
+ try {
+ reader.readAsArrayBuffer(formValues.icon?.[0]);
+ reader.onerror = () => {
+ this.notifyOperationStatus(null, successMsg, errorMsg);
+ };
+ reader.onloadend = (evt) => {
+ if (evt.target.readyState === FileReader.DONE) {
+ const buf = evt.target.result;
+ const bytes: number[] = [];
+ if (buf instanceof ArrayBuffer) {
+ const arr = new Uint8Array(buf);
+ for (const b of arr) { bytes.push(b); }
+ }
+ updatedLabel.icon = bytes;
+ this.doUpdateLabel(updatedLabel, successMsg, errorMsg);
+ }
+ };
+ } catch {
+ // no new file selected – clear the icon when requested, otherwise keep the existing one
+ updatedLabel.icon = formValues.clearIcon ? [] : 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 +558,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 +593,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 +743,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 @@
-