[ACS-12333][ACS-12335][ACS-12334] Add Checkout / Check-in Support (#5360)

Co-authored-by: Adam Świderski <adam.tomasz.swiderski@gmail.com>
This commit is contained in:
Mykyta Maliarchuk
2026-09-08 10:57:16 +02:00
committed by GitHub
co-authored by Adam Świderski
parent ff7560b7fc
commit c4f3e988c8
40 changed files with 1271 additions and 311 deletions
@@ -5,6 +5,7 @@
}"
[maxRetries]="settings.viewerMaxRetries"
[nodeId]="nodeId"
[readOnly]="node?.aspectNames?.includes('cm:checkedOut')"
[versionId]="versionId"
[allowNavigate]="navigateMultiple"
[allowRightSidebar]="true"
@@ -24,8 +24,15 @@
import { ActivatedRoute, Router } from '@angular/router';
import { ComponentFixture, fakeAsync, TestBed, tick } from '@angular/core/testing';
import { AuthenticationService } from '@alfresco/adf-core';
import { DiscoveryApiService, DocumentListService, FileUploadCompleteEvent, NodesApiService, UploadService } from '@alfresco/adf-content-services';
import { AuthenticationService, UnitTestingUtils } from '@alfresco/adf-core';
import {
AlfrescoViewerComponent,
DiscoveryApiService,
DocumentListService,
FileUploadCompleteEvent,
NodesApiService,
UploadService
} from '@alfresco/adf-content-services';
import { ClosePreviewAction, RefreshPreviewAction, ViewNodeAction } from '@alfresco/aca-shared/store';
import { AcaViewerComponent } from './viewer.component';
import { of } from 'rxjs';
@@ -64,6 +71,7 @@ describe('AcaViewerComponent', () => {
let appHookService: AppHookService;
let documentListService: DocumentListService;
let store: Store<any>;
let testingUtils: UnitTestingUtils;
beforeEach(() => {
TestBed.configureTestingModule({
@@ -86,6 +94,7 @@ describe('AcaViewerComponent', () => {
appHookService = TestBed.inject(AppHookService);
documentListService = TestBed.inject(DocumentListService);
store = TestBed.inject(Store);
testingUtils = new UnitTestingUtils(fixture.debugElement);
});
it('should set folderId and call displayNode with nodeId upon init', () => {
@@ -189,6 +198,39 @@ describe('AcaViewerComponent', () => {
expect(contentApi.getNodeInfo).toHaveBeenCalledWith('displayed-node');
}));
it('should navigate the viewer to the original node when a working copy is checked in on new version upload', fakeAsync(() => {
spyOn(store, 'dispatch');
spyOn(component, 'displayNode').and.stub();
fixture.detectChanges();
component.nodeId = 'working-copy-node';
component.node = { id: 'working-copy-node', aspectNames: ['cm:workingcopy'] } as Node;
uploadService.fileUploadComplete.next({
file: { id: 'working-copy-node' },
data: { entry: { id: 'original-node' } }
} as FileUploadCompleteEvent);
tick(300);
expect(store.dispatch).toHaveBeenCalledWith(jasmine.objectContaining({ ...new ViewNodeAction('original-node', { location: router.url }) }));
expect(component.displayNode).not.toHaveBeenCalled();
}));
it('should set readOnly on the viewer when the node is a checked-out original (cm:checkedOut aspect)', () => {
component.nodeId = 'checked-out-node';
component.node = { id: 'checked-out-node', aspectNames: ['cm:checkedOut'] } as Node;
fixture.detectChanges();
expect(testingUtils.getByDirective(AlfrescoViewerComponent).componentInstance.readOnly).toBe(true);
});
it('should not set readOnly on the viewer when the node is not checked out', () => {
component.nodeId = 'regular-node';
component.node = { id: 'regular-node', aspectNames: [] } as Node;
fixture.detectChanges();
expect(testingUtils.getByDirective(AlfrescoViewerComponent).componentInstance.readOnly).toBe(false);
});
describe('return on event', () => {
beforeEach(async () => {
spyOn<any>(component, 'navigateToFileLocation');
@@ -187,10 +187,15 @@ export class AcaViewerComponent implements OnInit, OnDestroy {
this.uploadService.fileUploadDeleted.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(() => this.navigateToFileLocation());
this.uploadService.fileUploadComplete.pipe(debounceTime(300), takeUntilDestroyed(this.destroyRef)).subscribe((file) => {
this.nodesApiService.nodeUpdated.next(file.data.entry);
if (file.data.entry.id === this.nodeId) {
void this.displayNode(file.data.entry.id);
this.uploadService.fileUploadComplete.pipe(debounceTime(300), takeUntilDestroyed(this.destroyRef)).subscribe((event) => {
const uploadedEntry = event.data.entry;
this.nodesApiService.nodeUpdated.next(uploadedEntry);
if (this.node?.aspectNames?.includes('cm:workingcopy') && event.file?.id === this.nodeId && uploadedEntry.id !== this.nodeId) {
const location = this.route.snapshot.queryParams['location'] || this.router.url;
this.store.dispatch(new ViewNodeAction(uploadedEntry.id, { location }));
} else if (uploadedEntry.id === this.nodeId) {
void this.displayNode(uploadedEntry.id);
}
});
@@ -218,4 +218,66 @@ describe('ViewerService', () => {
it('should return empty array when there are no nodes', async () => {
expect(await viewerService.getFileIds('', null)).toEqual([]);
});
describe('cm:checkedOut filtering for personal-files and libraries', () => {
beforeEach(() => {
spyOn(preferences, 'get').and.returnValues(...preferencesNoCurSortValues);
});
it('should exclude cm:checkedOut nodes from personal-files results', async () => {
const listWithCheckedOut = {
list: {
entries: [
{ entry: { id: 'node1', name: 'node 1', aspectNames: [] } },
{ entry: { id: 'node2', name: 'node 2', aspectNames: ['cm:checkedOut'] } },
{ entry: { id: 'node3', name: 'node 3', aspectNames: ['cm:titled'] } }
]
}
} as NodePaging;
spyOn(contentApi, 'getNodeChildren').and.returnValue(of(listWithCheckedOut));
const ids = await viewerService.getFileIds('personal-files', 'folder1');
expect(ids).toEqual(['node1', 'node3']);
});
it('should exclude cm:checkedOut nodes from libraries results', async () => {
const listWithCheckedOut = {
list: {
entries: [
{ entry: { id: 'node1', name: 'node 1', aspectNames: ['cm:checkedOut'] } },
{ entry: { id: 'node2', name: 'node 2', aspectNames: [] } }
]
}
} as NodePaging;
spyOn(contentApi, 'getNodeChildren').and.returnValue(of(listWithCheckedOut));
const ids = await viewerService.getFileIds('libraries', 'folder1');
expect(ids).toEqual(['node2']);
});
it('should include nodes when aspectNames is undefined', async () => {
const listWithUndefinedAspects = {
list: {
entries: [{ entry: { id: 'node1', name: 'node 1' } }, { entry: { id: 'node2', name: 'node 2', aspectNames: undefined } }]
}
} as NodePaging;
spyOn(contentApi, 'getNodeChildren').and.returnValue(of(listWithUndefinedAspects));
const ids = await viewerService.getFileIds('personal-files', 'folder1');
expect(ids).toEqual(['node1', 'node2']);
});
it('should pass aspectNames in include and fields to getNodeChildren', async () => {
spyOn(contentApi, 'getNodeChildren').and.returnValue(of(list as NodePaging));
await viewerService.getFileIds('personal-files', 'folder1');
const callArgs = (contentApi.getNodeChildren as jasmine.Spy).calls.mostRecent().args[1];
expect(callArgs.include).toEqual(['aspectNames']);
expect(callArgs.fields).toContain('aspectNames');
});
});
});
@@ -111,13 +111,18 @@ export class ViewerService {
return [];
}
const orderBy = isClient ? null : ['isFolder desc', `${sortKey} ${sortDirection}`];
nodes = await this.contentApi
const result = await this.contentApi
.getNodeChildren(folderId, {
orderBy: orderBy,
fields: this.getFields(sortKey, previousSortKey),
include: ['aspectNames'],
fields: [...this.getFields(sortKey, previousSortKey), 'aspectNames'],
where: '(isFile=true)'
})
.toPromise();
if (result) {
result.list.entries = result.list.entries.filter((e) => !e.entry.aspectNames?.includes('cm:checkedOut'));
}
nodes = result;
}
if (source === 'favorites') {