diff --git a/src/app/admin/admin-search-page/admin-search-results/item-admin-search-result-actions.component.html b/src/app/admin/admin-search-page/admin-search-results/item-admin-search-result-actions.component.html index ba4ab153637..28070afc3f3 100644 --- a/src/app/admin/admin-search-page/admin-search-results/item-admin-search-result-actions.component.html +++ b/src/app/admin/admin-search-page/admin-search-results/item-admin-search-result-actions.component.html @@ -15,6 +15,10 @@ {{"admin.search.item.edit" | translate}} + + {{"admin.search.item.clone" | translate}} + + {{"admin.search.item.withdraw" | translate}} diff --git a/src/app/admin/admin-search-page/admin-search-results/item-admin-search-result-actions.component.ts b/src/app/admin/admin-search-page/admin-search-results/item-admin-search-result-actions.component.ts index 89d51481d7c..f19a125d411 100644 --- a/src/app/admin/admin-search-page/admin-search-results/item-admin-search-result-actions.component.ts +++ b/src/app/admin/admin-search-page/admin-search-results/item-admin-search-result-actions.component.ts @@ -12,6 +12,7 @@ import { TranslateModule } from '@ngx-translate/core'; import { Item } from '../../../core/shared/item.model'; import { URLCombiner } from '../../../core/url-combiner/url-combiner'; import { + ITEM_EDIT_CLONE_PATH, ITEM_EDIT_DELETE_PATH, ITEM_EDIT_MOVE_PATH, ITEM_EDIT_PRIVATE_PATH, @@ -56,6 +57,9 @@ export class ItemAdminSearchResultActionsComponent { return new URLCombiner(this.getEditRoute(), ITEM_EDIT_MOVE_PATH).toString(); } + getCloneRoute(): string { + return new URLCombiner(this.getEditRoute(), ITEM_EDIT_CLONE_PATH).toString(); + } /** * Returns the path to the delete page of this item */ diff --git a/src/app/core/data/feature-authorization/feature-id.ts b/src/app/core/data/feature-authorization/feature-id.ts index 3d5803b018b..5004dd8bc15 100644 --- a/src/app/core/data/feature-authorization/feature-id.ts +++ b/src/app/core/data/feature-authorization/feature-id.ts @@ -26,6 +26,7 @@ export enum FeatureID { CanEditVersion = 'canEditVersion', CanDeleteVersion = 'canDeleteVersion', CanCreateVersion = 'canCreateVersion', + CanClone = CanCreateVersion, CanViewUsageStatistics = 'canViewUsageStatistics', CanSendFeedback = 'canSendFeedback', CanClaimItem = 'canClaimItem', diff --git a/src/app/core/data/item-data.service.ts b/src/app/core/data/item-data.service.ts index e1f789b5da7..a2d5b7ad1cc 100644 --- a/src/app/core/data/item-data.service.ts +++ b/src/app/core/data/item-data.service.ts @@ -280,6 +280,18 @@ export abstract class BaseItemDataService extends IdentifiableDataService ); } + /** + * Get the endpoint to clone the item + * @param itemId UUID of the source item + * @param collectionId UUID of the target collection + */ + public getCloneItemEndpoint(itemId: string, collectionId: string): Observable { + return this.halService.getEndpoint('workspaceitems').pipe( + map((endpoint: string) => this.getIDHref(endpoint, itemId)), + map((endpoint: string) => `${endpoint}?owningCollection=${collectionId}`), + ); + } + /** * Move the item to a different owning collection * @param itemId @@ -313,6 +325,31 @@ export abstract class BaseItemDataService extends IdentifiableDataService return this.rdbService.buildFromRequestUUID(requestId); } + /** + * Create a new item in a specified collection using properties from the existing item + * @param item a source item + * @param collectionId an UUID of a target collection + */ + public clone(item: Item, collectionId: string): Observable> { + const options: HttpOptions = Object.create({}); + let headers = new HttpHeaders(); + headers = headers.append('Content-Type', 'text/uri-list'); + options.headers = headers; + + const requestId = this.requestService.generateRequestId(); + const href$ = this.getCloneItemEndpoint(item.id, collectionId); + + href$.pipe( + find((href: string) => hasValue(href)), + map((href: string) => { + const request = new PostRequest(requestId, href, item._links.self.href, options); + this.requestService.send(request); + }), + ).subscribe(); + + return this.rdbService.buildFromRequestUUID(requestId); + } + /** * Import an external source entry into a collection * @param externalSourceEntry diff --git a/src/app/core/submission/workspaceitem-data.service.ts b/src/app/core/submission/workspaceitem-data.service.ts index 17aafaf6514..033d908a74e 100644 --- a/src/app/core/submission/workspaceitem-data.service.ts +++ b/src/app/core/submission/workspaceitem-data.service.ts @@ -95,6 +95,31 @@ export class WorkspaceitemDataService extends IdentifiableDataService> { + const options: HttpOptions = Object.create({}); + let headers = new HttpHeaders(); + headers = headers.append('Content-Type', 'text/uri-list'); + options.headers = headers; + + const requestId = this.requestService.generateRequestId(); + const href$ = this.halService.getEndpoint(this.linkPath).pipe(map((href) => `${href}?owningCollection=${collectionId}`)); + + href$.pipe( + find((href: string) => hasValue(href)), + map((href: string) => { + const request = new PostRequest(requestId, href, itemHref, options); + this.requestService.send(request); + }), + ).subscribe(); + + return this.rdbService.buildFromRequestUUID(requestId); + } + /** * Import an external source entry into a collection * @param externalSourceEntryHref diff --git a/src/app/item-page/edit-item-page/edit-item-page-routes.ts b/src/app/item-page/edit-item-page/edit-item-page-routes.ts index 1b1e43a883c..599e9acbe9b 100644 --- a/src/app/item-page/edit-item-page/edit-item-page-routes.ts +++ b/src/app/item-page/edit-item-page/edit-item-page-routes.ts @@ -12,6 +12,7 @@ import { resourcePolicyTargetResolver } from '../../shared/resource-policies/res import { EditItemPageComponent } from './edit-item-page.component'; import { ITEM_EDIT_AUTHORIZATIONS_PATH, + ITEM_EDIT_CLONE_PATH, ITEM_EDIT_DELETE_PATH, ITEM_EDIT_MOVE_PATH, ITEM_EDIT_PRIVATE_PATH, @@ -23,6 +24,7 @@ import { import { ItemAccessControlComponent } from './item-access-control/item-access-control.component'; import { ItemAuthorizationsComponent } from './item-authorizations/item-authorizations.component'; import { ItemBitstreamsComponent } from './item-bitstreams/item-bitstreams.component'; +import { ItemCloneComponent } from './item-clone/item-clone.component'; import { ItemCollectionMapperComponent } from './item-collection-mapper/item-collection-mapper.component'; import { ItemCurateComponent } from './item-curate/item-curate.component'; import { ItemDeleteComponent } from './item-delete/item-delete.component'; @@ -46,7 +48,6 @@ import { ItemRelationshipsComponent } from './item-relationships/item-relationsh import { ThemedItemStatusComponent } from './item-status/themed-item-status.component'; import { ItemVersionHistoryComponent } from './item-version-history/item-version-history.component'; import { ItemWithdrawComponent } from './item-withdraw/item-withdraw.component'; - /** * Routing module that handles the routing for the Edit Item page administrator functionality */ @@ -161,6 +162,11 @@ export const ROUTES: Route[] = [ component: ItemMoveComponent, data: { title: 'item.edit.move.title' }, }, + { + path: ITEM_EDIT_CLONE_PATH, + component: ItemCloneComponent, + data: { title: 'item.edit.clone.title' }, + }, { path: ITEM_EDIT_REGISTER_DOI_PATH, component: ItemRegisterDoiComponent, diff --git a/src/app/item-page/edit-item-page/edit-item-page.routing-paths.ts b/src/app/item-page/edit-item-page/edit-item-page.routing-paths.ts index 6b0907dcebf..90c726d569b 100644 --- a/src/app/item-page/edit-item-page/edit-item-page.routing-paths.ts +++ b/src/app/item-page/edit-item-page/edit-item-page.routing-paths.ts @@ -4,5 +4,6 @@ export const ITEM_EDIT_PRIVATE_PATH = 'private'; export const ITEM_EDIT_PUBLIC_PATH = 'public'; export const ITEM_EDIT_DELETE_PATH = 'delete'; export const ITEM_EDIT_MOVE_PATH = 'move'; +export const ITEM_EDIT_CLONE_PATH = 'clone'; export const ITEM_EDIT_AUTHORIZATIONS_PATH = 'authorizations'; export const ITEM_EDIT_REGISTER_DOI_PATH = 'register-doi'; diff --git a/src/app/item-page/edit-item-page/item-clone/item-clone.component.html b/src/app/item-page/edit-item-page/item-clone/item-clone.component.html new file mode 100644 index 00000000000..1e8afaa535d --- /dev/null +++ b/src/app/item-page/edit-item-page/item-clone/item-clone.component.html @@ -0,0 +1,59 @@ + + + + {{'item.edit.clone.head' | translate: {id: (itemRD$ | async)?.payload?.handle} }} + {{'item.edit.clone.description' | translate}} + + + + {{'dso-selector.placeholder' | translate: { type: 'dso-selector.placeholder.type.collection' | translate } }} + + + + + + + + + + + + + + {{'item.edit.clone.cancel' | translate}} + + + + {{'item.edit.clone.save-button' | translate}} + + + {{'item.edit.clone.processing' | translate}} + + + + {{"item.edit.clone.discard-button" | translate}} + + + + + + diff --git a/src/app/item-page/edit-item-page/item-clone/item-clone.component.ts b/src/app/item-page/edit-item-page/item-clone/item-clone.component.ts new file mode 100644 index 00000000000..33fbf4c942a --- /dev/null +++ b/src/app/item-page/edit-item-page/item-clone/item-clone.component.ts @@ -0,0 +1,175 @@ +import { + AsyncPipe, + NgIf, +} from '@angular/common'; +import { + Component, + OnInit, +} from '@angular/core'; +import { + FormsModule, + ReactiveFormsModule, +} from '@angular/forms'; +import { + ActivatedRoute, + Router, + RouterLink, +} from '@angular/router'; +import { NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap'; +import { + TranslateModule, + TranslateService, +} from '@ngx-translate/core'; +import { Observable } from 'rxjs'; +import { + map, + switchMap, +} from 'rxjs/operators'; + +import { DSONameService } from '../../../core/breadcrumbs/dso-name.service'; +import { ItemDataService } from '../../../core/data/item-data.service'; +import { RemoteData } from '../../../core/data/remote-data'; +import { RequestService } from '../../../core/data/request.service'; +import { Collection } from '../../../core/shared/collection.model'; +import { DSpaceObjectType } from '../../../core/shared/dspace-object-type.model'; +import { Item } from '../../../core/shared/item.model'; +import { + getAllSucceededRemoteDataPayload, + getFirstCompletedRemoteData, + getFirstSucceededRemoteData, + getFirstSucceededRemoteDataPayload, + getRemoteDataPayload, +} from '../../../core/shared/operators'; +import { SearchService } from '../../../core/shared/search/search.service'; +import { WorkspaceItem } from '../../../core/submission/models/workspaceitem.model'; +import { WorkspaceitemDataService } from '../../../core/submission/workspaceitem-data.service'; +import { AuthorizedCollectionSelectorComponent } from '../../../shared/dso-selector/dso-selector/authorized-collection-selector/authorized-collection-selector.component'; +import { NotificationsService } from '../../../shared/notifications/notifications.service'; +import { getItemPageRoute } from '../../item-page-routing-paths'; + +@Component({ + selector: 'ds-item-clone', + templateUrl: './item-clone.component.html', + imports: [ + AsyncPipe, + AuthorizedCollectionSelectorComponent, + NgIf, + ReactiveFormsModule, + TranslateModule, + FormsModule, + NgbTooltipModule, + RouterLink, + ], + standalone: true, +}) +export class ItemCloneComponent implements OnInit { + /** + * TODO: Similarly to {@code ItemMoveComponent}, there is currently no backend support to change the + * owningCollection and inherit policies, hence the code that was commented out + */ + + selectorType = DSpaceObjectType.COLLECTION; + + inheritPolicies = false; + itemRD$: Observable>; + originalCollection: Collection; + + selectedCollectionName: string; + selectedCollection: Collection; + canSubmit = false; + + item: Item; + processing = false; + + /** + * Route to the item's page + */ + itemPageRoute$: Observable; + + COLLECTIONS = [DSpaceObjectType.COLLECTION]; + + constructor(private route: ActivatedRoute, + private router: Router, + private notificationsService: NotificationsService, + private itemDataService: ItemDataService, + private workspaceItemDataService: WorkspaceitemDataService, + private searchService: SearchService, + private translateService: TranslateService, + private requestService: RequestService, + protected dsoNameService: DSONameService, + ) {} + + ngOnInit(): void { + this.itemRD$ = this.route.data.pipe( + map((data) => data.dso), getFirstSucceededRemoteData(), + ) as Observable>; + this.itemPageRoute$ = this.itemRD$.pipe( + getAllSucceededRemoteDataPayload(), + map((item) => getItemPageRoute(item)), + ); + this.itemRD$.subscribe((rd) => { + this.item = rd.payload; + }, + ); + this.itemRD$.pipe( + getFirstSucceededRemoteData(), + getRemoteDataPayload(), + switchMap((item) => item.owningCollection), + getFirstSucceededRemoteData(), + getRemoteDataPayload(), + ).subscribe((collection) => { + this.originalCollection = collection; + }); + } + + /** + * Set the collection name and id based on the selected value + * @param data - obtained from the ds-input-suggestions component + */ + selectDso(data: any): void { + this.selectedCollection = data; + this.selectedCollectionName = this.dsoNameService.getName(data); + this.canSubmit = true; + } + + /** + * @returns {string} the current URL + */ + getCurrentUrl() { + return this.router.url; + } + + /** + * Moves the item to a new collection based on the selected collection + */ + cloneToCollection() { + this.processing = true; + const clone$ = this.workspaceItemDataService.cloneToCollection(this.item._links.self.href, this.selectedCollection.id) + .pipe(getFirstCompletedRemoteData()); + + clone$.subscribe((response: RemoteData) => { + if (response.hasSucceeded) { + this.notificationsService.success(this.translateService.get('item.edit.clone.success')); + } else { + this.notificationsService.error(this.translateService.get('item.edit.clone.error')); + } + }); + + clone$.pipe( + getFirstSucceededRemoteDataPayload()) + .subscribe((wsi) => { + this.processing = false; + this.router.navigate(['/workspaceitems', wsi.id, 'edit']); + }); + + } + + discard(): void { + this.selectedCollection = null; + this.canSubmit = false; + } + + get canClone(): boolean { + return this.canSubmit; + } +} diff --git a/src/app/item-page/edit-item-page/item-status/item-status.component.ts b/src/app/item-page/edit-item-page/item-status/item-status.component.ts index bbd7b99a977..7fd346c8645 100644 --- a/src/app/item-page/edit-item-page/item-status/item-status.component.ts +++ b/src/app/item-page/edit-item-page/item-status/item-status.component.ts @@ -188,6 +188,7 @@ export class ItemStatusComponent implements OnInit { ? new ItemOperation('private', `${currentUrl}/private`, FeatureID.CanMakePrivate, true) : new ItemOperation('public', `${currentUrl}/public`, FeatureID.CanMakePrivate, true), new ItemOperation('move', `${currentUrl}/move`, FeatureID.CanMove, true), + new ItemOperation('clone', `${currentUrl}/clone`, FeatureID.CanClone, true), new ItemOperation('delete', `${currentUrl}/delete`, FeatureID.CanDelete, true), ]; diff --git a/src/assets/i18n/en.json5 b/src/assets/i18n/en.json5 index bd837c64f63..687ad5f873b 100644 --- a/src/assets/i18n/en.json5 +++ b/src/assets/i18n/en.json5 @@ -758,6 +758,8 @@ "admin.search.item.move": "Move", + "admin.search.item.clone": "Clone", + "admin.search.item.reinstate": "Reinstate", "admin.search.item.withdraw": "Withdraw", @@ -2482,6 +2484,34 @@ "item.edit.move.title": "Move item", + "item.edit.clone.cancel": "Back", + + "item.edit.clone.save-button": "Save", + + "item.edit.clone.discard-button": "Discard", + + "item.edit.clone.description": "Select the collection you wish to clone this item to. To narrow down the list of displayed collections, you can enter a search query in the box.", + + "item.edit.clone.error": "An error occurred when attempting to clone the item", + + "item.edit.clone.head": "Clone item: {{id}}", + + "item.edit.clone.inheritpolicies.checkbox": "Inherit policies", + + "item.edit.clone.inheritpolicies.description": "Inherit the default policies of the destination collection", + + "item.edit.clone.inheritpolicies.tooltip": "Warning: When enabled, the read access policy for the item and any files associated with the item will be replaced by the default read access policy of the collection. This cannot be undone.", + + "item.edit.clone.clone": "Clone", + + "item.edit.clone.processing": "Processing...", + + "item.edit.clone.search.placeholder": "Enter a search query to look for collections", + + "item.edit.clone.success": "The item has been cloned successfully", + + "item.edit.clone.title": "Clone item", + "item.edit.private.cancel": "Cancel", "item.edit.private.confirm": "Make it non-discoverable",
{{'item.edit.clone.description' | translate}}