[ACA-3394] Generally available file preview feature for extensions (#1496)

* Adding general purpose preview overlay route

Preparation for file preview feature

Remove unnecessary preview root route and ability to use loadChildren

Extract RouterExtensionService

Remove loadChildren support and use component instead

Cover RouterExtensionService with unit tests

Fix tests

Fix build

Fix rebase issue

Add generally available PluginPreviewAction

Add data option to child routes and navigateBackAsClose option for the preview component

Support plain mode preview

Fix linting

Update to latest alpha of ADF

* Adding documentation

* Rebase fix

* Update to latest adf
This commit is contained in:
Popovics András
2020-08-07 18:24:38 +02:00
committed by GitHub
parent ac6cfdb5b6
commit cd1252cb94
17 changed files with 560 additions and 233 deletions

View File

@@ -37,7 +37,6 @@ import {
reduceEmptyMenus,
ExtensionService,
ExtensionConfig,
ComponentRegisterService,
NavBarGroupRef
} from '@alfresco/adf-extensions';
import { AppConfigService } from '@alfresco/adf-core';
@@ -46,7 +45,6 @@ describe('AppExtensionService', () => {
let service: AppExtensionService;
let store: Store<AppStore>;
let extensions: ExtensionService;
let components: ComponentRegisterService;
let appConfigService: AppConfigService;
beforeEach(() => {
@@ -58,7 +56,6 @@ describe('AppExtensionService', () => {
store = TestBed.inject(Store);
service = TestBed.inject(AppExtensionService);
extensions = TestBed.inject(ExtensionService);
components = TestBed.inject(ComponentRegisterService);
});
const applyConfig = (config: ExtensionConfig) => {
@@ -227,97 +224,6 @@ describe('AppExtensionService', () => {
});
});
describe('components', () => {
let component1;
beforeEach(() => {
component1 = {};
components.setComponents({
'component-1': component1
});
});
it('should fetch registered component', () => {
const component = service.getComponentById('component-1');
expect(component).toEqual(component1);
});
it('should not fetch registered component', () => {
const component = service.getComponentById('missing');
expect(component).toBeFalsy();
});
});
describe('routes', () => {
let component1, component2;
let guard1;
beforeEach(() => {
applyConfig({
$id: 'test',
$name: 'test',
$version: '1.0.0',
$license: 'MIT',
$vendor: 'Good company',
$runtime: '1.5.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 = {};
components.setComponents({
'aca:components/about': component1,
'aca:layouts/main': component2
});
guard1 = {};
extensions.authGuards['aca:auth'] = guard1;
});
it('should load routes from the config', () => {
expect(extensions.routes.length).toBe(1);
});
it('should find a route by id', () => {
const route = extensions.getRouteById('aca:routes/about');
expect(route).toBeTruthy();
expect(route.path).toBe('ext/about');
});
it('should not find a route by id', () => {
const route = extensions.getRouteById('some-route');
expect(route).toBeFalsy();
});
it('should build application routes', () => {
const routes = service.getApplicationRoutes();
expect(routes.length).toBe(1);
const route = routes[0];
expect(route.path).toBe('ext/about');
expect(route.component).toEqual(component2);
expect(route.canActivateChild).toEqual([guard1]);
expect(route.canActivate).toEqual([guard1]);
expect(route.children.length).toBe(1);
expect(route.children[0].path).toBe('');
expect(route.children[0].component).toEqual(component1);
});
});
describe('content actions', () => {
it('should load content actions from the config', () => {
applyConfig({

View File

@@ -23,7 +23,7 @@
* along with Alfresco. If not, see <http://www.gnu.org/licenses/>.
*/
import { Injectable, Type } from '@angular/core';
import { Injectable } from '@angular/core';
import { Store } from '@ngrx/store';
import { MatIconRegistry } from '@angular/material/icon';
import { DomSanitizer } from '@angular/platform-browser';
@@ -53,7 +53,7 @@ import { AppConfigService, AuthenticationService, LogService } from '@alfresco/a
import { BehaviorSubject, Observable } from 'rxjs';
import { RepositoryInfo, NodeEntry } from '@alfresco/js-api';
import { ViewerRules } from '../models/viewer.rules';
import { SettingsGroupRef, ExtensionRoute } from '../models/types';
import { SettingsGroupRef } from '../models/types';
import { NodePermissionService } from '../services/node-permission.service';
@Injectable({
@@ -62,11 +62,6 @@ import { NodePermissionService } from '../services/node-permission.service';
export class AppExtensionService implements RuleContext {
private _references = new BehaviorSubject<ExtensionRef[]>([]);
defaults = {
layout: 'app.layout.main',
auth: ['app.auth']
};
headerActions: Array<ContentActionRef> = [];
toolbarActions: Array<ContentActionRef> = [];
viewerToolbarActions: Array<ContentActionRef> = [];
@@ -318,31 +313,6 @@ export class AppExtensionService implements RuleContext {
return this.sidebarTabs.filter((action) => this.filterVisible(action));
}
getComponentById(id: string): Type<{}> {
return this.extensions.getComponentById(id);
}
getApplicationRoutes(): Array<ExtensionRoute> {
return this.extensions.routes.map((route) => {
const guards = this.extensions.getAuthGuards(route.auth && route.auth.length > 0 ? route.auth : this.defaults.auth);
return {
path: route.path,
component: this.getComponentById(route.layout || this.defaults.layout),
canActivateChild: guards,
canActivate: guards,
parentRoute: route.parentRoute,
children: [
{
path: '',
component: this.getComponentById(route.component),
data: route.data
}
]
};
});
}
getCreateActions(): Array<ContentActionRef> {
return this.createActions
.filter((action) => this.filterVisible(action))

View File

@@ -0,0 +1,283 @@
/*!
* @license
* Alfresco Example Content Application
*
* Copyright (C) 2005 - 2020 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 { TestBed } from '@angular/core/testing';
import { LibTestingModule } from '../testing/lib-testing-module';
import { RouterExtensionService } from './router.extension.service';
import { ExtensionService } from '@alfresco/adf-extensions';
import { Router } from '@angular/router';
import { Type } from '@angular/core';
describe('RouterExtensionService', () => {
let extensionService: ExtensionService;
let service: RouterExtensionService;
let router: Router;
let component1, component2, component3, layoutComponent;
let guard1, guard2, guard3;
beforeEach(() => {
component1 = { name: 'component-1' };
component2 = { name: 'component-2' };
component3 = { name: 'component-3' };
layoutComponent = { name: 'layoutComponent' };
guard1 = { name: 'guard1' };
guard2 = { name: 'guard2' };
guard3 = { name: 'guard3' };
TestBed.configureTestingModule({
imports: [LibTestingModule],
providers: [
{
provide: ExtensionService,
useValue: {
routes: [],
getAuthGuards: (authKeys) => {
const authMapping = {
'app.auth': guard1,
'ext.auth1': guard2,
'ext.auth2': guard3
};
return authKeys.map((authKey) => authMapping[authKey]);
},
getComponentById: (componentKey) => {
const componentMapping = {
'ext:components/about': component1,
'ext:components/settings': component2,
'ext:components/info': component3,
'app.layout.main': layoutComponent
};
return componentMapping[componentKey];
}
}
}
]
});
extensionService = TestBed.get(ExtensionService);
service = TestBed.get(RouterExtensionService);
router = TestBed.get(Router);
router.config = [
{ path: 'login', component: {} as Type<any> },
{ path: 'settings', component: {} as Type<any> },
{ path: 'custom', children: [] },
{
path: '',
children: [
{ path: 'child-route1', component: {} as Type<any> },
{ path: 'child-route2', component: {} as Type<any> }
]
}
];
});
describe('getApplicationRoutes', () => {
function getDummyRoute(overrides) {
return {
id: 'aca:routes/about',
path: 'ext/about',
component: 'ext:components/about',
layout: 'aca:layouts/main',
auth: ['aca:auth'],
data: { title: 'Custom About' },
...overrides
};
}
it('should calculate path properly', () => {
extensionService.routes = [getDummyRoute({ path: 'aca:routes/about' })];
expect(service.getApplicationRoutes().length).toBe(1);
expect(service.getApplicationRoutes()[0].path).toBe('aca:routes/about');
});
it('should calculate parentRoute properly', () => {
extensionService.routes = [getDummyRoute({ parentRoute: 'parent-1' })];
expect(service.getApplicationRoutes()[0].parentRoute).toBe('parent-1');
});
it('should calculate the "component" to default layout, if no "layout" defined for the route', () => {
extensionService.routes = [getDummyRoute({ layout: undefined })];
expect(service.getApplicationRoutes()[0].component).toBe(layoutComponent);
});
it('should calculate the "component" to the registered component matching the "layout" value of the route', () => {
extensionService.routes = [getDummyRoute({ layout: 'ext:components/about' })];
expect(service.getApplicationRoutes()[0].component).toBe(component1);
});
it('should calculate the "canActivateChild" and "canActivate" to default auth guard, if no "auth" defined for the route', () => {
extensionService.routes = [getDummyRoute({ auth: undefined })];
expect(service.getApplicationRoutes()[0].canActivateChild).toEqual([guard1]);
expect(service.getApplicationRoutes()[0].canActivate).toEqual([guard1]);
});
it('should calculate the "canActivateChild" and "canActivate" to default auth guard, if "auth" is defined as [] for the route', () => {
extensionService.routes = [getDummyRoute({ auth: [] })];
expect(service.getApplicationRoutes()[0].canActivateChild).toEqual([guard1]);
expect(service.getApplicationRoutes()[0].canActivate).toEqual([guard1]);
});
it('should calculate the "canActivateChild" and "canActivate" to the registered guard(s) matching the "auth" value of the route', () => {
extensionService.routes = [getDummyRoute({ auth: ['ext.auth1', 'ext.auth2'] })];
expect(service.getApplicationRoutes()[0].canActivateChild).toEqual([guard2, guard3]);
expect(service.getApplicationRoutes()[0].canActivate).toEqual([guard2, guard3]);
});
it('should calculate the main path and data of "children" with the component and data of the route', () => {
const routeData = {};
extensionService.routes = [getDummyRoute({ component: 'ext:components/about', data: routeData })];
expect(service.getApplicationRoutes()[0].children[0].path).toBe('');
expect(service.getApplicationRoutes()[0].children[0].component).toBe(component1);
expect(service.getApplicationRoutes()[0].children[0].data).toBe(routeData);
});
it('should calculate the "children"-s with the "children" value of the route', () => {
extensionService.routes = [
getDummyRoute({
component: 'ext:components/about',
children: [
{
path: 'child-path1',
outlet: 'outlet1',
component: 'ext:components/settings'
},
{
path: 'child-path2',
component: 'ext:components/info'
}
]
})
];
expect(service.getApplicationRoutes()[0].children[0].path).toBe('child-path1');
expect(service.getApplicationRoutes()[0].children[0].component).toBe(component2);
expect(service.getApplicationRoutes()[0].children[0].outlet).toBe('outlet1');
expect(service.getApplicationRoutes()[0].children[1].path).toBe('child-path2');
expect(service.getApplicationRoutes()[0].children[1].component).toBe(component3);
expect(service.getApplicationRoutes()[0].children[1].outlet).toBe(undefined);
expect(service.getApplicationRoutes()[0].children[2].path).toBe('');
expect(service.getApplicationRoutes()[0].children[2].component).toBe(component1);
});
it('should transform more routes, not just one', () => {
extensionService.routes = [getDummyRoute({ path: 'aca:routes/about' }), getDummyRoute({ path: 'aca:routes/login' })];
expect(service.getApplicationRoutes().length).toBe(2);
expect(service.getApplicationRoutes()[0].path).toBe('aca:routes/about');
expect(service.getApplicationRoutes()[1].path).toBe('aca:routes/login');
});
});
describe('mapExtensionRoutes', () => {
it('should prepend routes without parent', () => {
const route1 = {
id: 'aca:routes/about',
path: 'ext/about',
component: 'ext:components/about'
};
extensionService.routes = [route1];
service.mapExtensionRoutes();
expect(router.config.length).toBe(5);
expect(router.config[0].path).toBe(route1.path);
expect(router.config[1].path).toBe('login');
});
it('should add routes to the right parent in reverse order (only one level deep searching)', () => {
const parentRoutePath = '';
const parentRoute = router.config.find((routeConfig) => routeConfig.path === parentRoutePath);
const route1 = {
id: 'aca:routes/about',
path: 'dynamic-extension-route',
component: 'ext:components/about',
parentRoute: parentRoutePath
};
const route2 = {
id: 'aca:routes/info',
path: 'dynamic-extension-route2',
component: 'ext:components/info',
parentRoute: parentRoutePath
};
extensionService.routes = [route1, route2];
expect(parentRoute.children.length).toBe(2);
expect(parentRoute.children[0].path).toBe('child-route1');
service.mapExtensionRoutes();
expect(router.config.length).toBe(4);
expect(parentRoute.children.length).toBe(4);
expect(parentRoute.children[0].path).toBe(route2.path);
expect(parentRoute.children[1].path).toBe(route1.path);
expect(parentRoute.children[2].path).toBe('child-route1');
});
it('should remove plugin related properties from the route when adding to the router config of Angular', () => {
const parentRoutePath = '';
const parentRoute = router.config.find((routeConfig) => routeConfig.path === parentRoutePath);
const route1 = {
id: 'aca:routes/about',
path: 'dynamic-extension-route',
component: 'ext:components/about',
parentRoute: parentRoutePath
};
extensionService.routes = [route1];
service.mapExtensionRoutes();
expect(parentRoute.children[0].path).toBe(route1.path);
expect((parentRoute.children[0] as any).parentRoute).toBe(undefined);
expect((parentRoute.children[0] as any).component).toBe(undefined);
});
it('should NOT add routes at all if parent can not be found (only one level deep searching)', () => {
const parentRoutePath = 'not-existing';
const route1 = {
id: 'aca:routes/about',
path: 'dynamic-extension-route',
component: 'ext:components/about',
parentRoute: parentRoutePath
};
extensionService.routes = [route1];
function invalidParentAddition() {
service.mapExtensionRoutes();
}
expect(invalidParentAddition).not.toThrow();
});
});
});

View File

@@ -0,0 +1,108 @@
/*!
* @license
* Alfresco Example Content Application
*
* Copyright (C) 2005 - 2020 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, Type } from '@angular/core';
import { ExtensionService } from '@alfresco/adf-extensions';
import { ExtensionRoute } from '../models/types';
import { Router } from '@angular/router';
@Injectable({
providedIn: 'root'
})
export class RouterExtensionService {
defaults = {
layout: 'app.layout.main',
auth: ['app.auth']
};
constructor(private router: Router, protected extensions: ExtensionService) {}
mapExtensionRoutes() {
const routesWithoutParent = [];
this.getApplicationRoutes().forEach((extensionRoute: ExtensionRoute) => {
if (this.extensionRouteHasChild(extensionRoute)) {
const parentRoute = this.findRoute(extensionRoute.parentRoute);
if (parentRoute) {
this.convertExtensionRouteToRoute(extensionRoute);
parentRoute.children.unshift(extensionRoute);
}
} else {
routesWithoutParent.push(extensionRoute);
}
});
this.router.config.unshift(...routesWithoutParent);
}
public getApplicationRoutes(): Array<ExtensionRoute> {
return this.extensions.routes.map((route) => {
const guards = this.extensions.getAuthGuards(route.auth && route.auth.length > 0 ? route.auth : this.defaults.auth);
return {
path: route.path,
component: this.getComponentById(route.layout || this.defaults.layout),
canActivateChild: guards,
canActivate: guards,
parentRoute: route.parentRoute,
children: [
...(route['children']
? route['children'].map(({ path, component, outlet, data }) => {
return {
path,
outlet,
data,
component: this.getComponentById(component)
};
})
: []),
{
path: '',
component: this.getComponentById(route.component),
data: route.data
}
]
};
});
}
private getComponentById(id: string): Type<{}> {
return this.extensions.getComponentById(id);
}
private extensionRouteHasChild(route: ExtensionRoute): boolean {
return route.parentRoute !== undefined;
}
private convertExtensionRouteToRoute(extensionRoute: ExtensionRoute) {
delete extensionRoute.parentRoute;
delete extensionRoute.component;
}
private findRoute(parentRoute) {
const routeIndex = this.router.config.findIndex((route) => route.path === parentRoute);
return this.router.config[routeIndex];
}
}

View File

@@ -44,6 +44,7 @@ export * from './lib/services/app.service';
export * from './lib/services/content-api.service';
export * from './lib/services/node-permission.service';
export * from './lib/services/app.extension.service';
export * from './lib/services/router.extension.service';
export * from './lib/components/generic-error/generic-error.component';
export * from './lib/components/generic-error/generic-error.module';

View File

@@ -31,7 +31,8 @@ export enum ViewerActionTypes {
ViewNode = 'VIEW_NODE',
ViewNodeVersion = 'VIEW_NODE_VERSION',
FullScreen = 'FULLSCREEN_VIEWER',
ClosePreview = 'CLOSE_PREVIEW'
ClosePreview = 'CLOSE_PREVIEW',
PluginPreview = 'PLUGIN_PREVIEW'
}
export interface ViewNodeExtras {
@@ -67,3 +68,9 @@ export class ClosePreviewAction implements Action {
readonly type = ViewerActionTypes.ClosePreview;
constructor(public payload?: MinimalNodeEntity) {}
}
export class PluginPreviewAction implements Action {
readonly type = ViewerActionTypes.PluginPreview;
constructor(public pluginRoute: string, public nodeId: string) {}
}