Merge pull request #2638 from DSpace/backport-2633-to-dspace-7_x

[Port dspace-7_x] Edit-item view: random order of buttons in status tab
This commit is contained in:
Tim Donohue
2023-11-13 16:51:21 -06:00
committed by GitHub
4 changed files with 130 additions and 102 deletions

View File

@@ -28,4 +28,12 @@ export class ItemOperation {
this.disabled = disabled; this.disabled = disabled;
} }
/**
* Set whether this operation is authorized
* @param authorized
*/
setAuthorized(authorized: boolean): void {
this.authorized = authorized;
}
} }

View File

@@ -27,7 +27,7 @@
</div> </div>
<div *ngFor="let operation of (operations$ | async)" class="w-100" [ngClass]="{'pt-3': operation}"> <div *ngFor="let operation of (operations$ | async)" class="w-100" [ngClass]="{'pt-3': operation}">
<ds-item-operation *ngIf="operation" [operation]="operation"></ds-item-operation> <ds-item-operation [operation]="operation"></ds-item-operation>
</div> </div>
</div> </div>

View File

@@ -16,6 +16,7 @@ import { AuthorizationDataService } from '../../../core/data/feature-authorizati
import { IdentifierDataService } from '../../../core/data/identifier-data.service'; import { IdentifierDataService } from '../../../core/data/identifier-data.service';
import { ConfigurationDataService } from '../../../core/data/configuration-data.service'; import { ConfigurationDataService } from '../../../core/data/configuration-data.service';
import { ConfigurationProperty } from '../../../core/shared/configuration-property.model'; import { ConfigurationProperty } from '../../../core/shared/configuration-property.model';
import { OrcidAuthService } from '../../../core/orcid/orcid-auth.service';
let mockIdentifierDataService: IdentifierDataService; let mockIdentifierDataService: IdentifierDataService;
let mockConfigurationDataService: ConfigurationDataService; let mockConfigurationDataService: ConfigurationDataService;
@@ -57,12 +58,18 @@ describe('ItemStatusComponent', () => {
}; };
let authorizationService: AuthorizationDataService; let authorizationService: AuthorizationDataService;
let orcidAuthService: any;
beforeEach(waitForAsync(() => { beforeEach(waitForAsync(() => {
authorizationService = jasmine.createSpyObj('authorizationService', { authorizationService = jasmine.createSpyObj('authorizationService', {
isAuthorized: observableOf(true) isAuthorized: observableOf(true)
}); });
orcidAuthService = jasmine.createSpyObj('OrcidAuthService', {
onlyAdminCanDisconnectProfileFromOrcid: observableOf ( true ),
isLinkedToOrcid: true
});
TestBed.configureTestingModule({ TestBed.configureTestingModule({
imports: [CommonModule, RouterTestingModule.withRoutes([]), TranslateModule.forRoot(), NgbModule], imports: [CommonModule, RouterTestingModule.withRoutes([]), TranslateModule.forRoot(), NgbModule],
declarations: [ItemStatusComponent], declarations: [ItemStatusComponent],
@@ -71,7 +78,8 @@ describe('ItemStatusComponent', () => {
{ provide: HostWindowService, useValue: new HostWindowServiceStub(0) }, { provide: HostWindowService, useValue: new HostWindowServiceStub(0) },
{ provide: AuthorizationDataService, useValue: authorizationService }, { provide: AuthorizationDataService, useValue: authorizationService },
{ provide: IdentifierDataService, useValue: mockIdentifierDataService }, { provide: IdentifierDataService, useValue: mockIdentifierDataService },
{ provide: ConfigurationDataService, useValue: mockConfigurationDataService } { provide: ConfigurationDataService, useValue: mockConfigurationDataService },
{ provide: OrcidAuthService, useValue: orcidAuthService },
], schemas: [CUSTOM_ELEMENTS_SCHEMA] ], schemas: [CUSTOM_ELEMENTS_SCHEMA]
}).compileComponents(); }).compileComponents();
})); }));

View File

