Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Duplicate data from an existing Item to a new WorskpaceItem, aka clone/copy item #3076

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@
<i class="fa fa-edit"></i><span *ngIf="!small" class="d-none d-sm-inline"> {{"admin.search.item.edit" | translate}}</span>
</a>

<a [ngClass]="{'btn-sm': small}" class="btn btn-secondary edit-link" [routerLink]="[getCloneRoute()]" [title]="'admin.search.item.clone' | translate">
<i class="fa fa-copy"></i><span *ngIf="!small" class="d-none d-sm-inline"> {{"admin.search.item.clone" | translate}}</span>
</a>

<a [ngClass]="{'btn-sm': small}" *ngIf="item && !item.isWithdrawn" class="btn btn-warning t withdraw-link" [routerLink]="[getWithdrawRoute()]" [title]="'admin.search.item.withdraw' | translate">
<i class="fa fa-ban"></i><span *ngIf="!small" class="d-none d-sm-inline"> {{"admin.search.item.withdraw" | translate}}</span>
</a>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -56,6 +57,13 @@ export class ItemAdminSearchResultActionsComponent {
return new URLCombiner(this.getEditRoute(), ITEM_EDIT_MOVE_PATH).toString();
}

/**
* Returns the path to the clone page of this item
*/
getCloneRoute(): string {
return new URLCombiner(this.getEditRoute(), ITEM_EDIT_CLONE_PATH).toString();
}

/**
* Returns the path to the delete page of this item
*/
Expand Down
1 change: 1 addition & 0 deletions src/app/core/data/feature-authorization/feature-id.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export enum FeatureID {
CanEditVersion = 'canEditVersion',
CanDeleteVersion = 'canDeleteVersion',
CanCreateVersion = 'canCreateVersion',
CanClone = CanCreateVersion,
CanViewUsageStatistics = 'canViewUsageStatistics',
CanSendFeedback = 'canSendFeedback',
CanClaimItem = 'canClaimItem',
Expand Down
37 changes: 37 additions & 0 deletions src/app/core/data/item-data.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -280,6 +280,18 @@
);
}

/**
* 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<string> {
return this.halService.getEndpoint('workspaceitems').pipe(
map((endpoint: string) => this.getIDHref(endpoint, itemId)),
map((endpoint: string) => `${endpoint}?owningCollection=${collectionId}`),

Check warning on line 291 in src/app/core/data/item-data.service.ts

View check run for this annotation

Codecov / codecov/patch

src/app/core/data/item-data.service.ts#L289-L291

Added lines #L289 - L291 were not covered by tests
);
}

/**
* Move the item to a different owning collection
* @param itemId
Expand Down Expand Up @@ -313,6 +325,31 @@
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<RemoteData<any>> {
const options: HttpOptions = Object.create({});
let headers = new HttpHeaders();
headers = headers.append('Content-Type', 'text/uri-list');
options.headers = headers;

Check warning on line 337 in src/app/core/data/item-data.service.ts

View check run for this annotation

Codecov / codecov/patch

src/app/core/data/item-data.service.ts#L334-L337

Added lines #L334 - L337 were not covered by tests

const requestId = this.requestService.generateRequestId();
const href$ = this.getCloneItemEndpoint(item.id, collectionId);

Check warning on line 340 in src/app/core/data/item-data.service.ts

View check run for this annotation

Codecov / codecov/patch

src/app/core/data/item-data.service.ts#L339-L340

Added lines #L339 - L340 were not covered by tests

href$.pipe(
find((href: string) => hasValue(href)),

Check warning on line 343 in src/app/core/data/item-data.service.ts

View check run for this annotation

Codecov / codecov/patch

src/app/core/data/item-data.service.ts#L342-L343

Added lines #L342 - L343 were not covered by tests
map((href: string) => {
const request = new PostRequest(requestId, href, item._links.self.href, options);
this.requestService.send(request);

Check warning on line 346 in src/app/core/data/item-data.service.ts

View check run for this annotation

Codecov / codecov/patch

src/app/core/data/item-data.service.ts#L345-L346

Added lines #L345 - L346 were not covered by tests
}),
).subscribe();

return this.rdbService.buildFromRequestUUID(requestId);

Check warning on line 350 in src/app/core/data/item-data.service.ts

View check run for this annotation

Codecov / codecov/patch

src/app/core/data/item-data.service.ts#L350

Added line #L350 was not covered by tests
}

/**
* Import an external source entry into a collection
* @param externalSourceEntry
Expand Down
25 changes: 25 additions & 0 deletions src/app/core/submission/workspaceitem-data.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,31 @@
return this.findByHref(href$, useCachedVersionIfAvailable, reRequestOnStale, ...linksToFollow);
}

/**
* Create a new WorkspaceItem in a specified collection using properties from an existing item
* @param itemHref a source item
* @param collectionId an UUID of a target collection
*/
public cloneToCollection(itemHref: string, collectionId: string): Observable<RemoteData<WorkspaceItem>> {
const options: HttpOptions = Object.create({});
let headers = new HttpHeaders();
headers = headers.append('Content-Type', 'text/uri-list');
options.headers = headers;

Check warning on line 107 in src/app/core/submission/workspaceitem-data.service.ts

View check run for this annotation

Codecov / codecov/patch

src/app/core/submission/workspaceitem-data.service.ts#L104-L107

Added lines #L104 - L107 were not covered by tests

const requestId = this.requestService.generateRequestId();
const href$ = this.halService.getEndpoint(this.linkPath).pipe(map((href) => `${href}?owningCollection=${collectionId}`));

Check warning on line 110 in src/app/core/submission/workspaceitem-data.service.ts

View check run for this annotation

Codecov / codecov/patch

src/app/core/submission/workspaceitem-data.service.ts#L109-L110

Added lines #L109 - L110 were not covered by tests

href$.pipe(
find((href: string) => hasValue(href)),

Check warning on line 113 in src/app/core/submission/workspaceitem-data.service.ts

View check run for this annotation

Codecov / codecov/patch

src/app/core/submission/workspaceitem-data.service.ts#L112-L113

Added lines #L112 - L113 were not covered by tests
map((href: string) => {
const request = new PostRequest(requestId, href, itemHref, options);
this.requestService.send(request);

Check warning on line 116 in src/app/core/submission/workspaceitem-data.service.ts

View check run for this annotation

Codecov / codecov/patch

src/app/core/submission/workspaceitem-data.service.ts#L115-L116

Added lines #L115 - L116 were not covered by tests
}),
).subscribe();

return this.rdbService.buildFromRequestUUID(requestId);

Check warning on line 120 in src/app/core/submission/workspaceitem-data.service.ts

View check run for this annotation

Codecov / codecov/patch

src/app/core/submission/workspaceitem-data.service.ts#L120

Added line #L120 was not covered by tests
}

