[ACA-2193] Lock node - unlock after new version is uploaded (#924)

* unlock node api call

* unlock action and effect

* unlock node after version upload

* check if locked

* clear version input on dialog cancel event

* update viewer on node version upload

* update viewer on file upload delete

* test

* update tests

* update tests

* rename evaluators

* update docs
This commit is contained in:
Cilibiu Bogdan 2019-02-10 15:56:02 +02:00 committed by Denys Vuika
parent 65e0a1138c
commit 894a928187
13 changed files with 278 additions and 54 deletions

View File

@ -76,42 +76,44 @@ and perform document list reload if needed.
Below is the list of public actions types you can use in the plugin definitions as a reference to the action:
| Name | Payload | Description |
| -- | -- | -- |
| SET_CURRENT_FOLDER | Node | Notify components about currently opened folder. |
| SET_CURRENT_URL | string | Notify components about current browser URL. |
| SET_USER_PROFILE | Person | Assign current user profile. |
| TOGGLE_INFO_DRAWER | n/a | Toggle info drawer for the selected node. |
| ADD_FAVORITE | MinimalNodeEntity[] | Add nodes (or selection) to favorites. |
| REMOVE_FAVORITE | MinimalNodeEntity[] | Removes nodes (or selection) from favorites. |
| DELETE_LIBRARY | string | Delete a Library by id. Takes selected node if payload not provided. |
| CREATE_LIBRARY | n/a | Invoke a "Create Library" dialog. |
| SET_SELECTED_NODES | MinimalNodeEntity[] | Notify components about selected nodes. |
| DELETE_NODES | MinimalNodeEntity[] | Delete the nodes (or selection). Supports undo actions. |
| UNDO_DELETE_NODES | any[] | Reverts deletion of nodes (or selection). |
| RESTORE_DELETED_NODES | MinimalNodeEntity[] | Restores deleted nodes (or selection). Typically used with Trashcan. |
| PURGE_DELETED_NODES | MinimalNodeEntity[] | Permanently delete nodes (or selection). Typically used with Trashcan. |
| DOWNLOAD_NODES | MinimalNodeEntity[] | Download nodes (or selections). Creates a ZIP archive for folders or multiple items. |
| CREATE_FOLDER | string | Invoke a "Create Folder" dialog for the opened folder (or the parent folder id in the payload). |
| EDIT_FOLDER | MinimalNodeEntity | Invoke an "Edit Folder" dialog for the node (or selection). |
| SHARE_NODE | MinimalNodeEntity | Invoke a "Share" dialog for the node (or selection). |
| UNSHARE_NODES | MinimalNodeEntity[] | Remove nodes (or selection) from the shared nodes (does not remove content). |
| COPY_NODES | MinimalNodeEntity[] | Invoke a "Copy" dialog for the nodes (or selection). Supports undo actions. |
| MOVE_NODES | MinimalNodeEntity[] | Invoke a "Move" dialog for the nodes (or selection). Supports undo actions. |
| MANAGE_PERMISSIONS | MinimalNodeEntity | Invoke a "Manage Permissions" dialog for the node (or selection). |
| MANAGE_VERSIONS | MinimalNodeEntity | Invoke a "Manage Versions" dialog for the node (or selection). |
| NAVIGATE_URL | string | Navigate to a given route URL within the application. |
| NAVIGATE_ROUTE | any[] | Navigate to a particular Route (supports parameters). |
| NAVIGATE_FOLDER | MinimalNodeEntity | Navigate to a folder based on the Node properties. |
| NAVIGATE_PARENT_FOLDER | MinimalNodeEntity | Navigate to a containing folder based on the Node properties. |
| NAVIGATE_LIBRARY | string | Navigate to library. |
| SEARCH_BY_TERM | string | Perform a simple search by the term and navigate to Search results. |
| SNACKBAR_INFO | string | Show information snackbar with the message provided. |
| SNACKBAR_WARNING | string | Show warning snackbar with the message provided. |
| SNACKBAR_ERROR | string | Show error snackbar with the message provided. |
| UPLOAD_FILES | n/a | Invoke "Upload Files" dialog and upload files to the currently opened folder. |
| UPLOAD_FOLDER | n/a | Invoke "Upload Folder" dialog and upload selected folder to the currently opened one. |
| VIEW_FILE | MinimalNodeEntity | Preview the file (or selection) in the Viewer. |
| PRINT_FILE | MinimalNodeEntity | Print the file opened in the Viewer (or selected). |
| FULLSCREEN_VIEWER | n/a | Enters fullscreen mode to view the file opened in the Viewer. |
| LOGOUT | n/a | Log out and redirect to Login screen. |
| Name | Payload | Description |
| ---------------------- | ------------------- | ----------------------------------------------------------------------------------------------- |
| SET_CURRENT_FOLDER | Node | Notify components about currently opened folder. |
| SET_CURRENT_URL | string | Notify components about current browser URL. |
| SET_USER_PROFILE | Person | Assign current user profile. |
| TOGGLE_INFO_DRAWER | n/a | Toggle info drawer for the selected node. |
| ADD_FAVORITE | MinimalNodeEntity[] | Add nodes (or selection) to favorites. |
| REMOVE_FAVORITE | MinimalNodeEntity[] | Removes nodes (or selection) from favorites. |
| DELETE_LIBRARY | string | Delete a Library by id. Takes selected node if payload not provided. |
| CREATE_LIBRARY | n/a | Invoke a "Create Library" dialog. |
| SET_SELECTED_NODES | MinimalNodeEntity[] | Notify components about selected nodes. |
| DELETE_NODES | MinimalNodeEntity[] | Delete the nodes (or selection). Supports undo actions. |
| UNDO_DELETE_NODES | any[] | Reverts deletion of nodes (or selection). |
| RESTORE_DELETED_NODES | MinimalNodeEntity[] | Restores deleted nodes (or selection). Typically used with Trashcan. |
| PURGE_DELETED_NODES | MinimalNodeEntity[] | Permanently delete nodes (or selection). Typically used with Trashcan. |
| DOWNLOAD_NODES | MinimalNodeEntity[] | Download nodes (or selections). Creates a ZIP archive for folders or multiple items. |
| CREATE_FOLDER | string | Invoke a "Create Folder" dialog for the opened folder (or the parent folder id in the payload). |
| EDIT_FOLDER | MinimalNodeEntity | Invoke an "Edit Folder" dialog for the node (or selection). |
| SHARE_NODE | MinimalNodeEntity | Invoke a "Share" dialog for the node (or selection). |
| UNSHARE_NODES | MinimalNodeEntity[] | Remove nodes (or selection) from the shared nodes (does not remove content). |
| COPY_NODES | MinimalNodeEntity[] | Invoke a "Copy" dialog for the nodes (or selection). Supports undo actions. |
| MOVE_NODES | MinimalNodeEntity[] | Invoke a "Move" dialog for the nodes (or selection). Supports undo actions. |
| MANAGE_PERMISSIONS | MinimalNodeEntity | Invoke a "Manage Permissions" dialog for the node (or selection). |
| MANAGE_VERSIONS | MinimalNodeEntity | Invoke a "Manage Versions" dialog for the node (or selection). |
| NAVIGATE_URL | string | Navigate to a given route URL within the application. |
| NAVIGATE_ROUTE | any[] | Navigate to a particular Route (supports parameters). |
| NAVIGATE_FOLDER | MinimalNodeEntity | Navigate to a folder based on the Node properties. |
| NAVIGATE_PARENT_FOLDER | MinimalNodeEntity | Navigate to a containing folder based on the Node properties. |
| NAVIGATE_LIBRARY | string | Navigate to library. |
| SEARCH_BY_TERM | string | Perform a simple search by the term and navigate to Search results. |
| SNACKBAR_INFO | string | Show information snackbar with the message provided. |
| SNACKBAR_WARNING | string | Show warning snackbar with the message provided. |
| SNACKBAR_ERROR | string | Show error snackbar with the message provided. |
| UPLOAD_FILES | n/a | Invoke "Upload Files" dialog and upload files to the currently opened folder. |
| UPLOAD_FOLDER | n/a | Invoke "Upload Folder" dialog and upload selected folder to the currently opened one. |
| UPLOAD_FILE_VERSION | n/a | Invoke "New File Version" dialog. |
| VIEW_FILE | MinimalNodeEntity | Preview the file (or selection) in the Viewer. |
| UNLOCK_WRITE | NodeEntry | Unlock file from read only mode |
| PRINT_FILE | MinimalNodeEntity | Print the file opened in the Viewer (or selected). |
| FULLSCREEN_VIEWER | n/a | Enters fullscreen mode to view the file opened in the Viewer. |
| LOGOUT | n/a | Log out and redirect to Login screen. |

View File

@ -148,6 +148,9 @@ The button will be visible only when the linked rule evaluates to `true`.
| app.selection.hasNoLibraryRole | The selected Library node has no role property. |
| app.selection.folder | A single Folder node is selected. |
| app.selection.folder.canUpdate | User has permissions to update the selected folder. |
| app.selection.folder.canUpdate | User has permissions to update the selected folder. |
| app.selection.file.canLock | User has permissions to lock file. |
| app.selection.file.canUnlock | User has permissions to unlock file. |
| repository.isQuickShareEnabled | Whether the quick share repository option is enabled or not. |
## Navigation Evaluators
@ -176,6 +179,8 @@ for example mixing `core.every` and `core.not`.
| app.navigation.isNotRecentFiles | Current page is not **Recent Files**. |
| app.navigation.isSearchResults | User is using the **Search Results** page. |
| app.navigation.isNotSearchResults | Current page is not the **Search Results**. |
| app.navigation.isSharedPreview | Current page is preview **Shared Files** |
| app.navigation.isFavoritesPreview | Current page is preview **Favorites** |
**Tip:** See the [Registration](/extending/registration) section for more details
on how to register your own entries to be re-used at runtime.

View File

@ -25,11 +25,19 @@
import { NO_ERRORS_SCHEMA } from '@angular/core';
import { Router, ActivatedRoute } from '@angular/router';
import { TestBed, ComponentFixture } from '@angular/core/testing';
import {
TestBed,
ComponentFixture,
async,
fakeAsync,
tick
} from '@angular/core/testing';
import {
UserPreferencesService,
AppConfigPipe,
NodeFavoriteDirective
NodeFavoriteDirective,
UploadService,
AlfrescoApiService
} from '@alfresco/adf-core';
import { PreviewComponent } from './preview.component';
import { of, throwError } from 'rxjs';
@ -38,6 +46,7 @@ import { ExperimentalDirective } from '../../directives/experimental.directive';
import { NodeEffects } from '../../store/effects/node.effects';
import { AppTestingModule } from '../../testing/app-testing.module';
import { ContentApiService } from '../../services/content-api.service';
import { ContentManagementService } from '../../services/content-management.service';
describe('PreviewComponent', () => {
let fixture: ComponentFixture<PreviewComponent>;
@ -46,10 +55,14 @@ describe('PreviewComponent', () => {
let route: ActivatedRoute;
let preferences: UserPreferencesService;
let contentApi: ContentApiService;
let uploadService: UploadService;
let alfrescoApiService: AlfrescoApiService;
let contentManagementService: ContentManagementService;
beforeEach(() => {
TestBed.configureTestingModule({
imports: [AppTestingModule, EffectsModule.forRoot([NodeEffects])],
providers: [AlfrescoApiService, ContentManagementService],
declarations: [
AppConfigPipe,
PreviewComponent,
@ -66,6 +79,9 @@ describe('PreviewComponent', () => {
route = TestBed.get(ActivatedRoute);
preferences = TestBed.get(UserPreferencesService);
contentApi = TestBed.get(ContentApiService);
uploadService = TestBed.get(UploadService);
alfrescoApiService = TestBed.get(AlfrescoApiService);
contentManagementService = TestBed.get(ContentManagementService);
});
it('should extract the property path root', () => {
@ -697,4 +713,29 @@ describe('PreviewComponent', () => {
const ids = await component.getFileIds('recent-files');
expect(ids).toEqual(['node2', 'node1']);
});
it('should return to parent folder on nodesDeleted event', async(() => {
spyOn(component, 'navigateToFileLocation');
fixture.detectChanges();
contentManagementService.nodesDeleted.next();
expect(component.navigateToFileLocation).toHaveBeenCalled();
}));
it('should return to parent folder on fileUploadDeleted event', async(() => {
spyOn(component, 'navigateToFileLocation');
fixture.detectChanges();
uploadService.fileUploadDeleted.next();
expect(component.navigateToFileLocation).toHaveBeenCalled();
}));
it('should emit nodeUpdated event on fileUploadComplete event', fakeAsync(() => {
spyOn(alfrescoApiService.nodeUpdated, 'next');
fixture.detectChanges();
uploadService.fileUploadComplete.next(<any>{ data: { entry: {} } });
tick(300);
expect(alfrescoApiService.nodeUpdated.next).toHaveBeenCalled();
}));
});

View File

@ -38,7 +38,13 @@ import {
UrlSegment,
PRIMARY_OUTLET
} from '@angular/router';
import { UserPreferencesService, ObjectUtils } from '@alfresco/adf-core';
import { debounceTime } from 'rxjs/operators';
import {
UserPreferencesService,
ObjectUtils,
UploadService,
AlfrescoApiService
} from '@alfresco/adf-core';
import { Store } from '@ngrx/store';
import { AppStore } from '../../store/states/app.state';
import { SetSelectedNodesAction } from '../../store/actions';
@ -84,6 +90,8 @@ export class PreviewComponent extends PageComponent
private appDataService: AppDataService,
private route: ActivatedRoute,
private router: Router,
private apiService: AlfrescoApiService,
private uploadService: UploadService,
store: Store<AppStore>,
extensions: AppExtensionService,
content: ContentManagementService
@ -122,7 +130,15 @@ export class PreviewComponent extends PageComponent
this.subscriptions = this.subscriptions.concat([
this.content.nodesDeleted.subscribe(() =>
this.navigateToFileLocation(true)
)
),
this.uploadService.fileUploadDeleted.subscribe(() =>
this.navigateToFileLocation(true)
),
this.uploadService.fileUploadComplete
.pipe(debounceTime(300))
.subscribe(file => this.apiService.nodeUpdated.next(file.data.entry))
]);
this.openWith = this.extensions.openWithActions;

View File

@ -111,8 +111,8 @@ export class CoreExtensionsModule {
extensions.setEvaluators({
'app.selection.canDelete': app.canDeleteSelection,
'app.selection.canUnlockFile': app.canUnlockFile,
'app.selection.canLockFile': app.canLockFile,
'app.selection.file.canUnlock': app.canUnlockFile,
'app.selection.file.canLock': app.canLockFile,
'app.selection.canDownload': app.canDownloadSelection,
'app.selection.notEmpty': app.hasSelection,
'app.selection.canUnshare': app.canUnshareNodes,

View File

@ -281,4 +281,8 @@ export class ContentApiService {
)
);
}
unlockNode(nodeId: string, opts?) {
return this.api.nodesApi.unlockNode(nodeId, opts);
}
}

View File

@ -43,7 +43,8 @@ import {
MoveNodesAction,
CopyNodesAction,
ShareNodeAction,
SetSelectedNodesAction
SetSelectedNodesAction,
UnlockWriteAction
} from '../store/actions';
import { map } from 'rxjs/operators';
import { NodeEffects } from '../store/effects/node.effects';
@ -1567,4 +1568,34 @@ describe('ContentManagementService', () => {
);
}));
});
describe('Unlock Node', () => {
it('should unlock node', fakeAsync(() => {
spyOn(contentApi, 'unlockNode').and.returnValue(Promise.resolve({}));
store.dispatch(new UnlockWriteAction({ entry: { id: 'node-id' } }));
tick();
flush();
expect(contentApi.unlockNode).toHaveBeenCalled();
}));
it('should raise error when unlock node fails', fakeAsync(done => {
spyOn(contentApi, 'unlockNode').and.callFake(
() => new Promise((resolve, reject) => reject('error'))
);
spyOn(store, 'dispatch').and.callThrough();
store.dispatch(
new UnlockWriteAction({ entry: { id: 'node-id', name: 'some-file' } })
);
tick();
flush();
expect(store.dispatch['calls'].argsFor(1)[0]).toEqual(
new SnackbarErrorAction('APP.MESSAGES.ERRORS.UNLOCK_NODE', {
fileName: 'some-file'
})
);
}));
});
});

View File

@ -51,7 +51,8 @@ import {
SiteEntry,
DeletedNodesPaging,
PathInfoEntity,
SiteBody
SiteBody,
NodeEntry
} from '@alfresco/js-api';
import { NodePermissionService } from './node-permission.service';
import { NodeInfo, DeletedNodeInfo, DeleteStatus } from '../store/models';
@ -1217,4 +1218,14 @@ export class ContentManagementService {
})
);
}
unlockNode(node: NodeEntry) {
this.contentApi.unlockNode(node.entry.id).catch(() => {
this.store.dispatch(
new SnackbarErrorAction('APP.MESSAGES.ERRORS.UNLOCK_NODE', {
fileName: node.entry.name
})
);
});
}
}

