diff --git a/projects/aca-content/assets/i18n/en.json b/projects/aca-content/assets/i18n/en.json index 611606c98..e1ce84a11 100644 --- a/projects/aca-content/assets/i18n/en.json +++ b/projects/aca-content/assets/i18n/en.json @@ -743,5 +743,21 @@ "TITLE": "AI Agents", "DISCOVERY": "Discovery" } + }, + "CHECKOUT": { + "ERRORS": { + "CHECKOUT": { + "400": "This node cannot be checked out. It must be a content (cm:content) node.", + "403": "You do not have permission to check out this node.", + "409": "This node is already checked out or locked by another user." + }, + "CANCEL_CHECKOUT": { + "400": "Checkout cannot be cancelled. The node is not a content node or is not currently checked out.", + "403": "You do not have permission to cancel the checkout for this node.", + "409": "Failed to cancel checkout. Please try again or contact your IT team." + }, + "404": "Node not found.", + "UNKNOWN": "An unexpected error occurred. Please try again or contact your IT team." + } } } diff --git a/projects/aca-content/src/lib/services/content-management.service.spec.ts b/projects/aca-content/src/lib/services/content-management.service.spec.ts index 765c32ae0..ab68dc6d2 100644 --- a/projects/aca-content/src/lib/services/content-management.service.spec.ts +++ b/projects/aca-content/src/lib/services/content-management.service.spec.ts @@ -2410,4 +2410,91 @@ describe('ContentManagementService', () => { expect(showErrorSpy).toHaveBeenCalledWith('APP.MESSAGES.ERRORS.GENERIC'); }); }); + + describe('Checkout operations', () => { + const nodeId = 'fake-node-id'; + const makeApiError = (statusCode: number): Error => new Error(JSON.stringify({ error: { statusCode } })); + + describe('checkout()', () => { + it('should return the working copy NodeEntry on success', () => { + const fakeEntry = { entry: { id: nodeId } } as NodeEntry; + spyOn(nodesApiService, 'checkoutNode').and.returnValue(of(fakeEntry)); + contentManagementService.checkout(nodeId).subscribe((result) => { + expect(nodesApiService.checkoutNode).toHaveBeenCalledWith(nodeId); + expect(result).toEqual(fakeEntry); + }); + }); + + it('should show 400 error notification', () => { + spyOn(nodesApiService, 'checkoutNode').and.returnValue(throwError(() => makeApiError(400))); + contentManagementService.checkout(nodeId).subscribe({ next: () => {} }); + expect(showErrorSpy).toHaveBeenCalledWith('CHECKOUT.ERRORS.CHECKOUT.400'); + }); + + it('should show 403 error notification', () => { + spyOn(nodesApiService, 'checkoutNode').and.returnValue(throwError(() => makeApiError(403))); + contentManagementService.checkout(nodeId).subscribe({ next: () => {} }); + expect(showErrorSpy).toHaveBeenCalledWith('CHECKOUT.ERRORS.CHECKOUT.403'); + }); + + it('should show 404 error notification', () => { + spyOn(nodesApiService, 'checkoutNode').and.returnValue(throwError(() => makeApiError(404))); + contentManagementService.checkout(nodeId).subscribe({ next: () => {} }); + expect(showErrorSpy).toHaveBeenCalledWith('CHECKOUT.ERRORS.404'); + }); + + it('should show 409 error notification', () => { + spyOn(nodesApiService, 'checkoutNode').and.returnValue(throwError(() => makeApiError(409))); + contentManagementService.checkout(nodeId).subscribe({ next: () => {} }); + expect(showErrorSpy).toHaveBeenCalledWith('CHECKOUT.ERRORS.CHECKOUT.409'); + }); + + it('should show UNKNOWN notification for unrecognised status code (500)', () => { + spyOn(nodesApiService, 'checkoutNode').and.returnValue(throwError(() => makeApiError(500))); + contentManagementService.checkout(nodeId).subscribe({ next: () => {} }); + expect(showErrorSpy).toHaveBeenCalledWith('CHECKOUT.ERRORS.UNKNOWN'); + }); + }); + + describe('cancelCheckout()', () => { + it('should return the original NodeEntry on success', () => { + const fakeEntry = { entry: { id: nodeId } } as NodeEntry; + spyOn(nodesApiService, 'cancelCheckoutNode').and.returnValue(of(fakeEntry)); + contentManagementService.cancelCheckout(nodeId).subscribe((result) => { + expect(result).toEqual(fakeEntry); + expect(nodesApiService.cancelCheckoutNode).toHaveBeenCalledWith(nodeId); + }); + }); + + it('should show 400 error notification', () => { + spyOn(nodesApiService, 'cancelCheckoutNode').and.returnValue(throwError(() => makeApiError(400))); + contentManagementService.cancelCheckout(nodeId).subscribe({ next: () => {} }); + expect(showErrorSpy).toHaveBeenCalledWith('CHECKOUT.ERRORS.CANCEL_CHECKOUT.400'); + }); + + it('should show 403 error notification', () => { + spyOn(nodesApiService, 'cancelCheckoutNode').and.returnValue(throwError(() => makeApiError(403))); + contentManagementService.cancelCheckout(nodeId).subscribe({ next: () => {} }); + expect(showErrorSpy).toHaveBeenCalledWith('CHECKOUT.ERRORS.CANCEL_CHECKOUT.403'); + }); + + it('should show 404 error notification', () => { + spyOn(nodesApiService, 'cancelCheckoutNode').and.returnValue(throwError(() => makeApiError(404))); + contentManagementService.cancelCheckout(nodeId).subscribe({ next: () => {} }); + expect(showErrorSpy).toHaveBeenCalledWith('CHECKOUT.ERRORS.404'); + }); + + it('should show 409 error notification', () => { + spyOn(nodesApiService, 'cancelCheckoutNode').and.returnValue(throwError(() => makeApiError(409))); + contentManagementService.cancelCheckout(nodeId).subscribe({ next: () => {} }); + expect(showErrorSpy).toHaveBeenCalledWith('CHECKOUT.ERRORS.CANCEL_CHECKOUT.409'); + }); + + it('should show UNKNOWN notification for unrecognised status code', () => { + spyOn(nodesApiService, 'cancelCheckoutNode').and.returnValue(throwError(() => makeApiError(503))); + contentManagementService.cancelCheckout(nodeId).subscribe({ next: () => {} }); + expect(showErrorSpy).toHaveBeenCalledWith('CHECKOUT.ERRORS.UNKNOWN'); + }); + }); + }); }); diff --git a/projects/aca-content/src/lib/services/content-management.service.ts b/projects/aca-content/src/lib/services/content-management.service.ts index a702a2914..cac85162f 100644 --- a/projects/aca-content/src/lib/services/content-management.service.ts +++ b/projects/aca-content/src/lib/services/content-management.service.ts @@ -53,7 +53,7 @@ import { DeletedNodesPaging, Node, NodeEntry, PathInfo, SiteBodyCreate, SiteEntr import { inject, Injectable } from '@angular/core'; import { MatDialog, MatDialogConfig } from '@angular/material/dialog'; import { Store } from '@ngrx/store'; -import { forkJoin, Observable, of, zip } from 'rxjs'; +import { forkJoin, Observable, of, zip, EMPTY } from 'rxjs'; import { catchError, map, mergeMap, take, tap } from 'rxjs/operators'; import { LinkOperationResult, NodeActionsService } from './node-actions.service'; import { ActivatedRoute, Router } from '@angular/router'; @@ -543,6 +543,35 @@ export class ContentManagementService { }); } + checkout(nodeId: string): Observable { + return this.nodesApiService.checkoutNode(nodeId).pipe( + catchError((err) => { + this.notificationService.showError(this.resolveCheckoutErrorKey(err, 'CHECKOUT')); + return EMPTY; + }) + ); + } + + cancelCheckout(nodeId: string): Observable { + return this.nodesApiService.cancelCheckoutNode(nodeId).pipe( + catchError((err) => { + this.notificationService.showError(this.resolveCheckoutErrorKey(err, 'CANCEL_CHECKOUT')); + return EMPTY; + }) + ); + } + + private resolveCheckoutErrorKey(error: Error, operation: 'CHECKOUT' | 'CANCEL_CHECKOUT'): string { + let statusCode = -1; + try { + statusCode = JSON.parse(error.message).error.statusCode; + } catch {} + if (statusCode === 404) { + return 'CHECKOUT.ERRORS.404'; + } + return [400, 403, 409].includes(statusCode) ? `CHECKOUT.ERRORS.${operation}.${statusCode}` : 'CHECKOUT.ERRORS.UNKNOWN'; + } + private showCopyMessage(info: any, nodes: Array, newItems?: Array) { const succeeded = newItems?.length ?? 0; const failed = nodes.length - succeeded;