[ACA-1591] Load extensions from multiple files ()

* rework extension service, separate file with config

* improve loading, optional entries

* simplify config and unify content actions

* load and merge multiple files

* improve plugin loading, introduce second demo

* move demo stuff to a plugin

* rework navbar to make it pluggable

* code and naming convention cleanup

* extension schema

* switch off custom navbar group by default

* hotfix for facetQueries issue

* consolidate files, final renames
This commit is contained in:
Denys Vuika 2018-07-19 20:54:39 +01:00 committed by GitHub
parent 43a71aa1c8
commit 8c9ffc1160
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
34 changed files with 1209 additions and 1048 deletions

300
extension.schema.json Normal file

@ -0,0 +1,300 @@
{
"$schema": "http://json-schema.org/draft-07/schema#",
"$id": "https://github.com/Alfresco/alfresco-content-app/blob/development/extension.schema.json",
"title": "ACA Extension Schema",
"description": "Provides a validation schema for ACA extensions",
"definitions": {
"ruleRef": {
"type": "object",
"required": ["id", "type"],
"properties": {
"id": {
"description": "Unique rule definition id",
"type": "string"
},
"type": {
"description": "Rule evaluator type",
"type": "string"
},
"parameters": {
"description": "Rule evaluator parameters",
"type": "array",
"items": { "$ref": "#/definitions/ruleParameter" },
"minItems": 1
}
}
},
"ruleParameter": {
"type": "object",
"required": ["type", "value"],
"properties": {
"type": {
"description": "Rule parameter type",
"type": "string"
},
"value": {
"description": "Rule parameter value",
"type": "string"
}
}
},
"routeRef": {
"type": "object",
"required": ["id", "path", "component"],
"properties": {
"id": {
"description": "Unique route reference identifier.",
"type": "string"
},
"path": {
"description": "Route path to register.",
"type": "string"
},
"component": {
"description": "Unique identifier for the Component to use with the route.",
"type": "string"
},
"layout": {
"description": "Unique identifier for the custom layout component to use.",
"type": "string"
},
"auth": {
"description": "List of the authentication guards to use with the route.",
"type": "array",
"items": {
"type": "string"
},
"minLength": 1,
"uniqueItems": true
},
"data": {
"description": "Custom data to pass to the activated route so that your components can access it",
"type": "object"
}
}
},
"actionRef": {
"type": "object",
"required": ["id", "type"],
"properties": {
"id": {
"description": "Unique action identifier",
"type": "string"
},
"type": {
"description": "Action type",
"type": "string"
},
"payload": {
"description": "Action payload value (string or expression)",
"type": "string"
}
}
},
"contentActionRef": {
"type": "object",
"required": ["id", "type"],
"properties": {
"id": {
"description": "Unique action identifier.",
"type": "string"
},
"type": {
"description": "Element type",
"type": "string",
"enum": ["default", "button", "separator", "menu"]
},
"title": {
"description": "Element title",
"type": "string"
},
"order": {
"description": "Element order",
"type": "number"
},
"icon": {
"description": "Element icon",
"type": "string"
},
"disabled": {
"description": "Toggles disabled state",
"type": "boolean"
},
"children": {
"description": "Child entries for the container types.",
"type": "array",
"items": { "$ref": "#/definitions/contentActionRef" },
"minItems": 1
},
"actions": {
"description": "Element actions",
"type": "object",
"properties": {
"click": {
"description": "Action reference for the click handler",
"type": "string"
}
}
},
"rules": {
"description": "Element rules",
"type": "object",
"properties": {
"enabled": {
"description": "Rule to evaluate the enabled state",
"type": "string"
},
"visible": {
"description": "Rule to evaluate the visibility state",
"type": "string"
}
}
}
}
},
"navBarLinkRef": {
"type": "object",
"required": ["id", "icon", "title", "route"],
"properties": {
"id": {
"description": "Unique identifier",
"type": "string"
},
"icon": {
"description": "Element icon",
"type": "string"
},
"title": {
"description": "Element title",
"type": "string"
},
"route": {
"description": "Route reference identifier",
"type": "string"
},
"description": {
"description": "Element description or tooltip",
"type": "string"
},
"order": {
"description": "Element order",
"type": "number"
},
"disabled": {
"description": "Toggles the disabled state",
"type": "boolean"
}
}
},
"navBarGroupRef": {
"type": "object",
"required": ["id", "items"],
"properties": {
"id": {
"description": "Unique identifier for the navigation group",
"type": "string"
},
"items": {
"description": "Navigation group items",
"type": "array",
"items": { "$ref": "#/definitions/navBarLinkRef" },
"minItems": 1
},
"order": {
"description": "Group order",
"type": "number"
},
"disabled": {
"description": "Toggles the disabled state",
"type": "boolean"
}
}
}
},
"type": "object",
"required": ["name", "version"],
"properties": {
"name": {
"description": "Extension name",
"type": "string"
},
"version": {
"description": "Extension version",
"type": "string"
},
"description": {
"description": "Brief description on what the extension does"
},
"references": {
"description": "References to external files",
"type": "array",
"items": {
"type": "string"
},
"minItems": 1,
"uniqueItems": true
},
"rules": {
"description": "List of rule definitions",
"type": "array",
"items": { "$ref": "#/definitions/ruleRef" },
"minItems": 1
},
"routes": {
"description": "List of custom application routes",
"type": "array",
"items": { "$ref": "#/definitions/routeRef" },
"minItems": 1
},
"actions": {
"description": "List of action definitions",
"type": "array",
"items": { "$ref": "#/definitions/actionRef" },
"minItems": 1
},
"features": {
"description": "Application-specific features and extensions",
"type": "object",
"properties": {
"create": {
"description": "The [New] menu component extensions",
"type": "array",
"items": { "$ref": "#/definitions/contentActionRef" },
"minItems": 1
},
"viewer": {
"description": "Viewer component extensions",
"type": "object",
"properties": {
"openWith": {
"description": "The [Open With] menu extensions",
"type": "array",
"items": { "$ref": "#/definitions/contentActionRef" },
"minItems": 1
}
}
},
"navbar": {
"description": "Navigation bar extensions",
"type": "array",
"items": { "$ref": "#/definitions/navBarGroupRef" },
"minItems": 1
},
"content": {
"description": "Main application content extensions",
"type": "object",
"properties": {
"actions": {
"description": "Content actions (toolbar, context menus, etc.)",
"type": "array",
"items": { "$ref": "#/definitions/contentActionRef" },
"minItems": 1
}
}
}
}
}
}
}

