[ADF-1041] Node picker, first iteration (#2122)

* First try

* Dialog basic functionality

* Search input

* Hammering it together

* Fist working proto for copy

* Fix the tests and tslint errors for a happier world

* Add more tests (and test shells for the future)

* copyNode and moveNode methods

* Copy and move actions for content type

* Extract common parts in favor of using them in folder content type also

* Small fixes

* Copy and Move actions for folders as well

* Style fixes, ui behaviours and tests needed to be written

* Move duplicated search service from documentlist to core

* Use search service from core within the search component

* Fix dialog width

* Update docs

* Tests for node selector

* Change seletionMade event's name to select
This commit is contained in:
Popovics András
2017-07-25 09:17:11 +01:00
committed by Eugenio Romano
parent 952da3ab99
commit 4fd8bfb875
33 changed files with 1218 additions and 138 deletions

View File

@@ -17,12 +17,13 @@
import { EventEmitter } from '@angular/core';
import { async, TestBed } from '@angular/core/testing';
import { AlfrescoContentService, CoreModule } from 'ng2-alfresco-core';
import { AlfrescoContentService, AlfrescoTranslationService, CoreModule, NotificationService } from 'ng2-alfresco-core';
import { FileNode } from './../../assets/document-library.model.mock';
import { DocumentListServiceMock } from './../../assets/document-list.service.mock';
import { ContentActionHandler } from './../../models/content-action.model';
import { DocumentActionsService } from './../../services/document-actions.service';
import { FolderActionsService } from './../../services/folder-actions.service';
import { NodeActionsService } from './../../services/node-actions.service';
import { DocumentListComponent } from './../document-list.component';
import { ContentActionListComponent } from './content-action-list.component';
import { ContentActionComponent } from './content-action.component';
@@ -35,6 +36,9 @@ describe('ContentAction', () => {
let folderActions: FolderActionsService;
let contentService: AlfrescoContentService;
let translateService: AlfrescoTranslationService;
let notificationService: NotificationService;
let nodeActionsService: NodeActionsService;
beforeEach(async(() => {
TestBed.configureTestingModule({
@@ -49,9 +53,12 @@ describe('ContentAction', () => {
beforeEach(() => {
contentService = TestBed.get(AlfrescoContentService);
translateService = <AlfrescoTranslationService> { addTranslationFolder: () => {}};
nodeActionsService = new NodeActionsService(null, translateService, null, null);
notificationService = new NotificationService(null);
let documentServiceMock = new DocumentListServiceMock();
documentActions = new DocumentActionsService(null, null);
folderActions = new FolderActionsService(null, contentService);
documentActions = new DocumentActionsService(translateService, notificationService, nodeActionsService);
folderActions = new FolderActionsService(translateService, notificationService, nodeActionsService, null, contentService);
documentList = new DocumentListComponent(documentServiceMock, null, null, null);
actionList = new ContentActionListComponent(documentList);

View File

@@ -0,0 +1,73 @@
<header mdDialogTitle
class="adf-content-node-selector-title"
data-automation-id="content-node-selector-title">{{title}}</header>
<section mdDialogContent
class="adf-content-node-selector-content"
(node-select)="onNodeSelect($event)"
(node-unselect)="onNodeUnselect($event)">
<md-input-container floatPlaceholder="never" class="adf-content-node-selector-content-input">
<input #searchInput
mdInput
placeholder="Search"
(keyup)="search(searchInput.value)"
[value]="searchTerm"
data-automation-id="content-node-selector-search-input">
<md-icon *ngIf="searchTerm.length > 0"
mdSuffix (click)="clear()"
class="adf-content-node-selector-content-input-icon"
data-automation-id="content-node-selector-search-clear">clear</md-icon>
<md-icon *ngIf="searchTerm.length === 0"
mdSuffix
class="adf-content-node-selector-content-input-icon"
data-automation-id="content-node-selector-search-icon">search</md-icon>
</md-input-container>
<adf-sites-dropdown
(change)="siteChanged($event)"
data-automation-id="content-node-selector-sites-combo"></adf-sites-dropdown>
<div class="adf-content-node-selector-content-list" data-automation-id="content-node-selector-content-list">
<adf-document-list *ngIf="searched"
#documentList
[node]="nodes"
[permissionsStyle]="permissionsStyle"
[creationMenuActions]="false"
[currentFolderId]="currentFolderId"
[selectionMode]="'single'"
[contextMenuActions]="false"
[contentActions]="false"
[allowDropFiles]="false"
[enablePagination]="false"
data-automation-id="content-node-selector-document-list">
<empty-folder-content>
<template>
<div>{{ 'NODE_SELECTOR.NO_RESULTS' | translate }}</div>
</template>
</empty-folder-content>
</adf-document-list>
</div>
</section>
<footer mdDialogActions class="adf-content-node-selector-actions">
<button *ngIf="inDialog"
md-button
class="adf-content-node-selector-actions-cancel"
(click)="close()"
data-automation-id="content-node-selector-actions-cancel">{{ 'NODE_SELECTOR.CANCEL' | translate }}
</button>
<button md-button
[disabled]="!chosenNode"
class="adf-content-node-selector-actions-choose"
(click)="choose()"
data-automation-id="content-node-selector-actions-choose">{{ 'NODE_SELECTOR.CHOOSE' | translate }}
</button>
</footer>

View File

@@ -0,0 +1,116 @@
@import 'theming';
.#{$ADF}-content-node-selector-dialog {
.mat-dialog-container {
padding: 0;
}
.#{$ADF}-content-node-selector {
&-title,
&-content,
&-actions {
padding: 16px;
margin: 0;
}
&-title {
text-transform: capitalize;
}
&-content {
padding-top: 0;
&-input {
width: 100%;
&-icon {
color: rgba(0, 0, 0, 0.38);
cursor: pointer;
&:hover {
color: rgba(0, 0, 0, 1);
}
}
}
& /deep/ .mat-input-underline .mat-input-ripple {
height: 1px;
transition: none;
}
& /deep/ .adf-site-dropdown-list-element {
width: 100%;
margin-bottom: 20px;
.mat-select-trigger {
font-size: 14px;
}
.mat-select-placeholder,
&.mat-select {
font-family: 'Muli', "Helvetica", "Arial", sans-serif;
}
}
&-list {
height: 200px;
overflow: auto;
border: 1px solid rgba(0, 0, 0, 0.07);
& /deep/ .adf-data-table {
border: none;
.adf-no-content-container {
text-align: center;
}
thead {
display: none;
}
.adf-data-table-cell {
padding-top: 8px;
padding-bottom: 8px;
border-top: none;
height: 30px;
}
tbody tr {
height: auto !important;
&:last-child {
.adf-data-table-cell {
border-bottom: none;
}
}
}
}
}
}
&-actions {
padding: 8px;
background-color: rgb(250, 250, 250);
display: flex;
justify-content: flex-end;
color: rgb(121, 121, 121);
&:last-child {
margin-bottom: 0px;
}
&-cancel {
font-weight: normal;
}
&-choose {
font-weight: normal;
&[disabled] {
opacity: 0.6;
}
}
}
}
}

View File

@@ -0,0 +1,377 @@
/*!
* @license
* Copyright 2016 Alfresco Software, Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { DebugElement, EventEmitter } from '@angular/core';
import { async, ComponentFixture, TestBed } from '@angular/core/testing';
import { MD_DIALOG_DATA, MdDialogRef } from '@angular/material';
import { By } from '@angular/platform-browser';
import { MinimalNodeEntryEntity, NodePaging } from 'alfresco-js-api';
import { AlfrescoTranslationService, CoreModule, SearchService, SiteModel } from 'ng2-alfresco-core';
import { DataTableModule } from 'ng2-alfresco-datatable';
import { MaterialModule } from '../../material.module';
import { DocumentListService } from '../../services/document-list.service';
import { DocumentListComponent } from '../document-list.component';
import { DocumentMenuActionComponent } from '../document-menu-action.component';
import { EmptyFolderContentDirective } from '../empty-folder/empty-folder-content.directive';
import { DropdownSitesComponent } from '../site-dropdown/sites-dropdown.component';
import { ContentNodeSelectorComponent } from './content-node-selector.component';
const ONE_FOLDER_RESULT = {
list: {
entries: [
{
entry: {
id: '123', name: 'MyFolder', isFile: false, isFolder: true,
createdByUser: { displayName: 'John Doe' },
modifiedByUser: { displayName: 'John Doe' }
}
}
]
}
};
const NO_RESULT = {
list: {
entries: []
}
};
describe('ContentNodeSelectorComponent', () => {
let component: ContentNodeSelectorComponent;
let fixture: ComponentFixture<ContentNodeSelectorComponent>;
let element: DebugElement;
let data: any;
let searchService: SearchService;
let searchSpy: jasmine.Spy;
let _resolve: Function;
let _reject: Function;
function typeToSearchBox(searchTerm = 'string-to-search') {
let searchInput = fixture.debugElement.query(By.css('[data-automation-id="content-node-selector-search-input"]'));
searchInput.nativeElement.value = searchTerm;
searchInput.triggerEventHandler('keyup', {});
fixture.detectChanges();
}
function respondWithSearchResults(result) {
_resolve(result);
}
function setupTestbed(plusProviders) {
TestBed.configureTestingModule({
imports: [
CoreModule.forRoot(),
DataTableModule.forRoot(),
MaterialModule
],
declarations: [
DocumentListComponent,
DocumentMenuActionComponent,
EmptyFolderContentDirective,
DropdownSitesComponent,
ContentNodeSelectorComponent
],
providers: [
AlfrescoTranslationService,
DocumentListService,
SearchService,
...plusProviders
]
});
}
afterEach(() => {
fixture.destroy();
TestBed.resetTestingModule();
});
describe('Dialog features', () => {
beforeEach(async(() => {
data = {
title: 'Move along citizen...',
select: new EventEmitter<MinimalNodeEntryEntity>()
};
setupTestbed([{ provide: MD_DIALOG_DATA, useValue: data }]);
TestBed.compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(ContentNodeSelectorComponent);
element = fixture.debugElement;
component = fixture.componentInstance;
fixture.detectChanges();
});
describe('Data injecting with the "Material dialog way"', () => {
it('should show the INJECTED title', () => {
const titleElement = fixture.debugElement.query(By.css('[data-automation-id="content-node-selector-title"]'));
expect(titleElement).not.toBeNull();
expect(titleElement.nativeElement.innerText).toBe('Move along citizen...');
});
it('should trigger the INJECTED select event when selection has been made', (done) => {
const expectedNode = <MinimalNodeEntryEntity> {};
data.select.subscribe((node) => {
expect(node).toBe(expectedNode);
done();
});
component.chosenNode = expectedNode;
component.choose();
});
});
describe('Cancel button', () => {
let dummyMdDialogRef;
beforeEach(() => {
dummyMdDialogRef = <MdDialogRef<ContentNodeSelectorComponent>> { close: () => {} };
});
it('should be shown if dialogRef is injected', () => {
const componentInstance = new ContentNodeSelectorComponent(null, null, data, dummyMdDialogRef);
expect(componentInstance.inDialog).toBeTruthy();
});
it('should should call the close method in the injected dialogRef', () => {
spyOn(dummyMdDialogRef, 'close');
const componentInstance = new ContentNodeSelectorComponent(null, null, data, dummyMdDialogRef);
componentInstance.close();
expect(dummyMdDialogRef.close).toHaveBeenCalled();
});
});
});
describe('General component features', () => {
beforeEach(async(() => {
setupTestbed([]);
TestBed.compileComponents();
}));
beforeEach(() => {
fixture = TestBed.createComponent(ContentNodeSelectorComponent);
element = fixture.debugElement;
component = fixture.componentInstance;
searchService = TestBed.get(SearchService);
searchSpy = spyOn(searchService, 'getQueryNodesPromise').and.callFake(() => {
return new Promise((resolve, reject) => {
_resolve = resolve;
_reject = reject;
});
});
});
describe('Parameters', () => {
it('should show the title', () => {
component.title = 'Move along citizen...';
fixture.detectChanges();
const titleElement = fixture.debugElement.query(By.css('[data-automation-id="content-node-selector-title"]'));
expect(titleElement).not.toBeNull();
expect(titleElement.nativeElement.innerText).toBe('Move along citizen...');
});
it('should trigger the select event when selection has been made', (done) => {
const expectedNode = <MinimalNodeEntryEntity> {};
component.select.subscribe((node) => {
expect(node).toBe(expectedNode);
done();
});
component.chosenNode = expectedNode;
component.choose();
});
});
describe('Search functionality', () => {
it('should load the results by calling the search api on search change', () => {
typeToSearchBox('kakarot');
expect(searchSpy).toHaveBeenCalledWith('kakarot*', {
include: ['path'],
skipCount: 0,
rootNodeId: undefined,
nodeType: 'cm:folder',
maxItems: 40,
orderBy: null
});
});
it('should NOT call the search api if the searchTerm length is less than 4 characters', () => {
typeToSearchBox('1');
typeToSearchBox('12');
typeToSearchBox('123');
expect(searchSpy).not.toHaveBeenCalled();
});
xit('should debounce the search call by 500 ms', () => {
});
it('should call the search api on changing the site selectbox\'s value', () => {
typeToSearchBox('vegeta');
expect(searchSpy.calls.count()).toBe(1, 'Search count should be one after only one search');
component.siteChanged(<SiteModel> { guid: 'namek' });
expect(searchSpy.calls.count()).toBe(2, 'Search count should be two after the site change');
expect(searchSpy.calls.argsFor(1)).toEqual(['vegeta*', {
include: ['path'],
skipCount: 0,
rootNodeId: 'namek',
nodeType: 'cm:folder',
maxItems: 40,
orderBy: null
}]);
});
it('should show the search icon by default without the X (clear) icon', () => {
fixture.detectChanges();
let searchIcon = fixture.debugElement.query(By.css('[data-automation-id="content-node-selector-search-icon"]'));
let clearIcon = fixture.debugElement.query(By.css('[data-automation-id="content-node-selector-search-clear"]'));
expect(searchIcon).not.toBeNull('Search icon should be in the DOM');
expect(clearIcon).toBeNull('Clear icon should NOT be in the DOM');
});
it('should show the X (clear) icon without the search icon when the search contains at least one character', () => {
fixture.detectChanges();
typeToSearchBox('123');
let searchIcon = fixture.debugElement.query(By.css('[data-automation-id="content-node-selector-search-icon"]'));
let clearIcon = fixture.debugElement.query(By.css('[data-automation-id="content-node-selector-search-clear"]'));
expect(searchIcon).toBeNull('Search icon should NOT be in the DOM');
expect(clearIcon).not.toBeNull('Clear icon should be in the DOM');
});
it('should clear the search field, nodes and chosenNode when clicking on the X (clear) icon', () => {
component.chosenNode = <MinimalNodeEntryEntity> {};
component.nodes = [ component.chosenNode ];
component.searchTerm = 'whatever';
component.searched = true;
component.clear();
expect(component.searched).toBe(false);
expect(component.searchTerm).toBe('');
expect(component.nodes).toEqual([]);
expect(component.chosenNode).toBeNull();
});
it('should show the default text instead of result list if search was not performed', () => {
let documentList = fixture.debugElement.query(By.css('[data-automation-id="content-node-selector-document-list"]'));
expect(documentList).toBeNull('Document list should not be shown by default');
});
it('should show the result list when search was performed', async(() => {
typeToSearchBox();
respondWithSearchResults(ONE_FOLDER_RESULT);
fixture.whenStable().then(() => {
fixture.detectChanges();
let documentList = fixture.debugElement.query(By.css('[data-automation-id="content-node-selector-document-list"]'));
expect(documentList).not.toBeNull('Document list should be shown after search');
});
}));
it('should show the default text instead of result list if search was cleared', async(() => {
typeToSearchBox();
respondWithSearchResults(ONE_FOLDER_RESULT);
fixture.whenStable().then(() => {
fixture.detectChanges();
let clearButton = fixture.debugElement.query(By.css('[data-automation-id="content-node-selector-search-clear"]'));
expect(clearButton).not.toBeNull('Clear button should be in DOM');
clearButton.triggerEventHandler('click', {});
fixture.detectChanges();
let documentList = fixture.debugElement.query(By.css('[data-automation-id="content-node-selector-document-list"]'));
expect(documentList).toBeNull('Document list should NOT be shown after clearing the search');
});
}));
xit('should do something with pagination or with many results', () => {
});
xit('should trigger some kind of error when error happened during search', () => {
});
});
describe('Cancel button', () => {
it('should not be shown if dialogRef is NOT injected', () => {
const closeButton = fixture.debugElement.query(By.css('[content-node-selector-actions-cancel]'));
expect(closeButton).toBeNull();
});
});
describe('Choose button', () => {
it('should be disabled by default', () => {
fixture.detectChanges();
let chooseButton = fixture.debugElement.query(By.css('[data-automation-id="content-node-selector-actions-choose"]'));
expect(chooseButton.nativeElement.disabled).toBe(true);
});
it('should be enabled when clicking on one element in the list (onNodeSelect)', () => {
fixture.detectChanges();
component.onNodeSelect({ detail: { node: { entry: <MinimalNodeEntryEntity> {} } } });
fixture.detectChanges();
let chooseButton = fixture.debugElement.query(By.css('[data-automation-id="content-node-selector-actions-choose"]'));
expect(chooseButton.nativeElement.disabled).toBe(false);
});
it('should be disabled when deselecting the previously selected element in the list (onNodeUnselect)', () => {
component.onNodeSelect({ detail: { node: { entry: <MinimalNodeEntryEntity> {} } } });
fixture.detectChanges();
component.onNodeUnselect();
fixture.detectChanges();
let chooseButton = fixture.debugElement.query(By.css('[data-automation-id="content-node-selector-actions-choose"]'));
expect(chooseButton.nativeElement.disabled).toBe(true);
});
});
describe('Mini integration test', () => {
xit('should trigger the select event properly when search results are loaded, one element is selected and choose button is clicked', () => {
});
});
});
});

View File

@@ -0,0 +1,152 @@
/*!
* @license
* Copyright 2016 Alfresco Software, Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { Component, EventEmitter, Inject, Input, Optional, Output, ViewEncapsulation } from '@angular/core';
import { MD_DIALOG_DATA, MdDialogRef } from '@angular/material';
import { MinimalNodeEntryEntity, NodePaging } from 'alfresco-js-api';
import { AlfrescoTranslationService, SearchOptions, SearchService, SiteModel } from 'ng2-alfresco-core';
export interface ContentNodeSelectorComponentData {
title: string;
select: EventEmitter<MinimalNodeEntryEntity>;
}
@Component({
selector: 'adf-content-node-selector',
styleUrls: ['./content-node-selector.component.scss'],
templateUrl: './content-node-selector.component.html',
encapsulation: ViewEncapsulation.None
})
export class ContentNodeSelectorComponent {
nodes: NodePaging|Array<any>;
siteId: null|string;
searchTerm: string = '';
searched: boolean = false;
inDialog: boolean = false;
chosenNode: MinimalNodeEntryEntity | null = null;
@Input()
title: string;
@Output()
select: EventEmitter<MinimalNodeEntryEntity> = new EventEmitter<MinimalNodeEntryEntity>();
constructor(private searchService: SearchService,
@Optional() private translateService: AlfrescoTranslationService,
@Optional() @Inject(MD_DIALOG_DATA) public data?: ContentNodeSelectorComponentData,
@Optional() private containingDialog?: MdDialogRef<ContentNodeSelectorComponent>) {
if (translateService) {
translateService.addTranslationFolder('ng2-alfresco-documentlist', 'assets/ng2-alfresco-documentlist');
}
if (data) {
this.title = data.title;
this.select = data.select;
}
if (containingDialog) {
this.inDialog = true;
}
}
/**
* Updates the site attribute and starts a new search
*
* @param chosenSite Sitemodel to search within
*/
siteChanged(chosenSite: SiteModel): void {
this.siteId = chosenSite.guid;
this.querySearch();
}
/**
* Updates the searchTerm attribute and starts a new search
*
* @param searchTerm string value to search against
*/
search(searchTerm: string): void {
this.searchTerm = searchTerm;
this.querySearch();
}
/**
* Clear the search input
*/
clear(): void {
this.searched = false;
this.searchTerm = '';
this.nodes = [];
this.chosenNode = null;
}
/**
* Perform the call to searchService with the proper parameters
*/
private querySearch(): void {
if (this.searchTerm.length > 3) {
const searchTerm = this.searchTerm + '*';
let searchOpts: SearchOptions = {
include: ['path'],
skipCount: 0,
rootNodeId: this.siteId,
nodeType: 'cm:folder',
maxItems: 40,
orderBy: null
};
this.searchService
.getNodeQueryResults(searchTerm, searchOpts)
.subscribe(
results => {
this.searched = true;
this.nodes = results;
}
);
}
}
/**
* Invoked when user selects a node
*
* @param event CustomEvent for node-select
*/
onNodeSelect(event: any): void {
this.chosenNode = event.detail.node.entry;
}
/**
* * Invoked when user unselects a node
*/
onNodeUnselect(): void {
this.chosenNode = null;
}
/**
* Emit event with the chosen node
*/
choose(): void {
this.select.next(this.chosenNode);
}
/**
* Close the dialog
*/
close(): void {
this.containingDialog.close();
}
}

View File

@@ -1,6 +1,7 @@
<div id="site-dropdown-container" class="adf-site-dropdown-container">
<md-select class="adf-site-dropdown-list-element" id="site-dropdown"
placeholder="{{'DROPDOWN.PLACEHOLDER_LABEL' | translate}}"
floatPlaceholder="never"
[(ngModel)]="siteSelected"
(ngModelChange)="selectedSite()">
<md-option id="default_site_option" [value]="DEFAULT_VALUE">{{'DROPDOWN.DEFAULT_OPTION' | translate}}</md-option>

View File

@@ -1,10 +1,7 @@
@import 'theming';
.adf-site-dropdown {
&-list-element {
width: 300px;
}
}

View File

@@ -15,5 +15,26 @@
"DROPDOWN": {
"PLACEHOLDER_LABEL": "Site List",
"DEFAULT_OPTION": "No Site Chosen"
},
"NODE_SELECTOR": {
"CANCEL": "Cancel",
"CHOOSE": "Choose",
"NO_RESULTS": "No results found"
},
"OPERATION": {
"SUCCES": {
"CONTENT": {
"COPY": "Content was copied successfully.",
"MOVE": "Content was moved successfully."
},
"FOLDER": {
"COPY": "Folder was copied successfully.",
"MOVE": "Folder was moved successfully."
}
},
"ERROR": {
"CONFLICT": "Name already exists in target location.",
"UNKNOWN": "Unknown error happened."
}
}
}

View File

@@ -16,15 +16,18 @@
*/
import { NgModule } from '@angular/core';
import { MdButtonModule, MdIconModule, MdMenuModule, MdProgressSpinnerModule, MdSelectModule } from '@angular/material';
import { MdButtonModule, MdDialogModule, MdIconModule, MdInputModule, MdMenuModule, MdProgressSpinnerModule, MdRippleModule, MdSelectModule } from '@angular/material';
export function modules() {
return [
MdMenuModule,
MdDialogModule,
MdButtonModule,
MdIconModule,
MdInputModule,
MdProgressSpinnerModule,
MdSelectModule
MdSelectModule,
MdRippleModule
];
}

View File

@@ -15,23 +15,32 @@
* limitations under the License.
*/
import { AlfrescoContentService } from 'ng2-alfresco-core';
import { MdDialog } from '@angular/material';
import { TranslateService } from '@ngx-translate/core';
import { AlfrescoContentService, AlfrescoTranslationService, NotificationService } from 'ng2-alfresco-core';
import { FileNode, FolderNode } from '../assets/document-library.model.mock';
import { DocumentListServiceMock } from '../assets/document-list.service.mock';
import { ContentActionHandler } from '../models/content-action.model';
import { DocumentActionsService } from './document-actions.service';
import { DocumentListService } from './document-list.service';
import { NodeActionsService } from './node-actions.service';
describe('DocumentActionsService', () => {
let service: DocumentActionsService;
let documentListService: DocumentListService;
let contentService: AlfrescoContentService;
let translateService: AlfrescoTranslationService;
let notificationService: NotificationService;
let nodeActionsService: NodeActionsService;
beforeEach(() => {
documentListService = new DocumentListServiceMock();
contentService = new AlfrescoContentService(null, null, null);
service = new DocumentActionsService(documentListService, contentService);
translateService = <AlfrescoTranslationService> { addTranslationFolder: () => {}};
nodeActionsService = new NodeActionsService(null, translateService, null, null);
notificationService = new NotificationService(null);
service = new DocumentActionsService(translateService, notificationService, nodeActionsService, documentListService, contentService);
});
it('should register default download action', () => {
@@ -63,7 +72,7 @@ describe('DocumentActionsService', () => {
let file = new FileNode();
expect(service.canExecuteAction(file)).toBeTruthy();
service = new DocumentActionsService(null);
service = new DocumentActionsService(translateService, notificationService, nodeActionsService);
expect(service.canExecuteAction(file)).toBeFalsy();
});
@@ -80,23 +89,6 @@ describe('DocumentActionsService', () => {
expect(service.setHandler('my-handler', handler)).toBeTruthy();
});
// TODO: to be removed once demo handlers are removed
it('should execute demo actions', () => {
spyOn(window, 'alert').and.stub();
service.getHandler('system1')(null);
expect(window.alert).toHaveBeenCalledWith('standard document action 1');
service.getHandler('system2')(null);
expect(window.alert).toHaveBeenCalledWith('standard document action 2');
});
// TODO: to be removed once demo handlers are removed
it('should register demo handlers', () => {
expect(service.getHandler('system1')).toBeDefined();
expect(service.getHandler('system2')).toBeDefined();
});
it('should register delete action', () => {
expect(service.getHandler('delete')).toBeDefined();
});
@@ -201,7 +193,7 @@ describe('DocumentActionsService', () => {
});
it('should require internal service for download action', () => {
let actionService = new DocumentActionsService(null, contentService);
let actionService = new DocumentActionsService(translateService, notificationService, nodeActionsService, null, contentService);
let file = new FileNode();
let result = actionService.getHandler('download')(file);
result.subscribe((value) => {
@@ -210,7 +202,7 @@ describe('DocumentActionsService', () => {
});
it('should require content service for download action', () => {
let actionService = new DocumentActionsService(documentListService, null);
let actionService = new DocumentActionsService(translateService, notificationService, nodeActionsService, documentListService, null);
let file = new FileNode();
let result = actionService.getHandler('download')(file);
result.subscribe((value) => {

View File

@@ -16,12 +16,15 @@
*/
import { Injectable } from '@angular/core';
import { AlfrescoContentService } from 'ng2-alfresco-core';
import { MinimalNodeEntity } from 'alfresco-js-api';
import { AlfrescoContentService, AlfrescoTranslationService, NotificationService } from 'ng2-alfresco-core';
import { Observable } from 'rxjs/Observable';
import { Subject } from 'rxjs/Rx';
import { ContentNodeSelectorComponent } from '../components/content-node-selector/content-node-selector.component';
import { ContentActionHandler } from '../models/content-action.model';
import { PermissionModel } from '../models/permissions.model';
import { DocumentListService } from './document-list.service';
import { NodeActionsService } from './node-actions.service';
@Injectable()
export class DocumentActionsService {
@@ -30,9 +33,15 @@ export class DocumentActionsService {
private handlers: { [id: string]: ContentActionHandler; } = {};
constructor(private documentListService?: DocumentListService,
constructor(private translateService: AlfrescoTranslationService,
private notificationService: NotificationService,
private nodeActionsService: NodeActionsService,
private documentListService?: DocumentListService,
private contentService?: AlfrescoContentService) {
this.setupActionHandlers();
if (translateService) {
translateService.addTranslationFolder('ng2-alfresco-documentlist', 'assets/ng2-alfresco-documentlist');
}
}
getHandler(key: string): ContentActionHandler {
@@ -58,35 +67,9 @@ export class DocumentActionsService {
private setupActionHandlers() {
this.handlers['download'] = this.download.bind(this);
this.handlers['copy'] = this.copyNode.bind(this);
this.handlers['move'] = this.moveNode.bind(this);
this.handlers['delete'] = this.deleteNode.bind(this);
// TODO: for demo purposes only, will be removed during future revisions
this.handlers['system1'] = this.handleStandardAction1.bind(this);
this.handlers['system2'] = this.handleStandardAction2.bind(this);
}
// TODO: for demo purposes only, will be removed during future revisions
/**
* @deprecated in 1.7.0
*
* @private
* @memberof DocumentActionsService
*/
private handleStandardAction1(/*obj: any*/) {
console.log('handleStandardAction1 is deprecated in 1.7.0 and will be removed in future versions');
window.alert('standard document action 1');
}
// TODO: for demo purposes only, will be removed during future revisions
/**
* @deprecated in 1.7.0
*
* @private
* @memberof DocumentActionsService
*/
private handleStandardAction2(/*obj: any*/) {
console.log('handleStandardAction2 is deprecated in 1.7.0 and will be removed in future versions');
window.alert('standard document action 2');
}
private download(obj: any): Observable<boolean> {
@@ -102,6 +85,43 @@ export class DocumentActionsService {
return Observable.of(false);
}
private copyNode(obj: MinimalNodeEntity, target?: any, permission?: string) {
const actionObservable = this.nodeActionsService.copyContent(obj.entry, permission);
this.prepareHandlers(actionObservable, 'content', 'copy', target, permission);
return actionObservable;
}
private moveNode(obj: MinimalNodeEntity, target?: any, permission?: string) {
const actionObservable = this.nodeActionsService.moveContent(obj.entry, permission);
this.prepareHandlers(actionObservable, 'content', 'move', target, permission);
return actionObservable;
}
private prepareHandlers(actionObservable, type: string, action: string, target?: any, permission?: string): void {
actionObservable.subscribe(
(fileOperationMessage) => {
this.notificationService.openSnackMessage(fileOperationMessage, 3000);
if (target && typeof target.reload === 'function') {
target.reload();
}
},
(errorStatusCode) => {
switch (errorStatusCode) {
case 403:
this.permissionEvent.next(new PermissionModel({type, action, permission}));
break;
case 409:
let conflictError: any = this.translateService.get('OPERATION.ERROR.CONFLICT');
this.notificationService.openSnackMessage(conflictError.value, 3000);
break;
default:
let unknownError: any = this.translateService.get('OPERATION.ERROR.UNKNOWN');
this.notificationService.openSnackMessage(unknownError.value, 3000);
}
}
);
}
private deleteNode(obj: any, target?: any, permission?: string): Observable<any> {
let handlerObservable;

View File

@@ -177,4 +177,24 @@ describe('DocumentListService', () => {
contentType: 'json'
});
});
it('should copy a node', (done) => {
service.copyNode('node-id', 'parent-id').subscribe(done);
expect(jasmine.Ajax.requests.mostRecent().method).toBe('POST');
expect(jasmine.Ajax.requests.mostRecent().url).toContain('/nodes/node-id/copy');
expect(jasmine.Ajax.requests.mostRecent().params).toEqual(JSON.stringify({ targetParentId: 'parent-id' }));
jasmine.Ajax.requests.mostRecent().respondWith({ status: 200, contentType: 'json' });
});
it('should move a node', (done) => {
service.moveNode('node-id', 'parent-id').subscribe(done);
expect(jasmine.Ajax.requests.mostRecent().method).toBe('POST');
expect(jasmine.Ajax.requests.mostRecent().url).toContain('/nodes/node-id/move');
expect(jasmine.Ajax.requests.mostRecent().params).toEqual(JSON.stringify({ targetParentId: 'parent-id' }));
jasmine.Ajax.requests.mostRecent().respondWith({ status: 200, contentType: 'json' });
});
});

View File

@@ -65,6 +65,28 @@ export class DocumentListService {
return Observable.fromPromise(this.apiService.getInstance().nodes.deleteNode(nodeId));
}
/**
* Copy a node to destination node
*
* @param nodeId The id of the node to be copied
* @param targetParentId The id of the folder-node where the node have to be copied to
*/
copyNode(nodeId: string, targetParentId: string) {
return Observable.fromPromise(this.apiService.getInstance().nodes.copyNode(nodeId, { targetParentId }))
.catch(err => this.handleError(err));
}
/**
* Move a node to destination node
*
* @param nodeId The id of the node to be moved
* @param targetParentId The id of the folder-node where the node have to be moved to
*/
moveNode(nodeId: string, targetParentId: string) {
return Observable.fromPromise(this.apiService.getInstance().nodes.moveNode(nodeId, { targetParentId }))
.catch(err => this.handleError(err));
}
/**
* Create a new folder in the path.
* @param name Folder name

View File

@@ -16,12 +16,13 @@
*/
import { async, TestBed } from '@angular/core/testing';
import { AppConfigModule, CoreModule } from 'ng2-alfresco-core';
import { AlfrescoTranslationService, AppConfigModule, CoreModule, NotificationService } from 'ng2-alfresco-core';
import { Observable } from 'rxjs/Rx';
import { FileNode, FolderNode } from '../assets/document-library.model.mock';
import { ContentActionHandler } from '../models/content-action.model';
import { DocumentListService } from './document-list.service';
import { FolderActionsService } from './folder-actions.service';
import { NodeActionsService } from './node-actions.service';
describe('FolderActionsService', () => {
@@ -38,7 +39,10 @@ describe('FolderActionsService', () => {
],
providers: [
DocumentListService,
FolderActionsService
FolderActionsService,
NodeActionsService,
AlfrescoTranslationService,
NotificationService
]
}).compileComponents();
}));
@@ -90,23 +94,6 @@ describe('FolderActionsService', () => {
expect(service.setHandler('my-handler', handler)).toBeTruthy();
});
// TODO: to be removed once demo handlers are removed
it('should execute demo actions', () => {
spyOn(window, 'alert').and.stub();
service.getHandler('system1')(null);
expect(window.alert).toHaveBeenCalledWith('standard folder action 1');
service.getHandler('system2')(null);
expect(window.alert).toHaveBeenCalledWith('standard folder action 2');
});
// TODO: to be removed once demo handlers are removed
it('should register demo handlers', () => {
expect(service.getHandler('system1')).toBeDefined();
expect(service.getHandler('system2')).toBeDefined();
});
it('should register delete action', () => {
expect(service.getHandler('delete')).toBeDefined();
});

View File

@@ -16,11 +16,13 @@
*/
import { Injectable } from '@angular/core';
import { AlfrescoContentService } from 'ng2-alfresco-core';
import { MinimalNodeEntity } from 'alfresco-js-api';
import { AlfrescoContentService, AlfrescoTranslationService, NotificationService } from 'ng2-alfresco-core';
import { Observable, Subject } from 'rxjs/Rx';
import { ContentActionHandler } from '../models/content-action.model';
import { PermissionModel } from '../models/permissions.model';
import { DocumentListService } from './document-list.service';
import { NodeActionsService } from './node-actions.service';
@Injectable()
export class FolderActionsService {
@@ -29,7 +31,10 @@ export class FolderActionsService {
private handlers: { [id: string]: ContentActionHandler; } = {};
constructor(private documentListService: DocumentListService,
constructor(private translateService: AlfrescoTranslationService,
private notificationService: NotificationService,
private nodeActionsService: NodeActionsService,
private documentListService: DocumentListService,
private contentService: AlfrescoContentService) {
this.setupActionHandlers();
}
@@ -56,36 +61,46 @@ export class FolderActionsService {
}
private setupActionHandlers() {
this.handlers['copy'] = this.copyNode.bind(this);
this.handlers['move'] = this.moveNode.bind(this);
this.handlers['delete'] = this.deleteNode.bind(this);
// TODO: for demo purposes only, will be removed during future revisions
this.handlers['system1'] = this.handleStandardAction1.bind(this);
this.handlers['system2'] = this.handleStandardAction2.bind(this);
}
// TODO: for demo purposes only, will be removed during future revisions
/**
* @deprecated in 1.7.0
*
* @private
* @param {*} document
* @memberof FolderActionsService
*/
private handleStandardAction1(/*document: any*/) {
console.log('handleStandardAction1 is deprecated in 1.7.0 and will be removed in future versions');
window.alert('standard folder action 1');
private copyNode(obj: MinimalNodeEntity, target?: any, permission?: string) {
const actionObservable = this.nodeActionsService.copyFolder(obj.entry, permission);
this.prepareHandlers(actionObservable, 'folder', 'copy', target, permission);
return actionObservable;
}
// TODO: for demo purposes only, will be removed during future revisions
/**
* @deprecated in 1.7.0
*
* @private
* @memberof FolderActionsService
*/
private handleStandardAction2(/*document: any*/) {
console.log('handleStandardAction1 is deprecated in 1.7.0 and will be removed in future versions');
window.alert('standard folder action 2');
private moveNode(obj: MinimalNodeEntity, target?: any, permission?: string) {
const actionObservable = this.nodeActionsService.moveFolder(obj.entry, permission);
this.prepareHandlers(actionObservable, 'folder', 'move', target, permission);
return actionObservable;
}
private prepareHandlers(actionObservable, type: string, action: string, target?: any, permission?: string): void {
actionObservable.subscribe(
(fileOperationMessage) => {
this.notificationService.openSnackMessage(fileOperationMessage, 3000);
if (target && typeof target.reload === 'function') {
target.reload();
}
},
(errorStatusCode) => {
switch (errorStatusCode) {
case 403:
this.permissionEvent.next(new PermissionModel({type, action, permission}));
break;
case 409:
let conflictError: any = this.translateService.get('OPERATION.ERROR.CONFLICT');
this.notificationService.openSnackMessage(conflictError.value, 3000);
break;
default:
let unknownError: any = this.translateService.get('OPERATION.ERROR.UNKNOWN');
this.notificationService.openSnackMessage(unknownError.value, 3000);
}
}
);
}
private deleteNode(obj: any, target?: any, permission?: string): Observable<any> {

View File

@@ -0,0 +1,121 @@
/*!
* @license
* Copyright 2016 Alfresco Software, Ltd.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { EventEmitter, Injectable } from '@angular/core';
import { MdDialog } from '@angular/material';
import { MinimalNodeEntryEntity } from 'alfresco-js-api';
import { AlfrescoContentService, AlfrescoTranslationService } from 'ng2-alfresco-core';
import { Observable } from 'rxjs/Observable';
import { Subject } from 'rxjs/Rx';
import { ContentNodeSelectorComponent } from '../components/content-node-selector/content-node-selector.component';
import { DocumentListService } from './document-list.service';
@Injectable()
export class NodeActionsService {
constructor(private dialog: MdDialog,
private translateService: AlfrescoTranslationService,
private documentListService?: DocumentListService,
private contentService?: AlfrescoContentService) {
if (translateService) {
translateService.addTranslationFolder('ng2-alfresco-documentlist', 'assets/ng2-alfresco-documentlist');
}
}
/**
* Copy content node
*
* @param contentEntry node to copy
* @param permission permission which is needed to apply the action
*/
public copyContent(contentEntry: MinimalNodeEntryEntity, permission?: string): Subject<string> {
return this.doFileOperation('copy', 'content', contentEntry, permission);
}
/**
* Copy folder node
*
* @param contentEntry node to copy
* @param permission permission which is needed to apply the action
*/
public copyFolder(contentEntry: MinimalNodeEntryEntity, permission?: string): Subject<string> {
return this.doFileOperation('copy', 'folder', contentEntry, permission);
}
/**
* Move content node
*
* @param contentEntry node to move
* @param permission permission which is needed to apply the action
*/
public moveContent(contentEntry: MinimalNodeEntryEntity, permission?: string): Subject<string> {
return this.doFileOperation('move', 'content', contentEntry, permission);
}
/**
* Move folder node
*
* @param contentEntry node to move
* @param permission permission which is needed to apply the action
*/
public moveFolder(contentEntry: MinimalNodeEntryEntity, permission?: string): Subject<string> {
return this.doFileOperation('move', 'folder', contentEntry, permission);
}
/**
* General method for performing the given operation (copy|move)
*
* @param action the action to perform (copy|move)
* @param type type of the content (content|folder)
* @param contentEntry the contentEntry which has to have the action performed on
* @param permission permission which is needed to apply the action
*/
private doFileOperation(action: string, type: string, contentEntry: MinimalNodeEntryEntity, permission?: string): Subject<string> {
const observable: Subject<string> = new Subject<string>();
if (this.contentService.hasPermission(contentEntry, permission)) {
const title = `${action} ${contentEntry.name} to ...`,
select: EventEmitter<MinimalNodeEntryEntity> = new EventEmitter<MinimalNodeEntryEntity>();
this.dialog.open(ContentNodeSelectorComponent, {
data: { title, select },
panelClass: 'adf-content-node-selector-dialog',
width: '576px'
});
select.subscribe((parent: MinimalNodeEntryEntity) => {
this.documentListService[`${action}Node`].call(this.documentListService, contentEntry.id, parent.id)
.subscribe(
() => {
let fileOperationMessage: any = this.translateService.get(`OPERATION.SUCCES.${type.toUpperCase()}.${action.toUpperCase()}`);
observable.next(fileOperationMessage.value);
},
(errors) => {
const errorStatusCode = JSON.parse(errors.message).error.statusCode;
observable.error(errorStatusCode);
}
);
this.dialog.closeAll();
});
return observable;
} else {
observable.error(403);
return observable;
}
}
}