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