diff --git a/projects/aca-content/assets/app.extensions.json b/projects/aca-content/assets/app.extensions.json
index da40a6e9c..eaa473143 100644
--- a/projects/aca-content/assets/app.extensions.json
+++ b/projects/aca-content/assets/app.extensions.json
@@ -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"
]
}
},
diff --git a/projects/aca-content/assets/i18n/en.json b/projects/aca-content/assets/i18n/en.json
index e23647c52..635ad71af 100644
--- a/projects/aca-content/assets/i18n/en.json
+++ b/projects/aca-content/assets/i18n/en.json
@@ -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",
diff --git a/projects/aca-content/src/lib/aca-content.module.ts b/projects/aca-content/src/lib/aca-content.module.ts
index 7182533e6..44cbc20ac 100644
--- a/projects/aca-content/src/lib/aca-content.module.ts
+++ b/projects/aca-content/src/lib/aca-content.module.ts
@@ -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,
diff --git a/projects/aca-content/src/lib/components/files/files.component.spec.ts b/projects/aca-content/src/lib/components/files/files.component.spec.ts
index b999210b8..2514d3b48 100644
--- a/projects/aca-content/src/lib/components/files/files.component.spec.ts
+++ b/projects/aca-content/src/lib/components/files/files.component.spec.ts
@@ -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;
diff --git a/projects/aca-content/src/lib/components/files/files.component.ts b/projects/aca-content/src/lib/components/files/files.component.ts
index 479d93488..c056aeae4 100644
--- a/projects/aca-content/src/lib/components/files/files.component.ts
+++ b/projects/aca-content/src/lib/components/files/files.component.ts
@@ -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);
diff --git a/projects/aca-content/src/lib/components/info-drawer/comments-tab/comments-tab.component.spec.ts b/projects/aca-content/src/lib/components/info-drawer/comments-tab/comments-tab.component.spec.ts
index 9d8b08630..a0739bbcd 100644
--- a/projects/aca-content/src/lib/components/info-drawer/comments-tab/comments-tab.component.spec.ts
+++ b/projects/aca-content/src/lib/components/info-drawer/comments-tab/comments-tab.component.spec.ts
@@ -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();
+ });
+ });
});
diff --git a/projects/aca-content/src/lib/components/info-drawer/comments-tab/comments-tab.component.ts b/projects/aca-content/src/lib/components/info-drawer/comments-tab/comments-tab.component.ts
index 6e339c5cd..35f5b0bbc 100644
--- a/projects/aca-content/src/lib/components/info-drawer/comments-tab/comments-tab.component.ts
+++ b/projects/aca-content/src/lib/components/info-drawer/comments-tab/comments-tab.component.ts
@@ -33,7 +33,7 @@ import { ExternalNodePermissionCommentsTabService } from './external-node-permis
imports: [MatCardModule, NodeCommentsComponent],
selector: 'app-comments-tab',
template: ` `,
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) {
diff --git a/projects/aca-content/src/lib/components/info-drawer/metadata-tab/metadata-tab.component.spec.ts b/projects/aca-content/src/lib/components/info-drawer/metadata-tab/metadata-tab.component.spec.ts
index 930ffbbcc..e5da6aa7b 100644
--- a/projects/aca-content/src/lib/components/info-drawer/metadata-tab/metadata-tab.component.spec.ts
+++ b/projects/aca-content/src/lib/components/info-drawer/metadata-tab/metadata-tab.component.spec.ts
@@ -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();
+ });
});
});
diff --git a/projects/aca-content/src/lib/components/info-drawer/metadata-tab/metadata-tab.component.ts b/projects/aca-content/src/lib/components/info-drawer/metadata-tab/metadata-tab.component.ts
index 9e175652f..58d98a6b5 100644
--- a/projects/aca-content/src/lib/components/info-drawer/metadata-tab/metadata-tab.component.ts
+++ b/projects/aca-content/src/lib/components/info-drawer/metadata-tab/metadata-tab.component.ts
@@ -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);
diff --git a/projects/aca-content/src/lib/dialogs/node-details/node-information.component.ts b/projects/aca-content/src/lib/dialogs/node-details/node-information.component.ts
index 947bb0a01..fbd647940 100644
--- a/projects/aca-content/src/lib/dialogs/node-details/node-information.component.ts
+++ b/projects/aca-content/src/lib/dialogs/node-details/node-information.component.ts
@@ -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);
}
}
}
diff --git a/projects/aca-content/src/lib/directives/document-list.directive.spec.ts b/projects/aca-content/src/lib/directives/document-list.directive.spec.ts
index b95dd257e..ad531be7f 100644
--- a/projects/aca-content/src/lib/directives/document-list.directive.spec.ts
+++ b/projects/aca-content/src/lib/directives/document-list.directive.spec.ts
@@ -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(),
@@ -80,35 +84,29 @@ describe('DocumentListDirective', () => {
hasItem: jasmine.createSpy('hasItem')
};
+ const nodeToSelect$ = new BehaviorSubject(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([]);
+ }));
});
diff --git a/projects/aca-content/src/lib/directives/document-list.directive.ts b/projects/aca-content/src/lib/directives/document-list.directive.ts
index f10932f86..8b79ff41d 100644
--- a/projects/aca-content/src/lib/directives/document-list.directive.ts
+++ b/projects/aca-content/src/lib/directives/document-list.directive.ts
@@ -22,7 +22,7 @@
* from Hyland Software. If not, see .
*/
-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() {
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 7d75bf3c3..0a8bf9490 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
@@ -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;
+
+ beforeEach(() => {
+ subject = new Subject();
+ 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');
+ });
+ });
});
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 e0e4c782c..30a96b318 100644
--- a/projects/aca-content/src/lib/services/content-management.service.ts
+++ b/projects/aca-content/src/lib/services/content-management.service.ts
@@ -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, newItems?: Array) {
- 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, { 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[] = [];
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,
diff --git a/projects/aca-content/src/lib/services/node-actions.service.spec.ts b/projects/aca-content/src/lib/services/node-actions.service.spec.ts
index 1acb0f9a2..d3a08742d 100644
--- a/projects/aca-content/src/lib/services/node-actions.service.spec.ts
+++ b/projects/aca-content/src/lib/services/node-actions.service.spec.ts
@@ -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();
+ 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();
+ 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();
+ });
+ });
+ });
});
diff --git a/projects/aca-content/src/lib/services/node-actions.service.ts b/projects/aca-content/src/lib/services/node-actions.service.ts
index c185a1d23..7639504ba 100644
--- a/projects/aca-content/src/lib/services/node-actions.service.ts
+++ b/projects/aca-content/src/lib/services/node-actions.service.ts
@@ -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;
+type BatchOperationType = Extract;
+
+export interface LinkOperationResult {
+ succeeded: NodeEntry[];
+ failed: Error[];
+}
@Injectable({
providedIn: 'root'
@@ -66,6 +72,7 @@ export class NodeActionsService {
contentCopied: Subject = new Subject();
contentMoved: Subject = new Subject();
+ contentLinked = new Subject();
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 {
+ 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 {
+ 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 {
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);
diff --git a/projects/aca-content/src/lib/store/effects/node.effects.spec.ts b/projects/aca-content/src/lib/store/effects/node.effects.spec.ts
index 267eae99b..00e1fd959 100644
--- a/projects/aca-content/src/lib/store/effects/node.effects.spec.ts
+++ b/projects/aca-content/src/lib/store/effects/node.effects.spec.ts
@@ -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();
diff --git a/projects/aca-content/src/lib/store/effects/node.effects.ts b/projects/aca-content/src/lib/store/effects/node.effects.ts
index 38ed0e71c..ca476af65 100644
--- a/projects/aca-content/src/lib/store/effects/node.effects.ts
+++ b/projects/aca-content/src/lib/store/effects/node.effects.ts
@@ -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(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(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(
diff --git a/projects/aca-shared/rules/src/app.rules.spec.ts b/projects/aca-shared/rules/src/app.rules.spec.ts
index aedd34dd0..bd58d29fd 100644
--- a/projects/aca-shared/rules/src/app.rules.spec.ts
+++ b/projects/aca-shared/rules/src/app.rules.spec.ts
@@ -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;
diff --git a/projects/aca-shared/rules/src/app.rules.ts b/projects/aca-shared/rules/src/app.rules.ts
index df81db1fd..bd0cf57cd 100644
--- a/projects/aca-shared/rules/src/app.rules.ts
+++ b/projects/aca-shared/rules/src/app.rules.ts
@@ -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');
diff --git a/projects/aca-shared/src/lib/services/app-hook.service.ts b/projects/aca-shared/src/lib/services/app-hook.service.ts
index 292a4f398..870a54a6c 100644
--- a/projects/aca-shared/src/lib/services/app-hook.service.ts
+++ b/projects/aca-shared/src/lib/services/app-hook.service.ts
@@ -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();
+
+ /**
+ * Holds the node that should be pre-selected in the document list after navigation.
+ */
+ nodeToSelect$ = new BehaviorSubject(null);
}
diff --git a/projects/aca-shared/src/lib/services/content-api.service.ts b/projects/aca-shared/src/lib/services/content-api.service.ts
index 0317e03ce..8ecd718d5 100644
--- a/projects/aca-shared/src/lib/services/content-api.service.ts
+++ b/projects/aca-shared/src/lib/services/content-api.service.ts
@@ -114,7 +114,7 @@ export class ContentApiService {
*/
getNode(nodeId: string, options: any = {}): Observable {
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 {
const defaults = {
- include: ['isFavorite', 'allowableOperations', 'path', 'definition']
+ include: ['isFavorite', 'allowableOperations', 'path', 'definition', 'isLink']
};
const queryOptions = Object.assign(defaults, options || {});
diff --git a/projects/aca-shared/store/src/actions/node.actions.ts b/projects/aca-shared/store/src/actions/node.actions.ts
index 200331858..29e9c7907 100644
--- a/projects/aca-shared/store/src/actions/node.actions.ts
+++ b/projects/aca-shared/store/src/actions/node.actions.ts
@@ -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,
+ public configuration?: ModalConfiguration
+ ) {}
+}
+
+export class LocateLinkedItemAction implements Action {
+ readonly type = NodeActionTypes.Locate;
+
+ constructor(public payload: Array) {}
+}
+
export class ManagePermissionsAction implements Action {
readonly type = NodeActionTypes.ManagePermissions;