/**
* Import an external source entry into a collection
* @param externalSourceEntryHref
Expand Down
7 changes: 7 additions & 0 deletions src/app/item-page/edit-item-page/edit-item-page-routes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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';
Expand Down Expand Up @@ -161,6 +163,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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
<div class="container">
<div class="row">
<div class="col-12">
<h1>{{'item.edit.clone.head' | translate: {id: (itemRD$ | async)?.payload?.handle} }}</h1>
<p>{{'item.edit.clone.description' | translate}}</p>
<div class="row">
<div class="col-12">
<div class="card mb-3">
<div class="card-header">{{'dso-selector.placeholder' | translate: { type: 'dso-selector.placeholder.type.collection' | translate } }}</div>
<div class="card-body">
<ds-authorized-collection-selector [types]="COLLECTIONS"
[entityType]="item.getRenderTypes[0]"
[currentDSOId]="selectedCollection ? selectedCollection.id : originalCollection.id"
(onSelect)="selectDso($event)">
</ds-authorized-collection-selector>
</div>
<div></div>
</div>
</div>
</div>
<!--<div class="row">
<div class="col-12">
<p>
<label for="inheritPoliciesCheckbox">
<ng-template #tooltipContent>
{{ 'item.edit.clone.inheritpolicies.tooltip' | translate }}
</ng-template>
<input type="checkbox" name="tc" [(ngModel)]="inheritPolicies" id="inheritPoliciesCheckbox" [ngbTooltip]="tooltipContent"
>
{{'item.edit.clone.inheritpolicies.checkbox' |translate}}
</label>
</p>
<p>
{{'item.edit.clone.inheritpolicies.description' | translate}}
</p>
</div>
</div>-->

<div class="button-row bottom">
<div class="float-right space-children-mr">
<button [routerLink]="[(itemPageRoute$ | async), 'edit']" class="btn btn-outline-secondary">
<i class="fas fa-arrow-left"></i> {{'item.edit.clone.cancel' | translate}}
</button>
<button class="btn btn-primary" [disabled]="!canClone" (click)="cloneToCollection()">
<span *ngIf="!processing">
<i class="fas fa-save"></i> {{'item.edit.clone.save-button' | translate}}
</span>
<span *ngIf="processing">
<i class="fas fa-circle-notch fa-spin"></i> {{'item.edit.clone.processing' | translate}}
</span>
</button>
<button class="btn btn-danger" [disabled]="!canSubmit" (click)="discard()">
<i class="fas fa-times"></i> {{"item.edit.clone.discard-button" | translate}}
</button>
</div>
</div>
</div>
</div>
</div>
175 changes: 175 additions & 0 deletions src/app/item-page/edit-item-page/item-clone/item-clone.component.ts
Original file line number Diff line number Diff line change
@@ -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<RemoteData<Item>>;
originalCollection: Collection;

selectedCollectionName: string;
selectedCollection: Collection;
canSubmit = false;

item: Item;
processing = false;

/**
* Route to the item's page
*/
itemPageRoute$: Observable<string>;

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<RemoteData<Item>>;
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;
}

/**
* Clones the item, saving it 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<any>) => {
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<WorkspaceItem>())
.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;
}
}
Loading
Loading