diff --git a/docs/extending/components.md b/docs/extending/components.md index 334093d4d..f2595b37f 100644 --- a/docs/extending/components.md +++ b/docs/extending/components.md @@ -20,6 +20,7 @@ The components are used to create custom: | app.toolbar.toggleFavoriteLibrary | ToggleFavoriteLibraryComponent | The toolbar button component that toggles Favorite library state for the selection. | | app.toolbar.toggleJoinLibrary | ToggleJoinLibraryComponent | The toolbar button component that toggles Join/Cancel Join request for the selected library | | app.toolbar.viewNode | ViewNodeComponent | Action component to view files | +| app.components.preview | PreviewComponent | Preview feature which can be used by plugins. For more info see the [tutorials](/extending/tutorials) about the Preview. | See [Registration](/extending/registration) section for more details on how to register your own entries to be re-used at runtime. diff --git a/docs/extending/routes.md b/docs/extending/routes.md index 7877dc256..f5bdb1a21 100644 --- a/docs/extending/routes.md +++ b/docs/extending/routes.md @@ -18,6 +18,18 @@ To create a new route, populate the `routes` section with the corresponding entr "path": "ext/bin", "layout": "app.layout.main", "component": "your.component.id", + "children": [ + { + "id": "plugin1.routes.bin.preview", + "path": "preview/:nodeId", + "component": "app.components.preview", + "data": { + "navigateBackAsClose": true, + "simplestMode": true + }, + "outlet": "viewer" + } + ], "parentRoute": "your-parent-route" } ] @@ -34,7 +46,8 @@ To create a new route, populate the `routes` section with the corresponding entr | layout | The layout [component](/extending/components) to use for the route. | | auth | List of [authentication guards](#authentication-guards). Defaults to `[ "app.auth" ]`. | | data | Custom property bag to carry with the route. | -| parentRoute | The path that the route will become child of | +| children | List of child routes of the injected route. [More info](#dynamically-injected-routes-with-their-children) | +| parentRoute | The path that the route will become child of. See more info about and its limitations under the [Child routes](#child-routes) section | Use the `app.layout.main` value for the `layout` property to get the default application layout, with header, navigation sidebar and main content area. @@ -72,6 +85,8 @@ Defaults to the `['app.auth']` value. ## Child Routes +### Injecting child routes under top-level routes: `parentRoute` + Extensions may register a routes that are children of some existing application routes. Imagine the situation when application has the following route structure: @@ -109,6 +124,18 @@ so giving you an option for nested linking: `/files/my-path`. > For the time being, you can provide child entries only for the root (top-level) routes. +### Dynamically injected routes with their children + +For a dynamically created route, we can define the children property as well, which contain the child routes of the mainly injected route. For the time being, for a child route, the following properties are supported and translated to Angular's Router configuration: + +| Property | Description | +| - | - | +| **id** | Unique identifier. | +| **path** | Runtime path of the route. | +| **component** | The main [component](/extending/components) to use for the route. | +| data | Custom property bag to carry with the route. | +| outlet | Router outlet's name. Especially useful when using the PluginPreviewAction within a plugin | + ## Authentication Guards Below is the list of the authentication guards main application registers on startup. diff --git a/docs/extending/tutorials.md b/docs/extending/tutorials.md index 940abcd60..b034f21c8 100644 --- a/docs/extending/tutorials.md +++ b/docs/extending/tutorials.md @@ -259,3 +259,67 @@ Update the `src/assets/app.extensions.json` file, and insert a new entry to the ``` Now, once you run the application, you should see an extra button that invokes your dialog on every click. + +### File preview from a plugin with custom route + +There might be scenarios where you build a plugin with a custom route, and from that route you might want to preview a file within an overlay. +When having a plugin's entry point in a custom route, using the `/view` root-level application routes for previewing a file might be contradictory, since hitting any of these urls results a navigation away from the original route implying a reload of the original route's entry component when closing the preview panel (navigating back). + +#### Example + +Let's say you have a custom plugin with which you can start a process with any of your files. The plugin registers a custom route (`start-process`) with its entry component, where the user can start a process. +In this component the user can fill in a form with different values for text fields and selectboxes and select a file. But for file selection, we would like to provide a preview functionality (with the `PreviewComponent` provided by the core application) to let the user be sure that the right file was selected. Obviously having a form filled in values (but not saved) means, that we don't want to loose our filled in data just because we are previewing a file. Because of this we would like the file preview to be opened in an overlay mode. The core application has one overlay region already defined for this reason, called `viewer`. This is the named router outlet we need to target without route change. + +#### Solution + +In our plugin we need to do the following steps: + +##### Registering the custom route in the plugin.json + +We need to add the custom route with our entry component and its child route for the preview: + +```json +{ + ... + "routes": [{ + "id": "start-process", + "path": "start-process", + "parentRoute": "", + "layout": "app.layout.main", + // The component we register to be our entry point for this particular route + "component": "myplugin.components.start-process", + "children": [ + { + "id": "start-process-preview", + // It can be accessed on the "/start-process(viewer:preview/nodeId)" route + "path": "preview/:nodeId", + "component": "app.components.preview", + "data": { + // Using history.back() when closing the preview + "navigateBackAsClose": true, + // Disabling complex action and buttons for the preview + "simplestMode": true + }, + // We would like to target that named router outlet which is used for the viewer overlay + "outlet": "viewer" + } + ] + }] + ... +``` + + +##### Dispatching the right action within our component to open the file preview + +```ts +import { PluginPreviewAction } from '@alfresco/aca-shared/store'; + +@Component({...}) +export class StartProcessComponent { + ... + + onFilePreview({ nodeId }) { + this.store.dispatch(new PluginPreviewAction('start-process-cloud', nodeId)); + } +} +``` diff --git a/package-lock.json b/package-lock.json index 7f57c51c8..fe0104203 100644 --- a/package-lock.json +++ b/package-lock.json @@ -5,12 +5,12 @@ "requires": true, "dependencies": { "@alfresco/adf-cli": { - "version": "3.10.0-6f5ff737ddd734c02a38aef7efadf13250387fa6", - "resolved": "https://registry.npmjs.org/@alfresco/adf-cli/-/adf-cli-3.10.0-6f5ff737ddd734c02a38aef7efadf13250387fa6.tgz", - "integrity": "sha512-xpAHyi1BnFtNtUxGTHJ+iaBehqn5K+wzbJA4mIwaCigJ0kVwUlZsl0o/RNVrv5MBcEKfXXOqw+DdMwO/zf33dw==", + "version": "3.10.0-89b37067ff6b1ccde8af1a1611828ee61da0bcac", + "resolved": "https://registry.npmjs.org/@alfresco/adf-cli/-/adf-cli-3.10.0-89b37067ff6b1ccde8af1a1611828ee61da0bcac.tgz", + "integrity": "sha512-CyYT/+/X1nKJ92VFd60CcSDHiLH0Y2FztGRAhOkcIfL/x0RAXp8OLXrSXImwsJVAcKeIR+WJZiy9sAACndElyw==", "dev": true, "requires": { - "@alfresco/js-api": "3.10.0-8e063ceb7b7d71ab3001b50fc8d0a79a1a8797c5", + "@alfresco/js-api": "3.10.0-23b97d354151ced90eb2fc68f9d57cad852376d7", "commander": "^4.0.0", "ejs": "^2.6.1", "license-checker": "^25.0.1", @@ -20,17 +20,6 @@ "spdx-license-list": "^5.0.0" }, "dependencies": { - "@alfresco/js-api": { - "version": "3.10.0-8e063ceb7b7d71ab3001b50fc8d0a79a1a8797c5", - "resolved": "https://registry.npmjs.org/@alfresco/js-api/-/js-api-3.10.0-8e063ceb7b7d71ab3001b50fc8d0a79a1a8797c5.tgz", - "integrity": "sha512-2ogpnobx5TwWmZy7FO4L5Z1ZE1rVWDxtiRF6Jl1prmHML8GWY3GKV7uTOEQvDX569RRYo/fzsdiwExYipNr1QQ==", - "dev": true, - "requires": { - "event-emitter": "^0.3.5", - "minimatch": "3.0.4", - "superagent": "^5.1.2" - } - }, "commander": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/commander/-/commander-4.1.1.tgz", @@ -40,42 +29,42 @@ } }, "@alfresco/adf-content-services": { - "version": "3.10.0-0afbe367878b8a9ae17cad4e839a02a8c6728d8d", - "resolved": "https://registry.npmjs.org/@alfresco/adf-content-services/-/adf-content-services-3.10.0-0afbe367878b8a9ae17cad4e839a02a8c6728d8d.tgz", - "integrity": "sha512-3QSUoV1wya4wTGeIPaMnIvERMm6EmXvD/PVWkU4NZc9H+c4pEWDU2gaQisFoAA/OTWQnliBA7lt602lUWOtWlg==", + "version": "3.10.0-89b37067ff6b1ccde8af1a1611828ee61da0bcac", + "resolved": "https://registry.npmjs.org/@alfresco/adf-content-services/-/adf-content-services-3.10.0-89b37067ff6b1ccde8af1a1611828ee61da0bcac.tgz", + "integrity": "sha512-37lZG0s9WfyZYvuwiA/JZPO8iHWarRPUHujQbPPCGhRBUqCteix/7Qj2QXkF5aqjkkLpN4tAej/OyU18edYc2Q==", "requires": { "tslib": "^2.0.0" } }, "@alfresco/adf-core": { - "version": "3.10.0-0afbe367878b8a9ae17cad4e839a02a8c6728d8d", - "resolved": "https://registry.npmjs.org/@alfresco/adf-core/-/adf-core-3.10.0-0afbe367878b8a9ae17cad4e839a02a8c6728d8d.tgz", - "integrity": "sha512-gKq7nwtck2tPywARF/TL/KqJKfJ7+8cwaGDLU7uri5PbbLJ1l7Abew2rhebckMa6kMd+FUpHu4qJDkLELAfEng==", + "version": "3.10.0-89b37067ff6b1ccde8af1a1611828ee61da0bcac", + "resolved": "https://registry.npmjs.org/@alfresco/adf-core/-/adf-core-3.10.0-89b37067ff6b1ccde8af1a1611828ee61da0bcac.tgz", + "integrity": "sha512-B2LQ6OU/qT8lhDbX4t6ZMa/zSTYDyse9VuzJwzAC2s0ppLcypp6AzNNeuTMdlyJyLEPXviSXVIAjxT1DJvMFbg==", "requires": { "tslib": "^2.0.0" } }, "@alfresco/adf-extensions": { - "version": "3.10.0-0afbe367878b8a9ae17cad4e839a02a8c6728d8d", - "resolved": "https://registry.npmjs.org/@alfresco/adf-extensions/-/adf-extensions-3.10.0-0afbe367878b8a9ae17cad4e839a02a8c6728d8d.tgz", - "integrity": "sha512-W9W3CTsUcwcuXd0ZgPpOiOYrnMhonqlFb1zxqlgTLs5c0KRovsvtyDVEGN3Uv4Fmllc484HfoJVE/6qvvZvBVw==", + "version": "3.10.0-89b37067ff6b1ccde8af1a1611828ee61da0bcac", + "resolved": "https://registry.npmjs.org/@alfresco/adf-extensions/-/adf-extensions-3.10.0-89b37067ff6b1ccde8af1a1611828ee61da0bcac.tgz", + "integrity": "sha512-DIC1QKnOaVugsNr2+nqH8BhfspN6/1MEovIf4IERuc142viGyDpGxkgaTzF/qDnYMBoAzDf1x2Qfz0y97g+HWw==", "requires": { "tslib": "^2.0.0" } }, "@alfresco/adf-testing": { - "version": "3.10.0-6f5ff737ddd734c02a38aef7efadf13250387fa6", - "resolved": "https://registry.npmjs.org/@alfresco/adf-testing/-/adf-testing-3.10.0-6f5ff737ddd734c02a38aef7efadf13250387fa6.tgz", - "integrity": "sha512-uxWkZWReVavmnv3sFtheDIt1/H4Wv3PZkbSLqQMeq+neWxa1qWJ5BuYaI/zVGLc2dqU0mqVlZupNUnGFbR1bVQ==", + "version": "3.10.0-89b37067ff6b1ccde8af1a1611828ee61da0bcac", + "resolved": "https://registry.npmjs.org/@alfresco/adf-testing/-/adf-testing-3.10.0-89b37067ff6b1ccde8af1a1611828ee61da0bcac.tgz", + "integrity": "sha512-4Pj8G8QXmyuLJir8CP9G57SprZfIxfwY3L3JWrQmwLLFTQdw+pEDlrNAD42yvUfSN42g+RvFs3zZTxVPhb7GwA==", "dev": true, "requires": { "tslib": "^2.0.0" } }, "@alfresco/js-api": { - "version": "3.10.0-42769ecb372a1b7f6841e1971bb63f9f0fc78753", - "resolved": "https://registry.npmjs.org/@alfresco/js-api/-/js-api-3.10.0-42769ecb372a1b7f6841e1971bb63f9f0fc78753.tgz", - "integrity": "sha512-k8xByKGltU3T5othhHjrGQ0kLRQ8zTkiMr+VYb2s2C4aSrq/nMoTbJc47F/f6EU549XmvZuNGCQGTUSQlpo3bA==", + "version": "3.10.0-23b97d354151ced90eb2fc68f9d57cad852376d7", + "resolved": "https://registry.npmjs.org/@alfresco/js-api/-/js-api-3.10.0-23b97d354151ced90eb2fc68f9d57cad852376d7.tgz", + "integrity": "sha512-EY8H6gz0T6RCCGh1SjHhklgEd/ulXhEGpu0V2MMTfhHI59kOzyZCGswBnPtbdI7p8Gwnx8oo2tzoSoddm0FzHA==", "requires": { "event-emitter": "^0.3.5", "minimatch": "3.0.4", diff --git a/package.json b/package.json index c034dbb3a..b3cecdd87 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "build": "npm run validate-config && npm run build.app -- --prod", "build.e2e": "npm run build.app -- --prod --configuration=e2e", "test": "ng test app --code-coverage", + "unit": "ng test --browsers=Chrome --watch", "test:ci": "npm run build.extensions && ng test adf-office-services-ext --watch=false && ng test app --code-coverage --watch=false", "lint": "ng lint && npm run spellcheck && npm run e2e.typecheck", "update-webdriver": "./scripts/update-webdriver.sh", @@ -32,10 +33,10 @@ }, "private": true, "dependencies": { - "@alfresco/adf-content-services": "3.10.0-0afbe367878b8a9ae17cad4e839a02a8c6728d8d", - "@alfresco/adf-core": "3.10.0-0afbe367878b8a9ae17cad4e839a02a8c6728d8d", - "@alfresco/adf-extensions": "3.10.0-0afbe367878b8a9ae17cad4e839a02a8c6728d8d", - "@alfresco/js-api": "3.10.0-42769ecb372a1b7f6841e1971bb63f9f0fc78753", + "@alfresco/adf-content-services": "3.10.0-89b37067ff6b1ccde8af1a1611828ee61da0bcac", + "@alfresco/adf-core": "3.10.0-89b37067ff6b1ccde8af1a1611828ee61da0bcac", + "@alfresco/adf-extensions": "3.10.0-89b37067ff6b1ccde8af1a1611828ee61da0bcac", + "@alfresco/js-api": "3.10.0-23b97d354151ced90eb2fc68f9d57cad852376d7", "@angular-custom-builders/lite-serve": "0.2.2", "@angular/animations": "10.0.4", "@angular/cdk": "^10.0.2", @@ -66,8 +67,8 @@ "zone.js": "~0.10.2" }, "devDependencies": { - "@alfresco/adf-cli": "3.10.0-6f5ff737ddd734c02a38aef7efadf13250387fa6", - "@alfresco/adf-testing": "3.10.0-6f5ff737ddd734c02a38aef7efadf13250387fa6", + "@alfresco/adf-cli": "3.10.0-89b37067ff6b1ccde8af1a1611828ee61da0bcac", + "@alfresco/adf-testing": "3.10.0-89b37067ff6b1ccde8af1a1611828ee61da0bcac", "@angular-devkit/build-angular": "~0.1000.4", "@angular-devkit/build-ng-packagr": "~0.1000.5", "@angular/cli": "^10.0.5", diff --git a/projects/aca-shared/src/lib/services/app.extension.service.spec.ts b/projects/aca-shared/src/lib/services/app.extension.service.spec.ts index 14c149d80..28b02238b 100644 --- a/projects/aca-shared/src/lib/services/app.extension.service.spec.ts +++ b/projects/aca-shared/src/lib/services/app.extension.service.spec.ts @@ -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; 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({ diff --git a/projects/aca-shared/src/lib/services/app.extension.service.ts b/projects/aca-shared/src/lib/services/app.extension.service.ts index bfd769f7b..340b100e1 100644 --- a/projects/aca-shared/src/lib/services/app.extension.service.ts +++ b/projects/aca-shared/src/lib/services/app.extension.service.ts @@ -23,7 +23,7 @@ * along with Alfresco. If not, see . */ -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([]); - defaults = { - layout: 'app.layout.main', - auth: ['app.auth'] - }; - headerActions: Array = []; toolbarActions: Array = []; viewerToolbarActions: Array = []; @@ -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 { - 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 { return this.createActions .filter((action) => this.filterVisible(action)) diff --git a/projects/aca-shared/src/lib/services/router.extension.service.spec.ts b/projects/aca-shared/src/lib/services/router.extension.service.spec.ts new file mode 100644 index 000000000..ddb26175a --- /dev/null +++ b/projects/aca-shared/src/lib/services/router.extension.service.spec.ts @@ -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 . + */ + +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 }, + { path: 'settings', component: {} as Type }, + { path: 'custom', children: [] }, + { + path: '', + children: [ + { path: 'child-route1', component: {} as Type }, + { path: 'child-route2', component: {} as Type } + ] + } + ]; + }); + + 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(); + }); + }); +}); diff --git a/projects/aca-shared/src/lib/services/router.extension.service.ts b/projects/aca-shared/src/lib/services/router.extension.service.ts new file mode 100644 index 000000000..e55a759a8 --- /dev/null +++ b/projects/aca-shared/src/lib/services/router.extension.service.ts @@ -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 . + */ + +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 { + 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]; + } +} diff --git a/projects/aca-shared/src/public-api.ts b/projects/aca-shared/src/public-api.ts index 07bb49499..9e2072113 100644 --- a/projects/aca-shared/src/public-api.ts +++ b/projects/aca-shared/src/public-api.ts @@ -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'; diff --git a/projects/aca-shared/store/src/actions/viewer.actions.ts b/projects/aca-shared/store/src/actions/viewer.actions.ts index 93b0e8d70..0bbc1a0cb 100644 --- a/projects/aca-shared/store/src/actions/viewer.actions.ts +++ b/projects/aca-shared/store/src/actions/viewer.actions.ts @@ -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) {} +} diff --git a/src/app/app.component.spec.ts b/src/app/app.component.spec.ts index abe3dfc50..17b482634 100644 --- a/src/app/app.component.spec.ts +++ b/src/app/app.component.spec.ts @@ -28,7 +28,6 @@ import { SetInitialStateAction } from '@alfresco/aca-shared/store'; import { Router } from '@angular/router'; import { TestBed } from '@angular/core/testing'; import { RouterTestingModule } from '@angular/router/testing'; -import { mockRoutesWithoutParentRoute, mockRoutesWithParentRoute } from './mock/extension-routes.mock'; describe('AppComponent', () => { let component: AppComponent; @@ -105,31 +104,4 @@ describe('AppComponent', () => { expect(storeMock.dispatch['calls'].argsFor(0)[0].payload).toBe('APP.MESSAGES.UPLOAD.ERROR.GENERIC'); }); }); - - describe('Routing Configuration', () => { - it('Should extension route be included as child of the defined parent path', () => { - component.mapExtensionRoutes(mockRoutesWithParentRoute); - - expect(router.config[0]).toEqual({ - path: 'fake-path', - children: [ - { - path: 'extension-path', - canActivate: ['fake-guard'], - canActivateChild: ['fake-guard'] - } - ] - }); - }); - - it('Should extension route be included as root entry when there is no parent path defined', () => { - component.mapExtensionRoutes(mockRoutesWithoutParentRoute); - expect(router.config[0]).toEqual({ - component: null, - path: 'extension-path', - canActivate: ['fake-guard'], - canActivateChild: ['fake-guard'] - }); - }); - }); }); diff --git a/src/app/app.component.ts b/src/app/app.component.ts index 029283bd3..ea7b6a5b2 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -48,7 +48,7 @@ import { SetRepositoryInfoAction } from '@alfresco/aca-shared/store'; import { filter, takeUntil } from 'rxjs/operators'; -import { AppExtensionService, AppService, ContentApiService, ExtensionRoute } from '@alfresco/aca-shared'; +import { RouterExtensionService, AppService, ContentApiService } from '@alfresco/aca-shared'; import { DiscoveryEntry, GroupEntry, Group } from '@alfresco/js-api'; import { Subject } from 'rxjs'; import { INITIAL_APP_STATE } from './store/initial-state'; @@ -71,7 +71,7 @@ export class AppComponent implements OnInit, OnDestroy { private alfrescoApiService: AlfrescoApiService, private authenticationService: AuthenticationService, private uploadService: UploadService, - private extensions: AppExtensionService, + private routerExtensionService: RouterExtensionService, private contentApi: ContentApiService, private appService: AppService, private sharedLinksApiService: SharedLinksApiService, @@ -112,8 +112,7 @@ export class AppComponent implements OnInit, OnDestroy { this.store.dispatch(new SetCurrentUrlAction(router.url)); }); - const extensionRoutes = this.extensions.getApplicationRoutes(); - this.mapExtensionRoutes(extensionRoutes); + this.routerExtensionService.mapExtensionRoutes(); this.uploadService.fileUploadError.subscribe((error) => this.onFileUploadedError(error)); @@ -140,30 +139,6 @@ export class AppComponent implements OnInit, OnDestroy { }); } - private extensionRouteHasChild(route: ExtensionRoute): boolean { - return route.parentRoute !== undefined; - } - - private convertExtensionRouteToRoute(extensionRoute: ExtensionRoute) { - delete extensionRoute.parentRoute; - delete extensionRoute.component; - } - - mapExtensionRoutes(extensionRoutes: ExtensionRoute[]) { - const routesWithoutParent = []; - extensionRoutes.forEach((extensionRoute: ExtensionRoute) => { - if (this.extensionRouteHasChild(extensionRoute)) { - const routeIndex = this.router.config.findIndex((route) => route.path === extensionRoute.parentRoute); - this.convertExtensionRouteToRoute(extensionRoute); - this.router.config[routeIndex].children.unshift(extensionRoute); - } else { - routesWithoutParent.push(extensionRoute); - } - }); - - this.router.config.unshift(...routesWithoutParent); - } - private async loadUserProfile() { const groupsEntries: GroupEntry[] = await this.groupService.listAllGroupMembershipsForPerson('-me-', { maxItems: 250 }); diff --git a/src/app/components/preview/preview.component.html b/src/app/components/preview/preview.component.html index f7c182c95..1fa045f20 100644 --- a/src/app/components/preview/preview.component.html +++ b/src/app/components/preview/preview.component.html @@ -27,10 +27,8 @@ - - + + diff --git a/src/app/components/preview/preview.component.ts b/src/app/components/preview/preview.component.ts index 44e3b8f0f..2c025ffc2 100644 --- a/src/app/components/preview/preview.component.ts +++ b/src/app/components/preview/preview.component.ts @@ -24,6 +24,7 @@ */ import { Component, OnInit, OnDestroy, ViewEncapsulation, HostListener } from '@angular/core'; +import { Location } from '@angular/common'; import { ActivatedRoute, Router, UrlTree, UrlSegmentGroup, UrlSegment, PRIMARY_OUTLET } from '@angular/router'; import { debounceTime, map, takeUntil } from 'rxjs/operators'; import { UserPreferencesService, ObjectUtils, UploadService, AlfrescoApiService } from '@alfresco/adf-core'; @@ -57,6 +58,8 @@ export class PreviewComponent extends PageComponent implements OnInit, OnDestroy openWith: Array = []; contentExtensions: Array = []; showRightSide = false; + navigateBackAsClose = false; + simplestMode = false; recentFileFilters = [ 'TYPE:"content"', @@ -92,6 +95,7 @@ export class PreviewComponent extends PageComponent implements OnInit, OnDestroy private apiService: AlfrescoApiService, private uploadService: UploadService, private actions$: Actions, + private location: Location, store: Store, extensions: AppExtensionService, content: ContentManagementService @@ -111,6 +115,8 @@ export class PreviewComponent extends PageComponent implements OnInit, OnDestroy this.previewLocation = this.router.url.substr(0, this.router.url.indexOf('/', 1)).replace(/\//g, ''); const routeData = this.route.snapshot.data; + this.navigateBackAsClose = !!routeData.navigateBackAsClose; + this.simplestMode = !!routeData.simplestMode; if (routeData.navigateMultiple) { this.navigateMultiple = true; @@ -202,16 +208,18 @@ export class PreviewComponent extends PageComponent implements OnInit, OnDestroy } navigateToFileLocation(shouldNavigate: boolean) { - const shouldSkipNavigation = this.routesSkipNavigation.includes(this.previewLocation); - if (shouldNavigate) { - const route = this.getNavigationCommands(this.previewLocation); + if (this.navigateBackAsClose) { + this.location.back(); + } else { + const shouldSkipNavigation = this.routesSkipNavigation.includes(this.previewLocation); + const route = this.getNavigationCommands(this.previewLocation); - if (!shouldSkipNavigation && this.folderId) { - route.push(this.folderId); + if (!shouldSkipNavigation && this.folderId) { + route.push(this.folderId); + } + this.router.navigate(route); } - - this.router.navigate(route); } } diff --git a/src/app/extensions/core.extensions.module.ts b/src/app/extensions/core.extensions.module.ts index 462f77b63..bd6575d75 100644 --- a/src/app/extensions/core.extensions.module.ts +++ b/src/app/extensions/core.extensions.module.ts @@ -54,6 +54,7 @@ import { LanguagePickerComponent } from '../components/common/language-picker/la import { LogoutComponent } from '../components/common/logout/logout.component'; import { CurrentUserComponent } from '../components/current-user/current-user.component'; import { AppExtensionService, ExtensionsDataLoaderGuard } from '@alfresco/aca-shared'; +import { PreviewComponent } from '../components/preview/preview.component'; export function setupExtensions(service: AppExtensionService): Function { return () => service.load(); @@ -90,6 +91,7 @@ export class CoreExtensionsModule { 'app.components.tabs.library.metadata': LibraryMetadataTabComponent, 'app.components.tabs.comments': CommentsTabComponent, 'app.components.tabs.versions': VersionsTabComponent, + 'app.components.preview': PreviewComponent, 'app.toolbar.toggleInfoDrawer': ToggleInfoDrawerComponent, 'app.toolbar.toggleFavorite': ToggleFavoriteComponent, 'app.toolbar.toggleFavoriteLibrary': ToggleFavoriteLibraryComponent, diff --git a/src/app/store/effects/viewer.effects.ts b/src/app/store/effects/viewer.effects.ts index 917d48a28..9cc17aa13 100644 --- a/src/app/store/effects/viewer.effects.ts +++ b/src/app/store/effects/viewer.effects.ts @@ -25,7 +25,7 @@ import { Effect, Actions, ofType } from '@ngrx/effects'; import { Injectable } from '@angular/core'; -import { map, take } from 'rxjs/operators'; +import { map, take, tap } from 'rxjs/operators'; import { AppStore, ViewerActionTypes, @@ -34,7 +34,8 @@ import { getCurrentFolder, getAppSelection, FullscreenViewerAction, - ViewNodeVersionAction + ViewNodeVersionAction, + PluginPreviewAction } from '@alfresco/aca-shared/store'; import { Router, UrlTree, UrlSegmentGroup, PRIMARY_OUTLET, UrlSegment } from '@angular/router'; import { Store, createSelector } from '@ngrx/store'; @@ -145,6 +146,20 @@ export class ViewerEffects { }) ); + pluginPreview$ = this.actions$.pipe( + ofType(ViewerActionTypes.PluginPreview), + tap((action) => { + this.router.navigate([ + action.pluginRoute, + { + outlets: { + viewer: ['preview', action.nodeId] + } + } + ]); + }) + ); + private displayPreview(nodeId: string, parentId: string) { if (!nodeId) { return;