@ -36,275 +36,6 @@
"preserveState": true,
"expandedSidenav": true
},
"extensions": {
"external": [
"plugin1.json",
"plugin2.json"
],
"core": {
"rules": [
{
"id": "app.create.canCreateFolder",
"type": "app.navigation.folder.canCreate"
},
{
"id": "app.toolbar.canEditFolder",
"type": "core.every",
"parameters": [
{ "type": "rule", "value": "app.selection.folder" },
{ "type": "rule", "value": "app.selection.folder.canUpdate" }
]
},
{
"id": "app.toolbar.canViewFile",
"type": "app.selection.file"
},
{
"id": "app.toolbar.canDownload",
"type": "app.selection.canDownload"
}
],
"routes": [
{
"id": "aca:routes/about",
"path": "ext/about",
"component": "aca:components/about",
"layout": "aca:layouts/main",
"auth":[ "aca:auth" ],
"data": {
"title": "Custom About"
}
}
],
"actions": [
{
"id": "aca:actions/create-folder",
"type": "CREATE_FOLDER",
"payload": null
},
{
"id": "aca:actions/edit-folder",
"type": "EDIT_FOLDER",
"payload": null
},
{
"id": "aca:actions/download",
"type": "DOWNLOAD_NODES",
"payload": null
},
{
"id": "aca:actions/preview",
"type": "VIEW_FILE",
"payload": null
},
{
"id": "aca:actions/info",
"type": "SNACKBAR_INFO",
"payload": "I'm a nice little popup raised by extension."
},
{
"id": "aca:actions/node-name",
"type": "SNACKBAR_INFO",
"payload": "$('Action for ' + context.selection.first.entry.name)"
},
{
"id": "aca:actions/settings",
"type": "NAVIGATE_URL",
"payload": "/settings"
}
],
"features": {
"create": [
{
"id": "app.create.folder",
"icon": "create_new_folder",
"title": "ext: Create Folder",
"actions": {
"click": "aca:actions/create-folder"
},
"rules": {
"enabled": "app.create.canCreateFolder"
}
}
],
"navigation": {
"aca:main": [
{
"id": "aca/personal-files",
"order": 100,
"icon": "folder",
"title": "APP.BROWSE.PERSONAL.SIDENAV_LINK.LABEL",
"description": "APP.BROWSE.PERSONAL.SIDENAV_LINK.TOOLTIP",
"route": "personal-files"
},
{
"id": "aca/libraries",
"order": 101,
"icon": "group_work",
"title": "APP.BROWSE.LIBRARIES.SIDENAV_LINK.LABEL",
"description": "APP.BROWSE.LIBRARIES.SIDENAV_LINK.TOOLTIP",
"route": "libraries"
}
],
"aca:secondary": [
{
"id": "aca/shared",
"order": 100,
"icon": "people",
"title": "APP.BROWSE.SHARED.SIDENAV_LINK.LABEL",
"description": "APP.BROWSE.SHARED.SIDENAV_LINK.TOOLTIP",
"route": "shared"
},
{
"id": "aca/recent-files",
"order": 101,
"icon": "schedule",
"title": "APP.BROWSE.RECENT.SIDENAV_LINK.LABEL",
"description": "APP.BROWSE.RECENT.SIDENAV_LINK.TOOLTIP",
"route": "recent-files"
},
{
"id": "aca/favorites",
"order": 102,
"icon": "star",
"title": "APP.BROWSE.FAVORITES.SIDENAV_LINK.LABEL",
"description": "APP.BROWSE.FAVORITES.SIDENAV_LINK.TOOLTIP",
"route": "favorites"
},
{
"id": "aca/trashcan",
"order": 103,
"icon": "delete",
"title": "APP.BROWSE.TRASHCAN.SIDENAV_LINK.LABEL",
"description": "APP.BROWSE.TRASHCAN.SIDENAV_LINK.TOOLTIP",
"route": "trashcan"
}
],
"aca:demo": [
{
"disabled": true,
"id": "aca:demo/link1",
"order": 100,
"icon": "build",
"title": "About (native)",
"description": "Uses native application route",
"route": "about"
},
{
"disabled": true,
"id": "aca:demo/link2",
"order": 100,
"icon": "build",
"title": "About (custom)",
"description": "Uses custom defined route",
"route": "aca:routes/about"
}
]
},
"viewer": {
"open-with": [
{
"disabled": false,
"id": "aca:viewer/action1",
"order": 100,
"icon": "build",
"title": "Snackbar",
"action": "aca:actions/info"
}
]
},
"content": {
"actions": [
{
"id": "aca:toolbar/separator-1",
"order": 5,
"type": "separator"
},
{
"id": "aca:toolbar/create-folder",
"type": "button",
"order": 10,
"title": "APP.NEW_MENU.TOOLTIPS.CREATE_FOLDER",
"icon": "create_new_folder",
"actions": {
"click": "aca:actions/create-folder"
},
"rules": {
"visible": "app.create.canCreateFolder"
}
},
{
"id": "aca:toolbar/preview",
"type": "button",
"order": 15,
"title": "APP.ACTIONS.VIEW",
"icon": "open_in_browser",
"actions": {
"click": "aca:actions/preview"
},
"rules": {
"visible": "app.toolbar.canViewFile"
}
},
{
"id": "aca:toolbar/download",
"type": "button",
"order": 20,
"title": "APP.ACTIONS.DOWNLOAD",
"icon": "get_app",
"actions": {
"click": "aca:actions/download"
},
"rules": {
"visible": "app.toolbar.canDownload"
}
},
{
"id": "aca:toolbar/edit-folder",
"type": "button",
"order": 30,
"title": "APP.ACTIONS.EDIT",
"icon": "create",
"actions": {
"click": "aca:actions/edit-folder"
},
"rules": {
"visible": "app.toolbar.canEditFolder"
}
},
{
"id": "aca:toolbar/separator-2",
"order": 200,
"type": "separator"
},
{
"id": "aca:toolbar/menu-1",
"type": "menu",
"icon": "storage",
"order": 300,
"children": [
{
"id": "aca:action3",
"type": "button",
"title": "Settings",
"icon": "settings_applications",
"actions": {
"click": "aca:actions/settings"
}
}
]
},
{
"id": "aca:toolbar/separator-3",
"type": "separator"
}
]
}
}
}
},
"languages": [
{
"key": "de",
@ -460,6 +191,7 @@
{ "field": "modifier", "mincount": 1, "label": "SEARCH.FACET_FIELDS.MODIFIER" },
{ "field": "SITE", "mincount": 1, "label": "SEARCH.FACET_FIELDS.FILE_LIBRARY" }
],
"facetQueries": {},
"categories": [
{
"id": "modifiedDate",

@ -91,8 +91,6 @@ export class AppComponent implements OnInit {
pageTitle.setTitle(data.title || '');
});
this.extensions.init();
this.router.config.unshift(...this.extensions.getApplicationRoutes());
this.uploadService.fileUploadError.subscribe(error =>

@ -24,7 +24,7 @@
*/
import { BrowserModule } from '@angular/platform-browser';
import { NgModule } from '@angular/core';
import { NgModule, APP_INITIALIZER } from '@angular/core';
import { RouterModule, RouteReuseStrategy } from '@angular/router';
import { BrowserAnimationsModule } from '@angular/platform-browser/animations';
import { FormsModule, ReactiveFormsModule } from '@angular/forms';
@ -84,7 +84,11 @@ import { NodePermissionsDialogComponent } from './dialogs/node-permissions/node-
import { NodePermissionsDirective } from './directives/node-permissions.directive';
import { PermissionsManagerComponent } from './components/permission-manager/permissions-manager.component';
import { AppRouteReuseStrategy } from './app.routes.strategy';
import { ExtensionService } from './extensions/extension.service';
export function setupExtensionServiceFactory(service: ExtensionService): Function {
return () => service.load();
}
@NgModule({
imports: [
BrowserModule,
@ -96,7 +100,7 @@ import { AppRouteReuseStrategy } from './app.routes.strategy';
enableTracing: false // enable for debug only
}),
MaterialModule,
CoreModule,
CoreModule.forRoot(),
ContentModule,
AppStoreModule,
CoreExtensionsModule,
@ -159,7 +163,13 @@ import { AppRouteReuseStrategy } from './app.routes.strategy';
NodePermissionService,
ProfileResolver,
ExperimentalGuard,
ContentApiService
ContentApiService,
{
provide: APP_INITIALIZER,
useFactory: setupExtensionServiceFactory,
deps: [ExtensionService],
multi: true
}
],
entryComponents: [
LibraryDialogComponent,

@ -36,8 +36,8 @@ import { AppStore } from '../store/states/app.state';
import { SelectionState } from '../store/states/selection.state';
import { Observable } from 'rxjs/Rx';
import { ExtensionService } from '../extensions/extension.service';
import { ContentActionExtension } from '../extensions/content-action.extension';
import { ContentManagementService } from '../services/content-management.service';
import { ContentActionRef } from '../extensions/action.extensions';
export abstract class PageComponent implements OnInit, OnDestroy {
@ -52,7 +52,7 @@ export abstract class PageComponent implements OnInit, OnDestroy {
selection: SelectionState;
displayMode = DisplayMode.List;
sharedPreviewUrl$: Observable<string>;
actions: Array<ContentActionExtension> = [];
actions: Array<ContentActionRef> = [];
canUpdateFile = false;
canUpdateNode = false;
canDelete = false;

@ -18,7 +18,7 @@
<adf-viewer-open-with *ifExperimental="'extensions'">
<button *ngFor="let entry of openWith"
mat-menu-item
(click)="runAction(entry.action)">
(click)="runAction(entry.actions.click)">
<mat-icon>{{ entry.icon }}</mat-icon>
<span>{{ entry.title }}</span>
</button>

@ -32,8 +32,8 @@ import { DeleteNodesAction, SetSelectedNodesAction } from '../../store/actions';
import { PageComponent } from '../page.component';
import { ContentApiService } from '../../services/content-api.service';
import { ExtensionService } from '../../extensions/extension.service';
import { OpenWithExtension } from '../../extensions/open-with.extension';
import { ContentManagementService } from '../../services/content-management.service';
import { ContentActionRef } from '../../extensions/action.extensions';
@Component({
selector: 'app-preview',
templateUrl: 'preview.component.html',
@ -52,7 +52,7 @@ export class PreviewComponent extends PageComponent implements OnInit {
previousNodeId: string;
nextNodeId: string;
navigateMultiple = false;
openWith: Array<OpenWithExtension> = [];
openWith: Array<ContentActionRef> = [];
constructor(
private contentApi: ContentApiService,

@ -59,13 +59,13 @@
<div class="sidenav__section sidenav__section--menu" *ngFor="let group of groups">
<ul class="sidenav-menu">
<li *ngFor="let item of group" class="sidenav-menu__item"
<li *ngFor="let item of group.items" class="sidenav-menu__item"
routerLinkActive
#rla="routerLinkActive"
title="{{ item.description | translate }}">
<button
[routerLink]="item.route"
[routerLink]="item.url"
[color]="rla.isActive ? 'accent': 'primary'"
[attr.aria-label]="item.title | translate"
mat-icon-button
@ -78,7 +78,7 @@
</button>
<span #rippleTrigger
[routerLink]="item.route"
[routerLink]="item.url"
class="menu__item--label"
[hidden]="!showLabel"
[ngClass]="{

@ -28,13 +28,13 @@ import { Component, Input, OnInit, OnDestroy } from '@angular/core';
import { Node } from 'alfresco-js-api';
import { NodePermissionService } from '../../services/node-permission.service';
import { ExtensionService } from '../../extensions/extension.service';
import { NavigationExtension } from '../../extensions/navigation.extension';
import { Store } from '@ngrx/store';
import { AppStore } from '../../store/states';
import { CreateFolderAction } from '../../store/actions';
import { currentFolder } from '../../store/selectors/app.selectors';
import { takeUntil } from 'rxjs/operators';
import { ContentActionExtension } from '../../extensions/content-action.extension';
import { NavBarGroupRef } from '../../extensions/navbar.extensions';
import { ContentActionRef } from '../../extensions/action.extensions';
@Component({
selector: 'app-sidenav',
@ -45,8 +45,8 @@ export class SidenavComponent implements OnInit, OnDestroy {
@Input() showLabel: boolean;
node: Node = null;
groups: Array<NavigationExtension[]> = [];
createActions: Array<ContentActionExtension> = [];
groups: Array<NavBarGroupRef> = [];
createActions: Array<ContentActionRef> = [];
canCreateContent = false;
onDestroy$: Subject<boolean> = new Subject<boolean>();
@ -64,7 +64,7 @@ export class SidenavComponent implements OnInit, OnDestroy {
.pipe(takeUntil(this.onDestroy$))
.subscribe(node => {
this.node = node;
this.createActions = this.extensions.getFolderCreateActions(node);
this.createActions = this.extensions.getCreateActions();
this.canCreateContent = node && this.permission.check(node, ['create']);
});
}

@ -30,21 +30,28 @@ export enum ContentActionType {
menu = 'menu'
}
export interface ContentActionExtension {
export interface ContentActionRef {
id: string;
type: ContentActionType;
title?: string;
order?: number;
title: string;
icon?: string;
disabled?: boolean;
children?: Array<ContentActionExtension>;
children?: Array<ContentActionRef>;
actions?: {
click?: string;
[key: string]: string;
};
rules: {
rules?: {
enabled?: string;
visible?: string;
[key: string]: string;
};
}
export interface ActionRef {
id: string;
type: string;
payload?: string;
}

@ -1,30 +0,0 @@
/*!
* @license
* Alfresco Example Content Application
*
* Copyright (C) 2005 - 2018 Alfresco Software Limited
*
* This file is part of the Alfresco Example Content Application.
* If the software was purchased under a paid Alfresco license, the terms of
* the paid license agreement will prevail. Otherwise, the software is
* provided under the following open source license terms:
*
* The Alfresco Example Content Application is free software: you can redistribute it and/or modify
* it under the terms of the GNU Lesser General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* The Alfresco Example Content Application is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with Alfresco. If not, see <http://www.gnu.org/licenses/>.
*/
export interface ActionRef {
id: string;
type: string;
payload?: string;
}

@ -1,141 +0,0 @@
/*!
* @license
* Alfresco Example Content Application
*
* Copyright (C) 2005 - 2018 Alfresco Software Limited
*
* This file is part of the Alfresco Example Content Application.
* If the software was purchased under a paid Alfresco license, the terms of
* the paid license agreement will prevail. Otherwise, the software is
* provided under the following open source license terms:
*
* The Alfresco Example Content Application is free software: you can redistribute it and/or modify
* it under the terms of the GNU Lesser General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* The Alfresco Example Content Application is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with Alfresco. If not, see <http://www.gnu.org/licenses/>.
*/
import { AppConfigService } from '@alfresco/adf-core';
import { ActionService } from './action.service';
import { Store } from '@ngrx/store';
import { AppStore } from '../../store/states';
import { TestBed } from '@angular/core/testing';
import { AppTestingModule } from '../../testing/app-testing.module';
describe('ActionService', () => {
let config: AppConfigService;
let actions: ActionService;
let store: Store<AppStore>;
beforeEach(() => {
TestBed.configureTestingModule({
imports: [AppTestingModule]
});
actions = TestBed.get(ActionService);
store = TestBed.get(Store);
config = TestBed.get(AppConfigService);
config.config['extensions'] = {};
});
describe('actions', () => {
beforeEach(() => {
config.config.extensions = {
core: {
actions: [
{
id: 'aca:actions/create-folder',
type: 'CREATE_FOLDER',
payload: 'folder-name'
}
]
}
};
});
it('should load actions from the config', () => {
actions.init();
expect(actions.actions.length).toBe(1);
});
it('should have an empty action list if config provides nothing', () => {
config.config.extensions = {};
actions.init();
expect(actions.actions).toEqual([]);
});
it('should find action by id', () => {
actions.init();
const action = actions.getActionById(
'aca:actions/create-folder'
);
expect(action).toBeTruthy();
expect(action.type).toBe('CREATE_FOLDER');
expect(action.payload).toBe('folder-name');
});
it('should not find action by id', () => {
actions.init();
const action = actions.getActionById('missing');
expect(action).toBeFalsy();
});
it('should run the action via store', () => {
actions.init();
spyOn(store, 'dispatch').and.stub();
actions.runActionById('aca:actions/create-folder');
expect(store.dispatch).toHaveBeenCalledWith({
type: 'CREATE_FOLDER',
payload: 'folder-name'
});
});
it('should not use store if action is missing', () => {
actions.init();
spyOn(store, 'dispatch').and.stub();
actions.runActionById('missing');
expect(store.dispatch).not.toHaveBeenCalled();
});
});
describe('expressions', () => {
it('should eval static value', () => {
const value = actions.runExpression('hello world');
expect(value).toBe('hello world');
});
it('should eval string as an expression', () => {
const value = actions.runExpression('$( "hello world" )');
expect(value).toBe('hello world');
});
it('should eval expression with no context', () => {
const value = actions.runExpression('$( 1 + 1 )');
expect(value).toBe(2);
});
it('should eval expression with context', () => {
const context = {
a: 'hey',
b: 'there'
};
const expression = '$( context.a + " " + context.b + "!" )';
const value = actions.runExpression(expression, context);
expect(value).toBe('hey there!');
});
});
});

@ -1,76 +0,0 @@
/*!
* @license
* Alfresco Example Content Application
*
* Copyright (C) 2005 - 2018 Alfresco Software Limited
*
* This file is part of the Alfresco Example Content Application.
* If the software was purchased under a paid Alfresco license, the terms of
* the paid license agreement will prevail. Otherwise, the software is
* provided under the following open source license terms:
*
* The Alfresco Example Content Application is free software: you can redistribute it and/or modify
* it under the terms of the GNU Lesser General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* The Alfresco Example Content Application is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with Alfresco. If not, see <http://www.gnu.org/licenses/>.
*/
import { Injectable } from '@angular/core';
import { AppConfigService } from '@alfresco/adf-core';
import { Store } from '@ngrx/store';
import { AppStore } from '../../store/states';
import { ActionRef } from './action-ref';
@Injectable()
export class ActionService {
actions: Array<ActionRef> = [];
constructor(
private config: AppConfigService,
private store: Store<AppStore>
) {}
init() {
this.actions = this.config.get<Array<ActionRef>>(
'extensions.core.actions',
[]
);
}
getActionById(id: string): ActionRef {
return this.actions.find(action => action.id === id);
}
runActionById(id: string, context?: any) {
const action = this.getActionById(id);
if (action) {
const { type, payload } = action;
const expression = this.runExpression(payload, context);
this.store.dispatch({ type, payload: expression });
}
}
runExpression(value: string, context?: any) {
const pattern = new RegExp(/\$\((.*\)?)\)/g);
const matches = pattern.exec(value);
if (matches && matches.length > 1) {
const expression = matches[1];
const fn = new Function('context', `return ${expression}`);
const result = fn(context);
return result;
}
return value;
}
}

@ -31,13 +31,13 @@ import {
OnInit,
OnDestroy
} from '@angular/core';
import { ContentActionExtension } from '../../content-action.extension';
import { AppStore, SelectionState } from '../../../store/states';
import { Store } from '@ngrx/store';
import { ExtensionService } from '../../extension.service';
import { appSelection } from '../../../store/selectors/app.selectors';
import { Subject } from 'rxjs/Rx';
import { takeUntil } from 'rxjs/operators';
import { ContentActionRef } from '../../action.extensions';
@Component({
selector: 'aca-toolbar-action',
@ -47,7 +47,7 @@ import { takeUntil } from 'rxjs/operators';
host: { class: 'aca-toolbar-action' }
})
export class ToolbarActionComponent implements OnInit, OnDestroy {
@Input() entry: ContentActionExtension;
@Input() entry: ContentActionRef;
selection: SelectionState;
onDestroy$: Subject<boolean> = new Subject<boolean>();

@ -30,21 +30,38 @@ import { AboutComponent } from '../components/about/about.component';
import { LayoutComponent } from '../components/layout/layout.component';
import { ToolbarActionComponent } from './components/toolbar-action/toolbar-action.component';
import { CommonModule } from '@angular/common';
import { RuleService } from './rules/rule.service';
import { ActionService } from './actions/action.service';
import { every, some } from './evaluators/core.evaluators';
import {
canCreateFolder,
hasFolderSelected,
canUpdateSelectedFolder,
hasFileSelected,
canDownloadSelection
} from './evaluators/app.evaluators';
@NgModule({
imports: [CommonModule, CoreModule.forChild()],
declarations: [ToolbarActionComponent],
exports: [ToolbarActionComponent],
entryComponents: [AboutComponent],
providers: [ExtensionService, RuleService, ActionService]
providers: [ExtensionService]
})
export class CoreExtensionsModule {
constructor(extensions: ExtensionService) {
extensions
.setComponent('aca:layouts/main', LayoutComponent)
.setComponent('aca:components/about', AboutComponent)
.setAuthGuard('aca:auth', AuthGuardEcm);
.setComponent('app.layout.main', LayoutComponent)
.setComponent('app.components.about', AboutComponent)
.setAuthGuard('app.auth', AuthGuardEcm)
.setEvaluator('core.every', every)
.setEvaluator('core.some', some)
.setEvaluator('app.selection.canDownload', canDownloadSelection)
.setEvaluator('app.selection.file', hasFileSelected)
.setEvaluator('app.selection.folder', hasFolderSelected)
.setEvaluator(
'app.selection.folder.canUpdate',
canUpdateSelectedFolder
)
.setEvaluator('app.navigation.folder.canCreate', canCreateFolder);
}
}

@ -23,9 +23,8 @@
* along with Alfresco. If not, see <http://www.gnu.org/licenses/>.
*/
import { RuleContext } from './rule-context';
import { RuleParameter } from './rule-parameter';
import { Node } from 'alfresco-js-api';
import { RuleContext, RuleParameter } from '../rule.extensions';
export function canCreateFolder(context: RuleContext, ...args: RuleParameter[]): boolean {
const folder = context.navigation.currentFolder;

@ -23,8 +23,7 @@
* along with Alfresco. If not, see <http://www.gnu.org/licenses/>.
*/
import { RuleContext } from './rule-context';
import { RuleParameter } from './rule-parameter';
import { RuleContext, RuleParameter } from '../rule.extensions';
export function every(context: RuleContext, ...args: RuleParameter[]): boolean {
if (!args || args.length === 0) {

@ -23,13 +23,26 @@
* along with Alfresco. If not, see <http://www.gnu.org/licenses/>.
*/
import { RuleRef } from './rules/rule-ref';
import { ActionRef } from './actions/action-ref';
import { RouteRef } from './route-ref';
import { NavBarGroupRef } from './navbar.extensions';
import { RouteRef } from './routing.extensions';
import { RuleRef } from './rule.extensions';
import { ActionRef, ContentActionRef } from './action.extensions';
export interface ExtensionConfig {
version: string;
references?: Array<string>;
rules?: Array<RuleRef>;
routes?: Array<RouteRef>;
actions?: Array<ActionRef>;
features?: { [key: string]: any };
features?: {
[key: string]: any;
create?: Array<ContentActionRef>;
viewer?: {
openWith?: Array<ContentActionRef>;
};
navbar?: Array<NavBarGroupRef>;
content?: {
actions?: Array<ContentActionRef>;
};
};
}

@ -26,22 +26,98 @@
import { TestBed } from '@angular/core/testing';
import { AppTestingModule } from '../testing/app-testing.module';
import { ExtensionService } from './extension.service';
import { AppConfigService } from '@alfresco/adf-core';
import { ContentActionType } from './content-action.extension';
import { Store } from '@ngrx/store';
import { AppStore } from '../store/states';
import { ContentActionType } from './action.extensions';
describe('ExtensionService', () => {
let config: AppConfigService;
let extensions: ExtensionService;
let store: Store<AppStore>;
beforeEach(() => {
TestBed.configureTestingModule({
imports: [AppTestingModule]
});
store = TestBed.get(Store);
extensions = TestBed.get(ExtensionService);
});
config = TestBed.get(AppConfigService);
config.config['extensions'] = {};
describe('actions', () => {
beforeEach(() => {
extensions.setup({
version: '1.0.0',
actions: [
{
id: 'aca:actions/create-folder',
type: 'CREATE_FOLDER',
payload: 'folder-name'
}
]
});
});
it('should load actions from the config', () => {
expect(extensions.actions.length).toBe(1);
});
it('should find action by id', () => {
const action = extensions.getActionById(
'aca:actions/create-folder'
);
expect(action).toBeTruthy();
expect(action.type).toBe('CREATE_FOLDER');
expect(action.payload).toBe('folder-name');
});
it('should not find action by id', () => {
const action = extensions.getActionById('missing');
expect(action).toBeFalsy();
});
it('should run the action via store', () => {
spyOn(store, 'dispatch').and.stub();
extensions.runActionById('aca:actions/create-folder');
expect(store.dispatch).toHaveBeenCalledWith({
type: 'CREATE_FOLDER',
payload: 'folder-name'
});
});
it('should not use store if action is missing', () => {
spyOn(store, 'dispatch').and.stub();
extensions.runActionById('missing');
expect(store.dispatch).not.toHaveBeenCalled();
});
});
describe('expressions', () => {
it('should eval static value', () => {
const value = extensions.runExpression('hello world');
expect(value).toBe('hello world');
});
it('should eval string as an expression', () => {
const value = extensions.runExpression('$( "hello world" )');
expect(value).toBe('hello world');
});
it('should eval expression with no context', () => {
const value = extensions.runExpression('$( 1 + 1 )');
expect(value).toBe(2);
});
it('should eval expression with context', () => {
const context = {
a: 'hey',
b: 'there'
};
const expression = '$( context.a + " " + context.b + "!" )';
const value = extensions.runExpression(expression, context);
expect(value).toBe('hey there!');
});
});
describe('auth guards', () => {
@ -54,8 +130,6 @@ describe('ExtensionService', () => {
extensions.authGuards['guard1'] = guard1;
extensions.authGuards['guard2'] = guard2;
extensions.init();
});
it('should fetch auth guards by ids', () => {
@ -86,7 +160,6 @@ describe('ExtensionService', () => {
component1 = {};
extensions.components['component-1'] = component1;
extensions.init();
});
it('should fetch registered component', () => {
@ -105,22 +178,21 @@ describe('ExtensionService', () => {
let guard1;
beforeEach(() => {
config.config.extensions = {
core: {
routes: [
{
id: 'aca:routes/about',
path: 'ext/about',
component: 'aca:components/about',
layout: 'aca:layouts/main',
auth: ['aca:auth'],
data: {
title: 'Custom About'
}
extensions.setup({
version: '1.0.0',
routes: [
{
id: 'aca:routes/about',
path: 'ext/about',
component: 'aca:components/about',
layout: 'aca:layouts/main',
auth: ['aca:auth'],
data: {
title: 'Custom About'
}
]
}
};
}
]
});
component1 = {};
component2 = {};
@ -129,8 +201,6 @@ describe('ExtensionService', () => {
guard1 = {};
extensions.authGuards['aca:auth'] = guard1;
extensions.init();
});
it('should load routes from the config', () => {
@ -166,60 +236,54 @@ describe('ExtensionService', () => {
describe('content actions', () => {
it('should load content actions from the config', () => {
config.config.extensions = {
core: {
features: {
content: {
actions: [
{
id: 'aca:toolbar/separator-1',
order: 1,
type: 'separator'
},
{
id: 'aca:toolbar/separator-2',
order: 2,
type: 'separator'
}
]
}
extensions.setup({
version: '1.0.0',
features: {
content: {
actions: [
{
id: 'aca:toolbar/separator-1',
order: 1,
type: ContentActionType.separator,
title: 'action1',
},
{
id: 'aca:toolbar/separator-2',
order: 2,
type: ContentActionType.separator,
title: 'action2'
}
]
}
}
};
});
extensions.init();
expect(extensions.contentActions.length).toBe(2);
});
it('should have an empty content action list if config is empty', () => {
config.config.extensions = {};
extensions.init();
expect(extensions.contentActions).toEqual([]);
});
it('should sort content actions by order', () => {
config.config.extensions = {
core: {
features: {
content: {
actions: [
{
id: 'aca:toolbar/separator-2',
order: 2,
type: 'separator'
},
{
id: 'aca:toolbar/separator-1',
order: 1,
type: 'separator'
}
]
}
extensions.setup({
version: '1.0.0',
features: {
content: {
actions: [
{
id: 'aca:toolbar/separator-2',
order: 2,
type: ContentActionType.separator,
title: 'action2'
},
{
id: 'aca:toolbar/separator-1',
order: 1,
type: ContentActionType.separator,
title: 'action1'
}
]
}
}
};
});
extensions.init();
expect(extensions.contentActions.length).toBe(2);
expect(extensions.contentActions[0].id).toBe(
'aca:toolbar/separator-1'
@ -232,94 +296,97 @@ describe('ExtensionService', () => {
describe('open with', () => {
it('should load [open with] actions for the viewer', () => {
config.config.extensions = {
core: {
features: {
viewer: {
'open-with': [
{
disabled: false,
id: 'aca:viewer/action1',
order: 100,
icon: 'build',
title: 'Snackbar',
action: 'aca:actions/info'
extensions.setup({
version: '1.0.0',
features: {
viewer: {
openWith: [
{
disabled: false,
id: 'aca:viewer/action1',
order: 100,
icon: 'build',
title: 'Snackbar',
type: ContentActionType.default,
actions: {
click: 'aca:actions/info'
}
]
}
}
]
}
}
};
});
extensions.init();
expect(extensions.openWithActions.length).toBe(1);
});
it('should have an empty [open with] list if config is empty', () => {
config.config.extensions = {};
extensions.init();
expect(extensions.openWithActions).toEqual([]);
});
it('should load only enabled [open with] actions for the viewer', () => {
config.config.extensions = {
core: {
features: {
viewer: {
'open-with': [
{
id: 'aca:viewer/action2',
order: 200,
icon: 'build',
title: 'Snackbar',
action: 'aca:actions/info'
},
{
disabled: true,
id: 'aca:viewer/action1',
order: 100,
icon: 'build',
title: 'Snackbar',
action: 'aca:actions/info'
extensions.setup({
version: '1.0.0',
features: {
viewer: {
openWith: [
{
id: 'aca:viewer/action2',
order: 200,
icon: 'build',
title: 'Snackbar',
type: ContentActionType.default,
actions: {
click: 'aca:actions/info'
}
]
}
},
{
disabled: true,
id: 'aca:viewer/action1',
order: 100,
icon: 'build',
title: 'Snackbar',
type: ContentActionType.default,
actions: {
click: 'aca:actions/info'
}
}
]
}
}
};
});
extensions.init();
expect(extensions.openWithActions.length).toBe(1);
expect(extensions.openWithActions[0].id).toBe('aca:viewer/action2');
});
it('should sort [open with] actions by order', () => {
config.config.extensions = {
core: {
features: {
viewer: {
'open-with': [
{
id: 'aca:viewer/action2',
order: 200,
icon: 'build',
title: 'Snackbar',
action: 'aca:actions/info'
},
{
id: 'aca:viewer/action1',
order: 100,
icon: 'build',
title: 'Snackbar',
action: 'aca:actions/info'
extensions.setup({
version: '1.0.0',
features: {
viewer: {
openWith: [
{
id: 'aca:viewer/action2',
order: 200,
icon: 'build',
title: 'Snackbar',
type: ContentActionType.default,
actions: {
click: 'aca:actions/info'
}
]
}
},
{
id: 'aca:viewer/action1',
order: 100,
icon: 'build',
title: 'Snackbar',
type: ContentActionType.default,
actions: {
click: 'aca:actions/info'
}
}
]
}
}
};
});
extensions.init();
expect(extensions.openWithActions.length).toBe(2);
expect(extensions.openWithActions[0].id).toBe('aca:viewer/action1');
expect(extensions.openWithActions[1].id).toBe('aca:viewer/action2');
@ -328,67 +395,47 @@ describe('ExtensionService', () => {
describe('create', () => {
it('should load [create] actions from config', () => {
config.config.extensions = {
core: {
features: {
create: [
{
disabled: false,
id: 'aca:create/folder',
order: 100,
icon: 'create_new_folder',
title: 'ext: Create Folder',
target: {
permissions: ['create'],
action: 'aca:actions/create-folder'
}
}
]
}
extensions.setup({
version: '1.0.0',
features: {
create: [
{
id: 'aca:create/folder',
order: 100,
icon: 'create_new_folder',
title: 'ext: Create Folder',
type: ContentActionType.default
}
]
}
};
});
extensions.init();
expect(extensions.createActions.length).toBe(1);
});
it('should have an empty [create] actions if config is empty', () => {
config.config.extensions = {};
extensions.init();
expect(extensions.createActions).toEqual([]);
});
it('should sort [create] actions by order', () => {
config.config.extensions = {
core: {
features: {
create: [
{
id: 'aca:create/folder',
order: 100,
icon: 'create_new_folder',
title: 'ext: Create Folder',
target: {
permissions: ['create'],
action: 'aca:actions/create-folder'
}
},
{
id: 'aca:create/folder-2',
order: 10,
icon: 'create_new_folder',
title: 'ext: Create Folder',
target: {
permissions: ['create'],
action: 'aca:actions/create-folder'
}
}
]
}
extensions.setup({
version: '1.0.0',
features: {
create: [
{
id: 'aca:create/folder',
order: 100,
icon: 'create_new_folder',
title: 'ext: Create Folder',
type: ContentActionType.default
},
{
id: 'aca:create/folder-2',
order: 10,
icon: 'create_new_folder',
title: 'ext: Create Folder',
type: ContentActionType.default
}
]
}
};
});
extensions.init();
expect(extensions.createActions.length).toBe(2);
expect(extensions.createActions[0].id).toBe('aca:create/folder-2');
expect(extensions.createActions[1].id).toBe('aca:create/folder');

@ -24,69 +24,190 @@
*/
import { Injectable, Type } from '@angular/core';
import { AppConfigService } from '@alfresco/adf-core';
import {
ContentActionExtension,
ContentActionType
} from './content-action.extension';
import { OpenWithExtension } from './open-with.extension';
import { NavigationExtension } from './navigation.extension';
import { HttpClient } from '@angular/common/http';
import { Store } from '@ngrx/store';
import { Route } from '@angular/router';
import { Node } from 'alfresco-js-api';
import { RuleService } from './rules/rule.service';
import { ActionService } from './actions/action.service';
import { ActionRef } from './actions/action-ref';
import { RouteRef } from './route-ref';
import { ExtensionConfig } from './extension.config';
import { AppStore, SelectionState } from '../store/states';
import { NavigationState } from '../store/states/navigation.state';
import { selectionWithFolder } from '../store/selectors/app.selectors';
import { NavBarGroupRef } from './navbar.extensions';
import { RouteRef } from './routing.extensions';
import { RuleContext, RuleRef, RuleEvaluator } from './rule.extensions';
import { ActionRef, ContentActionRef, ContentActionType } from './action.extensions';
@Injectable()
export class ExtensionService {
export class ExtensionService implements RuleContext {
configPath = 'assets/app.extensions.json';
pluginsPath = 'assets/plugins';
contentActions: Array<ContentActionExtension> = [];
openWithActions: Array<OpenWithExtension> = [];
createActions: Array<ContentActionExtension> = [];
defaults = {
layout: 'app.layout.main',
auth: ['app.auth']
};
rules: Array<RuleRef> = [];
routes: Array<RouteRef> = [];
actions: Array<ActionRef> = [];
contentActions: Array<ContentActionRef> = [];
openWithActions: Array<ContentActionRef> = [];
createActions: Array<ContentActionRef> = [];
navbar: Array<NavBarGroupRef> = [];
authGuards: { [key: string]: Type<{}> } = {};
components: { [key: string]: Type<{}> } = {};
constructor(
private config: AppConfigService,
private ruleService: RuleService,
private actionService: ActionService
) {}
evaluators: { [key: string]: RuleEvaluator } = {};
selection: SelectionState;
navigation: NavigationState;
// initialise extension service
// in future will also load and merge data from the external plugins
init() {
this.routes = this.config.get<Array<RouteRef>>(
'extensions.core.routes',
[]
);
constructor(private http: HttpClient, private store: Store<AppStore>) {
this.store.select(selectionWithFolder).subscribe(result => {
this.selection = result.selection;
this.navigation = result.navigation;
});
}
this.contentActions = this.config
.get<Array<ContentActionExtension>>(
'extensions.core.features.content.actions',
[]
)
.sort(this.sortByOrder);
load(): Promise<boolean> {
return new Promise<any>(resolve => {
this.loadConfig(this.configPath, 0).then(result => {
let config = result.config;
this.openWithActions = this.config
.get<Array<OpenWithExtension>>(
'extensions.core.features.viewer.open-with',
[]
)
.filter(entry => !entry.disabled)
.sort(this.sortByOrder);
if (config.references && config.references.length > 0) {
const plugins = config.references.map(
(name, idx) => this.loadConfig(`${this.pluginsPath}/${name}`, idx)
);
this.createActions = this.config
.get<Array<ContentActionExtension>>(
'extensions.core.features.create',
[]
)
.sort(this.sortByOrder);
Promise.all(plugins).then((results => {
const configs = results
.filter(entry => entry)
.sort(this.sortByOrder)
.map(entry => entry.config);
this.ruleService.init();
this.actionService.init();
if (configs.length > 0) {
config = this.mergeConfigs(config, ...configs);
}
this.setup(config);
resolve(true);
}));
} else {
this.setup(config);
resolve(true);
}
});
});
}
setup(config: ExtensionConfig) {
if (!config) {
console.error('Extension configuration not found');
return;
}
this.rules = this.loadRules(config);
this.actions = this.loadActions(config);
this.routes = this.loadRoutes(config);
this.contentActions = this.loadContentActions(config);
this.openWithActions = this.loadViewerOpenWith(config);
this.createActions = this.loadCreateActions(config);
this.navbar = this.loadNavBar(config);
}
protected loadConfig(url: string, order: number): Promise<{ order: number, config: ExtensionConfig }> {
return new Promise(resolve => {
this.http.get<ExtensionConfig>(url).subscribe(
config => {
resolve({
order,
config
});
},
error => {
console.log(error);
resolve(null);
}
);
});
}
protected loadCreateActions(config: ExtensionConfig): Array<ContentActionRef> {
if (config && config.features) {
return (config.features.create || []).sort(
this.sortByOrder
);
}
return [];
}
protected loadContentActions(config: ExtensionConfig) {
if (config && config.features && config.features.content) {
return (config.features.content.actions || []).sort(
this.sortByOrder
);
}
return [];
}
protected loadNavBar(config: ExtensionConfig): any {
if (config && config.features) {
return (config.features.navbar || [])
.filter(entry => !entry.disabled)
.sort(this.sortByOrder)
.map(group => {
return {
...group,
items: (group.items || [])
.filter(item => !item.disabled)
.sort(this.sortByOrder)
.map(item => {
const routeRef = this.getRouteById(item.route);
const url = `/${routeRef ? routeRef.path : item.route}`;
return {
...item,
url
};
})
};
});
}
return {};
}
protected loadViewerOpenWith(config: ExtensionConfig): Array<ContentActionRef> {
if (config && config.features && config.features.viewer) {
return (config.features.viewer.openWith || [])
.filter(entry => !entry.disabled)
.sort(this.sortByOrder);
}
return [];
}
protected loadRules(config: ExtensionConfig): Array<RuleRef> {
if (config && config.rules) {
return config.rules;
}
return [];
}
protected loadRoutes(config: ExtensionConfig): Array<RouteRef> {
if (config) {
return config.routes || [];
}
return [];
}
protected loadActions(config: ExtensionConfig): Array<ActionRef> {
if (config) {
return config.actions || [];
}
return [];
}
setEvaluator(key: string, value: RuleEvaluator): ExtensionService {
this.evaluators[key] = value;
return this;
}
setAuthGuard(key: string, value: Type<{}>): ExtensionService {
@ -98,45 +219,14 @@ export class ExtensionService {
return this.routes.find(route => route.id === id);
}
getActionById(id: string): ActionRef {
return this.actionService.getActionById(id);
}
runActionById(id: string, context?: any) {
this.actionService.runActionById(id, context);
}
getAuthGuards(ids: string[]): Array<Type<{}>> {
return (ids || [])
.map(id => this.authGuards[id])
.filter(guard => guard);
}
getNavigationGroups(): Array<NavigationExtension[]> {
const settings = this.config.get<any>(
'extensions.core.features.navigation'
);
if (settings) {
const groups = Object.keys(settings).map(key => {
return settings[key]
.map(group => {
const customRoute = this.getRouteById(group.route);
const route = `/${
customRoute ? customRoute.path : group.route
}`;
return {
...group,
route
};
})
.filter(entry => !entry.disabled);
});
return groups;
}
return [];
getNavigationGroups(): Array<NavBarGroupRef> {
return this.navbar;
}
setComponent(id: string, value: Type<{}>): ExtensionService {
@ -150,11 +240,15 @@ export class ExtensionService {
getApplicationRoutes(): Array<Route> {
return this.routes.map(route => {
const guards = this.getAuthGuards(route.auth);
const guards = this.getAuthGuards(
route.auth && route.auth.length > 0
? route.auth
: this.defaults.auth
);
return {
path: route.path,
component: this.getComponentById(route.layout),
component: this.getComponentById(route.layout || this.defaults.layout),
canActivateChild: guards,
canActivate: guards,
children: [
@ -168,8 +262,7 @@ export class ExtensionService {
});
}
// evaluates create actions for the folder node
getFolderCreateActions(folder: Node): Array<ContentActionExtension> {
getCreateActions(): Array<ContentActionRef> {
return this.createActions
.filter(this.filterEnabled)
.filter(action => this.filterByRules(action))
@ -177,18 +270,18 @@ export class ExtensionService {
let disabled = false;
if (action.rules && action.rules.enabled) {
disabled = !this.ruleService.evaluateRule(action.rules.enabled);
disabled = !this.evaluateRule(action.rules.enabled);
}
return {
...action,
disabled
};
});
});
}
// evaluates content actions for the selection and parent folder node
getAllowedContentActions(): Array<ContentActionExtension> {
getAllowedContentActions(): Array<ContentActionRef> {
return this.contentActions
.filter(this.filterEnabled)
.filter(action => this.filterByRules(action))
@ -211,11 +304,11 @@ export class ExtensionService {
}
reduceSeparators(
acc: ContentActionExtension[],
el: ContentActionExtension,
acc: ContentActionRef[],
el: ContentActionRef,
i: number,
arr: ContentActionExtension[]
): ContentActionExtension[] {
arr: ContentActionRef[]
): ContentActionRef[] {
// remove duplicate separators
if (i > 0) {
const prev = arr[i - 1];
@ -238,9 +331,9 @@ export class ExtensionService {
}
reduceEmptyMenus(
acc: ContentActionExtension[],
el: ContentActionExtension
): ContentActionExtension[] {
acc: ContentActionRef[],
el: ContentActionRef
): ContentActionRef[] {
if (el.type === ContentActionType.menu) {
if ((el.children || []).length === 0) {
return acc;
@ -262,7 +355,7 @@ export class ExtensionService {
return !entry.disabled;
}
copyAction(action: ContentActionExtension): ContentActionExtension {
copyAction(action: ContentActionRef): ContentActionRef {
return {
...action,
children: (action.children || []).map(child =>
@ -271,10 +364,70 @@ export class ExtensionService {
};
}
filterByRules(action: ContentActionExtension): boolean {
filterByRules(action: ContentActionRef): boolean {
if (action && action.rules && action.rules.visible) {
return this.ruleService.evaluateRule(action.rules.visible);
return this.evaluateRule(action.rules.visible);
}
return true;
}
getActionById(id: string): ActionRef {
return this.actions.find(action => action.id === id);
}
runActionById(id: string, context?: any) {
const action = this.getActionById(id);
if (action) {
const { type, payload } = action;
const expression = this.runExpression(payload, context);
this.store.dispatch({ type, payload: expression });
}
}
runExpression(value: string, context?: any) {
const pattern = new RegExp(/\$\((.*\)?)\)/g);
const matches = pattern.exec(value);
if (matches && matches.length > 1) {
const expression = matches[1];
const fn = new Function('context', `return ${expression}`);
const result = fn(context);
return result;
}
return value;
}
evaluateRule(ruleId: string): boolean {
const ruleRef = this.rules.find(ref => ref.id === ruleId);
if (ruleRef) {
const evaluator = this.evaluators[ruleRef.type];
if (evaluator) {
return evaluator(this, ...ruleRef.parameters);
}
}
return false;
}
// todo: requires overwrite support for array entries
// todo: overwrite only particular areas, don't touch version or other top-level props
protected mergeConfigs(...objects): any {
const result = {};
objects.forEach(source => {
Object.keys(source).forEach(prop => {
if (prop in result && Array.isArray(result[prop])) {
result[prop] = result[prop].concat(source[prop]);
} else if (prop in result && typeof result[prop] === 'object') {
result[prop] = this.mergeConfigs(result[prop], source[prop]);
} else {
result[prop] = source[prop];
}
});
});
return result;
}
}

@ -23,12 +23,22 @@
* along with Alfresco. If not, see <http://www.gnu.org/licenses/>.
*/
export interface NavigationExtension {
export interface NavBarGroupRef {
id: string;
items: Array<NavBarLinkRef>;
order?: number;
disabled?: boolean;
}
export interface NavBarLinkRef {
id: string;
order: number;
icon: string;
title: string;
route: string;
url?: string; // evaluated at runtime based on route ref
description?: string;
order?: number;
disabled?: boolean;
}

@ -1,33 +0,0 @@
/*!
* @license
* Alfresco Example Content Application
*
* Copyright (C) 2005 - 2018 Alfresco Software Limited
*
* This file is part of the Alfresco Example Content Application.
* If the software was purchased under a paid Alfresco license, the terms of
* the paid license agreement will prevail. Otherwise, the software is
* provided under the following open source license terms:
*
* The Alfresco Example Content Application is free software: you can redistribute it and/or modify
* it under the terms of the GNU Lesser General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* The Alfresco Example Content Application is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with Alfresco. If not, see <http://www.gnu.org/licenses/>.
*/
export interface OpenWithExtension {
id: string;
order?: number;
icon: string;
title: string;
action: string;
disabled?: boolean;
}

@ -27,7 +27,8 @@ export interface RouteRef {
id: string;
path: string;
component: string;
layout: string;
auth: string[];
layout?: string;
auth?: string[];
data?: { [key: string]: string };
}

@ -23,12 +23,24 @@
* along with Alfresco. If not, see <http://www.gnu.org/licenses/>.
*/
import { SelectionState } from '../../store/states';
import { RuleEvaluator } from './rule.service';
import { NavigationState } from '../../store/states/navigation.state';
import { SelectionState } from '../store/states';
import { NavigationState } from '../store/states/navigation.state';
export type RuleEvaluator = (context: RuleContext, ...args: any[]) => boolean;
export interface RuleContext {
selection: SelectionState;
navigation: NavigationState;
evaluators: { [key: string]: RuleEvaluator };
}
export class RuleRef {
type: string;
id?: string;
parameters?: Array<RuleParameter>;
}
export interface RuleParameter {
type: string;
value: any;
}

@ -1,29 +0,0 @@
/*!
* @license
* Alfresco Example Content Application
*
* Copyright (C) 2005 - 2018 Alfresco Software Limited
*
* This file is part of the Alfresco Example Content Application.
* If the software was purchased under a paid Alfresco license, the terms of
* the paid license agreement will prevail. Otherwise, the software is
* provided under the following open source license terms:
*
* The Alfresco Example Content Application is free software: you can redistribute it and/or modify
* it under the terms of the GNU Lesser General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* The Alfresco Example Content Application is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with Alfresco. If not, see <http://www.gnu.org/licenses/>.
*/
export interface RuleParameter {
type: string;
value: any;
}

@ -1,34 +0,0 @@
/*!
* @license
* Alfresco Example Content Application
*
* Copyright (C) 2005 - 2018 Alfresco Software Limited
*
* This file is part of the Alfresco Example Content Application.
* If the software was purchased under a paid Alfresco license, the terms of
* the paid license agreement will prevail. Otherwise, the software is
* provided under the following open source license terms:
*
* The Alfresco Example Content Application is free software: you can redistribute it and/or modify
* it under the terms of the GNU Lesser General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* The Alfresco Example Content Application is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with Alfresco. If not, see <http://www.gnu.org/licenses/>.
*/
import { RuleParameter } from './rule-parameter';
import { RuleEvaluator } from './rule.service';
export class RuleRef {
type: string;
id?: string;
parameters?: Array<RuleParameter>;
evaluator?: RuleEvaluator;
}

@ -1,97 +0,0 @@
/*!
* @license
* Alfresco Example Content Application
*
* Copyright (C) 2005 - 2018 Alfresco Software Limited
*
* This file is part of the Alfresco Example Content Application.
* If the software was purchased under a paid Alfresco license, the terms of
* the paid license agreement will prevail. Otherwise, the software is
* provided under the following open source license terms:
*
* The Alfresco Example Content Application is free software: you can redistribute it and/or modify
* it under the terms of the GNU Lesser General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* The Alfresco Example Content Application is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with Alfresco. If not, see <http://www.gnu.org/licenses/>.
*/
import { Injectable } from '@angular/core';
import { AppConfigService } from '@alfresco/adf-core';
import { every, some } from './core.evaluators';
import { RuleContext } from './rule-context';
import { RuleRef } from './rule-ref';
import { createSelector, Store } from '@ngrx/store';
import {
appSelection,
appNavigation
} from '../../store/selectors/app.selectors';
import { AppStore, SelectionState } from '../../store/states';
import { NavigationState } from '../../store/states/navigation.state';
import { canCreateFolder, hasFolderSelected, canUpdateSelectedFolder, hasFileSelected, canDownloadSelection } from './app.evaluators';
export type RuleEvaluator = (context: RuleContext, ...args: any[]) => boolean;
export const selectionWithFolder = createSelector(
appSelection,
appNavigation,
(selection, navigation) => {
return {
selection,
navigation
};
}
);
@Injectable()
export class RuleService implements RuleContext {
rules: Array<RuleRef> = [];
evaluators: { [key: string]: RuleEvaluator } = {};
selection: SelectionState;
navigation: NavigationState;
constructor(
private config: AppConfigService,
private store: Store<AppStore>
) {
this.evaluators['core.every'] = every;
this.evaluators['core.some'] = some;
this.evaluators['app.selection.canDownload'] = canDownloadSelection;
this.evaluators['app.selection.file'] = hasFileSelected;
this.evaluators['app.selection.folder'] = hasFolderSelected;
this.evaluators['app.selection.folder.canUpdate'] = canUpdateSelectedFolder;
this.evaluators['app.navigation.folder.canCreate'] = canCreateFolder;
this.store
.select(selectionWithFolder)
.subscribe(result => {
this.selection = result.selection;
this.navigation = result.navigation;
});
}
init() {
this.rules = this.config
.get<Array<RuleRef>>('extensions.core.rules', [])
.map(rule => {
rule.evaluator = this.evaluators[rule.type];
return rule;
});
}
evaluateRule(ruleId: string): boolean {
const ruleRef = this.rules.find(ref => ref.id === ruleId);
if (ruleRef.evaluator) {
return ruleRef.evaluator(this, ...ruleRef.parameters);
}
return false;
}
}

@ -36,3 +36,14 @@ export const selectUser = createSelector(selectApp, state => state.user);
export const sharedUrl = createSelector(selectApp, state => state.sharedUrl);
export const appNavigation = createSelector(selectApp, state => state.navigation);
export const currentFolder = createSelector(selectApp, state => state.navigation.currentFolder);
export const selectionWithFolder = createSelector(
appSelection,
appNavigation,
(selection, navigation) => {
return {
selection,
navigation
};
}
);

@ -60,8 +60,6 @@ import { NodeActionsService } from '../services/node-actions.service';
import { NodePermissionService } from '../services/node-permission.service';
import { ContentApiService } from '../services/content-api.service';
import { ExtensionService } from '../extensions/extension.service';
import { RuleService } from '../extensions/rules/rule.service';
import { ActionService } from '../extensions/actions/action.service';
@NgModule({
imports: [
@ -114,9 +112,7 @@ import { ActionService } from '../extensions/actions/action.service';
NodeActionsService,
NodePermissionService,
ContentApiService,
ExtensionService,
RuleService,
ActionService
ExtensionService
]
})
export class AppTestingModule {}

@ -0,0 +1,189 @@
{
"$schema": "../../extension.schema.json",
"name": "app",
"version": "1.0.0",
"references": [
"plugin1.json",
"plugin2.json"
],
"rules": [
{
"id": "app.create.canCreateFolder",
"type": "app.navigation.folder.canCreate"
},
{
"id": "app.toolbar.canEditFolder",
"type": "core.every",
"parameters": [
{ "type": "rule", "value": "app.selection.folder" },
{ "type": "rule", "value": "app.selection.folder.canUpdate" }
]
},
{
"id": "app.toolbar.canViewFile",
"type": "app.selection.file"
},
{
"id": "app.toolbar.canDownload",
"type": "app.selection.canDownload"
}
],
"actions": [
{
"id": "app.actions.createFolder",
"type": "CREATE_FOLDER"
},
{
"id": "app.actions.editFolder",
"type": "EDIT_FOLDER"
},
{
"id": "app.actions.download",
"type": "DOWNLOAD_NODES"
},
{
"id": "app.actions.preview",
"type": "VIEW_FILE"
}
],
"features": {
"create": [
{
"id": "app.create.folder",
"type": "default",
"icon": "create_new_folder",
"title": "ext: Create Folder",
"actions": {
"click": "app.actions.createFolder"
},
"rules": {
"enabled": "app.create.canCreateFolder"
}
}
],
"navbar": [
{
"id": "app.navbar.primary",
"items": [
{
"id": "app.navbar.personalFiles",
"icon": "folder",
"title": "APP.BROWSE.PERSONAL.SIDENAV_LINK.LABEL",
"description": "APP.BROWSE.PERSONAL.SIDENAV_LINK.TOOLTIP",
"route": "personal-files"
},
{
"id": "app.navbar.libraries",
"icon": "group_work",
"title": "APP.BROWSE.LIBRARIES.SIDENAV_LINK.LABEL",
"description": "APP.BROWSE.LIBRARIES.SIDENAV_LINK.TOOLTIP",
"route": "libraries"
}
]
},
{
"id": "app.navbar.secondary",
"items": [
{
"id": "app.navbar.shared",
"icon": "people",
"title": "APP.BROWSE.SHARED.SIDENAV_LINK.LABEL",
"description": "APP.BROWSE.SHARED.SIDENAV_LINK.TOOLTIP",
"route": "shared"
},
{
"id": "app.navbar.recentFiles",
"icon": "schedule",
"title": "APP.BROWSE.RECENT.SIDENAV_LINK.LABEL",
"description": "APP.BROWSE.RECENT.SIDENAV_LINK.TOOLTIP",
"route": "recent-files"
},
{
"id": "app.navbar.favorites",
"icon": "star",
"title": "APP.BROWSE.FAVORITES.SIDENAV_LINK.LABEL",
"description": "APP.BROWSE.FAVORITES.SIDENAV_LINK.TOOLTIP",
"route": "favorites"
},
{
"id": "app.navbar.trashcan",
"icon": "delete",
"title": "APP.BROWSE.TRASHCAN.SIDENAV_LINK.LABEL",
"description": "APP.BROWSE.TRASHCAN.SIDENAV_LINK.TOOLTIP",
"route": "trashcan"
}
]
}
],
"content": {
"actions": [
{
"id": "app.toolbar.separator.1",
"order": 5,
"type": "separator"
},
{
"id": "app.toolbar.createFolder",
"type": "button",
"order": 10,
"title": "APP.NEW_MENU.TOOLTIPS.CREATE_FOLDER",
"icon": "create_new_folder",
"actions": {
"click": "app.actions.createFolder"
},
"rules": {
"visible": "app.create.canCreateFolder"
}
},
{
"id": "app.toolbar.preview",
"type": "button",
"order": 15,
"title": "APP.ACTIONS.VIEW",
"icon": "open_in_browser",
"actions": {
"click": "app.actions.preview"
},
"rules": {
"visible": "app.toolbar.canViewFile"
}
},
{
"id": "app.toolbar.download",
"type": "button",
"order": 20,
"title": "APP.ACTIONS.DOWNLOAD",
"icon": "get_app",
"actions": {
"click": "app.actions.download"
},
"rules": {
"visible": "app.toolbar.canDownload"
}
},
{
"id": "app.toolbar.editFolder",
"type": "button",
"order": 30,
"title": "APP.ACTIONS.EDIT",
"icon": "create",
"actions": {
"click": "app.actions.editFolder"
},
"rules": {
"visible": "app.toolbar.canEditFolder"
}
},
{
"id": "app.toolbar.separator.2",
"order": 200,
"type": "separator"
}
]
}
}
}

@ -0,0 +1,66 @@
{
"$schema": "../../../extension.schema.json",
"version": "1.0.0",
"name": "plugin1",
"description": "demo plugin",
"actions": [
{
"id": "plugin1.actions.settings",
"type": "NAVIGATE_URL",
"payload": "/settings"
},
{
"id": "plugin1.actions.info",
"type": "SNACKBAR_INFO",
"payload": "I'm a nice little popup raised by extension."
},
{
"id": "plugin1.actions.node-name",
"type": "SNACKBAR_INFO",
"payload": "$('Action for ' + context.selection.first.entry.name)"
}
],
"features": {
"viewer": {
"openWith": [
{
"id": "plugin1.viewer.openWith.action1",
"type": "default",
"icon": "build",
"title": "Snackbar",
"actions": {
"click": "plugin1.actions.info"
}
}
]
},
"content": {
"actions": [
{
"id": "plugin1.toolbar.menu1",
"type": "menu",
"icon": "storage",
"order": 300,
"children": [
{
"id": "plugin1.toolbar.menu1.settings",
"type": "button",
"title": "Settings",
"icon": "settings_applications",
"actions": {
"click": "plugin1.actions.settings"
}
}
]
},
{
"id": "plugin1.toolbar.separator3",
"order": 301,
"type": "separator"
}
]
}
}
}

@ -0,0 +1,42 @@
{
"$schema": "../../../extension.schema.json",
"version": "1.0.0",
"name": "plugin2",
"description": "demo plugin",
"routes": [
{
"id": "plugin2.routes.about",
"path": "ext/about",
"component": "app.components.about",
"data": {
"title": "Custom About"
}
}
],
"features": {
"navbar": [
{
"id": "plugin2.navbar.group1",
"disabled": true,
"items": [
{
"id": "plugin2.navbar.group1.link1",
"icon": "build",
"title": "About (native)",
"description": "Uses native application route",
"route": "about"
},
{
"id": "plugin2.navbar.group1.link2",
"icon": "build",
"title": "About (custom)",
"description": "Uses custom defined route",
"route": "plugin2.routes.about"
}
]
}
]
}
}

@ -69,7 +69,6 @@
"no-trailing-whitespace": true,
"no-unnecessary-initializer": true,
"no-unused-expression": true,
"no-use-before-declare": true,
"no-var-keyword": true,
"object-literal-sort-keys": false,
"one-line": [