@@ -3,21 +3,20 @@ import { fadeIn, fadeInOut } from '../../../shared/animations/fade';
import { Item } from '../../../core/shared/item.model'; import { Item } from '../../../core/shared/item.model';
import { ActivatedRoute } from '@angular/router'; import { ActivatedRoute } from '@angular/router';
import { ItemOperation } from '../item-operation/itemOperation.model'; import { ItemOperation } from '../item-operation/itemOperation.model';
import { distinctUntilChanged, map, mergeMap, switchMap, toArray } from 'rxjs/operators'; import { concatMap, distinctUntilChanged, first, map, mergeMap, switchMap, toArray } from 'rxjs/operators';
import { BehaviorSubject, Observable, Subscription } from 'rxjs'; import { BehaviorSubject, combineLatest, Observable, of, Subscription } from 'rxjs';
import { RemoteData } from '../../../core/data/remote-data'; import { RemoteData } from '../../../core/data/remote-data';
import { getItemEditRoute, getItemPageRoute } from '../../item-page-routing-paths'; import { getItemEditRoute, getItemPageRoute } from '../../item-page-routing-paths';
import { AuthorizationDataService } from '../../../core/data/feature-authorization/authorization-data.service'; import { AuthorizationDataService } from '../../../core/data/feature-authorization/authorization-data.service';
import { FeatureID } from '../../../core/data/feature-authorization/feature-id'; import { FeatureID } from '../../../core/data/feature-authorization/feature-id';
import { hasValue, isNotEmpty } from '../../../shared/empty.util'; import { hasValue } from '../../../shared/empty.util';
import { import { getAllSucceededRemoteDataPayload, getFirstCompletedRemoteData, } from '../../../core/shared/operators';
getAllSucceededRemoteDataPayload, getFirstCompletedRemoteData, getFirstSucceededRemoteData, getRemoteDataPayload,
} from '../../../core/shared/operators';
import { IdentifierDataService } from '../../../core/data/identifier-data.service'; import { IdentifierDataService } from '../../../core/data/identifier-data.service';
import { Identifier } from '../../../shared/object-list/identifier-data/identifier.model'; import { Identifier } from '../../../shared/object-list/identifier-data/identifier.model';
import { ConfigurationProperty } from '../../../core/shared/configuration-property.model'; import { ConfigurationProperty } from '../../../core/shared/configuration-property.model';
import { ConfigurationDataService } from '../../../core/data/configuration-data.service'; import { ConfigurationDataService } from '../../../core/data/configuration-data.service';
import { IdentifierData } from '../../../shared/object-list/identifier-data/identifier-data.model'; import { IdentifierData } from '../../../shared/object-list/identifier-data/identifier-data.model';
import { OrcidAuthService } from '../../../core/orcid/orcid-auth.service';
@Component({ @Component({
selector: 'ds-item-status', selector: 'ds-item-status',
@@ -73,6 +72,7 @@ export class ItemStatusComponent implements OnInit {
private authorizationService: AuthorizationDataService, private authorizationService: AuthorizationDataService,
private identifierDataService: IdentifierDataService, private identifierDataService: IdentifierDataService,
private configurationService: ConfigurationDataService, private configurationService: ConfigurationDataService,
private orcidAuthService: OrcidAuthService
) { ) {
} }
@@ -82,14 +82,16 @@ export class ItemStatusComponent implements OnInit {
ngOnInit(): void { ngOnInit(): void {
this.itemRD$ = this.route.parent.data.pipe(map((data) => data.dso)); this.itemRD$ = this.route.parent.data.pipe(map((data) => data.dso));
this.itemRD$.pipe( this.itemRD$.pipe(
first(),
map((data: RemoteData<Item>) => data.payload) map((data: RemoteData<Item>) => data.payload)
).subscribe((item: Item) => { ).pipe(
this.statusData = Object.assign({ switchMap((item: Item) => {
id: item.id, this.statusData = Object.assign({
handle: item.handle, id: item.id,
lastModified: item.lastModified handle: item.handle,
}); lastModified: item.lastModified
this.statusDataKeys = Object.keys(this.statusData); });
this.statusDataKeys = Object.keys(this.statusData);
// Observable for item identifiers (retrieved from embedded link) // Observable for item identifiers (retrieved from embedded link)
this.identifiers$ = this.identifierDataService.getIdentifierDataFor(item).pipe( this.identifiers$ = this.identifierDataService.getIdentifierDataFor(item).pipe(
@@ -105,99 +107,108 @@ export class ItemStatusComponent implements OnInit {
// Observable for configuration determining whether the Register DOI feature is enabled // Observable for configuration determining whether the Register DOI feature is enabled
let registerConfigEnabled$: Observable<boolean> = this.configurationService.findByPropertyName('identifiers.item-status.register-doi').pipe( let registerConfigEnabled$: Observable<boolean> = this.configurationService.findByPropertyName('identifiers.item-status.register-doi').pipe(
getFirstCompletedRemoteData(), getFirstCompletedRemoteData(),
map((rd: RemoteData<ConfigurationProperty>) => { map((enabledRD: RemoteData<ConfigurationProperty>) => enabledRD.hasSucceeded && enabledRD.payload.values.length > 0)
// If the config property is exposed via rest and has a value set, return it
if (rd.hasSucceeded && hasValue(rd.payload) && isNotEmpty(rd.payload.values)) {
return rd.payload.values[0] === 'true';
}
// Otherwise, return false
return false;
})
); );
/* /**
Construct a base list of operations. * Construct a base list of operations.
The key is used to build messages * The key is used to build messages
i18n example: 'item.edit.tabs.status.buttons.<key>.label' * i18n example: 'item.edit.tabs.status.buttons.<key>.label'
The value is supposed to be a href for the button * The value is supposed to be a href for the button
*/
const operations: ItemOperation[] = [];
operations.push(new ItemOperation('authorizations', this.getCurrentUrl(item) + '/authorizations', FeatureID.CanManagePolicies, true));
operations.push(new ItemOperation('mappedCollections', this.getCurrentUrl(item) + '/mapper', FeatureID.CanManageMappings, true));
if (item.isWithdrawn) {
operations.push(new ItemOperation('reinstate', this.getCurrentUrl(item) + '/reinstate', FeatureID.ReinstateItem, true));
} else {
operations.push(new ItemOperation('withdraw', this.getCurrentUrl(item) + '/withdraw', FeatureID.WithdrawItem, true));
}
if (item.isDiscoverable) {
operations.push(new ItemOperation('private', this.getCurrentUrl(item) + '/private', FeatureID.CanMakePrivate, true));
} else {
operations.push(new ItemOperation('public', this.getCurrentUrl(item) + '/public', FeatureID.CanMakePrivate, true));
}
operations.push(new ItemOperation('delete', this.getCurrentUrl(item) + '/delete', FeatureID.CanDelete, true));
operations.push(new ItemOperation('move', this.getCurrentUrl(item) + '/move', FeatureID.CanMove, true));
this.operations$.next(operations);
/*
When the identifier data stream changes, determine whether the register DOI button should be shown or not.
This is based on whether the DOI is in the right state (minted or pending, not already queued for registration
or registered) and whether the configuration property identifiers.item-status.register-doi is true
*/ */
this.identifierDataService.getIdentifierDataFor(item).pipe( const currentUrl = this.getCurrentUrl(item);
getFirstSucceededRemoteData(), const inititalOperations: ItemOperation[] = [
getRemoteDataPayload(), new ItemOperation('authorizations', `${currentUrl}/authorizations`, FeatureID.CanManagePolicies, true),
mergeMap((data: IdentifierData) => { new ItemOperation('mappedCollections', `${currentUrl}/mapper`, FeatureID.CanManageMappings, true),
let identifiers = data.identifiers; item.isWithdrawn
let no_doi = true; ? new ItemOperation('reinstate', `${currentUrl}/reinstate`, FeatureID.ReinstateItem, true)
let pending = false; : new ItemOperation('withdraw', `${currentUrl}/withdraw`, FeatureID.WithdrawItem, true),
if (identifiers !== undefined && identifiers !== null) { item.isDiscoverable
identifiers.forEach((identifier: Identifier) => { ? new ItemOperation('private', `${currentUrl}/private`, FeatureID.CanMakePrivate, true)
if (hasValue(identifier) && identifier.identifierType === 'doi') { : new ItemOperation('public', `${currentUrl}/public`, FeatureID.CanMakePrivate, true),
// The item has some kind of DOI new ItemOperation('move', `${currentUrl}/move`, FeatureID.CanMove, true),
no_doi = false; new ItemOperation('delete', `${currentUrl}/delete`, FeatureID.CanDelete, true)
if (identifier.identifierStatus === 'PENDING' || identifier.identifierStatus === 'MINTED' ];
|| identifier.identifierStatus == null) {
// The item's DOI is pending, minted or null. this.operations$.next(inititalOperations);
// It isn't registered, reserved, queued for registration or reservation or update, deleted
// or queued for deletion. /**
pending = true; * When the identifier data stream changes, determine whether the register DOI button should be shown or not.
} * This is based on whether the DOI is in the right state (minted or pending, not already queued for registration
* or registered) and whether the configuration property identifiers.item-status.register-doi is true
*/
const ops$ = this.identifierDataService.getIdentifierDataFor(item).pipe(
getFirstCompletedRemoteData(),
mergeMap((dataRD: RemoteData<IdentifierData>) => {
if (dataRD.hasSucceeded) {
let identifiers = dataRD.payload.identifiers;
let no_doi = true;
let pending = false;
if (identifiers !== undefined && identifiers !== null) {
identifiers.forEach((identifier: Identifier) => {
if (hasValue(identifier) && identifier.identifierType === 'doi') {
// The item has some kind of DOI
no_doi = false;
if (['PENDING', 'MINTED', null].includes(identifier.identifierStatus)) {
// The item's DOI is pending, minted or null.
// It isn't registered, reserved, queued for registration or reservation or update, deleted
// or queued for deletion.
pending = true;
}
}
});
} }
}); // If there is no DOI, or a pending/minted/null DOI, and the config is enabled, return true
} return registerConfigEnabled$.pipe(
// If there is no DOI, or a pending/minted/null DOI, and the config is enabled, return true map((enabled: boolean) => {
return registerConfigEnabled$.pipe( return enabled && (pending || no_doi);
map((enabled: boolean) => { }
return enabled && (pending || no_doi); ));
} else {
return of(false);
}
}),
// Switch map pushes the register DOI operation onto a copy of the base array then returns to the pipe
switchMap((showDoi: boolean) => {
const ops = [...inititalOperations];
if (showDoi) {
const op = new ItemOperation('register-doi', `${currentUrl}/register-doi`, FeatureID.CanRegisterDOI, true);
ops.splice(ops.length - 1, 0, op); // Add item before last
}
return inititalOperations;
}),
concatMap((op: ItemOperation) => {
if (hasValue(op.featureID)) {
return this.authorizationService.isAuthorized(op.featureID, item.self).pipe(
distinctUntilChanged(),
map((authorized) => {
op.setDisabled(!authorized);
op.setAuthorized(authorized);
return op;
})
);
} }
));
}),
// Switch map pushes the register DOI operation onto a copy of the base array then returns to the pipe
switchMap((showDoi: boolean) => {
let ops = [...operations];
if (showDoi) {
ops.push(new ItemOperation('register-doi', this.getCurrentUrl(item) + '/register-doi', FeatureID.CanRegisterDOI, true));
}
return ops;
}),
// Merge map checks and transforms each operation in the array based on whether it is authorized or not (disabled)
mergeMap((op: ItemOperation) => {
if (hasValue(op.featureID)) {
return this.authorizationService.isAuthorized(op.featureID, item.self).pipe(
distinctUntilChanged(),
map((authorized) => new ItemOperation(op.operationKey, op.operationUrl, op.featureID, !authorized, authorized))
);
} else {
return [op]; return [op];
} }),
}), toArray()
// Wait for all operations to be emitted and return as an array );
toArray(),
).subscribe((data) => { let orcidOps$ = of([]);
// Update the operations$ subject that draws the administrative buttons on the status page if (this.orcidAuthService.isLinkedToOrcid(item)) {
this.operations$.next(data); orcidOps$ = this.orcidAuthService.onlyAdminCanDisconnectProfileFromOrcid().pipe(
}); map((canDisconnect) => {
}); if (canDisconnect) {
return [new ItemOperation('unlinkOrcid', `${currentUrl}/unlink-orcid`)];
}
return [];
})
);
}
return combineLatest([ops$, orcidOps$]);
}),
map(([ops, orcidOps]: [ItemOperation[], ItemOperation[]]) => [...ops, ...orcidOps])
).subscribe((ops) => this.operations$.next(ops));
this.itemPageRoute$ = this.itemRD$.pipe( this.itemPageRoute$ = this.itemRD$.pipe(
getAllSucceededRemoteDataPayload(), getAllSucceededRemoteDataPayload(),
@@ -206,6 +217,7 @@ export class ItemStatusComponent implements OnInit {
} }
/** /**
* Get the current url without query params * Get the current url without query params
* @returns {string} url * @returns {string} url