View File

@ -43,6 +43,7 @@ export const PRINT_FILE = 'PRINT_FILE';
export const FULLSCREEN_VIEWER = 'FULLSCREEN_VIEWER';
export const MANAGE_VERSIONS = 'MANAGE_VERSIONS';
export const EDIT_OFFLINE = 'EDIT_OFFLINE';
export const UNLOCK_WRITE = 'UNLOCK_WRITE_LOCK';
export class SetSelectedNodesAction implements Action {
readonly type = SET_SELECTED_NODES;
@ -128,3 +129,8 @@ export class EditOfflineAction implements Action {
readonly type = EDIT_OFFLINE;
constructor(public payload: any) {}
}
export class UnlockWriteAction implements Action {
readonly type = UNLOCK_WRITE;
constructor(public payload: any) {}
}

View File

@ -42,7 +42,10 @@ import {
EditFolderAction,
CopyNodesAction,
MoveNodesAction,
ManagePermissionsAction
ManagePermissionsAction,
UnlockWriteAction,
FullscreenViewerAction,
PrintFileAction
} from '../actions/node.actions';
import { SetCurrentFolderAction } from '../actions/app.actions';
@ -398,4 +401,62 @@ describe('NodeEffects', () => {
expect(contentService.managePermissions).not.toHaveBeenCalled();
});
});
describe('printFile$', () => {
it('it should print node content from payload', () => {
spyOn(contentService, 'printFile').and.stub();
const node: any = { entry: { id: 'node-id' } };
store.dispatch(new PrintFileAction(node));
expect(contentService.printFile).toHaveBeenCalledWith(node);
});
it('it should print node content from store', fakeAsync(() => {
spyOn(contentService, 'printFile').and.stub();
const node: any = { entry: { isFile: true, id: 'node-id' } };
store.dispatch(new SetSelectedNodesAction([node]));
tick(100);
store.dispatch(new PrintFileAction(null));
expect(contentService.printFile).toHaveBeenCalledWith(node);
}));
});
describe('fullscreenViewer$', () => {
it('should call fullscreen viewer', () => {
spyOn(contentService, 'fullscreenViewer').and.stub();
store.dispatch(new FullscreenViewerAction(null));
expect(contentService.fullscreenViewer).toHaveBeenCalled();
});
});
describe('unlockWrite$', () => {
it('should unlock node from payload', () => {
spyOn(contentService, 'unlockNode').and.stub();
const node: any = { entry: { id: 'node-id' } };
store.dispatch(new UnlockWriteAction(node));
expect(contentService.unlockNode).toHaveBeenCalledWith(node);
});
it('should unlock node from store selection', fakeAsync(() => {
spyOn(contentService, 'unlockNode').and.stub();
const node: any = { entry: { isFile: true, id: 'node-id' } };
store.dispatch(new SetSelectedNodesAction([node]));
tick(100);
store.dispatch(new UnlockWriteAction(null));
expect(contentService.unlockNode).toHaveBeenCalledWith(node);
}));
});
});

