[MNT-25522] Add support for 'Link document' functionality (#5195)

* [MNT-25522] Add 'Link document' functionality

* [MNT-25522] address copilot comments

* [MNT-25522] cr fixes

* [MNT-25522] address code smell
This commit is contained in:
Mykyta Maliarchuk
2026-05-22 17:10:40 +02:00
committed by GitHub
parent c4ff14f359
commit 286bf1b9b4
23 changed files with 1100 additions and 100 deletions
@@ -554,6 +554,24 @@
]
}
},
{
"id": "app.toolbar.locate",
"title": "APP.ACTIONS.LOCATE_LINKED_ITEM",
"order": 250,
"icon": "my_location",
"actions": {
"click": "LOCATE_LINKED_ITEM"
},
"rules": {
"visible": [
"app.selection.isNodeLink",
"app.selection.notEmpty",
"!app.navigation.isTrashcan",
"!app.navigation.isLibraries",
"!isMultiSelection"
]
}
},
{
"id": "app.toolbar.editFolder",
"order": 300,
@@ -611,7 +629,8 @@
"!app.navigation.isRecentFiles",
"!app.navigation.isSharedFiles",
"!app.navigation.isSearchResults",
"!app.navigation.isFavorites"
"!app.navigation.isFavorites",
"!app.selection.isNodeLink"
]
}
},
@@ -631,7 +650,8 @@
"!app.navigation.isRecentFiles",
"!app.navigation.isSharedFiles",
"!app.navigation.isSearchResults",
"!app.navigation.isFavorites"
"!app.navigation.isFavorites",
"!app.selection.isNodeLink"
]
}
},
@@ -673,6 +693,23 @@
]
}
},
{
"id": "app.toolbar.link",
"title": "APP.ACTIONS.CREATE_LINK",
"order": 750,
"icon": "link",
"actions": {
"click": "CREATE_LINK"
},
"rules": {
"visible": [
"app.selection.notEmpty",
"!app.navigation.isTrashcan",
"!app.navigation.isLibraries",
"!app.selection.isNodeLink"
]
}
},
{
"id": "app.toolbar.delete",
"order": 800,
@@ -725,7 +762,8 @@
"visible": [
"app.selection.file",
"!app.navigation.isTrashcan",
"!app.selection.file.isLocked"
"!app.selection.file.isLocked",
"!app.selection.isNodeLink"
]
}
},
@@ -775,7 +813,8 @@
"!app.navigation.isTrashcan",
"app.selection.folder",
"!app.navigation.isFavorites",
"!isSmartFolder"
"!isSmartFolder",
"!app.selection.isNodeLink"
]
}
},
@@ -813,6 +852,24 @@
}
],
"contextMenu": [
{
"id": "app.context.menu.locate",
"title": "APP.ACTIONS.LOCATE_LINKED_ITEM",
"order": 50,
"icon": "my_location",
"actions": {
"click": "LOCATE_LINKED_ITEM"
},
"rules": {
"visible": [
"app.selection.isNodeLink",
"app.selection.notEmpty",
"!app.navigation.isTrashcan",
"!app.navigation.isLibraries",
"!isMultiSelection"
]
}
},
{
"id": "app.context.menu.share",
"type": "custom",
@@ -923,7 +980,8 @@
"!app.navigation.isRecentFiles",
"!app.navigation.isSharedFiles",
"!app.navigation.isSearchResults",
"!app.navigation.isFavorites"
"!app.navigation.isFavorites",
"!app.selection.isNodeLink"
]
}
},
@@ -943,7 +1001,8 @@
"!app.navigation.isRecentFiles",
"!app.navigation.isSharedFiles",
"!app.navigation.isSearchResults",
"!app.navigation.isFavorites"
"!app.navigation.isFavorites",
"!app.selection.isNodeLink"
]
}
},
@@ -1016,6 +1075,23 @@
]
}
},
{
"id": "app.context.menu.link",
"title": "APP.ACTIONS.CREATE_LINK",
"order": 1150,
"icon": "link",
"actions": {
"click": "CREATE_LINK"
},
"rules": {
"visible": [
"app.selection.notEmpty",
"!app.navigation.isTrashcan",
"!app.navigation.isLibraries",
"!app.selection.isNodeLink"
]
}
},
{
"id": "app.context.menu.delete",
"title": "APP.ACTIONS.DELETE",
@@ -1068,7 +1144,8 @@
"visible": [
"app.selection.file",
"!app.navigation.isTrashcan",
"!app.selection.file.isLocked"
"!app.selection.file.isLocked",
"!app.selection.isNodeLink"
]
}
},
@@ -1118,7 +1195,8 @@
"!app.navigation.isTrashcan",
"app.selection.folder",
"!app.navigation.isFavorites",
"!isSmartFolder"
"!isSmartFolder",
"!app.selection.isNodeLink"
]
}
},
@@ -1442,7 +1520,8 @@
"visible": [
"app.selection.file",
"!app.navigation.isTrashcan",
"!app.selection.file.isLocked"
"!app.selection.file.isLocked",
"!app.selection.isNodeLink"
]
}
},
+16 -2
View File
@@ -320,7 +320,9 @@
"EDIT_OFFLINE_CANCEL": "Cancel Editing",
"CHANGE_ASPECT": "Edit Aspects",
"ADD_ASPECTS": "Add Aspects",
"NODE_INFO": "Information"
"NODE_INFO": "Information",
"LOCATE_LINKED_ITEM": "Locate Linked Item",
"CREATE_LINK": "Create Link"
},
"DIALOGS": {
"CONFIRM_PURGE": {
@@ -366,6 +368,7 @@
"GENERIC": "The action was unsuccessful. Try again or contact your IT Team.",
"CONFLICT": "This name is already in use, try a different name.",
"NODE_MOVE": "Move unsuccessful, a file with the same name already exists.",
"NODE_LINK_DUPLICATE": "Can't create a link, a link with the same name already exists in this location.",
"EXISTENT_FOLDER": "There's already a folder with this name. Try a different name.",
"NODE_DELETION": "{{ name }} couldn't be deleted",
"NODE_DELETION_PLURAL": "{{ number }} items couldn't be deleted",
@@ -426,6 +429,14 @@
"FAIL_SINGULAR": "{{ failed }} item couldn't be copied.",
"FAIL_PLURAL": "{{ failed }} items couldn't be copied."
},
"NODE_LINK": {
"SINGULAR": "Link created for {{ success }} item",
"PLURAL": "Links created for {{ success }} items",
"PARTIAL_SINGULAR": "Link created for {{ success }} item, couldn't create a link for {{ failed }}.",
"PARTIAL_PLURAL": "Links created for {{ success }} items, couldn't create links for {{ failed }}.",
"FAIL_SINGULAR": "Couldn't create a link for {{ failed }} item.",
"FAIL_PLURAL": "Couldn't create links for {{ failed }} items."
},
"NODE_MOVE": {
"SINGULAR": "Moved {{ success }} item.",
"PLURAL": "Moved {{ success }} items.",
@@ -513,10 +524,13 @@
"COPY_ITEMS": "Copy {{ number }} items to...",
"MOVE_ITEM": "Move '{{ name }}' to...",
"MOVE_ITEMS": "Move {{ number }} items to...",
"LINK_ITEM": "Create link for '{{ name }}' in...",
"LINK_ITEMS": "Create links for {{ number }} items in...",
"SEARCH": "Search",
"NEXT": "Next",
"SELECT_FILE_TEMPLATE_TITLE": "Select a document template",
"SELECT_FOLDER_TEMPLATE_TITLE": "Select a folder template"
"SELECT_FOLDER_TEMPLATE_TITLE": "Select a folder template",
"LINK_NAME": "Link to {{ name }}"
},
"NODE_FROM_TEMPLATE": {
"CANCEL": "CANCEL",
@@ -154,6 +154,7 @@ import { IsFeatureSupportedInCurrentAcsPipe } from './pipes/is-feature-supported
'app.selection.folder.canUpdate': rules.canUpdateSelectedFolder,
'app.selection.displayedKnowledgeRetrievalButton': rules.canDisplayKnowledgeRetrievalButton,
'app.selection.isCheckedOut': rules.isCheckedOut,
'app.selection.isNodeLink': rules.isNodeLink,
'app.navigation.folder.canCreate': rules.canCreateFolder,
'app.navigation.isTrashcan': rules.isTrashcan,
@@ -248,7 +248,7 @@ describe('FilesComponent', () => {
spyOn(component.documentList, 'loadFolder').and.callFake(() => {});
});
it('should call refresh onContentCopied event if parent is the same', () => {
it('should call refresh onContentAdded event if parent is the same', () => {
const nodes: any[] = [{ entry: { parentId: '1' } }, { entry: { parentId: '2' } }];
component.node = { id: '1' } as any;
@@ -258,7 +258,7 @@ describe('FilesComponent', () => {
expect(component.reload).toHaveBeenCalled();
});
it('should not call refresh onContentCopied event when parent mismatch', () => {
it('should not call refresh onContentAdded event when parent mismatch', () => {
const nodes: any[] = [{ entry: { parentId: '1' } }, { entry: { parentId: '2' } }];
component.node = { id: '3' } as any;
@@ -268,6 +268,26 @@ describe('FilesComponent', () => {
expect(component.reload).not.toHaveBeenCalled();
});
it('should call reload when contentLinked emits a node in the current folder', () => {
const nodes = [{ entry: { parentId: '1' } }, { entry: { parentId: '2' } }] as NodeEntry[];
component.node = { id: '1' } as Node;
nodeActionsService.contentLinked.next({ succeeded: nodes, failed: [] });
expect(component.reload).toHaveBeenCalled();
});
it('should not call reload when contentLinked emits nodes outside the current folder', () => {
const nodes = [{ entry: { parentId: '1' } }, { entry: { parentId: '2' } }] as NodeEntry[];
component.node = { id: '3' } as Node;
nodeActionsService.contentLinked.next({ succeeded: nodes, failed: [] });
expect(component.reload).not.toHaveBeenCalled();
});
it('should call refresh on fileUploadComplete event if parent node match', fakeAsync(() => {
const file: any = { file: { options: { parentId: 'parentId' } } };
component.node = { id: 'parentId' } as any;
@@ -152,7 +152,8 @@ export class FilesComponent extends PageComponent implements OnInit, OnDestroy {
});
this.subscriptions = this.subscriptions.concat([
this.nodeActionsService.contentCopied.subscribe((nodes) => this.onContentCopied(nodes)),
this.nodeActionsService.contentCopied.subscribe((nodes) => this.onContentAdded(nodes)),
this.nodeActionsService.contentLinked.subscribe(({ succeeded }) => this.onContentAdded(succeeded)),
this.uploadService.fileUploadComplete.pipe(debounceTime(300)).subscribe((file) => this.onFileUploadedEvent(file)),
this.uploadService.fileUploadDeleted.pipe(debounceTime(300)).subscribe((file) => this.onFileUploadedEvent(file))
]);
@@ -305,7 +306,7 @@ export class FilesComponent extends PageComponent implements OnInit, OnDestroy {
this.reload(this.selectedNode);
}
onContentCopied(nodes: NodeEntry[]) {
onContentAdded(nodes: NodeEntry[]) {
const newNode = nodes.find((node) => node?.entry?.parentId === this.getParentNodeId());
if (newNode) {
this.reload(this.selectedNode);
@@ -138,4 +138,59 @@ describe('CommentsTabComponent', () => {
await fixture.whenStable();
expect(component.canUpdateNode).toBe(true);
});
describe('link node (isLink)', () => {
it('should set destination to cm:destination property value', async () => {
component.node = {
id: 'link-node-id',
isFile: false,
isFolder: false,
isLink: true,
properties: { 'cm:destination': 'original-node-id' }
} as Node;
fixture.detectChanges();
await fixture.whenStable();
expect(component.destination).toBe('original-node-id');
});
it('should set canUpdateNode based on permission check for link nodes', async () => {
component.node = {
id: 'link-node-id',
isFile: true,
isFolder: false,
isLink: true,
properties: { 'cm:destination': 'original-node-id' }
} as Node;
fixture.detectChanges();
await fixture.whenStable();
expect(component.canUpdateNode).toBe(true);
});
it('should check permissions for link nodes', async () => {
component.node = {
id: 'link-node-id',
isFile: false,
isFolder: true,
isLink: true,
properties: { 'cm:destination': 'original-node-id' }
} as Node;
fixture.detectChanges();
await fixture.whenStable();
expect(nodePermissionService.check).toHaveBeenCalled();
expect(checked).toContain('update');
});
it('should leave destination null if cm:destination property is missing', async () => {
component.node = {
id: 'link-node-id',
isFile: false,
isFolder: false,
isLink: true,
properties: {}
} as Node;
fixture.detectChanges();
await fixture.whenStable();
expect(component.destination).toBeNull();
});
});
});
@@ -33,7 +33,7 @@ import { ExternalNodePermissionCommentsTabService } from './external-node-permis
imports: [MatCardModule, NodeCommentsComponent],
selector: 'app-comments-tab',
template: ` <mat-card class="adf-comments-tab-container" appearance="raised"
><adf-node-comments [readOnly]="!canUpdateNode" [nodeId]="node?.id"
><adf-node-comments [readOnly]="!canUpdateNode" [nodeId]="destination ?? node?.id"
/></mat-card>`,
styles: ['app-comments-tab mat-card { padding: 16px }'],
encapsulation: ViewEncapsulation.None,
@@ -47,11 +47,17 @@ export class CommentsTabComponent implements OnInit {
node: Node;
canUpdateNode = false;
destination: string | null = null;
ngOnInit(): void {
if (!this.node) {
this.canUpdateNode = false;
}
if (this.node.isLink) {
this.destination = this.node.properties?.['cm:destination'] ?? null;
}
if (this.node.isFolder || (this.node.isFile && !isLocked({ entry: this.node }))) {
this.canUpdateNode = this.permission.check(this.node, ['update']);
if (this.externalPermissionNodeService) {
@@ -394,6 +394,15 @@ describe('MetadataTabComponent', () => {
expect(getContentMetadata().displayCategories).toBeFalse();
});
it('should have assigned displayCategories to false if node is a link even if areCategoriesEnabled returns true', () => {
const categoryService = TestBed.inject(CategoryService);
spyOn(categoryService, 'areCategoriesEnabled').and.returnValue(true);
component.node = { isLink: true, allowableOperations: [] } as Node;
component.ngOnInit();
expect(component.displayCategories).toBeFalse();
});
it('should have assigned displayTags to true if tagService.areTagsEnabled returns true', () => {
const tagService = TestBed.inject(TagService);
spyOn(tagService, 'areTagsEnabled').and.returnValue(true);
@@ -411,6 +420,15 @@ describe('MetadataTabComponent', () => {
expect(tagService.areTagsEnabled).toHaveBeenCalled();
expect(getContentMetadata().displayTags).toBeFalse();
});
it('should have assigned displayTags to false if node is a link even if areTagsEnabled returns true', () => {
const tagService = TestBed.inject(TagService);
spyOn(tagService, 'areTagsEnabled').and.returnValue(true);
component.node = { isLink: true, allowableOperations: [] } as Node;
component.ngOnInit();
expect(component.displayTags).toBeFalse();
});
});
});
@@ -98,8 +98,8 @@ export class MetadataTabComponent implements OnInit {
}
ngOnInit() {
this._displayTags = this.tagService.areTagsEnabled();
this._displayCategories = this.categoryService.areCategoriesEnabled();
this._displayTags = !this.node?.isLink && this.tagService.areTagsEnabled();
this._displayCategories = !this.node?.isLink && this.categoryService.areCategoriesEnabled();
this.contentMetadataService.error.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((err: { message: string }) => {
this.notificationService.showError(err.message);
@@ -117,7 +117,7 @@ export class NodeInformationComponent implements OnInit {
}
});
} else {
this.nodeDetails.size = this.fileSizePipe.transform(this.node.content.sizeInBytes);
this.nodeDetails.size = this.fileSizePipe.transform(this.node.content?.sizeInBytes);
}
}
}
@@ -23,13 +23,16 @@
*/
import { DocumentListDirective } from './document-list.directive';
import { Subject } from 'rxjs';
import { BehaviorSubject, Subject } from 'rxjs';
import { SetSelectedNodesAction } from '@alfresco/aca-shared/store';
import { TestBed } from '@angular/core/testing';
import { fakeAsync, TestBed, tick } from '@angular/core/testing';
import { Store } from '@ngrx/store';
import { DocumentListComponent, DocumentListService } from '@alfresco/adf-content-services';
import { UserPreferencesService } from '@alfresco/adf-core';
import { ActivatedRoute, Router } from '@angular/router';
import { ElementRef } from '@angular/core';
import { AppHookService } from '@alfresco/aca-shared';
import { NodeEntry } from '@alfresco/js-api';
describe('DocumentListDirective', () => {
let documentListDirective: DocumentListDirective;
@@ -45,6 +48,7 @@ describe('DocumentListDirective', () => {
setSorting: jasmine.createSpy('setSorting')
},
selection: [],
preselectNodes: [],
reload: jasmine.createSpy('reload'),
resetSelection: jasmine.createSpy('resetSelection'),
ready: new Subject<any>(),
@@ -80,35 +84,29 @@ describe('DocumentListDirective', () => {
hasItem: jasmine.createSpy('hasItem')
};
const nodeToSelect$ = new BehaviorSubject<NodeEntry | null>(null);
const appHookServiceMock = { nodeToSelect$ };
const mockSelectedElement = { focus: jasmine.createSpy('focus') };
const elementRefMock = {
nativeElement: {
querySelector: jasmine.createSpy('querySelector').and.returnValue(mockSelectedElement)
}
};
beforeEach(() => {
TestBed.configureTestingModule({
imports: [DocumentListDirective],
providers: [
DocumentListDirective,
{
provide: Store,
useValue: storeMock
},
{
provide: DocumentListComponent,
useValue: documentListMock
},
{
provide: UserPreferencesService,
useValue: userPreferencesServiceMock
},
{
provide: ActivatedRoute,
useValue: mockRoute
},
{
provide: Router,
useValue: mockRouter
},
{
provide: DocumentListService,
useValue: documentListServiceMock
}
{ provide: Store, useValue: storeMock },
{ provide: DocumentListComponent, useValue: documentListMock },
{ provide: UserPreferencesService, useValue: userPreferencesServiceMock },
{ provide: ActivatedRoute, useValue: mockRoute },
{ provide: Router, useValue: mockRouter },
{ provide: DocumentListService, useValue: documentListServiceMock },
{ provide: AppHookService, useValue: appHookServiceMock },
{ provide: ElementRef, useValue: elementRefMock }
]
});
documentListDirective = TestBed.inject(DocumentListDirective);
@@ -116,6 +114,11 @@ describe('DocumentListDirective', () => {
afterEach(() => {
storeMock.dispatch.calls.reset();
nodeToSelect$.next(null);
documentListMock.preselectNodes = [];
documentListMock.selection = [];
elementRefMock.nativeElement.querySelector.calls.reset();
mockSelectedElement.focus.calls.reset();
});
it('should not update store selection on `documentList.ready` if route includes `viewer:view`', () => {
@@ -278,4 +281,75 @@ describe('DocumentListDirective', () => {
});
});
});
describe('nodeToSelect$', () => {
const pendingNode = { entry: { id: 'node-id', parentId: 'parent-id' } } as NodeEntry;
it('should set preselectNodes when a non-null node is emitted', () => {
documentListDirective.ngOnInit();
nodeToSelect$.next(pendingNode);
expect(documentListMock.preselectNodes).toEqual([pendingNode]);
});
it('should not set preselectNodes when null is emitted (initial BehaviorSubject value)', () => {
documentListDirective.ngOnInit();
expect(documentListMock.preselectNodes).toEqual([]);
});
});
describe('onReady with pending node', () => {
const pendingNode = { entry: { id: 'node-id', parentId: 'parent-id' } } as NodeEntry;
let latestNodeToSelect: NodeEntry | null;
beforeEach(() => {
latestNodeToSelect = null;
nodeToSelect$.subscribe((value) => (latestNodeToSelect = value));
documentListDirective.ngOnInit();
nodeToSelect$.next(pendingNode);
});
it('should focus the selected row when the pending node is in the current selection', fakeAsync(() => {
documentListMock.selection = [{ entry: { id: 'node-id' } }] as NodeEntry[];
documentListDirective.onReady();
tick();
expect(elementRefMock.nativeElement.querySelector).toHaveBeenCalledWith('.adf-is-selected');
expect(mockSelectedElement.focus).toHaveBeenCalled();
}));
it('should clear preselectNodes and reset nodeToSelect$ when node was selected', fakeAsync(() => {
documentListMock.selection = [{ entry: { id: 'node-id' } }] as NodeEntry[];
documentListDirective.onReady();
tick();
expect(documentListMock.preselectNodes).toEqual([]);
expect(latestNodeToSelect).toBeNull();
}));
it('should not focus or clear state when pending node is not in the current selection', fakeAsync(() => {
documentListMock.selection = [];
documentListDirective.onReady();
tick();
expect(elementRefMock.nativeElement.querySelector).not.toHaveBeenCalled();
expect(mockSelectedElement.focus).not.toHaveBeenCalled();
expect(documentListMock.preselectNodes).toEqual([pendingNode]);
expect(latestNodeToSelect).toBe(pendingNode);
}));
});
it('should not attempt to focus or clear preselectNodes when no node is pending', fakeAsync(() => {
documentListDirective.ngOnInit();
documentListDirective.onReady();
tick();
expect(elementRefMock.nativeElement.querySelector).not.toHaveBeenCalled();
expect(documentListMock.preselectNodes).toEqual([]);
}));
});
@@ -22,7 +22,7 @@
* from Hyland Software. If not, see <http://www.gnu.org/licenses/>.
*/
import { DestroyRef, Directive, HostListener, inject, OnInit } from '@angular/core';
import { DestroyRef, Directive, ElementRef, HostListener, inject, OnInit } from '@angular/core';
import { DocumentListComponent, DocumentListService } from '@alfresco/adf-content-services';
import { ActivatedRoute, Router } from '@angular/router';
import { UserPreferencesService } from '@alfresco/adf-core';
@@ -31,6 +31,7 @@ import { SetSelectedNodesAction } from '@alfresco/aca-shared/store';
import { filter } from 'rxjs/operators';
import { NodeEntry } from '@alfresco/js-api';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { AppHookService } from '@alfresco/aca-shared';
@Directive({
standalone: true,
@@ -43,8 +44,12 @@ export class DocumentListDirective implements OnInit {
private readonly route = inject(ActivatedRoute);
private readonly router = inject(Router);
private readonly documentListService = inject(DocumentListService);
private readonly appHookService = inject(AppHookService);
private readonly elementRef = inject(ElementRef);
private isLibrary = false;
private pendingNode: NodeEntry | null = null;
selectedNode: NodeEntry;
get sortingPreferenceKey(): string {
@@ -55,7 +60,7 @@ export class DocumentListDirective implements OnInit {
ngOnInit() {
this.documentList.stickyHeader = true;
this.documentList.includeFields = ['isFavorite', 'aspectNames', 'definition'];
this.documentList.includeFields = ['isFavorite', 'aspectNames', 'definition', 'isLink'];
this.isLibrary =
this.documentList.currentFolderId === '-mysites-' ||
// workaround for custom node list
@@ -96,6 +101,16 @@ export class DocumentListDirective implements OnInit {
this.documentListService.resetSelection$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe(() => {
this.reset();
});
this.appHookService.nodeToSelect$
.pipe(
filter((node): node is NodeEntry => node !== null),
takeUntilDestroyed(this.destroyRef)
)
.subscribe((node) => {
this.pendingNode = node;
this.documentList.preselectNodes = [node];
});
}
@HostListener('sorting-changed', ['$event'])
@@ -147,6 +162,15 @@ export class DocumentListDirective implements OnInit {
onReady() {
this.updateSelection();
this.restoreSorting();
if (this.pendingNode) {
const wasSelected = this.documentList.selection.some((node) => node.entry?.id === this.pendingNode.entry.id);
if (wasSelected) {
setTimeout(() => this.elementRef.nativeElement.querySelector('.adf-is-selected')?.focus());
this.pendingNode = null;
this.documentList.preselectNodes = [];
this.appHookService.nodeToSelect$.next(null);
}
}
}
private updateSelection() {
@@ -28,6 +28,7 @@ import {
AppStore,
CopyNodesAction,
DeleteNodesAction,
LinkNodesAction,
MoveNodesAction,
NavigateRouteAction,
NavigateToParentFolder,
@@ -44,7 +45,7 @@ import { AppTestingModule } from '../testing/app-testing.module';
import { AppHookService, AppSettingsService, ContentApiService } from '@alfresco/aca-shared';
import { Store } from '@ngrx/store';
import { ContentManagementService } from './content-management.service';
import { NodeActionsService } from './node-actions.service';
import { LinkOperationResult, NodeActionsService } from './node-actions.service';
import { ConfirmDialogComponent, DialogComponent, DialogSize, NotificationService, TranslationService } from '@alfresco/adf-core';
import { MatDialog, MatDialogModule, MatDialogRef } from '@angular/material/dialog';
import { MatSnackBarModule, MatSnackBarRef, SimpleSnackBar } from '@angular/material/snack-bar';
@@ -2101,4 +2102,224 @@ describe('ContentManagementService', () => {
expect(mockElement.focus).toHaveBeenCalled();
}));
});
describe('Link node action', () => {
let subject: Subject<string>;
beforeEach(() => {
subject = new Subject<string>();
openSnackMessageActionSpy.and.callThrough();
});
afterEach(() => subject.complete());
it('notifies successful link of a single node', () => {
spyOn(nodeActions, 'createLinkNodes').and.returnValue(subject);
const selection = [{ entry: { id: 'node-to-link-id', name: 'name' } }] as NodeEntry[];
const linkedItems = [{ entry: { id: 'link-id', name: 'name' } }] as NodeEntry[];
const result: LinkOperationResult = { succeeded: linkedItems, failed: [] };
store.dispatch(new LinkNodesAction(selection));
nodeActions.contentLinked.next(result);
subject.next('OPERATION.SUCCESS.CONTENT.LINK');
const snackMessageCall = openSnackMessageActionSpy.calls.argsFor(0);
expect(nodeActions.createLinkNodes).toHaveBeenCalled();
expect(snackMessageCall[0]).toBe('APP.MESSAGES.INFO.NODE_LINK.SINGULAR');
expect(snackMessageCall[2].panelClass).toBe('adf-info-snackbar');
});
it('notifies successful link of multiple nodes', () => {
spyOn(nodeActions, 'createLinkNodes').and.returnValue(subject);
const selection = [{ entry: { id: 'node-to-link-1', name: 'name1' } }, { entry: { id: 'node-to-link-2', name: 'name2' } }] as NodeEntry[];
const linkedItems = [{ entry: { id: 'link-of-node-1', name: 'name1' } }, { entry: { id: 'link-of-node-2', name: 'name2' } }] as NodeEntry[];
const result: LinkOperationResult = { succeeded: linkedItems, failed: [] };
store.dispatch(new LinkNodesAction(selection));
nodeActions.contentLinked.next(result);
subject.next('OPERATION.SUCCESS.CONTENT.LINK');
const snackMessageCall = openSnackMessageActionSpy.calls.argsFor(0);
expect(nodeActions.createLinkNodes).toHaveBeenCalled();
expect(snackMessageCall[0]).toBe('APP.MESSAGES.INFO.NODE_LINK.PLURAL');
expect(snackMessageCall[2].panelClass).toBe('adf-info-snackbar');
});
it('notifies partial link of one node out of multiple selection', () => {
spyOn(nodeActions, 'createLinkNodes').and.returnValue(subject);
const selection = [{ entry: { id: 'node-to-link-1', name: 'name1' } }, { entry: { id: 'node-to-link-2', name: 'name2' } }] as NodeEntry[];
const linkedItems = [{ entry: { id: 'link-of-node-1', name: 'name1' } }] as NodeEntry[];
const result: LinkOperationResult = { succeeded: linkedItems, failed: [new Error('Server error')] };
store.dispatch(new LinkNodesAction(selection));
nodeActions.contentLinked.next(result);
subject.next('OPERATION.SUCCESS.CONTENT.LINK');
const snackMessageCall = openSnackMessageActionSpy.calls.argsFor(0);
expect(nodeActions.createLinkNodes).toHaveBeenCalled();
expect(snackMessageCall[0]).toBe('APP.MESSAGES.INFO.NODE_LINK.PARTIAL_SINGULAR');
expect(snackMessageCall[2].panelClass).toBe('adf-warning-snackbar');
});
it('notifies failed link of a single node with a generic error', () => {
spyOn(nodeActions, 'createLinkNodes').and.returnValue(subject);
const selection = [{ entry: { id: 'node-to-link-id', name: 'name' } }] as NodeEntry[];
const result: LinkOperationResult = { succeeded: [], failed: [new Error('Server error')] };
store.dispatch(new LinkNodesAction(selection));
nodeActions.contentLinked.next(result);
subject.next('OPERATION.SUCCESS.CONTENT.LINK');
const snackMessageCall = openSnackMessageActionSpy.calls.argsFor(0);
expect(nodeActions.createLinkNodes).toHaveBeenCalled();
expect(snackMessageCall[0]).toBe('APP.MESSAGES.INFO.NODE_LINK.FAIL_SINGULAR');
expect(snackMessageCall[2].panelClass).toBe('adf-error-snackbar');
});
it('notifies duplicate link error when single node link fails with 409', () => {
spyOn(nodeActions, 'createLinkNodes').and.returnValue(subject);
const selection = [{ entry: { id: 'node-to-link-id', name: 'name' } }] as NodeEntry[];
const result: LinkOperationResult = {
succeeded: [],
failed: [new Error(JSON.stringify({ error: { statusCode: 409 } }))]
};
store.dispatch(new LinkNodesAction(selection));
nodeActions.contentLinked.next(result);
subject.next('OPERATION.SUCCESS.CONTENT.LINK');
const snackMessageCall = openSnackMessageActionSpy.calls.argsFor(0);
expect(nodeActions.createLinkNodes).toHaveBeenCalled();
expect(snackMessageCall[0]).toBe('APP.MESSAGES.ERRORS.NODE_LINK_DUPLICATE');
expect(snackMessageCall[2].panelClass).toBe('adf-error-snackbar');
});
it('notifies failed link of multiple nodes', () => {
spyOn(nodeActions, 'createLinkNodes').and.returnValue(subject);
const selection = [{ entry: { id: 'node-to-link-0', name: 'name0' } }, { entry: { id: 'node-to-link-1', name: 'name1' } }] as NodeEntry[];
const result: LinkOperationResult = { succeeded: [], failed: [new Error('Server error'), new Error('Server error')] };
store.dispatch(new LinkNodesAction(selection));
nodeActions.contentLinked.next(result);
subject.next('OPERATION.SUCCESS.CONTENT.LINK');
const snackMessageCall = openSnackMessageActionSpy.calls.argsFor(0);
expect(nodeActions.createLinkNodes).toHaveBeenCalled();
expect(snackMessageCall[0]).toBe('APP.MESSAGES.INFO.NODE_LINK.FAIL_PLURAL');
expect(snackMessageCall[2].panelClass).toBe('adf-error-snackbar');
});
it('notifies permission error on link failure', () => {
spyOn(nodeActions, 'createLinkNodes').and.returnValue(subject);
const selection = [{ entry: { id: 'node-to-link-id', name: 'name' } }] as NodeEntry[];
store.dispatch(new LinkNodesAction(selection));
subject.error(new Error(JSON.stringify({ error: { statusCode: 403 } })));
const snackMessageCall = openSnackMessageActionSpy.calls.argsFor(0);
expect(nodeActions.createLinkNodes).toHaveBeenCalled();
expect(snackMessageCall[0]).toBe('APP.MESSAGES.ERRORS.PERMISSION');
expect(snackMessageCall[2].panelClass).toBe('adf-error-snackbar');
});
it('should never show Undo action for link notifications', () => {
spyOn(nodeActions, 'createLinkNodes').and.returnValue(subject);
const selection = [{ entry: { id: 'node-to-link', name: 'name' } }] as NodeEntry[];
const linkedItems = [{ entry: { id: 'link-id', name: 'name' } }] as NodeEntry[];
const result: LinkOperationResult = { succeeded: linkedItems, failed: [] };
store.dispatch(new LinkNodesAction(selection));
nodeActions.contentLinked.next(result);
subject.next('OPERATION.SUCCESS.CONTENT.LINK');
const snackMessageCall = openSnackMessageActionSpy.calls.argsFor(0);
expect(snackMessageCall[1]).toBeNull();
});
});
describe('navigateToLinkTarget', () => {
it('should dispatch NavigateToParentFolder when destination node is found', () => {
const originalNodeEntry: NodeEntry = {
entry: {
id: 'original-node-id',
name: 'original-file.txt',
nodeType: 'cm:content',
isFolder: false,
isFile: true,
modifiedAt: new Date(),
modifiedByUser: new UserInfo(),
createdAt: new Date(),
createdByUser: new UserInfo(),
parentId: 'parent-folder-id'
}
};
const linkNode: NodeEntry = {
entry: {
id: 'link-node-id',
name: 'Link to original-file.txt.url',
nodeType: 'app:filelink',
isFolder: false,
isFile: false,
modifiedAt: new Date(),
modifiedByUser: new UserInfo(),
createdAt: new Date(),
createdByUser: new UserInfo(),
properties: { 'cm:destination': 'original-node-id' }
}
};
spyOn(contentApi, 'getNode').and.returnValue(of(originalNodeEntry));
spyOn(store, 'dispatch').and.callThrough();
contentManagementService.navigateToLinkTarget(linkNode);
expect(contentApi.getNode).toHaveBeenCalledWith('original-node-id');
expect(store.dispatch).toHaveBeenCalledWith(jasmine.objectContaining({ ...new NavigateToParentFolder(originalNodeEntry) }));
});
it('should show error when cm:destination property is missing', () => {
const linkNode: NodeEntry = {
entry: {
id: 'link-node-id',
name: 'Link to file.url',
nodeType: 'app:filelink',
isFolder: false,
isFile: false,
modifiedAt: new Date(),
modifiedByUser: new UserInfo(),
createdAt: new Date(),
createdByUser: new UserInfo(),
properties: {}
}
};
contentManagementService.navigateToLinkTarget(linkNode);
expect(showErrorSpy).toHaveBeenCalledWith('APP.MESSAGES.ERRORS.GENERIC');
});
it('should show error when getNode API call fails', () => {
const linkNode: NodeEntry = {
entry: {
id: 'link-node-id',
name: 'Link to file.url',
nodeType: 'app:filelink',
isFolder: false,
isFile: false,
modifiedAt: new Date(),
modifiedByUser: new UserInfo(),
createdAt: new Date(),
createdByUser: new UserInfo(),
properties: { 'cm:destination': 'original-node-id' }
}
};
spyOn(contentApi, 'getNode').and.returnValue(throwError(() => new Error('Not found')));
contentManagementService.navigateToLinkTarget(linkNode);
expect(showErrorSpy).toHaveBeenCalledWith('APP.MESSAGES.ERRORS.GENERIC');
});
});
});
@@ -55,7 +55,7 @@ import { MatDialog, MatDialogConfig } from '@angular/material/dialog';
import { Store } from '@ngrx/store';
import { forkJoin, Observable, of, zip } from 'rxjs';
import { catchError, map, mergeMap, take, tap } from 'rxjs/operators';
import { NodeActionsService } from './node-actions.service';
import { LinkOperationResult, NodeActionsService } from './node-actions.service';
import { ActivatedRoute, Router } from '@angular/router';
import { NodeInformationComponent } from '../dialogs/node-details/node-information.component';
@@ -475,6 +475,34 @@ export class ContentManagementService {
);
}
linkNodes(nodes: NodeEntry[], focusedElementOnCloseSelector?: string) {
zip(this.nodeActionsService.createLinkNodes(nodes, focusedElementOnCloseSelector), this.nodeActionsService.contentLinked.pipe(take(1))).subscribe(
{
next: ([, linkResponse]) => this.showLinkMessage(nodes, linkResponse),
error: (error) => this.showLinkMessage(nodes, { succeeded: [], failed: [error] })
}
);
}
navigateToLinkTarget(linkNode: NodeEntry): void {
const destinationId = linkNode.entry?.properties?.['cm:destination'];
if (!destinationId) {
this.notificationService.showError('APP.MESSAGES.ERRORS.GENERIC');
return;
}
this.contentApi.getNode(destinationId).subscribe({
next: (originalNode) => {
this.store.dispatch(new NavigateToParentFolder(originalNode));
this.appHookService.nodeToSelect$.next(originalNode);
this.documentListService.reload();
},
error: () => {
this.notificationService.showError('APP.MESSAGES.ERRORS.GENERIC');
}
});
}
getErrorMessage(errorObject: { message: any }): string {
let i18nMessageString = 'APP.MESSAGES.ERRORS.GENERIC';
@@ -516,63 +544,76 @@ export class ContentManagementService {
}
private showCopyMessage(info: any, nodes: Array<NodeEntry>, newItems?: Array<NodeEntry>) {
const numberOfCopiedItems = newItems ? newItems.length : 0;
const failedItems = nodes.length - numberOfCopiedItems;
const succeeded = newItems?.length ?? 0;
const failed = nodes.length - succeeded;
let i18nMessageString = 'APP.MESSAGES.ERRORS.GENERIC';
if (typeof info === 'string') {
if (info.toLowerCase().indexOf('succes') !== -1) {
let i18MessageSuffix;
if (failedItems) {
if (numberOfCopiedItems) {
i18MessageSuffix = numberOfCopiedItems === 1 ? 'PARTIAL_SINGULAR' : 'PARTIAL_PLURAL';
} else {
i18MessageSuffix = failedItems === 1 ? 'FAIL_SINGULAR' : 'FAIL_PLURAL';
}
} else {
i18MessageSuffix = numberOfCopiedItems === 1 ? 'SINGULAR' : 'PLURAL';
}
i18nMessageString = `APP.MESSAGES.INFO.NODE_COPY.${i18MessageSuffix}`;
}
} else {
if (typeof info === 'string' && info.toLowerCase().includes('succes')) {
i18nMessageString = `APP.MESSAGES.INFO.NODE_COPY.${this.getOperationMessageSuffix(succeeded, failed)}`;
} else if (typeof info !== 'string') {
try {
const {
error: { statusCode }
} = JSON.parse(info.message);
if (statusCode === 403) {
i18nMessageString = 'APP.MESSAGES.ERRORS.PERMISSION';
}
} catch {}
}
const undo = numberOfCopiedItems > 0 ? this.translation.instant('APP.ACTIONS.UNDO') : '';
const message = this.translation.instant(i18nMessageString, {
success: numberOfCopiedItems,
failed: failedItems
});
let messageType: string;
if (numberOfCopiedItems === 0) {
messageType = 'adf-error-snackbar';
} else if (failedItems > 0) {
messageType = 'adf-warning-snackbar';
} else {
messageType = 'adf-info-snackbar';
}
const undo = succeeded > 0 ? this.translation.instant('APP.ACTIONS.UNDO') : '';
const message = this.translation.instant(i18nMessageString, { success: succeeded, failed });
this.notificationService
.openSnackMessageAction(message, undo, {
panelClass: messageType
})
.openSnackMessageAction(message, undo, { panelClass: this.getSnackbarPanelClass(succeeded, failed) })
.onAction()
.subscribe(() => this.undoCopyNodes(newItems));
}
private showLinkMessage(nodes: Array<NodeEntry>, { succeeded: newItems, failed }: LinkOperationResult) {
const succeeded = newItems.length;
const totalFailed = nodes.length - succeeded;
let isDuplicate = false;
let isPermissionError = false;
if (nodes.length === 1 && succeeded === 0) {
try {
const statusCode = JSON.parse(failed[0].message).error.statusCode;
isDuplicate = statusCode === 409;
isPermissionError = statusCode === 403;
} catch {}
}
let i18nMessageString: string;
if (isDuplicate) {
i18nMessageString = 'APP.MESSAGES.ERRORS.NODE_LINK_DUPLICATE';
} else if (isPermissionError) {
i18nMessageString = 'APP.MESSAGES.ERRORS.PERMISSION';
} else {
i18nMessageString = `APP.MESSAGES.INFO.NODE_LINK.${this.getOperationMessageSuffix(succeeded, totalFailed)}`;
}
const message = this.translation.instant(i18nMessageString, { success: succeeded, failed: totalFailed });
this.notificationService.openSnackMessageAction(message, null, { panelClass: this.getSnackbarPanelClass(succeeded, totalFailed) });
}
private getOperationMessageSuffix(succeeded: number, failed: number): string {
if (failed > 0 && succeeded > 0) {
return succeeded === 1 ? 'PARTIAL_SINGULAR' : 'PARTIAL_PLURAL';
}
if (failed > 0) {
return failed === 1 ? 'FAIL_SINGULAR' : 'FAIL_PLURAL';
}
return succeeded === 1 ? 'SINGULAR' : 'PLURAL';
}
private getSnackbarPanelClass(succeeded: number, failed: number): string {
if (succeeded === 0) {
return 'adf-error-snackbar';
}
return failed > 0 ? 'adf-warning-snackbar' : 'adf-info-snackbar';
}
private undoCopyNodes(nodes: NodeEntry[]) {
const batch = this.nodeActionsService
.flatten(nodes)
@@ -697,6 +738,7 @@ export class ContentManagementService {
deleteNodes(items: NodeEntry[], allowUndo = true, focusedElementOnCloseSelector?: string): void {
this.focusAfterClose(focusedElementOnCloseSelector);
const canUndo = allowUndo && !items.every((node) => node.entry.nodeType === 'app:filelink' || node.entry.nodeType === 'app:folderlink');
const batch: Observable<DeletedNodeInfo>[] = [];
items.forEach((node) => {
@@ -709,7 +751,7 @@ export class ContentManagementService {
if (messageData && status.someSucceeded) {
const translatedMessage: string = this.translation.instant(messageData.key, messageData.params);
const action: string | null = allowUndo ? this.translation.instant('APP.ACTIONS.UNDO') : null;
const action: string | null = canUndo ? this.translation.instant('APP.ACTIONS.UNDO') : null;
const snackBarRef = this.notificationService.openSnackMessageAction(
translatedMessage,
@@ -27,7 +27,7 @@ import { MatDialog, MatDialogModule, MatDialogRef } from '@angular/material/dial
import { of, throwError, Subject, Observable } from 'rxjs';
import { TranslationService } from '@alfresco/adf-core';
import { AlfrescoApiService, DocumentListService, NodeAction } from '@alfresco/adf-content-services';
import { NodeActionsService } from './node-actions.service';
import { LinkOperationResult, NodeActionsService } from './node-actions.service';
import { Node, NodeChildAssociationEntry, NodeEntry } from '@alfresco/js-api';
import { AppTestingModule } from '../testing/app-testing.module';
import { ContentApiService } from '@alfresco/aca-shared';
@@ -1228,4 +1228,122 @@ describe('NodeActionsService', () => {
});
});
});
describe('createLinkNodes', () => {
let fileToLink: TestNode;
let destinationFolder: TestNode;
beforeEach(() => {
fileToLink = new TestNode(fileId, isFile, 'file-name');
destinationFolder = new TestNode(folderDestinationId);
});
it('should call doBatchOperation with NodeAction.LINK', () => {
const subject = new Subject<Node[]>();
const spyOnBatchOperation = spyOn(service, 'doBatchOperation').and.callThrough();
spyOn(service, 'getContentNodeSelection').and.returnValue(subject);
spyOn(service, 'linkNodeAction').and.returnValue(of({}));
service.createLinkNodes([fileToLink]);
subject.next([destinationFolder.entry]);
expect(spyOnBatchOperation).toHaveBeenCalledWith(NodeAction.LINK, [fileToLink], undefined, undefined);
});
it('should emit on contentLinked after successful link', (done) => {
const subject = new Subject<Node[]>();
spyOn(service, 'getContentNodeSelection').and.returnValue(subject);
spyOn(service, 'linkNodeAction').and.returnValue(of({ entry: { id: 'new-link-id' } }));
service.contentLinked.subscribe((result: LinkOperationResult) => {
expect(result.succeeded.length).toBe(1);
expect(result.succeeded[0].entry.id).toBe('new-link-id');
expect(result.failed.length).toBe(0);
done();
});
service.createLinkNodes([fileToLink]);
subject.next([destinationFolder.entry]);
});
it('should error if contentEntities is missing', (done) => {
service.createLinkNodes(undefined).subscribe({
next: () => spyOnSuccess(),
error: (error: Error) => {
spyOnError(error);
expect(spyOnSuccess).not.toHaveBeenCalled();
expect(spyOnError).toHaveBeenCalled();
done();
}
});
});
});
describe('linkNodeAction', () => {
let translationService: TranslationService;
beforeEach(() => {
translationService = TestBed.inject(TranslationService);
spyOn(translationService, 'instant').and.callFake((key) => key);
});
it('should call nodesApi.createNode with app:filelink type for a file', (done) => {
const fileEntry = new TestNode(fileId, isFile, 'my-file.txt').entry;
spyOn(nodesApi, 'createNode').and.returnValue(Promise.resolve({ entry: { id: 'link-id' } }));
service.linkNodeAction(fileEntry, folderDestinationId).subscribe(() => {
expect(nodesApi.createNode).toHaveBeenCalledWith(
folderDestinationId,
jasmine.objectContaining({
nodeType: 'app:filelink',
properties: jasmine.objectContaining({ 'cm:destination': fileId })
})
);
done();
});
});
it('should call nodesApi.createNode with app:folderlink type for a folder', (done) => {
const folderEntry = new TestNode('folder-id', !isFile, 'my-folder').entry;
spyOn(nodesApi, 'createNode').and.returnValue(Promise.resolve({ entry: { id: 'link-id' } } as NodeEntry));
service.linkNodeAction(folderEntry, folderDestinationId).subscribe(() => {
expect(nodesApi.createNode).toHaveBeenCalledWith(folderDestinationId, jasmine.objectContaining({ nodeType: 'app:folderlink' }));
done();
});
});
it('should append .url suffix to link name if not already present', (done) => {
const fileEntry = new TestNode(fileId, isFile, 'report').entry;
spyOn(nodesApi, 'createNode').and.returnValue(Promise.resolve({ entry: { id: 'link-id' } } as NodeEntry));
service.linkNodeAction(fileEntry, folderDestinationId).subscribe(() => {
const body = (nodesApi.createNode as jasmine.Spy).calls.mostRecent().args[1];
expect(body.name.endsWith('.url')).toBe(true);
done();
});
});
it('should not double-append .url suffix if name already ends with .url', (done) => {
const fileEntry = new TestNode(fileId, isFile, 'report').entry;
spyOn(nodesApi, 'createNode').and.returnValue(Promise.resolve({ entry: { id: 'link-id' } } as NodeEntry));
service.linkNodeAction(fileEntry, folderDestinationId).subscribe(() => {
const body = (nodesApi.createNode as jasmine.Spy).calls.mostRecent().args[1];
expect(body.name).toBe('NODE_SELECTOR.LINK_NAME.url');
done();
});
});
it('should use nodeId over id when building the link body', (done) => {
const sharedFileEntry: any = { id: 'original-id', nodeId: 'shared-node-id', name: 'shared.txt', isFile: true, isFolder: false };
spyOn(nodesApi, 'createNode').and.returnValue(Promise.resolve({ entry: { id: 'link-id' } } as NodeEntry));
service.linkNodeAction(sharedFileEntry, folderDestinationId).subscribe(() => {
const body = (nodesApi.createNode as jasmine.Spy).calls.mostRecent().args[1];
expect(body.properties['cm:destination']).toBe('shared-node-id');
done();
});
});
});
});
@@ -39,6 +39,7 @@ import {
import {
NodeEntry,
Node,
SharedLink,
SitePaging,
NodeChildAssociationPaging,
NodeChildAssociationEntry,
@@ -50,7 +51,12 @@ import {
import { ContentApiService } from '@alfresco/aca-shared';
import { catchError, map, mergeMap } from 'rxjs/operators';
type BatchOperationType = Extract<NodeAction, 'COPY' | 'MOVE'>;
type BatchOperationType = Extract<NodeAction, 'COPY' | 'MOVE' | 'LINK'>;
export interface LinkOperationResult {
succeeded: NodeEntry[];
failed: Error[];
}
@Injectable({
providedIn: 'root'
@@ -66,6 +72,7 @@ export class NodeActionsService {
contentCopied: Subject<NodeEntry[]> = new Subject<NodeEntry[]>();
contentMoved: Subject<any> = new Subject<any>();
contentLinked = new Subject<LinkOperationResult>();
moveDeletedEntries: any[] = [];
isSitesDestinationAvailable = false;
@@ -83,6 +90,16 @@ export class NodeActionsService {
return this.doBatchOperation(NodeAction.COPY, contentEntities, permission, focusedElementOnCloseSelector);
}
/**
* Create links for node list
*
* @param contentEntities nodes to create links for
* @param focusedElementOnCloseSelector element's selector which should be autofocused after closing modal
*/
createLinkNodes(contentEntities: any[], focusedElementOnCloseSelector?: string): Subject<string> {
return this.doBatchOperation(NodeAction.LINK, contentEntities, undefined, focusedElementOnCloseSelector);
}
/**
* Move node list
*
@@ -138,7 +155,11 @@ export class NodeActionsService {
this.contentCopied.next(processedData.succeeded);
} else if (action === NodeAction.MOVE) {
this.contentMoved.next(processedData);
} else if (action === NodeAction.LINK) {
this.contentLinked.next(processedData);
}
observable.complete();
}, observable.error.bind(observable));
});
} else {
@@ -339,6 +360,19 @@ export class NodeActionsService {
}
}
linkNodeAction(nodeEntry: Node | SharedLink, destinationFolderId: string): Observable<NodeEntry> {
const sourceNodeId = (nodeEntry as SharedLink).nodeId || nodeEntry.id;
const isFolder = (nodeEntry as Node).isFolder ?? false;
const baseName = this.translation.instant('NODE_SELECTOR.LINK_NAME', { name: nodeEntry.name });
const linkName = baseName.endsWith('.url') ? baseName : `${baseName}.url`;
const nodeBody = {
name: linkName,
nodeType: isFolder ? 'app:folderlink' : 'app:filelink',
properties: { 'cm:destination': sourceNodeId, 'cm:description': linkName, 'cm:title': linkName }
};
return from(this.nodesApi.createNode(destinationFolderId, nodeBody)).pipe(catchError((err) => of(err)));
}
copyContentAction(contentEntry: any, selectionId: string, oldName?: string): Observable<any> {
const _oldName = oldName || contentEntry.name;
// Check if there's nodeId for Shared Files
@@ -549,7 +583,7 @@ export class NodeActionsService {
}
private isActionAllowed(action: BatchOperationType, node: Node, permission?: string): boolean {
if (action === NodeAction.COPY) {
if (action === NodeAction.COPY || action === NodeAction.LINK) {
return true;
}
return this.contentService.hasAllowableOperations(node, permission);
@@ -51,7 +51,9 @@ import {
ShowLoaderAction,
UndoDeleteNodesAction,
UnlockWriteAction,
UnshareNodesAction
UnshareNodesAction,
LinkNodesAction,
LocateLinkedItemAction
} from '@alfresco/aca-shared/store';
import { RenditionService } from '@alfresco/adf-content-services';
import { ViewerEffects } from './viewer.effects';
@@ -390,6 +392,70 @@ describe('NodeEffects', () => {
});
});
describe('createLink$', () => {
it('should link nodes from the payload', () => {
spyOn(contentService, 'linkNodes').and.stub();
const node = { entry: { isFile: true } } as NodeEntry;
store.dispatch(new LinkNodesAction([node]));
expect(contentService.linkNodes).toHaveBeenCalledWith([node]);
});
it('should link nodes from the active selection', fakeAsync(() => {
spyOn(contentService, 'linkNodes').and.stub();
const node = { entry: { isFile: true } } as NodeEntry;
store.dispatch(new SetSelectedNodesAction([node]));
tick(100);
store.dispatch(new LinkNodesAction([]));
expect(contentService.linkNodes).toHaveBeenCalledWith([node], undefined);
}));
it('should do nothing if invoking link with no data', () => {
spyOn(contentService, 'linkNodes').and.stub();
store.dispatch(new LinkNodesAction([]));
expect(contentService.linkNodes).not.toHaveBeenCalled();
});
});
describe('locateLinkedItem$', () => {
it('should navigate to link target from the payload', () => {
spyOn(contentService, 'navigateToLinkTarget').and.stub();
const node = { entry: { isFile: true, id: 'link-node-id' } } as NodeEntry;
store.dispatch(new LocateLinkedItemAction([node]));
expect(contentService.navigateToLinkTarget).toHaveBeenCalledWith(node);
});
it('should navigate to link target from the active selection', fakeAsync(() => {
spyOn(contentService, 'navigateToLinkTarget').and.stub();
const node = { entry: { isFile: true, id: 'link-node-id' } } as NodeEntry;
store.dispatch(new SetSelectedNodesAction([node]));
tick(100);
store.dispatch(new LocateLinkedItemAction(null));
expect(contentService.navigateToLinkTarget).toHaveBeenCalledWith(node);
}));
it('should do nothing if invoking locate with no data', () => {
spyOn(contentService, 'navigateToLinkTarget').and.stub();
store.dispatch(new LocateLinkedItemAction(null));
expect(contentService.navigateToLinkTarget).not.toHaveBeenCalled();
});
});
describe('managePermissions$', () => {
it('should manage permissions from the payload', () => {
spyOn(router, 'navigateByUrl').and.stub();
@@ -52,7 +52,9 @@ import {
UndoDeleteNodesAction,
UnlockWriteAction,
UnshareNodesAction,
NodeInformationAction
NodeInformationAction,
LinkNodesAction,
LocateLinkedItemAction
} from '@alfresco/aca-shared/store';
import { ContentManagementService } from '../../services/content-management.service';
import { RenditionService } from '@alfresco/adf-content-services';
@@ -281,6 +283,50 @@ export class NodeEffects {
{ dispatch: false }
);
createLink$ = createEffect(
() =>
this.actions$.pipe(
ofType<LinkNodesAction>(NodeActionTypes.Link),
map((action) => {
if (action.payload?.length > 0) {
this.contentService.linkNodes(action.payload);
} else {
this.store
.select(getAppSelection)
.pipe(take(1))
.subscribe((selection) => {
if (selection && !selection.isEmpty) {
this.contentService.linkNodes(selection.nodes, action.configuration?.focusedElementOnCloseSelector);
}
});
}
})
),
{ dispatch: false }
);
locateLinkedItem$ = createEffect(
() =>
this.actions$.pipe(
ofType<LocateLinkedItemAction>(NodeActionTypes.Locate),
map((action) => {
if (action.payload?.length > 0) {
this.contentService.navigateToLinkTarget(action.payload[0]);
} else {
this.store
.select(getAppSelection)
.pipe(take(1))
.subscribe((selection) => {
if (selection && !selection.isEmpty) {
this.contentService.navigateToLinkTarget(selection.nodes[0]);
}
});
}
})
),
{ dispatch: false }
);
managePermissions$ = createEffect(
() =>
this.actions$.pipe(
@@ -71,6 +71,27 @@ describe('app.evaluators', () => {
expect(app.canDownloadSelection(context)).toBe(false);
});
it('should not allow downloading when selection contains a file link', () => {
context.selection.isEmpty = false;
context.selection.nodes = [{ entry: { nodeType: 'app:filelink', isFile: true } }] as NodeEntry[];
expect(app.canDownloadSelection(context)).toBe(false);
});
it('should not allow downloading when selection contains a folder link', () => {
context.selection.isEmpty = false;
context.selection.nodes = [{ entry: { nodeType: 'app:folderlink', isFolder: true } }] as NodeEntry[];
expect(app.canDownloadSelection(context)).toBe(false);
});
it('should not allow downloading when any node in multi-selection is a link', () => {
context.selection.isEmpty = false;
context.selection.nodes = [{ entry: { isFile: true } }, { entry: { nodeType: 'app:filelink', isFile: true } }] as NodeEntry[];
expect(app.canDownloadSelection(context)).toBe(false);
});
});
describe('isWriteLocked', () => {
@@ -212,6 +233,22 @@ describe('app.evaluators', () => {
expect(app.canUploadVersion(context)).toBe(true);
});
it('should return [false] when any selected node is a file link', () => {
context.navigation.url = '/personal-files';
context.selection.isEmpty = false;
context.selection.nodes = [{ entry: { nodeType: 'app:filelink' } }] as NodeEntry[];
expect(app.canUploadVersion(context)).toBe(false);
});
it('should return [false] when any selected node is a folder link', () => {
context.navigation.url = '/personal-files';
context.selection.isEmpty = false;
context.selection.nodes = [{ entry: { nodeType: 'app:folderlink' } }] as NodeEntry[];
expect(app.canUploadVersion(context)).toBe(false);
});
});
describe('isShared', () => {
@@ -397,6 +434,26 @@ describe('app.evaluators', () => {
expect(app.canOpenWithOffice(context)).toBeTruthy();
});
it('should return [false] when any selected node is a file link', () => {
context.appConfig = { get: () => true } as any;
context.selection.isEmpty = false;
context.selection.nodes = [{ entry: { nodeType: 'app:filelink' } }] as NodeEntry[];
context.selection.file = { entry: { name: 'document.docx', isLocked: false, properties: {} } } as NodeEntry;
context.permissions = { check: () => true };
expect(app.canOpenWithOffice(context)).toBeFalsy();
});
it('should return [false] when any selected node is a folder link', () => {
context.appConfig = { get: () => true } as any;
context.selection.isEmpty = false;
context.selection.nodes = [{ entry: { nodeType: 'app:folderlink' } }] as NodeEntry[];
context.selection.file = { entry: { name: 'document.docx', isLocked: false, properties: {} } } as NodeEntry;
context.permissions = { check: () => true };
expect(app.canOpenWithOffice(context)).toBeFalsy();
});
});
describe('canEditAspects', () => {
@@ -736,6 +793,22 @@ describe('app.evaluators', () => {
context.repository.status.isQuickShareEnabled = true;
expect(app.canShareFile(context)).toBeTrue();
});
it('should return false when selection contains a file link node', () => {
context.selection.file = { entry: { properties: {} } } as NodeEntry;
context.selection.nodes = [{ entry: { nodeType: 'app:filelink' } }] as NodeEntry[];
context.navigation.url = '/personal-files';
context.repository.status.isQuickShareEnabled = true;
expect(app.canShareFile(context)).toBeFalse();
});
it('should return false when selection contains a folder link node', () => {
context.selection.file = { entry: { properties: {} } } as NodeEntry;
context.selection.nodes = [{ entry: { nodeType: 'app:folderlink' } }] as NodeEntry[];
context.navigation.url = '/personal-files';
context.repository.status.isQuickShareEnabled = true;
expect(app.canShareFile(context)).toBeFalse();
});
});
describe('canToggleJoinLibrary', () => {
@@ -1133,6 +1206,22 @@ describe('app.evaluators', () => {
context.permissions = { check: () => false };
expect(app.canToggleFileLock(context)).toBeTrue();
});
it('should return false when any selected node is a file link', () => {
context.selection.isEmpty = false;
context.selection.nodes = [{ entry: { nodeType: 'app:filelink' } }] as NodeEntry[];
context.selection.file = { entry: { properties: {} } } as NodeEntry;
context.permissions = { check: () => true };
expect(app.canToggleFileLock(context)).toBeFalse();
});
it('should return false when any selected node is a folder link', () => {
context.selection.isEmpty = false;
context.selection.nodes = [{ entry: { nodeType: 'app:folderlink' } }] as NodeEntry[];
context.selection.file = { entry: { properties: {} } } as NodeEntry;
context.permissions = { check: () => true };
expect(app.canToggleFileLock(context)).toBeFalse();
});
});
describe('canPrintFile', () => {
@@ -1223,6 +1312,49 @@ describe('app.evaluators', () => {
});
});
describe('isNodeLink', () => {
it('should return false when selection is empty', () => {
context.selection.isEmpty = true;
expect(app.isNodeLink(context)).toBeFalse();
});
it('should return false when selected node has no nodeType', () => {
context.selection.isEmpty = false;
context.selection.nodes = [{ entry: { isFile: true } }] as NodeEntry[];
expect(app.isNodeLink(context)).toBeFalse();
});
it('should return false when selected node has an unrelated nodeType', () => {
context.selection.isEmpty = false;
context.selection.nodes = [{ entry: { nodeType: 'cm:content' } }] as NodeEntry[];
expect(app.isNodeLink(context)).toBeFalse();
});
it('should return true when selected node has nodeType app:filelink', () => {
context.selection.isEmpty = false;
context.selection.nodes = [{ entry: { nodeType: 'app:filelink' } }] as NodeEntry[];
expect(app.isNodeLink(context)).toBeTrue();
});
it('should return true when selected node has nodeType app:folderlink', () => {
context.selection.isEmpty = false;
context.selection.nodes = [{ entry: { nodeType: 'app:folderlink' } }] as NodeEntry[];
expect(app.isNodeLink(context)).toBeTrue();
});
it('should return true when any node in multi-selection is a link', () => {
context.selection.isEmpty = false;
context.selection.nodes = [{ entry: { nodeType: 'cm:content' } }, { entry: { nodeType: 'app:filelink' } }] as NodeEntry[];
expect(app.isNodeLink(context)).toBeTrue();
});
it('should return false when no node in multi-selection is a link', () => {
context.selection.isEmpty = false;
context.selection.nodes = [{ entry: { nodeType: 'cm:content' } }, { entry: { nodeType: 'cm:folder' } }] as NodeEntry[];
expect(app.isNodeLink(context)).toBeFalse();
});
});
describe('isSSOEnabled', () => {
it('should return true if sso is enabled', () => {
context.appConfig = { get: () => 'OAUTH' } as any;
+29 -2
View File
@@ -121,7 +121,9 @@ export function canRemoveFavorite(context: RuleContext): boolean {
* JSON ref: `app.selection.file.canShare`
*/
export const canShareFile = (context: RuleContext): boolean =>
[context.selection.file, !navigation.isTrashcan(context), repository.hasQuickShareEnabled(context), !isShared(context)].every(Boolean);
[context.selection.file, !navigation.isTrashcan(context), repository.hasQuickShareEnabled(context), !isShared(context), !isNodeLink(context)].every(
Boolean
);
/**
* Checks if user can perform "Join" or "Cancel Join Request" on a library.
@@ -208,6 +210,9 @@ export function canCreateFolder(context: AcaRuleContext): boolean {
* JSON ref: `app.selection.canDownload`
*/
export function canDownloadSelection(context: RuleContext): boolean {
if (isNodeLink(context)) {
return false;
}
return context.selection.nodes.every((node: any) => node.entry && (node.entry.isFile || node.entry.isFolder || !!node.entry.nodeId));
}
@@ -344,6 +349,10 @@ export function canUnlockFile(context: RuleContext): boolean {
* JSON ref: `app.selection.file.canUploadVersion`
*/
export function canUploadVersion(context: RuleContext): boolean {
if (isNodeLink(context)) {
return false;
}
if (navigation.isFavorites(context) || navigation.isSharedFiles(context)) {
return hasFileSelected(context);
}
@@ -363,6 +372,9 @@ export function canUploadVersion(context: RuleContext): boolean {
*/
export const canPrintFile = (context: RuleContext): boolean => {
const nodeEntry = context.selection.file.entry;
if (!nodeEntry?.content?.mimeType) {
return false;
}
const mediaMimeTypes = ['video/mp4', 'video/webm', 'video/ogg', 'audio/mpeg', 'audio/mp3', 'audio/ogg', 'audio/wav'];
return !mediaMimeTypes.includes(nodeEntry.content.mimeType);
};
@@ -390,7 +402,8 @@ export const canEditAspects = (context: RuleContext): boolean =>
repository.isMajorVersionAvailable(context, '7')
].every(Boolean);
export const canToggleFileLock = (context: RuleContext): boolean => [canLockFile(context) || canUnlockFile(context)].some(Boolean);
export const canToggleFileLock = (context: RuleContext): boolean =>
!isNodeLink(context) && [canLockFile(context) || canUnlockFile(context)].some(Boolean);
/**
* @deprecated Uses workarounds for for recent files and search api issues.
@@ -434,6 +447,10 @@ export function canOpenWithOffice(context: AcaRuleContext): boolean {
return false;
}
if (isNodeLink(context)) {
return false;
}
if (context.navigation?.url?.startsWith('/trashcan')) {
return false;
}
@@ -577,3 +594,13 @@ export const isCheckedOut = (context: RuleContext): boolean => {
}
return false;
};
/**
* Checks if any of the selected nodes is a link node (app:filelink or app:folderlink).
* JSON ref: `app.selection.isNodeLink`
*
* @param context Rule execution context
*/
export const isNodeLink = (context: RuleContext): boolean =>
!context.selection?.isEmpty &&
context.selection.nodes.some((node) => node.entry?.nodeType === 'app:filelink' || node.entry?.nodeType === 'app:folderlink');
@@ -23,8 +23,8 @@
*/
import { Injectable } from '@angular/core';
import { Subject } from 'rxjs';
import { SiteEntry } from '@alfresco/js-api';
import { BehaviorSubject, Subject } from 'rxjs';
import { NodeEntry, SiteEntry } from '@alfresco/js-api';
@Injectable({
providedIn: 'root'
@@ -84,4 +84,9 @@ export class AppHookService {
* Gets emitted when user mark the favorite library
*/
favoriteLibraryToggle = new Subject<void>();
/**
* Holds the node that should be pre-selected in the document list after navigation.
*/
nodeToSelect$ = new BehaviorSubject<NodeEntry | null>(null);
}
@@ -114,7 +114,7 @@ export class ContentApiService {
*/
getNode(nodeId: string, options: any = {}): Observable<NodeEntry> {
const defaults = {
include: ['path', 'properties', 'allowableOperations', 'permissions', 'definition']
include: ['path', 'properties', 'allowableOperations', 'permissions', 'definition', 'isLink']
};
const queryOptions = Object.assign(defaults, options);
@@ -123,7 +123,7 @@ export class ContentApiService {
getNodeInfo(nodeId: string, options?: any): Observable<Node> {
const defaults = {
include: ['isFavorite', 'allowableOperations', 'path', 'definition']
include: ['isFavorite', 'allowableOperations', 'path', 'definition', 'isLink']
};
const queryOptions = Object.assign(defaults, options || {});
@@ -39,6 +39,8 @@ export enum NodeActionTypes {
Unshare = 'UNSHARE_NODES',
Copy = 'COPY_NODES',
Move = 'MOVE_NODES',
Link = 'CREATE_LINK',
Locate = 'LOCATE_LINKED_ITEM',
NodeInformation = 'NODE_INFORMATION',
ManagePermissions = 'MANAGE_PERMISSIONS',
PrintFile = 'PRINT_FILE',
@@ -149,6 +151,21 @@ export class MoveNodesAction implements Action {
) {}
}
export class LinkNodesAction implements Action {
readonly type = NodeActionTypes.Link;
constructor(
public payload: Array<NodeEntry>,
public configuration?: ModalConfiguration
) {}
}
export class LocateLinkedItemAction implements Action {
readonly type = NodeActionTypes.Locate;
constructor(public payload: Array<NodeEntry>) {}
}
export class ManagePermissionsAction implements Action {
readonly type = NodeActionTypes.ManagePermissions;