Skip to content

Commit 1e195a4

Browse files
Port #1278 to dtq-dev-9-base: UFAL/Add New version button for archived submission items (#1278) (#1503)
/mydspace lists an archived submission with a single action, View. To start a new version of it the submitter had to navigate to the item page and find the versioning menu. This adds a Create new version button next to View, gated on the same authorization feature and backed by the same vanilla services the item page uses: - the button renders only when the user holds FeatureID.CanCreateVersion on the item (canCreateVersion$); - it is disabled, with a tooltip and a matching aria-label, while a draft already exists in the version history (disableNewVersion$ / newVersionTooltip$, both from DsoVersioningModalService.isNewVersionButtonDisabled and getVersioningTooltipMessage, keys item.page.version.hasDraft / item.page.version.create - both already present in en.json5 and cs.json5); - clicking it opens the existing versioning modal. initVersioningControls() seeds the three observables with safe defaults and returns early unless the item has both a self link and a version link, so a partially loaded object cannot fire an authorization request against undefined. v9 notes (this is a translate, not a transplant): - ItemActionsComponent is standalone here, so imports[] gains AsyncPipe (the template is all `| async`) and BtnDisabledDirective (the source already uses [dsBtnDisabled]). Missing either is the wiring-dropped failure mode - the template would silently stop resolving the pipe or the directive. - *ngIf -> @if; Bootstrap 4 ml-1 -> Bootstrap 5 ms-1 (9-base has 0 ml-1 in src/**/*.html). - rxjs `of as observableOf` -> `of`; the spec is a standalone TestBed, so the new providers go alongside the existing ones and no declarations are needed. - DEVIATION: the source pipes `shareReplay(1)`. That form is a lint error on this branch - @smarttools/rxjs/no-sharereplay, "shareReplay is forbidden unless a config argument is passed". It is written here as the exactly equivalent `shareReplay({ bufferSize: 1, refCount: false })`, which is also the idiom the rest of this branch uses (access-control-form-container, log-in-password). Spec: 1 test -> 6. The five new ones cover shown-when-authorized, hidden-when-not, disabled state, the tooltip key coming from getVersioningTooltipMessage, and the modal opening on click. Card PB-04 (tranche T3). Source: f46cb9c (dtq-dev PR #1278) Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 7624fb1 commit 1e195a4

3 files changed

Lines changed: 171 additions & 1 deletion

File tree

src/app/shared/mydspace-actions/item/item-actions.component.html

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,3 +3,13 @@
33
[routerLink]="[itemPageRoute]">
44
<i class="fa fa-info-circle"></i> {{"submission.workflow.generic.view" | translate}}
55
</button>
6+
7+
@if (canCreateVersion$ | async) {
8+
<button class="btn btn-outline-primary mt-1 mb-3 ms-1"
9+
[dsBtnDisabled]="disableNewVersion$ | async"
10+
[ngbTooltip]="(newVersionTooltip$ | async) | translate"
11+
[attr.aria-label]="(newVersionTooltip$ | async) | translate"
12+
(click)="openCreateVersionModal()">
13+
<i class="fas fa-code-branch"></i> {{ 'item.page.version.create' | translate }}
14+
</button>
15+
}

src/app/shared/mydspace-actions/item/item-actions.component.spec.ts

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import {
88
TestBed,
99
waitForAsync,
1010
} from '@angular/core/testing';
11+
import { By } from '@angular/platform-browser';
1112
import {
1213
Router,
1314
RouterLink,
@@ -18,10 +19,12 @@ import {
1819
} from '@ngx-translate/core';
1920
import { of } from 'rxjs';
2021

22+
import { AuthorizationDataService } from '../../../core/data/feature-authorization/authorization-data.service';
2123
import { ItemDataService } from '../../../core/data/item-data.service';
2224
import { RequestService } from '../../../core/data/request.service';
2325
import { Item } from '../../../core/shared/item.model';
2426
import { SearchService } from '../../../core/shared/search/search.service';
27+
import { DsoVersioningModalService } from '../../dso-page/dso-versioning-modal-service/dso-versioning-modal.service';
2528
import { getMockRequestService } from '../../mocks/request.service.mock';
2629
import { getMockSearchService } from '../../mocks/search-service.mock';
2730
import { TranslateLoaderMock } from '../../mocks/translate-loader.mock';
@@ -37,7 +40,25 @@ let mockObject: Item;
3740

3841
const mockDataService = {};
3942

43+
const authorizationService = jasmine.createSpyObj('authorizationService', {
44+
isAuthorized: of(true),
45+
});
46+
47+
const dsoVersioningModalService = jasmine.createSpyObj('dsoVersioningModalService', {
48+
isNewVersionButtonDisabled: of(false),
49+
getVersioningTooltipMessage: of('item.page.version.create'),
50+
openCreateVersionModal: undefined,
51+
});
52+
4053
mockObject = Object.assign(new Item(), {
54+
_links: {
55+
self: {
56+
href: 'https://rest.test/server/api/core/items/item-id',
57+
},
58+
version: {
59+
href: 'https://rest.test/server/api/core/versions/1',
60+
},
61+
},
4162
bundles: of({}),
4263
metadata: {
4364
'dc.title': [
@@ -90,6 +111,8 @@ describe('ItemActionsComponent', () => {
90111
{ provide: NotificationsService, useValue: new NotificationsServiceStub() },
91112
{ provide: SearchService, useValue: searchService },
92113
{ provide: RequestService, useValue: requestServce },
114+
{ provide: AuthorizationDataService, useValue: authorizationService },
115+
{ provide: DsoVersioningModalService, useValue: dsoVersioningModalService },
93116
],
94117
schemas: [NO_ERRORS_SCHEMA],
95118
}).overrideComponent(ItemActionsComponent, {
@@ -99,6 +122,14 @@ describe('ItemActionsComponent', () => {
99122
}));
100123

101124
beforeEach(() => {
125+
authorizationService.isAuthorized.calls.reset();
126+
authorizationService.isAuthorized.and.returnValue(of(true));
127+
dsoVersioningModalService.isNewVersionButtonDisabled.calls.reset();
128+
dsoVersioningModalService.isNewVersionButtonDisabled.and.returnValue(of(false));
129+
dsoVersioningModalService.getVersioningTooltipMessage.calls.reset();
130+
dsoVersioningModalService.getVersioningTooltipMessage.and.returnValue(of('item.page.version.create'));
131+
dsoVersioningModalService.openCreateVersionModal.calls.reset();
132+
102133
fixture = TestBed.createComponent(ItemActionsComponent);
103134
component = fixture.componentInstance;
104135
component.object = mockObject;
@@ -117,4 +148,70 @@ describe('ItemActionsComponent', () => {
117148
expect(component.object).toEqual(mockObject);
118149
});
119150

151+
it('should show the New version button when version creation is authorized', () => {
152+
fixture.detectChanges();
153+
154+
const newVersionButton = fixture.debugElement.query(By.css('button.btn-outline-primary'));
155+
156+
expect(newVersionButton).toBeTruthy();
157+
});
158+
159+
it('should hide the New version button when version creation is not authorized', () => {
160+
authorizationService.isAuthorized.and.returnValue(of(false));
161+
162+
fixture = TestBed.createComponent(ItemActionsComponent);
163+
component = fixture.componentInstance;
164+
component.object = mockObject;
165+
fixture.detectChanges();
166+
167+
const newVersionButton = fixture.debugElement.query(By.css('button.btn-outline-primary'));
168+
169+
expect(newVersionButton).toBeNull();
170+
});
171+
172+
it('should mark the New version button as disabled when version creation is disabled', () => {
173+
dsoVersioningModalService.isNewVersionButtonDisabled.and.returnValue(of(true));
174+
175+
fixture = TestBed.createComponent(ItemActionsComponent);
176+
component = fixture.componentInstance;
177+
component.object = mockObject;
178+
fixture.detectChanges();
179+
180+
let isDisabled: boolean;
181+
component.disableNewVersion$.subscribe((value) => {
182+
isDisabled = value;
183+
});
184+
185+
expect(isDisabled).toBeTrue();
186+
});
187+
188+
it('should use getVersioningTooltipMessage to derive tooltip key', () => {
189+
dsoVersioningModalService.isNewVersionButtonDisabled.and.returnValue(of(true));
190+
dsoVersioningModalService.getVersioningTooltipMessage.and.returnValue(of('item.page.version.hasDraft'));
191+
192+
fixture = TestBed.createComponent(ItemActionsComponent);
193+
component = fixture.componentInstance;
194+
component.object = mockObject;
195+
fixture.detectChanges();
196+
197+
let tooltipKey: string;
198+
component.newVersionTooltip$.subscribe((value) => {
199+
tooltipKey = value;
200+
});
201+
202+
expect(tooltipKey).toBe('item.page.version.hasDraft');
203+
expect(dsoVersioningModalService.getVersioningTooltipMessage)
204+
.toHaveBeenCalledWith(mockObject, 'item.page.version.hasDraft', 'item.page.version.create');
205+
});
206+
207+
it('should open the create version modal when the New version button is clicked', () => {
208+
fixture.detectChanges();
209+
210+
const newVersionButton = fixture.debugElement.query(By.css('button.btn-outline-primary'));
211+
212+
newVersionButton.triggerEventHandler('click');
213+
214+
expect(dsoVersioningModalService.openCreateVersionModal).toHaveBeenCalledWith(mockObject);
215+
});
216+
120217
});

src/app/shared/mydspace-actions/item/item-actions.component.ts

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { AsyncPipe } from '@angular/common';
12
import {
23
Component,
34
Injector,
@@ -13,12 +14,22 @@ import {
1314
TranslateModule,
1415
TranslateService,
1516
} from '@ngx-translate/core';
17+
import {
18+
Observable,
19+
of,
20+
} from 'rxjs';
21+
import { shareReplay } from 'rxjs/operators';
1622

23+
import { AuthorizationDataService } from '../../../core/data/feature-authorization/authorization-data.service';
24+
import { FeatureID } from '../../../core/data/feature-authorization/feature-id';
1725
import { ItemDataService } from '../../../core/data/item-data.service';
1826
import { RequestService } from '../../../core/data/request.service';
1927
import { Item } from '../../../core/shared/item.model';
2028
import { SearchService } from '../../../core/shared/search/search.service';
2129
import { getItemPageRoute } from '../../../item-page/item-page-routing-paths';
30+
import { BtnDisabledDirective } from '../../btn-disabled.directive';
31+
import { DsoVersioningModalService } from '../../dso-page/dso-versioning-modal-service/dso-versioning-modal.service';
32+
import { hasValue } from '../../empty.util';
2233
import { NotificationsService } from '../../notifications/notifications.service';
2334
import { MyDSpaceActionsComponent } from '../mydspace-actions';
2435

@@ -30,6 +41,8 @@ import { MyDSpaceActionsComponent } from '../mydspace-actions';
3041
styleUrls: ['./item-actions.component.scss'],
3142
templateUrl: './item-actions.component.html',
3243
imports: [
44+
AsyncPipe,
45+
BtnDisabledDirective,
3346
NgbTooltip,
3447
RouterLink,
3548
TranslateModule,
@@ -48,6 +61,21 @@ export class ItemActionsComponent extends MyDSpaceActionsComponent<Item, ItemDat
4861
*/
4962
itemPageRoute: string;
5063

64+
/**
65+
* Whether the current user can create a new version for this item.
66+
*/
67+
canCreateVersion$: Observable<boolean>;
68+
69+
/**
70+
* Whether the New version button should be disabled.
71+
*/
72+
disableNewVersion$: Observable<boolean>;
73+
74+
/**
75+
* Tooltip key for the New version button.
76+
*/
77+
newVersionTooltip$: Observable<string>;
78+
5179
/**
5280
* Initialize instance variables
5381
*
@@ -63,12 +91,15 @@ export class ItemActionsComponent extends MyDSpaceActionsComponent<Item, ItemDat
6391
protected notificationsService: NotificationsService,
6492
protected translate: TranslateService,
6593
protected searchService: SearchService,
66-
protected requestService: RequestService) {
94+
protected requestService: RequestService,
95+
protected authorizationService: AuthorizationDataService,
96+
protected dsoVersioningModalService: DsoVersioningModalService) {
6797
super(Item.type, injector, router, notificationsService, translate, searchService, requestService);
6898
}
6999

70100
ngOnInit(): void {
71101
this.initPageRoute();
102+
this.initVersioningControls();
72103
}
73104

74105
/**
@@ -79,6 +110,7 @@ export class ItemActionsComponent extends MyDSpaceActionsComponent<Item, ItemDat
79110
initObjects(object: Item) {
80111
this.object = object;
81112
this.initPageRoute();
113+
this.initVersioningControls();
82114
}
83115

84116
/**
@@ -88,4 +120,35 @@ export class ItemActionsComponent extends MyDSpaceActionsComponent<Item, ItemDat
88120
this.itemPageRoute = getItemPageRoute(this.object);
89121
}
90122

123+
/**
124+
* Initialize authorization and button state for version creation.
125+
*/
126+
initVersioningControls(): void {
127+
this.canCreateVersion$ = of(false);
128+
this.disableNewVersion$ = of(false);
129+
this.newVersionTooltip$ = of('item.page.version.create');
130+
131+
if (!hasValue(this.object?.self) || !hasValue(this.object?._links?.version?.href)) {
132+
return;
133+
}
134+
135+
this.canCreateVersion$ = this.authorizationService.isAuthorized(
136+
FeatureID.CanCreateVersion,
137+
this.object.self,
138+
);
139+
this.disableNewVersion$ = this.dsoVersioningModalService.isNewVersionButtonDisabled(this.object).pipe(shareReplay({ bufferSize: 1, refCount: false }));
140+
this.newVersionTooltip$ = this.dsoVersioningModalService.getVersioningTooltipMessage(
141+
this.object,
142+
'item.page.version.hasDraft',
143+
'item.page.version.create',
144+
);
145+
}
146+
147+
/**
148+
* Open the existing Create version modal for the current item.
149+
*/
150+
openCreateVersionModal(): void {
151+
this.dsoVersioningModalService.openCreateVersionModal(this.object);
152+
}
153+
91154
}

0 commit comments

Comments
 (0)