View File

@ -44,7 +44,9 @@ import {
ShareNodeAction,
SHARE_NODE,
ManageVersionsAction,
MANAGE_VERSIONS
MANAGE_VERSIONS,
UnlockWriteAction,
UNLOCK_WRITE
} from '../actions';
import { ContentManagementService } from '../../services/content-management.service';
import { currentFolder, appSelection } from '../selectors/app.selectors';
@ -316,4 +318,23 @@ export class NodeEffects {
this.contentService.fullscreenViewer();
})
);
@Effect({ dispatch: false })
unlockWrite$ = this.actions$.pipe(
ofType<UnlockWriteAction>(UNLOCK_WRITE),
map(action => {
if (action && action.payload) {
this.contentService.unlockNode(action.payload);
} else {
this.store
.select(appSelection)
.pipe(take(1))
.subscribe(selection => {
if (selection && selection.file) {
this.contentService.unlockNode(selection.file);
}
});
}
})
);
}

View File

@ -34,7 +34,8 @@ import {
UPLOAD_FOLDER,
UPLOAD_FILE_VERSION,
UploadFileVersionAction,
SnackbarErrorAction
SnackbarErrorAction,
UnlockWriteAction
} from '../actions';
import {
map,
@ -42,7 +43,9 @@ import {
flatMap,
distinctUntilChanged,
catchError,
switchMap
switchMap,
tap,
filter
} from 'rxjs/operators';
import { FileUtils, FileModel, UploadService } from '@alfresco/adf-core';
import { currentFolder } from '../selectors/app.selectors';
@ -114,6 +117,12 @@ export class UploadEffects {
return fromEvent(this.fileVersionInput, 'change').pipe(
distinctUntilChanged(),
flatMap(() => this.contentService.versionUploadDialog().afterClosed()),
tap(form => {
if (!form) {
this.fileVersionInput.value = '';
}
}),
filter(form => !!form),
flatMap(form => forkJoin(of(form), this.contentService.getNodeInfo())),
map(([form, node]) => {
const file = this.fileVersionInput.files[0];
@ -134,7 +143,7 @@ export class UploadEffects {
);
this.fileVersionInput.value = '';
this.uploadQueue([fileModel]);
this.uploadVersion(fileModel);
}),
catchError(error => {
this.fileVersionInput.value = '';
@ -176,4 +185,21 @@ export class UploadEffects {
});
}
}
private uploadVersion(file: FileModel) {
this.ngZone.run(() => {
this.uploadService.addToQueue(file);
this.uploadService.uploadFilesInTheQueue();
this.uploadService.fileUploadComplete.subscribe(completed => {
if (
file.data.entry.properties &&
file.data.entry.properties['cm:lockType'] === 'WRITE_LOCK' &&
completed.data.entry.id === file.data.entry.id
) {
this.store.dispatch(new UnlockWriteAction(completed.data));
}
});
});
}
}

View File

@ -251,8 +251,8 @@
"type": "rule",
"value": "core.some",
"parameters": [
{ "type": "rule", "value": "app.selection.canUnlockFile" },
{ "type": "rule", "value": "app.selection.canLockFile" }
{ "type": "rule", "value": "app.selection.file.canUnlock" },
{ "type": "rule", "value": "app.selection.file.canLock" }
]
}
]