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;